Earth

A procedural planet with GPU-baked albedo, night lights and clouds, a lit atmosphere, and an HDR bloom chain tuned so only the sun glows.

Open fullscreen
import type { Draw, Effect, Frame, Gpu, GeometryLike, Surface, Target } from 'vgpu';
import { perspectiveCamera, sphere } from 'vgpu/scene';
 
import type { BrowserRendererOptions, ExampleRenderer, RenderSize, ThumbnailOptions } from '../../lib/example-renderer';
import { EARTH_TUNING, bloomSize, cameraBasis, normalizeSize, orbitPosition, sunDegreesAt, sunDirection, type OrbitState } from './planet';
import { DEFAULT_EARTH_CONTROLS, type EarthControls } from './types';
 
import atmosphereWgsl from './atmosphere.wgsl';
import bakeCloudsWgsl from './bake-clouds.wgsl';
import bakeSurfaceWgsl from './bake-surface.wgsl';
import blurWgsl from './blur.wgsl';
import brightPassWgsl from './bright-pass.wgsl';
import compositeWgsl from './composite.wgsl';
import earthWgsl from './earth.wgsl';
import overlayWgsl from './overlay.wgsl';
import skyWgsl from './sky.wgsl';
import { clock, draw, effect, frame, frameLoop, geometry, sampler, surface, target } from "vgpu";
 
type Output = Surface | Target;
 
type ThumbOptions = ThumbnailOptions;
 
interface Maps {
  /** Equirectangular day albedo in rgb, night-light mask in alpha. */
  readonly surface: Target;
  /** Single-channel cloud coverage. */
  readonly clouds: Target;
}
 
interface Scene {
  readonly earthGeometry: GeometryLike;
  readonly atmosphereGeometry: GeometryLike;
  readonly earth: Draw;
  readonly atmosphere: Draw;
  readonly sky: Effect;
  readonly overlay: Effect;
  readonly bright: Effect;
  readonly blur: readonly [Effect, Effect, Effect, Effect];
  readonly composite: Effect;
  readonly mapSampler: GPUSampler;
  readonly linearSampler: GPUSampler;
}
 
interface Targets {
  readonly beauty: Target;
  readonly planet: Target;
  readonly bloomA: Target;
  readonly bloomB: Target;
}
 
const HDR_FORMAT: GPUTextureFormat = 'rgba16float';
/** The planet is clamped to 0.7 by its own shader, so 8-bit sRGB is plenty and keeps MSAA available. */
const PLANET_FORMAT: GPUTextureFormat = 'rgba8unorm-srgb';
const OPAQUE_BLACK = [0, 0, 0, 1] as const;
const TRANSPARENT = [0, 0, 0, 0] as const;
 
export function createRenderer(options: BrowserRendererOptions<EarthControls>): ExampleRenderer<EarthControls> {
  let disposed = false;
  let reportedError = false;
  let controls = { ...(options.initialControls ?? DEFAULT_EARTH_CONTROLS) };
  let sunDegrees = controls.sunDegrees;
  let gpu: Gpu | undefined;
  let canvasSurface: Surface | undefined;
  let maps: Maps | undefined;
  let scene: Scene | undefined;
  let targets: Targets | undefined;
  let orbit: ReturnType<typeof installOrbitInput> | undefined;
  let loop: { stop(): void } | undefined;
  let observer: ResizeObserver | undefined;
  let resizeFrame = 0;
  let pendingSize: RenderSize | undefined;
  let lastDpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio;
 
  const applyResize = () => {
    resizeFrame = 0;
    const size = pendingSize;
    pendingSize = undefined;
    if (disposed || !size || !scene || !targets) return;
    try {
      resizeTargets(targets, [
        Math.max(1, Math.round(size.width * size.dpr)),
        Math.max(1, Math.round(size.height * size.dpr)),
      ]);
      setSizeBindings(scene, targets);
    } catch (error) {
      fail(error);
    }
  };
  const resize = (size: RenderSize) => {
    if (disposed || size.width <= 0 || size.height <= 0) return;
    pendingSize = size;
    if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
  };
  const measure = () => {
    const rect = options.canvas.getBoundingClientRect();
    resize({ width: rect.width, height: rect.height, dpr: Math.min(2, Math.max(1, window.devicePixelRatio || 1)) });
  };
  const onWindowResize = () => {
    if (window.devicePixelRatio === lastDpr) return;
    lastDpr = window.devicePixelRatio;
    measure();
  };
  const setControls = (next: Readonly<EarthControls>) => {
    const valid = {
      sunDegrees: Math.max(0, Math.min(360, Number(next.sunDegrees) || 0)),
      autoRotate: Boolean(next.autoRotate),
    };
    if (!valid.autoRotate || !controls.autoRotate) sunDegrees = valid.sunDegrees;
    controls = valid;
  };
  const dispose = () => {
    if (disposed) return;
    disposed = true;
    loop?.stop();
    loop = undefined;
    if (resizeFrame) cancelAnimationFrame(resizeFrame);
    resizeFrame = 0;
    pendingSize = undefined;
    observer?.disconnect();
    observer = undefined;
    if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize);
    orbit?.dispose();
    orbit = undefined;
    if (scene) destroyScene(scene);
    scene = undefined;
    if (targets) destroyTargets(targets);
    targets = undefined;
    if (maps) destroyMaps(maps);
    maps = undefined;
    canvasSurface?.dispose();
    canvasSurface = undefined;
    gpu?.dispose();
    gpu = undefined;
  };
  const fail = (error: unknown) => {
    if (disposed) return;
    if (!reportedError) {
      reportedError = true;
      try { options.onError?.(error); } catch { /* error reporting must not block teardown */ }
    }
    dispose();
  };
  const initialize = async () => {
    const { init } = await import('vgpu');
    if (disposed) return;
    const nextGpu = await init();
    if (disposed) { nextGpu.dispose(); return; }
    gpu = nextGpu;
    canvasSurface = surface(gpu, options.canvas, { dpr: [1, 2] });
    maps = createMaps(gpu, 'earth-live');
    scene = createScene(gpu, maps, 'earth-live');
    targets = createTargets(gpu, canvasSurface.size, 'earth-live');
    setStaticBindings(scene, maps, targets);
    await Promise.all([bakeMaps(gpu, maps), prewarm(scene, targets, canvasSurface)]);
    if (disposed) return;
    orbit = installOrbitInput(options.canvas, {
      yaw: 0,
      pitch: EARTH_TUNING.poster.pitch,
      radius: EARTH_TUNING.camera.radius,
    });
    observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
    observer?.observe(options.canvas);
    window.addEventListener('resize', onWindowResize);
    measure();
    const time = clock(gpu);
    loop = frameLoop(gpu, (currentFrame) => {
      if (disposed || !gpu || !canvasSurface || !scene || !targets || !orbit) return;
      const deltaTime = Math.min(0.05, time.deltaTime);
      if (controls.autoRotate) sunDegrees = (sunDegrees + deltaTime * EARTH_TUNING.sun.degreesPerSecond) % 360;
      setFrameUniforms(scene, canvasSurface, orbit.step(deltaTime), sunDegrees, time.time);
      render(currentFrame, scene, targets, canvasSurface);
    });
  };
  const ready = initialize().catch((error: unknown) => {
    if (disposed) return;
    fail(error);
    throw error;
  });
 
  return { ready, setControls, invalidate() {}, resize, dispose };
}
 
export async function renderThumbnail(gpu: Gpu, output: Target, opts: ThumbOptions = {}): Promise<void> {
  const maps = createMaps(gpu, 'earth-thumb');
  const scene = createScene(gpu, maps, 'earth-thumb');
  const targets = createTargets(gpu, output.size, 'earth-thumb');
  setStaticBindings(scene, maps, targets);
  await Promise.all([bakeMaps(gpu, maps), prewarm(scene, targets, output)]);
 
  // The poster framing is fixed; only `time` moves, and the sun angle is derived
  // from it so a given `thumb.time` always produces the same terminator.
  const { yaw, pitch, radius, sunDegrees } = EARTH_TUNING.poster;
  const dt = opts.dt ?? 1 / 60;
  let time = opts.time ?? 0;
  for (let i = 0; i < Math.max(1, opts.warmupFrames ?? 1); i++) {
    setFrameUniforms(scene, output, { yaw, pitch, radius }, sunDegrees + sunDegreesAt(time), time);
    frame(gpu, (currentFrame) => render(currentFrame, scene, targets, output));
    time += dt;
  }
 
  await gpu.gpu.queue.onSubmittedWorkDone();
  await gpu.settled();
  destroyScene(scene);
  destroyTargets(targets);
  destroyMaps(maps);
}
 
function createMaps(gpu: Gpu, label: string): Maps {
  const size = EARTH_TUNING.maps.size;
  return {
    // `-srgb` stores the albedo with a transfer curve (precision where the oceans
    // are dark) and hands linear values back to `textureSample`; alpha stays linear.
    surface: target(gpu, { size, format: PLANET_FORMAT, label: `${label}-surface-map` }),
    clouds: target(gpu, { size, format: 'r8unorm', label: `${label}-cloud-map` }),
  };
}
 
function createScene(gpu: Gpu, maps: Maps, label: string): Scene {
  const earthGeometry = geometry(gpu, sphere(EARTH_TUNING.planet));
  const atmosphereGeometry = geometry(gpu, sphere(EARTH_TUNING.atmosphere));
  return {
    earthGeometry,
    atmosphereGeometry,
    earth: draw(gpu, { shader: earthWgsl, geometry: earthGeometry, label: `${label}-earth` }),
    // `alpha` blending over the transparent clear is what turns the shell's
    // fresnel alpha into the rim glow, and leaves coverage in the target's alpha.
    atmosphere: draw(gpu, { shader: atmosphereWgsl, geometry: atmosphereGeometry, blend: 'alpha', label: `${label}-atmosphere` }),
    sky: effect(gpu, skyWgsl, { label: `${label}-sky` }),
    overlay: effect(gpu, overlayWgsl, { blend: 'premultiplied', label: `${label}-overlay` }),
    bright: effect(gpu, brightPassWgsl, { label: `${label}-bright` }),
    // One effect per blur pass: sharing a single effect would make every encoded
    // pass observe the last direction and radius written to its uniform buffer.
    blur: [
      effect(gpu, blurWgsl, { label: `${label}-blur-h1` }),
      effect(gpu, blurWgsl, { label: `${label}-blur-v1` }),
      effect(gpu, blurWgsl, { label: `${label}-blur-h2` }),
      effect(gpu, blurWgsl, { label: `${label}-blur-v2` }),
    ],
    composite: effect(gpu, compositeWgsl, { label: `${label}-composite` }),
    mapSampler: sampler(gpu, {
      minFilter: 'linear',
      magFilter: 'linear',
      // Longitude wraps, latitude does not.
      addressModeU: 'repeat',
      addressModeV: 'clamp-to-edge',
    }),
    linearSampler: sampler(gpu, { minFilter: 'linear', magFilter: 'linear' }),
  };
}
 
function createTargets(gpu: Gpu, size: readonly [number, number], label: string): Targets {
  const full = normalizeSize(size);
  const bloom = bloomSize(full);
  return {
    beauty: target(gpu, { size: full, format: HDR_FORMAT, label: `${label}-beauty` }),
    planet: target(gpu, { size: full, format: PLANET_FORMAT, msaa: true, depth: true, label: `${label}-planet` }),
    bloomA: target(gpu, { size: bloom, format: HDR_FORMAT, label: `${label}-bloom-a` }),
    bloomB: target(gpu, { size: bloom, format: HDR_FORMAT, label: `${label}-bloom-b` }),
  };
}
 
function setStaticBindings(scene: Scene, maps: Maps, targets: Targets): void {
  const { bloom, grade } = EARTH_TUNING;
  scene.earth.set({ surfaceMap: maps.surface, cloudMap: maps.clouds, mapSampler: scene.mapSampler });
  scene.overlay.set({ planetTexture: targets.planet, samp: scene.linearSampler });
  scene.bright.set({ samp: scene.linearSampler, bright: { threshold: bloom.threshold, knee: bloom.knee } });
  const directions = [
    [1, 0],
    [0, 1],
    [1, 0],
    [0, 1],
  ] as const;
  scene.blur.forEach((pass, index) => {
    pass.set({
      samp: scene.linearSampler,
      blur: { direction: directions[index]!, radius: bloom.radii[index < 2 ? 0 : 1] },
    });
  });
  scene.composite.set({
    samp: scene.linearSampler,
    bloom: targets.bloomA,
    composite: {
      bloomStrength: bloom.strength,
      exposure: grade.exposure,
      vignetteStart: grade.vignetteStart,
      vignetteDarkness: grade.vignetteDarkness,
      grain: grade.grain,
    },
  });
  setSizeBindings(scene, targets);
}
 
function setSizeBindings(scene: Scene, targets: Targets): void {
  scene.bright.set({ src: targets.beauty });
  scene.blur[0].set({ src: targets.bloomA, blur: { texelSize: targets.bloomA.texelSize } });
  scene.blur[1].set({ src: targets.bloomB, blur: { texelSize: targets.bloomB.texelSize } });
  scene.blur[2].set({ src: targets.bloomA, blur: { texelSize: targets.bloomA.texelSize } });
  scene.blur[3].set({ src: targets.bloomB, blur: { texelSize: targets.bloomB.texelSize } });
  scene.composite.set({ beauty: targets.beauty });
}
 
/**
 * One-shot: the maps never change, so this runs once before the first frame and is
 * never repeated. Both bakes go in a single submit.
 */
async function bakeMaps(gpu: Gpu, maps: Maps): Promise<void> {
  const canvasSurface = effect(gpu, bakeSurfaceWgsl, { label: 'earth-bake-surface' });
  const clouds = effect(gpu, bakeCloudsWgsl, { label: 'earth-bake-clouds' });
  await Promise.all([canvasSurface.compile(maps.surface), clouds.compile(maps.clouds)]);
  frame(gpu, (currentFrame) => {
    currentFrame.pass({ target: maps.surface, clear: TRANSPARENT }, (pass) => pass.draw(canvasSurface));
    currentFrame.pass({ target: maps.clouds, clear: TRANSPARENT }, (pass) => pass.draw(clouds));
  });
}
 
async function prewarm(scene: Scene, targets: Targets, output: Output): Promise<void> {
  await Promise.all([
    scene.sky.compile(targets.beauty),
    scene.earth.compile(targets.planet),
    scene.atmosphere.compile(targets.planet),
    scene.overlay.compile(targets.beauty),
    scene.bright.compile(targets.bloomA),
    scene.blur[0].compile(targets.bloomB),
    scene.blur[1].compile(targets.bloomA),
    scene.blur[2].compile(targets.bloomB),
    scene.blur[3].compile(targets.bloomA),
    scene.composite.compile({ colors: [output.format] }),
  ]);
}
 
function setFrameUniforms(
  scene: Scene,
  output: Output,
  orbit: OrbitState,
  sunDegrees: number,
  time: number,
): void {
  const { camera, atmosphere, sun, grade } = EARTH_TUNING;
  const size = output.size;
  const aspect = size[0] / Math.max(1, size[1]);
  const position = orbitPosition(orbit);
  const light = sunDirection(sunDegrees);
  const view = perspectiveCamera({
    fov: camera.fov,
    aspect,
    near: camera.near,
    far: camera.far,
    position,
    target: [0, 0, 0],
  });
  const basis = cameraBasis(position, [0, 0, 0], camera.fov);
 
  scene.earth.set({
    earth: {
      viewProjection: view.viewProjection,
      cameraPosition: position,
      time,
      lightDirection: light,
      nightLights: 1,
    },
  });
  scene.atmosphere.set({
    atmosphere: {
      viewProjection: view.viewProjection,
      cameraPosition: position,
      strength: atmosphere.strength,
      lightDirection: light,
      _pad: 0,
    },
  });
  scene.sky.set({
    sky: {
      right: basis.right,
      tanHalfFov: basis.tanHalfFov,
      up: basis.up,
      aspect,
      forward: basis.forward,
      starBrightness: grade.starBrightness,
      lightDirection: light,
      sunIntensity: sun.intensity,
    },
  });
}
 
function render(currentFrame: Frame, scene: Scene, targets: Targets, output: Output): void {
  // Sky first, then the planet into its own MSAA target, then lay it over the sky.
  currentFrame.pass({ target: targets.beauty, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.sky));
  currentFrame.pass({ target: targets.planet, clear: TRANSPARENT }, (pass) => {
    pass.draw(scene.earth);
    pass.draw(scene.atmosphere);
  });
  currentFrame.pass({ target: targets.beauty, clear: false }, (pass) => pass.draw(scene.overlay));
 
  currentFrame.pass({ target: targets.bloomA, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.bright));
  currentFrame.pass({ target: targets.bloomB, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.blur[0]));
  currentFrame.pass({ target: targets.bloomA, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.blur[1]));
  currentFrame.pass({ target: targets.bloomB, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.blur[2]));
  currentFrame.pass({ target: targets.bloomA, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.blur[3]));
 
  currentFrame.pass({ target: output, clear: OPAQUE_BLACK }, (pass) => pass.draw(scene.composite));
}
 
function resizeTargets(targets: Targets, size: readonly [number, number]): void {
  const full = normalizeSize(size);
  const bloom = bloomSize(full);
  targets.beauty.resize(full);
  targets.planet.resize(full);
  targets.bloomA.resize(bloom);
  targets.bloomB.resize(bloom);
}
 
function destroyScene(scene: Scene): void {
  (scene.earthGeometry as { destroy?: () => void }).destroy?.();
  (scene.atmosphereGeometry as { destroy?: () => void }).destroy?.();
}
 
function destroyTargets(targets: Targets): void {
  for (const colorTarget of [targets.beauty, targets.planet, targets.bloomA, targets.bloomB]) {
    (colorTarget as { destroy?: () => void }).destroy?.();
  }
}
 
function destroyMaps(maps: Maps): void {
  for (const colorTarget of [maps.surface, maps.clouds]) {
    (colorTarget as { destroy?: () => void }).destroy?.();
  }
}
 
 
function installOrbitInput(canvas: HTMLCanvasElement, initial: OrbitState) {
  let { yaw, pitch, radius } = initial;
  let targetYaw = yaw;
  let targetPitch = pitch;
  let targetRadius = radius;
  let dragging = false;
  let lastX = 0;
  let lastY = 0;
 
  const down = (event: PointerEvent) => {
    if (!event.isPrimary) return;
    dragging = true;
    lastX = event.clientX;
    lastY = event.clientY;
    canvas.setPointerCapture?.(event.pointerId);
  };
  const move = (event: PointerEvent) => {
    if (!dragging || !event.isPrimary) return;
    const rect = canvas.getBoundingClientRect();
    targetYaw -= ((event.clientX - lastX) / Math.max(1, rect.width)) * Math.PI * 2;
    targetPitch += ((event.clientY - lastY) / Math.max(1, rect.height)) * Math.PI;
    targetPitch = Math.max(-1.45, Math.min(1.45, targetPitch));
    lastX = event.clientX;
    lastY = event.clientY;
  };
  const up = (event: PointerEvent) => { if (event.isPrimary) dragging = false; };
  const wheel = (event: WheelEvent) => {
    event.preventDefault();
    const { minRadius, maxRadius } = EARTH_TUNING.camera;
    targetRadius = Math.max(minRadius, Math.min(maxRadius, targetRadius * Math.exp(event.deltaY * 0.0012)));
  };
  canvas.addEventListener('pointerdown', down);
  canvas.addEventListener('pointermove', move);
  canvas.addEventListener('pointerup', up);
  canvas.addEventListener('pointercancel', up);
  canvas.addEventListener('wheel', wheel, { passive: false });
 
  return {
    step(deltaTime: number): OrbitState {
      const blend = 1 - Math.exp(-deltaTime * 9);
      yaw += (targetYaw - yaw) * blend;
      pitch += (targetPitch - pitch) * blend;
      radius += (targetRadius - radius) * blend;
      return { yaw, pitch, radius };
    },
    dispose() {
      canvas.removeEventListener('pointerdown', down);
      canvas.removeEventListener('pointermove', move);
      canvas.removeEventListener('pointerup', up);
      canvas.removeEventListener('pointercancel', up);
      canvas.removeEventListener('wheel', wheel);
    },
  };
}