Implement playable Mac client and rendering validation

This commit is contained in:
shen
2026-09-11 14:58:22 +08:00
parent 1ab68bd06e
commit 374d4165d8
233 changed files with 6546 additions and 284 deletions
+33 -11
View File
@@ -67,7 +67,19 @@ void decode_dxt5_alpha(const uint8_t* blk, uint8_t out[16][4]) {
}
}
enum Fmt { F_NONE, F_DXT1, F_DXT3, F_DXT5, F_BGRA8 };
enum Fmt { F_NONE, F_DXT1, F_DXT3, F_DXT5, F_RGB };
bool valid_mask(uint32_t mask, uint32_t bits) {
if (!mask || (bits < 32 && (mask >> bits))) return false;
while (!(mask & 1)) mask >>= 1;
return (mask & (mask + 1)) == 0; // contiguous channel bits
}
uint8_t channel(uint32_t pixel, uint32_t mask) {
if (!mask) return 255;
while (!(mask & 1)) { mask >>= 1; pixel >>= 1; }
return uint8_t((uint64_t(pixel & mask) * 255 + mask / 2) / mask);
}
Image decode(const uint8_t* d, size_t len) {
Image img;
@@ -85,8 +97,8 @@ Image decode(const uint8_t* d, size_t len) {
if (!std::memcmp(fourcc, "DXT1", 4)) fmt = F_DXT1;
else if (!std::memcmp(fourcc, "DXT3", 4)) fmt = F_DXT3;
else if (!std::memcmp(fourcc, "DXT5", 4)) fmt = F_DXT5;
} else if ((pf_flags & 0x40) && rgb_bitcount == 32) { // DDPF_RGB, 32bpp
fmt = F_BGRA8; // Metin2 的非压缩 DDS 一般是 B8G8R8A8
} else if ((pf_flags & 0x40) && (rgb_bitcount == 16 || rgb_bitcount == 24 || rgb_bitcount == 32)) {
fmt = F_RGB;
}
if (fmt == F_NONE || w == 0 || h == 0 || w > 8192 || h > 8192) return img;
@@ -96,15 +108,25 @@ Image decode(const uint8_t* d, size_t len) {
img.h = uint16_t(h);
img.rgba.assign(size_t(w) * h * 4, 0);
if (fmt == F_BGRA8) {
if (avail < size_t(w) * h * 4) { img = Image{}; return img; }
for (size_t i = 0; i < size_t(w) * h; ++i) {
img.rgba[i * 4 + 0] = src[i * 4 + 2];
img.rgba[i * 4 + 1] = src[i * 4 + 1];
img.rgba[i * 4 + 2] = src[i * 4 + 0];
img.rgba[i * 4 + 3] = src[i * 4 + 3];
if (fmt == F_RGB) {
const uint32_t r = rd_le32(d + 92), g = rd_le32(d + 96), b = rd_le32(d + 100);
const uint32_t a = (pf_flags & 1) ? rd_le32(d + 104) : 0;
if (!valid_mask(r, rgb_bitcount) || !valid_mask(g, rgb_bitcount) || !valid_mask(b, rgb_bitcount) ||
(a && !valid_mask(a, rgb_bitcount)) || ((pf_flags & 1) && !a) ||
(r & g) || (r & b) || (g & b) || (a & (r | g | b))) return {};
const size_t bytes = rgb_bitcount / 8, row_bytes = size_t(w) * bytes;
const size_t pitch = (rd_le32(d + 8) & 8) ? rd_le32(d + 20) : row_bytes;
if (pitch < row_bytes || avail < row_bytes || pitch > avail || (h - 1) > (avail - row_bytes) / pitch) return {};
for (size_t y = 0; y < h; ++y) {
for (size_t x = 0; x < w; ++x) {
uint32_t pixel = 0;
for (size_t k = 0; k < bytes; ++k) pixel |= uint32_t(src[y * pitch + x * bytes + k]) << (8 * k);
auto* out = &img.rgba[(y * w + x) * 4];
out[0] = channel(pixel, r); out[1] = channel(pixel, g);
out[2] = channel(pixel, b); out[3] = channel(pixel, a);
}
}
img.format = "BGRA8";
img.format = rgb_bitcount == 32 && r == 0xff0000 && b == 0xff ? "BGRA8" : "RGB_MASKED";
return img;
}
+1 -1
View File
@@ -1,5 +1,5 @@
// dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp}
// (namespace engine -> mtgodot). DDS DXT1/3/5 + BGRA8 -> RGBA8, level 0.
// (namespace engine -> mtgodot). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
// engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3
// 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。
// 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。
+144 -28
View File
@@ -10,6 +10,7 @@
#include <cmath>
#include <string>
#include <list>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/os.hpp>
@@ -25,10 +26,102 @@ using namespace godot;
namespace mtgodot {
namespace {
struct ClipEntry {
String path;
uint64_t modified;
size_t bytes;
std::shared_ptr<const gr2::File> file;
};
// Immutable decoded clips are shared, not playback time/pose/event cursors.
// Main-thread animation loading only; no Godot objects survive in this cache.
std::list<ClipEntry> clip_cache;
size_t clip_bytes = 0;
uint64_t clip_hits = 0, clip_misses = 0;
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
struct MsaEntry {
String path;
uint64_t modified;
fmt::Msa metadata;
};
std::list<MsaEntry> msa_cache;
uint64_t msa_hits = 0, msa_misses = 0;
// Keep events immutable in the cache; reload copies them into each actor.
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
const uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++msa_hits;
metadata = it->metadata;
msa_cache.splice(msa_cache.begin(), msa_cache, it);
return true;
}
msa_cache.erase(it);
break;
}
++msa_misses;
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
if (msa_cache.size() >= 128) msa_cache.pop_back();
msa_cache.push_front({path, modified, metadata});
return true;
}
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
uint64_t modified = FileAccess::get_modified_time(path);
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
if (it->path != path) continue;
if (it->modified == modified) {
++clip_hits;
clip_cache.splice(clip_cache.begin(), clip_cache, it);
return clip_cache.front().file;
}
clip_bytes -= it->bytes;
clip_cache.erase(it);
break;
}
++clip_misses;
auto loaded = gr2_from_file(path, err);
if (!loaded) return {};
size_t bytes = 0;
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
if (bytes <= CLIP_BYTE_LIMIT) {
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
clip_bytes -= clip_cache.back().bytes;
clip_cache.pop_back();
}
clip_cache.push_front({path, modified, bytes, file});
clip_bytes += bytes;
}
return file;
}
}
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
Dictionary d;
d["hits"] = clip_hits;
d["misses"] = clip_misses;
d["entries"] = static_cast<int64_t>(clip_cache.size());
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
d["msa_hits"] = msa_hits;
d["msa_misses"] = msa_misses;
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
return d;
}
void Metin2AnimPlayer::clear_clip_cache() {
clip_cache.clear();
clip_bytes = 0;
clip_hits = clip_misses = 0;
msa_cache.clear();
msa_hits = msa_misses = 0;
}
Metin2AnimPlayer::Metin2AnimPlayer() {}
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
@@ -49,6 +142,7 @@ void Metin2AnimPlayer::_bind_methods() {
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
ClassDB::bind_static_method("Metin2AnimPlayer",
@@ -63,6 +157,7 @@ void Metin2AnimPlayer::_bind_methods() {
PropertyInfo(Variant::STRING, "sound"),
PropertyInfo(Variant::VECTOR3, "pos")));
ADD_SIGNAL(MethodInfo("playback_finished"));
ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
"set_anim_path", "get_anim_path");
@@ -132,17 +227,11 @@ static String strip_dpath(const String &raw) {
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
}
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) {
String low = spec.to_lower();
if (!low.ends_with(".msa")) {
return spec; // already a .gr2 path
}
fmt::Msa m;
std::string e;
if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str()));
return spec;
}
const String motion = String(m.motion_gr2.c_str());
const String base = motion.replace("\\", "/").get_file();
const String msa_dir = spec.get_base_dir();
@@ -171,16 +260,45 @@ String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
return cand;
}
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
d["bone"] = String(ev.attaching_bone.c_str());
d["attaching"] = ev.attaching;
d["following"] = ev.following;
d["independent"] = ev.independent;
d["fishing_effect"] = ev.fishing_effect;
return d;
}
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
Dictionary result;
Metin2Model *model = resolve_model();
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
if (!sk || !anim_file) return result;
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
if (String(sk->bones[i].name.c_str()) == bone) {
result["transform"] = gr2_to_godot(world_buf[i]);
break;
}
}
return result;
}
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_signal("motion_event_detailed", motion_event_dict(ev));
}
Array Metin2AnimPlayer::get_events() const {
Array a;
for (const fmt::MotionEvent &ev : events) {
Dictionary d;
d["type"] = ev.type;
d["start_time"] = ev.start_time;
d["effect"] = String(ev.effect_file.c_str());
d["sound"] = String(ev.sound_file.c_str());
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
a.push_back(d);
a.push_back(motion_event_dict(ev));
}
return a;
}
@@ -280,7 +398,7 @@ void Metin2AnimPlayer::_ready() {
}
void Metin2AnimPlayer::reload() {
anim_file = std::nullopt;
anim_file.reset();
duration = 0.0;
time = 0.0;
prev_time = 0.0;
@@ -294,16 +412,18 @@ void Metin2AnimPlayer::reload() {
}
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
String gr2_spec = anim_path;
if (anim_path.to_lower().ends_with(".msa")) {
fmt::Msa m;
std::string e;
if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) {
if (cached_msa(anim_path, m, &e)) {
msa_metadata = m;
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
events = m.events;
gr2_spec = resolve_anim_gr2(anim_path);
UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
// Reuse the metadata just parsed; path resolution must not parse it again.
gr2_spec = resolve_anim_gr2(anim_path, m);
if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
m.loop_start_time, m.loop_end_time) : String("none")));
@@ -314,7 +434,7 @@ void Metin2AnimPlayer::reload() {
}
gr2::LoadError err;
auto f = mtgodot::gr2_from_file(gr2_spec, &err);
auto f = cached_clip(gr2_spec, &err);
if (!f) {
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
@@ -330,7 +450,7 @@ void Metin2AnimPlayer::reload() {
duration = an.duration;
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
if (Metin2Model *model = resolve_model()) {
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
int matched = 0;
godot::String unmatched;
@@ -356,7 +476,7 @@ void Metin2AnimPlayer::reload() {
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
String(an.name.c_str()), duration, (int)an.tracks.size(),
(int)anim_file->file_info().animations.size());
UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
apply_pose(0.0);
}
@@ -480,7 +600,7 @@ void Metin2AnimPlayer::_process(double delta) {
if (prev_anim_file) {
blend_elapsed += delta; // real seconds, independent of time_scale
if (blend_elapsed >= blend_time) {
prev_anim_file = std::nullopt;
prev_anim_file.reset();
}
}
double next = time + delta * time_scale;
@@ -508,9 +628,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) {
double b = std::fmax(0.0, std::fmin(to, duration));
for (const fmt::MotionEvent &ev : events) {
if (a < ev.start_time && ev.start_time <= b) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_motion_event(ev);
}
}
return;
@@ -522,9 +640,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) {
double t = ev.start_time;
bool hit = (a < t && t <= b) || (b > duration && t <= b - duration);
if (hit) {
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
String(ev.sound_file.c_str()),
Vector3(ev.position[0], ev.position[1], ev.position[2]));
emit_motion_event(ev);
}
}
}
+9 -3
View File
@@ -10,6 +10,7 @@
#include <msa.h>
#include <optional>
#include <memory>
#include <vector>
namespace mtgodot {
@@ -67,8 +68,12 @@ public:
// .msa metadata (empty / zero when anim_path is a raw .gr2).
godot::Vector3 get_accumulation() const { return accumulation; }
godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...]
// Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending.
godot::Dictionary get_effect_bone_pose(const godot::String &bone);
godot::Dictionary get_loop_data() const;
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
static godot::Dictionary get_clip_cache_stats();
static void clear_clip_cache();
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
@@ -86,6 +91,7 @@ protected:
static void _bind_methods();
private:
void emit_motion_event(const fmt::MotionEvent &ev);
godot::String anim_path;
godot::NodePath model_path;
bool playing = true;
@@ -95,14 +101,14 @@ private:
double duration = 0.0;
godot::String last_info;
std::optional<gr2::File> anim_file;
std::shared_ptr<const gr2::File> anim_file;
std::vector<gr2::Mat4> world_buf;
std::vector<gr2::Mat4> skin_buf;
// Crossfade state: the outgoing clip is kept and sampled at a frozen time,
// its pose blended into the incoming clip for `blend_time` seconds.
double blend_time = 0.15;
std::optional<gr2::File> prev_anim_file;
std::shared_ptr<const gr2::File> prev_anim_file;
double prev_anim_time = 0.0;
double prev_anim_duration = 0.0;
bool prev_anim_loop = true;
@@ -119,7 +125,7 @@ private:
void apply_pose(double t);
void dispatch_events(double from, double to);
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
static godot::String resolve_anim_gr2(const godot::String &spec);
static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata);
};
} // namespace mtgodot
+81 -16
View File
@@ -69,6 +69,7 @@ void Metin2Model::_bind_methods() {
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);
@@ -274,14 +275,26 @@ void Metin2Model::_set_lod(int n) {
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;
for (int s = 0; s < (int)surf_mats.size(); ++s) {
Ref<Material> src = surf_mats[s];
// 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);
@@ -298,7 +311,11 @@ void Metin2Model::_set_lod(int n) {
_end_lod_fade();
return;
}
if (cpu_mesh.is_valid()) {
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);
@@ -310,6 +327,9 @@ void Metin2Model::_set_lod(int n) {
}
}
_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);
}
@@ -372,6 +392,8 @@ void Metin2Model::_clear_children() {
}
void Metin2Model::reload() {
current_world_pose.clear();
fly_target_bounds_valid = false;
_clear_children();
*file = std::nullopt;
last_info = "";
@@ -595,24 +617,37 @@ void Metin2Model::_load_hair() {
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())
}
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)
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())
if (file && *file && !hair_gr2.is_empty()) {
_load_hair();
_refresh_hair_mesh();
}
}
void Metin2Model::set_weapon_gr2(const String &p) {
@@ -793,6 +828,7 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
}
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.
@@ -804,6 +840,38 @@ void Metin2Model::update_weapon_pose(const std::vector<gr2::Mat4> &world_pose) {
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;
@@ -1137,6 +1205,7 @@ void Metin2Model::_apply_materials() {
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);
@@ -1591,10 +1660,11 @@ void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
return;
}
current_skin = skin;
// The shader owns deformation after this point. Sample the posed bounds
// once for culling and initial camera framing; doing the full vertex walk on
// every GPU frame would erase the CPU saving this path is intended to make.
if (!gpu_visual_bounds_ready && _update_skinned_bounds(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");
}
@@ -1620,12 +1690,7 @@ String Metin2Model::get_info() const {
}
AABB Metin2Model::get_visual_aabb() {
// Consumers such as CharSelect ask only while framing. Refresh on demand
// so their final camera fit reflects the current GPU pose without adding a
// per-frame CPU vertex walk to every world actor.
if (gpu_skin_active && !current_skin.empty()) {
_update_skinned_bounds(current_skin);
}
// Both animation paths publish bounds when sampling, before camera queries.
if (have_visual_bounds) {
return visual_bounds;
}
+5
View File
@@ -130,6 +130,7 @@ public:
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
godot::AABB get_visual_aabb();
godot::Dictionary get_fly_target_bounds();
// Parent-space Y adjustment that places the bind-pose model's lowest point
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
double get_ground_offset() const { return ground_offset; }
@@ -187,6 +188,7 @@ private:
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
int gpu_base_surf = 0; // base surfaces before appended GPU hair
void _load_hair();
void _refresh_hair_mesh();
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
godot::String weapon_gr2; // attached weapon .gr2 (or "")
@@ -244,6 +246,9 @@ private:
bool gpu_visual_bounds_ready = false;
bool have_visual_bounds = false;
godot::AABB visual_bounds;
std::vector<gr2::Mat4> current_world_pose;
godot::AABB fly_target_bounds;
bool fly_target_bounds_valid = false;
std::vector<gr2::Mat4> current_skin;
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
godot::Ref<godot::ImageTexture> bones_tex;
+9 -3
View File
@@ -611,12 +611,13 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
}
}
// Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。
// 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。
// Native extracted tree geometry, shared once per treefile across instances.
for (auto &kv : tree_groups) {
if (kv.second.xforms.empty())
continue;
Ref<godot::ArrayMesh> tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f);
Ref<godot::ArrayMesh> tmesh = get_tree_mesh(kv.first, *resolver, 12.0f);
const bool native = String(tmesh->get_meta("tree_geometry", "proxy")) == "native_spt";
(native ? native_trees_placed : proxy_trees_placed) += (int)kv.second.xforms.size();
Ref<MultiMesh> mm;
mm.instantiate();
mm->set_transform_format(MultiMesh::TRANSFORM_3D);
@@ -628,6 +629,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
mmi->set_layer_mask(1u << 0);
mmi->set_multimesh(mm);
mmi->set_meta("tree_geometry", native ? "native_spt" : "proxy");
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
: GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
@@ -827,6 +829,7 @@ void Metin2World::set_focus_position(double gx_m, double gz_m) {
}
void Metin2World::_process(double) {
update_water_animation();
for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) {
auto [tx, ty] = stream_queue.front();
stream_queue.erase(stream_queue.begin());
@@ -854,6 +857,7 @@ void Metin2World::unload_map() {
chunks_built = chunks_failed = chunks_splatted = 0;
objects_placed = objects_skipped = objects_missing_model = 0;
trees_placed = tree_species = 0;
native_trees_placed = proxy_trees_placed = 0;
objects_mdatr_built = 0;
build_ms = 0;
splat_ready = registry_ok = false;
@@ -997,6 +1001,8 @@ Dictionary Metin2World::get_load_report() const {
d["ambience_sources"] = (int)ambience_sources.size();
d["trees_placed"] = trees_placed;
d["tree_species"] = tree_species;
d["native_trees_placed"] = native_trees_placed;
d["proxy_trees_placed"] = proxy_trees_placed;
d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .mdatr 精确碰撞的对象数
d["static_meshes"] = static_cache.loaded;
d["build_ms"] = build_ms;
+1
View File
@@ -143,6 +143,7 @@ private:
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
int trees_placed = 0, tree_species = 0;
int native_trees_placed = 0, proxy_trees_placed = 0;
int objects_mdatr_built = 0;
godot::Node3D *objects_root = nullptr;
double build_ms = 0;
+1
View File
@@ -48,6 +48,7 @@ void initialize_mtgodot_module(ModuleInitializationLevel p_level) {
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
fmt::set_file_reader(nullptr);
mtgodot::Metin2AnimPlayer::clear_clip_cache();
mtgodot::cleanup_material_shaders();
mtgodot::cleanup_terrain_shader();
mtgodot::cleanup_tree_shader();
+50 -1
View File
@@ -9,6 +9,13 @@
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/classes/json.hpp>
#include <godot_cpp/classes/gltf_document.hpp>
#include <godot_cpp/classes/gltf_state.hpp>
#include <godot_cpp/classes/gltf_mesh.hpp>
#include <godot_cpp/classes/importer_mesh.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
@@ -477,15 +484,57 @@ Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
}
Ref<ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
static Ref<ArrayMesh> load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) {
const String source(resolver.resolve(treefile, nullptr).c_str());
if (source.is_empty()) return {};
const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry");
const String manifest_path = directory.path_join("manifest.json");
if (!FileAccess::file_exists(manifest_path)) return {};
const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path));
if (parsed.get_type() != Variant::DICTIONARY) return {};
const Dictionary manifest = parsed;
if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {};
const Dictionary trees = manifest.get("trees", Dictionary());
const String source_hash = FileAccess::get_sha256(source);
if (!trees.has(source_hash)) return {};
const Dictionary record = trees[source_hash];
const String filename = record.get("glb", "");
if (filename != source_hash + String(".glb")) return {};
const String path = directory.path_join(filename);
if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {};
Ref<GLTFDocument> document;
document.instantiate();
Ref<GLTFState> state;
state.instantiate();
if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {};
Ref<GLTFMesh> gltf_mesh = state->get_meshes()[0];
if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {};
Ref<ArrayMesh> mesh = gltf_mesh->get_mesh()->get_mesh();
if (mesh.is_null() || mesh->get_surface_count() == 0) return {};
mesh->set_meta("tree_geometry", "native_spt");
mesh->set_meta("source_sha256", source_hash);
mesh->set_meta("glb_sha256", record.get("glb_sha256", ""));
mesh->set_meta("leaf_mode", "static_extracted_cards");
// GLB vertices already use Godot metres. Never impose the proxy's 12m height.
return mesh;
}
Ref<ArrayMesh> get_tree_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m) {
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
"#" + std::to_string(height_m);
auto found = g_tree_mesh_cache.find(key);
if (found != g_tree_mesh_cache.end())
return found->second;
Ref<ArrayMesh> native = load_native_tree(treefile, resolver);
if (native.is_valid()) {
g_tree_mesh_cache.emplace(key, native);
return native;
}
UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str()));
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
mesh->set_meta("tree_geometry", "proxy");
g_tree_mesh_cache.emplace(key, mesh);
return mesh;
}
+7 -6
View File
@@ -10,8 +10,8 @@ namespace fmt {
struct AssetResolver;
}
// W4/R2 —— `.spt` 几何尚未跨平台读取(见 formats/spt.h),运行时使用 tree proxy
// 确定性枝干 + 多组交叉叶簇,并优先采用 .spt 指向的真实 bark/composite DDS。
// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary
// runtime on macOS. Deterministic procedural geometry remains a reported fallback.
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
namespace mtgodot {
@@ -19,10 +19,11 @@ namespace mtgodot {
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
float height_m = 12.0f);
// R2 tree proxy:保留无专有运行时的限制,但从 .spt 嗅探真实树皮和 composite atlas
// 用确定性的枝干/叶簇 mesh 近似 SpeedTree 的 branch/frond/leaf 分层。结果按 treefile 缓存,
// 可安全用于每树种一个 MultiMesh。若资源解析失败,自动退回纯程序化材质。
godot::Ref<godot::ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
// Cached per treefile for MultiMesh use. Missing or mismatched native resources
// use a proxy with explicit metadata and a warning; package acceptance rejects it.
// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural
// implementation is an explicitly reported fallback for missing/invalid trees.
godot::Ref<godot::ArrayMesh> get_tree_mesh(const std::string &treefile,
const fmt::AssetResolver &resolver, float height_m = 12.0f);
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
+35 -16
View File
@@ -1,4 +1,5 @@
#include "water_builder.h"
#include "water_motion.h"
#include "asset_io.h"
#include "dxt.h"
@@ -7,6 +8,8 @@
#include <godot_cpp/classes/shader.hpp>
#include <godot_cpp/classes/shader_material.hpp>
#include <godot_cpp/classes/texture2d_array.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_color_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
@@ -29,32 +32,30 @@ namespace {
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
const char *SRC_WATER = R"(shader_type spatial;
render_mode blend_mix, cull_disabled, depth_draw_never, diffuse_lambert, specular_schlick_ggx;
render_mode blend_mix, cull_disabled, depth_draw_never, unshaded;
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
uniform vec3 tint : source_color = vec3(0.10, 0.22, 0.26);
uniform float uv_per_meter = 0.125; // 1/8m= 原客户端 1/(CELLSCALE*4)
uniform float bob_amp = 0.06; // 高度浮动幅度(米),近似 MapOutdoorWater 0..-15cm
uniform float height_offset = 0.0; // Shared reference water translation, metres.
void vertex() {
VERTEX.y += sin(TIME * 0.6 + VERTEX.x * 0.01 + VERTEX.z * 0.013) * bob_amp;
VERTEX.y += height_offset;
}
void fragment() {
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
vec2 uv = UV * uv_per_meter;
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
float fres = pow(1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0), 3.0);
ALBEDO = mix(tint, tex, 0.6) + fres * 0.15;
ALPHA = clamp(COLOR.a + fres * 0.25, 0.12, 0.95);
ROUGHNESS = 0.08;
METALLIC = 0.0;
SPECULAR = 0.6;
// MapOutdoorWater selects texture RGB and vertex diffuse alpha directly.
// Do not introduce PBR lighting, invented tint or view-dependent opacity.
ALBEDO = tex;
ALPHA = COLOR.a;
}
)";
Ref<Shader> g_water_shader;
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
WaterMotion g_water_motion;
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
if (g_water_mat.is_valid())
@@ -107,6 +108,19 @@ Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
void cleanup_water_shader() {
g_water_shader.unref();
g_water_mat.unref();
g_water_motion = WaterMotion{};
}
void update_water_animation() {
if (g_water_mat.is_null()) return;
const uint64_t now = Time::get_singleton()->get_ticks_msec();
uint64_t duration = 1000;
double depth = 0;
if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) {
duration = UtilityFunctions::randi_range(1000, 3000);
if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15);
}
g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth));
}
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
@@ -126,8 +140,9 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
double depth_cm = water_h_cm - th;
float a = float(depth_cm / 60.0); // 60cm 深 -> 接近不透明
return a < 0.12f ? 0.12f : (a > 0.9f ? 0.9f : a);
// AreaTerrain uses RAW height differences, before HeightScale, with
// OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor).
return water_depth_alpha(depth_cm, height_scale);
};
for (int layer = 0; layer < wm.layer_count; ++layer) {
@@ -147,8 +162,14 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
continue;
}
int xs = x;
while (x < W && wm.ids[y * W + x] == layer)
++x;
// Keep each cell's four shoreline samples. Merging a whole row
// discarded interior terrain peaks and bridged dry banks with water.
++x;
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
// AreaTerrain skips the quad when every diffuse alpha is zero.
if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0)
continue;
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
int base = v.size();
@@ -162,8 +183,6 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
uv.push_back(Vector2(float(x1), float(z0)));
uv.push_back(Vector2(float(x1), float(z1)));
uv.push_back(Vector2(float(x0), float(z1)));
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
col.push_back(Color(1, 1, 1, aLL));
col.push_back(Color(1, 1, 1, aLR));
col.push_back(Color(1, 1, 1, aUR));
+3
View File
@@ -1,4 +1,5 @@
#pragma once
#include "water_depth.h"
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
@@ -16,6 +17,7 @@ struct AssetResolver;
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
namespace mtgodot {
struct WaterPiece {
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
float godot_y = 0;
@@ -26,5 +28,6 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
void cleanup_water_shader();
void update_water_animation();
} // namespace mtgodot
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <cmath>
namespace mtgodot {
// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres.
inline float water_depth_alpha(double depth_cm, double height_scale) {
if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) ||
height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f;
double alpha = depth_cm / (400.0 * height_scale);
return static_cast<float>(alpha > 0.8 ? 0.8 : alpha);
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <algorithm>
#include <cstdint>
namespace mtgodot {
// MapOutdoorWater: one shared vertical translation, alternating a random
// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms.
struct WaterMotion {
uint64_t start_ms = 0, duration_ms = 300;
double begin = 0, end = 0, current = 0;
bool initialized = false;
double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) {
if (!initialized) { start_ms = now; initialized = true; }
if (now - start_ms > duration_ms) {
begin = current;
end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0;
start_ms = now;
duration_ms = std::clamp<uint64_t>(next_duration, 1000, 3000);
}
current = begin + (end - begin) * double(now - start_ms) / double(duration_ms);
return current;
}
};
}