reikhelm/reikhelm-web/depth.js
Parley Hatch ed00e31009 feat(render): real-geometry depth export + first-person explorable atlas
Render actual generated dungeons through the AI pipeline — both top-down and
first-person (Eye-of-the-Beholder style) — instead of synthetic test depth.
All renderer-side: derived from the envelope's tiles+regions+theme, the core
stores none of it.

- depth.js: top-down height-field depth exporter (walls raised, pools recessed,
  pillars as bumps, subtle per-theme relief), calibrated to the proven room_depth
  levels so one tall room can't crush every floor dark.
- fpv.js: first-person depth via a Wolfenstein-style grid raycaster (16:9),
  reports straight-ahead distance for opening detection.
- explore.js: room->room nav graph derived from REAL tile openings (scan
  boundary gaps, bin by cardinal, flood the corridor to the destination room) —
  nav + open share one source of truth with the rendered passages.
- bake_atlas.py: bakes per-room x4-facing pixel-art frames; per-theme + per-facing
  (wall vs passage) prompts, fixed diffusion seed for cross-frame style coherence,
  per-run unique prefixes so re-bakes don't silently skip.
- explore.html + explore-viewer.js: WASD first-person dungeon explorer
  (room-to-room movement, turning, minimap), integer-pixel fullscreen.
- serve.py: stdlib dev server — static web root + /out tree + POST /save, no-store.
- main.js/render.js/style.css: export hooks, the depth button, distanceToWall export.
- tools/README.md: Stage 0/1/2/3 docs; .dev/2026-06-01-fpv-prompts.md: prompt
  research (cfg-1 negatives are inert; trigger words summon hands; empty-ruin
  reframe; fixed seed = consistency).

Proven end-to-end on seed 7 (17 rooms). Outputs organized under git-ignored
tools/out/. Control strength: 0.80-0.85 top-down, 0.85 first-person.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:02:14 -06:00

236 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// depth.js — top-down depth-map exporter for reikhelm map envelopes.
//
// A *renderer* concern, exactly like render.js: it reads the same JSON envelope
// (integer tile codes, regions, themes) and derives an 8-bit grayscale depth map
// for the diffusion control pipeline (tools/comfy-spike → ComfyUI Z-Image). It
// holds NO generation logic and the core stores none of this — height, like
// color, lives renderer-side and is derived from tiles + regions + theme.
//
// Convention (matches tools/comfy-spike/comfy.py): a top-down depth map where
// NEAR the camera = white(255), FAR = black(0). Looking straight down, the tops
// of walls are closest to the camera (white), the floor sits mid-gray, and the
// bottoms of recessed pools are farthest (black). So depth here is a *height
// field*: brightness ∝ height.
//
// Note on the BFS field: render.js's `distanceToWall` is an openness/cavity map,
// not a depth map — feeding it raw would dome the floors toward the camera (room
// centres bulging), which is wrong. We build a height model instead, and reuse
// the distance field only for a subtle ambient-occlusion darkening in the
// crevices where stone meets floor.
import { getStage, distanceToWall } from './render.js';
const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5;
// Per-tile base height in [0,1] (1 = tallest = nearest the top-down camera =
// whitest). The wall plateau is the high reference; pools recess below the
// floor; a door notches just under floor level so passages read as openings;
// a pillar stands as a tall column on the floor. Renderer-owned, like PAL.
const HEIGHT = {
[WALL]: 1.00, // flat wall-top plateau (uniform: from directly above, all wall tops sit level)
[FLOOR]: 0.50, // mid reference plane
[DOOR]: 0.45, // threshold, a hair below floor → reads as a gap in the wall line
[PILLAR]: 0.84, // free-standing column rising off the floor
[WATER]: 0.15, // recessed pool
[LAVA]: 0.10, // recessed molten channel (lowest)
};
// Per-theme relief (Stage-B layer, kept subtle so it modulates the room without
// swamping the wall↔pool contrast that carries the structure). Floor bias raises
// or sinks a themed room's floor; wall bias makes its walls stand a touch taller.
// Keyed by the wire theme id (render.js THEMES). Absent themes → flat (0,0).
const THEME_RELIEF = {
throne: { floor: 0.07, wall: 0.06 }, // a raised dais under taller walls
vault: { floor: 0.04, wall: 0.03 }, // built-up stone room
hall: { floor: 0.025, wall: 0.02 },
library: { floor: 0.02, wall: 0.01 },
forge: { floor: 0.0, wall: 0.01 }, // lava already carries the relief
threshold: { floor: 0.0, wall: 0.0 },
stone: { floor: 0.0, wall: 0.0 },
den: { floor: -0.02, wall: 0.0 },
cistern: { floor: -0.06, wall: 0.0 }, // sunken water room
crypt: { floor: -0.05, wall: 0.0 }, // sunken vault
};
const AO = { depth: 2.4, strength: 0.07 }; // crevice darkening near walls (reuses distanceToWall)
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
// Per-cell theme relief: for every cell of a themed Room, its {floor,wall} bias;
// {0,0} elsewhere. Rooms don't overlap, so the assignment is unambiguous.
function reliefField(regions, w, h) {
const fl = new Float32Array(w * h);
const wl = new Float32Array(w * h);
for (const r of regions) {
if (r.kind !== 'Room' || !r.theme || !r.cells) continue;
const rel = THEME_RELIEF[r.theme];
if (!rel) continue;
for (const [x, y] of r.cells) {
if (x < 0 || y < 0 || x >= w || y >= h) continue;
fl[y * w + x] = rel.floor;
wl[y * w + x] = rel.wall;
}
}
return { fl, wl };
}
// In-place separable box blur over a Float32 field (two passes ≈ Gaussian).
// Used as a small bevel so wall edges read as sloped faces rather than 1px
// cliffs, without melting the flat plateaus. radius in pixels; clamps at edges.
function boxBlur(src, w, h, radius, passes = 2) {
if (radius < 1) return src;
let buf = src;
const tmp = new Float32Array(w * h);
const norm = 1 / (radius * 2 + 1);
for (let p = 0; p < passes; p++) {
// horizontal
for (let y = 0; y < h; y++) {
const row = y * w;
let acc = 0;
for (let k = -radius; k <= radius; k++) acc += buf[row + clamp(k, 0, w - 1)];
for (let x = 0; x < w; x++) {
tmp[row + x] = acc * norm;
const out = row + clamp(x - radius, 0, w - 1);
const inc = row + clamp(x + radius + 1, 0, w - 1);
acc += buf[inc] - buf[out];
}
}
// vertical
for (let x = 0; x < w; x++) {
let acc = 0;
for (let k = -radius; k <= radius; k++) acc += tmp[clamp(k, 0, h - 1) * w + x];
for (let y = 0; y < h; y++) {
buf[y * w + x] = acc * norm;
const out = clamp(y - radius, 0, h - 1) * w + x;
const inc = clamp(y + radius + 1, 0, h - 1) * w + x;
acc += tmp[inc] - tmp[out];
}
}
}
return buf;
}
// --- main entry ----------------------------------------------------------
// Derive a depth height field from an envelope. Returns { width, height, gray }
// where gray is a Uint8ClampedArray (row-major, one 8-bit value per pixel),
// full-range normalized with near(tall)=255.
//
// opts: { stage?, scale?, bevel?, ao? }
// stage — snapshot to read (default final)
// scale — pixels per cell (default ≈ 1024 / longest map edge, clamped 6..48)
// bevel — edge-softening as a fraction of a cell (default 0.16; 0 = hard steps)
// ao — crevice AO strength (default AO.strength; 0 = off)
export function computeDepthField(env, opts = {}) {
const { tiles, regions } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const scale = opts.scale ?? clamp(Math.round(1024 / Math.max(w, h)), 6, 48);
const aoStrength = opts.ao ?? AO.strength;
// 1) Per-cell height = tile base + theme relief crevice AO.
const dist = distanceToWall(tiles, w, h);
const { fl, wl } = reliefField(regions, w, h);
const cell = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const t = tiles[y][x];
let v = HEIGHT[t] ?? HEIGHT[FLOOR];
if (t === WALL) {
v += wl[i]; // taller themed walls
} else {
v += fl[i]; // raised/sunken themed floors
// Crevice AO: open cells right against stone sit slightly lower. `dist`
// is 0 in walls and grows outward; openness = min(dist/aoDepth, 1).
const openness = Math.min(1, dist[i] / AO.depth);
v -= (1 - openness) * aoStrength;
}
cell[i] = v;
}
}
// 2) Upscale to pixels as flat blocks (nearest), preserving plateaus/pools —
// hard depth steps at wall tops are physically correct top-down.
const W = w * scale, H = h * scale;
const px = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
const cy = (y / scale) | 0;
for (let x = 0; x < W; x++) {
px[y * W + x] = cell[cy * w + ((x / scale) | 0)];
}
}
// 3) Light bevel: a small blur turns the 1px block edges into sloped wall
// faces the depth ControlNet can read, without rounding off the flat tops.
const bevel = opts.bevel ?? 0.16;
const radius = Math.max(0, Math.round(scale * bevel));
boxBlur(px, W, H, radius);
// 4) Height → 8-bit gray, near(tall)=white. Default ('levels') maps heights
// onto the *proven* room_depth levels (floor≈120, wall≈235, recessed pools
// ≈3045 — the input distribution the Z-Image depth patch was validated
// against): gray = h·230 + 5, so floor(0.5)→120 and wall(1.0)→235. This
// keeps the floor a stable mid-gray across seeds instead of letting one tall
// themed room crush every other floor toward black (what literal full-range
// did). `normalize:'range'` opts back into data-driven full-range.
const gray = new Uint8ClampedArray(W * H);
if (opts.normalize === 'range') {
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < px.length; i++) { const v = px[i]; if (v < lo) lo = v; if (v > hi) hi = v; }
const span = hi - lo || 1;
for (let i = 0; i < px.length; i++) gray[i] = ((px[i] - lo) / span) * 255 + 0.5;
} else {
for (let i = 0; i < px.length; i++) gray[i] = px[i] * 230 + 5 + 0.5;
}
return { width: W, height: H, gray };
}
// --- canvas / export glue ------------------------------------------------
// Paint a depth field onto a fresh offscreen canvas (R=G=B=gray).
export function depthToCanvas(field) {
const { width, height, gray } = field;
const cv = document.createElement('canvas');
cv.width = width; cv.height = height;
const c = cv.getContext('2d');
const img = c.createImageData(width, height);
const d = img.data;
for (let i = 0; i < gray.length; i++) {
const v = gray[i], j = i * 4;
d[j] = v; d[j + 1] = v; d[j + 2] = v; d[j + 3] = 255;
}
c.putImageData(img, 0, 0);
return cv;
}
// Build the depth PNG and return its data URL plus dimensions.
export function exportDepthDataURL(env, opts = {}) {
const field = computeDepthField(env, opts);
return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height };
}
// Trigger a browser download of the depth PNG (the human / button path).
export function downloadDepth(env, opts = {}) {
const { url, width, height } = exportDepthDataURL(env, opts);
const a = document.createElement('a');
a.href = url;
a.download = `reikhelm-depth-seed${env.seed}-${width}x${height}.png`;
document.body.appendChild(a);
a.click();
a.remove();
return { width, height };
}
// POST the depth PNG to the dev save-server (tools/serve.py) under `name` — the
// automated-pipeline path (Playwright / scripted export). Returns the server's
// JSON ({ok, path, bytes}) merged with the dimensions; throws if no server is
// listening (use downloadDepth() for the browser-download path instead).
export async function exportDepthToServer(env, name, opts = {}) {
const field = computeDepthField(env, opts);
const blob = await new Promise((res) => depthToCanvas(field).toBlob(res, 'image/png'));
const resp = await fetch('/save/' + encodeURIComponent(name), { method: 'POST', body: blob });
if (!resp.ok) throw new Error('save failed: ' + resp.status);
const info = await resp.json();
return { ...info, width: field.width, height: field.height };
}