One UBO, seven scenes: architecting a WebGPU scene switcher
A single shared uniform layout plus a small Scene type that lets fullscreen-triangle scenes and a GPU compute scene coexist in one component — and how a stable byte layout let me add scenes for weeks without refactoring.
The /lab page started as one metaballs shader. It is now seven scenes — metaballs, a Mandelbulb, a tunnel, a 25k-particle compute simulation, a reaction-diffusion field, an audio spectrum analyzer, and a hidden hyperspace warp. They share one component, one uniform buffer, and one render loop. Adding each new scene was mostly writing WGSL, not touching the host. This post is about the small amount of structure that made that possible.
The Scene type
Every scene is described by one object. The type is deliberately small, with most fields optional so the common case stays trivial:
export type Scene = {
id: SceneId;
label: string;
shader: string;
knob: { label: string; min: number; max: number; defaultValue: number };
compute?: {
entry: string;
particleCount: number;
stride: number;
workgroupSize: number;
vertsPerParticle: number;
};
needsSpectrum?: boolean;
hidden?: boolean;
};A plain fullscreen-triangle scene is just id, label, shader, and a knob descriptor. The three optional fields are the entire vocabulary for the exceptions: compute marks a scene that runs a GPU simulation, needsSpectrum marks one that reads an FFT buffer, and hidden keeps the secret scene out of the tab row until it is unlocked. The full registry is a single readonly array; the component never special-cases a scene by name, only by these flags.
The shared uniform header
Every shader, including the compute one, declares the identical uniform struct. It is defined once as a string constant and prepended to each fullscreen shader:
struct Uniforms {
resolution: vec2f,
time: f32,
mouse: vec2f,
tintA: vec3f,
tintB: vec3f,
tintC: vec3f,
knob: f32,
clicks: vec4f,
audio: vec4f,
zoom4: vec4f,
}This is the contract. The host fills one Float32Array, writes it to one buffer, and binds that buffer at @group(0) @binding(0) for every scene. Resolution and time and mouse are obvious; the three tints come from the active theme so every scene re-colors when you switch themes; knob is whatever the per-scene slider controls; clicks carries a decaying tap pulse; audio carries bass/mid/high/level from the mic; zoom4 carries the wheel-or-pinch zoom in its first lane with the rest reserved. Because the layout is identical everywhere, the host packs the buffer the same way regardless of which scene is active — there is no per-scene uniform code at all.
Appending fields at the end, always
The single most important discipline here is boring: new uniform fields go at the end of the struct, never in the middle. The buffer grew across features — it was 80 bytes when there were just tints and a knob, then 112 once clicks and audio were added, then 128 when zoom4 arrived. Every one of those growths was purely additive.
This matters because of how the host writes the buffer — by hardcoded float offsets into the array:
uboData[19] = knobRef.current;
uboData[20] = clkX; // clicks.x
uboData[24] = bass; // audio.x
uboData[28] = zoomRef.current; // zoom4.xIf I had inserted a field in the middle of the struct, every offset after it would shift and every existing scene would silently read garbage — tints showing up where the time used to be. By only ever appending, the offsets for existing fields never move, old shaders keep working untouched, and a new scene either uses the new field or ignores it. The byte layout follows WGSL's std140-ish 16-byte alignment rules (vec3 padding included), which is why the comment block documenting the offsets lives right next to the buffer creation.
Three branches in one component
The render loop is shared, but pipeline construction forks three ways based on the Scene flags. The default and simplest is the fullscreen-triangle path: layout: "auto", a vertex entry that emits one big triangle, a fragment entry, and a bind group with just the UBO. Five of the seven scenes take this path verbatim.
The compute branch (only the particles scene) builds an explicit bind-group layout with the UBO at binding 0 and a storage buffer at binding 1, creates a separate compute pipeline alongside the render pipeline, and binds the same particle buffer as a per-instance vertex buffer for the draw. Its render pass is preceded by a compute pass each frame.
The spectrum branch is a superset of the fullscreen path: still a fullscreen triangle, but with an explicit layout that adds a read-only storage buffer at binding 1. In WGSL that binding is declared as a fixed-length float array of the bin count — the kind of generic storage declaration that has to live in the shader, not in prose. The host allocates that buffer only for scenes flagged needsSpectrum, so the other six are completely untouched by its existence.
The branching reads cleanly because the flags map one-to-one: scene.compute picks branch two, scene.needsSpectrum picks branch three, neither picks the default. No scene names appear in the conditionals.
Rebuild on change, live refs on tick
GPU pipelines are immutable, so anything that changes the pipeline — switching scenes, switching themes, hitting reset — tears down and rebuilds everything in a single effect keyed on those dependencies. That is correct but expensive, so it must happen rarely.
The things that change every frame must not trigger a rebuild. The knob slider, the zoom value, and the paused flag are all read through refs inside the render loop rather than from React state:
const knobRef = useRef(knob);
useEffect(() => { knobRef.current = knob; }, [knob]);Dragging the slider updates knobRef.current and the next frame picks it up from the uniform write — zero pipeline churn. The same pattern covers zoom (wheel and pinch) and pause. The result is a clean split: structural changes rebuild, per-frame changes flow through refs. Without it, every slider tick would destroy and recreate a GPU pipeline 60 times a second.
The lesson
None of this is clever. It is a 9-field struct, a 7-field type, a rule about appending, and a refs-versus-rebuild discipline. But that small amount of contract is exactly what let the lab grow from one shader to seven over several weeks of evenings without a single refactor of the host component. Every new scene was a new WGSL string and one entry in an array. A stable uniform layout and a tiny descriptor type buy you that — the boring structure is the whole point.
See all seven scenes at /lab.