OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 4005584

explore: cap the queue, and shade an apron so tiles join cleanly
Two problems reported off a screenshot, and they are unrelated. 1. "TILES DO NOT MATCH UP" — a rendering bug, mine. The ELEVATION has no seam. Measured across 12 adjacent cached pairs generated as independent requests: join step 6.25 m vs interior step 6.14 m, ratio 1.02x. The data is continuous. The seam was entirely in the shading. np.gradient falls back to ONE-SIDED differences at an array edge, so every tile's outer row and column is lit differently from its interior and neighbours mismatch exactly at the join. Tiles are now stored with a 1 px apron, shaded, then cropped. Old apron-less tiles are renamed t_legacy_* rather than deleted. They cannot be reused — they lack the apron — so those regions regenerate once. 2. "THE FURTHER AWAY THE TILES GET THEY BLUR OUT" — not a bug, twice over. Measured on 150 cached tiles, controlling for what the tile contains: near half far half ratio land, detail/relief 0.0333 0.0363 1.09 ocean, detail/relief 0.0143 0.0124 0.86 Land tiles have slightly MORE detail further out. The raw signal (0.296x) was confounded: the far tiles in cache are nearly all abyssal plain at -3200 to -4000 m, and ocean floor genuinely carries 4x less high-frequency detail than land. No distance degradation, no coordinate-precision loss. What DOES make it look soft is zoom and contrast, both mine: - at the screenshot's "on-screen 97 px" a 7.68 km tile is 79 m per screen pixel, so a 30 m/px field is being viewed 2.6x below its own resolution. - the ramp is pinned to +/-4000 m so every tile shares a scale, which means terrain living in a 500-2500 m band uses a quarter of it. 3. THE QUEUE, which is the urgent one. MIN_PX was 48 and tiles were on screen at 97 px, so the gate never fired: one view change queued all 220 visible tiles plus a ring, 445 deep, at ~11 s each. 84 minutes of GPU from a single pan. MIN_PX is now 110 and the request is capped to the 12 nearest tiles, re-sorted on every poll. A queue longer than can be delivered while you are still looking is a promise the page cannot keep, and it is stale the moment you pan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4005584da528b8d0dd6a44d846ff426697d4a0dd
by plankatron <noreply@anthropic.com> · 2026-08-31T19:10:27.000Z

Changed files (4) +70 -10

__pycache__/tiles.cpython-312.pyc +0 -0
(no content changes — binary or rename)
explore.html +21 -4
……@@ -145,7 +145,18 @@ async function poll() {
145145 // the queue explodes (343 deep on first load). Below MIN_PX the tile is drawn
146146 // as a placeholder and never requested; that band is exactly where the coarse
147147 // stage (7.7 km/px, cheap) belongs, and is the next thing to wire in.
148 const MIN_PX = 48;
148 // TWO limits, and the first run had neither working.
149 //
150 // MIN_PX: below this a tile is a thumbnail and generating 30 m/px for it is
151 // waste. 48 was far too permissive -- at 97 px on screen the gate never fired
152 // and the page queued all 220 visible tiles plus a ring: 445 deep, at ~11 s
153 // each, 84 minutes of GPU from ONE view change.
154 //
155 // MAX_QUEUE: never ask for more than can be delivered while you are still
156 // looking at it. One tile at a time is by design, so a queue longer than this
157 // is a promise the page cannot keep -- and it is stale the moment you pan.
158 const MIN_PX = 110;
159 const MAX_QUEUE = 12;
149160 const onScreen = TILE_PX * view.scale;
150161 if (onScreen < MIN_PX) {
151162 try {
……@@ -160,11 +171,17 @@ async function poll() {
160171 for (let dy = -rings; dy <= rings; dy++)
161172 for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy));
162173 }
163 const list = [...want].join(";");
164174 const ctx_ = [Math.floor(view.x / TILE_PX), Math.floor(view.y / TILE_PX)];
175 // Sort by distance from the view centre and ask only for the nearest few.
176 const sorted = [...want].map(k => {
177 const [x, y] = k.split(",").map(Number);
178 return {k, d: (x - ctx_[0]) ** 2 + (y - ctx_[1]) ** 2};
179 }).sort((a, b) => a.d - b.d);
180 const list = sorted.map(o => o.k).join(";"); // state for all
181 const ask = sorted.slice(0, MAX_QUEUE).map(o => o.k).join(";"); // generate a few
165182 try {
166183 if (!STOPPED) {
167 await fetch(`/api/tiles/request?tiles=${encodeURIComponent(list)}` +
184 await fetch(`/api/tiles/request?tiles=${encodeURIComponent(ask)}` +
168185 `&cx=${ctx_[0]}&cy=${ctx_[1]}`);
169186 }
170187 const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`);
……@@ -201,7 +218,7 @@ function hud() {
201218
202219<span class="k">generated </span>${s.generated||0} tiles <span class="k">=</span> ${(s.km2||0).toFixed(0)} km²
203220<span class="k">avg gen </span>${(s.avg_gen_s||0).toFixed(2)} s/tile ${(s.km2_per_s||0).toFixed(1)} km²/s
204<span class="k">queue </span><span class="${(s.queue||0)>0?'w':'k'}">${s.queue||0}</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span>
221<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>
205222<span class="k">cache </span>${s.disk_tiles||0} on disk, ${s.mem_tiles||0} in memory
206223<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>`:""}`;
207224}
server.py +34 -3
……@@ -272,6 +272,33 @@ def to_png(rgb):
272272 return buf.getvalue()
273273
274274
275def colourise_array(h, mpp, view, vmin, vmax):
276 """The colour computation without the PNG encode, so a caller can crop an
277 apron off before encoding."""
278 finite = h[np.isfinite(h)]
279 lo = float(vmin) if vmin is not None else (float(finite.min()) if finite.size else 0.0)
280 hi = float(vmax) if vmax is not None else (float(finite.max()) if finite.size else 1.0)
281 if hi <= lo:
282 hi = lo + 1.0
283 if view == "slope":
284 s = np.clip(slope_map(h, mpp) / 60.0, 0, 1)
285 rgb = np.stack([s, 1.0 - s * 0.65, 1.0 - s], axis=-1)
286 elif view == "height":
287 t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
288 rgb = np.stack([0.15 + 0.85 * t, 0.25 + 0.6 * t, 0.35 + 0.3 * (1 - t)], axis=-1)
289 else:
290 sh = hillshade(h, mpp)
291 if view == "shaded-height":
292 t = np.nan_to_num(np.clip((h - lo) / (hi - lo), 0, 1))
293 base = np.stack([0.20 + 0.75 * t, 0.30 + 0.55 * t,
294 0.38 + 0.22 * (1 - t)], axis=-1)
295 rgb = base * (0.35 + 0.65 * sh[..., None])
296 else:
297 rgb = np.repeat(sh[..., None], 3, axis=-1)
298 rgb = np.where(np.isfinite(h)[..., None], rgb, 0.0)
299 return (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
300
301
275302def colourise(h, mpp, view, vmin=None, vmax=None):
276303 """view: shade | height | slope | shaded-height
277304
……@@ -476,9 +503,13 @@ def api_tile():
476503 # reads as terrain discontinuity -- the same misread as upstream #19.
477504 vmin = float(request.args.get("vmin", -4000))
478505 vmax = float(request.args.get("vmax", 4000))
479 png, _lo, _hi = colourise(a.astype(np.float64), 30.0,
480 request.args.get("view", "shaded-height"),
481 vmin, vmax)
506 # Shade WITH the apron, then crop it. Shading the bare tile is what put
507 # a seam on every join even though the elevation is continuous.
508 full = a.astype(np.float64)
509 shaded = colourise_array(full, 30.0, request.args.get("view", "shaded-height"),
510 vmin, vmax)
511 ap = tiles().APRON
512 png = to_png(shaded[ap:-ap, ap:-ap] if ap else shaded)
482513 r = Response(png, mimetype="image/png")
483514 r.headers["Cache-Control"] = "public, max-age=3600"
484515 r.headers["X-Tile"] = f"{tx},{ty}"
tiles.py +15 -3
……@@ -57,8 +57,19 @@ class TileStore:
5757 self._worker.start()
5858
5959 # ---- addressing --------------------------------------------------------
60 # Tiles are stored with a 1 px APRON on every side.
61 #
62 # np.gradient falls back to ONE-SIDED differences at an array edge, so a
63 # tile shaded on its own has its outer row and column lit differently from
64 # its interior -- and adjacent tiles then mismatch exactly at the join. The
65 # elevation has no seam (measured: join step 6.25 m vs inner 6.14 m, ratio
66 # 1.02x); the seam was entirely in the shading. Shade the apron, crop it off.
67 #
68 # v2 filename, so the old apron-less cache is ignored rather than deleted.
69 APRON = 1
70
6071 def path(self, tx, ty):
61 return os.path.join(self.dir, f"t_{self.tile_px}_{tx}_{ty}.npy")
72 return os.path.join(self.dir, f"t2_{self.tile_px}_{tx}_{ty}.npy")
6273
6374 def state(self, tx, ty):
6475 k = (tx, ty)
……@@ -118,7 +129,8 @@ class TileStore:
118129 t0 = time.time()
119130 try:
120131 n = self.tile_px
121 e, _c = self.fetch(tx * n, ty * n, n)
132 a_ = self.APRON
133 e, _c = self.fetch(tx * n - a_, ty * n - a_, n + 2 * a_)
122134 a = np.clip(e, -32768, 32767).astype(np.int16)
123135 np.save(self.path(tx, ty), a)
124136 self._mem[(tx, ty)] = a
……@@ -140,7 +152,7 @@ class TileStore:
140152 s["mem_tiles"] = len(self._mem)
141153 try:
142154 s["disk_tiles"] = len([f for f in os.listdir(self.dir)
143 if f.startswith(f"t_{self.tile_px}_")])
155 if f.startswith(f"t2_{self.tile_px}_")])
144156 except OSError:
145157 s["disk_tiles"] = 0
146158 s["avg_gen_s"] = (s["gen_seconds"] / s["generated"]) if s["generated"] else 0.0