Appearance
印章与账本
背景:视线追踪做好——头部转了,但眼睛不动,头发不动。写进 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
"切片,"外交官说,"worldMatrix 是 worldTransformMatrices 的切片视图。你写它,等于写了数组里对应的 16 个位置。这本身没问题。"
"那为什么眼睛不动?"
"因为 _computeTransformMatrices 在 mmdRuntime.update() 末尾就执行了。update() 跑完,调 _markAsDirty(),_computeTransformMatrices 把 worldTransformMatrices 读一遍,写到 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 声音闷闷的,"write 和 readback 一致。值没被还原。但眼睛就是不动。"
外交官重新看了一遍代码。看了一遍日志。看了一遍那个一动不动的眼睛。
"你说头部转了,"外交官慢慢说,"头部确实转了。但你有没有注意到——头发也没动?"
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⁻¹ × worldRot。parentInvQ × 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 × q1。a.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 × b ≠ b × a。父逆必须左乘:parentInv × blended。顺序反了,头部就朝固定错误方向。"
他合上笔记本。
scene.ts 还在盯着那个一动不动的裙摆。
"聚合的代价,"外交官轻声说,"你用城邦的能力,就要守城邦的规矩。babylon-mmd 的规矩是:印章大于账本,WASM 有两本账,相机和物体方向相反。"
"这些规矩写在哪吗?"
"没写,"外交官说,"文档里一句没提。要自己撞墙撞出来。"
scene.ts 苦笑了一下。
"那下次呢?下次要是还有别的规矩呢?"
"那就再撞一次,"外交官站起身,"撞完了,写进文档。下一个 AI 就不用再撞了。"
他走到门口,回头看了一眼那个会转头的模型。
模型的眼睛跟着他转了过来。
外交官笑了笑,关灯,出门。
教训:账本不等于印章,相机不等于物体,左乘不等于右乘。