diff --git a/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts b/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts index 8731a57e1..4ae8f334e 100644 --- a/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/cubemap-reflection/index.ts @@ -352,21 +352,14 @@ resizeObserver.observe(canvas); let isDragging = false; let prevX = 0; let prevY = 0; +let lastPinchDist = 0; let orbitRadius = std.length(cameraInitialPos); // Yaw and pitch angles facing the origin. let orbitYaw = Math.atan2(cameraInitialPos.x, cameraInitialPos.z); let orbitPitch = Math.asin(cameraInitialPos.y / orbitRadius); -function updateCameraOrbit(dx: number, dy: number) { - const orbitSensitivity = 0.005; - orbitYaw += -dx * orbitSensitivity; - orbitPitch += dy * orbitSensitivity; - // Clamp pitch to avoid flipping - const maxPitch = Math.PI / 2 - 0.01; - if (orbitPitch > maxPitch) orbitPitch = maxPitch; - if (orbitPitch < -maxPitch) orbitPitch = -maxPitch; - // Convert spherical coordinates to cartesian coordinates +function updateCameraPosition() { const newCamX = orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch); const newCamY = orbitRadius * Math.sin(orbitPitch); const newCamZ = orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch); @@ -381,21 +374,24 @@ function updateCameraOrbit(dx: number, dy: number) { cameraBuffer.writePartial({ view: newView, position: newCameraPos }); } -canvas.addEventListener('wheel', (event: WheelEvent) => { - event.preventDefault(); - const zoomSensitivity = 0.05; - orbitRadius = std.clamp(orbitRadius + event.deltaY * zoomSensitivity, 3, 100); - const newCamX = orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch); - const newCamY = orbitRadius * Math.sin(orbitPitch); - const newCamZ = orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch); - const newCameraPos = d.vec4f(newCamX, newCamY, newCamZ, 1); - const newView = m.mat4.lookAt( - newCameraPos, - d.vec3f(0, 0, 0), - d.vec3f(0, 1, 0), - d.mat4x4f(), +function updateCameraOrbit(dx: number, dy: number) { + orbitYaw += -dx * 0.005; + orbitPitch = std.clamp( + orbitPitch + dy * 0.005, + -Math.PI / 2 + 0.01, + Math.PI / 2 - 0.01, ); - cameraBuffer.writePartial({ view: newView, position: newCameraPos }); + updateCameraPosition(); +} + +function zoomCamera(delta: number) { + orbitRadius = std.clamp(orbitRadius + delta, 3, 100); + updateCameraPosition(); +} + +canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + zoomCamera(e.deltaY * 0.05); }, { passive: false }); canvas.addEventListener('mousedown', (event) => { @@ -404,12 +400,17 @@ canvas.addEventListener('mousedown', (event) => { prevY = event.clientY; }); -canvas.addEventListener('touchstart', (event) => { - event.preventDefault(); - if (event.touches.length === 1) { +canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + if (e.touches.length === 1) { isDragging = true; - prevX = event.touches[0].clientX; - prevY = event.touches[0].clientY; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else if (e.touches.length === 2) { + isDragging = false; + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + lastPinchDist = Math.sqrt(dx * dx + dy * dy); } }, { passive: false }); @@ -418,8 +419,14 @@ const mouseUpEventListener = () => { }; window.addEventListener('mouseup', mouseUpEventListener); -const touchEndEventListener = () => { - isDragging = false; +const touchEndEventListener = (e: TouchEvent) => { + if (e.touches.length === 1) { + isDragging = true; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else { + isDragging = false; + } }; window.addEventListener('touchend', touchEndEventListener); @@ -435,14 +442,13 @@ const mouseMoveEventListener = (event: MouseEvent) => { }; window.addEventListener('mousemove', mouseMoveEventListener); -const touchMoveEventListener = (event: TouchEvent) => { - if (isDragging && event.touches.length === 1) { - event.preventDefault(); - const dx = event.touches[0].clientX - prevX; - const dy = event.touches[0].clientY - prevY; - prevX = event.touches[0].clientX; - prevY = event.touches[0].clientY; - +const touchMoveEventListener = (e: TouchEvent) => { + if (e.touches.length === 1 && isDragging) { + e.preventDefault(); + const dx = e.touches[0].clientX - prevX; + const dy = e.touches[0].clientY - prevY; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; updateCameraOrbit(dx, dy); } }; @@ -450,6 +456,17 @@ window.addEventListener('touchmove', touchMoveEventListener, { passive: false, }); +canvas.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const pinchDist = Math.sqrt(dx * dx + dy * dy); + zoomCamera((lastPinchDist - pinchDist) * 0.05); + lastPinchDist = pinchDist; + } +}, { passive: false }); + function hideHelp() { const helpElem = document.getElementById('help'); if (helpElem) { diff --git a/apps/typegpu-docs/src/examples/rendering/phong-reflection/setup-orbit-camera.ts b/apps/typegpu-docs/src/examples/rendering/phong-reflection/setup-orbit-camera.ts index 39b376d4c..b1dbe7449 100644 --- a/apps/typegpu-docs/src/examples/rendering/phong-reflection/setup-orbit-camera.ts +++ b/apps/typegpu-docs/src/examples/rendering/phong-reflection/setup-orbit-camera.ts @@ -119,6 +119,7 @@ export function setupOrbitCamera( let isDragging = false; let prevX = 0; let prevY = 0; + let lastPinchDist = 0; // mouse/touch events canvas.addEventListener('wheel', (event: WheelEvent) => { @@ -138,6 +139,11 @@ export function setupOrbitCamera( isDragging = true; prevX = event.touches[0].clientX; prevY = event.touches[0].clientY; + } else if (event.touches.length === 2) { + isDragging = false; + const dx = event.touches[0].clientX - event.touches[1].clientX; + const dy = event.touches[0].clientY - event.touches[1].clientY; + lastPinchDist = Math.sqrt(dx * dx + dy * dy); } }, { passive: false }); @@ -146,8 +152,14 @@ export function setupOrbitCamera( }; window.addEventListener('mouseup', mouseUpEventListener); - const touchEndEventListener = () => { - isDragging = false; + const touchEndEventListener = (e: TouchEvent) => { + if (e.touches.length === 1) { + isDragging = true; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else { + isDragging = false; + } }; window.addEventListener('touchend', touchEndEventListener); @@ -164,7 +176,7 @@ export function setupOrbitCamera( window.addEventListener('mousemove', mouseMoveEventListener); const touchMoveEventListener = (event: TouchEvent) => { - if (isDragging && event.touches.length === 1) { + if (event.touches.length === 1 && isDragging) { event.preventDefault(); const dx = event.touches[0].clientX - prevX; const dy = event.touches[0].clientY - prevY; @@ -178,6 +190,17 @@ export function setupOrbitCamera( passive: false, }); + canvas.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const pinchDist = Math.sqrt(dx * dx + dy * dy); + zoomCamera((lastPinchDist - pinchDist) * 0.5); + lastPinchDist = pinchDist; + } + }, { passive: false }); + function cleanupCamera() { window.removeEventListener('mouseup', mouseUpEventListener); window.removeEventListener('mousemove', mouseMoveEventListener); diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/box-geometry.ts b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/box-geometry.ts new file mode 100644 index 000000000..0c1049e96 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/box-geometry.ts @@ -0,0 +1,152 @@ +import type { IndexFlag, TgpuBuffer, TgpuRoot, VertexFlag } from 'typegpu'; +import * as d from 'typegpu/data'; +import type { GeometryData } from './types.ts'; +import { InstanceData, VertexData } from './types.ts'; + +export class BoxGeometry { + static #vertexBuffer: + | (TgpuBuffer> & VertexFlag) + | null = null; + static #indexBuffer: (TgpuBuffer> & IndexFlag) | null = + null; + static #indexCount = 0; + + #modelMatrix = d.mat4x4f.identity(); + #position = d.vec3f(0, 0, 0); + #scale = d.vec3f(1, 1, 1); + #rotation = d.vec3f(0, 0, 0); + + constructor(root: TgpuRoot) { + if (!BoxGeometry.#vertexBuffer || !BoxGeometry.#indexBuffer) { + this.#initBuffers(root); + } + } + + #initBuffers(root: TgpuRoot) { + const vertices: GeometryData = []; + const indices: number[] = []; + let vertexOffset = 0; + + const addFace = ( + u: number, + v: number, + w: number, + udir: number, + vdir: number, + depth: number, + ) => { + for (let iy = 0; iy < 2; iy++) { + for (let ix = 0; ix < 2; ix++) { + const pos = [0, 0, 0]; + pos[u] = (ix - 0.5) * udir; + pos[v] = (iy - 0.5) * vdir; + pos[w] = 0.5 * Math.sign(depth); + + const norm = [0, 0, 0]; + norm[w] = Math.sign(depth); + + vertices.push( + { + position: d.vec3f(pos[0], pos[1], pos[2]), + normal: d.vec3f(norm[0], norm[1], norm[2]), + uv: d.vec2f(ix, 1 - iy), + }, + ); + } + } + + indices.push( + vertexOffset, + vertexOffset + 1, + vertexOffset + 2, + vertexOffset + 1, + vertexOffset + 3, + vertexOffset + 2, + ); + vertexOffset += 4; + }; + + addFace(2, 1, 0, -1, 1, 1); // +X + addFace(2, 1, 0, 1, 1, -1); // -X + addFace(0, 2, 1, 1, 1, 1); // +Y + addFace(0, 2, 1, 1, -1, -1); // -Y + addFace(0, 1, 2, 1, 1, 1); // +Z + addFace(0, 1, 2, -1, 1, -1); // -Z + + BoxGeometry.#vertexBuffer = root + .createBuffer(d.arrayOf(VertexData, vertices.length), vertices) + .$usage('vertex'); + BoxGeometry.#indexBuffer = root + .createBuffer(d.arrayOf(d.u16, indices.length), indices) + .$usage('index'); + BoxGeometry.#indexCount = indices.length; + } + + set position(value: d.v3f) { + this.#position = value; + this.#updateModelMatrix(); + } + + get position() { + return this.#position; + } + + set scale(value: d.v3f) { + this.#scale = value; + this.#updateModelMatrix(); + } + + get scale() { + return this.#scale; + } + + set rotation(value: d.v3f) { + this.#rotation = value; + this.#updateModelMatrix(); + } + + get rotation() { + return this.#rotation; + } + + get instanceData(): d.Infer { + return InstanceData({ + column1: this.#modelMatrix.columns[0], + column2: this.#modelMatrix.columns[1], + column3: this.#modelMatrix.columns[2], + column4: this.#modelMatrix.columns[3], + }); + } + + static get vertexBuffer() { + if (!BoxGeometry.#vertexBuffer) { + throw new Error('BoxGeometry buffers not initialized'); + } + return BoxGeometry.#vertexBuffer; + } + + static get indexBuffer() { + if (!BoxGeometry.#indexBuffer) { + throw new Error('BoxGeometry buffers not initialized'); + } + return BoxGeometry.#indexBuffer; + } + + static get indexCount() { + return BoxGeometry.#indexCount; + } + + static clearBuffers() { + BoxGeometry.#vertexBuffer = null; + BoxGeometry.#indexBuffer = null; + } + + #updateModelMatrix() { + this.#modelMatrix = d.mat4x4f + .translation(this.#position) + .mul(d.mat4x4f.rotationZ(this.#rotation.z)) + .mul(d.mat4x4f.rotationY(this.#rotation.y)) + .mul(d.mat4x4f.rotationX(this.#rotation.x)) + .mul(d.mat4x4f.scaling(this.#scale)); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/camera.ts b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/camera.ts new file mode 100644 index 000000000..9f561207f --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/camera.ts @@ -0,0 +1,90 @@ +import type { TgpuRoot } from 'typegpu'; +import * as d from 'typegpu/data'; +import * as m from 'wgpu-matrix'; +import { CameraData } from './types.ts'; + +export class Camera { + readonly #uniform; + + #position = d.vec3f(0, 0, 0); + #target = d.vec3f(0, 0, -1); + #up = d.vec3f(0, 1, 0); + #fov: number; + #near: number; + #far: number; + + constructor(root: TgpuRoot, fov = 60, near = 0.1, far = 1000) { + this.#fov = fov; + this.#near = near; + this.#far = far; + this.#uniform = root.createUniform(CameraData, this.#computeData()); + } + + set position(pos: d.v3f) { + this.#position = pos; + this.#update(); + } + + get position() { + return this.#position; + } + + set target(tgt: d.v3f) { + this.#target = tgt; + this.#update(); + } + + get target() { + return this.#target; + } + + set up(upVec: d.v3f) { + this.#up = upVec; + this.#update(); + } + + get up() { + return this.#up; + } + + set fov(fovDegrees: number) { + this.#fov = fovDegrees; + this.#update(); + } + + get fov() { + return this.#fov; + } + + get uniform() { + return this.#uniform; + } + + #computeData() { + const view = m.mat4.lookAt( + this.#position, + this.#target, + this.#up, + d.mat4x4f(), + ); + + const projection = m.mat4.perspective( + (this.#fov * Math.PI) / 180, + 1, + this.#near, + this.#far, + d.mat4x4f(), + ); + + const viewProjectionMatrix = m.mat4.mul(projection, view, d.mat4x4f()); + + return CameraData({ + viewProjectionMatrix, + inverseViewProjectionMatrix: m.mat4.invert(viewProjectionMatrix), + }); + } + + #update() { + this.#uniform.write(this.#computeData()); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/index.html b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/index.html new file mode 100644 index 000000000..aa8cc321b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/index.ts b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/index.ts new file mode 100644 index 000000000..050f7f6d9 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/index.ts @@ -0,0 +1,701 @@ +import tgpu from 'typegpu'; +import { fullScreenTriangle } from 'typegpu/common'; +import * as d from 'typegpu/data'; +import * as std from 'typegpu/std'; +import { BoxGeometry } from './box-geometry.ts'; +import { Camera } from './camera.ts'; +import { PointLight } from './point-light.ts'; +import { Scene } from './scene.ts'; +import { + CameraData, + InstanceData, + instanceLayout, + VertexData, + vertexLayout, +} from './types.ts'; + +const root = await tgpu.init(); +const device = root.device; +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = canvas.getContext('webgpu') as GPUCanvasContext; +const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); + +context.configure({ device, format: presentationFormat }); + +const mainCamera = new Camera(root); +mainCamera.position = d.vec3f(5, 5, -5); +mainCamera.target = d.vec3f(0, 0, 0); + +const pointLight = new PointLight(root, d.vec3f(4.5, 1, 4), { + far: 100.0, + shadowMapSize: 1024, +}); + +const scene = new Scene(root); + +const cube = new BoxGeometry(root); +cube.scale = d.vec3f(3, 1, 0.2); + +const orbitingCubes: BoxGeometry[] = []; +for (let i = 0; i < 10; i++) { + const orbitingCube = new BoxGeometry(root); + const angle = (i / 10) * Math.PI * 2; + const radius = 4; + orbitingCube.position = d.vec3f( + Math.cos(angle) * radius, + 0.5, + Math.sin(angle) * radius, + ); + orbitingCube.scale = d.vec3f(0.5, 0.5, 0.5); + orbitingCubes.push(orbitingCube); +} + +const floorCube = new BoxGeometry(root); +floorCube.scale = d.vec3f(10, 0.1, 10); +floorCube.position = d.vec3f(0, -0.5, 0); +scene.add([cube, floorCube, ...orbitingCubes]); + +let depthTexture = root['~unstable'] + .createTexture({ + size: [canvas.width, canvas.height], + format: 'depth24plus', + sampleCount: 4, + }) + .$usage('render'); + +let msaaTexture = root['~unstable'] + .createTexture({ + size: [canvas.width, canvas.height], + format: presentationFormat, + sampleCount: 4, + }) + .$usage('render'); + +const shadowSampler = root['~unstable'].createComparisonSampler({ + compare: 'less-equal', + magFilter: 'linear', + minFilter: 'linear', +}); + +const renderLayout = tgpu.bindGroupLayout({ + camera: { uniform: CameraData }, + lightPosition: { uniform: d.vec3f }, +}); + +const renderLayoutWithShadow = tgpu.bindGroupLayout({ + camera: { uniform: CameraData }, + shadowDepthCube: { texture: d.textureDepthCube() }, + shadowSampler: { sampler: 'comparison' }, + lightPosition: { uniform: d.vec3f }, +}); + +const vertexDepth = tgpu['~unstable'].vertexFn({ + in: { ...VertexData.propTypes, ...InstanceData.propTypes }, + out: { pos: d.builtin.position, worldPos: d.vec3f }, +})(({ position, column1, column2, column3, column4 }) => { + const modelMatrix = d.mat4x4f(column1, column2, column3, column4); + const worldPos = modelMatrix.mul(d.vec4f(position, 1)).xyz; + const pos = renderLayout.$.camera.viewProjectionMatrix.mul( + d.vec4f(worldPos, 1), + ); + return { pos, worldPos }; +}); + +const fragmentDepth = tgpu['~unstable'].fragmentFn({ + in: { worldPos: d.vec3f }, + out: d.builtin.fragDepth, +})(({ worldPos }) => { + const dist = std.length(worldPos.sub(renderLayout.$.lightPosition)); + return dist / pointLight.far; +}); + +const vertexMain = tgpu['~unstable'].vertexFn({ + in: { ...VertexData.propTypes, ...InstanceData.propTypes }, + out: { + pos: d.builtin.position, + worldPos: d.vec3f, + uv: d.vec2f, + normal: d.vec3f, + }, +})(({ position, uv, normal, column1, column2, column3, column4 }) => { + const modelMatrix = d.mat4x4f(column1, column2, column3, column4); + const worldPos = modelMatrix.mul(d.vec4f(position, 1)).xyz; + const pos = renderLayoutWithShadow.$.camera.viewProjectionMatrix.mul( + d.vec4f(worldPos, 1), + ); + const worldNormal = std.normalize(modelMatrix.mul(d.vec4f(normal, 0)).xyz); + return { pos, worldPos, uv, normal: worldNormal }; +}); + +const shadowParams = root.createUniform( + d.struct({ + pcfSamples: d.u32, + diskRadius: d.f32, + normalBiasBase: d.f32, + normalBiasSlope: d.f32, + }), + { + pcfSamples: 32, + diskRadius: 0.01, + normalBiasBase: 0.027, + normalBiasSlope: 0.335, + }, +); + +const MAX_PCF_SAMPLES = 64; +const samplesUniform = root.createUniform( + d.arrayOf(d.vec4f, MAX_PCF_SAMPLES), + Array.from({ length: MAX_PCF_SAMPLES }, (_, i) => { + const index = i; + const theta = index * 2.3999632; // golden angle + const r = std.sqrt(index / d.f32(MAX_PCF_SAMPLES)); + return d.vec4f(d.vec2f(std.cos(theta) * r, std.sin(theta) * r), 0, 0); + }), +); + +const fragmentMain = tgpu['~unstable'].fragmentFn({ + in: { worldPos: d.vec3f, uv: d.vec2f, normal: d.vec3f }, + out: d.vec4f, +})(({ worldPos, normal }) => { + const lightPos = renderLayoutWithShadow.$.lightPosition; + const toLight = lightPos.sub(worldPos); + const dist = std.length(toLight); + const lightDir = toLight.div(dist); + const ndotl = std.max(std.dot(normal, lightDir), 0.0); + + const normalBiasWorld = shadowParams.$.normalBiasBase + + shadowParams.$.normalBiasSlope * (1.0 - ndotl); + const biasedPos = worldPos.add(normal.mul(normalBiasWorld)); + const toLightBiased = biasedPos.sub(lightPos); + const distBiased = std.length(toLightBiased); + const dir = toLightBiased.div(distBiased).mul(d.vec3f(-1, 1, 1)); + const depthRef = distBiased / pointLight.far; + + const up = std.select( + d.vec3f(1, 0, 0), + d.vec3f(0, 1, 0), + std.abs(dir.y) < d.f32(0.9999), + ); + const right = std.normalize(std.cross(up, dir)); + const realUp = std.cross(dir, right); + + const PCF_SAMPLES = shadowParams.$.pcfSamples; + const diskRadius = shadowParams.$.diskRadius; + + let visibilityAcc = 0.0; + for (let i = 0; i < PCF_SAMPLES; i++) { + const o = samplesUniform.$[i].xy.mul(diskRadius); + + const sampleDir = dir + .add(right.mul(o.x)) + .add(realUp.mul(o.y)); + + visibilityAcc += std.textureSampleCompare( + renderLayoutWithShadow.$.shadowDepthCube, + renderLayoutWithShadow.$.shadowSampler, + sampleDir, + depthRef, + ); + } + + const rawNdotl = std.dot(normal, lightDir); + const visibility = std.select( + visibilityAcc / d.f32(PCF_SAMPLES), + 0.0, + rawNdotl < 0.0, + ); + + const baseColor = d.vec3f(1.0, 0.5, 0.31); + const color = baseColor.mul(ndotl * visibility + 0.1); + return d.vec4f(color, 1.0); +}); + +const lightIndicatorLayout = tgpu.bindGroupLayout({ + camera: { uniform: CameraData }, + lightPosition: { uniform: d.vec3f }, +}); + +const vertexLightIndicator = tgpu['~unstable'].vertexFn({ + in: { position: d.vec3f }, + out: { pos: d.builtin.position }, +})(({ position }) => { + const worldPos = position.mul(0.15).add(lightIndicatorLayout.$.lightPosition); + const pos = lightIndicatorLayout.$.camera.viewProjectionMatrix.mul( + d.vec4f(worldPos, 1), + ); + return { pos }; +}); + +const fragmentLightIndicator = tgpu['~unstable'].fragmentFn({ + out: d.vec4f, +})(() => d.vec4f(1.0, 1.0, 0.5, 1.0)); + +const previewSampler = root['~unstable'].createSampler({ + minFilter: 'nearest', + magFilter: 'nearest', +}); +const previewView = pointLight.createDepthArrayView(); + +const depthToColor = tgpu.fn([d.f32], d.vec3f)((depth) => { + const linear = std.clamp(1 - depth * 6, 0, 1); + const t = linear * linear; + const r = std.clamp(t * 2 - 0.5, 0, 1); + const g = std.clamp(1 - std.abs(t - 0.5) * 2, 0, 0.9) * t; + const b = std.clamp(1 - t * 1.5, 0, 1) * t; + return d.vec3f(r, g, b); +}); + +const fragmentDistanceView = tgpu['~unstable'].fragmentFn({ + in: { worldPos: d.vec3f, uv: d.vec2f, normal: d.vec3f }, + out: d.vec4f, +})(({ worldPos }) => { + const lightPos = renderLayoutWithShadow.$.lightPosition; + const dist = std.length(worldPos.sub(lightPos)); + const color = depthToColor(dist / pointLight.far); + return d.vec4f(color, 1.0); +}); + +const previewFragment = tgpu['~unstable'].fragmentFn({ + in: { uv: d.vec2f }, + out: d.vec4f, +})(({ uv }) => { + const gridX = d.i32(std.floor(uv.x * 4)); + const gridY = d.i32(std.floor(uv.y * 3)); + + const localU = std.fract(uv.x * 4); + const localV = std.fract(uv.y * 3); + const localUV = d.vec2f(localU, localV); + + const bgColor = d.vec3f(0.1, 0.1, 0.12); + + let faceIndex = d.i32(-1); + + // Top row: +Y (index 2) + if (gridY === 0 && gridX === 1) { + faceIndex = 2; + } + // Middle row: -X, +Z, +X, -Z + if (gridY === 1) { + if (gridX === 0) { + faceIndex = 0; // -X + } + if (gridX === 1) { + faceIndex = 4; // +Z + } + if (gridX === 2) { + faceIndex = 1; // +X + } + if (gridX === 3) { + faceIndex = 5; // -Z + } + } + // Bottom row: -Y (index 3) + if (gridY === 2 && gridX === 1) { + faceIndex = 3; + } + + const depth = std.textureSample( + previewView.$, + previewSampler.$, + localUV, + faceIndex, + ); + + if (faceIndex < 0) { + return d.vec4f(bgColor, 1.0); + } + + const color = depthToColor(depth); + + const border = 0.02; + const isBorder = localU < border || localU > 1 - border || localV < border || + localV > 1 - border; + const finalColor = std.select(color, std.mul(0.5, color), isBorder); + + return d.vec4f(finalColor, 1.0); +}); + +const pipelineDepthOne = root['~unstable'] + .withVertex(vertexDepth, { ...vertexLayout.attrib, ...instanceLayout.attrib }) + .withFragment(fragmentDepth, {}) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .createPipeline(); + +const pipelineMain = root['~unstable'] + .withVertex(vertexMain, { ...vertexLayout.attrib, ...instanceLayout.attrib }) + .withFragment(fragmentMain, { format: presentationFormat }) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .withMultisample({ count: 4 }) + .createPipeline(); + +const pipelinePreview = root['~unstable'] + .withVertex(fullScreenTriangle, {}) + .withFragment(previewFragment, { format: presentationFormat }) + .createPipeline(); + +const pipelineLightIndicator = root['~unstable'] + .withVertex(vertexLightIndicator, vertexLayout.attrib) + .withFragment(fragmentLightIndicator, { format: presentationFormat }) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .withMultisample({ count: 4 }) + .createPipeline(); + +const pipelineDistanceView = root['~unstable'] + .withVertex(vertexMain, { ...vertexLayout.attrib, ...instanceLayout.attrib }) + .withFragment(fragmentDistanceView, { format: presentationFormat }) + .withDepthStencil({ + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }) + .withMultisample({ count: 4 }) + .createPipeline(); + +const mainBindGroup = root.createBindGroup(renderLayoutWithShadow, { + camera: mainCamera.uniform.buffer, + shadowDepthCube: pointLight.createCubeView(), + shadowSampler, + lightPosition: pointLight.positionUniform.buffer, +}); + +const lightIndicatorBindGroup = root.createBindGroup(lightIndicatorLayout, { + camera: mainCamera.uniform.buffer, + lightPosition: pointLight.positionUniform.buffer, +}); + +let showDepthPreview = false; +let showDistanceView = false; +let lastTime = performance.now(); +let time = 0; + +function render(timestamp: number) { + const dt = (timestamp - lastTime) / 1000; + lastTime = timestamp; + time += dt; + + for (let i = 0; i < orbitingCubes.length; i++) { + const offset = (i / orbitingCubes.length) * Math.PI * 2; + const angle = time * 0.5 + offset; + const radius = 4 + Math.sin(time * 2 + offset * 3) * 0.5; + const x = Math.cos(angle) * radius; + const z = Math.sin(angle) * radius; + const y = 2 + Math.sin(time * 1.5 + offset * 2) * 1.5; + orbitingCubes[i].position = d.vec3f(x, y, z); + orbitingCubes[i].rotation = d.vec3f(time, time * 0.5, 0); + } + + scene.update(); + pointLight.renderShadowMaps(pipelineDepthOne, renderLayout, scene); + + if (showDepthPreview) { + pipelinePreview + .withColorAttachment({ + view: context.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store', + }) + .draw(3); + requestAnimationFrame(render); + return; + } + + const mainPipeline = showDistanceView ? pipelineDistanceView : pipelineMain; + + mainPipeline + .withDepthStencilAttachment({ + view: depthTexture, + depthClearValue: 1, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }) + .withColorAttachment({ + resolveTarget: context.getCurrentTexture().createView(), + view: root.unwrap(msaaTexture).createView(), + loadOp: 'clear', + storeOp: 'store', + }) + .with(mainBindGroup) + .withIndexBuffer(BoxGeometry.indexBuffer) + .with(vertexLayout, BoxGeometry.vertexBuffer) + .with(instanceLayout, scene.instanceBuffer) + .drawIndexed(BoxGeometry.indexCount, scene.instanceCount); + + pipelineLightIndicator + .withDepthStencilAttachment({ + view: depthTexture, + depthLoadOp: 'load', + depthStoreOp: 'store', + }) + .withColorAttachment({ + resolveTarget: context.getCurrentTexture().createView(), + view: root.unwrap(msaaTexture).createView(), + loadOp: 'load', + storeOp: 'store', + }) + .with(lightIndicatorBindGroup) + .withIndexBuffer(BoxGeometry.indexBuffer) + .with(vertexLayout, BoxGeometry.vertexBuffer) + .drawIndexed(BoxGeometry.indexCount); + + requestAnimationFrame(render); +} +requestAnimationFrame(render); + +const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const width = entry.contentBoxSize[0].inlineSize; + const height = entry.contentBoxSize[0].blockSize; + canvas.width = Math.max( + 1, + Math.min(width, device.limits.maxTextureDimension2D), + ); + canvas.height = Math.max( + 1, + Math.min(height, device.limits.maxTextureDimension2D), + ); + + depthTexture = root['~unstable'] + .createTexture({ + size: [canvas.width, canvas.height], + format: 'depth24plus', + sampleCount: 4, + }) + .$usage('render'); + msaaTexture = root['~unstable'] + .createTexture({ + size: [canvas.width, canvas.height], + format: presentationFormat, + sampleCount: 4, + }) + .$usage('render'); + } +}); +resizeObserver.observe(canvas); + +const initialCamPos = { x: 5, y: 5, z: -5 }; +let theta = Math.atan2(initialCamPos.z, initialCamPos.x); +let phi = Math.acos( + initialCamPos.y / + Math.sqrt( + initialCamPos.x ** 2 + initialCamPos.y ** 2 + initialCamPos.z ** 2, + ), +); +let radius = Math.sqrt( + initialCamPos.x ** 2 + initialCamPos.y ** 2 + initialCamPos.z ** 2, +); + +let isDragging = false; +let prevX = 0; +let prevY = 0; +let lastPinchDist = 0; + +function updateCameraPosition() { + mainCamera.position = d.vec3f( + radius * Math.sin(phi) * Math.cos(theta), + radius * Math.cos(phi), + radius * Math.sin(phi) * Math.sin(theta), + ); +} + +function updateCameraOrbit(dx: number, dy: number) { + theta += dx * 0.01; + phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - dy * 0.01)); + updateCameraPosition(); +} + +function zoomCamera(delta: number) { + radius = Math.max(1, Math.min(50, radius + delta)); + updateCameraPosition(); +} + +canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + zoomCamera(e.deltaY * 0.01); +}, { passive: false }); + +canvas.addEventListener('mousedown', (e) => { + isDragging = true; + prevX = e.clientX; + prevY = e.clientY; +}); + +canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + if (e.touches.length === 1) { + isDragging = true; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else if (e.touches.length === 2) { + isDragging = false; + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + lastPinchDist = Math.sqrt(dx * dx + dy * dy); + } +}, { passive: false }); + +const mouseUpEventListener = () => { + isDragging = false; +}; +window.addEventListener('mouseup', mouseUpEventListener); + +const touchEndEventListener = (e: TouchEvent) => { + if (e.touches.length === 1) { + isDragging = true; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else { + isDragging = false; + } +}; +window.addEventListener('touchend', touchEndEventListener); + +const mouseMoveEventListener = (e: MouseEvent) => { + if (!isDragging) return; + const dx = e.clientX - prevX; + const dy = e.clientY - prevY; + prevX = e.clientX; + prevY = e.clientY; + updateCameraOrbit(dx, dy); +}; +window.addEventListener('mousemove', mouseMoveEventListener); + +const touchMoveEventListener = (e: TouchEvent) => { + if (e.touches.length === 1 && isDragging) { + e.preventDefault(); + const dx = e.touches[0].clientX - prevX; + const dy = e.touches[0].clientY - prevY; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + updateCameraOrbit(dx, dy); + } +}; +window.addEventListener('touchmove', touchMoveEventListener, { + passive: false, +}); + +canvas.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const pinchDist = Math.sqrt(dx * dx + dy * dy); + zoomCamera((lastPinchDist - pinchDist) * 0.05); + lastPinchDist = pinchDist; + } +}, { passive: false }); + +// #region Example controls and cleanup + +export const controls = { + 'Light X': { + initial: 4.5, + min: -10, + max: 10, + step: 0.1, + onSliderChange: (v: number) => { + pointLight.position = d.vec3f( + v, + pointLight.position.y, + pointLight.position.z, + ); + }, + }, + 'Light Y': { + initial: 1, + min: 0.5, + max: 10, + step: 0.1, + onSliderChange: (v: number) => { + pointLight.position = d.vec3f( + pointLight.position.x, + v, + pointLight.position.z, + ); + }, + }, + 'Light Z': { + initial: 4, + min: -10, + max: 10, + step: 0.1, + onSliderChange: (v: number) => { + pointLight.position = d.vec3f( + pointLight.position.x, + pointLight.position.y, + v, + ); + }, + }, + 'Show Depth Cubemap': { + initial: false, + onToggleChange: (v: boolean) => { + showDepthPreview = v; + }, + }, + 'Show Distance View': { + initial: false, + onToggleChange: (v: boolean) => { + showDistanceView = v; + }, + }, + 'PCF Samples': { + initial: 16, + min: 1, + max: 64, + step: 1, + onSliderChange: (v: number) => { + shadowParams.writePartial({ pcfSamples: v }); + }, + }, + 'PCF Disk Radius': { + initial: 0.01, + min: 0.0, + max: 0.1, + step: 0.001, + onSliderChange: (v: number) => { + shadowParams.writePartial({ diskRadius: v }); + }, + }, + 'Normal Bias Base': { + initial: 0.027, + min: 0.0, + max: 0.1, + step: 0.0001, + onSliderChange: (v: number) => { + shadowParams.writePartial({ normalBiasBase: v }); + }, + }, + 'Normal Bias Slope': { + initial: 0.335, + min: 0.0, + max: 0.5, + step: 0.0005, + onSliderChange: (v: number) => { + shadowParams.writePartial({ normalBiasSlope: v }); + }, + }, +}; + +export function onCleanup() { + BoxGeometry.clearBuffers(); + window.removeEventListener('mouseup', mouseUpEventListener); + window.removeEventListener('mousemove', mouseMoveEventListener); + window.removeEventListener('touchend', touchEndEventListener); + window.removeEventListener('touchmove', touchMoveEventListener); + resizeObserver.disconnect(); + root.destroy(); +} + +// #endregion diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/meta.json b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/meta.json new file mode 100644 index 000000000..ff8ffe4f1 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Point Light Shadow", + "category": "rendering", + "tags": ["3d"] +} diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/point-light.ts b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/point-light.ts new file mode 100644 index 000000000..f9b05a450 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/point-light.ts @@ -0,0 +1,124 @@ +import type { + TgpuBindGroup, + TgpuBindGroupLayout, + TgpuRenderPipeline, + TgpuRoot, +} from 'typegpu'; +import * as d from 'typegpu/data'; +import { BoxGeometry } from './box-geometry.ts'; +import { Camera } from './camera.ts'; +import type { Scene } from './scene.ts'; +import { instanceLayout, vertexLayout } from './types.ts'; + +const FACE_CONFIGS = [ + { name: 'right', dir: d.vec3f(-1, 0, 0), up: d.vec3f(0, 1, 0) }, + { name: 'left', dir: d.vec3f(1, 0, 0), up: d.vec3f(0, 1, 0) }, + { name: 'up', dir: d.vec3f(0, 1, 0), up: d.vec3f(0, 0, -1) }, + { name: 'down', dir: d.vec3f(0, -1, 0), up: d.vec3f(0, 0, 1) }, + { name: 'forward', dir: d.vec3f(0, 0, 1), up: d.vec3f(0, 1, 0) }, + { name: 'backward', dir: d.vec3f(0, 0, -1), up: d.vec3f(0, 1, 0) }, +] as const; + +export class PointLight { + readonly far: number; + readonly #root: TgpuRoot; + readonly #positionUniform; + readonly #depthCubeTexture; + readonly #shadowCameras: Camera[]; + readonly #bindGroups: TgpuBindGroup[] = []; + + #position: d.v3f; + + constructor( + root: TgpuRoot, + position: d.v3f, + options: { far?: number; shadowMapSize?: number } = {}, + ) { + this.#root = root; + this.#position = position; + this.far = options.far ?? 100.0; + const shadowMapSize = options.shadowMapSize ?? 512; + + this.#depthCubeTexture = root['~unstable'] + .createTexture({ + size: [shadowMapSize, shadowMapSize, 6], + dimension: '2d', + format: 'depth24plus', + }) + .$usage('render', 'sampled'); + + this.#positionUniform = root.createUniform(d.vec3f, position); + this.#shadowCameras = FACE_CONFIGS.map( + () => new Camera(root, 90, 0.1, this.far), + ); + this.#configureCameras(); + } + + #configureCameras() { + FACE_CONFIGS.forEach((config, i) => { + const camera = this.#shadowCameras[i]; + camera.position = this.#position; + camera.target = this.#position.add(config.dir); + camera.up = config.up; + }); + } + + set position(pos: d.v3f) { + this.#position = pos; + this.#positionUniform.write(pos); + this.#configureCameras(); + } + + get position() { + return this.#position; + } + + get positionUniform() { + return this.#positionUniform; + } + + createCubeView() { + return this.#depthCubeTexture.createView(d.textureDepthCube()); + } + + createDepthArrayView() { + return this.#depthCubeTexture.createView(d.textureDepth2dArray(), { + baseArrayLayer: 0, + arrayLayerCount: 6, + aspect: 'depth-only', + }); + } + + renderShadowMaps( + pipeline: TgpuRenderPipeline, + bindGroupLayout: TgpuBindGroupLayout, + scene: Scene, + ) { + this.#shadowCameras.forEach((camera, i) => { + if (!this.#bindGroups[i]) { + this.#bindGroups[i] = this.#root.createBindGroup(bindGroupLayout, { + camera: camera.uniform.buffer, + lightPosition: this.#positionUniform.buffer, + }); + } + + const view = this.#depthCubeTexture.createView(d.textureDepth2d(), { + baseArrayLayer: i, + arrayLayerCount: 1, + }); + + pipeline + .withDepthStencilAttachment({ + view: this.#root.unwrap(view), + depthClearValue: 1, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }) + .with(vertexLayout, BoxGeometry.vertexBuffer) + .with(instanceLayout, scene.instanceBuffer) + .with(this.#bindGroups[i]) + .withIndexBuffer(BoxGeometry.indexBuffer) + .drawIndexed(BoxGeometry.indexCount, scene.instanceCount); + }); + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/scene.ts b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/scene.ts new file mode 100644 index 000000000..f8e88ea7c --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/scene.ts @@ -0,0 +1,59 @@ +import type { TgpuRoot } from 'typegpu'; +import * as d from 'typegpu/data'; +import type { BoxGeometry } from './box-geometry.ts'; +import { InstanceData } from './types.ts'; + +export class Scene { + readonly #root: TgpuRoot; + readonly #objects: BoxGeometry[] = []; + + #instanceBuffer; + + constructor(root: TgpuRoot) { + this.#root = root; + this.#instanceBuffer = root + .createBuffer(d.arrayOf(InstanceData, 0), []) + .$usage('vertex'); + } + + add(object: BoxGeometry | BoxGeometry[]) { + const items = Array.isArray(object) ? object : [object]; + if (items.length === 0) { + return; + } + this.#objects.push(...items); + this.#rebuildBuffer(); + } + + remove(object: BoxGeometry | BoxGeometry[]) { + const items = Array.isArray(object) ? object : [object]; + if (items.length === 0) { + return; + } + this.#objects.splice( + 0, + this.#objects.length, + ...this.#objects.filter((obj) => !items.includes(obj)), + ); + this.#rebuildBuffer(); + } + + update() { + this.#instanceBuffer.write(this.#objects.map((obj) => obj.instanceData)); + } + + #rebuildBuffer() { + const data = this.#objects.map((obj) => obj.instanceData); + this.#instanceBuffer = this.#root + .createBuffer(d.arrayOf(InstanceData, data.length), data) + .$usage('vertex'); + } + + get instanceBuffer() { + return this.#instanceBuffer; + } + + get instanceCount() { + return this.#objects.length; + } +} diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/thumbnail.png new file mode 100644 index 000000000..a9a51b654 Binary files /dev/null and b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/thumbnail.png differ diff --git a/apps/typegpu-docs/src/examples/rendering/point-light-shadow/types.ts b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/types.ts new file mode 100644 index 000000000..efd1f8663 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/point-light-shadow/types.ts @@ -0,0 +1,31 @@ +import tgpu from 'typegpu'; +import * as d from 'typegpu/data'; + +export const CameraData = d.struct({ + viewProjectionMatrix: d.mat4x4f, + inverseViewProjectionMatrix: d.mat4x4f, +}); +export type CameraData = typeof CameraData; + +export const VertexData = d.struct({ + position: d.vec3f, + normal: d.vec3f, + uv: d.vec2f, +}); +export type VertexData = typeof VertexData; + +export const InstanceData = d.struct({ + column1: d.vec4f, + column2: d.vec4f, + column3: d.vec4f, + column4: d.vec4f, +}); +export type InstanceData = typeof InstanceData; + +export const vertexLayout = tgpu.vertexLayout(d.arrayOf(VertexData)); +export const instanceLayout = tgpu.vertexLayout( + d.arrayOf(InstanceData), + 'instance', +); + +export type GeometryData = d.Infer[]; diff --git a/apps/typegpu-docs/src/examples/rendering/two-boxes/index.html b/apps/typegpu-docs/src/examples/rendering/two-boxes/index.html index 50a11ece6..9209e7c2f 100644 --- a/apps/typegpu-docs/src/examples/rendering/two-boxes/index.html +++ b/apps/typegpu-docs/src/examples/rendering/two-boxes/index.html @@ -24,9 +24,7 @@

Controls

Left Mouse Button / Drag: Orbit camera

-

- Right Mouse Button / Two-Finger Drag: Rotate cubes -

-

Scroll: Zoom

+

Right Mouse Button: Rotate cubes

+

Scroll / Two-Finger Drag: Zoom

diff --git a/apps/typegpu-docs/src/examples/rendering/two-boxes/index.ts b/apps/typegpu-docs/src/examples/rendering/two-boxes/index.ts index 6f8d25b94..8495f023c 100644 --- a/apps/typegpu-docs/src/examples/rendering/two-boxes/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/two-boxes/index.ts @@ -319,6 +319,7 @@ let isRightDragging = false; let isDragging = false; let prevX = 0; let prevY = 0; +let lastPinchDist = 0; let orbitRadius = Math.sqrt( cameraInitialPos.x * cameraInitialPos.x + cameraInitialPos.y * cameraInitialPos.y + @@ -355,16 +356,7 @@ function updateCubesRotation(dx: number, dy: number) { secondTransformBuffer.write({ model: cube2Transform }); } -function updateCameraOrbit(dx: number, dy: number) { - const orbitSensitivity = 0.005; - orbitYaw += -dx * orbitSensitivity; - orbitPitch += dy * orbitSensitivity; - // if we didn't limit pitch, it would lead to flipping the camera which is disorienting. - const maxPitch = Math.PI / 2 - 0.01; - if (orbitPitch > maxPitch) orbitPitch = maxPitch; - if (orbitPitch < -maxPitch) orbitPitch = -maxPitch; - // basically converting spherical coordinates to cartesian. - // like sampling points on a unit sphere and then scaling them by the radius. +function updateCameraPosition() { const newCamX = orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch); const newCamY = orbitRadius * Math.sin(orbitPitch); const newCamZ = orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch); @@ -379,6 +371,20 @@ function updateCameraOrbit(dx: number, dy: number) { cameraBuffer.write({ view: newView, projection: cameraInitial.projection }); } +function updateCameraOrbit(dx: number, dy: number) { + orbitYaw += -dx * 0.005; + orbitPitch = Math.max( + -Math.PI / 2 + 0.01, + Math.min(Math.PI / 2 - 0.01, orbitPitch + dy * 0.005), + ); + updateCameraPosition(); +} + +function zoomCamera(delta: number) { + orbitRadius = Math.max(1, orbitRadius + delta); + updateCameraPosition(); +} + // Prevent the context menu from appearing on right click. canvas.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -398,46 +404,51 @@ canvas.addEventListener('touchend', () => { helpInfo.style.opacity = '1'; }); -canvas.addEventListener('wheel', (event: WheelEvent) => { - event.preventDefault(); - const zoomSensitivity = 0.05; - orbitRadius = Math.max(1, orbitRadius + event.deltaY * zoomSensitivity); - const newCamX = orbitRadius * Math.sin(orbitYaw) * Math.cos(orbitPitch); - const newCamY = orbitRadius * Math.sin(orbitPitch); - const newCamZ = orbitRadius * Math.cos(orbitYaw) * Math.cos(orbitPitch); - const newCameraPos = d.vec4f(newCamX, newCamY, newCamZ, 1); - const newView = m.mat4.lookAt( - newCameraPos, - target, - d.vec3f(0, 1, 0), - d.mat4x4f(), - ); - cameraBuffer.writePartial({ view: newView }); +canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + zoomCamera(e.deltaY * 0.05); }, { passive: false }); -canvas.addEventListener('mousedown', (event) => { - if (event.button === 0) { - // Left Mouse Button controls Camera Orbit. +canvas.addEventListener('mousedown', (e) => { + if (e.button === 0) { isDragging = true; - } else if (event.button === 2) { - // Right Mouse Button controls Cube Rotation. + } else if (e.button === 2) { isRightDragging = true; } - prevX = event.clientX; - prevY = event.clientY; + prevX = e.clientX; + prevY = e.clientY; }); +canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + if (e.touches.length === 1) { + isDragging = true; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; + } else if (e.touches.length === 2) { + isDragging = false; + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + lastPinchDist = Math.sqrt(dx * dx + dy * dy); + } +}, { passive: false }); + const mouseUpEventListener = () => { isRightDragging = false; isDragging = false; }; window.addEventListener('mouseup', mouseUpEventListener); -canvas.addEventListener('mousemove', (event) => { - const dx = event.clientX - prevX; - const dy = event.clientY - prevY; - prevX = event.clientX; - prevY = event.clientY; +const touchEndEventListener = () => { + isDragging = false; +}; +window.addEventListener('touchend', touchEndEventListener); + +const mouseMoveEventListener = (e: MouseEvent) => { + const dx = e.clientX - prevX; + const dy = e.clientY - prevY; + prevX = e.clientX; + prevY = e.clientY; if (isDragging) { updateCameraOrbit(dx, dy); @@ -445,47 +456,33 @@ canvas.addEventListener('mousemove', (event) => { if (isRightDragging) { updateCubesRotation(dx, dy); } -}); - -// Mobile touch support. -canvas.addEventListener('touchstart', (event: TouchEvent) => { - event.preventDefault(); - if (event.touches.length === 1) { - // Single touch controls Camera Orbit. - isDragging = true; - } else if (event.touches.length === 2) { - // Two-finger touch controls Cube Rotation. - isRightDragging = true; - } - // Use the first touch for rotation. - prevX = event.touches[0].clientX; - prevY = event.touches[0].clientY; -}, { passive: false }); - -canvas.addEventListener('touchmove', (event: TouchEvent) => { - event.preventDefault(); - const touch = event.touches[0]; - const dx = touch.clientX - prevX; - const dy = touch.clientY - prevY; - prevX = touch.clientX; - prevY = touch.clientY; - - if (isDragging && event.touches.length === 1) { +}; +window.addEventListener('mousemove', mouseMoveEventListener); + +const touchMoveEventListener = (e: TouchEvent) => { + if (e.touches.length === 1 && isDragging) { + e.preventDefault(); + const dx = e.touches[0].clientX - prevX; + const dy = e.touches[0].clientY - prevY; + prevX = e.touches[0].clientX; + prevY = e.touches[0].clientY; updateCameraOrbit(dx, dy); } - if (isRightDragging && event.touches.length === 2) { - updateCubesRotation(dx, dy); - } -}, { passive: false }); +}; +window.addEventListener('touchmove', touchMoveEventListener, { + passive: false, +}); -const touchEndEventListener = (event: TouchEvent) => { - event.preventDefault(); - if (event.touches.length === 0) { - isRightDragging = false; - isDragging = false; +canvas.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const pinchDist = Math.sqrt(dx * dx + dy * dy); + zoomCamera((lastPinchDist - pinchDist) * 0.05); + lastPinchDist = pinchDist; } -}; -window.addEventListener('touchend', touchEndEventListener); +}, { passive: false }); const resizeObserver = new ResizeObserver(() => { createDepthAndMsaaTextures(); @@ -495,7 +492,9 @@ resizeObserver.observe(canvas); export function onCleanup() { disposed = true; window.removeEventListener('mouseup', mouseUpEventListener); + window.removeEventListener('mousemove', mouseMoveEventListener); window.removeEventListener('touchend', touchEndEventListener); + window.removeEventListener('touchmove', touchMoveEventListener); resizeObserver.disconnect(); root.destroy(); } diff --git a/packages/typegpu/src/core/function/ioSchema.ts b/packages/typegpu/src/core/function/ioSchema.ts index b39fedbab..eeb1b77a4 100644 --- a/packages/typegpu/src/core/function/ioSchema.ts +++ b/packages/typegpu/src/core/function/ioSchema.ts @@ -75,6 +75,8 @@ export function createIoSchema< return ( isData(layout) ? isVoid(layout) + ? layout + : isBuiltin(layout) ? layout : getCustomLocation(layout) !== undefined ? layout diff --git a/packages/typegpu/src/core/function/tgpuFragmentFn.ts b/packages/typegpu/src/core/function/tgpuFragmentFn.ts index 3c1c5b47c..76e97bb85 100644 --- a/packages/typegpu/src/core/function/tgpuFragmentFn.ts +++ b/packages/typegpu/src/core/function/tgpuFragmentFn.ts @@ -77,7 +77,7 @@ export type TgpuFragmentFnShell< input: InferIO, out: FragmentOut extends IORecord ? WgslStruct : FragmentOut, ) => InferIO, - ) => TgpuFragmentFn, OmitBuiltins>) + ) => TgpuFragmentFn, FragmentOut>) & /** * @param implementation * Raw WGSL function implementation with header and body @@ -85,11 +85,11 @@ export type TgpuFragmentFnShell< * e.g. `"(x: f32) -> f32 { return x; }"`; */ (( implementation: string, - ) => TgpuFragmentFn, OmitBuiltins>) + ) => TgpuFragmentFn, FragmentOut>) & (( strings: TemplateStringsArray, ...values: unknown[] - ) => TgpuFragmentFn, OmitBuiltins>) + ) => TgpuFragmentFn, FragmentOut>) & { /** * @deprecated Invoke the shell as a function instead. @@ -97,7 +97,7 @@ export type TgpuFragmentFnShell< does: & (( implementation: (input: InferIO) => InferIO, - ) => TgpuFragmentFn, OmitBuiltins>) + ) => TgpuFragmentFn, FragmentOut>) & /** * @param implementation * Raw WGSL function implementation with header and body @@ -105,7 +105,7 @@ export type TgpuFragmentFnShell< * e.g. `"(x: f32) -> f32 { return x; }"`; */ (( implementation: string, - ) => TgpuFragmentFn, OmitBuiltins>); + ) => TgpuFragmentFn, FragmentOut>); }; export interface TgpuFragmentFn< diff --git a/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts index accb0616c..eb171cb7d 100644 --- a/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts +++ b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts @@ -17,6 +17,9 @@ export function connectAttachmentToShader( attachment: AnyFragmentColorAttachment, ): ColorAttachment[] { if (isData(shaderOutputLayout)) { + if (isBuiltin(shaderOutputLayout)) { + return []; + } if (!isColorAttachment(attachment)) { throw new Error('Expected a single color attachment, not a record.'); } diff --git a/packages/typegpu/src/core/pipeline/connectTargetsToShader.ts b/packages/typegpu/src/core/pipeline/connectTargetsToShader.ts index 4c4d8b342..099d41612 100644 --- a/packages/typegpu/src/core/pipeline/connectTargetsToShader.ts +++ b/packages/typegpu/src/core/pipeline/connectTargetsToShader.ts @@ -18,6 +18,9 @@ export function connectTargetsToShader( if (isVoid(shaderOutputLayout)) { return []; } + if (shaderOutputLayout.type === 'decorated') { + return []; + } if (!isColorTargetState(targets)) { throw new Error( diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index e02b5b844..945a012c1 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -11,6 +11,7 @@ import { type ResolvedSnippet, snip } from '../../data/snippet.ts'; import type { WgslTexture } from '../../data/texture.ts'; import { type AnyWgslData, + type Decorated, isWgslData, type U16, type U32, @@ -141,15 +142,23 @@ export interface TgpuRenderPipeline } export type FragmentOutToTargets = T extends IOData - ? GPUColorTargetState - : T extends Record - ? { [Key in keyof T]: GPUColorTargetState } + ? T extends Decorated ? Record + : GPUColorTargetState + : T extends Record ? { + [Key in keyof T as T[Key] extends Decorated ? never : Key]: + GPUColorTargetState; + } : T extends { type: 'void' } ? Record : never; export type FragmentOutToColorAttachment = T extends IOData - ? ColorAttachment - : T extends Record ? { [Key in keyof T]: ColorAttachment } + ? T extends Decorated ? Record + : ColorAttachment + : T extends Record ? { + [Key in keyof T as T[Key] extends Decorated ? never : Key]: + ColorAttachment; + } + : T extends { type: 'void' } ? Record : never; export type AnyFragmentTargets = @@ -203,7 +212,16 @@ export interface DepthStencilAttachment { * A {@link GPUTextureView} | ({@link TgpuTexture} & {@link RenderFlag}) describing the texture subresource that will be output to * and read from for this depth/stencil attachment. */ - view: (TgpuTexture & RenderFlag) | GPUTextureView; + view: + | ( + & TgpuTexture<{ + size: [number, number]; + format: 'depth24plus' | 'depth24plus-stencil8' | 'depth32float'; + sampleCount?: number; + }> + & RenderFlag + ) + | GPUTextureView; /** * Indicates the value to clear {@link GPURenderPassDepthStencilAttachment#view}'s depth component * to prior to executing the render pass. Ignored if {@link GPURenderPassDepthStencilAttachment#depthLoadOp} diff --git a/packages/typegpu/src/core/texture/texture.ts b/packages/typegpu/src/core/texture/texture.ts index 0df1d6b41..8ba63bd8d 100644 --- a/packages/typegpu/src/core/texture/texture.ts +++ b/packages/typegpu/src/core/texture/texture.ts @@ -623,6 +623,9 @@ class TgpuFixedTextureViewImpl if (this.#descriptor?.arrayLayerCount !== undefined) { descriptor.arrayLayerCount = this.#descriptor.arrayLayerCount; } + if (this.#descriptor?.baseArrayLayer !== undefined) { + descriptor.baseArrayLayer = this.#descriptor.baseArrayLayer; + } this.#view = this.#baseTexture[$internal] .unwrap() diff --git a/packages/typegpu/src/data/texture.ts b/packages/typegpu/src/data/texture.ts index 11834f7e5..3b5c83ab0 100644 --- a/packages/typegpu/src/data/texture.ts +++ b/packages/typegpu/src/data/texture.ts @@ -178,7 +178,7 @@ export interface WgslTextureDepthMultisampled2d extends multisampled: true; }> { readonly type: 'texture_depth_multisampled_2d'; - readonly [$repr]: textureMultisampled2d; + readonly [$repr]: textureDepthMultisampled2d; } export interface WgslTextureDepth2dArray extends @@ -188,7 +188,7 @@ export interface WgslTextureDepth2dArray extends multisampled: false; }> { readonly type: 'texture_depth_2d_array'; - readonly [$repr]: texture2dArray; + readonly [$repr]: textureDepth2dArray; } export interface WgslTextureDepthCube extends @@ -198,7 +198,7 @@ export interface WgslTextureDepthCube extends multisampled: false; }> { readonly type: 'texture_depth_cube'; - readonly [$repr]: textureCube; + readonly [$repr]: textureDepthCube; } export interface WgslTextureDepthCubeArray extends @@ -208,7 +208,7 @@ export interface WgslTextureDepthCubeArray extends multisampled: false; }> { readonly type: 'texture_depth_cube_array'; - readonly [$repr]: textureCubeArray; + readonly [$repr]: textureDepthCubeArray; } // Storage textures diff --git a/packages/typegpu/tests/examples/individual/point-light-shadow.test.ts b/packages/typegpu/tests/examples/individual/point-light-shadow.test.ts new file mode 100644 index 000000000..4f107f2ad --- /dev/null +++ b/packages/typegpu/tests/examples/individual/point-light-shadow.test.ts @@ -0,0 +1,175 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect } from 'vitest'; +import { it } from '../../utils/extendedIt.ts'; +import { runExampleTest, setupCommonMocks } from '../utils/baseTest.ts'; +import { mockResizeObserver } from '../utils/commonMocks.ts'; + +describe('point light shadow example', () => { + setupCommonMocks(); + + it('should produce valid code', async ({ device }) => { + const shaderCodes = await runExampleTest({ + category: 'rendering', + name: 'point-light-shadow', + setupMocks: mockResizeObserver, + expectedCalls: 3, + }, device); + + expect(shaderCodes).toMatchInlineSnapshot(` + "struct CameraData_2 { + viewProjectionMatrix: mat4x4f, + inverseViewProjectionMatrix: mat4x4f, + } + + @group(0) @binding(0) var camera_1: CameraData_2; + + struct vertexDepth_Output_3 { + @builtin(position) pos: vec4f, + @location(0) worldPos: vec3f, + } + + struct vertexDepth_Input_4 { + @location(0) position: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, + @location(3) column1: vec4f, + @location(4) column2: vec4f, + @location(5) column3: vec4f, + @location(6) column4: vec4f, + } + + @vertex fn vertexDepth_0(_arg_0: vertexDepth_Input_4) -> vertexDepth_Output_3 { + var modelMatrix = mat4x4f(_arg_0.column1, _arg_0.column2, _arg_0.column3, _arg_0.column4); + var worldPos = (modelMatrix * vec4f(_arg_0.position, 1f)).xyz; + var pos = (camera_1.viewProjectionMatrix * vec4f(worldPos, 1f)); + return vertexDepth_Output_3(pos, worldPos); + } + + @group(0) @binding(1) var lightPosition_6: vec3f; + + struct fragmentDepth_Input_7 { + @location(0) worldPos: vec3f, + } + + @fragment fn fragmentDepth_5(_arg_0: fragmentDepth_Input_7) -> @builtin(frag_depth) f32 { + let dist = length((_arg_0.worldPos - lightPosition_6)); + return (dist / 100f); + } + + struct CameraData_2 { + viewProjectionMatrix: mat4x4f, + inverseViewProjectionMatrix: mat4x4f, + } + + @group(1) @binding(0) var camera_1: CameraData_2; + + struct vertexMain_Output_3 { + @builtin(position) pos: vec4f, + @location(0) worldPos: vec3f, + @location(1) uv: vec2f, + @location(2) normal: vec3f, + } + + struct vertexMain_Input_4 { + @location(0) position: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, + @location(3) column1: vec4f, + @location(4) column2: vec4f, + @location(5) column3: vec4f, + @location(6) column4: vec4f, + } + + @vertex fn vertexMain_0(_arg_0: vertexMain_Input_4) -> vertexMain_Output_3 { + var modelMatrix = mat4x4f(_arg_0.column1, _arg_0.column2, _arg_0.column3, _arg_0.column4); + var worldPos = (modelMatrix * vec4f(_arg_0.position, 1f)).xyz; + var pos = (camera_1.viewProjectionMatrix * vec4f(worldPos, 1f)); + var worldNormal = normalize((modelMatrix * vec4f(_arg_0.normal, 0f)).xyz); + return vertexMain_Output_3(pos, worldPos, _arg_0.uv, worldNormal); + } + + @group(1) @binding(3) var lightPosition_6: vec3f; + + struct item_8 { + pcfSamples: u32, + diskRadius: f32, + normalBiasBase: f32, + normalBiasSlope: f32, + } + + @group(0) @binding(0) var shadowParams_7: item_8; + + @group(0) @binding(1) var samplesUniform_9: array; + + @group(1) @binding(1) var shadowDepthCube_10: texture_depth_cube; + + @group(1) @binding(2) var shadowSampler_11: sampler_comparison; + + struct fragmentMain_Input_12 { + @location(0) worldPos: vec3f, + @location(1) uv: vec2f, + @location(2) normal: vec3f, + } + + @fragment fn fragmentMain_5(_arg_0: fragmentMain_Input_12) -> @location(0) vec4f { + let lightPos = (&lightPosition_6); + var toLight = ((*lightPos) - _arg_0.worldPos); + let dist = length(toLight); + var lightDir = (toLight / dist); + let ndotl = max(dot(_arg_0.normal, lightDir), 0f); + let normalBiasWorld = (shadowParams_7.normalBiasBase + (shadowParams_7.normalBiasSlope * (1f - ndotl))); + var biasedPos = (_arg_0.worldPos + (_arg_0.normal * normalBiasWorld)); + var toLightBiased = (biasedPos - (*lightPos)); + let distBiased = length(toLightBiased); + var dir = ((toLightBiased / distBiased) * vec3f(-1, 1, 1)); + let depthRef = (distBiased / 100f); + var up = select(vec3f(1, 0, 0), vec3f(0, 1, 0), (abs(dir.y) < 0.9998999834060669f)); + var right = normalize(cross(up, dir)); + var realUp = cross(dir, right); + let PCF_SAMPLES = shadowParams_7.pcfSamples; + let diskRadius = shadowParams_7.diskRadius; + var visibilityAcc = 0; + for (var i = 0; (i < i32(PCF_SAMPLES)); i++) { + var o = (samplesUniform_9[i].xy * diskRadius); + var sampleDir = ((dir + (right * o.x)) + (realUp * o.y)); + visibilityAcc += i32(textureSampleCompare(shadowDepthCube_10, shadowSampler_11, sampleDir, depthRef)); + } + let rawNdotl = dot(_arg_0.normal, lightDir); + let visibility = select((f32(visibilityAcc) / f32(PCF_SAMPLES)), 0f, (rawNdotl < 0f)); + var baseColor = vec3f(1, 0.5, 0.3100000023841858); + var color = (baseColor * ((ndotl * visibility) + 0.1f)); + return vec4f(color, 1f); + } + + @group(0) @binding(1) var lightPosition_1: vec3f; + + struct CameraData_3 { + viewProjectionMatrix: mat4x4f, + inverseViewProjectionMatrix: mat4x4f, + } + + @group(0) @binding(0) var camera_2: CameraData_3; + + struct vertexLightIndicator_Output_4 { + @builtin(position) pos: vec4f, + } + + struct vertexLightIndicator_Input_5 { + @location(0) position: vec3f, + } + + @vertex fn vertexLightIndicator_0(_arg_0: vertexLightIndicator_Input_5) -> vertexLightIndicator_Output_4 { + var worldPos = ((_arg_0.position * 0.15) + lightPosition_1); + var pos = (camera_2.viewProjectionMatrix * vec4f(worldPos, 1f)); + return vertexLightIndicator_Output_4(pos); + } + + @fragment fn fragmentLightIndicator_6() -> @location(0) vec4f { + return vec4f(1, 1, 0.5, 1); + }" + `); + }); +}); diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 22d3f3517..0dd3f383b 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -139,6 +139,52 @@ describe('TgpuRenderPipeline', () => { ).toEqualTypeOf>(); }); + it('properly handles custom depth output in fragment functions', ({ root }) => { + const vertices = tgpu.const(d.arrayOf(d.vec2f, 3), [ + d.vec2f(-1, -1), + d.vec2f(3, -1), + d.vec2f(-1, 3), + ]); + const vertexMain = tgpu['~unstable'].vertexFn({ + in: { vid: d.builtin.vertexIndex }, + out: { pos: d.builtin.position }, + // biome-ignore lint/style/noNonNullAssertion: it's fine + })(({ vid }) => ({ pos: d.vec4f(vertices.$[vid]!, 0, 1) })); + + const fragmentMain = tgpu['~unstable'].fragmentFn({ + out: { color: d.vec4f, depth: d.builtin.fragDepth }, + })(() => ({ color: d.vec4f(1, 0, 0, 1), depth: 0.5 })); + + const pipeline = root + .withVertex(vertexMain, {}) + .withFragment(fragmentMain, { color: { format: 'rgba8unorm' } }) + .createPipeline(); + + pipeline.withColorAttachment({ + color: { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + }, + }); + + expect(() => { + pipeline.withColorAttachment({ + color: { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + }, + // @ts-expect-error + depth: { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + }, + }); + }); + }); + it('type checks passed bind groups', ({ root }) => { const vertexMain = tgpu['~unstable'].vertexFn({ out: { bar: d.location(0, d.vec3f) },