Skip to content

快门与心跳

背景:缩略图截到空姿态,程序化动作切换时序与截帧冲突

过程:追踪 stopProcMotion 调用点与 vmdData 赋值的时序差


缩略图是一片空白。

不是全黑——Babylon.js 的 RTT 渲染器忠实地输出了每一帧,canvas 的 ImageData 里填满了像素,base64 编码无误,SaveThumbnail 成功写入磁盘。但图里什么都没有:角色的骨骼在 T-Pose,肌肉没有任何弯曲,像是从未被任何动作触碰过。

"又来了。"桌面壳盯着缩略图缓存,第三次看到同一张空白证件照。

外交官从动作弹窗里探出头:"我刚加载了那个舞蹈 VMD,缩略图应该截到第一帧才对。"

"我知道。"桌面壳打开 vmd-loader.ts,光标停在第 82 行附近,"加载流程是这样的:先停程序化动作,再绑 VMD,再截缩略图。顺序没问题。"

typescript
// If user loads a real VMD, stop procedural motion
if (isProcVmdActive() && name !== PROC_VMD_NAME_IDLE && name !== PROC_VMD_NAME_AUTODANCE) {
    stopProcMotion();
}

"那为什么是空白?"

桌面壳没有回答。他打开了 proc-motion-bridge.ts,找到了 stopProcMotion 的实现:

typescript
export function stopProcMotion(): void {
    _procVmdActive = false;
    if (procModelId) {
        const inst = modelManager.get(procModelId);
        if (inst && inst.mmdModel && mmdRuntime) {
            const userVmdPresent = inst.vmdData !== null && inst.vmdData !== undefined;
            if (!userVmdPresent) {
                inst.mmdModel.setRuntimeAnimation(null);
            }
        }
        procModelId = null;
    }
}

"等等。"桌面壳的鼠标在 userVmdPresent 上停了三秒,"这个检查——它在看 inst.vmdData 是否为 null。"

"对啊,上次修的,防止已有 VMD 时被程序化动作覆盖。"

"但是 vmdData 是什么时候赋值的?"

外交官翻回 vmd-loader.ts,往下滚了四十行:

typescript
inst.vmdData = data;

第 197 行。在缩略图截帧之后。

沉默。

桌面壳说:"所以时序是——stopProcMotion() 在第 82 行调用,此时 vmdData 还是 null,userVmdPresent=false,动画被清空。缩略图在第 180 行截帧,截到的是空姿态。vmdData 在第 197 行才赋值——太晚了。"

"也就是说,"外交官慢慢说,"那个守卫条件假设 vmdData 已经赋值了,但调用点在赋值之前?"

"对。代码读起来像'先停程序化、再绑 VMD、再截帧',是正确顺序。但 stopProcMotion 内部读了一个还没被赋值的状态。"

桌面壳关掉编辑器,靠在椅背上。

"这就是异步时序假象——字面顺序是对的,但状态依赖的顺序是错的。三处代码各管一段,没人能一眼看到全貌。"

"那怎么修?"

"把 stopProcMotion() 移到 vmdData 赋值之后。这样截帧时 userVmdPresent=true,动画不会被清空。"

typescript
inst.vmdData = data;
if (isProcVmdActive() && name !== PROC_VMD_NAME_IDLE && name !== PROC_VMD_NAME_AUTODANCE) {
    stopProcMotion();
}

"就这么一行?"

"就这么一行。但找到它花了六轮。"

外交官沉默了一会儿。"因为没人会去检查一个'已经修好'的函数的调用时序。"

"对。"桌面壳看着窗外,"bug 最隐蔽的形态不是代码写错了——是代码写对了,但状态还没准备好。"


尾声

快门按下的那一刻,心跳还没停。

程序化动作的 setRuntimeAnimation(null) 像一次提前到来的停搏——骨骼回到静止,肌肉松开,角色在镜头前摆出从未存在过的空姿态。而缩略图的 RTT 渲染器不知道这些:它只负责截当前帧,忠实地记录下"什么都没有"的画面。

后来的修复只是把 stopProcMotion() 往后挪了几十行——从心跳停止之前,挪到心跳停止之后。但那几十行之间,隔着一个 await、一次 RTT 渲染、一次 readPixels、一次 base64 编码。隔着一整个"截图时角色还活着"的窗口。

有些 bug 不是逻辑错误,是时间线上的裂缝。代码的每一行都在正确的位置,但状态的每一次赋值都有自己的时钟。修它的方法不是改逻辑,是把两颗心跳对齐。