《InsideUE4》GamePlay 架構(二)Level 和 World

大釗發表於2017-01-11

我發現了新大陸!

引言

上文談到Actor和Component的關係,UE利用Actor的概念組成一片遊戲物件森林,並利用Component組裝擴充套件Actor的能力,讓世界裡擁有了形形色色的Actor們,擁有了自由表達3D世界的能力。
那麼,這些Actor們,到底是怎麼組織起來的呢?

既然提到了世界,我們的直覺反應是採用一個"World"物件來包容所有的Actor們。但是當遊戲的虛擬世界非常巨大時,這種方式就捉襟見肘了。首先,目前雖然PC的效能日益強大,但是依然記憶體也限制了不能一下子載入進所有的遊戲資源;其次,因為玩家的活動和可見範圍有限,為了最優效能,把即使是很遠的跟玩家無關的物件也考慮進來也明顯是不明智的。所以我們需要一種更細粒度的概念來劃分世界。
不同的遊戲引擎們,看待這個過程的角度和理念也不一樣。Cocos2dx會認為遊戲世界是由Scene組成的,Scene再由一個個Layer層疊表現,然後再有一個Director來導演整個遊戲。Unity覺得世界也是由Scene組成的,然後一個Application來扮演上帝來LoadLevel,後來換成了SceneManager。其他的,有的會稱為關卡(Level)或地圖(map)等等。而UE中把這種拆分叫做關卡(Level),由一個或多個Level組成一個World。
不要覺得這種劃分好像很隨意,只是個名字不同而已。實際上一個遊戲引擎的“世界觀”關係到了一整串後續的內容組織,玩家的管理,世界的生成,變換和毀滅。遊戲引擎內部的資源的載入釋放也往往都是和這種劃分(Level)繫結在一起的。

Level

在UE的世界中,我們之前已經有了空氣(C++),土壤(UObject),物件(Actor)。而現在UE又施展神力建立了一片片大陸(Level),在這片大陸上(.map檔案),Actor們秩序井然,各種地形拔地而起,植被繁茂,天空霧雲繚繞,聖光普照,這也是玩家們降生開始精彩冒險的地方。

《InsideUE4》GamePlay 架構(二)Level 和 World
LevelAndActors.png-56.5kB

可以從ULevel的字首U看出來Level(大陸)也確實是繼承於UObject(土壤)的。那既然同屬於Object下面的各Actor們都擁有了一定的智慧能力(支援藍圖指令碼),Level自然也得體現出大地的意志,所以預設帶了一個土地公(ALevelScriptActor),允許我們在關卡里編寫指令碼,可以對本關卡里的所有Actor通過名字呼之則來,關卡藍圖實際上就代表著該片大陸上的執行規則。
在Level已經有了管理者之後,一開始大家都挺滿意,但漸漸的就發現,好像各個Level需要的功能好像都差不多,都是修改一下光照,物理等一些屬性。所以為了方便起見,UE便給每一個Level也都預設配了一個書記官(Info),他一一記錄著本Level的各種規則屬性,在UE需要的時候便負責相告。更重要的是,在Level需要有其他管理人員一起協助的時候,他也記錄著“遊戲模式”的名字來讓UE可以指派。
前面我們說過,有一些Actor是不“顯示”的(沒有SceneComponent),是不能“擺放”到Level裡的,但是它依然可以在關卡里出力。其中一個家族系列就是AInfo和其之類。今天我們只簡單介紹一下跟Level直接相關的一位書記官:AWorldSettings。
《InsideUE4》GamePlay 架構(二)Level 和 World
Level_Settings_Options_Menu.jpg-79.2kB

其實雖然名字叫做WorldSettings,但其實只是跟Level相關,我猜可能是在上古時代,當時整個世界只有一塊大陸,人們就以為當前的大陸就是整個世界,所以給這塊大陸的設定就起名為WorldSettings,後來等技術進步了,發現必須有其他大陸了,這個名字已經用得太多反而不好改了,就只好遺留下來了。當然也有可能是因為當Level被新增進World後,這個Level的Settings如果是主PersistentLevel,那它就會被當作整個World的WorldSettings。
注意,Actors裡也儲存著AWorldSettings和ALevelScriptActor的指標,所以Actors實際上確實是儲存了所有Actor。

思考:為何AWorldSettings要放進在Actors[0]的位置?而ALevelScriptActor卻不用?

void ULevel::SortActorList()
{
    //[...]
    TArray<AActor*> NewActors;
    TArray<AActor*> NewNetActors;
    NewActors.Reserve(Actors.Num());
    NewNetActors.Reserve(Actors.Num());
    // The WorldSettings tries to stay at index 0
    NewActors.Add(WorldSettings);
    // Add non-net actors to the NewActors immediately, cache off the net actors to Append after
    for (AActor* Actor : Actors)
    {
        if (Actor != nullptr && Actor != WorldSettings && !Actor->IsPendingKill())
        {
            if (IsNetActor(Actor))
            {
                NewNetActors.Add(Actor);
            }
            else
            {
                NewActors.Add(Actor);
            }
        }
    }
    iFirstNetRelevantActor = NewActors.Num();
    NewActors.Append(MoveTemp(NewNetActors));
    Actors = MoveTemp(NewActors);   // Replace with sorted list.
    // Add all network actors to the owning world
    //[...]
}複製程式碼

實際上通過這一段程式碼可知,Actors們的排序依據是把那些“非網路”的Actor放在前面,而把“網路可複製”的Actor們放在後面,然後加一個起始索引標記iFirstNetRelevantActor,相當於為網路Actor劃分了一個快取,從而加速了網路複製時的檢測速度。AWorldSettings因為都是靜態的資料提供者,在遊戲執行過程中也不會改變,不需要網路複製,所以也就可以一直放在前列,而如果再加個規則,一直放在第一個的話,也能同時把AWorldSettings和其他的前列Actor們再度區分開,在需要的時候也能加速判斷。ALevelScriptActor因為是代表關卡藍圖,是允許攜帶“複製”變數函式的,所以也有可能被排序到後列。

思考:既然ALevelScriptActor也繼承於AActor,為何關卡藍圖不設計能新增Component?
觀察到,平常我們在建立Actor的時候,我們藍圖介面是可以建立Component的。
那為什麼在關卡藍圖裡,卻不能這麼做(沒有提供該介面功能)?
我雖然在圖裡標出了Level中擁有ModelComponents,但那其實只是針對BSP應用的一個子集。通過原始碼發現,其實UE自己也是在C++裡往ALevelScriptActor新增UInputComponent來實現關卡藍圖可以響應事件。

void ALevelScriptActor::PreInitializeComponents()
{
    if (UInputDelegateBinding::SupportsInputDelegate(GetClass()))
    {
        // create an InputComponent object so that the level script actor can bind key events
        InputComponent = NewObject<UInputComponent>(this);
        InputComponent->RegisterComponent();

        UInputDelegateBinding::BindInputDelegates(GetClass(), InputComponent);
    }
    Super::PreInitializeComponents();
}複製程式碼

其實既然ALevelScriptActor是個Actor,那意味著我們當然可以為它新增元件,實際上也確實可以這麼做。比如你可以在關卡藍圖裡這麼幹:

《InsideUE4》GamePlay 架構(二)Level 和 World
AddLevelAudioComponent.png-37.6kB

而如果你實際意識到關卡藍圖本身就是一個看不見的Actor,你就可以在上面用Actor的各種操作:
《InsideUE4》GamePlay 架構(二)Level 和 World
LevelGetActorLocation.png-19.6kB

在關卡藍圖裡的self其實也是個Actor!雖然一般這麼幹也沒什麼毛用。
那麼好好想想,為啥UE要給你這麼一個關卡藍圖介面呢?
《InsideUE4》GamePlay 架構(二)Level 和 World
LevelBluePrint.png-16.7kB

在此,我也只能進行一番猜測,ALevelScriptActor作為一個特化的Actor,卻把Components列表介面給隱藏了,說明UE其實是不希望我們去複雜化關卡構成的。
假設說UE開放了關卡Component,那麼我們在建立元件時就必然要考慮一個問題:哪些是ActorComponent,哪些是LevelComponent,再怎麼ALevelScriptActor本質是個Actor,但Level的概念還是要突出,ALevelScriptActor的Actor本質是要隱藏的。所以使用者就會多一些心智負擔,可能混淆。而如果像這樣不開放,大家的思路就都轉向先建立個Actor,然後再往之上新增component,思路會比較統一清晰。
再之,從遊戲邏輯的組織上來說,Level其實更應該表現為一個Actor的容器。UE其實也是不鼓勵在Level裡編寫太複雜的邏輯的。所以才接著會有了之後的GameMode,Controller那些真正的邏輯控制類(後續會再細討論)。
所以遊戲引擎也並不是說最大化的暴露一切功能給你就是最好的,有時候選擇太多了反而容易出錯。在這一點上,我覺得UE很好的保持了剋制,為我們提供了一個優秀的清晰的不易出錯的框架,同時也對高階使用者保留了靈活性。

World

終於,到了把大陸們(Level)拼裝起來的時候了。可以用SubLevel的方式:

《InsideUE4》GamePlay 架構(二)Level 和 World
LevelsWindows.png-62kB

也支援WorldComposition的方式自動把專案裡的所有Level都組合起來,並設定擺放位置:
《InsideUE4》GamePlay 架構(二)Level 和 World
world_layout.jpg-116.8kB

具體擺放的操作和技巧並不是本文的重點。簡單本質來說,就是一個World裡有多個Level,這些Level在什麼位置,是在一開始就載入進來,還是Streaming執行時載入。
UE裡每個World支援一個PersistentLevel和多個其他Level:
《InsideUE4》GamePlay 架構(二)Level 和 World
WorldAndLevel.png-39.5kB

Persistent的意思是一開始就載入進World,Streaming是後續動態載入的意思。Levels裡儲存有所有的當前已經載入的Level,StreamingLevels儲存整個World的Levels配置列表。PersistentLevel和CurrentLevel只是個快速引用。在編輯器裡編輯的時候,CurrentLevel可以指向其他Level,但執行時CurrentLevel只能是指向PersistentLevel。

思考:為何要有主PersistentLevel?
首先,World至少得有一個Level,就像你也得先出生在一塊大陸上才可以繼續談起去探索別的新大陸。所以這塊玩家出生的大陸就是主Level了。當然了,因為我們也可以同時配置別的Level一開始就載入進來,其實跟PersistentLevel是差不多等價的,但再考慮到另一問題:Levels拼接進World一起之後,各自有各自的worldsetting,那整個World的配置應該以誰的為主?

AWorldSettings* UWorld::GetWorldSettings( bool bCheckStreamingPesistent, bool bChecked ) const
{
    checkSlow(IsInGameThread());
    AWorldSettings* WorldSettings = nullptr;
    if (PersistentLevel)
    {
        WorldSettings = PersistentLevel->GetWorldSettings(bChecked);

        if( bCheckStreamingPesistent )
        {
            if( StreamingLevels.Num() > 0 &&
                StreamingLevels[0] &&
                StreamingLevels[0]->IsA<ULevelStreamingPersistent>()) 
            {
                ULevel* Level = StreamingLevels[0]->GetLoadedLevel();
                if (Level != nullptr)
                {
                    WorldSettings = Level->GetWorldSettings();
                }
            }
        }
    }
    return WorldSettings;
}複製程式碼

可以看出,World的Settings也是以PersistentLevel為主的,但這也並不以為著其他Level的Settings就完全沒有作用了,本篇也無法一一列出所有配置選項來說明,簡單來說,就是需要在整個世界範圍內起作用的配置選項(比如VR的WorldToMeters,KillZ,WorldGravity其他大部分都是)就是需要從主PersistentLevel的配置中提取。而一些配置選項可以在單獨Level中起作用的,比如在編輯Level時的光照質量配置就是一個個Level單獨的,目前這種配置很少,但可能以後也會增加。在這裡只是闡明一個為主其他為輔的Level配置系統。

思考:Levels們的Actors和World有直接關係嗎?
當別的Level被新增進當前World之後,我們能直接在WorldOutliner裡看到其他Level的Actor們。

《InsideUE4》GamePlay 架構(二)Level 和 World
LevelsWorldOutliner.png-31.3kB

但這並不代表著World直接引用了Level裡的Actor們。TActorIteratorBase(World的Actor迭代器)內部的實現也只是在遍歷Levels來獲得所有Actor。當然World為了更快速的操作Controllers和Pawn也都儲存了引用。但Levels卻共享著World的一個PhysicsScene,這也意味著Levels裡的Actors的物理實體其實都是在World裡的,這也好理解,畢竟物理的碰撞之類的當然要是全域性的了。再說到導航,World在拼接Level的時候,也是會同時把兩個Level的導航網格給“拼接”起來的。當然目前還不是深入細節的時候,現在只要從大局上明白World-Level-Actor的關係。

思考:為什麼要在Level裡儲存Actors,而不是把所有Map的Actors配置都生成在World一個總Actors裡?
這肯定也是一種實現方式,好處是把整個World看成一個整體,所有的actors都從屬於world,這樣就不存在Level邊界,可以更整體的處理Actors的作用範圍和判定問題,實現上也少了拼接導航等步驟。當然壞處也是模糊了Level邊界,這樣在載入進一個Level之後,之後再動態釋放,就需要再重新再從整體中抽離出部分來釋放,這個篩選過程也會產生比較大的損耗。試著去理解UE的權衡,應該是儘量的把損耗平攤(這裡是把Level載入釋放的損耗盡量減小),才不會產生比較大的幀率波動,讓玩家感覺到卡幀。

總結

Level作為Actor的容器,同時也劃分了World,一方面支援了Level的動態載入,另一方面也允許了團隊的實時協作,大家可以同時並行編輯不同的Level。一般而言,一個玩家從遊戲開始到結束,UE會創造一個GameWorld給玩家並一直存在。玩家切換場景或關卡,也只是在這個World中載入釋放不同的Level。既然Level擁有了管理者(LevelScriptActor),玩家可以編寫特定關卡的邏輯,那麼我們能否對World這種層次編寫邏輯呢?答案是肯定的,不過本文篇幅有限,敬請期待下篇。

下篇:GamePlayer架構(三)WorldContext,GameInstance,Engine

修訂

###LevelCollection 4.14
每種Type只有一個,所以其實只是分類成3個

The levels of a world are now categorized into different collections: dynamic, static, or duplicated. Levels default to dynamic, and this preserves existing behavior.
Streaming levels can be marked as static in the Levels Details panel. This distinction has no effect on how the levels are rendered, but it will cause the levels to be placed into the corresponding collection.
Games may opt-in to duplicating the dynamic levels when the world is loaded, and they will be maintained separately from the original levels. The game may use these duplicated levels at runtime, if desired.

/** Indicates the type of a level collection, used in FLevelCollection. */
enum class ELevelCollectionType
{
    /**
     * The dynamic levels that are used for normal gameplay and the source for any duplicated collections.
     * Will contain a world's persistent level and any streaming levels that contain dynamic or replicated gameplay actors.
     */
    DynamicSourceLevels,
    /** Gameplay relevant levels that have been duplicated from DynamicSourceLevels if requested by the game. */
    DynamicDuplicatedLevels,
    /**
     * These levels are shared between the source levels and the duplicated levels, and should contain
     * only static geometry and other visuals that are not replicated or affected by gameplay.
     * These will not be duplicated in order to save memory.
     */
    StaticLevels
};複製程式碼

UE4.13.2


知乎專欄:InsideUE4
UE4深入學習QQ群:456247757(非新手入門群,請先學習完官方文件和視訊教程)
微信公眾號:aboutue,關於UE的一切新聞資訊、技巧問答、文章釋出,歡迎關注。
個人原創,未經授權,謝絕轉載!

相關文章