天天看点

Unity DOTS(ECS + JobSystem + BurstCompile) 要点分享基础特性

从接触DOTS到基于该技术栈开发游戏有段时间了,整理了些东西,部分来自于官网文档的要点,部分来自于项目实践,mark一下,欢迎各位看官指正。

关于传统游戏开发困境的简单思考

       传统方式基于面向对象开发方式,优点是框架设计简便,封装、继承、多态、可以怼出来各种你想要的东西,入门低产出相对高且稳定,缺点相对不明显,只有在开发到特定游戏表现的时候,比如超大场景,超多战斗单位,有很大计算量的时候,才会发现oo模式下的框架,笨拙且相对低效,无法应对这些移动端游戏性能表现和特定需求之间的矛盾。

        Unity DOTS的框架应运而生,面向数据的技术栈,ECS+JobSystem+Burst,ECS框架下处理数据的能力提升,外加Job多线程充分利用多线程资源提升算力,并且在编译层面对编译生成的IL中间语言进行进一步优化,生成高性能的机器码。这是一套上限很高的高性能的开发框架。

        缺点么,当然更明显,首先基础概念深入,理论知识要求高,一知半解的情况下开发很危险。其次System Job组织调度写法相对复杂且关注点很多。除此之外,性能敏感、熵值不稳也是很难搞。

会把一些开发关注点罗列,如果还没简单过一遍Unity官方文档的话,最好过去先简单看一下,最起码要知道我们在讨论什么对吧。

https://docs.unity3d.com/Packages/[email protected]/manual/component_data.html

头脑风暴~~~

ECS + JobSystem + BurstCompile = 高性能 + 多线程 +  编译层面优化

基础特性

ECS

  • ArcheType & Chunk
    • ArcheType是Entity模板的定义,来确定创建一个Entity时,同时为其创建哪些Component。
    • Chunk是一块规整的内存,里边存储着同一ArcheType的Entity的实例。
  • EntityManager、EntityQuery

                    Entity、Chunk 的创建、设置、查询、删除

  • ECS中 Icomponent组件中只存在数据或者数据的访问方法,不定义其他任何其他行为,所有相关的游戏逻辑都应该在System中处理。
  • Component中的数据类型必须是非托管类的对象(Blittable Types),Blittable可以满足托管内存和非托管内存转移时,可以直接copy,而不需要进行类型转换或者任何的包装操作。
  • ECS SharedComponent的管理,会将所有和当前SharedComponent关联且字段相同的Entity组装进同一个Chunk,以便高效的访问具有相同conponent的Entity,对每一个Entity来讲不会有额外的内存消耗。
  • Simulation Group
    • 多个System可以被组织进同一Group进行管理,Unity默认有三个Group(InitializationSystemGroup、SimulationSystemGroup、PresentationSystemGroup)
    • 当然你也可以创建自己的SimulationGroup,不过通常没什么必要。
    • Simulation中的,可以使用UpdateSystemOrder 调度System之间的执行顺序。
  • Entity CommandBuffer的作用,是将每一帧逻辑中产生的Entity 结构性变更(AddComponent、RemoveComponent,Destroy、NewEntity)的操作指令,缓存,然后在当前SimulationGroup结尾同步点执行生效。为什么这么做呢?是因为ECS的高效遍历依赖于Entity及Component在内存中的紧密排列,而结构性变更则需要对内存进行重新排布,所以必须在一个统一的节点使指令生效。通常是在某个SimulationGroup的结尾,按照CommandBuffer实例创建的顺序,操作缓存的每条指令。
  • DestroyEntity会在做下边这些事儿的时候付出很大代价
    • 找到所有和特定EntityID相关的Component数据
    • 删除这些数据
    • 回收复用这个EntityID
  • AlwaysUpdateSystem 和 EntityQuery 对System OnUpdate 执行的影响

    如果System中不存在EntityQuery的定义,OnUpdate每帧执行

    如果System中存在EntityQuery的定义,且存在满足Query条件的Entity需要处理时,OnUpdate才执行

    如果System中存在EntityQuery的定义,且不存在满足Query的Entity,添加[AlwaysUpdateSystem]标签,则OnUpdate执行,否则不执行

JobSystem

  • Unity CSharp Job 
    • 允许使用者编写简单易用的多线程代码,并且提供了和Unity本身良好的交互。
    • Job System通过创建一个Job而不是线程来管理多线程代码,Job System在多个内核之间管理工作线程,通常一个cpu内核有一个初始线程,来避免线程上下文的切换。
    • Job System将多个Job放入一个工作队列去执行,工作线程从这里获取并执行他们,这个系统同样需要保证管理他们的依赖,并确保按照合适的顺序执行。
  • Native Contanier  
    • NativeContainer是一个对Unity本地内存进行相对安全的封装,接受管理的值类型。在使用Unity JobSystem时,NativeContainer允许job和主线程访问共享内存,而不是通过内存拷贝。
  • Safety System
    • Safety System是在所有NativeContainer类型中内置的,它追踪了是谁在读写NativeContainer。
    • DisposeSentinel可能会在内存泄露后很久,才会触发并给出内存泄漏的错误日志
    • AtomicSafetyHandle在代码中使用AtomicSafetyHandle移交NativeContainer的权限, 所有NativeContainer的安全类型检查(如:越界检查、释放检查、类型检查),都只在Editor和PlayMode下有效。

Burst Compile

  • Burst是一个编译器,它使用LLVM将IL / .NET字节码转换为高度优化的本机代码。它作为Unity包发布,并使用Unity Package Manager集成到Unity中

实际开发中的困境:

  • 实际开发中,entity 携带的数据量大大多于 Demo 中的,使得增删 component 的成本大增,因为这种操作会导致 archetype 变化,进而发生 memcpy,chunk 的数据结构让 memcpy 的次数和 component 的数量成正比,最终堆高成本
  • entity 不能方便的包含变长容器,于是实现 buff、effect 等概念时挺费劲儿,也许应该用 DynamicBuffer?可能也没那么好用,因为所有实例需要使用相同的数据结构
  • cpu cache 命中率的提升基于一个假设:处理逻辑所需数据排列紧密。数据种类多、关联性差会降低 cache 命中率,让基础假设不成立,所以这种优化有较强的局限性
  • job system 要通用许多,并发任务执行、充分利用多核这一做法的局限性相当小,但相应的,它费电、让手机发热,也不能用的太过分
  •  burst compiler 的局限性最大,只适用于纯数学计算,平时无需关注,发现了适用场景再考虑
  •  我们现在的解法是大量的使用全局容器,基本不增删 component。有些极端,这让 entity 在很大程度上沦为句柄,甚至,如果不考虑渲染,再往前走几步,ecs 都不重要了,重要的是 job system、是并发
  •  什么情况下应该增删 component?对一个 entity 来说,增删频率不宜太高,拍脑门儿吧,以 3s 为界,平均生命期长于 3s 的按增删 component 处理
  • 如果 component 只携带一个标记,比如 dead,可把这些标记凑在一起,放进一个 uint、ulong,让 entity 永远携带这个 component,每帧遍历处理
  •  如果 component 的拥有率比较高,且体积不大,满足标准:64 / component 字节数 * 拥有率 >= 0.8(有待验证),让 entity 永远携带,每帧遍历处理
  • 其它增删频率高的 component 怎么处理?本质上,需额外存储两项数据:1)entity,也就是句柄;2)本该做成 component 的数据。落到实处,就是用 NativeArray、NativeHashMap、NativeMultiHashMap 存储,Dictionary 等也可以,但因为普通容器会造成 GC 压力,不宜保存太多条目
  •  全局容器的线程安全:AsParallelWritter 不是万能的,它不能保证 job1 write => job 2 read 顺序依赖,怎么办?向 Job 添加字段,引用全局容器,
  • Native containers are read-write by default when used in a job. This means that you cannot schedule two jobs referencing the same containers simultaneously. By adding the ReadOnly attribute to the container field in the job struct the container is marked as read-only, which allows two jobs to run in parallel reading data from the same container. 于是,不该在Job的Excute中直接使用全局容器,要通过Job Struct字段声明的方式引用。
  • 尽量不要因数据类型相同而共享全局容器,尽量专器专用,明确所有权。
  • 传统事件系统:对 ECS 来说就是灾难,因为 observer 要访问的数据不明确,线程安全也没法保证,应改用其它处理形式
  • 因为 cpu cache 命中率太难维护,是不是可以:让 entity 又薄又小,其它数据都用普通数据结构存在外面,每个 entity 有一个对应的 twin object,在获得一部分高性能的同时,尽可能降低实现功能的复杂度。NativeContainer 是一种选择,好处是没有 GC 问题,缺点是不能包含引用类型,这也是我们现在的选择。这只是一个假想,需要讨论
  • CreateEntity 时,尽量列全 Component 类型,减少拷贝。

继续阅读