A compute kernel runs an ordinary calculation on the GPU, without
any graphics. This page builds the smallest useful one: a counter
that the GPU increments. Every code block that names the program is
a quote from programs/b22-first-program.ts.
A GPU function needs more steps than a host function: handles cross between two processors, the memory is allocated ahead, and the code compiles for the device. TypeGPU resolves the WGSL, the layouts, and the pipeline at run time. This library does that work before the program runs: a generator reads the typed program and emits the WGSL and the layout constants the host code imports.
A kernel is a plain function. A module-level computePipeline
declaration marks it, and the generator finds every such
declaration.
function incrementCounter(res: CounterLayout, ctx: ComputeInvocation): void {
const state: State = res.state[0];
state.counter += state.incrementBy;
res.state[0] = state;
}
export const firstProgram: ComputePipelineSpec = computePipeline<CounterLayout>(
incrementCounter,
{
name: "firstProgram",
workgroupSize: [1, 1, 1],
},
);TypeGPU writes root.createGuardedComputePipeline(() => { 'use gpu'; ... }), and its build plugin parses the marked body ahead of time
into a compact AST. Here the declaration is the marker. The
generator emits one WGSL module and a support module
./b22-first-program.typegpu. That module holds the entry name,
the workgroup size, and the layout constants.
The kernel reads and writes res.state — a binding, not a host
variable. A schema class gives the state its layout, and a layout
class names the bindings of one bind group.
@CStruct
class State {
counter: u32;
incrementBy: u32;
constructor(counter: u32, incrementBy: u32) {
this.counter = counter;
this.incrementBy = incrementBy;
}
}class CounterLayout {
state!: MutStorage<State>;
}TypeGPU builds the same shapes at run time with d.struct({ ... })
and root.createMutable(...). Here the generator computes the
layout before the program runs and emits it as constants:
State_STRIDE is the schema's array stride, and
State_OFFSET_incrementBy is one field's byte offset. No byte
count is written by hand.
using stateBuffer: Buffer<State> = createBuffer<State>(
device,
State_STRIDE,
1,
GPUBufferUsage.STORAGE + GPUBufferUsage.COPY_SRC + GPUBufferUsage.COPY_DST,
"b22-state",
);The initial value crosses as the schema's bytes, at the schema's own offsets:
const initialState = new State(0, 10);
stateBuffer.write(
device.queue,
0,
Context.bytesOf<State>(initialState),
);The pipeline builds from the generated WGSL and layout constants,
inside a validation error scope. A rejected shader prints
pipeline:invalid and ends the program. The typed factory builds
the bind group, and one dispatch runs one invocation — a larger
dispatch adds invocations without any other change.
device.pushErrorScope("validation");
using pipeline = createComputePipeline(
device,
firstProgram_WGSL,
firstProgram_ENTRY,
[firstProgram_LAYOUT0],
[
firstProgram_WORKGROUP_X,
firstProgram_WORKGROUP_Y,
firstProgram_WORKGROUP_Z,
],
);
const validationError = await device.popErrorScope();
if (validationError !== null) {
print("pipeline:invalid");
print("FAIL");
return;
}
const resources: CounterLayoutResources = createCounterLayoutResources(
stateBuffer.handle(),
);
using bindGroup = createFirstProgramBindGroup0(
device,
pipeline,
resources,
);
using firstEncoder = device.createCommandEncoderDefault();
pipeline.dispatch(firstEncoder, [bindGroup], 1, 1, 1);
using firstCommand = firstEncoder.finishDefault();
device.queue.submit([firstCommand]);TypeGPU compiles the pipeline lazily on the first
dispatchThreads(). Here createComputePipeline returns a created
pipeline, and the program checks the scope before it uses it.
patch writes one field of one element, so the counter's current
value stays untouched on the GPU. readOne copies the element back
through a staging buffer, and Context.fromBytes rebuilds the
typed value.
stateBuffer.patch(
device.queue,
0,
State_OFFSET_incrementBy,
Context.bytesOf<FixedArray<u32, 1>>([25]),
);
using secondEncoder = device.createCommandEncoderDefault();
pipeline.dispatch(secondEncoder, [bindGroup], 1, 1, 1);
using secondCommand = secondEncoder.finishDefault();
device.queue.submit([secondCommand]);
const readbackBytes: u8[] = await stateBuffer.readOne(device, 0);
const readback: State = Context.fromBytes<State>(readbackBytes, 0);
print(`readback:counter=${readback.counter} incrementBy=${readback.incrementBy}`);TypeGPU spells the same three steps buffer.patch(...),
buffer.read(), and schema-driven deserialization. One difference
matters on a backend that executes no shaders, such as yawgpu's
Noop: the copies run, the kernel does not, and the readback shows
counter=0. A real device shows the two increments. The host lane
below checks the arithmetic anywhere, device or not.
let counter: u32 = 0;
function badKernel(res: CounterLayout, ctx: ComputeInvocation): void {
res.state[0] = new State(counter, 1);
}
export const badProgram: ComputePipelineSpec = computePipeline<CounterLayout>(
badKernel,
{ name: "badProgram", workgroupSize: [1, 1, 1] },
);A kernel becomes GPU code, and GPU code has no access to host
memory. The generator rejects a kernel that reads a mutable global,
and the K19 diagnostic names the global. State the GPU can write
lives in a buffer behind a MutStorage binding, and the host reads
it back as bytes.
The same function body is host code. simulateCompute runs it over
host-side bindings, one invocation at a time, so the arithmetic is
proven without a device.
const hostLayout = new CounterLayout();
hostLayout.state = new MutStorage<State>([new State(0, 10)]);
simulateCompute<CounterLayout>(
incrementCounter,
hostLayout,
firstProgram,
[1, 1, 1],
firstProgram_HOST_RUNNABLE,
);
const hostState: State = hostLayout.state[0];
hostState.incrementBy = 25;
hostLayout.state[0] = hostState;
simulateCompute<CounterLayout>(
incrementCounter,
hostLayout,
firstProgram,
[1, 1, 1],
firstProgram_HOST_RUNNABLE,
);
print(`host:counter=${hostLayout.state[0].counter}`);Two increments — 10, then 25 — leave the host counter at 35, on the JIT and on the C build alike.
docs/tutorial.mdbuilds a particle pipeline with vectors and a larger schema.docs/from-typegpu.mdcompares TypeGPU with this library, topic by topic.examples/holds twenty ported TypeGPU examples, headless and windowed.