Shaders and GPU-parallel visuals
Learning objectives
- learner can explain the shader-as-pixel-color-function model and GPU massively-parallel execution
- learner can write p5.js filter shaders that post-process the canvas via framebuffer textures
- learner can work in normalised shader coordinate spaces and distribute results across RGB channels
Capstone — one whole task that evidences the objectives
Write a GPU post-processing shader pipeline: a p5.js filter shader that reads the canvas through a framebuffer and applies a per-pixel effect authored in a normalised coordinate space with RGB-channel distribution.
Prerequisite modules
In a live AV set, the moment your visuals need bloom, chromatic aberration, or audio-reactive distortion over the whole frame, CPU pixel loops die — at club resolution and 60fps, only the GPU keeps up. This module builds toward writing your own post-processing pipeline: a p5.js filter shader that treats the finished canvas as a texture and re-colors every pixel in parallel, the same machinery Hydra compiles your chains into behind the scenes.
The arc starts conceptually inside tools you already know from the prereq modules. First, internalise that a whole Hydra chain compiles to one GPU shader deciding every pixel’s color simultaneously — that mental model (“what color should this pixel be?”) is what you’ll be authoring directly. Then a supported first exercise: recreate a simple effect in Punctual, leaning on its normalised centre-origin coordinate space and on distributing a list of values across red, green, and blue channels — the exact coordinate and channel habits fragment shaders demand, in a forgiving live environment. From there, move to p5.js: use the filter-shader recipe (tex0, vTexCoord, gl_FragColor) as your JIT how-to for reading the canvas per-pixel, and the framebuffer-as-GPU-texture concept to understand why the canvas can be sampled without leaving the GPU. One convention to watch as you make that move: p5.js filter shaders hand you vTexCoord in a 0-to-1 range with the origin at a corner, whereas Punctual’s warm-up space runs -1 to 1 with (0,0) at the centre — the normalised-coordinate habit transfers, but the range and origin differ. The capstone is then unsupported: author the full pipeline yourself.
The five required atoms gate the capstone directly — without the parallel-execution model, the filter-shader mechanics, the framebuffer texture path, normalised coordinates, or RGB distribution, the pipeline either won’t run or won’t be authored idiomatically. Supporting atoms enrich by contrast: Processing’s CPU-side off-screen layers show what the GPU path replaces, and cables.gl shows the same WebGL machinery patched visually. Drill the tex0/vTexCoord skeleton until it’s automatic — it recurs in every filter shader you’ll ever write.
Walkthrough
A shader runs the same tiny program on every pixel at once — that’s the GPU’s power and its constraint. You’ll build a per-pixel post-processing pipeline: take a source image and grade, offset, and vignette it in one pass. Paste each into The Book of Shaders editor (u_time is the clock). Each is a complete GLSL ES 1.00 fragment shader. (In p5, the identical main() code runs as a filter() shader reading the canvas through a framebuffer — the host changes, the per-pixel logic doesn’t.)
1 — the per-pixel parallel model. Every pixel gets this program with one input: its own coordinate gl_FragCoord. No neighbours, no memory of other pixels — pure parallelism. Prove it by turning position straight into colour ([[hydra-shaders-gpu]]).
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution; // this pixel's normalised position
gl_FragColor = vec4(uv.x, uv.y, 0.5, 1.0); // x → red, y → green
}
2 — normalised coordinates, aspect-correct. Divide by resolution to get uv in 0..1 regardless of window size — the portable coordinate space every effect is authored in. Centre it and fix the aspect ratio so a circle stays round ([[punctual-coordinate-system]]).
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
uv -= 0.5; // origin at centre
uv.x *= u_resolution.x / u_resolution.y; // aspect-correct
float d = length(uv);
float c = smoothstep(0.4, 0.38, d); // a crisp round disc
gl_FragColor = vec4(vec3(c), 1.0);
}
3 — RGB channel distribution. The three colour channels are independent fields — drive each with a different spatial/temporal function and you author colour per-pixel instead of picking it. This channel-splitting is the heart of shader colour ([[punctual-rgb-channel-distribution]]).
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float r = uv.x;
float g = uv.y;
float b = 0.5 + 0.5 * sin(u_time + uv.x * 6.0); // each channel, its own function
gl_FragColor = vec4(r, g, b, 1.0);
}
4 — a post-processing pass (vignette). Post-processing means: given a source colour at this pixel, transform it. Here a procedural striped source, darkened toward the edges by a smoothstep of distance — the filter-shader pattern, minus the framebuffer plumbing ([[p5js-filter-shader]]).
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec3 src = vec3(0.5 + 0.5 * sin(uv.x * 30.0 + u_time), 0.4, 0.7); // the "source" pixel
float vig = smoothstep(0.8, 0.2, length(uv - 0.5)); // darken the edges
gl_FragColor = vec4(src * vig, 1.0);
}
5 — sampling at offset coordinates. Reading the source at shifted coordinates per channel gives chromatic aberration — the move behind glitch, refraction, and lens effects. A helper srcTex(uv) stands in for the framebuffer texture you’d sample in p5 ([[p5js-framebuffer-gpu-texture]]).
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 srcTex(vec2 uv) {
return vec3(0.5 + 0.5 * sin(uv.x * 20.0),
0.5 + 0.5 * cos(uv.y * 20.0 + u_time), 0.6);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float o = 0.008;
float r = srcTex(uv + vec2(o, 0.0)).r; // sample each channel at a different offset
float g = srcTex(uv).g;
float b = srcTex(uv - vec2(o, 0.0)).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
6 — the full pipeline (the capstone). Chain three post-process stages on one source: chromatic channel offset, a colour grade (gamma + a teal tint), and a vignette. This is a complete filter shader — drop the same main() into a p5 filter() and it grades your live canvas ([[p5js-filter-shader]]):
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 srcTex(vec2 uv) { // the "canvas" being post-processed
float w = sin(uv.x * 24.0 + u_time) * sin(uv.y * 24.0 - u_time);
return vec3(0.5 + 0.5 * w, 0.3 + 0.3 * w, 0.7);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float o = 0.006; // 1) chromatic channel offset
vec3 col = vec3(srcTex(uv + vec2(o, 0.0)).r, srcTex(uv).g, srcTex(uv - vec2(o, 0.0)).b);
col = pow(col, vec3(0.8)); // 2) grade: gamma lift...
col *= vec3(0.9, 1.05, 1.1); // ...and a teal tint
float vig = smoothstep(0.9, 0.25, length(uv - 0.5)); // 3) vignette
gl_FragColor = vec4(col * vig, 1.0);
}
What good looks like. A post-processing pass should serve the source, not bury it — the grade should feel like a mood (warmer, cooler, crushed) while the underlying image stays legible; the aberration should whisper at the edges, not scream. The beginner failure is stacking effects at full strength until the source disappears under grade and glitch. Pull each offset/tint toward subtlety, and always keep the un-graded version to compare. (Skill map: live-visualist Domain A3 — GLSL as the control layer; B2/B4 — grading and contrast as mood.)
Now make it yours. Animate the aberration offset o with u_time so it pulses. Replace srcTex with an actual sampler2D in p5 and grade your real canvas. Add a scanline term (sin(uv.y * u_resolution.y)). Drive the vignette radius from an audio band. Swap the teal tint for your own signature grade.
Atoms in this module
Required — these gate the capstone
Supporting — enrichment, not gating
Part of curricula
- Live Visualist — zero to performing live-coded & generative visuals — Reactive & procedural — make it listen, and go to the GPU required
Unlocks — modules that require this one