Skip to content

Data on the GPU

Keep objects as rows in buffers, move them on the GPU on a fixed step, and read results back without waiting.

A game with four thousand enemies can’t upload each one’s position every frame. The kit keeps every object as a row in a GPU buffer. The CPU writes the few fields it owns when they change, kernels move the rest, and one pipeline draws them all. What the CPU needs to know comes back a frame or two later, without stalling the frame.

import {
import clock
clock
,
import events
events
,
import frames
frames
,
import table
table
} from '@typegpu/kit';
const
const STEP: number
STEP
= 1 / 60;
const
const sim: clock.FixedStep
sim
=
import clock
clock
.
function fixedStep(options: clock.FixedStepOptions): clock.FixedStep

Accumulates elapsed time for fixed simulation steps. Accumulates nothing while paused. Throws unless step is finite and above 0 and maxSteps is an integer above 0.

@returns ― a clock whose advance says how many steps to run each frame

fixedStep
({
FixedStepOptions.step: number

Sets the step duration in seconds, such as 1 / 120.

step
:
const STEP: number
STEP
});
const
const Enemy: table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>
Enemy
=
import table
table
.
function define<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>(name: "Enemy", parts: {
host?: {
radius: d.F32;
} | undefined;
device?: {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
} | undefined;
}): table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>

Declares CPU-written host fields and GPU-written device fields using the records accepted by d.struct. Define it at module scope beside shaders. Throws if no fields are declared, a field appears in both parts, the CPU writer cannot write a field type such as f16, or a field uses a reserved member name such as alive.

name brands row ids as RowId<'Enemy'> and labels structs, buffers, WGSL functions and errors. It is not a lookup key.

@param ― name a label for ids and WGSL

@param ― parts the host fields the CPU writes and the device fields the GPU writes

@returns ― a definition whose getters shaders read and that table.create allocates

define
('Enemy', {
host?: {
radius: d.F32;
} | undefined
host
: {
radius: d.F32
radius
:
import d
d
.
const f32: d.F32
export f32

A schema that represents a 32-bit float value. (equivalent to f32 in WGSL)

Can also be called to cast a value to an f32.

@example const value = f32(); // 0

@example const value = f32(1.23); // 1.23

@example const value = f32(true); // 1

f32
}, // written by the CPU
device?: {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
} | undefined
device
: {
position: d.Vec3f
position
:
import d
d
.
const vec3f: d.Vec3f
vec3f
,
velocity: d.Vec3f
velocity
:
import d
d
.
const vec3f: d.Vec3f
vec3f
,
health: d.F32
health
:
import d
d
.
const f32: d.F32
export f32

A schema that represents a 32-bit float value. (equivalent to f32 in WGSL)

Can also be called to cast a value to an f32.

@example const value = f32(); // 0

@example const value = f32(1.23); // 1.23

@example const value = f32(true); // 1

f32
}, // by kernels
});
const
const enemies: table.Table<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>>
enemies
=
import table
table
.
function create<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>>(root: TgpuRoot, def: table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>, options: table.Options<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>>): table.Table<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>> (+1 overload)

Allocates GPU buffers and a CPU copy of host rows. Throws unless capacity is an integer in [1, 2^20].

@param ― def the definition its shaders were written against, from table.define

@returns ― a table you destroy

create
(
const root: TgpuRoot
root
,
const Enemy: table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>
Enemy
, {
Options<T extends AnyTableDef>.capacity: number

Sets the table capacity for its lifetime. Must be an integer in [1, 2^20], with slots from 0 to capacity - 1. A row id uses enough bits for the slot and the rest for its generation. The generation has 29 bits at 8 rows and 12 bits at 2^20 rows.

capacity
: 4096,
Options<TableDef<"Enemy", { radius: F32; }, { position: Vec3f; velocity: Vec3f; health: F32; }>>.defaults?: table.Values<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>> | undefined

Sets values for fields omitted by add. Without defaults, every field starts at zero. Default none.

defaults
: {
radius?: number | undefined
radius
: 0.5,
health?: number | undefined
health
: 10 },
});
const
const falls: events.Events<"falls">
falls
=
import events
events
.
function create<"falls">(root: TgpuRoot, names: readonly "falls"[], options?: events.Options): events.Events<"falls">

Counts named events on the GPU and reads them back to the CPU. Kernels add hits, kills, pickups or other events. The CPU receives the increases a frame or two later. Requires at least one name, with no duplicates. Throws if names are invalid, hz is not finite and above 0, or staging is not an integer above 0.

@injects ― per-instance add, for kernels piped with it

@binds ― 1 storage: add, the counters

@owns ― a buffer, a readback: the counters; the readback copies them at hz

@records ― capture after the passes that add; clear before them, whenever the counting starts again; in frame mode, capture clears after copying.

@param ― names one counter each

@returns ― a set of counters you destroy

create
(
const root: TgpuRoot
root
, ['falls']);
const
const crowd: TgpuComputeFn<{
gid: d.BuiltinGlobalInvocationId;
}>
crowd
=
const tgpu: {
const: typeof import("node_modules/typegpu/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/tgpuUnstable");
}

@module ― typegpu

tgpu
.
computeFn: <{
gid: d.BuiltinGlobalInvocationId;
}>(options: {
in: {
gid: d.BuiltinGlobalInvocationId;
};
workgroupSize: number[];
}) => TgpuComputeFnShell<{
gid: d.BuiltinGlobalInvocationId;
}> (+1 overload)
computeFn
({
workgroupSize: number[]
workgroupSize
: [64],
in: {
gid: d.BuiltinGlobalInvocationId;
}
in
: {
gid: d.BuiltinGlobalInvocationId
gid
:
import d
d
.
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 builtin
builtin
.
globalInvocationId: d.BuiltinGlobalInvocationId
globalInvocationId
} })(({
gid: d.v3u
gid
,
}) => {
'use gpu';
const
const i: number
i
=
gid: d.v3u
gid
.
v3u.x: number
x
;
if (!
const Enemy: table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>
Enemy
.
alive: TgpuGenericFn
(i: number) => boolean

Tests whether slot i holds a row. Returns false beyond the span.

alive
(
const i: number
i
)) return;
const
const enemy: {
position: d.v3f;
velocity: d.v3f;
health: number;
}
enemy
=
const Enemy: table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>
Enemy
.
device: TgpuAccessor<table.Rows<{
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>>

Provides GPU-written rows with read-write access in compute pipelines and read-only access in render pipelines.

device
.
TgpuAccessor<Rows<{ position: Vec3f; velocity: Vec3f; health: F32; }>>.$: {
position: d.v3f;
velocity: d.v3f;
health: number;
}[]
$
[
const i: number
i
];
const enemy: {
position: d.v3f;
velocity: d.v3f;
health: number;
}
enemy
.
position: d.v3f
position
+=
const enemy: {
position: d.v3f;
velocity: d.v3f;
health: number;
}
enemy
.
velocity: d.v3f
velocity
*
const STEP: number
STEP
;
if (
const enemy: {
position: d.v3f;
velocity: d.v3f;
health: number;
}
enemy
.
position: d.v3f
position
.
v3f.y: number
y
< -10 &&
const enemy: {
position: d.v3f;
velocity: d.v3f;
health: number;
}
enemy
.
health: number
health
> 0) {
const enemy: {
position: d.v3f;
velocity: d.v3f;
health: number;
}
enemy
.
health: number
health
= 0;
const falls: events.Events<"falls">
falls
.
Events<"falls">.add: {
readonly falls: (amount: number) => void;
}

Adds amount to a counter atomically in a shader. Create the shader from root.pipe(contacts.inject()), then call contacts.add.stomps(1).

add
.
falls: (amount: number) => void
falls
(1);
}
});
const
const crowding: TgpuComputePipeline
crowding
=
const root: TgpuRoot
root
.
WithBinding.pipe(transform: (cfg: Configurable) => Configurable): WithBinding
pipe
(
const enemies: table.Table<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>>
enemies
.
Table<TableDef<"Enemy", { radius: F32; }, { position: Vec3f; velocity: Vec3f; health: F32; }>>.inject(): Inject

Fills the definition's accessors with this table's buffers.

inject
())
.
WithBinding.pipe(transform: (cfg: Configurable) => Configurable): WithBinding
pipe
(
const falls: events.Events<"falls">
falls
.
Events<"falls">.inject(): Inject

Fills add for the pipelines created from what it is piped into.

inject
())
.
WithBinding.createComputePipeline<{
gid: d.BuiltinGlobalInvocationId;
}>(descriptor: TgpuComputePipeline.Descriptor<{
gid: d.BuiltinGlobalInvocationId;
}>): TgpuComputePipeline
createComputePipeline
({
compute: TgpuComputeFn<{
gid: d.BuiltinGlobalInvocationId;
}>
compute
:
const crowd: TgpuComputeFn<{
gid: d.BuiltinGlobalInvocationId;
}>
crowd
});
import frames
frames
.
function create(root: TgpuRoot, record: Recorder): frames.Frames

Records a frame into one encoder and submits once, with a clock. Borrows the root. The synchronous recorder borrows each frame's encoder.

@submits ― always once a frame after your recorder returns, and again at each frame.submit()

@param ― record your frame, recorded into each frame's encoder

@returns ― a loop you run with each frame's time and destroy

create
(
const root: TgpuRoot
root
, (
frame: frames.Frame
frame
) => {
const
const fell: events.Counts<"falls"> | undefined
fell
=
const falls: events.Events<"falls">
falls
.
Events<"falls">.take(): events.Counts<"falls"> | undefined

Returns the increase in each counter since the last take, or the captured counts in frame mode. Returns each read once, then undefined until the next read arrives. Reuses one object and updates it in place.

take
(); // what kernels counted, a frame or two ago
if (
const fell: events.Counts<"falls"> | undefined
fell
?.
falls: number | undefined
falls
)
function score(points: number): void
score
(
const fell: events.Counts<"falls">
fell
.
falls: number
falls
);
const enemies: table.Table<table.TableDef<"Enemy", {
radius: d.F32;
}, {
position: d.Vec3f;
velocity: d.Vec3f;
health: d.F32;
}>>
enemies
.
Table<TableDef<"Enemy", { radius: F32; }, { position: Vec3f; velocity: Vec3f; health: F32; }>>.flush(encoder?: TgpuCommandEncoder): void

Uploads changes since the last flush with queue.writeBuffer. Writes reach the queue before the next submit. Call once per frame before passes that read the table. A HistoryTable takes the frame's encoder and throws without it.

flush
(); // the CPU's changes, uploaded once
const
const compute: TgpuComputePass
compute
=
frame: frames.Frame
frame
.
Frame.encoder: TgpuCommandEncoder

Records 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.

encoder
.
TgpuCommandEncoder.beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePass

Begins recording a compute pass

beginComputePass
();
const compute: TgpuComputePass
compute
.
TgpuComputePass.setPipeline(pipeline: TgpuComputePipeline): void

Sets the current

TgpuComputePipeline

for subsequent dispatches

setPipeline
(
const crowding: TgpuComputePipeline
crowding
);
for (let
let i: number
i
=
const sim: clock.FixedStep
sim
.
FixedStep.advance(dt: number): number

Adds dt seconds and returns the number of steps to run. Each step lasts step seconds, 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.

advance
(
frame: frames.Frame
frame
.
Frame.dt: number

Reports seconds since the previous submitted frame. The first frame has a dt of zero.

dt
);
let i: number
i
> 0;
let i: number
i
--)
const compute: TgpuComputePass
compute
.
TgpuComputePass.dispatchWorkgroups(x: number, y?: number, z?: number): void
dispatchWorkgroups
(64);
const compute: TgpuComputePass
compute
.
TgpuComputePass.end(): void

Completes the recording of this compute pass

end
();
const falls: events.Events<"falls">
falls
.
Events<"falls">.capture(frame: frames.Frame): void

Copies counters when a read is due, then clears them in frame mode. Record after the passes that add events.

capture
(
frame: frames.Frame
frame
);
});

A table splits each row into the fields the CPU writes and the fields kernels write. The CPU’s fields have a mirror on the CPU, so a write costs no GPU call, and flush uploads only the rows that changed. Shaders read either part through the definition, as Enemy.position(i), whichever table they are piped with.

A field kernels move belongs in the device part. In the host part, the next flush of its row would overwrite the kernel’s motion with the CPU’s stale copy:

Hit rows 1 and 6 while a kernel moves every row. The flush uploads host rows between the two tints; storing x in the same struct overwrites GPU motion with stale CPU values.

The kit’s own pools are tables or work the same way:

  • Every object instances draw is a row of its kind’s table, which your kernels can read and move.
  • A particle system keeps its particles in a pool, started by CPU bursts or by emit from any kernel.
  • Machines run a state machine for each row on the GPU, with the state in the rows.
  • Point and spot lights are rows of a table that the lighting reads.

Rows come and go, so a buffer has gaps. Live lists compact the live rows of each group into a list on the GPU and write the count into an indirect draw, so a draw covers only the live rows, with no CPU readback. Particles draw through them, and so can your own tables, grouped by a field such as species.

State that evolves, such as positions and velocities, stays on the GPU and advances in fixed steps. clock.fixedStep turns each frame’s time into a whole number of steps, and a kernel that captures the step length needs no time uploaded. The result is the same at 30 or 144 frames a second. Particles, machines and the flow field in Write a module step this way.

A module’s settings live in a uniform it returns, such as glow.settings. Upload them when a setting changes, not every frame. patch uploads only the fields you pass, as in glow.settings.patch({ threshold: 0.8 }); write uploads every field. Values that change every frame, such as a camera’s, go in a uniform of their own, so a change of exposure doesn’t upload the view again.

The CPU reads GPU results without waiting for them. A read is copied into a staging buffer during a frame and mapped later, so the answer arrives a frame or two after the work:

  • Events count what kernels report, such as kills or contacts. Counters only increase, so a late read loses nothing.
  • Readback returns a buffer’s contents, such as the fish tank’s bites.
  • The picker returns what is under the pointer.