Flow field
65 536 particles drift across the screen, each turning to the angle of the Perlin noise under it and glowing in the colour of its heading. The Write a module tutorial writes it as a module and a scene.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- flow-field.tsAdvects particles through Perlin noise.
Kit modules clock, owner, pipelines. Also @typegpu/noise, typegpu.
scene.ts
import { clock, owner, type Frame, type Surface } from '@typegpu/kit';import { d, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { createFlowField, defaults } from './flow-field.ts';const PARTICLES = 65_536;const BACKDROP = [0.01, 0.015, 0.03, 1] as const;/** Runs the flow field at a fixed step, seeding it in the first frame and after each `reseed`. */export async function createScene(root: TgpuRoot, surface: Surface) { const own = owner(); const sim = clock.fixedStep({ step: 1 / 60 }); const ahead = root.createUniform(d.f32); const values = { ...defaults, paused: false }; const field = own( createFlowField(root, { count: PARTICLES, step: sim.step, ahead, format: surface.format, size: surface, settings: values, }), ); await field.initAsync(); let seeding = true; let writes = 0; const settings: Settings<typeof values> = { values, knobs: { speed: { min: 0, max: 1, step: 0.01 }, scale: { min: 0.1, max: 6, step: 0.1 }, pointSize: { label: 'point size (px)', min: 0.5, max: 8, step: 0.5 }, paused: {}, }, write(key) { if (key === undefined || key === 'paused') sim.paused = values.paused; if (key === 'paused') return; field.settings.write(values); writes += 1; }, actions: { /** Schedules particle reseeding for the next frame. */ reseed() { seeding = true; }, }, readouts: { /** Counts settings-uniform writes. Frames do not write this uniform. */ 'settings writes': () => writes, }, }; return { settings, frame(frame: Frame) { const compute = frame.encoder.beginComputePass(); if (seeding) { field.seed(compute); seeding = false; } for (let steps = sim.advance(frame.dt); steps > 0; steps--) field.step(compute); ahead.write(sim.ahead); compute.end(); const pass = frame.encoder.beginRenderPass({ colorAttachments: { view: surface.view(frame), clearValue: BACKDROP }, }); field.draw(pass); pass.end(); }, destroy: own.destroy, };}flow-field.ts
import { owner, pipelines, type SizeSource } from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRenderCommands, type TgpuRoot, type TgpuUniform,} from 'typegpu';const WORKGROUP = 64;const TAU = Math.PI * 2;/** Chance per step of respawning a particle elsewhere. Prevents particles collecting in sinks. */const RESPAWN = 1 / 240;/** A random position across the canvas, in clip space. */const spawn = () => { 'use gpu'; return d.vec2f(randf.sample(), randf.sample()) * 2 - 1;};const Particle = d.struct({ position: d.vec2f, direction: d.vec2f });export const FlowSettings = d.struct({ /** Clip-space units per second. */ speed: d.f32, /** Noise cells across the height. */ scale: d.f32, /** Particle diameter in pixels. */ pointSize: d.f32,});export const defaults: d.Infer<typeof FlowSettings> = { speed: 0.2, scale: 1.5, pointSize: 2 };export interface FlowFieldOptions { readonly count: number; /** Seconds per `step`. */ readonly step: number; /** * Seconds since the last fixed step, written from `sim.ahead` each frame. Particles are drawn * that far along their direction, so they move at the display rate. Default 0. */ readonly ahead?: TgpuUniform<d.F32>; readonly format: GPUTextureFormat; /** What the particles are drawn into, such as the canvas surface. */ readonly size: SizeSource; /** Initial values for speed, scale and point size. Override `defaults`. */ readonly settings?: Partial<d.InferInput<typeof FlowSettings>>;}/** Advects particles through Perlin noise. Owns the buffers and records into caller passes. */export function createFlowField(root: TgpuRoot, options: FlowFieldOptions) { const { count, step, format, size } = options; const own = owner('The flow field was destroyed.'); const ahead = tgpu.accessor(d.f32); const particles = own(root.createBuffer(d.arrayOf(Particle, count)).$usage('storage')); const writable = particles.as('mutable'); const readable = particles.as('readonly'); const settings = root.createUniform(FlowSettings, { ...defaults, ...options.settings }); const viewport = root.createUniform(d.vec2f, d.vec2f(size.width, size.height)); const seedValue = root.createUniform(d.f32); const workgroups = Math.ceil(count / WORKGROUP); const seedKernel = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= count) return; randf.seed2(d.vec2f(d.f32(gid.x), seedValue.$)); writable.$[gid.x] = Particle({ position: spawn(), direction: d.vec2f() }); }); const stepKernel = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; if (gid.x >= count) return; const particle = writable.$[gid.x]; const aspect = viewport.$.x / viewport.$.y; const sample = particle.position * d.vec2f(aspect, 1) * settings.$.scale; const angle = perlin2d.sample(sample) * TAU; const direction = d.vec2f(std.cos(angle), std.sin(angle)); let position = particle.position + direction * (settings.$.speed * step); randf.seed3(d.vec3f(position, d.f32(gid.x))); if (randf.sample() < RESPAWN || std.any(std.gt(std.abs(position), d.vec2f(1)))) { position = spawn(); } writable.$[gid.x] = Particle({ position, direction }); }); const vertex = tgpu.vertexFn({ in: { vertexIndex: d.builtin.vertexIndex, instanceIndex: d.builtin.instanceIndex }, out: { position: d.builtin.position, color: d.vec3f, corner: d.vec2f }, })(({ vertexIndex, instanceIndex }) => { 'use gpu'; const particle = readable.$[instanceIndex]; const corner = d.vec2f(d.f32(vertexIndex & 1), d.f32(vertexIndex >>> 1)) * 2 - 1; const extent = (corner * settings.$.pointSize) / viewport.$; const center = particle.position + particle.direction * (settings.$.speed * ahead.$); return { position: d.vec4f(center + extent, 0, 1), color: d.vec3f(particle.direction * 0.5 + 0.5, 0.9), corner, }; }); const fragment = tgpu.fragmentFn({ in: { color: d.vec3f, corner: d.vec2f }, out: d.vec4f, })(({ color, corner }) => { 'use gpu'; const falloff = 1 - std.smoothstep(0.4, 1, std.length(corner)); return d.vec4f(color * (falloff * 0.5), falloff); }); const seedPipeline = root.createComputePipeline({ compute: seedKernel }); const stepPipeline = root.createComputePipeline({ compute: stepKernel }); const drawPipeline = root.with(ahead, options.ahead ?? 0).createRenderPipeline({ vertex, fragment, targets: { format, blend: pipelines.blend.add }, primitive: { topology: 'triangle-strip' }, }); let width = size.width; let height = size.height; return { settings, async initAsync() { await Promise.all([ seedPipeline.initAsync(), stepPipeline.initAsync(), drawPipeline.initAsync(), ]); }, seed(pass: TgpuComputePass) { own.usable(); seedValue.write(Math.random()); pass.setPipeline(seedPipeline); pass.dispatchWorkgroups(workgroups); }, step(pass: TgpuComputePass) { own.usable(); pass.setPipeline(stepPipeline); pass.dispatchWorkgroups(workgroups); }, draw(pass: TgpuRenderCommands) { own.usable(); if (size.width !== width || size.height !== height) { width = size.width; height = size.height; viewport.write(d.vec2f(width, height)); } pass.setPipeline(drawPipeline); pass.draw(4, count); }, destroy: own.destroy, [Symbol.dispose]: own.destroy, };}