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

IK:想让手抓住那个点,关节该转多少

全课骨架:动画 = 每一帧先把骨架的姿态算出来,再把顶点按权重「挂」到骨头上。 上一课做的是前半句里的「读姿态」(姿态给定 → 顶点跟着走);这一课做的是后半句里的「写姿态」第一步: 给定末端要到达的位置,反解出各节的角。这件事在游戏里到处都是(脚踩地面、手扶栏杆、瞄准), 而它和我们习惯的「拧一个角、看它转到哪」是反着的 —— 反着的问题,解起来完全是另一回事。 保真度:真机可证(画面与读数都由本页自己的求解器给出;对照物是双骨的余弦定理闭式解、以及可达性判断,读者能拿笔复核)。 交互:在画面上直接拖动那个目标环。

(脚本启动中…)

定点观察:在画面上拖动那个目标环,把它拖到虚线圆(= 臂长总和)外面 → 看画面里那条链的末端与目标环之间的空隙,以及表④ 的「末端误差」 → 够不到时误差不再是 0,而恰好等于「目标距离 − 臂长总和」:这不是求解器坏了,是"这个问题本身无解", 好的实现会把链朝着目标拉直并如实地告诉你差多少。

本帧的读数

(脚本启动中…)

2.1 段 · 两个角,一个目标:余弦定理直接算

本段只增加一样东西:目标点(画面上出现可拖动的目标环;求解用解析解;其余控件与本课各段一致)。 保真度:真机可证。两节骨头、给定末端目标,这件事有闭式解:末端到根的距离 d 与两节骨长 围成一个三角形,用余弦定理先求出两节之间的夹角,再求出根节的绝对方向。全程四次三角函数,没有迭代。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 目标点 (tx, ty) 骨长 L₁ = 0.80 骨长 L₂ = 0.60 末端到根的距离 d = |t| 必须落在 [|L₁−L₂|, L₁+L₂] 内 否则无解(见 2.4) function solveTwo(t, L1, L2) { const d = Math.hypot(t.x, t.y); const c = (d*d - L1*L1 - L2*L2) / (2*L1*L2); const dA = Math.acos(Math.max(-1, Math.min(1, c))); // 相对角 const a0 = Math.atan2(t.y, t.x) - Math.atan2(L2*Math.sin(dA), L1 + L2*Math.cos(dA)); return [a0, a0 + dA]; } ★ 全程四次三角函数,没有一步迭代 —— 两节时解是唯一的 根节绝对角 a₀ 相对角 dA 把两角代回 FK 末端误差 ≈ 1e−16 d 超出 [|L₁−L₂|, L₁+L₂] ⇒ acos 的入参被夹住 ⇒ 解退化(2.4 的正题)
定点观察:拖动目标环在虚线圆内绕一圈 → 看画面里链的形状,以及表① 的「末端误差」一列 → 不管目标在哪,末端都精确落在目标上(误差 1e−16 量级),而且目标一过虚线圆就出问题: 因为解析解的前提是"d 与两节骨长能围成一个三角形"—— 这个前提一旦不成立,公式还在算, 但它算出来的已经不是那个问题的答案了。
表① 双骨解析解:目标点 → 两个角(L₁ = 0.80、L₂ = 0.60、根骨原点在 (0,0))
目标 (tx, ty)|t| = d相对角 dA (°)根节角 a₀ (°)末端误差 (m)闭式核对 dA

算式:把末端到根的距离记作 d = |t|。两节之间要张开多大?由余弦定理 cos(dA) = (d² − L₁² − L₂²) / (2L₁L₂) 给出相对角 dA;再让根节先指向目标方向、往回让一点: a₀ = atan2(t_y, t_x) − atan2(L₂·sin dA, L₁ + L₂·cos dA)。拿 t = (1.4, 0) 验算: d = 1.4,cos(dA) = (1.96 − 0.64 − 0.36)/(2×0.8×0.6) = 0.96/0.96 = 1 ⇒ dA = 0°(手臂完全伸直), a₀ = 0 − atan2(0, 1.4) = 0° —— 表① 那一行应当逐位对上(差 0)。 注意这条解只在"末端是两节"时唯一:换成三节,三角形就变成四边形,解从"一个"变成"一族"。

2.2 段 · 同一个目标,换成迭代:CCD

本段只增加一样东西:求解方法(把解析解换成迭代法 CCD;目标、骨长、控件都不动)。 保真度:真机可证。CCD 的想法朴素到一句话:从最靠近末端的那一节开始,把这一节转到"末端正好对准目标", 再往根走一节、重复。一遍扫完算一次迭代,多扫几遍误差就下去了 —— 它和解析解应当收敛到同一个答案。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 当前各节角 目标点 迭代次数 / 阻尼 从末节往根扫 (顺序不能反) 阻尼 < 1 ⇒ 每步只转一部分 function ccdOnce(ang, t, damp) { let end = fkEnd(ang); for (let j = ang.length - 1; j >= 0; j--) { const J = fkJoint(j), e = sub(end, J), g = sub(t, J); const dth = Math.atan2(g.y,g.x) - Math.atan2(e.y,e.x); ang[j] += damp * Math.atan2(Math.sin(dth), Math.cos(dth)); end = fkEnd(ang); } return end; ★ 每一遍都让"末端指向目标",代价是会把根节也一起转歪 迭代 1 次 误差还很大 迭代 8~16 次 误差 → 1e−16 且与解析解一致 但两节的解是唯一的 (所以两条路必到同一处)
定点观察:把「迭代次数」从 1 拉到 16,再把「阻尼」从 1 压到 0.3 → 看链的形状变化,以及表②「末端误差」与「相对上一档」两列 → 迭代 1 次时误差肉眼可见、16 次时降到 1e−16(与解析解一致);阻尼压小只是让每步走得更少、要更多遍: 迭代法不是"另一种答案",它是在用多走几遍换掉"必须知道闭式解"这个前提 —— 而前提一撤,节数就不再受限。
表② CCD 迭代:两节、同一目标 (1.00, 0.40)、阻尼 1.0,迭代次数与末端误差(L₁ = 0.80、L₂ = 0.60)
迭代次数末端误差 (m)相对上一档根节角 a₀ (°)与解析解的角差 (°)

算式:CCD 的一遍扫描是"末节先转、再往前一节",所以信息在一遍里是自梢向根传的; 要让整条链同时满足末端位置,得扫若干遍。表② 的看点有两个:误差一路下降(相对上一档 < 1), 以及收敛之后根节角与解析解逐位一致(差 → 0)—— 两节的解是唯一的,所以两条路不可能到不同地方。
阻尼那一档要单独看:它不改变"最终到哪儿",只改变"每步走多远" —— 阻尼越小,同样的遍数走得越少, 所以误差更大。真实引擎要给阻尼/限位,是为了让动作看起来自然(不要一帧扭到位),不是为了精度。
★ CCD 有一个真实的脾气,本页实测到并如实写出来:当目标恰好落在当前各节的同一条射线上时, 每一节"要转的角"算出来都是 0 —— 它会原地不动(本页把目标放在 (1.0, 0)、链从全零姿态起步时, 误差就卡在 0.4000 一动不动)。所以真实实现从不给它全零/共线的初值,而是用上一帧的姿态当初值 —— 游戏里角色每帧都在动,天然避开了这个退化点。本页的初始姿态也因此设成"微弯"而不是全零。

表②b CCD 的收敛速度强烈依赖目标在哪儿:同一求解器、同一初值、阻尼 1.0,"到 1e−9 需要几遍"(遍数按翻倍试出来的,L₁+L₂ = 1.40)
目标 (tx, ty)d (m)d / ΣL到 1e−9 需几遍48 遍时的误差 (m)

这张表是 2.2 段最值钱的一条,也是实测才看出来的:CCD 的收敛速度不是常数,它强烈依赖目标在哪儿。 目标落在"手臂中段"(d/ΣL ≈ 0.73~0.77)时 32 遍就够了;越靠近"手臂全伸",需要的遍数越大 —— d/ΣL = 0.958 要 256 遍、0.988 要 512 遍、0.995 要 2048 遍(最后 3% 里翻了 8 倍)。 原因是几何上的:接近全伸时这条链接近奇异位形,各节的转动对末端位置的影响几乎互相抵消, 于是"每一遍能纠正多少"急剧变小。这就是后来 FABRIK 一类算法被广泛采用的原因 —— 它们换了一种更新方式(先把末端拉到目标、再往回推保骨长),在接近全伸时不会这么慢。 本页选 CCD 是因为它最好解释(每节只需转到"对准目标"),它的这个缺点如实写在这里。

2.3 段 · 三节的时候,答案不止一个

本段只增加一样东西:骨节数(2 → 3~4 节;方法仍是上一段的 CCD,其余控件一致)。 保真度:真机可证。2.1 的解之所以是"一个",是因为两节加一个目标刚好构成一个三角形。 加第三节,位置方程变成 2 个方程、3 个未知角 —— 于是解从"一个"变成"一族": 末端都能落在目标上,但肘、腕摆在哪儿,取决于你从哪儿出发。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 节数 n = 3 三个不同的初值 (起始姿态不同) 2 个方程 3 个未知角 ⇒ 解是一族 迭代从初值出发 ⇒ 停在哪一个解上 // 同一个求解器,只换起点 const A = ccdSolve([ 0.6, -0.4, 0.2], t, 200, 1); const B = ccdSolve([ 0.0, 0.0, 0.0], t, 200, 1); const C = ccdSolve([-0.6, 0.4, -0.2], t, 200, 1); // 三个解:末端位置相同、中间关节各不相同 endOf(A) ≈ endOf(B) ≈ endOf(C) ≈ t joint1Of(A) ≠ joint1Of(B) ≠ joint1Of(C) ★ 这就是"欠定"的可证形式:末端一样、形状三样 真实引擎靠限位/偏好姿态把这一族收成一个(附录 A2) 末端误差 三者都 ≈ 0 肘/腕位置 三者差很多 差异有多大 可以直接量(见表③) 节数越多、这一族越大
定点观察:把「骨节数」拉到 3 或 4,然后反复点「复位」与「2.3 三节的欠定」两下 → 看表③:「末端位置」三行是否一致,「肘的位置」三行是否分散 → 末端都对准了目标,但肘可以摆在目标上方、下方、或反折过去: 这说明"三节的 IK"问的根本不是"解是多少",而是"你要哪一个解" —— 这一步是纯数学给不了的, 必须由限位、偏好姿态、或每一帧的连贯性来补。
表③ 多节的欠定:同一目标、三个不同初值,CCD 各解 200 遍(3 节、L = 0.80/0.60/0.45、目标 (0.90, −0.30)、迭代 200 遍)
初值(各节角 °)末端位置末端误差 (m)第 1 个关节位置与 A 的关节距离 (m)

算式(欠定的由来):平面里"末端落在目标上"是 2 个方程;n 节骨头有 n 个未知角。 于是 n = 2 时"2 方程 2 未知"⇒ 解是离散的(本例唯一);n ≥ 3 时"2 方程 n 未知"⇒ 解是一条(n−2)维的族。 表③ 就是把这个族摊开给读者看:三行末端逐位相同,而第 1 个关节的位置彼此分开(0.30 m 与 0.75 m)。 迭代法会停在离初值最近的那个解上 —— 所以同一个目标、同一个求解器,换个初值就换一个姿势, 这在动画上要么是"可控性",要么是"每帧乱跳"(取决于你有没有做帧间连贯)。
★ 顺带记一笔实测:把表③ 的迭代次数从 200 降到 32 时,第三行那个反折的初值还差 2.5e−4 没收敛(另外两行已经到 1e−16 量级)—— CCD 的收敛速度与初值有关, 这是它和解析解最不一样的脾气:解析解一次到位,迭代法得看"从哪儿出发"。所以本表统一用 200 遍, 让"末端逐位相同"这句话站得住。

2.4 段 · 够不到、或者懒得迭代:可达性与阻尼

本段只增加一样东西:目标距离(把目标拖到臂长总和之外;其余控件一致)。 保真度:真机可证。2.1~2.3 都默认"这个问题有解"。可目标一旦落在虚线圆外面, 方程就无解了:余弦定理里的 acos 入参会被夹到 ±1(解退化),CCD 则会让整条链慢慢朝目标方向伸直、 但永远差一截。好的实现不假装能解,而是把链拉直、并如实报出差多少。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 目标距离 d = |t| 臂长总和 ΣL d > ΣL 时无解 (求解器不会告诉你 这件事,除非你先判) 兜底:朝目标拉直 并把差额报出来 function solveReach(t, LB, ang, iters, damp) { const d = Math.hypot(t.x, t.y), sum = sumL(LB); if (d <= sum) return ccdSolve(ang, t, iters, damp); // 可达:正常解 const dir = Math.atan2(t.y, t.x); return ang.map(() => dir); // 不可达:整条链朝目标拉直 } // 于是末端误差恰好 = d − ΣL(与方向、与节数、与迭代次数都无关) // —— 这条式子读者拿笔就能验算,也是"这个目标确实够不到"的定义 // 真实引擎还会在这里夹住目标、或做目标预平滑(附录 A2) d ≤ ΣL 误差 → 0 d > ΣL 误差 = d − ΣL 链的形状 朝目标方向完全伸直 (不再随初值变)
定点观察:把目标环从虚线圆内拖到圆外,继续往外拖 → 看链是不是朝着目标方向伸直了,以及表④「末端误差」与「d − ΣL」两列 → 圆外那一档误差恰好等于 d − ΣL,两列逐位相同,而且不再随迭代次数变化: 这是"无解"的样子 —— 迭代再多次也没用,因为它要的那个位置根本不在可达空间里。 两种做法都算对的:拉直(把姿势交给可达空间里最近的一点)或夹住目标(假装目标是可及的), 区别只在"动画看起来想要哪一种"。
表④ 可达性:目标距离与末端误差(两节、L₁+L₂ = 1.40、迭代 16 次、阻尼 1.0、初值微弯、目标沿偏 35° 的方向)
目标距离 d (m)可达?末端误差 (m)闭式 d − ΣL(负取 0)根节角 a₀ (°)

算式:可达的上界就是臂长总和 ΣL(链完全伸直)。于是末端误差 = max(0, d − ΣL) —— 与目标方向、与节数、与迭代次数、与阻尼都无关。拿 d = 2.00、ΣL = 1.40 验算:误差 = 0.60 m, 表④ 那一行应当逐位对上。而根节角在圆外恒为 0°(朝 +x 拉直),圆内则跟着目标方向走。
这就是它和前三段最大的不同:前三段在问"怎么解得更快更准",这一段在问"这个问题有没有解"。 真实项目里,可达性判断和"目标预平滑"是同一个模块的两半(脚要踩地、手要扶墙,目标几乎总在边缘上跳)。

课末:写姿态有两种,这是第一种

这一课把"写姿态"里的第一种做完了:给定末端目标,反解关节角。 它的形状很固定 —— 解析解(只有两节才有)× 迭代解(多节唯一可行)× 可达性(无解时怎么办), 而三段其实在回答同一个问题的三个面:解存不存在、唯一不唯一、算得快不快。
如果只记一句话:IK 不是在"求一个角",而是在"从一个解族里挑一个" —— 数学只能告诉你族的形状,挑哪一个得靠限位、偏好姿态与帧间连贯(那些是动画师的活)。 下一课是"写姿态"的第二种:弹簧骨(头发、尾巴、挂坠)—— 那一半不求解,而是让骨头自己变成一根弹簧, 跟着主体滞后地动。