Rendering test · sphere shading on a beeswarm
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.
Same parameters, two renderers · left is ground truth
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.
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.
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.
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.
Settings that matter more than the maths
gloss below about 0.35 with shininess up around 40. A hard white hotspot on a 5px dot turns into a sparkle that fights the data — it reads as a highlight on the screen, not on the object.