diff --git a/apps/typegpu-docs/package.json b/apps/typegpu-docs/package.json index 2b8273217d..dbd52ef384 100644 --- a/apps/typegpu-docs/package.json +++ b/apps/typegpu-docs/package.json @@ -31,6 +31,7 @@ "@typegpu/color": "workspace:*", "@typegpu/geometry": "workspace:*", "@typegpu/noise": "workspace:*", + "@typegpu/radiance-cascades": "workspace:*", "@typegpu/sdf": "workspace:*", "@typegpu/sort": "workspace:*", "@typegpu/three": "workspace:*", diff --git a/apps/typegpu-docs/src/examples/algorithms/jump-flood-distance/index.ts b/apps/typegpu-docs/src/examples/algorithms/jump-flood-distance/index.ts index aed6fef874..ce3767e05b 100644 --- a/apps/typegpu-docs/src/examples/algorithms/jump-flood-distance/index.ts +++ b/apps/typegpu-docs/src/examples/algorithms/jump-flood-distance/index.ts @@ -23,6 +23,8 @@ const canvas = document.querySelector('canvas') as HTMLCanvasElement; const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); const context = root.configureContext({ canvas }); +const F32_MAX = 3.40282346e38; + let brushSize = 1; let isDrawing = false; let lastDrawPos: { x: number; y: number } | null = null; @@ -180,8 +182,8 @@ const jumpFlood = root.createGuardedComputePipeline((x, y) => { let bestInsideCoord = d.vec2f(-1); let bestOutsideCoord = d.vec2f(-1); - let bestInsideDist = 1e20; - let bestOutsideDist = 1e20; + let bestInsideDist = d.f32(F32_MAX); + let bestOutsideDist = d.f32(F32_MAX); for (const dx of tgpu.unroll([-1, 0, 1])) { for (const dy of tgpu.unroll([-1, 0, 1])) { @@ -242,8 +244,8 @@ const createDistanceField = root.createGuardedComputePipeline((x, y) => { const insideCoord = texel.xy; const outsideCoord = texel.zw; - let insideDist = 1e20; - let outsideDist = 1e20; + let insideDist = d.f32(F32_MAX); + let outsideDist = d.f32(F32_MAX); if (insideCoord.x >= 0) { insideDist = std.distance(pos, insideCoord.mul(d.vec2f(size))); diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/drawInteraction.ts b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/drawInteraction.ts new file mode 100644 index 0000000000..d8d326f7c8 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/drawInteraction.ts @@ -0,0 +1,176 @@ +import { d } from 'typegpu'; + +type Point = { x: number; y: number }; + +type DrawInteractionOptions = { + canvas: HTMLCanvasElement; + onDraw: (state: { last: Point | null; current: Point; color: d.v3f }) => void; + onStop: () => void; +}; + +function hslToRgb(h: number, s: number, l: number) { + const a = s * Math.min(l, 1 - l); + + function channel(n: number) { + const k = (n + h * 12) % 12; + return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + } + + return d.vec3f(channel(0), channel(8), channel(4)); +} + +export function createDrawInteraction({ canvas, onDraw, onStop }: DrawInteractionOptions) { + let last: Point | null = null; + let current: Point | null = null; + let isDrawing = false; + let activeButton: 0 | 2 = 0; + let primaryColor = d.vec3f(1, 0.9, 0.7); + let secondaryColor = d.vec3f(0.25, 0.55, 1); + let animateColor = false; + + function brushColor(timestamp = performance.now()) { + if (activeButton === 2) { + return secondaryColor; + } + + if (!animateColor) { + return primaryColor; + } + + return hslToRgb((timestamp * 0.00008) % 1, 0.82, 0.62); + } + + function mousePosition(e: MouseEvent): Point { + const rect = canvas.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) / rect.width, + y: (e.clientY - rect.top) / rect.height, + }; + } + + function touchPosition(touches: TouchList): Point { + const first = touches[0]; + const second = touches[1]; + const clientX = second ? (first.clientX + second.clientX) / 2 : first.clientX; + const clientY = second ? (first.clientY + second.clientY) / 2 : first.clientY; + + const rect = canvas.getBoundingClientRect(); + return { + x: (clientX - rect.left) / rect.width, + y: (clientY - rect.top) / rect.height, + }; + } + + function setButton(useSecondary: boolean) { + activeButton = useSecondary ? 2 : 0; + } + + function draw(point: Point, timestamp?: number) { + if (!isDrawing) { + return; + } + + const previous = last; + last = point; + current = point; + onDraw({ last: previous, current: point, color: brushColor(timestamp) }); + } + + function start(point: Point, useSecondary: boolean) { + setButton(useSecondary); + isDrawing = true; + last = null; + draw(point); + } + + function stop() { + if (!isDrawing) { + return; + } + + onStop(); + isDrawing = false; + last = null; + current = null; + } + + function onMouseDown(e: MouseEvent) { + if (e.button !== 0 && e.button !== 2) { + return; + } + + start(mousePosition(e), e.button === 2); + } + + function onMouseMove(e: MouseEvent) { + draw(mousePosition(e)); + } + + function onTouchStart(e: TouchEvent) { + if (e.touches.length === 0) { + return; + } + + e.preventDefault(); + start(touchPosition(e.touches), e.touches.length >= 2); + } + + function onTouchMove(e: TouchEvent) { + if (e.touches.length === 0) { + return; + } + + e.preventDefault(); + setButton(e.touches.length >= 2); + draw(touchPosition(e.touches)); + } + + function onTouchEnd(e: TouchEvent) { + if (e.touches.length === 0) { + stop(); + } else { + start(touchPosition(e.touches), e.touches.length >= 2); + } + } + + canvas.addEventListener('mousedown', onMouseDown); + canvas.addEventListener('mousemove', onMouseMove); + canvas.addEventListener('mouseup', stop); + canvas.addEventListener('mouseleave', stop); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + canvas.addEventListener('touchstart', onTouchStart, { passive: false }); + canvas.addEventListener('touchmove', onTouchMove, { passive: false }); + canvas.addEventListener('touchend', onTouchEnd); + canvas.addEventListener('touchcancel', stop); + + return { + controls: { + 'Primary Color': { + initial: primaryColor, + onColorChange(value: d.v3f) { + primaryColor = value; + }, + }, + 'Secondary Color': { + initial: secondaryColor, + onColorChange(value: d.v3f) { + secondaryColor = value; + }, + }, + 'Animated Color': { + initial: false, + onToggleChange(value: boolean) { + animateColor = value; + }, + }, + }, + + update(timestamp: number) { + if (isDrawing && animateColor && activeButton === 0 && current) { + onDraw({ last: null, current, color: brushColor(timestamp) }); + } + }, + + stop, + }; +} diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/index.html b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/index.html new file mode 100644 index 0000000000..aa8cc321b3 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/index.ts new file mode 100644 index 0000000000..d1600e4189 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/index.ts @@ -0,0 +1,253 @@ +import * as rc from '@typegpu/radiance-cascades'; +import * as sdf from '@typegpu/sdf'; +import tgpu, { common, d, std } from 'typegpu'; +import { defineControls } from '../../common/defineControls.ts'; +import { createDrawInteraction } from './drawInteraction.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 [width, height] = [canvas.width, canvas.height]; + +// Scene texture + views. +const sceneTexture = root + .createTexture({ + size: [width, height], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + +const sceneWriteView = sceneTexture.createView(d.textureStorage2d('rgba16float')); +const sceneSampledView = sceneTexture.createView(); + +// Samplers. +const linSampler = root.createSampler({ + magFilter: 'linear', + minFilter: 'linear', +}); + +// Draw params + uniform. +const DrawParams = d.struct({ + isDrawing: d.u32, + lastMousePos: d.vec2f, + mousePos: d.vec2f, + brushRadius: d.f32, + lightColor: d.vec3f, +}); + +const paramsUniform = root.createUniform(DrawParams, { + isDrawing: 0, + lastMousePos: d.vec2f(0.5), + mousePos: d.vec2f(0.5), + brushRadius: 0.05, + lightColor: d.vec3f(1, 0.9, 0.7), +}); + +const sceneDataLayout = tgpu.bindGroupLayout({ + sceneRead: { texture: d.texture2d() }, +}); +const sceneDataBG = root.createBindGroup(sceneDataLayout, { + sceneRead: sceneSampledView, +}); + +const drawCompute = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + const params = paramsUniform.$; + if (params.isDrawing === 0) { + return; + } + + const uv = (d.vec2f(x, y) + 0.5) / d.vec2f(std.textureDimensions(sceneWriteView.$)); + + const noLast = std.any(std.lt(params.lastMousePos, d.vec2f(0))); + const a = std.select(params.lastMousePos, params.mousePos, noLast); + + const dist = sdf.sdLine(uv, a, params.mousePos); + if (dist >= params.brushRadius) { + return; + } + + const out = d.vec4f(params.lightColor, 1); + + std.textureStore(sceneWriteView.$, d.vec2u(x, y), out); +}); + +const floodSize = { width: canvas.width, height: canvas.height }; +const floodRunner = sdf + .createJumpFlood({ + root, + size: floodSize, + classify: (coord: d.v2u, size: d.v2u) => { + 'use gpu'; + const sceneData = std.textureSampleLevel( + sceneDataLayout.$.sceneRead, + linSampler.$, + (d.vec2f(coord) + 0.5) / d.vec2f(size), + 0, + ); + return sceneData.w > 0; + }, + getSdf: (_coord, size, signedDist) => { + 'use gpu'; + const minDim = std.min(size.x, size.y); + return signedDist / minDim; + }, + getColor: (_coord, size, _signedDist, insidePx) => { + 'use gpu'; + const uv = (d.vec2f(insidePx) + 0.5) / d.vec2f(size); + const seedData = std.textureSampleLevel(sceneDataLayout.$.sceneRead, linSampler.$, uv, 0); + return d.vec4f(seedData.xyz, 1); + }, + }) + .with(sceneDataBG); + +const floodSdfView = floodRunner.sdfOutput.createView(); +const floodColorView = floodRunner.colorOutput.createView(); + +const radianceRunner = rc.createRadianceCascades({ + root, + size: { width: Math.floor(width / 4), height: Math.floor(height / 4) }, + sdfResolution: floodSize, + sdf: (uv) => { + 'use gpu'; + if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) { + return 1; + } + return std.textureSampleLevel(floodSdfView.$, linSampler.$, uv, 0).x; + }, + color: (uv) => { + 'use gpu'; + return std.textureSampleLevel(floodColorView.$, linSampler.$, uv, 0).xyz; + }, +}); +const radianceRes = radianceRunner.output.createView(d.texture2d()); + +// Display pipeline. +const displayModeUniform = root.createUniform(d.u32); +const displayFragment = tgpu.fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + 'use gpu'; + let result = d.vec4f(0); + if (displayModeUniform.$ === 0) { + const sdfDist = std.textureSampleLevel(floodSdfView.$, linSampler.$, uv, 0).x; + const sdfTexel = 1 / d.f32(std.textureDimensions(floodSdfView.$).x); + const edgeWidth = std.max(std.fwidth(sdfDist), sdfTexel); + const surfaceAlpha = 1 - std.smoothstep(-edgeWidth, edgeWidth, sdfDist); + + const seedColor = std.textureSampleLevel(floodColorView.$, linSampler.$, uv, 0); + const radiance = std.textureSampleLevel(radianceRes.$, linSampler.$, uv, 0); + result = d.vec4f(std.mix(radiance.xyz, seedColor.xyz, surfaceAlpha), 1); + } else { + const signedDist = std.textureSampleLevel(floodSdfView.$, linSampler.$, uv, 0).x; + const absDist = std.abs(signedDist); + + const normalizedDist = std.clamp(absDist * 2, 0, 1) ** 0.8; + + const isInside = signedDist < 0; + + const distColor = std.select( + d.vec3f(normalizedDist, 0, 0), + d.vec3f(0, 0, normalizedDist), + isInside, + ); + + result = d.vec4f(distColor, 1); + } + + return result; +}); + +const displayPipeline = root.createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: displayFragment, + targets: { format: presentationFormat }, +}); + +let sceneDirty = false; + +function drawScene() { + drawCompute.dispatchThreads(width, height); + sceneDirty = true; +} + +function updateScene() { + if (sceneDirty) { + floodRunner.run(); + radianceRunner.run(); + sceneDirty = false; + } +} + +const drawInteraction = createDrawInteraction({ + canvas, + onDraw({ last, current, color }) { + paramsUniform.patch({ + lastMousePos: d.vec2f(last?.x ?? -1, last?.y ?? -1), + mousePos: d.vec2f(current.x, current.y), + lightColor: color, + isDrawing: 1, + }); + drawScene(); + }, + onStop() { + updateScene(); + paramsUniform.patch({ isDrawing: 0 }); + }, +}); + +let frameId = requestAnimationFrame(frame); +function frame(timestamp: number) { + drawInteraction.update(timestamp); + updateScene(); + + displayPipeline.withColorAttachment({ view: context }).draw(3); + + frameId = requestAnimationFrame(frame); +} + +// #region Example controls and cleanup + +export const controls = defineControls({ + ...drawInteraction.controls, + 'Brush Size': { + initial: 0.05, + min: 0.01, + max: 0.15, + step: 0.01, + onSliderChange(value: number) { + paramsUniform.patch({ + brushRadius: value, + }); + }, + }, + 'Display Mode': { + initial: 'Radiance', + options: ['Radiance', 'Distance'], + onSelectChange(value: string) { + displayModeUniform.write(value === 'Radiance' ? 0 : 1); + }, + }, + Clear: { + onButtonClick() { + sceneTexture.clear(); + sceneDirty = true; + }, + }, +}); + +export function onCleanup() { + cancelAnimationFrame(frameId); + root.destroy(); +} + +// #endregion diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/meta.json b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/meta.json new file mode 100644 index 0000000000..bbb6fc1e90 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Radiance Cascades (with drawing)", + "category": "rendering", + "tags": ["ecosystem", "interaction", "lighting", "sdf", "spatial"], + "coolFactor": 8 +} diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/thumbnail.png new file mode 100644 index 0000000000..50c06d9f72 Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/radiance-cascades-drawing/thumbnail.png differ diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades/drag-controller.ts b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/drag-controller.ts new file mode 100644 index 0000000000..f86b983c38 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/drag-controller.ts @@ -0,0 +1,151 @@ +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) + : this.hitTestBox(uv, el.position, el.size); + if (hit) { + return el; + } + } + return null; + } + + private setupEventListeners() { + this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this)); + this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); + this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this)); + this.canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this)); + this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false }); + this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false }); + this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this)); + this.canvas.addEventListener('touchcancel', this.onTouchEnd.bind(this)); + } + + 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'; + } + } + + private touchPoint(e: TouchEvent): Touch | null { + return e.touches[0] ?? e.changedTouches[0] ?? null; + } + + private onTouchStart(e: TouchEvent) { + const touch = this.touchPoint(e); + if (!touch) { + return; + } + + const target = this.hitTest(touch.clientX, touch.clientY); + if (target) { + e.preventDefault(); + this.isDragging = true; + this.draggedElement = target; + } + } + + private onTouchMove(e: TouchEvent) { + if (!this.isDragging || !this.draggedElement) { + return; + } + + const touch = this.touchPoint(e); + if (!touch) { + return; + } + + e.preventDefault(); + const newPos = this.canvasToUV(touch.clientX, touch.clientY); + this.onDragMove(this.draggedElement.id, newPos); + } + + private onTouchEnd(e: TouchEvent) { + if (!this.isDragging || !this.draggedElement) { + return; + } + + const touch = this.touchPoint(e); + if (touch) { + const finalPos = this.canvasToUV(touch.clientX, touch.clientY); + this.onDragEnd(this.draggedElement.id, finalPos); + } + + this.isDragging = false; + this.draggedElement = null; + } + + destroy() { + this.canvas.style.cursor = 'default'; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades/index.html b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/index.html new file mode 100644 index 0000000000..aa8cc321b3 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades/index.ts b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/index.ts new file mode 100644 index 0000000000..286dde4bbc --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/index.ts @@ -0,0 +1,501 @@ +import * as sdf from '@typegpu/sdf'; +import tgpu, { common, d, std } from 'typegpu'; +import { defineControls } from '../../common/defineControls.ts'; +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 LIGHTING_RESOLUTION = 0.35; +const maxOutputResolution = 1024; + +function getCascadeDimensions() { + const outputProbes = Math.max(1, Math.floor(Math.min(canvas.width, maxOutputResolution))); + const diagonal = outputProbes * Math.SQRT2; + const optimalProbes = diagonal * LIGHTING_RESOLUTION; + const cascadeProbes = 2 ** Math.round(Math.log2(optimalProbes)); + + return { + outputProbes, + cascadeProbes, + cascadeDim: cascadeProbes * 2, + }; +} + +let dimensions = getCascadeDimensions(); + +const interval0 = 1 / dimensions.cascadeProbes; +const maxIntervalStart = 1.5; +const cascadeAmount = Math.ceil(Math.log2((maxIntervalStart * 3) / interval0 + 1) / 2); + +function createCascadeTextures() { + return Array.from({ length: 2 }, () => + root + .createTexture({ + size: [dimensions.cascadeDim, dimensions.cascadeDim, cascadeAmount], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'), + ); +} + +function createRadianceFieldTexture() { + return root + .createTexture({ + size: [dimensions.outputProbes, dimensions.outputProbes], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); +} + +let cascadeTextures = createCascadeTextures(); +let radianceFieldTex = createRadianceFieldTexture(); + +let radianceFieldView = radianceFieldTex.createView(d.texture2d()); + +let radianceFieldStoreView = radianceFieldTex.createView(d.textureStorage2d('rgba16float')); + +const buildRadianceFieldBGL = tgpu.bindGroupLayout({ + src: { texture: d.texture2d(d.f32) }, + srcSampler: { sampler: 'filtering' }, + dst: { storageTexture: d.textureStorage2d('rgba16float') }, +}); + +const outputProbesUniform = root.createUniform(d.vec2u, d.vec2u(dimensions.outputProbes)); + +const radianceSampler = root.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(dimensions.cascadeDim)); +const cascadeProbesUniform = root.createUniform(d.vec2u, d.vec2u(dimensions.cascadeProbes)); + +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') }, +}); + +const cascadeSampler = root.createSampler({ + magFilter: 'linear', + minFilter: 'linear', +}); + +const cascadePassPipeline = root + .with(sceneDataAccess, sceneDataUniform) + .createGuardedComputePipeline((x, y) => { + 'use gpu'; + const gid = d.vec2u(x, y); + const cascadeDim = cascadeDimUniform.$; + const layer = cascadeIndexUniform.$; + const probes = probesUniform.$; + const cascadeProbes = cascadeProbesUniform.$; + + const dirStored = gid.xy / probes; + const probe = gid.xy % probes; + const raysDimStored = d.u32(2 << layer); + const raysDimActual = raysDimStored * 2; + const rayCountActual = raysDimActual * raysDimActual; + + const probePos = (d.vec2f(probe) + 0.5) / d.vec2f(probes); + const baseProbeCount = d.f32(cascadeProbes.x); + const baseRayInterval = 1 / baseProbeCount; + const rayIntervalScale = d.f32(1 << (layer * 2)); + // Ray intervals are distances in normalized scene space, not texture UV coordinates. + const rayStartDistance = (baseRayInterval * (rayIntervalScale - 1)) / 3; + const rayEndDistance = rayStartDistance + baseRayInterval * rayIntervalScale; + const eps = 0.5 / baseProbeCount; + const minStep = 0.25 / baseProbeCount; + + let accum = d.vec4f(); + + for (const i of tgpu.unroll(std.range(4))) { + const dirActual = dirStored * 2 + d.vec2u(i & 1, i >> 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 = rayStartDistance; + + for (let step = 0; step < 64; step++) { + if (t > rayEndDistance) { + break; + } + const hit = sceneSDF(probePos + rayDir * 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 >> 1, 1), std.max(probes.y >> 1, 1)); + const tileOrigin = d.vec2f(dirActual) * d.vec2f(probesU); + const probePixel = std.clamp( + probePos * d.vec2f(probesU), + d.vec2f(0.5), + d.vec2f(probesU) - 0.5, + ); + const upperCascadeUv = (tileOrigin + probePixel) / d.vec2f(cascadeDim); + + const upper = std.textureSampleLevel( + cascadePassBGL.$.upper, + cascadePassBGL.$.upperSampler, + upperCascadeUv, + 0, + ); + rgb += upper.xyz * T; + T *= upper.w; + } + + accum += d.vec4f(rgb, T); + } + + std.textureStore(cascadePassBGL.$.dst, gid.xy, accum * 0.25); + }); + +const buildRadianceFieldPipeline = root.createGuardedComputePipeline((x, y) => { + 'use gpu'; + const gid = d.vec2u(x, y); + const outputProbes = outputProbesUniform.$; + const cascadeProbes = cascadeProbesUniform.$; + const cascadeDim = cascadeDimUniform.$; + + const invCascadeDim = d.vec2f(1) / d.vec2f(cascadeDim); + const uv = (d.vec2f(gid.xy) + 0.5) / d.vec2f(outputProbes); + + const probePixel = std.clamp( + uv * d.vec2f(cascadeProbes), + d.vec2f(0.5), + d.vec2f(cascadeProbes) - 0.5, + ); + + const uvStride = d.vec2f(cascadeProbes) * invCascadeDim; + const baseSampleUV = probePixel * invCascadeDim; + + let sum = d.vec3f(); + for (const i of tgpu.unroll(std.range(4))) { + const offset = d.vec2f(d.f32(i & 1), d.f32(i >> 1)) * uvStride; + sum += std.textureSampleLevel( + buildRadianceFieldBGL.$.src, + buildRadianceFieldBGL.$.srcSampler, + baseSampleUV + offset, + 0, + ).xyz; + } + + std.textureStore(buildRadianceFieldBGL.$.dst, gid.xy, d.vec4f(sum * 0.25, 1)); +}); + +const ACESFilm = (x: d.v3f): d.v3f => { + 'use gpu'; + const a = 2.51; + const b = 0.03; + const c = 2.43; + const dVal = 0.59; + const e = 0.01; + const res = (x * (x * a + b)) / (x * (x * c + dVal) + e); + + return std.saturate(res); +}; + +const overlayDebugBGL = tgpu.bindGroupLayout({ + cascadeTex: { texture: d.texture2dArray() }, + cascadeSampler: { sampler: 'filtering' }, +}); + +const overlayFrag = tgpu.fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + 'use gpu'; + const field = std.textureSample(radianceFieldView.$, radianceSampler.$, uv).xyz; + const sceneHit = sceneSDF(uv); + const fieldColor = ACESFilm(std.saturate(field)); + const surfaceColor = ACESFilm(std.saturate(sceneHit.color)); + const fieldTexel = 1 / d.f32(std.textureDimensions(radianceFieldView.$).x); + const edgeWidth = std.max(std.fwidth(sceneHit.dist), fieldTexel); + const surfaceAlpha = 1 - std.smoothstep(-edgeWidth, edgeWidth, sceneHit.dist); + const baseColor = std.mix(fieldColor, surfaceColor, surfaceAlpha); + + if (overlayEnabledUniform.$ === 0) { + return d.vec4f(baseColor, 1); + } + + const debugLayer = overlayDebugCascadeUniform.$; + const cascadeProbes = cascadeProbesUniform.$; + const probes = std.max( + d.vec2u(cascadeProbes.x >> debugLayer, cascadeProbes.y >> debugLayer), + d.vec2u(1), + ); + const raysDimStored = d.u32(2) << debugLayer; + const raysDimActual = raysDimStored * 2; + const rayCountActual = raysDimActual * raysDimActual; + const baseProbeCount = d.f32(cascadeProbes.x); + const baseRayInterval = 1 / baseProbeCount; + const rayIntervalScale = d.f32(d.u32(1) << (debugLayer * 2)); + const rayEndDistance = + (baseRayInterval * (rayIntervalScale - 1)) / 3 + baseRayInterval * rayIntervalScale; + const probeSpacing = 1 / probes.x; + 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 * d.vec2f(probes))); + + for (let py = -1; py <= 1; py++) { + for (let px = -1; px <= 1; px++) { + const probeXY = centerProbe + 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) + 0.5) / d.vec2f(probes); + minProbeDist = std.min(minProbeDist, sdf.sdDisk(uv - probePos, probeRadius)); + + if (std.length(uv - 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 + rayDir * std.max(rayEndDistance, 0.01)); + + if (rayDist < minRayDist) { + const dirStored = d.vec2u((ri % raysDimActual) >> 1, d.u32(ri / raysDimActual) >> 1); + const sample = std.textureLoad( + overlayDebugBGL.$.cascadeTex, + d.vec2i(dirStored * probes + probe), + debugLayer, + 0, + ); + minRayDist = rayDist; + closestRayColor = sample.xyz; + } + 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, 1, 0), edgeAlpha); + overlayAlpha = std.max(overlayAlpha, edgeAlpha); + } + + return d.vec4f(std.mix(baseColor, overlayColor, overlayAlpha), 1); +}); + +function createCascadePassBindGroups() { + return 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, + }), + }); + }); +} + +let cascadePassBindGroups = createCascadePassBindGroups(); + +function createBuildRadianceFieldBG(textureIndex: number) { + return root.createBindGroup(buildRadianceFieldBGL, { + src: cascadeTextures[textureIndex].createView(d.texture2d(d.f32), { + baseArrayLayer: 0, + arrayLayerCount: 1, + }), + srcSampler: cascadeSampler, + dst: radianceFieldStoreView, + }); +} + +let buildRadianceFieldBindGroups = [createBuildRadianceFieldBG(0), createBuildRadianceFieldBG(1)]; + +function buildRadianceField() { + const cascade0InA = (cascadeAmount - 1) % 2 === 0; + const buildRadianceFieldBG = buildRadianceFieldBindGroups[cascade0InA ? 0 : 1]; + + buildRadianceFieldPipeline + .with(buildRadianceFieldBG) + .dispatchThreads(dimensions.outputProbes, dimensions.outputProbes); +} + +function runCascadesTopDown() { + for (let layer = cascadeAmount - 1; layer >= 0; layer--) { + const probes = Math.max(1, dimensions.cascadeProbes >> layer); + + cascadeIndexUniform.write(layer); + probesUniform.write(d.vec2u(probes)); + + cascadePassPipeline + .with(cascadePassBindGroups[layer]) + .dispatchThreads(dimensions.cascadeDim, dimensions.cascadeDim); + } +} + +function updateLighting() { + runCascadesTopDown(); + buildRadianceField(); +} +updateLighting(); + +function createOverlayDebugBG(textureIndex: number) { + return root.createBindGroup(overlayDebugBGL, { + cascadeTex: cascadeTextures[textureIndex].createView(d.texture2dArray(d.f32)), + cascadeSampler: cascadeSampler, + }); +} + +let overlayDebugBindGroups = [createOverlayDebugBG(0), createOverlayDebugBG(1)]; + +function createRenderPipeline() { + return root.with(sceneDataAccess, sceneDataUniform).createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: overlayFrag, + }); +} + +let renderPipeline = createRenderPipeline(); + +let frameId: number; +let debugLayer = 0; + +function updateCascadeDimensions() { + dimensions = getCascadeDimensions(); + + outputProbesUniform.write(d.vec2u(dimensions.outputProbes)); + cascadeDimUniform.write(d.vec2u(dimensions.cascadeDim)); + cascadeProbesUniform.write(d.vec2u(dimensions.cascadeProbes)); +} + +function destroySizedResources() { + for (const texture of cascadeTextures) { + texture.destroy(); + } + radianceFieldTex.destroy(); +} + +function recreateSizedResources() { + destroySizedResources(); + updateCascadeDimensions(); + + cascadeTextures = createCascadeTextures(); + radianceFieldTex = createRadianceFieldTexture(); + radianceFieldView = radianceFieldTex.createView(d.texture2d()); + radianceFieldStoreView = radianceFieldTex.createView(d.textureStorage2d('rgba16float')); + + cascadePassBindGroups = createCascadePassBindGroups(); + buildRadianceFieldBindGroups = [createBuildRadianceFieldBG(0), createBuildRadianceFieldBG(1)]; + overlayDebugBindGroups = [createOverlayDebugBG(0), createOverlayDebugBG(1)]; + renderPipeline = createRenderPipeline(); + + updateLighting(); +} + +function frame() { + const writeToA = (cascadeAmount - 1 - debugLayer) % 2 === 0; + const overlayDebugBG = overlayDebugBindGroups[writeToA ? 0 : 1]; + + renderPipeline.with(overlayDebugBG).withColorAttachment({ view: context }).draw(3); + frameId = requestAnimationFrame(frame); +} +frameId = requestAnimationFrame(frame); + +function onDrag(id: string, position: d.v2f) { + updateElementPosition(id, position); + sceneDataUniform.write(sceneData); + updateLighting(); +} + +const dragController = new DragController(canvas, onDrag, onDrag); + +// #region Example controls and cleanup + +let resizeTimeout: ReturnType; +const resizeObserver = new ResizeObserver(() => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(recreateSizedResources, 100); +}); +resizeObserver.observe(canvas); + +export function onCleanup() { + dragController.destroy(); + resizeObserver.disconnect(); + clearTimeout(resizeTimeout); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + root.destroy(); +} + +export const controls = defineControls({ + '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; + }, + }, +}); + +// #endregion diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades/meta.json b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/meta.json new file mode 100644 index 0000000000..26f5e96103 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Radiance Cascades", + "category": "rendering", + "tags": ["ecosystem", "interaction", "lighting", "ray marching", "sdf"], + "coolFactor": 8 +} diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades/scene.ts b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/scene.ts new file mode 100644 index 0000000000..a002a37f90 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/scene.ts @@ -0,0 +1,145 @@ +import * as sdf from '@typegpu/sdf'; +import tgpu, { d } from 'typegpu'; + +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.accessor(SceneData); +export const sceneSDF = (p: d.v2f) => { + 'use gpu'; + const scene = sceneDataAccess.$; + + let minDist = d.f32(2e31); + let color = d.vec3f(); + + for (const disk of scene.disks) { + const dist = sdf.sdDisk(p - disk.pos, disk.radius); + + if (dist < minDist) { + minDist = dist; + color = d.vec3f(disk.emissiveColor); + } + } + + for (const box of scene.boxes) { + const dist = sdf.sdBox2d(p - box.pos, box.size); + + if (dist < minDist) { + minDist = dist; + color = d.vec3f(box.emissiveColor); + } + } + + return SceneResult({ dist: minDist, color }); +}; diff --git a/apps/typegpu-docs/src/examples/rendering/radiance-cascades/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/thumbnail.png new file mode 100644 index 0000000000..d5fdbd4f03 Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/radiance-cascades/thumbnail.png differ diff --git a/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts b/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts index 3fb0e7d685..775ee742d2 100644 --- a/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts +++ b/apps/typegpu-docs/tests/individual-example-tests/jump-flood-distance.test.ts @@ -77,8 +77,8 @@ describe('jump flood (distance) example', () => { var pos = vec2f(f32(x), f32(y)); var bestInsideCoord = vec2f(-1); var bestOutsideCoord = vec2f(-1); - var bestInsideDist = 1e+20; - var bestOutsideDist = 1e+20; + var bestInsideDist = 3.4028234663852886e+38f; + var bestOutsideDist = 3.4028234663852886e+38f; // unrolled iteration #0 { // unrolled iteration #0 @@ -272,8 +272,8 @@ describe('jump flood (distance) example', () => { var texel = textureLoad(readView, vec2i(i32(x), i32(y))); var insideCoord = texel.xy; var outsideCoord = texel.zw; - var insideDist = 1e+20; - var outsideDist = 1e+20; + var insideDist = 3.4028234663852886e+38f; + var outsideDist = 3.4028234663852886e+38f; if ((insideCoord.x >= 0f)) { insideDist = distance(pos, (insideCoord * vec2f(size))); } diff --git a/packages/typegpu-radiance-cascades/README.md b/packages/typegpu-radiance-cascades/README.md new file mode 100644 index 0000000000..a39aee9242 --- /dev/null +++ b/packages/typegpu-radiance-cascades/README.md @@ -0,0 +1,27 @@ +
+ +# @typegpu/radiance-cascades + +
+ +A helper library for computing 2D radiance cascades with TypeGPU. + +```ts +import { createRadianceCascades } from '@typegpu/radiance-cascades'; + +const runner = createRadianceCascades({ + root, + size: { width, height }, + sdfResolution: { width: sdfWidth, height: sdfHeight }, + sdf: (uv) => { + 'use gpu'; + return sampleSdf(uv); + }, + color: (uv) => { + 'use gpu'; + return sampleColor(uv); + }, +}); + +runner.run(); +``` diff --git a/packages/typegpu-radiance-cascades/build.config.ts b/packages/typegpu-radiance-cascades/build.config.ts new file mode 100644 index 0000000000..7f9f024f1f --- /dev/null +++ b/packages/typegpu-radiance-cascades/build.config.ts @@ -0,0 +1,12 @@ +import { type BuildConfig, defineBuildConfig } from 'unbuild'; +import typegpu from 'unplugin-typegpu/rollup'; + +const Config: BuildConfig[] = defineBuildConfig({ + hooks: { + 'rollup:options': (_options, config) => { + config.plugins.push(typegpu({ include: [/\.ts$/] })); + }, + }, +}); + +export default Config; diff --git a/packages/typegpu-radiance-cascades/package.json b/packages/typegpu-radiance-cascades/package.json new file mode 100644 index 0000000000..714408c9ec --- /dev/null +++ b/packages/typegpu-radiance-cascades/package.json @@ -0,0 +1,44 @@ +{ + "name": "@typegpu/radiance-cascades", + "version": "0.11.0", + "private": true, + "description": "Radiance Cascades implementation for TypeGPU", + "keywords": [], + "license": "MIT", + "type": "module", + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "directory": "dist", + "exports": { + "./package.json": "./dist/package.json", + ".": { + "types": "./dist/index.d.ts", + "module": "./dist/index.mjs", + "import": "./dist/index.mjs", + "default": "./dist/index.cjs" + } + }, + "linkDirectory": false, + "types": "./dist/index.d.ts" + }, + "scripts": { + "build": "unbuild", + "test:types": "pnpm tsc --p ./tsconfig.json --noEmit", + "prepublishOnly": "tgpu-dev-cli prepack" + }, + "devDependencies": { + "@typegpu/tgpu-dev-cli": "workspace:*", + "@webgpu/types": "catalog:types", + "typegpu": "workspace:*", + "typescript": "catalog:types", + "unbuild": "catalog:build", + "unplugin-typegpu": "workspace:*" + }, + "peerDependencies": { + "typegpu": "workspace:^" + } +} diff --git a/packages/typegpu-radiance-cascades/src/cascades.ts b/packages/typegpu-radiance-cascades/src/cascades.ts new file mode 100644 index 0000000000..cc2f473f90 --- /dev/null +++ b/packages/typegpu-radiance-cascades/src/cascades.ts @@ -0,0 +1,245 @@ +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import tgpu from 'typegpu'; + +const ERODE_BIAS = 2; + +export function getCascadeDim(width: number, height: number) { + const aspect = width / height; + const diagonal = Math.sqrt(width ** 2 + height ** 2); + + const minPow2 = 16; + const closestPowerOfTwo = Math.max(minPow2, 2 ** Math.floor(Math.log2(diagonal))); + + let cascadeWidth: number; + let cascadeHeight: number; + if (aspect >= 1) { + cascadeWidth = closestPowerOfTwo; + cascadeHeight = Math.max(minPow2, Math.round(closestPowerOfTwo / aspect)); + } else { + cascadeWidth = Math.max(minPow2, Math.round(closestPowerOfTwo * aspect)); + cascadeHeight = closestPowerOfTwo; + } + + const cascadeDimX = cascadeWidth * 2; + const cascadeDimY = cascadeHeight * 2; + + const interval = 1 / closestPowerOfTwo; + const maxIntervalStart = 2.0; + + const minCascades = 5; + const cascadeAmount = Math.max( + minCascades, + Math.ceil(Math.log2((maxIntervalStart * 3) / interval + 1) / 2), + ); + + return [cascadeDimX, cascadeDimY, cascadeAmount] as const; +} + +export const sdfSlot = tgpu.slot<(uv: d.v2f) => number>(); +export const colorSlot = tgpu.slot<(uv: d.v2f) => d.v3f>(); + +// Slot for SDF resolution to calculate proper texel-based eps/minStep (so we don't do redundant sub-texel steps) +export const sdfResolutionSlot = tgpu.slot(); + +export const RayMarchResult = d.struct({ + color: d.vec3f, + transmittance: d.f32, // 1.0 = no hit, 0.0 = fully opaque hit +}); + +export const defaultRayMarch = tgpu.fn( + [d.vec2f, d.vec2f, d.f32, d.f32, d.f32, d.f32, d.f32], + RayMarchResult, +)((probePos, rayDir, startT, endT, eps, minStep, bias) => { + 'use gpu'; + let rgb = d.vec3f(); + let T = d.f32(1); + let t = startT; + let hitPos = d.vec2f(); + let didHit = false; + + for (let step = 0; step < 64; step++) { + if (t > endT) { + break; + } + const pos = probePos + rayDir * t; + if (std.any(std.lt(pos, d.vec2f(0))) || std.any(std.gt(pos, d.vec2f(1)))) { + break; + } + + const dist = std.max(sdfSlot.$(pos) + bias, 0); + if (dist <= eps) { + hitPos = d.vec2f(pos); + didHit = true; + T = 0; + break; + } + t += std.max(dist, minStep); + } + + if (didHit) { + rgb = colorSlot.$(hitPos); + } + + return RayMarchResult({ color: rgb, transmittance: T }); +}); + +export const rayMarchSlot = tgpu.slot(defaultRayMarch); + +export const CascadeStaticParams = d.struct({ + baseProbes: d.vec2u, + cascadeDim: d.vec2u, + cascadeCount: d.u32, +}); + +export const cascadePassBGL = tgpu.bindGroupLayout({ + staticParams: { uniform: CascadeStaticParams }, + layer: { uniform: d.u32 }, + upper: { texture: d.texture2d() }, + upperSampler: { sampler: 'filtering' }, + dst: { storageTexture: d.textureStorage2d('rgba16float') }, +}); + +export const cascadePassCompute = tgpu.computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const dim2 = std.textureDimensions(cascadePassBGL.$.dst); + if (gid.x >= dim2.x || gid.y >= dim2.y) { + return; + } + + const params = cascadePassBGL.$.staticParams; + const layer = cascadePassBGL.$.layer; + const probes = std.max( + d.vec2u(params.baseProbes.x >> layer, params.baseProbes.y >> layer), + d.vec2u(1, 1), + ); + + const dirStored = gid.xy / probes; + const probe = gid.xy % probes; + const raysDimStored = d.u32(2) << layer; + const raysDimActual = raysDimStored * 2; + const rayCountActual = d.f32(raysDimActual) ** 2; + + if (dirStored.x >= raysDimStored || dirStored.y >= raysDimStored) { + std.textureStore(cascadePassBGL.$.dst, gid.xy, d.vec4f(0, 0, 0, 1)); + return; + } + + // const probePos = d.vec2f(probe).add(0.5).div(d.vec2f(probes)); + const probePos = (d.vec2f(probe) + 0.5) / d.vec2f(probes); + const aspect = params.baseProbes.x / params.baseProbes.y; + const cascadeProbesMinVal = d.f32(std.min(params.baseProbes.x, params.baseProbes.y)); + const interval0 = 1 / cascadeProbesMinVal; + const pow4 = d.f32(d.u32(1) << (layer * 2)); + const startUv = (interval0 * (pow4 - 1)) / 3; + const endUv = startUv + interval0 * pow4; + + const sdfDim = sdfResolutionSlot.$; + const texelSizeMin = 1.0 / d.f32(std.max(std.min(sdfDim.x, sdfDim.y), 1)); + // Use texel size as minimum threshold to avoid sub-texel stepping + const eps = std.max(texelSizeMin, 0.25 / cascadeProbesMinVal); + const minStep = std.max(texelSizeMin * 0.5, 0.125 / cascadeProbesMinVal); + const biasUv = d.f32(ERODE_BIAS) / cascadeProbesMinVal; + + let accum = d.vec4f(); + + for (let i = 0; i < 4; i++) { + const dirActual = dirStored * 2 + d.vec2u(i & 1, i >> 1); + const rayIndex = d.f32(dirActual.y * raysDimActual + dirActual.x) + 0.5; + const angle = (rayIndex / rayCountActual) * (Math.PI * 2) - Math.PI; + const cosA = std.cos(angle); + const sinA = -std.sin(angle); + let rayDir = d.vec2f(cosA, sinA); + if (aspect >= 1) { + rayDir = d.vec2f(cosA / aspect, sinA); + } else { + rayDir = d.vec2f(cosA, sinA * aspect); + } + + const marchResult = rayMarchSlot.$(probePos, rayDir, startUv, endUv, eps, minStep, biasUv); + let rgb = d.vec3f(marchResult.color); + let T = d.f32(marchResult.transmittance); + + if (layer < params.cascadeCount - 1 && T > 0.01) { + const probesU = std.max(d.vec2u(probes.x >> 1, probes.y >> 1), d.vec2u(1)); + const tileOrigin = d.vec2f(dirActual) * d.vec2f(probesU); + const probePixel = std.clamp( + probePos * d.vec2f(probesU), + d.vec2f(0.5), + d.vec2f(probesU) - 0.5, + ); + const uvU = (tileOrigin + probePixel) / d.vec2f(dim2); + + const upper = std.textureSampleLevel( + cascadePassBGL.$.upper, + cascadePassBGL.$.upperSampler, + uvU, + 0, + ); + rgb = rgb + upper.xyz * T; + T *= upper.w; + } + + accum += d.vec4f(rgb, T); + } + + std.textureStore(cascadePassBGL.$.dst, gid.xy, accum * 0.25); +}); + +export const BuildRadianceFieldParams = d.struct({ + outputProbes: d.vec2u, + cascadeProbes: d.vec2u, +}); + +export const buildRadianceFieldBGL = tgpu.bindGroupLayout({ + params: { uniform: BuildRadianceFieldParams }, + src: { texture: d.texture2d() }, + srcSampler: { sampler: 'filtering' }, + dst: { storageTexture: d.textureStorage2d('rgba16float') }, +}); + +export const buildRadianceFieldCompute = tgpu.computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const dim2 = std.textureDimensions(buildRadianceFieldBGL.$.dst); + if (gid.x >= dim2.x || gid.y >= dim2.y) { + return; + } + + const params = buildRadianceFieldBGL.$.params; + const cascadeDim = params.cascadeProbes * 2; + + const invCascadeDim = 1 / d.vec2f(cascadeDim); + const uv = (d.vec2f(gid.xy) + 0.5) / d.vec2f(params.outputProbes); + + const probePixel = std.clamp( + uv * d.vec2f(params.cascadeProbes), + d.vec2f(0.5), + d.vec2f(params.cascadeProbes) - 0.5, + ); + + const uvStride = d.vec2f(params.cascadeProbes) * invCascadeDim; + const baseSampleUV = probePixel * invCascadeDim; + + let sum = d.vec3f(); + for (let i = d.u32(0); i < 4; i++) { + const offset = d.vec2f(i & 1, i >> 1) * uvStride; + const sample = std.textureSampleLevel( + buildRadianceFieldBGL.$.src, + buildRadianceFieldBGL.$.srcSampler, + baseSampleUV + offset, + 0, + ); + sum = sum + sample.xyz; + } + + const avg = sum * 0.25; + const res = d.vec3f(avg); + + std.textureStore(buildRadianceFieldBGL.$.dst, gid.xy, d.vec4f(res, 1)); +}); diff --git a/packages/typegpu-radiance-cascades/src/index.ts b/packages/typegpu-radiance-cascades/src/index.ts new file mode 100644 index 0000000000..63100d7437 --- /dev/null +++ b/packages/typegpu-radiance-cascades/src/index.ts @@ -0,0 +1,11 @@ +export { createRadianceCascades } from './runner.ts'; +export type { RadianceCascadesExecutor } from './runner.ts'; +export { + colorSlot, + defaultRayMarch, + getCascadeDim, + RayMarchResult, + rayMarchSlot, + sdfResolutionSlot, + sdfSlot, +} from './cascades.ts'; diff --git a/packages/typegpu-radiance-cascades/src/runner.ts b/packages/typegpu-radiance-cascades/src/runner.ts new file mode 100644 index 0000000000..8953f53c8d --- /dev/null +++ b/packages/typegpu-radiance-cascades/src/runner.ts @@ -0,0 +1,235 @@ +import { + isTexture, + isTextureView, + type SampledFlag, + type StorageFlag, + type TgpuBindGroup, + type TgpuRoot, + type TgpuTexture, + type TgpuTextureView, +} from 'typegpu'; +import * as d from 'typegpu/data'; +import { + buildRadianceFieldBGL, + buildRadianceFieldCompute, + BuildRadianceFieldParams, + cascadePassBGL, + cascadePassCompute, + CascadeStaticParams, + colorSlot, + defaultRayMarch, + getCascadeDim, + type RayMarchResult, + rayMarchSlot, + sdfResolutionSlot, + sdfSlot, +} from './cascades.ts'; + +type OutputTexture = + | (TgpuTexture<{ size: [number, number]; format: 'rgba16float' }> & StorageFlag) + | TgpuTextureView>; + +type CascadesOptions = { + root: TgpuRoot; + sdf: (uv: d.v2f) => number; + color: (uv: d.v2f) => d.v3f; + sdfResolution: { width: number; height: number }; + rayMarch?: ( + probePos: d.v2f, + rayDir: d.v2f, + startT: number, + endT: number, + eps: number, + minStep: number, + bias: number, + ) => d.InferGPU; + output?: OutputTexture; + size?: { width: number; height: number }; +}; + +type OutputTextureProp = TgpuTexture<{ + size: [number, number]; + format: 'rgba16float'; +}> & + StorageFlag & + SampledFlag; + +export type RadianceCascadesExecutor = { + run(): void; + with(bindGroup: TgpuBindGroup): RadianceCascadesExecutor; + destroy(): void; + readonly output: OutputTextureProp; +}; + +export function createRadianceCascades(options: CascadesOptions): RadianceCascadesExecutor { + const { root, sdf, color, sdfResolution, output, size, rayMarch } = options; + + const hasOutputProvided = !!output && (isTexture(output) || isTextureView(output)); + + // Determine output dimensions + let outputWidth: number; + let outputHeight: number; + + if (hasOutputProvided) { + if (isTexture(output)) { + [outputWidth, outputHeight] = output.props.size; + } else { + const viewSize = output.size ?? [size?.width, size?.height]; + if (!viewSize[0] || !viewSize[1]) { + throw new Error( + 'Size could not be inferred from texture view, pass explicit size in options.', + ); + } + [outputWidth, outputHeight] = viewSize as [number, number]; + } + } else { + if (!size) { + throw new Error('Size is required when output texture is not provided.'); + } + outputWidth = size.width; + outputHeight = size.height; + } + + // Create or use provided output texture + const dst = hasOutputProvided + ? output + : root + .createTexture({ + size: [outputWidth, outputHeight], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + + const ownsOutput = !hasOutputProvided; + + const [cascadeDimX, cascadeDimY, cascadeAmount] = getCascadeDim(outputWidth, outputHeight); + + const cascadeProbesX = cascadeDimX / 2; + const cascadeProbesY = cascadeDimY / 2; + + const cascadeTextureA = root + .createTexture({ + size: [cascadeDimX, cascadeDimY, cascadeAmount], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + + const cascadeTextureB = root + .createTexture({ + size: [cascadeDimX, cascadeDimY, cascadeAmount], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + + const cascadeSampler = root.createSampler({ + magFilter: 'linear', + minFilter: 'linear', + }); + + const staticParamsBuffer = root + .createBuffer(CascadeStaticParams, { + baseProbes: [cascadeProbesX, cascadeProbesY], + cascadeDim: [cascadeDimX, cascadeDimY], + cascadeCount: cascadeAmount, + }) + .$usage('uniform'); + + const layerBuffer = root.createBuffer(d.u32).$usage('uniform'); + + const cascadePassPipeline = root + .with(sdfResolutionSlot, d.vec2u(sdfResolution.width, sdfResolution.height)) + .with(sdfSlot, sdf) + .with(colorSlot, color) + .with(rayMarchSlot, rayMarch ?? defaultRayMarch) + .createComputePipeline({ compute: cascadePassCompute }); + + const cascadePassBindGroups = Array.from({ length: cascadeAmount }, (_, layer) => { + const writeToA = (cascadeAmount - 1 - layer) % 2 === 0; + const dstTexture = writeToA ? cascadeTextureA : cascadeTextureB; + const srcTexture = writeToA ? cascadeTextureB : cascadeTextureA; + + return root.createBindGroup(cascadePassBGL, { + staticParams: staticParamsBuffer, + layer: layerBuffer, + upper: srcTexture.createView(d.texture2d(d.f32), { + baseArrayLayer: Math.min(layer + 1, cascadeAmount - 1), + arrayLayerCount: 1, + }), + upperSampler: cascadeSampler, + dst: dstTexture.createView(d.textureStorage2d('rgba16float'), { + baseArrayLayer: layer, + arrayLayerCount: 1, + }), + }); + }); + + const buildRadianceFieldPipeline = root.createComputePipeline({ + compute: buildRadianceFieldCompute, + }); + + const radianceFieldParamsBuffer = root + .createBuffer(BuildRadianceFieldParams, { + outputProbes: [outputWidth, outputHeight], + cascadeProbes: [cascadeProbesX, cascadeProbesY], + }) + .$usage('uniform'); + + const cascade0InA = (cascadeAmount - 1) % 2 === 0; + const srcCascadeTexture = cascade0InA ? cascadeTextureA : cascadeTextureB; + + const buildRadianceFieldBG = root.createBindGroup(buildRadianceFieldBGL, { + params: radianceFieldParamsBuffer, + src: srcCascadeTexture.createView(d.texture2d(d.f32), { + baseArrayLayer: 0, + arrayLayerCount: 1, + }), + srcSampler: cascadeSampler, + dst, + }); + + const cascadeWorkgroupsX = Math.ceil(cascadeDimX / 8); + const cascadeWorkgroupsY = Math.ceil(cascadeDimY / 8); + const outputWorkgroupsX = Math.ceil(outputWidth / 8); + const outputWorkgroupsY = Math.ceil(outputHeight / 8); + + function destroy() { + cascadeTextureA.destroy(); + cascadeTextureB.destroy(); + if (ownsOutput && isTexture(dst)) { + dst.destroy(); + } + } + + function createExecutor(additionalBindGroups: TgpuBindGroup[] = []): RadianceCascadesExecutor { + const prebuiltCascadePipelines = cascadePassBindGroups.map((bg) => { + let p = cascadePassPipeline.with(bg); + for (const addBg of additionalBindGroups) { + p = p.with(addBg); + } + return p; + }); + + let prebuiltRadiancePipeline = buildRadianceFieldPipeline.with(buildRadianceFieldBG); + for (const bg of additionalBindGroups) { + prebuiltRadiancePipeline = prebuiltRadiancePipeline.with(bg); + } + + function run() { + for (let layer = cascadeAmount - 1; layer >= 0; layer--) { + layerBuffer.write(layer); + prebuiltCascadePipelines[layer]?.dispatchWorkgroups(cascadeWorkgroupsX, cascadeWorkgroupsY); + } + + prebuiltRadiancePipeline.dispatchWorkgroups(outputWorkgroupsX, outputWorkgroupsY); + } + + return { + run, + with: (bg) => createExecutor([...additionalBindGroups, bg]), + destroy, + output: dst as OutputTextureProp, + }; + } + + return createExecutor(); +} diff --git a/packages/typegpu-radiance-cascades/tsconfig.json b/packages/typegpu-radiance-cascades/tsconfig.json new file mode 100644 index 0000000000..5f257dc0f0 --- /dev/null +++ b/packages/typegpu-radiance-cascades/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/typegpu-sdf/src/index.ts b/packages/typegpu-sdf/src/index.ts index c4c594f691..97c2bb6ef7 100644 --- a/packages/typegpu-sdf/src/index.ts +++ b/packages/typegpu-sdf/src/index.ts @@ -18,3 +18,6 @@ export { opSmoothUnion, opUnion, } from './operators.ts'; + +export { classifySlot, createJumpFlood } from './jumpFlood.ts'; +export * as JumpFlood from './jumpFlood.ts'; diff --git a/packages/typegpu-sdf/src/jumpFlood.ts b/packages/typegpu-sdf/src/jumpFlood.ts new file mode 100644 index 0000000000..d7ad5bb16c --- /dev/null +++ b/packages/typegpu-sdf/src/jumpFlood.ts @@ -0,0 +1,436 @@ +import tgpu, { + type SampledFlag, + type StorageFlag, + type TgpuBindGroup, + type TgpuRoot, + type TgpuTexture, +} from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; + +const INVALID_COORD = 0xffff; + +const pingPongLayout = tgpu.bindGroupLayout({ + readView: { + storageTexture: d.textureStorage2d('rgba16uint', 'read-only'), + }, + writeView: { + storageTexture: d.textureStorage2d('rgba16uint', 'write-only'), + }, +}); + +const initLayout = tgpu.bindGroupLayout({ + writeView: { + storageTexture: d.textureStorage2d('rgba16uint', 'write-only'), + }, +}); + +const distWriteLayout = tgpu.bindGroupLayout({ + sdfTexture: { + storageTexture: d.textureStorage2d('rgba16float', 'write-only'), + }, + colorTexture: { + storageTexture: d.textureStorage2d('rgba8unorm', 'write-only'), + }, +}); + +const finalizeReadLayout = tgpu.bindGroupLayout({ + readView: { + storageTexture: d.textureStorage2d('rgba16uint', 'read-only'), + }, +}); + +/** + * Slot for the classify function that determines which pixels are "inside" for the SDF. + * The function receives the pixel coordinate and texture size, and returns whether + * the pixel is inside (true) or outside (false). + */ +export const classifySlot = tgpu.slot<(coord: d.v2u, size: d.v2u) => boolean>(); + +/** Slot for SDF getter - returns the signed distance value to store. */ +const sdfSlot = + tgpu.slot< + (coord: d.v2u, size: d.v2u, signedDist: number, insidePx: d.v2u, outsidePx: d.v2u) => number + >(); + +/** Slot for color getter - returns the color value to store. */ +const colorSlot = + tgpu.slot< + (coord: d.v2u, size: d.v2u, signedDist: number, insidePx: d.v2u, outsidePx: d.v2u) => d.v4f + >(); + +const sampleWithOffset = ( + tex: d.textureStorage2d<'rgba16uint', 'read-only'>, + dims: d.v2u, + pos: d.v2i, + offset: d.v2i, +) => { + 'use gpu'; + const samplePos = pos.add(offset); + + const outOfBounds = + samplePos.x < 0 || + samplePos.y < 0 || + samplePos.x >= d.i32(dims.x) || + samplePos.y >= d.i32(dims.y); + + if (outOfBounds) { + return d.vec4u(INVALID_COORD); + } + + return std.textureLoad(tex, samplePos); +}; + +const offsetAccessor = tgpu.accessor(d.i32); + +const initFromSeedCompute = tgpu.computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + const size = std.textureDimensions(initLayout.$.writeView); + if (gid.x >= size.x || gid.y >= size.y) { + return; + } + + // Use classify slot to determine if this pixel is inside + const isInside = classifySlot.$(gid.xy, size); + const invalid = d.vec2u(INVALID_COORD); + + // Store pixel coords directly (not UVs) + // If inside: inside coord = this pixel, outside coord = invalid + // If outside: outside coord = this pixel, inside coord = invalid + const insideCoord = std.select(invalid, gid.xy, isInside); + const outsideCoord = std.select(gid.xy, invalid, isInside); + + std.textureStore(initLayout.$.writeView, d.vec2i(gid.xy), d.vec4u(insideCoord, outsideCoord)); +}); + +const jumpFloodCompute = tgpu.computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const size = std.textureDimensions(pingPongLayout.$.readView); + if (gid.x >= size.x || gid.y >= size.y) { + return; + } + + const offset = offsetAccessor.$; + const pos = d.vec2i(gid.xy); + + const invalid = d.vec2u(INVALID_COORD); + let bestInsideCoord = d.vec2u(invalid); + let bestOutsideCoord = d.vec2u(invalid); + + let bestInsideDist2 = d.i32(2147483647); + let bestOutsideDist2 = d.i32(2147483647); + + for (const dy of tgpu.unroll([-1, 0, 1])) { + for (const dx of tgpu.unroll([-1, 0, 1])) { + const sample = sampleWithOffset( + pingPongLayout.$.readView, + size, + pos, + d.vec2i(dx, dy) * offset, + ); + + if (sample.x !== INVALID_COORD) { + const deltaIn = pos - d.vec2i(sample.xy); + const dist2 = deltaIn.x * deltaIn.x + deltaIn.y * deltaIn.y; + + if (dist2 < bestInsideDist2) { + bestInsideDist2 = dist2; + bestInsideCoord = d.vec2u(sample.xy); + } + } + + if (sample.z !== INVALID_COORD) { + const deltaOut = pos - d.vec2i(sample.zw); + const dist2 = deltaOut.x * deltaOut.x + deltaOut.y * deltaOut.y; + + if (dist2 < bestOutsideDist2) { + bestOutsideDist2 = dist2; + bestOutsideCoord = d.vec2u(sample.zw); + } + } + } + } + + std.textureStore( + pingPongLayout.$.writeView, + d.vec2i(gid.xy), + d.vec4u(bestInsideCoord, bestOutsideCoord), + ); +}); + +// Runs a final JFA pass at offset=1 and immediately computes the signed distance +const finalizeCompute = tgpu.computeFn({ + workgroupSize: [8, 8], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const size = std.textureDimensions(finalizeReadLayout.$.readView); + if (gid.x >= size.x || gid.y >= size.y) { + return; + } + + const pos = d.vec2i(gid.xy); + const invalid = d.vec2u(INVALID_COORD); + let bestInsideCoord = d.vec2u(invalid); + let bestOutsideCoord = d.vec2u(invalid); + let bestInsideDist2 = d.i32(2147483647); + let bestOutsideDist2 = d.i32(2147483647); + + for (const dy of tgpu.unroll([-1, 0, 1])) { + for (const dx of tgpu.unroll([-1, 0, 1])) { + const sample = sampleWithOffset(finalizeReadLayout.$.readView, size, pos, d.vec2i(dx, dy)); + + if (sample.x !== INVALID_COORD) { + const deltaIn = pos - d.vec2i(sample.xy); + const dist2 = deltaIn.x * deltaIn.x + deltaIn.y * deltaIn.y; + + if (dist2 < bestInsideDist2) { + bestInsideDist2 = dist2; + bestInsideCoord = d.vec2u(sample.xy); + } + } + + if (sample.z !== INVALID_COORD) { + const deltaOut = pos - d.vec2i(sample.zw); + const dist2 = deltaOut.x * deltaOut.x + deltaOut.y * deltaOut.y; + + if (dist2 < bestOutsideDist2) { + bestOutsideDist2 = dist2; + bestOutsideCoord = d.vec2u(sample.zw); + } + } + } + } + + const posF = d.vec2f(gid.xy); + let insideDist = d.f32(3.4 * 10 ** 38); + let outsideDist = d.f32(3.4 * 10 ** 38); + + if (bestInsideCoord.x !== INVALID_COORD) { + insideDist = std.distance(posF, d.vec2f(bestInsideCoord)); + } + + if (bestOutsideCoord.x !== INVALID_COORD) { + outsideDist = std.distance(posF, d.vec2f(bestOutsideCoord)); + } + + const signedDist = insideDist - outsideDist; + const sdfValue = sdfSlot.$(gid.xy, size, signedDist, bestInsideCoord, bestOutsideCoord); + const colorValue = colorSlot.$(gid.xy, size, signedDist, bestInsideCoord, bestOutsideCoord); + + std.textureStore(distWriteLayout.$.sdfTexture, d.vec2i(gid.xy), d.vec4f(sdfValue, 0, 0, 0)); + std.textureStore(distWriteLayout.$.colorTexture, d.vec2i(gid.xy), colorValue); +}); + +export type SdfTexture = TgpuTexture<{ + size: [number, number]; + format: 'rgba16float'; +}> & + StorageFlag & + SampledFlag; + +export type ColorTexture = TgpuTexture<{ + size: [number, number]; + format: 'rgba8unorm'; +}> & + StorageFlag & + SampledFlag; + +export type Executor = { + /** Run the jump flood algorithm. */ + run(): void; + /** The SDF output texture (r32float). */ + readonly sdfOutput: SdfTexture; + /** The color output texture (rgba8unorm). */ + readonly colorOutput: ColorTexture; + /** + * Returns a new executor with the additional bind group attached. + * Use this to pass resources needed by custom classify or getter functions. + */ + with(bindGroup: TgpuBindGroup): Executor; + /** Clean up GPU resources created by this executor. */ + destroy(): void; +}; + +type JumpFloodOptions = { + root: TgpuRoot; + size: { width: number; height: number }; + /** + * Classify function that determines which pixels are "inside" for the SDF. + * Returns true if the pixel is inside, false if outside. + */ + classify: (coord: d.v2u, size: d.v2u) => boolean; + /** + * Get the SDF value to store. Receives signed distance in pixels. + */ + getSdf: ( + coord: d.v2u, + size: d.v2u, + signedDist: number, + insidePx: d.v2u, + outsidePx: d.v2u, + ) => number; + /** + * Get the color value to store. + */ + getColor: ( + coord: d.v2u, + size: d.v2u, + signedDist: number, + insidePx: d.v2u, + outsidePx: d.v2u, + ) => d.v4f; +}; + +/** + * Create a Jump Flood Algorithm executor with separate SDF and color output textures. + */ +export function createJumpFlood(options: JumpFloodOptions): Executor { + const { root, size, classify, getSdf, getColor } = options; + const { width, height } = size; + + // Create output textures + const sdfTexture = root + .createTexture({ + size: [width, height], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + + const colorTexture = root + .createTexture({ + size: [width, height], + format: 'rgba8unorm', + }) + .$usage('storage', 'sampled'); + + // Create flood textures + const floodTextureA = root + .createTexture({ + size: [width, height], + format: 'rgba16uint', + }) + .$usage('storage'); + + const floodTextureB = root + .createTexture({ + size: [width, height], + format: 'rgba16uint', + }) + .$usage('storage'); + + const offsetUniform = root.createUniform(d.i32); + + const initFromSeedPipeline = root + .with(classifySlot, classify) + .createComputePipeline({ compute: initFromSeedCompute }); + + const jumpFloodPipeline = root + .with(offsetAccessor, offsetUniform) + .createComputePipeline({ compute: jumpFloodCompute }); + + const finalizePipeline = root + .with(sdfSlot, getSdf) + .with(colorSlot, getColor) + .createComputePipeline({ compute: finalizeCompute }); + + // Create bind groups + const initBG = root.createBindGroup(initLayout, { + writeView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'write-only')), + }); + + const pingPongBGs = [ + root.createBindGroup(pingPongLayout, { + readView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'read-only')), + writeView: floodTextureB.createView(d.textureStorage2d('rgba16uint', 'write-only')), + }), + root.createBindGroup(pingPongLayout, { + readView: floodTextureB.createView(d.textureStorage2d('rgba16uint', 'read-only')), + writeView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'write-only')), + }), + ]; + + const distWriteBG = root.createBindGroup(distWriteLayout, { + sdfTexture: sdfTexture.createView(d.textureStorage2d('rgba16float', 'write-only')), + colorTexture: colorTexture.createView(d.textureStorage2d('rgba8unorm', 'write-only')), + }); + + const finalizeReadBGs = [ + root.createBindGroup(finalizeReadLayout, { + readView: floodTextureA.createView(d.textureStorage2d('rgba16uint', 'read-only')), + }), + root.createBindGroup(finalizeReadLayout, { + readView: floodTextureB.createView(d.textureStorage2d('rgba16uint', 'read-only')), + }), + ]; + + const workgroupsX = Math.ceil(width / 8); + const workgroupsY = Math.ceil(height / 8); + const maxDim = Math.max(width, height); + + // Largest power-of-two strictly less than maxDim. + const maxRange = 2 ** Math.floor(Math.log2(Math.max(maxDim - 1, 1))); + + function destroy() { + floodTextureA.destroy(); + floodTextureB.destroy(); + sdfTexture.destroy(); + colorTexture.destroy(); + } + + function createExecutor(additionalBindGroups: TgpuBindGroup[] = []): Executor { + // Pre-cache pipeline+bindgroup combos to avoid re-chaining per frame. + let prebuiltInitPipeline = initFromSeedPipeline.with(initBG); + for (const bg of additionalBindGroups) { + prebuiltInitPipeline = prebuiltInitPipeline.with(bg); + } + + const prebuiltFloodPipelines = pingPongBGs.map((bg) => { + let p = jumpFloodPipeline.with(bg); + for (const addBg of additionalBindGroups) { + p = p.with(addBg); + } + return p; + }); + + const prebuiltFinalizePipelines = finalizeReadBGs.map((bg) => { + let p = finalizePipeline.with(bg).with(distWriteBG); + for (const addBg of additionalBindGroups) { + p = p.with(addBg); + } + return p; + }); + + function run() { + prebuiltInitPipeline.dispatchWorkgroups(workgroupsX, workgroupsY); + + let sourceIdx = 0; + let offset = maxRange; + + while (offset >= 1) { + offsetUniform.write(offset); + prebuiltFloodPipelines[sourceIdx]?.dispatchWorkgroups(workgroupsX, workgroupsY); + sourceIdx ^= 1; + offset = Math.floor(offset / 2); + } + + // Finalize: JFA+1 at offset=1 fused with distance field output + prebuiltFinalizePipelines[sourceIdx]?.dispatchWorkgroups(workgroupsX, workgroupsY); + } + + return { + run, + with: (bindGroup) => createExecutor([...additionalBindGroups, bindGroup]), + destroy, + sdfOutput: sdfTexture, + colorOutput: colorTexture, + }; + } + + return createExecutor(); +} diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index f21cff1bf3..18a872cc92 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -15,7 +15,7 @@ export { export { isBuffer, isUsableAsVertex } from './core/buffer/buffer.ts'; export { isAccessor, isLazy, isMutableAccessor, isSlot } from './core/slot/slotTypes.ts'; export { isComparisonSampler, isSampler } from './core/sampler/sampler.ts'; -export { isTexture } from './core/texture/texture.ts'; +export { isTexture, isTextureView } from './core/texture/texture.ts'; export { isUsableAsRender, isUsableAsSampled } from './core/texture/usageExtension.ts'; export { isUsableAsStorage } from './extension.ts'; export { isUsableAsUniform } from './core/buffer/bufferUsage.ts'; diff --git a/packages/typegpu/src/tgsl/accessProp.ts b/packages/typegpu/src/tgsl/accessProp.ts index b264a154ba..6c07cffc5e 100644 --- a/packages/typegpu/src/tgsl/accessProp.ts +++ b/packages/typegpu/src/tgsl/accessProp.ts @@ -176,11 +176,10 @@ export function accessProp(target: Snippet, propName: string): Snippet | undefin return accessProp(derefed, propName); } - if (isVec(target.dataType)) { - // Example: d.vec3f().kind === 'vec3f' - if (propName === 'kind') { - return snip(target.dataType.type, UnknownData, 'constant'); - } + // Example: d.vec3f().kind === 'vec3f' + // We are not a struct here so it's okey + if (propName === 'kind' && target.dataType !== UnknownData) { + return snip(target.dataType.type, UnknownData, 'constant'); } const propLength = propName.length; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bae32d359d..335a8179fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: '@typegpu/noise': specifier: workspace:* version: link:../../packages/typegpu-noise + '@typegpu/radiance-cascades': + specifier: workspace:* + version: link:../../packages/typegpu-radiance-cascades '@typegpu/sdf': specifier: workspace:* version: link:../../packages/typegpu-sdf @@ -681,6 +684,28 @@ importers: version: link:../unplugin-typegpu publishDirectory: dist + packages/typegpu-radiance-cascades: + devDependencies: + '@typegpu/tgpu-dev-cli': + specifier: workspace:* + version: link:../tgpu-dev-cli + '@webgpu/types': + specifier: catalog:types + version: 0.1.66 + typegpu: + specifier: workspace:* + version: link:../typegpu + typescript: + specifier: npm:tsover@^5.9.11 + version: tsover@5.9.11 + unbuild: + specifier: catalog:build + version: 3.5.0(tsover@5.9.11) + unplugin-typegpu: + specifier: workspace:* + version: link:../unplugin-typegpu + publishDirectory: dist + packages/typegpu-sdf: devDependencies: '@typegpu/tgpu-dev-cli':