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