
做游戏时只要场景里出现一群角色你早晚会碰上一个问题明明数量够了画面却假得离谱。鱼群挤成一团互相穿模鸟群飞起来像阅兵方阵人群走位更是毫无生气。去年我在Unity2D里重做水下场景的鱼群系统时最先想到的是给每条鱼单独写状态机结果鱼一多代码爆炸行为还是僵。后来换成Boids算法三个规则加几个权重就把整个鱼群盘活了而且全程只需要纯C#连Rigidbody2D都不用挂。这篇内容就完整记录我怎么在Unity2D中把Boids落地包括规则拆解、核心脚本、三百条鱼不卡的优化手段以及调参过程中踩进去的坑做群集类玩法或者想学习群体AI的朋友可以直接抄作业。1. 核心思路拆解Boids算法在解决什么问题1.1 一个1986年提出的经典模型Boids这个名称来自bird-oid object也就是类鸟物体是Craig Reynolds在1986年发表的论文里提出的群体行为模拟算法。论文里的核心洞察其实非常朴素一只鸟并不需要理解整个鸟群要飞向哪里它只需要感知自己附近几只鸟的位置和方向遵守极少数简单规则整个群体就会自然涌现出复杂的编队、转向、散开等行为。这种简单个体局部规则复杂群体行为的思想后来在游戏NPC、电影特效、动画群组、机器人编队等领域被用到发烂发臭。我把这套算法迁移到Unity2D里时感受最深的一点是Boids并不依赖中心化的控制逻辑。每条鱼自己管自己它看到的邻居只是半径范围内那几个对象。整个鱼群没有领头鱼去下达命令但看起来却有明确的队形和转向趋势这种涌现效果正是游戏群组表现最需要的质感。它不像Animator预设动画那样每条鱼行为完全一致也不像神经网络那样需要训练数据三个力算一算就能出效果。1.2 为什么Unity2D天然适合做这件事Unity2D的坐标系、Transform组件和Sprite系统让Boids的实现难度低很多。2D场景中每个物体只有X、Y两个坐标轴需要处理速度方向可以用一个Vector2表示转向角度通过Z轴旋转就能完成数学模型和渲染表现之间几乎不需要额外的转换层。换成3D场景你还要面对四元数、欧拉角、万向锁这些额外负担调试起来也更费劲。另一个现实原因是Unity2D的物理系统在这里属于多余负担。Boids本质是一个运动学模型它计算的是速度的增量不是受力的仿真所以完全可以用Update里直接改Transform的方式来实现。我之前尝试过给每条鱼挂Rigidbody2D然后用AddForce驱动结果场景里几百个刚体同时做物理运算开销陡增还时不时触发碰撞响应导致鱼群行为走样。放弃物理引擎之后性能立刻好转行为也更好控制。2. 三个核心规则的数学拆解与实现要点2.1 分离规则别和邻居挤成一团分离规则解决的是个体间距问题。当一条鱼发现自己和其他邻居距离过近时需要施加一个远离邻居的力。这个力的方向是从邻居指向自己强度可以和距离挂钩距离越近力越强。公式上可以写成对每个邻居累加自身位置减邻居位置的向量再除以距离做归一化相当于给近距离邻居更大的排斥权重。实际编码时有一个细节要注意直接用offset.normalized / dist这种形式会在距离趋近0时产生极大的力导致鱼突然弹飞。我习惯给分母加一个极小值保护比如Mathf.Max(dist, 0.001f)或者用offset / (dist * dist)这种平方衰减形式效果更平滑。分离半径通常设置得比感知半径小很多如果两者相等每一条鱼都会对周围所有邻居产生排斥群体就会像爆米花一样炸开。2.2 对齐规则让个体朝邻居的平均方向移动对齐规则让群体拥有统一的行进方向。每个个体需要检查感知范围内的邻居速度计算它们的平均速度方向然后让自己趋向这个方向。这条规则是鸟群保持整体协调性的关键没有对齐群体虽然聚集在一起但方向混乱看起来像无头苍蝇对齐权重太高群体又会过于整齐每条鱼都朝着几乎相同的方向飞失去自然感。在Unity2D实现里对齐力通常不直接赋值速度而是作为转向力的一部分参与速度更新。标准的转向行为写法是先求期望速度和当前速度的差值再把这个差值限制在最大转向力范围内。如果直接让速度瞬间跳到邻居平均方向鱼就会看起来像瞬移转向没有任何惯性和过渡。Reynolds原始论文里的Boids也是采用这种平滑转向的思路不是简单速度加权。2.3 聚合规则拉近个体与群体质心的距离聚合规则保证群体不会散成若干个小碎片。每个个体需要找到感知范围内所有邻居的平均位置然后产出一个向这个平均位置移动的力。这个力让整个群体维持一个整体不管群体怎么转向个体之间始终存在吸引力。聚合力和分离力是一对互斥力。分离力短距离生效负责防止个体重叠聚合力范围更广负责把整个群体拉在一起。两者同时存在时群体会自然到达一个平衡状态太近了被推开太远了被拉回。这个平衡距离不是显式设定的而是由分离半径、聚合权重和邻居数量共同决定的所以调参时别指望一次调到位需要反复试。2.4 三力合并一个计算框架全部收下三力合并的逻辑并不复杂把分离力、对齐力、聚合力分别乘上各自的权重系数然后相加得到合力。权重系数决定了哪种行为占主导。在我实际项目里一般初始化权重如下规则权重建议调参观察分离1.2 ~ 1.8过大群体炸开过小个体重叠对齐0.8 ~ 1.2过大钢铁方阵过小方向混乱聚合0.5 ~ 1.0过大团成球过小群体离散把合力归一化乘上最大速度得到期望速度再和当前速度做差得到转向力最后限幅后加到速度上。这个流程本质上是一个负反馈控制系统每一步更新都在把个体的运动状态往邻居们期望的方向拉因此群体行为天然具有稳定性。3. 从零搭建可运行的Unity2D Boids项目3.1 场景搭建与预制体设计先在Unity中新建2D工程创建一张Sprite作为Boid的预制体。我用的是一张尖角朝右的三角形图片长度大约0.3个单位颜色随意在预制体上挂SpriteRenderer和Boid脚本。注意三角形尖端朝右还是朝上会影响后面的旋转偏移量Unity2D中默认认为一个Sprite的前方是右边X轴方向如果素材尖端朝上旋转时要额外减90度。场景里加一个空物体挂BoidManager并在Manager上配置预制体引用、初始数量、生成范围和世界边界。世界边界建议直接用公开字段暴露在Inspector里方便调整不需要写死。生成位置用Random.Range在矩形区域内撒点初始速度方向随机化这样模拟一开始就处于一个非平衡态群体运动的涌现效果肉眼可见。3.2 管理器脚本统一的邻居信息收集为了避免每条鱼自己遍历所有其他鱼我让BoidManager统一收集所有Boid的位置和速度数组每帧调用UpdateBoid时传给每个实例。这样至少把邻居列表这个概念收敛到了管理器内部后续做性能优化时也直接在管理器里动手不需要改Boid脚本。using UnityEngine; public class BoidManager : MonoBehaviour { public Boid boidPrefab; public int boidCount 150; public Vector2 spawnArea new Vector2(10f, 6f); public float worldWidth 12f; public float worldHeight 8f; private Boid[] boids; void Start() { boids new Boid[boidCount]; for (int i 0; i boidCount; i) { Vector2 pos new Vector2( Random.Range(-spawnArea.x * 0.5f, spawnArea.x * 0.5f), Random.Range(-spawnArea.y * 0.5f, spawnArea.y * 0.5f) ); boids[i] Instantiate(boidPrefab, pos, Quaternion.identity); } } void Update() { Vector2[] positions new Vector2[boidCount]; Vector2[] velocities new Vector2[boidCount]; for (int i 0; i boidCount; i) { positions[i] boids[i].transform.position; velocities[i] boids[i].velocity; } for (int i 0; i boidCount; i) { boids[i].UpdateBoid(positions, velocities, worldWidth, worldHeight); } } }这个管理器在设计上有意避免了直接在每个Boid里持有其他Boid引用。因为用原生数组加索引的方式访问速度比对象引用数组快不少也为后面改用空间哈希网格铺路。如果数量少于100直接遍历数组也够用但统一走这条路径不会错。3.3 Boid核心脚本三力的完整实现Boid脚本是全部算法的落脚点。它接收管理器传入的位置和速度数组遍历其中所有邻居累加三力最后更新自身速度和位置。代码如下using UnityEngine; public class Boid : MonoBehaviour { [Header(运动参数)] public float maxSpeed 3f; public float maxForce 0.5f; [Header(感知范围)] public float neighborRadius 1.5f; public float separationRadius 0.8f; [Header(行为权重)] public float separationWeight 1.5f; public float alignmentWeight 1f; public float cohesionWeight 0.8f; public Vector2 velocity; void Start() { float angle Random.Range(0f, 360f); velocity new Vector2(Mathf.Cos(angle * Mathf.Deg2Rad), Mathf.Sin(angle * Mathf.Deg2Rad)) * maxSpeed; } public void UpdateBoid(Vector2[] positions, Vector2[] velocities, float worldWidth, float worldHeight) { Vector2 current transform.position; Vector2 separation Vector2.zero; Vector2 alignment Vector2.zero; Vector2 cohesion Vector2.zero; int neighborCount 0; for (int i 0; i positions.Length; i) { Vector2 other positions[i]; if (Vector2.Distance(other, current) 0.0001f) continue; Vector2 offset other - current; float dist offset.magnitude; if (dist neighborRadius) continue; if (dist separationRadius dist 0.0001f) { separation - offset.normalized / Mathf.Max(dist, 0.001f); } alignment velocities[i]; cohesion other; neighborCount; } Vector2 finalForce Vector2.zero; if (neighborCount 0) { alignment / neighborCount; cohesion / neighborCount; Vector2 separationDir separation.normalized; Vector2 alignmentDir alignment.normalized; Vector2 cohesionDir (cohesion - current).normalized; finalForce separationDir * separationWeight alignmentDir * alignmentWeight cohesionDir * cohesionWeight; } // 边界转向力靠近边界时向内推 float margin 1.5f; float halfW worldWidth * 0.5f; float halfH worldHeight * 0.5f; if (current.x -halfW margin) finalForce.x (1f - Mathf.Abs(current.x) / halfW) * 2f; else if (current.x halfW - margin) finalForce.x - (1f - Mathf.Abs(current.x) / halfW) * 2f; if (current.y -halfH margin) finalForce.y (1f - Mathf.Abs(current.y) / halfH) * 2f; else if (current.y halfH - margin) finalForce.y - (1f - Mathf.Abs(current.y) / halfH) * 2f; Vector2 steer finalForce.normalized * maxSpeed - velocity; steer Vector2.ClampMagnitude(steer, maxForce); velocity steer * Time.deltaTime; velocity Vector2.ClampMagnitude(velocity, maxSpeed); transform.position (Vector3)velocity * Time.deltaTime; if (velocity.sqrMagnitude 0.001f) { float angle Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg; if (GetComponentSpriteRenderer() ! null) angle - 90f; transform.rotation Quaternion.Euler(0f, 0f, angle); } } }这段代码里我专门处理了一个细节最后旋转角度减90度。因为大多数美术素材的三角形图标尖端朝上而不像Unity的默认Cube那样尖端朝右。如果你的素材尖端本来就是朝右的删掉这行减90即可。这个坑几乎每个用Sprite做Boid的人都会踩一次。3.4 初始参数参考与调试手段直接跑上面的代码如果参数没有乱调应该已经能看到鱼群聚合成团且整体移动的效果。这里给一份我常用的参数初始值maxSpeed 2.5 maxForce 0.4 neighborRadius 1.5 separationRadius 0.8 separationWeight 1.5 alignmentWeight 1.0 cohesionWeight 0.8 margin 1.5调试时最直观的辅助是Scene视图下的Gizmos。在Boid脚本里加一个OnDrawGizmosSelected把感知半径和分离半径画出来你就能直观看到每条鱼的可视范围有多大问题基本一眼定位。我习惯用两种颜色区分黄色画neighborRadius红色画separationRadius。这一步看似简单但能省下大量反复试参的时间。4. 性能优化数量突破300时如何保持帧率稳定4.1 O(n^2)算法的瓶颈出现在哪里上面的朴素实现是典型的O(n^2)复杂度每条鱼都要遍历所有其他鱼计算距离n条鱼就是n×n次距离判断。当n100时一帧检查1万次距离毫无压力当n300时9万次距离检测加上向量运算在低端电脑上每帧耗时开始明显上升当n1000时那就是百万级别的循环每帧直接卡成幻灯片。想要扩大群体规模核心思路是减少无效的距离检查。真正符合邻居定义的对象只可能出现在自己周围一小片空间里没必要把整个场景里所有鱼都查一遍。这就像在教室里要找同桌你不会跑遍整栋教学楼只需要看身边的人。4.2 空间哈希网格把查找范围局部化我用空间哈希网格来做这件事。基本思路是把世界划分成一个个正方形单元格每个单元格的尺寸等于neighborRadius。每条鱼根据自己坐标算出所在单元格的索引存进一个Dictionary。查询邻居时只查询自己所在格子以及周围8个格子里面的鱼距离判断总量立刻降到一个很低的常数级别。using System.Collections.Generic; using UnityEngine; public class SpatialGrid { private readonly DictionaryVector2Int, Listint cells new DictionaryVector2Int, Listint(); private readonly float cellSize; private Vector2[] positions; public SpatialGrid(float cellSize) { this.cellSize cellSize; } public void Build(Vector2[] positions) { this.positions positions; cells.Clear(); for (int i 0; i positions.Length; i) { Vector2Int key CellOf(positions[i]); if (!cells.TryGetValue(key, out Listint list)) { list new Listint(); cells[key] list; } list.Add(i); } } public void QueryNeighbors(Vector2 pos, float radius, Listint results) { results.Clear(); Vector2Int center CellOf(pos); int range Mathf.CeilToInt(radius / cellSize); for (int x -range; x range; x) { for (int y -range; y range; y) { Vector2Int key new Vector2Int(center.x x, center.y y); if (!cells.TryGetValue(key, out Listint list)) continue; for (int i 0; i list.Count; i) { int idx list[i]; if ((positions[idx] - pos).sqrMagnitude radius * radius) { results.Add(idx); } } } } } private Vector2Int CellOf(Vector2 pos) { return new Vector2Int( Mathf.FloorToInt(pos.x / cellSize), Mathf.FloorToInt(pos.y / cellSize) ); } }使用这个网格后Boid脚本里的遍历循环就不再依赖全部数组而是查询neighborIndices这个临时列表。管理器每帧先Build(positions)再让每条鱼调QueryNeighbors。实际测试中300条鱼时我依然能稳定跑满60帧500条时也只是轻微波动。如果继续追求更高上限还可以用Unity的Job System配合Burst把网格构建和邻居查询放到并行线程中但对多数游戏项目来说已经没有必要。5. 常见问题与调参经验实录5.1 高频异常排查速查表做Boids模拟时遇到的问题绝大多数都集中在参数失衡和技术细节上。我把这两年实际踩过的坑整理成一个速查表对着症状找原因比盲调快得多。现象常见原因解决思路群体像爆米花一样炸开separationWeight过大或separationRadius相对neighborRadius过大降低分离权重缩小分离半径所有鱼挤成一个密集球cohesionWeight过大或separationRadius过小降低聚合权重增大分离半径群体方向混乱、原地打转alignmentWeight过小或neighborRadius太小导致邻居少提高对齐权重增大感知半径个体频繁抖动、路径抖出锯齿maxForce过大速度变化过于剧烈降低maxForce增加惯性鱼群整齐划一像阅兵alignmentWeight过高降低对齐权重加入少量随机扰动鱼飞到边界外不再回来边界转向力代码没生效检查世界宽高参数与生成范围是否匹配三角形方向不对横着飞Sprite尖端朝向和旋转偏移不一致素材尖端朝上减90度朝右不减5.2 三套不同场景的调参心得按我的经验Boids参数的合理范围与你想要什么群体风格强相关。做水下鱼群时我会把maxSpeed压到2左右neighborRadius设小一点让鱼群呈现出缓慢、悠闲的游动感做匆匆赶路的鸟群时maxSpeed调到5以上对齐权重提高整体移动更坚决做人群闲逛场景时聚合权重不需要太高否则所有人都聚在一起像开会。不要迷信一组参数能通用所有项目。我自己每次换一个场景第一步总是先把maxSpeed和maxForce调好再动三个权重最后才碰半径。因为速度参数决定了整体节奏权重决定了群体性格半径决定了交互密度这个调整顺序能避免调参时互相干扰。关于随机扰动这个点真实鱼群并非每条鱼的运动完全精确所以我会在速度更新后的某处加一个非常小的随机偏移量幅度大概在maxSpeed的2%以下。这个抖动不会破坏群体结构但会显著增加视觉真实感看起来像是有生命的东西而不是机器人集群。5.3 可以继续深挖的扩展玩法Boids算法的扩展空间很大。比如在聚合规则里引入一只虚拟领导者所有鱼都朝它移动就能模拟候鸟迁徙队形。又或者在感知范围上做文章让每条鱼只对前方120度范围内的邻居产生反应把鱼群从绝对全向感知变成前方视野优先群体形态会发生有趣的变化。如果再把分离力和障碍物碰撞结合还能做出一个简单的避障飞行系统。我自己的项目最终在Boids基础上加了一个追踪目标点的额外力当玩家用鼠标点击场景时所有鱼朝目标点移动到达后重新散开游荡。这个功能的实现成本极低只需要在边界转向力那段代码旁边再加一个finalForce (target - current).normalized * targetWeight;但交互体验立刻上了一个档次。建议完成基本模拟后第一个扩展就做这个你会直观感受到Boids这种模型对附加规则的包容能力有多强。最后再分享一个对我帮助最大的习惯每次修改权重前先在Script上把旧的参数记录下来新参数跑五分钟截图对比再决定保留还是回退。群体行为是涌现型结果直觉判断经常不准数据对比比凭空猜要靠谱得多。Boids看似只是一个老算法的简单复刻但把它调得既自然又稳定是个非常值得花时间打磨的过程。