Skip to content

@typegpu/kitevents

Events

Named counters that kernels increment and the CPU reads as changes since its last read.

kit.events

module

events.create(root, names, options?) → Events

rootborrows
the root
namestakes
one counter each
Eventsreturns
a set of counters you destroy
types
CountsEvents
Optionscreate · 3 fields
per?

'total' | 'frame'= 'total'

Selects accumulated counters or counters for individual frames.

hz?

number= 30

Reads counters for take and latest this many times per second on average.

staging?

number= 3

Sets the number of readback staging buffers.

records
  • captureframe
  • clearencoder

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

owns

a buffer ·a readback

The counters; the readback copies them at hz.

lifecycle

destroy()[Symbol.dispose]

injects
  • per-instance

    add, for kernels piped with it.

binds
  • 1 storage

    add, the counters.

Add stomps or gold, then try 60 Hz with a slow map to see captures skipped without losing counts. Each gain reaches take() once after a delay; clear drops pending reads.

The GPU detects collisions and kills, while the CPU updates scores, plays sounds and restarts levels. A kernel can’t call CPU code. Reporting event details requires a queue, but a count is often enough. Kernels increment a counter for each event kind. The CPU reads the change a frame or two later.

import {
import events
events
} from '@typegpu/kit';
const
const contacts: events.Events<"stomps" | "hurts">
contacts
=
import events
events
.
function create<"stomps" | "hurts">(root: TgpuRoot, names: readonly ("stomps" | "hurts")[], options?: events.Options): events.Events<"stomps" | "hurts">

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
, ['stomps', 'hurts'], {
Options.hz?: number | undefined

Reads counters for take and latest this many times per second on average. Default 30.

hz
: 60 });
const
const touch: TgpuComputeFn<{
gid: d.BuiltinGlobalInvocationId;
}>
touch
=
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';
if (
gid: d.v3u
gid
.
v3u.x: number
x
>= 64) return;
const
const offset: d.v2f
offset
=
const hero: TgpuUniform<d.Vec2f>
hero
.
TgpuUniform<Vec2f>.$: d.v2f
$
-
const critters: TgpuReadonly<d.WgslArray<d.Vec2f>>
critters
.
TgpuReadonly<WgslArray<Vec2f>>.$: d.v2f[]
$
[
gid: d.v3u
gid
.
v3u.x: number
x
]!;
if (
const offset: d.v2f
offset
.
v2f.y: number
y
> 0.4 &&
const offset: d.v2f
offset
.
v2f.y: number
y
< 0.8)
const contacts: events.Events<"stomps" | "hurts">
contacts
.
Events<"stomps" | "hurts">.add: {
readonly stomps: (amount: number) => void;
readonly hurts: (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
.
stomps: (amount: number) => void
stomps
(1);
});
const
const touching: TgpuComputePipeline
touching
=
const root: TgpuRoot
root
.
WithBinding.pipe(transform: (cfg: Configurable) => Configurable): WithBinding
pipe
(
const contacts: events.Events<"stomps" | "hurts">
contacts
.
Events<"stomps" | "hurts">.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 touch: TgpuComputeFn<{
gid: d.BuiltinGlobalInvocationId;
}>
touch
});
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 contact: events.Counts<"stomps" | "hurts"> | undefined
contact
=
const contacts: events.Events<"stomps" | "hurts">
contacts
.
Events<"stomps" | "hurts">.take(): events.Counts<"stomps" | "hurts"> | 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 each gained since the last take, or undefined
if (
const contact: events.Counts<"stomps" | "hurts"> | undefined
contact
?.
stomps: number | undefined
stomps
)
function bounce(times: number): void
bounce
(
const contact: events.Counts<"stomps" | "hurts">
contact
.
stomps: number
stomps
);
if (
const contact: events.Counts<"stomps" | "hurts"> | undefined
contact
?.
hurts: number | undefined
hurts
)
function restart(): void
restart
();
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 touching: TgpuComputePipeline
touching
);
const compute: TgpuComputePass
compute
.
TgpuComputePass.dispatchWorkgroups(x: number, y?: number, z?: number): void
dispatchWorkgroups
(1);
const compute: TgpuComputePass
compute
.
TgpuComputePass.end(): void

Completes the recording of this compute pass

end
();
const contacts: events.Events<"stomps" | "hurts">
contacts
.
Events<"stomps" | "hurts">.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
); // after the passes that add
});
  • add.stomps(amount) adds atomically, from any thread of any kernel piped with contacts.inject(). The amount can vary, such as a kill’s reward of 1, 4 or 1000 gold.
  • capture copies counters at hz, 30 times per second by default. It uses a readback with staging buffers, 3 by default, and skips a capture while all are mapping. The platformer reads at 60 Hz, so the hero bounces a frame or two after a stomp.
  • take() returns each counter’s increase since the last take, once per completed read, and undefined between reads. latest holds totals since the last clear. Each reuses an object and updates it in place. Copy a result to keep it.
  • Counters only increase, so late or repeated reads lose no events. The next difference includes them. Differences use u32 arithmetic and remain correct across a wrap at 2^32.

Use { per: 'frame' } to count objects in a frame, such as the stampede’s skinned horses and cards. capture copies when a read is due and clears counters every frame. latest and take() then return one frame’s counts. Call capture every frame, after the passes that count.

clear(encoder) sets every counter to 0 in your encoder and drops the reads made before it, so the next gains count from the clear. Record it before the passes that add, as at a restart:

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
) => {
if (
let restarting: boolean
restarting
)
const tally: events.Events<"spawned" | "slain" | "breached">
tally
.
Events<"spawned" | "slain" | "breached">.clear(encoder: TgpuCommandEncoder): void

Resets all counters to 0 in the caller's encoder. Record before the passes that add events.

clear
(
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
);
let restarting: boolean
restarting
= false;
// ... the passes that add, then tally.capture(frame)
});

Read latest to display totals such as enemies spawned, slain or through the gate. The platformer keeps its counters across restarts and ignores damage for 1.5 s, longer than a read takes to complete.

  • Counters omit event details such as location. Handle immediate effects on the GPU, such as squashing a critter or emitting dust. Let the CPU react to the count.
  • Per-item thresholds need separate counters. The fish tank uses its own counter array to count bites per pellet and decide on the GPU when each is eaten.