Files
mtgodot-poc/extension/src/metin2_model.cpp
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

1771 lines
59 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "metin2_model.h"
#include "asset_io.h"
#include "dxt.h"
#include "gr2_bridge.h"
#include "m2_material.h"
#include "texture_util.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/base_material3d.hpp>
#include <godot_cpp/classes/camera3d.hpp>
#include <godot_cpp/classes/dir_access.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/skeleton3d.hpp>
#include <godot_cpp/classes/skin.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/viewport.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <msm.h>
#include <cmath>
#include <string>
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<gr2::MaterialInfo> &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<String, Ref<ImageTexture>> s_model_tex_cache;
static HashMap<String, PackedStringArray> 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<std::optional<gr2::File>>();
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::optional<gr2::File>>(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<Ref<Material>> &mats, float v) {
for (const Ref<Material> &m : mats) {
Ref<ShaderMaterial> 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<Ref<Material>> ghost_mats;
// A frozen GPU ghost must not share the live, updated bone texture.
Ref<ImageTexture> ghost_bones;
if (gpu_skin_active && bones_img.is_valid()) {
Ref<godot::Image> 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<Material> src = mi->get_active_material(s);
Ref<Material> dup;
if (src.is_valid()) {
dup = src->duplicate();
Ref<ShaderMaterial> 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<ArrayMesh> 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<Node> kids = get_children();
for (int i = 0; i < kids.size(); ++i) {
Node *n = Object::cast_to<Node>(kids[i]);
if (n) {
remove_child(n);
n->queue_free();
}
}
}
void Metin2Model::reload() {
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<gr2::Mat4> 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<ArrayMesh> 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<double>(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<std::optional<gr2::File>>();
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 贴图:同名 .ddsalpha-scissor 镂空
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
Ref<ImageTexture> 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<ShaderMaterial> 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<std::optional<gr2::File>> &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<std::optional<gr2::File>>();
*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<mtgodot::RenderPart> wparts = mtgodot::build_parts(wfi);
AABB wb;
Ref<ArrayMesh> 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<DirAccess> 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<ImageTexture> {
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<ImageTexture>();
};
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<gr2::MaterialInfo> 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<ImageTexture> tex = find_wdds(want_base);
if (tex.is_valid())
++wtextured;
Ref<Material> mat;
if (material_mode == String("standard")) {
Ref<StandardMaterial3D> 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<gr2::Mat4> 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<gr2::Mat4> &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
// "<pack>/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<godot::Texture2D> 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<DirAccess> 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<godot::Texture2D>();
}
Ref<godot::Image> img;
img.instantiate();
if (img->load(hit) != OK) {
UtilityFunctions::push_warning("[Metin2Model] failed to decode " + hit);
return Ref<godot::Texture2D>();
}
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 "<level>/PC/ymir work/<tail>" and "<level>/ymir work/<tail>".
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<DirAccess> 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/<pack>/ymir work/<tail>). Scan the asset root's subdirs.
if (!assets_root.is_empty()) {
Ref<DirAccess> 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<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
Ref<ImageTexture> 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<godot::ImageTexture> 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<ImageTexture> {
for (int i = 0; i < dds_files.size(); ++i) {
if (dds_files[i].to_lower().find(needle) != -1) {
Ref<ImageTexture> t = _load_dds(dir.path_join(dds_files[i]));
if (t.is_valid()) {
return t;
}
}
}
return Ref<ImageTexture>();
};
// 1. surface-name keyword slots
if (sn.find("face") != -1) {
Ref<ImageTexture> 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<ImageTexture> 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<ImageTexture> t = _load_dds(dir.path_join(surface_name + String(".dds")));
if (t.is_valid()) {
return t;
}
}
// 3. exact stem
Ref<ImageTexture> 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<ImageTexture> any = _load_dds(dir.path_join(dds_files[i]));
if (any.is_valid()) {
return any;
}
}
}
return Ref<ImageTexture>();
}
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<gr2::MaterialInfo> &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<DirAccess> 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<ImageTexture> {
if (want.is_empty()) {
return Ref<ImageTexture>();
}
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<ImageTexture> shared = _load_dds(resolved);
if (shared.is_valid()) {
return shared;
}
}
return Ref<ImageTexture>();
};
Ref<ArrayMesh> mesh = mi->get_mesh();
int textured = 0;
const int nsurf = mesh->get_surface_count();
surf_mats.assign(nsurf, Ref<Material>());
// §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<godot::Texture2D> smap =
(specular_power > 0.0 && material_mode != String("standard")) ? _load_sphere_map()
: Ref<godot::Texture2D>();
for (int s = 0; s < nsurf; ++s) {
const String sname = mesh->surface_get_name(s);
Ref<ImageTexture> 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<Material> 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<Material> mat;
if (material_mode == String("standard")) {
Ref<StandardMaterial3D> 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<ArrayMesh> 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<ArrayMesh> 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<Skin>());
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<gr2::Mat4> &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<PackedVector3Array> mesh_pos(fi.meshes.size());
std::vector<PackedVector3Array> mesh_nrm(fi.meshes.size());
std::vector<PackedVector2Array> mesh_uv(fi.meshes.size());
std::vector<bool> 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<PackedVector3Array> hp(hfi.meshes.size()), hn(hfi.meshes.size());
std::vector<PackedVector2Array> hu(hfi.meshes.size());
std::vector<bool> 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<gr2::Mat4> &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<int> *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<Skin>());
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<gr2::Mat4> &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<float *>(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