Coloured Shadows

After Vercel

Studied 2026-08-30WebGL2. Baked visibility per lamp, a 1/d falloff in float, a linear sum and a tone map.

Ten dots in a triangle. Every one is a lamp, every one is permanently solid, and the pointer only picks which lamp burns. The soft crossed rays are neither traced per frame nor blurred: what each lamp can see is baked once into a picture, and a frame multiplies ten pictures by ten weights and adds them up in linear light. The softness is not one trick. It is a long falloff, an averaged penumbra, a supersampled frame, a tone map and a dither, and leaving any one of them out makes it look like a different piece.

1 · Bake it, don't trace it

The lamps never move, so what each lamp can see is a constant — one greyscale picture per lamp, an average of however many hard shadows you can afford. Everything that does change is arithmetic: weight, colour and a falloff evaluated in the shader. Drag samplesfrom one to ninety-six and watch the edges go from knives to penumbrae while the frame time does not move.

Read this: the readout keeps two numbers apart on purpose.bake is milliseconds spent once; frame is milliseconds spent sixty times a second. Sixty-four shadow samples per lamp is unthinkable in the second column and free in the first, and that gap is the entire reason a scene like this can sit behind a headline. The lamp radius is in dot radii: at 1 the emitter is exactly as big as the things it lights, which gives a parallel umbra with a penumbra that opens forever — measurably what the reference does.

2 · Ten fields, three bakes

The formation has the symmetry of its own outline. Under the six ways a triangle maps onto itself the ten dots fall into three orbits — three corners, six edge dots, one centre — so there are only three distinct fields. The other seven are one of these three read through a reflection, which is a permutation of barycentric coordinates and nothing more.

corner · lamp 0
edge · lamp 1
centre · lamp 4
Read this: the three panels are the whole lighting model. Switch to10 bakes and the picture does not change while the bake takes three and a bit times as long — measured at a mean difference of 0.06 levels out of 765, which is two independent stochastic bakes agreeing to within their own noise.

3 · Shadows go away because they are filled in

This is the part I got wrong first, twice. It is tempting to say a dot stops blocking when it lights up — one value, two roles, very tidy. The dots are solid the whole time. The shadows disappear under full illumination because every shadow is lit by some other lamp, which is also what happens in a room with two lamps in it.

Read this: tick the box and the shadows retract toward each dot as it lights, which is a different and duller motion. The decisive tell is in the readout rather than the picture: the true model bakes once and never again, because what a lamp can see does not depend on which lamps are on. The tidy model has to rebake on every change, which is the practical reason nobody ships it.

4 · Five things, and it is smooth or it is not

This figure exists because the first two versions of this study looked jagged and jittery next to the reference and I could not say why. There is no single trick. There are five, they are independent, and each switch below turns one of them off.

Read this, one switch at a time.penumbra off makes the lamp a point and every ray a knife.1/d falloff off swaps the reference's inverse-distance light, which never quite reaches zero, for a curve that dies at a fixed radius — and a shadow is only visible against lit ground, so the far rays vanish.linear light off adds the ten lamps in sRGB, the way a 2D canvas has to, and the mid-tones pile up and clip.tone map off clips the sum at one instead of rolling it off, so every bright region turns into a flat white shape with a hard border.supersample off drops from 2.4× to 1× — the reference renders its fragment shader at 2.4 times the box it is displayed in, which is most of what "jagged" was.dither off leaves the far field banded. Note that theirs is a static hash keyed on the pixel and ramped up outside the triangle; a per-frame random offset does the same job in a still frame and boils horribly in motion.

5 · Nineteen dots, four bakes

Nothing above is about a triangle. Here is the same machinery on a hexagon standing on a point, three dots to an edge, nineteen in all. It is a better arrangement to be a lamp in: a hexagon has twicethe symmetry of a triangle — twelve ways of mapping onto itself against six — so the dots fall into four orbits rather than three, and nineteen fields come out of four bakes. Nearly twice the dots for one more bake.

Read this:the four orbits are the centre, the ring of six around it, the six edge middles and the six corners — tickcolour by orbit to see them. Every dot in an orbit has the same view of the arrangement, which is exactly what makes one bake serve all of them, and switching to 19 bakes produces the same picture for about three and a half times the work. The saving is not in the number of dots, it is in how symmetric the arrangement is: ten dots on a triangle need three bakes, nineteen on a hexagon need four.

This is also where the even-odd bug that made the shadows look misaligned would have been worst. A hexagon is full of straight lines of three and four dots, so from most lamps there are several occluders hidden directly behind nearer ones — and every one of those overlaps was a bright slit.
After the Vercelhomepage hero, plus one arrangement of our own to check that the technique is about symmetry rather than about triangles. Theirs is one WebGL2 pass over a precomputed RGBA atlas — three baked light fields in the colour channels, ambient occlusion in the alpha, ten weights, a photographic tone map and an instanced bloom quad per lamp — drawn into a backing store 2.4 times the size of the box it appears in. This is a rebuild of that pipeline, not their code. The one deliberate difference: they pack falloff and visibility into the same texture because they have one fetch to spend, and this bakes visibility alone and evaluates the falloff in the shader, which is cheaper here and holds more precision in the dark.

What it actually does

  • The lamps never move, so what each lamp can see is a constant. The whole interaction is ten constants scaled by ten numbers, and that is what makes sixty-four shadow samples per lamp affordable: they are paid once. Their version stores those constants as one RGBA image and reads it once per pixel.
  • Ten fields, three bakes. Under the six ways a triangle maps onto itself the ten dots fall into three orbits — three corners, six edge dots, one centre — so only three fields are distinct and the other seven are one of those read through a reflection. Their shader does it by permuting barycentric coordinates and packs the three into the red, green and blue channels of one texture.
  • The dots are solid the whole time. Shadows disappear under full illumination because every shadow is lit by some other lamp, not because the blockers switch off.
  • None of this is about a triangle. The same machinery on a hexagon standing on a point, three dots to an edge and nineteen in all, gives four orbits rather than three: the centre, the ring of six, the six edge middles and the six corners. Nearly twice the dots for one more bake, because a hexagon has twelve symmetries against a triangle’s six.
  • The falloff is 1/d, measured off their atlas: 0.634 of a lamp at one dot spacing, halving every doubling of distance, clamped at 4.25 near the source and with no reach limit at all. That long tail is why the far rays are still visible; a curve that dies at a fixed radius drops them onto ground that is already black.
  • The lamp is the same size as the things it lights — an eight pixel emitter and eight pixel occluders — which gives a parallel-sided umbra with a penumbra that opens forever. Measured off their atlas at 260px behind an occluder: umbra still about six pixels, penumbra past forty-five.
  • The backing store is 2.4 times the box it is displayed in. Nothing in the code looks wrong without this and it is most of what reads as jagged.
  • The sum happens in linear light and is tone mapped once at the end — exposure, black point, a neutral highlight compression from 0.76 with desaturation, contrast around a 0.05 pivot, saturation 1.31, then sRGB. Adding ten lamps in display space instead piles the mid-tones up and clips them, which is what a 2D canvas is forced to do.
  • The dither is a static hash keyed on the pixel, offsetting where each lamp is sampled from by up to 13.5px, rotated by an even slice of a turn per lamp so the ten offsets are decorrelated. It is ramped up only outside the triangle, over 420px, where the gradients are long enough to band. It is not a blur and it does not move.
  • The bloom is three pixels of point spread on an eight pixel dot, its own instanced quad with an editable cubic-bezier falloff. The big glow is the baked field. Treating the bloom as the glow washes the rays out.
  • The quoted fade times are arrival times, not time constants: their code fades by 1 - exp(ln(0.002) * dt / ms), so 850ms in is a tau of about 137ms. Reading them as time constants makes the whole piece feel like treacle.
  • The palette is three saturated lights at the midpoints of a triangle 1.4 times the size of the dots, blended inverse-cube per dot and luminance-normalised so a redder dot is not also a brighter one, then scaled by a hand-tuned per-dot reach compensation.
  • The pointer picks the nearest lamp inside about 3.4 spacings, sends it to weight 3 and every other to 0; nothing hovered rests all ten at 1. A selection, not a proximity falloff.

What the rebuild taught

  • A technique has a schedule as well as a shape. If a reference is doing something you could not afford per frame, you have misread the schedule, not the picture — something is precomputed. That tell was available three wrong readings before it was used.
  • Getting the architecture right is not the same as getting the picture right. The bake, the symmetry and the fill-light model were all correct one round before this one, and it still looked jagged and jittery, because the medium underneath could not carry them: eight-bit sRGB compositing cannot sum in linear, cannot tone map, and cannot supersample cheaply.
  • When a rebuild looks wrong, measure it against the reference rather than adjusting until it feels closer. A radial profile against a capture, with the scale of both pinned by where the shadows start rather than by anything about the glow, turned an argument about taste into a single number that was off.
  • Pin the scale on something with an edge. Two earlier comparisons were wrong because the dot spacing was estimated from the bright core, which the bloom inflates by half again. The radius at which shadows begin is unambiguous, and it validated against a known value.
  • A tone map makes a fitted constant sensitive. Raising exposure by 3.4 moved the output by twelve, because at these levels the neutral tonemap subtracts almost the whole darkest channel. Fit the constant by solving in log space, not by halving the interval.
  • Conjugate your transforms. Reading one baked field as another means mapping out of tile space, turning the triangle over and mapping back; writing the reflection straight in draws the field somewhere else, magnified, and it still looks like a plausible glow.
  • A per-frame random offset and a static hash are the same picture in a still and completely different in motion. One is a dither; the other boils.
  • Separating what you bake from what you evaluate is worth a look. Their atlas holds visibility times falloff and needs a gamma curve to keep an HDR range in eight bits; baking visibility alone, which only ever spans nought to one, and evaluating the falloff in the shader is cheaper and holds more precision in the dark.
  • Accumulate progressively at 1/n. Source-over at that alpha is exactly the incremental mean, so the bake sits at full strength from the first sample and only the noise goes away.
  • Painting is idempotent; winding rules are not. Drawing a bake sample as the frame plus every tangent wedge filled even-odd is elegant, is what the geometry suggests, and flips the OVERLAP of two shadows back to lit — so wherever one dot hides directly behind another the umbra opens a bright slit down its middle. Paint the frame lit and paint each shadow black over it instead; it is also faster, since it drops the clip.
  • A bug can hide in the one case you keep looking at. In a triangular lattice every lamp except the centre has exactly two dots hidden directly behind nearer ones, and the centre lamp has none — so the even-odd bug was invisible in every screenshot taken of the centre dot, which was all of them, for three rounds.
  • Cost tracks symmetry, not size. Ten dots on a triangle need three bakes and nineteen on a hexagon need four, so the more regular arrangement is cheaper per dot rather than dearer. Worth knowing before choosing the shape of the thing, since the choice is usually made on how it looks.
  • Find the orbits rather than writing them down. Every symmetry of an arrangement like this is an isometry fixing its centre, so there are only twelve to try — six rotations, each with and without a flip. Keep the ones that carry the whole set onto itself and give each element the first that lands it on an orbit already chosen. Twenty lines, and it generalises where a hand-written table does not.
  • Check geometry with a test rather than an eye. Sampling just behind each occluder along the ray and comparing against the upper quartile of a ring at the same radius turns ninety lamp-and-occluder pairs into one pass-or-fail number; it found eighteen failures in a picture that looked plausible, and they were exactly the collinear pairs.
  • Say which number is a fit. One exposure constant here is not derived from anything and is set by matching a measured profile; it is a separate named constant with the measurement written beside it rather than folded quietly into the falloff.

Rebuild

WebGL2, because the technique cannot survive a 2D canvas. Four figures: the bake, with bake-milliseconds and frame-milliseconds kept apart and a samples slider that runs the edges from knives to penumbrae without moving the frame time; the symmetry, with the three baked tiles shown beside the assembled field and a switch to bake all ten instead; fill light against the tidy model, where one checkbox turns a rebake counter stuck at one into one that climbs on every drag; five switches for the five things that make it smooth, since that was the question that produced this version; and a nineteen dot hexagon on the same machinery, to show that the saving is about symmetry rather than about triangles.

Primitives

Bake the constantSplit what moves from what does not, render the part that does not once into a picture, and let the frame be a weighted sum of pictures. It buys quality that would be unaffordable per frame, and it costs the freedom to change the thing you baked.
Solve one orbitWhen an arrangement maps onto itself, the distinct cases are its orbits, not its members. Solve one of each and read the rest through the transform that carries them there. The cost then tracks how symmetric the arrangement is rather than how big it is, so a more symmetric shape is cheaper per element, not dearer.
Filled, not unblockedA shadow disappears when another light reaches into it, not when the thing casting it stops existing. Obstacles stay obstacles; what changes is how many directions light arrives from.
Cast wedgeA shadow built as geometry from the tangents of a light and an occluder rather than found per pixel. Two rays and a cap, exact at any zoom, one polygon.
Light falloffBrightness as a function of distance. The exponent is the mood, and it is a bigger decision than the colour: too shallow and the light floods the frame, leaving no dark for a shadow to be seen against.
Add in linearLight adds where it is proportional to energy, not where it is proportional to display value. Summing sRGB piles the mid-tones up and clips them; sum in linear, tone map the total, convert once at the end.
Roll off, do not clipA sum of lights exceeds one, and what happens next is the whole look. Clipping gives flat white shapes with hard borders; a compression curve keeps the shape of the highlight and desaturates it the way film does.
Break bands with a fixed hashA long smooth gradient bands in eight bits. A hash keyed on the pixel breaks the bands and stays put; the same idea reseeded every frame does the same job in a still and boils in motion.
Draw it biggerRender into a backing store larger than the box and let the browser resolve it down. The cheapest smoothness there is, and the easiest to forget, because nothing about the code looks wrong without it.
Additive lightOverlapping light sums instead of covering. Two lamps make a third colour, and a shadow becomes the absence of one of them rather than a dark shape.

The work it is after

vercel.com/home