Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
1533 lines
50 KiB
C++
1533 lines
50 KiB
C++
#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);
|
||
|
||
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("reload"), &Metin2Model::reload);
|
||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||
ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options);
|
||
ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2);
|
||
ClassDB::bind_method(D_METHOD("get_hair_gr2"), &Metin2Model::get_hair_gr2);
|
||
ClassDB::bind_method(D_METHOD("set_hair_skin", "p"), &Metin2Model::set_hair_skin);
|
||
ClassDB::bind_method(D_METHOD("get_hair_skin"), &Metin2Model::get_hair_skin);
|
||
ClassDB::bind_method(D_METHOD("set_weapon_gr2", "p"), &Metin2Model::set_weapon_gr2);
|
||
ClassDB::bind_method(D_METHOD("get_weapon_gr2"), &Metin2Model::get_weapon_gr2);
|
||
ClassDB::bind_method(D_METHOD("set_weapon_bone", "b"), &Metin2Model::set_weapon_bone);
|
||
ClassDB::bind_method(D_METHOD("get_weapon_bone"), &Metin2Model::get_weapon_bone);
|
||
ClassDB::bind_method(D_METHOD("set_shield_gr2", "p"), &Metin2Model::set_shield_gr2);
|
||
ClassDB::bind_method(D_METHOD("get_shield_gr2"), &Metin2Model::get_shield_gr2);
|
||
ClassDB::bind_method(D_METHOD("set_shield_bone", "b"), &Metin2Model::set_shield_bone);
|
||
ClassDB::bind_method(D_METHOD("get_shield_bone"), &Metin2Model::get_shield_bone);
|
||
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
||
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "gr2_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msm"),
|
||
"set_gr2_path", "get_gr2_path");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "texture_dir", PROPERTY_HINT_GLOBAL_DIR),
|
||
"set_texture_dir", "get_texture_dir");
|
||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "unit_scale", PROPERTY_HINT_RANGE, "0.0001,1,0.0001"),
|
||
"set_unit_scale", "get_unit_scale");
|
||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_z"), "set_flip_z", "get_flip_z");
|
||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_winding"), "set_flip_winding", "get_flip_winding");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "material_mode", PROPERTY_HINT_ENUM, "metin2,standard"),
|
||
"set_material_mode", "get_material_mode");
|
||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_gr2_materials"),
|
||
"set_use_gr2_materials", "get_use_gr2_materials");
|
||
ClassDB::bind_method(D_METHOD("set_lod_enabled", "v"), &Metin2Model::set_lod_enabled);
|
||
ClassDB::bind_method(D_METHOD("get_lod_enabled"), &Metin2Model::get_lod_enabled);
|
||
ClassDB::bind_method(D_METHOD("set_lod_distances", "d"), &Metin2Model::set_lod_distances);
|
||
ClassDB::bind_method(D_METHOD("get_lod_distances"), &Metin2Model::get_lod_distances);
|
||
ClassDB::bind_method(D_METHOD("get_lod_level"), &Metin2Model::get_lod_level);
|
||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lod_enabled"), "set_lod_enabled", "get_lod_enabled");
|
||
ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "lod_distances"),
|
||
"set_lod_distances", "get_lod_distances");
|
||
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "specular_power", PROPERTY_HINT_RANGE, "0,4,0.01"),
|
||
"set_specular_power", "get_specular_power");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
|
||
"set_hair_gr2", "get_hair_gr2");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "hair_skin", PROPERTY_HINT_GLOBAL_FILE, "*.dds"),
|
||
"set_hair_skin", "get_hair_skin");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
|
||
"set_weapon_gr2", "get_weapon_gr2");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "weapon_bone"), "set_weapon_bone", "get_weapon_bone");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_gr2", PROPERTY_HINT_GLOBAL_FILE, "*.gr2"),
|
||
"set_shield_gr2", "get_shield_gr2");
|
||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "shield_bone"), "set_shield_bone", "get_shield_bone");
|
||
}
|
||
|
||
void Metin2Model::set_material_mode(const String &m) {
|
||
material_mode = m;
|
||
if (is_inside_tree()) {
|
||
reload();
|
||
}
|
||
}
|
||
|
||
void Metin2Model::set_specular_power(double p) {
|
||
specular_power = p < 0.0 ? 0.0 : p;
|
||
if (is_inside_tree() && mi) {
|
||
_apply_materials();
|
||
}
|
||
}
|
||
|
||
void Metin2Model::set_use_gr2_materials(bool v) {
|
||
use_gr2_materials = v;
|
||
if (is_inside_tree()) {
|
||
_apply_materials();
|
||
}
|
||
}
|
||
|
||
void Metin2Model::set_surface_texture(int surface, const String &path) {
|
||
if (surface < 0) {
|
||
return;
|
||
}
|
||
while (surface_tex_override.size() <= surface) {
|
||
surface_tex_override.push_back(String());
|
||
}
|
||
surface_tex_override[surface] = path;
|
||
if (is_inside_tree()) {
|
||
_apply_materials();
|
||
}
|
||
}
|
||
|
||
void Metin2Model::set_gr2_path(const String &p) {
|
||
gr2_path = p;
|
||
if (is_inside_tree()) {
|
||
reload();
|
||
}
|
||
}
|
||
void Metin2Model::set_texture_dir(const String &p) {
|
||
texture_dir = p;
|
||
if (is_inside_tree()) {
|
||
reload();
|
||
}
|
||
}
|
||
void Metin2Model::set_unit_scale(double s) {
|
||
unit_scale = s;
|
||
if (is_inside_tree()) {
|
||
reload();
|
||
}
|
||
}
|
||
void Metin2Model::set_flip_z(bool v) {
|
||
flip_z = v;
|
||
if (is_inside_tree()) {
|
||
reload();
|
||
}
|
||
}
|
||
void Metin2Model::set_flip_winding(bool v) {
|
||
flip_winding = v;
|
||
if (is_inside_tree()) {
|
||
reload();
|
||
}
|
||
}
|
||
|
||
void Metin2Model::_ready() {
|
||
set_process(true);
|
||
if (!gr2_path.is_empty()) {
|
||
reload();
|
||
}
|
||
}
|
||
|
||
void Metin2Model::set_lod_enabled(bool v) {
|
||
lod_enabled = v;
|
||
if (!v && lod_level != 0) {
|
||
_set_lod(0);
|
||
}
|
||
}
|
||
|
||
void Metin2Model::set_lod_distances(const PackedFloat32Array &d) {
|
||
lod_dist = d;
|
||
}
|
||
|
||
const gr2::FileInfo *Metin2Model::active_fi() const {
|
||
if (lod_level >= 1 && lod_level <= (int)lod_files.size() && lod_files[lod_level - 1] &&
|
||
*lod_files[lod_level - 1]) {
|
||
return &(**lod_files[lod_level - 1]).file_info();
|
||
}
|
||
return (file && *file) ? &(*file)->file_info() : nullptr;
|
||
}
|
||
|
||
void Metin2Model::_load_lods(const String &loaded_spec) {
|
||
lod_files.clear();
|
||
lod_parts.clear();
|
||
String stem = loaded_spec.get_basename(); // ".../warrior_cheongrin"
|
||
for (int i = 1; i <= 3; ++i) {
|
||
String lp = stem + vformat("_lod_%02d.gr2", i);
|
||
if (!FileAccess::file_exists(lp)) {
|
||
break;
|
||
}
|
||
gr2::LoadError e;
|
||
auto f = mtgodot::gr2_from_file(lp, &e);
|
||
if (!f) {
|
||
UtilityFunctions::push_warning(String("[Metin2Model] lod load failed: ") + lp);
|
||
break;
|
||
}
|
||
auto slot = std::make_shared<std::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()) {
|
||
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_mesh(mi->get_mesh());
|
||
add_child(lod_prev_mi);
|
||
lod_prev_mi->set_transform(mi->get_transform());
|
||
std::vector<Ref<Material>> ghost_mats;
|
||
for (int s = 0; s < (int)surf_mats.size(); ++s) {
|
||
Ref<Material> src = surf_mats[s];
|
||
Ref<Material> dup;
|
||
if (src.is_valid()) {
|
||
dup = src->duplicate();
|
||
}
|
||
lod_prev_mi->set_surface_override_material(s, dup);
|
||
ghost_mats.push_back(dup);
|
||
}
|
||
set_lod_fade(ghost_mats, 1.0f);
|
||
lod_ghost_mats = std::move(ghost_mats);
|
||
lod_fade_t = 0.0;
|
||
}
|
||
|
||
lod_level = n;
|
||
parts = (n == 0) ? base_parts : lod_parts[n - 1];
|
||
const gr2::FileInfo *afi = active_fi();
|
||
if (!afi) {
|
||
_end_lod_fade();
|
||
return;
|
||
}
|
||
if (cpu_mesh.is_valid()) {
|
||
AABB b;
|
||
cpu_mesh = build_mesh(*afi, parts, flip_winding, b);
|
||
mi->set_mesh(cpu_mesh);
|
||
} else {
|
||
AABB b;
|
||
Ref<ArrayMesh> m = build_mesh(*afi, parts, flip_winding, b);
|
||
if (m->get_surface_count() > 0) {
|
||
mi->set_mesh(m);
|
||
}
|
||
}
|
||
_apply_materials();
|
||
if (cpu_mesh.is_valid() && !current_skin.empty()) {
|
||
cpu_skin(current_skin);
|
||
}
|
||
if (lod_fade_t >= 0.0) {
|
||
set_lod_fade(surf_mats, 0.0f); // new level starts invisible, fades in
|
||
}
|
||
}
|
||
|
||
void Metin2Model::_process(double delta) {
|
||
// LOD crossfade progression
|
||
if (lod_fade_t >= 0.0) {
|
||
lod_fade_t += delta;
|
||
float k = (float)std::min(1.0, lod_fade_t / kLodFadeDur);
|
||
set_lod_fade(surf_mats, k);
|
||
set_lod_fade(lod_ghost_mats, 1.0f - k);
|
||
if (k >= 1.0f) {
|
||
_end_lod_fade();
|
||
lod_ghost_mats.clear();
|
||
}
|
||
}
|
||
|
||
if (!lod_enabled || lod_files.empty() || !mi || lod_dist.is_empty()) {
|
||
return;
|
||
}
|
||
Viewport *vp = get_viewport();
|
||
Camera3D *cam = vp ? vp->get_camera_3d() : nullptr;
|
||
if (!cam) {
|
||
return;
|
||
}
|
||
float d = get_global_position().distance_to(cam->get_global_position());
|
||
// hysteresis: step up at lod_dist[i], step down only 15% closer
|
||
int lv = 0;
|
||
for (int i = 0; i < (int)lod_dist.size() && i < 3; ++i) {
|
||
float up = lod_dist[i];
|
||
float down = up * 0.85f;
|
||
if (d > up) {
|
||
lv = i + 1;
|
||
} else if (d > down && lv == i && lod_level >= i + 1) {
|
||
lv = i + 1; // stay in the higher LOD across the dead-band
|
||
}
|
||
}
|
||
_set_lod(lv);
|
||
}
|
||
|
||
void Metin2Model::_clear_children() {
|
||
skel = nullptr;
|
||
mi = nullptr;
|
||
weapon_mi = nullptr;
|
||
weapon_bone_idx = -1;
|
||
shield_mi = nullptr;
|
||
shield_bone_idx = -1;
|
||
TypedArray<Node> kids = get_children();
|
||
for (int i = 0; i < kids.size(); ++i) {
|
||
Node *n = Object::cast_to<Node>(kids[i]);
|
||
if (n) {
|
||
remove_child(n);
|
||
n->queue_free();
|
||
}
|
||
}
|
||
}
|
||
|
||
void Metin2Model::reload() {
|
||
_clear_children();
|
||
*file = std::nullopt;
|
||
last_info = "";
|
||
resolved_gr2_dir = "";
|
||
hair_options.clear();
|
||
if (hair_file)
|
||
*hair_file = std::nullopt;
|
||
hair_parts.clear();
|
||
hair_bone_remap.clear();
|
||
hair_mat.unref();
|
||
hair_tex.unref();
|
||
gpu_base_surf = 0;
|
||
if (weapon_file)
|
||
*weapon_file = std::nullopt;
|
||
if (shield_file)
|
||
*shield_file = std::nullopt;
|
||
materials.clear();
|
||
parts.clear();
|
||
base_parts.clear();
|
||
lod_files.clear();
|
||
lod_parts.clear();
|
||
lod_level = 0;
|
||
surf_mats.clear();
|
||
cpu_mesh.unref();
|
||
gpu_skin_active = false;
|
||
bones_img.unref();
|
||
bones_tex.unref();
|
||
current_skin.clear();
|
||
|
||
if (gr2_path.is_empty()) {
|
||
return;
|
||
}
|
||
|
||
// .msm -> resolve BaseModelFileName; stash the hair catalog for get_hair_options().
|
||
String gr2_spec = gr2_path;
|
||
if (gr2_path.to_lower().ends_with(".msm")) {
|
||
fmt::Msm ms;
|
||
std::string e;
|
||
if (fmt::parse_msm_file(std::string(gr2_path.utf8().get_data()), ms, &e)) {
|
||
gr2_spec = resolve_rel_gr2(gr2_path, String(ms.base_model_gr2.c_str()));
|
||
const String hp = String(ms.hair_path.c_str());
|
||
for (const fmt::HairEntry &h : ms.hairs) {
|
||
Dictionary d;
|
||
d["index"] = h.index;
|
||
d["model"] = resolve_rel_gr2(gr2_path, hp + String(h.model.c_str()));
|
||
// SourceSkin / TargetSkin resolved to absolute (§2.4). TargetSkin is
|
||
// relative to PathName; SourceSkin too (the gr2's own texture).
|
||
const String ss(h.source_skin.c_str()), ts(h.target_skin.c_str());
|
||
d["source_skin"] = ss.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ss);
|
||
d["target_skin"] = ts.is_empty() ? String() : resolve_rel_gr2(gr2_path, hp + ts);
|
||
hair_options.push_back(d);
|
||
}
|
||
UtilityFunctions::print(vformat("[Metin2Model] .msm -> %s hairs=%d",
|
||
gr2_spec, (int)hair_options.size()));
|
||
} else {
|
||
UtilityFunctions::push_error(String("[Metin2Model] .msm: ") + String(e.c_str()));
|
||
return;
|
||
}
|
||
}
|
||
|
||
gr2::LoadError err;
|
||
auto loaded = mtgodot::gr2_from_file(gr2_spec, &err);
|
||
if (!loaded) {
|
||
UtilityFunctions::push_error(String("[Metin2Model] gr2 load failed [") +
|
||
String(err.stage.c_str()) + "]: " + String(err.message.c_str()) + " (" + gr2_spec + ")");
|
||
return;
|
||
}
|
||
*file = std::move(loaded);
|
||
resolved_gr2_dir = gr2_spec.get_base_dir();
|
||
const gr2::FileInfo &fi = (*file)->file_info();
|
||
|
||
set_transform(make_conv((float)unit_scale, flip_z));
|
||
|
||
// Skeleton (optional).
|
||
const gr2::Skeleton *sk = fi.skeletons.empty() ? nullptr : &fi.skeletons[0];
|
||
if (sk) {
|
||
std::vector<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);
|
||
if (mesh->get_surface_count() > 0) {
|
||
mi = memnew(MeshInstance3D);
|
||
mi->set_name("MeshInstance3D");
|
||
mi->set_mesh(mesh);
|
||
add_child(mi);
|
||
mi->set_owner(get_owner() ? get_owner() : this);
|
||
if (skel && sk) {
|
||
mi->set_skeleton_path(mi->get_path_to(skel));
|
||
if (OS::get_singleton()->get_environment("MTGODOT_AUTOSKIN") == "1") {
|
||
// let Godot derive the skin from skeleton rest poses
|
||
} else {
|
||
mi->set_skin(build_skin(*sk));
|
||
}
|
||
}
|
||
mi->set_custom_aabb(bounds.grow(0.01));
|
||
}
|
||
|
||
materials = gr2::dump_materials(**file);
|
||
|
||
_apply_materials();
|
||
|
||
int vtot = 0;
|
||
for (const auto &m : fi.meshes) {
|
||
vtot += (int)m.vertices.size();
|
||
}
|
||
{
|
||
int with_tex = 0;
|
||
for (const auto &mm : materials) {
|
||
if (!mm.diffuse_texture.empty()) {
|
||
++with_tex;
|
||
}
|
||
}
|
||
UtilityFunctions::print(vformat("[Metin2Model] gr2 materials: %d (%d with diffuse name)",
|
||
(int)materials.size(), with_tex));
|
||
}
|
||
last_info = vformat("gr2 v%d | bones=%d meshes=%d surfaces=%d verts=%d | bounds=%s",
|
||
(int)(*file)->format_version(),
|
||
sk ? (int)sk->bones.size() : 0,
|
||
(int)fi.meshes.size(),
|
||
mesh->get_surface_count(),
|
||
vtot,
|
||
String(bounds));
|
||
UtilityFunctions::print("[Metin2Model] ", last_info);
|
||
|
||
_load_lods(gr2_spec);
|
||
|
||
if (!hair_gr2.is_empty())
|
||
_load_hair();
|
||
if (!weapon_gr2.is_empty())
|
||
_load_weapon();
|
||
if (!shield_gr2.is_empty())
|
||
_load_shield();
|
||
}
|
||
|
||
void Metin2Model::_load_hair() {
|
||
hair_parts.clear();
|
||
hair_bone_remap.clear();
|
||
hair_mat.unref();
|
||
hair_tex.unref();
|
||
gpu_base_surf = 0;
|
||
if (hair_gr2.is_empty() || !file || !*file)
|
||
return;
|
||
const gr2::FileInfo &base_fi = (*file)->file_info();
|
||
if (base_fi.skeletons.empty())
|
||
return;
|
||
|
||
if (!hair_file)
|
||
hair_file = std::make_shared<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 贴图:同名 .dds,alpha-scissor 镂空
|
||
Ref<StandardMaterial3D> hm;
|
||
hm.instantiate();
|
||
hm->set_roughness(1.0f);
|
||
hm->set_transparency(StandardMaterial3D::TRANSPARENCY_ALPHA_SCISSOR);
|
||
hm->set_alpha_scissor_threshold(0.5f);
|
||
hm->set_cull_mode(StandardMaterial3D::CULL_DISABLED);
|
||
// SourceSkin (gr2 sibling .dds) -> TargetSkin override (§2.4)
|
||
String hdds = hair_skin.is_empty() ? (hair_gr2.get_basename() + ".dds") : hair_skin;
|
||
Ref<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
|
||
}
|
||
if (ht.is_valid())
|
||
hm->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, ht);
|
||
else
|
||
hm->set_albedo(Color(0.18f, 0.12f, 0.08f));
|
||
hair_mat = hm;
|
||
hair_tex = ht; // reused by the GPU-skin path (§2.2)
|
||
|
||
int matched = 0;
|
||
for (int r : hair_bone_remap)
|
||
if (r >= 0)
|
||
++matched;
|
||
UtilityFunctions::print(vformat("[Metin2Model] hair: %s %d/%d bones matched, %d parts",
|
||
hair_gr2.get_file(), matched, (int)hbones.size(), (int)hair_parts.size()));
|
||
|
||
if (has_cpu_skin_mesh() && !current_skin.empty())
|
||
cpu_skin(current_skin);
|
||
}
|
||
|
||
void Metin2Model::set_hair_gr2(const String &p) {
|
||
if (p == hair_gr2)
|
||
return;
|
||
hair_gr2 = p;
|
||
if (file && *file)
|
||
_load_hair();
|
||
}
|
||
|
||
void Metin2Model::set_hair_skin(const String &p) {
|
||
if (p == hair_skin)
|
||
return;
|
||
hair_skin = p;
|
||
if (file && *file && !hair_gr2.is_empty())
|
||
_load_hair();
|
||
}
|
||
|
||
void Metin2Model::set_weapon_gr2(const String &p) {
|
||
if (p == weapon_gr2)
|
||
return;
|
||
weapon_gr2 = p;
|
||
if (file && *file)
|
||
_load_weapon();
|
||
}
|
||
|
||
void Metin2Model::set_weapon_bone(const String &b) {
|
||
if (b == weapon_bone)
|
||
return;
|
||
weapon_bone = b;
|
||
if (file && *file)
|
||
_load_weapon();
|
||
}
|
||
|
||
void Metin2Model::set_shield_gr2(const String &p) {
|
||
if (p == shield_gr2)
|
||
return;
|
||
shield_gr2 = p;
|
||
if (file && *file)
|
||
_load_shield();
|
||
}
|
||
|
||
void Metin2Model::set_shield_bone(const String &b) {
|
||
if (b == shield_bone)
|
||
return;
|
||
shield_bone = b;
|
||
if (file && *file)
|
||
_load_shield();
|
||
}
|
||
|
||
void Metin2Model::_load_weapon() {
|
||
_load_attach(weapon_gr2, weapon_bone, weapon_file, weapon_mi, weapon_bone_idx, weapon_pre,
|
||
"Weapon", "weapon");
|
||
}
|
||
|
||
void Metin2Model::_load_shield() {
|
||
_load_attach(shield_gr2, shield_bone, shield_file, shield_mi, shield_bone_idx, shield_pre,
|
||
"Shield", "shield");
|
||
}
|
||
|
||
void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
|
||
std::shared_ptr<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 the client's weapon composite:
|
||
// weaponComposite[0] = weaponInverseWorld[0] · weaponLocal[0]
|
||
// weaponMeshWorld = weaponComposite[0] · pAttachBoneMatrix(handWorld) · charWorld
|
||
// (EterGrnLib/ModelInstanceUpdate.cpp:171 + GetBoneMatrixPointer).
|
||
// Most Metin2 weapons are authored directly in hand space so this pre is ~I;
|
||
// a few non-standard files carry a huge bone offset that would fling the mesh
|
||
// metres away — clamp those out and fall back to the raw hand pose.
|
||
if (!wfi.skeletons.empty() && !wfi.skeletons[0].bones.empty()) {
|
||
const gr2::Bone &wb0 = wfi.skeletons[0].bones[0];
|
||
gr2::Mat4 pre = mtgodot::mul4x3(wb0.inverse_world, wb0.local_transform);
|
||
const float tx = pre[12], ty = pre[13], tz = pre[14];
|
||
if (tx * tx + ty * ty + tz * tz < 30.0f * 30.0f) // < 30 cm
|
||
slot_pre = pre;
|
||
}
|
||
|
||
std::vector<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_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) {
|
||
// weapon local = gripPre · handWorld (client: weaponBone[0] composed onto the
|
||
// parent attach-bone matrix). gripPre is identity for near-origin weapons, so
|
||
// this is backward-compatible with the ones that already looked right.
|
||
if (weapon_mi && weapon_bone_idx >= 0 && weapon_bone_idx < (int)world_pose.size())
|
||
weapon_mi->set_transform(mtgodot::gr2_to_godot(
|
||
mtgodot::mul4x3(weapon_pre, world_pose[weapon_bone_idx])));
|
||
if (shield_mi && shield_bone_idx >= 0 && shield_bone_idx < (int)world_pose.size())
|
||
shield_mi->set_transform(mtgodot::gr2_to_godot(
|
||
mtgodot::mul4x3(shield_pre, world_pose[shield_bone_idx])));
|
||
}
|
||
|
||
String Metin2Model::_guess_texture_dir() const {
|
||
if (!texture_dir.is_empty()) {
|
||
return texture_dir;
|
||
}
|
||
return resolved_gr2_dir.is_empty() ? gr2_path.get_base_dir() : resolved_gr2_dir;
|
||
}
|
||
|
||
// Shared sphere map for §2.7 specular. Client sphere index 0 == the global
|
||
// "d:/ymir work/special/spheremap.jpg"; in the loose-file packs that lands at
|
||
// "<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 (tex_cache.has(path)) {
|
||
return tex_cache[path];
|
||
}
|
||
if (FileAccess::file_exists(path)) {
|
||
mtgodot::Image dds = mtgodot::dds_from_file(path);
|
||
if (dds.ok()) {
|
||
// Character skins are sRGB colour -> eligible for mobile ASTC
|
||
// (mtgodot::make_color_texture; no-op on desktop). §F4.
|
||
t = mtgodot::make_color_texture(dds.w, dds.h, dds.rgba.data(),
|
||
dds.rgba.size(), /*mipmaps=*/true);
|
||
}
|
||
}
|
||
tex_cache[path] = t;
|
||
return t;
|
||
}
|
||
|
||
// Best-effort texture pick for a surface. libgr2's POD API gives no material or
|
||
// texture names yet (see GODOT-POC-PLAN.md §M2.5 gap list), so this is filename
|
||
// convention + a case-insensitive directory scan.
|
||
Ref<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;
|
||
{
|
||
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();
|
||
}
|
||
}
|
||
|
||
// 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]));
|
||
}
|
||
}
|
||
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.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]);
|
||
}
|
||
// 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);
|
||
}
|
||
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);
|
||
}
|
||
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];
|
||
}
|
||
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]);
|
||
nw[i] = Vector3(nn[0], nn[1], nn[2]);
|
||
uw[i] = Vector2(v.uv0[0], v.uv0[1]);
|
||
}
|
||
};
|
||
for (const mtgodot::RenderPart &rp : hair_parts) {
|
||
if (rp.mesh < 0 || rp.mesh >= (int)hfi.meshes.size())
|
||
continue;
|
||
skin_hair(rp.mesh);
|
||
const gr2::Mesh &m = hfi.meshes[rp.mesh];
|
||
const uint32_t ib = rp.idx_first;
|
||
const uint32_t ic = (rp.idx_count && ib + rp.idx_count <= m.indices.size())
|
||
? rp.idx_count
|
||
: (uint32_t)m.indices.size() - ib;
|
||
PackedInt32Array idx;
|
||
idx.resize((int)ic);
|
||
int32_t *iw = idx.ptrw();
|
||
for (uint32_t t = 0; t < ic; ++t)
|
||
iw[t] = (int)m.indices[ib + t];
|
||
Array a;
|
||
a.resize(Mesh::ARRAY_MAX);
|
||
a[Mesh::ARRAY_VERTEX] = hp[rp.mesh];
|
||
a[Mesh::ARRAY_NORMAL] = hn[rp.mesh];
|
||
a[Mesh::ARRAY_TEX_UV] = hu[rp.mesh];
|
||
a[Mesh::ARRAY_INDEX] = idx;
|
||
cpu_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, a);
|
||
++hair_surfaces;
|
||
}
|
||
}
|
||
|
||
// clear_surfaces() dropped the override materials — just re-assign the cached
|
||
// ones. surf_mats is sized to the full surface count (body + folded hair) by
|
||
// _apply_materials(); here we only rebind the body slots, so `>=` is the
|
||
// right test. Using `==` made this fall through to _apply_materials() EVERY
|
||
// frame whenever hair was present (surf_mats bigger than body_surfaces) —
|
||
// reloading every DDS per frame, stalling the frame loop (missed PONGs ->
|
||
// server disconnect) and thrashing the mesh.
|
||
if (body_surfaces > 0 && (int)surf_mats.size() >= body_surfaces) {
|
||
for (int s = 0; s < body_surfaces; ++s)
|
||
mi->set_surface_override_material(s, surf_mats[s]);
|
||
} else {
|
||
_apply_materials();
|
||
}
|
||
for (int s = 0; s < hair_surfaces; ++s)
|
||
mi->set_surface_override_material(body_surfaces + s, hair_mat);
|
||
if (mi && have_animated_bounds) {
|
||
mi->set_custom_aabb(animated_bounds.grow(0.01));
|
||
}
|
||
}
|
||
|
||
void Metin2Model::enable_gpu_skin(bool on) {
|
||
if (!on || gpu_skin_active || !mi || !file || !*file) {
|
||
return;
|
||
}
|
||
const gr2::FileInfo &fi = (*file)->file_info();
|
||
const int nbones = fi.skeletons.empty() ? 0 : (int)fi.skeletons[0].bones.size();
|
||
if (nbones == 0) {
|
||
return;
|
||
}
|
||
bones_img = godot::Image::create_empty(3, nbones, false, godot::Image::FORMAT_RGBAF);
|
||
bones_tex = ImageTexture::create_from_image(bones_img);
|
||
|
||
// §2.2: fold the attached hair into the GPU mesh (bind pose, bone indices
|
||
// remapped to the base skeleton). No shader change — SRC_SKIN + the shared
|
||
// bones_tex deform it. No hair -> mesh unchanged.
|
||
gpu_base_surf = mi->get_mesh().is_valid() ? mi->get_mesh()->get_surface_count() : 0;
|
||
if (hair_file && *hair_file && !hair_parts.empty() && !hair_bone_remap.empty()) {
|
||
_build_gpu_mesh();
|
||
}
|
||
|
||
mi->set_skeleton_path(NodePath()); // no Skeleton3D skinning — the shader does it
|
||
mi->set_skin(Ref<Skin>());
|
||
if (skel) {
|
||
skel->set_show_rest_only(true);
|
||
}
|
||
// The mesh AABB is bind-pose; the shader moves verts, so widen the cull box
|
||
// (gr2-skin-space units, ~100/m) or arms-up poses get frustum/shadow-culled.
|
||
mi->set_custom_aabb(AABB(Vector3(-400, -400, -400), Vector3(800, 800, 800)));
|
||
gpu_skin_active = true;
|
||
_apply_materials(); // rebuild surface materials with the skinned shader + bones_tex
|
||
UtilityFunctions::print(vformat("[Metin2Model] GPU-skin mode ON (%d bones)", nbones));
|
||
}
|
||
|
||
void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||
if (!gpu_skin_active || bones_img.is_null()) {
|
||
return;
|
||
}
|
||
current_skin = skin;
|
||
const int nbones = bones_img->get_height();
|
||
PackedByteArray buf;
|
||
buf.resize(nbones * 3 * 4 * 4); // rows * 3 texels * RGBA * float
|
||
float *f = reinterpret_cast<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;
|
||
}
|
||
|
||
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
|