← 3D

Tiny planet

<TinyPlanet3D /> Low-poly planet with cottages, a windmill and orbiting clouds.

Animated · Source · Markdown

Theme
Usage
<TinyPlanet3D />
Props and theme tokens
PropTypeDefaultDescription
themePartial<TinyPlanetTheme>—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/tiny-planet-3d.tsx
'use client';

// <TinyPlanet3D>: a small low-poly planet turning slowly, with trees, cottages,
// a windmill, orbiting clouds, a moon and a starfield. three.js.

import * as THREE from 'three';
import { ThreeFrame, seeded, type SceneSetup, type ThreeSceneProps } from './three-frame';

export const tinyPlanetTheme = {
  skyTop: '#141a3a',
  skyBottom: '#3d3270',
  ground: '#7cc576',
  leaves: '#3f9d5a',
  trunk: '#8a5a3c',
  walls: '#fbf3e4',
  roof: '#e4573d',
  cloud: '#ffffff',
  moon: '#e3ddd0',
  stars: '#ffffff',
};
export type TinyPlanetTheme = typeof tinyPlanetTheme;

const RADIUS = 1.8;
const UP = new THREE.Vector3(0, 1, 0);

/** Stand an object on the surface, pointing outward along `dir`. */
function plant(object: THREE.Object3D, dir: THREE.Vector3, lift = 0) {
  object.position.copy(dir).multiplyScalar(RADIUS - 0.02 + lift);
  object.quaternion.setFromUnitVectors(UP, dir);
  return object;
}

function randomDir(rand: () => number) {
  const u = rand() * 2 - 1;
  const theta = rand() * Math.PI * 2;
  const s = Math.sqrt(1 - u * u);
  return new THREE.Vector3(s * Math.cos(theta), u, s * Math.sin(theta));
}

const setup: SceneSetup<TinyPlanetTheme> = ({ scene, camera, theme }) => {
  const rand = seeded(5);
  camera.position.set(0, 1.4, 9.5);
  camera.lookAt(0, 0, 0);

  const flat = (color: string, extra: THREE.MeshStandardMaterialParameters = {}) =>
    new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.85, ...extra });
  const mats = {
    ground: flat(theme.ground),
    leaves: flat(theme.leaves),
    trunk: flat(theme.trunk),
    walls: flat(theme.walls),
    roof: flat(theme.roof),
    cloud: flat(theme.cloud, { roughness: 1 }),
    moon: flat(theme.moon),
    door: flat('#5a3e2b'),
    window: new THREE.MeshStandardMaterial({ color: '#ffd98a', emissive: '#ffb84d', emissiveIntensity: 0.9 }),
  };

  const hemi = new THREE.HemisphereLight('#dfe8ff', theme.skyBottom, 1.3);
  const sun = new THREE.DirectionalLight('#fff1d6', 2.4);
  sun.position.set(5, 6, 4);
  const rim = new THREE.DirectionalLight('#9fb4ff', 0.9);
  rim.position.set(-4, 2, -5);
  scene.add(hemi, sun, rim);

  // Planet: an icosphere with gentle seeded bumps.
  const world = new THREE.Group();
  world.rotation.z = 0.25;
  scene.add(world);
  const planetGeo = new THREE.IcosahedronGeometry(RADIUS, 4);
  const pos = planetGeo.attributes.position;
  const v = new THREE.Vector3();
  const bumps = new Map<string, number>();
  for (let i = 0; i < pos.count; i++) {
    v.fromBufferAttribute(pos, i);
    const key = `${v.x.toFixed(3)},${v.y.toFixed(3)},${v.z.toFixed(3)}`; // shared vertices move together
    if (!bumps.has(key)) bumps.set(key, 1 + (rand() - 0.5) * 0.035);
    v.multiplyScalar(bumps.get(key)!);
    pos.setXYZ(i, v.x, v.y, v.z);
  }
  planetGeo.computeVertexNormals();
  world.add(new THREE.Mesh(planetGeo, mats.ground));

  // Cottages first, so trees can keep their distance.
  const taken: THREE.Vector3[] = [];
  const house = (dir: THREE.Vector3) => {
    const g = new THREE.Group();
    const walls = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.26, 0.3), mats.walls);
    walls.position.y = 0.13;
    const roof = new THREE.Mesh(new THREE.ConeGeometry(0.29, 0.2, 4), mats.roof);
    roof.position.y = 0.36;
    roof.rotation.y = Math.PI / 4;
    const door = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.12, 0.02), mats.door);
    door.position.set(0, 0.06, 0.155);
    const window = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.02), mats.window);
    window.position.set(0.09, 0.15, 0.155);
    g.add(walls, roof, door, window);
    g.rotateY(rand() * Math.PI * 2);
    taken.push(dir);
    world.add(plant(g, dir));
  };
  [new THREE.Vector3(0.3, 0.8, 0.5), new THREE.Vector3(-0.7, 0.2, 0.7), new THREE.Vector3(0.6, -0.3, -0.7)].forEach(d =>
    house(d.normalize()),
  );

  // Windmill with four blades.
  const blades = new THREE.Group();
  {
    const dir = new THREE.Vector3(-0.2, 0.7, -0.68).normalize();
    taken.push(dir);
    const g = new THREE.Group();
    const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.11, 0.7, 6), mats.walls);
    tower.position.y = 0.35;
    const cap = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.14, 6), mats.roof);
    cap.position.y = 0.76;
    blades.position.set(0, 0.68, 0.1);
    for (let i = 0; i < 4; i++) {
      const blade = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.36, 0.01), mats.walls);
      blade.position.y = 0.18;
      const arm = new THREE.Group();
      arm.rotation.z = (i * Math.PI) / 2;
      arm.add(blade);
      blades.add(arm);
    }
    g.add(tower, cap, blades);
    world.add(plant(g, dir));
  }

  // Trees: a mix of cones and round tops, kept away from buildings.
  let planted = 0;
  while (planted < 22) {
    const dir = randomDir(rand);
    if (taken.some(t => t.angleTo(dir) < 0.32)) continue;
    taken.push(dir);
    planted++;
    const g = new THREE.Group();
    const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.05, 0.2, 5), mats.trunk);
    trunk.position.y = 0.1;
    const round = rand() > 0.5;
    const top = round
      ? new THREE.Mesh(new THREE.IcosahedronGeometry(0.17, 0), mats.leaves)
      : new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.4, 6), mats.leaves);
    top.position.y = round ? 0.32 : 0.38;
    g.add(trunk, top);
    g.scale.setScalar(0.8 + rand() * 0.5);
    world.add(plant(g, dir));
  }

  // Clouds on tilted orbits, each a cluster of puffs.
  const orbits: { pivot: THREE.Object3D; speed: number }[] = [];
  for (let i = 0; i < 5; i++) {
    const pivot = new THREE.Object3D();
    pivot.rotation.set((rand() - 0.5) * 1.6, rand() * Math.PI * 2, (rand() - 0.5) * 1.2);
    const cloud = new THREE.Group();
    const puffs = 3 + Math.floor(rand() * 2);
    for (let p = 0; p < puffs; p++) {
      const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(0.13 + rand() * 0.08, 1), mats.cloud);
      puff.position.set((p - puffs / 2) * 0.16, rand() * 0.06, (rand() - 0.5) * 0.08);
      cloud.add(puff);
    }
    cloud.position.set(0, 0, RADIUS + 0.75);
    pivot.add(cloud);
    scene.add(pivot);
    orbits.push({ pivot, speed: 0.12 + rand() * 0.12 });
  }

  // Moon.
  const moonPivot = new THREE.Object3D();
  moonPivot.rotation.x = 0.35;
  const moon = new THREE.Mesh(new THREE.IcosahedronGeometry(0.28, 1), mats.moon);
  moon.position.set(3.4, 0.4, 0);
  moonPivot.add(moon);
  scene.add(moonPivot);

  // Stars on a distant shell.
  const starPositions = new Float32Array(300 * 3);
  for (let i = 0; i < 300; i++) {
    const d = randomDir(rand).multiplyScalar(40 + rand() * 20);
    starPositions.set([d.x, d.y, d.z], i * 3);
  }
  const starGeo = new THREE.BufferGeometry();
  starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
  const starMat = new THREE.PointsMaterial({ color: theme.stars, size: 0.18, sizeAttenuation: true, transparent: true, opacity: 0.85 });
  const stars = new THREE.Points(starGeo, starMat);
  scene.add(stars);

  return {
    update(time) {
      world.rotation.y = time * 0.12;
      world.position.y = Math.sin(time * 0.6) * 0.05;
      blades.rotation.z = time * 1.8;
      orbits.forEach((o, i) => o.pivot.rotation.set(o.pivot.rotation.x, i * 1.3 + time * o.speed, o.pivot.rotation.z));
      moonPivot.rotation.y = time * 0.08;
      moon.rotation.y = time * 0.2;
      stars.rotation.y = time * 0.004;
    },
    setTheme(t) {
      mats.ground.color.set(t.ground);
      mats.leaves.color.set(t.leaves);
      mats.trunk.color.set(t.trunk);
      mats.walls.color.set(t.walls);
      mats.roof.color.set(t.roof);
      mats.cloud.color.set(t.cloud);
      mats.moon.color.set(t.moon);
      starMat.color.set(t.stars);
      hemi.groundColor.set(t.skyBottom);
    },
  };
};

/** A small low-poly planet turning slowly, with trees, cottages, a windmill and orbiting clouds. */
export function TinyPlanet3D(props: ThreeSceneProps<TinyPlanetTheme>) {
  return (
    <ThreeFrame
      {...props}
      defaultTheme={tinyPlanetTheme}
      setup={setup}
      background={t => `radial-gradient(circle at 50% 45%, ${t.skyBottom}, ${t.skyTop} 75%)`}
    />
  );
}
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>;

/** 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;

    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;
}