diff --git a/apps/typegpu-docs/astro.config.mjs b/apps/typegpu-docs/astro.config.mjs index 3e4a0ae245..b6cdc938a1 100644 --- a/apps/typegpu-docs/astro.config.mjs +++ b/apps/typegpu-docs/astro.config.mjs @@ -337,6 +337,11 @@ export default defineConfig({ label: '@typegpu/radiance-cascades', slug: 'ecosystem/typegpu-radiance-cascades', }, + DEV && { + label: '@typegpu/geometry', + slug: 'ecosystem/typegpu-geometry', + badge: { text: 'dev', variant: 'note' }, + }, DEV && { label: '@typegpu/color', slug: 'ecosystem/typegpu-color', diff --git a/apps/typegpu-docs/src/components/geometry/MeshPreview.tsx b/apps/typegpu-docs/src/components/geometry/MeshPreview.tsx new file mode 100644 index 0000000000..1991421e21 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/MeshPreview.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react'; +import { demos, defaultParams, type DemoName } from './demos.ts'; +import { createMeshViewer, type MeshViewer, type PreviewState, type Shading } from './viewer.ts'; + +export default function MeshPreview({ demo }: { demo: DemoName }) { + return ; +} + +function Preview({ demo }: { demo: DemoName }) { + const spec = demos[demo]; + const canvasRef = useRef(null); + const [viewer, setViewer] = useState(null); + const [state, setState] = useState({ status: 'loading' }); + const [shading, setShading] = useState('smooth'); + const [paused, setPaused] = useState(false); + const [params, setParams] = useState(() => defaultParams(spec)); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + let disposed = false; + let visible = false; + let instance: MeshViewer | undefined; + const updateActivity = () => instance?.setActive(visible && !document.hidden); + const observer = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + updateActivity(); + }); + observer.observe(canvas); + document.addEventListener('visibilitychange', updateActivity); + setPaused(window.matchMedia('(prefers-reduced-motion: reduce)').matches); + + createMeshViewer(canvas, (value) => { + if (!disposed) setState(value); + }) + .then((value) => { + if (disposed) { + value.destroy(); + return; + } + instance = value; + updateActivity(); + setViewer(value); + }) + .catch((error: unknown) => { + if (!disposed) + setState({ + status: 'error', + message: error instanceof Error ? error.message : String(error), + }); + }); + + return () => { + disposed = true; + observer.disconnect(); + document.removeEventListener('visibilitychange', updateActivity); + instance?.destroy(); + }; + }, []); + + useEffect(() => { + viewer?.show(spec, params); + }, [viewer, spec, params]); + useEffect(() => { + viewer?.setShading(shading); + }, [viewer, shading]); + useEffect(() => { + viewer?.setPaused(paused); + }, [viewer, paused]); + + return ( + + + + {state.status === 'loading' && ( + + Loading preview… + + )} + {state.status === 'error' && ( + + Could not render preview: {state.message} + + )} + + + Preview controls + {spec.params.map((p) => { + const set = (value: number) => setParams((previous) => ({ ...previous, [p.key]: value })); + return ( + + + {p.label} + {p.kind === 'range' && {params[p.key]}} + + {p.kind === 'select' ? ( + set(Number(e.target.value))} + value={params[p.key]} + > + {p.options.map((option, index) => ( + + {option} + + ))} + + ) : ( + set(Number(e.target.value))} + step={p.step} + type="range" + value={params[p.key]} + /> + )} + + ); + })} + + View + setShading(e.target.value as Shading)} + value={shading} + > + Smooth + Flat + Wireframe + + + setPaused((value) => !value)} + type="button" + > + {paused ? 'Resume animation' : 'Pause animation'} + + + + ); +} diff --git a/apps/typegpu-docs/src/components/geometry/demos.ts b/apps/typegpu-docs/src/components/geometry/demos.ts new file mode 100644 index 0000000000..a959f03ab1 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/demos.ts @@ -0,0 +1,194 @@ +import { meshes } from '@typegpu/geometry'; +import { d, std, tgpu, type TgpuCommandEncoder, type TgpuRoot, type TgpuUniform } from 'typegpu'; +import { Paint, type VertexSchema, type Scene } from './shaders.ts'; + +const TAU = Math.PI * 2; +const SHAPES = [ + 'sphere', + 'box', + 'cylinder', + 'torus', + 'plane', + 'icosphere', + 'capsule', + 'rounded box', +] as const; + +type Param = { key: string; label: string; value: number } & ( + | { kind: 'select'; options: readonly string[] } + | { kind: 'range'; min: number; max: number; step: number } +); +export type PreviewMesh = meshes.BakedIndexed; +export type DemoContext = { root: TgpuRoot; uniforms: TgpuUniform }; +export interface Demo { + description: string; + params: Param[]; + rebuild: string[]; + animated?: boolean; + build(params: Record, ctx: DemoContext): meshes.IndexedGeometry; + update?(mesh: PreviewMesh, ctx: DemoContext): Promise<(encoder: TgpuCommandEncoder) => void>; +} + +export const demos: Record = { + primitives: { + description: 'A primitive mesh with adjustable tessellation', + params: [ + { key: 'shape', label: 'Shape', kind: 'select', options: SHAPES, value: 0 }, + { key: 'detail', label: 'Detail', kind: 'range', min: 3, max: 48, step: 1, value: 16 }, + ], + rebuild: ['shape', 'detail'], + build: ({ shape, detail }) => { + switch (SHAPES[shape]) { + case 'icosphere': + return meshes.icosphere({ segments: detail }); + case 'capsule': + return meshes.capsule({ segments: detail }); + case 'rounded box': + return meshes.roundedBox({ segments: detail }); + case 'box': + return meshes.box({ + widthSegments: detail, + heightSegments: detail, + depthSegments: detail, + }); + case 'cylinder': + return meshes.cylinder({ radialSegments: detail }); + case 'torus': + return meshes.torus({ ringSegments: detail * 2, tubeSegments: detail }); + case 'plane': + return meshes.plane({ widthSegments: detail, depthSegments: detail }); + default: + return meshes.sphere({ radialSegments: detail * 2, heightSegments: detail }); + } + }, + }, + ripple: { + description: 'A parametric ripple sampled on an adjustable grid', + params: [ + { key: 'cols', label: 'Columns', kind: 'range', min: 1, max: 64, step: 1, value: 24 }, + { key: 'rows', label: 'Rows', kind: 'range', min: 1, max: 64, step: 1, value: 24 }, + { + key: 'amplitude', + label: 'Amplitude', + kind: 'range', + min: 0, + max: 0.3, + step: 0.01, + value: 0.1, + }, + { + key: 'frequency', + label: 'Frequency', + kind: 'range', + min: 1, + max: 24, + step: 0.5, + value: 10, + }, + ], + rebuild: ['cols', 'rows'], + build: ({ cols, rows }, { uniforms }) => + meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + const amplitude = uniforms.$.params.amplitude; + const frequency = uniforms.$.params.frequency; + const height = amplitude * std.sin(u * frequency) * std.cos(v * frequency); + return d.vec3f(u - 0.5, height, 0.5 - v); + }, + }, + { cols, rows }, + ), + }, + gyroscope: { + description: 'Torus meshes rotated and combined into concentric rings', + params: [ + { key: 'count', label: 'Rings', kind: 'range', min: 1, max: 8, step: 1, value: 3 }, + { key: 'tilt', label: 'Tilt', kind: 'range', min: 0, max: 1.5, step: 0.05, value: 0.6 }, + ], + rebuild: ['count'], + build: ({ count }, { uniforms }) => { + const ring = meshes.torus({ radius: 0.45, tube: 0.04, ringSegments: 48, tubeSegments: 12 }); + const tilted = meshes.map(ring, meshes.Surface, (v) => { + 'use gpu'; + const rotation = std.rotationX4(uniforms.$.params.tilt); + return meshes.Surface({ + position: (rotation * d.vec4f(v.position, 1)).xyz, + normal: (rotation * d.vec4f(v.normal, 0)).xyz, + uv: v.uv, + }); + }); + return meshes.concat( + ...Array.from({ length: count }, (_, i) => + meshes.transform(tilted, std.rotationY4((i * Math.PI) / count)), + ), + ); + }, + }, + stripes: { + description: 'A torus with vertex colors forming a twisting striped pattern', + params: [ + { key: 'stripes', label: 'Stripes', kind: 'range', min: 1, max: 24, step: 1, value: 8 }, + { key: 'twist', label: 'Twist', kind: 'range', min: 0, max: 6, step: 0.25, value: 2 }, + { key: 'hue', label: 'Hue', kind: 'range', min: 0, max: 1, step: 0.01, value: 0 }, + ], + rebuild: [], + build: (_, { uniforms }) => + meshes.attach(meshes.torus({ ringSegments: 128, tubeSegments: 48 }), Paint, (s) => { + 'use gpu'; + const stripes = uniforms.$.params.stripes; + const twist = uniforms.$.params.twist; + const hue = uniforms.$.params.hue; + const wave = std.sin((s.uv.x * stripes + s.uv.y * twist) * TAU); + const phases = (hue + s.uv.x + d.vec3f(0, 0.33, 0.67)) * TAU; + const palette = std.cos(phases) * 0.5 + 0.5; + return Paint({ color: palette * (0.55 + 0.45 * wave) }); + }), + }, + deformation: { + description: 'A subdivided plane deformed in place by a compute shader', + params: [ + { + key: 'amplitude', + label: 'Amplitude', + kind: 'range', + min: 0, + max: 0.3, + step: 0.01, + value: 0.1, + }, + { key: 'frequency', label: 'Frequency', kind: 'range', min: 1, max: 16, step: 0.5, value: 6 }, + ], + rebuild: [], + animated: true, + build: () => meshes.plane({ width: 1.4, depth: 1.4, widthSegments: 64, depthSegments: 64 }), + update: async (mesh, { root, uniforms }) => { + const vertices = mesh.vertices.as('mutable'); + const sway = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, + })(({ gid }) => { + 'use gpu'; + if (gid.x >= mesh.vertexCount) return; + + const amplitude = uniforms.$.params.amplitude; + const frequency = uniforms.$.params.frequency; + const v = vertices.$[gid.x]; + const phase = v.position.x * frequency + uniforms.$.time; + v.position.y = amplitude * std.sin(phase); + v.normal = std.normalize(d.vec3f(-amplitude * frequency * std.cos(phase), 1, 0)); + }); + const pipeline = root.createComputePipeline({ compute: sway }); + await pipeline.initAsync(); + const workgroups = Math.ceil(mesh.vertexCount / 64); + return (encoder) => pipeline.with(encoder).dispatchWorkgroups(workgroups); + }, + }, +}; + +export type DemoName = 'primitives' | 'ripple' | 'gyroscope' | 'stripes' | 'deformation'; + +export function defaultParams(demo: Demo) { + return Object.fromEntries(demo.params.map((p) => [p.key, p.value])); +} diff --git a/apps/typegpu-docs/src/components/geometry/renderer.ts b/apps/typegpu-docs/src/components/geometry/renderer.ts new file mode 100644 index 0000000000..54b2d62401 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/renderer.ts @@ -0,0 +1,84 @@ +import type { PreviewMesh } from './demos.ts'; +import { d, type TgpuCommandEncoder, type TgpuRoot, type TgpuUniform } from 'typegpu'; +import * as m from 'wgpu-matrix'; +import { fragment, sceneAccess, vertexShader, type Scene, type VertexSchema } from './shaders.ts'; + +export function createRenderer( + root: TgpuRoot, + canvas: HTMLCanvasElement, + uniforms: TgpuUniform, +) { + const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); + let depth = root + .createTexture({ size: [canvas.width, canvas.height], format: 'depth24plus' }) + .$usage('transient'); + const projection = d.mat4x4f(); + const view = d.mat4x4f(); + const viewProj = d.mat4x4f(); + const eye = d.vec3f(); + const origin = d.vec3f(); + const up = d.vec3f(0, 1, 0); + + function createPipeline(schema: VertexSchema) { + const { layout, vertex } = vertexShader(schema); + const pipeline = root.with(sceneAccess, uniforms).createRenderPipeline({ + vertex, + fragment, + depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, + }); + return { layout, pipeline }; + } + const pipelines = new Map>(); + + return { + async prepare(mesh: PreviewMesh) { + let cached = pipelines.get(mesh.schema); + if (!cached) { + cached = createPipeline(mesh.schema); + pipelines.set(mesh.schema, cached); + } + const pipeline = cached.pipeline + .with( + root.createBindGroup(cached.layout, { + vertices: mesh.vertices, + indices: mesh.indices, + }), + ) + .withIndexBuffer(mesh.indices); + await pipeline.initAsync(); + + return (encoder: TgpuCommandEncoder, wireframe: boolean) => { + const draw = pipeline + .with(encoder) + .withColorAttachment({ view: context, clearValue: [0.09, 0.082, 0.149, 1] }) + .withDepthStencilAttachment({ view: depth, depthStoreOp: 'discard' }); + if (wireframe) draw.draw(mesh.indexCount); + else draw.drawIndexed(mesh.indexCount); + }; + }, + updateCamera(seconds: number) { + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.min(1200, Math.max(1, Math.round(canvas.clientWidth * ratio))); + const height = Math.min(900, Math.max(1, Math.round(canvas.clientHeight * ratio))); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + depth.destroy(); + depth = root + .createTexture({ size: [width, height], format: 'depth24plus' }) + .$usage('transient'); + } + m.mat4.perspective(Math.PI / 5, width / height, 0.1, 100, projection); + const angle = seconds * 0.4; + eye.x = Math.cos(angle) * 2.6; + eye.y = 1.3; + eye.z = Math.sin(angle) * 2.6; + m.mat4.lookAt(eye, origin, up, view); + m.mat4.multiply(projection, view, viewProj); + uniforms.patch({ viewProj }); + }, + destroy() { + depth.destroy(); + }, + }; +} diff --git a/apps/typegpu-docs/src/components/geometry/shaders.ts b/apps/typegpu-docs/src/components/geometry/shaders.ts new file mode 100644 index 0000000000..b7e432ed01 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/shaders.ts @@ -0,0 +1,81 @@ +import { meshes } from '@typegpu/geometry'; +import { d, std, tgpu } from 'typegpu'; + +export const Parameters = d.struct({ + amplitude: d.f32, + frequency: d.f32, + tilt: d.f32, + stripes: d.f32, + twist: d.f32, + hue: d.f32, +}); +export const Scene = d.struct({ + viewProj: d.mat4x4f, + time: d.f32, + flat: d.u32, + wireframe: d.u32, + params: d.align(16, Parameters), +}); +export const Paint = d.struct({ color: d.vec3f }); +export type Painted = meshes.Attached; +export type VertexSchema = typeof meshes.Surface | Painted; + +export const sceneAccess = tgpu.accessor(Scene); +const lightDirection = std.normalize(d.vec3f(0.5, 1, 0.35)); +const varyings = { + worldPos: d.vec3f, + normal: d.vec3f, + color: d.vec3f, + barycentric: d.interpolate('linear', d.vec3f), +}; + +export const fragment = tgpu.fragmentFn({ + in: { ...varyings, front: d.builtin.frontFacing }, + out: d.vec4f, +})(({ worldPos, normal, color, barycentric, front }) => { + 'use gpu'; + const smooth = std.normalize(normal); + const faceted = std.normalize(std.cross(std.dpdx(worldPos), std.dpdy(worldPos))); + const oriented = faceted * std.sign(std.dot(faceted, smooth)); + const n = + std.select(smooth, oriented, sceneAccess.$.flat === 1) * std.select(-1, d.f32(1), front); + const diffuse = std.max(std.dot(n, lightDirection), 0); + const sky = n.y * 0.5 + 0.5; + const lit = color * (0.2 + 0.25 * sky + 0.6 * diffuse); + + const edges = std.smoothstep(d.vec3f(0), std.fwidth(barycentric) * 1.2, barycentric); + const interior = std.min(edges.x, std.min(edges.y, edges.z)); + const wire = std.mix(d.vec3f(0.65, 0.8, 1), lit * 0.25, interior); + return d.vec4f(std.select(lit, wire, sceneAccess.$.wireframe === 1), 1); +}); + +export function vertexShader(schema: VertexSchema) { + const layout = tgpu.bindGroupLayout({ + vertices: { storage: d.arrayOf(schema) }, + indices: { storage: d.arrayOf(d.u32) }, + }); + const painted = 'color' in schema.propTypes; + const vertex = tgpu.vertexFn({ + in: { vid: d.builtin.vertexIndex }, + out: { pos: d.builtin.position, ...varyings }, + })(({ vid }) => { + 'use gpu'; + let index = vid; + if (sceneAccess.$.wireframe === 1) index = layout.$.indices[vid]; + const v = layout.$.vertices[index]; + const color = painted ? (v as d.InferGPU).color : d.vec3f(0.55, 0.7, 0.95); + const corner = vid % 3; + return { + pos: sceneAccess.$.viewProj * d.vec4f(v.position, 1), + worldPos: v.position, + normal: v.normal, + color, + barycentric: std.select( + d.vec3f(0), + d.vec3f(1), + d.vec3b(corner === 0, corner === 1, corner === 2), + ), + }; + }); + return { layout, vertex }; +} diff --git a/apps/typegpu-docs/src/components/geometry/viewer.ts b/apps/typegpu-docs/src/components/geometry/viewer.ts new file mode 100644 index 0000000000..8c5c93c098 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/viewer.ts @@ -0,0 +1,159 @@ +import { tgpu, type TgpuCommandEncoder, type TgpuRoot } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { type Demo, type PreviewMesh } from './demos.ts'; +import { createRenderer } from './renderer.ts'; +import { Parameters, Scene } from './shaders.ts'; + +export type Shading = 'smooth' | 'flat' | 'wireframe'; +export type PreviewState = { status: 'loading' | 'ready' } | { status: 'error'; message: string }; +export interface MeshViewer { + show(demo: Demo, params: Record): void; + setShading(shading: Shading): void; + setActive(active: boolean): void; + setPaused(paused: boolean): void; + destroy(): void; +} + +let sharedRoot: Promise | undefined; + +export async function createMeshViewer( + canvas: HTMLCanvasElement, + onState: (state: PreviewState) => void = () => {}, +): Promise { + const root = await (sharedRoot ??= tgpu.init()); + const uniforms = root.createUniform(Scene); + const renderer = createRenderer(root, canvas, uniforms); + const ctx = { root, uniforms }; + let current: + | { + demo: Demo; + params: Record; + mesh: PreviewMesh; + update(encoder: TgpuCommandEncoder): void; + draw(encoder: TgpuCommandEncoder, wireframe: boolean): void; + } + | undefined; + let pending: { demo: Demo; params: Record } | undefined; + let building = false; + let disposed = false; + let failed = false; + let active = true; + let paused = false; + let dirty = true; + let shading: Shading = 'smooth'; + let seconds = 0; + let previous: number | undefined; + + function releaseResources() { + current?.mesh.destroy(); + renderer.destroy(); + uniforms.buffer.destroy(); + } + + function fail(error: unknown) { + failed = true; + active = false; + cancelAnimationFrame(frame); + onState({ status: 'error', message: error instanceof Error ? error.message : String(error) }); + } + + async function build(demo: Demo, params: Record) { + building = true; + if (!current) onState({ status: 'loading' }); + let allocated: PreviewMesh | undefined; + try { + const mesh = await meshes.bakeAsync(root, demo.build(params, ctx)); + allocated = mesh; + if (disposed) { + mesh.destroy(); + return; + } + const [draw, deform] = await Promise.all([renderer.prepare(mesh), demo.update?.(mesh, ctx)]); + if (disposed) { + mesh.destroy(); + return; + } + const update = deform ?? ((encoder: TgpuCommandEncoder) => mesh.updateVertices({ encoder })); + current?.mesh.destroy(); + current = { demo, params, mesh, update, draw }; + dirty = deform !== undefined; + onState({ status: 'ready' }); + } catch (error) { + allocated?.destroy(); + if (!disposed) fail(error); + } finally { + building = false; + if (disposed) releaseResources(); + } + } + + function render(now: number) { + if (!active || disposed) return; + try { + if (!paused && previous !== undefined) seconds += Math.min((now - previous) * 0.001, 0.1); + previous = now; + uniforms.patch({ time: seconds }); + if (pending && !building) { + const { demo, params } = pending; + pending = undefined; + const values = Parameters(); + for (const key of Object.keys(values) as (keyof typeof values)[]) { + values[key] = params[key] ?? 0; + } + uniforms.patch({ params: values }); + const displayed = current; + if ( + displayed?.demo === demo && + demo.rebuild.every((key) => displayed.params[key] === params[key]) + ) { + displayed.params = params; + dirty = true; + } else { + void build(demo, params); + } + } + renderer.updateCamera(seconds); + if (current) { + const encoder = root['~unstable'].createCommandEncoder(); + if (dirty || (current.demo.animated && !paused)) current.update(encoder); + current.draw(encoder, shading === 'wireframe'); + encoder.submit(); + dirty = false; + } + if (active) frame = requestAnimationFrame(render); + } catch (error) { + fail(error); + } + } + let frame = requestAnimationFrame(render); + + return { + show(demo, params) { + pending = { demo, params }; + }, + setShading(value) { + shading = value; + uniforms.patch({ + flat: value === 'flat' ? 1 : 0, + wireframe: value === 'wireframe' ? 1 : 0, + }); + }, + setActive(value) { + if (disposed || failed || active === value) return; + active = value; + previous = undefined; + if (active) frame = requestAnimationFrame(render); + else cancelAnimationFrame(frame); + }, + setPaused(value) { + paused = value; + }, + destroy() { + if (disposed) return; + disposed = true; + cancelAnimationFrame(frame); + // A pending bake still needs the uniforms after its pipelines finish compiling + if (!building) releaseResources(); + }, + }; +} diff --git a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx new file mode 100644 index 0000000000..9b006a520a --- /dev/null +++ b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx @@ -0,0 +1,558 @@ +--- +title: "@typegpu/geometry" +description: Procedural meshes described as functions, composed on the CPU and resolved into shaders, and lines of varying width with joins and caps. +--- + +import MeshPreview from '../../../components/geometry/MeshPreview.tsx'; + +`@typegpu/geometry` builds meshes and [lines](#lines) in shaders. +Its `meshes` namespace provides 3D primitives, parametric surfaces and functions for transforming and combining them. +A geometry describes its vertices through a schema, a count and a `vertexAt` function. Indexed geometries also provide `indexCount` and `indexAt`. +You can evaluate these functions in a vertex shader or bake their output into buffers using compute. + +:::caution +The package is under active development and its API may still change. +::: + +## Primitives + +Primitives fit in a unit cube around the origin by default and use counter-clockwise winding. Options control their size and subdivisions. + +```ts twoslash +import { meshes } from '@typegpu/geometry'; + +const ball = meshes.sphere({ radius: 0.5, radialSegments: 32, heightSegments: 16 }); +const ground = meshes.plane({ width: 4, depth: 4 }); +const crate = meshes.box({ width: 1, height: 1, depth: 1 }); +const can = meshes.cylinder({ radius: 0.5, height: 1, radialSegments: 32, caps: true }); +const ring = meshes.torus({ radius: 0.35, tube: 0.15 }); +const ico = meshes.icosphere({ radius: 0.5, segments: 4 }); +const capsule = meshes.capsule({ radius: 0.25, height: 0.5 }); +const rounded = meshes.roundedBox({ radius: 0.1 }); +``` + +Every option has a default, so `meshes.box()` is a unit cube and `meshes.torus()` a ring that fits the same space. +Options ending in `Segments` count the subdivisions along one direction: `radialSegments` around the vertical axis of a sphere or cylinder, `heightSegments` from top to bottom. The `segments` option of `icosphere`, `capsule` and `roundedBox` subdivides each edge of their [triangular patches](#triangular-patches). +Switch the previews to **Wireframe** to inspect their triangles. **Pause animation** freezes both rotation and deformation. + + + +Primitives use the `Surface` schema: `position: d.vec3f`, `normal: d.vec3f` and `uv: d.vec2f`. Normals point outwards. UVs follow each surface's parametrization. +`capsule.height` is the length of its straight section, excluding the caps. +Primitives can also be evaluated on the CPU. Geometry that reads uniforms or storage buffers requires GPU execution. + +```ts twoslash +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const ball = meshes.sphere(); +const pole = ball.vertexAt(0); + +console.log(pole.position, pole.normal, pole.uv); +``` + +## Basic usage + +`bake` fills vertex and index buffers using compute. The returned mesh provides a vertex layout and an `inject()` helper for binding its buffers. +The snippet below draws a lit torus seen from `[2, 1.5, 3]`: + +```ts twoslash +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { mat4 } from 'wgpu-matrix'; + +const root = await tgpu.init(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); + +const projection = mat4.perspective(Math.PI / 4, canvas.width / canvas.height, 0.1, 100, d.mat4x4f()); +const view = mat4.lookAt([2, 1.5, 3], [0, 0, 0], [0, 1, 0], d.mat4x4f()); +const viewProj = root.createUniform(d.mat4x4f, mat4.mul(projection, view, d.mat4x4f())); + +const depth = root + .createTexture({ size: [canvas.width, canvas.height], format: 'depth24plus' }) + .$usage('transient'); + +const mesh = meshes.bake(root, meshes.torus()); +const lightDir = std.normalize(d.vec3f(1, 2, 1)); + +const pipeline = root + .createRenderPipeline({ + attribs: mesh.layout.attrib, + vertex: ({ position, normal }) => { + 'use gpu'; + return { $position: viewProj.$ * d.vec4f(position, 1), normal }; + }, + fragment: ({ normal }) => { + 'use gpu'; + const light = std.max(std.dot(std.normalize(normal), lightDir), 0) * 0.8 + 0.2; + return d.vec4f(d.vec3f(0.55, 0.7, 0.95) * light, 1); + }, + depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, + primitive: { cullMode: 'back' }, + }) + .pipe(mesh.inject()); + +pipeline + .withColorAttachment({ view: context, clearValue: [0.08, 0.09, 0.12, 1] }) + .withDepthStencilAttachment({ view: depth, depthStoreOp: 'discard' }) + .drawIndexed(mesh.indexCount); +``` + +`mesh.destroy()` releases buffers allocated by `bake`. + +Baking compiles pipelines synchronously by default. Use `await meshes.bakeAsync(root, source)` or `await meshes.bakeIndicesAsync(root, source)` to compile asynchronously before filling the buffers. The returned meshes have the same API. + +:::note +`mesh.inject()` only binds buffers. For a geometry made of lines or points, set `primitive.topology` to `mesh.topology` when creating the pipeline. +::: + +## Parametric surfaces + +`parametric` samples a surface over the unit square. +`at` maps `(u, v)` to a point in space, and the package builds a grid of `cols` by `rows` cells with two triangles each. + +```ts twoslash +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const ripple = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + const height = 0.1 * std.sin(u * 10) * std.cos(v * 10); + return d.vec3f(u - 0.5, height, 0.5 - v); + }, + }, + { cols: 24, rows: 24 }, +); +``` + + + +Normals are computed as `cross(d/du, d/dv)` by default. The order of the cross product determines the normal's direction. +This uses four extra surface samples per vertex, all within the unit square. At the `v` boundaries, derivatives are estimated slightly inside the surface to handle pinched ends such as sphere poles. If these samples still cannot determine a normal, the result is a zero vector. + +Supply `normalAt` to use an analytic normal instead. For the ripple above: + +```ts twoslash +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +const at = (u: number, v: number) => { + 'use gpu'; + return d.vec3f(u - 0.5, 0.1 * std.sin(u * 10) * std.cos(v * 10), 0.5 - v); +}; +// ---cut--- +const normalAt = (u: number, v: number) => { + 'use gpu'; + const du = std.cos(u * 10) * std.cos(v * 10); + const dv = -std.sin(u * 10) * std.sin(v * 10); + return std.normalize(d.vec3f(-du, 1, dv)); +}; + +const ripple = meshes.parametric({ at, normalAt }, { cols: 24, rows: 24 }); +``` + +The primitive surface functions are also available under `meshes.surfaces`, for example `surfaces.sphere(u, v)` returns a point on a sphere of radius 1. + +## Triangular patches + +`icosphere`, `capsule` and `roundedBox` are built from triangular patches. Their `segments` option splits each edge of every patch, so a patch has `segments * segments` triangles. +The `meshes.patches` namespace exposes the surfaces before subdivision: + +```ts twoslash +import { d } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const surface = meshes.patches.icosphere(); +const center = surface.at(0, d.vec3f(1 / 3)); +const geometry = meshes.tessellate(surface, 4); +``` + +`at(patch, weights)` samples a point using three barycentric weights, one for each corner. The weights must be nonnegative and sum to 1. +Here equal weights select the center of patch 0. `tessellate` produces an indexed geometry that works with the same combinators and baking helpers as other primitives. +Patch UVs are local to each triangle, or to a pair of triangles on the capsule's sides and the rounded box's faces and edges. + +For detail that changes during drawing, use `triangleBarycentrics(vertexIndex, segments)` in the vertex shader and pass the result to `surface.at(instanceIndex, weights)`. +`triangleIndices(maxSegments)` creates a shared index array. Each lower detail level uses its first `triangleIndexCount(segments)` entries, so changing detail requires no new buffers. +The [Mesh Patches](/TypeGPU/examples#example=rendering--mesh-patches) example uses this to draw each patch as an instance. + +The icosphere uses `patches.uniformArea` to distribute triangles more evenly. Pass `{ interpolate: meshes.patches.linear }` for normalized linear interpolation instead. + +## Custom geometry + +Any object implementing `Geometry` can be used with the combinators and baking helpers. Its callbacks must be [shader-compatible functions](/TypeGPU/apis/functions/). + +```ts twoslash +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const triangle: meshes.Geometry = { + schema: meshes.Surface, + topology: 'triangle-list', + vertexCount: 3, + vertexAt: (i) => { + 'use gpu'; + const angle = i * Math.PI * 2 / 3; + return meshes.Surface({ + position: d.vec3f(std.cos(angle), std.sin(angle), 0), + normal: d.vec3f(0, 0, 1), + uv: d.vec2f(), + }); + }, +}; +``` + +This triangle has no index buffer: bake it, bind it with `inject()`, then call `draw(mesh.vertexCount)`. + +## Transforming and combining + +`transform` applies a matrix to every vertex. +Positions go through the matrix, normals through its inverse transpose, and any other fields pass through untouched. +The matrix is captured as a constant and must be affine. Reflections reverse the triangle winding to keep faces pointing outwards. +A matrix that flattens a shape, such as `std.scaling4(d.vec3f(1, 0, 1))`, turns normals across the resulting plane. Normals of faces that collapse to a line become zero. + +`concat` requires the same schema object and topology for every input. It preserves indexed vertices and adds sequential indices for unindexed inputs. + +```ts twoslash +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const ring = meshes.torus({ radius: 0.45, tube: 0.04 }); + +const gyroscope = meshes.concat( + meshes.transform(ring, std.rotationX4(0.6)), + meshes.transform(ring, std.mul(std.rotationY4(Math.PI / 3), std.rotationX4(0.6))), + meshes.transform(ring, std.mul(std.rotationY4((2 * Math.PI) / 3), std.rotationX4(0.6))), +); +``` + + + +For a transform that changes every frame, read a matrix uniform in the vertex shader. Transform normals with the inverse transpose of its upper-left 3×3 matrix. + +## Custom vertex data + +`attach` adds fields to every vertex. +Pass a struct for the added fields and a function that computes them. The [vertex layout](/TypeGPU/apis/vertex-layouts/) includes the new fields automatically. + +Geometries attached using the same source schema and added schema can still be combined with `concat`. +This example uses UVs to add a striped vertex color: + +```ts twoslash +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const Paint = d.struct({ color: d.vec3f }); + +function palette(t: number) { + 'use gpu'; + return std.cos((t + d.vec3f(0, 0.33, 0.67)) * Math.PI * 2) * 0.5 + 0.5; +} + +const striped = meshes.attach(meshes.torus({ ringSegments: 128, tubeSegments: 48 }), Paint, (s) => { + 'use gpu'; + const wave = std.sin((s.uv.x * 8 + s.uv.y * 2) * Math.PI * 2); + return Paint({ color: palette(s.uv.x) * (0.55 + 0.45 * wave) }); +}); +``` + + + +Vertex colors are interpolated across triangles, so smooth patterns look best. +For per-cell values, mark the attribute as `d.interpolate('flat, first', ...)` in the vertex output and the fragment input. +Both triangles in a `parametric` cell use the value from its `(col, row)` corner. + +`attach` refuses field names that already exist in the source schema. +Use `map` to change the vertex schema, for example to keep only positions. + +```ts twoslash +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +// ---cut--- +const points = meshes.map(meshes.sphere(), d.vec3f, (s) => { + 'use gpu'; + return std.copy(s.position); +}); +``` + +## Updating meshes + +Choose where to evaluate geometry based on how it changes and how often you draw it: + +| Path | Work each frame | +| --- | --- | +| Bake once | Draw stored vertices | +| `bakeIndices` + `vertexAt` in the vertex shader | Evaluate vertices during each draw | +| `updateVertices` | Evaluate in compute, then reuse the buffer across draws | + +The [Mesh Primitives](/TypeGPU/examples#example=rendering--mesh-primitives) example uses `bakeIndices` and procedural vertices in both its shadow and lit passes. + +### Evaluating in the vertex shader + +Calling `vertexAt` in a vertex function evaluates the surface during drawing, with no vertex buffer at all. +For an indexed geometry, create its index buffer once with `bakeIndices` and let the surface function read uniforms: + +```ts twoslash +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; + +const root = await tgpu.init(); +const viewProj = root.createUniform(d.mat4x4f); +// ---cut--- +const time = root.createUniform(d.f32); + +const source = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + const p = meshes.surfaces.sphere(u, v); + return p * (0.5 + 0.1 * std.sin(p.x * 4 + time.$)); + }, + }, + { cols: 96, rows: 64 }, +); +const evaluated = meshes.bakeIndices(root, source); + +const pipeline = root + .createRenderPipeline({ + vertex: tgpu.vertexFn({ + in: { vid: d.builtin.vertexIndex }, + out: { pos: d.builtin.position, normal: d.vec3f }, + })(({ vid }) => { + 'use gpu'; + const vertex = evaluated.vertexAt(vid); + return { pos: viewProj.$ * d.vec4f(vertex.position, 1), normal: vertex.normal }; + }), + fragment: ({ normal }) => { + 'use gpu'; + return d.vec4f(normal * 0.5 + 0.5, 1); + }, + }) + .pipe(evaluated.inject()); +``` + +Update `time`, bind render attachments, then call `pipeline.drawIndexed(evaluated.indexCount)`. The vertex index already comes from the index buffer. +`evaluated.destroy()` releases the index buffer. +To draw without any buffers, use `const flat = meshes.unindexed(source)`. It repeats vertices in index order, so you can call `draw(flat.vertexCount)` instead. + +### Re-evaluating in compute + +`mesh.updateVertices()` re-evaluates the source into the same vertex buffer, normals and attached attributes included. +For a source that reads a time uniform, update the uniform before re-evaluating: + +```ts twoslash +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +const root = await tgpu.init(); +const time = root.createUniform(d.f32); +const source = meshes.parametric({ + at: (u, v) => { + 'use gpu'; + return d.vec3f(u, 0.1 * std.sin(u * 4 + time.$), v); + }, +}, { cols: 96, rows: 64 }); +// ---cut--- +const mesh = meshes.bake(root, source); + +function frame(now: number) { + time.write(now * 0.001); + const encoder = root['~unstable'].createCommandEncoder(); + mesh.updateVertices({ encoder }); + encoder.submit(); +} +``` + +Vertex updates leave indices unchanged. They reuse the pipeline and bindings prepared by `bake`, including on the first update. +Counts and schemas are fixed at bake time. To change the number of subdivisions, create and bake a new geometry. + +Without options, an update submits its own compute work. Use `{ encoder }` to record it as one compute pass alongside draws before submitting, or `{ pass }` for an open compute pass. Pass one or the other, not both. Updates leave the encoder or pass open. `bake` and `bakeIndices` accept these options for the initial fill too, as `@typegpu/sort` does for its `run()`. + +If the source accesses resources through a [bind group layout](/TypeGPU/apis/bind-groups/), pass the bind groups to `bake` through `{ bindGroups }`. +Updates keep using them, and `{ bindGroups }` on an update swaps them for that one call. +For [slots](/TypeGPU/apis/slots/) and [accessors](/TypeGPU/apis/accessors/), pass a configured pipeline builder, for example `{ with: root.with(heightAccess, heightUniform) }`. + +### Deforming in place + +Use `mesh.vertices.as('mutable')` to write to the vertex buffer from compute shaders. +A simulation can evolve positions from their previous values while the render pipeline keeps drawing the same buffers. +Update normals along with positions. Here a compute shader writes a sine wave into a plane. Each frame, write the time uniform and dispatch it before drawing. + +```ts twoslash +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; + +const root = await tgpu.init(); +const time = root.createUniform(d.f32); +// ---cut--- +const sheet = meshes.plane({ width: 1.4, depth: 1.4, widthSegments: 64, depthSegments: 64 }); +const mesh = meshes.bake(root, sheet); +const vertices = mesh.vertices.as('mutable'); + +const sway = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, +})(({ gid }) => { + 'use gpu'; + if (gid.x >= mesh.vertexCount) return; + + const v = vertices.$[gid.x]; + const phase = v.position.x * 6 + time.$; + v.position.y = 0.1 * std.sin(phase); + v.normal = std.normalize(d.vec3f(-0.6 * std.cos(phase), 1, 0)); +}); + +const pipeline = root.createComputePipeline({ compute: sway }); +pipeline.dispatchWorkgroups(Math.ceil(mesh.vertexCount / 64)); +``` + + + +### Existing buffers + +`bake(root, source, { vertices, indices })` writes into existing buffers. +The buffers can be larger than necessary. The mesh uses the source's vertex and index counts, and `mesh.destroy()` does not destroy these buffers. + +Several meshes can share one pair of buffers, so one pipeline and one set of bindings draw all of them. `baseVertex` and `firstIndex` place each mesh after the previous ones. +Indices stay relative to the mesh's first vertex, so pass both offsets when drawing. The mesh's own `vertexAt` and `indexAt` already apply them. + +```ts twoslash +import { d, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; + +const root = await tgpu.init(); +// ---cut--- +const vertices = root.createBuffer(d.arrayOf(meshes.Surface, 4096)).$usage('vertex', 'storage'); +const indices = root.createBuffer(d.arrayOf(d.u32, 16384)).$usage('index', 'storage'); + +const encoder = root['~unstable'].createCommandEncoder(); +const box = meshes.bake(root, meshes.box(), { vertices, indices, encoder }); +const ball = meshes.bake(root, meshes.icosphere(), { + vertices, + indices, + baseVertex: box.baseVertex + box.vertexCount, + firstIndex: box.firstIndex + box.indexCount, + encoder, +}); +encoder.submit(); + +// With a pipeline created from `.pipe(box.inject())`: +// pipeline.drawIndexed(ball.indexCount, 1, ball.firstIndex, ball.baseVertex); +``` + +`bake` throws when a mesh does not fit in the buffers past its offsets. + +:::note +Sources and destinations must be different buffers, including when a combinator reads from a baked mesh. +::: + +## Lines + +Lines are drawn as triangles placed in the vertex shader, with a width that can change at every point. +A `LineControlPoint` holds a 2D `position` and a `radius`, half the width at that point. Use positions in a space with square units, such as pixels or clip space corrected for the aspect ratio. + +`polylineVariableWidth(A, B, C, D, vertexIndex, maxJoinCount)` places one vertex of the segment from `B` to `C`, with the join at each end shaped by its neighbours `A` and `D`. +Draw every segment as an instance with the index buffer from `lineSegmentIndices(maxJoinCount)`, and pass the same `maxJoinCount` to the vertex shader. Higher counts make rounder joins and caps. +Where a polyline ends, repeat its end point as the neighbour, and the segment gets a cap instead of a join. + +```ts twoslash +import { d, std, tgpu } from 'typegpu'; +import { LineControlPoint, lineSegmentIndices, polylineVariableWidth } from '@typegpu/geometry'; + +const root = await tgpu.init(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); +// ---cut--- +const POINTS = 64; +const MAX_JOIN_COUNT = 6; +const points = root.createReadonly(d.arrayOf(LineControlPoint, POINTS)); +const indices = lineSegmentIndices(MAX_JOIN_COUNT); +const indexBuffer = root.createBuffer(d.arrayOf(d.u16, indices.length), indices).$usage('index'); + +const vertex = tgpu.vertexFn({ + in: { vertexIndex: d.builtin.vertexIndex, segment: d.builtin.instanceIndex }, + out: { position: d.builtin.position }, +})(({ vertexIndex, segment }) => { + 'use gpu'; + const A = points.$[std.max(segment, 1) - 1]; + const D = points.$[std.min(segment + 2, POINTS - 1)]; + const line = polylineVariableWidth( + A, + points.$[segment], + points.$[segment + 1], + D, + vertexIndex, + MAX_JOIN_COUNT, + ); + return { position: d.vec4f(line.vertexPosition * line.w, 0, line.w) }; +}); + +const pipeline = root + .createRenderPipeline({ + vertex, + fragment: () => { + 'use gpu'; + return d.vec4f(1, 1, 1, 1); + }, + }) + .withIndexBuffer(indexBuffer); + +pipeline.withColorAttachment({ view: context }).drawIndexed(indices.length, POINTS - 1); +``` + +`lineVariableWidth(A, B, vertexIndex, maxJoinCount)` draws a single segment with caps at both ends. +`lineSegmentWireframeIndices` lists the triangle edges for debugging, and `lineSegmentLeftIndices` covers only the left half of the line, for outlines drawn beside a shape. + +### Joins and caps + +Joins and caps are slots filled when creating the pipeline. All three default to round. + +```ts twoslash +import { tgpu } from 'typegpu'; +import { arrowCapParamsSlot, caps, endCapSlot, joins, joinSlot, miterJoinLimitSlot, startCapSlot } from '@typegpu/geometry'; + +const root = await tgpu.init(); +// ---cut--- +const configured = root + .with(joinSlot, joins.miter) + .with(miterJoinLimitSlot, 3) + .with(startCapSlot, caps.butt) + .with(endCapSlot, caps.arrow) + .with(arrowCapParamsSlot, { length: 5, width: 1.5, slant: 0.8 }); +``` + +| Slot | Choices | +| --- | --- | +| `joinSlot` | `joins.round`, `joins.miter`, `joins.bevel` | +| `startCapSlot`, `endCapSlot` | `caps.round`, `caps.butt`, `caps.square`, `caps.arrow`, `caps.triangle`, `caps.wedge` | + +A miter longer than `miterJoinLimitSlot` times the radius, 2 by default, bends back towards a bevel. `arrowCapParamsSlot` sets the arrow's length, width and slant, relative to the radius. +A custom join or cap is a `'use gpu'` function with the same signature, `(join: JoinInput, joinVertexIndex: number, maxJoinCount: number) => d.v2f`. For index 0 it returns the segment's own corner, `join.v`. Indices 1 to `maxJoinCount` fan out from that corner around the control point `join.C`. + +The [Lines Combinations](/TypeGPU/examples#example=geometry--lines-combinations) example shows every join and cap. + +## Available utilities + +Line utilities are exported from the package root: + +| Group | Exports | +| --- | --- | +| Drawing | `polylineVariableWidth`, `lineVariableWidth`, `LineControlPoint`, `LineSegmentOutput` | +| Indices | `lineSegmentIndices`, `lineSegmentWireframeIndices`, `lineSegmentLeftIndices` | +| Joins and caps | `joins`, `caps`, `joinSlot`, `startCapSlot`, `endCapSlot`, `miterJoinLimitSlot`, `arrowCapParamsSlot`, `JoinInput` | + +Mesh utilities live under the `meshes` namespace: + +| Group | Exports | +| --- | --- | +| Primitives | `sphere`, `plane`, `box`, `cylinder`, `torus`, `icosphere`, `capsule`, `roundedBox` | +| Surfaces | `parametric`, `surfaces`, `patches.icosphere`, `patches.capsule`, `patches.roundedBox` | +| Tessellation | `tessellate`, `triangleBarycentrics`, `triangleIndexAt`, `triangleIndices`, `triangleVertexCount`, `triangleIndexCount` | +| Spherical interpolation | `patches.linear`, `patches.uniformArea` | +| Combinators | `transform`, `map`, `attach`, `concat`, `unindexed` | +| Buffers | `bake`, `bakeAsync`, `bakeIndices`, `bakeIndicesAsync`, `UpdateOptions`, `RecordOptions` | +| Schema and type guard | `Surface`, `isIndexed` | + +## Examples + +- [Mesh Primitives](/TypeGPU/examples#example=rendering--mesh-primitives) combines primitives with a parametric spring and an animated surface, using procedural vertices for lighting and shadows. +- [Mesh Patches](/TypeGPU/examples#example=rendering--mesh-patches) changes tessellation detail while reusing one index buffer. diff --git a/apps/typegpu-docs/tests/individual-example-tests/mesh-preview.test.ts b/apps/typegpu-docs/tests/individual-example-tests/mesh-preview.test.ts new file mode 100644 index 0000000000..2e23a61b3c --- /dev/null +++ b/apps/typegpu-docs/tests/individual-example-tests/mesh-preview.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment jsdom + */ + +import { expect, vi } from 'vitest'; +import { it } from 'typegpu-testing-utility'; +import { demos, defaultParams } from '../../src/components/geometry/demos.ts'; +import { createMeshViewer } from '../../src/components/geometry/viewer.ts'; +import { setupCommonMocks } from './utils/commonMocks.ts'; + +setupCommonMocks(); + +it('does not submit a pending bake with destroyed uniforms after disposal', async ({ device }) => { + document.body.innerHTML = ''; + let frame: FrameRequestCallback = () => {}; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frame = callback; + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const compilation = Promise.withResolvers(); + const initialize = device.mock.createComputePipelineAsync.getMockImplementation()!; + device.mock.createComputePipelineAsync.mockImplementation(async (descriptor) => { + await compilation.promise; + return initialize(descriptor); + }); + + const viewer = await createMeshViewer(document.querySelector('canvas')!); + try { + viewer.show(demos.ripple, defaultParams(demos.ripple)); + frame(1); + await vi.waitFor(() => expect(device.mock.createComputePipelineAsync).toHaveBeenCalledTimes(2)); + + const uniforms = device.mock.createBuffer.mock.results.find( + ({ value }) => (value.usage & GPUBufferUsage.UNIFORM) !== 0, + )!.value; + let submittedWithDestroyedUniforms = false; + device.mock.queue.submit.mockImplementation(() => { + submittedWithDestroyedUniforms ||= uniforms.destroy.mock.calls.length > 0; + }); + + viewer.destroy(); + compilation.resolve(); + await vi.waitFor(() => expect(device.mock.queue.submit).toHaveBeenCalledOnce()); + + expect(submittedWithDestroyedUniforms).toBe(false); + expect(uniforms.destroy).toHaveBeenCalledOnce(); + } finally { + compilation.resolve(); + viewer.destroy(); + } +});
+ Loading preview… +
+ Could not render preview: {state.message} +