M1+M2: gr2 -> Skeleton3D/ArrayMesh/Skin + libgr2-driven animation

- gr2_bridge: gr2 Mat4 (row-major) -> Godot Transform3D (4x4 transpose);
  build_skeleton (rest from local_transform, initial_placement folded into
  roots), build_skin (bind i -> bone i, pose = inverse_world), build_mesh
  (surface per gr2 mesh, ARRAY_BONES = skeleton idx via bone_bindings,
  rigid meshes bound to bone_bindings[0]).
- dxt: DDS DXT1/3/5 decoder ported from xrender-poc.
- Metin2Model: load_gr2 -> Skeleton3D + MeshInstance3D + StandardMaterial3D
  (runtime DDS albedo, face/body split by surface name). Z-up cm -> Y-up m
  conversion on the node transform (unit_scale, flip_z, flip_winding props).
- Metin2AnimPlayer: _process samples a (separate) anim .gr2 with
  gr2::sample_pose(model.skeleton, anim, t) -> set_bone_global_pose per bone;
  Godot GPU-skins via skin_matrix = global_pose(i) * bind_pose(i) == gr2
  deformer matrix. selfcheck() NaN-scans all anims.

Verified: warrior_cheongrin renders (75 bones, 5 surfaces, 3324 verts,
textured, upright). dance01 (dur 28.3s, 74 tracks) plays, 0 NaN over 25
samples, distinct poses at t=2/9/16/23. Screenshots in test/golden/.

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 09:23:29 +09:00
parent 67109990ec
commit 27e39993f7
121 changed files with 1058 additions and 57 deletions
+176
View File
@@ -0,0 +1,176 @@
#include "metin2_anim.h"
#include "gr2_bridge.h"
#include "metin2_model.h"
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <cmath>
#include <string>
using namespace godot;
namespace mtgodot {
Metin2AnimPlayer::Metin2AnimPlayer() {}
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
ClassDB::bind_method(D_METHOD("get_model_path"), &Metin2AnimPlayer::get_model_path);
ClassDB::bind_method(D_METHOD("set_playing", "v"), &Metin2AnimPlayer::set_playing);
ClassDB::bind_method(D_METHOD("get_playing"), &Metin2AnimPlayer::get_playing);
ClassDB::bind_method(D_METHOD("set_time_scale", "s"), &Metin2AnimPlayer::set_time_scale);
ClassDB::bind_method(D_METHOD("get_time_scale"), &Metin2AnimPlayer::get_time_scale);
ClassDB::bind_method(D_METHOD("set_time", "t"), &Metin2AnimPlayer::set_time);
ClassDB::bind_method(D_METHOD("get_time"), &Metin2AnimPlayer::get_time);
ClassDB::bind_method(D_METHOD("get_duration"), &Metin2AnimPlayer::get_duration);
ClassDB::bind_method(D_METHOD("reload"), &Metin2AnimPlayer::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2AnimPlayer::get_info);
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
"set_anim_path", "get_anim_path");
ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "model_path", PROPERTY_HINT_NODE_PATH_VALID_TYPES, "Node3D"),
"set_model_path", "get_model_path");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "playing"), "set_playing", "get_playing");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "time_scale", PROPERTY_HINT_RANGE, "0,4,0.01"),
"set_time_scale", "get_time_scale");
}
void Metin2AnimPlayer::set_anim_path(const String &p) {
anim_path = p;
if (is_inside_tree()) {
reload();
}
}
void Metin2AnimPlayer::set_model_path(const NodePath &p) {
model_path = p;
}
void Metin2AnimPlayer::set_time(double t) {
time = t;
if (is_inside_tree()) {
apply_pose(time);
}
}
Metin2Model *Metin2AnimPlayer::resolve_model() const {
if (model_path.is_empty()) {
return nullptr;
}
Node *n = get_node_or_null(model_path);
return Object::cast_to<Metin2Model>(n);
}
void Metin2AnimPlayer::_ready() {
set_process(true);
reload();
}
void Metin2AnimPlayer::reload() {
anim_file = std::nullopt;
duration = 0.0;
time = 0.0;
last_info = "";
if (anim_path.is_empty()) {
return;
}
gr2::LoadError err;
const std::string p(anim_path.utf8().get_data());
auto f = gr2::File::load_path(p, &err);
if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
return;
}
const gr2::FileInfo &fi = f->file_info();
if (fi.animations.empty()) {
UtilityFunctions::push_error("[Metin2AnimPlayer] no animations in " + anim_path);
return;
}
anim_file = std::move(f);
const gr2::Animation &an = anim_file->file_info().animations[0];
duration = an.duration;
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());
UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
apply_pose(0.0);
}
void Metin2AnimPlayer::apply_pose(double t) {
if (!anim_file) {
return;
}
Metin2Model *model = resolve_model();
if (!model) {
return;
}
const gr2::Skeleton *sk = model->gr2_skeleton();
Skeleton3D *skel = model->skeleton_node();
if (!sk || !skel) {
return;
}
const gr2::Animation &an = anim_file->file_info().animations[0];
double tt = t;
if (duration > 0.0) {
tt = std::fmod(t, duration);
if (tt < 0.0) {
tt += duration;
}
}
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());
for (int i = 0; i < n; ++i) {
skel->set_bone_global_pose(i, gr2_to_godot(world_buf[i]));
}
}
void Metin2AnimPlayer::_process(double delta) {
if (!playing || !anim_file) {
return;
}
time += delta * time_scale;
apply_pose(time);
}
String Metin2AnimPlayer::selfcheck(int samples) {
if (!anim_file) {
return "no anim loaded";
}
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk) {
return "no model/skeleton";
}
const auto &anims = anim_file->file_info().animations;
int total = 0;
int nan_hits = 0;
std::vector<gr2::Mat4> w, s;
for (const auto &an : anims) {
double dur = an.duration > 0.0 ? an.duration : 1.0;
for (int k = 0; k <= samples; ++k) {
float tt = (float)(dur * k / samples);
gr2::sample_pose(*sk, an, tt, w, s);
for (const auto &mtx : w) {
for (float v : mtx) {
++total;
if (std::isnan(v) || std::isinf(v)) {
++nan_hits;
}
}
}
}
}
return vformat("selfcheck: anims=%d samples/anim=%d floats=%d NaN/Inf=%d",
(int)anims.size(), samples + 1, total, nan_hits);
}
} // namespace mtgodot