(
+ sceneElements.map((el) => [el.id, el]),
+);
+
+export function updateElementPosition(id: string, position: d.v2f): void {
+ const element = elementById.get(id);
+ if (!element) {
+ console.warn(`Element with id ${id} not found in scene.`);
+ return;
+ }
+
+ element.position = position;
+ if (element.type === 'disk') {
+ sceneData.disks[element.dataIndex].pos = position;
+ } else {
+ sceneData.boxes[element.dataIndex].pos = position;
+ }
+}
+
+export const SceneResult = d.struct({
+ dist: d.f32,
+ color: d.vec3f,
+});
+
+const DiskData = d.struct({
+ pos: d.vec2f,
+ radius: d.f32,
+ emissiveColor: d.vec3f,
+});
+
+const BoxData = d.struct({
+ pos: d.vec2f,
+ size: d.vec2f,
+ emissiveColor: d.vec3f,
+});
+
+export const SceneData = d.struct({
+ disks: d.arrayOf(DiskData, sceneData.disks.length),
+ boxes: d.arrayOf(BoxData, sceneData.boxes.length),
+});
+
+export const sceneDataAccess = tgpu['~unstable'].accessor(SceneData);
+export const sceneSDF = (p: d.v2f) => {
+ 'use gpu';
+ const scene = sceneDataAccess.$;
+
+ let minDist = d.f32(2e31);
+ let color = d.vec3f();
+
+ for (let i = 0; i < scene.disks.length; i++) {
+ const disk = scene.disks[i];
+ const dist = sdf.sdDisk(p.sub(disk.pos), disk.radius);
+
+ if (dist < minDist) {
+ minDist = dist;
+ color = d.vec3f(disk.emissiveColor);
+ }
+ }
+
+ for (let i = 0; i < scene.boxes.length; i++) {
+ const box = scene.boxes[i];
+ const dist = sdf.sdBox2d(p.sub(box.pos), box.size);
+
+ if (dist < minDist) {
+ minDist = dist;
+ color = d.vec3f(box.emissiveColor);
+ }
+ }
+
+ return SceneResult({ dist: minDist, color });
+};
diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-flood/index.html b/apps/typegpu-docs/src/examples/rendering/radiance-flood/index.html
new file mode 100644
index 0000000000..581d6789f8
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/radiance-flood/index.html
@@ -0,0 +1 @@
+
diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-flood/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-flood/index.ts
new file mode 100644
index 0000000000..4d29d6da9a
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/radiance-flood/index.ts
@@ -0,0 +1,319 @@
+import * as rc from '@typegpu/radiance-cascades';
+import * as sdf from '@typegpu/sdf';
+import tgpu from 'typegpu';
+import { fullScreenTriangle } from 'typegpu/common';
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+
+const root = await tgpu.init();
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = canvas.getContext('webgpu') as GPUCanvasContext;
+const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
+
+context.configure({
+ device: root.device,
+ format: presentationFormat,
+});
+
+const [width, height] = [canvas.width / 4, canvas.height / 4];
+const aspect = width / height;
+
+const sceneTexture = root['~unstable'].createTexture({
+ size: [width, height],
+ format: 'rgba8unorm',
+}).$usage('storage', 'sampled');
+
+const sceneWriteView = sceneTexture.createView(
+ d.textureStorage2d('rgba8unorm'),
+);
+const sceneSampledView = sceneTexture.createView();
+
+const DrawParams = d.struct({
+ isDrawing: d.u32,
+ lastMousePos: d.vec2f,
+ mousePos: d.vec2f,
+ brushRadius: d.f32,
+ drawMode: d.u32,
+ lightColor: d.vec3f,
+});
+
+const paramsUniform = root.createUniform(DrawParams, {
+ isDrawing: 0,
+ lastMousePos: d.vec2f(0.5),
+ mousePos: d.vec2f(0.5),
+ brushRadius: 0.05,
+ drawMode: 0,
+ lightColor: d.vec3f(1, 0.9, 0.7),
+});
+
+const drawCompute = root['~unstable'].createGuardedComputePipeline((x, y) => {
+ 'use gpu';
+
+ const params = paramsUniform.$;
+ if (params.isDrawing === d.u32(0)) {
+ return;
+ }
+
+ const aspectF = d.f32(aspect);
+ const dims = std.textureDimensions(sceneWriteView.$);
+ const invDims = d.vec2f(1).div(d.vec2f(dims));
+
+ const uv = d.vec2f(x, y).add(0.5).mul(invDims);
+ const uvA = d.vec2f(uv.x * aspectF, uv.y);
+
+ const mouse = d.vec2f(params.mousePos.x * aspectF, params.mousePos.y);
+
+ const last = d.vec2f(
+ params.lastMousePos.x * aspectF,
+ params.lastMousePos.y,
+ );
+
+ const noLast = std.any(std.lt(params.lastMousePos, d.vec2f()));
+ const a = std.select(last, mouse, noLast);
+
+ const dist = sdf.sdLine(uvA, a, mouse);
+ if (dist >= params.brushRadius) {
+ return;
+ }
+
+ const isLight = params.drawMode !== d.u32(0);
+ const out = std.select(
+ d.vec4f(0, 0, 0, 0.5),
+ d.vec4f(params.lightColor, 1),
+ isLight,
+ );
+
+ std.textureStore(sceneWriteView.$, d.vec2u(x, y), out);
+});
+
+const floodOutputTexture = root['~unstable']
+ .createTexture({
+ size: [width, height],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled') as sdf.DistanceTexture;
+const floodOutputWriteView = floodOutputTexture.createView(
+ d.textureStorage2d('rgba16float', 'write-only'),
+);
+
+const sceneDataLayout = tgpu.bindGroupLayout({
+ sceneRead: { texture: d.texture2d() },
+});
+const sceneDataBG = root.createBindGroup(sceneDataLayout, {
+ sceneRead: sceneSampledView,
+});
+
+const customDistanceWrite = (
+ coord: d.v2u,
+ signedDist: number,
+ insidePx: d.v2u,
+) => {
+ 'use gpu';
+ const size = std.textureDimensions(sceneDataLayout.$.sceneRead);
+ const uv = d.vec2f(insidePx).add(0.5).div(d.vec2f(size));
+
+ const seedData = std.textureSampleLevel(
+ sceneDataLayout.$.sceneRead,
+ linSampler.$,
+ uv,
+ 0,
+ );
+
+ const isLight = seedData.w > 0.75;
+ const outputColor = std.select(d.vec3f(0), seedData.xyz, isLight);
+
+ std.textureStore(
+ floodOutputWriteView.$,
+ d.vec2i(coord),
+ d.vec4f(signedDist, outputColor),
+ );
+};
+
+const floodRunner = sdf
+ .createJumpFlood({
+ root,
+ size: { width, height },
+ output: floodOutputTexture,
+ classify: (coord: d.v2u, size: d.v2u) => {
+ 'use gpu';
+ const sceneData = std.textureSampleLevel(
+ sceneDataLayout.$.sceneRead,
+ linSampler.$,
+ d.vec2f(coord).add(0.5).div(d.vec2f(size)),
+ 0,
+ );
+ return sceneData.w > 0;
+ },
+ distanceWrite: customDistanceWrite,
+ })
+ .with(sceneDataBG);
+
+const res = floodOutputTexture.createView(d.texture2d(d.f32));
+const linSampler = root['~unstable'].createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+});
+
+// Precompute normalization factor to convert pixel distance to UV distance
+const maxDim = Math.max(width, height);
+
+const radianceSceneFn = (uv: d.v2f) => {
+ 'use gpu';
+ // .x = signed distance in pixels, .yzw = propagated light color (0 if wall/empty)
+ const sample = std.textureSampleLevel(res.$, linSampler.$, uv, 0);
+ const sdfDistPx = sample.x;
+ const lightColor = sample.yzw;
+
+ // Convert pixel distance to UV distance (0-1 range) for radiance cascades
+ const sdfDistUv = sdfDistPx / d.f32(maxDim);
+
+ // Light emitters have non-zero color, walls/empty have zero color
+ // The color is already propagated from the nearest seed
+ return rc.SceneData({
+ color: d.vec4f(lightColor, 1),
+ dist: sdfDistUv,
+ });
+};
+
+const radianceRunner = rc.createRadianceCascades({
+ root,
+ scene: radianceSceneFn,
+ size: { width: Math.floor(width), height: Math.floor(height) },
+});
+
+const radianceRes = radianceRunner.output.createView(
+ d.texture2d(),
+);
+
+const displayModeUniform = root.createUniform(d.u32);
+const displayFragment = tgpu['~unstable'].fragmentFn({
+ in: { uv: d.vec2f },
+ out: d.vec4f,
+})(({ uv }) => {
+ 'use gpu';
+ let result = d.vec4f(0);
+ if (displayModeUniform.$ === 0) {
+ result = std.textureSample(
+ radianceRes.$,
+ linSampler.$,
+ uv,
+ );
+ } else {
+ result = d.vec4f(
+ std.textureSample(
+ res.$,
+ linSampler.$,
+ uv,
+ ).xxx,
+ 1,
+ );
+ }
+
+ return d.vec4f(result.xyz, 1.0);
+});
+
+const displayPipeline = root['~unstable']
+ .withVertex(fullScreenTriangle)
+ .withFragment(displayFragment, { format: presentationFormat })
+ .createPipeline();
+
+let isMouseDown = false;
+let lastMousePos = { x: -1, y: -1 };
+canvas.addEventListener('mousemove', (e) => {
+ paramsUniform.writePartial({
+ lastMousePos: d.vec2f(lastMousePos.x, lastMousePos.y),
+ });
+
+ const rect = canvas.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ const y = (e.clientY - rect.top) / rect.height;
+ lastMousePos = { x, y };
+ paramsUniform.writePartial({
+ mousePos: d.vec2f(x, y),
+ });
+});
+
+canvas.addEventListener('mousedown', () => {
+ isMouseDown = true;
+ paramsUniform.writePartial({
+ isDrawing: 1,
+ });
+});
+
+canvas.addEventListener('mouseup', () => {
+ isMouseDown = false;
+ lastMousePos = { x: -1, y: -1 };
+ paramsUniform.writePartial({
+ isDrawing: 0,
+ });
+});
+
+let frameId = 0;
+function frame() {
+ frameId++;
+
+ drawCompute
+ .dispatchThreads(width, height);
+
+ floodRunner.run();
+ radianceRunner.run();
+
+ displayPipeline
+ .withColorAttachment({
+ view: context.getCurrentTexture().createView(),
+ loadOp: 'clear',
+ storeOp: 'store',
+ })
+ .draw(3);
+
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+export const controls = {
+ 'Draw Mode': {
+ initial: 'Walls',
+ options: ['Walls', 'Light'],
+ onSelectChange(value: string) {
+ paramsUniform.writePartial({
+ drawMode: value === 'Walls' ? 0 : 1,
+ });
+ },
+ },
+ 'Light Color': {
+ initial: [1, 0.9, 0.7],
+ onColorChange(rgb: readonly [number, number, number]) {
+ paramsUniform.writePartial({
+ lightColor: d.vec3f(...rgb),
+ });
+ },
+ },
+ 'Brush Size': {
+ initial: 0.05,
+ min: 0.01,
+ max: 0.15,
+ step: 0.01,
+ onSliderChange(value: number) {
+ paramsUniform.writePartial({
+ brushRadius: value,
+ });
+ },
+ },
+ 'Display Mode': {
+ initial: 'Radiance',
+ options: ['Radiance', 'Distance'],
+ onSelectChange(value: string) {
+ displayModeUniform.write(value === 'Radiance' ? 0 : 1);
+ },
+ },
+ Clear: {
+ onButtonClick() {
+ sceneTexture.clear();
+ },
+ },
+};
+
+export function onCleanup() {
+ cancelAnimationFrame(frameId);
+ root.destroy();
+}
diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-flood/meta.json b/apps/typegpu-docs/src/examples/rendering/radiance-flood/meta.json
new file mode 100644
index 0000000000..fdc9947888
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/radiance-flood/meta.json
@@ -0,0 +1,5 @@
+{
+ "title": "Compute Cascades (with flooding)",
+ "category": "rendering",
+ "tags": ["experimental", "3d"]
+}
diff --git a/packages/typegpu-radiance-cascades/README.md b/packages/typegpu-radiance-cascades/README.md
new file mode 100644
index 0000000000..4f9f750ee8
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/README.md
@@ -0,0 +1,37 @@
+
+
+# @typegpu/three
+
+🚧 **Under Construction** 🚧
+
+
+
+A helper library for using TypeGPU with Three.js.
+
+```ts
+import * as TSL from 'three/tsl';
+import * as t3 from '@typegpu/three';
+import { fract } from 'typegpu/std';
+
+const material1 = new THREE.MeshBasicNodeMaterial();
+const pattern = TSL.texture(detailMap, TSL.uv().mul(10));
+// `fromTSL` can be used to access any TSL node from a TypeGPU function
+const patternAccess = t3.fromTSL(pattern, d.vec4f);
+material1.colorNode = t3.toTSL(() => {
+ 'use gpu';
+ return patternAccess.$;
+});
+
+const material2 = new THREE.MeshBasicNodeMaterial();
+material2.colorNode = t3.toTSL(() => {
+ 'use gpu';
+ // Many builtin TSL nodes are already reexported as `accessors`
+ const uv = t3.uv().$;
+
+ if (uv.x < 0.5) {
+ return d.vec4f(fract(uv.mul(4)), 0, 1);
+ }
+
+ return d.vec4f(1, 0, 0, 1);
+});
+```
diff --git a/packages/typegpu-radiance-cascades/build.config.ts b/packages/typegpu-radiance-cascades/build.config.ts
new file mode 100644
index 0000000000..7f9f024f1f
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/build.config.ts
@@ -0,0 +1,12 @@
+import { type BuildConfig, defineBuildConfig } from 'unbuild';
+import typegpu from 'unplugin-typegpu/rollup';
+
+const Config: BuildConfig[] = defineBuildConfig({
+ hooks: {
+ 'rollup:options': (_options, config) => {
+ config.plugins.push(typegpu({ include: [/\.ts$/] }));
+ },
+ },
+});
+
+export default Config;
diff --git a/packages/typegpu-radiance-cascades/deno.json b/packages/typegpu-radiance-cascades/deno.json
new file mode 100644
index 0000000000..66699a4b54
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/deno.json
@@ -0,0 +1,7 @@
+{
+ "exclude": ["."],
+ "fmt": {
+ "exclude": ["!."],
+ "singleQuote": true
+ }
+}
diff --git a/packages/typegpu-radiance-cascades/package.json b/packages/typegpu-radiance-cascades/package.json
new file mode 100644
index 0000000000..e0072df6ac
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@typegpu/radiance-cascades",
+ "type": "module",
+ "version": "0.9.0",
+ "description": "Radiance Cascades implementation for TypeGPU",
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "directory": "dist",
+ "linkDirectory": false,
+ "main": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ "./package.json": "./dist/package.json",
+ ".": {
+ "types": "./dist/index.d.ts",
+ "module": "./dist/index.mjs",
+ "import": "./dist/index.mjs",
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "sideEffects": false,
+ "scripts": {
+ "build": "unbuild",
+ "test:types": "pnpm tsc --p ./tsconfig.json --noEmit",
+ "prepublishOnly": "tgpu-dev-cli prepack"
+ },
+ "keywords": [],
+ "license": "MIT",
+ "peerDependencies": {
+ "typegpu": "^0.9.0"
+ },
+ "devDependencies": {
+ "@typegpu/tgpu-dev-cli": "workspace:*",
+ "@webgpu/types": "catalog:types",
+ "typegpu": "workspace:*",
+ "typescript": "catalog:types",
+ "unbuild": "catalog:build",
+ "unplugin-typegpu": "workspace:*"
+ }
+}
diff --git a/packages/typegpu-radiance-cascades/src/cascades.ts b/packages/typegpu-radiance-cascades/src/cascades.ts
new file mode 100644
index 0000000000..121f8006ad
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/src/cascades.ts
@@ -0,0 +1,243 @@
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+import tgpu from 'typegpu';
+
+export function getCascadeDim(width: number, height: number, quality = 0.3) {
+ const aspect = width / height;
+ const diagonal = Math.sqrt(width ** 2 + height ** 2);
+ const base = diagonal * quality;
+ // Ensure minimum probe count for low resolutions (at least 16 probes on smallest axis)
+ const minPow2 = 16;
+ const closestPowerOfTwo = Math.max(
+ minPow2,
+ 2 ** Math.round(Math.log2(base)),
+ );
+
+ let cascadeWidth: number;
+ let cascadeHeight: number;
+ if (aspect >= 1) {
+ cascadeWidth = closestPowerOfTwo;
+ cascadeHeight = Math.max(minPow2, Math.round(closestPowerOfTwo / aspect));
+ } else {
+ cascadeWidth = Math.max(minPow2, Math.round(closestPowerOfTwo * aspect));
+ cascadeHeight = closestPowerOfTwo;
+ }
+
+ const cascadeDimX = cascadeWidth * 2;
+ const cascadeDimY = cascadeHeight * 2;
+
+ const interval = 1 / closestPowerOfTwo;
+ const maxIntervalStart = 1.5;
+
+ // Ensure minimum cascade count for proper light propagation
+ const minCascades = 4;
+ const cascadeAmount = Math.max(
+ minCascades,
+ Math.ceil(Math.log2((maxIntervalStart * 3) / interval + 1) / 2),
+ );
+
+ return [cascadeDimX, cascadeDimY, cascadeAmount] as const;
+}
+
+export const SceneData = d.struct({
+ color: d.vec4f, // doing vec3f is asking for trouble (unforunately)
+ dist: d.f32,
+});
+
+export const sceneSlot = tgpu.slot<(uv: d.v2f) => d.Infer>();
+
+// Result type for ray march function
+export const RayMarchResult = d.struct({
+ color: d.vec3f,
+ transmittance: d.f32, // 1.0 = no hit, 0.0 = fully opaque hit
+});
+
+// Default ray march implementation using sceneSlot
+export const defaultRayMarch = tgpu.fn(
+ [d.vec2f, d.vec2f, d.f32, d.f32, d.f32, d.f32],
+ RayMarchResult,
+)((probePos, rayDir, startT, endT, eps, minStep) => {
+ 'use gpu';
+ let rgb = d.vec3f();
+ let T = d.f32(1);
+ let t = startT;
+
+ for (let step = 0; step < 64; step++) {
+ if (t > endT) {
+ break;
+ }
+ const hit = sceneSlot.$(probePos.add(rayDir.mul(t)));
+ if (hit.dist <= eps) {
+ rgb = d.vec3f(hit.color.xyz);
+ T = d.f32(0);
+ break;
+ }
+ t += std.max(hit.dist, minStep);
+ }
+
+ return RayMarchResult({ color: rgb, transmittance: T });
+});
+
+// Slot for custom ray marching with default implementation
+export const rayMarchSlot = tgpu.slot(defaultRayMarch);
+
+export const CascadeParams = d.struct({
+ layer: d.u32,
+ baseProbes: d.vec2u,
+ cascadeDim: d.vec2u,
+ cascadeCount: d.u32,
+});
+
+export const cascadePassBGL = tgpu.bindGroupLayout({
+ params: { uniform: CascadeParams },
+ upper: { texture: d.texture2d(d.f32) },
+ upperSampler: { sampler: 'filtering' },
+ dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+});
+
+export const cascadePassCompute = tgpu['~unstable'].computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ const dim2 = std.textureDimensions(cascadePassBGL.$.dst);
+ if (std.any(std.ge(gid.xy, dim2))) {
+ return;
+ }
+
+ const params = cascadePassBGL.$.params;
+ const probes = d.vec2u(
+ std.max(params.baseProbes.x >> params.layer, d.u32(1)),
+ std.max(params.baseProbes.y >> params.layer, d.u32(1)),
+ );
+
+ const dirStored = gid.xy.div(probes);
+ const probe = std.mod(gid.xy, probes);
+ const raysDimStored = d.u32(2) << params.layer;
+ const raysDimActual = raysDimStored * d.u32(2);
+ const rayCountActual = raysDimActual * raysDimActual;
+
+ if (dirStored.x >= raysDimStored || dirStored.y >= raysDimStored) {
+ std.textureStore(cascadePassBGL.$.dst, gid.xy, d.vec4f(0, 0, 0, 1));
+ return;
+ }
+
+ const probePos = d.vec2f(probe).add(0.5).div(d.vec2f(probes));
+ const cascadeProbesMinVal = d.f32(
+ std.min(params.baseProbes.x, params.baseProbes.y),
+ );
+ const interval0 = 1.0 / cascadeProbesMinVal;
+ const pow4 = d.f32(d.u32(1) << (params.layer * d.u32(2)));
+ const startUv = (interval0 * (pow4 - 1.0)) / 3.0;
+ const endUv = startUv + interval0 * pow4;
+ // Use conservative epsilon values that don't scale too aggressively with resolution
+ // This ensures proper hit detection even at low resolution
+ const baseEps = d.f32(0.001); // ~0.1% of scene size minimum
+ const eps = std.max(baseEps, 0.25 / cascadeProbesMinVal);
+ const minStep = std.max(baseEps * 0.5, 0.125 / cascadeProbesMinVal);
+
+ let accum = d.vec4f();
+
+ for (let i = 0; i < 4; i++) {
+ const dirActual = dirStored
+ .mul(d.u32(2))
+ .add(d.vec2u(d.u32(i) & d.u32(1), d.u32(i) >> d.u32(1)));
+ const rayIndex = d.f32(dirActual.y * raysDimActual + dirActual.x) + 0.5;
+ const angle = (rayIndex / d.f32(rayCountActual)) * (Math.PI * 2) - Math.PI;
+ const rayDir = d.vec2f(std.cos(angle), -std.sin(angle));
+
+ // Use ray march slot for customizable ray marching
+ const marchResult = rayMarchSlot.$(
+ probePos,
+ rayDir,
+ startUv,
+ endUv,
+ eps,
+ minStep,
+ );
+ let rgb = d.vec3f(marchResult.color);
+ let T = d.f32(marchResult.transmittance);
+
+ if (params.layer < d.u32(params.cascadeCount - 1) && T > 0.01) {
+ const probesU = d.vec2u(
+ std.max(probes.x >> d.u32(1), d.u32(1)),
+ std.max(probes.y >> d.u32(1), d.u32(1)),
+ );
+ const tileOrigin = d.vec2f(dirActual).mul(d.vec2f(probesU));
+ const probePixel = std.clamp(
+ probePos.mul(d.vec2f(probesU)),
+ d.vec2f(0.5),
+ d.vec2f(probesU).sub(0.5),
+ );
+ const uvU = tileOrigin.add(probePixel).div(d.vec2f(dim2));
+
+ const upper = std.textureSampleLevel(
+ cascadePassBGL.$.upper,
+ cascadePassBGL.$.upperSampler,
+ uvU,
+ 0,
+ );
+ rgb = rgb.add(upper.xyz.mul(T));
+ T *= upper.w;
+ }
+
+ accum = accum.add(d.vec4f(rgb, T));
+ }
+
+ std.textureStore(cascadePassBGL.$.dst, gid.xy, accum.mul(0.25));
+});
+
+export const BuildRadianceFieldParams = d.struct({
+ outputProbes: d.vec2u,
+ cascadeProbes: d.vec2u,
+ cascadeDim: d.vec2u,
+});
+
+export const buildRadianceFieldBGL = tgpu.bindGroupLayout({
+ params: { uniform: BuildRadianceFieldParams },
+ src: { texture: d.texture2d(d.f32) },
+ srcSampler: { sampler: 'filtering' },
+ dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') },
+});
+
+export const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ const dim2 = std.textureDimensions(buildRadianceFieldBGL.$.dst);
+ if (std.any(std.ge(gid.xy, dim2))) {
+ return;
+ }
+
+ const params = buildRadianceFieldBGL.$.params;
+
+ const invCascadeDim = d.vec2f(1.0).div(d.vec2f(params.cascadeDim));
+ const uv = d.vec2f(gid.xy).add(0.5).div(d.vec2f(params.outputProbes));
+
+ const probePixel = std.clamp(
+ uv.mul(d.vec2f(params.cascadeProbes)),
+ d.vec2f(0.5),
+ d.vec2f(params.cascadeProbes).sub(0.5),
+ );
+
+ const uvStride = d.vec2f(params.cascadeProbes).mul(invCascadeDim);
+ const baseSampleUV = probePixel.mul(invCascadeDim);
+
+ let sum = d.vec3f();
+ for (let i = d.u32(0); i < 4; i++) {
+ const offset = d.vec2f(d.f32(i & 1), d.f32(i >> 1)).mul(uvStride);
+ sum = sum.add(
+ std.textureSampleLevel(
+ buildRadianceFieldBGL.$.src,
+ buildRadianceFieldBGL.$.srcSampler,
+ baseSampleUV.add(offset),
+ 0,
+ ).xyz,
+ );
+ }
+
+ std.textureStore(
+ buildRadianceFieldBGL.$.dst,
+ gid.xy,
+ d.vec4f(sum.mul(0.25), 1),
+ );
+});
diff --git a/packages/typegpu-radiance-cascades/src/index.ts b/packages/typegpu-radiance-cascades/src/index.ts
new file mode 100644
index 0000000000..09401f47d0
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/src/index.ts
@@ -0,0 +1,12 @@
+export { createRadianceCascades } from './runner.ts';
+export type {
+ RadianceCascadesExecutor,
+ RadianceCascadesExecutorBase,
+} from './runner.ts';
+export {
+ defaultRayMarch,
+ RayMarchResult,
+ rayMarchSlot,
+ SceneData,
+ sceneSlot,
+} from './cascades.ts';
diff --git a/packages/typegpu-radiance-cascades/src/runner.ts b/packages/typegpu-radiance-cascades/src/runner.ts
new file mode 100644
index 0000000000..eceb301b4a
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/src/runner.ts
@@ -0,0 +1,352 @@
+import {
+ isTexture,
+ isTextureView,
+ type SampledFlag,
+ type StorageFlag,
+ type TgpuBindGroup,
+ type TgpuRoot,
+ type TgpuTexture,
+ type TgpuTextureView,
+} from 'typegpu';
+import * as d from 'typegpu/data';
+import {
+ buildRadianceFieldBGL,
+ buildRadianceFieldCompute,
+ BuildRadianceFieldParams,
+ CascadeParams,
+ cascadePassBGL,
+ cascadePassCompute,
+ defaultRayMarch,
+ getCascadeDim,
+ rayMarchSlot,
+ type SceneData,
+ sceneSlot,
+} from './cascades.ts';
+
+type OutputTexture =
+ | (
+ & TgpuTexture<{
+ size: [number, number];
+ format: 'rgba16float';
+ }>
+ & StorageFlag
+ )
+ | TgpuTextureView>;
+
+type CascadesOptionsBase = {
+ root: TgpuRoot;
+ scene: (uv: d.v2f) => d.Infer;
+ /** Optional custom ray march function. Defaults to the built-in ray marcher that uses the scene slot. */
+ rayMarch?: typeof defaultRayMarch;
+ /**
+ * Quality factor for cascade generation (0.1 to 1.0, default 0.3).
+ * Higher values create more probes and cascades, improving quality at the cost of performance.
+ * At low output resolutions, consider using higher quality values (0.5-1.0) for better results.
+ */
+ quality?: number;
+};
+
+type CascadesOptionsWithOutput = CascadesOptionsBase & {
+ output: OutputTexture;
+ size?: { width: number; height: number };
+};
+
+type CascadesOptionsWithoutOutput = CascadesOptionsBase & {
+ output?: undefined;
+ size: { width: number; height: number };
+};
+
+type OutputTextureProp =
+ & TgpuTexture<{
+ size: [number, number];
+ format: 'rgba16float';
+ }>
+ & StorageFlag
+ & SampledFlag;
+
+/** Base executor type without output property (used when output is provided externally) */
+export type RadianceCascadesExecutorBase = {
+ /**
+ * Run the radiance cascades algorithm, filling the output texture.
+ */
+ run(): void;
+
+ /**
+ * Returns a new executor with the additional bind group attached.
+ * Use this to pass custom resources to custom ray march implementations.
+ * If the pipeline doesn't use this layout, it's safely ignored.
+ */
+ with(bindGroup: TgpuBindGroup): RadianceCascadesExecutorBase;
+
+ /**
+ * Clean up all GPU resources created by this executor.
+ */
+ destroy(): void;
+};
+
+/** Executor type with owned output texture */
+export type RadianceCascadesExecutor = RadianceCascadesExecutorBase & {
+ /**
+ * Returns a new executor with the additional bind group attached.
+ */
+ with(bindGroup: TgpuBindGroup): RadianceCascadesExecutor;
+
+ /**
+ * The output texture containing the radiance field.
+ * Use this for sampling in your render pass.
+ */
+ readonly output: OutputTextureProp;
+};
+
+/**
+ * Create a radiance cascades executor that renders to the provided output texture.
+ */
+export function createRadianceCascades(
+ options: CascadesOptionsWithOutput,
+): RadianceCascadesExecutorBase;
+
+/**
+ * Create a radiance cascades executor that creates and owns its own output texture.
+ */
+export function createRadianceCascades(
+ options: CascadesOptionsWithoutOutput,
+): RadianceCascadesExecutor;
+
+export function createRadianceCascades(
+ options: CascadesOptionsWithOutput | CascadesOptionsWithoutOutput,
+): RadianceCascadesExecutor | RadianceCascadesExecutorBase {
+ const { root, scene, output, size, rayMarch, quality = 0.3 } = options;
+
+ const hasOutputProvided = !!output &&
+ (isTexture(output) || isTextureView(output));
+
+ // Determine output dimensions
+ let outputWidth: number;
+ let outputHeight: number;
+
+ if (hasOutputProvided) {
+ if (isTexture(output)) {
+ [outputWidth, outputHeight] = output.props.size;
+ } else {
+ const viewSize = output.size ?? [size?.width, size?.height];
+ if (!viewSize[0] || !viewSize[1]) {
+ throw new Error(
+ 'Size could not be inferred from texture view, pass explicit size in options.',
+ );
+ }
+ [outputWidth, outputHeight] = viewSize as [number, number];
+ }
+ } else {
+ if (!size) {
+ throw new Error('Size is required when output texture is not provided.');
+ }
+ outputWidth = size.width;
+ outputHeight = size.height;
+ }
+
+ // Create output texture type
+ type OwnedOutputTexture =
+ & TgpuTexture<{
+ size: [number, number];
+ format: 'rgba16float';
+ }>
+ & StorageFlag
+ & SampledFlag;
+
+ // Create or use provided output texture
+ let ownedOutput: OwnedOutputTexture | null = null;
+ let dst: OutputTexture | OwnedOutputTexture;
+
+ if (hasOutputProvided) {
+ dst = output;
+ } else {
+ ownedOutput = root['~unstable']
+ .createTexture({
+ size: [outputWidth, outputHeight],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled');
+ dst = ownedOutput;
+ }
+
+ // Compute cascade dimensions with quality factor
+ const [cascadeDimX, cascadeDimY, cascadeAmount] = getCascadeDim(
+ outputWidth,
+ outputHeight,
+ quality,
+ );
+
+ const cascadeProbesX = cascadeDimX / 2;
+ const cascadeProbesY = cascadeDimY / 2;
+
+ // Create double-buffered cascade textures
+ const createCascadeTexture = () =>
+ root['~unstable']
+ .createTexture({
+ size: [cascadeDimX, cascadeDimY, cascadeAmount],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled');
+
+ const cascadeTextureA = createCascadeTexture();
+ const cascadeTextureB = createCascadeTexture();
+
+ // Create sampler for cascade textures
+ const cascadeSampler = root['~unstable'].createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+ addressModeU: 'clamp-to-edge',
+ addressModeV: 'clamp-to-edge',
+ });
+
+ // Create buffer for cascade parameters
+ const paramsBuffer = root.createBuffer(CascadeParams).$usage('uniform');
+
+ // Create cascade pass pipeline with scene and ray march slots bound
+ const cascadePassPipeline = root['~unstable']
+ .with(sceneSlot, scene)
+ .with(rayMarchSlot, rayMarch ?? defaultRayMarch)
+ .withCompute(cascadePassCompute)
+ .createPipeline();
+
+ // Create bind groups for all cascade passes
+ const cascadePassBindGroups = Array.from(
+ { length: cascadeAmount },
+ (_, layer) => {
+ const writeToA = (cascadeAmount - 1 - layer) % 2 === 0;
+ const dstTexture = writeToA ? cascadeTextureA : cascadeTextureB;
+ const srcTexture = writeToA ? cascadeTextureB : cascadeTextureA;
+
+ return root.createBindGroup(cascadePassBGL, {
+ params: paramsBuffer,
+ upper: srcTexture.createView(d.texture2d(d.f32), {
+ baseArrayLayer: Math.min(layer + 1, cascadeAmount - 1),
+ arrayLayerCount: 1,
+ }),
+ upperSampler: cascadeSampler,
+ dst: dstTexture.createView(
+ d.textureStorage2d('rgba16float', 'write-only'),
+ { baseArrayLayer: layer, arrayLayerCount: 1 },
+ ),
+ });
+ },
+ );
+
+ // Create build radiance field pipeline
+ const buildRadianceFieldPipeline = root['~unstable']
+ .withCompute(buildRadianceFieldCompute)
+ .createPipeline();
+
+ // Create buffer for radiance field params
+ const radianceFieldParamsBuffer = root
+ .createBuffer(BuildRadianceFieldParams, {
+ outputProbes: d.vec2u(outputWidth, outputHeight),
+ cascadeProbes: d.vec2u(cascadeProbesX, cascadeProbesY),
+ cascadeDim: d.vec2u(cascadeDimX, cascadeDimY),
+ })
+ .$usage('uniform');
+
+ // Determine which cascade texture has cascade 0
+ const cascade0InA = (cascadeAmount - 1) % 2 === 0;
+ const srcCascadeTexture = cascade0InA ? cascadeTextureA : cascadeTextureB;
+
+ // Get the output storage view
+ type StorageTextureView = TgpuTextureView<
+ d.WgslStorageTexture2d<'rgba16float', 'write-only'>
+ >;
+ const dstView: StorageTextureView = isTexture(dst)
+ ? (
+ dst as
+ & TgpuTexture<{ size: [number, number]; format: 'rgba16float' }>
+ & StorageFlag
+ ).createView(d.textureStorage2d('rgba16float', 'write-only'))
+ : dst;
+
+ // Create bind group for building radiance field
+ const buildRadianceFieldBG = root.createBindGroup(buildRadianceFieldBGL, {
+ params: radianceFieldParamsBuffer,
+ src: srcCascadeTexture.createView(d.texture2d(d.f32), {
+ baseArrayLayer: 0,
+ arrayLayerCount: 1,
+ }),
+ srcSampler: cascadeSampler,
+ dst: dstView,
+ });
+
+ // Precompute workgroup counts
+ const cascadeWorkgroupsX = Math.ceil(cascadeDimX / 8);
+ const cascadeWorkgroupsY = Math.ceil(cascadeDimY / 8);
+ const outputWorkgroupsX = Math.ceil(outputWidth / 8);
+ const outputWorkgroupsY = Math.ceil(outputHeight / 8);
+
+ function destroy() {
+ cascadeTextureA.destroy();
+ cascadeTextureB.destroy();
+ ownedOutput?.destroy();
+ }
+
+ // Create executor factory that supports .with(bindGroup) pattern
+ function createExecutorBase(
+ additionalBindGroups: TgpuBindGroup[] = [],
+ ): RadianceCascadesExecutorBase {
+ function run() {
+ // Run cascade passes top-down
+ for (let layer = cascadeAmount - 1; layer >= 0; layer--) {
+ paramsBuffer.write({
+ layer,
+ baseProbes: d.vec2u(cascadeProbesX, cascadeProbesY),
+ cascadeDim: d.vec2u(cascadeDimX, cascadeDimY),
+ cascadeCount: cascadeAmount,
+ });
+
+ const bindGroup = cascadePassBindGroups[layer];
+ if (bindGroup) {
+ let pipeline = cascadePassPipeline.with(bindGroup);
+ for (const bg of additionalBindGroups) {
+ pipeline = pipeline.with(bg);
+ }
+ pipeline.dispatchWorkgroups(cascadeWorkgroupsX, cascadeWorkgroupsY);
+ }
+ }
+
+ // Build the final radiance field
+ let radiancePipeline = buildRadianceFieldPipeline.with(
+ buildRadianceFieldBG,
+ );
+ for (const bg of additionalBindGroups) {
+ radiancePipeline = radiancePipeline.with(bg);
+ }
+ radiancePipeline.dispatchWorkgroups(outputWorkgroupsX, outputWorkgroupsY);
+ }
+
+ function withBindGroup(
+ bindGroup: TgpuBindGroup,
+ ): RadianceCascadesExecutorBase {
+ return createExecutorBase([...additionalBindGroups, bindGroup]);
+ }
+
+ return { run, with: withBindGroup, destroy };
+ }
+
+ function createExecutorWithOutput(
+ additionalBindGroups: TgpuBindGroup[] = [],
+ ): RadianceCascadesExecutor {
+ const base = createExecutorBase(additionalBindGroups);
+
+ function withBindGroup(bindGroup: TgpuBindGroup): RadianceCascadesExecutor {
+ return createExecutorWithOutput([...additionalBindGroups, bindGroup]);
+ }
+
+ return {
+ ...base,
+ with: withBindGroup,
+ output: ownedOutput as OwnedOutputTexture,
+ };
+ }
+
+ if (hasOutputProvided) {
+ return createExecutorBase();
+ }
+
+ return createExecutorWithOutput();
+}
diff --git a/packages/typegpu-radiance-cascades/tsconfig.json b/packages/typegpu-radiance-cascades/tsconfig.json
new file mode 100644
index 0000000000..5f257dc0f0
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/typegpu-sdf/src/index.ts b/packages/typegpu-sdf/src/index.ts
index a717c7db86..b8cd79bbdc 100644
--- a/packages/typegpu-sdf/src/index.ts
+++ b/packages/typegpu-sdf/src/index.ts
@@ -26,3 +26,11 @@ export {
opSmoothUnion,
opUnion,
} from './operators.ts';
+
+export {
+ classifySlot,
+ createJumpFlood,
+ defaultDistanceWrite,
+ distanceWriteSlot,
+} from './jumpFlood.ts';
+export type { DistanceTexture, JumpFloodExecutor } from './jumpFlood.ts';
diff --git a/packages/typegpu-sdf/src/jumpFlood.ts b/packages/typegpu-sdf/src/jumpFlood.ts
new file mode 100644
index 0000000000..f271eefaba
--- /dev/null
+++ b/packages/typegpu-sdf/src/jumpFlood.ts
@@ -0,0 +1,470 @@
+import tgpu, {
+ type SampledFlag,
+ type StorageFlag,
+ type TgpuBindGroup,
+ type TgpuRoot,
+ type TgpuTexture,
+} from 'typegpu';
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+
+const INVALID_COORD = 0xffffffff;
+
+const pingPongLayout = tgpu.bindGroupLayout({
+ readView: {
+ storageTexture: d.textureStorage2d('rgba32uint', 'read-only'),
+ },
+ writeView: {
+ storageTexture: d.textureStorage2d('rgba32uint', 'write-only'),
+ },
+});
+
+const initLayout = tgpu.bindGroupLayout({
+ writeView: {
+ storageTexture: d.textureStorage2d('rgba32uint', 'write-only'),
+ },
+});
+
+const distWriteLayout = tgpu.bindGroupLayout({
+ distTexture: {
+ storageTexture: d.textureStorage2d('rgba16float', 'write-only'),
+ },
+});
+
+/**
+ * Slot for the classify function that determines which pixels are "inside" for the SDF.
+ * The function receives the pixel coordinate and texture size, and returns whether
+ * the pixel is inside (true) or outside (false).
+ *
+ * Users should provide their own implementation that reads from their textures
+ * to determine inside/outside classification.
+ */
+export const classifySlot = tgpu.slot<(coord: d.v2u, size: d.v2u) => boolean>();
+
+/**
+ * Default distance write - writes signed distance to rgba16float texture.
+ * Users can provide a custom implementation to write additional data.
+ *
+ * @param coord - The pixel coordinate being written
+ * @param signedDist - Signed distance in pixels (positive = outside, negative = inside)
+ * @param insidePx - Pixel coordinates of the nearest inside seed
+ * @param outsidePx - Pixel coordinates of the nearest outside seed
+ */
+export const defaultDistanceWrite = (
+ coord: d.v2u,
+ signedDist: number,
+ _insidePx: d.v2u,
+ _outsidePx: d.v2u,
+) => {
+ 'use gpu';
+ std.textureStore(
+ distWriteLayout.$.distTexture,
+ d.vec2i(coord),
+ d.vec4f(signedDist, 0, 0, 0),
+ );
+};
+
+/** Slot for custom distance writing */
+export const distanceWriteSlot = tgpu.slot<
+ (coord: d.v2u, signedDist: number, insidePx: d.v2u, outsidePx: d.v2u) => void
+>(defaultDistanceWrite);
+
+const SampleResult = d.struct({
+ inside: d.vec2u,
+ outside: d.vec2u,
+});
+
+const sampleWithOffset = (
+ tex: d.textureStorage2d<'rgba32uint', 'read-only'>,
+ pos: d.v2i,
+ offset: d.v2i,
+) => {
+ 'use gpu';
+ const dims = std.textureDimensions(tex);
+ const samplePos = pos.add(offset);
+
+ const outOfBounds = samplePos.x < 0 ||
+ samplePos.y < 0 ||
+ samplePos.x >= d.i32(dims.x) ||
+ samplePos.y >= d.i32(dims.y);
+
+ const safePos = std.clamp(samplePos, d.vec2i(0), d.vec2i(dims.sub(1)));
+ const loaded = std.textureLoad(tex, safePos);
+
+ const inside = loaded.xy;
+ const outside = loaded.zw;
+
+ const invalid = d.vec2u(INVALID_COORD);
+ return SampleResult({
+ inside: std.select(inside, invalid, outOfBounds),
+ outside: std.select(outside, invalid, outOfBounds),
+ });
+};
+
+const offsetAccessor = tgpu['~unstable'].accessor(d.i32);
+
+const initFromSeedCompute = tgpu['~unstable'].computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ const size = std.textureDimensions(initLayout.$.writeView);
+ if (std.any(std.ge(gid.xy, size))) {
+ return;
+ }
+
+ // Use classify slot to determine if this pixel is inside
+ const isInside = classifySlot.$(gid.xy, size);
+ const invalid = d.vec2u(INVALID_COORD);
+
+ // Store pixel coords directly (not UVs)
+ // If inside: inside coord = this pixel, outside coord = invalid
+ // If outside: outside coord = this pixel, inside coord = invalid
+ const insideCoord = std.select(invalid, gid.xy, isInside);
+ const outsideCoord = std.select(gid.xy, invalid, isInside);
+
+ std.textureStore(
+ initLayout.$.writeView,
+ d.vec2i(gid.xy),
+ d.vec4u(insideCoord, outsideCoord),
+ );
+});
+
+const jumpFloodCompute = tgpu['~unstable'].computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ const size = std.textureDimensions(pingPongLayout.$.readView);
+ if (std.any(std.ge(gid.xy, size))) {
+ return;
+ }
+
+ const offset = offsetAccessor.$;
+ const pos = d.vec2f(gid.xy);
+
+ const invalid = d.vec2u(INVALID_COORD);
+ let bestInsideCoord = d.vec2u(invalid);
+ let bestOutsideCoord = d.vec2u(invalid);
+ let bestInsideDist2 = d.f32(1e20); // squared distance
+ let bestOutsideDist2 = d.f32(1e20); // squared distance
+
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const sample = sampleWithOffset(
+ pingPongLayout.$.readView,
+ d.vec2i(gid.xy),
+ d.vec2i(dx * offset, dy * offset),
+ );
+
+ // Check inside candidate (valid if not INVALID_COORD)
+ if (sample.inside.x !== INVALID_COORD) {
+ const deltaIn = pos.sub(d.vec2f(sample.inside));
+ const dist2 = std.dot(deltaIn, deltaIn);
+ if (dist2 < bestInsideDist2) {
+ bestInsideDist2 = dist2;
+ bestInsideCoord = d.vec2u(sample.inside);
+ }
+ }
+
+ // Check outside candidate (valid if not INVALID_COORD)
+ if (sample.outside.x !== INVALID_COORD) {
+ const deltaOut = pos.sub(d.vec2f(sample.outside));
+ const dist2 = std.dot(deltaOut, deltaOut);
+ if (dist2 < bestOutsideDist2) {
+ bestOutsideDist2 = dist2;
+ bestOutsideCoord = d.vec2u(sample.outside);
+ }
+ }
+ }
+ }
+
+ std.textureStore(
+ pingPongLayout.$.writeView,
+ d.vec2i(gid.xy),
+ d.vec4u(bestInsideCoord, bestOutsideCoord),
+ );
+});
+
+const createDistanceFieldCompute = tgpu['~unstable'].computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ const size = std.textureDimensions(pingPongLayout.$.readView);
+ if (std.any(std.ge(gid.xy, size))) {
+ return;
+ }
+
+ const pos = d.vec2f(gid.xy);
+ const texel = std.textureLoad(pingPongLayout.$.readView, d.vec2i(gid.xy));
+
+ const insideCoord = texel.xy;
+ const outsideCoord = texel.zw;
+
+ let insideDist = d.f32(1e20);
+ let outsideDist = d.f32(1e20);
+
+ // Compute distances in pixel space
+ if (insideCoord.x !== INVALID_COORD) {
+ insideDist = std.distance(pos, d.vec2f(insideCoord));
+ }
+
+ if (outsideCoord.x !== INVALID_COORD) {
+ outsideDist = std.distance(pos, d.vec2f(outsideCoord));
+ }
+
+ // Output signed distance in pixels
+ // Positive = outside (distance to nearest inside), Negative = inside (distance to nearest outside)
+ const signedDist = insideDist - outsideDist;
+
+ // Use distance write slot for customizable output
+ distanceWriteSlot.$(gid.xy, signedDist, insideCoord, outsideCoord);
+});
+
+type FloodTexture =
+ & TgpuTexture<{
+ size: [number, number];
+ format: 'rgba32uint';
+ }>
+ & StorageFlag;
+
+export type DistanceTexture =
+ & TgpuTexture<{
+ size: [number, number];
+ format: 'rgba16float';
+ }>
+ & StorageFlag
+ & SampledFlag;
+
+export type JumpFloodExecutor =
+ & {
+ /**
+ * Run the jump flood algorithm.
+ * The classify function determines which pixels are inside/outside.
+ */
+ run(): void;
+
+ /**
+ * Returns a new executor with the additional bind group attached.
+ * Use this to pass resources needed by custom classify or distance write functions.
+ */
+ with(bindGroup: TgpuBindGroup): JumpFloodExecutor;
+
+ /**
+ * Clean up GPU resources created by this executor.
+ */
+ destroy(): void;
+ }
+ & (OwnsOutput extends true ? {
+ /**
+ * The output distance field texture.
+ * Contains signed distance values in pixels after run() completes.
+ * Positive = outside (distance to nearest inside), Negative = inside (distance to nearest outside).
+ */
+ readonly output: DistanceTexture;
+ }
+ : object);
+
+type JumpFloodOptionsBase = {
+ root: TgpuRoot;
+ size: { width: number; height: number };
+ /**
+ * Classify function that determines which pixels are "inside" for the SDF.
+ * Returns true if the pixel is inside, false if outside.
+ */
+ classify: (coord: d.v2u, size: d.v2u) => boolean;
+ /** Optional custom distance write function. Defaults to writing signed distance to output texture. */
+ distanceWrite?: typeof defaultDistanceWrite;
+};
+
+type JumpFloodOptionsWithOutput = JumpFloodOptionsBase & {
+ output: DistanceTexture;
+};
+
+type JumpFloodOptionsWithoutOutput = JumpFloodOptionsBase & {
+ output?: undefined;
+};
+
+/**
+ * Create a Jump Flood Algorithm executor that creates its own output texture.
+ */
+export function createJumpFlood(
+ options: JumpFloodOptionsWithoutOutput,
+): JumpFloodExecutor;
+
+/**
+ * Create a Jump Flood Algorithm executor with a provided output texture.
+ */
+export function createJumpFlood(
+ options: JumpFloodOptionsWithOutput,
+): JumpFloodExecutor;
+
+export function createJumpFlood(
+ options: JumpFloodOptionsWithOutput | JumpFloodOptionsWithoutOutput,
+): JumpFloodExecutor {
+ const {
+ root,
+ size,
+ classify,
+ output: providedOutput,
+ distanceWrite,
+ } = options;
+ const { width, height } = size;
+
+ // Create or use provided output texture
+ const ownsOutput = !providedOutput;
+
+ const distanceTexture: DistanceTexture = providedOutput ??
+ (root['~unstable']
+ .createTexture({
+ size: [width, height],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled') as DistanceTexture);
+
+ // Create flood textures (always owned by executor)
+ const floodTextureA = root['~unstable']
+ .createTexture({
+ size: [width, height],
+ format: 'rgba32uint',
+ })
+ .$usage('storage') as FloodTexture;
+
+ const floodTextureB = root['~unstable']
+ .createTexture({
+ size: [width, height],
+ format: 'rgba32uint',
+ })
+ .$usage('storage') as FloodTexture;
+
+ // Create uniform for offset
+ const offsetUniform = root.createUniform(d.i32);
+
+ // Create pipelines with slot bindings
+ const initFromSeedPipeline = root['~unstable']
+ .with(classifySlot, classify)
+ .withCompute(initFromSeedCompute)
+ .createPipeline();
+
+ const jumpFloodPipeline = root['~unstable']
+ .with(offsetAccessor, offsetUniform)
+ .withCompute(jumpFloodCompute)
+ .createPipeline();
+
+ const createDistancePipeline = root['~unstable']
+ .with(distanceWriteSlot, distanceWrite ?? defaultDistanceWrite)
+ .withCompute(createDistanceFieldCompute)
+ .createPipeline();
+
+ // Create bind groups
+ const initBG = root.createBindGroup(initLayout, {
+ writeView: floodTextureA.createView(
+ d.textureStorage2d('rgba32uint', 'write-only'),
+ ),
+ });
+
+ const pingPongBGs = [
+ root.createBindGroup(pingPongLayout, {
+ readView: floodTextureA.createView(
+ d.textureStorage2d('rgba32uint', 'read-only'),
+ ),
+ writeView: floodTextureB.createView(
+ d.textureStorage2d('rgba32uint', 'write-only'),
+ ),
+ }),
+ root.createBindGroup(pingPongLayout, {
+ readView: floodTextureB.createView(
+ d.textureStorage2d('rgba32uint', 'read-only'),
+ ),
+ writeView: floodTextureA.createView(
+ d.textureStorage2d('rgba32uint', 'write-only'),
+ ),
+ }),
+ ];
+
+ const distWriteBG = root.createBindGroup(distWriteLayout, {
+ distTexture: distanceTexture.createView(
+ d.textureStorage2d('rgba16float', 'write-only'),
+ ),
+ });
+
+ // Precompute workgroup counts
+ const workgroupsX = Math.ceil(width / 8);
+ const workgroupsY = Math.ceil(height / 8);
+ // Use power-of-two offset for proper JFA coverage
+ const maxDim = Math.max(width, height);
+ const maxRange = 1 << Math.floor(Math.log2(maxDim));
+
+ function destroy() {
+ floodTextureA.destroy();
+ floodTextureB.destroy();
+ if (ownsOutput) {
+ distanceTexture.destroy();
+ }
+ }
+
+ // Create executor factory that supports .with(bindGroup) pattern
+ function createExecutor(
+ additionalBindGroups: TgpuBindGroup[] = [],
+ ): JumpFloodExecutor {
+ function run() {
+ // Initialize from seed function
+ let initPipeline = initFromSeedPipeline.with(initBG);
+ for (const bg of additionalBindGroups) {
+ initPipeline = initPipeline.with(bg);
+ }
+ initPipeline.dispatchWorkgroups(workgroupsX, workgroupsY);
+
+ // Run jump flood iterations
+ let sourceIdx = 0;
+ let offset = maxRange;
+
+ while (offset >= 1) {
+ offsetUniform.write(offset);
+
+ const bg = pingPongBGs[sourceIdx];
+ if (bg) {
+ let floodPipeline = jumpFloodPipeline.with(bg);
+ for (const addBg of additionalBindGroups) {
+ floodPipeline = floodPipeline.with(addBg);
+ }
+ floodPipeline.dispatchWorkgroups(workgroupsX, workgroupsY);
+ }
+
+ sourceIdx ^= 1;
+ offset = Math.floor(offset / 2);
+ }
+
+ // Create final distance field
+ const finalBG = pingPongBGs[sourceIdx];
+ if (finalBG) {
+ let distPipeline = createDistancePipeline.with(finalBG).with(
+ distWriteBG,
+ );
+ for (const bg of additionalBindGroups) {
+ distPipeline = distPipeline.with(bg);
+ }
+ distPipeline.dispatchWorkgroups(workgroupsX, workgroupsY);
+ }
+ }
+
+ function withBindGroup(bindGroup: TgpuBindGroup) {
+ return createExecutor([...additionalBindGroups, bindGroup]);
+ }
+
+ if (ownsOutput) {
+ return {
+ run,
+ with: withBindGroup,
+ destroy,
+ output: distanceTexture,
+ };
+ }
+
+ return {
+ run,
+ with: withBindGroup,
+ destroy,
+ };
+ }
+
+ return createExecutor();
+}
diff --git a/packages/typegpu/src/core/texture/texture.ts b/packages/typegpu/src/core/texture/texture.ts
index 15c727f4bc..5d5a89f695 100644
--- a/packages/typegpu/src/core/texture/texture.ts
+++ b/packages/typegpu/src/core/texture/texture.ts
@@ -203,6 +203,7 @@ export interface TgpuTextureView<
readonly [$internal]: TextureViewInternals;
readonly resourceType: 'texture-view';
readonly schema: TSchema;
+ readonly size?: number[] | undefined;
readonly [$gpuValueOf]: Infer;
value: Infer;
@@ -576,6 +577,7 @@ class TgpuFixedTextureViewImpl
declare readonly [$repr]: Infer;
readonly [$internal]: TextureViewInternals;
readonly resourceType = 'texture-view' as const;
+ readonly size: number[];
#baseTexture: TgpuTexture;
#view: GPUTextureView | undefined;
@@ -593,6 +595,7 @@ class TgpuFixedTextureViewImpl
) {
this.#baseTexture = baseTexture;
this.#descriptor = descriptor;
+ this.size = baseTexture.props.size;
this[$internal] = {
unwrap: () => {
diff --git a/packages/typegpu/src/index.ts b/packages/typegpu/src/index.ts
index 92a2b15d7f..2f8becc1b8 100644
--- a/packages/typegpu/src/index.ts
+++ b/packages/typegpu/src/index.ts
@@ -89,7 +89,7 @@ export {
export { isBuffer, isUsableAsVertex } from './core/buffer/buffer.ts';
export { isDerived, isSlot } from './core/slot/slotTypes.ts';
export { isComparisonSampler, isSampler } from './core/sampler/sampler.ts';
-export { isTexture } from './core/texture/texture.ts';
+export { isTexture, isTextureView } from './core/texture/texture.ts';
export {
isUsableAsRender,
isUsableAsSampled,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69ebcc5f6c..2428add7f9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -174,6 +174,9 @@ importers:
'@typegpu/noise':
specifier: workspace:*
version: link:../../packages/typegpu-noise
+ '@typegpu/radiance-cascades':
+ specifier: workspace:*
+ version: link:../../packages/typegpu-radiance-cascades
'@typegpu/sdf':
specifier: workspace:*
version: link:../../packages/typegpu-sdf
@@ -559,6 +562,28 @@ importers:
version: link:../unplugin-typegpu
publishDirectory: dist
+ packages/typegpu-radiance-cascades:
+ devDependencies:
+ '@typegpu/tgpu-dev-cli':
+ specifier: workspace:*
+ version: link:../tgpu-dev-cli
+ '@webgpu/types':
+ specifier: catalog:types
+ version: 0.1.66
+ typegpu:
+ specifier: workspace:*
+ version: link:../typegpu
+ typescript:
+ specifier: catalog:types
+ version: 5.9.3
+ unbuild:
+ specifier: catalog:build
+ version: 3.5.0(typescript@5.9.3)
+ unplugin-typegpu:
+ specifier: workspace:*
+ version: link:../unplugin-typegpu
+ publishDirectory: dist
+
packages/typegpu-sdf:
devDependencies:
'@typegpu/tgpu-dev-cli':