From eac8d16e33bf7cd6cc21b831a5d4a1524eabacfe Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 16 Dec 2025 15:37:39 +0100 Subject: [PATCH 1/9] init --- .../rendering/radiance-compute/index.html | 1 + .../rendering/radiance-compute/index.ts | 670 ++++++++++++++++++ .../rendering/radiance-compute/meta.json | 5 + 3 files changed, 676 insertions(+) create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-compute/meta.json diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html new file mode 100644 index 0000000000..aa8cc321b3 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts new file mode 100644 index 0000000000..79db569e7b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -0,0 +1,670 @@ +import tgpu from 'typegpu'; +import { fullScreenTriangle } from 'typegpu/common'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import * as sdf from '@typegpu/sdf'; + +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, + alphaMode: 'opaque', +}); + +const dim = 2048; +const cascadeAmount = Math.log2(dim / 8); +const baseProbes = dim / 8; +const baseRaysDim = dim / baseProbes; + +const cascadesTexture = root['~unstable'] + .createTexture({ + size: [dim, dim, cascadeAmount], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + +const mergedTexture = root['~unstable'] + .createTexture({ + size: [dim, dim, cascadeAmount - 1], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + +const mergeBindGroupLayout = tgpu.bindGroupLayout({ + t1: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, + t2: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, + dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, +}); + +const mergeBindGroups = Array.from( + { length: cascadeAmount - 1 }, + (_, n) => { + const layer = cascadeAmount - 2 - n; + + return root.createBindGroup(mergeBindGroupLayout, { + t1: cascadesTexture.createView( + d.textureStorage2d('rgba16float', 'read-only'), + { baseArrayLayer: layer, arrayLayerCount: 1 }, + ), + t2: (layer === cascadeAmount - 2 ? cascadesTexture : mergedTexture) + .createView( + d.textureStorage2d('rgba16float', 'read-only'), + { baseArrayLayer: layer + 1, arrayLayerCount: 1 }, + ), + dst: mergedTexture.createView( + d.textureStorage2d('rgba16float', 'write-only'), + { baseArrayLayer: layer, arrayLayerCount: 1 }, + ), + }); + }, +); + +const writeView = cascadesTexture.createView( + d.textureStorage2dArray('rgba16float', 'write-only'), +); +const renderView = cascadesTexture.createView(d.texture2dArray()); + +const nearestSampler = root['~unstable'].createSampler({ + magFilter: 'nearest', + minFilter: 'nearest', +}); + +const radianceFieldTex = root['~unstable'] + .createTexture({ + size: [baseProbes, baseProbes], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + +const radianceFieldView = radianceFieldTex.createView(d.texture2d()); + +const radianceFieldStoreView = radianceFieldTex.createView( + d.textureStorage2d('rgba16float', 'write-only'), +); + +const mergedCascade0RO = mergedTexture.createView( + d.textureStorage2d('rgba16float', 'read-only'), + { baseArrayLayer: 0, arrayLayerCount: 1 }, +); + +const buildRadianceFieldBGL = tgpu.bindGroupLayout({ + src: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, + dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, +}); + +const buildRadianceFieldBG = root.createBindGroup(buildRadianceFieldBGL, { + src: mergedCascade0RO, + dst: radianceFieldStoreView, +}); + +const radianceSampler = root['~unstable'].createSampler({ + magFilter: 'linear', + minFilter: 'linear', +}); + +const indexUniform = root.createUniform(d.u32, 0); +const raysDimUniform = root.createUniform(d.f32, baseRaysDim); +const mousePosUniform = root.createUniform(d.vec2f); +const lightPosUniform = root.createUniform(d.vec2f); +const pickerPosUniform = root.createUniform(d.vec2f); +const mergeRaysDimUniform = root.createUniform(d.u32, baseRaysDim); + +let currentCascadeIndex = 0; +let lightPos = { x: 0.5, y: 0.5 }; +let pickerPos = { x: 0.5, y: 0.5 }; + +lightPosUniform.write(d.vec2f(lightPos.x, lightPos.y)); +pickerPosUniform.write(d.vec2f(pickerPos.x, pickerPos.y)); + +const dirToAngle = (dir: d.v2f) => { + 'use gpu'; + const angle = std.atan2(-dir.y, dir.x); + const pi = d.f32(Math.PI); + const tau = d.f32(Math.PI * 2); + const t = (angle + pi) / tau; + return t; +}; + +const posToDir = (localPos: d.v2u, raysDim: number) => { + 'use gpu'; + const ix = d.f32(localPos.x); + const iy = d.f32(localPos.y); + const rd = d.f32(raysDim); + + const rayIndex = iy * rd + ix + 0.5; + const rayCount = rd * rd; + + const pi = d.f32(Math.PI); + const tau = d.f32(Math.PI * 2); + const angle = (rayIndex / rayCount) * tau - pi; + return d.vec2f(std.cos(angle), -std.sin(angle)); +}; + +const LocalPos = d.struct({ + probeCoord: d.vec2u, + local: d.vec2u, +}); + +const globalToLocal = (gid: d.v2u, raysDim: number) => { + 'use gpu'; + const probeCoord = d.vec2u(gid.x / raysDim, gid.y / raysDim); + const local = d.vec2u(gid.x % raysDim, gid.y % raysDim); + return LocalPos({ probeCoord, local }); +}; + +const sceneOccluderSDF = (p: d.v2f) => { + 'use gpu'; + const occluder1 = sdf.sdBox2d(p.sub(d.vec2f(0.3, 0.3)), d.vec2f(0.08, 0.15)); + const occluder2 = sdf.sdBox2d(p.sub(d.vec2f(0.7, 0.6)), d.vec2f(0.12, 0.08)); + const occluder3 = sdf.sdDisk(p.sub(d.vec2f(0.5, 0.75)), d.f32(0.1)); + return std.min(std.min(occluder1, occluder2), occluder3); +}; + +const debugFrag = tgpu['~unstable'].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + const color = std.textureSample( + renderView.$, + nearestSampler.$, + uv, + indexUniform.$, + ); + + if (std.distance(lightPosUniform.$, uv) < 0.06) { + return d.vec4f(1, 0, 0, 1); + } + if (std.distance(pickerPosUniform.$, uv) < 0.006) { + return d.vec4f(0, 0, 1, 1); + } + + const raysDim = raysDimUniform.$; + + const dir = std.normalize(mousePosUniform.$.sub(pickerPosUniform.$)); + const t = dirToAngle(dir); + + const raysPerProbe = raysDim * raysDim; + let rayIndex = std.floor(t * raysPerProbe); + rayIndex = std.min(rayIndex, raysPerProbe - 1); + + const iy = std.floor(rayIndex / raysDim); + const ix = rayIndex - iy * raysDim; + + const cpPx = std.floor(pickerPosUniform.$.mul(dim)); + const probeOrigin = std.floor(cpPx.div(raysDim)).mul(raysDim); + const highlightPx = probeOrigin.add(d.vec2f(ix, iy)); + + const fragPx = std.floor(uv.mul(dim)); + + if (fragPx.x === highlightPx.x && fragPx.y === highlightPx.y) { + return d.vec4f(1, 1, 0, 1); + } + + return color; +}); + +const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + if (gid.x >= baseProbes || gid.y >= baseProbes) { + return; + } + + const raysDim = d.u32(baseRaysDim); + const probe = d.vec2u(gid.x, gid.y); + + let sum = d.vec3f(0.0); + + let y = d.u32(0); + while (y < raysDim) { + let x = d.u32(0); + while (x < raysDim) { + const atlasPx = probe.mul(raysDim).add(d.vec2u(x, y)); + const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); + sum = sum.add(ray.xyz); + x = x + d.u32(1); + } + y = y + d.u32(1); + } + + const rayCount = d.f32(raysDim * raysDim); + const avg = sum.div(rayCount); + + std.textureStore(buildRadianceFieldBGL.$.dst, probe, d.vec4f(avg, 1.0)); +}); + +const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) + .xyz; + + // Add emissive light disk + const lightRadiusUv = 0.05; + const aa = d.f32(0.75) / d.f32(dim); + + const lightDist = sdf.sdDisk(uv.sub(lightPosUniform.$), lightRadiusUv); + const lightMask = d.f32(1.0) - std.smoothstep(d.f32(0.0), aa, lightDist); + const light = d.vec3f(lightMask); + + // Draw occluders + const occluderDist = sceneOccluderSDF(uv); + const occluderMask = d.f32(1.0) - + std.smoothstep(d.f32(0.0), aa, occluderDist); + + // Composite: radiance field + light, then occluders on top + let outRgb = std.min(field.add(light), d.vec3f(1.0)); + outRgb = std.mix(outRgb, d.vec3f(0.0), occluderMask); + + return d.vec4f(outRgb, 1.0); +}); + +const rayMarchCompute = tgpu['~unstable'].computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + if (gid.x >= dim || gid.y >= dim) { + return; + } + + const lightPos = lightPosUniform.$; + const lightRadiusUv = 0.05; + const lightColor = d.vec3f(1); + + const interval0Px = d.f32(baseRaysDim); + + let probes = d.u32(baseProbes); + let textureIndex = d.u32(0); + + while (probes > 1) { + const raysDim = d.u32(dim) / probes; + + const lp = globalToLocal(gid.xy, raysDim); + const dir = posToDir(lp.local, raysDim); + + const probePos = d.vec2f(lp.probeCoord).add(0.5).div(d.f32(probes)); + + // Radiance interval shell for this cascade: [t_i, t_{i+1}] + // t_i = L0 * (4^i - 1) / 3, length_i = L0 * 4^i + const c = d.f32(textureIndex); + const pow4 = std.pow(d.f32(4.0), c); + + const startPx = interval0Px * (pow4 - d.f32(1.0)) / d.f32(3.0); + const lengthPx = interval0Px * pow4; + const startUv = startPx / d.f32(dim); + const endUv = (startPx + lengthPx) / d.f32(dim); + + let rgb = d.vec3f(); + // Transmittance: 1 = transparent (miss), 0 = blocked (hit) + let T = d.f32(1); + + let t = startUv; + + const eps = d.f32(0.5) / d.f32(dim); + const minStep = d.f32(0.25) / d.f32(dim); + let step = d.u32(0); + const maxSteps = d.u32(96); + + while (step < maxSteps) { + if (t > endUv) { + break; + } + + const p = probePos.add(dir.mul(t)); + + if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) { + rgb = d.vec3f(0.0); + T = d.f32(0.0); + break; + } + + // Check occluders first + const occluderDist = sceneOccluderSDF(p); + + if (occluderDist <= eps) { + rgb = d.vec3f(0.0); + T = d.f32(0.0); + break; + } + + // Check light source + const lightDist = sdf.sdDisk(p.sub(lightPos), lightRadiusUv); + + if (lightDist <= eps) { + rgb = d.vec3f(lightColor); + T = d.f32(0.0); + break; + } + + // Use minimum distance for sphere tracing + const dist = std.min(occluderDist, lightDist); + t = t + std.max(dist, minStep); + step = step + d.u32(1); + } + + const cascadeIndex = d.u32(textureIndex); + std.textureStore(writeView.$, gid.xy, cascadeIndex, d.vec4f(rgb, T)); + + probes = probes / d.u32(2); + textureIndex = textureIndex + d.u32(1); + } +}); + +// --- Merge compute shader --- +// Merge cascade N (t1) with already-merged cascade N+1 (t2), output to dst. +// - probes(N+1) = probes(N) / 2 -> bilinear between 4 surrounding probes +// - raysDim(N+1) = raysDim(N) * 2 -> average 4 consecutive child rays (1D angular) +const mergeCompute = tgpu['~unstable'].computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + if (gid.x >= dim || gid.y >= dim) { + return; + } + + const nDim = mergeRaysDimUniform.$; // raysDim for cascade N (u32) + const uDim = nDim * d.u32(2); // raysDim for cascade N+1 (u32) + + // Current ray (cascade N) + const rayN = std.textureLoad(mergeBindGroupLayout.$.t1, gid.xy); + + // If near interval is blocking (T ~= 0), it fully determines the result. + // T is stored in .w: 0 = blocked, 1 = transparent + if (rayN.w <= 0.001) { + std.textureStore(mergeBindGroupLayout.$.dst, gid.xy, rayN); + return; + } + + const probeCoordN = d.vec2u(gid.x / nDim, gid.y / nDim); + const localN = d.vec2u(gid.x % nDim, gid.y % nDim); + + // Upper probe grid sizes + const probesN = d.u32(dim) / nDim; + const probesU = probesN / d.u32(2); + + // Handle edge case when probesU is very small + const maxProbeIdx = std.max(probesU, d.u32(1)) - d.u32(1); + + // Base upper probe index and bilinear weights + const ux0 = std.min(probeCoordN.x / d.u32(2), maxProbeIdx); + const uy0 = std.min(probeCoordN.y / d.u32(2), maxProbeIdx); + + const ux1 = std.min(ux0 + d.u32(1), maxProbeIdx); + const uy1 = std.min(uy0 + d.u32(1), maxProbeIdx); + + const rx = probeCoordN.x % d.u32(2); + const ry = probeCoordN.y % d.u32(2); + + // Bilinear weights + const fx = (d.f32(rx) + d.f32(0.5)) * d.f32(0.5); + const fy = (d.f32(ry) + d.f32(0.5)) * d.f32(0.5); + + // 4:1 angular merge using 1D angular indexing + // Parent angular index in 1D order + const angN = localN.y * nDim + localN.x; + const childBase = angN * d.u32(4); + + // Bounds for clamping texture coordinates + const minCoord = d.vec2u(0, 0); + const maxCoord = d.vec2u(dim - 1, dim - 1); + + const originTL = d.vec2u(ux0 * uDim, uy0 * uDim); + const originTR = d.vec2u(ux1 * uDim, uy0 * uDim); + const originBL = d.vec2u(ux0 * uDim, uy1 * uDim); + const originBR = d.vec2u(ux1 * uDim, uy1 * uDim); + + // Sample 4 consecutive child rays for TL probe + const child0 = childBase; + const child1 = childBase + d.u32(1); + const child2 = childBase + d.u32(2); + const child3 = childBase + d.u32(3); + + const childLocal0 = d.vec2u(child0 % uDim, child0 / uDim); + const childLocal1 = d.vec2u(child1 % uDim, child1 / uDim); + const childLocal2 = d.vec2u(child2 % uDim, child2 / uDim); + const childLocal3 = d.vec2u(child3 % uDim, child3 / uDim); + + // TL probe + const rTL0 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTL.add(childLocal0), minCoord, maxCoord), + ); + const rTL1 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTL.add(childLocal1), minCoord, maxCoord), + ); + const rTL2 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTL.add(childLocal2), minCoord, maxCoord), + ); + const rTL3 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTL.add(childLocal3), minCoord, maxCoord), + ); + const avgTL = rTL0.add(rTL1).add(rTL2).add(rTL3).mul(0.25); + const tTL = (rTL0.w + rTL1.w + rTL2.w + rTL3.w) * d.f32(0.25); + const TL = d.vec4f(avgTL.xyz, tTL); + + // TR probe + const rTR0 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTR.add(childLocal0), minCoord, maxCoord), + ); + const rTR1 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTR.add(childLocal1), minCoord, maxCoord), + ); + const rTR2 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTR.add(childLocal2), minCoord, maxCoord), + ); + const rTR3 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originTR.add(childLocal3), minCoord, maxCoord), + ); + const avgTR = rTR0.add(rTR1).add(rTR2).add(rTR3).mul(0.25); + const tTR = (rTR0.w + rTR1.w + rTR2.w + rTR3.w) * d.f32(0.25); + const TR = d.vec4f(avgTR.xyz, tTR); + + // BL probe + const rBL0 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBL.add(childLocal0), minCoord, maxCoord), + ); + const rBL1 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBL.add(childLocal1), minCoord, maxCoord), + ); + const rBL2 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBL.add(childLocal2), minCoord, maxCoord), + ); + const rBL3 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBL.add(childLocal3), minCoord, maxCoord), + ); + const avgBL = rBL0.add(rBL1).add(rBL2).add(rBL3).mul(0.25); + const tBL = (rBL0.w + rBL1.w + rBL2.w + rBL3.w) * d.f32(0.25); + const BL = d.vec4f(avgBL.xyz, tBL); + + // BR probe + const rBR0 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBR.add(childLocal0), minCoord, maxCoord), + ); + const rBR1 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBR.add(childLocal1), minCoord, maxCoord), + ); + const rBR2 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBR.add(childLocal2), minCoord, maxCoord), + ); + const rBR3 = std.textureLoad( + mergeBindGroupLayout.$.t2, + std.clamp(originBR.add(childLocal3), minCoord, maxCoord), + ); + const avgBR = rBR0.add(rBR1).add(rBR2).add(rBR3).mul(0.25); + const tBR = (rBR0.w + rBR1.w + rBR2.w + rBR3.w) * d.f32(0.25); + const BR = d.vec4f(avgBR.xyz, tBR); + + // Bilinear interpolation in probe space + const one = d.f32(1.0); + const top = TL.mul(one - fx).add(TR.mul(fx)); + const bot = BL.mul(one - fx).add(BR.mul(fx)); + const upper = top.mul(one - fy).add(bot.mul(fy)); + + // Merge: near interval transmits (T > 0), so add far interval's radiance + // weighted by near's transmittance + const outRgb = rayN.xyz.add(upper.xyz.mul(rayN.w)); + const outT = rayN.w * upper.w; + std.textureStore(mergeBindGroupLayout.$.dst, gid.xy, d.vec4f(outRgb, outT)); +}); + +const rayMarchPipeline = root['~unstable'] + .withCompute(rayMarchCompute) + .createPipeline(); + +const mergePipeline = root['~unstable'] + .withCompute(mergeCompute) + .createPipeline(); + +const buildRadianceFieldPipeline = root['~unstable'] + .withCompute(buildRadianceFieldCompute) + .createPipeline(); + +function buildRadianceField() { + buildRadianceFieldPipeline + .with(buildRadianceFieldBG) + .dispatchWorkgroups( + Math.ceil(baseProbes / 8), + Math.ceil(baseProbes / 8), + ); +} + +function mergeAllCascades() { + for (let n = 0; n < mergeBindGroups.length; n++) { + const layer = cascadeAmount - 2 - n; + const probes = baseProbes >> layer; + const raysDim = dim / probes; + + mergeRaysDimUniform.write(raysDim); + + mergePipeline + .with(mergeBindGroups[n]) + .dispatchWorkgroups(Math.ceil(dim / 8), Math.ceil(dim / 8)); + } +} + +function updateLighting() { + rayMarchPipeline.dispatchWorkgroups(Math.ceil(dim / 8), Math.ceil(dim / 8)); + mergeAllCascades(); + buildRadianceField(); +} +updateLighting(); + +const renderPipeline = root['~unstable'] + .withVertex(fullScreenTriangle) + .withFragment(finalRadianceFieldFrag, { format: presentationFormat }) + .createPipeline(); + +let frameId: number; +function frame() { + renderPipeline + .withColorAttachment({ + view: context.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store', + }) + .draw(3); + frameId = requestAnimationFrame(frame); +} +frameId = requestAnimationFrame(frame); + +function snapPickerToProbeCenter(uv: { x: number; y: number }, index: number) { + const probes = baseProbes >> index; + const raysDim = dim / probes; + + const px = Math.max(0, Math.min(dim - 1, Math.floor(uv.x * dim))); + const py = Math.max(0, Math.min(dim - 1, Math.floor(uv.y * dim))); + + const bx = Math.floor(px / raysDim); + const by = Math.floor(py / raysDim); + + const cx = bx * raysDim + Math.floor(raysDim / 2); + const cy = by * raysDim + Math.floor(raysDim / 2); + + return { + x: (cx + 0.5) / dim, + y: (cy + 0.5) / dim, + }; +} + +function setCascadeIndex(index: number) { + const idx = Math.max(0, Math.min(cascadeAmount - 1, Math.floor(index))); + currentCascadeIndex = idx; + + const probes = baseProbes >> idx; + const raysDim = dim / probes; + indexUniform.write(idx); + raysDimUniform.write(raysDim); + + const snapped = snapPickerToProbeCenter(pickerPos, idx); + pickerPos = snapped; + pickerPosUniform.write(d.vec2f(pickerPos.x, pickerPos.y)); +} + +canvas.addEventListener('click', (event) => { + const rect = canvas.getBoundingClientRect(); + const x = (event.clientX - rect.left) / rect.width; + const y = (event.clientY - rect.top) / rect.height; + + if (event.shiftKey) { + const snapped = snapPickerToProbeCenter({ x, y }, currentCascadeIndex); + pickerPos = snapped; + pickerPosUniform.write(d.vec2f(pickerPos.x, pickerPos.y)); + return; + } + + lightPos = { x, y }; + lightPosUniform.write(d.vec2f(lightPos.x, lightPos.y)); + updateLighting(); +}); + +canvas.addEventListener('mousemove', (event) => { + const rect = canvas.getBoundingClientRect(); + const x = (event.clientX - rect.left) / rect.width; + const y = (event.clientY - rect.top) / rect.height; + mousePosUniform.write(d.vec2f(x, y)); + + if (event.buttons === 1) { + lightPos = { x, y }; + lightPosUniform.write(d.vec2f(lightPos.x, lightPos.y)); + updateLighting(); + } +}); + +export const controls = { + 'index': { + initial: 0, + min: 0, + max: cascadeAmount - 1, + step: 1, + onSliderChange: (value: number) => { + setCascadeIndex(value); + }, + }, +}; + +export function onCleanup() { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + root.destroy(); +} diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/meta.json b/apps/typegpu-docs/src/examples/rendering/radiance-compute/meta.json new file mode 100644 index 0000000000..04c7353026 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Compute Cascades", + "category": "rendering", + "tags": ["experimental", "3d"] +} From f75dd66f0d3e22b689adf527f1cfba679239ff88 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 16 Dec 2025 16:42:57 +0100 Subject: [PATCH 2/9] first pass of fixes and simplifications --- .../rendering/radiance-compute/index.ts | 244 ++++++++---------- 1 file changed, 104 insertions(+), 140 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index 79db569e7b..c6e9a82425 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -17,7 +17,7 @@ context.configure({ const dim = 2048; const cascadeAmount = Math.log2(dim / 8); -const baseProbes = dim / 8; +const baseProbes = dim / 4; const baseRaysDim = dim / baseProbes; const cascadesTexture = root['~unstable'] @@ -106,20 +106,17 @@ const radianceSampler = root['~unstable'].createSampler({ minFilter: 'linear', }); +let currentCascadeIndex = 0; +let lightPos = d.vec2f(0.5); +let pickerPos = d.vec2f(0.5); + const indexUniform = root.createUniform(d.u32, 0); const raysDimUniform = root.createUniform(d.f32, baseRaysDim); const mousePosUniform = root.createUniform(d.vec2f); -const lightPosUniform = root.createUniform(d.vec2f); -const pickerPosUniform = root.createUniform(d.vec2f); +const lightPosUniform = root.createUniform(d.vec2f, lightPos); +const pickerPosUniform = root.createUniform(d.vec2f, pickerPos); const mergeRaysDimUniform = root.createUniform(d.u32, baseRaysDim); -let currentCascadeIndex = 0; -let lightPos = { x: 0.5, y: 0.5 }; -let pickerPos = { x: 0.5, y: 0.5 }; - -lightPosUniform.write(d.vec2f(lightPos.x, lightPos.y)); -pickerPosUniform.write(d.vec2f(pickerPos.x, pickerPos.y)); - const dirToAngle = (dir: d.v2f) => { 'use gpu'; const angle = std.atan2(-dir.y, dir.x); @@ -156,56 +153,68 @@ const globalToLocal = (gid: d.v2u, raysDim: number) => { return LocalPos({ probeCoord, local }); }; -const sceneOccluderSDF = (p: d.v2f) => { +const SceneResult = d.struct({ + dist: d.f32, + color: d.vec3f, +}); + +const sceneSDF = (p: d.v2f) => { 'use gpu'; const occluder1 = sdf.sdBox2d(p.sub(d.vec2f(0.3, 0.3)), d.vec2f(0.08, 0.15)); const occluder2 = sdf.sdBox2d(p.sub(d.vec2f(0.7, 0.6)), d.vec2f(0.12, 0.08)); const occluder3 = sdf.sdDisk(p.sub(d.vec2f(0.5, 0.75)), d.f32(0.1)); - return std.min(std.min(occluder1, occluder2), occluder3); + const minOccluder = std.min(occluder1, occluder2, occluder3); + const light = sdf.sdDisk(p.sub(lightPosUniform.$), d.f32(0.05)); + + if (light < minOccluder && minOccluder > 0) { + return SceneResult({ dist: light, color: d.vec3f(1) }); + } else { + return SceneResult({ dist: minOccluder, color: d.vec3f(0) }); + } }; -const debugFrag = tgpu['~unstable'].fragmentFn({ - in: { uv: d.vec2f }, - out: d.vec4f, -})(({ uv }) => { - const color = std.textureSample( - renderView.$, - nearestSampler.$, - uv, - indexUniform.$, - ); +// const debugFrag = tgpu['~unstable'].fragmentFn({ +// in: { uv: d.vec2f }, +// out: d.vec4f, +// })(({ uv }) => { +// const color = std.textureSample( +// renderView.$, +// nearestSampler.$, +// uv, +// indexUniform.$, +// ); - if (std.distance(lightPosUniform.$, uv) < 0.06) { - return d.vec4f(1, 0, 0, 1); - } - if (std.distance(pickerPosUniform.$, uv) < 0.006) { - return d.vec4f(0, 0, 1, 1); - } +// if (std.distance(lightPosUniform.$, uv) < 0.06) { +// return d.vec4f(1, 0, 0, 1); +// } +// if (std.distance(pickerPosUniform.$, uv) < 0.006) { +// return d.vec4f(0, 0, 1, 1); +// } - const raysDim = raysDimUniform.$; +// const raysDim = raysDimUniform.$; - const dir = std.normalize(mousePosUniform.$.sub(pickerPosUniform.$)); - const t = dirToAngle(dir); +// const dir = std.normalize(mousePosUniform.$.sub(pickerPosUniform.$)); +// const t = dirToAngle(dir); - const raysPerProbe = raysDim * raysDim; - let rayIndex = std.floor(t * raysPerProbe); - rayIndex = std.min(rayIndex, raysPerProbe - 1); +// const raysPerProbe = raysDim * raysDim; +// let rayIndex = std.floor(t * raysPerProbe); +// rayIndex = std.min(rayIndex, raysPerProbe - 1); - const iy = std.floor(rayIndex / raysDim); - const ix = rayIndex - iy * raysDim; +// const iy = std.floor(rayIndex / raysDim); +// const ix = rayIndex - iy * raysDim; - const cpPx = std.floor(pickerPosUniform.$.mul(dim)); - const probeOrigin = std.floor(cpPx.div(raysDim)).mul(raysDim); - const highlightPx = probeOrigin.add(d.vec2f(ix, iy)); +// const cpPx = std.floor(pickerPosUniform.$.mul(dim)); +// const probeOrigin = std.floor(cpPx.div(raysDim)).mul(raysDim); +// const highlightPx = probeOrigin.add(d.vec2f(ix, iy)); - const fragPx = std.floor(uv.mul(dim)); +// const fragPx = std.floor(uv.mul(dim)); - if (fragPx.x === highlightPx.x && fragPx.y === highlightPx.y) { - return d.vec4f(1, 1, 0, 1); - } +// if (fragPx.x === highlightPx.x && fragPx.y === highlightPx.y) { +// return d.vec4f(1, 1, 0, 1); +// } - return color; -}); +// return color; +// }); const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], @@ -216,9 +225,9 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ } const raysDim = d.u32(baseRaysDim); - const probe = d.vec2u(gid.x, gid.y); + const probe = gid.xy; - let sum = d.vec3f(0.0); + let sum = d.vec3f(); let y = d.u32(0); while (y < raysDim) { @@ -227,9 +236,9 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ const atlasPx = probe.mul(raysDim).add(d.vec2u(x, y)); const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); sum = sum.add(ray.xyz); - x = x + d.u32(1); + x += 1; } - y = y + d.u32(1); + y += 1; } const rayCount = d.f32(raysDim * raysDim); @@ -244,24 +253,7 @@ const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ })(({ uv }) => { const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) .xyz; - - // Add emissive light disk - const lightRadiusUv = 0.05; - const aa = d.f32(0.75) / d.f32(dim); - - const lightDist = sdf.sdDisk(uv.sub(lightPosUniform.$), lightRadiusUv); - const lightMask = d.f32(1.0) - std.smoothstep(d.f32(0.0), aa, lightDist); - const light = d.vec3f(lightMask); - - // Draw occluders - const occluderDist = sceneOccluderSDF(uv); - const occluderMask = d.f32(1.0) - - std.smoothstep(d.f32(0.0), aa, occluderDist); - - // Composite: radiance field + light, then occluders on top - let outRgb = std.min(field.add(light), d.vec3f(1.0)); - outRgb = std.mix(outRgb, d.vec3f(0.0), occluderMask); - + const outRgb = std.saturate(field); return d.vec4f(outRgb, 1.0); }); @@ -273,17 +265,12 @@ const rayMarchCompute = tgpu['~unstable'].computeFn({ return; } - const lightPos = lightPosUniform.$; - const lightRadiusUv = 0.05; - const lightColor = d.vec3f(1); - const interval0Px = d.f32(baseRaysDim); - let probes = d.u32(baseProbes); let textureIndex = d.u32(0); while (probes > 1) { - const raysDim = d.u32(dim) / probes; + const raysDim = d.u32(dim / probes); const lp = globalToLocal(gid.xy, raysDim); const dir = posToDir(lp.local, raysDim); @@ -293,23 +280,22 @@ const rayMarchCompute = tgpu['~unstable'].computeFn({ // Radiance interval shell for this cascade: [t_i, t_{i+1}] // t_i = L0 * (4^i - 1) / 3, length_i = L0 * 4^i const c = d.f32(textureIndex); - const pow4 = std.pow(d.f32(4.0), c); + const pow4 = std.pow(4, c); - const startPx = interval0Px * (pow4 - d.f32(1.0)) / d.f32(3.0); + const startPx = interval0Px * (pow4 - 1) / 3; const lengthPx = interval0Px * pow4; const startUv = startPx / d.f32(dim); const endUv = (startPx + lengthPx) / d.f32(dim); let rgb = d.vec3f(); - // Transmittance: 1 = transparent (miss), 0 = blocked (hit) let T = d.f32(1); let t = startUv; - const eps = d.f32(0.5) / d.f32(dim); - const minStep = d.f32(0.25) / d.f32(dim); + const eps = 0.5 / dim; + const minStep = 0.25 / dim; let step = d.u32(0); - const maxSteps = d.u32(96); + const maxSteps = d.u32(32); while (step < maxSteps) { if (t > endUv) { @@ -317,49 +303,26 @@ const rayMarchCompute = tgpu['~unstable'].computeFn({ } const p = probePos.add(dir.mul(t)); + const occluderDist = sceneSDF(p); - if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) { - rgb = d.vec3f(0.0); - T = d.f32(0.0); - break; - } - - // Check occluders first - const occluderDist = sceneOccluderSDF(p); - - if (occluderDist <= eps) { - rgb = d.vec3f(0.0); - T = d.f32(0.0); - break; - } - - // Check light source - const lightDist = sdf.sdDisk(p.sub(lightPos), lightRadiusUv); - - if (lightDist <= eps) { - rgb = d.vec3f(lightColor); - T = d.f32(0.0); + if (occluderDist.dist <= eps) { + rgb = d.vec3f(occluderDist.color); + T = d.f32(0); break; } - // Use minimum distance for sphere tracing - const dist = std.min(occluderDist, lightDist); - t = t + std.max(dist, minStep); - step = step + d.u32(1); + t += std.max(occluderDist.dist, minStep); + step += 1; } - const cascadeIndex = d.u32(textureIndex); + const cascadeIndex = textureIndex; std.textureStore(writeView.$, gid.xy, cascadeIndex, d.vec4f(rgb, T)); - probes = probes / d.u32(2); - textureIndex = textureIndex + d.u32(1); + probes /= 2; + textureIndex += 1; } }); -// --- Merge compute shader --- -// Merge cascade N (t1) with already-merged cascade N+1 (t2), output to dst. -// - probes(N+1) = probes(N) / 2 -> bilinear between 4 surrounding probes -// - raysDim(N+1) = raysDim(N) * 2 -> average 4 consecutive child rays (1D angular) const mergeCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, @@ -390,30 +353,31 @@ const mergeCompute = tgpu['~unstable'].computeFn({ // Handle edge case when probesU is very small const maxProbeIdx = std.max(probesU, d.u32(1)) - d.u32(1); + const maxProbeIdxF = d.f32(maxProbeIdx); - // Base upper probe index and bilinear weights - const ux0 = std.min(probeCoordN.x / d.u32(2), maxProbeIdx); - const uy0 = std.min(probeCoordN.y / d.u32(2), maxProbeIdx); + // Map fine probe coord -> coarse probe coord in index space. + // Because probes are cell-centered, coarse centers are offset by 0.5 fine cell: + // u = (x_f * probesU) - 0.5 == (probeCoordN + 0.5)/2 - 0.5 == (probeCoordN - 0.5)/2 + const uxf = (d.f32(probeCoordN.x) - d.f32(0.5)) * d.f32(0.5); + const uyf = (d.f32(probeCoordN.y) - d.f32(0.5)) * d.f32(0.5); + + const ux0f = std.clamp(std.floor(uxf), d.f32(0.0), maxProbeIdxF); + const uy0f = std.clamp(std.floor(uyf), d.f32(0.0), maxProbeIdxF); + + const ux0 = d.u32(ux0f); + const uy0 = d.u32(uy0f); const ux1 = std.min(ux0 + d.u32(1), maxProbeIdx); const uy1 = std.min(uy0 + d.u32(1), maxProbeIdx); - const rx = probeCoordN.x % d.u32(2); - const ry = probeCoordN.y % d.u32(2); - - // Bilinear weights - const fx = (d.f32(rx) + d.f32(0.5)) * d.f32(0.5); - const fy = (d.f32(ry) + d.f32(0.5)) * d.f32(0.5); + const fx = std.clamp(uxf - d.f32(ux0), d.f32(0.0), d.f32(1.0)); + const fy = std.clamp(uyf - d.f32(uy0), d.f32(0.0), d.f32(1.0)); // 4:1 angular merge using 1D angular indexing // Parent angular index in 1D order const angN = localN.y * nDim + localN.x; const childBase = angN * d.u32(4); - // Bounds for clamping texture coordinates - const minCoord = d.vec2u(0, 0); - const maxCoord = d.vec2u(dim - 1, dim - 1); - const originTL = d.vec2u(ux0 * uDim, uy0 * uDim); const originTR = d.vec2u(ux1 * uDim, uy0 * uDim); const originBL = d.vec2u(ux0 * uDim, uy1 * uDim); @@ -433,19 +397,19 @@ const mergeCompute = tgpu['~unstable'].computeFn({ // TL probe const rTL0 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTL.add(childLocal0), minCoord, maxCoord), + originTL.add(childLocal0), ); const rTL1 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTL.add(childLocal1), minCoord, maxCoord), + originTL.add(childLocal1), ); const rTL2 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTL.add(childLocal2), minCoord, maxCoord), + originTL.add(childLocal2), ); const rTL3 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTL.add(childLocal3), minCoord, maxCoord), + originTL.add(childLocal3), ); const avgTL = rTL0.add(rTL1).add(rTL2).add(rTL3).mul(0.25); const tTL = (rTL0.w + rTL1.w + rTL2.w + rTL3.w) * d.f32(0.25); @@ -454,19 +418,19 @@ const mergeCompute = tgpu['~unstable'].computeFn({ // TR probe const rTR0 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTR.add(childLocal0), minCoord, maxCoord), + originTR.add(childLocal0), ); const rTR1 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTR.add(childLocal1), minCoord, maxCoord), + originTR.add(childLocal1), ); const rTR2 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTR.add(childLocal2), minCoord, maxCoord), + originTR.add(childLocal2), ); const rTR3 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originTR.add(childLocal3), minCoord, maxCoord), + originTR.add(childLocal3), ); const avgTR = rTR0.add(rTR1).add(rTR2).add(rTR3).mul(0.25); const tTR = (rTR0.w + rTR1.w + rTR2.w + rTR3.w) * d.f32(0.25); @@ -475,19 +439,19 @@ const mergeCompute = tgpu['~unstable'].computeFn({ // BL probe const rBL0 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBL.add(childLocal0), minCoord, maxCoord), + originBL.add(childLocal0), ); const rBL1 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBL.add(childLocal1), minCoord, maxCoord), + originBL.add(childLocal1), ); const rBL2 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBL.add(childLocal2), minCoord, maxCoord), + originBL.add(childLocal2), ); const rBL3 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBL.add(childLocal3), minCoord, maxCoord), + originBL.add(childLocal3), ); const avgBL = rBL0.add(rBL1).add(rBL2).add(rBL3).mul(0.25); const tBL = (rBL0.w + rBL1.w + rBL2.w + rBL3.w) * d.f32(0.25); @@ -496,19 +460,19 @@ const mergeCompute = tgpu['~unstable'].computeFn({ // BR probe const rBR0 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBR.add(childLocal0), minCoord, maxCoord), + originBR.add(childLocal0), ); const rBR1 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBR.add(childLocal1), minCoord, maxCoord), + originBR.add(childLocal1), ); const rBR2 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBR.add(childLocal2), minCoord, maxCoord), + originBR.add(childLocal2), ); const rBR3 = std.textureLoad( mergeBindGroupLayout.$.t2, - std.clamp(originBR.add(childLocal3), minCoord, maxCoord), + originBR.add(childLocal3), ); const avgBR = rBR0.add(rBR1).add(rBR2).add(rBR3).mul(0.25); const tBR = (rBR0.w + rBR1.w + rBR2.w + rBR3.w) * d.f32(0.25); From 226589eeac5cb456a7ea85b067cc0c3d65795412 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 16 Dec 2025 18:18:28 +0100 Subject: [PATCH 3/9] clean up and simlify --- .../rendering/radiance-compute/index.ts | 319 ++++-------------- 1 file changed, 69 insertions(+), 250 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index c6e9a82425..5a33016c75 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -106,25 +106,12 @@ const radianceSampler = root['~unstable'].createSampler({ minFilter: 'linear', }); -let currentCascadeIndex = 0; -let lightPos = d.vec2f(0.5); -let pickerPos = d.vec2f(0.5); - const indexUniform = root.createUniform(d.u32, 0); const raysDimUniform = root.createUniform(d.f32, baseRaysDim); const mousePosUniform = root.createUniform(d.vec2f); -const lightPosUniform = root.createUniform(d.vec2f, lightPos); -const pickerPosUniform = root.createUniform(d.vec2f, pickerPos); +const lightPosUniform = root.createUniform(d.vec2f, d.vec2f(0.5)); const mergeRaysDimUniform = root.createUniform(d.u32, baseRaysDim); - -const dirToAngle = (dir: d.v2f) => { - 'use gpu'; - const angle = std.atan2(-dir.y, dir.x); - const pi = d.f32(Math.PI); - const tau = d.f32(Math.PI * 2); - const t = (angle + pi) / tau; - return t; -}; +const lightColorUniform = root.createUniform(d.vec3f, d.vec3f(1)); const posToDir = (localPos: d.v2u, raysDim: number) => { 'use gpu'; @@ -135,9 +122,7 @@ const posToDir = (localPos: d.v2u, raysDim: number) => { const rayIndex = iy * rd + ix + 0.5; const rayCount = rd * rd; - const pi = d.f32(Math.PI); - const tau = d.f32(Math.PI * 2); - const angle = (rayIndex / rayCount) * tau - pi; + const angle = (rayIndex / rayCount) * Math.PI * 2 - Math.PI; return d.vec2f(std.cos(angle), -std.sin(angle)); }; @@ -153,6 +138,21 @@ const globalToLocal = (gid: d.v2u, raysDim: number) => { return LocalPos({ probeCoord, local }); }; +const sampleProbeQuad = (origin: d.v2u, childBase: number, uDim: number) => { + 'use gpu'; + let sum = d.vec4f(); + for (let i = 0; i < 4; i++) { + const childIdx = childBase + i; + const childLocal = d.vec2u(childIdx % uDim, childIdx / uDim); + const ray = std.textureLoad( + mergeBindGroupLayout.$.t2, + origin.add(childLocal), + ); + sum = sum.add(ray); + } + return sum.mul(0.25); +}; + const SceneResult = d.struct({ dist: d.f32, color: d.vec3f, @@ -167,55 +167,12 @@ const sceneSDF = (p: d.v2f) => { const light = sdf.sdDisk(p.sub(lightPosUniform.$), d.f32(0.05)); if (light < minOccluder && minOccluder > 0) { - return SceneResult({ dist: light, color: d.vec3f(1) }); + return SceneResult({ dist: light, color: lightColorUniform.$ }); } else { return SceneResult({ dist: minOccluder, color: d.vec3f(0) }); } }; -// const debugFrag = tgpu['~unstable'].fragmentFn({ -// in: { uv: d.vec2f }, -// out: d.vec4f, -// })(({ uv }) => { -// const color = std.textureSample( -// renderView.$, -// nearestSampler.$, -// uv, -// indexUniform.$, -// ); - -// if (std.distance(lightPosUniform.$, uv) < 0.06) { -// return d.vec4f(1, 0, 0, 1); -// } -// if (std.distance(pickerPosUniform.$, uv) < 0.006) { -// return d.vec4f(0, 0, 1, 1); -// } - -// const raysDim = raysDimUniform.$; - -// const dir = std.normalize(mousePosUniform.$.sub(pickerPosUniform.$)); -// const t = dirToAngle(dir); - -// const raysPerProbe = raysDim * raysDim; -// let rayIndex = std.floor(t * raysPerProbe); -// rayIndex = std.min(rayIndex, raysPerProbe - 1); - -// const iy = std.floor(rayIndex / raysDim); -// const ix = rayIndex - iy * raysDim; - -// const cpPx = std.floor(pickerPosUniform.$.mul(dim)); -// const probeOrigin = std.floor(cpPx.div(raysDim)).mul(raysDim); -// const highlightPx = probeOrigin.add(d.vec2f(ix, iy)); - -// const fragPx = std.floor(uv.mul(dim)); - -// if (fragPx.x === highlightPx.x && fragPx.y === highlightPx.y) { -// return d.vec4f(1, 1, 0, 1); -// } - -// return color; -// }); - const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, @@ -331,161 +288,67 @@ const mergeCompute = tgpu['~unstable'].computeFn({ return; } - const nDim = mergeRaysDimUniform.$; // raysDim for cascade N (u32) - const uDim = nDim * d.u32(2); // raysDim for cascade N+1 (u32) + const nDim = mergeRaysDimUniform.$; + const uDim = nDim << 1; - // Current ray (cascade N) const rayN = std.textureLoad(mergeBindGroupLayout.$.t1, gid.xy); - - // If near interval is blocking (T ~= 0), it fully determines the result. - // T is stored in .w: 0 = blocked, 1 = transparent - if (rayN.w <= 0.001) { + if (rayN.w <= 0.01) { std.textureStore(mergeBindGroupLayout.$.dst, gid.xy, rayN); return; } - const probeCoordN = d.vec2u(gid.x / nDim, gid.y / nDim); - const localN = d.vec2u(gid.x % nDim, gid.y % nDim); + const localPosN = globalToLocal(gid.xy, nDim); - // Upper probe grid sizes - const probesN = d.u32(dim) / nDim; - const probesU = probesN / d.u32(2); + const probesN = d.u32(dim / nDim); + const probesU = probesN >> 1; - // Handle edge case when probesU is very small - const maxProbeIdx = std.max(probesU, d.u32(1)) - d.u32(1); + const maxProbeIdx = std.max(probesU, 1) - 1; const maxProbeIdxF = d.f32(maxProbeIdx); - // Map fine probe coord -> coarse probe coord in index space. - // Because probes are cell-centered, coarse centers are offset by 0.5 fine cell: - // u = (x_f * probesU) - 0.5 == (probeCoordN + 0.5)/2 - 0.5 == (probeCoordN - 0.5)/2 - const uxf = (d.f32(probeCoordN.x) - d.f32(0.5)) * d.f32(0.5); - const uyf = (d.f32(probeCoordN.y) - d.f32(0.5)) * d.f32(0.5); + const p = d.vec2f(localPosN.probeCoord); + const u = p.sub(d.vec2f(0.5)).mul(d.vec2f(0.5)); - const ux0f = std.clamp(std.floor(uxf), d.f32(0.0), maxProbeIdxF); - const uy0f = std.clamp(std.floor(uyf), d.f32(0.0), maxProbeIdxF); + const u0f = std.clamp( + std.floor(u), + d.vec2f(), + d.vec2f(maxProbeIdxF), + ); - const ux0 = d.u32(ux0f); - const uy0 = d.u32(uy0f); + const u1f = std.min( + u0f.add(d.vec2f(1)), + d.vec2f(maxProbeIdxF), + ); - const ux1 = std.min(ux0 + d.u32(1), maxProbeIdx); - const uy1 = std.min(uy0 + d.u32(1), maxProbeIdx); + const f = std.clamp( + u.sub(u0f), + d.vec2f(), + d.vec2f(1), + ); - const fx = std.clamp(uxf - d.f32(ux0), d.f32(0.0), d.f32(1.0)); - const fy = std.clamp(uyf - d.f32(uy0), d.f32(0.0), d.f32(1.0)); + const u0 = d.vec2u(u0f); + const u1 = d.vec2u(u1f); - // 4:1 angular merge using 1D angular indexing - // Parent angular index in 1D order - const angN = localN.y * nDim + localN.x; + const angN = localPosN.local.y * nDim + localPosN.local.x; const childBase = angN * d.u32(4); - const originTL = d.vec2u(ux0 * uDim, uy0 * uDim); - const originTR = d.vec2u(ux1 * uDim, uy0 * uDim); - const originBL = d.vec2u(ux0 * uDim, uy1 * uDim); - const originBR = d.vec2u(ux1 * uDim, uy1 * uDim); - - // Sample 4 consecutive child rays for TL probe - const child0 = childBase; - const child1 = childBase + d.u32(1); - const child2 = childBase + d.u32(2); - const child3 = childBase + d.u32(3); - - const childLocal0 = d.vec2u(child0 % uDim, child0 / uDim); - const childLocal1 = d.vec2u(child1 % uDim, child1 / uDim); - const childLocal2 = d.vec2u(child2 % uDim, child2 / uDim); - const childLocal3 = d.vec2u(child3 % uDim, child3 / uDim); - - // TL probe - const rTL0 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTL.add(childLocal0), - ); - const rTL1 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTL.add(childLocal1), - ); - const rTL2 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTL.add(childLocal2), - ); - const rTL3 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTL.add(childLocal3), - ); - const avgTL = rTL0.add(rTL1).add(rTL2).add(rTL3).mul(0.25); - const tTL = (rTL0.w + rTL1.w + rTL2.w + rTL3.w) * d.f32(0.25); - const TL = d.vec4f(avgTL.xyz, tTL); - - // TR probe - const rTR0 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTR.add(childLocal0), - ); - const rTR1 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTR.add(childLocal1), - ); - const rTR2 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTR.add(childLocal2), - ); - const rTR3 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originTR.add(childLocal3), - ); - const avgTR = rTR0.add(rTR1).add(rTR2).add(rTR3).mul(0.25); - const tTR = (rTR0.w + rTR1.w + rTR2.w + rTR3.w) * d.f32(0.25); - const TR = d.vec4f(avgTR.xyz, tTR); - - // BL probe - const rBL0 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBL.add(childLocal0), - ); - const rBL1 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBL.add(childLocal1), - ); - const rBL2 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBL.add(childLocal2), - ); - const rBL3 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBL.add(childLocal3), - ); - const avgBL = rBL0.add(rBL1).add(rBL2).add(rBL3).mul(0.25); - const tBL = (rBL0.w + rBL1.w + rBL2.w + rBL3.w) * d.f32(0.25); - const BL = d.vec4f(avgBL.xyz, tBL); - - // BR probe - const rBR0 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBR.add(childLocal0), - ); - const rBR1 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBR.add(childLocal1), - ); - const rBR2 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBR.add(childLocal2), - ); - const rBR3 = std.textureLoad( - mergeBindGroupLayout.$.t2, - originBR.add(childLocal3), + const s = d.vec2u(uDim, uDim); + + const originTL = d.vec2u(u0.x, u0.y).mul(s); + const originTR = d.vec2u(u1.x, u0.y).mul(s); + const originBL = d.vec2u(u0.x, u1.y).mul(s); + const originBR = d.vec2u(u1.x, u1.y).mul(s); + + const TL = sampleProbeQuad(originTL, childBase, uDim); + const TR = sampleProbeQuad(originTR, childBase, uDim); + const BL = sampleProbeQuad(originBL, childBase, uDim); + const BR = sampleProbeQuad(originBR, childBase, uDim); + + const upper = std.mix( + std.mix(TL, TR, f.x), + std.mix(BL, BR, f.x), + f.y, ); - const avgBR = rBR0.add(rBR1).add(rBR2).add(rBR3).mul(0.25); - const tBR = (rBR0.w + rBR1.w + rBR2.w + rBR3.w) * d.f32(0.25); - const BR = d.vec4f(avgBR.xyz, tBR); - - // Bilinear interpolation in probe space - const one = d.f32(1.0); - const top = TL.mul(one - fx).add(TR.mul(fx)); - const bot = BL.mul(one - fx).add(BR.mul(fx)); - const upper = top.mul(one - fy).add(bot.mul(fy)); - - // Merge: near interval transmits (T > 0), so add far interval's radiance - // weighted by near's transmittance + const outRgb = rayN.xyz.add(upper.xyz.mul(rayN.w)); const outT = rayN.w * upper.w; std.textureStore(mergeBindGroupLayout.$.dst, gid.xy, d.vec4f(outRgb, outT)); @@ -551,53 +414,12 @@ function frame() { } frameId = requestAnimationFrame(frame); -function snapPickerToProbeCenter(uv: { x: number; y: number }, index: number) { - const probes = baseProbes >> index; - const raysDim = dim / probes; - - const px = Math.max(0, Math.min(dim - 1, Math.floor(uv.x * dim))); - const py = Math.max(0, Math.min(dim - 1, Math.floor(uv.y * dim))); - - const bx = Math.floor(px / raysDim); - const by = Math.floor(py / raysDim); - - const cx = bx * raysDim + Math.floor(raysDim / 2); - const cy = by * raysDim + Math.floor(raysDim / 2); - - return { - x: (cx + 0.5) / dim, - y: (cy + 0.5) / dim, - }; -} - -function setCascadeIndex(index: number) { - const idx = Math.max(0, Math.min(cascadeAmount - 1, Math.floor(index))); - currentCascadeIndex = idx; - - const probes = baseProbes >> idx; - const raysDim = dim / probes; - indexUniform.write(idx); - raysDimUniform.write(raysDim); - - const snapped = snapPickerToProbeCenter(pickerPos, idx); - pickerPos = snapped; - pickerPosUniform.write(d.vec2f(pickerPos.x, pickerPos.y)); -} - canvas.addEventListener('click', (event) => { const rect = canvas.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; const y = (event.clientY - rect.top) / rect.height; - if (event.shiftKey) { - const snapped = snapPickerToProbeCenter({ x, y }, currentCascadeIndex); - pickerPos = snapped; - pickerPosUniform.write(d.vec2f(pickerPos.x, pickerPos.y)); - return; - } - - lightPos = { x, y }; - lightPosUniform.write(d.vec2f(lightPos.x, lightPos.y)); + lightPosUniform.write(d.vec2f(x, y)); updateLighting(); }); @@ -608,20 +430,17 @@ canvas.addEventListener('mousemove', (event) => { mousePosUniform.write(d.vec2f(x, y)); if (event.buttons === 1) { - lightPos = { x, y }; - lightPosUniform.write(d.vec2f(lightPos.x, lightPos.y)); + lightPosUniform.write(d.vec2f(x, y)); updateLighting(); } }); export const controls = { - 'index': { - initial: 0, - min: 0, - max: cascadeAmount - 1, - step: 1, - onSliderChange: (value: number) => { - setCascadeIndex(value); + 'Light Color': { + initial: [1, 1, 1], + onColorChange: (c: [number, number, number]) => { + lightColorUniform.write(d.vec3f(...c)); + updateLighting(); }, }, }; From 05d254a27617f78a810ed586a0beb823705f8764 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Wed, 7 Jan 2026 16:51:35 +0100 Subject: [PATCH 4/9] move to morton order and use hardware sampling --- .../rendering/radiance-compute/index.ts | 219 ++++++++++-------- 1 file changed, 122 insertions(+), 97 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index 5a33016c75..9b5d5dba58 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -12,13 +12,12 @@ const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device: root.device, format: presentationFormat, - alphaMode: 'opaque', }); const dim = 2048; -const cascadeAmount = Math.log2(dim / 8); const baseProbes = dim / 4; const baseRaysDim = dim / baseProbes; +const cascadeAmount = Math.log2(baseProbes); const cascadesTexture = root['~unstable'] .createTexture({ @@ -34,9 +33,17 @@ const mergedTexture = root['~unstable'] }) .$usage('storage', 'sampled'); +const mergeSampler = root['~unstable'].createSampler({ + magFilter: 'linear', + minFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', +}); + const mergeBindGroupLayout = tgpu.bindGroupLayout({ t1: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, - t2: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, + t2: { texture: d.texture2d(d.f32) }, + t2Sampler: { sampler: 'filtering' }, dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, }); @@ -52,9 +59,10 @@ const mergeBindGroups = Array.from( ), t2: (layer === cascadeAmount - 2 ? cascadesTexture : mergedTexture) .createView( - d.textureStorage2d('rgba16float', 'read-only'), + d.texture2d(d.f32), { baseArrayLayer: layer + 1, arrayLayerCount: 1 }, ), + t2Sampler: mergeSampler, dst: mergedTexture.createView( d.textureStorage2d('rgba16float', 'write-only'), { baseArrayLayer: layer, arrayLayerCount: 1 }, @@ -66,12 +74,6 @@ const mergeBindGroups = Array.from( const writeView = cascadesTexture.createView( d.textureStorage2dArray('rgba16float', 'write-only'), ); -const renderView = cascadesTexture.createView(d.texture2dArray()); - -const nearestSampler = root['~unstable'].createSampler({ - magFilter: 'nearest', - minFilter: 'nearest', -}); const radianceFieldTex = root['~unstable'] .createTexture({ @@ -106,25 +108,51 @@ const radianceSampler = root['~unstable'].createSampler({ minFilter: 'linear', }); -const indexUniform = root.createUniform(d.u32, 0); -const raysDimUniform = root.createUniform(d.f32, baseRaysDim); const mousePosUniform = root.createUniform(d.vec2f); const lightPosUniform = root.createUniform(d.vec2f, d.vec2f(0.5)); const mergeRaysDimUniform = root.createUniform(d.u32, baseRaysDim); -const lightColorUniform = root.createUniform(d.vec3f, d.vec3f(1)); +const lightColorUniform = root.createUniform(d.vec4f, d.vec4f(1)); + +// Z-order curve (Morton code) encoding/decoding for better cache efficiency +const spreadBits = tgpu.fn([d.u32], d.u32)((v) => { + let x = v; + x = (x | (x << 8)) & 0x00ff00ff; + x = (x | (x << 4)) & 0x0f0f0f0f; + x = (x | (x << 2)) & 0x33333333; + x = (x | (x << 1)) & 0x55555555; + return x; +}); -const posToDir = (localPos: d.v2u, raysDim: number) => { - 'use gpu'; - const ix = d.f32(localPos.x); - const iy = d.f32(localPos.y); - const rd = d.f32(raysDim); +const compactBits = tgpu.fn([d.u32], d.u32)((v) => { + let x = v & 0x55555555; + x = (x | (x >> 1)) & 0x33333333; + x = (x | (x >> 2)) & 0x0f0f0f0f; + x = (x | (x >> 4)) & 0x00ff00ff; + x = (x | (x >> 8)) & 0x0000ffff; + return x; +}); - const rayIndex = iy * rd + ix + 0.5; - const rayCount = rd * rd; +const mortonEncode2D = tgpu.fn([d.u32, d.u32], d.u32)((x, y) => { + return spreadBits(x) | (spreadBits(y) << 1); +}); - const angle = (rayIndex / rayCount) * Math.PI * 2 - Math.PI; +const mortonDecode2D = tgpu.fn([d.u32], d.vec2u)((code) => { + return d.vec2u(compactBits(code), compactBits(code >> 1)); +}); + +const linearToZOrder = tgpu.fn([d.u32], d.vec2u)((idx) => { + return mortonDecode2D(idx); +}); + +const zOrderToLinear = tgpu.fn([d.vec2u], d.u32)((pos) => { + return mortonEncode2D(pos.x, pos.y); +}); + +const indexToDir = tgpu.fn([d.u32, d.u32], d.vec2f)((mortonIdx, rayCount) => { + const rayIndex = d.f32(mortonIdx) + 0.5; + const angle = (rayIndex / d.f32(rayCount)) * Math.PI * 2 - Math.PI; return d.vec2f(std.cos(angle), -std.sin(angle)); -}; +}); const LocalPos = d.struct({ probeCoord: d.vec2u, @@ -138,19 +166,31 @@ const globalToLocal = (gid: d.v2u, raysDim: number) => { return LocalPos({ probeCoord, local }); }; -const sampleProbeQuad = (origin: d.v2u, childBase: number, uDim: number) => { +const getAtlasPos = (probeCoord: d.v2u, localRay: d.v2u, raysDim: number) => { 'use gpu'; - let sum = d.vec4f(); - for (let i = 0; i < 4; i++) { - const childIdx = childBase + i; - const childLocal = d.vec2u(childIdx % uDim, childIdx / uDim); - const ray = std.textureLoad( - mergeBindGroupLayout.$.t2, - origin.add(childLocal), - ); - sum = sum.add(ray); - } - return sum.mul(0.25); + return probeCoord.mul(d.u32(raysDim)).add(localRay); +}; + +const sampleProbeQuadFiltered = ( + probeCoord: d.v2u, + childBase: number, + uDim: number, +) => { + 'use gpu'; + const baseLocal = linearToZOrder(d.u32(childBase)); + // Compute center UV for the 2x2 quad + // The quad spans from baseLocal to baseLocal+(1,1), so center is baseLocal+(0.5,0.5) + const atlasBase = d.vec2f(probeCoord.mul(d.u32(uDim))).add( + d.vec2f(baseLocal), + ); + const centerUv = atlasBase.add(d.vec2f(1)).div(d.f32(dim)); + + return std.textureSampleLevel( + mergeBindGroupLayout.$.t2, + mergeBindGroupLayout.$.t2Sampler, + centerUv, + 0, + ); }; const SceneResult = d.struct({ @@ -167,10 +207,9 @@ const sceneSDF = (p: d.v2f) => { const light = sdf.sdDisk(p.sub(lightPosUniform.$), d.f32(0.05)); if (light < minOccluder && minOccluder > 0) { - return SceneResult({ dist: light, color: lightColorUniform.$ }); - } else { - return SceneResult({ dist: minOccluder, color: d.vec3f(0) }); + return SceneResult({ dist: light, color: lightColorUniform.$.xyz }); } + return SceneResult({ dist: minOccluder, color: d.vec3f(0) }); }; const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ @@ -183,27 +222,32 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ const raysDim = d.u32(baseRaysDim); const probe = gid.xy; + const rayCount = raysDim * raysDim; let sum = d.vec3f(); - - let y = d.u32(0); - while (y < raysDim) { - let x = d.u32(0); - while (x < raysDim) { - const atlasPx = probe.mul(raysDim).add(d.vec2u(x, y)); - const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); - sum = sum.add(ray.xyz); - x += 1; - } - y += 1; + let idx = d.u32(0); + while (idx < rayCount) { + const localRay = linearToZOrder(idx); + const atlasPx = getAtlasPos(probe, localRay, baseRaysDim); + const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); + sum = sum.add(ray.xyz); + idx += 1; } - const rayCount = d.f32(raysDim * raysDim); - const avg = sum.div(rayCount); - + const avg = sum.div(d.f32(rayCount)); std.textureStore(buildRadianceFieldBGL.$.dst, probe, d.vec4f(avg, 1.0)); }); +const ACESFilm = tgpu.fn([d.vec3f], d.vec3f)((x) => { + const a = 2.51; + const b = 0.03; + const c = 2.43; + const dVal = 0.59; + const e = 0.01; + const res = x.mul(x.mul(a).add(b)).div(x.mul(x.mul(c).add(dVal)).add(e)); + return std.saturate(res); +}); + const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ in: { uv: d.vec2f }, out: d.vec4f, @@ -211,7 +255,7 @@ const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) .xyz; const outRgb = std.saturate(field); - return d.vec4f(outRgb, 1.0); + return d.vec4f(ACESFilm(outRgb), 1.0); }); const rayMarchCompute = tgpu['~unstable'].computeFn({ @@ -227,17 +271,18 @@ const rayMarchCompute = tgpu['~unstable'].computeFn({ let textureIndex = d.u32(0); while (probes > 1) { - const raysDim = d.u32(dim / probes); + const raysDim = d.u32(dim) / probes; + const raysDimU = d.u32(raysDim); + const lp = globalToLocal(gid.xy, raysDimU); - const lp = globalToLocal(gid.xy, raysDim); - const dir = posToDir(lp.local, raysDim); + const mortonIdx = zOrderToLinear(lp.local); + const rayCount = raysDimU * raysDimU; + const dir = indexToDir(mortonIdx, rayCount); const probePos = d.vec2f(lp.probeCoord).add(0.5).div(d.f32(probes)); - // Radiance interval shell for this cascade: [t_i, t_{i+1}] - // t_i = L0 * (4^i - 1) / 3, length_i = L0 * 4^i - const c = d.f32(textureIndex); - const pow4 = std.pow(4, c); + // 4^i = 2^(2i) = 1 << (2*i) + const pow4 = d.f32(d.u32(1) << (textureIndex * d.u32(2))); const startPx = interval0Px * (pow4 - 1) / 3; const lengthPx = interval0Px * pow4; @@ -246,15 +291,12 @@ const rayMarchCompute = tgpu['~unstable'].computeFn({ let rgb = d.vec3f(); let T = d.f32(1); - let t = startUv; const eps = 0.5 / dim; const minStep = 0.25 / dim; - let step = d.u32(0); - const maxSteps = d.u32(32); - while (step < maxSteps) { + for (let step = 0; step < 32; step++) { if (t > endUv) { break; } @@ -269,13 +311,11 @@ const rayMarchCompute = tgpu['~unstable'].computeFn({ } t += std.max(occluderDist.dist, minStep); - step += 1; } - const cascadeIndex = textureIndex; - std.textureStore(writeView.$, gid.xy, cascadeIndex, d.vec4f(rgb, T)); + std.textureStore(writeView.$, gid.xy, textureIndex, d.vec4f(rgb, T)); - probes /= 2; + probes = probes >> 1; textureIndex += 1; } }); @@ -299,8 +339,8 @@ const mergeCompute = tgpu['~unstable'].computeFn({ const localPosN = globalToLocal(gid.xy, nDim); - const probesN = d.u32(dim / nDim); - const probesU = probesN >> 1; + const probesN = d.u32(dim) / nDim; + const probesU = d.u32(probesN) >> 1; const maxProbeIdx = std.max(probesU, 1) - 1; const maxProbeIdxF = d.f32(maxProbeIdx); @@ -308,40 +348,25 @@ const mergeCompute = tgpu['~unstable'].computeFn({ const p = d.vec2f(localPosN.probeCoord); const u = p.sub(d.vec2f(0.5)).mul(d.vec2f(0.5)); - const u0f = std.clamp( - std.floor(u), - d.vec2f(), - d.vec2f(maxProbeIdxF), - ); - - const u1f = std.min( - u0f.add(d.vec2f(1)), - d.vec2f(maxProbeIdxF), - ); - - const f = std.clamp( - u.sub(u0f), - d.vec2f(), - d.vec2f(1), - ); + const u0f = std.clamp(std.floor(u), d.vec2f(), d.vec2f(maxProbeIdxF)); + const u1f = std.min(u0f.add(d.vec2f(1)), d.vec2f(maxProbeIdxF)); + const f = std.clamp(u.sub(u0f), d.vec2f(), d.vec2f(1)); const u0 = d.vec2u(u0f); const u1 = d.vec2u(u1f); - const angN = localPosN.local.y * nDim + localPosN.local.x; + const angN = zOrderToLinear(localPosN.local); const childBase = angN * d.u32(4); - const s = d.vec2u(uDim, uDim); - - const originTL = d.vec2u(u0.x, u0.y).mul(s); - const originTR = d.vec2u(u1.x, u0.y).mul(s); - const originBL = d.vec2u(u0.x, u1.y).mul(s); - const originBR = d.vec2u(u1.x, u1.y).mul(s); + const probeTL = d.vec2u(u0.x, u0.y); + const probeTR = d.vec2u(u1.x, u0.y); + const probeBL = d.vec2u(u0.x, u1.y); + const probeBR = d.vec2u(u1.x, u1.y); - const TL = sampleProbeQuad(originTL, childBase, uDim); - const TR = sampleProbeQuad(originTR, childBase, uDim); - const BL = sampleProbeQuad(originBL, childBase, uDim); - const BR = sampleProbeQuad(originBR, childBase, uDim); + const TL = sampleProbeQuadFiltered(probeTL, childBase, uDim); + const TR = sampleProbeQuadFiltered(probeTR, childBase, uDim); + const BL = sampleProbeQuadFiltered(probeBL, childBase, uDim); + const BR = sampleProbeQuadFiltered(probeBR, childBase, uDim); const upper = std.mix( std.mix(TL, TR, f.x), @@ -439,7 +464,7 @@ export const controls = { 'Light Color': { initial: [1, 1, 1], onColorChange: (c: [number, number, number]) => { - lightColorUniform.write(d.vec3f(...c)); + lightColorUniform.write(d.vec4f(...c, 1)); updateLighting(); }, }, From 69df43f0337aa7d5cb250d3498d9234477bf40a8 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 8 Jan 2026 13:04:49 +0100 Subject: [PATCH 5/9] move to direction first --- .../rendering/radiance-compute/index.ts | 463 ++++++++---------- 1 file changed, 212 insertions(+), 251 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index 9b5d5dba58..a7334245ba 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -14,67 +14,26 @@ context.configure({ format: presentationFormat, }); -const dim = 2048; -const baseProbes = dim / 4; -const baseRaysDim = dim / baseProbes; -const cascadeAmount = Math.log2(baseProbes); +// Primary setting: texture dimension for cascade storage +const dim = 1024; +const baseRaysDimStored = 2; // Pre-averaged: 2x2 stored texels = 4x4 effective rays per probe +const baseProbes = dim / baseRaysDimStored; // Derived: 512 probes at finest cascade +const cascadeAmount = Math.round(Math.log2(baseProbes)); -const cascadesTexture = root['~unstable'] +const cascadesTextureA = root['~unstable'] .createTexture({ size: [dim, dim, cascadeAmount], format: 'rgba16float', }) .$usage('storage', 'sampled'); -const mergedTexture = root['~unstable'] +const cascadesTextureB = root['~unstable'] .createTexture({ - size: [dim, dim, cascadeAmount - 1], + size: [dim, dim, cascadeAmount], format: 'rgba16float', }) .$usage('storage', 'sampled'); -const mergeSampler = root['~unstable'].createSampler({ - magFilter: 'linear', - minFilter: 'linear', - addressModeU: 'clamp-to-edge', - addressModeV: 'clamp-to-edge', -}); - -const mergeBindGroupLayout = tgpu.bindGroupLayout({ - t1: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, - t2: { texture: d.texture2d(d.f32) }, - t2Sampler: { sampler: 'filtering' }, - dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, -}); - -const mergeBindGroups = Array.from( - { length: cascadeAmount - 1 }, - (_, n) => { - const layer = cascadeAmount - 2 - n; - - return root.createBindGroup(mergeBindGroupLayout, { - t1: cascadesTexture.createView( - d.textureStorage2d('rgba16float', 'read-only'), - { baseArrayLayer: layer, arrayLayerCount: 1 }, - ), - t2: (layer === cascadeAmount - 2 ? cascadesTexture : mergedTexture) - .createView( - d.texture2d(d.f32), - { baseArrayLayer: layer + 1, arrayLayerCount: 1 }, - ), - t2Sampler: mergeSampler, - dst: mergedTexture.createView( - d.textureStorage2d('rgba16float', 'write-only'), - { baseArrayLayer: layer, arrayLayerCount: 1 }, - ), - }); - }, -); - -const writeView = cascadesTexture.createView( - d.textureStorage2dArray('rgba16float', 'write-only'), -); - const radianceFieldTex = root['~unstable'] .createTexture({ size: [baseProbes, baseProbes], @@ -88,31 +47,23 @@ const radianceFieldStoreView = radianceFieldTex.createView( d.textureStorage2d('rgba16float', 'write-only'), ); -const mergedCascade0RO = mergedTexture.createView( - d.textureStorage2d('rgba16float', 'read-only'), - { baseArrayLayer: 0, arrayLayerCount: 1 }, -); - const buildRadianceFieldBGL = tgpu.bindGroupLayout({ src: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, }); -const buildRadianceFieldBG = root.createBindGroup(buildRadianceFieldBGL, { - src: mergedCascade0RO, - dst: radianceFieldStoreView, -}); - const radianceSampler = root['~unstable'].createSampler({ magFilter: 'linear', minFilter: 'linear', }); -const mousePosUniform = root.createUniform(d.vec2f); const lightPosUniform = root.createUniform(d.vec2f, d.vec2f(0.5)); -const mergeRaysDimUniform = root.createUniform(d.u32, baseRaysDim); const lightColorUniform = root.createUniform(d.vec4f, d.vec4f(1)); +// Uniforms for the fused cascade pass +const cascadeIndexUniform = root.createUniform(d.u32); +const probesUniform = root.createUniform(d.u32); + // Z-order curve (Morton code) encoding/decoding for better cache efficiency const spreadBits = tgpu.fn([d.u32], d.u32)((v) => { let x = v; @@ -144,54 +95,23 @@ const linearToZOrder = tgpu.fn([d.u32], d.vec2u)((idx) => { return mortonDecode2D(idx); }); -const zOrderToLinear = tgpu.fn([d.vec2u], d.u32)((pos) => { - return mortonEncode2D(pos.x, pos.y); -}); - -const indexToDir = tgpu.fn([d.u32, d.u32], d.vec2f)((mortonIdx, rayCount) => { - const rayIndex = d.f32(mortonIdx) + 0.5; - const angle = (rayIndex / d.f32(rayCount)) * Math.PI * 2 - Math.PI; - return d.vec2f(std.cos(angle), -std.sin(angle)); +// Direction-first layout helpers (key optimization for hardware filtering) +const AtlasLocal = d.struct({ + dir: d.vec2u, + probe: d.vec2u, }); -const LocalPos = d.struct({ - probeCoord: d.vec2u, - local: d.vec2u, +const atlasToDirFirst = tgpu.fn([d.vec2u, d.u32], AtlasLocal)((gid, probes) => { + const dir = d.vec2u(gid.x / probes, gid.y / probes); + const probe = d.vec2u(gid.x % probes, gid.y % probes); + return AtlasLocal({ dir, probe }); }); -const globalToLocal = (gid: d.v2u, raysDim: number) => { - 'use gpu'; - const probeCoord = d.vec2u(gid.x / raysDim, gid.y / raysDim); - const local = d.vec2u(gid.x % raysDim, gid.y % raysDim); - return LocalPos({ probeCoord, local }); -}; - -const getAtlasPos = (probeCoord: d.v2u, localRay: d.v2u, raysDim: number) => { - 'use gpu'; - return probeCoord.mul(d.u32(raysDim)).add(localRay); -}; - -const sampleProbeQuadFiltered = ( - probeCoord: d.v2u, - childBase: number, - uDim: number, -) => { - 'use gpu'; - const baseLocal = linearToZOrder(d.u32(childBase)); - // Compute center UV for the 2x2 quad - // The quad spans from baseLocal to baseLocal+(1,1), so center is baseLocal+(0.5,0.5) - const atlasBase = d.vec2f(probeCoord.mul(d.u32(uDim))).add( - d.vec2f(baseLocal), - ); - const centerUv = atlasBase.add(d.vec2f(1)).div(d.f32(dim)); - - return std.textureSampleLevel( - mergeBindGroupLayout.$.t2, - mergeBindGroupLayout.$.t2Sampler, - centerUv, - 0, - ); -}; +const dirFirstAtlasPos = tgpu.fn([d.vec2u, d.vec2u, d.u32], d.vec2u)( + (dir, probe, probes) => { + return dir.mul(probes).add(probe); + }, +); const SceneResult = d.struct({ dist: d.f32, @@ -212,212 +132,255 @@ const sceneSDF = (p: d.v2f) => { return SceneResult({ dist: minOccluder, color: d.vec3f(0) }); }; -const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ - workgroupSize: [8, 8], - in: { gid: d.builtin.globalInvocationId }, -})(({ gid }) => { - if (gid.x >= baseProbes || gid.y >= baseProbes) { - return; - } - - const raysDim = d.u32(baseRaysDim); - const probe = gid.xy; - const rayCount = raysDim * raysDim; - - let sum = d.vec3f(); - let idx = d.u32(0); - while (idx < rayCount) { - const localRay = linearToZOrder(idx); - const atlasPx = getAtlasPos(probe, localRay, baseRaysDim); - const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); - sum = sum.add(ray.xyz); - idx += 1; - } - - const avg = sum.div(d.f32(rayCount)); - std.textureStore(buildRadianceFieldBGL.$.dst, probe, d.vec4f(avg, 1.0)); -}); - -const ACESFilm = tgpu.fn([d.vec3f], d.vec3f)((x) => { - const a = 2.51; - const b = 0.03; - const c = 2.43; - const dVal = 0.59; - const e = 0.01; - const res = x.mul(x.mul(a).add(b)).div(x.mul(x.mul(c).add(dVal)).add(e)); - return std.saturate(res); +// Fused cascade pass: raymarch + merge in one top-down pass per layer +const cascadePassBGL = tgpu.bindGroupLayout({ + upper: { texture: d.texture2d(d.f32) }, + upperSampler: { sampler: 'filtering' }, + dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, }); -const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ - in: { uv: d.vec2f }, - out: d.vec4f, -})(({ uv }) => { - const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) - .xyz; - const outRgb = std.saturate(field); - return d.vec4f(ACESFilm(outRgb), 1.0); +const cascadeSampler = root['~unstable'].createSampler({ + magFilter: 'linear', + minFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', }); -const rayMarchCompute = tgpu['~unstable'].computeFn({ +const cascadePassCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { - if (gid.x >= dim || gid.y >= dim) { - return; - } + if (gid.x >= dim || gid.y >= dim) return; + + const layer = cascadeIndexUniform.$; + const probes = probesUniform.$; + const topLayer = d.u32(cascadeAmount - 1); + + // Direction-first layout: decode gid to (dir, probe) + const lp = atlasToDirFirst(gid.xy, probes); + const dirStored = lp.dir; + const probe = lp.probe; + + // Stored raysDim at this layer + const raysDimStored = d.u32(d.u32(dim) / probes); + // Actual direction grid is 2x (because we pre-average 4 rays per stored texel) + const raysDimActual = raysDimStored << d.u32(1); + const rayCountActual = raysDimActual * raysDimActual; + + const probePos = d.vec2f(probe).add(0.5).div(d.f32(probes)); - const interval0Px = d.f32(baseRaysDim); - let probes = d.u32(baseProbes); - let textureIndex = d.u32(0); + // Interval calculation (decoupled from dim for consistent lighting) + const interval0Uv = 1.0 / d.f32(baseProbes); + const pow4 = d.f32(d.u32(1) << (layer * d.u32(2))); + const startUv = interval0Uv * (pow4 - 1.0) / 3.0; + const endUv = startUv + interval0Uv * pow4; - while (probes > 1) { - const raysDim = d.u32(dim) / probes; - const raysDimU = d.u32(raysDim); - const lp = globalToLocal(gid.xy, raysDimU); + // March tuning + const eps = 0.5 / d.f32(baseProbes); + const minStep = 0.25 / d.f32(baseProbes); - const mortonIdx = zOrderToLinear(lp.local); - const rayCount = raysDimU * raysDimU; - const dir = indexToDir(mortonIdx, rayCount); + let accum = d.vec4f(); - const probePos = d.vec2f(lp.probeCoord).add(0.5).div(d.f32(probes)); + // Cast 4 rays per stored texel (2x2 block in actual direction grid) + for (let i = 0; i < 4; i++) { + const ox = d.u32(d.u32(i) & d.u32(1)); + const oy = d.u32(d.u32(i) >> d.u32(1)); - // 4^i = 2^(2i) = 1 << (2*i) - const pow4 = d.f32(d.u32(1) << (textureIndex * d.u32(2))); + // Map stored direction to actual direction (2x finer) + const dirActual2D = dirStored.mul(d.u32(2)).add(d.vec2u(ox, oy)); + const mortonIdx = mortonEncode2D(dirActual2D.x, dirActual2D.y); - const startPx = interval0Px * (pow4 - 1) / 3; - const lengthPx = interval0Px * pow4; - const startUv = startPx / d.f32(dim); - const endUv = (startPx + lengthPx) / d.f32(dim); + const rayIndex = d.f32(mortonIdx) + 0.5; + const angle = (rayIndex / d.f32(rayCountActual)) * (Math.PI * 2) - Math.PI; + const dir = d.vec2f(std.cos(angle), -std.sin(angle)); + // Raymarch let rgb = d.vec3f(); let T = d.f32(1); let t = startUv; - const eps = 0.5 / dim; - const minStep = 0.25 / dim; - for (let step = 0; step < 32; step++) { - if (t > endUv) { - break; - } + if (t > endUv) break; const p = probePos.add(dir.mul(t)); - const occluderDist = sceneSDF(p); + const hit = sceneSDF(p); - if (occluderDist.dist <= eps) { - rgb = d.vec3f(occluderDist.color); + if (hit.dist <= eps) { + rgb = d.vec3f(hit.color); T = d.f32(0); break; } - t += std.max(occluderDist.dist, minStep); + t += std.max(hit.dist, minStep); } - std.textureStore(writeView.$, gid.xy, textureIndex, d.vec4f(rgb, T)); + // Merge with upper cascade (hardware bilinear interpolation!) + if (layer < topLayer && T > 0.01) { + const probesU = std.max(probes >> d.u32(1), d.u32(1)); + + // Fractional probe position in upper grid + // Upper grid has half the probes, so we map probe/2 + 0.25 for center alignment + let probeUf = d.vec2f(probe).mul(0.5).add(d.vec2f(0.25)); + + // Clamp away from block edges to prevent filtering into neighboring direction blocks + if (probesU > d.u32(2)) { + const lo = d.vec2f(1.5); + const hi = d.vec2f(d.f32(probesU) - 1.5); + probeUf = std.clamp(probeUf, lo, hi); + } + + // Upper cascade uses direction-first too, and its stored raysDim == our actual raysDim + // So we can sample upper using dirActual2D directly + const atlasBaseU = d.vec2f(dirActual2D.mul(probesU)); + const atlasPxU = atlasBaseU.add(probeUf); + + const uvU = atlasPxU.div(d.f32(dim)); + + // Single bilinear sample across 4 probes (key optimization!) + const upper = std.textureSampleLevel( + cascadePassBGL.$.upper, + cascadePassBGL.$.upperSampler, + uvU, + 0, + ); - probes = probes >> 1; - textureIndex += 1; + rgb = rgb.add(upper.xyz.mul(T)); + T = T * upper.w; + } + + accum = accum.add(d.vec4f(rgb, T)); } + + // Store average of 4 rays + const outVal = accum.mul(0.25); + std.textureStore(cascadePassBGL.$.dst, gid.xy, outVal); }); -const mergeCompute = tgpu['~unstable'].computeFn({ +const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { - if (gid.x >= dim || gid.y >= dim) { + if (gid.x >= baseProbes || gid.y >= baseProbes) { return; } - const nDim = mergeRaysDimUniform.$; - const uDim = nDim << 1; + const probes = d.u32(baseProbes); + const raysDimStored = d.u32(d.u32(dim) / probes); // Should be 2 for cascade0 + const probe = gid.xy; + const rayCountStored = raysDimStored * raysDimStored; - const rayN = std.textureLoad(mergeBindGroupLayout.$.t1, gid.xy); - if (rayN.w <= 0.01) { - std.textureStore(mergeBindGroupLayout.$.dst, gid.xy, rayN); - return; + let sum = d.vec3f(); + let idx = d.u32(0); + while (idx < rayCountStored) { + // Direction-first layout: iterate over directions + const dir = linearToZOrder(idx); + const atlasPx = dirFirstAtlasPos(dir, probe, probes); + const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); + sum = sum.add(ray.xyz); + idx += 1; } - const localPosN = globalToLocal(gid.xy, nDim); - - const probesN = d.u32(dim) / nDim; - const probesU = d.u32(probesN) >> 1; - - const maxProbeIdx = std.max(probesU, 1) - 1; - const maxProbeIdxF = d.f32(maxProbeIdx); - - const p = d.vec2f(localPosN.probeCoord); - const u = p.sub(d.vec2f(0.5)).mul(d.vec2f(0.5)); - - const u0f = std.clamp(std.floor(u), d.vec2f(), d.vec2f(maxProbeIdxF)); - const u1f = std.min(u0f.add(d.vec2f(1)), d.vec2f(maxProbeIdxF)); - const f = std.clamp(u.sub(u0f), d.vec2f(), d.vec2f(1)); - - const u0 = d.vec2u(u0f); - const u1 = d.vec2u(u1f); - - const angN = zOrderToLinear(localPosN.local); - const childBase = angN * d.u32(4); - - const probeTL = d.vec2u(u0.x, u0.y); - const probeTR = d.vec2u(u1.x, u0.y); - const probeBL = d.vec2u(u0.x, u1.y); - const probeBR = d.vec2u(u1.x, u1.y); - - const TL = sampleProbeQuadFiltered(probeTL, childBase, uDim); - const TR = sampleProbeQuadFiltered(probeTR, childBase, uDim); - const BL = sampleProbeQuadFiltered(probeBL, childBase, uDim); - const BR = sampleProbeQuadFiltered(probeBR, childBase, uDim); + const avg = sum.div(d.f32(rayCountStored)); + std.textureStore(buildRadianceFieldBGL.$.dst, probe, d.vec4f(avg, 1.0)); +}); - const upper = std.mix( - std.mix(TL, TR, f.x), - std.mix(BL, BR, f.x), - f.y, - ); +const ACESFilm = tgpu.fn([d.vec3f], d.vec3f)((x) => { + const a = 2.51; + const b = 0.03; + const c = 2.43; + const dVal = 0.59; + const e = 0.01; + const res = x.mul(x.mul(a).add(b)).div(x.mul(x.mul(c).add(dVal)).add(e)); + return std.saturate(res); +}); - const outRgb = rayN.xyz.add(upper.xyz.mul(rayN.w)); - const outT = rayN.w * upper.w; - std.textureStore(mergeBindGroupLayout.$.dst, gid.xy, d.vec4f(outRgb, outT)); +const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) + .xyz; + const outRgb = std.saturate(field); + return d.vec4f(ACESFilm(outRgb), 1.0); }); -const rayMarchPipeline = root['~unstable'] - .withCompute(rayMarchCompute) +const cascadePassPipeline = root['~unstable'] + .withCompute(cascadePassCompute) .createPipeline(); -const mergePipeline = root['~unstable'] - .withCompute(mergeCompute) - .createPipeline(); +const cascadePassBindGroups = Array.from( + { length: cascadeAmount }, + (_, layer) => { + const writeToA = (cascadeAmount - 1 - layer) % 2 === 0; + const dstTexture = writeToA ? cascadesTextureA : cascadesTextureB; + const srcTexture = writeToA ? cascadesTextureB : cascadesTextureA; + + return root.createBindGroup(cascadePassBGL, { + 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 }, + ), + }); + }, +); const buildRadianceFieldPipeline = root['~unstable'] .withCompute(buildRadianceFieldCompute) .createPipeline(); +// Pre-create bind groups for both possible cascade 0 locations (ping-pong result) +const buildRadianceFieldBG_A = root.createBindGroup(buildRadianceFieldBGL, { + src: cascadesTextureA.createView( + d.textureStorage2d('rgba16float', 'read-only'), + { baseArrayLayer: 0, arrayLayerCount: 1 }, + ), + dst: radianceFieldStoreView, +}); + +const buildRadianceFieldBG_B = root.createBindGroup(buildRadianceFieldBGL, { + src: cascadesTextureB.createView( + d.textureStorage2d('rgba16float', 'read-only'), + { baseArrayLayer: 0, arrayLayerCount: 1 }, + ), + dst: radianceFieldStoreView, +}); + function buildRadianceField() { + // Determine which texture has cascade 0 after ping-pong + const cascade0InA = (cascadeAmount - 1) % 2 === 0; + const buildRadianceFieldBG = cascade0InA + ? buildRadianceFieldBG_A + : buildRadianceFieldBG_B; + buildRadianceFieldPipeline .with(buildRadianceFieldBG) - .dispatchWorkgroups( - Math.ceil(baseProbes / 8), - Math.ceil(baseProbes / 8), - ); + .dispatchWorkgroups(Math.ceil(baseProbes / 8), Math.ceil(baseProbes / 8)); } -function mergeAllCascades() { - for (let n = 0; n < mergeBindGroups.length; n++) { - const layer = cascadeAmount - 2 - n; +// Top-down cascade dispatch (replaces separate raymarch + merge) +function runCascadesTopDown() { + // Process from highest cascade down to 0 + // Each layer reads from layer+1 (already computed) and writes to itself + for (let layer = cascadeAmount - 1; layer >= 0; layer--) { const probes = baseProbes >> layer; - const raysDim = dim / probes; - mergeRaysDimUniform.write(raysDim); + cascadeIndexUniform.write(layer); + probesUniform.write(probes); - mergePipeline - .with(mergeBindGroups[n]) + cascadePassPipeline + .with(cascadePassBindGroups[layer]) .dispatchWorkgroups(Math.ceil(dim / 8), Math.ceil(dim / 8)); } } function updateLighting() { - rayMarchPipeline.dispatchWorkgroups(Math.ceil(dim / 8), Math.ceil(dim / 8)); - mergeAllCascades(); - buildRadianceField(); + runCascadesTopDown(); // Fused raymarch + merge + buildRadianceField(); // Build final radiance field from cascade 0 } updateLighting(); @@ -449,12 +412,10 @@ canvas.addEventListener('click', (event) => { }); canvas.addEventListener('mousemove', (event) => { - const rect = canvas.getBoundingClientRect(); - const x = (event.clientX - rect.left) / rect.width; - const y = (event.clientY - rect.top) / rect.height; - mousePosUniform.write(d.vec2f(x, y)); - if (event.buttons === 1) { + const rect = canvas.getBoundingClientRect(); + const x = (event.clientX - rect.left) / rect.width; + const y = (event.clientY - rect.top) / rect.height; lightPosUniform.write(d.vec2f(x, y)); updateLighting(); } From 59ee9dcc8b2a012d69cad8aa944ea4db4b553ff1 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 8 Jan 2026 19:22:45 +0100 Subject: [PATCH 6/9] work --- .../radiance-compute/drag-controller.ts | 115 ++++++++ .../rendering/radiance-compute/index.html | 2 +- .../rendering/radiance-compute/index.ts | 268 ++++++++---------- .../rendering/radiance-compute/scene.ts | 154 ++++++++++ 4 files changed, 396 insertions(+), 143 deletions(-) create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts new file mode 100644 index 0000000000..9ded947ee2 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts @@ -0,0 +1,115 @@ +import { sdBox2d, sdDisk } from '@typegpu/sdf'; +import type { AnySceneElement } from './scene.ts'; +import { sceneElements } from './scene.ts'; +import * as d from 'typegpu/data'; + +export interface DragTarget { + id: string; + element: AnySceneElement; +} + +export class DragController { + private isDragging = false; + private draggedElement: DragTarget | null = null; + + constructor( + private canvas: HTMLCanvasElement, + private onDragMove: (id: string, position: d.v2f) => void, + private onDragEnd: (id: string, position: d.v2f) => void, + ) { + this.setupEventListeners(); + } + + private canvasToUV(clientX: number, clientY: number): d.v2f { + const rect = this.canvas.getBoundingClientRect(); + const x = (clientX - rect.left) / rect.width; + const y = (clientY - rect.top) / rect.height; + return d.vec2f(x, y); + } + + private hitTestDisk(uv: d.v2f, center: d.v2f, radius: number): boolean { + const dist = sdDisk(uv.sub(center), radius); + return dist <= radius; + } + + private hitTestBox(uv: d.v2f, center: d.v2f, size: d.v2f): boolean { + const dist = sdBox2d(uv.sub(center), size.mul(1)); + return dist <= 0; + } + + private hitTest(clientX: number, clientY: number): DragTarget | null { + const uv = this.canvasToUV(clientX, clientY); + + for (const element of sceneElements) { + let hit = false; + + if (element.type === 'disk') { + const radius = element.size as number; + hit = this.hitTestDisk(uv, element.position, radius); + } else if (element.type === 'box') { + const size = element.size as d.v2f; + hit = this.hitTestBox(uv, element.position, size); + } + + if (hit) { + return { id: element.id, element }; + } + } + + return null; + } + + private setupEventListeners() { + this.canvas.addEventListener('mousedown', this.onMouseDown); + this.canvas.addEventListener('mousemove', this.onMouseMove); + this.canvas.addEventListener('mouseup', this.onMouseUp); + this.canvas.addEventListener('mouseleave', this.onMouseLeave); + } + + private onMouseDown = (e: MouseEvent) => { + const target = this.hitTest(e.clientX, e.clientY); + if (target) { + this.isDragging = true; + this.draggedElement = target; + this.canvas.style.cursor = 'grabbing'; + } + }; + + private onMouseMove = (e: MouseEvent) => { + if (!this.isDragging || !this.draggedElement) { + const target = this.hitTest(e.clientX, e.clientY); + this.canvas.style.cursor = target ? 'grab' : 'default'; + return; + } + + const newPos = this.canvasToUV(e.clientX, e.clientY); + this.onDragMove(this.draggedElement.id, newPos); + }; + + private onMouseUp = (e: MouseEvent) => { + if (this.isDragging && this.draggedElement) { + const finalPos = this.canvasToUV(e.clientX, e.clientY); + this.onDragEnd(this.draggedElement.id, finalPos); + this.isDragging = false; + this.draggedElement = null; + + const target = this.hitTest(e.clientX, e.clientY); + this.canvas.style.cursor = target ? 'grab' : 'default'; + } + }; + + private onMouseLeave = () => { + if (this.isDragging) { + this.isDragging = false; + this.draggedElement = null; + this.canvas.style.cursor = 'default'; + } + }; + + destroy() { + this.canvas.removeEventListener('mousedown', this.onMouseDown); + this.canvas.removeEventListener('mousemove', this.onMouseMove); + this.canvas.removeEventListener('mouseup', this.onMouseUp); + this.canvas.removeEventListener('mouseleave', this.onMouseLeave); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html index aa8cc321b3..581d6789f8 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html @@ -1 +1 @@ - + diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index a7334245ba..c7017ec13c 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -2,7 +2,14 @@ import tgpu from 'typegpu'; import { fullScreenTriangle } from 'typegpu/common'; import * as d from 'typegpu/data'; import * as std from 'typegpu/std'; -import * as sdf from '@typegpu/sdf'; +import { + SceneData, + sceneData, + sceneDataAccess, + sceneSDF, + updateElementPosition, +} from './scene.ts'; +import { DragController } from './drag-controller.ts'; const root = await tgpu.init(); const canvas = document.querySelector('canvas') as HTMLCanvasElement; @@ -14,29 +21,42 @@ context.configure({ format: presentationFormat, }); -// Primary setting: texture dimension for cascade storage const dim = 1024; const baseRaysDimStored = 2; // Pre-averaged: 2x2 stored texels = 4x4 effective rays per probe -const baseProbes = dim / baseRaysDimStored; // Derived: 512 probes at finest cascade -const cascadeAmount = Math.round(Math.log2(baseProbes)); + +let baseProbesX: number; +let baseProbesY: number; + +const canvasAspect = canvas.width / canvas.height; +if (canvasAspect >= 1.0) { + baseProbesY = dim / baseRaysDimStored; + baseProbesX = Math.round(baseProbesY * canvasAspect); +} else { + baseProbesX = dim / baseRaysDimStored; + baseProbesY = Math.round(baseProbesX / canvasAspect); +} + +const dimX = baseProbesX * baseRaysDimStored; +const dimY = baseProbesY * baseRaysDimStored; +const cascadeAmount = Math.round(Math.log2(Math.min(baseProbesX, baseProbesY))); const cascadesTextureA = root['~unstable'] .createTexture({ - size: [dim, dim, cascadeAmount], + size: [dimX, dimY, cascadeAmount], format: 'rgba16float', }) .$usage('storage', 'sampled'); const cascadesTextureB = root['~unstable'] .createTexture({ - size: [dim, dim, cascadeAmount], + size: [dimX, dimY, cascadeAmount], format: 'rgba16float', }) .$usage('storage', 'sampled'); const radianceFieldTex = root['~unstable'] .createTexture({ - size: [baseProbes, baseProbes], + size: [baseProbesX, baseProbesY], format: 'rgba16float', }) .$usage('storage', 'sampled'); @@ -57,82 +77,34 @@ const radianceSampler = root['~unstable'].createSampler({ minFilter: 'linear', }); -const lightPosUniform = root.createUniform(d.vec2f, d.vec2f(0.5)); -const lightColorUniform = root.createUniform(d.vec4f, d.vec4f(1)); +const sceneDataUniform = root.createUniform(SceneData, sceneData); -// Uniforms for the fused cascade pass const cascadeIndexUniform = root.createUniform(d.u32); -const probesUniform = root.createUniform(d.u32); - -// Z-order curve (Morton code) encoding/decoding for better cache efficiency -const spreadBits = tgpu.fn([d.u32], d.u32)((v) => { - let x = v; - x = (x | (x << 8)) & 0x00ff00ff; - x = (x | (x << 4)) & 0x0f0f0f0f; - x = (x | (x << 2)) & 0x33333333; - x = (x | (x << 1)) & 0x55555555; - return x; -}); - -const compactBits = tgpu.fn([d.u32], d.u32)((v) => { - let x = v & 0x55555555; - x = (x | (x >> 1)) & 0x33333333; - x = (x | (x >> 2)) & 0x0f0f0f0f; - x = (x | (x >> 4)) & 0x00ff00ff; - x = (x | (x >> 8)) & 0x0000ffff; - return x; -}); - -const mortonEncode2D = tgpu.fn([d.u32, d.u32], d.u32)((x, y) => { - return spreadBits(x) | (spreadBits(y) << 1); -}); - -const mortonDecode2D = tgpu.fn([d.u32], d.vec2u)((code) => { - return d.vec2u(compactBits(code), compactBits(code >> 1)); -}); - -const linearToZOrder = tgpu.fn([d.u32], d.vec2u)((idx) => { - return mortonDecode2D(idx); -}); +const probesUniform = root.createUniform(d.vec2u); +const dimUniform = root.createUniform(d.vec2u, d.vec2u(dimX, dimY)); +const baseProbesUniform = root.createUniform( + d.vec2u, + d.vec2u(baseProbesX, baseProbesY), +); -// Direction-first layout helpers (key optimization for hardware filtering) const AtlasLocal = d.struct({ dir: d.vec2u, probe: d.vec2u, }); -const atlasToDirFirst = tgpu.fn([d.vec2u, d.u32], AtlasLocal)((gid, probes) => { - const dir = d.vec2u(gid.x / probes, gid.y / probes); - const probe = d.vec2u(gid.x % probes, gid.y % probes); - return AtlasLocal({ dir, probe }); -}); - -const dirFirstAtlasPos = tgpu.fn([d.vec2u, d.vec2u, d.u32], d.vec2u)( - (dir, probe, probes) => { - return dir.mul(probes).add(probe); - }, -); - -const SceneResult = d.struct({ - dist: d.f32, - color: d.vec3f, -}); +const atlasToDirFirst = (gid: d.v2u, probes: d.v2u) => { + 'use gpu'; + return AtlasLocal({ + dir: gid.div(probes), + probe: std.mod(gid, probes), + }); +}; -const sceneSDF = (p: d.v2f) => { +const dirFirstAtlasPos = (dir: d.v2u, probe: d.v2u, probes: d.v2u) => { 'use gpu'; - const occluder1 = sdf.sdBox2d(p.sub(d.vec2f(0.3, 0.3)), d.vec2f(0.08, 0.15)); - const occluder2 = sdf.sdBox2d(p.sub(d.vec2f(0.7, 0.6)), d.vec2f(0.12, 0.08)); - const occluder3 = sdf.sdDisk(p.sub(d.vec2f(0.5, 0.75)), d.f32(0.1)); - const minOccluder = std.min(occluder1, occluder2, occluder3); - const light = sdf.sdDisk(p.sub(lightPosUniform.$), d.f32(0.05)); - - if (light < minOccluder && minOccluder > 0) { - return SceneResult({ dist: light, color: lightColorUniform.$.xyz }); - } - return SceneResult({ dist: minOccluder, color: d.vec3f(0) }); + return dir.mul(probes).add(probe); }; -// Fused cascade pass: raymarch + merge in one top-down pass per layer const cascadePassBGL = tgpu.bindGroupLayout({ upper: { texture: d.texture2d(d.f32) }, upperSampler: { sampler: 'filtering' }, @@ -150,49 +122,55 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { - if (gid.x >= dim || gid.y >= dim) return; + const dim2 = dimUniform.$; + if (gid.x >= dim2.x || gid.y >= dim2.y) { + return; + } const layer = cascadeIndexUniform.$; const probes = probesUniform.$; const topLayer = d.u32(cascadeAmount - 1); - // Direction-first layout: decode gid to (dir, probe) + // Decode atlas position to (direction, probe) const lp = atlasToDirFirst(gid.xy, probes); const dirStored = lp.dir; const probe = lp.probe; - // Stored raysDim at this layer - const raysDimStored = d.u32(d.u32(dim) / probes); - // Actual direction grid is 2x (because we pre-average 4 rays per stored texel) - const raysDimActual = raysDimStored << d.u32(1); + // Stored vs Actual ray dimensions: + // Each stored texel represents a 2x2 block of actual rays (pre-averaged) + const raysDimStoredX = d.u32(dim2.x / probes.x); + const raysDimStoredY = d.u32(dim2.y / probes.y); + const raysDimStored = std.min(raysDimStoredX, raysDimStoredY); + const raysDimActual = raysDimStored << d.u32(1); // 2x for cascade hierarchy const rayCountActual = raysDimActual * raysDimActual; - const probePos = d.vec2f(probe).add(0.5).div(d.f32(probes)); + const probePos = d.vec2f(probe).add(0.5).div(d.vec2f(probes)); - // Interval calculation (decoupled from dim for consistent lighting) - const interval0Uv = 1.0 / d.f32(baseProbes); + // Interval calculation + const baseProbes2 = baseProbesUniform.$; + const baseProbesMin = d.f32(std.min(baseProbes2.x, baseProbes2.y)); + const interval0Uv = 1.0 / baseProbesMin; const pow4 = d.f32(d.u32(1) << (layer * d.u32(2))); const startUv = interval0Uv * (pow4 - 1.0) / 3.0; const endUv = startUv + interval0Uv * pow4; - // March tuning - const eps = 0.5 / d.f32(baseProbes); - const minStep = 0.25 / d.f32(baseProbes); + const eps = 0.5 / baseProbesMin; + const minStep = 0.25 / baseProbesMin; let accum = d.vec4f(); - // Cast 4 rays per stored texel (2x2 block in actual direction grid) + // Cast 4 rays per stored texel (2x2 block) and average them for (let i = 0; i < 4; i++) { const ox = d.u32(d.u32(i) & d.u32(1)); const oy = d.u32(d.u32(i) >> d.u32(1)); - // Map stored direction to actual direction (2x finer) + // Map stored direction to actual direction (2x2 block) const dirActual2D = dirStored.mul(d.u32(2)).add(d.vec2u(ox, oy)); - const mortonIdx = mortonEncode2D(dirActual2D.x, dirActual2D.y); - const rayIndex = d.f32(mortonIdx) + 0.5; + // Compute ray direction from actual ray index + const rayIndex = d.f32(dirActual2D.y * raysDimActual + dirActual2D.x) + 0.5; const angle = (rayIndex / d.f32(rayCountActual)) * (Math.PI * 2) - Math.PI; - const dir = d.vec2f(std.cos(angle), -std.sin(angle)); + const rayDir = d.vec2f(std.cos(angle), -std.sin(angle)); // Raymarch let rgb = d.vec3f(); @@ -201,8 +179,7 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ for (let step = 0; step < 32; step++) { if (t > endUv) break; - - const p = probePos.add(dir.mul(t)); + const p = probePos.add(rayDir.mul(t)); const hit = sceneSDF(p); if (hit.dist <= eps) { @@ -210,33 +187,35 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ T = d.f32(0); break; } - t += std.max(hit.dist, minStep); } - // Merge with upper cascade (hardware bilinear interpolation!) + // Merge with upper cascade if (layer < topLayer && T > 0.01) { - const probesU = std.max(probes >> d.u32(1), d.u32(1)); + const probesU = d.vec2u( + std.max(probes.x >> d.u32(1), d.u32(1)), + std.max(probes.y >> d.u32(1), d.u32(1)), + ); - // Fractional probe position in upper grid - // Upper grid has half the probes, so we map probe/2 + 0.25 for center alignment - let probeUf = d.vec2f(probe).mul(0.5).add(d.vec2f(0.25)); + // Upper cascade's stored resolution matches our actual resolution + const tileOriginU = d.vec2f( + d.f32(dirActual2D.x * probesU.x), + d.f32(dirActual2D.y * probesU.y), + ); - // Clamp away from block edges to prevent filtering into neighboring direction blocks - if (probesU > d.u32(2)) { - const lo = d.vec2f(1.5); - const hi = d.vec2f(d.f32(probesU) - 1.5); - probeUf = std.clamp(probeUf, lo, hi); - } + // Map probe position to pixel coordinates within the tile + const probePixelU = probePos.mul(d.vec2f(probesU)); - // Upper cascade uses direction-first too, and its stored raysDim == our actual raysDim - // So we can sample upper using dirActual2D directly - const atlasBaseU = d.vec2f(dirActual2D.mul(probesU)); - const atlasPxU = atlasBaseU.add(probeUf); + // Clamp to prevent bilinear bleeding into neighboring direction tiles + const clampedPixelU = std.clamp( + probePixelU, + d.vec2f(0.5), + d.vec2f(probesU).sub(0.5), + ); - const uvU = atlasPxU.div(d.f32(dim)); + // Convert to UV space + const uvU = tileOriginU.add(clampedPixelU).div(d.vec2f(dim2)); - // Single bilinear sample across 4 probes (key optimization!) const upper = std.textureSampleLevel( cascadePassBGL.$.upper, cascadePassBGL.$.upperSampler, @@ -260,20 +239,24 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { - if (gid.x >= baseProbes || gid.y >= baseProbes) { + const baseProbes2 = baseProbesUniform.$; + if (gid.x >= baseProbes2.x || gid.y >= baseProbes2.y) { return; } - const probes = d.u32(baseProbes); - const raysDimStored = d.u32(d.u32(dim) / probes); // Should be 2 for cascade0 + const dim2 = dimUniform.$; + const probes = baseProbes2; + const raysDimStoredX = d.u32(dim2.x / probes.x); + const raysDimStoredY = d.u32(dim2.y / probes.y); + const raysDimStored = std.min(raysDimStoredX, raysDimStoredY); // Should be 2 for cascade0 const probe = gid.xy; const rayCountStored = raysDimStored * raysDimStored; let sum = d.vec3f(); let idx = d.u32(0); while (idx < rayCountStored) { - // Direction-first layout: iterate over directions - const dir = linearToZOrder(idx); + // Direction-first layout: iterate over directions (row-major) + const dir = d.vec2u(idx % raysDimStored, idx / raysDimStored); const atlasPx = dirFirstAtlasPos(dir, probe, probes); const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); sum = sum.add(ray.xyz); @@ -305,6 +288,7 @@ const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ }); const cascadePassPipeline = root['~unstable'] + .with(sceneDataAccess, sceneDataUniform) .withCompute(cascadePassCompute) .createPipeline(); @@ -359,7 +343,10 @@ function buildRadianceField() { buildRadianceFieldPipeline .with(buildRadianceFieldBG) - .dispatchWorkgroups(Math.ceil(baseProbes / 8), Math.ceil(baseProbes / 8)); + .dispatchWorkgroups( + Math.ceil(baseProbesX / 8), + Math.ceil(baseProbesY / 8), + ); } // Top-down cascade dispatch (replaces separate raymarch + merge) @@ -367,14 +354,15 @@ function runCascadesTopDown() { // Process from highest cascade down to 0 // Each layer reads from layer+1 (already computed) and writes to itself for (let layer = cascadeAmount - 1; layer >= 0; layer--) { - const probes = baseProbes >> layer; + const probesX = baseProbesX >> layer; + const probesY = baseProbesY >> layer; cascadeIndexUniform.write(layer); - probesUniform.write(probes); + probesUniform.write(d.vec2u(probesX, probesY)); cascadePassPipeline .with(cascadePassBindGroups[layer]) - .dispatchWorkgroups(Math.ceil(dim / 8), Math.ceil(dim / 8)); + .dispatchWorkgroups(Math.ceil(dimX / 8), Math.ceil(dimY / 8)); } } @@ -389,8 +377,12 @@ const renderPipeline = root['~unstable'] .withFragment(finalRadianceFieldFrag, { format: presentationFormat }) .createPipeline(); +let isRunning = true; let frameId: number; + function frame() { + if (!isRunning) return; // Prevent using destroyed device + renderPipeline .withColorAttachment({ view: context.getCurrentTexture().createView(), @@ -402,36 +394,28 @@ function frame() { } frameId = requestAnimationFrame(frame); -canvas.addEventListener('click', (event) => { - const rect = canvas.getBoundingClientRect(); - const x = (event.clientX - rect.left) / rect.width; - const y = (event.clientY - rect.top) / rect.height; - - lightPosUniform.write(d.vec2f(x, y)); - updateLighting(); -}); +function updateUniforms() { + sceneDataUniform.write(sceneData); +} -canvas.addEventListener('mousemove', (event) => { - if (event.buttons === 1) { - const rect = canvas.getBoundingClientRect(); - const x = (event.clientX - rect.left) / rect.width; - const y = (event.clientY - rect.top) / rect.height; - lightPosUniform.write(d.vec2f(x, y)); +// Set up drag controller for interactive scene manipulation +const dragController = new DragController( + canvas, + (id, position) => { + updateElementPosition(id, position); + updateUniforms(); updateLighting(); - } -}); - -export const controls = { - 'Light Color': { - initial: [1, 1, 1], - onColorChange: (c: [number, number, number]) => { - lightColorUniform.write(d.vec4f(...c, 1)); - updateLighting(); - }, }, -}; + (id, position) => { + updateElementPosition(id, position); + updateUniforms(); + updateLighting(); + }, +); export function onCleanup() { + isRunning = false; // Stop the loop logic immediately + dragController.destroy(); if (frameId !== null) { cancelAnimationFrame(frameId); } diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts new file mode 100644 index 0000000000..29544b4f8c --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts @@ -0,0 +1,154 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as sdf from '@typegpu/sdf'; + +export interface SceneElement { + id: string; + type: T; + position: d.v2f; + size: T extends 'box' ? d.v2f : number; + emission?: d.v3f; + dataIndex: number; // Index in sceneData.disks or sceneData.boxes +} +export type AnySceneElement = SceneElement<'box'> | SceneElement<'disk'>; + +export const sceneElements: AnySceneElement[] = [ + // 3 RGB lights (disks with emission) + { + id: 'light-red', + type: 'disk', + position: d.vec2f(0.2, 0.3), + emission: d.vec3f(1, 0, 0), + size: 0.05, + dataIndex: 0, + }, + { + id: 'light-green', + type: 'disk', + position: d.vec2f(0.5, 0.3), + emission: d.vec3f(0, 1, 0), + size: 0.05, + dataIndex: 1, + }, + { + id: 'light-blue', + type: 'disk', + position: d.vec2f(0.8, 0.3), + emission: d.vec3f(0, 0, 1), + size: 0.05, + dataIndex: 2, + }, + + // 3 occluders + { + id: 'box-1', + type: 'box', + position: d.vec2f(0.3, 0.5), + size: d.vec2f(0.08, 0.15), + dataIndex: 0, + }, + { + id: 'box-2', + type: 'box', + position: d.vec2f(0.7, 0.65), + size: d.vec2f(0.12, 0.08), + dataIndex: 1, + }, + { + id: 'disk-1', + type: 'disk', + position: d.vec2f(0.5, 0.75), + size: 0.1, + dataIndex: 3, + }, +]; + +export const sceneData = { + disks: sceneElements + .filter((el) => el.type === 'disk') + .map((el) => ({ + pos: el.position, + radius: el.size, + emissiveColor: el.emission ?? d.vec3f(), + })), + boxes: sceneElements + .filter((el) => el.type === 'box') + .map((el) => ({ + pos: el.position, + size: el.size, + emissiveColor: el.emission ?? d.vec3f(), + })), +}; + +const elementById = new Map( + 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; + } + + // Direct O(1) array access using stored index + 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, 4), + boxes: d.arrayOf(BoxData, 2), +}); + +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 }); +}; From 9440c6b11b3a0c6ef024018c503fdbdffde73ce7 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Sat, 10 Jan 2026 04:24:46 +0100 Subject: [PATCH 7/9] more work --- .../rendering/radiance-compute/index.html | 2 +- .../rendering/radiance-compute/index.ts | 462 ++++++++++++------ 2 files changed, 319 insertions(+), 145 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html index 581d6789f8..aa8cc321b3 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.html @@ -1 +1 @@ - + diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index c7017ec13c..10600eca0c 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -2,6 +2,7 @@ import tgpu from 'typegpu'; import { fullScreenTriangle } from 'typegpu/common'; import * as d from 'typegpu/data'; import * as std from 'typegpu/std'; +import * as sdf from '@typegpu/sdf'; import { SceneData, sceneData, @@ -21,42 +22,47 @@ context.configure({ format: presentationFormat, }); -const dim = 1024; -const baseRaysDimStored = 2; // Pre-averaged: 2x2 stored texels = 4x4 effective rays per probe - -let baseProbesX: number; -let baseProbesY: number; - -const canvasAspect = canvas.width / canvas.height; -if (canvasAspect >= 1.0) { - baseProbesY = dim / baseRaysDimStored; - baseProbesX = Math.round(baseProbesY * canvasAspect); -} else { - baseProbesX = dim / baseRaysDimStored; - baseProbesY = Math.round(baseProbesX / canvasAspect); -} - -const dimX = baseProbesX * baseRaysDimStored; -const dimY = baseProbesY * baseRaysDimStored; -const cascadeAmount = Math.round(Math.log2(Math.min(baseProbesX, baseProbesY))); +const OUTPUT_RESOLUTION: [number, number] = [canvas.width, canvas.height]; +const LIGHTING_RESOLUTION = 0.4; + +const [outputProbesX, outputProbesY] = OUTPUT_RESOLUTION; +const aspect = outputProbesX / outputProbesY; + +const diagonal = Math.sqrt(outputProbesX ** 2 + outputProbesY ** 2); +const optimalProbes = diagonal * LIGHTING_RESOLUTION; +const cascadeProbesMin = 2 ** Math.round(Math.log2(optimalProbes)); +const cascadeProbesX = aspect >= 1 + ? Math.round(cascadeProbesMin * aspect) + : cascadeProbesMin; +const cascadeProbesY = aspect >= 1 + ? cascadeProbesMin + : Math.round(cascadeProbesMin / aspect); +const cascadeDimX = cascadeProbesX * 2; // 2x2 stored rays per probe +const cascadeDimY = cascadeProbesY * 2; + +const interval0 = 1 / cascadeProbesMin; +const maxIntervalStart = 1.5; // ~diagonal in UV space +const cascadeAmount = Math.ceil( + Math.log2(maxIntervalStart * 3 / interval0 + 1) / 2, +); const cascadesTextureA = root['~unstable'] .createTexture({ - size: [dimX, dimY, cascadeAmount], + size: [cascadeDimX, cascadeDimY, cascadeAmount], format: 'rgba16float', }) .$usage('storage', 'sampled'); const cascadesTextureB = root['~unstable'] .createTexture({ - size: [dimX, dimY, cascadeAmount], + size: [cascadeDimX, cascadeDimY, cascadeAmount], format: 'rgba16float', }) .$usage('storage', 'sampled'); const radianceFieldTex = root['~unstable'] .createTexture({ - size: [baseProbesX, baseProbesY], + size: [outputProbesX, outputProbesY], format: 'rgba16float', }) .$usage('storage', 'sampled'); @@ -68,10 +74,16 @@ const radianceFieldStoreView = radianceFieldTex.createView( ); const buildRadianceFieldBGL = tgpu.bindGroupLayout({ - src: { storageTexture: d.textureStorage2d('rgba16float', 'read-only') }, + src: { texture: d.texture2d(d.f32) }, + srcSampler: { sampler: 'filtering' }, dst: { storageTexture: d.textureStorage2d('rgba16float', 'write-only') }, }); +const outputProbesUniform = root.createUniform( + d.vec2u, + d.vec2u(outputProbesX, outputProbesY), +); + const radianceSampler = root['~unstable'].createSampler({ magFilter: 'linear', minFilter: 'linear', @@ -81,26 +93,19 @@ const sceneDataUniform = root.createUniform(SceneData, sceneData); const cascadeIndexUniform = root.createUniform(d.u32); const probesUniform = root.createUniform(d.vec2u); -const dimUniform = root.createUniform(d.vec2u, d.vec2u(dimX, dimY)); -const baseProbesUniform = root.createUniform( +const cascadeDimUniform = root.createUniform( d.vec2u, - d.vec2u(baseProbesX, baseProbesY), + d.vec2u(cascadeDimX, cascadeDimY), +); +const cascadeProbesUniform = root.createUniform( + d.vec2u, + d.vec2u(cascadeProbesX, cascadeProbesY), ); -const AtlasLocal = d.struct({ - dir: d.vec2u, - probe: d.vec2u, -}); - -const atlasToDirFirst = (gid: d.v2u, probes: d.v2u) => { - 'use gpu'; - return AtlasLocal({ - dir: gid.div(probes), - probe: std.mod(gid, probes), - }); -}; +const overlayEnabledUniform = root.createUniform(d.u32, 0); +const overlayDebugCascadeUniform = root.createUniform(d.u32, 0); -const dirFirstAtlasPos = (dir: d.v2u, probe: d.v2u, probes: d.v2u) => { +const atlasPos = (dir: d.v2u, probe: d.v2u, probes: d.v2u) => { 'use gpu'; return dir.mul(probes).add(probe); }; @@ -122,66 +127,58 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { - const dim2 = dimUniform.$; - if (gid.x >= dim2.x || gid.y >= dim2.y) { - return; - } + const dim2 = cascadeDimUniform.$; + if (gid.x >= dim2.x || gid.y >= dim2.y) return; const layer = cascadeIndexUniform.$; const probes = probesUniform.$; - const topLayer = d.u32(cascadeAmount - 1); - - // Decode atlas position to (direction, probe) - const lp = atlasToDirFirst(gid.xy, probes); - const dirStored = lp.dir; - const probe = lp.probe; - - // Stored vs Actual ray dimensions: - // Each stored texel represents a 2x2 block of actual rays (pre-averaged) - const raysDimStoredX = d.u32(dim2.x / probes.x); - const raysDimStoredY = d.u32(dim2.y / probes.y); - const raysDimStored = std.min(raysDimStoredX, raysDimStoredY); - const raysDimActual = raysDimStored << d.u32(1); // 2x for cascade hierarchy + const cascadeProbes = cascadeProbesUniform.$; + + // Decode atlas position to (direction, probe) using direction-first layout + const dirStored = gid.xy.div(probes); + const probe = std.mod(gid.xy, probes); + + // Each stored texel = 2x2 actual rays; raysDimStored doubles per layer + const raysDimStored = d.u32(2) << layer; + const raysDimActual = raysDimStored * d.u32(2); const rayCountActual = raysDimActual * raysDimActual; + // Skip texels outside valid atlas region + 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)); - // Interval calculation - const baseProbes2 = baseProbesUniform.$; - const baseProbesMin = d.f32(std.min(baseProbes2.x, baseProbes2.y)); - const interval0Uv = 1.0 / baseProbesMin; + // Interval: each layer covers 4× the distance of the previous + // Use min probe dimension for uniform spacing in both directions + const cascadeProbesMinVal = d.f32(std.min(cascadeProbes.x, cascadeProbes.y)); + const interval0 = 1.0 / cascadeProbesMinVal; const pow4 = d.f32(d.u32(1) << (layer * d.u32(2))); - const startUv = interval0Uv * (pow4 - 1.0) / 3.0; - const endUv = startUv + interval0Uv * pow4; - - const eps = 0.5 / baseProbesMin; - const minStep = 0.25 / baseProbesMin; + const startUv = interval0 * (pow4 - 1.0) / 3.0; + const endUv = startUv + interval0 * pow4; + const eps = 0.5 / cascadeProbesMinVal; + const minStep = 0.25 / cascadeProbesMinVal; let accum = d.vec4f(); - // Cast 4 rays per stored texel (2x2 block) and average them + // Cast 4 rays per stored texel (2x2 block) and average for (let i = 0; i < 4; i++) { - const ox = d.u32(d.u32(i) & d.u32(1)); - const oy = d.u32(d.u32(i) >> d.u32(1)); - - // Map stored direction to actual direction (2x2 block) - const dirActual2D = dirStored.mul(d.u32(2)).add(d.vec2u(ox, oy)); - - // Compute ray direction from actual ray index - const rayIndex = d.f32(dirActual2D.y * raysDimActual + dirActual2D.x) + 0.5; + 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)); - // Raymarch let rgb = d.vec3f(); let T = d.f32(1); let t = startUv; for (let step = 0; step < 32; step++) { if (t > endUv) break; - const p = probePos.add(rayDir.mul(t)); - const hit = sceneSDF(p); - + const hit = sceneSDF(probePos.add(rayDir.mul(t))); if (hit.dist <= eps) { rgb = d.vec3f(hit.color); T = d.f32(0); @@ -190,31 +187,19 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ t += std.max(hit.dist, minStep); } - // Merge with upper cascade - if (layer < topLayer && T > 0.01) { + // Merge with upper cascade if ray didn't hit anything + if (layer < d.u32(cascadeAmount - 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)), ); - - // Upper cascade's stored resolution matches our actual resolution - const tileOriginU = d.vec2f( - d.f32(dirActual2D.x * probesU.x), - d.f32(dirActual2D.y * probesU.y), - ); - - // Map probe position to pixel coordinates within the tile - const probePixelU = probePos.mul(d.vec2f(probesU)); - - // Clamp to prevent bilinear bleeding into neighboring direction tiles - const clampedPixelU = std.clamp( - probePixelU, + 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), ); - - // Convert to UV space - const uvU = tileOriginU.add(clampedPixelU).div(d.vec2f(dim2)); + const uvU = tileOrigin.add(probePixel).div(d.vec2f(dim2)); const upper = std.textureSampleLevel( cascadePassBGL.$.upper, @@ -222,7 +207,6 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ uvU, 0, ); - rgb = rgb.add(upper.xyz.mul(T)); T = T * upper.w; } @@ -230,41 +214,74 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ accum = accum.add(d.vec4f(rgb, T)); } - // Store average of 4 rays - const outVal = accum.mul(0.25); - std.textureStore(cascadePassBGL.$.dst, gid.xy, outVal); + std.textureStore(cascadePassBGL.$.dst, gid.xy, accum.mul(0.25)); }); const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ workgroupSize: [8, 8], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { - const baseProbes2 = baseProbesUniform.$; - if (gid.x >= baseProbes2.x || gid.y >= baseProbes2.y) { - return; - } - - const dim2 = dimUniform.$; - const probes = baseProbes2; - const raysDimStoredX = d.u32(dim2.x / probes.x); - const raysDimStoredY = d.u32(dim2.y / probes.y); - const raysDimStored = std.min(raysDimStoredX, raysDimStoredY); // Should be 2 for cascade0 - const probe = gid.xy; - const rayCountStored = raysDimStored * raysDimStored; - - let sum = d.vec3f(); - let idx = d.u32(0); - while (idx < rayCountStored) { - // Direction-first layout: iterate over directions (row-major) - const dir = d.vec2u(idx % raysDimStored, idx / raysDimStored); - const atlasPx = dirFirstAtlasPos(dir, probe, probes); - const ray = std.textureLoad(buildRadianceFieldBGL.$.src, atlasPx); - sum = sum.add(ray.xyz); - idx += 1; - } - - const avg = sum.div(d.f32(rayCountStored)); - std.textureStore(buildRadianceFieldBGL.$.dst, probe, d.vec4f(avg, 1.0)); + const outputProbes = outputProbesUniform.$; + if (gid.x >= outputProbes.x || gid.y >= outputProbes.y) return; + + const cascadeProbes = cascadeProbesUniform.$; + const cascadeDim = cascadeDimUniform.$; + + const invCascadeDim = d.vec2f(1.0).div(d.vec2f(cascadeDim)); + const uv = d.vec2f(gid.xy).add(0.5).div(d.vec2f(outputProbes)); + + const probePixel = std.clamp( + uv.mul(d.vec2f(cascadeProbes)), + d.vec2f(0.5), + d.vec2f(cascadeProbes).sub(0.5), + ); + + const uvStride = d.vec2f(cascadeProbes).mul(invCascadeDim); + const baseSampleUV = probePixel.mul(invCascadeDim); + + // Sample Tile (0, 0) + let sum = std.textureSampleLevel( + buildRadianceFieldBGL.$.src, + buildRadianceFieldBGL.$.srcSampler, + baseSampleUV, + 0, + ).xyz; + + // Sample Tile (1, 0) + sum = sum.add( + std.textureSampleLevel( + buildRadianceFieldBGL.$.src, + buildRadianceFieldBGL.$.srcSampler, + baseSampleUV.add(d.vec2f(uvStride.x, 0.0)), + 0, + ).xyz, + ); + + // Sample Tile (0, 1) + sum = sum.add( + std.textureSampleLevel( + buildRadianceFieldBGL.$.src, + buildRadianceFieldBGL.$.srcSampler, + baseSampleUV.add(d.vec2f(0.0, uvStride.y)), + 0, + ).xyz, + ); + + // Sample Tile (1, 1) + sum = sum.add( + std.textureSampleLevel( + buildRadianceFieldBGL.$.src, + buildRadianceFieldBGL.$.srcSampler, + baseSampleUV.add(uvStride), + 0, + ).xyz, + ); + + std.textureStore( + buildRadianceFieldBGL.$.dst, + gid.xy, + d.vec4f(sum.mul(0.25), 1), + ); }); const ACESFilm = tgpu.fn([d.vec3f], d.vec3f)((x) => { @@ -287,6 +304,127 @@ const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ return d.vec4f(ACESFilm(outRgb), 1.0); }); +const overlayDebugBGL = tgpu.bindGroupLayout({ + cascadeTex: { texture: d.texture2dArray(d.f32) }, + cascadeSampler: { sampler: 'filtering' }, +}); + +const overlayFrag = tgpu['~unstable'].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) + .xyz; + const baseColor = ACESFilm(std.saturate(field)); + + if (overlayEnabledUniform.$ === d.u32(0)) { + return d.vec4f(baseColor, 1.0); + } + + const debugLayer = overlayDebugCascadeUniform.$; + const cascadeProbes = cascadeProbesUniform.$; + const probes = d.vec2u( + std.max(cascadeProbes.x >> debugLayer, d.u32(1)), + std.max(cascadeProbes.y >> debugLayer, d.u32(1)), + ); + + // Ray dimensions: 2x2 stored at layer 0, doubling each layer + const raysDimStored = d.u32(2) << debugLayer; + const raysDimActual = raysDimStored * d.u32(2); + const rayCountActual = raysDimActual * raysDimActual; + + // Interval for ray visualization + const cascadeProbesMinVal = d.f32(std.min(cascadeProbes.x, cascadeProbes.y)); + const interval0 = 1.0 / cascadeProbesMinVal; + const pow4 = d.f32(d.u32(1) << (debugLayer * d.u32(2))); + const endUv = interval0 * (pow4 - 1.0) / 3.0 + interval0 * pow4; + + // Visual parameters + const probeSpacing = std.min(1.0 / d.f32(probes.x), 1.0 / d.f32(probes.y)); + const probeRadius = std.max(probeSpacing * 0.08, 0.002); + const rayThickness = std.max(probeSpacing * 0.03, 0.001); + + let minProbeDist = d.f32(1000.0); + let minRayDist = d.f32(1000.0); + let closestRayColor = d.vec3f(); + + const centerProbe = d.vec2i(std.floor(uv.mul(d.vec2f(probes)))); + + // Check nearby probes (3x3 grid) + for (let py = -1; py <= 1; py++) { + for (let px = -1; px <= 1; px++) { + const probeXY = centerProbe.add(d.vec2i(px, py)); + if ( + probeXY.x < 0 || probeXY.x >= d.i32(probes.x) || probeXY.y < 0 || + probeXY.y >= d.i32(probes.y) + ) continue; + + const probe = d.vec2u(probeXY); + const probePos = d.vec2f(probe).add(0.5).div(d.vec2f(probes)); + minProbeDist = std.min( + minProbeDist, + sdf.sdDisk(uv.sub(probePos), probeRadius), + ); + + // Only draw rays near probe + if (std.length(uv.sub(probePos)) > probeSpacing * 0.7) continue; + + // Sample subset of rays + const rayStep = std.max(d.u32(1), rayCountActual / d.u32(24)); + let ri = d.u32(0); + while (ri < rayCountActual) { + const rayIndex = d.f32(ri) + 0.5; + const angle = (rayIndex / d.f32(rayCountActual)) * (Math.PI * 2) - + Math.PI; + const rayDir = d.vec2f(std.cos(angle), -std.sin(angle)); + const rayDist = sdf.sdLine( + uv, + probePos, + probePos.add(rayDir.mul(std.max(endUv, 0.01))), + ); + + if (rayDist < minRayDist) { + const dirStored = d.vec2u( + (ri % raysDimActual) >> d.u32(1), + d.u32(ri / raysDimActual) >> d.u32(1), + ); + const sample = std.textureLoad( + overlayDebugBGL.$.cascadeTex, + d.vec2i(atlasPos(dirStored, probe, probes)), + debugLayer, + 0, + ); + minRayDist = rayDist; + closestRayColor = sample.xyz; + } + ri = ri + rayStep; + } + } + } + + // Visualize rays + let overlayColor = d.vec3f(); + let overlayAlpha = d.f32(0); + if (minRayDist < rayThickness) { + overlayColor = ACESFilm(std.saturate(closestRayColor)); + overlayAlpha = + std.smoothstep(rayThickness * 1.5, rayThickness * 0.3, minRayDist) * 0.8; + } + + // Probe outline + if (std.abs(minProbeDist) < probeRadius * 0.2) { + const edgeAlpha = std.smoothstep( + probeRadius * 0.3, + probeRadius * 0.1, + std.abs(minProbeDist), + ) * 0.3; + overlayColor = std.mix(overlayColor, d.vec3f(1.0, 1.0, 0.0), edgeAlpha); + overlayAlpha = std.max(overlayAlpha, edgeAlpha); + } + + return d.vec4f(std.mix(baseColor, overlayColor, overlayAlpha), 1.0); +}); + const cascadePassPipeline = root['~unstable'] .with(sceneDataAccess, sceneDataUniform) .withCompute(cascadePassCompute) @@ -317,20 +455,21 @@ const buildRadianceFieldPipeline = root['~unstable'] .withCompute(buildRadianceFieldCompute) .createPipeline(); -// Pre-create bind groups for both possible cascade 0 locations (ping-pong result) const buildRadianceFieldBG_A = root.createBindGroup(buildRadianceFieldBGL, { - src: cascadesTextureA.createView( - d.textureStorage2d('rgba16float', 'read-only'), - { baseArrayLayer: 0, arrayLayerCount: 1 }, - ), + src: cascadesTextureA.createView(d.texture2d(d.f32), { + baseArrayLayer: 0, + arrayLayerCount: 1, + }), + srcSampler: cascadeSampler, dst: radianceFieldStoreView, }); const buildRadianceFieldBG_B = root.createBindGroup(buildRadianceFieldBGL, { - src: cascadesTextureB.createView( - d.textureStorage2d('rgba16float', 'read-only'), - { baseArrayLayer: 0, arrayLayerCount: 1 }, - ), + src: cascadesTextureB.createView(d.texture2d(d.f32), { + baseArrayLayer: 0, + arrayLayerCount: 1, + }), + srcSampler: cascadeSampler, dst: radianceFieldStoreView, }); @@ -344,25 +483,25 @@ function buildRadianceField() { buildRadianceFieldPipeline .with(buildRadianceFieldBG) .dispatchWorkgroups( - Math.ceil(baseProbesX / 8), - Math.ceil(baseProbesY / 8), + Math.ceil(outputProbesX / 8), + Math.ceil(outputProbesY / 8), ); } -// Top-down cascade dispatch (replaces separate raymarch + merge) function runCascadesTopDown() { - // Process from highest cascade down to 0 - // Each layer reads from layer+1 (already computed) and writes to itself for (let layer = cascadeAmount - 1; layer >= 0; layer--) { - const probesX = baseProbesX >> layer; - const probesY = baseProbesY >> layer; + const probesX = cascadeProbesX >> layer; + const probesY = cascadeProbesY >> layer; cascadeIndexUniform.write(layer); probesUniform.write(d.vec2u(probesX, probesY)); cascadePassPipeline .with(cascadePassBindGroups[layer]) - .dispatchWorkgroups(Math.ceil(dimX / 8), Math.ceil(dimY / 8)); + .dispatchWorkgroups( + Math.ceil(cascadeDimX / 8), + Math.ceil(cascadeDimY / 8), + ); } } @@ -372,18 +511,34 @@ function updateLighting() { } updateLighting(); +// Create bind groups for overlay debug - need both A and B textures for ping-pong +const overlayDebugBG_A = root.createBindGroup(overlayDebugBGL, { + cascadeTex: cascadesTextureA.createView(d.texture2dArray(d.f32)), + cascadeSampler: cascadeSampler, +}); + +const overlayDebugBG_B = root.createBindGroup(overlayDebugBGL, { + cascadeTex: cascadesTextureB.createView(d.texture2dArray(d.f32)), + cascadeSampler: cascadeSampler, +}); + const renderPipeline = root['~unstable'] .withVertex(fullScreenTriangle) - .withFragment(finalRadianceFieldFrag, { format: presentationFormat }) + .withFragment(overlayFrag, { format: presentationFormat }) .createPipeline(); let isRunning = true; let frameId: number; +let debugLayer = 0; -function frame() { +async function frame() { if (!isRunning) return; // Prevent using destroyed device + const writeToA = (cascadeAmount - 1 - debugLayer) % 2 === 0; + const overlayDebugBG = writeToA ? overlayDebugBG_A : overlayDebugBG_B; + renderPipeline + .with(overlayDebugBG) .withColorAttachment({ view: context.getCurrentTexture().createView(), loadOp: 'clear', @@ -421,3 +576,22 @@ export function onCleanup() { } root.destroy(); } + +export const controls = { + 'Show Overlay': { + initial: false, + onToggleChange: (value: boolean) => { + overlayEnabledUniform.write(value ? 1 : 0); + }, + }, + 'Cascade Layer': { + initial: 0, + min: 0, + max: cascadeAmount - 1, + step: 1, + onSliderChange: (value: number) => { + overlayDebugCascadeUniform.write(value); + debugLayer = value; + }, + }, +}; From 77561caad260cbfc9944bd65c9d13be4295cbeb0 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 13 Jan 2026 07:59:19 +0100 Subject: [PATCH 8/9] cleanup --- .../rendering/radiance-compute/index.ts | 175 ++++++++++-------- 1 file changed, 94 insertions(+), 81 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index 10600eca0c..79c131273c 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -1,8 +1,13 @@ -import tgpu from 'typegpu'; +import * as sdf from '@typegpu/sdf'; +import tgpu, { + type SampledFlag, + type StorageFlag, + type TgpuTexture, +} from 'typegpu'; import { fullScreenTriangle } from 'typegpu/common'; import * as d from 'typegpu/data'; import * as std from 'typegpu/std'; -import * as sdf from '@typegpu/sdf'; +import { DragController } from './drag-controller.ts'; import { SceneData, sceneData, @@ -10,7 +15,6 @@ import { sceneSDF, updateElementPosition, } from './scene.ts'; -import { DragController } from './drag-controller.ts'; const root = await tgpu.init(); const canvas = document.querySelector('canvas') as HTMLCanvasElement; @@ -23,7 +27,7 @@ context.configure({ }); const OUTPUT_RESOLUTION: [number, number] = [canvas.width, canvas.height]; -const LIGHTING_RESOLUTION = 0.4; +const LIGHTING_RESOLUTION = 0.35; const [outputProbesX, outputProbesY] = OUTPUT_RESOLUTION; const aspect = outputProbesX / outputProbesY; @@ -37,28 +41,33 @@ const cascadeProbesX = aspect >= 1 const cascadeProbesY = aspect >= 1 ? cascadeProbesMin : Math.round(cascadeProbesMin / aspect); -const cascadeDimX = cascadeProbesX * 2; // 2x2 stored rays per probe +const cascadeDimX = cascadeProbesX * 2; const cascadeDimY = cascadeProbesY * 2; const interval0 = 1 / cascadeProbesMin; -const maxIntervalStart = 1.5; // ~diagonal in UV space +const maxIntervalStart = 1.5; const cascadeAmount = Math.ceil( - Math.log2(maxIntervalStart * 3 / interval0 + 1) / 2, + Math.log2((maxIntervalStart * 3) / interval0 + 1) / 2, ); -const cascadesTextureA = root['~unstable'] - .createTexture({ - size: [cascadeDimX, cascadeDimY, cascadeAmount], - format: 'rgba16float', - }) - .$usage('storage', 'sampled'); - -const cascadesTextureB = root['~unstable'] - .createTexture({ - size: [cascadeDimX, cascadeDimY, cascadeAmount], - format: 'rgba16float', - }) - .$usage('storage', 'sampled'); +type CascadeTexture = + & TgpuTexture<{ + size: [number, number, number]; + format: 'rgba16float'; + }> + & StorageFlag + & SampledFlag; + +const cascadeTextures = Array.from( + { length: 2 }, + () => + root['~unstable'] + .createTexture({ + size: [cascadeDimX, cascadeDimY, cascadeAmount], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'), +) as [CascadeTexture, CascadeTexture]; const radianceFieldTex = root['~unstable'] .createTexture({ @@ -128,7 +137,9 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { const dim2 = cascadeDimUniform.$; - if (gid.x >= dim2.x || gid.y >= dim2.y) return; + if (gid.x >= dim2.x || gid.y >= dim2.y) { + return; + } const layer = cascadeIndexUniform.$; const probes = probesUniform.$; @@ -156,7 +167,7 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ const cascadeProbesMinVal = d.f32(std.min(cascadeProbes.x, cascadeProbes.y)); const interval0 = 1.0 / cascadeProbesMinVal; const pow4 = d.f32(d.u32(1) << (layer * d.u32(2))); - const startUv = interval0 * (pow4 - 1.0) / 3.0; + const startUv = (interval0 * (pow4 - 1.0)) / 3.0; const endUv = startUv + interval0 * pow4; const eps = 0.5 / cascadeProbesMinVal; const minStep = 0.25 / cascadeProbesMinVal; @@ -165,9 +176,9 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ // Cast 4 rays per stored texel (2x2 block) and average 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 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)); @@ -284,7 +295,10 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ ); }); -const ACESFilm = tgpu.fn([d.vec3f], d.vec3f)((x) => { +const ACESFilm = tgpu.fn( + [d.vec3f], + d.vec3f, +)((x) => { const a = 2.51; const b = 0.03; const c = 2.43; @@ -298,8 +312,11 @@ const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ in: { uv: d.vec2f }, out: d.vec4f, })(({ uv }) => { - const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv) - .xyz; + const field = std.textureSample( + radianceFieldView.$, + radianceSampler.$, + uv, + ).xyz; const outRgb = std.saturate(field); return d.vec4f(ACESFilm(outRgb), 1.0); }); @@ -318,7 +335,7 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ const baseColor = ACESFilm(std.saturate(field)); if (overlayEnabledUniform.$ === d.u32(0)) { - return d.vec4f(baseColor, 1.0); + return d.vec4f(baseColor, 1); } const debugLayer = overlayDebugCascadeUniform.$; @@ -335,17 +352,17 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ // Interval for ray visualization const cascadeProbesMinVal = d.f32(std.min(cascadeProbes.x, cascadeProbes.y)); - const interval0 = 1.0 / cascadeProbesMinVal; + const interval0 = 1 / cascadeProbesMinVal; const pow4 = d.f32(d.u32(1) << (debugLayer * d.u32(2))); - const endUv = interval0 * (pow4 - 1.0) / 3.0 + interval0 * pow4; + const endUv = (interval0 * (pow4 - 1)) / 3 + interval0 * pow4; // Visual parameters - const probeSpacing = std.min(1.0 / d.f32(probes.x), 1.0 / d.f32(probes.y)); + const probeSpacing = std.min(1 / d.f32(probes.x), 1 / d.f32(probes.y)); const probeRadius = std.max(probeSpacing * 0.08, 0.002); const rayThickness = std.max(probeSpacing * 0.03, 0.001); - let minProbeDist = d.f32(1000.0); - let minRayDist = d.f32(1000.0); + let minProbeDist = d.f32(1000); + let minRayDist = d.f32(1000); let closestRayColor = d.vec3f(); const centerProbe = d.vec2i(std.floor(uv.mul(d.vec2f(probes)))); @@ -355,9 +372,13 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ for (let px = -1; px <= 1; px++) { const probeXY = centerProbe.add(d.vec2i(px, py)); if ( - probeXY.x < 0 || probeXY.x >= d.i32(probes.x) || probeXY.y < 0 || + probeXY.x < 0 || + probeXY.x >= d.i32(probes.x) || + probeXY.y < 0 || probeXY.y >= d.i32(probes.y) - ) continue; + ) { + continue; + } const probe = d.vec2u(probeXY); const probePos = d.vec2f(probe).add(0.5).div(d.vec2f(probes)); @@ -366,15 +387,15 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ sdf.sdDisk(uv.sub(probePos), probeRadius), ); - // Only draw rays near probe - if (std.length(uv.sub(probePos)) > probeSpacing * 0.7) continue; + if (std.length(uv.sub(probePos)) > probeSpacing * 0.7) { + continue; + } - // Sample subset of rays - const rayStep = std.max(d.u32(1), rayCountActual / d.u32(24)); + const rayStep = std.max(1, d.u32(rayCountActual / 24)); let ri = d.u32(0); while (ri < rayCountActual) { const rayIndex = d.f32(ri) + 0.5; - const angle = (rayIndex / d.f32(rayCountActual)) * (Math.PI * 2) - + const angle = (rayIndex / rayCountActual) * (Math.PI * 2) - Math.PI; const rayDir = d.vec2f(std.cos(angle), -std.sin(angle)); const rayDist = sdf.sdLine( @@ -434,8 +455,8 @@ const cascadePassBindGroups = Array.from( { length: cascadeAmount }, (_, layer) => { const writeToA = (cascadeAmount - 1 - layer) % 2 === 0; - const dstTexture = writeToA ? cascadesTextureA : cascadesTextureB; - const srcTexture = writeToA ? cascadesTextureB : cascadesTextureA; + const dstTexture = cascadeTextures[writeToA ? 0 : 1]; + const srcTexture = cascadeTextures[writeToA ? 1 : 0]; return root.createBindGroup(cascadePassBGL, { upper: srcTexture.createView(d.texture2d(d.f32), { @@ -455,30 +476,25 @@ const buildRadianceFieldPipeline = root['~unstable'] .withCompute(buildRadianceFieldCompute) .createPipeline(); -const buildRadianceFieldBG_A = root.createBindGroup(buildRadianceFieldBGL, { - src: cascadesTextureA.createView(d.texture2d(d.f32), { - baseArrayLayer: 0, - arrayLayerCount: 1, - }), - srcSampler: cascadeSampler, - dst: radianceFieldStoreView, -}); - -const buildRadianceFieldBG_B = root.createBindGroup(buildRadianceFieldBGL, { - src: cascadesTextureB.createView(d.texture2d(d.f32), { - baseArrayLayer: 0, - arrayLayerCount: 1, - }), - srcSampler: cascadeSampler, - dst: radianceFieldStoreView, -}); +const createBuildRadianceFieldBG = (textureIndex: number) => + root.createBindGroup(buildRadianceFieldBGL, { + src: cascadeTextures[textureIndex].createView(d.texture2d(d.f32), { + baseArrayLayer: 0, + arrayLayerCount: 1, + }), + srcSampler: cascadeSampler, + dst: radianceFieldStoreView, + }); + +const buildRadianceFieldBindGroups = [ + createBuildRadianceFieldBG(0), + createBuildRadianceFieldBG(1), +]; function buildRadianceField() { - // Determine which texture has cascade 0 after ping-pong const cascade0InA = (cascadeAmount - 1) % 2 === 0; - const buildRadianceFieldBG = cascade0InA - ? buildRadianceFieldBG_A - : buildRadianceFieldBG_B; + const buildRadianceFieldBG = + buildRadianceFieldBindGroups[cascade0InA ? 0 : 1]; buildRadianceFieldPipeline .with(buildRadianceFieldBG) @@ -506,36 +522,35 @@ function runCascadesTopDown() { } function updateLighting() { - runCascadesTopDown(); // Fused raymarch + merge - buildRadianceField(); // Build final radiance field from cascade 0 + runCascadesTopDown(); + buildRadianceField(); } updateLighting(); -// Create bind groups for overlay debug - need both A and B textures for ping-pong -const overlayDebugBG_A = root.createBindGroup(overlayDebugBGL, { - cascadeTex: cascadesTextureA.createView(d.texture2dArray(d.f32)), - cascadeSampler: cascadeSampler, -}); +const createOverlayDebugBG = (textureIndex: number) => + root.createBindGroup(overlayDebugBGL, { + cascadeTex: cascadeTextures[textureIndex].createView( + d.texture2dArray(d.f32), + ), + cascadeSampler: cascadeSampler, + }); -const overlayDebugBG_B = root.createBindGroup(overlayDebugBGL, { - cascadeTex: cascadesTextureB.createView(d.texture2dArray(d.f32)), - cascadeSampler: cascadeSampler, -}); +const overlayDebugBindGroups = [ + createOverlayDebugBG(0), + createOverlayDebugBG(1), +]; const renderPipeline = root['~unstable'] .withVertex(fullScreenTriangle) .withFragment(overlayFrag, { format: presentationFormat }) .createPipeline(); -let isRunning = true; let frameId: number; let debugLayer = 0; async function frame() { - if (!isRunning) return; // Prevent using destroyed device - const writeToA = (cascadeAmount - 1 - debugLayer) % 2 === 0; - const overlayDebugBG = writeToA ? overlayDebugBG_A : overlayDebugBG_B; + const overlayDebugBG = overlayDebugBindGroups[writeToA ? 0 : 1]; renderPipeline .with(overlayDebugBG) @@ -553,7 +568,6 @@ function updateUniforms() { sceneDataUniform.write(sceneData); } -// Set up drag controller for interactive scene manipulation const dragController = new DragController( canvas, (id, position) => { @@ -569,7 +583,6 @@ const dragController = new DragController( ); export function onCleanup() { - isRunning = false; // Stop the loop logic immediately dragController.destroy(); if (frameId !== null) { cancelAnimationFrame(frameId); From a6773877e3070cd386f4f0d50095548b543b039d Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Wed, 14 Jan 2026 15:22:59 +0100 Subject: [PATCH 9/9] more work --- apps/typegpu-docs/package.json | 1 + .../radiance-compute/drag-controller.ts | 30 +- .../rendering/radiance-compute/index.ts | 150 ++---- .../rendering/radiance-compute/scene.ts | 10 +- .../rendering/radiance-flood/index.html | 1 + .../rendering/radiance-flood/index.ts | 319 ++++++++++++ .../rendering/radiance-flood/meta.json | 5 + packages/typegpu-radiance-cascades/README.md | 37 ++ .../typegpu-radiance-cascades/build.config.ts | 12 + packages/typegpu-radiance-cascades/deno.json | 7 + .../typegpu-radiance-cascades/package.json | 44 ++ .../typegpu-radiance-cascades/src/cascades.ts | 243 +++++++++ .../typegpu-radiance-cascades/src/index.ts | 12 + .../typegpu-radiance-cascades/src/runner.ts | 352 +++++++++++++ .../typegpu-radiance-cascades/tsconfig.json | 5 + packages/typegpu-sdf/src/index.ts | 8 + packages/typegpu-sdf/src/jumpFlood.ts | 470 ++++++++++++++++++ packages/typegpu/src/core/texture/texture.ts | 3 + packages/typegpu/src/index.ts | 2 +- pnpm-lock.yaml | 25 + 20 files changed, 1590 insertions(+), 146 deletions(-) create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-flood/index.html create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-flood/index.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/radiance-flood/meta.json create mode 100644 packages/typegpu-radiance-cascades/README.md create mode 100644 packages/typegpu-radiance-cascades/build.config.ts create mode 100644 packages/typegpu-radiance-cascades/deno.json create mode 100644 packages/typegpu-radiance-cascades/package.json create mode 100644 packages/typegpu-radiance-cascades/src/cascades.ts create mode 100644 packages/typegpu-radiance-cascades/src/index.ts create mode 100644 packages/typegpu-radiance-cascades/src/runner.ts create mode 100644 packages/typegpu-radiance-cascades/tsconfig.json create mode 100644 packages/typegpu-sdf/src/jumpFlood.ts diff --git a/apps/typegpu-docs/package.json b/apps/typegpu-docs/package.json index 17abaa8fa8..35630572e9 100644 --- a/apps/typegpu-docs/package.json +++ b/apps/typegpu-docs/package.json @@ -30,6 +30,7 @@ "@typegpu/noise": "workspace:*", "@typegpu/three": "workspace:*", "@typegpu/sdf": "workspace:*", + "@typegpu/radiance-cascades": "workspace:*", "three": "catalog:example", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts index 9ded947ee2..4bf8350b2c 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/drag-controller.ts @@ -3,10 +3,7 @@ import type { AnySceneElement } from './scene.ts'; import { sceneElements } from './scene.ts'; import * as d from 'typegpu/data'; -export interface DragTarget { - id: string; - element: AnySceneElement; -} +type DragTarget = AnySceneElement; export class DragController { private isDragging = false; @@ -28,34 +25,23 @@ export class DragController { } private hitTestDisk(uv: d.v2f, center: d.v2f, radius: number): boolean { - const dist = sdDisk(uv.sub(center), radius); - return dist <= radius; + return sdDisk(uv.sub(center), radius) <= 0; } private hitTestBox(uv: d.v2f, center: d.v2f, size: d.v2f): boolean { - const dist = sdBox2d(uv.sub(center), size.mul(1)); - return dist <= 0; + return sdBox2d(uv.sub(center), size) <= 0; } private hitTest(clientX: number, clientY: number): DragTarget | null { const uv = this.canvasToUV(clientX, clientY); - - for (const element of sceneElements) { - let hit = false; - - if (element.type === 'disk') { - const radius = element.size as number; - hit = this.hitTestDisk(uv, element.position, radius); - } else if (element.type === 'box') { - const size = element.size as d.v2f; - hit = this.hitTestBox(uv, element.position, size); - } - + for (const el of sceneElements) { + const hit = el.type === 'disk' + ? this.hitTestDisk(uv, el.position, el.size as number) + : this.hitTestBox(uv, el.position, el.size as d.v2f); if (hit) { - return { id: element.id, element }; + return el; } } - return null; } diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts index 79c131273c..61ac2dae24 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/index.ts @@ -1,9 +1,5 @@ import * as sdf from '@typegpu/sdf'; -import tgpu, { - type SampledFlag, - type StorageFlag, - type TgpuTexture, -} from 'typegpu'; +import tgpu from 'typegpu'; import { fullScreenTriangle } from 'typegpu/common'; import * as d from 'typegpu/data'; import * as std from 'typegpu/std'; @@ -50,24 +46,13 @@ const cascadeAmount = Math.ceil( Math.log2((maxIntervalStart * 3) / interval0 + 1) / 2, ); -type CascadeTexture = - & TgpuTexture<{ - size: [number, number, number]; - format: 'rgba16float'; - }> - & StorageFlag - & SampledFlag; - -const cascadeTextures = Array.from( - { length: 2 }, - () => - root['~unstable'] - .createTexture({ - size: [cascadeDimX, cascadeDimY, cascadeAmount], - format: 'rgba16float', - }) - .$usage('storage', 'sampled'), -) as [CascadeTexture, CascadeTexture]; +const cascadeTextures = Array.from({ length: 2 }, () => + root['~unstable'] + .createTexture({ + size: [cascadeDimX, cascadeDimY, cascadeAmount], + format: 'rgba16float', + }) + .$usage('storage', 'sampled')); const radianceFieldTex = root['~unstable'] .createTexture({ @@ -114,11 +99,6 @@ const cascadeProbesUniform = root.createUniform( const overlayEnabledUniform = root.createUniform(d.u32, 0); const overlayDebugCascadeUniform = root.createUniform(d.u32, 0); -const atlasPos = (dir: d.v2u, probe: d.v2u, probes: d.v2u) => { - 'use gpu'; - return dir.mul(probes).add(probe); -}; - const cascadePassBGL = tgpu.bindGroupLayout({ upper: { texture: d.texture2d(d.f32) }, upperSampler: { sampler: 'filtering' }, @@ -145,25 +125,18 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ const probes = probesUniform.$; const cascadeProbes = cascadeProbesUniform.$; - // Decode atlas position to (direction, probe) using direction-first layout const dirStored = gid.xy.div(probes); const probe = std.mod(gid.xy, probes); - - // Each stored texel = 2x2 actual rays; raysDimStored doubles per layer const raysDimStored = d.u32(2) << layer; const raysDimActual = raysDimStored * d.u32(2); const rayCountActual = raysDimActual * raysDimActual; - // Skip texels outside valid atlas region 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)); - - // Interval: each layer covers 4× the distance of the previous - // Use min probe dimension for uniform spacing in both directions const cascadeProbesMinVal = d.f32(std.min(cascadeProbes.x, cascadeProbes.y)); const interval0 = 1.0 / cascadeProbesMinVal; const pow4 = d.f32(d.u32(1) << (layer * d.u32(2))); @@ -174,7 +147,6 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ let accum = d.vec4f(); - // Cast 4 rays per stored texel (2x2 block) and average for (let i = 0; i < 4; i++) { const dirActual = dirStored .mul(d.u32(2)) @@ -187,8 +159,10 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ let T = d.f32(1); let t = startUv; - for (let step = 0; step < 32; step++) { - if (t > endUv) break; + for (let step = 0; step < 64; step++) { + if (t > endUv) { + break; + } const hit = sceneSDF(probePos.add(rayDir.mul(t))); if (hit.dist <= eps) { rgb = d.vec3f(hit.color); @@ -198,7 +172,6 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ t += std.max(hit.dist, minStep); } - // Merge with upper cascade if ray didn't hit anything if (layer < d.u32(cascadeAmount - 1) && T > 0.01) { const probesU = d.vec2u( std.max(probes.x >> d.u32(1), d.u32(1)), @@ -219,7 +192,7 @@ const cascadePassCompute = tgpu['~unstable'].computeFn({ 0, ); rgb = rgb.add(upper.xyz.mul(T)); - T = T * upper.w; + T *= upper.w; } accum = accum.add(d.vec4f(rgb, T)); @@ -233,7 +206,9 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { const outputProbes = outputProbesUniform.$; - if (gid.x >= outputProbes.x || gid.y >= outputProbes.y) return; + if (gid.x >= outputProbes.x || gid.y >= outputProbes.y) { + return; + } const cascadeProbes = cascadeProbesUniform.$; const cascadeDim = cascadeDimUniform.$; @@ -250,43 +225,18 @@ const buildRadianceFieldCompute = tgpu['~unstable'].computeFn({ const uvStride = d.vec2f(cascadeProbes).mul(invCascadeDim); const baseSampleUV = probePixel.mul(invCascadeDim); - // Sample Tile (0, 0) - let sum = std.textureSampleLevel( - buildRadianceFieldBGL.$.src, - buildRadianceFieldBGL.$.srcSampler, - baseSampleUV, - 0, - ).xyz; - - // Sample Tile (1, 0) - sum = sum.add( - std.textureSampleLevel( - buildRadianceFieldBGL.$.src, - buildRadianceFieldBGL.$.srcSampler, - baseSampleUV.add(d.vec2f(uvStride.x, 0.0)), - 0, - ).xyz, - ); - - // Sample Tile (0, 1) - sum = sum.add( - std.textureSampleLevel( - buildRadianceFieldBGL.$.src, - buildRadianceFieldBGL.$.srcSampler, - baseSampleUV.add(d.vec2f(0.0, uvStride.y)), - 0, - ).xyz, - ); - - // Sample Tile (1, 1) - sum = sum.add( - std.textureSampleLevel( - buildRadianceFieldBGL.$.src, - buildRadianceFieldBGL.$.srcSampler, - baseSampleUV.add(uvStride), - 0, - ).xyz, - ); + 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, @@ -308,19 +258,6 @@ const ACESFilm = tgpu.fn( return std.saturate(res); }); -const finalRadianceFieldFrag = tgpu['~unstable'].fragmentFn({ - in: { uv: d.vec2f }, - out: d.vec4f, -})(({ uv }) => { - const field = std.textureSample( - radianceFieldView.$, - radianceSampler.$, - uv, - ).xyz; - const outRgb = std.saturate(field); - return d.vec4f(ACESFilm(outRgb), 1.0); -}); - const overlayDebugBGL = tgpu.bindGroupLayout({ cascadeTex: { texture: d.texture2dArray(d.f32) }, cascadeSampler: { sampler: 'filtering' }, @@ -344,19 +281,13 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ std.max(cascadeProbes.x >> debugLayer, d.u32(1)), std.max(cascadeProbes.y >> debugLayer, d.u32(1)), ); - - // Ray dimensions: 2x2 stored at layer 0, doubling each layer const raysDimStored = d.u32(2) << debugLayer; const raysDimActual = raysDimStored * d.u32(2); const rayCountActual = raysDimActual * raysDimActual; - - // Interval for ray visualization const cascadeProbesMinVal = d.f32(std.min(cascadeProbes.x, cascadeProbes.y)); const interval0 = 1 / cascadeProbesMinVal; const pow4 = d.f32(d.u32(1) << (debugLayer * d.u32(2))); const endUv = (interval0 * (pow4 - 1)) / 3 + interval0 * pow4; - - // Visual parameters const probeSpacing = std.min(1 / d.f32(probes.x), 1 / d.f32(probes.y)); const probeRadius = std.max(probeSpacing * 0.08, 0.002); const rayThickness = std.max(probeSpacing * 0.03, 0.001); @@ -367,7 +298,6 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ const centerProbe = d.vec2i(std.floor(uv.mul(d.vec2f(probes)))); - // Check nearby probes (3x3 grid) for (let py = -1; py <= 1; py++) { for (let px = -1; px <= 1; px++) { const probeXY = centerProbe.add(d.vec2i(px, py)); @@ -411,7 +341,7 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ ); const sample = std.textureLoad( overlayDebugBGL.$.cascadeTex, - d.vec2i(atlasPos(dirStored, probe, probes)), + d.vec2i(dirStored.mul(probes).add(probe)), debugLayer, 0, ); @@ -423,7 +353,6 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ } } - // Visualize rays let overlayColor = d.vec3f(); let overlayAlpha = d.f32(0); if (minRayDist < rayThickness) { @@ -432,7 +361,6 @@ const overlayFrag = tgpu['~unstable'].fragmentFn({ std.smoothstep(rayThickness * 1.5, rayThickness * 0.3, minRayDist) * 0.8; } - // Probe outline if (std.abs(minProbeDist) < probeRadius * 0.2) { const edgeAlpha = std.smoothstep( probeRadius * 0.3, @@ -564,23 +492,13 @@ async function frame() { } frameId = requestAnimationFrame(frame); -function updateUniforms() { +const onDrag = (id: string, position: d.v2f) => { + updateElementPosition(id, position); sceneDataUniform.write(sceneData); -} + updateLighting(); +}; -const dragController = new DragController( - canvas, - (id, position) => { - updateElementPosition(id, position); - updateUniforms(); - updateLighting(); - }, - (id, position) => { - updateElementPosition(id, position); - updateUniforms(); - updateLighting(); - }, -); +const dragController = new DragController(canvas, onDrag, onDrag); export function onCleanup() { dragController.destroy(); diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts b/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts index 29544b4f8c..2a1de415b1 100644 --- a/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts +++ b/apps/typegpu-docs/src/examples/rendering/radiance-compute/scene.ts @@ -8,12 +8,11 @@ export interface SceneElement { position: d.v2f; size: T extends 'box' ? d.v2f : number; emission?: d.v3f; - dataIndex: number; // Index in sceneData.disks or sceneData.boxes + dataIndex: number; } export type AnySceneElement = SceneElement<'box'> | SceneElement<'disk'>; export const sceneElements: AnySceneElement[] = [ - // 3 RGB lights (disks with emission) { id: 'light-red', type: 'disk', @@ -38,8 +37,6 @@ export const sceneElements: AnySceneElement[] = [ size: 0.05, dataIndex: 2, }, - - // 3 occluders { id: 'box-1', type: 'box', @@ -91,7 +88,6 @@ export function updateElementPosition(id: string, position: d.v2f): void { return; } - // Direct O(1) array access using stored index element.position = position; if (element.type === 'disk') { sceneData.disks[element.dataIndex].pos = position; @@ -118,8 +114,8 @@ const BoxData = d.struct({ }); export const SceneData = d.struct({ - disks: d.arrayOf(DiskData, 4), - boxes: d.arrayOf(BoxData, 2), + disks: d.arrayOf(DiskData, sceneData.disks.length), + boxes: d.arrayOf(BoxData, sceneData.boxes.length), }); export const sceneDataAccess = tgpu['~unstable'].accessor(SceneData); 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':