diff --git a/apps/typegpu-docs/src/examples/simulation/cloth-mesh/cloth.ts b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/cloth.ts new file mode 100644 index 0000000000..b091bb0f23 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/cloth.ts @@ -0,0 +1,205 @@ +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { Camera } from '../../common/setup-orbit-camera.ts'; + +export const segments = 32; +export const timeStep = 1 / 720; + +const stride = segments + 1; +const size = 2.4; +const hangingWidth = 2; +const spacing = size / segments; + +export const sheet = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + return d.vec3f((u - 0.5) * hangingWidth, (0.5 - v) * size, 0.08 * std.sin(u * 8 * Math.PI)); + }, + }, + { cols: segments, rows: segments }, +); + +export const Pointer = d.struct({ position: d.vec2f, radius: d.vec2f }); +export const Params = d.struct({ time: d.f32, wind: d.f32, stiffness: d.f32 }); +export const Grab = d.struct({ + index: d.i32, + depth: d.f32, + offset: d.vec3f, + candidate: d.atomic(d.u32), +}); +export const Vertices = d.arrayOf(meshes.Surface, sheet.vertexCount); +export const Velocities = d.arrayOf(d.vec3f, sheet.vertexCount); +export const Forces = d.arrayOf(d.vec3f, sheet.vertexCount); + +export const verticesAccess = tgpu.mutableAccessor(Vertices); +export const velocityAccess = tgpu.mutableAccessor(Velocities); +export const forceAccess = tgpu.mutableAccessor(Forces); +export const grabAccess = tgpu.mutableAccessor(Grab); +export const cameraAccess = tgpu.accessor(Camera); +export const pointerAccess = tgpu.accessor(Pointer); +export const paramsAccess = tgpu.accessor(Params); + +const noCandidate = 0xffffffff; +const indexBits = 12; +const indexMask = (1 << indexBits) - 1; +const depthScale = (1 << (32 - indexBits)) - 1; + +export const released = { index: -1, depth: 0, offset: d.vec3f(), candidate: noCandidate }; + +const neighbors = tgpu.const(d.arrayOf(d.vec2i, 12), [ + d.vec2i(-1, 0), + d.vec2i(1, 0), + d.vec2i(0, -1), + d.vec2i(0, 1), + + d.vec2i(-1, -1), + d.vec2i(1, -1), + d.vec2i(-1, 1), + d.vec2i(1, 1), + + d.vec2i(-2, 0), + d.vec2i(2, 0), + d.vec2i(0, -2), + d.vec2i(0, 2), +]); + +export function isPinned(index: number) { + 'use gpu'; + return index <= segments && index % 8 === 0; +} + +function toClip(position: d.v3f) { + 'use gpu'; + const camera = cameraAccess.$; + return camera.projection * camera.view * d.vec4f(position, 1); +} + +function toWorld(ndc: d.v2f, depth: number) { + 'use gpu'; + const camera = cameraAccess.$; + const world = camera.viewInverse * camera.projectionInverse * d.vec4f(ndc, depth, 1); + return world.xyz / world.w; +} + +export const pick = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const i = gid.x; + if (i >= sheet.vertexCount || isPinned(i)) return; + + const clip = toClip(verticesAccess.$[i].position); + if (clip.w <= 0) return; + + const ndc = clip.xyz / clip.w; + const pointer = pointerAccess.$; + if (std.length((ndc.xy - pointer.position) / pointer.radius) > 1) return; + + const depth = d.u32(std.saturate(ndc.z) * depthScale); + std.atomicMin(grabAccess.$.candidate, (depth << indexBits) | i); +}); + +export const resolve = tgpu.computeFn({ workgroupSize: [1] })(() => { + 'use gpu'; + const key = std.atomicLoad(grabAccess.$.candidate); + std.atomicStore(grabAccess.$.candidate, noCandidate); + if (key === noCandidate) return; + + const index = d.i32(key & indexMask); + const position = verticesAccess.$[index].position; + const clip = toClip(position); + const depth = clip.z / clip.w; + + grabAccess.$.index = index; + grabAccess.$.depth = depth; + grabAccess.$.offset = position - toWorld(pointerAccess.$.position, depth); +}); + +export const forces = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const i = gid.x; + if (i >= sheet.vertexCount) return; + + if (isPinned(i) || d.i32(i) === grabAccess.$.index) { + forceAccess.$[i] = d.vec3f(); + return; + } + + const cell = d.vec2i(i % stride, std.intdiv(i, stride)); + const position = verticesAccess.$[i].position; + const velocity = velocityAccess.$[i]; + + const params = paramsAccess.$; + const gust = params.wind * (6 + 4 * std.sin(params.time * 2 + position.x * 3 + position.y)); + let force = + d.vec3f(params.wind * std.sin(params.time + position.y) * 2, -9.8, gust) * + (timeStep * timeStep); + + for (const offset of neighbors.$) { + const neighbor = cell + offset; + if (std.any(std.lt(neighbor, d.vec2i())) || std.any(std.gt(neighbor, d.vec2i(segments)))) { + continue; + } + + const neighborIndex = neighbor.y * stride + neighbor.x; + const delta = verticesAccess.$[neighborIndex].position - position; + const distance = std.max(std.length(delta), 0.000001); + const direction = delta / distance; + const restLength = std.length(d.vec2f(offset)) * spacing; + + const bending = std.any(std.eq(std.abs(offset), d.vec2i(2))); + const stiffness = params.stiffness * (bending ? 0.1 : 0.5); + const damping = bending ? 0 : 0.06; + const relativeVelocity = velocityAccess.$[neighborIndex] - velocity; + const stretchSpeed = std.dot(relativeVelocity, direction); + + force += direction * ((distance - restLength) * stiffness + stretchSpeed * damping); + } + + forceAccess.$[i] = d.vec3f(force); +}); + +export const integrate = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const i = gid.x; + if (i >= sheet.vertexCount || isPinned(i)) return; + + const vertex = verticesAccess.$[i]; + if (d.i32(i) === grabAccess.$.index) { + velocityAccess.$[i] = d.vec3f(); + const target = toWorld(pointerAccess.$.position, grabAccess.$.depth) + grabAccess.$.offset; + const delta = target - vertex.position; + vertex.position += delta * std.min(1, (8 * timeStep) / std.max(std.length(delta), 0.000001)); + } else { + velocityAccess.$[i] = velocityAccess.$[i] * 0.995 + forceAccess.$[i]; + vertex.position += velocityAccess.$[i]; + } +}); + +export const normals = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + const i = gid.x; + if (i >= sheet.vertexCount) return; + + const x = d.i32(i % stride); + const y = d.i32(std.intdiv(i, stride)); + + const left = verticesAccess.$[y * stride + std.max(x - 1, 0)].position; + const right = verticesAccess.$[y * stride + std.min(x + 1, segments)].position; + const top = verticesAccess.$[std.max(y - 1, 0) * stride + x].position; + const bottom = verticesAccess.$[std.min(y + 1, segments) * stride + x].position; + + const normal = std.cross(right - left, bottom - top); + verticesAccess.$[i].normal = normal / std.max(std.length(normal), 0.000001); +}); diff --git a/apps/typegpu-docs/src/examples/simulation/cloth-mesh/drag.ts b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/drag.ts new file mode 100644 index 0000000000..a9ab60df57 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/drag.ts @@ -0,0 +1,92 @@ +import { d } from 'typegpu'; +import type { Pointer } from './cloth.ts'; + +const grabRadius = 24; + +function suppressMenu(event: Event) { + event.preventDefault(); +} + +export function setupClothDrag( + canvas: HTMLCanvasElement, + { + onGrab, + onMove, + onRelease, + }: { + onGrab: (pointer: d.Infer) => void; + onMove: (position: d.v2f) => void; + onRelease: () => void; + }, +) { + const originalCursor = canvas.style.cursor; + let pointer: number | undefined; + + function pointerAt(event: PointerEvent) { + const rect = canvas.getBoundingClientRect(); + return { + position: d.vec2f( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + 1 - ((event.clientY - rect.top) / rect.height) * 2, + ), + radius: d.vec2f((2 * grabRadius) / rect.width, (2 * grabRadius) / rect.height), + }; + } + + function grab(event: PointerEvent) { + if (event.pointerType === 'touch' && !event.isPrimary) { + release(); + return; + } + if (pointer !== undefined || event.button !== 0 || event.shiftKey || !event.isPrimary) return; + event.preventDefault(); + pointer = event.pointerId; + canvas.setPointerCapture(pointer); + canvas.style.cursor = 'grabbing'; + onGrab(pointerAt(event)); + } + + function move(event: PointerEvent) { + if (event.pointerId === pointer) onMove(pointerAt(event).position); + } + + function release() { + if (pointer === undefined) return; + const id = pointer; + pointer = undefined; + if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id); + canvas.style.cursor = 'grab'; + onRelease(); + } + + function end(event: PointerEvent) { + if (event.pointerId === pointer) release(); + } + + canvas.style.cursor = 'grab'; + canvas.addEventListener('pointerdown', grab); + canvas.addEventListener('pointermove', move); + canvas.addEventListener('pointerup', end); + canvas.addEventListener('pointercancel', end); + canvas.addEventListener('lostpointercapture', end); + canvas.addEventListener('contextmenu', suppressMenu); + window.addEventListener('blur', release); + + return { + get active() { + return pointer !== undefined; + }, + release, + cleanup() { + release(); + canvas.removeEventListener('pointerdown', grab); + canvas.removeEventListener('pointermove', move); + canvas.removeEventListener('pointerup', end); + canvas.removeEventListener('pointercancel', end); + canvas.removeEventListener('lostpointercapture', end); + canvas.removeEventListener('contextmenu', suppressMenu); + window.removeEventListener('blur', release); + canvas.style.cursor = originalCursor; + }, + }; +} diff --git a/apps/typegpu-docs/src/examples/simulation/cloth-mesh/index.html b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/index.html new file mode 100644 index 0000000000..7355daffd0 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/simulation/cloth-mesh/index.ts b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/index.ts new file mode 100644 index 0000000000..c1ad59e1cc --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/index.ts @@ -0,0 +1,260 @@ +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { Camera, setupOrbitCamera } from '../../common/setup-orbit-camera.ts'; +import { defineControls } from '../../common/defineControls.ts'; +import { + Grab, + Params, + Pointer, + Velocities, + Forces, + sheet, + segments, + timeStep, + released, + verticesAccess, + velocityAccess, + forceAccess, + grabAccess, + cameraAccess, + pointerAccess, + paramsAccess, + pick, + resolve, + forces, + integrate, + normals, +} from './cloth.ts'; +import { setupClothDrag } from './drag.ts'; + +const root = await tgpu.init(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); + +const Scene = d.struct({ camera: Camera, pointer: Pointer, params: Params }); + +let cameraState = Camera(); +const scene = root.createUniform(Scene, { + camera: cameraState, + pointer: Pointer(), + params: { time: 0, wind: 1, stiffness: 0.2 }, +}); +const mesh = meshes.bake(root, sheet); +const velocity = root.createMutable(Velocities); +const force = root.createMutable(Forces); +const grab = root.createMutable(Grab, released); + +function createDepth() { + return root + .createTexture({ size: [canvas.width, canvas.height], format: 'depth24plus' }) + .$usage('transient'); +} +let depth = createDepth(); + +const observer = new ResizeObserver(() => { + depth.destroy(); + depth = createDepth(); +}); +observer.observe(canvas); + +const simulation = root + .with(verticesAccess, mesh.vertices.as('mutable')) + .with(velocityAccess, velocity) + .with(forceAccess, force) + .with(grabAccess, grab) + .with(cameraAccess, () => scene.$.camera) + .with(pointerAccess, () => scene.$.pointer) + .with(paramsAccess, () => scene.$.params); + +const pickPipeline = simulation.createComputePipeline({ compute: pick }); +const resolvePipeline = simulation.createComputePipeline({ compute: resolve }); +const forcePipeline = simulation.createComputePipeline({ compute: forces }); +const integratePipeline = simulation.createComputePipeline({ compute: integrate }); +const normalPipeline = simulation.createComputePipeline({ compute: normals }); + +const pipeline = root + .createRenderPipeline({ + attribs: mesh.layout.attrib, + vertex: ({ position, normal, uv }) => { + 'use gpu'; + const camera = scene.$.camera; + return { + $position: camera.projection * camera.view * d.vec4f(position, 1), + worldPos: position, + normal, + uv, + }; + }, + fragment: ({ worldPos, normal, uv, $frontFacing }) => { + 'use gpu'; + const n = std.normalize(normal) * ($frontFacing ? 1 : -1); + + const cell = std.floor(uv * 12); + const pattern = (cell.x + cell.y) % 2; + let color = std.mix(d.vec3f(0.035, 0.32, 0.36), d.vec3f(0.82, 0.69, 0.43), pattern); + + const edge = std.min(uv, 1 - uv); + color = std.mix( + d.vec3f(0.025, 0.13, 0.16), + color, + std.smoothstep(0.012, 0.02, std.min(edge.x, edge.y)), + ); + + const threadUV = uv * 180; + const threadCell = std.floor(threadUV); + const crossing = (threadCell.x + threadCell.y) % 2; + const profile = std.sin(std.fract(threadUV) * Math.PI); + const thread = std.mix(profile.x, profile.y, crossing); + const footprint = std.fwidth(threadUV); + const detail = 1 - std.smoothstep(0.5, 1, std.max(footprint.x, footprint.y)); + const weave = std.mix(1, 0.88 + 0.18 * thread, detail); + color *= weave; + + const index = grab.$.index; + if (index >= 0) { + const grabbedUV = + d.vec2f(index % (segments + 1), std.intdiv(index, segments + 1)) / segments; + const highlight = 1 - std.smoothstep(0.015, 0.035, std.distance(uv, grabbedUV)); + color = std.mix(color, d.vec3f(1, 0.8, 0.3), highlight); + } + + const light = std.normalize(d.vec3f(0.8, 0.6, 0.7)); + const view = std.normalize(scene.$.camera.position.xyz - worldPos); + const diffuse = 0.22 + 0.75 * std.saturate(std.dot(n, light)); + const grazing = 1 - std.saturate(std.dot(n, view)); + const sheen = 0.3 * grazing * grazing * weave; + const fibers = std.mix(color, d.vec3f(0.9, 0.88, 0.82), 0.6); + + return d.vec4f(color * diffuse + fibers * sheen, 1); + }, + depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, + }) + .pipe(mesh.inject()); + +let picking = false; + +const drag = setupClothDrag(canvas, { + onGrab: (pointer) => { + scene.patch({ pointer }); + picking = true; + }, + onMove: (position) => scene.patch({ pointer: { position } }), + onRelease: () => { + picking = false; + grab.write(released); + targetCamera(cameraState.position, cameraState.targetPos); + }, +}); + +const { cleanupCamera, targetCamera } = setupOrbitCamera( + canvas, + { + initPos: d.vec4f(2.2, 0.9, 3.7, 1), + target: d.vec4f(0, -0.1, 0, 1), + minZoom: 1.5, + maxZoom: 8, + }, + (updates) => { + if (drag.active && updates.position) return; + cameraState = { ...cameraState, ...updates }; + scene.patch({ camera: updates }); + }, +); + +const workgroups = Math.ceil(mesh.vertexCount / 64); + +let paused = false; +let previous = 0; +let accumulated = 0; +let time = 0; +let frame: number; + +function render(now: number) { + const delta = previous === 0 ? 0 : Math.min((now - previous) * 0.001, 1 / 30); + previous = now; + + const encoder = root['~unstable'].createCommandEncoder(); + const pass = encoder.beginComputePass(); + + if (picking) { + picking = false; + pickPipeline.with(pass).dispatchWorkgroups(workgroups); + resolvePipeline.with(pass).dispatchWorkgroups(1); + } + + if (!paused) { + accumulated += delta; + const steps = Math.floor(accumulated / timeStep); + accumulated -= steps * timeStep; + time += steps * timeStep; + scene.patch({ params: { time } }); + + for (let i = 0; i < steps; i++) { + forcePipeline.with(pass).dispatchWorkgroups(workgroups); + integratePipeline.with(pass).dispatchWorkgroups(workgroups); + } + if (steps > 0) normalPipeline.with(pass).dispatchWorkgroups(workgroups); + } + + pass.end(); + + pipeline + .with(encoder) + .withColorAttachment({ view: context, clearValue: [0.035, 0.045, 0.065, 1] }) + .withDepthStencilAttachment({ view: depth, depthStoreOp: 'discard' }) + .drawIndexed(mesh.indexCount); + + encoder.submit(); + + frame = requestAnimationFrame(render); +} + +frame = requestAnimationFrame(render); + +export const controls = defineControls({ + Wind: { + initial: 1, + min: 0, + max: 2, + step: 0.1, + onSliderChange(value) { + scene.patch({ params: { wind: value } }); + }, + }, + Stiffness: { + initial: 0.2, + min: 0.15, + max: 0.3, + step: 0.01, + onSliderChange(value) { + scene.patch({ params: { stiffness: value } }); + }, + }, + Pause: { + initial: false, + onToggleChange(value) { + paused = value; + }, + }, + Reset: { + onButtonClick() { + drag.release(); + mesh.updateVertices(); + velocity.buffer.clear(); + + time = 0; + accumulated = 0; + previous = 0; + scene.patch({ params: { time } }); + }, + }, +}); + +export function onCleanup() { + cancelAnimationFrame(frame); + observer.disconnect(); + drag.cleanup(); + cleanupCamera(); + + root.destroy(); +} diff --git a/apps/typegpu-docs/src/examples/simulation/cloth-mesh/meta.json b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/meta.json new file mode 100644 index 0000000000..c8f602f6f2 --- /dev/null +++ b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Cloth Mesh", + "description": "Bakes a cloth mesh once, then simulates wind, spring forces, and interactive dragging directly in its vertex buffer.", + "category": "simulation", + "tags": ["3d", "geometry", "physics", "compute", "rasterization"], + "coolFactor": 7 +} diff --git a/apps/typegpu-docs/src/examples/simulation/cloth-mesh/thumbnail.png b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/thumbnail.png new file mode 100644 index 0000000000..08740cc00e Binary files /dev/null and b/apps/typegpu-docs/src/examples/simulation/cloth-mesh/thumbnail.png differ diff --git a/apps/typegpu-docs/tests/individual-example-tests/cloth-mesh.test.ts b/apps/typegpu-docs/tests/individual-example-tests/cloth-mesh.test.ts new file mode 100644 index 0000000000..bf512224a4 --- /dev/null +++ b/apps/typegpu-docs/tests/individual-example-tests/cloth-mesh.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, vi } from 'vitest'; +import { it } from 'typegpu-testing-utility'; +import { setupClothDrag } from '../../src/examples/simulation/cloth-mesh/drag.ts'; +import { setupCommonMocks, mockResizeObserver } from './utils/commonMocks.ts'; +import { extractShaderCodes } from './utils/testUtils.ts'; + +function pointerEvent(type: string, values: Partial = {}) { + return Object.assign(new Event(type), { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + button: 0, + clientX: 128, + clientY: 128, + ...values, + }); +} + +function createCanvas() { + document.body.innerHTML = ''; + const canvas = document.querySelector('canvas')!; + canvas.setPointerCapture = vi.fn(); + canvas.hasPointerCapture = vi.fn(() => true); + canvas.releasePointerCapture = vi.fn(); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 256, 256)); + return canvas; +} + +describe('cloth mesh example', () => { + setupCommonMocks(); + + it('releases dragging for pinch gestures and lost capture, and removes its listeners', () => { + const canvas = createCanvas(); + const onGrab = vi.fn(); + const onRelease = vi.fn(); + const drag = setupClothDrag(canvas, { onGrab, onMove: vi.fn(), onRelease }); + + canvas.dispatchEvent(pointerEvent('pointerdown')); + expect(drag.active).toBe(true); + canvas.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2, isPrimary: false })); + expect(drag.active).toBe(false); + expect(canvas.releasePointerCapture).toHaveBeenCalledWith(1); + expect(onRelease).toHaveBeenCalledTimes(1); + + canvas.dispatchEvent(pointerEvent('pointerdown')); + canvas.dispatchEvent(pointerEvent('lostpointercapture')); + expect(drag.active).toBe(false); + expect(onRelease).toHaveBeenCalledTimes(2); + + drag.cleanup(); + canvas.dispatchEvent(pointerEvent('pointerdown')); + expect(onGrab).toHaveBeenCalledTimes(2); + expect(canvas.style.cursor).toBe(''); + }); + + it('resolves GPU picking, simulation, and rendering without reading the vertex buffer', async ({ + device, + }) => { + mockResizeObserver(); + const canvas = createCanvas(); + let frame: FrameRequestCallback = () => {}; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frame = callback; + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + const example = await import('../../src/examples/simulation/cloth-mesh/index.ts'); + try { + frame(1); + canvas.dispatchEvent(pointerEvent('pointerdown')); + frame(17); + const shaders = extractShaderCodes(device); + expect(shaders).toContain('atomicMin('); + expect(shaders).toContain('atomicLoad('); + expect(shaders).toContain('@fragment'); + expect(device.mock.createShaderModule).toHaveBeenCalledTimes(8); + expect( + device.mock.createBuffer.mock.results.every( + ({ value }) => !value.mapAsync.mock.calls.length, + ), + ).toBe(true); + } finally { + example.onCleanup(); + } + }); +});