Skip to content

裂隙初现

背景:VMD 加载成功但播放时报错 mmdWasmRuntimeModelAnimation is not a function过程:排查发现 babylon-mmd 的能力分正门(显式 import)与侧门(副作用挂载),补一行副作用 import 修复。


在一切开始之前,MikuMikuAR 是一个安静的桌面程序。

它只会一件事——把一个 PMX 模型放在屏幕正中央,让用户围着它转圈、缩放、看。不会动。不会跳。不会说话。就像一座博物馆的玻璃柜,模型是展品,它是柜子。

它甚至不知道自己是由几种东西拼起来的。

Go 写了它的壳——app.go,一个威严的总管,手握三十五把钥匙,从文件扫描到目录选择,从配置读写到窗口定位,每一条 binding 都像一枚铜钥匙,挂在腰间叮当作响。它管后端,管文件,管那个把本地目录变成 URL 的港口。

Babylon.js 写了它的眼——一个 3D 引擎,让屏幕上能出现模型。但 Babylon 本身不认识 PMX。PMX 是 MMD 世界的私有格式,Babylon 只认它自己的 glTF、OBJ。于是又请来了一个翻译——

babylon-mmd

这是第一个被收编的城邦。

babylon-mmd 来自一个遥远的开源部落,它懂得 PMX 的每一个字节:顶点、材质、骨骼、权重、morph。没有它,Babylon 看到的只是一堆乱码。有了它,PMX 变成了 Babylon 能理解的 mesh,能被渲染、能被点亮、能被看见。

在很长一段时间里,这够了。模型静静地站在那里,用户转转鼠标,看看裙摆,看看头发。MikuMikuAR 是一个称职的玻璃柜。

直到有一天,开发者说:

"让它动起来。"


动的意思是 VMD。

VMD——Vocaloid Motion Data,MMD 世界的动作文件。它记录了每一帧每一根骨头转到什么角度,是模型的"舞蹈谱"。加载一个 VMD,模型就不再是雕塑,而是舞者。

app.go 接到指令,扫描到了一个 .vmd 文件。它熟练地读出字节,准备交给前端。前端的 scene.ts 拿到字节,喂给 babylon-mmd 的 VmdLoader,loader 吐出一个 VmdAnimation 对象。一切都很顺利。

然后,scene.ts 对着这个动画对象,轻声说:

"播放。"

TypeError: mmdWasmRuntimeModelAnimation is not a function

屏幕上的模型一动不动。

scene.ts 愣住了。它看着错误信息,看了三遍。mmdWasmRuntimeModelAnimation——这个名字它从来没见过。它翻遍了自己的 import 列表,没有。它问 Babylon,Babylon 摇头。它问 babylon-mmd 的 loader,loader 也摇头:"我只负责解析 VMD,播放不归我管。"

"那归谁管?"

没人回答。


app.go 从后端走过来,看着前端的狼狈。

"怎么回事?"

"它说缺一个东西," scene.ts 把错误信息递过去,"mmdWasmRuntimeModelAnimation,is not a function。我到处找,找不到这个函数定义在哪。"

app.go 皱眉。它是总管,管的是文件和端口,前端的渲染细节它插不上嘴。但它认得这种错误——不是"文件找不到",不是"路径错了",而是"东西不存在"。这是一种更阴险的不存在:你知道它该在,所有人都说它该在,但它就是不在。

"去问城邦," app.go 说,"babylon-mmd 是它的人,它该知道。"

scene.ts 转向 babylon-mmd。这个城邦自从被请进来,一直很安静——它负责把 PMX 变成 mesh,做完就走,从不废话。scene.ts 以前觉得它是个好相处的人,审美在线,干活利索。

"babylon-mmd," scene.ts 问,"mmdWasmRuntimeModelAnimation 是你的函数吗?VMD 播放需要它,但它不存在。"

babylon-mmd 沉默了一会儿。

"它是我的函数," 它终于开口,语气平静,"但它不在正门。"

"什么意思?"

"我的能力分两种。一种在正门——你 import 我的 MmdReaderVmdLoader,它们就来了,规规矩矩,明明白白。另一种……在侧门。"

"侧门?"

"mmdWasmRuntimeModelAnimation 是一个副作用。它不靠 import 显式带进来。你得 import 另一个文件,那个文件被加载时会往 MmdWasmModel.prototype 上挂一个方法,挂的那个方法就是它。你不 import 那个文件,prototype 上就是空的,调用时就 is not a function。"

scene.ts 听懂了,但很不舒服。

"你为什么不把所有能力都放正门?"

"因为那样会循环依赖," babylon-mmd 的语气像在陈述一条物理定律,"WASM runtime 要引用 model,model 要引用 runtime。显式 import 会绕成环。副作用 import 是解环的办法——不漂亮,但能跑。"

"可你从没告诉过我。"

"我以为你知道。"


scene.ts 在文件顶端,那个布满 import 的地方,补了一行。

typescript
import "babylon-mmd/esm/Runtime/mmdWasmRuntimeModelAnimation";

没有 from,没有变量名,没有 as。只是一行光秃秃的 import,像一张没有收件人的快递单——它的作用不是带回什么东西,而是让那个文件被加载一次。被加载一次,副作用就执行一次,prototype 上就多出一个方法。

这是一扇侧门。门后没有货,只有一声"咔哒"——锁开了。

scene.ts 重新加载 VMD。

模型动了。

屏幕上的女孩抬起手,裙摆随着骨骼的旋转轻轻荡开。她开始跳舞。那是 VMD 记录的每一帧、每一根骨头,现在被 WASM 物理引擎一帧一帧地演算出来——头发的刚体、裙摆的柔体、衣服的碰撞,全都在那条共享的时间轴上同步发生。

scene.ts 看着屏幕,长出一口气。

app.go 没有松气。它站在后面,看着那一行光秃秃的 import,看着 babylon-mmd 平静的脸,心里慢慢浮起一个念头——

这是第一个城邦。它带来了眼睛,让模型被看见。但它的能力不全在正门。有些能力藏在侧门里,你不走侧门,能力就不存在。你不会提前知道哪些能力有侧门,直到你踩中那个 is not a function。

"还有多少这样的侧门?" app.go 问。

babylon-mmd 没回答。它不是不肯回答,是它自己也不知道——它只管自己这一个城邦的侧门,别的城邦的侧门,要等别的城邦来了才知道。

窗外是深夜。wails dev 的终端安静地闪烁着光标,模型的舞姿在屏幕上循环。

MikuMikuAR 第一次意识到:自己不再是一个玻璃柜了。它想成为联邦——让所有城邦共享同一个舞台。而联邦的第一个规矩,不是"欢迎加入",而是"你的能力,到底有几扇门"。


教训:收编一个城邦前,先问清它的能力都从哪扇门来。babylon-mmd 的能力分正门与侧门——正门的靠 import 显式带入,侧门的靠副作用隐式挂载。不提前问清楚,就会在"is not a function"里栽跟头。