去年接手一个国战类项目,策划给我提的需求只有一行:“同屏 1 万个角色,中低端手机不能塌帧。”当时团队还在用传统 GameObject + URP 方案推进,引擎群里都在讨论 DOTS 和 Entities Graphics,但真正被逼着切过去,是在第一次千人压力测试之后——主线程直接干了 42ms,GPU 才用掉不到 30%,整个战斗画面卡成幻灯片。换到 Entities Graphics 与 DOTS 路线以后,同样的压力场景主线程降到 1ms 上下,同屏 1 万角色的渲染一侧彻底不再是瓶颈。这篇记录不是官方文档的翻译,而是我把一个真实万人同屏方案从立项到落地的完整复盘,内容包括原理、可运行的代码、Benchmark,以及那些文档里根本不会写的坑。
1. 传统方案卡在万人门槛:瓶颈不是显卡,而是主线程
1.1 万人 GameObject 的成本账单
先说清楚一件事:用传统 GameObject 做万人场景,卡死你的往往不是 GPU,而是主线程上的组件遍历和渲染数据准备。
每个 GameObject 至少拆成 Transform、Renderer、MeshFilter,如果角色还要播放动画,再多一个 Animator。一次只算 10000 个角色的基础数据:
- 10000 个 Transform:每帧要同步世界坐标,Unity 内部要把矩阵写进 NativeBuffer,这是主线程逐实例操作的。
- 10000 个 Renderer:剔除系统要遍历每个 Renderer 的包围盒做视锥裁剪,这是完整 O(n) 的主线程遍历。
- 10000 个动画骨骼:哪怕全部用 GPU Skin,最后驱动骨骼矩阵、写回渲染数据结构,还是有一大笔主线程开销。
- Leaderboard 级别的 MonoBehaviour 调用:Update、LateUpdate、OnEnabled 这些生命周期回调,每个都要做托管调用,调用了就得付出调用开销。
这里还没算 UI、技能表现、AI 决策、网络同步。就算你把渲染相关逻辑全部做过一轮优化,渲染系统内部也要遍历一次完整列表。我们把这份成本叠起来,在普通桌面 CPU 上,10000 个角色的系统级开销在 30-60ms 属于非常正常的情况。
我当时做了个最简单的实验:场景里放 5000 个 Cube,全部是同一个 Prefab,挂同一个 Material,用 SRP Batcher 合批。理论上这已经是传统方案里最友好的情况了,结果主线程帧耗时依然在 18ms 左右。为什么?因为合批解决的是 GPU 侧的 Draw Call 次数,CPU 侧该遍历的 Transform、该准备的矩阵、该过的剔除逻辑一步都少不了。
传统 GameObject 方案在万人规模的账单,本质是“逐个实例管理”付出的固定成本。每个 Renderer 都有独立的场景管理记录、独立的剔除记录、独立的渲染线程通信,数量一上去,这些固定成本线性增长,无论怎么合批都压不回来。
1.2 为什么调画质、裁剪视距救不了这个问题
最开始组里有人提出,“是不是场景太大、模型太重?把画质调到最低、视距砍到 20 米不就好了?”我实测直接否掉了这个方案。
画质调低,影响的是 GPU 的像素工作量,你的主线程 CPU 瓶颈纹丝不动。裁视距看着有效,但剔除本身就要先把完整列表过一遍,才能知道谁在视距内、谁在视距外。你裁掉一半场景,CPU 还是得从第一个物体遍历到最后一个物体。何况国战玩法里,远处的大部队阵型、旗帜、指挥标记全都得显示,策划不可能接受 20 米外全消失。
还有一个更隐蔽的问题:Unity 的渲染线程和主线程之间存在数据同步。传统渲染路径中,主线程要把每个实例的变换矩阵、包围盒、材质引用全部拷贝到渲染线程的缓冲里。这个拷贝也是逐实例进行的。如果 10000 个实例每个拷贝 64 字节,就有 640KB 的逐帧同步数据,再叠加隔离缓冲的分配、队列同步、GPU 提交,整体开销根本不是配置层面可以抹掉的。
这个阶段我们得出结论:万人同屏不是“把画面调低一点就能跑”的问题,而是架构层面 CPU 主线程处理模式的问题。于是决定全面转向 DOTS 架构,渲染部分用 Entities Graphics 重写。
2. Entities Graphics 的渲染机理:从“逐个实例管理”到“按块批量提交”
2.1 Entity 的数据组织方式改变了效率
要理解 Entities Graphics 为什么能扛住万人,得先理解 ECS 的数据组织方式。
Entity 不是一个包含一堆组件的 C# 对象,它更像一个索引。真正的组件数据全部摆放在 ArchetypeChunk 里,一个 Chunk 是 16KB 的连续内存,里面只放同一种组件组合的数据。比如“拥有 LocalToWorld + RenderBounds + RenderMesh 的实体”会集中放在一批 Chunk 里,几十个实体的 LocalToWorld 矩阵是紧挨着的连续内存。
这套布局给渲染带来的好处非常直接:
- 矩阵连续排列,Burst 编译后的 Job 迭代时,CPU 缓存命中率极高,几乎等效于顺序读一个 float4x4 数组。
- 不需要逐实例的 Transform 同步。LocalToWorld 本身就在 Entity 的组件里,是 ECS 系统直接写好的。
- 相同 RenderMesh 和 Material 通过 SharedComponent 分组,绘制提交按组进行,每次提交能带上一整批实例。
打个简单的比方:传统方案是客服中心一个一个接待用户,每个用户都单独排队、单独建档;Entities Graphics 是把同一个地区的用户名单直接打包给业务窗口,业务员拿到的是一整版名单,按名单批量放行。
2.2 最小可行环境搭建
我们当时的项目基于 Unity 2022.3 LTS,这一点建议直接上 LTS,DOTS 相关包在 LTS 版本上更稳。
需要安装的包主要有:
com.unity.entitiescom.unity.entities.graphicscom.unity.burstcom.unity.collectionscom.unity.mathematicscom.unity.jobs(通常会被自动带进来)
com.unity.burst和com.unity.collections一般作为依赖自动安装,但建议手动确认版本。然后是渲染管线,我们用的是 URP。Entities Graphics 本身也可以配合 HDRP 使用,但 URP 在移动端和桌面端的平衡性更好。
搭建步骤:
- 创建 URP 项目。
- 通过 Package Manager 安装上述包。
- 场景里创建一个空物体,挂上
SubScene组件。 - 把带 RenderMesh 的 GameObject 拖进 SubScene,在烘焙设置里勾选 Convert To Entity。
- 运行场景,确认实体被转换出来,并且有渲染数据。
这一步容易忽略的点是:直接放在 SubScene 里的 GameObject,烘焙后在运行时是真正的 Entity,不再有 MonoBehaviour。所以你在 Update 里通过FindObjectOfType是找不到它的。如果项目里还有大量旧逻辑依赖 GameObject 查找,这个坑会在切换瞬间集中爆炸。
2.3 一个能跑通的万人实体生成器
渲染依托都搭好以后,写一个生成器系统来验证万人场景。
以 DOTS 1.0 的ISystem+SystemAPI风格为例:
using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; using Unity.Rendering; [BurstCompile] public partial struct HeroSpawnerSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { foreach (var (spawn, entity) in SystemAPI.Query<RefRW<SpawnPointData>>().WithEntityAccess()) { if (spawn.ValueRO.PreSpawned > 0) { spawn.ValueRW.PreSpawned = 0; for (int i = 0; i < spawn.ValueRO.Count; i++) { Entity prefabEntity = spawn.ValueRO.HeroPrefab; Entity instance = state.EntityManager.Instantiate(prefabEntity); float x = (i % 100) * 2f; float z = (i / 100) * 2f; float3 pos = new float3(x, 0f, z); state.EntityManager.SetComponentData(instance, new LocalToWorld { Value = float4x4.TRS(pos, quaternion.identity, 1f) }); } } } } }配套的SpawnPointData组件:
using Unity.Entities; public struct SpawnPointData : IComponentData { public Entity HeroPrefab; public int Count; public int PreSpawned; }这个系统的核心逻辑是:启动后第一帧读一次生成点数据,把 10000 个实体实例化出来,然后立刻把PreSpawned置为 0,避免后续每一帧重复生成。真正跑起来时,只要你的 Prefab 烘焙正确,10000 个同样式的角色会被自动归到同一批 Chunk 里,剔除与合批都由 Entities Graphics 接管。
我特别强调PreSpawned这个字段,是因为很多人第一次写 DOTS 生成系统都会忘了加“只执行一次”的标记,导致每次 OnUpdate 都再生成一万人,帧率瞬间崩掉。ECS 里没有 MonoBehaviour 的Awake/Start语义,这种一次性初始化要么靠运行时标记,要么靠state.Enabled = false关闭系统,否则就得承受每帧重复执行的后果。
3. 决定万人稳定性的三道关卡:剔除、局部性、GPU 带宽
3.1 剔除按块并行:RenderBounds 是生死线
在传统管线里,每个 Renderer 的剔除是在主线程做某项预处理,然后再交给渲染线程。Entities Graphics 的做法不同,它把剔除也做成了 Job,按 Chunk 并行跑。哪个实体需要画、哪个实体需要被视锥剔除,全部在 Worker Thread 上完成,主线程几乎不参与。
但这里有一个极其重要的前提:每个实体必须要有正确的RenderBounds。
如果你用的实体是通过 SubScene 里挂 RenderMesh 的 GameObject 烘焙出来的,Bounds 会自动计算。但如果你像我一样,某些实体是纯代码创建的 Entity Prefab,没有经过烘焙流程,那就必须手动设置RenderBounds,否则 Entities Graphics 会认为这个实体“无处不在”,视锥剔除永远放行,最终呈现出来的画面就是所有实体全部绘制,哪怕是屏幕外的也一个不落。
手动设置代码如下:
state.EntityManager.SetComponentData(instance, new RenderBounds { Value = new Aabb { Center = float3.zero, Extents = new float3(0.5f, 1f, 0.5f) } });这里 Center 是实体局部坐标下的包围盒中心,Extents 是半边长。你不用做的非常精确,但一定不能让中心缺失或者范围过大。范围过大的后果是剔除效率下降,范围缺失的后果是彻底失去剔除能力。
我们项目里还叠加了一层 AOI 裁剪。万人国战的实体本身就是由服务器 AOI 管理可见列表的,本地收到列表后,不在列表里的实体可以直接把Enabled属性设为 false。这个操作比任何渲染侧剔除都更彻底,因为实体直接不参与剔除和渲染提交了。渲染侧再叠加一层视锥剔除和距离 LOD,三层漏斗下来,实际提交到 GPU 的实例数通常只有总人数的 20%-30%。
3.2 Chunk 数据局部性:Spawn 的方式决定了缓存命中率
这点很多人会忽略。就算你用了 ECS,Spawning 的方式不对,照样可以把自己写到沟里。
ECS 的性能优势建立在 Chunk 连续内存之上。而 Chunk 的排列规则由 Archetype 决定,相同组件组合的实体放在同一组 Chunk 里。如果你在生成时穿插了不同 Prefab、不同组件组合,archetype 会被打碎成很多个小块,Chunk 填充率下降,缓存局部性变差,迭代性能也会跟着下降。
举个反面案例:我们一开始是随机遍历一个包含英雄、士兵、弓箭手、马匹的大列表,每生成一个人就 Instantiate 一个对应的 Prefab。运行后一查 Chunk 利用率,一堆 Chunk 只填充了两三个实体,整个渲染系统的迭代毛刺非常明显。
后来改成按区域批量生成:
- 一个 8x8 网格区域内,先全部生成士兵。
- 再切到下一个区域,生成弓箭手。
- 最后统一生成英雄。
相同 Prefab 的实体尽可能连续进入世界,Chunk 填充率马上上去了,同屏一万人时的系统迭代耗时从 2.3ms 下降到 0.6ms。这一版只是改了一下生成遍历顺序,没有任何渲染逻辑变化。
这也解释了为什么很多人用 Entities Graphics 跑万人依然卡:不是 Entities Graphics 不行,是他的数据布局把架构优势全浪费了。
3.3 顶点数与 Overdraw:GPU 侧的负担不是玄学
CPU 侧解决后,GPU 侧也不是没有底线。万人场景下,如果每个角色模型都按 4K 手游标准做,面数轻松突破百万级三角形,再叠加大量半透特效,GPU 带宽和 Overdraw 会成为新的瓶颈。
我们的经验模型是:
- LOD0:近距离精细模型,2000-3000 三角形,主要用于屏幕占比高、玩家能看清脸的场景。
- LOD1:中距离模型,800-1000 三角形,用简化蒙皮或纯骨骼驱动。
- LOD2:远距离模型,100-300 三角形,甚至可以使用 Billboard Imposter。
为什么这样分?因为万人场景里真正走 LOD0 的实体可能只有几十个,绝大多数实体都停留在 LOD1 和 LOD2。如果全场景都用 LOD0 的高模,GPU 要处理的三角形数量会从几十万一路飙到几千万。说实话,即使渲染管线能提交,GPU 光栅化和带宽也扛不住。
Overdraw 方面要尤其注意半透明材质和粒子。模型大面积叠加半透明描边、全屏受击特效这种东西,屏幕填充率会瞬间爆炸。我自己在调优时,会开启 URP 的 Overdraw 可视化,把同屏 Overdraw 控制在 150% 以内。超过这个值,优先砍特效层数和角色外描边,而不是去调模型面数。
4. 实战调优:从“能跑”到“跑得漂亮”
4.1 LOD 三级配置与切换距离
在 Entities Graphics 中配置 LOD,最直接的做法是给 Entity Prefab 挂上 Unity 的LODGroup组件,下面挂三个不同精度的 RenderMesh 子物体。烘焙到 SubScene 后,运行时渲染系统会根据实体与摄像机的距离自动选择对应 LOD,不需要自己写管理逻辑。
我建议的三档切换距离:
- 0-30 米:LOD0,完整模型。
- 30-80 米:LOD1,简化模型。
- 80 米以上:LOD2,极低模或 Imposter。
这里有个容易踩的坑:LOD0 的数量一定要做预算。就算有 LOD 系统,如果摄像机动一下让 2000 个实体同时进入 LOD0 范围,瞬间的三角形提交量也会爆。所以在生成时,我会给每个实体加一个距离预算组件,根据玩家周围的实际密度做微调。如果某个方向 30 米内聚集了超过预期的人数,就把 LOD0 切换距离从 30 米压到 20 米,优先保证帧率。
4.2 与 AOI、流式加载配合:先过滤再渲染
万人同屏场景不能只靠渲染侧硬扛,逻辑侧一定要先做一轮过滤。
我们项目的做法是这样的:
- 服务器 AOI 下发可见实体列表。
- 本地逻辑系统把不在列表内的实体
Enabled = false。 - 列表内的实体再参与渲染侧的视锥剔除、LOD 选择和 GPU 提交。
从实际效果看,万人在线时,单帧内真正有资格进入渲染通道的实体大约在 3000-5000 个。这个数字降到这个量级后,渲染压力已经非常接近传统大规模网游的日常水平。
另外,大世界会拆多个 SubScene 做流式加载。区块切换时,不要让实体直接卸载。我们维护了一个实体池,角色进出区块时只是启用或禁用,避免反复 Instantiate/Destroy。Instantiate 本身在 DOTS 里不便宜,万人规模下频繁创建销毁,GC 压力会指数级上升。
4.3 实测数据参考:一套可以抄作业的优化顺序
我们的测试环境是 Ryzen 7 5800X + RTX 3070 + 1080p 分辨率,URP 管线,1 万个角色,三级 LOD,开启 AOI 过滤。
| 场景规模 | 主线程帧耗时 | 渲染线程耗时 | GPU 耗时 | 内存增量 |
|---|---|---|---|---|
| 1000 人,无 AOI,纯 LOD | 0.8ms | 1.2ms | 2.1ms | 约 180MB |
| 5000 人,无 AOI,纯 LOD | 1.1ms | 2.0ms | 3.4ms | 约 850MB |
| 10000 人,有 AOI,LOD | 1.2ms | 2.3ms | 4.1ms | 约 1.6GB |
注意内存增量,人物模型、材质和合批数据是大头。优化顺序我建议严格按这个步骤来:
- 先查主线程各 ECS 系统的耗时,把非渲染系统的瓶颈压下去。逻辑系统没优化好的话,渲染再快也白搭。
- 再查渲染数据准备阶段的耗时,重点看 Chunk 迭代和剔除。
- 然后查 LOD 和 RenderBounds 是否正确,确保远距离实体和屏外实体真的被削掉了。
- 最后才看 GPU 侧的 Overdraw 和模型面数。
如果一上来就优化模型面数,结果 CPU 侧瓶颈还在,收益会非常有限。我们团队后来的习惯是:先用简单几何体跑通整个链路,再做美术模型替换。几何体场景能快速暴露 Culling、LOD、Chunk 缓存问题,换成高模后反而容易掩盖架构层面的短板。
5. DOTS 万人同屏开发中的避坑记录
5.1 Burst 代码里的随机数陷阱
在 DOTS 的 Job 里写随机数,第一反应别再调UnityEngine.Random。Burst 编译的代码无法调用托管 API,而且UnityEngine.Random本身有全局状态,在多线程 Job 里调用会引发大量竞争,结果不可控。
我自己写过一个哈希随机函数:
using Unity.Mathematics; public static class RandomHash { public static float Hash01(uint seed, uint index) { uint h = seed * 747796405u + index * 2891336453u; h = (h ^ (h >> 13)) * 1274126177u; h = h ^ (h >> 16); return (h & 0xFFFFFFu) / 16777215f; } }调用时传入固定的种子和实体序号,就能得到稳定复现的随机值。这在生成阵营阵型、随机偏移位置、随机翻滚角度时都很有用,而且完全能在 Burst 里跑。
5.2 系统更新顺序:渲染数据要在正确的时机写
很多新手会踩“我更新了 Transform,但画面纹丝不动”的坑。DOTS 里没有 MonoBehaviour 的隐式回调,组件的修改必须发生在渲染系统读取之前。一般来说,修改LocalToWorld的移动系统放在LateSimulationSystemGroup中即可,它会在渲染系统之前运行。
如果你需要修改渲染相关的组件,比如 RenderBounds、LOD 相关数据,就要再加一层[UpdateInGroup(typeof(PresentationSystemGroup))]的控制,确保数据在 Entities Graphics 自身系统之前写入。
[UpdateInGroup(typeof(PresentationSystemGroup))] [BurstCompile] public partial struct RenderBoundsUpdateSystem : ISystem { // 在这里写渲染侧数据更新 }这里的核心不是 API 背诵,而是记住一个原则:渲染是最后一个环节,对渲染数据的写操作一定不能晚于渲染系统执行。
5.3 材质实例、RenderMeshArray 与 GraphicsBuffer 容量
万人同屏最忌讳给每个角色单独实例化材质。一个角色一个材质实例,意味着渲染时可能要拆成 10000 个 Draw Call,Entities Graphics 再强也顶不住这种拆法。
正确做法是尽量复用少量材质,颜色差异用材质属性数组或者 Texture Array 解决。我们在项目里把阵营颜色、血量条、发光强度全部拆到了实例化数据里,材质本身保持个位数。
另外要留意 GraphicsBuffer 容量。某些版本下,渲染实例数据缓冲区的容量是有限制的,一旦实体数量超过上限,超出部分可能直接不绘制,而且不会报错,只会表现为“偶尔有人消失”。遇到这种情况,优先检查包版本,然后看渲染实例缓冲区的配置,必要时升级 Entities Graphics 到较新版本。
5.4 SubScene 切换与卸载的引用残留
大世界流式加载时,SubScene 的切换会带来一个隐蔽问题:渲染资源还没卸载干净,实体已经先被销毁,导致材质或网格引用残留,画面出现闪白或者紫皮模型。
我们的处理方式是:在卸载 SubScene 之前,先把其中所有实体的Enabled设为 false,让它们退出渲染系统,再请求卸载。这样渲染侧不会再尝试访问这些实体,资源释放时也不会产生悬空引用。
SceneSystem.RequestSceneLoad(worldUnmanaged, sceneEntity, SceneSystem.RequestFlags.Load);切换到新区块的逻辑里,一定会先处理一帧“禁用”状态,再走加载/卸载流程。整个过程虽然没有直接提升性能,但规避了大部分可见画面异常。
如果让我总结这条路线里最值得沉淀的经验,不是某个 API 的用法,而是数据流设计。现在我接到类似万人同屏的需求,不会先伸手加包,而是先画一张数据流图:逻辑系统把什么数据写进哪些组件,渲染系统怎么读,剔除系统在哪里拦截。数据流清楚了,Entities Graphics 的表现往往比预期稳定很多。
最后分享一个我的工作习惯:项目里永远保留一个只有几百个圆球 Mesh 的烘焙场景,用几何体替代美术模型做性能回归。圆球场景能把 Culling、LOD、Chunk 缓存问题全部暴露出来,配合帧时间面板,定位问题比在真实美术模型上排查快得多。各位如果真的在万人同屏上卡了很久,不妨先试试把所有美术资源换成一个球,看看剩下多少性能余量。