Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+184
View File
@@ -0,0 +1,184 @@
# 00 · Oracle —— Windows 真值源工具
> 总纲:[`../PLAN.md`](../PLAN.md) §07。本文件是 oracle 这条**关键路径**的详细施工文档。
> oracle 不是一个里程碑,但它和 M0 并行起步,M0/M1/M2 的主验证全部依赖它。
---
## 目标
在 Windows 侧建一个精简 harness,链接**锁定版本**的 `granny2.dll` + 抽出来的 `EterGrnLib`
对"给定 `.gr2` + `.msa` + 时刻 `t`"能稳定导出:
1. 两层骨骼世界矩阵(① 裸 Granny,② 过完 EterGrnLib
2. mesh 0 蒙皮后的顶点坐标
3. 确定态截图(固定相机 / 光 / model clock / 单动作)
这套 dump 是 Windows-only,永远留在 Windows。
## 前置
- 无硬前置,可与 [M0](./M0-gr2-reader.md) 第一天并行。
- 需要一个会 Windows/MSVC + D3D9 的人(技能和写 libgr2 不同,见 PLAN §09 人力硬约束)。
## 两条轨(关键点)
oracle 分成两半,混在一起会误判工期:
| 轨 | 任务 | 依赖 | 谁需要 | 状态 |
|---|---|---|---|---|
| **Oracle-Lite** —— 只用 `granny2.dll` C API | T1 · T3 · T5 · T6 · T7 · T8 | 一个 `granny2.dll` + 几百行 D3D9 | M0 / M1 / M2 的验证全靠它 | **必做,关键路径** |
| **Oracle-Full** —— 加上抽出来的 `EterGrnLib` | T2 · T4 | 从完整 D3D9 客户端里拆 `EterGrnLib` + 依赖 | 只有正式移植的"层②"对拍 | **可延后**demo/PoC 不阻塞 |
> M2 的门禁只对**层①(裸 Granny)**。层② 是给正式移植准备的。所以 **Oracle-FullT2/T4)不在 demo 关键路径**`EterGrnLib` 拆不干净就整体延后。
## 交付物
| 产物 | 位置 | 轨 |
|---|---|---|
| `granny_probe.exe`(读 gr2 → 打 FileInfo | `oracle/` | Lite |
| `oracle` CLI`oracle dump <model.gr2> <anim.gr2> <t> -o out.bin` | `oracle/` | Lite |
| 最小 D3D9 渲染器(骨架+mesh→PNG,不依赖 EterGrnLib | `oracle/render/` | Lite |
| 锁定的 `granny2.dll` + 判定记录 | `oracle/vendor/` + `oracle/GRANNY-VERSION.md` | Lite |
| dump 二进制格式规范(含坐标约定) | `oracle/FORMAT.md` | Lite |
| 噪声地板测量 | `test/noise_floor.json` | Lite |
| 自检脚本(CI Windows runner | `oracle/selfcheck.*` | Lite |
| `oracle_etergrn` 静态库 + 层② dump | `oracle/full/` | Full(延后)|
---
## 构建顺序(依赖图)
```
T1(锁 DLL + granny_probe) ─┬─▶ T3(层① dump) ─┬─▶ T5(蒙皮顶点 dump) ─▶ T8(噪声地板)
│ │ │
│ └─▶ T7(自检, 先用 1e-4) ◀──┘ T8 后收紧
└─▶ T6(确定态截图 · 独立最小 D3D9) ← M1 视觉验证依赖
[延后 / Oracle-Full] T1 ─▶ T2(抽 EterGrnLib)[L] ─▶ T4(层② dump)
```
**推荐推进**:T1 → T3 → T7(早期最强信号)→ T5 → T6(M1 要)→ T8 → 收紧 T7。T2/T4 只在决定做正式移植时启动。
尺寸:**S** ≈ 0.51 天 · **M** ≈ 24 天 · **L** ≈ 1 周+
---
## 任务分解
> 每个 T 的 AC 是可勾选项。`warrior_cheongrin.gr2` 的期望 count 与 [`M0-gr2-reader.md`](./M0-gr2-reader.md) T4 对齐(第一天用 Blender 导入核对后两边一起填实)。
### T1 · 锁定 Granny 版本 + `granny_probe` **[S]**
> **已知**(M0 T2 从解压后的 gr2 里读到):资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出。候选 `granny2.dll` **优先试 2.4.x 线**2.4.0.7 或最接近的 runtime)。9166 个里 11 个是文件格式 v7、其余 v6。
- 先写 `granny_probe.exe`~30 行):`GrannyReadEntireFileFromMemory``GrannyGetFileInfo` → 打印 `Skeletons/Meshes/Materials/Textures/Animations` 的 count、`FromFileName`、每 skeleton 的骨骼数。
- 收集候选 `granny2.dll`**2.4.x 优先**,再 2.6.x / 2.9.x / 2.11.x 兜底),各链一遍 `granny_probe``warrior_cheongrin.gr2` + 3 个 zone 静态件 + 1 个 `action/*.gr2`
- 选返回全部合理的那个,`git`-track 进 `oracle/vendor/granny2.dll`,判定写 `oracle/GRANNY-VERSION.md`(列出每个候选的输出)。
- **AC**(选定 DLL 下):
- [ ] `warrior_cheongrin.gr2``Skeletons==1``Meshes>=1``Animations==0``FromFileName` 是可读路径串、骨骼数 ∈ [20, 120](人形合理范围)—— 与 [M0 T4](./M0-gr2-reader.md) 期望值一致。
- [ ] `action/dance_1.gr2``Animations==1``Duration` ∈ (0, 60] 秒。
- [ ] zone 静态件:`Skeletons==0` 或 1rigid)、`Meshes>=1`
- [ ] 3+ 文件全过,其余候选 DLL 的失败表现记进 `GRANNY-VERSION.md`
### T3 · dump 层①(裸 Granny **[M]**
-`granny2.dll` C API。输入是**两个 gr2**`model.gr2`(骨架 + ModelInstance+ `anim.gr2``Animation`)。
- 链路:`GrannyReadEntireFileFromMemory(model)``GrannyInstantiateModel` → 读 `anim.gr2``Animations[0]``GrannyPlayControlledAnimation(startTime=0, anim, modelInstance)``GrannySetModelClock(modelInstance, t)``GrannySampleModelAnimations(...)``GrannyGetWorldPoseComposite4x4Array(worldPose, boneCount, 0, out4x4)`
- 输出:`boneCount` + 每骨 `float[16]`(行主序,Granny 原生)。**坐标约定写死**进 `FORMAT.md`:手系、单位、根变换是否已 apply;libgr2 侧声明同一约定。
- **AC**
- [ ] 固定 `(model, anim, t)` 连续两次 dump **byte-identical**(确定性)。
- [ ] **正确性锚**t=0 + identity 动画(或不 play 任何动画)时,层① 的每骨世界矩阵 == 该骨 `InverseWorld4x4` 求逆(`max|Δ| < 1e-4`,即 T7 折进来先跑一遍)。
- [ ] 骨骼数、骨骼名顺序与 `granny_probe` / Blender 一致。
### T5 · dump 蒙皮顶点 **[SM]**
- `GrannyNewMeshBinding(mesh, srcSkel, animSkel)` + `GrannyNewMeshDeformer(...)` + `GrannyDeformVertices(deformer, boneMatrixCount, worldPose4x4, vertexCount, srcVerts, dstVerts)`
- 另用 `GrannyCopyMeshVertices(mesh, PNT332Type, rawBuf)` 读原始顶点作对照。
- 输出 mesh 0 的 `vertexCount` + 每顶点 `float[3]`(要的话加法线)。
- **AC**
- [ ] bind poset=0 / identity)下,`GrannyDeformVertices` 的输出 == `GrannyCopyMeshVertices` 的原始顶点,`max‖Δ‖ < 1e-3`T8 后收紧到 noise_floor)。
- [ ] 顶点数与 [M0 T6](./M0-gr2-reader.md) / Blender 一致。
- [ ] 走路动画某帧的顶点与层① 世界矩阵手算 LBS 的结果一致(自洽)。
### T6 · 确定态截图 harness **[M]** —— M1 视觉验证依赖
- **独立最小 D3D9 渲染器**~200300 行,**不依赖 EterGrnLib**):`CreateDevice``CreateRenderTarget` 离屏 → 用 T3 的世界矩阵 + T5 的蒙皮顶点画三角 → `GetRenderTargetData` → 存 PNGPNG-0 / 无压缩)。
- 强制确定态:注入固定 view/proj、单方向光固定、`GrannySetModelClock(t)` 精确、单动作无混合、无 idle sway(本来就不走客户端 `ActorInstance`,所以天然没有)。
- **AC**
- [ ] 同参数两次截图 byte-identical(或 SSIM = 1.0)。
- [ ] **视觉 sanity**:warrior 是人形、直立、面朝已知方向(+Z)、不炸开 —— 和 Blender 同相机渲染目视一致。
- [ ]`t` 能看到姿势变化(动画链路真的接上了)。
### T7 · oracle 自检(信任前必须过) **[S]**
- 加载 gr2,dump t=0 层① 矩阵,对每骨验 `world_bind[i] · InverseWorld4x4[i] ≈ I``InverseWorld4x4` 来自文件)。
- **AC**
- [ ] 首轮:所有骨 `max|M I| < 1e-4`sanity 天花板)。
- [ ] T8 之后:收紧到 `max|M I| < noise_floor.mat`
- [ ] 过不了 = oracle 的坐标 / 读取有 bug**下游全部作废**,回 T3。
### T8 · 噪声地板 **[S]**
-`(model, anim, t)`:用选定 DLL 跑一遍 + 用一个相邻版本 `granny2.dll` 跑一遍(T1 收集的候选之一);再同版本重复 100 次。
-`mat`(矩阵元素 `max|Δ|`)和 `vtx`(顶点 `max‖Δ‖`)到 `test/noise_floor.json`
- **AC**
- [ ] `noise_floor.json` 产出,含 `mat` / `vtx` 两个值。
- [ ] **sanity**`mat < 1e-3``vtx < 1e-3`(模型单位)。若明显更大 → oracle 还有非确定源没关(回 T3/T6),不是"地板高"。
- [ ] M2 的 `ε_mat` / `ε_vtx` = 对应值 × 安全余量(×10)。
---
## 延后任务(Oracle-Full · 层②)
> 只在决定做正式移植时启动。demo / PoC 不需要。
### T2 · 抽 `EterGrnLib` 单独编 **[L]**
-`m2dev-client-src-main/src/``EterGrnLib` + 依赖最小集(`EterBase` 大部分、`EterLib` 数学 / `GrannyLib` 封装、`SphereLib` 视情况)。
- Windows/MSVC CMake 编成 `oracle_etergrn``#ifdef` 掉 D3D 渲染依赖(`ModelInstanceRender.cpp` 等),目标只是"加载 gr2、建 `CGrannyModelInstance`、跑动作混合、拿骨骼矩阵"。
- **AC**[ ] `oracle_etergrn` 链接通过;[ ] `new CGrannyModelInstance` + `SetModel(warrior_cheongrin.gr2)` 不崩。
- **风险**`EterGrnLib` 依赖 `EterLib` 一大坨(`GrpDevice` 等)。拆不干净 → **整体放弃层②**M0/M2 只对层① 本来就够。
### T4 · dump 层②(过 EterGrnLib **[M]** —— 依赖 T2
- `CGrannyModelInstance::Update(t)``GetBoneMatrixPointer()`,拿过完 LOD 骨骼裁剪 + `ActorInstanceBlend` 混合的最终矩阵。
- **AC**:[ ] 单动作无混合时,层② == 层①(noise_floor 内)——这是 sanity[ ] 混合场景(两动作 blend)与客户端一致(正式移植时再细化)。
---
## 门禁(Oracle-Lite = go / no-go
- **T1**:选定 DLL 下 3+ gr2 的 `GetFileInfo` 全过 AC`GRANNY-VERSION.md` 留档。
- **T7 自检**:全骨 `max|M I|` < 首轮 `1e-4`、T8 后 < `noise_floor.mat`。**这是硬门禁,不过则下游作废。**
- **T3 / T5**:对测试资产集(M0 T9c 产出)产出稳定可复现,含正确性锚(t=0 == bind pose / 原始顶点)。
- **T6**:确定态截图两次一致 + 视觉 sanity(人形直立)+ 换 t 有姿势变化。
- **T8**`noise_floor.json` 产出,`mat` / `vtx` 均 < `1e-3`(否则回 T3/T6 关非确定源)。
- Oracle-FullT2/T4**不是门禁**。
## 验证
- **三方 tie-break**:层① dump vs Blender `io_scene_gr2` 在同 `t` 计算的世界矩阵(Blender 侧写个小脚本)。三方不一致时,**先怀疑 oracle**DLL 版本 / 坐标约定 / T6 非确定源),再怀疑 libgr2。
- Blender 插件选型见 [M0 开工前 TODO](./M0-gr2-reader.md#开工前要定的-todo跑起来才能定非文档缺陷)。
## 本步风险(从 PLAN §08 筛)
| 风险 | 缓解 |
|---|---|
| granny2.dll 版本与资产不匹配 | T1 硬性锁定 + 每候选输出留档 |
| `EterGrnLib` 拆不干净(依赖 `EterLib` 渲染层) | T2/T4 是 **Oracle-Full**,本就延后。拆不动 → 整体放弃层②,M0/M2 只对层① 够用 |
| T6 需要一个 D3D9 渲染器 | 独立写 ~200300 行离屏 D3D9**不碰 EterGrnLib**(否则 T6 被 T2 阻塞,而 T6 是 M1 关键路径) |
| 确定态漏了程序化位移 | oracle 不走客户端 `ActorInstance`,天然无 idle sway;T6/T8 两次不一致就说明还有源,逐个查 |
| T7 阈值 / noise_floor 循环 | T7 首轮用 `1e-4` sanity 天花板;T8 测出真实地板后收紧 T7 |
## DoD 清单(Oracle-Lite
- [ ] `granny_probe.exe` + `oracle/vendor/granny2.dll` 锁定 + `GRANNY-VERSION.md`(含候选对比)
- [ ] `oracle dump <model.gr2> <anim.gr2> <t>` 可用;`oracle/FORMAT.md` 写清二进制布局 + 坐标约定
- [ ] T3 正确性锚:t=0 层① == bind pose`< 1e-4`
- [ ] T5 正确性锚:bind pose 蒙皮 == 原始顶点
- [ ] T6 确定态截图两次一致 + 视觉 sanity + 换 t 有变化
- [ ] T7 自检脚本在 Windows CI runner 绿(首轮 `1e-4`T8 后收紧)
- [ ] `test/noise_floor.json` 产出(`mat`/`vtx` < `1e-3`),M2 引用它设 ε
- [ ] 层① 与 Blender 三方对拍在测试资产集上一致
- [ ] Oracle-Full,可空)T2/T4 状态记录:已做 / 延后 / 放弃
+292
View File
@@ -0,0 +1,292 @@
# M0 · gr2 解析器(libgr2 + gr2dump + fuzz
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §05 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D1。
> 本文件是 M0 的详细施工文档。
---
## 目标
自研 `libgr2`(只读子集),证明能脱离 Granny 从 Metin2 真实 `.gr2` 读出骨架 / 蒙皮网格 / 骨骼权重 / 动画轨道,
且结构与 [oracle](./00-oracle.md) 逐字段一致。产出后续所有里程碑用的测试资产集。
## 前置
- 无代码前置。
- **构建可全程无 oracle 推进**T1T6、T7a、T8、T9a/b 的**代码实现**都不需要 oracle。
- **需要 oracle 的只有验收对拍**:门禁·主、T4/T6 的 count 对拍、T7b 的曲线结果对拍。这些在 oracle 就绪前用**替代基准**Blender `io_scene_gr2` 导入的 count、硬编码松阈值),oracle 到位后收紧。
- **需要 T8 的噪声地板**`test/noise_floor.json`,来自 [oracle](./00-oracle.md) T8):T5 自洽检查的**收紧阈值**。未就绪前 T5 用硬编码 `1e-3`
- oracle 那条线([00-oracle](./00-oracle.md))并行,由会 Windows/D3D 的人推。
## 参考文件(字节级规格来源)
`libgr2` 的所有 struct 布局、枚举值、组合公式,**从泄露 SDK 抄**(只读、不复制代码,作规格):
`MobileSource/Cross Platform/Granny-3D-SDK-main/source/`
| 要的东西 | 文件 | 关键内容 |
|---|---|---|
| 文件头 + magic 变体常量 | `granny_file_format.h` / `.cpp` | `grn_file_magic_value{ u32 MagicValue[4]; u32 HeaderSize; u32 HeaderFormat; u32 Reserved[2]; }`;命名常量 `GRNFileMV_32Bit_LittleEndian` 等 —— 比对文件前 16 字节即认版本 / 字节序 |
| section 头 + fixup 条目 | `granny_file_format.h` | `grn_reference{SectionIndex,Offset}``grn_pointer_fixup{u32 FromOffset; grn_reference To}`12B)、`grn_mixed_marshalling_fixup``grn_section` |
| 成员类型枚举 + stride | `granny_data_type_definition.h` / `.cpp` | `GrannyReal32Member / Int32Member / ReferenceMember / InlineMember / StringMember / TransformMember / EndMember …` 的**数值**从这里抄;stride 表自己按类型算 |
| SRT → 4×4 组合 | `granny_transform.h` / `.cpp` | `granny_transform{ u32 Flags; f32 Position[3]; f32 Orientation[4](quat); f32 ScaleShear[3][3]; }`(68B)+ 组合顺序(T · R · SS);`Flags` 位表明哪部分非单位 |
| **section 解压(Oodle0/1** | `granny_file_compressor.h`(接口 `DecompressData`);算法参照网上公开的 "granny2 Oodle0/Oodle1" 重实现 | **T2 主路径**Metin2 全用 Oodle1`Format==2`|
| 压缩格式枚举 | `granny_file_compressor.h` | `NoCompression=0, Oodle0Compression=1, Oodle1Compression=2` |
| 曲线解码 | `granny_curve.cpp` / `granny_curve_fast.cpp` / `granny_compress_curve.cpp` | T7b 的算法参照;先看 `granny_curve.cpp` 的未压缩关键帧路径。曲线类型标签(T7a)看 `granny_curve.h``CurveDataHeader` |
| v6 兼容分支 | `granny_back_compat.cpp` | SDK 是 2.9.12,读 v6 文件走 back-compat;扫一遍有无 v6 特有偏移 |
> SDK 头注明 `granny_29` / 2011 / v2.9.12。容器 structheader / section / fixup / typetree / transform)在 2.62.11 之间稳定,v6/v7 差异主要在 FileInfo 里装什么对象,不在容器。
## 约定
- **M0 全程用文件原始值,不加任何 basis fix / 单位缩放**。T5 自洽检查、与 oracle 对拍、gr2dump 输出,全在 raw 空间。坐标系转换(Granny art-tool basis → 左手 Y-up)是 [M1](./M1-static-render.md) 渲染时的事。
- oracle 侧 dump 也声明同一 raw 约定(见 [00-oracle](./00-oracle.md) T3)。
## 交付物
| 产物 | 位置 |
|---|---|
| `libgr2` 静态库(demo 子集) | `libgr2/` |
| `gr2dump` CLI(结构化 dump + `--sections` + 可选 glTF | `tools/gr2dump/` |
| `gr2fuzz`(全量 ~9166 解析 + 谓词 + 直方图) | `tools/gr2fuzz/` |
| 格式变体直方图报告 | `test/fuzz-report.json` |
| 测试资产集 | `test/assets.list` |
---
## 构建顺序(依赖图)
线性读 T1→T9 会在 T7↔T9 卡住。实际依赖:
```
T1(header+section ✓) ──▶ T2(Oodle1 解压[M]) ──▶ T3 ──▶ T4 ──▶ ┬──▶ T5
└──▶ T6
T3,T4 ──▶ T7a(分类曲线子类型)
T1..T6 + T7a ──▶ T9a(收集崩溃) ──▶ T9b(加固) ──▶ T9c(报告 + assets.list)
T9c ──▶ T7b(解码选定子集)
T4,T5,T6(+可选 T7b) ──▶ T8(gr2dump)
[并行] 00-oracle 全程;门禁·主 / T4·T6 count / T7b 结果 的验收依赖它
```
**推荐推进**T1→T3→T4 打通"能读到 FileInfo" → T5 自洽检查(最强早期信号,无 oracle)→ T6 → T7a → T9a/b(这里吃掉大部分工作量)→ T9c → T7b → T8 收尾。
尺寸标记:**S** ≈ 0.5–1 天 · **M** ≈ 24 天 · **L** ≈ 1 周+
---
## 任务分解
> 每个 T 下的"AC"是可勾选的验收标准(不是一句话)。带 *(oracle)* 的项在 oracle 就绪前用替代基准。
### T1 · `gr2_file` —— header + section table + fixup **[M]** ✅
> **状态:header + section 表解析已实现**`libgr2/src/gr2_file.cpp`),`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃。
> 剩下的是 **section 解压(Oodle1,见 T2**和 **fixup 重定位**(需展开后的数据)。
**实测结论**(写进代码注释,替换旧假设):
| 项 | 实测 |
|---|---|
| magic | `GRNFileMV_Old``{0xCAB067B8, 0x0FB16DF8, 0x7E8C7284, 0x1E00195E}`)—— **不是** `GRNFileMV_32Bit_LittleEndian` |
| 格式版本 | 6;每文件固定 8 个标准 section |
| section 压缩 | **全部 `Format==2`Oodle1**Texture section 例外(`Format==0` 且空)。`HeaderFormat`(=0)不是 section 压缩字段。|
| `total_size` 字段 | == 文件实际大小(9166/9166 |
| BitKnit | 全样本 0 个 |
| section 头 | `SectionArrayOffset` 相对 `grn_file_header` 起点(= magic 结构之后 32B);每 `grn_section` = 11×u32 = 44B |
-`grn_section``gr2_decompress` 展开到 `ExpandedDataSize`T2Oodle1)→ 遍历 `grn_pointer_fixup[]``FromOffset` 处的值改写成 `To`(section+offset) 的进程内指针 → 小端机跳过 `grn_mixed_marshalling_fixup`
- **AC**`warrior_cheongrin.gr2` = 92 770 B`warrior_cheongrin_lod_01.gr2` = 68 012 B):
- [x] magic 识别(`GRNFileMV_Old` → 32-bit LE);`version == 6`
- [x] `total_size` 字段 == 文件实际大小。
- [x] section 数 == 8,各 `DataSize` / `ExpandedDataSize` ≤ 文件大小,`gr2dump --sections` 输出正确。
- [x] 每个非空 section 成功 Oodle1 展开到 `ExpandedDataSize`T2 完成)。
- [x] 所有 `grn_pointer_fixup``From` / `To` 落在合法范围,0 越界(9166 全量)。
- [x] root object 的 `grn_reference` 落在合法范围。
- **注**"fixup 后能从 root 走到 skeleton" 不在 T1 验收 —— 需要 T3/T4,见"T1+T3+T4 集成检查点"。
### T2 · `gr2_decompress` **[M]** —— 已实现并验证
> **状态:done。** `libgr2/src/oodle1.c` —— 从泄露 SDK 的 `granny_oodle1_compression.cpp` + `radlz.c` + `radarith.c` + `arithbit.c` 端口了**解码路径**(自适应算术编码 + LZ)。约 450 行 C。
- `format==0` → memcpy`format==2`Oodle1)→ `gr2_oodle1_decompress()``format==1`Oodle0/ `format==4`(BitKnit,全样本 0 个)→ 留桩报错。
- 3-block 结构:`stop0/1/2` = section 的 `First16Bit` / `First8Bit` / `ExpandedDataSize`32 位 / 16 位 / 8 位 marshalling 区各一块,共享算术流)。
- **实测结果**
- [x] 全部 **9166 个文件**、每个非空 section 展开到**恰好** `ExpandedDataSize`(否则 `load()` 报错 → fuzz 计 crashed;实际 `crashed=0`)。
- [x] **内容锚(不依赖 oracle**:展开后 Main section 里的内嵌 ASCII 串干净可读 —— `Granny Standard Exporter, SDK version 2.4.0.7``D:\Ymir Work\pc\warrior\warrior_cheongrin.DDS`、类型成员名 `ExporterInfo` / `ExporterName` 等。解码错了这些会是乱码。
- [ ] (可选加强)与 granny2.dll 的 `GrannyDecompressData` 逐字节对拍一次(oracle 就绪后)。
**顺带实测发现**(更新其它文档):
| 发现 | 影响 |
|---|---|
| 资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出 | [00-oracle](./00-oracle.md) T1 的候选 `granny2.dll` 应锁定 **2.4.x 线**,不是 2.9 / 2.11 |
| 9166 个里 **11 个是格式 v7**,其余 v6 | 容器兼容,`libgr2` 两者都能读;`version != 6` 只 warn 不 fail |
### T3 · `gr2_typetree` —— 自描述类型树遍历器 **[M]** ✅
- `granny_data_type_definition` 数组:`{u32 MemberType, char* Name, def* ReferenceType, i32 ArrayWidth, i32 Extra[3], void* Ignored}`32-bit 指针 4B),`MemberType==0`(End) 结尾。枚举值 + stride 表从 `granny_data_type_definition.h/.cpp` 抄。
- `walk(void* obj, const TypeDef* type, Visitor&)`:按成员类型算 stride、递归 `Reference` / `ReferenceToArray`。**不硬编码 struct 布局**,上层按**成员名**取字段。
- 支持子集:`Inline / Reference / ReferenceToArray / ArrayOfReferences / Real32 / Int32 / UInt32 / String / Transform`。子集外 → 记日志、跳过、不崩。
- **AC**`warrior_cheongrin.gr2` root object):
- [x] 遍历不崩,无越界读(9166 全量)。
- [ ] root 的**顶层成员名集合**(去重、排序)== `GrannyFileInfo` 的标准字段:`{ArtToolInfo, ExporterInfo, FromFileName, Textures, Materials, Skeletons, VertexDatas, TriTopologies, Meshes, Models, TrackGroups, Animations, ...}`(以 `granny_file_info.h` 的实际 struct 为准)。
- [x] `gr2dump --members` 打印 `(name, MemberType, ArrayWidth)`,与 `granny_file_info.h` 一致。
- [x] 未知成员类型 0 个(全量)。
### T1+T3+T4 集成检查点
- [x] 从 root 跟指针无崩走到 `Skeletons[0].Bones[0].Name``warrior_cheongrin` 读出 "Bip01"。
### T4 · `gr2_fileinfo` **[S]** ✅(期望值待复核)
- 定位 `FileInfo` root,按名暴露 span 视图:`Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / TrackGroups[] / Animations[]`。对外只给 POD 视图,不泄露内部指针。
- **AC**`warrior_cheongrin.gr2`,期望值先用 Blender 导入核对,oracle 到位后换 `GrannyGetFileInfo`):
- [ ] `Skeletons` count == 1`Models` count == 1。
- [ ] `Meshes` count ≥ 1body,可能 + 附属);`Materials` count == `Textures` count(大概率 1,对应 `warrior_cheongrin.dds`)。
- [ ] `Animations` count == 0(角色本体不带动画,动画在 `action/*.gr2`)。
- [ ] 各 count *(oracle)*`GrannyGetFileInfo` 一致。
- **确认期望值**:M0 第一天先用 Blender 导入 `warrior_cheongrin.gr2` 记下真实 count,填进本节替换"大概率"。
### T5 · `gr2_skeleton` + bind pose 自洽检查 **[M]** ✅
- 骨骼数组:`Name` / `ParentIndex` / `LocalTransform``granny_transform` SRT/ `InverseWorld4x4`。SRT→4×4 组合顺序照 `granny_transform.cpp`
- **自洽检查(纯本地,不依赖 oracle)**:沿父索引累积 `LocalTransform` 重建 `world_bind[i]`,验 `world_bind[i] · InverseWorld4x4[i] ≈ I`
- 阈值:oracle 的 `noise_floor.json` 就绪前用 `max|Δ| < 1e-3`;就绪后收紧到 `noise_floor × 余量`
- 抓"读矩阵带转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
- **AC**
- [ ] `warrior_cheongrin` 全骨 `world_bind · invBind``max|Δ|` < 当前阈值,PASS。
- [ ] 骨骼数、`ParentIndex` 数组、骨骼名列表 *(oracle 或 Blender)* 一致。
- [ ] `LocalTransform.Flags` 分布打印(有多少骨带非单位 orientation / scaleshear)。
### T6 · `gr2_mesh` **[M]** ✅
- 识别顶点类型:`PNT332` / `PNT3322` / 带 `BoneWeights(4×u8)+BoneIndices(4×u8)` 的蒙皮变体(映射表见 DEMO-PLAN §7.3)。
- 实现 `CopyMeshVertices` / `CopyMeshIndices` 的效果:逐顶点拷到统一打包结构。`TriGroups``materialIndex, triFirst, triCount`)、`BoneBindings`mesh 骨骼名→skeleton 索引重映射)。
- 代码可无 oracle 构建;验收对拍见 AC。
- **AC**`warrior_cheongrin.gr2` mesh 0):
- [ ] 顶点类型正确识别(是蒙皮变体,含 weights+indices)。
- [ ] 顶点数 / 索引数 / 三角组数 *(oracle 或 Blender)* 一致。
- [ ] `BoneBindings` 里每个骨骼名都能在 skeleton 里查到(0 个悬空)。
- [ ] 每三角组的 `triFirst + triCount*3 ≤ 索引总数`
### T7a · 曲线子类型分类 **[S]** —— T9 依赖 ✅
- 只做**识别 + 分类**,不解码:遍历 `TrackGroups → TransformTracks`,读出每个 `PositionCurve` / `OrientationCurve` / `ScaleShearCurve` 的曲线类型标签(`CurveDataHeader.Format`)。
- **AC**[ ] `gr2fuzz` 能对任意 `action/*.gr2` 输出"用了哪些曲线子类型 + 每种出现次数";[ ] 未知类型有标签、不崩。
### T7b · 曲线解码(选定子集) **[M]** —— T9c 之后 ✅(精度待 oracle 对拍)
- 依据 `fuzz-report.json` 的曲线子类型直方图,实现**占比最高的 1–2 种**(大概率 `granny_curve.cpp` 的未压缩关键帧 `DaKeyframes*` + 线性插值 / slerp)。
- 其余子类型 → 记入报告,走 [M2](./M2-anim-skinning.md) 的 oracle 烘焙退路。
- **AC**
- [ ] `warrior/action/` 里至少 3 个只用已实现子类型的动画,能读出每骨曲线并在若干采样点求值。
- [ ] track 数 / 动画时长 *(oracle)* 一致。
- [ ] 某采样点的骨骼局部变换 *(oracle 层① @同 t)* `max|Δ| < ε`(ε 同 T5 阈值策略)。
### T8 · `gr2dump` CLI **[S]** ✅(`--gltf` 未实现,非门禁)
- `gr2dump <file.gr2> [--sections] [--gltf out.glb]`
- stdout:骨架树(缩进)、每 mesh 顶点/索引/三角组/顶点布局、每 animation 时长+track数+曲线子类型直方图、bind pose 自洽 PASS/FAIL + max 偏差。`--sections` 打 section 表。
- `--gltf`(**辅助、非门禁、可延后**):写骨架 + 第一个 mesh 的 bind pose+ 若 T7b 实现了,第一个 animation)。**用 `tinygltf` 或手写 JSON+bin —— 不用 `cgltf`(写支持弱)**。仅供 Blender 目视。
- **AC**[ ] `gr2dump warrior_cheongrin.gr2` 输出与上述 T4/T5/T6 的期望值一致;[ ] `--gltf` 产物能被选定的 Blender 插件导入(若已实现)。
### T9a · fuzz 收集崩溃 **[M]** ✅
- 遍历全部 `.gr2``find "$XRENDER_ASSET_ROOT" -name '*.gr2'`;路径含空格 `ymir work`;预期 ~9166 个)。
- 每个文件在子进程 / try 里跑 `libgr2` 全流程,捕获崩溃 / 异常 / 断言,记 `(路径, 阶段, 错误)`
- **AC**:[ ] 跑完全量,产出崩溃清单(首轮预期有几十~几百条);[ ] 崩溃按"阶段(header/section/typetree/skeleton/mesh/curve)× 错误类型"聚类。
### T9b · 逐类加固 **[L]** —— M0 的主要工作量 ✅(9166 零崩溃 / 零谓词失败)
- 按 T9a 的聚类逐个修 `libgr2`:多出来的顶点类型、类型树里的未知成员、section 变体、边界数据。
- 每修一类,重跑 T9a 确认该类清零、无回归。
- **AC**[ ] 9166 个文件**零崩溃**;[ ] 产物全过非退化谓词:
- 通用:骨骼数 ∈ [1,512]、父索引 < 自身或 = -1、变换无 NaN/Inf、顶点数 > 0、所有索引 < 顶点数。
- **仅蒙皮顶点格式**:骨骼索引在范围内;权重和 ∈ [0.99,1.01](3 显式 + 1 隐式的先补齐第 4 个)。rigid mesh 跳过这两条。
### T9c · 报告 + 测试资产集 **[S]** ✅
- 输出 `test/fuzz-report.json`:文件格式版本 / section 压缩类型 / 顶点类型 / **曲线子类型(来自 T7a** / 非单位 scaleshear 用量 的直方图。
- 人工挑 + 报告元数据 → `test/assets.list`(约 15 个,清单见 PLAN §07 测试资产集)。
- **AC**[ ] `fuzz-report.json` 五类直方图齐全;[ ] `assets.list` 覆盖多部件 / 全 4 级 LOD / 多 stage 材质 / 异常个例;[ ] 曲线子类型 + scaleshear 用量的退路决策写进本文件"开工前 TODO"或 PLAN §08。
---
## 门禁(go / no-go
- **主**`libgr2` dump vs 真 Granny —— ✅ **达成**`oracle probe``GrannyGetFileInfo``gr2dump` 逐字段一致;`tools/oracle_diff` 对拍 Granny **2.9.12**`GrannyGetWorldPose4x4Array` + `GrannyDeformVertices`23 用例(13 模型 bind + 8 动画帧,含 v7 / 双 root / 刚体)骨骼矩阵 ≤ 4.6e-5、蒙皮顶点 ≤ 6.5e-5`test/m2-numeric.json`)。oracle 在 macOS + Wine + MinGW 跑(`oracle/RUNBOOK.md`)。
- **自洽**T5 bind pose 自洽 —— ✅ `warrior_cheongrin` `6.1e-5` PASS。全量 2013/2017 < 1e-34 个 `>=1e-1` **经 oracle 证实 libgr2 正确**assassin 对 Granny 3.1e-5),是自洽不变量本身对这几个双-root 资产不成立,非 libgr2 bug。
- **fuzz**T9b 全量 9166 —— ✅ **零崩溃 + 零谓词失败**
- **变体可实现**:✅ 无 BitKnit / 无 Oodle0;曲线全 `OldCurveType`degree ≤ 2,T7b 全覆盖。不做的变体见 [`../../libgr2/README.md`](../../libgr2/README.md)「明确不做」(均 0 出现,坏输入干净报错不崩)。
- **产出**:✅ `test/{fuzz-report,m2-numeric,noise_floor}.json` + `test/assets.list` + `test/oracle_dumps/`
- **noise_floor**:✅ `test/noise_floor.json`。同-DLL 确定性 = 0byte-identical);跨实现地板 = mat 4.6e-5 / vtx 6.5e-5;跨版本一档待另一个 granny2.dll。
**M0 结论:全部门禁达成(含 vs 真 Granny 逐字段对拍)。**
## 验证
- **bootstrap 关系**:M0 自身验证用硬编码引导集(`warrior_*` + 若干 zone);`assets.list` 产出后,M1+ 一律用它。
- **辅助**`--gltf` 拖进 Blender 目视骨架拓扑 + bind pose 网格。glTF 证不了动画曲线解码(表达不了 ease / scale-shear / 常量轨道压缩)。
## 开工前要定的 TODO(跑起来才能定,非文档缺陷)
- **[T4/T5/T6 期望值] Blender 导入核对** —— M0 第一天用选定的 Blender 插件导入 `warrior_cheongrin.gr2`,记下真实的 skeleton / mesh / material / vertex / bone count,替换各 T 里 "大概率" 的占位期望值,写进 `test/README.md`
- **[T7b] 曲线子类型清单** —— 具体做哪 1–2 种,要 T7a + T9c 的 `fuzz-report.json` 才知道。T7b 开工前 `gr2_anim.cpp` 先只做 `granny_curve.cpp` 的未压缩关键帧 + 线性插值。
- **[验证] Blender `io_scene_gr2` 插件** —— 有多个同名插件(SWTOR 版、Metin2 fork),轴向约定各异。M0 第一周:定一个、确认能导入 `warrior_cheongrin.gr2`、记录轴向约定到 `test/README.md`。定不下来 → 三方 tie-break 暂时只靠 oracle,或找第二个开源 gr2 库。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| **section 全是 Oodle1**(实测 9166),T2 从 [S] 变 [M] | **已解**`oodle1.c` 端口自泄露 SDK9166/9166 展开 == `ExpandedDataSize` |
| **T9b 加固吃掉大部分工期** | **已解**:9166 零崩溃 + 零谓词失败。实际 T9b 几乎没触发额外加固(类型树通用遍历 + 懒解引用 + 全程边界检查一次到位)|
| section 用 Oodle0 | **不适用**:全样本 0 个 |
| section 用 BitKnit | **不适用**:全样本 0 个 |
| 自描述类型树递归 / 未知类型 | **已解**:T3 通用遍历器不假设布局;全量 0 个未知成员类型 |
| 曲线子类型多样(B 样条拟合等) | **已解**:全部 `OldCurveType`degree ∈ {0,1,2}、dim ∈ {0,3,4,9}。T7b 全覆盖(degree 3 全样本 0 个)|
| 非单位 scaleshear 普遍存在 | **确认普遍**2267 skeleton / 13298 骨):组合公式已用完整 `R3·SS3`;通知 M2/M3 蒙皮走完整仿射 |
| `InverseWorld4x4` 读错但一致 | **已兜住**T5 自洽检查全量跑,2013/2017 < 1e-34 个异常定位到辅助骨子树(见上)|
## DoD 清单
- [x] T1 header + section 表:`gr2dump --sections` 跑通全部 9166 个文件、0 崩溃、`total_size` 全对
- [x] T2 Oodle1 解压:9166/9166 全 section 展开 == `ExpandedDataSize`;内嵌路径串解压后干净可读(内容锚)
- [ ] T2 加强:与 `GrannyDecompressData` 逐字节对拍一次(oracle 就绪后)
- [x] T1 fixuppointer fixup 索引建立(`(section<<32|from)->Ref`),懒解引用,0 越界(9166 全量)
- [x] T3 类型树遍历器:root object 顶层成员名集合 == `granny_file_info` 标准字段(13 个),`gr2dump --members` 打印一致
- [x] T1+T3+T4 集成检查点:从 root 跟指针无崩走到 `Skeletons[0].Bones[0].Name` 读出字符串(`warrior_cheongrin` → "Bip01"
- [x] T4 FileInfo`warrior_cheongrin.gr2` → skeletons=1 / models=1 / animations=0 / materials=4 / textures=2 / meshes=5*期望值待 Blender/oracle 复核*
- [x] T5 骨架 + bind pose 自洽:`warrior_cheongrin` `max|Δ|=6.1e-5` PASS(阈值 1e-3);全量 2017 个多骨 skeleton 中 2013 个 < 1e-34 个异常见下)
- [x] T6 网格:`warrior_cheongrin` 5 mesh 全 `PNT332_Skinned`,顶点/索引/三角组自洽(`triFirst+triCount*3==indices`),bone-binding 0 悬空
- [x] T9a/b9166 个 `.gr2` **零崩溃** + **零谓词失败**(骨骼数/父索引/NaN/索引越界/权重和/bone-index 范围)
- [x] T9c`test/fuzz-report.json`6 类直方图)+ `test/assets.list`18 个)产出
- [x] T7a 曲线子类型统计完成(见下);T7b OldCurve 解码(d0 常量 / d1 线性 / d2 二次 B 样条 + 四元数归一)+ `Animation::sample_local``dance_1` 等 3+ 采样点无非有限数
- [ ] 门禁·主:结构 dump vs oracle 逐字段一致(oracle 就绪后)
- [ ] T4/T5/T6 期望值 Blender/oracle 复核(替换"待复核"占位)
- [ ] T7b 精度:某采样点骨骼局部变换 vs oracle 层① `max|Δ| < ε`oracle 就绪后)
## M0 实现结果(fuzz 全量 9166`test/fuzz-report.json`
| 维度 | 结果 |
|---|---|
| 解析成功 | **9166 / 9166**0 崩溃,0 谓词失败 |
| 格式版本 | v6 × 9155magic `GRNFileMV_Old`)、**v7 × 11**magic `GRNFileMV_32Bit_LittleEndian`,非 Old;含 `shaman_lord` / snow_dungeon zone / `warrior_rabbit1`|
| section 压缩 | 非空 section 全 **Oodle1**(64162 段);每文件 1 个空 sectioncompression=none)。**无 Oodle0,无 BitKnit** |
| 顶点类型 | `PNT332` × 4016rigid)、`PNT3322` × 309rigid 双 UV)、`PNT332_Skinned` × 3118。**无 `PNT3322_Skinned`,无未知类型** |
| 曲线格式 | **全部是 `OldCurveType`**Granny 2.4`{Int32 Degree; RefToArray Knots; RefToArray Controls}`,无压缩变体、无 `curve2`/`CurveData` variant)。子类型 = (degree, dim)<br>pos `d0·dim3`(322k) / `d0·dim0`(2.5k,恒等) / `d2·dim3`(41k)<br>rot `d0·dim0`(77k) / `d0·dim4`(79k,常量四元数) / `d2·dim4`(210k)<br>scale `d0·dim0`(321k) / `d0·dim9`(42k,常量 3×3) / **`d1·dim9`(3k,线性 3×3)**。**无 degree 3** |
| ScaleShear | 2267 个 skeleton 至少 1 骨带非单位 scaleshear,全语料 13298 骨。**普遍存在** → 组合公式必须走完整 `R3·SS3`(已实现,warrior self-check 15 个 ss 骨仍 6.1e-5|
| bind pose 自洽 | 多骨 skeleton`<1e-4` × 1835、`<1e-3` × 178、`>=1e-1` × **4** |
**bind pose 自洽 4 个异常**`>=1e-1`):`assassin.gr2`PC + season1,同)、`warrior_rabbit1_backup.gr2``hair_11_1.gr2`
这 4 个都是**多 root skeleton**(如 assassin`assasin_low` + `Bip01` 两个 root),且 model 的 `InitialPlacement` 非单位。
> **后续更正(oracle 对拍后)**:这不是 libgr2 的 bug。`assassin` bind pose 的 **骨骼世界矩阵 + 蒙皮顶点与真 Granny 2.9.12 逐字段一致(3.1e-5 / 4.6e-5**(见 [00-oracle](./00-oracle.md) / `oracle/RUNBOOK.md`)。
> `world[i]·InverseWorld4x4[i] ≈ I` 这个**自洽不变量本身**对这几个资产不成立 —— 它们的 `InverseWorld4x4` 是相对「不含 InitialPlacement 的参考」烘的,而 `world = local链 · InitialPlacement`。是资产属性,不是读取错误。
> 自洽检查因此是个**比 oracle 对拍弱的信号**:能抓转置 / 手系 bug,但对「InverseWorld 参考系不一致」会误报。`warrior_cheongrin` 等单 root 模型不受影响(IP 为单位,两者等价)。
## 关键格式发现(补 PLAN §05 / DEMO-PLAN §7
1. **`ReferenceToVariantArray` 磁盘布局是 `{def* Type; int32 Count; void* Ptr}`Type 在前)**,不是 `{Count; Type; Ptr}``VertexData.Vertices` 用它。踩过一次。
2. **`transform_track`Granny 2.4= `{String Name; Inline PositionCurve; Inline OrientationCurve; Inline ScaleShearCurve}`** —— 无 `Flags` 成员,且 Position 在 Orientation 之前,与 2.9 SDK 的 `TransformTrackType` 不同。类型树遍历器按文件自带 typedef 解,不硬编码,所以不受影响;但硬编码偏移会错。
3. **root 偏移的两种放法**:多数 Metin2 skeleton 把 model-space 偏移放在 `model.InitialPlacement`root 骨 `LocalTransform` 是单位;少数(如 `warrior_cheongrin`)把偏移烘进 root 骨 local。self-check 必须 `world[root] = Composite(local[root]) · InitialPlacement`
4. **一个文件可有多个 skeleton**(本体 + 武器/挂点,各带自己的 1-骨 skeleton 和 model)。mesh 的 `BoneBindings` 要对每个 skeleton 试解析、取悬空最少的那个。
5. 组合矩阵语义(`granny_transform.cpp BuildCompositeTransform4x4` + `granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl`):行主序、平移在 elem 12–14、`world[i] = Composite(local[i]) · world[parent]`、上 3×3 = `transpose(R3·SS3)`。self-check 用同一套。
+183
View File
@@ -0,0 +1,183 @@
# M1 · macOS 静态渲染
> 总纲:[`../PLAN.md`](../PLAN.md) §03 / §04 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D0D4。
> 本文件是 M1 的详细施工文档。
---
## 目标
`sokol_app`/SDL2 窗口 + bgfx(Metal),把 [M0](./M0-gr2-reader.md) 读出的 gr2 静态网格(绑定姿势)+ DDS 贴图渲染出来,
轨道相机可转。同时验证 **bgfx ↔ 窗口层的交接**PLAN §03 的高风险点)。
## 前置
- [M0](./M0-gr2-reader.md) 完成:`libgr2` 能出骨架 + 网格;`test/assets.list` 产出。
- [oracle](./00-oracle.md) 的 T6(确定态截图)—— M1 视觉对拍要它。
- `EterImageLib``CDXTCImage` 冻结拷进 `reuse/`
## 交付物
| 产物 | 位置 |
|---|---|
| `xrender-demo` 可执行(macOS | `build/app/` |
| `engine/rhi.{h,cpp}` bgfx 薄封装 | `engine/` |
| `engine/scene.{h,cpp}` `camera.{h,cpp}` | `engine/` |
| 着色器:`vs_pnt` / `fs_pnt` / `fs_normalviz` + `texture_stage.sh` | `app/shaders/` |
| 交接方案决议(sokol_app 还是 SDL2 | 记进 `app/README.md` |
---
## 任务分解(按 DEMO-PLAN 的 D0D4 切)
### T0a · toolchain 骨架(对应 D0a
- `CMakeLists.txt``add_subdirectory(third_party/bgfx.cmake)`pin bgfx 到与 `.sc` 草稿相近版本。
- 窗口层**默认用 bgfx 自带 `entry` 或 SDL2**(零风险,bgfx 所有 example 用的)——不是 sokol_app。
- 一个清屏 app`bgfx::setViewClear(0x303030ff)` + `bgfx::setDebug(BGFX_DEBUG_STATS)` + `bgfx::frame()`
- **编一个 dummy `.sc`**`vs_flat.sc` / `fs_flat.sc`),过 `shaderc -p metal --platform osx` + `bin2c`,断言 `*.sc.bin.h` 生成 —— 提前验证 shaderc 链路(否则要到 T2 才触发)。
- hi-dpiresize 事件里 `bgfx::reset(fbWidth, fbHeight)`retina 上用 framebuffer 像素,不是点)。
- **完成判据**:窗口出现纯色背景 + stats 角标;`*.sc.bin.h` 已生成。
### T0b · sokol_app ↔ bgfx 交接实验(对应 D0b,**不阻塞**)
- 另开一个 target,用 `sokol_app.h``sapp_metal_get_layer()`CAMetalLayer*)填 `bgfx::PlatformData::nwh``init.type = Metal`
- 已知障碍:sokol_app 会自建 MTKView + 自己 present,和 bgfx 抢 layerPLAN §03)。
- **成了** → M1 之后统一切 sokol_app(为移动端生命周期铺路)。**没成** → demo 全程用 T0a 的 SDL2/entrysokol_app 留到 [M3](./M3-mobile.md) 再单独攻。
- **完成判据**:二选一有明确结论并记进 `app/README.md`
### T1 · `engine/rhi` —— bgfx 薄封装(≈150 行)
- 接口签名见 DEMO-PLAN §5「engine/rhi」。语义对齐 `StateManager`
- 关键:`init(nativeHandle,w,h)``createVB/IB/Tex2D/Program``setModel/setBones/setTexture/setStageUniforms``submit``beginFrame/endFrame`
- 深度范围 / Y 翻转:用 `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 决定 `bx::mtxProj` 参数,别手写。
### T2 · `engine/scene` + `vs_pnt/fs_pnt` —— 白模上屏(对应 D2)
- `scene``libgr2::Mesh` → 打包顶点 → `bgfx::VertexLayout`(映射表见 DEMO-PLAN §7.3)→ `createVB`;索引 → `createIB`;每 `TriGroup` 一个 draw item。
- `vs_pnt.sc` / `fs_pnt.sc`:从 `shaders.rar` 拷改。`vs_pnt``a_color0`fog 先关。
- `camera.{h,cpp}`:轨道相机(鼠标拖 = 绕轨道、滚轮 = 距离)。
- 坐标系:加固定 `basisFix`Granny art-tool basis → 左手 Y-up),用朝向明确的资产手调到"warrior 正着站、面朝 +Z",写进 `scene.cpp` 注释。
- **完成判据**:可转的白模,轮廓 = Blender 里同模型。
### T3 · DXT 解码 + 贴图(对应 D3)
- `reuse/EterImageLib``CDXTCImage``LoadHeaderFromMemory` + `LoadFromMemory` + `Decompress(0, rgba)`
- `warrior_cheongrin.dds` 实测 **512×512 DXT3 5mip** —— 先只传 level 0`BGFX_SAMPLER_MIN_POINT` 关 mip;T3 过后再补全 mip 链。
- `texture_stage.sh``shaders.rar` 原样拷,喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform(等价 `tex * vertexColor`)。
-`T` 切贴图 / 白模。
- **完成判据**:UV 无错位、无镜像;和 oracle 确定态截图目视一致。
### T4 · 自检工具(对应 D4)
- `fs_normalviz.sc`10 行):`gl_FragColor = vec4(v_normal*0.5+0.5, 1)`。按 `N` 切。
- 线框:`BGFX_STATE_PT_LINES``bgfx::setDebug(BGFX_DEBUG_WIREFRAME)`。按 `W` 切。
- **完成判据**:法线朝外;三角组分段可见且正确。
### T5 · 多 stage 材质专项(PLAN §04 要求)
-`test/assets.list` 挑一个用了 `D3DTOP_MODULATE2X``ADDSIGNED` 的材质,单独渲一帧和 oracle 确定态截图对拍。
- 不能只靠整场景 SSIM 兜 `texture_stage.sh` 的正确性。
- **完成判据**:该材质的着色结果与 oracle 一致(差异分类内)。
---
## 门禁(go / no-go
- **子门禁 1(交接)**bgfx 经窗口层 handle 在 macOS 出画面 + 输入可用。sokol_app 啃不下 → 用 SDL2/entry,本门禁照样算过(方案已决议)。
- **子门禁 2(几何)**:网格拓扑、UV 正确;无背面剔除错误 / 法线翻转。
- **子门禁 3(材质)**:一个多 stage 材质的着色结果与 oracle 对上。
## 验证
- **对比场景两侧都喂同一张预解码 RGBA**(绕开 DXT),截图 diff 只反映几何 / 光照 / texture-stage,不被"软解 vs 硬件 S3TC"的 bit 级差异污染。
- oracle 侧进入确定态(T6:注入相机矩阵、固定光、绑定姿势、无程序化摇摆)。
- 与 oracle 截图像素 diff + SSIM;差异分类见 PLAN §07 视觉层。
- 法线可视化模式自检。
## 本步风险(从 PLAN §08 筛)
| 风险 | 状态 / 缓解 |
|---|---|
| sokol_app + bgfx 抢 context | **规避**M1 用 GLFW`GLFW_NO_API` + `glfwGetCocoaWindow``PlatformData.nwh`),零冲突。sokol_app 留到 M3。 |
| `.sc` 草稿针对某 bgfx 版本,shaderc 编不过 | **已解**`vs_pnt`/`fs_pnt``shaders.rar` 改写对上当前 pin 的 `bgfx_shader.sh``shaderc -p metal` 编过。 |
| `reuse/EterImageLib` 从没链接过 | **规避**DXT1/3/5 自研 `engine/dxt.cpp`~180 行),完全不碰 EterImageLib。 |
| 坐标系 basiswarrior 躺着 / 镜像 / 巨大 | **已解**`basis_fix = rotX(-90°)·scale(0.01)`warrior 正着站、比例约 1.7m。写死在 `scene.cpp`。 |
| DXT 软解 vs 客户端 GPU S3TC 色差 | 对拍时两侧都用软解 RGBA(`engine/dxt.cpp` 产出,喂给 oracle 侧同一张)。 |
| hi-dpi 视口 | `on_fb_size``glfwGetFramebufferSize``rhi::reset`framebuffer 像素)。 |
## DoD 清单
- [x] `xrender-demo` 在 macOS 起窗口(GLFW+ bgfx Metal init 成功(`renderer: Metal`)(T0a
- [x] 交接方案定为 **GLFW**,记进 `app/README.md`T0b)。sokol_app 不用(抢 layer),留到 M3。
- [x] warrior 绑定姿势上屏,正着站、`basis_fix` = rotX(-90°)·scale(0.01)Granny Z-up cm → Y-up m),轨道相机 + 键位 T/N/W/RT2
- [x] DXT1 / DXT3 软解(`engine/dxt.cpp`,绕开 `reuse/EterImageLib`),贴图 + UV 正确、无镜像(`warrior_cheongrin.dds` 512² DXT3、`warrior_face.dds` 256² DXT1)(T3
- [x] `N` 法线可视化(朝外、平滑)/ `W` 线框(拓扑正确)自检(T4
- [ ] 一个多 stage 材质与 oracle 对拍通过(T5)—— **待 oracle**`texture_stage.sh` 单独走 `fs_stage.sc`M1 主路径用固定 MODULATE(TEXTURE,DIFFUSE)
- [ ] 与 oracle 确定态截图 SSIM ≥ 0.98 或差异分类通过 —— **待 oracle**
## M1 实现结果
| 项 | 结果 |
|---|---|
| 依赖 | bgfx.cmake + bx/bimg/bgfx + glm + glfw`third_party/`pin 见 `VERSIONS.md`)。`tools/bootstrap-submodules.sh` 拉。 |
| 构建 | `cmake -B build -DXRENDER_BUILD_DEMO=ON -DXRENDER_BUILD_TOOLS=OFF``cmake --build build --target xrender-demo`。着色器经 bgfx 的 `shaderc` 编成 `metal` `.bin``cmake/xrender-shaders.cmake`),运行时加载。 |
| 无头验证 | `XR_HIDDEN=1`(隐藏窗口)+ `XR_SCREENSHOT=<path>`bgfx `requestScreenShot` → TGA`rhi` 自带 `CallbackI`+ `XR_MODE=normals|wireframe|bindpose` + `XR_FRAMES=n`。CI / 无显示器也能出确定态截图。 |
| 参考截图 | `test/golden/m1-warrior_cheongrin-bindpose-textured.png``-normals.png` |
| 几何 | 5 meshObject16 / face / Object03 / Object09 / body),顶点/索引数与 M0 一致;bind pose = Granny T-pose;朝向、比例正确 |
| 坑 | ① 近期 bgfx 把 `platform.h` 并进 `bgfx.h`;② `bgfx::setUniform` 要在每次 `submit` 前调(frame 头单调一次不可靠)→ `rhi` 缓存 light/stage 每 draw 重设;③ 截图 TGA 带 alpha 通道,shader 输出非 1 的 alpha 会让 PNG 查看器合成成白 → 不透明物体 shader 固定 `gl_FragColor.a = 1`,截图 writer 也强制 alpha 255 |
## 画质改进 pass(档 1 + 档 2)
用户反馈"渲染毛糙"后做的一轮画质提升。基线:无 MSAA / 无 mip / 平光 / 贴图靠文件名瞎猜 / 无 sRGB。
### 档 1 —— 采样与着色正确性(`engine/` + `app/shaders/`
| 项 | 做法 |
|---|---|
| MSAA | `rhi.cpp` `init`/`reset``BGFX_RESET_MSAA_X4`state 加 `BGFX_STATE_MSAA` |
| mip 链 | `engine/dxt.{h,cpp}` 重写:`decode()``dwMipMapCount`(off 28) 循环解每一级;`Image.mips` 存 level0..N。`scene.cpp` 把各级拼成一块传 `rhi::create_tex2d(packed, …, mips)`bgfx `hasMips = mips>1` |
| sRGB 正确性 | `create_tex2d``BGFX_TEXTURE_SRGB`(采样自动 sRGB→linear);`fs_pnt.sc` 线性空间着色,末尾 `pow(col, 1/2.2)` 编回显示空间 |
| 各向异性过滤 | `create_tex2d``BGFX_SAMPLER_{MIN,MAG}_ANISOTROPIC` |
| 半球环境光 | `fs_pnt.sc``mix(u_ambientGround, u_ambientSky, n.y*0.5+0.5)` 代替常数 ambient |
| 3 盏方向光 | `LightDesc``dir[3]`/`color[3]`key/fill/顶光),`.w` = 强度;`u_lightDir/u_lightColor``Vec4,3` uniform |
| 双面光照 | `fs_pnt.sc``abs(dot(n,l))`(薄片/头发/飘带两面都受光,和 Metin2 一致) |
| 法线 renormalize | vs 输出前 + fs `normalize(v_normal)` |
### 档 2 —— 材质绑定(`libgr2` + `engine/`
**这是"毛糙"的最大来源**:原来整个模型套一张 `<gr2 名>.dds``sura_lord` 根本没有 `sura_lord.dds` → 纯黑。
| 项 | 做法 |
|---|---|
| libgr2 解材质 | `gr2_mesh.cpp` `material_texture_name()``granny_material` → 直接 `.Texture.FromFileName`,或递归 `.Maps[].Map`**注意**:本版 Granny 里 `granny_material_map` 的子材质成员名是 `Map` 不是 `Material`)。`gr2_fileinfo.cpp``FileInfo.materials`(顶层表,调试用)。 |
| 每网格贴图表 | `Mesh.material_textures`(与 `granny_mesh.MaterialBindings` 平行);`tri_group.material_index` 索引它 |
| 逐 tri_group 上贴图 | `scene.cpp``SubMesh.ranges``DrawRange{ib,index_count,tex,alpha_cutout}`),一个网格按 tri_group 切多段,**每段切出独立 IB**,各自贴图。贴图按 `FromFileName` 的 basename 在 gr2 同目录里大小写不敏感查找,带缓存。找不到时回落老的 stem 猜测。 |
| bind pose 也要蒙皮 | 很多 Metin2 模型(shaman_lord 等)的 raw 顶点不在 bind 空间,直接上 raw 会整块错位。`Scene::set_bind_pose()` 有骨架时走 `File::bind_pose(0)` 的蒙皮矩阵(= `set_pose`);bounds/相机框选也按 bind 姿势的顶点算。 |
| alpha-test(镂空) | 解贴图时看 mip0 的 alpha 分布:**同时**有 >5% 近 0 且 >20% 近 255(双峰)才判 cutout(头发/飘带/树叶)。只看"低 alpha 比例"会误伤 alpha 平面全 0 的不透明 DXT3(整块被 discard)。`rhi::set_alpha_test` 逐 draw 开,`fs_pnt.sc``texColor.a < ref → discard`。 |
### 排障中发现并修掉的两个真 bug
| bug | 现象 | 根因 / 修复 |
|---|---|---|
| **多材质网格第 2 段起整块飞出视锥** | shaman_lord(1 网格 2 材质组)只渲出头顶一小撮;任何多组网格丢掉第一组之后的内容 | `Scene::draw``rhi::set_model()`(→ `bgfx::setTransform`)每网格只调一次,但 **bgfx 每次 `submit` 消费一次 transform**。第 2 个 draw range 没设 transform → 用单位阵 → 顶点(~120 单位的 skin 空间坐标)画在 basis_fix 之外。**修复**`set_model` 移进 range 循环,逐 submit 重设。 |
| **bind pose 直接上 raw 顶点** | shaman_lord 等模型整体错位、相机框选发飞(模型变成一个远处的点) | raw 顶点不在 bind 空间。**修复**:`set_bind_pose` 走蒙皮矩阵(见上表)。 |
调试加了 `XR_CAM_YAW` / `XR_CAM_PITCH` / `XR_CAM_DIST`(乘子)env 覆盖初始相机,`XR_VERBOSE` 打 bounds / 相机参数,方便无头抽查各角度。
### 结果
- `warrior_cheongrin` / `warrior_novice` / `sura_lord` / `assassin` / `shaman_lord` / `snakeman` 等正/背/动画各角度截图:贴图正确、比例朝向对、边缘平滑、明暗有层次(见 `test/render-samples/`
- **无回归**`gr2fuzz` 9166/9166、`render_fuzz` 9166/91660 crash/empty/nan)、oracle 逐字段对拍 23/23(≤6.5e-5)、iOS 交叉编译通过
### 明确不做(本 POC 范围外,记档)
| 项 | 原因 |
|---|---|
| 多 stage 材质混合(`fs_stage.sc` | Metin2 角色基本是单 stage MODULATE(TEXTURE,DIFFUSE);多 stage 主要用于地形。`texture_stage.sh` 已备,未接主路径 |
| alpha blend(半透明排序) | cutoutalpha-test)已覆盖头发/飘带主要场景;真半透明要 OIT 或按深度排序,收益低 |
| 顶点色 | 语料里角色网格全是 PNT332(无 color 分量) |
| `.msm` 装配(换发型/换肤) | `formats/msm.cpp` 已能解析,装配是模型组合子系统,非渲染画质 |
| 法线贴图 / 切线帧 | PNT332 无切线;Metin2 资产也没有法线贴图 |
| 阴影 / IBL / FXAA / LOD 选择 | 已有 MSAA x4;阴影/IBL 是独立子系统,env map 资产缺失;FXAA 在 MSAA 之上边际收益小 |
| 特效 / 粒子 / 地形 / 水 / SpeedTree | 独立子系统,POC(读+渲+动 `.gr2` 骨骼模型)范围外 |
+135
View File
@@ -0,0 +1,135 @@
# 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×3scaleshear 时要用逆转置)。
- `GrannyMeshIsRigid` 为真的网格不蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
- **完成判据**bind poseidentity 动画)下 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 03`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` 按骨骼名 retarget181 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 3x3scaleshear 严格应逆转置,M2 先近似,记 M4 收紧)|
| ε 拍脑袋 | 待 oracle T8 的 noise_floor × 10 |
| ~~数值门禁未跑~~ | **已跑**macOS + Wine + MinGW 交叉编译的 `oracle.exe`Granny 2.9.12vs 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_01dance_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 03 共享骨架:`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=<anim.gr2\|.msa>` `XR_ANIM_T=<0..1\|秒>` + 交互 `Space` 播放/暂停。 |
+128
View File
@@ -0,0 +1,128 @@
# M3 · iOS + Android 真机
> 总纲:[`../PLAN.md`](../PLAN.md) §04 / §06 / §07。
> 本文件是 M3 的详细施工文档。与 [M2](./M2-anim-skinning.md) 尾段并行。
---
## 目标
把 [M2](./M2-anim-skinning.md) 的工程原样交叉编译到 iOS + Android 真机跑基准场景,
证明三端渲染一致、移动端性能达标、生命周期(context loss / 后台)稳。
## 前置
- [M2](./M2-anim-skinning.md) 门禁 1–2 通过(骨骼矩阵 + 蒙皮顶点对拍)。
- [M1](./M1-static-render.md) T0b 的交接结论(决定移动端窗口壳用什么)。
- **资产上真机的方案**(见下 T1)—— 不是"原样交叉编译"就完事。
## 交付物
| 产物 | 位置 |
|---|---|
| iOS `.app`Xcode / CMake iOS toolchain | `platform/ios/` |
| Android APKNDK / Gradle 或纯 CMake + `native_app_glue` | `platform/android/` |
| 三端 CImacOS 原生 + iOS 模拟器 + Android 模拟器) | `.github/` 或等价 |
| 真机性能采集报告 | `test/m3-perf.json` |
| 三端快照互拍报告 | `test/m3-snapshot.json` |
---
## 任务分解
### T1 · 资产上真机方案(PLAN §04 / §08
- 9166 个散装 `.gr2` + `.dds` 不能直接堆进 APK / `.bundle`(体积、iOS 限制)。二选一:
- **A(推荐)**M3 前提前接 `EterPack`(现排 M4),资产走 `.eix/.epk``CMappedFile` 的 mmap / `AAsset_getBuffer` 路径。
- **B(最小)**:只把 `test/assets.list` 里那 ~15 个 + 依赖贴图打进去。
- **完成判据**:真机上 `libgr2` 能加载 warrior + dance_1,路径与桌面散文件结果一致。
### T2 · 平台壳
- **窗口 / 生命周期**:按 M1 T0b 结论——sokol_app(若交接成了)或各平台最小原生壳(iOS `MTKView` + `CADisplayLink`Android `NativeActivity` / `GLSurfaceView`)。
- **iOS**:全静态链接(无 `dlopen`);`.bundle` 打包资产;`Info.plist` / 签名走开发证书(不涉及上架)。
- **Android**NDK `arm64-v8a` + `armeabi-v7a``AAssetManager` 经 JNI 注入(复用 MobileSource `AndroidMain.cpp` 形态)。
- bgfx `init.type`iOS = `Metal`Android = `OpenGLES`(或 `Vulkan`,先 GLES3 稳)。
### T3 · Android context lossPLAN §08 高危,确定项)
- 切后台 → GL context 连同 GPU 资源可能失效。
-`SUSPENDED` / `RESUMED`(或 `onSurfaceDestroyed/Created`)里按 bgfx 的重置流程:`bgfx::reset` + 必要时重建 `sg_*` / bgfx 资源句柄。
- **测试**:切后台 ×100`adb shell input keyevent KEYCODE_HOME` + 回前台脚本循环)。
- **完成判据**:100 次无崩、无黑屏、无资源泄漏(`adb shell dumpsys meminfo` 稳定)。
### T4 · iOS 后台 / drawable 生命周期(PLAN §10.1
- 切后台 / 锁屏 / 来电 → 丢 `CAMetalDrawable`
- `applicationWillResignActive` 暂停渲染循环;`didBecomeActive` 恢复;`nextDrawable` 返回 nil 时跳过该帧不崩。
- **完成判据**:后台 / 锁屏 / 来电各 ×20 恢复正常。
### T5 · mediump 精度专项(PLAN §08 / §10.1
- 移动 GPU 上 `mediump` 存不下 60 骨链的骨骼矩阵 / 大坐标 → 抖动 / 爆顶点。
- 顶点着色器里骨骼矩阵与位置强制 `highp`;必要时把模型原点归一(减去包围盒中心)。
- **测试**:highp 前后对比截图,确认抖动 / 爆顶点消失。
- **完成判据**:highp 版无可见抖动。
### T6 · 性能采集(PLAN §07 性能层)
- **基准硬件写死具体机型**iPhone 11A13+ Pixel 6Mali-G78+ 一台 Adreno 机。
- **两种角色**:① 简化(≈5k 三角、≈60 骨、单 draw call);② **满配**(多部件 + 武器 + 时装 + 挂点特效,1–2 万三角、多 draw call)。每种 1 → 8 → 20 → 50 个。
- 指标:帧时间 p50/p99、FPS、draw call、GPU 显存(Xcode GPU report / Android GPU Inspector / `adb dumpsys meminfo`)、冷启动到首帧、单 `.gr2` 加载耗时、峰值 RSS。
- 写进 `test/m3-perf.json`,逐项标注是否越线。
### T7 · 三端快照互拍 + CI
- 确定性场景在 macOS / iOS / Android 各渲一帧,两两 SSIM。
- 三端之间应比各自与 oracle 更接近(同 `shaderc` 源、同逻辑)。
- CIiOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / Metal / GLES 验证层报错"(开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
---
## 门禁(go / no-go
- 三端渲染**差异分类通过**(不是"逐像素一致",见 PLAN §07 视觉层:SSIM ≥ 0.98 直接过;0.950.98 进分类;< 0.95 fail)。
- 性能:简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps**满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps**;冷启动 < 3s;基准 RSS < 300MB;单角色加载 < 30ms。
- 无 bgfx / 图形验证层报错。
- **Android 切后台 ×100 恢复正常**;**iOS 后台 / 锁屏 / 来电恢复正常**。
- **mediump 精度专项通过**highp 后无抖动)。
## 验证
- 三端快照互拍矩阵(T7)。
- 真机性能逐项对通过线(T6)。
- 生命周期压力测试脚本可复现(T3 / T4)。
## 本步风险(从 PLAN §08 筛)
| 风险 | 缓解 |
|---|---|
| Android GL context 丢失后 GPU 资源需重建(确定项) | T3 在 suspend/resume 走 bgfx resetM3 就做别拖 |
| iOS 全静态 + 后台丢 drawable | T2/T4 全静态链接 + drawable nil 跳帧 |
| 9166 散装资产上真机方式没定 | T1 二选一,M3 交付显式包含 |
| 移动 GPU mediump 精度不足 | T5 骨骼矩阵 / 位置用 highp |
| `shaderc` 跨编译 metal / 300_es 行为差异 | 单一 `.sc` 源;开验证层;T7 三端快照对拍 |
| 深度范围 / Y 翻转后端差异 | `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 抹平 |
| sokol_app 交接在 iOS/Android 也不成 | 用各平台最小原生壳(T2 备选) |
## DoD 清单
- [~] iOS **模拟器**跑起来,播 dance_1CPU 蒙皮 + bgfx Metal,输出与 macOS 同 t **像素级一致**(同 `.bin` 着色器、同 `demo_core`)。`test/golden/m3-ios-*.png`。**真机**(差异分类、后台/锁屏/来电、mediump、perf)待设备。
- [ ] Android 真机 —— **脚手架就绪(`platform/android/`)未构建**:本机无 NDK。
- [ ] `test/m3-perf.json` —— 待真机
- [ ] Android 切后台 ×100 —— 待设备(`android_main.cpp``APP_CMD_TERM/INIT_WINDOW` 分支已写 shutdown/reinit
- [ ] iOS 后台 / 锁屏 / 来电 —— 待设备(`ios_main.mm``applicationWillResignActive`/`DidBecomeActive` 暂停/恢复 CADisplayLink 已写)
- [ ] mediump → highp 专项 —— `vs_pnt_skinned.sc` 已写(骨骼矩阵 + 位置全 `highp`);对比测试待真机 GPU
- [ ] CI 三端 job —— 待
- [ ] **M3 全绿 = PLAN §01 判定"方案可行"** —— iOS 一端已验证(编译 + 运行 + 渲染一致);Android 端 + 真机压测待设备
## M3 实现结果(本轮)
| 项 | 结果 |
|---|---|
| **平台无关核心** | `app/demo_core.{h,cpp}` —— 加载 gr2 + 建场景 + 采样姿势 + 画,无窗口/输入依赖。桌面壳(`app/main.cpp` GLFW+ iOS 壳(`platform/ios/ios_main.mm` UIKit+ Android 壳(`platform/android/android_main.cpp` native_app_glue)都调它。 |
| **iOS 壳(T2** | `ios_main.mm``UIApplicationMain``MetalView``+layerClass = CAMetalLayer`)→ `CADisplayLink``demo::frame`。bgfx `nwh = CAMetalLayer*``init.type = Metal`。生命周期回调(T4 骨架)已接。 |
| **iOS 构建 + 运行** | `cmake -G Xcode -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphonesimulator`**BUILD SUCCEEDED**`xcrun simctl` 装 + 跑 iPhone 16 Pro 模拟器:libgr2 加载 warrior + dance_1、DXT3/DXT1 解码、bgfx Metal init、CADisplayLink 跑 120 帧无崩、`requestScreenShot` 出图。渲染结果与桌面同 t 一致。 |
| **交叉编译坑** | ① `BGFX_CONFIG_VIDEO` 默认 ON`video_mtl.cpp` 在 iOS 模拟器编不过(`CVMetalTextureCache` 不可用)→ 关掉。② shaderc 跑不了目标平台 → `xrender_compile_shaders(... PREBUILT <dir>)` 用宿主机预编译的 `metal .bin`Metal 字节码 macOS/iOS 通用)。③ iOS 不建 GLFW`third_party/CMakeLists.txt``NOT IOS` 守卫)。 |
| **Android 壳(T2** | `platform/android/``android_main.cpp`native_app_glue + `ANativeWindow` → bgfx GLES nwh + `AAssetManager` 解资产)、`CMakeLists.txt`NDK,链 `xr_democore` + bgfx GLES)、`build.gradle` + `AndroidManifest.xml``NativeActivity`)。**未构建**(本机无 NDK`platform/android/README.md` 记了装 NDK 后的步骤)。 |
| **T5 GPU 蒙皮 shader** | `app/shaders/vs_pnt_skinned.sc``u_bones[64]` 混合,骨骼矩阵 + 位置 + 法线全 `highp`。**未接进 demo**demo 走 CPU 蒙皮 = M2 数值门禁路径),留 M4 CPU/GPU 一致性 + 真机 mediump 对比。 |
+92
View File
@@ -0,0 +1,92 @@
# M4 · 逼近真实负载(可选)
> 总纲:[`../PLAN.md`](../PLAN.md) §06。
> 本文件是 M4 的详细施工文档。**可选** —— M3 全绿已经能给出 go/no-go 结论,M4 只是把结论从"骨骼网格能跑"推到"接近真实负载也能跑"。
---
## 目标
在 M3 已验证的基础上,加 GPU 蒙皮、eterpack 加载、多角色 + 地面 + 简单光照、LOD 切换,
把性能结论从简化场景推到接近真实的负载。
## 前置
- [M3](./M3-mobile.md) 全部门禁通过。
- [M2](./M2-anim-skinning.md) 的 CPU 蒙皮作为 GPU 蒙皮的对拍基准。
## 交付物
| 产物 | 位置 |
|---|---|
| GPU 蒙皮路径(`vs_pnt_skinned` 全量启用 + 骨骼矩阵 texture 上传) | `engine/skinning.cpp` `app/shaders/` |
| eterpack 加载路径接入 | `reuse/EterPack/` + `engine/scene.cpp` |
| 多角色场景(1 / 8 / 20 / 50 + 地面 + 方向光 | `app/` |
| CPU vs GPU 蒙皮对拍报告 | `test/m4-skin-cmp.json` |
| 扩展性能曲线 | `test/m4-perf-curve.json` |
---
## 任务分解
### T1 · GPU 蒙皮
- 骨骼矩阵改走 GPU:小骨架用 `bgfx::setUniform(u_bones, mtx, boneCount)`uniform 数组,上限 ~128);大骨架 / 多实例用**骨骼矩阵 texture**`bgfx::createTexture2D(RGBA32F)` 每帧 `updateTexture2D`VS 里 `texelFetch`)。
- `vs_pnt_skinned.sc` 从 M2 的演示版转正。
- **完成判据(门禁)**:GPU 蒙皮输出顶点与 M2 的 CPU LBS 结果一致(离屏 transform feedback 或渲到 RT 读回对拍),`‖Δ‖ < ε_vtx`
### T2 · eterpack 加载路径
- `reuse/EterPack`(冻结拷入):`CEterPackManager::RegisterPack` + `Get(mappedFile, "d:/ymir work/.../xxx.gr2", &data)` → LZO 解压 + 解密 → 裸字节喂 `libgr2`
- 需要 `assets/` 打包成 `.eix/.epk``m2dev-client-main/assets/PackMaker.exe``pack.py`Windows 侧一次性做)。
- **完成判据(门禁)**:同一 gr2 经 eterpack 路径 vs 散文件路径,`libgr2` 产出的骨架 / 网格 byte-identical。
### T3 · 多角色场景
- N 个 warrior 实例(各自动画时钟错开),一块带贴图的地面(用一个 zone 静态 gr2 或程序化 quad + terrain 贴图),一个方向光。
- 实例化:bgfx `instanceDataBuffer`(每实例 model 矩阵);蒙皮 texture 每实例一段。
- **完成判据**:50 个角色可交互帧率(真机上 ≥ 20fps 作为下限参考,非硬门禁)。
### T4 · LOD 切换(运行时)
- 按相机距离在 LOD 0–3 间切(用 M2 T6 已验的 LOD 一致性)。
- 切换要平滑:同帧不要让顶点跳(M2 已验位移 < 阈值),或加一帧 cross-fade。
- **完成判据**:镜头拉远/拉近,LOD 切换无肉眼可见 pop。
### T5 · 扩展性能曲线
- 简化 + 满配角色,各 1 / 8 / 20 / 50 个,三台基准机各跑一遍。
- 画帧时间 vs 角色数曲线,标出 CPU 蒙皮 vs GPU 蒙皮两条线。
- 写进 `test/m4-perf-curve.json`
---
## 门禁(go / no-go
- **GPU 蒙皮结果与 CPU 一致**`‖Δ‖ < ε_vtx`)。
- **eterpack 路径与散文件结果一致**byte-identical 骨架 / 网格)。
- 50 角色可交互帧率(参考线,非硬门禁)。
- LOD 运行时切换无 pop。
## 验证
- CPU / GPU 蒙皮对拍(T1)。
- eterpack vs 散文件对拍(T2)。
- 扩展性能曲线覆盖 1 / 8 / 20 / 50 角色 × 两种复杂度 × 三台机(T5)。
## 本步风险
| 风险 | 缓解 |
|---|---|
| GPU 蒙皮骨骼矩阵上传带宽(每帧每实例 60 骨 × 64B) | 用骨骼 texture + `texelFetch`,只 update 变化实例;half-float 视精度 |
| eterpack 解密 key / IV(客户端从服务器拿 `RetrieveHybridCryptPackKeys`) | demo 用离线打包的非加密 pack,或把 key 硬编进 `test/`(自用不公开)|
| 实例化 + 蒙皮 texture 在 GLES3 的上限 | GLES3 保证 `texelFetch` + `RGBA32F` sampledAdreno 老驱动测一下 |
| 50 角色 draw call 爆 | 按材质 / 程序批;满配角色本来就多 draw call,接受较低帧率 |
## DoD 清单
- [ ] GPU 蒙皮输出 == CPU LBS`test/m4-skin-cmp.json`
- [ ] eterpack 路径 == 散文件路径(骨架 / 网格 byte-identical
- [ ] 多角色场景(含地面 + 光)可跑,LOD 运行时切换无 pop
- [ ] `test/m4-perf-curve.json`1/8/20/50 × 简化/满配 × 三机,CPU/GPU 两条线
- [ ] 结论并入 PLAN §10.1 反证清单 + 一页纸结论的"正式移植还缺什么"部分