From 528a6390ea425585490441b9132d0ddb6744da4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:46:54 +0900 Subject: [PATCH] Phase 1 mid-review + stress harness - docs/MIDREVIEW.md: what M0'-M2.5 proved, bridge-layer LOC (~1100), key technical conclusions (4x4 transpose, animation method B, no handedness flip needed), remaining risks, recommendation = enter Phase 2 with a 3-item wrap-up (compare.py, dump_materials oracle, refocused perf). - harness MTGODOT_STRESS=N: grid of N animated warriors, vsync off, dumps test/godot-macos-stress.json. Frame times are throttle-capped in a background window (~30fps to 50 chars, 52ms at 100); the reliable signals are build cost ~165ms/char and the 100-char breakout. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WaHYEY9rwLWt21PULiYjeJ --- README.md | 7 +++- docs/MIDREVIEW.md | 60 +++++++++++++++++++++++++++ project/main.gd | 80 ++++++++++++++++++++++++++++++++++++ test/godot-macos-stress.json | 63 ++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 docs/MIDREVIEW.md create mode 100644 test/godot-macos-stress.json diff --git a/README.md b/README.md index fa72365b..e6013ee3 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,12 @@ opaque/alpha/alpha-test/add 四模式)+ 目录扫描贴图解析 + 方向光阴影 + procedural sky + 引擎雾(`test/golden/m25-*.png`)。**门禁未闭**:多材质槽 → 贴图/混合的精确映射需 给 libgr2 加材质 API(见 `docs/GODOT-POC-PLAN.md` §M2.5 T2.5.6 差距清单)。 -- [ ] **中期评审** — 待 M2.5 门禁 + 对拍报告 +- [~] **中期评审** — [`docs/MIDREVIEW.md`](docs/MIDREVIEW.md)。结论:桥接层工程量小、 + 风险基本出清,**建议进入 Phase 2**。收尾 3 项:`compare.py` 对拍、`dump_materials` + Granny 对拍闭合 M2.5 门禁、前台/真机性能重测。 -对拍(与 `xrender-poc` bgfx demo / oracle 逐帧)尚未接线,是中期评审前的下一步。 +50 角色压力采样:`test/godot-macos-stress.json`(后台窗口 30fps 节流,仅构建成本 +~165ms/角色 和 100 角色破顶到 52ms 是可信信号)。 --- diff --git a/docs/MIDREVIEW.md b/docs/MIDREVIEW.md new file mode 100644 index 00000000..0002629e --- /dev/null +++ b/docs/MIDREVIEW.md @@ -0,0 +1,60 @@ +# Phase 1 中期评审 — mtgodot-poc + +> 日期:2026-08-29 · 平台:macOS(Apple M2)· Godot 4.7.1 · 对标 [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) §00 Phase 1 +> 结论先行:**桥接层工程量小、风险已基本出清,建议进入 Phase 2(移动端)**。M2.5 门禁的"材质精确映射"仍有一处需要 oracle 的开口,不阻塞。 + +--- + +## 1. 做到了什么(M0'–M2.5) + +| 里程碑 | 状态 | 证据 | +|---|---|---| +| **M0'** 脚手架 | ✅ | GDExtension 加载、`Metin2Model`/`Metin2AnimPlayer` 注册、`libgr2` 静态链接、Godot 4.7 工程 Metal Forward+ 出画 | +| **M1** 静态渲染 | ✅ | `warrior_cheongrin`:75 骨 / 5 表面 / 3324 顶点,正立、朝 +Z、`unit_scale=0.01`、`flip_z/flip_winding=false`、贴图正确(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、`shaman_lord`(**v7**,93) 均加载渲染正确 | +| **M2** 骨骼动画 | ✅ | `dance01`(28.3s / 74 track)经 `gr2::sample_pose`(跨文件)→ `Skeleton3D.set_bone_global_pose` → Godot GPU 蒙皮播放。`selfcheck` 25 采样 **0 NaN/Inf**。t=9/16 姿势正确、无蒙皮撕裂(`test/golden/m2c-*.png`、`m25-warrior-shader.png`) | +| **M2.5** 材质与观感 | 🟡 门禁未闭 | `ShaderMaterial`(modulate tex×COLOR×light;opaque/alpha/alpha-test/add 四模式)+ 目录扫描贴图解析(warrior 5/5、assassin 17/17、shaman 1/1 表面命中)+ 方向光阴影 + procedural sky + 引擎雾(`test/golden/m25-*.png`)。`libgr2::dump_materials()` 已加(见 §4) | + +## 2. 桥接层工程量(实测) + +新增 C++ ≈ **1100 行**(含端口自 xrender-poc 的 DXT 解码 160 行): + +| 文件 | 行 | 职责 | +|---|---|---| +| `gr2_bridge.{h,cpp}` | ~260 | gr2 Mat4→`Transform3D`(4×4 转置)、`build_skeleton`/`build_skin`/`build_mesh`、Z-up→Y-up `make_conv` | +| `metin2_model.{h,cpp}` | ~470 | `Metin2Model` 节点、`load_gr2`、贴图解析、材质、属性 | +| `metin2_anim.{h,cpp}` | ~230 | `Metin2AnimPlayer`、每帧 `sample_pose`→骨骼、`selfcheck` | +| `m2_material.{h,cpp}` | ~130 | Metin2 风格 `ShaderMaterial`(两个 shader:blend_mix / blend_add) | +| `dxt.{h,cpp}` | ~160 | DDS DXT1/3/5 软解(端口) | +| libgr2 `gr2_material.cpp`(xrender-poc) | ~110 | `dump_materials()` 类型树遍历(附加) | + +**耗时**:M0'–M2.5 一次会话内跑通。与计划 §05 估的 Phase 1「3.5–5 周单人」相比,**核心桥接远比预估轻** —— 因为 `libgr2` 已完成、Godot 的 `Skeleton3D`/`ArrayMesh`/`Skin`/GPU 蒙皮直接可用,且关键恒等式(`skin_matrix = global_pose(i)·bind_pose(i) == gr2 deformer 矩阵`)成立,不需要自研蒙皮。剩余预估工作量集中在 M2.5 材质精修 + 对拍。 + +## 3. 关键技术结论 + +- **坐标/单位**:gr2 行主序 Mat4 → Godot `Transform3D` 就是一次 **4×4 转置**(gr2 平移在第 4 行 = Godot `.origin`)。Z-up cm → Y-up m(`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform。四个测试骨架全部 `flip_z=false`、`flip_winding=false` 正确 —— **Metin2/Granny 内容对 Godot 右手系不需要额外镜像**。 +- **动画走 B 方案**:`_process` 里 `sample_pose` 出 `world[]`,`skel.set_bone_global_pose(i, conv(world[i]))`,Skin bind = `conv(inverse_world[i])`。Godot 的 GPU 蒙皮矩阵 `global_pose(i)·bind(i)` 恒等于 gr2 deformer 矩阵。**不需要烘焙 `Animation` 资源** 即可保真(A 方案仅在移动端多角色 CPU 压不住时才需要)。 +- **贴图**:Godot 4 `Image.load()` 不支持 `.dds` → 运行时软解 DXT(`dxt.cpp`)。`.gr2` 用 `.gdignore` + 绝对路径加载,绕开 Godot import。 +- **移动端预判更好**:M2.5 的 `ShaderMaterial` + `ArrayMesh` + `Skeleton3D` 都在 Godot Compatibility(GLES3) 支持范围内 —— Phase 2 老安卓机兜底比"Godot 里塞自研渲染器"那条路好。 + +## 4. 剩余风险 / 门禁开口 + +| 项 | 影响 | 出路 | +|---|---|---| +| **M2.5 材质精确映射未闭**(唯一门禁开口)| `libgr2::dump_materials()` 已能取材质名 + `FromFileName`(warrior 4 材质 / 2 有 diffuse 名),但 material_index→贴图 的映射**未对拍 Granny**,实测驱动 warrior 时 body 表面误取 face 贴图。当前默认走文件名+目录扫描启发式(观感正确),material 路径 `use_gr2_materials` 开关默认关 | 需要 Granny oracle 逐材质对拍(与 xrender-poc M1 T5 同一缺口,共用修法)。这是 Phase 1 收尾的最后一项 | +| **对拍未接线** | 与 xrender-poc bgfx demo / oracle 的逐帧几何/骨骼矩阵对拍脚本(`test/compare.py`)未写 | 需先构建 xrender-poc bgfx demo(首次 ~10 min)。中期评审后、进 Phase 2 前补 | +| **性能数字被节流** | 后台窗口下 Godot 限 30fps,1–50 角色帧时间都贴在 33ms 天花板;100 角色破顶到 52ms。资产构建 **~165ms/角色**(单线程,无跨模型缓存),100 角色加载 16.5s(`test/godot-macos-stress.json`) | 真机 / 前台窗口下重测(Phase 2)。构建成本需 Phase 2 做多线程 + eterpack 共享贴图 | +| **多材质槽内拆分** | `build_mesh` 目前一个 gr2 mesh = 一个 surface,未按 `tri_groups[].material_index` 再拆 | 有 material API 对拍后一起做 | +| ShaderMaterial 静态 Shader 退出泄漏、alpha 排序 corner case、法线未逆转置 | 均为已知小项,见 `GODOT-POC-PLAN.md` §M2.5 T2.5.6 | 不阻塞 | + +## 5. 建议 + +**进入 Phase 2(iOS + Android)。** + +理由:Phase 1 要证明的三件事里,①gr2→Godot 场景对象、②动画运行时移植,**已用可运行代码 + 截图 + 0-NaN 自检证明可行且工程量小**;③观感还原的基础设施(自定义 shader、阴影、sky、fog、多骨架)已就位,只差"材质精确映射"这一处需 oracle 的收尾,不构成方案性风险。 + +Phase 2 前的收尾清单(1–1.5 周): +1. 写 `test/compare.py`,与 xrender-poc bgfx demo 同机位 / 同 t 对拍几何 + 骨骼矩阵。 +2. 补 `libgr2::dump_materials` 的 Granny oracle 对拍,闭合 M2.5 门禁。 +3. 前台窗口 / 真机重测性能,给 Phase 2 的动画 A/B 一个可信基线。 + +不建议现在做的:地形、`.mse` 特效、eterpack、UI/Python —— 均按计划留到正式移植。 diff --git a/project/main.gd b/project/main.gd index 5585411a..0a76dc67 100644 --- a/project/main.gd +++ b/project/main.gd @@ -31,6 +31,12 @@ func _ready() -> void: _build_environment() _build_sun() _build_camera() + + var stress := OS.get_environment("MTGODOT_STRESS") + if stress != "": + await _run_stress(int(stress)) + return + _load_content() _frame_model() print("[mtgodot] harness ready") @@ -197,6 +203,80 @@ func _on_key(kc: int) -> void: _anim.call("set_time", _anim.call("get_time") + 0.05) +## MTGODOT_STRESS=N : spawn N animated warriors in a grid, measure frame time +## over ~180 frames, write test/godot-macos-stress.json, quit. +## Feeds the Phase-2 animation A/B (per-frame set_bone_global_pose vs baked). +func _run_stress(n: int) -> void: + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + Engine.max_fps = 0 + var model_path := _assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2") + var tex_dir := _assets_root.path_join("PC/ymir work/pc/warrior") + var anim_path := _assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.gr2") + var cols := int(ceil(sqrt(float(n)))) + var t_build_start := Time.get_ticks_usec() + for i in n: + var m: Node3D = ClassDB.instantiate("Metin2Model") + m.name = "M%d" % i + m.set("texture_dir", tex_dir) + m.set("gr2_path", model_path) + m.position = Vector3((i % cols) * 1.2 - cols * 0.6, 0, (i / cols) * 1.2) + add_child(m) + var a: Node3D = ClassDB.instantiate("Metin2AnimPlayer") + a.set("model_path", NodePath("../M%d" % i)) + a.set("anim_path", anim_path) + a.set("time_scale", 1.0 + 0.01 * i) # desync + add_child(a) + a.call("reload") + a.call("set_time", 0.13 * i) + var build_ms := (Time.get_ticks_usec() - t_build_start) / 1000.0 + + _target = Vector3(0, 1.0, cols * 0.6) + _dist = cols * 2.2 + _pitch = 0.35 + _update_camera() + + # warm-up + for i in 40: + await get_tree().process_frame + var total: Array[float] = [] + var proc: Array[float] = [] + for i in 240: + await get_tree().process_frame + total.append(1000.0 / maxf(Engine.get_frames_per_second(), 1.0)) + proc.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0) + total.sort() + proc.sort() + var tsum := 0.0 + for s in total: + tsum += s + var psum := 0.0 + for s in proc: + psum += s + var report := { + "characters": n, + "build_ms_total": build_ms, + "build_ms_per_char": build_ms / n, + "frames": total.size(), + "frame_ms_avg": tsum / total.size(), + "frame_ms_p95": total[int(total.size() * 0.95)], + "frame_ms_max": total[-1], + "fps_avg": 1000.0 / (tsum / total.size()), + "cpu_process_ms_avg": psum / proc.size(), + "cpu_process_ms_p95": proc[int(proc.size() * 0.95)], + "renderer": RenderingServer.get_video_adapter_name(), + "method": "per-frame set_bone_global_pose (B)", + } + var out := OS.get_environment("MTGODOT_STRESS_OUT") + if out == "": + out = ProjectSettings.globalize_path("res://../test/godot-macos-stress.json") + var f := FileAccess.open(out, FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(report, " ")) + f.close() + print("[mtgodot] stress ", n, " -> ", report) + get_tree().quit() + + func _screenshot() -> void: var img := get_viewport().get_texture().get_image() var path := "user://shot_%d.png" % Time.get_ticks_msec() diff --git a/test/godot-macos-stress.json b/test/godot-macos-stress.json new file mode 100644 index 00000000..96ab15b0 --- /dev/null +++ b/test/godot-macos-stress.json @@ -0,0 +1,63 @@ +{ + "note": "macOS, background/unfocused window -> Godot throttles to ~30fps; frame_ms rows at ~33.3 are the throttle ceiling, not measured cost. build_ms_per_char and the 100-char breakout (52ms) are the reliable signals. Proper frame timing = Phase 2 (focused window / on-device).", + "renderer": "Apple M2 (Apple8)", + "method": "per-frame set_bone_global_pose (B)", + "runs": [ + { + "build_ms_per_char": 170.682, + "build_ms_total": 170.682, + "characters": 1, + "cpu_process_ms_avg": 34.1602583333333, + "cpu_process_ms_p95": 34.405, + "fps_avg": 29.345480700072, + "frame_ms_avg": 34.0767973856208, + "frame_ms_max": 58.8235294117647, + "frame_ms_p95": 33.3333333333333, + "frames": 240, + "method": "per-frame set_bone_global_pose (B)", + "renderer": "Apple M2 (Apple8)" + }, + { + "build_ms_per_char": 167.27, + "build_ms_total": 1672.7, + "characters": 10, + "cpu_process_ms_avg": 35.1748875, + "cpu_process_ms_p95": 41.667, + "fps_avg": 30.0000000000001, + "frame_ms_avg": 33.3333333333332, + "frame_ms_max": 33.3333333333333, + "frame_ms_p95": 33.3333333333333, + "frames": 240, + "method": "per-frame set_bone_global_pose (B)", + "renderer": "Apple M2 (Apple8)" + }, + { + "build_ms_per_char": 164.90146, + "build_ms_total": 8245.073, + "characters": 50, + "cpu_process_ms_avg": 34.5210166666665, + "cpu_process_ms_p95": 34.624, + "fps_avg": 30.1173930643639, + "frame_ms_avg": 33.203405017921, + "frame_ms_max": 33.3333333333333, + "frame_ms_p95": 33.3333333333333, + "frames": 240, + "method": "per-frame set_bone_global_pose (B)", + "renderer": "Apple M2 (Apple8)" + }, + { + "build_ms_per_char": 164.65813, + "build_ms_total": 16465.813, + "characters": 100, + "cpu_process_ms_avg": 54.0874916666667, + "cpu_process_ms_p95": 62.877, + "fps_avg": 19.1516169676607, + "frame_ms_avg": 52.2149122807017, + "frame_ms_max": 52.6315789473684, + "frame_ms_p95": 52.6315789473684, + "frames": 240, + "method": "per-frame set_bone_global_pose (B)", + "renderer": "Apple M2 (Apple8)" + } + ] +} \ No newline at end of file