资讯动态

DOTS开发基石:Collections与Mathematics包解析

发布时间:2026/10/18 3:29:57 来源:尧图企业网站定制
做DOTS开发的人几乎都会碰到同一个问题项目一旦开了ECS的头就再也回不去用GameObject数组手动搬运数据的日子了。而真正让ECS、Job System、Burst这三驾马车跑起来的是两个常常被忽视的基础包——Collections 和 Mathematics。标题里的“第4篇”已经说明这个系列走到了需要用代码实操的阶段这两个包就是接下来所有示例的地基。简单说Collections包提供了ECS世界里可以安全访问的原生内存容器Mathematics包提供了符合SIMD布局、能被Burst高效编译的数学类型和函数。两者一配合你才能在Job里愉快地读写数组、算坐标、做旋转而不必担心GC垃圾回收卡顿或安全系统报错。这篇内容适合已经了解ECS基础概念、想开始动手写实际迭代逻辑的开发者看完你就能明白为什么老项目里的List和Vector3到了DOTS世界里会浑身难受以及该用什么替代它们。1. 整体设计与思路拆解为什么这两个包是DOTS的地基1.1 从C#标准集合到Native容器的必然性传统Unity脚本里我们习惯了ListT、DictionaryK,V、Vector3、Mathf.Sin这一套组合。它们写起来顺手读起来也舒服。但到了DOTS体系里这套组合出现了三个硬伤。第一个硬伤是GC压力。ListT扩容会分配托管内存Dictionary的哈希桶也是托管对象频繁增删必然触发GC。DOTS想做的是大量实体、每帧密集更新的方案一次GC卡顿就足以让帧率曲线抖成心电图。第二个硬伤是缓存友好度。托管数组里的对象可能散落在堆的各处CPU缓存命中率极低。DOTS的核心思想是让数据连续紧凑排列一次加载一片内存CPU就能批量处理而NativeArray天生就是连续内存。第三个硬伤是Burst编译器的兼容性。Burst会把Job里的代码编译成高效的机器码但如果你的代码里出现ListT或者Mathf.Sin这类带托管调用和复杂分支的东西Burst要么拒绝编译要么退回到很慢的兼容模式。Mathematics包里的函数则从一开始就为Burst优化过。我见过很多人一开始不重视这两个包结果写到第二个Job就卡住了——要不然是NotSupportedException要不然是性能纹丝不动。所以我把这两个包称为“基础工具箱”它们不是可选项而是必选项。1.2 它们在整个DOTS生态里的位置先理清楚概念关系。DOTS由三个核心模块组成ECS负责数据结构化Job System负责并行执行Burst负责把代码编译成高性能机器码。Collections提供了一个“数据怎么放”的解决方案Mathematics提供了“数据怎么算”的解决方案。你可以这样理解ECS是仓库管理员Job是搬运工Burst是给搬运工换了一双跑鞋而Collections和Mathematics就是仓库里的货架和计算器。货架不稳搬运工再快也白搭计算器不顺手算账速度就上不去。还有一个容易忽略的点Collections包里的安全系统Safety System是DOTS在编辑器模式下保障开发体验的关键。它会帮你检测数据竞争、越界读写、重复释放等问题把Job里的并行写错误挡在编辑阶段。这在普通C#编程里没有对应物属于DOTS特有的“运行时教练”。2. 核心机制拆解Collections包的生命周期与容器选型2.1 分配器选型Temp、TempJob、Persistent怎么选Native容器的第一个知识点是分配器。创建NativeArray时必须指定一个Allocator这个参数决定了容器的存活时间和分配策略。新手最容易在这里踩坑因为选错分配器轻则性能受损重则直接崩溃。三个分配器的区别我用一句话概括Temp是最快的临时工TempJob是Job专用的短工Persistent是长期存活的正式员工。具体来说Temp分配在临时内存里函数返回后基本就失效了适合在同一个方法里短时间使用特点是分配速度极快、几乎零开销。TempJob允许你在Job里使用但必须在4帧内释放否则安全系统会报错适合“创建Job、调度、完成、释放”这种短生命周期场景。Persistent不会被自动回收需要手动Dispose适合存活很多帧的长期数据比如用来表示整个场景的碰撞网格。我自己的习惯是能用Temp就不选Persistent因为Persistent分配和释放的成本更高而且一旦忘记释放就会造成内存泄漏。只有确定数据要跨越多次Job甚至多个帧使用才考虑Persistent。注意Temp容器一定不要在IJob执行完后继续持有安全系统会在特定条件下直接抛出异常而且这类问题在编辑器里复现比较随机排查起来很费劲。2.2 常用容器类型对照什么时候用哪个Collections包提供了多个容器下面是我在实际项目里最常用的几个容器类型用途底层结构备注NativeArrayT定长有序访问连续数组最高性能最常用NativeListT变长动态数组可扩容数组类似ListT但支持在Job里写入NativeHashMapK,V键值对查找开放寻址哈希表注意容量需预先估计扩容代价高NativeHashSetT集合去重哈希表判断存在性非常快NativeQueueT先进先出队列环形缓冲适合生产者消费者模式NativeStackT后进先出栈线性存储用得相对少选型的原则很简单能确定长度用NativeArray长度不定且要频繁追加用NativeList需要通过ID查找关联数据用NativeHashMap需要去重或集合运算用NativeHashSet。这里有个经验NativeHashMap的构造参数里容量最好一次性给够且给得比实际需求大一些。因为哈希表一旦扩容需要把所有元素重新哈希这个操作在性能和实现复杂度上都比普通扩容高得多。我建表时通常按预估数量的1.3倍分配留出余量。2.3 并行写入的钥匙ParallelWriter在最常见的IJobParallelFor里多个线程会同时处理不同索引的数据。写各自的NativeArray元素没问题但如果多个线程同时Add到同一个NativeList或者NativeHashMap就会出现数据竞争。解决方案是容器的AsParallelWriter()方法。它返回一个可以安全并行写入的视图内部通过原子操作确保线程安全。举个例子你想在并行Job里把满足条件的实体筛选到同一个列表中var results new NativeListint(capacity, Allocator.TempJob); var parallelResults results.AsParallelWriter(); job new FilterJob { Input inputArray, Results parallelResults };注意ParallelWriter的写入顺序是不确定的——线程调度顺序决定了元素在列表里的排列顺序。如果你的业务逻辑依赖筛选结果的顺序后续需要再做一个排序步骤。我在做实体筛选时经常遇到这个坑后来干脆在Job结束后对results做一次并行排序既利用了多线程又不影响顺序。3. Mathematics包解析向量、四元数与实用函数的实战姿势3.1 为什么要用Unity.Mathematics而不是UnityEngine.MathfUnityEngine里的Vector3和Mathf是面向MonoBehaviour设计的老牌数学工具它们功能齐全、文档丰富。但到了DOTS场景它们有两个问题一是这些类型包含额外开销和托管相关的兼容层二是不保证与Burst的数学指令集完全对齐。Unity.Mathematics包提供了一整套与SIMD友好的类型float3、float4、float4x4、quaternion等。这些类型是纯结构体字段布局直接与向量寄存器对齐Burst编译时能生成高效的向量化指令比如一次处理四个float的SIMD运算。大概能带来多少性能提升取决于你的计算密集度。如果是简单的赋值和读取两者差别不大如果是在Job里做大量矩阵变换、点积、归一化Mathematics的SIMD优化能轻松拉开数倍差距。Burst的编译日志里会显示llvm生成的向量指令你可以亲眼看到它把多个标量运算合并成了单条向量指令。3.2 类型互转Vector3和float3的桥接姿势实际项目中DOTS数据和传统组件经常共存。转换关系如下// UnityEngine - Unity.Mathematics float3 f new float3(vector3.x, vector3.y, vector3.z); float3 f2 (float3)vector3; // 显式转换已定义 // Unity.Mathematics - UnityEngine Vector3 v (Vector3)float3Value;float3和Vector3之间定义了显式转换但要注意直接转换时可能需要注意分量含义。Vector3的z通常代表前方向而float3没有语义绑定只是纯数据。所以业务层的“前后左右”需要自己在代码里维护转换本身不做任何坐标语义调整。四元数也一样quaternion rot (quaternion)transform.rotation; transform.rotation (Quaternion)rot;3.3 高频数学函数速查表在实际DOTS项目里下面这些函数出现的频率最高函数作用替代旧APImath.normalize向量归一化vec.normalizedmath.length向量长度vec.magnitudemath.dot点积Vector3.Dotmath.cross叉积Vector3.Crossmath.mul四元数旋转向量quaternion * vectormath.lerp线性插值Vector3.Lerp注意无钳制math.radians/math.degrees角度弧度互转Mathf.Deg2Rad等math.select按条件选值三元运算符math.rcp快速倒数1f / x损失精度两个容易记混的细节math.lerp不做钳制t超过1时结果会超出范围和Vector3.Lerp的行为不同math.rcp是有精度损失的快速倒数如果你需要高精度除法直接用a / b就好Burst也会优化。4. 实操场景用Collections与Mathematics实现一个游戏对象同步系统4.1 需求定义与数据结构设计假设一个典型场景场景里有几千个移动中的实体每帧要根据速度向量更新位置。在传统ECS写法里我们需要使用IJobParallelFor处理一个NativeArrayfloat3其中每个元素代表一个实体的位置。数据结构的选取思路是这样的实体数量固定位置更新又是纯索引遍历用NativeArrayfloat3最合适内存连续且支持并行写。速度数据放在另一个NativeArrayfloat3里Job中每个线程处理一个索引就能完成各自实体的更新。4.2 Job定义与调度代码[BurstCompile] public struct MovementJob : IJobParallelFor { [ReadOnly] public NativeArrayfloat3 Velocities; public NativeArrayfloat3 Positions; public float DeltaTime; public void Execute(int index) { Positions[index] Velocities[index] * DeltaTime; } }调用处这样写var positions new NativeArrayfloat3(entityCount, Allocator.TempJob); var velocities new NativeArrayfloat3(entityCount, Allocator.TempJob); // 初始化数据... var job new MovementJob { Velocities velocities, Positions positions, DeltaTime SystemAPI.Time.DeltaTime }; var handle job.Schedule(entityCount, 64); // 64是批处理大小 handle.Complete(); // 等待完成 positions.Dispose(); velocities.Dispose();注意三个细节第一[ReadOnly]特性让安全系统检查读操作时不会误判为写入冲突。不加这个Job系统会认为Velocities可能被并发修改进而禁止并行调度。第二批处理大小64是经验值并非越大越好。批越大每个线程连续处理的元素越多缓存友好性越好但负载均衡会变差。我一般从64起步实测调整到128左右在不同设备上的差异很明显。第三handle.Complete()在真正需要结果时才调用。如果后续Job依赖这次计算结果就应该用JobHandle链式传递而不是直接Complete阻塞主线程。4.3 更复杂的数据组合哈希查找与归并再举一个展示NativeHashMap用处的场景实体需要根据ID找到所属的分组并累加分组总权重。拆解步骤是先用NativeHashMapint, float存每个分组的基准权重然后在并行Job里遍历实体ID从哈希表里查分组权重并累加。这个场景的关键在于并行Job里所有线程都在更新同一个累加结果直接写会造成竞争。解决方案是每个线程一个私有累加器最后归并或者使用支持原子操作的NativeCounter工具类需要额外实现。我在实际项目中用的方案是“每个线程独立的小数组最后在主线程归并”简单且无锁无竞争代码量也不大。这类“查表累加”的组合是DOTS开发中非常典型的模式掌握了NativeArray存储核心数据、NativeHashMap做关联查找大部分ECS逻辑的基本骨架都能搭出来。5. 常见问题与排查技巧实录5.1 忘记释放容器泄漏的排查套路我刚开始接触Collections包时最常犯的错误就是创建了容器忘记Dispose。编辑器里不会立刻报错但长时间运行后内存会持续上涨直到触发OOM。排查方法有两个。第一打开Profiler的Memory模块观察Native内存区域是否有持续增长。第二借助DOTS的调试工具例如在DisposeSentinel的辅助下检查是否有未释放的容器。编辑器模式下安全系统会记录容器的分配堆栈能直接定位到创建它的代码行。我的习惯是尽量使用using模式或写一个工具方法统一管理容器的创建和释放把Dispose放进finally块。虽然不能完全防止泄漏但至少能把生命周期集中到少数几个函数里排查范围降到最小。5.2 越界读写与写冲突安全系统的报错解析安全系统在编辑器下很“唠叨”但每一条提示都有价值。最常见的报错是IndexOutOfRangeException出现在原生内存访问中说明你访问的索引超出了NativeArray的Length。另一个常见报错是写冲突Since the job is parallel...这表示你的Job在一个线程上写、另一个线程在读或写同一块数据。处理写冲突的办法是能并行就按索引拆分比如NativeArray按index各写各的必须共享的东西使用ParallelWriter实在不行把需要的数据复制一份让每个线程操作副本。我在一个项目里遇到过非常隐蔽的写冲突Job里对一个NativeHashMap做ContainsKey判断另一个线程同时在做插入操作。编辑器报错信息明确指出了哈希表内部数据可能被并发修改才让我意识到并发读一个正在写入的哈希表同样不安全。记住写冲突不仅限于直接写入任何内部状态可能变化的操作都要考虑并发安全性。5.3 坐标变换的两个经典坑坐标系的语义转换是大坑。某个模拟项目里我从旧的Transform系统和新的ECS位置数据之间来回同步结果发现物体的朝向总是左右颠倒。排查了一个晚上最后定位到问题是Vector3.forward的z轴语义在ECS里没有任何特殊含义而我把不同坐标系的数据混在一起直接用数学库计算了。另一个经典坑是math.mul旋转向量的方向问题。Unity.Mathematics的quaternion旋转使用的是左乘约定和Unity自身一致但如果你之前用过别的引擎的矩阵库很容易习惯性右乘而导致旋转方向相反。实测方法是随便转一个90度观察结果轴是否符合预期两分钟就能确认约定。5.4 编辑器下的性能观察看Burst到底干了什么很多人在编辑器里跑DOTS代码发现性能提升不如预期原因可能是Burst没有启用。打开Burst的Jobs Burst Open Inspector可以看到Job有没有被成功编译成原生代码。如果显示Burst disabled那所有优化都白搭你需要到Player Settings里开启Burst支持并在编辑器里勾选Burst菜单里的Enable Compilation。另一个有用的观察方式是开Profiler的CPU模块对比Burst线程组和Main Thread的耗时。Burst编译后的Job通常只有几毫秒甚至微秒级如果发现耗时异常高大概率是代码里存在一次隐式的拷贝或转换把数据从NativeArray复制到了托管数组。永远不要在Job里直接访问普通的C#数组或List。写到这里我回想自己从MonoBehaviour迁移到DOTS的过程中这两个包是花时间最多、踩坑最深的部分。Collections包的安全系统在早期版本里报错信息不够友好遇到问题先在隔离测试里跑一遍总比直接丢了项目思路强。Mathematics包倒是相对简单含义机型的实践就是把所有能替换的数学计算都切过来再看着Burst的编译日志理解向量化是怎么回事。最后再分享一个小技巧如果你还在用老式写法在Job里取Vector3的组件请立刻改成float3并开启Burst编译后再看反汇编或者LLVM中间代码你会看到很多原本需要多个指令的操作合成了单条指令。这个视觉冲击会加深你对DOTS“数据即性能”的理解也会让之后的开发越写越顺。

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

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

免费获取报价 →
↑