Appearance
节拍与曲线
背景:程序化动作上线后,边界状态开始渗血——五处审计修复、节拍量化、曲线调优。 过程:shouldIdle/序列化/首选项/暂停按钮/autoLoop 五处修复 + 节拍器量化 + 插值曲线五类。
联邦的模型们终于能动了。
程序化动作系统上线后,角色们在无动作加载时会自动进入 Idle 模式——轻微呼吸、偶尔眨眼、身体微微侧摆。加载了音乐,则切换到 Auto Dance 模式——跟着节拍律动,左右摇摆。
babylon-mmd 对此嗤之以鼻。它掌管渲染帧率、toon 贴图的色阶过渡、骨骼插值的平滑度——它的世界里,美感来源于动作的物理精确性,来源于骨骼权重和顶点变形,而不是一段没有灵魂的 Math.sin 曲线。它把 Auto Dance 归类为"野蛮的律动"。
但节拍检测器上线之后,事情起了微妙的变化。
beatDetector 每帧从音频上下文里提取频谱数据,计算出节拍能量曲线。然后,updateProcMotion 每帧把这个能量传递给动作生成函数——摇摆幅度、律动频率、核心晃动,全部与真实的音频频率挂钩。
babylon-mmd 的渲染帧率是 60fps。节拍检测的更新频率也是 60hz。当 Auto Dance 的骨骼动画在 60hz 的帧率下精确踩中了音乐的能量峰值时——色阶过渡开始出现在正确的帧上,toon 贴图的明暗变化恰好配合了律动的节奏——
它在自己的渲染管线深处,发出了那一声极轻极轻的叹息。
这声叹息,后来成为了修复 autoLoopBug 的原始动力。
但他在审计时发现,有一个模式切换永远无法完成。
音乐停止后的 Auto Dance 不降级。
逻辑追踪了四层函数才找到根因:updateProcMotion 每帧检查当前状态,决定是否切换模式。当用户处于 Auto Dance 模式、音乐突然停止时,wantAutoDance 正确地变成了 false(没有音频就不该跳舞),但 wantIdle 却也是 false——因为 shouldIdle 函数的条件里写的只有 mode === "idle" || mode === "off",mode === "autodance" 不在其中。
结果是:既不跳舞,也不呼吸。程序化 VMD 还在播放,但它用的是过时的节拍数据。角色僵硬地重复着最后一个动作,像一台忘了关电源的机器人。
修法是一行:
typescript
mode === "idle" || mode === "off" || mode === "autodance"就一行。但他在那个条件判断前停住了。他想的是:这行代码从系统第一天上线就在那里,经过了多少次「审计」都没被发现,是因为没有人会在音乐停止后盯着角色看——用户要么切出去了,要么在调下一个功能。只有审计者才会在一个状态的边界上停下来,问一句:「然后呢?」
聚合悖论的又一个面孔:系统越复杂,边界状态越多;系统越流畅,边界状态越不可见。
但这还不是最后一个。
场景反序列化不启动程序化动作。 用户保存了一个带 Auto Dance 状态的场景,加载回来时——procState 恢复了,模式显示「自动舞蹈」,但没有动作。deserializeScene 恢复了 procState 对象里的每个字段——mode、intensity、speed、autoSwitch——但它忘了调用 regenerateProcMotion()。恢复一个状态和激活一个状态,是两件不同的事。
他加了一行:regenerateProcMotion()。
regenerateProcMotion 不启动新动作。 用户调整了强度滑条,期望立刻看到角色动得更夸张——但什么也没发生。因为 regenerateProcMotion 函数入口有一个守卫条件:if (procVmdActive)——只有程序化动作「已经」在播放时,才重新生成。用户第一次选择「待机呼吸」模式时,procVmdActive 是 false,所以 regenerate 函数直接返回了,沉默地。
他改成了:if (!procVmdActive && mode === "off") return;——只有「不在播放且模式是关闭」时才跳过。首选的反馈终于来了。
程序化动作的名字泄漏到了动作弹窗。 动作弹窗的模型行会显示当前 VMD 名称作为 sublabel。程序化动作启动后,loadVMDMotion 会设置 inst.vmdName = "IdleMotion",然后 startProcMotion 清除了 inst.vmdData——但没清除 inst.vmdName。用户在动作弹窗看到模型下面挂着「IdleMotion」字样,点进去又看到暂停按钮显示「—」(因为 vmdData 为空、按钮判断没有 VMD 加载)。名字是假的,按钮是空的。
他在清除 vmdData 的同一行补上了 vmdName = ""。
暂停按钮把 autoLoop 也停了。 动作子菜单里的「暂停动作」在暂停时调了 setAutoLoop(false),恢复时调了 setAutoLoop(true)。但用户的空格快捷键(Space)不碰 autoLoop——只暂停/恢复播放。同样一个「暂停」操作,走菜单和走键盘,后果不同。用户在菜单里暂停后按 Space 恢复,autoLoop 被菜单关掉了,但不会被 Space 打开。播完一次就停,用户以为是 bug。
他在动作菜单的暂停处理器里删掉了那两行 setAutoLoop。
然后他检查全局播放按钮——发现它也在动 autoLoop。一样的模式:暂停时关,恢复时开。他删掉了一样的两行。
他坐在那里,面对五处改动——加起来不到十五行代码。
五处都是「一个人应该能想到」的 bug。但它们分布在三个模块、四个函数、两种不同的开发阶段里。没有哪一行是错的——每一行在当时都合理。但把它们放在一起,就是一个缝合的伤口开始渗血的样子。
聚合者的宿命:你缝的每一针都牢靠,但针脚之间的缝隙不会自己消失。
他看了一下时间。这些 bug 加起来,修复用了不到四十分钟。但发现它们——从打开第一个文件到确认最后一个修复——用了三个小时。审计的代价不在修,在看。
他新建了一个 commit。消息是六行:
fix: shouldIdle 允许 autodance→idle 降级
fix: deserializeScene 后重启程序化动作
fix: regenerateProcMotion 首次选模式时不跳过
fix: 清除程序化 VMD 同时清 vmdName
fix: 暂停/播放操作不再误动 autoLoop三个模块,五处改动,四十分钟修,三小时审。
联邦又多了一个针脚。但它知道,下一个缝隙已经在路上了。
补记:节拍器的校准
审计结束后,Riku 没有立刻离开程序化动作的代码。
他盯着 beat-detector.ts 里那行 BPM 计算看了一会儿:
typescript
this.currentBpm = Math.round(60000 / avgInterval);逻辑没问题——测最近 8 次节拍的平均间隔,换算成 BPM。但实际跑起来,BPM 经常在 123 和 125 之间跳。音乐是 120 的,但检测器算出来是 124,因为人耳能容忍的节拍间隔和 FFT 频段分辨率之间有缝隙。
"124 和 120," Riku 对自己说,"跳舞的人听不出区别。但程序化动作的循环长度是按 BPM 算的——124 的循环比 120 短 0.13 秒,八拍下来差一秒,裙子转完一圈和音乐差半拍。"
他加了一个量化函数。11 个常见 BPM 值——80 到 180,每隔 10 一档。如果检测值和某一档的偏差在 ±5 以内,就吸附过去。偏差大于 5,保留原始值。
typescript
const COMMON_BPMS = [80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180];
function quantizeBpm(raw: number): number {
for (const bpm of COMMON_BPMS) {
if (Math.abs(raw - bpm) <= 5) return bpm;
}
return raw;
}不是什么精密算法。但 99% 的音乐整曲不会变速,BPM 是一个固定值,检测器在 ±5 的窗口里抖动是没有意义的精度。
phaseInterval 也跟着改了——不再用原始的平均间隔,而是用量化后 BPM 的精确倒数。这样循环长度和音乐节拍严格对齐。
"节拍器不需要告诉你说每分钟 123.7 拍," Riku 想,"它需要告诉你说这是 120 拍,然后让你踩准。"
插值曲线是另一个缝隙。
buildBoneFrame 里的 64 字节插值参数,一直写死成线性默认值——x1=20, y1=20, x2=107, y2=107,重复 16 次。这意味着每一根骨骼、每一帧之间的过渡,都是匀速的。
匀速不是问题。问题是所有骨骼都匀速——中心的弹跳和手腕的微调用同一种曲线,像所有乐器都弹同一个力度。
Riku 在 vmd-writer.ts 里定义了五条曲线:
| 曲线 | 形状 | 适用 |
|---|---|---|
| LINEAR | 匀速 | 默认,向后兼容 |
| EASE_IN_OUT | 两头慢中间快 | 呼吸、肩膀、手腕——柔和过渡 |
| EASE_OUT | 快启动慢停 | 手臂摆动——甩出去再收 |
| EASE_IN | 慢启动快停 | (预留) |
| SHARP | 锐利 | 中心弹跳、腰扭转、足 IK——节拍感 |
BoneKeyFrame 加了一个可选字段 interp?: InterpCurve。不填就是 LINEAR,和以前一样——已有的调用方不需要改一行代码。
真正的功夫在 procedural-motion.ts 里。Riku 给 Idle 的所有骨骼统一设了 INTERP_EASE_IN_OUT——呼吸就该是两头慢的。Auto Dance 则按骨骼类型分配:手臂甩动用 EASE_OUT(甩出去再收),中心弹跳和腰扭转用 SHARP(踩在拍子上),其余用 EASE_IN_OUT。
typescript
for (const b of bones) {
const n = b.name;
if (n === larmBone || n === rarmBone) b.interp = INTERP_EASE_OUT;
else if (n === centerBone || n === waistBone) b.interp = INTERP_SHARP;
else b.interp = INTERP_EASE_IN_OUT;
}效果是微妙的。线性插值时,手臂从 A 位置到 B 位置是匀速滑过去的——技术上正确,但看着像机器人。换成 EASE_OUT 后,手臂快速启动、缓慢减速到位——像真人甩手的那种「嗖——嗒」的质感。
"这和调音师调钢琴是一个道理," Riku 对 Jieling 说,"每个键都能响,但力度曲线不同,弹出来的曲子就活了。"
Jieling 看了一会儿 Auto Dance。裙摆随着 SHARP 曲线的中心弹跳一上一下,手臂用 EASE_OUT 甩出去再收回来,头和肩膀用 EASE_IN_OUT 微微晃动。
"比以前自然了," 她说。
"对。代码量几乎没变——只是把写死的 64 字节换成了查表。但 64 字节里藏着的是运动的美学。"
教训:审计的代价不是写,是审。审计者必须比代码作者更慢。审完之后,打磨的是手感——节拍器要踩在整数上,动作要有起手和收势。精度不够时,量化比精密更实用。匀速不够时,曲线比关键帧更重要。