#include "metin2_model.h" #include "asset_io.h" #include "dxt.h" #include "gr2_bridge.h" #include "m2_material.h" #include "texture_util.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace godot; namespace mtgodot { static mtgodot::BlendMode guess_blend(const godot::String &surface_name); // defined below static const gr2::MaterialInfo *match_material(const std::vector &mats, const godot::String &want_base); static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const godot::String &sname); static bool decide_two_sided(const gr2::MaterialInfo *m, const godot::String &sname); Metin2Model::Metin2Model() { file = std::make_shared>(); lod_dist.push_back(18.0f); lod_dist.push_back(42.0f); lod_dist.push_back(90.0f); } 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_specular_power", "p"), &Metin2Model::set_specular_power); ClassDB::bind_method(D_METHOD("get_specular_power"), &Metin2Model::get_specular_power); 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("get_hair_options"), &Metin2Model::get_hair_options); ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2); ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2); ClassDB::bind_method(D_METHOD("set_hair_skin", "p"), &Metin2Model::set_hair_skin); ClassDB::bind_method(D_METHOD("get_hair_skin"), &Metin2Model::get_hair_skin); ClassDB::bind_method(D_METHOD("set_weapon_gr2", "p"), &Metin2Model::set_weapon_gr2); ClassDB::bind_method(D_METHOD("get_weapon_gr2"), &Metin2Model::get_weapon_gr2); ClassDB::bind_method(D_METHOD("set_weapon_bone", "b"), &Metin2Model::set_weapon_bone); ClassDB::bind_method(D_METHOD("get_weapon_bone"), &Metin2Model::get_weapon_bone); ClassDB::bind_method(D_METHOD("set_shield_gr2", "p"), &Metin2Model::set_shield_gr2); ClassDB::bind_method(D_METHOD("get_shield_gr2"), &Metin2Model::get_shield_gr2); ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone); ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone); ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2); ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msm"), "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"); ClassDB::bind_method(D_METHOD("set_lod_enabled", "v"), &Metin2Model::set_lod_enabled); ClassDB::bind_method(D_METHOD("get_lod_enabled"), &Metin2Model::get_lod_enabled); ClassDB::bind_method(D_METHOD("set_lod_distances", "d"), &Metin2Model::set_lod_distances); ClassDB::bind_method(D_METHOD("get_lod_distances"), &Metin2Model::get_lod_distances); ClassDB::bind_method(D_METHOD("get_lod_level"), &Metin2Model::get_lod_level); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lod_enabled"), "set_lod_enabled", "get_lod_enabled"); ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "lod_distances"), "set_lod_distances", "get_lod_distances"); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "specular_power", PROPERTY_HINT_RANGE, "0,4,0.01"), "set_specular_power", "get_specular_power"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), "set_hair_gr2", "get_hair_gr2"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_skin", PROPERTY_HINT_GLOBAL_FILE, "*.dds"), "set_hair_skin", "get_hair_skin"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), "set_weapon_gr2", "get_weapon_gr2"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_bone"), "set_weapon_bone", "get_weapon_bone"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"), "set_shield_gr2", "get_shield_gr2"); ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_bone"), "set_shield_bone", "get_shield_bone"); } void Metin2Model::set_material_mode(const String &m) { material_mode = m; if (is_inside_tree()) { reload(); } } void Metin2Model::set_specular_power(double p) { specular_power = p < 0.0 ? 0.0 : p; if (is_inside_tree() && mi) { _apply_materials(); } } 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() { set_process(true); if (!gr2_path.is_empty()) { reload(); } } void Metin2Model::set_lod_enabled(bool v) { lod_enabled = v; if (!v && lod_level != 0) { _set_lod(0); } } void Metin2Model::set_lod_distances(const PackedFloat32Array &d) { lod_dist = d; } const gr2::FileInfo *Metin2Model::active_fi() const { if (lod_level >= 1 && lod_level <= (int)lod_files.size() && lod_files[lod_level - 1] && *lod_files[lod_level - 1]) { return &(**lod_files[lod_level - 1]).file_info(); } return (file && *file) ? &(*file)->file_info() : nullptr; } void Metin2Model::_load_lods(const String &loaded_spec) { lod_files.clear(); lod_parts.clear(); String stem = loaded_spec.get_basename(); // ".../warrior_cheongrin" for (int i = 1; i <= 3; ++i) { String lp = stem + vformat("_lod_%02d.gr2", i); if (!FileAccess::file_exists(lp)) { break; } gr2::LoadError e; auto f = mtgodot::gr2_from_file(lp, &e); if (!f) { UtilityFunctions::push_warning(String("[Metin2Model] lod load failed: ") + lp); break; } auto slot = std::make_shared>(std::move(f)); lod_parts.push_back(mtgodot::build_parts((**slot).file_info())); lod_files.push_back(std::move(slot)); } if (!lod_files.empty()) { UtilityFunctions::print(vformat("[Metin2Model] LOD: %d level(s) loaded", (int)lod_files.size())); } } namespace { constexpr double kLodFadeDur = 0.18; void set_lod_fade(const std::vector> &mats, float v) { for (const Ref &m : mats) { Ref sm = m; if (sm.is_valid()) { sm->set_shader_parameter("lod_fade", v); } } } } // namespace void Metin2Model::_end_lod_fade() { if (lod_prev_mi) { remove_child(lod_prev_mi); lod_prev_mi->queue_free(); lod_prev_mi = nullptr; } lod_fade_t = -1.0; set_lod_fade(surf_mats, 1.0f); } void Metin2Model::_set_lod(int n) { n = n < 0 ? 0 : (n > (int)lod_files.size() ? (int)lod_files.size() : n); if (n == lod_level || !mi) { return; } // snapshot the outgoing level into a frozen ghost that fades out _end_lod_fade(); if (mi->get_mesh().is_valid() && material_mode != String("standard")) { lod_prev_mi = memnew(MeshInstance3D); lod_prev_mi->set_name("LodGhost"); lod_prev_mi->set_mesh(mi->get_mesh()); add_child(lod_prev_mi); lod_prev_mi->set_transform(mi->get_transform()); std::vector> ghost_mats; for (int s = 0; s < (int)surf_mats.size(); ++s) { Ref src = surf_mats[s]; Ref dup; if (src.is_valid()) { dup = src->duplicate(); } lod_prev_mi->set_surface_override_material(s, dup); ghost_mats.push_back(dup); } set_lod_fade(ghost_mats, 1.0f); lod_ghost_mats = std::move(ghost_mats); lod_fade_t = 0.0; } lod_level = n; parts = (n == 0) ? base_parts : lod_parts[n - 1]; const gr2::FileInfo *afi = active_fi(); if (!afi) { _end_lod_fade(); return; } if (cpu_mesh.is_valid()) { AABB b; cpu_mesh = build_mesh(*afi, parts, flip_winding, b); mi->set_mesh(cpu_mesh); } else { AABB b; Ref m = build_mesh(*afi, parts, flip_winding, b); if (m->get_surface_count() > 0) { mi->set_mesh(m); } } _apply_materials(); if (cpu_mesh.is_valid() && !current_skin.empty()) { cpu_skin(current_skin); } if (lod_fade_t >= 0.0) { set_lod_fade(surf_mats, 0.0f); // new level starts invisible, fades in } } void Metin2Model::_process(double delta) { // LOD crossfade progression if (lod_fade_t >= 0.0) { lod_fade_t += delta; float k = (float)std::min(1.0, lod_fade_t / kLodFadeDur); set_lod_fade(surf_mats, k); set_lod_fade(lod_ghost_mats, 1.0f - k); if (k >= 1.0f) { _end_lod_fade(); lod_ghost_mats.clear(); } } if (!lod_enabled || lod_files.empty() || !mi || lod_dist.is_empty()) { return; } Viewport *vp = get_viewport(); Camera3D *cam = vp ? vp->get_camera_3d() : nullptr; if (!cam) { return; } float d = get_global_position().distance_to(cam->get_global_position()); // hysteresis: step up at lod_dist[i], step down only 15% closer int lv = 0; for (int i = 0; i < (int)lod_dist.size() && i < 3; ++i) { float up = lod_dist[i]; float down = up * 0.85f; if (d > up) { lv = i + 1; } else if (d > down && lv == i && lod_level >= i + 1) { lv = i + 1; // stay in the higher LOD across the dead-band } } _set_lod(lv); } void Metin2Model::_clear_children() { skel = nullptr; mi = nullptr; weapon_mi = nullptr; weapon_bone_idx = -1; shield_mi = nullptr; shield_bone_idx = -1; 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 = ""; resolved_gr2_dir = ""; hair_options.clear(); if (hair_file) *hair_file = std::nullopt; hair_parts.clear(); hair_bone_remap.clear(); hair_mat.unref(); hair_tex.unref(); gpu_base_surf = 0; if (weapon_file) *weapon_file = std::nullopt; if (shield_file) *shield_file = std::nullopt; materials.clear(); parts.clear(); base_parts.clear(); lod_files.clear(); lod_parts.clear(); lod_level = 0; surf_mats.clear(); cpu_mesh.unref(); gpu_skin_active = false; bones_img.unref(); bones_tex.unref(); current_skin.clear(); if (gr2_path.is_empty()) { return; } // .msm -> resolve BaseModelFileName; stash the hair catalog for get_hair_options(). String gr2_spec = gr2_path; if (gr2_path.to_lower().ends_with(".msm")) { fmt::Msm ms; std::string e; if (fmt::parse_msm_file(std::string(gr2_path.utf8().get_data()), ms, &e)) { gr2_spec = resolve_rel_gr2(gr2_path, String(ms.base_model_gr2.c_str())); const String hp = String(ms.hair_path.c_str()); for (const fmt::HairEntry &h : ms.hairs) { Dictionary d; d["index"] = h.index; d["model"] = resolve_rel_gr2(gr2_path, hp + String(h.model.c_str())); // SourceSkin / TargetSkin resolved to absolute (§2.4). TargetSkin is // relative to PathName; SourceSkin too (the gr2's own texture). const String ss(h.source_skin.c_str()), ts(h.target_skin.c_str()); d["source_skin"] = ss.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ss); d["target_skin"] = ts.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ts); hair_options.push_back(d); } UtilityFunctions::print(vformat("[Metin2Model] .msm -> %s hairs=%d", gr2_spec, (int)hair_options.size())); } else { UtilityFunctions::push_error(String("[Metin2Model] .msm: ") + String(e.c_str())); return; } } gr2::LoadError err; auto loaded = mtgodot::gr2_from_file(gr2_spec, &err); if (!loaded) { UtilityFunctions::push_error(String("[Metin2Model] gr2 load failed [") + String(err.stage.c_str()) + "]: " + String(err.message.c_str()) + " (" + gr2_spec + ")"); return; } *file = std::move(loaded); resolved_gr2_dir = gr2_spec.get_base_dir(); 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) { std::vector bind_world; gr2::bind_pose(*sk, bind_world, current_skin); skel = build_skeleton(*sk); if (skel) { add_child(skel); skel->set_owner(get_owner() ? get_owner() : this); } } // Mesh — one surface per render part (gr2 mesh split by tri_group.material_index). parts = mtgodot::build_parts(fi); base_parts = parts; AABB bounds; Ref mesh = build_mesh(fi, parts, 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); _load_lods(gr2_spec); if (!hair_gr2.is_empty()) _load_hair(); if (!weapon_gr2.is_empty()) _load_weapon(); if (!shield_gr2.is_empty()) _load_shield(); } void Metin2Model::_load_hair() { hair_parts.clear(); hair_bone_remap.clear(); hair_mat.unref(); hair_tex.unref(); gpu_base_surf = 0; if (hair_gr2.is_empty() || !file || !*file) return; const gr2::FileInfo &base_fi = (*file)->file_info(); if (base_fi.skeletons.empty()) return; if (!hair_file) hair_file = std::make_shared>(); gr2::LoadError e; auto h = mtgodot::gr2_from_file(hair_gr2, &e); if (!h) { UtilityFunctions::push_warning(String("[Metin2Model] hair load failed: ") + e.message.c_str()); return; } *hair_file = std::move(h); const gr2::FileInfo &hfi = (**hair_file).file_info(); if (hfi.skeletons.empty()) return; // hair 骨骼名 -> base 骨骼索引 const auto &bbones = base_fi.skeletons[0].bones; const auto &hbones = hfi.skeletons[0].bones; hair_bone_remap.assign(hbones.size(), -1); for (size_t i = 0; i < hbones.size(); ++i) for (size_t j = 0; j < bbones.size(); ++j) if (hbones[i].name == bbones[j].name) { hair_bone_remap[i] = (int)j; break; } hair_parts = mtgodot::build_parts(hfi); // hair 贴图:同名 .dds,alpha-scissor 镂空 Ref hm; hm.instantiate(); hm->set_roughness(1.0f); hm->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR); hm->set_alpha_scissor_threshold(0.5f); hm->set_cull_mode(StandardMaterial3D::CULL_DISABLED); // SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4) String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin; Ref ht = _load_dds(hdds); if (ht.is_null() && !hair_skin.is_empty()) { UtilityFunctions::push_warning(String("[Metin2Model] hair_skin not found: ") + hair_skin); ht = _load_dds(hair_gr2.get_basename() + ".dds"); // fall back to SourceSkin } if (ht.is_valid()) hm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, ht); else hm->set_albedo(Color(0.18f, 0.12f, 0.08f)); hair_mat = hm; hair_tex = ht; // reused by the GPU-skin path (§2.2) int matched = 0; for (int r : hair_bone_remap) if (r >= 0) ++matched; UtilityFunctions::print(vformat("[Metin2Model] hair: %s %d/%d bones matched, %d parts", hair_gr2.get_file(), matched, (int)hbones.size(), (int)hair_parts.size())); if (has_cpu_skin_mesh() && !current_skin.empty()) cpu_skin(current_skin); } void Metin2Model::set_hair_gr2(const String &p) { if (p == hair_gr2) return; hair_gr2 = p; if (file && *file) _load_hair(); } void Metin2Model::set_hair_skin(const String &p) { if (p == hair_skin) return; hair_skin = p; if (file && *file && !hair_gr2.is_empty()) _load_hair(); } void Metin2Model::set_weapon_gr2(const String &p) { if (p == weapon_gr2) return; weapon_gr2 = p; if (file && *file) _load_weapon(); } void Metin2Model::set_weapon_bone(const String &b) { if (b == weapon_bone) return; weapon_bone = b; if (file && *file) _load_weapon(); } void Metin2Model::set_shield_gr2(const String &p) { if (p == shield_gr2) return; shield_gr2 = p; if (file && *file) _load_shield(); } void Metin2Model::set_shield_bone(const String &b) { if (b == shield_bone) return; shield_bone = b; if (file && *file) _load_shield(); } void Metin2Model::_load_weapon() { _load_attach(weapon_gr2, weapon_bone, weapon_file, weapon_mi, weapon_bone_idx, weapon_pre, "Weapon", "weapon"); } void Metin2Model::_load_shield() { _load_attach(shield_gr2, shield_bone, shield_file, shield_mi, shield_bone_idx, shield_pre, "Shield", "shield"); } void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name, std::shared_ptr> &slot_file, MeshInstance3D *&slot_mi, int &slot_bone_idx, gr2::Mat4 &slot_pre, const char *node_name, const char *label) { if (slot_mi) { remove_child(slot_mi); slot_mi->queue_free(); slot_mi = nullptr; } slot_bone_idx = -1; slot_pre = gr2::Mat4{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; if (gr2_rel.is_empty() || !file || !*file) return; const gr2::FileInfo &base_fi = (*file)->file_info(); if (base_fi.skeletons.empty()) return; // attach bone: name -> base skeleton index (client: equip_right_hand / _left_) const auto &bbones = base_fi.skeletons[0].bones; std::string want(bone_name.utf8().get_data()); for (size_t j = 0; j < bbones.size(); ++j) if (bbones[j].name == want) { slot_bone_idx = (int)j; break; } if (slot_bone_idx < 0) { UtilityFunctions::push_warning(String("[Metin2Model] ") + label + " bone not found: " + bone_name); return; } String wpath = gr2_rel; if (!FileAccess::file_exists(wpath)) wpath = resolve_rel_gr2( resolved_gr2_dir.is_empty() ? gr2_path : resolved_gr2_dir.path_join("x"), gr2_rel); gr2::LoadError e; auto w = mtgodot::gr2_from_file(wpath, &e); if (!w) { UtilityFunctions::push_warning(String("[Metin2Model] ") + label + " load failed: " + String(e.message.c_str()) + " (" + wpath + ")"); return; } if (!slot_file) slot_file = std::make_shared>(); *slot_file = std::move(w); const gr2::FileInfo &wfi = (**slot_file).file_info(); // Grip pre-transform, mirroring the client's weapon composite: // weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0] // weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld // (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer). // Most Metin2 weapons are authored directly in hand space so this pre is ~I; // a few non-standard files carry a huge bone offset that would fling the mesh // metres away — clamp those out and fall back to the raw hand pose. if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) { const gr2::Bone &wb0 = wfi.skeletons[0].bones[0]; gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform); const float tx = pre[12], ty = pre[13], tz = pre[14]; if (tx * tx + ty * ty + tz * tz < 30.0f * 30.0f) // < 30 cm slot_pre = pre; } std::vector wparts = mtgodot::build_parts(wfi); AABB wb; Ref wmesh = mtgodot::build_mesh(wfi, wparts, flip_winding, wb); if (wmesh.is_null() || wmesh->get_surface_count() == 0) return; // textures: gr2 material binding -> sibling .dds by basename String wdir = wpath.get_base_dir(); PackedStringArray wdds; if (Ref da = DirAccess::open(wdir); 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")) wdds.push_back(f); da->list_dir_end(); } auto find_wdds = [&](const String &want_base) -> Ref { for (int i = 0; i < wdds.size(); ++i) if (wdds[i].get_basename().to_lower() == want_base) return _load_dds(wdir.path_join(wdds[i])); return Ref(); }; slot_mi = memnew(MeshInstance3D); slot_mi->set_name(node_name); slot_mi->set_mesh(wmesh); add_child(slot_mi); std::vector wmaterials = gr2::dump_materials(**slot_file); int wtextured = 0; for (int s = 0; s < wmesh->get_surface_count(); ++s) { String want_base; if (s < (int)wparts.size()) { const gr2::Mesh &gm = wfi.meshes[wparts[s].mesh]; if (wparts[s].mat_index >= 0 && wparts[s].mat_index < (int)gm.material_textures.size()) want_base = String(gm.material_textures[wparts[s].mat_index].c_str()) .replace("\\", "/") .get_file() .get_basename() .to_lower(); } Ref tex = find_wdds(want_base); if (tex.is_valid()) ++wtextured; Ref mat; if (material_mode == String("standard")) { Ref sm; sm.instantiate(); if (tex.is_valid()) sm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex); mat = sm; } else { const String wsn = wmesh->surface_get_name(s); const gr2::MaterialInfo *wm = match_material(wmaterials, want_base); mtgodot::MaterialDesc md; md.albedo = tex; md.blend = decide_blend(wm, wsn); md.two_sided = decide_two_sided(wm, wsn); mat = mtgodot::make_material(md); } slot_mi->set_surface_override_material(s, mat); } // place immediately at bind pose so a static (non-animated) model still holds it std::vector bw, bs; gr2::bind_pose(base_fi.skeletons[0], bw, bs); update_weapon_pose(bw); UtilityFunctions::print(vformat("[Metin2Model] %s: %s -> bone[%d] %s, %d surf (%d textured)", label, wpath.get_file(), slot_bone_idx, bone_name, wmesh->get_surface_count(), wtextured)); } void Metin2Model::update_weapon_pose(const std::vector &world_pose) { // weapon local = gripPre · handWorld (client: weaponBone[0] composed onto the // parent attach-bone matrix). gripPre is identity for near-origin weapons, so // this is backward-compatible with the ones that already looked right. if (weapon_mi && weapon_bone_idx >= 0 && weapon_bone_idx < (int)world_pose.size()) weapon_mi->set_transform(mtgodot::gr2_to_godot( mtgodot::mul4x3(weapon_pre, world_pose[weapon_bone_idx]))); if (shield_mi && shield_bone_idx >= 0 && shield_bone_idx < (int)world_pose.size()) shield_mi->set_transform(mtgodot::gr2_to_godot( mtgodot::mul4x3(shield_pre, world_pose[shield_bone_idx]))); } String Metin2Model::_guess_texture_dir() const { if (!texture_dir.is_empty()) { return texture_dir; } return resolved_gr2_dir.is_empty() ? gr2_path.get_base_dir() : resolved_gr2_dir; } // Shared sphere map for §2.7 specular. Client sphere index 0 == the global // "d:/ymir work/special/spheremap.jpg"; in the loose-file packs that lands at // "/ymir work/special/spheremap.jpg" (typically the ETC pack). Walk up from // the model dir and probe each level + its immediate subdirs. .jpg decodes via // Godot core (Image::load), no DDS path needed. Ref Metin2Model::_load_sphere_map() { if (sphere_map.is_valid()) { return sphere_map; } static const char *kTail = "ymir work/special/spheremap.jpg"; String hit; String dir = _guess_texture_dir(); for (int up = 0; up < 8 && !dir.is_empty() && dir != "/" && hit.is_empty(); ++up) { String direct = dir.path_join(kTail); if (FileAccess::file_exists(direct)) { hit = direct; break; } 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.begins_with(".")) { continue; } String cand = dir.path_join(f).path_join(kTail); if (FileAccess::file_exists(cand)) { hit = cand; break; } } da->list_dir_end(); } dir = dir.get_base_dir(); } if (hit.is_empty()) { UtilityFunctions::push_warning("[Metin2Model] specular_power set but spheremap.jpg not found"); return Ref(); } Ref img; img.instantiate(); if (img->load(hit) != OK) { UtilityFunctions::push_warning("[Metin2Model] failed to decode " + hit); return Ref(); } sphere_map = ImageTexture::create_from_image(img); UtilityFunctions::print("[Metin2Model] sphere map: " + hit); return sphere_map; } // "d:/ymir work/pc2/warrior/x.gr2" (referenced from a .msm/.msa at `base`) -> // absolute path. Walks up from base's dir trying each level as an asset root // against "/PC/ymir work/" and "/ymir work/". String Metin2Model::resolve_rel_gr2(const String &base, const String &spec) { String p = spec.replace("\\", "/"); if (FileAccess::file_exists(p)) { // already absolute return p; } String base_dir = base.get_base_dir(); String sib = base_dir.path_join(p.get_file()); if (FileAccess::file_exists(sib)) { return sib; } int kp = p.to_lower().find("ymir work/"); String yw_tail = (kp >= 0) ? p.substr(kp + 10) : p; // "pc2/warrior/x.gr2" String dir = base_dir; String assets_root; for (int up = 0; up < 8 && !dir.is_empty() && dir != "/"; ++up) { for (const String &pre : { String("PC/ymir work/"), String("ymir work/"), String("PC/Ymir Work/"), String("") }) { String cand = dir.path_join(pre + yw_tail); if (FileAccess::file_exists(cand)) { return cand; } } if (assets_root.is_empty()) { Ref da = DirAccess::open(dir); if (da.is_valid() && da->dir_exists(dir.path_join("PC"))) { assets_root = dir; } } dir = dir.get_base_dir(); } // eterpack layout: the model may live in another loose-file pack dir // (assets//ymir work/). Scan the asset root's subdirs. if (!assets_root.is_empty()) { Ref da = DirAccess::open(assets_root); 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.begins_with(".")) { continue; } String cand = assets_root.path_join(f).path_join("ymir work/") + yw_tail; if (FileAccess::file_exists(cand)) { da->list_dir_end(); return cand; } } da->list_dir_end(); } } return sib; // best guess } 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::dds_from_file(path); if (dds.ok()) { // Character skins are sRGB colour -> eligible for mobile ASTC // (mtgodot::make_color_texture; no-op on desktop). §F4. t = mtgodot::make_color_texture(dds.w, dds.h, dds.rgba.data(), dds.rgba.size(), /*mipmaps=*/true); } } 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; } // gr2 material for a mesh-local texture basename: match dump_materials() by the // diffuse_texture / material name (both reduced to lowercase basename). static const gr2::MaterialInfo *match_material(const std::vector &mats, const String &want_base) { if (want_base.is_empty()) { return nullptr; } std::string w(want_base.utf8().get_data()); for (const auto &m : mats) { String dt = String(m.diffuse_texture.c_str()) .replace("\\", "/") .get_file() .get_basename() .to_lower(); if (dt == want_base || String(m.name.c_str()).to_lower() == want_base) { return &m; } } return nullptr; } // Blend mode: prefer the GR2 material flag (EterGrnLib Material.cpp:233 = Name // "Blend*" + MapCount>1); fall back to the surface-name heuristic for the cases // GR2 doesn't encode (hair cutout, effect additive, cloth alpha). static mtgodot::BlendMode decide_blend(const gr2::MaterialInfo *m, const String &sname) { String sn = sname.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; } if (m && m->alpha_blend) { return mtgodot::BlendMode::Alpha; } return guess_blend(sname); } static bool decide_two_sided(const gr2::MaterialInfo *m, const String &sname) { if (m && m->two_sided) { return true; } String sn = sname.to_lower(); return sn.find("hair") != -1 || sn.find("cloak") != -1 || sn.find("cape") != -1 || sn.find("skirt") != -1 || sn.find("ribbon") != -1 || sn.find("leaf") != -1; } 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 <-> parts[s]. tri_group.material_index is a MESH-LOCAL index into // that mesh's MaterialBindings, so the texture name comes from // Mesh::material_textures[mat_index] (NOT the global dump_materials() list — // that mixed body/face up). Use the active LOD's fileinfo (§2.10). const gr2::FileInfo *afi_ = active_fi(); if (!afi_) { return; } const gr2::FileInfo &fi = *afi_; auto find_dds = [&](const String &want) -> Ref { if (want.is_empty()) { return Ref(); } for (int i = 0; i < dds_files.size(); ++i) { if (dds_files[i].get_basename().to_lower() == want) { return _load_dds(dir.path_join(dds_files[i])); } } return Ref(); }; Ref mesh = mi->get_mesh(); int textured = 0; const int nsurf = mesh->get_surface_count(); surf_mats.assign(nsurf, Ref()); // §2.7 sphere-map specular: applied uniformly to all skin parts here (no // per-part item_proto to distinguish body armor from face/hair). 0 = skip. Ref smap = (specular_power > 0.0 && material_mode != String("standard")) ? _load_sphere_map() : Ref(); for (int s = 0; s < nsurf; ++s) { const String sname = mesh->surface_get_name(s); Ref tex; // §2.2: appended GPU-hair surfaces — skinned shader + hair albedo + cutout. if (gpu_skin_active && s >= gpu_base_surf) { mtgodot::MaterialDesc md; md.albedo = hair_tex; md.blend = mtgodot::BlendMode::AlphaTest; md.alpha_scissor = 0.5f; md.skinned = true; md.bones_tex = bones_tex; Ref hm = mtgodot::make_material(md); surf_mats[s] = hm; mi->set_surface_override_material(s, hm); continue; } String mat_want; // mesh-local material -> dds basename if (s < (int)parts.size()) { const mtgodot::RenderPart &rp = parts[s]; const gr2::Mesh &gm = fi.meshes[rp.mesh]; if (rp.mat_index >= 0 && rp.mat_index < (int)gm.material_textures.size()) { mat_want = String(gm.material_textures[rp.mat_index].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 binding -> texture (default; set use_gr2_materials=false to skip) if (tex.is_null() && use_gr2_materials) { tex = find_dds(mat_want); } // filename-convention heuristic (fallback when the binding resolved nothing) if (tex.is_null()) { tex = _resolve_texture(dir, stem, sname, dds_files); } if (tex.is_valid()) { ++textured; } Ref mat; if (material_mode == String("standard")) { Ref sm; sm.instantiate(); sm->set_cull_mode(BaseMaterial3D::CULL_DISABLED); if (tex.is_valid()) { sm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex); } else { sm->set_albedo(Color(0.8, 0.8, 0.82)); } mat = sm; } else { const gr2::MaterialInfo *gm_mat = match_material(materials, mat_want); mtgodot::MaterialDesc md; md.albedo = tex; md.blend = decide_blend(gm_mat, sname); md.two_sided = decide_two_sided(gm_mat, sname); md.skinned = gpu_skin_active; md.bones_tex = bones_tex; if (smap.is_valid()) { md.spec_map = smap; md.spec_power = (float)specular_power; } mat = mtgodot::make_material(md); } surf_mats[s] = mat; mi->set_surface_override_material(s, mat); } UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)", textured, mesh->get_surface_count(), dir, (int)dds_files.size())); } void Metin2Model::_build_gpu_mesh() { if (!mi || !file || !*file) { return; } const gr2::FileInfo *bfi = active_fi(); if (!bfi) { return; } AABB b; Ref combined = build_mesh(*bfi, parts, flip_winding, b); gpu_base_surf = combined->get_surface_count(); if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { const gr2::FileInfo &hfi = (**hair_file).file_info(); AABB hb; Ref hm = build_mesh(hfi, hair_parts, flip_winding, hb); for (int s = 0; s < hm->get_surface_count(); ++s) { Array arr = hm->surface_get_arrays(s); PackedInt32Array bones = arr[Mesh::ARRAY_BONES]; for (int k = 0; k < bones.size(); ++k) { int hi = bones[k]; int bi = (hi >= 0 && hi < (int)hair_bone_remap.size()) ? hair_bone_remap[hi] : -1; bones.set(k, bi >= 0 ? bi : 0); } arr[Mesh::ARRAY_BONES] = bones; combined->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arr); combined->surface_set_name(combined->get_surface_count() - 1, String("hair_") + String::num_int64(s)); } } mi->set_mesh(combined); } 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; const gr2::FileInfo *afi_ = active_fi(); if (!afi_) { return; } if (parts.empty()) { parts = mtgodot::build_parts(*afi_); } cpu_mesh = build_mesh(*afi_, parts, flip_winding, b); 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 *afi_ = active_fi(); if (!afi_) { return; } const gr2::FileInfo &fi = *afi_; // active LOD mesh data (§2.10) current_skin = skin; static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; // Skin each referenced mesh's vertices once; parts of the same mesh share them. std::vector mesh_pos(fi.meshes.size()); std::vector mesh_nrm(fi.meshes.size()); std::vector mesh_uv(fi.meshes.size()); std::vector skinned(fi.meshes.size(), false); AABB animated_bounds; bool have_animated_bounds = false; auto skin_mesh = [&](int midx) { if (skinned[midx]) { return; } skinned[midx] = true; const gr2::Mesh &m = fi.meshes[midx]; const int nv = (int)m.vertices.size(); mesh_pos[midx].resize(nv); mesh_nrm[midx].resize(nv); mesh_uv[midx].resize(nv); Vector3 *pw = mesh_pos[midx].ptrw(); Vector3 *nw = mesh_nrm[midx].ptrw(); Vector2 *uw = mesh_uv[midx].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]); if (std::isfinite(p[0]) && std::isfinite(p[1]) && std::isfinite(p[2])) { if (have_animated_bounds) { animated_bounds = animated_bounds.expand(pw[i]); } else { animated_bounds = AABB(pw[i], Vector3()); have_animated_bounds = true; } } nw[i] = Vector3(nn[0], nn[1], nn[2]); uw[i] = Vector2(v.uv0[0], v.uv0[1]); } }; cpu_mesh->clear_surfaces(); for (const mtgodot::RenderPart &rp : parts) { skin_mesh(rp.mesh); const gr2::Mesh &m = fi.meshes[rp.mesh]; const uint32_t ib = rp.idx_first; const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size()) ? rp.idx_count : (uint32_t)m.indices.size() - ib; PackedInt32Array idx; idx.resize((int)ic); int32_t *iw = idx.ptrw(); for (uint32_t t = 0; t < ic; ++t) { iw[t] = (int)m.indices[ib + t]; } Array arrays; arrays.resize(Mesh::ARRAY_MAX); arrays[Mesh::ARRAY_VERTEX] = mesh_pos[rp.mesh]; arrays[Mesh::ARRAY_NORMAL] = mesh_nrm[rp.mesh]; arrays[Mesh::ARRAY_TEX_UV] = mesh_uv[rp.mesh]; arrays[Mesh::ARRAY_INDEX] = idx; cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays); } const int body_surfaces = cpu_mesh->get_surface_count(); // --- hair pass: skin the hair meshes with the base skeleton (bones remapped by name) --- int hair_surfaces = 0; if (hair_file && *hair_file && !hair_parts.empty()) { const gr2::FileInfo &hfi = (**hair_file).file_info(); std::vector hp(hfi.meshes.size()), hn(hfi.meshes.size()); std::vector hu(hfi.meshes.size()); std::vector hs(hfi.meshes.size(), false); auto skin_hair = [&](int midx) { if (hs[midx]) return; hs[midx] = true; const gr2::Mesh &m = hfi.meshes[midx]; const int nv = (int)m.vertices.size(); hp[midx].resize(nv); hn[midx].resize(nv); hu[midx].resize(nv); Vector3 *pw = hp[midx].ptrw(); Vector3 *nw = hn[midx].ptrw(); Vector2 *uw = hu[midx].ptrw(); auto bone_of = [&](int slot) -> const gr2::Mat4 & { if (slot < 0 || slot >= (int)m.bone_bindings.size()) return I; int hb = m.bone_bindings[slot]; // hair-skel index int bb = (hb >= 0 && hb < (int)hair_bone_remap.size()) ? hair_bone_remap[hb] : -1; return (bb < 0 || bb >= (int)skin.size()) ? I : skin[bb]; }; for (int i = 0; i < nv; ++i) { const gr2::Vertex &v = m.vertices[i]; float sw = 0, wq[4]; 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 (const mtgodot::RenderPart &rp : hair_parts) { if (rp.mesh < 0 || rp.mesh >= (int)hfi.meshes.size()) continue; skin_hair(rp.mesh); const gr2::Mesh &m = hfi.meshes[rp.mesh]; const uint32_t ib = rp.idx_first; const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size()) ? rp.idx_count : (uint32_t)m.indices.size() - ib; PackedInt32Array idx; idx.resize((int)ic); int32_t *iw = idx.ptrw(); for (uint32_t t = 0; t < ic; ++t) iw[t] = (int)m.indices[ib + t]; Array a; a.resize(Mesh::ARRAY_MAX); a[Mesh::ARRAY_VERTEX] = hp[rp.mesh]; a[Mesh::ARRAY_NORMAL] = hn[rp.mesh]; a[Mesh::ARRAY_TEX_UV] = hu[rp.mesh]; a[Mesh::ARRAY_INDEX] = idx; cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, a); ++hair_surfaces; } } // clear_surfaces() dropped the override materials — just re-assign the cached // ones. surf_mats is sized to the full surface count (body + folded hair) by // _apply_materials(); here we only rebind the body slots, so `>=` is the // right test. Using `==` made this fall through to _apply_materials() EVERY // frame whenever hair was present (surf_mats bigger than body_surfaces) — // reloading every DDS per frame, stalling the frame loop (missed PONGs -> // server disconnect) and thrashing the mesh. if (body_surfaces > 0 && (int)surf_mats.size() >= body_surfaces) { for (int s = 0; s < body_surfaces; ++s) mi->set_surface_override_material(s, surf_mats[s]); } else { _apply_materials(); } for (int s = 0; s < hair_surfaces; ++s) mi->set_surface_override_material(body_surfaces + s, hair_mat); if (mi && have_animated_bounds) { mi->set_custom_aabb(animated_bounds.grow(0.01)); } } void Metin2Model::enable_gpu_skin(bool on) { if (!on || gpu_skin_active || !mi || !file || !*file) { return; } const gr2::FileInfo &fi = (*file)->file_info(); const int nbones = fi.skeletons.empty() ? 0 : (int)fi.skeletons[0].bones.size(); if (nbones == 0) { return; } bones_img = godot::Image::create_empty(3, nbones, false, godot::Image::FORMAT_RGBAF); bones_tex = ImageTexture::create_from_image(bones_img); // §2.2: fold the attached hair into the GPU mesh (bind pose, bone indices // remapped to the base skeleton). No shader change — SRC_SKIN + the shared // bones_tex deform it. No hair -> mesh unchanged. gpu_base_surf = mi->get_mesh().is_valid() ? mi->get_mesh()->get_surface_count() : 0; if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { _build_gpu_mesh(); } mi->set_skeleton_path(NodePath()); // no Skeleton3D skinning — the shader does it mi->set_skin(Ref()); if (skel) { skel->set_show_rest_only(true); } // The mesh AABB is bind-pose; the shader moves verts, so widen the cull box // (gr2-skin-space units, ~100/m) or arms-up poses get frustum/shadow-culled. mi->set_custom_aabb(AABB(Vector3(-400, -400, -400), Vector3(800, 800, 800))); gpu_skin_active = true; _apply_materials(); // rebuild surface materials with the skinned shader + bones_tex UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones)); } void Metin2Model::gpu_skin(const std::vector &skin) { if (!gpu_skin_active || bones_img.is_null()) { return; } current_skin = skin; const int nbones = bones_img->get_height(); PackedByteArray buf; buf.resize(nbones * 3 * 4 * 4); // rows * 3 texels * RGBA * float float *f = reinterpret_cast(buf.ptrw()); static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; for (int b = 0; b < nbones; ++b) { const gr2::Mat4 &m = (b < (int)skin.size()) ? skin[b] : I; float *r = f + b * 12; // texel j = column j of the row-vector skin matrix ( [pos 1] * M ) r[0] = m[0]; r[1] = m[4]; r[2] = m[8]; r[3] = m[12]; r[4] = m[1]; r[5] = m[5]; r[6] = m[9]; r[7] = m[13]; r[8] = m[2]; r[9] = m[6]; r[10] = m[10]; r[11] = m[14]; } bones_img->set_data(3, nbones, false, godot::Image::FORMAT_RGBAF, buf); bones_tex->update(bones_img); } String Metin2Model::get_info() const { return last_info; } String Metin2Model::probe_gr2(const String &path) const { gr2::LoadError err; auto f = mtgodot::gr2_from_file(path, &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