• 《InsideUE4》3GamePlay架构(二)Level和World


    UE4深入学习QQ群: 456247757


    引言

    上文谈到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们秩序井然,各种地形拔地而起,植被繁茂,天空雾云缭绕,圣光普照,这也是玩家们降生开始精彩冒险的地方。
    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。
    Level_Settings_Options_Menu.jpg-79.2kB
    其实虽然名字叫做WorldSettings,但其实只是跟Level相关,我猜可能是在上古时代,当时整个世界只有一块大陆,人们就以为当前的大陆就是整个世界,所以给这块大陆的设置就起名为WorldSettings,后来等技术进步了,发现必须有其他大陆了,这个名字已经用得太多反而不好改了,就只好遗留下来了。当然也有可能是因为当Level被添加进World后,这个Level的Settings如果是主PersisitentLevel,那它就会被当作整个World的WorldSettings。
    注意,Actors里也保存着AWorldSettings和ALevelScriptActor的指针,所以Actors实际上确实是保存了所有Actor。

    思考:为何AWorldSettings要放进在Actors[0]的位置?而ALevelScriptActor却不用?

     
     1 void ULevel::SortActorList()
     2 {
     3     if (Actors.Num() == 0)
     4     {
     5         // No need to sort an empty list
     6         return;
     7     }
     8 
     9     TArray<AActor*> NewActors;
    10     TArray<AActor*> NewNetActors;
    11     NewActors.Reserve(Actors.Num());
    12     NewNetActors.Reserve(Actors.Num());
    13 
    14     check(WorldSettings);
    15 
    16     // The WorldSettings tries to stay at index 0
    17     NewActors.Add(WorldSettings);
    18 
    19     // Add non-net actors to the NewActors immediately, cache off the net actors to Append after
    20     for (AActor* Actor : Actors)
    21     {
    22         if (Actor != nullptr && Actor != WorldSettings && !Actor->IsPendingKill())
    23         {
    24             if (IsNetActor(Actor))
    25             {
    26                 NewNetActors.Add(Actor);
    27             }
    28             else
    29             {
    30                 NewActors.Add(Actor);
    31             }
    32         }
    33 
    34     }
    35     iFirstNetRelevantActor = NewActors.Num();
    36 
    37     NewActors.Append(MoveTemp(NewNetActors));
    38 
    39     // Replace with sorted list.
    40     Actors = MoveTemp(NewActors);
    41 
    42     // Add all network actors to the owning world
    43     if ( OwningWorld != nullptr )
    44     {
    45         // Don't use sorted optimization outside of gameplay so we can safely shuffle around actors e.g. in the Editor
    46         // without there being a chance to break code using dynamic/ net relevant actor iterators.
    47         if (!OwningWorld->IsGameWorld())
    48         {
    49             iFirstNetRelevantActor = 0;
    50         }
    51 
    52         for ( int32 i = iFirstNetRelevantActor; i < Actors.Num(); i++ )
    53         {
    54             if ( Actors[ i ] != nullptr )
    55             {
    56                 OwningWorld->AddNetworkActor( Actors[ i ] );
    57             }
    58         }
    59     }
    60 }

    实际上通过这一段代码可知,Actors们的排序依据是把那些“非网络”的Actor放在前面,而把“网络可复制”的Actor们放在后面,然后加一个起始索引标记iFirstNetRelevantActor,相当于为网络Actor划分了一个缓存,从而加速了网络复制时的检测速度。AWorldSettings因为都是静态的数据提供者,在游戏运行过程中也不会改变,不需要网络复制,所以也就可以一直放在前列,而如果再加个规则,一直放在第一个的话,也能同时把AWorldSettings和其他的前列Actor们再度区分开,在需要的时候也能加速判断。ALevelScriptActor因为是代表关卡蓝图,是允许携带“复制”变量函数的,所以也有可能被排序到后列。

    思考:既然ALevelScriptActor也继承于AActor,为何关卡蓝图不设计能添加Component?
    观察到,平常我们在创建Actor的时候,我们蓝图界面是可以创建Component的。
    那为什么在关卡蓝图里,却不能这么做(没有提供该界面功能)?
    我虽然在图里标出了Level中拥有ModelComponents,但那其实只是针对BSP应用的一个子集。通过源码发现,其实UE自己也是在C++里往ALevelScriptActor添加UInputComponent来实现关卡蓝图可以响应事件。

     
     1 void ALevelScriptActor::PreInitializeComponents()
     2 {
     3     if (UInputDelegateBinding::SupportsInputDelegate(GetClass()))
     4     {
     5         // create an InputComponent object so that the level script actor can bind key events
     6         InputComponent = NewObject<UInputComponent>(this);
     7         InputComponent->RegisterComponent();
     8 
     9         UInputDelegateBinding::BindInputDelegates(GetClass(), InputComponent);
    10     }
    11     Super::PreInitializeComponents();
    12 }

    其实既然ALevelScriptActor是个Actor,那意味着我们当然可以为它添加组件,实际上也确实可以这么做。比如你可以在关卡蓝图里这么干:
    AddLevelAudioComponent.png-37.6kB
    而如果你实际意识到关卡蓝图本身就是一个看不见的Actor,你就可以在上面用Actor的各种操作:
    LevelGetActorLocation.png-19.6kB
    在关卡蓝图里的self其实也是个Actor!虽然一般这么干也没什么毛用。
    那么好好想想,为啥UE要给你这么一个关卡蓝图界面呢?
    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的方式:
    LevelsWindows.png-62kB
    也支持WorldComposition的方式自动把项目里的所有Level都组合起来,并设置摆放位置:
    world_layout.jpg-116.8kB
    具体摆放的操作和技巧并不是本文的重点。简单本质来说,就是一个World里有多个Level,这些Level在什么位置,是在一开始就加载进来,还是Streaming运行时加载。
    UE里每个World支持一个PersisitentLevel和多个其他Level:
    WorldAndLevel.png-39.5kB
    Persisitent的意思是一开始就加载进World,Streaming是后续动态加载的意思。Levels里保存有所有的当前已经加载的Level,StreamingLevels保存整个World的Levels配置列表。PersisitentLevel和CurrentLevel只是个快速引用。在编辑器里编辑的时候,CurrentLevel可以指向其他Level,但运行时CurrentLevel只能是指向PersisitentLevel。

    思考:为何要有主PersisitentLevel?
    首先,World至少得有一个Level,就像你也得先出生在一块大陆上才可以继续谈起去探索别的新大陆。所以这块玩家出生的大陆就是主Level了。当然了,因为我们也可以同时配置别的Level一开始就加载进来,其实跟PersisitentLevel是差不多等价的,但再考虑到另一问题: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也是以PersisitentLevel为主的,但这也并不以为着其他Level的Settings就完全没有作用了,本篇也无法一一列出所有配置选项来说明,简单来说,就是需要在整个世界范围内起作用的配置选项(比如VR的WorldToMeters,KillZ,WorldGravity其他大部分都是)就是需要从主PersisitentLevel的配置中提取。而一些配置选项可以在单独Level中起作用的,比如在编辑Level时的光照质量配置就是一个个Level单独的,目前这种配置很少,但可能以后也会增加。在这里只是阐明一个为主其他为辅的Level配置系统。

    思考:Levels们的Actors和World有直接关系吗?
    当别的Level被添加进当前World之后,我们能直接在WorldOutliner里看到其他Level的Actor们。
    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

     

    UE4深入学习QQ群: 456247757


    个人原创,未经授权,谢绝转载,否则将追究法律责任!

  • 相关阅读:
    基于HT for Web矢量实现3D叶轮旋转
    基于HT for Web矢量实现2D叶轮旋转
    HT for Web列表和3D拓扑组件的拖拽应用
    基于HT for Web矢量实现HTML5文件上传进度条
    Java 8 VM GC Tunning Guide Charter 5
    Java 8 VM GC Tunning Guide Charter 6
    Java 8 VM GC Tunning Guide Charter 7-8-b
    Java 8 VM GC Tunning Guild Charter 9-b
    java文档 第十一章 其他考量-b
    iOS点击cell查看大图,点击大图还原小图-b
  • 原文地址:https://www.cnblogs.com/fjz13/p/5956393.html
Copyright © 2020-2023  润新知