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(", + ] + `); +});