← 图形原理 · 交互课 课题六 · 动画与骨骼 · 第 3 课

弹簧骨:头发为什么会自己摇

全课骨架:动画 = 每一帧先把骨架的姿态算出来,再把顶点按权重「挂」到骨头上。 「读姿态」那一半是第 1 课的蒙皮;「写姿态」有两种做法 —— 第 2 课是反解(IK:给定目标,解出角度), 这一课是另一种,也是更省事的那一种:不求解,直接让骨头自己变成一根弹簧。 头发、尾巴、挂坠、耳朵、披风全都归它管,而它的全部代码只有四行。 保真度:真机可证(画面与读数都由本页自己的 SpringBone 解算器给出;对照物是"位移比 = 1−drag"与"下垂角 tanθ = g·h²/(stiff·L)"两条闭式,读者能拿笔复核)。 算法按 VRM SpringBone 规范的四步(惯性 → 外力 → 刚度 → 长度约束)逐行落地。

(脚本启动中…)

定点观察:先点「复位(静止)」,把「节数」设为 1,然后把「刚度 stiffness」从 0.3 拉小到 0.01 → 看那根骨头垂下去多少,以及表② 的「实测下垂角」与「闭式」两列 → 下垂角恰好满足 tanθ = g·h² / (stiff·L):把刚度调到 1/4,下垂角就增大到约 4 倍。 这不是"调参手感",是一条能拿笔算出来的平衡式。

本帧的读数

(脚本启动中…)

3.1 段 · 让它跟不上:Verlet 的"速度"藏在两个位置里

本段只增加一样东西:惯性(用上一帧的位置当速度)(画面里是一根骨头;其余控件与本课各段一致)。 保真度:真机可证。上一课的 IK 是"立刻到位",可头发不能立刻到位 —— 它要滞后。 弹簧骨最省事的地方就在这里:它不存速度,速度就是"这一帧的位置减上一帧的位置"。 于是一行就能写出"带着惯性往前漂、每帧衰减一点"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 当前末端位置 cur 上一帧位置 prev 阻尼 drag ★ 没有速度变量 速度 = cur − prev drag 越大越"重" (水里的头发) function stepBone(b, anchor, dt, cfg) { // ① 惯性:把"上一帧的速度"按 drag 衰减后加回去 let nxt = b.p + (b.p - b.prev) * (1 - cfg.drag); // ② 外力(重力) nxt.y += GP; // ③④ 刚度与长度约束是后两段的事 return nxt; ★ 本段把刚度设为 0、并放开长度约束,只看"惯性"本身 ⇒ 位移逐帧按 (1 − drag) 缩,这条是拿笔能验的 位移比(本帧 / 上帧) 恰为 1 − drag drag = 0.2 ⇒ 0.8 drag = 0.6 ⇒ 0.4 drag = 0 ⇒ 永远匀速 (真空中甩出去的头发) 见表① 逐行核对
定点观察:把「刚度」设为 0.01(≈ 关掉)、「节数」1,然后点「摇一摇」,再把「阻尼 drag」从 0 拉到 0.9 → 看骨头甩出去后"刹住"的快慢,以及表① 的「位移比」一列 → 相邻两帧的位移之比恰好等于 1 − drag(drag = 0.2 时 0.800,drag = 0.6 时 0.400): 因为 Verlet 把"速度"存在两个位置之间,阻尼就是每帧把这个差值乘上一个固定系数 —— 所以它是一支几何级数, 而不是"感觉好像慢下来了"。
表① 惯性一步的位移比:drag 与"相邻两帧位移之比"(L = 0.30、刚度 0、重力 0、长度约束关,只看惯性)
drag1 − drag(闭式)实测位移比(第 2/1 帧)实测位移比(第 6/5 帧)差

算式:把"上一帧的速度"记作 v = cur − prev。惯性那一步做的是 next = cur + v·(1 − drag),于是下一帧的速度 = v·(1 − drag) —— 位移逐帧按固定比例缩,是一条几何级数,公比就是 1 − drag。 拿 drag = 0.2 验算:1 − 0.2 = 0.800;表① 的「实测位移比」两列都应当是这个数(差 0)。
这一条把"阻尼"从一个手感参数变成了一个可算的量:想让头发在一秒内基本停住, 就该让 (1 − drag)⁶⁰ 足够小 —— drag = 0.2 时 (0.8)⁶⁰ ≈ 1.5e−6,也就是一秒后速度只剩百万分之一。

3.2 段 · 把它拉回来:重力与刚度的平衡

本段只增加一样东西:刚度(把末端往"静止位"回拉)(其余控件一致)。 保真度:真机可证。3.1 里骨头会一直漂下去 —— 惯性会累积,它不会自己"垂下来"。 刚度那一步就是每帧把末端朝静止位(骨长方向上、跟着锚点走的那个点)拉回一点。 于是"重力往下拽"与"刚度往回拉"形成一个可以解出来的平衡,这就是头发的下垂角。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 刚度 stiffness 静止位 rest(沿骨长、 跟着锚点走) 静止位 = 水平朝右 重力 = 竖直向下 ⇒ 两者不共线 ⇒ 才有下垂角 刚度越大越挺 // ③ 刚度:每帧朝静止位拉一点 const rest = { x: anchor.x + L, y: anchor.y }; nxt.x += (rest.x - nxt.x) * cfg.stiff; nxt.y += (rest.y - nxt.y) * cfg.stiff; // ④ 长度约束:下一段的事 平衡时把"切向"两边的力摊平: stiff·L·sinθ = g·h²·cosθ·(1−stiff) ⇒ tanθ = g·h²·(1−stiff) / (stiff·L) ★ 只有一条式子,θ 随 stiff 反比 —— 可拿笔验 下垂角 θ = atan(g h²(1−stiff)/stiff L) stiff 0.05 ⇒ 9.79° stiff 0.02 ⇒ 23.99° stiff 调到 1/4 ⇒ tanθ 变 4 倍 见表② 逐行核对
定点观察:点「复位(静止)」,把「刚度 stiffness」从 0.3 一路拉到 0.01 → 看那根骨头垂下去的角度,以及表② 的「实测下垂角 / 闭式 / 差」三列 → 实测与闭式逐位相同,而且刚度减半、tanθ 大约翻倍(注意是 tanθ,不是角度本身): 因为平衡式里出现的是 tanθ 与 stiffness 的乘积关系 —— 所以这个旋钮的正确用法是"按 1/stiff 去想", 而不是"调大一点、调小一点"。
表② 重力与刚度的平衡:稳态下垂角对闭式 tanθ = g·h²/(stiff·L)(单节、L = 0.30、drag = 0.2、跑满 6 s 取稳态)
stiffnessg·h²/(stiff·L)闭式 θ (°)实测 θ (°)差 (°)

算式(本课的中心结论):稳态时末端停在"重力与刚度切向分量相等"的位置。 把骨向记作 u、它的垂线记作 t,则 刚度的切向分量 = stiff·L·sinθ(偏离静止位的角度 θ), 而重力的切向分量 = g·h²·cosθ。令两者相等得 tanθ = g·h²/(stiff·L) —— 但这条是近似式:实测发现它系统性地偏大,比值恰为 1−stiff(stiff = 0.2 时实测/近似 = 0.800)。 把"刚度那一步是施加在已经含重力的那个位置上"这件事算进去,精确解是 tanθ = g·h²·(1−stiff)/(stiff·L)。 拿 L = 0.30、stiff = 0.05 验算:g·h² = 9.81/60² = 0.002725, tanθ = 0.002725×0.95/(0.05×0.30) = 0.17258 ⇒ θ = 9.7918° —— 表② 那一行逐位对上(差 1e−15)。
★ 这条修正就是"先量后写"换来的:近似式读起来完全合理(切向平衡),但它漏了"顺序"这一层; 表格一跑,四档的比值都恰好是 1−stiff,修正项当场就现形了。
注意这里为什么会有下垂角:静止位是水平朝右(建模时的姿态),重力竖直向下,两者不共线。 如果静止位本身就朝下(比如一根垂直的长发),重力与骨向共线、切向分量为 0 —— 那根头发会笔直垂着不动, 这正好解释了"为什么贴着头皮的长发几乎不摇,而翘起来的呆毛摇得很欢"。

3.3 段 · 骨长不能变:长度约束

本段只增加一样东西:长度约束(把"回拉"之后的位置强行拉到骨长上;可开关,其余控件一致)。 保真度:真机可证。3.2 的回拉是"每帧朝静止位挪一点",它会顺手把骨头拉长或压短 —— 而骨头在动画里是刚体,长度必须是个常数。所以四步里的最后一步是: 从锚点出发,把方向保留、长度改成 L。这一步同时把"重力与惯性的位移"转化成"角度"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 锚点(父节末端) 骨长 L 回拉后的位置 nxt 不做归一化时 回拉会把长度也改掉 ⇒ 骨头会"长出来" 多节时误差会累积 // ④ 长度约束:保留方向,把长度改成 L if (cfg.fixLen) { let dx = nxt.x - anchor.x, dy = nxt.y - anchor.y; const len = Math.hypot(dx, dy) || 1e-9; nxt.x = anchor.x + dx / len * L; nxt.y = anchor.y + dy / len * L; } ★ 这一步把"位移"转成了"角度" —— 所以弹簧骨算出来的其实是姿态 加速度、惯性、刚度最后都归到"这一节朝哪儿" 每节长度 开 ⇒ 恒 = L(差 0) 关 ⇒ 每帧被改一点 多节时越往下越离谱 两种都可以,看要什么 "软体"才允许长度变 见表③ 两列对照
定点观察:把「节数」拉到 6,点「3.4 串成一条」,然后来回点「3.3 关掉长度约束」与其再点一次 → 看整条链的总长、以及表③ 的「每节长度」两列 → 开着归一化时每节长度恒等于 L(差 0);关掉之后每节长度都不等于 L,而且越靠末端偏离越大: 因为回拉那一步是"朝静止位挪 stiff 比例",它同时改了方向和长度 —— 关节越多,误差越有地方累积。
表③ 长度约束:每节长度对骨长 L(4 节、L = 0.30、stiff = 0.05、drag = 0.2、跑满 4 s)
配置第 1 节 (m)第 2 节 (m)第 3 节 (m)第 4 节 (m)最大 |长度 − L| (m)

算式:长度约束那一步是 next = anchor + (next − anchor)/|next − anchor| · L —— 它是"只改长度、不改方向"的投影,所以执行之后长度必然恰好是 L(实测差 0,是浮点意义的 0)。 关掉它则每帧都留下一点误差,而且这个误差会逐节累积(表③ 的最后一列就是它累积到什么程度)。
这一步是弹簧骨和"软体"的分界线:允许长度变,得到的是绳子(第 5 课的 PBD); 不许长度变,得到的是骨头 —— 同样四行代码,去掉这一步就换了东西。

3.4 段 · 串成一条:每节都挂着它下面那一串

本段只增加一样东西:节数(1 → 6;其余控件一致)。 保真度:真机可证。前三段都只有一节。真实的头发、尾巴、披风是一串: 每一节的"锚点"是上一节的末端 —— 于是惯性与重力会顺着链往下传,越靠末端越晚、越弯。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 节数 n 锚点运动 (可拖动) 每节的锚点 = 上一节的末端 ⇒ 自根向梢传 顺序不能反 (反了会慢一帧) function stepChain(root, bones, dt, cfg) { let anchor = root; // ★ 自根向梢 for (const b of bones) { const nxt = stepBone(b, anchor, dt, cfg); b.prev = b.p; b.p = nxt; anchor = nxt; // ★ 这一节的末端成为下一节的锚点 } ★ 四行 × 每节:这就是全部代码,没有矩阵、没有求解器 但链一长,末端就会"甩过" —— 所以还要子步/迭代(附录 A3) 各节下垂角 第 1 节:最挺 越往后越垂 总长仍 = n·L (归一化开着) 链越长,"末端甩过" 越明显 → 见表④
定点观察:点「3.4 串成一条」(6 节),再把「锚点速度」从 0 拉到 2.4 → 看整条链被拖着走的样子,以及表④「各节下垂角」与「末端滞后」两列 → 静止时各节下垂角依次变大(越靠末端越垂);拖起来之后整条链向后拖出一个稳定的滞后,且速度越大滞后越大: 这四行代码没有一行在写"头发应该怎么动",可它自己长出了"甩、拖、停住又荡回来"的所有细节 —— 这就是程序化次级动画的全部魅力,也是它难以调参的原因。
表④ 串成一条:下弯顺着链累加(L = 0.30、stiff = 0.05、drag = 0.2、跑满 10 s 取稳态;局部角闭式 9.792°)
节数 n第 1 节 θ (°)末节 θ (°)末节 / (n × 局部角)末端竖直下落 (m)

算式(级联从哪来):单节的平衡是 tanθ = g·h²·(1−stiff)/(stiff·L) = 9.7918°(局部角,相对父节的朝向)。 串起来之后每一节的静止方向是上一节现在的朝向 —— 于是第 i 节的绝对角是前面所有局部角之和, 塌得越靠末端越明显。表④ 的「末节 θ」就是"垂到底"的程度:n = 1 / 2 / 3 / 4 / 6 时实测 9.79 / 19.17 / 27.94 / 35.97 / 49.62°。
但它不是简单相加:最后一列「末节 / (n × 局部角)」从 1.0000 一路降到 0.8446 —— 因为重力对某一段的切向分量是 g·h²·cosθ,这一节越接近竖直,切向驱动越小、局部增量也越小 (实测各段增量 9.79 → 9.38 → 8.77 → 8.03 → …,比值与该段中点的 cosθ 同量级)。 所以"头发越长发尾越贴着背"这件事,是余弦项自然饱和的结果,不需要额外加什么。
本页如实说明一处限制:四行代码只做"一遍自根向梢",链一长、速度一大时末端会甩过头 (表现出来的是抖动而不是滞后)。真引擎靠子步 + 每帧多迭代几遍压住它 —— 本页在附录 A3 里点名,不在这里展开。

课末:三种"写姿态"的对比

把这一课和上一课并起来看:「写姿态」的两种做法在这里彻底分道扬镳 —— IK 是"解"(给定末端目标,解出角度;有唯一解族、有可达性、有迭代收敛); 弹簧骨是"演"(给四行规则,让姿态自己长出来;没有目标、没有解、只有稳定态)。
它们的适用面正好互补:脚要踩住地面、手要扶住栏杆 —— 那些位置是硬要求,用 IK; 头发、尾巴、挂坠 —— 那些只要"看起来对",用弹簧骨。真实角色一般两者都用: 腿用 IK 踩地面(动画师给目标),头发用弹簧骨(程序自己算)。
如果只记一句话:弹簧骨没有"目标"这个概念 —— 它只有"上一帧"与"四行规则",而运动是它们的副产品。 本课题到此收口(蒙皮 → IK → 弹簧骨);附录里留了三样下一层的:子步与迭代、碰撞与限位、以及"布料 vs 骨头"的分界。