Files
mtgodot-poc/libgr2/src/gr2_skeleton.cpp
T
shenleiandClaude Opus 5 e0c1853909 fix(net): 主角 VID 归参考端所有,GC_CHARACTER_DEL 不再把玩家「去主角化」
实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,2D UI 与小地图正常。

根因(已实证,非推测)。`EntityStore::mut_despawn()` 在 despawn 的 vid 等于
主角 vid 时把 `m_main_vid` 清零。40250 服务器在 spawn-in 头几秒会对玩家自己的
vid 反复下发 GC_CHARACTER_DEL + GC_CHARACTER_ADD(视野重算,实测约 10 次)。
第一次 DEL 就把玩家永久去主角化:`get_main_vid()` 返回 0 →
`_sync_main_character()` 直接 return → 建模代码从未跑过(main_attempts=0)→
占位胶囊留在 y=0 → 相机跟着它钻到 C1 地形底下 → 满屏灰紫。地图其实一直是好的
(chunks_built 20/20、objects_placed 565)。

参考端怎么做(40250 ClientVS22)。主角身份属于 `CNetworkActorManager::m_dwMainVID`,
只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写,只在管理器析构时清;
`RemoveActor()` 删 actor 行和角色实例,但绝不碰它;重新加入时
`__AppendCharacterManagerActor()` 用 `kCreateData.m_isMain = __IsMainActorVID(dwVID)`
重新推导,并再跑一次 `SetMainCharacterIndex(dwVID)`。

修法(entity_store.cpp):
- `mut_despawn()` 只删行、只发 Despawn,不动 m_main_vid;m2dev 分支的
  GC_CHARACTER_DEL 同规则。
- `mut_spawn_full()` 在 `e.vid == m_main_vid` 时把 `is_main` 置回 true,
  并重新广播 MainSet,对齐参考端的重新推导。

顺带落地的渲染修复(同一次实机排查中定位):
- libgr2 `sample_pose()` 增加 `root_offset` 形参;`Metin2AnimPlayer::apply_pose()`
  显式传单位阵而不是模型的 InitialPlacement。Metin2 动画 gr2 的根骨轨道自带骨盆
  高度,再乘一次 IP 会把整个人抬高一个骨盆高(武 99.4cm / 刺 92.6 / 巫 103.7 /
  萨 95.5,正好各自的 IP.z)——即此前的「人物悬空」。`bind_pose()` 语义不变。
- `Metin2Model::_load_hair()` 改用与身体/武器同一条 `make_material()`
  (specular_disabled)而不是 roughness=1.0 的 StandardMaterial3D,头发不再被
  PBR 高光洗成奶白色块。

取证与回归工具:
- `project/net_trace.gd`(新):主角 vid 生命周期取证。挂在 AppFlow 之后逐帧读
  `get_main_vid()`,归零时打印 `NETTRACE: MAIN_LOST was=.. now=0 reason=..` 加最近
  24 条环形事件;`reason` 区分 despawn 与 silent(reset_for_map_change 清空
  m_changes,是静默清零)。`MT_NETTRACE=1` 打开逐事件日志。
- `game_scene.gd`:诊断 tick 支持重复采样(分辨主角装配是「没跑」还是「没跑完」),
  新增 main_sync_ready / main_loading / main_view_key / main_map_vid /
  main_retry_in_ms 字段;`MT_DIAG_SHOT=<png>` 让游戏自己存一帧实机画面
  (screencapture 拍到的是终端窗口,没用)。
- `project/model_render_test.gd` + `test/rendering/model_baseline.json`(新):
  八个种族的模型渲染基线(--bless 重新落盘)。
- `project/char_bench_test.gd`(新):无网络无地图的单角色实验台,五变体 ×
  六机位出证据图,只产图不做阈值判定。
- `project/package_render_test.gd`(新):导出包内的渲染自检入口
  (导出模板不接受 --script,只能走 MT_TEST_MODE)。
- `/project/build/` 加入 .gitignore(渲染用例的落图输出)。

实机复验(真实服务器,非模拟):
- 修前 `LIVE_SMOKE FAIL: 主角色 VID 有效`;
  `NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn t=+7737ms`,
  环里紧跟 `spawn vid=25910 is_main=false`。
- 修后 `LIVE_SMOKE RESULT: PASS`,main_vid=25933、entities=19;
  40s 实机跑 16 次诊断采样全部 model_built=True / main_attempts=1 /
  main_sync_ready=True,player_pos 落在 C1 地形上而非 y=0;
  MT_DIAG_SHOT 存下的帧里地形、建筑、NPC、树木、真实角色模型、HUD、小地图俱全。

回归:gamescene_test / equip_model_test / gamescene_visual_test(feet_gap
0.001373)/ model_render_test(8 races)/ char_select_visual_test / 包内
MT_TEST_MODE=render 全绿;ctest 22/23,唯一失败 net.classic_session 已用
「还原本次改动 → 重跑 → diff 失败集合」确认与本次修改无关(失败集合完全相同)。

注:画面整体偏暗属于光照/色调差距,受 PARITY-GAP.md §0 门禁约束(对照帧未落盘前
不做相机/光照/色调/材质调参),本次不碰。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
2026-09-08 17:15:55 +09:00

228 lines
9.4 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// M0 T5 —— 骨架提取 + bind pose 自洽检查。见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_transform.cpp BuildCompositeTransform4x4 / granny_math.cpp
// MatrixEqualsQuaternion3x3 + MatrixMultiply3x3 /
// granny_matrix_operations.cpp ColumnMatrixMultiply4x3Impl /
// granny_world_pose.cpp BuildWorldPoseNoCompositeLOD + granny_bone_operations.cpp
// BuildFullWorldPoseOnly_Generic
#include "internal.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace gr2 {
using detail::Ref;
using detail::TypeMember;
namespace {
// 行主序 4x4R = A * BColumnMatrixMultiply4x3Impl 语义,平移在第 3 行 elem 12..14
Mat4 mul4x3(const Mat4& A, const Mat4& B) {
Mat4 R{};
for (int i = 0; i < 3; ++i) {
for (int k = 0; k < 3; ++k)
R[i * 4 + k] = A[i * 4 + 0] * B[0 * 4 + k]
+ A[i * 4 + 1] * B[1 * 4 + k]
+ A[i * 4 + 2] * B[2 * 4 + k];
R[i * 4 + 3] = 0.0f;
}
for (int k = 0; k < 3; ++k)
R[12 + k] = A[12] * B[0 * 4 + k]
+ A[13] * B[1 * 4 + k]
+ A[14] * B[2 * 4 + k]
+ B[12 + k];
R[15] = 1.0f;
return R;
}
// granny_math.cpp MatrixEqualsQuaternion3x3(行主序 3x3
void quat_to_m3(const float q[4], float d[9]) {
const float xx = q[0] * q[0], yy = q[1] * q[1], zz = q[2] * q[2];
const float xy = q[0] * q[1], xz = q[0] * q[2], yz = q[1] * q[2];
const float wx = q[3] * q[0], wy = q[3] * q[1], wz = q[3] * q[2];
d[0] = 1 - 2 * (yy + zz); d[1] = 2 * (xy - wz); d[2] = 2 * (xz + wy);
d[3] = 2 * (xy + wz); d[4] = 1 - 2 * (xx + zz); d[5] = 2 * (yz - wx);
d[6] = 2 * (xz - wy); d[7] = 2 * (yz + wx); d[8] = 1 - 2 * (xx + yy);
}
void m3_mul(const float a[9], const float b[9], float r[9]) {
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
r[i * 3 + j] = a[i * 3 + 0] * b[0 * 3 + j]
+ a[i * 3 + 1] * b[1 * 3 + j]
+ a[i * 3 + 2] * b[2 * 3 + j];
}
} // namespace
// granny_transform (68B: u32 Flags; f32 Position[3]; f32 Orientation[4]; f32 ScaleShear[3][3])
// → 行主序 4x4,上 3x3 = transpose(U3),平移在 elem 12..14。BuildCompositeTransform4x4 语义。
void File::Impl::transform_to_composite(Ref tf, Mat4& out, uint32_t& flags) const {
const uint8_t* p = at(tf, 68);
out = Mat4{};
out[0] = out[5] = out[10] = out[15] = 1.0f;
flags = 0;
if (!p) return;
auto f32 = [&](size_t off) { float v; std::memcpy(&v, p + off, 4); return v; };
flags = rd_u32(tf);
float pos[3] = { f32(4), f32(8), f32(12) };
float ori[4] = { f32(16), f32(20), f32(24), f32(28) };
float ss[9];
for (int i = 0; i < 9; ++i) ss[i] = f32(32 + size_t(i) * 4);
float q3[9];
quat_to_m3(ori, q3);
float u3[9];
if (flags & 0x4 /* HasScaleShear */) m3_mul(q3, ss, u3);
else std::memcpy(u3, q3, sizeof u3);
// C 的上 3x3 = transpose(U3)
out[0] = u3[0]; out[1] = u3[3]; out[2] = u3[6]; out[3] = 0;
out[4] = u3[1]; out[5] = u3[4]; out[6] = u3[7]; out[7] = 0;
out[8] = u3[2]; out[9] = u3[5]; out[10] = u3[8]; out[11] = 0;
out[12] = pos[0]; out[13] = pos[1]; out[14] = pos[2]; out[15] = 1;
}
// skeleton: {String Name; RefToArray Bones(bone[]); int32 LODType; VariantRef ExtendedData}
// bone: {String Name; int32 ParentIndex; Transform LocalTransform;
// Real32[16] InverseWorldTransform; Real32 LODError; VariantRef ExtendedData}
// 全程按类型树取偏移,不硬编码。
bool File::Impl::extract_skeleton(Ref skel_obj, Ref skel_type, Skeleton& out) {
out.bones.clear();
if (!skel_obj.valid() || !skel_type.valid()) return false;
const auto& sms = parse_type(skel_type);
const TypeMember* mBones = find_member(sms, "Bones");
if (!mBones) return false;
Ref bones_base;
uint32_t bone_count = 0, stride = 0;
if (!read_ref_to_array(skel_obj, *mBones, bones_base, bone_count, stride)) return false;
if (bone_count == 0) return false;
if (bone_count > 8192 || stride == 0) return false;
const auto& bms = parse_type(mBones->ref_type);
const TypeMember* mName = find_member(bms, "Name");
const TypeMember* mParent = find_member(bms, "ParentIndex");
const TypeMember* mLocal = find_member(bms, "LocalTransform");
if (!mLocal) mLocal = find_member(bms, "Transform");
const TypeMember* mInvW = find_member(bms, "InverseWorldTransform");
if (!mInvW) mInvW = find_member(bms, "InverseWorld4x4");
if (!mLocal || !mInvW) return false;
out.bones.resize(bone_count);
for (uint32_t i = 0; i < bone_count; ++i) {
Ref b = { bones_base.section, bones_base.offset + i * stride };
Bone& bo = out.bones[i];
if (mName) bo.name = rd_str(member_slot(b, *mName));
if (mParent) bo.parent = rd_i32(member_slot(b, *mParent));
uint32_t flags = 0;
transform_to_composite(member_slot(b, *mLocal), bo.local_transform, flags);
bo.lt_flags = flags;
const uint8_t* iw = at(member_slot(b, *mInvW), 64);
if (iw) for (int k = 0; k < 16; ++k) std::memcpy(&bo.inverse_world[k], iw + k * 4, 4);
else bo.inverse_world = Mat4{};
}
return true;
}
// ── M2:姿势采样(自由函数 = 主 primitive;File::* 是便捷封装)──────────
namespace {
void accumulate_world(const Skeleton& sk, const std::vector<Mat4>& local,
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4& root_offset) {
const size_t n = sk.bones.size();
const Mat4 I{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
world.assign(n, I);
skin.assign(n, I);
for (size_t i = 0; i < n; ++i) {
int32_t par = sk.bones[i].parent;
const Mat4& parent_w = (par < 0 || (size_t)par >= i) ? root_offset : world[par];
world[i] = mul4x3((i < local.size()) ? local[i] : sk.bones[i].local_transform, parent_w);
skin[i] = mul4x3(sk.bones[i].inverse_world, world[i]);
}
}
} // namespace
void bind_pose(const Skeleton& sk, std::vector<Mat4>& world, std::vector<Mat4>& skin) {
std::vector<Mat4> local(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) local[i] = sk.bones[i].local_transform;
accumulate_world(sk, local, world, skin, sk.initial_placement);
}
void sample_pose(const Skeleton& sk, const Animation& an, float t,
std::vector<Mat4>& world, std::vector<Mat4>& skin,
const Mat4* root_offset) {
std::vector<Mat4> track_local;
an.sample_local(t, track_local);
std::vector<Mat4> local(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) local[i] = sk.bones[i].local_transform; // 默认 = bind
// track 按骨骼名匹配(bone_index 若已在 anim 提取时对某个 skeleton 映射过,可能不是这个 sk)
for (size_t ti = 0; ti < an.tracks.size() && ti < track_local.size(); ++ti) {
const BoneTrack& tr = an.tracks[ti];
int bi = -1;
for (size_t k = 0; k < sk.bones.size(); ++k)
if (sk.bones[k].name == tr.bone_name) { bi = int(k); break; }
if (bi >= 0) local[bi] = track_local[ti];
}
accumulate_world(sk, local, world, skin, root_offset ? *root_offset : sk.initial_placement);
}
bool File::bind_pose(int skeleton, std::vector<Mat4>& world, std::vector<Mat4>& skin) const {
if (!impl_ || skeleton < 0 || (size_t)skeleton >= impl_->skels_cache.size()) return false;
gr2::bind_pose(impl_->skels_cache[skeleton], world, skin);
return true;
}
bool File::sample_pose(int anim, float t, int skeleton,
std::vector<Mat4>& world, std::vector<Mat4>& skin) const {
if (!impl_ || skeleton < 0 || (size_t)skeleton >= impl_->skels_cache.size()) return false;
if (anim < 0 || (size_t)anim >= info_.animations.size())
return bind_pose(skeleton, world, skin);
gr2::sample_pose(impl_->skels_cache[skeleton], info_.animations[anim], t, world, skin);
return true;
}
double File::Impl::skeleton_self_check(int skeleton) const {
if (skeleton < 0 || (size_t)skeleton >= skels_cache.size()) return 1e9;
const auto& sk = skels_cache[skeleton];
const size_t n = sk.bones.size();
if (n == 0) return 1e9;
std::vector<Mat4> world(n);
double dev = 0.0;
size_t worst = 0;
for (size_t i = 0; i < n; ++i) {
int32_t par = sk.bones[i].parent;
if (par < 0 || (size_t)par >= i) {
// 根:world = local * InitialPlacementBuildWorldPose 的 Offset4x4
world[i] = mul4x3(sk.bones[i].local_transform, sk.initial_placement);
} else {
world[i] = mul4x3(sk.bones[i].local_transform, world[par]);
}
// E = InverseWorld * world ≈ I
Mat4 E = mul4x3(sk.bones[i].inverse_world, world[i]);
double bd = 0.0;
for (int k = 0; k < 16; ++k) {
float want = (k % 5 == 0) ? 1.0f : 0.0f;
bd = std::max(bd, (double)std::fabs(E[k] - want));
}
if (bd > dev) { dev = bd; worst = i; }
}
if (getenv("GR2_DEBUG")) {
fprintf(stderr, "[skel] self-check dev=%.5f worst bone %zu '%s' parent=%d\n",
dev, worst, sk.bones[worst].name.c_str(), sk.bones[worst].parent);
const Mat4& E = mul4x3(sk.bones[worst].inverse_world, world[worst]);
fprintf(stderr, "[skel] E[worst]:");
for (float x : E) fprintf(stderr, " %.3f", x);
fprintf(stderr, "\n");
}
return dev;
}
} // namespace gr2