OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / b2dfe5c

explore: coarse-stage backdrop for zoomed-out navigation
The page kept provoking "why is it blurry over there", and the answer was always resolution rather than the model. This makes the cheap layer explicit. UPSTREAM: a /coarse endpoint on the local terrain-diffusion clone. self.coarse was already a separately-indexable InfiniteTensor (world_pipeline.py:1435) and only the refined output was reachable over HTTP, so a zoomed-out view had to pay for 30 m/px it could not display -- 445 tiles queued to be drawn at 40 px each. 246 x 246 km (60,516 km2) in 1.78 s = ~34,000 km2/s against the detail stage's ~50 km2/s. ~700x cheaper per km2. CALIBRATION, measured not assumed. Channel 0 is elevation under the paper's "compact Laplacian encoding". Fitted against 254 cached detail tiles: signed square sign(c0)*c0^2 R2 0.9952 coef 0.9953 offset 11.8 m linear R2 0.9126 Coefficient 1.0 and a 12 m offset means the encoding is exactly signed-sqrt of metres, so it is used as such rather than with fitted constants. The coarse map is drawn as a BACKDROP under everything and interpolated (the one place smoothing is correct). Detail tiles paint over it as they arrive, so a zoomed-out view is instant and correct instead of a field of dark holes. AND IT IS LABELLED. Coarse-only areas carry an amber tint and the HUD reads "N detail @ 30 m/px · M coarse @ 7.68 km/px — coarse is 256x lower resolution, a PLACEHOLDER, not the model being blurry". Every previous round of this question cost a screenshot and a measurement; the page now answers it. Also bounds the queue server-side. The client capped what it asked for per poll, but polls repeat -- which is how a 12-per-poll cap became a 376-deep queue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b2dfe5c42f7647b740b11fc51145897fa0b95ec8
by plankatron <noreply@anthropic.com> · 2026-08-31T19:25:20.000Z

Changed files (2) +114 -9

explore.html +59 -7
……@@ -45,7 +45,8 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
4545 <button id="gomtn">the mountain region</button>
4646 <button id="clearq">stop prefetching</button>
4747</div>
48<div class="hud" id="help">drag to pan · wheel to zoom · one tile generates at a time, by design</div>
48<div class="hud" id="help">drag to pan · wheel to zoom · one tile generates at a time, by design
49<span style="color:var(--warn)">amber tint</span> = coarse placeholder (7.68 km/px), sharpens as detail tiles arrive · <span style="color:var(--accent)">cyan</span> = generating now</div>
4950
5051<script>
5152const TILE_PX = 256; // must match the server's tile_px
……@@ -53,6 +54,12 @@ const TILE_PX = 256; // must match the server's tile_px
5354// hour, so without this in the URL a shading or apron change is invisible until
5455// the cache expires -- the page keeps drawing images from a build that is gone.
5556let TILE_VERSION = 3;
57const COARSE_RATIO = 256; // 1 coarse px = 256 detail px = 7.68 km
58// The coarse stage is ~700x cheaper per km2 than the detail stage (34,000 vs
59// ~50 km2/s measured). It is drawn as a BACKDROP under everything, so a
60// zoomed-out view is instant and correct instead of a field of dark holes, and
61// detail tiles simply paint over it as they arrive.
62let coarseImg = null, coarseBox = null, coarsePending = false;
5663const M_PER_PX = 30; // model native
5764const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile
5865
……@@ -111,10 +118,23 @@ function tileImage(tx, ty) {
111118 return "pending";
112119}
113120
121let nDetail = 0, nCoarse = 0;
114122function draw() {
123 nDetail = 0; nCoarse = 0;
115124 ctx.fillStyle = "#0d1114";
116125 ctx.fillRect(0, 0, innerWidth, innerHeight);
117126 const s = view.scale, size = TILE_PX * s;
127
128 // backdrop first
129 if (coarseImg && coarseBox) {
130 const bx = innerWidth/2 + (coarseBox.cj1 * COARSE_RATIO - view.x) * s;
131 const by = innerHeight/2 + (coarseBox.ci1 * COARSE_RATIO - view.y) * s;
132 const bw = (coarseBox.cj2 - coarseBox.cj1) * COARSE_RATIO * s;
133 const bh = (coarseBox.ci2 - coarseBox.ci1) * COARSE_RATIO * s;
134 ctx.imageSmoothingEnabled = true; // the coarse layer SHOULD interpolate
135 ctx.drawImage(coarseImg, bx, by, bw, bh);
136 ctx.imageSmoothingEnabled = false;
137 }
118138 const cxs = innerWidth/2, cys = innerHeight/2;
119139 const showGrid = $("grid").checked;
120140
……@@ -124,13 +144,22 @@ function draw() {
124144 const im = tileImage(tx, ty);
125145 if (im && im !== "pending") {
126146 ctx.drawImage(im, sx, sy, size + 1, size + 1);
147 nDetail++;
127148 } else {
149 nCoarse++;
150 // No opaque fill any more -- the coarse backdrop shows through, which is
151 // the whole point of having it. Only mark what is actively generating.
128152 const st = tileState[tx + "," + ty] || "missing";
129 ctx.fillStyle = st === "busy" ? "#1d3038" : "#141a20";
130 ctx.fillRect(sx, sy, size, size);
131 if (size < 48) { // below the request threshold: say so, do not pretend
132 ctx.fillStyle = "#243039";
133 ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
153 if (st === "busy") {
154 ctx.fillStyle = "rgba(116,194,205,0.10)";
155 ctx.fillRect(sx, sy, size, size);
156 } else if (showGrid) {
157 // These areas are the COARSE backdrop at 7.68 km/px, not detail. They
158 // look soft because they ARE soft -- 256x less resolution. Without a
159 // marker it reads as "the model is blurry over there", which is the
160 // question this page kept provoking.
161 ctx.fillStyle = "rgba(215,164,92,0.055)";
162 ctx.fillRect(sx, sy, size, size);
134163 }
135164 }
136165 if (showGrid) {
……@@ -155,7 +184,27 @@ function frame() { if (dirty) draw(); requestAnimationFrame(frame); }
155184requestAnimationFrame(frame);
156185
157186// ---- prefetch: ask for what is visible, plus N rings, centred on the view ----
187async function ensureCoarse() {
188 if (coarsePending) return;
189 const w = innerWidth / view.scale, h = innerHeight / view.scale;
190 const pad = COARSE_RATIO * 4;
191 const ci1 = Math.floor((view.y - h/2 - pad) / COARSE_RATIO);
192 const ci2 = Math.ceil ((view.y + h/2 + pad) / COARSE_RATIO);
193 const cj1 = Math.floor((view.x - w/2 - pad) / COARSE_RATIO);
194 const cj2 = Math.ceil ((view.x + w/2 + pad) / COARSE_RATIO);
195 if (coarseBox && ci1 >= coarseBox.ci1 && ci2 <= coarseBox.ci2 &&
196 cj1 >= coarseBox.cj1 && cj2 <= coarseBox.cj2) return; // still covered
197 if ((ci2-ci1) * (cj2-cj1) > 90000) return;
198 coarsePending = true;
199 const box = {ci1, cj1, ci2, cj2};
200 const im = new Image();
201 im.onload = () => { coarseImg = im; coarseBox = box; coarsePending = false; dirty = true; };
202 im.onerror = () => { coarsePending = false; };
203 im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}&v=${TILE_VERSION}`;
204}
205
158206async function poll() {
207 ensureCoarse();
159208 const vis = visibleTiles();
160209 const rings = +$("pre").value;
161210 // DO NOT queue what you cannot see the detail of. At low zoom a 7.68 km tile
……@@ -242,7 +291,10 @@ function hud() {
242291
243292<span class="k">generated </span>${s.generated||0} tiles <span class="k">=</span> ${(s.km2||0).toFixed(0)} km²
244293<span class="k">avg gen </span>${(s.avg_gen_s||0).toFixed(2)} s/tile ${(s.km2_per_s||0).toFixed(1)} km²/s
245<span class="k">queue </span><span class="${(s.queue||0)>12?'w':'k'}">${s.queue||0}</span><span class="k"> / cap 12</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span>
294<span class="k">queue </span><span class="${(s.queue||0)>12?'w':'k'}">${s.queue||0}</span><span class="k"> / cap 16</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span>
295<span class="k">on screen </span><span class="g">${nDetail} detail</span> @ 30 m/px · <span class="w">${nCoarse} coarse</span> @ 7.68 km/px
296<span class="k"> </span>coarse is 256x lower resolution — it is a PLACEHOLDER, not the model being blurry
297<span class="k">coarse </span>${coarseImg ? `${((coarseBox.cj2-coarseBox.cj1)*7.68).toFixed(0)} x ${((coarseBox.ci2-coarseBox.ci1)*7.68).toFixed(0)} km backdrop` : "loading…"}
246298<span class="k">cache </span>${s.disk_tiles||0} on disk, ${s.mem_tiles||0} in memory
247299<span class="k">hits </span>${s.from_disk||0} disk, ${s.from_mem||0} mem${(s.errors||0)?`\n<span class="w">errors ${s.errors}</span>`:""}`;
248300}
server.py +55 -2
……@@ -42,6 +42,21 @@ _tiles = None # TileStore, built lazily so importing this is cheap
4242# them, all rendered by the previous build, all mis-joined.
4343TILE_VERSION = 3
4444
45# 1 coarse px = 256 detail px = 7.68 km for the 30 m model.
46COARSE_RATIO = 256
47
48# Channel 0 of the coarse map is elevation under a SIGNED-SQUARE encoding --
49# the paper's "compact Laplacian encoding to stabilise outputs across
50# Earth-scale dynamic ranges". Calibrated against 254 cached detail tiles:
51#
52# metres = 0.9953 * sign(c0)*c0^2 + 11.8 R2 = 0.9952
53#
54# against R2 0.9126 for a linear fit. The coefficient being 1.0 and the offset
55# 12 m says the encoding is exactly signed-sqrt of metres, so it is used as
56# such rather than with the fitted constants.
57def coarse_to_metres(c0):
58 return np.sign(c0) * c0 * c0
59
4560
4661def tiles():
4762 global _tiles
……@@ -528,6 +543,38 @@ def api_tile():
528543 return Response(str(e), status=400, mimetype="text/plain")
529544
530545
546@app.get("/api/coarse.png")
547def api_coarse():
548 """The coarse stage as a backdrop — 7.68 km/px, ~700x cheaper per km2.
549
550 Measured: 246 x 246 km (60,516 km2) in 1.78 s = ~34,000 km2/s, against the
551 detail stage's ~50 km2/s. This is what a zoomed-out view should be made of;
552 generating 30 m/px to draw it at 132 m per screen pixel is the waste the
553 explore page was built to expose."""
554 try:
555 ci1 = int(request.args["ci1"]); cj1 = int(request.args["cj1"])
556 ci2 = int(request.args["ci2"]); cj2 = int(request.args["cj2"])
557 if (ci2 - ci1) * (cj2 - cj1) > 90000:
558 raise ValueError("coarse region too large")
559 q = (f"{app.config['TD_URL']}/coarse?i1={ci1}&j1={cj1}&i2={ci2}&j2={cj2}")
560 with urllib.request.urlopen(q, timeout=1800) as r:
561 h = int(r.headers["X-Height"]); w = int(r.headers["X-Width"])
562 c = int(r.headers["X-Channels"]); b = r.read()
563 a = np.frombuffer(b, dtype="<f4").reshape(h, w, c)
564 elev = coarse_to_metres(a[..., 0].astype(np.float64))
565 vmin = float(request.args.get("vmin", -4000))
566 vmax = float(request.args.get("vmax", 4000))
567 rgb = colourise_array(elev, 7680.0, request.args.get("view", "shaded-height"),
568 vmin, vmax)
569 resp = Response(to_png(rgb), mimetype="image/png")
570 resp.headers["Cache-Control"] = "public, max-age=3600"
571 resp.headers["X-Height"] = str(h); resp.headers["X-Width"] = str(w)
572 resp.headers["X-Elev"] = f"{elev.min():.0f},{elev.max():.0f}"
573 return resp
574 except Exception as e:
575 return Response(str(e), status=400, mimetype="text/plain")
576
577
531578@app.get("/api/tiles/request")
532579def api_tiles_request():
533580 """Queue a batch, prioritised by distance from the view centre."""
……@@ -541,8 +588,14 @@ def api_tiles_request():
541588 want.append((int(x), int(y)))
542589 cx = int(request.args.get("cx", 0)); cy = int(request.args.get("cy", 0))
543590 ts = tiles()
544 ts.request(want[:400], (cx, cy))
545 return Response(json.dumps({"queued": len(want), "depth": ts.queue_depth()}),
591 # The client caps what it ASKS for per poll, but polls repeat -- which is
592 # how a 12-per-poll cap became a 376-deep queue. Refuse to grow past a
593 # depth that can actually be delivered while the view is still current.
594 MAX_DEPTH = 16
595 room = max(0, MAX_DEPTH - ts.queue_depth())
596 ts.request(want[:room], (cx, cy))
597 return Response(json.dumps({"queued": min(len(want), room),
598 "depth": ts.queue_depth()}),
546599 mimetype="application/json")
547600 except Exception as e:
548601 return Response(str(e), status=400, mimetype="text/plain")