Skip to content

ADR-084: Mesh-to-Cloth 虚拟裙骨生成 —— WASM Bullet 运行时刚体注入

状态: 已完成(Phase 1-5 POC 全链路 + P2/P3 审计加固 + 单测全绿 + 五语言 i18n。代码层无剩余待办;「真机/模型实测微调参数」归用户验证,非开发任务。P2b(A1 蒙皮天花板)/P3c(worldId 回收) 为已知限制,详见 §3.5 / §十 P3c) 日期: 2026-07-19

关闭说明(2026-07-19):原状态行长期挂「实施中」黄灯,但实际代码与单测早已全绿,剩余项均非开发任务。逐项核查后关闭:

  • 真机实测微调参数:依赖用户在实际模型上反馈观感,非 AI/开发可推进的代码工作;若实测暴露参数问题,应另起小修 ADR 而非维持本 ADR 黄灯。
  • P2b(A1 蒙皮天花板):POC 故意取舍,升级路径 A2 已文档化于 §3.5,需要时按 A2 立新 ADR。
  • P3c(worldId 回收):WASM Bullet 无释放 API,§十 P3c 已说明,属运行时平台限制。 三项均非"未实现",而是"已知的、有明确触发条件的设计边界"。维持黄灯会让 grep 状态行 永远返回"实施中",掩盖真正的进行中工作。 关联: ADR-054(P2 路线图)、ADR-081(XPBD 移除)、ADR-019(XPBD 布料,已废弃)、ADR-029(物理 UI 重构)、ADR-043(竞品差距分析)

一、背景

1.1 问题来源

ADR-054 路线图 P2 第 4 项列出了「Mesh-to-Cloth 自动布料」——对没有自带裙骨的 PMX 模型,从 mesh 几何自动识别裙摆区域并生成物理约束,使裙摆产生动态飘动。

原计划基于 TS XPBD 求解器实现。ADR-081(2026-07-10)全栈移除 TS XPBD 后,该功能失去物理后端,路线图标注「转 WASM Bullet」但未评估可行性。

1.2 竞品参照

DanceXR(ADR-043 差距分析 D 类)已实现 Mesh-to-Cloth。本 ADR 的目标是补齐这一差距。

1.3 不做什么

  • ❌ 不实现顶点级有限元布料(那是 Blender/DCC 软件的领域)
  • ❌ 不修改 babylon-mmd WASM 源码(fork)
  • ❌ 不替换 MMD 自带裙骨物理(仅在模型缺少裙骨时补充)
  • ✅ 只做骨骼级刚体 + 弹簧约束——利用 WASM Bullet 现有 public API 注入运行时刚体

二、API 可行性核实(2026-07-11)

对 babylon-mmd v9.14.0 WASM Bullet 绑定层做了完整 API 审计。结论:无需 fork,全部 public API 可用

2.1 物理世界入口

API文件用途
MmdWasmPhysicsRuntimeImpl.addRigidBody(rb, worldId)Physics/mmdWasmPhysicsRuntimeImpl.d.ts:96运行时添加刚体到物理世界
MmdWasmPhysicsRuntimeImpl.addConstraint(c, worldId, disableCollisions)同上 :245运行时添加约束
MmdWasmPhysicsRuntimeImpl.removeRigidBody(rb, worldId)同上 :107移除刚体
MmdWasmPhysicsRuntimeImpl.removeConstraint(c, worldId)同上 :254移除约束

2.2 刚体构造

API文件用途
RigidBodyConstructionInfoBind/rigidBodyConstructionInfo.d.ts刚体属性容器(shape/mass/motionType/damping/friction/restitution/collisionGroup/Mask)
RigidBody(runtime, info)Bind/rigidBody.d.ts:35从 ConstructionInfo 创建刚体实例
MotionType.Dynamic = 0Bind/motionType.d.ts动态刚体(受力影响)
MotionType.Kinematic = 2同上运动学刚体(可手动移动,不受力)

2.3 碰撞形状

形状类构造参数裙骨用途
PhysicsSphereShape(runtime, radius)半径裙骨节点碰撞体(轻量)
PhysicsCapsuleShape(runtime, radius, height)半径+高度裙骨节点碰撞体(更贴合胶囊形)
PhysicsBoxShape(runtime, size)半尺寸腰部锚定体

2.4 弹簧约束(关键)

typescript
// Generic6DofSpringConstraint — 6 自由度弹簧约束
class Generic6DofSpringConstraint extends Generic6DofConstraintBase {
    constructor(
        runtime: IPhysicsRuntime,
        bodyA: RigidBody, bodyB: RigidBody,
        frameA: Matrix, frameB: Matrix,
        useLinearReferenceFrameA: boolean
    );
    enableSpring(index: number, onOff: boolean): void;    // 0-5 轴弹簧开关
    setStiffness(index: number, stiffness: number): void; // 弹簧刚度
    setDamping(index: number, damping: number): void;     // 弹簧阻尼
}

index 语义:0-2 = 线性 X/Y/Z,3-5 = 角 X/Y/Z。裙骨链需要限制为铰链式(角 X/Y 自由、其余锁定)或全自由弹簧。

2.5 刚体变换读写

API用途
rb.setTransformMatrix(matrix)设置运动学刚体位置(Kinematic 模式)
rb.setDynamicTransformMatrix(matrix)设置动态刚体位置
rb.getTransformMatrixToArray(arr, offset)读取刚体世界变换矩阵

2.6 Mesh 拓扑访问

MmdModel.mesh 是标准 Babylon.js Mesh,可直接读取:

typescript
const positions = mesh.getVerticesData(VertexBuffer.PositionKind);    // Float32Array
const indices   = mesh.getIndices();                                    // Uint32Array
const weights   = mesh.getVerticesData(VertexBuffer.MatricesWeightsKind); // 骨骼权重
const boneIdx   = mesh.getVerticesData(VertexBuffer.MatricesIndicesKind); // 骨骼索引

2.7 已有基础设施

physics/physics-bridge.ts(ADR-081 §五保留)提供:

  • findRuntimeBone(model, name) — 骨骼查找
  • getBoneWorldPosition(model, name) — 世界坐标读取
  • autoFitAttachment(anchor, opts) — 几何参数推算(含 radial topology 模式)
  • PerFrameUpdateRegistry — 每帧回调调度(自动管理 observer 生命周期)

三、架构设计

3.1 三阶段管线

PMX Mesh 顶点数据


┌──────────────────────────┐
│ Phase 1: 裙摆拓扑分析      │  纯几何,无物理依赖,可单测
│  · 边缘检测(开放边)       │  · 找 boundary edges(仅被 1 个三角形引用的边)
│  · 裙边环识别              │  · 按 Y 坐标最低的 boundary edge 连通分量 → 裙边
│  · 分链(径向)            │  · 沿法线向内分层 → 每层一个虚拟骨节
│  · 顶点→骨节映射           │  · 最近邻分配 + 距离衰减权重
└───────────┬──────────────┘

┌──────────────────────────┐
│ Phase 2: WASM Bullet 注入  │  运行时刚体 + 弹簧约束创建
│  · 每个骨节 → Sphere RB    │  · MotionType.Dynamic, 小 mass
│  · 父子链 → Spring Const.  │  · Generic6DofSpringConstraint
│  · 链头锚定腰骨            │  · Kinematic RB setTransformMatrix(腰骨)
│  · 腰体 → Box RB (Kinematic) │  · addRigidBodyToGlobal
└───────────┬──────────────┘

┌──────────────────────────┐
│ Phase 3: 每帧顶点回写      │  PerFrameUpdateRegistry 调度
│  · 读刚体世界矩阵          │  · rb.getTransformMatrixToArray()
│  · 计算偏移量              │  · currentPos - restPos → delta
│  · 写入 vertex buffer      │  · mesh.updateVerticesData()
└──────────────────────────┘

3.2 Phase 1 拓扑分析算法

输入: positions[], indices[], boneWeights[], boneIndices[]
输出: SkirtChains[]  (每条链 = 骨节链, 每个骨节 = {restPos, vertexIndices[]})

1. 构建 edge→triangle 映射
   · 对每个三角形的三条边: edge[(min,max)] → triangleCount++
   · boundary edges = count == 1 的边

2. 辅助过滤(模型已有裙骨时跳过)
   · 骨骼名匹配: /skirt|裾|スカート/i → 若已有裙骨则跳过自动生成
   · 仅对无裙骨模型执行

3. 裙边环提取
   · 取 Y 坐标 < 全身 30% 的 boundary edges
   · 连通分量分析 → 每个连通分量 = 一条裙边环段

4. 径向分链
   · 从裙边环每个顶点沿法线向内偏移 N 层(N = segmentsV, 默认 8-16)
   · 每层取平均位置作为骨节 restPos
   · 同一径向方向的 N 层 = 一条链

5. 顶点→骨节映射
   · 对每个顶点, 找最近的 2 个骨节
   · 按距离反比分配权重(类似线性 blend skinning)

3.3 Phase 2 物理注入

typescript
// 伪代码 — 创建一条裙骨链
function createSkirtChain(
    runtime: MmdWasmPhysicsRuntimeImpl,
    worldId: number,
    chain: SkirtChain,
    anchorBoneWorldPos: Vector3
): { rigidBodies: RigidBody[]; constraints: Constraint[] } {
    const rbs: RigidBody[] = [];
    const constraints: Constraint[] = [];

    // 链头: Kinematic 锚定体 (跟随腰骨)
    const anchorInfo = new RigidBodyConstructionInfo(wasmInstance);
    anchorInfo.shape = new PhysicsBoxShape(runtime, new Vector3(0.1, 0.05, 0.1));
    anchorInfo.motionType = MotionType.Kinematic;
    anchorInfo.mass = 0;
    const anchorRb = new RigidBody(runtime, anchorInfo);
    runtime.addRigidBody(anchorRb, worldId);

    // 链身: Dynamic 球体 + 弹簧约束
    for (let i = 0; i < chain.segments.length; i++) {
        const seg = chain.segments[i];
        const info = new RigidBodyConstructionInfo(wasmInstance);
        info.shape = new PhysicsSphereShape(runtime, seg.radius);
        info.motionType = MotionType.Dynamic;
        info.mass = 0.05;           // 轻质量
        info.linearDamping = 0.1;    // 阻尼防止抖动
        info.angularDamping = 0.3;
        info.friction = 0.5;
        info.restitution = 0.0;      // 不弹
        info.setInitialTransform(
            Matrix.Translation(seg.restPosition[0], seg.restPosition[1], seg.restPosition[2]),
        ); // 初始变换由 restPosition 构造(已核实 babylon-mmd RigidBodyConstructionInfo.setInitialTransform 真实存在)
        info.disableDeactivation = true;

        const rb = new RigidBody(runtime, info);
        runtime.addRigidBody(rb, worldId);
        rbs.push(rb);

        // 弹簧约束: 连接到父刚体
        const parent = i === 0 ? anchorRb : rbs[i - 1];
        const spring = new Generic6DofSpringConstraint(
            runtime, parent, rb,
            Matrix.Identity(), Matrix.Identity(),
            false
        );
        spring.enableSpring(0, true); spring.setStiffness(0, 50);  // X 线性
        spring.enableSpring(1, true); spring.setStiffness(1, 50);  // Y 线性
        spring.enableSpring(2, true); spring.setStiffness(2, 50);  // Z 线性
        spring.enableSpring(3, true); spring.setStiffness(3, 30);  // X 角
        spring.enableSpring(4, true); spring.setStiffness(4, 30);  // Y 角
        spring.enableSpring(5, false); // Z 角锁定(防扭转)
        spring.setDamping(0, 0.3);
        spring.setDamping(1, 0.3);
        spring.setDamping(2, 0.3);
        runtime.addConstraint(spring, worldId, true);
        constraints.push(spring);
    }

    return { rigidBodies: rbs, constraints };
}

3.4 Phase 3 顶点回写(策略 A1: 直接写 vertex buffer)

typescript
// PerFrameUpdateRegistry 回调(实际为 VirtualSkirtController._writeBackVertices 方法)
// 注意:buf / tmp 为类成员缓存,每帧复用,不重新分配(避免 GC 压力)
function writeBackVertices(
    mesh: Mesh,
    restPositions: Float32Array,
    chains: SkirtChain[],
    rigidBodies: RigidBody[]
): void {
    const buf = workBuf;     // 类成员缓存,长度 = restPositions.length
    buf.set(restPositions);  // 基准 = 静态 rest pose

    const tmp = tmpArray;    // 类成员缓存 Float32Array(16)
    let rbIdx = 0;
    for (const chain of chains) {
        for (const seg of chain.segments) {
            const rb = rigidBodies[rbIdx++];
            rb.getTransformMatrixToArray(tmp, 0);
            // 从刚体矩阵提取位移 delta = currentPos - restPos
            const dx = tmp[12] - seg.restPosition[0];
            const dy = tmp[13] - seg.restPosition[1];
            const dz = tmp[14] - seg.restPosition[2];
            // 按权重偏移该骨节关联的顶点(vertexIndices / weights 平行数组)
            for (let i = 0; i < seg.vertexIndices.length; i++) {
                const v = seg.vertexIndices[i];
                const w = seg.weights[i];
                buf[v * 3]     += dx * w;
                buf[v * 3 + 1] += dy * w;
                buf[v * 3 + 2] += dz * w;
            }
        }
    }

    mesh.updateVerticesData(VertexBuffer.PositionKind, buf, false, false);
}

3.5 顶点回写策略对比

策略做法优点缺点适用场景
A1. 直接写 buffer每帧把刚体位置→顶点偏移写入 PositionKind简单直接,无需改 skeleton仅位移不含旋转形变POC 验证
A2. 虚拟骨骼注入添加 Skeleton bone + 重分配权重形变自然,Babylon 蒙皮管线自动处理侵入 skeleton 结构,需对齐 beforePhysics/afterPhysics正式版

决策:POC 先走 A1,验证裙摆飘动效果。若形变质量不足(裙摆"飘但不像布"),升级到 A2。

3.6 Phase 5 性能 / LOD / 降频(Android 适配)

让低端机(Android)也能实时裙摆,核心是把「每帧全量顶点上传」拆成可降级的三维:

维度控制点档位映射(QUALITY_PRESETS)
LOD 链数上限物理刚体数量(链式弹簧数)high 32 / medium 16 / low 10
LOD 骨节上限单链刚体数high 16 / medium 10 / low 6
降频步长顶点回写间隔(每 N 帧上传 GPU)high 1 / medium 2 / low 3
顶点硬上限超过则跳过该模型(不注入)high 4000 / medium 2500 / low 1500

质量档位解析resolveVirtualSkirtQuality,纯函数):

  • auto(默认)→ 桌面 high / Android(经 isAndroidPlatform()low
  • high / medium / low 固定档,用户手动覆盖

降频实现(不牺牲物理真实感):

  • 物理始终在 WASM Bullet 内持续模拟(60Hz);
  • PerFrameUpdateRegistry 每帧回调中:锚定体跟随腰骨每帧执行(保证裙摆不脱腰),顶点写回按 _frame % _throttleEvery === 0 降频;
  • 帧计数器 _frame 从 0 起,首帧必写回,避免启动瞬间的静止。

设计约束遵守:质量档位解析抽为 resolveVirtualSkirtQuality 纯函数(不依赖运行期平台探测副作用),单测直接覆盖;virtual-skirt.ts 仍不被 scene.ts eager 导入。


四、实施计划

4.1 分阶段交付

阶段内容产出验收标准
Phase 1-POC裙摆拓扑分析算法scene/physics/skirt-analyzer.ts + 单元测试给定测试 mesh, 正确识别裙边环 + 分链
Phase 2-POCWASM Bullet 注入scene/physics/virtual-skirt.ts无裙骨模型加载后裙骨链创建成功, 无报错
Phase 3-POC顶点回写 + 联调PerFrameUpdateRegistry 回调裙摆在风力/移动下可见飘动
Phase 4-UI菜单入口 + 参数面板menus/motion-skirt-levels.ts开关 + 链数/刚度/阻尼滑块
Phase 5-优化性能 + Android 适配质量档位 / LOD 上限 / 降频 + motion:virtualSkirt 质量选择 UI桌面 60fps, Android 降频后稳定(✅ 已实现:QUALITY_PRESETS + 帧计数器降频)

4.2 文件规划

frontend/src/
  scene/physics/
    skirt-analyzer.ts      # Phase 1: 拓扑分析(纯几何,无依赖)
    virtual-skirt.ts       # Phase 2-3: WASM Bullet 注入 + 回写
  __tests__/
    skirt-analyzer.test.ts  # Phase 1 单测
  menus/
    motion-skirt-levels.ts  # Phase 4 UI

设计约束virtual-skirt.ts 不被 scene.ts 启动期 eager 导入(ADR-081 教训)。仅通过用户显式开启时按需 await import()

4.3 参数体系

typescript
interface VirtualSkirtConfig {
    enabled: boolean;          // 开关
    chains: number;            // 链数(默认 12, 范围 4-32)
    segmentsPerChain: number;  // 每链骨节数(默认 8, 范围 4-16)
    stiffness: number;         // 弹簧刚度(默认 50, 范围 10-200)
    damping: number;           // 弹簧阻尼(默认 0.3, 范围 0-1)
    mass: number;              // 单骨节质量(默认 0.05, 范围 0.01-0.5)
    radius: number;            // 碰撞球半径(默认 auto, 按 mesh 尺寸推算)
    maxVertices: number;       // 顶点数上限(性能保护, 默认 2000)
}

五、风险评估

风险严重度说明缓解
MMD 物理管线冲突🟡 中MMD runtime beforePhysics/afterPhysics 管控骨骼更新; 我们的 vertex buffer 写入需在 afterPhysics 之后执行PerFrameUpdateRegistryonBeforeRenderObservable 中注册, MMD 物理在 onBeforeAnimations/onAfterPhysics 阶段, 时序应正确; POC 需验证
双重位移🟡 中模型可能已有部分裙骨, 自动生成的刚体与已有物理叠加Phase 1 先检测骨骼名 /skirt|裾|スカート/i, 已有裙骨则跳过
拓扑分析不准🟡 中并非所有 PMX 模型的裙摆都有清晰 boundary edge多策略 fallback: 边缘检测 → 骨骼名匹配 → 按 Y < 30% 强制截取
性能🟡 中每帧 vertex buffer 更新; 大模型可能 >2000 顶点maxVertices 上限 + LOD 降级 + Android 降频
worldId 冲突🟢 低WASM Bullet 用 worldId 隔离物理世界; 注入刚体需用正确 worldId从 MmdWasmRuntime 获取 model 对应的 worldId
dispose 链路🟡 中刚体/约束/ConstructionInfo 需按序 dispose, 且需从物理世界 remove 后才能 dispose封装 disposeVirtualSkirt(): removeConstraint → removeRigidBody → rb.dispose → shape.dispose → info.dispose

六、与受影响 ADR 的关系

ADR关系处置
ADR-054P2 路线图第 4 项路线图条目更新引用本 ADR
ADR-081XPBD 移除后 physics-bridge.ts 保留直接复用 physics-bridge.tsPerFrameUpdateRegistry + autoFitAttachment + findRuntimeBone
ADR-019XPBD 布料选型(已废弃)本 ADR 完全替代, 不复活 XPBD
ADR-029物理 UI 重构新增 motion-skirt-levels.ts 遵循 ADR-029 的物理分治模式
ADR-043竞品差距 D 类补齐 DanceXR 的 Mesh-to-Cloth 差距

七、验证计划

bash
# Phase 1 单测
cd frontend && npx vitest run src/__tests__/skirt-analyzer.test.ts

# 类型检查
cd frontend && npm run check

# 全量单测不回归
cd frontend && npm run test

# 运行时验证
# 1. 加载无裙骨模型 → 开启虚拟裙骨 → 裙摆可见飘动
# 2. 加载有裙骨模型 → 开启虚拟裙骨 → 自动跳过(不叠加)
# 3. 切换模型 → 旧刚体正确 dispose, 无泄漏

八、代码审核记录(2026-07-11)

审核结论:有条件通过。三条意见处置如下:

级别位置意见处置状态
🟡 P2ADR §3.3setInitialTransform 是否真实存在未核实已核实:rigidBodyConstructionInfo.d.ts:48 声明 + .js:181 真实实现(写入 WASM 初始变换)。原实现正确,无需改误报消除
🟡 P3virtual-skirt.ts:272每帧 new Float32Array(16) 产生 GC 压力改为类成员缓存:_tmpArray(16)+ _workBuf(顶点工作缓冲,按 rest.length 惰性分配后每帧 .set() 复用);dispose() 中置 null✅ 已修
🟢 P4ADR §3.4伪代码用 segments[i].restX,代码用 seg.restPosition[0]ADR §3.4 伪代码对齐实现:restPosition[0/1/2] + 嵌套链循环 + 平行数组权重 + 缓存缓冲复用;§3.3 seg.restMatrix 占位字段同步改为 Matrix.Translation(restPosition)✅ 已修

验证npm run check(改动文件零类型错误)+ vitest 31/31 通过。


九、逻辑审计修复记录(2026-07-12)

P1 — 坐标空间不一致(🔴 必修,已修复)

根因核实:审计原建议"统一到 mesh 局部空间"。经核对 babylon-mmd 源码 mmdBulletPhysics.js:555-559

js
// then convert the body transform to world space
Vector3.TransformCoordinatesToRef(initialPosition, worldMatrix, initialPosition);
Matrix.ComposeToRef(one, initialRotation, initialPosition, initialTransformMatrix);
rbInfoList.setInitialTransform(index, initialTransformMatrix);

WASM 物理世界使用的是「世界坐标」(PMX 刚体在 setInitialTransform 前被 worldMatrix 转到世界空间)。因此审计的"局部空间"建议不成立,正确修法是 让本模块与世界坐标约定一致:

环节修复前修复后
骨节初始化 (setInitialTransform)局部 restPosition(被错放在世界原点)localToWorld(restPosition, meshWorld)世界坐标
每帧写回 delta世界位移 − 局部rest(混空间)世界位移 − localToWorld(restPosition, meshWorld)世界位移worldDeltaToLocal(·, meshWorldInv)局部位移 → 写 PositionKind
worldId复用模型 _physicsModel._worldId(私有字段,脆弱)始终分配专用 worldnextWorldId++),避免与 PMX 刚体坐标系/碰撞干扰

引入两个可单测的纯函数(virtual-skirt.ts 模块级导出):

  • localToWorld(local, world, out) — 局部点 → 世界点(含平移)
  • worldDeltaToLocal(delta, worldInv, out) — 世界位移 → 局部位移(仅旋转/缩放,忽略平移)

每帧 _update() 刷新 meshWorld / meshWorldInv(模型可平移/旋转),写回时实时转换。 效果:模型整体平移/旋转时,裙摆随模型移动而不漂移;多模型布局 / AR 叠加(ADR-055) 下不再错位。

专项测试(新增 6 个,共 20):

  • 坐标转换纯函数 4 例:平移/旋转 round-trip / 纯平移方向不变 / 端到端不漂移
  • P1 坐标空间一致性 2 例:骨节初始位置在世界空间(含 +10 平移)、平移 mesh 每帧写回不抛错

注:worldId 分配后未显式释放(WASM 侧无直接释放 API),属已知限制,POC 阶段可接受; 长期可评估复用模型自身 world 时由模型卸载统一回收。

验证npm run check(零类型错误)+ npm run test(全量 1272 通过,+6 P1 测试,零回归)。


十、P2 / P3 审计加固记录(2026-07-12)

逻辑审计(§九)后,用户要求继续推进 P2/P3 剩余项。本轮落地如下:

P2 — 拓扑分析的鲁棒性(🔴/🟡 必修,已修复)

位置问题修复状态
🔴 P2a 穿裤误判skirt-analyzer.ts 底部连通分量选择 + fallbacky-threshold fallback 收集全部 Y ≤ 阈值 顶点,会把裤子/腿柱误判为裙摆并生成骨节① 先收集所有「底部」连通分量;若出现 ≥2 个分离底环(左右腿洞)→ 判定非裙摆,安全跳过;② fallback 增加径向外扩过滤:仅保留径向距离 ≥ 35% 最大径向的顶点(裙摆为外扩锥,腿/裤为中央柱)✅ 已修(新增裤子 mesh 测试钉死)

P2b 已知限制(A1 蒙皮二次变换天花板):策略 A1 仅做「顶点位移」,不含骨骼旋转形变,裙摆在高速甩动时「飘但不像布」。已确认为 POC 故意取舍,升级路径为策略 A2(注入虚拟 Skeleton bone + 重分配权重,由 Babylon 蒙皮管线处理旋转形变),记入 §3.5,暂不实现。

P3 — 运行时健壮性与正确性(🟡 已修复)

位置问题修复状态
🟡 P3a 异常清理virtual-skirt.ts build()注入中途抛异常会泄漏半初始化的刚体/约束/构造信息/形状WASM 注入块包入 try/catch,失败时调用 dispose() 释放已分配资源并返回 false✅ 已修
🟡 P3b 卸载接入scene.ts modelManager.onRemoveModeldisposeVirtualSkirtForModel 未接到模型卸载流程 → 切换/删除模型时控制器泄漏onRemoveModel 回调内动态 import('./menus/motion-cloth-levels') 后调用 disposeVirtualSkirtForModel(id);保持 virtual-skirt 非 eager 导入约束✅ 已修
🟡 P3d 链间缝隙skirt-analyzer.ts 第 9 步原「每链内顶点映射」使链边界顶点只受单一链骨节影响 → 相邻链独立位移导致撕裂改为全局跨链映射:收集所有骨节,每个顶点取全局最近 2 个骨节 + 距离反比权重✅ 已修
🟡 P3e 腰骨缓存virtual-skirt.ts_update() 每帧遍历 runtimeBones 查找腰骨,浪费build() 解析并缓存腰骨对象引用 _waistBone_update() 每帧复用✅ 已修
🟡 P3f 包围盒刷新virtual-skirt.ts _writeBackVertices顶点位移后未刷新包围盒 → 视锥剔除/拾取错位写完 PositionKind 后调用 mesh.refreshBoundingInfo()(带 ?. 守卫)✅ 已修
🟢 P3c worldId 回收virtual-skirt.ts专用 worldId 分配后无 WASM 侧释放 API维持 §九 已知限制:POC 可接受;长期评估复用模型自身 world 时随卸载回收已知限制(文档化)

本轮新增测试(skirt-analyzer + virtual-skirt)

  • skirt-analyzer双分离底环裤子 mesh → 返回空链(钉死 P2a 防误判);fallback 径向过滤不误吞球体下半(既有测试仍通过)
  • virtual-skirtbuild() 注入异常 → 部分资源被 dispose 且无泄漏腰骨缓存 _waistBone 被设置且 _update 复用

验证npm run check(零类型错误)+ npm run test(全量 1272 → 1276 通过,+4 P2a/P3a/P3e 测试,零回归)。