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