ClaudeDocs/RE_NOTES_AVATAR.md
Humanoid Avatar 與角色動作逆向筆記
mot_define_* 的 637 個 clip 裡,537 個是角色動作,走 Unity 的 humanoid
muscle-space —— 主骨架由 137 條肌肉 DoF 驅動,clip 內的 Transform 曲線
只涵蓋 18 個節點(裙襬、緞帶等二次骨與 IK 目標)。
要把肌肉值還原成骨骼旋轉,需要該角色的 Avatar: 骨骼映射(哪根骨是 Hips/LeftUpperArm/…)+ 建構時算出的軸框架。
這份筆記記錄取得它的過程。
1. 先確認資產裡真的沒有
一開始我搜 catalog 的 address 有沒有 avatar 字樣,沒找到就下結論。
那個方法本身是錯的 —— address 是資產名稱,不含資產型別。
改成掃 bundle 內部的資產型別(scan_avatars.py):
掃 8938 個 bundle, 讀取失敗 0
humanoid Avatar : 0
generic Avatar : 60 (59 個角色 _hair + 1 個跳繩道具)
覆蓋率是 100% 而非抽樣:catalog 全部 36119 筆扣掉音效/影片/圖片後, 非媒體 bundle 共 8088 筆,全部下載並掃過(為此額外從 CDN 抓了約 0.5 GB)。
判斷 humanoid 時踩的坑
m_Avatar.m_Human 底下還隔一層 data。直接讀
m_Human["m_HumanBoneIndex"] 會拿到空的而誤判成 generic。
正確路徑是 m_Avatar.m_Human.data.m_HumanBoneIndex,至少一項 != -1 才算。
另外 m_HumanBoneMass[25] 是 Unity 的預設常數表,generic Avatar 也有 ——
看到它有值不代表是 humanoid,不能拿來當判準。
結論:humanoid Avatar 不隨資產出貨,是執行期由
AvatarBuilder.BuildHumanAvatar 建的,只能從記憶體取。
2. 執行期環境的三個絆腳石
| 問題 | 解法 |
|---|---|
| venv 的 frida 17.16.4 與裝置上 frida-server 16.1.4 不相容 | pip install frida==16.1.4 對齊 |
frida-il2cpp-bridge 偵測不到 Unity 版本(這個 build 的版本字串被改過) |
Il2Cpp.$config.unityVersion = "6000.3.0b1"(注意是 $config,Il2Cpp.unityVersion 是唯讀) |
bridge 0.13.1 在模組未載入時用 Process.attachModuleObserver(Frida 17 才有的 API) |
一律走 spawn 流程:rpc 在 resume + 等待之後才呼叫,模組早已載入,走同步快路徑。--attach 必定撞上 |
3. 骨骼映射表:不必等遊戲跑到用得到的畫面
IL2CPP 裡有遊戲自有的:
Vision.Common.VisionActorDefine // Vision.Common.dll,全是 static
Dictionary<string, HumanBodyBones> BoneToAvatarMap
Dictionary<HumanBodyBones, string> AvatarToBoneMap
Dictionary<string, string> PartsConnectionMap
靜態欄位在類別初始化前是 null。不需要等遊戲載入角色 ——
直接 class.initialize() 強制執行靜態建構子就會填好。
成果
BoneToAvatarMap 51 筆,全身 + 雙手 15 指 + 雙眼:
Hips jnt_C_hips00_00 LeftUpperArm jnt_L_upperArm00_00
Spine jnt_C_spine00_00 LeftLowerArm jnt_L_foreArm00_00
Chest jnt_C_spine00_01 LeftHand jnt_L_hand00_00
Neck jnt_C_neck00_00 LeftThumbProximal jnt_L_fingerThumb00_00
Head jnt_C_head00_00 … (完整表見 _avatar/bone_map.json)
PartsConnectionMap = jnt_C_headHair00_00 → jnt_C_head00_00 ——
正是先前在 §RE_NOTES_3D 純靠幾何推理出的頭髮掛點,現在有遊戲自己的宣告佐證。
獨立佐證
Unity 自己的 Animator.GetBoneTransform(HumanBodyBones.Hips) 回傳
jnt_C_hips00_00,與上表一致。兩個互不相干的來源對上了。
4. 一條走死的路:SetHumanPose
最想走的是讓 Unity 自己做 muscle→骨骼換算:
new HumanPoseHandler(avatar, root) 然後 SetHumanPose(ref pose)。
實測(il2cpp_resolve_icall 逐一查):
✅ GetHumanPose_Injected 0x119299d10
✅ Internal_CreateFromRoot_Injected 0x119299d08
❌ SetHumanPose / SetHumanPose_Injected 完全不存在
❌ HumanTrait 的 MuscleName / BoneFromMuscle / GetMuscleDefaultMin… 全無
託管層被 IL2CPP 的程式碼裁剪拿掉了,原生 icall 也沒註冊。
HumanTrait 只剩 get_MuscleCount 與 GetBoneIndexFromMono。
兩層都確認過,不是沒找到。
5. 改走主動校準(而不是被動錄製)
原本的備案是「讓遊戲播、我每幀錄骨骼變換」。問題是姿勢覆蓋受限於能操作什麼 —— 玩家只能走跳,手指 30 根與眼睛 2 根不會被動到,那些 DoF 就沒有資料可反解。
但實測這些 API 都在:
Transform.set_localRotation 可用 -> 主動把骨骼擺到已知旋轉
HumanPoseHandler(avatar, root) 可用
GetHumanPose 可用 -> 讀回 Unity 算出的肌肉值
HumanPose.muscles float[]
Animator.GetBoneTransform 可用
所以改成自己擺骨骼、讀回肌肉值,得到 (骨骼旋轉, 肌肉值) 配對, 反解每根骨的軸框架。覆蓋率由自己控制,與遊戲玩法無關。
場上實測:6 個 humanoid Avatar(isValid = true)、
13 個 VisionActorAnimation。播放走 Unity Playables
(VL.Animation 的 LayerParam / ClipParam),沒有可直接呼叫的
PlayMotion(name);但校準路線不需要它。
反解一次能不能通用?先驗證前提
Avatar 的軸框架是從綁定姿勢算出來的,所以「一次反解能否套用到所有角色」 取決於骨架是否一致。離線比對 279 個 body 骨架的 51 根 humanoid 骨 local TRS:
不同的 humanoid 綁定姿勢: 1 種 (279 個模型全部相同)
完全一致 —— 因此一次反解可套用到全部 62 位角色、537 個動作。
這裡連續踩了兩個坑:第一次算出「7 種」,是
-0.0與0.0序列化成不同 字串造成的假象(Python 比較相等、JSON 字串不等);而我的差異比對又只查了 「基準有它沒有」、漏了反向差集,所以印出「差異骨:無」卻仍分成 7 組, 自相矛盾卻沒立刻察覺。兩個都修掉才得到正確答案。
6. 讓骨骼停止被寫入 —— 先靜態分析,再注入
主動擺姿勢的前提是「擺完到讀取之間不會被覆寫」。前兩次都栽在這裡:
第一版(什麼都沒停) 角度 0 的對照組最大差 2.24
第二版(停掉 Animator) 角度 0 的對照組最大差 2.24 ← 完全沒改善
如果沒放「角度 0」的對照組,會拿到 918 筆看起來很完整的資料, 然後反解出完全錯誤的軸框架 —— 因為每根骨都「影響」84 條肌肉 (連 Hips 都影響手指),那是動畫在跑,不是我的操作。
第三版改成 hook 每幀的 Execute 在主執行緒做完,結果每秒 780 次呼叫
全導進 JS 執行緒,遊戲直接卡死、零產出。
改成先讀結構
il2cpp.h 裡的關鍵結構:
struct AnimationPlayerBase { // MonoBehaviour
struct Animator *_animator;
struct PlayableGraph _graph; // ← 主骨架由這個 graph 驅動
...
};
// vtable: GetPlayableGraph, GetPlayingBaseController, InsertRootPlayable, …
enum ActorAnimationProcessMask {
None=0, FullBodyIK=1, LookAtLimit=2, Constraint=4, QuartzDriver=8, Swing=16, All=31
};
兩件事因此清楚了:
- 主骨架由
AnimationPlayerBase._graph驅動。graph 是從管理者傳進VisionActorAnimation.InitializeActorAnimation(data, graph)的, 所以停掉 Animator 元件沒用 —— graph 不歸 Animator 管。 正解是GetPlayableGraph().Stop(),引擎正規 API,可用Play()還原。 - 後處理(FullBodyIK / QuartzDriver / Swing…)另外會寫 humanoid 相鄰骨
(型別名裡有
HumanoidArm/HumanoidHand/HumanoidUpLeg), 要用VisionActorAnimation.SetProcessMaskDisable()一起關。
實測
凍結前 四次取樣最大差異 0.127732 -> 動畫確實在跑
凍結 graphWasPlaying=true -> graphStopped=true, processMaskDisabled=true
凍結後 五次取樣最大漂移 0.00000000 -> 完全靜止
scripts/freeze_test.py 就是專門做這個驗證的 —— 不通過就不進行校準。
7. 校準結果
探測姿勢 1071 筆 (51 骨 × 3 軸 × 7 角度, 含 0 度對照與 ±30 度驗證集)
角度 0 的對照組: 最大絕對差 0.00000000
有反應的骨 50/51, 被觸動的肌肉 88/95
每根骨影響的肌肉完全符合 Unity 的 humanoid 佈局,沒有串音:
| 骨 | 肌肉索引 |
|---|---|
| Spine | 0, 1, 2 |
| Chest | 3, 4, 5 |
| Neck | 9, 10, 11 |
| Head | 12, 13, 14 |
| LeftEye | 15, 16 |
| LeftUpperLeg | 21, 22, 23 |
| LeftUpperArm | 39, 40, 41 |
| LeftHand | 43, 44, 45 |
| LeftThumbProximal | 55, 56 |
Hips 沒有反應是正確的 —— 它是根骨,由 bodyPosition / bodyRotation
驅動,本來就不佔肌肉。
LeftLowerArm 顯示 6 條(含 UpperArm 的 39、41)也是預期行為:
Unity 會把前臂的扭轉分配一部分到上臂。
8. 反解:三個錯誤依序被揪出
錯誤一:驗證問法錯了(第一版)
把每根骨的三軸旋轉全丟給最小平方法反解,結果大量骨的誤差正好等於探測角度本身 (40.000° / 30.000°)—— 模型只能輸出零。
原因不是模型不好,是問題本身無解:Unity 的 humanoid 刻意限制關節自由度, 手指只有 1~2 個 DoF。繞著沒有對應肌肉的軸轉動,肌肉值完全不變, 那個方向在肌肉空間裡不存在,任何方法都還原不了,Unity 自己也還原不了。
改成先擬合正向、用偽逆反解,並把真值投影到可觀測子空間再算誤差。 可觀測維度分佈與 Unity humanoid 的定義吻合:
維度 3: 脊椎/胸/頸/頭/四肢主關節 維度 2: 眼睛、手指根節
維度 1: 手指中節與末節 維度 0: Hips (根骨不佔肌肉 —— 正確)
可還原自由度合計 98
錯誤二:目標空間的參數化錯了
修好驗證後仍是中位數 3.02°,前臂 17~19°。診斷:
肌肉值是「特定座標系下尤拉角」的仿射函數,不是旋轉向量的。
我卻一直在回歸 log(q_base⁻¹ · q)。兩者是不同的非線性參數化 ——
目標空間錯了,加再多資料、拉再高次的基底都補不回來。
誤差分佈正好佐證:脊椎(Qpre/Qpost 接近單位)準到 0.2°,
肩膀前臂(偏離大)就爛到 17~19°。
改成直接擬合 Unity 的形式:
q_local = Qpre · R_ZXY(A·m + c) · Qpost
RightLowerLeg 立刻降到 0.058°、RightLowerArm 0.227° —— 形式確認正確。
錯誤三:k > 3 時多餘的肌肉被忽略(實作 bug)
e[:, :k] = ang[:, :3] —— 肌肉數超過 3 的骨,多出來的肌肉整個沒用到。
那正是前臂/肩膀的問題(Unity 的 ArmTwist = 0.5 把扭轉拆給父子兩根骨,
所以這些骨的肌肉數是 5~7)。而 k == 3 的 RightLowerArm 剛好沒踩到,
才會出現「同一支腳左右差 100 倍」的怪現象。
改成三個尤拉角各自是全部相關肌肉的仿射組合後:
LeftLowerArm 28.8° -> 4.84° LeftShoulder 8.10° -> 3.42°
錯誤四:拿流形外的姿勢考低自由度的骨
手指與眼睛仍是 15~83°。但那不是模型爛,是題目無效: 1 個自由度的手指只能到達一維的旋轉 family,我卻拿任意 3D 隨機旋轉去考它。 那些姿勢在流形之外,Unity 永遠不會產生 —— 答不出來才是正確行為。
這點值得記:難看的數字不一定代表答案錯,也可能代表題目錯。 反過來也成立 —— 若把流形外姿勢一起塞進訓練集、只看擬合誤差, 會得到一個「看起來收斂」但已被污染的模型。
9. 改用真實動畫幀
scripts/record_frames.py:不凍結(就是要讓動畫跑),每幀同時讀
「全部 humanoid 骨的 local 旋轉」與「肌肉值」。只讀不寫,沒有競爭問題。
真實動畫幀必然在流形上,而且分布就是實際要轉換的那個分布。
錄到 501 幀 (遊戲中途關閉, 原定 1500)
有變動的肌肉 88/95, 變動 > 0.1 的 82 條
校準資料沒有浪費 —— 拿來判定「每根骨自己的肌肉是哪幾條」, 那是在完全凍結、一次只動一根骨的條件下量的,最乾淨。
訓練/驗證用時間切分(前 70% / 後 30%)而非隨機切分 —— 相鄰幀高度相關,隨機切分會讓驗證集混進幾乎相同的訓練樣本,把準確度灌水。
結果
擬合 48 / 51 根骨
驗證平均誤差: 中位數 0.3063° 最大 2.4214°
<= 0.05° : 9/48
<= 0.1° : 17/48
<= 0.5° : 27/48
<= 1.0° : 36/48
未擬合 3 根:
Hips 此骨不佔肌肉 (根骨由 bodyPosition/bodyRotation 驅動 —— 正確)
LeftEye 錄製中未動 -> 無資料
RightEye 錄製中未動 -> 無資料
從第一版的中位數 15.5° 收斂到 0.31°。
手指的收斂最能說明問題 —— 從先前的 15~83° 變成:
LeftLittleDistal 0.0000° LeftThumbDistal 0.0007°
LeftThumbIntermediate 0.0016° LeftMiddleIntermediate 0.0248°
誤差最大的是手指根節(1.32.4°)與上臂(1.61.9°)。
10. 主動觸發動作 —— PlayMotion 找到了
先前寫「PlayMotion 存在但沒定位到宿主類別」,是因為只查了
VisionActorAnimation 一個類別就下結論。正確做法是掃 il2cpp.json 的
241,814 筆 methodDefinitions:
Vision.Common.VisionActorController
PlayMotion(VL.MotionDefine, ActorMotionTransitionMode, Nullable<...>)
PlayMotion(Int32 motionTrigger)
PlayMotion(SDActorPresetMotion)
ActorMotionTransitionMode = { Normal, In, Loop, Out }
VL.MotionDefine 正是 mot_define_* bundle 裡那個資產,執行期已載入的
名稱與 catalog 的 address 完全對應。
踩的坑:Nullable<> 是結構,不是指標
第一次呼叫直接吃到 parameter types mismatch。第三個參數是
Nullable<T>,屬於 value type —— 傳 NULL 指標會被拒。要傳一個歸零的
struct:
const nt = m3.parameters[2].type;
const size = (nt.class && nt.class.valueTypeSize) || 32;
const buf = Memory.alloc(size + 16);
buf.writeByteArray(new Array(size + 16).fill(0));
ctrl.method("PlayMotion", 3).invoke(md, mode, new Il2Cpp.ValueType(buf, nt));
實測:scripts/drive_motions.py 驅動 6 個動作、錄到 360 幀,
95 條肌肉中有 86 條變動、85 條變動 > 0.1。
11. 播放任意動作 —— 遊戲一次只載 6~7 個
PlayMotion 只能播「當下已載入記憶體」的 MotionDefine,實測場上只有
6~7 個(遊戲只載當前情境需要的)。要碰到全部 637 個,得自己觸發載入。
靜態分析找到的載入鏈:
Vision.Common.MotionProvider
UniTask<VL.MotionDefine> LoadAsync(VisionAddress, CancellationToken)
UniTask<VL.MotionDefine> LoadAsync(String, CharacterSdPersonalityType,
CharacterHandType, CancellationToken)
struct VisionAddress { struct String *_address; }; // 只是包一個字串
VisionAddress 只有一個字串欄位,所以可以直接在 Frida 這邊造出來,
不必去找 RemoteAddress.AssetEntry.GetAsset()。CancellationToken.None
同樣是歸零的 struct。
UniTask 從 Frida 很難 await,但不需要 —— 載入的副作用就是
MotionDefine 進了記憶體,之後用 gc.choose 依名稱撈出來即可。
結果:實測可用,scripts/drive_arbitrary_motions.py 對任選的 6 個
未載入動作 6/6 成功載入、播放並錄到真值。
過程中被抓出來六個問題,每一個都足以讓結果看起來「成功」卻是錯的 —— 其中第六個只在全量跑起來才會現形,小規模測試 8/8 全過。
坑一:gc.choose(MotionProvider) 回傳空
改從 il2cpp.h 反查誰持有它:
struct CommonActorController { ... struct MotionProvider *_motionProvider; }
struct OutGameSdActorPresenter { ... struct MotionProvider *_motionProvider; }
struct MultiLiveLobbyScenePresenter { ... struct MotionProvider *_motionProvider; }
但 Park 場景裡這三個宿主一個實例都沒有(實測 0/0/0)——
MotionProvider 在 Park 根本不被使用。最後的解法是自己建一個:
方法表顯示它有 .ctor(GameObject bindGameObject),會自行
VisionMultiAssetLoader.Create(...)。比 alloc() 後硬塞一個別人的
loader 可靠(借來的可能已經失效)。
坑二:findClasses(...)[0] 挑到沒有實例的同名類別
sceneInfo 加總所有同名類別,數到 12 個 VisionActorController;
playMotion 取 [0],回報 0。同一個類別名在不同組件裡有多個定義,
[0] 不保證是場上在用的那個。統一改成掃全部(chooseAll)。
這個 bug 讓「等角色出現」的迴圈輪詢 80 次全部落空,看起來像是遊戲
沒進場景 —— 但 sceneInfo 顯示它早就在 Park 了。
坑三:async 例外被吞進 UniTask
改對之後 LoadAsync 回傳 ok: true,東西還是沒進來。C# 的 async 方法
會把例外包進 UniTask 而不是拋出來,「呼叫沒噴錯」不等於載入成功。
改成直接讀 UniTask 的狀態:
UniTask<T> = { IUniTaskSource source; T result; short token }
source.GetStatus(token): 0=Pending 1=Succeeded 2=Faulted 3=Canceled
坑四:UniTask 回報 Succeeded,heap 上卻掃不到
gc.choose(MotionDefine) 找不到剛載入的那個。與其猜原因,直接拿
GetResult(token) 的回傳物件並保留參照 —— 那是最確定的來源。
(注意 UniTask 的 token 只能消費一次。)
坑五:播 A 錄 B
playLoaded 用「第 0 個 VisionActorController」,recordFrame 用
「第 0 個 humanoid Animator」—— 兩個排序毫無關聯,場上有 13 個角色。
改成用 _animator 欄位對上,確保播放與錄製是同一個角色。
這個修正不是理論上的:修正前變動 > 0.1 的肌肉是 30 條,修正後 84 條。 先前那批資料確實錄在別的角色身上。
一個差點被當成成功的假陽性
第一輪測試裡有 2/5 回報「新載入 + 播放成功」,但新出現的是
idle-action02 / idle-action03 —— 遊戲自己載的,不是我請求的那兩個。
判定條件只看「MotionDefine 集合有沒有變大」,沒有比對「變大的是不是
我要的那個」。同一時間遊戲自己在跑,這種判定必然被污染。
修正:只認名稱等於請求 address 的那一個。
坑六:IL2CPP 的 GC 不認得 JS 參照
小規模測試 8/8 全過,全量跑卻是 52 成功 / 578 失敗。
失敗訊息一開始看不懂:
[result] sit-talk00 : Error: abort was called
[load] 之後 577 個全部 : access violation accessing 0x63005f0070007f
那個「位址」拆開來看是 63 00 5f 00 70 00 7f 00 —— UTF-16 文字位元組。
它根本不是位址,是被字串重用的那塊記憶體。
我把 MotionProvider / MotionDefine / controller 存在 JS 變數裡當快取,
但那不構成 GC root。物件被回收後 handle 就指向垃圾,一個 clip 觸發
abort 之後,後面全部連鎖爆在同一個野指標上。
正解是建立真正的 GC root:
Il2Cpp.exports.gcHandleNew(obj.handle, 1) // pinned
配套:每次使用前先便宜地驗活、連續失敗 3 次清掉所有快取重來、 每 25 個 clip 存檔一次。
附帶修正:上一輪把 637 個 mot_define 全丟進去,但
_cam_/_env_/_plt_是鏡頭、場景物件與道具的動畫,本來就不會播在VisionActorController上。真正的角色動作是帶_chr_的 549 個。
附帶學到的:卡在 Title 時不能呼叫 gc.choose
有幾次重啟後遊戲停在 Title,此時呼叫 gc.choose 整個 frida 呼叫再也
不回來(實測掛了 20 分鐘)—— 它需要 GC world-stop,會跟卡住的主執行緒
互鎖。所以啟動流程只用便宜的 sceneInfo 輪詢,確認進了 Park 才做其他事,
超時就重啟。
recordFrame 快取的 Animator 也會失效(角色卸載/重生後再用就是
access violation),每次先便宜地驗一下、壞了就重建。
12. 跨 clip 驗證:換一把更嚴格的尺,兩個假設被否定
有了 497 個 clip 的真值後,可以做真正的驗收:訓練集與驗證集是不同的動作
(fit_and_validate_clips.py)。先前 fit_from_frames.py 是單一段錄影的
時間切分,只驗證了「同一段動作的後半段」。
時間切分 (舊, 寬鬆) 中位數 0.3063°
跨 clip 切分 (新, 嚴格) 中位數 0.8022° 最差骨 8.36°
不是退步,是換了尺。以下兩個假設都做了對照實驗、都被否定。
假設一:反解需要的肌肉集合比「自己的」更大 —— 否定
校準量到的是正向映射(轉動某根骨時哪些肌肉有反應)。Unity 的
ArmTwist = 0.5 把扭轉拆給父子兩根骨,所以同一組 (39,40,41) 對應到的
UpperArm 旋轉,理論上還取決於 LowerArm 的扭轉肌肉。
把有交集的骨互相併入對方的肌肉後重跑:
只用自己的 0.8022° 擴充後 0.8241° ← 略差
假設二:真值裡混了後處理(IK / Quartz / Swing)—— 否定
§6 查到 FullBodyIK / QuartzDriver / Swing 會寫 humanoid 相鄰骨
(型別名就是 HumanoidArm / HumanoidHand / HumanoidUpLeg),
而誤差最大的骨正好是那幾根。錄製時沒關它們,所以真值可能不只是肌肉的函數。
用 disablePostProcess()(只呼叫 SetProcessMaskDisable(),不停
PlayableGraph、不停 Animator)錄同一批 51 個動作:
後處理開 0.8223° 後處理關 1.3531° ← 反而更差
一個自己製造出來的崩潰:GetResult 直接呼叫
全量跑有 28 個 clip 回報 abort was called,而且每次都連帶讓後續整批
陪葬(script has been destroyed)。原因是 takeResult 直接呼叫
UniTask.GetResult(token) 而沒有先查狀態 —— 若 UniTask 是 Faulted,
GetResult 會把原生例外重新拋出,跨 frida 邊界沒有 managed handler,
整個遊戲行程當場 abort。
證據是它可重現:wavehands00_typ002 與 yoga00 兩輪都掛在同一個位置。
我先前把它歸因為「記憶體壓力」是猜測,這個才是有跡可循的。
修正時又踩了兩個坑:
Number(GetStatus(token))得到 NaN —— 回傳的是 il2cpp enum 的 ValueType 不是 JS number,於是st !== 1永遠成立,每個 clip 都被 誤判成失敗。要從它的記憶體讀 int32。- 固定等 2 秒就查狀態,有的 clip 那時還是
Pending而被當成失敗 (同一個wavehands00_typ000前一輪明明成功過)。改成輪詢到有結果為止 —— 查狀態不消耗 token,可以重複查。
修好之後 549/549 全部錄到。
存檔要原子
json.dump 直接寫目標檔,中途 Ctrl-C 會留下截斷的 JSON ——
實測毀掉一個 114 MB、499 個 clip 的成果檔,逐物件解析只搶救回 487 個。
改成先寫 .tmp 再 os.replace。
13. 真正的原因之一:我自己的取樣錯位
recordFrame 是從 frida 執行緒讀的,遊戲主執行緒同時在跑。
GetHumanPose 依當下骨骼算出肌肉值,但等我讀完 51 根骨的 localRotation,
主執行緒可能已經推進了一幀 —— 肌肉值與骨骼旋轉來自不同幀。
這個推測可以完全離線驗證,不必再碰手機:
誤差與該骨當幀角速度的相關係數: 中位數 0.466 (50 根骨裡 43 根 > 0.3)
幾乎靜止的幀 快速轉動的幀
中位數 0.344° 3.811° ← 差 11 倍
修法是原子取樣:讀骨(前) → 算肌肉 → 讀骨(後),前後相同才算乾淨。
「偵測後丟棄」不夠 —— 只有 0.9% 的幀是乾淨的
加上 torn 標記後實測 2480 幀裡只有 23 幀(0.9%)前後一致,
中位數 0.0349。主執行緒幾乎每次取樣都在推進,丟棄法會讓資料歸零。
必須讓它在取樣期間真的不動。
走錯的一步:Time.timeScale = 0
先想到的是把 timeScale 歸零 —— 動畫靠 deltaTime 推進,歸零就停,
而且「不必碰 PlayableGraph」。結果整個遊戲行程失去回應,
連 create_script 都 timeout,只能強制結束遊戲重開。
判斷失誤的地方在於:正確答案就在這份筆記的 §6 裡(graph Stop 之後
五次取樣漂移 0.00000000,且 Play() 可還原),我卻因為「怕 Stop
會重置 clip 時間」這個未經驗證的擔憂而繞路。
正解:每次取樣停該角色的 PlayableGraph
Stop() -> 讀骨 -> GetHumanPose -> 讀骨 -> Play()
torn == 0 的比例 torn 中位數
無凍結 0.9% 0.034908
graph Stop/Play 97.6% 0.00000000
而且幀內肌肉變動反而變大(1.33.0,先前 0.120.24)——
Stop() 沒有重置 clip 時間,動畫照樣前進,先前的擔憂不成立。
GetPlayableGraph() 回傳的 struct 在暫存記憶體上,要自己複製一份才能留著用。
但還有第二個問題,那不是錯位造成的
看每根骨在「幾乎靜止」時的誤差,會發現分成截然不同的兩群:
好: Neck 0.050 Head 0.087 LowerLeg 0.103 Eye 0.013
手指中節/末節 0.065~0.39 <- 全是 1 自由度
差: ThumbProximal 8.5 / 9.2 UpperArm 5.6 / 9.2
Hand 2.1 / 2.8 UpperLeg 2.5 其餘手指根節 1.8~4.4
<- 2~3 自由度, 含 spread/twist 軸
靜止時就差,錯位解釋不了。這一群共同的特徵是含 spread / twist 軸,
推測 Unity 對這些軸的旋轉套用順序與我的
q = Qpre · R_ZXY(仿射) · Qpost 單一形式不同。尚未驗證。
眼睛補上了
LeftEye / RightEye 0.0288°。先前標成「無資料」是因為走跳動畫
完全不動眼睛 —— 主動驅動動作補上了玩家操作永遠碰不到的 DoF。
14. 一連串被否定的假設,與一個方法論錯誤
修好取樣後,誤差分布變成乾淨的雙峰:
改善 84~359 倍 -> 0.0016~0.0038° LowerArm(k=7) / LowerLeg / 手指中末節
完全沒改善 -> 2.9~9.6° UpperArm / Hand / UpperLeg / 手指根節
第二群與取樣無關。以下每個假設都做了對照實驗:
| 假設 | 結果 |
|---|---|
| 尤拉順序不對 | 12 種都一樣(9.5824 → 9.5821) |
| 肌肉集合不足 | 擴充後略差;資料驅動選肌肉也沒找到遺漏的 |
| 肌肉值超出 ±1 被 clamp | LowerArm 同樣超界 8.8%,誤差卻 0.0038° |
| 資料本身有矛盾 | 同肌肉值下旋轉只差 0.0~0.36°,精確函數確實存在 |
| 仿射假設不成立 | 二次項改善 2~7 倍,但對照組 Neck 改善 10.4 倍更多 |
| 最佳化沒收斂 | LeftUpperArm 的 40 個隨機起點全部落在 4.77° |
方法論錯誤:先確認雜訊底線,再比較效應
前五個實驗其實沒有統計效力,因為當時的雜訊底線比要比的效應大:
取樣錯位造成的誤差 11 倍 (靜止幀 0.344° vs 快速轉動 3.811°)
最佳化局部極小的變異 3.3 倍 (RightHand 最佳 2.79 / 中位數 9.33 / 最差 100.76)
我在比較的效應 1.0~1.2 倍
RightHand 在兩支腳本裡分別得到 2.9766° 與 9.3332° —— 差別只有重啟次數
與種子。我卻拿這種數字去判定「假設 A 比 B 好」。
判準是:對照組。 尤拉順序實驗裡 RightLittleProximal 看似改善 1.2 倍,
但對照組 Neck 也「改善」1.1 倍 —— 同一量級,所以那 1.2 倍是雜訊。
沒有對照組時,「12 個選項挑最小」必然挑到雜訊。
一個沒有給出資訊的測試(不硬解讀)
想用最近鄰回歸量「肌肉值決定旋轉的程度」,結果對每根骨都很差,
包括本來準到 0.0035° 的 RightLowerArm(1-NN 得 7.14°)。
1167 幀在肌肉空間裡太稀疏,量到的是取樣密度而非資訊量。
這個測試失敗了,不採計,也不事後找理由把它說成支持某個結論。
目前的定論
模型形式 q = Qpre · R_ZXY(A·m + c) · Qpost 對那 16 根骨不足,
而更好的形式還沒找到。這是誠實的未解狀態,不是「差不多可以了」。
交付方式:fit_final.py 逐骨用選模集在仿射/二次之間挑,
三段切分(訓練 50 / 選模 25 / 回報 25),回報集從未參與任何選擇。
輸出逐骨標明模型與誤差,不用總體中位數蓋過差的部分。
結案:是模型地板,不是最佳化
跑 250 個隨機起點看「最佳值 vs 已用起點數」:
RightHand 1:9.3332 5:2.7903 10:2.7903 50:2.7903 250:2.7902
5 個起點就到底,之後 245 個毫無進展。所以 2.79° 是模型形式的真實地板。
但最佳化變異本身也是真的,且不可忽視:RightLowerArm 的最佳與中位數
都是 0.0035°,最差卻是 83.59°。單一起點的結果完全不可信 ——
先前那些 1.0~1.2 倍的假設比較就是這樣被雜訊淹沒的。
15. 換一條路:讓遊戲自己算(確定性烘焙)
回頭看目標是「把 mot_define 轉成骨骼動畫」。我一路在反解 Unity 的換算公式, 但遊戲自己就是那個轉換器 —— 不必重建它,讓它算給我就好。 這個方向我應該早點想到。
PlayableGraph.SetTimeUpdateMode(Manual) graph 不再自己前進
PlayableGraph.Evaluate(float) 我叫它走多少才走多少
PlayableExtensions.SetTime(U, double) 可精確定位
於是:想要幾 fps 就幾 fps、沒有取樣錯位(graph 根本不動)、 完全不需要 muscle → 旋轉的模型,那 16 根骨的未解問題直接繞開。
驗證(不能因為「聽起來更直接」就當成正確)
檢查一 Manual 生效 0/59 個重複格, 每格平滑前進
檢查三 22 根高精度骨 模型 vs 烘焙 0.005~0.021°
第三項最有力:模型是用即時錄製擬合的,卻能同樣精確地預測烘焙的資料。 兩條互不相干的擷取路徑互相印證,誤差就等於模型自身的精度。
又一次把遊戲弄掛:60 格可以,300 格不行
小規模驗證通過後我直接跳到 300 格,沒有先量測擴展行為。結果長時間佔住
il2cpp runtime 餓死主執行緒,遊戲失去回應(create_script 也 timeout),
只能強制結束。這與 §13 的 timeScale 是同一類錯誤,同一輪犯了兩次。
所以限制寫進 agent 本身(nFrames > 60 直接拒絕),而不只是寫在
呼叫端——下次不論誰呼叫都不會再踩到。驅動腳本改成每段 40 格。
位移不是小事:Hips 承載了整個身體的移動
原本以為只有旋轉就夠、位移頂多是輕微起伏。實測 26 個 clip 後推翻:
candy-lick-l00 Hips z 位移 0.757
kokeru00 (跌倒) Hips y 位移 0.493
glad00_typ001 Hips y 位移 0.439
只寫旋轉的話動作會嚴重失真,不只是「原地踏步」。所以每格加錄全部骨的
localPosition(實測只有 Hips 會動,其餘 50 根是骨架固定偏移,
add_animation 因此只為 Hips 寫 translation channel)。
root(角色 GameObject 的位移)在這 26 個裡全是 0 —— Manual 模式下
Evaluate 只驅動姿勢,遊戲移動角色的邏輯沒有跑。但這批沒有走路類 clip,
所以還不能斷定 root motion 不存在。
驗證:語意也要對,不只數字對
kokeru00(こける=跌倒)渲染出來是臉朝下、雙臂張開趴倒,垂直位移
101 像素。骨骼映射、座標轉換、旋轉、位移全部正確,而且結果在語意上
說得通 —— 這比「數字對得上」有力得多。
物件失效:同一個問題修了四次
每次都只修追蹤訊息直接指到的那一處:
- 加存活檢查 -> 但用呼叫方法檢查, 對已銷毀物件會
abort was called, try/catch 攔不住 - 改讀
m_CachedPtr-> 但只保護內層迴圈,graph.Evaluate與getPose.invoke在 perform 回呼的直接層級沒包 - 包住那兩個 -> 但
MotionProvider的檢查對象是錯的 - 改驗它綁的 GameObject -> 正常
第 3 個最隱蔽:MotionProvider 是純 C# 物件, 它自己永遠不會死,
死的是 ctor 時綁進去的 GameObject。所以角色重建後它「看起來還活著」,
但每次 LoadAsync 都回 Faulted, 548 個 clip 全部失敗。
正確的做法是一次列出所有跨呼叫存活的執行期參照
(animator / bones / graph / poseHandler / provider / provider 的 GameObject
/ controller), 逐一決定各自的失效判準 —— 而不是等追蹤訊息一個個指給我看。
現在的規則: Unity 物件用 m_CachedPtr 判定, 純 C# 物件則驗它持有的
Unity 物件。
16. 完成:549/549,以及「修環境」的四個真實原因
烘焙途中遊戲一直崩,我一度想收在 544/549(99.1%)。使用者要求完整, 於是回頭診斷環境 —— 結果全部四個原因都是我自己造成的。
一、資產洩漏(遊戲崩潰的主因)
先量測再歸因:frida/USB 連線實測 5/5 穩定,崩的是遊戲自己。
releaseLoaded 只放掉我的 GC 釘,沒有叫
VisionMultiAssetLoader.ReleaseAll() —— 549 個 MotionDefine 連同
AnimationClip 全部留在手機記憶體。而且每次 stale 重建 provider 又漏一個
loader,它持有的資產永遠不回收。「越跑越糟」正是預期行為。
使用者反問「遊戲會自己崩別人怎麼玩的」是對的:一般玩家不會在十分鐘內 連續載入 549 個資產且從不釋放,這是只有我的用法會踩到的路徑。
二、修一造成二:ReleaseAll 讓 loader 失效
加了 ReleaseAll 之後變成「每次只穩定成功一個 clip」,之後每個
LoadAsync 都 Faulted。釋放資產會連帶讓 loader 失效,而我的存活檢查
只驗它綁的 GameObject,驗不到這一層。
解法:釋放完就丟掉 provider,下次載入重建一個乾淨的(成本只是一次 ctor)。
這是同一輪第二次「修 A 造成 B」。共同模式是我改了執行期物件的 生命週期,卻沒有重新檢查誰依賴它 ——
ReleaseAll影響 loader, 而 provider 持有 loader。
三、成果檔 1.5 GB,每 10 個 clip 重寫一次
重寫要好幾秒,那段視窗被中斷就毀檔(實測毀過一次,逐物件解析只
搶救回 487/499)。改成 JSONL 逐行追加:O(1),單行寫入天然原子。
scripts/merge_baked.py 在需要時合併。
四、執行時期依賴放在會被清空的目錄
agent.js 編譯輸出放在 session scratchpad(/private/tmp/...),
macOS 重開機清空該目錄,六支腳本的 AGENT 路徑同時失效。
搬進 agent/,附 package.json 與 tsconfig.json
(後者必要,否則會撿到家目錄的 @types/node 而編譯衝突)。
另一個診斷盲點
「失敗 4 個」卻查不到原因 —— 載入失敗的分支只 append 不 print,
而改成 JSONL 後 out["failed"] 根本不會被寫出。
診斷不了的失敗等於沒有紀錄,改成統一走 fail() 印出並落檔。
成果
549/549 clip 164,700 格 @ 30fps 零截短
每個 clip 都有完整 51 骨的旋轉與位移
堆使用量穩定在 97~101MB (先前每 ~20 個 clip 就崩)
匯出 GLB:549 個動畫、140 MB、27,999 條旋轉 channel(549×51) + 549 條位移 channel(每個 clip 的 Hips)。
Blender 驗證:kokeru00(跌倒)渲染出臉朝下趴倒、walk00 呈跨步姿態
且角色留在原地 —— 印證 clip 本身是原地的,前進位移由遊戲另外套用。
挑動畫要用名稱不能用索引:Blender 的
bpy.data.actions順序與 GLB 的animations陣列不同,實測要 walk 卻拿到發牌動作。
17. 裙襬/頭髮:物理參數有出貨,只是我搜錯層級
烘焙只涵蓋 51 根 humanoid 骨。實測 GLB 有 126 根骨,少了 75 根:
skirt 31 cheek 10 ribbon 8 foreArm 4 pendant 4 bust 3 其餘
原本打算「讓遊戲算、我逐格錄」,但先查有沒有現成的。
先看已解出的 clip 裡有什麼
_extracted_motions_3d 的 637 個 json 早就有了,但我沒仔細看過內容:
stop_time: 1.0666667 <- walk00 只有 1.07 秒
曲線指向: loc_*_eff (IK 目標) / Geo_Eye,Brow,Iris / Mouth / Root_Body
jnt_?_skirt0?_02_sim <- 裙襬只有 8 根, 都是 _sim 結尾
頭髮曲線: 0 個
兩件事因此清楚:clip 只帶裙襬的模擬錨點,其餘是執行期算的;
而且 clip 長度本來就在檔案裡 —— 我固定烘 300 格(10 秒)卻沒查過
stop_time,walk00 實際只有 1.07 秒。
使用者指出「攔截只在播放時生效,角色本來在跑就會繼續跑」—— 所以「尾端還在動」只證明錄到了播放後的內容,不能推論 clip 被截斷。 我原本的解讀跳過了這個區別。
物理參數在模型 bundle 的 MonoBehaviour 裡
catalog 的 address 搜 swing|spring|phys|cloth 只有 14 筆且全不相關 ——
又是搜錯層級(跟 §1 搜 Avatar 犯的是同一個錯:address 不含資產型別,
更不含 MonoBehaviour 的類別名)。
改成掃 bundle 內部:
mdl_chr_drs_00001-cmmn-0000-00_body 的 MonoBehaviour 57 個
ActorSwingDynamicBone x25
ActorSwingStaticBone x9
ActorAnimationQuartzDriverSkirtBone x7
ActorSwingChain x1 ActorSwingCheekBone x1
參數是完整的彈簧骨設定:
damping 0.6 stiffness 0.2 spring 0.7 mass 1.0
pendulum 1.0 pendulumRange 0.3 wind 1.0 useWindGlobalForce 1
limitInfo axisY [-30,30] axisZ [-30,0]
dynamicCollider (型別 / collisionMask / 兩個向量)
rootHorizontalWeight 0.2 rootVerticalWeight 0.4
ActorSwingChain: rootBones + layers(active/around/radius/smoothing/bones)
逐服裝而非通用 —— 但既然每套服裝自帶參數,逐服裝就是正確的粒度。
因此改變作法
| 錄製裙襬 | 抽參數 | |
|---|---|---|
| 需要遊戲 | 是 | 否 |
| 產物 | 固定一份模擬結果 | 可在任何引擎重跑、可改風力/碰撞 |
| 風險 | Manual Evaluate 下 Swing 未必更新(未驗證) | 無 |
18. 找對 hook 點:純靜態推理
目標是攔下建 Avatar 用的 HumanDescription —— 那組參數能取代 §8~§14
整輪的最小平方擬合(HumanBone.limit 的 min/max 就是 muscle→角度的端點,
SkeletonBone 給軸框架)。
三次失敗,各自不同的原因
| 嘗試 | 結果 | 真正的問題 |
|---|---|---|
硬寫 0x0C0E46F0 |
掛得上但永不觸發 | 那個位址零個 BL/B 指向它 |
| 執行期解析類別 | 60 秒內找不到 AvatarBuilder |
啟動早期組件尚未載入 |
| attach 已跑的遊戲 | 永遠等不到 | 遊戲幾乎不卸載資源,Avatar 建好就不會重建 |
第三點是使用者指出的:必須 spawn,不能 attach。
我自己製造的兩個誤判
一、用截斷的清單下結論。 搜尋回報 15 筆但我只印 12 筆,
BuildHumanAvatar 剛好被截掉,我卻據此宣稱「它只以 methodInfoPointer 存在」。
跟先前「hit 數不是證據,要把 hit 印出來」是同一類錯誤。
二、vtable 裡的同名方法不是同一個。
VLActorController__VTable 的 GetHumanDescription 沒有參數,
與 GetHumanDescription(HumanBone[], SkeletonBone[]) 是不同的東西。
正確的呼叫鏈
0x09DEB074 BuildHumanAvatar(GameObject, HumanDescription) <- 唯一咽喉點
0x09DEB1F0 BuildHumanAvatarInternal(...) 被 0 處呼叫
0x09DEB2B0 BuildHumanAvatarInternal_Injected(IntPtr, HumanDescription ByRef)
BuildHumanAvatar 被 4 處呼叫:
0x0AF073B4 SetupAnimation(IVisionActorAnimationData, VisionActorBuildParameter)
0x0C08BCDC SetupAnimation(Boolean)
0x0769CB78 BuildAvatar()
0x0769FF9C BuildAvatar()
四條路徑都經過 BuildHumanAvatar,所以只要 hook 這一個位址就涵蓋全部。
vtable 槽位順序也佐證了流程:
BuildModel → InitializeData → SetupAnimation → GetHumanDescription → …
結構布局(逐欄位算出,不是猜的)
HumanBone 大小 64 BoneName 0, HumanName 8,
limit.min 16, max 28, center 40,
axisLength 52, useDefault 56
SkeletonBone 大小 56 name 0, parent 8, position 16,
rotation 28, scale 44
HumanDescription 大小 64 human 0, skeleton 8,
ArmTwist 16, ForeArmTwist 20, UpperLegTwist 24,
LegTwist 28, ArmStretch 32, LegStretch 36,
FeetSpacing 40, GlobalScale 44,
RootMotionBoneName 48, 三個 bool 56/57/58
先前 agent 裡把 HumanBone 步長寫成 48,正確是 64 —— 這個錯不會報錯,只會讀出垃圾資料。逐欄位算過才發現。
HumanDescription 64 bytes > 16,依 AArch64 ABI 以指標傳遞,
所以 args[1] 是指向該結構的指標(BuildHumanAvatar 是靜態方法,
args[0] = GameObject)。
需要人工點擊(我一度誤判)
我看到 scene_probe 的紀錄從 Title 進到 Park:
[ 40s] scenes=['Title']
[ 70s] scenes=['OutGame', 'Park', ...] VisionActorController 12 個
就寫成「遊戲會自己進去,不必手動操作」。使用者指出那是他當時手動點的。
這個誤判很典型:我看到「A 之後出現 B」就當成 A 導致 B, 完全沒考慮觀測期間有人在操作。日誌本身無法區分這兩者 —— 要判斷因果,得問「還有誰能造成這個變化」。
所以自動化流程要成立,必須另外找到程式化跳過標題的方法, 或接受每次 spawn 都要人工點一下。
19. 自動跳過標題:四個原因依序排除,仍未成功(擱置)
目標是全自動 spawn → 跳過標題 → 進園區 → 攔 HumanDescription,
落地的東西不該靠人工點擊。目前未成功,擱置。
這遊戲要點兩下:PreLoginTitleView.Button(登入)→
MainTitleView.TapToStartButton。推進流程的是 WaitForTapAsync
await 按鈕的點擊 observable,所以要對
VisionButton._onClicked(R3 Subject<Unit>)呼叫 OnNext ——
呼叫那兩個處理器沒用(b__2_0 只播音效、b__4_0 只切 UserId 顯示)。
依序排除的四個原因
| 症狀 | 真正原因 |
|---|---|
| 呼叫「成功」但沒反應 | 呼叫的是 TitleScenePresenter.b__11_1,不推進流程;而且我拿「呼叫沒噴錯」當成功(§11 同款錯誤),它在 Splash 畫面就「成功」了 |
| 按了但遊戲卡死 | 每 4 秒重按,登入處理中重入。Subject.OnNext 沒有訂閱者時丟失是無害的,處理中重入才會壞 |
| 一次都沒按到登入 | 存在 != 顯示中:兩個 view 從場景載入起就同時存在,用「有沒有實例」判斷永遠選同一顆 → 改用 GameObject.activeInHierarchy |
| 按到了仍卡死 | 跨執行緒:從 frida 執行緒呼叫 Subject.OnNext,R3 同步呼叫訂閱者,於是登入/GoToOutGameAsync/場景載入全跑在錯的執行緒 → 改用 tapOnMainThread(掛一幀 Time.get_deltaTime,送完卸載) |
四個都修掉之後仍然進不去。沒有繼續盲試,先擱置。
還沒試過的方向
- 真實觸控會經過
VisionButton的按下/放開狀態機 (PressedTransitionAsync、onPressedCallback),我直接送 Subject 跳過了那一段,可能有前置狀態沒滿足 - 或走更上層:
EventSystem送合成的PointerEventData
目前可行的替代
人工點兩下進到園區,之後所有工作(烘焙、擷取)都能自動跑 —— 只有「進入園區」這一步需要人。
20. 逆向 CalcDynamicSwing:結構已解,算式待解
元件與命名(離線抽出,extract_swing.py)
ActorSwingDynamicBone 掛 _sim 骨 被模擬的骨 (每套服裝 25~36 根)
ActorSwingStaticBone 掛身體骨 碰撞體 (裙襬會撞到身體)
ActorSwingChain 掛 hips 鏈結構 rootBones + layers
QuartzDriverSkirtBone 掛 _ast 骨 程序式輔助骨
_sim = 模擬 _ast = assist _end = 鏈尾 _hlp = 扭轉修正
結構布局:有硬證據,不是猜的
ActorSwingJobDynamicBone 逐欄位算出 948 bytes。
組合語言裡取子骨是 smaddl x8, w8, w9, x21,其中 w9 = 0x3b4 = 948 ——
陣列步長與我算的結構大小完全相同。這不是巧合,是布局正確的證據。
第一版算出 1008 bytes 是錯的:
#if defined(_CPLUSPLUS_)的兩個分支 (enum 與 int32_t)我都算進去了,後面所有偏移跟著偏掉。 解析 C header 時必須處理預處理器分支。
每個記憶體讀取都對應到欄位
[x20,#0x84] childIndex -> 取子骨 (乘 0x3b4)
[x20,#0xc4] selfTx.translation [x20,#0xd0] selfTx.rotation
[x8, #0x88] 子骨 boneAxis [x8, #0xc4] 子骨 selfTx.translation
[x20,#0xec] parentTx.rotation -> 接著 1/|q|² 與 fneg = **四元數求逆**
[x20,#0x324] limit.useLimit positive 0x328, negative 0x334
[x20,#0x348] seatDynamicCorrection
[x20,#0x364] referenceLimit.weight boneIndex 0x370
fcsel / fcmgt / bit 序列正是把三軸夾在 negative~positive 之間,
與資產裡的 limitInfo.axisY [-30,30] 對得起來。
呼叫的函式
FromToRotation(float3, float3) x1 從靜止方向轉到模擬後方向
ToUnityQuaternion(float3) x3 尤拉角 -> 四元數 (限制用)
ApplyAngleLimit(SimpleSwingDynamicBone, ActorSwingAffineTransform, …) x4
CalcDynamicSwing 不是積分器
掃它讀了哪些欄位 —— 完全沒有讀 damping/stiffness/spring/pendulum。
它只把位置轉成旋轉。積分在別的地方。
找積分器時的兩次假陽性
一、盲掃偏移數字。 搜「誰讀 0x134」不管基底暫存器指向什麼結構,
結果命中 DrawUI、ObscuredPrefs、Detect 這些明顯無關的。
偏移是結構相對的,單看數字必然有假陽性。
二、沒排除堆疊。 修正後仍命中 ProcessDynamicBoneLayer 的
stiffness/mass —— 印出實際指令才看到是
stp d9, d8, [sp, #0x130],那是暫存器保存到堆疊,不是讀欄位。
正確的判準是看讀了哪幾個欄位的組合:只有一個函式一次讀齊七個。
積分器:ProcessDynamicBones @ 0x0279255C
Void ProcessDynamicBones(AnimationStream, ActorAnimationManagePropertyData ByRef,
ActorSwingAffineTransform, ActorSwingAffineTransform)
1658 條指令; 參數載入集中在 0x02792f18~0x02792f60
已解出的片段:
0x02792f20 fsub s0, 1.0, damping -> (1 - damping)
0x02792f28 bl Square(Single) -> **平方** => (1 - damping)²
0x02792f8c 0x3c888f86 = 0.0166666 -> 1/60, 固定時間步長
0x02792fd0 0x3c23d70a = 0.01 -> mass × 0.01
0x02793080 fsub s3, s13, s0 -> 位置差 (現在 - 上一格)
0x02793094 fmul s8, s6, s3 -> × (1-damping)² ← Verlet 的慣性項
慣性係數是 (1 - damping)² 而不是 (1 - damping) ——
這種細節正是「自己推公式」拿不到的,只能從二進位讀。
更新式的骨架(已解)
0x027930f8 stiffness / pendulum / pendulumRange 三個一起傳進去
0x02793104 bl CalcStiffnessPendulum(DynamicType, float3, Single,
ActorSwingAffineTransform, …)
0x02793108 fadd 慣性項 + 它的輸出
0x02793118 fsub 從 y 分量減掉 mass × 0.01 <- **重力**
0x0279311c useWindGlobalForce ?
0x02793194 bl CalcWindPower(float3, ManagePropertyData ByRef, Single, Single)
0x027931a0 fmul wind × 全域風強度, 再旋轉後加上去
整理成式子:
inertia = (pos - prevPos) × (1 - damping)²
newPos = pos + inertia + CalcStiffnessPendulum(…)
newPos.y -= mass × 0.01 重力
if useWindGlobalForce:
newPos += rotate(CalcWindPower(…) × wind × 全域風強)
之後才是碰撞、角度限制、CalcDynamicSwing 轉成旋轉
CalcStiffnessPendulum —— 先前記錯了,這是更正版
準備實作模擬器時回頭核對,發現原本這一節把兩個分支寫反了,
而且距離向量也記錯。原記載會讓 6900 根骨(dynamicType == 0)
全部套到錯的分支。以下是重新逐條讀出來、並用符號執行驗證過的版本。
先把簽章抓全,參數名本身就解決一半的疑問:
float3 CalcStiffnessPendulum(DynamicType dynamicType,
float3 boneAxis, float boneLength,
ActorSwingAffineTransform selfTx,
ActorSwingAffineTransform childTx,
ActorSwingAffineTransform childDefaultTx,
float stiffness, float pendulum, float pendulumRange)
分支條件是 cbz w22, #0x27940f8 —— dynamicType == 0 跳到 0x27940f8,
落下來的那條(含 dist × 10)是 dynamicType != 0。原本寫反。
dynamicType == 0(角度型, 0x027940f8)—— 佔 6900/6940 根骨
delta = rotate(selfTx.rotation, boneAxis) <- 骨的靜止方向, 不含長度
cos = |dot(childTx.t, childDefaultTx.t)| / (|childTx.t| · |childDefaultTx.t|)
p = max(0, cos - (1 - pendulumRange)) / pendulumRange × pendulum
dynamicType != 0(距離型, 落下來那條)—— 40 根滑動骨
target = selfTx × (boneAxis × boneLength) <- 子骨的靜止世界位置
delta = target - childTx.translation
dist = |childDefaultTx.t - childTx.t|
p = (1 - min(dist × 10, pendulumRange) / pendulumRange) × pendulum
兩條匯合 (0x027942d8)
if pendulum <= 1e-5 或 pendulumRange <= 1e-5: p = 0
factor = stiffness - p
return delta × factor × 0.01
delta 對 type 0 是方向(單位向量旋轉後),對 type 1 是位移差(含長度)——
兩者量綱不同,套錯會差好幾個數量級。
rotate(selfTx.rotation, boneAxis) 是用符號執行確認的:
把 0x027940f8~0x02794178 逐條解釋成符號式,
與標準四元數旋轉公式相減得到 [0, 0, 0]。
pendulum 是從 stiffness 扣掉的衰減量,不是獨立的力 ——
擺得越開、剛性越低。這個結構無法從參數值本身推出來,只能從二進位讀。
呼叫端傳什麼(已確認)
ProcessDynamicBones 0x02793080~0x02793104 的參數組裝:
x2 = sp+0x280 selfTx <- [x29-0xd0], 由 op_Multiply 組出的仿射變換,
再減掉根部修正 (0x02792e3c~0x02792e4c 的 fsub)
x3 = sp+0x1e0 childTx <- [x29-0xf0], 來源是 [x25+0xc4] = 子骨的 selfTx.translation
x4 = sp+0x260 childDefaultTx <- (s13, s11, s12) = 本次迭代的目前位置
s0,s1,s2 = [x25+0x88..0x90] <- **子骨的 boneAxis**
s3 = [x25+0x94] <- **子骨的 boneLength**
其中 x25 = dynamicHead[bone.childIndex] (0x02792e64~0x02792e70, 步長 0x3b4)
boneAxis / boneLength 取自子骨而不是自己 —— 與簽章順序完全吻合,
這一條可以直接寫進模擬器。
座標系:animator root space(角色本地),不是世界座標。
理由是這整個函式跑在 IAnimationJob 裡(第一個參數就是 AnimationStream),
selfTx 是沿著骨鏈用 op_Multiply 累乘 localTx 得到的,
累到底是角色根節點而不是場景原點。
所以 type 0 拿兩個位置向量做內積是有意義的 ——
它們都是從角色原點指出去的方向,夾角就是「模擬後的方向偏離靜止方向多少」。
(原本這裡標「待確認,不要動手寫模擬器」。現在確認了,可以動。)
spring 是子骨速度的傳遞係數(已解)
0x02792f38 ldr spring -> [sp,#0x1a0]
0x02793044 fcsel prewarmCount < 1 ? spring : 0 預熱期間關閉
0x02793258 ldr childSpeed.x [x28,#0xfc]
0x02793260 fmul childSpeed × spring
0x02793268 fmul (y,z 分量)
0x0279326c fadd 累加進位移
也就是子骨的速度乘上 spring 回傳給父骨 —— 鏈的耦合強度。
時間步長會被鉗制(已解)
0x0279307c cset w21, hi dt > 1/60 ?
0x02793240 fmul s0, dt, 40.0
0x0279324c 0.6668 (= 40/60)
0x02793254 fcsel dt > 1/60 ? 0.6668 : dt × 40
0x027933e8 fmul 整包力 × 該係數
即 step = min(dt, 1/60) × 40。慢幀被鉗制,避免積分爆開 ——
離線重現時必須照做,否則長時距會發散。
根部修正(已解,完整)
GetRootCorrectionCancelPosition @ 0x027a0978 只有 9 條指令:
h = 1 - a × rootHorizontalWeight
v = 1 - b × rootVerticalWeight
return (p.x × h, p.y × v, p.z × h)
水平(x,z)與垂直(y)各自被削減 —— 抵消一部分根骨的移動, 讓裙襬不完全跟著身體走。函式名副其實。
ProcessDynamicBones 的完整執行順序(已解)
Time.deltaTime
GetUnsafeReadOnlyPtr<ActorSwingJobDynamicBone / ChainBone>
Inverse() parentTx 求逆
ProcessDynamicBoneLayer(...) 第一遍
GetRootCorrectionCancelPosition(...) 根部修正
GetGlobalTR / ActorSwingAffineTransform / Set
GetFloat(AnimationStream) 讀 bakeAnimationWeight
GetLocalRotation(AnimationStream)
op_Multiply ×3 變換組合
CalcStiffnessPendulum(...) 彈簧 + 擺錘
CalcWindPower(...) 風力
ProcessChainBoneSeatedDynamicCorrection 坐姿修正
FromToRotation(float3, float3) 方向 -> 旋轉
GetFloat(AnimationStream) 再讀 bakeAnimationWeight
Calc(float3×10, quaternion×5, …) 26 參數的約束計算
ApplyBlendBakeTarget(...) 與烘焙動畫混合
UpdateCollider(ByRef, float3, float3, quaternion)
ApplyBlendBakeTarget(...) 第二次
IsAlreadyStable()
ProcessDynamicBoneLayer(...) 第二遍
SetPosition(AnimationStream, Vector3) 寫回位置
Inverse() / GetRootCorrectionCancelPosition / op_Multiply
SetRotation(AnimationStream, Quaternion) 寫回旋轉
碰撞(CheckDynamicCollision / CheckChainCollision)不在這一層 ——
它們由 ProcessDynamicBoneLayer 呼叫,所以碰撞是每層各做一次,
而且整個流程跑兩遍 ProcessDynamicBoneLayer。
碰撞系統:完全具名,不必猜
碰撞型別 Sphere=0 Capsule=1 Line=2 Plane=3 None=4
0x027a099c CheckFastCollision 粗篩 (type==Plane 直接 true)
0x027a09ec ProcessColliderToCollider 分派
ProcessSphereToSphere / SphereToCapsule / CapsuleToSphere
CapsuleToCapsule / SphereToCirclePlane / CapsuleToCirclePlane
實際幾何:
0x027a0d8c CollisionSphereSphere 0x027a0ef0 CollisionSphereCapsule
0x027a1120 CollisionCapsuleCapsule 0x027a14d8 CollisionSpherePlane
0x027a15a0 CollisionCapsulePlane
CollisionSphereSphere 完整解出:
d = posB - posA
rSum = radiusA + radiusB (collider.float_A = 半徑)
if |d|² > rSum²: return false 早退, 不開根號
len = sqrt(|d|²)
normal = len > 1e-5 ? d / len : 預設向量 退化保護
HitInfo = { normal @0x00, …, rSum @0x18 }
return |d|² <= rSum²
穿透深度沒有在這裡算 —— 只回傳 normal、position 與 rSum。
其中 HitInfo.position 存的是靜態碰撞體的球心([x20],不是動態骨的位置),
呼叫端直接用 position + normal × rSum 把骨頭放到正好相切的位置(見下節)。
我原本寫「由呼叫端用
rSum - len推開」—— 代數上與實際做法完全等價(center + n·len + n·(rSum-len) = center + n·rSum), 但那是推測的形式,不是程式碼裡的形式。實際讀到之後改成實際形式, 因為平面類的HitInfo語意不同(見下),只有實際形式能一體適用。
CollisionSphereCapsule 也完整解出:
seg = B - A (膠囊 position -> subPosition)
toP = P - A (球心相對膠囊起點)
t = dot(seg, toP)
t <= 0 -> 最近點 = A, r = 半徑A
t >= |seg|² -> 最近點 = B, r = 半徑B
否則 t /= |seg|²
最近點 = A + seg × t
r = t × 半徑B + (1 - t) × 半徑A <- 半徑沿線段插值
rSum = r + 球半徑
之後與球對球相同: |d|² 早退 / 1e-5 退化保護 / HitInfo
膠囊有兩個半徑(float_A / float_B),沿線段線性插值 ——
是錐形膠囊而不是等粗的。用標準等粗膠囊近似,
大腿與手臂的碰撞形狀就會錯。
tst w20, #1 那組 fcsel 是方向交換旗標 —— 這解釋了
ProcessSphereToCapsule 與 ProcessCapsuleToSphere 為何只差 12 bytes:
兩者只是設旗標後跳進同一個實作。
平面碰撞(完整)
CollisionSpherePlane:
法線 = plane.subPosition, 平面偏移 = plane.float_A
dist = dot(球心, 法線) - 偏移
if dist > 球半徑: return false
HitInfo[0x18] = 球半徑 - dist <- **穿透深度**
CollisionCapsulePlane:
兩端點各算 dist, 取較靠近平面的那端
用對應端的半徑 (float_A 或 float_B) <- 又一次呼應錐形膠囊
HitInfo[0x18] = r - dist
HitInfo[0x18]在不同形狀存的是不同的量: 球對球 / 膠囊類存rSum(半徑和),平面類存r - dist(穿透深度)。 呼叫端必須分開處理。這種不一致沒讀到的話,推開的距離會整個錯。
膠囊對膠囊(完整)—— 標準線段對線段最近點
d1 = A2-A1, d2 = B2-B1, r = A1-B1
a = |d1|², e = |d2|²
a,e 都 <= 1e-5 -> 兩者都退化, 用端點與各自 float_A
a <= 1e-5 -> s = 0, t = clamp(f/e)
e <= 1e-5 -> t = 0, s = clamp(-c/a)
否則 b = dot(d1,d2), denom = a·e - b²
denom > 1e-5 -> s = clamp((b·f - c·e)/denom, 0, 1) 否則 s = 0
t = clamp((b·s + f)/e)
t 越界時回頭修正 s
P1 = A1 + d1·s rA = (1-s)·半徑A1 + s·半徑A2
P2 = B1 + d2·t rB = (1-t)·半徑B1 + t·半徑B2
rSum = rA + rB; 之後與球對球相同 (早退 / 1e-5 退化保護 / HitInfo)
兩個膠囊都用插值半徑 —— 錐形膠囊貫穿整個碰撞系統。
碰撞如何回饋到位置:CheckDynamicCollision @ 0x027A0150(完整)
這是把碰撞接回模擬的最後一環。簽章直接寫在 metadata 裡,不必猜:
Void CheckDynamicCollision(NativeList<ActorSwingJobStaticBone> staticBones,
ActorSwingJobDynamicBone* pChildDynamic,
ActorSwingAffineTransform& selfTx,
ActorSwingAffineTransform& childTx)
先確認結構(同樣用逐欄位算,不是猜):
ActorSwingAffineTransform = 28 B { float3 translation @0x00; quaternion rotation @0x0c }
-> 對得上 §20 已記的 [x20,#0xc4] selfTx.translation / [x20,#0xd0] selfTx.rotation
ActorSwingJobCollider = 84 B { active 0x00; type 0x01; collisionMask 0x04;
fastHitPosition 0x08; fastHitRadius 0x14;
float3_A 0x18; float3_B 0x24;
float_A 0x30; float_B 0x34;
position 0x38; subPosition 0x44; scale 0x50 }
ActorSwingJobStaticBone = 84 + 24 + 24 = 132 = 0x84
-> 組合語言裡走訪陣列正是 `add x23, x23, #0x84`。步長吻合 = 布局正確的硬證據。
ActorSwingJobDynamicBone 0x00c collider(84) 0x060 CollisionInfo collision(28)
0x07c depth 0x080 parentIndex 0x084 childIndex
0x088 boneAxis 0x094 boneLength 0x098 localTx
CollisionInfo = 28 B { float3 normal @0x00; float3 position @0x0c;
uint8 hitCheckCount @0x18 }
-> 回寫時 [x19+0x60] 16B + [x19+0x70] 8B + [x19+0x78] 計數。0x60+0x18 = 0x78 ✓
演算法:
localCollider = pChildDynamic->collider # 整個 84 B 複製到堆疊
hits = 0
for each staticBone in staticBones: # 步長 0x84
if not CheckCollision(&localCollider, &staticBone.collider, &hit): continue
hits += 1
# 1) 推開:直接算出接觸點,不是加一個位移量
childTx.translation = hit.position + hit.normal × hit.value
# 2) 骨長硬約束:把推開後的位置重新投影回以 selfTx 為圓心、
# 半徑 = pChildDynamic->boneLength 的球面上
delta = childTx.translation - selfTx.translation
if |delta|² <= 1e-10: # 退化:子骨與自己重合
childTx.translation = selfTx.translation
else:
childTx.translation = selfTx.translation
+ delta / |delta| × pChildDynamic->boneLength
# 3) 用新位置重算自己的碰撞體,下一顆碰撞體會看到更新後的形狀
UpdateCollider(&localCollider, childTx.translation,
selfTx.translation, selfTx.rotation)
accum.normal = hit.normal
accum.position = childTx.translation
pChildDynamic->collision = accum # normal + position
pChildDynamic->collision.hitCheckCount = hits
pChildDynamic->collider = localCollider # 寫回被 UpdateCollider 改過的碰撞體
三件事情是這裡才看清楚的:
推開是「算出絕對位置」而不是「加位移」。
hit.position + hit.normal × hit.value—— 球類position是靜態碰撞體球心、value是rSum,結果正好落在相切面上; 平面類position是骨自己的位置、value是穿透深度,結果正好落在平面上。 §「HitInfo[0x18]在不同形狀存的是不同的量」的兩種不一致,在這個統一式子下互相抵銷。 這就是為什麼原作者可以用同一行處理五種形狀。碰撞後一定會做骨長重投影。 推開的位置只決定方向, 距離永遠被拉回
boneLength。所以碰撞不會把裙襬撐長或壓扁 —— 它只轉方向。少了這步,裙襬碰到腿會被拉長。碰撞是循序解算,不是一次收集全部。 每次命中都立刻
UpdateCollider更新自己的碰撞體, 下一顆靜態碰撞體看到的是已經被推開的形狀。 順序會影響結果 —— 重現時必須照staticBones的原始順序。
collision 欄位只留最後一次命中的 normal / position,
hitCheckCount 才是命中總數。不是累加平均。
分派與粗篩:CheckCollision @ 0x02797E00(完整)
清空 HitInfo (0x00,0x08,0x10,0x18)
if dyn.type == None(4) or static.type == None(4): return false
if (dyn.collisionMask & static.collisionMask) == 0: return false # 位元遮罩
if static.type == Plane(3):
dyn Sphere(0) -> 0x027980a4 dyn Line(2) -> 0x02798754
其他 -> false
else:
# 粗篩:包圍球,用 fastHitPosition / fastHitRadius
if |fastPosA - fastPosB|² > (fastRA + fastRB)² : return false
dyn Sphere(0) -> 0x027980a4
dyn Line(2) 且 static ∈ {Capsule(1), Line(2)} -> 0x0279839c
dyn Line(2) 且 static == Sphere(0) -> 0x02797ef8
其他 -> false
兩個要點:平面不做粗篩(無限延伸,包圍球沒意義),
以及動態骨只會是 Sphere 或 Line,Capsule / Plane 只出現在靜態碰撞體那側。
所以 §前面解的 CollisionCapsuleCapsule 實際跑的是「動態線段 vs 靜態錐形膠囊」。
collisionMask 是 Skirt0/1/2 = 1/2/4、Hair0/1/2 = 8/16/32 的位元集合 ——
裙襬與頭髮各自分三層,同層才互相碰撞。
UpdateCollider @ 0x0279FC20(完整)—— 碰撞體怎麼跟著骨頭走
UpdateCollider(ActorSwingJobCollider& c, float3 bonePos, float3 parentPos,
quaternion parentRot)
第三個參數 parentPos 只有 Line 型別會用到;Sphere / Capsule 完全不讀它。
(一開始我以為四個參數都會用,實際讀了才發現 s5 在型別 0/1 的分支一進去就被覆蓋。)
Sphere(0):
c.position = bonePos + rotate(parentRot, c.float3_A)
Capsule(1):
c.position = bonePos + rotate(parentRot, c.float3_A)
c.subPosition = bonePos + rotate(parentRot, c.float3_B)
Line(2): # 線段就是「骨 -> 父骨」這條線, 不用局部偏移
d = parentPos - bonePos
len = |d|
if len <= 0.01: 原樣寫回
else:
dir = d / len
a = max(0, c.float3_A.x) × c.scale # NaN 也歸零
b = max(0, c.float3_A.y) × c.scale
if len < a + b: # 內縮量超過線段長 -> 等比縮小
a' = a × len / (a+b)
b' = b − b × ((a+b) − len) / (b + a')
兩者再各自 clamp 到 >= 0
c.position = bonePos + dir × a # 從骨端往內縮
c.subPosition = parentPos − dir × b # 從父端往內縮
Plane(3) / None(4): 不動
rotate(q, v) 是標準的 v + 2w·(q×v) + 2q×(q×v),
組合語言裡兩組 cross product 加上 fadd sX,sX,sX(乘 2)看得一清二楚。
尾端 b #0x27a0018 是 tail call,接著更新粗篩用的包圍球:
UpdateFastHit @ 0x027A0018:
Sphere / Plane: fastHitPosition = position
r = float_A
Capsule / Line: fastHitPosition = (position + subPosition) / 2
r = |position - subPosition| / 2 + max(float_A, float_B)
fastHitRadius = r × 1.1 <- 10% 餘裕
那個 1.1 很重要:粗篩半徑刻意放大 10%,
所以粗篩通過不代表真的碰到,但粗篩沒過就一定沒碰到。
重現時若用剛好的包圍球,邊界情況會與遊戲不同。
max(float_A, float_B) 的取法用 fccmp + fcsel 處理 NaN
(float_B 是 NaN 時取 float_A),不是單純的 fmax。
CheckChainCollision @ 0x027A0408(完整)—— 鏈骨是「線段整條側移」
Void CheckChainCollision(NativeList<ActorSwingJobStaticBone> staticBones,
ActorSwingJobChainBone* pChain,
ActorSwingAffineTransform& selfTx, # 線段起點
ActorSwingAffineTransform& subTx, # 線段終點
ActorSwingJobDynamicBone* pDynamicHead, # 陣列基底
ActorSwingJobDynamicBone* pBone, # 起點所屬骨
ActorSwingJobDynamicBone* pSubBone, # 終點所屬骨
ActorAnimationManagePropertyData& manage,
ActorSwingAffineTransform hipsTx) # 28 B, 用指標傳
ActorSwingJobChainBone = 136 B
0x00 collider(84) 0x54 collision(28) 0x70 dynamicBoneIndex
0x74 chainOffsetIndex 0x78 depth 0x7c smoothing 0x80 around
(注意:collider 在 ChainBone 是 offset 0,在 DynamicBone 是 0x0c。
兩者的 CollisionInfo 因此分別在 0x54 與 0x60。差一個偏移就全錯。)
localCollider = pChain->collider
for each staticBone:
if not CheckCollision(&localCollider, &staticBone.collider, &hit): continue
T = hit.position + hit.normal × hit.value # 與 dynamic 版同一條式子
P = selfTx.translation; Q = subTx.translation
axis = normalize(Q - P)
w = T - P
offset = w - axis × dot(axis, w) # w 垂直於線段的分量
newP = P + offset
newQ = Q + offset # 兩端同一個位移 -> 線段平移
整條線段側移,不轉向。 沿軸方向的分量被投影掉了 —— 所以鏈骨碰到身體是「滑開」,不是「被頂彎」。
接著兩端各自做骨長約束,錨點不是彼此而是各自的父骨:
for (end, bone) in [(newP, pBone), (newQ, pSubBone)]:
anchorBone = pDynamicHead[bone->parentIndex] # 步長 0x3b4 = 948
A = anchorBone.selfTx.translation
+ RootCorrectionCancel(hipsTx.translation,
manage.xy, bone->rootHorizontal/VerticalWeight)
d = end - A
if |d|² > 1e-10:
end = A + d/|d| × bone->boneLength
UpdateCollider(&localCollider, newP, newQ, selfTx.rotation) # Line 型別
selfTx.translation = newP; subTx.translation = newQ
accum.normal = hit.normal
accum.position = localCollider.fastHitPosition # 線段中點
pChain->collision = accum; hitCheckCount = 命中數; pChain->collider = localCollider
RootCorrectionCancel 就是 §「根部修正」解出的
(p.x×h, p.y×v, p.z×h),其中 h = 1 − a×rootHorizontalWeight、
v = 1 − b×rootVerticalWeight,(a,b) 來自 manage 的 offset 8。
組合語言完全吻合,不是套用而是重新讀出來對上的。
差點寫錯的地方:
dot前面有兩個bl #0x9815308, 看起來像在做normalize。查了才知道那是Unity.Mathematics.float3.op_Implicit(float3) -> Vector3, 函式本體只有一條ret—— 值直接透傳,什麼都沒做。 若當成 normalize,dot會變成 cosθ,投影量的量綱就錯了, 側移距離會整個不對。位址不確定就去查名字,不要看形狀猜。
碰撞部分完成度
| 函式 | 狀態 |
|---|---|
CollisionSphereSphere |
完整 |
CollisionSphereCapsule |
完整 |
CollisionCapsuleCapsule |
完整 |
CollisionSpherePlane |
完整 |
CollisionCapsulePlane |
完整 |
CheckCollision (分派 + 粗篩) |
完整 |
CheckDynamicCollision (推開 + 骨長約束) |
完整 |
UpdateCollider @ 0x0279fc20 |
完整 |
UpdateFastHit @ 0x027A0018 |
完整 |
CheckChainCollision @ 0x027A0408 |
完整 |
碰撞子系統全部解完。 閉環是:
形狀更新 → 包圍球粗篩(×1.1 餘裕)→ 型別分派 → 五個幾何函式 →
推開(hit.position + normal × value)→ 骨長重投影 → 更新形狀 → 下一顆碰撞體。
沒有留下「大概是這樣」的環節。
dynamic 與 chain 兩版的差別只在推開之後怎麼用: dynamic 直接把子骨放到推開點再拉回骨長; chain 先把位移投影掉沿軸分量、整條線段平移,再讓兩端各自拉回自己的骨長。
dynamicType == 1:軸間耦合(完整)
0x02793284~0x027933e0 那段四元數運算解出來了,而且欄位名字直接印證了推導。
if bone.dynamicType == 1:
local = rotate(inverse(bone.selfTx.rotation), force)
local.y += bone.axisAddXToY × sign(local.y) × |local.x|
local.z += bone.axisAddXToZ × sign(local.z) × |local.x|
force = rotate(bone.selfTx.rotation, local)
也就是沿骨軸(local X)的受力,會按比例外溢到 Y / Z 兩個側向, 方向取當下側向分量的正負號(已經往哪邊偏就往那邊加), 所以是「越擺越開」的發散耦合,不是回正。裙襬要展開靠的就是這個。
sign(0) = 0(fcmgt/fcmlt 兩個比較加 bit 組出來的,不是 copysign)——
側向分量剛好為 0 時不會被推向任一側。
兩個係數在 prewarmCount >= 1 時被遮成 0:
0x02792fc8 cmp w26, #1 ; cset w8, lt # w26 = bone.prewarmCount
0x02792ffc dup v0.2s, w8
0x02793014 shl / cmlt # 布林 -> 全 1 遮罩
0x02793030 and v1.8b, v1.8b, v0.8b # axisAddXToY / XToZ 歸零
0x02793044 fcsel s1, spring, 0, lt # spring 也一樣被關掉
暖機(prewarm)期間只跑基本彈簧,關掉 spring 傳遞與軸間耦合,
再配合固定的大時間步長(0.6668)快速收斂到穩態。
重現時如果暖機也開這兩項,起始姿勢會跟遊戲不一樣。
怎麼確定軸的對應(這裡差點寫錯)
編譯器把 float3 拆成「一個純量 + 一個 2-lane 向量」,而且不是按 x/(y,z) 拆的順序在用。
我先假設 v4 = (x, y)、s5 = z,推出來的中間值跟 rotate(inv(q), v) 對不上。
改用符號執行(scripts/simd_sym.py,逐條解釋 NEON 指令、
輸入設成符號、輸出跟 3×3 旋轉矩陣做全排列比對)才定出來:
比對結果: A = R(q)^T, 列序 (1,2,0), 行序 (1,2,0)
=> 暫存器 (v4.lane0, v4.lane1, s5) 對應向量的 (y, z, x)
回頭看載入端也吻合 —— childSpeed 在 0x0fc,
ldr s1,[x28,#0xfc] 進 s5(= x),ldr d2,[x28,#0x100] 進 v4(= y,z)。
兩條獨立證據指向同一個結論。
而且欄位就叫 axisAddXToY / axisAddXToZ ——
符號執行推出「X 的絕對值加到 Y 和 Z」,名字寫的正是同一件事。
這是第三條獨立證據。
教訓:向量指令被打散之後,「哪個暫存器裝哪個分量」不能用直覺配。 符號執行 + 與已知矩陣做全排列比對,才是能確定的做法。
26 參數的 Calc @ 0x02773858(完整)—— 是約束混合,不是彈簧
這個一直掛在「未解」的函式,metadata 的參數名就把結構寫清楚了:
ActorAnimationConstraintTransformBone.Calc(
float3 weight0..weight3, fieldWeight0, # 5 組權重
float3 position0..position3, fieldPosition0,
quaternion rotation0..rotation3, fieldRotation0,
float3 scale0..scale3, fieldScale0,
float3 offsetPosition, quaternion offsetRotation, float3 offsetScale,
out float3 position, out quaternion rotation, out float3 scale)
695 條指令,實際做的是四個來源 + 一個 field 來源的加權混合。 關鍵是三個輸出各自用權重的不同分量:
weight.x -> 位置 weight.y -> 旋轉 weight.z -> 縮放
(組合語言裡是 float3.get_Item(0/1/2),三段各呼叫五次,索引一路是 0、1、2。)
# 位置
tw = Σ weight_i.x
if tw == 0: position = offsetPosition
else: position = offsetPosition + Σ (weight_i.x / tw) × position_i
# 旋轉 —— 不是加權平均, 是「連續 slerp」
tw = Σ weight_i.y
if tw == 0: rotation = offsetRotation
else:
q = Quaternion.identity
for i in 0..4: q = Slerp(q, rotation_i, weight_i.y / tw)
rotation = q × offsetRotation
# 縮放
tw = Σ weight_i.z
if tw == 0: scale = offsetScale
else: scale = offsetScale ⊙ Σ (weight_i.z / tw) × scale_i # 逐分量相乘
三種 offset 的套用方式各不相同 —— 位置用加、旋轉用四元數乘、縮放用逐分量乘。 這是 TRS 該有的樣子,但如果照抄「都用加」就會錯。
Slerp @ 0x02774334 是共用的私有實作(ProcessDynamicBoneLayer 也呼叫它):
點積為負先取反走短弧,點積 ≥ 0x3f7fdf3b(≈ 0.99951)退化成 lerp,
否則走 acos + sin 的標準 slerp。
連續 slerp 與順序有關,不是真正的加權平均 (四元數的加權平均沒有閉式解,這是業界常見的近似)。 這是遊戲自己的近似,不是我的近似 —— 重現時照著做就會一致。
這個函式屬於 ActorAnimationConstraint 系統(_hlp 扭轉修正、
_ast 輔助骨那一類),與 ActorSwing 的彈簧積分是兩套獨立機制。
當初把它列進 swing 的未解清單是分類錯誤。
ProcessDynamicBoneLayer @ 0x02794AC4(呼叫結構已解,內部待解)
293 條指令,分兩段。鏈骨段只跑一次,但碰撞可能跑兩次:
if bone.depth < 某上限:
n = ProcessChainBoneCollider(chainHead, dynamicHead, depth+1, *chainStartIndex,
manage, hipsTx_copy)
if ProcessChainBoneSmoothing(chainHead, dynamicHead, depth+1, *chainStartIndex, …):
n = ProcessChainBoneCollider(…, 用原本那份 hipsTx 副本重跑) # 平滑後重解碰撞
*chainStartIndex = n
for each bone in [startIndex, startIndex+length):
CalcDynamicSlide(...) # 0x0279765c, 尚未解
CalcDynamicSwing(...) # 0x02796f38
w = stream.GetFloat(bakeAnimationWeightHandle)
if w != 0:
q = stream.GetLocalRotation(...)
rot = ApplyBlendBakeTarget(bone, rot, q, w) # 0x02795794
(另一路用 Slerp @ 0x02774334 混合)
tx = ActorSwingAffineTransform(pos, rot) # 0x02798bd8
tx = tx × parentTx # op_Multiply @ 0x0279f350
寫回 bone.selfTx (0xc4 / 0xd0) 與下一根的 selfTx
ProcessChainBoneSmoothing 回傳 bool 決定要不要重跑一次碰撞 ——
平滑會把骨頭挪動,挪完可能又插進碰撞體裡,所以要再解一次。
這解釋了 §「ProcessDynamicBoneLayer 跑兩次」附近觀察到的重複呼叫。
bakeAnimationWeight 走的是 PropertySceneHandle + stream.GetFloat,
不是結構欄位 —— 所以它是逐格從動畫曲線讀的,
與 §20 說的「clip 裡 jnt_*_sim 曲線控制 bakeAnimationWeight」對得上。
逐骨迴圈的實際分派(已解)
for bone in [startIndex, startIndex+length): # 步長 0x3b4 = 948
if bone.childIndex < 0: continue # tbnz #31, 檢查符號位
if bone.active != 1: continue
child = dynamicHead[bone.childIndex]
if child.dynamicType != 0:
child.selfTx = CalcDynamicSlide(bone, child, weight) # 平移型
continue
... CalcDynamicSwing + bake blend ... # 旋轉型
dynamicType == 0 走 Swing(轉),非 0 走 Slide(滑)。一根骨只會是其中一種。
CalcDynamicSlide @ 0x0279765C(完整)
ActorSwingAffineTransform CalcDynamicSlide(pDynamic, pChildDynamic, float weight)
local = inverse(pDynamic.selfTx) × pChildDynamic.selfTx # 子骨在父骨空間的變換
rest = pChildDynamic.localTx.translation # 靜止時的局部位移
delta = local.translation - rest
if pChildDynamic.limit.useLimit == 1:
delta = clamp(delta, limit.negative × 0.001, limit.positive × 0.001) # 逐軸
out.translation = rest + delta × weight # 往限制後的位置插值
out.rotation = local.rotation # 旋轉原封不動
return pDynamic.selfTx × out
limitInfo 的數字在 Slide 是「公釐」,在 Swing 是「度」。
Slide 這邊有 0x3a83126f = 0.001 把資產裡的值換成公尺
(CalcDynamicSwing 整個函式只有一個浮點常數,沒有 0.001,
它走 ToUnityQuaternion 把同樣的數字當尤拉角)。
同一組序列化欄位、兩種單位 —— 靠 dynamicType 分流,不會同時發生。
若照著「限制都是角度」重現,滑動型的骨會錯 1000 倍。
clamp 的 NaN 處理是 cmhi(把 |x| > +inf 當整數比)加 bif/bit,
不是 fmin/fmax —— NaN 會被夾成邊界值而不是傳播。
拿資產資料反查,單位推論成立
掃 279 套服裝的 ActorSwingDynamicBone:
dynamicType = 0 (Swing) 6900 根 limitInfo 典型值 ±180 / ±30 / ±30
dynamicType = 1 (Slide) 40 根 limitInfo 典型值 ±6 / ±8 / ±8
滑動型只有 40 根,全部集中在 9 套服裝,而且骨名清一色是
jnt_[LR]_foreArmSleeve00_0N_sim 袖口 26 根
jnt_[LR]_legSleeve / LegSleeve 褲管 10 根
jnt_[LR]_jewelString00_0N_sim 飾品繩 4 根
袖口與褲管沿著手臂/腿滑動,本來就該是平移而不是擺動。 ±6 / ±8 當公釐是合理的袖口滑動量;當角度則是個很怪的選擇 (其他擺動骨都用 ±30、±180 這種角度慣用值)。 組合語言的 ×0.001 與資產的數值量級、骨骼的語意三者一致。
鏈骨三兄弟
ProcessChainBone @ 0x02795C70(完整,50 條)
只是個包裝,內容與 ProcessDynamicBoneLayer 內聯的那段一模一樣:
n = ProcessChainBoneCollider(..., hipsTx 副本)
if ProcessChainBoneSmoothing(...):
n = ProcessChainBoneCollider(..., 重新複製一份原始 hipsTx)
*chainStartIndex = n
第二次呼叫重新從原始 hipsTx 複製,不是沿用第一次被改過的副本。
ProcessChainBoneCollider @ 0x02795D38(完整,186 條)
chain = chainHead + chainStartIndex × 0x88 # ChainBone = 136 B
while chain.depth == depth 且 index < 總數:
A = dynamicHead[chain.dynamicBoneIndex]
if !A.active: next
B = dynamicHead[chain.dynamicBoneIndex + chain.chainOffsetIndex]
if !B.active: next
txA = A.selfTx; txB = B.selfTx
rcA = GetRootCorrectionCancelPosition(hipsTx.translation, manage[8], manage[0xc],
A.rootHorizontalWeight, A.rootVerticalWeight)
rcB = 同上, 用 B 的權重
txA.translation += rcA # 進入「已抵銷根部位移」的空間
txB.translation += rcB
UpdateCollider(&chain.collider, txA.translation, txB.translation, txA.rotation)
CheckChainCollision(this.staticBones, &chain, &txA, &txB,
dynamicHead, A, B, manage, hipsTx)
txA.translation -= rcA # 出來, 還原
txB.translation -= rcB
A.selfTx = txA; B.selfTx = txB
next chain
return 最後的 index
碰撞是在「根部位移被抵銷過」的座標裡解的,解完再還原。 加上去、解、扣回來 —— 這個三明治不能省,否則角色移動時裙襬會被自己的位移拖著跑。
呼叫點是 0x027969D0,那是 CheckChainCollision 的內聯複本
(結構與 0x027A0408 逐條相同:同樣的 collider 複製偏移、
同樣的 HitInfo 堆疊位置、同樣的 GetUnsafeReadOnlyPtr)。
IL2CPP 產了兩份,別以為是兩個不同函式。
ProcessChainBoneSmoothing @ 0x02796050(進入條件已解,主體待解)
前 86 條就是三個守門條件,任何一個不過直接回傳 false:
chain.depth == depth
chain.around == 1 # 只有環狀鏈 (裙子一圈) 才平滑
chain.smoothing >= 1e-5
回傳 false 就不會重跑碰撞。 所以「碰撞跑兩次」只發生在
around 的環狀鏈上 —— 裙子那一圈骨頭彼此要平滑,平滑會把骨頭挪開,
挪完得再解一次碰撞。非環狀的頭髮、緞帶只跑一次。
主體 522 條指令,也解完了 —— 是環狀鏈的拉普拉斯平滑 + 環長回復:
配置兩個暫存 NativeList<float3>
B = [x29-0xa0] 原始位置
A = [sp+0xa8] 平滑後位置
# 第一趟: 把鏈上每根骨的位置原樣收進 B
# 第二趟: 對每根骨做拉普拉斯平滑, 結果 Add 進 A
for i in 鏈上每根骨:
cur = B[i]
prev = B[i-1] # around 時 i=0 取 B[n-1]
next = B[i+1] # around 時繞回 B[0]
A[i] = cur + chain.smoothing × ( ((prev-cur) + (next-cur)) / 2 )
# 鄰居一律讀 B, 不讀 A —— 是同步(Jacobi)更新, 不是逐點就地更新
# 第三趟: 環長回復
len = GetChainLoopLength(A, chain.around)
if len < chain.initialLoopLength: # 只在縮短時作用, 不會壓縮
c = centroid(A)
k = chain.initialLoopLength / len
A[i] = c + (A[i] - c) × k # 以質心為中心等比放大
# 第四趟: 被碰撞推開過的骨還原
for 每個 chain link:
if chain.collision.hitCheckCount != 0:
A[i] = B[i]; A[i+1] = B[i+1] # 貼著碰撞體的骨不參與平滑
# 第五趟: 寫回 bone.selfTx.translation, 旋轉原封不動
釋放兩個列表; return true
GetChainLoopLength @ 0x0279DCE8 就是折線長度:
Σ |p[i+1] - p[i]|,isLoop 為真時再加上 |p[0] - p[n-1]|。
initialLoopLength 是 ActorSwingJobChainBone 的最後一個欄位(0x84)——
裙擺那一圈的靜止周長。平滑會讓環縮小,這一步把它撐回去。
只在縮短時作用(fcmp len, target; b.pl 跳過),所以不會反過來壓扁。
三個細節少了就會不一樣: 鄰居讀原始值而非邊平滑邊讀、 撞到碰撞體的骨完全不平滑、 旋轉不動只改位移。
ApplyBlendBakeTarget @ 0x02795794(完整)
quaternion ApplyBlendBakeTarget(pDynamic, quaternion current,
quaternion bakedLocal, float weight)
target = pDynamic.parentTx.rotation × inverse(pDynamic.localTx.rotation) × bakedLocal
return Slerp(current, target, weight) # tail call 到 0x02774334
乘法順序是用符號執行對出來的(scripts/simd_sym.py,
把 72 條純量 fmul/fadd/fsub 跑成符號式,
再與七種可能的四元數乘積順序逐一比對,只有 P × inv(L) × B 完全相等)。
四元數不可交換,順序猜錯就是完全不同的姿勢。
weight 就是 ProcessDynamicBoneLayer 從 stream.GetFloat(bakeAnimationWeightHandle)
讀來的值 —— 也就是 clip 裡 jnt_*_sim 曲線。
weight = 0 全物理,weight = 1 完全跟隨烘焙的動畫。
覆蓋率:用資產資料確認哪些路徑真的會跑
解到這裡,該問的是「還沒解的東西實際上跑不跑」。掃 279 套服裝:
ActorSwingChain 的 layer 總數 657
active=0(根本不啟用) 276
active=1, around=0(頭髮、緞帶等非環狀) 291 -> 只跑碰撞, 不平滑
active=1, around=1, smoothing < 1e-5 75 -> 也不平滑
active=1, around=1, smoothing >= 1e-5 15 -> 走平滑 + 第二次碰撞
ActorSwingDynamicBone 總數 6940
seatDynamicCorrection != 0 1 (0.4, 只有一套服裝的一根骨)
平滑路徑只影響 15 個 layer;seatDynamicCorrection 只影響 1 根骨。
常跑的路徑(積分、碰撞、骨長約束、Slide/Swing 分流、bake blend)全部已解。
唯一仍未解:ProcessChainBoneSeatedDynamicCorrection @ 0x02794F5C。
已經確定的結構(512 條,只解到這裡):
float3 ProcessChainBoneSeatedDynamicCorrection(
AnimationStream, AffineTransform ×4, float3)
# 第一段: 從 stream 讀六個骨位置, 兩兩取中點
handles 在 this+0x100 的 {0x00, 0x1c, 0x34, 0x4c, 0x64, 0x7c}
mid1 = (GetPosition(h[0x00]) + GetPosition(h[0x4c])) × 0.5
mid2 = (GetPosition(h[0x1c]) + GetPosition(h[0x64])) × 0.5
mid3 = (GetPosition(h[0x34]) + GetPosition(h[0x7c])) × 0.5
—— 左右成對取中點, 形狀上像是大腿/膝/踝的中線
# 第二段: 每個中點經第 4 個 AffineTransform 變換後減去輸入 float3
# 第三段: 用 Y 座標比大小 ([x19+4] / [x20+4] 與參考 Y) 決定落在哪一段,
# 再用距離比做 lerp
逐式未解。6940 根骨裡只有 1 根 seatDynamicCorrection != 0,
優先度最低 —— 這是還沒解,不是拿近似頂替。
21. 臉部動畫:先發現自己的解碼漏了東西
臉部(眼、眉、虹膜)不是烘焙來的,clip 裡就有現成的 blendshape 權重曲線:
type_id 137 (SkinnedMeshRenderer)
path Root_Body/Geo_Eye_LOD0 property b_eye.eye_001
Root_Body/Geo_Brow_LOD0 b_brow.brow_003
Root_Body/Geo_Iris_LOD0 b_iris.iris_002
屬性名與 GLB 匯出的 targetNames 逐字相同,不必猜對應。
(部分 clip 前綴是 b_eye1 / b_brow1,形狀名相同,比對點號後面那段即可。)
嘴巴是另一回事:FacialDecal/Mouth(type_id 114,MonoBehaviour,
41 個 weights.wN),那是貼花系統不是 blendshape,另案處理。
解碼漏掉的東西:streamed clip 的三次係數
decode_streamed 每個 key 讀 4 個 float,但只留了 coeff[3]。
那 4 個是該段的三次多項式係數:
v(t) = c3rd·dt³ + c2nd·dt² + c1st·dt + value dt = t - key.time
對 [t_k, t_{k+1}) 有效
順序是驗證過的,不是照著猜:對每段用 v(Δ) 與下一個 key 的值比對,
44 段裡 40 段完全吻合;把係數當反序解讀當對照組,29 段對不上。
剩下 4 段係數全為 0(常數段)而在段末跳值 —— 那是 Unity 的階梯切線,
用「0.09999 / 0.10000 兩個相鄰 key」表示瞬間切換,不是解碼錯誤。
這個漏失有多大:全庫 225,492 個 streamed key 裡
三次 165,215 (73.3%) 線性 7,150 (3.2%)
常數 50,973 (22.6%) 二次 2,154 (1.0%)
臉部曲線用線性內插與精確三次相比,最大差 95 個權重單位(值域 0~120), RMS 4.04。那不是抖動級的誤差,是表情整個走樣。 原本的註解寫「切線資訊已在解碼時捨去, 以取樣頻率補足精度」—— 提高取樣頻率補不了曲率,那句話是錯的。
這件事的教訓不是「Unity 曲線很複雜」,是讀了 4 個值只用 1 個時要問為什麼。 當初解到能出結果就停了,沒回頭問剩下三個是什麼。
輸出:glTF CUBICSPLINE 是精確的,不是擬合
glTF 的 CUBICSPLINE 是 Hermite 三次, 給定兩端的值與導數就唯一決定一條三次曲線 —— 所以任意三次多項式都能完全表示,段內零誤差。
做法:把同一個網格上所有 target 的 key 時間取聯集 (中位數 10 個時間點,最大 112,量很小), 在每個時間點對每條曲線求「值 + 左導數 + 右導數」。
三個邊界條件各踩過一次坑,都是靠逐點比對抓出來的:
| 寫法 | 最大差 | 為什麼錯 |
|---|---|---|
| key 上取左極限 | 0.999 | 階梯跳變被攤到下一個時間點才發生;Unity 是右連續 |
| in/out 切線都填右導數 | 0.148 | 導數在 key 上本來就不連續,折線被畫成 S 形 |
| 第一個 key 的 out 切線填 0 | 0.0217 | 曲線起點的斜率被抹平 |
| 末端之後用最後一段外插 | 0.0022 | Unity 在曲線範圍外是夾住的,導數該是 0 |
全部修正後,549 個動畫、702,720 個隨機取樣點:
最大差 1.6e-06 RMS 1.2e-08 (權重尺度 0~1.2)
1.6e-06 就是 float32 的儲存精度,段內是精確的。
驗收工具 verify_facial.py 獨立重寫了兩邊:
照 glTF 規格的 Hermite 公式把 sampler 解回來,
再與照 Unity 語意重寫的求值比對。
用產生資料的同一支函式驗收等於自我背書,那不算驗證。
唯一的已知偏差,有界且明示
glTF 的 sampler 只能整條選一種內插,不能逐 key 切 STEP。
所以遇到真正的不連續(全庫 11,904 段裡 131 段,1.1%),
在跳變點前插一個 t - 1e-5 的 key 保持舊值,
把跳變壓在 1e-5 秒內完成 —— 比任何播放幀率短兩個數量級。
全庫共插了 122 處。
這是唯一的偏差,而且 Unity 自己的資料就是用 0.09999 / 0.10000 這種相鄰 key 表示瞬間切換的,做法一致。
產出
python3 add_facial.py <輸出>/hololive_549_animations.glb \
<輸出>/hololive_549_face.glb
python3 verify_facial.py <輸出>/hololive_549_face.glb --anims 549
549 個動畫全部加上臉部,1,647 條 weights channel
(眼 16 target / 眉 14 / 虹膜 2),151 MB。
22. 嘴巴不是 blendshape,是貼花換格子
FacialDecal/Mouth 那 40 條 weights.wN 一直沒解。
它不在 SkinnedMeshRenderer 上(type_id 114 = MonoBehaviour),
所以不是形變 —— 是 URP 的 DecalProjector 投在臉上的一張貼圖。
Vision.Common/VisionActorMouthDecal weights: 40 個 float + DecalProjector
Vision.Common/VisionActorFacialAnimationJob ProcessMouthDecal(AnimationStream)
ProcessMouthDecal @ 0x0ADCE344(完整,141 條)
if !UvBiasXStream.IsValid(stream) or !UvBiasYStream.IsValid(stream): return
best = 0; bestVal = 0.0
for i in 0 .. mouthDecalWeights.Length-1:
v = stream.GetFloat(mouthDecalWeights[i])
if v <= bestVal: continue
if v >= 1.0: { best = i; break } # 提早定案, 不看後面
bestVal = v; best = i
col = best % 5 # smull 0x66666667 -> 除以 5
row = floor(best / 5)
stream.SetFloat(UvBiasXStream, col × 0.1875)
stream.SetFloat(UvBiasYStream, 0.875 - row × 0.125)
取權重最大的那一格,5 欄 × 8 列 = 40 格。 提早定案那條分支重要:權重達到 1.0 就立刻採用,不會被後面同為 1.0 的蓋掉。
圖集幾何:組合語言與資產互相印證
資產 DecalProjector on Mouth: m_UVScale = (0.185, 0.125)
m_UVBias = (0.0, 0.875)
組合語言的步進: (0.1875, 0.125)
m_UVBias 的預設值 正好等於 index 0 算出來的值,
m_UVScale.y 與列步距 完全相同。兩邊獨立對上。
格寬 0.185 但步距 0.1875 → 欄間留 0.0025 間隙。
貼圖 t_chr_*_fdc_col 是 1024×1024,換算像素是步距 192×128,格寬 189×128。
5 欄佔到 x = 0.75 + 0.185 = 0.935;
而頰紅/蒼白那幾個投影器的 m_UVBias.x 是 0.93697 ——
正好接在嘴型格右邊。同一張圖集,左邊 40 格嘴巴,右邊一條臉頰。
直接掃圖集的 alpha 覆蓋率驗證:40 格裡 39 格有內容,
唯一的空格是 w34;而 w34 在 549 個 clip 裡從未非零。
(另外 w20/w26/w36 也沒用到,但那三格有圖 —— 只是這批動畫沒用上。)
資料本身:精確的 one-hot 階梯
掃 549 個 clip 的 weights.wN:
所有 key 的值只有 0.0 (51,694) 與 1.0 (7,995) —— 沒有中間值
streamed 段的三次係數全為 0 (17,813/17,813) —— 純階梯, 沒有斜坡
任一時刻同時為 1 的權重數 <= 1 (檢查 8,206 個時間點, 零例外)
所以嘴型是精確的 one-hot 階梯函數,切換點就落在 key 上,
不需要取樣、也沒有內插誤差。extract_facial_decal.py 直接輸出
「時間 → 格號 → UV bias」的切換清單。
六個投影器都會淡入淡出
FacialDecal/ 底下有六個 DecalProjector,
全部都有 m_FadeFactor 曲線(各 540 個 clip):
Mouth LeftCheek RightCheek CheekWhole Pale Blush
材質只有兩種(m_fdc 給嘴與臉頰、m_fdm 給蒼白與紅暈),
共用同一張 _fdc_col 圖集。除了 Mouth 之外,
其餘五個的 m_FadeFactor 預設是 0(平常不顯示),靠動畫拉起來。
產出
python3 extract_facial_decal.py
-> _avatar/facial_decal.json
549 個 clip, 嘴型切換 4,001 次 (平均每 clip 7.3 次)
另含六個投影器的 m_FadeFactor 原始 key (含三次係數, 可精確求值)
glTF 核心規格沒有可動畫的 UV 位移
(KHR_texture_transform 是靜態的),所以嘴型沒有寫進 GLB,
以資料形式輸出,交給使用端自行套用。這是格式限制,不是資料不足。
23. 渲染錯誤:alphaMode 用猜的,而答案就寫在材質裡
一張渲染圖裡兩個明顯的破綻 —— 裙邊飛出一片片不透明白色碎片、 臉上從鼻子到下巴有一條穿到頭殼內側的暗塊 —— 追出來是同一個根因。
先排除掉的(都查過,沒問題):
蒙皮 所有頂點權重和 = 1.0, joint 索引都在範圍內, 無全零權重頂點
骨長 動畫中每根骨的 local translation 長度都等於靜止值,
唯一有位移 channel 的是 jnt_C_hips00_00 (根動作, 本來就該動)
根因是 alphaMode。原本的判定是量貼圖的 alpha 直方圖來猜,
註解還特地寫了「依實際 alpha 內容決定, 不看檔名」。
方向是對的(不該看檔名),但問對象錯了 ——
m_bdy / m_bdyco / m_bdytrs 共用同一張 bdy_col 貼圖,
三個因此被判成同一種模式。
而 Unity 的材質早就把答案寫在資產裡:
m_bdy queue 2000 keywords [] SrcBlend=1 DstBlend=0
m_bdyco queue 2450 ['_ALPHATEST_ON'] SrcBlend=1 DstBlend=0
m_bdytrs queue 2600 ['_ALPHATEST_ON',
'_SURFACE_TYPE_TRANSPARENT'] SrcBlend=5 DstBlend=10
m_eye queue 2002 ['_ALPHATEST_ON'] SrcBlend=1 DstBlend=0
2000 / 2450 / 2600 正是 Unity 的 Geometry / AlphaTest / Transparent 三條界線,
SrcBlend=5, DstBlend=10 就是 SrcAlpha, OneMinusSrcAlpha 的標準混合。
一個都不用猜。
兩個破綻各自對應哪個錯判
m_bdytrs 該 BLEND, 被判成 MASK(cutoff 0.5)
-> 紗裙/緞帶那層半透明的部分被硬裁成非零即一
-> 裙邊變成一片片不透明白色碎片
m_bdy 該 OPAQUE, 被判成 MASK(cutoff 0.5)
-> 貼圖裡 alpha < 0.5 的位置被整片丟掉, 臉上被打洞
-> 從破洞看見頭殼內側 (背面未照亮) = 鼻子到下巴那條暗塊
修法
_declared_alpha_mode() 先問材質自己,問不到才退回看貼圖:
1. '_SURFACE_TYPE_TRANSPARENT' 或 DstBlend != Zero -> BLEND
2. renderQueue >= 2500 -> BLEND
3. '_ALPHATEST_ON' 或 renderQueue >= 2400 -> MASK
4. 其餘 -> OPAQUE
修正後與遊戲宣告的狀態逐項吻合:
m_bdy OPAQUE m_bdyco MASK m_bdytrs BLEND m_eye MASK
頭髮: m_hir OPAQUE m_hirco MASK m_hirprp OPAQUE
教訓與 §21 的三次係數是同一種: 資產已經給了答案,卻用統計去反推。 「不看檔名」是對的,「只看貼圖」不是 —— 該看的是材質的渲染狀態。
(m_fef 不在 body bundle 的材質清單裡,仍走貼圖判定的退路。)
24. 讀 mos9527 的 PJSK 筆記:能借的是方法,不是結論
https://mos9527.com/posts/pjsk/archive-20240105/
(Project SEKAI,Colorful Palette;與 QualiArts 同屬 CyberAgent 底下)
能直接借的:貼圖通道是有語意的,不是美術隨手畫的
他把 PJSK 的貼圖拆成三種角色:
tex_*_C 基礎顏色
tex_*_S 陰影顏色(暗部另外一張圖,不是把 C 調暗)
tex_*_H RGB565 遮罩圖
R = 膚色區
G = 高光強度
B = NPR 陰影閾值
頂點色也是遮罩:R 通道控制要不要描邊。 描邊本身是 shell shading(沿法線外推的殼),不是後處理。
對照我們手上的東西,命名對得起來得驚人:
t_*_bdy_col 2048×2048 對應他的 _C
t_*_bdy_nml 2048×2048 法線
t_*_bdy_def 2048×2048 對應他的 _H —— 通道語意未解
t_*_bdy_drp 128×16 ramp LUT,材質裡的槽就叫 _ShadeRampMap
頂點屬性 _VCMASK 我們目前**原樣丟進 GLB 讓 Blender 忽略**
128×16 這種尺寸只可能是查表用的 ramp。
材質早就把它掛在 _ShadeRampMap 這個槽上了 —— 名字都寫好了,我們沒用。
這裡的方法論是:貼圖與頂點色的每個通道都要當成獨立的訊號去問語意, 不要看到
_def就當成「某種附加貼圖」丟掉。 我們對_VCMASK就是犯這個錯 —— README 寫「那是 shader 遮罩不是顏色, Blender 會忽略它」,等於承認沒解就跳過。
借不到的
- 彈簧骨:那篇沒做。我們在 §20~§22 對
ActorSwing的逆向反而走得更深。 - 資產管線:解密、catalog、bundle 解析我們都自己走完了,沒有可借的。
- 動畫:他走曲線直出;我們走
PlayableGraph逐格烘焙,路線不同且更精確。
驗收方式的差異,值得記一筆
他的驗收是跟遊戲畫面/PV 目視比對。 我們在數值層面(三次係數 1.6e-06、碰撞公式逐條)比他嚴格得多, 但在渲染這一層我們什麼驗收都沒有 —— 而渲染恰恰是目視比對唯一可行的地方。 alphaMode 那個 bug 能存活到使用者截圖給我看,就是因為沒有這一關。
因此新增的工作項
1. 解 _VCMASK 各通道的語意(描邊寬度?高光?陰影閾值?)
2. 解 t_*_bdy_def 的通道語意
3. 把 _ShadeRampMap (bdy_drp 128×16) 接進材質
4. 描邊殼:目前直接剔除,應改成沿法線外推 + 正面剔除的重建
5. 建立渲染層的驗收 —— 與遊戲截圖對比,不能只靠數值驗收
25. 照 mos9527 的方法問通道語意:問到一半,先記已確定的
材質的貼圖槽早就寫好名字了
m_bdy 的 m_TexEnvs:
_BaseMap = t_*_bdy_col 2048²
_BumpMap = t_*_bdy_nml 2048²
_DefMap = t_*_bdy_def 2048² <- 對應 mos9527 的 _H
_ShadeRampMap = t_*_bdy_drp 128×16
_DetailRampMap / _AdditionalDefineTexture / _LocalReflectionTexture
浮點屬性裡有 _DEF_DEBUG 與 _DefDebugMask = 15(= 0b1111)——
shader 自己有除錯模式可以逐通道顯示 _DefMap,
這等於官方承認那張圖是四個獨立通道打包的。
還有 _StencilRef = 64 / _StencilComp = 8 / _StencilOp = 2 ——
README 說「遊戲另有 stencil 參與合成」,參數就在這裡。
_DefMap 四個通道的分布(已量,語意未定)
R 92% 為 0, 最大只到 0.196, 只有 49 個相異值 -> 稀疏、低幅值的參數場
G 69% 為 1.0, 平均 0.878, 256 階連續 -> 連續場(陰影/AO 之類)
B 77% 為 0, 其餘集中在 0.24(15%) 與 0.86(7%) -> 離散分區遮罩
A 89% 為 1.0, 11% 為 0 -> 二值遮罩
R 的最大值 0.196 很可疑:_ShadeRampMap 高 16 列,
0.196 × 255 / 16 ≈ 3.1 —— 形狀上像用 R 選 ramp 的第幾列。
但這是推測,還沒驗證,不要當結論用。
_ShadeRampMap 是 ramp 圖集,不是單一 ramp
128×16 逐列印出來看:列 0–1 相同、列 2–4 相同、列 5、列 6–7… 同一列橫向就是一條 128 階的查表,不同列是不同的 ramp。
列 0-1 (0.20,0.78,1.00) -> (0.52,0.92,0.94) -> (0.99,0.96,0.73) -> (1.00,0.53,0.85)
彩色, 藍→青→黃→粉 像是 rim/detail 用
列 2-4 (0,0,0) … (0.54,0.36,0.11) (0.23,0.15,0.05)
暗色帶暖尾 像是陰影 ramp
Alpha 只有 0 與 0.63 兩個值 —— 是旗標不是漸層。
_VCMASK:只有兩個通道在用
Geo_Body_LOD0 8471 個頂點:
R 6 個相異值 0(70%) 0.008 0.565(12%) 0.753(5%) 0.941(5%)
G 13 個相異值 0.941(58%) 0(39%) 0.188 0.502 …
B 全部為 0 100%
A 全部為 0 100%
R 的值換算成 8 bit 是 0, 2, 144, 192, 240 —— 144/192/240 都是 48 的倍數,
是量化過的離散等級,不是連續權重。
幾何上:R > 0.5 的 1876 個頂點全部落在 y ∈ [0, 0.87]
(角色全高 1.44)—— 集中在下半身。G 的分布散布到 1.07。
到此為止,不再往下猜
mos9527 那邊 R = 描邊遮罩。我們這邊 R 是下半身、48 的倍數的離散等級, 形狀上比較像分區索引或描邊寬度等級,但兩者不能直接套用 —— 不同公司的 shader 沒有理由共用通道約定。
要定案只有一條路:讀 shader —— 而 shader 已經解出來了,見下節。
已確定可以先做的(不需要等 shader):
B / A 通道恆為 0 -> 匯出時可以只保留 RG, GLB 少一半這個屬性的體積
_ShadeRampMap 是 16 條 ramp 的圖集, 不是單一 ramp -> 接進材質時要選列
_DefMap 有 shader 內建的逐通道除錯開關 (_DEF_DEBUG / _DefDebugMask)
26. Shader 原始碼到手:Vision/Actor/Default
m_bdy 的 m_Shader 指到依賴 bundle 裡的 Vision/Actor/Default,41 個屬性。
編譯後的原始碼有出貨,dump_shader.py 解得出來。
分段 LZ4,不能整塊解
第一次直接 lz4.decompress(compressedBlob) 撞牆:
unpack_from requires a buffer of at least 4352 bytes ... (actual 4348)
看了才發現 Shader 資產有三個平行陣列:
offsets 每段在 blob 裡的起點
compressedLengths [2990, 223987, 230979, …]
decompressedLengths [4348, 503128, 511920, …]
必須逐段解。12 段全部解開,共 5,465,204 bytes 的 Metal 原始碼
(platforms = [14] = Metal,iOS 版只出這一種)。
python3 dump_shader.py
-> _avatar/shader/Vision_Actor_Default.metal.txt (5.4 MB)
-> _avatar/shader/Vision_Actor_Default.props.txt (41 個屬性)
出現次數:_DefMap 668、_ShadeRampMap 484、_DetailRampMap 420、COLOR 26。
輸出是遊戲的 shader 原始碼,屬版權素材,
.gitignore已擋, 只留腳本可重現。
打開來看之後:那 5.4 MB 不是原始碼,是編譯後的 bitcode
打開找 _DefMap 的用法,才發現那 668 次全部是符號表字串,不是程式碼。
BC\xc0\xde (LLVM bitcode 魔數) 出現 251 次
MTLB (Metal 函式庫容器) 251 個
VATT (頂點屬性表) 23 組 -> POSITION0 NORMAL0 TANGENT0 TEXCOORD0 COLOR0
'#include <metal_stdlib>' 0 次
'fragment ' / 'vertex ' 0 次
也就是 251 個 MTLB 容器,每個裝一段 AIR(LLVM bitcode)。
可讀的只有反射用的中繼資料:屬性名、uniform 名、頂點語意。
COLOR0 確實在頂點輸入裡(所以 _VCMASK 真的是頂點色語意),
但它被拿去乘什麼,字串裡看不到。
我在上一段寫「Unity 的 Shader 資產保留反編譯得到的 GLSL/Metal 片段」—— 那句是錯的,至少對這個平台是。iOS 版只出 Metal, 而 Metal 的產物就是 bitcode,不是文字。 「拿到 5.4 MB」跟「拿到原始碼」是兩回事,我一開始講混了。
所以要解讀通道語意,還缺一段工具鏈
現況: 本機沒有 metal-objdump / llvm-dis (xcrun 找不到)
路線 A 裝 Xcode 完整版 -> metal-objdump 反組譯 AIR
路線 B llvm-dis (LLVM 版本要對得上 Apple 的 AIR 方言, 不保證)
路線 C 改抓 Android 版的 bundle -> 平台是 GLES, Unity 存的是 **GLSL 文字**
路線 C 最省事:同一個 shader 的 Android 版 platforms 會是 GLES3,
Unity 對 GLES 存的是可讀的 GLSL。catalog 若有 Android 資產就直接換平台抓,
不必碰反組譯。這是下一步要先確認的事。
§25 那五個問題(COLOR 的 R/G 乘去哪、_DefMap 四通道、
_ShadeRampMap 選列、_StencilRef=64 在哪個 pass、描邊外推量)
一個都還沒答 —— 解包完成,解讀連工具都還沒備齊。
27. 追 Android 版資產:平台編在版本號裡,但缺一個網域
目標是拿 Android 版的 shader(GLES 平台 Unity 存的是可讀 GLSL)。 追下來的結果是:平台切換的機制解出來了,但入口網址不在 app 裡。
平台是編進 octo 版本號的
VisionOctoConst.ResolveOctoVersion @ 0x0B44B930 只有 5 條指令:
cmp w1, #0x3e8 ; version < 1000 ?
b.hs …
mov w8, #0x186a0 ; = 100000
madd w0, w0, w8, w1 ; return platform × 100000 + version
配上列舉:
OctoPlatformType IPhonePlayer=1 Android=2 OSXPlayer=3 WindowsPlayer=4 LinuxServer=5
OctoEnvType Dev=1 Sbx=2 Stg=3 Review=4 Prd=5 Local=9 Expo=11
OctoAppType Dev=1 Stg=2 Prd=3 Local=100 Expo=11
GetOctoApp 查表 @0xc470da8 = [1,1,2,1,3,1,1,1,100,1,11] (以 env-1 為索引)
對照我們手上的快取路徑 octo/pdb/5/100001/octocacheevai:
5 = OctoEnvType.Prd ✓
100001 = 平台 1 (iPhone) × 100000 + 版本 1 ✓
兩個數字都對上了。Android 的對應值就是 200001。
但入口網址是執行期才有的
metadata 裡的樣板是:
https://OCTO_SERVER_DOMAIN/OCTO_SERVER_RELEASE_ID <- 兩個都是佔位符
{0}/v/{1}/list <- 清單端點的路徑格式
OCTO_SERVER_DOMAIN / OCTO_SERVER_RELEASE_ID 在 metadata 裡是字面的佔位字串,
表示真值是執行期填的(FetchOctoServerReleaseIdOperation 這個型別名也指向同一件事)。
解開 IPA 全掃過一遍:
Data/ 底下 無任何 octo 網域
Info.plist 只有 bundle id 與 URL scheme, 無 ATS 網域清單
全 app 二進位 搜不到任何 *.game-hololive-dreams.com 主機名
metadata 裡的主機 只有 jp.game-hololive-dreams.com 與 fonts.…(都不是 octo)
我們手上的 asset.game-hololive-dreams.com 是從解密後的 catalog 內含的
urlFormat 欄位讀到的,不是從 app 裡挖到的 —— 也就是說,
要先有 catalog 才知道 CDN 在哪,而要有 catalog 又得先知道 octo 在哪。
這是個環,iOS 那次是靠越獄裝置的 pdb 快取打破的。
所以 Android 路線缺的不是 CDN,是 octo 清單伺服器的網域 + releaseId。 可行的取得方式只有:Android 裝置的 octo 快取、或攔 Android 版的啟動流量。
同時確認:iOS 的 AIR 沒有內嵌原始碼
air.source 0 次 metal_stdlib 0 次
sample( / clip( / saturate( 0 次
texture2d 220 / half4 243 / unity_ObjectToWorld 253 <- 全是符號名
251 個 MTLB 容器, 中位數 19.7 KB
是 strip 過的 bitcode,沒有捷徑。要讀就得反組譯:
路線 A Metal Toolchain (metal-objdump)
Xcode-beta 有 metal 但工具鏈沒下載:
"cannot execute tool 'metal' due to missing Metal Toolchain;
use: xcodebuild -downloadComponent MetalToolchain"
路線 B brew install llvm -> llvm-dis / llvm-bcanalyzer
AIR 是 LLVM bitcode 的 Apple 方言, 版本要對得上才解得開
兩條都要下載數 GB 到本機,需要使用者同意才動。
28. _VCMASK 解出來了:4 個位元組裝了 8 個參數
Metal Toolchain 裝好之後(xcodebuild -downloadComponent MetalToolchain,805 MB),
metal-objdump --disassemble 直接把 AIR 還原成 LLVM IR。
把 5.4 MB 拆成 251 個 .metallib 逐個看,
其中 13 個是頂點著色器(含 COLOR0 頂點屬性),拿最小的 034 來讀。
頂點著色器對 COLOR0 做的事
%61 = fma(%6, 15.9375, 0.03125) ; %6 = COLOR0
%62 = floor(%61) ; hi
%63 = %62 * 16.0
%64 = shuffle %6 -> (r, b, g, a)
%67 = fma(%64, 255.0, -%63.xzyw) ; lo
代進去化簡(c = v/255,v 是 8-bit 原值):
c × 15.9375 = v/16
hi = floor(v/16) <- 高 4 bit
lo = v - hi × 16 <- 低 4 bit
每個通道被拆成兩個 4-bit 半位元組。4 個位元組 = 8 個獨立參數。
輸出到片段著色器的方式(含一個關鍵的例外)
%75 = %70 × (1/15, 1/15, 1/15, 1/15) -> INTERP5
%74 = %73 × (1/15, 1, 1/15, 1/15) -> INTERP6
(0x3FB1111120000000 = 0.0666… = 1/15)
追完 shuffle 的排列,並對照 insertvalue 的索引與
air.vertex_output 中繼資料的順序(0=position, 1=INTERP1, 2=INTERP4,
3=INTERP5, 4=INTERP6, 5=INTERP7, …):
INTERP6 = ( hi_R, lo_R, hi_G, lo_G ) / 15 全部正規化到 0~1
INTERP7 = ( hi_B/15, lo_B, hi_A/15, lo_A/15 ) <- lo_B 沒有除以 15
第一版把這兩個寫成 INTERP5 / INTERP6,差了一格 ——
insertvalue的索引 0 是air.position,不是第一個 INTERP。 算術(半位元組拆解、1/15 正規化、lo_B保持整數)是對的,只有標號錯。
lo_B 是唯一保持整數 0~15 的。而 §25 量到
_ShadeRampMap 是 16 列的 ramp 圖集 —— 0~15 對 16 列,
形狀上像是選第幾條 ramp 的索引。(見 §29:片段端確實有把
正規化過的半位元組乘回 15 當整數索引用,但用途不是 ramp。)
回頭解釋 §25 那個「48 的倍數」
§25 量到 R 通道只有 6 個相異值,8-bit 是 0, 2, 144, 192, 240,
當時看成「144/192/240 都是 48 的倍數」。拆成半位元組之後:
v=0 -> (hi=0, lo=0) hi/15 = 0.00
v=2 -> (hi=0, lo=2)
v=144 -> (hi=9, lo=0) hi/15 = 0.60
v=192 -> (hi=12, lo=0) hi/15 = 0.80
v=240 -> (hi=15, lo=0) hi/15 = 1.00
0 / 0.6 / 0.8 / 1.0 —— 乾淨的權重值。 G 通道同理:240/48/128/96 -> hi/15 = 1.00 / 0.20 / 0.53 / 0.40。
「48 的倍數」是拆錯位元造成的假象。 量化的資料只看數值間距會看出假結構,要先知道編碼才知道怎麼看。
匯出端的影響
現在的 gltf.py 把 _VCMASK 原樣當成 4 個 float 丟進 GLB。
那是錯的抽象層級:使用端拿到 0.941 這種數字毫無意義,
應該解包成 8 個具名的量再輸出(或至少在文件裡寫清楚編碼)。
§25 說「B/A 通道恆為 0,匯出可只留 RG」—— 那個結論仍然成立(B、A 兩個位元組的四個半位元組都是 0), 但理由不是「通道沒用」,是「這套服裝沒用到那四個參數」。
重現
python3 dump_shader.py # 解出並拆成 251 個 metallib
xcrun metal-objdump --disassemble \
_avatar/shader/Vision_Actor_Default.metallib/034.metallib
29. 片段著色器:半位元組是寫進 render target 的整數 ID
拆出來的 251 個容器裡 222 個是片段著色器,
其中 168 個同時含 _DefMap + _ShadeRampMap + 頂點色的 interpolant。
拿最小的 038 來讀。
先講一個會害人的陷阱
interpolant 的編號是逐變體的,不能跨檔案對照。
變體 034 (vertex) INTERP6 = 半位元組組 INTERP5 = 別的東西
變體 038 (fragment) INTERP5 = UV (拿去採樣 _BaseMap / _DefMap)
同一個 INTERP5,在兩個檔案裡是完全不同的東西。
Unity 每個 shader variant 各自排 interpolant,
metallib 的編號 N 與 M 之間沒有配對關係 ——
把 034 的頂點輸出直接接到 038 的片段輸入來讀,會得到完全錯的結論。
_BaseMap 與 _DefMap 共用同一組 UV
%15 = %11.xy ; INTERP5.xy = UV
%20 = fma(%15, _BaseMap_ST.xy, _BaseMap_ST.zw)
%24 = sample(_BaseMap, sampler0, %20) <- 有套 tiling/offset
%27 = sample(_DefMap, sampler2, %15) <- 直接用原始 UV, 不套 ST
_DefMap 不吃 _BaseMap_ST,表示它與 albedo 是同一套 UV 展開、逐點對齊的,
不是可以獨立平鋪的細節貼圖。
半位元組被乘回 15,當成整數索引
%120 = %12[1] ; INTERP6.y —— 頂點色的第二個半位元組
%121 = fma(%120, 15.0, 0.5) ; 把 /15 乘回來, +0.5 取整數中心
%142 = (uint)%121 ; 還原成 0~15 的整數
× 15 正好抵銷頂點端的 / 15 —— 兩邊的約定對得上,
這是 034 與 038 雖非同一變體、但共用同一套編碼的證據。
它最後被塞進 render target 的 alpha
%138 = %116[2] ; 另一個來源的整數
%140 = (%138 >> 4) & 0xFE ; 取 7 bit, 最低位留空
%143 = %142 << 4
%144 = %143 & 0x3F0 ; 取 6 bit, 放在 bit 4~9
%146 = (%144 | %140) | 1 ; 最低位設 1
%147 = (half)%146
%161 = insertelement %160, half %147, i64 3 ; -> 輸出的 .w
也就是
out.a = ((vcIndex << 4) & 0x3F0) | ((otherId >> 4) & 0xFE) | 1
這是 G-buffer 的材質 ID 通道,不是顏色。
頂點色的那個半位元組是材質/光照分類編號,
由後續的 pass 讀走(屬性表裡的 _ActorRenderMode、_VLRenderMode
指向同一個 deferred NPR 管線)。最低位的 | 1 是「這個像素有效」的旗標。
所以 §28 猜「lo_B 對應 _ShadeRampMap 的 16 列」——
這一條還沒被證實。片段端確實有把半位元組當整數索引用,
但追到的用途是寫進 alpha,不是選 ramp 列。
ramp 的選列在別的變體或別的 pass,還沒找到。
現階段能寫進匯出器的
確定 頂點色 4 位元組 = 8 個 4-bit 參數 (§28)
確定 其中至少一個是整數 ID, 會被寫進 render target 的 alpha
確定 _DefMap 與 _BaseMap 共用 UV, 但 _DefMap 不套 _BaseMap_ST
未定 哪一個半位元組對應哪個語意 (需要跨變體比對)
未定 _ShadeRampMap 的選列來源
沒有一條足以支撐「在 Blender 重建 toon shading」,
但足以讓匯出器停止把 _VCMASK 當成 4 個浮點數丟出去。
30. 動手寫模擬器:四個真 bug,但還沒到可用
sim_swing.py。輸出目前仍不正確,不要拿去出圖。
這一節記已經用證據釘死的東西。
bug 1:boneAxis 用錯骨
呼叫端是 x25 = dynamicHead[bone.childIndex],boneAxis/boneLength 從 x25 取,
是子骨的。筆記寫對了,實作用成骨自己的 local translation。
後果不是差一點:這套服裝 25 根彈簧骨裡 11 根自身位移長度為 0
(鏈根 _02_sim 與其 _01_ast 父骨完全重合),方向無定義,
from_to 產生任意旋轉 → 180° 亂翻、單格 150°+。
bug 2:cos 比的是前後兩步,不是「偏離靜止多少」
追迴圈的載入/回寫定案:
迴圈頭 0x0279304c ldp s0,s1,[x29,#-0xf0] / ldur s2,[x29,#-0xe8]
0x02793080 fsub s3, s13, s0 -> (s13,s11,s12) - (-0xf0) = pos - prevPos
迴圈尾 0x02793974 stur d7,[x29,#-0xf0] -> 新位置寫回, 供下一子步當 prevPos
0x02793a04 stur q3,[x25,#0xc4] -> 同一個值寫進 child.selfTx.translation
所以 childTx = prevPos、childDefaultTx = pos,
cos = |dot(prevPos, pos)| / (|prevPos|·|pos|) = 「這一步轉了多少」
不是「離靜止多遠」。慢速時 cos→1 → p→pendulum →
factor = stiffness - pendulum 通常是負的(0.2−1.0)。
bug 3:內迴圈是固定 1/60 秒的子步進
0x02793a20 fcmp s8, #0.0
0x02793a24 b.gt 0x02793054 <- 回跳到迴圈頭, s8 是剩餘時間累加器
30 fps 一格 = 2 個子步,力與重力每個子步各套一次。 第一版一格只跑一次,積分量少一半。
bug 4:尤拉角反函數符號寫錯
ToUnityQuaternion @0x027A1AE4 逐項核對,確認是 ZXY(q = qy·qx·qz):
out.x = cz·cy·sx + sz·sy·cx out.y = cz·sy·cx - sz·cy·sx
out.z = sz·cy·cx - cz·sy·sx out.w = cz·cy·cx + sz·sy·sx
四項全對。但我寫的反函數符號錯了,400 組隨機角往返測試 最大誤差 0.68(四元數尺度)——角度限制夾的是垃圾值。 正確版誤差 6e-16:
ex = asin(2(wx - yz))
ey = atan2(2(wy + xz), 1 - 2(x² + y²))
ez = atan2(2(wz + xy), 1 - 2(x² + z²))
順帶發現:角度限制不是可選項
CalcStiffnessPendulum 的 factor 在慢速時是負的,力沿骨軸向內,
經骨長硬約束之後在角度上等於沒作用 —— 系統裡沒有任何角度回復力。
於是重力一路贏,緞帶整根垂下去(實測 idle 偏離 120°)。
把 limitInfo 加進去之後偏離降到 11.2° —— 也就是說
limitInfo 才是把裙擺撐住的機制,不是彈簧項。
§20 把它列成「已解但可選」是低估了。
第五個 bug 在我的量測工具裡
我一度回報「idle 逐格變化中位數 11.99°,站著不動不該有這種抖動」。 那個數字量錯了 —— 我算的是「每根骨的逐格最大值,再取各骨的中位數」, 一個起始沉降的尖峰就把整根骨的統計拉滿。
改成先取每根骨的逐格中位數再彙總:
idle 平均偏離 14.9° 逐格變化中位數 0.197° 最大單格尖峰 24.1°
run 平均偏離 21.4° 逐格變化中位數 2.709° 最大單格尖峰 45.0°
逐格 0.2°/2.7° 是平順的。抓一根骨逐格印出來看更清楚:
jnt_L_skirt02_03_sim (idle) 與靜止的夾角
4.2 8.3 11.6 14.4 16.8 19.2 22.0 24.3 26.6 28.7 30.0 30.0 30.0 …
逐格差
4.1 3.4 2.8 2.4 2.3 2.8 2.3 2.3 2.1 1.3 0.0 0.0 0.0 …
在重力下滑到 30°(= limitInfo.axisY 的上限)就完全靜止,一點抖都沒有。
另一根 jnt_L_skirt02_04_sim 則是升到 23° 再鬆回 14°,是有阻尼的過衝 —— 合理。
idle 剩下的尖峰集中在第 276~287 格,那是烘焙資料尾端角色轉出姿勢的真實動作 (§「依 clip 真實長度裁切」已知 300 格裡只有前 35% 是 clip 本體),不是模擬問題。
這是這一輪最該記的教訓:量測工具本身也要驗。 我拿一個算錯的統計量當成「模擬不對」的證據, 差點回頭去改一個其實是對的模擬。
現況
可用 積分 / 骨長約束 / 角度限制 / 1-60 子步進 / 預熱
未實作 碰撞、鏈骨平滑、spring 鏈耦合、_ast 的 QuartzDriver、風
未驗證 與遊戲實際畫面的逐幀比對 (沒有 ground truth)
穩定性沒問題,正確性沒有驗過 —— 這兩件事不能混為一談。
要驗只能回頭 hook 遊戲讀 _sim 骨的旋轉來比。
31. 渲染驗收與第一份含彈簧骨的成品
scripts/render_sheet.py
§24 記下的缺口:數值驗得嚴、渲染層完全沒驗。補上了。
python3 scripts/render_sheet.py <glb> <out.png> --clip run00 \
--frames 0,20,40,60 --view front|side|3q [--ref 上一版.png]
--ref 會報「變動像素比例 / 平均差 / 最大差」,
用來確認改了 A 只影響 A —— 這正是 alphaMode 那個 bug 缺的那一關。
寫的過程自己踩了兩個坑,都被這支工具本身抓出來:
1. 手算 side view 的 rotation_euler -> 相機看向角色反方向, 渲出破碎畫面
改用 TRACK_TO 對準包圍盒中心, 不再手算
2. o.data.vertices[::37] -> bpy_prop_collection 不支援 slice step
改用 bound_box 的 8 個角
還澄清了一個誤判:側視圖看起來「頭與身體斷開」, 正面渲出來是好的 —— 那是跑步姿勢下脖子被肩膀擋住 + 純黑背景的錯覺。 逐項查過 channel 結構(兩個 clip 都是 54 個節點、hips 有 translation、 脖子與頭都有 rotation),骨架沒有問題。
差點又要去修一個沒壞的東西。換個視角再看一次比直接動手便宜太多。
第一份含彈簧骨的成品
mdl_chr_drs_00001-cmmn-0000-00_body.glb
-> add_animation.py 549 個動作 (烘焙, 逐格精確)
-> add_facial.py 眼/眉/虹膜 morph (CUBICSPLINE, 誤差 1.6e-06)
-> sim_swing.py 14 根彈簧骨 19 分鐘
= hololive_549_full.glb 182 MB
與無彈簧骨版本逐像素比(run00,正面,四格):
變動像素 3.42% 平均差 2.38 最大差 217
3.4% 是合理的量級 —— 裙擺與緞帶只佔畫面一小塊。 目視上緞帶會拖曳、裙片不再僵直外翹。
但仍然只有「穩定且合理」,沒有「正確」:
沒有 ground truth,這份輸出沒有與遊戲畫面比對過。
碰撞、鏈骨平滑、spring 鏈耦合、_ast 的 QuartzDriver 都還沒實作。
32. 頭髮終於接上了
到 §31 為止所有渲染圖裡角色都是禿的 —— _body 與 _hair 是兩個獨立 bundle。
兩副骨架沒有任何共用骨名
body … jnt_C_neck00_00 -> jnt_C_head00_00 -> {eye, cheek, FacialDecal, …}
hair jnt_C_headHair00_00 T = (0, 0.9134, -0.0083)
-> jnt_C_hair00_00_ofs / jnt_L_hairBangs00_00_ofs / loc_*_hairAcc / …
所以不能靠骨名對應。jnt_C_headHair00_00 的位移是角色空間的絕對位置
(0.9134 就是頭部高度),不是相對頭骨的偏移。掛上去要換算:
local = inverse(head 綁定世界變換) × hairRoot 綁定世界變換
直接把 translation 抄過去,頭髮會留在原地不跟著頭轉。
併檔踩到的坑:節點不能有兩個父
第一版把頭髮的網格節點加進場景根,卻沒清掉它原本父節點(hair bundle 的空根)
的 children。於是同一個節點同時掛在孤兒根與場景根底下,
Blender 的 glTF importer 直接
io_scene_gltf2/blender/imp/vnode.py:129 AssertionError
(沒有訊息,只有 assert。是靠印 stderr 才看到位置。)
修法是把舊根的 children 一併清掉。
頭髮的彈簧骨參數在自己的 bundle 裡
extract_swing.py 原本寫死只掃 _body。加上 --suffix _hair 之後:
mdl_chr_drs_00001-*-hair ActorSwingDynamicBone × 23
sim_swing.py 直接吃這份,17/23 根對得上 GLB 節點
(其餘是鏈尾無子骨或子骨重合,與身體那邊同樣的原因)。
產出
python3 merge_hair.py <body.glb> <hair.glb> <out.glb>
python3 extract_swing.py --suffix _hair --out _avatar/swing_hair
python3 sim_swing.py <out.glb> <final.glb> \
--swing _avatar/swing_hair/<同一角色>_hair.json
hololive_549_hair.glb 191.8 MB(549 動作 + 臉部 + 身體彈簧骨 + 頭髮網格)。
再跑一次 sim_swing.py 把頭髮的 17 根彈簧骨也算進去
-> hololive_549_final.glb 228 MB,與只有靜態頭髮的版本相比
變動像素 4.34%、平均差 2.11(跑步時側馬尾會甩)。
目前的完整管線:
body bundle ──┐
├─ merge_hair.py ─┐
hair bundle ──┘ │
├─ add_animation.py 549 動作 (烘焙)
├─ add_facial.py 眼/眉/虹膜 morph
├─ sim_swing.py 身體 14 根
└─ sim_swing.py 頭髮 17 根
-> hololive_549_final.glb 228 MB
順帶一提:頭髮有 5 個變體 bundle(base / cmmn×2 / nrml / uniq), 這裡只併了
base。要換髮型就換來源檔,流程一樣。
33. Shader:ramp 的 V 座標是常數 0,推翻 §28 的猜測
用 metal-objdump 讀了三個含 _ShadeRampMap 取樣的片段變體(100 / 150 / 200)。
_ShadeRampMap 的 UV
變體 150 / 200
%403 = dot(N, L)
%404 = fma(%403, 0.5, 0.5) ; half-Lambert
%406 = clamp(%404, 0, 1)
%407 = <2 x half> { %406, 0.0 } ; <- V 是**字面常數 0.0**
sample(_ShadeRampMap, %407)
變體 100 U 多了陰影衰減的 lerp/min, V 仍是 0.0
U = clamp(dot(N, L) × 0.5 + 0.5, 0, 1) 橫向 128 階查表
V = 0.0 恆為第 0 列
§28 猜「lo_B 選 _ShadeRampMap 的 16 列之一」—— 這一條被推翻了。
三個變體的 V 都是編譯期常數 0,沒有任何東西在選列。
形狀相符(4-bit 的 0~15 對 16 列)只是巧合,我當時已經標了「尚未證實」,
現在可以改成「已否證」。
其餘 15 列在這條 pass 裡用不到 —— 可能給別的 pass、
別的材質槽(_DetailRampMap 是獨立的槽),或根本是預留。還不知道。
_DefMap 的通道
%105 = _DefMap 的取樣結果 (half4)
%143 = %105.rg
%146 = %143 × _DefValue.xy <- 材質參數 _DefValue 的 xy 縮放 rg
%167 = %105.b
%172 = fma(%167, %87.z, -%170) <- b 進另一條 fma
%105.a 在這個變體裡沒被讀
所以 _DefMap 的 rg 由材質的 _DefValue.xy 加權,
b 另有用途,a 至少在變體 200 沒用到。
_DefValue 是 41 個屬性裡的一個 float4,這條連得起來了。
半蘭伯特是重建 toon shading 的第一塊拼圖
albedo = _BaseMap(uv) uv 來自 INTERP, 套 _BaseMap_ST
shade = _ShadeRampMap(clamp(NdotL·0.5+0.5, 0, 1), 0)
_drp 貼圖第 0 列(§25 印過)是
(0.20,0.78,1.00) → (0.52,0.92,0.94) → (0.99,0.96,0.73) → (1.00,0.53,0.85)——
暗部偏藍青、亮部偏暖粉,正是動畫風格的冷暗暖亮 ramp。內容與用途對得上。
這一段已經足以在 Blender 用「NdotL → 半蘭伯特 → 查 ramp 貼圖」重建基本卡通著色。
_DefMap 各通道的完整語意、描邊 pass、stencil 都還沒解。
34. 真值到手:模擬比不做還差
錄真值的工具
agent.bakeSwing + scripts/bake_swing_truth.py。
與 bakeMotion 同一套機制(Manual graph + 逐格 Evaluate),
差別在走 Transform 階層用名字找 _sim/_ast 骨,
而不是 GetBoneTransform(i)(那只回 HumanBodyBones 的 55 根,
彈簧骨一根都拿不到 —— baked.json 只有 51 根就是這個原因)。
彈簧骨的 IAnimationJob 掛在同一張 graph 上,Evaluate 會連物理一起跑。
踩到三個坑,都是「沒報錯但結果是錯的」那種:
1. attach 逾時 遊戲在背景被 iOS 掛起, 行程列得到但注入不進去
2. record_frame() 少傳 animIndex -> Math.min(undefined,n)=NaN
-> humans[NaN] = undefined
訊息只有 "cannot read property 'method' of undefined"
3. play_loaded() 少傳 address -> _loaded[undefined] 找不到
**回傳 error 但腳本沒檢查**, 於是錄到的是角色
原本在做的動作, 不是我要的 clip
4. __swingBones 快取 沒跟著 animator 失效, 換 actor 還是回上一個的骨列表
(13 個 actor 全回同一份, 差點據此下錯結論)
第一次比對是無效的
錄到的角色有 fur01 / rope00 / ornament01 / handSleeve00 這些骨,
我們的模型(drs_00001-cmmn-0000)沒有 —— 不是同一套服裝。
拿 A 的模擬去比 B 的物理,得到的 204% 沒有意義。
用骨名反查才定位到真正的服裝:
mdl_chr_drs_00010-nrml-0010-00_body 51/51 根彈簧骨全部出現在真值裡 (100%)
重新匯出那一套、跑同一個 clip 的模擬,才是有效比對。
有效比對的結果
33 根共同骨 × 90 格 run00
誤差中位數 13.36°
真值偏離靜止 9.13°
誤差 / 動作幅度 146%
146% 表示模擬比「什麼都不做」還差。 如果把彈簧骨留在綁定姿勢,誤差就是真值自己的偏離 9.13°; 我的模擬做出 13.36° 的誤差 —— 動了,但方向與幅度都不對。
逐骨看更清楚:裙擺末端真值只偏離靜止 616°,
我的模擬(§30 量過)平均偏離 1521° —— 明顯過擺。
與 §30 發現的「系統裡沒有角度回復力、全靠 limitInfo 撐住」完全吻合:
重力把骨推到限制角,就卡在那裡;遊戲裡卻是小幅度擺動。
這推翻了什麼
§30/§31 說「穩定且合理,但未驗證正確」—— 現在驗了,不正確。 「逐格 0.2° 很平滑」「目視上緞帶會拖曳」這些都是真的, 但平滑與好看都不是正確的證據。這正是當初堅持要 ground truth 的理由。
下一步很明確
誤差主要來自過擺,所以優先補會抑制擺幅的機制:
1. spring (子骨速度傳遞) 鏈上下游耦合, 目前完全沒做
2. 碰撞 裙擺撞到腿會被擋住, 擺不出去
3. 鏈骨平滑 環狀鏈互相拉住
4. _ast 的 QuartzDriver 鏈根目前是剛性跟隨 hips
現在有了 compare_swing.py,每補一項都能直接量出誤差降多少,
不必再靠猜。
第一項:spring —— 我先寫錯了結論,這是更正版
實作 force += childSpeed × spring 後再比一次,總體數字一模一樣:
補之前 誤差中位數 13.36°
補之後 誤差中位數 13.36°
我當下就下了「spring 完全沒效果,因為鏈只有兩節、尾骨遊戲自己也跳過」的結論。
那是錯的,而且是我沒查資料就編出來的解釋。
實際去比兩支模擬的輸出:
jnt_R_rope01_04_sim 最大差 76.2° 中位 38.4°
jnt_L_rope01_04_sim 最大差 74.5° 中位 33.4°
jnt_L_rope01_03_sim 最大差 61.9° 中位 29.4°
jnt_R_skirt03_02_sim 最大差 0.06° 中位 0.00°
spring 的效果非常大,只是集中在 rope01 那條 4 節長鏈上;
裙擺是 3 節鏈、子骨速度很小,幾乎沒變。
鏈長分布實際是「3 節 × 11 條、4 節 × 2 條、2 節 × 5 條」——
不是我說的兩節。
總體數字沒動,是因為它是 33 根骨的中位數 —— 4 根 rope 骨大幅改變,動不了中位數。
對那 4 根骨而言是有改善的:
補之前 補之後
L_rope01_04 38.89° -> 17.92°
R_rope01_04 24.31° -> 27.61°
L_rope01_03 31.13° -> (掉出前 5, < 27.6°)
教訓:用單一總體指標判斷「有沒有效」會漏掉局部的大改變, 而「總體沒動」很容易誘使人去編一個「所以它沒作用」的解釋。 正確做法是比兩次輸出本身,不是只看彙總的誤差。 這與 §31「量測工具本身也要驗」是同一類錯誤,我又犯了一次。
第二項:碰撞 —— 實作了,但讓誤差變差
照 §「碰撞如何回饋到位置」實作球 vs 錐形膠囊 + 位元遮罩 + 推開後重投影:
補之前 (只有 spring) 誤差中位數 13.36° 146%
補之後 (加碰撞) 誤差中位數 14.17° 155%
確實有在作用 —— 逐骨比兩次輸出,84 根裡 24 根有變,最大 87°。 所以不是「沒生效」,是方向不對。
過程中修掉一個真 bug:靜態碰撞體的線段怎麼取,要看型別。
type 1 Capsule 兩端 = 骨的局部偏移 vector3_A / vector3_B
jnt_C_spine00_01 的 B = (0, 0.295, 0) 沿軀幹往上
type 2 Line 線段 = 骨 -> 父骨
第一版一律用「骨 → 父骨」,對 Capsule 完全錯位 (軀幹的膠囊會變成往下指到 hips)。修好之後總體數字沒變, 表示這條路徑不是主要誤差來源。
目前的判斷
裙擺誤差的主因還是過擺(真值偏離靜止 616°,模擬 1521°),
而碰撞只在骨已經撞到身體時才起作用 —— 擺幅本身不對,碰撞救不了。
真正缺的應該是擺幅抑制:§30 已經發現
「CalcStiffnessPendulum 的 factor 在慢速時是負的,
沿骨軸向內、經骨長約束後角度上等於沒作用」——
也就是我對 delta 的解讀可能仍然不對。
delta = rotate(selfTx.rotation, boneAxis) 是從 §「更正版」讀出來的,
但如果 selfTx 在遊戲裡是上一子步的模擬結果而不是動畫靜止姿勢,
那 delta 就不是「回到靜止的方向」,整條回復力的語意會完全不同。
下一步不該再加子系統,而是回頭把 selfTx 在迴圈裡的來源釘死。
一直加東西而誤差不降,通常代表底層有一項讀錯了。
剩下:鏈骨平滑、_ast 的 QuartzDriver。
35. 釘死 selfTx:它在迴圈裡會被改,所以 delta 是當前方向不是靜止方向
§34 結尾的懷疑得到了證實。把內迴圈(0x02793028 ~ 0x02793b20)裡對
selfTx.translation([x29-0xd0] / -0xc8)與
selfTx.rotation([x20+0x7c])的所有存取按位址排出來:
027930a8 讀 ldur q4, [x29,#-0xd0] <- 迴圈入口, 傳給 CalcStiffnessPendulum
027930b0 讀 ldur q4, [x20,#0x7c]
...
0279362c 寫 stur q0, [x20,#0x7c] <- selfTx.rotation 被覆寫
...
02793960 寫 stp s6, s4, [x29,#-0xd0] <- selfTx.translation 被覆寫
02793964 寫 stur s5, [x29,#-0xc8]
027939d0 寫 stur d1, [x29,#-0xd0] <- 再覆寫一次
...
02793a08 讀 ldur q3, [x29,#-0xd0] -> 寫進 bone.selfTx (迴圈尾)
02793a10 讀 ldur q3, [x20,#0x7c]
selfTx 是迴圈攜帶的狀態,不是每個子步都重讀動畫。
所以第 N 個子步進入時,selfTx 裝的是第 N−1 個子步的模擬結果。
這推翻了 sim_swing.py 的核心假設
delta = rotate(selfTx.rotation, boneAxis)
selfTx 是當前模擬姿勢 → delta 是當前方向,不是「回到靜止的方向」。
而 force = delta × (stiffness − p) × 0.01 沿當前方向 = 純徑向,
經骨長硬約束正規化之後,角度上等於沒作用。
也就是說:這個系統裡本來就沒有「把骨拉回靜止姿勢」的力。 §30 當時觀察到這個現象、以為是自己讀錯,其實讀對了 —— 角度上的回復完全來自另外兩件事:
1. 每一格開頭 selfTx 由動畫骨架重算 (0x02792e24 附近的 op_Multiply)
父骨跟著動畫走, 骨長約束把子骨拖過去
2. limitInfo 把偏離夾在範圍內
sim_swing.py 目前用不含模擬回饋的動畫姿勢算 rest_dir,
等於憑空多給了一個角度回復力 —— 方向雖然「合理」,但遊戲裡沒有這一項。
而它又同時少了「selfTx 在子步之間持續演變」這件事。
兩個錯疊在一起,才會出現「加什麼子系統誤差都不降」。
下一步(明確且唯一)
改 sim_swing.py:
1. selfTx 改成迴圈攜帶狀態 —— 每格開頭由動畫骨架初始化,
之後每個子步用上一子步的模擬結果, 不要每子步重算
2. delta 改用當前模擬方向 (rotate(目前的 selfTx.rotation, boneAxis)),
不要用靜止方向
3. 改完直接跑 compare_swing.py 看誤差
改完量了:更差
把 delta 改成當前模擬方向(--delta-current),四種組合全量一遍:
組合 誤差 / 真值動作幅度
(預設: 動畫靜止方向, 無碰撞) 146%
--collision 155%
--delta-current 195%
--collision --delta-current 195%
§35 讀出來的東西(selfTx 是迴圈攜帶狀態)沒有錯,
但照它改讓結果更糟。兩個都成立,只能得出一個結論:
我對「哪一項提供角度回復力」的整體理解仍然缺一塊。
拿掉那個(遊戲裡不存在的)回復力之後,重力就毫無阻礙地把骨推到
limitInfo 的邊界卡住 —— 195% 就是這個。
遊戲裡骨只偏離靜止 6~16°,表示有某個東西在抑制擺幅,而我還沒找到它。
候選四項,這一輪查掉一項、又挖到一個新的:
1. 重力可能不是每個子步 mass × 0.01 —— 也許有別的縮放 還沒查
2. limitInfo 可能是「柔性拉回」而不是「硬夾」 還沒查
3. bakeAnimationWeight 若非 0 就是動畫在拉住彈簧骨 **已排除**
4. referenceLimitInfo / seatDynamicCorrection 等欄位 還沒查
5. 力不是直接加到位置, 中間還有一層累加器 **新發現**
3 已排除:bakeAnimationWeight 在 run00 是全 0
run00 的 bakeAnimationWeight 曲線 44 條, 非零 0 條
全庫 23,180 條, 非零 239 條 (1.03%)
非零最多的是 idle-add00 / sit-drink / run-bank 這類
比對用的 run00 完全沒有動畫在拉彈簧骨。這條路斷了, 但花的成本只是一次離線查詢 —— 比再改一次模擬便宜太多。
5 新發現:力先進累加器,不是直接加到位置
ProcessDynamicBones 0x027933e8~0x02793410:
027933e8 fmul s1, s0, s5 ; 力 × step
027933ec fmul v0.2s, v4.2s, v0.s[0]
02793404 fmul v13.2s, v0.2s, v2.s[0] ; **又乘一次** [sp+0x140]
02793408 ldur d0, [x20, #0x54] ; 讀 [x20+0x54]
0279340c fadd v4.2s, v13.2s, v0.2s ; 累加
02793410 stur d4, [x20, #0x54] ; 寫回
× step 之後還有第二個乘數 [sp+0x140],
而且結果不是加到位置,是累加進 [x20+0x54] 這個狀態。
我的模擬是 new = cur + inertia + force × step,
少了第二個乘數,也少了這個累加器 —— 結構就不一樣。
兩個都查出來了
[sp+0x140] = managePropertyData.swingPowerWeight
02792594 str x2, [sp,#0xd8] x2 = 第二個參數 = managePropertyData
0279268c ldr x8, [sp,#0xd8]
02792690 ldp s1, s0, [x8] s1 = 欄位 0, s0 = 欄位 4
02792698 str q0, [sp,#0x140] <- 欄位 4
對照 ActorAnimationManagePropertyData 的布局:
0x000 float weight
0x004 float swingPowerWeight <- 就是它
0x008 float rootHorizontalWeight
0x00c float rootVerticalWeight
0x010 float3 globalForce
0x01c float3 windDirection
0x028 float windPower …
整包力還要再乘一個全域的 swingPowerWeight。
0x08 / 0x0c 是 rootHorizontal/VerticalWeight,正好印證 §「根部修正」
當時讀到的「manage 的 offset 8」——布局對得起來,不是巧合。
我的模擬把這個乘數當成 1。如果遊戲裡它是 0.3 之類的值, 擺幅差三倍以上就有了直接解釋。這是目前最可能的主因。
x20 是堆疊暫存, 不是結構指標
027926f4 add x20, sp, #0x3a0
所以 [x20+0x54] = sp+0x3f4,是個函式內的區域累加器,
[x20+0x7c] = selfTx.rotation 的暫存位置。
累加器只在 0x02793408(讀)與 0x02793410(寫)出現,
在這個函式裡沒有被消費 —— 應該是之後整批寫回骨結構的欄位,
還沒追到是哪一個。
量了:swingPowerWeight = 1,這個假設也排除
agent.swingGlobals() 從遊戲讀 ActorAnimationManageData.propertyData:
weight 1.0
swingPowerWeight 1.0 <- 我的模擬本來就當 1, 沒有差
rootHorizontalWeight 1.0
rootVerticalWeight 1.0
windPower 0.7 <- **非零**
先量再改是對的 —— 如果直接照「乘上去」改,會白改一輪。
但量出來的東西指向兩個新的缺口
1. 根部修正我完全沒實作
GetRootCorrectionCancelPosition 的 h = 1 − a × rootHorizontalWeight,
現在知道 a = manage.rootHorizontalWeight = 1.0,
配上裙擺骨自己的 rootHorizontalWeight 0.2 / rootVerticalWeight 0.4:
h = 1 − 1 × 0.2 = 0.8
v = 1 − 1 × 0.4 = 0.6
也就是角色根部的位移有 20%(水平)/ 40%(垂直)不會傳給彈簧骨。
sim_swing.py 一點都沒做 —— 骨被身體的移動整個拖著走。
比對用的是 run00,角色 hips 位移範圍 0.24 m, 少了這個抵銷,擺幅被高估是必然的。這與觀察到的過擺方向完全一致。
2. windPower = 0.7,風是開著的
我的模擬 wind 項是 0。不過風會增加擺動,
而我的問題是擺太多,所以這一項不是主因,補了可能更糟 —— 先擱著。
補上根部修正:整個 session 第一次誤差下降
組合 誤差 / 真值動作幅度
(舊預設) 146%
--rootcancel 142% <- 唯一有改善的一項
--rootcancel --collision 142%
改善不大(146% → 142%),但方向對,而且是這幾輪唯一往下走的。
已改成預設開啟,要關用 --no-rootcancel。
實作時對公式做了一個未經驗證的解讀,必須標明:
遊戲傳給 GetRootCorrectionCancelPosition 的是 hipsTx.translation。
若那是絕對位置 (y ≈ 0.55), 乘上 v=0.6 會把錨點整個搬走 —— 顯然不對。
所以我用「這一格 hips 的位移量」當輸入。物理上合理, 但**沒有驗證**。
如果之後證明該用別的量,這一項要重做。
這條線目前的全貌
誤差 / 真值動作幅度
146% 基準 (spring + 角度限制 + 骨長約束)
142% + 根部修正 <- 唯一改善
155% + 碰撞 (變差)
195% + delta 用當前方向 (變差, 儘管二進位是這樣讀的)
還沒做:風(windPower = 0.7 是開著的,但風增加擺動、方向不對,先擱著)、
鏈骨平滑、_ast 的 QuartzDriver、力的累加器 [x20+0x54]。
仍然是 142%,也就是還是比不做(100%)差。 主要落差仍是過擺,而目前所有已知機制都補不上這個差距 —— 最可能的答案在那個還沒追完的累加器: 力先變成某種速度狀態再影響位置,本身就是阻尼。
目前的取捨
三個實驗(spring / 碰撞 / delta 語意)都留在程式裡,
但預設走實測最好的那組(146%),另外兩項要用旗標才會開:
python3 sim_swing.py … [--collision] [--delta-current]
把「讀對了但結果更差」的東西預設關掉、而不是刪掉, 是因為它們很可能在補上缺的那一塊之後才會變成正貢獻。 旗標與實測數字都留在這裡,下次不必重跑。
這一輪的教訓:「二進位讀對」不等於「模型正確」。 §35 的讀法我有把握是對的(存取序列擺在那裡), 但把它單獨接進一個還缺別的機制的模型裡,結果反而退步。 逆向可以逐條驗證,整合不行 —— 整合只能靠 ground truth 量。
相關檔案
scripts/frida_agent.ts frida-il2cpp-bridge agent 原始碼
scripts/drive_motions.py 主動觸發**已載入**的動作並錄真值
scripts/drive_arbitrary_motions.py 自行載入並播放任意 mot_define (可續跑)
fit_and_validate_clips.py 跨 clip 切分的擬合與驗收 (兩段切分, 已被 fit_final 取代)
fit_final.py 三段切分 + 逐骨選模型 (最終管線)
probe_euler_order.py 12 種尤拉順序 (含對照組)
probe_nonlinear.py 仿射 vs 二次 (含對照組)
probe_muscle_select.py 資料驅動選肌肉 vs 校準選肌肉
probe_restarts.py 40 個隨機起點的誤差分布
probe_init.py 解析初始值 vs 隨機起點 (無效)
probe_floor.py 最佳值隨起點數的收斂 -> 5 個起點就到底, 是地板
scripts/bake_motions.py 確定性烘焙 (Manual graph + Evaluate)
scripts/merge_baked.py JSONL -> 單一 JSON
scripts/render_anim.py Blender 渲染驗證 (依名稱挑動畫)
scripts/watch_stability.py 被動觀察遊戲穩定度 (只讀不寫)
scripts/render_sheet.py 渲染排格對照 + 逐像素差異 (渲染層驗收)
scripts/simd_sym.py ARM64 NEON 符號執行器 (確認向量運算的分量對應)
add_animation.py 把烘焙結果寫進既有 GLB
add_facial.py 把臉部 blendshape 曲線寫進 GLB (精確三次)
sim_swing.py 離線彈簧骨模擬 (穩定但不正確, 見 §34)
compare_swing.py 模擬 vs 遊戲真值逐幀比對
scripts/bake_swing_truth.py 從遊戲錄彈簧骨真值
verify_facial.py 獨立重寫兩邊求值, 逐點驗收臉部動畫
extract_facial_decal.py 嘴型貼花切換 + 六個投影器的淡入淡出
dump_shader.py 解出角色 shader 的 Metal 原始碼 (分段 LZ4)
agent/ frida agent 原始碼與編譯產物
_avatar/driven_clean.json 528 clip / 21054 幀, graph 凍結取樣
_avatar/baked.json 烘焙輸出 (30fps, 逐格)
_avatar/driven_all_torn.json 549 clip / 21895 幀 (有取樣錯位, 保留對照用)
_avatar/driven_clean.json 549 clip, graph 凍結取樣 (torn 97.6% 為 0)
scripts/dump_avatar.py 骨骼映射表 / API 探查
scripts/watch_character.py 啟動遊戲並等待場上出現角色
scripts/freeze_test.py 驗證骨骼真的停止被寫入 (不過就不校準)
scripts/calibrate_muscles.py 凍結 -> 主動擺骨骼 -> 讀回肌肉值
scripts/record_frames.py 錄真實動畫幀 (肌肉值 + 全骨旋轉)
solve_muscle_map.py 用校準資料擬合 (已被 fit_from_frames 取代)
fit_from_frames.py 用真實動畫幀擬合 + 時間切分驗證
scan_avatars.py 全量掃描 bundle 內的 Avatar 資產
_avatar/bone_map.json 骨骼映射表 + API 可用性 + 骨架一致性結論
_avatar/muscle_map.json 擬合出的 muscle -> 骨骼旋轉模型
尚未完成
盤點的基準是「能不能在遊戲外重現角色的畫面」。
1. 彈簧物理解出來了,但沒有實作 —— 目前最大的缺口
§20~§21 把 ActorSwing 的公式全部解出來了(碰撞、骨長約束、
軸間耦合、鏈骨平滑、Slide/Swing 分流),
extract_swing.py 也把 279 套服裝的參數抽齊了。
但沒有把它寫成程式跑過。
具體症狀:GLB 裡有 31 根 _sim 與 12 根 _ast 骨,
549 個動畫沒有任何一條 channel 驅動它們 ——
裙擺與頭髮是完全不動的。
要做的是:離線實作積分器 + 碰撞, 吃烘焙好的 humanoid 骨動畫當驅動,算出這 43 根骨的逐格旋轉, 再寫進 GLB。
2. 端到端驗證還沒做
物理實作出來之後,必須與遊戲實際畫面逐幀比對。 公式從組合語言讀對了,不等於整條管線接對了 —— §21 的臉部就是例子:四個邊界條件全錯過一輪, 是靠逐點比對才抓出來的。
比對方式:Hook 遊戲讀 _sim 骨的世界旋轉,
與離線算出來的同一個 clip 逐幀比。
3. 頭髮模型沒接上
mdl_chr_drs_*_hair 是獨立的 281 個 bundle,
目前的 GLB 只有 body。頭髮的彈簧骨參數已經抽出來了,
但網格本身還沒併進去。
4. 只驗過一個角色
骨骼映射、動畫、blendshape 都只在 mdl_chr_drs_00001 這一套上驗過。
149 個 clip 的 blendshape 前綴是 b_eye1/b_brow1(另一組命名),
表示至少有第二種模型 —— 骨架與形狀名是否真的通用,還沒驗證。
5. 相機動畫完全沒碰
20 個 mot_define_*_cam_*(translation / rotation / 視野等),
曲線已經解出來在 _extracted_motions_3d_v2 裡,
但沒有轉成 glTF camera animation。
6. ProcessChainBoneSeatedDynamicCorrection 逐式未解
結構已解(見 §20),逐式沒解完。 6940 根骨裡只有 1 根用得到,優先度最低。
7. 工具化
原始目標是做成通用工具 hohohololive。
現在是一堆各自可用的腳本 + 一份筆記,還沒收斂成介面。
已經不再是缺口的(記錄用)
烘焙全部 549 個 clip—— 完成(§16)把骨骼動畫接到 GLB—— 完成,549 個動畫臉部(眼/眉/虹膜)—— 完成(§21),誤差 1.6e-06嘴型—— 完成(§22),精確 one-hot 階梯那 16 根骨的模型形式 / 肌肉值擬合—— 這條路整個被取代了。 當初是想從肌肉值反推骨骼旋轉,六個假設全部否定(§14)。 後來改成直接把PlayableGraph切 Manual 逐格Evaluate烘焙, 拿到的就是引擎自己算出來的旋轉,不必擬合。 擬合線上的未解項(16 根骨的形式、端到端驗證、雙眼無資料) 因此都不再是缺口 —— 不是解決了,是不需要了。
36. 角速度累加器:過擺被治好了,誤差卻沒降 —— 主因不是幅度,是軸
§35 結尾把希望押在 [x20+0x54] 那個兩分量累加器上:
力乘 step 之後不是加到位置,而是累加進一個狀態 ——
結構上就是角速度,而有速度狀態的積分器本身就是阻尼,
「足以解釋 2~3 倍的過擺」。
這一輪把它實作了。結論是:假設的前半對、後半錯,而且推翻了「過擺是主因」。
先重現基準
基準 (--rootcancel 預設開) 誤差中位數 12.93° 真值偏離 9.13° 142%
與 §35 記的 142% 完全一致,環境沒漂。
實作
sim_swing.py 新增 --vel {off,add,replace} 與 --vel-damp:
vel[ni] = vel[ni] * (1 - vel_damp) + force * step
new = cur + inertia + vel[ni] * step # add
new = cur + vel[ni] * step # replace (vel 取代 Verlet inertia)
量到的
組合 誤差/幅度 過擺倍率 (模擬偏離靜止 / 真值偏離靜止)
off (基準) 142% 1.50x
--vel add 153% 1.08x
--vel replace 151% 1.09x
過擺確實被治好了(1.50x → 1.08x,幅度幾乎完全對上), 但總誤差反而變差 11 個百分點。
對照組:過擺倍率這個指標本身不夠格
compare_swing.py 這次加了「模擬偏離靜止」與過擺倍率。
拿 --prewarm 當對照組(純粹是暖機步數,與積分器結構無關):
--prewarm 30 (預設) 142% 1.50x
--prewarm 15 142% 1.50x (已收斂)
--prewarm 45 142% 1.50x
--prewarm 0 142% 1.07x <- 過擺一樣被「治好」, 誤差一格沒動
--prewarm 0 把過擺倍率壓到 1.07x,而誤差原地不動 142%。
也就是說「把擺幅壓對」這件事有兩條互不相干的路都做得到,
而兩條都不會讓誤差下降。
這是 §34「用單一總體指標判斷有沒有效」那個教訓的反面版本: 這次我補的新指標動了(1.50→1.08),但對照組證明它動了不代表模型變對。 新指標要跟舊指標一起看,而且要有對照組。
那誤差到底是什麼?把它拆成「軸」與「幅度」
新增 scripts/diag_swing_error.py:把每根骨相對綁定姿勢的 local rotation
拆成 旋轉軸 × 旋轉角,分別比。
軸夾角中位數 幅度比中位數 幅度時間相關中位數 誤差/幅度
基準 88.6° 1.36 -0.01 142%
--prewarm 0 87.2° 1.28 -0.04 142%
--vel add 92.2° 1.26 -0.07 153%
模擬的旋轉軸與真值幾乎正交(~88°),而且幅度隨時間的相關係數是 0。 不是延遲、不是阻尼、不是擺幅 —— 模擬與真值是兩個不相干的訊號。
兩個對照組,證明上面那句不是量測工具的錯
一、座標約定(scripts/diag_swing_convention.py)。
真值 → glTF 若鏡射錯了,一切下游都是垃圾。掃六種約定:
mirrorX (現用) 142% <- 最小, 而且真值偏離靜止也最小 (9.13°)
mirrorZ 257%
原樣 289%
conj 291%
mirrorX+conj 326%
mirrorY 351%
現用的 mirrorX 決定性勝出,142% 是真的,不是量測假象。
二、診斷工具自身(scripts/diag_swing_selfcheck.py)。
拿真值自己平移 k 格當「模擬」,看純相位誤差能做出多大的軸夾角:
位移格數 軸夾角中位 幅度相關 誤差/幅度
0 0.0° 1.00 0% <- 工具正確
1 9.9° 0.68 38%
3 33.9° -0.00 91%
5 44.8° -0.30 104%
45 48.2° -0.02 109% <- 最壞情況
純相位誤差最多做出 ~48° 軸夾角、~109% 誤差。 我們的模擬是 88.6° / 142% —— 超出「形狀對但時機錯」所能解釋的範圍。
軸錯在哪:scripts/diag_swing_axis.py
對每根骨取旋轉向量的主軸(SVD 第一主成分):
真值主軸 模擬主軸 主軸夾角
jnt_R_skirt02_03_sim [-0.01 -1.00 0.01] [ 0.01 -0.02 -1.00] 89.2°
jnt_L_skirt01_03_sim [ 0.03 1.00 -0.05] [ 0.01 -0.02 -1.00] 88.2°
jnt_L_skirt02_03_sim [ 0.02 1.00 0.03] [ 0.01 -0.02 -1.00] 87.3°
…
jnt_R_ribbon00_02_sim [-0.03 -0.99 0.12] [ 0.06 -0.98 0.20] 6.8° <- 這幾根是對的
jnt_R_skirt00_03_sim [ 0.03 0.05 1.00] [-0.01 0.02 -1.00] 3.9°
主軸夾角中位數 60.3° 真值集中度 0.63 模擬集中度 0.71
兩個具體事實:
- X 分量兩邊都 ≈ 0(|x| < 0.15)。兩邊都被限制在 YZ 平面內,
與
limitInfo對每一根都只放行Y,Z完全吻合。這一段是對的。 - 錯的是 YZ 平面「之內」的方向。 裙擺末端
_03真值繞 local Y、 模擬繞 local Z —— 平面內差 90°,而且方向一致地錯。 - 但不是全體一致的錯:ribbon 與部分 rope(3.9°/6.8°/8.0°/10.5°)軸是對的。 所以不是一個全域的 Y↔Z 約定 bug,否則不會有骨對得上。
這推翻了什麼
推翻 §34 起就一直沿用的診斷:「誤差主因是過擺」。
過擺 1.50x -> 1.08x (治好了)
誤差 142% -> 153% (沒降, 反而升)
擺幅對了誤差不降,是因為擺的方向本來就不對。 在方向錯的情況下把幅度調對,只會讓錯誤的位移更大 —— 153% 就是這麼來的。
同時解釋了 §34/§35 的整串怪現象:碰撞 155%、delta 當前方向 195%、 鏈耦合總體不動 —— 這些機制全都作用在「擺得多遠」上, 而瓶頸在「往哪擺」。難怪加什麼都不降。
[x20+0x54] 是不是角速度?—— 沒有否證,但也沒被支持
結構上它讓幅度對上了(1.50→1.08,這是全 session 對幅度最有效的一項)。 但因為軸是錯的,這個實驗無法判定它是不是角速度 —— 一個作用在錯方向上的正確阻尼,量出來就是「更差」。
所以 --vel 預設維持 off(照 §35 的慣例:讀對但結果更差的東西留旗標、
不設預設、不刪掉)。等軸修好之後必須重跑這一項,那時候它的數字才可信。
下一步(明確且唯一)
不要再碰積分器、阻尼、碰撞、鏈骨平滑 —— 那些都在調幅度。 要查的是「軸」,也就是位置 → 旋轉那一步:
1. from_to(rest_dir, cur_dir) 取的是最短弧, twist 恆為 0。
遊戲的 CalcDynamicSwing 是從 bone/child 的 selfTx 產生旋轉
(0x02796fa8 / 0x02796fb0), 不見得是最短弧。這是頭號嫌疑。
2. boneAxis 目前取「子骨靜止 local translation」。裙擺末端真值繞 Y、
模擬繞 Z, 差 90°, 與「軸取錯一個分量」的形狀吻合。
3. 為什麼 ribbon 對、skirt _03 錯 —— 差異本身是線索,
比較這兩群骨的 boneAxis / 鏈深度 / limitInfo 數值。
判準已經備好:scripts/diag_swing_axis.py 的「主軸夾角中位數 60.3°」
就是這一項的基準線,比 142% 敏感得多(它不受幅度污染)。
軸夾角降到 30° 以下之前,不必再看 142% 那個數字。
37. 追軸:四個假設全否證,最後量到「142% 這把尺本身分辨不出東西」
§36 定出的目標是主軸夾角 60.3° / 軸夾角 88.6°(模擬與真值的旋轉軸幾乎正交)。 這一節依序否證了四個候選,最後一個測試推翻的是量測基準本身。
先建立一個關鍵事實:boneAxis 幾乎全是 ±X
掃這套服裝所有 _sim 骨的子骨靜止位移方向:
jnt_*_skirt*_sim boneAxis = [±1.00, 0.00, 0.00]
jnt_*_rope*_sim boneAxis = [±1.00, 0.00, 0.00]
jnt_*_ribbon*_sim boneAxis = [±1.00, 0.00, 0.00]
骨沿左右方向。所以:
繞 local Z 轉 -> 骨尖上下擺 = 重力垂墜
繞 local Y 轉 -> 骨尖前後擺 = 跑步慣性
真值的裙擺 _03 繞 Y,我的模擬繞 Z。 這句話比「軸差 88°」具體得多。
假設 A:模擬保留了骨自己的綁定 local 旋轉 —— 否證
CalcDynamicSwing 讀 [x8,#0x88] 子骨 boneAxis、[x20,#0xec] parentTx.rotation
求逆,再 FromToRotation。那產生的是直接的 local rotation;
我的 qmul(corr, WR[ni]) 則保留了骨的綁定 local 旋轉,兩者差一個 restR[ni]。
量了每根骨的綁定 local 旋轉角:
jnt_L_skirt03_02_sim 153.5°
jnt_L_skirt02_02_sim 104.3°
…
jnt_L_skirt00_03_sim 0.0° <- 軸夾角 88° 的那幾根
jnt_R_skirt02_03_sim 0.0°
jnt_L_skirt01_03_sim 0.0°
中位數 0.0°
軸錯得最兇的 _03 骨綁定旋轉全是 0.0° —— 這個差異在它們身上根本不存在。
假設 A 與症狀無關。(_02 骨的 153°/104° 是另一回事,之後仍要處理。)
假設 B:重力主導,把運動壓進 Z —— 否證
加 --gravity-scale 掃:
重力縮放 誤差/幅度 過擺倍率 軸夾角
1.0 (原) 142% 1.50x 88.6°
0.5 146% 1.48x 88.2°
0.25 139% 1.36x 86.6°
0.0 131% 1.15x 88.6°
重力關到 0,軸夾角一動也不動(88.6°)。 誤差降到 131% 純粹是幅度變小。 不是重力把運動壓進 Z 的。
假設 C:limitInfo 的尤拉軸對應被我置換了 —— 否證(而且反過來驗證了對應是對的)
裙擺骨的限制長這樣:
axisX [ 0.0, 0.0] <- 完全鎖死
axisY [-20.0, 20.0] <- 對稱, 自由擺
axisZ [-40.0, 0.0] <- 單邊, 只能往負向垂
形狀本身就吻合症狀:真值走對稱的 Y,我的模擬走單邊的 Z(垂到 −40 卡住)。
但 axisX = [0,0] 提供了一個現成的對照:若我的尤拉分量→軸對應被置換,
被夾成 0 的會是別的分量。實測模擬與真值的主軸 X 分量都 ≈ 0
(|x| < 0.15),而模擬的 Z 分量是 −1.00(沒被夾)。
對應是對的。 這一項不必再查。
假設 D:_ast 骨沒實作 —— 是真缺口,但不是軸錯的原因
sim_swing.py 一直把 _ast 釘在綁定姿勢(QuartzDriverSkirtBone 沒逆向)。
真值裡有這些骨,量出來它們會動:
偏離綁定 最大 主軸 骨名
22.71° 68.63° [0,0,1] jnt_R_skirt00_01_ast
20.44° 61.77° [0,0,1] jnt_R_skirt01_01_ast
10.44° 61.03° [0,0,1] jnt_L_skirt00_01_ast
9.40° 54.93° [0,0,1] jnt_L_skirt01_01_ast
0.00° 0.00° jnt_*_ribbon00_01_ast / rope*_01_ast / cheek / sword
恰好是 0.00° 的那幾條鏈,正是 §36 裡我的模擬軸算對的那幾根 (ribbon 6.8°/8.0°、rope00_02 10.5°)。相關性很漂亮。
所以做神諭測試:--ast-from-truth 把 _ast 換成真值的已知正解,
隔離它佔多少。
誤差/幅度 軸夾角 主軸夾角 幅度比
基準 (_ast 釘死) 142% 88.6° 60.3° 1.36
--ast-from-truth (真) 171% 74.5° 56.7° 1.62
軸夾角 88.6° → 74.5°,方向對。但效應小於 2 倍,所以要對照組。
對照組:餵時間平移過的假 _ast 神諭(同一批旋轉,錯的時間):
誤差/幅度 軸夾角 主軸夾角
--ast-from-truth 真 171% 74.5° 56.7°
假神諭 (平移 45 格) 183% 75.2° 56.7°
假神諭 (平移 23 格) 189% 87.4° 59.9°
假神諭做出幾乎一模一樣的軸改善(75.2° / 56.7°)。
所以「軸夾角從 88.6 降到 74.5」只是因為 _ast 有動,
與它動得對不對無關。軸夾角這個指標的雜訊底線是 ±13°,
88.6 → 74.5 落在裡面,什麼也沒解釋。
但誤差那一欄不一樣:真神諭 171% 贏兩個假神諭 12~18 點。
所以 _ast 確實是真缺口(動得對比動得錯好),只是
加進去讓幅度從 1.36 漲到 1.62,賠掉的比賺的多。與其他每一項的形狀相同。
最後一測:142% 這把尺本身能分辨什麼?
r(幅度隨時間的相關係數)在每一個變體都是 ≈ 0,
從沒有任何一項讓它動過。所以直接去問對齊:
把模擬相對真值平移 k 格(k = 0..89)各算一次誤差
(scripts/diag_swing_align.py)。
k = 32 (最佳) 126%
k = 16 128%
k = 0 (現用) 142%
k = 9 (最差) 189%
兩個結論:
- 格對齊沒錯。 若 k=0 是錯的,某個 k 會大幅勝出(會是 40~60% 那種數字), 實際最佳只有 126%,仍然比「什麼都不做」差。
- 而這正是壞消息:142% 落在「把模擬的時間軸整個打亂」所產生的 126%~189% 這個帶子裡面。
也就是說 —— 這把尺分辨不出「我的模擬」與「我的模擬隨機平移一個時間」。 模擬幾乎沒有攜帶正確的時間資訊。
這推翻了什麼(含對自己前幾節的更正)
§34~§36 一路在比較的 146% / 142% / 131% / 153%,全都在亂序帶 126~189% 之內。 把它們當成「改善」或「退步」來解讀是過度解讀。唯一還站得住的敘述是 「全部這些變體都落在同一個帶子裡」。
包括 §35 那句「根部修正是整個 session 唯一讓誤差下降的東西(146% → 142%)」—— 那 4 個百分點在這個雜訊底線之下,不能算證據。 根部修正的二進位依據仍然成立,但「它讓結果變好」這個量測結論撤回。
同理,本節 --gravity-scale 0 的 131% 不宣稱是改善,
--vel 的 153% 也不宣稱是退步。預設維持原狀,不因為這些數字改動。
下一步
不要再用 33 根骨 × 90 格的中位數當判準了 —— 已證明它在這個問題上沒有解析度。
1. 換判準: 挑一根骨 (建議 jnt_R_skirt00_03_sim, 軸夾角 89°、真值偏離 16°),
把它的 local rotation 三個尤拉分量對時間畫出來, 模擬與真值疊在一起。
目標先求 r > 0.5 —— 「跟著真值一起動」, 而不是「中位數比較小」。
在單根骨上做對, 再回頭看總體。
2. `_ast` 是已確認的真缺口 (真神諭贏假神諭 12~18 點), 但要先有能分辨的判準,
否則逆向 QuartzDriverSkirtBone 出來也驗不了。
3. 幅度一致地過大 (1.36~1.62 倍) 是橫貫所有變體的第二個症狀,
§36 的 `--vel` 是唯一壓得下來的機制 (1.08x)。軸修好後重跑它。
已排除、不必再查的:綁定 local 旋轉(假設 A)、重力主導(B)、 尤拉軸對應置換(C)、格對齊(本節)。
38. 找到了:limitInfo 的度數是 Unity 空間,我拿到 glTF 空間去夾
§37 把判準從「33 骨 × 90 格的誤差中位數」換成 「單根骨、逐尤拉分量、看相關係數」。換完第一次執行就看到答案。
換了尺,一眼就看到
scripts/diag_swing_trace.py,jnt_R_skirt00_03_sim:
真值振幅 模擬振幅 真值均值 模擬均值 r
X 0.00° 0.00° -0.00° -0.00° (鎖死)
Y 9.49° 5.70° -0.64° 1.45° 0.37
Z 11.06° 9.23° +15.50° -37.17° -0.13
真值的 Z 均值是 +15.50°,模擬是 −37.17°。
而這根骨的 axisZ 限制是 [-40, 0] —— 真值整段都在限制範圍之外。
總體指標永遠看不到這個:夾角取絕對值,+15 與 −37 只是「差 52°」,
沒有東西告訴你它們在原點的兩側。
原因
匯出時 Unity → glTF 鏡射 X,四元數 (x,y,z,w) → (x,−y,−z,w)。
代進 q_to_euler_zxy 逐項展開:
ex' = arcsin(2(w·x − (−y)(−z))) = ex (不變)
ey' = atan2(2(w(−y) + x(−z)), …) = −ey (變號)
ez' = atan2(2(w(−z) + x(−y)), …) = −ez (變號)
而 limitInfo 的數字是資產裡的 Unity 空間度數,我直接拿來夾
glTF 空間的尤拉角。Y/Z 兩軸的區間應該變號並對調端點:
[lo, hi] -> [−hi, −lo]
這個 bug 只在區間非對稱時看得見。
X 是 [0,0]、Y 是 [-20,20] —— 對稱,鏡射前後一模一樣。
只有 axisZ = [-40, 0] 這種單邊區間會露餡,而它正是錯的那一個。
所以這個 bug 躲過了 §30~§37 的每一次檢查,包括 §37 假設 C 那次
「用 axisX=[0,0] 反過來驗證軸對應」——
那次驗的是「哪個分量對哪個軸」,對的;沒驗「區間的正負向」,錯的。
確認:不經過模擬器,直接拿真值比區間
把真值的 local rotation 換算成 glTF 空間尤拉角,取每根骨 Z 的實際範圍, 和「資產原樣」與「鏡射後」兩個候選區間比:
真值 Z 範圍 資產 axisZ 鏡射後 骨名
[ -0.0, 40.0] [-40.0, 0.0] [ -0.0, 40.0] jnt_R_skirt00_03_sim 鏡射
[ -0.0, 35.9] [-40.0, 0.0] [ -0.0, 40.0] jnt_C_fur01_02_sim 鏡射
[-10.0, 20.0] [-20.0, 10.0] [-10.0, 20.0] jnt_L_rope00_02_sim 鏡射
[-10.0, 10.0] [-10.0, 10.0] [-10.0, 10.0] jnt_C_sword00_03_sim 兩者
…
共 32 根: 資產原樣相符 8 鏡射後相符 32
32/32 符合鏡射後的區間,只有 8/32 符合原樣 —— 而那 8 根正是對稱區間、 兩個候選相同、沒有分辨力的。24 根有分辨力的骨零反例。
而且真值精確地貼在邊界上(-0.0、40.0、20.0、30.0),
表示限制確實在夾,不是碰巧落在區間內。
這是這一輪唯一一個不經過模擬器就能確認的結論 —— 直接拿真值去考兩個候選。比「改了跑跑看誤差」強得多, 因為它不受模擬器其他缺陷污染。
影響範圍
全庫啟用 limitInfo 的彈簧骨 5,403 根
至少一軸非對稱 4,379 根 (81.0%)
其中 axisZ 非對稱 4,320
axisY 非對稱 161
axisX 非對稱 3
81% 的彈簧骨被這個 bug 夾到錯的半邊。
修完量到的(--no-limit-mirror 可關掉)
誤差/幅度 軸夾角 過擺 對齊掃描
修正前 142% 88.6° 1.50x k=0 落在亂序帶內
修正後 110% 61.9° 1.50x k=0 在亂序帶之外
三件事同時發生:
- 誤差 142% → 110%
- 軸夾角 88.6° → 61.9°(遠超 §37 建立的 ±13° 雜訊底線)
k=0第一次成為對齊掃描的最佳位移,跳出 §37 那個 126~189% 的亂序帶 —— 模擬第一次攜帶了正確的時間資訊。這一項比 110% 這個數字重要。
§37 說「這把尺分辨不出東西」,現在它又有解析度了。
於是把 §36/§37 判過「更差」的機制全部重跑
(全部以修正後為基底)
組合 誤差% 軸夾角 過擺 對齊
基底 (只有鏡射修正) 110% 61.9° 1.50x k=0 最佳 ✓
--vel add 147% 79.5° 1.00x 落回亂序帶
--vel replace 147% 80.3° 1.01x 落回亂序帶
--gravity-scale 0 110% 61.9° 1.14x k=0 最佳 ✓
--collision 202% 66.3° 1.60x 落回亂序帶
--ast-from-truth 161% 59.7° 2.10x 落回亂序帶
--vel(角速度累加器)現在有定論了。 §36 當時說
「軸是錯的,所以判不了它是不是角速度,等軸修好後重跑」。
重跑了:它把過擺修到 1.00x(完美),但誤差升到 147%、軸夾角退到 79.5°,
而且讓 k=0 掉回亂序帶 —— 它破壞了時間資訊。
所以 [x20+0x54] 不是這樣接進位置積分的。預設維持 off。
--gravity-scale 0 給出完全相同的誤差與軸(110% / 61.9°),
只有過擺從 1.50x 降到 1.14x。因為 Z 現在被夾在 [0, 40],
重力推的方向直接撞到 0 這一端被夾掉 —— 重力在夾過之後沒有影響。
不改預設(重力有二進位依據,且此處無法分辨)。
碰撞與 _ast 神諭在新基底上仍然更差,但現在的更差是可信的更差
(它們讓 k=0 掉出最佳)。_ast 讓過擺衝到 2.10x —— 它注入的能量沒有東西吸收,
與「還缺一個阻尼機制」一致。
目前狀態與下一步
110% 仍然略差於「什麼都不做」(100%)
61.9° 軸夾角, 從 88.6° 下來, 但還遠不是對的
1.50x 過擺, 從頭到尾沒被正確機制解決過
r 單骨相關係數仍低; k=0 只是「最佳」, 不是「好」
下一步(依序,判準都用 §37/§38 那組有解析度的指標):
1. 剩下的 61.9° 軸夾角。同樣用 diag_swing_trace.py 逐分量看,
現在 Z 的符號對了, 要看 Y —— 真值 Y 振幅 9.49°、模擬 5.70°, r=0.37
但平移對照組能做到 0.46, 所以 Y 目前仍無時間資訊。
Y 是對稱區間, 不會有 §38 這種 bug, 要往驅動源找 (`_ast` / 鏈耦合)。
2. 1.50x 過擺。已知唯一能壓下來的是 --vel, 但它破壞時間資訊,
表示阻尼在別的地方。`damping` 目前只進 Verlet inertia 那一項, 重讀二進位。
3. `_ast` / QuartzDriverSkirtBone 仍是已確認的真缺口 (§37 神諭贏假神諭),
但要先有能吸收它能量的阻尼, 否則加進去只會把過擺推到 2.10x。
教訓
對稱的參數藏得住符號錯誤。 這個 bug 活過了七個章節的檢查, 因為 X 與 Y 的區間都對稱,只有一個軸非對稱 —— 而全庫 81% 的骨都受影響。 「檢查座標約定」時掃過的是四元數整體鏡射(§36 那次掃六種約定, 結論 mirrorX 正確 —— 也確實正確),但沒有掃「從資產讀進來的角度常數 是不是也要跟著鏡射」。轉換座標系時,資料和常數要一起轉。
39. 為什麼裙擺鏈尾會飽和:factor 為負 × delta 用靜止方向 = 斥力
§38 把誤差壓到 110%、讓 k=0 跳出亂序帶之後,指標恢復解析度,
於是可以往下拆剩下的 61.9° 軸夾角與 1.50x 過擺。
症狀被定位到 8 根骨
量每根骨「Z 貼在上界的時間比例」(上界 = §38 鏡射後的值):
上界 真值貼邊% 模擬貼邊% 骨名
40 4% 88% jnt_L_skirt00_03_sim
40 0% 84% jnt_L_skirt01_03_sim
40 0% 81% jnt_L_skirt02_03_sim
40 0% 87% jnt_L_skirt03_03_sim
40 10% 94% jnt_R_skirt00_03_sim
40 0% 89% jnt_R_skirt01_03_sim
40 0% 70% jnt_R_skirt02_03_sim
40 0% 73% jnt_R_skirt03_03_sim
30~20 0~19% 0~32% 其餘每一根 (_02 / rope / ribbon / sleeve)
中位數 2% 1%
問題完全集中在 8 根 skirt*_03_sim(鏈尾)。其他 24 根都對得很好。
總體中位數(真值 2% / 模擬 1%)完全看不到這件事 —— 又一次驗證
「要比兩次輸出本身,不要只看彙總」。
逐格印出來:單向漂移,不是振盪
--debug-bone jnt_R_skirt00_03_sim,印夾限制前/後的尤拉角:
f000 夾前 [ 0.00 -0.00 -0.00] 夾後 [ 0.00 -0.00 0.00]
f001 夾前 [-0.12 4.23 3.31] 夾後 [ 0.00 4.23 3.31]
f002 夾前 [-0.74 8.21 10.28] 夾後 [-0.00 8.21 10.28]
f003 夾前 [-1.93 10.87 20.04] 夾後 [ 0.00 10.87 20.04]
f004 夾前 [-2.84 10.61 29.93] 夾後 [ 0.00 10.61 29.93]
f005 夾前 [-3.29 8.96 40.27] 夾後 [ 0.00 8.96 40.00] <- 撞上界
f009 夾前 [-1.23 2.53 51.75] 夾後 [ 0.00 2.53 40.00]
…之後每一格都被夾在 40
每格 +10°,五格撞上界,再也沒回來。 骨長 |r| 始終等於 L,
約束沒問題。這是一個持續同向的力,不是慣性震盪
(damping 0.3 → 慣性係數 (1−0.3)² = 0.49,兩三個子步就衰減掉了)。
原因:factor 為負,而 delta 用了靜止方向
把這套服裝每根骨的 stiffness − pendulum(cos→1 時的 factor)列出來:
stiff pend range factor 骨名
1.00 0.00 1.00 +1.000 jnt_*_skirt*_02_sim (鏈根, 模擬算對的那些)
1.00 0.00 1.00 +1.000 jnt_*_ribbon00_02_sim / rope00_02 / rope01_02
0.10 1.00 0.50 -0.900 jnt_*_skirt*_03_sim (鏈尾, 飽和的那些)
0.10 1.50 0.20 -1.400 jnt_*_rope01_04_sim
0.10 1.00 0.40 -0.900 jnt_*_handSleeve00_02_sim
負 factor 的骨與飽和的骨完全對應。 全庫 7,009 根裡 4,430 根(63%)的 factor 在 cos→1 時為負。
sim_swing.py 預設 delta = rest_dir(動畫給的靜止方向)。
骨一旦偏離靜止,rest_dir 就不再是徑向的 ——
於是負 factor 讓這個力有一個把骨推離靜止的切向分量。
每個子步推一點,五格就推到限制角,然後靠 limitInfo 卡住。
§35 讀出的 delta = rotate(selfTx.rotation, boneAxis) 是當前方向,
純徑向,經骨長硬約束後角度上等於沒作用 —— 那才是遊戲的行為。
§30「順帶發現」那一段其實早就寫對了:
「factor 在慢速時是負的,力沿骨軸向內,經骨長硬約束之後在角度上等於沒作用」。
三處(§30 觀察、§35 位址序列、§39 的量測)互相印證。
順帶修掉一個不一致:--delta-current 原本用 cur - WT[ni] 算當前方向,
但骨長約束的基準是根部修正後的 anchor。已改成 cur - anchor。
量了:按 factor 正負分組,結論相反
(都以 §38 修正後為基底;--delta-current 開/關)
delta=rest_dir delta=current
factor < 0 (14 根) 36.12° 17.00° <- current 好 2.1 倍
factor > 0 (19 根) 9.29° 12.17° <- rest_dir 好 1.3 倍
總體 110% 153%
逐骨看,負 factor 那群改善得毫不含糊:
rest_dir current factor 骨名
40.09° 13.72° -0.90 jnt_R_skirt01_03_sim
38.19° 14.86° -0.90 jnt_L_skirt03_03_sim
40.18° 17.22° -0.90 jnt_R_skirt02_03_sim
39.46° 19.43° -0.90 jnt_R_skirt03_03_sim
34.69° 16.78° -1.40 jnt_L_rope01_04_sim
單骨軌跡也對上:jnt_R_skirt00_03_sim 的 Z 均值
38.45° → 20.71°(真值 15.50°),飽和消失。
怎麼解讀(重要,而且我沒有把它當成勝利)
「按 factor 正負分兩套」不是可接受的答案 —— 遊戲裡是同一條程式路徑。 唯一自洽的解讀是:
delta = 當前方向 是對的 (二進位, 三處印證)。
負 factor 群 36° -> 17° 是把一個真 bug 修掉。
正 factor 群 9.29° -> 12.17° 是**拆掉了一個我自己捏造的回復力**,
於是那群骨缺的機制第一次露出來 —— 那不是退步, 是把問題從隱藏變成可見。
正 factor 骨(stiffness=1.0, pendulum=0)的力是 current_dir × 1.0 × 0.01,
純徑向、被骨長約束完全吃掉、角度上零作用。
也就是說遊戲對這群骨根本沒有彈簧回復力,它們的動作只能來自
慣性 + 錨點運動 + 重力 + spring + limitInfo。
我的模擬在這四項上還缺東西(最可能是 _ast 與鏈耦合),
之前被那個假回復力遮住了。
預設維持不變,理由與風險一起寫
--delta-current 仍然預設關閉,照本專案「預設走實測最好的那組」的慣例
(110% vs 153%)。但這一次要明講:
預設值目前是已知不忠於二進位的。 它在總體上領先, 是因為一個遊戲裡不存在的回復力補償了另一個還沒找到的缺口。 一旦正 factor 群缺的機制補上,預設就應該翻過來。 判斷依據不是那時候的百分比,而是這一節的分組表。
(若下一輪決定以忠實度優先,把 --delta-current 設為預設是有充分依據的,
基準會是 153% / 分組 17.00° 與 12.17°。)
下一步
1. 正 factor 群缺什麼。它們是鏈根 (_02), 直接掛在 _ast 底下,
而 §37 已證明 _ast 是真缺口 (真神諭贏假神諭 12~18 點)。
-> 逆向 ActorAnimationQuartzDriverSkirtBone。這是目前證據最集中的一項。
2. 補上之後重測 --delta-current 的分組表, 決定要不要翻預設。
3. 過擺 1.50x 仍在。--vel 能壓到 1.00x 但破壞時間資訊 (§38),
所以阻尼在別的地方 —— 但先做 1, 因為過擺可能是 _ast 缺失的下游症狀。
教訓
參數的符號會決定一個項是回復還是發散,而全庫 63% 的骨落在發散那一側。
delta 該用靜止方向還是當前方向,這個問題在 §30、§34、§35 出現過三次,
每次都因為「總體數字更差」而被放回去。真正的原因是:
在一個還有別的錯誤(§38 的限制鏡射)的模型裡,
忠於二進位的那一項會被其他錯誤懲罰。
§38 修掉座標空間錯誤之後,同一個改動才顯出它的真面目 ——
而且只有分組才看得見,總體數字仍然是反的。
40. QuartzDriverSkirtBone:結構全解、參考骨確認,但重現只到 r=0.67
§39 把證據收斂到「鏈根 _ast 缺驅動」。這一節去解它。
結論是部分成功:結構與資料全部到手,公式形狀也確定了,
但照著算只能重現到 r = 0.67,而且最後一步的量測否證了我自己的解釋。
資料早就抽出來了(extract_swing.py 一直有抓)
ActorSwingDynamicBone 51
ActorSwingStaticBone 11
ActorAnimationQuartzDriverSkirtBone 8 <- 就是它
ActorAnimationQuartzDriverRotationBone 2
ActorAnimationQuartzDriverHumanoidSleeve/Arm/HandBone 各 2
WANT_PREFIX = ("ActorAnimationQuartzDriver",) 從一開始就在抓了,
只是沒人用過。「沒逆向」與「沒資料」是兩回事 ——
§30~§39 一直把 _ast 當成「還沒逆向所以釘死」,但參數其實躺在檔案裡。
結構(il2cpp.h,兩個都拿到了)
struct ActorAnimationQuartzDriverSkirtSetting {
math_RotationOrder rotationOrder;
float3 innerCoefficient; float3 outerCoefficient;
float3 limitMin; float3 limitMax;
math_RotationOrder connectionAxis;
GameObject *referenceBone;
};
struct ActorAnimationQuartzDriverSkirtJobBone {
bool _isActive;
ReadWriteTransformHandle drivenHandle;
ReadOnlyTransformHandle referenceHandle;
quaternion initialReferenceRotation; // <- 參考骨的綁定姿勢
…同一組 rotationOrder / inner / outer / limitMin / limitMax / connectionAxis
};
JobBone 沒有任何狀態欄位 —— 沒有速度、沒有上一格、沒有累加器。
所以這個驅動器是當前姿勢的無狀態函式。這一點下面會用到。
initialReferenceRotation 直接告訴我們來源是相對於參考骨綁定姿勢的旋轉,
不是絕對 local rotation。
於是公式形狀是:
rel = inverse(initialReferenceRotation) × reference.rotation
e = euler(rel, rotationOrder)
c = inner 或 outer (依符號選)
e' = clamp(e × c, limitMin, limitMax)
driven.localRotation = quaternion.Euler(e', rotationOrder)
參考骨是大腿(解出來了)
referenceBone 是 GameObject 的 PathID,而 bone_of_pathid 存的是
Transform 的 PathID —— 對不上。重開 bundle 建 GameObject 的對照表:
jnt_L_skirt00_01_ast … jnt_L_skirt03_01_ast -> jnt_L_thigh00_00
jnt_R_skirt00_01_ast … jnt_R_skirt03_01_ast -> jnt_R_thigh00_00
裙擺鏈根跟著同側大腿走。 這正是 §37/§39 一路推測的 「前後擺的驅動源是腿」,現在是資產寫死的事實,不是推論。
八根的設定(左右鏡像,L 側 Y 係數為負):
骨 inner outer limitMin/Max
R_skirt00_01_ast [0,0,0] [ 1.00, 0.80, 1.00] [-180,-180,-180]/[180, 40, 10]
R_skirt01_01_ast [0,0,0] [ 1.00, 1.00, 0.90] [-180,-180,-180]/[180, 10, 10]
L_skirt00_01_ast [0,0,0] [ 1.00,-0.80, 1.00] [-180, -40,-180]/[180,180, 10]
L_skirt01_01_ast [0,0,0] [ 1.00,-1.00, 0.90] [-180, -10,-180]/[180,180, 10]
innerCoefficient 全部是 0 —— 腿往一邊擺時裙擺跟著(outer),
往另一邊時係數 0(裙擺不會被推進身體裡)。
用真值直接考(scripts/fit_quartz_skirt.py)
這條路不必經過彈簧骨模擬:輸入是動畫的大腿旋轉(baked.json),
輸出是 _ast 的 local rotation,真值兩者都有,係數全部來自資產、
沒有自由參數。而且真值與 baked 都是 Unity 慣例,
整段留在 Unity 空間算,不碰 glTF 鏡射(§38 就是栽在那裡)。
先量真值長什麼樣:
jnt_R_skirt00_01_ast ast 只有一個尤拉分量在動, 範圍 -69°~0° (單邊, 上限剛好 0)
jnt_R_skirt01_01_ast 同, -62°~0°
jnt_L_skirt00_01_ast 同, -61°~0°
jnt_L_skirt01_01_ast 同, -55°~0°
大腿對應分量 -78°~+36° (有正有負)
大腿的正半邊全被壓成 0 —— 與 inner = 0 的門檻式完全吻合。
另外四根(skirt02/03_01_ast)真值中位數 0.00°,幾乎不動。
掃 36 種組合(6 種尤拉順序 × 3 種係數選擇規則 × 絕對/相對綁定):
對照組「_ast 不動」誤差中位數 4.70°
最佳組合 5.33° <- 沒有贏過對照組
最差組合 138.66°
沒有任何一種組合贏過「什麼都不做」。 (注意這個 4.70° 中位數被四根不動的骨主導 —— 又是 §34 的老問題, 所以下面改成只看真正會動的四根。)
改成分號迴歸(y = a·min(x,0) + b·max(x,0),掃所有尤拉順序與分量配對):
最佳 r = 0.668 負側係數 0.697 正側係數 -1.184
r 只有 0.67,而且擬合出來的係數對不上資產的 (inner 0 / outer 0.8~1.0)。
單一大腿分量的無狀態線性模型解釋不了 _ast 的動作。
兩個補充項都有效 —— 然後結構把我的解釋否證了
骨 單腿 +延遲 最佳延遲 雙腿 雙腿+延遲
jnt_R_skirt00_01_ast 0.668 0.863 2 格 0.831 0.892
jnt_L_skirt00_01_ast 0.633 0.744 1 格 0.721 0.845
我當下的解讀是「驅動器有時間狀態(平滑)」。
去讀 ActorAnimationQuartzDriverSkirtJobBone 的欄位,發現它沒有任何狀態欄位。
_isActive + 兩個 handle + initialReferenceRotation + 一組常數,就這樣。
驅動器是無狀態的,所以那 1~2 格延遲不可能來自它。
(這些 r 是擬合出來的,4~5 個自由參數配 90 格,本來就會偏高。 不是「重現」,只是「上限估計」。這一點必須標明。)
那延遲從哪來 —— 兩個候選,都還沒查
1. 我餵的大腿旋轉不是 job 讀到的那個值。
job 透過 ReadOnlyTransformHandle 從 AnimationStream 讀參考骨。
若 QuartzDriver 這個 job 排在寫入大腿的 job **之前**,
它看到的就是上一格的大腿 —— 剛好是 1 格延遲, 而且是真實存在的
Unity animation job 排序效應。
2. baked.json 與 swing_truth.json 是**兩次不同的錄製**。
兩者之間若差 1~2 格, 症狀一模一樣。
§37 的對齊掃描 (k=0 最佳) 只證明到「格」的解析度, 不足以排除這個。
候選 2 便宜且能一次排除:下一次錄真值時,把大腿一起錄進同一個 run
(agent.bakeSwing 已經走 Transform 階層找骨,加幾個名字即可)。
同一次 Evaluate 出來的兩個值不可能有排序或對齊問題。
在那之前不要照 r=0.89 的擬合係數去改模擬器 ——
那組係數有可能只是在補償一個 1 格的對齊誤差。
這一節的收支
拿到了 驅動器結構 (Setting + JobBone 全欄位)
拿到了 參考骨 = 同側大腿 (資產寫死, 八根全解)
拿到了 八根的 inner/outer/limit 實際數值
確認了 驅動器無狀態; 來源是相對 initialReferenceRotation
確認了 inner=0 的門檻式 (真值的正半邊被壓成 0, 與資料吻合)
沒拿到 能重現真值的公式 —— 無狀態最佳 r=0.67, 沒贏過「不動」
否證了 「驅動器有平滑/時間狀態」(結構裡沒有狀態欄位)
下一步 把大腿與 _ast 錄進同一個 run, 排除輸入對齊問題
sim_swing.py 沒有改動 —— 在公式能贏過「_ast 不動」之前接上去,
只會重演 §37 神諭那次(過擺衝到 2.10x)。
教訓
「還沒逆向」有時候只是「沒去看資料」。 _ast 從 §30 到 §39
被當成十個章節的已知缺口,而 extract_swing.py 第 60 行的
WANT_PREFIX 從第一天就在抓那個元件了。
在把某一項列進「未解」之前,先 grep 一次自己的輸出。
41. QuartzDriverSkirtBone 解出來了 —— 兩條獨立路徑驗證,而且證明它不是剩餘誤差的原因
§40 停在「無狀態最佳 r=0.67,沒贏過對照組」。這一節把它解完。
先排除 §40 留下的候選 2(不必跑遊戲)
§40 說延遲有兩個候選:job 排序,或 baked.json 與 swing_truth.json
是兩次錄製、差了幾格。後者可以用彈簧骨那條路排除 ——
若是全域的錄製偏移,用同一份 baked 驅動的彈簧骨模擬也會偏同樣的格數。
細掃彈簧骨路徑的對齊(§38 修正後的基底):
k 誤差/幅度
-2 117%
-1 111%
0 110% <- 最佳
+1 149%
+2 137%
k=0 最佳,而 k=+1 明顯變差 —— 與 Quartz 需要的「來源延遲 +1~+2 格」 方向相反。全域錄製偏移排除,延遲是 Quartz 這條路徑特有的。
公式解出來了
把 108 種組合(6 尤拉順序 × 3 係數規則 × 2 門檻極性 × 3 延遲)
逐骨考,只留四根會動的 _ast 全部贏過「不動」對照組的:
四根全贏的組合: 9 / 108 —— 全部集中在 percomp + neg 這一族
最佳 (xyz, percomp, neg, lag=1):
對照(不動) 預測 改善
jnt_R_skirt00 22.71° -> 5.82° 3.9x
jnt_R_skirt01 20.44° -> 5.24° 3.9x
jnt_L_skirt00 10.44° -> 5.19° 2.0x
jnt_L_skirt01 9.40° -> 4.67° 2.0x
四根全部改善超過 2 倍,而且贏的組合不是散落的 9 種,
而是集中在同一個結構(percomp + neg)——那是真機制的形狀,不是雜訊。
最關鍵的一點:勝出的尤拉順序是 XYZ,而資產宣告的
rotationOrder = 0 就是 XYZ。 這是一個沒有經過擬合的預測被證實 ——
順序是掃出來的,但它必須落在資產說的那個值上,而它落在了。
最終式(全部常數來自資產,沒有自由參數):
rel = inverse(initialReferenceRotation) x reference.localRotation
reference = 同側大腿 jnt_[LR]_thigh00_00
e = euler(rel, XYZ)
c_i = outer_i if e_i < 0 else inner_i 逐分量門檻 (inner 全是 0)
e' = clamp(e x c, limitMin, limitMax)
driven.localRotation = bind x quaternion.Euler(e', XYZ)
§40 之所以卡在 r=0.67,是因為當時用的是 byaxis
(拿 connectionAxis 那一軸的符號決定整組係數)。
正解是逐分量各看各的符號 —— 這也解釋了 §40 那個
「R 側有效、L 側完全沒作用」的症狀:L 側的 outer 係數是負的,
用整組門檻會被門檻擋掉,逐分量則不會。
接進模擬器:第二條獨立驗證
sim_swing.py --quartz。與 §37 的「真值神諭」(--ast-from-truth) 對比:
誤差% 軸夾角 過擺 對齊
mir (基底, _ast 不動) 110% 61.9° 1.50x k=0 最佳
--quartz 161% 56.6° 1.98x 落回亂序帶
--ast-from-truth 161% 59.7° 2.10x 落回亂序帶 <- 真值神諭
再直接比兩者的下游輸出:
我的 QuartzDriver vs 真值神諭, 33 根 _sim 骨:
差異中位數 0.04° 最大 3.77°
模擬器分辨不出我算的 _ast 與遊戲的真值。
這是第二條獨立驗證路徑,而且比第一條更強 ——
它不只說「比不動好」,它說「與真值等價」。
但它不改善彈簧骨模擬,而這正是重點
161% vs 基底的 110%。與真值神諭一模一樣的退步。
這把 §37 起掛著的一個假設釘死了:_ast 確實是真缺口
(§37 量到真神諭贏假神諭 12~18 點),但補上它不會讓誤差下降。
過擺從 1.50x 衝到 1.98x —— _ast 注入的能量下游沒有東西吸收。
所以 _ast 從「未知的缺口」變成「已知且已實作,但下游還缺阻尼」。
剩下的誤差與 _ast 無關,這一條可以從嫌疑名單劃掉了。
順帶測了 §39 的組合 --quartz --delta-current:175%,更差。
一個沒有解決的矛盾(不要當成已解)
延遲的最佳值,兩個量測不一致:
直接比 _ast (孤立目標) lag=1 最佳 (總和 20.9 vs lag=0 的 34.3)
接進模擬器看下游 lag=0 較好 (147% vs lag=1 的 161%)
以直接比對為準(孤立目標、沒有下游污染),所以 --quartz-lag 預設 1。
但這代表下游那個未解的阻尼問題會反過來污染延遲的判讀 ——
真正該做的仍是 §40 說的:把大腿與 _ast 錄進同一個 run,
同一次 Evaluate 出來的兩個值不可能有排序或對齊問題。
那一步需要跑遊戲(進園區那兩下是人工的)。
預設維持關閉
--quartz 預設 off,照本專案「預設走實測最好的那組」的慣例。
與 §39 的 --delta-current 同一個處境:忠於遊戲,但因為下游缺阻尼而被懲罰。
兩者都應該在阻尼補上之後翻成預設,判準用分組表與對齊掃描,不是百分比。
這條線目前的全貌
110% 基底 (§38 的限制鏡射修正) <- 目前最佳
147% + Quartz (lag 0)
161% + Quartz (lag 1, 忠實度最高)
175% + Quartz + delta-current
1.50x 過擺 (基底) -> 1.98x (加 Quartz)
唯一還沒找到的東西是阻尼。 已排除的機制清單現在包含:
角速度累加器(§36/§38)、重力(§37)、綁定旋轉(§37)、
尤拉軸對應(§37)、格對齊(§37/§41)、碰撞(§34/§38)、
swingPowerWeight/bakeAnimationWeight(§35)、_ast 驅動(本節)。
下一步只剩兩條:
1. damping 目前只進 Verlet 慣性項 (1-damping)²。重讀 0x02792f20 附近,
確認它有沒有第二個用途。--vel 能把過擺壓到 1.00x 但破壞時間資訊 (§38),
表示阻尼的形式不是「力進速度」, 而是別的。
2. 鏈骨平滑 ProcessChainBoneSmoothing —— 唯一還沒實作的子系統。
§20 已解到「五趟」的層級, 進入條件已解、主體待解。
它是拉普拉斯平滑 = **本質上就是一個空間阻尼**, 與症狀吻合。
教訓
「贏過對照組」要逐個目標檢查,不是看總和。 §40 用八根骨的中位數量, 被四根不動的骨主導,結論是「沒有任何組合贏過對照組」—— 換成「四根會動的骨每一根都要贏」,立刻從 108 種裡篩出 9 種, 而且集中在同一個結構。同一批資料、同一組公式,只換了判準。 這是 §34「用單一總體指標會漏掉局部大改變」的第三次重演。
42. 鏈骨平滑:實作完成、拓撲確實在作用,但不是那個缺失的阻尼
§41 把嫌疑收斂到「只剩阻尼」,而最有希望的候選是
ProcessChainBoneSmoothing —— 唯一還沒實作的子系統,
而拉普拉斯平滑本質上就是空間阻尼。這一節實作它並量測。
結論:假設被否證,但過程中修掉一個資料解析盲點與一個量測工具的誤報。
先解決「資料看起來缺失,其實是查錯表」
ActorSwingChain.chains.layers[].bones 用 bone_of_pathid 查全部是 "?"。
§40 的 referenceBone 也一樣。加了 GameObject 的表之後 referenceBone 解開了,
但鏈層還是 "?"。
第三種可能才是對的:那些 PathID 指向 ActorSwingDynamicBone 元件本身,
而每個元件的 path_id 早就記在輸出檔裡了。
rootBones -> jnt_L_skirt00_02_sim … jnt_R_skirt00_02_sim (8 根, 繞一圈)
layer 0 active=0 around=0 smoothing=0.00 _02_sim x8
layer 1 active=1 around=1 smoothing=0.15 _03_sim x8 <- 過擺的那些骨
layer 2 active=1 around=1 smoothing=0.07 _04_sim_end x8
環的順序是 L00→L01→L02→L03→R03→R02→R01→R00 —— 正確地繞裙子一圈。
extract_swing.py 已補上 go_of_pathid,並在註解裡寫明三種 PathID 都會出現。
(同一個坑在 §40 與這裡各絆了一次,所以修在來源,不是各自繞路。)
座標對應:層裡的骨要對到「它的父骨」
sim_swing.py 的 pos[ni] 存的是ni 之子骨的位置。
裙擺鏈是 _01_ast → _02_sim → _03_sim → _04_end,所以
layer(_03_sim) -> pos[_02_sim]
layer(_04_end) -> pos[_03_sim]
搞錯這一層會平滑到錯的東西,而且不會報錯。
實作
照 §20 已解的五趟(主體 522 條指令):
B[i] = pos[owner_i] # 原始位置
A[i] = B[i] + smoothing × ((B[i-1]-B[i]) + (B[i+1]-B[i]))/2 # Jacobi, 鄰居讀 B
len = Σ|A[i+1]-A[i]| (環狀)
if len < L0: A = centroid + (A - centroid) × (L0/len) # 環長回復, 只在縮短時
pos[owner_i] = A[i]; 重新導出旋轉
L0(initialLoopLength)遊戲是執行期算的,這裡從綁定姿勢重算:
layer 1 = 1.0227、layer 2 = 1.2837。
第四趟「被碰撞推開過的骨還原」沒有實作 —— 它需要 hitCheckCount,
而碰撞預設是關的。開 --collision 時這一項會缺,已在程式註解標明。
為了讓平滑後能重新導出旋轉,把旋轉導出抽成 derive(ni),
而且每次都由當前 WT/WR 重算 anchor 與 rest_dir ——
平滑會改父骨,用快取值就對不上。
量測(都以 §38 修正後為基底)
組合 誤差% 軸夾角 過擺 對齊
基底 (mir) 110% 61.9° 1.50x k=0 排名 1/90
--smoothing 110% 55.9° 1.50x k=0 排名 2/90 (+1 點)
--smoothing --quartz 142% 46.6° 1.55x k=0 排名 13/90
--quartz (無平滑) 161% 56.6° 1.98x k=0 排名 12/90
對照組:打亂環的順序
同樣的位移量、錯的鄰接關係(固定的交錯對半排列,不用亂數以便重現):
誤差% 軸夾角 過擺
正確環序 110% 55.9° 1.50x
打亂環序 174% 59.3° 2.19x <- 對照組
基底 (不平滑) 110% 61.9° 1.50x
誤差 110% vs 174%、過擺 1.50x vs 2.19x —— 拓撲確實在作用, 實作碰到的是真機制,不是隨便動一動。
但軸要誠實:6° 的改善(61.9 → 55.9)裡,只有 3.4° 撐得過對照組 (打亂的 59.3° 已經比基底好)。3.4° 低於 §37 建立的 ±13° 雜訊底線, 所以不能宣稱平滑改善了軸。
假設否證
平滑不是那個缺失的阻尼。過擺 1.50x 一點都沒動。
唯一沾到邊的是:與 --quartz 合用時,它把 Quartz 注入的能量吸掉一部分
(過擺 1.98x → 1.55x,誤差 161% → 142%)。方向對,但遠不足以解釋
基底本身就有的 1.50x —— 那個過擺在完全沒有 _ast 驅動時就存在了。
改成預設開啟
--smoothing 預設 on(要關用 --no-smoothing)。理由與其他旗標不同:
忠於二進位 是
對誤差的成本 零 (110% 對 110%)
時間資訊 保住 (k=0 排名 2/90, 只輸最佳 1 點)
打亂環序的對照組 174% —— 證明它在做真事
這是 §36 以來第一個「忠實且不被懲罰」的機制。
--delta-current(§39)與 --quartz(§41)仍然預設關閉,
它們忠實但會被下游缺的阻尼懲罰。
順手修掉一個我自己的量測工具誤報
scripts/diag_swing_align.py 原本用「k=0 有沒有嚴格落在最小與最大之間」判定。
--smoothing 的 k=0 是 110%、最佳 k=16 是 109% —— 只輸 1 點卻被判成
「指標分辨不出時間資訊」。判定改成看離最佳多遠與排名:
k=0 排名 2/90 離最佳 +1 點 亂序帶寬 53 點
-> k=0 等於(或接近)最佳: 模擬帶有正確的時間資訊
這是 §31「量測工具本身也要驗」的第三次重演。上一次差點讓我把一個 零成本的正確機制當成退步丟掉。
這條線目前的全貌
110% 預設 (§38 限制鏡射 + §42 鏈骨平滑) <- 目前最佳
142% + Quartz
161% + Quartz, 不含平滑
174% 平滑但環序打亂 (對照組)
1.50x 過擺 —— 從 §34 到現在沒有任何機制動過它
已實作完的子系統:積分器、limitInfo、骨長約束、spring 鏈耦合、
根部修正、碰撞(旗標)、QuartzDriverSkirtBone、鏈骨平滑。
ProcessDynamicBoneLayer 這條路上已經沒有未實作的東西了。
所以過擺的來源只剩三種可能:
1. 積分器本身的係數還有一項讀錯 (damping 只進 Verlet 慣性項,
0x02792f20 附近要重讀有沒有第二個用途)
2. 風 —— windPower = 0.7 是開著的 (§35 量到), 但沒實作。
§35 當時說「風會增加擺動、方向不對, 先擱著」——
**那是在還有四個 bug 的模型上做的判斷, 應該重測。**
3. 子步數。step = min(dt,1/60)x40 與 n_sub = round(dt/(1/60)),
30fps 給 2 個子步。若遊戲實際跑 60fps, 每格只有 1 個子步,
積分量差一倍 —— 而過擺正好是 1.5 倍。**這一項最便宜, 先測。**
教訓
「資料缺失」要先懷疑自己查錯表。 同一批 PathID 在這個專案裡有三種指涉
(Transform / GameObject / MonoBehaviour),查錯只會安靜地回 "?",
看起來就像資產沒出貨。§40 與 §42 各被絆一次,第二次才想到去比對元件的 path_id。
§42 附記:子步數也不是答案
§42 結尾列的三個過擺候選裡,「子步數差一倍」最便宜,順手測掉:
子步數 誤差% 軸夾角 過擺
1 113% 55.5° 1.22x
2 110% 55.9° 1.50x <- 現行 (dt=1/30 推算出來的)
3 112% 68.3° 1.48x
4 113% 65.9° 1.45x
1 個子步把過擺從 1.50x 壓到 1.22x(真值是 1.00x),但誤差反而升 3 點, 而且 2/3/4 都落在 1.45~1.50 —— 不是單調關係,是「1 對多」的分界, 不是「積分量成正比」。所以這不是「子步數算錯一倍」那種乾淨的 bug。
候選 3 排除。 剩下候選 1(damping 有沒有第二個用途)與
候選 2(風 —— windPower = 0.7 是開著的,而 §35「風會增加擺動所以先擱著」
那個判斷是在還有四個 bug 的模型上做的,該重測)。
43. Live2D Streamed Clip 三次多項式解碼修復與 Cubism Bezier 轉檔驗證
36.1 背景與問題
在 §21 中發現 3D 側 AnimationClip 的 decode_streamed 會讀 4 個 float 係數,但過去 Live2D 側 hohohololive/live2d_motion.py 的 streamed 解碼器只保留了 coeff[3](即 $v_0$ 常數項),丟掉了其餘三個三次多項式係數($c_3, c_2, c_1$),並以 LinearSegment (0) 連接 Keyframe。
全庫 57.4% 的 streamed keyframe 為真三次多項式,導致輸出的 motion3.json 帶著顯著內插誤差。
36.2 改動內容
hohohololive/live2d_motion.py:decode_streamed_clip補回保留全部 4 個三次多項式係數:(time, coeff[3], coeff[0], coeff[1], coeff[2])。解碼器與匯出端共用同一組係數順序假設,該假設已由scripts/verify_streamed_coeff_order.py用 C0 連續性獨立確認(不依賴任何實作,對應領先次佳 7 個數量級)。clip_to_motion3: 新增mode="bezier"(Option a) 與mode="dense30"/"dense60"(Option b)。- Option (a) 數學恆等轉換:Cubism Bezier Segment (
1) 的時間與數值控制點對應 Unity 多項式: $$c1_t = t_0 + \frac{\Delta t}{3}, \quad c1_v = v_0 + \frac{c_1 \Delta t}{3}$$ $$c2_t = t_0 + \frac{2 \Delta t}{3}, \quad c2_v = v_0 + \frac{2 c_1 \Delta t + c_2 \Delta t^2}{3}$$ 在時間線性映射下,Cubism Bezier 求值與 Unity 多項式 $v(dt) = c_3 dt^3 + c_2 dt^2 + c_1 dt + v_0$ 數學上完全恆等。
verify_live2d.py:- 建立獨立驗證腳本,獨立實作 Unity Ground Truth 求值器與 Cubism
motion3.json幾何求值器。 - 先執行驗證器自我測試 (§2.3),確定真值與求值器本身誤差為 0 / float32 精度 ($3.55 \times 10^{-15}$)。
- 建立獨立驗證腳本,獨立實作 Unity Ground Truth 求值器與 Cubism
36.3 實測量測數字
全庫 55 個可取得的 live2d_mot_* 動作 clip(共 7,607 個 keyframe,4,364 個真三次 key)獨立比對結果:
| 模式 (Mode) | 最大誤差 (Max Err) | RMS 誤差 | 檔案大小比 (Relative Size) | 備註 |
|---|---|---|---|---|
| linear_old (基準值) | 5.773 | 0.5078 | 1.00x (253 KB) | 改動前數字 (僅留 coeff[3]) |
| control_bad (對照組) | 6638.0 | 193.7 | 2.35x (595 KB) | 係數順序反轉之假真值 |
| dense30 (Option b) | 2.078 | 0.03106 | 12.51x (3.16 MB) | 30fps 均勻線性取樣 |
| dense60 (Option b) | 0.4978 | 0.007805 | 24.66x (6.24 MB) | 60fps 均勻線性取樣 |
| bezier (Option a, 本線採用) | 1.037e-05 | 4.177e-07 | 2.22x (561 KB) | Cubism 3次 Bezier (float32 精度級) |
- 雜訊底線:Float32 機械浮點精度極限 ($\approx 1.6 \times 10^{-6}$)。
36.4 被否證的假設
- 否證假設 A:「Cubism
motion3.json只能用 Linear Segment (0) 表示 Unity 曲線」- 結果:轉為 Cubism Bezier Segment (
1) 後,最大誤差由 5.773 降至 1.037e-05,RMS 誤差達 4.177e-07 (達到 float32 精確度),證明 Bezier Segment 能 100% 精確表達 Unity 的 streamed 曲線。
- 結果:轉為 Cubism Bezier Segment (
- 否證假設 B:「Dense sampling (密集取樣) 可以作為等效且簡單的替代方案」
- 結果:Dense 60fps 導致檔案大小膨脹至 24.66x (6.2 MB),但最大誤差 (0.4978) 仍比 Bezier ($1.037 \times 10^{-5}$) 差將近 50,000 倍。選用 Option (a) Bezier 為最佳解答。
44. 相機動畫(20 個 clip)轉成 glTF Camera 姿態與參數驗證 (線 B)
44.1 背景與問題
遊戲資產庫中包含 20 個獨立的相機動作 clip(含 4 個 ADV 相機動作與 16 個 Minigame 相機動作),其動畫曲線已由 _extracted_motions_3d_v2/ 重新解碼取得完整三次多項式曲線。
線 B 目標為將此 20 個相機 clip 轉換至 glTF camera 節點 (Perspective Camera) 及相應動畫 Channel (translation 與 rotation),並帶入視野參數 ($FOV, z_{near}, z_{far}$)。
44.2 數學推導與座標轉換 (X 鏡射 + glTF Camera 180° Y 翻轉)
位置轉換: Unity 左手座標系到 glTF 右手座標系採用 X 軸鏡射: $$P_{\text{gltf}} = (-x_{\text{unity}}, y_{\text{unity}}, z_{\text{unity}})$$
姿態轉換:
- Unity 相機旋轉四元數 $Q_{\text{u}} = (q_x, q_y, q_z, q_w)$。
- 套用 X-mirror: $Q_{\text{mirror}} = (q_x, -q_y, -q_z, q_w)$。
- 在 glTF 規範中,Perspective Camera 預設鏡頭指向為 $-\hat{Z}{\text{cam}}$,而 Unity 相機預設指向為 $+\hat{Z}{\text{u}}$。在 X 鏡射空間下,為使 glTF 相機視線完全對齊 Unity 視線,必須 post-multiply 繞 Y 軸 $180^\circ$ 之翻轉四元數 $Q_{\text{y180}} = (0, 1, 0, 0)$: $$Q_{\text{gltf_cam}} = Q_{\text{mirror}} \times (0, 1, 0, 0) = (-q_z, q_w, q_x, -q_y)$$
短弧插值保護: 對相鄰 keyframe 四元數進行連續性檢查,若 $Q_{i-1} \cdot Q_i < 0$,則取反 $Q_i \leftarrow -Q_i$,防止旋轉大圈瑕疵。
相機參數:
- Unity
fieldOfView(CRC32:2499785852) 單位為角度 (Degrees),轉換為 glTFyfov弧度 (Radians): $yfov = \text{radians}(\text{FOV})$。 - 帶入 $z_{near} = 0.1$, $z_{far} = 1000.0$。
- Unity
44.3 實測與驗證結果 (Rule 2.3 & 2.1)
手算與自我測試 (Self-Test):
- 設定 Unity 空間已知相機位姿: $P_{\text{u}} = (1.0, 2.0, 3.0)$,偏航角 30°、俯仰角 15°。
- 求值 Unity 相機視線向量與 Target 位置 $T_{\text{u}} = P_{\text{u}} + 10.0 \cdot \hat{F}_{\text{u}}$。
- 經轉換後導出 glTF 世界空間相機視線與位置:
- 位置誤差:$0.000 \times 10^0$
- 視線方向向量誤差:$5.551 \times 10^{-17}$ (達機械浮點極限)
- 自我測試驗證通過。
對照組比對 (Control Groups):
- 基準值 (Baseline):未加入相機動畫前 (0 camera channels)。
- 對照組 1 (無 Y-180 翻轉):相機視線誤差 = $2.000$ (視線方向完全相反,背對畫面)。
- 對照組 2 (未鏡射 X 位置):相機位置誤差 = $2.000$ (位置鏡射對立面)。
| 模式 / 項目 | 位置誤差 (Pos Err) | 視線向量誤差 (Fwd Err) | 狀態與說明 |
|---|---|---|---|
| Baseline (未寫入前) | N/A | N/A | GLB 無 camera 節點 |
| Control 1 (無 Y-180 翻轉) | $0.000$ | 2.000 | 視線完全反向 (朝 $+Z_{\text{g}}$ 看) |
| Control 2 (未鏡射 X 位置) | 2.000 | $0.000$ | 左右位置鏡射相反 |
| Correct Conversion (本線採用) | 0.000 | 5.551e-17 | 手算驗證 100% 精確對齊 |
- 全庫 20 個 Clip 實測與匯出:
- 執行
add_cameras.py將 20 個相機 clip 寫入<輸出>/hololive_549_final.glb。 - 經
verify_camera.py驗證:20 / 20 個 clip 均含有相機translation與rotationchannels。 - 經 Blender CLI 匯入載入驗證:
MainCamera物件及對應 20 個相機 Action 均完整識別且位姿/FOV 朝向完全正常。
- 執行
45. 通用性驗證——279 套服裝 swing JSON 四項統計清單 (線 C)
45.1 背景與目的
為評估彈簧骨 (Swing / DynamicBone / QuartzDriver) 機制在全庫 279 套服裝 JSON (_avatar/swing/*.json) 上的通用性與異常點,對全庫進行離線只讀掃描(不修改任何程式碼),完成四項特定結構與參數統計。
45.2 統計結果與細項清單
1. ActorSwingDynamicBone 骨名與 GLB 節點比對
- 總 DynamicBone 骨數:6,940 根
- 對應至 GLB 節點成功數:6,940 根 (100.00%)
- 對不到 GLB 節點數:0 根 (0.00%)
- 結論:全庫所有含有動態骨的服裝模型,其
ActorSwingDynamicBone骨名 100% 存在於對應 GLB 的 Transform 節點中,無孤立骨名。
2. ActorSwingChain 環狀鏈骨數一致性
- 含 Chain 服裝套數:209 套 / 279 套(合計 251 個
ActorSwingChain元件;其餘 70 套無 Chain) - 骨數是否每套一致:否(不一致),共有 20 種不同的
(rootBones, layers)骨數與層數組合。 - 前 8 主要結構統計:
(rootBones=8, layers=2):67 套 (32.06%)(rootBones=8, layers=3):57 套 (27.27%)(rootBones=7, layers=2):54 套 (25.84%)(rootBones=2, layers=3):14 套 (6.70%)(rootBones=8, layers=4):12 套 (5.74%)(rootBones=4, layers=3):9 套 (4.31%)(rootBones=6, layers=3):6 套 (2.87%)(rootBones=3, layers=2)與(rootBones=3, layers=4):各 5 套 (各 2.39%)- 其餘 11 種少數組合 (含 2
6 根骨、25 層):共 22 套 (10.53%)
3. QuartzDriverSkirtBone 之 referenceBone 指向檢查
- 總 QuartzDriverSkirtBone 骨數:1,625 根
- 指向同側 thigh 數量 (e.g.
jnt_L_skirt*$\rightarrow$jnt_L_thigh00_00):1,623 根 (99.88%) - 指向異側 thigh 數量 (e.g.
jnt_R_skirt*$\rightarrow$jnt_L_thigh00_00):2 根 (0.12%) - 無法解析 / 非 thigh 數量:0 根 (0.00%)
- 異側對應明細 (全庫共 2 筆):
mdl_chr_drs_00001-cmmn-0001-00_body:jnt_R_skirt04_01_ast(Right) $\rightarrow$jnt_L_thigh00_00(Left)mdl_chr_drs_00006-cmmn-0001-00_body:jnt_R_skirt04_01_ast(Right) $\rightarrow$jnt_L_thigh00_00(Left)
[!NOTE] 註:
referenceBone存放的是 GameObject 的 PathID。因磁碟上 278/279 套 JSON 尚未儲存go_of_pathid欄位,本項驗證採用scripts/verify_quartz_skirt_ref.py透過UnityPy+load_bundle_with_deps現場解開 279 個服裝 AssetBundle 動態建立go_name映射表,使解析率達 100.00% (1,625 / 1,625)。驗證腳本已保存於scripts/verify_quartz_skirt_ref.py。
4. limitInfo 非對稱 (Asymmetric) 比例全庫重新統計
- 總 ActorSwingDynamicBone 骨數:6,940 根
- limitInfo 欄位與啟用狀態:100% (6,940 / 6,940) 均含有欄位,77.12% (5,352 / 6,940) 實際啟用 (
useLimit == true),其餘 22.88% (1,588 / 6,940) 未啟用 (useLimit == false)。 - 非對稱比例 (僅以實際啟用的 5,352 根骨為分母):
- 非對稱 ($min \ne -max$) 骨數:4,328 / 5,352 根 (80.87%) (若嚴格以 $|min| \ne |max|$ 計算則為 4,316 / 5,352 = 80.64%)
- 對稱 ($min == -max$) 骨數:1,024 / 5,352 根 (19.13%)
- 結論:以實際啟用的骨為分母算出的 80.87% 非對稱比例,與 §38 已驗證的單套服裝基準(81%)完全一致。
- 非對稱軸向來源分佈:
axisZ非對稱 (典型限制如 $[-30^\circ, 0^\circ]$):4,257 根 (79.54%)axisY非對稱 (典型限制如 $[-10^\circ, 10^\circ]$ 或單邊限制):140 根 (2.62%)axisX非對稱:1 根 (0.02%)
46. README 更新與工具化評估 (線 D)
46.1 D1: README.md 全文過期敘述修正清單
- 角色動作進度 (Line 104):
- 舊:
| ↳ 537 個角色動作 | ⚠️ humanoid muscle-space | 骨骼映射已取得, 軸框架校準中 | - 新:
| ↳ 549 個角色動作 | ✅ 549/549 已完成 | 已烘焙為 51 根骨骼動畫, 完整併入 glTF (.glb) |
- 舊:
- 相機運鏡動作進度 (Line 103):
- 保留原
| ↳ 100 個場景/運鏡/道具 | ✅ 可直接轉骨骼動畫 | — |敘述,並於下方獨立追加 20 個相機動作子項:| ↳ 20 個運鏡相機動作 | ✅ 20/20 已完成 (X 鏡射 + 180° Y 翻轉 + FOV 併入 glTF) | — |
- 保留原
- Live2D 動作轉換誤差與描述 (Line 94):
- 舊:
| motion | ✅ 還原成 motion3.json (Unity streamed clip 解碼) | — | - 新:
| motion | ✅ 還原成 motion3.json (Cubism 3次 Bezier 幾何恆等轉換, 誤差 < 1e-5) | — |
- 舊:
- 3D BlendShape 表情還原描述 (Line 98):
- 舊:
| 3D BlendShape | ✅ glTF morph target (臉部 32 條) | — | - 新:
| 3D BlendShape | ✅ glTF morph target (眼/眉/虹膜 32 條, 誤差 < 1.6e-6) | 嘴型為貼花切換 (facial_decal.json) |
- 舊:
- 套件結構與模組列表 (Lines 30-32):
- 補齊已被併入
hohohololive/的 Live2D 模組:live2d_motion.py(動作)、live2d_expression.py(表情)、live2d_physics.py(物理)。
- 補齊已被併入
- 專案工具與驗證腳本列表 (Lines 48-52):
- 補齊根目錄新新增的匯出與獨立驗證工具:
add_animation.py(烘焙動作寫入 GLB)、add_cameras.py(相機寫入 GLB)、verify_camera.py(相機對照組驗證)、verify_live2d.py(Live2D Bezier 驗證)。
- 補齊根目錄新新增的匯出與獨立驗證工具:
46.2 D2: hohohololive 工具化評估
- 評估結論:本輪不需強行搬動或修改
hohohololive/底下的核心工具程式碼。 - 評估依據:
hohohololiveCLI (__main__.py) 目前已提供完整的資產處理子命令:catalog-parse(目錄解析)、decrypt-bundle(單檔解密)、decrypt-audio(音效解密)、unpack-octo(快取批次解密與抽取)、extract-3d(3D 模型/綁定轉 glTF)、extract-motions-3d(3D 動作解碼)、merge-motion(動作併入 GLB)以及build-live2d(Live2D 模型組裝)。- 其餘留在
scripts/及根目錄的腳本(如scripts/diag_swing_*.py、probe_*.py、fit_*.py、verify_*.py)屬於針對特定子系統研發、參數擬合或極端邊界測試的專用診斷/驗證工具,不宜強行併入常規用戶 CLI 造成介面臃腫。 - 現有
hohohololiveCLI 介面權責清晰、功能完備且跑合測試正常。
47. 一鍵量測工具,以及一個沒人記錄的東西:110% 基準線是用什麼跑出來的
.scratch/swing-damping/ 那批票的 01 與 02。原本預期 01 是純粹的雜務
(把四支診斷工具包成一支),結果它第一次跑就把一個更重要的問題頂出來。
47.1 scripts/measure_swing.py(票 01)
四項指標各自留在原本的工具裡,這支只負責呼叫並擷取摘要行。 刻意不把公式抄過來 —— 抄一份就會有兩份會漂移的實作, 而「量測工具本身也要驗」是 §34 付過學費的教訓。
擷取器有 --self-test,用寫死的輸出樣本驗正規表示式(9 項,含
「上游沒印摘要行」與「r 是 nan」兩種退化情況)。上游改格式時
self-test 會先失敗,而不是安靜回報「—」。
47.2 110% 這個基準線,重現不出來
用手上任何一個 base GLB 跑預設參數,得到的是:
指標 基準線(HANDOFF §2.1) 實測
誤差 / 真值幅度 110% 128%
過擺倍率 1.50x 0.58x
軸夾角中位數 55.9° 108.5°
k=0 對齊排名 2/90 44/90
逐分量 r 中位數 — X -0.04 Y -0.02 Z -0.03
r ≈ 0 表示這次的模擬完全沒有時間資訊,跟基準線描述的
「k=0 排名 2/90、帶有正確的時間資訊」是兩件不同的事。
47.3 根因不是模擬器,是真值與模型對不上
compare_swing.py 的對照欄直接指出來了:
真值偏離靜止中位數 29.04° <- HANDOFF §2.1 說的是 6~16°
jnt_R_skirt00_02_sim 真值偏離 155.39°
jnt_R_skirt01_02_sim 真值偏離 155.82°
裙擺骨相對綁定姿勢偏離 155° 是不可能的姿勢。
swing_truth.json 錄製時角色身上那套服裝,不是我手上任何一個 GLB 的那套。
兩邊都有的骨只有 12 根,而 §37 講的是「33 骨 × 90 格」。
47.4 排除掉的一個解釋:不是 base GLB 選錯
用 hololive_549_hair.glb(207 節點、與真值共同骨 57)與
hololive_549_full.glb(163 節點、共同骨 33)各跑一次,
四項指標逐位元相同。查下去原因很乾淨:兩次模擬的都是同一批 14 根身體骨
(服裝 swing JSON 只有那些),多出來的頭髮節點根本沒被 --swing 涵蓋,
輸出的 rotation 資料 hash 一致。
所以差異不在 base GLB 的選擇,在服裝與真值的錄製對象。
47.5 真正的缺口:輸入組合從來沒被記錄
RE_NOTES_AVATAR.md、HANDOFF.md、NEXT_STEPS.md 三份文件裡,
110% / 1.50x / 55.9° / k=0 排名 2/90 這組數字被引用了幾十次,
但沒有任何一處記錄它是用哪個 base GLB + 哪套服裝 JSON + 哪份 baked 跑出來的。
這比「數字對不上」嚴重:§36~§43 的每一個結論都是拿某次輸出跟這組數字比出來的, 而那組數字現在無法重跑。它是一個沒有出處的基準線。
measure_swing.py 已經改成每次都把輸入來源印在數字上面,
這一類問題以後不會再發生 —— 但已經發生的那些比較,補不回來。
47.6 這對票 03 / 05 的影響
兩張票的驗收都寫著「對照基準線」。在基準線的輸入組合被找回來之前, 03 與 05 沒有可比的基準。做下去會得到數字,但那些數字不能跟 §36~§43 的任何結論放在一起講。
順序因此要改:先找回輸入組合(新票 09),再做 03 / 05。
47.7 教訓
一個被反覆引用的數字,如果沒有記錄怎麼產生的,它遲早會變成不可重現的傳說。 這個專案在「每個假設都要量」上做得很嚴格,卻沒把「量的那把尺自己怎麼架起來的」 寫下來 —— 而尺比讀數更需要出處。
48. CalcWindPower 解出來了:風是 sin + noise 的時間函數,不是常力
票 02。§35 量到 windPower = 0.7 是開著的但一直沒實作,
§42/§43 把它列為過擺僅剩的兩個候選之一。
48.1 先修正一個位址
前面幾節(含 NEXT_STEPS.md)寫「CalcWindPower @ 0x02793194」。
那是呼叫點,不是函式本體。 函式在:
0x0279DEF0 ActorAnimationWindUtility::CalcWindPower
出處 il2cpp_map_output/il2cpp.json 的 addressMap.methodDefinitions。
48.2 兩個 Single 是什麼 —— 符號表直接寫了
票 02 最關鍵的問題(那兩個 Single 是什麼)不需要讀組合語言,
il2cpp 的 mangled name 裡就有參數名:
float3 ActorAnimationWindUtility_CalcWindPower(
float3 jointWorldPosition,
ActorAnimationManagePropertyData * managePropertyData,
float time, <- 第一個 Single
float windPowerScale, <- 第二個 Single
MethodInfo * method)
time 在裡面。這就是相位。
48.3 組合語言證實它怎麼用 time
s8 = time,s13/s12/s11 = jointWorldPosition.xyz,x19 = managePropertyData。
用途一:noise 的座標被 time 縮放
0279dfbc ldr s0, [x19, #0x2c] ; 空間頻率
0279dfc4 fmul s1, s13, s0 ; pos.x × freq
0279dfc8 fmul s2, s12, s0
0279dfd0 fmul s3, s11, s0
0279dfd4 fmul s0, s1, s8 ; × time
0279dfd8 fmul s1, s2, s8
0279dfdc fmul s2, s3, s8
0279dfe4 bl #0x980c614 ; noise(float3)
用途二:sin 的相位,而且常數是 2π
0279e030 mov w8, #0xfdb
0279e034 movk w8, #0x40c9, lsl #16 ; 0x40C90FDB = 6.2831855 = 2π
0279e048 fmul s0, s9, s8 ; [x19+0x34] × time
0279e04c fadd s0, s0, s10 ; + [x19+0x38]
0279e050 fmul s0, s0, s1 ; × 2π
0279e054 fcvt d0, s0
0279e058 bl #0x7c2060c ; sin
0279e064 fmul s0, s1, s0 ; × [x19+0x30] 振幅
noise 被 remap 到 [0,1] 再乘 windPower
0279e06c fmov s2, #0.5
0279e070 fmul s3, s12, s2 ; noise × 0.5
0279e074 fadd s2, s3, s2 ; + 0.5 -> [-1,1] 映到 [0,1]
0279e078 fmul s2, s11, s2 ; × [x19+0x28] = windPower
48.4 形狀
wind = base
+ 振幅 × sin(2π × (頻率 × time + 相位))
+ windPower × (noise(pos × 空間頻率 × time) × 0.5 + 0.5)
用到的 ManagePropertyData 欄位(0x1c windDirection、0x28 windPower 是 §35 已知的):
0x28 windPower §35 量到 0.7
0x2c noise 的空間頻率
0x30 sin 振幅
0x34 sin 頻率
0x38 sin 相位
0x40/0x44/0x48 第二組(另一條 sin,0x44 頻率、0x48 相位)
0x50 第二次 noise 的空間頻率(0x279e124 之後還有一段)
48.5 為什麼這值得做下去
過擺 1.50x 但 k=0 仍是最佳位移 —— 這兩件事一起看是矛盾的:
能量太多,時間資訊卻正確。純阻尼解釋不了後者。
而風是一個帶相位的週期驅動源:它同時往系統加能量,又把能量鎖在特定頻率上。 這是目前唯一在形狀上能同時解釋兩個症狀的機制。
48.6 但先別急著記成「找到了」
time 的來源沒查(是 Time.time 還是動畫的本地時間?兩者在 loop clip 上差很多),
第二組欄位(0x40 之後)那段還沒讀完,
而且 —— run00 錄製當時場景到底有沒有風,還是未知的(TODO_needs_game.md T1)。
若當時無風,真值裡就沒有風可比,實作了也量不出東西。 這一節解出的是機制,不是結論。
47/48 狀態
- 票 01:工具建好、self-test 通過。但驗收條件「重現基準線」沒過,見 §47.2。
- 票 02:完成。 兩個
Single解出,公式形狀解出。 - 新增票 09(找回基準線的輸入組合),且它擋住 03 / 05。