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
+226 -12
View File
@@ -1,39 +1,253 @@
#include "metin2_model.h"
#include "dxt.h"
#include "gr2_bridge.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/base_material3d.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <string>
#include <gr2/gr2.h> // libgr2 (xrender-poc)
using namespace godot;
namespace mtgodot {
Metin2Model::Metin2Model() = default;
Metin2Model::Metin2Model() {
file = std::make_shared<std::optional<gr2::File>>();
}
Metin2Model::~Metin2Model() = default;
void Metin2Model::_bind_methods() {
ClassDB::bind_method(D_METHOD("libgr2_info"), &Metin2Model::libgr2_info);
ClassDB::bind_method(D_METHOD("set_gr2_path", "path"), &Metin2Model::set_gr2_path);
ClassDB::bind_method(D_METHOD("get_gr2_path"), &Metin2Model::get_gr2_path);
ClassDB::bind_method(D_METHOD("set_texture_dir", "path"), &Metin2Model::set_texture_dir);
ClassDB::bind_method(D_METHOD("get_texture_dir"), &Metin2Model::get_texture_dir);
ClassDB::bind_method(D_METHOD("set_unit_scale", "s"), &Metin2Model::set_unit_scale);
ClassDB::bind_method(D_METHOD("get_unit_scale"), &Metin2Model::get_unit_scale);
ClassDB::bind_method(D_METHOD("set_flip_z", "v"), &Metin2Model::set_flip_z);
ClassDB::bind_method(D_METHOD("get_flip_z"), &Metin2Model::get_flip_z);
ClassDB::bind_method(D_METHOD("set_flip_winding", "v"), &Metin2Model::set_flip_winding);
ClassDB::bind_method(D_METHOD("get_flip_winding"), &Metin2Model::get_flip_winding);
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
"set_gr2_path", "get_gr2_path");
ADD_PROPERTY(PropertyInfo(Variant::STRING, "texture_dir", PROPERTY_HINT_GLOBAL_DIR),
"set_texture_dir", "get_texture_dir");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "unit_scale", PROPERTY_HINT_RANGE, "0.0001,1,0.0001"),
"set_unit_scale", "get_unit_scale");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_z"), "set_flip_z", "get_flip_z");
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_winding"), "set_flip_winding", "get_flip_winding");
}
String Metin2Model::libgr2_info() const {
// Forces a reference to a libgr2 symbol so the link is real, not dead-stripped.
return String("libgr2 linked; gr2::Magic enum size=") + itos((int)sizeof(gr2::Magic));
void Metin2Model::set_gr2_path(const String &p) {
gr2_path = p;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_texture_dir(const String &p) {
texture_dir = p;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_unit_scale(double s) {
unit_scale = s;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_flip_z(bool v) {
flip_z = v;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::set_flip_winding(bool v) {
flip_winding = v;
if (is_inside_tree()) {
reload();
}
}
void Metin2Model::_ready() {
if (!gr2_path.is_empty()) {
reload();
}
}
void Metin2Model::_clear_children() {
skel = nullptr;
mi = nullptr;
TypedArray<Node> kids = get_children();
for (int i = 0; i < kids.size(); ++i) {
Node *n = Object::cast_to<Node>(kids[i]);
if (n) {
remove_child(n);
n->queue_free();
}
}
}
void Metin2Model::reload() {
_clear_children();
*file = std::nullopt;
last_info = "";
if (gr2_path.is_empty()) {
return;
}
gr2::LoadError err;
const std::string p(gr2_path.utf8().get_data());
auto loaded = gr2::File::load_path(p, &err);
if (!loaded) {
UtilityFunctions::push_error(String("[Metin2Model] gr2 load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()) + " (" + gr2_path + ")");
return;
}
*file = std::move(loaded);
const gr2::FileInfo &fi = (*file)->file_info();
set_transform(make_conv((float)unit_scale, flip_z));
// Skeleton (optional).
const gr2::Skeleton *sk = fi.skeletons.empty() ? nullptr : &fi.skeletons[0];
if (sk) {
skel = build_skeleton(*sk);
if (skel) {
add_child(skel);
skel->set_owner(get_owner() ? get_owner() : this);
}
}
// Mesh.
AABB bounds;
Ref<ArrayMesh> mesh = build_mesh(fi, flip_winding, bounds);
if (mesh->get_surface_count() > 0) {
mi = memnew(MeshInstance3D);
mi->set_name("MeshInstance3D");
mi->set_mesh(mesh);
add_child(mi);
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));
}
mi->set_custom_aabb(bounds.grow(0.01));
}
_apply_materials();
int vtot = 0;
for (const auto &m : fi.meshes) {
vtot += (int)m.vertices.size();
}
last_info = vformat("gr2 v%d | bones=%d meshes=%d surfaces=%d verts=%d | bounds=%s",
(int)(*file)->format_version(),
sk ? (int)sk->bones.size() : 0,
(int)fi.meshes.size(),
mesh->get_surface_count(),
vtot,
String(bounds));
UtilityFunctions::print("[Metin2Model] ", last_info);
}
String Metin2Model::_guess_texture_dir() const {
if (!texture_dir.is_empty()) {
return texture_dir;
}
return gr2_path.get_base_dir();
}
void Metin2Model::_apply_materials() {
if (!mi || !file || !*file) {
return;
}
const gr2::FileInfo &fi = (*file)->file_info();
const String dir = _guess_texture_dir();
const String stem = gr2_path.get_file().get_basename();
// Cache: texture path -> ImageTexture.
auto load_tex = [&](const String &path) -> Ref<ImageTexture> {
Ref<ImageTexture> t;
if (!FileAccess::file_exists(path)) {
return t;
}
mtgodot::Image dds = mtgodot::load_dds_path(path.utf8().get_data());
if (!dds.ok()) {
return t;
}
PackedByteArray bytes;
bytes.resize((int)dds.rgba.size());
memcpy(bytes.ptrw(), dds.rgba.data(), dds.rgba.size());
Ref<godot::Image> img = godot::Image::create_from_data(
dds.w, dds.h, false, godot::Image::FORMAT_RGBA8, bytes);
if (img.is_null()) {
return t;
}
img->generate_mipmaps();
t = ImageTexture::create_from_image(img);
return t;
};
Ref<ImageTexture> tex_main = load_tex(dir.path_join(stem + String(".dds")));
Ref<ImageTexture> tex_face = load_tex(dir.path_join(stem + String("_face.dds")));
if (tex_face.is_null()) {
tex_face = load_tex(dir.path_join(String("warrior_face.dds")));
}
Ref<ArrayMesh> mesh = mi->get_mesh();
for (int s = 0; s < mesh->get_surface_count(); ++s) {
String sname = mesh->surface_get_name(s).to_lower();
Ref<ImageTexture> tex = (sname.find("face") != -1 && tex_face.is_valid()) ? tex_face : tex_main;
Ref<StandardMaterial3D> mat;
mat.instantiate();
mat->set_cull_mode(BaseMaterial3D::CULL_DISABLED); // M1: winding unverified
if (tex.is_valid()) {
mat->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex);
} else {
mat->set_albedo(Color(0.8, 0.8, 0.82));
}
mi->set_surface_override_material(s, mat);
}
}
const gr2::Skeleton *Metin2Model::gr2_skeleton() const {
if (!file || !*file) {
return nullptr;
}
const gr2::FileInfo &fi = (*file)->file_info();
return fi.skeletons.empty() ? nullptr : &fi.skeletons[0];
}
String Metin2Model::get_info() const {
return last_info;
}
String Metin2Model::probe_gr2(const String &path) const {
gr2::LoadError err;
const std::string p(path.utf8().get_data());
auto file = gr2::File::load_path(p, &err);
if (!file) {
return vformat("gr2 load FAILED [%s]: %s",
String(err.stage.c_str()), String(err.message.c_str()));
auto f = gr2::File::load_path(p, &err);
if (!f) {
return vformat("gr2 load FAILED [%s]: %s", String(err.stage.c_str()), String(err.message.c_str()));
}
return vformat("gr2 OK: format_version=%d sections=%d",
(int)file->format_version(), (int)file->sections().size());
(int)f->format_version(), (int)f->sections().size());
}
} // namespace mtgodot