Examples

Sky and atmosphere

A day in ninety seconds: sun, moon, stars, clouds, and lamps that come on at dusk.

Starts examples/sky in this page, on WebGPU where your browser has it.

Open on its own · Source

Sky and atmosphere is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /sky/.

Source

main.ts

examples/sky/main.ts
/**
 * A day in a minute and a half over a ring of standing stones: the sun rising and setting where it
 * would at forty degrees north in June, clouds drifting, the moon and stars at night, and two lamps
 * that come on at dusk.
 *
 * The sky's position comes from the celestial clock, its colours from a daylight palette, and the
 * exposure eases toward what the palette says the eye would settle on.
 */
import {
  MeshBuilder,
  SceneNode,
  celestialStateAt,
  computeLightMatrix,
  createCelestialState,
  createDaylightPalette,
  createDaylightState,
  createEnvironment,
  createPointLightBuffer,
  easeExposure,
  moonIllumination,
  resolveDaylight,
  selectPointLights,
} from '@driftengine/core';
import type { PointLightSource, ShadowCasters, SkyColors } from '@driftengine/core';
import { flagNumber, openStage } from '../common/stage';

const stage = await openStage({ outputTransform: 'aces', directionalShadows: true });
const { renderer, camera } = stage;

// #region clock
/** A site names where the sky is: forty degrees north, on the meridian, keeping UTC. */
const SITE = { latitudeDeg: 40, longitudeDeg: 0, utcOffsetHours: 0 };
/** Midsummer, and a day that lasts ninety seconds, starting before sunrise or at `?hour=`. */
const MIDSUMMER = Date.UTC(2026, 5, 21);
const DAY_SECONDS = 90;
const START_HOUR = flagNumber('hour', 3.5);
/** Where the world's north points, as an angle in the xz plane from +x toward +z: here, -z. */
const NORTH = -Math.PI / 2;

const celestial = createCelestialState();

function hourAt(seconds: number): number {
  return (START_HOUR + (seconds / DAY_SECONDS) * 24) % 24;
}
// #endregion

// #region palette
/** The day's light, keyed by how much day there is: night, the warm edge of dusk, and noon. */
const palette = createDaylightPalette([
  {
    at: 0,
    sunColor: [0, 0, 0],
    moonColor: [0.16, 0.2, 0.3],
    skyTop: [0.004, 0.006, 0.016],
    skyHorizon: [0.02, 0.03, 0.06],
    skyDeep: [0.01, 0.012, 0.02],
    ambient: [0.02, 0.025, 0.04],
    ambientGround: [0.01, 0.01, 0.014],
    fogColor: [0.02, 0.025, 0.04],
    fogDensity: 0.01,
    shadowStrength: 0.5,
    emissiveGain: 2,
    exposure: 2.4,
  },
  {
    at: 0.35,
    sunColor: [1.6, 0.8, 0.4],
    moonColor: [0, 0, 0],
    skyTop: [0.12, 0.16, 0.32],
    skyHorizon: [0.9, 0.5, 0.3],
    skyDeep: [0.2, 0.15, 0.15],
    ambient: [0.18, 0.16, 0.2],
    ambientGround: [0.08, 0.06, 0.05],
    fogColor: [0.5, 0.36, 0.3],
    fogDensity: 0.008,
    shadowStrength: 0.7,
    emissiveGain: 1,
    exposure: 1.4,
  },
  {
    at: 1,
    sunColor: [2, 1.9, 1.7],
    moonColor: [0, 0, 0],
    skyTop: [0.18, 0.36, 0.78],
    skyHorizon: [0.62, 0.74, 0.9],
    skyDeep: [0.3, 0.34, 0.4],
    ambient: [0.3, 0.36, 0.48],
    ambientGround: [0.14, 0.12, 0.1],
    fogColor: [0.6, 0.7, 0.85],
    fogDensity: 0.005,
    shadowStrength: 0.9,
    emissiveGain: 0,
    exposure: 1,
  },
]);
const light = createDaylightState();
// #endregion

const env = createEnvironment({ fogHeightFalloff: 0.02 });
const sky: SkyColors = {
  top: [0, 0, 0],
  horizon: [0, 0, 0],
  deep: [0, 0, 0],
  sunDir: [0, 1, 0],
  sunColor: [1.8, 1.6, 1.3],
  sunAngularRadius: 0.02,
  moonDir: [0, -1, 0],
  moonColor: [0.9, 0.92, 1],
  moonAngularRadius: 0.03,
  moonPhase: 0,
  nightFactor: 0,
  cloudOffsetX: 0,
  cloudOffsetZ: 0,
};

/** The set: a low hill and a ring of stones, with two lamps at the gap. */
const set = new MeshBuilder();
set.addBox([0, -0.5, 0], [60, 0.5, 60], [0.32, 0.36, 0.22]);
for (let i = 0; i < 12; i += 1) {
  if (i === 0) continue;
  const a = (i / 12) * Math.PI * 2;
  const tall = 1.6 + ((i * 7) % 5) * 0.25;
  set.addBox([Math.cos(a) * 7, tall, Math.sin(a) * 7], [0.45, tall, 0.3], [0.55, 0.53, 0.5]);
}
const lamps: PointLightSource[] = [];
for (const side of [-1, 1]) {
  const x = 7;
  const z = side * 1.6;
  set.addCylinder([x, 1, z], 0.05, 1, 'y', [0.1, 0.1, 0.1]);
  set.addSphere([x, 2.1, z], 0.12, [1, 0.7, 0.4], 1);
  lamps.push({
    x,
    y: 2.1,
    z,
    r: 0,
    g: 0,
    b: 0,
    radius: 9,
    flicker: 0.15,
    shadowNear: 0.2,
    sourceRadius: 0.1,
    castsShadow: false,
  });
}
const setMesh = renderer.createMesh(set.build());
const still = new SceneNode();
still.updateWorld();
const casters: ShadowCasters = (sink) => sink.mesh(setMesh, still.worldMatrix);
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
const chosen = createPointLightBuffer(renderer.shadedLights);

let time = 0;
let exposure = 1;
let shownAt = 0;

stage.run({
  simulate(dt) {
    time += dt;
  },
  render() {
    // #region frame
    const hour = hourAt(time);
    celestialStateAt(MIDSUMMER + hour * 3_600_000, NORTH, celestial, SITE);
    resolveDaylight(celestial.dayFactor, palette, light);

    /* Whichever body is up lights the world; the sky draws both and hides what has set. */
    const body = celestial.dayFactor > 0.5 ? celestial.sunDir : celestial.moonDir;
    env.directionalDir = body;
    const moonlight = moonIllumination(celestial.moonPhase);
    for (let c = 0; c < 3; c += 1)
      env.directionalColor[c] = light.sunColor[c] + light.moonColor[c] * moonlight;
    env.ambient = light.ambient;
    env.ambientGround = light.ambientGround;
    env.fogColor = light.fogColor;
    env.fogDensity = light.fogDensity;
    env.shadowStrength = light.shadowStrength;
    env.emissiveGain = light.emissiveGain;
    env.nightFactor = celestial.nightFactor;

    sky.top = light.skyTop;
    sky.horizon = light.skyHorizon;
    sky.deep = light.skyDeep;
    sky.sunDir = celestial.sunDir;
    sky.moonDir = celestial.moonDir;
    sky.moonPhase = celestial.moonPhase;
    sky.nightFactor = celestial.nightFactor;
    sky.cloudOffsetX = time * 1.5;
    sky.cloudOffsetZ = time * 0.6;

    exposure = easeExposure(exposure, light.exposure, time - shownAt, 1.5);
    renderer.setOutputExposure(exposure);
    shownAt = time;
    // #endregion

    /* The lamps follow the night on a steeper curve, so they are off all afternoon. */
    const lit = Math.min(1, celestial.nightFactor * celestial.nightFactor * 1.4);
    for (const lamp of lamps) {
      lamp.r = 3 * lit;
      lamp.g = 1.9 * lit;
      lamp.b = 0.9 * lit;
    }

    camera.fovYDeg = 55;
    camera.far = 600;
    camera.position[0] = -13;
    camera.position[1] = 2.2;
    camera.position[2] = 4;
    camera.lookAt(4, 5.2, -2);

    selectPointLights(lamps, camera.position[0], 2, camera.position[2], chosen, time);
    env.lightCount = chosen.count;
    env.lightPositions = chosen.positions;
    env.lightColors = chosen.colors;
    env.lightRadii = chosen.radii;
    env.lightSourceRadii = chosen.sourceRadii;
    env.lightWeights = chosen.weights;

    env.shadowDepthSpan = computeLightMatrix(
      body,
      0,
      1,
      0,
      14,
      renderer.shadowMapSize,
      lightMatrix,
    );
    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters(casters);
    renderer.endShadowPass();

    // #region draw
    renderer.beginFrame(sky.horizon);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(setMesh, still.worldMatrix);
    /* Last, so it fills only what the world left empty. */
    renderer.drawSky(camera, sky, env);
    renderer.endFrame();
    // #endregion
  },
});