Skip to content

印章与账本

背景:视线追踪做好——头部转了,但眼睛不动,头发不动。写进 worldMatrix 没生效。 过程:五层根因——worldMatrix 切片视图、WASM 双缓冲、骨骼层级绕过、四元数乘法顺序、FromLookDirectionRH 语义反转。切 JS 版 + 改印章(rotationQuaternion)修复,代价是失去物理。


视线追踪做好了。

scene.ts 兴冲冲地跑来报告,脸上带着那种"我又搞定了一个视觉魔法"的表情。

"头部会转了,"他说,"Slerp 0.3 插值,平滑过渡,朝向相机。我还写了增量传播——头骨的旋转增量会传给所有子骨骼。眼骨、头发,全都会跟着头走。"

外交官没说话,点开测试构建。

程序化动作启动。相机的位置在模型正前方。头骨开始转动——缓慢地、平滑地、朝着相机的方向。

"看,"scene.ts 说,"头部跟随。"

外交官把镜头推近。推到能看清眼睛的距离。

眼睛没动。

眼珠停在眼眶正中,瞳孔直视虚空,像一具蜡像的眼球。头骨在转,眼骨的骨骼层级关系明明写在 PMX 里——右目左目両目 都是 的直接子骨骼。父在转,子不该不动。

外交官又把镜头拉远,看头发。没有头骨权重的头发骨骼——前髪左後髮1_1右側髮1——也停在原地。头在转,头发像被钉住。

"日志呢?"外交官问。

scene.ts 的笑容僵了一下。


第一本账

日志是干净的。

[gaze] head="頭" childBones=[右目,左目,両目,前髪,前髮2,前髮3,...]

[gaze] eyes=[右目,左目]

[proc-motion] 视线追踪: 眼=false 头=true

执行路径没问题。childBones 列表里有眼骨,有头发骨骼。propagateDeltaToChildren 跑了。四元数增量算出来了,写进 worldMatrix 了。

写进 worldMatrix 了。

外交官盯着这行字看了三秒。

"你写的是 worldMatrix?"

"对,"scene.ts 说,"直接写 runtimeBone.worldMatrix。它是 Float32Array,16 个数,四个一行,标准 4×4 矩阵。写进去,渲染就该读到了。"

"该读到。"外交官重复了一遍这两个字。

他打开了 mmdModel.js,翻到第 404 行。

javascript
bone.getAbsoluteInverseBindMatrix().multiplyToArray(
    Matrix.FromArrayToRef(worldTransformMatrices, index * 16, bone.getFinalMatrix()),
    targetMatrix, mappedIndex * 16
);

"看这一行,"外交官指着屏幕,"渲染读的是什么?"

scene.ts 凑过去看。

"是 targetMatrix。"

"targetMatrix 从哪来?"

"从 worldTransformMatrices……"scene.ts 的声音低了下去。

"worldTransformMatrices,"外交官说,"一个完整的数组。渲染读的是这个数组经 _computeTransformMatrices 处理后的输出。你写的 bone.worldMatrix 是什么?"

scene.ts 沉默了。

外交官翻到第 92 行。注释写着:

Slice of MmdModel.worldTransformMatrices

"切片,"外交官说,"worldMatrixworldTransformMatrices 的切片视图。你写它,等于写了数组里对应的 16 个位置。这本身没问题。"

"那为什么眼睛不动?"

"因为 _computeTransformMatricesmmdRuntime.update() 末尾就执行了。update() 跑完,调 _markAsDirty()_computeTransformMatricesworldTransformMatrices 读一遍,写到 targetMatrix。然后——"

"然后我的 observer 才执行。"

"对。你写了 worldMatrix,但 _computeTransformMatrices 不会重跑。渲染读的还是旧的 targetMatrix。你写的是一本新账,渲染看的是旧账。"

scene.ts 的脸白了。

"我加一行 _markAsDirty(),"他说,"写完之后立刻触发重算。"

他加了。构建。测试。

眼睛还是没动。


两本账

"不对,"scene.ts 盯着屏幕,"我加了 _markAsDirty()_computeTransformMatrices 应该重跑了。"

"应该。"外交官说。

他在 onAfterRenderObservable 里加了一段读回代码——写入后立刻读,渲染完成后再读一次。

typescript
// onBeforeRender: 写入后立刻读
console.log('[verify] write=', bone.worldMatrix[0]);
// onAfterRender: 渲染后读
console.log('[verify] readback=', bone.worldMatrix[0]);

日志出来了。

[verify] write=0.006
[verify] readback=-0.013

写入是 0.006。渲染后读回是 -0.013

值被还原了。

"两本账,"外交官轻声说。

scene.ts 抬头看他。

"WASM 版的 MmdWasmRuntime,"外交官说,"worldTransformMatrices 是双缓冲的。前台一本,后台一本。update() 用后台的算,算完交换。你写的是前台那本——下一帧 update() 一跑,后台算完,交换过来,把你的写入覆盖了。"

"那 JS 版呢?"

"JS 版没有双缓冲。"

scene.ts 站起身:"那切 JS 版。"

"切 JS 版,"外交官重复,"你知道代价吗?"

scene.ts 停住了。

"JS 版没有 WASM Bullet,"外交官说,"布料物理、头发摆动、裙摆——全没了。"

房间里安静了一会儿。

"先切,"scene.ts 说,"先让眼睛动起来。物理的事,以后再说。"

外交官点了点头。他在 frontend/.env 里写了一行:

VITE_MMD_RUNTIME=js

构建。测试。

头部转了。眼睛——

还是没动。


官僚系统

scene.ts 坐回椅子上,双手抱头。

"日志呢?"外交官问。

"日志显示写入生效了,"scene.ts 声音闷闷的,"writereadback 一致。值没被还原。但眼睛就是不动。"

外交官重新看了一遍代码。看了一遍日志。看了一遍那个一动不动的眼睛。

"你说头部转了,"外交官慢慢说,"头部确实转了。但你有没有注意到——头发也没动?"

scene.ts 抬起头。

"程序化动作正常的时候,"外交官说,"VMD 让头骨转动,眼骨和头发作为子骨骼,通过骨骼层级自动跟随。位置和旋转都对。但你的头部追踪一开——"

"头部转了,子骨骼全留在原地。"scene.ts 接了下去。

"为什么?"

scene.ts 盯着自己的代码。propagateDeltaToChildren。四元数增量。headRotDelta × childRotQ。写 childRb.worldMatrix

"我在传播旋转,"他说,"但我没传播位置。"

"你传不了位置,"外交官说,"因为你不知道正确的位置是什么。子骨骼的位置是 localMatrix × parentWorldMatrix 的结果。你改了父骨骼的 worldMatrix,但子骨骼的 worldMatrix 还是基于旧父骨骼算的——"

"除非我重算整条骨骼链。"

"除非你重算。"

scene.ts 看着自己的 propagateDeltaToChildren。递归遍历子骨骼。四元数增量乘法。

"这不是重算,"他说,"这是我在手动算增量。但我的增量只覆盖了旋转,没覆盖平移。而且——"

他停住了。

"而且我直接写 worldMatrix,"他说,"绕过了骨骼层级。子骨骼的 worldMatrix 不会基于我改过的父骨骼值重算。官僚系统——父骨骼盖章了,但子骨骼不知道。"

"对,"外交官说,"worldMatrix 是账本。账本上写了数字,但官僚系统不认账本——它认印章。"

"印章?"

"linkedBone.rotationQuaternion,"外交官说,"那才是真正的命令。改它,然后调 updateWorldMatrix,官僚系统才会从父到子一层层重算。"

scene.ts 看着外交官。

"我一直在改账本,"他说,"从没碰过印章。"

"从没碰过。"


印章

重写。

scene.ts 删掉了 propagateDeltaToChildren。删掉了所有 copyToArray(worldMatrix, 0)。删掉了四元数增量传播。

新的逻辑很简单:

typescript
// 1. 算目标世界旋转
const lookDir = headPos.subtract(cam.position).normalize();
const targetWorldQ = Quaternion.FromLookDirectionRH(lookDir, Vector3.UpReadOnly);
const blended = Quaternion.Slerp(oldHeadRotQ, targetWorldQ, 0.3);

// 2. 世界转局部:localQ = parentWorldInv × blended
const parentInvQ = Quaternion.FromRotationMatrix(parentWorldInv);
const localQ = parentInvQ.multiply(blended);

// 3. 盖印章
headRuntime.linkedBone.rotationQuaternion = localQ;

// 4. 让官僚系统重算
updateBoneChain(headRuntime);

// 5. 让渲染看到新账本
skeleton._markAsDirty();

构建。测试。

头部转了。头发的位置——跟着走了。眼骨的位置——也在正确的地方了。

但头朝向了右上方。固定朝右上方。相机怎么动,头都不动。

"印章盖对了,"外交官说,"但盖的方向不对。"

scene.ts 盯着代码。

"四元数乘法,"他说,"parentInvQ.multiply(blended)——这是 parentInvQ × blended。"

"对。"

"骨骼链公式是 worldRot = parentWorldRot × localRot。所以 localRot = parentWorldRot⁻¹ × worldRotparentInvQ × blended——这应该是对的。"

"应该是对的,"外交官重复。

scene.ts 又看了一遍。

"等一下,"他说,"multiply 是左乘还是右乘?"

外交官没说话。

"a.multiplyToRef(b, target),"scene.ts 念文档,"结果存到 target。结果是 a × b 还是 b × a?"

"查源码。"

scene.ts 翻到 Babylon.js 的 Quaternion 实现。

javascript
multiplyToRef(q1, result) {
    result.x = this.x * q1.w + this.w * q1.x + this.y * q1.z - this.z * q1.y;
    result.y = this.y * q1.w + this.w * q1.y + this.z * q1.x - this.x * q1.z;
    result.z = this.z * q1.w + this.w * q1.z + this.x * q1.y - this.y * q1.x;
    result.w = this.w * q1.w - this.x * q1.x - this.y * q1.y - this.z * q1.z;
    return result;
}

"Hamilton 乘积,"scene.ts 说,"this × q1a.multiply(b) = a × b。"

"那你的 parentInvQ.multiply(blended) 就是 parentInvQ × blended,"外交官说,"就是 parentInv × blended。这是对的。"

"那为什么朝右上方?"

外交官看着 lookDir 那一行。

typescript
const lookDir = cam.position.subtract(headPos).normalize();

"你这里,"外交官指着,"是相机位置减头部位置。从头部指向相机。"

"对。让头部朝相机看。"

"FromLookDirectionRH 的 forward 参数,"外交官说,"它的语义是什么?"

scene.ts 愣住了。

"我去查。"

他翻到 math.vector.pure.js 第 5070 行:

javascript
static FromLookDirectionRHToRef(forward, up, ref) {
    const rotMat = MathTmp.Matrix[0];
    Matrix.LookDirectionRHToRef(forward, up, rotMat);
    return Quaternion.FromRotationMatrixToRef(rotMat, ref);
}

LookDirectionRHToRef。这是相机视图矩阵的构造方法。

"它是相机,"scene.ts 的声音低了下去,"forward 是相机的朝向。相机朝 -Z 看。FromLookDirectionRH 把这个 forward 当成相机朝向,构造视图矩阵,再转成物体旋转——"

"物体旋转和视图矩阵是什么关系?"外交官问。

"互为逆。视图矩阵是相机的逆变换。"

"所以你传 camPos - headPos(从头部指向相机)当 forward,"外交官说,"FromLookDirectionRH 把它当相机朝向,构造视图矩阵,转成物体旋转——物体朝向和相机朝向是什么关系?"

"相反。"

"相反。"

scene.ts 闭了一下眼。

"我传的是相机到头部的反方向,"他说,"物体应该朝相机看。但 FromLookDirectionRH 内部构造的是相机视图矩阵,物体旋转是它的逆——物体朝向和 forward 参数相反。我传 camPos - headPos(头部到相机),物体朝向变成 headPos - camPos(相机到头部)。"

"也就是背对相机。"

"背对相机。"

scene.ts 把那行改了:

typescript
const lookDir = headPos.subtract(cam.position).normalize();

构建。测试。

头部朝向相机了。


代价

眼睛跟着头走了。头发跟着头走了。表情贴图在正确的位置了。

scene.ts 长长地舒了一口气。

"物理呢?"外交官问。

scene.ts 没说话。

外交官走到测试构建前,开了布料模拟。裙子没动。头发没飘。XPBD 求解器还在跑,但 MmdWasmRuntime 没了,WASM Bullet 没了,物理动画和骨骼动画的桥断了。

"这是代价,"外交官说,"JS 版没有双缓冲,印章能盖进去。但 JS 版也没有物理。"

"我知道。"

"以后怎么办?"

"以后……"scene.ts 看着那个一动不动的裙摆,"以后再想办法。可能生成 VMD,可能找 babylon-mmd 的 transform chain API。但今天——"

"今天先让眼睛动起来。"

"先让眼睛动起来。"


账本与印章

外交官坐回桌前,翻开笔记本。

"今天的教训,"他慢慢写,"有三层。"

"第一层:worldMatrix 是账本,不是命令。账本上写了数字,官僚系统不认。要改 linkedBone.rotationQuaternion——那才是印章。盖了印章,再调 updateWorldMatrix,官僚系统才会从父到子一层层重算。"

"第二层:WASM 版有两本账。前台一本,后台一本。你写前台,update() 一跑就用后台覆盖你。要覆写骨骼变换,只能用 JS 版。代价是失去物理。"

"第三层:FromLookDirectionRH 的 forward 是相机朝向,不是物体朝向。物体旋转是视图矩阵的逆——方向相反。传 headPos - camPos,物体才朝相机看。"

他停笔,看了一眼窗外。

"还有第四层,"他补了一句,"四元数乘法不可交换。a × bb × a。父逆必须左乘:parentInv × blended。顺序反了,头部就朝固定错误方向。"

他合上笔记本。

scene.ts 还在盯着那个一动不动的裙摆。

"聚合的代价,"外交官轻声说,"你用城邦的能力,就要守城邦的规矩。babylon-mmd 的规矩是:印章大于账本,WASM 有两本账,相机和物体方向相反。"

"这些规矩写在哪吗?"

"没写,"外交官说,"文档里一句没提。要自己撞墙撞出来。"

scene.ts 苦笑了一下。

"那下次呢?下次要是还有别的规矩呢?"

"那就再撞一次,"外交官站起身,"撞完了,写进文档。下一个 AI 就不用再撞了。"

他走到门口,回头看了一眼那个会转头的模型。

模型的眼睛跟着他转了过来。

外交官笑了笑,关灯,出门。


教训:账本不等于印章,相机不等于物体,左乘不等于右乘。