前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >UE5的World Partition

UE5的World Partition

作者头像
quabqi
发布2024-01-07 10:41:59
5520
发布2024-01-07 10:41:59
举报
文章被收录于专栏:Dissecting UnrealDissecting Unreal

世界分区,是UE5给大世界项目提供的一套新的解决方案。相比于UE4的WorldComposition有了非常多的改进。官网也有很具体的介绍:

虚幻引擎中的世界分区 | 虚幻引擎5.3文档 (unrealengine.com)

毕竟这个功能也出来了很久了,到处也都能搜到很多关于World Partition相关的基础教程,因此一些基础的使用方法我就不详细去说了。相比于过去WC的解决方案,从我的使用感受来说,我觉得最值得说的方面主要是场景空间划分,Streaming,OFPA存储,DataLayer,LevelInstance,HLOD,WorldPartitionBuilder以及ContentBundle这几个方面,因此下面主要围绕着这些点写一些代码阅读笔记和心得。

场景空间划分

空间划分,主要是指的是怎样去用一种更效率的方式来划分场景。

在没有做场景空间划分的时候,我们想要去做一些关于场景相关的需求,比如获取玩家最近100米的所有Actor,那么就只能去遍历场景内所有的Actor,依次和玩家比较距离,将100米的Actor都删选出来。如果场景内的Actor数量非常庞大,那这个遍历操作就会非常的耗时。假如我们去对场景画格子,每100米是一个格子,那么在做搜索距离玩家最近100米的所有Actor这个需求时,我们就可以先找到玩家在哪个格子,然后把玩家附近的格子里所有Actor取出来依次判断距离,这样就不需要遍历场景内所有的Actor了,能减少很多的计算量,提升了性能。WP所做的场景空间划分也就是一种这样的画格子的方法,在引擎里这个方法叫做Hierarchical HashGrid。

这个画格子的算法和传统的画格子的方法有什么区别呢?或者说相比于传统的画格子方法他改进了什么?既然叫做Hierarchical HashGrid,那么改进的点其实就和Hierarchical以及Hash这两个关键词有关系。传统画格子的方法主要有下面这两个问题:

  1. 一定会有大量的格子是空的,那么这些空格子的存储,必然会造成了内存浪费。比如为10*10的格子创建了一个二维数组,可能只有少量几个格子有Actor,那么这个二维数组大量的内存就浪费掉了。
  2. 有些Actor的Bounds会特别的大,横跨了好几个格子,那么这个Actor属于哪个格子呢?

因此Hierarchical HashGrid就针对这两点给了一个改进的画格子方案。大量的格子都是空的,那么就不要去存储空格子的空间。可以类比TArray和TSet的区别:在一维的情况下如果用TArray存储格子x坐标,0~100放到元素0,101~200坐标放到元素1,依次放下去,那么整个场景的Actor都可以放到指定的一个格子里。但如果整个一维场景的Actor坐标分布不是那么均匀,一定有很多格子是空的,空的数组元素的内存就浪费了。如果用TSet存储格子,内部其实是按照编号对应的Hash值来索引的,那么这些空的格子,就不会分配实际内存,这样就能节省很多空间。那么把这个方式扩展到平面和空间上,也就是对格子格子(x,y或x,y,z方向的编号)建立hash,存到一个TSet里,相比于二维数组来说就节省了很多的空间,这样就解决了上述的第一个问题。

对于有些Actor的Bounds特别大的情况,我们可以在小格子的基础上,建立更大范围的格子。比如已经有了100米的格子发现放不下这个Actor,我们可以再建立200米的格子尝试去放,如果放不下再尝试建立400米的格子去放,直到最后一个格子,肯定和整个地图一样大,那么必然能够放下这个Actor。这种方式就叫做Hierarchical。因为第一步,我们已经使用了Hash去存储格子,我们可以将这个hash值扩展一下,用格子(x,y,z方向的编号,层级l)这样的4元组作为key建立hash,去存储整个空间结构,那么最终就解决了上述的两个问题。

World Partition内部就是通过这样的一种方式去管理空间的。除了WorldPartition,UE5还有很多其他模块也是这样管理的,包括SmartObject,ZoneGraph等。具体可以看HierarchicalGrid2D.h,这个是通用的容器,项目也可以直接复用。而WP的实现在WorldPartitionRuntimeSpatialHash.h里的FSpatialHashStreamingGridLevel。如下图所示:

LayerCellsMapping就是xy的索引,映射到LayerCells数组的下标。本质上和上面说的Hierarchical HashGrid是一回事。

当然Hierarchical HashGrid这种方式也可能会引起一些BUG:有些覆盖到x,y坐标轴上的Actor,永远都处于加载状态,无论什么情况都不会卸载。根据上面的算法我们也很容易就能理解,就是因为WP在画格子的时候是轴对齐的,这些覆盖轴的Actor没法放到一个合适的格子里,最终被放到了和整个场景一样大的那个最大格子里,这个最大的格子当然也就是覆盖玩家的位置,所以这些Actor永远不卸载,下面有写5.3的解决方案,低版本可以根据这个原理手动改。

游戏里可以点击Preview Grids去看当前加载的格子,下面Cell Size可以调整Level 0的格子大小,Level 1的格子默认就是0格子的2倍,从下图就可以看到当前加载的格子。Loading Range就是加载的距离,也就是图里圆圈的半径,扫到的格子就是处于加载的状态。

Use Aligned Grid Levels这个一定要选Disabled,从上面两张图也能看到,两层level的格子边界是没有对齐的,而如果是Enabled,就能看到多层格子坐标严格按照边界对齐。因为每一级的格子如果是对齐的,就会导致覆盖坐标轴的Actor永远卸载不掉,因此Disabled能解决这个问题。

GenerateStreaming

我看的是5.3的代码,老版本的代码流程差不多但功能确实有不少缺失。WP的入口是UWorldPartition这个类。这个对象本身是挂在AWorldSettings上面的,配置以及运行时基本都在这个类里,引擎为这个类定制了编辑器,所以能在WorldSettings页签里显示。

在PIE启动游戏加载场景时候,会触发OnBeginPlay函数,最终会调用到UWorldPartitionRuntimeSpatialHash的GenerateStreaming函数。这个函数就是生成地块的地方。PIE下会生成到/Memory开头的Package里,打包的时候会调用到UWorldPartition::GatherPackagesToCook,这里也会生成对应每个Cell的Package,然后逐个Package保存对应的数据。

因此WorldPartition底层其实还是通过UE4原来的那套levelStreaming机制去动态加载和卸载。cook的结果其实也和老的子level地块是一样的,只不过这个划地块变成实际资源的过程被WorldPartition自动做了,在编辑器下很特殊,是直接在内存里建立了对应地块的。下面可以看到,UWorldPartitionRuntimeLevelStreamingCell内部这个类继承的是LevelStreaming对象,内部和ue4的大地图加载是同样的机制。

在这个阶段,最重要的事情就是对所有的Actor做空间划分,每个Actor都会根据自己所在的位置以及包围盒大小放到实际的Cell中,如果多个Actor有引用关系,那么有引用关系的Actor会被打到同一个Cluster中。这里要注意的是,引用关系是指,引用别的Actor以及被Actor引用这两种情况。

一般多个Actor的引用是不能跨DataLayer的,否则在加载的时候会出现问题。如果引用跨了多个DataLayer,那么会在打开地图时候报MapCheck的Error,这些Actor实际很有可能就不会随着DataLayer加载了,而是只要对应Cell加载出来就会直接把这些Actor直接加载出来。

这个具体原因是,WP的逻辑是先划分Cell,然后再看Cell内所有Actor属于哪个DataLayer,然后再决定这个Cluster属于哪个Cell。具体的划分结果,可以通过Saved/Log/WorldPartition/下找到详细的log。因为GenerateStreaming的执行时机是在PIE启动时候或者cook的时候,所以一定要先运行一次游戏才可以看。这个文件也是检查WP的BUG各种加载问题的利器。对于5.3版本的引擎,也可以使用wp.Editor.DumpStreamingGenerationLog输出一次,这个指令可以不运行游戏即可输出结果。

具体如下,是Lyra的一个地图的信息:

前面一段,记录了当前地图内所有的Actor的ActorDesc信息。

然后是Clustor的分组信息,这个信息很重要,可以看到哪些Actor被分到了一组。

然后是地块信息,上面这个是Persistent Level,也就是随着地图启动就进来,永远不卸载的Actor。

这里是地图分块的统计信息,具体分了多少个Level,每个Level里多少个Cell,每个Cell里多少个Actor等。

然后就是每个Grid里面,每个Cell的分组信息。可以看到上图是MainGrid,L0_X0_Y-1这个Cell的Actor信息。前面也具体讲了L0_X0_Y-1,就是HashGrid的key,L0就是最小的一级地块,L1的大小是L0的2倍。然后X0_Y-1就是xy坐标的编号。

如果有Runtime类型的DataLayer,那么在生成的时候就会看到这样的Cell,除了Cell的编号外,还有一段DL开头的。这个就表示这个Cell内是有DataLayer的。因此同一个Cell,也会按照DataLayer来进行拆分。所以这也解释清楚了上面的问题,为什么一个Actor引用了另一个DataLayer里的Actor会导致这个Cluster不知道放哪里了。因为Cluster肯定要放在一个Cell下面才行。

UpdateStreamingState

在运行时,每帧都会调用到UWorldPartitionSubsystem::UpdateStreamingState()函数,这个函数内部就是在根据当前的StreamingSources来更新哪些地块需要加载和卸载。也会调用到每个WordPartition的UWorldPartitionStreamingPolicy::UpdateStreamingState()函数。算法很简单,其实就是根据source算距离,看哪些Cell需要ToLoad和ToActivate,然后再跟上一帧已经Load和Activate的Cell做diff,diff得到的结果就是这帧需要Load和Activate的Cell列表;然后用上帧已经Load的Cell列表和这帧ToLoad/ToActivate的列表做diff,diff得到的结果就是这帧需要UnLoad的Cell列表。

Load和Activate就和老的levelStreaming做的事情是一样的。Load就是加载资源,Activate就是把地图变为可见并AddToWorld。

OFPA(One File Per Actor)

一个文件一个Actor的底层实现,其实是调用Actor的SetPackageExternal(true)来设置的,内部会对这个Actor创建一个独立的Package,并在原来的Object的Flag上标记RF_HasExternalPackage。这个独立的Package会保存在"Content/__ExternalActors__/"这个文件夹下,如果Actor有保存在文件夹内,那么文件夹本身的这个对象,也会存在"Content/__ExternalObjects__"这个文件夹下。这两个文件夹在引擎的ContentBrowser里看不到。

有一个专门的类FExternalPackageHelper会处理具体的保存和读取工作。

最终会根据是不是Actor选择对应文件夹。

每个Actor,在WP下会额外创建一个FWorldPartitionActorDesc。这里保存了当前Actor的和WP相关的基本信息,包括自己的Guid,名字,路径,基类,Native基类,以及其他Actor的References,自己所在的DataLayer等。

WP在建立Cluster的时候,就是依赖这个References以及对应的DataLayer的信息来处理的。具体来说,就是A引用了B,那么把这个单向关系变成双向的画一个连通图出来,那么Graph所有有连通的就是一个Cluster,最后就是一个Cluster列表。具体可以参考下面这个函数的算法:

DataLayer

虚幻引擎中的世界分区 - 数据层 | 虚幻引擎5.3文档 (unrealengine.com)

datalayer,其实就是取代ue4的layer功能的,可以把一批Actor放在一个DataLayer下,这样程序就可以在运行时动态加载。但和ue4的layer不同的是,一个Actor可以属于多个DataLayer。

这里要注意区分Editor还是Runtime类型的。如果是Runtime就会要求额外是定运行初始状态。

用DataLayerManager的下面这些函数,就可以在运行时控制加载卸载了。也有个委托可以去监听DataLayer的状态。这些函数都可以通过蓝图来调用。

LevelInstance

这个是ue5的一个新的机制,有点类似unity的prefab。具体可以看文档:

虚幻引擎中的关卡实例化 | 虚幻引擎5.3文档 (unrealengine.com)

本身使用起来还是挺简单的。我觉得比较适合使用LevelInstance的场合就是拿来做地图上的兴趣点,假如有多个同样的兴趣点,且内容都差不多,那么就可以做成一个LevelInstance,然后把这个LevelInstance摆到地图的各个地方来复用。也可以用来实现动态关卡,不同的时候加载不同LevelInstance。

另一个比较重要的功能,我觉得就是可以用LevelInstance来搞子关卡蓝图。在ue4的时候,每个子关卡都可以有个自己的蓝图,但是ue5的WorldPartition都是一个大关卡了,那么正常情况只有一个关卡蓝图,这对于策划来说是很坑的。但是如果对于不同的兴趣点,把相关的Actor包到一个LevelInstance里,就可以在这个LevelInstance里面去写这个兴趣点专有的蓝图了。

另外就是理论上可以对WorldPartition做静态烘培。LevelInstance本身就是一个普通关卡因此可以去单独烘BuiltData,WorldPartition里如果包了多个带烘培BuiltData的LevelInstance,那么是不是就可以让WorldPartition就支持了多套静态烘培?不过我也没尝试这么做过,不一定说的对,希望有做过这方面的同学能一起探讨。

还有个比较好用的类就是APackedLevelActor,这个类会让内部所有的Actor自动按不同Mesh合Instance,在摆到WorldPartition的大地图里面,就是合批后的一个Actor,而直接打开这个levelInstance,就是合批前的那些Actor,同时也因为有对应的关卡蓝图,这个关卡蓝图可以去控制合批的细节,相当于是官方提供的一个手动合批工具,非常好用。

最后就再来说说LevelInstance的存储。LevelInstance本身就是一个子level,所以是单独存成了一个umap资源,可以认为这个子level就是CDO,对于unity来说就是prefab本身。而把LevelInstance拖到关卡里面,内部的Actor就会额外产生ExternalPackage,也就是说在__ExternalActor__路径下会有LevelInstance的每个Actor资源。umap和WorldPartition内部的这些单Actor的文件,可以理解为CDO和实例对象的关系,因为同一个LevelInstance可以在关卡内摆多个,也就相当于多个实例对象。

HLOD

hlod可以看官方文档:

虚幻引擎中的世界分区 - 分层细节级别 | 虚幻引擎5.3文档 (unrealengine.com)

是可以分多级HLOD的,每一级可以选择单独的烘培方式,合instance,合mesh,或者是减面合mesh。

这里要注意的是,HLOD会自己额外建立对应的Grid,内部也有自己的Cell,不会放在MainGrid内也不受WorldSetting的规则影响,而是受HLOD Layer这个配置文件影响。然后HLOD的实现是通过WorldPartitionBuilder方式实现的。

WorldPartitionBuilder

这个其实是个编辑器的Commandlet脚本。因为WorldPartition在编辑器层面每个Actor是OFPA,而打包后就是和ue4本身的地图没区别了。就相当于是对编辑器地图做了一个cook操作,生成实际的发布地图。因此WorldPartition提供了这样一个工具,让程序员可以也自定义一些操作,对编辑器地图做一些自定义的处理,然后把结果保留下来。

自带了这几个功能,包括烘培HLOD,烘培小地图,烘培植被,烘培RVT等:

一些插件里也有一些Builder,包括SmartObject的,可以批量收集全图的SmartObject到对应的Collection里。

当然除了引擎提供的功能以外,我们也可以去自定义Builder,比如在这里做一些剔除合并Actor,或者合批工作,以及做一些和地图性能优化相关的功能,就可以让地图开发工作变得很方便。也可以去做一些数据收集工作,比如统计场景内各个兴趣点,指定Actor类型并整理成报告,去做一些静态分析等。

随便找一个Builder,可以看到其实只需要Override这几个函数就可以实现对应的需求了。其中GetLoadingMode是告诉Commandlet怎样处理对应地块的

其中Custom/EntireWorld可以一次处理一整块地图,IterativeCells/IterativeCells2D可以分Cell处理,如果分Cell,就会多次调用RunInternal函数。在RunInternal内部可以通过传统方式TActorIterator遍历当前地块内的Actor,也可以通过FActorDescContainerCollection::TIterator(WorldPartition)去遍历所有Actor的ActorDesc。

ContentBundle

这个功能肯定有人好奇是什么,官方连文档也没有,但是ue5的WorldPartition里还提供了一个编辑器。

其实这个功能是给GameFeature用的。我们知道GF可以动态给游戏增加很多原来没有的功能,而对于WorldPartition,就可以通过ContentBundle给原来的关卡里动态新增一些Actor,这样我们工程就可以只做一个基础的地图,而动态可变的部分都通过ContentBundle来实现。

具体来说,就是在GameFeatureData的配置文件里,新增一个Action,这个Action要选Add World Partition Content。然后就可以去编辑这个ContentBundleDescriptor了。

可以看到这里什么都没,就一个名字,把名字改成我们想要的就好了,然后保存重启编辑器。然后在ContentBundlesOutliner里面就能看到了刚才加好的ContentBundle了

之后,我们就可以给这个ContentBundle里面放Actor,这些Actor就会随着GameFeature的生命周期。

至于怎么放,目前没特别好的办法,只能先把ContentBundle设为当前编辑器的Context然后再设置。具体可以看下面这个介绍,怎么来设编辑器的Context:

虚幻引擎中的Actor编辑器上下文 | 虚幻引擎5.3文档 (unrealengine.com)

在源码层面,ContentBundle的Actor也是单独管理的。在加载卸载的时候都有做特殊处理。然后这些Actor也是存储在插件下面的__ExternalActor__文件夹下的,而不是主地图本身的__ExternalActor__下面。

不过要注意的是,ContentBundle是不支持LevelInstance的,另外也有小道消息听说这个功能会在ue5.5被干掉,所以要不要把这个功能用到自己项目里,就还是看自己项目实际情况来考虑。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2024-01-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 场景空间划分
  • GenerateStreaming
  • UpdateStreamingState
  • OFPA(One File Per Actor)
  • DataLayer
  • LevelInstance
  • HLOD
  • WorldPartitionBuilder
  • ContentBundle
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档