From fb39f88287173ba8fac11f1f3e39763eea6594a6 Mon Sep 17 00:00:00 2001
From: Marcin Hawryluk <70582973+mhawryluk@users.noreply.github.com>
Date: Thu, 28 Nov 2024 11:32:27 +0100
Subject: [PATCH] Confetti example (#587)
---
.../examples/simulation/confetti/index.html | 1 +
.../examples/simulation/confetti/index.ts | 261 ++++++++++++++++++
.../examples/simulation/confetti/meta.json | 5 +
3 files changed, 267 insertions(+)
create mode 100644 apps/typegpu-docs/src/content/examples/simulation/confetti/index.html
create mode 100644 apps/typegpu-docs/src/content/examples/simulation/confetti/index.ts
create mode 100644 apps/typegpu-docs/src/content/examples/simulation/confetti/meta.json
diff --git a/apps/typegpu-docs/src/content/examples/simulation/confetti/index.html b/apps/typegpu-docs/src/content/examples/simulation/confetti/index.html
new file mode 100644
index 00000000..c733673c
--- /dev/null
+++ b/apps/typegpu-docs/src/content/examples/simulation/confetti/index.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/typegpu-docs/src/content/examples/simulation/confetti/index.ts b/apps/typegpu-docs/src/content/examples/simulation/confetti/index.ts
new file mode 100644
index 00000000..e1c1e67c
--- /dev/null
+++ b/apps/typegpu-docs/src/content/examples/simulation/confetti/index.ts
@@ -0,0 +1,261 @@
+import { arrayOf, f32, struct, vec2f, vec4f } from 'typegpu/data';
+import tgpu, { asMutable, asUniform, builtin } from 'typegpu/experimental';
+
+// constants
+
+const PARTICLE_AMOUNT = 200;
+const COLOR_PALETTE: vec4f[] = [
+ [255, 190, 11],
+ [251, 86, 7],
+ [255, 0, 110],
+ [131, 56, 236],
+ [58, 134, 255],
+].map(([r, g, b]) => vec4f(r / 255, g / 255, b / 255, 1));
+
+// setup
+
+const root = await tgpu.init();
+
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = canvas.getContext('webgpu') as GPUCanvasContext;
+const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
+
+context.configure({
+ device: root.device,
+ format: presentationFormat,
+ alphaMode: 'premultiplied',
+});
+
+// data types
+
+const VertexOutput = {
+ position: builtin.position,
+ color: vec4f,
+};
+
+const ParticleGeometry = struct({
+ tilt: f32,
+ angle: f32,
+ color: vec4f,
+});
+
+const ParticleData = struct({
+ position: vec2f,
+ velocity: vec2f,
+ seed: f32,
+});
+
+// buffers
+
+const canvasAspectRatioBuffer = root
+ .createBuffer(f32, canvas.width / canvas.height)
+ .$usage('uniform');
+
+const canvasAspectRatioUniform = asUniform(canvasAspectRatioBuffer);
+
+const particleGeometryBuffer = root
+ .createBuffer(
+ arrayOf(ParticleGeometry, PARTICLE_AMOUNT),
+ Array(PARTICLE_AMOUNT)
+ .fill(0)
+ .map(() => ({
+ angle: Math.floor(Math.random() * 50) - 10,
+ tilt: Math.floor(Math.random() * 10) - 10 - 10,
+ color: COLOR_PALETTE[Math.floor(Math.random() * COLOR_PALETTE.length)],
+ })),
+ )
+ .$usage('vertex');
+
+const particleDataBuffer = root
+ .createBuffer(arrayOf(ParticleData, PARTICLE_AMOUNT))
+ .$usage('storage', 'uniform', 'vertex');
+
+const deltaTimeBuffer = root.createBuffer(f32).$usage('uniform');
+const timeBuffer = root.createBuffer(f32).$usage('storage');
+
+// layouts
+
+const geometryLayout = tgpu.vertexLayout(
+ (n: number) => arrayOf(ParticleGeometry, n),
+ 'instance',
+);
+
+const dataLayout = tgpu.vertexLayout(
+ (n: number) => arrayOf(ParticleData, n),
+ 'instance',
+);
+
+const particleDataStorage = asMutable(particleDataBuffer);
+const deltaTimeUniform = asUniform(deltaTimeBuffer);
+const timeStorage = asMutable(timeBuffer);
+
+// functions
+
+const rotate = tgpu.fn([vec2f, f32], vec2f).does(/* wgsl */ `
+ (v: vec2f, angle: f32) -> vec2f {
+ let pos = vec2(
+ (v.x * cos(angle)) - (v.y * sin(angle)),
+ (v.x * sin(angle)) + (v.y * cos(angle))
+ );
+
+ return pos;
+ }
+`);
+
+const mainVert = tgpu
+ .vertexFn(
+ {
+ tilt: f32,
+ angle: f32,
+ color: vec4f,
+ center: vec2f,
+ index: builtin.vertexIndex,
+ },
+ VertexOutput,
+ )
+ .does(
+ /* wgsl */ `(
+ @location(0) tilt: f32,
+ @location(1) angle: f32,
+ @location(2) color: vec4f,
+ @location(3) center: vec2f,
+ @builtin(vertex_index) index: u32,
+ ) -> VertexOutput {
+ let width = tilt;
+ let height = tilt / 2;
+
+ var pos = rotate(array(
+ vec2f(0, 0),
+ vec2f(width, 0),
+ vec2f(0, height),
+ vec2f(width, height),
+ )[index] / 350, angle) + center;
+
+ if (canvasAspectRatio < 1) {
+ pos.x /= canvasAspectRatio;
+ } else {
+ pos.y *= canvasAspectRatio;
+ }
+
+ return VertexOutput(vec4f(pos, 0.0, 1.0), color);
+ }`,
+ )
+ .$uses({
+ rotate,
+ canvasAspectRatio: canvasAspectRatioUniform,
+ get VertexOutput() {
+ return mainVert.Output;
+ },
+ });
+
+const mainFrag = tgpu.fragmentFn(VertexOutput, vec4f).does(/* wgsl */ `
+ (@location(0) color: vec4f) -> @location(0) vec4f {
+ return color;
+ }`);
+
+const mainCompute = tgpu
+ .computeFn([builtin.globalInvocationId], { workgroupSize: [1] })
+ .does(
+ /* wgsl */ `(@builtin(global_invocation_id) gid: vec3u) {
+ let index = gid.x;
+ if index == 0 {
+ time += deltaTime;
+ }
+ let phase = (time + particleData[index].seed) / 200;
+ particleData[index].position += particleData[index].velocity * deltaTime / 20 + vec2f(sin(phase) / 600, cos(phase) / 500);
+ }`,
+ )
+ .$uses({
+ particleData: particleDataStorage,
+ deltaTime: deltaTimeUniform,
+ time: timeStorage,
+ });
+
+// pipelines
+
+const renderPipeline = root
+ .withVertex(mainVert, {
+ tilt: geometryLayout.attrib.tilt,
+ angle: geometryLayout.attrib.angle,
+ color: geometryLayout.attrib.color,
+ center: dataLayout.attrib.position,
+ })
+ .withFragment(mainFrag, {
+ format: presentationFormat,
+ })
+ .withPrimitive({
+ topology: 'triangle-strip',
+ })
+ .createPipeline()
+ .with(geometryLayout, particleGeometryBuffer)
+ .with(dataLayout, particleDataBuffer);
+
+const computePipeline = root.withCompute(mainCompute).createPipeline();
+
+// compute and draw
+
+function randomizePositions() {
+ particleDataBuffer.write(
+ Array(PARTICLE_AMOUNT)
+ .fill(0)
+ .map(() => ({
+ position: vec2f(Math.random() * 2 - 1, Math.random() * 2 + 1),
+ velocity: vec2f(
+ (Math.random() * 2 - 1) / 50,
+ -(Math.random() / 25 + 0.01),
+ ),
+ seed: Math.random(),
+ })),
+ );
+}
+
+randomizePositions();
+
+let disposed = false;
+
+function onFrame(loop: (deltaTime: number) => unknown) {
+ let lastTime = Date.now();
+ const runner = () => {
+ if (disposed) {
+ return;
+ }
+ const now = Date.now();
+ const dt = now - lastTime;
+ lastTime = now;
+ loop(dt);
+ requestAnimationFrame(runner);
+ };
+ requestAnimationFrame(runner);
+}
+
+onFrame((deltaTime) => {
+ deltaTimeBuffer.write(deltaTime);
+ canvasAspectRatioBuffer.write(canvas.width / canvas.height);
+
+ computePipeline.dispatchWorkgroups(PARTICLE_AMOUNT);
+
+ renderPipeline
+ .withColorAttachment({
+ view: context.getCurrentTexture().createView(),
+ clearValue: [0, 0, 0, 0],
+ loadOp: 'clear' as const,
+ storeOp: 'store' as const,
+ })
+ .draw(4, PARTICLE_AMOUNT);
+
+ root.flush();
+});
+
+// example controls and cleanup
+
+export const controls = {
+ '🎉': {
+ onButtonClick: () => randomizePositions(),
+ },
+};
+
+export function onCleanup() {
+ disposed = true;
+ root.destroy();
+ root.device.destroy();
+}
diff --git a/apps/typegpu-docs/src/content/examples/simulation/confetti/meta.json b/apps/typegpu-docs/src/content/examples/simulation/confetti/meta.json
new file mode 100644
index 00000000..070e8d8b
--- /dev/null
+++ b/apps/typegpu-docs/src/content/examples/simulation/confetti/meta.json
@@ -0,0 +1,5 @@
+{
+ "title": "Confetti",
+ "category": "simulation",
+ "tags": ["experimental"]
+}