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
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- 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); }, };}