// fpv.js — first-person (Eye-of-the-Beholder / Daggerfall style) depth exporter. // // A renderer concern, like depth.js: it reads the same envelope and produces an // 8-bit depth map for the AI-render pipeline — but viewed from *inside* the // dungeon rather than top-down. A grid dungeon is a 2D map extruded: walls are // full-height blocks, floor and ceiling are flat planes, the eye sits at mid // height, and you face one of four cardinal directions. So we don't need any 3D // data from the core — a classic Wolfenstein-style raycaster turns the grid into // a perspective depth map directly. // // Per screen column we cast one ray, march the grid (DDA) to the first blocking // cell, and fill the column: a wall slice at that distance, floor ramping toward // the camera below it, ceiling above. Distance → gray with NEAR=white/FAR=black // (the same convention comfy.py expects). Doorways are non-blocking, so they // read as dark recesses leading deeper — which is exactly what we want. import { getStage, distanceToWall } from './render.js'; import { depthToCanvas } from './depth.js'; const WALL = 0, PILLAR = 3; // Cardinal facings as (dx, dy) on the grid (y grows downward / "south"). export const DIRS = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }; const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v; const blocks = (t) => t === WALL || t === PILLAR; // line-of-sight blockers // Centroid of a cell list as [cx, cy]. export function centroidOf(cells) { let cx = 0, cy = 0; for (const [x, y] of cells) { cx += x; cy += y; } return [cx / cells.length, cy / cells.length]; } // The *most open* cell (max distance-to-wall) among `cells`, so a camera stands // in open space rather than against a wall (a room centroid can land near a wall // when the room is non-convex). Ties break toward the cell nearest the centroid, // keeping it visually centred. `dist` is a precomputed distanceToWall field. export function mostOpenCell(dist, w, cells) { const [cx, cy] = centroidOf(cells); let pick = cells[0], pmax = -1, pcen = Infinity; for (const [x, y] of cells) { const d = dist[y * w + x]; const cen = (x - cx) ** 2 + (y - cy) ** 2; if (d > pmax || (d === pmax && cen < pcen)) { pmax = d; pcen = cen; pick = [x, y]; } } return pick; } // Vantage cell for one room region (its most-open cell). export function roomVantage(env, region, opts = {}) { const { tiles } = getStage(env, opts.stage); return mostOpenCell(distanceToWall(tiles, env.width, env.height), env.width, region.cells); } // Per-(room, facing) camera cell, chosen so the faced geometry reads // UNAMBIGUOUSLY in the depth map. The room-wide most-open vantage stands far // from everything: the faced wall lands near max-view (≈ black), and the // diffusion model decorates that ambiguity with hallucinated doors — while a // real opening renders off-center because the camera isn't aligned with the // gap. Instead: // open ahead → stand in a cell whose straight-ahead ray ESCAPES the room // through the gap, a comfortable 2–7 cells back: the passage // you'd walk through is centered and clearly deep. // wall ahead → stand ~3 cells from the wall: it renders mid-bright and // solid, leaving the model nothing to invent. export function directedVantage(env, region, dirName, opts = {}) { const { tiles } = getStage(env, opts.stage); const w = env.width, h = env.height; const dist = distanceToWall(tiles, w, h); const [dx, dy] = DIRS[dirName]; const cellSet = new Set(region.cells.map(([x, y]) => x + ',' + y)); const [cx, cy] = centroidOf(region.cells); let best = null, bestScore = Infinity, anyEscape = false; const cand = []; for (const [x, y] of region.cells) { let px = x, py = y, run = 0, escaped = false; for (let s = 0; s < 24; s++) { px += dx; py += dy; if (px < 0 || py < 0 || px >= w || py >= h) break; if (blocks(tiles[py][px])) break; run++; if (!cellSet.has(px + ',' + py)) escaped = true; // ray left the room through a gap } if (escaped) anyEscape = true; cand.push({ x, y, run, escaped }); } for (const c of cand) { if (anyEscape && !c.escaped) continue; // when a gap is sightable, only gap-aligned cells qualify const clearance = dist[c.y * w + c.x]; // Sweet bands: gaps read best 2–7 cells out; a blank wall at ~2. const band = anyEscape ? (c.run < 2 ? (2 - c.run) * 6 : c.run > 7 ? (c.run - 7) * 2 : 0) : Math.abs(c.run - 2) * 3; // closed: stand close — a bright dominant slab leaves no room for phantom doors const hug = clearance <= 1 ? 5 : 0; // don't press against a side wall // Open: center on the gap. Closed: deliberately stand OFF the room's // perpendicular axis — a symmetric stage begs the model to paint a // centered focal door; an off-axis dead-end corner doesn't. const perp = dirName === 'N' || dirName === 'S' ? c.x - cx : c.y - cy; const cen = ((c.x - cx) ** 2 + (c.y - cy) ** 2) * 0.05; const offAxis = anyEscape ? 0 : (Math.abs(perp) < 1.5 ? 4 : 0); const score = band + hug + (anyEscape ? cen : offAxis + cen * 0.4); if (score < bestScore) { bestScore = score; best = [c.x, c.y]; } } return best ?? mostOpenCell(dist, w, region.cells); } // Default demo vantage: the most-open cell of the largest room (or, failing that, // the most open floor cell anywhere). export function vantage(env, opts = {}) { const { tiles, regions } = getStage(env, opts.stage); const w = env.width, h = env.height; const dist = distanceToWall(tiles, w, h); let best = null, bestArea = 0; for (const r of regions) { if (r.kind !== 'Room' || !r.cells || r.cells.length === 0) continue; if (r.cells.length > bestArea) { bestArea = r.cells.length; best = r; } } if (best) return mostOpenCell(dist, w, best.cells); const all = []; for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!blocks(tiles[y][x])) all.push([x, y]); return all.length ? mostOpenCell(dist, w, all) : [w >> 1, h >> 1]; } // Build a first-person depth field. Returns { width, height, gray, at, dir }. // opts: { stage?, at?[x,y], dir?'N'|'E'|'S'|'W', width?, height?, fov?deg, // maxView?, gamma?, nearPlane?, minSpan? } export function computeFPVDepth(env, opts = {}) { const { tiles } = getStage(env, opts.stage); const w = env.width, h = env.height; const W = opts.width ?? 1280, H = opts.height ?? 720; // 16:9 — integer-scales to 1080p/1440p const fov = (opts.fov ?? 80) * Math.PI / 180; // wider horizontal FOV to fill the widescreen frame const planeLen = Math.tan(fov / 2); const maxView = opts.maxView ?? 12; // how far the eye sees (cells) before it's pure dark const gamma = opts.gamma ?? 1.5; // >1 deepens the falloff to black; normalization owns the range now const at = opts.at ?? vantage(env, opts); const dirName = opts.dir ?? 'N'; const [dirX, dirY] = DIRS[dirName]; const posX = at[0] + 0.5, posY = at[1] + 0.5; // Camera plane ⟂ to the view direction (rotate dir 90°), scaled to the FOV. const planeX = -dirY * planeLen, planeY = dirX * planeLen; const gray = new Uint8ClampedArray(W * H); const half = H / 2; const centerX = W >> 1; let ahead = maxView; // straight-ahead wall distance (cells) — big = an opening leads onward // Pass 1 — cast every column, recording the wall-hit distance (or maxView when // the ray escapes off-map / through a gap). We need the frame's true near/far // span BEFORE mapping to gray. A fixed 0..maxView scale wastes most of the // tonal range on small rooms: the nearest floor sits ~1 cell off (never quite // white) and a wall 3 cells ahead lands at mid-gray (never black), so a whole // depth map collapses into the 160–214 band — almost no contrast for the depth // ControlNet to honor. Normalizing each frame to its own near/far instead // makes near surfaces white and the deepest recess black every time. const colDist = new Float32Array(W); let nearRaw = 1.0; // the floor at the screen's bottom edge always sits ~1 cell away let farRaw = 0; for (let x = 0; x < W; x++) { const camX = 2 * x / W - 1; // −1 … 1 across the screen const rayX = dirX + planeX * camX; const rayY = dirY + planeY * camX; // DDA grid march. let mapX = posX | 0, mapY = posY | 0; const deltaX = Math.abs(1 / rayX), deltaY = Math.abs(1 / rayY); // Infinity when axis-aligned — fine let stepX, stepY, sideX, sideY; if (rayX < 0) { stepX = -1; sideX = (posX - mapX) * deltaX; } else { stepX = 1; sideX = (mapX + 1 - posX) * deltaX; } if (rayY < 0) { stepY = -1; sideY = (posY - mapY) * deltaY; } else { stepY = 1; sideY = (mapY + 1 - posY) * deltaY; } let hit = false, side = 0; const maxSteps = maxView * 2 + 4; for (let s = 0; s < maxSteps; s++) { if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; } else { sideY += deltaY; mapY += stepY; side = 1; } if (mapX < 0 || mapY < 0 || mapX >= w || mapY >= h) break; // off-map → open/far if (blocks(tiles[mapY][mapX])) { hit = true; break; } } const perp = side === 0 ? sideX - deltaX : sideY - deltaY; const dist = hit ? clamp(perp, 0.02, maxView) : maxView; colDist[x] = dist; if (dist < nearRaw) nearRaw = dist; if (dist > farRaw) farRaw = dist; if (x === centerX) ahead = dist; } // Per-frame normalization window. // nearPlane = 0 (the camera), NOT the nearest surface: a GPU sweep showed // that mapping the closest pixel to pure white (255) made the depth // ControlNet render blown-out, overexposed near floors/ceilings. Anchoring // white at the camera leaves the nearest real surface (~1 cell) at ~gray // 214 — bright, but with headroom — which renders as lit stone, not glare. // farPlane tracks the deepest surface actually visible so a real passage // reads black, but is floored (minSpan) so a shallow dead-end isn't // stretched into a fake tunnel — its faced wall stays a brighter mid-gray // slab. (Phantom doors on fully-closed walls are a separate camera-vantage // problem, not a normalization one — the faced wall is unavoidably darker // than the near floor; see the .dev reliability note.) const nearPlane = opts.nearPlane ?? 0; const minSpan = opts.minSpan ?? 8; const farPlane = clamp(farRaw, nearPlane + minSpan, maxView); const span = Math.max(farPlane - nearPlane, 1e-3); // Pass 2 — paint each column: a wall slice at its distance, floor/ceiling // ramping toward the camera, every depth mapped through the per-frame window. for (let x = 0; x < W; x++) { const dist = colDist[x]; // Project the 1-cell-tall wall: lineH = H/dist (fills the screen at dist 1). const lineH = H / dist; const ds = clamp(Math.floor(half - lineH / 2), 0, H); const de = clamp(Math.floor(half + lineH / 2), 0, H); for (let y = 0; y < H; y++) { let dpix; if (y >= ds && y < de) { dpix = dist; // wall slice } else { // Floor (below) / ceiling (above). (0.5·H)/p is continuous with the // wall distance exactly at the slice edge, so the column has no seam. const p = y < half ? (half - y) : (y - half + 1); dpix = Math.min((0.5 * H) / p, maxView); } const t = clamp((dpix - nearPlane) / span, 0, 1); gray[y * W + x] = Math.round(255 * Math.pow(1 - t, gamma)); } } return { width: W, height: H, gray, at, dir: dirName, ahead }; } // --- export glue (mirrors depth.js) -------------------------------------- export function exportFPVDataURL(env, opts = {}) { const field = computeFPVDepth(env, opts); return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead }; } export async function exportFPVToServer(env, name, opts = {}) { const field = computeFPVDepth(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, at: field.at, dir: field.dir, ahead: field.ahead }; }