diff --git a/apps/typegpu-docs/src/components/ExampleShowcase.tsx b/apps/typegpu-docs/src/components/ExampleShowcase.tsx index a2434fdabf..e065d28f3f 100644 --- a/apps/typegpu-docs/src/components/ExampleShowcase.tsx +++ b/apps/typegpu-docs/src/components/ExampleShowcase.tsx @@ -68,6 +68,7 @@ export const ADVANCED_EXAMPLE_KEYS = [ 'rendering--clouds', 'rendering--cubemap-reflection', 'rendering--function-visualizer', + 'rendering--jelly-knob', 'rendering--jelly-slider', 'rendering--jelly-switch', 'rendering--os-awards', diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/camera.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/camera.ts new file mode 100644 index 0000000000..b717c7bbd7 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/camera.ts @@ -0,0 +1,118 @@ +import { d, type TgpuRoot, type TgpuUniform } from 'typegpu'; +import * as m from 'wgpu-matrix'; + +export 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; + #viewInv: 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.#viewInv = m.mat4.invert(this.#view, d.mat4x4f()); + this.#baseProjInv = m.mat4.invert(this.#baseProj, d.mat4x4f()); + + this.#uniform = root.createUniform(Camera, { + view: this.#view, + proj: this.#baseProj, + viewInv: this.#viewInv, + projInv: this.#baseProjInv, + }); + + 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.patch({ + 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.patch({ + view: this.#view, + viewInv: this.#viewInv, + }); + } + + updateProjection(fov: number, width: number, height: number, near = 0.1, far = 10) { + 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.patch({ + proj: this.#baseProj, + projInv: this.#baseProjInv, + }); + } + + get cameraUniform() { + return this.#uniform; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/constants.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/constants.ts new file mode 100644 index 0000000000..654d739b6a --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/constants.ts @@ -0,0 +1,54 @@ +import { d, std } from 'typegpu'; +import type { SpringProperties } from './spring.ts'; + +// Rendering constants +export const MAX_STEPS = 64; +export const MAX_DIST = 10; +export const SURF_DIST = 0.001; + +// Fixed camera, looking at the origin with +Y up +export const CAMERA_POSITION = d.vec3f(0, 2.7, 0.8); + +// Ground material constants +// Linear blue-gray, tuned for the example page's dark #1b1f2c background. +export const GROUND_ALBEDO = d.vec3f(0.025, 0.032, 0.054); +export const LIGHT_GROUND_ALBEDO = d.vec3f(0.65, 0.7, 0.79); +export const EXPOSURE = 1.5; +export const METER_TICKS = 16; + +export const GroundParams = { + groundThickness: 0.01, + groundRoundness: 0.01, + jellyCutoutRadius: 0.38, + meterCutoutRadius: 0.6, + meterCutoutGirth: 0.04, +}; + +// Lighting constants +export const AMBIENT_COLOR = d.vec3f(0.6); +export const AMBIENT_INTENSITY = 0.6; +export const SPECULAR_POWER = 10; +export const SPECULAR_INTENSITY = 0.6; +export const LIGHT_DIR = std.normalize(d.vec3f(-0.5, -0.14, -0.8)); +// Light travels from above and behind the knob toward the camera (+Z). +export const LIGHT_MODE_LIGHT_DIR = std.normalize(d.vec3f(-0.12, -0.75, 0.7)); + +// 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; + +// Jelly constants +export const JELLY_HALFSIZE = d.vec3f(0.3, 0.3, 0.3); + +// Spring dynamics constants +export const twistProperties: SpringProperties = { + mass: 4, + stiffness: 700, + damping: 50, +}; diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/dataTypes.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/dataTypes.ts new file mode 100644 index 0000000000..b482e36fdf --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/dataTypes.ts @@ -0,0 +1,70 @@ +import { tgpu, d, type TgpuUniform } from 'typegpu'; +import type { KnobBehavior } from './knob.ts'; +import type { Camera } from './camera.ts'; + +export const DirectionalLight = d.struct({ + direction: d.vec3f, + color: d.vec3f, +}); + +export const ObjectType = { + JELLY: 1, + BACKGROUND: 3, +} as const; + +export const HitInfo = d.struct({ + distance: d.f32, + objectType: d.i32, +}); + +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 RayMarchResult = d.struct({ + point: d.vec3f, + color: d.vec3f, +}); + +export type BoundingBox = d.Infer; +export const BoundingBox = d.struct({ + min: d.vec3f, + max: d.vec3f, +}); + +export const KnobState = d.struct({ + topDisplacement: d.vec3f, + topProgress: d.f32, + bottomProgress: d.f32, +}); + +export const taaResolveLayout = tgpu.bindGroupLayout({ + currentTexture: { + texture: d.texture2d(), + }, + historyTexture: { + texture: d.texture2d(), + }, + outputTexture: { + storageTexture: d.textureStorage2d('rgba16float', 'write-only'), + }, +}); + +export const sampleLayout = tgpu.bindGroupLayout({ + currentTexture: { + texture: d.texture2d(), + }, +}); + +export const knobBehaviorSlot = tgpu.slot(); +export const cameraUniformSlot = tgpu.slot>(); +export const lightUniformSlot = tgpu.slot>(); +export const jellyColorUniformSlot = tgpu.slot>(); +export const darkModeUniformSlot = tgpu.slot>(); diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.html b/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.html new file mode 100644 index 0000000000..aa8cc321b3 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.ts new file mode 100644 index 0000000000..f7e1325f2b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.ts @@ -0,0 +1,334 @@ +import { tgpu, common, d, std } from 'typegpu'; +import { blueNoise2d } from '@typegpu/noise'; + +import { PhoneMotion } from './motion.ts'; +import { KnobBehavior } from './knob.ts'; +import { CameraController } from './camera.ts'; +import { + cameraUniformSlot, + darkModeUniformSlot, + DirectionalLight, + jellyColorUniformSlot, + knobBehaviorSlot, + lightUniformSlot, + sampleLayout, +} from './dataTypes.ts'; +import { createTextures } from './utils.ts'; +import { TAAResolver } from './taa.ts'; +import { CAMERA_POSITION, LIGHT_DIR, LIGHT_MODE_LIGHT_DIR } from './constants.ts'; +import { raymarchFn } from './raymarchers.ts'; +import { defineControls } from '../../common/defineControls.ts'; + +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const root = await tgpu.init({ + device: { + optionalFeatures: ['timestamp-query'], + }, +}); +const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); +const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); +const hasTimestampQuery = root.enabledFeatures.has('timestamp-query'); + +const knobBehavior = new KnobBehavior(root); +const phoneMotion = new PhoneMotion(); +knobBehavior.motionAcceleration = phoneMotion.acceleration; + +let qualityScale = 0.5; +let [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale]; + +let textures = createTextures(root, width, height); + +const filteringSampler = root.createSampler({ + magFilter: 'linear', + minFilter: 'linear', +}); + +const camera = new CameraController( + root, + CAMERA_POSITION, + 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: LIGHT_MODE_LIGHT_DIR, + color: d.vec3f(1, 1, 1), +}); + +const DEFAULT_JELLY_COLOR = d.vec3f(1, 0, 0.25); +const jellyColorUniform = root.createUniform(d.vec4f, d.vec4f(DEFAULT_JELLY_COLOR, 1)); + +const darkModeUniform = root.createUniform(d.u32, 0); +// Generate and upload once at startup; this array can also be precomputed at build time. +const BLUE_NOISE_SIZE = 64; +const blueNoise = root.createReadonly( + d.arrayOf(d.f32, BLUE_NOISE_SIZE * BLUE_NOISE_SIZE), + blueNoise2d.generate({ size: BLUE_NOISE_SIZE }), +); + +const fragmentMain = tgpu.fragmentFn({ + in: { uv: d.vec2f, position: d.builtin.position }, + out: d.vec4f, +})((input) => { + 'use gpu'; + const color = std.textureSample(sampleLayout.$.currentTexture, filteringSampler.$, input.uv); + // Dither in display space after TAA and upscaling, before the canvas quantizes to 8 bits. + // Tile at output-pixel resolution without filtering. Keep it fixed to avoid flicker, + // and share the blue-noise value across RGB to avoid colored speckles. + const pixel = d.vec2u(input.position.xy) % d.vec2u(BLUE_NOISE_SIZE); + const threshold = blueNoise.$[pixel.y * BLUE_NOISE_SIZE + pixel.x]; + const noise = ((threshold - 0.5) * 12) / 255; + return d.vec4f(std.saturate(color.rgb + noise), color.a); +}); + +const rayMarchPipeline = root + .with(knobBehaviorSlot, knobBehavior) + .with(cameraUniformSlot, cameraUniform) + .with(lightUniformSlot, lightUniform) + .with(jellyColorUniformSlot, jellyColorUniform) + .with(darkModeUniformSlot, darkModeUniform) + .createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: raymarchFn, + targets: { format: 'rgba16float' }, + }); + +const renderPipeline = root.createRenderPipeline({ + vertex: common.fullScreenTriangle, + fragment: fragmentMain, + targets: { format: presentationFormat }, +}); + +let lastTimeStamp = performance.now(); +let frameCount = 0; +const taaResolver = new TAAResolver(root, width, height); + +function createRenderBindGroups() { + return [0, 1].map((frame) => + root.createBindGroup(sampleLayout, { + currentTexture: taaResolver.getResolvedTexture(frame), + }), + ); +} + +let renderBindGroups = createRenderBindGroups(); + +let animationFrameHandle: number; +function render(timestamp: number) { + frameCount++; + camera.jitter(); + const deltaTime = Math.min((timestamp - lastTimeStamp) * 0.001, 0.1); + lastTimeStamp = timestamp; + + phoneMotion.update(); + knobBehavior.update(deltaTime); + + const currentFrame = frameCount % 2; + + rayMarchPipeline + .withColorAttachment({ + view: textures[currentFrame].sampled, + loadOp: 'clear', + storeOp: 'store', + }) + .draw(3); + + taaResolver.resolve(textures[currentFrame].sampled, frameCount, currentFrame); + + renderPipeline + .withColorAttachment({ view: context }) + .with(renderBindGroups[currentFrame]) + .draw(3); + + animationFrameHandle = requestAnimationFrame(render); +} + +function destroyRenderTextures() { + for (const { texture } of textures) { + texture.destroy(); + } +} + +function handleResize() { + [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale]; + camera.updateProjection(Math.PI / 4, width, height); + destroyRenderTextures(); + textures = createTextures(root, width, height); + taaResolver.resize(width, height); + frameCount = 0; + + renderBindGroups = createRenderBindGroups(); +} + +const resizeObserver = new ResizeObserver(() => { + handleResize(); +}); +resizeObserver.observe(canvas); + +animationFrameHandle = requestAnimationFrame(render); + +// #region Example controls and cleanup + +let prevX = 0; + +canvas.addEventListener('touchstart', (event) => { + knobBehavior.pressed = true; + event.preventDefault(); + prevX = event.touches[0].clientX; +}); + +function handleTouchEnd() { + knobBehavior.pressed = false; +} + +canvas.addEventListener('touchend', handleTouchEnd); +canvas.addEventListener('touchcancel', handleTouchEnd); + +canvas.addEventListener('touchmove', (event) => { + if (!knobBehavior.pressed) return; + event.preventDefault(); + const x = event.touches[0].clientX; + knobBehavior.progress += ((x - prevX) / canvas.clientHeight) * 2; + prevX = x; +}); + +canvas.addEventListener('mousedown', (event) => { + knobBehavior.pressed = true; + event.preventDefault(); + prevX = event.clientX; +}); + +canvas.addEventListener('mouseup', (event) => { + knobBehavior.pressed = false; + event.stopPropagation(); +}); + +function handleMouseUp() { + knobBehavior.pressed = false; +} + +window.addEventListener('mouseup', handleMouseUp); + +canvas.addEventListener('mousemove', (event) => { + if (!knobBehavior.pressed) return; + event.preventDefault(); + const x = event.clientX; + knobBehavior.progress += ((x - prevX) / canvas.clientHeight) * 2; + prevX = x; +}); + +let disposed = false; +// Incremented on every quality change, so late Auto benchmarks can tell they were superseded. +let qualityRequestId = 0; + +async function autoSetQuality(isCancelled: () => boolean) { + 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++) { + if (isCancelled()) break; + + const testTexture = root + .createTexture({ + size: [canvas.width * resolutionScale, canvas.height * resolutionScale], + format: 'rgba16float', + }) + .$usage('render'); + + measurePipeline + .withColorAttachment({ + view: testTexture, + loadOp: 'clear', + storeOp: 'store', + }) + .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 = defineControls({ + 'Enable Phone Motion': { + onButtonClick: () => phoneMotion.enable(), + }, + 'Dark Mode': { + initial: false, + onToggleChange: (dark) => { + darkModeUniform.write(d.u32(dark)); + lightUniform.patch({ direction: dark ? LIGHT_DIR : LIGHT_MODE_LIGHT_DIR }); + // Discard the previous lighting setup's TAA history without reallocating textures. + frameCount = 0; + }, + }, + Quality: { + initial: 'Auto', + options: ['Auto', 'Very Low', 'Low', 'Medium', 'High', 'Ultra'], + onSelectChange: (value) => { + const requestId = ++qualityRequestId; + if (value === 'Auto') { + const isCancelled = () => disposed || requestId !== qualityRequestId; + void autoSetQuality(isCancelled).then((scale) => { + if (isCancelled()) return; + 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(); + }, + }, + 'Jelly Color': { + initial: DEFAULT_JELLY_COLOR, + onColorChange: (c) => { + jellyColorUniform.write(d.vec4f(c, 1)); + }, + }, +}); + +export function onCleanup() { + disposed = true; + cancelAnimationFrame(animationFrameHandle); + resizeObserver.disconnect(); + phoneMotion.destroy(); + window.removeEventListener('mouseup', handleMouseUp); + destroyRenderTextures(); + taaResolver.destroy(); + root.destroy(); +} + +// #endregion diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/knob.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/knob.ts new file mode 100644 index 0000000000..80437c3a11 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/knob.ts @@ -0,0 +1,50 @@ +import { d, std, type TgpuRoot, type TgpuUniform } from 'typegpu'; +import { twistProperties } from './constants.ts'; +import { KnobState } from './dataTypes.ts'; +import { Spring, Spring3D } from './spring.ts'; + +export class KnobBehavior { + stateUniform: TgpuUniform; + + // State + pressed = false; + motionAcceleration = d.vec3f(); + #topSpring = new Spring3D(); + + // Derived physical state + #progress: number; + #twistSpring: Spring; + + constructor(root: TgpuRoot) { + this.#progress = 0; + this.#twistSpring = new Spring(twistProperties); + + this.stateUniform = root.createUniform(KnobState); + } + + get progress(): number { + return this.#progress; + } + + set progress(value: number) { + this.#progress = std.saturate(value); + } + + update(dt: number) { + if (dt <= 0) return; + + this.#topSpring.update(dt, this.motionAcceleration); + this.#twistSpring.target = this.#progress; + this.#twistSpring.update(dt); + + this.#updateGPUBuffer(); + } + + #updateGPUBuffer() { + this.stateUniform.write({ + topDisplacement: this.#topSpring.displacement, + topProgress: this.#progress, + bottomProgress: this.#twistSpring.value, + }); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/material.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/material.ts new file mode 100644 index 0000000000..009d2a2b88 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/material.ts @@ -0,0 +1,17 @@ +import { d, std } from 'typegpu'; + +export function bottomReflectionMask(position: d.v3f, normal: d.v3f, camera: d.v3f) { + 'use gpu'; + // Restrict the dark reflection to the low, downward-curving edge facing the camera. + const towardCamera = std.normalize(camera.xz - position.xz); + const front = std.smoothstep(0.45, 0.8, std.dot(normal.xz, towardCamera)); + const underside = std.smoothstep(0.03, 0.23, -normal.y); + const bottom = 1 - std.smoothstep(0.015, 0.09, position.y); + return front * underside * bottom; +} + +export function jellyReflection(position: d.v3f, normal: d.v3f, camera: d.v3f) { + 'use gpu'; + const original = d.vec3f(std.saturate(position.y + 0.2)); + return std.mix(original, d.vec3f(0.002), bottomReflectionMask(position, normal, camera)); +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/meta.json b/apps/typegpu-docs/src/examples/rendering/jelly-knob/meta.json new file mode 100644 index 0000000000..1e2494db09 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Jelly Knob", + "description": "Builds a tactile rotary control from ray-marched SDF geometry, with springy jelly deformation and a frosted-glass LED progress ring.", + "category": "rendering", + "tags": ["experimental"], + "coolFactor": 9.9 +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/motion.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/motion.ts new file mode 100644 index 0000000000..a8f1b06227 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/motion.ts @@ -0,0 +1,108 @@ +import { d } from 'typegpu'; +import { CAMERA_POSITION } from './constants.ts'; + +const finite = (value: number | null | undefined) => + typeof value === 'number' && Number.isFinite(value) ? value : 0; + +/** Phone axes -> screen axes -> the fixed camera's world-space basis. */ +export function motionToWorld(x: number, y: number, z: number, angle: number) { + const c = Math.cos(angle); + const s = Math.sin(angle); + const screenX = c * x + s * y; + const screenY = -s * x + c * y; + // The camera sits in the YZ plane, so its basis only depends on these two coordinates. + const { y: cy, z: cz } = CAMERA_POSITION; + const length = Math.hypot(cy, cz); + return d.vec3f(screenX, (cz * screenY + cy * z) / length, (-cy * screenY + cz * z) / length); +} + +export class PhoneMotion { + acceleration = d.vec3f(); + #gravity: number[] | undefined; + #lastSample = -Infinity; + #listening = false; + #disposed = false; + + constructor() { + // Browsers without a permission API can start immediately. + if (typeof DeviceMotionEvent !== 'undefined' && !this.#motionAPI()?.requestPermission) { + this.#listen(); + } + } + + #motionAPI() { + return typeof DeviceMotionEvent === 'undefined' + ? undefined + : (DeviceMotionEvent as typeof DeviceMotionEvent & { + requestPermission?: () => Promise; + }); + } + + async enable() { + if (this.#disposed || this.#listening) return; + const api = this.#motionAPI(); + if (!api) return; + try { + if (api.requestPermission && (await api.requestPermission()) !== 'granted') return; + if (!this.#disposed) this.#listen(); + } catch (error) { + console.warn('Phone motion could not be enabled.', error); + } + } + + #listen() { + this.#listening = true; + window.addEventListener('devicemotion', this.#onMotion); + } + + #onMotion = (event: DeviceMotionEvent) => { + const now = performance.now(); + const dt = Math.min(Math.max((now - this.#lastSample) / 1000, 1 / 240), 0.1); + const raw = event.accelerationIncludingGravity; + let linear = [event.acceleration?.x, event.acceleration?.y, event.acceleration?.z]; + if (!linear.every((v) => typeof v === 'number' && Number.isFinite(v))) { + const sample = [finite(raw?.x), finite(raw?.y), finite(raw?.z)]; + if (!this.#gravity || now - this.#lastSample > 250) this.#gravity = [...sample]; + const blend = 1 - Math.exp(-dt / 0.35); + const gravity = this.#gravity; + linear = sample.map((value, axis) => { + gravity[axis] += (value - gravity[axis]) * blend; + return value - gravity[axis]; + }); + } + const angle = ((screen.orientation?.angle ?? 0) * Math.PI) / 180; + const acceleration = motionToWorld( + finite(linear[0]), + finite(linear[1]), + finite(linear[2]), + angle, + ); + // rotationRate uses alpha, beta, gamma for X, Y, Z (unlike orientation angles). + const rate = event.rotationRate; + const radians = Math.PI / 180; + const omega = motionToWorld( + finite(rate?.alpha) * radians, + finite(rate?.beta) * radians, + finite(rate?.gamma) * radians, + angle, + ); + // Translation inertia plus rotational drag at the imaginary top point (0, .5, 0). + this.acceleration.x = -acceleration.x * 0.7 + omega.z * 3; + this.acceleration.y = -acceleration.y * 0.7; + this.acceleration.z = -acceleration.z * 0.7 - omega.x * 3; + const scale = Math.min(1, 18 / Math.hypot(...this.acceleration)); + for (let axis = 0; axis < 3; axis++) this.acceleration[axis] *= scale; + this.#lastSample = now; + }; + + update() { + if (performance.now() - this.#lastSample > 250) + this.acceleration.x = this.acceleration.y = this.acceleration.z = 0; + } + + destroy() { + this.#disposed = true; + window.removeEventListener('devicemotion', this.#onMotion); + this.acceleration.x = this.acceleration.y = this.acceleration.z = 0; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/raymarchers.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/raymarchers.ts new file mode 100644 index 0000000000..1a3eb21ff0 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/raymarchers.ts @@ -0,0 +1,496 @@ +import { tgpu, d, std } from 'typegpu'; +import { perlin3d } from '@typegpu/noise'; +import { linearToSrgb } from '@typegpu/color'; +import { + cameraUniformSlot, + darkModeUniformSlot, + jellyColorUniformSlot, + knobBehaviorSlot, + lightUniformSlot, + ObjectType, + Ray, + RayMarchResult, +} from './dataTypes.ts'; +import { + getJellyBounds, + dialShadow, + getSceneDist, + sdBackground, + sdFloorCutout, + sdJelly, + sdMeter, +} from './sdfs.ts'; +import { + AMBIENT_COLOR, + AMBIENT_INTENSITY, + AO_BIAS, + AO_INTENSITY, + AO_RADIUS, + AO_STEPS, + EXPOSURE, + GROUND_ALBEDO, + GroundParams, + JELLY_IOR, + JELLY_SCATTER_STRENGTH, + LIGHT_GROUND_ALBEDO, + MAX_DIST, + MAX_STEPS, + METER_TICKS, + SPECULAR_INTENSITY, + SPECULAR_POWER, + SURF_DIST, +} from './constants.ts'; +import { beerLambert, fresnelSchlick, intersectBox, rotateY } from './utils.ts'; +import { bottomReflectionMask, jellyReflection } from './material.ts'; + +function getRay(ndc: d.v2f) { + 'use gpu'; + const clipPos = d.vec4f(ndc.x, ndc.y, -1.0, 1.0); + + const invView = cameraUniformSlot.$.viewInv; + const invProj = cameraUniformSlot.$.projInv; + + const viewPos = invProj * clipPos; + const viewPosNormalized = d.vec4f(viewPos.xyz / viewPos.w, 1.0); + + const worldPos = invView * viewPosNormalized; + + const rayOrigin = invView.columns[3].xyz; + const rayDir = std.normalize(worldPos.xyz - rayOrigin); + + return Ray({ + origin: rayOrigin, + direction: rayDir, + }); +} + +function getSceneDistForAO(position: d.v3f) { + 'use gpu'; + const mainScene = sdBackground(position); + const jelly = sdJelly(position); + return std.min(mainScene, jelly); +} + +function getApproxNormal(position: d.v3f, epsilon: number): d.v3f { + 'use gpu'; + const k = d.vec3f(1, -1, 0); + + const offset1 = k.xyy * epsilon; + const offset2 = k.yyx * epsilon; + const offset3 = k.yxy * epsilon; + const offset4 = k.xxx * epsilon; + + const sample1 = offset1 * getSceneDist(position + offset1).distance; + const sample2 = offset2 * getSceneDist(position + offset2).distance; + const sample3 = offset3 * getSceneDist(position + offset3).distance; + const sample4 = offset4 * getSceneDist(position + offset4).distance; + + const gradient = sample1 + sample2 + sample3 + sample4; + + return std.normalize(gradient); +} + +function sqLength(a: d.v2f | d.v3f) { + 'use gpu'; + return std.dot(a, a); +} + +function getFakeShadow(position: d.v3f, lightDir: d.v3f): d.v3f { + 'use gpu'; + if (position.y < -GroundParams.groundThickness) { + // Applying darkening under the ground (the shadow cast by the upper ground layer) + const fadeSharpness = d.f32(30); + const inset = 0.02; + const cutout = sdFloorCutout(position.xz) + inset; + const edgeDarkening = std.saturate(1 - cutout * fadeSharpness); + + // Applying a slight gradient based on the light direction + const lightGradient = std.saturate(-position.z * 4 * lightDir.z + 1); + + return d.vec3f(1) * edgeDarkening * (lightGradient * 0.5); + } + + return d.vec3f(1); +} + +function 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 (const i of std.range(1, AO_STEPS + 1)) { + const sampleHeight = stepDistance * d.f32(i); + const samplePosition = position + normal * 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); +} + +function calculateLighting(hitPosition: d.v3f, normal: d.v3f, rayOrigin: d.v3f) { + 'use gpu'; + const lightDir = std.neg(lightUniformSlot.$.direction); + + const fakeShadow = getFakeShadow(hitPosition, lightDir); + const diffuse = std.max(std.dot(normal, lightDir), 0.0); + + const viewDir = std.normalize(rayOrigin - hitPosition); + const reflectDir = std.reflect(std.neg(lightDir), normal); + const dark = darkModeUniformSlot.$ === 1; + const specularFactor = + std.max(std.dot(viewDir, reflectDir), 0) ** std.select(d.f32(96), SPECULAR_POWER, dark); + const specular = + lightUniformSlot.$.color * (specularFactor * std.select(1.2, SPECULAR_INTENSITY, dark)); + + const baseColor = d.vec3f(0.9); + + const directionalLight = baseColor * lightUniformSlot.$.color * diffuse * fakeShadow; + const ambientLight = baseColor * AMBIENT_COLOR * AMBIENT_INTENSITY; + + const finalSpecular = specular * fakeShadow; + + return directionalLight + ambientLight + finalSpecular; +} + +function applyAO(litColor: d.v3f, hitPosition: d.v3f, normal: d.v3f) { + 'use gpu'; + const ao = calculateAO(hitPosition, normal); + const finalColor = litColor * std.pow(ao, std.select(2.5, 1, darkModeUniformSlot.$ === 1)); + return d.vec4f(finalColor, 1.0); +} + +function rayMarchScene(rayOrigin: d.v3f, rayDirection: d.v3f, maxSteps: number) { + 'use gpu'; + let distanceFromOrigin = d.f32(); + let point = d.vec3f(); + + for (let i = 0; i < maxSteps; i++) { + point = rayOrigin + rayDirection * distanceFromOrigin; + const hit = std.min(sdBackground(point), sdMeter(point)); + distanceFromOrigin += hit; + if (distanceFromOrigin > MAX_DIST || hit < SURF_DIST) { + break; + } + } + + let color = d.vec3f(); + if (distanceFromOrigin < MAX_DIST) { + color = std.select( + renderBackground(rayOrigin, rayDirection, distanceFromOrigin), + renderMeter(rayOrigin, rayDirection, distanceFromOrigin), + sdMeter(point) < SURF_DIST, + ).xyz; + } + + return RayMarchResult({ + color, + point, + }); +} + +function getTickDist(p: d.v3f, tick: number) { + 'use gpu'; + const angle = (tick / (METER_TICKS - 1)) * Math.PI; + const origin = + d.vec3f(-std.cos(angle), 0, -std.sin(angle)) * GroundParams.meterCutoutRadius + + d.vec3f(0, -0.1, 0); + + return std.length(p - origin); +} + +// One bounded reflection of the jelly, without recursively shading the ground. +function reflectJelly(position: d.v3f, direction: d.v3f) { + 'use gpu'; + const bounds = intersectBox(position, direction, getJellyBounds()); + if (!bounds.hit) return d.vec4f(); + let distance = std.max(bounds.tMin, SURF_DIST * 4); + for (let i = 0; i < 32; i++) { + const point = position + direction * distance; + const step = sdJelly(point); + if (step < SURF_DIST * 2) { + const normal = getApproxNormal(point, 1e-4); + const diffuse = std.max(std.dot(normal, std.neg(lightUniformSlot.$.direction)), 0); + const rim = (1 - std.saturate(std.dot(normal, std.neg(direction)))) ** 3; + const bottom = bottomReflectionMask( + point, + normal, + cameraUniformSlot.$.viewInv.columns[3].xyz, + ); + const color = + jellyColorUniformSlot.$.xyz * (0.16 + diffuse * 0.35) + d.vec3f(rim * 0.2 * (1 - bottom)); + return d.vec4f(color, 1); + } + distance += std.max(step, SURF_DIST); + if (distance > bounds.tMax) break; + } + return d.vec4f(); +} + +function groundCaustics(position: d.v3f) { + 'use gpu'; + // Project a scalloped focal pattern along the light, following the twisting lobes. + // This is an artistic approximation of focused transmission, not photon tracing. + const light = lightUniformSlot.$.direction; + const projectedDirection = light.xz / -light.y; + // Keep the stylized focal pattern close to the knob under grazing light. + const projection = projectedDirection / std.max(1, std.length(projectedDirection) / 1.2); + const p = (position.xz - projection * 0.34) / d.vec2f(1, 1.18); + const radius = std.length(p); + const angle = std.atan2(p.y, p.x); + const state = knobBehaviorSlot.$.stateUniform.$; + const phase = state.bottomProgress * Math.PI; + const fold = std.cos((angle + phase) * 12); + const distortion = std.sin(angle * 5 - phase) * 0.018; + const outer = (radius - (0.24 + fold * 0.015 + distortion)) / 0.012; + const inner = (radius - (0.18 - fold * 0.012)) / 0.019; + const focus = std.exp(-outer * outer) + std.exp(-inner * inner) * 0.45; + const breakup = std.smoothstep( + -0.15, + 0.65, + std.sin(angle * 3 + phase) * std.cos(angle * 5 - phase), + ); + const tint = std.mix(jellyColorUniformSlot.$.xyz, d.vec3f(1), 0.02); + return tint * focus * breakup * 0.35; +} + +function renderBackground(rayOrigin: d.v3f, rayDirection: d.v3f, backgroundHitDist: number) { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const hitPosition = rayOrigin + rayDirection * backgroundHitDist; + + const newNormal = getApproxNormal(hitPosition, 1e-4); + + // Calculate fake bounce lighting + const jellyColor = jellyColorUniformSlot.$; + const sqDist = sqLength(hitPosition); + // Keep the colored spill local so the outer ground retains its blue-gray tint. + const bounceLight = jellyColor.xyz * ((1 / (sqDist * 15 + 1)) ** 2 * 0.4); + const sideBounceLight = + jellyColor.xyz * ((1 / (sqDist * 40 + 1)) ** 2 * 0.3) * std.abs(newNormal.z); + const dark = darkModeUniformSlot.$ === 1; + const emission = (1 + d.f32(state.topProgress) * 2) * std.select(0.12, 1, dark); + + const litColor = calculateLighting(hitPosition, newNormal, rayOrigin); + const albedo = std.select(LIGHT_GROUND_ALBEDO, GROUND_ALBEDO, dark); + // Project the knob's soft shadow along the incoming light, toward the camera. + let contactShadow = d.vec3f(1); + if (!dark && hitPosition.y < 0.02) { + const light = lightUniformSlot.$.direction; + const projection = light.xz / -light.y; + const shadowPosition = (hitPosition.xz - projection * 0.34) / d.vec2f(0.36, 0.48); + const contact = std.exp(-sqLength(hitPosition.xz / d.vec2f(0.35)) * 2); + const castShadow = std.exp(-sqLength(shadowPosition) * 1.6); + // Preserve the neutral contact shadow; tint only the light transmitted through jelly. + const transmitted = jellyColor.xyz * 0.12 + 0.35; + contactShadow = std.mix(d.vec3f(1), transmitted, castShadow) - 0.2 * contact; + } + + let meterLight = d.vec3f(0); + const litTickCount = d.i32(std.floor(METER_TICKS * state.topProgress)); + for (let i = 0; i < litTickCount; i++) { + const tickDist = getTickDist(hitPosition, i); + meterLight = meterLight + d.vec3f(0.2 / (1 + (tickDist * 30) ** 2)); + } + + let backgroundColor = + applyAO(albedo * litColor * contactShadow, hitPosition, newNormal) + + d.vec4f(bounceLight * emission, 0) + + d.vec4f(sideBounceLight * emission, 0) + + d.vec4f(meterLight * std.select(0.15, 1, dark), 0); + + if (!dark && hitPosition.y < 0.02 && newNormal.y > 0.95) { + const reflected = reflectJelly( + hitPosition + newNormal * SURF_DIST * 4, + std.reflect(rayDirection, newNormal), + ); + const fresnel = + 0.22 + 0.35 * (1 - std.saturate(std.dot(std.neg(rayDirection), newNormal))) ** 5; + backgroundColor = d.vec4f( + std.mix(backgroundColor.xyz, reflected.xyz, reflected.w * fresnel), + 1, + ); + backgroundColor += d.vec4f(groundCaustics(hitPosition), 0); + } + + return d.vec4f(backgroundColor.xyz, 1); +} + +function getMeterLedLight(position: d.v3f) { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const dark = darkModeUniformSlot.$ === 1; + // Colored emitters stay legible in daylight against the smoked glass channel. + const ledColor = std.select( + std.mix(d.vec3f(0.12), jellyColorUniformSlot.$.xyz, 0.88), + d.vec3f(0.95, 0.97, 1), + dark, + ); + let light = d.vec3f(0); + + for (const tick of tgpu.unroll(std.range(METER_TICKS))) { + const angle = (tick / (METER_TICKS - 1)) * Math.PI; + const ledPosition = d.vec2f(-std.cos(angle), -std.sin(angle)) * GroundParams.meterCutoutRadius; + const distanceToLed = std.distance(position.xz, ledPosition); + const core = 1 - std.smoothstep(0.006, 0.022, distanceToLed); + const localGlow = std.exp(-distanceToLed * distanceToLed * 420); + const wideGlow = std.exp(-distanceToLed * distanceToLed * 130); + const isLit = state.topProgress * METER_TICKS >= tick + 1; + const intensity = std.select(0.035, 1, isLit); + + const emitter = ledColor * intensity * (core * 2.2 + localGlow * 0.65 + wideGlow * 0.2); + light += std.select(d.vec3f(0.08 * core), emitter, dark || isLit); + } + + return light; +} + +function renderMeter(rayOrigin: d.v3f, rayDirection: d.v3f, distanceFromOrigin: number) { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const hitPosition = rayOrigin + rayDirection * distanceFromOrigin; + const viewDirection = std.normalize(rayOrigin - hitPosition); + const fresnel = (1 - std.saturate(viewDirection.y)) ** 3; + const frostNoise = perlin3d.sample(hitPosition * 38); + const frost = 0.82 + frostNoise * 0.08; + const jellyColor = jellyColorUniformSlot.$; + const jellyBounce = + jellyColor.xyz * ((1 / (sqLength(hitPosition) * 15 + 1)) * (0.24 + state.topProgress * 0.24)); + const dark = darkModeUniformSlot.$ === 1; + const glassTint = std.select(d.vec3f(0.18, 0.21, 0.26), d.vec3f(0.075, 0.09, 0.105), dark); + const jellyLightFilter = std.mix(d.vec3f(1), jellyColor.xyz, std.select(0.12, 0.55, dark)); + const glass = + glassTint * jellyLightFilter * frost + + jellyBounce * std.select(0.15, 1, dark) + + d.vec3f(fresnel * 0.32); + const ledLight = getMeterLedLight(hitPosition); + + return d.vec4f(glass + ledLight, 1); +} + +function rayMarch(rayOrigin: d.v3f, rayDirection: d.v3f) { + 'use gpu'; + // Render the background and meter before compositing the jelly. + const sceneResult = rayMarchScene(rayOrigin, rayDirection, MAX_STEPS); + const scene = d.vec4f(sceneResult.color, 1); + const sceneDist = std.distance(rayOrigin, sceneResult.point); + + const bbox = getJellyBounds(); + const intersection = intersectBox(rayOrigin, rayDirection, bbox); + + if (!intersection.hit) { + return scene; + } + + let distanceFromOrigin = std.max(d.f32(0.0), intersection.tMin); + + for (const _i of std.range(MAX_STEPS)) { + const currentPosition = rayOrigin + rayDirection * distanceFromOrigin; + + const hitInfo = getSceneDist(currentPosition); + distanceFromOrigin += hitInfo.distance; + + if (hitInfo.distance < SURF_DIST) { + const hitPosition = rayOrigin + rayDirection * distanceFromOrigin; + + if (!(hitInfo.objectType === ObjectType.JELLY)) { + break; + } + + const N = getApproxNormal(hitPosition, 1e-4); + 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 = jellyReflection(hitPosition, N, rayOrigin); + + 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(I * eta + N * (eta * cosi - std.sqrt(k))); + const p = hitPosition + refrDir * (SURF_DIST * 2.0); + const exitPos = p + refrDir * (SURF_DIST * 2.0); + + const env = rayMarchScene(exitPos, refrDir, 6).color; + const jellyColor = jellyColorUniformSlot.$; + + const scatterTint = jellyColor.xyz * 1.5; + const density = d.f32(20.0); + const absorb = (d.vec3f(1.0) - jellyColor.xyz) * density; + + const state = knobBehaviorSlot.$.stateUniform.$; + const rotPos = rotateY(hitPosition, -state.topProgress * Math.PI); + const progress = std.saturate(std.mix(1, 0.2, -rotPos.x * 5 + 1.5)); + // More absorption keeps the daylight jelly richly colored over a bright floor. + const absorptionProgress = std.select( + std.max(progress, 0.55), + progress, + darkModeUniformSlot.$ === 1, + ); + const T = beerLambert( + absorb * absorptionProgress ** 2, + std.select(0.14, 0.08, darkModeUniformSlot.$ === 1), + ); + + const lightDir = std.neg(lightUniformSlot.$.direction); + + const forward = std.max(0.0, std.dot(lightDir, refrDir)); + const scatter = scatterTint * (JELLY_SCATTER_STRENGTH * forward * progress ** 3); + refractedColor = env * T + scatter; + } + + let jelly = reflection * F + refractedColor * (1 - F); + if (darkModeUniformSlot.$ === 0) { + // Broad studio fill reveals the material even when forward scattering is weak. + const lightDir = std.neg(lightUniformSlot.$.direction); + const diffuse = std.max(std.dot(N, lightDir), 0); + const fill = jellyColorUniformSlot.$.xyz * (0.1 + diffuse * 0.2); + const halfVector = std.normalize(lightDir - I); + const highlight = std.max(std.dot(N, halfVector), 0) ** 80; + const bottom = bottomReflectionMask(hitPosition, N, rayOrigin); + jelly += fill + d.vec3f(highlight * 0.35 * (1 - bottom)); + jelly = jelly * dialShadow(hitPosition, knobBehaviorSlot.$.stateUniform.$.topProgress); + } + + return d.vec4f(jelly, 1.0); + } + + if (distanceFromOrigin > sceneDist) { + break; + } + } + + return scene; +} + +export const raymarchFn = tgpu.fragmentFn({ + in: { + uv: d.vec2f, + }, + out: d.vec4f, +})(({ uv }) => { + 'use gpu'; + + const ndc = d.vec2f(uv.x * 2 - 1, -(uv.y * 2 - 1)); + const ray = getRay(ndc); + + const color = rayMarch(ray.origin, ray.direction); + + // ACES filmic fit: a dark toe and a soft shoulder for the jelly and LED cores. + // https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/ + const exposed = + std.max(color.xyz, d.vec3f(0)) * std.select(1.1, EXPOSURE, darkModeUniformSlot.$ === 1); + const mapped = std.saturate( + (exposed * (exposed * 2.51 + 0.03)) / (exposed * (exposed * 2.43 + 0.59) + 0.14), + ); + // The intermediate textures store sRGB values; encode once before the display-space TAA. + return d.vec4f(linearToSrgb(mapped), 1); +}); diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/sdfs.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/sdfs.ts new file mode 100644 index 0000000000..35d264ff8c --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/sdfs.ts @@ -0,0 +1,197 @@ +import { d, std } from 'typegpu'; +import * as sdf from '@typegpu/sdf'; +import { GroundParams, JELLY_HALFSIZE } from './constants.ts'; +import { rotateY } from './utils.ts'; +import { BoundingBox, HitInfo, knobBehaviorSlot, ObjectType } from './dataTypes.ts'; + +// background sdfs + +function sdJellyCutout(position: d.v2f) { + 'use gpu'; + const groundRoundness = GroundParams.groundRoundness; + const groundRadius = GroundParams.jellyCutoutRadius; + + return sdf.sdDisk(position, groundRadius + groundRoundness); +} + +function sdMeterCutout(position: d.v2f) { + 'use gpu'; + const groundRoundness = GroundParams.groundRoundness; + const meterCutoutRadius = GroundParams.meterCutoutRadius; + const meterCutoutGirth = GroundParams.meterCutoutGirth; + const angle = Math.PI / 2; + + // sdArc's arc is centered on +Y, but the meter spans the -Z half of the floor (-Y here). + const arcPosition = d.vec2f(position.x, -position.y); + + return ( + sdf.sdArc(arcPosition, d.vec2f(std.sin(angle), std.cos(angle)), meterCutoutRadius) - + (meterCutoutGirth + groundRoundness) + ); +} + +export function sdFloorCutout(position: d.v2f) { + 'use gpu'; + const jellyCutoutDistance = sdJellyCutout(position); + const meterCutoutDistance = sdMeterCutout(position); + return sdf.opUnion(jellyCutoutDistance, meterCutoutDistance); +} + +function sdArrowHead(p: d.v3f) { + 'use gpu'; + return ( + sdf.sdRhombus3d( + p, + // shorter on one end, longer on the other + std.select(0.15, 0.05, p.x > 0), + 0.04, // width of the arrow head + 0.001, // thickness + std.smoothstep(-0.1, 0.1, p.x) * 0.02, + ) - 0.007 + ); +} + +/** Shared inverse deformation keeps the dial attached to the jelly's top. */ +export function jellyRestPosition(position: d.v3f, displacement: d.v3f) { + 'use gpu'; + // Inverse of p -> p + displacement * max(p.y / .5, 0). + const restHeight = std.max(position.y, 0) / (0.5 + displacement.y); + return std.sub(position, std.mul(displacement, restHeight)); +} + +function deformationStretch(displacement: d.v3f) { + 'use gpu'; + return 1 + std.length(displacement) / (0.5 + displacement.y); +} + +export function dialShadow(position: d.v3f, progress: number) { + 'use gpu'; + const displacement = knobBehaviorSlot.$.stateUniform.$.topDisplacement; + const gap = 0.5 + displacement.y - position.y; + if (gap <= 0) return 1; + // Reuse the dial silhouette so its overhead shadow rotates with the pointer. + const local = rotateY( + d.vec3f(position.x - displacement.x, 0, position.z - displacement.z), + -progress * Math.PI, + ); + const softness = 0.014 + gap * 0.2; + const coverage = 1 - std.smoothstep(0, softness, sdArrowHead(local)); + return 1 - coverage * 0.35 * std.smoothstep(0, 0.025, gap); +} + +export function sdBackground(position: d.v3f) { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const groundThickness = GroundParams.groundThickness; + const groundRoundness = GroundParams.groundRoundness; + + let dist = std.min( + sdf.sdPlane(position, d.vec3f(0, 1, 0), 0.1), // the plane underneath the jelly + sdf.opExtrudeY(position, -sdFloorCutout(position.xz), groundThickness - groundRoundness) - + groundRoundness, + ); + + // Axis + dist = std.min( + dist, + sdArrowHead( + rotateY( + jellyRestPosition(position, state.topDisplacement) - d.vec3f(0, 0.5, 0), + -state.topProgress * Math.PI, + ), + ) / deformationStretch(state.topDisplacement), + ); + + return dist; +} + +// meter sdfs + +export function sdMeter(position: d.v3f) { + 'use gpu'; + return sdf.opExtrudeY(position, sdMeterCutout(position.xz), 0); +} + +// jelly sdfs + +/** + * Returns a transformed position. + */ +function opCheapBend(p: d.v3f, k: number) { + 'use gpu'; + const c = std.cos(k * p.x); + const s = std.sin(k * p.x); + const m = d.mat2x2f(c, -s, s, c); + return d.vec3f(m * p.xy, p.z); +} + +/** + * Returns a transformed position. + */ +function opTwist(p: d.v3f, k: number): d.v3f { + 'use gpu'; + const c = std.cos(k * p.y); + const s = std.sin(k * p.y); + const m = d.mat2x2f(c, -s, s, c); + return d.vec3f(m * p.xz, p.y); +} + +function sdJellySegment(position: d.v3f) { + 'use gpu'; + return sdf.sdRoundedBox3d( + opCheapBend(opCheapBend(position, 0.8).zyx, 0.8).zyx, + JELLY_HALFSIZE - 0.1 / 2, + 0.1, + ); +} + +export function sdJelly(position: d.v3f) { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const origin = d.vec3f(0, 0.18, 0); + const twist = state.bottomProgress - state.topProgress; + const displacement = state.topDisplacement; + const undeformed = jellyRestPosition(position, displacement); + let localPos = rotateY(undeformed - origin, -(state.topProgress + twist * 0.5) * Math.PI); + localPos = opTwist(localPos, twist * 3).xzy; + const rotated1Pos = rotateY(localPos, Math.PI / 6); + const rotated2Pos = rotateY(localPos, Math.PI / 3); + + const distance = sdf.opSmoothUnion( + sdJellySegment(localPos), + sdf.opSmoothUnion(sdJellySegment(rotated1Pos), sdJellySegment(rotated2Pos), 0.01), + 0.01, + ); + // Bound the inverse warp's stretch so ray steps do not skip the bent surface. + return distance / deformationStretch(displacement); +} + +// sdf helpers + +export function getJellyBounds() { + 'use gpu'; + return BoundingBox({ + min: d.vec3f(-1, -1, -1), + max: d.vec3f(1, 1, 1), + }); +} + +export function getSceneDist(position: d.v3f) { + 'use gpu'; + const jelly = sdJelly(position); + const mainScene = sdBackground(position); + + const hitInfo = HitInfo(); + hitInfo.distance = 1e30; + + if (jelly < hitInfo.distance) { + hitInfo.distance = jelly; + hitInfo.objectType = ObjectType.JELLY; + } + if (mainScene < hitInfo.distance) { + hitInfo.distance = mainScene; + hitInfo.objectType = ObjectType.BACKGROUND; + } + + return hitInfo; +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/spring.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/spring.ts new file mode 100644 index 0000000000..5c4d8a18d4 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/spring.ts @@ -0,0 +1,71 @@ +import { d } from 'typegpu'; + +export type SpringProperties = { + mass: number; + stiffness: number; + damping: number; +}; + +export class Spring { + value: number; + target: number; + properties: SpringProperties; + + #velocity: number; + + constructor(properties: SpringProperties) { + this.target = 0; + this.value = this.target; + this.properties = { ...properties }; + + this.#velocity = 0; + } + + update(dt: number) { + if (dt <= 0 || !Number.isFinite(dt)) return; + + // Keep integration stable during slow frames while advancing the full timestep. + const steps = Math.ceil(dt * 120); + const stepDt = dt / steps; + for (let i = 0; i < steps; i++) { + const F_spring = -this.properties.stiffness * (this.value - this.target); + const F_damp = -this.properties.damping * this.#velocity; + const a = (F_spring + F_damp) / this.properties.mass; + this.#velocity = this.#velocity + a * stepDt; + this.value = this.value + this.#velocity * stepDt; + } + } +} + +/** One imaginary point, displaced from its rest position at the jelly's top. */ +export class Spring3D { + displacement = d.vec3f(); + velocity = d.vec3f(); + + update(dt: number, acceleration: d.v3f) { + if (dt <= 0 || !Number.isFinite(dt)) return; + const steps = Math.ceil(Math.min(dt, 0.1) * 120); + const h = Math.min(dt, 0.1) / steps; + for (let step = 0; step < steps; step++) { + for (let axis = 0; axis < 3; axis++) { + this.velocity[axis] += + (acceleration[axis] - 110 * this.displacement[axis] - 7 * this.velocity[axis]) * h; + this.displacement[axis] += this.velocity[axis] * h; + } + // Keep the deformation invertible and within the ray-marching bounds. + const length = Math.hypot(...this.displacement); + if (length > 0.16) { + for (let axis = 0; axis < 3; axis++) this.displacement[axis] *= 0.16 / length; + const outward = + this.velocity.x * this.displacement.x + + this.velocity.y * this.displacement.y + + this.velocity.z * this.displacement.z; + if (outward > 0) { + for (let axis = 0; axis < 3; axis++) { + this.velocity[axis] -= (outward * this.displacement[axis]) / (0.16 * 0.16); + } + } + } + } + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/taa.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/taa.ts new file mode 100644 index 0000000000..302b950e66 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/taa.ts @@ -0,0 +1,122 @@ +import { + tgpu, + d, + std, + type TgpuComputePipeline, + type TgpuRoot, + type TgpuTextureView, +} from 'typegpu'; +import { taaResolveLayout } from './dataTypes.ts'; + +export const taaResolveFn = tgpu.computeFn({ + workgroupSize: [16, 16], + in: { + gid: d.builtin.globalInvocationId, + }, +})(({ gid }) => { + 'use gpu'; + 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 (const x of tgpu.unroll([-1, 0, 1])) { + for (const y of tgpu.unroll([-1, 0, 1])) { + const sampleCoord = d.vec2i(gid.xy) + d.vec2i(x, y); + const clampedCoord = std.clamp( + sampleCoord, + d.vec2i(0, 0), + d.vec2i(dimensions.xy) - 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 blendFactor = d.f32(0.9); + + 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 + .createTexture({ + size: [width, height], + format: 'rgba16float', + }) + .$usage('storage', 'sampled'); + + return { + texture, + write: texture.createView(d.textureStorage2d('rgba16float')), + 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.createComputePipeline({ compute: taaResolveFn }); + + 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.destroy(); + this.#width = width; + this.#height = height; + this.#textures = createTaaTextures(this.#root, width, height); + } + + getResolvedTexture(frame: number) { + return this.#textures[frame].sampled; + } + + destroy() { + for (const { texture } of this.#textures) { + texture.destroy(); + } + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/jelly-knob/thumbnail.png new file mode 100644 index 0000000000..3f978873f7 Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/jelly-knob/thumbnail.png differ diff --git a/apps/typegpu-docs/src/examples/rendering/jelly-knob/utils.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/utils.ts new file mode 100644 index 0000000000..7ee7aa7549 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/utils.ts @@ -0,0 +1,60 @@ +import { d, std, type TgpuRoot } from 'typegpu'; +import { type BoundingBox, BoxIntersection } from './dataTypes.ts'; + +export function 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 function beerLambert(sigma: d.v3f, dist: number) { + 'use gpu'; + return std.exp(sigma * -dist); +} + +export function intersectBox(rayOrigin: d.v3f, rayDirection: d.v3f, box: BoundingBox) { + 'use gpu'; + const invDir = d.vec3f(1.0) / rayDirection; + + const t1 = (box.min - rayOrigin) * invDir; + const t2 = (box.max - rayOrigin) * 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; +} + +/** + * Source: https://mini.gmshaders.com/p/3d-rotation + */ +export function rotateY(p: d.v3f, angle: number) { + 'use gpu'; + return ( + std.mix(d.vec3f(0, p.y, 0), p, std.cos(angle)) + std.cross(p, d.vec3f(0, 1, 0)) * std.sin(angle) + ); +} + +export function createTextures(root: TgpuRoot, width: number, height: number) { + return [0, 1].map(() => { + const texture = root + .createTexture({ + size: [width, height], + format: 'rgba16float', + }) + .$usage('sampled', 'render'); + + return { + texture, + sampled: texture.createView(), + }; + }); +} diff --git a/apps/typegpu-docs/tests/individual-example-tests/jelly-knob-lifecycle.test.ts b/apps/typegpu-docs/tests/individual-example-tests/jelly-knob-lifecycle.test.ts new file mode 100644 index 0000000000..33c230e0fa --- /dev/null +++ b/apps/typegpu-docs/tests/individual-example-tests/jelly-knob-lifecycle.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, vi } from 'vitest'; +import { it } from 'typegpu-testing-utility'; +import { Spring } from '../../src/examples/rendering/jelly-knob/spring.ts'; +import { setupCommonMocks, mockResizeObserver } from './utils/commonMocks.ts'; + +const flushTasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('jelly knob spring', () => { + it('converges at both normal and slow frame rates', () => { + for (const dt of [1 / 120, 1 / 60, 0.1]) { + const spring = new Spring({ mass: 4, stiffness: 700, damping: 50 }); + spring.target = 1; + for (let time = 0; time < 10; time += dt) { + spring.update(dt); + expect(Math.abs(spring.value)).toBeLessThan(2); + } + expect(spring.value).toBeCloseTo(1, 5); + } + }); + + it('advances the full elapsed time across a slow frame', () => { + const slow = new Spring({ mass: 4, stiffness: 700, damping: 50 }); + const fast = new Spring(slow.properties); + slow.target = fast.target = 1; + slow.update(0.1); + for (let i = 0; i < 12; i++) fast.update(1 / 120); + expect(slow.value).toBeCloseTo(fast.value, 10); + }); +}); + +describe('jelly knob resource lifecycle', () => { + setupCommonMocks(); + + it('releases textures on quality changes and removes its global listener on cleanup', async ({ + device, + }) => { + mockResizeObserver(); + const requestFrame = vi.fn<(callback: FrameRequestCallback) => number>(() => 1); + vi.stubGlobal('requestAnimationFrame', requestFrame); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + const addListener = vi.spyOn(window, 'addEventListener'); + const removeListener = vi.spyOn(window, 'removeEventListener'); + document.body.innerHTML = ''; + + const example = await import('../../src/examples/rendering/jelly-knob/index.ts'); + const renderFrame = () => requestFrame.mock.calls.at(-1)?.[0](performance.now()); + const createdTextures = () => + device.mock.createTexture.mock.results.map((result) => result.value); + + try { + renderFrame(); + const textureCount = createdTextures().length; + const shaderCount = device.mock.createShaderModule.mock.calls.length; + const darkMode = example.controls['Dark Mode']; + for (const dark of [true, false]) { + if (darkMode && 'onToggleChange' in darkMode) darkMode.onToggleChange(dark); + renderFrame(); + expect(createdTextures()).toHaveLength(textureCount); + expect(device.mock.createShaderModule).toHaveBeenCalledTimes(shaderCount); + } + for (const quality of ['Low', 'Ultra'] as const) { + const previousTextures = createdTextures(); + expect(previousTextures.length).toBeGreaterThan(0); + const control = example.controls.Quality; + if (control && 'onSelectChange' in control) control.onSelectChange(quality); + for (const texture of previousTextures) { + expect(texture.destroy).toHaveBeenCalledOnce(); + } + renderFrame(); + } + + // A superseded Auto benchmark stops early and does not apply its result. + const quality = example.controls.Quality; + if (quality && 'onSelectChange' in quality) { + quality.onSelectChange('Auto'); + quality.onSelectChange('Ultra'); + const textureCount = createdTextures().length; + await flushTasks(); + expect(createdTextures()).toHaveLength(textureCount); + + // Leave an Auto benchmark in flight to be cancelled by cleanup. + quality.onSelectChange('Auto'); + } + } finally { + example.onCleanup(); + } + + const textureCountAtCleanup = createdTextures().length; + await flushTasks(); + expect(createdTextures()).toHaveLength(textureCountAtCleanup); + + for (const texture of createdTextures()) { + expect(texture.destroy).toHaveBeenCalledOnce(); + } + const mouseUp = addListener.mock.calls.find(([event]) => event === 'mouseup'); + expect(mouseUp).toBeDefined(); + expect(removeListener).toHaveBeenCalledWith('mouseup', mouseUp?.[1]); + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); +});