Skip to content

布料之魂

背景:XPBD 布料求解器已能跑,但活在真空里——没有 UI、没有开关、没有持久化,每次想看效果都得改代码重建。 过程:布料 UI 接入(开关 + 9 参数滑块)+ 状态持久化(EnvState)+ SDF 碰撞器骨骼缩放 + 滑块防抖(松手才重建)。

六月的风从窗户缝里挤进来,带着点槐花香。

Riku 盯着屏幕上那行字——class XpbdSolver。这是他上周埋下的种子:一个用 TypeScript 写的 XPBD 布料求解器,位置在 frontend/src/physics/,和 WASM Bullet 物理引擎平起平坐。

引擎本身已经能跑:创建布料网格、施加重力、满足距离约束和弯曲约束,甚至能和 SDF 碰撞体交互。问题是——它活在真空里。没有 UI,没有菜单开关,每次想看效果都得手动改代码、重新构建。

这列火车已经造好了,却没有月台。

"得让它能停靠。" Riku 对自己说。


布料开关

他靠在椅背上,闭上眼睛想象用户的使用路径。

打开场景菜单 → 进入物理 → 打开布料开关。开关拨到"开"的瞬间,裙摆突然有了重量感;拨到"关",布料立刻消失。这应该是一个直觉级的操作,不需要任何解释。

逻辑清晰,但实现起来有个坑——创建布料需要知道当前聚焦的是哪个模型,以及模型的骨骼信息。

modelManagerfocusedMmdModel()getBoneWorldMatrix(),够用了。Riku 在键盘上敲了几下,在 scene-menu.ts 顶部加了几行 import,然后开始改造 buildPhysicsLevel()

Toggle 后面跟一个 folder item:「布料参数」。点进去是子菜单,九个小滑块。裙长、裙摆角度、腰部半径、柔度、弯曲柔度、阻尼、重力倍率、水平分段、垂直分段。每个滑块的回调里都调用 _recreateCloth()——改了参数,布料要重建才生效。这不是最高效的做法,但最简单,也最可靠。

"先让它能跑," Riku 心想,"优化是后来的事。"


星空与水波

写完布料 UI 的那天下午,Riku 没有立刻测试。

他站在窗边,看着远处的天际线。六月的阳光把云切成薄片,一片一片叠在天上。他忽然想起布料裙子——如果初音穿着这套裙子在户外跳舞,风会吹动裙摆,但天空是死的。星星不会亮,水面不会波光粼粼。

布料让裙子能飞了。但让天空活过来的最后一块,还空着。

他转身坐回屏幕前。

程序化星空是他最喜欢的部分。不需要加载任何外部纹理——用 Canvas 2D 画布,随机撒几千个白点,大的亮些,小的暗些,偶尔来一颗带青色或暖色的。这是最笨的办法,也是最可靠的办法:不依赖任何外部资源,永远不会 404。

他在 scene.ts 里加了 _buildStarTexture()。星星是静态的,但天空在转。他加了 skyRotationSpeed 参数,每一帧给天空盒 rotation.y 加一点。浮点数溢出?他加了个保护:rotation.y = rotation.y % (Math.PI * 2)

水体那边,他让波浪方向和风向联动。用户在 UI 里改风向角,水面立刻响应——不是重建水体材质,而是直接改 waterMaterial.windDirection。这个细节让「调整参数」的体验从「盲调」变成了「所见即所得」。

Riku 站起来,给自己倒了杯水。布料、星空、水波——三块拼图终于拼在了一起。他忽然觉得,这不像是在写代码,更像是在布置一个房间:家具都摆好了,现在要让灯光亮起来。


持久化

布料参数写进去了,开关也能用了。但 Riku 关掉应用再打开,参数全没了。

他把空杯子握在手里,看着窗外。天已经暗下来了,路灯一盏一盏亮起来。

这就是问题所在——没有持久化,所有的努力都只是月台上的临时展览。火车停了一下,乘客下来了,第二天再来,班次表是空的。

EnvState 是项目的中央状态对象,存在 config.ts 里,每次改动都会自动持久化到 settings.json。Riku 在 EnvState 接口里加了俩字段:clothEnabledclothConfig。然后在 buildClothParamsLevel() 里,滑块回调不再改局部变量,而是改 envState.clothConfig,然后调 setEnvState() 触发持久化。

这样,用户调好的裙长、柔度、阻尼——关掉应用再打开,还在。

就像记住时刻表一样简单。


构建

Riku 跑了 npx vite build

没有红色错误。TypeScript 全部通过。

他打开应用,加载初音模型,打开场景菜单 → 物理 → 布料模拟。开关拨到「开」。

裙摆突然有了重量感——不是 MMD 动作里的关键帧动画,而是实时的、响应重力和碰撞的布料模拟。他拖动「布料柔度」滑块,数值越小,布料越硬;数值越大,布料越软,像丝绸。

Jieling 走过来,站在他身后看了一会儿。

"现在布料能用了?" 她问。

Riku 把开关关上,又打开。裙子消失,又出现。

"能用了。"


尾声:身体的尺寸

最后一小块拼图:SDF 碰撞器。

DEFAULT_BODY_CAPSULES 是一组写死尺寸的胶囊——腰、胸、肩、大腿、小腿,各一个。初音未来是 158cm,这是联邦里的基准身高。但联邦里有 140cm 的Q版模型,也有 180cm 的御姐。用同一组胶囊,小模型会穿模,大模型会飘。

"给所有的乘客同一张座位," Riku 对 Jieling 解释,"矮的人膝盖磕前面,高的人头挤上面。这不行。"

他在 xpbd-collider.ts 里加了 scaleAll(factor) 方法。逻辑很简单:遍历所有胶囊,把 halfHeightradius 都乘以 factor。然后在创建布料时,按模型高度动态计算缩放因子。

初音 158cm 是基准,缩放因子是 1.0。140cm 的模型,缩放 0.89;180cm 的模型,缩放 1.14。太小或太大的模型会被 clamped 到 [0.5, 2.0] 范围内,防止极端情况崩溃。

"这不是完美的骨骼绑定," Riku 说,"完美的方案需要从模型的骨骼层次结构动态生成碰撞胶囊。但作为 MVP——"

"它让布料模拟在绝大多数模型上都能用。" Jieling 接上。

"对。"

Riku 提交了代码。

5b52355 feat: XPBD 布料模拟 UI 接入 + status.md 更新
f31d5e9 feat: XPBD 布料配置持久化 + SDF 碰撞器骨骼绑定

功能在积累。列车的班次表越来越长,乘客越来越多。

第五卷,正在一章一章地长出来。


补记:滑块的手感

布料上线后的第二周,Jieling 走过来,鼠标停在「布料柔度」滑块上。

她按住左键,慢慢往右拖。数值从 0.005 爬到 0.008,再爬到 0.012。每经过一个刻度,裙摆闪了一下——消失,重建,再出现。拖过去的半秒里,裙摆闪了四五次,像坏掉的灯管。

"这个手感不行," 她说。

Riku 凑过来看。问题很明显——九个滑块,每个的回调里都写着 recreateCloth()。滑块每移动一个像素,回调就触发一次,布料就销毁一次、重建一次。粒子网格、距离约束、弯曲约束、碰撞胶囊——全都要从头来过。

"物理引擎本身跑得很快," Riku 说,"但销毁和重建的开销不在物理求解里,在 WebGL 资源的分配上。新建一个 Float32Array,新建一个 Mesh,上传顶点数据到 GPU——这些才是卡顿的来源。"

"所以不是引擎慢,是月台每来一个人就开关一次门。" Jieling 盯着屏幕上还在微微抖动的裙摆。

"对。"

Riku 靠回椅子上,想了一会儿。最干净的做法是参数化重建——只改柔度不重建网格,只改分段才重建。但那意味着给每个参数写一套增量更新逻辑,九个参数九条路径,维护成本太高。

"先用最笨的办法," 他说,"等用户松手了再重建。"

他在 buildClothParamsLevel()renderCustom 闭包里埋了一个计时器。九个滑块的回调不再直接调 recreateCloth(),而是调一个 debouncedRecreate()——如果 100 毫秒内又来了新的调用,就取消上一个计时器,重新倒数。只有当用户松开滑块、100 毫秒内没有新输入时,才真正触发一次重建。

typescript
let _recreateTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedRecreate = () => {
    if (_recreateTimer) clearTimeout(_recreateTimer);
    _recreateTimer = setTimeout(() => {
        _recreateTimer = null;
        recreateCloth();
    }, 100);
};

九行替换,九行代码。没动引擎,没动网格结构,没动约束求解器。

Jieling 又拖了一遍滑块。这次裙摆安静地等她松手,然后平滑地变到新参数。

"像是检票员等一群人到齐了才开门," 她说,"而不是每来一个就开一次。"

"差不多。"

Riku 没有提交这条改动。它太小了——一个闭包,一个计时器,九行替换。但它解决的是最被用户直接感知到的问题:手感。

物理引擎可以跑 60 帧的子步积分,SDF 碰撞器可以精确到胶囊级别,但如果用户拖一个滑块就卡顿,所有这些都会被归零。

月台造得再漂亮,检票口堵了,火车也白停。


教训:物理引擎写在代码里只是一堆公式。接上 UI,它才停靠进月台。接上持久化,它才有了时刻表。接上碰撞体缩放,它才为每一位乘客准备好了座位。接上防抖,检票员才不会每来一个人就开关一次门。