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>
236 lines
10 KiB
JavaScript
236 lines
10 KiB
JavaScript
// 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
|
||
// ≈30–45 — 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 };
|
||
}
|