# M2 · 骨骼动画 + CPU 蒙皮 > 总纲:[`../PLAN.md`](../PLAN.md) §04 / §06 / §07。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D5(拉伸)。 > 本文件是 M2 的详细施工文档。 --- ## 目标 解析 `.msm` / `.msa`,把 [M0](./M0-gr2-reader.md) 读出的动画曲线采样成世界姿势,CPU 线性混合蒙皮, 让 warrior 播 idle / walk / dance,结果与 [oracle](./00-oracle.md) 的**层①(裸 Granny)**逐帧数值一致。 装配一个多部件角色 + 挂点武器,验 LOD 一致性。 ## 前置 - [M1](./M1-static-render.md) 完成:静态网格 + 贴图上屏。 - [M0](./M0-gr2-reader.md) T7(`gr2_anim` 子集)+ scaleshear 用量已知。 - [oracle](./00-oracle.md) T3(层① dump)+ T5(蒙皮顶点 dump)+ T8(噪声地板 → ε)。 ## 交付物 | 产物 | 位置 | |---|---| | `engine/animation.{h,cpp}` 曲线采样 + 世界姿势累积 | `engine/` | | `engine/skinning.{h,cpp}` CPU LBS | `engine/` | | `formats/msa.cpp`(+ 视需要 `msm.cpp`) | `formats/` | | `app/shaders/vs_pnt_skinned.sc` | `app/shaders/` | | `tools/anim_bake/`(曲线退路,视 M0 结论决定是否要) | `tools/` | | 数值对拍报告 | `test/m2-numeric.json` | --- ## 任务分解 ### T1 · `formats/msa` + `msm` 解析 + 验证门 - `.msa`(文本):引用的动画 `.gr2`、混合参数(blend time / ease)、事件。逻辑照 `RaceManager.cpp` / `EterGrnLib/Util.cpp`。 - `.msm`(文本,仅多部件时需要):base model gr2、挂点、材质类型。 - **验证门(PLAN §04 要求)**:dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。 - **完成判据**:`dance_1.msa` 解析出的动画路径 + blend 参数与文本一致。 ### T2 · `engine/animation` —— 曲线采样 + 世界姿势累积 - 按局部时钟 `t` 对每骨 position / orientation / scaleshear 曲线插值 → 局部 `granny_transform` → 沿父链累积 → `world[bone]`。 - 对应 `GrannyBuildWorldPose` / `GrannyGetWorldPoseComposite4x4Array`。 - ease-in/out 曲线、loop count、raw local clock 语义:照 `EterGrnLib/Motion*` + `ModelInstanceMotion.cpp` 调用序列复刻。 - **非单位 scaleshear**:M0 若报告普遍存在,`world[bone]` 要保留完整仿射(4×4 或 4×3),不能退化成刚体。 - **完成判据**:`gr2_anim` 子集覆盖的动画能采样出每帧世界矩阵。 ### T3 · `engine/skinning` —— CPU LBS - `skinMatrix[bone] = world[bone] · InverseWorld4x4[bone]`。 - 逐顶点:`v' = Σ weight[i] · skinMatrix[boneIndex[i]] · v`(4 权重),法线用 `skinMatrix` 的 3×3(scaleshear 时要用逆转置)。 - `GrannyMeshIsRigid` 为真的网格不蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。 - **完成判据**:bind pose(identity 动画)下 CPU 蒙皮结果 == 原始顶点。 ### T4 · `vs_pnt_skinned.sc`(GPU 版,D5 演示用) - `mat4 skin = u_bones[a_indices.x]*a_weight.x + u_bones[a_indices.y]*a_weight.y + ...`。 - CPU 端 `rhi::setBones(skinMatrix[], boneCount)` 上传 uniform 数组。 - **注意**:M2 的**数值门禁走 CPU 蒙皮**(可 dump 顶点对拍);GPU shader 只是演示。CPU/GPU 一致性放 [M4](./M4-realistic-load.md)。 ### T5 · 多部件装配 + 挂点武器 - `.msm` → base gr2 + 额外 gr2(头发 / 时装);各自 mesh binding 到**同一骨架**。 - 武器:`GrannyFindBoneByName("Bip01 R Hand"(或对应名))` → 取该骨世界矩阵 × 武器局部挂点矩阵 → 武器 gr2 的 model 变换。 - **完成判据**:挂点武器的世界变换与 oracle 一致。 ### T6 · LOD 一致性 - 加载同模型 LOD 0–3(`warrior_cheongrin_lod_01/02/03.gr2`),确认它们绑**同一骨架**、骨骼索引一致。 - 在某帧切 LOD,测顶点位移(对应骨骼的顶点,切换前后位置差)。 - **完成判据**:切换帧顶点位移 < 阈值(无肉眼可见跳变)。 ### T7 · 曲线退路(`tools/anim_bake`,条件性) - **仅当** M0 fuzz 报告曲线子类型超出"1–2 种可实现"范围时启用。 - `anim_bake`:Windows 侧用 oracle 把动画按固定帧率(如 60fps)烘焙成密集关键帧(每骨每帧一个 TRS),存自有格式。 - `libgr2` / `animation` 增加"读烘焙格式"分支,PoC 完全绕开曲线解码。 - **完成判据**:烘焙动画在 M2 数值对拍中照常通过。 --- ## 门禁(分级,按序) 0. `.msm` / `.msa` 解析子检查通过(T1)。 1. **骨骼世界矩阵**与 oracle **层①(裸 Granny,`GrannyGetWorldPoseComposite4x4Array` 直出)** 逐帧对拍,`max|Δ| < ε_mat`(noise_floor × 余量)—— 隔离曲线采样 + 姿势累积,**先过**。 - **M2 只对层①**。层②(`ActorInstanceBlend` + LOD 骨骼裁剪)不在 PoC 范围,留正式移植。 2. **蒙皮顶点坐标**与 oracle 逐帧对拍,`‖Δ‖ < ε_vtx`(在矩阵已对上的前提下)—— 隔离蒙皮 + 顶点格式。 3. 挂点武器世界变换与 oracle 一致(T5)。 4. LOD 0–3 共享骨骼绑定,切换无跳变(T6)。 ## 验证 - 数值层对拍:N 帧 × M 顶点、**全部测试资产集**,不是单文件。 - **必须按序**:先骨骼矩阵,再顶点。否则动画错 + 蒙皮错相互抵消、最终顶点却"对",掩盖两个 bug。 - **tie-break**:libgr2 与 oracle 分歧又都合理时,用 Blender `io_scene_gr2` 在同 `t` 算的世界矩阵仲裁(也能抓 oracle 自己的 bug)。 - 视觉层:3 个确定姿势(idle 第 0 帧、走路中段、旋转量大的姿势),差异分类见 PLAN §07。 ## 本步风险(从 PLAN §08 筛) | 风险 | 状态 / 缓解 | |---|---| | Granny 曲线压缩格式多样 | **消除**:M0 实测全 `OldCurveType`,degree ≤ 2,`gr2_anim` 全覆盖,无烘焙退路依赖 | | ease/loop/局部时钟语义 | demo 用简单 loop(`fmod(t, duration)`);ease / blend / accumulation 是正式移植照 `EterGrnLib/Motion*` 复刻,PoC 不需要 | | 骨骼绑定顺序 / mesh binding 重映射 | `gr2::sample_pose` 按骨骼名 retarget(181 warrior 动画 NaN=0);skin 矩阵按 `mesh.bone_bindings` → skeleton 索引 | | rigid + deformable mesh 混合 | `engine/skinning.cpp` 按 `mesh.rigid` 分支(单骨刚体 vs 4 权重混合)| | 非单位 scaleshear 让 `world·invBind` 近似失效 | 全程完整仿射 4x4(`mul4x3` 保 3x3 + 平移);法线用 skin 3x3(scaleshear 严格应逆转置,M2 先近似,记 M4 收紧)| | ε 拍脑袋 | 待 oracle T8 的 noise_floor × 10 | | ~~数值门禁未跑~~ | **已跑**:macOS + Wine + MinGW 交叉编译的 `oracle.exe`(Granny 2.9.12)vs libgr2,骨骼矩阵 + 蒙皮顶点全 ≤ 6.5e-5(float 累积误差量级)。`oracle/RUNBOOK.md` / `tools/oracle_diff` | ## DoD 清单 - [x] `.msm`/`.msa` 解析 dump 与源文本核对通过(`formats/textscript` token 树 + `msa`/`msm`) - [x] **骨骼世界矩阵 vs oracle 层① `max|Δ| ≤ 4.6e-5`** —— `oracle/run-diff-suite.sh` 跑 **23 用例**(warrior_cheongrin/lord ×4 LOD、assassin 双 root、shaman_lord **v7**、redthief2、snakeman、rabbit_backup、ox_01;dance_1/attack/run/wait @ 多个 t)。`test/m2-numeric.json`。 - [x] **蒙皮顶点 vs oracle `‖Δ‖ ≤ 6.5e-5`**(同上,vs `GrannyDeformVertices`;法线用同 3x3,Granny 也不做逆转置 —— `engine/skinning.cpp`) - [x] `test/noise_floor.json`:同-DLL 确定性 0,跨实现地板 mat 4.6e-5 / vtx 6.5e-5,ε=1e-3 - [x] **`app/render_fuzz` 全量渲染烟测**:9166 个 `.gr2` 全过真管线(bgfx + `Scene::build` + DXT 解码 + CPU 蒙皮 + `submit`;skinned 4055 / anim-only 5109 / rigid 2)——**0 崩溃 / 0 空场景 / 0 NaN 姿势**(`test/render-fuzz.json`)。 - **顺带修的 libgr2 bug**:`redthief_general/{back,front}_damage.gr2` 的 finger track 控制点在文件里就是 `NaN`。`read_old_curve` 现在遇到非有限 knot/control 整条曲线弃(回退 bind),不把 NaN 灌进蒙皮链。 - 抽样截图里「模型扭曲」的都是 **render_fuzz 把某物种的动画 retarget 到别的 rig**(如怪物动画播到 warrior 身上,或 warrior dance 播到怪物身上)—— 按名 retarget 到 bind pose 比例不同的骨架,肢体会拉长。**不是 libgr2/engine bug**(同 rig 的 warrior + dance_1/attack/run/wait 已对拍 Granny 1e-5)。render_fuzz 已改成只在 rig 匹配时才应用动画,其余画 bind pose。 - [~] 挂点武器变换 —— `gr2::sample_pose` 出每骨世界矩阵,武器 = `world[handBone]·mount`;未接进 demo(无 .msm 武器数据),逻辑就绪 - [x] LOD 0–3 共享骨架:`warrior_cheongrin` lod_01/02/03 与 base 均 75 骨、骨骼名 + ParentIndex 逐项一致 → 切 LOD 骨骼索引不变、无跳变 - [x] `xrender-demo` 按 `Space` 播 `dance_1`:CPU LBS 每帧更新 dynamic VB,姿势连贯(`test/golden/m2-*.png`) - [ ] (条件)曲线退路 `anim_bake` —— **不需要**:M0 实测曲线全 `OldCurveType` degree ≤ 2,`gr2_anim` 全覆盖 ## M2 实现结果 | 项 | 结果 | |---|---| | T1 `.msa`/`.msm` | `formats/textscript.cpp` 通用 token 树 + `msa.cpp`/`msm.cpp`。`dance_1.msa`→duration 28.333334 / accum 0;`throw.msa`→1 event(type10, t=0.824);`warrior_w.msm`→base + hair_path + 75 hairs。dump 与源文本逐项一致。 | | T2 世界姿势 | `gr2::sample_pose(skeleton, animation, t, world, skin)`(libgr2,跨文件:model gr2 的 skeleton + anim gr2 的 tracks,按骨骼名 retarget)。`world[i]=Composite(local[i])·(parent<0?InitialPlacement:world[parent])`,`skin[i]=InverseWorld4x4[i]·world[i]`。bind pose 时 `skin ≈ I`(6.1e-5)。 | | T3 CPU LBS | `engine/skinning.cpp`:`v'=Σ wᵢ·skin[bᵢ]·v`(4 权重归一),rigid mesh 走单骨刚体分支。`skin_bind_pose_residual` 自检 = 0(skin 全单位 → 输出 == 输入)。 | | T4 GPU 蒙皮 shader | 跳过:M2 数值门禁走 CPU(可 dump 对拍),demo 也用 CPU + dynamic VB。`vs_pnt_skinned.sc` 留到 M4 CPU/GPU 一致性。 | | 无 oracle 抽检 | 全部 181 个 `pc/warrior/**.gr2`:139 个带动画,各在 t=0/⅓/⅔/1 采样 —— **NaN=0**;56 个 track 全名匹配 skeleton(其余部分匹配,未匹配的骨退回 bind,安全)。 | | demo 用法 | `XR_ANIM=` `XR_ANIM_T=<0..1\|秒>` + 交互 `Space` 播放/暂停。 |