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

动画混合:两个动作怎么叠成一个

全课骨架:动画 = 每一帧先把骨架的姿态算出来,再把顶点按权重「挂」到骨头上。 到这里,"姿态从哪来"已经有三条路(第 2 课解出来的、第 3 课演出来的、第 4 课补出来的), 可它们讲的都是一条动作。真实角色同时有走路、跑步、转身、挥手 —— 引擎每帧要问的是 这几条动作各出多少力、怎么叠。这一课就是那个"叠"字。 保真度:真机可证(混合算式、蒙皮与四张表都由本页自己的代码算出;对照物是四条闭式: 混合中点 p = (1−w)·A + w·B、相位包络 S·|cos(Φ/2)|、遮罩"被遮通道差恰好 0"、加层 k = 0 ⇒ 逐位还原)。 画面用的是网上的真实人体模型:Mixamo 的 Vanguard(49 关节 / 7 325 顶点 / 11 186 三角, 自带 512² 漫反射贴图;Mixamo 的角色可免费使用),蒙皮按 glTF 2.0 的 jointMatrix = world · inverseBind 逐顶点算 —— 也就是第 1 课那条式子,只不过这次挂的是真人网格。光栅化走 CPU:逐三角形 z-buffer + 贴图 + 三点光照 + 地面 + 平面投影软阴影 (示意 · 等价重现;该资产有些顶点的权重和不为 1,本页按 glTF 惯例先归一化再算)。

(脚本启动中…)

定点观察:先点「5.2 同一条动作错开 φ」,然后把「相位差 φ」从 0 慢慢拉到 180° → 看画面里这个人(真人网格,49 个关节)的迈步幅度一路缩下去、以及表② 的「混合后的极值」一列 → φ = 180° 时两条动作各出一半,恰好把彼此抵消干净(极值 0.0000)—— 人当场立定。 所以"混一半"从来不是"各取一半",它取决于两条动作当时对不对得上。

本帧的读数

(脚本启动中…)

5.1 段 · 两个动作之间:按权重混合

本段只增加一样东西:混合权重 w(画面里是一个真实的蒙皮人体 + 右上腿摆曲线;其余控件与各段一致)。 保真度:真机可证。第 4 课能把一条动作播出来了,可游戏里角色随时要换动作 —— 站在"走"突然变成"跑",两条动作的姿态不接,硬切会在那一帧跳一下。最省事的补法就是 让两条动作在同一时刻各出多少力:w = 0 全听 A,w = 1 全听 B,中间按权重取。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 动作 A(走)的 4 条通道 动作 B(跑)的 4 条通道 混合权重 w ★ 是逐通道的 不是"整条动作" w = 0 ⇒ 逐位等于 A w = 1 ⇒ 逐位等于 B w = 0.5 ⇒ 恰为中点 function blendCh(A, B, w, mask) { const o = new Array(4); for (let c = 0; c < 4; c++) o[c] = (1 - w) * A[c] + w * B[c]; return o; } ★ 混合出来的 4 条通道,接着交给驱动: driveTo(ch) → 49 个关节的局部旋转 → 蒙皮 髋/膝/踝、肩/肘都用这一份通道值 混合后的 4 条通道 w = 0.5 恰为 (A + B) / 2 与相位无关,永远成立 但"幅度"不是 (见 5.2) 见表① 逐行核对
定点观察:点「5.1 走 → 跑」,把「混合权重 w」从 0 拉到 1 → 看画面上这个真人的腿从"走"的节奏变成"跑"的节奏,以及表① 的「闭式 / 实测 / 差」三列 → w = 0 与 1 时逐位等于 A 与 B,w = 0.5 时恰为两者中点,五档的差全是 0: 混合就是逐通道的加权平均 —— 记住这句话里的"逐通道"与"平均", 后面两段(相位、遮罩)改的都是"平均"之外的东西。
表① 两个动作之间按权重混合(腿·左通道,t = 0.13 s 处取样;A = 走、B = 跑)。闭式一列故意写成等价的 A + w·(B−A),用来交叉验证页面真正跑的 (1−w)·A + w·B
w闭式 (1−w)·A + w·B (°)实测腿·左 (°)差 (°)

算式:混合是逐通道做的 —— 四条通道各算一次 p = (1−w)·A + w·B,共用同一个 w。 把 w = 0.5 代进去就得到 (A + B)/2,也就是"两点中点"。表① 的五档逐位对上(差 0,是浮点意义的 0)。
注意这四条通道是什么意思:它们是摆幅(度,0 = 静止位,正 = 向前)—— 腿·左 / 腿·右是两条腿的髋摆,臂·左 / 臂·右是两条臂的肩摆。它们不是"关节角"本身: 膝、肘、踝由它们经一条固定的示意曲线导出来(本页如实标注)。 混合发生在这四条上;这也是引擎里的真实做法(混合的是每条通道的曲线,不是"整个姿态")。
但"中点"与"幅度"要分开看:中点这条与相位无关、永远成立; 可混合出来的动作幅度完全由两条动作"对不对得上"决定 —— 那正是下一段的事。 真实引擎里还会把 w 先归一化(`wᵢ / Σwⱼ`),否则多条动作一起混时姿态会被整体缩放 —— 本页只有两条,`(1−w) + w ≡ 1` 天然成立,所以看不出这件事。

5.2 段 · 相位:两条循环错开了,就会互相抵消

本段只增加一样东西:两条动作的相位差 φ(本段把 B 换成 A 的同款动作、只整体错开 φ, 这样"抵消"这条式子才量得干净;其余控件一致)。 保真度:真机可证。5.1 悄悄假设了一件事:两条动作在同一时刻是"可比"的。 可真实的两条动作各自从自己的第一帧开始播 —— 过渡那一刻它们往往根本不对齐。 于是"各出一半"出来的不是中点姿态,而是两个错开的波动相加。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 动作的周期 T 当前时间 t 相位差 φ ★ 两条动作的 "内部时刻"不同 (起点各算各的) 对齐它,就叫 phase matching function clipAt(style, t, phase) { const a = 2*Math.PI * (t / st.T) + phase * DEG; return [ st.S * Math.sin(a), st.S * Math.sin(a + Math.PI), st.Sa * Math.sin(a + Math.PI), st.Sa * Math.sin(a) ]; } 各出一半:S·sin(a) 与 S·sin(a+Φ) 相加 ⇒ S·sin(a + Φ/2)·cos(Φ/2) 极值 = S·|cos(Φ/2)| 混合后的极值 = S·|cos(Φ/2)| Φ = 60° ⇒ 25.98° Φ = 90° ⇒ 21.21° Φ = 120° ⇒ 15.00° Φ = 180° ⇒ 0.00° (动作被完全抵消) 见表② 逐行核对
定点观察:点「5.2 同一条动作错开 φ」,把「相位差 φ」从 0 拉到 180° → 看右上方那条亮线(混合结果)的摆幅一路缩下去、以及表② 的「混合后的极值」一列 → φ = 180° 时亮线变成一条直线:两条各出一半、恰好完全抵消(实测 0.0000),画面里的人也不再迈步: 所以交叉淡化之前必须先把两条动作的脚步接触时刻对齐(phase matching)—— 否则你以为在"过渡",其实在"抵消"。
表② 相位失配的代价:两条同振幅同周期(腿摆 ±30° 的走路)各出一半,混合后的极值对闭式 S·|cos(Φ/2)|。实测不是照抄公式 —— 是拿本页自己的 blendCh 扫 20000 档 α 取出来的峰值
相位差 Φ (°)闭式 30·|cos(Φ/2)| (°)实测混合极值 (°)差 (°)

算式:两条同振幅同周期的通道错开 Φ、各出一半,用和差化积: S·sin(a) 与 S·sin(a+Φ) 各一半 → S·sin(a + Φ/2)·cos(Φ/2)。 括号里那个 cos(Φ/2) 就是包络 —— 它乘在整个正弦上,所以"幅度"恰好被削成 S·|cos(Φ/2)|。 拿 Φ = 90° 验算:cos45° = 0.70711,作用在 ±30° 上就是 21.213°;Φ = 180° 时 cos90° = 0, 极值 0.0000 —— 表② 逐行对上。
注意这条式子为什么要"同振幅同周期":振幅或周期一旦不同,包络就不再是一个常数, 而变成随时间的"拍"(5.1 那种走 + 跑就是拍)。本段的表用同款动作的两个相位副本,正是为了让这个包络可逐位验证 —— 页面上把 B 换成 A 的同款,就是这件事。
★ "差"这一列不是 0,而是 ~1e−7,这是扫描分辨率、不是模型误差:峰值在 α 网格上只能落在最近的一档, 取整误差 ≈ ½·S|cos(Φ/2)|·(π/N)²,N = 20000 时约 3e−7 —— 实测 1.4e−7,同量级。 把 N 从 2000 提到 20000,这一列从 ~6e−5 掉到 ~1e−7(差十倍),正是"它随步长同比例缩、所以是精度问题"的实证。 (两条我特意没做的"作弊":照着闭式直接返回、或者只在峰值附近加密采样 —— 那样这一列就变成 0, 可它也就不是在"扫"了。)
这条式子还解释了一件常见的事:为什么"随便交叉淡化两条动作"有时看起来像没动? 因为那两条动作在过渡时相位差接近 180°,恰好把彼此抵消掉了 —— 不是引擎坏了,是相位没对齐。

5.3 段 · 遮罩:只混一部分

本段只增加一样东西:遮罩(下半身不参与混合)(其余控件一致)。 保真度:真机可证。5.1/5.2 的混合是"全身一起混",可很多时候你只想换一部分: 上半身在挥手,下半身还得照常走路。做法就是在混合那一步加一张遮罩: 被遮住的通道直接取基础动作,其余照常混合。 本段的遮罩按"通道"分(腿两条 / 臂两条);真实引擎会把它做成关节级的遮罩 —— 这一页的 49 个关节里,髋以上的关节正好由臂通道驱动、髋及以下由腿通道驱动,所以两者是同一件事。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 遮罩(哪几条通道) A / B / w ★ 遮罩是按"通道"说的 (真实引擎按骨骼段/层级) 被遮的通道: 逐位取 A(一点没混) 其余通道:照常混合 for (let c = 0; c < 4; c++) { if (mask === 'lower' && (c === 0 || c === 1)) o[c] = A[c]; // ★ 遮罩:不参与混合 else o[c] = (1 - w) * A[c] + w * B[c]; } ★ 被遮通道的差必须恰好是 0 (不是"很小" —— 它根本没进那行加权) 这条差是判断"遮罩真的生效了吗"的唯一判据 腿通道 与 A 的最大差 无遮罩 ⇒ ≠ 0 下半身遮罩 ⇒ 0.000000 臂通道 与 A 的最大差 两行完全相同 (遮罩只动了下半身) 见表③ 两行对照
定点观察:点「5.3 只混上半身」,来回切「遮罩:无 / 下半身」 → 看这个人的腿有没有跟着变,以及表③ 两行的「腿通道 与 A 的最大差」 → 无遮罩时腿也在混(差 ≠ 0);切到下半身遮罩后这个差恰好是 0.000000,而臂通道那一列一位没动: "遮罩只改了它该改的那几条通道" —— 这句话是可以逐位验的,不必靠肉眼看。
表③ 遮罩:被遮通道逐位等于基础动作 A(A = 走、B = 跑、w = 0.5;"腿"= 左腿 + 右腿,"臂"= 左臂 + 右臂)
档位腿通道 与 A 的最大差 (°)臂通道 与 A 的最大差 (°)臂通道 与"纯混合"的最大差 (°)

算式:遮罩那一步是一个分支,不是一个系数 —— 被遮的通道完全不走那行加权。 所以"被遮通道与 A 的差"在数学上恒等于 0,实测 0.000000(是浮点意义的 0,不是"很小")。 这条"恰好为 0"比"大致不变"值钱得多:如果那里写成 `o[c] = (1−w)·A[c] + w·B[c]` 再乘一个 α=0 的遮罩权重, 差就会变成 `w·|B−A|` 量级的残渣 —— 肉眼同样看不出,但它在长时间播放里会累积成漂移。
另外注意表③ 的第三列:两行完全相同。这正是"遮罩只改了它该改的那几条通道"的证据 —— 如果那一列也变了,说明遮罩影响到了不该影响的通道。

5.4 段 · 加层:在基础动作之上叠一个偏移

本段只增加一样东西:层强度 k(其余控件一致)。 保真度:真机可证。遮罩解决的是"哪几条参与混合",可它做不到另一件事: 在现成的动作之上再叠一个偏移(走路的同时挥手、受伤的同时瘸腿)。 做法是加层:把一条动作减去它的参考姿态,得到"纯偏移",再按 k 加到基础动作上。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 基础动作 base(已混合) 层曲线 add(挥手) 参考姿态 ref 层强度 k ★ 层里存的是 add − ref(纯偏移) 不是 add 本身 所以 k = 0 时它必须归零 // 加层:只在臂·右通道上叠一条偏移 const d = ADD.A * Math.sin(2*Math.PI * t / ADD.T) - ADD_REF; const out = base.slice(); out[ADD.ch] += k * d; // ★ 只作用在臂·右 return out; ★ k = 0 ⇒ out[c] 逐位等于 base[c](差 0) 这条"还原性"一句话锁死三处常见写错: 层里混进了 base / ref 取错 / 层越过了遮罩 层作用到左边那条臂时,左臂差必须仍是 0 与 base 的最大差 = k × 35 k = 0 ⇒ 0.000 k = 0.5 ⇒ 17.500 k = 1 ⇒ 35.000 左臂:差恒为 0 (层只作用在臂·右) 见表④ 逐行核对
定点观察:点「5.4 叠一个挥手层」,把「层强度 k」从 0 拉到 1.5 → 看这个人的右臂在走路的动作之上多摆出多少,以及表④ 的「与 base 的最大差」一列 → k = 0 时这一列恰好是 0.000(右手臂逐位回到基础动作),而这一列严格正比于 k(17.500 / 35.000 / 52.500): 加层不是"再混一条",而是"加一个偏移" —— 所以它能叠在任何基础上, 也所以它的第一条件就是"k = 0 时什么都不能变"。
表④ 加层:与基础动作的最大差严格正比于层强度 k(层曲线 = 臂·右通道上一条 35° 的摆动,参考姿态取该曲线在 t = 0 的值)
层强度 k与 base 的最大差 (°)闭式 k × 35 (°)臂·左 与 base 的最大差 (°)

算式:加层做的是 p = base + k·(add − ref),其中 add − ref 是"纯偏移"。 于是与 base 的差恰好是 k·|add − ref| —— 一条过原点的直线:k = 0 ⇒ 0.000, k = 1 时等于那条曲线自己的极值 35.000°(表④ 逐行对上)。
"k = 0 必须逐位还原"是加层最重要的性质,它一条就锁死了三处最常见的写错: ① 层里直接存了 `add` 而不是 `add − ref`(k = 0 时动作会整体偏掉一截); ② 参考姿态取成了另一个东西(比如绑定位),于是"层关掉"时动作仍然被挪走; ③ 层越过了遮罩作用到全身(k = 0 也看不出,可 k ≠ 0 时另一条臂也跟着摆)。
还有一条配套的:层的效果必须与 base 无关 —— 把基础动作从"走"换成"跑"、或换成 5.1 的混合结果, 表④ 那一列数应当一位不变(因为它是加法)。这正是"加层可以叠在任何基础上"的可验形式。

课末:把"叠"拆成四件事

这一课把"叠"字拆成了四件互不替代的事,它们恰好对应真实引擎里动画图上的四类节点: ① 权重混合(两条动作各出多少)/② 相位对齐(它们当时对不对得上)/ ③ 遮罩(哪几条通道参与)/④ 加层(在基础上叠偏移)。
把它们串起来就是一次最普通的"走 → 跑"过渡:先把两条动作的脚步接触时刻对齐(②), 再让 w 在一小段时间里从 0 走到 1(①),下半身用遮罩留在走上面(③), 同时叠一个"喘气"的加层(④)。少了②,过渡会变成抵消;少了④,你只能整条整条地换,做不出叠加效果。
如果只记一句话:混合不是"选一条动作",而是"每一条动作各出多少力" —— 而"出多少力"必须和"对不对得上"分开算。 本课题到此五课收口(蒙皮 → IK → 弹簧骨 → 关键帧 → 混合);附录里留了三样下一层的: 状态机与过渡条件(什么时候切、切多久)、根运动与脚步锁定(怎么让脚不滑)、以及混合树与分层遮罩。
本页那一格"如实说明":腿/臂的驱动信号是按通道用正弦生成的示意步态(膝、肘、踝、趾由通道经一条固定曲线导出; 其中膝的时机还额外用了驱动层算出的一个相位提示 —— 通道值本身区分不了"摆动中段"与"支撑中段"), 不是动作捕捉数据;模型本身是真实人体网格,蒙皮、贴图、地面阴影与混合都是逐顶点真算的。 模型 Vanguard 取自 three.js 的示例资产(原作者 Mixamo / Adobe;Mixamo 的角色与动画可免费使用,含商用)—— 此处仍列出出处:threejs.org/examples/#webgl_animation_skinning_blending。