Skip to content

ADR-016: 视线追踪子系统架构

日期: 2026-07-03 状态: 已完成 — 双路径方案已实施(WASM frontBuffer 直写 + JS linkedBone + updateWorldMatrix),手动计时方案(方案 A)为优化项,需上游 babylon-mmd 暴露 beforePhysics/afterPhysics API


背景

实现头部/眼球跟随相机的程序化动作,核心挑战是在 VMD 动画播放期间安全覆写特定骨骼旋转,且不破坏骨骼层级传播。调研 babylon-mmd 内部机制后发现:

  1. WASM 双缓冲worldTransformMatrices 采用双缓冲,mmdRuntime.update() 后写入会被下一帧覆盖
  2. worldMatrix 是切片视图:渲染管线读的是 _computeTransformMatrices 输出的 targetMatrixworldMatrix 只是其引用
  3. 骨骼层级是官僚系统:直接写父骨骼 worldMatrix 不会传播给子骨骼
  4. 四元数乘法顺序敏感parentInv × blendedblended × parentInv
  5. FromLookDirectionRH 语义:forward 是相机朝向,物体方向需取反
  6. 骨骼 worldMatrix 坐标系:babylon-mmd 的骨骼 worldMatrix 是 rootMesh 局部坐标系(不含 rootMesh 的 scaling/rotation/translation),而相机 position 是世界坐标系。模型 autoScale 后两坐标系不一致,必须对齐

决策

核心方案:linkedBone 覆写 + 手动骨骼链重算

不直接操作 worldMatrix,而是修改 linkedBone.rotationQuaternion(局部旋转),然后手动触发骨骼链重算:

typescript
// 1. 世界旋转 → 局部旋转(左乘父骨骼世界逆)
const parentInvQ = Quaternion.FromRotationMatrix(parentWorldInv);
const localQ = parentInvQ.multiply(blended);  // 父逆左乘

// 2. 写入 linkedBone
headRuntime.linkedBone.rotationQuaternion = localQ;

// 3. 递归重算骨骼链
const updateBoneChain = (rb: IMmdRuntimeBone) => {
    (rb as any).updateWorldMatrix?.(false, false);
    for (const child of rb.childBones) updateBoneChain(child);
};
updateBoneChain(headRuntime);

// 4. 触发 skeleton 重算
(mmdModel.mesh.metadata as any).skeleton?._markAsDirty?.();

执行时序

onBeforeRenderObservable
  ├─ mmdRuntime.update()          // VMD 求解,写 worldTransformMatrices
  │   └─ skeleton._markAsDirty()  // 触发 _computeTransformMatrices
  └─ gaze observer                // 改 linkedBone → updateWorldMatrix → _markAsDirty
      └─ skeleton._markAsDirty()  // 再次触发(读新值)

gaze observer 必须在 mmdRuntime.update() 之后注册,确保覆盖 VMD 写入。

坐标系对齐(2026-07-22 补充)

问题:模型 autoScale 后,骨骼 worldMatrix(rootMesh 局部坐标)与相机 position(世界坐标)不在同一空间,lookDir = bonePos - camPos 毫无几何意义。典型症状:头部追踪正常但眼部追踪在近距离时偏向角色原始正前方,cam↔eyeY 出现虚假的 -18 高度差。

根因:babylon-mmd 的 MmdRuntimeBone.updateWorldMatrix 只递归乘父骨骼矩阵,不乘 rootMesh.worldMatrix。rootMesh 的 scaling 仅在 vertex shader 阶段通过 mesh.worldMatrix 应用到顶点,与骨骼 worldMatrix 计算解耦。

方案:在 _applyGaze 入口把 gazeTarget 从世界坐标系转换到 rootMesh 局部坐标系:

typescript
const rootWorld = mmdModel.mesh.getWorldMatrix() as Matrix;
const invRoot = _m().copyFrom(rootWorld).invert();
Vector3.TransformCoordinatesToRef(target, invRoot, target);

转换后 gazeTarget 与骨骼 worldMatrix 在同一坐标系,lookDir 恢复正确几何意义。此转换在每帧 gaze 计算前执行一次,开销可忽略。

运行时约束

双路径方案已落地(2026-07-26 核对):WASM 路径通过 _writeMatToBuffer 直写 frontBuffer + _propagateChildrenWasm 递归传播,绕过双缓冲覆盖;JS 路径用于 VITE_MMD_RUNTIME=js 调试模式。

组件运行时物理gaze写入策略
MmdWasmRuntime(生产默认)WASM直写 frontBuffer + 递归传播
MmdRuntime(调试专用)JSlinkedBone + updateWorldMatrix + _markAsDirty

⚠️ 历史描述「MmdWasmRuntime gaze ❌(双缓冲覆盖)」已废弃——双路径方案已绕过此约束。scene.ts:561 注释明令「JS 版保留作为 gaze 行为对比排查与 WASM 兼容性回退,勿删除」。

关键发现:babylon-mmd 的 IMmdRuntime 暴露了 beforePhysics/afterPhysics,注释明确支持手动调用。这意味着未来可以不调 mmdRuntime.register(scene),手动控制时序,在 afterPhysics 之后执行 gaze 覆写,从而保留 WASM 物理。

风险与缓解

风险等级缓解
四元数乘法顺序已验证:parentInv × blended 是唯一正确顺序
骨骼链重算性能只重算头部及其子骨骼(~20 骨骼),开销可忽略
物理骨骼抖动头骨旋转后物理体用旧值,需在 gaze 覆写后调 afterPhysics 同步
WASM 升级 breaking changegaze 仅依赖公开 API(linkedBone/updateWorldMatrix/_markAsDirty
坐标系不一致_applyGaze 入口用 invRootMeshWorld × gazeTarget 转换到局部坐标系
大角度复合旋转 clamp 丢信息swing-twist 分解替代 toEulerAngles,分别限位 yaw(绕 Y)与 swing(pitch+roll)

实现文件

文件角色
motion/proc-motion-bridge.tsgaze observer 实现(头部+眼球跟随)
motion/perception-gaze.tsgaze 调度 + swing-twist clamp + 坐标系对齐(_worldToLocalGazeTarget
motion/perception-gaze-wasm.tsWASM 路径:frontBuffer 直写 + 递归传播
motion/perception-gaze-js.tsJS 路径:linkedBone 覆写 + updateWorldMatrix
scene/scene.ts运行时切换(VITE_MMD_RUNTIME
core/config.tsRuntimeModel 扩展类型
model/model-manager.tsfocusedMmdModel() 签名适配
model/model-loader.tsmmdModel 赋值类型守卫
motion/vmd-loader.ts动画创建分支 instanceof 守卫
env/env-bridge.tsmmdRuntime.physics 访问守卫

未来方向

方案 A(推荐):手动时序 + linkedBone 覆写

  • 不调 mmdRuntime.register(scene)
  • 手动在 onBeforeRenderObservable 执行:beforePhysics(dt)afterPhysics() → gaze 覆写
  • 保留 WASM Bullet 物理,gaze 实时性不变

需验证:WASM 版 MmdWasmRuntimeBone.updateWorldMatrix 是否被 override(若 JS 端覆写无效则需 fork babylon-mmd)。