From cea79fe17c49577dc75ffe27c8e72599dcff3eb7 Mon Sep 17 00:00:00 2001 From: shen <> Date: Tue, 8 Sep 2026 13:26:42 +0800 Subject: [PATCH] fix(rendering): complete character visual regression fixes --- docs/RENDERING-REPAIR-PLAN.md | 27 ++++++++- extension/src/metin2_model.cpp | 96 +++++++++++++++++++++++++++++- extension/src/metin2_model.h | 8 +++ project/char_select_visual_test.gd | 85 ++++++++++++++++++++++++-- project/character_winding_test.gd | 63 ++++++++++++++++++++ project/hud.gd | 20 +------ project/race_spec_test.gd | 45 +++++++++++++- project/skill_test.gd | 4 ++ project/ui/char_select_screen.gd | 21 ++++++- project/ui/equip_model.gd | 12 +++- project/ui/player_view.gd | 2 + project/ui/quickbar.gd | 7 ++- project/ui/race_spec.gd | 10 +++- script/rendering_test.sh | 11 ++++ test/rendering/README.md | 4 +- 15 files changed, 380 insertions(+), 35 deletions(-) create mode 100644 project/character_winding_test.gd create mode 100644 script/rendering_test.sh diff --git a/docs/RENDERING-REPAIR-PLAN.md b/docs/RENDERING-REPAIR-PLAN.md index f90b80e1..7d76db33 100644 --- a/docs/RENDERING-REPAIR-PLAN.md +++ b/docs/RENDERING-REPAIR-PLAN.md @@ -242,9 +242,9 @@ godot --path project --script char_select_visual_test.gd | R1-01 | 主角晚到、重复事件、重连回归与修复 | R0 最小基线 | 晚到/重入/重试回归通过;真实重连待验 | | R1-02 | 地图坐标、高度、相机就绪检查 | R1-01 | A1/C1 晚到定位实景通过;坡地/边界待验 | | R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 待执行 | -| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | 待执行 | +| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | CPU 绕序、GPU 动态取景和选角朝向已修复;8 个种族 GPU 待机基体矩阵已通过,装备/动作/最终包待验 | | R3-01 | 深度/材质/环境与地形校准 | R1、R2 几何稳定 | 材质已有改动,待验证 | -| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 待执行 | +| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 快捷栏重叠修复及服务器恢复回归通过;真实登录数据待验 | | R5-01 | 包资源清单、最终 `.app` 冒烟与画面报告 | R1–R4 | 待执行 | | R6-01 | 性能基线与静态资源缓存试验 | R5 | 后续 | @@ -258,8 +258,31 @@ godot --path project --script char_select_visual_test.gd - 发现地图路径原来只在 setup 时按已收到的主角位置选择。新增首次有效主角位置到达后的地图重选,复用已有地图重载和相机/移动同步基点定位函数。 - 修改 `ui/player_view.gd`:拒绝非法 race、缺失身体文件及解析失败的源文件。模型节点在入树时才实际加载,因此使用源文件预检,不能把入树前为空的 `get_info()` 当成加载失败。 - 新增 `main_character_sync_test.gd`:测试失败重试、晚到/无信号实体、重入、重复事件、移动不回滚、过期构建等,已执行得到 `failures=0`。注入失败用例的 warning 属预期。 + +### 2026-09-08 R2/R4 后续记录 + +以下为历史定位过程,早期 C++ 实验加载了旧 debug 扩展,相关排除结论无效;以文末复核记录为准。 + +- 确认用户选角用的 `HairIndex 0` 由 `warrior.msm` 精确映射到 + `hair/hair_1_1.gr2` 与 `warrior_hair_01.dds`;新增解析回归,避免把默认路径或颜色猜测为实际配置。 +- 选角实景中,`warrior_5_1.GR2`(63 骨)与 `hair_1_1.gr2`(74 骨)组合仍无脸部可见。去除发型后脸部完整,故护甲/脸部几何未丢失;分别使用当前身体蒙皮、强制发型绑定姿势、源发型 alpha 和背面剔除渲染均无差异。没有提交这些无效的猜测性改动。下一步校验角色选择协议的 `hair_part` 语义与标准发型编号,而不是继续调整绕序、动画矩阵或透明参数。 +- 修复 `root/msm` 覆盖规格缺少 `PathName` 时的解析回退:原逻辑只查职业根目录,漏掉 `hair/` 子目录,导致 `HairIndex 1001..1012` 找不到模型并静默保留 `PlayerView` 默认的 `hair_1_1`。`race_spec_test.gd` 现覆盖真实 1001 解析和文件定位;选角视觉用例确认 1001 实际替换为 `hair_2_1.gr2`。发型前脸遮挡尚未随该修复消失,继续作为几何组合项跟踪。 +- 移除 `Hud` 中覆盖真实栏位的静态空快捷栏;`Quickbar` 现在有明确的底部锚点尺寸和更高绘制层级。`skill_test.gd` 验证服务端 `type=2` 技能槽恢复后图标可见,且 `race_spec_test.gd`、`skill_test.gd` 已通过。 - `gamescene_test.gd` 与 `equip_model_test.gd` 已通过;前者故意使用缺失地图,其预期加载错误不能当成真实地图测试通过。 - 新增 `gamescene_visual_test.gd`:Apple M4 / Metal Forward+ / Godot 4.7.1 上已执行 A1/C1;A1 加载 20 区块/601 物件,C1 加载 20 区块/565 物件,身体加发型共 4 个表面,实体定位断言通过。两者均使用 FakeClient,未连接实际服务器。 - 截图人工观察:地图和真实人物出现;UI 仍有布局/风格问题,不能标为 R3/R4 完成。选角截图仍复现发型遮脸;单独翻转 winding 的试验未解决,未保留新的绕序修改。 - 用例命令、输出位置和验证边界见 [test/rendering/README.md](../test/rendering/README.md)。早期失败截图保留在 `build/rendering/`;后续脚本变化应重新执行,不复用旧报告作为新代码证据。 - 尚未完成:GLB 对照、发型根因修复、完整视觉基线与区域阈值、真实服务器重连、最终 macOS 包验收、R6 优化。下一优先项是发型的身体/附件变换与深度隔离。 + +### 2026-09-08 R2 根因复核与修复 + +- 构建复核:`build` 为 Release,编译更新 release dylib,但本地 Godot 加载仍停留在 9 月 7 日的 debug dylib。此前 inverse-bind、alpha 和剔除实验没有加载修改后的扩展,不能用它们排除根因。新增 `bash script/rendering_test.sh`,构建后同步本地加载库、重签名、输出哈希并运行绕序回归。 +- 冻结动画后改用双面材质显示完整后背,证实原选角相机在人物背后;身体背面被剔除,露出远侧正面内壁,后脑头发遂表现为“盖脸”。`cpu_skin()` 重建身体和发型索引时忽略 `flip_winding`,已对齐静态/GPU 网格的索引顺序;`PlayerView` 设置 PC 绕序,选角 pivot 从 -90° 改为 +90°。 +- 真实 Metal 选角复核:`main_part=20009`、`hair_part=0` 的脸、肩甲、护腕及裙甲完整,头发位于头顶;保留头发和原透明贴图。颜色/护甲与用户参考资源的进一步一致性仍待校准,不标为 40250 像素验收。 +- 更正 PathName 结论:MSM 中原有 PathName;`RaceSpec` 的空 SpecialPath 错误覆盖父级路径。已修复继承,并去除引号 token 的词法标记。前轮 hair 子目录回退仅缓解此问题。 +- `character_winding_test.gd` 对比绑定姿势及待机 0/0.5/1 秒的逐三角形反向索引,覆盖身体与发型,结果 `failures=0`。`race_spec_test`、`equip_model_test`、主角恢复回归通过;A1 GameScene 重新加载 20 区块/601 物件,主角定位及真实模型断言通过。 +- 选角输出现在按独立目录保存 `selection.png`、`report.json` 和发型纹理,报告包含实际 debug dylib、身体/头发/贴图哈希、引擎和取样时间。默认发型 CPU 截图:`build/rendering/selection-1788843070-9087/selection.png`;1001 GPU 用例:`build/rendering/selection-1788843071-9069/selection.png`。 +- 未覆盖:全职业、完整动作/换装矩阵、GLB 同资源对照、坡地足底误差、40250 同相机比较、真实联机和最终 macOS 包;R2 整体仍未关闭。 +- GPU 1001 取景根因修复:GPU 顶点着色器变形不会改变 `MeshInstance3D.get_aabb()`,选角此前仍以绑定姿势盒取景,故角色被推到窗口上方。`Metin2Model` 现首帧采样实际蒙皮包围盒用于剔除,并按需提供当前姿势包围盒;`CharSelectScreen` 收到 GPU 就绪通知后用该盒重新取景。`char_select_visual_test.gd` 在冻结 0.5 秒参考姿势后也走同一生产取景路径,并在报告中保留相机与网格包围盒数据。Apple M4 / Metal 的 HairIndex 1001 GPU 截图 [selection-1788844385-11872](../build/rendering/selection-1788844385-11872/selection.png) 人工审阅通过,脸部、发型、盔甲与完整人物均在画面内;对应 debug dylib SHA-256 为 `a268eef3f5cd1fc263652c4e0a9c3693f33f359b3706aa6850525c8e52108b39`。 +- GPU 路径仍保持环境变量启用的实验状态;1001 发型和带装备场景当前只覆盖男战士。完整职业的装备、动作、地图与性能测试仍按 R2/R6 的未完成项推进。 +- 追加 GPU 基体矩阵:使用 `--job=0..7 --main-part=0` 在同一 Apple M4 / Metal 环境生成八种种族的选择截图;全部装配断言通过,人工审阅的 [八种族联系图](../build/rendering/r2-matrix-gpu-contact-sheet.png) 中人物、脸部和默认发型均完整入框。此覆盖仅是无装备的待机选择画面,不替代每职业装备、动作或 40250 参考对比。 diff --git a/extension/src/metin2_model.cpp b/extension/src/metin2_model.cpp index c81f5c35..839ad870 100644 --- a/extension/src/metin2_model.cpp +++ b/extension/src/metin2_model.cpp @@ -68,6 +68,7 @@ void Metin2Model::_bind_methods() { ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture); ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload); ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info); + ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb); ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset); ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options); ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2); @@ -83,6 +84,11 @@ void Metin2Model::_bind_methods() { ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone); ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone); ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2); + // A renderer consumer may have framed the model before an animation switches + // it from the CPU mesh to GPU skinning. That switch updates the effective + // AABB, so expose a small notification instead of making each scene guess a + // number of frames to wait. + ADD_SIGNAL(MethodInfo("visual_bounds_changed")); ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msm"), "set_gr2_path", "get_gr2_path"); @@ -391,6 +397,8 @@ void Metin2Model::reload() { surf_mats.clear(); cpu_mesh.unref(); gpu_skin_active = false; + gpu_visual_bounds_ready = false; + have_visual_bounds = false; bones_img.unref(); bones_tex.unref(); current_skin.clear(); @@ -476,6 +484,8 @@ void Metin2Model::reload() { } } mi->set_custom_aabb(bounds.grow(0.01)); + visual_bounds = bounds; + have_visual_bounds = true; } materials = gr2::dump_materials(**file); @@ -1369,6 +1379,10 @@ void Metin2Model::cpu_skin(const std::vector &skin) { for (uint32_t t = 0; t < ic; ++t) { iw[t] = (int)m.indices[ib + t]; } + // Match build_mesh(): CPU animation must preserve the configured winding. + if (flip_winding) + for (uint32_t t = 0; t + 2 < ic; t += 3) + std::swap(iw[t + 1], iw[t + 2]); Array arrays; arrays.resize(Mesh::ARRAY_MAX); arrays[Mesh::ARRAY_VERTEX] = mesh_pos[rp.mesh]; @@ -1430,6 +1444,14 @@ void Metin2Model::cpu_skin(const std::vector &skin) { nn[2] /= ln; } pw[i] = Vector3(p[0], p[1], p[2]); + if (std::isfinite(p[0]) && std::isfinite(p[1]) && std::isfinite(p[2])) { + if (have_animated_bounds) { + animated_bounds = animated_bounds.expand(pw[i]); + } else { + animated_bounds = AABB(pw[i], Vector3()); + have_animated_bounds = true; + } + } nw[i] = Vector3(nn[0], nn[1], nn[2]); uw[i] = Vector2(v.uv0[0], v.uv0[1]); } @@ -1448,6 +1470,9 @@ void Metin2Model::cpu_skin(const std::vector &skin) { int32_t *iw = idx.ptrw(); for (uint32_t t = 0; t < ic; ++t) iw[t] = (int)m.indices[ib + t]; + if (flip_winding) + for (uint32_t t = 0; t + 2 < ic; t += 3) + std::swap(iw[t + 1], iw[t + 2]); Array a; a.resize(Mesh::ARRAY_MAX); a[Mesh::ARRAY_VERTEX] = hp[rp.mesh]; @@ -1476,9 +1501,55 @@ void Metin2Model::cpu_skin(const std::vector &skin) { mi->set_surface_override_material(body_surfaces + s, hair_mat); if (mi && have_animated_bounds) { mi->set_custom_aabb(animated_bounds.grow(0.01)); + visual_bounds = animated_bounds; + have_visual_bounds = true; } } +bool Metin2Model::_update_skinned_bounds(const std::vector &skin) { + const gr2::FileInfo *afi = active_fi(); + if (!afi) { + return false; + } + static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + AABB bounds; + bool have_bounds = false; + auto expand_mesh = [&](const gr2::FileInfo &fi, const std::vector *remap) { + for (const gr2::Mesh &m : fi.meshes) { + for (const gr2::Vertex &v : m.vertices) { + gr2::Mat4 blend{}; + float sum = 0.0f; + for (int k = 0; k < 4; ++k) sum += v.bone_weight[k]; + for (int k = 0; k < 4; ++k) { + const float w = sum > 0.0f ? v.bone_weight[k] / sum : (k == 0 ? 1.0f : 0.0f); + if (w == 0.0f) continue; + int bone = (v.bone_index[k] >= 0 && v.bone_index[k] < (int)m.bone_bindings.size()) + ? m.bone_bindings[v.bone_index[k]] : -1; + if (remap && bone >= 0 && bone < (int)remap->size()) bone = (*remap)[bone]; + const gr2::Mat4 &mat = bone >= 0 && bone < (int)skin.size() ? skin[bone] : I; + for (int e = 0; e < 16; ++e) blend[e] += w * mat[e]; + } + float p[3]; + xform_pt(blend, v.pos, p); + if (!std::isfinite(p[0]) || !std::isfinite(p[1]) || !std::isfinite(p[2])) continue; + const Vector3 point(p[0], p[1], p[2]); + bounds = have_bounds ? bounds.expand(point) : AABB(point, Vector3()); + have_bounds = true; + } + } + }; + expand_mesh(*afi, nullptr); + if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { + expand_mesh((**hair_file).file_info(), &hair_bone_remap); + } + if (have_bounds) { + visual_bounds = bounds; + have_visual_bounds = true; + if (mi) mi->set_custom_aabb(bounds.grow(0.01)); + } + return have_bounds; +} + void Metin2Model::enable_gpu_skin(bool on) { if (!on || gpu_skin_active || !mi || !file || !*file) { return; @@ -1504,9 +1575,8 @@ void Metin2Model::enable_gpu_skin(bool on) { if (skel) { skel->set_show_rest_only(true); } - // The mesh AABB is bind-pose; the shader moves verts, so widen the cull box - // (gr2-skin-space units, ~100/m) or arms-up poses get frustum/shadow-culled. - mi->set_custom_aabb(AABB(Vector3(-400, -400, -400), Vector3(800, 800, 800))); + // gpu_skin() supplies the first actual posed bounds once the animation + // sampler has produced its skin matrices. gpu_skin_active = true; _apply_materials(); // rebuild surface materials with the skinned shader + bones_tex UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones)); @@ -1517,6 +1587,13 @@ void Metin2Model::gpu_skin(const std::vector &skin) { return; } current_skin = skin; + // The shader owns deformation after this point. Sample the posed bounds + // once for culling and initial camera framing; doing the full vertex walk on + // every GPU frame would erase the CPU saving this path is intended to make. + if (!gpu_visual_bounds_ready && _update_skinned_bounds(skin)) { + gpu_visual_bounds_ready = true; + emit_signal("visual_bounds_changed"); + } const int nbones = bones_img->get_height(); PackedByteArray buf; buf.resize(nbones * 3 * 4 * 4); // rows * 3 texels * RGBA * float @@ -1538,6 +1615,19 @@ String Metin2Model::get_info() const { return last_info; } +AABB Metin2Model::get_visual_aabb() { + // Consumers such as CharSelect ask only while framing. Refresh on demand + // so their final camera fit reflects the current GPU pose without adding a + // per-frame CPU vertex walk to every world actor. + if (gpu_skin_active && !current_skin.empty()) { + _update_skinned_bounds(current_skin); + } + if (have_visual_bounds) { + return visual_bounds; + } + return mi && mi->get_mesh().is_valid() ? mi->get_mesh()->get_aabb() : AABB(); +} + String Metin2Model::probe_gr2(const String &path) const { gr2::LoadError err; auto f = mtgodot::gr2_from_file(path, &err); diff --git a/extension/src/metin2_model.h b/extension/src/metin2_model.h index dd5d077d..6a69c689 100644 --- a/extension/src/metin2_model.h +++ b/extension/src/metin2_model.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -126,6 +127,9 @@ public: // One-line summary (bones / meshes / verts / bounds). godot::String get_info() const; + // Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike + // MeshInstance3D::get_aabb(), this follows the GPU shader pose as well. + godot::AABB get_visual_aabb(); // Parent-space Y adjustment that places the bind-pose model's lowest point // on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres. double get_ground_offset() const { return ground_offset; } @@ -237,12 +241,16 @@ private: std::vector> surf_mats; // resolved once; re-assigned each cpu_skin frame godot::Ref cpu_mesh; // rebuilt each frame in cpu_skin mode bool gpu_skin_active = false; + bool gpu_visual_bounds_ready = false; + bool have_visual_bounds = false; + godot::AABB visual_bounds; std::vector current_skin; godot::Ref bones_img; // RGBAF 3 x bone_count godot::Ref bones_tex; void _clear_children(); void _apply_materials(); + bool _update_skinned_bounds(const std::vector &skin); godot::String _guess_texture_dir() const; // Resolve a "d:/ymir work/..." path referenced from `base` (.msm/.msa) to an // absolute file. Passes existing absolute paths through. diff --git a/project/char_select_visual_test.gd b/project/char_select_visual_test.gd index d9208eb9..4b26eda0 100644 --- a/project/char_select_visual_test.gd +++ b/project/char_select_visual_test.gd @@ -12,15 +12,29 @@ func _initialize() -> void: func _run() -> void: root.size = Vector2i(1472, 854) + var output := OS.get_environment("MT_RENDER_OUTPUT") + if output.is_empty(): + output = ProjectSettings.globalize_path("res://../build/rendering/selection-%d-%d" % [Time.get_unix_time_from_system(), OS.get_process_id()]) + DirAccess.make_dir_recursive_absolute(output) + var hair_part := 0 + var job := 0 + var main_part := 20009 + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--hair="): + hair_part = int(arg.trim_prefix("--hair=")) + elif arg.begins_with("--job="): + job = clampi(int(arg.trim_prefix("--job=")), 0, 7) + elif arg.begins_with("--main-part="): + main_part = max(0, int(arg.trim_prefix("--main-part="))) var client := Client.new() root.add_child(client) var screen := Screen.new() root.add_child(screen) screen.setup(client, AssetRoot.path(), [{ - "index": 0, "name": "[SA]Admin", "job": 0, "level": 105, + "index": 0, "name": "[SA]Admin", "job": job, "level": 105, "guild_name": "[GM-TEAM]", "play_minutes": 2180, "ht": 90, "iq": 15, "st": 90, "dx": 90, - "main_part": 20009, "hair_part": 0, + "main_part": main_part, "hair_part": hair_part, }]) for i in 40: await process_frame @@ -28,19 +42,80 @@ func _run() -> void: printerr("FAIL: selection model did not load") quit(1) return + # R2 diagnostics: compare the exact same armour with the configured hair + # removed. This distinguishes an absent/incorrect face surface from a hair + # depth or alpha problem; normal visual tests keep the real HairIndex 0. + if "--no-hair" in OS.get_cmdline_user_args(): + screen._pv.model.set("hair_gr2", "") + for _i in 2: + await process_frame print("Selected armor: ", screen._pv.model.get("gr2_path")) + screen._pv.anim.set_process(false) + screen._pv.anim.set("blend_time", 0.0) + screen._pv.anim.set("time", 0.5) + # Sample after freezing the reference pose: GPU deformation lives in the + # shader, so its current bounds are requested by the same production framing + # path rather than read back from the static ArrayMesh. + screen._frame_model() print("Hair skin: ", screen._pv.model.get("hair_skin")) var ua = preload("res://ui/ui_assets.gd") - ua.load_dds_image(String(screen._pv.model.get("hair_skin"))).save_png("/tmp/metin2-hair.png") + var hair_image: Image = ua.load_dds_image(String(screen._pv.model.get("hair_skin"))) + if hair_image != null: + hair_image.save_png(output.path_join("hair-texture.png")) + var mesh_bounds: Array = [] for mi in screen._pv.find_children("*", "MeshInstance3D", true, false): + var local_aabb: AABB = mi.get_aabb() + var world_aabb: AABB = mi.get_global_transform() * local_aabb + mesh_bounds.append({ + "node": String(mi.name), + "local_position": [local_aabb.position.x, local_aabb.position.y, local_aabb.position.z], + "local_size": [local_aabb.size.x, local_aabb.size.y, local_aabb.size.z], + "world_position": [world_aabb.position.x, world_aabb.position.y, world_aabb.position.z], + "world_size": [world_aabb.size.x, world_aabb.size.y, world_aabb.size.z], + }) for surface in mi.mesh.get_surface_count(): print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface)) - if not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"): + var mat: Material = mi.get_active_material(surface) + if mat is ShaderMaterial: + print("MATERIAL mode=", mat.get_shader_parameter("mode"), " alpha_write=", mat.shader.code.contains("ALPHA =")) + if "--solid-body" in OS.get_cmdline_user_args(): + var solid := StandardMaterial3D.new() + solid.albedo_texture = mat.get_shader_parameter("albedo_tex") + solid.cull_mode = BaseMaterial3D.CULL_DISABLED + mi.set_surface_override_material(surface, solid) + if main_part == 20009 and not String(screen._pv.model.get("gr2_path")).to_lower().contains("warrior_5_1"): printerr("FAIL: server armor was not applied") quit(1) return + if hair_part == 1001 and not String(screen._pv.model.get("hair_gr2")).to_lower().contains("hair_2_1"): + printerr("FAIL: standard HairIndex 1001 did not replace the default hair model") + quit(1) + return if DisplayServer.get_name() != "headless": await RenderingServer.frame_post_draw - root.get_texture().get_image().save_png("/tmp/metin2-char-select.png") + if root.get_texture().get_image().save_png(output.path_join("selection.png")) != OK: + quit(1) + return + var hashes := {} + for path in ["res://ui/char_select_screen.gd", "res://ui/player_view.gd", "res://ui/equip_model.gd", "res://ui/race_spec.gd", "res://char_select_visual_test.gd"]: + hashes[path] = FileAccess.get_sha256(path) + for key in ["gr2_path", "hair_gr2", "hair_skin"]: + var path := String(screen._pv.model.get(key)) + if not path.is_empty(): + hashes[path] = FileAccess.get_sha256(path) + var library := "res://bin/libmtgodot.macos.template_debug.dylib" + hashes[library] = FileAccess.get_sha256(library) + var report := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) + report.store_string(JSON.stringify({"sha256": hashes, "engine": Engine.get_version_info(), + "job": job, "main_part": main_part, "hair_part": hair_part, "sample_time": 0.5, "args": OS.get_cmdline_user_args(), + "gpu_skin": OS.get_environment("MTGODOT_GPUSKIN"), + "camera_position": [_cam_pos(screen).x, _cam_pos(screen).y, _cam_pos(screen).z], + "mesh_bounds": mesh_bounds, + "scope": "assembly assertions; screenshot requires visual review, not 40250 pixel parity"}, "\t")) + report.close() + print("SELECTION_OUTPUT: ", output) print("PASS: selection model, server armor and UI assembly") quit() + +func _cam_pos(screen: Node) -> Vector3: + return screen._cam.position if screen._cam else Vector3.ZERO diff --git a/project/character_winding_test.gd b/project/character_winding_test.gd new file mode 100644 index 00000000..19bedc85 --- /dev/null +++ b/project/character_winding_test.gd @@ -0,0 +1,63 @@ +# Detect CPU animation dropping winding (including accidentally loading an old dylib). +extends SceneTree + +var failures := 0 + +func _initialize() -> void: + call_deferred("run") + +func indices(model: Node) -> Array: + var meshes := model.find_children("*", "MeshInstance3D", true, false) + var out := [] + for mi in meshes: + for s in mi.mesh.get_surface_count(): + out.append(mi.mesh.surface_get_arrays(s)[Mesh.ARRAY_INDEX]) + return out + +func check_reversed(a: Array, b: Array, stage: String) -> void: + if a.is_empty() or a.size() != b.size(): + failures += 1 + printerr("FAIL: missing surfaces at ", stage) + return + for s in a.size(): + if a[s].size() != b[s].size(): + failures += 1 + continue + for t in range(0, a[s].size(), 3): + if a[s][t] != b[s][t] or a[s][t + 1] != b[s][t + 2] or a[s][t + 2] != b[s][t + 1]: + failures += 1 + printerr("FAIL: winding lost at ", stage, " surface ", s) + break + +func run() -> void: + var base := AssetRoot.path().path_join("PC/ymir work/pc/warrior") + var models := [] + var animations := [] + for flip in [false, true]: + var model: Node = ClassDB.instantiate("Metin2Model") + model.set("gr2_path", base.path_join("warrior_novice.gr2")) + model.set("hair_gr2", base.path_join("hair/hair_1_1.gr2")) + model.set("flip_winding", flip) + model.set("lod_enabled", false) + root.add_child(model) + models.append(model) + check_reversed(indices(models[0]), indices(models[1]), "bind") + for model in models: + var anim: Node = ClassDB.instantiate("Metin2AnimPlayer") + anim.set("model_path", model.get_path()) + anim.set("anim_path", base.path_join("general/wait.msa")) + anim.set("playing", false) + root.add_child(anim) + animations.append(anim) + for time in [0.0, 0.5, 1.0]: + for anim in animations: + anim.set("time", time) + check_reversed(indices(models[0]), indices(models[1]), "animated %s" % time) + if indices(models[0]).size() != 4: + failures += 1 + printerr("FAIL: body and hair surfaces must both be exercised") + print("character_winding_test: failures=", failures) + for node in animations + models: + node.queue_free() + await process_frame + quit(1 if failures else 0) diff --git a/project/hud.gd b/project/hud.gd index ef0bce7d..f8e16a4c 100644 --- a/project/hud.gd +++ b/project/hud.gd @@ -46,7 +46,9 @@ func setup(world: Node, player: Node3D) -> void: _map_h_m = float(rep["map_size_y"]) * 256.0 _build_minimap(int(rep["map_size_x"]), int(rep["map_size_y"])) _build_status() - _build_hotbar() + # 技能快捷栏由 ui/quickbar.gd 管理:它恢复服务端的 36 个槽位、显示 + # 技能图标并处理冷却。这里以前画的静态空槽会和它重叠,让实际技能看起来 + # 没有加载,因此 HUD 不再创建第二条快捷栏。 _build_inventory() _build_dungeon_compass() @@ -368,22 +370,6 @@ func _build_target_panel() -> void: _target_fill.size = Vector2(180, 12) slot.add_child(_target_fill) -func _build_hotbar() -> void: - var row := HBoxContainer.new() - row.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) - row.position = Vector2(-8 * 23, -56) - row.add_theme_constant_override("separation", 4) - add_child(row) - for i in 8: - var slot := _nine("thinboard", 16, 16) - slot.custom_minimum_size = Vector2(42, 42) - var n := Label.new() - n.text = str(i + 1) - n.position = Vector2(4, 2) - n.add_theme_font_size_override("font_size", 11) - slot.add_child(n) - row.add_child(slot) - func _build_inventory() -> void: _inv = _nine("board", 32, 128) _inv.set_anchors_preset(Control.PRESET_CENTER_RIGHT) diff --git a/project/race_spec_test.gd b/project/race_spec_test.gd index cf9f0408..25ce7c3f 100644 --- a/project/race_spec_test.gd +++ b/project/race_spec_test.gd @@ -19,10 +19,14 @@ class FakeClient extends Node: class StubModel extends Node3D: var gr2_path := "" var weapon_gr2 := "" + var hair_gr2 := "" + var hair_skin := "" var surf := {} func _set(p, v): if p == "gr2_path": gr2_path = v; return true if p == "weapon_gr2": weapon_gr2 = v; return true + if p == "hair_gr2": hair_gr2 = v; return true + if p == "hair_skin": hair_skin = v; return true return false func set_surface_texture(s, path): surf[s] = path @@ -33,7 +37,10 @@ func _ck(c: bool, m: String) -> void: printerr("FAIL: " + m) func _init() -> void: - _run() + call_deferred("_run_and_finish") + +func _run_and_finish() -> void: + await _run() if _fail == 0: print("PASS: race_spec_test (msm parse + body swap)") quit(0) @@ -60,6 +67,7 @@ func _run() -> void: var rs := RaceSpec.new() _ck(rs.load_file(spec_path), "race spec parsed: %s" % spec_path) + _ck(not rs.base_model.begins_with(String.chr(1)), "quoted tokens do not retain lexer sentinel") _ck(rs.shapes.size() >= 5, "shapes parsed (%d)" % rs.shapes.size()) _ck(rs.base_model.to_lower().contains("warrior_novice"), "base_model = warrior_novice (%s)" % rs.base_model) var s0: Dictionary = rs.shape(0) @@ -72,6 +80,15 @@ func _run() -> void: if not h3.is_empty(): _ck(h3.get("model", "").contains("hair"), "hair 3 model has 'hair' (%s)" % h3.get("model")) _ck(h3.get("target_skin", "").contains("red"), "hair 3 target_skin = red variant (%s)" % h3.get("target_skin")) + # 用户出错的角色用 HairIndex 0;它不是推测的默认路径,必须由 MSM + # 明确解析到 hair_1_1,且保持可换的 TargetSkin。 + var h0: Dictionary = rs.hair(0) + _ck(h0.get("path", "") == rs.hair_path and rs.hair_path != "", + "empty SpecialPath inherits HairData PathName") + _ck(h0.get("model", "").to_lower() == "hair/hair_1_1.gr2", + "hair 0 model = hair/hair_1_1.gr2 (%s)" % h0.get("model", "")) + _ck(h0.get("target_skin", "").to_lower().contains("warrior_hair_01"), + "hair 0 target skin is warrior_hair_01 (%s)" % h0.get("target_skin", "")) # --- equip_model 用 race_spec 换身体 --- var fc := FakeClient.new() @@ -95,3 +112,29 @@ func _run() -> void: if not rs.shape(1).is_empty(): _ck(model.gr2_path.to_lower().contains("novice"), "shape 1 -> novice body") _ck(model.surf.has(0), "shape 1 recolour -> set_surface_texture(0, ...)") + + # 标准发型有两段编号:0..5 是 hair_1 的换色,1001..1012 才是 + # hair_2..4 的不同几何。它们不是道具 vnum,远端/选角不能把它们 + # 丢给 item_list 后静默回落到 PlayerView 的 hair_1_1 默认模型。 + var remote_model := StubModel.new() + get_root().add_child(remote_model) + var remote: Node = EquipModel.new() + get_root().add_child(remote) + remote.setup_remote(null, func() -> Node: return remote_model, assets, 0, + [0, 0, 0, 1001]) + await process_frame + var h1001: Dictionary = rs.hair(1001) + _ck(not h1001.is_empty(), "hair 1001 exists in warrior.msm") + var remote_h1001: Dictionary = remote._spec_hair(1001) + _ck(not remote_h1001.is_empty(), "remote actor resolves hair 1001 (%s)" % remote_h1001) + _ck(remote._hair_part() == 1001, "remote actor preserves hair part 1001 (%s)" % str(remote._remote_parts)) + var remote_hair_model: String = remote._resolve_spec_asset(remote_h1001, String(remote_h1001.get("model", ""))) + _ck(remote_hair_model != "", "hair 1001 source model path resolves (%s)" % remote_hair_model) + # setup_remote refreshes synchronously; this direct retry distinguishes a + # setup ordering bug from asset/spec resolution failure. + remote._apply_hair_from_parts(remote_model, true) + if not h1001.is_empty(): + _ck(remote_model.hair_gr2.to_lower().ends_with(String(h1001.get("model", "")).to_lower()), + "standard hair 1001 resolves MSM geometry (%s)" % remote_model.hair_gr2) + _ck(remote_model.hair_skin.to_lower().ends_with(String(h1001.get("target_skin", "")).to_lower()), + "standard hair 1001 resolves MSM skin (%s)" % remote_model.hair_skin) diff --git a/project/skill_test.gd b/project/skill_test.gd index faaa42b9..ab631425 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -319,7 +319,11 @@ func _run() -> void: fc.quickslots = [{"pos": 2, "type": 2, "ref": 16}, {"pos": 3, "type": 1, "ref": 9}, {"pos": 4, "type": 3, "ref": 11}] qb2.setup(fc, st, ui, func() -> Node: return pl) # setup 里自动 restore_from_server + _ck(qb2._root.size == Vector2(400, 56) and qb2._root.z_index > 0, + "quickbar has a real bottom-anchor extent and stays above legacy HUD") _ck(qb2._state[2].kind == "skill" and int(qb2._state[2].id) == 16, "quickslot 恢复:格2=技能16") + _ck(qb2._slots[2].icon.texture != null and qb2._slots[2].icon.visible, + "server-restored skill slot renders its icon") _ck(qb2._state[3].kind == "item" and int(qb2._state[3].id) == 9, "quickslot 恢复:格3=道具9") _ck(qb2._state[4].kind == "emote" and int(qb2._state[4].id) == 11, "quickslot 恢复:格4=表情11") qb2.activate(4) diff --git a/project/ui/char_select_screen.gd b/project/ui/char_select_screen.gd index 6dcbf618..6a0bb9ce 100644 --- a/project/ui/char_select_screen.gd +++ b/project/ui/char_select_screen.gd @@ -238,7 +238,7 @@ func _build_stage() -> void: root.add_child(we) _pivot = Node3D.new() - _pivot.rotation.y = -PI * 0.5 + _pivot.rotation.y = PI * 0.5 root.add_child(_pivot) _cam = Camera3D.new() @@ -254,10 +254,20 @@ func _frame_model() -> void: return var aabb := AABB() var first := true + # GPU skinning happens in the vertex shader, so MeshInstance3D's mesh AABB + # remains in bind pose. Prefer the extension's matching deformed bounds for + # the main body; accessories continue through their own mesh bounds below. + if _pv.model and _pv.model.has_method("get_visual_aabb"): + var posed: AABB = _pv.model.call("get_visual_aabb") + if posed.size.length_squared() > 0.0: + aabb = (_pv.model as Node3D).global_transform * posed + first = false for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false): var m := mi as MeshInstance3D if m.mesh == null: continue + if m.get_parent() == _pv.model and m.name == "MeshInstance3D": + continue # already covered by Metin2Model.get_visual_aabb() var wb: AABB = m.get_global_transform() * m.get_aabb() aabb = wb if first else aabb.merge(wb) first = false @@ -274,6 +284,13 @@ func _frame_model() -> void: _cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0))) _cam.look_at(target, Vector3.UP) +# Metin2AnimPlayer enables the optional GPU skin path on its first animation +# sample. Its material/mesh swap changes the MeshInstance AABB after the +# initial CPU framing pass, so frame again after the extension confirms it. +func _on_model_visual_bounds_changed(pv: Node) -> void: + if pv == _pv and is_instance_valid(pv): + call_deferred("_frame_model") + func _rebuild_model() -> void: if _pivot == null: return @@ -290,6 +307,8 @@ func _rebuild_model() -> void: _pivot.add_child(pv) if pv.build(_assets, job, pump): _pv = pv + if pv.model and pv.model.has_signal("visual_bounds_changed"): + pv.model.visual_bounds_changed.connect(_on_model_visual_bounds_changed.bind(pv)) if _proto == null and ClassDB.class_exists("Metin2Proto"): _proto = ClassDB.instantiate("Metin2Proto") add_child(_proto) diff --git a/project/ui/equip_model.gd b/project/ui/equip_model.gd index 273cdb39..01d9def5 100644 --- a/project/ui/equip_model.gd +++ b/project/ui/equip_model.gd @@ -346,11 +346,19 @@ func _resolve_spec_asset(e: Dictionary, rel: String) -> String: var byp := _resolve_asset(String(e.get("path", "")) + rel) if byp != "": return byp - # 按 basename 在 pc// 和 pc2// 下找 + # 规格覆盖层(例如 root/msm/warrior_m.msm)可能只给 Model 名而没有 + # PathName。按 basename 回退时除职业根目录外,还必须查 hair/;否则 + # HairIndex 1001..1012 能解析却加载失败,并静默保留 PlayerView 的 + # hair_1_1 默认模型,造成选角发型遮脸。 var bn := rel.get_file() if race >= 0: var cls: String = CLASS_OF[race & 3] - for sub in ["PC/ymir work/pc/%s/%s" % [cls, bn], "pc2/ymir work/pc2/%s/%s" % [cls, bn]]: + for sub in [ + "PC/ymir work/pc/%s/%s" % [cls, bn], + "PC/ymir work/pc/%s/hair/%s" % [cls, bn], + "pc2/ymir work/pc2/%s/%s" % [cls, bn], + "pc2/ymir work/pc2/%s/hair/%s" % [cls, bn], + ]: var q := _resolve_asset(sub) if q != "": return q diff --git a/project/ui/player_view.gd b/project/ui/player_view.gd index 3cfb77e1..c6c5cc46 100644 --- a/project/ui/player_view.gd +++ b/project/ui/player_view.gd @@ -43,6 +43,8 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool: model = ClassDB.instantiate("Metin2Model") model.name = "Metin2Model" + # PC GR2 surfaces need the same outward winding in bind, CPU and GPU poses. + model.set("flip_winding", true) model.set("texture_dir", base) if pump.is_valid(): pump.call() var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)]) diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd index 1e79ce7e..6716aa6c 100644 --- a/project/ui/quickbar.gd +++ b/project/ui/quickbar.gd @@ -339,7 +339,12 @@ func _swap(local_a: int, local_b: int) -> void: func _build(parent: Node) -> void: _root = Control.new() _root.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) - _root.position = Vector2(-SLOTS_PER_PAGE * 23, -54) + # Give the root a real extent. A zero-sized Control happened to work with + # some viewport sizes, but makes anchoring and hit testing fragile on Retina + # windows. The old HUD's empty row then made this look like missing skills. + _root.size = Vector2(400, 56) + _root.position = Vector2(-200, -62) + _root.z_index = 10 parent.add_child(_root) var prev := Button.new() prev.text = "‹" diff --git a/project/ui/race_spec.gd b/project/ui/race_spec.gd index 0993a6b6..80313060 100644 --- a/project/ui/race_spec.gd +++ b/project/ui/race_spec.gd @@ -57,7 +57,9 @@ func shape(idx: int) -> Dictionary: if e.is_empty(): return {} e = e.duplicate() - e["path"] = String(e.get("special_path", shape_path)) + e["path"] = String(e.get("special_path", "")) + if e["path"] == "": + e["path"] = shape_path e["spec_dir"] = _dir return e @@ -66,7 +68,9 @@ func hair(idx: int) -> Dictionary: if e.is_empty(): return {} e = e.duplicate() - e["path"] = String(e.get("special_path", hair_path)) + e["path"] = String(e.get("special_path", "")) + if e["path"] == "": + e["path"] = hair_path e["spec_dir"] = _dir return e @@ -150,6 +154,8 @@ func _parse_block(toks: Array, i: Array, top := false) -> Dictionary: return d func _tokval(t): + if t is String and t.begins_with(String.chr(1)): + return t.substr(1) if t is Dictionary and t.has("str"): return t["str"] return str(t) diff --git a/script/rendering_test.sh b/script/rendering_test.sh new file mode 100644 index 00000000..acfb39be --- /dev/null +++ b/script/rendering_test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Local macOS visual tests must load the library that was just built. +set -euo pipefail +cd "$(dirname "$0")/.." +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --target mtgodot -j8 +cp project/bin/libmtgodot.macos.template_release.dylib project/bin/libmtgodot.macos.template_debug.dylib +codesign --force --sign - project/bin/libmtgodot.macos.template_debug.dylib +shasum -a 256 project/bin/libmtgodot.macos.template_debug.dylib +"${GODOT:-godot}" --headless --path project --script character_winding_test.gd +"${GODOT:-godot}" --path project --script char_select_visual_test.gd -- "$@" diff --git a/test/rendering/README.md b/test/rendering/README.md index a0e9b6db..2903ca05 100644 --- a/test/rendering/README.md +++ b/test/rendering/README.md @@ -1,9 +1,11 @@ # 渲染回归入口 -第一批用例针对 R1 场景装配,2026-09-08 开始执行。未宣称 40250 像素一致、发型正确或最终包通过。 +第一批用例针对 R1 场景装配与 R2 角色选择,2026-09-08 开始执行。未宣称 40250 像素一致或最终包通过。 在仓库根目录运行: +本地 macOS 修改 C++ 后先执行 `bash script/rendering_test.sh`:它构建并同步 Godot 实际加载的 debug 路径,重签名后运行 CPU 绕序回归与选角截图。仅构建 Release 不会更新本地 Godot 的 debug 扩展。可传 `--hair=1001`;截图与哈希报告在 `build/rendering/selection--/`。`--job=0..7` 与 `--main-part=0` 可复现无装备的八种族选择矩阵;`MTGODOT_GPUSKIN=1` 验证 GPU 路径。`--no-hair` 和 `--solid-body` 仅用于隔离诊断,其画面不能当作普通角色验收。 + ```bash godot --headless --path project --script main_character_sync_test.gd godot --headless --path project --script gamescene_test.gd