跳至主要内容
搜索Ctrl+K
博客
让鸟群按剧本飞:剧场动画是怎么写的

| 17 分钟阅读 |

博客里有个叫剧场的页面:几百只鸟在屏幕上飞,一幕一幕地把我做过的项目演一遍——一会儿扑向一篇文档,一会儿在管道里排队,最后落成一张简历、再拼成一个二维码。

先坦白:这些代码不是我一行行敲出来的,是 agent(Claude Code)写的。我做的是提需求、看效果、一轮轮地挑毛病,告诉它哪里不像鸟、哪里节奏不对。

但我觉得这反而值得写一写。agent 交出来的代码结构很清楚,里面有不少值得学的设计,读懂它,比单纯知道”它能跑”有用得多。这篇就顺着它的实现,聊聊这群鸟是怎么动起来的,以及每一幕是怎么指挥它们的。

先定个规矩:场景说去哪儿,物理管怎么飞

整套代码最核心的一条约定是:写剧情的代码不能直接摆弄鸟的位置。

不然每写一幕,都得重新操心”鸟怎么从这儿飞到那儿才好看”,写到第三幕就乱套了。所以每只鸟身上的字段分成两拨:

Boid {
x, y // 现在在哪 —— 物理来写
vx, vy // 现在往哪飞 —— 物理来写
tx, ty // 要去哪儿 —— 场景来写
delay // 什么时候出发 —— 场景来写
color // 什么颜色 —— 场景来写
}

场景代码只填目标、出发顺序和颜色,物理层每一帧读这些字段,自己算速度和位置。这样写剧情的时候完全不用想物理;回头调物理手感,也不会把哪一幕弄坏。

后面所有的东西,都是围绕这几个字段展开的。

自由飞行:boids

没有目标的时候,鸟群用的是经典的 boids 算法,出自 Craig Reynolds 1987 年在 SIGGRAPH 上发表的论文 Flocks, Herds, and Schools: A Distributed Behavioral Model。每只鸟只看身边的邻居,遵守三条规则:

for each boid b:
for each neighbour o nearby:
align += o.velocity // 记下邻居往哪飞
cohere += o.position // 记下邻居在哪
if too close: separate -= (o.position - b.position)
b.velocity += (avg(align) - b.velocity) * 0.05 // 跟邻居同向
+ (avg(cohere) - b.position) * 0.0012 // 往邻居堆里靠
+ separate * 0.03 // 但别撞上

自由飞行中的鸟群

不过只有这三条的话,鸟群很快就会抱成一团,朝一个方向匀速直飞,像一块砖。为了让它像活的,代码里又加了几样东西:

b.velocity = rotate(b.velocity, smallNoise(t, b.seed)) // 每只鸟自己慢慢拐弯
b.velocity += windField(b.position, t) // 一阵缓慢变化的风
b.velocity += (wanderingCenter(t) - b.position) * tiny // 一个四处游走的吸引点,免得鸟群贴边
b.velocity = clampSpeed(b.velocity, MIN, MAX) // 不许悬停,也不许暴冲

第一行的”自己慢慢拐弯”,思路来自 Reynolds 后来的 Steering Behaviors For Autonomous Characters 里的 wander 行为。

这里的”噪声”不是随机数。随机数每帧都在跳,鸟会抖;用的是几条频率不成整数比的正弦波叠在一起,曲线是平滑的,又永远不会重复,鸟看起来就像在闲逛。

还有两个小取舍:

  • 每只鸟只看三分之一的邻居。 每帧只检查下标是 i % 3, i % 3 + 3, … 的那些同伴。鸟群本来就是个模糊的整体,少看几只,大方向不变,计算量却直接降到三分之一。几百只鸟在 Canvas 2D 上能流畅地飞,主要靠这个。 当然,这只是打了个折,每只鸟还是要把三分之一的鸟群挨个看一遍,复杂度仍然是 O(n²)。更正经的做法是空间哈希(属于固定半径近邻查询问题):把屏幕切成格子,格子边长取邻居半径,每只鸟只查自己和周围 8 个格子里的同伴,复杂度就接近 O(n)。鸟数上了几千,就得这么做;剧场只有几百只,抽样更省事。
  • 所有的力都乘一个 weight。 剧场开场时,鸟群是从一个旋转的小漩涡里长出来的,然后慢慢”学会”自己飞。代码没有写”某一帧切换成自由飞行”,而是让上面所有的力都乘一个从 0 慢慢涨到 1 的权重。于是”放飞”是一个渐变的过程,不会在某一帧突然变了个样。

编队:从生物到集群控制

那么鸟群已经可以像生物一样运动了,但是构成有序排列需要的是类似集群控制的逻辑。我们如何来实现这点呢?

先说 boids 为什么做不到。boids 的每条规则都只看邻居,整个鸟群的样子是这些局部规则”涌现”出来的,没有哪只鸟知道队伍应该长什么样。所以它能飞出一团好看的鸟群,却永远飞不出一个字母——“排成某个形状”是一个全局目标,局部规则里压根没有它的位置。

无人机灯光秀碰到的是同一个问题。它的做法拆开来是三件事:

  1. 分配:哪架飞机去哪个位置。这是全局决策,地面站一次算好。
  2. 控制:每架飞机只管把自己送到自己的位置,不用知道别人在干嘛。
  3. 调度:谁先飞、谁后飞,别让几百条航线在同一时刻挤在一块儿。

剧场的编队就照着这个思路来:集中规划,分散执行。分配是场景代码的活儿(后面细说),它给每只鸟写好 tx, ty, delay;物理层的编队模式只管控制和调度。

控制:每只鸟拴一根弹簧

一进编队模式,boids 的三条规则就全关掉了,每只鸟只认自己的目标点:

error = target - b.position
b.velocity += error * spring // 离得越远,拉得越狠
b.velocity *= damping // 每帧损失一点速度
b.position += b.velocity

就是一根弹簧加一点阻尼。学过控制的同学会认出来,这基本就是个最朴素的 PD 控制器:弹簧是比例项,阻尼起微分项的作用,防止冲过头之后来回晃。

这两个数怎么配,决定了动画的手感:

  • 弹簧太硬、阻尼太小:鸟冲过头、弹回来、再冲过头,队形一直在抖;
  • 弹簧太软、阻尼太大:鸟像陷在泥里,半天挪不到位;
  • 配得刚好:鸟带着一点惯性画一道弧过去,轻轻冲过一点,然后稳稳停住。

剧场里的数字(弹簧 0.004 到 0.054,阻尼让速度每帧保留 84% 到 78%)没什么公式推导,是一轮轮看效果调出来的。

还有一点是顺手得来的:切进编队的时候,速度没有清零。 鸟是带着自由飞行时的方向进来的,所以它会先顺着原来的方向滑出去一段,再被弹簧拉回来,飞出一道弧线,而不是原地掉头、直线冲向目标。

调度:别让所有鸟同时出发

如果所有鸟在同一帧拿到一样的弹簧,几百只鸟会同时掉头冲向目标,看起来就像瞬移。所以弹簧的强度不是固定的,每只鸟有自己的”出发进度” k:

// formProgress:这一幕编队的整体进度,大约 1.4 秒从 0 涨到 1
k = clamp((formProgress - b.delay * 0.55) / 0.45, 0, 1)
spring = 0.004 + 0.05 * k²
damping = 0.84 - 0.06 * k

拆开来说:

  • delay 决定谁先走。 delay 是场景代码按角度顺序写进去的,一般在 0 到 1 之间。delay 为 0 的鸟一开始就出发,delay 为 1 的鸟要等整体进度过了一半多才动身。所以形状不是一下子冒出来的,而是像雷达扫描一样,从一个方向转一圈扫出来的。
  • 每只鸟也是慢慢加力的。 一只鸟的 k 从 0 涨到 1,要花掉整体进度的 45%,弹簧是一点点收紧的。
  • 用 k² 而不是 k。 平方让刚出发时的拉力特别弱,鸟能顺着惯性多滑一段,弧线更舒展;快到的时候拉力才变大,把它稳稳收进目标点。用线性的话,一出发拉力就不小,弧线被拉直,看着就硬。
  • 没轮到的鸟也在准备。 k = 0 的时候弹簧还留着一点点底数,阻尼也已经生效了,排队的鸟会先减速、慢慢往目标那边漂。等它真正出发时,画面早就从”四散乱飞”变成了”安静待命”。

下面三张是同一幕里隔几帧截的:刚开始,简历的边框还是一团乱麻;过了一会儿,边框已经按顺序扫出了一半;再过一会儿,简历和名字都排好了,剩下一小股鸟顺着下面流走。

编队的三个瞬间:刚出发、扫到一半、成形

排好之后:别让它变成一张贴图

所有鸟都到位以后,如果一动不动,形状看起来就像一张贴上去的图片,鸟群的感觉一下子就没了。所以每帧还会给它加一点点扰动:

b.velocity += (cos(t / 400ms + b.phase), sin(t / 470ms + b.phase)) * 0.06

每只鸟按自己的相位绕一个很小的圈,横竖两个方向的周期还不一样,所以不是整齐划一的转圈。弹簧一直往回拉,形状不会散,但每个点都在轻轻地动。远看是一个字,凑近看还是一群活着的鸟。

散场:把方向盘还给 boids

一幕结束、下一幕回到自由飞行时,有的幕会给每只鸟一个随机方向的速度,让它们像受惊一样炸开,然后切回 boids。boids 本来就会把乱飞的鸟重新拢成一群,所以炸开之后它们会自己慢慢聚回来,这段过渡一行额外的代码都不用写。

到这儿,物理层就齐活了:自由的时候是一群生物,编队的时候是一个集群。剩下的问题就是:谁来决定每只鸟去哪儿、什么时候出发?

场景代码:每一幕就是一个对象

每一幕要实现的东西不多:

Act {
mode: 'free' | 'form' // 这一幕用哪套物理
enter(flock, stage) // 进场时调一次:分配目标、出发顺序、颜色
update?(flock, stage, t) // 可选:每帧调一次,让目标跟着剧情动
}

mode 告诉物理层用 boids 还是弹簧;enter 在这一幕开始时调一次;剧情需要动起来的幕再写个 update,每帧都会被调用,拿到这一幕里的时间 t。

场景代码能碰的,就只有每只鸟的 tx, ty, delay, color。下面三步就是它的全部家当。

第一步:形状就是一堆点

不管什么形状,最后都变成一组二维坐标:

ring(cx, cy, r, n) // 圆周上均匀撒 n 个点
disc(cx, cy, r, n) // 实心圆,用黄金角螺旋撒点,不扎堆也不留空
line(x1, y1, x2, y2, n) // 一条线段
rectLine(x, y, w, h, n) // 一个矩形框

实心圆用的黄金角螺旋,就是向日葵花盘上种子的排列方式:每个点比上一个多转约 137.5°,半径按平方根增长,点就均匀地铺满整个圆。

文字也一样。先把字画到一张看不见的 canvas 上,再一个像素一个像素地扫,不透明的地方就是一个目标点:

textPoints(str, size):
offscreen.fillText(str)
for y, x in grid(step):
if alpha(x, y) > threshold: points.push([x, y])

字越大,扫描的步长就越大,这样不管写什么字,点的数量都在鸟能填满的范围里。

最后那个二维码也是这么来的:每个黑色模块的中心是一个点,一只鸟落一个。

鸟群落成的二维码,每个模块是一只鸟

第二步:把鸟分给点

点有了,接下来的问题是哪只鸟去哪个点。

最省事的写法是第 i 只鸟去第 i 个点。结果很惨:挨在一起的两只鸟,可能被派去屏幕两头,几百条路线互相穿插,整个鸟群看起来像在抽筋。

agent 的办法是按角度配对:

assign(birds, points):
targets = resample(points, birds.length) // 先把点数凑成和鸟一样多
center = centroid(targets)
birdOrder = sortByAngle(birds, around: center)
targetOrder = sortByAngle(targets, around: center)
for k in 0..n:
bird = birdOrder[k]
target = targetOrder[k]
bird.tx, bird.ty = target
bird.delay = k / n * stagger // 出发顺序也按角度排

把鸟和目标点都按”绕目标中心的角度”排个序,然后按顺序一对一。角度挨着的鸟,分到的目标也挨着,大家的路线差不多是平行的,不会交叉。顺手把 delay 也按角度排,编队就会像雷达一样转一圈扫出来——就是上面三连拍中间那张的样子。

这段代码不起眼,但对观感的影响可能是最大的:物理一模一样,只换个配对方式,效果就从”乱”变成了”顺”。

严格来说,按角度配对只是个近似。“n 只鸟分给 n 个点,让总飞行距离最短”是一个经典的指派问题,标准解法是匈牙利算法,复杂度 O(n³)。几百只鸟、每换一个形状就算一次,这个代价不划算。按角度排序只要 O(n log n),不保证总距离最短,但能保证路线基本不交叉——而交叉才是肉眼最容易看出来的毛病。为了观感,够用就好。

第三步:让目标动起来

enter 只能摆出一个静止的形状。想讲故事,目标点本身得动,这就轮到 update 了。

用得最多的小工具是”围着某个点转”:

orbitAt(b, i, center, radius, t):
angle = i * goldenAngle + t * speed
r = radius * (0.4 + 0.6 * pseudoRandom(i)) // 每只鸟半径不一样,像一团蜂群,而不是一个圆环
b.tx = center.x + cos(angle) * r
b.ty = center.y + sin(angle) * r

有了它,“一群鸟围着某个东西”就成了一个能随时搬家的东西:每帧把 center 挪到新位置,鸟群就会被弹簧拽着追过去,一路上还保持着蜂群的样子。

比如讲检索的那一幕,剧情分三段:

update(flock, t):
for each boid b, index i:
if t < 1.4:
orbitAt(b, i, wrongDoc, ...) // 只靠关键词:全体扑向一篇错的文档
elif t < HYBRID:
group = i % 2 // 分成两队
hits = group == 0 ? keywordHits : semanticHits
orbitAt(b, i, hits[currentHop(t)], ...) // 两队各自挨个拜访自己找到的文档
else:
orbitAt(b, i, modelCore, ...) // 融合:最好的结果被送进模型

下图是中间那段:青色的一队走关键词检索,紫色的一队走向量检索,各自在屏幕上的小方块(文档)之间跳来跳去。

检索那一幕:两队鸟各自访问自己的检索结果

你会发现这段代码里一行物理都没有,只有”这一秒,这群鸟该围着谁转”。

剧情复杂了,就把它写成纯函数

再复杂一点的剧情,会把时间线单独抽成一个纯函数,update 只负责把它的结果翻译成目标点。

讲 agent 写操作的那一幕(沿用了我接支付 webhook 时的那套做法),有十几个事件在一条管道里流动,每个事件的遭遇都不一样:

eventState(k, t) -> { x, lane, status }
正常: 发出 ──────────────────────────────► 入库 done
重复: 发出 ──► 在幂等校验处被拦下 skipped
超时: 发出 ──► worker 超时 ◄─ 退避 ─► 重试 ──► 入库 retrying → done
失败: 发出 ──► 连败三次 ──► 死信队列 …… 对账时重放 ──► 入库
dead → replaying → done
update(flock, t):
for each event k:
state = eventState(k, t)
for each boid i in group[k]:
orbitAt(flock[i], i, pipelinePosition(state.x, state.lane), ...)
flock[i].color = colorFor(state.status)

每个事件由一小团鸟来演。下图里,黄色那团就是正在 worker 那儿重试的事件,下面虚线连着的是死信队列。

写操作管道:每一小团鸟是一个事件

eventState 不碰 Canvas,所以可以直接写单测,比如”第 7 个事件在对账之前是 dead,之后是 replaying”。剧情测对了,动画就不会错,因为动画只是把这个函数的结果一帧一帧画出来而已。

最后:二维码

回头看,整个剧场其实就三层:物理层只认 tx, ty, delay,负责把鸟顺滑地送过去;几何层把任何形状变成一堆点,再按角度把鸟分上去;场景层只回答”这一秒,每只鸟该去哪儿”。

最后想展示的,是我调整得最多、也最满意的部分:结尾的二维码。

最初的版本并不像现在这么漂亮。简历交代完之后,每只鸟直接飞向自己的模块,落下来变成一个小方块,就结束了。能扫,但没什么看头。后来方块换成了三角形的鸟,颜色也在飞行中从简历的琥珀色渐变成紫色,可整个过程还是”一窝蜂飞过去”,没有层次。

真正让它变好看的,是我把想要的过程拆成几个阶段,一段一段地讲给 agent:

  1. 螺旋组成边框。 一部分鸟离开简历,沿着螺旋一边转一边收紧,落到二维码的外框上,然后绕着框一直转。
  2. 中间环形盘旋。 负责数据模块的鸟先不急着落位,而是在框里绕成一个缓慢、带点噪声的漩涡,像在等待,也呼应了开场那个漩涡。
  3. 从中心往外落位。 漩涡里的鸟从中心开始,一圈圈向外漂到自己的模块上,落地时长成一个小三角,闪一下白光。
  4. 亮起背景和定位码,然后变色。 白色底板亮起,三个角上的定位方块显现,鸟群沿对角线依次变成深紫色,二维码就”显影”出来了。

第 3 步里,每只鸟从漩涡漂到模块的过程用的是 smoothstep 缓动:进度 x 先经过 x²(3 − 2x) 再拿去插值。这条曲线两头平、中间陡,鸟是慢慢起步、慢慢停下的,不会一下子弹出去,也不会急刹车。

第 4 步为什么要单独画底板和定位方块,跟二维码的结构有关。扫码器先找三个角上”回”字形的定位图案,靠它们确定二维码的位置和朝向;四周还要留一圈空白的静区,才能把码和背景分开。三角形的鸟拼不出能被识别的定位图案,所以这三个方块是直接画的实心块,鸟只负责中间的数据模块;白色底板就是静区。

光说有哪几个阶段还不够,我还跟它一起抠了次序和大概的时长:简历先停留 2.4 秒;边框在接下来不到一秒里陆续出发,每只鸟大约 1.4 秒螺旋到位;漩涡转到第 4 秒,才开始从中心往外落位,每只鸟飞 1.4 秒左右,外圈比内圈最多晚 0.8 秒;到 6.4 秒底板和定位码亮起,颜色跟着扫过去。

这些数字改了很多轮,最后才是现在这个节奏,我也终于满意了。下面是最后两幕的录屏:

在 agent 的帮助下,写代码已经不是最难的部分了。难的是知道自己想要什么,再把它拆成一步一步、对方能照着做的描述。