🚀 Building an Interactive 3D Rocket Easter Egg with React Three Fiber
When we hit 1000 followers on LinkedIn, we knew we had to celebrate in style! Inspired by Vercel's awesome post on Building an Interactive 3D Event Badge with React Three Fiber, we wanted to take that vibe and launch something fun of our own. So, what better way to celebrate than with a 3D rocket Easter egg on our shiny new website, zerodays.dev?
It’s one of those things that’s just fun to build, like the digital equivalent of a fidget spinner, but way cooler. We had a blast working on it, and today we’re going to take you behind the scenes.
Let's get straight into it.
🚀 Table of Contents:
🏁 The Setup:
🛠️ Tools to Build Our 3D Rocket
We kept our tech stack lean but powerful to make this Easter egg fly.
- React: The core of our interactive UI.
- TypeScript: Strong typing to keep our code bug-free.
- react-three-fiber: The magic behind our 3D scene, integrating three.js with React.
- react-three-drei: Handy helpers for cameras, lighting, and effects.
- react-three-rapier: Physics engine for realistic collisions and rocket interactions.
🎬 Setting Up the 3D Scene
The first step was setting up the basic 3D scene for our rocket. We used react-three-fiber to handle rendering the scene inside a React component, along with react-three-drei for some helpers like the camera and lighting. We also integrated react-three-rapier for physics and collision detection.
Here’s a simplified version of the initial setup:
'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;
This sets up our 3D world with:
- Rocket: The star of the show.
- SpaceEnvironment: A cool space backdrop.
- SpaceRocks: Floating space debris that adds extra flair—and danger! The rocket can collide with these rocks, causing them to split apart for a more dynamic and interactive experience.
- Lighting: Ambient and directional lights for a cosmic glow.
- Physics: Handles collisions and interactions between objects.
🎥 Camera Choice: Why We Went with Orthographic
We chose an Orthographic Camera for our scene because it keeps everything proportional, no matter where the rocket is flying. Since our rocket scene spans the entire page (with height dictated by the scrollable content), an orthographic camera lets us fly the rocket up and down smoothly without any weird perspective distortions. It ensures a consistent, user-friendly experience as you scroll through the page and interact with the rocket.
💡 Quick Tip: Handling Events with eventSource
To ensure the 3D rocket scene can still respond to pointer events (e.g., mouse or touch interactions) even when other page content overlays it, we used the eventSource
prop in our Canvas component. This tells react-three-fiber where to listen for events. By setting eventSource
to the root element (#root
), the rocket can receive events while other content layers above it.
Here's how we set it up:
<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
/>
🚀 The Main Actor: Our Interactive Rocket
The rocket is the star of the show—interacting with the environment, responding to mouse movements, and launching across the page. To make it truly dynamic, we used react-three-fiber for rendering and react-three-rapier for physics. Let’s break down the core functionality.
Key Features:
- State Machine: Handles the rocket’s different states—idle, launching, following (tracking the mouse), and resetting.
- Collision and Physics: Using CapsuleCollider and RoundConeCollider, the rocket can collide with space rocks. Physics also helps simulate rocket movement and impulse on launch.
- Hover and Click Interaction: The rocket responds to mouse hover with a scale-up effect, and clicking either launches it or resets its position.
- Smooth Movement: The rocket tracks the mouse, using vectors to calculate distance and apply force, while also smoothly rotating to follow its flight direction in useFrame().
- Particles: Adds dynamic exhaust particles during the rocket’s movement.
Here’s a key snippet:
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>
);
});
Rocket Following Movement: Keeping Track of the Mouse
One of the coolest parts of our rocket is its ability to follow the mouse as it flies through space. This is powered by the useFrame
hook in react-three-fiber, which lets us update the rocket's position and rotation every frame. Here's how we handle the rocket's smooth movement and rotation while tracking the user's pointer.
Key Points:
- Mouse Tracking: We convert the normalized device coordinates (NDC) from the pointer into viewport coordinates and move the rocket towards that point.
- Distance Calculation: We compute the distance between the rocket's current position and the target, using that to apply a force in the correct direction.
- Rotation: The rocket smoothly rotates to face the direction it’s moving, giving a realistic flight experience.
Here’s a simplified version of how we achieve this:
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);
});
💡 Quick Tip: Handling Events on <group>
When dealing with three.js and raycasting, it's common to have multiple meshes or submeshes within a <group>
. This can result in multiple onClick
or onPointerEnter
events being fired as each submesh gets hit by the raycaster. To avoid this, we can use e.stopPropagation()
to prevent multiple event triggers.
Here’s a quick example:
<group
name="rocket"
onClick={(e) => {
e.stopPropagation(); // Prevent multiple onClick calls
}}
>
<RocketModel />
<pointLight />
<mesh ... />
</group>
🚀 Summary - Rocket:
State Machine: Controls the rocket's different modes—idle, launching, following (tracking the mouse), and resetting. This ensures smooth transitions between states and interactions.
Physics and Collisions: Using CapsuleCollider and RoundConeCollider, the rocket can collide with objects like space rocks. Physics-based movement and impulse handling ensure realistic responses to collisions.
Mouse Interaction: The rocket grows when hovered over, thanks to scaling effects. Clicking it launches the rocket or resets it to its initial state, adding fun interaction for users.
Smooth Mouse Tracking: The rocket tracks the mouse position smoothly, with calculated forces applied based on the distance to the pointer. It rotates toward the direction it’s moving, giving the feeling of true flight.
Particles: The dynamic exhaust particles follow the rocket, adding visual flair as it launches and moves.
💥 Particle System: Fueling the Rocket’s Exhaust
Our rocket wouldn't feel complete without some epic exhaust particles! We built a custom particle system using three.js for rendering and shader-based control to give it that dynamic look. The system is fairly customizable, allowing us to control everything from particle lifetime to velocity and turbulence.
Key Concepts:
-
Custom Shader Material: We use a ShaderMaterial to control particle appearance, such as size and color. The fragment shader includes logic to fade particles based on their age.
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); } `, });
-
Particle Initialization: Particles are initialized with random positions and velocities, creating a realistic spread for the exhaust. Each particle has attributes like position, age, and size.
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 }; }
-
Frame Updates: Each frame, we update particle positions based on their velocity and apply gravity and turbulence. As particles age, they fade out and are reset when they reach their lifetime limit.
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); } });
-
Dynamic Particle Reset: When a particle’s age reaches its limit, it’s reset to a new position, velocity, and age, making it ready for the next emission cycle.
function resetParticle(index) { // Reset particle to new random position and velocity positions[index * 3] = ...; velocities[index] = new Vector3(...); ages[index] = 0; }
Component Structure:
The particle system is rendered as a <points>
mesh with buffer attributes for position, age, and size. A custom ShaderMaterial controls particle appearance and behavior.
<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>
💥 Summary - Particles:
Custom Shader Material: We use a ShaderMaterial to manage particle appearance, handling size, color, and fade effects. The particles fade based on their age, providing a realistic exhaust effect.
Particle Initialization: Each particle is randomly initialized with a position, velocity, age, and size. This randomness gives the exhaust a natural spread as the rocket moves.
Frame Updates: Every frame, the system updates particle positions based on their velocity and applies effects like gravity and turbulence. As particles age, they fade out and are reset when their lifetime ends.
Dynamic Particle Reset: When a particle expires, it’s reset with new random properties (position, velocity, etc.), ensuring continuous emission without needing to generate new particles from scratch.
Efficient Structure: The system leverages a
<points>
mesh with buffer attributes for efficient handling of particle data. The ShaderMaterial manages the rendering and visual effects, keeping everything performant.
🪨 SpaceRocks: Asteroids with Physics and Splitting
The SpaceRocks component brings extra life to our scene by generating asteroid-like rocks that float around and can be split upon collisions. Using Rapier for physics, these rocks bounce around, interact with the rocket, and split dynamically when hit hard enough.
Key Features:
-
Rock Geometry: Each rock is created using a ConvexGeometry made from random vertices. This results in irregular, rock-like shapes.
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; };
-
Rock Splitting: When a rock collides with enough force, it's split into two smaller rocks. The splitting is handled by calculating the intersection points along a defined plane, then generating two new rocks from the original.
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]; };
-
Collision Handling: When a rock collides with the rocket or another object, we compute the force of the collision. If the force is above a threshold, the rock splits.
onContactForce={(payload) => { const forceMag = payload.totalForceMagnitude / 100000; if (forceMag > 80) handleCollision(key, forceVec); // Only split if the force is strong enough }}
Rock Generation:
-
Rocks are randomly positioned and given velocity in a grid. They are assigned attributes like velocity, angular velocity, and the ability to split on collisions.
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, }); }
Component Structure:
Each rock is a RigidBody with properties such as restitution (bounciness) and friction. These rocks interact dynamically, bouncing off the environment and splitting upon impact.
<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>
🪨 Summary - SpaceRocks:
- Dynamic Geometries: Rocks are randomly generated with irregular shapes using ConvexGeometry.
- Collisions and Splitting: Rocks split into smaller rocks upon high-force collisions, creating dynamic interactions.
- Physics Integration: Using Rapier for realistic movement, friction, and bouncy collisions makes the rocks behave convincingly in the scene.
This system adds an extra layer of interactivity and fun as the rocket navigates through space!
🌌 SpaceEnvironment: Immersive Space Backdrop
The SpaceEnvironment component brings a dynamic backdrop to our rocket scene. It includes a starfield and a collection of planets scattered throughout space. Here’s how we built it using three.js, react-three-fiber, and shaders for custom effects.
Key Features:
-
Dynamic Starfield: Stars are randomly generated in a cylindrical volume (height = pageHeight) and made to twinkle with a custom shader that controls size and opacity. The stars appear to blink and fade, simulating a living, dynamic space scene.
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, });
-
Instanced Planets: We use instancing to efficiently render planets at random positions within the space. Each planet has a random color, size, and location, filling the scene with variety.
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]);
-
Rotating Space Environment: The entire space environment rotates slowly, making the stars and planets appear to move in the background.
useFrame((state, delta) => { environmentRef.current.rotation.y += delta * 0.015; StarShaderMaterial.uniforms.time.value = state.clock.getElapsedTime(); });
💡 Quick Tip: Using window.devicePixelRatio
for Consistent Shader Rendering
When working with shaders that involve point size (like stars in a starfield), it’s crucial to account for varying screen resolutions and pixel densities. By incorporating window.devicePixelRatio
, you ensure that your shader adjusts to different screens, maintaining consistent rendering across devices.
Here's how we applied this:
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
}
`,
});
Reminder: Always update the dpr
value in your useFrame
or relevant hook when screens change, like dragging your window across monitors with different resolutions!
useFrame(() => {
StarShaderMaterial.uniforms.dpr.value = window.devicePixelRatio;
});
Component Structure:
The stars and planets are rendered inside a <group>
element. Stars are rendered using <points>
, while planets are created using Instances for efficient rendering.
<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>
🌌 Summary - SpaceEnvironment:
- Dynamic Starfield: Twinkling stars scattered randomly in a cylindrical space add life to the environment.
- Instanced Planets: Randomly positioned and scaled planets provide variety and depth to the scene.
- Rotating Environment: Slow rotation of the entire backdrop makes space feel vast and alive.
- Smooth Transitions: Stars and planets fade in and out seamlessly when the environment becomes visible or hidden.
This space environment creates an immersive, dynamic backdrop for the rocket's adventure, contributing to the overall feeling of motion and depth in the scene.
🏎️ Performance Boosts: Optimizing for Smooth Rocket Experience
For an interactive experience like our rocket scene, keeping performance top-notch is critical—especially when dealing with 3D rendering and physics calculations. Let’s explore how we squeezed out those extra frames and kept things smooth:
🚀 1. Frame Loop Control: "Always" vs. "Demand"
To prevent unnecessary GPU usage when the rocket isn’t in view, we created a trigger element just below the content on the webpage. This switch toggles the frame loop between "always"
and "demand"
, ensuring the browser only re-renders when needed.
- "Always": Used when the rocket is visible (scrolled into view) or animating, ensuring buttery-smooth performance.
- "Demand": Used when the rocket is idle and not in view, meaning the scene only re-renders when a specific event occurs.
This technique helps reduce GPU strain and optimizes battery usage for mobile and laptop users.
<Canvas frameloop={isRocketVisibleOrFlying ? 'always' : 'demand'} />
🚀 2. Custom useViewportSize
Hook
We opted for a custom useViewportSize()
hook to track viewport changes without triggering excessive re-renders. While useThree()
's { viewport/size }
would cause rerenders on every scroll, we throttle viewport size checks to avoid performance hits.
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. Mesh Instancing for Planets
For efficiency, we used mesh instancing to handle the rendering of planets. Instancing allows you to render multiple copies of a geometry while reusing the same material, which drastically reduces the overhead of drawing each individual planet in the scene.
<Instances limit={100} material={PlanetMaterial}>
<sphereGeometry args={[1, 32, 32]} />
{planets}
</Instances>
🚀 4. Using Refs for Non-Rerender Values
When dealing with dynamic updates inside the useFrame()
loop, we stored frequently changing values (like positions or velocities) in Refs. This way, we can manipulate them directly without triggering component rerenders, which saves CPU cycles.
🚀 5. General React-Three-Fiber Performance Tips
Lastly, we adhered to best practices from the React Three Fiber docs to avoid performance pitfalls. Some of these include:
-
Avoid excessive use of
useFrame()
unless absolutely necessary. -
Batch updates by using
setState()
sparingly inside animation loops. - Minimize new object creation inside loops to prevent garbage collection.
For more advanced performance tips, be sure to check out the official React Three Fiber pitfalls guide and performance optimization tips.
💡 Quick Tip: Canvas Size Limits on Some Devices
When working with large canvases, remember that some devices (like Android Chrome and Firefox on Desktop) have size limitations that may not render properly if the canvas height exceeds 4096px. Always account for these limitations and consider dynamic scaling or feature disabling for large viewports!
if (height > 4096 && browserName === 'Firefox' && isDesktop) {
setSize(null);
return;
}
By implementing these performance tweaks, we ensured a smooth, immersive experience without bogging down users' devices—whether they’re on mobile or desktop. 🖥️📱
🚀 Wrapping it Up: The Final Countdown
Celebrating our 1,000 followers on LinkedIn with this interactive 3D rocket has been an absolute blast! From building a dynamic, mouse-controlled rocket to adding splitting space rocks, twinkling stars, and performance optimizations—this project has truly been a fun journey.
It’s projects like these that remind us why we love what we do: blending creativity, tech, and a little bit of rocket science (okay, a lot of rocket science). We hope you enjoyed reading about how we put it all together and maybe even picked up a few tips for your own interactive web projects.
Feel free to check out the live rocket Easter egg on zerodays.dev and, of course, keep an eye out for more interactive fun as we continue to build cool things and push the boundaries of what’s possible in web development.
Here’s to the next milestone—and maybe, the next rocket! 🚀
Thanks for reading and following along!