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 000000000..242d316ff --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/camera.ts @@ -0,0 +1,147 @@ +import type { TgpuRoot, TgpuUniform } from 'typegpu'; +import * as d from 'typegpu/data'; +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; + #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-knob/constants.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/constants.ts new file mode 100644 index 000000000..cdcc832fd --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/constants.ts @@ -0,0 +1,70 @@ +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import type { SpringProperties } from './spring.ts'; + +// Rendering constants +export const MAX_STEPS = 64; +export const MAX_DIST = 10; +export const SURF_DIST = 0.001; + +// Ground material constants +export const LIGHT_GROUND_ALBEDO = d.vec3f(1); +export const DARK_GROUND_ALBEDO = d.vec3f(0.2); + +export const GroundParams = { + groundThickness: 0.03, + groundRoundness: 0.02, + jellyCutoutRadius: 0.38, + meterCutoutRadius: 0.7, + meterCutoutGirth: 0.08, +}; + +// 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_MODE_LIGHT_DIR = std.normalize(d.vec3f(0.18, -0.30, 0.64)); +export const DARK_MODE_LIGHT_DIR = std.normalize(d.vec3f(-0.5, -0.14, -0.8)); + +// 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: 1, + stiffness: 700, + damping: 10, +}; + +export const wiggleXProperties: SpringProperties = { + mass: 1, + stiffness: 1000, + damping: 20, +}; + +export const wiggleZProperties: SpringProperties = { + mass: 1, + stiffness: 1000, + damping: 20, +}; + +// 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-knob/dataTypes.ts b/apps/typegpu-docs/src/examples/rendering/jelly-knob/dataTypes.ts new file mode 100644 index 000000000..e9b84e854 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/dataTypes.ts @@ -0,0 +1,81 @@ +import tgpu, { type TgpuUniform } from 'typegpu'; +import * as d from 'typegpu/data'; +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, + PROGRESS_METER: 2, + 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({ + topProgress: d.f32, + bottomProgress: d.f32, + time: 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(), + }, +}); + +export const knobBehaviorSlot = tgpu.slot(); +export const cameraUniformSlot = tgpu.slot>(); +export const lightUniformSlot = tgpu.slot< + TgpuUniform +>(); +export const jellyColorUniformSlot = tgpu.slot>(); +export const darkModeUniformSlot = tgpu.slot>(); +export const randomUniformSlot = tgpu.slot>(); +// shader uses this as time, but it advances faster the more the knob is turned +export const effectTimeUniformSlot = 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 000000000..c733673c4 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.html @@ -0,0 +1 @@ + \ No newline at end of file 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 000000000..dfa792f77 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/index.ts @@ -0,0 +1,350 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { fullScreenTriangle } from 'typegpu/common'; + +import { KnobBehavior } from './knob.ts'; +import { CameraController } from './camera.ts'; +import { + cameraUniformSlot, + darkModeUniformSlot, + DirectionalLight, + effectTimeUniformSlot, + jellyColorUniformSlot, + knobBehaviorSlot, + lightUniformSlot, + randomUniformSlot, + rayMarchLayout, + sampleLayout, +} from './dataTypes.ts'; +import { createBackgroundTexture, createTextures } from './utils.ts'; +import { TAAResolver } from './taa.ts'; +import { DARK_MODE_LIGHT_DIR, LIGHT_MODE_LIGHT_DIR } from './constants.ts'; +import { raymarchFn } from './raymarchers.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 knobBehavior = new KnobBehavior(root); +await knobBehavior.init(); + +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, 2.7, 0.8), + 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 darkModeUniform = root.createUniform(d.u32); + +const randomUniform = root.createUniform(d.vec2f); + +const effectTimeUniform = root.createUniform(d.f32); + +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'] + .with(knobBehaviorSlot, knobBehavior) + .with(cameraUniformSlot, cameraUniform) + .with(lightUniformSlot, lightUniform) + .with(jellyColorUniformSlot, jellyColorUniform) + .with(darkModeUniformSlot, darkModeUniform) + .with(randomUniformSlot, randomUniform) + .with(effectTimeUniformSlot, effectTimeUniform) + .withVertex(fullScreenTriangle, {}) + .withFragment(raymarchFn, { format: 'rgba8unorm' }) + .createPipeline(); + +const renderPipeline = root['~unstable'] + .withVertex(fullScreenTriangle, {}) + .withFragment(fragmentMain, { format: presentationFormat }) + .createPipeline(); + +let lastTimeStamp = performance.now(); +let effectTime = 0; +let frameCount = 0; +const taaResolver = new TAAResolver(root, width, height); + +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), + ); + effectTime += deltaTime * (5 ** (2 * knobBehavior.progress)); + effectTimeUniform.write(effectTime); + + knobBehavior.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 + +let prevX = 0; + +canvas.addEventListener('touchstart', (event) => { + knobBehavior.pressed = true; + event.preventDefault(); + prevX = event.touches[0].clientX; +}); + +canvas.addEventListener('touchend', (event) => { + knobBehavior.pressed = false; + knobBehavior.toggled = !knobBehavior.toggled; +}); + +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; + knobBehavior.toggled = !knobBehavior.toggled; + event.stopPropagation(); +}); + +window.addEventListener('mouseup', (event) => { + knobBehavior.pressed = false; +}); + +canvas.addEventListener('mousemove', (event) => { + if (!knobBehavior.pressed) return; + event.preventDefault(); + const x = event.clientX; + knobBehavior.progress += (x - prevX) / canvas.clientHeight * 2; + prevX = x; +}); + +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(); + }, + }, + 'Jelly Color': { + // initial: [0.63, 0.08, 1], + initial: [1.0, 0.35, 0.075], + onColorChange: (c: [number, number, number]) => { + jellyColorUniform.write(d.vec4f(...c, 1.0)); + }, + }, + 'Dark Mode': { + initial: true, + onToggleChange: (v: boolean) => { + darkModeUniform.write(d.u32(v)); + lightUniform.writePartial({ + direction: v ? DARK_MODE_LIGHT_DIR : LIGHT_MODE_LIGHT_DIR, + }); + }, + }, +}; + +export function onCleanup() { + resizeObserver.disconnect(); + 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 000000000..bd3718a30 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/knob.ts @@ -0,0 +1,95 @@ +import type { TgpuRoot, TgpuUniform } from 'typegpu'; +import * as std from 'typegpu/std'; +import { twistProperties } from './constants.ts'; +import { KnobState } from './dataTypes.ts'; +import { Spring } from './spring.ts'; + +export class KnobBehavior { + stateUniform: TgpuUniform; + + // State + toggled = false; + pressed = false; + + // Audio system + // #audioContext: AudioContext; + // #backgroundGainNode: GainNode; + // #backgroundSource: AudioBufferSourceNode | undefined; + // #switchOnBuffer: AudioBuffer | undefined; + // #switchOffBuffer: AudioBuffer | undefined; + + // Derived physical state + #progress: number; + #twistSpring: Spring; + + constructor(root: TgpuRoot) { + this.#progress = 0; + this.#twistSpring = new Spring(twistProperties); + + this.stateUniform = root.createUniform(KnobState); + + // Initialize audio system + // this.#audioContext = new AudioContext(); + // this.#backgroundGainNode = this.#audioContext.createGain(); + // this.#backgroundGainNode.connect(this.#audioContext.destination); + // this.#backgroundGainNode.gain.value = 0; + } + + get progress(): number { + return this.#progress; + } + + set progress(value: number) { + this.#progress = std.saturate(value); + } + + async init() { + // const [backgroundResponse, switchOnResponse, switchOffResponse] = + // await Promise.all([ + // fetch('/TypeGPU/assets/jelly-knob/drag-noise.ogg'), + // fetch('/TypeGPU/assets/jelly-knob/switch-on.ogg'), + // fetch('/TypeGPU/assets/jelly-knob/switch-off.ogg'), + // ]); + + // const [backgroundArrayBuffer, switchOnArrayBuffer, switchOffArrayBuffer] = + // await Promise.all([ + // backgroundResponse.arrayBuffer(), + // switchOnResponse.arrayBuffer(), + // switchOffResponse.arrayBuffer(), + // ]); + + // const [backgroundBuffer, switchOnBuffer, switchOffBuffer] = await Promise + // .all([ + // this.#audioContext.decodeAudioData(backgroundArrayBuffer), + // this.#audioContext.decodeAudioData(switchOnArrayBuffer), + // this.#audioContext.decodeAudioData(switchOffArrayBuffer), + // ]); + + // this.#switchOnBuffer = switchOnBuffer; + // this.#switchOffBuffer = switchOffBuffer; + + // const source = this.#audioContext.createBufferSource(); + // source.buffer = backgroundBuffer; + // source.loop = true; + // source.connect(this.#backgroundGainNode); + // source.start(); + // this.#backgroundSource = source; + } + + update(dt: number) { + if (dt <= 0) return; + + this.#twistSpring.target = this.#progress; + this.#twistSpring.update(dt); + + this.#updateGPUBuffer(); + } + + #updateGPUBuffer() { + this.stateUniform.write({ + topProgress: this.#progress, + bottomProgress: this.#twistSpring.value, + time: Date.now() / 1000 % 1000, + }); + } +} 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 000000000..57e014667 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Jelly Knob", + "category": "rendering", + "tags": ["experimental"] +} 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 000000000..e135ad7e0 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/raymarchers.ts @@ -0,0 +1,422 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { perlin3d, randf } from '@typegpu/noise'; +import { + cameraUniformSlot, + darkModeUniformSlot, + effectTimeUniformSlot, + jellyColorUniformSlot, + knobBehaviorSlot, + lightUniformSlot, + ObjectType, + randomUniformSlot, + Ray, + RayMarchResult, +} from './dataTypes.ts'; +import { + getJellyBounds, + getSceneDist, + sdBackground, + sdFloorCutout, + sdJelly, + sdMeter, +} from './sdfs.ts'; +import { + AMBIENT_COLOR, + AMBIENT_INTENSITY, + AO_BIAS, + AO_INTENSITY, + AO_RADIUS, + AO_STEPS, + DARK_GROUND_ALBEDO, + GroundParams, + JELLY_IOR, + JELLY_SCATTER_STRENGTH, + LIGHT_GROUND_ALBEDO, + MAX_DIST, + MAX_STEPS, + SPECULAR_INTENSITY, + SPECULAR_POWER, + SURF_DIST, +} from './constants.ts'; +import { beerLambert, fresnelSchlick, intersectBox, rotateY } from './utils.ts'; + +const 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.mul(clipPos); + const viewPosNormalized = d.vec4f(viewPos.xyz.div(viewPos.w), 1.0); + + const worldPos = invView.mul(viewPosNormalized); + + const rayOrigin = invView.columns[3].xyz; + const rayDir = std.normalize(worldPos.xyz.sub(rayOrigin)); + + return Ray({ + origin: rayOrigin, + direction: rayDir, + }); +}; + +const getSceneDistForAO = (position: d.v3f) => { + 'use gpu'; + const mainScene = sdBackground(position); + const jelly = sdJelly(position); + return std.min(mainScene, jelly); +}; + +const getApproxNormal = (position: d.v3f, epsilon: number): d.v3f => { + '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(getSceneDist(position.add(offset1)).distance); + const sample2 = offset2.mul(getSceneDist(position.add(offset2)).distance); + const sample3 = offset3.mul(getSceneDist(position.add(offset3)).distance); + const sample4 = offset4.mul(getSceneDist(position.add(offset4)).distance); + + const gradient = sample1.add(sample2).add(sample3).add(sample4); + + return std.normalize(gradient); +}; + +const sqLength = (a: d.v2f | d.v3f) => { + 'use gpu'; + return std.dot(a, a); +}; + +const 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 = 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).mul(edgeDarkening).mul(lightGradient * 0.5); + } + + return d.vec3f(1); +}; + +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(lightUniformSlot.$.direction); + + const fakeShadow = getFakeShadow(hitPosition, lightDir); + 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 = lightUniformSlot.$.color.mul( + specularFactor * SPECULAR_INTENSITY, + ); + + const baseColor = d.vec3f(0.9); + + const directionalLight = baseColor + .mul(lightUniformSlot.$.color) + .mul(diffuse) + .mul(fakeShadow); + const ambientLight = baseColor.mul(AMBIENT_COLOR).mul(AMBIENT_INTENSITY); + + const finalSpecular = specular.mul(fakeShadow); + + 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, + maxSteps: number, + uv: d.v2f, +) => { + 'use gpu'; + let distanceFromOrigin = d.f32(); + let point = d.vec3f(); + + for (let i = 0; i < maxSteps; i++) { + point = rayOrigin.add(rayDirection.mul(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) { + if (sdMeter(point) < SURF_DIST) { + color = renderMeter(rayOrigin, rayDirection, distanceFromOrigin, uv).xyz; + } else { + color = renderBackground(rayOrigin, rayDirection, distanceFromOrigin).xyz; + } + } + + return RayMarchResult({ + color, + point, + }); +}; + +const renderBackground = ( + rayOrigin: d.v3f, + rayDirection: d.v3f, + backgroundHitDist: number, +) => { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const hitPosition = rayOrigin.add(rayDirection.mul(backgroundHitDist)); + + let offsetX = d.f32(); + let offsetZ = d.f32(0.05); + + const lightDir = lightUniformSlot.$.direction; + const causticScale = 0.2; + offsetX -= lightDir.x * causticScale; + offsetZ += lightDir.z * causticScale; + + const newNormal = getApproxNormal(hitPosition, 1e-4); + + // Calculate fake bounce lighting + const switchX = 0; + const jellyColor = jellyColorUniformSlot.$; + const sqDist = sqLength(hitPosition.sub(d.vec3f(switchX, 0, 0))); + const bounceLight = jellyColor.xyz.mul(1 / (sqDist * 15 + 1) * 0.4); + const sideBounceLight = jellyColor.xyz + .mul(1 / (sqDist * 40 + 1) * 0.3) + .mul(std.abs(newNormal.z)); + const emission = 1 + d.f32(state.topProgress) * 2; + + const litColor = calculateLighting(hitPosition, newNormal, rayOrigin); + const albedo = std.select( + LIGHT_GROUND_ALBEDO, + DARK_GROUND_ALBEDO, + darkModeUniformSlot.$ === 1, + ); + + const backgroundColor = applyAO( + albedo.mul(litColor), + hitPosition, + newNormal, + ) + .add(d.vec4f(bounceLight.mul(emission), 0)) + .add(d.vec4f(sideBounceLight.mul(emission), 0)); + + return d.vec4f(backgroundColor.xyz, 1); +}; + +const caustics = (uv: d.v2f, time: number, profile: d.v3f): d.v3f => { + 'use gpu'; + const distortion = perlin3d.sample(d.vec3f(std.mul(uv, 0.5), time * 0.2)); + // Distorting UV coordinates + const uv2 = std.add(uv, distortion); + const noise = std.abs(perlin3d.sample(d.vec3f(std.mul(uv2, 5), time))); + return std.pow(d.vec3f(1 - noise), profile); +}; + +const renderMeter = ( + rayOrigin: d.v3f, + rayDirection: d.v3f, + backgroundHitDist: number, + uv: d.v2f, +) => { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const hitPosition = rayOrigin.add(rayDirection.mul(backgroundHitDist)); + const ambientColor = jellyColorUniformSlot.$.xyz; + + // caustics + const c1 = caustics(uv, effectTimeUniformSlot.$ * 0.2, d.vec3f(4, 4, 1)).mul( + 0.0001, + ); + const c2 = caustics( + uv.mul(2), + effectTimeUniformSlot.$ * 0.4, + d.vec3f(16, 1, 4), + ) + .mul(0.0001); + + const blendCoord = d.vec3f( + uv.mul(d.vec2f(5, 10)), + effectTimeUniformSlot.$ * 0.2 + 5, + ); + const blend = std.saturate(perlin3d.sample(blendCoord.mul(0.5))); + + const color = std.mix(ambientColor, std.add(c1, c2), blend); + + // make the color darker based on the progress + return d.vec4f(color.mul((1 + state.topProgress) / 3), 1); +}; + +const rayMarch = (rayOrigin: d.v3f, rayDirection: d.v3f, uv: d.v2f) => { + 'use gpu'; + // first, generate the scene without a jelly + const noJellyResult = rayMarchNoJelly(rayOrigin, rayDirection, MAX_STEPS, uv); + const scene = d.vec4f(noJellyResult.color, 1); + const sceneDist = std.distance(rayOrigin, noJellyResult.point); + + // second, generate the jelly + 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 (let i = 0; i < MAX_STEPS; i++) { + const currentPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin)); + + const hitInfo = getSceneDist(currentPosition); + distanceFromOrigin += hitInfo.distance; + + if (hitInfo.distance < SURF_DIST) { + const hitPosition = rayOrigin.add(rayDirection.mul(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 = 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, 6, uv).color; + const jellyColor = jellyColorUniformSlot.$; + + 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 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, + ), + ); + const T = beerLambert(absorb.mul(progress ** 2), 0.08); + + const lightDir = std.neg(lightUniformSlot.$.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 > sceneDist) { + break; + } + } + + return scene; +}; + +export const raymarchFn = tgpu['~unstable'].fragmentFn({ + in: { + uv: d.vec2f, + }, + out: d.vec4f, +})(({ uv }) => { + randf.seed2(randomUniformSlot.$.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, + ); + + const exposure = std.select(1.5, 3, darkModeUniformSlot.$ === 1); + return d.vec4f(std.tanh(std.pow(color.xyz.mul(exposure), d.vec3f(1.2))), 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 000000000..0d5b1f99c --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/sdfs.ts @@ -0,0 +1,198 @@ +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +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 + +const sdJellyCutout = (position: d.v2f) => { + 'use gpu'; + const groundRoundness = GroundParams.groundRoundness; + const groundRadius = GroundParams.jellyCutoutRadius; + + return sdf.sdDisk( + position, + groundRadius + groundRoundness, + ); +}; + +const sdMeterCutout = (position: d.v2f) => { + 'use gpu'; + const groundRoundness = GroundParams.groundRoundness; + const meterCutoutRadius = GroundParams.meterCutoutRadius; + const meterCutoutGirth = GroundParams.meterCutoutGirth; + const angle = Math.PI / 2; + + return sdf.sdArc( + position, + d.vec2f(std.sin(angle), std.cos(angle)), + meterCutoutRadius, + meterCutoutGirth + groundRoundness, + ); +}; + +export const sdFloorCutout = (position: d.v2f) => { + 'use gpu'; + const jellyCutoutDistance = sdJellyCutout(position); + const meterCutoutDistance = sdMeterCutout(position); + return sdf.opUnion(jellyCutoutDistance, meterCutoutDistance); +}; + +const sdArrowHead = (p: d.v3f) => { + 'use gpu'; + return sdf.sdRhombus( + 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; +}; + +export const 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( + position.sub(d.vec3f(0, 0.5, 0)), + -state.topProgress * Math.PI, + ), + ), + ); + + return dist; +}; + +// meter sdfs + +export const sdMeter = (position: d.v3f) => { + 'use gpu'; + const groundRoundness = GroundParams.groundRoundness; + const meterCutoutRadius = GroundParams.meterCutoutRadius; + const meterCutoutGirth = GroundParams.meterCutoutGirth; + const angle = Math.PI / 2 * knobBehaviorSlot.$.stateUniform.$.topProgress; + + const arc = sdf.sdArc( + rotateY(position, Math.PI / 2 - angle).xz, + d.vec2f(std.sin(angle), std.cos(angle)), + meterCutoutRadius, + meterCutoutGirth + groundRoundness, + ); + + return sdf.opExtrudeY(position, arc, 0); +}; + +// jelly sdfs + +/** + * Returns a transformed position. + */ +const 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.mul(p.xy), p.z); +}; + +/** + * Returns a transformed position. + */ +const 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.mul(p.xz), p.y); +}; + +const getJellySegment = (position: d.v3f) => { + 'use gpu'; + return sdf.sdRoundedBox3d( + opCheapBend(opCheapBend(position, 0.8).zyx, 0.8).zyx, + JELLY_HALFSIZE.sub(0.1 / 2), + 0.1, + ); +}; + +export const sdJelly = (position: d.v3f) => { + 'use gpu'; + const state = knobBehaviorSlot.$.stateUniform.$; + const origin = d.vec3f(0, 0.18, 0); + const twist = state.bottomProgress - state.topProgress; + let localPos = rotateY( + position.sub(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); + + return sdf.opSmoothUnion( + getJellySegment(localPos), + sdf.opSmoothUnion( + getJellySegment(rotated1Pos), + getJellySegment(rotated2Pos), + 0.01, + ), + 0.01, + ); +}; + +// sdf helpers + +export const getJellyBounds = () => { + 'use gpu'; + return BoundingBox({ + min: d.vec3f(-1, -1, -1), + max: d.vec3f(1, 1, 1), + }); +}; + +export const getSceneDist = (position: d.v3f) => { + 'use gpu'; + const jelly = sdJelly(position); + const meter = sdMeter(position); + const mainScene = sdBackground(position); + + const hitInfo = HitInfo(); + hitInfo.distance = 1e30; + + if (jelly < hitInfo.distance) { + hitInfo.distance = jelly; + hitInfo.objectType = ObjectType.JELLY; + } + if (meter < hitInfo.distance) { + hitInfo.distance = mainScene; + hitInfo.objectType = ObjectType.PROGRESS_METER; + } + 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 000000000..3d61292a8 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/spring.ts @@ -0,0 +1,29 @@ +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) { + 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 * dt; + this.value = this.value + this.#velocity * dt; + } +} 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 000000000..c5beeb4f5 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/taa.ts @@ -0,0 +1,135 @@ +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 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['~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-knob/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/jelly-knob/thumbnail.png new file mode 100644 index 000000000..a337cdc67 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 000000000..97292321d --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/jelly-knob/utils.ts @@ -0,0 +1,88 @@ +import type { TgpuRoot } from 'typegpu'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { type BoundingBox, BoxIntersection } from './dataTypes.ts'; + +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, + box: BoundingBox, +) => { + 'use gpu'; + const invDir = d.vec3f(1.0).div(rayDirection); + + const t1 = std.sub(box.min, rayOrigin).mul(invDir); + const t2 = std.sub(box.max, 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; +}; + +/** + * Source: https://mini.gmshaders.com/p/3d-rotation + */ +export const rotateY = (p: d.v3f, angle: number) => { + 'use gpu'; + return std.add( + std.mix(d.vec3f(0, p.y, 0), p, std.cos(angle)), + std.cross(p, d.vec3f(0, 1, 0)).mul(std.sin(angle)), + ); +}; + +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 88ff0bf2a..039eeb11d 100644 --- a/packages/typegpu-sdf/src/2d.ts +++ b/packages/typegpu-sdf/src/2d.ts @@ -159,3 +159,13 @@ export const sdPie = tgpu.fn([vec2f, vec2f, f32], f32)((p, c, 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)); }); + +export const sdArc = tgpu.fn([vec2f, vec2f, f32, f32], f32)( + (position, sc, radius, girth) => { + const pos = vec2f(abs(position.x), -position.y); + if (sc.y * pos.x > sc.x * pos.y) { + return length(pos.sub(sc.mul(radius))) - girth; + } + return abs(length(pos) - radius) - girth; + }, +); diff --git a/packages/typegpu-sdf/src/3d.ts b/packages/typegpu-sdf/src/3d.ts index 5c05c0d19..dd781f9d0 100644 --- a/packages/typegpu-sdf/src/3d.ts +++ b/packages/typegpu-sdf/src/3d.ts @@ -1,6 +1,20 @@ import tgpu from 'typegpu'; -import { f32, vec3f } from 'typegpu/data'; -import { abs, add, dot, length, max, min, saturate, sub } from 'typegpu/std'; +import { f32, v2f, type v3f, vec2f, vec3f } from 'typegpu/data'; +import { + abs, + add, + clamp, + cross, + dot, + length, + max, + min, + saturate, + select, + sign, + sqrt, + sub, +} from 'typegpu/std'; /** * Signed distance function for a sphere @@ -96,3 +110,64 @@ export const sdCapsule = tgpu const h = saturate(dot(pa, ba) / dot(ba, ba)); return length(sub(pa, ba.mul(h))) - radius; }); + +const dot2 = (a: v2f | v3f) => { + 'use gpu'; + return dot(a, a); +}; + +export const sdTriangle3d = (p: v3f, a: v3f, b: v3f, c: v3f) => { + 'use gpu'; + const ba = b.sub(a); + const pa = p.sub(a); + const cb = c.sub(b); + const pb = p.sub(b); + const ac = a.sub(c); + const pc = p.sub(c); + const nor = cross(ba, ac); + + const cond = sign(dot(cross(ba, nor), pa)) + + sign(dot(cross(cb, nor), pb)) + + sign(dot(cross(ac, nor), pc)) < 2; + + return sqrt( + select( + // false + dot(nor, pa) * dot(nor, pa) / dot2(nor), + // true + min( + min( + dot2(ba.mul(saturate(dot(ba, pa) / dot2(ba))).sub(pa)), + dot2(cb.mul(saturate(dot(cb, pb) / dot2(cb))).sub(pb)), + ), + dot2(ac.mul(saturate(dot(ac, pc) / dot2(ac))).sub(pc)), + ), + cond, + ), + ); +}; + +export const sdCappedCylinder = tgpu.fn([vec3f, f32, f32], f32)((p, r, h) => { + const dd = abs(vec2f(length(p.xz), p.y)).sub(vec2f(r, h)); + return min(max(dd.x, dd.y), 0.0) + length(max(dd, vec2f())); +}); + +const ndot = (a: v2f, b: v2f) => { + 'use gpu'; + return a.x * b.x - a.y * b.y; +}; + +export const sdRhombus = tgpu.fn([vec3f, f32, f32, f32, f32], f32)( + (p, la, lb, h, ra) => { + const ap = abs(p); + const b = vec2f(la, lb); + const f = clamp(ndot(b, b.sub(ap.xz.mul(2))) / dot2(b), -1, 1); + const q = vec2f( + length(ap.xz.sub(b.mul(vec2f(1 - f, 1 + f)).mul(0.5))) * + sign(ap.x * b.y + ap.z * b.x - b.x * b.y) - + ra, + ap.y - h, + ); + return min(max(q.x, q.y), 0.0) + length(max(q, vec2f())); + }, +); diff --git a/packages/typegpu-sdf/src/index.ts b/packages/typegpu-sdf/src/index.ts index 78942f225..c134e3271 100644 --- a/packages/typegpu-sdf/src/index.ts +++ b/packages/typegpu-sdf/src/index.ts @@ -1,6 +1,7 @@ export * from './operators.ts'; export { + sdArc, sdBezier, sdBezierApprox, sdBox2d, @@ -12,9 +13,12 @@ export { export { sdBox3d, sdBoxFrame3d, + sdCappedCylinder, sdCapsule, sdLine3d, sdPlane, + sdRhombus, sdRoundedBox3d, sdSphere, + sdTriangle3d, } from './3d.ts';