继上一轮修复了控制预览同步的三个典型 BUG 之后,这一轮迭代的重点转向了编辑器本身的体验打磨:让环境贴图真正可用、让热点交互更灵活、让系统更稳定。看起来每一项都不算大,但真正做下来,每一项背后都有值得记录的工程决策。
这篇文章复盘 5 月 28 日当天的五个核心改动:
- 环境贴图预设从"外部 HDR 依赖"到"本地程序化图片"的演进
- 热点缩放、字体大小与弹框尺寸的交互增强
- 场景切换导致右侧面板崩溃的防御性修复
- 爆炸距离范围的扩展
- GLB 模型坐标轴偏离几何中心的通用修复
一、环境贴图预设:从外部 HDR 到本地图片
1.1 初始方案:外部 HDR URL
最初的环境贴图预设直接使用了外部 HDR 文件 URL:
{
name: '天空',
url: 'https://example.com/hdr/sky_1k.hdr',
gradient: 'linear-gradient(135deg, #56CCF2, #2F80ED)',
icon: 'Sunny'
}
这个方案在海外环境可以工作,但在国内因为网络和跨域问题,这些 HDR 文件根本加载不了。用户点击预设卡片后,模型没有任何变化——看起来就像功能坏了。
1.2 第二稿:程序化生成
于是转向了不依赖任何外部文件的方案:利用 Three.js 内置的 RoomEnvironment,通过修改光源颜色来生成不同风格的环境贴图:
function createPresetEnvMap(presetName) {
let envScene = new RoomEnvironment()
if (presetName === '__sky__') {
envScene.traverse(child => {
if (child.isMesh && child.material?.emissive) {
child.material.emissive.set(0xaaccff) // 偏蓝色调
}
})
}
// ... 其他预设
const envMap = pmremGenerator.fromScene(envScene, 0.04).texture
return envMap
}
这个方案确实能用了,但效果不够好。单纯修改 RoomEnvironment 的 emissive 颜色,生成的环境贴图在反射细节上比较单调——所有预设看起来都像是"同一个房间换了灯泡颜色",缺乏真实环境的光照层次感。
1.3 最终方案:本地程序化图片
最终选择了用 Python + Pillow 生成 equirectangular 环境贴图图片,直接放在项目的 public/envmaps/ 目录下。每张图 1024×512,大小控制在 12~97KB,非常轻量:
# 生成天空环境贴图
def gen_sky():
img = vertical_gradient(W, H, (40, 80, 180), (120, 170, 230), (160, 190, 210))
# 太阳
draw_gaussian_spot(img, W // 4, 60, 60, 40, (255, 255, 240), 2.0)
# 云层亮斑
for cx, cy, r in [(200, 100, 80), (600, 80, 100), (850, 120, 70)]:
draw_gaussian_spot(img, cx, cy, r, r * 0.5, (255, 255, 255), 0.25)
# 地面反弹光
draw_gaussian_spot(img, W // 2, H - 30, 500, 30, (100, 130, 100), 0.2)
save(img, 'sky')
每个预设通过叠加渐变背景、高斯光斑(模拟面光源)、矩形光条(模拟柔光箱)来构造不同的光照环境。最终生成了 10 个预设:
| 预设 | 大小 | 光照特点 |
|---|---|---|
| 默认 | 12 KB | 标准影棚,中性灰调 |
| 天空 | 70 KB | 蓝天白云,太阳高光 |
| 森林 | 68 KB | 绿色调,斑驳光斑 |
| 室内 | 12 KB | 三面柔光箱布局 |
| 日落 | 97 KB | 暖橙色调,地平线光晕 |
| 冷光 | 28 KB | 蓝紫色调,冷色面板 |
| 暖光 | 28 KB | 黄色调,暖色面板 |
| 夜景 | 28 KB | 深蓝暗调,月光+星点 |
| 中性 | 29 KB | 均匀灰调,无色偏 |
| 高对比 | 13 KB | 强单侧光,暗底 |
前端加载时直接用 TextureLoader 读取本地 PNG,再通过 PMREMGenerator.fromEquirectangular 转换为 Three.js 使用的环境贴图格式:
const envPresets = [
{ name: '天空', url: '/envmaps/sky.png', gradient: '...', icon: 'Sunny' },
{ name: '森林', url: '/envmaps/forest.png', gradient: '...', icon: 'Sunset' },
// ...
]
function loadSceneEnvironmentMap(url) {
const loader = new THREE.TextureLoader()
loader.load(url, (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping
const envMap = pmremGenerator.fromEquirectangular(texture).texture
threeScene.environment = envMap
texture.dispose()
})
}
这个方案兼顾了效果和可靠性:不依赖外部服务、图片足够小不影响加载速度、每个预设的光照特征明显且实用。
二、热点交互增强:缩放、字体与弹框尺寸
2.1 需求背景
热点(Hotspot)是 SceneView 中标注模型关键位置的核心交互元素。之前的热点配置相对简单,只有位置、颜色和触发方式。用户反馈了三个痛点:
- 热点大小不可调——小模型上的热点太大,大模型上的热点太小
- 弹框文字大小固定——信息量大时内容挤在一起看不清
- 弹框尺寸调整后无法保存——每次打开都恢复默认大小
2.2 实现方案
在 hotspotStore 的 addHotspot 中为每个热点新增了四个字段:
const newHotspot = {
id: `hotspot-${Date.now()}`,
name: `热点 ${scene.hotspots.length + 1}`,
position: position || { x: 0, y: 0, z: 0 },
scale: 1, // 热点缩放倍率
fontSize: 14, // 弹框字体大小(px)
popupWidth: 320, // 弹框宽度(px)
popupHeight: 320, // 弹框高度(px)
// ... 其他已有字段
}
右侧面板增加了缩放和字体大小的滑块控件:
<div class="config-section">
<div class="section-title">热点缩放</div>
<el-slider v-model="selectedHotspot.scale" :min="0.2" :max="5" :step="0.1"
@input="onHotspotScaleChange" />
</div>
<div class="config-section">
<div class="section-title">字体大小</div>
<el-slider v-model="selectedHotspot.fontSize" :min="10" :max="28" :step="1"
@input="onHotspotFontSizeChange" />
</div>
弹框宽高则不显示在面板中——用户直接通过拖拽弹框边角来调整大小,松开鼠标时自动保存到 store:
function onCardMouseUp() {
// ... 拖拽结束逻辑 ...
if (activeHotspotInfo.value) {
hotspotStore.updateHotspot(activeHotspotInfo.value.id, {
popupWidth: hotspotCardSize.value.width,
popupHeight: hotspotCardSize.value.height
})
}
}
视口渲染时,热点的 scale 字段直接应用到 THREE.Group 的 scale 上,并兼容旧的 size 字段:
const groupScale = hotspot.scale || hotspot.size || 1
group.scale.set(groupScale, groupScale, groupScale)
三、场景切换崩溃:explosion 属性未定义
3.1 问题现象
一切换场景,右侧面板直接消失,控制台报错:
TypeError: Cannot read properties of undefined (reading 'explosion')
3.2 根因分析
右侧面板的爆炸视图配置区域有两个关键变量:
explosionTargetId— 当前选中的爆炸目标模型 ID(ref)explosionSelectedModel— 根据 ID 从模型列表中查找的 computed
const explosionSelectedModel = computed(() => {
return models.value.find(m => String(m.id) === String(explosionTargetId.value))
})
切换场景后,模型列表变了,但 explosionTargetId 还保留着旧场景的模型 ID。find 返回 undefined,而模板中直接访问 explosionSelectedModel.explosion.mode 就炸了。
更糟的是,v-if="explosionTargetId" 仍然为 true(旧 ID 非空),所以条件守卫完全失效。
3.3 修复
两处改动:
1) 条件守卫换成 computed 本身:
<!-- 修复前 -->
<div class="explosion-settings" v-if="explosionTargetId">
<!-- 修复后 -->
<div class="explosion-settings" v-if="explosionSelectedModel">
2) 切换场景时重置 ID:
watch(() => sceneStore.currentScene, () => {
explosionTargetId.value = ''
animTargetId.value = ''
})
这是一个典型的"响应式状态跨场景残留"问题——ref 不会因为数据源变化而自动重置,必须显式处理。
四、爆炸距离扩展:从 20 到 40
4.1 需求背景
爆炸视图的距离滑块最大值原来是 20,对于大型模型或需要展示内部结构的场景来说偏短。特别是"前后爆炸"(Z 轴方向)和"上下爆炸"(Y 轴方向),用户需要更大的展开范围才能清晰看到内部零件。
4.2 修改
编辑端和控制页面的滑块最大值统一从 20 调整到 40:
<!-- EditorRightPanel.vue -->
<el-slider v-model="explosionSelectedModel.explosion.intensity"
:min="0" :max="40" :step="1" />
<!-- ControlPage.vue -->
<el-slider v-model="explosionIntensity"
:min="0" :max="40" :step="1" />
改动很小,但需要注意两端同步——编辑端和控制页面的范围必须一致,否则会出现"编辑器里拉到 30 但控制页只能拉到 20"的不一致体验。
五、模型坐标轴偏离几何中心
5.1 问题现象
选中模型后,TransformControls 的坐标轴(平移/旋转/缩放手柄)出现在模型旁边甚至很远的位置,而不是模型的视觉中心。这对用户操作造成了很大困扰——拖坐标轴时模型不是按预期方向移动。
5.2 根因
TransformControls 的坐标轴始终出现在被 attach 对象的局部原点(即 object.position)处。而很多 GLB 模型在建模软件中,pivot point(轴心点)并不在几何中心——建模师可能把轴心放在了底部、角落,或者其他方便动画的位置。
之前代码中已经有 normalizeObjMeshPivot 和 normalizeObjRootPivot 两个函数来处理 OBJ 模型的轴心偏移,但它们有两个限制:
- 只对 OBJ 模型生效,GLB 模型完全不走这个逻辑
normalizeObjMeshPivot的阈值极高(centerDistance > Math.max(sizeLength * 2, 1000)),只处理了极端的"绝对坐标 baked 到 geometry"的情况
5.3 修复:通用几何中心对齐
在模型加载流程中,对所有模型类型(GLB + OBJ)统一做一次几何中心对齐:
// 在 clonedScene 加入 group 之前,将几何中心对齐到局部原点
clonedScene.updateMatrixWorld(true)
const pivotBox = new THREE.Box3().setFromObject(clonedScene)
if (Number.isFinite(pivotBox.min.x) && Number.isFinite(pivotBox.max.x)) {
const pivotCenter = pivotBox.getCenter(new THREE.Vector3())
if (pivotCenter.length() > 0.001) {
clonedScene.position.sub(pivotCenter)
clonedScene.updateMatrix()
clonedScene.updateMatrixWorld(true)
}
}
核心思路:计算 clonedScene 的世界空间 bounding box,取其中心,然后把几何体"推回去"使中心对齐到局部原点,同时补偿到 clonedScene.position 上。这样模型在世界空间中的视觉位置不变,但局部原点落在了几何中心,TransformControls 的坐标轴自然就出现在了正确的位置。
需要特别注意这段代码的执行时机——必须在 clonedScene 被 group.add() 之前执行,否则 bounding box 的计算会受到 group 自身 transform 的影响。
六、总结
这一轮迭代的核心主题是"可用性打磨"。五个改动中有三个是 BUG 修复,两个是功能增强,但它们有一个共同点:都是用户真正使用时才会暴露出来的问题。
| 改动 | 类型 | 核心决策 |
|---|---|---|
| 环境贴图预设 | 功能增强 | 本地程序化图片替代外部 HDR,兼顾效果与可靠性 |
| 热点交互增强 | 功能增强 | 缩放/字体可调,弹框尺寸拖拽即保存 |
| 场景切换崩溃 | BUG 修复 | computed 做条件守卫 + 场景切换时重置 ref |
| 爆炸距离扩展 | 体验优化 | 编辑端与控制页同步扩展到 40 |
| 坐标轴偏离 | BUG 修复 | 通用几何中心对齐,覆盖所有模型类型 |
几个值得记住的工程教训:
- 外部依赖是可靠性杀手:环境贴图从外部 HDR 到程序化生成再到本地图片,每次迭代都是在消除一个不可控因素。
- ref 不会自动重置:场景切换时所有与场景关联的 ref 都要显式清空,否则就会成为下一个崩溃的导火索。
- 通用方案优于特殊处理:坐标轴对齐从"只处理 OBJ 的极端情况"到"所有模型统一做中心对齐",代码反而更简单了。