这一轮迭代围绕一个核心问题展开:带骨骼的 SkinnedMesh 模型在缩放时"爆炸飞散"。从定位根因到修复,再到衍生出的缩放微调需求、相机聚焦距离修正、以及上传预览弹出框的纹理丢失和白色背景——五个改动串联起来,恰好构成了一条从"不能缩放"到"缩放体验完整"的完整链路。
这篇文章复盘 5 月 29 日当天的五个核心改动:
- SkinnedMesh 缩放爆炸的根因分析与修复
- 缩放微调控件(±0.01 精度加减按钮 + 暗色主题)
- 相机聚焦进入大模型内部的距离修正
- 控制端模型缩放最大值从 10 扩展到 100
- 上传模型预览弹出框纹理恢复与白色背景修复
一、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:
detached模式(默认):bindMatrixInverse在bind()时被固定为inverse(mesh.matrixWorld)的快照。之后无论 mesh 如何变换,这个值都不再更新。attached模式:每帧更新时,bindMatrixInverse = inverse(mesh.matrixWorld),始终与 mesh 当前的世界矩阵同步。
当用户缩放模型时,我们实际上是在缩放模型所在的父 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²!当 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,涉及四处同步修改:
<el-slider>的:max属性<el-input-number>的:max属性- 加号按钮的
:disabled条件 adjustScale中的Math.min(100, ...)上限
这个改动本身很简单,但需要注意与编辑器端保持一致——两端共享同一个缩放逻辑,范围必须对齐。
五、上传预览弹出框:纹理恢复与白色背景修复
5.1 问题描述
上传模型时弹出的预览对话框有两个问题:
- 海龟等带骨骼的模型预览中没有皮肤纹理,只显示灰色轮廓
- 弹出框背景是白色的,与编辑器的暗色主题不协调
5.2 纹理丢失的根因
预览功能使用独立的 ModelPreviewer 类,它有自己的 Three.js 渲染器和场景。对比编辑器主视口(EditorViewport.vue)的加载逻辑,发现预览器缺少三个关键处理:
| 处理步骤 | 编辑器主视口 | 预览器(修复前) |
|---|---|---|
GLTFPbrSpecularGlossinessPlugin | ✅ 已注册 | ❌ 缺失 |
| 嵌入纹理加载补丁 | ✅ 已应用 | ❌ 缺失 |
recoverMissingBaseColorTextures | ✅ 已调用 | ❌ 缺失 |
| SkinnedMesh bindMode 修复 | ✅ 已设置 | ❌ 缺失 |
这三个处理分别解决不同场景下的纹理丢失:
GLTFPbrSpecularGlossinessPlugin:旧版 glTF 使用KHR_materials_pbrSpecularGlossiness扩展定义材质。Three.js r159+ 已移除对该扩展的内置支持,不注册插件的话,这些材质会被当作默认的灰色 MeshStandardMaterial 处理。- 嵌入纹理加载补丁:部分 GLB 文件的纹理以 bufferView 方式内嵌,Three.js 的 GLTFParser 在某些情况下无法正确加载这些纹理(特别是
isImageBitmapLoader检查不通过时),需要拦截loadImageSource方法手动处理。 recoverMissingBaseColorTextures:即使 GLTFParser 成功解析了纹理定义,有时也未能将纹理正确赋值给material.map。这个函数遍历所有 Mesh,对于material.map为空但 JSON 中定义了baseColorTexture或diffuseTexture的材质,手动从 parser 的 bufferView 中恢复纹理。
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 修复 | 移植三个纹理修复处理 + 暗色对话框背景 |
几个值得记住的工程教训:
- SkinnedMesh 的 bindMode 是隐性的缩放杀手:默认的 detached 模式在缩放父级 Group 时会产生 S² 效应。如果你的项目有任何骨骼模型需要缩放,第一时间设置
bindMode = 'attached'。 - 预览场景必须与主场景保持加载逻辑一致:主视口做了纹理修复处理,预览器没有同步,就会导致"编辑器里正常、预览里没纹理"的诡异现象。这类"逻辑分叉"是最难定位的 BUG 来源之一。
- 硬编码的距离上限是反模式:相机距离 12 的上限在小模型时没问题,大模型时就变成了 BUG。动态计算 + 合理下限才是正确做法。
- UI 细节决定体验质感:从箭头到加减号、从默认按钮到暗色主题,每个小改动都在提升整体一致性。用户可能说不出哪里不对,但一致性缺失会让人觉得"不专业"。