TypeGPU 0.11
Hello fellow GPU enthusiast!
Over the past 2 months, my team and I have been pulling on a few threads that we thought would improve TypeGPU in terms of efficiency, and as a byproduct, we actually made the APIs more convenient. We are also introducing a lint plugin to further improve the diagnostics and feedback you receive while writing TypeGPU shaders, on top of the type safety we already provide.
- New examples
- Migration guide
- New and improved write APIs
- Shader code ergonomics
- Ecosystem updates
- What’s next?
We have been pulling a few more threads than I mentioned here… but for those, you’ll have to wait for the next blog post 🤐.
New examples
Section titled “New examples”My teammate Konrad Reczko (@reczkok) has outdone himself again, and delivered 3 new examples that push TypeGPU APIs to their limits:
- “Genetic Racing” - watch a swarm of cars learn to traverse a procedurally generated race track.
- “Mesh Skinning” - an animation and skinning system built from scratch in TypeGPU.
- “Parallax Occlusion Mapping” - squeezing amazing depth out of just two triangles and a set of textures.
Migration guide
Section titled “Migration guide”Deprecated APIs
Section titled “Deprecated APIs”The buffer.writePartial API is being deprecated in favor of buffer.patch (and here are the reasons why).
To migrate, simply replace any partial write of arrays in the form of [{ idx: 2, value: foo }, /* ... */] with { 2: foo, /* ... */ }.
const buffer = root.createBuffer(d.arrayOf(d.vec3f, 5)).$usage('storage');
buffer.writePartial([{ idx: 2, value: d.vec3f(1, 2, 3) }]); buffer.patch({ 2: d.vec3f(1, 2, 3) });Stabilizing textures and samplers
Section titled “Stabilizing textures and samplers”One by one, we’re making our APIs available without the ['~unstable'] prefix, and this time around, it’s textures and samplers.
Just drop the unstable prefix, and you’re good to go.
const sampler = root['~unstable'].createSampler({ const sampler = root.createSampler({ magFilter: 'linear', minFilter: 'linear',});
const texture = root['~unstable'].createTexture({ const texture = root.createTexture({ size: [256, 256], format: 'rgba8unorm' as const,}).$usage('sampled');New and improved write APIs
Section titled “New and improved write APIs”Efficient data
Section titled “Efficient data”When writing to a buffer with an array of vectors, it’s no longer required to create vector instances (e.g. d.vec3f()).
const positionsMutable = root.createMutable(d.arrayOf(d.vec3f, 3));
// existing overloadpositionsMutable.write([d.vec3f(0, 1, 2), d.vec3f(3, 4, 5), d.vec3f(6, 7, 8)]);// new overloads ⚡positionsMutable.write([[0, 1, 2], [3, 4, 5], [6, 7, 8]]); // tuplespositionsMutable.write(new Float32Array([0, 1, 2, 0, 3, 4, 5, 0, 6, 7, 8, 0])); // typed arrays (mind the padding)// and more...Each one is more efficient than the previous, so you can choose the appropriate API for your efficiency needs. More about these new APIs here.
A better partial write
Section titled “A better partial write”When writing to a buffer, we require the passed in value to exactly match the schema. This specifically means that updating a single field of a single array item was very costly. The buffer.writePartial API remedied that by accepting partial records
for structs, and a list of indices and values to update in arrays. This works fine, but doesn’t compose well with more complex data structures:
const Node = d.struct({ color: d.vec3f, // Indices of neighboring nodes neighbors: d.arrayOf(d.u32, 4),});
const nodes = root.createUniform(d.arrayOf(Node, 100));
// Updating the 50th nodenodes.writePartial([ { idx: 50, value: { color: d.vec3f(1, 0, 1), // We cannot pass [48, 49, 51, 52], as we could with nodes.write() neighbors: [{ idx: 0, value: 48 }, { idx: 1, value: 49 }, { idx: 2, value: 51 }, { idx: 3, value: 52 }], }, }]);If we loosened the type to accept either partial arrays or full arrays, then we would reach an ambiguity in the following case:
const Foo = d.struct({ idx: d.u32, value: d.f32,});
const foos = root.createUniform(d.arrayOf(Foo, 2));
foos.writePartial([{ idx: 1, value: /* ... */ }, { idx: 0, value: /* ... */ }]);We could traverse the value deeper to disambiguate, but for the sake of efficiency and being able to reuse optimizations added to buffer.write by Konrad, we chose to add a new API:
foos.writePartial([{ idx: 1, value: /* ... */ }, { idx: 0, value: /* ... */ }]);foos.patch({ 1: /* ... */, 0: /* ... */ });You can read more about .patch in the Buffers guide.
Writing struct-of-arrays (SoA) data
Section titled “Writing struct-of-arrays (SoA) data”When the buffer schema is an array<struct<...>>, you can write the data in a struct-of-arrays form with writeSoA from typegpu/common. This is useful when your CPU-side data is already stored per-field, such as simulation attributes kept in separate typed arrays.
const Particle = d.struct({ pos: d.vec3f, vel: d.f32,});
const particleBuffer = root.createBuffer(d.arrayOf(Particle, 2));
common.writeSoA(particleBuffer, { pos: new Float32Array([ 1, 2, 3, 4, 5, 6, ]), vel: new Float32Array([10, 20]),});More about this API can be found in the Buffers guide.
Shader code ergonomics
Section titled “Shader code ergonomics”There have been a lot of improvements to our shader generation, mainly regarding comptime execution and pruning of unreachable branches. I will highlight some of them in the following sections.
std.range
Section titled “std.range”The new std.range function works similarly to range() in Python, and returns an array that can be iterated over.
When combined with tgpu.unroll, it’s now very easy to produce a set amount of code blocks.
let result = d.u32();for (const i of tgpu.unroll(std.range(3))) { // this block will be inlined 3 times result += i * 10;}Generates:
var result = 0u;// unrolled iteration #0{ result += 0u;}// unrolled iteration #1{ result += 10u;}// unrolled iteration #2{ result += 20u;}Because i is known at comptime, the i * 10 is evaluated and injected into the generated code in each block.
For more, refer to tgpu.unroll documentation..
Boolean logic
Section titled “Boolean logic”Logical expressions are now short-circuited if we can determine the result early.
const clampingEnabled = tgpu.accessor(d.bool);
function interpolate(a: number, b: number, t: number) { 'use gpu'; let value = a + (b - a) * t; if (clampingEnabled.$ && value > 1) { // Constantly increasing, without ever going past 2 value = 1 + (value - 1) / value; } return value;}Generated WGSL depending on the value of clampingEnabled:
// clampingEnabled.$ === falsefn interpolate(a: f32, b: f32, t: f32) -> f32 { var value = a + (b - a) * t; return value;}
// clampingEnabled.$ === truefn interpolate(a: f32, b: f32, t: f32) -> f32 { var value = a + (b - a) * t; if (value > 1) { value = 1 + (value - 1) / value; } return value;}Convenience overload for tgpu.const API
Section titled “Convenience overload for tgpu.const API”If you’re defining a WGSL constant using an array schema, you no longer have to duplicate the array length both in the value and in the schema.
The tgpu.const function now accepts dynamically-sized schemas.
const ColorStops = d.arrayOf(d.vec3f);
const colorStops = tgpu.const( ColorStops(3), ColorStops, [d.vec3f(1, 0, 0), d.vec3f(0, 1, 0), d.vec3f(0, 0, 1)],);Ecosystem updates
Section titled “Ecosystem updates”There has been a lot of work outside of the typegpu package, both internally and from the community.
Lint plugin
Section titled “Lint plugin”Aleksander Katan (@aleksanderkatan) has been working behind the scenes on an ESLint/Oxlint plugin, capable of catching user errors that types cannot.
import tgpu, { d } from 'typegpu';
function increment(n: number) { 'use gpu'; return n++; // ^^^ // Cannot assign to 'n' since WGSL parameters are immutable. // If you're using d.ref, please either use '.$' or disable this rule}
function createBoid() { 'use gpu'; const boid = { pos: d.vec2f(), size: 1 }; // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ // { pos: d.vec2f(), size: 1 } must be wrapped in a schema call return boid;}
function clampTo0(n: number) { 'use gpu'; let result; // ^^^^^^ // 'result' must have an initial value if (n < 0) { result = 0; } else { result = n; } return result;}For setup instructions and available rules, refer to the documentation
New @typegpu/color helpers
Section titled “New @typegpu/color helpers”There are three new helper functions importable from @typegpu/color which can be called at comptime to create
color vectors from hexadecimal strings: hexToRgb, hexToRgba and hexToOklab.
import { hexToRgb } from '@typegpu/color';
function getGradientColor(t: number) { 'use gpu'; const from = hexToRgb('#FF00FF'); const to = hexToRgb('#00FF00'); return std.mix(from, to, t);}Generated WGSL:
fn getGradientColor(t: f32) -> vec3f { var from = vec3f(1, 0, 1); var to = vec3f(0, 1, 0); return mix(from, to, t);}Bundler plugin rewrite
Section titled “Bundler plugin rewrite”The unplugin-typegpu package is what enables TypeScript shaders, and to support its continued development, we rewrote it from
the ground up. It should now support more bundlers than ever before, out of the box, including esbuild.
Motion GPU
Section titled “Motion GPU”A minimalist WebGPU framework called Motion GPU introduced a way to integrate with TypeGPU, and wrote about it in their documentation (Integrations / TypeGPU). It’s awesome to see the continued adoption of TypeGPU in other ecosystems and communities 🎉
What’s next?
Section titled “What’s next?”There are many more things introduced in TypeGPU 0.11 that I haven’t mentioned. If you’re curious, you can read the full 0.11.0 changelog.