次级运动:用阻尼弹簧让头发和尾巴跟上来
次级运动:用阻尼弹簧让头发和尾巴跟上来
站内搜索
直接问 AI

次级运动:用阻尼弹簧让头发和尾巴跟上来

把关键帧曲线调好之后,角色动起来了,但总觉得「硬」。头发跟着头一起瞬间到位,披风像一块贴在背上的板子,耳朵和尾巴完全不动。缺的是次级运动——那些不由动画师逐帧摆、而是被主体带动、慢半拍跟上来的部分。

次级运动可以手 K,但成本高且改一次主体动作就要重做一遍。更划算的做法是让它由物理算出来。这篇讲最实用的那个模型——阻尼弹簧——怎么用,以及它在实际接进 2D 骨骼系统时会踩的四个坑。

一根阻尼弹簧

模型只有一行:某个量当前值 x,目标值 t,速度 v。加速度由两项构成——把它拉回目标的弹力,和阻碍它运动的阻尼。

a = -k · (x - t) - c · v

k 是劲度,决定回弹多快;c 是阻尼,决定抖动多久停下。直接把这两个数交给动画师是不友好的,因为它们不正交——调大 k 会同时让运动变快和变得更爱抖。

换一组参数就好用多了:频率 f(每秒振荡几次)和阻尼比 ζ(衰减得多干脆)。

omega = 2 * math.pi * f
k = omega * omega
c = 2 * zeta * omega

这两个参数是正交的:f 只管快慢,ζ 只管回弹次数。ζ < 1 欠阻尼,会来回摆几下——头发、耳朵、尾巴要的就是这个;ζ = 1 临界阻尼,最快到位且不过冲——镜头跟随、UI 滑动用这个;ζ > 1 过阻尼,慢吞吞地贴过去,适合很重的东西。

实践中给动画师的滑块就是这两个,范围 f ∈ [0.5, 6]ζ ∈ [0.1, 1.2] 基本够用。

积分方式选错,弹簧会自己炸掉

这是第一个坑,而且是最容易踩的。写出加速度之后要往前推进一帧,最直觉的写法是:

x += v * dt          # 用旧速度更新位置
v += a * dt

这是显式欧拉法,它对弹簧系统无条件不稳定。原因可以精确地说:对无阻尼谐振子,显式欧拉每步的振幅放大倍数是 √(1 + ω²dt²),这个数恒大于 1。也就是说每一帧系统的能量都在增加,只是 k 小的时候增长慢,看不出来;一旦调硬,头发就会越甩越大最后飞出屏幕。

改一个字符的顺序就解决了——先更新速度,再用新速度更新位置:

v += a * dt          # 先更新速度
x += v * dt          # 再用新速度更新位置

这是半隐式欧拉(也叫辛欧拉),计算量完全一样,但它在 ω · dt < 2 的范围内稳定。按 60 fps、dt ≈ 0.0167 算,允许的频率上限约 19 Hz,远超任何动画会用到的值。

这个坑之所以隐蔽,是因为参数调得温和时两种写法看起来一模一样。它只在别人把弹簧调硬、或者游戏掉帧导致 dt 变大时才发作,而那时候没人会怀疑到积分器上。

帧率不同,结果就不同

换成半隐式欧拉解决了炸掉的问题,但没有解决不同帧率下运动幅度不一致的问题。30 fps 和 60 fps 跑同一套参数,尾巴摆动的幅度和衰减速度都会有肉眼可见的差别。录屏对比时这个差别很明显。

可靠的修法是把物理步长固定下来,用累加器补齐:

accumulator += frame_dt
while accumulator >= FIXED_H:          # FIXED_H 通常取 1/120
    v += (-k * (x - t) - c * v) * FIXED_H
    x += v * FIXED_H
    accumulator -= FIXED_H

代价是一帧内可能要跑好几步,但每步只有几次乘加,即便几十根弹簧也几乎不占时间。

要注意给这个循环加一个步数上限。切场景、断点调试、窗口从后台切回来时,frame_dt 可能是好几秒,不设上限的话这个 while 会一口气跑上千步,表现为「卡住一下」——而且越卡 frame_dt 越大,能自己把自己拖死。超过上限就直接丢弃剩余时间。

骨骼链要在同一帧内从根到梢更新

头发和尾巴不是单根弹簧,是一串。每一节的目标位置由它的父节点决定,这就带来一个顺序问题。

如果每节都读取父节点上一帧的位置来算自己的目标,那么运动传到第 n 节就要延迟 n 帧。六节的尾巴,尖端比根部慢六帧——在 24 fps 下是四分之一秒,看起来就是尾巴「断开」了,尖端在做和根部无关的运动。

正确做法是每帧按从根到梢的顺序遍历,每节都用父节点本帧刚算完的结果:

for i, seg in enumerate(chain):        # chain 已按根到梢排好序
    anchor = chain[i - 1].tip if i else root_transform.point
    seg.target = anchor + seg.rest_offset
    seg.step(h)                        # 立即积分,供下一节使用

这条对所有层级化的次级运动都成立:依赖父节点结果的计算,必须和父节点在同一帧内完成,且排在它之后。

弹角度,不要弹位置

第三个坑关于「弹什么」。直觉是让骨骼末端的位置做弹簧运动,但这样做每一帧算出来的位置和骨骼的固定长度是不相容的——头发会一边摆一边变长变短。

解决办法有两种。简单的一种是对角度做弹簧:每节骨骼只有一个局部旋转角是自由的,弹簧作用在这个角上,长度天然不变。2D 里这条尤其合适,因为每节只有一个自由度,参数直观,动画师也能理解「这一节最多甩 30 度」这样的限制。

另一种是先按位置积分,再把每节投影回静止长度

d = seg.tip - seg.root
seg.tip = seg.root + d / max(length(d), 1e-6) * seg.rest_length

这是位置约束的做法,处理外力和碰撞更灵活,但要注意投影会改变位置却不改变速度,下一帧的速度里就残留了被约束掉的那个分量。严格的做法是把速度也一并投影掉法向分量,否则会看到轻微的持续抖动。

有时候你要的不是弹簧,是滞后

不是所有次级运动都该回弹。镜头跟随角色、血条追赶实际数值、手持道具跟上手的位置——这些要的是「慢半拍地贴过去」,不要来回摆。用弹簧配临界阻尼可以做到,但有更简单的一阶模型:

x += (t - x) * (1 - math.exp(-rate * dt))

这里没有速度这个状态,只有一个 rate 参数,值越大追得越紧。它永远不会过冲,也不可能不稳定。

要注意的是那个 1 - exp(-rate·dt)。绝大多数人会直接写成下面这样,这是整个图形代码里最常见的帧率 bug:

x += (t - x) * 0.1        # 错:0.1 是"每帧",不是"每秒"

每帧固定拉近 10%,那么 60 fps 下一秒拉近的比例是 1 - 0.9^60 ≈ 99.8%,30 fps 下只有 1 - 0.9^30 ≈ 95.8%。帧率一变,跟随的松紧就变了。指数形式则天然帧率无关:把一帧拆成两个半帧算两次,结果和一次算完完全相同。

顺带一提,用「半衰期」来暴露这个参数比 rate 更直观——「差距每 0.2 秒缩小一半」是动画师能直接理解的说法,换算是 rate = ln(2) / half_life

角度弹簧要处理绕圈

对角度做弹簧时有个必须处理的边界。角度通常存在 (-180°, 180°] 区间里,当目标从 179° 变到 -179°(实际只转了 2 度)时,直接相减得到 -358 度,弹簧会认为要转一大圈,头发就会绕着脑袋甩一圈回来。

修法是把差值折进最短路径:

def shortest_delta(a, b):
    d = (b - a + 180.0) % 360.0 - 180.0
    return d

这个 bug 的特点是只在特定朝向触发。角色朝右时一切正常,转身朝左经过那条边界时头发突然甩一圈。测试时如果只在默认朝向下试,是发现不了的——所以角度相关的测试必须覆盖整个 360 度。

弹簧要排在姿态计算的最后

还有一个管线顺序问题,在同时用了反向动力学的骨架上一定会遇到。

次级运动是叠加在最终姿态之上的。如果先算弹簧再解 IK,IK 会为了够到目标点重新写入关节角,把弹簧刚算出来的结果整个覆盖掉——表现是次级运动在用了 IK 的那条链上完全消失,而其它链正常,很容易被误判成弹簧参数没生效。

正确的每帧顺序是:先由关键帧曲线求出基础姿态,再解 IK 得到最终的骨骼角度,最后才在这个结果上跑弹簧,从根到梢。

pose = sample_curves(time)      # 关键帧
solve_ik(pose)                  # 反向动力学覆盖部分关节
step_springs(pose, dt)          # 次级运动叠加在最终姿态上

换句话说,弹簧的输入必须是这一帧已经定稿的姿态。任何还会再改动骨骼的步骤,都要排在它前面。

瞬移会让弹簧爆炸

最后一个坑不在数学里,在流程里。

切镜头、角色传送、关卡重载——这些时刻角色的根变换会在一帧之内跳到完全不同的地方。弹簧看到的是「目标位置在 16 毫秒内移动了 2000 像素」,于是它算出一个巨大的速度,接下来几秒头发都在疯狂甩动,非常显眼。

修法是显式地告诉弹簧系统「这次位移不是运动」:在任何瞬移之后,把整条链的位置直接设为静止姿态、速度清零。

def teleport(self, new_root):
    for seg in self.chain:
        seg.x = seg.rest_position_under(new_root)
        seg.v = Vec2(0, 0)

不要试图用「位移超过阈值就重置」这类启发式来自动判断。角色正常的高速移动会误触发,看起来是头发在快速奔跑时突然定住一下,比原问题更难查。瞬移是调用方知道的事实,让调用方说出来。

怎么验证它是对的

次级运动的效果很依赖主观感受,但它的实现有几个可以客观检查的性质。

第一,能量不能自己增长。把目标固定住,给系统一个初始偏移,然后让它自由衰减,记录每个周期的峰值。峰值必须单调下降;只要有一次上升,积分器就写错了。这个测试跑几百帧就能看出来,比肉眼可靠得多。

第二,静止时必须完全静止。目标不动的情况下运行一分钟,末端的位置变化应当收敛到浮点噪声级别。如果它在某个很小的幅度上持续抖动,通常是约束投影没有同步处理速度。

第三,不同帧率下的包络应当重合。用 30、60、144 三个帧率跑同一段主体动画,把末端位置随时间的曲线画出来叠在一起。固定步长实现得对的话,三条曲线几乎完全重合;有偏差就说明还有某处直接用了 frame_dt

这三条都不需要美术判断,可以写成自动测试,在改动参数或重构积分器之后跑一遍。

发表回复

向下探索