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.
We kept our tech stack lean but powerful to make this Easter egg fly.
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:
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.
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 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.
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>
);
});
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.
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>
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>
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.
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.
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;
}
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>
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.
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.
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
}}
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,
});
}
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>
This system adds an extra layer of interactivity and fun as the rocket navigates through space!
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.
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();
});
window.devicePixelRatio
for Consistent Shader RenderingWhen 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;
});
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>
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.
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:
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.
This technique helps reduce GPU strain and optimizes battery usage for mobile and laptop users.
<Canvas frameloop={isRocketVisibleOrFlying ? 'always' : 'demand'} />
useViewportSize
HookWe 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;
};
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>
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.
Lastly, we adhered to best practices from the React Three Fiber docs to avoid performance pitfalls. Some of these include:
useFrame()
unless absolutely necessary.setState()
sparingly inside animation loops.For more advanced performance tips, be sure to check out the official React Three Fiber pitfalls guide and performance optimization tips.
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. 🖥️📱
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!