Skip to content
Ashutosh Singh
Building the WebGL Shaders Behind This Portfolio

Building the WebGL Shaders Behind This Portfolio

Published March 2026

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.

attribute vec2 a_position;
varying vec2 v_uv;

void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}

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.

vec2 GetDrops(vec2 uv, float seed, float m) {
float t = u_time * u_lightsSpeed + m * 30.;
uv.y += t * 0.05;

// Divide into grid cells
uv *= vec2(10., 2.5) * 2. / u_rainSize;
vec2 id = floor(uv);
vec3 n = N31(id.x + (id.y + seed) * 546.3524);

// Position within cell
vec2 bd = fract(uv) - 0.5;
bd.y *= 4.;
bd.x += (n.x - 0.5) * 0.6;

// Irregular slide animation
t += n.z * 6.28;
float slide = SawTooth(t);

// Trail: smaller drops left in the wake
vec2 trailPos = vec2(bd.x * 1.5, (fract(bd.y * 3. - t * 2.) - 0.5) * 0.5);

bd.y += slide * 2.;
bd.y += bd.x * bd.x * DeltaSawTooth(t);

float d = length(bd);
float trailMask = S(-0.2, 0.2, bd.y) * bd.y;
float td = length(trailPos * max(0.5, trailMask));

return mix(bd * S(0.2, 0.1, d), trailPos, S(0.1, 0.02, td) * trailMask);
}

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.

// Primary hash: takes a vec2, returns a float in [0, 1]
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13831);
p3 += dot(p3, p3.yzx + 3.333);
return fract((p3.x + p3.y) * p3.z);
}

// Alternative hash for grain
float hash21(vec2 p) {
p = fract(p * vec2(234.34, 435.345));
p += dot(p, p + 34.23);
return fract(p.x * p.y);
}

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.

float noise(vec2 p) {
vec2 i = floor(p);   // which grid cell
vec2 f = fract(p);   // position within the cell

// Hermite smoothstep interpolation
f = f * f * (3.0 - 2.0 * f);

// Bilinear interpolation between four corners
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
);
}

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.

S(t) = 3t² - 2t³

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.

float fbm(vec2 p, float oct) {
float v = 0.;
float a = 0.5;      // amplitude starts at 0.5
float freq = 1.;    // frequency starts at 1

for (float i = 0.; i < 8.; i++) {
  if (i >= oct) break;
  v += a * noise(p * freq);
  freq *= 2.;        // double the frequency
  a *= 0.5;          // halve the amplitude
}
return v;
}

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.

0.5 + 0.25 + 0.125 + 0.0625 + ... → 1.0

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.

// Constant directional flow
vec2 flow = flowDir * t * u_flowSpeed;

// FBM-based warp: noise displaces the sampling position
vec2 warpUV = distortedUV * u_warpScale + flow * 0.5;
vec2 warpOffset = (vec2(
fbm(warpUV + vec2(0., t * u_flowSpeed * 0.3), u_noiseOctaves),
fbm(warpUV + vec2(5.2, t * u_flowSpeed * 0.3 + 1.3), u_noiseOctaves)
) - 0.5) * u_warpStrength;

// High-frequency turbulence
vec2 turbUV = distortedUV * u_noiseScale * 2. + t * u_flowSpeed * 0.7;
vec2 turbOffset = vec2(
noise(turbUV + vec2(7.7, 1.2)) - 0.5,
noise(turbUV + vec2(3.1, 8.4)) - 0.5
) * u_turbulence * 0.1;

// Swirl: rotation that increases with distance from centre
vec2 swirledUV = rotate2d(centeredUV, u_swirl * length(centeredUV) * sin(t * 0.3));
vec2 swirlOffset = swirledUV - centeredUV;

// Everything combined
vec2 animatedUV = distortedUV + flow + warpOffset + turbOffset + swirlOffset * 0.3;

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:

float gradientT;

if (u_blendMode < 0.5) {
// Mode 0: Radial, distance from center
gradientT = length(offsetUV) / (u_gradientSpread * pulse);
}
else if (u_blendMode < 1.5) {
// Mode 1: Linear, projection onto an angle
float angle = u_gradientAngle * PI / 180.;
gradientT = dot(
  distortedUV - 0.5 - gradOffset * 0.5,
  vec2(cos(angle), sin(angle))
) / (u_gradientSpread * pulse) + 0.5;
}
else {
// Mode 2: Blob, radial + FBM wobble
gradientT = length(
  offsetUV + fbm(offsetUV * 2. + t * u_flowSpeed * 0.15, 4.) * 0.15
) / (u_gradientSpread * pulse);
}

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.

// Layer 1: fast hash grain, different every frame
color += (hash21(uv * u_grainSize + fract(t * 0.71)) * 2. - 1.) * u_grainIntensity;

// Layer 2: slow noise grain, drifts gently over time
color += (noise(uv * u_grainSize * 0.5 + t * 0.02) - 0.5) * u_grainIntensity * 0.5;

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

tanh(x) = (e²ˣ - 1) / (e²ˣ + 1)

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.

// In main():
vec3 e2 = exp(2. * color * 1.1);
color = (e2 - 1.) / (e2 + 1.);     // tonemapping

if (u_lightsOn > 0.5) {
color += computeLights(uv - rainOffset * 0.3);
}
color += computeTapLights(uv - rainOffset * 0.15);

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.

// Basic rounded-rectangle SDF
float roundedBoxSDF(vec2 p, vec2 b, float r) {
vec2 d = abs(p) - b + r;
return length(max(d, 0.)) + min(max(d.x, d.y), 0.) - r;
}

// Applied in main():
float sdf = roundedBoxSDF(
pixelCoord - center,
u_resolution * 0.5 - edgeMargin,
u_edgeCornerRadius * minDim * 0.15
);

// Fractal wobble: three scales of FBM added to the SDF
float wobble =
(fbm(edgeUV * u_edgeRoughnessScale * 3. + vec2(42.7, 13.3), 4.) - 0.5) * 0.6
+ (fbm(edgeUV * u_edgeRoughnessScale * 8. + vec2(91.2, 57.8), 3.) - 0.5) * 0.3
+ (noise(edgeUV * u_edgeRoughnessScale * 20. + vec2(7.1, 3.9)) - 0.5) * 0.1;

sdf += wobble * u_edgeRoughness * minDim * 0.12;

// Feathered alpha
float feather = max(u_edgeFeather * minDim * 0.15, 1.);
float alpha = 1. - smoothstep(-feather, feather * 0.25, sdf);

// Premultiplied alpha output
gl_FragColor = vec4(color * alpha, alpha);

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.

Thanks for visiting.

orNext blog