Streaming Open World In ECS Framework - Unity Mega City 

编辑器

    数据准备

  • 原先 Scene 中 Gameobject 进行序列化,最终写出三种文件: SubSceneHeader.entities 文件SharedComponent 预制体  。SubSceneHeader 基础数据后有详解, Entities 文件存储 Section 内所有 Component 数据,Shared Component 同样按照 Scetion ID 划分作为预制体存储(RenderMeshProxy 作为 MeshRenderer 和 Material 的代理,包含渲染所需的模型,材质,阴影等信息)。三者之间使用 SceneGUID 作为统一名称用作辨识。
  • Main Scene 保存所有的 SubScene Objecct (编辑器时为 GameObject ,运行时为 Entity ),其作用在编辑器时用于场景编辑,和 Entity 数据的保存接口,没有导出 entity cache 不产生任何作用。运行时作为一个 Entity,用于请求加载和卸载的判断和场景实体的所属(SceneTag 标识所属),加载前,中,后都会给予不同的 Flag (Request ,Streaming State) 用作标识状态。
  • 序列化时会为每个 SubScene Section 创建其 streaming world ,每个 streaming world 只含有所属与其的 SubScene Section 内的 GameObject 转化的实体, 序列化后(格式参考 SerializeUtility.cs )存储为 .entities 文件,Component 数据都放在其中。
  • Mesh 和 Mateiral 等数据存在 Shared Component Proxy 中,当 Shared Component 预制被加载时一并加载。C# Object 被存储在 Archetype 的 ManagedArrayStorage 中,chunk 只存储其所在索引 (这部分数据暂时没看到有被序列化处理,所以 converted 之后并没有生效,目前只作用在 ConversionSystem )需要序列化的 Object 必须有对应的 Shared Component Proxy  使其能够进行转化才能被使用(这里有个隐式操作,在进行 GameObject 2 Entity 时,Game Object Conversion System 进行实体遍历,将原先的 C# Object 重新转为 Shared Component Data,Shared Component 在生成预制体时,再反射成 Shared Component Proxy 序列化),最后生成一个包含当前 Scetion 所有 Shared Component 的预制体。
  • SubSceneHeader  在最后被写出。包含数据 :Scetion ID,Bounding Box , Eneities 文件大小,SharedComponent 数量和 SceneGUID。
  • Section ID 的划分基于编辑器时  HLOD  所做的划分,除最高层级 LOD0 处理 Section ID 1(高模),其余 Renderer 都被划分为 Section ID 0(低模)。

实时处理

    核心逻辑

  • StreamingLogicSystem
  • 处理加载与卸载逻辑,在 Demo 中 处于 600 米内的场景会被申请加载,800 外的场景会被申请卸载。判断的依据为在此距离内是否和 Sub Scene Bounding Box 有相交。同 Scene 的不同 Section , Bounding Box 不一定需要相同,留有很多可操作空间。
  • 低模 SceneID 0 在进入主场景时必定先被加载,内存存在于整个主场景的生命周期,通过 HLOD(最后转化为 MeshLODGroupComponent) 处理是否被渲染。高模 Scene ID 1 在运行时判断加载。 
  • SubSceneStreamingSystem
  • 接收加载与卸载的申请,单帧加载的 scetion 不超过4个,单帧处理的 world merge 不超过 1 次。Section ID 1 的申请会被推迟到 Section ID 0 的处理完之后。
  • 收到的加载申请会创建一个 AsyncLoadSceneOperation ,等待其几帧完成后,staging world 中已包含所有该 Section 的 ECS 数据 ,在此之前,因确保 staging world 是干净的,无任何已有实体。空世界的目的是为了保证序列化前和反序列化后之间的数据不存在索引差异,并且保证在数据操作时不会挂起主线程等待(ExclusiveEntityTransaction)。
  • 最终做一次 MoveEntites 操作,将 staging world 中的 entities 移动到 main world 中,具体有 MoveAllChunksJobRemapAllChunksJobRemapArchetypesJob 将数据进行移动。这里的 Move 虽然使用子线程处理大部分逻辑,但是其是在此单帧之内强制完成,所以耗费的时间是所有在主线程中较为长的一段。 加载结束。
  • 卸载则很简单,判断其是否加载了高模,并获取所有 Scene Tag 为此 SceneEntity 的 entities 然后销毁,低模始终存在不参与处理,引用计数为 0 的资源自动卸载。
  • AsyncLoadSceneOperation
  •  AsyncLoadSceneOperation 处理反序列与资源加载,首先划分出两个子任务分别加载 SharedComponent 的预制和 .entites 文件,加载为异步执行,主线程不进行等待。
  • 加载资源完毕后,AsyncLoadSceneJob 被使用,其主要执行 .entities 文件的反序列化和通过 ExclusiveEntityTransaction 在子线程中访问 EntityManager 并向其中添加 ECS 数据,具体部分后面会提到。
  • 当 ExclusiveEntityTransaction 任务被执行完毕后,staging world 构建成功,之后进行 MoveEntites 如上所述。
  • ExclusiveEntityTransaction
  • 特殊的 ECS 数据访问渠道,因为是唯一一个能在子线程直接进行数据增删行为。通过创建一个完全同步点,并暂停所有当前世界在子线程的工作,进行大量的原型,组件添加。再完成之后,重新运行世界。
  • 完全为了加载场景处理而出现,其特性让他不能直接在主世界运行(否则主世界在修改完毕之前完全挂起),通常使用在 staging world ,待其填充 staging world 数据后,再进行一次 MoveEntites。

总结

  •     资源加载:资源部分 实体数据存放的 .entity 文件放在 StreamingAssets/文件夹下,此文件不同于 AssetsBundle 或 Resource 下 Texture Mesh 等资源,是新的一种资源文件,加载使用新增的第三种 API AsyncReadManager.Read(), 其本质就是异步的二进制(小端)文件加载。Mesh Texture 等数据放在 Shared Component 预制体中,使用 Resources.LoadAsync() 进行加载,预制挂载的组件都为 mono 运行时进行反射。加载都为异步加载。
  •     反序列化:异步加载资源完成后进行反序列化。Entities 文件的反序列化工作单纯的按预定的序列化格式读取字节,然后重新解析为 ID。包括读取的文件的导出版本,type 数量,每个 type 在 type manager 中的索引位置,entity 的总量等等,Shared Component 预制将获取其全部组件,反射成 ISharedComponentData,根据 .entities 文件中的 SharedComponentID 进行添加 。其中不包括无法转化的 C# 脚本。反序列化的执行在子线程进行。
  •     添加实体:添加实体与反序列化一同执行,反序列化出的数据将更新 staging world 中的数据,因为序列化时 scetion world 与反序列时的 staging world 都是只包含此 scetion 内的数据,所以可以直接解析,在移动的时候需要  Remap 。与反序列执行在同一线程。
  •     移动实体:在序列化和添加实体线程结束后,主线程向 main world 发出合并请求,合并时需要做一次 Remap  确认其引用关系(当前场景是否引用到已处于主场景的实体,更新其在主世界的真正位置),获取到 Remapping Info 后即可以对主场景调用 MoveEntitiesFrom() 将场景合入主场景。移动时主场景和子场景都进行一次同步。
  •     目前加载与卸载逻辑的处理,从申请进行加载到加载完毕(资源加载,反序列化,添加实体,移动实体),主线程的逻辑处理很少,大部分都被分发到子线程中进行处理,主线程执行的时间会更少,后面会进行一系列 demo 测试,列出一些具体数据对比。

问题

  • 记录下目前所发现的一些实际使用还需要探究解决的问题:
  • Static Batch 在 ECS SubScene 下不起作用。原因是原先 Static Batch 产生的 Combined Mesh 存储在打包后的 level 中,现在并没有进行存储(目前版本并未发现有相关逻辑)
  • 示例中并没有包含物理部分,不确定是否能与原先的物理架构兼容,或是使用新的 Dots 下 Physics
  • SubScene 下的 Monobehaviour 并未被序列化存储,不确定是否能够增加逻辑保留此类数据

对比与差异

逻辑差异

  • ESC 下的 SubScene 并非 .unity 文件的 Scene,并不通过 SceneManager 加载或管理。SubScene 的真正数据存储于 .entities 文件,其他脚本数据存储于 SharedComponent 预制。但是还是需要  UnityScene 作为在编辑器下做编辑功能,Entity 本身并不提供编辑,只提供查看功能。
  • 增加 ConversionSystem 逻辑将 Unity 之前版本的模块进行兼容(目前版本只有 Render 模块的部分,Unity Physics 模块处于试验阶段),并且处于 SubScene 中的 C# Object 都需要进行转化才能被序列化保存(目前版本如此,后续可能会支持,或两者取一?)。

速度差异

  • 测试状态:只保留场景本身数据,使用 FreeCam 移动,出现加载信息记录,取三份最长消耗的信息。
  • 测试机:坚果 Pro 2 
  • ECS 架构下,SubScene 加载在主线程的耗时状况如下图所示,去除 debug 状态下的消耗,大约在 5 ms 左右。