Lite 版 5·29 迭代复盘:骨骼模型缩放修复、微调控件与预览纹理恢复

这一轮迭代围绕一个核心问题展开:带骨骼的 SkinnedMesh 模型在缩放时"爆炸飞散"。从定位根因到修复,再到衍生出的缩放微调需求、相机聚焦距离修正、以及上传预览弹出框的纹理丢失和白色背景——五个改动串联起来,恰好构成了一条从"不能缩放"到"缩放体验完整"的完整链路。

这篇文章复盘 5 月 29 日当天的五个核心改动:

先直接体验这套链路

建议在编辑器中导入一个带骨骼的模型(如海龟),缩放观察是否正常;点击模型列表的"聚焦"按钮观察相机距离;上传模型查看预览弹出框。

一、SkinnedMesh 缩放爆炸:从 S² 到 S

1.1 现象

导入一个带骨骼的模型(如海龟),模型默认大小下显示正常。但当通过编辑器的缩放控件把模型放大到一定程度后,模型突然"飞散"——所有顶点像是被弹射出去一样,瞬间炸开。

1.2 根因分析

这是 Three.js 中 SkinnedMesh 的经典问题。理解它需要先搞清楚顶点着色器中骨骼蒙皮的计算逻辑:

// Three.js 顶点着色器(简化)
vec4 skinVertex = bindMatrix * vec4(position, 1.0);
vec4 skinned = boneMatX * skinVertex * weight.x
             + boneMatY * skinVertex * weight.y
             + boneMatZ * skinVertex * weight.z
             + boneMatW * skinVertex * weight.w;
vec4 mvPosition = modelViewMatrix * bindMatrixInverse * skinned;

关键在于 bindMatrixInverse 的来源。Three.js 有两种 bindMode:

当用户缩放模型时,我们实际上是在缩放模型所在的父 Group。此时 mesh.matrixWorld 包含了 Group 的缩放 S,而骨骼的 bone.matrixWorld 也包含了 Group 的缩放 S。

detached 模式下,bindMatrixInverse 是模型初始加载时的快照(那时 Group 的缩放还是 1)。所以顶点着色器的计算变成了:

// detached 模式下的缩放效果
// boneMatX 包含 S(来自 Group 的 matrixWorld)
// bindMatrixInverse 不包含 S(是初始快照)
// 最终顶点位置 = modelViewMatrix * bindMatrixInverse * boneMat * bindMatrix * vertex
// 缩放因子 = S(来自 boneMat) × S(来自 modelViewMatrix) = S²

缩放因子变成了 !当 S=2 时,模型实际被放大 4 倍;当 S=5 时,放大 25 倍;当 S=10 时,放大 100 倍——这就是"爆炸飞散"的根因。

1.3 修复:切换到 attached 模式

attached 模式下,bindMatrixInverse 每帧更新为 inverse(mesh.matrixWorld),它也包含了 S。于是:

// attached 模式下的缩放效果
// boneMatX 包含 S
// bindMatrixInverse 包含 1/S(因为它是 matrixWorld 的逆)
// 缩放因子 = (1/S) × S × S = S —— 正确!

修复代码非常简洁:

const clonedScene = SkeletonUtils.clone(gltf.scene)
clonedScene.traverse((node) => {
  if (node.isSkinnedMesh) {
    node.bindMode = 'attached'
    node.frustumCulled = false
  }
})

同时设置 frustumCulled = false,因为骨骼动画可能让顶点移动到 bounding box 之外,导致模型在运动过程中突然消失。

二、缩放微调控件:从滑块到精确控制

2.1 需求

修复了缩放爆炸之后,用户提出了一个新需求:缩放值需要精确微调,每次 ±0.01。原来的 UI 只有一个滑块和输入框,拖动滑块很难精确到小数点后两位。

2.2 实现

在控制页(ControlPage)和编辑器右侧面板(EditorRightPanel)同时添加了微调控件:

<div class="scale-control">
  <el-button size="small" class="scale-btn"
    @click="adjustScale(-0.01)" :disabled="uniformScale <= 0.01">
    <el-icon><Minus /></el-icon>
  </el-button>
  <el-input-number v-model="uniformScale"
    :min="0.01" :max="100" :step="0.01"
    :precision="2" :controls="false" size="small"
    class="scale-input" @change="onScaleChange" />
  <el-button size="small" class="scale-btn"
    @click="adjustScale(0.01)" :disabled="uniformScale >= 100">
    <el-icon><Plus /></el-icon>
  </el-button>
</div>

微调逻辑用四舍五入避免浮点精度问题:

function adjustScale(delta) {
  const next = Math.round((uniformScale.value + delta) * 100) / 100
  uniformScale.value = Math.max(0.01, Math.min(100, next))
  onScaleChange(uniformScale.value)
}

2.3 暗色主题按钮

最初使用 Element Plus 默认按钮样式,在暗色编辑器中显得非常突兀。用户要求改成暗色系,与编辑器整体风格统一:

.scale-btn {
  background: #2a2a2a;
  border-color: #444;
  color: #e0e0e0;
}
.scale-btn:hover {
  background: #3a3a3a;
  border-color: #555;
  color: #fff;
}
.scale-btn.is-disabled {
  background: #1a1a1a;
  border-color: #333;
  color: #666;
}

图标也从最初的上下箭头(ArrowDown/ArrowUp)改为了更直观的加减号(Minus/Plus)。

三、相机聚焦距离修正:不再钻进模型内部

3.1 问题

模型列表中每个模型旁有一个"聚焦"按钮,点击后相机自动飞到模型正前方。但某些大模型点击聚焦后,相机直接进入了模型内部,用户不得不手动拉远视角。

3.2 根因

聚焦逻辑中计算相机距离的代码有一个硬编码的上限:

// 修复前
const distance = Math.min(Math.max(maxDim * 2.5, 1.8), 12)

对于大模型(比如 maxDim = 20),合理的相机距离应该是 20 × 2.5 = 50,但被 Math.min(..., 12) 截断到了 12——远小于模型尺寸,相机自然就钻进去了。

3.3 修复

移除距离上限,只保留下限保证小模型不会贴太近:

// 修复后
const distance = Math.max(maxDim * 2.5, 1.8)

同样的修复也应用到 focusOnObject3D 函数中。

四、控制端缩放范围扩展

控制页的模型缩放滑块最大值原来是 10,用户需要更大的缩放范围。将最大值从 10 扩展到 100,涉及四处同步修改:

这个改动本身很简单,但需要注意与编辑器端保持一致——两端共享同一个缩放逻辑,范围必须对齐。

五、上传预览弹出框:纹理恢复与白色背景修复

5.1 问题描述

上传模型时弹出的预览对话框有两个问题:

5.2 纹理丢失的根因

预览功能使用独立的 ModelPreviewer 类,它有自己的 Three.js 渲染器和场景。对比编辑器主视口(EditorViewport.vue)的加载逻辑,发现预览器缺少三个关键处理:

处理步骤编辑器主视口预览器(修复前)
GLTFPbrSpecularGlossinessPlugin✅ 已注册❌ 缺失
嵌入纹理加载补丁✅ 已应用❌ 缺失
recoverMissingBaseColorTextures✅ 已调用❌ 缺失
SkinnedMesh bindMode 修复✅ 已设置❌ 缺失

这三个处理分别解决不同场景下的纹理丢失:

5.3 修复

将这三个处理从 EditorViewport.vue 移植到 modelPreviewer.js

// 注册旧版材质扩展插件
const loader = new GLTFLoader()
loader.register((parser) => GLTFPbrSpecularGlossinessPlugin(parser))

// 修复嵌入纹理加载
patchGLTFLoaderEmbeddedTextureLoading(loader)

// 加载模型
const gltf = await new Promise((resolve, reject) => {
  loader.load(url, resolve, undefined, reject)
})

// 恢复缺失的 baseColor 纹理
await recoverMissingBaseColorTextures(gltf)

5.4 白色背景修复

Element Plus 的 el-dialog 组件默认有白色背景。之前只覆盖了 .el-dialog 的背景色,但 .el-dialog__body.el-dialog__header 仍有默认白色,导致弹出框内容区域显示白色。

:deep(.el-dialog) { background: #1e1e1e !important; }
:deep(.el-dialog__header) { background: #1e1e1e !important; }
:deep(.el-dialog__body) { background: #1e1e1e !important; }
:deep(.el-dialog__footer) { background: #1e1e1e !important; }

六、总结

这一轮迭代的核心主题是"缩放体验完整性"。从一个严重的 BUG(缩放爆炸)出发,衍生出精确控制需求(微调控件)、视觉体验需求(相机距离)、范围需求(最大值扩展),最后还顺带修复了上传预览的同源问题(纹理丢失 + 白色背景)。

改动类型核心决策
SkinnedMesh 缩放爆炸BUG 修复bindMode 从 detached 切换到 attached,消除 S² 缩放
缩放微调控件功能增强±0.01 精度加减按钮 + 暗色主题 + Minus/Plus 图标
相机聚焦距离BUG 修复移除硬编码上限 12,改为动态计算
缩放范围扩展体验优化控制端与编辑端同步扩展到 100
预览纹理恢复BUG 修复移植三个纹理修复处理 + 暗色对话框背景

几个值得记住的工程教训: