尧图精选

Unity DOTS与Entities Graphics实战:万人同屏渲染性能优化全解析

🕒 发布时间:2026/10/2 15:35:07 📁 来源:尧图网络
去年接手一个国战类项目策划给我提的需求只有一行“同屏 1 万个角色中低端手机不能塌帧。”当时团队还在用传统 GameObject URP 方案推进引擎群里都在讨论 DOTS 和 Entities Graphics但真正被逼着切过去是在第一次千人压力测试之后——主线程直接干了 42msGPU 才用掉不到 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这一点建议直接上 LTSDOTS 相关包在 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 的ISystemSystemAPI风格为例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.QueryRefRWSpawnPointData().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 的方式决定了缓存命中率这点很多人会忽略。就算你用了 ECSSpawning 的方式不对照样可以把自己写到沟里。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 顶点数与 OverdrawGPU 侧的负担不是玄学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纯 LOD0.8ms1.2ms2.1ms约 180MB5000 人无 AOI纯 LOD1.1ms2.0ms3.4ms约 850MB10000 人有 AOILOD1.2ms2.3ms4.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 CallEntities 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 缓存问题全部暴露出来配合帧时间面板定位问题比在真实美术模型上排查快得多。各位如果真的在万人同屏上卡了很久不妨先试试把所有美术资源换成一个球看看剩下多少性能余量。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →