Coloured Shadows
After Vercel
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.
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.
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.
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.
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.
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.
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.