diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-patches/index.html b/apps/typegpu-docs/src/examples/rendering/mesh-patches/index.html
new file mode 100644
index 0000000000..581d6789f8
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/mesh-patches/index.html
@@ -0,0 +1 @@
+
diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-patches/index.ts b/apps/typegpu-docs/src/examples/rendering/mesh-patches/index.ts
new file mode 100644
index 0000000000..c3b4281f3c
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/mesh-patches/index.ts
@@ -0,0 +1,142 @@
+import { meshes } from '@typegpu/geometry';
+import { d, std, tgpu } from 'typegpu';
+import { setupOrbitCamera } from '../../common/setup-orbit-camera.ts';
+import { defineControls } from '../../common/defineControls.ts';
+
+const root = await tgpu.init();
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
+
+const maxSegments = 32;
+let segments = 4;
+const Camera = d.struct({ viewProjection: d.mat4x4f });
+const Scene = d.struct({ camera: Camera, segments: d.u32, wireframe: d.u32 });
+const scene = root.createUniform(Scene);
+const indices = root
+ .createBuffer(
+ d.arrayOf(d.u32, meshes.triangleIndexCount(maxSegments)),
+ meshes.triangleIndices(maxSegments),
+ )
+ .$usage('index');
+
+const shapes = [
+ meshes.patches.icosphere(),
+ meshes.patches.capsule({ radius: 0.35, height: 0.6 }),
+ meshes.patches.roundedBox({ radius: 0.2 }),
+];
+const colors = [d.vec3f(0.2, 0.65, 0.8), d.vec3f(0.9, 0.55, 0.2), d.vec3f(0.6, 0.4, 0.8)];
+
+const fragment = tgpu.fragmentFn({
+ in: { normal: d.vec3f, color: d.vec3f, grid: d.vec3f },
+ out: d.vec4f,
+})(({ normal, color, grid }) => {
+ 'use gpu';
+ const light = std.normalize(d.vec3f(-0.4, 0.8, 1));
+ let shaded = color * (0.25 + 0.75 * std.saturate(std.dot(std.normalize(normal), light)));
+
+ if (scene.$.wireframe !== 0) {
+ const f = std.fract(grid);
+ const distance = std.min(f, 1 - f) / std.max(std.fwidth(grid), d.vec3f(1e-4));
+ const edge = std.min(distance.x, std.min(distance.y, distance.z));
+ shaded = std.mix(shaded * 0.2, shaded, std.smoothstep(0, 1, edge));
+ }
+ return d.vec4f(shaded, 1);
+});
+
+const pipelines = shapes.map((shape, i) => {
+ const offset = d.vec3f((i - 1) * 1.5, 0, 0);
+ const color = colors[i];
+ return root
+ .createRenderPipeline({
+ vertex: ({ $vertexIndex, $instanceIndex }) => {
+ 'use gpu';
+ const weights = meshes.triangleBarycentrics($vertexIndex, scene.$.segments);
+ const vertex = shape.at($instanceIndex, weights);
+ return {
+ $position: scene.$.camera.viewProjection * d.vec4f(vertex.position + offset, 1),
+ normal: vertex.normal,
+ color,
+ grid: weights * d.f32(scene.$.segments),
+ };
+ },
+ fragment,
+ primitive: { cullMode: 'back' },
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
+ })
+ .withIndexBuffer(indices);
+});
+
+await Promise.all(pipelines.map((pipeline) => pipeline.initAsync()));
+
+function createDepth() {
+ return root
+ .createTexture({ size: [canvas.width, canvas.height], format: 'depth24plus' })
+ .$usage('transient');
+}
+
+let depth = createDepth();
+let frame: number | undefined;
+
+function render() {
+ frame = undefined;
+ const encoder = root['~unstable'].createCommandEncoder();
+ const pass = encoder.beginRenderPass({
+ colorAttachments: [{ view: context, clearValue: [0.04, 0.05, 0.07, 1] }],
+ depthStencilAttachment: { view: depth, depthStoreOp: 'discard' },
+ });
+ pipelines.forEach((pipeline, i) => {
+ pipeline.with(pass).drawIndexed(meshes.triangleIndexCount(segments), shapes[i].patchCount);
+ });
+ pass.end();
+ encoder.submit();
+}
+
+function requestRender() {
+ frame ??= requestAnimationFrame(render);
+}
+
+const { cleanupCamera } = setupOrbitCamera(
+ canvas,
+ { initPos: d.vec4f(2.5, 1.8, 5, 1), target: d.vec4f(0, 0, 0, 1), minZoom: 2, maxZoom: 10 },
+ (camera) => {
+ scene.patch({ camera });
+ requestRender();
+ },
+);
+
+requestRender();
+
+const resizeObserver = new ResizeObserver(() => {
+ depth.destroy();
+ depth = createDepth();
+ requestRender();
+});
+resizeObserver.observe(canvas);
+
+export const controls = defineControls({
+ Detail: {
+ initial: segments,
+ min: 1,
+ max: maxSegments,
+ step: 1,
+ onSliderChange: (value) => {
+ segments = value;
+ scene.patch({ segments });
+ requestRender();
+ },
+ },
+ Wireframe: {
+ initial: true,
+ onToggleChange: (value) => {
+ scene.patch({ wireframe: value ? 1 : 0 });
+ requestRender();
+ },
+ },
+});
+
+export function onCleanup() {
+ if (frame !== undefined) cancelAnimationFrame(frame);
+ resizeObserver.disconnect();
+ cleanupCamera();
+ root.destroy();
+}
diff --git a/apps/typegpu-docs/src/examples/rendering/mesh-patches/meta.json b/apps/typegpu-docs/src/examples/rendering/mesh-patches/meta.json
new file mode 100644
index 0000000000..9e4b4cfccd
--- /dev/null
+++ b/apps/typegpu-docs/src/examples/rendering/mesh-patches/meta.json
@@ -0,0 +1,7 @@
+{
+ "title": "Mesh Patches",
+ "description": "Samples icosphere, capsule and rounded box patches directly in the vertex shader from one shared index buffer, so the subdivision level changes without rebuilding any buffers.",
+ "category": "rendering",
+ "tags": ["3d", "geometry", "rasterization"],
+ "coolFactor": 5
+}
diff --git a/apps/typegpu-docs/tests/individual-example-tests/mesh-patches.test.ts b/apps/typegpu-docs/tests/individual-example-tests/mesh-patches.test.ts
new file mode 100644
index 0000000000..4cb1f4cc55
--- /dev/null
+++ b/apps/typegpu-docs/tests/individual-example-tests/mesh-patches.test.ts
@@ -0,0 +1,38 @@
+/**
+ * @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 patches example', () => {
+ setupCommonMocks();
+
+ it('resolves one instanced pipeline per patch surface', async ({ device }) => {
+ const shaderCodes = await runExampleTest(
+ {
+ category: 'rendering',
+ name: 'mesh-patches',
+ setupMocks: mockResizeObserver,
+ expectedCalls: 3,
+ },
+ device,
+ );
+
+ const signatures = shaderCodes.match(/^fn .*(?= \{$)/gm);
+ expect([...new Set(signatures)].join('\n')).toMatchInlineSnapshot(`
+ "fn triangleBarycentrics(vertex_1: u32, segments: u32) -> vec3f
+ fn warp(cosAngle: f32, t: f32) -> f32
+ fn uniformArea(a: vec3f, b: vec3f, c: vec3f, weights: vec3f) -> vec3f
+ fn at(patch_1: u32, w: vec3f) -> Surface
+ fn bitSigns(bits: u32) -> vec3f
+ fn cornerNormal(octant: u32, weights: vec3f) -> vec3f
+ fn quadCoordinates(triangle: u32, weights: vec3f) -> vec2f
+ fn arc(a: vec3f, b: vec3f, t: f32) -> vec3f
+ fn edgeSurface(triangle: u32, weights: vec3f, center: vec3f, axis: vec3f, u: vec3f, v: vec3f, halfHeight: f32, radius: f32) -> Surface
+ fn at(index: u32, w: vec3f) -> Surface"
+ `);
+ });
+});
diff --git a/packages/typegpu-geometry/src/mesh/index.ts b/packages/typegpu-geometry/src/mesh/index.ts
index a43e3bb078..853469a65c 100644
--- a/packages/typegpu-geometry/src/mesh/index.ts
+++ b/packages/typegpu-geometry/src/mesh/index.ts
@@ -6,3 +6,6 @@ export type { ConstructibleSchema, Geometry, IndexedGeometry, Topology } from '.
export { parametric } from './parametric.ts';
export type { Parametric, ParametricOptions } from './parametric.ts';
export * from './primitives.ts';
+export * from './patchPrimitives.ts';
+export * from './triangles.ts';
+export * as patches from './patches/index.ts';
diff --git a/packages/typegpu-geometry/src/mesh/patchPrimitives.ts b/packages/typegpu-geometry/src/mesh/patchPrimitives.ts
new file mode 100644
index 0000000000..4a0a643dc4
--- /dev/null
+++ b/packages/typegpu-geometry/src/mesh/patchPrimitives.ts
@@ -0,0 +1,29 @@
+import type { IndexedGeometry } from './geometry.ts';
+import * as patches from './patches/index.ts';
+import { tessellate } from './triangles.ts';
+
+export interface TessellationOptions {
+ /** Segments along each edge of every triangular patch. Default 4 */
+ segments?: number;
+}
+
+export function icosphere({
+ segments = 4,
+ ...options
+}: patches.IcosphereOptions & TessellationOptions = {}): IndexedGeometry {
+ return tessellate(patches.icosphere(options), segments);
+}
+
+export function capsule({
+ segments = 4,
+ ...options
+}: patches.CapsuleOptions & TessellationOptions = {}): IndexedGeometry {
+ return tessellate(patches.capsule(options), segments);
+}
+
+export function roundedBox({
+ segments = 4,
+ ...options
+}: patches.RoundedBoxOptions & TessellationOptions = {}): IndexedGeometry {
+ return tessellate(patches.roundedBox(options), segments);
+}
diff --git a/packages/typegpu-geometry/src/mesh/patches/icosphere.ts b/packages/typegpu-geometry/src/mesh/patches/icosphere.ts
new file mode 100644
index 0000000000..5c5f70c250
--- /dev/null
+++ b/packages/typegpu-geometry/src/mesh/patches/icosphere.ts
@@ -0,0 +1,55 @@
+import { d, std, tgpu } from 'typegpu';
+import { Surface } from '../geometry.ts';
+import { type PatchSurface } from '../triangles.ts';
+import { uniformArea } from './spherical.ts';
+
+const phi = (1 + Math.sqrt(5)) / 2;
+const corners = (
+ [
+ [-1, phi, 0],
+ [1, phi, 0],
+ [-1, -phi, 0],
+ [1, -phi, 0],
+ [0, -1, phi],
+ [0, 1, phi],
+ [0, -1, -phi],
+ [0, 1, -phi],
+ [phi, 0, -1],
+ [phi, 0, 1],
+ [-phi, 0, -1],
+ [-phi, 0, 1],
+ ] as const
+).map(([x, y, z]) => std.normalize(d.vec3f(x, y, z)));
+const faces = [
+ 0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8,
+ 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1,
+];
+const vertices = tgpu.const(
+ d.arrayOf(d.vec3f, faces.length),
+ faces.map((i) => corners[i] as d.v3f),
+);
+
+export interface IcosphereOptions {
+ radius?: number;
+ interpolate?: (a: d.v3f, b: d.v3f, c: d.v3f, weights: d.v3f) => d.v3f;
+}
+
+/** UVs are local barycentric coordinates within each patch */
+export function icosphere({
+ radius = 0.5,
+ interpolate = uniformArea,
+}: IcosphereOptions = {}): PatchSurface {
+ return {
+ schema: Surface,
+ patchCount: 20,
+ at: (patch: number, w: d.v3f) => {
+ 'use gpu';
+ const offset = patch * 3;
+ const a = vertices.$[offset] as d.v3f;
+ const b = vertices.$[offset + 1] as d.v3f;
+ const c = vertices.$[offset + 2] as d.v3f;
+ const normal = interpolate(a, b, c, w);
+ return Surface({ position: normal * radius, normal, uv: w.yz });
+ },
+ };
+}
diff --git a/packages/typegpu-geometry/src/mesh/patches/index.ts b/packages/typegpu-geometry/src/mesh/patches/index.ts
new file mode 100644
index 0000000000..7522460b94
--- /dev/null
+++ b/packages/typegpu-geometry/src/mesh/patches/index.ts
@@ -0,0 +1,3 @@
+export * from './icosphere.ts';
+export * from './rounded.ts';
+export { linear, uniformArea } from './spherical.ts';
diff --git a/packages/typegpu-geometry/src/mesh/patches/rounded.ts b/packages/typegpu-geometry/src/mesh/patches/rounded.ts
new file mode 100644
index 0000000000..0d4f1ffc88
--- /dev/null
+++ b/packages/typegpu-geometry/src/mesh/patches/rounded.ts
@@ -0,0 +1,128 @@
+import { d, std, tgpu } from 'typegpu';
+import { Surface } from '../geometry.ts';
+import { X, Y, Z } from '../math.ts';
+import { type PatchSurface } from '../triangles.ts';
+import { arc, uniformArea } from './spherical.ts';
+
+const axes = tgpu.const(d.arrayOf(d.vec3f, 3), [X, Y, Z]);
+
+function bitSigns(bits: number) {
+ 'use gpu';
+ return d.vec3f(
+ std.select(1, -1, (bits & 1) !== 0),
+ std.select(1, -1, (bits & 2) !== 0),
+ std.select(1, -1, (bits & 4) !== 0),
+ );
+}
+
+function cornerNormal(octant: number, weights: d.v3f) {
+ 'use gpu';
+ const signs = bitSigns(octant);
+ const w = std.select(weights.xzy, weights, signs.x * signs.y * signs.z > 0);
+ return uniformArea(d.vec3f(signs.x, 0, 0), d.vec3f(0, signs.y, 0), d.vec3f(0, 0, signs.z), w);
+}
+
+function quadCoordinates(triangle: number, weights: d.v3f) {
+ 'use gpu';
+ return std.select(
+ d.vec2f(weights.y, weights.y + weights.z),
+ d.vec2f(weights.y + weights.z, weights.z),
+ triangle === 0,
+ );
+}
+
+const edgeSurface = tgpu.fn(
+ [d.u32, d.vec3f, d.vec3f, d.vec3f, d.vec3f, d.vec3f, d.f32, d.f32],
+ Surface,
+)((triangle, weights, center, axis, u, v, halfHeight, radius) => {
+ 'use gpu';
+ const w = std.select(weights.xzy, weights, std.dot(std.cross(v, axis), u) > 0);
+ const uv = quadCoordinates(triangle, w);
+ const normal = arc(u, v, uv.x);
+ return Surface({
+ position: center + axis * ((2 * uv.y - 1) * halfHeight) + normal * radius,
+ normal,
+ uv,
+ });
+});
+
+export interface CapsuleOptions {
+ radius?: number;
+ /** Length of the cylindrical section */
+ height?: number;
+}
+
+/** Cap UVs are patch-local, side UVs span each quarter cylinder */
+export function capsule({ radius = 0.25, height = 0.5 }: CapsuleOptions = {}): PatchSurface {
+ const halfHeight = height / 2;
+ return {
+ schema: Surface,
+ patchCount: height === 0 ? 8 : 16,
+ at: (patch: number, w: d.v3f) => {
+ 'use gpu';
+ if (patch < 8) {
+ const normal = cornerNormal(patch, w);
+ const center = d.vec3f(0, bitSigns(patch).y * halfHeight, 0);
+ return Surface({ position: center + normal * radius, normal, uv: w.yz });
+ }
+ const signs = bitSigns(std.intdiv(patch - 8, 2));
+ const u = X * signs.x;
+ const v = Z * signs.y;
+ return edgeSurface(patch % 2, w, d.vec3f(), Y, u, v, halfHeight, radius);
+ },
+ };
+}
+
+export interface RoundedBoxOptions {
+ width?: number;
+ height?: number;
+ depth?: number;
+ /** Clamped to half of the smallest dimension */
+ radius?: number;
+}
+
+/** Corner UVs are patch-local, face and edge UVs span their respective quads */
+export function roundedBox({
+ width = 1,
+ height = 1,
+ depth = 1,
+ radius: maxRadius = 0.1,
+}: RoundedBoxOptions = {}): PatchSurface {
+ const halfSize = d.vec3f(width / 2, height / 2, depth / 2);
+ const radius = Math.min(maxRadius, halfSize.x, halfSize.y, halfSize.z);
+ const inset = halfSize.sub(radius);
+ return {
+ schema: Surface,
+ patchCount: radius === 0 ? 12 : 44,
+ at: (index: number, w: d.v3f) => {
+ 'use gpu';
+ const patch = index + (radius === 0 ? 8 : 0);
+ if (patch < 8) {
+ const normal = cornerNormal(patch, w);
+ return Surface({
+ position: bitSigns(patch) * inset + normal * radius,
+ normal,
+ uv: w.yz,
+ });
+ }
+ if (patch < 20) {
+ const face = std.intdiv(patch - 8, 2);
+ const axisIndex = std.intdiv(face, 2);
+ const sign = bitSigns(face).x;
+ const normal = (axes.$[axisIndex] as d.v3f) * sign;
+ const u = axes.$[(axisIndex + 1) % 3] as d.v3f;
+ const v = (axes.$[(axisIndex + 2) % 3] as d.v3f) * sign;
+ const uv = quadCoordinates(patch % 2, w);
+ const position = normal * halfSize + (u * (2 * uv.x - 1) + v * (2 * uv.y - 1)) * inset;
+ return Surface({ position, normal, uv });
+ }
+ const edge = std.intdiv(patch - 20, 2);
+ const axisIndex = std.intdiv(edge, 4);
+ const axis = axes.$[axisIndex] as d.v3f;
+ const signs = bitSigns(edge);
+ const u = (axes.$[(axisIndex + 1) % 3] as d.v3f) * signs.x;
+ const v = (axes.$[(axisIndex + 2) % 3] as d.v3f) * signs.y;
+ return edgeSurface(patch % 2, w, (u + v) * inset, axis, u, v, std.dot(axis, inset), radius);
+ },
+ };
+}
diff --git a/packages/typegpu-geometry/src/mesh/patches/spherical.ts b/packages/typegpu-geometry/src/mesh/patches/spherical.ts
new file mode 100644
index 0000000000..e7620c1889
--- /dev/null
+++ b/packages/typegpu-geometry/src/mesh/patches/spherical.ts
@@ -0,0 +1,41 @@
+import { d, std, tgpu } from 'typegpu';
+
+export const linear = tgpu.fn(
+ [d.vec3f, d.vec3f, d.vec3f, d.vec3f],
+ d.vec3f,
+)((a, b, c, weights) => {
+ 'use gpu';
+ return std.normalize(a * weights.x + b * weights.y + c * weights.z);
+});
+
+const warp = tgpu.fn(
+ [d.f32, d.f32],
+ d.f32,
+)((cosAngle, t) => {
+ 'use gpu';
+ const cosHalf = std.sqrt(0.5 * (1 + cosAngle));
+ const b = (2 - 2 * cosHalf) / (2 + cosHalf);
+ const u = 2 * t - 1;
+ return (u * (1 - b) * 0.5) / (1 - b * u * u) + 0.5;
+});
+
+export const uniformArea = tgpu.fn(
+ [d.vec3f, d.vec3f, d.vec3f, d.vec3f],
+ d.vec3f,
+)((a, b, c, weights) => {
+ 'use gpu';
+ const w = d.vec3f(
+ warp(std.dot(b, c), weights.x),
+ warp(std.dot(a, c), weights.y),
+ warp(std.dot(a, b), weights.z),
+ );
+ return std.normalize(a * w.x + b * w.y + c * w.z);
+});
+
+export const arc = tgpu.fn(
+ [d.vec3f, d.vec3f, d.f32],
+ d.vec3f,
+)((a, b, t) => {
+ 'use gpu';
+ return std.normalize(std.mix(a, b, warp(std.dot(a, b), t)));
+});
diff --git a/packages/typegpu-geometry/src/mesh/triangles.ts b/packages/typegpu-geometry/src/mesh/triangles.ts
new file mode 100644
index 0000000000..1080bddd6d
--- /dev/null
+++ b/packages/typegpu-geometry/src/mesh/triangles.ts
@@ -0,0 +1,78 @@
+import { d, std, tgpu } from 'typegpu';
+import { type ConstructibleSchema, type IndexedGeometry, type Surface } from './geometry.ts';
+
+export interface PatchSurface {
+ readonly schema: V;
+ readonly patchCount: number;
+ at(this: void, patch: number, weights: d.v3f): d.InferGPU;
+}
+
+export function triangleVertexCount(segments: number) {
+ return ((segments + 1) * (segments + 2)) / 2;
+}
+
+export function triangleIndexCount(segments: number) {
+ return 3 * segments * segments;
+}
+
+export const triangleBarycentrics = tgpu.fn(
+ [d.u32, d.u32],
+ d.vec3f,
+)((vertex, segments) => {
+ 'use gpu';
+ let row = d.u32((std.sqrt(8 * d.f32(vertex) + 1) - 1) / 2);
+ if (std.intdiv(row * (row + 1), 2) > vertex) row -= 1;
+ else if (std.intdiv((row + 1) * (row + 2), 2) <= vertex) row += 1;
+ const start = std.intdiv(row * (row + 1), 2);
+ const col = vertex - start;
+ return d.vec3f(1 - row / segments, (row - col) / segments, col / segments);
+});
+
+export const triangleIndexAt = tgpu.fn(
+ [d.u32],
+ d.u32,
+)((index) => {
+ 'use gpu';
+ const triangle = std.intdiv(index, 3);
+ let row = d.u32(std.sqrt(d.f32(triangle)));
+ if (row * row > triangle) row -= 1;
+ else if ((row + 1) * (row + 1) <= triangle) row += 1;
+ const col = triangle - row * row;
+ const start = std.intdiv(row * (row + 1), 2);
+ const corner = index % 3;
+ if (col <= row) {
+ if (corner === 0) return start + col;
+ return start + col + row + corner;
+ }
+ const vertex = start + col - row - 1;
+ if (corner === 0) return vertex;
+ if (corner === 1) return vertex + row + 2;
+ return vertex + 1;
+});
+
+/** Lower subdivision levels use a prefix of these indices */
+export function triangleIndices(maxSegments: number) {
+ return Array.from({ length: triangleIndexCount(maxSegments) }, (_, i) => triangleIndexAt(i));
+}
+
+export function tessellate(
+ patches: PatchSurface,
+ segments: number,
+): IndexedGeometry {
+ const vertices = triangleVertexCount(segments);
+ const indices = triangleIndexCount(segments);
+ return {
+ schema: patches.schema,
+ topology: 'triangle-list',
+ vertexCount: patches.patchCount * vertices,
+ indexCount: patches.patchCount * indices,
+ vertexAt: (i: number) => {
+ 'use gpu';
+ return patches.at(std.intdiv(i, vertices), triangleBarycentrics(i % vertices, segments));
+ },
+ indexAt: (i: number) => {
+ 'use gpu';
+ return std.intdiv(i, indices) * vertices + triangleIndexAt(i % indices);
+ },
+ };
+}
diff --git a/packages/typegpu-geometry/tests/mesh.test.ts b/packages/typegpu-geometry/tests/mesh.test.ts
index 8ad3c6b5d2..f7b5e4fe9a 100644
--- a/packages/typegpu-geometry/tests/mesh.test.ts
+++ b/packages/typegpu-geometry/tests/mesh.test.ts
@@ -30,6 +30,13 @@ describe('meshes on the CPU', () => {
['box', meshes.box()],
['cylinder', meshes.cylinder({ radialSegments: 6 })],
['torus', meshes.torus({ ringSegments: 6, tubeSegments: 4 })],
+ ['icosphere', meshes.icosphere()],
+ ['capsule', meshes.capsule()],
+ ['rounded box', meshes.roundedBox()],
+ ['sharp box', meshes.roundedBox({ radius: 0 })],
+ ['fully rounded box', meshes.roundedBox({ radius: 0.5 })],
+ ['thin rounded box', meshes.roundedBox({ height: 0.05 })],
+ ['collapsed capsule', meshes.capsule({ radius: 0.5, height: 0 })],
])('%s fits a unit cube with outward unit normals', (_, shape) => {
expect(windingAgreesWithNormals(shape)).toBe(true);
@@ -197,8 +204,8 @@ describe('meshes on the CPU', () => {
expect(vertex.position.y).toBe(0);
});
- it.each([meshes.sphere(), meshes.cylinder()])(
- 'omits collapsed pole and cap triangles',
+ it.each([meshes.sphere(), meshes.cylinder(), meshes.roundedBox({ radius: 0 })])(
+ 'omits collapsed pole, cap and zero-radius triangles',
(shape) => {
for (let i = 0; i < shape.indexCount; i += 3) {
const a = shape.vertexAt(shape.indexAt(i)).position;
diff --git a/packages/typegpu-geometry/tests/patches.test.ts b/packages/typegpu-geometry/tests/patches.test.ts
new file mode 100644
index 0000000000..2a77cd748b
--- /dev/null
+++ b/packages/typegpu-geometry/tests/patches.test.ts
@@ -0,0 +1,95 @@
+import { expect } from 'vitest';
+import { d, std, tgpu } from 'typegpu';
+import { it } from 'typegpu-testing-utility';
+import { meshes } from '@typegpu/geometry';
+
+it('covers the whole triangle with each index prefix', () => {
+ const indices = meshes.triangleIndices(16);
+ for (const segments of [1, 2, 3, 7, 16]) {
+ const prefix = indices.slice(0, meshes.triangleIndexCount(segments));
+ expect(prefix).toEqual(meshes.triangleIndices(segments));
+ expect(Math.max(...prefix) + 1).toBe(meshes.triangleVertexCount(segments));
+ let area = 0;
+ for (let i = 0; i < prefix.length; i += 3) {
+ const [a, b, c] = prefix
+ .slice(i, i + 3)
+ .map((v) => meshes.triangleBarycentrics(v, segments).yz) as [d.v2f, d.v2f, d.v2f];
+ const ab = b.sub(a);
+ const ac = c.sub(a);
+ const signedArea = (ab.x * ac.y - ab.y * ac.x) / 2;
+ expect(signedArea).toBeGreaterThan(0);
+ area += signedArea;
+ }
+ expect(area).toBeCloseTo(0.5);
+ }
+});
+
+it.each([
+ ['icosphere', meshes.patches.icosphere()],
+ ['capsule', meshes.patches.capsule()],
+ ['rounded box', meshes.patches.roundedBox({ width: 2, height: 1, depth: 3 })],
+])('%s keeps adjacent patches watertight and supports both evaluation paths', (_, patches) => {
+ const segments = 3;
+ const perPatch = meshes.triangleVertexCount(segments);
+ const mesh = meshes.tessellate(patches, segments);
+ const edges = new Map();
+ const positions = Array.from({ length: mesh.vertexCount }, (_, i) => {
+ const vertex = mesh.vertexAt(i);
+ const patchVertex = patches.at(
+ Math.floor(i / perPatch),
+ meshes.triangleBarycentrics(i % perPatch, segments),
+ );
+ expect(vertex).toEqual(patchVertex);
+ return [...vertex.position].map((v) => v.toFixed(5).replace('-0.00000', '0.00000')).join(',');
+ });
+ for (let i = 0; i < mesh.indexCount; i += 3) {
+ const corners = [0, 1, 2].map((c) => positions[mesh.indexAt(i + c)]);
+ for (let c = 0; c < 3; c++) {
+ const key = [corners[c], corners[(c + 1) % 3]].toSorted().join('|');
+ edges.set(key, (edges.get(key) ?? 0) + 1);
+ }
+ }
+ expect(new Set(edges.values())).toEqual(new Set([2]));
+
+ const procedural = tgpu.fn([d.u32, d.vec3f], meshes.Surface)(patches.at);
+ const baked = tgpu.fn([d.u32], meshes.Surface)(mesh.vertexAt);
+ expect(() => tgpu.resolve([procedural, baked])).not.toThrow();
+});
+
+it('keeps the icosphere on the sphere without pole triangles', () => {
+ const mesh = meshes.icosphere({ segments: 7, radius: 2 });
+ const areas = [];
+ for (let i = 0; i < mesh.indexCount; i += 3) {
+ const a = mesh.vertexAt(mesh.indexAt(i));
+ const b = mesh.vertexAt(mesh.indexAt(i + 1));
+ const c = mesh.vertexAt(mesh.indexAt(i + 2));
+ expect(std.length(a.position)).toBeCloseTo(2);
+ expect(std.length(b.position)).toBeCloseTo(2);
+ expect(std.length(c.position)).toBeCloseTo(2);
+ areas.push(std.length(std.cross(b.position.sub(a.position), c.position.sub(a.position))));
+ }
+ expect(Math.min(...areas)).toBeGreaterThan(0);
+ expect(Math.max(...areas) / Math.min(...areas)).toBeLessThan(1.15);
+});
+
+it('specializes spherical interpolation through a slot', () => {
+ const interpolation = tgpu.slot(meshes.patches.uniformArea);
+ const patches = meshes.patches.icosphere({
+ interpolate: (a, b, c, w) => {
+ 'use gpu';
+ return interpolation.$(a, b, c, w);
+ },
+ });
+ const vertex = tgpu
+ .fn(
+ [d.u32, d.vec3f],
+ meshes.Surface,
+ )(patches.at)
+ .with(interpolation, meshes.patches.linear);
+ const code = tgpu.resolve([vertex]);
+ expect(code.match(/fn (linear|uniformArea|warp)\(/g)).toMatchInlineSnapshot(`
+ [
+ "fn linear(",
+ ]
+ `);
+});