Skip to content

Commit 0aff9ee

Browse files
committed
Confetti example
1 parent 918b363 commit 0aff9ee

10 files changed

Lines changed: 299 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div id="example-app"></div>
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import tgpu, { d, std } from 'typegpu';
2+
import { useFrame, useRoot, useBuffer, useUniformValue, useMutable } from '@typegpu/react';
3+
4+
// constants
5+
6+
const PARTICLE_AMOUNT = 200;
7+
const COLOR_PALETTE: d.v4f[] = [
8+
[255, 190, 11],
9+
[251, 86, 7],
10+
[255, 0, 110],
11+
[131, 56, 236],
12+
[58, 134, 255],
13+
].map(([r, g, b]) => d.vec4f(r / 255, g / 255, b / 255, 1));
14+
15+
// data types
16+
17+
const VertexOutput = {
18+
position: d.builtin.position,
19+
color: d.vec4f,
20+
};
21+
22+
const ParticleGeometry = d.struct({
23+
tilt: d.f32,
24+
angle: d.f32,
25+
color: d.vec4f,
26+
});
27+
28+
const ParticleData = d.struct({
29+
position: d.vec2f,
30+
velocity: d.vec2f,
31+
seed: d.f32,
32+
});
33+
34+
// layouts
35+
36+
const geometryLayout = tgpu.vertexLayout(d.arrayOf(ParticleGeometry), 'instance');
37+
38+
const dataLayout = tgpu.vertexLayout(d.arrayOf(ParticleData), 'instance');
39+
40+
// functions
41+
42+
const rotate = tgpu.fn(
43+
[d.vec2f, d.f32],
44+
d.vec2f,
45+
)((v, angle) => {
46+
const pos = d.vec2f(
47+
v.x * std.cos(angle) - v.y * std.sin(angle),
48+
v.x * std.sin(angle) + v.y * std.cos(angle),
49+
);
50+
51+
return pos;
52+
});
53+
54+
const mainFrag = tgpu.fragmentFn({
55+
in: VertexOutput,
56+
out: d.vec4f,
57+
}) /* wgsl */ `{ return in.color; }`;
58+
59+
// compute and draw
60+
61+
function createRandomPositions() {
62+
return Array.from({ length: PARTICLE_AMOUNT }, () => ({
63+
position: d.vec2f(Math.random() * 2 - 1, Math.random() * 2 + 1),
64+
velocity: d.vec2f((Math.random() * 2 - 1) / 50, -(Math.random() / 25 + 0.01)),
65+
seed: Math.random(),
66+
}));
67+
}
68+
69+
function App() {
70+
const root = useRoot();
71+
const ctxRef = useRef<GPUCanvasContext | null>(null);
72+
73+
// buffers
74+
75+
const particleGeometryBuffer = useBuffer(d.arrayOf(ParticleGeometry, PARTICLE_AMOUNT), () =>
76+
Array.from({ length: PARTICLE_AMOUNT }, () => ({
77+
angle: Math.floor(Math.random() * 50) - 10,
78+
tilt: Math.floor(Math.random() * 10) - 10 - 10,
79+
color: COLOR_PALETTE[Math.floor(Math.random() * COLOR_PALETTE.length)],
80+
})),
81+
).$usage('vertex');
82+
83+
const particleDataBuffer = useBuffer(
84+
d.arrayOf(ParticleData, PARTICLE_AMOUNT),
85+
createRandomPositions,
86+
).$usage('storage', 'uniform', 'vertex');
87+
88+
const aspectRatio = useUniformValue(d.f32, 1);
89+
const deltaTime = useUniformValue(d.f32);
90+
const time = useMutable(d.f32);
91+
92+
const particleDataStorage = particleDataBuffer.as('mutable');
93+
94+
const mainCompute = useMemo(
95+
() =>
96+
tgpu.computeFn({
97+
in: { gid: d.builtin.globalInvocationId },
98+
workgroupSize: [1],
99+
}) /* wgsl */ `{
100+
let index = in.gid.x;
101+
if index == 0 {
102+
time += deltaTime.$;
103+
}
104+
let phase = (time / 300) + particleData[index].seed;
105+
particleData[index].position += particleData[index].velocity * deltaTime.$ / 20 + vec2f(sin(phase) / 600, cos(phase) / 500);
106+
}`.$uses({
107+
particleData: particleDataStorage,
108+
deltaTime,
109+
time,
110+
}),
111+
[],
112+
);
113+
114+
// pipelines
115+
//
116+
const renderPipeline = useMemo(
117+
() =>
118+
root
119+
.createRenderPipeline({
120+
attribs: {
121+
...geometryLayout.attrib,
122+
center: dataLayout.attrib.position,
123+
},
124+
vertex: tgpu.vertexFn({
125+
in: {
126+
tilt: d.f32,
127+
angle: d.f32,
128+
color: d.vec4f,
129+
center: d.vec2f,
130+
index: d.builtin.vertexIndex,
131+
},
132+
out: VertexOutput,
133+
})((input) => {
134+
'use gpu';
135+
const width = input.tilt;
136+
const height = input.tilt / 2;
137+
138+
const pos =
139+
rotate(
140+
[d.vec2f(0, 0), d.vec2f(width, 0), d.vec2f(0, height), d.vec2f(width, height)][
141+
input.index
142+
] / 350,
143+
input.angle,
144+
) + input.center;
145+
146+
if (aspectRatio.$ < 1) {
147+
pos.x /= aspectRatio.$;
148+
} else {
149+
pos.y *= aspectRatio.$;
150+
}
151+
152+
return { position: d.vec4f(pos, 0, 1), color: input.color };
153+
}),
154+
fragment: mainFrag,
155+
primitive: {
156+
topology: 'triangle-strip',
157+
},
158+
})
159+
.with(geometryLayout, particleGeometryBuffer)
160+
.with(dataLayout, particleDataBuffer),
161+
[mainFrag, particleGeometryBuffer, particleDataBuffer, aspectRatio],
162+
);
163+
164+
const computePipeline = root.createComputePipeline({ compute: mainCompute });
165+
166+
useFrame(({ deltaSeconds }) => {
167+
const context = ctxRef.current;
168+
if (!context) {
169+
return;
170+
}
171+
172+
const canvas = context.canvas as HTMLCanvasElement;
173+
deltaTime.value = deltaSeconds * 1000;
174+
aspectRatio.value = canvas.width / canvas.height;
175+
176+
computePipeline.dispatchWorkgroups(PARTICLE_AMOUNT);
177+
renderPipeline.withColorAttachment({ view: context }).draw(4, PARTICLE_AMOUNT);
178+
});
179+
180+
const canvasRefCallback = useCallback((el: HTMLCanvasElement | null) => {
181+
if (el) {
182+
ctxRef.current = root.configureContext({ canvas: el, alphaMode: 'premultiplied' });
183+
} else {
184+
ctxRef.current = null;
185+
}
186+
}, []);
187+
188+
return (
189+
<div>
190+
<canvas ref={canvasRefCallback}></canvas>
191+
<button type="button" onClick={() => particleDataBuffer.write(createRandomPositions())}>
192+
🎉
193+
</button>
194+
</div>
195+
);
196+
}
197+
198+
// example controls and cleanup
199+
200+
// #region Example controls and cleanup
201+
202+
import { createRoot } from 'react-dom/client';
203+
import { useCallback, useMemo, useRef } from 'react';
204+
const reactRoot = createRoot(document.getElementById('example-app') as HTMLDivElement);
205+
reactRoot.render(<App />);
206+
207+
export function onCleanup() {
208+
setTimeout(() => reactRoot.unmount(), 0);
209+
}
210+
211+
// #endregion
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"title": "React: Confetti",
3+
"category": "react",
4+
"tags": ["react", "compute", "instancing"]
5+
}
112 KB
Loading
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"title": "React: Spinning Triangle",
33
"category": "react",
4-
"tags": ["experimental"]
4+
"tags": ["react"]
55
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
export { useRoot } from './root-context.tsx';
12
export { useFrame } from './use-frame.ts';
23
export { useRender } from './use-render.ts';
4+
export { useMutable } from './use-mutable.ts';
35
export { useUniformValue } from './use-uniform-value.ts';
6+
export { useBuffer } from './use-buffer.ts';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type * as d from 'typegpu/data';
2+
import { useRoot } from './root-context.tsx';
3+
import { useEffect, useRef, useState } from 'react';
4+
import type { TgpuBuffer, ValidateBufferSchema } from 'typegpu';
5+
6+
// TODO: Recreate the buffer when the schema changes
7+
export function useBuffer<TSchema extends d.AnyWgslData>(
8+
schema: ValidateBufferSchema<TSchema>,
9+
initialValue?: (() => d.Infer<NoInfer<TSchema>>) | d.Infer<NoInfer<TSchema>>,
10+
): TgpuBuffer<TSchema> {
11+
const root = useRoot();
12+
13+
const [buffer] = useState(() => {
14+
return root.createBuffer(
15+
schema,
16+
typeof initialValue === 'function'
17+
? (initialValue as () => d.Infer<NoInfer<TSchema>>)()
18+
: initialValue,
19+
);
20+
});
21+
22+
const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);
23+
useEffect(() => {
24+
if (cleanupRef.current) {
25+
clearTimeout(cleanupRef.current);
26+
}
27+
28+
return () => {
29+
cleanupRef.current = setTimeout(() => {
30+
buffer.buffer.destroy();
31+
}, 200);
32+
};
33+
}, [buffer]);
34+
35+
return buffer;
36+
}

packages/typegpu-react/src/use-frame.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { useEffect, useRef } from 'react';
22

3-
export function useFrame(cb: () => void) {
3+
interface FrameCtx {
4+
readonly deltaSeconds: number;
5+
}
6+
7+
export function useFrame(cb: (ctx: FrameCtx) => void) {
48
const latestCb = useRef(cb);
59

610
useEffect(() => {
@@ -9,10 +13,17 @@ export function useFrame(cb: () => void) {
913

1014
useEffect(() => {
1115
let frameId: number | undefined;
16+
let lastTime: number | undefined;
1217

1318
const loop = () => {
1419
frameId = requestAnimationFrame(loop);
15-
latestCb.current();
20+
21+
const now = performance.now();
22+
if (lastTime === undefined) {
23+
lastTime = now;
24+
}
25+
latestCb.current({ deltaSeconds: (now - lastTime) / 1000 });
26+
lastTime = now;
1627
};
1728

1829
loop();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useRoot } from './root-context.tsx';
2+
import { useEffect, useRef, useState } from 'react';
3+
import type { TgpuMutable, ValidateStorageSchema, d } from 'typegpu';
4+
5+
export function useMutable<TSchema extends d.AnyWgslData>(
6+
schema: ValidateStorageSchema<TSchema>,
7+
initialValue?: d.Infer<TSchema>,
8+
): TgpuMutable<TSchema> {
9+
const root = useRoot();
10+
11+
const [mutable] = useState(() => {
12+
return root.createMutable(schema, initialValue);
13+
});
14+
15+
const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16+
useEffect(() => {
17+
if (cleanupRef.current) {
18+
clearTimeout(cleanupRef.current);
19+
}
20+
21+
return () => {
22+
cleanupRef.current = setTimeout(() => {
23+
mutable.buffer.destroy();
24+
}, 200);
25+
};
26+
}, [mutable]);
27+
28+
return mutable;
29+
}

packages/typegpu-react/src/use-uniform-value.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export function useUniformValue<TSchema extends d.AnyWgslData, TValue extends d.
4242
};
4343
}, [uniformBuffer]);
4444

45-
// biome-ignore lint/correctness/useExhaustiveDependencies: This value needs to be stable
4645
const uniformValue = useMemo(() => {
4746
let currentValue = initialValue ?? (initialValueFromSchema(schema) as TValue);
4847
return {

0 commit comments

Comments
 (0)