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.
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(newError('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 = newconsole.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(newError('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
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()).
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(newError('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 = newconsole.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(newError('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
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()).
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.
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:
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.
}); // { 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.