(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.accessor(SceneData);
+export const sceneSDF = (p: d.v2f) => {
+ 'use gpu';
+ const scene = sceneDataAccess.$;
+
+ let minDist = d.f32(2e31);
+ let color = d.vec3f();
+
+ for (const disk of scene.disks) {
+ const dist = sdf.sdDisk(p - disk.pos, disk.radius);
+
+ if (dist < minDist) {
+ minDist = dist;
+ color = d.vec3f(disk.emissiveColor);
+ }
+ }
+
+ for (const box of scene.boxes) {
+ const dist = sdf.sdBox2d(p - 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-cascades/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/thumbnail.png
new file mode 100644
index 0000000000..d5fdbd4f03
Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/thumbnail.png differ
diff --git a/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts b/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts
index 3fb0e7d685..775ee742d2 100644
--- a/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts
+++ b/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts
@@ -77,8 +77,8 @@ describe('jump flood (distance) example', () => {
var pos = vec2f(f32(x), f32(y));
var bestInsideCoord = vec2f(-1);
var bestOutsideCoord = vec2f(-1);
- var bestInsideDist = 1e+20;
- var bestOutsideDist = 1e+20;
+ var bestInsideDist = 3.4028234663852886e+38f;
+ var bestOutsideDist = 3.4028234663852886e+38f;
// unrolled iteration #0
{
// unrolled iteration #0
@@ -272,8 +272,8 @@ describe('jump flood (distance) example', () => {
var texel = textureLoad(readView, vec2i(i32(x), i32(y)));
var insideCoord = texel.xy;
var outsideCoord = texel.zw;
- var insideDist = 1e+20;
- var outsideDist = 1e+20;
+ var insideDist = 3.4028234663852886e+38f;
+ var outsideDist = 3.4028234663852886e+38f;
if ((insideCoord.x >= 0f)) {
insideDist = distance(pos, (insideCoord * vec2f(size)));
}
diff --git a/packages/typegpu-radiance-cascades/README.md b/packages/typegpu-radiance-cascades/README.md
new file mode 100644
index 0000000000..a39aee9242
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/README.md
@@ -0,0 +1,27 @@
+
+
+# @typegpu/radiance-cascades
+
+
+
+A helper library for computing 2D radiance cascades with TypeGPU.
+
+```ts
+import { createRadianceCascades } from '@typegpu/radiance-cascades';
+
+const runner = createRadianceCascades({
+ root,
+ size: { width, height },
+ sdfResolution: { width: sdfWidth, height: sdfHeight },
+ sdf: (uv) => {
+ 'use gpu';
+ return sampleSdf(uv);
+ },
+ color: (uv) => {
+ 'use gpu';
+ return sampleColor(uv);
+ },
+});
+
+runner.run();
+```
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/package.json b/packages/typegpu-radiance-cascades/package.json
new file mode 100644
index 0000000000..714408c9ec
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@typegpu/radiance-cascades",
+ "version": "0.11.0",
+ "private": true,
+ "description": "Radiance Cascades implementation for TypeGPU",
+ "keywords": [],
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "directory": "dist",
+ "exports": {
+ "./package.json": "./dist/package.json",
+ ".": {
+ "types": "./dist/index.d.ts",
+ "module": "./dist/index.mjs",
+ "import": "./dist/index.mjs",
+ "default": "./dist/index.cjs"
+ }
+ },
+ "linkDirectory": false,
+ "types": "./dist/index.d.ts"
+ },
+ "scripts": {
+ "build": "unbuild",
+ "test:types": "pnpm tsc --p ./tsconfig.json --noEmit",
+ "prepublishOnly": "tgpu-dev-cli prepack"
+ },
+ "devDependencies": {
+ "@typegpu/tgpu-dev-cli": "workspace:*",
+ "@webgpu/types": "catalog:types",
+ "typegpu": "workspace:*",
+ "typescript": "catalog:types",
+ "unbuild": "catalog:build",
+ "unplugin-typegpu": "workspace:*"
+ },
+ "peerDependencies": {
+ "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..cc2f473f90
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/src/cascades.ts
@@ -0,0 +1,245 @@
+import * as d from 'typegpu/data';
+import * as std from 'typegpu/std';
+import tgpu from 'typegpu';
+
+const ERODE_BIAS = 2;
+
+export function getCascadeDim(width: number, height: number) {
+ const aspect = width / height;
+ const diagonal = Math.sqrt(width ** 2 + height ** 2);
+
+ const minPow2 = 16;
+ const closestPowerOfTwo = Math.max(minPow2, 2 ** Math.floor(Math.log2(diagonal)));
+
+ 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 = 2.0;
+
+ const minCascades = 5;
+ const cascadeAmount = Math.max(
+ minCascades,
+ Math.ceil(Math.log2((maxIntervalStart * 3) / interval + 1) / 2),
+ );
+
+ return [cascadeDimX, cascadeDimY, cascadeAmount] as const;
+}
+
+export const sdfSlot = tgpu.slot<(uv: d.v2f) => number>();
+export const colorSlot = tgpu.slot<(uv: d.v2f) => d.v3f>();
+
+// Slot for SDF resolution to calculate proper texel-based eps/minStep (so we don't do redundant sub-texel steps)
+export const sdfResolutionSlot = tgpu.slot();
+
+export const RayMarchResult = d.struct({
+ color: d.vec3f,
+ transmittance: d.f32, // 1.0 = no hit, 0.0 = fully opaque hit
+});
+
+export const defaultRayMarch = tgpu.fn(
+ [d.vec2f, d.vec2f, d.f32, d.f32, d.f32, d.f32, d.f32],
+ RayMarchResult,
+)((probePos, rayDir, startT, endT, eps, minStep, bias) => {
+ 'use gpu';
+ let rgb = d.vec3f();
+ let T = d.f32(1);
+ let t = startT;
+ let hitPos = d.vec2f();
+ let didHit = false;
+
+ for (let step = 0; step < 64; step++) {
+ if (t > endT) {
+ break;
+ }
+ const pos = probePos + rayDir * t;
+ if (std.any(std.lt(pos, d.vec2f(0))) || std.any(std.gt(pos, d.vec2f(1)))) {
+ break;
+ }
+
+ const dist = std.max(sdfSlot.$(pos) + bias, 0);
+ if (dist <= eps) {
+ hitPos = d.vec2f(pos);
+ didHit = true;
+ T = 0;
+ break;
+ }
+ t += std.max(dist, minStep);
+ }
+
+ if (didHit) {
+ rgb = colorSlot.$(hitPos);
+ }
+
+ return RayMarchResult({ color: rgb, transmittance: T });
+});
+
+export const rayMarchSlot = tgpu.slot(defaultRayMarch);
+
+export const CascadeStaticParams = d.struct({
+ baseProbes: d.vec2u,
+ cascadeDim: d.vec2u,
+ cascadeCount: d.u32,
+});
+
+export const cascadePassBGL = tgpu.bindGroupLayout({
+ staticParams: { uniform: CascadeStaticParams },
+ layer: { uniform: d.u32 },
+ upper: { texture: d.texture2d() },
+ upperSampler: { sampler: 'filtering' },
+ dst: { storageTexture: d.textureStorage2d('rgba16float') },
+});
+
+export const cascadePassCompute = tgpu.computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ 'use gpu';
+ const dim2 = std.textureDimensions(cascadePassBGL.$.dst);
+ if (gid.x >= dim2.x || gid.y >= dim2.y) {
+ return;
+ }
+
+ const params = cascadePassBGL.$.staticParams;
+ const layer = cascadePassBGL.$.layer;
+ const probes = std.max(
+ d.vec2u(params.baseProbes.x >> layer, params.baseProbes.y >> layer),
+ d.vec2u(1, 1),
+ );
+
+ const dirStored = gid.xy / probes;
+ const probe = gid.xy % probes;
+ const raysDimStored = d.u32(2) << layer;
+ const raysDimActual = raysDimStored * 2;
+ const rayCountActual = d.f32(raysDimActual) ** 2;
+
+ 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 probePos = (d.vec2f(probe) + 0.5) / d.vec2f(probes);
+ const aspect = params.baseProbes.x / params.baseProbes.y;
+ const cascadeProbesMinVal = d.f32(std.min(params.baseProbes.x, params.baseProbes.y));
+ const interval0 = 1 / cascadeProbesMinVal;
+ const pow4 = d.f32(d.u32(1) << (layer * 2));
+ const startUv = (interval0 * (pow4 - 1)) / 3;
+ const endUv = startUv + interval0 * pow4;
+
+ const sdfDim = sdfResolutionSlot.$;
+ const texelSizeMin = 1.0 / d.f32(std.max(std.min(sdfDim.x, sdfDim.y), 1));
+ // Use texel size as minimum threshold to avoid sub-texel stepping
+ const eps = std.max(texelSizeMin, 0.25 / cascadeProbesMinVal);
+ const minStep = std.max(texelSizeMin * 0.5, 0.125 / cascadeProbesMinVal);
+ const biasUv = d.f32(ERODE_BIAS) / cascadeProbesMinVal;
+
+ let accum = d.vec4f();
+
+ for (let i = 0; i < 4; i++) {
+ const dirActual = dirStored * 2 + d.vec2u(i & 1, i >> 1);
+ const rayIndex = d.f32(dirActual.y * raysDimActual + dirActual.x) + 0.5;
+ const angle = (rayIndex / rayCountActual) * (Math.PI * 2) - Math.PI;
+ const cosA = std.cos(angle);
+ const sinA = -std.sin(angle);
+ let rayDir = d.vec2f(cosA, sinA);
+ if (aspect >= 1) {
+ rayDir = d.vec2f(cosA / aspect, sinA);
+ } else {
+ rayDir = d.vec2f(cosA, sinA * aspect);
+ }
+
+ const marchResult = rayMarchSlot.$(probePos, rayDir, startUv, endUv, eps, minStep, biasUv);
+ let rgb = d.vec3f(marchResult.color);
+ let T = d.f32(marchResult.transmittance);
+
+ if (layer < params.cascadeCount - 1 && T > 0.01) {
+ const probesU = std.max(d.vec2u(probes.x >> 1, probes.y >> 1), d.vec2u(1));
+ const tileOrigin = d.vec2f(dirActual) * d.vec2f(probesU);
+ const probePixel = std.clamp(
+ probePos * d.vec2f(probesU),
+ d.vec2f(0.5),
+ d.vec2f(probesU) - 0.5,
+ );
+ const uvU = (tileOrigin + probePixel) / d.vec2f(dim2);
+
+ const upper = std.textureSampleLevel(
+ cascadePassBGL.$.upper,
+ cascadePassBGL.$.upperSampler,
+ uvU,
+ 0,
+ );
+ rgb = rgb + upper.xyz * T;
+ T *= upper.w;
+ }
+
+ accum += d.vec4f(rgb, T);
+ }
+
+ std.textureStore(cascadePassBGL.$.dst, gid.xy, accum * 0.25);
+});
+
+export const BuildRadianceFieldParams = d.struct({
+ outputProbes: d.vec2u,
+ cascadeProbes: d.vec2u,
+});
+
+export const buildRadianceFieldBGL = tgpu.bindGroupLayout({
+ params: { uniform: BuildRadianceFieldParams },
+ src: { texture: d.texture2d() },
+ srcSampler: { sampler: 'filtering' },
+ dst: { storageTexture: d.textureStorage2d('rgba16float') },
+});
+
+export const buildRadianceFieldCompute = tgpu.computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ 'use gpu';
+ const dim2 = std.textureDimensions(buildRadianceFieldBGL.$.dst);
+ if (gid.x >= dim2.x || gid.y >= dim2.y) {
+ return;
+ }
+
+ const params = buildRadianceFieldBGL.$.params;
+ const cascadeDim = params.cascadeProbes * 2;
+
+ const invCascadeDim = 1 / d.vec2f(cascadeDim);
+ const uv = (d.vec2f(gid.xy) + 0.5) / d.vec2f(params.outputProbes);
+
+ const probePixel = std.clamp(
+ uv * d.vec2f(params.cascadeProbes),
+ d.vec2f(0.5),
+ d.vec2f(params.cascadeProbes) - 0.5,
+ );
+
+ const uvStride = d.vec2f(params.cascadeProbes) * invCascadeDim;
+ const baseSampleUV = probePixel * invCascadeDim;
+
+ let sum = d.vec3f();
+ for (let i = d.u32(0); i < 4; i++) {
+ const offset = d.vec2f(i & 1, i >> 1) * uvStride;
+ const sample = std.textureSampleLevel(
+ buildRadianceFieldBGL.$.src,
+ buildRadianceFieldBGL.$.srcSampler,
+ baseSampleUV + offset,
+ 0,
+ );
+ sum = sum + sample.xyz;
+ }
+
+ const avg = sum * 0.25;
+ const res = d.vec3f(avg);
+
+ std.textureStore(buildRadianceFieldBGL.$.dst, gid.xy, d.vec4f(res, 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..63100d7437
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/src/index.ts
@@ -0,0 +1,11 @@
+export { createRadianceCascades } from './runner.ts';
+export type { RadianceCascadesExecutor } from './runner.ts';
+export {
+ colorSlot,
+ defaultRayMarch,
+ getCascadeDim,
+ RayMarchResult,
+ rayMarchSlot,
+ sdfResolutionSlot,
+ sdfSlot,
+} 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..8953f53c8d
--- /dev/null
+++ b/packages/typegpu-radiance-cascades/src/runner.ts
@@ -0,0 +1,235 @@
+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,
+ cascadePassBGL,
+ cascadePassCompute,
+ CascadeStaticParams,
+ colorSlot,
+ defaultRayMarch,
+ getCascadeDim,
+ type RayMarchResult,
+ rayMarchSlot,
+ sdfResolutionSlot,
+ sdfSlot,
+} from './cascades.ts';
+
+type OutputTexture =
+ | (TgpuTexture<{ size: [number, number]; format: 'rgba16float' }> & StorageFlag)
+ | TgpuTextureView>;
+
+type CascadesOptions = {
+ root: TgpuRoot;
+ sdf: (uv: d.v2f) => number;
+ color: (uv: d.v2f) => d.v3f;
+ sdfResolution: { width: number; height: number };
+ rayMarch?: (
+ probePos: d.v2f,
+ rayDir: d.v2f,
+ startT: number,
+ endT: number,
+ eps: number,
+ minStep: number,
+ bias: number,
+ ) => d.InferGPU;
+ output?: OutputTexture;
+ size?: { width: number; height: number };
+};
+
+type OutputTextureProp = TgpuTexture<{
+ size: [number, number];
+ format: 'rgba16float';
+}> &
+ StorageFlag &
+ SampledFlag;
+
+export type RadianceCascadesExecutor = {
+ run(): void;
+ with(bindGroup: TgpuBindGroup): RadianceCascadesExecutor;
+ destroy(): void;
+ readonly output: OutputTextureProp;
+};
+
+export function createRadianceCascades(options: CascadesOptions): RadianceCascadesExecutor {
+ const { root, sdf, color, sdfResolution, output, size, rayMarch } = 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 or use provided output texture
+ const dst = hasOutputProvided
+ ? output
+ : root
+ .createTexture({
+ size: [outputWidth, outputHeight],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled');
+
+ const ownsOutput = !hasOutputProvided;
+
+ const [cascadeDimX, cascadeDimY, cascadeAmount] = getCascadeDim(outputWidth, outputHeight);
+
+ const cascadeProbesX = cascadeDimX / 2;
+ const cascadeProbesY = cascadeDimY / 2;
+
+ const cascadeTextureA = root
+ .createTexture({
+ size: [cascadeDimX, cascadeDimY, cascadeAmount],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled');
+
+ const cascadeTextureB = root
+ .createTexture({
+ size: [cascadeDimX, cascadeDimY, cascadeAmount],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled');
+
+ const cascadeSampler = root.createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+ });
+
+ const staticParamsBuffer = root
+ .createBuffer(CascadeStaticParams, {
+ baseProbes: [cascadeProbesX, cascadeProbesY],
+ cascadeDim: [cascadeDimX, cascadeDimY],
+ cascadeCount: cascadeAmount,
+ })
+ .$usage('uniform');
+
+ const layerBuffer = root.createBuffer(d.u32).$usage('uniform');
+
+ const cascadePassPipeline = root
+ .with(sdfResolutionSlot, d.vec2u(sdfResolution.width, sdfResolution.height))
+ .with(sdfSlot, sdf)
+ .with(colorSlot, color)
+ .with(rayMarchSlot, rayMarch ?? defaultRayMarch)
+ .createComputePipeline({ compute: cascadePassCompute });
+
+ 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, {
+ staticParams: staticParamsBuffer,
+ layer: layerBuffer,
+ upper: srcTexture.createView(d.texture2d(d.f32), {
+ baseArrayLayer: Math.min(layer + 1, cascadeAmount - 1),
+ arrayLayerCount: 1,
+ }),
+ upperSampler: cascadeSampler,
+ dst: dstTexture.createView(d.textureStorage2d('rgba16float'), {
+ baseArrayLayer: layer,
+ arrayLayerCount: 1,
+ }),
+ });
+ });
+
+ const buildRadianceFieldPipeline = root.createComputePipeline({
+ compute: buildRadianceFieldCompute,
+ });
+
+ const radianceFieldParamsBuffer = root
+ .createBuffer(BuildRadianceFieldParams, {
+ outputProbes: [outputWidth, outputHeight],
+ cascadeProbes: [cascadeProbesX, cascadeProbesY],
+ })
+ .$usage('uniform');
+
+ const cascade0InA = (cascadeAmount - 1) % 2 === 0;
+ const srcCascadeTexture = cascade0InA ? cascadeTextureA : cascadeTextureB;
+
+ const buildRadianceFieldBG = root.createBindGroup(buildRadianceFieldBGL, {
+ params: radianceFieldParamsBuffer,
+ src: srcCascadeTexture.createView(d.texture2d(d.f32), {
+ baseArrayLayer: 0,
+ arrayLayerCount: 1,
+ }),
+ srcSampler: cascadeSampler,
+ dst,
+ });
+
+ 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();
+ if (ownsOutput && isTexture(dst)) {
+ dst.destroy();
+ }
+ }
+
+ function createExecutor(additionalBindGroups: TgpuBindGroup[] = []): RadianceCascadesExecutor {
+ const prebuiltCascadePipelines = cascadePassBindGroups.map((bg) => {
+ let p = cascadePassPipeline.with(bg);
+ for (const addBg of additionalBindGroups) {
+ p = p.with(addBg);
+ }
+ return p;
+ });
+
+ let prebuiltRadiancePipeline = buildRadianceFieldPipeline.with(buildRadianceFieldBG);
+ for (const bg of additionalBindGroups) {
+ prebuiltRadiancePipeline = prebuiltRadiancePipeline.with(bg);
+ }
+
+ function run() {
+ for (let layer = cascadeAmount - 1; layer >= 0; layer--) {
+ layerBuffer.write(layer);
+ prebuiltCascadePipelines[layer]?.dispatchWorkgroups(cascadeWorkgroupsX, cascadeWorkgroupsY);
+ }
+
+ prebuiltRadiancePipeline.dispatchWorkgroups(outputWorkgroupsX, outputWorkgroupsY);
+ }
+
+ return {
+ run,
+ with: (bg) => createExecutor([...additionalBindGroups, bg]),
+ destroy,
+ output: dst as OutputTextureProp,
+ };
+ }
+
+ return createExecutor();
+}
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 c4c594f691..97c2bb6ef7 100644
--- a/packages/typegpu-sdf/src/index.ts
+++ b/packages/typegpu-sdf/src/index.ts
@@ -18,3 +18,6 @@ export {
opSmoothUnion,
opUnion,
} from './operators.ts';
+
+export { classifySlot, createJumpFlood } from './jumpFlood.ts';
+export * as JumpFlood 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..d7ad5bb16c
--- /dev/null
+++ b/packages/typegpu-sdf/src/jumpFlood.ts
@@ -0,0 +1,436 @@
+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 = 0xffff;
+
+const pingPongLayout = tgpu.bindGroupLayout({
+ readView: {
+ storageTexture: d.textureStorage2d('rgba16uint', 'read-only'),
+ },
+ writeView: {
+ storageTexture: d.textureStorage2d('rgba16uint', 'write-only'),
+ },
+});
+
+const initLayout = tgpu.bindGroupLayout({
+ writeView: {
+ storageTexture: d.textureStorage2d('rgba16uint', 'write-only'),
+ },
+});
+
+const distWriteLayout = tgpu.bindGroupLayout({
+ sdfTexture: {
+ storageTexture: d.textureStorage2d('rgba16float', 'write-only'),
+ },
+ colorTexture: {
+ storageTexture: d.textureStorage2d('rgba8unorm', 'write-only'),
+ },
+});
+
+const finalizeReadLayout = tgpu.bindGroupLayout({
+ readView: {
+ storageTexture: d.textureStorage2d('rgba16uint', 'read-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).
+ */
+export const classifySlot = tgpu.slot<(coord: d.v2u, size: d.v2u) => boolean>();
+
+/** Slot for SDF getter - returns the signed distance value to store. */
+const sdfSlot =
+ tgpu.slot<
+ (coord: d.v2u, size: d.v2u, signedDist: number, insidePx: d.v2u, outsidePx: d.v2u) => number
+ >();
+
+/** Slot for color getter - returns the color value to store. */
+const colorSlot =
+ tgpu.slot<
+ (coord: d.v2u, size: d.v2u, signedDist: number, insidePx: d.v2u, outsidePx: d.v2u) => d.v4f
+ >();
+
+const sampleWithOffset = (
+ tex: d.textureStorage2d<'rgba16uint', 'read-only'>,
+ dims: d.v2u,
+ pos: d.v2i,
+ offset: d.v2i,
+) => {
+ 'use gpu';
+ 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);
+
+ if (outOfBounds) {
+ return d.vec4u(INVALID_COORD);
+ }
+
+ return std.textureLoad(tex, samplePos);
+};
+
+const offsetAccessor = tgpu.accessor(d.i32);
+
+const initFromSeedCompute = tgpu.computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ const size = std.textureDimensions(initLayout.$.writeView);
+ if (gid.x >= size.x || gid.y >= size.y) {
+ 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.computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ 'use gpu';
+ const size = std.textureDimensions(pingPongLayout.$.readView);
+ if (gid.x >= size.x || gid.y >= size.y) {
+ return;
+ }
+
+ const offset = offsetAccessor.$;
+ const pos = d.vec2i(gid.xy);
+
+ const invalid = d.vec2u(INVALID_COORD);
+ let bestInsideCoord = d.vec2u(invalid);
+ let bestOutsideCoord = d.vec2u(invalid);
+
+ let bestInsideDist2 = d.i32(2147483647);
+ let bestOutsideDist2 = d.i32(2147483647);
+
+ for (const dy of tgpu.unroll([-1, 0, 1])) {
+ for (const dx of tgpu.unroll([-1, 0, 1])) {
+ const sample = sampleWithOffset(
+ pingPongLayout.$.readView,
+ size,
+ pos,
+ d.vec2i(dx, dy) * offset,
+ );
+
+ if (sample.x !== INVALID_COORD) {
+ const deltaIn = pos - d.vec2i(sample.xy);
+ const dist2 = deltaIn.x * deltaIn.x + deltaIn.y * deltaIn.y;
+
+ if (dist2 < bestInsideDist2) {
+ bestInsideDist2 = dist2;
+ bestInsideCoord = d.vec2u(sample.xy);
+ }
+ }
+
+ if (sample.z !== INVALID_COORD) {
+ const deltaOut = pos - d.vec2i(sample.zw);
+ const dist2 = deltaOut.x * deltaOut.x + deltaOut.y * deltaOut.y;
+
+ if (dist2 < bestOutsideDist2) {
+ bestOutsideDist2 = dist2;
+ bestOutsideCoord = d.vec2u(sample.zw);
+ }
+ }
+ }
+ }
+
+ std.textureStore(
+ pingPongLayout.$.writeView,
+ d.vec2i(gid.xy),
+ d.vec4u(bestInsideCoord, bestOutsideCoord),
+ );
+});
+
+// Runs a final JFA pass at offset=1 and immediately computes the signed distance
+const finalizeCompute = tgpu.computeFn({
+ workgroupSize: [8, 8],
+ in: { gid: d.builtin.globalInvocationId },
+})(({ gid }) => {
+ 'use gpu';
+ const size = std.textureDimensions(finalizeReadLayout.$.readView);
+ if (gid.x >= size.x || gid.y >= size.y) {
+ return;
+ }
+
+ const pos = d.vec2i(gid.xy);
+ const invalid = d.vec2u(INVALID_COORD);
+ let bestInsideCoord = d.vec2u(invalid);
+ let bestOutsideCoord = d.vec2u(invalid);
+ let bestInsideDist2 = d.i32(2147483647);
+ let bestOutsideDist2 = d.i32(2147483647);
+
+ for (const dy of tgpu.unroll([-1, 0, 1])) {
+ for (const dx of tgpu.unroll([-1, 0, 1])) {
+ const sample = sampleWithOffset(finalizeReadLayout.$.readView, size, pos, d.vec2i(dx, dy));
+
+ if (sample.x !== INVALID_COORD) {
+ const deltaIn = pos - d.vec2i(sample.xy);
+ const dist2 = deltaIn.x * deltaIn.x + deltaIn.y * deltaIn.y;
+
+ if (dist2 < bestInsideDist2) {
+ bestInsideDist2 = dist2;
+ bestInsideCoord = d.vec2u(sample.xy);
+ }
+ }
+
+ if (sample.z !== INVALID_COORD) {
+ const deltaOut = pos - d.vec2i(sample.zw);
+ const dist2 = deltaOut.x * deltaOut.x + deltaOut.y * deltaOut.y;
+
+ if (dist2 < bestOutsideDist2) {
+ bestOutsideDist2 = dist2;
+ bestOutsideCoord = d.vec2u(sample.zw);
+ }
+ }
+ }
+ }
+
+ const posF = d.vec2f(gid.xy);
+ let insideDist = d.f32(3.4 * 10 ** 38);
+ let outsideDist = d.f32(3.4 * 10 ** 38);
+
+ if (bestInsideCoord.x !== INVALID_COORD) {
+ insideDist = std.distance(posF, d.vec2f(bestInsideCoord));
+ }
+
+ if (bestOutsideCoord.x !== INVALID_COORD) {
+ outsideDist = std.distance(posF, d.vec2f(bestOutsideCoord));
+ }
+
+ const signedDist = insideDist - outsideDist;
+ const sdfValue = sdfSlot.$(gid.xy, size, signedDist, bestInsideCoord, bestOutsideCoord);
+ const colorValue = colorSlot.$(gid.xy, size, signedDist, bestInsideCoord, bestOutsideCoord);
+
+ std.textureStore(distWriteLayout.$.sdfTexture, d.vec2i(gid.xy), d.vec4f(sdfValue, 0, 0, 0));
+ std.textureStore(distWriteLayout.$.colorTexture, d.vec2i(gid.xy), colorValue);
+});
+
+export type SdfTexture = TgpuTexture<{
+ size: [number, number];
+ format: 'rgba16float';
+}> &
+ StorageFlag &
+ SampledFlag;
+
+export type ColorTexture = TgpuTexture<{
+ size: [number, number];
+ format: 'rgba8unorm';
+}> &
+ StorageFlag &
+ SampledFlag;
+
+export type Executor = {
+ /** Run the jump flood algorithm. */
+ run(): void;
+ /** The SDF output texture (r32float). */
+ readonly sdfOutput: SdfTexture;
+ /** The color output texture (rgba8unorm). */
+ readonly colorOutput: ColorTexture;
+ /**
+ * Returns a new executor with the additional bind group attached.
+ * Use this to pass resources needed by custom classify or getter functions.
+ */
+ with(bindGroup: TgpuBindGroup): Executor;
+ /** Clean up GPU resources created by this executor. */
+ destroy(): void;
+};
+
+type JumpFloodOptions = {
+ 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;
+ /**
+ * Get the SDF value to store. Receives signed distance in pixels.
+ */
+ getSdf: (
+ coord: d.v2u,
+ size: d.v2u,
+ signedDist: number,
+ insidePx: d.v2u,
+ outsidePx: d.v2u,
+ ) => number;
+ /**
+ * Get the color value to store.
+ */
+ getColor: (
+ coord: d.v2u,
+ size: d.v2u,
+ signedDist: number,
+ insidePx: d.v2u,
+ outsidePx: d.v2u,
+ ) => d.v4f;
+};
+
+/**
+ * Create a Jump Flood Algorithm executor with separate SDF and color output textures.
+ */
+export function createJumpFlood(options: JumpFloodOptions): Executor {
+ const { root, size, classify, getSdf, getColor } = options;
+ const { width, height } = size;
+
+ // Create output textures
+ const sdfTexture = root
+ .createTexture({
+ size: [width, height],
+ format: 'rgba16float',
+ })
+ .$usage('storage', 'sampled');
+
+ const colorTexture = root
+ .createTexture({
+ size: [width, height],
+ format: 'rgba8unorm',
+ })
+ .$usage('storage', 'sampled');
+
+ // Create flood textures
+ const floodTextureA = root
+ .createTexture({
+ size: [width, height],
+ format: 'rgba16uint',
+ })
+ .$usage('storage');
+
+ const floodTextureB = root
+ .createTexture({
+ size: [width, height],
+ format: 'rgba16uint',
+ })
+ .$usage('storage');
+
+ const offsetUniform = root.createUniform(d.i32);
+
+ const initFromSeedPipeline = root
+ .with(classifySlot, classify)
+ .createComputePipeline({ compute: initFromSeedCompute });
+
+ const jumpFloodPipeline = root
+ .with(offsetAccessor, offsetUniform)
+ .createComputePipeline({ compute: jumpFloodCompute });
+
+ const finalizePipeline = root
+ .with(sdfSlot, getSdf)
+ .with(colorSlot, getColor)
+ .createComputePipeline({ compute: finalizeCompute });
+
+ // Create bind groups
+ const initBG = root.createBindGroup(initLayout, {
+ writeView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'write-only')),
+ });
+
+ const pingPongBGs = [
+ root.createBindGroup(pingPongLayout, {
+ readView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'read-only')),
+ writeView: floodTextureB.createView(d.textureStorage2d('rgba16uint', 'write-only')),
+ }),
+ root.createBindGroup(pingPongLayout, {
+ readView: floodTextureB.createView(d.textureStorage2d('rgba16uint', 'read-only')),
+ writeView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'write-only')),
+ }),
+ ];
+
+ const distWriteBG = root.createBindGroup(distWriteLayout, {
+ sdfTexture: sdfTexture.createView(d.textureStorage2d('rgba16float', 'write-only')),
+ colorTexture: colorTexture.createView(d.textureStorage2d('rgba8unorm', 'write-only')),
+ });
+
+ const finalizeReadBGs = [
+ root.createBindGroup(finalizeReadLayout, {
+ readView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'read-only')),
+ }),
+ root.createBindGroup(finalizeReadLayout, {
+ readView: floodTextureB.createView(d.textureStorage2d('rgba16uint', 'read-only')),
+ }),
+ ];
+
+ const workgroupsX = Math.ceil(width / 8);
+ const workgroupsY = Math.ceil(height / 8);
+ const maxDim = Math.max(width, height);
+
+ // Largest power-of-two strictly less than maxDim.
+ const maxRange = 2 ** Math.floor(Math.log2(Math.max(maxDim - 1, 1)));
+
+ function destroy() {
+ floodTextureA.destroy();
+ floodTextureB.destroy();
+ sdfTexture.destroy();
+ colorTexture.destroy();
+ }
+
+ function createExecutor(additionalBindGroups: TgpuBindGroup[] = []): Executor {
+ // Pre-cache pipeline+bindgroup combos to avoid re-chaining per frame.
+ let prebuiltInitPipeline = initFromSeedPipeline.with(initBG);
+ for (const bg of additionalBindGroups) {
+ prebuiltInitPipeline = prebuiltInitPipeline.with(bg);
+ }
+
+ const prebuiltFloodPipelines = pingPongBGs.map((bg) => {
+ let p = jumpFloodPipeline.with(bg);
+ for (const addBg of additionalBindGroups) {
+ p = p.with(addBg);
+ }
+ return p;
+ });
+
+ const prebuiltFinalizePipelines = finalizeReadBGs.map((bg) => {
+ let p = finalizePipeline.with(bg).with(distWriteBG);
+ for (const addBg of additionalBindGroups) {
+ p = p.with(addBg);
+ }
+ return p;
+ });
+
+ function run() {
+ prebuiltInitPipeline.dispatchWorkgroups(workgroupsX, workgroupsY);
+
+ let sourceIdx = 0;
+ let offset = maxRange;
+
+ while (offset >= 1) {
+ offsetUniform.write(offset);
+ prebuiltFloodPipelines[sourceIdx]?.dispatchWorkgroups(workgroupsX, workgroupsY);
+ sourceIdx ^= 1;
+ offset = Math.floor(offset / 2);
+ }
+
+ // Finalize: JFA+1 at offset=1 fused with distance field output
+ prebuiltFinalizePipelines[sourceIdx]?.dispatchWorkgroups(workgroupsX, workgroupsY);
+ }
+
+ return {
+ run,
+ with: (bindGroup) => createExecutor([...additionalBindGroups, bindGroup]),
+ destroy,
+ sdfOutput: sdfTexture,
+ colorOutput: colorTexture,
+ };
+ }
+
+ return createExecutor();
+}
diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts
index f21cff1bf3..18a872cc92 100644
--- a/packages/typegpu/src/indexNamedExports.ts
+++ b/packages/typegpu/src/indexNamedExports.ts
@@ -15,7 +15,7 @@ export {
export { isBuffer, isUsableAsVertex } from './core/buffer/buffer.ts';
export { isAccessor, isLazy, isMutableAccessor, 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 } from './core/texture/usageExtension.ts';
export { isUsableAsStorage } from './extension.ts';
export { isUsableAsUniform } from './core/buffer/bufferUsage.ts';
diff --git a/packages/typegpu/src/tgsl/accessProp.ts b/packages/typegpu/src/tgsl/accessProp.ts
index b264a154ba..6c07cffc5e 100644
--- a/packages/typegpu/src/tgsl/accessProp.ts
+++ b/packages/typegpu/src/tgsl/accessProp.ts
@@ -176,11 +176,10 @@ export function accessProp(target: Snippet, propName: string): Snippet | undefin
return accessProp(derefed, propName);
}
- if (isVec(target.dataType)) {
- // Example: d.vec3f().kind === 'vec3f'
- if (propName === 'kind') {
- return snip(target.dataType.type, UnknownData, 'constant');
- }
+ // Example: d.vec3f().kind === 'vec3f'
+ // We are not a struct here so it's okey
+ if (propName === 'kind' && target.dataType !== UnknownData) {
+ return snip(target.dataType.type, UnknownData, 'constant');
}
const propLength = propName.length;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bae32d359d..335a8179fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -247,6 +247,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
@@ -681,6 +684,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: npm:tsover@^5.9.11
+ version: tsover@5.9.11
+ unbuild:
+ specifier: catalog:build
+ version: 3.5.0(tsover@5.9.11)
+ unplugin-typegpu:
+ specifier: workspace:*
+ version: link:../unplugin-typegpu
+ publishDirectory: dist
+
packages/typegpu-sdf:
devDependencies:
'@typegpu/tgpu-dev-cli':