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:413138363439A18649。 所以 ("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.pyCRC32("Parameters/"+id) 是同一套機制。 所以 skeleton.json 每個節點同時輸出 pathhash,之後綁 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 節骨架。

正確性驗證(不是「跑完沒報錯」)

  1. OBJ 自洽v / vt / vn 數量相等,最大面索引 = 頂點數,無越界。
  2. 骨架語意:骨鏈為 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 二次骨。
  3. 量綱:兎田ぺこら 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_LOD0m_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·SS = diag(-1,1,1,1)
UV v → 1-v(Unity 原點左下,glTF 左上)
三角形環繞 反轉(行列式變號)

四元數那條的推導:R' = M R MM 為非正常變換(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

AnimationClipgenericBindings 只存雜湊:

{'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.BoneToAvatarMapDictionary<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):實測含 -inf9.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.nameCAB-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 等間隔取樣補足精度。快速變化的曲線 會比原始播放略平滑。

在 SiaoHub 檢視原始檔