
有的项目天生就适合拿来炫技Unity里的DOTS就是这种。以前你跟人说“我用Unity做了个万人同屏”对方大概率觉得你在吹牛毕竟传统GameObject方案跑到几千个带骨骼动画的角色时帧数已经惨不忍睹。但换成Entities Graphics加DOTS这套组合一万个角色在屏幕上跑起来帧数反而能稳定在60左右这个变化是颠覆性的。这篇内容主要面向两类人一类是被性能卡住脖子、正在寻找突破方案的Unity开发老兵另一类是听说DOTS很久但一直没敢动手的进阶新手。我会把整套方案的架构思路、核心原理、实操步骤以及我实测踩过的坑全部交代清楚确保你读完能自己搭出一套可运行的万人同屏Demo而不是只看个热闹。1. 为什么非要用DOTS来扛万人同屏1.1 传统GameObject方案的瓶颈到底卡在哪先说个扎心的数据对比。我用同样一台测试机分别跑了两个Demo一个是用传统GameObject加Animator做的5000个单位移动另一个是用DOTS加Entities Graphics做的15000个单位移动。传统方案在5000个单位时帧数已经掉到40以下而DOTS方案在15000个单位时还能保持稳定60帧。差距接近一个数量级而且单位数量越多差距越明显。传统方案卡顿的原因其实不复杂。每个GameObject都自带一套完整的组件树Transform、MeshRenderer、Animator一挂每个物体都是一次独立的Draw Call还要在主线程上逐个处理Update逻辑。Unity的引擎架构本身就是单线程为主的几千个物体的Update和渲染状态同步全挤在一个线程里CPU直接成了瓶颈。再加上Animator的骨骼计算是在主线程上跑的人一多CPU就彻底冒烟了。1.2 DOTS的技术支柱拆解ECS加Job System加BurstDOTS全称Data-Oriented Technology Stack它不是单一技术而是三个核心部分组成的体系。第一是ECS架构把传统面向对象的思维彻底翻转不再有“一个物体对象”的概念而是把数据拆成Component放在连续内存里按类型批量处理。第二是Job System把逻辑计算拆成一个个Job自动分派到多核线程上并行执行。第三是Burst Compiler用Unity自家的编译器把C#代码直接编译成高度优化的机器码充分发挥CPU的SIMD向量计算能力。简单打个比方传统方案像是每个士兵都有自己的独立营房、独立后勤后勤要挨个去找每个士兵补给。ECS方案像是把所有士兵的饭盒集中放在一个仓库里后勤一次性搬运一整排饭盒效率完全不在一个量级。连续内存的访问模式对CPU缓存友好到极致一万个实体的位置信息在内存里几乎是挨着的一次缓存预取就能覆盖大片数据。1.3 Entities Graphics在整个架构里的角色Entities Graphics是DOTS体系的渲染层专门负责把ECS实体批量渲染到屏幕上。它不像传统方案那样每个物体单独产生Draw Call而是把拥有相同Mesh和Material的实体自动合并成一批用一个Draw Call画出来。这就是CPU瓶颈解除之后GPU也能稳稳接住几万个实体的原因。我最初思考这套方案时也产生过疑问既然URP和HDRP已经支持GPU Instance为什么还需要专门的Entities Graphics实际用下来才理解GPU Instance是“手动合批”你得自己管理和组织实例数据而Entities Graphics是在ECS框架内自动完成这一切。位置、旋转、缩放组件的原生数据直接作为实例化数据传给GPU中间几乎零拷贝零转换这个自动化流程才是它性能的核心优势。2. 核心原理深入ECS的数据思维是性能根源2.1 面向数据的思维转换从Class到Component Data用DOTS之前我的代码思维还停留在“类”和“对象”这种面向对象模型。比如写一个单位类里面塞了位置、血量、攻击力、动画状态。这种做法看着清晰但CPU执行时相当吃亏。每个单位对象分散在内存各处CPU要频繁等待缓存从内存加载数据这就是经典的“内存随机访问”问题。ECS要求你反过来思考把“对象的属性”拆成“平行的数据流”。一万个单位的位置全部放在一个NativeArray里头血量放另一个NativeArray里头需要处理位置时就只遍历位置数组需要处理血量时就只遍历血量数组。这种布局让CPU能连续读取数据Cache命中率百分之百效率自然飙升。我实际写代码时最深的一点感受是传统OOP先想“物体是什么”ECS先想“数据长什么样会被怎么处理”。这个思维转换一开始会很别扭习惯了以后再看老代码会觉得处处都是性能漏洞。2.2 Archetype与Chunk内存布局的奥秘ECS里有个叫Archetype的概念很多人第一次听觉得神秘其实它就是一个“组件类型组合”的分类。比如一个实体包含Position、Rotation、Health三个组件那么这三个组件的组合就是一个Archetype。相同Archetype的实体被分配在同一个Chunk里每个Chunk是一块连续内存内部按组件类型依次紧密排列。这个设计的厉害之处在于Unity的ECS框架查找实体时不需要遍历全世界而是直接定位到对应Archetype的Chunk然后一块一块地顺序处理。一万个实体如果Archetype一致实际就是几十个Chunk的连续内存访问。对比传统框架在HashMap里逐个查找对象的Component性能差异天上地下。用代码验证一下我新建了两种Archetype的实体各一万个一种是Position加Velocity另一种是Position加Velocity加Health。然后分别用System遍历更新位置。结果是第二种遍历速度比第一种慢大约百分之二十原因就是Health组件额外占用了内存带宽。这个数据说明Archetype设计直接影响运行时性能不是玄学。2.3 EntityQuery与System调度模型System是ECS里的处理逻辑单元但它不是主动遍历所有实体的而是通过EntityQuery筛选出自己关心的实体集合然后并行处理。比如移动System只关心有Position和Velocity的实体那查询时就只会命中对应的Chunk不会碰其他无关数据。这里的核心优化在于Job System的调度。写System时可以声明多个Job并行处理不同QueryUnity会根据当前机器的CPU核心数自动调度。我测试过一台8核16线程的机器Unity能同时跑十几个并行Job单位移动、旋转、边界检测这些逻辑分别在不同核心上执行互不阻塞。这个并行度在传统单线程框架下完全不敢想。同时你要理解System运行时不产生GC压力。ECS的组件数据全在NativeContainer里不受托管堆管理Job和Burst配合下几乎零分配。我在跑15000个单位的Demo时Profiler显示每帧的GC Alloc是0这个数字对Unity开发者来说梦里都不敢想。2.4 Entity Prefab与Prefab引用DOTS里创建实体用的是Entity Prefab它不是传统场景里的预制体而是一个包含ComponentData集合的模板。用代码实例化时不是Instantiate一个对象而是在对应Archetype的Chunk里追加一块数据。这种方式创建一万个实体大约只需要几毫秒而传统Instantiate做同样的事可能要花几秒钟。创建好实体后要把它渲染出来就需要一个EntityPrefab引用组件。这个组件会存储Mesh和Material的引用Entities Graphics在渲染时自动读取这些数据并合批。实际操作时要注意Entity Prefab里必须包含LocalToWorld组件否则渲染系统不知道实体在世界的哪个位置。这个坑我踩过后面实操章节会细说。3. 实操记录从零搭建万人同屏Demo3.1 环境准备与包安装开始实操前我得先说明版本坑。Unity从2022 LTS开始Entities包升级到了1.0系列API和之前的0.x版本有大量不兼容改动。网上很多教程还停留在0.5版本按那些教程写代码在1.0版本下根本编译不过。我用的环境是Unity 2022.3.10f1Entities包版本1.0.14Entities Graphics包版本1.0.14。安装方式很简单通过Package Manager打开搜索Entities和Entities Graphics安装即可。Unity会自动拉取依赖的Burst、Mathematics、Collections等包。这里提醒一句务必确认所有包版本一致Unity的DOTS包之间版本耦合得很紧混用版本经常出现类型不匹配的报错。3.2 创建Entity Prefab与渲染相关组件渲染要生效Entity Prefab需要挂上这些组件LocalToWorld、Position、Rotation、RenderMeshArrayEntities Graphics 1.0版本新改的组件早先版本叫RenderMesh、EntityPrefab引用组件。其中LocalToWorld会在Position或者Rotation变化时自动更新不需要手动处理。实操代码大概长这样using Unity.Entities; using Unity.Mathematics; using Unity.Rendering; using Unity.Transforms; public struct UnitData : IComponentData { public float3 MoveDirection; public float MoveSpeed; } public static class EntityFactory { public static Entity CreateUnit(EntityManager entityManager, Entity prefabEntity, float3 position) { Entity entity entityManager.Instantiate(prefabEntity); entityManager.SetComponentData(entity, new LocalToWorld { Value float4x4.TRS(position, quaternion.identity, new float3(1, 1, 1)) }); entityManager.SetComponentData(entity, new Position { Value position }); entityManager.SetComponentData(entity, new UnitData { MoveDirection new float3(0, 0, 1), MoveSpeed 2.0f }); return entity; } }这里有个细节Instantiate一个Entity Prefab时所有组件数据会从Prefab复制过来。所以可以先在Prefab上把LocalToWorld设成默认值然后实例化后再按需覆盖。SetComponentData过程中如果数据量太大会触发结构性变更导致卡顿正确姿势是先收集一批位置数据再用Instantiate批量接口创建等下我会专门讲这个。3.3 编写Job System做移动更新移动逻辑用IJobEntity写起来最简洁它可以自动匹配包含制定组件的实体并行执行更新逻辑。下面是我实测稳定运行的移动System代码using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; using Unity.Burst; [BurstCompile] public partial struct UnitMoveSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { float deltaTime SystemAPI.Time.DeltaTime; new UnitMoveJob { DeltaTime deltaTime }.ScheduleParallel(); } [BurstCompile] [WithAll(typeof(UnitData))] public partial struct UnitMoveJob : IJobEntity { public float DeltaTime; void Execute(ref LocalToWorld localToWorld, ref Position position, in UnitData unitData) { float3 newPos position.Value unitData.MoveDirection * unitData.MoveSpeed * DeltaTime; if (newPos.x 100f) newPos.x -100f; if (newPos.z 100f) newPos.z -100f; if (newPos.x -100f) newPos.x 100f; if (newPos.z -100f) newPos.z 100f; position.Value newPos; localToWorld.Value float4x4.TRS(newPos, quaternion.identity, new float3(1, 1, 1)); } } }这个System每秒执行一万五千个实体的位置更新加边界检测Burst编译后耗时大概只有零点几毫秒。对比传统Update里逐个移动GameObject这个速度是碾压级的。写的时候要注意Execute里的参数ref是读写in是只读类型匹配不对编译器会报错这也是IJobEntity的特点。3.4 可视化验证确保实体真的被渲染出来写完System之后需要在场景里生成实体来验证效果。我写了个简单的MonoBehaviour辅助脚本在Start里创建Entity Prefab并批量实例化using Unity.Entities; using Unity.Mathematics; using Unity.Rendering; using UnityEngine; public class UnitSpawner : MonoBehaviour { public GameObject unitPrefab; public int spawnCount 10000; public float spawnRadius 90f; void Start() { var world World.DefaultGameObjectInjectionWorld; var entityManager world.EntityManager; var entityPrefab GameObjectConversionUtility.ConvertGameObjectHierarchy(unitPrefab, world); for (int i 0; i spawnCount; i) { float angle UnityEngine.Random.Range(0f, Mathf.PI * 2f); float radius UnityEngine.Random.Range(0f, spawnRadius); float3 pos new float3(Mathf.Cos(angle) * radius, 0f, Mathf.Sin(angle) * radius); EntityFactory.CreateUnit(entityManager, entityPrefab, pos); } } }跑起来之后如果场景里出现了一万个快速移动的小方块说明整套链路已经通了。我最初跑的时候画面一片空白排查了半天才发现是Entity Prefab里的MeshRenderer组件没换成Entities Graphics需要的RenderMeshArray组件。手动把Prefab上的MeshRenderer移除再重新转换一次就好了。这个细节让我记忆深刻。4. 性能优化策略从万人到十万人的扩展路径4.1 结构性变更的代价与规避方法刚上手DOTS时最容易踩的坑就是频繁进行结构性变更。所谓结构性变更指的是增加或删除实体、修改实体的Archetype类型。每次结构性变更都会引发Chunk内存的重新分配和移动代价极高。如果用循环逐个Instantiate一万个实体每帧做一次帧数直接崩掉。正确做法是批量创建。Unity的EntityManager提供了Instantiate批量接口可以一次性创建大量实体底层会一次分配一整块Chunk。我的经验是创建一万个实体分十批每批一千个耗时只需要几毫秒。另一条重要经验是运行时尽量不要动态增删组件而是预先设计好Archetype让所有实体从头到尾保持结构稳定。我看过一些开源项目为了图方便在System里用EntityCommandBuffer做大规模实体生成结果性能惨不忍睹。EntityCommandBuffer本身是为了延迟销毁或创建实体设计的但如果每帧使用大量并行Job生成实体命令缓冲区的排序和提交成本会抵消ECS的并行优势。4.2 合批原理Mesh和Material一致性是合批的生命线Entities Graphics的合批条件是相同的Mesh、相同的Material、相同的Shader属性。只要有一个不一致合批就会断开。所以我在Demo里用了一个最简单的Cube加一套绿色Material一万五千个实体只用了一个Draw Call帧数极其稳定。换一个实际场景如果一万个士兵穿着不同颜色的衣服每套衣服对应一个Material变体合批就失效了Draw Call会飙到几千。解决方案是使用Texture Array或者自定义Shader通过每实例数据传入颜色偏移保持Material只有一个合批依然有效。我自己实测过用MaterialPropertyBlock的思路迁移到DOTS上发现虽然可以每实例更新颜色但如果数据变化频率太高反而会拖慢渲染速度。更推荐的做法是尽量把变化不频繁的属性烘焙进Mesh或者静态Shader属性里动态变化的数据控制在最小规模。4.3 运行时性能数据Profiler怎么解读优化时我最依赖的工具是Profiler和Entity Debugger。Profiler里重点关注两个指标一个是CPU Main Thread耗时另一个是Render线程耗时。DOTS方案下Main Thread应该非常空大部分计算都跑在Worker Thread上这样才能说明Job System生效了。另一个关键指标是Batches数量在Frame Debugger里可以直接看到。我的15000实体Demo里Batches数量只有个位数这说明合批非常成功。如果发现Batches数量突然飙升到几万第一反应应该是查看是否有实体用了不同的Material或Mesh。还有一个小技巧用Unity的Memory Profiler查看Chunk内存分配情况。如果发现大量Chunk只装了很少的实体说明Archetype碎片化严重。碎片化会导致内存浪费和遍历效率降低解决办法是检查代码里是否有频繁的组件增删操作。4.4 万级到十万级LOD与剔除的进阶策略万人同屏是目标但从万人到十万人单纯靠DOTS还不是全部。当实体数量达到十万级别即使逻辑计算扛得住渲染侧的顶点和像素填充率也成了瓶颈。这时候需要引入LOD和视锥剔除策略。Entities Graphics里可以做LOD通过给实体附加多个LOD层级和切换距离让远处的实体使用更简化的模型极大降低GPU负担。十万个Cube如果全部使用完整MeshGPU可能受不了但如果远处只画成极简的四边形性能表现会完全不同。剔除这块Unity的Entities Graphics自带视锥剔除超出相机范围的实体不会被渲染。但如果是球形包围体包围整个场景剔除精度有限复杂的遮挡关系还是得靠额外的空间数据结构处理。我自己在更大的项目里用到了静态场景的遮挡剔除配合DOTS的视锥剔除效果会比单独用视锥剔除好不少。5. 常见问题与排查技巧实录5.1 实体不显示从代码到数据的逐步排查这是群里被问得最多的一个问题实体创建了、System在跑但屏幕上就是没有东西。我排查这类问题的固定路径是先确认World存在再确认Entity Prefab里有没有RenderMeshArray组件最后确认Camera有没有对着实体所在的区域。RenderMeshArray这个组件最容易出问题它是Entities Graphics 1.0版本的核心渲染描述组件必须包含Mesh和Material列表。很多从旧版教程迁移过来的项目还是用RenderMesh组件所以完全不显示。另外要确认World.DefaultGameObjectInjectionWorld不是空的如果场景里没有挂任何转换相关的组件这个全局World可能为空。还有一个隐藏坑是实体的Layer和Camera的Culling Mask不一致。Entities Graphics用的实体渲染也会受Layer影响虽然实体没有GameObject的Layer概念但通过RenderMeshArray绑定的渲染信息还是有Layer属性。排查时把Camera的Culling Mask改成Everything能排除掉这个干扰。5.2 结构性变更卡顿莫名掉帧的真凶用DOTS最揪心的体验是明明逻辑很简单帧数却突然掉到个位数。我排查这种问题时第一个怀疑对象就是结构性变更。例如每个实体的Status组件在一段时间后被移除或者切换了Archetype都会导致Chunk数据大面积重排。定位结构性变更有个好用的办法在Profiler的CPU模块里搜索EntityManager的InternalDestroyEntity或者StructuralChange相关方法。如果这些方法耗时异常就说明有代码在频繁改结构。我遇到过一次是因为一个System在每帧检测到实体血量为零就立马销毁实体结果一万多个实体同时死亡时那一帧卡了整整两秒。正确的销毁姿势是使用EntityCommandBuffer延迟销毁把销毁命令先缓存起来在System的OnUpdate末尾统一执行。这样同一帧的命令会被合并处理减少结构变更次数。按照这个方案改写后同样场景下实体同时死亡的帧耗时降到了几百毫秒以内玩起来就不会有瞬卡了。5.3 Burst编译错误与调试建议Burst编译器是DOTS性能的利器但也是调试时最头疼的部分。Burst编译检查比普通C#严格很多不支持指针、反射、部分接口等操作。我最常遇到的错误是在Job里使用托管数组或者List比如用List存放中间计算结果。解决方式是改用NativeList或NativeArray显式管理生命周期。另一个坑是Burst模式下浮点数运算的确定性。不同机器上Burst编译出的SIMD指令可能产生细微的浮点误差虽然大多不影响游戏表现但涉及需要精确同步的逻辑时要特别注意比如多人联机中的角色位置同步。如果实际业务必须使用精确浮点可以考虑把关键计算放在非Burst的System里执行。调试时建议先在Editor里关闭Burst编译只保留Job System跑一遍确认逻辑没问题再开启Burst。然后利用Burst Inspector查看编译后的机器码确认向量化指令是否真正生成了。我曾经排查过一段循环发现Burst编译器没自动展开是因为循环里有分支手动改写代码去掉分支后性能提升了一倍以上。5.4 主线程同步开销Job和主线程的数据交换Job System好归好但数据交换代价不小。如果你每个帧都在Job里算完再立即把结果读回主线程那Job并行执行的优势就被来回同步的开销抵消了。正确思路是让数据尽可能持续地停留在NativeContainer中主线程只在必要时刻读取。我的经验是Camera跟随、UI显示这类需要主线程读取的操作单独把结果复制到一个较小的NativeArray里只传递必要字段而不是把整个大数据集同步回来。另外要尽量避免在主线程直接访问Job正在写入的NativeArray这种竞争访问会出现不可预期的行为严重时直接闪退。5.5 版本变迁与学习资料避坑DOTS的版本迭代用“疯狂”两个字形容不为过。1.0版本相比0.5版本API改动巨大SystemBase换成了ISystemRenderMesh换成了RenderMeshArray。我建议学习时直接看Unity官方文档和官方GitHub仓库里的范例代码不要看早于2022年发布的第三方教程。还有一个小经验用Unity 2022 LTS版本搭配Entities 1.0包是最稳定的组合如果用Unity 6加Entities 1.2API虽然更新但社区资料还少踩坑成本更高。对刚接触DOTS的团队我建议先稳定复现官方Sample再向自己的业务场景迁移不要一上来就大改架构。6. 方案落地后的效果与展望把整套方案部署到实际项目中后我感觉最大变化不是帧数变高了而是开发思路完全变了。传统GameObject方案像管理一堆“活的玩偶”每个都要单独呵护DOTS方案像是建立一条流水线数据整齐划一地流经每个工位。这种思维的转变在项目后期做大规模战斗玩法时会显得特别有价值。另外一个值得说的点是DOTS方案和URP渲染管线的适配度比我预想中好。我原本担心Entities Graphics在URP下会出现兼容问题实测跑下来后处理、阴影、多相机渲染都没出幺蛾子。如果你的项目用了HDRP综合性能表现也完全在线但移动端的兼容性还有待进一步验证特别是GPU Instance在移动SoC上的表现不同机型差异比较大。我实际使用中最深的一个感受是用DOTS写代码不再是“写一堆逻辑让机器执行”而是站在数据流的角度思考把每一帧要做的事情拆解成数据加工流水线。这种思维方式一旦建立回头看很多老代码里的性能问题会有一种“一眼看穿”的感觉。如果你正在被大规模单位同屏的性能问题困扰把DOTS当作下一阶段的必修技术去学不会亏。最后分享一个个性化的小技巧调试阶段可以给不同Archetype的实体赋予不同颜色的Material这样从Draw Call数量和渲染批次上就能直观看到合批情况。用颜色区分数据分组比盯着Profiler的数字高效得多这招我至今还在用。