做类萌宠宠之战这种游戏,最让我上头的画面不是什么高清大世界,而是战斗刚开的那个瞬间:上万只宠物从四面八方涌向同一个目标,屏幕上密密麻麻全是单位,压迫感直接拉满。但这份压迫感首先压垮的是程序员自己。我第一版原型用传统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
- 空间与移动:LocalTransform(Unity自带)、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,把它放进SubScene,Unity会把场景中的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 : Baker<PetAuthoring> { 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字节,接近240KB,8个动画就是约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 | 中高配PC(i7/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这类API,Unity会强制产生一个同步点,让所有工作线程停下来等主线程处理。这个同步点一多,Job并行带来的性能收益全部白给。排查的时候我习惯在Entity Debugger(Window -> 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,效果会完全不一样。