#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); static HashMap> s_model_tex_cache; static HashMap s_model_dir_dds_cache; static bool model_diagnostics() { static int diag = -1; if (diag == -1) { diag = (OS::get_singleton()->get_environment("MTGODOT_MODEL_DIAGNOSTICS") == "1") ? 1 : 0; } return diag == 1; } void Metin2Model::clear_texture_cache() { s_model_tex_cache.clear(); s_model_dir_dds_cache.clear(); } 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("set_skin_texture", "source", "target"), &Metin2Model::set_skin_texture); ClassDB::bind_method(D_METHOD("clear_skin_textures"), &Metin2Model::clear_skin_textures); ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload); ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info); ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb); ClassDB::bind_method(D_METHOD("get_fly_target_bounds"), &Metin2Model::get_fly_target_bounds); ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset); 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); ClassDB::bind_static_method("Metin2Model", D_METHOD("clear_texture_cache"), &Metin2Model::clear_texture_cache); // A renderer consumer may have framed the model before an animation switches // it from the CPU mesh to GPU skinning. That switch updates the effective // AABB, so expose a small notification instead of making each scene guess a // number of frames to wait. ADD_SIGNAL(MethodInfo("visual_bounds_changed")); 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_skin_texture(const String &source, const String &target) { const String key = source.replace("\\", "/").get_file().get_basename().to_lower(); if (key.is_empty()) { return; } if (target.is_empty()) { skin_tex_override.erase(key); } else { skin_tex_override[key] = target; } if (is_inside_tree() && mi) { _apply_materials(); } } void Metin2Model::clear_skin_textures() { skin_tex_override.clear(); if (is_inside_tree() && mi) { _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()) { if (model_diagnostics()) { 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_layer_mask(1u << 1); lod_prev_mi->set_mesh(mi->get_mesh()); lod_prev_mi->set_custom_aabb(mi->get_custom_aabb()); add_child(lod_prev_mi); lod_prev_mi->set_transform(mi->get_transform()); std::vector> ghost_mats; // A frozen GPU ghost must not share the live, updated bone texture. Ref ghost_bones; if (gpu_skin_active && bones_img.is_valid()) { Ref snapshot = bones_img->duplicate(); ghost_bones = ImageTexture::create_from_image(snapshot); } for (int s = 0; s < mi->get_mesh()->get_surface_count(); ++s) { // CPU hair overrides are not necessarily present in surf_mats. Ref src = mi->get_active_material(s); Ref dup; if (src.is_valid()) { dup = src->duplicate(); Ref shader = dup; if (shader.is_valid() && ghost_bones.is_valid()) { shader->set_shader_parameter("bones_tex", ghost_bones); } } 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 (gpu_skin_active) { // Keep attachments and the body/hair material boundary across LODs. _build_gpu_mesh(); gpu_visual_bounds_ready = false; } else 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 (gpu_skin_active && !current_skin.empty()) { gpu_skin(current_skin); } 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() { current_world_pose.clear(); fly_target_bounds_valid = false; _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; gpu_visual_bounds_ready = false; have_visual_bounds = 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); // build_mesh keeps the source GR2 coordinate frame (Z-up, centimetres); // make_conv() below maps it to Godot Y-up and applies unit_scale. Keep the // actor root on the terrain while translating only the visual subtree. ground_offset = -static_cast(bounds.position.z) * unit_scale; if (mesh->get_surface_count() > 0) { mi = memnew(MeshInstance3D); mi->set_name("MeshInstance3D"); mi->set_layer_mask(1u << 1); // CharacterLight visibility layer. 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)); visual_bounds = bounds; have_visual_bounds = true; } materials = gr2::dump_materials(**file); _apply_materials(); int vtot = 0; for (const auto &m : fi.meshes) { vtot += (int)m.vertices.size(); } if (model_diagnostics()) { 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)); if (model_diagnostics()) { 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 镂空 // 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 } // R2-02: the same forward material the body/weapon surfaces use, not a // StandardMaterial3D. The old one ran Godot's PBR specular at roughness 1.0 // and washed the fringe out into flat cream patches (bisection: roughness 0 // or unshaded clean, alpha/cull/metallic changes not). make_material's // shaders are `specular_disabled`, so the hair now lights like the body. // CPU path: skinning already happened on the CPU, so skinned = false. mtgodot::MaterialDesc hd; hd.albedo = ht; hd.blend = mtgodot::BlendMode::AlphaTest; // discard < 0.5, still depth-opaque hd.alpha_scissor = 0.5f; hd.two_sided = true; // hair cards are single-sided geometry Ref hm = mtgodot::make_material(hd); if (ht.is_null()) // keep the untextured fallback colour (make_material's is pale grey) hm->set_shader_parameter("modulate", Color(0.18f, 0.12f, 0.08f, 1.0f)); 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())); } void Metin2Model::_refresh_hair_mesh() { if (gpu_skin_active) { _build_gpu_mesh(); _apply_materials(); gpu_visual_bounds_ready = false; if (!current_skin.empty()) gpu_skin(current_skin); } else 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(); _refresh_hair_mesh(); } } 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(); _refresh_hair_mesh(); } } 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 Granny's rigid-mesh composite in the client: // weaponWorld[0] = weaponLocal[0] · handWorld // weaponComposite[0] = weaponInverseWorld[0] · weaponWorld[0] // The explicit parent matrix passed to GrannyBuildWorldPose replaces the // weapon model's InitialPlacement; multiplying it here would cancel 03150's // inverse-bind correction. See ModelInstanceUpdate.cpp:148. // Do not clamp a large composite translation: files such as 03150 also store // their raw vertices metres away from the skeleton origin, and this authored // inverse-bind transform is precisely what moves the grip back onto the hand. if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) { const gr2::Bone &wb0 = wfi.skeletons[0].bones[0]; slot_pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform); } 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_layer_mask(1u << 1); // equipment follows the character light. 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) { current_world_pose = 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]))); } namespace { inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o); } Dictionary Metin2Model::get_fly_target_bounds() { // Legacy caches its sphere until the model is reset. Do not let particles, // hair, weapons, LOD ghosts or subsequent animation frames shift the centre. if (!fly_target_bounds_valid) { const gr2::FileInfo *fi = active_fi(); bool found = false; AABB bounds; if (fi) for (const auto &mesh : fi->meshes) { for (size_t slot = 0; slot < mesh.bone_bounds.size(); ++slot) { const auto &obb = mesh.bone_bounds[slot]; const int bone = slot < mesh.bone_bindings.size() ? mesh.bone_bindings[slot] : -1; if (!obb.valid || bone < 0 || bone >= (int)current_world_pose.size()) continue; for (int corner = 0; corner < 8; ++corner) { float point[3], transformed[3]; for (int axis = 0; axis < 3; ++axis) point[axis] = (corner & (1 << axis)) ? obb.max[axis] : obb.min[axis]; xform_pt(current_world_pose[bone], point, transformed); const Vector3 p(transformed[0], transformed[1], transformed[2]); bounds = found ? bounds.expand(p) : AABB(p, Vector3()); found = true; } } } if (found) { fly_target_bounds = bounds; fly_target_bounds_valid = true; } } Dictionary result; result["valid"] = fly_target_bounds_valid; result["bounds"] = fly_target_bounds; return result; } 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 (s_model_tex_cache.has(path)) { return s_model_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); } } s_model_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; if (s_model_dir_dds_cache.has(dir)) { dds_files = s_model_dir_dds_cache[dir]; } else { 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(); } s_model_dir_dds_cache[dir] = dds_files; } // 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])); } } // Expansion armors share face textures with the base PC pack. // Resolve that exact name before falling back to unrelated local DDS. String cls = gr2_path.get_base_dir().get_file(); for (const String &pc : { String("pc"), String("pc2") }) { String spec = "d:/ymir work/" + pc + "/" + cls + "/" + want + ".dds"; String resolved = resolve_rel_gr2(gr2_path, spec); Ref shared = _load_dds(resolved); if (shared.is_valid()) { return shared; } } 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.two_sided = true; // Match CPU hair cards when viewed from behind. 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]); } // `.msm` SourceSkin -> TargetSkin remaps are material-name based in the // 40250 client. An armour can have face/body/effect surfaces in a // different order, so applying TargetSkin to hard-coded surface 0 gives // the right geometry with the wrong (usually base black) appearance. if (tex.is_null() && skin_tex_override.has(mat_want)) { tex = _load_dds(String(skin_tex_override[mat_want])); } // 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); } if (model_diagnostics()) { 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); } if (model_diagnostics()) { 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]; } // Match build_mesh(): CPU animation must preserve the configured winding. if (flip_winding) for (uint32_t t = 0; t + 2 < ic; t += 3) std::swap(iw[t + 1], iw[t + 2]); 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]); 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]); } }; 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]; if (flip_winding) for (uint32_t t = 0; t + 2 < ic; t += 3) std::swap(iw[t + 1], iw[t + 2]); 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)); visual_bounds = animated_bounds; have_visual_bounds = true; } } bool Metin2Model::_update_skinned_bounds(const std::vector &skin) { const gr2::FileInfo *afi = active_fi(); if (!afi) { return false; } static const gr2::Mat4 I{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; AABB bounds; bool have_bounds = false; auto expand_mesh = [&](const gr2::FileInfo &fi, const std::vector *remap) { for (const gr2::Mesh &m : fi.meshes) { for (const gr2::Vertex &v : m.vertices) { gr2::Mat4 blend{}; float sum = 0.0f; for (int k = 0; k < 4; ++k) sum += v.bone_weight[k]; for (int k = 0; k < 4; ++k) { const float w = sum > 0.0f ? v.bone_weight[k] / sum : (k == 0 ? 1.0f : 0.0f); if (w == 0.0f) continue; int bone = (v.bone_index[k] >= 0 && v.bone_index[k] < (int)m.bone_bindings.size()) ? m.bone_bindings[v.bone_index[k]] : -1; if (remap && bone >= 0 && bone < (int)remap->size()) bone = (*remap)[bone]; const gr2::Mat4 &mat = bone >= 0 && bone < (int)skin.size() ? skin[bone] : I; for (int e = 0; e < 16; ++e) blend[e] += w * mat[e]; } float p[3]; xform_pt(blend, v.pos, p); if (!std::isfinite(p[0]) || !std::isfinite(p[1]) || !std::isfinite(p[2])) continue; const Vector3 point(p[0], p[1], p[2]); bounds = have_bounds ? bounds.expand(point) : AABB(point, Vector3()); have_bounds = true; } } }; expand_mesh(*afi, nullptr); if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) { expand_mesh((**hair_file).file_info(), &hair_bone_remap); } if (have_bounds) { visual_bounds = bounds; have_visual_bounds = true; if (mi) mi->set_custom_aabb(bounds.grow(0.01)); } return have_bounds; } 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); } // gpu_skin() supplies the first actual posed bounds once the animation // sampler has produced its skin matrices. gpu_skin_active = true; _apply_materials(); // rebuild surface materials with the skinned shader + bones_tex if (model_diagnostics()) { 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; // Culling must follow every pose, including offscreen actors and one-shots. // Only the initial ready notification reframes selection cameras; emitting // it on every pose would make the camera follow the breathing animation. const bool bounds_updated = _update_skinned_bounds(skin); if (!gpu_visual_bounds_ready && bounds_updated) { gpu_visual_bounds_ready = true; emit_signal("visual_bounds_changed"); } 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; } AABB Metin2Model::get_visual_aabb() { // Both animation paths publish bounds when sampling, before camera queries. if (have_visual_bounds) { return visual_bounds; } return mi && mi->get_mesh().is_valid() ? mi->get_mesh()->get_aabb() : AABB(); } 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