From 35937f3bf138405f505af93e5c46c47eea618f06 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 7 Sep 2026 15:47:28 +0200 Subject: [PATCH 01/18] Add procedural meshes to @typegpu/geometry --- packages/typegpu-geometry/package.json | 8 +- packages/typegpu-geometry/src/index.ts | 1 + packages/typegpu-geometry/src/mesh/bake.ts | 284 +++++++++++++++ .../typegpu-geometry/src/mesh/combinators.ts | 337 ++++++++++++++++++ packages/typegpu-geometry/src/mesh/fill.ts | 66 ++++ .../typegpu-geometry/src/mesh/geometry.ts | 36 ++ packages/typegpu-geometry/src/mesh/index.ts | 8 + packages/typegpu-geometry/src/mesh/math.ts | 19 + .../typegpu-geometry/src/mesh/parametric.ts | 88 +++++ .../typegpu-geometry/src/mesh/primitives.ts | 257 +++++++++++++ packages/typegpu-geometry/tests/bake.test.ts | 180 ++++++++++ packages/typegpu-geometry/tests/fixtures.ts | 22 ++ packages/typegpu-geometry/tests/mesh.test.ts | 206 +++++++++++ .../typegpu-geometry/tests/updates.test.ts | 190 ++++++++++ packages/typegpu-geometry/tsconfig.test.json | 4 + packages/typegpu-geometry/vitest.config.mts | 16 + packages/typegpu/src/indexNamedExports.ts | 6 +- pnpm-lock.yaml | 9 + 18 files changed, 1734 insertions(+), 3 deletions(-) create mode 100644 packages/typegpu-geometry/src/mesh/bake.ts create mode 100644 packages/typegpu-geometry/src/mesh/combinators.ts create mode 100644 packages/typegpu-geometry/src/mesh/fill.ts create mode 100644 packages/typegpu-geometry/src/mesh/geometry.ts create mode 100644 packages/typegpu-geometry/src/mesh/index.ts create mode 100644 packages/typegpu-geometry/src/mesh/math.ts create mode 100644 packages/typegpu-geometry/src/mesh/parametric.ts create mode 100644 packages/typegpu-geometry/src/mesh/primitives.ts create mode 100644 packages/typegpu-geometry/tests/bake.test.ts create mode 100644 packages/typegpu-geometry/tests/fixtures.ts create mode 100644 packages/typegpu-geometry/tests/mesh.test.ts create mode 100644 packages/typegpu-geometry/tests/updates.test.ts create mode 100644 packages/typegpu-geometry/tsconfig.test.json create mode 100644 packages/typegpu-geometry/vitest.config.mts diff --git a/packages/typegpu-geometry/package.json b/packages/typegpu-geometry/package.json index 46d8c955a5..ce713057d0 100644 --- a/packages/typegpu-geometry/package.json +++ b/packages/typegpu-geometry/package.json @@ -34,16 +34,20 @@ }, "scripts": { "build": "unbuild", - "test:types": "pnpm tsc --p ./tsconfig.json --noEmit", + "test": "vitest run", + "test:types": "pnpm tsc --p ./tsconfig.json --noEmit && pnpm tsc --p ./tsconfig.test.json --noEmit", "prepublishOnly": "tgpu-dev-cli prepack" }, "devDependencies": { "@typegpu/tgpu-dev-cli": "workspace:*", "@webgpu/types": "catalog:types", + "jiti": "catalog:build", "typegpu": "workspace:*", + "typegpu-testing-utility": "workspace:*", "typescript": "catalog:types", "unbuild": "catalog:build", - "unplugin-typegpu": "workspace:*" + "unplugin-typegpu": "workspace:*", + "vitest": "catalog:test" }, "peerDependencies": { "typegpu": "workspace:^" diff --git a/packages/typegpu-geometry/src/index.ts b/packages/typegpu-geometry/src/index.ts index 33f48fa5c3..c34c8b211b 100644 --- a/packages/typegpu-geometry/src/index.ts +++ b/packages/typegpu-geometry/src/index.ts @@ -1,2 +1,3 @@ export * from './circle.ts'; export * from './lines/index.ts'; +export * as meshes from './mesh/index.ts'; diff --git a/packages/typegpu-geometry/src/mesh/bake.ts b/packages/typegpu-geometry/src/mesh/bake.ts new file mode 100644 index 0000000000..3234fd3bab --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/bake.ts @@ -0,0 +1,284 @@ +import { + d, + type HasIndexBuffer, + type IndexFlag, + type StorageFlag, + type TgpuBuffer, + type TgpuRenderPipeline, + type TgpuRoot, + type TgpuVertexLayout, + type VertexFlag, + type WithBinding, +} from 'typegpu'; +import { fromBuffer } from './combinators.ts'; +import { createFill, type UpdateOptions } from './fill.ts'; +import { type Geometry, type IndexedGeometry, isIndexed, layoutOf } from './geometry.ts'; + +export type { UpdateOptions } from './fill.ts'; + +export type VertexBuffer = TgpuBuffer> & + VertexFlag & + StorageFlag; +export type IndexBuffer = TgpuBuffer> & IndexFlag & StorageFlag; + +export interface Baked extends Geometry { + readonly vertices: VertexBuffer; + readonly layout: TgpuVertexLayout>; + inject():

(pipeline: P) => P; + updateVertices(this: void, options?: UpdateOptions): void; + destroy(): void; +} + +export interface BakedIndexed extends Baked, IndexedGeometry { + readonly indices: IndexBuffer; + inject():

(pipeline: P) => P & HasIndexBuffer; +} + +export interface BakeOptions extends UpdateOptions { + vertices?: VertexBuffer; + indices?: IndexBuffer; + with?: WithBinding; +} + +function fits(kind: string, capacity: number, needed: number) { + if (!Number.isSafeInteger(needed) || needed < 0 || needed > capacity) { + throw new Error(`The ${kind} count must be an integer between 0 and ${capacity}`); + } +} + +function record(root: TgpuRoot, options: UpdateOptions, fills: ReturnType[]) { + if (options.encoder && options.pass) { + throw new Error('Supply either an encoder or a compute pass, not both'); + } + if (options.encoder || options.pass) { + for (const fill of fills) fill.run(options); + return; + } + const encoder = root['~unstable'].createCommandEncoder(); + const pass = encoder.beginComputePass(); + for (const fill of fills) fill.run({ ...options, pass }); + pass.end(); + encoder.submit(); +} + +interface Prepared { + mesh: T; + fills: ReturnType[]; +} + +function finish( + root: TgpuRoot, + prepared: Prepared, + options: UpdateOptions, +): T { + try { + record(root, options, prepared.fills); + return prepared.mesh; + } catch (error) { + prepared.mesh.destroy(); + throw error; + } +} + +async function finishAsync( + root: TgpuRoot, + prepared: Prepared, + options: UpdateOptions, +): Promise { + try { + await Promise.all(prepared.fills.map((fill) => fill.initAsync())); + } catch (error) { + prepared.mesh.destroy(); + throw error; + } + return finish(root, prepared, options); +} + +export function bake( + root: TgpuRoot, + g: IndexedGeometry, + options?: BakeOptions, +): BakedIndexed; +export function bake( + root: TgpuRoot, + g: Geometry, + options?: BakeOptions, +): Baked; +export function bake( + root: TgpuRoot, + g: Geometry, + options: BakeOptions = {}, +): Baked { + return finish(root, prepareBake(root, g, options), options); +} + +export function bakeAsync( + root: TgpuRoot, + g: IndexedGeometry, + options?: BakeOptions, +): Promise>; +export function bakeAsync( + root: TgpuRoot, + g: Geometry, + options?: BakeOptions, +): Promise>; +export async function bakeAsync( + root: TgpuRoot, + g: Geometry, + options: BakeOptions = {}, +): Promise> { + return finishAsync(root, prepareBake(root, g, options), options); +} + +function prepareBake( + root: TgpuRoot, + g: Geometry, + options: BakeOptions, +): Prepared> { + const { schema, topology, vertexCount } = g; + const indexed = isIndexed(g) ? g : undefined; + const indexCount = indexed?.indexCount ?? 0; + fits('vertex', 0xffffffff, vertexCount); + fits('index', 0xffffffff, indexCount); + const owned: { destroy(): void }[] = []; + const destroy = () => owned.forEach((resource) => resource.destroy()); + + try { + const vertices = + options.vertices ?? + (root + .createBuffer(d.arrayOf(schema, Math.max(1, vertexCount)) as d.WgslArray) + .$usage('vertex', 'storage') as unknown as VertexBuffer); + if (!options.vertices) owned.push(vertices); + fits('vertex', vertices.dataType.elementCount, vertexCount); + + const indices = indexed + ? (options.indices ?? + root.createBuffer(d.arrayOf(d.u32, Math.max(1, indexCount))).$usage('index', 'storage')) + : undefined; + if (indices) { + if (!options.indices) owned.push(indices); + fits('index', indices.dataType.elementCount, indexCount); + } + const output = vertices.as('mutable'); + const writeIndices = indices?.as('mutable'); + const pipelineRoot = options.with ?? root; + const fillVertices = createFill( + pipelineRoot, + vertexCount, + (i) => { + 'use gpu'; + output.$[i] = g.vertexAt(i); + }, + options.bindGroups, + ); + const fills = [fillVertices]; + if (indexed && writeIndices) { + fills.push( + createFill( + pipelineRoot, + indexCount, + (i) => { + 'use gpu'; + writeIndices.$[i] = indexed.indexAt(i); + }, + options.bindGroups, + ), + ); + } + + const layout = layoutOf(schema); + const mesh = { + vertices, + layout, + updateVertices: fillVertices.run, + destroy, + }; + if (indices) { + const indexedMesh: BakedIndexed = { + ...fromBuffer(vertices, { indices, topology, vertexCount, indexCount }), + ...mesh, + indices, + inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indices), + }; + return { mesh: indexedMesh, fills }; + } + return { + mesh: { + ...fromBuffer(vertices, { topology, vertexCount }), + ...mesh, + inject: () => (pipeline) => pipeline.with(layout, vertices), + }, + fills, + }; + } catch (error) { + destroy(); + throw error; + } +} + +export interface BakeIndicesOptions extends UpdateOptions { + indices?: IndexBuffer; + with?: WithBinding; +} + +export interface BakedIndices extends IndexedGeometry { + readonly indices: IndexBuffer; + inject():

(pipeline: P) => P & HasIndexBuffer; + destroy(): void; +} + +export function bakeIndices( + root: TgpuRoot, + g: IndexedGeometry, + options: BakeIndicesOptions = {}, +): BakedIndices { + return finish(root, prepareIndices(root, g, options), options); +} + +export async function bakeIndicesAsync( + root: TgpuRoot, + g: IndexedGeometry, + options: BakeIndicesOptions = {}, +): Promise> { + return finishAsync(root, prepareIndices(root, g, options), options); +} + +function prepareIndices( + root: TgpuRoot, + g: IndexedGeometry, + options: BakeIndicesOptions, +): Prepared> { + fits('index', 0xffffffff, g.indexCount); + const indices = + options.indices ?? + root.createBuffer(d.arrayOf(d.u32, Math.max(1, g.indexCount))).$usage('index', 'storage'); + const destroy = () => { + if (!options.indices) indices.destroy(); + }; + try { + fits('index', indices.dataType.elementCount, g.indexCount); + const output = indices.as('mutable'); + const fill = createFill( + options.with ?? root, + g.indexCount, + (i) => { + 'use gpu'; + output.$[i] = g.indexAt(i); + }, + options.bindGroups, + ); + return { + mesh: { + ...g, + indices, + inject: () => (pipeline) => pipeline.withIndexBuffer(indices), + destroy, + }, + fills: [fill], + }; + } catch (error) { + destroy(); + throw error; + } +} diff --git a/packages/typegpu-geometry/src/mesh/combinators.ts b/packages/typegpu-geometry/src/mesh/combinators.ts new file mode 100644 index 0000000000..888aa6123b --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/combinators.ts @@ -0,0 +1,337 @@ +import { d, std, tgpu, type StorageFlag, type TgpuBuffer } from 'typegpu'; +import { type Geometry, type IndexedGeometry, isIndexed, type Topology } from './geometry.ts'; + +function reversedCorner(i: number) { + 'use gpu'; + const corner = i % 3; + + if (corner === 1) { + return i + 1; + } + if (corner === 2) { + return i - 1; + } + + return i; +} + +function normalMatrixOf(m: d.m4x4f): d.m3x3f { + 'use gpu'; + const c0 = m.columns[0].xyz; + const c1 = m.columns[1].xyz; + const c2 = m.columns[2].xyz; + const det = std.dot(c0, std.cross(c1, c2)); + + return d.mat3x3f(std.cross(c1, c2) / det, std.cross(c2, c0) / det, std.cross(c0, c1) / det); +} + +export type Placed = d.WgslStruct<{ position: d.Vec3f; normal: d.Vec3f }>; + +export function transform(g: IndexedGeometry, m: d.m4x4f): IndexedGeometry; +export function transform(g: Geometry, m: d.m4x4f): Geometry; +export function transform(g: Geometry, m: d.m4x4f): Geometry { + const matrix = std.copy(m); + const columns = matrix.columns; + + const determinant = std.dot(columns[0].xyz, std.cross(columns[1].xyz, columns[2].xyz)); + if ( + !Number.isFinite(determinant) || + determinant === 0 || + columns[0].w !== 0 || + columns[1].w !== 0 || + columns[2].w !== 0 || + columns[3].w !== 1 || + ![...columns].every((column) => [...column].every(Number.isFinite)) + ) { + throw new Error('transform needs a finite invertible affine matrix'); + } + const normalM = normalMatrixOf(matrix); + const reflected = determinant < 0 && g.topology === 'triangle-list'; + const reverseVertices = reflected && !isIndexed(g); + + const result = { + ...g, + vertexAt: (i: number) => { + 'use gpu'; + let s = std.copy(g.vertexAt(reverseVertices ? reversedCorner(i) : i)); + s.position = (matrix * d.vec4f(s.position, 1)).xyz; + + const normal = normalM * s.normal; + if (std.dot(normal, normal) > 0) { + s.normal = std.normalize(normal); + } else { + s.normal = d.vec3f(); + } + + return s; + }, + }; + + if (reflected && isIndexed(g)) { + const indexed: IndexedGeometry = { + ...result, + indexCount: g.indexCount, + indexAt: (i: number) => { + 'use gpu'; + return g.indexAt(reversedCorner(i)); + }, + }; + return indexed; + } + + return result; +} + +export function map( + g: IndexedGeometry, + schema: V, + fn: (vertex: d.InferGPU) => d.InferGPU, +): IndexedGeometry; +export function map( + g: Geometry, + schema: V, + fn: (vertex: d.InferGPU) => d.InferGPU, +): Geometry; +export function map( + g: Geometry, + schema: V, + fn: (vertex: d.InferGPU) => d.InferGPU, +): Geometry { + return { + ...g, + schema, + vertexAt: (i: number) => { + 'use gpu'; + return fn(g.vertexAt(i)); + }, + }; +} + +export type Attached = d.WgslStruct< + VIn['propTypes'] & E['propTypes'] +>; + +const attachedSchemas = new WeakMap>(); + +function attachedSchema( + base: VIn, + extra: E, +): Attached { + for (const key of Object.keys(extra.propTypes)) { + if (Object.hasOwn(base.propTypes, key)) { + throw new Error(`attach cannot replace the existing field '${key}'; use map instead`); + } + } + + let byExtra = attachedSchemas.get(base); + if (!byExtra) { + byExtra = new WeakMap(); + attachedSchemas.set(base, byExtra); + } + + let schema = byExtra.get(extra); + if (!schema) { + const Vertex = d.struct({ ...base.propTypes, ...extra.propTypes } as Record< + string, + d.AnyWgslData + >); + byExtra.set(extra, Vertex); + schema = Vertex; + } + + return schema as Attached; +} + +export function attach( + g: IndexedGeometry, + extra: E, + fn: (vertex: d.InferGPU) => d.InferGPU, +): IndexedGeometry>; +export function attach( + g: Geometry, + extra: E, + fn: (vertex: d.InferGPU) => d.InferGPU, +): Geometry>; +export function attach( + g: Geometry, + extra: E, + fn: (vertex: d.InferGPU) => d.InferGPU, +): Geometry> { + const schema = attachedSchema(g.schema, extra); + const baseKeys = Object.keys(g.schema.propTypes); + const extraKeys = Object.keys(extra.propTypes); + + return { + ...g, + schema, + vertexAt: (i: number) => { + 'use gpu'; + const s = g.vertexAt(i); + const e = fn(s); + + let out: Record = schema(); + for (const key of tgpu.unroll(baseKeys)) { + out[key] = std.copy(s[key]); + } + for (const key of tgpu.unroll(extraKeys)) { + out[key] = std.copy(e[key]); + } + + return out as d.InferGPU>; + }, + }; +} + +export function unindexed(g: Geometry): Geometry { + if (!isIndexed(g)) { + return g; + } + + return { + schema: g.schema, + topology: g.topology, + vertexCount: g.indexCount, + vertexAt: (i: number) => { + 'use gpu'; + return g.vertexAt(g.indexAt(i)); + }, + }; +} + +type ConstructibleSchema = Extract unknown>; + +export function concat(...parts: Geometry[]): IndexedGeometry { + const first = parts[0]; + if (!first) { + throw new Error('concat needs at least one geometry'); + } + + const schema = first.schema; + const topology = first.topology; + + for (const g of parts) { + if (g.schema !== schema) { + throw new Error('concat needs one vertex schema across all of its parts'); + } + if (g.topology !== topology) { + throw new Error( + `concat needs one topology across all of its parts, got '${topology}' and '${g.topology}'`, + ); + } + } + + const vertexCount = parts.reduce((n, g) => n + g.vertexCount, 0); + + const vertexAt = (i: number) => { + 'use gpu'; + let local = i; + for (const g of tgpu.unroll(parts)) { + if (local < g.vertexCount) { + return g.vertexAt(local); + } + local -= g.vertexCount; + } + + return schema() as d.InferGPU; + }; + + let offset = 0; + const entries = parts.map((g) => { + const indexCount = isIndexed(g) ? g.indexCount : g.vertexCount; + const indexAt = isIndexed(g) + ? g.indexAt + : (i: number) => { + 'use gpu'; + return i; + }; + const entry = { indexAt, indexCount, vertexOffset: offset }; + offset += g.vertexCount; + return entry; + }); + + const result: IndexedGeometry = { + schema, + topology, + vertexCount, + vertexAt, + indexCount: entries.reduce((n, e) => n + e.indexCount, 0), + indexAt: (i: number) => { + 'use gpu'; + let local = i; + for (const e of tgpu.unroll(entries)) { + if (local < e.indexCount) { + return e.indexAt(local) + e.vertexOffset; + } + local -= e.indexCount; + } + + return d.u32(0); + }, + }; + + return result; +} + +type VertexSource = TgpuBuffer> & StorageFlag; +type IndexSource = TgpuBuffer> & StorageFlag; + +export interface FromBufferOptions { + indices?: IndexSource; + topology?: Topology; + vertexCount?: number; + indexCount?: number; +} + +export function fromBuffer( + vertices: VertexSource, + options: FromBufferOptions & { indices: IndexSource }, +): IndexedGeometry; +export function fromBuffer( + vertices: VertexSource, + options?: FromBufferOptions, +): Geometry; +export function fromBuffer( + vertices: VertexSource, + options: FromBufferOptions = {}, +): Geometry { + const indices = options.indices; + const topology = options.topology ?? 'triangle-list'; + const vertexCount = options.vertexCount ?? vertices.dataType.elementCount; + const indexCount = options.indexCount ?? indices?.dataType.elementCount ?? 0; + + for (const [kind, count, capacity] of [ + ['vertex', vertexCount, vertices.dataType.elementCount], + ['index', indexCount, indices?.dataType.elementCount ?? 0], + ] as const) { + if (!Number.isSafeInteger(count) || count < 0 || count > capacity) { + throw new Error(`The ${kind} count must be an integer between 0 and ${capacity}`); + } + } + + const readVertices = vertices.as('readonly'); + const base: Geometry = { + schema: vertices.dataType.elementType, + topology, + vertexCount, + vertexAt: (i: number) => { + 'use gpu'; + return std.copy(readVertices.$[i]) as d.InferGPU; + }, + }; + + if (!indices) { + return base; + } + + const readIndices = indices.as('readonly'); + const result: IndexedGeometry = { + ...base, + indexCount, + indexAt: (i: number) => { + 'use gpu'; + return readIndices.$[i] as number; + }, + }; + + return result; +} diff --git a/packages/typegpu-geometry/src/mesh/fill.ts b/packages/typegpu-geometry/src/mesh/fill.ts new file mode 100644 index 0000000000..3d9727a4b1 --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/fill.ts @@ -0,0 +1,66 @@ +import { + d, + tgpu, + type TgpuBindGroup, + type TgpuCommandEncoder, + type TgpuComputePass, + type WithBinding, +} from 'typegpu'; + +export interface UpdateOptions { + bindGroups?: readonly TgpuBindGroup[]; + encoder?: TgpuCommandEncoder | GPUCommandEncoder; + pass?: TgpuComputePass | GPUComputePassEncoder; +} + +export function createFill( + root: WithBinding, + count: number, + write: (index: number) => void, + bindGroups: readonly TgpuBindGroup[] = [], +) { + const x = Math.min(Math.ceil(count / 64), 65535); + const y = x === 0 ? 0 : Math.ceil(count / (x * 64)); + + const compute = tgpu.computeFn({ + workgroupSize: [64], + in: { gid: d.builtin.globalInvocationId }, + })(({ gid }) => { + 'use gpu'; + const i = gid.x + gid.y * (x * 64); + + if (i < count) { + write(i); + } + }); + + let pipeline = root.createComputePipeline({ compute }); + for (const group of bindGroups) { + pipeline = pipeline.with(group); + } + + return { + initAsync: async () => { + if (count > 0) await pipeline.initAsync(); + }, + run: (options: UpdateOptions = {}) => { + if (options.encoder && options.pass) { + throw new Error('Supply either an encoder or a compute pass, not both'); + } + if (count === 0) return; + + let target = pipeline; + for (const group of options.bindGroups ?? []) { + target = target.with(group); + } + + if (options.pass) { + target = target.with(options.pass as TgpuComputePass); + } else if (options.encoder) { + target = target.with(options.encoder as TgpuCommandEncoder); + } + + target.dispatchWorkgroups(x, y); + }, + }; +} diff --git a/packages/typegpu-geometry/src/mesh/geometry.ts b/packages/typegpu-geometry/src/mesh/geometry.ts new file mode 100644 index 0000000000..8da307179b --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/geometry.ts @@ -0,0 +1,36 @@ +import { d, tgpu, type TgpuVertexLayout } from 'typegpu'; + +/** Default vertex schema for primitives */ +export const Surface = d.struct({ position: d.vec3f, normal: d.vec3f, uv: d.vec2f }); + +export type Topology = 'triangle-list' | 'line-list' | 'point-list'; + +export interface Geometry { + readonly schema: V; + readonly topology: Topology; + readonly vertexCount: number; + + vertexAt(this: void, i: number): d.InferGPU; +} + +export interface IndexedGeometry extends Geometry { + readonly indexCount: number; + + indexAt(this: void, i: number): number; +} + +const layouts = new WeakMap(); + +export function isIndexed(g: Geometry): g is IndexedGeometry { + return typeof (g as IndexedGeometry).indexAt === 'function'; +} + +export function layoutOf(schema: V): TgpuVertexLayout> { + let layout = layouts.get(schema); + if (!layout) { + layout = tgpu.vertexLayout(d.arrayOf(schema)); + layouts.set(schema, layout); + } + + return layout as TgpuVertexLayout>; +} diff --git a/packages/typegpu-geometry/src/mesh/index.ts b/packages/typegpu-geometry/src/mesh/index.ts new file mode 100644 index 0000000000..fe4db633e9 --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/index.ts @@ -0,0 +1,8 @@ +export * from './bake.ts'; +export { attach, concat, map, transform, unindexed } from './combinators.ts'; +export type { Attached, Placed } from './combinators.ts'; +export { isIndexed, Surface } from './geometry.ts'; +export type { Geometry, IndexedGeometry, Topology } from './geometry.ts'; +export { parametric } from './parametric.ts'; +export type { Parametric, ParametricOptions } from './parametric.ts'; +export * from './primitives.ts'; diff --git a/packages/typegpu-geometry/src/mesh/math.ts b/packages/typegpu-geometry/src/mesh/math.ts new file mode 100644 index 0000000000..a23e01c3cc --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/math.ts @@ -0,0 +1,19 @@ +import { d } from 'typegpu'; + +export const TAU = Math.PI * 2; + +export const X = d.vec3f(1, 0, 0); +export const Y = d.vec3f(0, 1, 0); +export const Z = d.vec3f(0, 0, 1); +export const NX = d.vec3f(-1, 0, 0); +export const NY = d.vec3f(0, -1, 0); +export const NZ = d.vec3f(0, 0, -1); + +export function frame(tangent: d.v3f, normal: d.v3f, bitangent: d.v3f, center: d.v3f) { + return d.mat4x4f( + d.vec4f(tangent, 0), + d.vec4f(normal, 0), + d.vec4f(bitangent, 0), + d.vec4f(center, 1), + ); +} diff --git a/packages/typegpu-geometry/src/mesh/parametric.ts b/packages/typegpu-geometry/src/mesh/parametric.ts new file mode 100644 index 0000000000..f335b62634 --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/parametric.ts @@ -0,0 +1,88 @@ +import { d, std, tgpu } from 'typegpu'; +import { type IndexedGeometry, Surface } from './geometry.ts'; + +const QUAD = tgpu.const(d.arrayOf(d.vec2u, 6), [ + d.vec2u(0, 0), + d.vec2u(1, 0), + d.vec2u(1, 1), + d.vec2u(0, 0), + d.vec2u(1, 1), + d.vec2u(0, 1), +]); + +const EPS = 1e-3; + +export interface Parametric { + at(this: void, u: number, v: number): d.v3f; + normalAt?(this: void, u: number, v: number): d.v3f; +} + +export interface ParametricOptions { + cols: number; + rows: number; +} + +export function parametric( + surface: Parametric, + { cols, rows }: ParametricOptions, +): IndexedGeometry { + const at = surface.at; + const normalAt = + surface.normalAt ?? + ((u: number, v: number) => { + 'use gpu'; + const vv = std.clamp(v, 2 * EPS, 1 - 2 * EPS); + const du = at(std.min(u + EPS, 1), vv) - at(std.max(u - EPS, 0), vv); + const dv = at(u, vv + EPS) - at(u, vv - EPS); + const normal = std.cross(du, dv); + if (std.dot(normal, normal) > 0) { + return std.normalize(normal); + } + + return d.vec3f(); + }); + + return sampleGrid( + (u, v) => { + 'use gpu'; + return Surface({ position: at(u, v), normal: normalAt(u, v), uv: d.vec2f(u, v) }); + }, + { cols, rows }, + ); +} + +export function sampleGrid( + sample: (u: number, v: number) => d.Infer, + { cols, rows }: ParametricOptions, +): IndexedGeometry { + if (!Number.isSafeInteger(cols) || cols < 1 || !Number.isSafeInteger(rows) || rows < 1) { + throw new Error('parametric needs positive integer cols and rows'); + } + + return { + schema: Surface, + topology: 'triangle-list', + vertexCount: (cols + 1) * (rows + 1), + indexCount: cols * rows * 6, + + vertexAt: (i: number) => { + 'use gpu'; + const col = i % (cols + 1); + const row = std.intdiv(i, cols + 1); + const u = col / cols; + const v = row / rows; + + return sample(u, v); + }, + + indexAt: (i: number) => { + 'use gpu'; + const cell = std.intdiv(i, 6); + const corner = QUAD.$[i % 6] as d.v2u; + const col = (cell % cols) + corner.x; + const row = std.intdiv(cell, cols) + corner.y; + + return row * (cols + 1) + col; + }, + }; +} diff --git a/packages/typegpu-geometry/src/mesh/primitives.ts b/packages/typegpu-geometry/src/mesh/primitives.ts new file mode 100644 index 0000000000..3f15380bad --- /dev/null +++ b/packages/typegpu-geometry/src/mesh/primitives.ts @@ -0,0 +1,257 @@ +import { d, std } from 'typegpu'; +import { concat, transform } from './combinators.ts'; +import { type IndexedGeometry, Surface } from './geometry.ts'; +import { frame, NX, NY, NZ, TAU, X, Y, Z } from './math.ts'; +import { parametric, sampleGrid } from './parametric.ts'; + +export const surfaces = { + sphere: (u: number, v: number) => { + 'use gpu'; + const theta = v * Math.PI; + const phi = u * TAU; + const sinTheta = std.sin(theta); + + return d.vec3f(sinTheta * std.cos(phi), std.cos(theta), sinTheta * std.sin(phi)); + }, + plane: (u: number, v: number) => { + 'use gpu'; + return d.vec3f(u - 0.5, 0, 0.5 - v); + }, + disc: (u: number, v: number) => { + 'use gpu'; + const phi = u * TAU; + return d.vec3f(v * std.cos(phi), 0, v * std.sin(phi)); + }, + cylinder: (u: number, v: number) => { + 'use gpu'; + const phi = u * TAU; + return d.vec3f(std.cos(phi), 0.5 - v, std.sin(phi)); + }, + torus: (u: number, v: number, radius: number, tube: number) => { + 'use gpu'; + const phi = u * TAU; + const t = v * TAU; + const ring = radius + tube * std.cos(t); + + return d.vec3f(ring * std.cos(phi), -tube * std.sin(t), ring * std.sin(phi)); + }, +}; + +export interface SphereOptions { + radius?: number; + segments?: number; + rings?: number; +} + +export function sphere({ + radius = 0.5, + segments = 32, + rings = 16, +}: SphereOptions = {}): IndexedGeometry { + if (!Number.isFinite(radius) || radius <= 0) { + throw new Error('sphere needs a positive finite radius'); + } + if (segments < 3 || rings < 2) { + throw new Error('sphere needs at least 3 segments and 2 rings'); + } + const grid = sampleGrid( + (u, v) => { + 'use gpu'; + const normal = surfaces.sphere(u, v); + return Surface({ position: normal * radius, normal, uv: d.vec2f(u, v) }); + }, + { cols: segments, rows: rings }, + ); + const fan = segments * 3; + + return { + ...grid, + indexCount: 6 * segments * (rings - 1), + indexAt: (i: number) => { + 'use gpu'; + if (i < fan) return grid.indexAt(std.intdiv(i, 3) * 6 + (i % 3) + 3); + if (i >= grid.indexCount - 3 * fan) { + const last = i - (grid.indexCount - 3 * fan); + return grid.indexAt((rings - 1) * segments * 6 + std.intdiv(last, 3) * 6 + (last % 3)); + } + return grid.indexAt(i + fan); + }, + }; +} + +export interface PlaneOptions { + width?: number; + depth?: number; + widthSegments?: number; + depthSegments?: number; +} + +export function plane({ + width = 1, + depth = 1, + widthSegments = 1, + depthSegments = 1, +}: PlaneOptions = {}): IndexedGeometry { + if (![width, depth].every((value) => Number.isFinite(value) && value > 0)) { + throw new Error('plane needs positive finite dimensions'); + } + return parametric( + { + at: (u, v) => { + 'use gpu'; + return surfaces.plane(u, v) * d.vec3f(width, 1, depth); + }, + normalAt: () => { + 'use gpu'; + return d.vec3f(0, 1, 0); + }, + }, + { cols: widthSegments, rows: depthSegments }, + ); +} + +export interface BoxOptions { + width?: number; + height?: number; + depth?: number; + widthSegments?: number; + heightSegments?: number; + depthSegments?: number; +} + +export function box({ + width = 1, + height = 1, + depth = 1, + widthSegments = 1, + heightSegments = 1, + depthSegments = 1, +}: BoxOptions = {}): IndexedGeometry { + const top = plane({ width, depth, widthSegments, depthSegments }); + const front = plane({ width, depth: height, widthSegments, depthSegments: heightSegments }); + const side = plane({ + width: depth, + depth: height, + widthSegments: depthSegments, + depthSegments: heightSegments, + }); + + const x = width / 2; + const y = height / 2; + const z = depth / 2; + + return concat( + transform(top, frame(X, Y, Z, d.vec3f(0, y, 0))), + transform(top, frame(X, NY, NZ, d.vec3f(0, -y, 0))), + transform(front, frame(X, Z, NY, d.vec3f(0, 0, z))), + transform(front, frame(NX, NZ, NY, d.vec3f(0, 0, -z))), + transform(side, frame(NZ, X, NY, d.vec3f(x, 0, 0))), + transform(side, frame(Z, NX, NY, d.vec3f(-x, 0, 0))), + ); +} + +export interface CylinderOptions { + radius?: number; + height?: number; + radialSegments?: number; + heightSegments?: number; + caps?: boolean; +} + +export function cylinder({ + radius = 0.5, + height = 1, + radialSegments = 32, + heightSegments = 1, + caps = true, +}: CylinderOptions = {}): IndexedGeometry { + if (![radius, height].every((value) => Number.isFinite(value) && value > 0)) { + throw new Error('cylinder needs positive finite dimensions'); + } + if (radialSegments < 3) { + throw new Error('cylinder needs at least 3 radial segments'); + } + const side = sampleGrid( + (u, v) => { + 'use gpu'; + const phi = u * TAU; + const normal = d.vec3f(std.cos(phi), 0, std.sin(phi)); + return Surface({ + position: normal * radius + d.vec3f(0, (0.5 - v) * height, 0), + normal, + uv: d.vec2f(u, v), + }); + }, + { cols: radialSegments, rows: heightSegments }, + ); + + if (!caps) { + return side; + } + + const cap: IndexedGeometry = { + schema: Surface, + topology: 'triangle-list', + vertexCount: radialSegments + 1, + indexCount: radialSegments * 3, + vertexAt: (i: number) => { + 'use gpu'; + let direction = d.vec3f(); + if (i > 0) direction = surfaces.disc((i - 1) / radialSegments, 1); + return Surface({ + position: direction * radius, + normal: d.vec3f(0, 1, 0), + uv: direction.xz * 0.5 + 0.5, + }); + }, + indexAt: (i: number) => { + 'use gpu'; + const triangle = std.intdiv(i, 3); + if (i % 3 === 0) return 0; + if (i % 3 === 1) return ((triangle + 1) % radialSegments) + 1; + return triangle + 1; + }, + }; + + return concat( + side, + transform(cap, frame(X, Y, Z, d.vec3f(0, height / 2, 0))), + transform(cap, frame(X, NY, NZ, d.vec3f(0, -height / 2, 0))), + ); +} + +export interface TorusOptions { + radius?: number; + tube?: number; + ringSegments?: number; + tubeSegments?: number; +} + +export function torus({ + radius = 0.35, + tube = 0.15, + ringSegments = 48, + tubeSegments = 24, +}: TorusOptions = {}): IndexedGeometry { + if (![radius, tube].every((value) => Number.isFinite(value) && value > 0)) { + throw new Error('torus needs positive finite radii'); + } + if (ringSegments < 3 || tubeSegments < 3) { + throw new Error('torus needs at least 3 ring and tube segments'); + } + return sampleGrid( + (u, v) => { + 'use gpu'; + const phi = u * TAU; + const t = v * TAU; + const radial = d.vec3f(std.cos(phi), 0, std.sin(phi)); + const normal = radial * std.cos(t) - d.vec3f(0, std.sin(t), 0); + return Surface({ + position: radial * radius + normal * tube, + normal, + uv: d.vec2f(u, v), + }); + }, + { cols: ringSegments, rows: tubeSegments }, + ); +} diff --git a/packages/typegpu-geometry/tests/bake.test.ts b/packages/typegpu-geometry/tests/bake.test.ts new file mode 100644 index 0000000000..94c08d3763 --- /dev/null +++ b/packages/typegpu-geometry/tests/bake.test.ts @@ -0,0 +1,180 @@ +import { expect } from 'vitest'; +import { d, tgpu } from 'typegpu'; +import { it } from 'typegpu-testing-utility'; +import { meshes } from '@typegpu/geometry'; +import { fromBuffer } from '../src/mesh/combinators.ts'; +import { scalars, shaderCodes } from './fixtures.ts'; + +it('maps and unindexes in a shader', () => { + const doubled = meshes.map(scalars, d.f32, (value) => { + 'use gpu'; + return value * 2; + }); + const flat = meshes.unindexed(doubled); + const read = tgpu.fn([d.u32], d.f32)(flat.vertexAt); + + expect(tgpu.resolve([read])).toMatchInlineSnapshot(` + "fn indexAt(i: u32) -> u32 { + return (i % 4u); + } + + fn vertexAt_2(i: u32) -> f32 { + return f32(i); + } + + fn item(value: f32) -> f32 { + return (value * 2f); + } + + fn vertexAt_1(i: u32) -> f32 { + return item(vertexAt_2(i)); + } + + fn vertexAt(i: u32) -> f32 { + return vertexAt_1(indexAt(i)); + }" + `); +}); + +it('prepares vertex and index fills before submitting', ({ root, device }) => { + meshes.bake(root, scalars); + + expect(shaderCodes(device).join('\n\n')).toMatchInlineSnapshot(` + "@group(0) @binding(0) var output: array; + + fn vertexAt(i: u32) -> f32 { + return f32(i); + } + + fn item(i: u32) { + output[i] = vertexAt(i); + } + + @compute @workgroup_size(64) fn compute(@builtin(global_invocation_id) gid: vec3u) { + let i = (gid.x + (gid.y * 64u)); + if ((i < 4u)) { + item(i); + } + } + + @group(0) @binding(0) var writeIndices: array; + + fn indexAt(i: u32) -> u32 { + return (i % 4u); + } + + fn item(i: u32) { + writeIndices[i] = indexAt(i); + } + + @compute @workgroup_size(64) fn compute(@builtin(global_invocation_id) gid: vec3u) { + let i = (gid.x + (gid.y * 64u)); + if ((i < 6u)) { + item(i); + } + }" + `); +}); + +it('injects the vertex and index buffers', ({ root, renderPassEncoder }) => { + const mesh = meshes.bake(root, meshes.plane()); + const pipeline = root + .createRenderPipeline({ + attribs: mesh.layout.attrib, + vertex: ({ position }) => { + 'use gpu'; + return { $position: d.vec4f(position, 1) }; + }, + }) + .pipe(mesh.inject()); + + pipeline.with(renderPassEncoder).drawIndexed(mesh.indexCount); + + expect(mesh.layout).toBe(meshes.bake(root, meshes.plane()).layout); + expect(renderPassEncoder.mock.setVertexBuffer).toHaveBeenCalledWith( + 0, + root.unwrap(mesh.vertices), + undefined, + undefined, + ); + expect(renderPassEncoder.mock.setIndexBuffer).toHaveBeenCalledWith( + root.unwrap(mesh.indices), + 'uint32', + undefined, + undefined, + ); +}); + +it('retains caller-owned buffers and uses geometry counts instead of capacity', ({ root }) => { + const vertices = root.createBuffer(d.arrayOf(d.f32, 64)).$usage('vertex', 'storage'); + const indices = root.createBuffer(d.arrayOf(d.u32, 64)).$usage('index', 'storage'); + const mesh = meshes.bake(root, scalars, { vertices, indices }); + mesh.destroy(); + + expect([mesh.vertexCount, mesh.indexCount]).toEqual([4, 6]); + expect(mesh.vertices).toBe(vertices); + expect(mesh.indices).toBe(indices); + expect(vertices.destroyed).toBe(false); + expect(indices.destroyed).toBe(false); +}); + +it('destroys buffers it allocated', ({ root }) => { + const mesh = meshes.bake(root, scalars); + mesh.destroy(); + + expect(mesh.vertices.destroyed).toBe(true); + expect(mesh.indices.destroyed).toBe(true); +}); + +it('rejects counts outside the buffer capacity', ({ root }) => { + const vertices = root.createBuffer(d.arrayOf(d.f32, 4)).$usage('storage'); + const indices = root.createBuffer(d.arrayOf(d.u32, 6)).$usage('storage'); + + for (const vertexCount of [-1, 1.5, 5, NaN]) { + expect(() => fromBuffer(vertices, { vertexCount })).toThrowErrorMatchingInlineSnapshot( + `[Error: The vertex count must be an integer between 0 and 4]`, + ); + } + for (const indexCount of [-1, 1.5, 7, NaN]) { + expect(() => fromBuffer(vertices, { indices, indexCount })).toThrowErrorMatchingInlineSnapshot( + `[Error: The index count must be an integer between 0 and 6]`, + ); + } + expect(fromBuffer(vertices, { indices, vertexCount: 0, indexCount: 0 }).indexCount).toBe(0); +}); + +it('exposes writable vertices and allows mapped reads in another shader', ({ root }) => { + const mesh = meshes.bake(root, scalars); + const copied = meshes.map(mesh, d.f32, (value) => { + 'use gpu'; + return value + 1; + }); + const vertices = mesh.vertices.as('mutable'); + const edit = tgpu.computeFn({ workgroupSize: [1] })(() => { + 'use gpu'; + vertices.$[0] = 1; + }); + const read = tgpu.fn([d.u32], d.f32)(copied.vertexAt); + + expect(tgpu.resolve([edit, read])).toMatchInlineSnapshot(` + "@group(0) @binding(0) var output: array; + + @compute @workgroup_size(1) fn edit() { + output[0i] = 1f; + } + + @group(0) @binding(1) var output_1: array; + + fn vertexAt_1(i: u32) -> f32 { + return output_1[i]; + } + + fn item(value: f32) -> f32 { + return (value + 1f); + } + + fn vertexAt(i: u32) -> f32 { + return item(vertexAt_1(i)); + }" + `); +}); diff --git a/packages/typegpu-geometry/tests/fixtures.ts b/packages/typegpu-geometry/tests/fixtures.ts new file mode 100644 index 0000000000..b32ff1c745 --- /dev/null +++ b/packages/typegpu-geometry/tests/fixtures.ts @@ -0,0 +1,22 @@ +import { vi } from 'vitest'; +import { d } from 'typegpu'; +import type { meshes } from '@typegpu/geometry'; + +export const scalars: meshes.IndexedGeometry = { + schema: d.f32, + topology: 'triangle-list', + vertexCount: 4, + indexCount: 6, + vertexAt: (i) => { + 'use gpu'; + return d.f32(i); + }, + indexAt: (i) => { + 'use gpu'; + return i % 4; + }, +}; + +export function shaderCodes(device: GPUDevice) { + return vi.mocked(device.createShaderModule).mock.calls.map(([{ code }]) => code); +} diff --git a/packages/typegpu-geometry/tests/mesh.test.ts b/packages/typegpu-geometry/tests/mesh.test.ts new file mode 100644 index 0000000000..94acc3a3f1 --- /dev/null +++ b/packages/typegpu-geometry/tests/mesh.test.ts @@ -0,0 +1,206 @@ +import { describe, expect } from 'vitest'; +import { d, std } from 'typegpu'; +import { it } from 'typegpu-testing-utility'; +import { meshes } from '@typegpu/geometry'; + +function windingAgreesWithNormals(g: meshes.IndexedGeometry) { + for (let t = 0; t < g.indexCount; t += 3) { + const a = g.vertexAt(g.indexAt(t)); + const b = g.vertexAt(g.indexAt(t + 1)); + const c = g.vertexAt(g.indexAt(t + 2)); + const face = std.cross(b.position.sub(a.position), c.position.sub(a.position)); + if (std.length(face) < 1e-9) { + continue; + } + const n = a.normal.add(b.normal).add(c.normal); + if (std.dot(face, n) <= 0) { + return false; + } + } + return true; +} + +describe('meshes on the CPU', () => { + it('rejects subdivisions that would produce invalid coordinates or connectivity', () => { + for (const count of [0, -1, 1.5, NaN, Infinity]) { + expect(() => meshes.plane({ widthSegments: count })).toThrowErrorMatchingInlineSnapshot( + `[Error: parametric needs positive integer cols and rows]`, + ); + } + }); + + it('rejects attached fields that collide with the source schema', () => { + const ReplacedPosition = d.struct({ position: d.f32 }); + + expect(() => + meshes.attach(meshes.plane(), ReplacedPosition, () => ReplacedPosition({ position: 1 })), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: attach cannot replace the existing field 'position'; use map instead]`, + ); + }); + + it.each([ + ['plane', meshes.plane()], + ['sphere', meshes.sphere({ segments: 8, rings: 4 })], + ['box', meshes.box()], + ['cylinder', meshes.cylinder({ radialSegments: 6 })], + ['torus', meshes.torus({ ringSegments: 6, tubeSegments: 4 })], + ])('%s fits a unit cube with outward unit normals', (_, shape) => { + expect(windingAgreesWithNormals(shape)).toBe(true); + + for (let i = 0; i < shape.vertexCount; i++) { + const { position, normal } = shape.vertexAt(i); + expect( + Math.max(Math.abs(position.x), Math.abs(position.y), Math.abs(position.z)), + ).toBeLessThanOrEqual(0.5 + 1e-6); + expect(std.length(normal)).toBeCloseTo(1); + } + }); + + it('scales each box dimension independently', () => { + const slab = meshes.box({ width: 2, height: 0.5, depth: 4 }); + let extent = d.vec3f(); + for (let i = 0; i < slab.vertexCount; i++) { + extent = std.max(extent, std.abs(slab.vertexAt(i).position)); + } + + expect([...extent]).toEqual([1, 0.25, 2]); + }); + + it('keeps collapsed surface normals zero through transforms', () => { + const collapsed = meshes.parametric( + { + at: () => { + 'use gpu'; + return d.vec3f(); + }, + }, + { cols: 1, rows: 1 }, + ); + const moved = meshes.transform(collapsed, std.translation4(d.vec3f(1, 2, 3))); + + expect(collapsed.vertexAt(0).normal).toEqual(d.vec3f()); + expect(moved.vertexAt(0).normal).toEqual(d.vec3f()); + }); + + it('keeps concat indexed and offsets the indices of later parts', () => { + const a = meshes.plane(); + const b = meshes.transform(meshes.plane(), std.translation4(d.vec3f(0, 1, 0))); + const g = meshes.concat(a, b); + + expect(g.vertexCount).toBe(8); + expect(g.indexCount).toBe(12); + expect(g.indexAt(6)).toBe(4); + expect(g.vertexAt(g.indexAt(6)).position.y).toBeCloseTo(1); + + const flat = meshes.unindexed(g); + expect(flat.vertexCount).toBe(12); + expect(flat.vertexAt(11).position.y).toBeCloseTo(1); + + const mixed = meshes.concat(a, { ...meshes.unindexed(b), vertexCount: 3 }); + expect([mixed.vertexCount, mixed.indexCount]).toEqual([7, 9]); + expect([6, 7, 8].map(mixed.indexAt)).toEqual([4, 5, 6]); + }); + + it('returns zero vertices past the end of concatenated geometry', () => { + const plane = meshes.plane(); + const scalar = meshes.map(plane, d.f32, (vertex) => { + 'use gpu'; + return vertex.position.x; + }); + const scalars: meshes.IndexedGeometry = meshes.concat(scalar, scalar); + const surfaces = meshes.concat(plane, plane); + + expect(scalars.vertexAt(scalars.vertexCount)).toBe(0); + expect(surfaces.vertexAt(surfaces.vertexCount)).toEqual(meshes.Surface()); + }); + + it('attaches fields and shares the schema between calls', () => { + const Paint = d.struct({ color: d.vec3f, id: d.u32 }); + const paint = (g: meshes.IndexedGeometry, id: number) => + meshes.attach(g, Paint, (s) => { + 'use gpu'; + return Paint({ color: d.vec3f(s.uv, 0), id }); + }); + const a = paint(meshes.plane(), 1); + const b = paint(meshes.sphere({ segments: 4, rings: 2 }), 2); + const v = a.vertexAt(3); + const scene = meshes.concat(a, b); + + expect(a.schema).toBe(b.schema); + expect(Object.keys(a.schema.propTypes)).toEqual(['position', 'normal', 'uv', 'color', 'id']); + expect(v.position.x).toBeCloseTo(0.5); + expect(v.color.x).toBeCloseTo(1); + expect(v.id).toBe(1); + expect(scene.vertexAt(4).id).toBe(2); + }); + + it('preserves outward winding under reflections', () => { + for (const source of [meshes.plane(), meshes.unindexed(meshes.plane())]) { + const flat = meshes.unindexed(meshes.transform(source, std.scaling4(d.vec3f(-2, 3, 0.5)))); + const a = flat.vertexAt(0), + b = flat.vertexAt(1), + c = flat.vertexAt(2); + const face = std.cross(b.position.sub(a.position), c.position.sub(a.position)); + + expect(std.dot(face, a.normal)).toBeGreaterThan(0); + expect(std.length(a.normal)).toBeCloseTo(1); + } + }); + + it('transforms a custom schema and keeps its other fields', () => { + const Tagged = d.struct({ position: d.vec3f, normal: d.vec3f, tag: d.f32 }); + const tagged = meshes.map(meshes.plane(), Tagged, (s) => { + 'use gpu'; + return Tagged({ position: s.position, normal: s.normal, tag: s.uv.x }); + }); + const moved = meshes.transform(tagged, std.translation4(d.vec3f(0, 2, 0))); + + expect(moved.schema).toBe(Tagged); + const v = moved.vertexAt(1); + expect(v.position.y).toBe(2); + expect(v.tag).toBe(1); + }); + + it('samples normals inside the unit square', () => { + const shape = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + return d.vec3f(std.sqrt(u), 0, 1 - v); + }, + }, + { cols: 1, rows: 1 }, + ); + for (let i = 0; i < shape.vertexCount; i++) { + expect(std.distance(shape.vertexAt(i).normal, d.vec3f(0, 1, 0))).toBeCloseTo(0); + } + }); + + it('does not mutate cached source vertices when transforming', () => { + const vertex = meshes.plane().vertexAt(0); + const shape = { ...meshes.plane(), vertexAt: () => vertex }; + const moved = meshes.transform(shape, std.translation4(d.vec3f(0, 2, 0))); + expect(moved.vertexAt(0).position.y).toBe(2); + expect(moved.vertexAt(0).position.y).toBe(2); + expect(vertex.position.y).toBe(0); + + expect(() => + meshes.transform(shape, std.scaling4(d.vec3f(1, 0, 1))), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: transform needs a finite invertible affine matrix]`, + ); + }); + + it.each([meshes.sphere(), meshes.cylinder()])( + 'omits collapsed pole and cap triangles', + (shape) => { + for (let i = 0; i < shape.indexCount; i += 3) { + const a = shape.vertexAt(shape.indexAt(i)).position; + const b = shape.vertexAt(shape.indexAt(i + 1)).position; + const c = shape.vertexAt(shape.indexAt(i + 2)).position; + expect(std.length(std.cross(b.sub(a), c.sub(a)))).toBeGreaterThan(1e-9); + } + }, + ); +}); diff --git a/packages/typegpu-geometry/tests/updates.test.ts b/packages/typegpu-geometry/tests/updates.test.ts new file mode 100644 index 0000000000..3954c572ba --- /dev/null +++ b/packages/typegpu-geometry/tests/updates.test.ts @@ -0,0 +1,190 @@ +import { expect, vi } from 'vitest'; +import { it } from 'typegpu-testing-utility'; +import { d, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { scalars, shaderCodes } from './fixtures.ts'; + +for (const asynchronous of [false, true]) { + it(`reuses the initial fill pipeline (async: ${asynchronous})`, async ({ root, device }) => { + const mesh = asynchronous ? await meshes.bakeAsync(root, scalars) : meshes.bake(root, scalars); + expect(device.mock.createComputePipeline).toHaveBeenCalledTimes(asynchronous ? 0 : 2); + expect(device.mock.createComputePipelineAsync).toHaveBeenCalledTimes(asynchronous ? 2 : 0); + device.mock.createBuffer.mockClear(); + device.mock.createBindGroup.mockClear(); + device.mock.createComputePipeline.mockClear(); + device.mock.createComputePipelineAsync.mockClear(); + + mesh.updateVertices(); + mesh.updateVertices(); + + expect(device.mock.createBuffer).not.toHaveBeenCalled(); + expect(device.mock.createBindGroup).not.toHaveBeenCalled(); + expect(device.mock.createComputePipeline).not.toHaveBeenCalled(); + expect(device.mock.createComputePipelineAsync).not.toHaveBeenCalled(); + }); +} + +it('bakes indices from baked geometry without reading GPU values on the CPU', ({ root }) => { + const source = meshes.bake(root, scalars); + const mesh = meshes.bakeIndices(root, source); + mesh.destroy(); + + expect([mesh.vertexCount, mesh.indexCount]).toEqual([4, 6]); + expect(mesh.indices.destroyed).toBe(true); + expect(source.vertices.destroyed).toBe(false); + expect(source.indices.destroyed).toBe(false); +}); + +it('specializes the vertex evaluator through a configured pipeline builder', ({ root }) => { + const height = tgpu.accessor(d.f32); + const source = { + ...scalars, + vertexAt: () => { + 'use gpu'; + return height.$; + }, + }; + const mesh = meshes.bake(root, source, { with: root.with(height, 2) }); + expect(mesh.vertexCount).toBe(4); +}); + +it('does not submit or retain a caller-supplied encoder', ({ root, device }) => { + const encoder = root['~unstable'].createCommandEncoder(); + const mesh = meshes.bake(root, scalars, { encoder }); + mesh.updateVertices({ encoder }); + expect(device.mock.queue.submit).not.toHaveBeenCalled(); + + encoder.submit(); + mesh.updateVertices(); + + expect(device.mock.queue.submit).toHaveBeenCalledTimes(2); +}); + +it('records into a supplied pass without ending it', ({ root, device, commandEncoder }) => { + const mesh = meshes.bake(root, scalars); + const pass = commandEncoder.beginComputePass(); + const nativePass = commandEncoder.mock.beginComputePass.mock.results[0]?.value; + nativePass.end.mockClear(); + commandEncoder.mock.beginComputePass.mockClear(); + device.mock.queue.submit.mockClear(); + + mesh.updateVertices({ pass }); + + expect(commandEncoder.mock.beginComputePass).not.toHaveBeenCalled(); + expect(nativePass.end).not.toHaveBeenCalled(); + expect(device.mock.queue.submit).not.toHaveBeenCalled(); +}); + +it('overrides source bindings for one update', ({ root, device, commandEncoder }) => { + let nextBindGroup = 0; + device.mock.createBindGroup.mockImplementation(() => `bindGroup${nextBindGroup++}`); + const config = tgpu.bindGroupLayout({ height: { uniform: d.f32 } }); + const initial = root.createBindGroup(config, { height: root.createUniform(d.f32, 2) }); + const override = root.createBindGroup(config, { height: root.createUniform(d.f32, 3) }); + const mesh = meshes.bake( + root, + { + schema: d.f32, + topology: 'point-list', + vertexCount: 1, + vertexAt: () => { + 'use gpu'; + return config.$.height; + }, + }, + { bindGroups: [initial] }, + ); + const pass = commandEncoder.mock.beginComputePass.mock.results[0]?.value; + pass.setBindGroup.mockClear(); + + mesh.updateVertices({ bindGroups: [override] }); + mesh.updateVertices(); + + const inputs = pass.setBindGroup.mock.calls + .map((call: unknown[]) => call[1]) + .filter((group: unknown) => group === root.unwrap(initial) || group === root.unwrap(override)); + expect(inputs).toEqual([root.unwrap(override), root.unwrap(initial)]); + expect(device.mock.createComputePipeline).toHaveBeenCalledOnce(); +}); + +it('spans multiple dispatch rows without evaluating vertices', ({ + root, + device, + commandEncoder, +}) => { + const position = tgpu.accessor(d.f32); + meshes.bakeIndices(root, { + ...scalars, + indexCount: 64 * 65535 + 1, + vertexAt: () => { + 'use gpu'; + return position.$; + }, + }); + const pass = commandEncoder.mock.beginComputePass.mock.results[0]?.value; + + expect(device.mock.createBuffer).toHaveBeenCalledOnce(); + expect(pass.dispatchWorkgroups).toHaveBeenCalledWith(65535, 2, undefined); + expect(shaderCodes(device)[0]).toMatchInlineSnapshot(` + "@group(0) @binding(0) var output: array; + + fn indexAt(i: u32) -> u32 { + return (i % 4u); + } + + fn item(i: u32) { + output[i] = indexAt(i); + } + + @compute @workgroup_size(64) fn compute(@builtin(global_invocation_id) gid: vec3u) { + let i = (gid.x + (gid.y * 4194240u)); + if ((i < 4194241u)) { + item(i); + } + }" + `); +}); + +it('prepares index-only baking asynchronously without evaluating vertices', async ({ + root, + device, +}) => { + const position = tgpu.accessor(d.f32); + const mesh = await meshes.bakeIndicesAsync(root, { + ...scalars, + vertexAt: () => { + 'use gpu'; + return position.$; + }, + }); + expect(mesh.indexCount).toBe(6); + expect(device.mock.createComputePipelineAsync).toHaveBeenCalledOnce(); + expect(device.mock.createComputePipeline).not.toHaveBeenCalled(); +}); + +for (const asynchronous of [false, true]) { + it(`cleans up failed baking and retains caller buffers (async: ${asynchronous})`, async ({ + root, + device, + }) => { + const vertices = root.createBuffer(d.arrayOf(d.f32, 4)).$usage('vertex', 'storage'); + const allocated = vi.spyOn(root, 'createBuffer'); + const compile = asynchronous + ? device.mock.createComputePipelineAsync + : device.mock.createComputePipeline; + compile.mockImplementationOnce(() => { + throw new Error('Compilation failed'); + }); + + await expect( + Promise.resolve().then(() => + asynchronous + ? meshes.bakeAsync(root, scalars, { vertices }) + : meshes.bake(root, scalars, { vertices }), + ), + ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Compilation failed]`); + expect(allocated.mock.results.map(({ value }) => value.destroyed)).toEqual([true]); + expect(vertices.destroyed).toBe(false); + expect(device.mock.queue.submit).not.toHaveBeenCalled(); + }); +} diff --git a/packages/typegpu-geometry/tsconfig.test.json b/packages/typegpu-geometry/tsconfig.test.json new file mode 100644 index 0000000000..f3b3ce014a --- /dev/null +++ b/packages/typegpu-geometry/tsconfig.test.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "tests/**/*", "vitest.config.mts"] +} diff --git a/packages/typegpu-geometry/vitest.config.mts b/packages/typegpu-geometry/vitest.config.mts new file mode 100644 index 0000000000..3367210cff --- /dev/null +++ b/packages/typegpu-geometry/vitest.config.mts @@ -0,0 +1,16 @@ +import { createJiti } from 'jiti'; +import type TypeGPUPlugin from 'unplugin-typegpu/vite'; +import { defineConfig } from 'vitest/config'; +import { typegpuBuiltAliases } from 'typegpu-testing-utility/config'; + +const jiti = createJiti(import.meta.url); +const typegpu = await jiti.import('unplugin-typegpu/vite', { + default: true, +}); + +export default defineConfig({ + plugins: [typegpu({ forceTgpuAlias: 'tgpu', earlyPruning: false })], + resolve: { + alias: typegpuBuiltAliases(), + }, +}); diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index c4c2f0cf0c..6daba4eb63 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -66,7 +66,11 @@ export type { } from './core/root/rootTypes.ts'; export type { Storage, StorageFlag } from './extension.ts'; export type { TgpuVertexLayout } from './core/vertexLayout/vertexLayout.ts'; -export type { TgpuPrimitiveState, TgpuRenderPipeline } from './core/pipeline/renderPipeline.ts'; +export type { + HasIndexBuffer, + TgpuPrimitiveState, + TgpuRenderPipeline, +} from './core/pipeline/renderPipeline.ts'; export type { ColorAttachment, DepthStencilAttachment } from './core/commandEncoder/attachments.ts'; export type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; export type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d47e15f24..4e8adf0e47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -781,9 +781,15 @@ importers: '@webgpu/types': specifier: catalog:types version: 0.1.71 + jiti: + specifier: catalog:build + version: 2.6.1 typegpu: specifier: workspace:* version: link:../typegpu + typegpu-testing-utility: + specifier: workspace:* + version: link:../typegpu-testing-utility typescript: specifier: npm:tsover@^6.0.2 version: tsover@6.0.2 @@ -793,6 +799,9 @@ importers: unplugin-typegpu: specifier: workspace:* version: link:../unplugin-typegpu + vitest: + specifier: catalog:test + version: 4.1.2(@types/node@24.10.0)(@vitest/browser-preview@4.1.2)(jsdom@27.0.0(postcss@8.5.8))(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.10.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3)) publishDirectory: dist packages/typegpu-gl: From 5d95efd43c351685d837db4383715c72b72721c8 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 7 Sep 2026 15:47:28 +0200 Subject: [PATCH 02/18] Add mesh primitives example --- .../rendering/mesh-primitives/blob.ts | 19 +++ .../rendering/mesh-primitives/gallery.ts | 14 ++ .../rendering/mesh-primitives/geometry.ts | 20 +++ .../rendering/mesh-primitives/index.html | 1 + .../rendering/mesh-primitives/index.ts | 146 ++++++++++++++++++ .../rendering/mesh-primitives/meta.json | 6 + .../rendering/mesh-primitives/shading.ts | 44 ++++++ .../rendering/mesh-primitives/spring.ts | 92 +++++++++++ .../mesh-primitives.test.ts | 51 ++++++ 9 files changed, 393 insertions(+) create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/blob.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.html create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/spring.ts create mode 100644 apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/blob.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/blob.ts new file mode 100644 index 0000000000..438cc61f5b --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/blob.ts @@ -0,0 +1,19 @@ +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { paint } from './geometry.ts'; +import { sceneLayout } from './shading.ts'; + +const surface = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + const time = sceneLayout.$.uniforms.time; + const p = meshes.surfaces.sphere(u, v); + const ripple = std.sin(p.x * 4 + time * 2) * std.sin(p.y * 3 - time) * std.sin(p.z * 5); + return p * (0.55 + 0.15 * ripple) + d.vec3f(0, 0.55, 0); + }, + }, + { cols: 96, rows: 64 }, +); + +export const blob = paint(surface, d.vec3f(0.9)); diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts new file mode 100644 index 0000000000..a13f394367 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts @@ -0,0 +1,14 @@ +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { place } from './geometry.ts'; + +const floor = meshes.plane({ width: 6, depth: 6 }); +const twoSidedFloor = meshes.concat(floor, meshes.transform(floor, std.rotationX4(Math.PI))); + +export const gallery = meshes.concat( + place(twoSidedFloor, d.vec3f(0, -0.51, 0), d.vec3f(0.75, 0.75, 0.8)), + place(meshes.box(), d.vec3f(-1.5, 0, 1), d.vec3f(0.9, 0.35, 0.3)), + place(meshes.cylinder(), d.vec3f(1.5, 0, 1), d.vec3f(0.3, 0.6, 0.9)), + place(meshes.torus(), d.vec3f(1.5, -0.35, -1), d.vec3f(0.95, 0.75, 0.2)), + place(meshes.sphere(), d.vec3f(-1.5, 0, -1), d.vec3f(0.4, 0.85, 0.5)), +); diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts new file mode 100644 index 0000000000..77d177e825 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts @@ -0,0 +1,20 @@ +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; + +const Paint = d.struct({ color: d.vec3f }); + +export function paint(g: meshes.IndexedGeometry, color: d.v3f) { + return meshes.attach(g, Paint, () => { + 'use gpu'; + + return Paint({ color }); + }); +} + +export function place( + g: meshes.IndexedGeometry, + at: d.v3f, + color: d.v3f, +) { + return paint(meshes.transform(g, std.translation4(at)), color); +} diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.html b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.html new file mode 100644 index 0000000000..581d6789f8 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.html @@ -0,0 +1 @@ + diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts new file mode 100644 index 0000000000..1a1c5cca6c --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts @@ -0,0 +1,146 @@ +import { d, std, tgpu } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { mat4 } from 'wgpu-matrix'; +import { setupOrbitCamera } from '../../common/setup-orbit-camera.ts'; +import { blob } from './blob.ts'; +import { gallery } from './gallery.ts'; +import { place } from './geometry.ts'; +import { + fragment, + SceneUniforms, + sceneLayout, + shadowLayout, + worldToClip, + worldToLight, +} from './shading.ts'; +import { spring } from './spring.ts'; + +const root = await tgpu.init(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = root.configureContext({ canvas, alphaMode: 'premultiplied' }); + +const uniforms = root.createUniform(SceneUniforms); +const sceneBindGroup = root.createBindGroup(sceneLayout, { uniforms }); + +const shadowMap = root + .createTexture({ size: [2048, 2048], format: 'depth32float' }) + .$usage('render', 'sampled'); + +const shadowBindGroup = root.createBindGroup(shadowLayout, { + map: shadowMap, + sampler: root.createComparisonSampler({ + compare: 'less-equal', + magFilter: 'linear', + minFilter: 'linear', + }), +}); + +function createDepth() { + return root + .createTexture({ size: [canvas.width, canvas.height], format: 'depth24plus' }) + .$usage('transient'); +} + +let depth = createDepth(); + +const scene = meshes.bakeIndices( + root, + meshes.concat(gallery, blob, place(spring, d.vec3f(0, 0.07, 2.2), d.vec3f(0.7, 0.45, 0.95))), +); + +const shadow = root + .createRenderPipeline({ + vertex: ({ $vertexIndex }) => { + 'use gpu'; + return { $position: worldToLight(scene.vertexAt($vertexIndex).position) }; + }, + depthStencil: { + format: 'depth32float', + depthWriteEnabled: true, + depthCompare: 'less', + depthBias: 1, + depthBiasSlopeScale: 2, + }, + primitive: { cullMode: 'back' }, + }) + .with(sceneBindGroup) + .pipe(scene.inject()); + +const lit = root + .createRenderPipeline({ + vertex: ({ $vertexIndex }) => { + 'use gpu'; + const vertex = scene.vertexAt($vertexIndex); + return { + $position: worldToClip(vertex.position), + worldPos: vertex.position, + normal: vertex.normal, + color: vertex.color, + }; + }, + fragment, + depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, + primitive: { cullMode: 'back' }, + }) + .with(sceneBindGroup) + .with(shadowBindGroup) + .pipe(scene.inject()); + +const { cleanupCamera } = setupOrbitCamera( + canvas, + { initPos: d.vec4f(3.2, 2.2, 3.7, 1), target: d.vec4f(0, 0.3, 0, 1), minZoom: 2, maxZoom: 12 }, + (camera) => uniforms.patch({ camera }), +); + +const lightProjection = mat4.ortho(-4.5, 4.5, -4.5, 4.5, 0.5, 20, d.mat4x4f()); + +function lightAt(seconds: number) { + const angle = seconds * 0.2; + const direction = std.normalize(d.vec3f(Math.cos(angle) * 0.7, -1, Math.sin(angle) * 0.7)); + const eye = std.mul(direction, -10); + const view = mat4.lookAt(eye, d.vec3f(), d.vec3f(0, 1, 0), d.mat4x4f()); + + return { viewProj: std.mul(lightProjection, view), direction }; +} + +let frame: number; + +function render(now: number) { + const seconds = now * 0.001; + uniforms.patch({ light: lightAt(seconds), time: seconds }); + + const encoder = root['~unstable'].createCommandEncoder(); + + const shadowPass = encoder.beginRenderPass({ depthStencilAttachment: { view: shadowMap } }); + shadow.with(shadowPass).drawIndexed(scene.indexCount); + shadowPass.end(); + + const pass = encoder.beginRenderPass({ + colorAttachments: [{ view: context, clearValue: [0.08, 0.09, 0.12, 1] }], + depthStencilAttachment: { view: depth, depthStoreOp: 'discard' }, + }); + + lit.with(pass).drawIndexed(scene.indexCount); + pass.end(); + + encoder.submit(); + + frame = requestAnimationFrame(render); +} + +frame = requestAnimationFrame(render); + +const resizeObserver = new ResizeObserver(() => { + depth.destroy(); + depth = createDepth(); +}); + +resizeObserver.observe(canvas); + +export function onCleanup() { + cancelAnimationFrame(frame); + resizeObserver.disconnect(); + cleanupCamera(); + + root.destroy(); +} diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json new file mode 100644 index 0000000000..f68f78e19d --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Mesh Primitives", + "category": "rendering", + "tags": ["3d", "geometry", "rasterization"], + "coolFactor": 6 +} diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts new file mode 100644 index 0000000000..c3db8f73d9 --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts @@ -0,0 +1,44 @@ +import { d, std, tgpu } from 'typegpu'; +import { Camera } from '../../common/setup-orbit-camera.ts'; + +const Light = d.struct({ viewProj: d.mat4x4f, direction: d.vec3f }); + +export const SceneUniforms = d.struct({ camera: Camera, light: Light, time: d.f32 }); +export const sceneLayout = tgpu.bindGroupLayout({ uniforms: { uniform: SceneUniforms } }); + +export const shadowLayout = tgpu.bindGroupLayout({ + map: { texture: d.textureDepth2d() }, + sampler: { sampler: 'comparison' }, +}); + +export function worldToClip(position: d.v3f) { + 'use gpu'; + const camera = sceneLayout.$.uniforms.camera; + return camera.projection * camera.view * d.vec4f(position, 1); +} + +export function worldToLight(position: d.v3f) { + 'use gpu'; + return sceneLayout.$.uniforms.light.viewProj * d.vec4f(position, 1); +} + +function shadowAt(position: d.v3f, normal: d.v3f) { + 'use gpu'; + const clip = worldToLight(position + normal * 0.03); + const uv = clip.xy * d.vec2f(0.5, -0.5) + 0.5; + + return std.textureSampleCompare(shadowLayout.$.map, shadowLayout.$.sampler, uv, clip.z); +} + +export const fragment = tgpu.fragmentFn({ + in: { worldPos: d.vec3f, normal: d.vec3f, color: d.vec3f }, + out: d.vec4f, +})(({ worldPos, normal, color }) => { + 'use gpu'; + const n = std.normalize(normal); + const toLight = std.neg(sceneLayout.$.uniforms.light.direction); + const diffuse = std.max(std.dot(n, toLight), 0) * shadowAt(worldPos, n); + const sky = n.y * 0.5 + 0.5; + + return d.vec4f(color * (0.2 + 0.25 * sky + 0.6 * diffuse), 1); +}); diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/spring.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/spring.ts new file mode 100644 index 0000000000..6379750aaa --- /dev/null +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/spring.ts @@ -0,0 +1,92 @@ +import { d, std } from 'typegpu'; +import { meshes } from '@typegpu/geometry'; +import { sceneLayout } from './shading.ts'; + +const TAU = Math.PI * 2; +const turns = 4; +const radius = 0.3; +const tube = 0.07; +const height = 1; + +const centerAt = (u: number) => { + 'use gpu'; + const phi = u * TAU * turns; + return d.vec3f(radius * std.cos(phi), (u - 0.5) * height, radius * std.sin(phi)); +}; + +const radialAt = (u: number) => { + 'use gpu'; + const phi = u * TAU * turns; + return d.vec3f(std.cos(phi), 0, std.sin(phi)); +}; + +const tangentAt = (u: number) => { + 'use gpu'; + const phi = u * TAU * turns; + return std.normalize( + d.vec3f(-radius * std.sin(phi) * TAU * turns, height, radius * std.cos(phi) * TAU * turns), + ); +}; + +const offset = (u: number, v: number) => { + 'use gpu'; + const t = v * TAU; + const radial = radialAt(u); + const binormal = std.cross(radial, tangentAt(u)); + return radial * std.cos(t) + binormal * std.sin(t); +}; + +const body = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + return centerAt(u) + offset(u, v) * tube; + }, + normalAt: offset, + }, + { cols: 48 * turns, rows: 12 }, +); + +const cap = meshes.parametric( + { + at: (u, v) => { + 'use gpu'; + return meshes.surfaces.disc(u, v) * tube; + }, + normalAt: () => { + 'use gpu'; + return d.vec3f(0, 1, 0); + }, + }, + { cols: 12, rows: 1 }, +); + +const capAt = (u: number, sign: number) => { + const radial = radialAt(u); + const tangent = tangentAt(u).mul(sign); + const binormal = std.cross(radial, tangent); + + const frame = d.mat4x4f( + d.vec4f(radial, 0), + d.vec4f(tangent, 0), + d.vec4f(binormal, 0), + d.vec4f(centerAt(u), 1), + ); + + return meshes.transform(cap, frame); +}; + +export const spring = meshes.map( + meshes.concat(body, capAt(0, -1), capAt(1, 1)), + meshes.Surface, + (vertex) => { + 'use gpu'; + const stretch = 1 + 0.3 * std.sin(sceneLayout.$.uniforms.time * 2); + + return meshes.Surface({ + position: vertex.position * d.vec3f(1, stretch, 1), + normal: std.normalize(vertex.normal / d.vec3f(1, stretch, 1)), + uv: vertex.uv, + }); + }, +); diff --git a/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts b/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts new file mode 100644 index 0000000000..2b03ec536e --- /dev/null +++ b/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect } from 'vitest'; +import { it } from 'typegpu-testing-utility'; +import { runExampleTest, setupCommonMocks } from './utils/baseTest.ts'; +import { mockResizeObserver } from './utils/commonMocks.ts'; + +describe('mesh primitives example', () => { + setupCommonMocks(); + + it('resolves the procedural scene and its shadows', async ({ device }) => { + const shaderCodes = await runExampleTest( + { + category: 'rendering', + name: 'mesh-primitives', + setupMocks: mockResizeObserver, + expectedCalls: 3, + }, + device, + ); + + const uniforms = shaderCodes.match( + /struct (?:Camera|Light|SceneUniforms) \{[^}]*\}|@group[^\n]*var[^\n]*/g, + ); + expect([...new Set(uniforms)].join('\n\n')).toMatchInlineSnapshot(` + "struct Camera { + position: vec4f, + targetPos: vec4f, + view: mat4x4f, + projection: mat4x4f, + viewInverse: mat4x4f, + projectionInverse: mat4x4f, + } + + struct Light { + viewProj: mat4x4f, + direction: vec3f, + } + + struct SceneUniforms { + camera: Camera, + light: Light, + time: f32, + } + + @group(0) @binding(0) var uniforms: SceneUniforms;" + `); + }); +}); From aba757b8113b7149cdb1229a3d6f3b5817c38cfa Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 8 Sep 2026 04:46:54 +0200 Subject: [PATCH 03/18] add thumbnail --- .../rendering/mesh-primitives/thumbnail.png | Bin 0 -> 155736 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/typegpu-docs/src/examples/rendering/mesh-primitives/thumbnail.png diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/thumbnail.png b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..9988292c05dcd07698746719b1dd79a652bfeaee GIT binary patch literal 155736 zcmZr(c|6qX_s?8&6W`lLNui>W5~+l2Eu<22OB2dgDn%Ik*iGH8tEO?)4Owri8nRUO zZMwxITjk20DO%On4j_!<6k?5wTlf(1(#95&u}0xve$-WTQO;U_y$LTBGJ`0&5w%RRm? z|7>EsjuQ83)%FJqe~$}af6Q3?*dOaq=tX}dE%5taQ$@v3O-&U}c|#LTak=eoc_YLc z`THCGn4|2EA-3(J=X%!qr<}rKhJwiMzoe3Dn|r;-PUj?FVzBj7LRcmmNrlX24OaJD zxrW5gv;UjiNvjJt`su%Zq)*p{&oMG*zaS4pO*qq(HEXvC!fd2}%bCLjJBsjzGvZV)xWeMK;lg*EM* zBuQn4q>#j$m$!Z*G5qTCb;DyoIA(6~ppYm^3pz21`>f7-F*H>=u$gp(GpsK4Z5fvW z&gzbH@GeW+*A&H+#%ayl@X~czp@bDFzrOM;*J%78AO`tC^Fy7KM%c$j%g8f4zAYB) zWXV<#}g^afN*SY!jF?bpmbm9T#fm5 zR6r>A2j#q&0ZgL*cledgpS~6fxj|@^ur}inVSN=wSAV|0lcPQdB=fcBF|BPsth~}X zM)6j736D0xDg&~(f1F^?`W1N>>I8pe)!7S0aL0HCU`Gf_8)TO{}N#D8+0^P;E zL)BVvx0QZs?<>!S%*HPqF;i;L>B7fA%!aTq^WHZ_aYYMC5?_OPs}RPe())jtB&7u= zEuJr6mNZTUp)vmku*~jXTfykC4=VX6U#PhEpn(h7ftN|JX{xeg>U`?g!RRq-4;{IA zY({ibOl1S^SLEICJ(qtM%9t=Np|gndu#Pz>P<=4N!kn{~g%zbzgm(O|FuJhY*Bd47 zzQs}D$LSXn2EZEV+_U^qij9ln;P_MQLGzjK`Ys5mo!Ce-5r;S;E2 zzWQ*FUZW(4xytFgwfNv|A>|wr5Y<!ad0*SV>fCRc2d%2oi%(hpEnIYje!Mmqu0!1UGV zZRNkr=MADI!*39l%%Jo|%nYG^DeeeolejG~n)>~55>FCM8y$)Kd4X;N;22%*-yw;m zO%dTiKPm%t3vTpEXLj=Y(jWJmAZ2|RUJn9Ef0lmVE#$W10m@E3@l+*{q?*S&L*#AR zQuJV?aAnxZN}xY&A(h3YhH4O2&L9MXj+@y93<^QX5?0{xT9{C6HPyj3^srQHKTtv&K7L}bhPCE;7S zn<6}r1=XLn`nG^WFzpLkpCCGq_KII|#ZUk81(!!5@%g~p8=@rbh?3CnG;g0rl730J z0!K-(K(|%pDiyv~>lljZaZM+(6EE1aN*H=KSK+|(iE=HG1iR`h9_T(iEWyt&XC>0dpnnN&DU!IO+m_FWj1Z6va-8MD^aU z$2&%XA^H~D9-y!djPJ;&bLzsQI{YsxnI8k_*uvVcL$v)kZjidkv5dzp?&h|u>P#QV z3KU!TBiTqy5}^$GrNj8gLa-$K$l5K|yB^_?G-9M)#nRt$C!{=;(lHWnj1VCW?9e&w z58R~;iKh#g%%yTG;!P^ec5zLsaQbuy%A!R}n^J()vuf=k>P%ou=C9$-j{6OSXgc}v z@?DN%=sycj6*W^d7uax`TlG>Mb+Mu!S@uZ-C$4e=r++2(+>LpBz%`-#Oc7m|g~?@C zo)QcWEXS?ps{ug;Asgir1D~zm z{&dK8Y*ic)#|3X6hPTZ<>TS5pAcRQfbImoEVg9gk@lWw)qTXIWqxfPQM(viN50oF1-Q-n*GB_L z9K=wPgo?m9t;Z1S*Pmk}aQ3_t#nqDhD4;rI%vQuVQaTsmmvd>y$sk%X5`Xy?hrRL{ z;Yp&raRlX+*Lt}q-IQ{8{;C{b(*+$7J^AS({ zi?a-3hmRrl?OO8zWLnx5u}-0w;4fL~pai}pbsfr>3XsV%Cx6g#vn zM>)kwr-7aM9VzWRW=CzLAvTzQ^xF=>@WG=iAq{wfnrGONeaOd94nPDr@Y|1(w1oa| z;XxoE$5h~`#mcL~8A(`*z%^mE{!N>FIh)Lh`BIf=iNeRgUo3-tNe6r&6`ZDQp2sw< zw+Tkf5OsWF7tkRfcWIO4BzQS7)K;+SwtY8z)p(fK=AV*Iy3&9ETf)m=4t60qc&EpsdiH)*23FH<_M!pdazC0pvO8TTw27g-~thuGi#ILgv;@$ zg2^#{`ys%1z;v9qF1QgD#7j;Ca~kJJNq_QRrJYCh2$XID37)l&Xe%KM50T1WE7&aZ zLmu&%BPB0VKWF!fC@@E-AaC4{@<(Efok;@x0Abs36DpZ&Ru(&e-5BnOR^VCb2zAFW zfwn*<06}cBriVEA0`7wl0JWT;L&^d!xhR`VzFIVIoj9R|Y#^Yf$ zLVH4-!xCIcxQ!@p(!rj2nrPG?o7hTo7a<6-Ai%FgoB)0h9n4P&Pvab`KY*iAlikgG zc=Xad(fT*}+yyzqu!H+K#1t$(O`-Euj5`%Y3^E48_f@#lq6ERhJSDjSg4T#%C30+O z`Di{8f!`0iQhCF?$lcW`lm&mk2;r)5{F47U?ROBxO$G%NAwNE84F3TUO0e zwrEJokVknw&y+@NaMa-N1TqXz{gG!)yu*&GMokEWx|jA4MlsXldin&*<+zhSOi$Ak zPtK)Hu2LEM(Vo#Rq4kp%5NNIZh#bc0s z4ru?eGQ)&0eKE|u9dj5;`;7(rZi~1-KmVelw?Jz6`V*8ZIs(c^dngoA$*?uBIneJLxQ#ny-z4cN+3FsmmcDg{L8d^ zVmp^pf1}Tdu`8Ut%6C~&@WFkPBR|OpKmmeDPah=S1>?3IMHgaUuu)~iCq9?GP!zG= zQC3JfXA6ie8Fq)){k^^U({Y8Se=|H4F^Uud*g(Zespkm9hwPf-Dbci1zyiWEVCkn< z7#QSvf4=r0k1E5vhCA?zDgu6Se?Yplel~o zq`8!SSlEhrMBxvC^#q`~jSnN>tU$ku-W71uF8$JQ6@F@hFpWk-Y~1XMX4)62ruhH> zH;^rU3X(Q|l+MqcI46McS~`mpOz7yq?&fnu2apz^nM;X|9gNn8FfA;?DSeA{ zH0P6#$HC}AmC)v&s{hl4ry|+-9!ASyA)}UBOMc@$8w3$!o8Hqkyv_Vjf1pC_jxT1^ z)-KY|4eu2RSU98&$mTO4l4Y2G`*}XEY4^}=DL^|FSz4d*&O)&CVslEUK%4E6w3zUQ zvq|})aP~a%DfNgWO=3SaXbup(E~@{BUwv4M;+-na6zTThd^u;V6L~c`M}<%Dyy}DX zJ&=T?16die9V0QTd5gk4{<1nTS327<-{!drM8q7}l&}H}RR(S29+W@|goqV5fBx~V z0X^_=KhHhlI%;A?BAz~+A_i7pk!F_tsB>jjkSOgiKN29|5s%cc`+|T)9No$nALJkd zi;--6f&GjPVVtu-r2a6t4^GySKiqjPN&jJZkjVT{FOVcr$8rbnP}VPMng;>^aZt|D zOZ@fK$l{CYqPzfBV^nF)Km$Mu078W7O$aJ3^9m4IV5{2JO>*i=pVVlV=aD}g2TMmi z9kP){39%R9zJ@ozwlLpw^mYlPC5B2>qYZK%p{<9}wZ|Jv`1++So5fB0Ia;&@u?Hio z6|R7Xk;ZZ0O~H`_b=Zxf+Te<77CjRAD)q`NS-NFo#)+esd1)sp> zB4DTQo*s_$C?gV0sv;DMFGk>xb@o@y)1i967cg(*i1q&h(VULUd>K;$#S?!+4nndq zynMLHfFF(F3Q=G=I7bAe;SOZ^mN}#%)dX|&e0wB@3PPAI)p?-(jVR8FY>ex4eBoqe z420|vbtbIkxV2nerdc4q@T~VT?p}VVKv2#gzHow-t%ss2Ii}2$BM>u;|mLDYP9D{vHhm4Sb-QXH=?(!rV1(zp?)4?DtGq!)}Bx=(g^TLmDX327Zx7mS(JRouJ{1OE15NJ`3WZtD+a%HkG^FdZQSvDNvf zKr0$bPc0Pi40uBsLfdj#MMqqx!@fp7k&5;dY2jAnZ^F_ainpxUxR*98J}3-1xX095 zGCh-)uYYfT%?eJhp_q!&UYa9Nwq&BA3CS&ZTe7e}}9-BWOpBJ2jhpGb13T9N3M3f2dA`n{!qAs^8#JStW zT}Dla=Ee0$n9SD8`U&MazaGz%R4=lt)c3q?^4E2h#wh~BAdtJ#*_jZe#Al^wJ;JdZ zOwQKO0uskFI_af|oKUf$4|Iba@Z(AW)wf#`#BU;v!7__3ab7%lnfl$Gt zN0KKtO1&hWk2rv+eu4mApBC%n3W;WHc)O@!LhM%k%ZV!w{Qn!_)>#Ld`BR`=uCPw( zXos@k;pd6>VaKYazRsLHu6`w8Pa@(Lv>}Lie}Tx-9cVHZPiS~ak7hX$Nr5xmBj$}v zl4S}j8=Nf^BtsNKbQ>2Vg-IZ%1xq>dtQkq4laEtg%$p;eq6OS;q{-L+Rr2F*xk|DQ z*!@3`O3{2mq#W@xEG>3`S5CFQ9sWW;eK-sv1qjro892 zA&49o8w!%+g5A2(>9jdWh}II)(AE6ePaTOp3%h13Hd8fE-lgl6rAO^k%$-|UmfJO z<0R(8Xylw2RO66Ip$K2g-5{6Soggej(hFz|$oh|R#14W2ntCVcy{9NSh*ecU{SJQu z;67C(E zY8gNkWd)*z+@pd=qI1)qnL5Jx#Ff@@SwP4EJ;)fZ*~9A)xe@Lv${QR8UJXJOCQ_Uv z@WRu>)k8=Nav&}6+~)C7SM;U5wDsa`oKx%$U>4qNdzIk0E(R`M8l+20m(=@5`BQG9}eWUGUq`<@QB z=px0;ITZa6|Mfkv^n;-DyE#i^G#nQSl?kT8AqX6HM@1AgF$lQAU4oMpyh3zrX6y5P zBJ@hgmxgokc>;Kp%ZvDY;)W8ub=M7l1CB&l&WEh20u3K5}cNlu~pXuRV}EM;=mL(7G!n0dd-Y)E}UQWs8m? zqv!=RO9>gd6whmgB*cRDmHzcs00IyW$YvSwA0@m-0_#NoGb->dM6*aB%ik^7-bL_k za+{g|{F(|6riVmLMn0wX^KB6(>u8Dfax*;@#1Ek~nwN1$2?OYA5r36s1R`YH6av`I zT7ml!E~iPL-EDCC|46s&;Oe7*CY+fVqUkt)c)h5#KqL?Cl*F&*Jxr4T!lGy!n>lJN zpBvuJhd4O=psZ%@DGt51RB5fK(HxZ55j0e#4h1!<*v+eH9#rE1uj7=`O_h1 zfM0x|Yyrn^HFZjX*HoEb6uwo+&85O71d-kYp)Savn4tOPi`;~2z&&93vEermEm;Fv z(t6QG7zWdD;)9&*AIQi32`pPUb?P!_?H`K|xE2^qWzS-I$1FsVZe$n8zLxN@2Z8M2 zhhquKf)fO%Lb!uJ#b_0%#fBokcvBt z-83K2mcJ1mf*f56I7pqHlY$&K>!et(;Edo!TM+o22ZEjPS`bQFvqd@&M3p2Rl;rhv zp+M&lISQ%|){!uJ#QvcZZ!b%fX(Hi+M4JWp8b5nzdd96n`3cD5>52RkilP*0Vc`B& z0j)d}H?f&^5-7|^oM7Nu5#WJzfTGUV!i7i|;5v} zPB;wR5LY^p^q)rQl zhkv*MfgBK-4v?L$FTiCy}^Q%)FlOVVS zLHh#<6TY2q-oj>?gsSDo19)RNpQdjnHgnkhLm2|jnk30JIIY%atyrT-$i$fnQv81w0?re>*EVln zs3`yCyOMS zpe4K_gf#+Ck{vQ%3qqbYTZ*9aL-ve0y~TuEzv_-?>G}u`XnK(;{JoS4@TGi)mX!^{67{UD0@J68%GqA zzTOjWo0sjvOo;B&A@TFfOmr7ii#W{S7a?>kKvtc>9)A3?3x11OoiH@SAqDpw{Y|hm zP7O6NUnb&<4*)~gcU%=MZgFPk;7{V};F|2ez848!L@E$a@xRhppC80MaynSCUJ)&J z>;}!>8up50O1pqMDUdaf)dRBlM96fw$OBijTqzLRQ3rw2tR1kYJ0-tT1uHj2g%|PV zB++ILRJ*%pAj(TfT?SPbp%6iNk@E0QVf(1r2JE)M0# zRprG9>`Sr#V~9`dQXC(!M6?(%Bs|&UbHSp-$KjU3K#;2`OVn@+ARaWl*ZSkg!gY>P z&>wakSoXB|Qm@c4XPHs>ptfTj@GR(OUp>xtvX+Q31^Nd+DA|y}DGnsOUN~ zN@!h(>d!_kSRfotT}LDGj)4=XkYUWrULH3!Bt<*H9DQNOX+e(`8`EY{bWue>hy+t| z4>2XbKBo)Ae$JNNHE4)TGNJ4q3rzLVi#zrSP263$%Y<>HJceoyS71{rlOGj?7G0$9FpPuICC&r2Jgu335yBA-qg(Njq`kB= z=(Zp3W+HXJSx}RFBeVHPv;dBfNBSR@mF_w%uuu3?Vi-ZpmXBNuaapqySsIcqzxleh z5WzkcXq;(3joTwsa1NPV^+%G&fA$L?e?*dks}RHDKsY9x9?jdfe+a9>h1bbcF7OyJ9(F zCZx&?Ulyfa9)UjAmnj74Z4#T0BY^A>?bn9Tqgd?LMlRtzy~e#oaa$H5kj{%k9>x-Tt0QKSMeAUtuZJV=_ zisN~L34loRG3HEO+%lm;vk)Os9tr1`|&0OBFI!_ z+C$E>^KL>QBUX!qH?R0NAuy@y_c}VDL~vA&GDx%?ragfkggy6e@P)x!pYULL7BmU} zcoRbWq{c9TF+0(Bb?szC{gU9}koqNa6y{N*=>u!6*>{(Wh3E2Py8LKh=gl}ZF0&)H zI66SpW!RiCyRbf}Y^c57f9`?D-BJddkt9Vl`hJy>@_BY5uWlqauf#2I;B?2HlJ0FD zcW1}TCO`DeO~)GTrk2e(8x<>xtMJ`=I`sE=qBTA&Tl^~}hD$zS&7$COtI*1^WRFR5 zpI+&>o%b=#@;BO_@lJv4@y66f`yjW#@dm42eD@uz$8Sb^>v|LYOh3&U&Gn>M%rVPm zNBSJtqiYL3cyHs!wu2@Vb3bsN^mCerxI9~`vyH%Hzh)ktYUpOneP$$v4ztQ!-)T#Y zrd37Gto9B~3P@3Mm|~XU0vj7=*{U6h1^o1lRR!0Jd--t zwU*&sL^px^6^ojnT@w&uB;=}}4&v7~X&=zX2dgqQdQV^L7*FcZ4`DEasWYzcJW8i} z{w*()p?|Ek4t+G0#0c)48K|1l>Y0%ojhg$m(4&YE!0uputxGEH-&%?fwWpU2oF=Nu zldipanU;&LAm7mM7#=ijD93+F{qwGeezLilxZ$*lDIW{q>q8W@E=>N>qzGc=xj~D= zw6(!gvgRGb;|`jS@NxUkUcWwdr(0%n`PA+&*yt&eZ6ms5#_qbd=FChTySlGxmfn$E z(c9Ck5W>(mLcgxX%3gb8S2uOdVe(plj;^NvZX>eGJA>msM+rXP`NCa_-vzG7^g(I) zRW*J*I(Up`R@OE8c*ZVr>fjm4Ak{Y;k2jf}Xn2&0wHr%Q&r5Te9q+MC3F=;ReTpSL zS5r2vQjeKwVq&W5oWpwNc89#`4f$kyvARX`_~mMYM1+BE8p`4E3c>?Yx=bk_R+cWq{GDK^gGj77AE)jp5d_Zk1#RXor2Ziytv--uG} ze{?Y}Y~P+TF9SEjl-bz1nK5m{f%>{I6TP5A4<}>aPLl?oDKq<@1ZKa_FBtzTWJ87D zbdi!P*=083!fd$_`<@RPaC&Q=&1pkEI1n0|!E-&xtsM9D!!}_<8kjJa_w4jvD2MBT zPGTB)egCvI)i#CA*3AW|_iS`nVUtH}OC|SwbxMx)&&u97IU;dV$<;5uyKu=Yo#)c z~ zU=z!9WLac&$(zlWue&7`Er=~9$}E>QNcK}=*IrVIVH3OM6tG&1?#7vT2k)of;`Tc! zU90GlS*;g?rjo0A<7y3cF-mnuEgqI^-4XltQ6)S(Knat|&XJuHWO3Uz?|3dHLuE&FCvm*KcBdXP1;8y@8o zyYIsC-=nUWotLqO?|UumKdT!E3nTs*EJ*E;s<<0qeVhe$G?KU&bg zq0V|ypEu|3Km< z=qqPhlIrPio8Vtf0f~%(7+uyA@>IeN)g6yh+R7_}i!?P?uJQhzl#bC0kYlH}5}7UJ zq2$nSYs38AHF%bK;R@WS05#=09m*E**;FHTM~z9b{No!Jo#-rkd$nHIXrH(;Dhj0gx#I;ij#`A(dfyA|-6DBeZ|YJ_h}AjJp4HUXe~*Xp6>;Y`jjccl+~{>^I*( z_eDjQY$SuJo;l^)Q@7;B#dB`=BkJuBW-zi}>IJ5IN2xHad)ti4YS|%-Fpqcv0_?a1 zWonCSd)?R0{}av_74|iX{VE{OPA$krQR?y~6)$ry)9crqi0JaPmXgp!Ms`=5UV$>( zI63LU?)i9TXu;i zi)W-Qj?<#p(U{~qgOzoK1}ob>mL7h%N+-k22#l-dahaoGcX#FzN2Xs=zp;DP0Vl6& z+xZOo+U#^Q1Xj!0q-1NRCVd-mPYuIfR@9n1;$PbRjp0SC*!Bjq>d|1}z8Jx-InV37Rv)Y-w*q-@RO>jpzx6gsV2(umtm?Fv`@O(6Wi0N-)kGDjtZh-so3z(+Lb>*!v5&XZR4^I$>G<7jZSNd-7j7 z`8tfoEPmOfv$FQMt=T!XsmIY*9FzvA#cA!Xek;}lsWom4bn`Nuh>WPATdTk~Yt@$_qXz#^mX)1FWPj5IUS#D@u zaTjw=J-~{Hro5!SorY8F8GPBfl$At1+u1~)>LLy9TsAthXLKO5%rK-&dF|clQrO{} z=SiVT-U@yG=hTL}G%K?8{S(cKTu#qaCqf=>j;iGpk)j~Cn{4{ZyQ~H<>pjZz$=xff zKPoDtUl>>HNe#LE)_b{4(N^cnawUH99a^78@2=^T4gDh9~KVCROr}) zUK48Kg9n*IvvE7MCyJ6rjG3e9?#{)mN`+=B68p8^5rohpnFjX~3tVj}W(|}EPrvD4 zjr4Z74=UO8%2XAG)VDQma9Cj{-*(5lCSyc#U`o@!sBwdHx^j@m2su94D9o^XZFF;v zx@&ASOO5K@n({lQVWN@j5Hyr?=7iw;9gqsc)vFajGvmp;Xf83F5*z%bBffOjwe<3I zg?~u*ScZRztNQN8f5+rsTK25bk3LYC5}a`83*&q=Yg;4H=~7g5j+0SP^}sXo)Y?BQ zGfzf!cn#**ux)N{Xgr@CVtMn|*1e zUvH%PmyRaYr)8Gz31K&ev1hiH+r^n38&o&Rv915WJY+OP8uM7)TxWPT*nT29)a$by zwx+{tra6V(j8HJTBac{mp>Y!HU6s>^(K7_yHk|tqRCek6`m7bhyuIfmaiv>%Ixllg zws%D9O`c9+mtn$~Hw>KvS*;z0-Bx(Z%fZZ|j*waZRgc%jd_$e;B@SIrX0*9}#Hg|4 ziZBmX;qAIU-^G-w2Rc+@TOBTuOiH`+i`88xR!k}X6|_;M&@QL_Kxm0xC*St$rA;6# z*Y<{1Tq_CAv4!rPnW4I=fqK2qBc#4<9syM|&nje^K943tOnt%Ox{qq;qo$bP*ta3#&zt6ng2ywddLpI(A!KP1&jB5ePbm&L{6qNvqgx z**ka0JhLx*tey^D=`^WhqQ*gcij&GBPOW(J@bhJ8YV4xB{3L$x*t+rxnbjTm&YJ~I zlkc`>9_^SN>M#V?vNk^KrVcvC__`zbyC$tq-i_V1{`H5x3_o^#;a*_KHm-Zr{=o^< z+P~#YM&ab|XCz4l>fR-rrV8I0#Ecrmw0&?bH0mM8OOd8}`jAM6j~dG(y|k~2CD-uc z-A`)6ePL5|n3TDK6y2~iPqp2pj1WdbXpeJfPjOS+erw6gkuPI4SKsuOTg%eE)GKFY zA{o+H?%O!t_I;Y@d>Juq?2{a({IsJawBUkm7&fRSIMBU5vFrOLlI*#cP~!n z_3~!>@2j*GsPA3GkpO5H5kG(c8|kVq@6YNWNSdw>&&aq)&$q^4P0)xS^%$8F2X zI!LIS+!}UfmbJPgc(kT~7I(I@jO9P)K22L_q$9g#l)1adD#CK|$eVQmR)Y{<-f#3T z@{a1zN^N33=Y&WZt~>HvgXIT8PBz#MS7mBBmr^s|0a*p+EjEtTZL4fzpR}x$tG77O za3bgCjKQsd&} zIaZj_T++?CJ|lLjD%h?or*iM!xAszkP}yK&XNPA4zD~p2$X_=(v}gGxm5-(BG39T@ zVxtFk#uRV;o@iVf{e)cbZBpl_@ioeW{`SsgLJ| z*Ur8U$R2sHmi=ui^LXz&nW|eFXX7(#CU*qZc+KRp&#%{D8+RbleMEC#bn5Ahvi9y$Q}3}Beb)q)aza5;jqcqYm;X? z!)BtqJvHYxS4S9*-784S4t}9oUNMX9Z_BuGw$h2{4I%%ioEfq65@U50Uedo3T-%tn zSH&%9I0t^yjV6-UozA&2>p!n32vZ8eoM^E1sm|Q(98A14QrUL)+rf6So2TZDwA>+b zd`g0cPS4uRyTJl#-PN+<0lC0j=3h*skx&lI28TT*9KZC~f9|TTSAtBZYWQ-sGelJUCcYbDl(JbHP~0U9wn1aF4LBM@cfQC@zL9+Xw=zR z!-_G>6N6vBT3@Wje>&&(soPLnHKzTX`9#iEFE^@Ahm%un=yHP?$f&yBG|@X1a^Pcv z+;0cYW@IOum2Qb?+}3#B@EF$ni^kk@|5=M_qWKLBHmNKnnM^LQ!S;G%wW7wO$nm{o zbuUSxQ&)jU7^{`FJb-^O1cJ8$I7qtJxXq17msy)B?M5lh*#uRPYLa!8=g5yi8 z^1fFb8rg30=FRhV6KeIN;kYT|>o*Hb>CH@fVm3+9EYzke-m~U;hquO>x?m;eq2e@y z+VivLvt2{}7t=Ui*k#NpBKP=5m(`6ZFz{5{4T-geSl>@ANo4!PO!AZ>R#w-_3J?RA z&D<(E=tRY@CD!T4Dp^*C;Qe~Q-NIr#jfU2zV;b#te$Ujvg#hx9<5ZU3XRh6 z{xp6!*1KrhCS|0{c;c_Od5bM)s*Ki+BDk0Ostd*BTRl$@f!DGwooJZCW6xhCkqdX( zV@L7HS_ezJCqH`xG4*>Fa?`WDw1X&>;zO@Sesd^&z=KL-(S`$=>~2-`Gjja#;Zp}? zYmV(n^@BDC0}rTH|NbN{hVd8OmUzv{R_gu&FVNh)yBbMVz=PLpqp$gECt zQQwoAWF)Ps^Z30kXen+C#ck?b0$MsbT#?MfH01Ls4wDYE)V`gw?;PT!u&VC94>!l0 zJ!2|)MpcQ#c2{vWL{}Jp+PEY6t!-~x-r?`*?hbb{Pg&+{yEX2MZKxU5w{L|QHqf); zqebbKz|zqSU)>JhWjB&BECh#e*gSlpxY4Kf1=&Si{ISGjRoIk9N-I{YtcWb@E%`fG z)&BA+a=dezOR3c-tA;7|M@&~h=(9ip@Fe`W%U+rcI`J;$lEh5G+*DZHcP8qj-Rwyx zN&85dqFer8Vj=;7Xs>O_PVuV%FiENWMS`SNgIVlJyY#0m?WO&jnyYp^&!rZ&7f>0u zSc8=c1(-alJ}I?5I>cw$b`z?0N3gg0(hF*EpvqhlcE&tunMljkbyC2#W|Q3%sr44G zG)D{=tWWy(g4o4Y8PxNa+I<$yyiew2X%mdbRgBzb$J^SrU(1krd5ijwV@ssWFVap$ zEQmieYsalS^y(^i>iO%jji-z5yPW=CTA-$LNQ0mLkTG^+O?lofaxVL&-gH3GcbypU zZ>OnCp6PQ~0cMcg?=|YzD*^FOjb3a<`s8utJh$iFS{I?YPTVsT=2e$t!d@fOU^9-i!_b(?F+T@ znW6ZHvAVp4cl?TN0dDC&c#_BCh;6vk_D6^ANLf}N8QF5}8HspvXiQqp?2IOo9rv&d z+w0;UYokt$H=viS$k2Z8Cz9Oelca&`-# zUevNYW%OM^OFAww>oRzXlj8wv*ftXOewR> zUvP4k{z18Mk2ZNfav2v;g6lwKqn%-$wm9G~*nYkN!^x@RwZdpGBmoOZu1B~hy~ zGCOJ3Tq#$<#wfSG&frBQIeyN*Rz|)9sUZyMJhF~Ud_o>Q>oVP!Tj6O7O z@GncboozQnya}jryG$XuzgXua)>|yS|GRa=4R&`{bF7tV$VW*c} zg|r+d)aH%)vX0tIWoplN*B$DbQT^ZJZ*gJYHnL@;^ggbAK)%pR#JaVDSL%!`HSBme zLs(L(5mkt_=3iHWTa0ky0AE$hNm=1*CVJ}$QFG7ALe;lC+TNhKvh4W#{t-=Q{pjp; z06Qi>GTvIZR63n&-$8cK^?B-mjg=&-;;{|EzawS^%#lmc9Krge4AY7;y*ly)jGbmmqJ5PeVBO;l@P@A&k!pJ-^S^Mw=t zVeK@fxR&?%4O#{&~|-WXY(X*qV}gk3Ttn|xy&>+gYK&b(2|TLp)G=x%6UtS*C0{b*m2 zao_1vSA3f`sBgCBHL8r%PKO-dg4)Y1!Q@=Xdb~0!L%t6kE<07S?|a9zcp~>YV;j;< zqlj1M0cj*tTHfqq2fatbe<`FcZHUf(c^i}k46}-gR}@6!FZDiReC9HA(a%V5#7RzN z0O!23ZpgLsjoz93q{aTUucnnRbHbedB@uqdyhDTqq6=`4-!C%9f z`TNe-Cc6hNtZG{a5b#i~&CY`j<%L8fAhW-WNx)#;Lfz+XROb4QcsE+tP1t%V(Ic}B zN>j5balV~x8GfHOc$FC^pT=624#oZBlu>DYzyGT|R(8nFJNxj%Zam<$K4bfUg;pP+Ah5)hWp`%?da!0WK~M5ZgC4n%uP--478@{o0dNk$7DqKWL%$Nl96E!z4=m`zlwxAGM69*F;X5)8Sj^PtSo;L|LYL z?{sm>jLUe`42`u@jT3ktL+cZ{jd=QR^3DR(ikv3+qOIYC_Dp}lpZ{u`dp=b*c?99G zea4Vh<=wQFmC46(Wb*%#OtCd4PsvrfuF~K;hXS1IpODSauhYvsPS}Oikqf;tvEZZJ z4GkfVi*5AyU7g+dxKbftp@N-SwQZDdZQCl<9h)k$J>6OgFs8eYV@Ep}aUOa-EuM7p zgwMoN@>Iy9Z{HlAC950qz9B$447~`+V*~zp#PI8EBv=}{UO%WC=A~5rBzt&Q?Ks}6 zLK<5nbpb2e35j;$smvd^R&3ly1j>XDfbv{-JeTb~@w$=j@c2b#Rzu7VnJl(A(skq3jBlLk~$-k+pBLxCk6H6TE~&f4D3(!f1imY2=Rt)8&VvH%C)9rpEns<%HL?!d=J{ zA9Th*dz)$nNl8Lasf5{1LD!g>>2kn=l4cC+}f1)X{RoPM^ezSG^-71*57 zsiIFLr;xf@5W?rQ{XT|e6RQ#<*@Gxc}%~r9`|eLI_3nK z8_RV=fX}Jw>mH>PXzqwvMFj+I+kM~l>YV@sk^|KO)S7(t3Y>s+mr?Kd8(4!-Y^{OE zYOHT*N`n0hSG$Q@Bej<_9*}#KT!3WYcYF|5%i(qq;*qrKx=jbEZ;Mtddpj8o71yVI zC4*(_Z84_beLI?#JNM*h2J3Fh)ZI&oYg=s+0BqCuJd*vfqj%MceY92LgPewKMWiia z{fTxS45@R7puXuQ`T=p{ju>+*>_;iEk^5B9&o#-po!|jSRrNPUeQ=s;XeFvRtLc6i z>Z(?8SYeP-yw$5cVQ6wJd#-YrgJ`0p8jm#o_}G@W>JJV*{|u*I2aN+xgY zJ`DKRj9X_`?tFd|a-h9n-9NqIN|$&IiS^^*%FU+3xUX<;V4%>Y*X7E;`?w_ewqR4 ze9fwws;7Lpb74|IiD&ob{ z?(6^j`fH3zs`X!fy)VcxwK7uCEJ)6( z=6tboqmN!dbD7I!`8uttx4}xCl~ChR2~-;*5jPg5B!+_dC~qOhe}CH2Q+%7eCr_al zcVTM+9}DpMz|HLtSdg*6%ZlYg-Ls28xmrLzRY%0q1PE zX4i1dp`m#v(uD)T;M3Zhc;60z z@7n=Nn*6cV?<5Mdnf3t;Fa8KY=8ruUAwDCUz2!2=1$+NGYYq9{7@0hZI#(fWr)G9< z8=E{7PfSe-#zG9<;}A05FqMGoQ`rIaWKFLQ2+j2%IAoIDYMV0N`bH+3vB+-?_dI{x zYSo>1zOfDO))L?cU?%yQU4u#T$`rsQZuvtsFXVzcn?z5&sB^H#^5W5R`pB`hiyl}G z2%~B5eW#XyidD@xYz>2{5<18d;JomGzAhqsHlBB3hC+rYY?tB=r}oUk%@=^JAF})J z8?2-r-(Y5REXVG)pOQDkqEhCU0L7E({>Ui4j6@ib?*6q+kY%||N4BXI$nkZnuC8U@TQZh~T=Jl<9M%U_&?m#;d*Bts>$im~@=ju#>lmTfnrqKw${v5Q z#EworKLlE=h#g9<15Bi#El=57LDle4QZbPTCDgap-`(!J6vYl?^sz{~LvOr1K-l{` zn-m$h{U$!@f{ss-t3F}1Zl;7r$fc1-!@i|bZ%eB#P*=Y8id5|zRIR#>em%W%G7c)3 z?BxM|_nEQYJq+&|+QQj0zt`^P_ba7ag7zjc8jx?J?&U-YQA@@%#w%4RWCIUK7E}UwyTVQ7Ka2PprmRP;Anx@7JZl(?iZP5iUZ&M)$Aa0C0 z<~uS-j%V8YMyK@$V>*2=^@bMRXa1S^P3w||liC*bUPyH+68-K|>lKw*{fH~*vugMk zyh+F5X^6&Ezq{}JQ~%)S@l~peN$i(YrL!$nZ!;}aVmjN`_Ch?zSPwb;)KLgBPYV^K z1=6#|+25_a45?f%&RlV5#JSjXT2&I;U@Ou8)k?MiyuG`xQ9xt7VrHQ=RFA#vrYjZf z@KyM{Aosg3HXR%Px)aiIY^(PPH&%&Z0I@9yDG%F4hunUp1$Ebr@oC{$(BRh(<$Sfv5<$!dtaDMm9t zb?oGqmx|ED42a`B6&N2>c^)QK;^FWM+gtmsqJU`p;Qf<5$JY&ndNtf|eV*ZRdJ~4t=t$6ISOfo->ild(+XEHSQ9TQ5_hJSRm+*J*X%5UlThtz z4|#7$%m28CoRX7iMNxafjfhIsEu;_EDv0L| zqy4~P6N5UzuBz>||wd3xe#MYF3 zjsI$W-5vs;m1!=DP_zKM+4|2(Fjf$k?1H01O0cc2+iA>{4J;_p!opcrbZ4Ob0r-JCd!3CKW)Y>M^m** zb)0!;)?H<|ZmwZYTlR0VOKBEFVkZG(%>MIEXoH_&r)uCv6|#Cossb#?NPv_oWKWB zh7mWCw_H-+?Vp|LElG%!`RTUa@y$ooq=*Kepz+A>ItdWN56s#Z-)Amw>74>NO1mZ} zVw9KHs~}f3&T6!Ia)J%YN!rUW2#UhZUiIcQ+9ya|Q4M_56qg7vd<$0VDs*@4fxy=a z&K-Fw8LKs(n)!?zpw1ouo1Nj3z%ooqQ?CH6${>3Jd3_acKvJMF@dgsi*P9t)$N#wwh7DU2G=PQ? zY|R0P@R=|4Am4)Uv@?>t$H)l^Rmeeaai+T>I8xqiDzwpHwT#LdiI7lqgO|Jek^Yv2 zx#vNY5tD{$k*TaCH4$~z^g1!J+BqG<@^kthm^Ck+#~M#e%rd5PH}Kk^5^qu7)saO% zEK0eyR~AopIg+huLX~+o?O+3;_EWIu7eN$~rw|E{Yj4 zP!Pvky7af!9X%JNi+K)CwGTbW=>(oo9=>dp=gIvND&*#8q-GvRO=b3!Q8Qgm{{j2D zRxxwL*9)@oY?-j-9J)_K3Q-`FPgb8rcaeu|C2bPS)Y(=!Nog%TrMWJxnJqnvYc-(C zn!wuBQ`o0`-#w^}`k)i~t)zagxF-5;~<19eXNuQg!#^_i>@uZ&u2gJxr>{ zx=C57UVfDF&#zvlSYL1TF(h(Rmj;(_coN$?)b_%qweCLIt*Bo~BPG_?ASU?sTdm0S z_B+r8Mvjjl=LXOLYigL9)ovg4gI?r@hM`1VB;1?G_#><$^WEJwNM?GWp&SyZp}s*& ztn;%=p;jY@y6TLT%F8s5FD*X&@JUm4T2ov>XG_4Dp9*Nht^ISgSdq@iM4og$GXyrZ|Z%lvT*5G(8*`c!a(Ng25Z z)W-pIzeyig6BTL^;{!=(XCyRVX)@!x5)Ni`XKWkNt5j6evMhMlGL7I1X3C7OoRSNhTKpYek93n^ZM(I{o4wEZ7fUjY>5`$bL3 zf;6Hu3j)F_(%mJUi-;g4EP_(fT_RmetF)A~v@|NP3({Qz!U9WoF7dtS@Be)>-@G%< zs581e?|tq)_ndR@`g|!~doh1dJ5~IzSlldP|Nocb0{iqJ`~a5N?Gd59@vHzw0}ZE# zPf8Vm-l;O0y`Wkf5O;pha)`wPofCj$rh)(76OejN%BSmAgZknC*xuS~b`DXqPcc1r zHwF42^=)J$U&_*&wX|6Lu>xQfsV^@+5O`)%V+pY6XDxYBN^{+l+D&gxrU4nC?j-9k z%hi0!v#Qx)7fp3VNvp1<%L_2b+FV58Yt|RVXh-Q4Q$|BTr`v=y^=2VpPR%&(|7}FF z13+CpssDdYiVdm{+DDabe+&jX8>w99_EYLLA}(_IAU64@AAtJ8IsT-?`1wsrc3lNj z(ciPQK(NfJ?{WS$jRTCqOfzlcp&9V=PPiVv+vah(ZOOP~92esS5E4rQjTwMY&}5OW zE>UT0pq~nmHPRs7V zZ{3{i$LgdWOHXakY$c@Qm?NIkZ2ou|XQf$>nB5TPnE)hSTi~b3%qsIg5b<|Q_z#N^ zFx0kJAN;ocA2xZ$4LDoU99zYJ%BWJw*IDX2!WZvOP+ZqpT&(61OI5TIZl3;szf-VF z9e5pHd|T}51BEU-vBU)#I8U9GPZBF4owmLeAS!+t2K>Hob7@)aH%xQ?{8!nYDtO}& z8=!AuXw&NpBy6Ue?A%;j0K6NqEyd&Ucma=SV5k8Pft&>bjwlSUbNG*Q@XuAI4|tBV zd6RYPr=Fxq-E6(yG-)RR>iTXw;4UL0-aP(~9~~ib4T7|(WdL^TD2OmXW@B@OlxzwD zvQnmhxn_BBo)~}12Z^7+ixB#`oByg@-I#9HmEYYYk0u%U>4sUDXY0b`Wgy)EJF0>K|G2OquDENR0n)Zj<3=`-W4iNYjmnH3&hmo0LqD2R}HjZ}am; zNX+qFEAX9R==DC(u_~8M-}3>IB#_?Yec0pTE3FQFyHd9R@+{L=KaXGxu$N_h)~`29 z!A$e5emayWY>D1jsj54XS*c0Y)#stQS5nUO>qy^Vz+fx$7p8Q&f0@Y=*7gg1!0yo@ z6fggWAMmh5F9DX$E}_?}SKSe&GIau3$jmmt3(2MWznF-91~7OwX1^hhv;TI%0jv0+ zZ#8oG;vGc`M3YsTiu)a(yA5rB2M*)*GpQ7SK{~gLUi#%eel+<(@9nz(^e>g1H)1LN zT@u4)UC!K>%bzNr$cy>&aj|_5(G`OtJy`GjpAWMlMa2!sWPj@11OVjxLJQGqH(74R zZJ8WE_>Vk+{?s@wpEnt>=xFsD-`~37dl}lwWu%)XtTv00hIga@dk?@`&go+YmRwS{ z<_uQTi!>7?pyUj>h(v+R^vSV)DFm_+Fu}XF9end|u=c|NahNOo{ZtF=K-BVJ^Zz$> z%}_6UTOAnSkys7L5Wj`+_2B|$7mBH)OqO1kyxGjWZV73yejpyV743HDc@Ai(R(T7f zfSjVW`B3f1bG1~piS;s;>BY)l{KYt>UN2$2S^PYc=aSF)_vHK-04LBd+~TXt*H;GRwZP!wHp&)e*WI^Ja}Zsd*DIq8JQ+9X>>Js*wl7Jrrm~_5#HOn zK05TFoZl-N+40&DCSAc2mS14ParBv)DY&3>yQ=&^MJoX)L*J4siyIGM=hTC01%1MD2GTLTW~HQG)ju93z`8kNpG^d5I`$T;Bv zoGUk`*CaUq9e14tsKN&`HICNq5rq8R-#^_8>YsH?LML)HNrOG$-#VIO1QD>sq17h!1n@@hA70e1x>WMs`e~hpV`0BIRp9w8<|TJ0qVH7l+E>qf zRx!V=`(RqLoW*-{YQ=xR%*V=m`N*KaVBXWs`^dgRJ8Lood2%`Qnp>&$L~qA~*Wk`% zZac@taa_iJU)Plnebd5Z98mXl#xAMyhPAZve#iUY56QV1%j(c~k;)We1a!3hD&}OM zK5|=H225#ve^ki;#w#KK=b%{(4 zE&x}|*>S*_Cv7h0kIfnZzhdW3hX0Jg<<(kkeXBI}Ri=Gp;DB)FcWdigKdAsK%&L&o zjG~l}a9{lKD-)n^EVj0e->W{ZuJ AIaJ|;yRfu9hepqzU({TPj%EP7;K0Ej!8HK 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

|NExhc;{)ew+RMdx!69f;r_|gx`$v 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 FPa5*4o& zV275?eU1fzYd}FJL3!JoVHa|U~D*21T?zlOJ?Si#YH`$!@qk|Ed9 z!FTAnrdWO8v zfN~SUv{6h-A<(68*Op`Chk=}qko{i4)GR!lb+tN&&7uDJze8t6jIuyO`oDi`1@t$; f4;o~1xsFO9g4ZY8jqnEkLZxzC<5+@%+1398* Date: Wed, 23 Sep 2026 03:23:02 +0200 Subject: [PATCH 04/18] drop core export, defensive checks and error-path tests --- packages/typegpu-geometry/src/mesh/bake.ts | 244 ++++++++---------- .../typegpu-geometry/src/mesh/combinators.ts | 80 +----- .../typegpu-geometry/src/mesh/parametric.ts | 4 - .../typegpu-geometry/src/mesh/primitives.ts | 21 -- packages/typegpu-geometry/tests/bake.test.ts | 26 -- packages/typegpu-geometry/tests/mesh.test.ts | 24 -- .../typegpu-geometry/tests/updates.test.ts | 76 +----- packages/typegpu/src/indexNamedExports.ts | 6 +- 8 files changed, 116 insertions(+), 365 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/bake.ts b/packages/typegpu-geometry/src/mesh/bake.ts index 3234fd3bab..f01e9d027c 100644 --- a/packages/typegpu-geometry/src/mesh/bake.ts +++ b/packages/typegpu-geometry/src/mesh/bake.ts @@ -1,6 +1,6 @@ import { d, - type HasIndexBuffer, + std, type IndexFlag, type StorageFlag, type TgpuBuffer, @@ -10,12 +10,17 @@ import { type VertexFlag, type WithBinding, } from 'typegpu'; -import { fromBuffer } from './combinators.ts'; import { createFill, type UpdateOptions } from './fill.ts'; import { type Geometry, type IndexedGeometry, isIndexed, layoutOf } from './geometry.ts'; export type { UpdateOptions } from './fill.ts'; +// TODO: use HasIndexBuffer from typegpu once it's exported +type HasIndexBuffer = Pick< + ReturnType, + 'hasIndexBuffer' | 'drawIndexed' +>; + export type VertexBuffer = TgpuBuffer> & VertexFlag & StorageFlag; @@ -40,57 +45,36 @@ export interface BakeOptions extends UpdateOptions { with?: WithBinding; } +interface Prepared { + mesh: T; + fills: ReturnType[]; +} + function fits(kind: string, capacity: number, needed: number) { - if (!Number.isSafeInteger(needed) || needed < 0 || needed > capacity) { - throw new Error(`The ${kind} count must be an integer between 0 and ${capacity}`); + if (needed > capacity) { + throw new Error(`The ${kind} count ${needed} exceeds the buffer capacity ${capacity}`); } } -function record(root: TgpuRoot, options: UpdateOptions, fills: ReturnType[]) { - if (options.encoder && options.pass) { - throw new Error('Supply either an encoder or a compute pass, not both'); - } +function finish(root: TgpuRoot, { mesh, fills }: Prepared, options: UpdateOptions): T { if (options.encoder || options.pass) { for (const fill of fills) fill.run(options); - return; + return mesh; } const encoder = root['~unstable'].createCommandEncoder(); const pass = encoder.beginComputePass(); for (const fill of fills) fill.run({ ...options, pass }); pass.end(); encoder.submit(); + return mesh; } -interface Prepared { - mesh: T; - fills: ReturnType[]; -} - -function finish( - root: TgpuRoot, - prepared: Prepared, - options: UpdateOptions, -): T { - try { - record(root, options, prepared.fills); - return prepared.mesh; - } catch (error) { - prepared.mesh.destroy(); - throw error; - } -} - -async function finishAsync( +async function finishAsync( root: TgpuRoot, prepared: Prepared, options: UpdateOptions, ): Promise { - try { - await Promise.all(prepared.fills.map((fill) => fill.initAsync())); - } catch (error) { - prepared.mesh.destroy(); - throw error; - } + await Promise.all(prepared.fills.map((fill) => fill.initAsync())); return finish(root, prepared, options); } @@ -138,83 +122,84 @@ function prepareBake( const { schema, topology, vertexCount } = g; const indexed = isIndexed(g) ? g : undefined; const indexCount = indexed?.indexCount ?? 0; - fits('vertex', 0xffffffff, vertexCount); - fits('index', 0xffffffff, indexCount); - const owned: { destroy(): void }[] = []; - const destroy = () => owned.forEach((resource) => resource.destroy()); - try { - const vertices = - options.vertices ?? - (root - .createBuffer(d.arrayOf(schema, Math.max(1, vertexCount)) as d.WgslArray) - .$usage('vertex', 'storage') as unknown as VertexBuffer); - if (!options.vertices) owned.push(vertices); - fits('vertex', vertices.dataType.elementCount, vertexCount); + const vertices = + options.vertices ?? + (root + .createBuffer(d.arrayOf(schema, Math.max(1, vertexCount)) as d.WgslArray) + .$usage('vertex', 'storage') as unknown as VertexBuffer); + fits('vertex', vertices.dataType.elementCount, vertexCount); + + const indices = indexed + ? (options.indices ?? + root.createBuffer(d.arrayOf(d.u32, Math.max(1, indexCount))).$usage('index', 'storage')) + : undefined; + if (indices) fits('index', indices.dataType.elementCount, indexCount); + + const pipelineRoot = options.with ?? root; + const output = vertices.as('mutable'); + const fillVertices = createFill( + pipelineRoot, + vertexCount, + (i) => { + 'use gpu'; + output.$[i] = g.vertexAt(i); + }, + options.bindGroups, + ); + const fills = [fillVertices]; + + const readVertices = vertices.as('readonly'); + const layout = layoutOf(schema); + const mesh = { + schema, + topology, + vertexCount, + vertexAt: (i: number) => { + 'use gpu'; + return std.copy(readVertices.$[i]) as d.InferGPU; + }, + vertices, + layout, + updateVertices: fillVertices.run, + destroy: () => { + if (!options.vertices) vertices.destroy(); + if (!options.indices) indices?.destroy(); + }, + }; + + if (!indexed || !indices) { + return { + mesh: { ...mesh, inject: () => (pipeline) => pipeline.with(layout, vertices) }, + fills, + }; + } - const indices = indexed - ? (options.indices ?? - root.createBuffer(d.arrayOf(d.u32, Math.max(1, indexCount))).$usage('index', 'storage')) - : undefined; - if (indices) { - if (!options.indices) owned.push(indices); - fits('index', indices.dataType.elementCount, indexCount); - } - const output = vertices.as('mutable'); - const writeIndices = indices?.as('mutable'); - const pipelineRoot = options.with ?? root; - const fillVertices = createFill( + const writeIndices = indices.as('mutable'); + fills.push( + createFill( pipelineRoot, - vertexCount, + indexCount, (i) => { 'use gpu'; - output.$[i] = g.vertexAt(i); + writeIndices.$[i] = indexed.indexAt(i); }, options.bindGroups, - ); - const fills = [fillVertices]; - if (indexed && writeIndices) { - fills.push( - createFill( - pipelineRoot, - indexCount, - (i) => { - 'use gpu'; - writeIndices.$[i] = indexed.indexAt(i); - }, - options.bindGroups, - ), - ); - } + ), + ); - const layout = layoutOf(schema); - const mesh = { - vertices, - layout, - updateVertices: fillVertices.run, - destroy, - }; - if (indices) { - const indexedMesh: BakedIndexed = { - ...fromBuffer(vertices, { indices, topology, vertexCount, indexCount }), - ...mesh, - indices, - inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indices), - }; - return { mesh: indexedMesh, fills }; - } - return { - mesh: { - ...fromBuffer(vertices, { topology, vertexCount }), - ...mesh, - inject: () => (pipeline) => pipeline.with(layout, vertices), - }, - fills, - }; - } catch (error) { - destroy(); - throw error; - } + const readIndices = indices.as('readonly'); + const indexedMesh: BakedIndexed = { + ...mesh, + indices, + indexCount, + indexAt: (i: number) => { + 'use gpu'; + return readIndices.$[i] as number; + }, + inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indices), + }; + return { mesh: indexedMesh, fills }; } export interface BakeIndicesOptions extends UpdateOptions { @@ -249,36 +234,31 @@ function prepareIndices( g: IndexedGeometry, options: BakeIndicesOptions, ): Prepared> { - fits('index', 0xffffffff, g.indexCount); const indices = options.indices ?? root.createBuffer(d.arrayOf(d.u32, Math.max(1, g.indexCount))).$usage('index', 'storage'); - const destroy = () => { - if (!options.indices) indices.destroy(); - }; - try { - fits('index', indices.dataType.elementCount, g.indexCount); - const output = indices.as('mutable'); - const fill = createFill( - options.with ?? root, - g.indexCount, - (i) => { - 'use gpu'; - output.$[i] = g.indexAt(i); - }, - options.bindGroups, - ); - return { - mesh: { - ...g, - indices, - inject: () => (pipeline) => pipeline.withIndexBuffer(indices), - destroy, + fits('index', indices.dataType.elementCount, g.indexCount); + + const output = indices.as('mutable'); + const fill = createFill( + options.with ?? root, + g.indexCount, + (i) => { + 'use gpu'; + output.$[i] = g.indexAt(i); + }, + options.bindGroups, + ); + + return { + mesh: { + ...g, + indices, + inject: () => (pipeline) => pipeline.withIndexBuffer(indices), + destroy: () => { + if (!options.indices) indices.destroy(); }, - fills: [fill], - }; - } catch (error) { - destroy(); - throw error; - } + }, + fills: [fill], + }; } diff --git a/packages/typegpu-geometry/src/mesh/combinators.ts b/packages/typegpu-geometry/src/mesh/combinators.ts index 888aa6123b..03e3017290 100644 --- a/packages/typegpu-geometry/src/mesh/combinators.ts +++ b/packages/typegpu-geometry/src/mesh/combinators.ts @@ -1,5 +1,5 @@ -import { d, std, tgpu, type StorageFlag, type TgpuBuffer } from 'typegpu'; -import { type Geometry, type IndexedGeometry, isIndexed, type Topology } from './geometry.ts'; +import { d, std, tgpu } from 'typegpu'; +import { type Geometry, type IndexedGeometry, isIndexed } from './geometry.ts'; function reversedCorner(i: number) { 'use gpu'; @@ -32,19 +32,7 @@ export function transform(g: Geometry, m: d.m4x4f): Geometr export function transform(g: Geometry, m: d.m4x4f): Geometry { const matrix = std.copy(m); const columns = matrix.columns; - const determinant = std.dot(columns[0].xyz, std.cross(columns[1].xyz, columns[2].xyz)); - if ( - !Number.isFinite(determinant) || - determinant === 0 || - columns[0].w !== 0 || - columns[1].w !== 0 || - columns[2].w !== 0 || - columns[3].w !== 1 || - ![...columns].every((column) => [...column].every(Number.isFinite)) - ) { - throw new Error('transform needs a finite invertible affine matrix'); - } const normalM = normalMatrixOf(matrix); const reflected = determinant < 0 && g.topology === 'triangle-list'; const reverseVertices = reflected && !isIndexed(g); @@ -271,67 +259,3 @@ export function concat(...parts: Geometry[]): return result; } - -type VertexSource = TgpuBuffer> & StorageFlag; -type IndexSource = TgpuBuffer> & StorageFlag; - -export interface FromBufferOptions { - indices?: IndexSource; - topology?: Topology; - vertexCount?: number; - indexCount?: number; -} - -export function fromBuffer( - vertices: VertexSource, - options: FromBufferOptions & { indices: IndexSource }, -): IndexedGeometry; -export function fromBuffer( - vertices: VertexSource, - options?: FromBufferOptions, -): Geometry; -export function fromBuffer( - vertices: VertexSource, - options: FromBufferOptions = {}, -): Geometry { - const indices = options.indices; - const topology = options.topology ?? 'triangle-list'; - const vertexCount = options.vertexCount ?? vertices.dataType.elementCount; - const indexCount = options.indexCount ?? indices?.dataType.elementCount ?? 0; - - for (const [kind, count, capacity] of [ - ['vertex', vertexCount, vertices.dataType.elementCount], - ['index', indexCount, indices?.dataType.elementCount ?? 0], - ] as const) { - if (!Number.isSafeInteger(count) || count < 0 || count > capacity) { - throw new Error(`The ${kind} count must be an integer between 0 and ${capacity}`); - } - } - - const readVertices = vertices.as('readonly'); - const base: Geometry = { - schema: vertices.dataType.elementType, - topology, - vertexCount, - vertexAt: (i: number) => { - 'use gpu'; - return std.copy(readVertices.$[i]) as d.InferGPU; - }, - }; - - if (!indices) { - return base; - } - - const readIndices = indices.as('readonly'); - const result: IndexedGeometry = { - ...base, - indexCount, - indexAt: (i: number) => { - 'use gpu'; - return readIndices.$[i] as number; - }, - }; - - return result; -} diff --git a/packages/typegpu-geometry/src/mesh/parametric.ts b/packages/typegpu-geometry/src/mesh/parametric.ts index f335b62634..ae84912268 100644 --- a/packages/typegpu-geometry/src/mesh/parametric.ts +++ b/packages/typegpu-geometry/src/mesh/parametric.ts @@ -55,10 +55,6 @@ export function sampleGrid( sample: (u: number, v: number) => d.Infer, { cols, rows }: ParametricOptions, ): IndexedGeometry { - if (!Number.isSafeInteger(cols) || cols < 1 || !Number.isSafeInteger(rows) || rows < 1) { - throw new Error('parametric needs positive integer cols and rows'); - } - return { schema: Surface, topology: 'triangle-list', diff --git a/packages/typegpu-geometry/src/mesh/primitives.ts b/packages/typegpu-geometry/src/mesh/primitives.ts index 3f15380bad..df070c0a2a 100644 --- a/packages/typegpu-geometry/src/mesh/primitives.ts +++ b/packages/typegpu-geometry/src/mesh/primitives.ts @@ -48,12 +48,6 @@ export function sphere({ segments = 32, rings = 16, }: SphereOptions = {}): IndexedGeometry { - if (!Number.isFinite(radius) || radius <= 0) { - throw new Error('sphere needs a positive finite radius'); - } - if (segments < 3 || rings < 2) { - throw new Error('sphere needs at least 3 segments and 2 rings'); - } const grid = sampleGrid( (u, v) => { 'use gpu'; @@ -92,9 +86,6 @@ export function plane({ widthSegments = 1, depthSegments = 1, }: PlaneOptions = {}): IndexedGeometry { - if (![width, depth].every((value) => Number.isFinite(value) && value > 0)) { - throw new Error('plane needs positive finite dimensions'); - } return parametric( { at: (u, v) => { @@ -165,12 +156,6 @@ export function cylinder({ heightSegments = 1, caps = true, }: CylinderOptions = {}): IndexedGeometry { - if (![radius, height].every((value) => Number.isFinite(value) && value > 0)) { - throw new Error('cylinder needs positive finite dimensions'); - } - if (radialSegments < 3) { - throw new Error('cylinder needs at least 3 radial segments'); - } const side = sampleGrid( (u, v) => { 'use gpu'; @@ -233,12 +218,6 @@ export function torus({ ringSegments = 48, tubeSegments = 24, }: TorusOptions = {}): IndexedGeometry { - if (![radius, tube].every((value) => Number.isFinite(value) && value > 0)) { - throw new Error('torus needs positive finite radii'); - } - if (ringSegments < 3 || tubeSegments < 3) { - throw new Error('torus needs at least 3 ring and tube segments'); - } return sampleGrid( (u, v) => { 'use gpu'; diff --git a/packages/typegpu-geometry/tests/bake.test.ts b/packages/typegpu-geometry/tests/bake.test.ts index 94c08d3763..d03f60a9c5 100644 --- a/packages/typegpu-geometry/tests/bake.test.ts +++ b/packages/typegpu-geometry/tests/bake.test.ts @@ -2,7 +2,6 @@ import { expect } from 'vitest'; import { d, tgpu } from 'typegpu'; import { it } from 'typegpu-testing-utility'; import { meshes } from '@typegpu/geometry'; -import { fromBuffer } from '../src/mesh/combinators.ts'; import { scalars, shaderCodes } from './fixtures.ts'; it('maps and unindexes in a shader', () => { @@ -118,31 +117,6 @@ it('retains caller-owned buffers and uses geometry counts instead of capacity', expect(indices.destroyed).toBe(false); }); -it('destroys buffers it allocated', ({ root }) => { - const mesh = meshes.bake(root, scalars); - mesh.destroy(); - - expect(mesh.vertices.destroyed).toBe(true); - expect(mesh.indices.destroyed).toBe(true); -}); - -it('rejects counts outside the buffer capacity', ({ root }) => { - const vertices = root.createBuffer(d.arrayOf(d.f32, 4)).$usage('storage'); - const indices = root.createBuffer(d.arrayOf(d.u32, 6)).$usage('storage'); - - for (const vertexCount of [-1, 1.5, 5, NaN]) { - expect(() => fromBuffer(vertices, { vertexCount })).toThrowErrorMatchingInlineSnapshot( - `[Error: The vertex count must be an integer between 0 and 4]`, - ); - } - for (const indexCount of [-1, 1.5, 7, NaN]) { - expect(() => fromBuffer(vertices, { indices, indexCount })).toThrowErrorMatchingInlineSnapshot( - `[Error: The index count must be an integer between 0 and 6]`, - ); - } - expect(fromBuffer(vertices, { indices, vertexCount: 0, indexCount: 0 }).indexCount).toBe(0); -}); - it('exposes writable vertices and allows mapped reads in another shader', ({ root }) => { const mesh = meshes.bake(root, scalars); const copied = meshes.map(mesh, d.f32, (value) => { diff --git a/packages/typegpu-geometry/tests/mesh.test.ts b/packages/typegpu-geometry/tests/mesh.test.ts index 94acc3a3f1..f84bf9519e 100644 --- a/packages/typegpu-geometry/tests/mesh.test.ts +++ b/packages/typegpu-geometry/tests/mesh.test.ts @@ -21,24 +21,6 @@ function windingAgreesWithNormals(g: meshes.IndexedGeometry) { } describe('meshes on the CPU', () => { - it('rejects subdivisions that would produce invalid coordinates or connectivity', () => { - for (const count of [0, -1, 1.5, NaN, Infinity]) { - expect(() => meshes.plane({ widthSegments: count })).toThrowErrorMatchingInlineSnapshot( - `[Error: parametric needs positive integer cols and rows]`, - ); - } - }); - - it('rejects attached fields that collide with the source schema', () => { - const ReplacedPosition = d.struct({ position: d.f32 }); - - expect(() => - meshes.attach(meshes.plane(), ReplacedPosition, () => ReplacedPosition({ position: 1 })), - ).toThrowErrorMatchingInlineSnapshot( - `[Error: attach cannot replace the existing field 'position'; use map instead]`, - ); - }); - it.each([ ['plane', meshes.plane()], ['sphere', meshes.sphere({ segments: 8, rings: 4 })], @@ -184,12 +166,6 @@ describe('meshes on the CPU', () => { expect(moved.vertexAt(0).position.y).toBe(2); expect(moved.vertexAt(0).position.y).toBe(2); expect(vertex.position.y).toBe(0); - - expect(() => - meshes.transform(shape, std.scaling4(d.vec3f(1, 0, 1))), - ).toThrowErrorMatchingInlineSnapshot( - `[Error: transform needs a finite invertible affine matrix]`, - ); }); it.each([meshes.sphere(), meshes.cylinder()])( diff --git a/packages/typegpu-geometry/tests/updates.test.ts b/packages/typegpu-geometry/tests/updates.test.ts index 3954c572ba..3f97aa39a6 100644 --- a/packages/typegpu-geometry/tests/updates.test.ts +++ b/packages/typegpu-geometry/tests/updates.test.ts @@ -1,29 +1,9 @@ -import { expect, vi } from 'vitest'; +import { expect } from 'vitest'; import { it } from 'typegpu-testing-utility'; import { d, tgpu } from 'typegpu'; import { meshes } from '@typegpu/geometry'; import { scalars, shaderCodes } from './fixtures.ts'; -for (const asynchronous of [false, true]) { - it(`reuses the initial fill pipeline (async: ${asynchronous})`, async ({ root, device }) => { - const mesh = asynchronous ? await meshes.bakeAsync(root, scalars) : meshes.bake(root, scalars); - expect(device.mock.createComputePipeline).toHaveBeenCalledTimes(asynchronous ? 0 : 2); - expect(device.mock.createComputePipelineAsync).toHaveBeenCalledTimes(asynchronous ? 2 : 0); - device.mock.createBuffer.mockClear(); - device.mock.createBindGroup.mockClear(); - device.mock.createComputePipeline.mockClear(); - device.mock.createComputePipelineAsync.mockClear(); - - mesh.updateVertices(); - mesh.updateVertices(); - - expect(device.mock.createBuffer).not.toHaveBeenCalled(); - expect(device.mock.createBindGroup).not.toHaveBeenCalled(); - expect(device.mock.createComputePipeline).not.toHaveBeenCalled(); - expect(device.mock.createComputePipelineAsync).not.toHaveBeenCalled(); - }); -} - it('bakes indices from baked geometry without reading GPU values on the CPU', ({ root }) => { const source = meshes.bake(root, scalars); const mesh = meshes.bakeIndices(root, source); @@ -48,33 +28,6 @@ it('specializes the vertex evaluator through a configured pipeline builder', ({ expect(mesh.vertexCount).toBe(4); }); -it('does not submit or retain a caller-supplied encoder', ({ root, device }) => { - const encoder = root['~unstable'].createCommandEncoder(); - const mesh = meshes.bake(root, scalars, { encoder }); - mesh.updateVertices({ encoder }); - expect(device.mock.queue.submit).not.toHaveBeenCalled(); - - encoder.submit(); - mesh.updateVertices(); - - expect(device.mock.queue.submit).toHaveBeenCalledTimes(2); -}); - -it('records into a supplied pass without ending it', ({ root, device, commandEncoder }) => { - const mesh = meshes.bake(root, scalars); - const pass = commandEncoder.beginComputePass(); - const nativePass = commandEncoder.mock.beginComputePass.mock.results[0]?.value; - nativePass.end.mockClear(); - commandEncoder.mock.beginComputePass.mockClear(); - device.mock.queue.submit.mockClear(); - - mesh.updateVertices({ pass }); - - expect(commandEncoder.mock.beginComputePass).not.toHaveBeenCalled(); - expect(nativePass.end).not.toHaveBeenCalled(); - expect(device.mock.queue.submit).not.toHaveBeenCalled(); -}); - it('overrides source bindings for one update', ({ root, device, commandEncoder }) => { let nextBindGroup = 0; device.mock.createBindGroup.mockImplementation(() => `bindGroup${nextBindGroup++}`); @@ -161,30 +114,3 @@ it('prepares index-only baking asynchronously without evaluating vertices', asyn expect(device.mock.createComputePipelineAsync).toHaveBeenCalledOnce(); expect(device.mock.createComputePipeline).not.toHaveBeenCalled(); }); - -for (const asynchronous of [false, true]) { - it(`cleans up failed baking and retains caller buffers (async: ${asynchronous})`, async ({ - root, - device, - }) => { - const vertices = root.createBuffer(d.arrayOf(d.f32, 4)).$usage('vertex', 'storage'); - const allocated = vi.spyOn(root, 'createBuffer'); - const compile = asynchronous - ? device.mock.createComputePipelineAsync - : device.mock.createComputePipeline; - compile.mockImplementationOnce(() => { - throw new Error('Compilation failed'); - }); - - await expect( - Promise.resolve().then(() => - asynchronous - ? meshes.bakeAsync(root, scalars, { vertices }) - : meshes.bake(root, scalars, { vertices }), - ), - ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Compilation failed]`); - expect(allocated.mock.results.map(({ value }) => value.destroyed)).toEqual([true]); - expect(vertices.destroyed).toBe(false); - expect(device.mock.queue.submit).not.toHaveBeenCalled(); - }); -} diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index 6daba4eb63..c4c2f0cf0c 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -66,11 +66,7 @@ export type { } from './core/root/rootTypes.ts'; export type { Storage, StorageFlag } from './extension.ts'; export type { TgpuVertexLayout } from './core/vertexLayout/vertexLayout.ts'; -export type { - HasIndexBuffer, - TgpuPrimitiveState, - TgpuRenderPipeline, -} from './core/pipeline/renderPipeline.ts'; +export type { TgpuPrimitiveState, TgpuRenderPipeline } from './core/pipeline/renderPipeline.ts'; export type { ColorAttachment, DepthStencilAttachment } from './core/commandEncoder/attachments.ts'; export type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; export type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; From 946d67e3c7f27b5cd316c8d74a5d79b9b9f285d8 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Wed, 23 Sep 2026 03:28:57 +0200 Subject: [PATCH 05/18] shell float-only surface functions --- .../typegpu-geometry/src/mesh/primitives.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/primitives.ts b/packages/typegpu-geometry/src/mesh/primitives.ts index df070c0a2a..3798a22250 100644 --- a/packages/typegpu-geometry/src/mesh/primitives.ts +++ b/packages/typegpu-geometry/src/mesh/primitives.ts @@ -1,40 +1,55 @@ -import { d, std } from 'typegpu'; +import { d, std, tgpu } from 'typegpu'; import { concat, transform } from './combinators.ts'; import { type IndexedGeometry, Surface } from './geometry.ts'; import { frame, NX, NY, NZ, TAU, X, Y, Z } from './math.ts'; import { parametric, sampleGrid } from './parametric.ts'; export const surfaces = { - sphere: (u: number, v: number) => { + sphere: tgpu.fn( + [d.f32, d.f32], + d.vec3f, + )((u, v) => { 'use gpu'; const theta = v * Math.PI; const phi = u * TAU; const sinTheta = std.sin(theta); return d.vec3f(sinTheta * std.cos(phi), std.cos(theta), sinTheta * std.sin(phi)); - }, - plane: (u: number, v: number) => { + }), + plane: tgpu.fn( + [d.f32, d.f32], + d.vec3f, + )((u, v) => { 'use gpu'; return d.vec3f(u - 0.5, 0, 0.5 - v); - }, - disc: (u: number, v: number) => { + }), + disc: tgpu.fn( + [d.f32, d.f32], + d.vec3f, + )((u, v) => { 'use gpu'; const phi = u * TAU; return d.vec3f(v * std.cos(phi), 0, v * std.sin(phi)); - }, - cylinder: (u: number, v: number) => { + }), + cylinder: tgpu.fn( + [d.f32, d.f32], + d.vec3f, + )((u, v) => { 'use gpu'; const phi = u * TAU; return d.vec3f(std.cos(phi), 0.5 - v, std.sin(phi)); - }, - torus: (u: number, v: number, radius: number, tube: number) => { + }), + torus: tgpu.fn( + [d.f32, d.f32, d.f32, d.f32], + d.vec3f, + )((u, v, radius, tube) => { 'use gpu'; const phi = u * TAU; const t = v * TAU; const ring = radius + tube * std.cos(t); return d.vec3f(ring * std.cos(phi), -tube * std.sin(t), ring * std.sin(phi)); - }, + }), }; export interface SphereOptions { From 061a4c963084b5a3f99f55631c39bba7caa7ebca Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Wed, 23 Sep 2026 11:21:08 +0200 Subject: [PATCH 06/18] drop custom bind group mock --- packages/typegpu-geometry/tests/updates.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/typegpu-geometry/tests/updates.test.ts b/packages/typegpu-geometry/tests/updates.test.ts index 3f97aa39a6..b471987db6 100644 --- a/packages/typegpu-geometry/tests/updates.test.ts +++ b/packages/typegpu-geometry/tests/updates.test.ts @@ -29,8 +29,6 @@ it('specializes the vertex evaluator through a configured pipeline builder', ({ }); it('overrides source bindings for one update', ({ root, device, commandEncoder }) => { - let nextBindGroup = 0; - device.mock.createBindGroup.mockImplementation(() => `bindGroup${nextBindGroup++}`); const config = tgpu.bindGroupLayout({ height: { uniform: d.f32 } }); const initial = root.createBindGroup(config, { height: root.createUniform(d.f32, 2) }); const override = root.createBindGroup(config, { height: root.createUniform(d.f32, 3) }); From 81e28630b5bd5502e4f4936fba0fdbaecec06bdc Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 6 Oct 2026 16:34:53 +0200 Subject: [PATCH 07/18] address review --- .../src/examples/rendering/mesh-primitives/geometry.ts | 1 - .../src/examples/rendering/mesh-primitives/meta.json | 1 + packages/typegpu-geometry/src/mesh/bake.ts | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts index 77d177e825..4d3bfa2518 100644 --- a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts @@ -6,7 +6,6 @@ const Paint = d.struct({ color: d.vec3f }); export function paint(g: meshes.IndexedGeometry, color: d.v3f) { return meshes.attach(g, Paint, () => { 'use gpu'; - return Paint({ color }); }); } diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json index f68f78e19d..9dd070d0f3 100644 --- a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/meta.json @@ -1,5 +1,6 @@ { "title": "Mesh Primitives", + "description": "Combines procedural primitives, a rippling parametric blob and a spring into one mesh, evaluated in the vertex shader and lit by a circling shadow-casting light.", "category": "rendering", "tags": ["3d", "geometry", "rasterization"], "coolFactor": 6 diff --git a/packages/typegpu-geometry/src/mesh/bake.ts b/packages/typegpu-geometry/src/mesh/bake.ts index f01e9d027c..d1e850aed8 100644 --- a/packages/typegpu-geometry/src/mesh/bake.ts +++ b/packages/typegpu-geometry/src/mesh/bake.ts @@ -15,7 +15,6 @@ import { type Geometry, type IndexedGeometry, isIndexed, layoutOf } from './geom export type { UpdateOptions } from './fill.ts'; -// TODO: use HasIndexBuffer from typegpu once it's exported type HasIndexBuffer = Pick< ReturnType, 'hasIndexBuffer' | 'drawIndexed' From c836c3fc61ecf1eb20984c47ff8baef5c767a39f Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 6 Oct 2026 16:36:40 +0200 Subject: [PATCH 08/18] port to new orbit camera --- .../src/examples/rendering/mesh-primitives/shading.ts | 5 ++--- .../tests/individual-example-tests/mesh-primitives.test.ts | 7 +------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts index c3db8f73d9..455f67b96a 100644 --- a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/shading.ts @@ -1,6 +1,6 @@ import { d, std, tgpu } from 'typegpu'; -import { Camera } from '../../common/setup-orbit-camera.ts'; +const Camera = d.struct({ viewProjection: d.mat4x4f }); const Light = d.struct({ viewProj: d.mat4x4f, direction: d.vec3f }); export const SceneUniforms = d.struct({ camera: Camera, light: Light, time: d.f32 }); @@ -13,8 +13,7 @@ export const shadowLayout = tgpu.bindGroupLayout({ export function worldToClip(position: d.v3f) { 'use gpu'; - const camera = sceneLayout.$.uniforms.camera; - return camera.projection * camera.view * d.vec4f(position, 1); + return sceneLayout.$.uniforms.camera.viewProjection * d.vec4f(position, 1); } export function worldToLight(position: d.v3f) { diff --git a/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts b/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts index 2b03ec536e..4f1c29ec41 100644 --- a/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts +++ b/apps/typegpu-docs/tests/individual-example-tests/mesh-primitives.test.ts @@ -26,12 +26,7 @@ describe('mesh primitives example', () => { ); expect([...new Set(uniforms)].join('\n\n')).toMatchInlineSnapshot(` "struct Camera { - position: vec4f, - targetPos: vec4f, - view: mat4x4f, - projection: mat4x4f, - viewInverse: mat4x4f, - projectionInverse: mat4x4f, + viewProjection: mat4x4f, } struct Light { From 043c0ab01051daaec0e8121f2112af439c9e2041 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Fri, 16 Oct 2026 20:52:36 +0200 Subject: [PATCH 09/18] Fix geometry lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e8adf0e47..f51687a0fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -801,7 +801,7 @@ importers: version: link:../unplugin-typegpu vitest: specifier: catalog:test - version: 4.1.2(@types/node@24.10.0)(@vitest/browser-preview@4.1.2)(jsdom@27.0.0(postcss@8.5.8))(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.10.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.10.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.0.0(postcss@8.5.8))(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.10.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.20.6)(yaml@2.9.1)) publishDirectory: dist packages/typegpu-gl: From 350d0ca685453f366a692701c9de04f7a67df6ae Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:05:03 +0200 Subject: [PATCH 10/18] Build @typegpu/geometry with tsdown --- packages/typegpu-geometry/build.config.ts | 12 ------------ packages/typegpu-geometry/package.json | 11 +++++------ packages/typegpu-geometry/tsdown.config.ts | 14 ++++++++++++++ pnpm-lock.yaml | 6 +++--- 4 files changed, 22 insertions(+), 21 deletions(-) delete mode 100644 packages/typegpu-geometry/build.config.ts create mode 100644 packages/typegpu-geometry/tsdown.config.ts diff --git a/packages/typegpu-geometry/build.config.ts b/packages/typegpu-geometry/build.config.ts deleted file mode 100644 index 7f9f024f1f..0000000000 --- a/packages/typegpu-geometry/build.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type BuildConfig, defineBuildConfig } from 'unbuild'; -import typegpu from 'unplugin-typegpu/rollup'; - -const Config: BuildConfig[] = defineBuildConfig({ - hooks: { - 'rollup:options': (_options, config) => { - config.plugins.push(typegpu({ include: [/\.ts$/] })); - }, - }, -}); - -export default Config; diff --git a/packages/typegpu-geometry/package.json b/packages/typegpu-geometry/package.json index ce713057d0..4c81b95ca9 100644 --- a/packages/typegpu-geometry/package.json +++ b/packages/typegpu-geometry/package.json @@ -23,17 +23,16 @@ "./package.json": "./dist/package.json", ".": { "types": "./dist/index.d.ts", - "module": "./dist/index.mjs", - "import": "./dist/index.mjs", - "default": "./dist/index.cjs" + "module": "./dist/index.js", + "import": "./dist/index.js" } }, "linkDirectory": false, - "main": "./dist/index.mjs", + "main": "./dist/index.js", "types": "./dist/index.d.ts" }, "scripts": { - "build": "unbuild", + "build": "tsdown", "test": "vitest run", "test:types": "pnpm tsc --p ./tsconfig.json --noEmit && pnpm tsc --p ./tsconfig.test.json --noEmit", "prepublishOnly": "tgpu-dev-cli prepack" @@ -42,10 +41,10 @@ "@typegpu/tgpu-dev-cli": "workspace:*", "@webgpu/types": "catalog:types", "jiti": "catalog:build", + "tsdown": "catalog:build", "typegpu": "workspace:*", "typegpu-testing-utility": "workspace:*", "typescript": "catalog:types", - "unbuild": "catalog:build", "unplugin-typegpu": "workspace:*", "vitest": "catalog:test" }, diff --git a/packages/typegpu-geometry/tsdown.config.ts b/packages/typegpu-geometry/tsdown.config.ts new file mode 100644 index 0000000000..07ad9608d2 --- /dev/null +++ b/packages/typegpu-geometry/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown'; +import typegpu from 'unplugin-typegpu/rolldown'; + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'dist', + format: 'esm', + dts: true, + platform: 'neutral', + unbundle: true, + sourcemap: false, + target: false, + plugins: [typegpu({ include: [/\.ts$/], exclude: [/\.d\.ts$/] })], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f51687a0fb..013756a312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -784,6 +784,9 @@ importers: jiti: specifier: catalog:build version: 2.6.1 + tsdown: + specifier: catalog:build + version: 0.15.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(tsover@6.0.2)(unrun@0.2.31(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)) typegpu: specifier: workspace:* version: link:../typegpu @@ -793,9 +796,6 @@ importers: typescript: specifier: npm:tsover@^6.0.2 version: tsover@6.0.2 - unbuild: - specifier: catalog:build - version: 3.5.0(tsover@6.0.2) unplugin-typegpu: specifier: workspace:* version: link:../unplugin-typegpu From fa470eebb53fb8dcb236d62e780a77439d0a11ba Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:06:03 +0200 Subject: [PATCH 11/18] Export miterJoinLimitSlot --- .../typegpu-geometry/src/lines/joins/index.ts | 2 ++ packages/typegpu-geometry/tests/lines.test.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 packages/typegpu-geometry/tests/lines.test.ts diff --git a/packages/typegpu-geometry/src/lines/joins/index.ts b/packages/typegpu-geometry/src/lines/joins/index.ts index 0254331b8c..25224ed6ca 100644 --- a/packages/typegpu-geometry/src/lines/joins/index.ts +++ b/packages/typegpu-geometry/src/lines/joins/index.ts @@ -1,4 +1,6 @@ import { miter } from './miter.ts'; import { round } from './round.ts'; +export { miterJoinLimitSlot } from './miter.ts'; + export const joins = { miter, round }; diff --git a/packages/typegpu-geometry/tests/lines.test.ts b/packages/typegpu-geometry/tests/lines.test.ts new file mode 100644 index 0000000000..4bb0d0682d --- /dev/null +++ b/packages/typegpu-geometry/tests/lines.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest'; +import { d, tgpu } from 'typegpu'; +import { it } from 'typegpu-testing-utility'; +import { + joins, + joinSlot, + LineControlPoint, + miterJoinLimitSlot, + polylineVariableWidth, +} from '@typegpu/geometry'; + +it('configures joins through exported slots', () => { + const vertex = tgpu.fn( + [LineControlPoint, d.u32], + d.vec2f, + )((point, i) => { + 'use gpu'; + return polylineVariableWidth(point, point, point, point, i, 4).vertexPosition; + }); + + const code = tgpu.resolve([vertex.with(joinSlot, joins.miter).with(miterJoinLimitSlot, 3)]); + + expect(code).toContain('miterLimit(miterPointBisectorWhenClockwise(join.start, join.end), 3i)'); +}); From eec04b4375146c111c03a379debb3aec42fabb0b Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:06:17 +0200 Subject: [PATCH 12/18] Add a bevel join --- .../typegpu-geometry/src/lines/joins/bevel.ts | 16 ++++++++++++ .../typegpu-geometry/src/lines/joins/index.ts | 3 ++- packages/typegpu-geometry/tests/lines.test.ts | 25 ++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 packages/typegpu-geometry/src/lines/joins/bevel.ts diff --git a/packages/typegpu-geometry/src/lines/joins/bevel.ts b/packages/typegpu-geometry/src/lines/joins/bevel.ts new file mode 100644 index 0000000000..11ef78140e --- /dev/null +++ b/packages/typegpu-geometry/src/lines/joins/bevel.ts @@ -0,0 +1,16 @@ +import { f32, vec2f } from 'typegpu/data'; +import { mix } from 'typegpu/std'; +import type { JoinInput } from '../types.ts'; + +/** + * Cuts the outside of a turn with a straight edge between the sides of the two segments. + */ +export function bevel(join: JoinInput, joinVertexIndex: number, maxJoinCount: number) { + 'use gpu'; + if (joinVertexIndex === 0) { + return vec2f(join.v); + } + const t = f32(joinVertexIndex) / f32(maxJoinCount); + const dir = mix(join.d, (join.start + join.end) * 0.5, t); + return join.C.position + dir * join.C.radius; +} diff --git a/packages/typegpu-geometry/src/lines/joins/index.ts b/packages/typegpu-geometry/src/lines/joins/index.ts index 25224ed6ca..c1e6e78607 100644 --- a/packages/typegpu-geometry/src/lines/joins/index.ts +++ b/packages/typegpu-geometry/src/lines/joins/index.ts @@ -1,6 +1,7 @@ +import { bevel } from './bevel.ts'; import { miter } from './miter.ts'; import { round } from './round.ts'; export { miterJoinLimitSlot } from './miter.ts'; -export const joins = { miter, round }; +export const joins = { bevel, miter, round }; diff --git a/packages/typegpu-geometry/tests/lines.test.ts b/packages/typegpu-geometry/tests/lines.test.ts index 4bb0d0682d..684a048e97 100644 --- a/packages/typegpu-geometry/tests/lines.test.ts +++ b/packages/typegpu-geometry/tests/lines.test.ts @@ -1,7 +1,8 @@ import { expect } from 'vitest'; -import { d, tgpu } from 'typegpu'; +import { d, std, tgpu } from 'typegpu'; import { it } from 'typegpu-testing-utility'; import { + JoinInput, joins, joinSlot, LineControlPoint, @@ -9,6 +10,28 @@ import { polylineVariableWidth, } from '@typegpu/geometry'; +const turn = JoinInput({ + C: LineControlPoint({ position: d.vec2f(1, 2), radius: 0.5 }), + v: d.vec2f(1, 2), + d: d.vec2f(1, 0), + fw: d.vec2f(0, 1), + cross: d.vec2f(1, 0), + start: d.vec2f(1, 0), + end: d.vec2f(0, 1), +}); + +it('cuts a bevel join straight between the sides of the two segments', () => { + const points = [0, 1, 2, 3, 4].map((i) => joins.bevel(turn, i, 4)); + const start = d.vec2f(1.5, 2); + const end = d.vec2f(1, 2.5); + + expect(points[0]).toEqual(turn.v); + expect(points[4]).toEqual(std.mix(start, end, 0.5)); + for (const point of points.slice(1)) { + expect(std.dot(point.sub(start), d.vec2f(1, 1))).toBeCloseTo(0); + } +}); + it('configures joins through exported slots', () => { const vertex = tgpu.fn( [LineControlPoint, d.u32], From ba5f4684c83d2590244389b11f46ccfacd253c1f Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:06:48 +0200 Subject: [PATCH 13/18] Keep normals of transforms that flatten a shape --- .../typegpu-geometry/src/mesh/combinators.ts | 25 +++++++++---------- packages/typegpu-geometry/tests/mesh.test.ts | 14 +++++++++++ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/combinators.ts b/packages/typegpu-geometry/src/mesh/combinators.ts index 03e3017290..7835349958 100644 --- a/packages/typegpu-geometry/src/mesh/combinators.ts +++ b/packages/typegpu-geometry/src/mesh/combinators.ts @@ -15,25 +15,24 @@ function reversedCorner(i: number) { return i; } -function normalMatrixOf(m: d.m4x4f): d.m3x3f { - 'use gpu'; - const c0 = m.columns[0].xyz; - const c1 = m.columns[1].xyz; - const c2 = m.columns[2].xyz; - const det = std.dot(c0, std.cross(c1, c2)); - - return d.mat3x3f(std.cross(c1, c2) / det, std.cross(c2, c0) / det, std.cross(c0, c1) / det); -} - export type Placed = d.WgslStruct<{ position: d.Vec3f; normal: d.Vec3f }>; export function transform(g: IndexedGeometry, m: d.m4x4f): IndexedGeometry; export function transform(g: Geometry, m: d.m4x4f): Geometry; export function transform(g: Geometry, m: d.m4x4f): Geometry { const matrix = std.copy(m); - const columns = matrix.columns; - const determinant = std.dot(columns[0].xyz, std.cross(columns[1].xyz, columns[2].xyz)); - const normalM = normalMatrixOf(matrix); + const c0 = matrix.columns[0].xyz; + const c1 = matrix.columns[1].xyz; + const c2 = matrix.columns[2].xyz; + const determinant = std.dot(c0, std.cross(c1, c2)); + // The cofactor matrix is the inverse transpose scaled by the determinant. Unlike the inverse, it + // exists for transforms that flatten a shape, and its sign keeps reflected normals outwards. + const sign = determinant < 0 ? -1 : 1; + const normalM = d.mat3x3f( + std.cross(c1, c2).mul(sign), + std.cross(c2, c0).mul(sign), + std.cross(c0, c1).mul(sign), + ); const reflected = determinant < 0 && g.topology === 'triangle-list'; const reverseVertices = reflected && !isIndexed(g); diff --git a/packages/typegpu-geometry/tests/mesh.test.ts b/packages/typegpu-geometry/tests/mesh.test.ts index f84bf9519e..7737cc5df9 100644 --- a/packages/typegpu-geometry/tests/mesh.test.ts +++ b/packages/typegpu-geometry/tests/mesh.test.ts @@ -130,6 +130,20 @@ describe('meshes on the CPU', () => { } }); + it('keeps normals of transforms that flatten a shape', () => { + const decal = meshes.transform(meshes.plane(), std.scaling4(d.vec3f(2, 0, 1))); + expect(decal.vertexAt(0).normal).toEqual(d.vec3f(0, 1, 0)); + + const coin = meshes.transform(meshes.sphere(), std.scaling4(d.vec3f(1, 0, -1))); + for (let i = 0; i < coin.vertexCount; i++) { + const { position, normal } = coin.vertexAt(i); + expect(position.y).toBe(0); + if (std.length(normal) > 0) { + expect(Math.abs(normal.y)).toBeCloseTo(1); + } + } + }); + it('transforms a custom schema and keeps its other fields', () => { const Tagged = d.struct({ position: d.vec3f, normal: d.vec3f, tag: d.f32 }); const tagged = meshes.map(meshes.plane(), Tagged, (s) => { From eee548ac912cc5d7a8703050f2e38a66aa151a70 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:09:26 +0200 Subject: [PATCH 14/18] Record mesh baking into one compute pass --- packages/typegpu-geometry/src/mesh/bake.ts | 93 ++++++++------------ packages/typegpu-geometry/src/mesh/fill.ts | 66 ++++++++++---- packages/typegpu-geometry/tests/bake.test.ts | 26 +++++- 3 files changed, 111 insertions(+), 74 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/bake.ts b/packages/typegpu-geometry/src/mesh/bake.ts index d1e850aed8..a8e41dcbdb 100644 --- a/packages/typegpu-geometry/src/mesh/bake.ts +++ b/packages/typegpu-geometry/src/mesh/bake.ts @@ -10,10 +10,10 @@ import { type VertexFlag, type WithBinding, } from 'typegpu'; -import { createFill, type UpdateOptions } from './fill.ts'; +import { createFill, type Fill, recordFills, type UpdateOptions } from './fill.ts'; import { type Geometry, type IndexedGeometry, isIndexed, layoutOf } from './geometry.ts'; -export type { UpdateOptions } from './fill.ts'; +export type { RecordOptions, UpdateOptions } from './fill.ts'; type HasIndexBuffer = Pick< ReturnType, @@ -38,15 +38,18 @@ export interface BakedIndexed extends Baked, Indexed inject():

(pipeline: P) => P & HasIndexBuffer; } -export interface BakeOptions extends UpdateOptions { +export type BakeOptions = UpdateOptions & { + /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ vertices?: VertexBuffer; + /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ indices?: IndexBuffer; + /** Creates the fill pipelines from this configured root, for slots and accessors */ with?: WithBinding; -} +}; interface Prepared { mesh: T; - fills: ReturnType[]; + fills: Fill[]; } function fits(kind: string, capacity: number, needed: number) { @@ -56,15 +59,7 @@ function fits(kind: string, capacity: number, needed: number) { } function finish(root: TgpuRoot, { mesh, fills }: Prepared, options: UpdateOptions): T { - if (options.encoder || options.pass) { - for (const fill of fills) fill.run(options); - return mesh; - } - const encoder = root['~unstable'].createCommandEncoder(); - const pass = encoder.beginComputePass(); - for (const fill of fills) fill.run({ ...options, pass }); - pass.end(); - encoder.submit(); + recordFills(root, fills, options); return mesh; } @@ -120,7 +115,6 @@ function prepareBake( ): Prepared> { const { schema, topology, vertexCount } = g; const indexed = isIndexed(g) ? g : undefined; - const indexCount = indexed?.indexCount ?? 0; const vertices = options.vertices ?? @@ -129,16 +123,9 @@ function prepareBake( .$usage('vertex', 'storage') as unknown as VertexBuffer); fits('vertex', vertices.dataType.elementCount, vertexCount); - const indices = indexed - ? (options.indices ?? - root.createBuffer(d.arrayOf(d.u32, Math.max(1, indexCount))).$usage('index', 'storage')) - : undefined; - if (indices) fits('index', indices.dataType.elementCount, indexCount); - - const pipelineRoot = options.with ?? root; const output = vertices.as('mutable'); const fillVertices = createFill( - pipelineRoot, + options.with ?? root, vertexCount, (i) => { 'use gpu'; @@ -146,7 +133,6 @@ function prepareBake( }, options.bindGroups, ); - const fills = [fillVertices]; const readVertices = vertices.as('readonly'); const layout = layoutOf(schema); @@ -160,51 +146,43 @@ function prepareBake( }, vertices, layout, - updateVertices: fillVertices.run, - destroy: () => { - if (!options.vertices) vertices.destroy(); - if (!options.indices) indices?.destroy(); - }, + updateVertices: (update: UpdateOptions = {}) => recordFills(root, [fillVertices], update), }; - if (!indexed || !indices) { + if (!indexed) { return { - mesh: { ...mesh, inject: () => (pipeline) => pipeline.with(layout, vertices) }, - fills, + mesh: { + ...mesh, + inject: () => (pipeline) => pipeline.with(layout, vertices), + destroy: () => { + if (!options.vertices) vertices.destroy(); + }, + }, + fills: [fillVertices], }; } - const writeIndices = indices.as('mutable'); - fills.push( - createFill( - pipelineRoot, - indexCount, - (i) => { - 'use gpu'; - writeIndices.$[i] = indexed.indexAt(i); - }, - options.bindGroups, - ), - ); - - const readIndices = indices.as('readonly'); + const { mesh: indexOnly, fills } = prepareIndices(root, indexed, options); const indexedMesh: BakedIndexed = { ...mesh, - indices, - indexCount, - indexAt: (i: number) => { - 'use gpu'; - return readIndices.$[i] as number; + indices: indexOnly.indices, + indexCount: indexOnly.indexCount, + indexAt: indexOnly.indexAt, + inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indexOnly.indices), + destroy: () => { + if (!options.vertices) vertices.destroy(); + indexOnly.destroy(); }, - inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indices), }; - return { mesh: indexedMesh, fills }; + return { mesh: indexedMesh, fills: [fillVertices, ...fills] }; } -export interface BakeIndicesOptions extends UpdateOptions { +export type BakeIndicesOptions = UpdateOptions & { + /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ indices?: IndexBuffer; + /** Creates the fill pipeline from this configured root, for slots and accessors */ with?: WithBinding; -} +}; export interface BakedIndices extends IndexedGeometry { readonly indices: IndexBuffer; @@ -249,10 +227,15 @@ function prepareIndices( options.bindGroups, ); + const readIndices = indices.as('readonly'); return { mesh: { ...g, indices, + indexAt: (i: number) => { + 'use gpu'; + return readIndices.$[i] as number; + }, inject: () => (pipeline) => pipeline.withIndexBuffer(indices), destroy: () => { if (!options.indices) indices.destroy(); diff --git a/packages/typegpu-geometry/src/mesh/fill.ts b/packages/typegpu-geometry/src/mesh/fill.ts index 3d9727a4b1..fbf06aaf2a 100644 --- a/packages/typegpu-geometry/src/mesh/fill.ts +++ b/packages/typegpu-geometry/src/mesh/fill.ts @@ -4,13 +4,38 @@ import { type TgpuBindGroup, type TgpuCommandEncoder, type TgpuComputePass, + type TgpuRoot, type WithBinding, } from 'typegpu'; -export interface UpdateOptions { +interface EncoderOptions { + /** Records the work as one compute pass on this encoder. Nothing is submitted */ + encoder: GPUCommandEncoder | TgpuCommandEncoder; + pass?: never; +} + +interface PassOptions { + encoder?: never; + /** Records the work into this pass. Nothing is submitted and the pass is not ended */ + pass: GPUComputePassEncoder | TgpuComputePass; +} + +interface SubmitOptions { + encoder?: never; + pass?: never; +} + +/** Controls where the work is recorded. Defaults to a standalone submit */ +export type RecordOptions = EncoderOptions | PassOptions | SubmitOptions; + +export type UpdateOptions = RecordOptions & { + /** Bind groups for this call, applied over the ones given when baking */ bindGroups?: readonly TgpuBindGroup[]; - encoder?: TgpuCommandEncoder | GPUCommandEncoder; - pass?: TgpuComputePass | GPUComputePassEncoder; +}; + +export interface Fill { + initAsync(): Promise; + record(pass: TgpuComputePass, bindGroups?: readonly TgpuBindGroup[]): void; } export function createFill( @@ -18,7 +43,7 @@ export function createFill( count: number, write: (index: number) => void, bindGroups: readonly TgpuBindGroup[] = [], -) { +): Fill { const x = Math.min(Math.ceil(count / 64), 65535); const y = x === 0 ? 0 : Math.ceil(count / (x * 64)); @@ -43,24 +68,31 @@ export function createFill( initAsync: async () => { if (count > 0) await pipeline.initAsync(); }, - run: (options: UpdateOptions = {}) => { - if (options.encoder && options.pass) { - throw new Error('Supply either an encoder or a compute pass, not both'); - } + record: (pass, groups = []) => { if (count === 0) return; let target = pipeline; - for (const group of options.bindGroups ?? []) { + for (const group of groups) { target = target.with(group); } - - if (options.pass) { - target = target.with(options.pass as TgpuComputePass); - } else if (options.encoder) { - target = target.with(options.encoder as TgpuCommandEncoder); - } - - target.dispatchWorkgroups(x, y); + target.with(pass).dispatchWorkgroups(x, y); }, }; } + +export function recordFills(root: TgpuRoot, fills: readonly Fill[], options: UpdateOptions) { + const record = (pass: TgpuComputePass) => { + for (const fill of fills) fill.record(pass, options.bindGroups); + }; + + if (options.pass) { + record(options.pass as TgpuComputePass); + return; + } + + const encoder = options.encoder ?? root['~unstable'].createCommandEncoder(); + const pass = encoder.beginComputePass() as TgpuComputePass; + record(pass); + pass.end(); + if (!options.encoder) (encoder as TgpuCommandEncoder).submit(); +} diff --git a/packages/typegpu-geometry/tests/bake.test.ts b/packages/typegpu-geometry/tests/bake.test.ts index d03f60a9c5..052c7bd4ed 100644 --- a/packages/typegpu-geometry/tests/bake.test.ts +++ b/packages/typegpu-geometry/tests/bake.test.ts @@ -56,14 +56,14 @@ it('prepares vertex and index fills before submitting', ({ root, device }) => { } } - @group(0) @binding(0) var writeIndices: array; + @group(0) @binding(0) var output: array; fn indexAt(i: u32) -> u32 { return (i % 4u); } fn item(i: u32) { - writeIndices[i] = indexAt(i); + output[i] = indexAt(i); } @compute @workgroup_size(64) fn compute(@builtin(global_invocation_id) gid: vec3u) { @@ -152,3 +152,25 @@ it('exposes writable vertices and allows mapped reads in another shader', ({ roo }" `); }); + +it('records vertex and index fills into one pass of a given encoder', ({ + root, + device, + commandEncoder, + computePassEncoder, +}) => { + const mesh = meshes.bake(root, scalars, { encoder: commandEncoder }); + + expect(commandEncoder.mock.beginComputePass).toHaveBeenCalledOnce(); + expect(computePassEncoder.mock.dispatchWorkgroups).toHaveBeenCalledTimes(2); + expect(computePassEncoder.mock.end).toHaveBeenCalledOnce(); + + mesh.updateVertices({ pass: computePassEncoder }); + + expect(computePassEncoder.mock.dispatchWorkgroups).toHaveBeenCalledTimes(3); + expect(computePassEncoder.mock.end).toHaveBeenCalledOnce(); + expect(device.mock.queue.submit).not.toHaveBeenCalled(); + + // @ts-expect-error: work goes either into an encoder or into an open pass + mesh.updateVertices({ encoder: commandEncoder, pass: computePassEncoder }); +}); From 6bccb21246e8f6ef888bc5df8a8621dc819c1652 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:10:25 +0200 Subject: [PATCH 15/18] Bake meshes into shared buffers at an offset --- packages/typegpu-geometry/src/mesh/bake.ts | 40 ++++++++++++++----- packages/typegpu-geometry/tests/bake.test.ts | 42 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/bake.ts b/packages/typegpu-geometry/src/mesh/bake.ts index a8e41dcbdb..a62e02db40 100644 --- a/packages/typegpu-geometry/src/mesh/bake.ts +++ b/packages/typegpu-geometry/src/mesh/bake.ts @@ -27,6 +27,11 @@ export type IndexBuffer = TgpuBuffer> & IndexFlag & StorageFl export interface Baked extends Geometry { readonly vertices: VertexBuffer; + /** + * Index of the mesh's first vertex in `vertices`. Pass it as `baseVertex` to `drawIndexed` or as + * `firstVertex` to `draw`. `vertexAt` already adds it. + */ + readonly baseVertex: number; readonly layout: TgpuVertexLayout>; inject():

(pipeline: P) => P; updateVertices(this: void, options?: UpdateOptions): void; @@ -35,6 +40,8 @@ export interface Baked extends Geometry { export interface BakedIndexed extends Baked, IndexedGeometry { readonly indices: IndexBuffer; + /** Index of the mesh's first index in `indices`. Pass it as `firstIndex` when drawing */ + readonly firstIndex: number; inject():

(pipeline: P) => P & HasIndexBuffer; } @@ -43,6 +50,10 @@ export type BakeOptions = UpdateOptions & { vertices?: VertexBuffer; /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ indices?: IndexBuffer; + /** Writes the first vertex at this index of `vertices`. Indices stay relative to it. Default 0 */ + baseVertex?: number; + /** Writes the first index at this index of `indices`. Default 0 */ + firstIndex?: number; /** Creates the fill pipelines from this configured root, for slots and accessors */ with?: WithBinding; }; @@ -52,9 +63,11 @@ interface Prepared { fills: Fill[]; } -function fits(kind: string, capacity: number, needed: number) { - if (needed > capacity) { - throw new Error(`The ${kind} count ${needed} exceeds the buffer capacity ${capacity}`); +function fits(kind: string, capacity: number, first: number, count: number) { + if (first + count > capacity) { + throw new Error( + `The ${kind} range ${first}..${first + count} exceeds the buffer capacity ${capacity}`, + ); } } @@ -114,6 +127,7 @@ function prepareBake( options: BakeOptions, ): Prepared> { const { schema, topology, vertexCount } = g; + const { baseVertex = 0 } = options; const indexed = isIndexed(g) ? g : undefined; const vertices = @@ -121,7 +135,7 @@ function prepareBake( (root .createBuffer(d.arrayOf(schema, Math.max(1, vertexCount)) as d.WgslArray) .$usage('vertex', 'storage') as unknown as VertexBuffer); - fits('vertex', vertices.dataType.elementCount, vertexCount); + fits('vertex', vertices.dataType.elementCount, baseVertex, vertexCount); const output = vertices.as('mutable'); const fillVertices = createFill( @@ -129,7 +143,7 @@ function prepareBake( vertexCount, (i) => { 'use gpu'; - output.$[i] = g.vertexAt(i); + output.$[baseVertex === 0 ? i : baseVertex + i] = g.vertexAt(i); }, options.bindGroups, ); @@ -142,9 +156,10 @@ function prepareBake( vertexCount, vertexAt: (i: number) => { 'use gpu'; - return std.copy(readVertices.$[i]) as d.InferGPU; + return std.copy(readVertices.$[baseVertex === 0 ? i : baseVertex + i]) as d.InferGPU; }, vertices, + baseVertex, layout, updateVertices: (update: UpdateOptions = {}) => recordFills(root, [fillVertices], update), }; @@ -166,6 +181,7 @@ function prepareBake( const indexedMesh: BakedIndexed = { ...mesh, indices: indexOnly.indices, + firstIndex: indexOnly.firstIndex, indexCount: indexOnly.indexCount, indexAt: indexOnly.indexAt, inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indexOnly.indices), @@ -180,12 +196,16 @@ function prepareBake( export type BakeIndicesOptions = UpdateOptions & { /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ indices?: IndexBuffer; + /** Writes the first index at this index of `indices`. Default 0 */ + firstIndex?: number; /** Creates the fill pipeline from this configured root, for slots and accessors */ with?: WithBinding; }; export interface BakedIndices extends IndexedGeometry { readonly indices: IndexBuffer; + /** Index of the mesh's first index in `indices`. Pass it as `firstIndex` when drawing */ + readonly firstIndex: number; inject():

(pipeline: P) => P & HasIndexBuffer; destroy(): void; } @@ -211,10 +231,11 @@ function prepareIndices( g: IndexedGeometry, options: BakeIndicesOptions, ): Prepared> { + const { firstIndex = 0 } = options; const indices = options.indices ?? root.createBuffer(d.arrayOf(d.u32, Math.max(1, g.indexCount))).$usage('index', 'storage'); - fits('index', indices.dataType.elementCount, g.indexCount); + fits('index', indices.dataType.elementCount, firstIndex, g.indexCount); const output = indices.as('mutable'); const fill = createFill( @@ -222,7 +243,7 @@ function prepareIndices( g.indexCount, (i) => { 'use gpu'; - output.$[i] = g.indexAt(i); + output.$[firstIndex === 0 ? i : firstIndex + i] = g.indexAt(i); }, options.bindGroups, ); @@ -232,9 +253,10 @@ function prepareIndices( mesh: { ...g, indices, + firstIndex, indexAt: (i: number) => { 'use gpu'; - return readIndices.$[i] as number; + return readIndices.$[firstIndex === 0 ? i : firstIndex + i] as number; }, inject: () => (pipeline) => pipeline.withIndexBuffer(indices), destroy: () => { diff --git a/packages/typegpu-geometry/tests/bake.test.ts b/packages/typegpu-geometry/tests/bake.test.ts index 052c7bd4ed..bcb403cac4 100644 --- a/packages/typegpu-geometry/tests/bake.test.ts +++ b/packages/typegpu-geometry/tests/bake.test.ts @@ -174,3 +174,45 @@ it('records vertex and index fills into one pass of a given encoder', ({ // @ts-expect-error: work goes either into an encoder or into an open pass mesh.updateVertices({ encoder: commandEncoder, pass: computePassEncoder }); }); + +it('bakes several meshes into shared buffers at offsets', ({ root, device }) => { + const vertices = root.createBuffer(d.arrayOf(d.f32, 8)).$usage('vertex', 'storage'); + const indices = root.createBuffer(d.arrayOf(d.u32, 12)).$usage('index', 'storage'); + const first = meshes.bake(root, scalars, { vertices, indices }); + const second = meshes.bake(root, scalars, { vertices, indices, baseVertex: 4, firstIndex: 6 }); + + expect([first.baseVertex, first.firstIndex]).toEqual([0, 0]); + expect([second.baseVertex, second.firstIndex]).toEqual([4, 6]); + expect(() => meshes.bake(root, scalars, { vertices, indices, baseVertex: 5 })).toThrow( + 'The vertex range 5..9 exceeds the buffer capacity 8', + ); + + const fills = shaderCodes(device).slice(2).join('\n\n'); + expect(fills).toContain('vertices[(4u + i)] = vertexAt(i);'); + expect(fills).toContain('indices[(6u + i)] = indexAt(i);'); + + const read = tgpu.fn( + [d.u32], + d.f32, + )((i) => { + 'use gpu'; + return second.vertexAt(second.indexAt(i)); + }); + expect(tgpu.resolve([read])).toMatchInlineSnapshot(` + "@group(0) @binding(0) var indices: array; + + fn indexAt(i: u32) -> u32 { + return indices[(6u + i)]; + } + + @group(0) @binding(1) var vertices: array; + + fn vertexAt(i: u32) -> f32 { + return vertices[(4u + i)]; + } + + fn read_1(i: u32) -> f32 { + return vertexAt(indexAt(i)); + }" + `); +}); From 0b0b53a1675022ace23957db194b12aee2b11a04 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:10:45 +0200 Subject: [PATCH 16/18] Name sphere segments like cylinder segments --- .../typegpu-geometry/src/mesh/primitives.ts | 20 +++++++++++-------- packages/typegpu-geometry/tests/mesh.test.ts | 4 ++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/primitives.ts b/packages/typegpu-geometry/src/mesh/primitives.ts index 3798a22250..1c93f96caa 100644 --- a/packages/typegpu-geometry/src/mesh/primitives.ts +++ b/packages/typegpu-geometry/src/mesh/primitives.ts @@ -54,14 +54,16 @@ export const surfaces = { export interface SphereOptions { radius?: number; - segments?: number; - rings?: number; + /** Segments around the vertical axis, as in `cylinder` */ + radialSegments?: number; + /** Segments from pole to pole, at least 2 */ + heightSegments?: number; } export function sphere({ radius = 0.5, - segments = 32, - rings = 16, + radialSegments = 32, + heightSegments = 16, }: SphereOptions = {}): IndexedGeometry { const grid = sampleGrid( (u, v) => { @@ -69,19 +71,21 @@ export function sphere({ const normal = surfaces.sphere(u, v); return Surface({ position: normal * radius, normal, uv: d.vec2f(u, v) }); }, - { cols: segments, rows: rings }, + { cols: radialSegments, rows: heightSegments }, ); - const fan = segments * 3; + const fan = radialSegments * 3; return { ...grid, - indexCount: 6 * segments * (rings - 1), + indexCount: 6 * radialSegments * (heightSegments - 1), indexAt: (i: number) => { 'use gpu'; if (i < fan) return grid.indexAt(std.intdiv(i, 3) * 6 + (i % 3) + 3); if (i >= grid.indexCount - 3 * fan) { const last = i - (grid.indexCount - 3 * fan); - return grid.indexAt((rings - 1) * segments * 6 + std.intdiv(last, 3) * 6 + (last % 3)); + return grid.indexAt( + (heightSegments - 1) * radialSegments * 6 + std.intdiv(last, 3) * 6 + (last % 3), + ); } return grid.indexAt(i + fan); }, diff --git a/packages/typegpu-geometry/tests/mesh.test.ts b/packages/typegpu-geometry/tests/mesh.test.ts index 7737cc5df9..95fdf57b05 100644 --- a/packages/typegpu-geometry/tests/mesh.test.ts +++ b/packages/typegpu-geometry/tests/mesh.test.ts @@ -23,7 +23,7 @@ function windingAgreesWithNormals(g: meshes.IndexedGeometry) { describe('meshes on the CPU', () => { it.each([ ['plane', meshes.plane()], - ['sphere', meshes.sphere({ segments: 8, rings: 4 })], + ['sphere', meshes.sphere({ radialSegments: 8, heightSegments: 4 })], ['box', meshes.box()], ['cylinder', meshes.cylinder({ radialSegments: 6 })], ['torus', meshes.torus({ ringSegments: 6, tubeSegments: 4 })], @@ -105,7 +105,7 @@ describe('meshes on the CPU', () => { return Paint({ color: d.vec3f(s.uv, 0), id }); }); const a = paint(meshes.plane(), 1); - const b = paint(meshes.sphere({ segments: 4, rings: 2 }), 2); + const b = paint(meshes.sphere({ radialSegments: 4, heightSegments: 2 }), 2); const v = a.vertexAt(3); const scene = meshes.concat(a, b); From 95e1ad5b475d387e4be815d777fd62084225691d Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 22 Oct 2026 14:40:28 +0200 Subject: [PATCH 17/18] wider better --- .../examples/rendering/mesh-primitives/gallery.ts | 12 ++++++------ .../examples/rendering/mesh-primitives/geometry.ts | 14 +++++++++----- .../examples/rendering/mesh-primitives/index.ts | 2 +- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts index a13f394367..6d588ec97b 100644 --- a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/gallery.ts @@ -1,4 +1,4 @@ -import { d, std } from 'typegpu'; +import { std } from 'typegpu'; import { meshes } from '@typegpu/geometry'; import { place } from './geometry.ts'; @@ -6,9 +6,9 @@ const floor = meshes.plane({ width: 6, depth: 6 }); const twoSidedFloor = meshes.concat(floor, meshes.transform(floor, std.rotationX4(Math.PI))); export const gallery = meshes.concat( - place(twoSidedFloor, d.vec3f(0, -0.51, 0), d.vec3f(0.75, 0.75, 0.8)), - place(meshes.box(), d.vec3f(-1.5, 0, 1), d.vec3f(0.9, 0.35, 0.3)), - place(meshes.cylinder(), d.vec3f(1.5, 0, 1), d.vec3f(0.3, 0.6, 0.9)), - place(meshes.torus(), d.vec3f(1.5, -0.35, -1), d.vec3f(0.95, 0.75, 0.2)), - place(meshes.sphere(), d.vec3f(-1.5, 0, -1), d.vec3f(0.4, 0.85, 0.5)), + place(twoSidedFloor, [0, -0.51, 0], [0.75, 0.75, 0.8]), + place(meshes.box(), [-1.5, 0, 1], [0.9, 0.35, 0.3]), + place(meshes.cylinder(), [1.5, 0, 1], [0.3, 0.6, 0.9]), + place(meshes.torus(), [1.5, -0.35, -1], [0.95, 0.75, 0.2]), + place(meshes.sphere(), [-1.5, 0, -1], [0.4, 0.85, 0.5]), ); diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts index 4d3bfa2518..1b9485ac70 100644 --- a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/geometry.ts @@ -3,17 +3,21 @@ import { meshes } from '@typegpu/geometry'; const Paint = d.struct({ color: d.vec3f }); -export function paint(g: meshes.IndexedGeometry, color: d.v3f) { +export function paint( + g: meshes.IndexedGeometry, + color: d.InferInput, +) { + const rgb = d.vec3f(color[0], color[1], color[2]); return meshes.attach(g, Paint, () => { 'use gpu'; - return Paint({ color }); + return Paint({ color: rgb }); }); } export function place( g: meshes.IndexedGeometry, - at: d.v3f, - color: d.v3f, + at: d.InferInput, + color: d.InferInput, ) { - return paint(meshes.transform(g, std.translation4(at)), color); + return paint(meshes.transform(g, std.translation4(d.vec3f(at[0], at[1], at[2]))), color); } diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts index 1a1c5cca6c..a8dd4f1c4b 100644 --- a/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/mesh-primitives/index.ts @@ -45,7 +45,7 @@ let depth = createDepth(); const scene = meshes.bakeIndices( root, - meshes.concat(gallery, blob, place(spring, d.vec3f(0, 0.07, 2.2), d.vec3f(0.7, 0.45, 0.95))), + meshes.concat(gallery, blob, place(spring, [0, 0.07, 2.2], [0.7, 0.45, 0.95])), ); const shadow = root From 4330f1726ccc2ee4782081d9f020ce0f93925629 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 27 Oct 2026 17:46:09 +0100 Subject: [PATCH 18/18] address review --- packages/typegpu-geometry/src/mesh/bake.ts | 50 ++++++---- .../typegpu-geometry/src/mesh/combinators.ts | 17 ++-- packages/typegpu-geometry/src/mesh/fill.ts | 16 +++- .../typegpu-geometry/src/mesh/geometry.ts | 15 ++- packages/typegpu-geometry/src/mesh/index.ts | 2 +- .../typegpu-geometry/src/mesh/primitives.ts | 33 ++++--- packages/typegpu-geometry/tests/bake.test.ts | 95 +++++++++++++++++++ packages/typegpu-geometry/tests/mesh.test.ts | 15 +++ 8 files changed, 194 insertions(+), 49 deletions(-) diff --git a/packages/typegpu-geometry/src/mesh/bake.ts b/packages/typegpu-geometry/src/mesh/bake.ts index a62e02db40..51a78b6766 100644 --- a/packages/typegpu-geometry/src/mesh/bake.ts +++ b/packages/typegpu-geometry/src/mesh/bake.ts @@ -11,7 +11,13 @@ import { type WithBinding, } from 'typegpu'; import { createFill, type Fill, recordFills, type UpdateOptions } from './fill.ts'; -import { type Geometry, type IndexedGeometry, isIndexed, layoutOf } from './geometry.ts'; +import { + type ConstructibleSchema, + type Geometry, + type IndexedGeometry, + isIndexed, + layoutOf, +} from './geometry.ts'; export type { RecordOptions, UpdateOptions } from './fill.ts'; @@ -20,12 +26,12 @@ type HasIndexBuffer = Pick< 'hasIndexBuffer' | 'drawIndexed' >; -export type VertexBuffer = TgpuBuffer> & +export type VertexBuffer = TgpuBuffer> & VertexFlag & StorageFlag; export type IndexBuffer = TgpuBuffer> & IndexFlag & StorageFlag; -export interface Baked extends Geometry { +export interface Baked extends Geometry { readonly vertices: VertexBuffer; /** * Index of the mesh's first vertex in `vertices`. Pass it as `baseVertex` to `drawIndexed` or as @@ -38,14 +44,14 @@ export interface Baked extends Geometry { destroy(): void; } -export interface BakedIndexed extends Baked, IndexedGeometry { +export interface BakedIndexed extends Baked, IndexedGeometry { readonly indices: IndexBuffer; /** Index of the mesh's first index in `indices`. Pass it as `firstIndex` when drawing */ readonly firstIndex: number; inject():

(pipeline: P) => P & HasIndexBuffer; } -export type BakeOptions = UpdateOptions & { +export type BakeOptions = UpdateOptions & { /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ vertices?: VertexBuffer; /** Writes into this buffer instead of creating one. `destroy()` leaves it alive */ @@ -85,17 +91,17 @@ async function finishAsync( return finish(root, prepared, options); } -export function bake( +export function bake( root: TgpuRoot, g: IndexedGeometry, options?: BakeOptions, ): BakedIndexed; -export function bake( +export function bake( root: TgpuRoot, g: Geometry, options?: BakeOptions, ): Baked; -export function bake( +export function bake( root: TgpuRoot, g: Geometry, options: BakeOptions = {}, @@ -103,17 +109,17 @@ export function bake( return finish(root, prepareBake(root, g, options), options); } -export function bakeAsync( +export function bakeAsync( root: TgpuRoot, g: IndexedGeometry, options?: BakeOptions, ): Promise>; -export function bakeAsync( +export function bakeAsync( root: TgpuRoot, g: Geometry, options?: BakeOptions, ): Promise>; -export async function bakeAsync( +export async function bakeAsync( root: TgpuRoot, g: Geometry, options: BakeOptions = {}, @@ -121,7 +127,7 @@ export async function bakeAsync( return finishAsync(root, prepareBake(root, g, options), options); } -function prepareBake( +function prepareBake( root: TgpuRoot, g: Geometry, options: BakeOptions, @@ -170,7 +176,9 @@ function prepareBake( ...mesh, inject: () => (pipeline) => pipeline.with(layout, vertices), destroy: () => { - if (!options.vertices) vertices.destroy(); + if (!options.vertices) { + vertices.destroy(); + } }, }, fills: [fillVertices], @@ -186,7 +194,9 @@ function prepareBake( indexAt: indexOnly.indexAt, inject: () => (pipeline) => pipeline.with(layout, vertices).withIndexBuffer(indexOnly.indices), destroy: () => { - if (!options.vertices) vertices.destroy(); + if (!options.vertices) { + vertices.destroy(); + } indexOnly.destroy(); }, }; @@ -202,7 +212,7 @@ export type BakeIndicesOptions = UpdateOptions & { with?: WithBinding; }; -export interface BakedIndices extends IndexedGeometry { +export interface BakedIndices extends IndexedGeometry { readonly indices: IndexBuffer; /** Index of the mesh's first index in `indices`. Pass it as `firstIndex` when drawing */ readonly firstIndex: number; @@ -210,7 +220,7 @@ export interface BakedIndices extends IndexedGeometry( +export function bakeIndices( root: TgpuRoot, g: IndexedGeometry, options: BakeIndicesOptions = {}, @@ -218,7 +228,7 @@ export function bakeIndices( return finish(root, prepareIndices(root, g, options), options); } -export async function bakeIndicesAsync( +export async function bakeIndicesAsync( root: TgpuRoot, g: IndexedGeometry, options: BakeIndicesOptions = {}, @@ -226,7 +236,7 @@ export async function bakeIndicesAsync( return finishAsync(root, prepareIndices(root, g, options), options); } -function prepareIndices( +function prepareIndices( root: TgpuRoot, g: IndexedGeometry, options: BakeIndicesOptions, @@ -260,7 +270,9 @@ function prepareIndices( }, inject: () => (pipeline) => pipeline.withIndexBuffer(indices), destroy: () => { - if (!options.indices) indices.destroy(); + if (!options.indices) { + indices.destroy(); + } }, }, fills: [fill], diff --git a/packages/typegpu-geometry/src/mesh/combinators.ts b/packages/typegpu-geometry/src/mesh/combinators.ts index 7835349958..7a916ee8c8 100644 --- a/packages/typegpu-geometry/src/mesh/combinators.ts +++ b/packages/typegpu-geometry/src/mesh/combinators.ts @@ -1,5 +1,10 @@ import { d, std, tgpu } from 'typegpu'; -import { type Geometry, type IndexedGeometry, isIndexed } from './geometry.ts'; +import { + type ConstructibleSchema, + type Geometry, + type IndexedGeometry, + isIndexed, +} from './geometry.ts'; function reversedCorner(i: number) { 'use gpu'; @@ -69,17 +74,17 @@ export function transform(g: Geometry, m: d.m4x4f): Geometr return result; } -export function map( +export function map( g: IndexedGeometry, schema: V, fn: (vertex: d.InferGPU) => d.InferGPU, ): IndexedGeometry; -export function map( +export function map( g: Geometry, schema: V, fn: (vertex: d.InferGPU) => d.InferGPU, ): Geometry; -export function map( +export function map( g: Geometry, schema: V, fn: (vertex: d.InferGPU) => d.InferGPU, @@ -169,7 +174,7 @@ export function attach( }; } -export function unindexed(g: Geometry): Geometry { +export function unindexed(g: Geometry): Geometry { if (!isIndexed(g)) { return g; } @@ -185,8 +190,6 @@ export function unindexed(g: Geometry): Geometry }; } -type ConstructibleSchema = Extract unknown>; - export function concat(...parts: Geometry[]): IndexedGeometry { const first = parts[0]; if (!first) { diff --git a/packages/typegpu-geometry/src/mesh/fill.ts b/packages/typegpu-geometry/src/mesh/fill.ts index fbf06aaf2a..0580e2e948 100644 --- a/packages/typegpu-geometry/src/mesh/fill.ts +++ b/packages/typegpu-geometry/src/mesh/fill.ts @@ -66,10 +66,14 @@ export function createFill( return { initAsync: async () => { - if (count > 0) await pipeline.initAsync(); + if (count > 0) { + await pipeline.initAsync(); + } }, record: (pass, groups = []) => { - if (count === 0) return; + if (count === 0) { + return; + } let target = pipeline; for (const group of groups) { @@ -82,7 +86,9 @@ export function createFill( export function recordFills(root: TgpuRoot, fills: readonly Fill[], options: UpdateOptions) { const record = (pass: TgpuComputePass) => { - for (const fill of fills) fill.record(pass, options.bindGroups); + for (const fill of fills) { + fill.record(pass, options.bindGroups); + } }; if (options.pass) { @@ -94,5 +100,7 @@ export function recordFills(root: TgpuRoot, fills: readonly Fill[], options: Upd const pass = encoder.beginComputePass() as TgpuComputePass; record(pass); pass.end(); - if (!options.encoder) (encoder as TgpuCommandEncoder).submit(); + if (!options.encoder) { + (encoder as TgpuCommandEncoder).submit(); + } } diff --git a/packages/typegpu-geometry/src/mesh/geometry.ts b/packages/typegpu-geometry/src/mesh/geometry.ts index 8da307179b..52c9ffb458 100644 --- a/packages/typegpu-geometry/src/mesh/geometry.ts +++ b/packages/typegpu-geometry/src/mesh/geometry.ts @@ -3,9 +3,12 @@ import { d, tgpu, type TgpuVertexLayout } from 'typegpu'; /** Default vertex schema for primitives */ export const Surface = d.struct({ position: d.vec3f, normal: d.vec3f, uv: d.vec2f }); +/** Vertex schemas a 'use gpu' function can return */ +export type ConstructibleSchema = Extract unknown>; + export type Topology = 'triangle-list' | 'line-list' | 'point-list'; -export interface Geometry { +export interface Geometry { readonly schema: V; readonly topology: Topology; readonly vertexCount: number; @@ -13,7 +16,9 @@ export interface Geometry { vertexAt(this: void, i: number): d.InferGPU; } -export interface IndexedGeometry extends Geometry { +export interface IndexedGeometry< + V extends ConstructibleSchema = typeof Surface, +> extends Geometry { readonly indexCount: number; indexAt(this: void, i: number): number; @@ -21,11 +26,13 @@ export interface IndexedGeometry exten const layouts = new WeakMap(); -export function isIndexed(g: Geometry): g is IndexedGeometry { +export function isIndexed(g: Geometry): g is IndexedGeometry { return typeof (g as IndexedGeometry).indexAt === 'function'; } -export function layoutOf(schema: V): TgpuVertexLayout> { +export function layoutOf( + schema: V, +): TgpuVertexLayout> { let layout = layouts.get(schema); if (!layout) { layout = tgpu.vertexLayout(d.arrayOf(schema)); diff --git a/packages/typegpu-geometry/src/mesh/index.ts b/packages/typegpu-geometry/src/mesh/index.ts index fe4db633e9..a43e3bb078 100644 --- a/packages/typegpu-geometry/src/mesh/index.ts +++ b/packages/typegpu-geometry/src/mesh/index.ts @@ -2,7 +2,7 @@ export * from './bake.ts'; export { attach, concat, map, transform, unindexed } from './combinators.ts'; export type { Attached, Placed } from './combinators.ts'; export { isIndexed, Surface } from './geometry.ts'; -export type { Geometry, IndexedGeometry, Topology } from './geometry.ts'; +export type { ConstructibleSchema, Geometry, IndexedGeometry, Topology } from './geometry.ts'; export { parametric } from './parametric.ts'; export type { Parametric, ParametricOptions } from './parametric.ts'; export * from './primitives.ts'; diff --git a/packages/typegpu-geometry/src/mesh/primitives.ts b/packages/typegpu-geometry/src/mesh/primitives.ts index 1c93f96caa..05a70554a8 100644 --- a/packages/typegpu-geometry/src/mesh/primitives.ts +++ b/packages/typegpu-geometry/src/mesh/primitives.ts @@ -75,12 +75,16 @@ export function sphere({ ); const fan = radialSegments * 3; + // The grid's indices are valid, but in the pole rows one triangle of every quad collapses to a + // point. Skip those so the mesh has no zero-area triangles return { ...grid, indexCount: 6 * radialSegments * (heightSegments - 1), indexAt: (i: number) => { 'use gpu'; - if (i < fan) return grid.indexAt(std.intdiv(i, 3) * 6 + (i % 3) + 3); + if (i < fan) { + return grid.indexAt(std.intdiv(i, 3) * 6 + (i % 3) + 3); + } if (i >= grid.indexCount - 3 * fan) { const last = i - (grid.indexCount - 3 * fan); return grid.indexAt( @@ -178,11 +182,10 @@ export function cylinder({ const side = sampleGrid( (u, v) => { 'use gpu'; - const phi = u * TAU; - const normal = d.vec3f(std.cos(phi), 0, std.sin(phi)); + const p = surfaces.cylinder(u, v); return Surface({ - position: normal * radius + d.vec3f(0, (0.5 - v) * height, 0), - normal, + position: p * d.vec3f(radius, height, radius), + normal: d.vec3f(p.x, 0, p.z), uv: d.vec2f(u, v), }); }, @@ -201,7 +204,9 @@ export function cylinder({ vertexAt: (i: number) => { 'use gpu'; let direction = d.vec3f(); - if (i > 0) direction = surfaces.disc((i - 1) / radialSegments, 1); + if (i > 0) { + direction = surfaces.disc((i - 1) / radialSegments, 1); + } return Surface({ position: direction * radius, normal: d.vec3f(0, 1, 0), @@ -211,8 +216,12 @@ export function cylinder({ indexAt: (i: number) => { 'use gpu'; const triangle = std.intdiv(i, 3); - if (i % 3 === 0) return 0; - if (i % 3 === 1) return ((triangle + 1) % radialSegments) + 1; + if (i % 3 === 0) { + return 0; + } + if (i % 3 === 1) { + return ((triangle + 1) % radialSegments) + 1; + } return triangle + 1; }, }; @@ -240,13 +249,9 @@ export function torus({ return sampleGrid( (u, v) => { 'use gpu'; - const phi = u * TAU; - const t = v * TAU; - const radial = d.vec3f(std.cos(phi), 0, std.sin(phi)); - const normal = radial * std.cos(t) - d.vec3f(0, std.sin(t), 0); return Surface({ - position: radial * radius + normal * tube, - normal, + position: surfaces.torus(u, v, radius, tube), + normal: surfaces.torus(u, v, 0, 1), uv: d.vec2f(u, v), }); }, diff --git a/packages/typegpu-geometry/tests/bake.test.ts b/packages/typegpu-geometry/tests/bake.test.ts index bcb403cac4..1b7dc637cd 100644 --- a/packages/typegpu-geometry/tests/bake.test.ts +++ b/packages/typegpu-geometry/tests/bake.test.ts @@ -216,3 +216,98 @@ it('bakes several meshes into shared buffers at offsets', ({ root, device }) => }" `); }); + +it('bakes only the indices and keeps reading vertices from the source', ({ root, device }) => { + const baked = meshes.bakeIndices(root, scalars); + const read = tgpu.fn( + [d.u32], + d.f32, + )((i) => { + 'use gpu'; + return baked.vertexAt(baked.indexAt(i)); + }); + + expect(shaderCodes(device).join('\n\n')).toMatchInlineSnapshot(` + "@group(0) @binding(0) var output: array; + + fn indexAt(i: u32) -> u32 { + return (i % 4u); + } + + fn item(i: u32) { + output[i] = indexAt(i); + } + + @compute @workgroup_size(64) fn compute(@builtin(global_invocation_id) gid: vec3u) { + let i = (gid.x + (gid.y * 64u)); + if ((i < 6u)) { + item(i); + } + }" + `); + expect(tgpu.resolve([read])).toMatchInlineSnapshot(` + "@group(0) @binding(0) var output: array; + + fn indexAt(i: u32) -> u32 { + return output[i]; + } + + fn vertexAt(i: u32) -> f32 { + return f32(i); + } + + fn read_1(i: u32) -> f32 { + return vertexAt(indexAt(i)); + }" + `); +}); + +it('injects only the index buffer', ({ root, renderPassEncoder }) => { + const baked = meshes.bakeIndices(root, scalars); + const pipeline = root + .createRenderPipeline({ + vertex: () => { + 'use gpu'; + return { $position: d.vec4f() }; + }, + }) + .pipe(baked.inject()); + + pipeline.with(renderPassEncoder).drawIndexed(baked.indexCount); + + expect(renderPassEncoder.mock.setVertexBuffer).not.toHaveBeenCalled(); + expect(renderPassEncoder.mock.setIndexBuffer).toHaveBeenCalledWith( + root.unwrap(baked.indices), + 'uint32', + undefined, + undefined, + ); +}); + +it('bakes indices into a caller-owned buffer at an offset', ({ root, device }) => { + const indices = root.createBuffer(d.arrayOf(d.u32, 12)).$usage('index', 'storage'); + const baked = meshes.bakeIndices(root, scalars, { indices, firstIndex: 6 }); + baked.destroy(); + + expect([baked.firstIndex, baked.indexCount]).toEqual([6, 6]); + expect(baked.indices).toBe(indices); + expect(indices.destroyed).toBe(false); + expect(shaderCodes(device).join('\n\n')).toContain('indices[(6u + i)] = indexAt(i);'); + expect(() => meshes.bakeIndices(root, scalars, { indices, firstIndex: 7 })).toThrow( + 'The index range 7..13 exceeds the buffer capacity 12', + ); +}); + +it('records the index fill into a given encoder', ({ + root, + device, + commandEncoder, + computePassEncoder, +}) => { + meshes.bakeIndices(root, scalars, { encoder: commandEncoder }); + + expect(commandEncoder.mock.beginComputePass).toHaveBeenCalledOnce(); + expect(computePassEncoder.mock.dispatchWorkgroups).toHaveBeenCalledOnce(); + expect(computePassEncoder.mock.end).toHaveBeenCalledOnce(); + expect(device.mock.queue.submit).not.toHaveBeenCalled(); +}); diff --git a/packages/typegpu-geometry/tests/mesh.test.ts b/packages/typegpu-geometry/tests/mesh.test.ts index 95fdf57b05..8ad3c6b5d2 100644 --- a/packages/typegpu-geometry/tests/mesh.test.ts +++ b/packages/typegpu-geometry/tests/mesh.test.ts @@ -4,6 +4,9 @@ import { it } from 'typegpu-testing-utility'; import { meshes } from '@typegpu/geometry'; function windingAgreesWithNormals(g: meshes.IndexedGeometry) { + if (g.topology !== 'triangle-list') { + throw new Error('Invalid topology.'); + } for (let t = 0; t < g.indexCount; t += 3) { const a = g.vertexAt(g.indexAt(t)); const b = g.vertexAt(g.indexAt(t + 1)); @@ -117,6 +120,18 @@ describe('meshes on the CPU', () => { expect(scene.vertexAt(4).id).toBe(2); }); + it('throws when attaching a field that is already present', () => { + const Paint = d.struct({ position: d.vec3f }); + expect(() => + meshes.attach(meshes.plane(), Paint, (s) => { + 'use gpu'; + return Paint({ position: d.vec3f(s.uv, 0) }); + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: attach cannot replace the existing field 'position'; use map instead]`, + ); + }); + it('preserves outward winding under reflections', () => { for (const source of [meshes.plane(), meshes.unindexed(meshes.plane())]) { const flat = meshes.unindexed(meshes.transform(source, std.scaling4(d.vec3f(-2, 3, 0.5))));