# Metin2 跨平台渲染引擎 PoC — 完整方案 > 内部研究方案,不对外公开。 > 基于对 `m2dev-client-src-main`、`MobileSource` 及 `m2dev-client-main/assets` 中真实 `.gr2` 资源的实测编写。 > 所有版本号、格式偏移、API 数量均来自源码与文件抽查。 | | | |---|---| | 状态 | PoC 规划 | | 技术栈 | C++20 · **bgfx**(RHI) · sokol_app(仅窗口/输入) | | 目标平台 | macOS · iOS · Android(并行) | | 判定周期 | ≈ 2–3 个月 → go / no-go | | 复用 | EterBase · EterPack · EterImageLib · `MobileSource` 的 `.sc` 着色器草稿 | | 自研 | libgr2(gr2 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/reference/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` + 三份 toolchain:iOS(`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 Universal(bgfx 的 `bimg` / `texturec` 就能做)。 - **文件访问** — 直接用 `CMappedFile`,桌面走 mmap,Android 走 `AAsset_getBuffer`(MobileSource 已实现)。M0–M3 用散装文件,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 着色器基线**,M1–M2 直接改 | | `vs_pdt.sc` / `fs_pdt.sc` | position-diffuse-texcoord,Metin2 最常用 FVF | 带顶点色的网格 | | `vs_pc.sc` / `fs_pc.sc` | position-color,无纹理 | UI 图元 | | `vs_pt.sc` / `pt2`、`fs_pt.sc` / `pt2` | position-texcoord,2D 图像/精灵 | UI 贴图 | | `vs_terrain.sc` / `fs_terrain.sc` | 地形 splatting,很小 | 基本占位,PoC 不用 | | **`texture_stage.sh`** | 把 D3D 固定功能 `SetTextureStageState` 完整翻译成 shader:COLOROP/ALPHAOP(MODULATE、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 数组或 texture,VS 里做 LBS)。这是 M2 的核心。 - `vs_pnt` 丢了 `a_color0`(Metin2 网格常带顶点色),要补。 - `v_texcoord1`(第二 UV,lightmap/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`(rigid,4016 mesh)、`PNT3322`(rigid 双 UV,309)、`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 静态渲染 — 2–3 周 - **交付** — sokol_app 窗口 + bgfx(Metal),渲染 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 真机 — 1–2 周(与 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.95–0.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 11(A13)+ Pixel 6(Mali-G78)+ 一台 Adreno 机(如 Redmi Note 系列),三种主流移动 GPU 架构各一。 - **指标** — 帧时间 p50 / p99(ms)、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 Universal(bgfx `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 实现或第三方 LZ(gr2 里的 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 周 | 合计 ≈ 6–8 周(libgr2 / oracle+验证台 / RHI+壳 三线并行) | | M1 macOS 静态渲染(含 bgfx↔sokol_app 交接子门禁 2–4 天) | 2–3 周 | | | M2 动画 + CPU 蒙皮(曲线解码是关键路径) | 2–3 周 | | | M3 iOS + Android 真机 | 1–2 周 | | | **到 go/no-go 结论** | **≈ 2–3 个月** | **≈ 6–8 周** | | 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.list(M0 产出) 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) | 待 M1–M2 | | 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 GPU(Adreno / Mali / PowerVR)× API level × 厂商 ROM。 7. **上架前检查** —— iOS 无私有 API、隐私清单、包大小;Android 64 位、target SDK、权限。 8. **回归防线** —— CI 每次改动跑快照回归 + 性能基准,防"某次改动让 Mali 上花屏"这类回归。 ### 10.3 贯穿始终的一条原则 **Windows 客户端是唯一真值源。** 任何"三端自己看着对"都不算数,必须能和 Windows 逐帧 / 逐场景对拍。 这套 oracle 基础设施从 M0 就开始建(先只 dump 骨骼 + 顶点 + 单帧),移植阶段扩成整机录制回放 —— 是整个项目最值的一笔投资。