|
| 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 |
0 commit comments