热点跟随模型动画:从 Scene Root 到 Mesh 子节点的挂载演进

在 3D 展示系统中,热点(Hotspot)是用户与模型交互的核心入口——点击热点弹出说明卡片、播放视频或跳转链接。但当模型开始播放动画时,热点却纹丝不动,像钉在空气中的标签,和模型彻底脱节。这篇文章复盘的就是这个问题的定位过程和最终解决方案:把热点从 Scene Root 挪到目标 Mesh 的子节点上,让它随模型动画自然移动。

先直接体验热点效果

建议同时打开控制页与预览页,放置热点后开启轴动画,观察热点是否跟随模型旋转。

一、问题现象

用户在编辑器中开启模型的轴动画(比如 Y 轴旋转),模型开始匀速旋转。但之前放置在模型表面的热点,依然停留在原始的世界坐标位置,不会随模型一起转动。视觉效果上,热点像是"飘"在空中,和模型完全脱节。

同样的问题也出现在骨骼动画中:角色的手臂挥动时,挂在手臂 Mesh 上的热点不会跟随手臂移动,仍然停在手臂的初始位置。

二、根因分析

2.1 热点的原始挂载方式

最初的热点实现中,热点被创建为一个 THREE.Group,直接添加到 Scene Root:

// 原始实现:热点挂在 Scene Root
function createHotspot(hotspot) {
  const group = new THREE.Group()
  // ... 创建圆环、光晕等子对象 ...
  group.userData.hotspotId = hotspot.id
  group.position.set(hotspot.position.x, hotspot.position.y, hotspot.position.z)
  threeScene.add(group)  // 直接加到场景根节点
  hotspotMeshes.push(group)
}

这种方式下,热点的世界坐标是固定的。Scene Root 不会因为任何模型的动画而改变子节点的位置——因为热点根本不是模型的子节点。

2.2 Three.js Scene Graph 的变换传播机制

Three.js 的变换传播遵循 Scene Graph 的层级关系:当父节点的 matrixWorld 更新时,所有子节点的世界矩阵会自动重新计算。这意味着:

所以问题的根因很清晰:热点挂在 Scene Root 上,而动画改变的是 Mesh 的变换,两者没有父子关系,变换自然无法传播

三、解决方案:挂载到目标 Mesh 子节点

3.1 核心思路

将热点从 Scene Root 移到目标 Mesh 的子节点。这样当 Mesh 被动画驱动旋转或平移时,热点作为子节点会自动跟随。

但这里有一个关键问题:热点的 position 需要从世界坐标转换为 Mesh 的本地坐标。因为 mesh.add(group) 后,group.position 是相对于 Mesh 的本地偏移,而不是世界坐标。

3.2 放置时计算本地偏移

当用户点击模型表面放置热点时,Raycaster 返回命中点 hitPoint(世界坐标)和命中 Mesh hitMesh。我们需要计算 hitPointhitMesh 本地坐标系下的位置:

// 放置热点时计算本地偏移
if (hotspotStore.isPlacementMode) {
  const intersects = raycaster.intersectObjects(allMeshesForRaycast, false)
  if (intersects.length > 0) {
    const hitMesh = intersects[0].object
    const hitPoint = intersects[0].point

    // 查找所属模型 ID
    let attachModelId = hitMesh.userData.modelId || null
    if (!attachModelId) {
      let parent = hitMesh.parent
      while (parent && !parent.userData.modelId) {
        parent = parent.parent
      }
      if (parent) attachModelId = parent.userData.modelId
    }

    // 计算热点在命中 mesh 本地坐标系下的偏移
    let attachOffset = null
    let attachMeshName = null
    if (attachModelId) {
      attachMeshName = hitMesh.name || null
      hitMesh.updateWorldMatrix(true, false)
      const meshWorldInverse = new THREE.Matrix4()
        .copy(hitMesh.matrixWorld).invert()
      const localPos = hitPoint.clone().applyMatrix4(meshWorldInverse)
      attachOffset = { x: localPos.x, y: localPos.y, z: localPos.z }
    }

    hotspotStore.addHotspot(
      { x: hitPoint.x, y: hitPoint.y, z: hitPoint.z },
      { attachModelId, attachMeshName, attachOffset }
    )
  }
}

关键步骤是 hitMesh.updateWorldMatrix(true, false) + meshWorldInverse:先确保 Mesh 的世界矩阵是最新的,然后求逆,将世界坐标的命中点转换到 Mesh 的本地坐标系。

3.3 热点数据结构扩展

hotspotStore 中,热点对象新增三个字段:

字段 类型 说明
attachModelId String | null 热点挂载的目标模型 ID
attachMeshName String | null 热点挂载的目标 Mesh 名称(用于模型重载后重新查找)
attachOffset { x, y, z } | null 热点在目标 Mesh 本地坐标系下的偏移

同时保留 position 字段存储世界坐标,作为无附加信息时的 fallback,也方便序列化和反序列化。

3.4 渲染时挂载到目标 Mesh

function findAttachMesh(hotspot) {
  if (!hotspot.attachModelId || !hotspot.attachMeshName) return null
  const modelGroup = modelGroups[hotspot.attachModelId]
  if (!modelGroup) return null
  let found = null
  modelGroup.traverse((child) => {
    if (child.name === hotspot.attachMeshName) found = child
  })
  return found
}

function updateHotspots() {
  for (const hotspot of hotspots.value) {
    if (!existingIds.has(hotspot.id)) {
      // 创建热点 Group ...
      const group = new THREE.Group()
      // ... 子对象创建 ...
      group.userData.hotspotId = hotspot.id

      // 尝试挂载到目标 mesh
      const targetMesh = findAttachMesh(hotspot)
      if (targetMesh && hotspot.attachOffset) {
        group.position.set(
          hotspot.attachOffset.x,
          hotspot.attachOffset.y,
          hotspot.attachOffset.z
        )
        targetMesh.add(group)  // 挂载到 Mesh 子节点
      } else {
        // fallback:无附加信息,使用世界坐标
        group.position.set(
          hotspot.position.x,
          hotspot.position.y,
          hotspot.position.z
        )
        threeScene.add(group)
      }

      hotspotMeshes.push(group)
    }
  }
}

3.5 模型重载后重新挂载

模型可能被重新加载(切换场景、上传新模型等),此时 Mesh 引用失效。需要在 updateHotspots 中检测并重新挂载:

// 已有热点但可能需要重新挂载
const mesh = hotspotMeshes.find(m => m.userData.hotspotId === hotspot.id)
if (mesh) {
  if (hotspot.attachModelId && hotspot.attachOffset) {
    const targetMesh = findAttachMesh(hotspot)
    if (targetMesh && mesh.parent !== targetMesh) {
      // 从当前 parent 移除,挂载到目标 mesh
      if (mesh.parent) mesh.parent.remove(mesh)
      mesh.position.set(
        hotspot.attachOffset.x,
        hotspot.attachOffset.y,
        hotspot.attachOffset.z
      )
      targetMesh.add(mesh)
    }
  } else if (!hotspot.attachModelId && mesh.parent !== threeScene) {
    // 无附加信息但不在 scene root,移回 scene root
    if (mesh.parent) mesh.parent.remove(mesh)
    mesh.position.set(
      hotspot.position.x,
      hotspot.position.y,
      hotspot.position.z
    )
    threeScene.add(mesh)
  }
}

四、TransformControls 拖拽时的坐标同步

热点挂载到 Mesh 子节点后,用 TransformControls 拖拽热点时,objectChange 事件中的 obj.position 是 Mesh 本地坐标而非世界坐标。需要区分处理:

transformControls.addEventListener('objectChange', () => {
  const obj = transformControls.object
  if (!obj) return

  // 同步热点位置
  if (obj?.userData?.hotspotId) {
    const hs = hotspots.value.find(
      h => String(h.id) === String(obj.userData.hotspotId)
    )
    if (hs) {
      const hotspotScale = Math.max(0.1, Number(
        ((Math.abs(obj.scale.x) + Math.abs(obj.scale.y) + Math.abs(obj.scale.z)) / 3)
          .toFixed(4)
      ))
      const updates = { scale: hotspotScale }

      if (hs.attachModelId && hs.attachMeshName && obj.parent) {
        // 热点挂载在 mesh 上,obj.position 是 parent 本地坐标
        updates.attachOffset = {
          x: obj.position.x,
          y: obj.position.y,
          z: obj.position.z
        }
        // 同步更新世界坐标
        const worldPos = new THREE.Vector3()
        obj.getWorldPosition(worldPos)
        updates.position = {
          x: worldPos.x,
          y: worldPos.y,
          z: worldPos.z
        }
      } else {
        updates.position = {
          x: obj.position.x,
          y: obj.position.y,
          z: obj.position.z
        }
      }

      hotspotStore.updateHotspot(obj.userData.hotspotId, updates)
    }
  }
})

这里的关键是:当热点挂载在 Mesh 上时,同时保存 attachOffset(本地坐标)和 position(世界坐标)。本地坐标用于渲染时的挂载定位,世界坐标用于序列化存储和 fallback 显示。

五、为什么不用每帧手动同步世界坐标?

一个直觉的替代方案是:热点仍然挂在 Scene Root,但在每帧的 animate 循环中,根据目标 Mesh 的世界矩阵手动更新热点的世界坐标:

// 不推荐的方案:每帧手动同步
function animate() {
  for (const hotspot of hotspots.value) {
    if (hotspot.attachModelId) {
      const mesh = findAttachMesh(hotspot)
      if (mesh) {
        const meshMesh = hotspotMeshes.find(
          m => m.userData.hotspotId === hotspot.id
        )
        if (meshMesh) {
          const worldPos = new THREE.Vector3(
            hotspot.attachOffset.x,
            hotspot.attachOffset.y,
            hotspot.attachOffset.z
          )
          mesh.localToWorld(worldPos)
          meshMesh.position.copy(worldPos)
        }
      }
    }
  }
}

这个方案能工作,但有几个问题:

相比之下,利用 Scene Graph 的层级关系是零额外开销的——Three.js 的矩阵运算本来就要做,子节点自动继承父节点的变换,不需要任何手动同步代码。

六、爆炸视图下的热点行为

爆炸视图会把模型的子部件沿法线方向分散。如果热点挂载在某个子 Mesh 上,爆炸时热点会跟随该子 Mesh 移动——这通常是期望的行为,因为热点标注的就是该部件的信息。

但需要注意一个边界情况:如果热点挂载的 Mesh 是爆炸计算的目标(即 isSubObject),在 updateExplosions 中该 Mesh 的位置会被重新计算。由于热点是它的子节点,热点会自动跟随,不需要额外处理。

七、修改文件清单

3d-editor-lite/src/stores/hotspotStore.js
  - addHotspot() 新增 attachInfo 参数
  - 热点对象新增 attachModelId / attachMeshName / attachOffset 字段

3d-editor-lite/src/components/viewport/EditorViewport.vue
  - 新增 findAttachMesh() 函数
  - updateHotspots() 改为优先挂载到目标 Mesh 子节点
  - 放置热点时计算本地偏移 attachOffset
  - TransformControls objectChange 事件区分本地/世界坐标
  - 模型重载后重新挂载逻辑

八、总结

这个问题的修复看起来很简单——就是把 threeScene.add(group) 改成 targetMesh.add(group)——但真正的工作量在于:

  1. 坐标转换:从世界坐标到本地坐标的转换,以及拖拽时反向同步,需要理解 Three.js 的矩阵运算。
  2. 生命周期管理:模型重载、场景切换时,Mesh 引用失效,需要重新查找并挂载。
  3. 向后兼容:旧数据中没有 attachModelId 等字段,fallback 到世界坐标模式必须无缝工作。

核心教训:在 Three.js 中,对象之间的"跟随"关系应该优先通过 Scene Graph 的父子层级来表达,而不是通过手动同步坐标。Scene Graph 是 Three.js 最基础的数据结构,利用好它的变换传播机制,可以避免大量脆弱的手动同步代码。

如果你也在做 3D 场景中的标注、标签、热点等跟随模型移动的功能,希望这篇文章帮你少走弯路——直接把标注对象挂到目标 Mesh 的子节点上,是最简单、最可靠、性能最好的方案。