Performance
What each shader costs, the two tiers every shader draws at, and the knobs that decide it.
Cost classes
Each shader is tagged light, medium or heavy in the registry.
- light: a handful of noise or trigonometry samples per pixel. Plasma, Truchet, Halftone, Dither, Neon Grid, Film Grain, Mesh Gradient, Louvres.
- medium: a few dozen samples, or a 3×3 neighbourhood loop. Aurora, Voronoi, Starfield, Ripple, Kaleidoscope, Silk, Bokeh, Caustics.
- heavy: a raymarch or an iteration loop. Tunnel, Julia, Liquid Metal.
The simulation family is its own case: Particles, Fluid and Cloth run compute
passes before every draw, so their cost tracks the simulation rather than the
pixels. A small card pays most of what a hero pays; play="visible" (the
default) is what keeps a page of them affordable, because a simulation that is
off screen does not step at all. The particle fields (Chladni, Dunes, Maelstrom,
Ferro, Pulsar) go further: each frame is a render graph of several passes, with
an instanced particle draw into an HDR target and a bloom chain on top, so
treat them as heavy and give them the same play="visible" discipline.
Blueprint is a render graph too, but a cheaper one: its measuring and its
distance field run on small fixed-size textures, so what scales with the
canvas is one plate pass and the bloom chain. Filament, Spectra, Shafts and
Gossamer are cheaper still: one scene pass and the same chain. Billow is that one pass as
a raymarch, so treat it as heavy. Alloy and Basalt rasterise geometry into an
antialiased HDR target and follow it with depth of field, so treat them as heavy too.
A heavy shader fullscreen at DPR 2 on a 27-inch display is real work. The same shader in a 300 px card is nothing.
Quality tiers
Every shader draws at one of two tiers. High is the picture as designed. Low clamps the device pixel ratio to 1 and halves every loop the shader marked as cost rather than look: march steps, noise octaves, blur taps, the rays a light shader fans out. The picture is the same picture, softer. A shader with no such loop still gets the pixel saving, so all shaders support both tiers.
import { setQualityTier } from "shadertown";
setQualityTier("low"); // every canvas on the page, liveThe tier is one setting for the whole page, because the thing it describes is
the reader's device, which every canvas shares. setQualityTier takes auto,
high or low and defaults to auto; qualityTier() reads it and
onQualityTier(listener) hears it change. A canvas that names its own tier
keeps it and ignores the page's:
<ShaderCanvas shader="tunnel" quality={{ tier: "high" }} />Auto starts on High and drops to Low once, if one of three advisory signals asks:
- The GPU. A software adapter starts on Low straight away. Everything else is looked up in detect-gpu's benchmark tables, loaded after the first frame is on screen: a blocklisted GPU, or one at tier 0 or 1 (under 30 frames a second in those tables), asks for Low. Phones are judged by their GPU like everything else, so an iPhone 14 keeps High. A GPU the tables do not know keeps High too.
- The battery. Discharging at 30% or less asks for Low.
- The frame rate. Two seconds of presented frames under 80% of the display's refresh rate asks for Low. Hidden tabs and idle gaps do not count.
Every signal is advisory: one that cannot be read keeps High. The benchmark
tables come from unpkg by default; to serve them from your own origin, copy
@pmndrs/detect-gpu/dist/benchmarks somewhere static and say where before the
first canvas mounts:
import { configureQuality } from "shadertown";
configureQuality({ benchmarksUrl: "/benchmarks" });
``` Auto never
climbs back, because a picture that flips between two resolutions reads as
broken. The verdict is page-wide and remembered, so a canvas that scrolls into
view after the drop starts on Low rather than dropping a beat later.
The swap is a new canvas surface and one uniform. No pipeline recompiles, and
the shared frame loop does not stop. To show a reader what their device is
actually drawing, read `onQualityChange`:
```tsx
<ShaderCanvas
shader="hills"
onQualityChange={({ preference, effective, reason }) => setLabel(effective)}
/>reason is initial or forced, or the signal that asked for Low:
device, battery or frame-health.
In your own WGSL, st_steps(n) is the helper: n on High, about half on
Low, never under three. Give it the bound of every loop whose length is price
rather than picture, and read st_low() where a step needs to grow to cover
the ground the missing steps would have.
The four knobs
play
The largest saving available, because it is the difference between drawing and not drawing.
<ShaderCanvas shader="tunnel" play="hover" />visible uses an IntersectionObserver; hover adds a pointer check. Both
stop the shared frame loop entirely when nothing on the page is animating.
Whatever you set here, a canvas does not compile its pipeline until it comes
within a screen of the viewport, and keeps it once it has. So a long page of
cards costs the first screenful on load rather than all of it, and scrolling
back up redraws rather than recompiles. Nothing to switch on: this is what
ShaderCanvas does.
dpr
<ShaderCanvas shader="julia" quality={{ dpr: [1, 1.25] }} />The cheapest way to make a heavy shader affordable on a retina display. Halving
the ceiling quarters the fragment count. For a background sitting behind
content, [1, 1.25] is usually indistinguishable from [1, 2]. On the Low
tier the ceiling is clamped to 1 whatever you set here.
fps
<ShaderCanvas shader="mesh-gradient" quality={{ fps: 30 }} />Slow, smooth shaders survive a 30 fps cap without looking wrong. Fast ones do not: Starfield at 30 fps stutters.
scale
Not a performance control on paper, but a large scale means more high-frequency
detail, which means more visible aliasing, which pushes you towards a higher DPR.
Turning scale down often buys back more than the DPR change costs.
What the runtime already does
- One
Gpucontext and one frame loop for the whole page. - The loop stops when every renderer is paused or off screen.
setParamswrites uniforms in place; it never rebuilds a pipeline.- Pipelines are pre-warmed against the canvas signature, so the first visible frame is not a compile stall.
- Bind groups are cached by resource identity, so rebinding the same surface is free.