Skip to main content

GPU effects

The GPU folder (src/engine/gpu/, exported from @/engine) holds effects that move thousands of things without JavaScript per thing. They all follow one split: three draws, TypeGPU simulates. A TypeGPU compute shader, written in TypeScript with the 'use gpu' directive, fills buffers that three's WebGPURenderer draws from in the same frame, on the same GPU device.

Use these for decoration that reacts to the game: fire, sparks, rain, magic, trails, a meadow, water. Gameplay never reads GPU state back: collisions and scoring stay in JavaScript.

Smallest working example​

A campfire: fire, smoke above it and sparks you can burst on demand.

import React, { useRef } from 'react';
import { EngineCanvas, Environment, Particles, type ParticlesHandle } from '@/engine';

export const Campfire = () => {
const sparks = useRef<ParticlesHandle>(null);
// from gameplay: sparks.current?.burst(60, [0, 0.3, 0]);
return (
<EngineCanvas camera={{ position: [0, 2, 4] }}>
<Environment preset="night" />
<Particles preset="fire" count={300} origin={[0, 0.1, 0]} />
<Particles preset="smoke" count={100} origin={[0, 0.9, 0]} />
<Particles ref={sparks} preset="sparks" count={300} emitting={false} ground={{ y: 0, bounce: 0.45 }} />
<pointLight position={[0, 0.6, 0]} color="#FFB454" intensity={4} distance={6} />
</EngineCanvas>
);
};

These components must be inside EngineCanvas: they read the renderer and its device.

Particles​

Particles are camera-facing soft discs (or streaks), drawn as instanced sprites from two storage attributes the compute shader writes. One draw call per <Particles>.

PropTypeDefaultWhat it does
preset'fire', 'sparks', 'smoke', 'magic', 'rain', 'snow', 'dust', 'puff', 'vortex''fire'The emitter to start from
countnumber256How many live at once
origin[x, y, z][0, 0, 0]Where they spawn, world space
followRefObject<Object3D>noneSpawn at this object's world position instead (a car's exhaust, a glow around the player)
emittingbooleantruefalse stops new particles; the live ones finish and the system stays warm
configEmitterConfignoneA whole emitter instead of a preset plus overrides
timeScalenumber1Simulation speed
any EmitterConfig fieldfrom presetOverrides, below

EmitterConfig fields (sizes in metres, speeds in m/s):

FieldWhat it is
spreadHalf size of the spawn box around the origin, [x, y, z]
velocity, jitterInitial velocity and its random share
gravity[x, y, z] acceleration
dragVelocity damping per second (0 none, 1 strong)
swirlSideways swirl around the emitter's up axis
life, lifeJitterSeconds a particle lives, and the random share
sizeStart, sizeEndSize over life
stretchHeight over width (rain streaks, sparks)
colorA, colorBColour over life
additiveAdditive (glows) or normal blending (smoke, rain)
opacityPeak opacity
ground{ y, bounce?, friction? }: a floor at height y. bounce above 0 bounces (sparks); 0 lands them as fading dots (rain splashes)
attractor{ at?, strength?, vortex? }: pull toward a point (default the origin; negative pushes) and spin around it

The ref is a ParticlesHandle with burst(count, at?): respawn count particles this frame, from at when given. An emitting={false} system plus burst is a burst-only effect (hits, crashes, coins). puff is made for that: kicked-up dust on landings and footsteps. PARTICLE_PRESETS holds every preset's values.

<Particles preset="sparks" count={200} follow={carRef} emitting={boosting} />
<Particles preset="rain" count={2000} origin={[0, 8, 0]} spread={[12, 0.5, 12]} ground={{ y: 0 }} />
<Particles preset="vortex" count={80} origin={[3, 1.1, 2]} emitting={!taken} />

Trail​

A ribbon behind a moving object (a light wall behind a bike, a skid line, a sword's arc), thinning and fading toward the tail. One mesh, a few dozen points updated on the CPU each frame.

PropTypeDefaultWhat it does
followRefObject<Object3D>requiredWhat leaves the trail
lengthnumber48Points kept
widthnumber0.3Width at the head, metres
colorstring'#FF4FD8'
orientation'vertical', 'flat', 'camera''vertical'A wall, a line on the ground, or a ribbon that faces the camera
offsetYnumber0Lift above the follow point
taperbooleantrueThin toward the tail
additivebooleantrueGlow or plain
minDistancenumber0.08A new point only after moving this far
opacitynumber0.85

Grass and LivelyGround​

A meadow: thousands of blades swaying in a wind that travels across the field and parting around whoever walks through. A compute shader bends every blade each frame; one draw call for the blades. LivelyGround is the ground under it: soft colour patches, a fine speckle and a slow shimmer.

import React, { useRef } from 'react';
import { Grass, LivelyGround } from '@/engine';

export const Meadow = ({ player }: { player: React.RefObject<{ x: number; z: number }> }) => (
<>
<LivelyGround w={60} d={60} base="#5A9A3A" light="#6DAE44" />
<Grass
area={{ w: 60, d: 60 }}
count={6000}
height={0.55}
colorA="#5A9A2C"
colorB="#E6C24E"
trample={() => (player.current ? { x: player.current.x, z: player.current.z } : null)}
/>
</>
);
Grass propTypeDefaultWhat it does
area{ x?, z?, w, d }requiredThe rectangle the blades cover, centre and size in metres
countnumber3000Blades: 1,000 for a yard, 3,000 to 6,000 for a field
heightnumber0.5Blade height
widthnumber0.09Blade width at the root
colorA, colorBstring'#4E9A3C', '#C9C35A'Body and tip colours
follow() => ({ x, z })noneFollow mode: blades fill a tile square around this point and wrap as it moves, so a big field stays dense for the cost of one tile. area and mask are then ignored
tilenumber24The follow tile's width
clearingsnumber0.35How much ground is open between tufts (0 to 1)
windnumber10 still, 1 breezy, 2 stormy
windDir[x, z][1, 0.3]Wind direction
mask(x, z) => booleannoneGrow only where this returns true (not on paths or water)
maskKeystring''Change it to re-scatter after the mask changes
trample() => ({ x, z }) or nullnoneBlades lean away from this point
trampleRadiusnumber1.3
seednumber3Scatter seed
receiveShadowbooleantrue
LivelyGround propTypeDefaultWhat it does
x, znumber0Centre
w, dnumberrequiredSize in metres
basestringrequiredThe main green
lightstringa lighter baseThe second green the patches drift to
ynumber0.004Height of the plane, a hair above a plain ground
receiveShadowbooleantrue

Water​

Rivers, moats, ponds and sea: one plane with a stylised TSL material (drifting deep and shallow patches, toon ripple lines, sparkles, a foam band at the edges) and, when ripples is on, a 2D wave simulation in a TypeGPU compute shader (raindrops, splashes, a wake). No reflection or refraction pass, so it stays cheap on a phone. It lives in src/engine/render/Water.tsx and is exported from @/engine.

import React, { useRef } from 'react';
import { Water, type WaterHandle } from '@/engine';

export const Pond = () => {
const water = useRef<WaterHandle>(null);
// something fell in: water.current?.splash(2, -1);
return <Water ref={water} x={0} z={0} w={12} d={8} color="#2F7FA8" ripples drops={3} />;
};
PropTypeDefaultWhat it does
x, znumber0Centre
w, dnumberrequiredSize in metres
ynumber0.02Surface height
colorstringrequiredThe water's colour; the other tones are derived from it
deep, shallow, foamstringderivedOverride a derived tone
speednumber1Drift speed of the patches and lines
scalenumber1Size of the patches and lines
wavesnumber0.04Slow heave height, metres
foamWidthnumber0.06Foam band as a fraction of the shorter side (0 none)
opacitynumber0.94
style'toon' or 'soft''toon'Hard ripple lines and sparkles, or smooth highlights
ripplesbooleanfalseThe ripple simulation (one compute pass a frame)
dropsnumber2Raindrop splashes per second, with ripples
rippleHeightnumber0.12Metres
disturb() => ({ x, z, strength? }) or nullnoneA moving source each frame (a wading hero)
simulatebooleantrueDebug: keep the ripple attribute but never run the compute shader
receiveShadowbooleantrue

The ref is a WaterHandle with splash(x, z, strength?). The ripple grid is capped at 16,384 cells, so very large water gets coarser ripples.

GpuStrip​

A small HUD strip drawn by a TypeGPU fragment shader on its own WebGPU canvas (outside the 3D scene): moving stripes whose speed and glow follow energy. The racing HUD uses it as a reactive bar.

PropTypeDefaultWhat it does
energySharedValue<number>required0 to 1, how energetic it animates
colorA, colorB[r, g, b] (0 to 1)violet, tealThe gradient
styleViewProps['style']height 28
testIDstringnone

It wraps itself in EngineErrorBoundary and shows a flat bar if the GPU fails.

Your own compute shader​

For anything the presets do not cover (a flock, a cloth, a wave field), use the same split. The exports:

ExportWhat it does
useTypeGpuRoot()The TypeGPU root for the canvas's device (null before the renderer is ready). One per device, never destroyed: the device belongs to three
attributeGpuBuffer(renderer, attribute)The GPUBuffer three created for a buffer attribute, or null until three has drawn with it once
rendererDevice(renderer)The GPUDevice behind a WebGPURenderer
typeGpuRootFor(device)The root for a device, created once

The steps, as Particles.tsx does them:

  1. Make a three StorageInstancedBufferAttribute (or StorageBufferAttribute, both from three/webgpu) and draw from it with a node material (instancedBufferAttribute(attr) in TSL).
  2. In useFrame, poll attributeGpuBuffer(renderer, attr) until it is not null, then wrap it: root.createBuffer(d.arrayOf(d.vec4f, n), gpuBuffer).$usage('storage').as('mutable').
  3. Write the step with tgpu.computeFn({ in: { gid: d.builtin.globalInvocationId }, workgroupSize: [64] }), a function whose body starts with 'use gpu', and dispatch it each frame with pipeline.dispatchWorkgroups(Math.ceil(n / 64)). Any useFrame runs before three renders.
import tgpu, { d } from 'typegpu';
import * as std from 'typegpu/std';
import { attributeGpuBuffer, useTypeGpuRoot } from '@/engine';

The 'use gpu' functions are compiled to WGSL at build time by unplugin-typegpu/babel, which the template's babel.config.js already loads.

Gotchas​

  • Rules for 'use gpu' code, or TypeGPU rejects the shader and the effect goes dark with an [engine/gpu] warning: vector maths goes through typegpu/std (std.add, std.mul, std.mix), scalars use plain operators; a let you reassign must hold a copy (let pos = d.vec3f(buf.$[i].pos)); guard the index against the count.
  • d.struct field names must not be WGSL reserved words (active, filter, type, target, self and others). TypeGPU throws when the struct is built, at module load, so the whole game fails to open. Use names like alive, mode, kind.
  • Never call root.destroy(): the device belongs to three. Destroy your own buffers on unmount.
  • Budget: a few hundred particles for a local effect, 1,000 to 3,000 for weather. Each <Particles> is one draw call, so prefer one bigger system over many small ones.
  • <PostFX preset="glow" /> makes additive particles and emissive materials bloom (Canvas and rendering).
  • In development, particle systems add a line to the debug overlay (Utilities).