diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/camera.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/camera.ts new file mode 100644 index 000000000..4d7436b92 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/camera.ts @@ -0,0 +1,147 @@ +import type { TgpuRoot, TgpuUniform } from 'typegpu'; +import * as d from 'typegpu/data'; +import * as m from 'wgpu-matrix'; + +const Camera = d.struct({ + view: d.mat4x4f, + proj: d.mat4x4f, + viewInv: d.mat4x4f, + projInv: d.mat4x4f, +}); + +function halton(index: number, base: number) { + let result = 0; + let f = 1 / base; + let i = index; + while (i > 0) { + result += f * (i % base); + i = Math.floor(i / base); + f = f / base; + } + return result; +} + +function* haltonSequence(base: number) { + let index = 1; + while (true) { + yield halton(index, base); + index++; + } +} + +export class CameraController { + #uniform: TgpuUniform; + #view: d.m4x4f; + #proj: d.m4x4f; + #viewInv: d.m4x4f; + #projInv: d.m4x4f; + #baseProj: d.m4x4f; + #baseProjInv: d.m4x4f; + #haltonX: Generator; + #haltonY: Generator; + #width: number; + #height: number; + + constructor( + root: TgpuRoot, + position: d.v3f, + target: d.v3f, + up: d.v3f, + fov: number, + width: number, + height: number, + near = 0.1, + far = 10, + ) { + this.#width = width; + this.#height = height; + + this.#view = m.mat4.lookAt(position, target, up, d.mat4x4f()); + this.#baseProj = m.mat4.perspective( + fov, + width / height, + near, + far, + d.mat4x4f(), + ); + this.#proj = this.#baseProj; + + this.#viewInv = m.mat4.invert(this.#view, d.mat4x4f()); + this.#baseProjInv = m.mat4.invert(this.#baseProj, d.mat4x4f()); + this.#projInv = this.#baseProjInv; + + this.#uniform = root.createUniform(Camera, { + view: this.#view, + proj: this.#proj, + viewInv: this.#viewInv, + projInv: this.#projInv, + }); + + this.#haltonX = haltonSequence(2); + this.#haltonY = haltonSequence(3); + } + + jitter() { + const [jx, jy] = [ + this.#haltonX.next().value, + this.#haltonY.next().value, + ] as [ + number, + number, + ]; + + const jitterX = ((jx - 0.5) * 2.0) / this.#width; + const jitterY = ((jy - 0.5) * 2.0) / this.#height; + + const jitterMatrix = m.mat4.identity(d.mat4x4f()); + jitterMatrix[12] = jitterX; // x translation in NDC + jitterMatrix[13] = jitterY; // y translation in NDC + + const jitteredProj = m.mat4.mul(jitterMatrix, this.#baseProj, d.mat4x4f()); + const jitteredProjInv = m.mat4.invert(jitteredProj, d.mat4x4f()); + + this.#uniform.writePartial({ + proj: jitteredProj, + projInv: jitteredProjInv, + }); + } + + updateView(position: d.v3f, target: d.v3f, up: d.v3f) { + this.#view = m.mat4.lookAt(position, target, up, d.mat4x4f()); + this.#viewInv = m.mat4.invert(this.#view, d.mat4x4f()); + + this.#uniform.writePartial({ + view: this.#view, + viewInv: this.#viewInv, + }); + } + + updateProjection( + fov: number, + width: number, + height: number, + near = 0.1, + far = 100, + ) { + this.#width = width; + this.#height = height; + + this.#baseProj = m.mat4.perspective( + fov, + width / height, + near, + far, + d.mat4x4f(), + ); + this.#baseProjInv = m.mat4.invert(this.#baseProj, d.mat4x4f()); + + this.#uniform.writePartial({ + proj: this.#baseProj, + projInv: this.#baseProjInv, + }); + } + + get cameraUniform() { + return this.#uniform; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/constants.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/constants.ts new file mode 100644 index 000000000..d70b368e2 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/constants.ts @@ -0,0 +1,39 @@ +import * as d from 'typegpu/data'; + +// Rendering constants +export const MAX_STEPS = 64; +export const MAX_DIST = 10; +export const SURF_DIST = 0.001; + +// Ground material constants +export const GROUND_ALBEDO = d.vec3f(1); + +// Lighting constants +export const AMBIENT_COLOR = d.vec3f(0.6); +export const AMBIENT_INTENSITY = 0.6; +export const SPECULAR_POWER = 120.0; +export const SPECULAR_INTENSITY = 0.6; + +// Jelly material constants +export const JELLY_IOR = 1.42; +export const JELLY_SCATTER_STRENGTH = 3; + +// Ambient occlusion constants +export const AO_STEPS = 3; +export const AO_RADIUS = 0.1; +export const AO_INTENSITY = 0.5; +export const AO_BIAS = SURF_DIST * 5; + +// Line/slider constants +export const LINE_RADIUS = 0.024; +export const LINE_HALF_THICK = 0.17; + +// Mouse interaction constants +export const MOUSE_SMOOTHING = 0.08; +export const MOUSE_MIN_X = 0.45; +export const MOUSE_MAX_X = 0.9; +export const MOUSE_RANGE_MIN = 0.4; +export const MOUSE_RANGE_MAX = 0.9; +export const TARGET_MIN = -0.7; +export const TARGET_MAX = 1.0; +export const TARGET_OFFSET = -0.5; diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/dataTypes.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/dataTypes.ts new file mode 100644 index 000000000..3eb87db43 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/dataTypes.ts @@ -0,0 +1,64 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; + +export const DirectionalLight = d.struct({ + direction: d.vec3f, + color: d.vec3f, +}); + +export const ObjectType = { + SLIDER: 1, + BACKGROUND: 2, +} as const; + +export const HitInfo = d.struct({ + distance: d.f32, + objectType: d.i32, + t: d.f32, +}); + +export const LineInfo = d.struct({ + t: d.f32, + distance: d.f32, + normal: d.vec2f, +}); + +export const BoxIntersection = d.struct({ + hit: d.bool, + tMin: d.f32, + tMax: d.f32, +}); + +export const Ray = d.struct({ + origin: d.vec3f, + direction: d.vec3f, +}); + +export const SdfBbox = d.struct({ + left: d.f32, + right: d.f32, + bottom: d.f32, + top: d.f32, +}); + +export const rayMarchLayout = tgpu.bindGroupLayout({ + backgroundTexture: { texture: d.texture2d(d.f32) }, +}); + +export const taaResolveLayout = tgpu.bindGroupLayout({ + currentTexture: { + texture: d.texture2d(), + }, + historyTexture: { + texture: d.texture2d(), + }, + outputTexture: { + storageTexture: d.textureStorage2d('rgba8unorm', 'write-only'), + }, +}); + +export const sampleLayout = tgpu.bindGroupLayout({ + currentTexture: { + texture: d.texture2d(), + }, +}); diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/events.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/events.ts new file mode 100644 index 000000000..43e50a116 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/events.ts @@ -0,0 +1,93 @@ +import { + MOUSE_MAX_X, + MOUSE_MIN_X, + MOUSE_RANGE_MAX, + MOUSE_RANGE_MIN, + MOUSE_SMOOTHING, + TARGET_MAX, + TARGET_MIN, + TARGET_OFFSET, +} from './constants.ts'; + +export class EventHandler { + private canvas: HTMLCanvasElement; + private mouseX = 1.0; + private targetMouseX = 1.0; + private isMouseDown = false; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.setupEventListeners(); + } + + private setupEventListeners() { + // Mouse events + this.canvas.addEventListener('mouseup', () => { + this.isMouseDown = false; + }); + + this.canvas.addEventListener('mouseleave', () => { + this.isMouseDown = false; + }); + + this.canvas.addEventListener('mousedown', (e) => { + this.handlePointerDown(e.clientX); + }); + + this.canvas.addEventListener('mousemove', (e) => { + if (!this.isMouseDown) return; + this.handlePointerMove(e.clientX); + }); + + // Touch events + this.canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + this.handlePointerDown(touch.clientX); + }); + + this.canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + if (!this.isMouseDown) return; + const touch = e.touches[0]; + this.handlePointerMove(touch.clientX); + }); + + this.canvas.addEventListener('touchend', (e) => { + e.preventDefault(); + this.isMouseDown = false; + }); + } + + private handlePointerDown(clientX: number) { + this.isMouseDown = true; + this.updateTargetMouseX(clientX); + } + + private handlePointerMove(clientX: number) { + this.updateTargetMouseX(clientX); + } + + private updateTargetMouseX(clientX: number) { + const rect = this.canvas.getBoundingClientRect(); + const normalizedX = (clientX - rect.left) / rect.width; + const clampedX = Math.max(MOUSE_MIN_X, Math.min(MOUSE_MAX_X, normalizedX)); + this.targetMouseX = + ((clampedX - MOUSE_RANGE_MIN) / (MOUSE_RANGE_MAX - MOUSE_RANGE_MIN)) * + (TARGET_MAX - TARGET_MIN) + TARGET_OFFSET; + } + + update() { + if (this.isMouseDown) { + this.mouseX += (this.targetMouseX - this.mouseX) * MOUSE_SMOOTHING; + } + } + + get currentMouseX() { + return this.mouseX; + } + + get isPointerDown() { + return this.isMouseDown; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.html b/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.html new file mode 100644 index 000000000..d29c21b53 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.html @@ -0,0 +1,37 @@ + + +
+ Inspired by work of + Voicu Apostol +
+ diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts new file mode 100644 index 000000000..da0af3b8e --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/index.ts @@ -0,0 +1,1022 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import * as sdf from '@typegpu/sdf'; +import { fullScreenTriangle } from 'typegpu/common'; + +import { randf } from '@typegpu/noise'; +import { Slider } from './slider.ts'; +import { CameraController } from './camera.ts'; +import { EventHandler } from './events.ts'; +import { + DirectionalLight, + HitInfo, + LineInfo, + ObjectType, + Ray, + rayMarchLayout, + sampleLayout, + SdfBbox, +} from './dataTypes.ts'; +import { + beerLambert, + createBackgroundTexture, + createTextures, + fresnelSchlick, + intersectBox, +} from './utils.ts'; +import { TAAResolver } from './taa.ts'; +import { + AMBIENT_COLOR, + AMBIENT_INTENSITY, + AO_BIAS, + AO_INTENSITY, + AO_RADIUS, + AO_STEPS, + GROUND_ALBEDO, + JELLY_IOR, + JELLY_SCATTER_STRENGTH, + LINE_HALF_THICK, + LINE_RADIUS, + MAX_DIST, + MAX_STEPS, + SPECULAR_INTENSITY, + SPECULAR_POWER, + SURF_DIST, +} from './constants.ts'; +import { NumberProvider } from './numbers.ts'; + +const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = canvas.getContext('webgpu') as GPUCanvasContext; + +const root = await tgpu.init({ + device: { + optionalFeatures: ['timestamp-query'], + }, +}); +const hasTimestampQuery = root.enabledFeatures.has('timestamp-query'); +context.configure({ + device: root.device, + format: presentationFormat, + alphaMode: 'premultiplied', +}); + +const NUM_POINTS = 17; + +const slider = new Slider( + root, + d.vec2f(-1, 0), + d.vec2f(0.9, 0), + NUM_POINTS, + -0.03, +); +const bezierTexture = slider.bezierTexture.createView(); +const bezierBbox = slider.bbox; + +const digitsProvider = new NumberProvider(root); +const digitsTextureView = digitsProvider.digitTextureAtlas.createView( + d.texture2dArray(d.f32), +); + +let qualityScale = 0.5; +let [width, height] = [ + canvas.width * qualityScale, + canvas.height * qualityScale, +]; + +let textures = createTextures(root, width, height); +let backgroundTexture = createBackgroundTexture(root, width, height); + +const filteringSampler = root['~unstable'].createSampler({ + magFilter: 'linear', + minFilter: 'linear', +}); + +const camera = new CameraController( + root, + d.vec3f(0.024, 2.7, 1.9), + d.vec3f(0, 0, 0), + d.vec3f(0, 1, 0), + Math.PI / 4, + width, + height, +); +const cameraUniform = camera.cameraUniform; + +const lightUniform = root.createUniform(DirectionalLight, { + direction: std.normalize(d.vec3f(0.19, -0.24, 0.75)), + color: d.vec3f(1, 1, 1), +}); + +const jellyColorUniform = root.createUniform( + d.vec4f, + d.vec4f(1.0, 0.45, 0.075, 1.0), +); + +const randomUniform = root.createUniform(d.vec2f); +const blurEnabledUniform = root.createUniform(d.u32); + +const getRay = (ndc: d.v2f) => { + 'use gpu'; + const clipPos = d.vec4f(ndc.x, ndc.y, -1.0, 1.0); + + const invView = cameraUniform.$.viewInv; + const invProj = cameraUniform.$.projInv; + + const viewPos = std.mul(invProj, clipPos); + const viewPosNormalized = d.vec4f(viewPos.xyz.div(viewPos.w), 1.0); + + const worldPos = std.mul(invView, viewPosNormalized); + + const rayOrigin = invView.columns[3].xyz; + const rayDir = std.normalize(std.sub(worldPos.xyz, rayOrigin)); + + return Ray({ + origin: rayOrigin, + direction: rayDir, + }); +}; + +const getSliderBbox = () => { + 'use gpu'; + return SdfBbox({ + left: d.f32(bezierBbox[3]), + right: d.f32(bezierBbox[1]), + bottom: d.f32(bezierBbox[2]), + top: d.f32(bezierBbox[0]), + }); +}; + +const sdInflatedPolyline2D = (p: d.v2f) => { + 'use gpu'; + const bbox = getSliderBbox(); + + const uv = d.vec2f( + (p.x - bbox.left) / (bbox.right - bbox.left), + (bbox.top - p.y) / (bbox.top - bbox.bottom), + ); + const clampedUV = std.saturate(uv); + + const sampledColor = std.textureSampleLevel( + bezierTexture.$, + filteringSampler.$, + clampedUV, + 0, + ); + const segUnsigned = sampledColor.x; + const progress = sampledColor.y; + const normal = sampledColor.zw; + + return LineInfo({ + t: progress, + distance: segUnsigned, + normal: normal, + }); +}; + +const cap3D = (position: d.v3f) => { + 'use gpu'; + const endCap = slider.endCapUniform.$; + const secondLastPoint = d.vec2f(endCap.x, endCap.y); + const lastPoint = d.vec2f(endCap.z, endCap.w); + + const angle = std.atan2( + lastPoint.y - secondLastPoint.y, + lastPoint.x - secondLastPoint.x, + ); + const rot = d.mat2x2f( + std.cos(angle), + -std.sin(angle), + std.sin(angle), + std.cos(angle), + ); + + let pieP = position.sub(d.vec3f(secondLastPoint, 0)); + pieP = d.vec3f(rot.mul(pieP.xy), pieP.z); + const hmm = sdf.sdPie(pieP.zx, d.vec2f(1, 0), LINE_HALF_THICK); + const extrudeEnd = sdf.opExtrudeY( + pieP, + hmm, + 0.001, + ) - LINE_RADIUS; + return extrudeEnd; +}; + +const sliderSdf3D = (position: d.v3f) => { + 'use gpu'; + const poly2D = sdInflatedPolyline2D(position.xy); + + let finalDist = d.f32(0.0); + if (poly2D.t > 0.94) { + finalDist = cap3D(position); + } else { + const body = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - + LINE_RADIUS; + finalDist = body; + } + + return LineInfo({ + t: poly2D.t, + distance: finalDist, + normal: poly2D.normal, + }); +}; + +const getMainSceneDist = (position: d.v3f) => { + 'use gpu'; + return sdf.opSmoothDifference( + sdf.sdPlane(position, d.vec3f(0, 1, 0), 0), + sdf.opExtrudeY( + position, + sdf.sdRoundedBox2d(position.xz, d.vec2f(1, 0.2), 0.2), + 0.06, + ), + 0.01, + ); +}; + +const sliderApproxDist = (position: d.v3f) => { + 'use gpu'; + const bbox = getSliderBbox(); + + const p = position.xy; + if ( + p.x < bbox.left || p.x > bbox.right || p.y < bbox.bottom || p.y > bbox.top + ) { + return 1e9; + } + + const poly2D = sdInflatedPolyline2D(p); + const dist3D = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - + LINE_RADIUS; + + return dist3D; +}; + +const getSceneDist = (position: d.v3f) => { + 'use gpu'; + const mainScene = getMainSceneDist(position); + const poly3D = sliderSdf3D(position); + + const hitInfo = HitInfo(); + + if (poly3D.distance < mainScene) { + hitInfo.distance = poly3D.distance; + hitInfo.objectType = ObjectType.SLIDER; + hitInfo.t = poly3D.t; + } else { + hitInfo.distance = mainScene; + hitInfo.objectType = ObjectType.BACKGROUND; + } + return hitInfo; +}; + +const getSceneDistForAO = (position: d.v3f) => { + 'use gpu'; + const mainScene = getMainSceneDist(position); + const sliderApprox = sliderApproxDist(position); + return std.min(mainScene, sliderApprox); +}; + +const sdfSlot = tgpu.slot<(pos: d.v3f) => number>(); + +const getNormalFromSdf = tgpu.fn([d.vec3f, d.f32], d.vec3f)( + (position, epsilon) => { + 'use gpu'; + const k = d.vec3f(1, -1, 0); + + const offset1 = k.xyy.mul(epsilon); + const offset2 = k.yyx.mul(epsilon); + const offset3 = k.yxy.mul(epsilon); + const offset4 = k.xxx.mul(epsilon); + + const sample1 = offset1.mul(sdfSlot.$(position.add(offset1))); + const sample2 = offset2.mul(sdfSlot.$(position.add(offset2))); + const sample3 = offset3.mul(sdfSlot.$(position.add(offset3))); + const sample4 = offset4.mul(sdfSlot.$(position.add(offset4))); + + const gradient = sample1.add(sample2).add(sample3).add(sample4); + + return std.normalize(gradient); + }, +); + +const getNormalCapSdf = getNormalFromSdf.with(sdfSlot, cap3D); +const getNormalMainSdf = getNormalFromSdf.with(sdfSlot, getMainSceneDist); + +const getNormalCap = (pos: d.v3f) => { + 'use gpu'; + return getNormalCapSdf(pos, 0.01); +}; + +const getNormalMain = (position: d.v3f) => { + 'use gpu'; + if (std.abs(position.z) > 0.22 || std.abs(position.x) > 1.02) { + return d.vec3f(0, 1, 0); + } + return getNormalMainSdf(position, 0.0001); +}; + +const getSliderNormal = ( + position: d.v3f, + hitInfo: d.Infer, +) => { + 'use gpu'; + const poly2D = sdInflatedPolyline2D(position.xy); + const gradient2D = poly2D.normal; + + const threshold = LINE_HALF_THICK * 0.85; + const absZ = std.abs(position.z); + const zDistance = std.max( + 0, + (absZ - threshold) * LINE_HALF_THICK / (LINE_HALF_THICK - threshold), + ); + const edgeDistance = LINE_RADIUS - poly2D.distance; + + const edgeContrib = 0.9; + const zContrib = 1.0 - edgeContrib; + + const zDirection = std.sign(position.z); + const zAxisVector = d.vec3f(0, 0, zDirection); + + const edgeBlendDistance = edgeContrib * LINE_RADIUS + + zContrib * LINE_HALF_THICK; + + const blendFactor = std.smoothstep( + edgeBlendDistance, + 0.0, + zDistance * zContrib + edgeDistance * edgeContrib, + ); + + const normal2D = d.vec3f(gradient2D.xy, 0); + const blendedNormal = std.mix( + zAxisVector, + normal2D, + blendFactor * 0.5 + 0.5, + ); + + let normal = std.normalize(blendedNormal); + + if (hitInfo.t > 0.94) { + const ratio = (hitInfo.t - 0.94) / 0.02; + const fullNormal = getNormalCap(position); + normal = std.normalize(std.mix(normal, fullNormal, ratio)); + } + + return normal; +}; + +const getNormal = ( + position: d.v3f, + hitInfo: d.Infer, +) => { + 'use gpu'; + if (hitInfo.objectType === ObjectType.SLIDER && hitInfo.t < 0.96) { + return getSliderNormal(position, hitInfo); + } + + return std.select( + getNormalCap(position), + getNormalMain(position), + hitInfo.objectType === ObjectType.BACKGROUND, + ); +}; + +const getShadow = (position: d.v3f, normal: d.v3f, lightDir: d.v3f) => { + 'use gpu'; + const newDir = std.normalize(lightDir); + + const bias = 0.005; + const newOrigin = position.add(normal.mul(bias)); + + const bbox = getSliderBbox(); + const zDepth = d.f32(0.21); + + const sliderMin = d.vec3f(bbox.left, bbox.bottom, -zDepth); + const sliderMax = d.vec3f(bbox.right, bbox.top, zDepth); + + const intersection = intersectBox( + newOrigin, + newDir, + sliderMin, + sliderMax, + ); + + if (intersection.hit) { + let t = std.max(0.0, intersection.tMin); + const maxT = intersection.tMax; + + for (let i = 0; i < MAX_STEPS; i++) { + const currPos = newOrigin.add(newDir.mul(t)); + const hitInfo = getSceneDist(currPos); + + if (hitInfo.distance < SURF_DIST) { + return std.select( + 0.8, + 0.3, + hitInfo.objectType === ObjectType.SLIDER, + ); + } + + t += hitInfo.distance; + if (t > maxT) { + break; + } + } + } + + return d.f32(0); +}; + +const calculateAO = (position: d.v3f, normal: d.v3f) => { + 'use gpu'; + let totalOcclusion = d.f32(0.0); + let sampleWeight = d.f32(1.0); + const stepDistance = AO_RADIUS / AO_STEPS; + + for (let i = 1; i <= AO_STEPS; i++) { + const sampleHeight = stepDistance * d.f32(i); + const samplePosition = position.add(normal.mul(sampleHeight)); + const distanceToSurface = getSceneDistForAO(samplePosition) - AO_BIAS; + const occlusionContribution = std.max( + 0.0, + sampleHeight - distanceToSurface, + ); + totalOcclusion += occlusionContribution * sampleWeight; + sampleWeight *= 0.5; + if (totalOcclusion > AO_RADIUS / AO_INTENSITY) { + break; + } + } + + const rawAO = 1.0 - (AO_INTENSITY * totalOcclusion) / AO_RADIUS; + return std.saturate(rawAO); +}; + +const calculateLighting = ( + hitPosition: d.v3f, + normal: d.v3f, + rayOrigin: d.v3f, +) => { + 'use gpu'; + const lightDir = std.neg(lightUniform.$.direction); + + const shadow = getShadow(hitPosition, normal, lightDir); + const visibility = 1.0 - shadow; + + const diffuse = std.max(std.dot(normal, lightDir), 0.0); + + const viewDir = std.normalize(rayOrigin.sub(hitPosition)); + const reflectDir = std.reflect(std.neg(lightDir), normal); + const specularFactor = std.max(std.dot(viewDir, reflectDir), 0) ** + SPECULAR_POWER; + const specular = lightUniform.$.color.mul( + specularFactor * SPECULAR_INTENSITY, + ); + + const baseColor = d.vec3f(0.9); + + const directionalLight = baseColor + .mul(lightUniform.$.color) + .mul(diffuse * visibility); + const ambientLight = baseColor.mul(AMBIENT_COLOR).mul(AMBIENT_INTENSITY); + + const finalSpecular = specular.mul(visibility); + + return std.saturate(directionalLight.add(ambientLight).add(finalSpecular)); +}; + +const applyAO = ( + litColor: d.v3f, + hitPosition: d.v3f, + normal: d.v3f, +) => { + 'use gpu'; + const ao = calculateAO(hitPosition, normal); + const finalColor = litColor.mul(ao); + return d.vec4f(finalColor, 1.0); +}; + +const rayMarchNoJelly = (rayOrigin: d.v3f, rayDirection: d.v3f) => { + 'use gpu'; + let distanceFromOrigin = d.f32(); + let hit = d.f32(); + + for (let i = 0; i < 6; i++) { + const p = rayOrigin.add(rayDirection.mul(distanceFromOrigin)); + hit = getMainSceneDist(p); + distanceFromOrigin += hit; + if (distanceFromOrigin > MAX_DIST || hit < SURF_DIST * 10) { + break; + } + } + + if (distanceFromOrigin < MAX_DIST) { + return renderBackground( + rayOrigin, + rayDirection, + distanceFromOrigin, + std.select(d.f32(), 0.87, blurEnabledUniform.$ === 1), + ).xyz; + } + return d.vec3f(); +}; + +const renderPercentageOnGround = ( + hitPosition: d.v3f, + center: d.v3f, + percentage: number, +) => { + 'use gpu'; + + const textWidth = 0.38; + const textHeight = 0.33; + + if ( + std.abs(hitPosition.x - center.x) > textWidth * 0.5 || + std.abs(hitPosition.z - center.z) > textHeight * 0.5 + ) { + return d.vec4f(); + } + + const localX = hitPosition.x - center.x; + const localZ = hitPosition.z - center.z; + + const uvX = (localX + textWidth * 0.5) / textWidth; + const uvZ = (localZ + textHeight * 0.5) / textHeight; + + if (uvX < 0.0 || uvX > 1.0 || uvZ < 0.0 || uvZ > 1.0) { + return d.vec4f(); + } + + return std.textureSampleLevel( + digitsTextureView.$, + filteringSampler.$, + d.vec2f(uvX, uvZ), + percentage, + 0, + ); +}; + +const renderBackground = ( + rayOrigin: d.v3f, + rayDirection: d.v3f, + backgroundHitDist: number, + offset: number, +) => { + 'use gpu'; + const hitPosition = rayOrigin.add(rayDirection.mul(backgroundHitDist)); + + const percentageSample = renderPercentageOnGround( + hitPosition, + d.vec3f(0.72, 0, 0), + d.u32((slider.endCapUniform.$.x + 0.43) * 84), + ); + + let highlights = d.f32(); + + const highlightWidth = 1; + const highlightHeight = 0.2; + let offsetX = d.f32(); + let offsetZ = d.f32(0.05); + + const lightDir = lightUniform.$.direction; + const causticScale = 0.2; + offsetX -= lightDir.x * causticScale; + offsetZ += lightDir.z * causticScale; + + const endCapX = slider.endCapUniform.$.x; + const sliderStretch = (endCapX + 1) * 0.5; + + if ( + std.abs(hitPosition.x + offsetX) < highlightWidth && + std.abs(hitPosition.z + offsetZ) < highlightHeight + ) { + const uvX_orig = (hitPosition.x + offsetX + highlightWidth * 2) / + highlightWidth * 0.5; + const uvZ_orig = (hitPosition.z + offsetZ + highlightHeight * 2) / + highlightHeight * 0.5; + + const centeredUV = d.vec2f(uvX_orig - 0.5, uvZ_orig - 0.5); + const finalUV = d.vec2f( + centeredUV.x, + 1 - ((std.abs(centeredUV.y - 0.5) * 2) ** 2) * 0.3, + ); + + const density = std.max( + 0, + (std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0) + .x - 0.25) * 8, + ); + + const fadeX = std.smoothstep(0, -0.2, hitPosition.x - endCapX); + const fadeZ = 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 3; + const fadeStretch = std.saturate(1 - sliderStretch); + const edgeFade = std.saturate(fadeX) * std.saturate(fadeZ) * fadeStretch; + + highlights = density ** 3 * edgeFade * 3 * (1 + lightDir.z) / 1.5; + } + + const originYBound = std.saturate(rayOrigin.y + 0.01); + const posOffset = hitPosition.add( + d.vec3f(0, 1, 0).mul( + offset * + (originYBound / (1.0 + originYBound)) * + (1 + randf.sample() / 2), + ), + ); + const newNormal = getNormalMain(posOffset); + + const litColor = calculateLighting(posOffset, newNormal, rayOrigin); + const backgroundColor = applyAO( + GROUND_ALBEDO.mul(litColor), + posOffset, + newNormal, + ); + + const textColor = std.saturate(backgroundColor.xyz.mul(d.vec3f(0.5))); + + return d.vec4f( + std.mix(backgroundColor.xyz, textColor, percentageSample.x).mul( + 1.0 + highlights, + ), + 1.0, + ); +}; + +const rayMarch = (rayOrigin: d.v3f, rayDirection: d.v3f, uv: d.v2f) => { + 'use gpu'; + let totalSteps = d.u32(); + + let backgroundDist = d.f32(); + for (let i = 0; i < MAX_STEPS; i++) { + const p = rayOrigin.add(rayDirection.mul(backgroundDist)); + const hit = getMainSceneDist(p); + backgroundDist += hit; + if (hit < SURF_DIST) { + break; + } + } + const background = renderBackground( + rayOrigin, + rayDirection, + backgroundDist, + d.f32(), + ); + + const bbox = getSliderBbox(); + const zDepth = d.f32(0.25); + + const sliderMin = d.vec3f(bbox.left, bbox.bottom, -zDepth); + const sliderMax = d.vec3f(bbox.right, bbox.top, zDepth); + + const intersection = intersectBox( + rayOrigin, + rayDirection, + sliderMin, + sliderMax, + ); + + if (!intersection.hit) { + return background; + } + + let distanceFromOrigin = std.max(d.f32(0.0), intersection.tMin); + + for (let i = 0; i < MAX_STEPS; i++) { + if (totalSteps >= MAX_STEPS) { + break; + } + + const currentPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin)); + + const hitInfo = getSceneDist(currentPosition); + distanceFromOrigin += hitInfo.distance; + totalSteps++; + + if (hitInfo.distance < SURF_DIST) { + const hitPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin)); + + if (!(hitInfo.objectType === ObjectType.SLIDER)) { + break; + } + + const N = getNormal(hitPosition, hitInfo); + const I = rayDirection; + const cosi = std.min( + 1.0, + std.max(0.0, std.dot(std.neg(I), N)), + ); + const F = fresnelSchlick(cosi, d.f32(1.0), d.f32(JELLY_IOR)); + + const reflection = std.saturate(d.vec3f(hitPosition.y + 0.2)); + + const eta = 1.0 / JELLY_IOR; + const k = 1.0 - eta * eta * (1.0 - cosi * cosi); + let refractedColor = d.vec3f(); + if (k > 0.0) { + const refrDir = std.normalize( + std.add( + I.mul(eta), + N.mul(eta * cosi - std.sqrt(k)), + ), + ); + const p = hitPosition.add(refrDir.mul(SURF_DIST * 2.0)); + const exitPos = p.add(refrDir.mul(SURF_DIST * 2.0)); + + const env = rayMarchNoJelly(exitPos, refrDir); + const progress = hitInfo.t; + const jellyColor = jellyColorUniform.$; + + const scatterTint = jellyColor.xyz.mul(1.5); + const density = d.f32(20.0); + const absorb = d.vec3f(1.0).sub(jellyColor.xyz).mul(density); + + const T = beerLambert(absorb.mul(progress ** 2), 0.08); + + const lightDir = std.neg(lightUniform.$.direction); + + const forward = std.max(0.0, std.dot(lightDir, refrDir)); + const scatter = scatterTint.mul( + JELLY_SCATTER_STRENGTH * forward * progress ** 3, + ); + refractedColor = env.mul(T).add(scatter); + } + + const jelly = std.add( + reflection.mul(F), + refractedColor.mul(1 - F), + ); + + return d.vec4f(jelly, 1.0); + } + + if (distanceFromOrigin > backgroundDist) { + break; + } + } + + return background; +}; + +const raymarchFn = tgpu['~unstable'].fragmentFn({ + in: { + uv: d.vec2f, + }, + out: d.vec4f, +})(({ uv }) => { + randf.seed2(randomUniform.$.mul(uv)); + + const ndc = d.vec2f(uv.x * 2 - 1, -(uv.y * 2 - 1)); + const ray = getRay(ndc); + + const color = rayMarch( + ray.origin, + ray.direction, + uv, + ); + return d.vec4f(std.tanh(color.xyz.mul(1.3)), 1); +}); + +const fragmentMain = tgpu['~unstable'].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})((input) => { + return std.textureSample( + sampleLayout.$.currentTexture, + filteringSampler.$, + input.uv, + ); +}); + +const rayMarchPipeline = root['~unstable'] + .withVertex(fullScreenTriangle, {}) + .withFragment(raymarchFn, { format: 'rgba8unorm' }) + .createPipeline(); + +const renderPipeline = root['~unstable'] + .withVertex(fullScreenTriangle, {}) + .withFragment(fragmentMain, { format: presentationFormat }) + .createPipeline(); + +const eventHandler = new EventHandler(canvas); +let lastTimeStamp = performance.now(); +let frameCount = 0; +const taaResolver = new TAAResolver(root, width, height); + +let attributionDismissed = false; +const attributionElement = document.getElementById( + 'attribution', +) as HTMLDivElement; + +function dismissAttribution() { + if (!attributionDismissed && attributionElement) { + attributionElement.style.opacity = '0'; + attributionElement.style.pointerEvents = 'none'; + attributionDismissed = true; + } +} + +canvas.addEventListener('mousedown', dismissAttribution, { once: true }); +canvas.addEventListener('touchstart', dismissAttribution, { once: true }); +canvas.addEventListener('wheel', dismissAttribution, { once: true }); + +function createBindGroups() { + return { + rayMarch: root.createBindGroup(rayMarchLayout, { + backgroundTexture: backgroundTexture.sampled, + }), + render: [0, 1].map((frame) => + root.createBindGroup(sampleLayout, { + currentTexture: taaResolver.getResolvedTexture(frame), + }) + ), + }; +} + +let bindGroups = createBindGroups(); + +function render(timestamp: number) { + frameCount++; + camera.jitter(); + const deltaTime = Math.min((timestamp - lastTimeStamp) * 0.001, 0.1); + lastTimeStamp = timestamp; + + randomUniform.write( + d.vec2f((Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2), + ); + + eventHandler.update(); + slider.setDragX(eventHandler.currentMouseX); + slider.update(deltaTime); + + const currentFrame = frameCount % 2; + + rayMarchPipeline + .withColorAttachment({ + view: root.unwrap(textures[currentFrame].sampled), + loadOp: 'clear', + storeOp: 'store', + }) + .draw(3); + + taaResolver.resolve( + textures[currentFrame].sampled, + frameCount, + currentFrame, + ); + + renderPipeline + .withColorAttachment({ + view: context.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store', + }) + .with(bindGroups.render[currentFrame]) + .draw(3); + + requestAnimationFrame(render); +} + +function handleResize() { + [width, height] = [ + canvas.width * qualityScale, + canvas.height * qualityScale, + ]; + camera.updateProjection(Math.PI / 4, width, height); + textures = createTextures(root, width, height); + backgroundTexture = createBackgroundTexture(root, width, height); + taaResolver.resize(width, height); + frameCount = 0; + + bindGroups = createBindGroups(); +} + +const resizeObserver = new ResizeObserver(() => { + handleResize(); +}); +resizeObserver.observe(canvas); + +requestAnimationFrame(render); + +// #region Example controls and cleanup + +async function autoSetQuaility() { + if (!hasTimestampQuery) { + return 0.5; + } + + const targetFrameTime = 5; + const tolerance = 2.0; + let resolutionScale = 0.3; + let lastTimeMs = 0; + + const measurePipeline = rayMarchPipeline + .withPerformanceCallback((start, end) => { + lastTimeMs = Number(end - start) / 1e6; + }); + + for (let i = 0; i < 8; i++) { + const testTexture = root['~unstable'].createTexture({ + size: [canvas.width * resolutionScale, canvas.height * resolutionScale], + format: 'rgba8unorm', + }).$usage('render'); + + measurePipeline + .withColorAttachment({ + view: root.unwrap(testTexture).createView(), + loadOp: 'clear', + storeOp: 'store', + }) + .with( + root.createBindGroup(rayMarchLayout, { + backgroundTexture: backgroundTexture.sampled, + }), + ) + .draw(3); + + await root.device.queue.onSubmittedWorkDone(); + testTexture.destroy(); + + if (Math.abs(lastTimeMs - targetFrameTime) < tolerance) { + break; + } + + const adjustment = lastTimeMs > targetFrameTime ? -0.1 : 0.1; + resolutionScale = Math.max( + 0.3, + Math.min(1.0, resolutionScale + adjustment), + ); + } + + console.log(`Auto-selected quality scale: ${resolutionScale.toFixed(2)}`); + return resolutionScale; +} + +export const controls = { + 'Quality': { + initial: 'Auto', + options: [ + 'Auto', + 'Very Low', + 'Low', + 'Medium', + 'High', + 'Ultra', + ], + onSelectChange: (value: string) => { + if (value === 'Auto') { + autoSetQuaility().then((scale) => { + qualityScale = scale; + handleResize(); + }); + return; + } + + const qualityMap: { [key: string]: number } = { + 'Very Low': 0.3, + 'Low': 0.5, + 'Medium': 0.7, + 'High': 0.85, + 'Ultra': 1.0, + }; + + qualityScale = qualityMap[value] || 0.5; + handleResize(); + }, + }, + 'Light dir': { + initial: 0, + min: 0, + max: 1, + step: 0.01, + onSliderChange: (v: number) => { + const dir1 = std.normalize(d.vec3f(0.18, -0.30, 0.64)); + const dir2 = std.normalize(d.vec3f(-0.5, -0.14, -0.8)); + const finalDir = std.normalize(std.mix(dir1, dir2, v)); + lightUniform.writePartial({ + direction: finalDir, + }); + }, + }, + 'Jelly Color': { + initial: [1.0, 0.45, 0.075], + onColorChange: (c: [number, number, number]) => { + jellyColorUniform.write(d.vec4f(...c, 1.0)); + }, + }, + 'Blur': { + initial: false, + onToggleChange: (v: boolean) => { + blurEnabledUniform.write(d.u32(v)); + }, + }, +}; + +export function onCleanup() { + resizeObserver.disconnect(); + root.destroy(); +} + +// #endregion diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/meta.json b/apps/typegpu-docs/src/examples/rendering/jelly-slider/meta.json new file mode 100644 index 000000000..c4204f436 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Jelly slider", + "category": "rendering", + "tags": ["experimental"] +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/numbers.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/numbers.ts new file mode 100644 index 000000000..46f0f32d3 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/numbers.ts @@ -0,0 +1,61 @@ +import type { SampledFlag, TgpuRoot, TgpuTexture } from 'typegpu'; + +const PERCENTAGE_WIDTH = 256 * 2; +const PERCENTAGE_HEIGHT = 128 * 2; +const PERCENTAGE_COUNT = 101; // 0% to 100% + +export class NumberProvider { + digitTextureAtlas: + & TgpuTexture<{ + size: [ + typeof PERCENTAGE_WIDTH, + typeof PERCENTAGE_HEIGHT, + typeof PERCENTAGE_COUNT, + ]; + format: 'rgba8unorm'; + }> + & SampledFlag; + + constructor(root: TgpuRoot) { + this.digitTextureAtlas = root['~unstable'].createTexture({ + size: [PERCENTAGE_WIDTH, PERCENTAGE_HEIGHT, PERCENTAGE_COUNT], + format: 'rgba8unorm', + }).$usage('sampled', 'render'); + + this.#fillAtlas(); + } + + #fillAtlas() { + const canvas = document.createElement('canvas'); + canvas.width = PERCENTAGE_WIDTH; + canvas.height = PERCENTAGE_HEIGHT; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + throw new Error('Failed to get 2D context'); + } + + ctx.font = + '160px "SF Mono", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'white'; + + const percentageImages = []; + + for (let i = 0; i <= 100; i++) { + ctx.clearRect(0, 0, PERCENTAGE_WIDTH, PERCENTAGE_HEIGHT); + + const text = `${i}%`; + const x = PERCENTAGE_WIDTH - 20; + const y = PERCENTAGE_HEIGHT / 2; + + ctx.fillText(text, x, y); + + percentageImages.push( + ctx.getImageData(0, 0, PERCENTAGE_WIDTH, PERCENTAGE_HEIGHT), + ); + } + + this.digitTextureAtlas.write(percentageImages); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/slider.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/slider.ts new file mode 100644 index 000000000..122f3cb54 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/slider.ts @@ -0,0 +1,478 @@ +import type { + SampledFlag, + StorageFlag, + TgpuBuffer, + TgpuRoot, + TgpuTexture, + TgpuUniform, +} from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { sdBezier } from '@typegpu/sdf'; + +const BEZIER_TEXTURE_SIZE = [256, 128] as const; + +export class Slider { + #root: TgpuRoot; + #pos: d.v2f[]; + #normals: d.v2f[]; + #prev: d.v2f[]; + #invMass: Float32Array; + #targetX: number; + #controlPoints: d.v2f[]; + #yOffset: number; + #computeBezierTexture: ReturnType< + TgpuRoot['~unstable']['prepareDispatch'] + >; + + pointsBuffer: TgpuBuffer> & StorageFlag; + controlPointsBuffer: TgpuBuffer> & StorageFlag; + normalsBuffer: TgpuBuffer> & StorageFlag; + bezierTexture: + & TgpuTexture<{ + size: typeof BEZIER_TEXTURE_SIZE; + format: 'rgba16float'; + }> + & SampledFlag + & StorageFlag; + endCapUniform: TgpuUniform; + + readonly n: number; + readonly totalLength: number; + readonly restLen: number; + readonly baseY: number; + readonly anchor: d.v2f; + readonly bbox: [top: number, right: number, bottom: number, left: number]; + + // Physics parameters + iterations = 16; + substeps = 6; + damping = 0.01; + bendingStrength = 0.1; + archStrength = 2; + endFlatCount = 1; + endFlatStiffness = 0.05; + bendingExponent = 1.2; + archEdgeDeadzone = 0.01; + + constructor( + root: TgpuRoot, + start: d.v2f, + end: d.v2f, + numPoints: number, + yOffset = 0, + ) { + this.#root = root; + this.n = Math.max(2, numPoints | 0); + this.anchor = start; + this.baseY = start.y; + this.#targetX = end.x; + this.#yOffset = yOffset; + + const dx = end.x - start.x; + const dy = end.y - start.y; + this.totalLength = Math.hypot(dx, dy); + this.restLen = this.totalLength / (this.n - 1); + + this.#pos = new Array(this.n); + this.#controlPoints = new Array(this.n - 1); + this.#normals = new Array(this.n); + this.#prev = new Array(this.n); + this.#invMass = new Float32Array(this.n); + + for (let i = 0; i < this.n; i++) { + const t = i / (this.n - 1); + const x = start[0] * (1 - t) + end[0] * t; + const y = (start[1] * (1 - t) + end[1] * t) + this.#yOffset; + this.#pos[i] = d.vec2f(x, y); + this.#prev[i] = d.vec2f(x, y); + this.#normals[i] = d.vec2f(0, 1); + this.#invMass[i] = i === 0 || i === this.n - 1 ? 0 : 1; + if (i < this.n - 1) { + const t2 = (i + 0.5) / (this.n - 1); + const cx = start[0] * (1 - t2) + end[0] * t2; + const cy = (start[1] * (1 - t2) + end[1] * t2) + this.#yOffset; + this.#controlPoints[i] = d.vec2f(cx, cy); + } + } + + this.pointsBuffer = this.#root + .createBuffer( + d.arrayOf(d.vec2f, this.n), + this.#pos, + ) + .$usage('storage'); + + this.controlPointsBuffer = this.#root + .createBuffer( + d.arrayOf(d.vec2f, this.n - 1), + this.#controlPoints, + ) + .$usage('storage'); + + this.normalsBuffer = this.#root + .createBuffer( + d.arrayOf(d.vec2f, this.n), + this.#normals, + ) + .$usage('storage'); + + this.bezierTexture = this.#root['~unstable'] + .createTexture({ + size: BEZIER_TEXTURE_SIZE, + format: 'rgba16float', + }) + .$usage('sampled', 'storage', 'render'); + + this.endCapUniform = this.#root.createUniform(d.vec4f, d.vec4f(0, 0, 0, 0)); + + const bezierWriteView = this.bezierTexture.createView( + d.textureStorage2d('rgba16float', 'write-only'), + ); + const pointsView = this.pointsBuffer.as('readonly'); + const controlPointsView = this.controlPointsBuffer.as('readonly'); + + const padding = 0.01; + const left = start.x - this.totalLength * padding; + const right = end.x + this.totalLength * padding * 10; + const bottom = -0.3; + const top = 0.65; + + this.bbox = [top, right, bottom, left]; + + this.#computeBezierTexture = this.#root['~unstable'].prepareDispatch( + (x, y) => { + 'use gpu'; + const size = std.textureDimensions(bezierWriteView.$); + const pixelUV = d.vec2f(x, y).add(0.5).div(d.vec2f(size)); + + const sliderPos = d.vec2f( + left + pixelUV.x * (right - left), + top - pixelUV.y * (top - bottom), + ); + + let minDist = d.f32(1e10); + let closestSegment = d.i32(0); + let closestT = d.f32(0); + + const epsilon = d.f32(0.03); + const xOffset = d.vec2f(epsilon, 0.0); + const yOffset = d.vec2f(0.0, epsilon); + + let xPlusDist = d.f32(1e10); + let xMinusDist = d.f32(1e10); + let yPlusDist = d.f32(1e10); + let yMinusDist = d.f32(1e10); + + for (let i = 0; i < pointsView.$.length - 1; i++) { + const A = pointsView.$[i]; + const B = pointsView.$[i + 1]; + const C = controlPointsView.$[i]; + + const dist = sdBezier(sliderPos, A, C, B); + if (dist < minDist) { + minDist = dist; + closestSegment = i; + + const AB = B.sub(A); + const AP = sliderPos.sub(A); + const ABLength = std.length(AB); + + if (ABLength > 0.0) { + closestT = std.clamp( + std.dot(AP, AB) / (ABLength * ABLength), + 0.0, + 1.0, + ); + } else { + closestT = 0.0; + } + } + + xPlusDist = std.min( + xPlusDist, + sdBezier(sliderPos.add(xOffset), A, C, B), + ); + xMinusDist = std.min( + xMinusDist, + sdBezier(sliderPos.sub(xOffset), A, C, B), + ); + yPlusDist = std.min( + yPlusDist, + sdBezier(sliderPos.add(yOffset), A, C, B), + ); + yMinusDist = std.min( + yMinusDist, + sdBezier(sliderPos.sub(yOffset), A, C, B), + ); + } + + const overallProgress = (d.f32(closestSegment) + closestT) / + d.f32(pointsView.$.length - 1); + + const normalX = (xPlusDist - xMinusDist) / (2.0 * epsilon); + const normalY = (yPlusDist - yMinusDist) / (2.0 * epsilon); + + std.textureStore( + bezierWriteView.$, + d.vec2u(x, y), + d.vec4f(minDist, overallProgress, normalX, normalY), + ); + }, + ); + } + + setDragX(x: number) { + const minX = this.anchor[0] - this.totalLength; + const maxX = this.anchor[0] + this.totalLength; + this.#targetX = std.clamp(x, minX, maxX); + } + + update(dt: number) { + if (dt <= 0) return; + + const h = dt / this.substeps; + const damp = std.clamp(this.damping, 0, 0.999); + const compression = Math.max( + 0, + 1 - Math.abs(this.#targetX - this.anchor[0]) / this.totalLength, + ); + + for (let s = 0; s < this.substeps; s++) { + this.#integrate(h, damp, compression); + this.#projectConstraints(); + } + + this.#computeNormals(); + this.#computeControlPoints(); + this.#updateGPUBuffer(); + this.#computeBezierTexture.dispatchThreads(...BEZIER_TEXTURE_SIZE); + } + + #integrate(h: number, damp: number, compression: number) { + for (let i = 0; i < this.n; i++) { + const px = this.#pos[i].x; + const py = this.#pos[i].y; + + // Pin endpoints + if (i === 0) { + this.#pos[i] = d.vec2f(this.anchor[0], this.anchor[1] + this.#yOffset); + this.#prev[i] = d.vec2f(this.anchor[0], this.anchor[1] + this.#yOffset); + continue; + } + if (i === this.n - 1) { + this.#pos[i] = d.vec2f(this.#targetX, 0.08 + this.#yOffset); + this.#prev[i] = d.vec2f(this.#targetX, 0.08 + this.#yOffset); + continue; + } + + // Verlet integration with damping + const vx = (px - this.#prev[i].x) * (1 - damp); + const vy = (py - this.#prev[i].y) * (1 - damp); + + // Arch bias in middle section only + let ay = 0; + if (compression > 0) { + const t = i / (this.n - 1); + const edge = this.archEdgeDeadzone; + const window = std.smoothstep(edge, 1 - edge, t) * + std.smoothstep(edge, 1 - edge, 1 - t); + const profile = Math.sin(Math.PI * t) * window; + ay = this.archStrength * profile * compression; + } + + this.#prev[i] = d.vec2f(px, py); + this.#pos[i] = d.vec2f(px + vx, py + vy + ay * h * h); + + // Keep above baseline + if (this.#pos[i].y < this.baseY + this.#yOffset) { + this.#pos[i] = d.vec2f(this.#pos[i].x, this.baseY + this.#yOffset); + } + } + } + + #projectConstraints() { + for (let it = 0; it < this.iterations; it++) { + // Segment length constraints + for (let i = 0; i < this.n - 1; i++) { + this.#projectDistance(i, i + 1, this.restLen, 0.1); + } + + // Bending resistance (stronger at ends) + for (let i = 1; i < this.n - 1; i++) { + const t = i / (this.n - 1); + const distFromCenter = Math.abs(t - 0.5) * 2; + const strength = distFromCenter ** this.bendingExponent; + const k = this.bendingStrength * (0.05 + 0.95 * strength); + this.#projectDistance(i - 1, i + 1, 2 * this.restLen, k); + } + + // Flatten ends + if (this.endFlatCount > 0) { + const count = Math.min(this.endFlatCount, this.n - 2); + for (let i = 1; i <= count; i++) { + this.#projectLineY( + i, + this.baseY + this.#yOffset, + this.endFlatStiffness, + ); + } + for (let i = this.n - 1 - count; i < this.n - 1; i++) { + this.#projectLineY( + i, + this.baseY + this.#yOffset, + this.endFlatStiffness, + ); + } + } + + // Re-pin endpoints + this.#pos[0] = d.vec2f(this.anchor[0], this.anchor[1] + this.#yOffset); + this.#pos[this.n - 1] = d.vec2f( + this.#targetX, + 0.08 + this.#yOffset, + ); + } + } + + #projectDistance(i: number, j: number, rest: number, k: number) { + const dx = this.#pos[j].x - this.#pos[i].x; + const dy = this.#pos[j].y - this.#pos[i].y; + const len = Math.hypot(dx, dy); + + if (len < 1e-8) return; + + const w1 = this.#invMass[i]; + const w2 = this.#invMass[j]; + const wsum = w1 + w2; + if (wsum <= 0) return; + + const diff = (len - rest) / len; + const c1 = (w1 / wsum) * k; + const c2 = (w2 / wsum) * k; + + this.#pos[i] = d.vec2f( + this.#pos[i].x + dx * diff * c1, + this.#pos[i].y + dy * diff * c1, + ); + this.#pos[j] = d.vec2f( + this.#pos[j].x - dx * diff * c2, + this.#pos[j].y - dy * diff * c2, + ); + } + + #projectLineY(i: number, yTarget: number, k: number) { + if (i <= 0 || i >= this.n - 1 || this.#invMass[i] <= 0) return; + this.#pos[i] = d.vec2f( + this.#pos[i].x, + this.#pos[i].y + (yTarget - this.#pos[i].y) * std.saturate(k), + ); + } + + #computeNormals() { + const n = this.n; + const eps = 1e-6; + for (let i = 0; i < n; i++) { + let dx: number; + let dy: number; + + if (i === 0 && n > 1) { + dx = this.#pos[1].x - this.#pos[0].x; + dy = this.#pos[1].y - this.#pos[0].y; + } else if (i === n - 1 && n > 1) { + dx = this.#pos[n - 1].x - this.#pos[n - 2].x; + dy = this.#pos[n - 1].y - this.#pos[n - 2].y; + } else { + dx = this.#pos[i + 1].x - this.#pos[i - 1].x; + dy = this.#pos[i + 1].y - this.#pos[i - 1].y; + } + + let len = Math.hypot(dx, dy); + if (len < eps) { + if (i > 0) { + dx = this.#pos[i].x - this.#pos[i - 1].x; + dy = this.#pos[i].y - this.#pos[i - 1].y; + len = Math.hypot(dx, dy); + } + if (len < eps && i < n - 1) { + dx = this.#pos[i + 1].x - this.#pos[i].x; + dy = this.#pos[i + 1].y - this.#pos[i].y; + len = Math.hypot(dx, dy); + } + if (len < eps) { + this.#normals[i] = i > 0 ? this.#normals[i - 1] : d.vec2f(0, 1); + continue; + } + } + + dx /= len; + dy /= len; + this.#normals[i] = d.vec2f(-dy, dx); + } + } + + #computeControlPoints() { + const eps = 1e-6; + for (let i = 0; i < this.n - 1; i++) { + const A = this.#pos[i]; + const B = this.#pos[i + 1]; + + const nA = this.#normals[i]; + const nB = this.#normals[i + 1]; + + if (i === 0 || i === this.n - 2) { + this.#controlPoints[i] = d.vec2f( + (A.x + B.x) * 0.5, + (A.y + B.y) * 0.5, + ); + continue; + } + + if (std.dot(nA, nB) > 0.99) { + // Nearly parallel normals; midpoint fallback prevents explosions. + // tiny offset opposite to the normal (any of them) + this.#controlPoints[i] = d.vec2f( + (A.x + B.x) * 0.5, + (A.y + B.y) * 0.5, + ); + continue; + } + + const tA = d.vec2f(nA.y, -nA.x); + const tB = d.vec2f(nB.y, -nB.x); + + // Solve A + t*tA = B - s*tB -> t*tA + s*tB = (B - A) + const dx = B.x - A.x; + const dy = B.y - A.y; + const denom = tA.x * tB.y - tA.y * tB.x; // cross(tA, tB) + + if (Math.abs(denom) <= 1e-6) { + // Nearly parallel tangents; midpoint fallback prevents explosions. + this.#controlPoints[i] = d.vec2f( + (A.x + B.x) * 0.5, + (A.y + B.y) * 0.5, + ); + continue; + } + + // t = cross(B - A, tB) / cross(tA, tB) + const t = (dx * tB.y - dy * tB.x) / denom; + const cx = A.x + t * tA.x; + const cy = A.y + t * tA.y; + this.#controlPoints[i] = d.vec2f(cx, cy); + } + } + + #updateGPUBuffer() { + this.pointsBuffer.write(this.#pos); + this.controlPointsBuffer.write(this.#controlPoints); + this.normalsBuffer.write(this.#normals); + + const len = this.#pos.length; + const secondLast = this.#pos[len - 2]; + const last = this.#pos[len - 1]; + this.endCapUniform.write( + d.vec4f(secondLast.x, secondLast.y, last.x, last.y), + ); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/taa.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/taa.ts new file mode 100644 index 000000000..8fb82642f --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/taa.ts @@ -0,0 +1,166 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import type { TgpuComputePipeline, TgpuRoot, TgpuTextureView } from 'typegpu'; +import { taaResolveLayout } from './dataTypes.ts'; + +export const taaResolveFn = tgpu['~unstable'].computeFn({ + workgroupSize: [16, 16], + in: { + gid: d.builtin.globalInvocationId, + }, +})(({ gid }) => { + const currentColor = std.textureLoad( + taaResolveLayout.$.currentTexture, + d.vec2u(gid.xy), + 0, + ); + + const historyColor = std.textureLoad( + taaResolveLayout.$.historyTexture, + d.vec2u(gid.xy), + 0, + ); + + let minColor = d.vec3f(9999.0); + let maxColor = d.vec3f(-9999.0); + + const dimensions = std.textureDimensions(taaResolveLayout.$.currentTexture); + + for (let x = -1; x <= 1; x++) { + for (let y = -1; y <= 1; y++) { + const sampleCoord = d.vec2i(gid.xy).add(d.vec2i(x, y)); + const clampedCoord = std.clamp( + sampleCoord, + d.vec2i(0, 0), + d.vec2i(dimensions.xy).sub(d.vec2i(1)), + ); + + const neighborColor = std.textureLoad( + taaResolveLayout.$.currentTexture, + clampedCoord, + 0, + ); + + minColor = std.min(minColor, neighborColor.xyz); + maxColor = std.max(maxColor, neighborColor.xyz); + } + } + + const historyColorClamped = std.clamp(historyColor.xyz, minColor, maxColor); + + const uv = d.vec2f(gid.xy).div(d.vec2f(dimensions.xy)); + + const textRegionMinX = d.f32(0.71); + const textRegionMaxX = d.f32(0.85); + const textRegionMinY = d.f32(0.47); + const textRegionMaxY = d.f32(0.55); + + const borderSize = d.f32(0.02); + + const fadeInX = std.smoothstep( + textRegionMinX - borderSize, + textRegionMinX + borderSize, + uv.x, + ); + const fadeOutX = d.f32(1.0) - (std.smoothstep( + textRegionMaxX - borderSize, + textRegionMaxX + borderSize, + uv.x, + )); + const fadeInY = std.smoothstep( + textRegionMinY - borderSize, + textRegionMinY + borderSize, + uv.y, + ); + const fadeOutY = d.f32(1.0) - (std.smoothstep( + textRegionMaxY - borderSize, + textRegionMaxY + borderSize, + uv.y, + )); + + const inTextRegion = fadeInX * fadeOutX * fadeInY * fadeOutY; + const blendFactor = std.mix(d.f32(0.9), d.f32(0.7), inTextRegion); + + const resolvedColor = d.vec4f( + std.mix(currentColor.xyz, historyColorClamped, blendFactor), + 1.0, + ); + + std.textureStore( + taaResolveLayout.$.outputTexture, + d.vec2u(gid.x, gid.y), + resolvedColor, + ); +}); + +export function createTaaTextures( + root: TgpuRoot, + width: number, + height: number, +) { + return [0, 1].map(() => { + const texture = root['~unstable'].createTexture({ + size: [width, height], + format: 'rgba8unorm', + }).$usage('storage', 'sampled'); + + return { + write: texture.createView(d.textureStorage2d('rgba8unorm')), + sampled: texture.createView(), + }; + }); +} + +export class TAAResolver { + #pipeline: TgpuComputePipeline; + #textures: ReturnType; + #root: TgpuRoot; + #width: number; + #height: number; + + constructor(root: TgpuRoot, width: number, height: number) { + this.#root = root; + this.#width = width; + this.#height = height; + + this.#pipeline = root['~unstable'] + .withCompute(taaResolveFn) + .createPipeline(); + + this.#textures = createTaaTextures(root, width, height); + } + + resolve( + currentTexture: TgpuTextureView>, + frameCount: number, + currentFrame: number, + ) { + const previousFrame = 1 - currentFrame; + + this.#pipeline.with( + this.#root.createBindGroup(taaResolveLayout, { + currentTexture, + historyTexture: frameCount === 1 + ? currentTexture + : this.#textures[previousFrame].sampled, + outputTexture: this.#textures[currentFrame].write, + }), + ).dispatchWorkgroups( + Math.ceil(this.#width / 16), + Math.ceil(this.#height / 16), + ); + + return this.#textures[currentFrame].sampled; + } + + resize(width: number, height: number) { + this.#width = width; + this.#height = height; + this.#textures = createTaaTextures(this.#root, width, height); + } + + getResolvedTexture(frame: number) { + return this.#textures[frame].sampled; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/jelly-slider/thumbnail.png new file mode 100644 index 000000000..51cd3959a Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/jelly-slider/thumbnail.png differ diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-slider/utils.ts b/apps/typegpu-docs/src/examples/rendering/jelly-slider/utils.ts new file mode 100644 index 000000000..d4b96cf9b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-slider/utils.ts @@ -0,0 +1,74 @@ +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { BoxIntersection } from './dataTypes.ts'; +import type { TgpuRoot } from 'typegpu'; + +export const fresnelSchlick = ( + cosTheta: number, + ior1: number, + ior2: number, +) => { + 'use gpu'; + const r0 = std.pow((ior1 - ior2) / (ior1 + ior2), 2.0); + return r0 + (1.0 - r0) * std.pow(1.0 - cosTheta, 5.0); +}; + +export const beerLambert = (sigma: d.v3f, dist: number) => { + 'use gpu'; + return std.exp(std.mul(sigma, -dist)); +}; + +export const intersectBox = ( + rayOrigin: d.v3f, + rayDirection: d.v3f, + boxMin: d.v3f, + boxMax: d.v3f, +) => { + 'use gpu'; + const invDir = d.vec3f(1.0).div(rayDirection); + + const t1 = std.sub(boxMin, rayOrigin).mul(invDir); + const t2 = std.sub(boxMax, rayOrigin).mul(invDir); + + const tMinVec = std.min(t1, t2); + const tMaxVec = std.max(t1, t2); + + const tMin = std.max(std.max(tMinVec.x, tMinVec.y), tMinVec.z); + const tMax = std.min(std.min(tMaxVec.x, tMaxVec.y), tMaxVec.z); + + const result = BoxIntersection(); + result.hit = tMax >= tMin && tMax >= 0.0; + result.tMin = tMin; + result.tMax = tMax; + + return result; +}; + +export function createTextures(root: TgpuRoot, width: number, height: number) { + return [0, 1].map(() => { + const texture = root['~unstable'].createTexture({ + size: [width, height], + format: 'rgba8unorm', + }).$usage('storage', 'sampled', 'render'); + + return { + write: texture.createView(d.textureStorage2d('rgba8unorm')), + sampled: texture.createView(), + }; + }); +} + +export function createBackgroundTexture( + root: TgpuRoot, + width: number, + height: number, +) { + const texture = root['~unstable'].createTexture({ + size: [width, height], + format: 'rgba16float', + }).$usage('sampled', 'render'); + + return { + sampled: texture.createView(), + }; +} diff --git a/packages/typegpu-sdf/src/2d.ts b/packages/typegpu-sdf/src/2d.ts index c4cec9d1c..8b0c7a54f 100644 --- a/packages/typegpu-sdf/src/2d.ts +++ b/packages/typegpu-sdf/src/2d.ts @@ -1,6 +1,23 @@ import tgpu from 'typegpu'; -import { f32, vec2f } from 'typegpu/data'; -import { abs, add, length, max, min, sub } from 'typegpu/std'; +import { f32, type v2f, vec2f, vec3f } from 'typegpu/data'; +import { + abs, + acos, + add, + clamp, + cos, + dot, + length, + max, + min, + mul, + pow, + saturate, + sign, + sin, + sqrt, + sub, +} from 'typegpu/std'; /** * Signed distance function for a disk (filled circle) @@ -32,3 +49,113 @@ export const sdRoundedBox2d = tgpu const d = add(sub(abs(p), size), vec2f(cornerRadius)); return length(max(d, vec2f(0))) + min(max(d.x, d.y), 0) - cornerRadius; }); + +/** + * Signed distance function for a line segment + * @param p Point to evaluate + * @param a First endpoint of the line + * @param b Second endpoint of the line + */ +export const sdLine = tgpu.fn([vec2f, vec2f, vec2f], f32)((p, a, b) => { + const pa = sub(p, a); + const ba = sub(b, a); + const h = max(0, min(1, dot(pa, ba) / dot(ba, ba))); + return length(sub(pa, ba.mul(h))); +}); + +const dot2 = (v: v2f) => { + 'use gpu'; + return dot(v, v); +}; + +/** + * Signed distance function for a quadratic Bezier curve + * @param pos Point to evaluate + * @param A First control point of the Bezier curve + * @param B Second control point of the Bezier curve + * @param C Third control point of the Bezier curve + */ +export const sdBezier = tgpu.fn([vec2f, vec2f, vec2f, vec2f], f32)( + (pos, A, B, C) => { + const a = B.sub(A); + const b = A.sub(B.mul(2)).add(C); + const c = a.mul(f32(2)); + const d = A.sub(pos); + + const dotB = max(dot(b, b), 0.0001); + const kk = 1 / dotB; + const kx = kk * dot(a, b); + const ky = (kk * (f32(2) * dot(a, a) + dot(d, b))) / 3; + const kz = kk * dot(d, a); + + let res = f32(0); + const p = ky - kx * kx; + const p3 = p * p * p; + const q = kx * (2 * kx * kx - 3 * ky) + kz; + let h = q * q + 4 * p3; + + if (h >= 0.0) { + h = sqrt(h); + const x = vec2f(h, -h).sub(q).mul(0.5); + const uv = sign(x).mul(pow(abs(x), vec2f(1 / 3))); + const t = clamp(uv.x + uv.y - kx, 0, 1); + res = dot2(d.add(c.add(b.mul(t)).mul(t))); + } else { + const z = sqrt(-p); + const v = acos(q / (p * z * 2)) / 3; + const m = cos(v); + const n = mul(sin(v), 1.732050808); // sqrt(3) + const t = saturate( + vec3f(m + m, -n - m, n - m) + .mul(z) + .sub(kx), + ); + + res = min( + dot2(d.add(c.add(b.mul(t.x)).mul(t.x))), + dot2(d.add(c.add(b.mul(t.y)).mul(t.y))), + ); + } + + return sqrt(res); + }, +); + +const cro = (a: v2f, b: v2f) => { + 'use gpu'; + return a.x * b.y - a.y * b.x; +}; + +export const sdBezierApprox = tgpu.fn( + [vec2f, vec2f, vec2f, vec2f], + f32, +)((pos, A, B, C) => { + const i = A.sub(C); + const j = C.sub(B); + const k = B.sub(A); + const w = j.sub(k); + + const v0 = A.sub(pos); + const v1 = B.sub(pos); + const v2 = C.sub(pos); + + const x = cro(v0, v2); + const y = cro(v1, v0); + const z = cro(v2, v1); + + const s = j.mul(y).add(k.mul(z)).mul(2).sub(i.mul(x)); + + const r = (y * z - x * x * 0.25) / dot2(s); + const t = saturate((0.5 * x + y + r * dot(s, w)) / (x + y + z)); + + const d = v0.add(k.add(k).add(w.mul(t)).mul(t)); + return length(d); +}); + +export const sdPie = tgpu.fn([vec2f, vec2f, f32], f32)((p, c, r) => { + const p_w = p; + p_w.x = abs(p.x); + const l = length(p_w) - r; + const m = length(p_w.sub(c.mul(clamp(dot(p_w, c), 0, r)))); + return max(l, m * sign(c.y * p_w.x - c.x * p_w.y)); +}); diff --git a/packages/typegpu-sdf/src/3d.ts b/packages/typegpu-sdf/src/3d.ts index 3f38444d1..5c05c0d19 100644 --- a/packages/typegpu-sdf/src/3d.ts +++ b/packages/typegpu-sdf/src/3d.ts @@ -1,6 +1,6 @@ import tgpu from 'typegpu'; import { f32, vec3f } from 'typegpu/data'; -import { abs, add, dot, length, max, min, sub } from 'typegpu/std'; +import { abs, add, dot, length, max, min, saturate, sub } from 'typegpu/std'; /** * Signed distance function for a sphere @@ -59,6 +59,19 @@ export const sdBoxFrame3d = tgpu return min(min(d1, d2), d3); }); +/** + * Signed distance function for a 3D line segment + * @param p Point to evaluate + * @param a First endpoint of the line + * @param b Second endpoint of the line + */ +export const sdLine3d = tgpu.fn([vec3f, vec3f, vec3f], f32)((p, a, b) => { + const pa = sub(p, a); + const ba = sub(b, a); + const h = max(0, min(1, dot(pa, ba) / dot(ba, ba))); + return length(sub(pa, ba.mul(h))); +}); + /** * Signed distance function for an infinite plane * @param p Point to evaluate @@ -68,3 +81,18 @@ export const sdBoxFrame3d = tgpu export const sdPlane = tgpu.fn([vec3f, vec3f, f32], f32)((p, n, h) => { return dot(p, n) + h; }); + +/** + * Signed distance function for a 3D capsule + * @param p Point to evaluate + * @param a First endpoint of the capsule segment + * @param b Second endpoint of the capsule segment + * @param radius Radius of the capsule + */ +export const sdCapsule = tgpu + .fn([vec3f, vec3f, vec3f, f32], f32)((p, a, b, radius) => { + const pa = sub(p, a); + const ba = sub(b, a); + const h = saturate(dot(pa, ba) / dot(ba, ba)); + return length(sub(pa, ba.mul(h))) - radius; + }); diff --git a/packages/typegpu-sdf/src/index.ts b/packages/typegpu-sdf/src/index.ts index 711108458..78942f225 100644 --- a/packages/typegpu-sdf/src/index.ts +++ b/packages/typegpu-sdf/src/index.ts @@ -1,9 +1,19 @@ export * from './operators.ts'; -export { sdBox2d, sdDisk, sdRoundedBox2d } from './2d.ts'; +export { + sdBezier, + sdBezierApprox, + sdBox2d, + sdDisk, + sdLine, + sdPie, + sdRoundedBox2d, +} from './2d.ts'; export { sdBox3d, sdBoxFrame3d, + sdCapsule, + sdLine3d, sdPlane, sdRoundedBox3d, sdSphere, diff --git a/packages/typegpu-sdf/src/operators.ts b/packages/typegpu-sdf/src/operators.ts index 4faff0962..2aefcf40c 100644 --- a/packages/typegpu-sdf/src/operators.ts +++ b/packages/typegpu-sdf/src/operators.ts @@ -14,9 +14,43 @@ import * as std from 'typegpu/std'; export const opSmoothUnion = tgpu .fn([d.f32, d.f32, d.f32], d.f32)((d1, d2, k) => { const h = std.max(k - std.abs(d1 - d2), 0) / k; - return std.min(d1, d2) - h * h * k * (1 / d.f32(4)); + return std.min(d1, d2) - h * h * k * (1 / 4); }); +/** + * Smooth difference operator for subtracting one SDF from another with a smooth transition + * + * @param d1 First SDF distance (base shape) + * @param d2 Second SDF distance (shape to subtract) + * @param k Smoothing factor (larger k = more smoothing) + */ +export const opSmoothDifference = tgpu + .fn([d.f32, d.f32, d.f32], d.f32)((d1, d2, k) => { + const h = std.max(k - std.abs(-d1 - d2), 0) / k; + return std.max(-d2, d1) + h * h * k * (1 / 4); + }); + +export const opExtrudeZ = tgpu.fn([d.vec3f, d.f32, d.f32], d.f32)( + (p, dd, h) => { + const w = d.vec2f(dd, std.abs(p.z) - h); + return std.min(std.max(w.x, w.y), 0) + std.length(std.max(w, d.vec2f())); + }, +); + +export const opExtrudeX = tgpu.fn([d.vec3f, d.f32, d.f32], d.f32)( + (p, dd, h) => { + const w = d.vec2f(dd, std.abs(p.x) - h); + return std.min(std.max(w.x, w.y), 0) + std.length(std.max(w, d.vec2f())); + }, +); + +export const opExtrudeY = tgpu.fn([d.vec3f, d.f32, d.f32], d.f32)( + (p, dd, h) => { + const w = d.vec2f(dd, std.abs(p.y) - h); + return std.min(std.max(w.x, w.y), 0) + std.length(std.max(w, d.vec2f())); + }, +); + /** * Union operator for combining two SDFs * Returns the minimum distance between two SDFs