Slime mould
Grow a slime mould of 400 000 agents from an empty canvas into glowing threads that find food, with no shader or TypeGPU experience.
Build the slime mould example with 400 000 agents on a dish of agar. Each agent represents a speck of Physarum slime mould. It senses the trail ahead, turns toward the strongest trail, steps forward and leaves more trail. Each agent reads only its surroundings. Together they grow glowing threads that connect the food on the dish.
The GPU repeats the same small job for thousands of agents at once. You write that job in TypeScript, and TypeGPU turns it into a shader, a program the GPU runs. You need a browser with WebGPU. You need no experience with shaders or TypeGPU.
-
Pour the agar.
Start with two files.
index.tsgives the scene a canvas and runs its frames.scene.tsbuilds what you draw and records each frame.index.ts import * asweb from '@typegpu/kit/web';import webimport {createScene } from './scene.ts';function createScene(root: TgpuRoot, surface: Surface, options?: SceneOptions): Promise<{settings: Settings<{count: number;sensorAngle: number;sensorDistance: number;turn: number;speed: number;deposit: number;doubling: number;endurance: number;decay: number;blur: number;food: number;light: number;pulse: number;glow: number;paused: boolean;}>;pointing: Pointing;frame(frame: Frame): void;destroy: () => void;}>A Physarum slime mould whose agents follow and lay a trail, fed by oats a tap drops.
web.import webhost(function host<{settings: Settings<{count: number;sensorAngle: number;sensorDistance: number;turn: number;speed: number;deposit: number;doubling: number;endurance: number;decay: number;blur: number;food: number;light: number;pulse: number;glow: number;paused: boolean;}>;pointing: Pointing;frame(frame: Frame): void;destroy: () => void;}>(createScene: web.CreateScene<Record<never, never>, {settings: Settings<{count: number;sensorAngle: number;sensorDistance: number;turn: number;speed: number;deposit: number;doubling: number;endurance: number;decay: number;blur: number;food: number;light: number;pulse: number;glow: number;paused: boolean;}>;pointing: Pointing;frame(frame: Frame): void;destroy: () => void;}>, hosting: web.HostOptions<...>): web.Hosted<...> (+1 overload)Runs a scene on a canvas with a root, surface, frame loop and animation.
destroytears them down in order. Usesendto control the scene oronFrameto read it. If scene options are optional, callhost(createScene, { canvas }). Throws if bothdeviceandrootare supplied.createScene, {function createScene(root: TgpuRoot, surface: Surface, options?: SceneOptions): Promise<{settings: Settings<{count: number;sensorAngle: number;sensorDistance: number;turn: number;speed: number;deposit: number;doubling: number;endurance: number;decay: number;blur: number;food: number;light: number;pulse: number;glow: number;paused: boolean;}>;pointing: Pointing;frame(frame: Frame): void;destroy: () => void;}>A Physarum slime mould whose agents follow and lay a trail, fed by oats a tap drops.
canvas:HostOptions<S extends Scene>.canvas: HTMLCanvasElementSupplies the canvas for the scene.
document.var document: Documentwindow.documentreturns a reference to the document contained in the window.querySelector('canvas')! });ParentNode.querySelector<"canvas">(selectors: "canvas"): HTMLCanvasElement | null (+4 overloads)Returns the first element that is a descendant of node that matches selectors.
web.hostcreates a root, a surface, your scene and a frame loop. The root is TypeGPU’s handle on the GPU. You use it to create GPU resources. The surface sizes the canvas to its place on the page. The loop calls the scene’sframeat every animation frame.framerecords GPU commands into an encoder, a list of commands to run later. Whenframereturns, the loop submits that list. The GPU runs it while the CPU moves on. Commands come in passes. A render pass draws into an image, and a compute pass runs calculations. The figure shows a larger frame, with its calls on the left and the commands the GPU receives on the right.Make the frame throw to see writes arrive while recorded commands and callbacks are dropped. Calls appear on the left and GPU execution on the right, with queue writes before the command buffer. Draw the agar first. A draw runs two functions. The vertex function places the corners of triangles on the screen. The fragment function colours each pixel they cover.
Cover the canvas with one triangle to colour every pixel.
common.fullScreenTriangleplaces its three corners so the triangle spans twice the canvas width and height. The GPU clips the part outside the canvas. It runs the fragment function for each pixel inside and gives it the pixel position.Point at a pixel to see its position and where sheetAtmaps it on the dish. Three vertices cover the canvas; the GPU clips the outside and runs fragments for the covered pixels in parallel, without a fixed order.agar.ts /** Picture size in pixels and elapsed growth time in seconds. */export constWorld =const World: d.WgslStruct<{size: d.Vec2f;time: d.F32;}>Picture size in pixels and elapsed growth time in seconds.
d.import dstruct({struct<{size: d.Vec2f;time: d.F32;}>(props: {size: d.Vec2f;time: d.F32;}): d.WgslStruct<{size: d.Vec2f;time: d.F32;}>export structCreates a struct schema that can be used to construct GPU buffers. Ensures proper alignment and padding of properties (as opposed to a
d.unstructschema). The order of members matches the passed in properties object.size:size: d.Vec2fd.import dvec2f,const vec2f: d.Vec2ftime:time: d.F32d.import df32 });const f32: d.F32export f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
/** Converts an sRGB hex colour, such as 0xc0301c, to linear light. */export consthex = (const hex: (rgb: number) => d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
rgb: number) =>rgb: numbersrgbToLinear(function srgbToLinear(rgb: d.v3f): d.v3fhexToRgb(function hexToRgb(hex: string | number): d.v3frgb));rgb: numberconstAGAR =const AGAR: d.v3fhex(0x080b10);const hex: (rgb: number) => d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
constDEEP =const DEEP: d.v3fhex(0x030406);const hex: (rgb: number) => d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
/*** Sheet coordinates have (0, 0) at the picture centre, with y pointing down. One unit is half* the picture's longer side. The mould grid covers -1 to 1 on both axes, so on a wide picture its* top and bottom run past the edges.*/export constsheetAt = (const sheetAt: (pixel: d.v2f, size: d.v2f) => d.v2fSheet coordinates have (0, 0) at the picture centre, with y pointing down. One unit is half the picture's longer side. The mould grid covers -1 to 1 on both axes, so on a wide picture its top and bottom run past the edges.
pixel:pixel: d.v2fd.import dv2f,export v2fInterface representing its WGSL vector type counterpart: vec2f or vec2. A vector with 2 elements of type f32
size:size: d.v2fd.import dv2f) => {export v2fInterface representing its WGSL vector type counterpart: vec2f or vec2. A vector with 2 elements of type f32
'use gpu';return ((pixel -pixel: d.v2fsize * 0.5) * 2) /size: d.v2fstd.import stdmax(function max(fst: number, ...rest: number[]): number (+1 overload)export maxsize.size: d.v2fx,v2f.x: numbersize.size: d.v2fy);v2f.y: number};// Inside createAgar(root, world, format)constground =const ground: TgpuFragmentFn<{}, d.Vec4f>tgpu.const tgpu: {const: {<TDataType extends d.AnyData>(dataType: TDataType, value: d.InferGPU<TDataType>): TgpuConst<TDataType>;<TElement extends d.AnyWgslData>(dataType: (elementCount: number) => d.WgslArray<TElement>, value: d.InferGPU<d.WgslArray<TElement>>): TgpuConst<d.WgslArray<TElement>>;};fn: {<Args extends d.BaseData[] | []>(argTypes: Args, returnType?: undefined): TgpuFnShell<Args, d.Void>;<Args extends d.BaseData[] | [], Return extends d.BaseData>(argTypes: Args, returnType: Return): TgpuFnShell<Args, Return>;<T extends AnyFn>(inner: T): TgpuGenericFn<T>;};... 17 more ...;'~unstable': typeof import("../../node_modules/typegpu/tgpuUnstable");}fragmentFn({fragmentFn: <{pixel: d.BuiltinPosition;}, d.Vec4f>(options: {in: {pixel: d.BuiltinPosition;};out: d.Vec4f;}) => TgpuFragmentFnShell<{pixel: d.BuiltinPosition;}, d.Vec4f> (+1 overload)in: {in: {pixel: d.BuiltinPosition;}pixel:pixel: d.BuiltinPositiond.import dbuiltin.const builtin: {readonly vertexIndex: d.BuiltinVertexIndex;readonly instanceIndex: d.BuiltinInstanceIndex;readonly clipDistances: d.BuiltinClipDistances;readonly position: d.BuiltinPosition;readonly frontFacing: d.BuiltinFrontFacing;readonly fragDepth: d.BuiltinFragDepth;readonly primitiveIndex: d.BuiltinPrimitiveIndex;readonly sampleIndex: d.BuiltinSampleIndex;... 11 more ...;readonly numSubgroups: d.BuiltinNumSubgroups;}export builtinposition },position: d.BuiltinPositionout:out: d.Vec4fd.import dvec4f })(({const vec4f: d.Vec4fpixel,pixel: d.v4f}) => {'use gpu';constat =const at: d.v2fsheetAt(const sheetAt: (pixel: d.v2f, size: d.v2f) => d.v2fSheet coordinates have (0, 0) at the picture centre, with y pointing down. One unit is half the picture's longer side. The mould grid covers -1 to 1 on both axes, so on a wide picture its top and bottom run past the edges.
pixel.pixel: d.v4fxy,xy: d.v2fworld.const world: TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>$.TgpuUniform<WgslStruct<{ size: Vec2f; time: F32; }>>.$: {size: d.v2f;time: number;}size);size: d.v2fconstcolor =const color: d.v3fstd.import stdmix(mix<d.v3f>(e1: d.v3f, e2: d.v3f, e3: number): d.v3f (+2 overloads)export mixAGAR,const AGAR: d.v3fDEEP,const DEEP: d.v3fstd.import stdsmoothstep(0.2, 1.1,function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothstepstd.import stdlength(length<d.v2f>(value: d.v2f): number (+1 overload)export lengthat)));const at: d.v2freturnd.import dvec4f(const vec4f: d.Vec4f(v0: AnyNumericVec3Instance, w: number) => d.v4f (+9 overloads)linearToSrgb(function linearToSrgb(linear: d.v3f): d.v3fcolor), 1);const color: d.v3f});constdrawing =const drawing: TgpuRenderPipeline<d.Vec4f>root.const root: TgpuRootcreateRenderPipeline({WithBinding.createRenderPipeline<{}, {}, {uv: d.Vec2f;}, d.Vec4f>(descriptor: TgpuRenderPipeline<in Targets = never>.DescriptorBase & {attribs?: {} | undefined;vertex: TgpuVertexFn<{}, {uv: d.Vec2f;}> | ((input: AutoVertexIn<InferGPURecord<AttribRecordToDefaultDataTypes<{}>>>) => AutoVertexOut<AnyAutoCustoms>);fragment: TgpuFragmentFn<{uv: d.Vec2f;} & Record<...>, d.Vec4f> | ((input: AutoFragmentIn<...>) => d.v4f | (AnyAutoCustoms & Partial<...>));targets?: TgpuColorTargetState;}): TgpuRenderPipeline<...> (+2 overloads)vertex:vertex: TgpuVertexFn<{}, {uv: d.Vec2f;}> | ((input: AutoVertexIn<InferGPURecord<AttribRecordToDefaultDataTypes<{}>>>) => AutoVertexOut<AnyAutoCustoms>)common.import commonfullScreenTriangle,const fullScreenTriangle: TgpuVertexFn<{}, {uv: d.Vec2f;}>export fullScreenTriangleA vertex function that defines a single full-screen triangle out of three points.
fragment:fragment: TgpuFragmentFn<{uv: d.Vec2f;} & Record<string, AnyFragmentInputBuiltin>, d.Vec4f> | ((input: AutoFragmentIn<InferGPURecord<{uv: d.Vec2f;}>>) => d.v4f | (AnyAutoCustoms & Partial<InferGPURecord<{readonly $fragDepth: d.BuiltinFragDepth;readonly $sampleMask: d.BuiltinSampleMask;}>>))ground,const ground: TgpuFragmentFn<{}, d.Vec4f>targets: {targets?: TgpuColorTargetStateformat },format?: GPUTextureFormat | undefinedThe
GPUTextureFormat
of this color target. The pipeline will only be compatible with
GPURenderPassEncoder
s which use a
GPUTextureView
of this format in the corresponding color attachment.
});constdraw = (const draw: (pass: TgpuRenderCommands) => voidpass:pass: TgpuRenderCommandsTgpuRenderCommands) => {(alias) interface TgpuRenderCommandsimport TgpuRenderCommandsThe draw-recording surface shared by render passes and render bundle encoders, mirroring
GPURenderCommandsMixin
.
Draw either by binding TypeGPU pipelines to it (
pipeline.with(pass).draw(...)), or proxy-style viapass.setPipeline(pipeline)followed bypass.draw(...). Pipeline resolution is lazy: shaders compile on the first draw.pass.pass: TgpuRenderCommandssetPipeline(TgpuRenderCommands.setPipeline(pipeline: TgpuRenderPipeline): voidSets the current
TgpuRenderPipeline
for subsequent draw calls
drawing);const drawing: TgpuRenderPipeline<d.Vec4f>pass.pass: TgpuRenderCommandsdraw(3);TgpuRenderCommands.draw(vertexCount: number, instanceCount?: number, firstVertex?: number, firstInstance?: number): void};'use gpu'marks a function that TypeGPU compiles for the GPU. It can call other such functions and the maths instd.d.vec2fandd.vec3fhold two and three numbers. The operators+,-,*and/work on those vectors inside GPU functions.pixelcounts pixels from the canvas’s top-left corner.sheetAtmoves the origin to the centre and divides by half the longer side. On a wide canvas, the dish runs from -1 to 1 horizontally. The mould lives on a square from -1 to 1 in both directions, so its top and bottom extend beyond a wide canvas.std.mix(a, b, t)interpolates fromaatt = 0tobatt = 1.std.smoothstep(low, high, x)changes smoothly from 0 to 1 asxgoes fromlowtohigh. These functions darken the agar toward its edges.worldis a uniform, a small piece of GPU memory with the same values for every pixel. Your TypeScript writes it, and a shader reads it asworld.$.- A pipeline combines a vertex function and a fragment function for drawing. Compilation takes time, so
createAgarreturnsinitAsyncalongsidedraw. The scene awaitsinitAsyncbefore its first frame.draw(3)runs the triangle’s three vertices. - Shaders add and mix colours in linear light.
hexsplits an sRGB colour, as you would pick it in an editor, into red, green and blue withhexToRgb, then converts them into linear light. The canvas displays sRGB, solinearToSrgbencodes the final colour. In step 8, the final pass handles that encoding.
The scene makes the uniform and the agar, and draws the agar in its pass:
scene.ts import type {Frame,(alias) interface Frameimport FrameRepresents one frame being recorded. Each
runcreates a separate object, so modules can compare frames by identity.Surface } from '@typegpu/kit';(alias) interface Surfaceimport SurfaceProvides a destination for frames, such as a canvas from
web.surfaceornative.surface, or a texture fromoffscreenSurfacein a test. Pass the frame toviewfor code that works with every destination.import {d, typeimport dTgpuRoot } from 'typegpu';(alias) interface TgpuRootimport TgpuRootimport {createAgar,function createAgar(root: TgpuRoot, world: TgpuUniform<typeof World>, format: GPUTextureFormat): {initAsync: () => Promise<void>;draw(pass: TgpuRenderCommands): void;}Draws the agar dish over the whole picture, darkening towards its edges.
World } from './agar.ts';const World: d.WgslStruct<{size: d.Vec2f;time: d.F32;}>Picture size in pixels and elapsed growth time in seconds.
export async functioncreateScene(function createScene(root: TgpuRoot, surface: Surface): Promise<{frame(frame: Frame): void;destroy(): void;}>root:root: TgpuRootTgpuRoot,(alias) interface TgpuRootimport TgpuRootsurface:surface: SurfaceSurface) {(alias) interface Surfaceimport SurfaceProvides a destination for frames, such as a canvas from
web.surfaceornative.surface, or a texture fromoffscreenSurfacein a test. Pass the frame toviewfor code that works with every destination.constworld =const world: TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>root.root: TgpuRootcreateUniform(TgpuRoot.createUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>(typeSchema: d.WgslStruct<{size: d.Vec2f;time: d.F32;}>, initial?: ((buffer: TgpuBuffer<NoInfer<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>>) => void) | d.InferInput<NoInfer<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>> | undefined): TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>> (+1 overload)Allocates memory on the GPU, allows passing data between host and shader. Read-only on the GPU, optimized for small data. For a general-purpose buffer, use
TgpuRoot.createBuffer
.
World, {const World: d.WgslStruct<{size: d.Vec2f;time: d.F32;}>Picture size in pixels and elapsed growth time in seconds.
size:size: d.v2fd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)surface.surface: Surfacewidth,Surface.width: numberReports the drawing-buffer size in pixels.
surface.surface: Surfaceheight),Surface.height: numbertime: 0,time: number});constagar =const agar: {initAsync: () => Promise<void>;draw(pass: TgpuRenderCommands): void;}createAgar(function createAgar(root: TgpuRoot, world: TgpuUniform<typeof World>, format: GPUTextureFormat): {initAsync: () => Promise<void>;draw(pass: TgpuRenderCommands): void;}Draws the agar dish over the whole picture, darkening towards its edges.
root,root: TgpuRootworld,const world: TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>surface.surface: Surfaceformat);Surface.format: GPUTextureFormatawaitagar.const agar: {initAsync: () => Promise<void>;draw(pass: TgpuRenderCommands): void;}initAsync();initAsync: () => Promise<void>return {frame(function frame(frame: Frame): voidframe:frame: FrameFrame) {(alias) interface Frameimport FrameRepresents one frame being recorded. Each
runcreates a separate object, so modules can compare frames by identity.constpass =const pass: TgpuRenderPassframe.frame: Frameencoder.Frame.encoder: TgpuCommandEncoderRecords commands for submission after the recorder returns.
submit()replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.beginRenderPass({TgpuCommandEncoder.beginRenderPass(descriptor: TgpuRenderPassDescriptor): TgpuRenderPassBegins recording a render pass. Attachment views accept TypeGPU textures, texture views and canvas contexts, next to raw
GPUTextureView
s.
colorAttachments: {TgpuRenderPassDescriptor.colorAttachments?: ColorAttachment | readonly (ColorAttachment | null)[] | undefinedview:ColorAttachment.view: GPUTextureView | (ColorTextureConstraint & RenderFlag) | TgpuTextureView<d.WgslTexture<WgslTextureProps>> | TgpuTextureRenderView | GPUCanvasContextA
GPUTextureView
describing the texture subresource that will be output to for this color attachment.
surface.surface: Surfaceview(Surface.view(frame: Frame): GPUTextureViewReturns a view of the current texture, created once per texture. React Native requires the frame to present after submission.
frame) },frame: Frame});agar.const agar: {initAsync: () => Promise<void>;draw(pass: TgpuRenderCommands): void;}draw(function draw(pass: TgpuRenderCommands): voidpass);const pass: TgpuRenderPasspass.const pass: TgpuRenderPassend();TgpuRenderPass.end(): voidCompletes the recording of this render pass
},destroy() {},function destroy(): void};}destroyfrees what the scene makes when the host closes it. A uniform is small, so the garbage collector frees it, anddestroyhas nothing to free yet.Look at the edges of the dish. The near-black agar has a faint cool tint and darkens toward them.
-
Put agents in a buffer, and move them.
A buffer is GPU memory that holds many values of one shape. Create one for up to a million agents. Each
Agentstores a position, a heading and whether it is awake. Positions use cells of a square grid that covers the dish, 2048 cells across. You create that grid in step 3.A kernel is a compute function that the GPU runs many times at once. Each run is a thread, numbered by
gid.xfrom 0. Here, threadnplaces agentn. The first 16 384 agents start awake on a spiral at the centre. The rest sleep until step 5.agents.ts /** Initial agent count and fraction of blob cells occupied. */constSEED = 16384;const SEED: 16384Initial agent count and fraction of blob cells occupied.
constFILL = 0.2;const FILL: 0.2/** The golden angle prevents agents in the initial blob from lining up. */constGOLDEN =const GOLDEN: numberThe golden angle prevents agents in the initial blob from lining up.
Math.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
PI * (3 -Math.PI: numberPi. This is the ratio of the circumference of a circle to its diameter.
Math.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
sqrt(5));Math.sqrt(x: number): numberReturns the square root of a number.
constAgent =const Agent: d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>d.import dstruct({struct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>(props: {position: d.Vec2f;heading: d.F32;awake: d.U32;}): d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>export structCreates a struct schema that can be used to construct GPU buffers. Ensures proper alignment and padding of properties (as opposed to a
d.unstructschema). The order of members matches the passed in properties object.position:position: d.Vec2fd.import dvec2f,const vec2f: d.Vec2fheading:heading: d.F32d.import df32,const f32: d.F32export f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
awake:awake: d.U32d.import du32 });const u32: d.U32export u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
/** Agent count and step distance in trail cells. */constPhysarum =const Physarum: d.WgslStruct<{count: d.U32;speed: d.F32;}>Agent count and step distance in trail cells.
d.import dstruct({struct<{count: d.U32;speed: d.F32;}>(props: {count: d.U32;speed: d.F32;}): d.WgslStruct<{count: d.U32;speed: d.F32;}>export structCreates a struct schema that can be used to construct GPU buffers. Ensures proper alignment and padding of properties (as opposed to a
d.unstructschema). The order of members matches the passed in properties object.count:count: d.U32d.import du32,const u32: d.U32export u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
speed:speed: d.F32d.import df32 });const f32: d.F32export f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
constagents =const agents: TgpuMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>root.const root: TgpuRootcreateMutable(TgpuRoot.createMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>(typeSchema: d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>, initial?: ((buffer: TgpuBuffer<NoInfer<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>>) => void) | d.InferInput<NoInfer<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>> | undefined): TgpuMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>> (+1 overload)Allocates memory on the GPU, allows passing data between host and shader. Can be mutated in-place on the GPU. For a general-purpose buffer, use
TgpuRoot.createBuffer
.
d.import darrayOf(arrayOf<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>(elementType: d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>, elementCount: number): d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>> (+2 overloads)export arrayOfAgent,const Agent: d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>capacity));const capacity: numberconstphysarum =const physarum: TgpuUniform<d.WgslStruct<{count: d.U32;speed: d.F32;}>>root.const root: TgpuRootcreateUniform(TgpuRoot.createUniform<d.WgslStruct<{count: d.U32;speed: d.F32;}>>(typeSchema: d.WgslStruct<{count: d.U32;speed: d.F32;}>, initial?: ((buffer: TgpuBuffer<NoInfer<d.WgslStruct<{count: d.U32;speed: d.F32;}>>>) => void) | d.InferInput<NoInfer<d.WgslStruct<{count: d.U32;speed: d.F32;}>>> | undefined): TgpuUniform<d.WgslStruct<{count: d.U32;speed: d.F32;}>> (+1 overload)Allocates memory on the GPU, allows passing data between host and shader. Read-only on the GPU, optimized for small data. For a general-purpose buffer, use
TgpuRoot.createBuffer
.
Physarum);const Physarum: d.WgslStruct<{count: d.U32;speed: d.F32;}>Agent count and step distance in trail cells.
constcentre =const centre: d.v2fd.import dvec2f(const vec2f: d.Vec2f(xy: number) => d.v2f (+3 overloads)size / 2);const size: numberconsttoward = (const toward: (angle: number) => d.v2fangle: number) => {angle: number'use gpu';returnd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)std.import stdcos(function cos(value: number): number (+1 overload)export cosangle),angle: numberstd.import stdsin(function sin(value: number): number (+1 overload)export sinangle));angle: number};constscatter =const scatter: TgpuComputeFn<{gid: d.BuiltinGlobalInvocationId;}>tgpu.const tgpu: {const: {<TDataType extends d.AnyData>(dataType: TDataType, value: d.InferGPU<TDataType>): TgpuConst<TDataType>;<TElement extends d.AnyWgslData>(dataType: (elementCount: number) => d.WgslArray<TElement>, value: d.InferGPU<d.WgslArray<TElement>>): TgpuConst<d.WgslArray<TElement>>;};fn: {<Args extends d.BaseData[] | []>(argTypes: Args, returnType?: undefined): TgpuFnShell<Args, d.Void>;<Args extends d.BaseData[] | [], Return extends d.BaseData>(argTypes: Args, returnType: Return): TgpuFnShell<Args, Return>;<T extends AnyFn>(inner: T): TgpuGenericFn<T>;};... 17 more ...;'~unstable': typeof import("../../node_modules/typegpu/tgpuUnstable");}computeFn({computeFn: <{gid: d.BuiltinGlobalInvocationId;}>(options: {in: {gid: d.BuiltinGlobalInvocationId;};workgroupSize: number[];}) => TgpuComputeFnShell<{gid: d.BuiltinGlobalInvocationId;}> (+1 overload)workgroupSize: [workgroupSize: number[]WORKGROUP],const WORKGROUP: 64in: {in: {gid: d.BuiltinGlobalInvocationId;}gid:gid: d.BuiltinGlobalInvocationIdd.import dbuiltin.const builtin: {readonly vertexIndex: d.BuiltinVertexIndex;readonly instanceIndex: d.BuiltinInstanceIndex;readonly clipDistances: d.BuiltinClipDistances;readonly position: d.BuiltinPosition;readonly frontFacing: d.BuiltinFrontFacing;readonly fragDepth: d.BuiltinFragDepth;readonly primitiveIndex: d.BuiltinPrimitiveIndex;readonly sampleIndex: d.BuiltinSampleIndex;... 11 more ...;readonly numSubgroups: d.BuiltinNumSubgroups;}export builtinglobalInvocationId },globalInvocationId: d.BuiltinGlobalInvocationId})(({gid }) => {gid: d.v3u'use gpu';if (gid.gid: d.v3ux >=v3u.x: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{count: d.U32;speed: d.F32;}>>$.TgpuUniform<WgslStruct<{ count: U32; speed: F32; }>>.$: {count: number;speed: number;}count) return;count: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}seed(seed: (seed: number) => voidThreads do not share the generator's
State. As a result, unless you change the seed in each thread, each thread will produce the same sequence.randf.randSeedsets the private seed of the thread.d.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
gid.gid: d.v3ux));v3u.x: numberconstout =const out: numberd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
gid.gid: d.v3ux) *v3u.x: numberGOLDEN;const GOLDEN: numberThe golden angle prevents agents in the initial blob from lining up.
constposition =const position: d.v2fcentre +const centre: d.v2ftoward(const toward: (angle: number) => d.v2fout) *const out: numberstd.import stdsqrt(function sqrt(value: number): number (+1 overload)export sqrtd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
gid.gid: d.v3ux) / (v3u.x: numberMath.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
PI *Math.PI: numberPi. This is the ratio of the circumference of a circle to its diameter.
FILL));const FILL: 0.2constheading =const heading: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() *sample: () => numberReturns a random f32 value in [0, 1) range.
Math.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
PI * 2;Math.PI: numberPi. This is the ratio of the circumference of a circle to its diameter.
constawake =const awake: numberstd.import stdselect(function select(f: number, t: number, cond: boolean): number (+2 overloads)export selectReturns
tifcondistrue, andfotherwise. Component-wise ifcondis a vector.d.import du32(0),function u32(v?: number | boolean): numberexport u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
d.import du32(1),function u32(v?: number | boolean): numberexport u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
gid.gid: d.v3ux <v3u.x: numberSEED);const SEED: 16384Initial agent count and fraction of blob cells occupied.
agents.const agents: TgpuMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>$[TgpuMutable<WgslArray<WgslStruct<{ position: Vec2f; heading: F32; awake: U32; }>>>.$: {position: d.v2f;heading: number;awake: number;}[]gid.gid: d.v3ux] =v3u.x: numberAgent({const Agent: d.WgslStruct(props?: {position: d.v2f;heading: number;awake: number;} | undefined) => {position: d.v2f;heading: number;awake: number;}position,position: d.v2fheading,heading: numberawake });awake: number});createMutablecreates a buffer that a kernel can write.physarumis a uniform with the agent count and distance per step. The scene writes it through thesetmethod thatcreateAgentsreturns.- Place agent
natngolden angles around the centre and√(n / (π · FILL))cells from it. The firstnagents fill a disc ofn / FILLcells, one agent per five cells. The golden angle prevents rows from lining up. - After
randf.seed(...),randf.sample()from@typegpu/noisereturns a random number from 0 to 1. Seed with the thread number to give each agent its own numbers.std.select(a, b, condition)returnsbwhen the condition holds andaotherwise.
Threads start in workgroups, groups of threads that run together. Use 64 threads per workgroup and dispatch enough groups to cover every agent, rounded up with
dispatchWorkgroups(Math.ceil(count / 64)). The last workgroup can have more threads than agents. The bounds check at the start of the kernel stops those extra threads.Change the agent count to see dispatch round up to whole workgroups of 64 threads. The bounds check if (gid.x >= count) returnstops extra threads before buffer access.Add a second kernel,
move, to move each awake agent forward byspeedcells. Vary its heading a little each step so it does not walk in a straight line. An agent that would leave the dish stays put and turns back by 135° to 225°. This prevents agents from collecting along the edge.agents.ts /** How much an agent's heading wanders each step on its own, in radians either way. */constWANDER = 0.08;const WANDER: 0.08How much an agent's heading wanders each step on its own, in radians either way.
/** Excludes a one-cell border around the sheet. */constonSheet = (const onSheet: (position: d.v2f) => booleanExcludes a one-cell border around the sheet.
position:position: d.v2fd.import dv2f) => {export v2fInterface representing its WGSL vector type counterpart: vec2f or vec2. A vector with 2 elements of type f32
'use gpu';returnstd.import stdall(function all(value: boolean | AnyBooleanVecInstance): booleanexport allReturns
trueif each component ofvalueis true.std.import stdge(ge<d.v2f>(lhs: d.v2f, rhs: d.v2f): d.v2bexport geChecks component-wise whether
lhs >= rhs. This function does not returnbool, for that use-case, wrap the result inall.position,position: d.v2fd.import dvec2f(1))) &&const vec2f: d.Vec2f(xy: number) => d.v2f (+3 overloads)std.import stdall(function all(value: boolean | AnyBooleanVecInstance): booleanexport allReturns
trueif each component ofvalueis true.std.import stdlt(lt<d.v2f>(lhs: d.v2f, rhs: d.v2f): d.v2bexport ltChecks component-wise whether
lhs < rhs. This function does not returnbool, for that use-case, wrap the result inall.position,position: d.v2fd.import dvec2f(const vec2f: d.Vec2f(xy: number) => d.v2f (+3 overloads)size - 1)));const size: number};constmove =const move: TgpuComputeFn<{gid: d.BuiltinGlobalInvocationId;}>tgpu.const tgpu: {const: {<TDataType extends d.AnyData>(dataType: TDataType, value: d.InferGPU<TDataType>): TgpuConst<TDataType>;<TElement extends d.AnyWgslData>(dataType: (elementCount: number) => d.WgslArray<TElement>, value: d.InferGPU<d.WgslArray<TElement>>): TgpuConst<d.WgslArray<TElement>>;};fn: {<Args extends d.BaseData[] | []>(argTypes: Args, returnType?: undefined): TgpuFnShell<Args, d.Void>;<Args extends d.BaseData[] | [], Return extends d.BaseData>(argTypes: Args, returnType: Return): TgpuFnShell<Args, Return>;<T extends AnyFn>(inner: T): TgpuGenericFn<T>;};... 17 more ...;'~unstable': typeof import("../../node_modules/typegpu/tgpuUnstable");}computeFn({computeFn: <{gid: d.BuiltinGlobalInvocationId;}>(options: {in: {gid: d.BuiltinGlobalInvocationId;};workgroupSize: number[];}) => TgpuComputeFnShell<{gid: d.BuiltinGlobalInvocationId;}> (+1 overload)workgroupSize: [workgroupSize: number[]WORKGROUP],const WORKGROUP: 64in: {in: {gid: d.BuiltinGlobalInvocationId;}gid:gid: d.BuiltinGlobalInvocationIdd.import dbuiltin.const builtin: {readonly vertexIndex: d.BuiltinVertexIndex;readonly instanceIndex: d.BuiltinInstanceIndex;readonly clipDistances: d.BuiltinClipDistances;readonly position: d.BuiltinPosition;readonly frontFacing: d.BuiltinFrontFacing;readonly fragDepth: d.BuiltinFragDepth;readonly primitiveIndex: d.BuiltinPrimitiveIndex;readonly sampleIndex: d.BuiltinSampleIndex;... 11 more ...;readonly numSubgroups: d.BuiltinNumSubgroups;}export builtinglobalInvocationId },globalInvocationId: d.BuiltinGlobalInvocationId})(({gid }) => {gid: d.v3u'use gpu';if (gid.gid: d.v3ux >=v3u.x: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{count: d.U32;speed: d.F32;}>>$.TgpuUniform<WgslStruct<{ count: U32; speed: F32; }>>.$: {count: number;speed: number;}count) return;count: numberconstagent =const agent: {position: d.v2f;heading: number;awake: number;}agents.const agents: TgpuMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>$[TgpuMutable<WgslArray<WgslStruct<{ position: Vec2f; heading: F32; awake: U32; }>>>.$: {position: d.v2f;heading: number;awake: number;}[]gid.gid: d.v3ux]!;v3u.x: numberif (agent.const agent: {position: d.v2f;heading: number;awake: number;}awake === 0) return;awake: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}seed3(seed3: (seed: d.v3f) => voidThreads do not share the generator's
State. As a result, unless you change the seed in each thread, each thread will produce the same sequence.randf.randSeed3sets the private seed of the thread. Sets the private seed of the thread.d.import dvec3f(const vec3f: d.Vec3f(v0: AnyNumericVec2Instance, z: number) => d.v3f (+5 overloads)agent.const agent: {position: d.v2f;heading: number;awake: number;}position,position: d.v2fd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
gid.gid: d.v3ux)));v3u.x: numberletheading =let heading: numberagent.const agent: {position: d.v2f;heading: number;awake: number;}heading + (heading: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() - 0.5) * 2 *sample: () => numberReturns a random f32 value in [0, 1) range.
WANDER;const WANDER: 0.08How much an agent's heading wanders each step on its own, in radians either way.
constnext =const next: d.v2fagent.const agent: {position: d.v2f;heading: number;awake: number;}position +position: d.v2ftoward(const toward: (angle: number) => d.v2fheading) *let heading: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{count: d.U32;speed: d.F32;}>>$.TgpuUniform<WgslStruct<{ count: U32; speed: F32; }>>.$: {count: number;speed: number;}speed;speed: numberif (!onSheet(const onSheet: (position: d.v2f) => booleanExcludes a one-cell border around the sheet.
next)) {const next: d.v2fheading +=let heading: numberMath.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
PI * (0.75 + 0.5 *Math.PI: numberPi. This is the ratio of the circumference of a circle to its diameter.
randf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample());sample: () => numberReturns a random f32 value in [0, 1) range.
} else {agent.const agent: {position: d.v2f;heading: number;awake: number;}position =position: d.v2fd.import dvec2f(const vec2f: d.Vec2f(v: AnyNumericVec2Instance) => d.v2f (+3 overloads)next);const next: d.v2f}agent.const agent: {position: d.v2f;heading: number;awake: number;}heading =heading: numberheading;let heading: number});agentpoints into the buffer, so assigning its fields writes the buffer. Seed with its position and number to get different random numbers each step. Create a pipeline for each kernel withcreateComputePipeline({ compute: move }). Returnscatter(encoder)to dispatchscatterin its own compute pass. Returnmove(pass)to dispatchmoveinto the pass the caller supplies.Stepping once per frame would make the mould move twice as fast at 120 Hz as at 60 Hz. A fixed step counts how many simulation steps fit in the elapsed time. It carries the unused time into the next frame:
Stall a frame or change the display rate. Step length stays fixed; catch-up frames run at most maxStepsand drop time beyond the cap, shown at one fifth speed.scene.ts // Inside createSceneconstown =const own: Ownerowner();function owner(message?: string): OwnerCollects what a scene or module makes, to free it all in one call, and guards its methods. Pass each thing through it as you make it, and return its
destroyas your own. Take a resource that is replaced during the module's life, such as a texture that follows the canvas size, as one cleanup that frees the current one. Methods that only read CPU state or do optional work, such ascapture,takeor a camera'supdate, should return early ondestroyedinstead of callingusable.const own = owner('The swarm was destroyed.');const screen = own(targets.create(root, surface, { hdr }));const settings = own(root.createUniform(Settings));own(() => {for (const buffer of reads) buffer.destroy();});return { step(pass) { own.usable(); … }, destroy: own.destroy, [Symbol.dispose]: own.destroy };constSTEP = 1 / 60;const STEP: numberconstsim =const sim: clock.FixedStepclock.import clockfixedStep({function fixedStep(options: clock.FixedStepOptions): clock.FixedStepAccumulates elapsed time for fixed simulation steps. Accumulates nothing while paused. Throws unless
stepis finite and above 0 andmaxStepsis an integer above 0.step:FixedStepOptions.step: numberSets the step duration in seconds, such as 1 / 120.
STEP });const STEP: numberconstagents =const agents: {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}own(const own: Owner<{initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}>(item: {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}) => {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}Takes
itemto free atdestroyand returns it. Passesundefinedthrough. Frees a fixed resource, such as one fromroot.createUniform, through itsbuffer, and runs a cleanup function. Afterdestroy, freesitemat once.createAgents(function createAgents(root: TgpuRoot, options: {size: number;capacity: number;}): {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}root, {const root: TgpuRootsize: 2048,size: numbercapacity: 1_000_000 }));capacity: numberawaitagents.const agents: {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}initAsync();function initAsync(): Promise<unknown>agents.const agents: {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}set({function set(values: {count: number;speed: number;}): voidcount: 400_000,count: numberspeed: 1.2 });speed: numberletscattering = true;let scattering: boolean// Inside frame, before the render passconststeps =const steps: numbersim.const sim: clock.FixedStepadvance(FixedStep.advance(dt: number): numberAdds
dtseconds and returns the number of steps to run. Each step lastsstepseconds, so a GPU kernel can use a fixed duration. All steps in a frame see the same queued CPU writes. Keep a step counter on the GPU for values that must change per step.frame.const frame: Framedt);Frame.dt: numberReports seconds since the previous submitted frame. The first frame has a
dtof zero.if (steps > 0) {const steps: numberworld.const world: TgpuUniform<WgslStruct<{size: Vec2f;time: F32;}>>patch({TgpuBufferBindingBase<WgslStruct<{ size: Vec2f; time: F32; }>>.patch(data: {size?: InferInput<Vec2f> | undefined;time?: number | undefined;} | undefined): voidtime:time?: number | undefinedsim.const sim: clock.FixedSteptime });FixedStep.time: numberReports the simulated seconds: the sum of every step
advancehas returned.}if (scattering)let scattering: trueagents.const agents: {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}scatter(function scatter(encoder: TgpuCommandEncoder): voidframe.const frame: Frameencoder);Frame.encoder: TgpuCommandEncoderRecords commands for submission after the recorder returns.
submit()replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.scattering = false;let scattering: booleanfor (leti = 0;let i: numberi <let i: numbersteps;const steps: numberi++) {let i: numberconstpass =const pass: TgpuComputePassframe.const frame: Frameencoder.Frame.encoder: TgpuCommandEncoderRecords commands for submission after the recorder returns.
submit()replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.beginComputePass();TgpuCommandEncoder.beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePassBegins recording a compute pass
agents.const agents: {initAsync(): Promise<unknown>;set(values: {count: number;speed: number;}): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}move(function move(pass: TgpuComputePass): voidpass);const pass: TgpuComputePasspass.const pass: TgpuComputePassend();TgpuComputePass.end(): voidCompletes the recording of this compute pass
}Record the scatter, the steps and the render pass into one encoder, in that order. The GPU places the agents before it moves them.
world.patchwrites only the fields you supply, here the elapsed simulation time in seconds. The picture stays the same because nothing draws the agents yet.A million agents fill a large buffer, so free it with the scene. Return
destroyfromcreateAgentsto free its buffers.owner()collects what you pass throughownas you make it. Replace the scene’sdestroy() {}withdestroy: own.destroy, which frees them in reverse order. -
Leave a trail.
Display the mould as a trail on a grid of 2048 × 2048 cells. Store that grid in a texture, an image the GPU can read or write. Reading can blend nearby cells, while writing sets one cell at a time.
at(uv)reads the trail, withuvfrom 0 to 1 across the grid.Each step, every agent adds a drop to its cell. If two threads read and add to the same value at once, one update can overwrite the other. Put the drops in a buffer of atomic numbers. An atomic add completes before another thread adds to the same number. These atomics hold whole numbers, so represent a drop of 1 as
PARTS, or 1024. A kernel namedtakemoves each cell’s drops into the texture and empties the cell.trail.ts constPARTS = 1024;const PARTS: 1024constWORKGROUP = 16;const WORKGROUP: 16consttrail =const trail: TgpuTexture<{size: [number, number];format: "rgba16float";}> & StorageFlag & SampledFlagrootconst root: TgpuRoot.createTexture({TgpuRoot.createTexture<number, number, number, [number, number], "rgba16float", number, number, GPUTextureFormat[], GPUTextureDimension>(props: CreateTextureOptions<[number, number], "rgba16float", number, number, GPUTextureFormat[], GPUTextureDimension>): TgpuTexture<{size: [number, number];format: "rgba16float";}>size: [size: [number, number]The width, height, and depth or layer count of the texture.
size,const size: numbersize],const size: numberformat: 'rgba16float' })format: "rgba16float"The format of the texture.
.$usage('storage', 'sampled');TgpuTexture<{ size: [number, number]; format: "rgba16float"; }>.$usage<["storage", "sampled"]>(usages_0: "storage", usages_1: "sampled"): TgpuTexture<{size: [number, number];format: "rgba16float";}> & StorageFlag & SampledFlagconstreading =const reading: TgpuTextureView<d.WgslTexture2d<d.F32>>trail.const trail: TgpuTexture<{size: [number, number];format: "rgba16float";}> & StorageFlag & SampledFlagcreateView(TgpuTexture<{ size: [number, number]; format: "rgba16float"; }>.createView<d.WgslTexture2d<d.F32>>(schema: d.WgslTexture2d<d.F32>, viewDescriptor?: (TgpuTextureViewDescriptor & {sampleType?: "float" | "unfilterable-float" | undefined;}) | undefined): TgpuTextureView<d.WgslTexture2d<d.F32>> (+3 overloads)d.import dtexture2d(texture2d<d.F32>(sampleType: d.F32): d.WgslTexture2d<d.F32> (+1 overload)export texture2dd.import df32));const f32: d.F32export f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
constwriting =const writing: TgpuTextureView<d.WgslStorageTexture2d<"rgba16float", "write-only">>trail.const trail: TgpuTexture<{size: [number, number];format: "rgba16float";}> & StorageFlag & SampledFlagcreateView(TgpuTexture<{ size: [number, number]; format: "rgba16float"; }>.createView<d.WgslStorageTexture2d<"rgba16float", "write-only">>(schema: d.WgslStorageTexture2d<"rgba16float", "write-only">, viewDescriptor?: TgpuTextureViewDescriptor): TgpuTextureView<d.WgslStorageTexture2d<"rgba16float", "write-only">> (+3 overloads)d.import dtextureStorage2d('rgba16float', 'write-only'));textureStorage2d<"rgba16float", "write-only">(format: "rgba16float", access: "write-only"): d.WgslStorageTexture2d<"rgba16float", "write-only"> (+1 overload)export textureStorage2dconstsmooth =const smooth: TgpuFixedSamplerroot.const root: TgpuRootcreateSampler({TgpuRoot.createSampler(props: WgslSamplerProps): TgpuFixedSamplermagFilter: 'linear',WgslSamplerProps.magFilter?: GPUFilterMode | undefinedSpecifies the sampling behavior when the sample footprint is smaller than or equal to one texel.
minFilter: 'linear' });WgslSamplerProps.minFilter?: GPUFilterMode | undefinedSpecifies the sampling behavior when the sample footprint is larger than one texel.
constdropped =const dropped: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>root.const root: TgpuRootcreateMutable(TgpuRoot.createMutable<d.WgslArray<d.Atomic<d.U32>>>(typeSchema: d.WgslArray<d.Atomic<d.U32>>, initial?: ((buffer: TgpuBuffer<NoInfer<d.WgslArray<d.Atomic<d.U32>>>>) => void) | d.InferInput<NoInfer<d.WgslArray<d.Atomic<d.U32>>>> | undefined): TgpuMutable<d.WgslArray<d.Atomic<d.U32>>> (+1 overload)Allocates memory on the GPU, allows passing data between host and shader. Can be mutated in-place on the GPU. For a general-purpose buffer, use
TgpuRoot.createBuffer
.
d.import darrayOf(arrayOf<d.Atomic<d.U32>>(elementType: d.Atomic<d.U32>, elementCount: number): d.WgslArray<d.Atomic<d.U32>> (+2 overloads)export arrayOfd.import datomic(atomic<d.U32>(data: d.U32): d.Atomic<d.U32>export atomicMarks a concrete integer scalar type schema (u32 or i32) as a WGSL atomic.
d.import du32),const u32: d.U32export u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
size *const size: numbersize));const size: numberconstdeposit = (const deposit: (cell: d.v2i, amount: number) => voidcell:cell: d.v2id.import dv2i,export v2iInterface representing its WGSL vector type counterpart: vec2i or vec2. A vector with 2 elements of type i32
amount: number) => {amount: number'use gpu';std.import stdatomicAdd(atomicAdd<d.atomicU32>(a: d.atomicU32, value: number): numberexport atomicAdddropped.const dropped: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]cell.cell: d.v2iy *v2i.y: numbersize +const size: numbercell.cell: d.v2ix]!,v2i.x: numberd.import du32(function u32(v?: number | boolean): numberexport u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
amount *amount: numberPARTS));const PARTS: 1024};constat = (const at: (uv: d.v2f) => numberuv:uv: d.v2fd.import dv2f) => {export v2fInterface representing its WGSL vector type counterpart: vec2f or vec2. A vector with 2 elements of type f32
'use gpu';returnstd.import stdtextureSampleLevel(textureSampleLevel<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, sampler: d.sampler, coords: d.v2f, level: number): d.v4f (+13 overloads)export textureSampleLevelreading.const reading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>smooth.const smooth: TgpuFixedSampler$,TgpuSampler.$: d.sampleruv, 0).uv: d.v2fx;v4f.x: number};consttake =const take: TgpuComputeFn<{gid: d.BuiltinGlobalInvocationId;}>tgpu.const tgpu: {const: {<TDataType extends d.AnyData>(dataType: TDataType, value: d.InferGPU<TDataType>): TgpuConst<TDataType>;<TElement extends d.AnyWgslData>(dataType: (elementCount: number) => d.WgslArray<TElement>, value: d.InferGPU<d.WgslArray<TElement>>): TgpuConst<d.WgslArray<TElement>>;};fn: {<Args extends d.BaseData[] | []>(argTypes: Args, returnType?: undefined): TgpuFnShell<Args, d.Void>;<Args extends d.BaseData[] | [], Return extends d.BaseData>(argTypes: Args, returnType: Return): TgpuFnShell<Args, Return>;<T extends AnyFn>(inner: T): TgpuGenericFn<T>;};... 17 more ...;'~unstable': typeof import("../../node_modules/typegpu/tgpuUnstable");}computeFn({computeFn: <{gid: d.BuiltinGlobalInvocationId;}>(options: {in: {gid: d.BuiltinGlobalInvocationId;};workgroupSize: number[];}) => TgpuComputeFnShell<{gid: d.BuiltinGlobalInvocationId;}> (+1 overload)workgroupSize: [workgroupSize: number[]WORKGROUP,const WORKGROUP: 16WORKGROUP],const WORKGROUP: 16in: {in: {gid: d.BuiltinGlobalInvocationId;}gid:gid: d.BuiltinGlobalInvocationIdd.import dbuiltin.const builtin: {readonly vertexIndex: d.BuiltinVertexIndex;readonly instanceIndex: d.BuiltinInstanceIndex;readonly clipDistances: d.BuiltinClipDistances;readonly position: d.BuiltinPosition;readonly frontFacing: d.BuiltinFrontFacing;readonly fragDepth: d.BuiltinFragDepth;readonly primitiveIndex: d.BuiltinPrimitiveIndex;readonly sampleIndex: d.BuiltinSampleIndex;... 11 more ...;readonly numSubgroups: d.BuiltinNumSubgroups;}export builtinglobalInvocationId },globalInvocationId: d.BuiltinGlobalInvocationId})(({gid }) => {gid: d.v3u'use gpu';if (gid.gid: d.v3ux >=v3u.x: numbersize ||const size: numbergid.gid: d.v3uy >=v3u.y: numbersize) return;const size: numberconstindex =const index: numbergid.gid: d.v3uy *v3u.y: numbersize +const size: numbergid.gid: d.v3ux;v3u.x: numberconstfresh =const fresh: numberd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
std.import stdatomicLoad(atomicLoad<d.atomicU32>(a: d.atomicU32): numberexport atomicLoaddropped.const dropped: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]index]!)) /const index: numberPARTS;const PARTS: 1024std.import stdatomicStore(atomicStore<d.atomicU32>(a: d.atomicU32, value: number): voidexport atomicStoredropped.const dropped: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]index]!, 0);const index: numberstd.import stdtextureStore(textureStore<d.textureStorage2d<"rgba16float", "write-only">>(texture: d.textureStorage2d<"rgba16float", "write-only">, coords: d.v2i | d.v2u, value: d.v4f): void (+3 overloads)export textureStorewriting.const writing: TgpuTextureView<d.WgslStorageTexture2d<"rgba16float", "write-only">>$,TgpuTextureView<WgslStorageTexture2d<"rgba16float", "write-only">>.$: d.textureStorage2d<"rgba16float", "write-only">gid.gid: d.v3uxy,xy: d.v2ud.import dvec4f(const vec4f: d.Vec4f(x: number, y: number, z: number, w: number) => d.v4f (+9 overloads)fresh, 0, 0, 1));const fresh: number});- Give the texture two views,
readingto sample through thesmoothsampler andwritingto store a cell. Thergba16floatformat holds four numbers per cell. The trail uses the first. takeruns one thread per cell in workgroups of 16 × 16. Returnspread(pass)alongsidedepositandatfromcreateTrail. It dispatchesMath.ceil(2048 / 16)workgroups in each direction.
Pass the trail to
createAgentsso it can read the grid size. Endmoveby depositing a drop at the agent’s position. Adddeposit: d.f32toPhysarumfor the drop size:trail.deposit(d.vec2i(agent.position), physarum.$.deposit);Draw a second full-screen triangle over the agar in
veins.ts. Its fragment function reads the trail under each pixel and makes it orange in proportion to the density. A density of 1 gives full orange.veins.ts constORANGE =const ORANGE: d.v3fhex(0xff7a2a);function hex(rgb: number): d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
constveins =const veins: TgpuFragmentFn<{}, d.Vec4f>tgpu.const tgpu: {const: {<TDataType extends d.AnyData>(dataType: TDataType, value: d.InferGPU<TDataType>): TgpuConst<TDataType>;<TElement extends d.AnyWgslData>(dataType: (elementCount: number) => d.WgslArray<TElement>, value: d.InferGPU<d.WgslArray<TElement>>): TgpuConst<d.WgslArray<TElement>>;};fn: {<Args extends d.BaseData[] | []>(argTypes: Args, returnType?: undefined): TgpuFnShell<Args, d.Void>;<Args extends d.BaseData[] | [], Return extends d.BaseData>(argTypes: Args, returnType: Return): TgpuFnShell<Args, Return>;<T extends AnyFn>(inner: T): TgpuGenericFn<T>;};... 17 more ...;'~unstable': typeof import("../../node_modules/typegpu/tgpuUnstable");}fragmentFn({fragmentFn: <{pixel: d.BuiltinPosition;}, d.Vec4f>(options: {in: {pixel: d.BuiltinPosition;};out: d.Vec4f;}) => TgpuFragmentFnShell<{pixel: d.BuiltinPosition;}, d.Vec4f> (+1 overload)in: {in: {pixel: d.BuiltinPosition;}pixel:pixel: d.BuiltinPositiond.import dbuiltin.const builtin: {readonly vertexIndex: d.BuiltinVertexIndex;readonly instanceIndex: d.BuiltinInstanceIndex;readonly clipDistances: d.BuiltinClipDistances;readonly position: d.BuiltinPosition;readonly frontFacing: d.BuiltinFrontFacing;readonly fragDepth: d.BuiltinFragDepth;readonly primitiveIndex: d.BuiltinPrimitiveIndex;readonly sampleIndex: d.BuiltinSampleIndex;... 11 more ...;readonly numSubgroups: d.BuiltinNumSubgroups;}export builtinposition },position: d.BuiltinPositionout:out: d.Vec4fd.import dvec4f })(({const vec4f: d.Vec4fpixel,pixel: d.v4f}) => {'use gpu';constuv =const uv: d.v2fsheetAt(function sheetAt(pixel: d.v2f, size: d.v2f): d.v2fSheet coordinates have (0, 0) at the picture centre, with y pointing down. One unit is half the picture's longer side. The mould grid covers -1 to 1 on both axes, so on a wide picture its top and bottom run past the edges.
pixel.pixel: d.v4fxy,xy: d.v2fworld.const world: TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>$.TgpuUniform<WgslStruct<{ size: Vec2f; time: F32; }>>.$: {size: d.v2f;time: number;}size) * 0.5 + 0.5;size: d.v2freturnd.import dvec4f(const vec4f: d.Vec4f(v0: AnyNumericVec3Instance, w: number) => d.v4f (+9 overloads)linearToSrgb(function linearToSrgb(linear: d.v3f): d.v3fORANGE *const ORANGE: d.v3fstd.import stdsaturate(function saturate(value: number): number (+1 overload)export saturatetrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv))), 0);const uv: d.v2f});constdrawing =const drawing: TgpuRenderPipeline<d.Vec4f>root.const root: TgpuRootcreateRenderPipeline({WithBinding.createRenderPipeline<{}, {}, {uv: d.Vec2f;}, d.Vec4f>(descriptor: TgpuRenderPipeline<in Targets = never>.DescriptorBase & {attribs?: {} | undefined;vertex: TgpuVertexFn<{}, {uv: d.Vec2f;}> | ((input: AutoVertexIn<InferGPURecord<AttribRecordToDefaultDataTypes<{}>>>) => AutoVertexOut<AnyAutoCustoms>);fragment: TgpuFragmentFn<{uv: d.Vec2f;} & Record<...>, d.Vec4f> | ((input: AutoFragmentIn<...>) => d.v4f | (AnyAutoCustoms & Partial<...>));targets?: TgpuColorTargetState;}): TgpuRenderPipeline<...> (+2 overloads)vertex:vertex: TgpuVertexFn<{}, {uv: d.Vec2f;}> | ((input: AutoVertexIn<InferGPURecord<AttribRecordToDefaultDataTypes<{}>>>) => AutoVertexOut<AnyAutoCustoms>)common.import commonfullScreenTriangle,const fullScreenTriangle: TgpuVertexFn<{}, {uv: d.Vec2f;}>export fullScreenTriangleA vertex function that defines a single full-screen triangle out of three points.
fragment:fragment: TgpuFragmentFn<{uv: d.Vec2f;} & Record<string, AnyFragmentInputBuiltin>, d.Vec4f> | ((input: AutoFragmentIn<InferGPURecord<{uv: d.Vec2f;}>>) => d.v4f | (AnyAutoCustoms & Partial<InferGPURecord<{readonly $fragDepth: d.BuiltinFragDepth;readonly $sampleMask: d.BuiltinSampleMask;}>>))veins,const veins: TgpuFragmentFn<{}, d.Vec4f>targets: {targets?: TgpuColorTargetStateformat,format?: GPUTextureFormat | undefinedThe
GPUTextureFormat
of this color target. The pipeline will only be compatible with
GPURenderPassEncoder
s which use a
GPUTextureView
of this format in the corresponding color attachment.
blend:blend?: GPUBlendState | undefinedThe blending behavior for this color target. If left undefined, disables blending for this color target.
pipelines.import pipelinesblend.const blend: Readonly<{add: Readonly<{color: GPUBlendComponent;alpha: GPUBlendComponent;}>;over: Readonly<{color: GPUBlendComponent;alpha: GPUBlendComponent;}>;straight: Readonly<{color: GPUBlendComponent;alpha: GPUBlendComponent;}>;}>Provides blend states for pipeline
targets.add },add: Readonly<{color: GPUBlendComponent;alpha: GPUBlendComponent;}>Adds fragment values to the target, for sparks, glow or bloom.
});Use the pipeline’s
addblend state to add each pixel’s colour to the agar beneath it.std.saturateclamps a number to the range 0 to 1. Create the trail and veins in the scene, the trail throughownto free its texture and drops. In each step’s compute pass, runtrail.spread(pass)afteragents.move(pass). Draw the veins after the agar.Watch the specks spread from the centre as the 16 384 awake agents walk. Each speck shows only the current step’s drops.
- Give the texture two views,
-
Spread the trail, and let it fade.
A trail that lasts one step is only specks. Real Physarum leaves a chemical that spreads into the agar around it and breaks down. So each step blurs the trail, keeps 93% of it, and adds the new drops.
Blur each cell by averaging it with its neighbours. Reading and writing one texture would let some threads read neighbours that have already changed. Add a second texture,
spare. The first kernel reads the trail, blurs horizontally and writes tospare. The second readsspare, blurs vertically and writes to the trail. This alternation is called ping-pong. The kit’s history uses the same pattern to keep state between frames.Lower keepto fade the trail faster. Each step blurs horizontally into the spare texture, then vertically back into the trail with fading and fresh drops, so neither kernel writes the texture it reads.trail.ts /** Trail retention per step and blur strength, both from 0 to 1. */export constSpread =const Spread: d.WgslStruct<{keep: d.F32;blur: d.F32;}>Trail retention per step and blur strength, both from 0 to 1.
d.import dstruct({struct<{keep: d.F32;blur: d.F32;}>(props: {keep: d.F32;blur: d.F32;}): d.WgslStruct<{keep: d.F32;blur: d.F32;}>export structCreates a struct schema that can be used to construct GPU buffers. Ensures proper alignment and padding of properties (as opposed to a
d.unstructschema). The order of members matches the passed in properties object.keep:keep: d.F32d.import df32,const f32: d.F32export f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
blur:blur: d.F32d.import df32 });const f32: d.F32export f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
// Inside createTrailconstspread =const spread: TgpuUniform<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>root.const root: TgpuRootcreateUniform(TgpuRoot.createUniform<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>(typeSchema: d.WgslStruct<{keep: d.F32;blur: d.F32;}>, initial?: ((buffer: TgpuBuffer<NoInfer<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>>) => void) | d.InferInput<NoInfer<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>> | undefined): TgpuUniform<d.WgslStruct<{keep: d.F32;blur: d.F32;}>> (+1 overload)Allocates memory on the GPU, allows passing data between host and shader. Read-only on the GPU, optimized for small data. For a general-purpose buffer, use
TgpuRoot.createBuffer
.
Spread);const Spread: d.WgslStruct<{keep: d.F32;blur: d.F32;}>Trail retention per step and blur strength, both from 0 to 1.
constinside = (const inside: (cell: d.v2i) => d.v2icell:cell: d.v2id.import dv2i) => {export v2iInterface representing its WGSL vector type counterpart: vec2i or vec2. A vector with 2 elements of type i32
'use gpu';returnstd.import stdclamp(clamp<d.v2i>(value: d.v2i, low: d.v2i, high: d.v2i): d.v2i (+1 overload)export clampcell,cell: d.v2id.import dvec2i(0),const vec2i: d.Vec2i(xy: number) => d.v2i (+3 overloads)d.import dvec2i(const vec2i: d.Vec2i(xy: number) => d.v2i (+3 overloads)size - 1));const size: number};constacross =const across: TgpuComputeFn<{gid: d.BuiltinGlobalInvocationId;}>tgpu.const tgpu: {const: {<TDataType extends d.AnyData>(dataType: TDataType, value: d.InferGPU<TDataType>): TgpuConst<TDataType>;<TElement extends d.AnyWgslData>(dataType: (elementCount: number) => d.WgslArray<TElement>, value: d.InferGPU<d.WgslArray<TElement>>): TgpuConst<d.WgslArray<TElement>>;};fn: {<Args extends d.BaseData[] | []>(argTypes: Args, returnType?: undefined): TgpuFnShell<Args, d.Void>;<Args extends d.BaseData[] | [], Return extends d.BaseData>(argTypes: Args, returnType: Return): TgpuFnShell<Args, Return>;<T extends AnyFn>(inner: T): TgpuGenericFn<T>;};... 17 more ...;'~unstable': typeof import("../../node_modules/typegpu/tgpuUnstable");}computeFn({computeFn: <{gid: d.BuiltinGlobalInvocationId;}>(options: {in: {gid: d.BuiltinGlobalInvocationId;};workgroupSize: number[];}) => TgpuComputeFnShell<{gid: d.BuiltinGlobalInvocationId;}> (+1 overload)workgroupSize: [workgroupSize: number[]WORKGROUP,const WORKGROUP: numberWORKGROUP],const WORKGROUP: numberin: {in: {gid: d.BuiltinGlobalInvocationId;}gid:gid: d.BuiltinGlobalInvocationIdd.import dbuiltin.const builtin: {readonly vertexIndex: d.BuiltinVertexIndex;readonly instanceIndex: d.BuiltinInstanceIndex;readonly clipDistances: d.BuiltinClipDistances;readonly position: d.BuiltinPosition;readonly frontFacing: d.BuiltinFrontFacing;readonly fragDepth: d.BuiltinFragDepth;readonly primitiveIndex: d.BuiltinPrimitiveIndex;readonly sampleIndex: d.BuiltinSampleIndex;... 11 more ...;readonly numSubgroups: d.BuiltinNumSubgroups;}export builtinglobalInvocationId },globalInvocationId: d.BuiltinGlobalInvocationId})(({gid }) => {gid: d.v3u'use gpu';if (gid.gid: d.v3ux >=v3u.x: numbersize ||const size: numbergid.gid: d.v3uy >=v3u.y: numbersize) return;const size: numberconstcell =const cell: d.v2id.import dvec2i(const vec2i: d.Vec2i(v: AnyNumericVec2Instance) => d.v2i (+3 overloads)gid.gid: d.v3uxy);xy: d.v2uconsthere =const here: numberstd.import stdtextureLoad(textureLoad<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, coords: d.v2i | d.v2u, level: number): d.v4f (+12 overloads)export textureLoadreading.const reading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>cell, 0).const cell: d.v2ix;v4f.x: numberconstleft =const left: numberstd.import stdtextureLoad(textureLoad<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, coords: d.v2i | d.v2u, level: number): d.v4f (+12 overloads)export textureLoadreading.const reading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>inside(const inside: (cell: d.v2i) => d.v2icell -const cell: d.v2id.import dvec2i(1, 0)), 0).const vec2i: d.Vec2i(x: number, y: number) => d.v2i (+3 overloads)x;v4f.x: numberconstright =const right: numberstd.import stdtextureLoad(textureLoad<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, coords: d.v2i | d.v2u, level: number): d.v4f (+12 overloads)export textureLoadreading.const reading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>inside(const inside: (cell: d.v2i) => d.v2icell +const cell: d.v2id.import dvec2i(1, 0)), 0).const vec2i: d.Vec2i(x: number, y: number) => d.v2i (+3 overloads)x;v4f.x: numberconstblurred =const blurred: numberstd.import stdmix(function mix(e1: number, e2: number, e3: number): number (+2 overloads)export mixhere, (const here: numberleft +const left: numberhere * 2 +const here: numberright) / 4,const right: numberspread.const spread: TgpuUniform<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>$.TgpuUniform<WgslStruct<{ keep: F32; blur: F32; }>>.$: {keep: number;blur: number;}blur);blur: numberstd.import stdtextureStore(textureStore<d.textureStorage2d<"rgba16float", "write-only">>(texture: d.textureStorage2d<"rgba16float", "write-only">, coords: d.v2i | d.v2u, value: d.v4f): void (+3 overloads)export textureStorespareWriting.const spareWriting: TgpuTextureView<d.WgslStorageTexture2d<"rgba16float", "write-only">>$,TgpuTextureView<WgslStorageTexture2d<"rgba16float", "write-only">>.$: d.textureStorage2d<"rgba16float", "write-only">gid.gid: d.v3uxy,xy: d.v2ud.import dvec4f(const vec4f: d.Vec4f(x: number, y: number, z: number, w: number) => d.v4f (+9 overloads)blurred, 0, 0, 1));const blurred: number});constblurringAcross =const blurringAcross: TgpuComputePipelineroot.const root: TgpuRootcreateComputePipeline({WithBinding.createComputePipeline<{gid: d.BuiltinGlobalInvocationId;}>(descriptor: TgpuComputePipeline.Descriptor<{gid: d.BuiltinGlobalInvocationId;}>): TgpuComputePipelinecompute:compute: TgpuComputeFn<{gid: d.BuiltinGlobalInvocationId;}>across });const across: TgpuComputeFn<{gid: d.BuiltinGlobalInvocationId;}>constgroups =const groups: numberMath.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
ceil(Math.ceil(x: number): numberReturns the smallest integer greater than or equal to its numeric argument.
size /const size: numberWORKGROUP);const WORKGROUP: numberreturn {// Beside deposit and atspread(function spread(pass: TgpuComputePass): voidpass:pass: TgpuComputePassTgpuComputePass) {(alias) interface TgpuComputePassimport TgpuComputePassA compute pass recording into a
TgpuCommandEncoder
.
Dispatch either by binding TypeGPU pipelines to it (
pipeline.with(pass).dispatchWorkgroups(...)), or proxy-style viapass.setPipeline(pipeline)followed bypass.dispatchWorkgroups(...).Call
end()when done recording.pass.pass: TgpuComputePasssetPipeline(TgpuComputePass.setPipeline(pipeline: TgpuComputePipeline): voidSets the current
TgpuComputePipeline
for subsequent dispatches
blurringAcross);const blurringAcross: TgpuComputePipelinepass.pass: TgpuComputePassdispatchWorkgroups(TgpuComputePass.dispatchWorkgroups(x: number, y?: number, z?: number): voidgroups,const groups: numbergroups);const groups: numberpass.pass: TgpuComputePasssetPipeline(TgpuComputePass.setPipeline(pipeline: TgpuComputePipeline): voidSets the current
TgpuComputePipeline
for subsequent dispatches
blurringDown);const blurringDown: TgpuComputePipelinepass.pass: TgpuComputePassdispatchWorkgroups(TgpuComputePass.dispatchWorkgroups(x: number, y?: number, z?: number): voidgroups,const groups: numbergroups);const groups: number},};std.textureLoadreads one cell without blending.insidekeeps reads of neighbouring cells within the grid. Renametaketodown. Readspareabove and below the cell, then store the blurred, faded trail plus the drops:trail.ts // Inside down, after the bounds checkconstcell =const cell: d.v2id.import dvec2i(const vec2i: d.Vec2i(v: AnyNumericVec2Instance) => d.v2i (+3 overloads)gid.const gid: d.v3uxy);xy: d.v2uconsthere =const here: numberstd.import stdtextureLoad(textureLoad<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, coords: d.v2i | d.v2u, level: number): d.v4f (+12 overloads)export textureLoadspareReading.const spareReading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>cell, 0).const cell: d.v2ix;v4f.x: numberconstup =const up: numberstd.import stdtextureLoad(textureLoad<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, coords: d.v2i | d.v2u, level: number): d.v4f (+12 overloads)export textureLoadspareReading.const spareReading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>inside(const inside: (cell: d.v2i) => d.v2icell -const cell: d.v2id.import dvec2i(0, 1)), 0).const vec2i: d.Vec2i(x: number, y: number) => d.v2i (+3 overloads)x;v4f.x: numberconstbelow =const below: numberstd.import stdtextureLoad(textureLoad<d.texture2d<d.F32>>(texture: d.texture2d<d.F32>, coords: d.v2i | d.v2u, level: number): d.v4f (+12 overloads)export textureLoadspareReading.const spareReading: TgpuTextureView<d.WgslTexture2d<d.F32>>$,TgpuTextureView<WgslTexture2d<F32>>.$: d.texture2d<d.F32>inside(const inside: (cell: d.v2i) => d.v2icell +const cell: d.v2id.import dvec2i(0, 1)), 0).const vec2i: d.Vec2i(x: number, y: number) => d.v2i (+3 overloads)x;v4f.x: numberconstblurred =const blurred: numberstd.import stdmix(function mix(e1: number, e2: number, e3: number): number (+2 overloads)export mixhere, (const here: numberup +const up: numberhere * 2 +const here: numberbelow) / 4,const below: numberspread.const spread: TgpuUniform<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>$.TgpuUniform<WgslStruct<{ keep: F32; blur: F32; }>>.$: {keep: number;blur: number;}blur);blur: numberconstindex =const index: numbergid.const gid: d.v3uy *v3u.y: numbersize +const size: numbergid.const gid: d.v3ux;v3u.x: numberconstfresh =const fresh: numberd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
std.import stdatomicLoad(atomicLoad<d.atomicU32>(a: d.atomicU32): numberexport atomicLoaddropped.const dropped: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]index]!)) /const index: numberPARTS;const PARTS: numberstd.import stdatomicStore(atomicStore<d.atomicU32>(a: d.atomicU32, value: number): voidexport atomicStoredropped.const dropped: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]index]!, 0);const index: numberstd.import stdtextureStore(textureStore<d.textureStorage2d<"rgba16float", "write-only">>(texture: d.textureStorage2d<"rgba16float", "write-only">, coords: d.v2i | d.v2u, value: d.v4f): void (+3 overloads)export textureStorewriting.const writing: TgpuTextureView<d.WgslStorageTexture2d<"rgba16float", "write-only">>$,TgpuTextureView<WgslStorageTexture2d<"rgba16float", "write-only">>.$: d.textureStorage2d<"rgba16float", "write-only">gid.const gid: d.v3uxy,xy: d.v2ud.import dvec4f(const vec4f: d.Vec4f(x: number, y: number, z: number, w: number) => d.v4f (+9 overloads)blurred *const blurred: numberspread.const spread: TgpuUniform<d.WgslStruct<{keep: d.F32;blur: d.F32;}>>$.TgpuUniform<WgslStruct<{ keep: F32; blur: F32; }>>.$: {keep: number;blur: number;}keep +keep: numberfresh, 0, 0, 1));const fresh: numberDispatch both kernels in the step’s compute pass, one after the other. The GPU finishes one dispatch’s writes before the next reads them. Return a
setmethod fromcreateTrail, and use it in the scene to setkeepto 0.93 andblurto 1.Watch the trails fade behind the agents. With no steering yet, their walks spread the blob into an even haze.
-
Sense and steer.
Give each agent three sensors, one ahead and one to either side. The side sensors sit
sensorAngleradians from its heading. All three sitsensorDistancecells away. Read the trail at each sensor and turn toward the strongest. If the trail ahead is weakest, turn left or right at random. Agents follow and strengthen each other’s trails, so the haze gathers into threads.Widen the angle to switch between threads, or shorten the distance to follow bends. Over this patch of a few dozen cells, brightness shows trail strength; the agent turns toward the strongest sensor or randomly left or right when the forward sensor is weakest. agents.ts /** Samples trail attraction `sensorDistance` cells away at `angle` radians from the heading. */constsniff = (const sniff: (agent: d.Infer<typeof Agent>, angle: number) => numberSamples trail attraction
sensorDistancecells away atangleradians from the heading.agent:agent: {position: d.v2f;heading: number;awake: number;}d.import dInfer<typeoftype Infer<T> = T extends {readonly [$repr]: infer TRepr;} ? TRepr : Texport InferExtracts the inferred representation of a resource. For inferring types as seen by the GPU, see
InferGPU
Agent>,const Agent: d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>angle: number) => {angle: number'use gpu';constuv = (const uv: d.v2fagent.agent: {position: d.v2f;heading: number;awake: number;}position +position: d.v2ftoward(const toward: (angle: number) => d.v2fagent.agent: {position: d.v2f;heading: number;awake: number;}heading +heading: numberangle) *angle: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{sensorAngle: d.F32;sensorDistance: d.F32;turn: d.F32;}>>$.TgpuUniform<WgslStruct<{ sensorAngle: F32; sensorDistance: F32; turn: F32; }>>.$: {sensorAngle: number;sensorDistance: number;turn: number;}sensorDistance) /sensorDistance: numbersize;const size: numberreturntrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv);const uv: d.v2f};// Inside move, after the seed, replacing the random walkconstangle =const angle: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{sensorAngle: d.F32;sensorDistance: d.F32;turn: d.F32;}>>$.TgpuUniform<WgslStruct<{ sensorAngle: F32; sensorDistance: F32; turn: F32; }>>.$: {sensorAngle: number;sensorDistance: number;turn: number;}sensorAngle;sensorAngle: numberconstahead =const ahead: numbersniff(const sniff: (agent: d.Infer<typeof Agent>, angle: number) => numberSamples trail attraction
sensorDistancecells away atangleradians from the heading.agent,const agent: {position: d.v2f;heading: number;awake: number;}d.import df32(0));function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
constleft =const left: numbersniff(const sniff: (agent: d.Infer<typeof Agent>, angle: number) => numberSamples trail attraction
sensorDistancecells away atangleradians from the heading.agent, -const agent: {position: d.v2f;heading: number;awake: number;}angle);const angle: numberconstright =const right: numbersniff(const sniff: (agent: d.Infer<typeof Agent>, angle: number) => numberSamples trail attraction
sensorDistancecells away atangleradians from the heading.agent,const agent: {position: d.v2f;heading: number;awake: number;}angle);const angle: numberconstturn =const turn: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{sensorAngle: d.F32;sensorDistance: d.F32;turn: d.F32;}>>$.TgpuUniform<WgslStruct<{ sensorAngle: F32; sensorDistance: F32; turn: F32; }>>.$: {sensorAngle: number;sensorDistance: number;turn: number;}turn * (0.5 +turn: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample());sample: () => numberReturns a random f32 value in [0, 1) range.
letheading =let heading: numberagent.const agent: {position: d.v2f;heading: number;awake: number;}heading + (heading: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() - 0.5) * 2 *sample: () => numberReturns a random f32 value in [0, 1) range.
WANDER;const WANDER: numberif (ahead <const ahead: numberleft &&const left: numberahead <const ahead: numberright) {const right: numberheading +=let heading: numberstd.import stdselect(-function select(f: number, t: number, cond: boolean): number (+2 overloads)export selectReturns
tifcondistrue, andfotherwise. Component-wise ifcondis a vector.turn,const turn: numberturn,const turn: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() < 0.5);sample: () => numberReturns a random f32 value in [0, 1) range.
} else if (left >const left: numberahead &&const ahead: numberleft >const left: numberright) {const right: numberheading -=let heading: numberturn;const turn: number} else if (right >const right: numberahead &&const ahead: numberright >const right: numberleft) {const left: numberheading +=let heading: numberturn;const turn: number}Add
sensorAngle,sensorDistanceandturntoPhysarum. Set them from 30°, 35 cells and 45° in the scene. Convert the two angles to radians. Vary each turn between half and one and a half timesturnso agents do not all turn by the same amount.Without another rule, agents collect in a few thick threads and strengthen them further. The sleeping agents also need a way to wake. Track the agent count per cell in
takenand use it for two rules:- Occasionally put an agent to sleep if the 3 × 3 cells around it contain 30 or more agents.
- Let a sleeping agent pick an awake agent at random. Wake it two cells ahead of that agent if fewer than 5 agents stand there.
These rules move agents out of crowds and let the mould grow where its edges have room.
agents.ts consttaken =const taken: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>root.const root: TgpuRootcreateMutable(TgpuRoot.createMutable<d.WgslArray<d.Atomic<d.U32>>>(typeSchema: d.WgslArray<d.Atomic<d.U32>>, initial?: ((buffer: TgpuBuffer<NoInfer<d.WgslArray<d.Atomic<d.U32>>>>) => void) | d.InferInput<NoInfer<d.WgslArray<d.Atomic<d.U32>>>> | undefined): TgpuMutable<d.WgslArray<d.Atomic<d.U32>>> (+1 overload)Allocates memory on the GPU, allows passing data between host and shader. Can be mutated in-place on the GPU. For a general-purpose buffer, use
TgpuRoot.createBuffer
.
d.import darrayOf(arrayOf<d.Atomic<d.U32>>(elementType: d.Atomic<d.U32>, elementCount: number): d.WgslArray<d.Atomic<d.U32>> (+2 overloads)export arrayOfd.import datomic(atomic<d.U32>(data: d.U32): d.Atomic<d.U32>export atomicMarks a concrete integer scalar type schema (u32 or i32) as a WGSL atomic.
d.import du32),const u32: d.U32export u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
size *const size: numbersize));const size: numberconstneighbours = (const neighbours: (position: d.v2f) => numberposition:position: d.v2fd.import dv2f) => {export v2fInterface representing its WGSL vector type counterpart: vec2f or vec2. A vector with 2 elements of type f32
'use gpu';letcount =let count: numberd.import du32(0);function u32(v?: number | boolean): numberexport u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
for (lety = -1;let y: numbery <= 1;let y: numbery++) {let y: numberfor (letx = -1;let x: numberx <= 1;let x: numberx++) {let x: numbercount +=let count: numberstd.import stdatomicLoad(atomicLoad<d.atomicU32>(a: d.atomicU32): numberexport atomicLoadtaken.const taken: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]cellOf(const cellOf: (position: d.v2f) => numberposition +position: d.v2fd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)d.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
x),let x: numberd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
y)))]!);let y: number}}returncount;let count: number};// Inside move, replacing the early return for sleeping agentsif (agent.const agent: {position: d.v2f;heading: number;awake: number;}awake === 0) {awake: numberif (gid.const gid: d.v3ux >=v3u.x: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{alive: d.U32;}>>$.TgpuUniform<WgslStruct<{ alive: U32; }>>.$: {alive: number;}alive) return;alive: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}seed2(seed2: (seed: d.v2f) => voidThreads do not share the generator's
State. As a result, unless you change the seed in each thread, each thread will produce the same sequence.randf.randSeed2sets the private seed of the thread.d.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)d.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
gid.const gid: d.v3ux),v3u.x: numberagent.const agent: {position: d.v2f;heading: number;awake: number;}heading));heading: numberagent.const agent: {position: d.v2f;heading: number;awake: number;}heading =heading: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() *sample: () => numberReturns a random f32 value in [0, 1) range.
Math.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
PI * 2;Math.PI: numberPi. This is the ratio of the circumference of a circle to its diameter.
constparent =const parent: {position: d.v2f;heading: number;awake: number;}agents.const agents: TgpuMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>$[TgpuMutable<WgslArray<WgslStruct<{ position: Vec2f; heading: F32; awake: U32; }>>>.$: {position: d.v2f;heading: number;awake: number;}[]d.import du32(function u32(v?: number | boolean): numberexport u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
randf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() *sample: () => numberReturns a random f32 value in [0, 1) range.
d.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
physarum.const physarum: TgpuUniform<d.WgslStruct<{alive: d.U32;}>>$.TgpuUniform<WgslStruct<{ alive: U32; }>>.$: {alive: number;}alive))]!;alive: numberif (parent.const parent: {position: d.v2f;heading: number;awake: number;}awake === 0) return;awake: numberconstheading =const heading: numberparent.const parent: {position: d.v2f;heading: number;awake: number;}heading + (heading: numberrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() - 0.5) * 2;sample: () => numberReturns a random f32 value in [0, 1) range.
constposition =const position: d.v2fparent.const parent: {position: d.v2f;heading: number;awake: number;}position +position: d.v2ftoward(const toward: (angle: number) => d.v2fheading) * 2;const heading: numberif (neighbours(const neighbours: (position: d.v2f) => numberposition) <const position: d.v2fROOM &&const ROOM: numberenter(const enter: (position: d.v2f) => booleanposition)) {const position: d.v2fagents.const agents: TgpuMutable<d.WgslArray<d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;}>>>$[TgpuMutable<WgslArray<WgslStruct<{ position: Vec2f; heading: F32; awake: U32; }>>>.$: {position: d.v2f;heading: number;awake: number;}[]gid.const gid: d.v3ux] =v3u.x: numberAgent({const Agent: d.WgslStruct(props?: {position: d.v2f;heading: number;awake: number;} | undefined) => {position: d.v2f;heading: number;awake: number;}position,position: d.v2fheading,heading: numberawake: 1 });awake: number}return;}// After the seedif (randf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() <sample: () => numberReturns a random f32 value in [0, 1) range.
THINNING &&const THINNING: numberneighbours(const neighbours: (position: d.v2f) => numberagent.const agent: {position: d.v2f;heading: number;awake: number;}position) >=position: d.v2fCROWD) {const CROWD: numberstd.import stdatomicSub(atomicSub<d.atomicU32>(a: d.atomicU32, value: number): numberexport atomicSubtaken.const taken: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]cellOf(const cellOf: (position: d.v2f) => numberagent.const agent: {position: d.v2f;heading: number;awake: number;}position)]!, 1);position: d.v2fagent.const agent: {position: d.v2f;heading: number;awake: number;}awake = 0;awake: numberreturn;}takenholds an atomic count per cell.enter(position)adds 1 to the cell at that position. Call it inscatterfor each awake agent. Whenmovecrosses into another cell, subtract 1 from the old cell withstd.atomicSuband add 1 to the destination.- A sleeping agent changes its heading each step and uses it to seed random numbers. This lets it try a different parent each step until it wakes.
- Add
alivetoPhysarumto limit how many agents can wake. Returngrow(seconds, doubling)fromcreateAgents. It setsaliveto 16 384 doubled everydoublingseconds, capped at the agent count. Callagents.grow(sim.time, 2)after each frame’s steps so the mould grows gradually.
Watch the blob form threads within a second and grow outward as agents wake at its edges. Threads merge and branch as the network changes.
-
Feed it.
Real Physarum grows toward food and keeps paths between food sources. Add up to 32 oat flakes in
oats.ts, each a small rotated oval. When the flakes change, paint them into a texture the same size as the trail, with a baker, as a function of each cell’s position. Store each cell’s flake coverage, food scent, bright core and faint halo. The scent fades over about a tenth of the dish’s width. Create the oats in the scene withcreateOats, throughownto free the texture.oats.ts // Inside the baker's function, at cell at, with coordinates from -1 to 1 and width cellfor (leti =let i: numberd.import du32(0);function u32(v?: number | boolean): numberexport u32A schema that represents an unsigned 32-bit integer value. (equivalent to
u32in WGSL)Can also be called to cast a value to an u32 in accordance with WGSL casting rules.
i <let i: numberfood.const food: TgpuUniform<d.WgslStruct<{count: d.U32;strength: d.F32;reach: d.F32;}>>$.TgpuUniform<WgslStruct<{ count: U32; strength: F32; reach: F32; }>>.$: {count: number;strength: number;reach: number;}count;count: numberi++) {let i: numberconstoat =const oat: {at: d.v2f;size: d.v2f;angle: number;}oats.const oats: TgpuReadonly<d.WgslArray<d.WgslStruct<{at: d.Vec2f;size: d.Vec2f;angle: d.F32;}>>>$[TgpuReadonly<WgslArray<WgslStruct<{ at: Vec2f; size: Vec2f; angle: F32; }>>>.$: {at: d.v2f;size: d.v2f;angle: number;}[]i]!;let i: number// Transform the ellipse into a unit circle before measuring distanceconstlocal =const local: d.v2frotate(const rotate: (v: d.v2f, angle: number) => d.v2fat -const at: d.v2foat.const oat: {at: d.v2f;size: d.v2f;angle: number;}at, -at: d.v2foat.const oat: {at: d.v2f;size: d.v2f;angle: number;}angle) /angle: numberoat.const oat: {at: d.v2f;size: d.v2f;angle: number;}size;size: d.v2fconstr =const r: numberstd.import stdlength(length<d.v2f>(value: d.v2f): number (+1 overload)export lengthlocal);const local: d.v2fconstedge = (const edge: numberr - 1) *const r: numberstd.import stdmin(function min(fst: number, ...rest: number[]): number (+1 overload)export minoat.const oat: {at: d.v2f;size: d.v2f;angle: number;}size.size: d.v2fx,v2f.x: numberoat.const oat: {at: d.v2f;size: d.v2f;angle: number;}size.size: d.v2fy);v2f.y: numbercover =let cover: numberstd.import stdmax(function max(fst: number, ...rest: number[]): number (+1 overload)export maxcover,let cover: numberstd.import stdsmoothstep(function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothstepcell, -const cell: numbercell,const cell: numberedge));const edge: numberconstreach =const reach: numberstd.import stdmax(function max(fst: number, ...rest: number[]): number (+1 overload)export maxedge, 0) /const edge: numberfood.const food: TgpuUniform<d.WgslStruct<{count: d.U32;strength: d.F32;reach: d.F32;}>>$.TgpuUniform<WgslStruct<{ count: U32; strength: F32; reach: F32; }>>.$: {count: number;strength: number;reach: number;}reach;reach: numbersmell +=let smell: numberstd.import stdexp(-function exp(value: number): number (+1 overload)export expreach *const reach: numberreach);const reach: numbercore +=let core: numberstd.import stdexp(-function exp(value: number): number (+1 overload)export expr *const r: numberr *const r: numberCORE);const CORE: numberhalo +=let halo: numberstd.import stdexp(-function exp(value: number): number (+1 overload)export expr *const r: numberr *const r: numberHALO);const HALO: number}returnd.import dvec4f(const vec4f: d.Vec4f(x: number, y: number, z: number, w: number) => d.v4f (+9 overloads)cover,let cover: numbersmell,let smell: numbercore,let core: numberhalo);let halo: numberoats.foodAt(uv)returns the food scent and coverage at an agent’s position. It multiplies the scent bystrength, which the scene sets to 100. Pass this function tocreateAgentsas itsfoodoption. Add afedfield to each agent, with 1 for fully fed and 0 for starved:agents.ts constsniff = (const sniff: (agent: d.Infer<typeof Agent>, angle: number) => numberagent:agent: {position: d.v2f;heading: number;awake: number;fed: number;}d.import dInfer<typeoftype Infer<T> = T extends {readonly [$repr]: infer TRepr;} ? TRepr : Texport InferExtracts the inferred representation of a resource. For inferring types as seen by the GPU, see
InferGPU
Agent>,const Agent: d.WgslStruct<{position: d.Vec2f;heading: d.F32;awake: d.U32;fed: d.F32;}>angle: number) => {angle: number'use gpu';constuv = (const uv: d.v2fagent.agent: {position: d.v2f;heading: number;awake: number;fed: number;}position +position: d.v2ftoward(const toward: (angle: number) => d.v2fagent.agent: {position: d.v2f;heading: number;awake: number;fed: number;}heading +heading: numberangle) *angle: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{sensorDistance: d.F32;deposit: d.F32;hunger: d.F32;}>>$.TgpuUniform<WgslStruct<{ sensorDistance: F32; deposit: F32; hunger: F32; }>>.$: {sensorDistance: number;deposit: number;hunger: number;}sensorDistance) /sensorDistance: numbersize;const size: numberconsthunger =const hunger: numberstd.import stdsaturate(1 -function saturate(value: number): number (+1 overload)export saturateagent.agent: {position: d.v2f;heading: number;awake: number;fed: number;}fed * 2);fed: numberreturntrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv) +const uv: d.v2ffood(const food: (uv: d.v2f) => d.v2fuv).const uv: d.v2fx *v2f.x: numberhunger;const hunger: number};// Inside move, after the seedconsteats =const eats: booleanfood(const food: (uv: d.v2f) => d.v2fagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}position /position: d.v2fsize).const size: numbery > 0.5;v2f.y: numberagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}fed =fed: numberstd.import stdselect(function select(f: number, t: number, cond: boolean): number (+2 overloads)export selectReturns
tifcondistrue, andfotherwise. Component-wise ifcondis a vector.agent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}fed -fed: numberphysarum.const physarum: TgpuUniform<d.WgslStruct<{sensorDistance: d.F32;deposit: d.F32;hunger: d.F32;}>>$.TgpuUniform<WgslStruct<{ sensorDistance: F32; deposit: F32; hunger: F32; }>>.$: {sensorDistance: number;deposit: number;hunger: number;}hunger, 1,hunger: numbereats);const eats: booleanconstcrowded =const crowded: booleanrandf.const randf: {seed: TgpuFn<(seed: d.F32) => d.Void>;seed2: TgpuFn<(seed: d.Vec2f) => d.Void>;seed3: TgpuFn<(seed: d.Vec3f) => d.Void>;seed4: TgpuFn<(seed: d.Vec4f) => d.Void>;sample: TgpuFn<() => d.F32>;sampleExclusive: TgpuFn<() => d.F32>;normal: TgpuFn<(mu: d.F32, sigma: d.F32) => d.F32>;... 10 more ...;onUnitSphere: TgpuFn<...>;}sample() <sample: () => numberReturns a random f32 value in [0, 1) range.
THINNING &&const THINNING: numberneighbours(const neighbours: (position: d.v2f) => numberagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}position) >=position: d.v2fCROWD;const CROWD: numberif (agent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}fed <= 0 ||fed: numbercrowded) {const crowded: booleanstd.import stdatomicSub(atomicSub<d.atomicU32>(a: d.atomicU32, value: number): numberexport atomicSubtaken.const taken: TgpuMutable<d.WgslArray<d.Atomic<d.U32>>>$[TgpuMutable<WgslArray<Atomic<U32>>>.$: d.atomicU32[]cellOf(const cellOf: (position: d.v2f) => numberagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}position)]!, 1);position: d.v2fagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}awake = 0;awake: numberreturn;}// Replace the deposit with an amount based on fedtrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}deposit(deposit: (cell: d.v2i, amount: number) => voidAtomically adds
amountto a cell, allowing concurrent deposits.d.import dvec2i(const vec2i: d.Vec2i(v: AnyNumericVec2Instance) => d.v2i (+3 overloads)agent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}position),position: d.v2fphysarum.const physarum: TgpuUniform<d.WgslStruct<{sensorDistance: d.F32;deposit: d.F32;hunger: d.F32;}>>$.TgpuUniform<WgslStruct<{ sensorDistance: F32; deposit: F32; hunger: F32; }>>.$: {sensorDistance: number;deposit: number;hunger: number;}deposit * (0.3 + 1.7 *deposit: numberagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}fed *fed: numberagent.const agent: {position: d.v2f;heading: number;awake: number;fed: number;}fed));fed: numberThree rules make the mould forage:
- Hunger. Set
fedto 1 when an agent stands on a flake. Otherwise subtracthungereach step. WithhungeratSTEP / 6, a fully fed agent lasts 6 seconds without food. At 0 it sleeps, as crowded agents do. Branches without food die back, and their agents can wake where others have fed. Give a waking agent its parent’sfedvalue or a random value, whichever is greater. Inscatter, start agents between 0.5 and 1 so they do not all starve at once. - Smell. Below half fed, an agent senses food as well as trail. The hungrier it is, the more it follows food. Fed agents follow the network while hungry agents search for food.
- Deposit. Vary the drop size from 0.3 times
depositwhen starving to 2 timesdepositwhen fully fed. The strongest trails form at food and along paths from it, creating thick branches.
Draw the food in
veinsby adding a bright point and faint halo to the orange trail.oats.glowAt(uv)returns these from the texture’s last two numbers. Calloats.paint(frame.encoder)after the scatter in the scene’s frame. It records work only when the flakes have changed.Give the scene the methods and values a page needs.
feed(x, y)takes a tap, withxandyfrom 0 to 1 across and down the canvas, and adds a flake there. A'use gpu'function also runs as plain TypeScript, so usesheetAton the CPU to convert the tap to a position on the dish.valuesholds the numbers a panel changes, andwritesends them to the uniforms.regrowclears the trail and scatters the blob again. Keep the time it did so inborn, and grow withagents.grow(sim.time - born, 2)from then on.scene.ts // Inside createScene, replacing the fixed agents.set and trail.setconstvalues = {const values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}sensorAngle: 30,sensorAngle: numbersensorDistance: 35,sensorDistance: numberdecay: 0.07,decay: numberfood: 100 };food: numberconstwrite = () => {const write: () => voidagents.const agents: {initAsync: () => Promise<[void, void]>;set(values: Omit<d.InferInput<typeof Physarum>, "alive">): void;grow(seconds: number, doubling: number): void;scatter(encoder: TgpuCommandEncoder): void;move(pass: TgpuComputePass): void;destroy(): void;}set({function set(values: Omit<d.InferInput<typeof Physarum>, "alive">): voidSets the growth limit, sensing, steering and hunger rate. Clamps the count to capacity.
count: 400_000,count: numbersensorAngle:sensorAngle: numbervalues.const values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}sensorAngle *sensorAngle: numberDEGREES,const DEGREES: numbersensorDistance:sensorDistance: numbervalues.const values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}sensorDistance,sensorDistance: numberturn: 45 *turn: numberDEGREES,const DEGREES: numberspeed: 1.2,speed: numberdeposit: 0.5,deposit: numberhunger:hunger: numberSTEP / 6,const STEP: number});trail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}set({set: (values: d.InferInput<typeof Spread>) => voidkeep: 1 -keep: numbervalues.const values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}decay,decay: numberblur: 1 });blur: numberoats.const oats: {foodAt: (uv: d.v2f) => d.v2f;glowAt: (uv: d.v2f) => d.v2f;initAsync: () => Promise<void>;set(strength: number): void;drop(at: d.v2f): void;paint(encoder: TgpuCommandEncoder): void;destroy(): void;}set(function set(strength: number): voidvalues.const values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}food);food: number};write();const write: () => void// Beside frame and destroyreturn {values,values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}write,write: () => voidregrow() {function regrow(): voidtrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}clear();function clear(): voidscattering = true;let scattering: booleanborn =let born: numbersim.const sim: clock.FixedSteptime;FixedStep.time: numberReports the simulated seconds: the sum of every step
advancehas returned.},feed(function feed(x: number, y: number): voidx: number,x: numbery: number) {y: numberconst {width,const width: numberReports the drawing-buffer size in pixels.
height } =const height: numbersurface;const surface: Surfaceoats.const oats: {foodAt: (uv: d.v2f) => d.v2f;glowAt: (uv: d.v2f) => d.v2f;initAsync: () => Promise<void>;set(strength: number): void;drop(at: d.v2f): void;paint(encoder: TgpuCommandEncoder): void;destroy(): void;}drop(function drop(at: d.v2f): voidAdds a flake at sheet coordinates
at, from -1 to 1. Replaces the oldest flake at capacity.sheetAt(function sheetAt(pixel: d.v2f, size: d.v2f): d.v2fSheet coordinates have (0, 0) at the picture centre, with y pointing down. One unit is half the picture's longer side. The mould grid covers -1 to 1 on both axes, so on a wide picture its top and bottom run past the edges.
d.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)x *x: numberwidth,const width: numberReports the drawing-buffer size in pixels.
y *y: numberheight),const height: numberd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)width,const width: numberReports the drawing-buffer size in pixels.
height)));const height: number},};index.tsconnects them to the page inattach, which runs once the scene is built.web.pointercallsdownwith the tap’s position. A panel from lil-gui gets a slider for each value and a button forregrow. Add lil-gui to the project:npm i lil-guipnpm add lil-guiyarn add lil-guibun add lil-guiThe host disposes what
attachreturns with the scene, so return a disposable that removes both:index.ts import * asweb from '@typegpu/kit/web';import webimportGUI from 'lil-gui';import GUIimport {createScene } from './scene.ts';function createScene(root: TgpuRoot, surface: Surface): Promise<{values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}>constcanvas =const canvas: HTMLCanvasElementdocument.var document: Documentwindow.documentreturns a reference to the document contained in the window.querySelector('canvas')!;ParentNode.querySelector<"canvas">(selectors: "canvas"): HTMLCanvasElement | null (+4 overloads)Returns the first element that is a descendant of node that matches selectors.
web.import webhost(function host<{values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}>(createScene: web.CreateScene<Record<never, never>, {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}>, hosting: web.HostOptions<{values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}>): web.Hosted<...> (+1 overload)Runs a scene on a canvas with a root, surface, frame loop and animation.
destroytears them down in order. Usesendto control the scene oronFrameto read it. If scene options are optional, callhost(createScene, { canvas }). Throws if bothdeviceandrootare supplied.createScene, {function createScene(root: TgpuRoot, surface: Surface): Promise<{values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}>canvas,HostOptions<S extends Scene>.canvas: HTMLCanvasElementSupplies the canvas for the scene.
attach({HostOptions<{ values: { sensorAngle: number; sensorDistance: number; decay: number; food: number; }; write(): void; regrow(): void; feed(x: number, y: number): void; frame(frame: Frame): void; destroy(): void; }, Surface>.attach?: ((stage: Stage<{values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}, web.Surface>) => Disposable | void) | undefinedConnects page code, such as orbit controls, to the stage after scene creation. The host disposes its return value with the scene. Default none.
scene }) {scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}constpointer =const pointer: web.Pointerweb.import webpointer(function pointer(element: HTMLElement, options?: web.PointerOptions): web.PointerFollows the mouse, touch or pen on an element in the coordinates
rayAttakes, 0 to 1 across the element with +y down. Read the position each frame or take each move, press, release and zoom. A button pressed on the element keeps the pointer until it comes up, so a drag continues outside and its positions run past 0 and 1.canvas, {const canvas: HTMLCanvasElementdown: (PointerOptions.down?: web.PointerHandler | undefinedRuns as a button goes down on the element. Read the button from
event.button. Default none.x,x: numbery) =>y: numberscene.scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}feed(function feed(x: number, y: number): voidx,x: numbery) });y: numberconstgui = newconst gui: anyGUI({import GUItitle: 'slime mould' });title: stringgui.const gui: anyadd(anyscene.scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}values, 'sensorAngle', 5, 90, 1).values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}name('sensor angle (degrees)');anygui.const gui: anyadd(anyscene.scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}values, 'sensorDistance', 2, 40, 0.5).values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}name('sensor distance (cells)');anygui.const gui: anyadd(anyscene.scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}values, 'decay', 0, 0.5, 0.005);values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}gui.const gui: anyadd(anyscene.scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}values, 'food', 0, 200, 1);values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;}gui.const gui: anyonChange(anyscene.scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}write);function write(): voidgui.const gui: anyadd(anyscene, 'regrow');scene: {values: {sensorAngle: number;sensorDistance: number;decay: number;food: number;};write(): void;regrow(): void;feed(x: number, y: number): void;frame(frame: Frame): void;destroy(): void;}return {[Symbol.var Symbol: SymbolConstructordispose]() {SymbolConstructor.dispose: typeof Symbol.disposeA method that is used to release resources held by an object. Called by the semantics of the
usingstatement.pointer.const pointer: web.Pointerdestroy();Pointer.destroy(): voidRemoves the listeners and restores the element's
touch-action. Also[Symbol.dispose].gui.const gui: anydestroy();any},};},});Watch the mould reach for the flakes and keep the threads between them. The centre empties as the food sources connect and unfed threads die back.
- Hunger. Set
-
Turn density into light.
A thread made by one agent and a hub of hundreds can differ a hundredfold in density. Mapping density directly to brightness cannot show both.
saturate(density)makes dense threads the same flat orange. Dividing density enough to distinguish the hubs makes the fine threads too dark.Use
log(1 + density × GAIN)to brighten faint trails while compressing dense ones. Divide by the same curve atFULL, or 18, to get a tone from 0 to 1. Use that tone to select a colour along a ramp. It runs from crimson for faint threads through red and orange to gold at the hubs.Raise GAINto brighten faint threads while compressing dense hubs. The strips compare the same cross-section using flat amber or a curve and ramp that distinguish threads by brightness and colour.veins.ts constCRIMSON =const CRIMSON: d.v3fhex(0x3a0610);function hex(rgb: number): d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
constRED =const RED: d.v3fhex(0xb0201a);function hex(rgb: number): d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
constGOLD =const GOLD: d.v3fhex(0xffe0a0);function hex(rgb: number): d.v3fConverts an sRGB hex colour, such as 0xc0301c, to linear light.
constFULL = 18;const FULL: 18constGAIN = 0.6;const GAIN: 0.6constramp = (const ramp: (tone: number) => d.v3ftone: number) => {tone: number'use gpu';letcolor =let color: d.v3fCRIMSON *const CRIMSON: d.v3fstd.import stdsmoothstep(0, 0.25,function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothsteptone);tone: numbercolor =let color: d.v3fstd.import stdmix(mix<d.v3f>(e1: d.v3f, e2: d.v3f, e3: number): d.v3f (+2 overloads)export mixcolor,let color: d.v3fRED,const RED: d.v3fstd.import stdsmoothstep(0.2, 0.5,function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothsteptone));tone: numbercolor =let color: d.v3fstd.import stdmix(mix<d.v3f>(e1: d.v3f, e2: d.v3f, e3: number): d.v3f (+2 overloads)export mixcolor,let color: d.v3fORANGE,const ORANGE: d.v3fstd.import stdsmoothstep(0.45, 0.8,function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothsteptone));tone: numberreturnstd.import stdmix(mix<d.v3f>(e1: d.v3f, e2: d.v3f, e3: number): d.v3f (+2 overloads)export mixcolor,let color: d.v3fGOLD,const GOLD: d.v3fstd.import stdsmoothstep(0.75, 1,function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothsteptone));tone: number};// Inside veins, replacing the plain orangeconstreach = 0.75 /const reach: numberd.import df32(function f32(v?: number | boolean): numberexport f32A schema that represents a 32-bit float value. (equivalent to
f32in WGSL)Can also be called to cast a value to an f32.
trail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}size);size: numberGrid width and height in cells.
constdensity =const density: number(trail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv) * 2 +const uv: d.v2ftrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv +const uv: d.v2fd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)reach, 0)) +const reach: numbertrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv -const uv: d.v2fd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)reach, 0)) +const reach: numbertrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv +const uv: d.v2fd.import dvec2f(0,const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)reach)) +const reach: numbertrail.const trail: {size: number;deposit: (cell: d.v2i, amount: number) => void;at: (uv: d.v2f) => number;initAsync: () => Promise<[void, void]>;set: (values: d.InferInput<typeof Spread>) => void;spread(pass: TgpuComputePass): void;clear(): void;destroy(): void;}at(at: (uv: d.v2f) => numberSamples the trail at grid coordinates
uvfrom 0 to 1, with interpolation between cells.uv -const uv: d.v2fd.import dvec2f(0,const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)reach))) /const reach: number6;consttone =const tone: numberstd.import stdlog(1 +function log(value: number): number (+1 overload)export logdensity *const density: numberGAIN) /const GAIN: 0.6std.import stdlog(1 +function log(value: number): number (+1 overload)export logFULL *const FULL: 18GAIN);const GAIN: 0.6returnd.import dvec4f(const vec4f: d.Vec4f(v0: AnyNumericVec3Instance, w: number) => d.v4f (+9 overloads)linearToSrgb(function linearToSrgb(linear: d.v3f): d.v3framp(const ramp: (tone: number) => d.v3ftone) *const tone: numberlook.const look: TgpuUniform<d.WgslStruct<{light: d.F32;pulse: d.F32;}>>$.TgpuUniform<WgslStruct<{ light: F32; pulse: F32; }>>.$: {light: number;pulse: number;}light +light: numberfood), 0);const food: d.v3fThis curve would make individual drops look grainy. Smooth the density by averaging the trail under the pixel with four surrounding points. Store the brightness,
light, in alookuniform. Addlight: 1tovalues, write it inwritewithveins.look.write, and give it a slider from 0 to 2 inindex.ts. Mix the ramp’s colours in linear light and encode the result withlinearToSrgbuntil the next step.slime-mouldA slime mould grows glowing threads across dark agar and connects the food it finds.Change the brightness to see fine crimson threads, red and orange branches, and gold paths to the food. Pulse and glow are off for this step. -
Pulse, and glow.
In living Physarum, cytoplasm flows back and forth through the veins every minute or two. Represent that motion with a brightness wave that travels outward along the thick branches every 3.5 seconds:
- Decrease its phase with distance from the centre so the wave travels outward.
- Vary the phase with angle to bend each wavefront slightly.
- Use
smoothstepto keep the pulse in dense branches, leaving fine threads still.
veins.ts constPERIOD = 3.5;const PERIOD: 3.5constWAVES = 1.6;const WAVES: 1.6// Inside veins, after calculating toneconstswirl = 0.15 *const swirl: numberstd.import stdsin(function sin(value: number): number (+1 overload)export sinstd.import stdatan2(function atan2(y: number, x: number): number (+1 overload)export atan2at.const at: d.v2fy,v2f.y: numberat.const at: d.v2fx) * 3 +v2f.x: numberstd.import stdlength(length<d.v2f>(value: d.v2f): number (+1 overload)export lengthat) * 5);const at: d.v2fconstphase =const phase: numberworld.const world: TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>$.TgpuUniform<WgslStruct<{ size: Vec2f; time: F32; }>>.$: {size: d.v2f;time: number;}time /time: numberPERIOD -const PERIOD: 3.5std.import stdlength(length<d.v2f>(value: d.v2f): number (+1 overload)export lengthat) *const at: d.v2fWAVES +const WAVES: 1.6swirl;const swirl: numberconstwave =const wave: numberstd.import stdsin(function sin(value: number): number (+1 overload)export sinphase *const phase: numberMath.var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.
PI * 2) *Math.PI: numberPi. This is the ratio of the circumference of a circle to its diameter.
std.import stdsmoothstep(0.55, 0.9,function smoothstep(edge0: number, edge1: number, x: number): number (+1 overload)export smoothsteptone);const tone: numberconstpulse = 1 + 0.4 *const pulse: numberlook.const look: TgpuUniform<d.WgslStruct<{light: d.F32;pulse: d.F32;}>>$.TgpuUniform<WgslStruct<{ light: F32; pulse: F32; }>>.$: {light: number;pulse: number;}pulse *pulse: numberwave;const wave: numberreturnd.import dvec4f(const vec4f: d.Vec4f(v0: AnyNumericVec3Instance, w: number) => d.v4f (+9 overloads)ramp(const ramp: (tone: number) => d.v3ftone) * (const tone: numberlook.const look: TgpuUniform<d.WgslStruct<{light: d.F32;pulse: d.F32;}>>$.TgpuUniform<WgslStruct<{ light: F32; pulse: F32; }>>.$: {light: number;pulse: number;}light *light: numberpulse) +const pulse: numberfood, 0);const food: d.v3fatissheetAt(pixel.xy, world.$.size).world.$.timeholds elapsed simulation seconds, written by the scene as in step 2. AddpulsetoLookand tovalues, with a slider from 0 to 2.Let the brightest hubs exceed the canvas’s range. Change the ramp’s last line to raise the densest trail to 2.2 times gold:
return color * (1 + 1.2 * std.smoothstep(0.85, 1.2, tone));The canvas holds colour values from 0 to 1 and clips anything brighter. Draw into an
hdrrender target with thergba16floatformat to keep values above 1. Finish the frame with two passes from@typegpu/kit:- Bloom blurs what is brighter than its
thresholdinto a wide, soft glow, as a camera lens does. - The final pass adds bloom, maps brightness into the canvas’s range of 0 to 1, darkens the corners and encodes sRGB. Return linear colours from the agar and veins fragment functions, without
linearToSrgb.
In the figure, each disc is twice as bright as the previous one. Change the threshold to choose which discs glow.
Raise the threshold to stop dimmer discs glowing, or reduce levels to narrow the glow. Each disc is twice as bright as the previous one. scene.ts // Inside createScene, give agar and veins the HDR formatconstHDR = 'rgba16float';const HDR: "rgba16float"constscreen =const screen: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>own(const own: Owner<targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>>(item: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>) => targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>Takes
itemto free atdestroyand returns it. Passesundefinedthrough. Frees a fixed resource, such as one fromroot.createUniform, through itsbuffer, and runs a cleanup function. Afterdestroy, freesitemat once.targets.import targetscreate(function create<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>(root: TgpuRoot, source: targets.SizeSource, specs: {readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}, options?: targets.Options): targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>Creates render targets that follow a size. Replaces the whole set when the size changes. Put targets needed only sometimes in their own set and create that set when needed. Throws for transient targets with sampling, storage or mip levels, for multisampled targets with storage or mip levels, and for layered targets that are multisampled, mipped or have more layers than the device allows.
root,const root: TgpuRootsurface, {const surface: targets.Surfacehdr: {hdr: {readonly format: "rgba16float";readonly sampled: true;}format:format: "rgba16float"HDR,const HDR: "rgba16float"sampled: true } }));sampled: trueconsthdr = () =>const hdr: () => targets.Texture<{readonly format: "rgba16float";readonly sampled: true;}>screen.const screen: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>textures.Targets<{ readonly hdr: { readonly format: "rgba16float"; readonly sampled: true; }; }>.textures: {readonly hdr: targets.Texture<{readonly format: "rgba16float";readonly sampled: true;}>;}Provides the current textures. A size change replaces them. Throws after
destroy.hdr;hdr: targets.Texture<{readonly format: "rgba16float";readonly sampled: true;}>constglow =const glow: bloom.Bloomown(const own: Owner<bloom.Bloom>(item: bloom.Bloom) => bloom.BloomTakes
itemto free atdestroyand returns it. Passesundefinedthrough. Frees a fixed resource, such as one fromroot.createUniform, through itsbuffer, and runs a cleanup function. Afterdestroy, freesitemat once.bloom.import bloomcreate(function create(root: TgpuRoot, options: bloom.Options): bloom.BloomCreates bloom from a chain of half-size textures. Each level downsamples with 13 taps, then a tent filter adds it to the next larger level. The result is mip 0 of
output, at half the source size. Withregion, passes draw and sample only that region; changing the region allocates nothing.root, {const root: TgpuRootsource:Options.source: (() => finish.FloatTexture) | finish.RenderedProvides the HDR picture: a rendered picture, such as
aa, or a function returning a texture, such as() => screen.textures.hdr.recordreads it on every call, so the texture can be rebuilt.hdr,const hdr: () => targets.Texture<{readonly format: "rgba16float";readonly sampled: true;}>settings: {Options.settings?: Partial<d.InferInput<d.WgslStruct<{threshold: d.F32;knee: d.F32;spread: d.F32;}>>> | undefinedSets initial values over
defaults. Defaultdefaults, with threshold 1, knee 0.5 and spread 1.threshold: 1,threshold: numberknee: 0.5 } }));knee: numberconstfinal =const final: finish.Finishown(const own: Owner<finish.Finish>(item: finish.Finish) => finish.FinishTakes
itemto free atdestroyand returns it. Passesundefinedthrough. Frees a fixed resource, such as one fromroot.createUniform, through itsbuffer, and runs a cleanup function. Afterdestroy, freesitemat once.finish.import finishcreate(function create(root: TgpuRoot, options: finish.Options): finish.FinishCreates the final pass of a frame. It reads the source, adds glow, tone maps, grades, applies the vignette, encodes and dithers. It draws one triangle into the caller pass.
root, {const root: TgpuRootformat:Options.format: GPUTextureFormatSets the output format, such as the canvas format.
surface.const surface: targets.Surfaceformat,Surface.format: GPUTextureFormatsource:Options.source: finish.Rendered | (() => finish.FloatTexture)Provides the picture at any size: a rendered picture, such as
aaorblur, or a function returning a texture, such as() => screen.textures.hdr.drawreads it on every call, so the texture can be rebuilt.hdr,const hdr: () => targets.Texture<{readonly format: "rgba16float";readonly sampled: true;}>glow,Options.glow?: finish.Rendered | undefinedAdds a bloom, or a glow of your own such as an emission target, before tone mapping.
drawreads itsoutputandregionon every call. Default none.settings: {Options.settings?: Partial<d.InferInput<d.WgslStruct<{exposure: d.F32;vignette: d.F32;dither: d.F32;glowIntensity: d.F32;}>>> | undefinedSets initial values over
defaults. Defaultdefaults, with exposure 1, vignette 0.35, dither 1 and glowIntensity 0.12.glowIntensity: 0.2,glowIntensity: numbervignette: 0.3 },vignette: number}),);awaitpipelines.import pipelinescompile([function compile(items: readonly pipelines.Warmable[], options?: pipelines.CompileOptions): Promise<void>Starts compiling every item, yielding to the event loop past the time budget so the page's other work, such as a loading animation, keeps running. Use before the frame loop starts. Waits for every item to finish, then rejects if any failed.
agar,const agar: pipelines.Warmabletrail,const trail: pipelines.Warmableoats,const oats: pipelines.Warmableagents,const agents: pipelines.Warmableveins,const veins: pipelines.Warmableglow,const glow: bloom.Bloomfinal]);const final: finish.Finish// Inside frameif (screen.const screen: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>fit())Targets<{ readonly hdr: { readonly format: "rgba16float"; readonly sampled: true; }; }>.fit(): booleanRebuilds all textures if the source size changed, destroying the old textures. Updates
regionsfor the current resolution. Returns true after a rebuild so callers can update bind groups that hold old textures. Call before recording passes that use the targets. Throws afterdestroy.world.const world: TgpuUniform<d.WgslStruct<{size: d.Vec2f;time: d.F32;}>>patch({TgpuBufferBindingBase<WgslStruct<{ size: Vec2f; time: F32; }>>.patch(data: {size?: d.InferInput<d.Vec2f> | undefined;time?: number | undefined;} | undefined): voidsize:size?: d.InferInput<d.Vec2f> | undefinedd.import dvec2f(const vec2f: d.Vec2f(x: number, y: number) => d.v2f (+3 overloads)screen.const screen: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>width,Targets<{ readonly hdr: { readonly format: "rgba16float"; readonly sampled: true; }; }>.width: numberReports the source size rounded up to whole pixels, before applying each target's scale.
screen.const screen: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>height) });Targets<{ readonly hdr: { readonly format: "rgba16float"; readonly sampled: true; }; }>.height: number// Record the simulation steps hereconstpass =const pass: TgpuRenderPassframe.const frame: Frameencoder.Frame.encoder: TgpuCommandEncoderRecords commands for submission after the recorder returns.
submit()replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.beginRenderPass({TgpuCommandEncoder.beginRenderPass(descriptor: TgpuRenderPassDescriptor): TgpuRenderPassBegins recording a render pass. Attachment views accept TypeGPU textures, texture views and canvas contexts, next to raw
GPUTextureView
s.
colorAttachments: {TgpuRenderPassDescriptor.colorAttachments?: ColorAttachment | readonly (ColorAttachment | null)[] | undefinedview:ColorAttachment.view: TgpuTextureRenderView | (ColorTextureConstraint & RenderFlag) | GPUTextureView | TgpuTextureView<d.WgslTexture<WgslTextureProps>> | GPUCanvasContextA
GPUTextureView
describing the texture subresource that will be output to for this color attachment.
screen.const screen: targets.Targets<{readonly hdr: {readonly format: "rgba16float";readonly sampled: true;};}>views.Targets<{ readonly hdr: { readonly format: "rgba16float"; readonly sampled: true; }; }>.views: {readonly hdr: GPUTextureView;}Provides attachment views of mip 0, created once per rebuild. Throws after
destroy.hdr } });hdr: GPUTextureView// Draw the agar and veins herepass.const pass: TgpuRenderPassend();TgpuRenderPass.end(): voidCompletes the recording of this render pass
glow.const glow: bloom.Bloomrecord(Bloom.record(target: Frame | TgpuCommandEncoder, options?: Timed): voidRecords bloom passes after the source is written. Optional timer writes measure the passes together.
frame);const frame: Framefinal.const final: finish.Finishrecord(Finish.record(target: Frame | TgpuCommandEncoder, view: GPUTextureView, options?: finish.RecordOptions): voidOpens a pass on
view, such assurface.view(frame), draws into it and ends it. Aviewportdraws the picture into that rectangle and clears the rest, transparent withalpha: 'premultiplied'and black otherwise. Optional timer writes measure the pass.frame,const frame: Framesurface.const surface: targets.Surfaceview(Surface.view(frame: Frame): GPUTextureViewReturns a view of the current texture, created once per texture. React Native requires the frame to present after submission.
frame));const frame: FrameDraw only the final pass into the canvas.
final.recordopens a pass on the canvas, draws the final pass and ends it. When the canvas resizes,screen.fit()creates matching textures and returnstrue. Write that size toworld. Usepipelines.compileto compile all pipelines together instead of awaiting eachinitAsync. For aglowslider, addglow: 1tovaluesand patchfinal.settingswithglowIntensity: 0.2 * values.glowinwrite.slime-mouldA slime mould grows glowing threads across dark agar and connects the food it finds.Tap to add food, widen the sensor angle for a looser network, or increase the sensor distance for longer, straighter threads. The mould grows toward food; regrow to start again.
What’s in the example
Section titled “What’s in the example”Directorysrc/
- index.ts
web.host, the tap and the panel - scene.ts
createScene(root, surface)with the uniform, target, modules, values and frame - agar.ts the dish,
sheetAtandhex - agents.ts the agents’ buffer, the crowd counts and the two kernels
- trail.ts the trail’s two textures, the drops and the blur
- oats.ts the flakes, their texture and the baker that paints it
- veins.ts the tone curve, the ramp, the pulse and the draw
- index.ts
The example’s files match yours, except that its scene.ts also describes its panel and taps for the example page, which hosts it without an index.ts.
Things to try in the example:
- Add a second species with its own buffer. Let its agents sense their own trail and avoid the first species’ trail.
- Make food run out by lowering a flake’s strength while agents eat on it. The mould should move on.
- Raise the count toward its million on a fast GPU, or lower it on a phone, and see how the network’s grain changes.
Domino run builds a 3D scene with 1 474 dominoes that fall on the GPU from the one you tap. Write a module builds a module of your own and tests it.
© Software Mansion 2026. All trademarks and copyrights belong to their respective owners. Read about our Privacy Policy.