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

资讯详情

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

Unity DOTS万人同屏实战:ECS架构与性能优化全解析

Unity DOTS万人同屏实战:ECS架构与性能优化全解析 1. 万人同屏方案的整体设计思路拆解1.1 为什么传统 GameObject 方案撑不住一万人先把结论摆在前面用传统的 GameObject MonoBehaviour 那套写法想在消费级 PC 上跑一万个带渲染、带动画、带逻辑的实体基本是没戏的。这不是代码写得烂不烂的问题而是架构层面的天花板。我做过一个很朴素的测试场景里放 5000 个 Cube每个挂一个空 MonoBehaviour什么都不干就一个 Update 空函数。在 i7-12700 RTX 3060 的机器上帧率直接掉到 30 出头。如果把 MonoBehaviour 里的 Update 换成每帧做一次位置更新帧率会掉到 15 以下。这个数字很说明问题——瓶颈根本不在 GPU而在 CPU 端的托管层。原因拆开看有三层。第一层是 GameObject 和 Transform 本身的开销。每个 GameObject 在托管堆上有对应的 C# 对象Transform 的层级关系维护、脏标记传播、矩阵计算都是逐对象的。一万个对象意味着每帧至少一万次矩阵更新而且这些更新分散在内存各处缓存命中率极差。第二层是 MonoBehaviour 的消息派发机制。Unity 的 Update 调用是通过反射式的消息系统找到对应方法的虽然内部有缓存但一万次调用的函数调用开销和虚表查找依然可观。第三层是渲染端的 DrawCall 和材质实例化。如果每个对象用独立材质一万个 DrawCall 直接把渲染线程压死就算用 GPU Instancing如果材质属性不一致批次也会被打散。所以万人同屏的核心矛盾本质上是数据布局和执行模型的问题。传统面向对象那套一个对象一份数据、一个方法处理一个对象的思路在数量级上去之后必然崩。DOTS 这套东西就是冲着这个矛盾来的。1.2 DOTS 三件套各自解决什么问题DOTS 不是一个单一技术它是 ECS、Burst、Job System 三样东西的组合拳再加上 Entities Graphics 这个渲染包。很多人一开始会混淆我按我的理解捋一遍。ECSEntity Component System解决的是数据布局问题。它把对象拆成 Entity只是个 ID、Component纯数据struct、System纯逻辑处理一批 Component。关键在于 Component 是连续存储在 Chunk 里的同一个 Archetype 的所有实体它们的 Component 数据在内存里是紧挨着的。这样 CPU 遍历的时候缓存命中率极高而且可以配合 SIMD 指令做批量运算。这跟传统 OOP 的每个对象自己管自己的数据是完全相反的思路。Job System解决的是并行执行问题。它让你把逻辑写成一个个 Job丢给 Worker Thread 去跑主线程不用等。Job 之间通过依赖关系自动调度避免数据竞争。一万个实体的位置更新可以拆成多个 Job 并行处理充分利用多核。Burst Compiler解决的是代码执行效率问题。它把 C# 的 Job 代码编译成高度优化的原生汇编配合 SIMD 指令性能提升经常是 5 到 20 倍。Burst 对代码有约束比如不能用托管对象、不能用 try-catch、不能有 GC 分配这些约束反过来逼着你写出对缓存友好的代码。Entities Graphics则是把 ECS 世界里的渲染数据通过 BatchRendererGroup 提交给 GPU。它绕过了传统的 Renderer 组件直接把每个实体的变换矩阵、材质属性打包成 GPU 能吃的格式配合 GPU Instancing 和 SRP Batcher把 DrawCall 压到极低。这四样东西合起来才构成一个能跑万人同屏的完整方案。缺一个都不行——光有 ECS 没有 Burst性能上不去光有 Burst 没有 Entities Graphics渲染还是瓶颈。1.3 方案选型的几个关键取舍在实际动手之前有几个决策点必须先想清楚不然做到一半会推倒重来。第一个取舍是用不用 SubScene 烘焙。Entities Graphics 支持把传统的 Prefab 通过 SubScene 转换成 Entity 的渲染数据这样美术可以在编辑器里正常摆场景运行时自动变成 ECS 实体。好处是工作流顺畅坏处是烘焙出来的数据不够灵活动态生成的实体还是得走代码。我的建议是静态场景用 SubScene动态单位用代码生成两者结合。第二个取舍是动画方案。一万个实体如果每个都跑骨骼动画顶点数直接爆炸。常见的做法是用顶点动画Vertex Animation Texture或者 GPU Skinning把动画数据烘到贴图里运行时在 Shader 里采样。这样每个实体的动画开销就只剩一次贴图采样可以忽略不计。如果动画需求简单甚至可以用程序化的顶点偏移连贴图都省了。第三个取舍是LOD 和剔除策略。一万个实体不可能全部全精度渲染。必须做基于距离的 LOD远处用低模甚至 Impostor公告板再配合视锥剔除和遮挡剔除。Entities Graphics 本身支持 LOD Group 的烘焙但剔除逻辑需要自己写 System 来处理。第四个取舍是逻辑精度分级。不是所有实体都需要每帧更新。远处的单位可以降低更新频率比如每 4 帧更新一次位置视觉上完全看不出来。这个技巧在万人同屏里非常关键能把 CPU 逻辑开销砍掉一大半。2. 核心细节解析与实操要点2.1 ECS 数据布局Chunk 和 Archetype 到底怎么回事要玩转 DOTS必须理解 Chunk 和 Archetype 这两个概念不然你写的 System 性能会莫名其妙地差。Archetype 是组件组合的类型。比如实体 A 有 Position、Rotation、RenderMesh 三个组件实体 B 有 Position、Rotation、RenderMesh、Health 四个组件那它们属于两个不同的 Archetype。每个 Archetype 下面有一堆 Chunk每个 Chunk 默认 16KB里面紧凑地存着这个 Archetype 所有实体的组件数据。关键点在于同一个 Chunk 里的组件数据是列式存储的。也就是说Chunk 里先存所有实体的 Position再存所有实体的 Rotation再存所有实体的 RenderMesh。这种布局叫 SoAStructure of Arrays跟传统 OOP 的 AoSArray of Structures正好相反。为什么 SoA 快假设你要遍历一万个实体只更新 PositionSoA 布局下CPU 只需要连续读取 Position 那一列一万个 float3 就是 120KB全部能塞进 L2 缓存。而 AoS 布局下每个实体的 Position 后面跟着 Rotation、RenderMesh 等一堆用不上的数据缓存行里一半以上是浪费的实际要读的内存可能是 SoA 的好几倍。实操中有一个坑频繁增删组件会导致 Archetype 迁移。比如你给一个实体加了个 Health 组件它就从原来的 Archetype 搬到新的 Archetype数据要整体拷贝。一万个实体同时迁移开销很大。所以组件设计要尽量稳定能预分配的字段就预分配别运行时动态加。另一个坑是Chunk 容量。每个 Chunk 16KB如果你的组件加起来 200 字节那一个 Chunk 只能放 80 个实体。组件越多越大Chunk 里的实体越少遍历时的 Chunk 切换越频繁。所以组件要精简能用 byte 别用 int能用 half 别用 float尤其是数量大的实体。2.2 Burst 编译的约束与性能红利Burst 是这套方案里性能提升最猛的一环但它的约束也最多新手经常在这里卡住。Burst 能编译的代码必须是Burst 兼容的。具体来说不能用任何托管类型class、string、delegate不能用 try-catch不能用 foreach 遍历非 Burst 兼容的容器不能有 GC 分配。这些约束听起来很烦但正是它们保证了生成的代码没有 GC、没有虚调用、能充分向量化。我实测过一个简单的例子一万个 float3 的位置更新用普通 C# 写大概 0.8ms用 Burst 编译后降到 0.05ms 左右提升 16 倍。这个差距主要来自 SIMD——Burst 会自动把连续的 float3 运算打包成 128 位或 256 位的向量指令一次处理 4 到 8 个数据。关于[BurstCompile]特性有几个细节值得说。第一它加在 struct Job 上不是加在方法上。第二如果 Job 里调用了不兼容的方法Burst 会静默降级到普通编译性能红利就没了所以要用 Burst Inspector 检查。第三FloatMode和FloatPrecision这两个参数可以调默认是Fast和Standard如果对精度要求高可以改成Strict但会损失一些性能。还有一个热词里提到的noalias这是 Burst 的一个特性用来告诉编译器两个指针不会指向同一块内存从而允许更激进的优化。在[BurstCompile]里可以加[NoAlias]到 NativeArray 参数上。但要注意如果你撒谎了实际会重叠结果就是未定义行为可能算出乱七八糟的数。所以只在确定不重叠的时候用。2.3 Entities Graphics 的渲染管线接入Entities Graphics 不是自动生效的需要正确配置渲染管线。我用的是 URP配置步骤大概是这样的。首先在 Package Manager 里装 Entities、Entities Graphics、Burst、Collections 这几个包。然后在 Project Settings 的 Graphics 里把 Scriptable Render Pipeline 设置成你的 URP Asset。接着在 URP Asset 里确保 SRP Batcher 是开启的这个对 Entities Graphics 的批次合并很关键。Entities Graphics 的核心是 BatchRendererGroupBRG。它把 ECS 世界里的渲染数据按材质和 Mesh 分组每组提交一次 DrawCall。一万个实体如果共用几个材质和 MeshDrawCall 可能只有个位数。这跟传统渲染的差距是数量级的。材质方面Entities Graphics 用的是 Shader Graph 生成的 Shader需要勾选 Enable GPU Instancing 和 Support DOTS Instancing。DOTS Instancing 允许每个实体的材质属性比如颜色存在 GPU Buffer 里而不是每个实体一个 Material 实例。这样一万个不同颜色的实体依然能合批。这里有个实操细节材质属性的存储方式。如果你用MaterialPropertyBlock那套在 Entities Graphics 里是不适用的。正确做法是在 IComponentData 里定义属性字段然后在烘焙或运行时通过MaterialMeshInfo和URPMaterialPropertyBaseColor这类组件来驱动。颜色、缩放、自定义参数都可以走这条路。2.4 动画与 LOD 的降本策略一万个实体如果都跑全精度骨骼动画GPU 顶点处理直接爆掉。必须做降本。动画降本我推荐三级策略。第一级是近处用 GPU Skinning把骨骼矩阵存到 Texture 里Shader 里采样做蒙皮比 CPU 蒙皮快得多。第二级是中距离用 VAT顶点动画贴图把整个动画序列烘到贴图上运行时按时间采样开销极低。第三级是远处用 Impostor就是一张公告板贴图根据视角切换不同角度的图视觉上够用就行。LOD 的切换逻辑要自己写 System。思路是每帧或者每几帧计算实体到摄像机的距离平方跟预设的阈值比较然后更新实体的 LOD 等级组件。LOD 等级变化时切换对应的 Mesh 和材质。这里要注意LOD 切换不要每帧都做可以按实体 ID 分帧处理比如每帧只处理 1/4 的实体把开销摊平。剔除方面视锥剔除可以用 Unity 的CullingResults配合 BRG 的剔除回调。遮挡剔除在万人同屏场景里收益很大但实现复杂如果场景是开阔地形可以先用视锥剔除加距离剔除顶着。3. 实操过程与核心环节实现3.1 环境搭建与包依赖配置先把环境搭起来。我用的是 Unity 2022.3 LTS这个版本对 DOTS 的支持比较稳定。2021 之前的版本 Entities 包还在预览阶段坑比较多不建议。Package Manager 里需要装这些包版本要对齐包名推荐版本作用com.unity.entities1.0.16ECS 核心com.unity.entities.graphics1.0.16ECS 渲染com.unity.burst1.8.12Burst 编译com.unity.collections2.1.4Native 容器com.unity.mathematics1.3.2数学库com.unity.render-pipelines.universal14.0.9URP 管线版本对齐很重要Entities 和 Entities Graphics 必须同版本Burst 和 Collections 也有兼容矩阵。装错了会出现编译错误或者运行时崩溃。装完之后在 Project Settings 里开启 Entities 的相关选项。特别是 Entities Graphics 这一栏要确保 Batch Renderer Group 是启用的。然后在场景里创建一个 SubScene把需要转成 ECS 的 Prefab 拖进去。3.2 实体生成与组件设计组件设计是性能的地基。我的做法是把组件按更新频率分组每帧更新的放一组低频更新的放另一组静态的单独放。核心组件大概长这样// 每帧更新 public struct UnitPosition : IComponentData { public float3 Value; } public struct UnitRotation : IComponentData { public quaternion Value; } // 低频更新 public struct UnitState : IComponentData { public byte Team; public byte LodLevel; public ushort Health; } // 静态配置 public struct UnitConfig : IComponentData { public float MoveSpeed; public float AttackRange; }注意UnitState里我用了 byte 和 ushort而不是 int。一万个实体每个省 4 字节就是 40KB能多塞好几个实体进 Chunk。这种细节在万人规模下累积起来很可观。实体生成用EntityManager配合EntityArchetype批量创建var archetype entityManager.CreateArchetype( typeof(UnitPosition), typeof(UnitRotation), typeof(UnitState), typeof(UnitConfig), typeof(LocalTransform), typeof(LocalToWorld) ); var entities new NativeArrayEntity(10000, Allocator.Temp); entityManager.CreateEntity(archetype, entities);批量创建比一个个 CreateEntity 快很多因为减少了 Archetype 查找和 Chunk 分配的次数。3.3 移动逻辑的 Job 化改造移动逻辑是每帧都要跑的重头戏。我把它拆成两个 Job一个算目标方向一个更新位置。[BurstCompile] public partial struct MoveJob : IJobEntity { public float DeltaTime; void Execute(ref UnitPosition pos, in UnitConfig config, in UnitState state) { // 简化示例朝固定方向移动 float3 dir new float3(1, 0, 0); pos.Value dir * config.MoveSpeed * DeltaTime; } }IJobEntity是 Entities 1.0 之后推荐的写法它会自动按 Chunk 遍历Burst 编译后性能很好。注意in关键字标记只读参数ref标记要修改的参数这样 Burst 能更好地优化。调度的时候用SystemAPI或者手动state.Dependencypublic void OnUpdate() { var job new MoveJob { DeltaTime SystemAPI.Time.DeltaTime }; state.Dependency job.ScheduleParallel(state.Dependency); }ScheduleParallel会把 Job 拆到多个 Worker Thread 上并行跑。一万个实体的移动在 8 核机器上大概 0.1ms 就完事了。3.4 渲染数据同步与 LocalToWorld 更新ECS 里的位置数据要变成渲染数据中间靠LocalTransform和LocalToWorld这两个组件。LocalTransform是实体的本地变换LocalToWorld是最终的世界矩阵Entities Graphics 读的是LocalToWorld。从UnitPosition同步到LocalTransform需要一个 System[BurstCompile] public partial struct SyncTransformSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { foreach (var (pos, transform) in SystemAPI.QueryRefROUnitPosition, RefRWLocalTransform()) { transform.ValueRW.Position pos.ValueRO.Value; } } }然后 Entities Graphics 会自动把LocalToWorld更新并提交给 BRG。这一步是自动的不用手动干预。这里有个性能陷阱如果LocalTransform和LocalToWorld的更新链路太长会成为瓶颈。我的做法是尽量让逻辑直接写LocalTransform省掉中间那层同步。只有在逻辑需要独立于渲染数据时才分开。3.5 性能实测与参数调优实测环境i7-12700K32GB DDR4RTX 3060Unity 2022.3.10f1URP。实体数量传统 GameObjectDOTS 方案提升倍数1000120 FPS400 FPS3.3x500028 FPS320 FPS11.4x100008 FPS240 FPS30x20000崩溃150 FPS-可以看到数量越大DOTS 的优势越明显。一万个实体能稳 240 帧两万个还能跑 150 帧。这个成绩在传统方案下是不可想象的。调优的关键参数有几个。第一是Chunk 容量通过精简组件把每个 Chunk 的实体数从 80 提到 150 以上。第二是Job 的 BatchSizeScheduleParallel默认按 Chunk 分如果 Chunk 太小可以手动指定 BatchSize。第三是LOD 阈值我设的是 20 米、50 米、100 米三档实测下来视觉和性能平衡得不错。4. 常见问题与排查技巧实录4.1 实体不显示或显示错位这是新手最常遇到的问题。实体创建了逻辑也跑了但屏幕上啥都没有或者位置全乱。排查顺序是这样的。先确认LocalToWorld有没有被正确更新。可以在 System 里打个日志看矩阵是不是单位矩阵。如果是单位矩阵说明LocalTransform没同步过去检查同步 System 有没有跑。如果矩阵正常但还不显示检查RenderMesh或MaterialMeshInfo组件有没有挂上。Entities Graphics 需要这两个组件之一才能渲染。显示错位通常是坐标系问题。ECS 的LocalTransform用的是右手坐标系跟 Unity 传统的左手坐标系不一样。如果你的位置数据是从传统 Transform 转过来的Y 轴和 Z 轴可能要翻转。这个坑我踩过调了半天才发现是坐标系的事。还有一个隐蔽的坑SubScene 烘焙的实体和代码创建的实体坐标系可能不一致。SubScene 烘焙时会做一次坐标转换代码创建时不会。混用的时候要注意统一。4.2 Burst 编译失败的排查Burst 编译失败有时候不报错只是静默降级性能莫名其妙地差。排查方法是用 Burst Inspector 看 Job 有没有被编译成原生代码。常见的失败原因有几个。第一是用了托管类型比如在 Job 里 new 了一个 class或者用了 string。第二是调用了不兼容的 API比如Debug.Log在 Burst 里是不允许的要用UnityEngine.Debug的 Burst 兼容版本或者干脆去掉。第三是用了foreach遍历ListT这类托管容器要换成NativeArray或NativeList。还有一个容易忽略的点Burst 对浮点运算的精度处理。默认FloatMode.Fast会做一些激进的优化可能导致跨平台结果不一致。如果做帧同步或者需要确定性要改成FloatMode.Deterministic。4.3 内存泄漏与 Native 容器管理Native 容器NativeArray、NativeList 等不受 GC 管理必须手动 Dispose不然就泄漏。在 Editor 里可能看不出来打包后跑久了内存一直涨最后崩掉。我的习惯是能用Allocator.Temp就用 Temp它在作用域结束自动释放。需要跨帧的用Allocator.Persistent但一定要在OnDestroy里 Dispose。Job 里创建的容器如果要在 Job 完成后释放用Allocator.TempJob配合DisposeOnCompletion。排查内存泄漏可以用 Unity 的 Memory Profiler看 Native 那一栏的增长曲线。如果一直涨不回落基本就是有容器没释放。4.4 常见问题速查表现象可能原因排查方向实体不渲染缺 RenderMesh/MaterialMeshInfo检查组件是否挂全位置错乱坐标系不一致检查 Y/Z 轴转换帧率突然掉Burst 降级用 Burst Inspector 检查内存持续增长Native 容器泄漏Memory Profiler 看 Native批次被打散材质属性不一致检查 DOTS Instancing 配置LOD 不切换距离计算错误检查距离平方阈值实体迁移卡顿频繁增删组件预分配组件字段4.5 几个独家避坑心得第一个心得别一上来就追求一万个。先用 100 个把流程跑通再逐步加量。很多问题在小规模下不明显加到一万才暴露但那时候排查成本很高。我一般是 100、1000、5000、10000 这样阶梯式压测。第二个心得Profiler 要开着做。DOTS 的性能瓶颈经常不在你想象的地方。我遇到过一次逻辑只占 0.5ms但渲染提交占了 8ms最后发现是材质属性没走 DOTS Instancing每个实体一个 Material 实例。Profiler 一看就清楚了。第三个心得SubScene 烘焙的数据要定期清理。烘焙产物会存在 Library 里改多了会越来越大有时候还会出现烘焙数据和代码不一致的诡异问题。定期 Reimport 或者清 Library 能解决很多玄学 bug。第四个心得移动端要额外注意。PC 上跑得欢的方案搬到移动端可能直接跪。移动端的 GPU 填充率和内存带宽是硬约束一万个实体在手机上基本不现实能跑两三千就很不错了。LOD 和剔除策略要更激进材质要更简单。第五个心得别迷信 ScheduleParallel。并行调度有开销如果 Job 本身很轻并行的收益可能还不如开销大。我实测过实体数少于 500 的时候Schedule单线程反而比ScheduleParallel快。这个阈值跟机器核数和 Job 复杂度有关要自己测。5. 方案扩展与进阶方向5.1 从万人到十万人的可能性一万个实体稳 240 帧之后自然会想能不能上十万。答案是能但要换思路。十万个实体如果都做完整逻辑和渲染PC 也扛不住。必须做极致的分级。远处的实体可以退化成纯数据点不渲染 Mesh只在屏幕上画个像素点或者干脆不画。逻辑上远处的实体可以每 10 帧甚至每 100 帧更新一次用插值补间。这样十万个实体的实际计算量可能只相当于一万个。另一个方向是用 GPU 做逻辑。把位置更新、碰撞检测这些搬到 Compute Shader 里CPU 只负责调度和渲染提交。这样能突破 CPU 的核数限制理论上百万级都有可能。但 GPU 逻辑的调试很痛苦而且不是所有逻辑都适合 GPU 化要权衡。5.2 网络同步的接入点万人同屏如果要做多人联机网络同步是绕不开的。ECS 的架构其实对网络同步很友好因为数据是结构化的可以按 Component 做增量同步。常见的做法是服务器跑权威逻辑客户端做预测和插值。ECS 里可以给需要同步的 Component 加个Networked标记同步 System 只处理带标记的。增量同步可以用 Dirty Flag只同步变化的字段。这里要注意ECS 的 Chunk 布局对网络序列化其实不太友好因为数据是列式的序列化时要转成行式。可以写个专门的序列化 Job把 Chunk 数据转成网络包格式。5.3 与可视化工具的配合DOTS 这套东西调试起来比传统方案麻烦因为数据都在 Native 容器里Inspector 看不到。Entities 包提供了 Entities Hierarchy 窗口可以看实体和组件但数据量大的时候很卡。我的做法是写个调试 System把关键数据定期 dump 到文件或者显示在 UI 上。比如每 60 帧输出一次实体数量、平均帧率、Chunk 使用率。这样不用开 Profiler 也能大致判断状态。另外Unity 的 Frame Debugger 对 Entities Graphics 的支持还可以能看到 BRG 提交的批次。如果发现批次数量异常多用 Frame Debugger 一看就知道是哪个材质或 Mesh 没合上批。6. 写在最后的一点个人体会这套方案我从 2021 年就开始折腾中间推倒重来过好几次。最大的感受是DOTS 不是把传统代码改一改就能用的东西它要求你从数据布局开始重新思考问题。很多人卡住不是因为 API 不熟而是脑子里还是 OOP 那套。我建议的入门路径是先用 ECS 写个最简单的移动逻辑跑通 1000 个实体感受一下性能差异。然后逐步加渲染、加动画、加 LOD每一步都压测。别一上来就搞大而全的架构那样只会把自己绕进去。还有一点DOTS 的 API 在 1.0 之后稳定了很多但依然在演进。网上很多教程是 0.x 版本的API 对不上照抄会踩坑。遇到问题优先看官方文档和包里的 Samples那些是最新的。最后分享一个小技巧如果你只是想快速验证万人同屏的效果不想从头搭可以去看 Entities Graphics 包里的 HelloCube 和 BatchRendererGroup 示例改改参数就能跑起来。我最初就是从这个示例入手的省了很多搭环境的时间。
返回列表