What looks like a single water effect is actually two pipelines, glued together. Canvas 2D builds a "water map" of where drops are and what shape each one bends light by. A WebGL fragment shader reads that water map and uses it to refract a background image. Take away either side and you don't have rain.
The trick that makes this fast is that refraction is a texture lookup, not a computation. The shader does almost no per-pixel math. The drop's refraction profile is pre-baked into an RGBA bitmap once, at startup, and then stamped wherever a drop should appear. That's the article.
A drop is a lens
Hold a water drop on a window in front of a city light. The light through the drop is flipped, distorted, and concentrated. Different parts of the drop pull different parts of the background into your eye. That's what we have to fake.
For real refraction we'd compute Snell's law per pixel: the angle of incidence, the index of refraction, the path the light takes through curved glass. Doable in a fragment shader. Slow, and overkill. Real raindrops on a real window aren't perfect spheres. They're squished blobs with surface tension. The "lens" math gets dirty fast.
The shortcut: for each pixel of a drop, just remember where it should sample from. That's a 2D offset. Store it. The shader reads the offset and samples. No Snell, no per-pixel math.
Encoding refraction as a texture
Here's the drop, drawn once into a 256×256 Canvas 2D bitmap. The RGB channels each carry a different piece of the refraction recipe. Drag the marker on the left to see what gets encoded at each point.
The encoding is intentionally simple: a point at distance d from the drop's
center stores an offset that points outward from the center, with magnitude
proportional to d. That's the shape a real lens makes: the rim bends light most,
the center barely. The B channel stores depth (a half-sphere falloff), used later as a
multiplier so the shader can dial refraction strength up where the drop is thickest.
1// Bake the drop's refraction normals into RGB once, at init time.2// R = vertical offset (centered at 128 = 'no offset')3// G = horizontal offset (same convention)4// B = depth into the drop — scales refraction strength5// A = the visible-area mask6for (var py = 0; py < SIZE; py++) {7 for (var px = 0; px < SIZE; px++) {8 var dx = (px - cx) / cx;9 var dy = (py - cy) / cy;10 dy *= 1.0 + dy * 0.15; // slight tear shape11 var dist = Math.sqrt(dx * dx + dy * dy);12 if (dist > 1.0) continue;13 14 var nx = dist > 0.001 ? dx / dist : 0;15 var ny = dist > 0.001 ? dy / dist : 0;16 var r = Math.round(ny * 60 * dist + 128);17 var g = Math.round(nx * 60 * dist + 128);18 var depth = Math.sqrt(Math.max(0, 1.0 - dist*dist)) * 255;19 var alpha = Math.max(0, 1.0 - Math.pow(dist / 0.45, 6)) * 255;20 21 var idx = (py * SIZE + px) * 4;22 d[idx] = r; // → texture.r in the shader23 d[idx + 1] = g; // → texture.g24 d[idx + 2] = depth; // → texture.b25 d[idx + 3] = alpha; // → texture.a26 }27} The visible portion of the bitmap is much smaller than the drawn radius. The pow(dist / 0.45, 6) alpha falloff means only the inner ~45% of the bitmap is
opaque. That's deliberate: the bitmap gets drawn at many different scales, and the rest of the
256×256 frame is unused padding for safe blending at the edges.
Line 7's dy *= 1 + dy * 0.15 bias gives the drop a slight teardrop shape, taller
than wide. Surface tension does that on real glass.
The shader: one lookup, two textures
Now we use the bitmap. The fragment shader needs two textures: the water map (where drops are, drawn by Canvas 2D each frame), and the background (whatever we're refracting: a photograph, a procedural city scene). Each pixel reads the water map, decodes RG as a 2D offset, scales it by the depth channel, and samples the background at offset position.
1precision mediump float;2uniform sampler2D u_waterMap;3uniform sampler2D u_background;4uniform vec2 u_res;5uniform float u_refraction;6 7void main() {8 vec2 uv = gl_FragCoord.xy / u_res;9 vec4 water = texture2D(u_waterMap, vec2(uv.x, 1.0 - uv.y));10 11 // ─── The whole technique, six lines ───12 vec2 offset = (vec2(water.g, water.r) - 0.5) * 2.0;13 float depth = water.b;14 vec2 refractedUV = uv + offset * (256.0 + depth * 256.0) / u_res.x * u_refraction;15 vec4 bg = texture2D(u_background, refractedUV);16 17 // Outside a drop: show the background as-is. Inside: show the lensed lookup.18 gl_FragColor = mix(19 texture2D(u_background, uv),20 bg,21 clamp(water.a * 4.0 - 1.0, 0.0, 1.0)22 );23} Line 12 is the decode: (g, r) − 0.5 shifts the 0..1 stored offset back into a
signed −1..1 range, the * 2.0 recovers the original scale. The pixel-to-UV
conversion on line 14 uses 256 / u_res.x as the base offset and adds another 256 · depth on top, so a pixel at the drop's deepest point can pull from up to
512 pixels away. On a 1400-pixel-wide canvas that's roughly a third of the screen, which
looks like a real drop's lensing.
Below: a single drop, mouse-positioned, refracting a procedural background. Move your cursor anywhere. The toggle flips between the rendered drop and the raw normal map texture, so you can see the encoded image directly.
A water map of many drops
One drop is a curiosity. A windowful is the point. Each frame we clear a Canvas 2D buffer (the water map), draw every drop's bitmap into it at the drop's current position and size, and upload the result as a WebGL texture. The shader does the same lookup for every pixel; it doesn't know or care that there are now two hundred drops instead of one.
The drawing loop is literally this:
1waterCtx.clearRect(0, 0, w, h);2for (const drop of drops) {3 const size = drop.r * 2;4 waterCtx.drawImage(5 dropBitmap,6 drop.x - size/2, drop.y - size/2,7 size, size8 );9} One drawImage call per drop. Canvas 2D is extremely good at this, orders of
magnitude faster than computing refraction in a shader, because the drop bitmap is already
correctly composited (the alpha channel masks the shape, the RGB channels carry the
refraction). Stamping a 60×60 pixel sprite is something the browser's 2D pipeline has been
optimized to death for.
Drops that move
Real drops on glass mostly sit still. Surface tension is stronger than gravity at the scales we care about. Then something (a vibration, an arriving drop, a temperature gradient) tips the balance and the drop suddenly starts sliding. Once it's moving, it gains momentum, sheds smaller drops in its wake, and either makes it to the bottom or fuses with another drop and passes its mass along.
Modeling all that physically would be a research project. We model it as probability. Each frame, every drop has some chance of getting a momentum kick. Bigger drops get kicked more often (their weight wins against tension sooner). Once moving, drops decay back toward stillness unless they get kicked again or run into another drop.
1// Most of the time, do nothing. That's a feature.2// Drops only start sliding when something kicks them — modeled as3// a probability that grows with drop size. Surface tension is just4// "low probability of kick for small drops; higher for big ones."5if (Math.random() < drop.r / (100 * dpr) * dt * 0.5 / SURFACE_TENSION) {6 drop.momentum += rand(0.5, 2.5);7}8 9// Once moving, fall and shed trail drops behind us.10drop.y += drop.momentum * dt;11drop.momentum *= Math.pow(0.95, dt); // friction12 13if (drop.momentum > 0.5 && drop.lastSpawn > 20) {14 spawnTrailDrop(drop); // a smaller drop, in our slipstream15 drop.r *= 0.97; // we lose a little water making it16 drop.lastSpawn = 0;17} Watch what happens when surface tension drops to 0.2: the kicks happen constantly, drops never really sit still, and the glass becomes a wash of streaks. Crank it up to 3 and drops freeze in place after a brief slide. Real plausibility is somewhere around 0.7–1.5.
Drops aren't round
Look closely at the rolling drop above. It's not a circle. A real water drop on glass is taller than it is wide, with a flattened bottom. Gravity pulls down, surface tension pulls the top back up, and the steady-state shape is a teardrop. The drops in our shader cheat the same way real drops do.
The shape is two factors stacked together. scaleY is constant. Every drop is
drawn ~1.5x taller than wide; that's the teardrop baseline. On top of that, spreadX and spreadY are transient "splat" factors. A freshly-spawned drop hits the glass
spread out; a freshly-merged drop bulges from the impact. Both spreads decay every frame, which
is what surface tension does in real life.
1// The drop's drawn size has three components.2// scaleY is constant — a real drop on glass is taller than wide.3// spreadX/Y are transient "splat" factors set on spawn or collision.4// Both spreads decay every frame, which is surface tension settling.5function drawDrop(d) {6 const SHAPE_TEARDROP = 1.5;7 const w = d.r * 2 * (1 + d.spreadX);8 const h = d.r * 2 * SHAPE_TEARDROP * (1 + d.spreadY);9 ctx.drawImage(dropBitmap, d.x - w/2, d.y - h/2, w, h);10}11 12// Per frame, in updatePhysics:13drop.spreadX *= Math.pow(0.4, dt); // rapid X collapse14drop.spreadY *= Math.pow(0.7, dt); // slower Y settling The decay rates matter. pow(0.4, dt) on X means horizontal spread collapses in
~3 frames. pow(0.7, dt) on Y means vertical settling takes ~6 frames. Drops snap
back narrow first, then settle vertically. That's the asymmetry you see in a real droplet
flattening on glass.
Without this, a merge looks like two circles snapping into one bigger circle. With it, the bigger circle bulges for a moment and then pulls itself back together. That bulge is the difference between "fake" and "real" in this shader.
Drops that merge
Two drops meet. Surface tension at the contact point collapses; the smaller drop folds into the bigger. The bigger one's radius grows, but not by the full sum of the two: a little water gets left behind as residue. Volume is roughly conserved.
In code:
1// Sort by y so we only check nearby drops (O(n·k), not O(n²))2drops.sort((a, b) => a.y - b.y);3 4for (var i = 0; i < drops.length; i++) {5 var d1 = drops[i];6 for (var j = i + 1; j < Math.min(i + 30, drops.length); j++) {7 var d2 = drops[j];8 if (d1.r <= d2.r) continue; // only the bigger one absorbs9 10 var dx = d2.x - d1.x, dy = d2.y - d1.y;11 if (dx*dx + dy*dy < ((d1.r + d2.r) * 0.45) ** 2) {12 // Area conservation with an 0.8 loss factor — some water13 // gets left behind as the merger happens.14 var a1 = Math.PI * d1.r * d1.r;15 var a2 = Math.PI * d2.r * d2.r;16 d1.r = Math.sqrt((a1 + a2 * 0.8) / Math.PI);17 d1.momentum += 1.5; // mergers snowball18 d1.spreadX = Math.max(d1.spreadX, 0.25); // small settle wobble19 d1.spreadY = Math.max(d1.spreadY, 0.15);20 d2.killed = true;21 }22 }23} The area-conservation formula on line 13 is the only piece of real geometry in the whole
shader. Two drops with radii r₁ and r₂ have combined area π·r₁² + π·r₂². The merged drop has the same area minus a fudge factor (we use
0.8) for water lost in the merge, so its radius is the square root of all that divided by π.
The slider below has it live:
The momentum boost on line 16 is what makes this look right. Without it, a drop that absorbs five smaller drops on its way down would arrive at the bottom at the same speed it started. With it, every merger adds a small kick and the drop accelerates, exactly the snowballing-bowling-ball trajectory real raindrops make.
The wet-glass trick
There's one more piece of visual sleight of hand that does most of the heavy lifting in the final shader: two different background textures. The "no drop here" pixels sample a heavily-blurred version of the scene; the "you're looking through a drop" pixels sample a less-blurred version. The drop's alpha mask switches between them.
The effect is psychological. Wet glass is hazy because the water on the surface is itself acting as a million tiny micro-lenses, each one slightly blurring what's behind. But the moment a real drop forms, it concentrates that view back into focus, like a magnifying glass. Our drops become portals; they reveal sharper detail than the surrounding glass.
Toggle the trick on and off below. Off, the scene is sharp everywhere and drops are just refractions of a clear image; they read as bumps, not lenses. On, the surrounding glass fogs out and the drops become clear windows into a more detailed view.
The Radiant production shader does this with a 384×256 heavy-blur background and a 96×64
lighter-blur foreground, both procedurally generated cityscapes. The cost is one extra texture
upload and one extra texture2D call per pixel. The payoff is the entire
atmosphere of the effect.
Two layers of drops
One last thing the production shader does that the teaching variants don't. There are
actually two separate "drops" systems running in parallel: the drops[] array of
big, physics-having drops we've been talking about, plus a second Canvas 2D layer of tiny
static "spray" droplets, the kind that mist up the glass between real drops without
individually moving.
Spray drops never collide and never have momentum. They just exist as a fine speckle on the
water map. Big drops, as they slide, erase spray drops underneath them via Canvas
2D's destination-out composite operation: clean tracks of glass appear behind
moving drops, while the rest of the window stays misty. It's the visual signature of the
whole effect.
Same trick, different surface
The rain-on-umbrella shader in the Radiant catalog uses the exact same drop bitmap, the exact same refraction shader, and a barely-modified physics loop. The differences are all in the spawn surface and the trajectories: drops spawn on a curved dome, slide radially outward from the center of the umbrella, and inherit a small "walking" jitter that makes the whole image bob as if you're walking under it.
Open it side by side with this one and switch between them. The look is wildly different. The pipeline is identical.
Why this is fast
A windowful of rain is two hundred drops, each with its own physics update, plus a few thousand static spray droplets, plus a per-pixel refraction lookup. None of that is intuitively cheap.
The bitmap is pre-baked. The drop's normal map is computed once, at init.
255 depth variants pre-rendered as separate sprites so the shader can pick the right one
for any drop size without extra math. After init, drawing a drop is one drawImage. There's no per-frame texture generation.
Canvas 2D is the right tool for the water map. A WebGL implementation of "stamp two hundred sprites onto a canvas" would need batched draw calls, instanced quads, and texture atlases. Canvas 2D does it natively, on the CPU, at speed, with no bookkeeping. The water map gets uploaded to the GPU as one texture per frame. That's the only cross-domain transfer.
The physics loop is O(n)-ish. Collision detection sorts drops by y and only checks each one against its 30 nearest neighbors. With drops sorted, the y-sort is nearly stable across frames (a near-sorted insertion sort is O(n)), and each drop's collision check is bounded. No spatial hash, no quadtree. Two hundred drops, six thousand cheap distance checks per frame.
Where to go from here
Lucas Bebber's original article at Codrops covers the technique with more depth on the production setup, including the depth-variant pre-baking, the parallax handling, and the shine/shadow that make the drops look 3D rather than purely flat. The source for Radiant's version is the same algorithm; it differs mostly in the procedural background and the interaction details (click to splash, drag to wipe).
If you want a deeper rabbit hole, look up normal mapping in general 3D rendering. The drop bitmap is exactly a 2D normal map: each pixel stores a surface normal direction encoded in the RGB channels, and the shader uses it to offset something else. Drops on glass are one application; bumpy floors in video games, water surfaces, and bricks on walls are others. The encoding trick generalizes.
The whole rain-on-glass file is about 1000 lines of HTML. Half is the physics. A quarter is the procedural background. A tenth is the fragment shader. Drop it on any page; it has no dependencies.