用 React Three Fiber 实现 3D 模型”爆炸图”拆解动画
在武器装配展示系统中,有一个核心交互功能:点击按钮后,模型的各个零部件像爆炸一样向外分离展开,悬停在空中,以便讲解每个零件的位置和功能。这个功能叫做爆炸图(Exploded View),是工业 3D 展示中的经典交互。
这篇文章记录我在 React Three Fiber(R3F)中实现这个功能时遇到的核心技术挑战和最终方案。
1. 理解”爆炸图”的本质
爆炸图的本质是:让模型的每个子网格(Mesh)从其原始位置,沿某个方向平滑移动到一个新位置,并支持还原。
难点在于:
- 如何确定每个零件的爆炸方向? 通常是从模型中心点向外放射,但武器拆解有特定的方向(枪机向后、弹匣向下等)。
- 如何在 R3F 中优雅地驱动多个 Mesh 的位置动画? 直接操控
ref.current.position很”命令式”,不符合 React 的数据驱动理念。 - GLB 模型加载后的结构是动态的,如何统一管理所有子节点?
2. GLB 模型的节点结构解析
武器模型的 GLB 文件,在 Blender 中导出时就已经按零件分好了节点(Node),每个零件是一个独立的 Mesh。加载后通过 useGLTF 拿到的 scene 是一棵树:
scene (Group)
├── Body (Mesh) // 枪身
├── Barrel (Mesh) // 枪管
├── Magazine (Mesh) // 弹匣
├── BoltCarrier (Mesh) // 枪机
├── Trigger (Mesh) // 扳机
└── Stock (Mesh) // 枪托首先要遍历场景树,收集所有 Mesh 及其原始世界坐标(这是还原动画的基础):
// hooks/useModelNodes.ts
import { useEffect, useRef } from 'react'
import { useGLTF } from '@react-three/fiber'
import * as THREE from 'three'
interface NodeInfo {
mesh: THREE.Mesh
originalPosition: THREE.Vector3
originalQuaternion: THREE.Quaternion
}
export function useModelNodes(modelPath: string) {
const { scene } = useGLTF(modelPath)
const nodesRef = useRef<Map<string, NodeInfo>>(new Map())
useEffect(() => {
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
// 记录加载完成时的初始变换,作为"归位"的目标
nodesRef.current.set(obj.name, {
mesh: obj,
originalPosition: obj.position.clone(),
originalQuaternion: obj.quaternion.clone(),
})
}
})
}, [scene])
return { scene, nodesRef }
}3. 爆炸方向的配置化设计
纯粹的”从中心向外”策略在武器拆解里行不通,因为枪管要向前伸出,枪机要向后滑动,弹匣要向下脱落,这是有物理含义的方向。
我设计了一套方向配置文件,与模型节点名称绑定:
// config/explosion-directions.ts
import * as THREE from 'three'
export const EXPLOSION_CONFIG: Record<string, {
direction: THREE.Vector3
distance: number
delay: number // 错峰动画,增加层次感
}> = {
Barrel: { direction: new THREE.Vector3(0, 0, 1), distance: 0.8, delay: 0 },
BoltCarrier: { direction: new THREE.Vector3(0, 0, -1), distance: 0.6, delay: 50 },
Magazine: { direction: new THREE.Vector3(0, -1, 0), distance: 0.5, delay: 100 },
Trigger: { direction: new THREE.Vector3(0, -1, 0.3), distance: 0.3, delay: 150 },
Stock: { direction: new THREE.Vector3(0, 0, -1), distance: 0.7, delay: 200 },
// Body 不设置,保持不动作为参照
}4. 用 @react-spring/three 驱动动画
直接在 useFrame 里手写插值虽然可行,但对于多零件并发动画、可中断、可反向播放的需求,@react-spring/three 是更优的选择。它能让每个 Mesh 的位置变化像 React state 一样被声明式地驱动。
// components/ExplodableMesh.tsx
import { animated } from '@react-spring/three'
import { useSpring } from '@react-spring/core'
import { useExplodeStore } from '@/store/explode-store'
interface Props {
mesh: THREE.Mesh
nodeName: string
}
export function ExplodableMesh({ mesh, nodeName }: Props) {
const isExploded = useExplodeStore(s => s.isExploded)
const config = EXPLOSION_CONFIG[nodeName]
const { position } = useSpring({
position: isExploded && config
? [
mesh.userData.originalPosition.x + config.direction.x * config.distance,
mesh.userData.originalPosition.y + config.direction.y * config.distance,
mesh.userData.originalPosition.z + config.direction.z * config.distance,
]
: [
mesh.userData.originalPosition.x,
mesh.userData.originalPosition.y,
mesh.userData.originalPosition.z,
],
config: {
tension: 120,
friction: 14,
},
delay: config?.delay ?? 0,
})
return (
<animated.mesh
geometry={mesh.geometry}
material={mesh.material}
position={position as any}
/>
)
}爆炸/归位状态通过 Zustand store 全局管理,一个按钮控制所有零件同步动画:
// store/explode-store.ts
import { create } from 'zustand'
interface ExplodeStore {
isExploded: boolean
selectedPart: string | null
toggleExplode: () => void
selectPart: (name: string | null) => void
}
export const useExplodeStore = create<ExplodeStore>((set) => ({
isExploded: false,
selectedPart: null,
toggleExplode: () => set(s => ({ isExploded: !s.isExploded })),
selectPart: (name) => set({ selectedPart: name }),
}))5. 点击高亮与零件说明联动
爆炸图展开后,用户还需要能点击具体零件查看说明。这里用到了 R3F 的 raycaster:
// 给每个零件绑定点击事件
<animated.mesh
{...props}
onClick={(e) => {
e.stopPropagation()
selectPart(nodeName)
}}
onPointerOver={(e) => {
e.stopPropagation()
document.body.style.cursor = 'pointer'
}}
onPointerOut={() => {
document.body.style.cursor = 'default'
}}
>
<meshStandardMaterial
{...mesh.material}
// 选中时叠加一层高亮颜色
emissive={selectedPart === nodeName ? '#4a9eff' : '#000000'}
emissiveIntensity={selectedPart === nodeName ? 0.4 : 0}
/>
</animated.mesh>右侧 UI 面板监听 selectedPart 展示对应零件的图文说明,实现 3D 模型与 2D 面板的双向联动。
6. 性能优化:避免每帧触发渲染
R3F 默认在每一帧都触发 React 渲染,但我们的场景在静止时不需要持续更新。通过设置 frameloop="demand" 并在状态变化时手动触发:
<Canvas
frameloop="demand" // 按需渲染,不自动循环
camera={{ position: [0, 1, 3], fov: 60 }}
>
<ExplodableModel />
</Canvas>// 在 spring 动画期间开启持续渲染,动画结束后停止
import { invalidate } from '@react-three/fiber'
useSpring({
// ...
onChange: () => invalidate(), // 每次 spring 值变化时触发一帧渲染
})这个优化在搭载集成显卡的工控机上效果显著,GPU 占用从持续 30% 降到待机时接近 0%。
7. 踩坑记录
坑 1:GLB 导出时忘记应用变换
Blender 里的模型如果没有 Apply Transform(Ctrl+A → All Transforms),导出的 GLB 中各 Mesh 的 position 会全部是 (0,0,0),爆炸方向完全错乱。要求美术在导出前必须应用所有变换。
坑 2:animated.mesh 与自定义 Material 的冲突
直接用 animated.mesh 包裹带有自定义 ShaderMaterial 的 Mesh 时,Spring 的 position 属性有时不生效。最终方案是将位置控制抽到父级 animated.group,内部 Mesh 保持普通写法。
坑 3:大量 Mesh 导致的 Draw Call 过多
武器模型有 40+ 个零件,每个独立 Mesh 都是一个 Draw Call。通过 R3F 的 <Instances> 对相同材质的零件合并,Draw Call 从 47 降到 12,帧率提升明显。
总结
| 技术选型 | 原因 |
|---|---|
@react-spring/three | 声明式动画,支持中断/反向,比手写插值更稳定 |
| Zustand 全局状态 | 爆炸/选中状态需要跨 3D 场景和 2D UI 面板共享 |
frameloop="demand" | 工控机性能有限,按需渲染是必须项 |
| 方向配置文件 | 与模型节点名解耦,美术调整时只改配置不改代码 |
最后更新:2026年4月
标签:React Three Fiber、Three.js、3D动画、Electron、爆炸图