在 DAY 14 我們成功建立了手腳 Two-Bone IK 兩節解析解與「橙色目標球 / 黃綠極向球」雙球控制系統。但在實際測試時,我發現了一個問題:「當我先用 FK 細細調好一個完美的手臂彎曲姿態,一按開關切換到 IK,整條手臂會瞬間地跳動扭轉成另一個奇怪角度!」
突然想到 Vibe coding 過程中都只是注重於開發,好像沒有研究如何排除異常!?!?
所以,今天 DAY 15 我們要深入解構這個「姿勢瞬間跳動 Bug」的背後根因吧!

Vibe Coding 遇到異常時的分析流程,重點是兩個排查迴圈:

當使用者用 FK 旋轉關節擺好姿態後,開啟 IK 時,理論上目標球與極向球應該「無縫接軌」對齊當前姿勢,但系統卻強制將手肘拉到了另一個彎曲平面。
[ FK 擺好彎曲姿勢 ] → (開啟 IK 開關) → [ 手肘瞬間扭轉跳動!姿態崩壞 ]
問題出在開啟 IK 瞬間執行的 syncIKMarkersToDefault() 函式。原本的舊程式碼寫法如下:
JavaScript
// 有問題的舊版本
const mp = new THREE.Vector3();
midBone.getWorldPosition(mp);
ikPoleMeshes[limb].position.copy(mp.clone().add(chain.poleOffset));
Two-Bone IK 解算時,手肘向哪一側彎曲,是由極向球相對於 root → target 的側向分量(bendDir)決定。若極向球被放到了固定位置,算出的彎曲平面就會與實際姿勢產生角度偏差,導致求解出的手肘世界方向被瞬間轉走。
要達成「FK 切換 IK 零跳動」,初始化極向球時就不能用猜的,而是要從目前 FK 骨骼的世界座標反推實際的彎曲向量 bendDir:
| 步驟 | 向量計算邏輯 | 幾何意義 |
|---|---|---|
| 1. 取得關節座標 | 取得 rootPos(肩)、midPos(肘)、endPos(手)世界座標。 |
建立三點共面的幾何基礎。 |
| 2. 提取軸線方向 | targetDir = normalize(endPos - rootPos) |
取得肢體延伸的主軸向量。 |
| 3. 向量投影扣除 | toMid = midPos - rootPoslateral = toMid - targetDir * dot(toMid, targetDir) |
算出手肘點相對於主軸的垂直側向向量。 |
| 4. 極向向量正規化 | 若 lateral.length() > epsilon bendDir = normalize(lateral)否則使用預設 chain.poleOffset |
取得當前手臂真正彎曲的方向;打直時則退回猜測值。 |
| 5. 極向球定位 | polePos = midPos + bendDir * poleDist |
將極向球推至手肘側向外側,精準鎖定彎曲平面! |
修正完成後,我們可以進行以下情境驗證:
修好這個 Bug 後,FK 與 IK 的雙向流動終於完全順暢!可以用 FK 快速抓大角度,再一鍵切換 IK 用橙/黃綠雙球進行細節調整。
# Prompt:修正「FK 編輯完切換到 IK 時姿勢瞬間變形」的 Bug
## 背景
Tutting 3D 模擬器已經有手腳 IK 功能:每條肢體開啟 IK 時,會有一顆橙色**目標球**(控制
手掌/腳掌想到達的世界座標)和一顆黃綠色八面體**極向球**(控制手肘/膝蓋的彎曲方向)。
開啟 IK 的當下,程式會呼叫 `syncIKMarkersToDefault(limb)` 把這兩顆球定位到「目前姿勢」
對應的預設位置,理論上應該要無縫接軌、不改變畫面上的姿勢。
## 現象(Bug)
使用者先用 FK(點關節球、拖旋轉環)擺好一個手臂彎曲的姿勢,接著切換該肢體的 IK 開關,
畫面上的手臂會**瞬間跳到另一個姿勢**(手掌位置通常還在原地附近,但手肘彎的方向明顯不對,
整條手臂像是被扭轉了一下)。
## 根因
問題出在 `syncIKMarkersToDefault()` 對「極向球」初始位置的計算方式:
``js
// 有問題的版本
const mp = new THREE.Vector3();
midBone.getWorldPosition(mp);
ikPoleMeshes[limb].position.copy(mp.clone().add(chain.poleOffset));
``
`chain.poleOffset` 是一個**固定寫死的猜測向量**(例如手臂是 `(0,-0.15,0.35)`),跟目前
FK 姿勢實際彎曲的方向完全無關。
兩節 IK 求解時,手掌能不能碰到目標點只跟「root→target 距離」有關(這個距離在切換當下是
對的,因為目標球本來就設在目前手掌位置);但「手肘要往哪一側彎」是由極向球的**方向**決定:
``js
const toPole = polePos.clone().sub(rootPos);
const poleOnAxis = targetDir.clone().multiplyScalar(toPole.dot(targetDir));
const bendDir = toPole.clone().sub(poleOnAxis); // 極向球相對 root→target 連線的側向分量
const bendAxis = crossVectors(targetDir, bendDir).normalize();
const midDir = targetDir.clone().applyAxisAngle(bendAxis, angleRoot); // 算出新的肘部方向
``
如果極向球方向(固定猜測值)跟目前姿勢的實際彎曲方向不一致,算出來的彎曲平面就會不同,
求解出的肘部方向會跟目前實際方向差一個明顯角度,導致整條手臂在切換 IK 的那一瞬間被轉到
另一個彎曲平面 —— 這就是「瞬間變形」的成因。目標球的位置沒有問題,出錯的只有極向球。
## 修正方法
初始化極向球時,**不要用固定猜測方向,改成從目前 FK 姿勢反推出實際的彎曲方向**:
1. 取得 root / mid / end 三根骨骼目前的世界座標。
2. 算出 `root→end` 方向 `rootToEndDir`(即目前的 targetDir)。
3. 算出 `root→mid` 向量在「垂直於 `rootToEndDir`」方向上的分量 `lateral`
(做法:`toMid` 減去它投影在 `rootToEndDir` 上的分量)。
4. 若 `lateral` 長度夠大(手臂/腿有明顯彎曲),就把它正規化當作 `bendDir`;
若太接近 0(手臂/腿完全打直,沒有側向分量可反推,這是唯一無法還原方向的邊界情況),
才退回使用原本的固定猜測方向 `chain.poleOffset` 正規化後的方向。
5. 極向球最終位置 = `midPos + bendDir * poleDist`,其中 `poleDist` 建議沿用原本
`chain.poleOffset` 的長度(只換方向、不換距離),維持極向球跟骨骼之間視覺上合理的間距。
修正後效果:因為 `bendDir` 會對齊目前實際姿勢,切換 FK→IK 當下算出的
`deltaRoot`/`deltaMid`(世界空間旋轉增量)應該接近單位旋轉,姿勢不會跳動,只有在使用者
接著拖動目標球或極向球時才會開始明顯改變姿勢。
## 交付要求
- 只修改 `syncIKMarkersToDefault()` 這個函式內部「極向球定位」的邏輯,目標球的定位方式
不需要改。
- 不要更動兩節 IK 求解函式 `solveTwoBoneIK()` 本身的數學,這個函式已經正確,問題完全出在
初始化時極向球放的位置不對。
- 修正後要能通過的驗證情境:先用 FK 把手臂彎成任意角度(例如手肘往前彎 90 度),
切換該肢體 IK 開關,畫面上的手臂姿勢應該維持原樣,不會有明顯跳動;只有在手臂完全打直
(target 距離 = upperLen + lowerLen)時才允許用預設猜測方向。