ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity DOTS万人同屏实战:Entities Graphics与ECS性能优化

Unity DOTS万人同屏实战:Entities Graphics与ECS性能优化 1. 万人同屏这件事卡点从来不在渲染两个字上先把结论摆在前面Unity 里做万人同屏真正拖垮帧率的往往不是 GPU 画不动而是 CPU 在主线程里被逐个对象处理活活拖死。你随便打开一个传统 GameObject 场景塞进去一万个带 MeshRenderer 的实体Profiler 里BehaviourUpdate、Culling、Transform这几项会直接爆表——因为每个对象都要走一遍 Transform 层级计算、剔除判断、材质属性设置、DrawCall 提交。一万个对象就是一万次这样的循环主线程一帧只有 16.6ms60帧的预算根本不够分。Entities Graphics前身是 Hybrid Renderer配合 DOTS 这套组合解决的正是这个结构性问题。它把每个对象一份数据变成一万个对象共享同一块连续内存把主线程逐个处理变成Job System 多核并行 Burst 编译成接近手写汇编的机器码。渲染侧则通过 BatchRendererGroup 把成千上万个实例合并成极少数几次绘制调用。这套东西不是优化技巧而是换了一套数据组织哲学。这篇内容适合谁看已经写过一些 Unity 常规项目、听说过 ECS 但没真正跑通过、想搞清楚万人同屏到底该怎么落地的人。我会从数据布局讲到渲染管线从环境搭建讲到实测调参把中间那些文档里不会写的坑一并交代清楚。全程围绕 Entities Graphics 和 DOTS 展开不跑题。需要先明确一个认知DOTS 不是某个单一功能它是 Data-Oriented Technology Stack 的缩写包含四块拼图——ECS实体组件系统负责数据组织、Job System负责多线程调度、Burst Compiler负责把 C# 编译成高性能机器码、以及 Entities Graphics负责把 ECS 数据喂给渲染管线。四者缺一不可单独拎出任何一个都跑不出万人同屏的效果。2. 把数据从对象搬进内存块ECS 到底改了什么2.1 传统 GameObject 模式为什么在万级规模下必然崩传统模式下一个敌人就是一个 GameObject身上挂着 Transform、MeshRenderer、Collider、各种 MonoBehaviour。这些组件在内存里是分散的每个对象的数据散落在堆的各处。CPU 读取时缓存命中率极低——你处理完 A 对象的 Transform跳到内存另一头去读 B 对象的 Transform缓存行通常 64 字节里大部分数据是用不上的。这就是所谓的缓存不友好。更致命的是MonoBehaviour 的 Update 是主线程串行调用的。哪怕你写的是空 Update一万个对象也要走一万次虚函数调用和托管堆检查。实测中一万个空 Update 的 GameObject 就能吃掉 3-5ms还没算任何逻辑。2.2 Archetype 与 ChunkECS 的内存布局核心ECS 的做法是把组件按类型分列存储。所有拥有相同组件组合的实体被归入同一个 Archetype原型。比如位置旋转缩放渲染网格是一个 Archetype位置旋转缩放渲染网格血量是另一个。每个 Archetype 内部数据被切成固定大小的 Chunk默认 16KB。关键在于同一个 Chunk 里所有实体的同一种组件是连续排列的。位置数据挨着位置数据旋转数据挨着旋转数据。CPU 遍历时一次缓存行加载能命中十几个实体的位置数据缓存命中率飙升。这就是所谓的 SoAStructure of Arrays布局和传统 AoSArray of Structures正好相反。我用一个直观的对比说明差异维度GameObject 模式ECS 模式内存布局对象分散在堆上组件按类型连续存储遍历方式主线程逐个 UpdateJob 多核并行遍历 Chunk缓存命中低频繁跳内存高顺序读取万级对象开销主线程直接爆分摊到多核可控渲染提交每对象一次或少量合批BatchRendererGroup 批量提交2.3 从 GameObject 到 Entity 的思维转换刚上手最容易犯的错是拿 ECS 当性能更好的 GameObject用。比如想给某个实体加个特殊行为第一反应是给它挂个脚本。但 ECS 里没有挂在实体上的脚本这个概念只有 System系统在遍历拥有特定组件组合的实体。正确的思路是反过来先想清楚我要对哪一类数据做什么批量操作再定义组件和系统。比如所有带 Velocity 和 LocalTransform 的实体每帧把 Velocity 乘 deltaTime 加到 LocalTransform 上——这就是一个移动系统。它不关心具体是哪个敌人只关心数据。这个思维转换是整套方案的门槛所在。转不过来写出来的代码会比 GameObject 还慢因为你在 ECS 里模拟 OOP等于同时吃了两边的亏。3. 环境搭建版本、包、渲染管线一个都不能错3.1 版本选择与包依赖Entities Graphics 对版本极其敏感。截至我写这篇时的稳定组合是 Unity 2022.3 LTS 搭配 Entities 1.x 系列。2021 及更早的版本用的是旧版 Hybrid RendererAPI 差异很大网上很多老教程直接照抄会编译不过。需要安装的包通过 Package Manager 的 Add package by namecom.unity.entities核心会连带拉入 Burst、Mathematics、Collectionscom.unity.entities.graphics渲染桥接com.unity.rendering.hybrid部分版本已合并进上面两个注意Entities 1.x 之后很多 API 从ComponentSystem改成了ISystem和SystemBaseEntityManager的用法也有调整。如果你搜到的教程里出现ComponentSystem基本是 0.x 时代的老货参考价值有限。3.2 渲染管线必须选 URP 或 HDRPEntities Graphics 不支持内置渲染管线Built-in。这一点没有商量余地。项目创建时就要选 URP 或 HDRP 模板或者手动把渲染管线资源配好。我实测下来万人同屏场景用 URP 更轻量HDRP 画质上限高但对 GPU 压力大看你的目标平台。URP 下还需要确认一件事SRP Batcher和 Entities Graphics 的兼容性。Entities Graphics 走的是自己的 BatchRendererGroup 路径和 SRP Batcher 是两套机制不要指望它们叠加。配置里把 Entities Graphics 的渲染路径理顺即可。3.3 项目设置里几个必改项Scripting Backend设为 IL2CPPBurst 在 IL2CPP 下才能发挥全部实力。Api Compatibility Level设为 .NET Standard 2.1 或更高。Jobs Leak Detection在开发期设为 Enabled能帮你抓出没释放的 NativeContainer。Burst Enable Compilation打开并勾选同步编译开发期否则第一次运行会有明显卡顿。这些设置看着琐碎但少一个就可能在后面某个环节莫名其妙报错。我踩过最坑的一次是 Api Compatibility Level 没调Burst 编译静默失败性能直接退回普通 C# 水平查了半天才发现。4. 渲染侧的核心BatchRendererGroup 怎么把一万次提交压成几次4.1 从 DrawCall 说起GPU 本身画三角形很快慢的是 CPU 每次提交绘制命令DrawCall的开销。传统模式下即使开了静态合批、动态合批、GPU Instancing一万个不同位置的对象也很难压到很低。GPU Instancing 要求材质和网格相同且每实例数据要通过MaterialPropertyBlock或实例化数组传入管理起来很麻烦。Entities Graphics 的 BatchRendererGroupBRG是更彻底的方案。它允许你把所有实例的变换矩阵、颜色、自定义属性打包成连续的 NativeArray一次性提交给 GPU。GPU 侧通过unity_ObjectToWorld等内置数组按实例索引取值。一万个实例可能就几次 DrawCall。4.2 渲染数据的自动同步你不需要手动往 BRG 里塞数据。Entities Graphics 会自动扫描所有带MaterialMeshInfo和LocalToWorld组件的实体把它们组织成批次。MaterialMeshInfo告诉它用哪个材质和网格LocalToWorld提供变换矩阵。系统在每帧的渲染阶段自动完成数据收集和提交。这里有个容易忽略的点LocalToWorld是渲染用的最终矩阵而LocalTransform是逻辑用的位置旋转缩放。两者之间需要一个系统来同步。Entities Graphics 提供了LocalToWorldSystem自动处理但如果你手动改了LocalTransform要确保同步系统在渲染前跑完。系统的执行顺序通过UpdateBefore/UpdateAfter特性控制顺序错了会出现逻辑动了但画面没动或者画面延迟一帧的现象。4.3 材质与网格的批量管理每个实体通过MaterialMeshInfo引用材质和网格。如果一万个实体用同一个材质和网格它们会被合并进同一个批次效率最高。如果分成十种材质就是十个批次仍然远好于一万次提交。但要注意材质变体会打断合批。比如你给一部分实体换了颜色如果通过不同材质实现就会多出批次。正确做法是用材质属性如_BaseColor配合每实例数据让 Entities Graphics 在同一个批次里通过实例化属性区分颜色。这需要在 Shader 里声明对应的实例化属性URP 的 Lit Shader 默认支持一部分自定义 Shader 要手动加。5. 让 CPU 真正跑起来Job、Burst 与系统调度5.1 ISystem 与 SystemBase 的选择Entities 1.x 提供两种写系统的基类。SystemBase用起来接近传统 C#可以用托管对象但性能有损耗。ISystem是纯值类型配合 Burst 能编译成极致性能的代码但不能用托管堆上的东西。万人同屏场景下核心的移动、动画、碰撞检测这类每帧跑的逻辑强烈建议用ISystem。我实测过同一个移动逻辑ISystem Burst 比SystemBase快 3-5 倍。差距主要来自 Burst 的 SIMD 向量化和去虚函数化。5.2 Burst 编译的收益与限制Burst 把 C# 的 IL 编译成高度优化的机器码会自动做循环向量化、内联、死代码消除。但它有严格限制不能用class、不能用try-catch、不能调用大部分托管 API、不能用string。这些限制逼着你写数据友好的代码反而成了好事。一个典型收益遍历一万个实体的位置更新普通 C# 可能要 2msBurst 编译后能压到 0.2ms 以内。这就是为什么万人同屏在 DOTS 下可行——单帧逻辑开销被压到了原来的十分之一。提示Burst 编译失败时不会报错中断而是静默回退到普通编译。性能突然变差时第一件事就是去 Burst Inspector 看有没有编译失败的函数。5.3 系统执行顺序与依赖管理ECS 的系统默认按创建顺序执行但依赖关系复杂时必须显式指定。比如移动系统必须在渲染同步系统之前碰撞检测必须在移动之后。用[UpdateBefore(typeof(XXXSystem))]和[UpdateAfter(...)]标注。更隐蔽的坑是数据依赖。如果两个系统都读写同一个组件Job 调度器会自动插入依赖但如果你手动用了NativeArray且没声明依赖就会出现数据竞争表现为偶发的、难以复现的数值错乱。这类 bug 最折磨人建议开发期把 Job 的 Safety Checks 全开。6. 实测调参从一千到一万瓶颈会转移6.1 分阶段压测的方法不要一上来就冲一万。我的做法是 1000、3000、5000、10000 分四档压测每档记录 CPU 主线程耗时、渲染线程耗时、GPU 耗时、DrawCall 数、批次数量。这样能清楚看到瓶颈在哪个阶段转移。实测数据URP中端独显1080p实体数主线程(ms)渲染线程(ms)DrawCall帧率10000.81.2320030001.52.1316050002.23.04120100003.85.5675可以看到一万实体时主线程仍然只有 3.8ms远没到瓶颈。真正开始吃紧的是渲染线程和 GPU。这说明 DOTS 把 CPU 逻辑侧的问题基本解决了剩下的优化重心要转到渲染侧。6.2 瓶颈转移后的优化方向当实体数继续往上走GPU 的顶点处理和像素填充会成为新瓶颈。这时候的优化手段和 CPU 侧完全不同LOD 分级远处实体用低模甚至用公告板替代。Entities Graphics 支持通过组件切换网格。视锥剔除Entities Graphics 内置了基于 Chunk 的剔除但可以进一步用自定义的剔除系统把不可见实体直接排除出渲染批次。阴影降级万人同屏下实时阴影是 GPU 杀手。远处实体关闭阴影投射或者整体用一张烘焙的假阴影贴图。材质简化远处实体用 Unlit 材质省掉光照计算。这些手段要配合使用单靠一个很难把帧率拉回来。6.3 内存与带宽的隐性成本一万个实体的变换矩阵每个 64 字节4x4 矩阵就是 640KB。每帧都要从 CPU 传到 GPU按 60 帧算就是 38MB/s 的带宽。实体数再翻几倍带宽压力就上来了。这也是为什么每实例数据要精简——能用 3 个 float 表示的位置别用 16 个 float 的完整矩阵。Entities Graphics 内部会做一定压缩但自定义的每实例属性要自己控制。我见过有人往每实例数据里塞了一堆用不上的字段结果带宽直接翻倍帧率掉了一大截。7. 那些文档不会告诉你的坑7.1 子场景SubScene的烘焙陷阱Entities Graphics 推荐用 SubScene 来组织场景内容。SubScene 里的 GameObject 会在编辑期被烘焙成 Entity。烘焙过程有几个坑第一烘焙是增量的改了组件但没触发重新烘焙时运行的是旧数据。遇到改了没生效先检查 SubScene 是否重新烘焙。第二烘焙时引用的资源网格、材质如果被打包进 AssetBundle运行时可能丢失引用。要确保这些资源在正确的打包组里。第三SubScene 里的 MonoBehaviour 如果没实现烘焙转换运行时不会有对应组件。需要写 Baker 把 MonoBehaviour 的数据转成 ECS 组件。7.2 托管内存的隐形泄漏ECS 用大量 NativeContainer这些是非托管内存不受 GC 管理。忘记 Dispose 就会泄漏。开发期把 Leak Detection 打开退出播放模式时会报出未释放的容器。我遇到过一次一个临时 NativeArray 忘了释放跑十分钟内存涨了 2GB。7.3 调试信息的缺失ECS 里没有 GameObject 那样的 Inspector 可以点开看。调试要靠 Entities Hierarchy 窗口需要装 Entities 包自带的调试工具和EntityManager的查询 API。想看某个实体的组件值得写代码查。刚开始会很不习惯但习惯了之后批量查看数据的效率反而更高。7.4 与第三方插件的兼容性大部分传统 Unity 插件寻路、动画、UI都是基于 GameObject 的不能直接在 ECS 里用。寻路要用 ECS 版的方案动画要用 Entities Graphics 支持的骨骼动画方案。UI 通常还是走传统 Canvas和 ECS 世界通过桥接层通信。这个桥接层如果设计不好会成为新的性能瓶颈。8. 从 Demo 到产品还需要补的几块跑通万人同屏 Demo 只是起点。真要做成产品还有几块要补。动画系统。Entities Graphics 支持骨骼动画但需要把 SkinnedMeshRenderer 烘焙成 ECS 格式。大量实体的动画计算要放到 Job 里并行否则又回到主线程瓶颈。顶点动画纹理VAT是另一个思路把动画烘焙进贴图GPU 采样播放CPU 侧零开销适合数量极大的场景。碰撞与寻路。Unity 的物理引擎和 NavMesh 都是 GameObject 体系的。ECS 下要么用 Unity.PhysicsDOTS 版物理要么自己写基于空间划分的碰撞检测。寻路可以用流场Flow Field或基于网格的 A*配合 Job 并行计算。网络同步。万人同屏如果是多人游戏同步量是天文数字。通常做法是服务器只同步关键状态客户端做大量预测和插值。这块和 DOTS 的结合需要仔细设计不是简单套用。性能监控。产品环境要有实时的性能埋点监控实体数、批次、帧率、内存。DOTS 的性能特征和传统项目不同监控指标也要相应调整。我在实际项目里的体会是DOTS 的学习曲线陡峭在前两周一旦思维转过来后面写批量逻辑的效率反而比传统模式高。因为不用再纠结这个对象那个对象只需要想这类数据怎么处理。万人同屏只是它最直观的一个应用场景真正值钱的是这套数据驱动的思维方式迁移到任何大规模计算场景都成立。最后分享一个实用技巧压测时把 Burst 的同步编译打开虽然首次运行慢但能确保你测的是真实性能而不是被静默回退的普通编译结果骗了。这个细节不注意优化方向可能从一开始就是错的。
返回列表