19 鸟群与人群:从独立粒子到邻居行为

前置知识:第 11 章的预分配与秒制更新、第 12 章的速度积分、第 18 章的独立效果配方。
本章示例:鸟群与独立运动 · 源码。
预期画面:120 个青色鸟形实例在屏幕平面中运动。群体模式读取邻居,产生相互避让和方向变化;切到独立模式后平均邻居数和距离检查变为 0。调半径、权重和数量可观察群体聚散与计算量。

先看现象:看上去都是很多小东西,状态依赖不同

第 18 章的烟点不需要知道其他烟点在哪里;每点只由自己的初始属性、年龄与全局参数决定。鸟群(flock)中的鸟需要感知附近个体:太近要分开,方向要趋同,群体要适度聚合。它们仍可用“很多个体”的数据结构,却不能只把独立粒子的速度参数换一下就得到群体行为。

本页把行为放在 CPU 的 TypedArray 中,每帧根据旧位置和旧速度计算下一步;渲染用一个 THREE.InstancedMesh 重复绘制鸟形。实例化减少绘制调用,不会替我们计算邻居。 人群通常还需要目标寻路、障碍物避让、个体半径和动画状态;本页的鸟群规则只是理解这种依赖关系的最小模型。

1. 三条局部规则怎样改变速度

对个体 i,先找距离小于邻居半径 R 的其他个体。示例在二维世界坐标 (x,y) 中比较平方距离,避免每次都开平方:

const dx = x[ j ] - x[ i ], dy = y[ j ] - y[ i ];
const d2 = dx * dx + dy * dy;
if ( d2 >= radius2 ) continue;

入选邻居后分别累计:

规则 计算方向 视觉作用
分离 separation 对很近的邻居,沿 -(pⱼ-pᵢ)/max(d²,ε) 远离 避免挤在同一点;太大时群体散开
对齐 alignment 邻居平均速度 - 自身速度 方向逐渐相似;过强可能机械地整齐转向
聚合 cohesion 邻居平均位置 - 自身位置 朝局部群心移动;过强可能形成团块

这些方向乘控制面板的权重,再加一项边界回转力,构成加速度。示例限制加速度最大值为 3 世界单位/秒²、速度范围约 0.45—1.35 世界单位/秒,避免个体瞬间跳转或完全停下。之后用 dt 积分速度和位置:

nextVx[ i ] = vx[ i ] + ax * dt;
nextVy[ i ] = vy[ i ] + ay * dt;
// 所有 nextV 都算完后,第二个循环才更新 vx、vy、x、y。

实际源码还在这段前后做速度限幅和边界反弹。用 nextVx/nextVy 双缓冲的目的,是让第 i 只读同一帧的旧状态。若在查邻居的循环里立即改写 vx[i],后面的个体会读到部分新速度,结果依赖数组顺序。dt= min(clock.getDelta(),0.05) 以秒为单位;后台恢复时丢弃超出的墙钟时间,避免一次过大的积分步长。独立模式跳过邻居循环,只保留自身速度、边界力与积分,因此“平均邻居数”和“距离检查”显示为 0。

2. 邻居半径为何既改画面也改成本

本页直接对每个 i 扫描所有 j,群体模式每帧做 N(N-1) 次候选距离检查。默认 120 只鸟是 14,280 次;调到 240 则是 57,360 次,即使实际邻居很少也要先检查候选。半径越大,入选邻居更多,群体响应更强;在这个朴素实现中,半径不会改变候选检查次数。它只改变进入三条规则的邻居数。独立模式不扫邻居,因此计数为 0。

更大群体常把世界划成网格或空间哈希,只检查本格及周围格的候选,再做精确距离比较。那能降低稀疏场景的平均候选数,但会增加建格、边界处理和数据同步成本;最坏情况(全部挤在一格)仍可能接近 O(N²)。视野锥、邻居上限、低频更新与远处简化也可用,但会改变行为。不能仅看一次绘制调用就断言 240 个体无成本。

3. 为什么用 InstancedMesh 画鸟形

一个点图元始终面向相机,适合亮点、雪片,却难表现鸟朝向。示例用三组三角形组成一个向本地 +Y 的简单鸟形;InstancedMesh 让所有鸟共享几何体与材质,逐实例给出矩阵与颜色。每帧复用一个 Object3D 暂存变换,不在个体循环里创建 Vector3:

dummy.position.set( x[ i ], y[ i ], 0 );
dummy.rotation.z = - Math.atan2( vx[ i ], vy[ i ] );
dummy.updateMatrix();
birds.setMatrixAt( i, dummy.matrix );
birds.instanceMatrix.addUpdateRange( 0, count * 16 );
birds.instanceMatrix.needsUpdate = true;

真实代码先 clearUpdateRanges(),再添加连续范围。一个 4×4 矩阵有 16 个 float32,因此活动 N 个实例的理论矩阵更新是 N×16×4 字节;120 个约 7.5 KiB,240 个约 15 KiB。首次创建 GPU 缓冲仍按 240 容量分配。birds.count 改变绘制数,setMatrixAt 与 needsUpdate 则改变位置/朝向;两者不是一回事。实例颜色只在初始化设置,不逐帧上传。这里没有其他场景物体,所以鸟形绘制调用为 1,但 CPU 邻居计算、矩阵写入和 GPU 顶点工作仍存在。

birds.frustumCulled=false 避免动态移动的实例被过时包围体整体误剔除。正式项目可以维护保守包围球或按空间分组。实例在同一个 Mesh 内不能逐实例使用 renderOrder 做透明排序;本例使用不透明 MeshBasicMaterial,无需处理这种透明次序问题。

4. 鸟群与人群的共同点和分界

鸟群局部规则能产生有机聚散,但聚合不等于寻路。人群个体往往有目的地、可行走区域、障碍物、排队和角色状态;直接把三条鸟群规则加到行人身上,可能让人群穿墙、在出口前互相挤压或来回振荡。一个实用拆分是:上层路径给目标方向,中层局部避让调速度,下层实例或骨骼动画呈现步态。还要区分感知邻居与视觉遮挡:墙挡住鸟形只是深度测试结果;是否不查询墙后的邻居,是行为系统另行决定的感知规则。

本页是二维、固定高度的演示,没有视野锥、遮挡感知、障碍物、三维转弯和真实翅膀动画;边界附近的回转力也仅为保持画面可见。它说明的是数据依赖与渲染分工,不能直接当游戏 NPC 系统。

常见问题

切到独立模式后为何鸟仍会转向? 边界回转和反弹对两种模式都生效;独立模式只是不读取其他鸟。

把邻居半径调小,距离检查为何不变? 朴素双层循环仍检查每一对候选;半径只决定哪些候选进入规则。要少检查候选需空间索引。

为什么显示一个绘制调用,个体数增加仍可能卡? 渲染合批只解决部分提交成本。邻居扫描是 O(N²),矩阵每帧上传,GPU 仍绘制每只鸟的三角形。

完成第 19 章练习。继续第 20 章:性能诊断与 three.quarks,分别辨认 CPU、上传、绘制调用与 overdraw,再看原生实现和兼容 r174 的库 API。参考:InstancedMesh、InstancedBufferAttribute、BufferAttribute。