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

关键帧与插值:姿态是"补"出来的

全课骨架:动画 = 每一帧先把骨架的姿态算出来,再把顶点按权重「挂」到骨头上。 「读姿态」那一半是第 1 课的蒙皮;「写姿态」前面给了两条路 —— 第 2 课的 IK 是解出来的、第 3 课的弹簧骨是演出来的。 可真实项目里占比最大的是第三条,也是最朴素的一条:姿态是动画师摆的。他只在少数几个时刻摆好姿势, 中间那几百帧全是补出来的。这一课就讲"补"。 保真度:真机可证(画面与四张表都由本页自己的采样器算出;对照物是三条闭式: 线性插值 p(u) = (1−u)a + u·b、保持段的过冲 b + (b−a)/8、缓动的时间推进率 6u(1−u) —— 读者能拿笔复核)。

(脚本启动中…)

定点观察:先点「4.3 换成 Catmull-Rom」→ 看右上那条曲线在"保持"那一段(t = 1…2)鼓起来多高,以及表③ 的「过冲」一列 → 关键帧明明写着 60°,曲线却冲到 67.5° —— 而且它会「在关键帧处折一下」这件事同时消失了(表② 的跳变从 60 变成 0)。 平滑与过冲是同一枚硬币的两面。

本帧的读数

(脚本启动中…)

4.1 段 · 两个姿态之间:先把时间归一化,再线性插值

本段只增加一样东西:时间 t(画面里是两条通道曲线 + 手臂;关键帧成对出现,其余控件与各段一致)。 保真度:真机可证。上一课结束时你已经能"算出一个姿态"了,可关键帧给的是几张照片 —— t = 0 一张、t = 3 一张,中间什么都没有。播放器每一帧要问的是"现在这一刻该是什么姿态", 所以第一件事是把时间归一化:这一帧落在哪两个关键帧之间、走到这一段的多大比例 u。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 关键帧 A(t = 0) 关键帧 B(t = 3) 当前时间 t ★ 只有两张"照片" 中间是空的 u 是把"还要走多远" 换算成 0…1 一条通道一条曲线 function segOf(keys, t) { const n = keys.length; if (t <= keys[0].t) return { i: 0, u: 0 }; if (t >= keys[n-1].t) return { i: n-2, u: 1 }; let i = 0; while (i < n-2 && t > keys[i+1].t) i++; return { i, u: (t - keys[i].t) / (keys[i+1].t - keys[i].t) }; } function lerp(a, b, u) { return a + (b - a) * u; } ★ u 与"哪一条通道"无关 —— 所以一次算好、所有通道共用 任意时刻的姿态 u = 0.5 ⇒ 恰为中点 p = (a + b) / 2 u = 0 ⇒ 就是 A u = 1 ⇒ 就是 B 五档 u 逐位命中闭式 (见表①,差 0) ★ 但"会不会拐弯"它管不了
定点观察:把「时间 t」滑块从 0 慢慢拖到 3 → 看右侧 θ₀ 曲线上那颗游标点,以及画面下方手臂的姿态、左上角的读数 → t 走到一半(u = 0.5)时,姿态恰好是两个关键帧的中点(θ₀ = 30.00°);两端则逐位等于关键帧本身: 线性插值把"两个姿势之间"变成了一条直线,代价是它永远走直线 —— 后面三段就是要修这件事。
表① 两个关键帧之间的线性插值:p(u) = (1−u)·a + u·b(θ₀ 通道,a = 0°、b = 60°,间隔 3 s)
u闭式 (1−u)·0 + u·60 (°)实测 θ₀ (°)差 (°)

算式:把"这一段里还要走多远"归一化成 u = (t − t₀)/(t₁ − t₀),然后对每一条通道各做一次线性插值 p(u) = (1−u)·a + u·b。拿 u = 0.5 验算:0.5×0 + 0.5×60 = 30.000° —— 表① 那一行逐位对上(差 0,是浮点意义的 0)。
注意 u 与通道无关:手臂有 θ₀、θ₁ 两条通道,可"走到这一段的多大比例"只有一份。 所以真实实现里是先算 u、再逐通道插值(一条时间轴、多条曲线),而不是给每条通道各算一遍时间 —— 后者在通道数一多就会因为浮点路径不同而对不齐,关键帧处出现极小的错位。

4.2 段 · 一串关键帧:逐段补出来,会在关键帧处折一下

本段只增加一样东西:关键帧数(2 → 4)(插值方法仍是线性,其余控件一致)。 保真度:真机可证。一段真实动作不止两个姿势:抬起来、停一下、放下来 —— 至少三四个。 于是"逐段线性插值"就变成一条折线:每一段各自是直的,可接缝处的方向是断的。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 四条关键帧(t = 0/1/2/3) θ₀: 0 → 60 → 60 → 0 θ₁: −40 → 0 → 0 → −40 ★ 相邻两段各自是直的 接缝处方向是断的 "停一下"就是两条 相同的关键帧 真实动作大多长这样 // 逐段:先找到这一段,再各自线性插值 while (i < n-2 && t > keys[i+1].t) i++; const u = (t - keys[i].t) / (keys[i+1].t - keys[i].t); const A = keys[i].a, B = keys[i+1].a; v = A.map((av, c) => lerp(av, B[c], u)); 接缝处:左斜率 = 本段斜率 (v[i] - v[i-1]) / dt 与 (v[i+1] - v[i]) / dt 两个数一般不相等 ⇒ 速度突变(只是 C0) ⇒ 跳变 = 60 °/s(见表②) 关键帧处的斜率 左 60 / 右 0 ⇒ 跳变 60 °/s "停一下"那一段 斜率恒为 0 眼睛看得出的"顿一下" 其实是这一列数 见表② 逐行核对
定点观察:点「4.2 一串关键帧(4 条)」,然后盯着右上 θ₀ 曲线在 t = 1 与 t = 2 那两个拐角 → 看曲线是不是"折"的,以及表② 里线性插值那两行的「跳变」 → 拐角处左斜率 60 °/s、右斜率 0 °/s,跳变 60 —— 而这段动作本该是"抬起来、停住",中间不该有这一下急刹: 折线只能保证"关键帧上姿态是对的"(C0),保证不了"速度是连的"(C1)。
表② 关键帧处的左右斜率:折角有多大,是一个可以量出来的数(θ₀ 通道,° / s)
方法 / 位置左斜率右斜率跳变

算式:斜率用数值微分量(在关键帧两侧各取 t ∓ 5e−4 采样,再除以 2h), 这样"这条曲线顺不顺"就不必用眼睛判了。 线性插值下两侧斜率就是前后两段的斜率:θ₀ 在 t = 1 处左 = (60−0)/1 = 60、右 = (60−60)/1 = 0 ⇒ 跳变 60 °/s;t = 2 处对称,跳变也是 60。
★ 这张表里最容易被忽略的一行是最后一行:Catmull-Rom 的 C1 只在关键帧等间距时成立。 它的切线 m = (v[i+1] − v[i−1]) / 2 是按段内参数定义的,换回真实时间轴要除以该段的时长 —— 所以把间距从 1/1/1 改成 1/1/2,t = 2 处就变成左 −30、右 −15,跳变 15 °/s,不再是 0。

4.3 段 · 让折角消失:切线由邻居说了算(代价是过冲)

本段只增加一样东西:插值方法(线性 → Catmull-Rom)(关键帧仍是 4 条,其余控件一致)。 保真度:真机可证。折角的根源是"每段只顾自己":它只知道自己的两端,不知道旁边那两段往哪走。 补法非常直接:让关键帧处的切线由它的左右邻居决定 —— 把相邻两点的差取一半(m = (v₊ − v₋)/2), 塞进 Hermite 基函数。这样每一段的两端斜率就自动与邻居接上。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 这一段的两端 a, b 左右邻居 v₋, v₊ 段内参数 s 切线不是拍脑袋定的 它由邻居算出来 两端各自拿到 同一条切线 ⇒ 接得上 代价见下面 ★ function hermite(p0, m0, p1, m1, s) { const s2 = s * s, s3 = s2 * s; return (2*s3 - 3*s2 + 1) * p0 + (s3 - 2*s2 + s) * m0 + (-2*s3 + 3*s2) * p1 + (s3 - s2) * m1; } // ★ 切线 = 邻居差的一半(没有邻居就钳住自己) const m0 = (p1_v - p_prev) / 2, m1 = (p_next - p0_v) / 2; ★ 两端的切线都不为 0 ⇒ "保持"那一段会被撑起来 闭式:p(s) = b + (b−a)/2 · s(1−s)(峰值在 s = 0.5) 关键帧处 左 = 右 ⇒ 跳变 0 保持段峰值 60 → 67.5° 闭式 b + (b−a)/8 关键帧说 60,冲到 67.5 过冲 7.5° 见表③ 逐行核对
定点观察:点「4.3 换成 Catmull-Rom」,看右上曲线在 t = 1…2 那一段鼓起来多少 → 再看表③ 的「曲线极值」与「过冲」两列,以及表② 里跳变那一列 → 拐角没了(跳变 60 → 0),可曲线冲到了 67.5°,越过了关键帧的 60° —— 过冲 7.5°,逐位等于闭式 (b−a)/8: 这就是"平滑"的账单。真实工具里的钳制切线 / 自动切线就是来付这笔账的。
表③ 平滑的代价:曲线离开关键帧值域的最大幅度(4 条关键帧,θ₀: 0→60→60→0;θ₁: −40→0→0→−40)
配置关键帧值域 (°)曲线极值 (°)过冲 (°)闭式 (b−a)/8 (°)

算式:考虑"抬起来 → 停住 → 放下"里那个保持段(两条关键帧的值相同,记作 b;外侧邻居记作 a)。 这一段的两个切线是 m₀ = (b − a)/2、m₁ = (a − b)/2(把邻居差取一半),代进 Hermite: p(s) = b + (b−a)/2 · s(1−s),它是开口向下的抛物线,峰值在 s = 0.5,等于 b + (b−a)/8。 拿 θ₀ 验算:a = 0、b = 60 ⇒ 峰值 = 60 + 60/8 = 67.5°,过冲 = 60/8 = 7.5° —— 表③ 那一行逐位对上。θ₁ 同理:a = −40、b = 0 ⇒ 峰值 0 + 40/8 = 5°。
线性插值则永远不离开关键帧值域(过冲恒为 0)—— 它不是更好,它只是"没有形状"。
这条式子解释了那个很常见的疑问:为什么停在关键帧上还会抖一下? 因为关键帧只规定"经过这里",不规定"怎么经过";两侧邻居把切线撑开了,曲线在中间自己鼓起来。 所以"停在 60°"这句话在关键帧数据里根本不存在 —— 想要真的停住,得再加一条关键帧、或者用钳制切线。

4.4 段 · 快慢 ≠ 路径:缓动改的是时间,不是姿态

本段只增加一样东西:时间重映射(缓动开关)(关键帧 4 条、方法仍是 Catmull-Rom,其余控件一致)。 保真度:真机可证。前三段改的都是"走过哪些姿态"。还有一种非常常见、却常被混起来的改动: 姿态一个不改,只改"什么时候走到"。做法是把段内参数 u 先过一遍缓动函数(这里用 smoothstep s = u²(3−2u)), 再拿 s 去采样曲线。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 段内参数 u(线性时间) 缓动开关 曲线(4.3 那条) ★ 它不动曲线 只动"什么时候到" 所以姿态序列一位没变 变的只有速度剖面 这是"另一把旋钮" // 4.4 段真正在跑的:时间重映射 const easeU = (u) => u * u * (3 - 2 * u); // smoothstep const s = ease ? easeU(u) : u; // ← 只改这一行 // 之后照旧:v = hermite(a, m0, b, m1, s) 时间推进率 = ds/du = 6u(1−u) u = 0.25 → 1.125 u = 0.50 → 1.500 ← 峰值 u = 0.75 → 1.125 ★ 中点 u = 0.5 处 s 也等于 0.5 ⇒ 姿态恰好相同 姿态序列 中点:差 0(逐位) 速度剖面 峰值比 1.500 两端变慢、中间变快 (就是"慢进慢出") 见表④ 逐行核对
定点观察:点「4.4 打开缓动」,来回切几次 → 看右上曲线本身有没有变,以及表④ 的「姿态与线性之差」与「实测 dτ/dt」两列 → 曲线一动不动(中点姿态逐位相同、差 0),变的只有游标在时间轴上的走法:中点速度比恰为 1.500: "改曲线形状"(4.3)与"改时间映射"(4.4)是两把不同的旋钮 —— 前者回答"走过哪些姿态", 后者回答"什么时候走到"。混起来调,就会怎么调都不对。
表④ 缓动只改时间:s = u²(3−2u) 的时间推进率 ds/du = 6u(1−u)(4 条关键帧、Catmull-Rom)
u闭式 6u(1−u)实测 dτ/dt该点姿态与线性之差 (°)是否同一姿态

算式:缓动把段内参数换成 s = ease(u),于是"剪辑时间" τ = tᵢ + s·Δt, 时间推进率 dτ/dt = ds/du。对 smoothstep 求导得 ds/du = 6u(1−u) —— 它在两端为 0(起步与收尾都慢)、在 u = 0.5 处取峰值 1.5。表④ 的「实测」是对页面自己的 τ 做数值微分得到的, 三行分别是 1.125 / 1.500 / 1.125,与闭式逐位一致。
而"姿态"那一列只在 u = 0.5 处为 0:因为 smoothstep 关于中点对称,s(0.5) = 0.5 = u, 两条路恰好在那里重合;其余位置 s ≠ u,走过的姿态自然不同 —— 但"走过的姿态集合"是同一个(都是同一条曲线上的点,只是到达的时刻不同)。这就是"快慢 ≠ 路径"的可验形式。
★ 一处如实的代价:缓动是逐段施加的,所以在每个关键帧上推进率都落到 0 —— 动作会在每个关键帧"刹一下车"。真实工具通常用整条剪辑的时间重映射、或自动切线来避免它; 本页保留逐段缓动,正是为了让你看见这个副作用。

课末:姿态从哪来 —— 三条路都齐了

把这一课与前三课并起来看,骨架那句话的两半都落到实处了: 读姿态(第 1 课蒙皮)、写姿态有三条路 —— 解出来的(第 2 课 IK:给目标,解角度,有可达性、有迭代收敛)、 演出来的(第 3 课弹簧骨:给规则,让姿态自己长出来,只有稳定态)、 补出来的(本课关键帧:给几张照片,中间插值出来)。
三者的选用也互补:手要抓住栏杆、脚要踩住地面 —— 硬要求,用 IK; 头发、尾巴、挂坠 —— 只要"看起来对",用弹簧骨; 角色的主体动作 —— 那是表演,只能由人摆关键帧。真实角色的每一帧常常三条路都用上: 关键帧给主体姿态、IK 修脚部落地、弹簧骨补头发。
如果只记一句话:关键帧只规定"经过哪里",不规定"怎么经过" —— 中间那段全是插值,而插值是有性格的。 本课题到此四课收口(蒙皮 → IK → 弹簧骨 → 关键帧与插值);附录里留了三样下一层的: 切线与钳制(怎样既平滑又不过冲)、姿态混合与状态机(两个动作之间怎么过渡)、以及根运动与脚步对齐。