资讯动态

Unity DOTS 存在哪些性能问题:快车也会堵车

发布时间:2026/10/17 5:36:08 来源:尧图企业网站定制
一句话DOTS 是一条高速公路但高速公路也会堵车。它的性能问题主要来自两方面一是框架本身的固定成本比如 Chunk 管理、Job 调度、命令回放二是用法不当比如频繁搬家、到处等红灯、数据跳着读、Burst 没有真正生效。不少团队用了 DOTS 反而变慢原因往往就在这些地方。上两篇讲了 DOTS 为什么快。这一篇讲它在什么情况下会慢以及怎么发现和避开这些问题。一、先打个比方高速公路为什么也会堵高速公路比普通马路快前提是车按车道开、不随便变道、收费站不排长队。高速公路DOTS频繁变道结构性变化增删组件实体要搬家收费站排队同步点所有 Job 停下来等主线程很多车道只有一两辆车Chunk 碎片抽屉大多是空的下高速去别的城市取东西随机访问跳着读其他实体的数据为了运一个箱子专门派一辆卡车Job 太小调度成本比干活成本还高所有车挤在一条车道上依赖链串行多核变单核买了跑车却挂着低速挡Burst 没生效或没有向量化二、全景地图类别问题严重程度常见程度数据结构结构性变化太频繁☠️非常常见线程调度同步点打断并行☠️非常常见线程调度依赖链让 Job 串行执行⚠️⚠️常见线程调度Job 太小、系统太多固定开销大⚠️常见内存Chunk 碎片、原型数量爆炸⚠️⚠️常见内存随机访问、深层 Transform 层级⚠️⚠️常见编译Burst 没生效、没有向量化⚠️⚠️很隐蔽组件可开关组件、变化过滤的粒度问题⚠️中等混合开发托管组件、GameObject 数据同步⚠️⚠️常见子系统渲染、物理、网络各自的成本⚠️⚠️视项目而定测量在编辑器中测出错误结论⚠️⚠️非常常见下面一个个拆开讲。三、问题一结构性变化——频繁搬家发生了什么实体的数据存放在由组件组合决定的原型Archetype里。增加或删除组件等于换了组合实体必须搬到另一个原型的 Chunk 中给僵尸加一个眩晕组件 原型 A [位置, 速度, 血量] 原型 B [位置, 速度, 血量, 眩晕] ┌───────────────────┐ ┌───────────────────┐ │ 僵尸 #42 的全部数据 │ ── 整体拷贝 ──→ │ 僵尸 #42 的全部数据 │ └───────────────────┘ └───────────────────┘ ↑ 原位置留下空洞需要用最后一个实体填补又是一次拷贝每次搬家都要步骤代价查找或创建目标原型查表首次还要分配内存在目标 Chunk 中分配位置满了要申请新 Chunk拷贝所有组件数据实体组件越多越贵用最后一个实体填补原来的空洞又一次拷贝更新实体索引和版本号影响变化过滤一次搬家并不慢问题在于数量每帧几千次搬家就会变成明显的热点。ECB 不是免费的很多人以为用了 ECB 就没问题了。实际上 ECB 只是把搬家推迟并没有取消搬家而且ECB 的回放通常是在主线程上单线程执行的工作线程 ████ 记录 5000 条命令 ████ 主线程 ██████ 回放 5000 次搬家 ██████ ← 新的瓶颈怎么解决做法说明频繁切换的状态用可开关组件只翻转一个标记位实体不搬家数值变化直接改字段血量、速度不需要增删组件按查询批量操作按 Chunk 整块处理比逐个实体快得多批量实例化一次Instantiate(prefab, count, ...)不要循环调用预先把可能用到的组件加好用开关代替有无// ❌ 逐个实体加组件每个实体单独搬家一次foreach(vareinfrozenEntities)state.EntityManager.AddComponentFrozen(e);// ✅ 按查询批量处理以 Chunk 为单位操作// 给整个 Chunk 加标签时很多情况下只需修改 Chunk 的原型不必逐个拷贝实体state.EntityManager.AddComponentFrozen(frozenQuery);四、问题二同步点——全体停工等红灯发生了什么Job 在工作线程上运行时主线程如果要做结构性变化或者要读写某个 Job 正在使用的组件就必须先等这些 Job 全部完成。理想情况 主线程 [调度A][调度B][调度C] ………… 做别的事 ………… 工作线程 ████A████ ████B████ ████C████ 出现同步点 主线程 [调度A][ 等待 A 完成 ][直接修改结构][调度B] 工作线程 ████A████ ████B████ ↑ 工作线程空闲 ↑ 主线程忙时工作线程空闲常见的触发方式触发方式说明在主线程调用EntityManager增删组件、创建或销毁实体结构性变化会等待相关 Job 完成在主线程中用SystemAPI.GetComponent等读取刚被 Job 写入的组件会自动等待写入该组件的 Job手动调用JobHandle.Complete()显式等待ECB 回放系统本身就是一个同步点所以数量要少在SystemBase中访问托管对象、调用 Unity 主线程 API经常伴随数据等待publicvoidOnUpdate(refSystemStatestate){newMoveJob{DeltaTimeSystemAPI.Time.DeltaTime}.ScheduleParallel();// ❌ 刚调度完就在主线程读取被写入的组件 → 当场等待 MoveJob 完成varplayerPosSystemAPI.GetComponentLocalTransform(playerEntity).Position;}怎么解决做法说明结构性修改统一写入 ECB交给固定的 ECB 系统每帧集中在一两个位置尽量复用内置的 ECB 系统不要自己创建很多个 ECB 回放点需要读取的数据放在 Job 内部读取不要让主线程去拿必须在主线程读取时安排在合适的时机例如读上一帧已经完成的结果在 Profiler 中怎么认出它现象可能原因工作线程大片空白Job 被同步点打断或者 Job 之间串行主线程出现JobHandle.Complete、CompleteAllJobs等等待标记同步点主线程的 ECB 回放系统耗时很长结构性变化太多五、问题三依赖链——八车道变成单车道发生了什么Job System 会自动分析依赖两个 Job 都要写同一种组件就必须一个接一个执行。系统 A写 LocalTransform移动 系统 B写 LocalTransform击退 系统 C写 LocalTransform贴地 系统 D写 LocalTransform避障 结果 工作线程 ██A██ → ██B██ → ██C██ → ██D██ ← 每个 Job 内部是并行的 但四个系统只能排队更糟的是很多系统明明只读却声明成了读写// ❌ 只是读速度却用了 ref → 被当成写入阻止其他 Job 同时读取voidExecute(refLocalTransformt,refMoveSpeedspeed){…}// ✅ 只读的参数用 invoidExecute(refLocalTransformt,inMoveSpeedspeed){…}怎么解决做法说明只读一律用in、RefRO、[ReadOnly]让调度器知道可以同时运行合并对同一组件的连续修改把移动、击退、贴地合并成一个系统或先把位移累积到中间组件拆分大组件不相关的数据放在不同组件中减少假依赖合理安排系统顺序让互不依赖的系统相邻有机会并行这和研发效能那一篇的约束理论一样整个流程的速度由最慢的那一段决定。依赖链就是 Job 世界里的排队。六、问题四Job 太小、系统太多——固定开销发生了什么调度一个 Job 本身就有成本分配任务、唤醒线程、设置依赖、执行安全检查。每个系统的OnUpdate也有固定开销。情况结果一个 Job 只处理十几个实体调度成本可能比实际计算还高项目中有几百个系统大部分每帧什么都不做主线程光是挨个问一遍就要花不少时间对很少的实体使用ScheduleParallel拆分任务的成本得不偿失就像为了寄一封信专门派一辆卡车。怎么解决做法说明RequireForUpdateT()没有相关实体时系统直接跳过实体很少时用Run()或直接在主线程循环Burst 编译后单线程也很快合并逻辑很少的小系统减少系统数量尽量使用ISystem[BurstCompile]连系统本身的更新逻辑都可以被 Burst 编译减少主线程开销publicvoidOnCreate(refSystemStatestate){state.RequireForUpdateBoss();// 场景里没有 Boss这个系统就不运行}publicvoidOnUpdate(refSystemStatestate){// Boss 只有一个直接在主线程处理即可没必要调度并行 Jobforeach(varbossinSystemAPI.QueryRefRWBoss()){…}}七、问题五Chunk 碎片与内存浪费每个抽屉的大小是固定的Chunk 固定为16KB而且在 Entities 1.x 中每个 Chunk 最多容纳 128 个实体可开关组件使用 128 位的标记位。这带来两个后果情况后果某个原型只有 1 个实体依然占用一个 16KB 的 Chunk实体组件很小也不会超过 128 个Chunk 剩余空间浪费实体组件很大每个 Chunk 能放的实体很少Chunk 数量增多原型数量爆炸每一种不同的组件组合都是一个新原型。如果大量使用标签组件的排列组合[敌人] [敌人,燃烧] [敌人,冰冻] [敌人,燃烧,冰冻] [敌人,燃烧,中毒] …… 5 种状态的任意组合 → 最多 32 种原型 → 每种都只有零星几个实体理想 [Chunk 满][Chunk 满][Chunk 满] 碎片 [3 个][1 个][5 个][2 个][1 个][4 个][1 个] …… ↑ 每个 Chunk 都要单独处理缓存利用率低内存浪费共享组件滥用ISharedComponentData按值把实体分进不同 Chunk。值的种类太多就会产生大量几乎空的 Chunk共享组件的用法结果所属区域几十种✅ 合理渲染材质几十种✅ 合理Entities Graphics 就是这样做的每个玩家 ID、每个单位 ID几千种❌ 几千个几乎空的 Chunk怎么解决做法说明状态多、组合多时用可开关组件原型不变状态只是标记位打开 Archetypes 窗口检查看 Chunk 数量和利用率共享组件只用于少量取值其余用普通组件字段拆分肥组件把很少使用的大数据放进单独的实体或 Blob 资源中八、问题六随机访问与深层层级——又开始跳着读ComponentLookup方便但不便宜// 每颗子弹查目标的位置vartransformLookupSystemAPI.GetComponentLookupLocalTransform(true);float3targetPostransformLookup[bullet.Target].Position;每次查找都要实体 ID → 查实体索引 → 找到所在 Chunk → 找到组件数组 → 读取数据 ↑ 每一步都可能缓存缺失目标实体可能在内存的另一头ECS 最大的优势是顺序读取随机访问一多就退回到了 GameObject 那种东跑西跑的状态。做法说明把需要的数据先复制成连续数组例如先收集所有目标位置再统一查询用空间网格组织附近的单位按格子顺序遍历而不是到处查找少用实体之间的指针式关联关系复杂时考虑把相关数据放在同一个实体中或者用缓冲区DynamicBuffer就近存放按目标排序后再处理处理顺序接近内存顺序缓存命中率会高很多深层 Transform 层级一串连环套在 Entities 中子实体的世界坐标LocalToWorld要根据父实体计算城堡根 └─ 城墙 └─ 塔楼 └─ 炮台 └─ 炮管 ← 必须等前面四层都算完才能算它问题原因层级越深计算越串行子节点依赖父节点的结果父子实体通常不在同一个 Chunk计算时要跳着读移动根节点所有子节点都要重算一动全动怎么解决做法说明正确设置TransformUsageFlags不会动的物体用Renderable烘焙时只保留世界矩阵不参与每帧的变换计算静态物体压平层级场景中的树木、石块没有必要保留父子关系动态物体保持浅层级美术做的五六层嵌套在烘焙前先整理武器、挂件等简单跟随可以自己写系统直接计算位置而不是挂在层级下很多从 GameObject 迁移过来的场景层级都是美术随手拖出来的。迁移到 ECS 之前先整理层级往往能省下可观的变换计算时间。九、问题七Burst 没生效——买了跑车却挂着一挡这是最隐蔽的问题代码能跑结果正确只是悄悄地慢了好几倍。情况一Burst 根本没有编译原因现象忘了加[BurstCompile]ISystem的结构体和OnUpdate方法都需要标注Job 结构体也需要使用了SystemBase它的OnUpdate不能被 Burst 编译其中调度的 Job 可以代码中用了托管类型编辑器中报错或者整个方法无法 Burst 编译Burst 被关闭菜单 Jobs → Burst → Enable Compilation 没勾选编辑器异步编译进入 Play 模式后最初几帧仍在运行普通 C#测出来的数据偏慢[BurstCompile]// ① 结构体上要加publicpartialstructDamageSystem:ISystem{[BurstCompile]// ② 方法上也要加漏了这里系统本身就不走 BurstpublicvoidOnUpdate(refSystemStatestate){…}}在编辑器中可以把 Burst 设置为同步编译Synchronous Compilation或者在测量时等待几秒钟再开始记录。情况二编译了但没有向量化Burst 能把循环变成 SIMD 指令前提是代码足够规整。以下写法会挡住它阻碍向量化的写法改进循环体内有复杂的if分支用math.select代替简单分支编译器担心两个数组指向同一块内存别名使用[NoAlias]告诉编译器它们互不重叠循环内调用复杂函数尽量内联或简化数据是float3数组AoS计算密集的部分可以改成三个独立的float数组SoA严格浮点精度要求对精度不敏感时可以使用FloatMode.Fast// ❌ 分支让每个元素走不同的路径难以向量化if(health[i]0)health[i]0;// ✅ 两边都算按条件选择没有分支health[i]math.select(health[i],0f,health[i]0f);// 或者更直接health[i] math.max(health[i], 0f);怎么确认打开Burst InspectorJobs → Burst → Open Inspector查看生成的汇编。看到vmulps、vaddps这类带pspacked single的指令通常说明成功向量化了如果到处都是ssscalar single说明还是一个一个在算。还可以在关键循环中使用Unity.Burst.CompilerServices.Loop.ExpectVectorized()如果没有向量化编译时会直接报错。情况三安全检查拖慢了速度编辑器中默认开启边界检查和Job 安全检查每次访问数组都要额外判断是否越界。这在开发时非常有用但会让性能数据严重失真见第十四节。十、问题八可开关组件与变化过滤的粒度陷阱可开关组件不是免费的可开关组件解决了搬家问题但也有代价代价说明关闭的组件仍然占内存数据还在 Chunk 里只是标记为关闭查询时要逐个检查标记位Chunk 中全部开启或全部关闭时可以整块处理开关混杂时要逐个判断修改开关需要写权限会和其他写入该组件的 Job 产生依赖1 万只僵尸只有 10 只被眩晕 眩晕系统仍然要扫描所有僵尸所在的 Chunk检查每个 Chunk 的标记位 → 实体多、启用的极少时扫描本身也是成本经验法则情况推荐状态切换频繁比例较高可开关组件状态极少出现、持续时间长增删组件搬家次数少查询更精确只是一个数值直接存在普通组件字段里变化过滤一个ref就让它失效WithChangeFilterT()可以让系统只处理组件发生变化的 Chunk例如只在血量变化时更新血条。但它有两个特点很容易踩坑特点后果以Chunk为单位而不是以实体为单位一个 Chunk 中只要有一个实体变了整个 Chunk 都会被处理只要以写权限访问组件版本号就会更新哪怕你一个字节都没改// ❌ 用 ref 访问 Health即使没有修改也会让这个 Chunk 被标记为已变化voidExecute(refHealthhp){if(hp.Value999){/* 很少发生 */}}// ✅ 只读就用 in变化过滤才能起作用voidExecute(inHealthhp){…}一个系统误用ref下游所有依赖变化过滤的系统都会每帧处理全部数据这是一种很难察觉的连锁浪费。十一、问题九并行写入的抢座位多个线程同时往同一个容器里写数据就像很多人同时往一个箱子里扔东西会互相挤。容器 / 写法潜在问题NativeQueueT.ParallelWriter写入很多时有内部竞争NativeParallelMultiHashMap的并行写入容量不足时无法扩容要预先分配足够大小原子计数Interlocked所有线程抢同一个缓存行伪共享那一篇讲过ECB 的并行写入回放前要先按sortKey排序命令越多排序越久怎么解决做法说明每个线程、每个 Chunk 先写本地结果最后合并上一篇的局部累加预先估算容量哈希表一次分配到位避免溢出能用计算代替写入就别写例如用排序 扫描替代并行插入哈希表内存分配器选错分配器适合用错的后果Allocator.Temp本帧内、单个函数内的临时数据跨帧使用会出错Allocator.TempJob传给 Job、几帧内释放超过 4 帧未释放会报警告Allocator.Persistent长期存在每帧都用它分配和释放代价很高World.UpdateAllocator本帧内跨系统使用帧末自动回收非常便宜十二、问题十混合开发的过境税真实项目几乎都是 ECS 和 GameObject 混用。两个世界之间每来往一次就要交一次过境税。托管组件// class 类型的组件方便但它不住在 Chunk 里publicclassEnemyVisual:IComponentData{publicGameObjectModel;}问题说明数据不在 Chunk 中分散存储失去连续内存的优势不能在 Burst 中访问只能在主线程上处理会产生 GC退回到传统方式的问题伴随 GameObject 和每帧同步每一帧 ECS 计算好 5000 个敌人的位置 → 主线程逐个把位置写回 5000 个 GameObject 的 Transform ← 这里最慢 → Animator 再各自更新做法说明只为少数重要角色保留 GameObject主角、Boss而不是所有小兵批量同步使用TransformAccessArrayIJobParallelForTransform上一篇鱼群示例只同步变化或可见的对象屏幕外的不更新大量单位改用 VAT 或 ECS 动画方案彻底摆脱逐个 Animator混合开发的原则边界越少越好过境时一次打包过去不要一个一个运。十三、问题十一渲染、物理、网络各有各的账ECS 让游戏逻辑变快了但游戏的帧时间还包括其他部分。渲染Entities Graphics问题说明GPU 瓶颈不受影响过度绘制、填充率、复杂着色器ECS 帮不上忙每帧上传变化的实例数据大量实体每帧移动就要大量上传数据到 GPU网格和材质组合太多批次被拆散渲染效率下降需要 URP 或 HDRP不支持内置渲染管线每个实体覆盖材质属性需要额外的实例数据增加上传量做法控制网格和材质的种类静止的物体不要每帧修改变换用 LOD 和剔除减少需要绘制的数量用 GPU Profiler 或 Frame Debugger 确认瓶颈到底在 CPU 还是 GPU。物理Unity PhysicsUnity Physics 是无状态的每一步都会重新构建宽阶段的空间结构问题说明动态刚体多时成本明显每个物理步都要重建、检测、求解帧率下降时物理步可能执行多次固定步长要追上真实时间越卡越要算越算越卡碰撞事件处理不当大量事件在主线程处理会变成新瓶颈做法能用简单碰撞体就不用网格碰撞体不需要物理的单位用自己的空间网格做简单检测限制每帧最多的物理步数堆叠和复杂交互较多时可以评估 Havok Physics有状态带缓存需要相应授权。网络Netcode for Entities问题说明预测回滚要重复模拟客户端收到服务器快照后要把预测系统从确认的那一帧重新运行到当前帧可能一帧算好几次预测实体越多越贵每个预测实体都参与重算Ghost 序列化成本大量实体、大量字段的快照打包和比较做法只有玩家直接控制、需要即时反馈的实体才使用预测其他使用插值控制同步字段数量和精度设置合理的同步频率和重要性兴趣管理。这和网络篇讲的一样预测越多、同步越多带宽和 CPU 的账就越大。十四、问题十二测量陷阱——在编辑器里得出错误结论这是导致误判最多的地方。很多DOTS 很慢的结论其实是在编辑器中测出来的。编辑器中的额外开销影响Jobs 安全检查每次访问 Native 容器都有额外判断边界检查数组访问变慢内存泄漏检测尤其是完整堆栈模式分配和释放明显变慢Entities 调试与日志记录Journaling记录结构性变化额外开销Burst 异步编译前几帧运行的是普通 C#编辑器自身的窗口、Inspector 刷新占用主线程正确的测量方法步骤说明1. 用目标平台的真机测试手机和 PC 差别很大2. 使用 Release 构建或至少关闭安全检查开发构建可以连接 Profiler但要知道它有额外开销3. 预热之后再记录避开加载、首次编译和首次分配4. 手机上注意发热降频连续测试几分钟观察性能是否下降5. 对比时只改一个变量和前面所有篇章的原则一样手机上的特殊问题大小核手机 CPU 通常是几个大核 几个小核大核███ ███ 很快 小核█████████ 慢不少把任务平均分给所有工作线程时分到小核上的那份会拖后腿整体要等最慢的那一份完成。做法说明让任务切分得细一些快的线程可以多拿几份工作窃取不要一味追求更多线程线程越多发热越严重可能导致降频必要时调整工作线程数JobsUtility.JobWorkerCount以真机数据为准十五、排查流程遇到卡顿怎么办① 先确认瓶颈在 CPU 还是 GPU └─ GPU → 去看渲染过度绘制、着色器、批次DOTS 不是主要原因 ② 在 Profiler Timeline 中看工作线程 ├─ 大片空白 → 找同步点JobHandle.Complete、依赖链、Job 太小 └─ 一直很满 → 找最耗时的 Job往下看 ③ 看主线程 ├─ ECB 回放很慢 → 结构性变化太多问题一 ├─ 系统数量多、每个都很短 → 固定开销问题四 └─ 托管代码、GameObject 同步 → 混合开发的过境税问题十 ④ 看最慢的那个 Job ├─ 打开 Burst Inspector确认已编译、已向量化问题七 ├─ 检查是否大量 ComponentLookup问题六 └─ 检查是否误用 ref、写入冲突问题三、八 ⑤ 看内存结构 └─ Archetypes 窗口原型数量、Chunk 数量、利用率问题五十六、检查清单检查项✅ 推荐频繁切换的状态用可开关组件或字段不要反复增删组件结构性修改统一写入 ECB按查询批量处理主线程读组件避免在 Job 调度后立刻读取只读数据一律用in/RefRO/[ReadOnly]系统和 Job 的粒度用RequireForUpdate实体少时不必并行共享组件只用于取值很少的分组原型数量定期在 Archetypes 窗口检查实体之间的引用少用随机查找必要时先收集成连续数组Transform 层级设置好TransformUsageFlags层级尽量浅Burst结构体和方法都要标注用 Burst Inspector 确认混合开发托管组件和伴随 GameObject 越少越好内存分配不在每帧使用Persistent分配性能测试真机、Release、关闭安全检查、预热后再测一句话总结DOTS 的性能问题归根到底只有三种让 CPU 等同步点、依赖链、ECB 回放、小核拖后腿让数据乱频繁搬家、Chunk 碎片、随机访问、深层层级让引擎白干Burst 没生效、没向量化、误用ref、Job 太小、在编辑器里测性能口诀“少搬家少停工顺着读只读用 inBurst 要验证真机再下结论。”DOTS 不是自动变快的魔法而是一条修好了的高速公路。按车道开它快得惊人随便变道、频繁停车照样会堵。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑