尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity DOTS实战:ECS+Job System+Burst构建万人同屏Demo

Unity DOTS实战:ECS+Job System+Burst构建万人同屏Demo 最近开发者群里聊“Dots节点”的频率明显又上来了。这里的 DOTS说的就是 Unity 官方那套 Data-Oriented Technology Stack面向数据的技术栈核心包含 ECS 实体组件系统、Job System 多线程调度、Burst 高性能编译器这三件套。网上那些几万个小球同时弹跳、帧率纹丝不动的演示视频基本都出自这套东西。这篇文章我想换个角度把 DOTS 拆成一张“节点图”来理解再带大家从零搭一个万人同屏的 Demo把整个流程跑通最后聊聊实际开发中容易踩的坑。无论你是在研究引擎底层还是为现有项目做技术选型这篇应该都能给你一个比较清晰的判断依据。1. 当 5 万个单位同时动起来传统 GameObject 方案的崩溃现场先说清楚我们到底在解决什么问题。很多人在没接触 DOTS 之前觉得“无非就是优化一下代码嘛”。但等你真正面对一个需要同时驱动几万个单位的项目时会发现问题根本不在某一个函数写得快慢而是整个 GameObject 体系的底层设计完全撑不住这种规模。1.1 GameObject 随机访问的内存量级一个常规的 GameObject本质上是一个托管对象它持有的每个 Component 都是 C# 托管堆上分散的小对象。你在场景里放了 5 万个士兵每个士兵身上挂两三个 Component遍历一遍这些 Component 就等于在内存里做几万次随机访问。CPU 取数可不是按字节来的而是按 cache line 来一条 cache line 通常是 64 字节。哪怕你只需要读一个 float4 字节CPU 也会把周围的 64 字节整个拉进缓存。如果这 64 字节里恰好躺着同一个士兵的另外几个字段那这趟访问就值回票价。可惜 MonoBehaviour 的现状是position 在托管堆这一块hp 在那一块动画状态又在另一块。结果就是每次访问都在浪费缓存空间真实的 CPU 计算时间占比低得可怜。这就是典型的“内存布局不友好”也是 DOTS 想解决的第一个核心问题把数据按访问顺序整整齐齐地摆在一起。1.2 Update 调用的调度开销第二个问题是调度开销。传统的 MonoBehaviour.Update 是引擎每帧逐个调用的每个组件都有虚函数调用、生命周期判断、引擎内部状态机切换的成本。当活跃脚本数量从几百涨到几万这部分固定开销会暴涨。再加上 Transform 有层级 dirty 标记改一个父节点可能连锁触发一串子节点的矩阵更新。5 万个节点同时在场景里跑光是 Transform 的级联刷新就够 CPU 喝一壶了。我测过一个比较典型的场景在 i7-10700K、16GB 内存的机器上用纯 GameObject 方案在场景里生成 5 万个带简单旋转逻辑的 Cube帧时间直接飙到 40ms 以上也就是 20 多帧。这个数字不是某一个函数写得烂而是整个架构的天花板。DOTS 的思路一句话概括就是让数据长得像数据让逻辑变成对连续内存的批量处理。接下来我们把这套结构拆开看。2. Entity、Component、System 的节点化拆解DOTS 的骨架很多人第一次接触 ECS 会被一堆新名词劝退。其实把 DOTS 看成一张节点图就好懂了Entity 是图里的节点Component 是挂在节点上的数据包System 是遍历节点做计算的处理器World 是整张图的容器。2.1 Entity一个可复用的数字 IDEntity 不是一个对象它更像一张表里的行号。在 DOTS 里Entity 本质上是个 int 组成的 ID包含 index 和 version 两部分。index 定位到数据version 用来检测这个 ID 是否已经失效。这个设计带来的好处是Entity 可以放进数组、Job、甚至跨线程传递因为它就是纯值类型没有引用计数的困扰拷贝代价极低。你不需要“new”一个 Entity你只是声明“我要往这个 ID 上挂数据”。2.2 Component纯数据不掺逻辑Component 是 DOTS 里最纯粹的环节。它就是个 unmanaged struct比如public struct MoveOptions : IComponentData { public float Speed; public float Amplitude; public float Phase; }只能存值类型不能存 class 引用不能有方法逻辑。你甚至可以写一个没有任何字段的 Component纯粹当“标签”用。这就是 Tag Component用来标记“这个实体是敌军”“这个实体需要下雨特效”“这个实体已经被冻结”系统可以按标签筛选参与计算的实体。因为 Component 是纯数据引擎就能把大量同类型 Component 整整齐齐地连续排在内存里遍历的时候按顺序一条条读过去充分发挥 CPU 缓存。这是 ECS 性能碾压传统方案的第一层底气。2.3 Archetype 和 Chunk内存到底怎么排这里有个关键概念叫 Archetype。简单说所有组件类型组合完全一致的实体会被归到同一个 archetype 里。一个实体身上挂的是Position Velocity Mass另一个挂的是Position Velocity Color这两个实体就属于不同 archetype。每个 archetype 的数据存在一块块 16KB 的连续内存块里这块内存叫 Chunk。同一个 chunk 内的实体数量取决于组件总大小。比如一个实体组件总大小是 128 字节那一个 chunk 就能放下 16KB / 128B 128 个实体。真正精妙的地方在于DOTS 在 chunk 内部不是把“实体一条、实体一条”串起来而是按组件列存储。也就是说chunk 里先连续存一列所有的 Position再连续存一列所有的 Velocity。这样系统在批量处理某个组件时读到的永远是一整块连续内存而且可以做数据并行让不同线程处理不同 chunk。2.4 用一张表对比传统方案维度GameObject MonoBehaviourDOTSECS Job Burst实体表示托管对象有引用计数int ID纯值类型组件内存分散在托管堆按 archetype 连续排列在 chunk数据访问随机访问缓存命中差顺序访问缓存友好逻辑调度单线程逐组件调用多线程 Job 并行处理 chunk编译优化JIT 解释执行Burst 编译为原生码这套设计下来遍历 5 万个实体本质上是几段连续内存的循环配合多线程和 BurstCPU 每帧处理时间能压到毫秒级。3. 封装实测 Demo从包安装到十万实体生成的完整步骤理论再好也得跑起来。下面这个 Demo 是典型的 DOTS 入门场景生成 5 万个 Cube让它们按正弦波上下浮动看起来像一片起伏的海洋或者麦浪。整个流程我拆成四步每一步都给出实际可用的代码。3.1 环境准备与包安装我用的是 Unity 2022.3 LTS 加 Entities 1.0.16 这套组合这也是目前比较稳定的版本线。如果你是新开的 Unity 6 项目后面的代码同样适用注意个别 API 名称差异即可。通过 Package Manager 安装以下几个包com.unity.entitiescom.unity.entities.graphicscom.unity.burstcom.unity.collectionscom.unity.mathematics其中 entities.graphics 负责把实体渲染出来没有它你只能拿到数据看不到画面。安装完成后把项目切换成 URP 管线或者保持内置管线都行DOTS 渲染不挑渲染管线。注意如果你在旧项目里看到的是 Entities 0.51.x那说明还在用 2021 时代的版本。那个版本的 API 跟 1.0 差别很大下面代码不适用建议直接升级项目再继续。3.2 定义数据组件与 Authoring 组件DOTS 的烘焙流程是这样的你还是在场景里摆 GameObject但通过 Authoring 组件告诉烘焙器“这个 GameObject 烘焙成什么样的实体和组件”。这种设计的好处是美术和策划不用碰代码在 Inspector 里配参数就行。先定义运行时组件// MoveOptions.cs using Unity.Entities; public struct MoveOptions : IComponentData { public float Speed; public float Amplitude; public float Phase; }再定义 Authoring 类和它的 Baker// MoveOptionsAuthoring.cs using Unity.Entities; using UnityEngine; public class MoveOptionsAuthoring : MonoBehaviour { public float Speed 1f; public float Amplitude 2f; public float Phase 0f; private class Baker : BakerMoveOptionsAuthoring { public override void Bake(MoveOptionsAuthoring authoring) { Entity entity GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new MoveOptions { Speed authoring.Speed, Amplitude authoring.Amplitude, Phase authoring.Phase }); } } }TransformUsageFlags.Dynamic是烘焙时的一个关键参数它告诉烘焙器这个实体需要动态变换位置会实时变化所以系统会为它保留 Transform 相关组件。如果你只是静态装饰物可以用WorldSpace或Renderable选错了会导致实体没有 Transform烘焙出来位置对不上或者变换不生效。接着定义生成器组件// Spawner.cs using Unity.Entities; public struct Spawner : IComponentData { public Entity Prefab; public int Count; public float Radius; }// SpawnerAuthoring.cs using Unity.Entities; using UnityEngine; public class SpawnerAuthoring : MonoBehaviour { public GameObject Prefab; public int Count 50000; public float Radius 60f; private class Baker : BakerSpawnerAuthoring { public override void Bake(SpawnerAuthoring authoring) { Entity entity GetEntity(TransformUsageFlags.WorldSpace); AddComponent(entity, new Spawner { Prefab GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic), Count authoring.Count, Radius authoring.Radius }); } } }这里GetEntity会返回 Prefab 对应的实体引用。注意 Prefab 不能为空否则烘焙器会报错。3.3 编写生成系统和运动系统生成器系统只执行一次从 Prefab 实例化 Count 个实体给每个实体随机位置和随机运动参数然后把自己销毁。// SpawnerSystem.cs using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] public partial struct SpawnerSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { if (!SystemAPI.TryGetSingletonEntitySpawner(out Entity spawnerEntity)) return; Spawner spawner SystemAPI.GetComponentSpawner(spawnerEntity); EntityCommandBuffer ecb new EntityCommandBuffer(Allocator.Temp); var random new Random(42); for (int i 0; i spawner.Count; i) { Entity instance ecb.Instantiate(spawner.Prefab); float angle random.NextFloat(0f, math.PI2); float radius random.NextFloat(0f, spawner.Radius); float3 pos new float3( math.cos(angle) * radius, 0f, math.sin(angle) * radius ); ecb.SetComponent(instance, LocalTransform.FromPosition(pos)); ecb.SetComponent(instance, new MoveOptions { Speed random.NextFloat(0.5f, 2f), Amplitude random.NextFloat(0.5f, 3f), Phase random.NextFloat(0f, math.PI2) }); } ecb.DestroyEntity(spawnerEntity); ecb.Playback(state.EntityManager); ecb.Dispose(); } }这里用了临时 ECB 来记录结构性操作实例化、删除实体最后一次性 Playback。原因是 ECS 规定你正在遍历查询结果的时候不能直接改实体结构否则数据会被打乱。ECB 相当于把“增删实体”的请求先记账再在安全时机统一执行。这个 Demo 里系统只在第一帧跑一次用临时 ECB 是最直观的写法后面如果每帧都要生成应该用官方的EntityCommandBufferSystem让 ECB 延迟执行避免每帧造成同步点。运动系统就更直接了每一帧找出所有带LocalTransform和MoveOptions的实体把 y 坐标刷成正弦波的值。// MovementSystem.cs using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] public partial struct MovementSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { double elapsed SystemAPI.Time.ElapsedTime; foreach (var (transform, move) in SystemAPI.QueryRefRWLocalTransform, RefROMoveOptions()) { float t (float)elapsed * move.ValueRO.Speed move.ValueRO.Phase; float y math.sin(t) * move.ValueRO.Amplitude; float3 pos transform.ValueRW.Position; pos.y y; transform.ValueRW.Position pos; } } }这段代码看起来就是一个普通的 foreach但背后 DOTS 会把它编译成对 chunk 数据的多线程批量处理。你不需要手动开线程Job System 会基于可用的 CPU 核心自动分块并行。3.4 搭场景SubScene 与预制体在场景里建一个空物体PrefabRoot下面挂一个 Cube 子物体把MoveOptionsAuthoring挂到 Cube 上配好参数拖到 Project 窗口做成 Prefab注意要被 SubScene 引用的预制体不要放在场景里而是放在 Assets 下。再建一个空物体SpawnerObject挂SpawnerAuthoring把 Prefab 拖到字段里Count 填 50000Radius 填 60。最后把SpawnerObject所在的场景整体转成 SubScene。方法是右键 Hierarchy 里的场景根节点选 New Sub Scene然后把物体拖进去。SubScene 的作用是让烘焙器在进入 Play Mode 时自动把场景里的 GameObject 烘焙成实体你不需要手动写转换代码。进入 Play Mode 后打开 Window Entities Hierarchy 面板你会看到五万个 Entity 齐刷刷列出来每帧帧时间应该在 2~5ms 左右视 CPU 而定对比 GameObject 方案的 40ms差距非常直观。注意如果画面里什么都看不到先检查 Entities Graphics 是否安装以及预制体上的 MeshRenderer 是否正常。DOTS 渲染需要单独的渲染路径缺一个包就是全黑。4. System 之间的依赖链与调度数据流是怎么“流动”的Demo 跑通了但离“能上生产项目”还差得远。真正复杂的是多个系统之间如何协作、如何避免数据竞争、如何高效调度。4.1 World 与 SystemGroup 的层级结构DOTS 把系统分组成几个阶段默认有三个InitializationSystemGroup、SimulationSystemGroup、PresentationSystemGroup。你写的系统会默认挂到 Simulation 组里。每个组内部还有 UpdateOrder 排序系统间可以通过[UpdateBefore(typeof(SomeSystem))]或[UpdateAfter]显式声明先后。为什么顺序这么重要因为 DOTS 的多线程调度有一个硬性规则同一帧内两个系统不能同时写同一份组件数据一个系统在读某个组件时另一个系统不能去改它。这个规则叫安全系统SafetySystem约束。如果两个系统都声明要读写相同组件DOTS 会让它们串行执行如果完全没有交集就可以并行跑。所以从“节点图”的角度看World 里的每个系统就是图中的一个节点系统之间的依赖关系是边数据沿着这些边流动。你排的 UpdateOrder 决定了数据的流经顺序排错了就可能出现“上一帧的输入被下一帧的逻辑覆盖”这种诡异问题。4.2 Sync Point 的代价多线程调度最怕碰到 Structural Change也就是增删实体、增删组件这类改“地图结构”的操作。这类操作会动 archetype而 archetype 一变chunk 的数据布局全要重排正在其他线程上跑着的任务必须停下来等。每次出现这种“等待”就叫一个 Sync Point。用了 ECB 并不意味着没有代价了ECB 的 Playback 发生在所有依赖它的系统之后这个时刻仍会强制所有线程汇聚。实践上的做法是尽量把结构性操作集中到帧头或帧尾避免一帧里多次触发不同系统尽量复用同一个 ECB 系统减少等待次数如果可以用固定大小的原生容器NativeArray预先生成好实体再一次性批量创建我在自己项目里就吃过亏生成 5 万实体没有问题但在一个每隔 300 帧就会销毁一批敌人再生成一批敌人的玩法里帧时间会从 3ms 瞬间跳到 12ms。后来把销毁和生成操作合并到同一个 ECB并且安排在帧的末尾帧时间降回 5ms 左右。这就是 Sync Point 的实感。4.3 从数据依赖看 Job 的并行度使用 IJobEntity 时DOTS 会根据每个 Job 声明的读写权限自动计算依赖。比如一个系统读了 Position另一个系统只读 Velocity两者互不干扰可以并行但有个系统要写 Position就必须等前一个读完。这里有一个容易被忽略的点如果你在 Job 里用到了[NativeDisableParallelForRestriction]这类特性等于手动告诉引擎“我不在乎竞争”引擎就会放开并行限制。但责任也在你身上一旦真的写了数据竞争的结果是不可预知的。我的建议是只有在确认逻辑上必要的单例访问时才用比如所有实体都读同一个GlobalParam组件可以把它标记为只读而不是禁用限制。4.4 系统数量多起来之后的排查方法系统多了以后单靠脑子记调度顺序不现实。我用得最多的是 Window Entities Systems 面板它会把所有系统、系统组的执行顺序和每帧耗时列出来每个系统后面还带一行 Schedule 信息能看出它是在主线程跑还是子线程跑。排查“某个实体数值不对”这种问题时直接在 Entities Hierarchy 里点击实体右边 Inspector 能看到它当前所有组件的数据。如果数值在被某个系统改错了就在 Systems 面板里逐个系统禁用来做二分定位比 Debug.Log 高效得多。因为 DOTS 是数据流驱动Log 只会告诉你“现在不对了”而不会告诉你“是哪个系统在什么时候改的”。5. 性能数据与优化方向哪些指标才是真瓶颈跑通 Demo 之后大家最关心的问题通常是DOTS 到底能快多少我的建议是别只记一个“快 10 倍”的结论要看你自己的瓶颈在哪。下面是我在 i7-10700K RTX 3070 的机器上针对“5 万个带正弦运动的 Cube”场景实测出来的数据供参考方案初始化耗时每帧 CPU 耗时备注纯 GameObject Update约 1.2s40~55msTransform 级联更新严重DOTS 单线程 Foreach约 230ms9~12msBurst 已生效DOTS Job 并行 Burst约 120ms2.8~4ms多线程达到预期DOTS 按 chunk 手工批处理约 120ms2.1~3ms极致优化收益有限可以看到最大的提升来自“内存布局 Burst”而不是多线程本身。单线程 DOTS 已经比 GameObject 快好几倍了多线程再翻一倍多。这个结论很重要如果你的项目瓶颈真的是 CPU 逻辑DOTS 帮得上如果瓶颈是 Draw Call、GPU 填充率那 DOTS 只能帮你把 CPU 侧余量腾出来最终还是要靠合批、LOD 和遮挡剔除。5.1 优化方向一把查询范围收缩到最小SystemAPI.Query 的过滤条件越精确越好。如果你只关心 y 轴在某个范围内的实体别在系统里写if (pos.y threshold) continue而应该考虑用IJobChunk配合 chunk 级别的过滤甚至在 baking 阶段就把不需要的实体排除掉。DOTS 的查询是 archetype 匹配的条件少匹配的实体才多每多一个过滤条件遍历成本就上一个台阶。5.2 优化方向二慎用 SharedComponent 和 EnableableComponentSharedComponent 会把实体按共享值分组这相当于在 archetype 里又加了一个分组维度会破坏部分线性内存特性。比如你用 SharedComponent 存“阵营颜色”几十个颜色就分出几十个 chunk每块 chunk 大小不足缓存命中率下降。EnableableComponent 是 IComponentData 上加了[Unity.Entities.EnableableComponent]特性可以不用结构性变更就开关组件。这个很好用但查询时如果同时过滤 enabled 状态DOTS 需要额外做一次位标记检查。属于“用得起但别滥用”的优化手段。5.3 优化方向三用 Burst 的 AOT 编译开发模式下 Burst 默认用 JIT性能已经不错但发布时记得开启 AOT 编译并且为目标平台勾选对应的指令集比如 SSE4.2、AVX、AVX2。Burst 编译出的代码会针对 SIMD 指令做向量化四个 float 一次算完这种优化手写是做不到的。一个常用的检查方法在 Profiler 的 CPU 模块里看某个 Job 的耗时如果 Burst 生效函数名会带上代码生成的行号且旁边的“编译器类型”显示 Burst。当你看到方法体里是绿色汇编指令而不是 C# 解释执行时说明优化链路已经通了。6. 上手 DOTS 必踩的坑清单与定位思路DOTS 的坑不是“不会用”而是“以为会用但行为跟直觉不符”。我把自己踩过的和从同行那里听到的高频问题整理成一份清单每一条都备注了定位思路。坑一API 版本差异导致的编译废案Entities 0.51 时代大家写entities.ForEachSystemBase 配OnUpdate到了 1.0推荐 ISystem SystemAPI.Query IJobEntity。网上教程一大半还是老 API直接抄下来编译报错一大片。定位思路很简单先看包版本再看代码用到的命名空间。尽可能用官方迁移工具 Entities API Updater它能把大多数旧 API 自动升级。坑二ECB 每帧 new、每帧销毁造成的性能雪崩我之前见过有人每个系统每帧都new EntityCommandBuffer(Allocator.TempJob)然后在 OnUpdate 结尾 Playback。这么写功能没错但每帧都会制造两个以上 Sync Point五万实体的场景帧时间能翻三倍。正解是让系统单独维护一个 ECB或者使用BeginSimulationEntityCommandBufferSystem这类官方 ECB 系统让它在所有依赖系统之后统一回放。坑三Baking 时 TransformUsageFlags 选错导致位置错乱这是最常见的问题。如果预制体是动态物体会移动但你在 Baker 里写了TransformUsageFlags.WorldSpace烘焙出来的实体没有 LocalTransform 的动态权限运动系统查不到RefRWLocalTransform实体就永远定在原地。反过来一个静态装饰物如果标记成 Dynamic会浪费不必要的 Transform 更新开销。定位思路烘焙后在 Entities Inspector 里看实体实际挂的组件对照你预期的组件列表缺哪个组件就往 Baker 里找原因。坑四把 Entity 存进普通 List 导致 GC 抖动Entity 虽然是值类型但存进ListEntity后每帧增删都会产生 GC。DOTS 里请统一用NativeListEntity或DynamicBuffer管理实体引用。另外UI 或 MonoBehaviour 侧如果每帧引用 Entity 查询实体数据也会产生托管调用记得缓存查询结果。坑五查询的“结构性变更”把自己正在用的 chunk 改了这是新手最容易困惑的明明在 Job 里只是枚举实体怎么跑着跑着就抛异常了原因多半是另一个系统在你枚举期间做了结构性变更导致当前 chunk 被回收。定位思路是看控制台抛出的异常类型如果指向StructuralChange相关安全性错误就把改结构的操作挪到 ECB 统一处理并且关注系统执行顺序是否合理。坑六SubScene 的预烘焙目录没有正确配置SubScene 的实体是在 Editor 里预先烘焙成二进制打包时如果没有把 SubScene 所在的场景和预烘焙目录一起打进去真机上会一片空白。定位思路真机包启动后用 Debug 查看World里的实体数量是否为 0。如果为 0检查 Project Settings 里的 Entities 相关配置以及烘培产物是否在 Build 路径里。经验总结DOTS 的排查思路跟普通 C# 项目差别很大它没有“断点调试每一步”的顺手感。正确路径是先在 Entities Hierarchy 里看数据结构对不对再到 Systems 面板看调度顺序最后用 Profiler 定位 CPU 耗时。三步走下来绝大多数问题都能找到根因。上面这些坑前三个我基本每个项目都踩过一遍尤其是 TransformUsageFlags 那个排查了整整一个下午。不过也正是那几次踩坑让我把 DOTS 的烘焙机制和调度模型彻底吃透了。最后再分享一个小技巧如果你要在 DOTS 项目里快速验证某个想法不用搭复杂的场景直接在初始化系统里用EntityManager.CreateEntity批量创建实体配合一个简单的 Debug 渲染把位置画出来比反复改场景里的 SubScene 快得多。等逻辑验证通过再补齐烘焙和渲染链路。这个工作流能帮你把“写逻辑”和“做内容”两件事彻底解耦省下的时间足够多刷两集剧了。
返回列表