OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 1111fba

explore: the PLAYER is the origin of generation, not the viewport
WASD moves a player box held at the centre of the screen; the world moves under it. Tiles are requested inside a radius of the PLAYER's tile, sorted by distance from it, and only tiles inside that radius are ever generated. WHY THIS IS THE RIGHT MODEL AND THE VIEWPORT WAS NOT Anchoring generation to the viewport meant zoom controlled the workload: at 180 km across, 312 tiles were visible and the page queued 445 of them at full 30 m/px to draw each at 40 screen px. Every mitigation after that -- a minimum on-screen size, a per-poll cap, a server-side depth bound -- was patching a model that was wrong at the root. With the player as the origin the request set is (2r+1)^2 by construction: 25 tiles at r=2, however far the camera is zoomed out. The coarse layer covers whatever else is on screen, which is what it is for. Zoom becomes a viewing decision with no cost, which is what it should always have been. The camera can be detached (follow off) and the player keeps its own world position, so you can look somewhere without generating there. ALSO: BACK OFF ON FAILURE. The HUD read "errors 962" from one stopped backend. Each failure returned instantly, the worker took the next tile and failed instantly too, and the whole queue burned in seconds. Failures here are almost always the backend being down or busy, and hammering it helps nobody. Exponential backoff to 30 s, reset on the first success, and the last error string is surfaced in the stats instead of only a count -- a count says something is wrong, the string says what. (The 962 were real: the diffusion service had been killed during ONNX benchmarking and not restarted. The page could not say so, which is why the error string is now reported.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1111fba52f4d353fe612cee3af0c0672f35fe463
by plankatron <noreply@anthropic.com> · 2026-08-31T20:28:32.000Z

Changed files (3) +97 -23

__pycache__/tiles.cpython-312.pyc +0 -0
(no content changes — binary or rename)
explore.html +87 -23
……@@ -37,8 +37,11 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
3737<div class="hud" id="ctl">
3838 <label>zoom <span class="v" id="v_zoom">1.00</span>
3939 <input type="range" id="zoom" min="-3" max="1.6" step="0.05" value="0"></label>
40 <label>prefetch rings <span class="v" id="v_pre">1</span>
41 <input type="range" id="pre" min="0" max="3" step="1" value="1"></label>
40 <label>prefetch radius <span class="v" id="v_pre">2</span>
41 <input type="range" id="pre" min="1" max="5" step="1" value="2"></label>
42 <label>move speed <span class="v" id="v_speed">400</span> m/s
43 <input type="range" id="speed" min="1.7" max="3.7" step="0.05" value="2.6"></label>
44 <label><input type="checkbox" id="follow" checked style="width:auto"> camera follows player</label>
4245 <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label>
4346 <hr>
4447 <label style="display:block"><input type="checkbox" id="biome" style="width:auto">
……@@ -57,7 +60,7 @@ canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
5760 <button id="gomtn">the mountain region</button>
5861 <button id="clearq">stop prefetching</button>
5962</div>
60<div class="hud" id="help">drag to pan · wheel to zoom · one tile generates at a time, by design
63<div class="hud" id="help"><b>WASD</b> to move (shift = 8x) · drag to pan · wheel to zoom · one tile generates at a time, by design
6164<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>
6265
6366<script>
……@@ -93,6 +96,20 @@ const $ = id => document.getElementById(id);
9396//
9497// view.x is world j (columns, screen X); view.y is world i (rows, screen Y).
9598let view = {x: 2048 + 128, y: 128, scale: 0.25};
99
100// THE PLAYER IS THE ORIGIN OF GENERATION, not the viewport.
101//
102// Tiles are requested inside a radius of the PLAYER's tile, so what gets
103// generated depends on where the player is and not on how far the camera is
104// zoomed out. Zooming out used to queue 445 tiles of 30 m/px detail to draw
105// them at 40 px each; with generation anchored to the player that cannot
106// happen, and the coarse layer covers whatever else is on screen.
107//
108// Position is in model px (1 px = 30 m). Held in world j/i to match the tile
109// axes: player.x is j (east-west), player.y is i (north-south).
110const player = {x: view.x, y: view.y, vx: 0, vy: 0};
111const keys = new Set();
112let lastT = performance.now();
96113const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending"
97114let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error
98115let stats = {};
……@@ -189,10 +206,42 @@ function draw() {
189206 }
190207 }
191208 }
209 // the player, always at its world position (the camera may be detached)
210 const px = innerWidth/2 + (player.x - view.x) * s;
211 const py = innerHeight/2 + (player.y - view.y) * s;
212 const R = (+$("pre").value + 0.5) * TILE_PX * s;
213 ctx.strokeStyle = "rgba(116,194,205,0.35)";
214 ctx.lineWidth = 1; ctx.setLineDash([6, 5]);
215 ctx.strokeRect(px - R, py - R, R * 2, R * 2);
216 ctx.setLineDash([]);
217 const box = Math.max(10, 0.20 * TILE_PX * s); // ~1.5 km
218 ctx.strokeStyle = "#74c2cd"; ctx.lineWidth = 2;
219 ctx.strokeRect(px - box/2, py - box/2, box, box);
220 ctx.fillStyle = "rgba(116,194,205,0.20)";
221 ctx.fillRect(px - box/2, py - box/2, box, box);
222
192223 dirty = false;
193224}
194225
195function frame() { if (dirty) draw(); requestAnimationFrame(frame); }
226function speedMps() { return Math.pow(10, +$("speed").value); }
227
228function step(now) {
229 const dt = Math.min(0.1, (now - lastT) / 1000);
230 lastT = now;
231 let dx = (keys.has("d") ? 1 : 0) - (keys.has("a") ? 1 : 0);
232 let dy = (keys.has("s") ? 1 : 0) - (keys.has("w") ? 1 : 0);
233 if (dx || dy) {
234 const n = Math.hypot(dx, dy) || 1;
235 const mps = speedMps() * (keys.has("shift") ? 8 : 1);
236 const pxPerS = mps / M_PER_PX; // model px per second
237 player.x += (dx / n) * pxPerS * dt;
238 player.y += (dy / n) * pxPerS * dt;
239 if ($("follow").checked) { view.x = player.x; view.y = player.y; }
240 dirty = true;
241 }
242}
243
244function frame(now) { step(now || performance.now()); if (dirty) draw(); requestAnimationFrame(frame); }
196245requestAnimationFrame(frame);
197246
198247// ---- prefetch: ask for what is visible, plus N rings, centred on the view ----
……@@ -219,7 +268,6 @@ async function ensureCoarse() {
219268
220269async function poll() {
221270 ensureCoarse();
222 const vis = visibleTiles();
223271 const rings = +$("pre").value;
224272 // DO NOT queue what you cannot see the detail of. At low zoom a 7.68 km tile
225273 // lands on ~40 screen px, so generating 30 m/px for it is pure waste -- and
……@@ -236,31 +284,29 @@ async function poll() {
236284 // MAX_QUEUE: never ask for more than can be delivered while you are still
237285 // looking at it. One tile at a time is by design, so a queue longer than this
238286 // is a promise the page cannot keep -- and it is stale the moment you pan.
239 const MIN_PX = 110;
287 // The request set is a RADIUS AROUND THE PLAYER, not the viewport. Bounded by
288 // construction: (2r+1)^2 tiles, 25 at r=2, however far you zoom out.
289 const pti = Math.floor(player.y / TILE_PX); // i = north-south
290 const ptj = Math.floor(player.x / TILE_PX); // j = east-west
240291 const MAX_QUEUE = 12;
241 const onScreen = TILE_PX * view.scale;
242 if (onScreen < MIN_PX) {
243 try {
244 const r = await fetch(`/api/tiles/state?tiles=`);
245 stats = (await r.json()).stats; hud();
246 } catch (e) {}
247 return;
248 }
249 const want = new Set(vis.map(t => t.join(",")));
250 if (rings > 0) {
251 for (const [tx, ty] of vis)
252 for (let dy = -rings; dy <= rings; dy++)
253 for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy));
254 }
292 const want = new Set();
293 for (let di = -rings; di <= rings; di++)
294 for (let dj = -rings; dj <= rings; dj++) want.add((pti+di) + "," + (ptj+dj));
295 for (const [tx, ty] of visibleTiles()) want.add(tx + "," + ty); // state only
255296 // centre expressed as (tx, ty) = (i, j), matching the server's ordering
256 const ctx_ = [Math.floor(view.y / TILE_PX), Math.floor(view.x / TILE_PX)];
297 const ctx_ = [pti, ptj]; // priority is distance from the PLAYER
257298 // Sort by distance from the view centre and ask only for the nearest few.
258299 const sorted = [...want].map(k => {
259300 const [tx, ty] = k.split(",").map(Number);
260301 return {k, d: (tx - ctx_[0]) ** 2 + (ty - ctx_[1]) ** 2};
261302 }).sort((a, b) => a.d - b.d);
262303 const list = sorted.map(o => o.k).join(";"); // state for all
263 const ask = sorted.slice(0, MAX_QUEUE).map(o => o.k).join(";"); // generate a few
304 // only tiles inside the player's radius are ever GENERATED
305 const inRing = sorted.filter(o => {
306 const [ti, tj] = o.k.split(",").map(Number);
307 return Math.abs(ti - pti) <= rings && Math.abs(tj - ptj) <= rings;
308 });
309 const ask = inRing.slice(0, MAX_QUEUE).map(o => o.k).join(";");
264310 try {
265311 if (!STOPPED) {
266312 await fetch(`/api/tiles/request?tiles=${encodeURIComponent(ask)}` +
……@@ -299,7 +345,9 @@ function hud() {
299345 const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1);
300346 $("stats").innerHTML =
301347`<span class="a">INFINITE DIFFUSION — STREAMING</span>
302<span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.y/TILE_PX)},${Math.floor(view.x/TILE_PX)}<span class="k">)</span>
348<span class="k">player </span>${(player.x*M_PER_PX/1000).toFixed(2)} , ${(player.y*M_PER_PX/1000).toFixed(2)} km <span class="k">tile</span> ${Math.floor(player.y/TILE_PX)},${Math.floor(player.x/TILE_PX)}
349<span class="k">ring </span>${(2*(+$("pre").value)+1)}x${(2*(+$("pre").value)+1)} tiles = ${((2*(+$("pre").value)+1)*7.68).toFixed(1)} km across
350<span class="k">camera </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.y/TILE_PX)},${Math.floor(view.x/TILE_PX)}<span class="k">)</span>
303351<span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km
304352<span class="k">visible </span>${visibleTiles().length} tiles <span class="k">on-screen</span> ${(TILE_PX*view.scale).toFixed(0)} px${TILE_PX*view.scale < 48 ? ' <span class="w">— too small, not requesting</span>' : ''}
305353
……@@ -347,5 +395,21 @@ $("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); };
347395// the mountain region is i=0, j=2048 -> screen y=128, x=2176
348396$("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); };
349397
398addEventListener("keydown", e => {
399 if (/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)) return;
400 const k = e.key.toLowerCase();
401 if ("wasd".includes(k) || k === "shift") { keys.add(k === "shift" ? "shift" : k); e.preventDefault(); }
402});
403addEventListener("keyup", e => {
404 const k = e.key.toLowerCase();
405 keys.delete(k === "shift" ? "shift" : k);
406});
407addEventListener("blur", () => keys.clear());
408$("speed").addEventListener("input", () => {
409 $("v_speed").textContent = speedMps() < 1000 ? speedMps().toFixed(0)
410 : (speedMps()/1000).toFixed(1) + "k";
411});
412$("speed").dispatchEvent(new Event("input"));
413
350414resize(); setZoom(-2); hud(); poll();
351415</script>
tiles.py +10 -0
……@@ -157,7 +157,17 @@ class TileStore:
157157 except Exception as ex:
158158 self.stats["errors"] += 1
159159 self._state[(tx, ty)] = "error"
160 self.stats["last_error"] = str(ex)[:120]
160161 self.log(f" tile ({tx},{ty}) FAILED: {ex}")
162 # BACK OFF. Without this a downed backend fails instantly, the
163 # worker takes the next tile and fails instantly too, and the
164 # whole queue burns in seconds -- observed as 962 errors from
165 # one stopped service. Failures are almost always the backend,
166 # and hammering it helps nobody.
167 self._fail_streak = getattr(self, "_fail_streak", 0) + 1
168 time.sleep(min(30.0, 0.5 * (2 ** min(self._fail_streak, 6))))
169 else:
170 self._fail_streak = 0
161171
162172 def drop_pending(self):
163173 """Forget everything not yet started. The in-flight tile finishes."""