ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity ECS实现Boids群集模拟:空间哈希与Burst性能优化

Unity ECS实现Boids群集模拟:空间哈希与Burst性能优化 简介面向 Unity 开发者的 ECS Boids 群体模拟示例工程特别适合想理解 ECS 与普通面向对象写法差异的读者。工程把位置、速度、邻近列表定义成组件将分离、对齐、聚拢三类规则实现为独立系统并逐步引入 Job System 与 Burst 编译最终让大量个体的行为计算做到并行与高效从而支持更大规模的群体模拟。示例从只处理墙体边界的简化版开始逐渐扩展到规则完整的版本再到带 Job 依赖和实体生成的优化版每个阶段都有独立场景与脚本Assets 目录按示例分组可以直接打开运行便于对比学习。压缩包共 90 个文件包含 21 个 C# 脚本、17 个 asset 工程配置、7 个 Unity 场景以及 prefab、材质、README 等辅助文件整体仅 76KB体积小巧但结构完整。目前已有 224 人学习适合刚入门 ECS、或想优化大规模对象实时模拟性能的开发者参考也可作为 ECS 入门课程的配套示例。1. Boids 的难点不在规则本身而在于邻居查询先给一个反直觉的结论Boids 模拟里三规则——分离、对齐、聚合——每一条都能用一行公式表达真正难的是回答「我附近有谁」。用最笨的两两遍历一万只鸟每帧就是五千万次距离计算随机内存访问会把吞吐拖垮。Unity ECS 的解法是把鸟的位移数据连续放在 chunk 里用空间哈希做邻居查询再让 Burst 把 Job 编译成原生代码。标题里的示例 zip 解压后核心只有三块Component 怎么设计、粒群怎么建哈希、三规则怎么在 Job 里合并。下面沿这条路径把原理、可抄代码和踩坑讲透。2. 搭出第一个 Boids 骨架Component 结构与 IJobEntity 主循环我一般把 ECS 的写法理解成「先写数据再写并行任务」。第一步要决定的是一只鸟在内存里长什么样。不要写 class Boid那会立刻回到托管堆和对象引用的老路上。正确做法是用几个 unmanaged struct 把一个 Boid 实体拼出来让内存天然连续Job 遍历时缓存友好。2.1 把「鸟」拆成 IComponentData而不是 class一个最小 Boid 实体由以下组件组成组件类型作用BoidTag空 IComponentData标记供 EntityQuery 过滤LocalTransformUnity.Transforms 自带位置、旋转、缩放BoidVelocityIComponentData当前速度向量BoidAccelerationIComponentData本帧计算出的加速度下一阶段消费BoidSimulationConfigIComponentData 单例所有鸟共享的全局参数BoidTag是空结构体只用来在查询时区分「鸟」和场景里其他 Entity。别把它省掉否则后面一旦混入别的 LocalTransform 实体Job 会误伤。LocalTransform用 Unity.Transforms 里那个而不是自己定义位置结构因为渲染管线只认它速度单独存是因为每帧它被 force 阶段读、move 阶段写和 Transform 的读写分开能减少系统间的访问冲突。using Unity.Entities; using Unity.Mathematics; public struct BoidTag : IComponentData {} public struct BoidVelocity : IComponentData { public float3 Value; } public struct BoidAcceleration : IComponentData { public float3 Value; } public struct BoidSimulationConfig : IComponentData { public float PerceptionRadius; public float CellSize; public float MaxSpeed; public float SeparationWeight; public float AlignmentWeight; public float CohesionWeight; }注意IComponentData都必须是 unmanaged struct成员不能是 string、class、List 这类托管类型否则 Burst 无法编译。空BoidTag会被分配 1 字节这是正常现象不要为了省字节去硬塞数据。2.2 ISystem IJobEntity每帧处理 Boid 的入口从老的ComponentSystem切到ISystem最主要原因是OnUpdate本身可以被 Burst 编译Job 调度不经过 managed 边界。IJobEntity是 Entities 1.0 之后推荐的遍历方式你只要写清Execute的参数框架自动生成 EntityQuery并且按 chunk 并行调度。[BurstCompile] public partial struct BoidMoveSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { var config SystemAPI.GetSingletonBoidSimulationConfig(); new BoidMoveJob { DeltaTime SystemAPI.Time.DeltaTime, MaxSpeed config.MaxSpeed }.Schedule(); } } [BurstCompile] public partial struct BoidMoveJob : IJobEntity { [ReadOnly] public float DeltaTime; [ReadOnly] public float MaxSpeed; public void Execute(ref LocalTransform transform, in BoidVelocity velocity) { transform.Position velocity.Value * DeltaTime; } }Execute签名里的ref表示这个 Job 对该组件有写权限in表示只读。这个签名会直接影响依赖推断和同步粒度写 Transform 的 Job 会被安排在读取 Transform 的 Job 之后。这段代码现在没有任何规则只是让鸟沿当前速度直线飞但已经能验证「ECS 里最常见的遍历模式」是否跑通。2.3 单例参数与 RequireForUpdate 的配合BoidSimulationConfig以单例组件形式存在用SystemAPI.GetSingleton读取。如果一个场景里没有这个单例GetSingleton会直接抛异常。所以最好在OnCreate里声明「这个系统需要什么才运行」public void OnCreate(ref SystemState state) { state.RequireForUpdateBoidSimulationConfig(); state.RequireForUpdateBoidTag(); }RequireForUpdateBoidTag()还有一个好处当场景里一只鸟都没有时系统会跳过OnUpdate不产生空调度。这个习惯在项目后期积累十几个 System 时能省掉大量无意义的 main thread 开销。3. 三规则的 ECS 实现用 NativeParallelMultiHashMap 做邻居查询到了第三个环节才真正进入 Boids 的核心。三规则里 separation 是远离邻居、alignment 是向邻居平均方向对齐、cohesion 是向邻居质心靠拢。三者都依赖同一个前置拿到感知半径内的邻居列表。3.1 为什么选 cell map而不是两两 for 或 KD-Tree暴力 for 是 O(n²)一万只鸟就是五千万次距离计算即使丢进 Burst 也扛不住。KD-Tree 查询是 O(log n)但每帧重建树、在 Burst 里写指针遍历复杂度和 Boids 均匀分布的局部性不匹配。Cell map 的做法最贴合 Boids每帧把每个 Boid 坐标映射到网格 key写进NativeParallelMultiHashMapint3, Entity查询时只需要遍历相邻最多 27 个格子。这里有一个容易忽略的前提CellSize 必须大于等于 PerceptionRadius否则感知球体可能超出 27 格覆盖范围出现「明明很近却找不到邻居」的断层。如果 CellSize 太小同一个感知半径会被拆成多个格子查询次数膨胀得不偿失。3.2 BuildCellMapJob 与 BoidForceJob 的代码骨架先写网格坐标辅助函数和构建哈希表的 Jobinternal static class GridHash { public static int3 WorldToCell(float3 pos, float cellSize) { return (int3)math.floor(pos / cellSize); } } [BurstCompile] public partial struct BuildCellMapJob : IJobEntity { public NativeParallelMultiHashMapint3, Entity.ParallelWriter CellMap; public float CellSize; public void Execute([EntityInQuery] in Entity self, in LocalTransform transform) { int3 cell GridHash.WorldToCell(transform.Position, CellSize); CellMap.Add(cell, self); } }WorldToCell用floor而不是round保证坐标正好落在边界时只属于一个格子避免同一个坐标在相邻帧之间跳格。ParallelWriter允许并行循环安全写入同一个哈希表。然后是力的计算 Job。这里我把结果写到BoidAcceleration而不是直接改BoidVelocity原因后文会讲[BurstCompile] public partial struct BoidForceJob : IJobEntity { [ReadOnly] public NativeParallelMultiHashMapint3, Entity CellMap; [ReadOnly] public ComponentLookupLocalTransform TransformLookup; [ReadOnly] public ComponentLookupBoidVelocity VelocityLookup; [ReadOnly] public BoidSimulationConfig Config; public void Execute( [EntityInQuery] in Entity self, in LocalTransform transform, in BoidVelocity velocity, ref BoidAcceleration acceleration) { float3 separation float3.zero; float3 alignment float3.zero; float3 cohesion float3.zero; int neighborCount 0; int3 center GridHash.WorldToCell(transform.Position, Config.CellSize); for (int dz -1; dz 1; dz) for (int dy -1; dy 1; dy) for (int dx -1; dx 1; dx) { int3 cell center new int3(dx, dy, dz); if (!CellMap.TryGetFirstValue(cell, out Entity other, out var it)) continue; do { if (other self) continue; float3 offset TransformLookup[other].Position - transform.Position; float distSq math.lengthsq(offset); if (distSq Config.PerceptionRadius * Config.PerceptionRadius) continue; float dist math.sqrt(distSq); separation - offset / (dist 0.01f); alignment VelocityLookup[other].Value; cohesion TransformLookup[other].Position; neighborCount; } while (CellMap.TryGetNextValue(out other, ref it)); } if (neighborCount 0) { float3 avgHeading alignment / neighborCount; float3 centerOfMass cohesion / neighborCount; acceleration.Value float3.zero; acceleration.Value separation * Config.SeparationWeight; acceleration.Value math.normalizesafe(avgHeading - velocity.Value) * Config.AlignmentWeight; acceleration.Value math.normalizesafe(centerOfMass - transform.Position) * Config.CohesionWeight; } else { acceleration.Value float3.zero; } } }为什么输出到BoidAcceleration而不是直接改BoidVelocity因为这里同时通过VelocityLookup读取邻居速度。如果让 job 自己既ref BoidVelocity又通过ComponentLookupBoidVelocity读就构成对同一组件的读写并发Unity 的依赖系统会拒绝或要求你用[NativeDisableContainerSafetyRestriction]硬压警告那是在埋雷。把「算力」和「用力改速度」拆成两个 Job依赖链清晰也没有 Permission 冲突。math.lengthsq在过滤前直接和半径平方比较省掉了大量暂时用不到的sqrt。只有确实进入感知范围的邻居才开根号计算距离这个优化在实体数上万时非常明显。3.3 依赖链与同步点三个 Job 的顺序不能乱完整的OnUpdate应该把 Build、Force、Move 串成一条依赖链var boidQuery SystemAPI.QueryBuilder() .WithAllBoidTag, BoidVelocity, BoidAcceleration, LocalTransform() .Build(); int count boidQuery.CalculateEntityCount(); var config SystemAPI.GetSingletonBoidSimulationConfig(); var map new NativeParallelMultiHashMapint3, Entity(count, Allocator.TempJob); var buildHandle new BuildCellMapJob { CellMap map.AsParallelWriter(), CellSize config.CellSize }.Schedule(state.Dependency); var forceHandle new BoidForceJob { CellMap map, TransformLookup state.GetComponentLookupLocalTransform(true), VelocityLookup state.GetComponentLookupBoidVelocity(true), Config config }.Schedule(buildHandle); var moveHandle new BoidMoveJob { DeltaTime SystemAPI.Time.DeltaTime, MaxSpeed config.MaxSpeed }.Schedule(forceHandle); state.Dependency map.Dispose(moveHandle);count用来预分配哈希表容量按「每只鸟一个键值对」来定。如果容量远小于实际条目数ParallelWriter需要扩容在 Job 执行中途扩容既慢又不安全。map.Dispose(moveHandle)会把释放动作排在 move 完成后再用返回值更新state.Dependency。这样下一帧的 Job 会等这一帧的释放结束不会出现上一帧哈希表还没回收、这一帧又开始写入的竞态。一个常见错误是把CalculateEntityCount()放进每帧执行还嫌慢其实它只是轻量查询不是遍历但如果你在OnUpdate中反复构造 EntityQueryBuilder那才是浪费。把 query 放到OnCreate里缓存是更专业的做法。3.4 Boids 三规则的参数表与调参方向参数初值建议影响PerceptionRadius5邻居数量约随半径立方增长CellSize等于 PerceptionRadius必须 感知半径否则漏邻居SeparationWeight1.0间距直觉越大越散AlignmentWeight0.8队形整齐度CohesionWeight0.5群体聚拢速度MaxSpeed8整体运动速度上限Fixed DeltaTime0.02越高越稳但肉眼看会发飘感知半径从 5 调到 8查询量大约变成原来的 4 到 8 倍BoidForceJob耗时能明显看到上升。如果此时视觉上鸟群还是散的优先调 weights 而不是继续加大半径。力太剧烈出现爆炸时先把三个 weight 各降一半再检查 MaxSpeed 是否远小于力的量级。4. 把 Boids 放进场景Spawner、LocalTransform 与渲染链路前面写完了模拟核心接下来要把一批实体真正放进场景里。4.1 用 EntityManager 产生 N 个 Boid而不是 InstantiateGameObject.Instantiate每次都要创建序列化对象、执行生命周期回调一次性生成上万只鸟会卡住 main thread。ECS 的做法是直接用EntityManager.CreateEntity拼组件。下面这段代码适合示例规模逻辑最直白public partial struct BoidSpawnerSystem : ISystem { public void OnCreate(ref SystemState state) { state.RequireForUpdateBoidSpawnerConfig(); } public void OnUpdate(ref SystemState state) { var spawner SystemAPI.GetSingletonBoidSpawnerConfig(); if (spawner.Spawned) return; var em state.EntityManager; var random new Unity.Mathematics.Random(spawner.Seed); for (int i 0; i spawner.Count; i) { var pos new float3( random.NextFloat(-spawner.BoundSize, spawner.BoundSize), 0f, random.NextFloat(-spawner.BoundSize, spawner.BoundSize)); var entity em.CreateEntity(); em.AddComponentData(entity, new LocalTransform { Position pos, Rotation quaternion.identity, Scale 1f }); em.AddComponentData(entity, new BoidVelocity { Value math.normalizesafe(random.NextFloat3()) * spawner.MaxSpeed }); em.AddComponentData(entity, new BoidAcceleration()); em.AddComponentData(entity, new BoidTag()); } spawner.Spawned true; em.SetComponentData(SystemAPI.GetSingletonEntityBoidSpawnerConfig(), spawner); } } public struct BoidSpawnerConfig : IComponentData { public int Count; public float BoundSize; public float MaxSpeed; public uint Seed; public bool Spawned; }这个生成系统我没有加[BurstCompile]因为一次性生成工作在 main thread 上跑不值得为它引入 Burst 兼容性限制。Seed字段很关键后面调参会用到。逐个AddComponentData在 1 万以内完全够用到了 10 万应该换成先创建Archetype再用CreateEntity(archetype, count, Allocator.Temp)批量创建。4.2 LocalTransform 与 TransformAspect 的更新方式在这个模拟里Job 直接ref LocalTransform是成本最低、最直接的写法。Unity.Transforms后面的系统会自动用LocalTransform重建LocalToWorld渲染层读取LocalToWorld所以不要手动去改后者否则下一帧会被覆盖。如果你在 main thread 上需要方便地对单个实体做位置变换可以用 Aspectref var aspect ref SystemAPI.GetAspectRWTransformAspect(entity); aspect.LocalPosition newPos;但注意GetAspectRW会引入对这个实体的写访问在并行 Job 里不要这么干。Boids 的并行路径始终走ref LocalTransformAspect 只留给编辑器工具或单向调试。4.3 边界策略Wrap 与 Bounce 的取舍方案实现优点缺点Wrap超界后坐标取负实现最便宜鸟永远在视野内群飞过边界瞬间瞬移Bounce反向速度视觉连续需要处理角点极端情况震荡Steer在边界内加回转向力像有隐形的墙多一面墙就多一份复杂度示例项目用 Wrap 最常见因为 Boids 重点看的是三规则涌现的群态边界只是防止跑丢。BoundaryWrapJob 放在 MoveJob 之后调度[BurstCompile] public partial struct BoundaryWrapJob : IJobEntity { public float BoundSize; public void Execute(ref LocalTransform transform) { var p transform.Position; if (p.x BoundSize) p.x -BoundSize; if (p.x -BoundSize) p.x BoundSize; if (p.z BoundSize) p.z -BoundSize; if (p.z -BoundSize) p.z BoundSize; transform.Position p; } }注意 Wrap 不要处理 y 轴否则鸟会在垂直方向跳来跳去。如果模拟已经限制在 XZ 平面让 y 保持 0 就行。还要保证BoundSize与 Spawn 时的范围一致否则鸟可能在出生点就处于「下一帧要被包回去」的状态。5. 调参技巧固化随机种子用 Profiler 拆解 Boids 的三个 Job5.1 固定随机种子前面BoidSpawnerConfig里特意留了Seed字段就是为了这一步。调参时固定种子例如 100保证每次都得到相同的初始分布否则你只是改了参数初始分布也在变根本分不清效果来自哪个变量。等参数稳定后再换成随机种子看最终效果是否普遍成立。5.2 Profiler 里看 BoidForceJob 与 BoidMoveJob 的占比打开 Window Analysis Profiler切到 CPU Usage选中时间轴里的BoidSystem片段能看到 BuildCellMapJob、BoidForceJob、BoidMoveJob 三个并行时间片。如果 force 占了大半时间说明邻居查询是瓶颈优先调 CellSize 与 PerceptionRadius如果 move 比 force 还久多半是没开 Burst 或者依赖链里插入了额外同步点。顺手打开 Window Burst Enable Compilation没有 Burst 的 Job 性能会差 5 到 10 倍这是示例 zip 里最常见的「为什么我跑不起来」原因。5.3 一个可以立刻落地的收尾技巧先调 CellSize再调 PerceptionRadius很多人直接把 PerceptionRadius 从 5 改到 10结果模拟几乎没变化因为 CellSize 没跟着改27 个格子只覆盖原来一半的感知范围。正确顺序是先改 CellSize 等于新的感知半径再微调 PerceptionRadius 观察邻居数。如果鸟群运动过于混乱把 MaxSpeed 和三个 weight 各降一半先看稳定性如果跑得太平优先升 AlignmentWeight 而不是 CohesionWeight。格子边不要小于感知半径力不要给到超过速度上限的量级种子固定才能对比结果——记住这三条Boids 就不会跑偏。本文还有配套的精品资源点击获取
返回列表