Terrain Viewer
Dev

Equations & Formulas

The exact math behind every terrain-derivative visualization mode

Every formula on this page is transcribed directly from its lib/*-protocol.ts source, not re-derived — see that file for the full derivation/attribution comments. For how these values get from a formula to a rendered layer, see Terrain Analysis Rendering Pipeline, LRM, and Lighting Effects. Sun/light-position astronomy (declination, hour angle, azimuth/elevation) is covered separately on Sun Position, since it drives Phong's light direction and the shadow calculator rather than being a terrain derivative itself.

Shared inputs

Elevation decoding (lib/elevation-encoding.ts) — every mode starts by decoding an upstream raster-dem tile:

hterrarium=(R256+G+B256)32768h_{\text{terrarium}} = \left(R \cdot 256 + G + \frac{B}{256}\right) - 32768 hmapbox=10000+(R2562+G256+B)0.1h_{\text{mapbox}} = -10000 + (R \cdot 256^2 + G \cdot 256 + B) \cdot 0.1

The Horn 3×3 gradient (hornGradient(), ported from GDAL's GDALSlopeHornAlg), the shared building block for Slope, Aspect, TRI*, Curvature, and (per-sub-cell) Blobness. The window is named a0..a8a_0..a_8 row-major — GDAL's own afWin[0..8] convention, kept for line-by-line parity with the ported code — laid out on the tile grid as (xx = column = east, yy = row = south):

The Horn 3×3 gradient window — ported from GDAL's GDALSlopeHornAlg

west (col −1)center coleast (col +1)
north (row −1)a0a_0a1a_1a2a_2
center rowa3a_3a4a_4 (center)a5a_5
south (row +1)a6a_6a7a_7a8a_8

With ground resolution LL at the tile's center latitude:

zx=a0+2a3+a6a22a5a88Lcos(lat)\frac{\partial z}{\partial x} = \frac{a_0 + 2a_3 + a_6 - a_2 - 2a_5 - a_8}{8 L \cos(\text{lat})} zy=a6+2a7+a8a02a1a28Lcos(lat)\frac{\partial z}{\partial y} = \frac{a_6 + 2a_7 + a_8 - a_0 - 2a_1 - a_2}{8 L \cos(\text{lat})} *TRI/TPI/Roughness don't use the gradient directly — they're plain 3×3 aggregations of elevation itself, see below.

Terrain analysis

Slope

slope°=arctan((zx)2+(zy)2)180π\text{slope}° = \arctan\left(\sqrt{\left(\tfrac{\partial z}{\partial x}\right)^2 + \left(\tfrac{\partial z}{\partial y}\right)^2}\right) \cdot \frac{180}{\pi}

Re-encoded via Mapbox Terrain-RGB packing (base −10000, 0.1° step). lib/slope-protocol.ts.

Aspect

Compass bearing of the gradient direction, converted from math convention (CCW from east) to compass convention (CW from north):

θmath=atan2 ⁣(zy,zx)180π\theta_{\text{math}} = \operatorname{atan2}\!\left(\tfrac{\partial z}{\partial y},\, -\tfrac{\partial z}{\partial x}\right) \cdot \frac{180}{\pi} aspect°=(90θmath)mod360\text{aspect}° = (90 - \theta_{\text{math}}) \bmod 360

(0 if z/x=z/y=0\partial z/\partial x = \partial z/\partial y = 0, i.e. flat ground.) lib/aspect-protocol.ts.

TRI (Terrain Ruggedness Index)

Root-mean-square elevation difference to the 8 immediate neighbors (Riley et al. 2006), in source elevation units (meters):

TRI=i4(aia4)2\text{TRI} = \sqrt{\sum_{i \ne 4} (a_i - a_4)^2}

lib/tri-protocol.ts.

TPI (Topographic Position Index)

Center elevation minus the mean of its 8 neighbors:

TPI=a418i4ai\text{TPI} = a_4 - \frac{1}{8}\sum_{i \ne 4} a_i

Positive → above the local neighborhood (ridge/peak); negative → below it (valley/pit). lib/tpi-protocol.ts.

Roughness

Roughness=max(a0a8)min(a0a8)\text{Roughness} = \max(a_0 \ldots a_8) - \min(a_0 \ldots a_8)

lib/roughness-protocol.ts.

Curvature

Four sub-modes share one Zevenbergen & Thorne (1987) second-order fit over the 3×3 window (p,qp, q = first partials; r,t,sr, t, s = second partials; LL = ground spacing):

p=a5a32Lr=a52a4+a3L2p = \frac{a_5 - a_3}{2L} \qquad r = \frac{a_5 - 2a_4 + a_3}{L^2} q=a7a12Lt=a72a4+a1L2q = \frac{a_7 - a_1}{2L} \qquad t = \frac{a_7 - 2a_4 + a_1}{L^2} s=a2a0a8+a64L2s = \frac{a_2 - a_0 - a_8 + a_6}{4L^2}
  • Combined — discrete Laplacian, ×100 (no single canonical GDAL algorithm to port from, unlike slope/aspect/TRI):

    2z=a1+a3+a5+a74a4L2\nabla^2 z = \frac{a_1 + a_3 + a_5 + a_7 - 4a_4}{L^2} combined=1002z\text{combined} = 100 \cdot \nabla^2 z
  • Profile (curvature along steepest descent — flow acceleration) / Plan (curvature across contours — flow convergence/divergence, div(z/z)\equiv \operatorname{div}(\nabla z / |\nabla z|)), both ×100, undefined (0\to 0) on flat ground:

    g=p2+q2g = p^2 + q^2 profile=100(rp2+2spq+tq2)g(1+g)1.5\text{profile} = \frac{100\,(r p^2 + 2spq + tq^2)}{g\,(1+g)^{1.5}} plan=100(rq22spq+tp2)g1.5\text{plan} = \frac{100\,(r q^2 - 2spq + tp^2)}{g^{1.5}}
  • Det-Hessianfxxfyyfxy2f_{xx} f_{yy} - f_{xy}^2, a blob/saddle detector (positive at bowl/dome extrema, negative at saddles, ~0 on a uniform slope or straight ridge), ×10,000:

    detHessian=(rts2)10000\text{detHessian} = (rt - s^2) \cdot 10000
  • Casorati / Shape Index — both derived from the principal curvatures (eigenvalues of the Hessian [rsst]\begin{bmatrix} r & s \\ s & t \end{bmatrix}), the small-slope approximation:

    tr=r+tdisc=(rt)2+4s2\text{tr} = r + t \qquad \text{disc} = \sqrt{(r-t)^2 + 4s^2} κ1=tr+disc2κ2=trdisc2\kappa_1 = \frac{\text{tr} + \text{disc}}{2} \qquad \kappa_2 = \frac{\text{tr} - \text{disc}}{2} Casorati (Koch 1993)=κ12+κ222100— always0, magnitude only\text{Casorati (Koch 1993)} = \sqrt{\frac{\kappa_1^2 + \kappa_2^2}{2}} \cdot 100 \quad\text{— always} \ge 0\text{, magnitude only} ShapeIndex (Koenderink & van Doorn 1992)=2πatan2(κ1+κ2, κ1κ2)— always in [1,1]\text{ShapeIndex (Koenderink \& van Doorn 1992)} = \frac{2}{\pi}\operatorname{atan2}(\kappa_1+\kappa_2,\ \kappa_1-\kappa_2) \quad\text{— always in } [-1, 1]

    (+1+1 dome/peak, +0.5+0.5 ridge, 00 saddle, 0.5-0.5 valley, 1-1 pit/bowl.)

All modes ×CURVATURE_ENCODE_SCALE = 1000 at the wire-encoding step only (undone on read), to spread the small near-zero-heavy range across more of Terrarium's discrete levels. lib/curvature-protocol.ts.

Blobness / Eigen-Ratio / Orientation

A Förstner/Harris-style structure tensor J=[IxxIxyIxyIyy]J = \begin{bmatrix} I_{xx} & I_{xy} \\ I_{xy} & I_{yy} \end{bmatrix}, where each entry is the 3×3-box average of gx2g_x^2, gy2g_y^2, gxgyg_x g_y — the Horn gradient computed at each of the window's 9 sub-cells (needs a 5×5 halo, since each sub-cell's own gradient needs its own 3×3 neighborhood):

Ixx=gx2Iyy=gy2I_{xx} = \overline{g_x^2} \qquad I_{yy} = \overline{g_y^2} Ixy=gxgytr=Ixx+IyyI_{xy} = \overline{g_x g_y} \qquad \text{tr} = I_{xx} + I_{yy} disc=(IxxIyy)2+4Ixy2\text{disc} = \sqrt{(I_{xx}-I_{yy})^2 + 4I_{xy}^2} λmax=tr+disc2λmin=trdisc2\lambda_{\max} = \frac{\text{tr} + \text{disc}}{2} \qquad \lambda_{\min} = \frac{\text{tr} - \text{disc}}{2}
  • Blobness =det(J)tr(J)10064= \dfrac{\det(J)}{\operatorname{tr}(J)} \cdot \dfrac{100}{64} — the 10064\tfrac{100}{64} corrects for the Horn kernel's 8×8\times gradient inflation (det\det is degree-4 in the gradient, so 82=648^2 = 64) and rescales to roughly match TRI/Roughness's visual range. High where gradient direction varies in every direction (peaks/pits/saddles/knolls); near zero on a uniform slope or straight ridge, however steep.

  • Eigen-Ratio =λminλmax100= \dfrac{\lambda_{\min}}{\lambda_{\max}} \cdot 100 — shape only, independent of magnitude: 0 on a perfectly coherent edge (slope/ridge/valley), 100 on a perfectly isotropic blob (peak/pit/saddle). The 8×8\times inflation cancels in this ratio, so no extra scale correction is needed.

  • Orientation — dominant eigenvector's axis, folded to 00180°180° (a line has no inherent direction, only an axis):

    θmath=12atan2(2Ixy,IxxIyy)180π\theta_{\text{math}} = \tfrac{1}{2}\operatorname{atan2}(2I_{xy},\, I_{xx}-I_{yy}) \cdot \frac{180}{\pi} orientation°=((90θmath)mod180+180)mod180\text{orientation}° = \big((90 - \theta_{\text{math}}) \bmod 180 + 180\big) \bmod 180

lib/blobness-protocol.ts.

Relief visualization

Horizon angle (shared core)

lib/horizon-angle.ts — for each of 8 compass directions, march outward pixel-by-pixel (up to a user-set search radius) and find the steepest elevation angle to any point along the ray:

horizonAngle(dir)=maxr=1radiusatan2 ⁣(h(r)hcenter, rL)\text{horizonAngle}(\text{dir}) = \max_{r=1}^{\text{radius}} \operatorname{atan2}\!\big(h(r) - h_{\text{center}},\ r \cdot L\big)

SVF and Openness apply different aggregations to this same set of 8 angles (Zakšek, Oštir & Kokalj, 2011).

Sky-View Factor (SVF)

Each angle is clamped to 0\ge 0 first (a ray dipping downhill still leaves the entire sky visible in that direction — SVF can't exceed "fully open"), then:

SVF=(1sin(max(θi,0)))100\text{SVF} = \left(1 - \overline{\sin(\max(\theta_i, 0))}\right) \cdot 100

lib/svf-protocol.ts.

Openness (Positive / Negative)

Mean angular distance from zenith to the horizon, not clamped to 0\ge 0 (so it can read above/below what SVF calls "fully open" — e.g. standing on a summit looking outward gives a negative angle in every direction). Negative Openness is Positive Openness computed on the terrain's mirror image (elevation differences × 1-1), turning pits into the "peaks" the same formula highlights.

openness=90°θi180π\text{openness} = \overline{90° - \theta_i \cdot \tfrac{180}{\pi}}

lib/openness-protocol.ts.

Local Dominance

Places a virtual observer of eye height oh=1.6moh = 1.6\text{m} at each pixel and averages the downward-looking angle to the surrounding terrain across several compass directions and distances (Hesse, 2016):

θ(dir,d)=arctan ⁣(zobserver+ohzterraind)\theta(\text{dir}, d) = \arctan\!\left(\frac{z_{\text{observer}} + oh - z_{\text{terrain}}}{d}\right) localDominance=θ(dir,d)\text{localDominance} = \overline{\theta(\text{dir}, d)}

averaged over sampled directions × distances. Positive/high on local highs that look down on their surroundings (mounds, ridges, tells); low/negative in enclosed depressions. Distances beyond a near-field threshold are sampled from pyramid ancestor tiles (one ring per octave) rather than at native resolution — the same trick LRM uses, valid here because this quantity is a mean (a coarse per-octave sample is a legitimate low-pass proxy), unlike SVF/Openness's max. lib/local-dominance-protocol.ts.

LRM (Local Relief Model)

Not a neighborhood formula — see the dedicated LRM page for the full pyramid-ancestor mechanism:

LRM=hfine(native zoom)bilinear(hancestor, k levels up)\text{LRM} = h_{\text{fine}}(\text{native zoom}) - \operatorname{bilinear}(h_{\text{ancestor}},\ k\text{ levels up}) k=clamp ⁣(round(log2(radiusPx)), 1, 6)k = \operatorname{clamp}\!\big(\operatorname{round}(\log_2(\text{radiusPx})),\ 1,\ 6\big)

Lighting effects

See Lighting Effects for the full pipeline (surface normals, live-GL fast path, staleness guards); formulas only, here.

Surface normal (shared by Matcap, Phong, Hard Shadows' input)

Standard heightfield-to-normal formula from the Horn gradient, object-space (not tangent-space):

=1(zx)2+(zy)2+1\ell = \frac{1}{\sqrt{\left(\tfrac{\partial z}{\partial x}\right)^2 + \left(\tfrac{\partial z}{\partial y}\right)^2 + 1}} n=(zx, zy, )\vec n = \left(-\tfrac{\partial z}{\partial x}\cdot\ell,\ -\tfrac{\partial z}{\partial y}\cdot\ell,\ \ell\right)

Encoded as RGB=n0.5+0.5\text{RGB} = \vec n \cdot 0.5 + 0.5. lib/normals-protocol.ts.

Matcap

Rotated normal (nx,ny)(n_x, n_y) used directly as a texture UV (orthographic simplification):

(rx,ry)=rotate(nx,ny, θrotation)(r_x, r_y) = \operatorname{rotate}(n_x, n_y,\ \theta_{\text{rotation}}) uv=(rx,ry)0.5+0.5\text{uv} = (r_x, r_y) \cdot 0.5 + 0.5 color=matcapTexture(uv)\text{color} = \text{matcapTexture}(\text{uv})

lib/matcap-protocol.ts.

Phong (Blinn-Phong)

AMBIENT=0.35SHININESS=32\text{AMBIENT} = 0.35 \qquad \text{SHININESS} = 32 H=normalize(L+V),  V=(0,0,1)\vec H = \operatorname{normalize}(\vec L + \vec V),\ \ \vec V = (0,0,1) diffuse=kdmax(NL, 0)\text{diffuse} = k_d \cdot \max(\vec N \cdot \vec L,\ 0) Idiffuse=clamp(AMBIENT+diffuse, 0, 1)I_{\text{diffuse}} = \operatorname{clamp}(\text{AMBIENT} + \text{diffuse},\ 0,\ 1) specular=ksmax(NH, 0)SHININESS\text{specular} = k_s \cdot \max(\vec N \cdot \vec H,\ 0)^{\text{SHININESS}} total=Idiffuse+specular\text{total} = I_{\text{diffuse}} + \text{specular}

Light vector from compass azimuth/altitude:

L=(sin(az)cos(alt), cos(az)cos(alt), sin(alt))\vec L = \big(-\sin(\text{az})\cos(\text{alt}),\ -\cos(\text{az})\cos(\text{alt}),\ \sin(\text{alt})\big)

(signs empirically pinned against MapLibre's native hillshade shader — see Lighting Effects). total is then split into a multiply-darken regime (total1\text{total} \le 1) and a screen-brighten regime (total>1\text{total} > 1) for compositing — see that page for the exact alpha encoding. lib/phong-protocol.ts.

Hard Shadows

Single-ray horizon march toward the sun's actual azimuth (not snapped to a compass tick), compared against the sun's altitude:

d=(sin(az), cos(az))(dCol, dRow)\vec d = (\sin(\text{az}),\ -\cos(\text{az})) \qquad \text{(dCol, dRow)} horizonAngle=maxr=1radiusatan2 ⁣(h(r)hcenter, rL)\text{horizonAngle} = \max_{r=1}^{\text{radius}} \operatorname{atan2}\!\big(h(r) - h_{\text{center}},\ r \cdot L\big) inShadow=horizonAngle>altrad\text{inShadow} = \text{horizonAngle} > \text{alt}_{\text{rad}}

lib/shadow-protocol.ts.

On this page