October 7, 2026
Nearly every guide to shader effects starts the same way: an empty file and a
lecture on gl_FragColor. That is a fine way to learn the language and a
slow way to learn what a good effect actually looks like in code. The faster route
is to start from an effect that already works — a liquid hero, a noise-warped
image, a scroll-driven dissolve on a site you admire — and take it apart until
you understand every line. This is the workflow we use, in the order we use it.
Step 1: confirm it is really WebGL
Plenty of “shader-looking” effects are not shaders at all. A blurred,
animated gradient can be pure CSS; a displacement ripple can be an SVG
feTurbulence filter; a particle field can be a 2D canvas. Open DevTools,
find the <canvas> element, and check whether it holds a WebGL
context (canvas.getContext('webgl2') returns the existing context
rather than null if it was created that way). No canvas, or a 2D
context, means you are reading the wrong chapter.
It also pays to check that the effect is active. A shader sitting in a bundle proves it exists, not that it runs. Scroll, hover and wait before you decide which program on the page is the one you are looking at.
Step 2: capture the shader source
By hand, you can use a WebGL inspector such as Spector.js to record a frame and list every program, its vertex and fragment shaders, its uniforms and its draw calls. It works, but sites that compile many programs, or load shaders as minified strings inside a bundle, make this a slog.
This is the job reverse_engineer_webgl does in a single call: it
loads the page in a real browser, lets the canvas draw, and reports the programs
that actually ran — shader source, uniform names and values, draw-call
counts — instead of whatever happens to be in the JavaScript. If the effect
only appears after scrolling or a click, drive the page first with
interact_with_page, then capture. For the wider walkthrough of
picking the right tool for each question, see
how to reverse-engineer a live site with Enrichr, step by step.
Step 3: read the shader like a recipe
Once you have the source, resist the urge to paste it into your project. Read it in three passes.
- The vertex shader is usually boring: it passes UVs along and places a quad. If it is not boring (displaced geometry, instancing), the effect lives there rather than in the fragment shader.
- The uniforms are the effect's knobs. Names like
uTime,uMouse,uProgress,uResolutionanduTexturetell you what the effect reacts to before you read a single line of math. - The fragment shader is the look. Find the noise function
(value, simplex or fbm), find where it perturbs the UVs or the colour, and find
the final
mix()between palette colours. Nearly every award-site effect reduces to “noise, warp the coordinates, mix some colours.”
Step 4: find what drives the uniforms
The shader only describes how to draw one frame. What makes it feel alive is
whatever writes to the uniforms between frames: elapsed time, pointer position,
or a scroll-scrubbed progress value. In the sites we capture, that last one is
usually GSAP and ScrollTrigger changing a plain JavaScript number while the render
loop copies it into the shader — the split we describe in
Three.js and GSAP: the two libraries behind almost every award-site hero.
Run reverse_engineer_animations against the same URL and you get the
other half of the bridge: the trigger, the scrub value, the easing.
Step 5: rebuild it in a minimal harness
Start with the smallest scene that can show the effect: one full-screen quad,
one ShaderMaterial, no lights, no camera tricks. Here is a stripped
down version of the pattern — a noise-warped gradient with time and pointer
uniforms — that you can swap the captured source into.
const uniforms = {
uTime: { value: 0 },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}`,
fragmentShader: `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec2 uMouse;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
void main() {
vec2 uv = vUv;
float n = noise(uv * 4.0 + uTime * 0.15);
uv += (n - 0.5) * 0.08 + (uMouse - 0.5) * 0.02;
vec3 col = mix(vec3(0.05, 0.04, 0.12), vec3(0.55, 0.35, 0.95), noise(uv * 3.0 + n));
gl_FragColor = vec4(col, 1.0);
}`,
});
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
renderer.setAnimationLoop((t) => {
uniforms.uTime.value = t * 0.001;
renderer.render(scene, camera);
});
Get the static frame matching first, with time frozen at the captured value. Only then wire up pointer and scroll. Debugging a shader and its motion at the same time is how an afternoon disappears.
Step 6: verify against the original
“Looks about right” is not a test. Take a screenshot of the original and of your rebuild at the same viewport size and the same uniform values, and compare them side by side. The usual reasons a faithful port still looks off:
- Colour space. Three.js converts output to sRGB by default; a raw shader that expects linear values will look washed out or too dark. Match the original’s output setting before you touch the maths.
- Pixel ratio. A shader that works in pixel coordinates behaves
differently at
devicePixelRatio1 and 2. Clamp it the same way the original does. - Post-processing. The effect you admire might be a bloom or grain pass layered on top of a plain shader. Check the draw-call list for extra render targets and full-screen passes.
- Textures. A uniform called
uTexturemeans an image you do not have yet. Substitute your own and expect the result to differ accordingly.
Where to go from here
Once one effect makes sense, the next is much faster, because the vocabulary
repeats: noise, warped coordinates, a handful of uniforms, one driver. If you want
the bigger picture of why these effects cluster on the same few libraries, read
building the special effects: shaders, scroll-linked motion, and the other 10%,
and if you want to recover a whole 3D scene rather than a single shader, the
extract_scene_graph tool is built for that. Prefer to browse before you
capture? The Enrichr gallery has real captures to start from.