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

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