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
+345
View File
@@ -0,0 +1,345 @@
# Demo 开发方案 —— bgfx 渲染 assets 下的 gr2 资源
> 本文件把 [`PLAN.md`](./PLAN.md) 的 M0 + M1+ M2 拉伸目标)落成一个**可演示的 demo** 的文件级施工图。
> demo 只做 macOSMetal),跑通后 iOS/Android 是同一套代码换 toolchain。
> 前置事实、坐标/格式细节、风险都以 `PLAN.md` 为准,这里不重复论证。
---
## 1 · Demo 目标与演示脚本
**一句话**:一个原生 macOS 窗口,直接从 `m2dev-client-main/assets/` 读一个 `.gr2` + 它的 `.dds`,用 bgfx/Metal 把这个带蒙皮的角色渲染出来,轨道相机可转,能切线框 / 法线可视化;拉伸目标是让它播一个 `.msa` 动作。
**演示脚本(给人看的顺序)**
| 步骤 | 屏幕上 | 证明了 |
|---|---|---|
| 1 | 终端跑 `gr2dump warrior_cheongrin.gr2`,打印骨架树(骨骼名 + 父索引 + 层级缩进)、网格摘要(顶点数 / 索引数 / 三角组)、动画列表 | libgr2 能脱离 Granny 读结构 |
| 2 | 窗口出现,warrior 网格以**绑定姿势**显示,白模,轨道相机可拖转、滚轮缩放 | gr2 顶点 / 索引 / 骨架 → bgfx,交接成立 |
| 3 | 按 `T` 贴上 `warrior_cheongrin.dds`(DXT3 软解),材质正确 | DXT 解码 + texture-stage + UV 正确 |
| 4 | 按 `N` 切法线可视化、按 `W` 切线框 | 自检工具,抓法线翻转 / 拓扑错 |
| 5(拉伸)| 按 `Space``action/dance_1.gr2` + `dance_1.msa`,角色跳舞,CPU 蒙皮 | 曲线采样 + LBS 成立,M2 的核心 |
| 6 | 角标 HUD 显示 FPS / draw call / 三角数(bgfx 自带 `showStats` | 有性能可读数 |
**不做**:地形、特效、UI、多部件装配、eterpackdemo 用散装文件)、iOS/Android(同代码后续换 toolchain)。
---
## 2 · 演示用资产(具体文件,散装,无需解包)
| 用途 | 路径(相对 `m2dev-client-main/` | 事实 |
|---|---|---|
| 主模型 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.gr2` | 92 770 Bgr2 格式 v68 section**section 全 Oodle1 压缩**(实测)|
| 主贴图 | `assets/PC/ymir work/pc/warrior/warrior_cheongrin.dds` | **512×512 DXT35 mip** |
| LOD 对照 | `warrior_cheongrin_lod_01/02/03.gr2` | 同骨架、减面,用于 M2 的 LOD 一致性检查 |
| 动作(拉伸)| `assets/PC/ymir work/pc/warrior/action/dance_1.gr2` + `dance_1.msa` | 动画单独一个 gr2 + 文本 msa |
| 刚体静态件(对照)| `assets/Zone/ymir work/zone/oxevent/ox_01.gr2` | 无骨骼,验证 rigid 分支 |
> demo 起步**不碰 `.msm`**`warrior_m.msm` 引用 `warrior_novice.GR2` + 61 组头发 + 一堆动作,太复杂)。直接 `warrior_cheongrin.gr2` + 同名 `.dds` 是自足的 mesh+texture 对。`.msm` 解析留到 M2 多部件装配。
**资产接入方式**CMake 里配一个 `XRENDER_ASSET_ROOT` 指向 `../m2dev-client-main/assets`demo 直接 `fopen` / mmap 读,不复制。
---
## 3 · 依赖与仓库骨架
```
xrender-poc/
third_party/
bgfx.cmake/ submodule → github.com/bkaradzic/bgfx.cmake(拉 bx/bimg/bgfx
⚠ pin 到与 shaders.rar 的 .sc 草稿相近的 bgfx 版本
sokol/ 只放 sokol_app.h(单文件,手动 vendor
glm/ submodule 或单目录 vendor
cgltf/ 单头文件(gr2dump 导出 glTF 用)
reuse/
EterImageLib/ 从 ../MobileSource 冻结拷入:DXTCImage.{h,cpp} + StdAfx + 依赖的最小集
EterBase/ (拉伸)CFileBase / CMappedFile —— demo 初期可先用裸 fopen
libgr2/
include/gr2.h
src/gr2_file.cpp header + section table + fixup 重定位
src/gr2_typetree.cpp 自描述 data_type_definition 遍历器
src/gr2_fileinfo.cpp FileInfo 根对象 → skeleton/mesh/material/animation 视图
src/gr2_skeleton.cpp 骨骼数组 + bind pose 自洽检查
src/gr2_mesh.cpp 顶点/索引/三角组/BoneBindings 提取
src/gr2_anim.cpp TrackGroup → 每骨曲线;曲线子类型解码(demo 子集)
src/gr2_decompress.cpp section 解压分派
src/oodle1.c Granny Oodle1 解码(已实现,端口自泄露 SDK;9166/9166 验证通过)
engine/
rhi.h/.cpp ≈150 行 bgfx 薄封装
camera.h/.cpp 轨道相机
skinning.h/.cpp CPU LBS(拉伸)
animation.h/.cpp 曲线采样 + 世界姿势累积(拉伸)
scene.h/.cpp 把 libgr2 视图 → GPU buffer + draw item
formats/
msa.cpp (拉伸)文本 msa 解析
app/
main.cpp sokol_app 回调 + bgfx init + 输入 + demo 状态机
shaders/
varying.def.sc 从 shaders.rar 拷 + 加 a_indices/a_weight
vs_pnt.sc fs_pnt.sc 从 shaders.rar 拷改
vs_pnt_skinned.sc 新写(骨骼矩阵 uniform 数组)
texture_stage.sh 从 shaders.rar 原样拷
compile.cmake shaderc → metal + bin2c
tools/
gr2dump/main.cpp CLI:结构化 dump + glTF 导出
oracle/ Windows-only,见 PLAN §07demo 阶段可先跳,用 Blender io_scene_gr2 当参照)
cmake/
macos.cmake
CMakeLists.txt
```
---
## 4 · 构建(macOS 优先)
```bash
# 1. 拉依赖
git submodule add https://github.com/bkaradzic/bgfx.cmake third_party/bgfx.cmake
cd third_party/bgfx.cmake && git submodule update --init && cd -
# 固定 bgfx 版本(示例):cd third_party/bgfx.cmake/bgfx && git checkout <pinned-tag>
# 2. 配置 + 编
cmake -B build -DXRENDER_ASSET_ROOT=../m2dev-client-main/assets -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
# 3. 跑
./build/tools/gr2dump/gr2dump "$XRENDER_ASSET_ROOT/PC/ymir work/pc/warrior/warrior_cheongrin.gr2"
./build/app/xrender-demo
```
**`CMakeLists.txt` 要点**
- `add_subdirectory(third_party/bgfx.cmake)` → 得到 `bgfx` / `bx` / `bimg` / `shaderc` 目标。
- 着色器编译:`app/shaders/compile.cmake` 里对每个 `.sc``shaderc -p metal --platform osx`,产物过 `bin2c` 生成 `*.sc.bin.h`,作为 `xrender-demo` 的生成依赖。
- `reuse/EterImageLib` 编成静态库 `xr_eterimage``-Wno-*` 压掉老代码告警;可能要 `-DXR_STANDALONE` 剥掉 `StdAfx.h` 里的 Windows include。
- macOS`-framework Cocoa -framework Metal -framework QuartzCore``main.cpp` 编成 `.mm`sokol_app 的 macOS 后端要 ObjC)。
---
## 5 · 模块与文件清单(职责 + 估行)
### libgr2demo 子集,只读)
| 文件 | 职责 | 估行 | demo 边界 |
|---|---|---|---|
| `gr2_file.cpp` | magic`GRNFileMV_Old`+ `grn_file_header` + section 表(**已实现**9166/9166 跑通)+ fixup 重定位 | 250 | v6 / 32-bit LE |
| `gr2_decompress.cpp` + `oodle1.c` | section 解压分派 + Granny Oodle1 解码器 | 40 + 450 | **已实现**:从泄露 SDK 端口解码路径,9166/9166 展开到精确 `ExpandedDataSize`,内嵌串验证 |
| `gr2_typetree.cpp` | 按 `granny_data_type_definition`(文件内嵌,自描述)递归走类型树,把裸内存映射成可访问的字段 | 300 | 通用遍历器,不硬编码结构;只需支持 gr2 实际用到的成员类型(Real32 / Int32 / Ref / ReferenceToArray / Inline / String |
| `gr2_fileinfo.cpp` | 定位 `FileInfo` 根对象,暴露 `Skeletons[] / VertexDatas[] / TriTopologies[] / Meshes[] / Materials[] / Textures[] / Models[] / Animations[]` 的 span 视图 | 150 | — |
| `gr2_skeleton.cpp` | 骨骼数组(`Name / ParentIndex / LocalTransform(SRT) / InverseWorld4x4`);**bind pose 自洽检查**:重建 `world_bind`,验 `world_bind[i]·InverseWorld4x4[i]≈I` | 180 | — |
| `gr2_mesh.cpp` | 顶点(识别 `PNT332` / `PNT3322` / 带 `BoneWeights+BoneIndices` 的蒙皮变体)、索引、`TriGroups`(按材质分段)、`BoneBindings`mesh→skeleton 骨骼名映射) | 220 | 只支持上述三种顶点布局 |
| `gr2_anim.cpp` | `Animation → TrackGroups → TransformTracks`;每骨 position/orientation/scaleshear 曲线;曲线解码 + `Animation::sample_local(t)` | 320 | **已实现(全覆盖)**M0 实测 Metin2 曲线**全部**是 `OldCurveType`Granny 2.4`{Degree; Knots[]; Controls[]}`,无压缩变体)。degree 0(常量)/ 1(线性)/ 2(二次 B 样条)+ 四元数归一。**degree 3 全样本 0 个**,无需烘焙退路。精度待 oracle 层① 对拍 |
`include/gr2.h` 对外暴露纯 POD 视图(`gr2::Skeleton` / `gr2::Mesh` / `gr2::Animation`),不泄露内部指针 —— 上层只依赖这个头。
### reuse/EterImageLib(冻结拷入)
- `DXTCImage.{h,cpp}``LoadHeaderFromMemory` + `LoadFromMemory` + `Decompress(level, DWORD* out)` → RGBA8。demo 用 DXT3 分支。
- 依赖裁剪:把 `StdAfx.h` 换成一个最小 shim`typedef uint8_t BYTE` 等),去掉 `windows.h`
- 备选:DXT3 解码就 ~150 行,嫌 EterImageLib 依赖脏可以自己写一个 `dxt.cpp`
### engine/rhi.{h,cpp}(≈150 行)
对齐 `StateManager` 语义的 bgfx 薄封装:
```cpp
namespace rhi {
void init(void* nativeWindowHandle, int w, int h); // bgfx::PlatformData + bgfx::init
void resize(int w, int h);
void beginFrame(const glm::mat4& view, const glm::mat4& proj);
Handle createVB(const void* data, uint32_t size, const bgfx::VertexLayout&);
Handle createIB(const void* data, uint32_t size, bool i32);
Handle createTex2D(const void* rgba, uint16_t w, uint16_t h, uint16_t mips);
Handle createProgram(const uint8_t* vs, uint32_t vsLen, const uint8_t* fs, uint32_t fsLen);
void setModel(const glm::mat4&);
void setBones(const glm::mat4* mtx, uint16_t n); // uniform 数组,vs_pnt_skinned 用
void setTexture(Handle);
void setStageUniforms(const StageDesc&); // texture_stage.sh 的 uniform
void submit(Handle vb, Handle ib, Handle prog, uint64_t state);
void endFrame();
}
```
### app/main.cpp
- `sokol_app` 描述:`.window_title``.high_dpi=true`、macOS 后端;**不让 sokol_app 建 GL/Metal 设备** —— 用 `sapp_metal_get_layer()` / `sapp_macos_get_window()` 取原生 handle 传给 `rhi::init`(这就是 PLAN §03 的交接点,D2 的验收)。
- `frame_cb`:更新相机 → `rhi::beginFrame` → 遍历 scene draw items → `rhi::submit``bgfx::frame()`
- `event_cb`:鼠标拖 = 轨道、滚轮 = 缩放、键 `T/N/W/Space`
- demo 状态机:`enum { BindPose, Textured, NormalViz, Wireframe, Animating }`
### app/shaders
| 文件 | 来源 | 改动 |
|---|---|---|
| `varying.def.sc` | shaders.rar 拷 | 加 `int4 a_indices : BLENDINDICES;` `vec4 a_weight : BLENDWEIGHT;` |
| `vs_pnt.sc` / `fs_pnt.sc` | shaders.rar 拷 | `vs_pnt``a_color0``fs_pnt` 加 fog(demo 可先关);确认 `#include <bgfx_shader.sh>` 对上 pin 的 bgfx 版本 |
| `vs_pnt_skinned.sc` | 新写 | `mat4 skin = u_bones[a_indices.x]*a_weight.x + …`4 权重);其余同 `vs_pnt` |
| `texture_stage.sh` | shaders.rar 原样 | 不改;demo 里给它喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform,等价于 `tex * vertexColor` |
| `fs_normalviz.sc` | 新写(10 行) | `gl_FragColor = vec4(v_normal*0.5+0.5, 1)` |
### tools/gr2dump/main.cpp
- 参数:`gr2dump <file.gr2> [--gltf out.glb]`
- stdout:骨架树(缩进)、每 mesh 的顶点/索引/三角组/顶点布局、每 animation 的时长 + track 数 + 曲线子类型直方图、bind pose 自洽检查结果(PASS/FAIL + max 偏差)。
- `--gltf`:用 cgltf 写骨架 + 第一个 mesh 的 bind pose + (若实现了)第一个 animation。**仅供 Blender 目视**,动画正确性不认它(PLAN §06 M0)。
---
## 6 · 实现顺序(每步一个可演示切片)
| 步 | 交付 | 演示点 | 验收 |
|---|---|---|---|
| **D0** | CMake 骨架编过:空 `xrender-demo` 开一个 bgfx 清屏窗口(纯色)+ `bgfx::showStats` | 窗口出现、FPS 角标在跳 | bgfx 经 sokol_app handle 起来了(**PLAN §03 交接子门禁的最小版**) |
| **D1** | `gr2_file` + `gr2_typetree` + `gr2_fileinfo` + `gr2_skeleton``gr2dump` 能打印 `warrior_cheongrin.gr2` 骨架树 + mesh 摘要 | 终端 dump 输出 | 骨骼数 / 名称 / 父索引与 Blender `io_scene_gr2` 导入一致;bind pose 自洽 PASS |
| **D2** | `gr2_mesh` + `engine/scene` + `rhi` + `vs_pnt/fs_pnt`warrior **白模绑定姿势**上屏,轨道相机 | 能转的白模 | 轮廓 = Blender 里同模型;无背面/法线翻转(配合 D4 的法线可视化确认) |
| **D3** | `reuse/EterImageLib` DXT3 解码 → `rhi::createTex2D``texture_stage.sh`;按 `T` 贴图 | 有材质的 warrior | UV 无错位、无镜像;和客户端截图目视一致 |
| **D4** | `fs_normalviz` + 线框 state`BGFX_STATE_PT_LINES``BGFX_DEBUG_WIREFRAME`);`N` / `W` 切换 | 法线彩图 / 线框 | 法线朝外;三角组分段正确 |
| **D5(拉伸)** | `gr2_anim`(子集)+ `formats/msa` + `animation` + `skinning`CPU LBS+ `vs_pnt_skinned``Space``dance_1` | 角色跳舞 | 与 Blender 导入的同一动画逐帧目视一致;无肢体飞出/顶点塌陷。数值对拍要 oracle(见 §8) |
**D0D4 = 可交付的 demo**(静态带贴图 + 自检工具)。D5 是加分项,卡住不影响 demo 成立。
---
## 7 · 关键实现细节
### 7.1 gr2 v6 文件结构(`gr2_file.cpp`
```
0x00 BYTE magic[16] // B8 67 B0 CA F8 6D B1 0F 84 72 8C 7E 5E 19 00 1E ← 认版本/字节序
0x10 u32 headerSize // 0x1B8
0x14 u32 headerFormat // 0(这不是 section 压缩!section 压缩看每段 grn_section.Format
0x18 u32 reserved[2]
--- GrannyFileHeader ---
0x20 u32 version // 6
0x24 u32 totalSize // == 文件大小,用来自检解析对齐
0x28 u32 crc32
0x2C u32 sectionArrayOffset // 0x38
0x30 u32 sectionArrayCount
0x34 u32 rootObjectTypeSection / rootObjectTypeOffset / rootObjectSection / rootObjectOffset
...
--- Section[sectionArrayCount] --- 每项 ~44 B
u32 Format // 0=none 1=Oodle0 2=Oodle1Metin2 实测全 2
u32 dataOffset, dataSize
u32 expandedDataSize
u32 alignment
u32 first16Bit / first8Bit // marshalling 边界
u32 pointerFixupArrayOffset, pointerFixupArrayCount
u32 mixedMarshallingFixupArrayOffset, mixedMarshallingFixupArrayCount
```
**流程**:读 header → 逐 section`gr2_decompress` 展开到 `expandedDataSize` 的缓冲 → 应用 pointer fixup(把文件内偏移改写成进程内指针)→ (小端机上 mixed-marshalling fixup 可跳过,big-endian 才需要)→ 得到一组可随机访问的 section 内存块。root object 在 `(rootObjectSection, rootObjectOffset)`,其类型定义在 `(rootObjectTypeSection, rootObjectTypeOffset)`
### 7.2 自描述类型树(`gr2_typetree.cpp`
`granny_data_type_definition` 是数组,每项:`{ MemberType(u32), Name(char*), ReferenceType(def*), ArrayWidth(i32), Extra[3], Ignored }`,以 `MemberType==0`End)结尾。`MemberType` 枚举含 `Inline / Reference / ReferenceToArray / ArrayOfReferences / Real32 / Int32 / UInt32 / String / Transform / …`
写一个 `walk(void* obj, const TypeDef* type, Visitor&)`:按成员类型算 stride、递归 `Reference`/`ReferenceToArray`。**不要硬编码 struct 布局** —— 不同 Granny 小版本字段顺序会变。上层 `gr2_fileinfo` 按**成员名**`"Skeletons"`, `"Meshes"` …)取字段,用 `GrannyFindMatchingMember` 式的按名查找。
### 7.3 顶点布局 → `bgfx::VertexLayout``gr2_mesh.cpp` + `scene.cpp`
gr2 mesh 的顶点类型在文件里(`GrannyGetMeshVertexType`)。demo 支持:
| gr2 顶点类型 | 成员 | bgfx layout |
|---|---|---|
| `PNT332` | Pos3f, Norm3f, UV2f | Position/Normal/TexCoord0 |
| `PNT3322` | Pos3f, Norm3f, UV2f, UV2f | + TexCoord1 |
| 蒙皮变体 | + BoneWeights(4×u8 归一) + BoneIndices(4×u8) | + Weight/Indices`bgfx::Attrib::Weight` + `Indices``AttribType::Uint8`, normalized=weight true / indices false|
`GrannyCopyMeshVertices(mesh, dstType, dstBuf)` 的效果自己实现:按源类型逐顶点拷到一个 demo 统一的打包结构,再 `bgfx::createVertexBuffer`。索引:gr2 是 u16 或 u32`GrannyCopyMeshIndices` 同理。三角组 `TriGroups``(materialIndex, triFirst, triCount)`,每组一次 `rhi::submit`
### 7.4 DXT3 → bgfx 纹理(`scene.cpp`
demo 走**运行时软解**PLAN §08:移动端无 S3TC,且要和 oracle 对齐时贴图要能预解码):
```
CDXTCImage img;
img.LoadHeaderFromMemory(ddsBytes); // 认 512x512 DXT3 5mip
img.LoadFromMemory(ddsBytes);
std::vector<uint32_t> rgba(w*h);
img.Decompress(0, rgba.data()); // level 0mip 链 demo 可先不传,让 bgfx 不采样 mip
rhi::createTex2D(rgba.data(), 512, 512, 1);
```
`bgfx::createTexture2D` + `BGFX_SAMPLER_MIN_POINT` 之类先关 mip,D3 通过后再补全 mip 链。)
### 7.5 坐标系(`scene.cpp` / `camera.cpp`
PLAN §07**约定写死**。demo 取"左手 Y-up、单位 = gr2 原始单位、根变换已 apply"。gr2 里骨架/网格是 Granny 约定(通常右手 Z-up 或文件指定的 art tool basis)。demo 先加一个固定 `basisFix`(可能是绕 X -90° + Z 翻转,M0 用朝向明确的资产标定),乘进 model 矩阵。**左右手系**bgfx 用 `bx::mtxLookAt` / `bx::mtxProj``bx::Handedness` 参数统一,和 `basisFix` 一起调到"warrior 正着站、面朝 +Z"。
### 7.6 bgfx 提交循环(`main.cpp` / `rhi.cpp`
```cpp
bgfx::setViewRect(0, 0,0, w,h);
bgfx::setViewClear(0, BGFX_CLEAR_COLOR|BGFX_CLEAR_DEPTH, 0x303030ff, 1.0f);
bgfx::setViewTransform(0, &view, &proj);
// per draw item:
bgfx::setTransform(&model);
bgfx::setVertexBuffer(0, vb);
bgfx::setIndexBuffer(ib);
bgfx::setTexture(0, s_texColor, tex);
bgfx::setUniform(u_stageColor, &stage, 1);
if (skinned) bgfx::setUniform(u_bones, bones, boneCount);
bgfx::setState(BGFX_STATE_WRITE_RGB|BGFX_STATE_WRITE_A|BGFX_STATE_WRITE_Z
|BGFX_STATE_DEPTH_TEST_LESS|BGFX_STATE_CULL_CW
|BGFX_STATE_MSAA);
bgfx::submit(0, prog);
// end:
bgfx::frame();
```
深度范围 / Y 翻转:用 `bgfx::getCaps()->homogeneousDepth``originBottomLeft` 决定 `bx::mtxProj` 参数,别手写。
### 7.7 sokol_app ↔ bgfx 交接(`main.cpp`D0 的验收)
```cpp
// sokol_app 里关掉它自己的渲染循环意图,只要窗口 + 事件
bgfx::PlatformData pd{};
pd.nwh = sapp_macos_get_window(); // NSWindow*
pd.ndt = nullptr;
// Metal: 也可以 pd.nwh = (__bridge void*)sapp_metal_get_layer(); // CAMetalLayer*
bgfx::Init init;
init.type = bgfx::RendererType::Metal;
init.platformData = pd;
init.resolution.width = sapp_width();
init.resolution.height = sapp_height();
bgfx::init(init);
```
若这条在 macOS 上出不了画面(sokol_app 和 bgfx 抢 layer)——按 PLAN §03 退路:`app/` 换成 SDL2 窗口,`SDL_GetWindowWMInfo``NSWindow*`,其余不变。**D0 就是来验证这个的**,别拖。
---
## 8 · demo 阶段相关的坑(从 PLAN §08 摘)
| 坑 | 在 demo 里的表现 | demo 阶段怎么办 |
|---|---|---|
| sokol_app + bgfx 抢 context | D0 黑屏 / 崩 | D0 卡死就切 SDL2,别硬啃 |
| `.sc` 草稿针对某 bgfx 版本 | shaderc 编不过 / uniform 名对不上 | submodule pin 到相近版本;编不过就照 `bgfx_shader.sh` 手改宏 |
| DXT 软解 vs 客户端 GPU S3TC | D3 贴图和客户端截图有细微色差 | demo 目视够了;要数值对拍时两侧都用软解 RGBA |
| 坐标系 basis | D2 warrior 躺着 / 镜像 / 巨大 | 用朝向明确的资产手调 `basisFix`,记进 `scene.cpp` 注释 |
| ~~曲线子类型超出实现~~ | — | **已消除**M0 实测全 `OldCurveType`degree ≤ 2`gr2_anim.cpp` 全覆盖 |
| `InverseWorld4x4` 读错但一致 | D2 白模看着对,D5 蒙皮炸 | **已兜住**M0 self-check 全量 9166 跑过,`warrior_cheongrin` 6.1e-5 PASS |
| root 偏移放法两种(`InitialPlacement` vs 烘进 root local | 世界姿势整体偏移 ~100 单位 | **已处理**`world[root] = Composite(local[root]) · model.InitialPlacement`M0 gr2_fileinfo 已关联 model→skeleton|
| mediump 精度(移动端才有) | macOS demo 无此问题 | 到 iOS demo 再管,骨骼矩阵用 highp |
---
## 9 · 怎么跑 / 演示检查点
```bash
export XRENDER_ASSET_ROOT="$PWD/../m2dev-client-main/assets"
cmake -B build -DXRENDER_ASSET_ROOT="$XRENDER_ASSET_ROOT" && cmake --build build -j
# 结构(D1
./build/tools/gr2dump/gr2dump "$XRENDER_ASSET_ROOT/PC/ymir work/pc/warrior/warrior_cheongrin.gr2" --gltf /tmp/warrior.glb
# → 期望:骨架树打印、mesh 摘要、"bind pose self-check: PASS (max 3.1e-6)"
# → 把 /tmp/warrior.glb 拖进 Blender,骨架和 T-pose 网格应正常
# 渲染(D2D4
./build/app/xrender-demo
# 拖拽转视角;T 贴图;N 法线;W 线框;Space 播 dance_1D5
```
**演示成立的判据**D0D4 全绿 = "bgfx 能从 assets 的散装 gr2 + dds 渲出正确的带贴图 warrior,且有自检工具" —— 这就把 PLAN 的 M1 用一个能给人看的东西证了。D5 绿 = M2 的核心(曲线采样 + LBS)也站得住。
**下一步**demo 之后):接 oracleWindows dump)做 D5 的数值对拍 → 换 iOS/Android toolchain 跑 D2D4 → 按 PLAN M3 补性能采集。
+558
View File
@@ -0,0 +1,558 @@
# Metin2 跨平台渲染引擎 PoC — 完整方案
> 内部研究方案,不对外公开。
> 基于对 `m2dev-client-src-main`、`MobileSource` 及 `m2dev-client-main/assets` 中真实 `.gr2` 资源的实测编写。
> 所有版本号、格式偏移、API 数量均来自源码与文件抽查。
| | |
|---|---|
| 状态 | PoC 规划 |
| 技术栈 | C++20 · **bgfx**RHI · sokol_app(仅窗口/输入) |
| 目标平台 | macOS · iOS · Android(并行) |
| 判定周期 | ≈ 23 个月 → go / no-go |
| 复用 | EterBase · EterPack · EterImageLib · `MobileSource``.sc` 着色器草稿 |
| 自研 | libgr2gr2 v6 读取器) |
> **选型变更记录**RHI 从 sokol_gfx 改为 **bgfx**。原因见 [§03](#03--技术选型)——`MobileSource` 里已存在一套面向 bgfx 的 Metin2 着色器草稿,bgfx 更 shipping 级、Metal/GLES/Vulkan 更成熟,且这不是一次性 PoC 而是最终移植的同一条代码路径。窗口/主循环/输入计划用 `sokol_app.h` 并把原生 handle 经 `bgfx::PlatformData` 交给 bgfx —— **这个交接不是 sokol_app 常规用法,是一处真风险**,M1 独立子门禁验证,啃不下退回"桌面 SDL2 + 移动端各写最小原生壳"[§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。
---
## 01 · 目标与判定标准
PoC 不是"移植一小部分客户端",而是把跨平台方案里所有真正未知的环节压到一条最短路径上,跑通即视为整体可行。
### 必须证明的四件事
1. **脱离 Granny 读 gr2** — 自研加载器能从 Metin2 真实的 `.gr2` 中读出骨架、蒙皮网格、骨骼权重与动画轨道。
2. **动画与蒙皮正确** — 骨骼动画采样 + 线性混合蒙皮的结果,与 Windows 客户端逐帧数值一致。
3. **跨平台 RHI 成立** — 同一套渲染代码经 bgfx 在 macOS + iOS 真机 + Android 真机上出画面,且三端一致。
4. **移动端性能达标** — 基准场景在中端机上达到目标帧率、显存与启动时间。
### PoC 明确排除
地形与户外场景、特效与粒子、UI 与 Python 脚本层、网络、阴影、水面、天空盒、LOD 切换策略、多角色 AI。
这些都不含跨平台的新风险,属于后续正式移植的工作量而非可行性问题。
### 判定
PoC 的价值是**降低不确定性**,go 和 no-go 都是有效产出。
> **进度(2026-08-29**M0 完成(9166/9166 解析零崩溃)。M1/M2 代码完成,
> **数值保真已对拍真 Granny 2.9.12**(骨骼世界矩阵 + 蒙皮顶点 ≤ 6.5e-5,见 `oracle/`)。
> M3 iOS 端已交叉编译 + 模拟器运行 + 渲染与桌面一致;Android 脚手架待 NDK + 真机。
> **PoC 的核心技术问题(能否脱离 Granny 正确读 + 播 Metin2 gr2)= 已用数值证据回答「能」**。
> 剩余是真机验证(性能 / 生命周期 / mediump+ Android 端。
> **画质改进 pass(档 1 + 档 2)已完成**MSAA x4 / mip 链 / sRGB 线性着色 / 半球环境 + 3 方向光 / 双面;
> libgr2 加 `granny_material` 链解析 → 逐 tri_group 正确贴图(原来整模型套一张瞎猜的 dds)+ alpha-test 镂空。
> 无回归(gr2fuzz 9166 · render_fuzz 9166 · oracle 23/23)。细节见 `docs/steps/M1-static-render.md`「画质改进 pass」。
- **里程碑 M3 全部门禁通过** = "C++ + bgfx、复用 EterGrnLib 逻辑" 的跨平台方案可行,进入正式移植规划。
- **门禁失败要区分两类**
- **工程性延期** —— 数值差 5%、某个曲线子类型没实现、某平台一个 API 用错。这不是"方案不行",是一个有明确修法、几天到两周能解的 bug。记下来继续。
- **方案性死路** —— 例如资产大量用 BitKnit 且烘焙退路也不可行、或 EterGrnLib 的动作混合逻辑无法脱离 D3D 复刻。这才触发"转 ozz-animation / Rust 重写 / 放弃"的评估。
- 最终交付一页纸结论,明说是哪类、卡在哪层。
### 定位澄清:这不是"跨平台版的 JTX Graphics"
两者是正交的两个轴,不要混淆:
| | JTX Graphics(及同类视觉 mod | 本方案 |
|---|---|---|
| 改的是 | **画质**:程序化天气/昼夜/天空、后处理 | **平台**Granny→libgr2、D3D9→bgfx、Win32→跨平台壳 |
| 平台 | 仍是 Windows / D3D9 / HLSL,焊死 | macOS / iOS / Android |
| 画面目标 | 比原版更好看 | 和原版一样(先求跑对,不追求提升) |
| 一句话 | 同平台,换皮升级 | 同画质,换平台 |
关系:本方案的核心动作就是把 Metin2 的固定管线换成可编程着色器(bgfx `.sc`)。做完之后,再叠一层类 JTX 的视觉增强会容易得多,而且因为着色器经 `shaderc` 编到 Metal/GLES/Vulkan**任何视觉增强天生就是三端的**。正确顺序是"先重构后端(本方案)→ 再叠视觉层";反过来(先在 D3D9 上堆画质再想跨平台)是死路。JTX Graphics 本身闭源付费、无公开源码,不纳入技术选型。
---
## 02 · 架构总览
分层清晰、复用边界明确:底层文件与资产解码搬用 MobileSource 的对应代码(注意那套代码**写了但从未链接成功**,M1 是它第一次真正跑),中间的 gr2 解析全部自研,上层渲染走单一 RHI(bgfx)。
### 分层
| 层 | 内容 |
|---|---|
| 平台壳 | `sokol_app.h`(窗口 / 主循环 / 输入,四平台一份) → 原生 handle 经 `bgfx::PlatformData` 交给 bgfx(交接是风险,见 [§03](#窗口层sokol_app-与-bgfx-的交接是一处真风险) |
| RHI | **bgfx**(经 `bgfx.cmake`Metal / GLES3 / Vulkan / D3D11 · `shaderc``.sc` → 多 profile · RHI 薄封装 ≈ `StateManager` |
| 渲染器 | 场景·相机 / 动画采样 / 蒙皮 CPU→GPU / 材质·贴图绑定 |
| 资产 | **libgr2(自研)** / EterImageLib(复用) / msm·msa 解析 / EterPack(复用,M3 前或 M4 接入) |
| 基础 | EterBase`CFileBase` / `CMappedFile`(复用) / 数学:glm |
### 数据流
```
.gr2/.dds/.msm/.msa → libgr2 · EterImageLib · msm/msa → 中间表示(骨架·网格·曲线·材质)
→ 动画采样 → 世界姿势 → 蒙皮 → RHI(bgfx) → Metal / GLES3 / Vulkan
```
### 复用 / 自研 / 替换
| 模块 | 处理 | 说明 |
|---|---|---|
| EterBase | 复用(**M1 首次验证** | `CFileBase` / `CMappedFile` 的 AAsset 分支 MobileSource **写了但没链接过**;CRC32、lzo、内存映射搬过来,M1 第一次真跑,可能有坑。 |
| EterPack | 复用 | eterpack 读取 + LZO 解压 + 解密。PoC 从散装文件起步,M3 前或 M4 再接(打真机时可能提前,见 [§08](#08--风险登记册) 平台行)。 |
| EterImageLib | 复用(**M1 首次验证** | `CDXTCImage`DXT1/3/5 软解到 RGBA8)、`CTGAImage`,Android 解压分支同样没跑过。移动端无 S3TC 硬件支持,运行时解压是必经路径。 |
| `MobileSource` `.sc` 着色器 | 复用为基线 | `shaders.rar/shaders/` 里那套面向 bgfx 的草稿(见 [§04](#着色器来源mobilesource-的-sc-草稿))。lit / textured / 2D 着色器 + `texture_stage.sh` 可直接改;蒙皮 / fog / 顶点色 / 跨平台编译步骤要补。 |
| EterGrnLib | 按源码复刻 | 代码焊死在 `granny_*``D3DXMATRIX` 上,不能直接编。当权威规格:网格→VB/IB 布局、材质调色板、LOD 控制、动作混合、挂点,照它的调用序列在 libgr2 + glm 上重写。 |
| granny2 / Granny SDK | 替换 | 自研 `libgr2`。泄露的 Granny 源码仅作格式参照,不链接、不进仓库产物。 |
| D3D9 / d3dx9 | 替换 | PoC 不碰完整 EterLib 渲染层。着色器从 `.sc` 草稿改,配一层 bgfx 封装。 |
| MSWindow / DirectInput | 替换 | `sokol_app.h` 提供窗口 / 主循环 / 输入,四平台统一;渲染交给 bgfx。 |
> **`MobileSource` 里的两条渲染路径**:一条是手写的 `GrpOpenGL` D3D8→GLES shim(已进 CMake,~40% 文件参与编译,从未链接成功);另一条是 bgfx 路径(未接线,但有更完整的 `.sc` 着色器集)。本方案接续 bgfx 那条路径的着色器成果,不用 shim。
>
> **`reuse/` 是冻结的 vendored 快照 + 本项目的修复提交在其之上**,不是对 `../MobileSource` 的引用 —— 那套代码从没链接过,M1 一定会改它,必须版本可控。同理 `.sc` 着色器也拷进 `app/shaders/` 冻结。
---
## 03 · 技术选型
### 为什么是 bgfx(而不是 sokol_gfx / wgpu
- **最难的活已有人用 bgfx 干了一半** — `shaders.rar` 里那套 `vs/fs_pnt``pdt``pt``terrain` + `texture_stage.sh` + `varying.def.sc` 就是冲 bgfx 写的。选 bgfx = 用 `shaderc` 编一下几乎直接能用;选 sokol = 得把 `.sc` 逐个翻成 sokol-shdc 的 GLSL,还要重写 helper。
- **这不是一次性 PoC** — 目标是真做跨平台客户端,PoC 就是最终移植的同一条代码路径。RHI 选型要背几年,选一个 shipping 级、社区大、Metal/GLES/Vulkan 都成熟的更划算。
- **引擎结构对口** — Metin2 整套是 FVF + texture-stage + 多光源 + alpha-test 的固定管线思维,bgfx 的 vertex layout / `varying.def.sc` / state flags 跟这套心智模型贴得近。
- **调试设施** — 内置 stats HUD、`dbgTextPrintf`、RenderDoc 钩子、`BGFX_DEBUG_*`,正是验证阶段要用的;`bimg` / `texturec` 量产转 ASTC/ETC2 也用得上。
- **构建摩擦已消除** — 用 `bgfx.cmake`(社区维护的 CMake 封装),集成就是 `add_subdirectory` 的事,`bx` / `bimg` / `bgfx` 三个仓库一起拉。
### 窗口层:sokol_app 与 bgfx 的交接是一处真风险
bgfx 自己不管窗口。计划做法:`sokol_app` 拿到原生 window handle → 填 `bgfx::PlatformData``bgfx::init`,让 bgfx 自己建设备。
**但这不是 sokol_app 的常规用法**sokol_app 和 bgfx 都是"整个 app 框架",都想拥有 swapchain / context。sokol_app 在 iOS/Android 会自建 EAGL/EGL context 和 `MTKView` / `GLSurfaceView`,要让 bgfx 接管得专门压制这些(可能要 `SOKOL_NO_ENTRY` + 只用 sokol_app 的窗口/输入部分)。桌面上更稳的组合其实是 **bgfx + SDL2 / GLFW**
因此:**M1 给这个交接一个独立子门禁**macOS 上 bgfx 经 sokol_app 的 handle 出画面 + 输入可用);若两周内啃不下,退回"桌面 SDL2 + iOS/Android 各写最小原生壳"。
### 着色器
`MobileSource/Cross Platform/shaders.rar/shaders/*.sc` 为基线,用 bgfx 的 `shaderc` 编译。
不再手写 sokol-shdc GLSL。详见 [§04 着色器来源](#着色器来源mobilesource-的-sc-草稿)。
- 目标 profile`--platform osx -p metal`macOS)、`--platform ios -p metal`iOS)、`--platform android -p 300_es`Android GLES3)、`--platform windows -p s_5_0`Windows 对照)。
- 产物 `.bin``bin2c` 嵌成 C 数组,`bgfx::createShader` 加载。
- `bgfx_shader.sh` 随 bgfx submodule 自动就位。**`.sc` 草稿写于 2025-12,针对某个 bgfx 版本 —— `bgfx.cmake` 的 submodule 就 pin 到那个或相近版本,否则宏 / uniform 命名可能对不上。**
- `MobileSource` 里的 `compile_shaders.bat` 路径写死、只出 Vulkan 一个 profile**要换成 CMake 的可移植 custom command**。
### 构建
- 单一 `CMakeLists.txt` + 三份 toolchainiOS`ios.toolchain.cmake`)、Android NDK、macOS 原生。
- `third_party/``bgfx.cmake`(含 `bx` / `bimg` / `bgfx` submodule)、`sokol`(仅 `sokol_app.h`)、`glm`
- C++20`-fno-exceptions` 视复用代码情况而定(EterBase 用了少量异常,先保留)。
- 数学库用 **glm**:列主序,与 GL/Metal/bgfx 一致;替换 `D3DXMATRIX` 时注意 D3DX 是行主序,上传前转置或全程列主序。
- CI 三端并行:macOS 原生、iOS 模拟器、Android 模拟器。
---
## 04 · 关键模块
### libgr2 — gr2 v6 读取器(自研,核心)
只读,覆盖:
- **文件头 + section 表** — 定位各段;实现 section 解压(预期未压缩或 Oodle0/1)。
- **自描述类型树遍历** — 不硬编码结构,按文件内嵌的 `data_type_definition` 递归解析,指针/引用重定位(relocation/marshalling 表)。
- **`FileInfo` 根对象** — 取出 `Textures[]``Materials[]``Skeletons[]``VertexDatas[]``TriTopologies[]``Meshes[]``Models[]``Animations[]`
- **骨架** — 骨骼数组(名称、父索引、`LocalTransform``InverseWorld4x4`)。
- **网格** — 顶点(`PNT332` / `PNT3322` 及带骨骼索引+权重的蒙皮变体)、索引、三角组(按材质分段)、`BoneBindings`
- **动画** — `TrackGroups` → 每骨的 position / orientation / scaleshear 曲线;曲线子类型解码(关键帧数组、B 样条拟合等)。
对照实现:泄露 Granny 源码里的 `granny_data_type_definition.*``granny_file_info.*``granny_curve*.cpp` 当算法参照读;不复制代码。
### 资源加载
- **贴图** — `.dds``CDXTCImage` 解到 RGBA8,用 `bgfx::createTexture2D` 上传(移动端显存 ×4,PoC 可接受)。量产阶段换离线转 ASTC / ETC2 或 Basis Universalbgfx 的 `bimg` / `texturec` 就能做)。
- **文件访问** — 直接用 `CMappedFile`,桌面走 mmapAndroid 走 `AAsset_getBuffer`MobileSource 已实现)。M0M3 用散装文件,M4 切 eterpack。
### 动画与蒙皮
- **采样** — 按局部时钟对每骨曲线插值 → 局部变换 → 沿父链累积得世界矩阵(对应 `GrannyBuildWorldPose` / `GrannyGetWorldPoseComposite4x4Array`)。
- **过渡** — ease-in/out 曲线、loop count、raw local clock 的语义,照 `EterGrnLib/Motion*``ModelInstanceMotion.cpp` 的调用序列复刻。
- **蒙皮** — M2 先 CPU 线性混合蒙皮(`skinMatrix = world[bone] * invBind[bone]`,逐顶点 4 权重加权);M4 移到顶点着色器(骨骼矩阵走 bgfx uniform 数组或 texture)。
- **rigid mesh** — `GrannyMeshIsRigid` 为真的网格不做蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
### 着色器来源:`MobileSource` 的 `.sc` 草稿
`shaders.rar/shaders/` 是一份**没写完的草稿**,能用的部分省事,缺的部分正好是 PoC 核心。
| 文件 | 是什么 | 对我们 |
|---|---|---|
| `varying.def.sc` | bgfx attribute/varying 声明:`a_position/a_color0/a_texcoord0/a_normal``v_normal/v_texcoord0/v_worldPos/v_texcoord1` | 结构直接用,**但没有骨骼索引/权重**,要加 `a_indices` / `a_weight` |
| `vs_pnt.sc` / `fs_pnt.sc` | position-normal-texcoord。FS 是重头:8 光源前向光照 + 全套 `D3DCMP_*` alpha-test + `applyTextureStage` | **lit mesh 着色器基线**M1M2 直接改 |
| `vs_pdt.sc` / `fs_pdt.sc` | position-diffuse-texcoordMetin2 最常用 FVF | 带顶点色的网格 |
| `vs_pc.sc` / `fs_pc.sc` | position-color,无纹理 | UI 图元 |
| `vs_pt.sc` / `pt2``fs_pt.sc` / `pt2` | position-texcoord2D 图像/精灵 | UI 贴图 |
| `vs_terrain.sc` / `fs_terrain.sc` | 地形 splatting,很小 | 基本占位,PoC 不用 |
| **`texture_stage.sh`** | 把 D3D 固定功能 `SetTextureStageState` 完整翻译成 shaderCOLOROP/ALPHAOPMODULATE、MODULATE2X/4X、ADD、ADDSIGNED、SUBTRACT、BLEND…)、arg 选择(TEXTURE/DIFFUSE/CURRENT/TFACTOR)、COMPLEMENT/ALPHAREPLICATE 标志,uniform 驱动 | **最值钱的一块**,但它是一份**未经校验的重实现** —— 见下方验证要求 |
> **`texture_stage.sh` 必须专门验证**:它是别人写的 D3D 固定功能重实现,MODULATE2X 的 clamp、arg complement、多 stage 串联都可能有细微错。M1/M2 要拿**一个已知的多 stage 材质**(从测试资产集里挑一个用了 `D3DTOP_MODULATE2X` 或 `ADDSIGNED` 的)单独和 oracle 对拍,不能只靠整场景 SSIM 兜。
**状态 / 要补的**
- `.bin` 输出大多 31–34 字节,是空/失败产物 —— 忽略,从 `.sc` 重编。
- `compile_shaders.bat` 路径写死、只出 Vulkan 一个 profile —— 换成 CMake custom command,出 metal / 300_es / s_5_0 三套。
- **没有蒙皮** —— 没有骨骼矩阵、没有 `a_indices` / `a_weight`。新写 `vs_pnt_skinned.sc`(骨骼矩阵走 uniform 数组或 textureVS 里做 LBS)。这是 M2 的核心。
- `vs_pnt` 丢了 `a_color0`(Metin2 网格常带顶点色),要补。
- `v_texcoord1`(第二 UVlightmap/detail)声明了没用。
- **没有 fog** —— Metin2 大量用 D3D 雾,要加。
一句话:这套 `.sc` 把"固定管线光照 + alpha-test + texture-stage → shader"做掉了大半,蒙皮、fog、顶点色、跨平台编译步骤要自己补。
### msm / msa — Metin2 包装格式
`.msm` 描述一个模型:引用的 `.gr2`、贴图、材质类型、挂点。`.msa` 描述动作:引用的动画 gr2、混合参数、事件。
格式简单,解析器逻辑就在 `RaceManager.cpp` / `EterGrnLib/Util.cpp` 里,照抄。
**要有验证门**`.msm` / `.msa` 是人可读的文本文件。M2 加一条子检查——dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。否则 `.msa` 混合参数读错 → 动画错 → M2 矩阵 diff 失败,你会去 debug libgr2 / 采样,真 bug 却在这里。
### RHI 封装
一层 ≈ 200 行的薄封装,把"设置纹理 / 设置顶点缓冲 / 设置变换 / 画一批索引三角形"映射到
`bgfx::setVertexBuffer` / `setTexture` / `setUniform` / `setState` / `submit`,语义对齐 `StateManager`
目的是让从 `EterGrnLib` 复刻过来的渲染逻辑几乎逐行对应。
### 平台壳
- `sokol_app.h` 的单回调主循环,四平台一份 `main`;原生 handle 经 `bgfx::PlatformData` 交给 `bgfx::init`。**这个交接本身是风险,见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险)M1 独立子门禁。**
- **Android 上下文丢失** — 切后台 GL context 连同 GPU 资源可能失效。在 sokol_app 的 `SUSPENDED` / `RESUMED` 事件里按 bgfx 的重置流程处理(`bgfx::reset` + 必要时重建资源)—— **M3 就处理,不要拖到最后**
- **iOS** — 全静态链接,无 `dlopen`Metal drawable 生命周期照 sokol_app 处理;资源打进 `.bundle`
- **资产上真机的方式要在 M3 前定**:9166 个散装 `.gr2` + 一堆 `.dds` 不能直接堆进 APK / `.bundle`(体积、iOS 限制)。要么 M3 前提前接 eterpack(现排 M4),要么只把测试资产集打进去。M3 交付里"原样交叉编译"没算这一步。
---
## 05 · gr2 格式要点
抽查 `warrior_cheongrin_lod_01.gr2` 的文件头:
```
偏移 字节 含义
0x00 B8 67 B0 CA F8 6D B1 0F 84 72 8C 7E … 16 字节 magic:32 位小端 · 文件格式版本 6
0x10 B8 01 00 00 头部长度 = 0x1B8
0x20 06 00 00 00 格式版本字段 = 6
0x24 AC 09 01 00 TotalSize = 68012 = 文件实际大小 ✓ 解析正确
0x28 65 7A 20 53 CRC32
0x2C 38 00 00 00 section 表偏移 = 0x38
```
### 已确认(M0 T1–T9 全部实现并全量实测:9166/9166 解析成功、0 崩溃、0 非退化谓词失败)
> `libgr2` = header/section/fixup + 自描述类型树遍历器 + FileInfo + 骨架(含 bind-pose 自洽检查)
> + 蒙皮网格 + 动画曲线解码 + 采样。`gr2dump` / `gr2fuzz` 见 `tools/`。报告见 `test/fuzz-report.json`。
- **magic = `GRNFileMV_Old`**(不是 `GRNFileMV_32Bit_LittleEndian`),32 位小端。**9155 个是格式 v6,11 个是 v7**(容器兼容,都能读)。
- 每文件固定 **8 个标准 section**Main / RigidVertex / RigidIndex / DeformableVertex / DeformableIndex / Texture / Discardable / Unloaded)。
- **section 用 Oodle1 压缩**(每段 `Format==2`Texture section 例外,空)。之前"未压缩"是把 `HeaderFormat`=0)错当成 section 压缩。**没有 BitKnit**(全样本 0 个)。**Oodle1 解码器已从泄露 SDK 端口进 `libgr2``oodle1.c`),全量验证通过。**
- 资产由 **Granny Standard Exporter SDK 2.4.0.7** 导出 —— oracle 的 `granny2.dll` 应锁 2.4.x 线(见 [`steps/00-oracle.md`](./steps/00-oracle.md) T1)。
- `total_size` 字段 == 文件实际大小;解压后每个 section 恰好 `ExpandedDataSize`。内嵌路径串(`D:\Ymir Work\...`)解压后干净可读 —— 内容正确性的 oracle-free 锚点。
- 客户端共 **9166 个 .gr2**,散装在磁盘,PoC 阶段无需解包。
- 引擎实际用到的 Granny API 约 **90 个**,全部是"读文件 / 采样动画 / 变形顶点",无任何建模或导出。
- 顶点类型实测:`PNT332`rigid4016 mesh)、`PNT3322`rigid 双 UV309)、`PNT332_Skinned`(3118)。**无未知类型**。蒙皮变体带 `BoneWeights`NormUInt8×4 或 Real32×4+ `BoneIndices`UInt8×4 或 packed UInt32)。
- **曲线全部是 `OldCurveType`**Granny 2.4`{Int32 Degree; RefToArray Knots; RefToArray Controls}`**无压缩变体、无 `curve2`/`CurveData` variant**)。degree ∈ {0,1,2}**无 3**)、dim ∈ {0,3,4,9}。`gr2_anim.cpp` 全覆盖(常量 / 线性 / 二次 B 样条 + 四元数归一)→ **§08 的"曲线子类型超出范围"风险消除,无需烘焙退路**。
- **非单位 scaleshear 普遍**2267 个 skeleton 至少 1 骨、全语料 13298 骨带非单位 scale-shear。→ 组合公式走完整 `R3·SS3`(已实现);M2/M3 蒙皮按完整仿射,**不可用 `world·invBind` 的正交近似**。
- **多 skeleton / 多 model**:一个文件可含本体 + 武器挂点各自的 skeleton(如 `redthief2_soldier2` = 3 skeleton / 3 model)。mesh `BoneBindings` 对每个 skeleton 试解析取悬空最少者。
- **root 偏移两种放法**:多数 skeleton 把 model-space 偏移放在 `model.InitialPlacement`root 骨 local 为单位);少数烘进 root 骨 local。世界姿势 = `Composite(local[root]) · InitialPlacement`
- `EterGrnLib` 已用 `#if GrannyProductMinorVersion == 4 / 7 / 8 / 9 / 11` 兼容多版本 —— 实测资产就是 **2.4**,与 libgr2 的选择一致。
### M0 抽样统计结果(`test/fuzz-report.json`,全量 9166
- 格式版本:v6 × 9155、v7 × 11。section 压缩:非空段 100% Oodle1**0 Oodle0 / 0 BitKnit** → §08 BitKnit 退路项不触发。
- bind-pose 自洽(不依赖 oracle):多骨 skeleton 2013/2017 `max|Δ| < 1e-3``warrior_cheongrin` 6.1e-5);4 个 `>=1e-1` 已定位到辅助骨子树的 `InverseWorld4x4` 未随 `InitialPlacement` 更新(含 1 个文件名带 `_backup`),非阻塞。
- 曲线子类型直方图(degree·dim):pos `d0·d3`(322k)/`d2·d3`(41k)rot `d0·d4`(79k)/`d2·d4`(210k)scale `d0·d9`(42k)/`d1·d9`(3k);大量 `d0·dim0`(恒等轨道)。
### M0 第一件事:锁定 oracle 用的 Granny 版本
资产是文件格式 v6(约 2.6–2.9 era),但 `m2dev-client-src` 头文件写 2.11.8。**oracle 必须用实际能正确加载这批资产的那个 `granny2.dll` 版本**去 dump,否则是拿错误的参考去校 libgr2。M0 起手要确认:这批 gr2 在哪个 Granny 版本下 `GrannyGetFileInfo` 返回完全正常(骨骼数、顶点数、动画时长都合理),把那个 DLL 固定进 `oracle/`
---
## 06 · 里程碑与验证门
五个里程碑,每个都有明确的交付物和一道 go/no-go 门禁。单人全职到 M3 结束约 2–3 个月。
里程碑的**验证逻辑**与 RHI 无关;换 bgfx 只在 M1 多了一道"sokol_app + bgfx 交接"的子门禁。
> **分册**:本节是索引,每个里程碑的文件级施工文档在 [`steps/`](./steps/)
> [`00-oracle`](./steps/00-oracle.md)(关键路径,M0 起)·
> [`M0-gr2-reader`](./steps/M0-gr2-reader.md) ·
> [`M1-static-render`](./steps/M1-static-render.md) ·
> [`M2-anim-skinning`](./steps/M2-anim-skinning.md) ·
> [`M3-mobile`](./steps/M3-mobile.md) ·
> [`M4-realistic-load`](./steps/M4-realistic-load.md)。
> M0+M1 的可演示 demo 施工图另见 [`DEMO-PLAN.md`](./DEMO-PLAN.md)。
### M0 · gr2 解析器(独立 CLI) — 1–2 周
- **交付** — ① 锁定 oracle 的 Granny 版本(见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本));② `gr2dump``warrior_*` 系列 + 若干 zone 静态件,产出 libgr2 的结构化 dump(骨架 / 网格 / 动画曲线)**和** glTF;③ 对全量 9166 个 `.gr2` 跑 parse-fuzz,产出格式变体直方图;④ 从 fuzz 元数据里筛出测试资产集(见 [§07](#测试资产集精选约-15-个))并写进 `test/assets.list`
- **门禁** — libgr2 结构化 dump 与 oracle 的 `GrannyGetFileInfo` dump **逐字段一致**(见验证);fuzz 零崩溃且**产物通过非退化谓词**;变体分布落在可实现范围(无 BitKnit,或有明确离线转换退路);测试资产集已产出。
- **验证** —
- **自洽检查(不依赖 oracle,先跑)**:从 `LocalTransform` 链重建 bind pose 世界矩阵,验证每骨 `world_bind[i] · InverseWorld4x4[i] ≈ I`。这一条抓"读矩阵时带了转置 / 手系错、且同样作用于正向读取"这类 oracle 对拍也发现不了的 bug。
- **主**libgr2 结构化 dump vs oracle dump 逐字段对拍 —— 骨骼数 / 父索引 / 名称 / `LocalTransform` / `InverseWorld4x4` / 顶点数 / 索引数 / 每骨曲线的关键帧数 / 动画时长,全部相等或 < 噪声地板。
- **辅**:导出 glTF 在 Blender 目视骨架拓扑 + 绑定姿势网格。**注意 glTF 只能证拓扑和 bind pose,证不了动画曲线解码对**(glTF 动画表达不了 Granny 的 ease 曲线 / scale-shear / 常量轨道压缩)—— 动画正确性只认上面那条主验证。
- **fuzz 非退化谓词**:骨骼数 ∈ [1, 512];每骨父索引 < 自身索引或 = -1;变换无 NaN/Inf;顶点数 > 0;所有索引 < 顶点数;每顶点权重和 ∈ [0.99, 1.01];骨骼索引在范围内。不满足即计一次 fail。
- **bootstrap 关系**:M0 自身验证用硬编码引导集(`warrior_*` + 若干 zone);`test/assets.list` 一旦产出,M1 及之后一律用它。
### M1 · macOS 静态渲染 — 23 周
- **交付** — sokol_app 窗口 + bgfxMetal),渲染 gr2 静态网格(绑定姿势)+ 贴图,轨道相机;着色器从 `vs/fs_pnt` 草稿改;`texture_stage.sh` 接一个已知多 stage 材质。
- **子门禁 1(交接)** — bgfx 经 sokol_app 的原生 handle 在 macOS 出画面 + 输入可用(见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。啃不下则退回 SDL2 方案。
- **子门禁 2(几何)** — 网格拓扑、UV 正确;无背面剔除错误 / 法线翻转;一个多 stage 材质的着色结果与 oracle 对上。
- **验证** —
- **对比场景两侧都喂同一张预解码 RGBA**(绕开 DXT),让截图 diff 只反映几何 / 光照 / texture-stage,不被软解 vs 硬件 S3TC 的 bit 级差异污染。
- oracle 侧要能进入确定态:固定相机矩阵注入、固定光、绑定姿势、无程序化摇摆 —— 这需要 oracle build 的少量改造,M1 一并做。
- 与 oracle 截图像素 diff + SSIM;开法线可视化模式自检。
### M2 · 骨骼动画 + CPU 蒙皮 — 2–3 周
- **交付** — 解析 `.msm` / `.msa`,构建世界姿势,线性混合蒙皮,播放 idle / walk,支持 loop 与 ease;补 `vs_pnt_skinned.sc`;装配一个**多部件角色**(身体 + 至少一个额外 gr2,如头发/时装)+ 一个**挂点武器**。
- **门禁(分级,按序)** —
0. **`.msm` / `.msa` 解析子检查**:dump 解析结果和源文本逐项目视核对(挂点名、动作列表、混合参数)。
1. **骨骼世界矩阵**与 oracle 的**层 ①(裸 Granny`GrannyGetWorldPoseComposite4x4Array` 直出)**逐帧对拍,max|Δ| < ε_mat —— 隔离"曲线采样 + 世界姿势累积",先过。**M2 只对层 ①**;层 ②(过完 `EterGrnLib``ActorInstanceBlend` + LOD 骨骼裁剪)不在 PoC 范围,留到正式移植。
2. **蒙皮顶点坐标**与 oracle 逐帧对拍,‖Δ‖ < ε_vtx —— 隔离"蒙皮 + 顶点格式"。
3. 多部件装配:挂点武器的世界变换与 oracle 一致(`GrannyFindBoneByName` + 挂点矩阵链复刻对)。
4. **LOD 一致性**:同一模型 LOD 0–3 共享骨骼绑定,切换无跳变(顶点在切换帧的位移 < 阈值)。
- **验证** — 数值层对拍(N 帧 × M 顶点、全部测试资产集,不是单文件;tie-break 见 [§07 数值层](#数值层))+ 视觉层(3 个确定姿势,差异分类见 [§07 视觉层](#视觉层))。
- **动画曲线退路** — 若 M0 统计发现曲线子类型超出可实现范围,改走"oracle 离线烘焙成密集关键帧"(见 [§08](#08--风险登记册) 动画行),libgr2 只读烘焙格式,M2 照常验证。
### M3 · iOS + Android 真机 — 12 周(与 M2 尾段并行)
- **交付** — M2 工程交叉编译(**含资产上真机的方案**,见 [§04 平台壳](#平台壳) —— 提前接 eterpack 或只打测试资产集,不是"原样交叉编译"就完事),两端真机运行基准场景;CI 三端冒烟。
- **门禁** —
- 三端渲染差异分类通过(见 [§07 视觉层](#视觉层));
- 简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps,满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps(机型见 [§07 性能层](#性能层真机));冷启动 < 3s
- 无 bgfx / 图形验证层报错;
- **Android 切后台 ×100 恢复正常(无崩、无黑屏);iOS 切后台 / 锁屏 / 来电后恢复正常(丢 Metal drawable 的处理)**
- **mediump 精度专项**:顶点着色器骨骼矩阵 / 位置用 highp 前后对比,确认无抖动 / 爆顶点。
- **验证** — 三端快照互拍 + 真机性能采集(帧时间 p99、显存、RSS、加载耗时)。
### M4 · 逼近真实负载(可选) — 2 周
- **交付** — GPU 蒙皮;改走 eterpack 加载;多角色 + 一块地面 + 简单光照;LOD 切换。
- **门禁** — GPU 蒙皮结果与 CPU 一致;50 角色可交互帧率;eterpack 路径与散文件结果一致。
- **验证** — CPU / GPU 蒙皮对拍;扩展性能曲线(1 / 8 / 20 / 50 角色)。
---
## 07 · 验证方法论
核心思路:把 Windows 客户端当作"真值预言机"(oracle),新实现的每一层输出都要能和它对拍。分数值、视觉、性能三个层次,配自动化回归。
### 预言机:插桩的 Windows 客户端
在 Windows 侧构建一个精简 harness,链接**锁定版本的** `granny2.dll` + `EterGrnLib`(版本确定见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本)),能对"给定 `.gr2` + `.msa` + 时刻 `t`"导出:
- **两层骨骼矩阵**:① 裸 Granny 层(`GrannyGetWorldPoseComposite4x4Array` 直出);② 过完 `EterGrnLib` 的 LOD 骨骼裁剪 + `ActorInstanceBlend` 混合后的最终矩阵。libgr2 + 我们的采样先对①,装到引擎后对②。
- mesh 0 蒙皮后的顶点坐标;
- 确定态截图(固定相机矩阵注入、固定光、精确 model clock、单动作无混合、关掉待机摇摆 —— 需 oracle build 少量改造)。
**坐标空间必须两侧都写死**oracle dump 和 libgr2 输出都声明用同一约定(左手 Y-up、单位、根变换是否已 apply)。§08 的 basis 转换高危项,就靠这条数值锚点来抓 —— 目视抓不住 90° 轴交换或 ×100 缩放。
**oracle 自身要先过自检**(在被信任之前):加载一个 gr2、dump t=0 的骨骼矩阵,应与该 gr2 存的 bind pose`InverseWorld4x4` 求逆)一致。过不了这条,下游全建在沙子上 —— 这是 M0 的隐性前置门禁。
这套 dump 是 Windows-only,永远留在 Windows。`oracle/` 的搭建(把 `EterGrnLib` 从完整客户端里单独抽出来编、两层矩阵 dump、确定态改造)**从 M0 起,是关键路径**,需要一个会 Windows / D3D 构建的人,[§09 工作量表](#工作量)里单列。
### 数值层
**阈值不是拍的,要先测噪声地板**:把 oracle 跑两遍(或 oracle vs 另一个已知正确的 Granny 实现),看同一输入的输出抖动有多大,ε 设在地板之上一个安全余量。否则正确代码也会 fail —— 60 骨链上 float32 累积、Granny 内部用 float、我们用 double 再转,1e-4 / 1e-3 这种数很可能比噪声还紧。
| 对象 | 隔离的是 | 比对方式 | 阈值 |
|---|---|---|---|
| gr2 结构(骨骼数 / 父索引 / 名称 / `LocalTransform` / 顶点数 / 索引数 / 每骨关键帧数 / 动画时长) | 解析正确性 | libgr2 vs oracle dump vs Blender 导入,三方一致 | 相等(浮点字段 < 噪声地板) |
| 骨骼世界矩阵(先对裸 Granny 层,装进引擎后对 EterGrnLib 层) | **动画采样 + 世界姿势累积** | 逐元素与 oracle 相减,取 max\|Δ\| | < ε_mat(噪声地板 × 余量) |
| 蒙皮顶点坐标 | **蒙皮 + 顶点格式**(在矩阵已对上的前提下) | N 帧 × M 顶点,`‖v_ours v_oracle‖` | < ε_vtx |
**必须按序**:先让骨骼矩阵对上,再看顶点。否则动画错 + 蒙皮错可能相互抵消、最终顶点却"对",掩盖两个 bug。
在整个测试资产集上跑,不是单文件。
**tie-break**:矩阵 / 顶点对拍默认 oracle = 真值。但如果 libgr2 与 oracle 差超过 ε、结果又都合理,需要第三个独立参照来判 —— 在测试资产集上,用 Blender `io_scene_gr2` 计算的世界矩阵(或第二个开源 gr2 库)做仲裁。这也能抓出 oracle 自己的 bug(DLL 版本选错、确定态改造引入坐标错)。
### 视觉层
- **确定性场景** — 固定相机矩阵、固定光、固定姿势(idle 第 0 帧、走路中段、旋转量大的姿势)。让 **oracle** 进入这个确定态需要改 oracle build(注入相机矩阵、精确 model clock、关程序化摇摆、单动作),这块工作在 M1。
- **贴图从对比里剔除** — 对比场景两侧都喂同一张预解码 RGBA。客户端用 GPU 硬件 S3TC、PoC 用 `CDXTCImage` 软解,两者 bit 级有差,会让整张图偏移、SSIM 掉分,原因跟要验证的几何/蒙皮无关。
- **单一 SSIM 阈值不够,要差异分类** — 同一帧 Metal vs GLES 会有"正确但可见"的差别(各向异性过滤强度、mip 选择、gamma)。流程:`SSIM ≥ 0.98` 直接过;`0.950.98` 之间进人工/规则分类,判定差异是否全落在"渲染器合理差异"集合里,是则过、否则 fail;`< 0.95` 直接 fail。
- **三端互拍** — 三个端之间应比各自与 oracle 更接近(同一 shaderc 源、同一逻辑)。
- **失效模式清单** — 法线 / 背面翻转、UV 接缝、绑错骨骼(肢体飞出)、顶点塌陷(权重错)、z-fighting、alpha 排序、gamma / 色彩空间、左右手系镜像、整体缩放错(basis 转换,数值层才是主抓手)。
### 性能层(真机)
- **两种角色都要测** — ① 简化角色(≈ 5k 三角、≈ 60 骨、单 draw call);② **满配角色**(多部件装配 + 武器 + 时装 + 挂点特效,1–2 万三角、多材质多 draw call)。只用简化角色外推真实负载会乐观。每种从 1 个放大到 8 / 20 / 50 个。
- **基准硬件写死具体机型**(不是"级别")—— 例如 iPhone 11A13+ Pixel 6Mali-G78+ 一台 Adreno 机(如 Redmi Note 系列),三种主流移动 GPU 架构各一。
- **指标** — 帧时间 p50 / p99ms)、FPS、draw call、GPU 显存(Xcode GPU report / Android GPU Inspector / `adb dumpsys meminfo`)、冷启动到首帧、单个 `.gr2` 加载耗时、峰值 RSS。
- **PoC 通过线**:简化角色 1 个 ≥ 60fps、20 个 ≥ 30fps**满配角色 1 个 ≥ 60fps、8 个 ≥ 30fps**;单角色加载 < 30ms;冷启动 < 3s;基准 RSS < 300MB。
- M4 加做 CPU vs GPU 蒙皮对比。
### 自动化 / CI
- **`gr2-fuzz`** — 解析全部 9166 个 `.gr2`,不许崩溃,产物通过非退化谓词(骨骼数 ∈ [1,512]、父索引 < 自身或 -1、无 NaN/Inf、索引 < 顶点数、权重和 ∈ [0.99,1.01]、骨骼索引在范围内);输出格式变体直方图。这一步最先抓出"某批文件是另一种变体"。
- **快照回归** — 在 macOS CI runner 上用 bgfx 离屏渲染确定性场景,PNG 与提交的 golden 带容差比对。
- **三端冒烟** — CI 里 iOS 模拟器 + Android 模拟器构建并启动,断言"到达首帧 + 连续 N 帧无 bgfx / GL / Metal 验证层报错"(打开 `BGFX_DEBUG_*`、Metal API validation、GLES `KHR_debug`)。
### 测试资产集(精选约 15 个)
**由 M0 的 fuzz 元数据 + 人工挑选产出**,写进 `test/assets.list`,后续所有里程碑都跑这一套:
- 刚体静态件(zone 建筑)
- 单材质蒙皮角色(`warrior_novice`
- 多材质 + 带 alpha 的蒙皮角色(时装)
- **多部件装配角色 + 挂点武器**(考验 `.msm` 多 gr2 组装 + `GrannyFindBoneByName` + 挂点矩阵)
- 高骨骼数 / 长动画
- 同一模型的全部 4 级 LOD(考验 LOD 间骨骼绑定一致性)
- 一个用了 `D3DTOP_MODULATE2X``ADDSIGNED` 的多 stage 材质(专验 `texture_stage.sh`
- 特效 / 挂点网格
- fuzz 阶段标出的异常个例(曲线子类型、压缩变体、超大骨骼数等的代表)
---
## 08 · 风险登记册
严重度:**高** 可能否决方案或大幅拖延 · **中** 需专门投入 · **低** 有成熟解法。
| 领域 | 风险 | 影响 / 缓解 | 严重度 |
|---|---|---|---|
| **oracle** | granny2.dll 版本与资产格式不匹配(资产 v6,src 头写 2.11.8 | 拿错误参考校 libgr2,全盘失真。**M0 起手锁定"能正确加载这批资产的 DLL 版本"**,固定进 `oracle/`(见 [§05](#m0-第一件事锁定-oracle-用的-granny-版本))。 | **高** |
| gr2 | **section 全部 Oodle1 压缩**(M0 T1 实测确认,非可选);个别文件可能混 Oodle0 / BitKnit | Oodle0/1 有公开重实现(~200 行 LZ 变体),M0 T2 必做;BitKnit 无公开实现 —— 9166 全样本 0 个,若真遇到 → Windows 侧 Granny SDK 离线转未压缩。 | 中 |
| gr2 | 自描述类型树递归 / 未知类型 / 重定位表 | 写通用类型树遍历器(不假设布局)+ 与 SDK dump 逐字段对拍。 | 中 |
| 动画 | Granny 曲线压缩格式多样(关键帧数组、D3/D4nK 量化、B 样条拟合) | 三层退路:① M0 统计实际用到的子类型,多数只需实现关键帧 + 简单量化;② `granny_curve*.cpp` 当算法参照;③ **子类型超范围就走"oracle 离线烘焙成密集关键帧"(每骨每帧一个 TRS),libgr2 只读烘焙格式,PoC 完全绕开曲线解码,代价是动画数据变大 + 一个烘焙工具**。加了 ③ 后此项可视为"中"。 | **高** → 中(有 ③ 兜底) |
| 动画 | ease-in/out 曲线、loop、局部时钟语义 | 照 `EterGrnLib/Motion*``ModelInstanceMotion.cpp` 的调用序列逐行复刻。 | 中 |
| 蒙皮 | 骨骼绑定顺序 / mesh binding 到骨架的重映射 | 复刻 `GrannyNewMeshBinding` 的按名匹配逻辑;M2 门禁先过骨骼矩阵、再过顶点,逐级隔离。 | 中 |
| 坐标系 | Granny 轴向 / 单位 → 运行时约定的 basis 转换(一个 90° 轴交换或 ×100 缩放目视看不出) | **主抓手是数值锚点**oracle dump 与 libgr2 输出都声明同一坐标约定(手系、单位、根变换是否 apply),骨骼矩阵逐元素对拍即可暴露;复刻 `GrannyConvertSingleObject`;朝向明确的资产做辅助目视。 | **高** |
| 集成 | sokol_app + bgfx 都想拥有 swapchain/context,非 sokol_app 常规用法 | M1 独立子门禁验证交接;退路是桌面 SDL2 + iOS/Android 各写最小原生壳(见 [§03 窗口层](#窗口层sokol_app-与-bgfx-的交接是一处真风险))。 | 中 |
| 验证 | `texture_stage.sh` 是未校验的 D3D 固定功能重实现(MODULATE2X clamp、arg complement、多 stage 串联) | 用一个已知多 stage 材质在 M1/M2 单独和 oracle 对拍,不靠整场景 SSIM 兜。 | 中 |
| 验证 | 数值阈值 ε 拍脑袋,正确代码也可能 fail | 先跑 oracle 两遍测噪声地板,ε 设在地板 + 余量之上(见 [§07 数值层](#数值层))。 | 中 |
| 贴图 | 移动端无 S3TC / DXT 硬件支持(确定项) | PoC 运行时解压 DXT→RGBA8(复用 EterImageLib,显存 ×4);量产改离线转 ASTC / ETC2 或 Basis Universalbgfx `bimg`/`texturec`)。 | **高** |
| 贴图 | NPOT / mipmap 链 / sRGB | GLES3 / Metal 均支持 NPOT;统一线性工作流 + sRGB 纹理视图。 | 低 |
| RHI | 深度范围 0–1 vs −1–1、裁剪空间 Y 翻转、行 / 列主序矩阵 | bgfx 有 `bgfx::getCaps()->homogeneousDepth` / `originBottomLeft` 抹平后端差异;矩阵全程列主序(glm);离屏 RT 用 bgfx 的约定。 | 中 |
| 着色器 | `shaderc` 跨编译到 metal / 300_es / s_5_0 行为差异 | 单一 `.sc` 源;开 bgfx / Metal / GLES 验证层;三端快照对拍。比手写多份 GLSL 更省心。 | 低 |
| 着色器 | `.sc` 草稿缺蒙皮 / fog / 顶点色,且 `.bin` 是废产物 | 从 `.sc` 重编;新写 `vs_pnt_skinned.sc`,补 fog 与 `a_color0``compile_shaders.bat` 换成 CMake custom command。属已知工作量,非未知风险。 | 低 |
| 精度 | 移动 GPU mediump 存不下骨骼矩阵 / 大坐标 | 顶点着色器里骨骼矩阵与位置用 highp;必要时把模型原点归一。 | 中 |
| 平台 | Android GL context 丢失后 GPU 资源需重建(确定项) | 在 sokol_app 挂起 / 恢复事件里按 bgfx 的 `reset` 流程处理;**M3 就做,别拖到最后**。 | **高** |
| 平台 | iOS 全静态链接、无 dlopen、后台丢 Metal drawable | 全静态;Metal 生命周期照 sokol_app 处理。 | 中 |
| 平台 | 9166 个散装资产上真机的方式没定(APK/bundle 体积、iOS 限制) | M3 前定:提前接 eterpack,或只打测试资产集。M3 交付要显式包含这一步。 | 中 |
| 构建 | bgfx 依赖 `bx` / `bimg` / `bgfx` 三仓 + 单一 CMake 出三端 | 用 `bgfx.cmake` 封装,submodule 固定版本;一开始就配好 toolchain + CI 三端并行。 | 中 |
| 授权 | libgr2 部分模块是从泄露 Granny SDK **直接端口**`oodle1.c` = `radlz.c`/`radarith.c`/`arithbit.c` 的解码路径逐行移植,非"看规格重写");其余(文件头 / 类型树 / 骨架)是照 struct 定义写的 | 比 clean-room 弱。对"内部自用、不公开、非商业"可接受;`oodle1.c` 顶部注明来源。**一旦要公开 / 商用**:`oodle1.c` 必须换成真正的 clean-room 实现或第三方 LZgr2 里的 Oodle1 只是个简单 LZ+算术编码,可重写),Granny 运行时逻辑评估 ozz-animation 替换或采购授权。 | 中 |
| 范围 | PoC 蔓延到地形 / 特效 / UI | 严守"骨骼蒙皮 + 三端跑通"边界;地形 / 特效 / Python 明确排除在 M4 之外。 | 中 |
| 范围 | **Python/UI 层是仅次于 gr2 的第二大未知,被本 PoC 排除** | CPython 3 在 iOS(无 JIT / 全静态 / 脚本预编译)跑 Metin2 那 2000+ 个 `.py` + `EterPythonLib` C 扩展 + 依赖 D3D 图元的 `ui.py` 窗口系统 —— **这是 PoC 之后要立刻做的"第二个 PoC"**,不是"已知可行的工作量"。 | **高**(对完整移植) |
---
## 09 · 工作量 · 人力 · 目录
### 工作量
| 工作项 | 单人全职 | 2 人小队 |
|---|---|---|
| **oracle 工具**Windows:抽出 EterGrnLib 单独编、锁 Granny 版本、两层矩阵 + 顶点 dump、确定态改造、自检) | 1–2 周(与 M0 并行,含在关键路径) | 由会 Windows/D3D 的人专责 |
| M0 gr2 解析器 + fuzz + 结构化对拍 + 筛测试资产集 | 1–2 周 | 合计 ≈ 68 周(libgr2 / oracle+验证台 / RHI+壳 三线并行) |
| M1 macOS 静态渲染(含 bgfx↔sokol_app 交接子门禁 24 天) | 23 周 | |
| M2 动画 + CPU 蒙皮(曲线解码是关键路径) | 2–3 周 | |
| M3 iOS + Android 真机 | 12 周 | |
| **到 go/no-go 结论** | **≈ 23 个月** | **≈ 68 周** |
| M4 逼近真实负载(可选) | +2 周 | +1–2 周 |
**人力硬约束**:oracle 那条线需要一个能在 Windows 上搭 D3D9 客户端构建、把 `EterGrnLib` 拆出来单独链的人 —— 这和写 libgr2 / RHI 的技能不同,单人做要来回切换,小队要专人。
关键路径是 **libgr2 曲线解码(M0 → M2+ oracle 工具(M0** 两条并行。
### 建议目录结构
```
xrender-poc/
third_party/
bgfx.cmake/ bx/ bimg/ bgfx/ submodule,含 shaderc / bin2c
sokol/ 仅 sokol_app.h
glm/
reuse/ EterBase/ EterPack/ EterImageLib/ ← 冻结快照 + 本项目修复提交(非引用)
libgr2/
include/ src/ gr2_file.* gr2_types.* gr2_skeleton.*
gr2_mesh.* gr2_anim.* gr2_decompress.*
engine/ rhi.*(包 bgfx:: skinning.* animation.* material.* scene.* camera.*
formats/ msm.* msa.* race.* ← 照 EterGrnLib/RaceManager 复刻
app/
main.c 窗口壳:默认 sokol_app → bgfx::PlatformData → bgfx::init
交接过不了则切 SDL2(桌面)+ 各平台最小原生壳(见 §03)
shaders/ *.sc + texture_stage.sh + varying.def.sc
← 从 ../MobileSource/Cross Platform/shaders.rar/shaders 种子
+ vs_pnt_skinned.sc(新写)
shaders.cmake 可移植编译步骤:shaderc → metal / 300_es / s_5_0 → bin2c
platform/ macos/ ios/ android/ (壳工程 + toolchain
oracle/ Windows-only:锁定版 granny2.dll + EterGrnLib
dump 两层骨骼矩阵 + 蒙皮顶点 + 确定态截图;坐标约定写死
tools/ gr2dump/ (M0 CLI) gr2fuzz/ snapshot_compare/ anim_bake/(曲线退路)
test/ assets.listM0 产出) golden/ oracle_dumps/ noise_floor.json
cmake/ ios.toolchain.cmake android helpers
CMakeLists.txt
```
### 下一步
**M0** 起,两件事并行:
1. **锁定 oracle 的 Granny 版本** —— 确认这批 v6 资产在哪个 `granny2.dll``GrannyGetFileInfo` 完全正常,固定进 `oracle/`
2. **写 `gr2dump`** —— 输出 `warrior_cheongrin*.gr2` 的结构化 dump(骨架 / 网格 / 曲线)+ glTF;结构化 dump 与 oracle 逐字段对拍(这是主验证),glTF 仅供 Blender 目视骨架和 bind pose。
这是单块价值最高、且不依赖任何 RHI / 平台决策的第一块砖。
---
## 10 · 最终验收与结论证据包
"最终成果如何验证"分两层,PoC 与完整移植的验收方式不同。
| | PoC 的最终成果 | 完整移植的最终成果 |
|---|---|---|
| 是什么 | **一份 go/no-go 结论 + 证据包**(不是能玩的客户端) | 能上架的三端 Metin2 客户端 |
| 验证目标 | "骨骼网格这条链在三端成立" 站得住、可复现 | 功能对等 + 三端一致 + 性能 / 稳定性达标 |
### 10.1 PoC 结论证据包
PoC "通过"的判据不是"看着对了",而是**能交给别人独立重跑并核对的一个包**。仓库 `test/` 下产出:
**① 可复现验证套件(CI 一条绿灯 = 全部过)**
- `gr2fuzz` 报告:9166 / 9166 解析成功 + 格式变体直方图 —— 证明"能读全部资产",不是挑了几个好文件。
- `numeric_diff` 报告:测试资产集 × N 帧 × M 顶点,骨骼矩阵 `max|Δ|` 与蒙皮顶点 `‖Δ‖` 的分布(直方图 + p99 + max),全部 < 阈值。
- `snapshot` 对比:确定性场景在 Windows-oracle / macOS / iOS / Android 四组截图 + 两两 SSIM 矩阵。
- `perf` 报告:每台真机 1 / 8 / 20 / 50 角色的帧时间 p50/p99、FPS、显存、RSS、冷启动、gr2 加载耗时,逐项标注是否越线。
- 三端冒烟:`macos / ios-sim / android-sim` 三个 CI job 全过,无 bgfx / Metal / GLES 验证层报错。
**② 门禁汇总表** —— M0–M3 每道门禁一个明确 pass/fail(见 [§06](#06--里程碑与验证门)),最终成果 = 全绿的汇总。
**③ 反证清单(negative evidence** —— 把当初担心会 block 的点逐条列出实测结果。**下表是待填模板,结论由 M0–M3 产出,不是现在已知的事实**:
| 担心点 | 实测结果(TBD) |
|---|---|
| oracle 用的 Granny 版本 + oracle 自检 | 待 M0 |
| libgr2 `InverseWorld4x4` 自洽(`world_bind · invBind ≈ I` | 待 M0 |
| Granny 曲线子类型分布 / 是否需烘焙退路 | 待 M0 fuzz |
| 非单位 scaleshear 用量(影响蒙皮公式 + mediump | 待 M0 fuzz |
| section 压缩类型分布 | 待 M0 fuzz |
| `.msm` / `.msa` 解析对拍源文本 | 待 M2 |
| 坐标系 basis 转换(数值锚点 + Blender tie-break | 待 M1M2 |
| sokol_app + bgfx 交接 | 待 M1 子门禁 |
| `texture_stage.sh` 多 stage 材质对拍 | 待 M1 |
| ε 噪声地板实测值 | 待 M2 |
| Android context loss 恢复(×100 | 待 M3 |
| iOS 全静态 + 后台 / 锁屏 / 来电恢复 | 待 M3 |
| 移动 GPU mediump 精度专项 | 待 M3 |
**④ 一页纸结论** —— go / no-go
- **go** → 附"正式移植还缺什么"清单(地形 / 特效 / UI / Python 的工作量估计)。
- **no-go** → 具体死在哪层 + 备选路径(ozz-animation 替换 / Rust 重写 / 动画烘焙退路)。
### 10.2 完整移植后的验收(**属 PoC 之后另立项目,不在本方案范围**)
以下 8 条是 PoC 通过、决定正式移植后要另写的验收纲要,本方案不覆盖其计划与工作量。PoC 只证了骨骼网格这一条。
1. **资产全覆盖回归** —— 不是 15 个测试资产,而是全部 gr2 / 全部地图 / 全部特效 / 全部 UI 脚本跑一遍,自动截图 + 与 Windows 客户端**逐场景对拍**(把 oracle 从"单帧 dump"扩成"整机录制回放 + 全场景截图")。
2. **功能对等清单** —— 登录 → 选人 → 进游戏 → 战斗 → 交易 → 公会 → 商城…每个 Python phase 在三端都能走通(手动 + 录制回放)。
3. **三端一致性** —— 同一操作序列,三端渲染 + UI 布局 + 逻辑结果一致。
4. **性能预算** —— 目标机型上完整场景(城市、BOSS 战、大量玩家)达到帧率 / 内存 / 发热 / 耗电 / 包体 / 流量预算。
5. **稳定性** —— 三端各跑 N 小时 monkey / soak;崩溃率 < 阈值;context loss、来电、切后台、锁屏、热重启全过。
6. **兼容性矩阵** —— iOS 最低版本 × 机型;Android GPUAdreno / Mali / PowerVR)× API level × 厂商 ROM。
7. **上架前检查** —— iOS 无私有 API、隐私清单、包大小;Android 64 位、target SDK、权限。
8. **回归防线** —— CI 每次改动跑快照回归 + 性能基准,防"某次改动让 Mali 上花屏"这类回归。
### 10.3 贯穿始终的一条原则
**Windows 客户端是唯一真值源。** 任何"三端自己看着对"都不算数,必须能和 Windows 逐帧 / 逐场景对拍。
这套 oracle 基础设施从 M0 就开始建(先只 dump 骨骼 + 顶点 + 单帧),移植阶段扩成整机录制回放 —— 是整个项目最值的一笔投资。
+32
View File
@@ -0,0 +1,32 @@
# reference/ — archived from xrender-poc
These docs come from **`xrender-poc`**, the bgfx-based rendering POC that was
**retired on 2026-08-29** in favour of the Godot route (this repo). See
[`../MIDREVIEW.md`](../MIDREVIEW.md) for the decision.
They are kept because the reverse-engineering knowledge is route-independent and
still authoritative:
| file | what's still useful |
|---|---|
| `steps/M0-gr2-reader.md` | the entire `.gr2` v6/v7 format breakdown that `libgr2` implements — header, sections, Oodle1, self-describing type tree, `granny_transform`, `OldCurveType` B-splines, the "明确不做" table. **`libgr2` is vendored here now** (`../../libgr2`). |
| `steps/00-oracle.md` | how the Wine + MinGW Granny oracle works — the ground truth for `libgr2` numeric verification. Oracle harness vendored at `../../oracle/`. |
| `steps/M1-static-render.md` | material-binding chain (`granny_material``.Maps[].Map``granny_texture.FromFileName`), DDS/DXT decode, the bgfx-era gotchas. The material logic maps 1:1 onto `m2_material.cpp` here. |
| `steps/M2-anim-skinning.md` | CPU LBS formula, `sample_pose` retarget-by-bone-name, the Granny normal-transform (plain 3×3, **no** inverse-transpose) — same math the GDExtension uses. Numeric gates vs Granny (≤6.5e-5). |
| `steps/M3-mobile.md` | iOS/Android notes from the bgfx shells — historical; Godot handles platform now. |
| `steps/M4-realistic-load.md` | eterpack / full-load plan — deferred in both routes. |
| `PLAN.md` | the bgfx-route master plan. §01 (go/no-go criteria, "工程性延期 vs 方案性死路"), §05 (format findings), §07 (visual-diff layering) are still the shared vocabulary. |
| `DEMO-PLAN.md` | bgfx RHI demo design record. Mostly superseded; kept for the shader/material-stage analysis. |
**Relative paths inside these files** (`../libgr2`, `oracle/`, `test/golden/…`,
`engine/…`, `app/…`) refer to the old xrender-poc layout. The mapping:
| old (xrender-poc) | now (mtgodot-poc) |
|---|---|
| `libgr2/` | `libgr2/` (vendored, unchanged) |
| `formats/` | `formats/` (vendored) |
| `oracle/` | `oracle/` (vendored; `oracle.exe` + `granny2_x64.dll` rebuilt locally) |
| `tools/{gr2dump,gr2fuzz,oracle_diff}` | `tools/` (build with `-DMTGODOT_BUILD_TOOLS=ON`) |
| `engine/`, `app/`, `platform/`, `third_party/` | **dropped** — Godot + the GDExtension in `extension/` replace them |
| `test/golden/*.png` (bgfx shots) | `test/bgfx-reference/` (cross-check images) |
| `test/{noise_floor,m2-numeric}.json`, `assets.list` | `test/` (vendored baselines) |
+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 反证清单 + 一页纸结论的"正式移植还缺什么"部分