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
+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;
}