-
- Explore TypeGPU examples
-
+
+ Explore TypeGPU examples
+
+
+
diff --git a/apps/typegpu-docs/src/starlight-docs.css b/apps/typegpu-docs/src/starlight-docs.css
new file mode 100644
index 0000000000..f7bd882569
--- /dev/null
+++ b/apps/typegpu-docs/src/starlight-docs.css
@@ -0,0 +1,133 @@
+/**
+ * Docs-shell alignment with the landing page.
+ *
+ * Loaded LAST in Starlight's `customCss` chain (see astro.config.mjs). The
+ * overrides here are intentionally unlayered so they win over Starlight's
+ * `@layer starlight.*` declarations and over the starlight-tailwind color
+ * mapping in `./src/tailwind.css` (itself `@layer utilities`). It is also
+ * imported by `PageLayout.astro` so Starlight-powered shared UI, such as the
+ * site search, uses the same tokens on docs, landing, and example pages. The
+ * landing and example page visuals remain owned by their existing Tailwind
+ * styles; the only non-token rule below is scoped to Starlight's docs shell.
+ */
+
+/* Landing palette: dark surfaces are navy (#232736), light surfaces are white.
+ The default Starlight grays (#171724 / #24233f) read cooler and slightly
+ purple — realign the shell (page bg, nav bar, sidebar, search surface) to
+ the shared navy so the docs feel like part of the same site as the landing.
+ `--sl-color-black` is only stated explicitly because several Starlight
+ surfaces (e.g. the mobile sidebar pane) derive from it. */
+/* Body + headings inherit the landing's Aeonik sans stack (as declared in
+ `@theme` in `tailwind.css`) so the docs text stops falling back to the
+ starker system UI font on the landing/examples pages. */
+:root {
+ --sl-font: 'Aeonik', ui-sans-serif, system-ui, sans-serif;
+ --sl-font-mono: 'JetBrains Mono', monospace;
+}
+
+:root[data-theme='dark'] {
+ --sl-color-bg: #232736;
+ --sl-color-black: #232736;
+ --sl-color-bg-nav: #232736;
+ --sl-color-bg-sidebar: #232736;
+ /* Keep the sidebar rail and menu hairlines readable against the navy. */
+ --sl-color-hairline-shade: #353452;
+ /* Second-step surfaces still landed on Starlight's cool lavender grays
+ (#24233f) instead of the navy. `--sl-color-gray-6` is the L2 surface:
+ expressive-code's editor/tab background (emitted as a runtime
+ `var(--sl-color-gray-6)`), the search dialog + `` chips, the mobile
+ menu-footer rail, the mobile TOC dropdown border and the default
+ `--sl-color-hairline` (`
` rules). Realign it one step *below* the page
+ navy so code panels read as recessed, not purple. */
+ --sl-color-gray-6: #1f2330;
+ /* Since gray-6 feeds the default `--sl-color-hairline` (ContentPanel top
+ border, TwoColumnContent rail, `
`), setting gray-6 below the navy would
+ make those dividers darker than the page and near-invisible. Decouple the
+ hairline and pin it to the lighter-than-navy rail tone so dividers read on
+ the shell. */
+ --sl-color-hairline: #353452;
+ /* `--sl-color-hairline-light` derives from `--sl-color-gray-5`, which the
+ dark block never overrides — so the sidebar sublist rail and `Steps`
+ line kept Starlight's lavender gray-5 (`hsl(224, 10%, 23%)`) instead of
+ the neutral rail tone. Pin it so every divider on the navy shell shares
+ one family. */
+ --sl-color-hairline-light: #353452;
+ /* Inline-code chips derive from their own token; lift them a step above the
+ page navy with no purple cast. */
+ --sl-color-bg-inline-code: #2b2f40;
+ /* Aside / badge / card backgrounds use the `--sl-color-*-low` family, whose
+ default values are saturated `hsl(234, 54%, 20%)`-style indigo wells. Fold
+ the four aside variants onto soft, low-saturation navy-neighbor tones. */
+ --sl-color-blue-low: #1e2a40;
+ --sl-color-purple-low: #2b2540;
+ --sl-color-orange-low: #3a2d1f;
+ --sl-color-red-low: #40252b;
+ /* Content typography against the navy. The default Starlight dark text is a
+ lavender-tinted gray (`hsl(224,6%,77%)`) layered over the equally cool
+ purple-ish shell, which drifts from the landing's neutral near-whites
+ (`almost-white` #fcfcff). Realign the reading text so it shares the
+ landing's warm, low-cast tone instead of the default indigo cast. */
+ --sl-color-white: #fcfcff; /* headings, th, summary, emphasis = landing almost-white */
+ --sl-color-gray-1: #edeff6; /* strongest secondary text (sidebar active) */
+ --sl-color-gray-2: #c7cbd7; /* body text --sl-color-text, de-purpled */
+ /* Links: Starlight's 100%-saturation light-blue accent reads electric on the
+ navy; pull it toward the landing's soft periwinkle. The landing's literal
+ dark link color is `almost-white #fcfcff` with a `hover:text-accent-200`
+ (`accent-200` is `#c3c4f1`), so no landing token equals a solid blue —
+ `#9db5ff` is a soft periwinkle in that family that keeps links legible on
+ navy without Starlight's electric sway. */
+ --sl-color-text-accent: #9db5ff;
+}
+
+/* --- Light mode: realign the shell to the landing light palette ---
+ Starlight's light defaults are lavender-tinted (gray-6 `hsl(224,20%,94%)`
+ surfaces, `hsl(224,10%,23%)` navy-gray text, dark-indigo `accent-high`
+ links) which drift from the landing's soft `navy-0 #f8f9ff` surfaces,
+ `navy-100 #001a72` / `navy-80 #33488e` text, and `accent-600 #6453d2` /
+ `navy-80` links. */
+:root[data-theme='light'] {
+ /* Surfaces: page + sidebar sit on the landing's navy-0 off-white (the
+ `--sl-color-black` token is the page `--sl-color-bg` base). The nav bar
+ stays white to match the unified SiteHeader (`bg-white` in light). */
+ --sl-color-bg: #f8f9ff;
+ --sl-color-black: #f8f9ff;
+ --sl-color-bg-nav: #ffffff;
+ --sl-color-bg-sidebar: #f8f9ff;
+ /* Hairlines: default gray-6 is a pale lavender; nudge to a neutral
+ blue-tinted hairline that reads on navy-0 without a magenta cast. */
+ --sl-color-hairline-shade: #e2e5f2;
+ /* L2 surfaces (expressive-code editor/tab bg, search dialog, `` chips,
+ mobile menu-footer rail, default `
`): keep them a step above the page
+ navy-0, still cool and non-purple. */
+ --sl-color-gray-6: #eceef7;
+ --sl-color-gray-7: #ffffff;
+ --sl-color-bg-inline-code: #eceef7;
+ /* Aside / badge / card L2 tones: the light defaults are saturated pastel
+ indigo wells; fold the four aside variants onto soft navy-0-truthy
+ tints that read as tinted surfaces, not flat pastel blocks. */
+ --sl-color-blue-low: #eef1fb;
+ --sl-color-purple-low: #efedfa;
+ --sl-color-orange-low: #fbf1e6;
+ --sl-color-red-low: #faecef;
+ /* Content typography against navy-0. Default light text is a cool navy-gray
+ (`hsl(224,10%,23%)`); the landing light uses `navy-100 #001a72` headings
+ and `navy-80 #33488e` body (both chain from `--sl-color-text` →
+ `--sl-color-gray-2`). */
+ --sl-color-white: #001a72; /* headings, th, summary, emphasis = navy-100 */
+ --sl-color-gray-1: #001a72; /* strongest secondary text (sidebar active) */
+ --sl-color-gray-2: #33488e; /* body text --sl-color-text = navy-80 */
+ /* Links: Starlight's light accent is electric `hsl(234,90%,60%)`; pull it
+ toward the landing's light link tone (`navy-80 #33488e` /
+ `hover:accent-700`) — `accent-600 #6453d2` is the landing-styled anchor
+ purple. */
+ --sl-color-text-accent: #6453d2;
+}
+
+/* The unified SiteHeader is full-bleed and seamless (the landing/examples
+ sticky header draws no border) — PageFrame still paints a 1px hairline under
+ it (`border-bottom: 1px solid var(--sl-color-hairline-shade)` in
+ @layer starlight.core). Drop it. Scoped to the docs shell's header so
+ expressive-code `.header` figcaptions are left alone. */
+.page > .header {
+ border-bottom: none;
+}
diff --git a/apps/typegpu-docs/src/tailwind.css b/apps/typegpu-docs/src/tailwind.css
index 978e666f77..c672982a52 100644
--- a/apps/typegpu-docs/src/tailwind.css
+++ b/apps/typegpu-docs/src/tailwind.css
@@ -1,4 +1,5 @@
@layer base, starlight, theme, components, utilities;
+@layer starlight.base, starlight.reset, starlight.core, starlight.content, starlight.components, starlight.utils;
@import '@astrojs/starlight-tailwind';
@import 'tailwindcss/theme.css' layer(theme);
@@ -53,6 +54,7 @@
/* --- Font Family --- */
--font-sans: 'Aeonik', ui-sans-serif, system-ui, sans-serif;
+ --font-mono: 'JetBrains Mono', monospace;
/* --- Keyframes --- */
@keyframes scroll-bg-y {
@@ -89,20 +91,82 @@ input {
font-family: inherit;
}
-.sl-badge.default {
- font-size: 0.75rem !important;
- color: rgb(164, 137, 255);
+.sl-badge {
+ isolation: isolate;
position: relative;
top: -1px;
+ border: 0 !important;
+ border-radius: 0 !important;
+ background-color: transparent !important;
+ color: color-mix(in srgb, var(--sl-color-text) 70%, transparent) !important;
+ font-family: 'JetBrains Mono', monospace !important;
+ font-weight: 600 !important;
+}
+
+.sl-badge::before {
+ position: absolute;
+ z-index: -1;
+ inset: 0.1rem;
+ border-radius: 0.2rem;
+ background-color: var(--typegpu-badge-color);
+ content: '';
+ filter: blur(0.1rem);
+ opacity: 20%;
+ pointer-events: none;
+}
+
+:root[data-theme='dark'] .sl-badge {
+ color: rgb(255 255 255 / 70%) !important;
+}
+
+:root[data-theme='dark'] .sl-badge::before {
+ opacity: 38.5%;
+}
+
+a[aria-current='page'] > .sl-badge {
+ color: rgb(255 255 255 / 70%) !important;
+}
+
+:root[data-theme='dark'] a[aria-current='page'] > .sl-badge {
+ color: rgb(0 0 0 / 70%) !important;
+}
+
+.sl-badge.default {
+ --typegpu-badge-color: var(--sl-badge-default-border);
+ font-size: 0.75rem !important;
+}
+
+.sl-badge.note {
+ --typegpu-badge-color: var(--sl-badge-note-border);
+}
+
+.sl-badge.success {
+ --typegpu-badge-color: var(--sl-badge-success-border);
+}
+
+.sl-badge.caution {
+ --typegpu-badge-color: var(--sl-badge-caution-border);
+}
+
+.sl-badge.tip {
+ --typegpu-badge-color: var(--sl-badge-tip-border);
+}
+
+.sl-badge.danger {
+ --typegpu-badge-color: var(--sl-badge-danger-border);
}
-.sl-badge:is([data-theme='light'] *) {
- background-color: white;
- color: var(--sl-badge-default-border);
- font-weight: normal !important;
+/* Remove Monaco editor rounding */
+.monaco-editor,
+.monaco-editor .overflow-guard,
+.monaco-editor .margin,
+.monaco-editor .scroll-decoration {
+ border-radius: 0 !important;
}
-a[aria-current='page'] > .sl-badge:is([data-theme='light'] *) {
- background-color: rgba(255, 255, 255, 0.6);
- color: var(--sl-badge-default-border);
+.no-transition,
+.no-transition *,
+.no-transition *::before,
+.no-transition *::after {
+ transition-duration: 0s !important;
}
diff --git a/apps/typegpu-docs/src/utils/examples/exampleViewStateAtoms.ts b/apps/typegpu-docs/src/utils/examples/exampleViewStateAtoms.ts
index bb236428fb..a7e9b4ca0e 100644
--- a/apps/typegpu-docs/src/utils/examples/exampleViewStateAtoms.ts
+++ b/apps/typegpu-docs/src/utils/examples/exampleViewStateAtoms.ts
@@ -1,29 +1,13 @@
import { atom } from 'jotai';
+import { atomWithSearchParams } from 'jotai-location';
import { atomWithStorage } from 'jotai/utils';
const storageOptions = { getOnInit: true };
export const menuShownAtom = atom(false);
-export const codeEditorShownAtom = atomWithStorage(
- 'code-editor-shown',
- false,
- undefined,
- storageOptions,
-);
+export const exampleFullscreenAtom = atomWithSearchParams('full', false, {
+ replace: true,
+});
export const tsoverUsedAtom = atomWithStorage('tsover-used', true, undefined, storageOptions);
-
-export const experimentalExamplesShownAtom = atomWithStorage(
- 'experimental-examples-shown',
- true,
- undefined,
- storageOptions,
-);
-
-export const groupExamplesByCategoryAtom = atomWithStorage(
- 'examples-group-by-category',
- false,
- undefined,
- storageOptions,
-);
diff --git a/apps/typegpu-docs/src/utils/examples/types.ts b/apps/typegpu-docs/src/utils/examples/types.ts
index 8cbef7e76e..2aff53cdbf 100644
--- a/apps/typegpu-docs/src/utils/examples/types.ts
+++ b/apps/typegpu-docs/src/utils/examples/types.ts
@@ -4,6 +4,7 @@ import type { Atom } from 'jotai';
export type ExampleMetadata = typeof ExampleMetadata.infer;
export const ExampleMetadata = type({
title: 'string',
+ description: 'string',
category: 'string',
'tags?': 'string[]',
coolFactor: 'number',
diff --git a/apps/typegpu-docs/tests/individual-example-tests/clouds.test.ts b/apps/typegpu-docs/tests/individual-example-tests/clouds.test.ts
index 761c29f529..90c98813fa 100644
--- a/apps/typegpu-docs/tests/individual-example-tests/clouds.test.ts
+++ b/apps/typegpu-docs/tests/individual-example-tests/clouds.test.ts
@@ -15,14 +15,70 @@ describe('clouds example', () => {
{
category: 'rendering',
name: 'clouds',
- expectedCalls: 1,
+ expectedCalls: 3,
setupMocks: mockResizeObserver,
},
device,
);
expect(shaderCodes).toMatchInlineSnapshot(`
- "struct fullScreenTriangle_Output {
+ "@group(0) @binding(0) var sizeUniform: vec3u;
+
+ @group(1) @binding(0) var noiseTexture: texture_2d;
+
+ @group(1) @binding(1) var sampler_1: sampler;
+
+ fn noise3d(pos: vec3f) -> f32 {
+ let idx = floor(pos);
+ let frac = fract(pos);
+ let smooth_1 = ((frac * frac) * (3f - (2f * frac)));
+ let texCoord0 = fract((((idx.xy + frac.xy) + (vec2f(37, 239) * idx.z)) / 32f));
+ let texCoord1 = fract((((idx.xy + frac.xy) + (vec2f(37, 239) * (idx.z + 1f))) / 32f));
+ let val0 = textureSampleLevel(noiseTexture, sampler_1, texCoord0, 0).x;
+ let val1 = textureSampleLevel(noiseTexture, sampler_1, texCoord1, 0).x;
+ return ((mix(val0, val1, smooth_1.z) * 2f) - 1f);
+ }
+
+ fn fbm(pos: vec3f) -> f32 {
+ var sum = 0f;
+ // unrolled iteration #0
+ sum += (noise3d((pos * 1.4f)) * 1f);
+ // unrolled iteration #1
+ sum += (noise3d((pos * 2.8f)) * 0.5f);
+ // unrolled iteration #2
+ sum += (noise3d((pos * 5.6f)) * 0.25f);
+ // ---
+ return sum;
+ }
+
+ fn packF32ToTwo8unorm(value: f32) -> vec2f {
+ let normalized = saturate(((value / 3.5f) + 0.5f));
+ let quantized = u32(floor((normalized * 65535f)));
+ let low = (quantized & 255u);
+ let high = (quantized >> 8u);
+ return (vec2f(f32(high), f32(low)) / 255f);
+ }
+
+ @group(1) @binding(2) var densityTexture: texture_storage_3d;
+
+ fn precomputeDensity(x: u32, y: u32, z: u32) {
+ let uvw = ((vec3f(f32(x), f32(y), f32(z)) + 0.5f) / 256f);
+ let worldPos = (uvw * 22.857142857142858f);
+ let fbmValue = fbm(worldPos);
+ let shadowFbmValue = fbm((worldPos + vec3f(1, 0, 0)));
+ let packedFbm = packF32ToTwo8unorm(fbmValue);
+ let packedShadowFbm = packF32ToTwo8unorm(shadowFbmValue);
+ textureStore(densityTexture, vec3u(x, y, z), vec4f(packedFbm, packedShadowFbm));
+ }
+
+ @compute @workgroup_size(8, 8, 4) fn mainCompute(@builtin(global_invocation_id) id: vec3u) {
+ if (any(id >= sizeUniform)) {
+ return;
+ }
+ precomputeDensity(id.x, id.y, id.z);
+ }
+
+ struct fullScreenTriangle_Output {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
}
@@ -78,6 +134,13 @@ describe('clouds example', () => {
@group(0) @binding(0) var resolutionUniform: vec2f;
+ fn getRayDirection(uv: vec2f) -> vec3f {
+ let screenRes = (&resolutionUniform);
+ let aspect = ((*screenRes).x / (*screenRes).y);
+ let screenPos = (((uv - 0.5f) * 2f) * vec2f(max(aspect, 1f), max((1f / aspect), 1f)));
+ return normalize(vec3f(screenPos.x, screenPos.y, 1f));
+ }
+
fn next() -> u32 {
{
let s0 = gpuSeed[0i];
@@ -105,62 +168,49 @@ describe('clouds example', () => {
return sample();
}
- @group(1) @binding(1) var noiseTexture: texture_2d;
+ @group(1) @binding(1) var densityTexture: texture_3d;
@group(1) @binding(2) var sampler_1: sampler;
- fn noise3d(pos: vec3f) -> f32 {
- let idx = floor(pos);
- let frac = fract(pos);
- let smooth_1 = ((frac * frac) * (3f - (2f * frac)));
- let texCoord0 = fract((((idx.xy + frac.xy) + (vec2f(37, 239) * idx.z)) / 256f));
- let texCoord1 = fract((((idx.xy + frac.xy) + (vec2f(37, 239) * (idx.z + 1f))) / 256f));
- let val0 = textureSampleLevel(noiseTexture, sampler_1, texCoord0, 0).x;
- let val1 = textureSampleLevel(noiseTexture, sampler_1, texCoord1, 0).x;
- return ((mix(val0, val1, smooth_1.z) * 2f) - 1f);
+ fn unpackTwo8unormToF32(encoded: vec2f) -> f32 {
+ let normalized = (((encoded.x * 256f) + encoded.y) / 257f);
+ return (((normalized * 2f) - 1f) * 1.75f);
}
- fn fbm(pos: vec3f) -> f32 {
- var sum = 0f;
- // unrolled iteration #0
- sum += (noise3d((pos * 1.4f)) * 1f);
- // unrolled iteration #1
- sum += (noise3d((pos * 2.8f)) * 0.5f);
- // unrolled iteration #2
- sum += (noise3d((pos * 5.6f)) * 0.25f);
- // ---
- return sum;
+ fn sampleDensityVolume(pos: vec3f) -> vec2f {
+ let uvw = fract((pos / 22.857142857142858f));
+ let sampled = textureSampleLevel(densityTexture, sampler_1, uvw, 0);
+ return vec2f(unpackTwo8unormToF32(sampled.xy), unpackTwo8unormToF32(sampled.zw));
}
- fn sampleDensity(pos: vec3f) -> f32 {
+ fn sampleDensities(pos: vec3f) -> vec2f {
+ let fbmValues = sampleDensityVolume(pos);
let coverage = (0.7f - (abs(pos.y) * 0.25f));
- return (saturate((fbm(pos) + coverage)) - 0.5f);
- }
-
- fn sampleDensityCheap(pos: vec3f) -> f32 {
- let noise = (noise3d((pos * 1.4f)) * 1f);
- return saturate(((noise + 0.7f) - 0.5f));
+ return (saturate((fbmValues + coverage)) - 0.5f);
}
- fn raymarch(rayOrigin: vec3f, rayDir: vec3f, sunDir: vec3f) -> vec4f {
+ fn raymarch(rayOrigin: vec3f, rayDir: vec3f) -> vec4f {
var accum = vec4f();
let params_1 = (¶ms);
let maxSteps = (*params_1).maxSteps;
let maxDepth = (*params_1).maxDistance;
let stepSize = (1f / f32(maxSteps));
+ let stepLength = (maxDepth / f32(maxSteps));
var dist = (randFloat01() * stepSize);
for (var i = 0; (i < maxSteps); i++) {
let samplePos = (rayOrigin + ((rayDir * dist) * maxDepth));
- let cloudDensity = sampleDensity(samplePos);
+ let densities = sampleDensities(samplePos);
+ let cloudDensity = densities.x;
if ((cloudDensity > 0f)) {
- let shadowPos = (samplePos + sunDir);
- let shadowDensity = sampleDensityCheap(shadowPos);
+ let shadowDensity = densities.y;
let shadow = saturate((cloudDensity - shadowDensity));
let lightVal = mix(0.3f, 1f, shadow);
let light = (vec3f(0.6600000262260437, 0.4949999749660492, 0.824999988079071) + ((vec3f(1, 0.699999988079071, 0.30000001192092896) * lightVal) * 0.9f));
let color = mix(vec3f(1), vec3f(0.20000000298023224), cloudDensity);
let lit = (color * light);
- let contrib = ((vec4f(lit, 1f) * cloudDensity) * (0.88f - accum.a));
+ let sampleOpacity = (1f - exp(((-(cloudDensity) * 4f) * stepLength)));
+ let weight = (sampleOpacity * (0.88f - accum.a));
+ let contrib = (vec4f(lit, 1f) * weight);
accum += contrib;
if ((accum.a >= 0.879f)) {
break;
@@ -176,20 +226,66 @@ describe('clouds example', () => {
}
@fragment fn fragment(_arg_0: FragmentIn) -> @location(0) vec4f {
- randSeed2((_arg_0.uv * params.time));
+ let time = params.time;
+ randSeed2((_arg_0.uv * time));
+ let rayOrigin = vec3f((sin((time * 0.6f)) * 0.5f), ((cos((time * 0.8f)) * 0.5f) - 1f), (time * 1f));
+ let rayDir = getRayDirection(_arg_0.uv);
+ return raymarch(rayOrigin, rayDir);
+ }
+
+ struct fullScreenTriangle_Output {
+ @builtin(position) pos: vec4f,
+ @location(0) uv: vec2f,
+ }
+
+ @vertex fn fullScreenTriangle(@builtin(vertex_index) vertexIndex: u32) -> fullScreenTriangle_Output {
+ const pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
+ const uv = array(vec2f(0, 1), vec2f(2, 1), vec2f(0, -1));
+
+ return fullScreenTriangle_Output(vec4f(pos[vertexIndex], 0, 1), uv[vertexIndex]);
+ }
+
+ @group(0) @binding(0) var resolutionUniform: vec2f;
+
+ fn getRayDirection(uv: vec2f) -> vec3f {
let screenRes = (&resolutionUniform);
let aspect = ((*screenRes).x / (*screenRes).y);
- var screenPos = ((_arg_0.uv - 0.5f) * 2f);
- screenPos = vec2f((screenPos.x * max(aspect, 1f)), (screenPos.y * max((1f / aspect), 1f)));
+ let screenPos = (((uv - 0.5f) * 2f) * vec2f(max(aspect, 1f), max((1f / aspect), 1f)));
+ return normalize(vec3f(screenPos.x, screenPos.y, 1f));
+ }
+
+ @group(1) @binding(0) var cloudTexture: texture_2d;
+
+ @group(1) @binding(1) var sampler_1: sampler;
+
+ struct FragmentIn {
+ @location(0) uv: vec2f,
+ }
+
+ @fragment fn fragment(_arg_0: FragmentIn) -> @location(0) vec4f {
+ let rayDir = getRayDirection(_arg_0.uv);
let sunDir = vec3f(1, 0, 0);
- let time = params.time;
- let rayOrigin = vec3f((sin((time * 0.6f)) * 0.5f), ((cos((time * 0.8f)) * 0.5f) - 1f), (time * 1f));
- let rayDir = normalize(vec3f(screenPos.x, screenPos.y, 1f));
let sunDot = saturate(dot(rayDir, sunDir));
let sunGlow = pow(sunDot, 1.371742112482853f);
- var skyCol = (vec3f(0.75, 0.6600000262260437, 0.8999999761581421) - ((vec3f(1, 0.699999988079071, 0.4300000071525574) * rayDir.y) * 0.35f));
+ let up = max(-(rayDir.y), 0f);
+ let down = max(rayDir.y, 0f);
+ var skyCol = (vec3f(0.75, 0.6600000262260437, 0.8999999761581421) - (vec3f(1, 0.699999988079071, 0.4300000071525574) * ((up * 0.35f) + (down * 0.15f))));
skyCol += (vec3f(1, 0.3700000047683716, 0.17000000178813934) * sunGlow);
- let cloudCol = raymarch(rayOrigin, rayDir, sunDir);
+ let halfTexel = (0.5f / vec2f(textureDimensions(cloudTexture)));
+ var cloudCol = (textureSample(cloudTexture, sampler_1, _arg_0.uv) * 0.5f);
+ // unrolled iteration #0
+ // unrolled iteration #0 / #0
+ cloudCol += (textureSample(cloudTexture, sampler_1, (_arg_0.uv + (halfTexel * vec2f(-1)))) * 0.125f);
+ // unrolled iteration #0 / #1
+ cloudCol += (textureSample(cloudTexture, sampler_1, (_arg_0.uv + (halfTexel * vec2f(-1, 1)))) * 0.125f);
+ // ---
+ // unrolled iteration #1
+ // unrolled iteration #1 / #0
+ cloudCol += (textureSample(cloudTexture, sampler_1, (_arg_0.uv + (halfTexel * vec2f(1, -1)))) * 0.125f);
+ // unrolled iteration #1 / #1
+ cloudCol += (textureSample(cloudTexture, sampler_1, (_arg_0.uv + (halfTexel * vec2f(1)))) * 0.125f);
+ // ---
+ // ---
let finalCol = ((skyCol * (1.1f - cloudCol.a)) + cloudCol.rgb);
return vec4f(finalCol, 1f);
}"
diff --git a/apps/typegpu-docs/tests/individual-example-tests/point-light-shadow.test.ts b/apps/typegpu-docs/tests/individual-example-tests/point-light-shadow.test.ts
index 351db35ed2..b8c219e677 100644
--- a/apps/typegpu-docs/tests/individual-example-tests/point-light-shadow.test.ts
+++ b/apps/typegpu-docs/tests/individual-example-tests/point-light-shadow.test.ts
@@ -22,12 +22,7 @@ describe('point light shadow example', () => {
);
expect(shaderCodes).toMatchInlineSnapshot(`
- "struct CameraData {
- viewProjectionMatrix: mat4x4f,
- inverseViewProjectionMatrix: mat4x4f,
- }
-
- @group(0) @binding(0) var camera: CameraData;
+ "var item: mat4x4f;
struct vertexDepth_Output {
@builtin(position) pos: vec4f,
@@ -37,7 +32,7 @@ describe('point light shadow example', () => {
@vertex fn vertexDepth(@location(0) position: vec3f, @location(3) column1: vec4f, @location(4) column2: vec4f, @location(5) column3: vec4f, @location(6) column4: vec4f) -> vertexDepth_Output {
let modelMatrix = mat4x4f(column1, column2, column3, column4);
let worldPos = (modelMatrix * vec4f(position, 1f)).xyz;
- let pos = (camera.viewProjectionMatrix * vec4f(worldPos, 1f));
+ let pos = (item * vec4f(worldPos, 1f));
return vertexDepth_Output(pos, worldPos);
}
diff --git a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts
index 6bcd642c44..e148027d11 100644
--- a/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts
+++ b/packages/eslint-plugin/src/rules/noUnsupportedSyntax.ts
@@ -140,15 +140,6 @@ export const noUnsupportedSyntax = createRule({
report(node, `'new' expression`);
},
- Property(node) {
- if (!directives.getEnclosingTypegpuFunction()) {
- return;
- }
- if (node.computed) {
- report(node, 'computed property key');
- }
- },
-
SequenceExpression(node) {
if (!directives.getEnclosingTypegpuFunction()) {
return;
diff --git a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts
index 7ee0b6798a..0bc73771de 100644
--- a/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts
+++ b/packages/eslint-plugin/tests/rules/noUnsupportedSyntax.test.ts
@@ -9,6 +9,7 @@ describe('noUnsupportedSyntax', () => {
"const fn = () => { 'use gpu'; const x = Struct({ prop: 1}); }",
"const fn = () => { 'use gpu'; let x = 1; }",
"const cls = new (class { #priv = 1; fn = () => { 'use gpu'; const a = this.#priv; } } )()",
+ "const fn = () => { 'use gpu'; const obj = { [key]: 1 }; }",
],
invalid: [
{
@@ -202,15 +203,6 @@ describe('noUnsupportedSyntax', () => {
},
],
},
- {
- code: "const fn = () => { 'use gpu'; const obj = { [key]: 1 }; }",
- errors: [
- {
- messageId: 'unexpected',
- data: { snippet: '[key]: 1', syntax: 'computed property key' },
- },
- ],
- },
{
code: "const fn = () => { 'use gpu'; (a, b); }",
errors: [
diff --git a/packages/tinyest-for-wgsl/package.json b/packages/tinyest-for-wgsl/package.json
index 5822a091e6..868b120461 100644
--- a/packages/tinyest-for-wgsl/package.json
+++ b/packages/tinyest-for-wgsl/package.json
@@ -63,6 +63,18 @@
"tsdown": "catalog:build",
"typescript": "catalog:types"
},
+ "peerDependencies": {
+ "@babel/types": "catalog:",
+ "acorn": "^8.14.1"
+ },
+ "peerDependenciesMeta": {
+ "acorn": {
+ "optional": true
+ },
+ "@babel/types": {
+ "optional": true
+ }
+ },
"engines": {
"node": ">=12.20.0"
},
diff --git a/packages/tinyest-for-wgsl/src/externals.ts b/packages/tinyest-for-wgsl/src/externals.ts
index 479ba914d4..8af9d3bef1 100644
--- a/packages/tinyest-for-wgsl/src/externals.ts
+++ b/packages/tinyest-for-wgsl/src/externals.ts
@@ -29,9 +29,9 @@ export function tryFindExternalChain(ctx: Context, node: JsNode): string | undef
let property;
if (node.property.type === 'Identifier' && node.property.name !== '$') {
property = node.property.name;
- } else if (node.property.type === 'PrivateName') {
+ } else if (node.property.type === /* babel */ 'PrivateName') {
property = `#${node.property.id.name}`;
- } else if (node.property.type === 'PrivateIdentifier') {
+ } else if (node.property.type === /* acorn */ 'PrivateIdentifier') {
property = `#${node.property.name}`;
} else {
return;
diff --git a/packages/tinyest-for-wgsl/src/functionParts.ts b/packages/tinyest-for-wgsl/src/functionParts.ts
new file mode 100644
index 0000000000..4fad4b9330
--- /dev/null
+++ b/packages/tinyest-for-wgsl/src/functionParts.ts
@@ -0,0 +1,122 @@
+import type * as babel from '@babel/types';
+import type * as acorn from 'acorn';
+import * as tinyest from 'tinyest';
+import type { JsNode } from './types.ts';
+
+type FunctionNode =
+ | acorn.ArrowFunctionExpression
+ | acorn.FunctionExpression
+ | acorn.FunctionDeclaration
+ | acorn.AnonymousFunctionDeclaration
+ | babel.ArrowFunctionExpression
+ | babel.FunctionExpression
+ | babel.FunctionDeclaration;
+
+/**
+ * Unwraps the root node until we get to a function.
+ */
+function unwrapToFunction(rootNode: JsNode): FunctionNode {
+ let functionNode: FunctionNode | null = null;
+
+ let unwrappedNode = rootNode;
+ while (true) {
+ if (unwrappedNode.type === 'Program') {
+ const statement = unwrappedNode.body.filter(
+ (n) => n.type === 'ExpressionStatement' || n.type === 'FunctionDeclaration',
+ )[0]; // <- assuming only one function declaration
+
+ if (!statement) {
+ break;
+ }
+
+ unwrappedNode = statement;
+ } else if (unwrappedNode.type === 'ExpressionStatement') {
+ unwrappedNode = unwrappedNode.expression;
+ } else if (unwrappedNode.type === 'ArrowFunctionExpression') {
+ functionNode = unwrappedNode;
+ break; // We got a function
+ } else if (unwrappedNode.type === 'FunctionExpression') {
+ functionNode = unwrappedNode;
+ break; // We got a function
+ } else if (unwrappedNode.type === 'FunctionDeclaration') {
+ functionNode = unwrappedNode;
+ break; // We got a function
+ } else {
+ // Unsupported node
+ break;
+ }
+ }
+
+ if (!functionNode) {
+ throw new Error(
+ `tgpu.fn expected a single function to be passed as implementation ${JSON.stringify(
+ unwrappedNode,
+ )}`,
+ );
+ }
+
+ return functionNode;
+}
+
+/**
+ * Rejects TypeGPU functions that cannot be represented.
+ */
+function validateFunction(functionNode: FunctionNode): void {
+ if (functionNode.async) {
+ throw new Error('tgpu.fn cannot be async');
+ }
+
+ if (functionNode.generator) {
+ throw new Error('tgpu.fn cannot be a generator');
+ }
+
+ const unsupportedTypes = new Set(
+ functionNode.params.flatMap((param) =>
+ param.type === 'ObjectPattern' || param.type === 'Identifier' ? [] : [param.type],
+ ),
+ );
+ if (unsupportedTypes.size > 0) {
+ throw new Error(`Unsupported function parameter type(s): ${[...unsupportedTypes].join(', ')}`);
+ }
+}
+
+function parseParams(functionNode: FunctionNode): tinyest.FuncParameter[] {
+ return (
+ functionNode.params as (
+ | babel.Identifier
+ | acorn.Identifier
+ | babel.ObjectPattern
+ | acorn.ObjectPattern
+ )[]
+ ).map((param) =>
+ param.type === 'ObjectPattern'
+ ? {
+ type: tinyest.FuncParameterType.destructuredObject,
+ props: param.properties.flatMap((prop) =>
+ (prop.type === /* acorn */ 'Property' || prop.type === /* babel */ 'ObjectProperty') &&
+ prop.key.type === 'Identifier' &&
+ prop.value.type === 'Identifier'
+ ? [{ name: prop.key.name, alias: prop.value.name }]
+ : [],
+ ),
+ }
+ : {
+ type: tinyest.FuncParameterType.identifier,
+ name: param.name,
+ },
+ );
+}
+
+export function extractFunctionParts(rootNode: JsNode): {
+ params: tinyest.FuncParameter[];
+ body: acorn.BlockStatement | acorn.Expression | babel.BlockStatement | babel.Expression;
+} {
+ const functionNode = unwrapToFunction(rootNode);
+
+ validateFunction(functionNode);
+
+ return {
+ params: parseParams(functionNode),
+ body: functionNode.body,
+ };
+}
diff --git a/packages/tinyest-for-wgsl/src/index.ts b/packages/tinyest-for-wgsl/src/index.ts
index b9d34d2db3..8a1088f65f 100644
--- a/packages/tinyest-for-wgsl/src/index.ts
+++ b/packages/tinyest-for-wgsl/src/index.ts
@@ -1,2 +1,9 @@
-export { transpileFn, transpileNode } from './parsers.ts';
-export { type Externals } from './types.ts';
+export {
+ transpileAcornFn,
+ transpileAcornNode,
+ transpileBabelFn,
+ transpileBabelNode,
+ transpileFn,
+ transpileNode,
+} from './parsers.ts';
+export type { Externals, TranspilationResult } from './types.ts';
diff --git a/packages/tinyest-for-wgsl/src/parsers.ts b/packages/tinyest-for-wgsl/src/parsers.ts
index 3bdc854d73..674dd62fdc 100644
--- a/packages/tinyest-for-wgsl/src/parsers.ts
+++ b/packages/tinyest-for-wgsl/src/parsers.ts
@@ -1,466 +1,173 @@
import type * as babel from '@babel/types';
import type * as acorn from 'acorn';
import * as tinyest from 'tinyest';
-import { FuncParameterType } from 'tinyest';
-import type { Context, JsNode, TranspilationResult } from './types.ts';
+import type { Context, JsNode, TranspilationResult, Transpile, Transpilers } from './types.ts';
import { tryFindExternalChain } from './externals.ts';
+import {
+ acornTranspilers,
+ babelTranspilers,
+ transpileAcornProperty,
+ transpileBabelObjectProperty,
+} from './transpilers.ts';
+import { extractFunctionParts } from './functionParts.ts';
const { NodeTypeCatalog: NODE } = tinyest;
-const tsFallthrough = (ctx: Context, node: { expression: babel.Expression }): tinyest.AnyNode => {
- return transpile(ctx, node.expression);
-};
-
-const Transpilers: Partial<{
- [Type in JsNode['type']]: (
- ctx: Context,
- node: Extract,
- ) => tinyest.AnyNode;
-}> = {
- Program(ctx, node) {
- const body = node.body[0];
-
- if (!body) {
- throw new Error('tgpu.fn was not implemented correctly.');
- }
-
- return transpile(ctx, body);
- },
-
- ExpressionStatement: (ctx, node) => transpile(ctx, node.expression),
-
- ArrowFunctionExpression: () => {
- throw new Error('Arrow functions are not supported inside TGSL.');
- },
-
- BlockStatement(ctx, node) {
- ctx.stack.push({ declaredNames: [] });
-
- const result = [
- NODE.block,
- node.body.map((statement) => transpile(ctx, statement) as tinyest.Statement),
- ] as const;
-
- ctx.stack.pop();
-
- return result;
- },
-
- ReturnStatement: (ctx, node) =>
- node.argument
- ? [NODE.return, transpile(ctx, node.argument) as tinyest.Expression]
- : [NODE.return],
-
- Identifier(ctx, node) {
- return node.name;
- },
-
- ThisExpression() {
- return 'this';
- },
-
- BinaryExpression(ctx, node) {
- const left = transpile(ctx, node.left) as tinyest.Expression;
- const right = transpile(ctx, node.right) as tinyest.Expression;
- return [NODE.binaryExpr, left, node.operator as tinyest.BinaryOperator, right];
- },
-
- LogicalExpression(ctx, node) {
- const left = transpile(ctx, node.left) as tinyest.Expression;
- const right = transpile(ctx, node.right) as tinyest.Expression;
- return [NODE.logicalExpr, left, node.operator as tinyest.LogicalOperator, right];
- },
-
- AssignmentExpression(ctx, node) {
- const left = transpile(ctx, node.left) as tinyest.Expression;
- const right = transpile(ctx, node.right) as tinyest.Expression;
- return [NODE.assignmentExpr, left, node.operator as tinyest.AssignmentOperator, right];
- },
-
- UnaryExpression(ctx, node) {
- const wgslOp = node.operator;
- const argument = transpile(ctx, node.argument) as tinyest.Expression;
- return [NODE.unaryExpr, wgslOp, argument] as tinyest.UnaryExpression;
- },
-
- MemberExpression(ctx, node) {
- const object = transpile(ctx, node.object) as tinyest.Expression;
-
- // If the property is computed, it could potentially be an external identifier.
- if (node.computed) {
- const property = transpile(ctx, node.property) as tinyest.Expression;
- return [NODE.indexAccess, object, property];
- }
-
- // If the property is not computed, we don't want to register identifiers as external.
- ctx.ignoreExternalDepth++;
- const property = transpile(ctx, node.property) as tinyest.Expression;
- ctx.ignoreExternalDepth--;
-
- if (typeof property !== 'string') {
- throw new Error('Expected identifier as property access key.');
- }
-
- return [NODE.memberAccess, object, property];
- },
-
- UpdateExpression(ctx, node) {
- const operator = node.operator;
- const argument = transpile(ctx, node.argument) as tinyest.Expression;
- if (node.prefix) {
- throw new Error('Prefix update expressions are not supported in WGSL.');
- }
- return [NODE.postUpdate, operator, argument];
- },
-
- ConditionalExpression(ctx, node) {
- const test = transpile(ctx, node.test) as tinyest.Expression;
- const consequent = transpile(ctx, node.consequent) as tinyest.Expression;
- const alternative = transpile(ctx, node.alternate) as tinyest.Expression;
-
- return [NODE.conditionalExpr, test, consequent, alternative];
- },
-
- Literal(ctx, node) {
- if (typeof node.value === 'boolean') {
- return node.value;
- }
- if (typeof node.value === 'string') {
- return [NODE.stringLiteral, node.value];
- }
- if (node.regex) {
- throw new Error('Regular expression literals are not representable in WGSL.');
- }
- if (node.bigint) {
- console.warn('BigInt literals are represented as numbers - loss of precision may occur.');
- }
- if (node.raw === 'null') {
- return [NODE.nullLiteral];
- }
- return [NODE.numericLiteral, String(Number(node.value))];
- },
-
- NumericLiteral(ctx, node) {
- return [NODE.numericLiteral, String(node.value)];
- },
-
- BigIntLiteral(ctx, node) {
- console.warn('BigInt literals are represented as numbers - loss of precision may occur.');
- return [NODE.numericLiteral, String(Number.parseInt(node.value))];
- },
-
- BooleanLiteral(ctx, node) {
- return node.value;
- },
-
- StringLiteral(ctx, node) {
- return [NODE.stringLiteral, node.value];
- },
-
- CallExpression(ctx, node) {
- const callee = transpile(ctx, node.callee) as tinyest.Expression;
-
- const args = node.arguments.map((arg) => transpile(ctx, arg)) as tinyest.Expression[];
-
- return [NODE.call, callee, args];
- },
+function createContext(params: tinyest.FuncParameter[]): Context {
+ return {
+ externalNames: new Map(),
+ ignoreExternalDepth: 0,
+ visitedNodes: new Set(),
+ stack: [
+ {
+ declaredNames: params.flatMap((param) =>
+ param.type === tinyest.FuncParameterType.identifier
+ ? param.name
+ : param.props.map((prop) => prop.alias),
+ ),
+ },
+ ],
+ };
+}
- ArrayExpression: (ctx, node) => [
- NODE.arrayExpr,
- node.elements.map((elem) => {
- if (!elem || elem.type === 'SpreadElement') {
- throw new Error('Spread elements are not supported in TGSL.');
+function createLegacyTraspilers() {
+ return {
+ ...babelTranspilers,
+ ...acornTranspilers,
+
+ ObjectExpression(ctx, node, transpile) {
+ const objectProperties = node.properties.map((prop) => {
+ if (prop.type === 'SpreadElement') {
+ throw new Error('Spread elements are not supported in TGSL.');
+ }
+
+ if (prop.type === 'ObjectMethod' || (prop.type === 'Property' && prop.method)) {
+ throw new Error('Object method elements are not supported in TGSL.');
+ }
+
+ return prop.type === 'Property'
+ ? transpileAcornProperty(ctx, prop, transpile)
+ : transpileBabelObjectProperty(ctx, prop, transpile);
+ });
+
+ if (objectProperties.some((prop) => /* computed */ prop[2])) {
+ return [NODE.objectExpr, objectProperties] as tinyest.ObjectExpression;
}
- return transpile(ctx, elem) as tinyest.Expression;
- }),
- ],
-
- VariableDeclaration(ctx, node) {
- if (node.declarations.length !== 1 || !node.declarations[0]) {
- throw new Error('Currently only one declaration in a statement is supported.');
- }
-
- const decl = node.declarations[0];
- ctx.ignoreExternalDepth++;
- const id = transpile(ctx, decl.id);
- ctx.ignoreExternalDepth--;
-
- if (typeof id !== 'string') {
- throw new Error('Invalid variable declaration, expected identifier.');
- }
-
- ctx.stack[ctx.stack.length - 1]?.declaredNames.push(id);
-
- const init = decl.init ? (transpile(ctx, decl.init) as tinyest.Expression) : undefined;
-
- if (node.kind === 'var') {
- throw new Error('`var` declarations are not supported.');
- }
-
- if (node.kind === 'const') {
- return init !== undefined ? [NODE.const, id, init] : [NODE.const, id];
- }
-
- return init !== undefined ? [NODE.let, id, init] : [NODE.let, id];
- },
-
- IfStatement(ctx, node) {
- const test = transpile(ctx, node.test) as tinyest.Expression;
- const consequent = transpile(ctx, node.consequent) as tinyest.Statement;
- const alternate = node.alternate
- ? (transpile(ctx, node.alternate) as tinyest.Statement)
- : undefined;
- return alternate ? [NODE.if, test, consequent, alternate] : [NODE.if, test, consequent];
- },
+ const obj: Record = {};
+ const seenKeys = new Set();
- ObjectExpression(ctx, node) {
- const properties: Record = {};
-
- for (const prop of node.properties) {
- // TODO: Handle SpreadElement
- if (prop.type === 'SpreadElement') {
- throw new Error('Spread elements are not supported in TGSL.');
+ for (const prop of objectProperties) {
+ const key = prop[0] as string;
+ if (seenKeys.has(key)) {
+ throw new Error(`Duplicate object property key: '${key}'.`);
+ }
+ seenKeys.add(key);
+ obj[key] = /* value */ prop[1];
}
- // TODO: Handle computed properties
- if (prop.key.type !== 'Identifier' && prop.key.type !== 'Literal') {
- throw new Error('Only Identifier and Literal keys are supported as object keys.');
- }
+ return [NODE.objectExpr, obj] as tinyest.ObjectExpression;
+ },
+ } as Transpilers;
+}
- // TODO: Handle Object method
- if (prop.type === 'ObjectMethod') {
- throw new Error('Object method elements are not supported in TGSL.');
+function createParser(kind: 'acorn' | 'babel' | 'legacy') {
+ const transpilers = (
+ kind === 'acorn'
+ ? acornTranspilers
+ : kind === 'babel'
+ ? babelTranspilers
+ : createLegacyTraspilers()
+ ) as Transpilers;
+
+ const transpile: Transpile = (ctx, node) => {
+ const transpiler = transpilers[node.type];
+
+ if (!transpiler) {
+ throw new Error(`Unsupported JS functionality: ${node.type}`);
+ }
+
+ if (ctx.ignoreExternalDepth === 0) {
+ // Check if the node is an external prop access chain, and if so,
+ // add it to externals and swap the AST node for an identifier.
+ const externalChain = tryFindExternalChain(ctx, node);
+ if (externalChain) {
+ ctx.externalNames.set(externalChain, externalChain);
+ return externalChain;
}
-
- ctx.ignoreExternalDepth++;
- const key =
- prop.key.type === 'Identifier'
- ? (transpile(ctx, prop.key) as string)
- : String(prop.key.value);
- ctx.ignoreExternalDepth--;
- const value = transpile(ctx, prop.value) as tinyest.Expression;
-
- properties[key] = value;
}
- return [NODE.objectExpr, properties];
- },
-
- ForStatement(ctx, node) {
- ctx.stack.push({ declaredNames: [] });
-
- const init = node.init ? (transpile(ctx, node.init) as tinyest.Statement) : null;
- const condition = node.test ? (transpile(ctx, node.test) as tinyest.Expression) : null;
- const update = node.update ? (transpile(ctx, node.update) as tinyest.Statement) : null;
- const body = transpile(ctx, node.body) as tinyest.Statement;
-
- ctx.stack.pop();
-
- return [NODE.for, init, condition, update, body];
- },
-
- WhileStatement(ctx, node) {
- const condition = transpile(ctx, node.test) as tinyest.Expression;
- const body = transpile(ctx, node.body) as tinyest.Statement;
-
- return [NODE.while, condition, body];
- },
-
- ForOfStatement(ctx, node) {
- ctx.stack.push({ declaredNames: [] });
-
- const loopVar = transpile(ctx, node.left) as tinyest.Const | tinyest.Let;
- const iterable = transpile(ctx, node.right) as tinyest.Expression;
- const body = transpile(ctx, node.body) as tinyest.Statement;
-
- ctx.stack.pop();
-
- return [NODE.forOf, loopVar, iterable, body];
- },
+ // @ts-ignore
+ return transpiler(ctx, node, transpile);
+ };
- ContinueStatement() {
- return [NODE.continue];
- },
+ return {
+ transpileFn(rootNode: JsNode): TranspilationResult {
+ const { params, body } = extractFunctionParts(rootNode);
+ const ctx = createContext(params);
+
+ const tinyestBody = transpile(ctx, body);
+
+ if (body.type === 'BlockStatement') {
+ return {
+ params,
+ body: tinyestBody as tinyest.Block,
+ externalNames: ctx.externalNames,
+ };
+ }
- BreakStatement() {
- return [NODE.break];
- },
+ return {
+ params,
+ body: [NODE.block, [[NODE.return, tinyestBody as tinyest.Expression]]],
+ externalNames: ctx.externalNames,
+ };
+ },
- NullLiteral() {
- return [NODE.nullLiteral];
- },
+ transpileNode(node: JsNode): tinyest.AnyNode {
+ return transpile(createContext([]), node);
+ },
+ };
+}
- TSAsExpression: tsFallthrough,
- TSSatisfiesExpression: tsFallthrough,
- TSNonNullExpression: tsFallthrough,
+const parsers = {
+ acorn: createParser('acorn'),
+ babel: createParser('babel'),
};
-function transpile(ctx: Context, node: JsNode): tinyest.AnyNode {
- const transpiler = Transpilers[node.type];
+let legacyParser: ReturnType | undefined = undefined;
- if (!transpiler) {
- throw new Error(`Unsupported JS functionality: ${node.type}`);
- }
-
- if (ctx.ignoreExternalDepth === 0) {
- // Check if the node is an external prop access chain, and if so,
- // add it to externals and swap the AST node for an identifier.
- const externalChain = tryFindExternalChain(ctx, node);
- if (externalChain) {
- ctx.externalNames.set(externalChain, externalChain);
- return externalChain;
- }
- }
-
- // @ts-expect-error
- return transpiler(ctx, node);
+export function transpileAcornFn(rootNode: acorn.AnyNode): TranspilationResult {
+ return parsers.acorn.transpileFn(rootNode);
}
-export function extractFunctionParts(rootNode: JsNode): {
- params: tinyest.FuncParameter[];
- body: acorn.BlockStatement | acorn.Expression | babel.BlockStatement | babel.Expression;
-} {
- let functionNode:
- | acorn.ArrowFunctionExpression
- | acorn.FunctionExpression
- | acorn.FunctionDeclaration
- | acorn.AnonymousFunctionDeclaration
- | babel.ArrowFunctionExpression
- | babel.FunctionExpression
- | babel.FunctionDeclaration
- | null = null;
-
- // Unwrapping until we get to a function
- let unwrappedNode = rootNode;
- while (true) {
- if (unwrappedNode.type === 'Program') {
- const statement = unwrappedNode.body.filter(
- (n) => n.type === 'ExpressionStatement' || n.type === 'FunctionDeclaration',
- )[0]; // <- assuming only one function declaration
-
- if (!statement) {
- break;
- }
-
- unwrappedNode = statement;
- } else if (unwrappedNode.type === 'ExpressionStatement') {
- unwrappedNode = unwrappedNode.expression;
- } else if (unwrappedNode.type === 'ArrowFunctionExpression') {
- functionNode = unwrappedNode;
- break; // We got a function
- } else if (unwrappedNode.type === 'FunctionExpression') {
- functionNode = unwrappedNode;
- break; // We got a function
- } else if (unwrappedNode.type === 'FunctionDeclaration') {
- functionNode = unwrappedNode;
- break; // We got a function
- } else {
- // Unsupported node
- break;
- }
- }
-
- if (!functionNode) {
- throw new Error(
- `tgpu.fn expected a single function to be passed as implementation ${JSON.stringify(
- unwrappedNode,
- )}`,
- );
- }
-
- if (functionNode.async) {
- throw new Error('tgpu.fn cannot be async');
- }
-
- if (functionNode.generator) {
- throw new Error('tgpu.fn cannot be a generator');
- }
+export function transpileAcornNode(rootNode: acorn.AnyNode): tinyest.AnyNode {
+ return parsers.acorn.transpileNode(rootNode);
+}
- const unsupportedTypes = new Set(
- functionNode.params.flatMap((param) =>
- param.type === 'ObjectPattern' || param.type === 'Identifier' ? [] : [param.type],
- ),
- );
- if (unsupportedTypes.size > 0) {
- throw new Error(`Unsupported function parameter type(s): ${[...unsupportedTypes].join(', ')}`);
- }
+export function transpileBabelFn(rootNode: babel.Node): TranspilationResult {
+ return parsers.babel.transpileFn(rootNode);
+}
- return {
- params: (
- functionNode.params as (
- | babel.Identifier
- | acorn.Identifier
- | babel.ObjectPattern
- | acorn.ObjectPattern
- )[]
- ).map((param) =>
- param.type === 'ObjectPattern'
- ? {
- type: FuncParameterType.destructuredObject,
- props: param.properties.flatMap((prop) =>
- (prop.type === 'Property' || prop.type === 'ObjectProperty') &&
- prop.key.type === 'Identifier' &&
- prop.value.type === 'Identifier'
- ? [{ name: prop.key.name, alias: prop.value.name }]
- : [],
- ),
- }
- : {
- type: FuncParameterType.identifier,
- name: param.name,
- },
- ),
- body: functionNode.body,
- };
+export function transpileBabelNode(rootNode: babel.Node): tinyest.AnyNode {
+ return parsers.babel.transpileNode(rootNode);
}
+/**
+ * @deprecated Use {@link transpileAcornFn} or {@link transpileBabelFn} instead.
+ */
export function transpileFn(rootNode: JsNode): TranspilationResult {
- const { params, body } = extractFunctionParts(rootNode);
-
- const ctx: Context = {
- externalNames: new Map(),
- ignoreExternalDepth: 0,
- visitedNodes: new Set(),
- stack: [
- {
- declaredNames: params.flatMap((param) =>
- param.type === FuncParameterType.identifier
- ? param.name
- : param.props.map((prop) => prop.alias),
- ),
- },
- ],
- };
-
- const tinyestBody = transpile(ctx, body);
-
- if (body.type === 'BlockStatement') {
- return {
- params,
- body: tinyestBody as tinyest.Block,
- externalNames: ctx.externalNames,
- };
+ if (legacyParser === undefined) {
+ legacyParser = createParser('legacy');
}
-
- return {
- params,
- body: [NODE.block, [[NODE.return, tinyestBody as tinyest.Expression]]],
- externalNames: ctx.externalNames,
- };
+ return legacyParser.transpileFn(rootNode);
}
-export function transpileNode(node: JsNode): tinyest.AnyNode {
- const ctx: Context = {
- externalNames: new Map(),
- ignoreExternalDepth: 0,
- visitedNodes: new Set(),
- stack: [
- {
- declaredNames: [],
- },
- ],
- };
-
- return transpile(ctx, node);
+/**
+ * @deprecated Use {@link transpileAcornNode} or {@link transpileBabelNode} instead.
+ */
+export function transpileNode(rootNode: JsNode): tinyest.AnyNode {
+ if (legacyParser === undefined) {
+ legacyParser = createParser('legacy');
+ }
+ return legacyParser.transpileNode(rootNode);
}
diff --git a/packages/tinyest-for-wgsl/src/transpilers.ts b/packages/tinyest-for-wgsl/src/transpilers.ts
new file mode 100644
index 0000000000..a81d307a36
--- /dev/null
+++ b/packages/tinyest-for-wgsl/src/transpilers.ts
@@ -0,0 +1,408 @@
+import type * as acorn from 'acorn';
+import type * as babel from '@babel/types';
+import * as tinyest from 'tinyest';
+import type { Context, JsNode, Transpile, Transpilers } from './types.ts';
+
+const { NodeTypeCatalog: NODE } = tinyest;
+
+type SharedTranspilers = Extract;
+
+export const baseTranspilers = {
+ Program(ctx, node, transpile) {
+ const body = node.body[0];
+
+ if (!body) {
+ throw new Error('tgpu.fn was not implemented correctly.');
+ }
+
+ return transpile(ctx, body);
+ },
+
+ ExpressionStatement(ctx, node, transpile) {
+ return transpile(ctx, node.expression);
+ },
+
+ ArrowFunctionExpression() {
+ throw new Error('Arrow functions are not supported inside TGSL.');
+ },
+
+ BlockStatement(ctx, node, transpile) {
+ ctx.stack.push({ declaredNames: [] });
+
+ try {
+ return [
+ NODE.block,
+ node.body.map((statement) => transpile(ctx, statement) as tinyest.Statement),
+ ] as const;
+ } finally {
+ ctx.stack.pop();
+ }
+ },
+
+ ReturnStatement(ctx, node, transpile) {
+ return node.argument
+ ? [NODE.return, transpile(ctx, node.argument) as tinyest.Expression]
+ : [NODE.return];
+ },
+
+ Identifier(_ctx, node) {
+ return node.name;
+ },
+
+ ThisExpression() {
+ return 'this';
+ },
+
+ BinaryExpression(ctx, node, transpile) {
+ const left = transpile(ctx, node.left) as tinyest.Expression;
+ const right = transpile(ctx, node.right) as tinyest.Expression;
+
+ return [NODE.binaryExpr, left, node.operator as tinyest.BinaryOperator, right];
+ },
+
+ LogicalExpression(ctx, node, transpile) {
+ const left = transpile(ctx, node.left) as tinyest.Expression;
+ const right = transpile(ctx, node.right) as tinyest.Expression;
+
+ return [NODE.logicalExpr, left, node.operator as tinyest.LogicalOperator, right];
+ },
+
+ AssignmentExpression(ctx, node, transpile) {
+ const left = transpile(ctx, node.left) as tinyest.Expression;
+ const right = transpile(ctx, node.right) as tinyest.Expression;
+
+ return [NODE.assignmentExpr, left, node.operator as tinyest.AssignmentOperator, right];
+ },
+
+ UnaryExpression(ctx, node, transpile) {
+ const wgslOp = node.operator;
+ const argument = transpile(ctx, node.argument) as tinyest.Expression;
+
+ return [NODE.unaryExpr, wgslOp, argument] as tinyest.UnaryExpression;
+ },
+
+ MemberExpression(ctx, node, transpile) {
+ const object = transpile(ctx, node.object) as tinyest.Expression;
+
+ // If the property is computed, it could potentially be an external identifier.
+ if (node.computed) {
+ const property = transpile(ctx, node.property) as tinyest.Expression;
+ return [NODE.indexAccess, object, property];
+ }
+
+ // If the property is not computed, we don't want to register identifiers as external.
+ ctx.ignoreExternalDepth++;
+ const property = transpile(ctx, node.property) as tinyest.Expression;
+ ctx.ignoreExternalDepth--;
+
+ if (typeof property !== 'string') {
+ throw new Error('Expected identifier as property access key.');
+ }
+
+ return [NODE.memberAccess, object, property];
+ },
+
+ UpdateExpression(ctx, node, transpile) {
+ const operator = node.operator;
+ const argument = transpile(ctx, node.argument) as tinyest.Expression;
+
+ if (node.prefix) {
+ throw new Error('Prefix update expressions are not supported in WGSL.');
+ }
+
+ return [NODE.postUpdate, operator, argument];
+ },
+
+ ConditionalExpression(ctx, node, transpile) {
+ const test = transpile(ctx, node.test) as tinyest.Expression;
+ const consequent = transpile(ctx, node.consequent) as tinyest.Expression;
+ const alternative = transpile(ctx, node.alternate) as tinyest.Expression;
+
+ return [NODE.conditionalExpr, test, consequent, alternative];
+ },
+
+ CallExpression(ctx, node, transpile) {
+ const callee = transpile(ctx, node.callee) as tinyest.Expression;
+ const args = node.arguments.map((argument) => transpile(ctx, argument) as tinyest.Expression);
+
+ return [NODE.call, callee, args];
+ },
+
+ ArrayExpression(ctx, node, transpile) {
+ return [
+ NODE.arrayExpr,
+ node.elements.map((element) => {
+ if (!element || element.type === 'SpreadElement') {
+ throw new Error('Spread elements are not supported in TGSL.');
+ }
+ return transpile(ctx, element) as tinyest.Expression;
+ }),
+ ];
+ },
+
+ VariableDeclaration(ctx, node, transpile) {
+ if (node.declarations.length !== 1 || !node.declarations[0]) {
+ throw new Error('Currently only one declaration in a statement is supported.');
+ }
+
+ const decl = node.declarations[0];
+ ctx.ignoreExternalDepth++;
+ const id = transpile(ctx, decl.id);
+ ctx.ignoreExternalDepth--;
+
+ if (typeof id !== 'string') {
+ throw new Error('Invalid variable declaration, expected identifier.');
+ }
+
+ ctx.stack[ctx.stack.length - 1]?.declaredNames.push(id);
+
+ const init = decl.init ? (transpile(ctx, decl.init) as tinyest.Expression) : undefined;
+
+ if (node.kind === 'var') {
+ throw new Error('`var` declarations are not supported.');
+ }
+
+ if (node.kind === 'const') {
+ return init !== undefined ? [NODE.const, id, init] : [NODE.const, id];
+ }
+
+ return init !== undefined ? [NODE.let, id, init] : [NODE.let, id];
+ },
+
+ IfStatement(ctx, node, transpile) {
+ const test = transpile(ctx, node.test) as tinyest.Expression;
+ const consequent = transpile(ctx, node.consequent) as tinyest.Statement;
+ const alternate = node.alternate
+ ? (transpile(ctx, node.alternate) as tinyest.Statement)
+ : undefined;
+
+ return alternate ? [NODE.if, test, consequent, alternate] : [NODE.if, test, consequent];
+ },
+
+ ForStatement(ctx, node, transpile) {
+ ctx.stack.push({ declaredNames: [] });
+
+ const init = node.init ? (transpile(ctx, node.init) as tinyest.Statement) : null;
+ const condition = node.test ? (transpile(ctx, node.test) as tinyest.Expression) : null;
+ const update = node.update ? (transpile(ctx, node.update) as tinyest.Statement) : null;
+ const body = transpile(ctx, node.body) as tinyest.Statement;
+
+ ctx.stack.pop();
+
+ return [NODE.for, init, condition, update, body];
+ },
+
+ WhileStatement(ctx, node, transpile) {
+ const condition = transpile(ctx, node.test) as tinyest.Expression;
+ const body = transpile(ctx, node.body) as tinyest.Statement;
+
+ return [NODE.while, condition, body];
+ },
+
+ ForOfStatement(ctx, node, transpile) {
+ ctx.stack.push({ declaredNames: [] });
+
+ const loopVar = transpile(ctx, node.left) as tinyest.Const | tinyest.Let;
+ const iterable = transpile(ctx, node.right) as tinyest.Expression;
+ const body = transpile(ctx, node.body) as tinyest.Statement;
+
+ ctx.stack.pop();
+
+ return [NODE.forOf, loopVar, iterable, body];
+ },
+
+ ContinueStatement() {
+ return [NODE.continue];
+ },
+
+ BreakStatement() {
+ return [NODE.break];
+ },
+} satisfies Pick, SharedTranspilers>;
+
+export function transpileAcornProperty(
+ ctx: Context,
+ node: acorn.Property,
+ transpile: Transpile,
+): tinyest.ObjectProperty {
+ if (node.computed) {
+ const key = transpile(ctx, node.key) as tinyest.Expression;
+ const value = transpile(ctx, node.value) as tinyest.Expression;
+
+ return [key, value, true];
+ }
+
+ if (
+ (node.key.type !== 'Identifier' && node.key.type !== 'Literal') ||
+ (node.key.type === 'Literal' && (node.key.raw === null || node.key.regex))
+ ) {
+ throw new Error(`Unsupported non-computed object property key.`);
+ }
+
+ const key = node.key.type === 'Identifier' ? node.key.name : String(node.key.value);
+ const value = transpile(ctx, node.value) as tinyest.Expression;
+ return [key, value, false];
+}
+
+const acornSpecificTranspilers = {
+ Literal(_ctx, node) {
+ if (node.regex) {
+ throw new Error('Regular expression literals are not representable in WGSL.');
+ }
+ if (node.raw === 'null') {
+ return [NODE.nullLiteral];
+ }
+ if (typeof node.value === 'boolean') {
+ return node.value;
+ }
+ if (typeof node.value === 'string') {
+ return [NODE.stringLiteral, node.value];
+ }
+ if (node.bigint) {
+ console.warn('BigInt literals are represented as numbers - loss of precision may occur.');
+ }
+ return [NODE.numericLiteral, String(Number(node.value))];
+ },
+
+ ObjectExpression(ctx, node, transpile) {
+ const objectProperties = node.properties.map((prop) => {
+ // TODO: Handle SpreadElement
+ if (prop.type === 'SpreadElement') {
+ throw new Error('Spread elements are not supported in TGSL.');
+ }
+
+ // TODO: Handle Object method
+ if (prop.method) {
+ throw new Error('Object method elements are not supported in TGSL.');
+ }
+
+ return transpileAcornProperty(ctx, prop, transpile);
+ });
+
+ if (objectProperties.some((prop) => /* computed */ prop[2])) {
+ return [NODE.objectExpr, objectProperties] as tinyest.ObjectExpression;
+ }
+
+ const obj: Record = {};
+ const seenKeys = new Set();
+
+ for (const prop of objectProperties) {
+ const key = prop[0] as string;
+ if (seenKeys.has(key)) {
+ throw new Error(`Duplicate object property key: '${key}'.`);
+ }
+ seenKeys.add(key);
+ obj[key] = /* value */ prop[1];
+ }
+
+ return [NODE.objectExpr, obj] as tinyest.ObjectExpression;
+ },
+} satisfies Transpilers;
+
+export const acornTranspilers = {
+ ...(baseTranspilers as Pick, SharedTranspilers>),
+ ...acornSpecificTranspilers,
+} satisfies Transpilers;
+
+const tsFallthrough = (
+ ctx: Context,
+ node: { expression: babel.Expression },
+ transpile: Transpile,
+) => {
+ return transpile(ctx, node.expression);
+};
+
+export function transpileBabelObjectProperty(
+ ctx: Context,
+ node: babel.ObjectProperty,
+ transpile: Transpile,
+): tinyest.ObjectProperty {
+ if (node.computed) {
+ const key = transpile(ctx, node.key) as tinyest.Expression;
+ const value = transpile(ctx, node.value) as tinyest.Expression;
+
+ return [key, value, true];
+ }
+
+ let key: string;
+ switch (node.key.type) {
+ case 'Identifier':
+ key = node.key.name;
+ break;
+ case 'StringLiteral':
+ case 'NumericLiteral':
+ case 'BigIntLiteral':
+ key = String(node.key.value);
+ break;
+ default:
+ throw new Error(`Unsupported non-computed object property key.`);
+ }
+
+ const value = transpile(ctx, node.value) as tinyest.Expression;
+ return [key, value, false];
+}
+
+const babelSpecificTranspilers = {
+ NumericLiteral(_ctx, node) {
+ return [NODE.numericLiteral, String(node.value)];
+ },
+
+ BigIntLiteral(_ctx, node) {
+ console.warn('BigInt literals are represented as numbers - loss of precision may occur.');
+ return [NODE.numericLiteral, String(Number(node.value))];
+ },
+
+ BooleanLiteral(_ctx, node) {
+ return node.value;
+ },
+
+ StringLiteral(_ctx, node) {
+ return [NODE.stringLiteral, node.value];
+ },
+
+ NullLiteral() {
+ return [NODE.nullLiteral];
+ },
+
+ ObjectExpression(ctx, node, transpile) {
+ const objectProperties = node.properties.map((prop) => {
+ // TODO: Handle SpreadElement
+ if (prop.type === 'SpreadElement') {
+ throw new Error('Spread elements are not supported in TGSL.');
+ }
+ // TODO: Handle Object method
+ if (prop.type === 'ObjectMethod') {
+ throw new Error('Object method elements are not supported in TGSL.');
+ }
+
+ return transpileBabelObjectProperty(ctx, prop, transpile);
+ });
+
+ if (objectProperties.some((prop) => /* computed */ prop[2])) {
+ return [NODE.objectExpr, objectProperties] as tinyest.ObjectExpression;
+ }
+
+ const obj: Record = {};
+ const seenKeys = new Set();
+
+ for (const prop of objectProperties) {
+ const key = prop[0] as string;
+ if (seenKeys.has(key)) {
+ throw new Error(`Duplicate object property key: '${key}'.`);
+ }
+ seenKeys.add(key);
+ obj[key] = /* value */ prop[1];
+ }
+
+ return [NODE.objectExpr, obj] as tinyest.ObjectExpression;
+ },
+
+ TSAsExpression: tsFallthrough,
+ TSSatisfiesExpression: tsFallthrough,
+ TSNonNullExpression: tsFallthrough,
+} satisfies Transpilers;
+
+export const babelTranspilers = {
+ ...(baseTranspilers as Pick, SharedTranspilers>),
+ ...babelSpecificTranspilers,
+} satisfies Transpilers;
diff --git a/packages/tinyest-for-wgsl/src/types.ts b/packages/tinyest-for-wgsl/src/types.ts
index 5f27600786..331847ccf7 100644
--- a/packages/tinyest-for-wgsl/src/types.ts
+++ b/packages/tinyest-for-wgsl/src/types.ts
@@ -35,3 +35,13 @@ export type TranspilationResult = {
};
export type JsNode = babel.Node | acorn.AnyNode;
+
+export type Transpile = (ctx: Context, node: TNode) => tinyest.AnyNode;
+
+export type Transpilers = Partial<{
+ [Type in TNode['type']]: (
+ ctx: Context,
+ node: Extract,
+ transpile: Transpile,
+ ) => tinyest.AnyNode;
+}>;
diff --git a/packages/tinyest-for-wgsl/tests/helpers.ts b/packages/tinyest-for-wgsl/tests/helpers.ts
index 7bd671e13c..3e074b2a1b 100644
--- a/packages/tinyest-for-wgsl/tests/helpers.ts
+++ b/packages/tinyest-for-wgsl/tests/helpers.ts
@@ -1,14 +1,20 @@
import babel from '@babel/parser';
import type { Node } from '@babel/types';
import * as acorn from 'acorn';
+import { transpileAcornFn, transpileBabelFn, type TranspilationResult } from 'tinyest-for-wgsl';
export const parseRollup = (code: string) => acorn.parse(code, { ecmaVersion: 'latest' });
export const parseBabel = (code: string) =>
babel.parse(code, { sourceType: 'module', plugins: ['typescript'] }).program.body[0] as Node;
-export function dualTest(test: (p: (code: string) => Node | acorn.AnyNode) => void) {
+export function dualTest(
+ test: (
+ p: (code: string) => TNode,
+ transpileFn: (node: TNode) => TranspilationResult,
+ ) => void,
+) {
return () => {
- test(parseBabel);
- test(parseRollup);
+ test(parseBabel, (node) => transpileBabelFn(node));
+ test(parseRollup, (node) => transpileAcornFn(node));
};
}
diff --git a/packages/tinyest-for-wgsl/tests/parsers.test.ts b/packages/tinyest-for-wgsl/tests/parsers.test.ts
index eb1344c27d..9130b31023 100644
--- a/packages/tinyest-for-wgsl/tests/parsers.test.ts
+++ b/packages/tinyest-for-wgsl/tests/parsers.test.ts
@@ -1,13 +1,13 @@
-import type { ClassDeclaration, ClassProperty, Expression, Node } from '@babel/types';
+import type { ClassDeclaration, ClassProperty, Expression } from '@babel/types';
import * as acorn from 'acorn';
import { describe, expect, it } from 'vitest';
-import { transpileFn } from '../src/parsers.ts';
-import { dualTest, parseBabel } from './helpers.ts';
+import { transpileBabelFn, transpileFn } from 'tinyest-for-wgsl';
+import { dualTest, parseBabel, parseRollup } from './helpers.ts';
-describe('transpileFn', () => {
+describe('transpileBabelFn and transpileAcornFn', () => {
it(
'handles weird identifiers',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(
p(`() => {
const a = undefined;
@@ -33,7 +33,7 @@ describe('transpileFn', () => {
it(
'parses null',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(
p(`() => {
const a = null;
@@ -48,14 +48,14 @@ describe('transpileFn', () => {
it(
'fails when the input is not a function',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
expect(() => transpileFn(p('1 + 2'))).toThrow();
}),
);
it(
'parses an empty arrow function',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(p('() => {}'));
expect(params).toStrictEqual([]);
@@ -66,7 +66,7 @@ describe('transpileFn', () => {
it(
'parses an empty named function',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(p('function example() {}'));
expect(params).toStrictEqual([]);
@@ -77,7 +77,7 @@ describe('transpileFn', () => {
it(
'gathers external names',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(p('(a, b) => a + b - c'));
expect(params).toStrictEqual([
@@ -97,7 +97,7 @@ describe('transpileFn', () => {
it(
'respects local declarations when gathering external names',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(
p(`() => {
const a = 0;
@@ -120,7 +120,7 @@ describe('transpileFn', () => {
it(
'respects outer scope when gathering external names',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(
p(`() => {
const a = 0;
@@ -145,7 +145,7 @@ describe('transpileFn', () => {
it(
'treats the object as a possible external value when accessing a member',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, body, externalNames } = transpileFn(p('() => external.outside.prop'));
expect(params).toStrictEqual([]);
@@ -161,7 +161,7 @@ describe('transpileFn', () => {
it(
'handles destructured args',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, externalNames } = transpileFn(
p(`({ pos, a: b }) => {
const x = pos.x;
@@ -190,7 +190,7 @@ describe('transpileFn', () => {
it(
'handles mixed type parameters',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { params, externalNames } = transpileFn(
p(`(y, { pos, a: b }, {c, d}) => {
const x = pos.x;
@@ -235,14 +235,14 @@ describe('transpileFn', () => {
);
it('handles TSNonNullExpression', () => {
- const { body } = transpileFn(parseBabel('() => x!.y'));
+ const { body } = transpileBabelFn(parseBabel('() => x!.y'));
expect(JSON.stringify(body)).toMatchInlineSnapshot(`"[0,[[10,[7,"x","y"]]]]"`);
});
it(
'defines a new scope for variables defined in the head of a `for` loop',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { externalNames } = transpileFn(
p(`() => {
let value = 0;
@@ -264,7 +264,7 @@ describe('transpileFn', () => {
it(
'defines a new scope for the iterator in a `for ... of` loop',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { externalNames } = transpileFn(
p(`() => {
let value = 0;
@@ -286,7 +286,7 @@ describe('transpileFn', () => {
it(
'handles complex external trees',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { externalNames, body } = transpileFn(
p(`() => {
const a = ext.p;
@@ -335,7 +335,7 @@ describe('transpileFn', () => {
it(
'does not duplicate externals',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { externalNames } = transpileFn(
p(`() => {
const a = ext;
@@ -353,7 +353,7 @@ describe('transpileFn', () => {
it(
'does not prune externals when they reappear',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
const { externalNames, body } = transpileFn(
p(`() => {
const a = ext.value;
@@ -379,7 +379,7 @@ describe('transpileFn', () => {
it(
'handles private property access',
- dualTest((p) => {
+ dualTest((p, transpileFn) => {
// `this.#v` is only valid inside a class body, so we parse a class and pluck out the arrow function.
const tree = p(`
class Foo {
@@ -396,7 +396,7 @@ describe('transpileFn', () => {
const lastProp = props.at(-1) as ClassProperty | acorn.PropertyDefinition;
const fn = lastProp.value as Expression | acorn.Expression;
- const { externalNames } = transpileFn(fn);
+ const { externalNames } = transpileFn(fn as Parameters[0]);
expect(externalNames).toMatchInlineSnapshot(`
Map {
@@ -405,4 +405,132 @@ describe('transpileFn', () => {
`);
}),
);
+
+ it(
+ 'parses binary bigints',
+ dualTest((p, transpileFn) => {
+ const { body, externalNames } = transpileFn(p('() => 0b101n'));
+
+ expect(JSON.stringify(body)).toMatchInlineSnapshot(`"[0,[[10,[5,"5"]]]]"`);
+ expect(externalNames).toMatchInlineSnapshot(`Map {}`);
+ }),
+ );
+
+ it(
+ 'parses identifier, string, numeric, and bigint object keys',
+ dualTest((p, transpileFn) => {
+ const { body, externalNames } = transpileFn(
+ p(`() => ({
+ identifier: 1,
+ 'string-key': 2,
+ 1: 3,
+ 2n: 4,
+ })`),
+ );
+
+ expect(JSON.stringify(body)).toMatchInlineSnapshot(
+ `"[0,[[10,[104,{"1":[5,"3"],"2":[5,"4"],"identifier":[5,"1"],"string-key":[5,"2"]}]]]]"`,
+ );
+ expect(externalNames).toMatchInlineSnapshot(`Map {}`);
+ }),
+ );
+
+ it(
+ 'rejects duplicate non-computed object keys',
+ dualTest((p, transpileFn) => {
+ expect(() =>
+ transpileFn(
+ p(`() => ({
+ field: 1,
+ field: 2,
+ })`),
+ ),
+ ).toThrowErrorMatchingInlineSnapshot(`[Error: Duplicate object property key: 'field'.]`);
+ }),
+ );
+
+ it(
+ 'parses computed object keys',
+ dualTest((p, transpileFn) => {
+ const { body, externalNames } = transpileFn(
+ p(`() => ({
+ [id]: 1,
+ [getId()]: 2,
+ })`),
+ );
+
+ expect(JSON.stringify(body)).toMatchInlineSnapshot(
+ `"[0,[[10,[104,[["id",[5,"1"],true],[[6,"getId",[]],[5,"2"],true]]]]]]"`,
+ );
+ expect(externalNames).toMatchInlineSnapshot(`
+ Map {
+ "id" => "id",
+ "getId" => "getId",
+ }
+ `);
+ }),
+ );
+});
+
+describe('legacy transpileFn', () => {
+ it('parsers object expression with identifier and literal keys', () => {
+ const code = `() => ({
+ identifier: 1,
+ 'string key': 2,
+ 3: 4,
+ 5n: 6,
+ });`;
+
+ const babelResult = transpileFn(parseBabel(code));
+ expect(JSON.stringify(babelResult.body)).toMatchInlineSnapshot(
+ `"[0,[[10,[104,{"3":[5,"4"],"5":[5,"6"],"identifier":[5,"1"],"string key":[5,"2"]}]]]]"`,
+ );
+
+ const acornResult = transpileFn(parseRollup(code));
+ expect(JSON.stringify(acornResult.body)).toMatchInlineSnapshot(
+ `"[0,[[10,[104,{"3":[5,"4"],"5":[5,"6"],"identifier":[5,"1"],"string key":[5,"2"]}]]]]"`,
+ );
+ });
+
+ it('parses computed object properties', () => {
+ const code = `() => ({
+ [id]: 1,
+ [getId()]: 2,
+ });`;
+
+ expect(JSON.stringify(transpileFn(parseBabel(code)).body)).toMatchInlineSnapshot(
+ `"[0,[[10,[104,[["id",[5,"1"],true],[[6,"getId",[]],[5,"2"],true]]]]]]"`,
+ );
+ expect(JSON.stringify(transpileFn(parseRollup(code)).body)).toMatchInlineSnapshot(
+ `"[0,[[10,[104,[["id",[5,"1"],true],[[6,"getId",[]],[5,"2"],true]]]]]]"`,
+ );
+ });
+
+ it('rejects spread elements', () => {
+ const code = `() => ({
+ ...obj,
+ });`;
+
+ expect(() => transpileFn(parseBabel(code))).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Spread elements are not supported in TGSL.]`,
+ );
+ expect(() => transpileFn(parseRollup(code))).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Spread elements are not supported in TGSL.]`,
+ );
+ });
+
+ it('rejects object methods', () => {
+ const code = `() => ({
+ foo() {
+ return 1;
+ },
+ });`;
+
+ expect(() => transpileFn(parseBabel(code))).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Object method elements are not supported in TGSL.]`,
+ );
+ expect(() => transpileFn(parseRollup(code))).toThrowErrorMatchingInlineSnapshot(
+ `[Error: Object method elements are not supported in TGSL.]`,
+ );
+ });
});
diff --git a/packages/tinyest/src/nodes.ts b/packages/tinyest/src/nodes.ts
index 942afe4d78..4b855d08c0 100644
--- a/packages/tinyest/src/nodes.ts
+++ b/packages/tinyest/src/nodes.ts
@@ -188,9 +188,13 @@ export type UnaryExpression = readonly [
inner: Expression,
];
+export type ObjectProperty =
+ | readonly [key: string, value: Expression, computed: false]
+ | readonly [key: Expression, value: Expression, computed: true];
+
export type ObjectExpression = readonly [
type: NodeTypeCatalog['objectExpr'],
- Record,
+ props: Record | ObjectProperty[],
];
export type ArrayExpression = readonly [type: NodeTypeCatalog['arrayExpr'], values: Expression[]];
diff --git a/packages/typegpu-cli/package.json b/packages/typegpu-cli/package.json
index cf90d668be..0250cfc180 100644
--- a/packages/typegpu-cli/package.json
+++ b/packages/typegpu-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@typegpu/cli",
- "version": "0.12.2",
+ "version": "0.12.3",
"description": "Add TypeGPU to your project",
"license": "MIT",
"repository": {
diff --git a/packages/typegpu-cli/templates/template-nextjs-bare/next.config.ts b/packages/typegpu-cli/templates/template-nextjs-bare/next.config.ts
index 9c1882429e..b2dde3c317 100644
--- a/packages/typegpu-cli/templates/template-nextjs-bare/next.config.ts
+++ b/packages/typegpu-cli/templates/template-nextjs-bare/next.config.ts
@@ -1,7 +1,6 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
- output: 'export',
distDir: './dist',
};
diff --git a/packages/typegpu-gl/package.json b/packages/typegpu-gl/package.json
index 29db952b32..da59458f97 100644
--- a/packages/typegpu-gl/package.json
+++ b/packages/typegpu-gl/package.json
@@ -1,6 +1,6 @@
{
"name": "@typegpu/gl",
- "version": "0.12.3",
+ "version": "0.12.4",
"description": "WebGL and GLSL utilities for TypeGPU",
"keywords": [],
"license": "MIT",
diff --git a/packages/typegpu-gl/src/glslGenerator.ts b/packages/typegpu-gl/src/glslGenerator.ts
index deb15ac312..493becede8 100644
--- a/packages/typegpu-gl/src/glslGenerator.ts
+++ b/packages/typegpu-gl/src/glslGenerator.ts
@@ -1,10 +1,11 @@
import { NodeTypeCatalog as NODE } from 'tinyest';
-import type { Expression, Return } from 'tinyest';
+import type { Expression, Return, ObjectExpression, ObjectProperty } from 'tinyest';
import { tgpu, d, type ShaderStage, std } from 'typegpu';
import {
abstractInt,
getName,
snip,
+ stringifyObjectProperty,
UnknownData,
WgslGenerator,
withValue,
@@ -598,6 +599,16 @@ export class GlslGenerator extends WgslGenerator {
templateParams: readonly Snippet[],
args: readonly Snippet[],
): string {
+ if (name === 'inverseSqrt') {
+ return super.emitCall('inversesqrt', templateParams, args);
+ }
+ if (name === 'dpdx') {
+ return super.emitCall('dFdx', templateParams, args);
+ }
+ if (name === 'dpdy') {
+ return super.emitCall('dFdy', templateParams, args);
+ }
+
if (name === 'textureSample' || name === 'textureSampleBias' || name === 'textureSampleLevel') {
const [texture, sampler, coords, ...rest] = this.#normalizeTextureArrayArguments(args);
if (!texture || !sampler || !coords) {
@@ -945,11 +956,7 @@ export class GlslGenerator extends WgslGenerator {
// Case 1: Object literal return like `return { $position: ..., uv: ... }`.
if (typeof exprNode === 'object' && exprNode[0] === NODE.objectExpr) {
- return this.#handleStructReturn(
- exprNode as unknown as [number, Record],
- expectedReturnType,
- entryFnState,
- );
+ return this.#handleStructReturn(exprNode, expectedReturnType, entryFnState);
}
// Non-literal return: inspect type to decide how to assign.
@@ -1003,10 +1010,30 @@ export class GlslGenerator extends WgslGenerator {
}
#handleStructReturn(
- exprNode: [number, Record],
+ exprNode: ObjectExpression,
expectedReturnType: d.BaseData | undefined,
entryFnState: EntryFnState,
): string {
+ // Normalize to `objectProperty[]`
+ const properties = Array.isArray(exprNode[1])
+ ? exprNode[1]
+ : Object.entries(exprNode[1]).map(
+ ([key, value]) => [key, value, false] satisfies ObjectProperty,
+ );
+
+ const seenKeys = new Map();
+ const resolveUniqueKey = (prop: ObjectProperty): string => {
+ const key = this._resolveObjectPropertyKey(prop);
+ const dupProp = seenKeys.get(key);
+ if (dupProp) {
+ throw new Error(
+ `Duplicate object property key found: '${stringifyObjectProperty(dupProp)}' and '${stringifyObjectProperty(prop)}'.`,
+ );
+ }
+ seenKeys.set(key, prop);
+ return key;
+ };
+
// Is this an auto-detected output struct? If so, register each prop so the
// output struct's propTypes reflects what the body actually returns.
const isAutoStruct = expectedReturnType?.type === 'auto-struct';
@@ -1020,20 +1047,36 @@ export class GlslGenerator extends WgslGenerator {
// Resolve each RHS first so module-level references get reserved (and types become
// available) before we allocate our LHS output identifiers.
- const resolved = Object.entries(exprNode[1]).map(([prop, rhsNode]) => {
- // oxlint-disable-next-line typescript/no-explicit-any
- const rhsExpr = this._expression(rhsNode as any);
+ const resolved: {
+ prop: string;
+ rhsStr: string;
+ dataType: d.BaseData;
+ }[] = [];
+ for (const prop of properties) {
+ const key = resolveUniqueKey(prop);
+ const rhsNode = prop[1];
+ const rhsExpr = this._expression(rhsNode);
const dataType = rhsExpr.dataType as d.BaseData;
const rhsStr = this.ctx.resolve(rhsExpr.value, dataType).value;
+
// Register the prop on the auto-struct so the caller's completeStruct picks it up.
if (autoStruct) {
- const existing = autoStruct.accessProp(prop);
+ const existing = autoStruct.accessProp(key);
if (!existing) {
- autoStruct.provideProp(prop, dataType);
+ autoStruct.provideProp(key, dataType);
}
}
- return { prop, rhsStr, dataType };
- });
+
+ if (
+ expectedReturnType &&
+ d.isWgslStruct(expectedReturnType) &&
+ expectedReturnType.propTypes[key] === undefined
+ ) {
+ continue;
+ }
+
+ resolved.push({ prop: key, rhsStr, dataType });
+ }
const lines: string[] = [];
for (const { prop, rhsStr, dataType } of resolved) {
diff --git a/packages/typegpu-gl/tests/glslGenerator.test.ts b/packages/typegpu-gl/tests/glslGenerator.test.ts
index 27956bddcb..3d88c3a494 100644
--- a/packages/typegpu-gl/tests/glslGenerator.test.ts
+++ b/packages/typegpu-gl/tests/glslGenerator.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect } from 'vitest';
+import { describe, expect, vi } from 'vitest';
import { tgpu, d, std } from 'typegpu';
import { dualGlOptions, glOptions } from '@typegpu/gl';
import { translateWgslTypeToGlsl } from '../src/glslGenerator.ts';
@@ -117,6 +117,70 @@ describe('GlslGenerator - variable declarations', () => {
});
describe('GlslGenerator - standard function calls', () => {
+ it('translates inverseSqrt() to inversesqrt() for scalars and vectors', () => {
+ const inverseSqrt = tgpu.fn(
+ [d.f32, d.vec3f],
+ d.vec3f,
+ )((scalar, vector) => {
+ 'use gpu';
+ return std.inverseSqrt(vector) * std.inverseSqrt(scalar);
+ });
+
+ expect(tgpu.resolve([inverseSqrt], glOptions())).toMatchInlineSnapshot(`
+ "vec3 inverseSqrt_1(float scalar, vec3 vector) {
+ return (inversesqrt(vector) * inversesqrt(scalar));
+ }"
+ `);
+ });
+
+ it('translates dpdx() to dFdx() for scalars and vectors', () => {
+ const dpdx = tgpu.fn(
+ [d.f32, d.vec3f],
+ d.vec3f,
+ )((scalar, vector) => {
+ 'use gpu';
+ return std.dpdx(vector) * std.dpdx(scalar);
+ });
+
+ expect(tgpu.resolve([dpdx], glOptions())).toMatchInlineSnapshot(`
+ "vec3 dpdx_1(float scalar, vec3 vector) {
+ return (dFdx(vector) * dFdx(scalar));
+ }"
+ `);
+ });
+
+ it('translates dpdy() to dFdy() for scalars and vectors', () => {
+ const dpdy = tgpu.fn(
+ [d.f32, d.vec3f],
+ d.vec3f,
+ )((scalar, vector) => {
+ 'use gpu';
+ return std.dpdy(vector) * std.dpdy(scalar);
+ });
+
+ expect(tgpu.resolve([dpdy], glOptions())).toMatchInlineSnapshot(`
+ "vec3 dpdy_1(float scalar, vec3 vector) {
+ return (dFdy(vector) * dFdy(scalar));
+ }"
+ `);
+ });
+
+ it('preserves fwidth() for scalars and vectors', () => {
+ const fwidth = tgpu.fn(
+ [d.f32, d.vec3f],
+ d.vec3f,
+ )((scalar, vector) => {
+ 'use gpu';
+ return std.fwidth(vector) * std.fwidth(scalar);
+ });
+
+ expect(tgpu.resolve([fwidth], glOptions())).toMatchInlineSnapshot(`
+ "vec3 fwidth_1(float scalar, vec3 vector) {
+ return (fwidth(vector) * fwidth(scalar));
+ }"
+ `);
+ });
+
it('translates textureLoad() to texelFetch()', () => {
const texture = tgpu['~unstable'].rawCodeSnippet('palette', d.texture2d(), 'handle');
@@ -567,4 +631,179 @@ describe('GlslGenerator - entry point generation with JS functions', () => {
- fn*:foo(): User-defined variables cannot start with 'gl_']
`);
});
+
+ it('resolves computed properties in entry point return', () => {
+ const positionKey = 'position' as const;
+ const getUvKey = tgpu.comptime(() => 'uv' as const);
+
+ const vertFn = tgpu.vertexFn({
+ out: {
+ position: d.builtin.position,
+ uv: d.vec2f,
+ },
+ })(() => {
+ 'use gpu';
+ return {
+ [positionKey]: d.vec4f(0, 0, 0, 1),
+ [getUvKey()]: d.vec2f(1, 2),
+ };
+ });
+
+ expect(tgpu.resolve([vertFn], dualGlOptions().vertex)).toMatchInlineSnapshot(`
+ "out vec2 vary_uv;
+
+ void main() {
+ {
+ gl_Position = vec4(0, 0, 0, 1);
+ vary_uv = vec2(1, 2);
+ return;
+ }
+ }"
+ `);
+ });
+
+ it('evaluates object properties in the order they are written in entry point return', () => {
+ using consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ const fieldX = tgpu.comptime(() => {
+ console.log('fieldX');
+ return 6;
+ });
+ const fieldY = tgpu.comptime(() => {
+ console.log('fieldY');
+ return 7;
+ });
+
+ const vertFn = tgpu.vertexFn({
+ out: {
+ position: d.builtin.position,
+ x: d.u32,
+ y: d.u32,
+ },
+ })(() => {
+ 'use gpu';
+ return {
+ position: d.vec4f(),
+ y: d.u32(fieldY()),
+ x: d.u32(fieldX()),
+ };
+ });
+
+ void tgpu.resolve([vertFn], dualGlOptions().vertex);
+
+ expect(consoleLogSpy.mock.calls).toEqual([['fieldY'], ['fieldX']]);
+ });
+
+ it('evaluates extra properties in entry point return before stripping them', () => {
+ using consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ const fieldX = tgpu.comptime(() => {
+ console.log('fieldX');
+ return 6;
+ });
+ const extraKey = tgpu.comptime(() => {
+ console.log('extraKey');
+ return 'extra' as const;
+ });
+ const extraField = tgpu.comptime(() => {
+ console.log('extraField');
+ return 8;
+ });
+ const fieldY = tgpu.comptime(() => {
+ console.log('fieldY');
+ return 7;
+ });
+
+ const vertFn = tgpu.vertexFn({
+ out: {
+ position: d.builtin.position,
+ x: d.u32,
+ y: d.u32,
+ },
+ })(() => {
+ 'use gpu';
+ return {
+ position: d.vec4f(),
+ x: d.u32(fieldX()),
+ [extraKey()]: d.u32(extraField()),
+ y: d.u32(fieldY()),
+ };
+ });
+
+ const result = tgpu.resolve([vertFn], dualGlOptions().vertex);
+
+ expect(result).not.toContain('extra');
+ expect(consoleLogSpy.mock.calls).toEqual([
+ ['fieldX'],
+ ['extraKey'],
+ ['extraField'],
+ ['fieldY'],
+ ]);
+ });
+
+ it('preserves JS evaluation order in entry point return', () => {
+ using consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ const key1 = tgpu.comptime(() => {
+ console.log('key1');
+ return 'x' as const;
+ });
+ const key2 = tgpu.comptime(() => {
+ console.log('key2');
+ return 'y' as const;
+ });
+ const field1 = tgpu.comptime(() => {
+ console.log('field1');
+ return 6;
+ });
+ const field2 = tgpu.comptime(() => {
+ console.log('field2');
+ return 7;
+ });
+
+ const vertFn = tgpu.vertexFn({
+ out: {
+ position: d.builtin.position,
+ x: d.u32,
+ y: d.u32,
+ },
+ })(() => {
+ 'use gpu';
+ return {
+ position: d.vec4f(),
+ [key1()]: d.u32(field1()),
+ [key2()]: d.u32(field2()),
+ };
+ });
+
+ void tgpu.resolve([vertFn], dualGlOptions().vertex);
+
+ expect(consoleLogSpy.mock.calls).toEqual([['key1'], ['field1'], ['key2'], ['field2']]);
+ });
+
+ it('rejects duplicate keys in entry point return', () => {
+ const getKey = tgpu.comptime(() => 'uv' as const);
+
+ const vertFn = tgpu.vertexFn({
+ out: {
+ position: d.builtin.position,
+ uv: d.vec2f,
+ },
+ })(() => {
+ 'use gpu';
+ return {
+ position: d.vec4f(),
+ uv: d.vec2f(1, 2),
+ // @ts-ignore
+ [getKey()]: d.vec2f(3, 4),
+ };
+ });
+
+ expect(() => tgpu.resolve([vertFn], dualGlOptions().vertex))
+ .toThrowErrorMatchingInlineSnapshot(`
+ [Error: Resolution of the following tree failed:
+ -
+ - vertexFn:vertFn: Duplicate object property key found: 'uv: d.vec2f(1, 2)' and '[getKey()]: d.vec2f(3, 4)'.]
+ `);
+ });
});
diff --git a/packages/typegpu-testing-utility/src/extendedIt.ts b/packages/typegpu-testing-utility/src/extendedIt.ts
index ebf60d52a9..e31ebda2d4 100644
--- a/packages/typegpu-testing-utility/src/extendedIt.ts
+++ b/packages/typegpu-testing-utility/src/extendedIt.ts
@@ -39,6 +39,7 @@ export const it = base
setVertexBuffer: vi.fn(),
setIndexBuffer: vi.fn(),
setStencilReference: vi.fn(),
+ setImmediates: vi.fn(),
executeBundles: vi.fn(),
};
@@ -46,20 +47,29 @@ export const it = base
mock: typeof mockRenderPassEncoder;
};
})
- .extend('commandEncoder', ({ renderPassEncoder }) => {
+ .extend('computePassEncoder', () => {
const mockComputePassEncoder = {
+ get mock() {
+ return mockComputePassEncoder;
+ },
dispatchWorkgroups: vi.fn(),
dispatchWorkgroupsIndirect: vi.fn(),
end: vi.fn(),
setBindGroup: vi.fn(),
setPipeline: vi.fn(),
+ setImmediates: vi.fn(),
};
+ return mockComputePassEncoder as unknown as GPUComputePassEncoder & {
+ mock: typeof mockComputePassEncoder;
+ };
+ })
+ .extend('commandEncoder', ({ renderPassEncoder, computePassEncoder }) => {
const mockCommandEncoder = {
get mock() {
return mockCommandEncoder;
},
- beginComputePass: vi.fn(() => mockComputePassEncoder),
+ beginComputePass: vi.fn(() => computePassEncoder),
beginRenderPass: vi.fn(() => renderPassEncoder),
clearBuffer: vi.fn(),
copyBufferToBuffer: vi.fn(),
@@ -83,6 +93,7 @@ export const it = base
setPipeline: vi.fn(),
setVertexBuffer: vi.fn(),
setIndexBuffer: vi.fn(),
+ setImmediates: vi.fn(),
finish: vi.fn(() => 'mockRenderBundle'),
label: '',
};
@@ -97,7 +108,7 @@ export const it = base
return mockDevice;
},
features: new Set(['timestamp-query']),
- createBindGroup: vi.fn((_descriptor: GPUBindGroupDescriptor) => 'mockBindGroup'),
+ createBindGroup: vi.fn(({ label }: GPUBindGroupDescriptor) => ({ label })),
createBindGroupLayout: vi.fn(
(_descriptor: GPUBindGroupLayoutDescriptor) => 'mockBindGroupLayout',
),
@@ -210,6 +221,7 @@ export const it = base
__brand: 'GPU',
requestAdapter: vi.fn(() => Promise.resolve(adapter)),
getPreferredCanvasFormat: vi.fn(() => 'bgra8unorm'),
+ wgslLanguageFeatures: new Set(['immediate_address_space']),
},
mediaDevices: {
getUserMedia: vi.fn(() => Promise.resolve()),
diff --git a/packages/typegpu/bin.mjs b/packages/typegpu/bin.mjs
index 9dbf585bf4..6293da0c53 100755
--- a/packages/typegpu/bin.mjs
+++ b/packages/typegpu/bin.mjs
@@ -1,79 +1,51 @@
#!/usr/bin/env node
-import { spawn } from 'node:child_process';
+import { execFile, spawn } from 'node:child_process';
+import { promisify } from 'node:util';
import pkg from './package.json' with { type: 'json' };
-/**
- * Used to extract the version of `typegpu` that was used to
- * trigger the CLI, which then allows us to download the latest
- * version matching the major and minor of the `typegpu` package.
- */
-const versionPattern = /^(\d+)\.(\d+)\.(\d+)/;
-
-const result = versionPattern.exec(pkg.version);
-const [_, major, minor] = result;
+const [major, minor] = pkg.version.split('.');
+const semver = `^${major}.${minor}.0`;
-if (major === undefined || minor === undefined) {
- throw new Error(`TypeGPU version doesn't match the expected major.minor.patch format`);
+const windows = process.platform === 'win32';
+const npm = windows ? 'npm.cmd' : 'npm';
+const npx = windows ? 'npx.cmd' : 'npx';
+const execFileAsync = promisify(execFile);
+
+/** True only when the registry confirms no `@typegpu/cli` satisfies `semver` */
+async function noMatchingCli() {
+ const args = ['view', `@typegpu/cli@${semver}`, 'version', '--json'];
+ try {
+ const { stdout } = await execFileAsync(npm, args, { shell: windows, timeout: 3_000 });
+ return ['', '[]'].includes(stdout.trim());
+ } catch (err) {
+ return /E404|ETARGET/.test(err.stderr ?? '');
+ }
}
-/**
- * Targeting the latest version with the same major and minor as `typegpu`
- */
-const semver = `^${major}.${minor}.0`;
-
-/**
- * @returns {Promise}
- */
-function asyncSpawn(...args) {
+/** Resolves with the child's exit code, re-raising a fatal signal on this process */
+function run(command, args) {
return new Promise((resolve, reject) => {
- const child = spawn(...args);
-
- child.on('exit', (code, signal) => {
- if (signal) {
- process.kill(process.pid, signal);
- process.exit(0);
- return;
- }
-
- resolve(code);
- });
-
- child.on('error', (err) => {
- reject(err);
- });
+ spawn(command, args, { stdio: 'inherit', shell: windows })
+ .on('error', reject)
+ .on('exit', (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ } else {
+ resolve(code ?? 1);
+ }
+ });
});
}
-/**
- * @param {string} label
- */
-function failedToRunErrHandler(label) {
- return (err) => {
- console.error(`Failed to run '${label}':`, err);
- process.exit(1);
- };
+const fallback = await noMatchingCli();
+if (fallback) {
+ console.warn(`Couldn't find @typegpu/cli version matching ${semver}, falling back to latest...`);
}
+const spec = fallback ? '@typegpu/cli@latest' : `@typegpu/cli@${semver}`;
-(async () => {
- const windows = process.platform === 'win32';
- const npxCommand = windows ? 'npx.cmd' : 'npx';
-
- const code = await asyncSpawn(npxCommand, [`@typegpu/cli@${semver}`, ...process.argv.slice(2)], {
- stdio: 'inherit',
- shell: windows, // needs to be ran through the shell on Windows
- }).catch(failedToRunErrHandler(`npx @typegpu/cli@${semver}`));
-
- if (code !== 0) {
- console.warn(
- `Couldn't find @typegpu/cli version matching ${semver}, falling back to latest...`,
- );
- // Fallback to latest
- const code = await asyncSpawn(npxCommand, [`@typegpu/cli@latest`, ...process.argv.slice(2)], {
- stdio: 'inherit',
- shell: windows, // needs to be ran through the shell on Windows
- }).catch(failedToRunErrHandler('npx @typegpu/cli@latest'));
- process.exit(code ?? 0);
- }
-
- process.exit(code ?? 0);
-})();
+try {
+ process.exit(await run(npx, [spec, ...process.argv.slice(2)]));
+} catch (err) {
+ console.error(`Failed to run 'npx ${spec}':`, err);
+ process.exit(2);
+}
diff --git a/packages/typegpu/package.json b/packages/typegpu/package.json
index 87b68e4400..499f77b23e 100644
--- a/packages/typegpu/package.json
+++ b/packages/typegpu/package.json
@@ -1,6 +1,6 @@
{
"name": "typegpu",
- "version": "0.12.4",
+ "version": "0.12.5",
"description": "A thin layer between JS and WebGPU/WGSL that improves development experience and allows for faster iteration.",
"keywords": [
"compute",
diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts
index 2d91d8ab4f..3871ba4ad5 100644
--- a/packages/typegpu/src/core/commandEncoder/computePass.ts
+++ b/packages/typegpu/src/core/commandEncoder/computePass.ts
@@ -1,5 +1,6 @@
import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts';
import type { AnyWgslData } from '../../data/wgslTypes.ts';
+import type { InferInput } from '../../shared/repr.ts';
import { $internal } from '../../shared/symbols.ts';
import type {
TgpuBindGroup,
@@ -8,12 +9,13 @@ import type {
} from '../../tgpuBindGroupLayout.ts';
import { isGPUBuffer } from '../../types.ts';
import type { IndirectFlag, TgpuBuffer } from '../buffer/buffer.ts';
+import type { TgpuImmediateVar } from '../immediate/immediateVar.ts';
import { DISPATCH_INDIRECT_SIZE, resolveIndirectOffset } from '../pipeline/pipelineUtils.ts';
import {
ComputeDrawState,
emitComputeDispatch,
recordBindGroup,
- stampComputePipeline,
+ selectPipeline,
} from '../pipeline/drawState.ts';
import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts';
import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts';
@@ -65,6 +67,21 @@ export interface TgpuComputePass {
bindGroup: TgpuBindGroup | GPUBindGroup,
): void;
+ /**
+ * Provides a value for the given immediate variable, used by subsequent dispatches.
+ * The value is captured (copied) at call time; mutating it afterwards has no
+ * effect until it is set again. Takes precedence over values held by the
+ * pipeline (`pipeline.with(immediate, value)`) and persists across pipeline
+ * switches until set again, like any other pass-level state.
+ *
+ * Passing an `ArrayBuffer` or typed array skips serialization entirely; the bytes
+ * are copied verbatim and the caller guarantees they match the schema's layout.
+ */
+ setImmediates(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): void;
+
dispatchWorkgroups(x: number, y?: number, z?: number): void;
/**
@@ -146,7 +163,7 @@ class TgpuComputePassImpl implements TgpuComputePass {
}
setPipeline(pipeline: TgpuComputePipeline): void {
- stampComputePipeline(this[$internal].state, pipeline);
+ selectPipeline(this[$internal].state, pipeline);
}
setBindGroup>(
@@ -156,6 +173,13 @@ class TgpuComputePassImpl implements TgpuComputePass {
recordBindGroup(this[$internal].state, first as TgpuBindGroup | TgpuBindGroupLayout, bindGroup);
}
+ setImmediates(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): void {
+ this[$internal].state.immediates.set(immediate, value);
+ }
+
dispatchWorkgroups(x: number, y?: number, z?: number): void {
this.#emit((rawPass) => rawPass.dispatchWorkgroups(x, y, z));
}
diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts
index 1cfe258d89..a3e8217566 100644
--- a/packages/typegpu/src/core/commandEncoder/renderPass.ts
+++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts
@@ -1,6 +1,7 @@
import type { Disarray } from '../../data/dataTypes.ts';
import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts';
-import type { BaseData, WgslArray } from '../../data/wgslTypes.ts';
+import type { AnyWgslData, BaseData, WgslArray } from '../../data/wgslTypes.ts';
+import type { InferInput } from '../../shared/repr.ts';
import { $internal } from '../../shared/symbols.ts';
import type {
TgpuBindGroup,
@@ -9,6 +10,7 @@ import type {
} from '../../tgpuBindGroupLayout.ts';
import { isGPUBuffer } from '../../types.ts';
import type { IndexFlag, IndirectFlag, TgpuBuffer, VertexFlag } from '../buffer/buffer.ts';
+import type { TgpuImmediateVar } from '../immediate/immediateVar.ts';
import {
DRAW_INDEXED_INDIRECT_SIZE,
DRAW_INDIRECT_SIZE,
@@ -19,7 +21,7 @@ import {
emitRenderDraw,
recordBindGroup,
RenderDrawState,
- stampRenderPipeline,
+ selectPipeline,
} from '../pipeline/drawState.ts';
import type { TgpuRenderPipeline } from '../pipeline/renderPipeline.ts';
import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts';
@@ -109,6 +111,21 @@ export interface TgpuRenderCommands {
size?: number,
): void;
+ /**
+ * Provides a value for the given immediate variable, used by subsequent draw calls.
+ * The value is captured (copied) at call time; mutating it afterwards has no
+ * effect until it is set again. Takes precedence over values held by the
+ * pipeline (`pipeline.with(immediate, value)`) and persists across pipeline
+ * switches until set again, like any other pass-level state.
+ *
+ * Passing an `ArrayBuffer` or typed array skips serialization entirely; the bytes
+ * are copied verbatim and the caller guarantees they match the schema's layout.
+ */
+ setImmediates(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): void;
+
draw(
vertexCount: number,
instanceCount?: number,
@@ -189,9 +206,10 @@ export interface TgpuRenderPass extends TgpuRenderCommands {
/**
* Executes previously recorded {@link GPURenderBundle}s as part of this pass.
- * As per the WebGPU spec, this resets the raw pass's pipeline, bind group
- * and vertex/index buffer state. The state tracked by this typed pass is
- * re-applied on the next draw.
+ * As per the WebGPU spec, this resets the raw pass's pipeline, bind group,
+ * vertex/index buffer and immediate data state. The state tracked by this
+ * typed pass (including values set via `setImmediates`) is re-applied on
+ * the next draw.
*/
executeBundles(bundles: Iterable): void;
@@ -378,7 +396,7 @@ class TgpuRenderCommandsImpl<
}
setPipeline(pipeline: TgpuRenderPipeline): void {
- stampRenderPipeline(this[$internal].state, pipeline);
+ selectPipeline(this[$internal].state, pipeline);
}
setBindGroup>(
@@ -410,6 +428,13 @@ class TgpuRenderCommandsImpl<
state.version++;
}
+ setImmediates(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): void {
+ this[$internal].state.immediates.set(immediate, value);
+ }
+
draw(
vertexCount: number,
instanceCount?: number,
@@ -518,6 +543,7 @@ class TgpuRenderPassImpl
const internals = this[$internal];
internals.rawPass.executeBundles(bundles);
internals.appliedVersion = undefined;
+ internals.state.immediates.invalidate();
}
end(): void {
diff --git a/packages/typegpu/src/core/immediate/immediateVar.ts b/packages/typegpu/src/core/immediate/immediateVar.ts
new file mode 100644
index 0000000000..eafe0131c2
--- /dev/null
+++ b/packages/typegpu/src/core/immediate/immediateVar.ts
@@ -0,0 +1,247 @@
+import { writeToArrayBuffer } from '../../data/dataIO.ts';
+import { undecorate } from '../../data/dataTypes.ts';
+import { sizeOf } from '../../data/sizeOf.ts';
+import { snip } from '../../data/snippet.ts';
+import {
+ type AnyWgslData,
+ type BaseData,
+ isBool,
+ isMat,
+ isNumericSchema,
+ isVec,
+ isVecBool,
+ isWgslStruct,
+} from '../../data/wgslTypes.ts';
+import { IllegalVarAccessError, MissingImmediatesError } from '../../errors.ts';
+import { isInsideTgpuFn } from '../../execMode.ts';
+import type { TgpuNamable } from '../../shared/meta.ts';
+import { getName, setName } from '../../shared/meta.ts';
+import type { InferGPU, InferInput } from '../../shared/repr.ts';
+import type { TgpuSoul } from '../../shared/soul.ts';
+import { $gpuValueOf, $internal, $soul } from '../../shared/symbols.ts';
+import { makeDereferenceable } from '../../tgsl/makeDereferenceable.ts';
+import { makeResolvable } from '../../tgsl/makeResolvable.ts';
+
+// ----------
+// Public API
+// ----------
+
+export interface TgpuImmediateVarSoul<
+ TDataType extends BaseData = BaseData,
+> extends TgpuSoul<'immediate-var'> {
+ readonly dataType: TDataType;
+ readonly defaultValue: InferInput | undefined;
+}
+
+export interface TgpuImmediateVar extends TgpuNamable {
+ readonly [$internal]: true;
+ readonly [$soul]: TgpuImmediateVarSoul;
+ readonly resourceType: 'immediate-var';
+ readonly dataType: TDataType;
+ readonly defaultValue: InferInput | undefined;
+ readonly [$gpuValueOf]: InferGPU;
+ readonly $: InferGPU;
+}
+
+/**
+ * Defines a variable in the 'immediate' address space, set from the CPU on a per-draw
+ * (or per-dispatch) basis without the overhead of a buffer.
+ *
+ * Only one immediate variable can be used in a single shader, and its size is limited
+ * by the device's `maxImmediateSize` limit. Requires the `immediate_address_space`
+ * WGSL language extension, check `root.enabledWgslLanguageFeatures` for support.
+ *
+ * @param dataType The schema of the held data's type. Cannot contain arrays, atomics or booleans.
+ * @param defaultValue The value used when no override is provided via `pipeline.with(immediate, value)`
+ * or `pass.setImmediates`. Captured (serialized) at creation time.
+ */
+export function immediateVar(
+ dataType: TDataType,
+ defaultValue?: InferInput,
+): TgpuImmediateVar {
+ assertValidImmediateSchema(dataType);
+ return new TgpuImmediateVarImpl(dataType, defaultValue);
+}
+
+export function isImmediateVar(value: unknown): value is TgpuImmediateVar {
+ return value instanceof TgpuImmediateVarImpl;
+}
+
+// --------------
+// Implementation
+// --------------
+
+function assertValidImmediateSchema(schema: BaseData, rootSchema: BaseData = schema): void {
+ const inner = undecorate(schema);
+
+ if (isWgslStruct(inner)) {
+ for (const propType of Object.values(inner.propTypes)) {
+ assertValidImmediateSchema(propType, rootSchema);
+ }
+ return;
+ }
+ if (isBool(inner) || isVecBool(inner)) {
+ throw new Error(
+ `Invalid schema '${rootSchema.type}' for immediateVar: immediates cannot contain booleans (found '${inner.type}'), use u32 or i32 instead`,
+ );
+ }
+ if (!isNumericSchema(inner) && !isVec(inner) && !isMat(inner)) {
+ throw new Error(
+ `Invalid schema '${rootSchema.type}' for immediateVar: immediates can only hold scalars, vectors, matrices and structs of those (found '${inner.type}')`,
+ );
+ }
+}
+
+/**
+ * Validates that the given immediate variable can be used with the given root.
+ * @returns The size of the immediate data in bytes, to be passed as
+ * `immediateSize` to `device.createPipelineLayout`.
+ */
+export function validateImmediateUsage(
+ immediate: TgpuImmediateVar,
+ root: { readonly enabledWgslLanguageFeatures: ReadonlySet },
+): number {
+ if (!root.enabledWgslLanguageFeatures.has('immediate_address_space')) {
+ throw new Error(
+ `Immediate variable '${
+ getName(immediate) ?? ''
+ }' cannot be used, because the 'immediate_address_space' WGSL language extension is not supported in this environment. Check support with root.enabledWgslLanguageFeatures and fall back to a uniform buffer (e.g. via tgpu.accessor) when unavailable.`,
+ );
+ }
+
+ return sizeOf(immediate.dataType);
+}
+
+/** Captured bytes of an immediate value, immutable once created */
+export type ImmediateSnapshot = Uint8Array;
+
+export type ImmediateSnapshotMap = Map;
+
+const defaultSnapshots = new WeakMap();
+
+export function createImmediateSnapshot(
+ immediate: TgpuImmediateVar,
+ value: unknown,
+): ImmediateSnapshot {
+ const bytes = new Uint8Array(sizeOf(immediate.dataType));
+ writeToArrayBuffer(bytes.buffer, immediate.dataType, value);
+ return bytes;
+}
+
+/** Captures pass overrides and tracks the last snapshot written to its encoder. */
+export class ImmediatePassState {
+ readonly #snapshots: ImmediateSnapshotMap = new Map();
+ #lastWritten: ImmediateSnapshot | undefined;
+
+ set(immediate: TgpuImmediateVar, value: unknown): void {
+ this.#snapshots.set(immediate, createImmediateSnapshot(immediate, value));
+ }
+
+ invalidate(): void {
+ this.#lastWritten = undefined;
+ }
+
+ /** Pass overrides take precedence over pipeline values and the variable's default. */
+ write(
+ pass: GPURenderPassEncoder | GPUComputePassEncoder | GPURenderBundleEncoder,
+ immediate: TgpuImmediateVar,
+ pipelineSnapshots: ReadonlyMap | undefined,
+ deduplicate: boolean,
+ ): void {
+ const snapshot =
+ this.#snapshots.get(immediate) ??
+ pipelineSnapshots?.get(immediate) ??
+ defaultSnapshots.get(immediate);
+
+ if (snapshot === undefined) {
+ throw new MissingImmediatesError(getName(immediate));
+ }
+ if (deduplicate && this.#lastWritten === snapshot) {
+ return;
+ }
+
+ pass.setImmediates(0, snapshot.buffer);
+ this.#lastWritten = snapshot;
+ }
+}
+
+class TgpuImmediateVarImpl implements TgpuImmediateVar {
+ readonly [$soul]: TgpuImmediateVarSoul;
+
+ // prototype properties
+ declare readonly [$internal]: true;
+ declare resourceType: 'immediate-var';
+ declare readonly $: InferGPU;
+ declare readonly [$gpuValueOf]: InferGPU;
+
+ static {
+ const prototype = TgpuImmediateVarImpl.prototype as TgpuImmediateVarImpl;
+
+ prototype.resourceType = 'immediate-var';
+
+ makeDereferenceable(
+ makeResolvable(prototype, {
+ asString() {
+ return `immediateVar:${getName(this) ?? ''}`;
+ },
+ resolve(ctx) {
+ ctx.registerImmediate(this);
+ const id = ctx.makeUniqueIdentifier(getName(this), 'global');
+
+ return ctx.gen.declareGlobalVar({
+ scope: 'immediate',
+ id,
+ dataType: this[$soul].dataType,
+ init: undefined,
+ });
+ },
+ }),
+ {
+ codegenMode: {
+ getBaseSnippet(trackingProxy) {
+ return snip(
+ trackingProxy,
+ this[$soul].dataType,
+ 'immediate',
+ /* possibleSideEffects */ false,
+ );
+ },
+ },
+ normalMode: {
+ get() {
+ throw new IllegalVarAccessError(
+ isInsideTgpuFn()
+ ? `Cannot access immediate variable '${getName(this) ?? ''}'. TypeGPU functions that depend on GPU resources need to be part of a compute dispatch or draw call`
+ : 'Immediate variables are inaccessible during normal JS execution',
+ );
+ },
+ },
+ },
+ );
+ }
+
+ constructor(dataType: TDataType, defaultValue?: InferInput) {
+ this[$soul] = {
+ type: 'immediate-var',
+ dataType,
+ defaultValue,
+ label: undefined,
+ };
+ if (defaultValue !== undefined) {
+ defaultSnapshots.set(this, createImmediateSnapshot(this, defaultValue));
+ }
+ }
+
+ get dataType(): TDataType {
+ return this[$soul].dataType;
+ }
+
+ get defaultValue(): InferInput | undefined {
+ return this[$soul].defaultValue;
+ }
+
+ $name(label: string) {
+ setName(this, label);
+ return this;
+ }
+}
diff --git a/packages/typegpu/src/core/pipeline/computePipeline.ts b/packages/typegpu/src/core/pipeline/computePipeline.ts
index 4695120113..f47d457156 100644
--- a/packages/typegpu/src/core/pipeline/computePipeline.ts
+++ b/packages/typegpu/src/core/pipeline/computePipeline.ts
@@ -6,6 +6,7 @@ import { Void } from '../../data/wgslTypes.ts';
import { resolve } from '../../resolutionCtx.ts';
import type { TgpuNamable } from '../../shared/meta.ts';
import { getName, PERF, setName } from '../../shared/meta.ts';
+import type { InferInput } from '../../shared/repr.ts';
import type { TgpuDeviceOwningSoul } from '../../shared/soul.ts';
import { $getNameForward, $internal, $resolve, $soul } from '../../shared/symbols.ts';
@@ -22,6 +23,13 @@ import {
type TgpuCommandEncoder,
} from '../commandEncoder/commandEncoder.ts';
import { INTERNAL_adoptComputePass, type TgpuComputePass } from '../commandEncoder/computePass.ts';
+import {
+ createImmediateSnapshot,
+ type ImmediateSnapshot,
+ isImmediateVar,
+ type TgpuImmediateVar,
+ validateImmediateUsage,
+} from '../immediate/immediateVar.ts';
import { emitComputeDispatch, finalizeOwnEncoder } from './drawState.ts';
import {
isGPUCommandEncoder,
@@ -76,6 +84,8 @@ export interface TgpuComputePipelineSoul extends TgpuDeviceOwningSoul<
> {
usedBindGroupLayouts?: TgpuBindGroupLayout[] | undefined;
bindGroups?: [TgpuBindGroupLayout, TgpuBindGroup | GPUBindGroup][] | undefined;
+ usedImmediate?: TgpuImmediateVar | undefined;
+ immediates?: [TgpuImmediateVar, ImmediateSnapshot][] | undefined;
timestampWrites?: TimestampWritesPriors['timestampWrites'];
performanceCallback?: TimestampWritesPriors['performanceCallback'];
nonTransferablePriors?: string[] | undefined;
@@ -100,6 +110,19 @@ export interface TgpuComputePipeline extends TgpuNamable, SelfResolvable, Timeab
): this;
with(bindGroupLayout: TgpuBindGroupLayout, bindGroup: GPUBindGroup): this;
with(bindGroup: TgpuBindGroup): this;
+ /**
+ * Provides a value for the given immediate variable, applied on dispatch
+ * like the rest of the pipeline-held state and overridden by
+ * `pass.setImmediates`. The value is captured (copied) at call time;
+ * mutating it afterwards has no effect.
+ *
+ * Passing an `ArrayBuffer` or typed array skips serialization entirely; the bytes
+ * are copied verbatim and the caller guarantees they match the schema's layout.
+ */
+ with(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): this;
/**
* Directs subsequent dispatches into the given compute pass, letting multiple
* pipelines share one pass (and one submission).
@@ -112,6 +135,11 @@ export interface TgpuComputePipeline extends TgpuNamable, SelfResolvable, Timeab
with(encoder: TgpuCommandEncoder): this;
with(encoder: GPUCommandEncoder): this;
with(pass: GPUComputePassEncoder): this;
+ /**
+ * Applies a transform to this pipeline, letting packages hand out reusable
+ * configuration steps, e.g. `pipeline.pipe(cache.inject())`.
+ */
+ pipe(transform: (pipeline: this) => T): T;
dispatchWorkgroups(x: number, y?: number, z?: number): void;
@@ -168,9 +196,11 @@ export function INTERNAL_restoreComputePipeline(
// The catchall group is already one of `bindGroups`, keyed by the layout it was resolved with
catchall: undefined,
logResources: undefined,
+ usedImmediate: soul.usedImmediate,
});
const pipeline: TgpuComputePipeline = new TgpuComputePipelineImpl(core, {
bindGroupLayoutMap: new Map(soul.bindGroups),
+ immediatesMap: new Map(soul.immediates),
});
return restoreTimestampPriors(pipeline, soul);
}
@@ -185,6 +215,7 @@ type TgpuComputePipelinePriors = {
readonly pass?: TgpuComputePass | undefined;
/** An encoder the pipeline records its own passes into, but does not submit */
readonly encoder?: TgpuCommandEncoder | undefined;
+ readonly immediatesMap?: Map | undefined;
} & TimestampWritesPriors;
type Memo = {
@@ -192,6 +223,7 @@ type Memo = {
usedBindGroupLayouts: TgpuBindGroupLayout[];
catchall: [number, TgpuBindGroup] | undefined;
logResources: LogResources | undefined;
+ usedImmediate: TgpuImmediateVar | undefined;
};
class TgpuComputePipelineImpl implements TgpuComputePipeline {
@@ -222,6 +254,8 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline {
memo.catchall,
priors.bindGroupLayoutMap,
);
+ soul.usedImmediate = memo.usedImmediate;
+ soul.immediates = priors.immediatesMap ? [...priors.immediatesMap] : [];
soul.timestampWrites = priors.timestampWrites;
soul.performanceCallback = priors.performanceCallback;
soul.nonTransferablePriors = nonTransferablePriorsOf(priors);
@@ -252,6 +286,10 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline {
): this;
with(bindGroupLayout: TgpuBindGroupLayout, bindGroup: GPUBindGroup): this;
with(bindGroup: TgpuBindGroup): this;
+ with(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): this;
with(pass: TgpuComputePass): this;
with(encoder: TgpuCommandEncoder): this;
with(encoder: GPUCommandEncoder): this;
@@ -260,11 +298,12 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline {
first:
| TgpuBindGroupLayout
| TgpuBindGroup
+ | TgpuImmediateVar
| TgpuComputePass
| TgpuCommandEncoder
| GPUCommandEncoder
| GPUComputePassEncoder,
- bindGroup?: TgpuBindGroup | GPUBindGroup,
+ resource?: unknown,
): this {
const internals = this[$internal];
@@ -290,10 +329,19 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline {
});
}
+ if (isImmediateVar(first)) {
+ return this.#withPriors({
+ immediatesMap: new Map(internals.priors.immediatesMap).set(
+ first,
+ createImmediateSnapshot(first, resource),
+ ),
+ });
+ }
+
if (isBindGroup(first) || isBindGroupLayout(first)) {
const [layout, group] = isBindGroup(first)
? [first.layout, first]
- : [first, bindGroup as TgpuBindGroup | GPUBindGroup];
+ : [first, resource as TgpuBindGroup | GPUBindGroup];
return this.#withPriors({
bindGroupLayoutMap: new Map([
@@ -306,6 +354,10 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline {
throw new Error('Unsupported value passed into .with()');
}
+ pipe(transform: (pipeline: this) => T): T {
+ return transform(this);
+ }
+
withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this {
const internals = this[$internal];
@@ -455,8 +507,8 @@ class ComputePipelineCore implements SelfResolvable {
if (this.#initAsyncPromise === undefined) {
// the pipeline did not start resolution & compilation
const device = this.root.device;
- const { resolutionResult, module } = this.resolveAndCreateShaderModule();
- const { usedBindGroupLayouts, catchall, logResources } = resolutionResult;
+ const { resolutionResult, module, immediateSize } = this.resolveAndCreateShaderModule();
+ const { usedBindGroupLayouts, catchall, logResources, usedImmediate } = resolutionResult;
this.#initAsyncPromise = device
.createComputePipelineAsync({
@@ -464,11 +516,12 @@ class ComputePipelineCore implements SelfResolvable {
layout: device.createPipelineLayout({
label: `${getName(this) ?? ''} - Pipeline Layout`,
bindGroupLayouts: usedBindGroupLayouts.map((l) => this.root.unwrap(l)),
+ ...(immediateSize !== undefined ? { immediateSize } : {}),
}),
compute: { module },
})
.then((pipeline) => {
- this.#memo = { pipeline, usedBindGroupLayouts, catchall, logResources };
+ this.#memo = { pipeline, usedBindGroupLayouts, catchall, logResources, usedImmediate };
this.#performanceTracker.measureCompile(device);
})
.finally(() => {
@@ -488,8 +541,8 @@ class ComputePipelineCore implements SelfResolvable {
}
const device = this.root.device;
- const { resolutionResult, module } = this.resolveAndCreateShaderModule();
- const { usedBindGroupLayouts, catchall, logResources } = resolutionResult;
+ const { resolutionResult, module, immediateSize } = this.resolveAndCreateShaderModule();
+ const { usedBindGroupLayouts, catchall, logResources, usedImmediate } = resolutionResult;
this.#memo = {
pipeline: device.createComputePipeline({
@@ -497,12 +550,14 @@ class ComputePipelineCore implements SelfResolvable {
layout: device.createPipelineLayout({
label: `${getName(this) ?? ''} - Pipeline Layout`,
bindGroupLayouts: usedBindGroupLayouts.map((l) => this.root.unwrap(l)),
+ ...(immediateSize !== undefined ? { immediateSize } : {}),
}),
compute: { module },
}),
usedBindGroupLayouts,
catchall,
logResources,
+ usedImmediate,
};
this.#performanceTracker.measureCompile(device);
@@ -532,7 +587,7 @@ class ComputePipelineCore implements SelfResolvable {
root: this.root,
}),
);
- const { code, usedBindGroupLayouts, catchall } = resolutionResult;
+ const { code, usedBindGroupLayouts, catchall, usedImmediate } = resolutionResult;
if (catchall !== undefined) {
usedBindGroupLayouts[catchall[0]]?.$name(
@@ -542,11 +597,14 @@ class ComputePipelineCore implements SelfResolvable {
warnIfOverflow(usedBindGroupLayouts, device.limits);
+ const immediateSize =
+ usedImmediate !== undefined ? validateImmediateUsage(usedImmediate, this.root) : undefined;
+
const module = device.createShaderModule({
label: `${getName(this) ?? ''} - Shader`,
code,
});
- return { resolutionResult, module };
+ return { resolutionResult, module, immediateSize };
}
}
diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts
index 7085ac33dd..7664e93013 100644
--- a/packages/typegpu/src/core/pipeline/drawState.ts
+++ b/packages/typegpu/src/core/pipeline/drawState.ts
@@ -14,6 +14,7 @@ import type { IndexFlag, TgpuBuffer, VertexFlag } from '../buffer/buffer.ts';
import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts';
import type { ComputePassInternals } from '../commandEncoder/computePass.ts';
import type { RenderPassInternals } from '../commandEncoder/renderPass.ts';
+import { ImmediatePassState } from '../immediate/immediateVar.ts';
import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts';
import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts';
import type { TgpuComputePipeline } from './computePipeline.ts';
@@ -36,6 +37,7 @@ export interface IndexBufferEntry {
export class RenderDrawState {
readonly bindGroups = new Map();
readonly vertexBuffers = new Map();
+ readonly immediates = new ImmediatePassState();
currentPipeline: TgpuRenderPipeline | undefined;
indexBuffer: IndexBufferEntry | undefined;
stencilReference: GPUStencilValue | undefined;
@@ -48,6 +50,7 @@ export class RenderDrawState {
export class ComputeDrawState {
readonly bindGroups = new Map();
+ readonly immediates = new ImmediatePassState();
currentPipeline: TgpuComputePipeline | undefined;
version = 0;
rawAccessed = false;
@@ -66,41 +69,15 @@ export function recordBindGroup(
state.version++;
}
-/** Writes the pipeline and its bound resources into pass state; later set* calls overwrite them */
-export function stampRenderPipeline(state: RenderDrawState, pipeline: TgpuRenderPipeline): void {
- const { priors } = pipeline[$internal];
- state.currentPipeline = pipeline;
-
- if (priors.bindGroupLayoutMap) {
- for (const [layout, group] of priors.bindGroupLayoutMap) {
- state.bindGroups.set(layout, group);
- }
- }
- if (priors.vertexLayoutMap) {
- for (const [layout, buffer] of priors.vertexLayoutMap) {
- state.vertexBuffers.set(layout, { buffer, offset: undefined, size: undefined });
- }
- }
- if (priors.indexBuffer) {
- state.indexBuffer = priors.indexBuffer;
- }
- if (priors.stencilReference !== undefined) {
- state.stencilReference = priors.stencilReference;
- }
- state.version++;
-}
-
-/** The compute counterpart of {@link stampRenderPipeline} */
-export function stampComputePipeline(state: ComputeDrawState, pipeline: TgpuComputePipeline): void {
- const { priors } = pipeline[$internal];
- state.currentPipeline = pipeline;
-
- if (priors.bindGroupLayoutMap) {
- for (const [layout, group] of priors.bindGroupLayoutMap) {
- state.bindGroups.set(layout, group);
- }
+/** Makes the pipeline current; its held state is resolved at draw time, below what the pass holds */
+export function selectPipeline(
+ state: { currentPipeline: TPipeline | undefined; version: number },
+ pipeline: TPipeline,
+): void {
+ if (state.currentPipeline !== pipeline) {
+ state.currentPipeline = pipeline;
+ state.version++;
}
- state.version++;
}
function applyIndexBuffer(
@@ -177,28 +154,38 @@ function applyRenderPipelineState(
pipeline: TgpuRenderPipeline,
passState: RenderDrawState,
): void {
- const memo = pipeline[$internal].core.unwrap();
+ const { core, priors } = pipeline[$internal];
+ const memo = core.unwrap();
encoder.setPipeline(memo.pipeline);
- applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) =>
- passState.bindGroups.get(layout),
+ applyBindGroups(
+ encoder,
+ root,
+ memo.usedBindGroupLayouts,
+ memo.catchall,
+ (layout) => passState.bindGroups.get(layout) ?? priors.bindGroupLayoutMap?.get(layout),
);
- applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (vertexLayout) =>
- passState.vertexBuffers.get(vertexLayout),
- );
+ applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (vertexLayout) => {
+ const passEntry = passState.vertexBuffers.get(vertexLayout);
+ if (passEntry !== undefined) {
+ return passEntry;
+ }
+ const buffer = priors.vertexLayoutMap?.get(vertexLayout);
+ return buffer !== undefined ? { buffer } : undefined;
+ });
- if (passState.indexBuffer !== undefined) {
- applyIndexBuffer(encoder, root, passState.indexBuffer);
+ const indexBuffer = passState.indexBuffer ?? priors.indexBuffer;
+ if (indexBuffer !== undefined) {
+ applyIndexBuffer(encoder, root, indexBuffer);
}
- if (
- typeof (encoder as GPURenderPassEncoder).setStencilReference === 'function' &&
- passState.stencilReference !== undefined
- ) {
- if (passState.rawAccessed || passState.stencilReference !== passState.appliedStencilReference) {
- (encoder as GPURenderPassEncoder).setStencilReference(passState.stencilReference);
- passState.appliedStencilReference = passState.stencilReference;
+ if (typeof (encoder as GPURenderPassEncoder).setStencilReference === 'function') {
+ const stencilReference = passState.stencilReference ?? priors.stencilReference ?? 0;
+ const dirty = passState.rawAccessed || stencilReference !== passState.appliedStencilReference;
+ if (dirty) {
+ (encoder as GPURenderPassEncoder).setStencilReference(stencilReference);
+ passState.appliedStencilReference = stencilReference;
}
}
}
@@ -209,11 +196,16 @@ function applyComputePipelineState(
pipeline: TgpuComputePipeline,
passState: ComputeDrawState,
): void {
- const memo = pipeline[$internal].core.unwrap();
+ const { core, priors } = pipeline[$internal];
+ const memo = core.unwrap();
encoder.setPipeline(memo.pipeline);
- applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) =>
- passState.bindGroups.get(layout),
+ applyBindGroups(
+ encoder,
+ root,
+ memo.usedBindGroupLayouts,
+ memo.catchall,
+ (layout) => passState.bindGroups.get(layout) ?? priors.bindGroupLayoutMap?.get(layout),
);
}
@@ -328,12 +320,10 @@ export function emitRenderDraw(
const { state, rawPass } = passInternals;
const { core, priors } = pipeline[$internal];
- if (state.currentPipeline !== pipeline) {
- stampRenderPipeline(state, pipeline);
- }
+ selectPipeline(state, pipeline);
if (usesIndexBuffer) {
- requireIndexBuffer(state.indexBuffer);
+ requireIndexBuffer(state.indexBuffer ?? priors.indexBuffer);
}
const memo = core.unwrap();
@@ -353,6 +343,10 @@ export function emitRenderDraw(
passInternals.appliedVersion = state.version;
}
+ if (memo.usedImmediate !== undefined) {
+ state.immediates.write(rawPass, memo.usedImmediate, priors.immediatesMap, !state.rawAccessed);
+ }
+
emit(rawPass);
}
@@ -366,9 +360,7 @@ export function emitComputeDispatch(
const { state, rawPass } = passInternals;
const { core, priors } = pipeline[$internal];
- if (state.currentPipeline !== pipeline) {
- stampComputePipeline(state, pipeline);
- }
+ selectPipeline(state, pipeline);
const memo = core.unwrap();
if (!ownsPass) {
@@ -386,5 +378,9 @@ export function emitComputeDispatch(
passInternals.appliedVersion = state.version;
}
+ if (memo.usedImmediate !== undefined) {
+ state.immediates.write(rawPass, memo.usedImmediate, priors.immediatesMap, !state.rawAccessed);
+ }
+
emit(rawPass);
}
diff --git a/packages/typegpu/src/core/pipeline/priors.ts b/packages/typegpu/src/core/pipeline/priors.ts
index 1a8f94ea6a..8c05c3e704 100644
--- a/packages/typegpu/src/core/pipeline/priors.ts
+++ b/packages/typegpu/src/core/pipeline/priors.ts
@@ -1,6 +1,7 @@
// GPU objects like query sets cross runtimes as shareable host objects, performanceCallback crosses only if it is a worklet
const TRANSFERABLE_PRIORS = new Set([
'bindGroupLayoutMap',
+ 'immediatesMap',
'vertexLayoutMap',
'indexBuffer',
'stencilReference',
diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts
index 1726b26159..f58f6bcfff 100644
--- a/packages/typegpu/src/core/pipeline/renderPipeline.ts
+++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts
@@ -8,6 +8,7 @@ import { type ResolvedSnippet, snip } from '../../data/snippet.ts';
import { formatToWGSLType } from '../../data/vertexFormatData.ts';
import {
type AnyVecInstance,
+ type AnyWgslData,
type BaseData,
isWgslData,
type U16,
@@ -21,6 +22,7 @@ import { invariant } from '../../errors.ts';
import { resolve } from '../../resolutionCtx.ts';
import type { TgpuNamable } from '../../shared/meta.ts';
import { getName, PERF, setName } from '../../shared/meta.ts';
+import type { InferInput } from '../../shared/repr.ts';
import type { TgpuDeviceOwningSoul } from '../../shared/soul.ts';
import { $getNameForward, $internal, $resolve, $soul } from '../../shared/symbols.ts';
import type { AnyVertexAttribs, TgpuVertexAttrib } from '../../shared/vertexFormat.ts';
@@ -59,6 +61,13 @@ import {
type TgpuCommandEncoder,
} from '../commandEncoder/commandEncoder.ts';
import type { ColorAttachment, DepthStencilAttachment } from '../commandEncoder/attachments.ts';
+import {
+ createImmediateSnapshot,
+ type ImmediateSnapshot,
+ isImmediateVar,
+ type TgpuImmediateVar,
+ validateImmediateUsage,
+} from '../immediate/immediateVar.ts';
import {
INTERNAL_adoptRenderCommands,
type TgpuRenderCommands,
@@ -112,6 +121,8 @@ export interface TgpuRenderPipelineSoul extends TgpuDeviceOwningSoul<
usedVertexLayouts?: TgpuVertexLayout[] | undefined;
fragmentOut?: BaseData | undefined;
bindGroups?: [TgpuBindGroupLayout, TgpuBindGroup | GPUBindGroup][] | undefined;
+ usedImmediate?: TgpuImmediateVar | undefined;
+ immediates?: [TgpuImmediateVar, ImmediateSnapshot][] | undefined;
vertexBuffers?: [TgpuVertexLayout, (TgpuBuffer & VertexFlag) | GPUBuffer][] | undefined;
indexBuffer?:
| {
@@ -184,6 +195,19 @@ export interface TgpuRenderPipeline
): this;
with(bindGroupLayout: TgpuBindGroupLayout, bindGroup: GPUBindGroup): this;
with(bindGroup: TgpuBindGroup): this;
+ /**
+ * Provides a value for the given immediate variable, applied on draw like
+ * the rest of the pipeline-held state and overridden by `pass.setImmediates`.
+ * The value is captured (copied) at call time; mutating it afterwards has
+ * no effect.
+ *
+ * Passing an `ArrayBuffer` or typed array skips serialization entirely; the bytes
+ * are copied verbatim and the caller guarantees they match the schema's layout.
+ */
+ with(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): this;
/**
* Directs subsequent draw calls into the given render pass or render bundle
* encoder, letting multiple pipelines share one pass (and one submission).
@@ -197,6 +221,11 @@ export interface TgpuRenderPipeline
with(encoder: GPUCommandEncoder): this;
with(pass: GPURenderPassEncoder): this;
with(bundleEncoder: GPURenderBundleEncoder): this;
+ /**
+ * Applies a transform to this pipeline, letting packages hand out reusable
+ * configuration steps, e.g. `pipeline.pipe(mesh.inject())`.
+ */
+ pipe(transform: (pipeline: this) => T): T;
/**
* Attaches texture views to the pipeline's targets (outputs).
@@ -371,9 +400,11 @@ export function INTERNAL_restoreRenderPipeline(
logResources: undefined,
usedVertexLayouts: soul.usedVertexLayouts ?? [],
fragmentOut: soul.fragmentOut,
+ usedImmediate: soul.usedImmediate,
});
const pipeline: TgpuRenderPipeline = new TgpuRenderPipelineImpl(core, {
bindGroupLayoutMap: new Map(soul.bindGroups),
+ immediatesMap: new Map(soul.immediates),
vertexLayoutMap: new Map(soul.vertexBuffers),
indexBuffer: soul.indexBuffer,
stencilReference: soul.stencilReference,
@@ -405,6 +436,7 @@ type TgpuRenderPipelinePriors = {
readonly pass?: TgpuRenderCommands | undefined;
/** An encoder the pipeline records its own passes into, but does not submit */
readonly encoder?: TgpuCommandEncoder | undefined;
+ readonly immediatesMap?: Map | undefined;
} & TimestampWritesPriors;
type Memo = {
@@ -414,6 +446,7 @@ type Memo = {
logResources: LogResources | undefined;
usedVertexLayouts: TgpuVertexLayout[];
fragmentOut: BaseData | undefined;
+ usedImmediate: TgpuImmediateVar | undefined;
};
class TgpuRenderPipelineImpl implements TgpuRenderPipeline {
@@ -446,6 +479,8 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline {
memo.catchall,
priors.bindGroupLayoutMap,
);
+ soul.usedImmediate = memo.usedImmediate;
+ soul.immediates = priors.immediatesMap ? [...priors.immediatesMap] : [];
soul.vertexBuffers = collectVertexBufferPairs(
memo.usedVertexLayouts,
priors.vertexLayoutMap,
@@ -492,6 +527,10 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline {
vertexLayout: TgpuVertexLayout,
buffer: GPUBuffer,
): this;
+ with(
+ immediate: TgpuImmediateVar,
+ value: InferInput | ArrayBuffer | ArrayBufferView,
+ ): this;
with(pass: TgpuRenderCommands): this;
with(encoder: TgpuCommandEncoder): this;
with(encoder: GPUCommandEncoder): this;
@@ -502,12 +541,13 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline {
| TgpuVertexLayout
| TgpuBindGroupLayout
| TgpuBindGroup
+ | TgpuImmediateVar
| TgpuRenderCommands
| TgpuCommandEncoder
| GPUCommandEncoder
| GPURenderPassEncoder
| GPURenderBundleEncoder,
- resource?: (TgpuBuffer & VertexFlag) | TgpuBindGroup | GPUBindGroup | GPUBuffer,
+ resource?: unknown,
): this {
const internals = this[$internal];
@@ -555,9 +595,22 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline {
});
}
+ if (isImmediateVar(first)) {
+ return this.#withPriors({
+ immediatesMap: new Map(internals.priors.immediatesMap).set(
+ first,
+ createImmediateSnapshot(first, resource),
+ ),
+ });
+ }
+
throw new Error('Unsupported value passed into .with()');
}
+ pipe(transform: (pipeline: this) => T): T {
+ return transform(this);
+ }
+
withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this {
const internals = this[$internal];
@@ -870,6 +923,7 @@ class RenderPipelineCore implements SelfResolvable {
logResources,
usedVertexLayouts: connectedAttribs.usedVertexLayouts,
fragmentOut,
+ usedImmediate: resolutionResult.usedImmediate,
};
this.#performanceTracker.measureCompile(device);
})
@@ -901,6 +955,7 @@ class RenderPipelineCore implements SelfResolvable {
logResources,
usedVertexLayouts: connectedAttribs.usedVertexLayouts,
fragmentOut,
+ usedImmediate: resolutionResult.usedImmediate,
};
this.#performanceTracker.measureCompile(device);
@@ -933,7 +988,7 @@ class RenderPipelineCore implements SelfResolvable {
}),
);
- const { code, usedBindGroupLayouts, catchall } = resolutionResult;
+ const { code, usedBindGroupLayouts, catchall, usedImmediate } = resolutionResult;
if (catchall !== undefined) {
usedBindGroupLayouts[catchall[0]]?.$name(
@@ -943,6 +998,9 @@ class RenderPipelineCore implements SelfResolvable {
warnIfOverflow(usedBindGroupLayouts, device.limits);
+ const immediateSize =
+ usedImmediate !== undefined ? validateImmediateUsage(usedImmediate, root) : undefined;
+
const module = device.createShaderModule({
label: `${getName(this) ?? ''} - Shader`,
code,
@@ -966,6 +1024,7 @@ class RenderPipelineCore implements SelfResolvable {
layout: device.createPipelineLayout({
label: `${getName(this) ?? ''} - Pipeline Layout`,
bindGroupLayouts: usedBindGroupLayouts.map((l) => root.unwrap(l)),
+ ...(immediateSize !== undefined ? { immediateSize } : {}),
}),
vertex: {
module,
diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts
index f1e96e59ed..1756bc5ba0 100644
--- a/packages/typegpu/src/core/root/init.ts
+++ b/packages/typegpu/src/core/root/init.ts
@@ -406,6 +406,10 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu
return this[$soul].minify;
}
+ get enabledWgslLanguageFeatures(): ReadonlySet {
+ return navigator.gpu?.wgslLanguageFeatures ?? new Set();
+ }
+
createBuffer(
typeSchema: TData,
initialOrBuffer?: BufferInitialData | GPUBuffer,
diff --git a/packages/typegpu/src/core/root/rootTypes.ts b/packages/typegpu/src/core/root/rootTypes.ts
index 3d6c044608..3ae5646f85 100644
--- a/packages/typegpu/src/core/root/rootTypes.ts
+++ b/packages/typegpu/src/core/root/rootTypes.ts
@@ -713,6 +713,18 @@ export interface TgpuRoot extends Unwrapper, WithBinding {
*/
get enabledFeatures(): ReadonlySet;
+ /**
+ * Retrieves a read-only list of WGSL language extensions supported in the
+ * current environment (`navigator.gpu.wgslLanguageFeatures`).
+ * Returns an empty set when WebGPU is unavailable.
+ *
+ * @example
+ * ```ts
+ * const canUseImmediates = root.enabledWgslLanguageFeatures.has('immediate_address_space');
+ * ```
+ */
+ get enabledWgslLanguageFeatures(): ReadonlySet;
+
/**
* Destroys all underlying resources (i.e. buffers...) created through this root object.
* If the object is created via `tgpu.init` instead of `tgpu.initFromDevice`,
diff --git a/packages/typegpu/src/core/slot/slotTypes.ts b/packages/typegpu/src/core/slot/slotTypes.ts
index 9032c27b07..152234c320 100644
--- a/packages/typegpu/src/core/slot/slotTypes.ts
+++ b/packages/typegpu/src/core/slot/slotTypes.ts
@@ -7,6 +7,7 @@ import { $gpuValueOf, $internal, $providing, $soul } from '../../shared/symbols.
import type { UnwrapRuntimeConstructor } from '../../tgpuBindGroupLayout.ts';
import type { TgpuBufferBinding } from '../buffer/bufferBinding.ts';
import type { TgpuConst } from '../constant/tgpuConstant.ts';
+import type { TgpuImmediateVar } from '../immediate/immediateVar.ts';
import type { Withable } from '../root/rootTypes.ts';
import type { TgpuTextureView } from '../texture/texture.ts';
import type { TgpuVar, VariableScope } from '../variable/tgpuVariable.ts';
@@ -74,6 +75,7 @@ type DataAccessorIn =
| TgpuBufferBinding
| TgpuVar
| TgpuConst
+ | TgpuImmediateVar
| Infer;
type TextureAccessorIn =
diff --git a/packages/typegpu/src/data/generalizeFn.ts b/packages/typegpu/src/data/generalizeFn.ts
index 17d5ef3336..c0f505ff44 100644
--- a/packages/typegpu/src/data/generalizeFn.ts
+++ b/packages/typegpu/src/data/generalizeFn.ts
@@ -12,7 +12,7 @@ import {
type v3b,
type v4b,
} from './wgslTypes.ts';
-import { invariant } from '../errors.ts';
+import { invariant, WgslTypeError } from '../errors.ts';
type Vec = AnyVecInstance; // alias
type Mat = AnyMatInstance; // alias
@@ -117,6 +117,10 @@ export function generalizeFn(fn: (...args: number[]) => num
/**
* Analogous to `generalizeFn`, but the return type is a boolean vector instead.
*/
+export function generalizeBoolFn(
+ fn: (a: boolean) => boolean,
+ args: [T],
+): T;
export function generalizeBoolFn(
fn: (a: number, b: number) => boolean,
args: [T, T],
@@ -132,7 +136,7 @@ export function generalizeBoolFn(
return applyArgs(fn, args, 'boolean') as ToBool;
}
-function kindOf(v: Algebraic): Kind {
+export function kindOf(v: Algebraic): Kind {
if (typeof v === 'number') {
return 'number';
}
@@ -142,11 +146,61 @@ function kindOf(v: Algebraic): Kind {
return v.kind;
}
+// Unless matrix is mentioned in the name, it is not included.
+const i32Kind: Set = new Set(['number', 'vec2i', 'vec3i', 'vec4i']);
+export const u32Kind: Set = new Set(['number', 'vec2u', 'vec3u', 'vec4u']);
+export const f32Kind: Set = new Set(['number', 'vec2f', 'vec3f', 'vec4f']);
+export const f16Kind: Set = new Set(['number', 'vec2h', 'vec3h', 'vec4h']);
+export const matrixKind: Set = new Set(['mat2x2f', 'mat3x3f', 'mat4x4f']);
+export const booleanKind: Set = new Set([
+ 'boolean',
+ 'vec2',
+ 'vec3',
+ 'vec4',
+]);
+export const floatKind: Set = new Set([...f32Kind, ...f16Kind]);
+export const signedKind: Set = new Set([...i32Kind, ...f32Kind, ...f16Kind]);
+export const numericKind: Set = new Set([...signedKind, ...u32Kind]);
+export const numericOrBooleanKind: Set = new Set([...numericKind, ...booleanKind]);
+export const numericOrMatrixKind: Set = new Set([...numericKind, ...matrixKind]);
+export const crossKind: Set = new Set(['vec3f', 'vec3h']);
+
+export function assertKind(
+ v: Algebraic | Algebraic[],
+ valid: Set,
+ excludeScalar: boolean = false,
+) {
+ if (!isVecInstance(v) && Array.isArray(v)) {
+ v.forEach((item) => assertKind(item, valid, excludeScalar));
+ return;
+ }
+ const kind = kindOf(v);
+ if (!valid.has(kind)) {
+ throw new WgslTypeError(
+ `Unsupported signature. Expected one of '${[...valid].join(', ')}', got '${kind}'.`,
+ );
+ }
+ if (excludeScalar && (kind === 'number' || kind === 'boolean')) {
+ throw new WgslTypeError(
+ `Unsupported signature. Expected kind to not be scalar, got '${kind}'.`,
+ );
+ }
+}
+
+export function assertEqualKinds(...values: Algebraic[]) {
+ const kinds = new Set(values.map(kindOf));
+ if (kinds.size !== 1) {
+ throw new WgslTypeError(
+ `Unsupported signature. Expected the following kinds to be equal: '${[...kinds].join(', ')}'.`,
+ );
+ }
+}
+
/**
* If one of the arguments is a vector and other is a number,
* the number is up-cased to a vector.
*/
-export function upCast(
+export function upCast(
args: [T, T],
): [Exclude, Exclude] {
const [lhs, rhs] = args;
diff --git a/packages/typegpu/src/data/snippet.ts b/packages/typegpu/src/data/snippet.ts
index 35db60b5f0..5acd799291 100644
--- a/packages/typegpu/src/data/snippet.ts
+++ b/packages/typegpu/src/data/snippet.ts
@@ -11,6 +11,7 @@ export type Origin =
| 'workgroup' /* defined in the 'workgroup' address space */
| 'private' /* defined in the 'private' address space */
| 'handle' /* defined in the 'handle' address space */
+ | 'immediate' /* defined in the 'immediate' address space */
| 'function' /* defined in a callee, passed down to us as an argument ('function' address space) */
// --- DEFINITIONS
// defined in the current function
@@ -74,6 +75,7 @@ export function isAlias(snippet: Snippet) {
export const originToPtrParams = {
uniform: { space: 'uniform', access: 'read' },
+ immediate: { space: 'immediate', access: 'read' },
readonly: { space: 'storage', access: 'read' },
mutable: { space: 'storage', access: 'read-write' },
workgroup: { space: 'workgroup', access: 'read-write' },
diff --git a/packages/typegpu/src/data/wgslTypes.ts b/packages/typegpu/src/data/wgslTypes.ts
index 69c7ef4eb9..3d77fefc15 100644
--- a/packages/typegpu/src/data/wgslTypes.ts
+++ b/packages/typegpu/src/data/wgslTypes.ts
@@ -1254,7 +1254,14 @@ export interface WgslStruct<
/** @deprecated Just use `WgslStruct` without any type parameters */
export type AnyWgslStruct = WgslStruct;
-export type AddressSpace = 'uniform' | 'storage' | 'workgroup' | 'private' | 'function' | 'handle';
+export type AddressSpace =
+ | 'uniform'
+ | 'storage'
+ | 'workgroup'
+ | 'private'
+ | 'function'
+ | 'handle'
+ | 'immediate';
export type Access = 'read' | 'write' | 'read-write';
export interface Ptr<
diff --git a/packages/typegpu/src/errors.ts b/packages/typegpu/src/errors.ts
index e6f89a91c3..8eda89e5de 100644
--- a/packages/typegpu/src/errors.ts
+++ b/packages/typegpu/src/errors.ts
@@ -173,6 +173,19 @@ export class MissingVertexBuffersError extends Error {
}
}
+export class MissingImmediatesError extends Error {
+ constructor(name: string | undefined) {
+ super(
+ `Missing value for immediate variable '${
+ name ?? ''
+ }'. Please provide it using pipeline.with(immediate, value), pass.setImmediates(immediate, value), or give the variable a default value`,
+ );
+
+ // Set the prototype explicitly.
+ Object.setPrototypeOf(this, MissingImmediatesError.prototype);
+ }
+}
+
export class IllegalVarAccessError extends Error {
constructor(msg: string) {
super(msg);
diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts
index e1a3c243f4..132b8c74ae 100644
--- a/packages/typegpu/src/indexNamedExports.ts
+++ b/packages/typegpu/src/indexNamedExports.ts
@@ -6,6 +6,7 @@ export * as common from './common/index.ts';
export {
MissingBindGroupsError,
+ MissingImmediatesError,
MissingSlotValueError,
MissingVertexBuffersError,
NotUniformError,
@@ -38,6 +39,7 @@ export { isComputePipeline, isPipeline, isRenderPipeline } from './core/pipeline
export { isQuerySet } from './core/querySet/querySet.ts';
export { isVariable } from './core/variable/tgpuVariable.ts';
export { isVertexLayout } from './core/vertexLayout/vertexLayout.ts';
+export { isImmediateVar } from './core/immediate/immediateVar.ts';
export type {
/** @deprecated Import from 'typegpu/~internal' instead */ ShaderGenerator,
} from './tgsl/shaderGenerator.ts';
@@ -129,6 +131,7 @@ export type { RenderFlag, SampledFlag } from './core/texture/usageExtension.ts';
export type { InitFromDeviceOptions, InitOptions } from './core/root/init.ts';
export type { TgpuConst } from './core/constant/tgpuConstant.ts';
export type { TgpuVar, VariableScope } from './core/variable/tgpuVariable.ts';
+export type { TgpuImmediateVar } from './core/immediate/immediateVar.ts';
export type {
TgpuComparisonSampler,
TgpuFixedComparisonSampler,
diff --git a/packages/typegpu/src/internal.ts b/packages/typegpu/src/internal.ts
index e817e56692..0a770cc4e3 100644
--- a/packages/typegpu/src/internal.ts
+++ b/packages/typegpu/src/internal.ts
@@ -7,7 +7,7 @@ export { UnknownData } from './data/dataTypes.ts';
export { getName, setName } from './shared/meta.ts';
export { WgslGenerator } from './tgsl/wgslGenerator.ts';
export { snip, withValue, withDataType, withSideEffects } from './data/snippet.ts';
-export { stringifyNode } from './shared/tseynit.ts';
+export { stringifyNode, stringifyObjectProperty } from './shared/tseynit.ts';
export { dualImpl } from './core/function/dualImpl.ts';
export {
isNonTransferableResource,
diff --git a/packages/typegpu/src/resolutionCtx.ts b/packages/typegpu/src/resolutionCtx.ts
index e8912f05de..3901051b00 100644
--- a/packages/typegpu/src/resolutionCtx.ts
+++ b/packages/typegpu/src/resolutionCtx.ts
@@ -1,3 +1,4 @@
+import type { TgpuImmediateVar } from './core/immediate/immediateVar.ts';
import type { Namespace, NamespaceInternal } from './core/resolve/namespace.ts';
import { ConfigurableImpl } from './core/root/configurableImpl.ts';
import type { Configurable, ExperimentalTgpuRoot } from './core/root/rootTypes.ts';
@@ -454,6 +455,8 @@ export class ResolutionCtxImpl implements ResolutionCtx {
public readonly enableExtensions: WgslEnableExtension[] | undefined;
public expectedType: BaseData | undefined;
+ #usedImmediate: TgpuImmediateVar | undefined;
+
/**
* A counter used to generate unique identifiers for globally-scoped definitions in the 'random' strategy.
*/
@@ -467,6 +470,21 @@ export class ResolutionCtxImpl implements ResolutionCtx {
this.gen.initGenerator(this);
}
+ registerImmediate(immediate: TgpuImmediateVar): void {
+ if (this.#usedImmediate !== undefined && this.#usedImmediate !== immediate) {
+ throw new Error(
+ `Cannot use both immediate variables '${getName(this.#usedImmediate) ?? '