<!doctype html>
<meta charset="utf-8">
<title>Infinite Terrain — Explore</title>
<style>
:root{
--bg:#0f141a; --panel:#171d24; --edge:#26313a; --edge2:#354350;
--ink:#e3e9e6; --dim:#8b9aa1; --accent:#74c2cd; --warn:#d7a45c; --ok:#77c39a;
font-family:"IBM Plex Sans",system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-size:13px}
#map{position:absolute;inset:0;cursor:grab;touch-action:none}
#map:active{cursor:grabbing}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.hud{position:absolute;background:var(--panel);border:1px solid var(--edge);
border-radius:4px;padding:11px 13px;font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:11px;line-height:1.65;white-space:pre;pointer-events:none;
font-variant-numeric:tabular-nums}
#stats{top:12px;left:12px}
#help{bottom:12px;left:12px;color:var(--dim);font-size:10.5px}
#ctl{top:12px;right:12px;pointer-events:auto;white-space:normal;width:214px}
#ctl label{display:grid;grid-template-columns:1fr auto;gap:2px 8px;margin:7px 0;
font-family:"IBM Plex Sans",sans-serif;font-size:11.5px;align-items:center}
#ctl input[type=range]{grid-column:1/-1;width:100%;accent-color:var(--accent)}
#ctl .v{color:var(--accent);font-family:"IBM Plex Mono",monospace}
#ctl button{width:100%;margin-top:7px;background:#22303a;color:var(--ink);
border:1px solid var(--edge2);border-radius:3px;padding:5px;
font:inherit;font-size:11px;cursor:pointer}
#ctl button:hover{border-color:var(--accent)}
#ctl hr{border:0;border-top:1px solid var(--edge);margin:11px 0}
.k{color:var(--dim)} .g{color:var(--ok)} .w{color:var(--warn)} .a{color:var(--accent)}
</style>
<div id="map"><canvas id="cv"></canvas></div>
<div class="hud" id="stats"></div>
<div class="hud" id="ctl">
<label>zoom <span class="v" id="v_zoom">1.00</span>
<input type="range" id="zoom" min="-3" max="1.6" step="0.05" value="0"></label>
<label>prefetch radius <span class="v" id="v_pre">2</span>
<input type="range" id="pre" min="1" max="5" step="1" value="2"></label>
<label>move speed <span class="v" id="v_speed">400</span> m/s
<input type="range" id="speed" min="1.7" max="3.7" step="0.05" value="2.6"></label>
<label><input type="checkbox" id="follow" checked style="width:auto"> camera follows player</label>
<label>vertical exaggeration <span class="v" id="v_exag">1</span>x
<input type="range" id="exag" min="1" max="14" step="0.5" value="1"></label>
<label><input type="checkbox" id="hyps" checked style="width:auto"> hypsometric tint (land/sea split)</label>
<label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label>
<hr>
<label style="display:block"><input type="checkbox" id="biome" style="width:auto">
backdrop: terrain class</label>
<div id="legend" hidden style="font-family:'IBM Plex Mono',monospace;font-size:10px;
line-height:1.7;color:var(--dim);margin:6px 0 2px">
<span style="color:#6b8c54">■</span> plains <60 m<br>
<span style="color:#8ca852">■</span> rolling 60–180 m<br>
<span style="color:#b8a859">■</span> hill country 180–400 m<br>
<span style="color:#bf8052">■</span> rugged 400–800 m<br>
<span style="color:#e0dbe6">■</span> alpine >800 m<br>
<span style="color:#5b6b8c">■</span> sea<br>
<span style="color:#6b7a80">relief over a 23 km window</span>
</div>
<button id="go0">origin (0,0)</button>
<button id="gomtn">the mountain region</button>
<button id="clearq">stop prefetching</button>
</div>
<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
<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>
<script>
const TILE_PX = 256; // must match the server's tile_px
// Must match server.py TILE_VERSION. Tile PNGs are browser-cacheable for an
// hour, so without this in the URL a shading or apron change is invisible until
// the cache expires -- the page keeps drawing images from a build that is gone.
let TILE_VERSION = 3;
const COARSE_RATIO = 256; // 1 coarse px = 256 detail px = 7.68 km
// The coarse stage is ~700x cheaper per km2 than the detail stage (34,000 vs
// ~50 km2/s measured). It is drawn as a BACKDROP under everything, so a
// zoomed-out view is instant and correct instead of a field of dark holes, and
// detail tiles simply paint over it as they arrive.
let coarseImg = null, coarseBox = null, coarsePending = false;
const M_PER_PX = 30; // model native
const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile
const cv = document.getElementById("cv"), ctx = cv.getContext("2d", {alpha:false});
const $ = id => document.getElementById(id);
const tileView = () => ($("hyps") && $("hyps").checked) ? "hypsometric" : "shaded-height";
// AXES, and getting this wrong is what made the terrain "not line up".
//
// A tile is fetched as fetch(i1 = tx*n, j1 = ty*n) and stored as array[i, j].
// Image.fromarray maps axis 0 -> rows -> screen Y. So:
//
// tx indexes i -> SCREEN Y (north-south)
// ty indexes j -> SCREEN X (east-west)
//
// The first version put tx on screen X, which lays every tile out transposed
// relative to its own content. Measured: joins stepped 6.86x an interior pixel.
// With the mapping below, 0.82x -- indistinguishable. The DATA was always
// exact (0.00 m across 108 overlapping pairs); this was pure bookkeeping.
//
// view.x is world j (columns, screen X); view.y is world i (rows, screen Y).
let view = {x: 2048 + 128, y: 128, scale: 0.25};
// THE PLAYER IS THE ORIGIN OF GENERATION, not the viewport.
//
// Tiles are requested inside a radius of the PLAYER's tile, so what gets
// generated depends on where the player is and not on how far the camera is
// zoomed out. Zooming out used to queue 445 tiles of 30 m/px detail to draw
// them at 40 px each; with generation anchored to the player that cannot
// happen, and the coarse layer covers whatever else is on screen.
//
// Position is in model px (1 px = 30 m). Held in world j/i to match the tile
// axes: player.x is j (east-west), player.y is i (north-south).
const player = {x: view.x, y: view.y, vx: 0, vy: 0};
const keys = new Set();
let lastT = performance.now();
const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending"
let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error
let stats = {};
let dirty = true;
function resize() {
const r = devicePixelRatio || 1;
cv.width = innerWidth * r; cv.height = innerHeight * r;
ctx.setTransform(r, 0, 0, r, 0, 0);
ctx.imageSmoothingEnabled = false;
dirty = true;
}
addEventListener("resize", resize);
function visibleTiles() {
const w = innerWidth / view.scale, h = innerHeight / view.scale;
const x0 = view.x - w/2, y0 = view.y - h/2;
// screen X spans world j -> ty ; screen Y spans world i -> tx
const tj0 = Math.floor(x0 / TILE_PX), tj1 = Math.floor((x0 + w) / TILE_PX);
const ti0 = Math.floor(y0 / TILE_PX), ti1 = Math.floor((y0 + h) / TILE_PX);
const out = [];
for (let tx = ti0; tx <= ti1; tx++)
for (let ty = tj0; ty <= tj1; ty++) out.push([tx, ty]);
return out;
}
function tileImage(tx, ty) {
const k = tx + "," + ty;
if (imgs.has(k)) return imgs.get(k);
const im = new Image();
im.onload = () => { imgs.set(k, im); dirty = true; };
im.onerror = () => { imgs.delete(k); }; // 204 = not ready yet
im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=${tileView()}` +
`&exag=${(+$("exag").value).toFixed(1)}&v=${TILE_VERSION}`;
imgs.set(k, "pending");
return "pending";
}
let nDetail = 0, nCoarse = 0;
function draw() {
nDetail = 0; nCoarse = 0;
ctx.fillStyle = "#0d1114";
ctx.fillRect(0, 0, innerWidth, innerHeight);
const s = view.scale, size = TILE_PX * s;
// backdrop first
if (coarseImg && coarseBox) {
const bx = innerWidth/2 + (coarseBox.cj1 * COARSE_RATIO - view.x) * s;
const by = innerHeight/2 + (coarseBox.ci1 * COARSE_RATIO - view.y) * s;
const bw = (coarseBox.cj2 - coarseBox.cj1) * COARSE_RATIO * s;
const bh = (coarseBox.ci2 - coarseBox.ci1) * COARSE_RATIO * s;
ctx.imageSmoothingEnabled = true; // the coarse layer SHOULD interpolate
ctx.drawImage(coarseImg, bx, by, bw, bh);
ctx.imageSmoothingEnabled = false;
}
const cxs = innerWidth/2, cys = innerHeight/2;
const showGrid = $("grid").checked;
for (const [tx, ty] of visibleTiles()) {
const sx = cxs + (ty * TILE_PX - view.x) * s; // ty = j = east-west
const sy = cys + (tx * TILE_PX - view.y) * s; // tx = i = north-south
const im = tileImage(tx, ty);
if (im && im !== "pending") {
ctx.drawImage(im, sx, sy, size + 1, size + 1);
nDetail++;
} else {
nCoarse++;
// No opaque fill any more -- the coarse backdrop shows through, which is
// the whole point of having it. Only mark what is actively generating.
const st = tileState[tx + "," + ty] || "missing";
if (st === "busy") {
ctx.fillStyle = "rgba(116,194,205,0.10)";
ctx.fillRect(sx, sy, size, size);
} else if (showGrid) {
// These areas are the COARSE backdrop at 7.68 km/px, not detail. They
// look soft because they ARE soft -- 256x less resolution. Without a
// marker it reads as "the model is blurry over there", which is the
// question this page kept provoking.
ctx.fillStyle = "rgba(215,164,92,0.055)";
ctx.fillRect(sx, sy, size, size);
}
}
if (showGrid) {
const st = tileState[tx + "," + ty] || "missing";
ctx.strokeStyle = st === "busy" ? "#74c2cd"
: st === "queued" ? "#d7a45c"
: (st === "cached" || st === "disk") ? "#2b3b45" : "#3a2b2b";
ctx.lineWidth = st === "busy" ? 2 : 1;
ctx.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1);
if (size > 90) {
ctx.fillStyle = "#8b9aa1";
ctx.font = "10px 'IBM Plex Mono', monospace";
ctx.fillText(`${tx},${ty}`, sx + 6, sy + 14);
if (st === "busy" || st === "queued") ctx.fillText(st, sx + 6, sy + 27);
}
}
}
// the player, always at its world position (the camera may be detached)
const px = innerWidth/2 + (player.x - view.x) * s;
const py = innerHeight/2 + (player.y - view.y) * s;
const R = (+$("pre").value + 0.5) * TILE_PX * s;
ctx.strokeStyle = "rgba(116,194,205,0.35)";
ctx.lineWidth = 1; ctx.setLineDash([6, 5]);
ctx.strokeRect(px - R, py - R, R * 2, R * 2);
ctx.setLineDash([]);
const box = Math.max(10, 0.20 * TILE_PX * s); // ~1.5 km
ctx.strokeStyle = "#74c2cd"; ctx.lineWidth = 2;
ctx.strokeRect(px - box/2, py - box/2, box, box);
ctx.fillStyle = "rgba(116,194,205,0.20)";
ctx.fillRect(px - box/2, py - box/2, box, box);
dirty = false;
}
function speedMps() { return Math.pow(10, +$("speed").value); }
function step(now) {
const dt = Math.min(0.1, (now - lastT) / 1000);
lastT = now;
let dx = (keys.has("d") ? 1 : 0) - (keys.has("a") ? 1 : 0);
let dy = (keys.has("s") ? 1 : 0) - (keys.has("w") ? 1 : 0);
if (dx || dy) {
const n = Math.hypot(dx, dy) || 1;
const mps = speedMps() * (keys.has("shift") ? 8 : 1);
const pxPerS = mps / M_PER_PX; // model px per second
player.x += (dx / n) * pxPerS * dt;
player.y += (dy / n) * pxPerS * dt;
if ($("follow").checked) { view.x = player.x; view.y = player.y; }
dirty = true;
}
}
function frame(now) { step(now || performance.now()); if (dirty) draw(); requestAnimationFrame(frame); }
requestAnimationFrame(frame);
// ---- prefetch: ask for what is visible, plus N rings, centred on the view ----
async function ensureCoarse() {
if (coarsePending) return;
const w = innerWidth / view.scale, h = innerHeight / view.scale;
const pad = COARSE_RATIO * 4;
const ci1 = Math.floor((view.y - h/2 - pad) / COARSE_RATIO);
const ci2 = Math.ceil ((view.y + h/2 + pad) / COARSE_RATIO);
const cj1 = Math.floor((view.x - w/2 - pad) / COARSE_RATIO);
const cj2 = Math.ceil ((view.x + w/2 + pad) / COARSE_RATIO);
if (coarseBox && ci1 >= coarseBox.ci1 && ci2 <= coarseBox.ci2 &&
cj1 >= coarseBox.cj1 && cj2 <= coarseBox.cj2) return; // still covered
if ((ci2-ci1) * (cj2-cj1) > 90000) return;
coarsePending = true;
const box = {ci1, cj1, ci2, cj2};
const im = new Image();
im.onload = () => { coarseImg = im; coarseBox = box; coarsePending = false; dirty = true; };
im.onerror = () => { coarsePending = false; };
const cview = $("biome").checked ? "terrain-class" : tileView();
im.src = `/api/coarse.png?ci1=${ci1}&cj1=${cj1}&ci2=${ci2}&cj2=${cj2}` +
`&view=${cview}&exag=${(+$("exag").value).toFixed(1)}&v=${TILE_VERSION}`;
}
async function poll() {
ensureCoarse();
const rings = +$("pre").value;
// DO NOT queue what you cannot see the detail of. At low zoom a 7.68 km tile
// lands on ~40 screen px, so generating 30 m/px for it is pure waste -- and
// the queue explodes (343 deep on first load). Below MIN_PX the tile is drawn
// as a placeholder and never requested; that band is exactly where the coarse
// stage (7.7 km/px, cheap) belongs, and is the next thing to wire in.
// TWO limits, and the first run had neither working.
//
// MIN_PX: below this a tile is a thumbnail and generating 30 m/px for it is
// waste. 48 was far too permissive -- at 97 px on screen the gate never fired
// and the page queued all 220 visible tiles plus a ring: 445 deep, at ~11 s
// each, 84 minutes of GPU from ONE view change.
//
// MAX_QUEUE: never ask for more than can be delivered while you are still
// looking at it. One tile at a time is by design, so a queue longer than this
// is a promise the page cannot keep -- and it is stale the moment you pan.
// The request set is a RADIUS AROUND THE PLAYER, not the viewport. Bounded by
// construction: (2r+1)^2 tiles, 25 at r=2, however far you zoom out.
const pti = Math.floor(player.y / TILE_PX); // i = north-south
const ptj = Math.floor(player.x / TILE_PX); // j = east-west
const MAX_QUEUE = 12;
const want = new Set();
for (let di = -rings; di <= rings; di++)
for (let dj = -rings; dj <= rings; dj++) want.add((pti+di) + "," + (ptj+dj));
for (const [tx, ty] of visibleTiles()) want.add(tx + "," + ty); // state only
// centre expressed as (tx, ty) = (i, j), matching the server's ordering
const ctx_ = [pti, ptj]; // priority is distance from the PLAYER
// Sort by distance from the view centre and ask only for the nearest few.
const sorted = [...want].map(k => {
const [tx, ty] = k.split(",").map(Number);
return {k, d: (tx - ctx_[0]) ** 2 + (ty - ctx_[1]) ** 2};
}).sort((a, b) => a.d - b.d);
const list = sorted.map(o => o.k).join(";"); // state for all
// only tiles inside the player's radius are ever GENERATED
const inRing = sorted.filter(o => {
const [ti, tj] = o.k.split(",").map(Number);
return Math.abs(ti - pti) <= rings && Math.abs(tj - ptj) <= rings;
});
const ask = inRing.slice(0, MAX_QUEUE).map(o => o.k).join(";");
try {
if (!STOPPED) {
await fetch(`/api/tiles/request?tiles=${encodeURIComponent(ask)}` +
`&cx=${ctx_[0]}&cy=${ctx_[1]}`);
}
const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`);
const j = await r.json();
tileState = j.state; stats = j.stats;
if (j.tile_version && j.tile_version !== TILE_VERSION) {
// The server re-rendered tiles differently. Drop every cached image
// rather than showing a mix of two builds.
TILE_VERSION = j.tile_version; imgs.clear(); dirty = true;
}
// retry any tile that has become available since we last failed on it
for (const [k, st] of Object.entries(tileState))
if ((st === "cached" || st === "disk") && !imgs.has(k)) {
const [tx, ty] = k.split(",").map(Number); tileImage(tx, ty);
}
dirty = true;
hud();
} catch (e) { /* keep polling */ }
}
setInterval(poll, 900);
let STOPPED = false;
$("clearq").onclick = () => {
STOPPED = !STOPPED;
$("clearq").textContent = STOPPED ? "resume prefetching" : "stop prefetching";
};
function hud() {
const s = stats || {};
const km = (TILE_PX * M_PER_PX / 1000).toFixed(2);
const wx = (view.x * M_PER_PX / 1000).toFixed(1); // j, east-west
const wy = (view.y * M_PER_PX / 1000).toFixed(1); // i, north-south
const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1);
$("stats").innerHTML =
`<span class="a">INFINITE DIFFUSION — STREAMING</span>
<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)}
<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
<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>
<span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km
<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>' : ''}
<span class="k">generated </span>${s.generated||0} tiles <span class="k">=</span> ${(s.km2||0).toFixed(0)} km²
<span class="k">avg gen </span>${(s.avg_gen_s||0).toFixed(2)} s/tile ${(s.km2_per_s||0).toFixed(1)} km²/s
<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>
<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
<span class="k"> </span>coarse is 256x lower resolution — it is a PLACEHOLDER, not the model being blurry
<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…"}
<span class="k">cache </span>${s.disk_tiles||0} on disk, ${s.mem_tiles||0} in memory
<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>`:""}`;
}
// ---- interaction -----------------------------------------------------------
let down = false, lx = 0, ly = 0;
$("map").addEventListener("pointerdown", e => { down = true; lx = e.clientX; ly = e.clientY;
$("map").setPointerCapture(e.pointerId); });
$("map").addEventListener("pointermove", e => {
if (!down) return;
view.x -= (e.clientX - lx) / view.scale;
view.y -= (e.clientY - ly) / view.scale;
lx = e.clientX; ly = e.clientY; dirty = true; hud();
});
$("map").addEventListener("pointerup", () => { down = false; poll(); });
$("map").addEventListener("wheel", e => {
e.preventDefault();
const z = Math.max(-3, Math.min(1.6, Math.log2(view.scale) - Math.sign(e.deltaY) * 0.15));
$("zoom").value = z; setZoom(z);
}, {passive:false});
function setZoom(z) {
view.scale = Math.pow(2, z);
$("v_zoom").textContent = view.scale.toFixed(3);
dirty = true; hud();
}
$("zoom").addEventListener("input", () => setZoom(+$("zoom").value));
$("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; });
$("grid").addEventListener("change", () => { dirty = true; });
function reshade() {
// colour/shading changed: drop the rendered images, keep the ELEVATION cache
// on the server, so this costs a re-encode and never a regeneration.
imgs.clear(); coarseImg = null; coarseBox = null; coarsePending = false;
$("v_exag").textContent = (+$("exag").value).toFixed(1).replace(/\.0$/, "");
ensureCoarse(); dirty = true;
}
$("exag").addEventListener("input", reshade);
$("hyps").addEventListener("change", reshade);
$("biome").addEventListener("change", () => {
$("legend").hidden = !$("biome").checked;
coarseImg = null; coarseBox = null; coarsePending = false; // force a refetch
ensureCoarse(); dirty = true;
});
$("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); };
// the mountain region is i=0, j=2048 -> screen y=128, x=2176
$("gomtn").onclick = () => { view.x = 2176; view.y = 128; dirty = true; poll(); };
addEventListener("keydown", e => {
if (/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName)) return;
const k = e.key.toLowerCase();
if ("wasd".includes(k) || k === "shift") { keys.add(k === "shift" ? "shift" : k); e.preventDefault(); }
});
addEventListener("keyup", e => {
const k = e.key.toLowerCase();
keys.delete(k === "shift" ? "shift" : k);
});
addEventListener("blur", () => keys.clear());
$("speed").addEventListener("input", () => {
$("v_speed").textContent = speedMps() < 1000 ? speedMps().toFixed(0)
: (speedMps()/1000).toFixed(1) + "k";
});
$("speed").dispatchEvent(new Event("input"));
resize(); setZoom(-2); hud(); poll();
</script>