Unity DOTS开发必备:Collections与Mathematics高性能编程实战
1. 项目概述为什么需要 DOTS 的基础工具箱如果你正在用 Unity 的 DOTSData-Oriented Technology Stack做项目尤其是性能要求比较高的那种比如大规模策略游戏、模拟城市或者海量单位的 RTS那你肯定对Entities.ForEach和IJobEntity这些玩意儿不陌生。写多了你就会发现很多活儿是重复的比如要高效地存一堆数据还得能快速查、快速改又比如要做点数学运算向量、矩阵、四元数来回倒腾。Unity 的原生System.Collections和Unity.Mathematics当然能用但在 ECS 的纯 Burst 编译环境下它们要么用起来别扭要么性能不是最优解。这就是Unity.Collections和Unity.Mathematics这两个包存在的意义。它们不是 DOTS 的“选修课”而是“必修课”。你可以把它们理解成 DOTS 生态里的“瑞士军刀”和“计算器”。Collections包提供了一套能在 Burst 编译的 Job 里安全、高效使用的数据结构比如NativeArray、NativeList、NativeHashMap解决了托管堆容器在 ECS 世界里水土不服的问题。而Mathematics包则是一套为性能而生的数学库它的float3、quaternion、float4x4等类型在设计之初就考虑了 SIMD 指令优化和 Burst 编译器比 Unity 传统的Vector3、Quaternion在特定场景下快上一个数量级。我接手过一个从传统 MonoBehaviour 架构向 DOTS 迁移的项目初期没重视这两个包结果在重构数据存储和战斗计算模块时踩遍了坑。后来系统性地用上Collections和Mathematics不仅代码更简洁同屏单位数量直接翻了两倍还多。所以这篇内容我会结合这些实战经验把这两个包的核心、易错点以及那些官方文档没明说的技巧给你掰开揉碎了讲清楚。2. Unity.Collections为高性能而生的数据结构传统 C# 的ListT、DictionaryTKey, TValue很方便但它们托管在堆上垃圾回收GC是个定时炸弹在每帧需要处理数万实体Entity的 DOTS 系统里频繁的 GC 分配会导致严重的卡顿。Unity.Collections命名空间下的容器我们称之为“原生容器”Native Container它们分配在 Unity 托管的内存中可以粗略理解为“非托管”侧其生命周期需要手动管理但好处是它们完全兼容 Burst 编译和 Job 系统并且避免了 GC 压力。2.1 核心容器详解与选型指南原生容器家族很庞大但最常用的就那几个。选对了容器性能优化就成功了一半。NativeArray 定长数组性能基石这是最基础、最常用的结构。它相当于一个固定长度的、非托管的数组。一旦创建长度就不可变。// 创建一个长度为10的 NativeArray 分配方式为 TempJob短期Job使用 NativeArrayfloat temperatures new NativeArrayfloat(10, Allocator.TempJob); // 赋值和读取 temperatures[0] 37.5f; float firstTemp temperatures[0]; // 使用完毕后必须手动释放 temperatures.Dispose();注意NativeArray的内存分配器Allocator至关重要。Allocator.Temp用于极短生命周期的数据如单帧内的计算速度最快但必须在同一帧内释放。Allocator.TempJob用于 Job 内部生命周期稍长通常几帧但必须在依赖它的 Job 完成后释放。Allocator.Persistent是手动管理的“常驻”内存生命周期最长但你需要像 C 一样牢记Dispose否则就是内存泄漏。NativeList 动态数组灵活之选当你需要可变长度的数组时NativeList就是答案。它内部封装了一个NativeArray并在需要扩容时自动处理。NativeListEntity enemyEntities new NativeListEntity(Allocator.Persistent); // 添加元素 enemyEntities.Add(enemyEntity); // 像数组一样访问 Entity firstEnemy enemyEntities[0]; // 获取当前长度 int count enemyEntities.Length; // 同样需要手动释放 enemyEntities.Dispose();实操心得NativeList的初始容量Capacity很重要。如果你能预估大致的元素数量在构造函数中指定初始容量如new NativeListEntity(1000, Allocator.Persistent)可以避免频繁的内部数组扩容和数据拷贝这对性能有显著影响。NativeHashMapTKey, TValue 高效的键值对需要根据一个 ID如 Entity快速查找另一个数据如生命值时NativeHashMap是不二之选。它的查找效率接近 O(1)。// 创建一个从Entity映射到生命值的HashMap NativeHashMapEntity, float entityHealthMap new NativeHashMapEntity, float(100, Allocator.Persistent); // 添加或设置 entityHealthMap[myEntity] 100.0f; // 尝试获取值推荐方式避免异常 if (entityHealthMap.TryGetValue(myEntity, out float health)) { // 使用 health } // 遍历所有键值对注意在Job中更高效 NativeArrayEntity keys entityHealthMap.GetKeyArray(Allocator.Temp); // ... 处理 keys keys.Dispose();踩坑记录NativeHashMap在创建时需要指定一个初始容量。这个容量最好是预计元素数量的 1.5 到 2 倍以减少哈希冲突。容量不足时它也会自动扩容但代价是一次全量的重新哈希Rehash和数据迁移在关键帧内触发会很要命。2.2 内存分配器Allocator深度解析与生命周期管理这是Collections包最容易出错的地方也是区分新手和老手的关键。内存管理不当轻则性能低下重则程序崩溃。三种分配器的核心区别与应用场景Allocator.Temp 分配在快速的线程本地存储上。生命周期极短必须在同一方法调用栈帧内释放通常就是OnUpdate方法结束前。它不能用于任何跨帧或传递给 Job 的数据。我一般只用于临时计算中间结果。Allocator.TempJob 专为 Job 设计。它的生命周期默认是 4 帧但安全起见你应该在调度该 Job 的同一帧内在 Job 完成依赖后立即释放。它是Entities.ForEach或IJobEntity中输出数据的首选分配器。Allocator.Persistent 分配在持久化的堆上生命周期由你完全控制。用于那些需要在整个系统、甚至多个系统间共享的长期数据。务必、务必、务必在数据不再需要时调用Dispose()通常放在系统的OnDestroy或OnStopRunning方法中。Job 依赖与安全性检查原生容器不是线程安全的。Unity 通过JobHandle依赖关系和[ReadOnly]、[WriteOnly]、[NativeDisableParallelForRestriction]等属性来保证数据访问安全。public partial struct MyJob : IJobEntity { [ReadOnly] public NativeArrayfloat InputData; // 只读多个线程可并行读取 public NativeArrayfloat OutputData; // 可写需要处理写冲突或保证每个线程写不同索引 public void Execute(Entity entity, ref Velocity velocity) { // 使用 InputData 和 OutputData } } // 在System中调度 protected override void OnUpdate() { var input new NativeArrayfloat(..., Allocator.TempJob); var output new NativeArrayfloat(..., Allocator.TempJob); // 填充input... var job new MyJob { InputData input, OutputData output }; // 调度Job并获取其句柄 JobHandle jobHandle job.ScheduleParallel(query, Dependency); // 将output的释放依赖于此Job的完成 // 同时确保后续使用output的Job也依赖此jobHandle this.Dependency JobHandle.CombineDependencies(this.Dependency, jobHandle); // 通常TempJob分配的容器在FrameCleanup时或确保不再使用后释放 // 更安全的做法是使用 Dispose(jobHandle) 扩展方法 output.Dispose(jobHandle); input.Dispose(jobHandle); }重要提示Unity 的 Entities 包提供了ComponentSystemGroup的帧末自动清理机制但对于Persistent分配器创建的容器你永远是第一责任人。我习惯在持有容器的 System 类中同时实现IDisposable接口或在OnDestroy中集中释放所有Persistent容器形成明确的资源管理契约。3. Unity.Mathematics面向数据的数学库如果你还在 ECS Job 里用UnityEngine.Vector3.Distance那你可能浪费了 80% 的潜在性能。Unity.Mathematics的 API 设计看起来和UnityEngine的数学类很像但底层实现天差地别。3.1 为什么 math 比 UnityEngine 数学类更快Burst 编译器友好math库中的函数如math.distance,math.mul都是用intrinsic内部函数实现的Burst 编译器能直接将其翻译为对应的 SIMD 汇编指令如 SSE, AVX实现单指令多数据流计算。而UnityEngine的许多函数是托管代码调用开销更大且难以被 Burst 深度优化。值类型与无拷贝float3,quaternion等都是struct值类型并且尺寸经过精心设计如float3是 12 字节能更好地利用 CPU 缓存。在 ECS 的ComponentData中直接使用这些类型避免了不必要的装箱和拆箱。函数式编程风格math库的函数大多是纯函数没有副作用输入输出明确这既方便编译器优化也符合 ECS 数据驱动的思想。3.2 核心类型与常用操作实战基本类型float2, float3, float4, int3, bool4...这些类型不仅用于表示坐标、颜色其 Swizzle分量重组特性非常强大。float3 position new float3(1, 2, 3); float3 velocity new float3(0, 5, 0); // 1. 基本运算 (支持操作符重载) position velocity * deltaTime; // 2. Swizzle 轻松创建新向量或重组分量 float2 xzPosition position.xz; // 取x和z分量得到 float2(1, 3) float4 posWithW position.xyzz; // 得到 float4(1, 2, 3, 3) float3 yxz position.yxz; // 分量顺序重排 // 3. 使用 math 函数 float distance math.distance(position, targetPosition); float3 direction math.normalize(targetPosition - position); float dotProduct math.dot(direction, forwardVector); float3 crossProduct math.cross(upVector, rightVector);矩阵float3x3, float4x4与变换在 DOTS 中我们经常需要处理局部到世界的变换。Mathematics提供了高效的矩阵运算。// 创建一个平移矩阵 float4x4 translationMatrix float4x4.Translate(new float3(5, 0, 0)); // 创建一个旋转矩阵绕Y轴旋转90度 quaternion rotation quaternion.RotateY(math.radians(90)); float4x4 rotationMatrix float4x4.Rotate(rotation); // 矩阵乘法先旋转后平移 float4x4 transformMatrix math.mul(translationMatrix, rotationMatrix); // 变换一个点 float3 localPoint new float3(1, 0, 0); float3 worldPoint math.transform(transformMatrix, localPoint); // 正确应用平移 // 如果只变换方向向量用 math.mul(matrix, new float4(vector, 0)).xyz float3 worldDirection math.rotate(rotation, localDirection); // 更简洁的方式避坑技巧math.mul的矩阵乘法顺序是“从右到左”即math.mul(A, B)表示先应用 B 变换再应用 A 变换。这和许多图形学教材的顺序一致但如果你从其他引擎顺序可能不同转过来需要特别注意。四元数quaternion与旋转quaternion是表示 3D 旋转最有效且无万向节锁的方式。// 创建四元数 quaternion fromAxisAngle quaternion.AxisAngle(math.up(), math.radians(45)); // 绕Y轴旋转45度 quaternion fromEuler quaternion.EulerXYZ(new float3(0, 45, 0)); // 欧拉角转四元数 // 球面线性插值 (Slerp) - 用于平滑旋转 quaternion startRot quaternion.identity; quaternion endRot quaternion.RotateY(math.radians(90)); float t 0.5f; // 插值比例 quaternion slerpedRot math.slerp(startRot, endRot, t); // 旋转一个向量 float3 forward new float3(0, 0, 1); float3 rotatedForward math.mul(fromAxisAngle, forward); // 或者 math.rotate(quat, vector) // 四元数乘法组合旋转 quaternion combinedRot math.mul(secondRot, firstRot); // 先应用 firstRot 再应用 secondRot3.3 随机数生成器Unity.Mathematics.Random在 Job 里生成随机数是个挑战因为System.Random不是线程安全的。Mathematics.Random是值类型每个线程可以有自己的实例且质量高、速度快。public partial struct SpawnJob : IJobEntity { public Random random; // 值类型每个Job实例独立 public float currentTime; public void Execute(Entity entity, ref Spawner spawner) { // 用实体索引和当前时间初始化随机种子确保不同实体结果不同 uint seed (uint)entity.Index ^ (uint)(currentTime * 1000); random Random.CreateFromIndex(seed); // 生成随机数 float nextSpawnTime random.NextFloat(spawner.minInterval, spawner.maxInterval); float3 randomOffset random.NextFloat3Direction() * spawner.radius; // 更新状态 spawner.nextSpawnTime currentTime nextSpawnTime; spawner.offset randomOffset; } }关键点Random.CreateFromIndex是创建独立随机流的推荐方法。确保每个并行执行的 Job 实例或每个实体有不同的种子否则你会得到大量重复的“随机”结果。4. 实战整合构建一个简单的粒子系统光说不练假把式。我们用一个简单的粒子系统例子把Collections和Mathematics串起来。这个系统会管理一堆粒子每帧更新它们的位置基于速度和生命周期并移除死掉的粒子。4.1 组件与系统设计首先定义粒子的组件数据。这里我们用IComponentData。using Unity.Entities; using Unity.Mathematics; public struct Particle : IComponentData { public float3 Position; public float3 Velocity; public float Lifetime; // 剩余生命周期 秒 public float4 Color; // RGBA }然后创建一个管理粒子生成和销毁的Spawner组件。public struct ParticleSpawner : IComponentData { public Entity Prefab; public float SpawnRate; // 每秒生成数量 public float3 SpawnArea; // 生成区域大小 (半长) public Random Random; // 随机状态 }现在创建两个 System一个用于生成粒子一个用于更新和销毁粒子。4.2 粒子生成系统实现这个系统负责按速率生成粒子。我们需要一个NativeList来临时存储这一帧要生成的粒子实体因为不能在Entities.ForEach内部直接实例化实体。using Unity.Entities; using Unity.Collections; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct ParticleSpawnSystem : ISystem { private NativeListEntity _particlesToSpawn; public void OnCreate(ref SystemState state) { // 使用 Persistent 分配器因为这个List在系统生命周期内会反复使用 _particlesToSpawn new NativeListEntity(Allocator.Persistent); } public void OnDestroy(ref SystemState state) { // 必须手动释放 if (_particlesToSpawn.IsCreated) { _particlesToSpawn.Dispose(); } } public void OnUpdate(ref SystemState state) { float deltaTime SystemAPI.Time.DeltaTime; EntityCommandBuffer ecb SystemAPI.GetSingletonBeginSimulationEntityCommandBufferSystem.Singleton().CreateCommandBuffer(state.WorldUnmanaged); // 清空上一帧的列表 _particlesToSpawn.Clear(); // 遍历所有 Spawner foreach (var (spawner, entity) in SystemAPI.QueryParticleSpawner().WithEntityAccess()) { var random spawner.Random; float spawnCount spawner.SpawnRate * deltaTime; int integerSpawnCount (int)spawnCount; float fractionalPart spawnCount - integerSpawnCount; // 处理小数部分按概率生成一个额外粒子 if (random.NextFloat() fractionalPart) { integerSpawnCount; } // 生成粒子 for (int i 0; i integerSpawnCount; i) { Entity newParticle ecb.Instantiate(spawner.Prefab); // 计算随机位置 float3 randomPos new float3( random.NextFloat(-spawner.SpawnArea.x, spawner.SpawnArea.x), random.NextFloat(-spawner.SpawnArea.y, spawner.SpawnArea.y), random.NextFloat(-spawner.SpawnArea.z, spawner.SpawnArea.z) ); // 设置初始位置和随机速度 ecb.SetComponent(newParticle, LocalTransform.FromPosition(randomPos)); // 这里简化假设Particle组件已在Prefab上。更复杂的做法是通过SetComponent设置Particle数据。 // 我们将需要设置数据的实体先记录下来 _particlesToSpawn.Add(newParticle); } // 更新Spawner的随机状态重要 var updatedSpawner spawner; updatedSpawner.Random random; ecb.SetComponent(entity, updatedSpawner); } // 现在为所有新生成的粒子设置初始Particle组件数据如果需要 // 这里假设Prefab上已有Particle组件我们只是修改它。 // 如果Prefab没有则需要先AddComponent。 // 为了演示我们用一个Job来并行设置初始速度。 if (_particlesToSpawn.Length 0) { var initJob new InitializeParticlesJob { ParticleEntities _particlesToSpawn.AsDeferredJobArray(), // 转换为Deferred数组安全用于Job Random Random.CreateFromIndex((uint)SystemAPI.Time.ElapsedTime * 1000), ECB ecb.AsParallelWriter() // 使用并行写入器 }; state.Dependency initJob.Schedule(_particlesToSpawn.Length, 64, state.Dependency); } } // 用于并行初始化粒子的Job [BurstCompile] public partial struct InitializeParticlesJob : IJobParallelFor { [ReadOnly] public NativeArrayEntity ParticleEntities; public Random Random; public EntityCommandBuffer.ParallelWriter ECB; public void Execute(int index) { Entity entity ParticleEntities[index]; // 为每个粒子生成随机速度 float3 randomVel Random.NextFloat3Direction() * Random.NextFloat(1f, 5f); // 使用ParallelWriter需要传入排序索引这里用index ECB.SetComponent(index, entity, new Particle { Velocity randomVel, Lifetime Random.NextFloat(2f, 5f), // 随机生命周期 Color new float4(Random.NextFloat3(), 1f) // 随机颜色 }); } } }这个系统展示了几个关键点使用NativeListEntity来收集需要处理的实体避免在查询循环中直接操作EntityCommandBuffer虽然也可以但这样组织更清晰。在 Job 中使用EntityCommandBuffer.ParallelWriter来安全地并行添加/设置组件。妥善管理Random状态确保每次生成都是随机的并在 System 中更新回Spawner组件。4.3 粒子更新与销毁系统实现这个系统每帧更新粒子的位置和生命周期并销毁生命周期结束的粒子。[UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(ParticleSpawnSystem))] // 确保在生成后更新 public partial struct ParticleUpdateSystem : ISystem { public void OnUpdate(ref SystemState state) { float deltaTime SystemAPI.Time.DeltaTime; EntityCommandBuffer ecb SystemAPI.GetSingletonBeginSimulationEntityCommandBufferSystem.Singleton().CreateCommandBuffer(state.WorldUnmanaged); // 方案一使用 Entities.ForEach (适合简单逻辑) // 注意在ForEach内不能直接销毁实体需要通过ECB // 这里我们选择方案二IJobEntity更灵活性能通常更好 // 方案二使用 IJobEntity EntityCommandBuffer.ParallelWriter var updateJob new UpdateParticlesJob { DeltaTime deltaTime, ECB ecb.AsParallelWriter() }; // 调度Job依赖之前的系统 state.Dependency updateJob.ScheduleParallel(state.Dependency); } [BurstCompile] public partial struct UpdateParticlesJob : IJobEntity { public float DeltaTime; public EntityCommandBuffer.ParallelWriter ECB; public void Execute(Entity entity, [ChunkIndexInQuery] int chunkIndex, ref Particle particle, ref LocalTransform transform) { // 更新生命周期 particle.Lifetime - DeltaTime; if (particle.Lifetime 0f) { // 标记实体为销毁使用chunkIndex作为排序索引 ECB.DestroyEntity(chunkIndex, entity); return; } // 更新位置简单欧拉积分 particle.Position particle.Velocity * DeltaTime; transform.Position particle.Position; // 可选根据生命周期改变颜色如淡出 float alpha math.saturate(particle.Lifetime / 5f); // 假设最大生命周期5秒 particle.Color.w alpha; } } }关键技巧在IJobEntity的Execute方法中我们通过[ChunkIndexInQuery] int chunkIndex参数获取了一个“排序索引”。这个索引对于EntityCommandBuffer.ParallelWriter是必需的它保证了来自不同线程的 ECB 操作能以确定的顺序合并避免竞争条件。粒子销毁是通过 ECB 延迟执行的而不是在 Job 内立即销毁这符合 ECS 的安全访问规则。我们将位置更新同步到了LocalTransform组件这样渲染系统才能正确绘制粒子。5. 性能调优与常见陷阱排查即使理解了基本用法在实际项目中还是会遇到各种性能问题和诡异的 Bug。下面是我总结的几个高频陷阱和排查思路。5.1 性能瓶颈定位问题1Job 调度开销过大现象Entities.ForEach或IJobEntity本身执行很快但 Profiler 中显示Schedule和Complete调用耗时很高尤其是当每帧有大量微小 Job 被调度时。根因调度 Job 本身有开销。如果每个 System 都调度一个只处理几十个实体的 Job开销可能比实际工作还大。解决方案合并 System将多个关联性强、执行顺序接近的 System 合并成一个在同一个 Job 中处理所有逻辑。使用IJobEntityBatch或IJobChunk对于极其简单的操作如批量设置一个组件值使用更底层的 Chunk 迭代器可以减少每个实体的函数调用开销。但代码会更复杂。减少不必要的依赖仔细检查Dependency链。确保 Job 只在真正需要数据依赖时才相互等待。过度同步会限制 CPU 的并行能力。问题2NativeContainer分配/释放过于频繁现象Profiler 的Burst或Managed分配区域显示每帧有大量的NativeArray/NativeList分配特别是用了Allocator.Temp或Allocator.TempJob。根因在OnUpdate中频繁创建和销毁容器。TempJob的分配虽然快但大量、高频的分配仍然有成本。解决方案对象池化对于需要每帧使用的容器如上面粒子系统中的_particlesToSpawn在 System 的OnCreate中一次性创建Allocator.Persistent然后在OnUpdate中复用使用Clear()而非Dispose()和new。重用EntityCommandBuffer考虑使用EntityCommandBufferSystem的单例 ECB而不是每帧自己创建新的。评估分配器确认是否真的需要TempJob。如果数据只在同一方法内使用Allocator.Temp更轻量如果数据需要存活几帧且跨 Job考虑用Persistent池化管理。5.2 常见编译与运行时错误错误1Burst编译错误Managed types are not supported...排查检查你的 Job 结构体IJobEntity或IJob中定义的字段。所有字段都必须是“非托管类型”unmanaged type。这包括所有基本值类型int,float,bool、Unity.Mathematics中的类型、Entity、BlobAssetReference以及由这些类型构成的struct。绝对不要在 Job 结构体中放入class、string或UnityEngine.Object引用。示例如果你想在 Job 里使用一个字符串作为调试输出这是行不通的。需要将信息编码为FixedString来自Unity.Collections或intID。错误2运行时异常ObjectDisposedException或访问无效内存排查这是典型的“释放后使用”Use-After-Free错误。根本原因是NativeContainer的生命周期管理混乱。检查清单你是否在 Job 还在执行JobHandle未完成时就释放了它正在读写的NativeArray确保使用Dispose(JobHandle)方法或将释放操作放在依赖该 Job 的后续 System 中。你是否在多个帧中使用了Allocator.Temp分配的容器Temp分配的内存在帧末会被自动回收跨帧访问必然崩溃。你是否尝试在 Burst Job 中访问一个[ReadOnly]标记的容器但同时又从主线程写入它这违反了并行安全规则。要么确保写入在 Job 调度之前完成要么使用[NativeDisableParallelForRestriction]并自行处理同步不推荐新手这么做。错误3逻辑错误随机数不随机或所有实体行为一致排查这是Unity.Mathematics.Random使用不当的典型症状。解决方案确保每个需要独立随机序列的上下文如每个实体、每个并行处理的 Chunk都有自己独立的Random实例并且种子不同。常用的模式是在 System 中创建一个主Random然后在调度 Job 时为每个 Job 实例或通过计算为每个实体派生一个唯一的种子。// 在System中 Random masterRandom Random.CreateFromIndex((uint)SystemAPI.Time.ElapsedTime * 1000); // 在Job中为每个索引生成唯一种子 uint seed masterRandom.NextUInt() ^ (uint)index; Random localRandom Random.CreateFromIndex(seed);5.3 调试与可视化技巧在 DOTS 世界里传统的Debug.Log在 Burst Job 里不能用而且性能极差。我们需要新的调试手段。使用UnityEngine.Debug.DrawLine/DrawRay这些方法可以在主线程的OnUpdate中调用用于可视化向量、范围等。虽然它们也产生一些开销但比Log好得多。使用ComponentData存储调试信息创建一个DebugInfo组件在 Job 中将需要观察的数据写入。然后在主线程的另一个 System 中读取并显示例如用 UI Text。利用 Entity Debugger 和 ProfilerUnity Editor 的 Entity Debugger 窗口是查看实体和组件数据的利器。Profiler 的Jobs和Burst面板可以帮助你分析 Job 的执行时间、并行效率和编译情况。条件编译与[BurstDiscard]对于复杂的调试逻辑可以使用#if UNITY_EDITOR条件编译或者给一个方法标记[BurstDiscard]属性让 Burst 编译器跳过它这样你就能在里面使用托管代码和Debug.Log了。但切记这会影响性能仅用于调试。[BurstCompile] public partial struct MyJob : IJobEntity { public void Execute(Entity entity, ref MyComponent data) { // ... 正常逻辑 LogDebugInfo(entity, data.Value); // 这个方法不会被Burst编译 } [BurstDiscard] // 关键属性 private void LogDebugInfo(Entity entity, float value) { UnityEngine.Debug.Log($Entity {entity.Index}: {value}); } }掌握Collections和Mathematics这两个包就像是拿到了高效开发 DOTS 应用的钥匙。它们提供的工具链让你能真正发挥出面向数据设计的威力。刚开始手动管理内存和 Job 依赖可能会觉得繁琐但一旦习惯你会发现代码的确定性和性能提升是值得的。多写多测多看看 Profiler那些踩过的坑最终都会变成你优化性能的直觉。