← 3D
Campfire
<Campfire3D /> A campfire in a night clearing, with a tent, pines and fireflies.
<Campfire3D />Props and theme tokens
| Prop | Type | Default | Description |
|---|---|---|---|
theme | Partial<CampfireTheme> | — | Colour overrides. Applied live without rebuilding the scene. |
speed | number | 1 | Animation speed multiplier. |
paused | boolean | false | Freezes the animation. Also stops rendering while off-screen or in a hidden tab. |
className | string | — | Applied to the root element (4:3 by default; override the aspect ratio or height here). |
style | CSSProperties | — | Merged into the root element style. |
children | ReactNode | — | Rendered above the canvas, filling it. |
| Theme key | CSS variable | Default |
|---|
Source files
Copy into components/three/. The shared files are needed once for all 3d. Also run npm i three.
'use client';
// <Campfire3D>: a campfire in a night clearing. Layered flame cones flicker and a warm point light
// pulses with them, lighting a ring of stones, a tent, a log bench and the pines around. Embers and
// smoke rise, fireflies blink at the tree line, and the camera drifts slowly. three.js.
import * as THREE from 'three';
import { ThreeFrame, seeded, type SceneSetup, type ThreeSceneProps } from './three-frame';
export const campfireTheme = {
skyTop: '#0a1028',
skyBottom: '#1f2a4a',
ground: '#2f5a3e',
pine: '#24503a',
trunk: '#6b4a32',
tent: '#e4a24a',
stone: '#8a8f99',
log: '#7a5236',
flame: '#ff8a3d',
flameCore: '#ffe08a',
firefly: '#d8ff7a',
moon: '#f1ead8',
};
export type CampfireTheme = typeof campfireTheme;
const EMBERS = 22;
const SMOKE = 6;
const setup: SceneSetup<CampfireTheme> = ({ scene, camera, theme }) => {
const rand = seeded(17);
scene.fog = new THREE.Fog(theme.skyBottom, 9, 20);
const flat = (color: string, extra: THREE.MeshStandardMaterialParameters = {}) =>
new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.9, ...extra });
const mats = {
ground: flat(theme.ground),
pine: flat(theme.pine),
trunk: flat(theme.trunk),
tent: flat(theme.tent, { side: THREE.DoubleSide }),
door: flat('#3a2a22', { side: THREE.DoubleSide }),
stone: flat(theme.stone),
log: flat(theme.log),
char: flat('#2a1d18'),
// Flames and embers make their own light, so they are unlit and additive.
flame: new THREE.MeshBasicMaterial({ color: theme.flame, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }),
core: new THREE.MeshBasicMaterial({ color: theme.flameCore, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }),
ember: new THREE.MeshBasicMaterial({ color: theme.flameCore }),
firefly: new THREE.MeshBasicMaterial({ color: theme.firefly }),
moon: new THREE.MeshBasicMaterial({ color: theme.moon, fog: false }),
};
const hemi = new THREE.HemisphereLight('#5d74b8', theme.ground, 0.55);
const moonLight = new THREE.DirectionalLight('#9fb4ff', 0.7);
moonLight.position.set(-6, 8, -4);
const fire = new THREE.PointLight(theme.flame, 30, 0, 2);
fire.position.set(0, 0.7, 0);
scene.add(hemi, moonLight, fire);
// Ground: a gently uneven clearing whose edge is lost in the fog.
const groundGeo = new THREE.PlaneGeometry(70, 70, 50, 50);
groundGeo.rotateX(-Math.PI / 2);
const gp = groundGeo.attributes.position;
for (let i = 0; i < gp.count; i++) {
const d = Math.hypot(gp.getX(i), gp.getZ(i));
gp.setY(i, d < 1.4 ? -0.02 : (rand() - 0.5) * 0.12 + Math.min(0.6, Math.max(0, d - 6) * 0.06));
}
groundGeo.computeVertexNormals();
scene.add(new THREE.Mesh(groundGeo, mats.ground));
// Fire pit: a ring of stones, three logs leaning into a tipi, charred ground in the middle.
const pit = new THREE.Group();
const char = new THREE.Mesh(new THREE.CircleGeometry(0.55, 10), mats.char);
char.rotation.x = -Math.PI / 2;
char.position.y = 0.01;
pit.add(char);
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2;
const stone = new THREE.Mesh(new THREE.DodecahedronGeometry(0.14 + rand() * 0.06, 0), mats.stone);
stone.position.set(Math.cos(a) * 0.68, 0.06, Math.sin(a) * 0.68);
stone.rotation.set(rand() * 3, rand() * 3, rand() * 3);
stone.scale.y = 0.7;
pit.add(stone);
}
for (let i = 0; i < 3; i++) {
const log = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 0.95, 6), mats.log);
const a = (i / 3) * Math.PI * 2 + 0.3;
log.position.set(Math.cos(a) * 0.24, 0.3, Math.sin(a) * 0.24);
log.lookAt(0, 0.9, 0);
log.rotateX(Math.PI / 2);
pit.add(log);
}
scene.add(pit);
// Flames: three nested cones, each flickering on its own beat.
const flames = [
{ r: 0.34, h: 0.95, mat: mats.flame, speed: 9 },
{ r: 0.22, h: 0.8, mat: mats.flame, speed: 13 },
{ r: 0.13, h: 0.5, mat: mats.core, speed: 17 },
].map(({ r, h, mat, speed }, i) => {
const geo = new THREE.ConeGeometry(r, h, 7, 1, true);
geo.translate(0, h / 2, 0);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.y = 0.12;
mesh.rotation.y = i;
scene.add(mesh);
return { mesh, speed, phase: i * 1.7 };
});
// Embers: tiny bright motes that rise, wander and shrink away. Stateless: position is a
// function of time and each ember's own phase, so a still frame is always valid.
const emberGeo = new THREE.IcosahedronGeometry(0.025, 0);
const embers = Array.from({ length: EMBERS }, (_, k) => {
const mesh = new THREE.Mesh(emberGeo, mats.ember);
scene.add(mesh);
return { mesh, offset: k / EMBERS, speed: 0.35 + rand() * 0.25, swirl: rand() * Math.PI * 2 };
});
// Smoke: soft grey puffs above the flames, each with its own fading material.
const smoke = Array.from({ length: SMOKE }, (_, k) => {
const mat = new THREE.MeshStandardMaterial({ color: '#9aa0ad', transparent: true, depthWrite: false, roughness: 1, flatShading: true });
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.16, 1), mat);
scene.add(mesh);
return { mesh, mat, offset: k / SMOKE };
});
// Tent: a triangular prism with a dark door, angled toward the fire.
const tent = new THREE.Group();
const profile = new THREE.Shape();
profile.moveTo(-0.9, 0);
profile.lineTo(0.9, 0);
profile.lineTo(0, 1.25);
profile.closePath();
const tentGeo = new THREE.ExtrudeGeometry(profile, { depth: 1.7, bevelEnabled: false });
tentGeo.translate(0, 0, -0.85);
tent.add(new THREE.Mesh(tentGeo, mats.tent));
const doorShape = new THREE.Shape();
doorShape.moveTo(-0.32, 0);
doorShape.lineTo(0.32, 0);
doorShape.lineTo(0, 0.8);
doorShape.closePath();
const door = new THREE.Mesh(new THREE.ShapeGeometry(doorShape), mats.door);
door.position.z = 0.86;
tent.add(door);
tent.position.set(-2.4, 0, -1.4);
tent.rotation.y = 0.75;
scene.add(tent);
// Log bench on the other side.
const bench = new THREE.Mesh(new THREE.CylinderGeometry(0.17, 0.17, 1.4, 7), mats.log);
bench.rotation.set(0, -0.5, Math.PI / 2);
bench.position.set(1.9, 0.17, 0.5);
scene.add(bench);
// Pines around the clearing, kept clear of the tent and the camera's view of the fire.
const pines: THREE.Vector3[] = [];
while (pines.length < 18) {
const a = rand() * Math.PI * 2;
const r = 4 + rand() * 4;
const p = new THREE.Vector3(Math.cos(a) * r, 0, Math.sin(a) * r);
if (p.z > 2.5 && Math.abs(p.x) < 4) continue; // keep the foreground open
if (pines.some(q => q.distanceTo(p) < 1.1)) continue;
pines.push(p);
const tree = new THREE.Group();
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.11, 0.6, 5), mats.trunk);
trunk.position.y = 0.3;
tree.add(trunk);
for (let tier = 0; tier < 3; tier++) {
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.75 - tier * 0.18, 1.1, 7), mats.pine);
cone.position.y = 0.9 + tier * 0.55;
tree.add(cone);
}
tree.position.copy(p);
tree.scale.setScalar(0.8 + rand() * 0.6);
tree.rotation.y = rand() * Math.PI;
scene.add(tree);
}
// Fireflies sway along the tree line behind the fire, never toward the camera.
const fireflyGeo = new THREE.IcosahedronGeometry(0.035, 0);
const fireflies = Array.from({ length: 10 }, () => {
const mesh = new THREE.Mesh(fireflyGeo, mats.firefly);
scene.add(mesh);
const a = Math.PI * (1.1 + rand() * 0.8);
return { mesh, a, r: 3 + rand() * 2.5, h: 0.5 + rand() * 1.2, speed: 0.2 + rand() * 0.2, blink: rand() * 6 };
});
// Sky: stars on a dome, and a moon.
const starPos = new Float32Array(260 * 3);
for (let i = 0; i < 260; i++) {
const a = rand() * Math.PI * 2;
const up = 0.08 + rand() * 0.9;
const v = new THREE.Vector3(Math.cos(a) * Math.cos(up), Math.sin(up), Math.sin(a) * Math.cos(up)).multiplyScalar(40);
starPos.set([v.x, v.y, v.z], i * 3);
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({ color: '#ffffff', size: 0.16, transparent: true, opacity: 0.85, fog: false });
scene.add(new THREE.Points(starGeo, starMat));
const moon = new THREE.Mesh(new THREE.IcosahedronGeometry(1.2, 2), mats.moon);
moon.position.set(-12, 11, -26);
scene.add(moon);
const flameColor = new THREE.Color(theme.flame);
return {
update(t) {
// Camera drifts in a slow arc, always looking at the fire.
camera.position.set(Math.sin(t * 0.06) * 1.6, 3 + Math.sin(t * 0.09) * 0.2, 8.4);
camera.lookAt(0, 0.7, 0);
// Two incommensurate sines read as an irregular flicker.
const flicker = 0.5 * Math.sin(t * 11) + 0.3 * Math.sin(t * 17.3 + 1) + 0.2 * Math.sin(t * 5.1);
fire.intensity = 30 + flicker * 7;
flames.forEach(({ mesh, speed, phase }) => {
const f = Math.sin(t * speed + phase) * 0.5 + Math.sin(t * speed * 1.7 + phase * 2) * 0.5;
mesh.scale.set(1 + f * 0.06, 1 + f * 0.18, 1 + f * 0.06);
mesh.rotation.y += 0.02;
mesh.rotation.z = f * 0.05;
});
embers.forEach(({ mesh, offset, speed, swirl }) => {
const p = (t * speed + offset) % 1;
const a = swirl + t * 1.4 + p * 4;
mesh.position.set(Math.cos(a) * (0.12 + p * 0.5), 0.5 + p * 2.6, Math.sin(a) * (0.12 + p * 0.5));
mesh.scale.setScalar(Math.max(0.01, 1.2 * (1 - p)));
});
smoke.forEach(({ mesh, mat, offset }) => {
const p = (t * 0.12 + offset) % 1;
mesh.position.set(0.15 + p * 1.1, 1.3 + p * 2.8, -p * 0.6);
mesh.scale.setScalar(0.5 + p * 1.6);
mat.opacity = 0.16 * Math.sin(p * Math.PI);
});
fireflies.forEach(f => {
const a = f.a + Math.sin(t * f.speed + f.blink) * 0.25;
f.mesh.position.set(Math.cos(a) * f.r, f.h + Math.sin(t * 0.7 + f.blink) * 0.25, Math.sin(a) * f.r);
const on = Math.max(0, Math.sin(t * 1.3 + f.blink)) ** 3;
f.mesh.scale.setScalar(0.2 + on * 1.2);
});
},
setTheme(th) {
mats.ground.color.set(th.ground);
mats.pine.color.set(th.pine);
mats.trunk.color.set(th.trunk);
mats.tent.color.set(th.tent);
mats.stone.color.set(th.stone);
mats.log.color.set(th.log);
mats.flame.color.set(th.flame);
mats.core.color.set(th.flameCore);
mats.ember.color.set(th.flameCore);
mats.firefly.color.set(th.firefly);
mats.moon.color.set(th.moon);
flameColor.set(th.flame);
fire.color.copy(flameColor);
hemi.groundColor.set(th.ground);
(scene.fog as THREE.Fog).color.set(th.skyBottom);
},
};
};
/** A campfire in a night clearing, with a tent, pines, embers and fireflies. */
export function Campfire3D(props: ThreeSceneProps<CampfireTheme>) {
return (
<ThreeFrame
{...props}
defaultTheme={campfireTheme}
setup={setup}
// The sky reaches the fog colour just above the horizon, so the fogged ground meets it seamlessly.
background={t => `linear-gradient(${t.skyTop}, ${t.skyBottom} 17%)`}
/>
);
}
'use client';
// Shared frame for every 3D scene: canvas sizing, render loop, pausing, theming and cleanup.
// Scenes provide a module-level `setup` function; this frame owns everything else.
import { useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react';
import * as THREE from 'three';
import styles from './three-frame.module.css';
/** Props every 3D scene accepts. `T` is the scene's theme (colour tokens). */
export type ThreeSceneProps<T> = {
/** Override any of the scene's colours. Updates live without rebuilding the scene. */
theme?: Partial<T>;
/** Freeze the animation. Scenes also stop rendering while off-screen or in a hidden tab. */
paused?: boolean;
/** Animation speed multiplier (default 1). */
speed?: number;
className?: string;
style?: CSSProperties;
/** Content rendered on top of the canvas. */
children?: ReactNode;
};
export type SceneContext<T> = {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
theme: T;
};
export type SceneController<T> = {
/** Advance the scene to `time` seconds (scaled by `speed`). */
update: (time: number) => void;
/** Apply new colours to existing materials and lights. */
setTheme: (theme: T) => void;
};
/** Build the scene once. Must be defined at module level so its identity is stable. */
export type SceneSetup<T> = (ctx: SceneContext<T>) => SceneController<T>;
/** True when the browser can create a WebGL context. The probe context is released immediately. */
function webglAvailable() {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
gl?.getExtension('WEBGL_lose_context')?.loseContext();
return Boolean(gl);
} catch {
return false;
}
}
/** Deterministic PRNG so scenes look the same on every load. */
export function seeded(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type ThreeFrameProps<T extends Record<string, string>> = ThreeSceneProps<T> & {
defaultTheme: T;
setup: SceneSetup<T>;
/** CSS background behind the transparent canvas, e.g. a sky gradient. */
background?: (theme: T) => string;
/** Time shown as a still frame under prefers-reduced-motion. */
stillTime?: number;
};
export function ThreeFrame<T extends Record<string, string>>({
defaultTheme,
setup,
background,
stillTime = 4,
theme,
paused = false,
speed = 1,
className,
style,
children,
}: ThreeFrameProps<T>) {
const hostRef = useRef<HTMLDivElement>(null);
const merged = useMemo(() => ({ ...defaultTheme, ...theme }) as T, [defaultTheme, theme]);
// Live values read by the render loop without restarting it.
const live = useRef({ theme: merged, paused, speed });
live.current.speed = speed;
const control = useRef<{ schedule: () => void; redraw: () => void; setTheme: (t: T) => void } | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
// Without WebGL, leave the CSS background and children visible (and skip three.js's console errors).
if (!webglAvailable()) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' });
} catch {
return; // No WebGL: leave the background and children visible.
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.domElement.className = styles.canvas;
host.prepend(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200);
const controller = setup({ scene, camera, renderer, theme: live.current.theme });
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let visible = true;
let time = reduceMotion.matches ? stillTime : 0;
let last = 0;
let raf = 0;
const draw = () => {
controller.update(time);
renderer.render(scene, camera);
};
const running = () => visible && !live.current.paused && !reduceMotion.matches && !document.hidden;
const frame = (now: number) => {
raf = 0;
if (!running()) return;
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
time += delta * live.current.speed;
draw();
raf = requestAnimationFrame(frame);
};
const schedule = () => {
if (reduceMotion.matches) time = stillTime;
if (running() && !raf) {
last = performance.now();
raf = requestAnimationFrame(frame);
} else if (!running()) {
draw();
}
};
const resize = () => {
const { width, height } = host.getBoundingClientRect();
if (!width || !height) return;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
draw();
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(host);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
schedule();
});
intersectionObserver.observe(host);
document.addEventListener('visibilitychange', schedule);
reduceMotion.addEventListener('change', schedule);
control.current = { schedule, redraw: draw, setTheme: controller.setTheme };
resize();
schedule();
return () => {
cancelAnimationFrame(raf);
resizeObserver.disconnect();
intersectionObserver.disconnect();
document.removeEventListener('visibilitychange', schedule);
reduceMotion.removeEventListener('change', schedule);
control.current = null;
scene.traverse(object => {
const mesh = object as THREE.Mesh;
mesh.geometry?.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
materials.forEach(m => m.dispose());
});
renderer.dispose();
renderer.domElement.remove();
};
}, [setup, stillTime]);
useEffect(() => {
live.current.theme = merged;
control.current?.setTheme(merged);
control.current?.redraw();
}, [merged]);
useEffect(() => {
live.current.paused = paused;
control.current?.schedule();
}, [paused]);
const classes = [styles.root, className].filter(Boolean).join(' ');
return (
<div ref={hostRef} className={classes} style={{ background: background?.(merged), ...style }}>
{children && <div className={styles.overlay}>{children}</div>}
</div>
);
}
.root {
position: relative;
display: block;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
}
.canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.overlay {
position: absolute;
inset: 0;
}