前阵子接了一个小型独立游戏项目的外包需求功能平平无奇结果卡在了一个看起来不那么起眼的地方——要在一张2D地图上做一大群鱼在水底巡游的效果。手摆动画不现实用寻路脚本一个个控制又太死板群里有人提了一句“试试Boids”我才重新把这套经典群集算法捡起来。做完之后发现这玩意儿在Unity2D里的实现难度其实不高但坑不少尤其是规则权重的调参、邻居查找的性能问题以及和Unity物理系统“错误天然耦合”这一点几乎每个人都会踩。这篇文章就把我在Unity2D里实现Boids的完整过程写出来从算法原理到C#代码从500只到5000只的性能优化再到怎么把它真正接入游戏逻辑希望能帮你少走弯路。1. 先搞清楚Boids在解决什么问题1.1 从鸟群到鱼群局部规则如何涌现出群体智能Boids最早由Craig Reynolds在1987年提出名字本身就是“bird-oid objects”的缩写。它的核心思想非常反直觉你不需要给每一只鸟下发“跟着大部队飞”的全局指令只需要让每个个体根据自己视野内少数几个邻居的状态做简单计算整个群体就能自发形成极其协调的飞行队列。这个现象在人工智能领域叫“涌现行为”放到游戏开发里就是一群鱼躲避鲨鱼时整齐散开、一群蝙蝠在洞穴里绕灯盘旋这种看着很高级、实际很好骗的效果。我当时接到需求后第一反应是想用Unity自带的NavMesh给每条鱼各自寻路结果被性能和时间成本劝退了。主角哪怕只有100条鱼每条都跑一次NavMesh路径更新都是灾难而且寻路结果太“理性”每条鱼走的路线几乎一样一眼就能看出是复制粘贴的行为。Boids天然带随机性和局部性反而更适合做这种看起来“活”的群体效果。1.2 三大规则分离、对齐、聚合Boids由三条极其简单的规则组成理解它们是后续所有调参和扩展的基础分离Separation每个个体要避开离自己过近的邻居防止群体内部发生挤压。它的作用范围最小通常只在紧邻的几个个体之间生效。对齐Alignment每个个体尽量让自己的飞行方向与周围邻居的平均方向一致。这条规则决定了群体的整体走向。聚合Cohesion每个个体要朝邻居群体的密度中心移动。这条规则保证群体不会散成碎片始终抱团。这三条规则叠在一起就能产生极其丰富的运动模式。注意三条规则不是简单加在一起就完事它们各自有作用范围、有权重、有最大力限制处理不好就会出现三种典型翻车现象所有鸟粘成一团、所有鸟四散而逃、或者整个群体陷入震荡抽搐。后面我专门用一节讲参数怎么配。1.3 邻域半径三条规则共同依赖的“视野”三条规则的输入都一样——每个个体要找自己的“邻居”。什么是邻居不是场景里所有Boid而是在指定半径范围内的那些个体。这个半径叫neighborhood radius通常用neighborRadius表示。判断邻居这一步是整个Boids的性能瓶颈。最朴素的做法是两层循环外层遍历所有个体内层再遍历所有个体判断距离是否小于半径复杂度是O(n²)。n100还好n1000就是100万次距离计算每帧算一次直接卡成PPT。所以一般实现Boids至少会做一个空间分桶来加速邻居查找这我在第5节重点写。此外2D的Boids在判断邻居时通常还会加一个“视野角度”限制模拟生物的FOV。鱼和鸟虽然视野广但也不是360度无死角。加上视野角之后群体会形成更自然的队形而不是每个个体都被身后力量拽着跑。2. Unity2D场景搭建与脚本骨架设计2.1 从空场景到第一只Boid预制体与外观设计在Unity里创建一个2D项目我习惯先把基础场景搭好再写逻辑。操作步骤很简单创建一个空物体挂上后续的BoidManager脚本作为全局控制器。创建一个Sprite物体作为单条Boid的预制体。外观可以先用Unity内置的三角形Sprite用白色三角形贴图即可或者简单用一张圆形图片运行时通过旋转表现朝向。给预制体挂上Boid组件把预制体存成Prefab。这里有个外观细节我要特别建议在2D里表现鱼或鸟最好把图形的“头部”侧向一边而不是正面对着你。这样运行时把物体的transform.up或transform.right对齐到速度方向就能自然表现出鱼头朝前的效果。如果你用Sprite Renderer还需要注意图片本身的朝向尽量在资源导入时就把正向朝右省得后续代码里到处补90度偏转。2.2 为什么我不建议让Boids走Unity物理系统很多人第一次实现Boids会顺手给每个个体加一个Rigidbody2D然后通过AddForce来驱动运动想着物理系统还能顺便处理碰撞。我用过之后强烈建议如果你想控制一群鱼或鸟的行为不要这么做。原因有三点Unity的物理引擎默认会处理刚体之间的碰撞Boids本身就是靠分离规则避免重叠的不需要物理碰撞参与。两者叠加会出现“推来推去”的抖动效果。物理系统的FixedUpdate和游戏逻辑的Update是不同频率如果你的Boid数量变多物理步进的调度会占掉大量CPU时间。2D物理的碰撞体数量一多物理世界的Broadphase检测本身就很费这个开销完全是浪费。我给Boids用的是纯运动学模型直接在Update或ManagedUpdate里计算速度然后手动设置transform.position。如果你担心穿透问题只需要把分离规则做扎实加上边界限制个体之间就不会重叠。物理引擎解决的是“真实物体碰撞”Boids解决的是“规则驱动的群体运动”两种需求不要混在一起。2.3 数据流设计谁负责感知谁负责移动在设计脚本骨架时我建议把“感知数据”和“驱动行为”分成两个角色Boid组件只负责保存个体的位置、速度、加速度以及暴露一个Steering方法用来接收外力。BoidManager组件负责管理所有Boid的列表在每一帧遍历所有个体计算邻居集合算出三条规则的合力然后回调每个Boid完成速度更新和位置更新。为什么不让每个Boid自己去找邻居因为邻居查找需要遍历全数组如果每个个体都自己遍历逻辑上没问题但后续想做空间网格、Job System就很别扭。集中管理最大的好处是未来把整个邻居查找和规则计算搬进NativeArray和Unity Job System时不需要大改Boid的代码结构。数据流示意图文字版BoidManager 收集所有 Boid 的位置/速度 ↓ 空间网格加速邻居查找 ↓ 对每个个体计算 分离 对齐 聚合 三个力 ↓ Boid 更新速度速度更新位置旋转朝向3. 三大规则从数学公式到C#代码3.1 数据结构Boid组件与可调配置先看基础代码这段是挂在预制体上的组件using UnityEngine; public class Boid : MonoBehaviour { public Vector2 velocity; public Vector2 acceleration; public float maxSpeed 5f; public float maxSteerForce 3f; public void UpdateBoid() { velocity acceleration * Time.deltaTime; velocity Vector2.ClampMagnitude(velocity, maxSpeed); // 如果速度方向变化物体朝向跟着转动 if (velocity.sqrMagnitude 0.01f) { float angle Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg; transform.rotation Quaternion.Euler(0, 0, angle); } transform.position (Vector3)(velocity * Time.deltaTime); acceleration Vector2.zero; } public void ApplySteering(Vector2 force) { acceleration force; } }然后是配置参数建议用一个ScriptableObject或一个普通的BoidConfig类来集中管理避免在Inspector上反复调一堆公共字段。我在这里用普通类保持代码精简[System.Serializable] public class BoidConfig { public float neighborRadius 2.5f; public float separationRadius 1.2f; public float separationWeight 1.8f; public float alignmentWeight 1.0f; public float cohesionWeight 1.2f; public float maxSpeed 5f; public float maxSteerForce 3f; }注意separationRadius一定小于neighborRadius否则分离规则的作用范围会盖过邻居范围导致每个个体都试图远离很远的个体群体根本无法形成。这是我第一次调参时犯的错后面细说。3.2 分离Separation让“个人空间”不被侵入分离规则的核心是对每个邻居计算一个从邻居指向自身的向量权重与距离成反比距离越近推力越强。这样个体就不会和邻居重叠。在BoidManager里核心代码如下为了可读性我按规则拆开private Vector2 ComputeSeparation(Boid boid, ListBoid neighbors, BoidConfig config) { Vector2 steer Vector2.zero; int count 0; foreach (var other in neighbors) { Vector2 diff (Vector2)boid.transform.position - (Vector2)other.transform.position; float dist diff.magnitude; if (dist config.separationRadius dist 0.01f) { // 距离越近权重越大 float weight 1f - (dist / config.separationRadius); steer diff.normalized * weight; count; } } if (count 0) { steer / count; steer.Normalize(); steer * config.maxSteerForce; } return steer; }这段代码里有个容易被忽略的点dist 0.01f是为了防止两个个体完全重叠时出现向量归一化零向量的问题。重叠状态下diff是零向量Normalize()会给出一个毫无意义的向量。如果你的场景允许个体出生点完全重合一定要记得加这个保护。3.3 对齐Alignment融入周围的主流方向对齐规则计算邻居速度的平均向量然后把自己当前速度朝这个平均方向拉动。注意我们要“拉”的方向是平均速度方向不是把平均速度直接赋给自己。直接赋值会导致鱼群在转向时失去惯性看起来像瞬移缺乏流畅感。private Vector2 ComputeAlignment(Boid boid, ListBoid neighbors, BoidConfig config) { Vector2 avgVelocity Vector2.zero; int count 0; foreach (var other in neighbors) { avgVelocity other.velocity; count; } if (count 0) { avgVelocity / count; Vector2 desired avgVelocity.normalized * config.maxSpeed; Vector2 steer desired - boid.velocity; steer Vector2.ClampMagnitude(steer, config.maxSteerForce); return steer; } return Vector2.zero; }这个逻辑和经典Steering Behavior里的Arrive行为很像先根据期望速度得到期望方向再计算当前速度与期望速度的差这个差值就是转向力。Clamp到maxSteerForce可以让转向更柔和不会因为邻居数量少导致瞬间暴力转向。3.4 聚合Cohesion向群体的密度中心靠拢聚合规则是所有邻居的位置平均值这个平均值就是当前群体中心质心。理论上每个个体都朝这个质心加速整个群体就能自然而然抱团。实现时我通常会先算中心点再调用一个“向目标点Steering”的通用函数private Vector2 ComputeCohesion(Boid boid, ListBoid neighbors, BoidConfig config) { Vector2 center Vector2.zero; int count 0; foreach (var other in neighbors) { center (Vector2)other.transform.position; count; } if (count 0) { center / count; Vector2 desired (center - (Vector2)boid.transform.position).normalized * config.maxSpeed; Vector2 steer desired - boid.velocity; steer Vector2.ClampMagnitude(steer, config.maxSteerForce); return steer; } return Vector2.zero; }如果聚合权值设得过大会出现一个经典现象整群个体缩成一个无限接近的点看起来像所有鱼重叠在一起。这个现象是因为越靠近质心聚合力的方向就越随机但分离力已经处于弱势重叠不可避免。解决方式就是分离权重要压过聚合权重或者给聚合加一个“只对一定距离外的个体生效”的阈值。3.5 三力合成权重不是越多越好三个力算完之后BoidManager把它们加权求和Vector2 steering Vector2.zero; steering separation * config.separationWeight; steering alignment * config.alignmentWeight; steering cohesion * config.cohesionWeight; steering Vector2.ClampMagnitude(steering, config.maxSteerForce); boid.ApplySteering(steering);这个maxSteerForce是用来限制每帧转向力的上限防止某个力太大导致速度突变。一个比较合适的初始值范围是maxSpeed的0.5~0.8倍。如果转向力上限超过最大速度个体可能会冲出群体然后被聚合拉回来来回震荡。3.6 把整个计算循环串起来在BoidManager的Update里完整流程是这样的using System.Collections.Generic; using UnityEngine; public class BoidManager : MonoBehaviour { public ListBoid allBoids new ListBoid(); public BoidConfig config; void Update() { // 第一步收集所有个体的位置与速度如果后面要空间网格这里顺便填充网格 // 第二步逐个计算邻居并施加力 foreach (var boid in allBoids) { ListBoid neighbors FindNeighbors(boid, config.neighborRadius); Vector2 separation ComputeSeparation(boid, neighbors, config); Vector2 alignment ComputeAlignment(boid, neighbors, config); Vector2 cohesion ComputeCohesion(boid, neighbors, config); Vector2 steering separation * config.separationWeight alignment * config.alignmentWeight cohesion * config.cohesionWeight; steering Vector2.ClampMagnitude(steering, config.maxSteerForce); boid.ApplySteering(steering); } // 第三步所有个体更新位置 foreach (var boid in allBoids) { boid.UpdateBoid(); } } private ListBoid FindNeighbors(Boid boid, float radius) { ListBoid neighbors new ListBoid(); foreach (var other in allBoids) { if (other boid) continue; float dist Vector2.Distance(boid.transform.position, other.transform.position); if (dist radius) { neighbors.Add(other); } } return neighbors; } }注意一个重点先算完所有个体的力再统一移动所有个体。如果你在循环里算完一个就移动一个那么后移动的个体会看到已经移动后的邻居状态导致行为出现偏差整个系统不再收敛可能出现群体自发旋转或散开。这个顺序问题我在第4节专门讲。4. 跑通Demo后的关键调试边界处理与参数调教4.1 边界选择环回边界vs软边界Boids需要一个边界否则鸟群会飞出屏幕。常见方案有两种环回边界个体超出边界后出现在另一侧像秀逗魔导士里的世界一样首尾相连。优点是群体永远在屏幕内适合做鱼群巡游的循环效果。缺点是个体穿越屏幕时看到的行为不连续仔细观察会有“闪现”感。软边界个体靠近边界一定距离时给它施加一个朝屏幕中心方向的推力。这是我认为游戏开发中最常用的方案因为视觉上自然而且参数好调——一个boundaryMargin比如2个屏幕单位和一个boundaryForce比如maxSteerForce的1.5倍就够private Vector2 ComputeBoundaryForce(Boid boid, Vector2 min, Vector2 max, float margin, float force) { Vector2 steer Vector2.zero; Vector2 pos boid.transform.position; if (pos.x min.x margin) steer.x force; else if (pos.x max.x - margin) steer.x - force; if (pos.y min.y margin) steer.y force; else if (pos.y max.y - margin) steer.y - force; return steer; }软边界的力要加在ApplySteering里不过要注意边界力不能太大否则个体在边界附近会“弹反”得很生硬。更柔和的做法是让力的强度随距离增加而加强这样鱼群在靠近边界时先减速、再掉头看起来更自然。4.2 调参实录从一坨混乱到秩序井然的参数区间我最初用默认参数跑屏幕上800条鱼直接搅成一团白噪声——速度一直在变方向混乱像一群无头苍蝇。后来我整理了一份调参顺序参数作用我的推荐初始值separationRadius个体之间的个人空间半径1.2比Sprite直径略大一点separationWeight分离强度1.5~2.0neighborRadius整体邻居视野半径2.5~4.0alignmentWeight对齐强度0.8~1.2cohesionWeight聚合强度1.0~1.5maxSpeed最大速度4~6maxSteerForce最大转向力maxSpeed * 0.6调参顺序建议从“分离”开始先把alignmentWeight和cohesionWeight设成0把所有个体撒在一个小区域内观察是否所有个体都能稳定地保持互不重叠。然后再缓缓加cohesionWeight看群体是否能聚拢最后加alignmentWeight让群体运动出现“队形感”。参数组合有个经验法则neighborRadius与separationRadius的比值最好在2~4之间。比值太小个体没有足够的信息感和群体归属感比值太大所有个体都互相影响群体变成一个整体大刚体失去了局部分散的趣味。另外maxSteerForce别设太高否则转向太灵敏导致高频震颤画面会非常“躁”。4.3 “幽灵邻居”问题先计算后移动的顺序陷阱这个坑我在第3.6节提过但实际效果只有亲眼看到才明白多严重。如果你在同一个循环里一边计算分离力一边移动个体后面被遍历的个体会在寻找邻居时看到已经被移动过的“未来位置”这会导致一个非常恶心的现象整个群体自发地绕着一个圈子旋转速度越来越快最后甩成旋转木马。为什么会出现这个现象因为先移动的个体相对于后移动的个体产生了一个“未来的邻居偏移”该偏移反向作用到了后移动的个体上形成一个方向一致的系统性误差这种误差很快会被Boids的正反馈放大成整体旋转。解决办法其实简单力的计算循环和位置更新循环必须分开。我在项目里甚至会把邻居数据先缓存到一个NativeArray或List里再在第二个循环里使用。这看起来微不足道但直接决定你的群集是否收敛。5. 性能优化实战从500只到5000只5.1 空间哈希网格去掉O(n²)的最朴素优化如果只是做500只左右直接两层循环勉强能跑取决于你目标帧率和平台。但如果要上2000、5000只必须做空间分桶。思路不复杂把2D世界划分成大小固定的小格子每个格子边长等于neighborRadius或者比它稍大一点点。每帧把所有个体按坐标放入对应格子查找邻居时只需要检查相邻9个格子里的个体即可。这样复杂度从O(n²)降到接近O(n)因为每个格子里的平均个体数远小于总数。代码实现我建议用DictionaryVector2Int, ListBoid来存格子using System.Collections.Generic; using UnityEngine; public class SpatialGrid { private float cellSize; private DictionaryVector2Int, ListBoid cells; public SpatialGrid(float cellSize) { this.cellSize cellSize; cells new DictionaryVector2Int, ListBoid(); } public void Clear() { cells.Clear(); } public void Insert(Boid boid) { Vector2Int cell GetCell(boid.transform.position); if (!cells.TryGetValue(cell, out ListBoid list)) { list new ListBoid(); cells[cell] list; } list.Add(boid); } public ListBoid GetNeighbors(Vector2 position, float radius) { ListBoid result new ListBoid(); Vector2Int min GetCell(position - Vector2.one * radius); Vector2Int max GetCell(position Vector2.one * radius); for (int x min.x; x max.x; x) { for (int y min.y; y max.y; y) { Vector2Int cell new Vector2Int(x, y); if (cells.TryGetValue(cell, out ListBoid list)) { for (int i 0; i list.Count; i) { Boid b list[i]; float dist Vector2.Distance(position, b.transform.position); if (dist radius) result.Add(b); } } } } return result; } private Vector2Int GetCell(Vector2 pos) { return new Vector2Int(Mathf.FloorToInt(pos.x / cellSize), Mathf.FloorToInt(pos.y / cellSize)); } }这里的cellSize推荐等于neighborRadius。如果格子太小查找时要把更小范围的格子全部遍历如果太大一个格子里的个体数量过多加速效果不明显。用Dictionary实现简单但每次Clear()会有GC压力2000只时可以接受5000只时我建议直接用二维数组或者NativeArray来避免字典开销。不过在Demo阶段字典方案已经完全够用且最易维护。5.2 使用Job System和Burst Compiler进一步提速空间网格能撑到3000只左右如果还要再上量且目标是以手机或低端PC为基准建议用Unity的Job System Burst。思路是把Boid数据搬进NativeArray直接在多线程Job里完成力计算。一个Job化的大致结构是这样的用NativeArrayVector3存位置和速度。用NativeMultiHashMapint, int或NativeParallelMultiHashMapint, int存格子索引到个体编号的映射。每个线程并行处理一个个体查找邻居、计算三大规则、输出加速度。Job用起来会引入不少代码量但对性能是质变。这里给个简单骨架[BurstCompile] struct BoidJob : IJobParallelFor { [ReadOnly] public NativeArrayVector2 positions; [ReadOnly] public NativeArrayVector2 velocities; public NativeArrayVector2 accelerations; [ReadOnly] public NativeParallelMultiHashMapint, int grid; public float cellSize; public float neighborRadius; public float separationRadius; public float maxSpeed; public float maxSteerForce; public float separationWeight; public float alignmentWeight; public float cohesionWeight; public void Execute(int i) { // 在这个函数里做单独的个体计算 // 通过网格查找邻居然后把结果累加到 accelerations[i] } }说实话第一次写Boids就把全部代码Job化不是一个好选择因为调试会很痛苦。我的建议是先把第3节的纯C#版本跑通确认行为正确再逐步迁移到Job。如果项目目标平台是中高端PC或主机Job化后上万只都没问题。5.3 渲染优化群集卡顿不一定是CPU的锅很多人做优化只关注算法复杂度忽略了渲染瓶颈。2000个Sprite如果都在屏幕上渲染批处理又不合适DrawCall会直接爆炸。我在优化时通常做三件事使用GPU Instancing给Boid预制体的Sprite Renderer开启GPU Instancing并且确保所有个体共用同一个材质和纹理。这样的话成千上万个Sprite的DrawCall可以压缩到个位数。避免每个个体单独挂复杂组件比如不必要的Collider2D、Animator、AudioSource这些组件在2000个个体上的开销远大于Boids本身。使用遮罩剔除如果群体很大屏幕外的个体不参与渲染直接配合Unity的Culling Group或手动判断屏幕边界。还有一个容易被忽视的点transform.position的更新在2000个物体时也会产生不少CPU开销因为每改一次位置都会触发Transform的脏标记与层级刷新。对于极大规模效果可以考虑用Graphics.DrawMeshInstanced或Entities Graphics直接绕开Transform但这类方案更适合技术Demo直接放进游戏项目会增加不少复杂度。做游戏功能2000只以内用常规Prefab加Sprite Renderer配合Instancing就完全够。6. 从DEMO到真实游戏项目群集行为的上层扩展6.1 视觉表现增强鱼群、鸟群和“异形蜂群”的区别Boids核心逻辑是通用的但不同生物的表现完全不同。拿我这次做的鱼群来说鱼的游动带明显的身体摆动如果让Sprite朝向每帧精确跟随速度方向看起来会像机器人。通常我会在Boid.UpdateBoid()里只做“缓慢旋转”而不是“瞬转”可以给旋转加一个RotationSpeed限制float currentAngle transform.eulerAngles.z; float targetAngle Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg; float newAngle Mathf.MoveTowardsAngle(currentAngle, targetAngle, rotationSpeed * Time.deltaTime); transform.rotation Quaternion.Euler(0, 0, newAngle);rotationSpeed的推荐值是150~300度/秒太小转向显得迟钝太大又失去群集本身的聚合感。另外鸟群和鱼群的视觉细节也不同鸟群在飞行时翅膀摆动幅度和速度相关鱼群游动时身体弯曲幅度也和速度相关。你可以用简单的SpriteRenderer.flipY配合速度大小做动画状态切换或者用Animator的bool参数控制快慢动画。这个层面的工作不难但对最终的游戏画面质感提升非常大。6.2 与导航、关卡障碍物的结合让群体聪明起来纯Boids跑在没有障碍物的空场景里没问题但真实游戏里总有柱子、岩石、树丛。处理障碍物的常见方法是给每个个体额外加一个“避障力”ObstacleAvoidance通过射线或者基于距离场判断前方是否出现危险物体。有一种轻量级做法是把障碍物也当成“巨大的虚拟Boid”来对待——在障碍物周围生成一组排斥力点这些点对所有Boid产生类似分离力的斥力。好处是代码简单不用引入寻路坏处是如果障碍物太多要手动画很多点不够灵活。如果项目已经有NavMesh或Pathfinding系统还有一个思路群体中只让一个“领导者”Boid做真正的寻路其余个体通过Boids规则跟随领导者。这样既保留了群集视觉效果又只付出单个个体的寻路成本。这个方法叫“Leader Following”在游戏里非常常用。我当时做鱼群时就指定一个“头鱼”负责沿着设定路径游动剩下的鱼单纯靠聚合规则自发跟着走效果出乎意料地自然。6.3 群体事件敌人靠近时如何“炸开”除了三大规则Boids最有趣的地方是可以很轻松地叠加“外部事件”力。比如玩家角色靠近时鱼群应该四散逃窜。实现方式很简单检测到玩家在一定距离内时给每个Boid额外施加一个“逃离力”方向是从玩家位置指向当前个体强度随距离衰减。这个力的权重甚至可以动态变化——距离越近权重越大形成“一石惊起千层浪”的扩散效果。同样如果你要做“群体觅食”只需要加一个“朝向食物源”的力要做“群体警戒”只需要把邻居半径调大个体就会更敏锐地感知到同伴的动向。这本质上是把Boids当成一套“感知-决策-行动”的框架规则是插槽业务逻辑按需注入。6.4 从DEMO到正式项目前最后再检查一遍的参数清单如果把Boids搬进正式项目建议按这份Checklist逐个检查是否做了先计算后更新的两阶段循环如果不是群体会出现自发旋转。是否设置了maxSterForce上限没有的话群体转向时会发生高频抖动。邻居查找是否使用了空间网格数量超过300只却依赖O(n²)的循环卡是必然的。边界力是否和三大规则叠加正确边界力过大个体在边界处会出现“撞墙反弹”效果。是否有随机初始化所有个体出生在同一个点且速度方向相同会导致最初几帧出现极不自然的重叠。我个人在实际调试中最容易忽略的其实是初始化。Boid出生坐标如果完全一样分离规则在最初几帧几乎不生效因为完全重叠时diff是零向量即使有保护也会导致初期的排布不稳定。建议初始化时给位置加一点随机偏移速度方向也做±30度的随机扰动这样群集在开局几秒就能快速进入稳定状态画面看起来也更生动。回头再看整个实现过程Boids的精髓在于“用极简的局部规则组合出复杂的整体行为”。这个特性放到游戏开发里尤其宝贵你不需要为每一条鱼写特殊的AI脚本不需要给玩家设计复杂的寻路系统只要配好参数、叠加几个业务力一个鲜活的群体就出现了。如果后续你想挑战更大的规模或更复杂的表现可以从Job化、ECS、以及Boids与感知网格的结合这几个方向深入每一层都还有不少东西可以挖。
阅读完成 · 觉得有帮助?