# Conflicts:
#	docs/RENDERING-REPAIR-PLAN.md
This commit is contained in:
shen
2026-09-08 16:25:54 +08:00
45 changed files with 1532 additions and 162 deletions
+15 -2
View File
@@ -415,7 +415,20 @@ void Metin2AnimPlayer::apply_pose(double t) {
tt = std::fmax(0.0, std::fmin(t, duration));
}
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf);
// Offset4x4 = identity, NOT the model's exported InitialPlacement.
//
// gr2::sample_pose defaults to sk.initial_placement because that is what the
// oracle passes (GrannyGetModelInitialPlacement4x4) and what the bind pose /
// InverseWorldTransform pair is self-consistent with. Metin2's animation .gr2
// carries the root bone's pelvis height inside its own track, so composing it
// onto InitialPlacement counts that placement twice and lifts the whole actor
// by one pelvis height (warrior 99.4 cm, assassin 92.6, sura 103.7, shaman
// 95.5 — exactly each model's IP.z). That was the "人物悬空" report.
// Measured with tools/rendering foot probe: with identity the lowest skinned
// vertex sits within a few mm of z=0 for wait/walk on all four classes, and
// only leaves the ground during run's airborne frames.
static const gr2::Mat4 kNoOffset{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
gr2::sample_pose(*sk, an, (float)tt, world_buf, skin_buf, &kNoOffset);
// Crossfade: blend the frozen outgoing-clip pose into this one, then rebuild
// skin_buf = invWorld · world_blended (PARITY §2.9).
@@ -423,7 +436,7 @@ void Metin2AnimPlayer::apply_pose(double t) {
const gr2::FileInfo &pfi = prev_anim_file->file_info();
if (!pfi.animations.empty()) {
gr2::sample_pose(*sk, pfi.animations[0], (float)prev_anim_time, prev_world_buf,
prev_skin_buf);
prev_skin_buf, &kNoOffset);
double w = blend_elapsed / blend_time;
// ease-in on the incoming clip, matching the client's
// GrannySetControlEaseInCurve(t0,t1, 0,0,1,1) Hermite (p0=0,m0=0,
+14 -10
View File
@@ -564,12 +564,6 @@ void Metin2Model::_load_hair() {
hair_parts = mtgodot::build_parts(hfi);
// hair 贴图:同名 .ddsalpha-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);
@@ -577,10 +571,20 @@ void Metin2Model::_load_hair() {
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));
// R2-02: the same forward material the body/weapon surfaces use, not a
// StandardMaterial3D. The old one ran Godot's PBR specular at roughness 1.0
// and washed the fringe out into flat cream patches (bisection: roughness 0
// or unshaded clean, alpha/cull/metallic changes not). make_material's
// shaders are `specular_disabled`, so the hair now lights like the body.
// CPU path: skinning already happened on the CPU, so skinned = false.
mtgodot::MaterialDesc hd;
hd.albedo = ht;
hd.blend = mtgodot::BlendMode::AlphaTest; // discard < 0.5, still depth-opaque
hd.alpha_scissor = 0.5f;
hd.two_sided = true; // hair cards are single-sided geometry
Ref<ShaderMaterial> hm = mtgodot::make_material(hd);
if (ht.is_null()) // keep the untextured fallback colour (make_material's is pale grey)
hm->set_shader_parameter("modulate", Color(0.18f, 0.12f, 0.08f, 1.0f));
hair_mat = hm;
hair_tex = ht; // reused by the GPU-skin path (§2.2)
+23 -9
View File
@@ -119,11 +119,24 @@ void EntityStore::mut_spawn_full(const Entity &src) {
// caller ever passes `store.get(vid)` back in.
Entity e = src;
e.vid = src.vid;
m_ents[src.vid] = e;
if (e.is_main) {
m_main_vid = src.vid;
// Reference CNetworkActorManager::__AppendCharacterManagerActor():
// kCreateData.m_isMain = __IsMainActorVID(dwVID);
// The main VID is owned by the manager, not by the actor row, so an actor
// re-added under the main VID becomes the main instance again. The 40250
// server does exactly this at spawn-in: GC_CHARACTER_DEL + GC_CHARACTER_ADD
// on our own VID moments after GC_MAIN_CHARACTER. Without this, the re-added
// row is a plain PC and the local player stays the fallback capsule.
if (e.vid == m_main_vid) {
e.is_main = true;
}
m_ents[src.vid] = e;
m_changes.push_back({ChangeKind::Spawn, src.vid});
if (e.is_main) {
// __AppendCharacterManagerActor() re-runs SetMainCharacterIndex(dwVID)
// on every main re-add, so re-announce it here too.
m_main_vid = src.vid;
m_changes.push_back({ChangeKind::MainSet, src.vid});
}
}
void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
@@ -170,12 +183,14 @@ void EntityStore::mut_map_bgm(const std::string &name, float volume) {
}
void EntityStore::mut_despawn(uint32_t vid) {
// Reference CNetworkActorManager::RemoveActor() drops the actor row and the
// character instance but never touches m_dwMainVID — that field is written
// only by SetMainActorVID() (GC_MAIN_CHARACTER) and cleared only when the
// manager is destroyed (our reset_for_map_change()). Clearing it here made a
// routine GC_CHARACTER_DEL on our own VID permanently un-main the player.
if (m_ents.erase(vid)) {
m_changes.push_back({ChangeKind::Despawn, vid});
}
if (vid == m_main_vid) {
m_main_vid = 0;
}
}
// CLIENT-GAP §3.2: GC_MOVE no longer mutates position directly. It appends a
@@ -1658,12 +1673,11 @@ void EntityStore::apply(uint16_t header, const void *body, uint16_t len) {
}
GCCharacterDel p;
std::memcpy(&p, body, sizeof(p));
// Same rule as mut_despawn(): the main VID survives a
// GC_CHARACTER_DEL and is re-bound by the matching re-add.
if (m_ents.erase(p.vid)) {
m_changes.push_back({ChangeKind::Despawn, p.vid});
}
if (p.vid == m_main_vid) {
m_main_vid = 0;
}
return;
}
case GC_MOVE: {