Fix cull mode + isolate head-collapse to a libgr2 curve bug

Rendering fixes (real):
- m2_material shaders: cull_disabled -> cull_back (inside-of-mesh faces were
  z-winning and darkening/hiding the head under the ShaderMaterial path).
- drop `* COLOR` from the shader (meshes carry no ARRAY_COLOR).

Animation: convert gr2 global poses to Godot *local* bone poses and set via
set_bone_pose (was set_bone_global_pose). MTGODOT_VERIFY=1 confirms
get_bone_global_pose(i) == conv(world[i]) for all 75 bones (<=2.6e-5).

Head/upper-armor collapse on dance_1 is NOT a Godot-route bug:
- reproduced identically in xrender-poc's bgfx demo at the same anim/t
- reproduced by the new MTGODOT_CPUSKIN=1 reference path (same LBS math as
  xrender's validated skin_mesh)
- `general/wait` / `run` render correctly in all three
=> upstream libgr2 Curve::eval (degree-2 quaternion B-spline) vs Granny.
Demo default animation switched to general/wait; documented in MIDREVIEW §4
and README. Debug scaffolding gated behind MTGODOT_VERIFY / MTGODOT_CPUSKIN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaHYEY9rwLWt21PULiYjeJ
This commit is contained in:
Claude
2026-08-29 10:47:01 +09:00
parent 30d6a81476
commit f4917a2b3b
15 changed files with 234 additions and 15 deletions
+4 -4
View File
@@ -12,7 +12,7 @@ namespace {
// blend_mix shader: covers opaque (mode 0, alpha-scissor) and alpha (mode 1).
const char *SRC_MIX = R"(shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_disabled, diffuse_lambert, specular_disabled;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_lambert, specular_disabled;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
@@ -22,7 +22,7 @@ uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout)
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
c *= modulate * COLOR;
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
if (mode == 2 && c.a < alpha_scissor) {
discard;
}
@@ -33,7 +33,7 @@ void fragment() {
// blend_add shader: additive glow (effects, enchant overlays).
const char *SRC_ADD = R"(shader_type spatial;
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_disabled, unshaded;
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_back, unshaded;
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
uniform bool use_texture = true;
@@ -41,7 +41,7 @@ uniform vec4 modulate : source_color = vec4(1.0);
void fragment() {
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
c *= modulate * COLOR;
c *= modulate; // no vertex color: Metin2 PC meshes carry none (ARRAY_COLOR absent)
ALBEDO = c.rgb * c.a;
ALPHA = 1.0;
}
+73 -2
View File
@@ -10,6 +10,18 @@
#include <cmath>
#include <string>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/skin.hpp>
static double _tdiff(const godot::Transform3D &a, const godot::Transform3D &b) {
double d = 0.0;
for (int r = 0; r < 3; ++r)
for (int c = 0; c < 3; ++c)
d = godot::Math::max(d, (double)godot::Math::abs(a.basis[r][c] - b.basis[r][c]));
return godot::Math::max(d, (double)(a.origin - b.origin).length());
}
using namespace godot;
namespace mtgodot {
@@ -95,6 +107,31 @@ void Metin2AnimPlayer::reload() {
anim_file = std::move(f);
const gr2::Animation &an = anim_file->file_info().animations[0];
duration = an.duration;
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
if (Metin2Model *model = resolve_model()) {
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
int matched = 0;
godot::String unmatched;
for (const auto &tr : an.tracks) {
bool hit = false;
for (const auto &b : sk->bones) {
if (b.name == tr.bone_name) {
hit = true;
break;
}
}
if (hit) {
++matched;
} else if (unmatched.length() < 300) {
unmatched += String(tr.bone_name.c_str()) + " ";
}
}
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] track->bone match: %d/%d (skel bones=%d) unmatched: %s",
matched, (int)an.tracks.size(), (int)sk->bones.size(), unmatched));
}
}
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
String(an.name.c_str()), duration, (int)an.tracks.size(),
(int)anim_file->file_info().animations.size());
@@ -127,9 +164,43 @@ void Metin2AnimPlayer::apply_pose(double t) {
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
const int n = (int)std::min<size_t>(world_buf.size(), (size_t)skel->get_bone_count());
if (godot::OS::get_singleton()->get_environment("MTGODOT_CPUSKIN") == "1") {
model->enable_cpu_skin(true);
model->cpu_skin(skin_buf);
return;
}
// Convert gr2 global poses -> Godot *local* bone poses and set those.
// (Setting global poses directly hits a parent-ordering hazard in
// Skeleton3D::set_bone_global_pose that detaches head/upper-body meshes
// once bones leave the bind pose.)
const int n = (int)std::min<size_t>(world_buf.size(),
std::min<size_t>(sk->bones.size(), (size_t)skel->get_bone_count()));
for (int i = 0; i < n; ++i) {
skel->set_bone_global_pose(i, gr2_to_godot(world_buf[i]));
const godot::Transform3D t_i = gr2_to_godot(world_buf[i]);
const int parent = sk->bones[i].parent;
if (parent < 0 || parent >= n) {
skel->set_bone_pose(i, t_i);
} else {
skel->set_bone_pose(i, gr2_to_godot(world_buf[parent]).affine_inverse() * t_i);
}
}
if (!verified_ && OS::get_singleton()->get_environment("MTGODOT_VERIFY") == "1") {
verified_ = true;
skel->force_update_all_bone_transforms();
double d_pose = 0.0;
int w_pose = -1;
for (int i = 0; i < n; ++i) {
double dp = _tdiff(skel->get_bone_global_pose(i), gr2_to_godot(world_buf[i]));
if (dp > d_pose) {
d_pose = dp;
w_pose = i;
}
}
UtilityFunctions::print(vformat(
"[Metin2AnimPlayer] verify: max |global_pose - conv(world)| = %.6f @ bone %d '%s'",
d_pose, w_pose, w_pose >= 0 ? String(sk->bones[w_pose].name.c_str()) : String()));
}
}
+1
View File
@@ -63,6 +63,7 @@ private:
double time_scale = 1.0;
double time = 0.0;
double duration = 0.0;
bool verified_ = false;
godot::String last_info;
std::optional<gr2::File> anim_file;
+124 -1
View File
@@ -11,6 +11,7 @@
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/classes/skin.hpp>
@@ -184,7 +185,11 @@ void Metin2Model::reload() {
mi->set_owner(get_owner() ? get_owner() : this);
if (skel && sk) {
mi->set_skeleton_path(mi->get_path_to(skel));
mi->set_skin(build_skin(*sk));
if (OS::get_singleton()->get_environment("MTGODOT_AUTOSKIN") == "1") {
// let Godot derive the skin from skeleton rest poses
} else {
mi->set_skin(build_skin(*sk));
}
}
mi->set_custom_aabb(bounds.grow(0.01));
}
@@ -435,6 +440,124 @@ const gr2::Skeleton *Metin2Model::gr2_skeleton() const {
return fi.skeletons.empty() ? nullptr : &fi.skeletons[0];
}
const gr2::FileInfo *Metin2Model::gr2_fileinfo() const {
return (file && *file) ? &(*file)->file_info() : nullptr;
}
namespace {
// row-major 4x4 (translation elem 12..14), row-vector: out = [v 1] * M
inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o) {
o[0] = v[0] * M[0] + v[1] * M[4] + v[2] * M[8] + M[12];
o[1] = v[0] * M[1] + v[1] * M[5] + v[2] * M[9] + M[13];
o[2] = v[0] * M[2] + v[1] * M[6] + v[2] * M[10] + M[14];
}
inline void xform_dir(const gr2::Mat4 &M, const float *v, float *o) {
o[0] = v[0] * M[0] + v[1] * M[4] + v[2] * M[8];
o[1] = v[0] * M[1] + v[1] * M[5] + v[2] * M[9];
o[2] = v[0] * M[2] + v[1] * M[6] + v[2] * M[10];
}
} // namespace
void Metin2Model::enable_cpu_skin(bool on) {
if (!mi || !file || !*file) {
return;
}
if (on && cpu_mesh.is_null()) {
AABB b;
cpu_mesh = build_mesh((*file)->file_info(), flip_winding, b);
cpu_surf_mesh.clear();
const gr2::FileInfo &fi = (*file)->file_info();
for (int i = 0; i < (int)fi.meshes.size(); ++i) {
if (!fi.meshes[i].vertices.empty() && !fi.meshes[i].indices.empty()) {
cpu_surf_mesh.push_back(i);
}
}
mi->set_skeleton_path(NodePath());
mi->set_skin(Ref<Skin>());
mi->set_mesh(cpu_mesh);
if (skel) {
skel->set_show_rest_only(true);
}
UtilityFunctions::print("[Metin2Model] CPU-skin mode ON");
}
}
void Metin2Model::cpu_skin(const std::vector<gr2::Mat4> &skin) {
if (cpu_mesh.is_null() || !file || !*file) {
return;
}
const gr2::FileInfo &fi = (*file)->file_info();
static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
cpu_mesh->clear_surfaces();
for (int s = 0; s < (int)cpu_surf_mesh.size(); ++s) {
const gr2::Mesh &m = fi.meshes[cpu_surf_mesh[s]];
const int nv = (int)m.vertices.size();
PackedVector3Array pos;
PackedVector3Array nrm;
PackedVector2Array uv;
PackedInt32Array idx;
pos.resize(nv);
nrm.resize(nv);
uv.resize(nv);
idx.resize((int)m.indices.size());
Vector3 *pw = pos.ptrw();
Vector3 *nw = nrm.ptrw();
Vector2 *uw = uv.ptrw();
int32_t *iw = idx.ptrw();
auto bone_of = [&](int slot) -> const gr2::Mat4 & {
if (slot < 0 || slot >= (int)m.bone_bindings.size()) {
return I;
}
int b = m.bone_bindings[slot];
return (b < 0 || b >= (int)skin.size()) ? I : skin[b];
};
for (int i = 0; i < nv; ++i) {
const gr2::Vertex &v = m.vertices[i];
float wq[4];
float sw = 0;
for (int k = 0; k < 4; ++k) {
sw += v.bone_weight[k];
}
for (int k = 0; k < 4; ++k) {
wq[k] = (sw > 0) ? v.bone_weight[k] / sw : (k == 0 ? 1.f : 0.f);
}
gr2::Mat4 M{};
for (int k = 0; k < 4; ++k) {
const gr2::Mat4 &bm = bone_of(v.bone_index[k]);
for (int e = 0; e < 16; ++e) {
M[e] += wq[k] * bm[e];
}
}
gr2::Mat4 M3 = M;
M3[12] = M3[13] = M3[14] = 0;
float p[3], nn[3];
xform_pt(M, v.pos, p);
xform_dir(M3, v.normal, nn);
float ln = std::sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]);
if (ln > 1e-8f) {
nn[0] /= ln;
nn[1] /= ln;
nn[2] /= ln;
}
pw[i] = Vector3(p[0], p[1], p[2]);
nw[i] = Vector3(nn[0], nn[1], nn[2]);
uw[i] = Vector2(v.uv0[0], v.uv0[1]);
}
for (size_t t = 0; t < m.indices.size(); ++t) {
iw[t] = (int)m.indices[t];
}
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
arrays[Mesh::ARRAY_VERTEX] = pos;
arrays[Mesh::ARRAY_NORMAL] = nrm;
arrays[Mesh::ARRAY_TEX_UV] = uv;
arrays[Mesh::ARRAY_INDEX] = idx;
cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
}
_apply_materials();
}
String Metin2Model::get_info() const {
return last_info;
}
+12 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/ref.hpp>
@@ -16,7 +17,6 @@
namespace godot {
class Skeleton3D;
class MeshInstance3D;
class ImageTexture;
} // namespace godot
namespace mtgodot {
@@ -70,6 +70,14 @@ public:
// --- C++ accessors for Metin2AnimPlayer (same extension) ---
const gr2::Skeleton *gr2_skeleton() const;
godot::Skeleton3D *skeleton_node() const { return skel; }
const gr2::FileInfo *gr2_fileinfo() const;
// CPU linear-blend skinning: rewrite ArrayMesh vertex regions from the
// per-bone deformer matrices (gr2::sample_pose's `skin` output). Debug/
// reference path; bypasses Godot's Skeleton3D skinning entirely.
void cpu_skin(const std::vector<gr2::Mat4> &skin);
bool has_cpu_skin_mesh() const { return cpu_mesh.is_valid(); }
void enable_cpu_skin(bool on);
// libgr2 sanity probe (kept from M0').
godot::String probe_gr2(const godot::String &path) const;
@@ -95,6 +103,9 @@ private:
godot::String last_info;
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
godot::Ref<godot::ArrayMesh> cpu_mesh; // rebuilt each frame in cpu_skin mode
std::vector<int> cpu_surf_mesh; // surface -> gr2 mesh index
void _clear_children();
void _apply_materials();
godot::String _guess_texture_dir() const;