Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/typegpu-docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ export default defineConfig({
label: '@typegpu/radiance-cascades',
slug: 'ecosystem/typegpu-radiance-cascades',
},
DEV && {
label: '@typegpu/geometry',
slug: 'ecosystem/typegpu-geometry',
badge: { text: 'dev', variant: 'note' },
},
DEV && {
label: '@typegpu/color',
slug: 'ecosystem/typegpu-color',
Expand Down
156 changes: 156 additions & 0 deletions apps/typegpu-docs/src/components/geometry/MeshPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'react';
import { demos, defaultParams, type DemoName } from './demos.ts';
import { createMeshViewer, type MeshViewer, type PreviewState, type Shading } from './viewer.ts';

export default function MeshPreview({ demo }: { demo: DemoName }) {
return <Preview demo={demo} key={demo} />;
}

function Preview({ demo }: { demo: DemoName }) {
const spec = demos[demo];
const canvasRef = useRef<HTMLCanvasElement>(null);
const [viewer, setViewer] = useState<MeshViewer | null>(null);
const [state, setState] = useState<PreviewState>({ status: 'loading' });
const [shading, setShading] = useState<Shading>('smooth');
const [paused, setPaused] = useState(false);
const [params, setParams] = useState(() => defaultParams(spec));

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
let visible = false;
let instance: MeshViewer | undefined;
const updateActivity = () => instance?.setActive(visible && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
updateActivity();
});
observer.observe(canvas);
document.addEventListener('visibilitychange', updateActivity);
setPaused(window.matchMedia('(prefers-reduced-motion: reduce)').matches);

createMeshViewer(canvas, (value) => {
if (!disposed) setState(value);
})
.then((value) => {
if (disposed) {
value.destroy();
return;
}
instance = value;
updateActivity();
setViewer(value);
})
.catch((error: unknown) => {
if (!disposed)
setState({
status: 'error',
message: error instanceof Error ? error.message : String(error),
});
});

return () => {
disposed = true;
observer.disconnect();
document.removeEventListener('visibilitychange', updateActivity);
instance?.destroy();
};
}, []);

useEffect(() => {
viewer?.show(spec, params);
}, [viewer, spec, params]);
useEffect(() => {
viewer?.setShading(shading);
}, [viewer, shading]);
useEffect(() => {
viewer?.setPaused(paused);
}, [viewer, paused]);

return (
<div className="not-content my-6 grid overflow-hidden rounded-sm border border-[var(--sl-color-gray-5)] bg-[var(--sl-color-bg)] md:grid-cols-[minmax(0,1fr)_13rem]">
<div className="min-w-0 bg-[var(--sl-color-bg-inline-code)] p-3">
<canvas
aria-label={spec.description}
className="block aspect-[4/3] w-full rounded-sm bg-[#171526]"
height={300}
ref={canvasRef}
role="img"
width={400}
/>
{state.status === 'loading' && (
<p className="mt-3 text-xs" role="status">
Loading preview…
</p>
)}
{state.status === 'error' && (
<p className="mt-3 text-xs leading-5 text-[var(--sl-color-text)]" role="alert">
Could not render preview: {state.message}
</p>
)}
</div>
<fieldset
disabled={!viewer || state.status === 'error'}
className="m-0 grid min-w-0 content-start gap-3 border-0 border-t border-[var(--sl-color-gray-5)] p-3 text-xs text-[var(--sl-color-gray-2)] md:border-l md:border-t-0"
>
<legend className="sr-only">Preview controls</legend>
{spec.params.map((p) => {
const set = (value: number) => setParams((previous) => ({ ...previous, [p.key]: value }));
return (
<label className="grid gap-1" key={p.key}>
<span className="flex justify-between font-medium text-[var(--sl-color-text)]">
<span>{p.label}</span>
{p.kind === 'range' && <span>{params[p.key]}</span>}
</span>
{p.kind === 'select' ? (
<select
className="rounded-sm border border-[var(--sl-color-gray-5)] bg-[var(--sl-color-bg)] px-2 py-1 text-[var(--sl-color-text)]"
onChange={(e) => set(Number(e.target.value))}
value={params[p.key]}
>
{p.options.map((option, index) => (
<option key={option} value={index}>
{option}
</option>
))}
</select>
) : (
<input
aria-label={p.label}
className="w-full accent-[var(--sl-color-accent)]"
max={p.max}
min={p.min}
onChange={(e) => set(Number(e.target.value))}
step={p.step}
type="range"
value={params[p.key]}
/>
)}
</label>
);
})}
<label className="grid gap-1 text-[var(--sl-color-text)]">
<span className="font-medium">View</span>
<select
className="rounded-sm border border-[var(--sl-color-gray-5)] bg-[var(--sl-color-bg)] px-2 py-1"
onChange={(e) => setShading(e.target.value as Shading)}
value={shading}
>
<option value="smooth">Smooth</option>
<option value="flat">Flat</option>
<option value="wireframe">Wireframe</option>
</select>
</label>
<button
aria-pressed={paused}
className="rounded-sm border border-[var(--sl-color-gray-5)] px-2 py-1 text-[var(--sl-color-text)]"
onClick={() => setPaused((value) => !value)}
type="button"
>
{paused ? 'Resume animation' : 'Pause animation'}
</button>
</fieldset>
</div>
);
}
194 changes: 194 additions & 0 deletions apps/typegpu-docs/src/components/geometry/demos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { meshes } from '@typegpu/geometry';
import { d, std, tgpu, type TgpuCommandEncoder, type TgpuRoot, type TgpuUniform } from 'typegpu';
import { Paint, type VertexSchema, type Scene } from './shaders.ts';

const TAU = Math.PI * 2;
const SHAPES = [
'sphere',
'box',
'cylinder',
'torus',
'plane',
'icosphere',
'capsule',
'rounded box',
] as const;

type Param = { key: string; label: string; value: number } & (
| { kind: 'select'; options: readonly string[] }
| { kind: 'range'; min: number; max: number; step: number }
);
export type PreviewMesh = meshes.BakedIndexed<VertexSchema>;
export type DemoContext = { root: TgpuRoot; uniforms: TgpuUniform<typeof Scene> };
export interface Demo {
description: string;
params: Param[];
rebuild: string[];
animated?: boolean;
build(params: Record<string, number>, ctx: DemoContext): meshes.IndexedGeometry<VertexSchema>;
update?(mesh: PreviewMesh, ctx: DemoContext): Promise<(encoder: TgpuCommandEncoder) => void>;
}

export const demos: Record<DemoName, Demo> = {
primitives: {
description: 'A primitive mesh with adjustable tessellation',
params: [
{ key: 'shape', label: 'Shape', kind: 'select', options: SHAPES, value: 0 },
{ key: 'detail', label: 'Detail', kind: 'range', min: 3, max: 48, step: 1, value: 16 },
],
rebuild: ['shape', 'detail'],
build: ({ shape, detail }) => {
switch (SHAPES[shape]) {
case 'icosphere':
return meshes.icosphere({ segments: detail });
case 'capsule':
return meshes.capsule({ segments: detail });
case 'rounded box':
return meshes.roundedBox({ segments: detail });
case 'box':
return meshes.box({
widthSegments: detail,
heightSegments: detail,
depthSegments: detail,
});
case 'cylinder':
return meshes.cylinder({ radialSegments: detail });
case 'torus':
return meshes.torus({ ringSegments: detail * 2, tubeSegments: detail });
case 'plane':
return meshes.plane({ widthSegments: detail, depthSegments: detail });
default:
return meshes.sphere({ radialSegments: detail * 2, heightSegments: detail });
}
},
},
ripple: {
description: 'A parametric ripple sampled on an adjustable grid',
params: [
{ key: 'cols', label: 'Columns', kind: 'range', min: 1, max: 64, step: 1, value: 24 },
{ key: 'rows', label: 'Rows', kind: 'range', min: 1, max: 64, step: 1, value: 24 },
{
key: 'amplitude',
label: 'Amplitude',
kind: 'range',
min: 0,
max: 0.3,
step: 0.01,
value: 0.1,
},
{
key: 'frequency',
label: 'Frequency',
kind: 'range',
min: 1,
max: 24,
step: 0.5,
value: 10,
},
],
rebuild: ['cols', 'rows'],
build: ({ cols, rows }, { uniforms }) =>
meshes.parametric(
{
at: (u, v) => {
'use gpu';
const amplitude = uniforms.$.params.amplitude;
const frequency = uniforms.$.params.frequency;
const height = amplitude * std.sin(u * frequency) * std.cos(v * frequency);
return d.vec3f(u - 0.5, height, 0.5 - v);
},
},
{ cols, rows },
),
},
gyroscope: {
description: 'Torus meshes rotated and combined into concentric rings',
params: [
{ key: 'count', label: 'Rings', kind: 'range', min: 1, max: 8, step: 1, value: 3 },
{ key: 'tilt', label: 'Tilt', kind: 'range', min: 0, max: 1.5, step: 0.05, value: 0.6 },
],
rebuild: ['count'],
build: ({ count }, { uniforms }) => {
const ring = meshes.torus({ radius: 0.45, tube: 0.04, ringSegments: 48, tubeSegments: 12 });
const tilted = meshes.map(ring, meshes.Surface, (v) => {
'use gpu';
const rotation = std.rotationX4(uniforms.$.params.tilt);
return meshes.Surface({
position: (rotation * d.vec4f(v.position, 1)).xyz,
normal: (rotation * d.vec4f(v.normal, 0)).xyz,
uv: v.uv,
});
});
return meshes.concat(
...Array.from({ length: count }, (_, i) =>
meshes.transform(tilted, std.rotationY4((i * Math.PI) / count)),
),
);
},
},
stripes: {
description: 'A torus with vertex colors forming a twisting striped pattern',
params: [
{ key: 'stripes', label: 'Stripes', kind: 'range', min: 1, max: 24, step: 1, value: 8 },
{ key: 'twist', label: 'Twist', kind: 'range', min: 0, max: 6, step: 0.25, value: 2 },
{ key: 'hue', label: 'Hue', kind: 'range', min: 0, max: 1, step: 0.01, value: 0 },
],
rebuild: [],
build: (_, { uniforms }) =>
meshes.attach(meshes.torus({ ringSegments: 128, tubeSegments: 48 }), Paint, (s) => {
'use gpu';
const stripes = uniforms.$.params.stripes;
const twist = uniforms.$.params.twist;
const hue = uniforms.$.params.hue;
const wave = std.sin((s.uv.x * stripes + s.uv.y * twist) * TAU);
const phases = (hue + s.uv.x + d.vec3f(0, 0.33, 0.67)) * TAU;
const palette = std.cos(phases) * 0.5 + 0.5;
return Paint({ color: palette * (0.55 + 0.45 * wave) });
}),
},
deformation: {
description: 'A subdivided plane deformed in place by a compute shader',
params: [
{
key: 'amplitude',
label: 'Amplitude',
kind: 'range',
min: 0,
max: 0.3,
step: 0.01,
value: 0.1,
},
{ key: 'frequency', label: 'Frequency', kind: 'range', min: 1, max: 16, step: 0.5, value: 6 },
],
rebuild: [],
animated: true,
build: () => meshes.plane({ width: 1.4, depth: 1.4, widthSegments: 64, depthSegments: 64 }),
update: async (mesh, { root, uniforms }) => {
const vertices = mesh.vertices.as('mutable');
const sway = tgpu.computeFn({
workgroupSize: [64],
in: { gid: d.builtin.globalInvocationId },
})(({ gid }) => {
'use gpu';
if (gid.x >= mesh.vertexCount) return;

const amplitude = uniforms.$.params.amplitude;
const frequency = uniforms.$.params.frequency;
const v = vertices.$[gid.x];
const phase = v.position.x * frequency + uniforms.$.time;
v.position.y = amplitude * std.sin(phase);
v.normal = std.normalize(d.vec3f(-amplitude * frequency * std.cos(phase), 1, 0));
});
const pipeline = root.createComputePipeline({ compute: sway });
await pipeline.initAsync();
const workgroups = Math.ceil(mesh.vertexCount / 64);
return (encoder) => pipeline.with(encoder).dispatchWorkgroups(workgroups);
},
},
};

export type DemoName = 'primitives' | 'ripple' | 'gyroscope' | 'stripes' | 'deformation';

export function defaultParams(demo: Demo) {
return Object.fromEntries(demo.params.map((p) => [p.key, p.value]));
}
Loading
Loading