ClaudeDocs/RE_NOTES_3D.md
3D 模型逆向筆記
Live2D 那條線走通之後,剩下的問題是:這遊戲到底有沒有 3D 模型。 下面是從「不知道有沒有」到「600 萬三角面落地」的完整推理鏈,包含走錯的岔路。
1. 先證明 3D 存在,再談怎麼提
一開始沒有任何直接證據。與其亂 Hook,先做兩條互相獨立的離線判定。
岔路一:VRM(走錯了)
hololive 官方 3D 模型多半是 VRM,所以先在解密後的 global-metadata.dat 裡 grep:
VRM -> 19 命中
看起來很像成立。但這是假陽性——把命中的字串印出來,全部落在 metadata 內嵌的
base64 protobuf 描述符裡,是隨機片段剛好含 vrm 三個字母。
改用「整行精確比對」重驗:
strings -n 5 global-metadata-decrypted.dat | grep -xE "(VRM|UniVRM|UniGLTF|glTF)[A-Za-z0-9_]*"
# -> 空
結論:專案裡沒有 UniVRM / UniGLTF。不是 VRM 路線。
教訓:子字串 grep 的命中數不是證據,要把命中內容印出來看。 這和先前「兩種加密」那次誤判是同一類錯誤——用間接跡象下結論,沒去驗。
正解:QualiArts 自製 Actor 系統
改掃遊戲自有的型別名(而非第三方套件名),一掃就出來:
ActorAnimationQuartzDriverHairBone ActorAnimationQuartzDriverSkirtBone
ActorAnimationQuartzDriverSleeveBone ActorAnimationQuartzDriverFurisodeBone
ActorAnimationQuartzDriverPonchoBone ActorAnimationQuartzDriverApronBone
ActorAnimationQuartzDriverFrillBone ActorAnimationQuartzDriverWaistBone
ActorAnimationFullBodyIKJobSkeleton ActorAnimationRig / ActorHumanBodyBones
ActorSwingBreastBone / ActorSwingCheekBone / ActorSwingDynamicBone
這是 QualiArts 自家的 Actor / Quartz 骨骼驅動器(與其 IDOLY PRIDE 同源): Humanoid 主骨 + FullBodyIK + 各部位二次動態骨(頭髮、裙、袖、振袖、圍裙…)。
3D 存在,而且資產本體仍是標準 Unity SkinnedMeshRenderer + Mesh + Transform, 解密後 UnityPy 直接讀得動。
岔路二:想跳過 catalog(也走錯了)
既然只有前 256 bytes 被加密,body 是原樣的,那直接 grep 3.6GB 快取找
SkinnedMeshRenderer 不就知道哪些 bundle 是 3D?
xargs -a bundles.txt grep -aoFf patterns.txt # -> 0 命中
0 命中。 原因:bundle body 是整塊 LZ4/LZMA 壓縮的, 連 type tree 的字串都在壓縮區內,不會以明文出現。 (會這樣想是因為 LZ4 的字面量常保留 ASCII——但那是對「未壓縮或低壓縮率區段」才成立。)
所以繞不開 catalog。 定位資產必須有 hash→address 對照。
2. catalog:從 hash→address 升級成完整條目
原本的 parse_catalog_blob() 只用正則抓 {hash: address},夠解密但不夠下載。
重新看記憶體 dump 的原始 protobuf,把一筆條目完整拆開:
1a <len> # 條目 (length-delimited submessage)
08 <varint> # 1 id -> 快取目錄名 = ("A"|"R") + id,再 hex 編碼
12 <len> <bytes> # 2 name -> address,即解密金鑰
18 <varint> # 3 size -> 明文位元組數
2a 20 <32 bytes> # 5 md5 -> 快取檔名
3a <len> <bytes> # 7 objectName -> CDN 物件鍵,6 字元隨機字串
實例(VisionProject.acf,整筆長度 0x42 = 66 bytes,逐欄相加剛好吻合):
1a 42 08 01 12 11 "VisionProject.acf" 18 89 77 2a 20 "bd19...8afb" 3a 06 "VQHQAP"
id=1 name(17) size=15241 md5(32) objectName(6)
驗證方式(不是「看起來對」)
octo 快取的目錄名是 hex 編碼的 ASCII:413138363439 → A18649。
所以 ("A"|"R") + id 可以拿本地 4942 個快取檔逐一對帳:
id 相符 4929 / 不符 13 / 不在 catalog 0 (99.74%)
13 筆不符全部是記憶體區塊邊界截斷——address 開頭混進 *、# 等雜訊字元
(例:*vo_live_cmn_chr_0),是 dump 的邊界問題不是解析邏輯問題,可用
「address 首字元須為英數」濾除。
這件事的意義:objectName = 免越獄下載
先前逆 OctoAPI.DecryptAes 時解出的 CDN 樣板是:
https://asset.game-hololive-dreams.com/{o}
當時不知道 {o} 是什麼,只當成紅鯡魚。現在知道 {o} 就是 objectName。
代表資源可直接從 CDN 取得,越獄只剩「取得一次 catalog」這一個用途。 (不過這版解析器抓 objectName 的方式有個錯誤,見 §5。)
3. 資產命名體系
catalog 全量 36247 筆,3D 相關前綴:
| 前綴 | 總數 | 本地快取 | 內容 |
|---|---|---|---|
mdl_chr_* |
608 | 314 | 3D 角色(body / hair / hairacc) |
fbx_mdl_env_* |
313 | 258 | 場景零件模型 |
mdl_env_* |
240 | 78 | 場景物件(樹、路燈、舞台…) |
mot_define_* |
637 | 350 | 3D 動作/運鏡定義 |
t_env_* |
199 | 112 | 場景貼圖 |
角色模型命名:
mdl_chr_drs_<角色ID>-<服裝碼>-<編號>-<變體>_<部位>
^^^^ base / nrml / uniq / cmmn
例: mdl_chr_drs_00019-uniq-0016-00_body (兎田ぺこら 專屬服 軀幹)
mdl_chr_drs_00019-uniq-0016-00_hair (同上 頭髮)
mdl_chr_acc_00000-hairpin-0001-00_hairacc (共用髮飾)
貼圖後綴:_col(albedo) _def(法線/細節) _drp _rmp(ramp,卡通渲染用) _fdc(臉部貼花)。
_fdc 恆指向 base-0000,即臉部貼花跨服裝共用——換裝只換身體,不換臉。
4. 抽取實作(hohohololive/model3d.py)
一個 bundle = 一個部位,拆成:
<address>/
meshes/*.obj 幾何(UnityPy 解 m_VertexData 頂點串流)
textures/*.png 貼圖(CPU 解 ASTC/ETC2)
skeleton.json Transform 階層 + 每節 local TRS + CRC32 路徑雜湊
materials.json 材質 -> 貼圖/float/color 對應
meta.json 統計
骨架用 m_BoneNameHashes 對應——Unity 慣例是對「不含根的相對路徑」取 CRC32,
和先前 live2d_motion.py 解 CRC32("Parameters/"+id) 是同一套機制。
所以 skeleton.json 每個節點同時輸出 path 與 hash,之後綁 mot_define_* 動作時可直接對上。
成果(CDN 補齊後的最終數字)
1161 / 1161 bundle 成功,0 失敗
mdl_chr 608 mdl_env 240 fbx_mdl 313
meshes 7,038
textures 4,596
materials 3,513
bones 114,123
vertices 11,673,508
(其中 112 個 bundle 沒有幾何,只是材質/貼圖的引用容器; 扣掉後 1049 個會產出 glb。)
63 位角色有 3D 模型、共 335 套服裝(55 位已對到 VTuber 名,
對照表見 character_map.json;多數角色 6 套,少數 1–2 套)。
單個 body 典型為 5 mesh(Body LOD0/LOD1、Eye、Iris、Brow)
- 6 貼圖 + 約 180–200 節骨架。
正確性驗證(不是「跑完沒報錯」)
- OBJ 自洽:
v / vt / vn數量相等,最大面索引 = 頂點數,無越界。 - 骨架語意:骨鏈為
jnt_C_root00_00 → jnt_C_hips00_00 → jnt_C_spine00_00 → jnt_L_shoulder00_00 → upperArm → foreArm → hand → fingerThumb/Middle..., 是合理的人形骨架,且帶jnt_*_skirt*/jnt_*_chestRibbon*等 Quartz 二次骨。 - 量綱:兎田ぺこら
Geo_Body_LOD0包圍盒 高 1.443 m、臂展 1.060 m、厚 0.681 m,Y 範圍-0.000 ~ 1.443——雙腳精確落在 Y=0 地面,身高是合理人體尺度。 幾何若解錯(串流位移錯、格式錯)不可能同時滿足這三項。
5. 用 objectName 從 CDN 補齊(免越獄)
mdl_chr 608 筆本地只快取了 314 筆。用 §2 解出的 objectName 直接打 CDN:
GET https://asset.game-hololive-dreams.com/UFfHjj
User-Agent: UnityPlayer/6000.3.0b1
-> 1462529 bytes, md5 = 94bb0bfa4f2a82415c87fff62763ef6a
catalog 的 md5 算的是密文,所以下載後可直接校驗,再送進 decrypt_bundle()。
這裡先踩了一個坑
第一版 parse_entries() 假設 objectName(field 7)緊接在 md5(field 5)之後。
結果 608 筆 mdl_chr 只有 2 筆抓到 objectName——因為中間隔著重複的 field 6:
2a 20 <md5> 30 ca8402 30 e19402 30 809502 ... 3a 06 "SswxO0" 42 23 <address>
\____ 6 = 依賴資產 id (repeated varint) ____/ \_ 7 = objectName
3D 模型每個都依賴貼圖與材質,所以全部有 field 6,全被跳過。 改成正規的欄位前進解析(依 wire type 逐欄推進)後:
36119 / 36119 筆都有 objectName (修正前 33799)
順帶把依賴清單也解了出來(dependencies),之後要做「連依賴一起抓」很方便。
實測
待下載 944 筆 (3D + 先前缺的 Live2D + mot_define), 1.01 GB
成功 944 / 跳過 0 / 失敗 0
完整離線鏈打通:CDN 下載 → 解密 → 抽取。越獄只剩「取得一次 catalog」這個用途。
6. 有骨架綁定的 glTF(hohohololive/gltf.py)
UnityPy 的 Mesh.export() 只吐 OBJ,會丟掉綁定權重,所以自己解 m_VertexData。
串流佈局
stride(s) = Σ dimension × sizeof(format)
offset(0) = 0
offset(s) = align16(offset(s-1) + vertexCount × stride(s-1))
角色 body 實測:
stream0 stride 40 ch0 Position(3f) ch1 Normal(3f) ch2 Tangent(4f)
stream1 stride 16 ch3 Color(4×UNorm8) ch4 UV0(2×half) ch7 UV3(2f)
stream2 stride 32 ch12 BlendWeight(4f) ch13 BlendIndices(4×uint32)
驗證方式:對每個網格算「計算總長 vs 實際 m_DataSize 長度」,全部逐位元組吻合。
兩個真實的 bug
(1) 骨影響數不是固定 4。 我原本假設 ch12/ch13 恆為 dim4,直接 [:, :4]:
| 網格 | ch12 | ch13 | 實際 |
|---|---|---|---|
Geo_Body_LOD0 |
dim4 | dim4 | 4 骨混合 |
Geo_Eye_LOD0 |
dim2 | dim2 | 2 骨混合 |
Geo_Brow_LOD0 / Geo_Iris_LOD0 |
無 | dim1 | 剛性單骨,權重恆 1 |
對 dim2 的網格,[:, :4] 只拿到 2 寬陣列卻宣告成 VEC4 → 權重和變成 -2.37 ~ 3.02、
joint 索引 63471。眉毛與虹膜則因為「沒有 ch12」被我整個當成無綁定跳過。
修正:一律補零到 4 寬;缺 ch12 時視為剛性綁定,第 0 槽權重設 1.0。
(2) 頂點資料有兩種存法。 角色模型內嵌在 m_VertexData.m_DataSize,
但場景零件多半放在外部串流檔(.resS),由 mesh.m_StreamData
指出 path/offset/size。沒處理時 fbx_mdl_env_* 整批解出 0 個網格。
改用 UnityPy.helpers.ResourceReader.get_resource_data() 取回。
一個推論錯了兩次的地方:描邊殼
Geo_Body_LOD0 有 3 個子網格,面數 13932 / 240 / 13932——第 0 與第 2 完全一樣。
索引緩衝 41796+720+41796 = 84312 剛好把 168624 bytes(uint16)填滿,
所以那不是解析錯誤,是真實資料。
第一次推論(錯):這些重複子網格在 renderer.m_Materials 裡沒有對應材質,
所以我判定「Unity 因此不渲染它」,用「無材質」當作剔除條件。
真相:材質一直都在,只是它是放在依賴 bundle 裡的共用材質,
沒載入依賴時 PPtr 解不開而已。加上 load_bundle_with_deps() 之後,
名字直接浮出來:
材質: ['m_eye', 'm_bdy', 'm_bdyco', 'SubMeshOutlineMaterial', 'm_fef']
Geo_Body_LOD0 sub0 面=13932 材質=m_bdy
Geo_Body_LOD0 sub1 面= 240 材質=m_bdyco
Geo_Body_LOD0 sub2 面=13932 材質=SubMeshOutlineMaterial <- 描邊殼
這是卡通渲染的 inverted-hull 描邊:同一份幾何,靠 front-face culling + 頂點沿法線外推的 shader 呈現輪廓線。
順帶發現:用「無材質」當條件還會誤刪真實子網格——
Geo_Eye_LOD0 的 m_fef(12 面)當時也解不到材質,被一起丟掉了。
改用材質名判斷後救回。
第二次推論(也錯):我看到材質名含 outline 就標成 doubleSided = true。
方向正好相反——描邊殼要的是 cull front(只畫背面),標成雙面會讓那層殼
整個蓋住角色。glTF 沒有 cull-front,所以正確做法是不動 doubleSided,
單純靠材質名讓使用者辨識。
最終策略:預設剔除材質名含 outline 的子網格(沒有那個 shader 就只是與本體
重疊的 z-fighting),--keep-shells 可保留;幾何在 OBJ 匯出中一律完整保留。
教訓:兩次都是「從缺失的東西反推語意」——缺材質就當作不渲染、 名字含 outline 就當作要雙面。缺失往往只代表我還沒把資料載齊。
座標系轉換
Unity 左手系 → glTF 右手系,以鏡射 X 軸 M = diag(-1,1,1):
| 對象 | 轉換 |
|---|---|
| 位置 / 法線 / 切線 | (x,y,z) → (-x,y,z) |
| 旋轉四元數 | (x,y,z,w) → (x,-y,-z,w) |
| 逆綁定矩陣 | M' = S·M·S,S = diag(-1,1,1,1) |
| UV | v → 1-v(Unity 原點左下,glTF 左上) |
| 三角形環繞 | 反轉(行列式變號) |
四元數那條的推導:R' = M R M,M 為非正常變換(det = -1),
把「繞軸 a 轉 θ」變成「繞 (aₓ,-a_y,-a_z) 轉 θ」,
代入 q = (sin(θ/2)·a, cos(θ/2)) 即得。
驗證
寫了獨立的 GLB 回讀器,不依賴匯出端的任何假設:
- GLB 標頭 magic/version/總長與檔案實際大小一致
- 每個 primitive 的最大面索引 < 頂點數(無越界)
- 每個綁定網格的權重和 min = max = 1.0000
- 每個 joint 索引 < skin.joints 長度、每個 skin 的 joints 數 = inverseBindMatrices 數
- 節點索引都在 nodes 範圍內
ぺこら專屬服 body:5 網格全部綁定、平均每頂點 2.41 根骨、19738 面,0 錯誤。
全量驗證(verify_glb.py):
驗證 982 個 .glb -> 通過 982 / 982 有問題 0
7. BlendShape 與材質貼圖
BlendShape -> glTF morph target
臉部表情不靠骨架,走 blendshape,而且只在臉部網格上:
| 網格 | 通道數 |
|---|---|
Geo_Eye_LOD0 |
16 |
Geo_Brow_LOD0 |
14 |
Geo_Iris_LOD0 |
2 |
Geo_Body_LOD0/1 |
0(身體變形全靠骨架) |
合計 32 —— 與動作 clip 內 typeID 137 的曲線數完全相同,互為佐證。
Unity 以稀疏方式儲存:channels[] 指向 shapes[] 的一段,shapes[i] 再指向
vertices[] 的一段,每個頂點自帶原網格索引。glTF 的 morph target 需要與網格
等長的稠密位移陣列,所以逐一展開回去(位移同樣要套 X 鏡射)。
channel.frameCount > 1 表示漸進形變,glTF 一個 target 只能表示一個形狀,
取最後一幀(權重 100 的完整形狀)。實測本作全部 frameCount = 1。
貼圖:先量測再決定,不要猜
貼圖後綴有 _col _def _drp _rmp _fdc。_def 尺寸與 _col 相同(2048²),
很容易當成法線貼圖直接接上 glTF 的 normalTexture——但那樣會讓光照全錯。
先量平均值:
_def 平均 RGBA = (0.6, 211.8, 17.4, 226.3) <- 切線空間法線圖應為 (128,128,255)
_col 平均 RGBA = (148.4, 151.1, 179.1, 239.5)
_drp 128x16 —— 是卡通渲染的 ramp LUT,不是貼圖
_def 是打包的遮罩資料圖,不是法線圖。所以只把 _col 接成
baseColorTexture,其餘留在 materials.json 供自行重建。
albedo 以 PNG 內嵌進 .glb(約 +2 MB/模型),檔案自帶材質,拖進 Blender 就有顏色。
8. 3D 動作(mot_define_*)
突破口是 MonoBehaviour,不是 AnimationClip
AnimationClip 的 genericBindings 只存雜湊:
{'path': 1182008026, 'attribute': 1661978518, 'typeID': 137}
一開始拿角色 skeleton.json 的骨骼路徑算 CRC32 去比對,608 個骨架 ×
所有路徑形式,0 命中。
真正的答案在同一個 bundle 的 MonoBehaviour(VisionActorMotionDefine):
它的 baseAnimation.bindings 以明文列出每條綁定:
{"name": "Geo_Eye_LOD0", "path": "Root_Body/Geo_Eye_LOD0",
"type": "UnityEngine.SkinnedMeshRenderer",
"properties": ["blendShape.b_eye.eye_001", ...]}
Animator 根節點叫 Root_Body,不是 bundle 名稱——這就是對不上的原因。
雜湊函數確認為 CRC32(明文):
| 字串 | CRC32 | 在 clip 內出現 |
|---|---|---|
b_eye.eye_001 |
1661978518 | blendshape 首條(不帶 blendShape. 前綴) |
m_FadeFactor |
682354173 | 6 次 = 6 個 DecalProjector |
bakeAnimationWeight |
3202011236 | 44 次 = 44 根 swing bone |
把 637 個 bundle 的 bindings 全部收集成全域反查表(單一 bundle 的字典只夠解 自己那幾條),得到 520 個路徑 / 198 個屬性名。
曲線索引 -> 綁定
壓縮 clip 的曲線是攤平的一維索引,要按「每個綁定佔幾條曲線」累加回去:
Transform + attribute 1(位移)/3(縮放)/4(尤拉) -> 3 條
Transform + attribute 2(四元數) -> 4 條
其餘 (float / blendshape / muscle) -> 1 條
全域配置:[0, streamedCount) streamed,接 dense,再接 constant。
成果
637 / 637 clip 成功,0 失敗
曲線 246,444 條 路徑已解名 69.5%
Transform 100,448 (98.9% 有解出路徑)
Animator (humanoid muscle) 73,569
MonoBehaviour 54,506
SkinnedMeshRenderer (blendshape) 17,568
Camera 20
兩個修正
尤拉角是度不是弧度 —— 解出後直接量最大絕對值 = 90.000,度無誤。
運鏡 clip 的 path_hash = 0 —— 一開始以為是解析失敗。CRC32("") = 0,
代表綁在 Animator 根節點自身。而且運鏡用的是 attribute 2(四元數,4 條曲線)
不是尤拉角,我原本只處理了尤拉分支,補上後運鏡才烘得出來。
可用性分級
| 類別 | clip 數 | 含肌肉曲線 | 可直接轉骨骼動畫 |
|---|---|---|---|
general_chr |
369 | 369 | 0 |
park_chr |
69 | 69 | 0 |
adv_chr |
65 | 53 | 12 |
park_env |
45 | 0 | 45 |
minigame_chr |
23 | 23 | 0 |
photo_chr |
20 | 20 | 0 |
minigame_cam / adv_cam |
20 | 0 | 20 |
adv_env / minigame_env / minigame_plt |
23 | 0 | 23 |
| 合計 | 637 | 537 | 100 |
角色動作是 Humanoid muscle-space —— 主骨架由 137 條肌肉 DoF 驅動, clip 裡的 Transform 曲線只涵蓋 18 個節點(裙襬、緞帶等二次骨與 IK 目標)。 要把肌肉值還原成骨骼旋轉需要該角色的 Avatar(HumanDescription 的肌肉範圍)。
Avatar 到底有沒有現成檔案:實掃過, 沒有
一開始我只搜 catalog 的 address 沒找到 avatar 字樣就下結論——
那是錯的方法(address 不含資產型別)。改成掃 bundle 內的資產型別:
scan_avatars.py 掃 8938 個 bundle, 讀取失敗 0
humanoid Avatar : 0
generic Avatar : 60 (59 個角色 _hair + 1 個跳繩道具)
覆蓋率是 100%,不是抽樣。 catalog 全部 36119 筆裡,扣掉音效/影片/圖片
這些不可能是 AssetBundle 的,非媒體 bundle 共 8088 筆,全部下載並掃過
(為此額外從 CDN 抓了 3164 筆/約 0.5 GB)。
ref_mot_sequence_* 只是 MonoBehaviour 的序列定義,不是 rig。
判斷 humanoid 的路徑也踩過一次坑:
m_Avatar.m_Human底下還隔一層data, 直接讀m_Human["m_HumanBoneIndex"]會拿到空的而誤判成 generic。 正確路徑是m_Avatar.m_Human.data.m_HumanBoneIndex, 至少一項!= -1才算 humanoid。 (順帶一提m_HumanBoneMass[25]是 Unity 的預設常數表, generic Avatar 也有,不能拿來當判準。)
結論:humanoid Avatar 不隨資產出貨。 IL2CPP 裡是
AvatarBuilder.BuildHumanAvatar + VisionActorDefine.BoneToAvatarMap
(Dictionary<string, HumanBodyBones>,靜態欄位,執行期才填)。
後續進展見
RE_NOTES_AVATAR.md: 骨骼映射表 51 筆已從執行期 dump 出來,SetHumanPose確認被裁剪, 改走「主動擺骨骼 + GetHumanPose 讀回肌肉值」的校準路線。
因此本工具不批次產生角色動畫 .glb:只有 18/180 根骨會動, 結果是「裙子在飄但人不動」,比沒有更誤導。肌肉曲線的原始值有完整輸出, 要自行重建的人拿得到全部資料。
併進模型(merge-motion)
動作與模型分屬不同 bundle。合併時用路徑最後一段對節點名匹配
(Root_Body/.../jnt_C_hips00_00 -> 節點 jnt_C_hips00_00)。
實測 18/18 節點命中、0 個名稱碰撞,假設成立;合併會回報碰撞數,非 0 即代表
該模型上此假設不成立。
python -m hohohololive merge-motion model.glb motion.json -o out.glb
9. 用 Blender 無頭渲染實際檢查(render_check.py)
前面所有驗證都是「數值自洽」——面索引不越界、權重和為 1、四元數是單位長度。 但那些全部通過,模型仍然可能是錯的。所以最後一步是真的把它渲出來看:
/Applications/Blender.app/Contents/MacOS/Blender -b -P render_check.py -- \
body.glb hair.glb out.png
刻意用 Blender 自己的 glTF importer —— 它是完全獨立於本工具的第三方實作, 能匯入就代表檔案結構合規;渲出來的圖再看比例、朝向、貼圖、綁定。 這一步抓到三個前面所有數值檢查都放過的錯誤。
錯誤一:整個模型是綠的(COLOR_0)
第一次渲出來,角色全身是螢光綠、頭是暗褐色。貼圖本身是對的 (藍白洋裝、膚色、胡蘿蔔橘),UV 範圍也正常。
問題在我把 Unity 的 ch3(名字叫 Color)匯成了 glTF 的 COLOR_0。
把數值印出來就露餡了:
Geo_Body_LOD0 COLOR_0 平均 RGBA = [0.101, 0.651, 0.0, 0.0]
B 通道恆為 0 A 通道恆為 0
真正的頂點色不可能整片 alpha = 0。 這是卡通 shader 的遮罩打包,
只有 R/G 有值。而 glTF 規範要求 COLOR_0 乘進 baseColor ——
於是 base × (0.10, 0.65, 0.0, 0.0) = 綠色且 alpha 歸零,正好是看到的結果。
改用底線前綴的自訂屬性 _VCMASK 保留資料而不賦予顏色語意。
順帶查了 ch7(Unity 叫 UV3):實測含 -inf 與 9.9e32,是打包資料不是 UV,
沒匯出是對的。
兩個通道都名不符實。Unity 的通道名只是槽位名稱,不是內容保證。
錯誤二:頭髮沒長在頭上
身體與頭髮是兩個 bundle。合起來渲,頭髮變成臀部下方一團。
一路查下來發現根節點的 TRS 是製作場景裡「把零件並排擺放」留下的殘留座標:
| 部位 | 根節點位移 |
|---|---|
00019-uniq-0016_body |
(−0.055, 0.807, −0.329) |
00019-uniq-0016_hair |
(−1.075, 1.017, −0.373) |
00019-cmmn-0001_body |
(−6.048, 0.745, −0.415) |
| 00001 / 00006 / 00035 各部位 | (0, 0, 0) |
我先歸零根節點——還是不對。保留根節點也不對。兩種都試過都不對齊。
真正的線索在原始頂點:
身體 raw Y 0.000 ~ 1.443 (腳在 0, 頭頂 1.44)
頭髮 raw Y 0.496 ~ 1.779 (辮子垂到腰, 兔耳高過頭頂)
兩者在原始頂點空間本來就是對齊的。 所以問題不是根節點,而是「靜置姿勢」:
身體: 蒙皮後 == 原始頂點 (最大誤差 0.018) -> 儲存姿勢 == 綁定姿勢
頭髮: 蒙皮後偏移平均 1.22 -> 儲存姿勢 != 綁定姿勢
bundle 內儲存的骨骼姿勢不保證等於綁定姿勢。而綁定姿勢是絕對已知的:
jointBindGlobal = inverse(IBM)
node.local = inverse(parentGlobal) @ jointBindGlobal
把骨架靜置姿勢改寫成綁定姿勢後:
身體 蒙皮後 Y 0.000~1.443 與原始頂點差異 平均 0.00000 最大 0.00000
頭髮 蒙皮後 Y 0.496~1.779 與原始頂點差異 平均 0.00000 最大 0.00000
每個部位都保證落在共用的角色空間,身體與頭髮自然對齊,腳精確踏在 Y=0, 連身體原本 0.018 的殘差也一併歸零。三個不同角色實測(ぺこら 1.779m、 ラプラス 1.735m、フブキ 1.592m)Z 都從 0.000 起算。
錯誤三:頭髮沒有貼圖
頭髮的 albedo 叫 t_..._hir_col_alp,而我用 endswith("_col") 篩選 —— 漏掉。
正解是別猜檔名:材質自己就宣告了哪張是基礎貼圖。
m_hir: {'_BaseMap': 't_..._hir_col_alp', '_DefMap': ..., '_DetailRampMap': ...}
改成優先信任 _BaseMap / _MainTex 等 shader 屬性名,檔名後綴只當後備。
錯誤四:眼睛(我第一次的結論是錯的)
第一次看渲圖,ぺこら 的眼睛是黃綠色,但貼圖下半部明明是紅虹膜配紫瞳孔。
我當時查到 m_eye 有 _StencilRef=64 / _ActorRenderMode=2,
就下結論說「遊戲用 stencil 把虹膜裁進眼白,glTF 表達不了」,列為已知限制。
那個結論是錯的。 把虹膜網格的 UV 分佈印出來就知道:
Geo_Iris_LOD0 164 頂點, 1 個 submesh
左上象限 41 頂點 X -0.163~-0.056 Z +0.1357~+0.1671
右上象限 41 頂點 X +0.056~+0.163 Z +0.1357~+0.1671
左下象限 41 頂點 X -0.158~-0.061 Z +0.1392~+0.1659
右下象限 41 頂點 X +0.061~+0.158 Z +0.1392~+0.1659
每隻眼睛有兩層重疊幾何(同一個 X/Z 位置各 41 頂點):
一層貼貼圖上半部、一層貼下半部。而 eye_col 的 alpha:
上半部 alpha=0 佔 91.1% <- 高光層, 幾乎全透明, 該疊加上去
下半部 alpha=0 佔 55.4%, 255 佔 42.8% <- 虹膜層
上半那層是高光疊加層。我把材質輸出成 OPAQUE,於是它 91% 的透明區 被畫成不透明,整片蓋掉底下的紅虹膜 —— 所以看起來是綠的。 跟 stencil 完全無關。
這個錯誤怎麼來的:把一次測量當成全域結論
前一節我為了頭髮量過 alpha:最小值 92、完全沒有 0,正確判定 「那是 shader 遮罩不是透明度,維持 OPAQUE」。然後我把它當成了通則, 再也沒量過其他貼圖。同一個角色的四張基礎貼圖差異其實極大:
| 貼圖 | alpha=0 | alpha=255 | 中間值 | 最小值 | 判定 |
|---|---|---|---|---|---|
身體 bdy_col |
5.9% | 93.7% | 0.4% | 0 | MASK |
眼睛 eye_col |
73.2% | 23.3% | 3.5% | 0 | MASK |
臉部貼花 fdc_col |
87.4% | 7.2% | 5.4% | 0 | BLEND |
頭髮 hir_col_alp |
0% | 93.9% | 6.1% | 92 | OPAQUE |
改成 alpha_mode_for() 逐張量測決定:
沒有全透明區 → OPAQUE;近二值 → MASK;有明顯漸變 → BLEND。
修好後眼睛正確呈現紅虹膜、紫瞳孔、下緣橘黃漸層,身體也沒有被 MASK 打出破洞。
這是這份筆記裡第三次犯同一類錯誤(前兩次是「兩種加密」、「無材質=不渲染」): 拿一次觀測推出通則,然後停止測量。 前一節我還寫下「不要用檔名猜、要量」,結果量了一張就套用到全部。
10. 材質參數與卡通著色
兩個讓 materials.json 一直是壞資料的 bug
(1) 顏色欄位名。 Unity 的材質顏色用 r/g/b/a,我卻用讀四元數的
_v4()(x/y/z/w)去讀 —— getattr(v, "x") 拿不到就回 0,
於是所有顏色都記成 (0,0,0,0)。_BaseColor 實際是 (1,1,1,1)。
(2) 抽材質時沒帶依賴。 extract_model() 內部又自己
load_bundle(data) 了一次 —— 那份沒有依賴,所以共用的 ramp 貼圖
PPtr 解不開,只記成 pathid_1049927514313800383。
修好後(refresh_materials.py 重寫全部 1161 個):
貼圖名稱解析數: 7084 -> 10985 (多解出 3901 個原本是 pathid_ 的)
共用貼圖 (ramp LUT 等): 548 張 -> _extracted_3d/_shared_textures/
順帶一個實作陷阱:
env.files的 key 是 bundle 層級的 id, 而obj.assets_file.name是CAB-xxxx—— 兩個命名空間。 要判斷「這個資產屬於本體還是依賴」,得從 BundleFile 再往下取一層 CAB 名, 否則過濾條件永遠不成立(我第一版就把本體的網格全濾掉了,meshes: 0)。
卡通 shader 到底是什麼
| 槽位 | 內容 |
|---|---|
_BaseMap |
albedo |
_ShadeRampMap |
1024×1 LUT —— 用光照量查表得陰影色 |
_DetailRampMap |
128×16,16 列細節 ramp(由 _DefMap 選列) |
_DefMap |
打包遮罩(平均 RGB ≈ (0,212,17),不是法線圖) |
| 描邊 | 與本體共用頂點的重疊子網格,inverted-hull |
ShadeRamp 實測是平滑漸層(0 個階梯邊界),從 (205,158,148) 暖膚陰影
漸變到白、alpha 30→255。全遊戲只有 5 種 ramp(4 種身體 + 1 種眼睛)。
這就是遊戲那種柔和粉彩感的來源——不是硬邊 cel。
glTF 只能表達 baseColor / metallic / roughness / normal / emissive, 所以匯進 Blender 只會得到「PBR 塑膠感」。這就是「卡通 shader 未還原」的意思。
blender_toon.py —— 近似重建
Diffuse BSDF -> Shader to RGB (EEVEE 專屬) -> 亮度 -> 當 U 取樣 ShadeRamp
-> 乘上 glTF 內嵌的 BaseMap -> 乘 _MultiplyColor -> Emission 直出
這裡又犯了一次「共用名稱」的錯
第一版我把 materials.json 全部讀進來、以材質名當全域 key。
但 m_bdy / m_hir / m_eye 是 608 個模型共用的通用名稱 ——
結果 ぺこら 套上了別的角色的貼圖,藍白裙渲成粉黃。
修正:baseColor 直接沿用 glTF 匯入時建好的節點(內嵌貼圖必然是對的那張), ShadeRamp 依「該 glb 檔名 → 對應模型目錄」查。
沒有重建的部分
DetailRamp 的分列選擇、_DefMap 各通道語意、rim light、
stencil 眼睛合成、頭部方向相關的臉部陰影控制。
描邊也沒有。 描邊殼與本體共用同一批頂點
(子網格 0 與 2 的 firstVertex 都是 0),外推是在 vertex shader 裡做的,
寬度很可能由 _VCMASK(ch3 的 R/G)控制 —— 那段 shader 沒逆出來。
11. 已知限制
- 角色動作無法直接轉成骨骼動畫:Humanoid muscle-space,需要 Avatar, 而 catalog 內沒有。詳見 §8。100 個非角色 clip(場景/運鏡/道具)則完全可用。
- 卡通 shader 只做到近似:
blender_toon.py重建了 ShadeRamp 主線, DetailRamp 分列、_DefMap通道語意、rim light、描邊外推都沒有。見 §10。 - 眼睛的邊緣是硬裁的:
eye_col用 MASK(cutoff 0.5),高光層邊緣 比遊戲內銳利。遊戲另有 stencil(_StencilRef=64)與 ramp 參與合成, 沒有完整重建。虹膜顏色與分層已正確。 - 動作曲線的切線資訊在解碼時捨去:StreamedClip 每個 key 帶三次曲線係數,
目前只取值(
coeff[3]),以 30fps 等間隔取樣補足精度。快速變化的曲線 會比原始播放略平滑。