
做类萌宠宠之战这种游戏最让我上头的画面不是什么高清大世界而是战斗刚开的那个瞬间上万只宠物从四面八方涌向同一个目标屏幕上密密麻麻全是单位压迫感直接拉满。但这份压迫感首先压垮的是程序员自己。我第一版原型用传统GameObject写了8000个单位编辑器一运行主线程单帧耗时冲到40毫秒别说手机连着台式机都卡成幻灯片。没办法只能老老实实上DOTS。其实这个项目的真实需求并不复杂UNITY3D下搞一套万人同屏的单位表现核心手段就是DOTS。这里说的“万人同屏”不是一万个真人玩家而是战场上同时存在一万个可战斗、可移动、可播放动画的宠物单位。类萌宠之战的玩法天然需要这种大场面一个是开局冲锋一个是后期缩圈式的大混战单位数量低了你根本感受不到“宠物大军”这四个字。这篇文章把我在这个项目里从头到尾的思考、选型、落地、踩坑完整过一遍内容包括ECS / Job System / Burst的分工、从Prefab到Entity的转换流程、万人同屏渲染和动画的优化方案以及最终的性能参数和排查经验。适合正在调研DOTS、或者准备把手游单位层从GameObject迁到ECS的开发者参考。1. 项目背景与核心痛点拆解1.1 “万人同屏”到底是一个什么问题很多人一听到“万人同屏”就觉得是渲染问题Mesh多嘛上GPU Instancing不就完了真做起来才发现渲染只是最表层的部分。单位一多CPU侧的三大瓶颈会依次爆掉逻辑更新、渲染提交、内存布局。逻辑更新不用多说一万个单位每个都要移动、检测战斗、播放动画传统MonoBehaviour每帧调用一次Update一万次函数调用本身就够呛。渲染提交指的是CPU向GPU提交渲染命令的过程即使GPU画得快CPU这边如果每次都要处理一万个网格实例的状态同步帧率照样被拖垮。内存布局则是最容易被忽视的GameObject的Transform是树形结构每个组件都分散在堆内存的不同位置CPU遍历一万个单位时缓存命中率低到可怕数据还没读完时间已经没了。打个比方传统GameObject方案就像一个快递分拣站只有一条传送带一个分拣员每来一个包裹他都要跑去找对应的货架再把包裹放上去。DOTS的思路则是提前把所有包裹按收货地分好类让一百个分拣员同时处理各自负责的那一堆人效自然完全不在一个量级。1.2 为什么传统GameObject方案撑不住我一开始也天真地以为只要把单位做成预制体光照关掉阴影关掉纹理压缩一下8000个单位总能跑起来吧。实测结果非常打脸一万个单位对应一万个Transform组件每个Transform上层的矩阵计算、层级同步、事件通知构成一条巨大的同步链。MonoBehaviour的Update调用是分散的数据访问模式混乱CPU分支预测和缓存预取基本失效。SkinnedMeshRenderer带来的骨骼动画更新是致命伤一万个角色等于每一帧都要算上万根骨骼的绑定矩阵CPU根本扛不住。物理系统更是不用想BoxCollider加上一万个Rigidbody物理引擎的Broadphase直接卡死。对这些症结常规优化手段比如对象池、LOD、GPU Instancing本质上都是在旧架构上打补丁。你会发现优化完一轮卡顿只是从40帧变成30帧并没有本质改善。原因是老架构的数据组织方式天生就不适合大规模并行。1.3 DOTS在这个项目里的定位DOTS是Unity官方推出的面向数据的技术栈核心是ECS、Job System、Burst Compiler三件套。但它不是来替代渲染管线的渲染层该用URP还是URP该用HDRP还是HDRP。DOTS替代的是“游戏对象”那一层的组织方式和执行方式。这个定位一定要想清楚不然很容易做成一锅乱炖。我做这个项目时定的原则是逻辑层全部Entity化渲染表现层选择性地保留传统GameObject比如UI特效、屏幕后处理、大范围粒子等验证完单位层稳定了再把边缘系统逐步迁过来。这样既保证核心的万人单位场景不卡又不用把整个游戏架构一夜推翻。2. DOTS技术栈选型与单位架构设计2.1 ECS、Job System、Burst各管哪一段很多人一上来就写代码写着写着发现System跑不起来原因是三件套的边界没搞清楚。我用自己的话理一遍ECS是把数据和行为解耦。一个宠物不再是“一个对象”而是一个Entity一个整数ID加一组Component纯数据。移动速度、当前位置、阵营、血量、动画状态全是独立的数据块。系统调度的时候每次只处理一种数据比如移动系统只关心所有单位的位移数据战斗系统只关心所有单位的血量和攻击力数据互不干扰。Job System解决的是多核问题。传统的游戏循环是单线程主线程干完一件再干下一件C# Job System允许你把类似的工作拆成Job在工作线程上并行执行。以移动为例一万个单位的位置更新可以切成几块同时交给几个线程去算互不冲突。Burst Compiler则是把Job里的C#代码编译成高度优化的机器码。普通的C#代码是托管代码有GC、有虚调用性能天花板有限加了[BurstCompile]之后编译器会把代码直接转成SIMD指令循环展开、内存对齐全部自动处理性能提升通常在几倍到几十倍之间。三个环节缺一不可。只引入ECS但不做Job化还是在主线程跑只做Job化但数据结构还是零散的对象Job内部照样缓存命中率低加了Burst但数据访问模式混乱再优化也发挥不出来。2.2 宠物单位的组件与系统设计做万单位架构第一步不是写System而是设计Component。组件设计直接决定了数据在内存里怎么摆放、系统能不能高效批处理。我建议按这个思路拆分身份与阵营EntityId、TeamId空间与移动LocalTransformUnity自带、MoveSpeed、TargetPosition、MoveDir战斗Health、AttackPower、AttackRange、NextAttackTime表现AnimState用int枚举表示当前动画、AnimTime、ColorIndex用于实例化颜色变体逻辑辅助UnitAliveTag、UnitDeadTag所有组件都必须是纯结构体不能有引用类型、不能有字符串、不能有List。你可能会问为什么连字符串都不行因为在ECS里数据是按块连续存放的每个Entity的组件放在同一个Chunk里如果塞入引用类型会造成数据跳转之前的优化就全废了。System这边我不建议一个PetSystem塞所有逻辑。合理的拆分是移动系统、寻路系统、排斥系统、战斗系统、动画系统、销毁系统。每个系统只处理自己关心的那部分数据。好处是Job之间的依赖关系清晰能并行执行的就并行执行互不阻塞。这里有一条很关键的原则系统尽量不要对Entity做增删操作也就是所谓的结构变更。结构变更会触发内存整理导致整个System执行中断所有Job都要等它完成。单位死亡时如果一帧内销毁一万个Entity那卡的酸爽你能想象。所以死亡单位先打上一个DeadTag然后由一个专属的回收系统分批处理。2.3 渲染层为什么也要跟着重构单位层的逻辑迁到ECS之后如果渲染还是老一套比如每个单位保留一个GameObject用来显示Mesh等于核心逻辑做了并行化渲染提交又回单线程瓶颈依旧。所以渲染层必须同步改造。好消息是Unity官方提供了Entities Graphics包它让你可以用ECS的方式渲染大量Mesh实例底层自动走GPU Instancing和SRP Batcher。只要把Mesh、Material、LocalToWorld这些组件加到Entity上渲染器会自动批量提交。坏消息是纸上谈兵容易实际操作还要处理几个细节材质必须尽量统一不同的材质球会导致渲染批次直接翻倍每个单位的LOD不能全用同一套得按距离降配一万个单位同时采样骨骼动画是不现实的动画方案必须重构这一点我会在下一节展开讲。3. 萌宠单位的实体化改造与渲染方案落地3.1 从Prefab到Entity的完整流程在Unity 2022和Unity 6.x版本下官方主推SubScene加Baker这套流程。简单说你先在场景里摆一个Prefab把它放进SubSceneUnity会把场景中的GameObject烘焙成Entity这个烘焙逻辑写在Baker脚本里。假设我有一个PetAuthoring挂在预制体上用来配置移动速度、阵营、网格和材质对应的Baker大概长这样using Unity.Entities; using Unity.Rendering; using Unity.Transforms; using UnityEngine; public class PetAuthoring : MonoBehaviour { public float moveSpeed 3f; public int teamId 0; public Mesh mesh; public Material material; } public class PetBaker : BakerPetAuthoring { public override void Bake(PetAuthoring authoring) { Entity entity GetEntity(TransformUsageFlags.Dynamic); AddComponent(entity, new MoveSpeed { Value authoring.moveSpeed }); AddComponent(entity, new TeamId { Value authoring.teamId }); AddComponent(entity, new AnimState { Value 0 }); AddComponent(entity, new AnimTime { Value 0f }); AddComponent(entity, new RenderMeshArray( new Material[] { authoring.material }, new Mesh[] { authoring.mesh } )); } }这里有几个注意事项。GetEntity的TransformUsageFlags.Dynamic表示这个Entity的Transform需要每帧更新适合移动单位如果单位是静态的用TransformUsageFlags.WorldSpace能省掉不少开销。RenderMeshArray是Entities Graphics里用来关联Mesh和Material的组件你要是漏了它烘焙出来单位在运行时就完全不显示。烘焙完成后运行时就不再有GameObject了场景里只剩纯数据Entity。这个步骤完成后先别急着加业务逻辑用一个最简单的移动System让单位动起来验证管线是通的再往下走。3.2 宠物动画的顶点纹理方案万人单位的动画是最大的坑。常规SkinnedMeshRenderer是CPU/GPU逐帧计算骨骼矩阵再对每个顶点做蒙皮变换一万个单位同时跑动画光骨骼计算就能把一个高端台式机耗干。所以我这个项目最终采用了顶点纹理采样动画Vertex Animation Texture方案简称VAT。思路一句话就能说清把骨骼动画的结果预先离线烘焙成纹理每一帧对应一组顶点坐标。运行时在Shader里用当前时间索引采样纹理直接覆盖顶点的位置完全绕开骨骼计算。烘焙流程大概是这样的在DCC工具或者Unity内把宠物的每个动画片段按固定帧率30fps采样。将每一帧的网格顶点位置、法线数据写入Texture2D单动画用Texture2DArray。运行时单位上不再挂SkinnedMeshRenderer换成普通MeshRenderer材质使用带顶点采样的Shader。每个单位在动画系统里维护一个帧索引把动态帧号写入一个全局属性或直接更新Mesh实例数据。这个方案换来的收益是巨大的一万个单位的动画更新不再随单位数量线性增长压力从CPU转移到了GPU的纹理采样而GPU处理这种带宽友好型任务非常轻松。不过VAT也不是没有代价。模型顶点数越多烘焙纹理占的内存越大单动画时长越长纹理存储越重。一个1000顶点的宠物30帧动画用RGBAHalf格式存储大约需要1000乘以30乘以8字节接近240KB8个动画就是约1.9MB。一万个单位共享同一份动画纹理内存不随单位数量上涨但模型复杂度必须克制单宠物顶点控制在1000到2000以内动画时长控制在2到3秒以内否则内存会失控。3.3 渲染与剔除的最终配置单位多了以后渲染管线里的每个环节都会成为木桶短板。我最终的配置是这样渲染管线用URP不开HDRP。HDRP效果上限高但移动端和万人场景的负担太大了URP配合GPU Instancing足够出效果。材质尽量共用不同颜色通过PerInstance属性传入而不是用不同材质球。Unity的MaterialPropertyBlock在ECS渲染中对应的是URP的Instance Color这类属性能做成变体就做成变体。LOD三档近距离完整模型加动画采样中距离低模同样采样动画远距离用极低模甚至广告牌不采样动画。LOD过渡距离按屏幕占比调宁可中距离就切低模也不能让极远单位消耗过多填充率。阴影直接全局关闭。一万个单位开实时阴影等于自杀阴影需求用烘焙光照贴图和假阴影贴花来解决。遮挡剔除和视锥剔除必须开。Unity自带的Occlusion Culling在开放场景里可能效果不大但单位密集时能砍掉相当大一部分不可见实例。这一套配置跑下来Draw Call被压在50以内渲染提交的CPU耗时降到2毫秒以内才算是真的把渲染管线的水龙头拧开了。4. 万人同屏系统中的关键实现与踩坑过程4.1 移动与寻路系统的并行化实现移动系统是最适合Job化的系统之一因为它天然每个单位独立。最早我用ISystem加SystemAPI.Query在主线程遍历跑一万个单位发现性能提升没有预期大后来改成IJobEntity加ScheduleParallel帧率才开始明显好转。using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; [BurstCompile] public partial struct PetMoveJob : IJobEntity { public float DeltaTime; [BurstCompile] public void Execute(ref LocalTransform transform, in MoveSpeed speed, in TargetPosition target) { float3 toTarget target.Value - transform.Position; if (math.lengthsq(toTarget) 0.01f) return; float3 dir math.normalizesafe(toTarget); transform.Position dir * speed.Value * DeltaTime; } } [BurstCompile] public partial struct PetMoveSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { PetMoveJob job new PetMoveJob { DeltaTime SystemAPI.Time.DeltaTime }; job.ScheduleParallel(); } }几个细节值得拎出来说。math.lengthsq比math.length少一次开方单位数量上去后能省不少计算。normalizesafe可以避免目标点与当前位置重合时产生NaN这个坑我在初期池塘里踩过单位走到目标点后瞬间瞬移几公里排查半天发现就是除零问题。Job里不要打印日志Debug.Log在Job里会导致主线程同步甚至直接报错调试要放在主线程系统里做。寻路部分我没有走传统A*。一万个单位各自寻路是不现实的我采用的是流场寻路在地图上预生成目标方向的网格流场每个单位只查询自己所在格子的方向向量。类萌宠之战的场景大多是开阔地形单位和单位之间的遮挡很少流场完全够用计算量从每单位递归式寻路变成了每单位一次纹理采样。4.2 战斗与技能系统的降频设计万人同屏场景下如果战斗逻辑也每帧判定一次CPU压力会非常明显。我的做法是把战斗判定做成低频Tick每隔0.1到0.2秒结算一次攻击范围、伤害、暴击。这类似服务端的战斗判定设计客户端表现上肉眼几乎感知不到延迟但CPU能省出好几倍余量。需要特别注意结构变更的问题。单位死亡时如果直接在战斗系统里DestroyEntity会破坏正在运行的Chunk迭代导致Job调度停滞。我的做法是死亡单位打上DeadTag然后在统一的回收系统里每帧限量销毁比如每帧最多销毁500个剩余单位等下帧处理。实测下来一万个单位同时阵亡也不会出现瞬时卡顿帧率曲线平滑很多。技能和Buff也不要做成每个单位一个脚本控制。领域效果范围加血、范围减速可以用一个区域Entity来管理单位进入区域时读取共享Buff配置将效果写入自身的Buff组件。这种共享数据模式在万人场景里能避免大量重复初始化。4.3 万人同屏的实战参数与性能目标这个项目跑下来我总结了一份比较实际的目标参数。注意这是“类萌宠之战”这类场景的参考值不是所有游戏都适用但可以作为初版优化目标。指标目标值备注同屏单位数10000逻辑和渲染都按这个量级设计PC帧率60 FPS中高配PCi7/RTX 3060级别移动端帧率30 FPS中端手机需配合降配处理单宠模型面数1000 - 2000高模只留给近景特写单动画时长≤3秒过长导致VAT纹理内存偏高同屏Draw Call≤50依赖材质合并和实例化单位层CPU耗时≤6ms移动、战斗、动画逻辑合计这些数字不是拍脑袋定的。单位层CPU耗时如果超过6毫秒加上渲染提交、UI、物理、特效单帧总耗时很容易逼近16.6毫秒的60帧红线。CPU耗时和帧率之间要留出至少20%的余量这是做游戏性能优化最基本的安全意识。5. 常见问题与排查技巧实录5.1 单位渲染不出来或直接消失这是刚接触Entities Graphics时最常见的现象SubScene里明明摆了模型运行起来画面却空空如也。排查顺序一般是这样先看SubScene有没有进入烘焙状态如果场景处于Dirty状态且没保存烘焙根本没执行。再看Baker有没有正确执行给Baker加Debug.Log是不行的因为烘焙发生在编辑期更可靠的是看Console里的Bake日志。最后确认Entity上有没有完整的RenderMeshArray组件以及Mesh和Material字段是否为空。有一次我排查了半天发现是灯光没开。用Entities Graphics渲染的单位默认需要场景里的Light组件来照亮如果场景是纯黑测试环境单位其实渲染了只是拍照黑得看不见。这种低级错误在熬夜改架构的时候很容易犯。5.2 Job不并行、帧率反而更差有时候代码看着没问题System也挂了[BurstCompile]但帧率就是上不去。这时候优先怀疑两件事一是有没有在主线程访问了GameObject或MonoBehaviour二是有没有在Job里频繁分配托管内存。这里有个常见的隐蔽问题System里如果直接调用了Instantiate、Destroy这类APIUnity会强制产生一个同步点让所有工作线程停下来等主线程处理。这个同步点一多Job并行带来的性能收益全部白给。排查的时候我习惯在Entity DebuggerWindow - Entities - Hierarchy里看每个System的耗时如果某个System呈现明显的串行尖峰十有八九就是它触发了结构变更。另一个很隐蔽的问题是查询匹配了多余的组件。SystemAPI.Query里如果你查询的组件集合比实际需要的多Unity会把大量不匹配的单位也遍历一遍看似并行实际做了大量无效工作。查询条件永远只写当前逻辑真正需要的最小集合。5.3 内存异常与真机闪退万人单位的项目在真机上比PC上容易出问题得多。最常见的是内存超限因为各家手机的内存预算就那么几个G单位模型、动画纹理、UI资源一叠很容易在低端机上闪退。我这边验证过的取舍是单位模型纹理全部走ASTC格式压缩PC上用的BC7在移动端部分机型会出兼容性问题。动画纹理能减帧就减帧2秒动画30帧就够了别上60帧。阴影、后处理、HDR特效全部降级低端机上直接关掉部分粒子。真机适配核心还是要以实际设备为准不要迷信编辑器里的Profiler数据。编辑器里CPU耗时6毫秒真机上可能变15毫秒中间那9毫秒就是移动端指令集差异和驱动开销造成的。所以性能方案永远要在目标机上做验收而不是在开发机上自我感觉良好。5.4 快速排查速查表现象可能原因检查顺序单位不显示SubScene未烘焙 / RenderMeshArray缺失 / 场景无光照查Console烘焙日志查Entity组件查灯光单位疯狂瞬移normalizesafe缺失 / 目标点重合产生NaN查移动Job查目标点数据帧率无提升结构变更频繁 / 同步点过多 / 查询组件过多看Entity Debugger各System耗时查同步点内存暴涨NativeContainer泄漏 / 动画纹理过多开Safety检查用Profiler查Native分配真机闪退纹理格式不兼容 / 内存超限压缩纹理、降模型面数、分批创建单位这张表是我每次做新项目时都会贴在自己工位上的一份清单。你未必马上能用上全部条目但等你真的踩到坑时回来翻一翻能省下至少两天的排查时间。从我自己的实操体验来看万人同屏项目里最值得投时间研究的不是某个写法的语法细节而是数据布局和批处理思维。我接手这个项目初期习惯了面向对象那一套脑子里全是“这个宠物自己的属性”“那个宠物自己的技能”差一点把ECS做成一个挂着DOTS名字的OOP游戏真做出来性能并不会比原来的GameObject方案好到哪里去。后来把视角切换到数据流之后整个架构才豁然开朗。如果你的项目也卡在万人同屏的单位表现上不妨先把传统对象概念放一放认真想清楚你的单位数据的形状和流向再动手写System效果会完全不一样。