This is the shader behind the hero section of this portfolio. Rain sliding down glass, a warm evening gradient, bokeh traffic lights drifting through fog, and an organic border that looks hand-painted rather than geometric. It is a single fragment shader, one function that runs for every pixel, sixty times a second. Everything you see comes from math: hash functions, noise, domain warping, and signed distance fields. Here is how I built it, layer by layer.
The fullscreen quad
A WebGL shader needs two programs. The vertex shader positions geometry. The fragment shader colours every pixel inside it. The simplest geometry that fills the screen is a fullscreen quad, four vertices at the corners of clip space.
Vertex shader: positions the quad and computes UV coordinates. Preview shows the UV gradient: red = x, green = y.
The line v_uv = a_position * 0.5 + 0.5 maps clip-space coordinates from [-1, 1] to [0, 1]. Now every pixel gets a UV: (0, 0) at the bottom-left, (1, 1) at the top-right. Colour a pixel with vec4(v_uv.x, v_uv.y, 0.0, 1.0) and you see a red-green gradient. That gradient is the canvas. Everything after this is functions of those two numbers.
Rain: the first distortion
Before any colour or noise, the shader warps the UV coordinates to simulate water droplets sliding down glass. Everything downstream, gradients, noise, lights, is seen through this distortion.
Raindrop generation, one drop per grid cell. Preview shows rain distortion on the gradient.
Each grid cell gets one raindrop. The cell ID is hashed with N31 to give each drop a unique offset, timing phase, and speed. The sliding motion comes from SawTooth, cosine self-modulation (cos(t + cos(t))) with harmonic overtones that pauses, accelerates, pauses again, like a real drop hesitating on glass. Three layers at different scales (1×, 1.4×, 2.4×) combine into the final offset, applied as distortedUV = uv - rainOffset * 0.5. The rain bends light rather than painting drops on top, which is why it feels like looking through a wet window.
Hash functions: cheap randomness
Shaders have no random(). Every pixel runs in parallel with no shared state, so I use hash functions, deterministic formulas that take a coordinate and return a number that looks random.
Two hash functions used throughout the shaders. Preview shows the per-pixel hash output as grayscale.
fract() keeps only the fractional part of a number, always between 0 and 1. Multiplying by large, irrational-looking constants and taking the fractional part scrambles the input enough that neighbouring pixels get completely different outputs, even though the function is perfectly deterministic. Same input, same output, every time. There are variants too: N31(float p) returns a vec3, three random values from one input, useful when a single seed needs to drive position, timing, and size simultaneously.
Value noise: smooth randomness
Hash gives sharp, per-pixel randomness. But organic textures need smooth variation. Value noise divides the canvas into a grid, hashes each corner, and interpolates between them.
Value noise: smooth interpolation between random grid corners. Preview shows the smooth noise output.
The critical line is f = f * f * (3.0 - 2.0 * f), the Hermite smoothstep polynomial. Linear interpolation would create visible seams at grid boundaries because the derivative jumps at each edge. Smoothstep has zero derivative at both ends, so transitions between cells are invisible. The mix() calls blend four corners, first along x, then along y, producing a smooth, continuous surface.
Fractional Brownian Motion: layering detail
One layer of noise is too uniform. Real textures have structure at multiple scales. FBM stacks multiple noise layers, each at higher frequency and lower amplitude.
FBM: stacking noise octaves for multi-scale detail. Preview shows 5 octaves of fractal noise.
Each iteration doubles the frequency (lacunarity) and halves the amplitude (gain). The first octave contributes broad shapes, the second adds medium detail, the third adds fine texture. The amplitudes converge to 1.0, a fractal, self-similar at every zoom level. The hero shader uses 4 octaves at a base frequency of 2.8 cycles across the canvas.
Domain warping: making noise move
Static noise looks frozen. To make it feel like slowly shifting material, I warp the domain, use noise to distort the coordinates I sample noise at.
The complete domain warping pipeline. Preview shows flow, warp, and turbulence animating the gradient.
Four layers of movement stack here. Flow drifts coordinates in a constant direction. Warp samples FBM at the current position and uses the result as displacement, the fbm(p + fbm(p)) trick, feeding noise into itself. Turbulence adds faster, smaller-scale jitter. Swirl rotates coordinates proportional to distance from centre. The double-warp is what matters most: noise warping its own output produces fluid-like patterns without simulating any physics.
Colour gradients and blend modes
Three colour stops (u_color1, u_color2, u_color3) define the palette. A float gradientT in [0, 1] picks where you sit along the gradient. The shader computes it three ways:
Three blend modes for computing the gradient parameter. Preview shows the blob blend mode with warm colours.
Radial measures distance from centre, pure circles. Linear projects each point onto a direction vector via dot product. Blob adds FBM to the radial distance, warping circles into organic shapes. Warped noise offsets gradientT by ±0.1, breaking colour boundaries into organic transitions. The hero uses linear mode at 268 degrees with warm oranges fading to cream.
Film grain
Two layers of grain break colour banding and add photographic texture.
Dual-layer grain system. Preview shows grain overlaid on the base gradient.
The first layer rehashes every frame, producing sharp per-pixel static. * 2. - 1. maps [0, 1] to [-1, 1] so it both brightens and darkens. The second layer uses smooth noise at half the frequency, crawling slowly, a softer shimmer underneath. Together they act as dithering, preventing smooth gradients from showing visible banding on 8-bit displays.
Tanh tonemapping
After stacking noise and grain, colour values can exceed [0, 1]. Hard-clipping creates flat bright spots. Instead, the shader compresses with the hyperbolic tangent:
vec3 e2 = exp(2. * color * 1.1);
color = (e2 - 1.) / (e2 + 1.);
Tanh tonemapping
Dark values pass through nearly unchanged. Bright values compress toward 1.0 without reaching it, an S-curve. The key design choice: tonemapping happens before lights are added. The base gradient is compressed into a safe range, and then bokeh lights layer on top uncompressed, so they bloom.
Bokeh lights
The last colour layer: traffic lights added on top of the tonemapped background. Because they skip compression, they are free to be very bright.
Lights added after tonemapping, they bloom naturally. Preview shows the full bokeh lighting system.
Street lights, headlights, and taillights are placed in a 3D scene and projected onto the 2D canvas using a virtual camera with depth-based bokeh sizing. Camera shake (bumps * camShake) adds the feeling of a handheld view from inside a car. Tapping the canvas spawns temporary bokeh lights that drift and fade. Each tap records coordinates and time into an 8-slot ring buffer, with a quadratic fade curve that holds the glow before dropping sharply.
The SDF edge
The canvas doesn't end with a hard rectangle. A Signed Distance Function fades it out with rounded corners and an organic, fractal border.
Rounded box SDF with fractal wobble. Preview shows the organic edge with premultiplied alpha.
abs(p) - b computes distance from point p to the nearest edge of a rectangle, negative inside, positive outside. Three scales of noise distort the SDF value, weighted 60/30/10: coarse wobble creates large undulations, medium adds bumps, fine adds tiny roughness. The border looks hand-painted rather than geometric. smoothstep feathers the edge asymmetrically, steeper outside than inside, and premultiplied alpha (color * alpha) composites correctly against any page background.