OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / ab79d96

explore: hypsometric tint and vertical exaggeration — the seafloor was legible all along
Reported as "what are those low detail areas". They are SEAFLOOR, and the model is right. Measured along the player's row, left to right: tile mean m detail -3,7 +713 6.40 land -3,9 +351 8.41 land -3,10 +20 7.38 coastline -3,11 -207 3.11 shelf -3,13 -1238 1.65 seafloor -3,15 -1840 1.74 deep ocean Detail collapses 8.41 -> 1.65 exactly at the 0 m contour, not at a tile boundary or a generation event, and the -2 row does the same thing one tile earlier, tracking the coast's diagonal. Submarine terrain IS smoother: rain, rivers, glaciers and mass wasting do not operate down there. The model is trained on Earth and this is Earth's bathymetry statistics. It agrees with the independent survey of 150 tiles (ocean 2.01 vs land 8.06). What made it read as broken was two of my rendering choices, not the data: ONE RAMP FOR EVERYTHING. Seafloor at -2200..-1000 m occupies 15% of a +/-4000 m ramp, so it arrived almost flat. Real maps tint land and sea on SEPARATE scales; both then get their full range and sea level becomes a hard readable edge rather than the middle of a gradient. The scales are absolute, so tiles still agree with each other -- this is not the per-tile ramp bug returning. 1x SHADING EVERYWHERE. An sd of 97 m over 7.68 km is genuinely near-flat; at 1x there is nothing for the light to catch. Exaggeration is now a slider and affects the SHADING ONLY -- the tint keeps telling the truth about elevation while the relief becomes legible. Submarine relief needs 5-10x to read at all. Changing either drops the rendered images and keeps the server's ELEVATION cache, so it costs a re-encode and never a regeneration. Deliberately NOT done: inventing detail that is not in the data. The seafloor has 5x less relief than land and should look like it; the job was to make what IS there visible, not to fake what is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ab79d96c6292341d2051c586e6e47ef38a9130bb
by plankatron <noreply@anthropic.com> · 2026-08-31T20:34:04.000Z

Changed files (2) +81 -9

explore.html +17 -3
……@@ -42,6 +42,9 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
4242 <label>move speed <span class="v" id="v_speed">400</span> m/s
4343 <input type="range" id="speed" min="1.7" max="3.7" step="0.05" value="2.6"></label>
4444 <label><input type="checkbox" id="follow" checked style="width:auto"> camera follows player</label>
45 <label>vertical exaggeration <span class="v" id="v_exag">1</span>x
46 <input type="range" id="exag" min="1" max="14" step="0.5" value="1"></label>
47 <label><input type="checkbox" id="hyps" checked style="width:auto"> hypsometric tint (land/sea split)</label>
4548 <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label>
4649 <hr>
4750 <label style="display:block"><input type="checkbox" id="biome" style="width:auto">
……@@ -80,6 +83,7 @@ const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile
8083
8184const cv = document.getElementById("cv"), ctx = cv.getContext("2d", {alpha:false});
8285const $ = id => document.getElementById(id);
86const tileView = () => ($("hyps") && $("hyps").checked) ? "hypsometric" : "shaded-height";
8387
8488// AXES, and getting this wrong is what made the terrain "not line up".
8589//
……@@ -142,7 +146,8 @@ function tileImage(tx, ty) {
142146 const im = new Image();
143147 im.onload = () => { imgs.set(k, im); dirty = true; };
144148 im.onerror = () => { imgs.delete(k); }; // 204 = not ready yet
145 im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height&v=${TILE_VERSION}`;
149 im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=${tileView()}` +
150 `&exag=${(+$("exag").value).toFixed(1)}&v=${TILE_VERSION}`;
146151 imgs.set(k, "pending");
147152 return "pending";
148153}
……@@ -261,9 +266,9 @@ async function ensureCoarse() {
261266 const im = new Image();
262267 im.onload = () => { coarseImg = im; coarseBox = box; coarsePending = false; dirty = true; };
263268 im.onerror = () => { coarsePending = false; };
264 const cview = $("biome").checked ? "terrain-class" : "shaded-height";
269 const cview = $("biome").checked ? "terrain-class" : tileView();
265270 im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}` +
266 `&view=${cview}&v=${TILE_VERSION}`;
271 `&view=${cview}&exag=${(+$("exag").value).toFixed(1)}&v=${TILE_VERSION}`;
267272}
268273
269274async function poll() {
……@@ -386,6 +391,15 @@ function setZoom(z) {
386391$("zoom").addEventListener("input", () => setZoom(+$("zoom").value));
387392$("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; });
388393$("grid").addEventListener("change", () => { dirty = true; });
394function reshade() {
395 // colour/shading changed: drop the rendered images, keep the ELEVATION cache
396 // on the server, so this costs a re-encode and never a regeneration.
397 imgs.clear(); coarseImg = null; coarseBox = null; coarsePending = false;
398 $("v_exag").textContent = (+$("exag").value).toFixed(1).replace(/\.0$/, "");
399 ensureCoarse(); dirty = true;
400}
401$("exag").addEventListener("input", reshade);
402$("hyps").addEventListener("change", reshade);
389403$("biome").addEventListener("change", () => {
390404 $("legend").hidden = !$("biome").checked;
391405 coarseImg = null; coarseBox = null; coarsePending = false; // force a refetch
server.py +64 -6
……@@ -340,7 +340,57 @@ def to_png(rgb):
340340 return buf.getvalue()
341341
342342
343def colourise_array(h, mpp, view, vmin, vmax):
343# HYPSOMETRIC / BATHYMETRIC TINTING.
344#
345# A single ramp across the whole range is right for consistency between tiles
346# and wrong for contrast within one. Seafloor at -2200..-1000 m occupies about
347# 15% of a +/-4000 m ramp, so it arrives on screen almost flat -- which read as
348# "the model has no detail down there" when the measurement says it has 5x less
349# detail, not none (land 8.41 vs seafloor 1.65, and the drop lands exactly on
350# the 0 m contour, not on any tile boundary).
351#
352# Real maps solve this by tinting land and sea on SEPARATE scales. Both then get
353# their full range, and sea level is a hard, readable edge instead of the middle
354# of a gradient. The scales are absolute, so tiles still agree with each other.
355LAND_STOPS = [
356 (0, (0.29, 0.48, 0.25)), # coastal green
357 (300, (0.49, 0.60, 0.29)),
358 (800, (0.72, 0.63, 0.35)), # tan
359 (1600, (0.66, 0.46, 0.25)), # brown
360 (2600, (0.55, 0.44, 0.38)), # bare rock
361 (3600, (0.91, 0.91, 0.94)), # snow
362 (6500, (1.00, 1.00, 1.00)),
363]
364SEA_STOPS = [
365 (0, (0.47, 0.68, 0.74)), # shore
366 (-200, (0.31, 0.53, 0.63)), # shelf
367 (-1000, (0.19, 0.37, 0.52)),
368 (-2500, (0.11, 0.24, 0.38)),
369 (-4500, (0.05, 0.13, 0.24)),
370 (-9000, (0.02, 0.06, 0.14)), # trench
371]
372
373
374def _ramp(h, stops):
375 xs = np.array([s[0] for s in stops], dtype=np.float64)
376 cs = np.array([s[1] for s in stops], dtype=np.float64)
377 if xs[0] > xs[-1]:
378 xs = xs[::-1]; cs = cs[::-1]
379 out = np.empty(h.shape + (3,))
380 for k in range(3):
381 out[..., k] = np.interp(h, xs, cs[:, k])
382 return out
383
384
385def hypsometric(h):
386 """Land and sea tinted on separate scales, so each gets its full range."""
387 rgb = _ramp(h, LAND_STOPS)
388 sea = _ramp(h, SEA_STOPS)
389 m = (h <= 0)[..., None]
390 return np.where(m, sea, rgb)
391
392
393def colourise_array(h, mpp, view, vmin, vmax, exag=1.0):
344394 """The colour computation without the PNG encode, so a caller can crop an
345395 apron off before encoding."""
346396 finite = h[np.isfinite(h)]
……@@ -354,8 +404,14 @@ def colourise_array(h, mpp, view, vmin, vmax):
354404 elif view == "height":
355405 t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
356406 rgb = np.stack([0.15 + 0.85 * t, 0.25 + 0.6 * t, 0.35 + 0.3 * (1 - t)], axis=-1)
407 elif view == "hypsometric":
408 # Exaggeration affects the SHADING only, never the colour -- the tint
409 # keeps telling the truth about elevation while the relief becomes
410 # legible. Submarine relief needs 5-10x before it reads at all.
411 sh = hillshade(h, mpp, exaggeration=exag)
412 rgb = hypsometric(h) * (0.30 + 0.70 * sh[..., None])
357413 else:
358 sh = hillshade(h, mpp)
414 sh = hillshade(h, mpp, exaggeration=exag)
359415 if view == "shaded-height":
360416 t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
361417 base = np.stack([0.20 + 0.75 * t, 0.30 + 0.55 * t,
……@@ -574,8 +630,9 @@ def api_tile():
574630 # Shade WITH the apron, then crop it. Shading the bare tile is what put
575631 # a seam on every join even though the elevation is continuous.
576632 full = a.astype(np.float64)
577 shaded = colourise_array(full, 30.0, request.args.get("view", "shaded-height"),
578 vmin, vmax)
633 shaded = colourise_array(full, 30.0, request.args.get("view", "hypsometric"),
634 vmin, vmax,
635 float(request.args.get("exag", 1.0)))
579636 ap = tiles().APRON
580637 png = to_png(shaded[ap:-ap, ap:-ap] if ap else shaded)
581638 r = Response(png, mimetype="image/png")
……@@ -609,11 +666,12 @@ def api_coarse():
609666 elev = coarse_to_metres(a[..., 0].astype(np.float64))
610667 vmin = float(request.args.get("vmin", -4000))
611668 vmax = float(request.args.get("vmax", 4000))
612 view = request.args.get("view", "shaded-height")
669 view = request.args.get("view", "hypsometric")
613670 if view == "terrain-class":
614671 rgb = colourise_terrain_class(elev)
615672 else:
616 rgb = colourise_array(elev, 7680.0, view, vmin, vmax)
673 rgb = colourise_array(elev, 7680.0, view, vmin, vmax,
674 float(request.args.get("exag", 1.0)))
617675 resp = Response(to_png(rgb), mimetype="image/png")
618676 resp.headers["Cache-Control"] = "public, max-age=3600"
619677 resp.headers["X-Height"] = str(h); resp.headers["X-Width"] = str(w)