Examples

Light in the air

A sunbeam through a window, fire and smoke, and a medium that fills the room.

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

Open on its own · Source

Light in the air is the chapter that walks through it.

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

Source

main.ts

examples/air/main.ts
/**
 * A dark room with one window, a shaft of sun with dust in it, and a brazier burning in the corner.
 *
 * Three ways light shows in the air: a volume of light drawn inside a hull, which the window's
 * shadow cuts to the window's shape; plumes of fire and smoke; and, when switched on in the strip,
 * a medium filling the whole room, which carries the sun from the window across everything. The
 * medium is a DriftScript module, `air.drs`, which fills and clears the room when the switch moves.
 */
import {
  MeshBuilder,
  SceneNode,
  buildLightVolume,
  computeLightMatrix,
  createEnvironment,
  createPointLightBuffer,
  selectPointLights,
} from '@driftengine/core';
import type { PointLightSource, RendererApi, ShadowCasters, Vec3 } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as airScript from './air.drs';

const stage = await openStage({
  directionalShadows: true,
  /* The ceiling's shadow falls seven metres from it; the default of six would dissolve it. */
  directionalShadowMaxDistance: 16,
  outputTransform: 'aces',
  outputExposure: 1.2,
  sceneSamples: 4,
  /* The march's ceiling. Nothing runs while the air's density is 0. */
  globalMediumSteps: 48,
});
const { renderer, camera } = stage;

// #region script
/** The air, hosted: its record lives here, so an edited script carries on from it. */
const air = hostScript(airScript);
interface Air {
  wanted: number;
}
const airState = exported<() => Air>(air, 'createAir')();
type Breathe = (air: Air, renderer: RendererApi, dt: number) => void;
if (import.meta.hot) {
  import.meta.hot.accept('./air.drs', (next) => {
    if (next !== undefined) patchModule(air, next as Record<string, unknown>, { Air: [airState] });
  });
}

controls([
  {
    key: 'medium',
    label: 'global medium',
    value: flag('medium', 'off'),
    options: [
      { text: 'off', value: 'off' },
      { text: 'on', value: 'on' },
    ],
    change: (value) => {
      airState.wanted = value === 'on' ? 1 : 0;
    },
  },
]);
airState.wanted = flag('medium', 'off') === 'on' ? 1 : 0;
// #endregion

/** Where the sun is, pointing at it: outside the east wall, well above the window. */
const SUN: Vec3 = [0.8, 0.5, 0.33];
const env = createEnvironment({
  directionalDir: SUN,
  directionalColor: [3.2, 2.8, 2.2],
  ambient: [0.015, 0.016, 0.02],
  ambientGround: [0.01, 0.009, 0.008],
  nightFactor: 1,
  emissiveGain: 1.5,
});

/** The room: floor, ceiling, three plain walls, and an east wall built around a window. */
const room = new MeshBuilder();
const PLASTER: Vec3 = [0.6, 0.56, 0.5];
room.addBox([0, -0.1, 0], [6, 0.1, 6], [0.45, 0.38, 0.3]);
room.addBox([0, 4.1, 0], [6, 0.1, 6], PLASTER);
room.addBox([-6.1, 2, 0], [0.1, 2, 6], PLASTER);
room.addBox([0, 2, -6.1], [6, 2, 0.1], PLASTER);
room.addBox([0, 2, 6.1], [6, 2, 0.1], PLASTER);
room.addBox([6.1, 0.75, 0], [0.1, 0.75, 6], PLASTER);
room.addBox([6.1, 3.65, 0], [0.1, 0.35, 6], PLASTER);
room.addBox([6.1, 2.3, -3.85], [0.1, 1, 2.15], PLASTER);
room.addBox([6.1, 2.3, 3.85], [0.1, 1, 2.15], PLASTER);
room.addBox([6.1, 2.3, 0], [0.1, 1, 0.06], [0.2, 0.18, 0.16]);
room.addBox([-1, 0.35, 4.5], [0.45, 0.35, 0.45], [0.18, 0.17, 0.16]);
room.addBox([-1, 0.75, 4.5], [0.35, 0.05, 0.35], [1, 0.5, 0.15], 1);
const roomMesh = renderer.createMesh(room.build());
const fixed = new SceneNode();
fixed.updateWorld();

const casters: ShadowCasters = (sink) => sink.mesh(roomMesh, fixed.worldMatrix);
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 1;

// #region shaft
/**
 * A shaft through a window is a slice of a very wide cone whose apex is far out toward the sun.
 * The hull is round and wider than the window; the window's own shadow cuts it square.
 */
const REACH = 30;
const shaftHull = buildLightVolume({
  nearM: REACH,
  lengthM: REACH + 10,
  spread: 2.2 / REACH,
  color: [1, 0.86, 0.62],
});
const shaft = renderer.createMesh(shaftHull);

/** The hull opens along its own +Z, so its matrix turns +Z to the way sunlight travels. */
function aimAlong(out: Float32Array, at: Vec3, travel: Vec3): void {
  const [zx, zy, zz] = travel;
  const side = Math.hypot(zz, zx);
  const xx = zz / side;
  const xz = -zx / side;
  out.set([xx, 0, xz, 0, zy * xz, zz * xx - zx * xz, -zy * xx, 0, zx, zy, zz, 0, ...at, 1]);
}
const length = Math.hypot(...SUN);
const travel: Vec3 = [-SUN[0] / length, -SUN[1] / length, -SUN[2] / length];
const shaftModel = new Float32Array(16);
aimAlong(
  shaftModel,
  [6.1 - travel[0] * REACH, 2.3 - travel[1] * REACH, -travel[2] * REACH],
  travel,
);
// #endregion

// #region plumes
/** Fire adds light; smoke covers it. One batch each, placed once. */
const fire = renderer.createPlumes([{ x: -1, y: 0.8, z: 4.5, width: 0.35, height: 1.1 }], {
  material: 'fire',
  blend: 'additive',
  windResponse: 0.1,
});
const smoke = renderer.createPlumes(
  [
    { x: -1, y: 1.6, z: 4.5, width: 0.5, height: 1.4 },
    { x: -0.9, y: 2.6, z: 4.4, width: 0.8, height: 1.4 },
  ],
  { material: 'smoke', blend: 'alpha', windResponse: 1, tint: [0.35, 0.33, 0.32] },
);
// #endregion

/** The brazier also lights the room, so it has a flickering lamp of its own. */
const glow: PointLightSource[] = [
  {
    x: -1,
    y: 1.2,
    z: 4.5,
    r: 2.4,
    g: 1.1,
    b: 0.35,
    radius: 7,
    flicker: 0.4,
    shadowNear: 0.3,
    sourceRadius: 0.15,
    castsShadow: false,
  },
];
const chosen = createPointLightBuffer(renderer.shadedLights);

let time = 0;
let breathedAt = 0;

stage.run({
  simulate(dt) {
    time += dt;
  },
  render() {
    camera.fovYDeg = 60;
    camera.position[0] = -4.5 + Math.sin(time * 0.1) * 0.8;
    camera.position[1] = 1.7;
    camera.position[2] = -4 + Math.cos(time * 0.13) * 0.6;
    camera.lookAt(1.5, 1.2, 2.2);

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

    selectPointLights(glow, camera.position[0], 1.7, 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;

    // #region medium
    /* The script eases the air toward what the switch asked for, and sets the medium. */
    exported<Breathe>(air, 'breathe')(airState, renderer, time - breathedAt);
    breathedAt = time;
    // #endregion

    renderer.beginFrame([0, 0, 0]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(roomMesh, fixed.worldMatrix);
    // #region draw
    renderer.drawLightVolume(shaft, shaftModel, camera, 0.8, REACH + 10, 2.2 / REACH, {
      nearM: REACH,
      dust: 0.35,
      dustScaleM: 0.8,
      driftM: [time * 0.05, 0, time * 0.02],
      sunShadow: 1,
      env,
    });
    renderer.drawPlumes(smoke, camera, time, env, 0.2, 0);
    renderer.drawPlumes(fire, camera, time, env, 0.2, 0);
    // #endregion
    renderer.endFrame();
  },
});

air.drs

examples/air/air.drs
// The air in the room: how much of it there is, and how it scatters the sun from the window.
// The page's switch says what it wants; this eases the room toward it, so the air fills and clears
// instead of appearing.
//
// Under `npm run examples`, change a number in `breathe` and save: the page keeps running with the
// new air. Try a density of 0.08 for smoke, or an albedo of 0.3 for soot that swallows the light.

import { medium } from "drift/render"
import { clamp } from "std/math"

data Air {
    // What the switch asked for: 1 for air, 0 for none.
    wanted: f32 = 0
    // How much of it the room holds now.
    present: f32 = 0
}

// #region breathe
fn breathe(air: mut Air, renderer: Renderer, dt: f32) {
    // Half of the change a second, either way.
    air.present = air.present + math.clamp(air.wanted - air.present, 0 - dt * 0.5, dt * 0.5)
    // Extinction per metre, how much of it comes back as light, how far forward it scatters, and
    // how far the march looks. A density of 0 is no air at all, and costs nothing.
    render.medium(renderer, 0.035 * air.present, 0.9, 0.6, 40)
}
// #endregion