← 3D

Campfire

<Campfire3D /> A campfire in a night clearing, with a tent, pines and fireflies.

Animated · Source · Markdown

Theme
Usage
<Campfire3D />
Props and theme tokens
PropTypeDefaultDescription
themePartial<CampfireTheme>—Colour overrides. Applied live without rebuilding the scene.
speednumber1Animation speed multiplier.
pausedbooleanfalseFreezes the animation. Also stops rendering while off-screen or in a hidden tab.
classNamestring—Applied to the root element (4:3 by default; override the aspect ratio or height here).
styleCSSProperties—Merged into the root element style.
childrenReactNode—Rendered above the canvas, filling it.
Theme keyCSS variableDefault
Source files

Copy into components/three/. The shared files are needed once for all 3d. Also run npm i three.

components/three/campfire-3d.tsx
'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%)`}
    />
  );
}
components/three/three-frame.tsx
'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>
  );
}
components/three/three-frame.module.css
.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;
}