From df00386806ebcdccc097eff88cbd165e11a8f785 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 7 Sep 2026 15:47:28 +0200 Subject: [PATCH 1/6] Add @typegpu/geometry guide with live previews --- apps/typegpu-docs/astro.config.mjs | 5 + .../src/components/geometry/MeshPreview.tsx | 156 ++++++ .../src/components/geometry/demos.ts | 193 ++++++++ .../src/components/geometry/renderer.ts | 80 ++++ .../src/components/geometry/shaders.ts | 83 ++++ .../src/components/geometry/viewer.ts | 156 ++++++ .../docs/ecosystem/typegpu-geometry.mdx | 443 ++++++++++++++++++ 7 files changed, 1116 insertions(+) create mode 100644 apps/typegpu-docs/src/components/geometry/MeshPreview.tsx create mode 100644 apps/typegpu-docs/src/components/geometry/demos.ts create mode 100644 apps/typegpu-docs/src/components/geometry/renderer.ts create mode 100644 apps/typegpu-docs/src/components/geometry/shaders.ts create mode 100644 apps/typegpu-docs/src/components/geometry/viewer.ts create mode 100644 apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx 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 ( + + ); + })} + + +
+
+ ); +} 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..b1cb6a39ef --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/demos.ts @@ -0,0 +1,193 @@ +import { meshes } from '@typegpu/geometry'; +import { d, std, tgpu, 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<() => 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({ segments: detail * 2, rings: 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: std.mul(rotation, d.vec4f(v.position, 1)).xyz, + normal: std.mul(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 = std.mul(std.add(hue + s.uv.x, d.vec3f(0, 0.33, 0.67)), TAU); + const palette = std.add(std.mul(std.cos(phases), 0.5), 0.5); + return Paint({ color: std.mul(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 pipeline = root.createComputePipeline({ + compute: 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)); + }, + ), + }); + await pipeline.initAsync(); + const workgroups = Math.ceil(mesh.vertexCount / 64); + return () => pipeline.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..12b502b781 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/renderer.ts @@ -0,0 +1,80 @@ +import type { PreviewMesh } from './demos.ts'; +import { d, 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 (wireframe: boolean) => { + const draw = pipeline + .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 }); + }, + }; +} 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..26a167f845 --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/shaders.ts @@ -0,0 +1,83 @@ +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 = std.mul(faceted, std.sign(std.dot(faceted, smooth))); + const n = std.mul( + 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 = std.mul(color, 0.2 + 0.25 * sky + 0.6 * diffuse); + + const edges = std.smoothstep(d.vec3f(0), std.mul(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), std.mul(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: std.mul(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..542646c6bf --- /dev/null +++ b/apps/typegpu-docs/src/components/geometry/viewer.ts @@ -0,0 +1,156 @@ +import { tgpu } 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; +} + +export async function createMeshViewer( + canvas: HTMLCanvasElement, + onState: (state: PreviewState) => void = () => {}, +): Promise { + const root = await tgpu.init(); + try { + const uniforms = root.createUniform(Scene); + const renderer = createRenderer(root, canvas, uniforms); + const ctx = { root, uniforms }; + let current: + | { + demo: Demo; + params: Record; + mesh: PreviewMesh; + update(): void; + draw(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 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; + } + deform?.(); + const update = deform ?? mesh.updateVertices; + current?.mesh.destroy(); + current = { demo, params, mesh, update, draw }; + dirty = false; + onState({ status: 'ready' }); + } catch (error) { + allocated?.destroy(); + if (!disposed) fail(error); + } finally { + building = false; + } + } + + 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) { + if (dirty || (current.demo.animated && !paused)) current.update(); + current.draw(shading === 'wireframe'); + 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); + root.destroy(); + }, + }; + } catch (error) { + root.destroy(); + throw error; + } +} 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..b033a8c1e4 --- /dev/null +++ b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx @@ -0,0 +1,443 @@ +--- +title: "@typegpu/geometry" +description: Procedural meshes described as functions, composed on the CPU and resolved into shaders. +--- + +import MeshPreview from '../../../components/geometry/MeshPreview.tsx'; + +The `meshes` namespace in `@typegpu/geometry` 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, segments: 32, rings: 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. +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. +Start with a canvas: + +```html + +``` + +The view matrix places the camera at `[2, 1.5, 3]`, looking towards the origin. +The projection matrix adds perspective, making distant objects appear smaller. +Their product, `viewProj`, transforms the mesh’s 3D positions into the coordinates the GPU uses for drawing. +A depth texture keeps nearer triangles in front of farther ones. + +```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. +You say where a point `(u, v)` lands 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 subdivides each patch. +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. +Segment counts must be between 1 and 32,767, and the counts produced by `tessellate` must fit in `u32`. + +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 invertible and affine. Reflections reverse the triangle winding to keep faces pointing outwards. + +`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 alongside draws before submitting, or `{ pass }` for an open compute pass. Updates leave both open. `bake` and `bakeIndices` accept these options for the initial fill too. + +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 = root.createComputePipeline({ + compute: 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)); + }), +}); + +sway.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. + +:::note +Sources and destinations must be different buffers, including when a combinator reads from a baked mesh. +::: + +## Available utilities + +Everything below lives 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` | +| 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. From 8aab43a9220f9bd0ddc619436ed2d5d7a551c4b6 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Wed, 23 Sep 2026 04:18:25 +0200 Subject: [PATCH 2/6] one encoder per frame, shared root and stale limits --- .../src/components/geometry/demos.ts | 16 +- .../src/components/geometry/renderer.ts | 8 +- .../src/components/geometry/shaders.ts | 16 +- .../src/components/geometry/viewer.ts | 249 +++++++++--------- .../docs/ecosystem/typegpu-geometry.mdx | 1 - 5 files changed, 144 insertions(+), 146 deletions(-) diff --git a/apps/typegpu-docs/src/components/geometry/demos.ts b/apps/typegpu-docs/src/components/geometry/demos.ts index b1cb6a39ef..bce08e9548 100644 --- a/apps/typegpu-docs/src/components/geometry/demos.ts +++ b/apps/typegpu-docs/src/components/geometry/demos.ts @@ -1,5 +1,5 @@ import { meshes } from '@typegpu/geometry'; -import { d, std, tgpu, type TgpuRoot, type TgpuUniform } from 'typegpu'; +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; @@ -26,7 +26,7 @@ export interface Demo { rebuild: string[]; animated?: boolean; build(params: Record, ctx: DemoContext): meshes.IndexedGeometry; - update?(mesh: PreviewMesh, ctx: DemoContext): Promise<() => void>; + update?(mesh: PreviewMesh, ctx: DemoContext): Promise<(encoder: TgpuCommandEncoder) => void>; } export const demos: Record = { @@ -114,8 +114,8 @@ export const demos: Record = { 'use gpu'; const rotation = std.rotationX4(uniforms.$.params.tilt); return meshes.Surface({ - position: std.mul(rotation, d.vec4f(v.position, 1)).xyz, - normal: std.mul(rotation, d.vec4f(v.normal, 0)).xyz, + position: (rotation * d.vec4f(v.position, 1)).xyz, + normal: (rotation * d.vec4f(v.normal, 0)).xyz, uv: v.uv, }); }); @@ -141,9 +141,9 @@ export const demos: Record = { 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 = std.mul(std.add(hue + s.uv.x, d.vec3f(0, 0.33, 0.67)), TAU); - const palette = std.add(std.mul(std.cos(phases), 0.5), 0.5); - return Paint({ color: std.mul(palette, 0.55 + 0.45 * wave) }); + 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: { @@ -181,7 +181,7 @@ export const demos: Record = { }); await pipeline.initAsync(); const workgroups = Math.ceil(mesh.vertexCount / 64); - return () => pipeline.dispatchWorkgroups(workgroups); + return (encoder) => pipeline.with(encoder).dispatchWorkgroups(workgroups); }, }, }; diff --git a/apps/typegpu-docs/src/components/geometry/renderer.ts b/apps/typegpu-docs/src/components/geometry/renderer.ts index 12b502b781..54b2d62401 100644 --- a/apps/typegpu-docs/src/components/geometry/renderer.ts +++ b/apps/typegpu-docs/src/components/geometry/renderer.ts @@ -1,5 +1,5 @@ import type { PreviewMesh } from './demos.ts'; -import { d, type TgpuRoot, type TgpuUniform } from 'typegpu'; +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'; @@ -47,8 +47,9 @@ export function createRenderer( .withIndexBuffer(mesh.indices); await pipeline.initAsync(); - return (wireframe: boolean) => { + 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); @@ -76,5 +77,8 @@ export function createRenderer( 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 index 26a167f845..b7e432ed01 100644 --- a/apps/typegpu-docs/src/components/geometry/shaders.ts +++ b/apps/typegpu-docs/src/components/geometry/shaders.ts @@ -36,18 +36,16 @@ export const fragment = tgpu.fragmentFn({ 'use gpu'; const smooth = std.normalize(normal); const faceted = std.normalize(std.cross(std.dpdx(worldPos), std.dpdy(worldPos))); - const oriented = std.mul(faceted, std.sign(std.dot(faceted, smooth))); - const n = std.mul( - std.select(smooth, oriented, sceneAccess.$.flat === 1), - std.select(-1, d.f32(1), front), - ); + 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 = std.mul(color, 0.2 + 0.25 * sky + 0.6 * diffuse); + const lit = color * (0.2 + 0.25 * sky + 0.6 * diffuse); - const edges = std.smoothstep(d.vec3f(0), std.mul(std.fwidth(barycentric), 1.2), barycentric); + 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), std.mul(lit, 0.25), interior); + 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); }); @@ -68,7 +66,7 @@ export function vertexShader(schema: VertexSchema) { const color = painted ? (v as d.InferGPU).color : d.vec3f(0.55, 0.7, 0.95); const corner = vid % 3; return { - pos: std.mul(sceneAccess.$.viewProj, d.vec4f(v.position, 1)), + pos: sceneAccess.$.viewProj * d.vec4f(v.position, 1), worldPos: v.position, normal: v.normal, color, diff --git a/apps/typegpu-docs/src/components/geometry/viewer.ts b/apps/typegpu-docs/src/components/geometry/viewer.ts index 542646c6bf..be2c7189d8 100644 --- a/apps/typegpu-docs/src/components/geometry/viewer.ts +++ b/apps/typegpu-docs/src/components/geometry/viewer.ts @@ -1,4 +1,4 @@ -import { tgpu } from 'typegpu'; +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'; @@ -14,143 +14,140 @@ export interface MeshViewer { destroy(): void; } +let sharedRoot: Promise | undefined; + export async function createMeshViewer( canvas: HTMLCanvasElement, onState: (state: PreviewState) => void = () => {}, ): Promise { - const root = await tgpu.init(); - try { - const uniforms = root.createUniform(Scene); - const renderer = createRenderer(root, canvas, uniforms); - const ctx = { root, uniforms }; - let current: - | { - demo: Demo; - params: Record; - mesh: PreviewMesh; - update(): void; - draw(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; + 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 fail(error: unknown) { - failed = true; - active = false; - cancelAnimationFrame(frame); - onState({ status: 'error', message: error instanceof Error ? error.message : String(error) }); - } + 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; - } - deform?.(); - const update = deform ?? mesh.updateVertices; - current?.mesh.destroy(); - current = { demo, params, mesh, update, draw }; - dirty = false; - onState({ status: 'ready' }); - } catch (error) { - allocated?.destroy(); - if (!disposed) fail(error); - } finally { - building = false; + 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; } + } - 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); - } + 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; } - renderer.updateCamera(seconds); - if (current) { - if (dirty || (current.demo.animated && !paused)) current.update(); - current.draw(shading === 'wireframe'); - dirty = false; + 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); } - if (active) frame = requestAnimationFrame(render); - } catch (error) { - fail(error); } + 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); - root.destroy(); - }, - }; - } catch (error) { - root.destroy(); - throw 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); + current?.mesh.destroy(); + renderer.destroy(); + uniforms.buffer.destroy(); + }, + }; } diff --git a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx index b033a8c1e4..8c39b7e783 100644 --- a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx +++ b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx @@ -182,7 +182,6 @@ Patch UVs are local to each triangle, or to a pair of triangles on the capsule's 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. -Segment counts must be between 1 and 32,767, and the counts produced by `tessellate` must fit in `u32`. The icosphere uses `patches.uniformArea` to distribute triangles more evenly. Pass `{ interpolate: meshes.patches.linear }` for normalized linear interpolation instead. From cf2efcb805ac0d37175a945b7aa31fef0429cc47 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 6 Oct 2026 16:38:40 +0200 Subject: [PATCH 3/6] address review --- .../src/components/geometry/demos.ts | 27 ++++++------- .../docs/ecosystem/typegpu-geometry.mdx | 39 +++++++------------ 2 files changed, 29 insertions(+), 37 deletions(-) diff --git a/apps/typegpu-docs/src/components/geometry/demos.ts b/apps/typegpu-docs/src/components/geometry/demos.ts index bce08e9548..a0b5957726 100644 --- a/apps/typegpu-docs/src/components/geometry/demos.ts +++ b/apps/typegpu-docs/src/components/geometry/demos.ts @@ -165,20 +165,21 @@ export const demos: Record = { 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 pipeline = root.createComputePipeline({ - compute: 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 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); diff --git a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx index 8c39b7e783..a06bdbd627 100644 --- a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx +++ b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx @@ -51,16 +51,7 @@ 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. -Start with a canvas: - -```html - -``` - -The view matrix places the camera at `[2, 1.5, 3]`, looking towards the origin. -The projection matrix adds perspective, making distant objects appear smaller. -Their product, `viewProj`, transforms the mesh’s 3D positions into the coordinates the GPU uses for drawing. -A depth texture keeps nearer triangles in front of farther ones. +The snippet below draws a lit torus seen from `[2, 1.5, 3]`: ```ts twoslash import { d, std, tgpu } from 'typegpu'; @@ -116,7 +107,7 @@ Baking compiles pipelines synchronously by default. Use `await meshes.bakeAsync( ## Parametric surfaces `parametric` samples a surface over the unit square. -You say where a point `(u, v)` lands in space, and the package builds a grid of `cols` by `rows` cells with two triangles each. +`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'; @@ -394,21 +385,21 @@ const sheet = meshes.plane({ width: 1.4, depth: 1.4, widthSegments: 64, depthSeg const mesh = meshes.bake(root, sheet); const vertices = mesh.vertices.as('mutable'); -const sway = root.createComputePipeline({ - compute: 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 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)); }); -sway.dispatchWorkgroups(Math.ceil(mesh.vertexCount / 64)); +const pipeline = root.createComputePipeline({ compute: sway }); +pipeline.dispatchWorkgroups(Math.ceil(mesh.vertexCount / 64)); ``` From 85a119f9b3e718b0b42e7ff27ba0787b5253a3e7 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Fri, 16 Oct 2026 20:55:11 +0200 Subject: [PATCH 4/6] Defer preview resource cleanup --- .../src/components/geometry/viewer.ts | 12 +++-- .../mesh-preview.test.ts | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 apps/typegpu-docs/tests/individual-example-tests/mesh-preview.test.ts diff --git a/apps/typegpu-docs/src/components/geometry/viewer.ts b/apps/typegpu-docs/src/components/geometry/viewer.ts index be2c7189d8..8c5c93c098 100644 --- a/apps/typegpu-docs/src/components/geometry/viewer.ts +++ b/apps/typegpu-docs/src/components/geometry/viewer.ts @@ -44,6 +44,12 @@ export async function createMeshViewer( 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; @@ -77,6 +83,7 @@ export async function createMeshViewer( if (!disposed) fail(error); } finally { building = false; + if (disposed) releaseResources(); } } @@ -145,9 +152,8 @@ export async function createMeshViewer( if (disposed) return; disposed = true; cancelAnimationFrame(frame); - current?.mesh.destroy(); - renderer.destroy(); - uniforms.buffer.destroy(); + // A pending bake still needs the uniforms after its pipelines finish compiling + if (!building) releaseResources(); }, }; } 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(); + } +}); From 82279dba188546396b4e06c11c3a0513c4c72762 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:12:15 +0200 Subject: [PATCH 5/6] Document mesh offsets, segment names and flattening transforms --- .../src/components/geometry/demos.ts | 2 +- .../docs/ecosystem/typegpu-geometry.mdx | 45 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/typegpu-docs/src/components/geometry/demos.ts b/apps/typegpu-docs/src/components/geometry/demos.ts index a0b5957726..a959f03ab1 100644 --- a/apps/typegpu-docs/src/components/geometry/demos.ts +++ b/apps/typegpu-docs/src/components/geometry/demos.ts @@ -58,7 +58,7 @@ export const demos: Record = { case 'plane': return meshes.plane({ widthSegments: detail, depthSegments: detail }); default: - return meshes.sphere({ segments: detail * 2, rings: detail }); + return meshes.sphere({ radialSegments: detail * 2, heightSegments: detail }); } }, }, diff --git a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx index a06bdbd627..65d4dbd585 100644 --- a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx +++ b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx @@ -20,7 +20,7 @@ Primitives fit in a unit cube around the origin by default and use counter-clock ```ts twoslash import { meshes } from '@typegpu/geometry'; -const ball = meshes.sphere({ radius: 0.5, segments: 32, rings: 16 }); +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 }); @@ -31,11 +31,12 @@ 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. +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. @@ -127,7 +128,7 @@ const ripple = meshes.parametric( -Normals are computed as `cross(d/du, d/dv)` by default. The order of the cross product determines the normal’s direction. +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: @@ -154,7 +155,7 @@ The primitive surface functions are also available under `meshes.surfaces`, for ## Triangular patches -`icosphere`, `capsule` and `roundedBox` are built from triangular patches. Their `segments` option subdivides each patch. +`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 @@ -206,7 +207,8 @@ This triangle has no index buffer: bake it, bind it with `inject()`, then call ` `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 invertible and affine. Reflections reverse the triangle winding to keep faces pointing outwards. +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. @@ -362,7 +364,7 @@ function frame(now: number) { 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 alongside draws before submitting, or `{ pass }` for an open compute pass. Updates leave both open. `bake` and `bakeIndices` accept these options for the initial fill too. +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. @@ -409,6 +411,35 @@ pipeline.dispatchWorkgroups(Math.ceil(mesh.vertexCount / 64)); `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. ::: @@ -424,7 +455,7 @@ Everything below lives under the `meshes` namespace. | Tessellation | `tessellate`, `triangleBarycentrics`, `triangleIndexAt`, `triangleIndices`, `triangleVertexCount`, `triangleIndexCount` | | Spherical interpolation | `patches.linear`, `patches.uniformArea` | | Combinators | `transform`, `map`, `attach`, `concat`, `unindexed` | -| Buffers | `bake`, `bakeAsync`, `bakeIndices`, `bakeIndicesAsync` | +| Buffers | `bake`, `bakeAsync`, `bakeIndices`, `bakeIndicesAsync`, `UpdateOptions`, `RecordOptions` | | Schema and type guard | `Surface`, `isIndexed` | ## Examples From ffef34c8e08f4216b2d6d65e7bf88d6ac05e45c1 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:13:19 +0200 Subject: [PATCH 6/6] Document lines, joins and caps --- .../docs/ecosystem/typegpu-geometry.mdx | 100 +++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx index 65d4dbd585..9b006a520a 100644 --- a/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx +++ b/apps/typegpu-docs/src/content/docs/ecosystem/typegpu-geometry.mdx @@ -1,11 +1,12 @@ --- title: "@typegpu/geometry" -description: Procedural meshes described as functions, composed on the CPU and resolved into shaders. +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'; -The `meshes` namespace in `@typegpu/geometry` provides 3D primitives, parametric surfaces and functions for transforming and combining them. +`@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. @@ -444,9 +445,102 @@ encoder.submit(); 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 -Everything below lives under the `meshes` namespace. +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 | | --- | --- |