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>.
| Prop | Type | Default | What it does |
|---|---|---|---|
preset | 'fire', 'sparks', 'smoke', 'magic', 'rain', 'snow', 'dust', 'puff', 'vortex' | 'fire' | The emitter to start from |
count | number | 256 | How many live at once |
origin | [x, y, z] | [0, 0, 0] | Where they spawn, world space |
follow | RefObject<Object3D> | none | Spawn at this object's world position instead (a car's exhaust, a glow around the player) |
emitting | boolean | true | false stops new particles; the live ones finish and the system stays warm |
config | EmitterConfig | none | A whole emitter instead of a preset plus overrides |
timeScale | number | 1 | Simulation speed |
any EmitterConfig field | from preset | Overrides, below |
EmitterConfig fields (sizes in metres, speeds in m/s):
| Field | What it is |
|---|---|
spread | Half size of the spawn box around the origin, [x, y, z] |
velocity, jitter | Initial velocity and its random share |
gravity | [x, y, z] acceleration |
drag | Velocity damping per second (0 none, 1 strong) |
swirl | Sideways swirl around the emitter's up axis |
life, lifeJitter | Seconds a particle lives, and the random share |
sizeStart, sizeEnd | Size over life |
stretch | Height over width (rain streaks, sparks) |
colorA, colorB | Colour over life |
additive | Additive (glows) or normal blending (smoke, rain) |
opacity | Peak 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.
| Prop | Type | Default | What it does |
|---|---|---|---|
follow | RefObject<Object3D> | required | What leaves the trail |
length | number | 48 | Points kept |
width | number | 0.3 | Width at the head, metres |
color | string | '#FF4FD8' | |
orientation | 'vertical', 'flat', 'camera' | 'vertical' | A wall, a line on the ground, or a ribbon that faces the camera |
offsetY | number | 0 | Lift above the follow point |
taper | boolean | true | Thin toward the tail |
additive | boolean | true | Glow or plain |
minDistance | number | 0.08 | A new point only after moving this far |
opacity | number | 0.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 prop | Type | Default | What it does |
|---|---|---|---|
area | { x?, z?, w, d } | required | The rectangle the blades cover, centre and size in metres |
count | number | 3000 | Blades: 1,000 for a yard, 3,000 to 6,000 for a field |
height | number | 0.5 | Blade height |
width | number | 0.09 | Blade width at the root |
colorA, colorB | string | '#4E9A3C', '#C9C35A' | Body and tip colours |
follow | () => ({ x, z }) | none | Follow 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 |
tile | number | 24 | The follow tile's width |
clearings | number | 0.35 | How much ground is open between tufts (0 to 1) |
wind | number | 1 | 0 still, 1 breezy, 2 stormy |
windDir | [x, z] | [1, 0.3] | Wind direction |
mask | (x, z) => boolean | none | Grow only where this returns true (not on paths or water) |
maskKey | string | '' | Change it to re-scatter after the mask changes |
trample | () => ({ x, z }) or null | none | Blades lean away from this point |
trampleRadius | number | 1.3 | |
seed | number | 3 | Scatter seed |
receiveShadow | boolean | true |
LivelyGround prop | Type | Default | What it does |
|---|---|---|---|
x, z | number | 0 | Centre |
w, d | number | required | Size in metres |
base | string | required | The main green |
light | string | a lighter base | The second green the patches drift to |
y | number | 0.004 | Height of the plane, a hair above a plain ground |
receiveShadow | boolean | true |
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} />;
};
| Prop | Type | Default | What it does |
|---|---|---|---|
x, z | number | 0 | Centre |
w, d | number | required | Size in metres |
y | number | 0.02 | Surface height |
color | string | required | The water's colour; the other tones are derived from it |
deep, shallow, foam | string | derived | Override a derived tone |
speed | number | 1 | Drift speed of the patches and lines |
scale | number | 1 | Size of the patches and lines |
waves | number | 0.04 | Slow heave height, metres |
foamWidth | number | 0.06 | Foam band as a fraction of the shorter side (0 none) |
opacity | number | 0.94 | |
style | 'toon' or 'soft' | 'toon' | Hard ripple lines and sparkles, or smooth highlights |
ripples | boolean | false | The ripple simulation (one compute pass a frame) |
drops | number | 2 | Raindrop splashes per second, with ripples |
rippleHeight | number | 0.12 | Metres |
disturb | () => ({ x, z, strength? }) or null | none | A moving source each frame (a wading hero) |
simulate | boolean | true | Debug: keep the ripple attribute but never run the compute shader |
receiveShadow | boolean | true |
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.
| Prop | Type | Default | What it does |
|---|---|---|---|
energy | SharedValue<number> | required | 0 to 1, how energetic it animates |
colorA, colorB | [r, g, b] (0 to 1) | violet, teal | The gradient |
style | ViewProps['style'] | height 28 | |
testID | string | none |
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:
| Export | What 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:
- Make a three
StorageInstancedBufferAttribute(orStorageBufferAttribute, both fromthree/webgpu) and draw from it with a node material (instancedBufferAttribute(attr)in TSL). - In
useFrame, pollattributeGpuBuffer(renderer, attr)until it is notnull, then wrap it:root.createBuffer(d.arrayOf(d.vec4f, n), gpuBuffer).$usage('storage').as('mutable'). - 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 withpipeline.dispatchWorkgroups(Math.ceil(n / 64)). AnyuseFrameruns 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 throughtypegpu/std(std.add,std.mul,std.mix), scalars use plain operators; aletyou reassign must hold a copy (let pos = d.vec3f(buf.$[i].pos)); guard the index against the count. d.structfield names must not be WGSL reserved words (active,filter,type,target,selfand others). TypeGPU throws when the struct is built, at module load, so the whole game fails to open. Use names likealive,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).