#include "metin2_model.h" #include "dxt.h" #include "gr2_bridge.h" #include "m2_material.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace godot; namespace mtgodot { Metin2Model::Metin2Model() { file = std::make_shared>(); } Metin2Model::~Metin2Model() = default; void Metin2Model::_bind_methods() { 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("set_material_mode", "m"), &Metin2Model::set_material_mode); ClassDB::bind_method(D_METHOD("get_material_mode"), &Metin2Model::get_material_mode); ClassDB::bind_method(D_METHOD("set_use_gr2_materials", "v"), &Metin2Model::set_use_gr2_materials); ClassDB::bind_method(D_METHOD("get_use_gr2_materials"), &Metin2Model::get_use_gr2_materials); ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture); 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"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "material_mode", PROPERTY_HINT_ENUM, "metin2,standard"), "set_material_mode", "get_material_mode"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_gr2_materials"), "set_use_gr2_materials", "get_use_gr2_materials"); } void Metin2Model::set_material_mode(const String &m) { material_mode = m; if (is_inside_tree()) { reload(); } } void Metin2Model::set_use_gr2_materials(bool v) { use_gr2_materials = v; if (is_inside_tree()) { _apply_materials(); } } void Metin2Model::set_surface_texture(int surface, const String &path) { if (surface < 0) { return; } while (surface_tex_override.size() <= surface) { surface_tex_override.push_back(String()); } surface_tex_override[surface] = path; if (is_inside_tree()) { _apply_materials(); } } 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 kids = get_children(); for (int i = 0; i < kids.size(); ++i) { Node *n = Object::cast_to(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 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)); 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)); } materials = gr2::dump_materials(**file); _apply_materials(); int vtot = 0; for (const auto &m : fi.meshes) { vtot += (int)m.vertices.size(); } { int with_tex = 0; for (const auto &mm : materials) { if (!mm.diffuse_texture.empty()) { ++with_tex; } } UtilityFunctions::print(vformat("[Metin2Model] gr2 materials: %d (%d with diffuse name)", (int)materials.size(), with_tex)); } 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(); } Ref Metin2Model::_load_dds(const String &path) { Ref t; if (tex_cache.has(path)) { return tex_cache[path]; } if (FileAccess::file_exists(path)) { mtgodot::Image dds = mtgodot::load_dds_path(path.utf8().get_data()); if (dds.ok()) { PackedByteArray bytes; bytes.resize((int)dds.rgba.size()); memcpy(bytes.ptrw(), dds.rgba.data(), dds.rgba.size()); Ref img = godot::Image::create_from_data( dds.w, dds.h, false, godot::Image::FORMAT_RGBA8, bytes); if (img.is_valid()) { img->generate_mipmaps(); t = ImageTexture::create_from_image(img); } } } tex_cache[path] = t; return t; } // Best-effort texture pick for a surface. libgr2's POD API gives no material or // texture names yet (see GODOT-POC-PLAN.md §M2.5 gap list), so this is filename // convention + a case-insensitive directory scan. Ref Metin2Model::_resolve_texture(const String &dir, const String &stem, const String &surface_name, const PackedStringArray &dds_files) { String sn = surface_name.to_lower(); auto scan = [&](const String &needle) -> Ref { for (int i = 0; i < dds_files.size(); ++i) { if (dds_files[i].to_lower().find(needle) != -1) { Ref t = _load_dds(dir.path_join(dds_files[i])); if (t.is_valid()) { return t; } } } return Ref(); }; // 1. surface-name keyword slots if (sn.find("face") != -1) { Ref t = _load_dds(dir.path_join(stem + String("_face.dds"))); if (t.is_null()) { t = scan("face"); } if (t.is_valid()) { return t; } } if (sn.find("hair") != -1) { Ref t = _load_dds(dir.path_join(stem + String("_hair.dds"))); if (t.is_null()) { t = scan("hair"); } if (t.is_valid()) { return t; } } // 2. surface name used verbatim as a texture basename if (!sn.is_empty()) { Ref t = _load_dds(dir.path_join(surface_name + String(".dds"))); if (t.is_valid()) { return t; } } // 3. exact stem Ref t = _load_dds(dir.path_join(stem + String(".dds"))); if (t.is_valid()) { return t; } // 4. any dds whose name contains the stem, then any non-face/hair dds t = scan(stem); if (t.is_valid()) { return t; } for (int i = 0; i < dds_files.size(); ++i) { String f = dds_files[i].to_lower(); if (f.find("face") == -1 && f.find("hair") == -1 && f.find("_lod") == -1) { Ref any = _load_dds(dir.path_join(dds_files[i])); if (any.is_valid()) { return any; } } } return Ref(); } static mtgodot::BlendMode guess_blend(const String &surface_name) { String sn = surface_name.to_lower(); if (sn.find("effect") != -1 || sn.find("glow") != -1 || sn.find("aura") != -1) { return mtgodot::BlendMode::Add; } if (sn.find("hair") != -1) { return mtgodot::BlendMode::AlphaTest; // Metin2 hair = alpha-test cutout } if (sn.find("cloak") != -1 || sn.find("cape") != -1 || sn.find("skirt") != -1 || sn.find("ribbon") != -1) { return mtgodot::BlendMode::Alpha; } return mtgodot::BlendMode::Opaque; } void Metin2Model::_apply_materials() { if (!mi || !file || !*file) { return; } const String dir = _guess_texture_dir(); const String stem = gr2_path.get_file().get_basename(); PackedStringArray dds_files; { Ref da = DirAccess::open(dir); if (da.is_valid()) { da->list_dir_begin(); for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) { if (!da->current_is_dir() && f.to_lower().ends_with(".dds")) { dds_files.push_back(f); } } da->list_dir_end(); } } // Surface s corresponds to the s-th gr2 mesh that has geometry (same skip // rule as build_mesh). Recover that mapping to reach tri_groups/material_index. const gr2::FileInfo &fi = (*file)->file_info(); std::vector surf_to_mesh; for (int mi_ = 0; mi_ < (int)fi.meshes.size(); ++mi_) { if (!fi.meshes[mi_].vertices.empty() && !fi.meshes[mi_].indices.empty()) { surf_to_mesh.push_back(mi_); } } Ref mesh = mi->get_mesh(); int textured = 0; for (int s = 0; s < mesh->get_surface_count(); ++s) { const String sname = mesh->surface_get_name(s); Ref tex; auto mat_texture_name = [&]() -> String { if (s >= (int)surf_to_mesh.size()) { return String(); } const gr2::Mesh &gm = fi.meshes[surf_to_mesh[s]]; int matidx = gm.tri_groups.empty() ? -1 : gm.tri_groups[0].material_index; if (matidx < 0 || matidx >= (int)materials.size() || materials[matidx].diffuse_texture.empty()) { return String(); } return String(materials[matidx].diffuse_texture.c_str()) .replace("\\", "/") .get_file() .get_basename() .to_lower(); }; if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) { tex = _load_dds(surface_tex_override[s]); } // gr2 material name -> texture, only when opted in (unverified vs oracle; // see GODOT-POC-PLAN.md §M2.5 T2.5.6). if (tex.is_null() && use_gr2_materials) { String want = mat_texture_name(); if (!want.is_empty()) { for (int i = 0; i < dds_files.size(); ++i) { if (dds_files[i].get_basename().to_lower() == want) { tex = _load_dds(dir.path_join(dds_files[i])); break; } } } } // filename-convention resolution (default path) if (tex.is_null()) { tex = _resolve_texture(dir, stem, sname, dds_files); } if (tex.is_valid()) { ++textured; } if (material_mode == String("standard")) { Ref mat; mat.instantiate(); mat->set_cull_mode(BaseMaterial3D::CULL_DISABLED); 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); } else { mtgodot::MaterialDesc md; md.albedo = tex; md.blend = guess_blend(sname); mi->set_surface_override_material(s, mtgodot::make_material(md)); } } UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)", textured, mesh->get_surface_count(), dir, (int)dds_files.size())); } 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]; } 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()); 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 &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; } String Metin2Model::probe_gr2(const String &path) const { gr2::LoadError err; const std::string p(path.utf8().get_data()); 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)f->format_version(), (int)f->sections().size()); } } // namespace mtgodot