Examples

DriftTexture

Materials as programs decoded per pixel, one animated by the simulation's clock. WebGPU.

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

Open on its own · Source

DriftTexture is the chapter that walks through it.

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

Source

main.ts

examples/drifttexture/main.ts
/**
 * Four materials as DriftTexture programs, decoded on the device for every shaded pixel: bricks and
 * a metal chequer encoded from channels written in code, a marble that is noise and a colour, and a
 * panel whose glow is animated by the simulation's clock.
 *
 * A DriftTexture is a small program over a latent image, not a picture: the same four-word
 * instructions run in the GPU-driven pass's shading and in `decodeCpu`, the reference on the CPU,
 * which the readout samples at the centre of the glowing panel. Hold the clock and the panel stops
 * while the camera carries on, because its time is an argument the page passes, never a wall clock.
 *
 * WebGPU only, as the GPU-driven pipeline is: on a browser without it, this page says so.
 */
import { buildClusters, encodeMaterial } from '@driftengine/assets';
import type { ChannelInput, EncodedMaterial } from '@driftengine/assets';
import {
  GpuDrivenPass,
  createEnvironment,
  programFromEncoded,
  solidBox,
  solidToMesh,
  streamingScene,
  transformSolid,
} from '@driftengine/core';
import type {
  GpuDrivenMaterial,
  GpuDrivenMesh,
  GpuDrivenProgram,
  GpuDrivenView,
  Vec3,
} from '@driftengine/core';
import type { MeshData } from '@driftengine/drft';
import {
  ADDRESS_MODE,
  DECODE_OP,
  addDecodeNode,
  createDecodeGraph,
  createDecodeRegisters,
  decodeCpu,
} from '@driftengine/texture';
import type { DecodeGraph } from '@driftengine/texture';
import { createReadout } from '../common/readout';
import { controls, flag, openStage } from '../common/stage';

const stage = await openStage(
  { outputTransform: 'aces', hdrScene: true, directionalShadows: false },
  { pipeline: 'gpu-driven' },
).catch((error: unknown) => {
  const readout = document.querySelector('#backend');
  if (readout !== null) readout.textContent = 'This example needs WebGPU, and this page has none.';
  console.info(error);
  return null;
});

// #region encode
/** Channels written in code, 64 texels square, then encoded jointly into one latent. */
const SIZE = 64;
function channel(
  semantic: ChannelInput['spec']['semantic'],
  component: number,
  value: (x: number, y: number) => number,
): ChannelInput {
  const data = new Float32Array(SIZE * SIZE);
  for (let y = 0; y < SIZE; y += 1)
    for (let x = 0; x < SIZE; x += 1) data[y * SIZE + x] = value(x, y);
  return { spec: { semantic, component }, data };
}

/** Tiling, full resolution, addressed at texel centres: what a surface texture wants. */
const encode = (channels: readonly ChannelInput[]): EncodedMaterial =>
  encodeMaterial(channels, SIZE, SIZE, { quality: 1, addressMode: ADDRESS_MODE.CENTRE_WRAP });

/** Courses of bricks: their colour, and a normal that turns at the mortar. */
const mortar = (x: number, y: number): boolean => y % 16 < 2 || (x + ((y >> 4) & 1) * 16) % 32 < 2;
const bevel = (offset: number): number => (offset === 2 ? 0.55 : offset === 31 ? -0.55 : 0);
const nx = (x: number, y: number): number => bevel((x + ((y >> 4) & 1) * 16) % 32);
const ny = (_x: number, y: number): number => bevel(y % 16 === 2 ? 2 : y % 16 === 15 ? 31 : 0);
const bricks = {
  baseColour: encode([
    channel('albedo-linear', 0, (x, y) => (mortar(x, y) ? 0.7 : 0.55)),
    channel('albedo-linear', 1, (x, y) => (mortar(x, y) ? 0.68 : 0.22)),
    channel('albedo-linear', 2, (x, y) => (mortar(x, y) ? 0.64 : 0.15)),
  ]),
  normal: encode([
    channel('normal-tangent-yup', 0, (x, y) => nx(x, y) * 0.5 + 0.5),
    channel('normal-tangent-yup', 1, (x, y) => ny(x, y) * 0.5 + 0.5),
    channel(
      'normal-tangent-yup',
      2,
      (x, y) => Math.sqrt(Math.max(0, 1 - nx(x, y) ** 2 - ny(x, y) ** 2)) * 0.5 + 0.5,
    ),
  ]),
};

/** A chequer of polished metal and rough stone: colour, and occlusion, roughness and metal. */
const tone = (x: number, y: number): number => ((x >> 3) + (y >> 3)) & 1;
const chequer = {
  baseColour: encode([
    channel('albedo-linear', 0, (x, y) => (tone(x, y) ? 0.85 : 0.25)),
    channel('albedo-linear', 1, (x, y) => (tone(x, y) ? 0.8 : 0.3)),
    channel('albedo-linear', 2, (x, y) => (tone(x, y) ? 0.7 : 0.35)),
  ]),
  orm: encode([
    channel('occlusion-linear', 0, (x, y) => (x % 8 === 0 || y % 8 === 0 ? 0.45 : 1)),
    channel('roughness-linear', 1, (x, y) => (tone(x, y) ? 0.25 : 0.85)),
    channel('metallic-linear', 2, (x, y) => (tone(x, y) ? 0.6 : 0)),
  ]),
};
// #endregion

// #region programs
/** A marble: five octaves of noise, with a warm white laid over it at half strength. */
const marbleGraph = createDecodeGraph(3);
addDecodeNode(marbleGraph, DECODE_OP.PROCEDURAL_FBM, 7, 5, 0);
addDecodeNode(marbleGraph, DECODE_OP.CONSTANT, 0, 0, 1);
addDecodeNode(marbleGraph, DECODE_OP.COMPOSITE, 1, 0, 2);
marbleGraph.result = 2;
marbleGraph.addressMode = ADDRESS_MODE.CENTRE_WRAP;
const marbleConstants = Float32Array.of(0.95, 0.9, 0.82, 0.55);

/** A glow that sweeps from amber to blue once a second of the time it is given. */
const glowGraph = createDecodeGraph(3);
addDecodeNode(glowGraph, DECODE_OP.CONSTANT, 0, 0, 0);
addDecodeNode(glowGraph, DECODE_OP.CONSTANT, 1, 0, 1);
addDecodeNode(glowGraph, DECODE_OP.LATENT_LERP, 0, 1, 2);
glowGraph.result = 2;
const glowConstants = Float32Array.of(1, 0.55, 0.15, 1, 0.2, 0.45, 1, 1);

/** A graph and its constants, as the device's program: no latent and no network to carry. */
const procedural = (graph: DecodeGraph, constants: Float32Array): GpuDrivenProgram => ({
  graph,
  latents: [],
  networks: [],
  constants,
});
// #endregion

if (stage !== null) {
  const { renderer, camera } = stage;

  // #region materials
  /** Material 0 is the ground; the rest wear DriftTexture programs. A cube's faces span 0 to 1, so
      a scale of three tiles the bricks three times across each face. */
  const materials: GpuDrivenMaterial[] = [
    { tint: [0.4, 0.42, 0.38], emissive: 0, roughness: 0.9 },
    {
      tint: [1, 1, 1],
      emissive: 0,
      roughness: 0.85,
      textures: {
        baseColour: programFromEncoded(bricks.baseColour),
        normal: programFromEncoded(bricks.normal),
        uScale: 3,
        vScale: 3,
      },
    },
    {
      tint: [1, 1, 1],
      emissive: 0,
      roughness: 1,
      textures: {
        baseColour: programFromEncoded(chequer.baseColour),
        orm: programFromEncoded(chequer.orm),
        uScale: 1,
        vScale: 1,
      },
    },
    {
      tint: [1, 1, 1],
      emissive: 0,
      roughness: 0.3,
      textures: { baseColour: procedural(marbleGraph, marbleConstants), uScale: 1, vScale: 1 },
    },
    {
      tint: [0.8, 0.8, 0.8],
      emissive: 1.2,
      roughness: 0.5,
      textures: { emissive: procedural(glowGraph, glowConstants) },
    },
  ];
  // #endregion

  /** A cube three metres a side for each textured material, in a row, on the ground. */
  const clustered = (mesh: MeshData, material: number): GpuDrivenMesh => {
    const set = buildClusters(mesh, 128);
    return {
      positions: mesh.positions,
      normals: mesh.normals,
      colours: mesh.colors,
      ...(mesh.uvs === undefined ? {} : { uvs: mesh.uvs }),
      clusters: {
        count: set.count,
        triangleOffsets: set.triangleOffsets,
        triangleCounts: set.triangleCounts,
        boundsCentre: set.boundsCentre,
        boundsRadius: set.boundsRadius,
        coneAxis: set.coneAxis,
        coneCutoff: set.coneCutoff,
        ownError: new Float32Array(set.count),
        parentError: new Float32Array(set.count).fill(Infinity),
        indices: set.indices,
      },
      material,
    };
  };
  const at = (x: number, y: number, z: number): number[] => [
    1,
    0,
    0,
    0,
    0,
    1,
    0,
    0,
    0,
    0,
    1,
    0,
    x,
    y,
    z,
    1,
  ];
  const meshes: GpuDrivenMesh[] = [
    clustered(solidToMesh(transformSolid(solidBox(40, 0.2, 40), at(0, -0.1, 0)), [1, 1, 1]), 0),
  ];
  for (let m = 1; m < materials.length; m += 1) {
    const cube = transformSolid(solidBox(3, 3, 3), at((m - 2.5) * 4.5, 1.5, 0));
    meshes.push(clustered(solidToMesh(cube, [1, 1, 1]), m));
  }
  const transforms = new Float32Array(meshes.length * 16);
  for (let i = 0; i < meshes.length; i += 1) transforms.set(at(0, 0, 0), i * 16);
  const pass = new GpuDrivenPass(streamingScene(meshes, transforms), materials);
  const handle = renderer.registerPass(pass);

  const env = createEnvironment({
    directionalDir: [0.45, 0.6, 0.55],
    directionalColor: [1.6, 1.5, 1.35],
    ambient: [0.3, 0.33, 0.4],
    ambientGround: [0.12, 0.11, 0.1],
  });
  const eye: Vec3 = [0, 0, 0];
  let time = 0;
  let decodeTime = 0;
  let held = flag('clock', 'running') === 'held';
  const view = {
    viewProj: camera.viewProjection,
    eye,
    lightDir: env.directionalDir,
    lightColour: env.directionalColor,
    ambient: env.ambient,
    ambientGround: env.ambientGround ?? env.ambient,
    lodThreshold: 1.5,
    fovY: (50 * Math.PI) / 180,
    time: 0,
  } satisfies GpuDrivenView;

  controls([
    {
      key: 'clock',
      label: 'texture clock',
      value: held ? 'held' : 'running',
      options: [
        { text: "the simulation's", value: 'running' },
        { text: 'held', value: 'held' },
      ],
      change: (value) => {
        held = value === 'held';
      },
    },
  ]);

  // #region reference
  /** The same glow, decoded on the CPU at the panel's centre with the time the device is given. */
  const registers = createDecodeRegisters();
  const sampled = new Float32Array(4);
  const glowResources = { latents: [], blocks: [], networks: [], constants: glowConstants };
  const readout = createReadout(renderer, 1);
  // #endregion

  stage.run({
    simulate(dt) {
      time += dt;
      if (!held) decodeTime += dt;
    },
    render() {
      camera.fovYDeg = 50;
      camera.far = 200;
      camera.position[0] = Math.sin(time * 0.15) * 7;
      camera.position[1] = 4;
      camera.position[2] = 14;
      camera.lookAt(0, 1.5, 0);
      eye[0] = camera.position[0];
      eye[1] = camera.position[1];
      eye[2] = camera.position[2];
      // #region time
      /* The decode reads this, never a clock of its own, so a held or replayed time decodes alike. */
      view.time = decodeTime;
      decodeCpu(glowGraph, glowResources, 0.5, 0.5, decodeTime, sampled, registers);
      // #endregion
      readout.set(
        0,
        `GLOW AT T ${decodeTime.toFixed(2)}  ${[0, 1, 2].map((c) => (sampled[c] ?? 0).toFixed(2)).join(' ')}`,
      );
      pass.resize(renderer.sceneWidth, renderer.sceneHeight);
      pass.setView(view);
      renderer.beginFrame([0.55, 0.6, 0.68]);
      renderer.bindMeshPass(camera, env);
      renderer.drawPass(handle);
      readout.draw(time);
      renderer.endFrame();
    },
  });
}