Skip to content

Explaining the Magic

This tutorial explains in depth what exactly happens during the bundle time to make ‘use gpu’ functions possible. Understanding this is not at all necessary to start using TypeGPU, but it may help contributors understand why some things work like they do.

Take a look at the following code. It declares a counter buffer, and increases it in incrementPipeline.

import {
const tgpu: {
const: typeof import("node_modules/typegpu/src/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/src/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/src/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/src/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/src/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/src/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/src/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/src/tgpuUnstable");
}

@moduletypegpu

tgpu
,
import d
d
} from 'typegpu';
const
const root: TgpuRoot
root
= await
const tgpu: {
const: typeof import("node_modules/typegpu/src/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/src/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/src/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/src/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/src/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/src/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/src/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/src/tgpuUnstable");
}

@moduletypegpu

tgpu
.
init: (options?: InitOptions) => Promise<TgpuRoot>

Requests a new GPU device and creates a root around it. If a specific device should be used instead, use

@seeinitFromDevice. *

@example

When given no options, the function will ask the browser for a suitable GPU device.

const root = await tgpu.init();

@example

If there are specific options that should be used when requesting a device, you can pass those in.

const adapterOptions: GPURequestAdapterOptions = ...;
const deviceDescriptor: GPUDeviceDescriptor = ...;
const root = await tgpu.init({ adapter: adapterOptions, device: deviceDescriptor });

init
();
const
const counter: TgpuMutable<d.Vec2u>
counter
=
const root: TgpuRoot
root
.
TgpuRoot.createMutable<d.Vec2u>(typeSchema: d.Vec2u, initial?: ((buffer: TgpuBuffer<NoInfer<d.Vec2u>>) => void) | d.InferInput<NoInfer<d.Vec2u>> | undefined): TgpuMutable<d.Vec2u> (+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

.

@paramtypeSchema The type of data that this buffer will hold.

@paraminitial Either initial value of the buffer, or an initializer to execute on the mapped buffer. (optional)

createMutable
(
import d
d
.
const vec2u: d.Vec2u
vec2u
);
const
const incrementBy: {
allModifier: TgpuConst<d.Vec2u>;
yModifier: TgpuConst<d.U32>;
}
incrementBy
= {
allModifier: TgpuConst<d.Vec2u>
allModifier
:
const tgpu: {
const: typeof import("node_modules/typegpu/src/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/src/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/src/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/src/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/src/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/src/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/src/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/src/tgpuUnstable");
}

@moduletypegpu

tgpu
.
const: <d.Vec2u>(dataType: d.Vec2u, value: d.v2u) => TgpuConst<d.Vec2u> (+1 overload)

Creates a module constant with specified value.

const
(
import d
d
.
const vec2u: d.Vec2u
vec2u
,
import d
d
.
const vec2u: d.Vec2u
(x: number, y: number) => d.v2u (+3 overloads)
vec2u
(1, 1)),
yModifier: TgpuConst<d.U32>
yModifier
:
const tgpu: {
const: typeof import("node_modules/typegpu/src/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/src/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/src/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/src/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/src/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/src/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/src/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/src/tgpuUnstable");
}

@moduletypegpu

tgpu
.
const: <d.U32>(dataType: d.U32, value: number) => TgpuConst<d.U32> (+1 overload)

Creates a module constant with specified value.

const
(
import d
d
.
const u32: d.U32
export u32

A schema that represents an unsigned 32-bit integer value. (equivalent to u32 in WGSL)

Can also be called to cast a value to an u32 in accordance with WGSL casting rules.

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

@example const value = u32(7); // 7

@example const value = u32(3.14); // 3

@example const value = u32(-1); // 4294967295

@example const value = u32(-3.1); // 0

u32
, 2),
};
const
const increment: TgpuComputeFn<{}>
increment
=
const tgpu: {
const: typeof import("node_modules/typegpu/src/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/src/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/src/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/src/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/src/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/src/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/src/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/src/tgpuUnstable");
}

@moduletypegpu

tgpu
.
computeFn: (options: {
workgroupSize: number[];
}) => TgpuComputeFnShell<{}> (+1 overload)
computeFn
({
workgroupSize: number[]
workgroupSize
: [1] })(() => {
'use gpu';
const counter: TgpuMutable<d.Vec2u>
counter
.
TgpuMutable<Vec2u>.$: d.v2u
$
+=
const incrementBy: {
allModifier: TgpuConst<d.Vec2u>;
yModifier: TgpuConst<d.U32>;
}
incrementBy
.
allModifier: TgpuConst<d.Vec2u>
allModifier
.
TgpuConst<Vec2u>.$: d.v2u
$
;
const counter: TgpuMutable<d.Vec2u>
counter
.
TgpuMutable<Vec2u>.$: d.v2u
$
.
v2u.y: number
y
+=
const incrementBy: {
allModifier: TgpuConst<d.Vec2u>;
yModifier: TgpuConst<d.U32>;
}
incrementBy
.
yModifier: TgpuConst<d.U32>
yModifier
.
TgpuConst<U32>.$: number
$
;
});
const
const incrementPipeline: TgpuComputePipeline
incrementPipeline
=
const root: TgpuRoot
root
.
WithBinding.createComputePipeline<{}>(descriptor: TgpuComputePipeline.Descriptor<{}>): TgpuComputePipeline
createComputePipeline
({
compute: TgpuComputeFn<{}>
compute
:
const increment: TgpuComputeFn<{}>
increment
});
const incrementPipeline: TgpuComputePipeline
incrementPipeline
.
TgpuComputePipeline.dispatchWorkgroups(x: number, y?: number, z?: number): void
dispatchWorkgroups
(1);
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(await
const counter: TgpuMutable<d.Vec2u>
counter
.
TgpuBufferBindingBase<Vec2u>.read(): Promise<d.v2u>
read
()); // d.vec2u(1, 3)

TypeGPU translated this code to WebGPU, which can be previewed using tgpu.resolve:

var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(
const tgpu: {
const: typeof import("node_modules/typegpu/src/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/src/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/src/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/src/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/src/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/src/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/src/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/src/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/src/tgpuUnstable");
}

@moduletypegpu

tgpu
.
resolve: (items: ResolvableObject[], options?: TgpuResolveOptions) => string (+1 overload)

A shorthand for calling tgpu.resolveWithContext(...).code.

@example

const Gradient = d.struct({ from: d.vec3f, to: d.vec3f });
const resolved = tgpu.resolve([Gradient]);
console.log(resolved);
// struct Gradient_0 {
// from: vec3f,
// to: vec3f,
// }

@example

const Gradient = d.struct({ from: d.vec3f, to: d.vec3f });
const code = tgpu.resolve({
template: `
fn getGradientAngle(gradient: Gradient) -> f32 {
return atan(gradient.to.y - gradient.from.y, gradient.to.x - gradient.from.x);
}
`,
externals: {
Gradient,
},
});
console.log(code);
// struct Gradient_0 {
// from: vec3f,
// to: vec3f,
// }
// fn getGradientAngle(gradient: Gradient_0) -> f32 {
// return atan(gradient.to.y - gradient.from.y, gradient.to.x - gradient.from.x);
// }

resolve
([
const incrementPipeline: TgpuComputePipeline
incrementPipeline
]));
// @group(0) @binding(0) var<storage, read_write> counter: vec2u;
//
// const allModifier: vec2u = vec2u(1);
//
// const yModifier: u32 = 2u;
//
// @compute @workgroup_size(1) fn increment() {
// counter += allModifier;
// counter.y += yModifier;
// }

This was made possible by unplugin-typegpu, which modifies the existing code during bundle (build) time. The modified code can be previewed for debug/educational purposes with the following trick:

// ... imports
const
const main: () => Promise<void>
main
= async () => {
// ... remaining file content
}
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
const main: () => Promise<void>
main
.
Function.toString(): string

Returns a string representation of a function.

toString
());

The code with the compute pipeline is transformed as follows (don’t try to parse it, we will explain it on higher level in a second):

async () => {
const root = await tgpu.init();
const counter = (/*#__PURE__*/(globalThis.__TYPEGPU_AUTONAME__ ?? (a => a))(root.createMutable(d.vec2u), "counter"));
const incrementBy = {
allModifier: (/*#__PURE__*/(globalThis.__TYPEGPU_AUTONAME__ ?? (a => a))(tgpu.const(d.vec2u, d.vec2u(1, 1)), "allModifier")),
yModifier: (/*#__PURE__*/(globalThis.__TYPEGPU_AUTONAME__ ?? (a => a))(tgpu.const(d.u32, 2), "yModifier"))
};
const increment = (/*#__PURE__*/(globalThis.__TYPEGPU_AUTONAME__ ?? (a => a))(tgpu.computeFn({ workgroupSize: [1] })((/*#__PURE__*/($ => (globalThis.__TYPEGPU_META__ ??= new WeakMap()).set($.f = (() => {
"use gpu";
counter.$ = __tsover_add(counter.$, incrementBy.allModifier.$);
counter.$.y = __tsover_add(counter.$.y, incrementBy.yModifier.$);
}), {
v: 2,
name: undefined,
ast: {"params":[],"body":[0,[[2,[7,"counter","$"],"+=",[7,"incrementBy.allModifier","$"]],[2,[7,[7,"counter","$"],"y"],"+=",[7,"incrementBy.yModifier","$"]]]]},
externals: { "counter": () => counter, "incrementBy.allModifier": () => incrementBy.allModifier, "incrementBy.yModifier": () => incrementBy.yModifier }
}) && $.f)({}))), "increment"));
const incrementPipeline = (/*#__PURE__*/(globalThis.__TYPEGPU_AUTONAME__ ?? (a => a))(root.createComputePipeline({ compute: increment }), "incrementPipeline"));
incrementPipeline.dispatchWorkgroups(1);
console.log(await counter.read());
}

As can be seen, unplugin heavily modifies code around some tgpu elements. The changes can be split into three categories:

  • auto-naming (globalThis.__TYPEGPU_AUTONAME__),
  • function metadata assignment (globalThis.__TYPEGPU_META__),
  • operator overloading (__tsover_add and similar).

Most TypeGPU resources can be named via the $name method. While this is completely optional, a name can make debugging easier, and it also serves as a primer for identifiers in resulting WGSL.

unplugin-typegpu traverses the AST of each bundled file, and matches most common patterns where a default name for a resource (recognized by constructor name) can be inferred.

const counter = root.createMutable(d.vec2u); // can be named 'counter'
const incrementBy = {
allModifier: tgpu.const(d.vec2u, d.vec2u(1, 1)), // can be named 'allModifier'
}

The matched resource is only wrapped in the autonaming function if the method $name was not called before:

const main = () => {
const myConst = tgpu.const(d.u32, 2).$name('incrementValue');
const otherConst = tgpu.const(d.u32, 2);
};
console.log(main.toString());
// () => {
// const myConst = tgpu.const(d.u32, 2).$name("incrementValue");
// const otherConst = (/*#__PURE__*/(globalThis.__TYPEGPU_AUTONAME__ ?? (a => a))(tgpu.const(d.u32, 2), "otherConst"));
// }

The globalThis.__TYPEGPU_AUTONAME__ function checks its argument for the $internal symbol and $name prop, and if both are present (and the item does not already have a name), calls $name on the item. Therefore, some potential false positive calls to __TYPEGPU_AUTONAME__ (e.g. if user named their unrelated function createMutable) cause no harm.

It is worth noting that we actually wrap the function in a (globalThis.__TYPEGPU_AUTONAME__ ?? (a => a)) call: we cannot know for sure that TypeGPU was already imported and assigned the autonaming function, so we need a fallback. This is also the reason why we cannot just export a function like tgpu.name.

Function metadata is what makes it possible to generate WGSL from JavaScript code. The metadata consists of the metadata version, the function name, the AST (abstract syntax tree), and externals (the captured scope of the function). Only functions marked with 'use gpu' are processed, as well as those immediately passed into tgpu.fn shells.

The plugin uses the tinyest-for-wgsl package to parse the function AST into our custom AST format called tinyest. Our WgslGenerator traverses this AST and generates code snippets.

This could theoretically be done at runtime from the function’s toString, but it would require bundling a JavaScript parser with TypeGPU, and make catching externals impossible.

Along with AST, tinyest-for-wgsl also returns a set of externals (variables captured from outer scope). This section is more technical to explain our design choices.

During tinyest generation, tinyest-for-wgsl also remembers all variables introduced into the scope. This lets it return a set of variables that reference outer scope. Externals are immediately flattened, which means that the prop accesses are ‘squashed’ into identifiers:

const fn = () => {
'use gpu';
const a = 1; // no externals introduced
const b = a + 1; // no externals introduced
const c = ext; // 'ext' added to externals
const d = ext.p; // 'ext.p' added to externals, the prop access is replaced with an identifier 'ext.p'
}
// tinyest-for-wgsl returns the generated AST and externals: Set { 'ext', 'ext.p' }
// No code was modified so far, the prop access squashing only applies to the generated AST.

The externals are not flattened all the way to the end though. There are three most important cases where we stop early:

const fn = () => {
'use gpu';
const a = ext.p1.p2().p3; // 'ext.p1.p2' added to externals
const b = ext.q1['q2'].q3; // 'ext.q1' added to externals
const c = ext.r1.$.r3; // 'ext.r1' added to externals
}

Stopping at $ is not necessary in any way for the library to function properly. However, this lets the library try to auto-name resources that were missed by unplugin autonaming, while also not affecting the tree-shaking.

When the unplugin assigns metadata, the list of externals is turned into an object of thunks:

const externals = {
"ext.p1.p2": () => ext.p1.p2,
"ext.q1": () => ext.q1,
"ext.r1": () => ext.r1
}

There are two important reasons why we need to delay the external computation and cannot just generate entries like "ext.p1.p2": ext.p1.p2.

The first reason is externals recapture:

let buffer;
let pipeline;
const computeFn = tgpu.computeFn({ workgroupSize: [1] })(() => {
'use gpu'; buffer.$ += 1;
}); // { buffer: buffer } would now become { buffer: undefined }
buffer = root.createMutable(d.u32); // externals do not update

The same scenario may occur when a function references resources that are recreated (e.g. a resized buffer).

The second reason is the fact that some of our getters depend on the current resolution mode. tgpu.const .$ call returns its value when called outside of resolution, but during resolution it returns a different object with [$ownSnippet] and [$resolve] symbols.

At runtime, the thunks are changed to getters for convenience.

const externals = {
get "ext.p1.p2"() { return ext.p1.p2 },
get "ext.q1"() { return ext.q1 },
get "ext.r1"() { return ext.r1 }
}

This iteration of externals is still shared between different resolutions of the same function, thus we cannot concretize the externals just yet.

Also, during resolution, deciding whether a given object comes from externals is difficult. Getters let us forget about this problem. Without this change, the WgslGenerator would throw when encountering a function not marked with ‘use gpu’.

When the WgslGenerator cannot find an identifier in scope, it accesses the externals, which return the correct code snippets and automatically add any used definitions to the context, as this is the behavior of resource prop access during resolution.

To allow tsover to work as we expect when the function is called in JavaScript, operators used inside the function body are swapped with calls to functions like __tsover_add, defined by tsover-runtime. This makes it possible to add vectors together without referencing std.add and such.