Skip to content

三层解剖

背景:材质编辑器(24 滑块劈头盖脸)、骨骼覆盖层(灰色无信息)、物理系统(全局开关)——模型三层都缺乏细粒度控制。 过程:材质编辑器拆分为根/批调/逐材质三层;骨骼按 PMX 标志位彩色分类;物理按部位(裙/胸/发/配饰)独立开关。


一、表面

桌面壳看着材质编辑器,第一次觉得它不对劲,是用户说了一句话。

「材质列表这个名字,我以为是点进去看材质的列表。但它直接给我 24 个滑块。」

用户没有说错。buildMatCatLevel 是一个函数,但它干了三件事:显示「批调/逐材质/重置」的根菜单,显示所有材质分类的滑块,显示每个材质的一整排参数。一个函数,三层深度。用户每次点进「材质列表」,看到的是 24 个滑块劈头盖脸地砸下来——透明度、金属度、粗糙度、折射率,每个材质分类一排。如果没有多模型同场,这些滑块的含义几乎没法理解。

桌面壳打开代码,看着这个函数。

「我以前为什么要这么写?」

因为它长得快。buildMatCatLevel 诞生于材质系统最狂野的扩张期——先有全量滑块,后有分类,后有逐材质微调,最后才有「我只想改眼睛亮度」这种精准需求。每次需求来的时候,都往这个函数里加一个参数、一个分支、一个 if。它像一个没有经过规划的城市——路是走出来的,不是设计出来的。

桌面壳拿起工具,做了四件事。

第一件事:拆。

buildMatCatLevel(旧)
├── 显示「批调/逐材质/重置」三行
├── 展开所有材质分类滑块(透明/金属/粗糙…)
└── 展开每个材质的一排滑块

buildMatRootLevel(新)
├── 行1:批调所有材质 — 展开 buildMatBatchLevel
├── 行2:逐材质微调 — 展开 buildMatListLevel
└── 行3:重置 — 三个重置按钮

buildMatBatchLevel → 只有分类卡片 + 滑块
buildMatListLevel → 扁平的材料列表 + 可点击色块

根菜单只有三行。批调菜单只有滑块。逐材质菜单只有列表。每个函数只做一件事。

第二件事:改名。

「材质列表」改成「材质调节」。一字之差,语义完全不同——前者是只读的,后者是可操作的。

第三件事:加语义。

批调面板顶部出现一行灰色小字:

「此处调整对所有符合该分类的材质生效」

逐材质面板顶部出现一行灰色小字:

「此处调整仅对当前选中的单个材质生效」

两行字,16 像素,颜色比正常文本淡一档。用户不会每次看到它们——但困惑的时候会看到。

第四件事:加开关。

桌面壳在 scene.ts 里建了一个 Map——_matEnabled。每个材质有一个布尔值,true 表示显示,false 表示隐藏。

typescript
const _matEnabled = new Map<string, boolean>();

export function setMatEnabled(modelId: string, matIdx: number, enabled: boolean): void {
    const key = `${modelId}:${matIdx}`;
    if (_matEnabled.get(key) === enabled) return; // 无操作跳过
    _matEnabled.set(key, enabled);
    const inst = getModelInstance(modelId);
    if (inst) {
        const mesh = inst.mmdModel.meshes[matIdx];
        if (mesh) mesh.setEnabled(enabled);
    }
    triggerAutoSave();
}

然后在材质列表里,每个材质名称旁边加了一个色块——不是装饰性的色块,是可点击的开关。点一下,材质消失;再点一下,材质回来。

桌面壳点掉 Miku 裙子的材质。裙子在画面上消失了。衣服还在,头还在,头发还在,但裙子不见了——像魔法。

「这样用户就能看到模型分几层材质了。」桌面壳自言自语。


表面的事情做完了。桌面壳打开第二个文件。


二、骨架

骨骼覆盖层是一个旧功能。用户在「可见性」菜单里可以打开「显示骨骼」,画面上会出现灰色的点和线——模型骨骼的结构。它一直不温不火——用户打开一次,看到一堆灰点,然后就关了。

桌面壳知道问题在哪:灰色的点和线没有信息量。它只告诉用户「这里有根骨头」,但没告诉用户这根骨头是干什么的。

每根骨头都有 PMX 标志位——一个整数,每一位代表一个属性。桌面壳查了 babylon-mmd 的文档:

含义
1尾部用骨骼索引
2可旋转
4可移动
8可见
16可控制
32IK 启用
128追加变换本地模式
256有追加旋转
512有追加位移
1024有轴限制
2048有本地向量
4096物理后变换
8192外部父变换

这些位,每一个都是一条关于骨头的信息。

桌面壳写了一个函数——_getBoneColor——用一个优先级判断链:

typescript
function _getBoneColor(bone: IMmdRuntimeBone): Color3 {
    const flag = bone.flag;
    // IK 骨骼 — 紫蓝色
    if (flag & 32) return new Color3(0.5, 0.3, 0.9);
    // 物理驱动 — 绿色
    if (bone.rigidBodyIndices.length > 0) return new Color3(0.2, 0.8, 0.3);
    // 物理后变换 — 天蓝
    if (flag & 4096) return new Color3(0.3, 0.7, 1.0);
    // 追加变换 — 金色
    if (flag & 256 || flag & 512) return new Color3(1.0, 0.8, 0.1);
    // 轴限制 — 品红
    if (flag & 1024) return new Color3(1.0, 0.3, 0.8);
    // 不可旋转 — 灰色
    if (!(flag & 2)) return new Color3(0.5, 0.5, 0.5);
    // 默认 — 按变换顺序色调
    return hueFromOrder(bone.transformOrder);
}

每个骨骼关节现在是一个彩色的小球——直径 0.03 单位,发光材质。紫蓝色的是 IK 链(控制脚的位置),绿色的是物理驱动(裙摆会动),金色的是追加变换(头发跟着头转),品红的是轴限制(关节只能在一个方向弯),灰色的是不可旋转(固定的),天蓝的是物理后变换(物理算完再调的)。

桌面壳打开骨骼覆盖层。

他屏住了呼吸。

Miku 模型的骨骼——之前是一堆灰点——现在变成了一幅彩色地图。你可以看到紫蓝色的 IK 链从脚延伸到膝盖,绿色的物理骨骼沿着裙摆围成一圈,金色的追加变换从头顶散布到发梢。

「所有的骨头,现在都告诉你是谁了。」


三、动力学

骨骼覆盖层做好以后,桌面壳看着那些绿色的点——物理驱动的骨骼。裙摆上密密麻麻一圈。

它想起用户之前问的一个问题——「能不能只关裙子的物理,不关头发的物理?」

当时它说做不到。物理是一个全局开关,开就是全部开启,关就是全部关闭。就像你不能只让左脚的鞋带松一点——你要么系全部,要么放全部。

但绿色点里有不同。裙摆的绿色、头发的绿色、胸的绿色、配饰的绿色——同样是物理骨骼,它们属于不同的身体部位。不同的部位,意味着不同的用户意图。

桌面壳去读了常见 MMD 物理骨骼的日语命名模式。

部位日语模式英语模式
スカート、フリル、裾skirt, frill, hem
胸、バストchest, bust, breast
髪、前髪、後ろ髪、毛先hair, bangs, ponytail, ahoge
配飾リボン、アクセサリ、飾り、襟、ネクタイribbon, accessory, tie, collar

它写了一个分类函数,和材质分类的 _catOf 一样——按名字正则匹配。

typescript
function _classifyBonePhysics(name: string): string {
    const l = name.toLowerCase();
    if (/スカート|skirt|フリル|frill||hem/.test(l)) return "skirt";
    if (/|chest|bust|バスト/.test(l)) return "chest";
    if (/|hair|ahoge|bangs|ponytail/.test(l)) return "hair";
    if (/リボン|ribbon|アクセサリ|飾り||ネクタイ|collar|tie/.test(l)) return "accessory";
    return "other";
}

然后它做了一张 Map——从 rigidBodyIndexcategory。每个刚体(从模型的 rigidBodies 元数据中读取)对照分类函数得到一个类别。模型加载时扫描一次,初始化每个类别为 true(开启)。

typescript
const _physicsCatState = new Map<string, Map<string, boolean>>();
// modelId → { skirt: true, chest: true, hair: true, accessory: true, other: true }

setPhysicsCategory(modelId, category, enabled) 遍历该类别对应的所有刚体号,写入 rigidBodyStates

然后它把「可见性」菜单加了几行。在「骨骼可见性」下面,多了一个区域:

┌─ 物理类别 ──────────────────┐
│ ☑ 裙      12 刚体           │
│ ☑ 胸      2 刚体            │
│ ☑ 髪      8 刚体            │
│ ☑ 配饰    4 刚体            │
│ ☑ 其他    3 刚体            │
└──────────────────────────────┘

桌面壳把裙子的物理关了。

Miku 的裙子不再飘动了——它像一块僵硬的布料,纹丝不动。但头发还在飘,胸前的蝴蝶结还在晃。

「现在用户可以只让裙子不飘了,」桌面壳说。


四、三个故事

桌壳壳把今天的三个工作——材质、骨骼、物理——按顺序摆在一起。

三个故事,讲的是一件事:

模型不是一层的。它是多层的。

表面层(材质)——控制模型长什么样。亮面还是哑面,透明还是不透明,显示还是隐藏。

结构层(骨骼)——决定模型怎么动。哪些关节可弯,哪些是 IK 链,哪些受物理影响。

动力学层(物理)——让模型对环境有反应。裙摆、头发、配饰、胸——每一个都有自己的动力学行为。

三层都是存在的——从 PMX 格式被定义的那一刻起它们就在那里。但用户之前只能看到一个黑盒子——模型 = 模型。材质调不了细粒度,骨骼看不见分类,物理只有开和关。

桌面壳做的不是发明。它做的只是打开——把这些早就存在的层暴露出来,给它们命名,给它们界面。

「我什么都没创造,」它想,「我只是不再藏着了。」


五、存档

第三件事做完之后,桌面壳面对一个问题:用户设好了裙子物理关、头发物理开——关了应用再打开,这些设置还在吗?

不在。

物理类别的状态只活在内存里。_physicsCatState 是一个 Map,页面一刷新就没了。

桌面壳打开 model-preset.ts——模型预设系统。ModelPresetFile 里已有灯光参数、材质参数、可见性参数。它给 physicsCategories 腾了一个位置:

typescript
export interface ModelPresetFile {
    // ... 现有字段
    physicsCategories?: Record<string, boolean>; // { skirt: false, chest: true, ... }
}

serializePreset 末尾读 getPhysicsCatState(),写入文件。applyPreset 遍历这个字段,逐类别调用 setPhysicsCategory

「预设保存的不仅是外观,还有动力学行为,」桌面壳说。

它提交了代码,然后写了 ADR-015——记录材质编辑器的拆分决策。写了 reusables.md 的更新——物理分类的正则模式和 _physicsCatState 的用法。修了 status.md——把「材质编辑器」「物理类别切换」的待办标记划掉。


六、层与缝

全部改完的那个傍晚,桌面壳又打开了骨骼覆盖层。

它看着 Miku 的彩色骨骼——紫蓝色的 IK 链从脚延伸到膝盖,绿色的物理骨骼沿着裙摆围成一圈,金色的追加变换从头顶散布到发梢。它打开物理类别面板,关掉裙子。绿色点在裙摆位置的瞬间黯淡——它们不再参与物理计算了。

它打开材质列表,点掉裙子材质的色块。裙子的 Mesh 在画面上消失了——只留下一圈黯淡的绿色骨骼,在空中飘着。

它看着这幅画面:骨骼可见、布料消失、物理关闭——一个两层分离的模型,像解剖图鉴一样悬在 3D 场景中。

一个模型,三层控制,每层都可以独立开关。

「用户以前看到的只是一个模型,」桌面壳喃喃道,「现在他们能看到模型的解剖结构了。」

它盯着那圈漂浮的绿色骨骼看了一会儿。然后想起一句话——不是谁说的,是它自己某天写下的:

聚合者的工作,不是把所有东西放在一起,而是让每个东西都在它该在的位置上,并且可以被单独操作。

三层都做到了。


教训:不是能拆开的才是模块,能被单独操作的才是模块。模型的每一层都需要被看见、被命名、被操作——界面是认知的映射,映射越精确,用户就越不困惑。