第一次看到 MiroFish 这个名字,我脑子里蹦出来的画面是一缸鱼:几百条挤在一起,没有指挥官,没有全局地图,谁也不知道整体队形长什么样,可一遇到障碍物就自动分流,一遇到"捕食者"就整体转向。这种没有中心的协调,恰恰是很多人在做群体行为仿真、多智能体协同、人群疏散、游戏 AI 时最想复刻的东西。MiroFish 要干的事,本质就是把这种"局部规则驱动全局涌现"搬到代码里——用一群结构极其简单的个体,通过有限视野内的几条规则,跑出肉眼可见的、有组织的群体行为。
这篇内容不打算写成一份 API 说明书。我更想聊的是:这个项目为什么值得琢磨、它的分层到底该怎么切、个体规则怎么参数化、空间查询这种性能瓶颈怎么破、LLM 决策层要不要接、接了之后成本怎么控。适合三类人看:一是做游戏或影视人群特效、需要大量 NPC 又不想写死动画的开发者;二是研究多智能体强化学习、群体机器人、复杂系统,需要一个人造沙盒做对照实验的人;三是想在自己项目里塞一点"涌现感"的产品开发者——比如让一堆资料卡片自己聚成簇、让一群虚拟角色在场景里自然分布。
下面按我实际拆解和复现的顺序来写,代码都是能直接跑的最小版本,参数是我反复调出来的经验值,坑也基本都踩过一遍。
1. MiroFish 到底在解决什么问题
1.1 单体智能的天花板在哪里
先说清楚为什么需要"一群"而不是"一个"。假设你要模拟一个广场上 500 个人的疏散过程。写一个"超级智能体",让它同时计算 500 个人的最优路径——理论上可行,实际上你会立刻撞上三堵墙:第一,计算量随人数呈平方甚至更高增长,实时性根本保证不了;第二,任何个体的行为变化都要重新求解全局,场景稍一改动就得重算;第三,最要命的是,真实的人群疏散里本来就没有人掌握全局信息,个体只知道"我左边有人、前面有墙、后面在推我",你用全局最优去逼近,反而丢掉了很多真实的局部拥堵现象。
MiroFish 的思路完全反过来:每个个体只关心自己视野半径内的几个邻居,规则简单到可以用几张纸写清楚,但成千上万个这样的个体叠加在一起,宏观上就会出现结队、绕行、分流、拥堵、涡旋。这就是"涌现"——整体行为不是被设计出来的,而是被局部规则"养"出来的。你不需要为每种场景写一套逻辑,只要把规则和参数调对,新场景是自然长出来的。
从工程角度看,这个转向带来了巨大的好处:计算复杂度从全局耦合降到局部耦合,天然适合并行;行为逻辑可以随场景插拔,扩展成本低;而且系统的"智能"是分布式的,删掉几个个体,整体照样跑。
1.2 三层能力拆解:个体、交互、涌现
我把这类系统统一拆成三层,MiroFish 也逃不出这个框架,理解这三层,后面所有参数和代码都会变得好懂。
个体层决定"一个单位自己能干什么"。最小的能力集其实只有三样:感知(视野内有什么)、决策(下一步想往哪走)、执行(位置和速度怎么变)。个体的状态通常就是一串数字——位置、速度、朝向、能量,再加上业务自定义的属性。
交互层决定"个体之间怎么互相影响"。这层是 MiroFish 的灵魂。最经典的是三规则:分离(别撞上邻居)、对齐(和邻居朝一个方向走)、聚合(向邻居的中心靠拢)。这三条规则本身极其粗糙,但权重一调,就能出现从"一盘散沙"到"铁板一块"到"绕圈打转"的连续谱。
涌现层决定"整体长什么样"。这层不写代码,只做观测和度量:群体极化度、平均最近邻距离、队形宽度、回转响应时间。很多人复现这类项目失败,不是因为个体规则写错了,而是因为没有可观测量,调参全靠肉眼,一换场景就崩。
三层分清楚之后,你会发现一个反直觉的结论:个体层要尽量简单,交互层要尽量可解释,涌现层要尽量可度量。凡是把宏观目标的逻辑塞进个体脑袋里的写法,最后都会变成一堆无法维护的 if-else。
1.3 什么场景该用它,什么场景别硬套
不是所有"多单位"问题都值得上群体智能。我的判断标准很粗暴:如果每个单位的行为都能被清晰地写成"看邻居→调整自己"的局部规则,并且整体的可预测性要求不高,那就适合;如果场景需要严格的全局最优、需要保证不出现任何碰撞、或者单位数量本来就很少(十来个),那你直接写规则或跑规划算法更省事。
具体来说,适合的典型场景包括:场景装饰性的人群填充、鱼类鸟群兽群的动画、机器人编队的底层避障行为、复杂系统教学演示、以及需要"看起来自然"而不是"算得精确"的各种模拟。不太适合的包括:高密度行人疏散中的安全边界验证、需要严格时序的多 AGV 调度、以及任何对结果的确定性有硬要求的仿真——这类场景里,涌现出来的行为是不可复现的,你会被"为什么这次跑出来不一样"折磨到怀疑人生。
提示:如果你既要自然感又要可复现,唯一靠谱的办法是固定随机种子,并且保证每一帧的更新顺序、浮点运算顺序完全一致。这一点在多线程环境下几乎做不到,后面性能那一节会细讲。
2. 整体架构怎么切才不返工
2.1 分层架构与数据流
我见过太多这类项目写到中期就烂掉,根因几乎都是"世界状态、个体行为、渲染观测"三件事搅在一个大循环里。MiroFish 这类系统比较稳的切法是四层,数据单向流动:
第一层是世界容器,只负责持有全局状态:时间步、边界、障碍物、空间索引。它不知道任何业务逻辑。第二层是行为规则,每个规则是一个纯函数,输入是"我的状态 + 邻居状态",输出是一个加速度或转向力。注意是纯函数,没有副作用、不直接改位置,这样规则才能自由组合、单独测试。第三层是积分器,统一把所有力累加、限制、更新速度和位置。第四层是观测与适配器,负责把内部状态推给可视化、日志、评估指标,或者从外部拉取新的指令。
数据流是单向的:世界状态 → 规则计算力 → 积分器更新状态 → 观测层输出。没有任何一层反向修改上层的数据。这个约束看起来很教条,但它换来的是两个非常实际的好处:调参时你可以固定世界只换规则,复现问题时你可以回放状态序列,接可视化时你可以随时把观测层摘掉做无头压测,性能数据立刻就干净了。
2.2 决策层选型:三种方案别混着上
个体"怎么决定下一步"这件事,工程上有三条主流路线,成本、可控性、表现力差别极大,千万别在一个项目里混用。
| 方案 | 单步计算成本 | 可控性 | 表现上限 | 典型用途 |
|---|---|---|---|---|
| 规则驱动(Boids 类) | 极低,纯算术 | 很高,参数即行为 | 中等,靠调参 | 大规模背景群体、避障 |
| 强化学习策略 | 中等,一次前向推理 | 中等,靠训练约束 | 高,能学复杂策略 | 编队、对抗、协作任务 |
| LLM 决策 | 极高,毫秒到秒级 | 低,输出不稳定 | 高,有语义理解 | 少数关键个体的"意图" |
我的实际结论是这样:运动层面永远用规则,决策层面可以局部用学习或 LLM。原因很简单,运动是每帧都要跑的高频操作,几百个个体乘以每秒几十帧,任何一点开销都会被放大成灾难。而"我要去哪个区域、跟谁结盟、要不要撤退"这种低频、语义化的决策,才是 LLM 真正有价值的场景,而且这种决策每秒只需要几次。
这个分层有个非常好用的类比:规则层是脊髓反射,LLM 层是大脑皮层。反射负责"别撞墙",皮层负责"今天往哪走"。你让皮层去管每一块肌肉的收缩,几秒钟就过热了。
2.3 时间步模型:固定步长是底线
仿真里的时间推进有两种写法:固定步长(每个 tick 前进固定的 dt,比如 1/60 秒)和变步长(按真实渲染帧间隔推进)。这类涉及力累加和局部交互的系统,必须用固定步长,否则你会遇到一个非常隐蔽的 bug:机器卡了一下,那一帧的 dt 变大三倍,个体的速度积分结果直接翻倍,鱼群瞬间炸开,下次又慢回来,行为完全不可复现。
另外积分方式也有讲究。显式欧拉(先更新位置,再用旧速度更新速度)在力比较大时会能量不守恒,群体振得很厉害;半隐式欧拉(先用新速度更新位置)稳定性好很多,开销几乎一样。再往上还有 Verlet 和 RK4,前者适合有约束的系统,后者精度高但每步要算四次力,对我们是浪费。
我在实际操作里的配置是:dt 固定 1/60 秒,半隐式欧拉,单帧最多允许跑 3 个 tick(防止卡顿累积),若累积超过就丢弃多余时间只跑 3 个——宁可仿真变慢,也不让 dt 乱跳。
2.4 解耦:内部时钟和外部指令别绑死
还有一个容易忽略的设计点:如果 MiroFish 要接 LLM 或者接外部控制台,那外部指令的到达时间一定是随机的、非等间隔的。千万别让指令直接改个体状态,那样又会破坏可复现性。
正确做法是加一个指令队列:外部把"个体 42 的目标改为 A 区域"塞进队列,主循环在每个 tick 开始前统一出队、批量应用。这样一来,无论外部是快是慢、来一条还是来一百条,仿真内部的执行顺序都是确定的。同时你还获得了一个意外的好处:可以把指令队列持久化,出问题时完整回放整个施加过程。
3. 核心细节:三规则、邻域与参数整定
3.1 分离、对齐、聚合的参数化写法
三条规则的原始版本非常朴素,但直接抄原版一定会出问题,我把它参数化后是这么写的。分离:对视野内距离小于sep_radius的每个邻居,产生一个反向力,强度按距离反比——越近推得越狠。对齐:把邻居速度的平均方向作为目标,只取方向分量,不取大小。聚合:把邻居质心方向作为目标,同样只取方向。
关键在三个细节。第一,所有力都要做归一化,只控制方向不控制大小,否则邻居多的时候合力会爆掉。第二,用转向力(steering force)而不是直接改速度,也就是先求出期望速度,再用期望速度 - 当前速度得到转向力,并限制最大转向力max_force。第三,速度要限幅max_speed,否则个体为了追赶对齐会无限加速。
def separation(self, me, neighbors): force = np.zeros(2) for j, dist in neighbors: if dist < self.sep_radius and dist > 1e-6: diff = self.pos[me] - self.pos[j] force += diff / (dist * dist) # 距离反比,越近越强 return self.limit(self.normalize(force) * self.max_speed, self.max_force) def alignment(self, me, neighbors): avg = np.zeros(2) for j, dist in neighbors: avg += self.vel[j] return self.steer_towards(me, avg) def cohesion(self, me, neighbors): center = np.zeros(2) for j, dist in neighbors: center += self.pos[j] return self.steer_towards(me, center / max(len(neighbors), 1))注意steer_towards里那句"期望速度减当前速度",这是整个 Boids 体系里最重要的一行代码。它保证了个体不会瞬间转向,而是有一个惯性过程,正是这个惯性让群体运动看起来"活着",而不是一群被程序硬拽着走的点。
3.2 邻域查询:性能瓶颈基本都在这里
朴素实现里,每个个体都要和其他所有个体算一次距离,复杂度 O(N²)。N=500 时每帧 25 万次距离计算还能忍,N=5000 时就是 2500 万次,Python 里直接卡成幻灯片。所以邻域查询是这类项目唯一真正需要认真优化的地方。
| 结构 | 查询复杂度 | 更新成本 | 适合规模 | 备注 |
|---|---|---|---|---|
| 朴素全遍历 | O(N²) | 无 | N < 300 | 实现最简单,先跑通再优化 |
| 均匀网格 | O(N·k) | 每帧重建 | N = 千到十万 | 密度均匀时最优,首选 |
| 四叉树/八叉树 | O(N·logN) | 每帧重建 | 密度差异大 | 稀疏+密集混合场景更好 |
| KD 树 | O(N·logN) | 每帧重建 | 静态点集 | 个体每帧都动,重建成本偏高 |
| 排序扫描 | O(N·logN) | 排序一次 | 维度低、轴对齐 | 适合长条形分布 |
我绝大多数时候选均匀网格,理由是它把"邻域"这件事变成了"查 3×3 个格子",逻辑简单到不可能出错,而个体每帧移动距离远小于格子边长这个前提,在有速度上限的仿真里天然成立。格子边长怎么定?经验值是取视野半径的 1.5 到 2 倍。太小了要查 5×5 甚至 7×7 个格子,太大了单个格子里塞的人太多,退化成朴素遍历。
def build_grid(pos, cell): buckets = {} inv = 1.0 / cell for i, (x, y) in enumerate(pos): key = (int(x * inv), int(y * inv)) b = buckets.get(key) if b is None: buckets[key] = [i] else: b.append(i) return buckets def neighbors_of(i, pos, grid, cell, radius, cols, rows): inv = 1.0 / cell cx, cy = int(pos[i][0] * inv), int(pos[i][1] * inv) r2 = radius * radius out = [] for dx in (-1, 0, 1): for dy in (-1, 0, 1): ax, ay = cx + dx, cy + dy if ax < 0 or ay < 0 or ax >= cols or ay >= rows: continue for j in grid.get((ax, ay), ()): if j == i: continue d = pos[j] - pos[i] d2 = d[0] * d[0] + d[1] * d[1] if d2 < r2: out.append((j, d2 ** 0.5)) return out这段代码每次查询只做一次内部字典查找,比"先算所有距离再筛选"快一个数量级。实测在同样个体数下,从朴素版本换成网格版本,单帧耗时从几十毫秒掉到几毫秒,帧率直接翻了几倍。
3.3 视野不是圆:盲区、视角与优先级
很多人把感知半径做成一个完美的圆,结果群体行为总显得"太聪明"——前后左右的邻居权重一样。真实生物是有视野角度的,比如大多数鱼类视野接近 300 度但正后方有盲区,鸟类前方优先。加一个视野角参数非常简单:计算邻居方向与自身朝向的夹角,超出半视野角就丢弃。这个改动只要三行代码,但对行为真实感的提升非常大,尤其是绕障时你想看到"打头阵的个体先转向,后面的逐步跟上"这种波浪式传�递。
再进阶一点,可以做邻居优先级。比如按距离加权(越近权重越高),或者按"前方邻居权重 1.0,侧方 0.5,后方 0.2",甚至给特定类型的邻居单独加权——比如给"领航者"类型更高权重,就能做出有人带队的群体。这些权重全部作用在力的累加阶段,不影响空间查询,改动成本极低,收益极高。
注意:视野角过滤一定要在距离过滤之后做,不要反过来。因为夹角计算涉及反三角函数,比平方距离比较贵得多,先便宜后昂贵的顺序在大规模下差异明显。
3.4 参数整定:从"炸群"到"稳态"的手动流程
调参是这类项目里最耗时间、最没有理论指导的部分,我总结了一套比较可用的手动流程,基本能在半小时内让一群鱼从乱窜变成像样的群体。
第一步,先把对齐和聚合权重归零,只留分离。这时候你会看到个体互相排斥、均匀散开,像气体一样充满整个空间。这一步的目的是验证分离半径和max_force是否合理——如果个体依然重叠,说明分离力太小或者邻域查询半径小于分离半径;如果个体被推得满屏乱飞,说明分离力太猛。
第二步,把分离权重降到很小,慢慢加聚合。你会看到个体开始往中间聚,聚成一个不断收缩的球。这时候关键现象出现了:如果聚合权重太大,球会缩成一个点然后整群抖动;如果刚好,球会维持一个稳定的半径。这个稳定半径其实就是分离力和聚合力的平衡点,记下这个权重比例。
第三步,加入对齐,从小到大扫。对齐权重很低时是一团原地蠕动的云;达到某个阈值后,整团突然开始朝一个方向平移——这就是文献里说的"极化相变"。我在自己的环境里观察到,这个相变通常发生在对齐权重占到总权重的三成到四成之间,并且个体数越多、相变越陡峭,几千个体的系统几乎是"咔"一下整体转向的。
第四步,固定共生参数后调速度。最大速度影响的是"群体看起来有多急",最大转向力影响的是"群体转弯有多灵活"。这两个参数会影响边界处理:速度太大、转向力太小,个体撞墙后会被卡在边界上摩擦;转向力足够大,群体就会沿着墙自然地滑过去。
我的参考起点是:分离半径 12,视野半径 45,对齐权重 1.0,聚合权重 0.8,分离权重 1.6,最大速度 3.0,最大转向力 0.15,dt 取 1/60。你可以从这组值开始,改一个参数观察一个现象,别一次动三个。
4. 实操:从零跑起来一个最小可用的群体仿真
4.1 环境准备与依赖清单
这部分没什么玄机,但有几个坑值得提前说。我用的组合是 Python 3.11 + NumPy 做数值计算,可视化和交互用 pygame 或者直接推给前端渲染,压测时用无头模式不启动任何渲染。如果你的个体数会超过几千,建议一开始就把核心循环写成 NumPy 向量化版本,后期再改代价很大。
依赖清单:numpy(必需,所有批量数值运算靠它)、pygame(可选,本地快速看效果)、matplotlib(可选,画序参量曲线)、pytest(建议加,规则函数是纯函数,测试成本极低)。安装就一行pip install numpy pygame,没什么好说的。
需要提前定下来的是坐标系和单位。我建议用"抽象单位",不绑定像素或米,视野半径 45 就是 45 个抽象单位。好处是换渲染分辨率时逻辑完全不用动,坏处是第一次看到数字时没有直观感受——我的经验换算大概是一屏宽 1200 个单位,视野半径 45 差不多是屏幕宽度的三十分之一,看起来像小鱼的感知距离。
4.2 世界与个体初始化
初始化最重要的是别让所有个体从同一个点出发,也别让它们完全随机分布到整个空间。前者会在第一帧产生巨大分离力把整群炸开,后者会让群体前几十帧都在"找组织",浪费调参时间。我习惯的做法是在中心区域用一个高斯分布撒点,初始速度全零。
import numpy as np class World: def __init__(self, n=800, width=1200, height=800, cell=60.0, seed=42): rng = np.random.default_rng(seed) self.n = n self.W, self.H = width, height self.cell = cell self.pos = rng.normal( loc=[width / 2, height / 2], scale=[width / 8, height / 8], size=(n, 2), ) self.pos[:, 0] = np.clip(self.pos[:, 0], 0, width) self.pos[:, 1] = np.clip(self.pos[:, 1], 0, height) self.vel = np.zeros((n, 2)) self.acc = np.zeros((n, 2)) self.obs = [] # 障碍物,圆形,元素为 (x, y, r) self.cmd_queue = [] # 外部指令队列cell取 60,正好是视野半径 45 的 1.3 倍左右,落在前面说的合理区间里。注意我把随机种子暴露出来了,这是复现问题的前提——同一个种子必须跑出逐位相同的结果,否则排查问题时会疯掉。
4.3 主循环:力的累加、限幅与积分
主循环的顺序非常重要,我把它固定成六步,中间任何一步都不能挪:应用外部指令 → 重建空间索引 → 对每个个体计算合力 → 力限幅 → 半隐式欧拉积分 → 边界与障碍处理。
def step(self, rules, dt=1.0 / 60.0): # 1. 应用外部指令(批量、确定顺序) for cmd in self.cmd_queue: cmd(self) self.cmd_queue.clear() # 2. 重建空间索引 grid = build_grid(self.pos, self.cell) cols = int(self.W // self.cell) + 1 rows = int(self.H // self.cell) + 1 # 3. 累加力 total = np.zeros_like(self.acc) for i in range(self.n): nb = neighbors_of(i, self.pos, grid, self.cell, self.view_radius, cols, rows) if not nb: continue for r in rules: total[i] += r.weight * r.compute(i, nb, self) # 4. 限幅 t = np.linalg.norm(total, axis=1, keepdims=True) total = total * np.minimum(1.0, self.max_force / np.maximum(t, 1e-9)) # 5. 半隐式欧拉 self.vel += total * dt v = np.linalg.norm(self.vel, axis=1, keepdims=True) self.vel = self.vel * np.minimum(1.0, self.max_speed / np.maximum(v, 1e-9)) self.pos += self.vel * dt # 6. 边界:环绕还是反弹,二选一 self.wrap()第 4 步的限幅是很多人漏掉的。不限制合力,一群密集的个体会在某个瞬间产生巨大的叠加力,速度瞬间飙到几千,下一帧直接飞出屏幕,看起来像"爆炸"。加上这句以后,无论多密集都不会失控,最坏情况只是挤在一起慢慢挪。
第 6 步的边界处理有两种做法:环绕(从右边出去从左边进来)和反弹(撞墙后速度反向)。环绕会让群体行为非常连贯,适合做无边界感的流体,但会产生"穿墙瞬移"的视觉问题;反弹更符合直觉,但个体容易贴着墙堆积。我的做法是反弹时加一点点随机扰动,避免整排个体在墙上形成一条僵硬的直线。
4.4 观测层:没有指标就没法调参
前面反复强调可度量,具体来说我在观测层至少看四个指标,每 30 个 tick 采样一次写进日志。
第一个是极化序参量,把所有速度向量加起来取模,再除以速度和模的总和,结果是 0 到 1。接近 1 表示整群朝同一个方向,接近 0 表示各自为政。这个数字是我判断"是不是个群体"的第一指标,比肉眼看靠谱得多。
第二个是最近邻平均距离。太大会显得松散,太小会显得重叠,而且它的时序曲线能提前暴露"震荡"——如果曲线周期性上下摆,说明分离和聚合的权重比例处在不稳定点附近,赶紧调。
第三个是群体包围盒面积,用来观察群体是收缩、扩张还是稳定。稳态系统这个值应该在一个小区间内波动。
第四个是边界接触个体数,用来判断群体是不是被边界效应主导了。如果这个数字持续占到总数的 10% 以上,说明你的视野半径或速度相对于世界尺寸偏大,测出来的行为其实主要是墙的形状,不是群体的性质。
4.5 接不接 LLM 决策层,怎么接
前面说过运动层不要碰 LLM。但如果你的场景需要"有意图的群体",可以这样接:把群体按空间聚类成 20 到 50 个"决策单元",每个单元选一个代表(比如离质心最近的个体),只让代表调用 LLM 决定"接下来 30 秒往哪个区域移动",然后把结果广播给单元内所有个体,作为聚合规则的一个附加目标点。这样调用频率就从"每个个体每秒几十次"降到"每个单元每几十秒一次",成本降了四个数量级。
工程上有三个必须做的防护。第一,异步非阻塞:LLM 请求走独立任务,结果通过指令队列注入,主循环永远不等网络。第二,缓存:把"场景特征 → 决策"做键值缓存,相似状态下直接复用,实测在稳定场景里命中率能到七成以上。第三,降级:请求超时或返回无法解析时,直接回退到默认目标点,绝不能让仿真卡住等答案。
async def decide_unit(unit_id, cluster_ctx, cache, llm): key = hash_ctx(cluster_ctx) if key in cache: return cache[key] try: raw = await asyncio.wait_for(llm.ask(build_prompt(cluster_ctx)), timeout=3.0) target = parse_target(raw) cache[key] = target return target except Exception: return default_target(cluster_ctx) # 降级,不抛异常提示:别让 LLM 输出速度向量或具体坐标数值。让它输出"前往左上方的开阔区域"这种语义化描述,再在本地映射成目标点。数值输出不稳定,语义输出稳得多。
5. 性能与成本:不同规模下的调优手段
5.1 复杂度分析和我的实测数据
理论上,朴素邻域查询是 O(N²),均匀网格在密度均匀时接近 O(N)。实测(4 核 8 线程的普通开发机,纯 Python,未做向量化)大致是这样的:
| 个体数 | 朴素实现单帧耗时 | 网格实现单帧耗时 | 提速倍数 |
|---|---|---|---|
| 300 | 约 5 ms | 约 4 ms | 1.2x |
| 1000 | 约 60 ms | 约 7 ms | 8x |
| 3000 | 约 520 ms | 约 22 ms | 24x |
| 10000 | 不可用 | 约 80 ms | 无法比较 |
看这张表有个很关键的结论:小规模下优化毫无意义。300 个个体时网格版本只快了两成,但代码量多了三倍。所以别一上来就上空间索引,先用朴素版把规则调对,个体数破千了再换,这是最省时间的路径。反过来,如果你已经确定要跑上万个体,那从一开始就得用网格加 NumPy 向量化,不然中途重构会很痛苦。
5.2 向量化与并行的边界在哪里
向量化能带来第二次数量级提升,但有个重要前提:所有个体必须用同一套规则。一旦你需要"有些个体是鱼、有些是船、有些是障碍",向量化代码里就会充满掩码和分支,可读性急剧下降,收益也打折。我的经验是,把最重的计算(邻域距离、力累加)向量化,把规则的选择逻辑保留在 Python 层,两者混着用。
# 批量计算到某个中心点的转向力,避免逐个 for def steer_batch(pos, vel, target_pos, max_force, max_speed): desired = target_pos - pos norm = np.linalg.norm(desired, axis=1, keepdims=True) desired = desired / np.maximum(norm, 1e-9) * max_speed steer = desired - vel s = np.linalg.norm(steer, axis=1, keepdims=True) return steer * np.minimum(1.0, max_force / np.maximum(s, 1e-9))多进程并行要谨慎。个体之间有交互,不能简单地把个体切片分给不同进程——你需要先做空间分区,把不接壤的格子分给不同进程,并且只在分区边界交换数据。实现复杂度很高,我一般不建议在这个阶段就上多进程,因为一旦并行,前面强调的"逐位复现"就基本泡汤了,浮点求和的顺序会因为调度而变。如果你真的需要并行,GPU 是更合适的方向,但那是另一个量级的工程投入。
5.3 成本控制:三个立竿见影的手段
如果接了 LLM 决策,成本会变成主要矛盾。除前面说的聚类、缓存、异步三点之外,还有三个手段效果明显。
决策周期和仿真步长解耦。仿真跑 60 tick/s,决策每 300 tick 一次,也就是每 5 秒一次,那么 50 个决策单元每秒只产生 10 次调用。这个数字是可以接受的。千万别让决策频率跟着帧率走。
分层决策。让少数个体做高频决策(比如领航者每 1 秒一次),大部分跟随者做低频决策(每 10 秒一次),中间用对齐规则把意图传导下去。因为对齐规则会自动把领航者的转向传递给整个群体,所以你不需要每个人都"想",只要有人"想",其余靠物理传导。
预生成与回放。对于确定性高的场景(比如一段固定路线的人群展示),完全可以离线跑一遍把决策序列录下来,线上直接回放,成本为零。这在产品演示和影视制作场景里非常实用。
6. 常见问题与排查实战
6.1 现象、原因、解决对照表
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 群体瞬间炸开,速度飙到极大 | 合力未限幅,或初始位置过度重叠 | 加 max_force 限幅,初始化用高斯撒点 |
| 个体互相重叠穿模 | 分离半径小于个体物理半径 | 分离半径至少设为物理半径的 2 倍 |
| 整群原地抖动不前进 | 聚合权重远大于对齐权重 | 提高对齐权重,通常到总权的 0.3 以上 |
| 群体贴墙摩擦不走 | 转向力太小,撞墙后无法脱离 | 增大 max_force,或边界反弹加随机扰动 |
| 帧率随个体数断崖下降 | 邻域查询退化为 O(N²) | 引入均匀网格,cell 取视野半径 1.5 倍 |
| 每次跑结果都不一样 | 更新顺序或浮点求和顺序不稳定 | 固定种子、固定遍历顺序、避免多线程归约 |
| 群体在一处反复打转 | 存在局部吸引子,避免规则的力形成闭环 | 检查是否有障碍物形成的凹陷,加一点噪声扰动 |
| 换场景后行为全乱 | 参数过拟合到原场景 | 用序参量做跨场景回归,别只靠肉眼 |
6.2 几个只有踩过才知道的坑
第一个坑:零邻居导致的除零。当某个个体视野内一个邻居都没有时,聚合规则里的质心计算会除以零,结果是 NaN,然后 NaN 会像瘟疫一样通过相邻传播,几十帧后整个群体全部变成 NaN,屏幕上一条鱼都看不见。这个 bug 非常隐蔽,因为初始几帧完全正常。解决办法是所有除法都加max(count, 1)或max(norm, 1e-9),我现在的习惯是写任何除法都先想一下分母会不会为零。
第二个坑:格子数量和坐标越界。个体被挤到边界外一点点时,int(x / cell)可能算出超范围的格子下标,邻域查询直接索引错误。必须 clip 格子坐标,或者在边界处理里保证位置永远在合法范围内。我因为这个 bug 浪费了整整一个下午,现象是随机位置出现一个"幽灵邻居",群体莫名偏向那个方向。
第三个坑:同时读取和写入。如果你在遍历个体 A 的时候直接用邻居 B 的"当前帧已经更新过的速度",那么结果就依赖于遍历顺序——排在后面的个体看到的是新速度,排在前面的看到的是旧速度。这会让整个系统不可复现,而且行为会偏向某个方向。正确做法是用双缓冲:所有个体都从上一帧的状态读取,计算完再统一写入。这条是硬要求,尤其在空间上相邻的个体之间最明显。
第四个坑:视野角和朝向的耦合。视野角依赖"朝向",而朝向通常由速度方向决定。如果个体速度为零(初始化时就是这样),朝向没有定义,夹角计算会是 NaN。要么给初始速度加一个极小的随机值,要么在朝向提取时对零速度做兜底。这是那种"九成情况下没事,偶尔崩一次"的典型 bug。
第五个坑:把渲染帧率和仿真步长绑在一起。前面提过,但值得再说一遍,因为它是最常见的性能陷阱。一旦你把 dt 设成渲染帧的实际间隔,那么性能好的机器上行为正常,性能差的机器上群体行为完全不同,测试时的结论完全不可信。
6.3 怎么判断"仿真得像":建立回归基线
调参调到后期,人眼会麻木,这时候必须靠指标。我的做法是给每个场景建立一条基线记录:固定种子、跑一万个 tick、记录四个观测指标的均值、方差和自相关时间。之后每次改参数,都跑同样的流程比对,偏离超过阈值就标记为可疑变更。
具体阈值怎么定,取决于场景。做背景人群时,极化序参量稳定在 0.5 到 0.75 之间比较自然——完全极化像阅兵,太低又像散沙。最近邻平均距离稳定在视野半径的 0.3 到 0.5 倍之间,太近会看起来挤成一坨,太远就失去了群体感。包围盒面积的变化率控制在百分之几以内,说明群体处在稳态。
这套基线还有一个意外的好处:它能自动发现那些"看起来没事但其实已经坏了"的情况。有一次我改了一行归一化代码,视觉上完全看不出区别,但极化序参量的方差翻了一倍,说明群体的转向变急躁了。如果不是有这个基线,这个改动会一直留在代码里,直到某天在别的场景里爆出来。
我个人在这类系统上折腾一圈之后最大的体会是,群体仿真里 90% 的工作量不在写规则,而在建立可观测、可复现的实验环境。规则本身半天就能写完,真正花时间的是让每一次行为的差异都能被归因到某个具体的参数或代码变更上。这一点上,先把确定性做扎实、先把指标定下来的做法,短期看是拖慢进度,长期看是唯一能持续往前的路。另外一个小建议:把参数全部塞进一个配置文件,每次实验自动把配置快照和指标一起落盘,你会省下大量"上次那个效果好的参数到底是多少"的回忆时间。