Examples

Writable textures

A wall a scripted turret scorches, with a clock that runs backwards and burns the marks again.

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

Open on its own · Source

Writable textures is the chapter that walks through it.

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

Source

main.ts

examples/scorch/main.ts
/**
 * A wall a turret scorches, three times a second, and a clock that can run backwards.
 *
 * The scorch marks are written into a sparse overlay on the wall's texture, and every write is
 * recorded in a journal beside the tick it happened on. Run the clock backwards and the wall is
 * rebuilt from the journal as it was at each earlier tick, so the marks vanish newest first; run it
 * forwards again and the journal is cut at the present, and the turret, whose aim is a function of
 * the tick, burns exactly the same marks again.
 *
 * When the turret fires and where it aims are rules in `turret.drs`.
 */
import {
  MeshBuilder,
  createEnvironment,
  hashToUnit,
  solidQuad,
  solidToMesh,
  transformSolid,
} from '@driftengine/core';
import type { Vec3 } from '@driftengine/core';
import {
  OVERLAY_CHANNELS,
  createOverlay,
  createOverlayJournal,
  journalLength,
  overlayTileCount,
  recordOverlayWrite,
  rewindOverlay,
  sampleOverlay,
  truncateJournalFrom,
  writeOverlay,
  writtenMaskAt,
} from '@driftengine/texture';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as turretScript from './turret.drs';

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

// #region overlay
/** The wall's texture, 256 texels square, as eight by eight tiles of 32 the overlay allocates. */
const RESOLUTION = 256;
const overlay = createOverlay(32, { tilesAcross: 8 });
const journal = createOverlayJournal();

/** One shot: a soot halo and a darker core, each written into the three colour channels. */
function scorch(tick: number, u: number, v: number, radius: number): void {
  const marks: [number, number][] = [
    [radius, 0.22],
    [radius * 0.5, 0.06],
  ];
  for (const [r, shade] of marks) {
    for (let channel = 0; channel < 3; channel += 1) {
      writeOverlay(overlay, u, v, r, channel, shade);
      recordOverlayWrite(journal, tick, u, v, r, channel, shade);
    }
  }
}
// #endregion

/** The stone the marks land on: courses of blocks, as linear RGB. */
const base = new Float32Array(RESOLUTION * RESOLUTION * OVERLAY_CHANNELS);
for (let y = 0; y < RESOLUTION; y += 1) {
  for (let x = 0; x < RESOLUTION; x += 1) {
    const row = Math.floor(y / 32);
    const joint = y % 32 < 2 || (x + (row % 2) * 32) % 64 < 2;
    const tone = joint
      ? 0.42
      : 0.62 + hashToUnit(Math.floor((x + (row % 2) * 32) / 64) * 31 + row) * 0.1;
    const at = (y * RESOLUTION + x) * OVERLAY_CHANNELS;
    base.set([tone, tone * 0.92, tone * 0.8, 1], at);
  }
}

// #region composite
/** The wall as the base with every written channel of every written texel laid over it. */
const pixels = new ImageData(RESOLUTION, RESOLUTION);
const sampled = new Float32Array(OVERLAY_CHANNELS);
function paint(): ImageData {
  for (let y = 0; y < RESOLUTION; y += 1) {
    for (let x = 0; x < RESOLUTION; x += 1) {
      const u = (x + 0.5) / RESOLUTION;
      const v = (y + 0.5) / RESOLUTION;
      const at = (y * RESOLUTION + x) * OVERLAY_CHANNELS;
      const mask = writtenMaskAt(overlay, u, v);
      if (mask !== 0) sampleOverlay(overlay, u, v, sampled);
      for (let c = 0; c < OVERLAY_CHANNELS; c += 1) {
        const value = (mask & (1 << c)) !== 0 ? (sampled[c] ?? 0) : (base[at + c] ?? 0);
        /* sRGB for the canvas, from the linear values the wall is authored in. */
        pixels.data[at + c] = c === 3 ? 255 : Math.round(Math.pow(value, 1 / 2.2) * 255);
      }
    }
  }
  return pixels;
}
// #endregion

const texture = renderer.createSurfaceTexture(paint(), { colorSpace: 'srgb' });
/** A wall eight metres square, its texture coordinates running 0 to 1 across it. */
const wall = renderer.createMesh(
  solidToMesh(
    transformSolid(solidQuad(8, 8), [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 4, 0, 1]),
    [1, 1, 1],
  ),
);
const ground = renderer.createMesh(
  new MeshBuilder().addBox([0, -0.1, 4], [12, 0.1, 10], [0.3, 0.33, 0.28]).build(),
);

// #region script
/** The turret's rules, hosted. They read nothing but the tick. */
const turret = hostScript(turretScript);
type ByTick<T> = (tick: number) => T;
if (import.meta.hot) {
  import.meta.hot.accept('./turret.drs', (next) => {
    if (next !== undefined) patchModule(turret, next as Record<string, unknown>, {});
  });
}
// #endregion

// #region clock
/** The simulation's tick, which the switch runs forwards or backwards. */
let tick = 0;
let backwards = flag('clock', 'forwards') === 'backwards';
let dirty = true;
controls([
  {
    key: 'clock',
    label: 'clock',
    value: backwards ? 'backwards' : 'forwards',
    options: ['forwards', 'backwards'].map((c) => ({ text: c, value: c })),
    change: (value) => {
      backwards = value === 'backwards';
      /* Going forwards from here: what the journal holds past now never happened. */
      if (!backwards) truncateJournalFrom(journal, tick + 1);
    },
  },
]);

function step(): void {
  if (backwards) {
    if (tick === 0) return;
    tick -= 1;
    /* A write cannot be undone, so the wall is rebuilt from the journal up to the earlier tick. */
    rewindOverlay(overlay, journal, tick);
    dirty = true;
    return;
  }
  tick += 1;
  if (!exported<ByTick<boolean>>(turret, 'fires')(tick)) return;
  scorch(
    tick,
    exported<ByTick<number>>(turret, 'aimU')(tick),
    exported<ByTick<number>>(turret, 'aimV')(tick),
    exported<ByTick<number>>(turret, 'size')(tick),
  );
  dirty = true;
}
// #endregion

const env = createEnvironment({
  directionalDir: [-0.3, 0.6, 0.75],
  directionalColor: [1.6, 1.5, 1.35],
  ambient: [0.34, 0.37, 0.44],
  ambientGround: [0.15, 0.14, 0.13],
});
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const SKY: Vec3 = [0.6, 0.66, 0.74];
const readout = createReadout(renderer, 2);
let time = 0;

stage.run({
  simulate(dt) {
    time += dt;
    step();
  },
  render() {
    if (dirty) {
      renderer.updateSurfaceTexture(texture, paint());
      dirty = false;
    }
    camera.fovYDeg = 50;
    camera.position[0] = Math.sin(time * 0.1) * 3;
    camera.position[1] = 4;
    camera.position[2] = 11;
    camera.lookAt(0, 4, 0);
    renderer.beginFrame(SKY);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(ground, IDENTITY);
    renderer.setSurfaceTexture(texture);
    renderer.drawMesh(wall, IDENTITY);
    renderer.setSurfaceTexture(null);
    readout.set(0, `TICK ${tick}  JOURNAL ${journalLength(journal)} WRITES`);
    readout.set(1, `${overlayTileCount(overlay)} OF 64 TILES ALLOCATED`);
    readout.draw(time);
    renderer.endFrame();
  },
});

turret.drs

examples/scorch/turret.drs
// The turret: when it fires, where on the wall it aims, and how big a mark each shot leaves. Every
// answer is a function of the tick alone, so running the same ticks again fires the same shots.
//
// Under `npm run examples`, change a rule and save: the shots from now on follow it, and the marks
// already on the wall stay as they were made. Try firing twice as often, or aiming at one corner.

import { range } from "drift/random"

// #region turret
// A shot every third of a second, at sixty ticks a second.
fn fires(tick: u32) -> bool {
    return tick % 20 == 0
}

// Where it lands, as a fraction of the wall across and up.
fn aimU(tick: u32) -> f32 {
    return random.range(tick, 0.08, 0.92)
}

fn aimV(tick: u32) -> f32 {
    return random.range(tick + 1, 0.1, 0.9)
}

// How wide the scorch is, as a fraction of the wall.
fn size(tick: u32) -> f32 {
    return random.range(tick + 2, 0.02, 0.05)
}
// #endregion