Files
mtgodot-poc/docs/reference/steps/M2-anim-skinning.md
T
shenandClaude Sonnet 5 47baf6c0c6 Metin2 game client (P0–P11) + mobile asset pipeline
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
2026-08-31 20:02:12 +09:00

10 KiB
Raw Blame History

M2 · 骨骼动画 + CPU 蒙皮

总纲:../PLAN.md §04 / §06 / §07。演示切片见 ../DEMO-PLAN.md D5(拉伸)。 本文件是 M2 的详细施工文档。


目标

解析 .msm / .msa,把 M0 读出的动画曲线采样成世界姿势,CPU 线性混合蒙皮, 让 warrior 播 idle / walk / dance,结果与 oracle 的**层①(裸 Granny)**逐帧数值一致。 装配一个多部件角色 + 挂点武器,验 LOD 一致性。

前置

  • M1 完成:静态网格 + 贴图上屏。
  • M0 T7gr2_anim 子集)+ scaleshear 用量已知。
  • oracle T3(层① dump+ T5(蒙皮顶点 dump)+ T8(噪声地板 → ε)。

交付物

产物 位置
engine/animation.{h,cpp} 曲线采样 + 世界姿势累积 engine/
engine/skinning.{h,cpp} CPU LBS engine/
formats/msa.cpp+ 视需要 msm.cpp formats/
app/shaders/vs_pnt_skinned.sc app/shaders/
tools/anim_bake/(曲线退路,视 M0 结论决定是否要) tools/
数值对拍报告 test/m2-numeric.json

任务分解

T1 · formats/msa + msm 解析 + 验证门

  • .msa(文本):引用的动画 .gr2、混合参数(blend time / ease)、事件。逻辑照 RaceManager.cpp / EterGrnLib/Util.cpp
  • .msm(文本,仅多部件时需要):base model gr2、挂点、材质类型。
  • 验证门(PLAN §04 要求):dump 解析结果(挂点名、动作列表、混合参数),和源文本逐项目视核对。
  • 完成判据dance_1.msa 解析出的动画路径 + blend 参数与文本一致。

T2 · engine/animation —— 曲线采样 + 世界姿势累积

  • 按局部时钟 t 对每骨 position / orientation / scaleshear 曲线插值 → 局部 granny_transform → 沿父链累积 → world[bone]
  • 对应 GrannyBuildWorldPose / GrannyGetWorldPoseComposite4x4Array
  • ease-in/out 曲线、loop count、raw local clock 语义:照 EterGrnLib/Motion* + ModelInstanceMotion.cpp 调用序列复刻。
  • 非单位 scaleshearM0 若报告普遍存在,world[bone] 要保留完整仿射(4×4 或 4×3),不能退化成刚体。
  • 完成判据gr2_anim 子集覆盖的动画能采样出每帧世界矩阵。

T3 · engine/skinning —— CPU LBS

  • skinMatrix[bone] = world[bone] · InverseWorld4x4[bone]
  • 逐顶点:v' = Σ weight[i] · skinMatrix[boneIndex[i]] · v4 权重),法线用 skinMatrix 的 3×3scaleshear 时要用逆转置)。
  • GrannyMeshIsRigid 为真的网格不蒙皮,只按挂载骨骼刚体变换 —— 分支照搬。
  • 完成判据bind poseidentity 动画)下 CPU 蒙皮结果 == 原始顶点。

T4 · vs_pnt_skinned.scGPU 版,D5 演示用)

  • mat4 skin = u_bones[a_indices.x]*a_weight.x + u_bones[a_indices.y]*a_weight.y + ...
  • CPU 端 rhi::setBones(skinMatrix[], boneCount) 上传 uniform 数组。
  • 注意M2 的数值门禁走 CPU 蒙皮(可 dump 顶点对拍);GPU shader 只是演示。CPU/GPU 一致性放 M4

T5 · 多部件装配 + 挂点武器

  • .msm → base gr2 + 额外 gr2(头发 / 时装);各自 mesh binding 到同一骨架
  • 武器:GrannyFindBoneByName("Bip01 R Hand"(或对应名)) → 取该骨世界矩阵 × 武器局部挂点矩阵 → 武器 gr2 的 model 变换。
  • 完成判据:挂点武器的世界变换与 oracle 一致。

T6 · LOD 一致性

  • 加载同模型 LOD 03warrior_cheongrin_lod_01/02/03.gr2),确认它们绑同一骨架、骨骼索引一致。
  • 在某帧切 LOD,测顶点位移(对应骨骼的顶点,切换前后位置差)。
  • 完成判据:切换帧顶点位移 < 阈值(无肉眼可见跳变)。

T7 · 曲线退路(tools/anim_bake,条件性)

  • 仅当 M0 fuzz 报告曲线子类型超出"1–2 种可实现"范围时启用。
  • anim_bakeWindows 侧用 oracle 把动画按固定帧率(如 60fps)烘焙成密集关键帧(每骨每帧一个 TRS),存自有格式。
  • libgr2 / animation 增加"读烘焙格式"分支,PoC 完全绕开曲线解码。
  • 完成判据:烘焙动画在 M2 数值对拍中照常通过。

门禁(分级,按序)

  1. .msm / .msa 解析子检查通过(T1)。
  2. 骨骼世界矩阵与 oracle 层①(裸 GrannyGrannyGetWorldPoseComposite4x4Array 直出) 逐帧对拍,max|Δ| < ε_matnoise_floor × 余量)—— 隔离曲线采样 + 姿势累积,先过
    • M2 只对层①。层②(ActorInstanceBlend + LOD 骨骼裁剪)不在 PoC 范围,留正式移植。
  3. 蒙皮顶点坐标与 oracle 逐帧对拍,‖Δ‖ < ε_vtx(在矩阵已对上的前提下)—— 隔离蒙皮 + 顶点格式。
  4. 挂点武器世界变换与 oracle 一致(T5)。
  5. LOD 0–3 共享骨骼绑定,切换无跳变(T6)。

验证

  • 数值层对拍:N 帧 × M 顶点、全部测试资产集,不是单文件。
  • 必须按序:先骨骼矩阵,再顶点。否则动画错 + 蒙皮错相互抵消、最终顶点却"对",掩盖两个 bug。
  • tie-breaklibgr2 与 oracle 分歧又都合理时,用 Blender io_scene_gr2 在同 t 算的世界矩阵仲裁(也能抓 oracle 自己的 bug)。
  • 视觉层:3 个确定姿势(idle 第 0 帧、走路中段、旋转量大的姿势),差异分类见 PLAN §07。

本步风险(从 PLAN §08 筛)

风险 状态 / 缓解
Granny 曲线压缩格式多样 消除M0 实测全 OldCurveTypedegree ≤ 2gr2_anim 全覆盖,无烘焙退路依赖
ease/loop/局部时钟语义 demo 用简单 loopfmod(t, duration));ease / blend / accumulation 是正式移植照 EterGrnLib/Motion* 复刻,PoC 不需要
骨骼绑定顺序 / mesh binding 重映射 gr2::sample_pose 按骨骼名 retarget181 warrior 动画 NaN=0);skin 矩阵按 mesh.bone_bindings → skeleton 索引
rigid + deformable mesh 混合 engine/skinning.cppmesh.rigid 分支(单骨刚体 vs 4 权重混合)
非单位 scaleshear 让 world·invBind 近似失效 全程完整仿射 4x4mul4x3 保 3x3 + 平移);法线用 skin 3x3scaleshear 严格应逆转置,M2 先近似,记 M4 收紧)
ε 拍脑袋 待 oracle T8 的 noise_floor × 10
数值门禁未跑 已跑macOS + Wine + MinGW 交叉编译的 oracle.exeGranny 2.9.12vs libgr2,骨骼矩阵 + 蒙皮顶点全 ≤ 6.5e-5(float 累积误差量级)。oracle/RUNBOOK.md / tools/oracle_diff

DoD 清单

  • .msm/.msa 解析 dump 与源文本核对通过(formats/textscript token 树 + msa/msm
  • 骨骼世界矩阵 vs oracle 层① max|Δ| ≤ 4.6e-5 —— oracle/run-diff-suite.sh23 用例warrior_cheongrin/lord ×4 LOD、assassin 双 root、shaman_lord v7、redthief2、snakeman、rabbit_backup、ox_01dance_1/attack/run/wait @ 多个 t)。test/m2-numeric.json
  • 蒙皮顶点 vs oracle ‖Δ‖ ≤ 6.5e-5(同上,vs GrannyDeformVertices;法线用同 3x3,Granny 也不做逆转置 —— engine/skinning.cpp
  • test/noise_floor.json:同-DLL 确定性 0,跨实现地板 mat 4.6e-5 / vtx 6.5e-5,ε=1e-3
  • app/render_fuzz 全量渲染烟测9166 个 .gr2 全过真管线(bgfx + Scene::build + DXT 解码 + CPU 蒙皮 + submitskinned 4055 / anim-only 5109 / rigid 2)——0 崩溃 / 0 空场景 / 0 NaN 姿势test/render-fuzz.json)。
    • 顺带修的 libgr2 bugredthief_general/{back,front}_damage.gr2 的 finger track 控制点在文件里就是 NaNread_old_curve 现在遇到非有限 knot/control 整条曲线弃(回退 bind),不把 NaN 灌进蒙皮链。
    • 抽样截图里「模型扭曲」的都是 render_fuzz 把某物种的动画 retarget 到别的 rig(如怪物动画播到 warrior 身上,或 warrior dance 播到怪物身上)—— 按名 retarget 到 bind pose 比例不同的骨架,肢体会拉长。不是 libgr2/engine bug(同 rig 的 warrior + dance_1/attack/run/wait 已对拍 Granny 1e-5)。render_fuzz 已改成只在 rig 匹配时才应用动画,其余画 bind pose。
  • [~] 挂点武器变换 —— gr2::sample_pose 出每骨世界矩阵,武器 = world[handBone]·mount;未接进 demo(无 .msm 武器数据),逻辑就绪
  • LOD 03 共享骨架:warrior_cheongrin lod_01/02/03 与 base 均 75 骨、骨骼名 + ParentIndex 逐项一致 → 切 LOD 骨骼索引不变、无跳变
  • xrender-demoSpacedance_1CPU LBS 每帧更新 dynamic VB,姿势连贯(test/golden/m2-*.png
  • (条件)曲线退路 anim_bake —— 不需要M0 实测曲线全 OldCurveType degree ≤ 2gr2_anim 全覆盖

M2 实现结果

结果
T1 .msa/.msm formats/textscript.cpp 通用 token 树 + msa.cpp/msm.cppdance_1.msa→duration 28.333334 / accum 0throw.msa→1 event(type10, t=0.824)warrior_w.msm→base + hair_path + 75 hairs。dump 与源文本逐项一致。
T2 世界姿势 gr2::sample_pose(skeleton, animation, t, world, skin)libgr2,跨文件:model gr2 的 skeleton + anim gr2 的 tracks,按骨骼名 retarget)。world[i]=Composite(local[i])·(parent<0?InitialPlacement:world[parent])skin[i]=InverseWorld4x4[i]·world[i]。bind pose 时 skin ≈ I6.1e-5)。
T3 CPU LBS engine/skinning.cppv'=Σ wᵢ·skin[bᵢ]·v4 权重归一),rigid mesh 走单骨刚体分支。skin_bind_pose_residual 自检 = 0(skin 全单位 → 输出 == 输入)。
T4 GPU 蒙皮 shader 跳过:M2 数值门禁走 CPU(可 dump 对拍),demo 也用 CPU + dynamic VB。vs_pnt_skinned.sc 留到 M4 CPU/GPU 一致性。
无 oracle 抽检 全部 181 个 pc/warrior/**.gr2:139 个带动画,各在 t=0/⅓/⅔/1 采样 —— NaN=056 个 track 全名匹配 skeleton(其余部分匹配,未匹配的骨退回 bind,安全)。
demo 用法 XR_ANIM=<anim.gr2|.msa> XR_ANIM_T=<0..1|秒> + 交互 Space 播放/暂停。