SPH Sculpt
An island of plasticine you dig and build on, ray traced in compute through an 8-bit distance field with seven mips. The kit's lighting shades it with soft wrapped light, cone-traced shadows and occlusion under a golden-hour sky baked into an environment, and FXAA smooths its edges. A spring feeds SPH water, sorted into the kit's neighbour grid each step. Only the grid's blocks near water rebuild the surface the rays meet.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- clay.tsClay as an 8-bit narrow-band SDF volume with a mip chain, after Claybook.
- island.ts
- render.tsRay traces the clay and the water in compute: an 8 × 8 cone pre-pass, a trace that writes depth and normals while the SDF is hot in cache, a half-resolution pass for sun shadow and ambient occlusion together, and a material pass that never touches the SDF.
- ui.ts
- water.tsWater as particles kept in grid order.
Kit modules cameras, clock, environment, finish, fxaa, lighting, owner, pipelines, readback, shading, targets, tone. Also @typegpu/noise, @typegpu/sdf, typegpu.
scene.ts
import { clock, finish, fxaa, owner, pipelines, readback, targets, tone, type Frame, type Surface,} from '@typegpu/kit';import type { TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import type { Bound } from '../ui.ts';import { createClay, formatsOf } from './clay.ts';import { SPRING } from './island.ts';import { createRender } from './render.ts';import type { ui } from './ui.ts';import { createWater, STEP } from './water.ts';/** Display-space target for FXAA after the final pass. */const LDR = 'rgba8unorm';/** A brush's centre off the surface, in radii: towards the eye to dig, above the ground to build. */const LIFT = { dig: 0.45, build: 0.35 };/** Metres a second a held brush moves the surface. */const CARVE = 0.8;/** * Claybook's clay and water on an island: an 8-bit SDF volume with a mip chain, ray traced in * compute, and SPH water on the neighbour grid. Drag to dig, or to raise clay while build is down. */export async function createScene(root: TgpuRoot, surface: Surface) { const own = owner(); const formats = formatsOf(root); const clay = own(createClay(root, formats)); const water = own(createWater(root, clay, formats)); const render = own(createRender(root, surface, { clay, water, formats })); const { camera } = render; // The rays take one sample a pixel, so FXAA smooths the clay's and the water's silhouettes const display = own(targets.create(root, surface, { ldr: { format: LDR, sampled: true } })); const final = own( finish.create(root, { format: LDR, source: render.hdr, tone: tone.neutral, settings: { exposure: 0.85, vignette: 0.2 }, }), ); const smooth = own( fxaa.create(root, { format: surface.format, source: () => display.textures.ldr }), ); const sim = clock.fixedStep({ step: STEP, maxSteps: 2 }); const alive = own(readback.create(root, water.sim.buffer, { hz: 4, decode: (s) => s.alive })); const values = { radius: 0.7, spring: true, shadows: true, occlusion: true, }; let restarting = true; let baked = false; /** Steps since the water was reset, which the GPU counts too. */ let stepped = 0; /** Particles the spring owes, carried between frames, and those it has given. */ let owed = 0; let spawned = 0; /** The brush mode that edited last frame, to tell when a stroke starts. */ let stroke = 0; const cursor = { x: 0, y: 0, over: false, down: false }; let building = false; /** * A stroke's first frame only traces: the brush aims at the last traced hit, which until this * frame's trace lies wherever the pointer was before it landed. */ let landing = false; const pointing = { move(x, y) { Object.assign(cursor, { x, y, over: true }); }, leave() { cursor.over = false; }, drag(x, y, start) { Object.assign(cursor, { x, y, over: true, down: true }); if (start) landing = true; }, end() { cursor.down = false; }, } satisfies Pointing; const settings: Settings<typeof values> = { values, knobs: { radius: { label: 'brush radius (m)', min: 0.3, max: 1.4, step: 0.05 }, spring: {}, shadows: { label: 'soft shadows' }, occlusion: { label: 'ambient occlusion' }, }, write() { render.choices.write({ radius: values.radius, shadows: values.shadows ? 1 : 0, ao: values.occlusion ? 1 : 0, }); }, actions: { reset() { restarting = true; }, }, readouts: { particles: () => alive.latest ?? 0, }, }; await pipelines.compile([clay, water, render, final, smooth]); return { view: camera, settings, pointing, ui: { controls: { build: { press: () => (building = true), release: () => (building = false), }, }, } satisfies Bound<typeof ui>, frame(frame: Frame) { camera.update(frame.dt); const screen = render.fit(); if (!baked) render.bake(frame.encoder); baked = true; if (restarting) { water.reset(frame.encoder); alive.reset(); stepped = 0; owed = 0; spawned = 0; } // The spring wells up at its rate of simulated time, in each frame's first step, until the // last count read back reaches its limit const steps = sim.advance(frame.dt); if (steps > 0) { owed += SPRING.rate * steps * STEP; const due = Math.floor(owed); owed -= due; const room = Math.max(SPRING.limit - (alive.latest ?? 0), 0); const count = values.spring ? Math.min(due, room) : 0; water.spawn.write({ count, first: stepped, total: spawned }); spawned += count; stepped += steps; } // A stroke starts when the brush switches on or changes mode const mode = cursor.down && !landing ? (building ? 2 : 1) : 0; const fresh = mode !== 0 && mode !== stroke; stroke = mode; landing = false; if (mode !== 0) { clay.brush.write({ radius: values.radius, blend: values.radius * 0.35, mode, lift: mode === 1 ? LIFT.dig : LIFT.build, reach: CARVE * Math.min(frame.dt, 1 / 30), fresh: fresh ? 1 : 0, }); } render.view.write({ size: [screen.width, screen.height], cursor: cursor.over ? [Math.floor(cursor.x * screen.width), Math.floor(cursor.y * screen.height)] : [-1, -1], mode: mode !== 0 ? mode : cursor.over ? 3 : 0, }); // Every kernel of the frame in one compute pass: usage is checked per dispatch const pass = frame.encoder.beginComputePass(); if (restarting) clay.generate(pass); // The brush follows the cursor's hit from last frame's trace if (mode !== 0) clay.edit(pass, fresh && mode === 2); for (let i = 0; i < steps; i++) water.step(pass); water.surface(pass); render.record(pass); pass.end(); display.fit(); final.record(frame, display.views.ldr); smooth.record(frame, surface.view(frame)); alive.capture(frame); restarting = false; }, destroy: own.destroy, };}clay.ts
import { owner } from '@typegpu/kit';import { perlin2d } from '@typegpu/noise';import { opSmoothDifference, opSmoothUnion, sdRoundedBox3d } from '@typegpu/sdf';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { MAX_PRIMS, Prim, PRIMS, Terrain, TERRAIN } from './island.ts';/** Voxel edge in metres at mip 0. */export const VOXEL = 0.1;/** Volume size in voxels at mip 0. */export const DIMS = [256, 128, 256] as const;/** World box size in metres; the box starts at the origin. */export const WORLD = d.vec3f(DIMS[0] * VOXEL, DIMS[1] * VOXEL, DIMS[2] * VOXEL);/** Mip levels. Level 6 is 4 × 2 × 4 voxels. */export const MIPS = 7;/** * Each mip stores distance clamped to ±BAND of its own voxels, as Claybook's 8-bit volumes do. The * clamp is what makes coarse mips useful: level 4 skips up to 64 level-0 voxels in one step. */export const BAND = 4;/** Encodes a distance in metres for a mip with voxel edge `s`. */export const encode = (dist: number, s: number) => { 'use gpu'; return dist / (2 * BAND * s) + 0.5;};/** Decodes a stored value back to metres for a mip with voxel edge `s`. */export const decode = (value: number, s: number) => { 'use gpu'; return (value - 0.5) * (2 * BAND * s);};/** * Storage formats. Claybook's narrow ones need `texture-formats-tier1`; without it each falls back * to `rgba8unorm`, four times the bytes for the same values. */export interface Formats { readonly distance: 'r8unorm' | 'rgba8unorm'; readonly pair: 'rg8unorm' | 'rgba8unorm'; readonly normal: 'rgb10a2unorm' | 'rgba8unorm';}export const formatsOf = (root: TgpuRoot): Formats => root.device.features.has('texture-formats-tier1') ? { distance: 'r8unorm', pair: 'rg8unorm', normal: 'rgb10a2unorm' } : { distance: 'rgba8unorm', pair: 'rgba8unorm', normal: 'rgba8unorm' };/** Height map resolution of the ground a build stroke aims at. */const MAP = 128;const HEIGHTS = DIMS[0] * DIMS[2];/** The material volume is one mip down: 0.2 m voxels. */const MATERIAL_VOXEL = VOXEL * 2;const MATERIAL_SIZE = [DIMS[0] / 2, DIMS[1] / 2, DIMS[2] / 2] as const;const voxelOf = (level: number) => VOXEL * 2 ** level;/** * Coarse levels store distance minus √3 voxels, so trilinear filtering between voxel centres never * reads more than the true distance. Level 0 is exact; the tracers hit surfaces there. */const dilationOf = (level: number) => (level === 0 ? 0 : Math.sqrt(3) * voxelOf(level));const sizeOf = (level: number) => DIMS.map((n) => Math.max(1, n >> level)) as [number, number, number];const LevelInfo = d.struct({ size: d.vec3u, /** Voxel edge in metres. */ s: d.f32, dilation: d.f32, level: d.u32,});export const Brush = d.struct({ radius: d.f32, /** Smooth blend distance in metres. */ blend: d.f32, /** 0 off, 1 dig, 2 build. */ mode: d.u32, /** * How far off the surface the brush centre sits, in radii. Digging moves it towards the eye; * building raises it above the ground as it was when the stroke started. */ lift: d.f32, /** How far the surface may move this frame, in metres, so a held brush carves at a speed. */ reach: d.f32, /** 1 on a stroke's first frame, which starts a new swept segment. */ fresh: d.u32,});/** What the cursor ray hit in the last traced frame. */export const Pick = d.struct({ position: d.vec3f, hit: d.u32, eye: d.vec3f, /** The cursor ray's direction, written even when it misses. */ direction: d.vec3f,});/** This frame's brush: a capsule from last frame's centre to this one's, so strokes leave no gaps. */export const Aim = d.struct({ previous: d.vec3f, valid: d.u32, center: d.vec3f, /** Where the cursor ray met the frozen ground, for the brush ring while building. */ ground: d.vec3f,});/** Largest brush radius plus blend, which sizes the edit dispatch. */export const BRUSH_REACH = 2;/** Longest segment a brush sweeps in one frame, in metres. A faster cursor leaves gaps. */const SEGMENT = 0.5;/** The edit box's half size around the segment's middle. */const EDIT_REACH = BRUSH_REACH + SEGMENT / 2;const EDIT_GROUP = 4;const editExtent = (level: number) => 2 * Math.ceil(EDIT_REACH / voxelOf(level) + 2) + 1;const SCRATCH = editExtent(0) ** 3;// `@typegpu/sdf`'s capsule divides by its squared length, and the brush's is 0 at a stroke's startconst sdCapsule = (p: d.v3f, a: d.v3f, b: d.v3f, r: number) => { 'use gpu'; const pa = p - a; const ba = b - a; const h = std.saturate(std.dot(pa, ba) / std.max(std.dot(ba, ba), 1e-8)); return std.length(pa - ba * h) - r;};const primDistance = (p: d.v3f, prim: d.Infer<typeof Prim>) => { 'use gpu'; if (prim.shape === 0) return std.length(p - prim.a.xyz) - prim.a.w; if (prim.shape === 1) return sdRoundedBox3d(p - prim.a.xyz, prim.b.xyz, prim.a.w); return sdCapsule(p, prim.a.xyz, prim.b.xyz, prim.a.w);};/** * Returns a `'use gpu'` function giving the unit normal of `field` at a point, from four taps on * the corners of a tetrahedron `step` metres across each axis. */export const tetrahedralNormal = (field: (p: d.v3f) => number, step: number) => { const normalAt = (p: d.v3f) => { 'use gpu'; const e = d.vec2f(1, -1) * step; return std.normalize( e.xyy * field(p + e.xyy) + e.yyx * field(p + e.yyx) + e.yxy * field(p + e.yxy) + e.xxx * field(p + e.xxx), ); }; return normalAt;};/** * Clay as an 8-bit narrow-band SDF volume with a mip chain, after Claybook. Generation evaluates * the terrain and its CSG list once per mip; edits apply the brush to the few voxels it covers at * every mip, so no mip is ever rebuilt from another. */export function createClay(root: TgpuRoot, formats: Formats) { const own = owner(); const { distance, pair } = formats; const volume = own( root .createTexture({ size: [...DIMS], format: distance, dimension: '3d', mipLevelCount: MIPS }) .$usage('sampled', 'storage'), ); const sampled = volume.createView(d.texture3d(d.f32)); const sampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'nearest', addressModeU: 'clamp-to-edge', addressModeV: 'clamp-to-edge', addressModeW: 'clamp-to-edge', }); /** * The island as generated, beside the live SDF, like Claybook's material volume: distance to the * original surface in r and to rock in g. The material pass reads one tap of it to tell topsoil * from dug strata, built clay and rock, and never touches the SDF. */ const origin = own( root .createTexture({ size: [...MATERIAL_SIZE], format: pair, dimension: '3d' }) .$usage('sampled', 'storage'), ); const originOut = origin.createView(d.textureStorage3d(pair, 'write-only')); const originIn = origin.createView(d.texture3d(d.f32)); const terrain = root.createUniform(Terrain, { ...TERRAIN, prims: PRIMS.length }); const prims = root.createReadonly(d.arrayOf(Prim, MAX_PRIMS), PRIMS); const heights = own(root.createBuffer(d.arrayOf(d.f32, HEIGHTS)).$usage('storage')); const heightsOut = heights.as('mutable'); const heightsIn = heights.as('readonly'); const brush = root.createUniform(Brush); const pick = root.createMutable(Pick); const aim = root.createMutable(Aim); const scratch = own(root.createBuffer(d.arrayOf(d.f32, SCRATCH)).$usage('storage')); const scratchOut = scratch.as('mutable'); const scratchIn = scratch.as('readonly'); const writeLayout = tgpu.bindGroupLayout({ out: { storageTexture: d.textureStorage3d(distance, 'write-only') }, info: { uniform: LevelInfo }, }); const readLayout = tgpu.bindGroupLayout({ src: { texture: d.texture3d(d.f32) }, info: { uniform: LevelInfo }, }); const levels = Array.from({ length: MIPS }, (_, level) => { const info = own( root .createBuffer(LevelInfo, { size: d.vec3u(...sizeOf(level)), s: voxelOf(level), dilation: dilationOf(level), level, }) .$usage('uniform'), ); const view = { baseMipLevel: level, mipLevelCount: 1 }; return { size: sizeOf(level), extent: editExtent(level), write: root.createBindGroup(writeLayout, { out: volume.createView(d.textureStorage3d(distance, 'write-only'), view), info, }), read: root.createBindGroup(readLayout, { src: volume.createView(d.texture3d(d.f32), view), info, }), }; }); /** Terrain height at a world column, from the noise. */ const heightAt = (xz: d.v2f) => { 'use gpu'; const t = terrain.$; let h = t.base; let amp = t.amp; let at = xz * t.freq + t.seed; // Three soft octaves: rolling ground with no dimples for a low sun to pick out for (let i = 0; i < 3; i++) { h += perlin2d.sample(at) * amp; amp *= 0.45; at = std.fma(at, d.vec2f(2.03), d.vec2f(17.1)); } // The island falls away near its rim, so water can be lost over the edge const rim = std.length(xz - WORLD.xz * 0.5) / t.island; return std.mix(h, -4, std.smoothstep(0.9, 1.05, rim)); }; const heightKernel = tgpu.computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= DIMS[0] || gid.y >= DIMS[2]) return; const xz = (d.vec2f(gid.xy) + 0.5) * VOXEL; heightsOut.$[gid.y * DIMS[0] + gid.x] = heightAt(xz); }); const heightTap = (x: number, z: number) => { 'use gpu'; const ix = d.u32(std.clamp(x, 0, DIMS[0] - 1)); const iz = d.u32(std.clamp(z, 0, DIMS[2] - 1)); return heightsIn.$[iz * DIMS[0] + ix]!; }; /** Bilinear terrain height from the baked columns. */ const groundAt = (xz: d.v2f) => { 'use gpu'; const g = xz / VOXEL - 0.5; const base = std.floor(g); const f = g - base; const h00 = heightTap(base.x, base.y); const h10 = heightTap(base.x + 1, base.y); const h01 = heightTap(base.x, base.y + 1); const h11 = heightTap(base.x + 1, base.y + 1); return std.mix(std.mix(h00, h10, f.x), std.mix(h01, h11, f.x), f.y); }; /** Distance to rock, which no brush removes. */ const rockAt = (p: d.v3f) => { 'use gpu'; // The plinth: a slab under the island, tucked inside its rim const t = terrain.$; const out = std.length(p.xz - WORLD.xz * 0.5) - t.island * 0.86; let rock = std.max(p.y - t.bedrock, std.max(out, 0.3 - p.y)); for (let i = d.u32(0); i < t.prims; i++) { const prim = Prim(prims.$[i]!); if (prim.rock === 1) rock = std.min(rock, primDistance(p, prim)); } return rock; }; /** * Signed distance to the terrain, corrected up to `reach` metres. The vertical gap shrunk by the * local slope is close on smooth ground, but next to a cliff it reports the drop below, not the * wall beside: outside the island's rim that was metres of free space a ray could jump. Every * baked column within reach is a point on the surface, so the nearest one corrects the slope * estimate. Columns are visited `pitch` metres apart. */ const groundDistance = (p: d.v3f, reach: number, pitch: number) => { 'use gpu'; const e = VOXEL; const h = groundAt(p.xz); const gx = (groundAt(p.xz + d.vec2f(e, 0)) - groundAt(p.xz - d.vec2f(e, 0))) / (2 * e); const gz = (groundAt(p.xz + d.vec2f(0, e)) - groundAt(p.xz - d.vec2f(0, e))) / (2 * e); const estimate = (p.y - h) / std.sqrt(1 + gx * gx + gz * gz); // Outside, the nearest solid column; inside, the nearest column with air above p const side = std.select(d.f32(-1), d.f32(1), estimate >= 0); let best = std.abs(estimate); const step = std.max(d.i32(std.round(pitch / VOXEL)), 1); const centre = d.vec2i(std.round((p.xz / VOXEL - 0.5) / d.f32(step))) * step; const n = d.i32(std.ceil(std.min(best, reach) / (d.f32(step) * VOXEL))); for (let dz = -n; dz <= n; dz++) { for (let dx = -n; dx <= n; dx++) { const column = centre + d.vec2i(dx, dz) * step; const across = std.length((d.vec2f(column) + 0.5) * VOXEL - p.xz); if (across >= best) continue; const gap = std.max(side * (p.y - heightTap(column.x, column.y)), 0); best = std.min(best, std.sqrt(across * across + gap * gap)); } } return side * best; }; /** The island's distance field: terrain, its CSG list in order, then rock on top. */ const fieldAt = (p: d.v3f, reach: number, pitch: number) => { 'use gpu'; // A floor slab under everything keeps the island's underside closed let dist = std.max(groundDistance(p, reach, pitch), 0.3 - p.y); for (let i = d.u32(0); i < terrain.$.prims; i++) { const prim = Prim(prims.$[i]!); if (prim.rock === 1) continue; const shape = primDistance(p, prim); if (prim.op === 0) dist = opSmoothUnion(dist, shape, prim.b.w); else dist = opSmoothDifference(dist, shape, prim.b.w); } return std.min(dist, rockAt(p)); }; const generateKernel = tgpu.computeFn({ workgroupSize: [4, 4, 4], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; const info = writeLayout.$.info; if (gid.x >= info.size.x || gid.y >= info.size.y || gid.z >= info.size.z) return; const p = (d.vec3f(gid) + 0.5) * info.s; // Coarse mips search coarser columns; their dilation covers the gaps between them const dist = fieldAt(p, BAND * info.s + info.dilation, info.s * 0.25) - info.dilation; std.textureStore(writeLayout.$.out, gid, d.vec4f(encode(dist, info.s), 0, 0, 1)); }); const originKernel = tgpu.computeFn({ workgroupSize: [4, 4, 4], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= MATERIAL_SIZE[0] || gid.y >= MATERIAL_SIZE[1] || gid.z >= MATERIAL_SIZE[2]) return; const p = (d.vec3f(gid) + 0.5) * MATERIAL_VOXEL; const value = d.vec2f( encode(fieldAt(p, BAND * MATERIAL_VOXEL, VOXEL), MATERIAL_VOXEL), encode(rockAt(p), MATERIAL_VOXEL), ); std.textureStore(originOut.$, gid, d.vec4f(value, 0, 1)); }); /** Edits run over a fixed box of voxels around the segment's middle, the same every frame. */ const editOrigin = (s: number, extent: number) => { 'use gpu'; const middle = (aim.$.previous + aim.$.center) * 0.5; return d.vec3i(std.floor(middle / s)) - d.i32(extent / 2); }; const editKernel = tgpu.computeFn({ workgroupSize: [EDIT_GROUP, EDIT_GROUP, EDIT_GROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; const info = readLayout.$.info; const extent = d.u32(2 * std.ceil(EDIT_REACH / info.s + 2) + 1); if (gid.x >= extent || gid.y >= extent || gid.z >= extent) return; const voxel = editOrigin(info.s, d.f32(extent)) + d.vec3i(gid); const slot = (gid.z * extent + gid.y) * extent + gid.x; if ( std.any(std.lt(voxel, d.vec3i())) || std.any(std.ge(d.vec3u(voxel), info.size)) || aim.$.valid === 0 ) { scratchOut.$[slot] = -1; return; } const p = (d.vec3f(voxel) + 0.5) * info.s; const old = decode(std.textureLoad(readLayout.$.src, voxel, 0).x, info.s); const reach = sdCapsule(p, aim.$.previous, aim.$.center, brush.$.radius) - info.dilation; let next = d.f32(old); if (brush.$.mode === 1) { next = std.min( opSmoothDifference(old, reach + 2 * info.dilation, brush.$.blend), old + brush.$.reach, ); } else { next = std.max(opSmoothUnion(old, reach, brush.$.blend), old - brush.$.reach); } next = std.min(next, rockAt(p) - info.dilation); scratchOut.$[slot] = std.saturate(encode(next, info.s)); }); const storeKernel = tgpu.computeFn({ workgroupSize: [EDIT_GROUP, EDIT_GROUP, EDIT_GROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; const info = writeLayout.$.info; const extent = d.u32(2 * std.ceil(EDIT_REACH / info.s + 2) + 1); if (gid.x >= extent || gid.y >= extent || gid.z >= extent) return; const value = scratchIn.$[(gid.z * extent + gid.y) * extent + gid.x]!; if (value < 0) return; const voxel = editOrigin(info.s, d.f32(extent)) + d.vec3i(gid); std.textureStore(writeLayout.$.out, d.vec3u(voxel), d.vec4f(value, 0, 0, 1)); }); /** * Samples the distance in metres at a mip. Pass a literal level, so the voxel size folds into a * constant. */ const sdf = (p: d.v3f, level: number) => { 'use gpu'; const lod = d.f32(level); const value = std.textureSampleLevel(sampled.$, sampler.$, p / WORLD, lod).x; return decode(value, VOXEL * std.exp2(lod)); }; /** Distance in metres at level 0. */ const fine = (p: d.v3f) => { 'use gpu'; return sdf(p, 0); }; /** Unit surface normal from level 0. */ const normalAt = tetrahedralNormal(fine, VOXEL * 0.75); /** * The island as generated, at a surface point: the distance to its surface in x, negative where * the point was dug below it and positive where clay was built above, and the distance to rock * in y, both in metres. One filtered tap of the material volume. */ const originAt = (p: d.v3f) => { 'use gpu'; const tap = std.textureSampleLevel(originIn.$, sampler.$, p / WORLD, 0).xy; return d.vec2f(decode(tap.x, MATERIAL_VOXEL), decode(tap.y, MATERIAL_VOXEL)); }; /** A top-down height map of the surface, frozen at a build stroke's start for it to aim at. */ const map = own(root.createBuffer(d.arrayOf(d.f32, MAP * MAP)).$usage('storage')); const mapOut = map.as('mutable'); const mapIn = map.as('readonly'); const mapKernel = tgpu.computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= MAP || gid.y >= MAP) return; const xz = (d.vec2f(gid.xy) + 0.5) * (WORLD.x / MAP); let y = WORLD.y - VOXEL; for (let i = 0; i < 256; i++) { const dist = sdf(d.vec3f(xz.x, y, xz.y), 0); if (dist < VOXEL * 0.2 || y < 0) break; y -= std.max(dist, VOXEL * 0.2); } mapOut.$[gid.y * MAP + gid.x] = y; }); /** Bilinear height of the ground as the last stroke start froze it. */ const frozenAt = (xz: d.v2f) => { 'use gpu'; const cell = WORLD.x / MAP; const g = std.clamp(xz / cell - 0.5, d.vec2f(), d.vec2f(MAP - 1)); const base = std.min(d.vec2u(g), d.vec2u(MAP - 2)); const f = g - d.vec2f(base); const at = base.y * MAP + base.x; const top = std.mix(mapIn.$[at]!, mapIn.$[at + 1]!, f.x); const bottom = std.mix(mapIn.$[at + MAP]!, mapIn.$[at + MAP + 1]!, f.x); return std.mix(top, bottom, f.y); }; /** * Places this frame's brush segment. Digging centres on the cursor's hit, so a held brush keeps * cutting down. Building aims at the ground as it was when the stroke started, so clay rises to * a set height instead of growing towards the eye along the cursor ray. */ const aimKernel = tgpu.computeFn({ workgroupSize: [1] })(() => { 'use gpu'; const b = brush.$; let valid = pick.$.hit === 1; const toward = std.normalize(pick.$.eye - pick.$.position); let center = pick.$.position + toward * (b.radius * b.lift); if (b.mode === 2) { // Step along the cursor ray until it dips under the frozen ground, then halve the last step const eye = pick.$.eye; const dir = pick.$.direction; let near = d.f32(0); let far = d.f32(0); valid = false; for (let i = 0; i < 640; i++) { const p = eye + dir * far; const above = p.y - frozenAt(p.xz); if (above < 0) { valid = true; break; } near = far; far += std.clamp(above * 0.5, 0.02, 0.15); } for (let i = 0; i < 8; i++) { const mid = (near + far) * 0.5; const p = eye + dir * mid; if (p.y < frozenAt(p.xz)) far = mid; else near = mid; } const ground = eye + dir * far; aim.$.ground = d.vec3f(ground); center = ground + d.vec3f(0, b.radius * b.lift, 0); } // Continue from last frame's centre, at most a segment behind let start = d.vec3f(center); if (b.fresh === 0 && aim.$.valid === 1) { const back = aim.$.center - center; const length = std.length(back); start = center + back * (std.min(length, SEGMENT) / std.max(length, 1e-6)); } aim.$.previous = d.vec3f(start); aim.$.center = d.vec3f(center); aim.$.valid = d.u32(valid); }); const heighting = root.createComputePipeline({ compute: heightKernel }); const originating = root.createComputePipeline({ compute: originKernel }); const generating = root.createComputePipeline({ compute: generateKernel }); const editing = root.createComputePipeline({ compute: editKernel }); const storing = root.createComputePipeline({ compute: storeKernel }); const mapping = root.createComputePipeline({ compute: mapKernel }); const aiming = root.createComputePipeline({ compute: aimKernel }); const pipelines = [heighting, originating, generating, editing, storing, mapping, aiming]; return { sampler, brush, pick, aim, sdf, fine, normalAt, originAt, /** Generates the island at every mip, and the material volume beside it. */ generate(pass: TgpuComputePass) { pass.setPipeline(heighting); pass.dispatchWorkgroups(DIMS[0] / 8, DIMS[2] / 8); pass.setPipeline(originating); pass.dispatchWorkgroups(MATERIAL_SIZE[0] / 4, MATERIAL_SIZE[1] / 4, MATERIAL_SIZE[2] / 4); for (const level of levels) { const [x, y, z] = level.size; pass.setPipeline(generating); pass.setBindGroup(level.write); pass.dispatchWorkgroups(Math.ceil(x / 4), Math.ceil(y / 4), Math.ceil(z / 4)); } }, /** * Applies this frame's brush to the voxels it covers at every mip. `freeze` keeps the ground as * it is now for a build stroke to aim at; pass it on the stroke's first frame. */ edit(pass: TgpuComputePass, freeze: boolean) { if (freeze) { pass.setPipeline(mapping); pass.dispatchWorkgroups(MAP / 8, MAP / 8); } pass.setPipeline(aiming); pass.dispatchWorkgroups(1); for (const level of levels) { const groups = Math.ceil(level.extent / EDIT_GROUP); pass.setPipeline(editing); pass.setBindGroup(level.read); pass.dispatchWorkgroups(groups, groups, groups); pass.setPipeline(storing); pass.setBindGroup(level.write); pass.dispatchWorkgroups(groups, groups, groups); } }, initAsync: () => Promise.all(pipelines.map((p) => p.initAsync())), destroy: own.destroy, };}export type Clay = ReturnType<typeof createClay>;island.ts
import { d } from 'typegpu';/** A CSG primitive applied after the terrain. Rock primitives can't be dug. */export const Prim = d.struct({ /** Sphere: centre and radius. Box: centre and edge rounding. Capsule: start and radius. */ a: d.vec4f, /** Box: half extents and blend. Capsule: end and blend. Sphere: blend in w. */ b: d.vec4f, /** 0 sphere, 1 box, 2 capsule. */ shape: d.u32, /** 0 union, 1 subtract. */ op: d.u32, /** 1 for rock, which stays solid under the dig brush and shades grey. */ rock: d.u32, pad: d.u32,});export const Terrain = d.struct({ /** Ground height in metres before noise. */ base: d.f32, /** Noise amplitude in metres. */ amp: d.f32, /** Noise frequency per metre. */ freq: d.f32, /** Island radius in metres, where the ground falls away into the sea. */ island: d.f32, /** Noise offset, which picks a different landscape. */ seed: d.vec2f, /** Top of the rock plinth under the clay, which no dig goes through. */ bedrock: d.f32, prims: d.u32,});type PrimInput = d.InferInput<typeof Prim>;const sphere = (at: [number, number, number], r: number, op: number, blend: number): PrimInput => ({ a: [...at, r], b: [0, 0, 0, blend], shape: 0, op, rock: 0, pad: 0,});const rockBox = (at: [number, number, number], half: [number, number, number]): PrimInput => ({ a: [...at, 0.3], b: [...half, 0.2], shape: 1, op: 0, rock: 1, pad: 0,});const DIG = 1;/** The world box is 25.6 × 12.8 × 25.6 m, with x and z from 0 to 25.6. */export const TERRAIN: Omit<d.InferInput<typeof Terrain>, 'prims'> = { base: 3.2, amp: 0.9, freq: 0.11, island: 11.5, seed: [3.1, 7.7], bedrock: 0.6,};/** A hill with a pond for the spring, a bowl beyond a rock ridge, and a gap in the ridge. */export const PRIMS: PrimInput[] = [ sphere([7, -2.5, 12.8], 8, 0, 1.5), sphere([7, 6.4, 12.8], 1.5, DIG, 0.4), sphere([19.5, 3.4, 12.8], 2.6, DIG, 0.6), rockBox([13, 4.5, 8.5], [0.6, 1.6, 4.2]), rockBox([13, 4.5, 18.6], [0.6, 1.6, 3.4]),];export const MAX_PRIMS = 8;/** * Where the spring wells up, just above the pond's rim, its radius in metres and particles a * second. It flows while fewer than `limit` particles are alive, so water lost off the island's * edge comes back. A spring higher up pours a stream that splashes droplets over the hill. */export const SPRING = { at: [7, 6, 12.8] as [number, number, number], radius: 0.28, rate: 900, limit: 30_000,};render.ts
import { cameras, environment, lighting, owner, shading, targets, type Surface,} from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { d, std, tgpu, type TgpuCommandEncoder, type TgpuComputePass, type TgpuRoot,} from 'typegpu';import { BAND, tetrahedralNormal, VOXEL, WORLD, type Clay, type Formats } from './clay.ts';import { SPRING } from './island.ts';import { CELL, type Water } from './water.ts';/** Pixels per side of a cone pre-pass tile. */const TILE = 8;/** The coarsest mip rays start at, and the levels they drop or climb at a time. */const TOP = 4;const STRIDE = 2;/** Noise period in noise units, and texels along each side of the baked volume. */const NOISE_PERIOD = 32;const NOISE_SIZE = 128;/** Depth written where a ray meets nothing. */const FAR = 1e4;const STEPS = 128;/** Voxels across the tetrahedron the shading normal samples. */const NORMAL_SPAN = 2.5;const FLUID_STEPS = 64;/** What changes each frame: the screen, the cursor's pixel and the brush ring. */export const View = d.struct({ size: d.vec2u, cursor: d.vec2i, /** 0 no brush ring, 1 dig, 2 build, 3 hover. */ mode: d.u32,});/** What the settings choose. */export const Choices = d.struct({ /** The brush ring's radius in metres. */ radius: d.f32, shadows: d.u32, ao: d.u32,});/** * A golden-hour sun 22° up on the camera's right: high enough that the clay's gentle slopes read * as smooth light, low enough that the rock walls throw long shadows across the valley. */const SUN = std.normalize(d.vec3f(0.886, 0.4, 0.463));/** * The sun through the evening air, the daylight sky it scatters, and haze from 30 m out. The sky * gradient stays dark: the daylight adds to it. */const LIGHT = { sunDirection: [SUN.x, SUN.y, SUN.z], sunColor: [...environment.sunColor(SUN)] as [number, number, number], sunIntensity: 6, skyTop: [0.01, 0.012, 0.02], skyBottom: [0.01, 0.008, 0.006], skyIntensity: 1, fogDensity: 0.004, fogStart: 30,} satisfies Partial<d.InferInput<typeof lighting.Settings>>;/** Height of the sea around the island, and its distance from the island's centre at the shore. */const SEA = 1.1;const SHORE = 10.6;/** The sea's colour offshore and over the shallows by the island. */const SEA_DEEP = d.vec3f(0.006, 0.03, 0.045);const SEA_SHALLOW = d.vec3f(0.03, 0.16, 0.15);/** The warm haze along the horizon. */const HAZE = d.vec3f(1.1, 0.55, 0.25);/** How bright the sun's disc is where a ray meets nothing, over its light on a surface. */const SUN_DISC = 4;/** Roughness of topsoil and dug clay, rock and built clay, and water, whose glint stays sharp. */const ROUGHNESS = { clay: 0.55, rock: 0.75, built: 0.5, water: 0.22 };/** Absorption per metre of water, for its colour by thickness. */const ABSORB = d.vec3f(0.9, 0.22, 0.12);/** Clay strata from low to high, like a cut through layered plasticine. */const STRATA = [ d.vec3f(0.56, 0.42, 0.28), d.vec3f(0.22, 0.27, 0.13), d.vec3f(0.55, 0.33, 0.09), d.vec3f(0.27, 0.1, 0.08), d.vec3f(0.56, 0.42, 0.28),];const ROCK = d.vec3f(0.122, 0.136, 0.202);/** Terracotta plasticine, a shade deeper low down. */const TOPSOIL_LOW = d.vec3f(0.44, 0.17, 0.09);const TOPSOIL_HIGH = d.vec3f(0.5, 0.21, 0.11);/** Clay built above the island as generated. */const BUILT = d.vec3f(0.863, 0.768, 0.624);const WATER = d.vec3f(0.13, 0.686, 0.631);const DEEP = d.vec3f(0.017, 0.262, 0.397);const FOAM = d.vec3f(0.807, 0.955, 0.823);const SPRING_AT = d.vec3f(...SPRING.at);/** * Metres from the island as generated where dug strata and built clay begin, the width of the * blend into them and into rock, and the distance from rock where the rock's colour is half in. */const MATERIAL_EDGE = 0.06;const MATERIAL_BLEND = 0.1;const ROCK_EDGE = 0.12;/** Coarse mips store distance less √3 of their voxels; samples add it back. */const DILATION_2 = Math.sqrt(3) * VOXEL * 4;const DILATION_4 = Math.sqrt(3) * VOXEL * 16;/** How sharply the sun's shadows fall off, and the nearest distance that sharpens them. */const PENUMBRA = 9;const PENUMBRA_FLOOR = 0.4;const pixelKernel = tgpu.computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId },});const boxHit = (o: d.v3f, dir: d.v3f) => { 'use gpu'; const inv = 1 / dir; const a = (d.vec3f() - o) * inv; const b = (WORLD - o) * inv; const lo = std.min(a, b); const hi = std.max(a, b); return d.vec2f( std.max(std.max(lo.x, lo.y), std.max(lo.z, 0)), std.min(hi.x, std.min(hi.y, hi.z)), );};const packNormal = (n: d.v3f) => { 'use gpu'; return d.vec4f(n * 0.5 + 0.5, 0);};const unpackNormal = (v: d.v4f) => { 'use gpu'; return std.normalize(v.xyz * 2 - 1);};/** * The baked sky with a band of warm haze along the horizon, warmest towards the sun. Fog takes * its colour from the same band, so the sea melts into the sky. */const hazeOf = (direction: d.v3f) => { 'use gpu'; const flat = std.normalize(d.vec3f(direction.x, std.max(direction.y, 0), direction.z)); const near = std.pow(1 - flat.y, 10); const sunward = std.saturate(std.dot(std.normalize(flat.xz), std.normalize(SUN.xz)) * 0.5 + 0.5); return environment.backdrop(flat) + HAZE * (near * (0.35 + 0.65 * sunward * sunward));};/** The sky and the sun where a ray meets nothing. */const skyOf = (direction: d.v3f) => { 'use gpu'; return hazeOf(direction) + environment.sunDisc(direction, SUN_DISC);};/** Where a ray meets the sea, or a negative distance where it misses or the island stands. */const seaHit = (o: d.v3f, dir: d.v3f) => { 'use gpu'; const t = (SEA - o.y) / dir.y; const at = o + dir * t; const offshore = std.length(at.xz - WORLD.xz * 0.5) > SHORE; return std.select(d.f32(-1), t, dir.y < 0 && t > 0 && offshore);};/** Sunlight at the pixel being painted, which the shadow pass found. */const sunLit = tgpu.privateVar(d.f32, 1);const fromShadowPass = (_point: shading.Point) => { 'use gpu'; return sunLit.$;};/** How far past the terminator light wraps round the clay, as through a waxy surface. */const WRAP = 0.3;/** * Plasticine: diffuse light that wraps a little past the terminator, and `pbr`'s broad highlight * on top. Surfaces without colour, such as the water's film, keep only the highlight. */const waxy = ( material: shading.Material, point: shading.Point, direction: d.v3f, radiance: d.v3f,) => { 'use gpu'; const wrapped = std.saturate((std.dot(point.normal, direction) + WRAP) / (1 + WRAP)); const glossy = shading.Material(material); glossy.color = d.vec3f(); return ( material.color * radiance * (wrapped * wrapped) + shading.pbr(glossy, point, direction, radiance) );};/** The sky's diffuse light on the clay over what the environment gives, so shadows stay soft. */const FILL = 0.8;/** The environment's light, with `FILL` more of its diffuse share. */const filled = (material: shading.Material, point: shading.Point) => { 'use gpu'; const diffuse = material.color * (1 - material.metallic) * material.occlusion; return ( environment.ambient(material, point) + diffuse * environment.irradiance(point.normal) * FILL );};/** Fog in the colour of the sky at the horizon behind the point, so the sea melts into it. */const skyward = (color: d.v3f, point: shading.Point) => { 'use gpu'; const away = point.view * -1; return std.mix(color, hazeOf(d.vec3f(away.x, 0, away.z)), shading.haze(point));};/** * Ray traces the clay and the water in compute: an 8 × 8 cone pre-pass, a trace that writes depth * and normals while the SDF is hot in cache, a half-resolution pass for sun shadow and ambient * occlusion together, and a material pass that never touches the SDF. */export function createRender( root: TgpuRoot, surface: Surface, { clay, water, formats }: { clay: Clay; water: Water; formats: Formats },) { const own = owner(); const { distance, normal, pair } = formats; const camera = own( cameras.orbit(root, { size: surface, target: [WORLD.x / 2, 2, WORLD.z / 2], distance: 27, yaw: -0.3, pitch: 0.36, fov: 0.85, pitchRange: [0.15, 1.45], distanceRange: [6, 60], near: 0.1, far: 400, }), ); const view = root.createUniform(View); const choices = root.createUniform(Choices); const sky = own(environment.create(root, { size: 256 })); const light = own( lighting.create(root, { settings: LIGHT, shading: { sunShadow: fromShadowPass, ambient: filled, reflect: waxy, fog: skyward }, }), ); const daylight = sky.add(tgpu.fn(environment.daylight).pipe(light.inject())); // Perlin noise baked into a tiling volume: one filtered tap instead of eight gradients const noise = own( root .createTexture({ size: [NOISE_SIZE, NOISE_SIZE, NOISE_SIZE], format: distance, dimension: '3d', }) .$usage('sampled', 'storage'), ); const noiseOut = noise.createView(d.textureStorage3d(distance, 'write-only')); const noiseIn = noise.createView(d.texture3d(d.f32)); const noiseSampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear', addressModeU: 'repeat', addressModeV: 'repeat', addressModeW: 'repeat', }); const noiseCache = perlin3d.staticCache({ root, size: d.vec3u(NOISE_PERIOD, NOISE_PERIOD, NOISE_PERIOD), }); const bakeNoise = tgpu.computeFn({ workgroupSize: [4, 4, 4], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; const u = (d.vec3f(gid) + 0.5) * (NOISE_PERIOD / NOISE_SIZE); std.textureStore(noiseOut.$, gid, d.vec4f(perlin3d.sample(u) * 0.5 + 0.5, 0, 0, 1)); }); root .pipe(noiseCache.inject()) .createComputePipeline({ compute: bakeNoise }) .dispatchWorkgroups(NOISE_SIZE / 4, NOISE_SIZE / 4, NOISE_SIZE / 4); const screen = own( targets.create(root, surface, { /** Where the clay cone stopped in r, and the water cone in g. */ cone: { format: 'rg32float', scale: 1 / TILE, sampled: true, storage: true }, depth: { format: 'r32float', sampled: true, storage: true }, normal: { format: normal, sampled: true, storage: true }, fluidDepth: { format: 'r32float', sampled: true, storage: true }, fluidNormal: { format: normal, sampled: true, storage: true }, /** Sun visibility in r, ambient openness in g. */ shadow: { format: pair, scale: 0.5, sampled: true, storage: true }, hdr: { format: 'rgba16float', sampled: true, storage: true }, }), ); const filtered = { texture: d.texture2d(d.f32) }; const exact = { texture: d.texture2d(d.f32), sampleType: 'unfilterable-float' } as const; const coneLayout = tgpu.bindGroupLayout({ cone: { storageTexture: d.textureStorage2d('rg32float', 'write-only') }, }); const worldLayout = tgpu.bindGroupLayout({ cone: exact, depth: { storageTexture: d.textureStorage2d('r32float', 'write-only') }, normal: { storageTexture: d.textureStorage2d(normal, 'write-only') }, }); const fluidLayout = tgpu.bindGroupLayout({ cone: exact, depth: exact, fluidDepth: { storageTexture: d.textureStorage2d('r32float', 'write-only') }, fluidNormal: { storageTexture: d.textureStorage2d(normal, 'write-only') }, }); const shadowLayout = tgpu.bindGroupLayout({ depth: exact, normal: filtered, shadow: { storageTexture: d.textureStorage2d(pair, 'write-only') }, }); const paintLayout = tgpu.bindGroupLayout({ depth: exact, normal: filtered, fluidDepth: exact, fluidNormal: filtered, shadow: filtered, hdr: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, }); const groupsFor = () => { const t = screen.textures; const read = d.texture2d(d.f32); return { cone: root.createBindGroup(coneLayout, { cone: t.cone.createView(d.textureStorage2d('rg32float', 'write-only')), }), world: root.createBindGroup(worldLayout, { cone: t.cone.createView(read), depth: t.depth.createView(d.textureStorage2d('r32float', 'write-only')), normal: t.normal.createView(d.textureStorage2d(normal, 'write-only')), }), fluid: root.createBindGroup(fluidLayout, { cone: t.cone.createView(read), depth: t.depth.createView(read), fluidDepth: t.fluidDepth.createView(d.textureStorage2d('r32float', 'write-only')), fluidNormal: t.fluidNormal.createView(d.textureStorage2d(normal, 'write-only')), }), shadow: root.createBindGroup(shadowLayout, { depth: t.depth.createView(read), normal: t.normal.createView(read), shadow: t.shadow.createView(d.textureStorage2d(pair, 'write-only')), }), paint: root.createBindGroup(paintLayout, { depth: t.depth.createView(read), normal: t.normal.createView(read), fluidDepth: t.fluidDepth.createView(read), fluidNormal: t.fluidNormal.createView(read), shadow: t.shadow.createView(read), hdr: t.hdr.createView(d.textureStorage2d('rgba16float', 'write-only')), }), }; }; /** * The clay's normal for shading, over a wider tetrahedron than the water's collisions use, so * the 8-bit field's steps don't show in grazing light. */ const smoothNormalAt = tetrahedralNormal(clay.fine, VOXEL * NORMAL_SPAN); const rayAt = (pixel: d.v2f) => { 'use gpu'; const uv = pixel / d.vec2f(view.$.size); return cameras.rayOf(cameras.current.$, d.vec2f(uv.x * 2 - 1, 1 - uv.y * 2)); }; /** Tangent of one pixel's angle, for cone radii. */ const pixelSpread = () => { 'use gpu'; return 2 / (d.f32(view.$.size.y) * cameras.lensOf(cameras.current.$).y); }; /** * The tracing loop over the mip chain. Starts coarse, drops two levels when a sample * is within half a voxel of a surface, climbs back when the band clamps, and steps the sampled * distance less the cone radius. A zero `spread` traces a ray; the pre-pass traces a cone. */ const march = (o: d.v3f, dir: d.v3f, start: number, stop: number, spread: number) => { 'use gpu'; const widen = d.f32(spread); let t = d.f32(start); let level = d.i32(TOP); let steps = d.u32(0); let hit = false; for (let i = 0; i < STEPS; i++) { if (t > stop) break; steps += 1; const p = o + dir * t; const s = VOXEL * std.exp2(d.f32(level)); const dist = clay.sdf(p, d.f32(level)) - t * widen; if (level > 0) { if (dist < s * 0.5) { level -= STRIDE; continue; } } else if (dist < 0.0006 * t + VOXEL * 0.02) { hit = true; break; } t += dist; if (level < TOP && dist > s * (BAND - 0.5)) level += STRIDE; } return d.vec3f(t, d.f32(hit), d.f32(steps)); }; const cone = pixelKernel(function cone({ gid }) { 'use gpu'; const tiles = (view.$.size + TILE - 1) / TILE; if (gid.x >= tiles.x || gid.y >= tiles.y) return; const ray = rayAt((d.vec2f(gid.xy) + 0.5) * TILE); const span = boxHit(ray.origin, ray.direction); let start = d.f32(0); let wet = d.f32(0); if (span.x < span.y) { const spread = pixelSpread() * TILE * 0.75; start = march(ray.origin, ray.direction, span.x, span.y, spread).x; // The same cone through the water's tile gaps, so water rays skip the dry tiles too wet = span.x; for (let i = 0; i < 48; i++) { if (wet > span.y) break; const gap = water.fluidGapAt(ray.origin + ray.direction * wet) - wet * spread; if (gap < CELL * 0.5) break; wet += gap; } } std.textureStore(coneLayout.$.cone, gid.xy, d.vec4f(start, wet, 0, 0)); }); const traceWorld = pixelKernel(function traceWorld({ gid }) { 'use gpu'; if (gid.x >= view.$.size.x || gid.y >= view.$.size.y) return; const ray = rayAt(d.vec2f(gid.xy) + 0.5); const span = boxHit(ray.origin, ray.direction); let depth = d.f32(FAR); let packed = d.vec4f(0.5, 1, 0.5, 0); if (span.x < span.y) { const start = std.max(span.x, std.textureLoad(worldLayout.$.cone, gid.xy / TILE, 0).x); const traced = march(ray.origin, ray.direction, start, span.y, 0); if (traced.y > 0) { depth = traced.x; // The SDF is hot in cache right here, so the normal is cheapest now packed = packNormal(smoothNormalAt(ray.origin + ray.direction * depth)); } } // The cursor's pixel leaves its hit on the clay for next frame's brush if (gid.x === d.u32(view.$.cursor.x) && gid.y === d.u32(view.$.cursor.y)) { const hit = depth < FAR; clay.pick.$.hit = d.u32(hit); clay.pick.$.eye = d.vec3f(ray.origin); clay.pick.$.direction = d.vec3f(ray.direction); if (hit) clay.pick.$.position = ray.origin + ray.direction * depth; } // The sea, where it lies nearer than the clay; the normal's alpha marks it const sea = seaHit(ray.origin, ray.direction); if (sea > 0 && sea < depth) { depth = sea; packed = d.vec4f(0.5, 1, 0.5, 1); } std.textureStore(worldLayout.$.depth, gid.xy, d.vec4f(depth, 0, 0, 0)); std.textureStore(worldLayout.$.normal, gid.xy, packed); }); /** Traces the water: its tiles skip empty space, its cells find the surface. */ const traceFluid = pixelKernel(function traceFluid({ gid }) { 'use gpu'; if (gid.x >= view.$.size.x || gid.y >= view.$.size.y) return; const ray = rayAt(d.vec2f(gid.xy) + 0.5); const span = boxHit(ray.origin, ray.direction); const stop = std.min(span.y, std.textureLoad(fluidLayout.$.depth, gid.xy, 0).x); let depth = d.f32(FAR); let packed = d.vec4f(0.5, 1, 0.5, 0); if (span.x < stop) { let t = std.max(span.x, std.textureLoad(fluidLayout.$.cone, gid.xy / TILE, 0).y); for (let i = 0; i < FLUID_STEPS; i++) { if (t > stop) break; const p = ray.origin + ray.direction * t; // Tiles away from water skip by their gap. The fine field is only rebuilt near water, so // it is only sampled where the gap says water is close const gap = water.fluidGapAt(p); if (gap >= CELL * 0.5) { t += gap; continue; } const dist = water.fluidAt(p); if (dist < 0.002 * t + 0.004) { depth = t; break; } t += dist; } if (depth < FAR) packed = packNormal(water.fluidNormalAt(ray.origin + ray.direction * depth)); } std.textureStore(fluidLayout.$.fluidDepth, gid.xy, d.vec4f(depth, 0, 0, 0)); std.textureStore(fluidLayout.$.fluidNormal, gid.xy, packed); }); /** * Soft sun shadow: a cone towards the sun through coarser mips as it widens. Its width never * narrows below `PENUMBRA_FLOOR`, so the 8-bit field's steps along a slope the ray grazes * don't darken the slope itself. */ const sunThrough = (o: d.v3f) => { 'use gpu'; const sun = shading.towardsSun(); const span = boxHit(o, sun); const exit = std.select(d.f32(-1), span.y, span.x < span.y); let lit = d.f32(1); let t = std.max(d.f32(VOXEL), span.x); for (let i = 0; i < 40; i++) { if (t > exit) break; const p = o + sun * t; let dist = d.f32(0); if (t < 1.2) dist = clay.sdf(p, 0); else if (t < 8) dist = clay.sdf(p, 2) + DILATION_2; else dist = clay.sdf(p, 4) + DILATION_4; lit = std.min(lit, (dist * PENUMBRA) / std.max(t, PENUMBRA_FLOOR)); if (lit < 0.01) break; t += std.max(dist, 0.04 + t * 0.04); } return std.smoothstep(0, 1, lit); }; /** Ambient openness from four taps along the normal, wider taps on coarser mips. */ const openAt = (p: d.v3f, n: d.v3f) => { 'use gpu'; let shut = std.max(0.12 - clay.sdf(p + n * 0.12, 0), 0) / 0.12; shut += (std.max(0.3 - clay.sdf(p + n * 0.3, 0), 0) / 0.3) * 0.8; shut += (std.max(0.8 - clay.sdf(p + n * 0.8, 2) - DILATION_2, 0) / 0.8) * 0.6; shut += (std.max(2 - clay.sdf(p + n * 2, 4) - DILATION_4, 0) / 2) * 0.45; return std.saturate(1 - shut * 0.35); }; /** * Shadow and occlusion in one half-resolution pass, the only one after the trace that reads the * SDF. They share the depth and normal loads and the ray rebuild. */ const shadowKernel = pixelKernel(function shadowKernel({ gid }) { 'use gpu'; const half = std.textureDimensions(shadowLayout.$.shadow); if (gid.x >= half.x || gid.y >= half.y) return; // Each texel lights the top-left pixel of its 2 × 2 block const pixel = std.min(gid.xy * 2, view.$.size - 1); const depth = std.textureLoad(shadowLayout.$.depth, pixel, 0).x; let reached = d.vec2f(1, 1); if (depth < FAR) { const ray = rayAt(d.vec2f(pixel) + 0.5); const n = unpackNormal(std.textureLoad(shadowLayout.$.normal, pixel, 0)); const p = ray.origin + ray.direction * depth; if (choices.$.shadows === 1) reached.x = sunThrough(p + n * (VOXEL * 2)); if (choices.$.ao === 1) reached.y = openAt(p, n); } std.textureStore(shadowLayout.$.shadow, gid.xy, d.vec4f(reached, 0, 1)); }); const noiseAt = (p: d.v3f) => { 'use gpu'; return std.textureSampleLevel(noiseIn.$, noiseSampler.$, p / NOISE_PERIOD, 0).x * 2 - 1; }; /** Bands of clay colour by height, wobbled by noise, that a dig cuts through. */ const strataOf = (p: d.v3f) => { 'use gpu'; const wobble = noiseAt(p * 0.35) * 0.9; const band = std.fract((p.y + wobble) * 0.45) * 5; const i = d.u32(band); const f = std.smoothstep(0.82, 1, std.fract(band)); let a = d.vec3f(STRATA[0]!); let b = d.vec3f(STRATA[1]!); if (i === 1) { a = d.vec3f(STRATA[1]!); b = d.vec3f(STRATA[2]!); } else if (i === 2) { a = d.vec3f(STRATA[2]!); b = d.vec3f(STRATA[3]!); } else if (i === 3) { a = d.vec3f(STRATA[3]!); b = d.vec3f(STRATA[4]!); } else if (i >= 4) { a = d.vec3f(STRATA[4]!); b = d.vec3f(STRATA[0]!); } return std.mix(a, b, f); }; /** * What the clay is at a hit: topsoil, rock, dug strata or built clay, blended across * `MATERIAL_BLEND` at each boundary so the edges between them stay smooth. The spring's mouth * glows. */ const clayAt = (p: d.v3f, occlusion: number) => { 'use gpu'; const origin = clay.originAt(p); const drift = noiseAt(p * 0.4) * 0.5 + 0.5; let albedo = std.mix( TOPSOIL_LOW, TOPSOIL_HIGH, std.saturate((p.y - 3) * 0.5 + drift * 0.5 - 0.25), ); let roughness = d.f32(ROUGHNESS.clay); const dug = std.smoothstep(-MATERIAL_EDGE, -MATERIAL_EDGE - MATERIAL_BLEND, origin.x); if (dug > 0) albedo = std.mix(albedo, strataOf(p), dug); const built = std.smoothstep(MATERIAL_EDGE, MATERIAL_EDGE + MATERIAL_BLEND, origin.x); albedo = std.mix(albedo, BUILT, built); roughness = std.mix(roughness, ROUGHNESS.built, built); const rock = std.smoothstep( ROCK_EDGE + MATERIAL_BLEND / 2, ROCK_EDGE - MATERIAL_BLEND / 2, origin.y, ); albedo = std.mix(albedo, ROCK * (0.92 + noiseAt(p * 0.5) * 0.12), rock); roughness = std.mix(roughness, ROUGHNESS.rock, rock); const glow = std.smoothstep(0.9, 0.3, std.length(p - SPRING_AT)) * 0.6; return shading.Material({ color: albedo, roughness, emission: d.vec3f(0.3, 0.9, 1.4) * glow, metallic: 0, occlusion, }); }; /** * The brush ring around the cursor's hit: faint while hovering, warm while digging, blue while * building, around the ground the clay rises from. */ const ringOver = (color: d.v3f, p: d.v3f) => { 'use gpu'; const mode = view.$.mode; const building = mode === 2 && clay.aim.$.valid === 1; let ringed = d.vec3f(color); if (building || (mode > 0 && clay.pick.$.hit === 1)) { let centre = d.vec3f(clay.pick.$.position); if (building) centre = d.vec3f(clay.aim.$.ground); const edge = std.smoothstep(0.06, 0, std.abs(std.length(p - centre) - choices.$.radius)); let tint = d.vec3f(1.4, 1.2, 1.1); let strength = d.f32(0.8); if (mode === 2) tint = d.vec3f(0.6, 1.1, 1.6); if (mode === 3) strength = 0.35; ringed = std.mix(color, tint, edge * strength); } return ringed; }; /** * The half-resolution light at a full-resolution pixel: bilinear over the four nearest texels, * each weighted down when the pixel it lit sits at a different depth, so shadow and occlusion * don't bleed across silhouettes. */ const shadowAt = (pixel: d.v2u, depth: number) => { 'use gpu'; const half = d.vec2i(std.textureDimensions(paintLayout.$.shadow)); const at = (d.vec2f(pixel) + 0.5) * 0.5 - 0.5; const base = d.vec2i(std.floor(at)); const f = at - std.floor(at); const tolerance = 0.05 + depth * 0.01; let sum = d.vec2f(); let weight = d.f32(0); for (let j = 0; j < 2; j++) { for (let i = 0; i < 2; i++) { const q = std.clamp(base + d.vec2i(i, j), d.vec2i(), half - 1); const lit = std.min(d.vec2u(q) * 2, view.$.size - 1); const apart = std.abs(std.textureLoad(paintLayout.$.depth, lit, 0).x - depth); const wx = std.select(1 - f.x, f.x, i === 1); const wy = std.select(1 - f.y, f.y, j === 1); const w = wx * wy * std.exp2((-apart / tolerance) * 4) + 1e-5; sum += std.textureLoad(paintLayout.$.shadow, q, 0).xy * w; weight += w; } } return sum / weight; }; /** The swell's height at `p`, from the baked noise. */ const swellAt = (p: d.v3f) => { 'use gpu'; const at = d.vec3f(p.x, 0, p.z) * 0.14; return noiseAt(at) + noiseAt(at * 3.1 + 5) * 0.4; }; /** * The sea at `p`: its body green over the shallows by the island and deep offshore, foam at the * shore, the sky and the sun's glint on its swell, and the island's shadow across it. */ const seaAt = (p: d.v3f, sun: number) => { 'use gpu'; const e = 0.15; const h = swellAt(p); // The swell flattens with distance, where its waves are finer than a pixel const far = std.length(p - cameras.current.$.position); const calm = e * (4 + far * 0.25); const n = std.normalize( d.vec3f(h - swellAt(p + d.vec3f(e, 0, 0)), calm, h - swellAt(p + d.vec3f(0, 0, e))), ); const offshore = std.length(p.xz - WORLD.xz * 0.5) - SHORE; const body = std.mix(SEA_SHALLOW, SEA_DEEP, std.saturate(offshore / 6)); const foam = std.smoothstep(0.45, 0, offshore) * (0.4 + h * 0.25); sunLit.$ = sun; const waves = shading.Material({ color: std.mix(body, d.vec3f(0.7), foam * 0.7), roughness: std.mix(0.08, 0.6, foam), emission: d.vec3f(), metallic: 0, occlusion: 1, }); return shading.shade(waves, shading.pointAt(p, n)); }; const paintKernel = pixelKernel(function paintKernel({ gid }) { 'use gpu'; if (gid.x >= view.$.size.x || gid.y >= view.$.size.y) return; const ray = rayAt(d.vec2f(gid.xy) + 0.5); const dir = ray.direction; const depth = std.textureLoad(paintLayout.$.depth, gid.xy, 0).x; let color = skyOf(dir); const packedNormal = std.textureLoad(paintLayout.$.normal, gid.xy, 0); if (depth < FAR && packedNormal.w > 0.5) { color = seaAt(ray.origin + dir * depth, shadowAt(gid.xy, depth).x); } else if (depth < FAR) { const p = ray.origin + dir * depth; const n = unpackNormal(packedNormal); const lit = shadowAt(gid.xy, depth); sunLit.$ = lit.x; color = ringOver(shading.shade(clayAt(p, lit.y), shading.pointAt(p, n)), p); } const top = std.textureLoad(paintLayout.$.fluidDepth, gid.xy, 0).x; if (top < depth) { const n = unpackNormal(std.textureLoad(paintLayout.$.fluidNormal, gid.xy, 0)); const thick = std.min(depth - top, 4); const through = std.exp(ABSORB * -thick); const point = shading.pointAt(ray.origin + dir * top, n); // A body colour that holds even in a thin film, deeper where thicker const lit = 0.55 + 0.45 * std.saturate(std.dot(n, shading.towardsSun())); const opacity = std.saturate(0.45 + (1 - through.y) * 0.6); const tint = std.mix(WATER, DEEP, std.saturate(thick * 0.8)); let body = std.mix(color * through, tint * lit * 1.15, opacity); // Foam where the water thins over the clay body = std.mix(body, FOAM * lit, std.smoothstep(0.1, 0.02, thick) * 0.6); // The surface reflects the sky and the sun's glint, and lets through what it doesn't reflect const facing = std.saturate(std.dot(n, point.view)); const reflected = shading.reflectance(d.vec3f(0.04), ROUGHNESS.water, facing); sunLit.$ = 1; const film = shading.Material({ color: d.vec3f(), roughness: ROUGHNESS.water, emission: body * (1 - reflected), metallic: 0, occlusion: 1, }); color = shading.shade(film, point); } std.textureStore(paintLayout.$.hdr, gid.xy, d.vec4f(color, 1)); }); const piped = root.pipe(camera.inject()).pipe(light.inject()).pipe(sky.inject()); const coning = piped.createComputePipeline({ compute: cone }); const tracing = piped.createComputePipeline({ compute: traceWorld }); const watering = piped.createComputePipeline({ compute: traceFluid }); const shadowing = piped.createComputePipeline({ compute: shadowKernel }); const painting = piped.createComputePipeline({ compute: paintKernel }); let groups = groupsFor(); return { camera, view, choices, hdr: () => screen.textures.hdr, /** Bakes the morning sky into the environment. Call once, before the first `record`. */ bake: (encoder: TgpuCommandEncoder) => sky.bake(encoder, daylight), /** Rebuilds bind groups when the targets were replaced. Call once a frame before `record`. */ fit() { if (screen.fit()) groups = groupsFor(); return screen; }, /** The cone pre-pass, the two traces, the shadow pass and the material pass. */ record(pass: TgpuComputePass) { const pixels = [Math.ceil(screen.width / 8), Math.ceil(screen.height / 8)] as const; const tiles = [Math.ceil(pixels[0] / TILE), Math.ceil(pixels[1] / TILE)] as const; pass.setPipeline(coning); pass.setBindGroup(groups.cone); pass.dispatchWorkgroups(...tiles); pass.setPipeline(tracing); pass.setBindGroup(groups.world); pass.dispatchWorkgroups(...pixels); pass.setPipeline(watering); pass.setBindGroup(groups.fluid); pass.dispatchWorkgroups(...pixels); pass.setPipeline(shadowing); pass.setBindGroup(groups.shadow); pass.dispatchWorkgroups(Math.ceil(screen.width / 16), Math.ceil(screen.height / 16)); pass.setPipeline(painting); pass.setBindGroup(groups.paint); pass.dispatchWorkgroups(...pixels); }, initAsync: () => Promise.all([ sky.initAsync(), ...[coning, tracing, watering, shadowing, painting].map((p) => p.initAsync()), ]), destroy: own.destroy, };}export type Render = ReturnType<typeof createRender>;ui.ts
import { buttons } from '../ui.ts';export const ui = { controls: buttons( { build: { label: 'build', keys: ['ShiftLeft', 'ShiftRight'], held: true, latch: true }, }, { right: [['build']] }, ),};water.ts
import { owner } from '@typegpu/kit';import * as grid from '@typegpu/kit/grid';import { randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuCommandEncoder, type TgpuComputePass, type TgpuRoot,} from 'typegpu';import { decode, DIMS, encode, tetrahedralNormal, WORLD, type Clay, type Formats } from './clay.ts';import { SPRING } from './island.ts';/** SPH cell edge, which is also the kernel radius and the fluid surface voxel. */export const CELL = 0.2;/** Particle pool size. */export const POOL = 1 << 16;/** Seconds per simulation step. */export const STEP = 1 / 120;/** Where new particles appear. */const SPRING_AT = d.vec3f(...SPRING.at);const GRID = [DIMS[0] / 2, DIMS[1] / 2, DIMS[2] / 2] as const;const GRAVITY = 9.8;/** Rest density in kernel units, and the stiffness of far and near pressure. */const REST = 5;const STIFFNESS = 120;const NEAR = 260;/** Linear and quadratic viscosity. */const SIGMA = 0.3;const BETA = 0.05;/** Collision radius against the clay, and the tangential friction there. */const RADIUS = 0.05;const FRICTION = 0.02;/** Fluid surface: blob radius and smooth-union blend, in metres. */const BLOB = 0.11;const BLEND = 0.12;/** Largest pressure correction a particle takes in one step, in metres. */const MAX_SHIFT = CELL * 0.25;/** Speed limit in metres per second, about a fall from three metres. */const MAX_SPEED = 8;/** * Particle spacing at rest density: REST counts (1 - r / CELL)² over neighbours, which a uniform * density n integrates to n · 4π CELL³ / 30. */const SPACING = Math.cbrt((4 * Math.PI * CELL ** 3) / 30 / REST);/** Places on the spout, one per spacing² of its disc. */const SPOUT = Math.round((Math.PI * SPRING.radius ** 2) / SPACING ** 2);/** Pouring speed in metres per second that moves the stream one spacing per round of the spout. */const POUR = (SPACING * SPRING.rate) / SPOUT;const GOLDEN = Math.PI * (3 - Math.sqrt(5));/** * Steps a new particle takes to reach full pressure weight. One that appears inside the water fades * in, so it eases its neighbours apart instead of throwing them as droplets in one step. */const RIPEN = 12;const Particle = d.struct({ /** Position in metres; w is the pressure weight, which grows from 0 to 1 after spawning. */ pos: d.vec4f, /** Velocity in metres per second; w is 1 alive, -1 dead. */ vel: d.vec4f,});/** Particles the spring adds this frame, in the frame's first step. */export const Spawn = d.struct({ count: d.u32, first: d.u32, /** Particles spawned before this frame, which numbers each new one's place on the spout. */ total: d.u32,});/** The live count and steps so far. */export const Sim = d.struct({ alive: d.u32, step: d.u32,});const WG = grid.WORKGROUP;/** Cells per tile edge: a tile is one of the grid's blocks and one coarse fluid voxel. */const T = grid.BLOCK;const TILES = [GRID[0] / T, GRID[1] / T, GRID[2] / T] as const;/** Coarse fluid voxel: one per tile. */const COARSE = CELL * T;/** Tiles the coarse fluid distance searches in each direction. */const SEARCH = 3;const inGrid = (c: d.v3i) => { 'use gpu'; return std.all(std.ge(c, d.vec3i())) && c.x < GRID[0] && c.y < GRID[1] && c.z < GRID[2];};const cellOf = (p: d.v3f) => { 'use gpu'; return d.vec3i(std.floor(p / CELL));};const kernelWeight = (r: d.v3f) => { 'use gpu'; return std.max(1 - std.length(r) / CELL, 0);};/** * Water as particles kept in grid order. Each step sorts the particles into `@typegpu/kit/grid`, * then solves pressure with double density relaxation (Clavet et al. 2005) over sorted indices, * where neighbours sit next to each other in memory. The grid lists the tiles holding water, so no * pass touches the whole box. */export function createWater(root: TgpuRoot, clay: Clay, formats: Formats) { const own = owner(); const spawn = root.createUniform(Spawn); /** Particles in slot order, predicted by `integrate`; `relax` writes them back in sorted order. */ const state = own(root.createBuffer(d.arrayOf(Particle, POOL)).$usage('storage')); /** Each particle's far and near pressure as an f16 pair, by sorted index. */ const pressure = own(root.createBuffer(d.arrayOf(d.u32, POOL)).$usage('storage')); const sim = root.createMutable(Sim); const stateRW = state.as('mutable'); const stateIn = state.as('readonly'); const pressureRW = pressure.as('mutable'); const pressureIn = pressure.as('readonly'); // The fluid surface, ray traced like the clay: a fine level per cell, a coarse one per tile const { distance } = formats; const surface = own( root .createTexture({ size: [...GRID], format: distance, dimension: '3d', mipLevelCount: 3 }) .$usage('sampled', 'storage'), ); const surfaceFine = surface.createView(d.textureStorage3d(distance, 'write-only'), { baseMipLevel: 0, mipLevelCount: 1, }); const surfaceCoarse = surface.createView(d.textureStorage3d(distance, 'write-only'), { baseMipLevel: 2, mipLevelCount: 1, }); const surfaceSampled = surface.createView(d.texture3d(d.f32)); /** Spawns only in the frame's first step; every step reads the same uniform. */ const end = () => { 'use gpu'; let born = d.u32(0); if (sim.$.step === spawn.$.first) born = spawn.$.count; return std.min(sim.$.alive + born, POOL); }; const cells = own( grid.create(root, { capacity: POOL, low: d.vec3f(), size: WORLD, sight: CELL, // The surface is rebuilt in tiles within two of water. Rays only take fine samples in tiles // within one of water, and their filtered taps reach one tile further margin: 2, item: Particle, alive: (i) => { 'use gpu'; return i < end() && stateIn.$[i]!.vel.w > 0; }, position: (i) => { 'use gpu'; return d.vec3f(stateIn.$[i]!.pos.xyz); }, itemOf: (i) => { 'use gpu'; return Particle(stateIn.$[i]!); }, }), ); const particleKernel = tgpu.computeFn({ workgroupSize: [WG], in: { gid: d.builtin.globalInvocationId }, }); /** Spawns, integrates gravity and predicts positions, in slot order. */ const integrate = particleKernel(function integrate({ gid }) { 'use gpu'; const i = gid.x; if (i >= end()) return; let p = d.vec3f(); let v = d.vec3f(); let weight = d.f32(0); if (i < sim.$.alive) { const q = Particle(stateRW.$[i]!); if (q.vel.w < 0) return; p = d.vec3f(q.pos.xyz); v = d.vec3f(q.vel.xyz); weight = std.min(q.pos.w + 1 / RIPEN, 1); } else { // Places on a sunflower spiral across the spout, at rest spacing, taken in turn. The stream // leaves fast enough that a place is clear by the time it comes round again const place = d.f32((spawn.$.total + i - sim.$.alive) % SPOUT); const out = SPRING.radius * std.sqrt((place + 0.5) / SPOUT); const angle = place * GOLDEN; randf.seed2(d.vec2f(d.f32(i), d.f32(sim.$.step))); p = SPRING_AT + d.vec3f(std.cos(angle) * out, 0, std.sin(angle) * out); p += randf.inUnitSphere() * (SPACING * 0.1); v = d.vec3f(0, -POUR, 0); } v.y -= GRAVITY * STEP; p += v * STEP; const alive = std.select(d.f32(-1), d.f32(1), inGrid(cellOf(p))); stateRW.$[i] = Particle({ pos: d.vec4f(p, weight), vel: d.vec4f(v, alive) }); }); /** Counts the particles the sort kept alive, and the step. */ const recount = tgpu.computeFn({ workgroupSize: [1] })(() => { 'use gpu'; sim.$.alive = cells.count(); sim.$.step += 1; }); /** Whether tile `t` holds water. None does after a reset, before a step sorts again. */ const held = (t: d.v3i) => { 'use gpu'; return sim.$.alive > 0 && cells.holds(t); }; /** Tiles holding water in and around the surface kernel's tile. */ const tally = tgpu.workgroupVar(d.atomic(d.u32)); /** Density and near density from the nine rows of cells around each sorted particle. */ const density = particleKernel(function density({ gid }) { 'use gpu'; const k = gid.x; if (k >= cells.count()) return; const p = cells.neighbourAt(k).pos.xyz; let rho = d.f32(0); let near = d.f32(0); const from = cells.around(p); for (let r = d.u32(0); r < grid.RANGES; r++) { const range = cells.range(from, r); for (let j = d.u32(0); j < range.count; j++) { const other = cells.neighbour(range, j).pos; const q = kernelWeight(other.xyz - p); rho += q * q * other.w; near += q * q * q * other.w; } } pressureRW.$[k] = std.pack2x16float(d.vec2f(STIFFNESS * (rho - REST), NEAR * near)); }); /** Relaxes positions apart by pressure, adds viscosity and collides with the clay SDF. */ const relax = particleKernel(function relax({ gid }) { 'use gpu'; const k = gid.x; if (k >= cells.count()) return; const me = cells.neighbourAt(k); const p = me.pos.xyz; let v = d.vec3f(me.vel.xyz); const mine = std.unpack2x16float(pressureIn.$[k]!); // One accumulator for both terms. Pressure displaces by dt² / 2; viscosity changes velocity by // dt / 2, which moves the particle that much times dt, as in Clavet's prediction step. The // velocity update below divides by dt, so it gets both back const half2 = STEP * STEP * 0.5; let shift = d.vec3f(); const from = cells.around(p); for (let r = d.u32(0); r < grid.RANGES; r++) { const range = cells.range(from, r); for (let j = d.u32(0); j < range.count; j++) { const index = cells.index(range, j); if (index === k) continue; const other = cells.neighbourAt(index); const offset = other.pos.xyz - p; const dist = std.length(offset); if (dist >= CELL || dist < 1e-5) continue; const q = 1 - dist / CELL; const dir = offset / dist; const theirs = std.unpack2x16float(pressureIn.$[index]!); const ripe = std.min(me.pos.w, other.pos.w); const push = ((mine.x + theirs.x) * q + (mine.y + theirs.y) * q * q) * ripe; const u = std.max(std.dot(v - other.vel.xyz, dir), 0); shift -= dir * ((push + q * (SIGMA * u + BETA * u * u)) * half2); } } // Clay pressed into the water can still overlap particles; capping the correction and the // speed keeps that from flinging droplets across the island const moved = std.length(shift); if (moved > MAX_SHIFT) shift *= MAX_SHIFT / moved; let next = p + shift; v += shift / STEP; const speed = std.length(v); if (speed > MAX_SPEED) v *= MAX_SPEED / speed; const reach = clay.sdf(next, 0); if (reach < RADIUS) { const normal = clay.normalAt(next); next += normal * (RADIUS - reach); const into = std.dot(v, normal); if (into < 0) v -= normal * into; v -= (v - normal * std.dot(v, normal)) * FRICTION; } // Water that falls off the island dies under it let alive = d.f32(1); if (next.y < 0.45 || std.any(std.lt(next, d.vec3f())) || std.any(std.ge(next, WORLD))) { alive = -1; } stateRW.$[k] = Particle({ pos: d.vec4f(next, me.pos.w), vel: d.vec4f(v, alive) }); }); /** * The fluid surface at each cell centre of a listed tile: a smooth union of particle blobs. * Voxels of unlisted tiles keep stale values, which no ray reads. */ const surfaceKernel = tgpu.computeFn({ workgroupSize: [WG], in: { wid: d.builtin.workgroupId, lid: d.builtin.localInvocationIndex }, })(({ wid, lid }) => { 'use gpu'; const t = d.vec3u(cells.block(wid.x)); const voxel = t * T + d.vec3u(lid & 3, (lid >>> 2) & 3, lid >>> 4); const p = (d.vec3f(voxel) + 0.5) * CELL; // Particles outside the 27 cells are at least 1.5 cells from the centre let dist = d.f32(1.5 * CELL - BLOB); // With no water in this tile or its neighbours, there is nothing to gather. 27 threads each // test one neighbouring tile, so the check costs one load a thread if (lid < 27) { const o = d.vec3i(t) + d.vec3i(d.i32(lid % 3), d.i32((lid / 3) % 3), d.i32(lid / 9)) - 1; if (held(o)) std.atomicAdd(tally.$, 1); } std.workgroupBarrier(); if (std.atomicLoad(tally.$) === 0) { std.textureStore(surfaceFine.$, voxel, d.vec4f(encode(dist, CELL), 0, 0, 1)); return; } const from = cells.around(p); for (let r = d.u32(0); r < grid.RANGES; r++) { const range = cells.range(from, r); for (let j = d.u32(0); j < range.count; j++) { const blob = std.length(stateIn.$[cells.index(range, j)]!.pos.xyz - p) - BLOB; const h = std.max(BLEND - std.abs(dist - blob), 0) / BLEND; dist = std.min(dist, blob) - h * h * BLEND * 0.25; } } std.textureStore(surfaceFine.$, voxel, d.vec4f(encode(dist, CELL), 0, 0, 1)); }); /** Tile occupancy for a 4 × 4 × 4 block of tiles and its search margin, staged once. */ const BLOCK = 4 + 2 * SEARCH; const occupied = tgpu.workgroupVar(d.arrayOf(d.u32, BLOCK * BLOCK * BLOCK)); /** * One coarse voxel per tile: the gap from this tile's box to the nearest tile holding water, less * a cell for blobs that reach over a tile's edge. Read with textureLoad, not filtered, so the gap * holds anywhere inside the tile. The workgroup stages its block's occupancy in shared memory, so * the 343-tile search reads no buffers. */ const coarseKernel = tgpu.computeFn({ workgroupSize: [4, 4, 4], in: { gid: d.builtin.globalInvocationId, wid: d.builtin.workgroupId, lid: d.builtin.localInvocationIndex, local: d.builtin.localInvocationId, }, })(({ gid, wid, lid, local }) => { 'use gpu'; const corner = d.vec3i(wid * 4) - SEARCH; for (let i = lid; i < BLOCK * BLOCK * BLOCK; i += WG) { const b = d.vec3i(d.i32(i % BLOCK), d.i32((i / BLOCK) % BLOCK), d.i32(i / (BLOCK * BLOCK))); occupied.$[i] = std.select(d.u32(0), d.u32(1), held(corner + b)); } std.workgroupBarrier(); const at = d.vec3i(local) + SEARCH; let gap = d.f32(SEARCH * COARSE); for (let z = -SEARCH; z <= SEARCH; z++) { for (let y = -SEARCH; y <= SEARCH; y++) { for (let x = -SEARCH; x <= SEARCH; x++) { const b = at + d.vec3i(x, y, z); if (occupied.$[(b.z * BLOCK + b.y) * BLOCK + b.x] === 0) continue; const apart = std.max(std.abs(d.vec3f(x, y, z)) - 1, d.vec3f()) * COARSE; gap = std.min(gap, std.length(apart)); } } } gap = std.max(gap - CELL, 0); std.textureStore(surfaceCoarse.$, gid, d.vec4f(encode(gap, COARSE), 0, 0, 1)); }); const walking = root.pipe(cells.inject()); const integrating = root.createComputePipeline({ compute: integrate }); const recounting = walking.createComputePipeline({ compute: recount }); const densities = walking.createComputePipeline({ compute: density }); const relaxing = walking.createComputePipeline({ compute: relax }); const surfacing = walking.createComputePipeline({ compute: surfaceKernel }); const coarsening = walking.createComputePipeline({ compute: coarseKernel }); const pipelines = [integrating, recounting, densities, relaxing, surfacing, coarsening]; /** Fluid distance in metres at a fine sample point, filtered. */ const fluidAt = (p: d.v3f) => { 'use gpu'; const value = std.textureSampleLevel(surfaceSampled.$, clay.sampler.$, p / WORLD, 0).x; return decode(value, CELL); }; /** The conservative gap to water from anywhere in the tile around `p`. */ const fluidGapAt = (p: d.v3f) => { 'use gpu'; const t = d.vec3i( std.clamp(p / COARSE, d.vec3f(), d.vec3f(TILES[0] - 1, TILES[1] - 1, TILES[2] - 1)), ); return decode(std.textureLoad(surfaceSampled.$, t, 2).x, COARSE); }; const fluidNormalAt = tetrahedralNormal(fluidAt, CELL * 0.5); const particleGroups = POOL / WG; return { spawn, sim, fluidAt, fluidGapAt, fluidNormalAt, /** One simulation step: integrate, sort, density, relax. */ step(pass: TgpuComputePass) { pass.setPipeline(integrating); pass.dispatchWorkgroups(particleGroups); cells.sort(pass); pass.setPipeline(recounting); pass.dispatchWorkgroups(1); pass.setPipeline(densities); cells.dispatch(pass); pass.setPipeline(relaxing); cells.dispatch(pass); }, /** Rebuilds the fluid surface from the last step's tiles. */ surface(pass: TgpuComputePass) { pass.setPipeline(surfacing); cells.dispatchBlocks(pass); pass.setPipeline(coarsening); pass.dispatchWorkgroups(TILES[0] / 4, TILES[1] / 4, TILES[2] / 4); }, /** Drains every particle. Tiles read empty until a step sorts again. */ reset(encoder: TgpuCommandEncoder) { sim.buffer.clear(encoder); }, initAsync: () => Promise.all([cells.initAsync(), ...pipelines.map((p) => p.initAsync())]), destroy: own.destroy, };}export type Water = ReturnType<typeof createWater>;