Rendering test · sphere shading on a beeswarm

The terminator
is an ellipse

Every dot is lit by one sun. Drag the dial to move its azimuth and watch four hundred spheres turn together — because relighting them all means editing four gradient definitions, not four hundred.

azimuth315.0°
elevation40.0°
L vector—
terminator—

Beeswarm · 420 synthetic observations

— nodes · — fps
value →

Sun azimuth

az 315° NW

Lighting model

Anatomy of one dot

Same parameters, two renderers · left is ground truth

Canvas · exact per-pixel

Lambert diffuse + Blinn–Phong specular evaluated at every pixel, mixed in linear light. The dashed arc is the terminator; the cross is the sub-solar point, the ring is the specular peak.

SVG · four stacked gradients

One elliptical diffuse gradient carrying ambient and Lambert together, then limb darkening, a fresnel rim and a specular hotspot. No per-pixel maths — the terminator lands right because the ellipse is right.

How it works

Three things worth knowing

1 · The lighting model is four lines. Put the viewer at +Z and give the sphere unit radius. A visible point at disc coordinates (u,v) has surface normal N = (u, v, √(1−u²−v²)), which is the whole trick — the normal falls straight out of the screen position, so there is no geometry to carry around. The sun direction comes from the two numbers on the dial, using compass azimuth so that 0° is light from the top of the frame:

// a = azimuth (compass, clockwise from north), e = elevation above the image plane
const L = [Math.cos(e)*Math.sin(a), Math.cos(e)*Math.cos(a), Math.sin(e)];
const H = normalise([L[0], L[1], L[2] + 1]);        // halfway vector, viewer is (0,0,1)

const lambert  = Math.max(0, dot(N, L));
const specular = Math.pow(Math.max(0, dot(N, H)), shininess);
const rgb      = base*(ambient + diffuse*lambert) + white*(gloss*specular);

Do that mixing in linear light, not in sRGB — decode, blend, re-encode. It costs two Math.pow calls and it is the difference between a sphere that looks lit and one that looks like a sticker with a grey smudge on it. The canvas renderer here does; the SVG one can't, and you can see it in the shoulder of the terminator if you flip between them.

2 · The terminator is an ellipse, and that's what makes the SVG version work. Set N·L = 0, substitute the normal, and the shadow boundary falls out as

u′ = −sin(e) · √(1 − v′²)        // in a frame rotated to the sun's azimuth

— an ellipse with semi-minor axis sin(e) along the azimuth and semi-major axis 1 across it. At e = 0° it collapses to the vertical diameter and you get a perfect half-moon; at e = 90° it opens out to the limb and the disc is fully lit. Once you know the terminator is an ellipse you can draw the diffuse falloff as a squashed radial gradient whose outer stop lands exactly on it, and the eye stops asking questions. That's the entire approximation.

Where the approximation shows. Lambert's iso-brightness contours are a family of ellipses with different eccentricities, not one ellipse scaled up and down — so a single gradient can only match the boundary and the peak, never every step in between. Flip to Canvas at low elevation and watch the mid-tones: the SVG version runs a little bright through the middle of the lit face. At normal dot sizes, on a real chart, nobody will ever see it.

3 · Four hundred spheres, one light. The reason this is cheap is that every dot shares a light source, so every dot shares its gradients. The sphere is defined once as a template and instanced with <use>:

<!-- defined once: four circles of r=1 at the origin -->
<g id="sphere">
  <circle r="1" fill="url(#diffuse)"/>   <circle r="1" fill="url(#limb)"/>
  <circle r="1" fill="url(#rim)"/>       <circle r="1" fill="url(#specular)"/>
</g>

<!-- instanced 420 times -->
<use href="#sphere" transform="translate(312,88) scale(5.5)"/>

Because the gradients use gradientUnits="userSpaceOnUse" inside a unit-radius template, they scale with each instance for free — so dots of different sizes stay correctly lit without a single extra definition. Moving the sun rewrites four gradient elements. The 420 <use> nodes never change, never get re-parsed, and never get touched by JavaScript again after layout. This is the one case where <use> is unambiguously right: the instances are supposed to be identical, so the usual objection — that you can't animate them independently — is the feature.

The practical ceiling is the same as any DOM-rendered chart. Four circles per dot means the node count is 4× the dot count if you inline them; with <use> the browser still builds the shadow instances, so treat it as 5× for budgeting. Around 600 dots you are at ~2,400 rendered nodes, which is the point where SVG starts costing real frame time. Past that, switch the renderer and keep the model — the canvas path here draws one sprite per colour per lighting change and blits it, which is flat in dot count.

Making it look right

Settings that matter more than the maths

← Shader Playground