🚀 使用 React Three Fiber 构建交互式 3D 火箭复活节彩蛋

当我们在领英上获得 1000 名粉丝时,我们知道必须好好庆祝一下!受到 Vercel 的精彩博文《使用 React Three Fiber 构建交互式 3D 活动徽章》的启发,我们想借此机会推出一些有趣的东西。那么,还有什么比在我们闪亮的新网站zerodays.dev上放一个 3D 火箭彩蛋更好的庆祝方式呢?
这是那种制作起来很有趣的东西,就像指尖陀螺的电子版,但更酷炫。我们制作它的过程非常愉快,今天我们将带你了解幕后花絮。
让我们直接开始吧。
🚀 目录:
🏁 设置:
🛠️ 构建 3D 火箭的工具
我们保持技术堆栈精简但功能强大,以使这个复活节彩蛋飞起来。
- React:我们的交互式 UI 的核心。
- TypeScript:强类型,使我们的代码无错误。
- react-three-fiber:我们的 3D 场景背后的魔力,将three.js与 React 集成。
- react-three-drei:相机、灯光和效果的得力助手。
- react-three-rapier:用于真实碰撞和火箭相互作用的物理引擎。
🎬 设置 3D 场景
第一步是为我们的火箭设置基本的 3D 场景。我们使用了react-three-fiber来处理 React 组件内部的场景渲染,并使用react-three-drei来处理摄像头和光照等辅助功能。我们还集成了react-three-rapier来实现物理和碰撞检测。
以下是初始设置的简化版本:
'use client';
import { Canvas } from '@react-three/fiber';
import { OrthographicCamera } from '@react-three/drei';
import { Physics } from '@react-three/rapier';
import Rocket from '@/components/three/rocket';
import SpaceEnvironment from '@/components/three/space-environment';
import SpaceRocks from '@/components/three/space-rocks';
const RocketScene = () => {
return (
<Canvas>
<OrthographicCamera makeDefault position={[0, 0, 0]} zoom={1} />
<ambientLight intensity={0.4} />
<directionalLight position={[-30, 100, 0]} intensity={0.8} />
<Physics gravity={[0, 0, 0]}>
<Rocket />
<SpaceRocks />
</Physics>
<SpaceEnvironment />
</Canvas>
);
};
export default RocketScene;
这将建立我们的 3D 世界:
- 火箭:节目的明星。
- 太空环境:很酷的太空背景。
- 太空岩石:漂浮的太空碎片,增添了额外的刺激和危险!火箭可以与这些岩石碰撞,使它们分裂,从而带来更具动感和互动性的体验。
- 照明:用于宇宙光辉的环境光和定向光。
- 物理:处理物体之间的碰撞和相互作用。
🎥 相机选择:为什么我们选择正交摄影
我们为场景选择了正交相机,因为无论火箭飞向何处,它都能保持所有元素的比例。由于我们的火箭场景横跨整个页面(高度由可滚动内容决定),正交相机让我们能够平滑地上下飞行火箭,而不会产生任何奇怪的透视变形。它确保了您在滚动页面并与火箭交互时获得一致且友好的体验。
💡 快速提示:使用eventSource
为了确保 3D 火箭场景即使在其他页面内容覆盖其上时仍能响应指针事件(例如鼠标或触摸交互),我们eventSource
在Canvas组件中使用了 prop。这会告诉react-three-fiber在哪里监听事件。通过将其设置eventSource
为根元素 ( #root
),火箭可以在其他内容覆盖其上方时接收事件。
以下是我们的设置方法:
<Canvas
eventSource={document.getElementById('root')} // Specifies the DOM element to listen for pointer events
eventPrefix="page" // The event prefix that is cast into canvas pointer x/y events
/>
🚀 主角:我们的互动火箭
火箭是整个演示的主角——它能够与环境互动,响应鼠标移动,并在页面上发射。为了让它真正动感十足,我们使用了react-three-fiber进行渲染,并使用 react-three-rapier实现物理效果。让我们来分解一下它的核心功能。
主要特点:
- 状态机:处理火箭的不同状态——空闲、发射、跟随(跟踪鼠标)和重置。
- 碰撞与物理:使用CapsuleCollider和RoundConeCollider,火箭可以与太空岩石碰撞。物理原理也有助于模拟火箭发射时的运动和冲击力。
- 悬停和点击交互:火箭通过放大效果响应鼠标悬停,单击可发射火箭或重置其位置。
- 平滑运动:火箭跟踪鼠标,使用矢量计算距离并施加力,同时在useFrame()中平滑旋转以跟随其飞行方向。
- 粒子:在火箭运动过程中添加动态排气粒子。
以下是关键片段:
const Rocket = memo(() => {
// Refs and Hooks
const rocketHovered = useRef(false);
...
// State machine
const { value: rocketState, transitionTo } = useStateMachine({
initial: 'idle',
states: {
idle: {},
launching: { onEnter: () => { setTimeout(() => transitionTo('following'), 500); } },
following: {},
resetting: { onEnter: async () => { await resetRocket(); transitionTo('idle'); } },
},
});
// Rocket reset logic
const resetRocket = useCallback(() => {
... // Reset rocket position, velocity, rotation
}, [viewportSize]);
// Frame logic (executed every frame)
useFrame((state, delta) => {
...
// Scale the rocket when hovered
rocketHovered.current
? meshRef.current?.scale.lerp(new Vector3(1.1, 1.1, 1.1), delta * 5)
: meshRef.current?.scale.lerp(new Vector3(1, 1, 1), delta * 5);
// Execute state-specific logic
switch (rocketState) {
case 'idle': resetRocket(); break;
case 'launching': rocket.applyImpulse(..., true); break;
case 'following': { ... } // Move rocket towards pointer, apply rotation
}
});
return (
<group>
<RigidBody ref={rigidBodyRef}>
<CapsuleCollider />
<RoundConeCollider />
<group
ref={meshRef}
onPointerEnter={() => { rocketHovered.current = true; }}
onPointerLeave={() => { rocketHovered.current = false; }}
onClick={() => rocketState === 'following' ? transitionTo('resetting') : transitionTo('launching')}
>
<RocketModel />
</group>
</RigidBody>
{/* Our own particle effects component of the rocket exhaust clouds */}
<Particles
visible={rocketState === 'launching' || rocketState === 'following'}
objectRef={exhaustMeshRef}
config={{ ... }}
/>
</group>
);
});
火箭跟随运动:跟踪鼠标
我们的火箭最酷炫的部分之一是它能够在太空中跟随鼠标移动。这项功能由react-three-fiberuseFrame
中的钩子实现,它允许我们每帧更新火箭的位置和旋转。以下是我们在跟踪用户鼠标的同时处理火箭平滑移动和旋转的方法。
要点:
- 鼠标跟踪:我们将指针的标准化设备坐标(NDC)转换为视口坐标,并将火箭移向该点。
- 距离计算:我们计算火箭当前位置和目标之间的距离,并利用该距离在正确的方向上施加力。
- 旋转:火箭平稳旋转以面向其移动方向,带来逼真的飞行体验。
以下是我们实现这一目标的简化版本:
useFrame((state, delta) => {
const rocket = rigidBodyRef.current;
if (!rocket || !viewportSize) return;
// Convert pointer coordinates to viewport space
const x = (state.pointer.x * viewportSize.width) / 2;
const y = (state.pointer.y * viewportSize.height) / 2;
// Calculate distance between current rocket position and target
const currentPos = rocket.translation() as Vector3;
const targetPos = new Vector3(x, y, currentPos.z);
const distance = targetPos.distanceTo(currentPos);
// Apply impulse to move rocket toward target
direction.current.copy(targetPos).sub(currentPos).normalize().multiplyScalar(delta * 1000 * distance);
rocket.applyImpulse(direction.current, true);
// Calculate the rotation angle and smoothly rotate the rocket
const rotationAngle = Math.atan2(y - currentPos.y, x - currentPos.x);
targetQuaternion.current.setFromAxisAngle(rotationAxis.current, rotationAngle - Math.PI / 2);
slerpedQuaternion.current.slerpQuaternions(rocket.rotation(), targetQuaternion.current, 0.08);
rocket.setRotation(slerpedQuaternion.current, true);
});
💡 快速提示:处理事件<group>
在处理three.js和光线投射时,一个 中通常会包含多个网格或子网格<group>
。这会导致每个子网格被光线投射器击中时触发多个onClick
或事件。为了避免这种情况,我们可以使用来阻止多个事件触发。onPointerEnter
e.stopPropagation()
这是一个简单的例子:
<group
name="rocket"
onClick={(e) => {
e.stopPropagation(); // Prevent multiple onClick calls
}}
>
<RocketModel />
<pointLight />
<mesh ... />
</group>
🚀 摘要 - 火箭:
-
状态机:控制火箭的不同模式——空闲、发射、跟随(跟踪鼠标)和重置。这确保了状态和交互之间的平滑过渡。
-
物理与碰撞:使用CapsuleCollider和RoundConeCollider,火箭可以与太空岩石等物体碰撞。基于物理的运动和脉冲处理确保碰撞响应逼真。
-
鼠标交互:得益于缩放效果,鼠标悬停在火箭上时,火箭会变大。点击即可发射火箭或将其重置为初始状态,为用户增添趣味互动。
-
流畅的鼠标追踪:火箭会根据鼠标与指针的距离,计算并施加相应的力度,从而流畅地追踪鼠标位置。火箭会沿着移动方向旋转,带来逼真的飞行体验。
-
粒子:动态排气粒子跟随火箭,在火箭发射和移动时增加视觉效果。
💥 粒子系统:为火箭排气提供燃料
如果没有一些史诗级的排气粒子,我们的火箭就感觉不完整!我们使用three.js构建了一个自定义粒子系统,用于渲染和基于着色器的控制,使其拥有动态外观。该系统高度可定制,让我们可以控制从粒子寿命到速度和湍流的所有内容。

关键概念:
-
自定义着色器材质:我们使用ShaderMaterial来控制粒子的外观,例如大小和颜色。片段着色器包含根据粒子年龄淡入淡出的逻辑。
const ParticleShaderMaterial = new ShaderMaterial({ uniforms: { color: { value: new Color('cyan') }, pointSize: { value: 1.0 }, dpr: { value: window.devicePixelRatio }, }, vertexShader: ` attribute float age; varying float vAge; void main() { vAge = age; gl_PointSize = ...; gl_Position = ...; } `, fragmentShader: ` varying float vAge; void main() { float alpha = 1.0 - vAge; // Fade based on age gl_FragColor = vec4(color, alpha); } `, });
-
粒子初始化:粒子以随机位置和速度初始化,从而创建逼真的尾气扩散效果。每个粒子都具有位置、年龄和大小等属性。
function initializeParticles(...) { const positions = [], velocities = [], ages = [], sizes = []; for (let i = 0; i < count; i++) { positions.push(...); // Set initial position velocities.push(new Vector3(...)); // Random velocity ages.push(-i / emissionRate); // Stagger particle emission sizes.push(size + sizeVariance * (Math.random() - 0.5) * 2); } return { positions, velocities, ages, sizes }; }
-
帧更新:每一帧,我们根据粒子的速度更新其位置,并应用重力和湍流。随着粒子老化,它们会逐渐消失,并在达到其寿命极限时重置。
useFrame((state, delta) => { // Update particle position and age every frame for (let i = 0; i < config.maxParticles; i++) { if (ages[i] >= 1.0) resetParticle(i); // Reset expired particles else updateParticle(i, delta); } });
-
动态粒子重置:当粒子的年龄达到其极限时,它会重置为新的位置、速度和年龄,为下一个发射周期做好准备。
function resetParticle(index) { // Reset particle to new random position and velocity positions[index * 3] = ...; velocities[index] = new Vector3(...); ages[index] = 0; }
组件结构:
粒子系统渲染为<points>
网格,其缓冲区属性包括位置、年龄和大小。自定义ShaderMaterial控制粒子的外观和行为。
<points visible={visible} ref={meshRef}>
<bufferGeometry attach="geometry">
<bufferAttribute attach="attributes-position" {...positions} />
<bufferAttribute attach="attributes-age" array={ages} itemSize={1} />
<bufferAttribute attach="attributes-particleSize" {...sizes} itemSize={1} />
</bufferGeometry>
<primitive attach="material" object={ParticleShaderMaterial} transparent />
</points>
💥 摘要 - 粒子:
-
自定义着色器材质:我们使用ShaderMaterial来管理粒子的外观、尺寸、颜色和淡入淡出效果。粒子会根据其年龄进行淡入淡出,从而提供逼真的排气效果。
-
粒子初始化:每个粒子的位置、速度、年龄和大小均随机初始化。这种随机性使尾气在火箭移动时能够自然扩散。
-
帧更新:系统会根据粒子的速度,每一帧更新其位置,并应用重力和湍流等效果。随着粒子老化,它们会逐渐消失,并在其生命周期结束时重置。
-
动态粒子重置:当粒子过期时,它会使用新的随机属性(位置、速度等)重置,确保连续发射,而无需从头开始生成新粒子。
-
高效的结构:系统利用
<points>
具有缓冲属性的网格来高效处理粒子数据。ShaderMaterial负责管理渲染和视觉效果,确保一切性能卓越。
🪨SpaceRocks:小行星的物理和分裂
SpaceRocks组件通过生成类似小行星的岩石,为我们的场景增添了活力。这些岩石漂浮在空中,在碰撞时会分裂开来。使用Rapier的物理引擎,这些岩石会四处弹跳,与火箭相互作用,并在受到足够重的撞击时动态分裂。

主要特点:
-
岩石几何:每块岩石都使用由随机顶点组成的凸几何体创建。这会产生不规则的岩石状形状。
const generateRockGeometry = () => { const vertices: Vector3[] = []; for (let i = 0; i < 50; i++) { vertices.push(new Vector3((Math.random() - 0.5) * 3, ...)); } const geometry = new ConvexGeometry(vertices); geometry.scale(5, 5, 5); return geometry; };
-
岩石分裂:当一块岩石以足够的力量碰撞时,它会分裂成两块较小的岩石。分裂的过程是通过计算沿特定平面的交点,然后从原始岩石生成两块新的岩石来实现的。
const splitRock = (rockGeometry: ConvexGeometry) => { const verticesA: Vector3[] = [], verticesB: Vector3[] = []; // Split the geometry along a plane const plane = new Plane(new Vector3(1, 0, 0), 0); const geometryA = new ConvexGeometry(verticesA), geometryB = new ConvexGeometry(verticesB); return [geometryA, geometryB]; };
-
碰撞处理:当岩石与火箭或其他物体发生碰撞时,我们会计算碰撞力。如果碰撞力超过阈值,岩石就会分裂。
onContactForce={(payload) => { const forceMag = payload.totalForceMagnitude / 100000; if (forceMag > 80) handleCollision(key, forceVec); // Only split if the force is strong enough }}
摇滚一代:
-
岩石在网格中随机定位并被赋予速度。它们被赋予了速度、角速度以及碰撞时分裂的能力等属性。
for (let i = 0; i < 10; i++) { const rockId = `${idPrefix}_rock_${i + 1}`; rockMap.set(rockId, { ref: createRef<RapierRigidBody>(), position: gridCells[i], velocity: new Vector3((Math.random() - 0.5) * 10, ...), geometry: generateRockGeometry(), canSplit: true, scale: 3 + Math.random() * 4, }); }
组件结构:
每块岩石都是一个刚体 ( RigidBody),具有恢复力(弹性)和摩擦力等属性。这些岩石会动态地相互作用,在环境中弹跳,并在撞击时分裂。
<RigidBody
ref={rock.ref}
position={rock.position}
linearVelocity={rock.velocity.toArray()}
restitution={0.9} // Bouncy collisions
friction={0.1}
onContactForce={(payload) => handleCollision(key, forceVec)} // Handle rock splitting
>
{/* Hull - Auto-generates mesh collider for convex geometries */}
<MeshCollider type="hull">
<mesh geometry={rock.geometry} scale={rock.scale}>
<primitive attach="material" object={rockMaterial} />
</mesh>
</MeshCollider>
</RigidBody>
🪨摘要 - SpaceRocks:
- 动态几何:使用凸几何随机生成不规则形状的岩石。
- 碰撞和分裂:岩石在高强度碰撞下分裂成更小的岩石,产生动态相互作用。
- 物理集成:使用Rapier实现逼真的运动、摩擦和弹性碰撞,使岩石在场景中表现得令人信服。
当火箭在太空中航行时,该系统增加了额外的互动性和乐趣!
🌌 太空环境:沉浸式太空背景
SpaceEnvironment组件为我们的火箭场景提供了动态背景。它包含一片星空和散布在太空中的行星集合。以下是我们使用three.js、react-three-fiber和着色器构建它以实现自定义效果的方法。

主要特点:
-
动态星空:星星在一个圆柱形空间(高度 = 页面高度)内随机生成,并通过控制大小和不透明度的自定义着色器使其闪烁。星星闪烁、消逝,模拟出一个生动活泼的太空场景。
const StarShaderMaterial = new ShaderMaterial({ uniforms: { color: { value: new Color('white') }, opacity: { value: 0 }, time: { value: 0 }, dpr: { value: 1.0 }}, vertexShader: ` varying float vTwinkle; uniform float time; void main() { vTwinkle = 0.5 + 0.5 * sin(time + position.x * 10.0); gl_PointSize = ...; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` varying float vTwinkle; void main() { gl_FragColor = vec4(color, opacity * vTwinkle); } `, transparent: true, });
-
实例化行星:我们使用实例化技术,高效地渲染空间中随机位置的行星。每颗行星都有随机的颜色、大小和位置,使场景充满变化。
const planets = useMemo(() => { return Array.from({ length: 20 }).map((_, index) => ( <Instance key={index} position={getRandomInCylinder(radius, height, innerPadding)} scale={Math.random() * 100} color={planetColors[index % planetColors.length]} /> )); }, [radius, height, innerPadding]);
-
旋转的太空环境:整个太空环境缓慢旋转,使恒星和行星看起来像在背景中移动。
useFrame((state, delta) => { environmentRef.current.rotation.y += delta * 0.015; StarShaderMaterial.uniforms.time.value = state.clock.getElapsedTime(); });
💡 快速提示:使用window.devicePixelRatio
一致的着色器渲染
使用涉及点大小(例如星空里的星星)的着色器时,务必考虑不同的屏幕分辨率和像素密度。通过引入window.devicePixelRatio
,您可以确保着色器能够适应不同的屏幕,从而在不同设备上保持一致的渲染效果。
以下是我们的应用方法:
const StarShaderMaterial = new ShaderMaterial({
uniforms: {
dpr: { value: window.devicePixelRatio }, // Ensures consistent point size across devices
},
vertexShader: `
uniform float dpr;
void main() {
gl_PointSize = gl_PointSize * dpr; // Adjust point size by device pixel ratio
}
`,
});
提醒:当屏幕发生变化时,请务必更新相关钩子dpr
中的值useFrame
,例如在不同分辨率的显示器上拖动窗口!
useFrame(() => {
StarShaderMaterial.uniforms.dpr.value = window.devicePixelRatio;
});
组件结构:
星星和行星在<group>
元素内部渲染。星星使用 渲染,而行星则使用实例<points>
创建,以提高渲染效率。
<group>
{/* Instanced stars */}
<points position={[0, 0, 0]}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" {...stars} />
</bufferGeometry>
<primitive attach="material" object={StarShaderMaterial} />
</points>
{/* Instanced planets */}
<Instances limit={100} material={PlanetMaterial}>
<sphereGeometry args={[1, 32, 32]} />
{planets}
</Instances>
</group>
🌌 摘要 - 空间环境:
- 动态星空:圆柱形空间中随机散布的闪烁星星为环境增添了生机。
- 实例行星:随机定位和缩放的行星为场景提供了多样性和深度。
- 旋转环境:整个背景的缓慢旋转使空间感觉广阔而生动。
- 平滑过渡:当环境变得可见或隐藏时,星星和行星会无缝地淡入和淡出。
这个太空环境为火箭的冒险创造了一个身临其境的动态背景,增强了场景的整体运动感和深度感。
🏎️ 性能提升:优化流畅的火箭体验
对于像火箭场景这样的互动体验,保持一流的性能至关重要——尤其是在处理 3D 渲染和物理计算时。让我们来探索一下我们是如何挤出这些额外的帧并保持流畅的:
🚀 1. 帧循环控制:“始终” vs. “按需”
为了防止火箭不在视野范围内时不必要的 GPU 占用,我们在网页内容下方创建了一个触发器元素。此开关会在和之间切换帧循环,确保浏览器仅在需要时重新渲染。"always"
"demand"
- “始终”:当火箭可见(滚动到视图中)或动画时使用,确保流畅的性能。
- “需求”:当火箭处于空闲状态且不在视野中时使用,这意味着场景仅在发生特定事件时重新渲染。
该技术有助于减少 GPU 压力并优化移动和笔记本电脑用户的电池使用情况。
<Canvas frameloop={isRocketVisibleOrFlying ? 'always' : 'demand'} />
🚀 2. 自定义useViewportSize
钩子
我们选择使用自定义useViewportSize()
钩子来跟踪视口变化,而不会触发过多的重新渲染。虽然useThree()
会{ viewport/size }
导致每次滚动时都重新渲染,但我们会限制视口大小检查,以避免性能下降。
const useViewportSize = () => {
const [size, setSize] = useState(null);
useFrame((state) => {
if (state.clock.elapsedTime % throttleTime > 0.01) return;
const { width, height } = state.size;
if (!size || size.width !== width || size.height !== height) {
setSize({ width, height });
}
});
return size;
};
🚀 3. 行星网格实例化
为了提高效率,我们使用网格实例化来处理行星的渲染。实例化允许您渲染几何体的多个副本,同时重复使用相同的材质,这大大减少了在场景中绘制每个行星的开销。
<Instances limit={100} material={PlanetMaterial}>
<sphereGeometry args={[1, 32, 32]} />
{planets}
</Instances>
🚀 4. 使用 Refs 来获取非渲染值
在处理useFrame()
循环内的动态更新时,我们将频繁变化的值(例如位置或速度)存储在Refs中。这样,我们可以直接操作它们,而无需触发组件重新渲染,从而节省 CPU 周期。
🚀 5. React-Three-Fiber 性能通用技巧
最后,我们遵循了React Three Fiber 文档中的最佳实践,以避免性能陷阱。其中包括:
- 除非绝对必要,否则避免过度使用
useFrame()
。 setState()
通过在动画循环内谨慎使用来进行批量更新。- 尽量减少循环内的新对象创建以防止垃圾收集。
要了解更多高级性能技巧,请务必查看官方的React Three Fiber 陷阱指南和性能优化技巧。
💡 快速提示:某些设备上的画布尺寸限制
使用大型画布时,请注意某些设备(例如Android Chrome和桌面版 Firefox)存在尺寸限制,如果画布高度超过 4096 像素,可能无法正常渲染。请务必考虑这些限制,并考虑为大视口启用动态缩放或禁用相关功能!
if (height > 4096 && browserName === 'Firefox' && isDesktop) {
setSize(null);
return;
}
通过实施这些性能调整,我们确保了流畅、身临其境的体验,而不会拖慢用户的设备——无论他们使用的是移动设备还是桌面设备。🖥️📱
🚀 总结:最后的倒计时
为了庆祝领英粉丝数量突破 1000,我们制作了这款互动式 3D 火箭,真是太棒了!从构建动态鼠标控制的火箭,到添加分裂的太空岩石、闪烁的星星,以及性能优化——这个项目真是一段充满乐趣的旅程。
正是这些项目提醒着我们热爱这份事业的原因:将创造力、科技以及一点点火箭科学(好吧,是很多火箭科学)融为一体。希望您喜欢阅读我们如何将这些元素融合在一起,甚至可能从中汲取一些经验,用于您自己的交互式网络项目。
欢迎随时查看zerodays.dev上的实时火箭复活节彩蛋,当然,请留意更多互动乐趣,因为我们将继续构建很酷的东西并突破 Web 开发的可能性界限。
为下一个里程碑——或许,下一枚火箭——干杯!🚀
感谢您的阅读和关注!
文章来源:https://dev.to/zerodays/building-an-interactive-3d-rocket-easter-egg-with-react- Three-Fiber-4pc