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:
@@ -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
|
||||
Reference in New Issue
Block a user