Lite 版 5·28 迭代复盘:环境贴图预设、热点交互增强与稳定性修复

继上一轮修复了控制预览同步的三个典型 BUG 之后,这一轮迭代的重点转向了编辑器本身的体验打磨:让环境贴图真正可用、让热点交互更灵活、让系统更稳定。看起来每一项都不算大,但真正做下来,每一项背后都有值得记录的工程决策。

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

先直接体验这套链路

建议在编辑器中切换不同环境贴图预设,调整热点缩放和字体大小,并切换场景观察稳定性。

一、环境贴图预设:从外部 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 实现方案

hotspotStoreaddHotspot 中为每个热点新增了四个字段:

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 根因分析

右侧面板的爆炸视图配置区域有两个关键变量:

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(轴心点)并不在几何中心——建模师可能把轴心放在了底部、角落,或者其他方便动画的位置。

之前代码中已经有 normalizeObjMeshPivotnormalizeObjRootPivot 两个函数来处理 OBJ 模型的轴心偏移,但它们有两个限制:

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 的坐标轴自然就出现在了正确的位置。

需要特别注意这段代码的执行时机——必须在 clonedScenegroup.add() 之前执行,否则 bounding box 的计算会受到 group 自身 transform 的影响。

六、总结

这一轮迭代的核心主题是"可用性打磨"。五个改动中有三个是 BUG 修复,两个是功能增强,但它们有一个共同点:都是用户真正使用时才会暴露出来的问题。

改动类型核心决策
环境贴图预设功能增强本地程序化图片替代外部 HDR,兼顾效果与可靠性
热点交互增强功能增强缩放/字体可调,弹框尺寸拖拽即保存
场景切换崩溃BUG 修复computed 做条件守卫 + 场景切换时重置 ref
爆炸距离扩展体验优化编辑端与控制页同步扩展到 40
坐标轴偏离BUG 修复通用几何中心对齐,覆盖所有模型类型

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