OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / explore.html

430 lines · html ref:
<!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 &lt;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 &gt;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>