Raymarch

Five balls merge into a cube in a single raymarching pass. A 'use gpu' function supplies a distance field built with @typegpu/sdf. Rays come from the orbit camera, and @typegpu/color supplies Oklab colours. The final pass applies post-processing, and FXAA smooths edges.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. raymarch.tsRaymarches a distance field with one ray per pixel, sunlight, soft shadows, sky light and fog.

Kit modules cameras, finish, fxaa, owner, pipelines, targets, tone. Also @typegpu/color, @typegpu/sdf, typegpu.

scene.ts

import {  cameras,  finish,  fxaa,  owner,  pipelines,  targets,  tone,  type Frame,  type Surface,} from '@typegpu/kit';import { oklabToLinearRgb } from '@typegpu/color';import { opSmoothUnion, sdPlane, sdRoundedBox3d, sdSphere } from '@typegpu/sdf';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { createRaymarch, UNSHADED } from './raymarch.ts';const HDR = 'rgba16float';/** Display-space target for FXAA after the final pass. */const LDR = 'rgba8unorm';const TAU = Math.PI * 2;/** Cube centre height, half-size and edge radius in world units. */const CUBE = { height: 0.7, half: 0.42, rounding: 0.08 };const BALLS = 5;/** World units from a ball's centre to its surface. */const BALL = 0.3;/** The balls' orbit: its centre, and radius, radial swing and vertical bob in world units. */const ORBIT = { middle: d.vec3f(0, 0.75, 0), out: 0.85, swing: 0.3, bob: 0.4 };/** * Ball `k` circles at `first + k × faster` radians per unit turned, and swings out and bobs `swing` * and `bob` times as fast as it circles. */const PACE = { first: 0.7, faster: 0.13, swing: 0.9, bob: 1.6 };/** Bounding radius around the orbit's centre, including the cube. */const REACH = Math.hypot(ORBIT.out + ORBIT.swing, ORBIT.bob) + BALL;/** Floor checker frequency per world unit and two grey levels. */const FLOOR = { squares: 2, dark: 0.16, light: 0.22 };/** Oklab lightness, chroma and hue rotation in radians per world unit along x, y and z. */const BLOB = { lightness: 0.66, chroma: 0.16, hues: d.vec3f(1.4, 1.8, 0.9) };/** Balls melting into a cube, a distance field marched once a pixel and smoothed with FXAA. */export async function createScene(root: TgpuRoot, surface: Surface) {  const own = owner();  const screen = own(    targets.create(root, surface, {      hdr: { format: HDR, sampled: true },      ldr: { format: LDR, sampled: true },    }),  );  const view = cameras.orbit(root, {    size: surface,    target: [0, CUBE.height, 0],    distance: 5,    pitch: 0.35,    pitchRange: [0.05, 1.4],    distanceRange: [2, 12],  });  const values = {    /** Smooth-union blend distance. */    blend: 0.35,    /** Turns a second of the balls' orbits. */    speed: 0.6,    /** Smooths the stair-steps along edges. */    fxaa: true,  };  const blend = root.createUniform(d.f32, values.blend);  /** Ball centres, placed once a frame rather than at every ray step. */  const balls = root.createUniform(d.arrayOf(d.vec4f, BALLS));  const centres = new Float32Array(BALLS * 4);  let turned = 0;  /**   * Spaces balls evenly round the orbit's centre, with increasing orbital speed, radial swing and   * vertical motion.   */  const place = () => {    for (let k = 0; k < BALLS; k++) {      const angle = turned * (PACE.first + k * PACE.faster) + (k * TAU) / BALLS;      const out = ORBIT.out + Math.sin(angle * PACE.swing + k) * ORBIT.swing;      centres[k * 4] = ORBIT.middle.x + Math.cos(angle) * out;      centres[k * 4 + 1] = ORBIT.middle.y + Math.sin(angle * PACE.bob) * ORBIT.bob;      centres[k * 4 + 2] = ORBIT.middle.z + Math.sin(angle) * out;    }    balls.write(centres);  };  place();  const blobs = (point: d.v3f) => {    'use gpu';    // Each smooth union expands the bound by at most a quarter of the blend. Beyond the shadow    // range, use distance to the bounding sphere instead of evaluating every blob    const outside = std.length(point - ORBIT.middle) - REACH - blend.$ * (BALLS / 4);    if (outside > UNSHADED) return outside;    let shape = sdRoundedBox3d(      point - d.vec3f(0, CUBE.height, 0),      d.vec3f(CUBE.half),      CUBE.rounding,    );    for (const k of tgpu.unroll(std.range(BALLS))) {      shape = opSmoothUnion(shape, sdSphere(point - balls.$[k]!.xyz, BALL), blend.$);    }    return shape;  };  const field = (point: d.v3f) => {    'use gpu';    return std.min(blobs(point), sdPlane(point, d.vec3f(0, 1, 0), 0));  };  const paint = (point: d.v3f) => {    'use gpu';    if (point.y < blobs(point)) {      const cell = std.floor(point.xz * FLOOR.squares);      const checker = std.fract((cell.x + cell.y) * 0.5) * 2;      return d.vec3f(std.mix(FLOOR.dark, FLOOR.light, checker));    }    const hue = std.dot(point, BLOB.hues);    const chroma = d.vec2f(std.cos(hue), std.sin(hue)) * BLOB.chroma;    return oklabToLinearRgb(d.vec3f(BLOB.lightness, chroma));  };  const march = createRaymarch(root, { camera: view, format: HDR, field, paint });  const look = { source: () => screen.textures.hdr, tone: tone.aces, settings: { vignette: 0.3 } };  const final = own(finish.create(root, { ...look, format: LDR }));  const plain = own(finish.create(root, { ...look, format: surface.format }));  const smooth = own(    fxaa.create(root, { format: surface.format, source: () => screen.textures.ldr }),  );  await pipelines.compile([march, final, plain, smooth]);  const settings: Settings<typeof values> = {    values,    knobs: {      blend: { min: 0.01, max: 1, step: 0.01 },      speed: { min: 0, max: 2, step: 0.01 },      fxaa: { label: 'FXAA' },    },    write(key) {      if (key === undefined || key === 'blend') blend.write(values.blend);    },  };  return {    view,    settings,    frame(frame: Frame) {      view.update(frame.dt);      screen.fit();      if (values.speed !== 0) {        turned += frame.dt * values.speed;        place();      }      const scene = frame.encoder.beginRenderPass({ colorAttachments: { view: screen.views.hdr } });      march.draw(scene);      scene.end();      if (values.fxaa) {        final.record(frame.encoder, screen.views.ldr);        smooth.record(frame.encoder, surface.view(frame));      } else {        plain.record(frame.encoder, surface.view(frame));      }    },    destroy: own.destroy,  };}

raymarch.ts

import { cameras, type CameraBase } from '@typegpu/kit';import { common, d, std, tgpu, type TgpuRenderCommands, type TgpuRoot } from 'typegpu';/** Signed distance from the nearest surface, negative inside. Runs on the GPU. */export type Field = (point: d.v3f) => number;/** Unlit surface colour at a point. Runs on the GPU. */export type Paint = (point: d.v3f) => d.v3f;export interface RaymarchOptions {  readonly camera: CameraBase;  /** The target's format, such as an HDR one. */  readonly format: GPUTextureFormat;  readonly field: Field;  readonly paint: Paint;}/** Maximum ray steps per pixel and maximum travel distance in world units. */const STEPS = 128;const FAR = 40;/** Surface-hit tolerance per world unit travelled. */const HIT = 0.0005;/** Normal-sampling offset in world units. */const NORMAL_STEP = 0.0005;/** Fog density. A surface `t` world units away retains `exp(-t × FOG)` of its colour. */const FOG = 0.035;/** Sun direction and linear sun, zenith and horizon colours. */const SUN = std.normalize(d.vec3f(0.5, 0.75, 0.45));const SUNLIGHT = d.vec3f(1.7, 1.55, 1.35);const ZENITH = d.vec3f(0.05, 0.12, 0.32);const HORIZON = d.vec3f(0.32, 0.38, 0.48);/** Sky-light weights for sideways and upward-facing surfaces. */const SKYLIGHT = { sideways: 0.35, up: 0.25 };/** Maximum shadow-ray steps and minimum and maximum step lengths in world units. */const SHADOW = { steps: 48, least: 0.02, most: 0.3 };/** World units off the surface where the shadow's ray starts, so it doesn't meet it at once. */const LIFT = 0.002;/** * Shadow sharpness. A ray passing distance `d` from the field at travel distance `t` transmits * `PENUMBRA × d / t` of sunlight. */const PENUMBRA = 8;/** Shadow-ray reach toward the sun in world units. */const SHADOW_REACH = 10;/** * Distance beyond which the field cannot darken a shadow. Allows cheaper distance bounds * outside this range. */export const UNSHADED = SHADOW_REACH / PENUMBRA;/** * Raymarches a distance field with one ray per pixel, sunlight, soft shadows, sky light and * fog. Draws one triangle into the caller's pass. */export function createRaymarch(root: TgpuRoot, options: RaymarchOptions) {  const { field, paint } = options;  /** Estimates the normal from field differences at tetrahedron corners. */  const normalAt = (point: d.v3f) => {    'use gpu';    const e = d.vec2f(1, -1) * NORMAL_STEP;    return std.normalize(      e.xyy * field(point + e.xyy) +        e.yyx * field(point + e.yyx) +        e.yxy * field(point + e.yxy) +        e.xxx * field(point + e.xxx),    );  };  /** The share of sunlight reaching `point`, less where the shadow ray grazes the field. */  const sunlight = (point: d.v3f) => {    'use gpu';    let light = d.f32(1);    let t = d.f32(SHADOW.least);    for (let i = d.u32(0); i < SHADOW.steps; i++) {      const near = field(point + SUN * t);      light = std.min(light, (PENUMBRA * near) / t);      t += std.clamp(near, SHADOW.least, SHADOW.most);      if (light < 0.001 || t > SHADOW_REACH) break;    }    return std.saturate(light);  };  const fragment = tgpu.fragmentFn({ in: { uv: d.vec2f }, out: d.vec4f })(({ uv }) => {    'use gpu';    const ray = cameras.rayOf(cameras.current.$, d.vec2f(uv.x, 1 - uv.y) * 2 - 1);    const sky = std.mix(HORIZON, ZENITH, std.saturate(ray.direction.y));    let t = d.f32(0);    let hit = false;    for (let i = d.u32(0); i < STEPS; i++) {      const distance = field(ray.origin + ray.direction * t);      if (distance < HIT * t) {        hit = true;        break;      }      t += distance;      if (t > FAR) break;    }    if (!hit) return d.vec4f(sky, 1);    const point = ray.origin + ray.direction * t;    const normal = normalAt(point);    const sun = std.max(std.dot(normal, SUN), 0) * sunlight(point + normal * LIFT);    const lit = SUNLIGHT * sun + sky * (SKYLIGHT.sideways + normal.y * SKYLIGHT.up);    const color = paint(point) * lit;    return d.vec4f(std.mix(sky, color, std.exp(-t * FOG)), 1);  });  const pipeline = root.pipe(options.camera.inject()).createRenderPipeline({    vertex: common.fullScreenTriangle,    fragment,    targets: { format: options.format },  });  return {    initAsync: () => pipeline.initAsync(),    draw(pass: TgpuRenderCommands) {      pass.setPipeline(pipeline);      pass.draw(3);    },  };}