Lighting Effects (Matcap / Phong / Hard Shadows)
Why these use a real surface normal and a live-GL fast path instead of the pseudo-elevation trick
Matcap, Phong, and Hard Shadows are grouped as "Lighting Effects" in the app's sidebar, but they don't share the terrain-analysis pipeline's pseudo-elevation trick — they need an actual shaded RGBA color per pixel, not a scalar smuggled through raster-dem. All three still start from the same shared surface normal computation.

Phong + Hillshade + Raster Basemap, Datetime-driven light direction — Matterhorn massif — open in app ↗
Shared foundation: normals-protocol.ts
lib/normals-protocol.ts computes a per-pixel surface normal from the same Horn gradient (hornGradient()) every terrain-analysis mode uses, via the standard heightfield-to-normal formula — a surface z = f(col, row) has unnormalized normal (-dz/dcol, -dz/drow, 1):
const { dx, dy } = hornGradient(window)
const invLen = 1 / Math.sqrt(dx * dx + dy * dy + 1)
const nx = -dx * invLen, ny = -dy * invLen, nz = invLenThis is an object-space normal map (not tangent-space — a heightfield has no per-vertex tangent-basis ambiguity): R/G/B = (nx, ny, nz) * 0.5 + 0.5, with nx along the tile's column axis (east), ny along the row axis (south), nz up. It does not go through runNormalDerivedProtocol (which always re-packs as Terrarium pseudo-elevation) — Matcap and Phong both need the raw normal vector, not an elevation-shaped scalar.
Computation runs on the GPU via WebGL2 (computeNormalPixelsGPU in gpu-normal-compute.ts — one draw call does every pixel's gradient+normalize+encode at once) with a CPU-loop fallback if WebGL2 is unavailable. Results are cached without any matcap/phong-specific parameter (rotation, light direction, strength) — both protocols call computeNormalPixels(upstreamTemplate, encoding, z, x, y, n, signal) with identical arguments regardless of their own params, so dragging a rotation or light-direction slider reuses the already-computed normal map and only redoes that protocol's own cheap final step.
Two independent rendering paths, per mode
Matcap and Phong each ship two implementations sharing that one normal computation:
addProtocolraster path (matcap-protocol.ts,phong-protocol.ts) — the classic custom-protocol tile handler, feeding a plaintype: "raster"source (notraster-dem). Each call does the normal fetch, a final GPU-accelerated shading pass (gpu-matcap-compute.ts/gpu-phong-compute.ts— a fragment shader draw +readPixels, CPU-loop fallback otherwise), PNG-encodes, and returns bytes. Because MapLibre's raster-source model has no live-uniform hook, any parameter change forces a brand-new tile round trip.- Live-GL layer path (
matcap-live-gl-layer.ts,phong-live-gl-layer.ts) — aCustomLayerInterfacemounted viamap.addLayer, exposed in the UI as the "Fast" option.onAddbuilds a persistent tile mesh/VAO;render()runs every MapLibre frame, fetching each visible tile's normal texture once (cached per z/x/y, via the samecomputeNormalPixels) and thereafter only rebinding uniforms (rotation, exaggeration, opacity, light direction) and redrawing. A slider drag becomes a uniform write +triggerRepaint()— never a refetch/PNG round trip. This is the actual "live" distinction, not per-frame DEM recomputation.
Hard Shadows has no live-GL counterpart or GPU compute path — only the addProtocol raster route, CPU-only.
Both raster protocols also carry a hand-rolled staleness guard (__currentParamsKey, module-scoped) on top of the abort signal: live instrumentation showed MapLibre does not abort in-flight tile requests when a source's tiles URL template itself changes (a new rotation/light commit) — abortController.signal.aborted never flips for them. Since MapLibre's raster tile cache is keyed by z/x/y (not the full URL), an old-template result could otherwise land in a slot after a newer-template result already did, repainting it with stale shading — the actual cause of an "old, new, old, new" flicker. __currentParamsKey tracks the most recent params tuple across all calls to that protocol and refuses to resolve a call whose tuple has since been superseded, independent of what the AbortController reports.
Matcap: material-capture lookup

Matcap — Clay Brown material — Matterhorn massif — open in app ↗
The rotated normal's (x, y) is used directly as a UV into a curated matcap material image (lib/matcap-textures.ts, a Potree/Blender-style set) — an orthographic simplification in the protocol/GPU-compute path:
vec2 nxy = vec2(n.x * cb + n.y * sb, -n.x * sb + n.y * cb); // rotate by u_rotationRad
vec2 uv = nxy * 0.5 + 0.5;
vec3 matcapColor = texture(u_matcap, uv).rgb;The live-GL-layer version uses the same lookup, with a choice of frame via its Light Anchor toggle: Absolute samples by the tile-space normal exactly as above (pinned to compass directions, matching the raster pipeline), while Camera (the default) projects the normal onto the camera's live right/up basis first — the classic view-space matcap, so the material tracks pitch/bearing like a sphere held up to the current view. (An earlier build instead reflected a per-fragment view ray off the normal; that doubled the angular response and painted a screen-locked blob of the matcap's center over flat terrain, and was scrapped.)
Phong: ambient + diffuse + specular
const AMBIENT = 0.35, SHININESS = 32
const diffuse = diffuseStrength * Math.max(nx*lx + ny*ly + nz*lz, 0)
const diffuseIntensity = Math.min(Math.max(AMBIENT + diffuse, 0), 1)
const specDot = Math.max(nx*hx + ny*hy + nz*hz, 0) // H = normalize(L + V), V = (0,0,1)
const specular = specularStrength * Math.pow(specDot, SHININESS)
const total = diffuseIntensity + specularLight direction is compass-fixed by default (state.illuminationDir/illuminationAlt — the same fields the on-map "hold L, drag" light control and MapLibre's own hillshade illumination direction use), not camera-relative — panning/rotating the map must not spin the light, unlike Matcap's material which is deliberately camera/rotation-relative. phong-live-gl-layer.ts adds an opt-in "headlamp" mode reinterpreting azimuth/altitude as an offset from the camera's live bearing+pitch instead.
The az/alt → (x, y, z) light-vector signs were pinned by empirical measurement against MapLibre's own native hillshade shader (rendering both at a known illumination direction, reading back pixels via gl.readPixels, and computing the Pearson correlation of luminance) rather than derived from first principles — an earlier attempt to reuse aspect-protocol.ts's dx/dy→compass formula as "ground truth" produced a plausible but inverted answer for the east/west sign (r ≈ −0.89 at due-east light before the fix, r ≈ +0.93 after).
total is encoded into a single alpha channel across two regimes, composited over the basemap raster with ordinary "over" blending (there's no multiply-and-screen blend mode in the MapLibre style spec):
total ≤ 1(shadow/neutral): color = black,alpha = 1 - total→basemap*(1-alpha) + black*alpha = basemap*total, a true multiply-darken.total > 1(specular highlight): color = white,alpha = total - 1→ a screen-like brightening, letting a strong reflection paint brighter than the albedo.
diffuseIntensity alone is capped at 1, so ordinary sunlit slopes (however bright) stay in the shadow/neutral regime — only specular can push total past 1 into the highlight regime.
Hard Shadows: single-ray horizon march toward the sun

Hard Shadows + Hillshade — Matterhorn massif — open in app ↗
lib/shadow-protocol.ts does not call into horizon-angle.ts's SVF/Openness functions — it's a hand-written single-ray variant of the same idea, explicitly framed that way in its header comment. SVF/Openness check 8 fixed compass directions and aggregate; a cast shadow only cares about one direction — straight toward the sun's actual azimuth (not snapped to a 45° tick):
const dCol = Math.sin(azimuthRad), dRow = -Math.cos(azimuthRad)
for (let r = 1; r <= radiusPx; r++) {
const elevDiff = padded[(pr+rr)*stride + (pc+rc)] - centerElevation
const angle = Math.atan2(elevDiff, dist)
if (angle > maxAngle) maxAngle = angle
}
const inShadow = maxAngle > altitudeRadIf anything between the pixel and the sun rises above the sun's own altitude angle, the pixel is in shadow. Output is plain opaque black where inShadow, fully transparent otherwise — the raster layer's own paint opacity (state.shadowOpacity) controls how dark shadows actually read on screen, the same convention every other derived-mode layer uses. Computation is synchronous CPU/JS, yielding periodically like the terrain-analysis protocols, with no GPU path and no live-GL layer.
Azimuth/altitude for Hard Shadows share the same state.illuminationDir/illuminationAlt state as Hillshade and Phong (no separate light control) — and can be driven by real solar position via the sun-position math.