Skip to Content
本人正在找工作,有合适的岗位可以联系我,简历
博客React Three Fiber 3D 爆炸图拆解动画

用 React Three Fiber 实现 3D 模型”爆炸图”拆解动画

在武器装配展示系统中,有一个核心交互功能:点击按钮后,模型的各个零部件像爆炸一样向外分离展开,悬停在空中,以便讲解每个零件的位置和功能。这个功能叫做爆炸图(Exploded View),是工业 3D 展示中的经典交互。

这篇文章记录我在 React Three Fiber(R3F)中实现这个功能时遇到的核心技术挑战和最终方案。


1. 理解”爆炸图”的本质

爆炸图的本质是:让模型的每个子网格(Mesh)从其原始位置,沿某个方向平滑移动到一个新位置,并支持还原

难点在于:

  1. 如何确定每个零件的爆炸方向? 通常是从模型中心点向外放射,但武器拆解有特定的方向(枪机向后、弹匣向下等)。
  2. 如何在 R3F 中优雅地驱动多个 Mesh 的位置动画? 直接操控 ref.current.position 很”命令式”,不符合 React 的数据驱动理念。
  3. 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、爆炸图

最近更新:4/19/2026, 3:19:53 PM