在 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 更新时,所有子节点的世界矩阵会自动重新计算。这意味着:
- 如果一个对象是 Mesh 的子节点,当 Mesh 旋转/平移时,对象的
getWorldPosition()会自动反映这些变换。 - 如果一个对象是 Scene Root 的子节点,它只受自身
position/rotation/scale的影响,不受任何模型动画的影响。
所以问题的根因很清晰:热点挂在 Scene Root 上,而动画改变的是 Mesh 的变换,两者没有父子关系,变换自然无法传播。
三、解决方案:挂载到目标 Mesh 子节点
3.1 核心思路
将热点从 Scene Root 移到目标 Mesh 的子节点。这样当 Mesh 被动画驱动旋转或平移时,热点作为子节点会自动跟随。
但这里有一个关键问题:热点的 position 需要从世界坐标转换为 Mesh 的本地坐标。因为 mesh.add(group) 后,group.position 是相对于 Mesh 的本地偏移,而不是世界坐标。
3.2 放置时计算本地偏移
当用户点击模型表面放置热点时,Raycaster 返回命中点 hitPoint(世界坐标)和命中 Mesh hitMesh。我们需要计算 hitPoint 在 hitMesh 本地坐标系下的位置:
// 放置热点时计算本地偏移
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)
}
}
}
}
}
这个方案能工作,但有几个问题:
- 性能开销:每帧对每个热点做矩阵乘法,热点数量多时会成为瓶颈。
- 缩放处理复杂:Mesh 的
scale会影响子节点的视觉大小。如果热点在 Scene Root,需要手动反向缩放来保持视觉大小不变;而作为子节点,Three.js 的矩阵运算自动处理了这一点。 - 代码脆弱:需要在每个可能改变 Mesh 变换的地方(轴动画、骨骼动画、爆炸视图、TransformControls 拖拽)都确保手动同步逻辑正确执行,遗漏任何一处都会导致不同步。
相比之下,利用 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)——但真正的工作量在于:
- 坐标转换:从世界坐标到本地坐标的转换,以及拖拽时反向同步,需要理解 Three.js 的矩阵运算。
- 生命周期管理:模型重载、场景切换时,Mesh 引用失效,需要重新查找并挂载。
- 向后兼容:旧数据中没有
attachModelId等字段,fallback 到世界坐标模式必须无缝工作。
核心教训:在 Three.js 中,对象之间的"跟随"关系应该优先通过 Scene Graph 的父子层级来表达,而不是通过手动同步坐标。Scene Graph 是 Three.js 最基础的数据结构,利用好它的变换传播机制,可以避免大量脆弱的手动同步代码。
如果你也在做 3D 场景中的标注、标签、热点等跟随模型移动的功能,希望这篇文章帮你少走弯路——直接把标注对象挂到目标 Mesh 的子节点上,是最简单、最可靠、性能最好的方案。