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>
This commit is contained in:
Parley Hatch 2026-06-02 00:02:14 -06:00
parent e3a30f6962
commit ed00e31009
13 changed files with 1217 additions and 17 deletions

View file

@ -0,0 +1,118 @@
# First-Person Dungeon FPV Prompts (Z-Image-Turbo + Depth Fun-ControlNet)
**Researched:** 2026-06-01
**Pipeline:** Z-Image-Turbo (Qwen3-4B encoder), 8 steps, cfg 1.0, Fun-ControlNet Union depth patch, strength ~0.65, res_multistep/simple. Output downsampled to 256-color pixel art.
## TL;DR
- At **cfg 1.0 the negative prompt is mechanically dead** (classifier-free guidance is off). Hands/figures must be killed by the POSITIVE prompt + avoiding trigger words, NOT by negatives.
- Top triggers that summon a player character: **"first person", "POV", "dungeon crawler", "Eye of the Beholder", "adventurer", "hero", "you"**. Drop them all. Frame as **architectural photography of an empty/abandoned place** instead.
- Let the depth map own the geometry; the prompt owns material + light. Don't fight the controlnet by over-describing structure.
---
## 1. Positive-prompt templates (copy-paste)
Frame every shot as a deserted architectural photograph. The model never adds a person to "an empty abandoned stone hall, no people."
### Corridor
```
empty abandoned stone dungeon corridor, deserted, no people, uninhabited,
narrow ancient stonework passage receding into darkness, wet mossy flagstone
floor, rough granite block walls, iron sconces with flickering torchlight,
warm orange firelight and deep black shadows, volumetric haze, damp cold air,
dark fantasy, architectural photography of an empty ruin, atmospheric, detailed stone
```
### Chamber / room
```
empty abandoned stone dungeon chamber, deserted, no people, uninhabited,
vast vaulted hall of ancient masonry, weathered granite pillars, cracked
flagstone floor, iron braziers with flickering torchlight, warm firelight
pooling in deep shadow, volumetric haze, dripping damp walls, dark fantasy,
architectural photography of an empty ruin, atmospheric, detailed stone
```
### Tighter variant (if long prompts drift)
```
empty stone dungeon corridor, no people, abandoned ancient ruin, mossy
flagstone, granite walls, torchlight, deep shadows, volumetric fog, dark
fantasy, architectural photo
```
### Style anchor block (PREPEND identically to EVERY frame in an atlas)
```
dark fantasy dungeon, ancient torchlit stone, warm orange torchlight, deep
black shadows, wet mossy granite masonry, volumetric haze,
```
Then append the per-room body (corridor vs chamber, any theme word like "crypt"/"flooded"/"library").
### Words to AVOID (they summon a character / break the empty frame)
| Avoid | Why | Use instead |
|---|---|---|
| first person, POV, first-person view | model fills the bottom edge with hands/weapon | (nothing — say "corridor receding into darkness") |
| dungeon crawler, Eye of the Beholder, Daggerfall | strong "RPG with a player" prior → arms/HUD | "dark fantasy", "old-school RPG art style" |
| adventurer, hero, explorer, party, you, your | literal person | "abandoned", "deserted" |
| holding, wielding, torch in hand | summons a hand+weapon | "iron sconce", "wall torch", "brazier" |
| game screenshot, HUD, interface, UI | summons fake UI overlay | "architectural photograph" |
Note "torch" alone is fine (wall torches); only **"holding/carrying a torch"** triggers a hand. Anchor light to fixtures: **sconce, brazier, wall torch.**
---
## 2. Negative prompt — and why it barely matters here
**Caveat first:** Z-Image-Turbo is a distilled model running at **cfg/guidance 1.0**, where classifier-free guidance is effectively off, so the negative branch is **ignored or near-inert.** Two independent sources confirm the Qwen-family text encoder + distillation means "the model does not use negative prompts at all." Do NOT rely on it to remove hands. The positive prompt and trigger-word avoidance do ~95% of the work.
Still set one (cheap insurance; matters only if you ever nudge cfg to ~1.21.5), ordered by importance:
```
person, people, human, figure, character, hands, fingers, hand, arm, arms,
holding weapon, sword, feet, legs, body, silhouette, UI, HUD, health bar,
text, watermark, signature, modern, photorealistic skin
```
**If hands still appear, the real fixes (in order):**
1. Remove every trigger word above from the positive prompt.
2. Add stronger empty-scene tokens up front: `empty, deserted, no people, uninhabited, abandoned`.
3. Floor-bias the depth so the lower frame is clearly continuous floor (your `corridor_depth --floor-bias`) — fewer ambiguous near-camera regions for the model to "fill" with a body. A bright featureless blob at the bottom of the depth map invites hands.
4. Only as a last resort raise cfg to ~1.31.5 (re-enables negatives) and accept slower, slightly stiffer renders.
---
## 3. Cross-frame style consistency (atlas of one dungeon)
Goal: many views, one coherent place. Levers, strongest first:
1. **Identical style-anchor block on every frame** (see §1). This is the #1 consistency lever since the prompt owns all style/lighting/material and the depth map varies per view. Keep the lighting + material tokens byte-identical across the whole atlas; only swap the geometry-neutral body ("corridor" vs "chamber") and optional theme word.
2. **Fix the seed across the whole batch.** Same seed + same model + same style block → consistent palette and lighting character; the per-view depth map supplies the differing geometry. (Note: Z-Image is reported to "hardwire" composition to the seed even across prompt changes — that's a *feature* here, it locks the look.)
3. **Lock everything else:** sampler `res_multistep`, scheduler `simple`, steps 8, cfg 1.0, shift, resolution. Any change to these shifts the rendering character.
4. **Keep control strength constant** across frames (one value, e.g. 0.65) so geometry adherence — and thus how much "model style" bleeds in — is uniform.
5. Bake a fixed `<style block>` constant in code (sibling of the prompt strings in `tools/comfy-spike/comfy.py`) and concatenate per-room, so it can never drift between frames.
6. Optional later: a small **style LoRA** is the documented route for hard brand/style lock if prompt+seed isn't tight enough. The 256-color pixel-art downsample after the fact also strongly homogenizes the palette, hiding minor per-frame drift — lean on it.
---
## 4. Depth-ControlNet-specific tips
- **Don't re-describe geometry that's already in the depth map.** Documented guidance: "Fix the reference, not the prompt" — prompt+model handle *style*, controlnet handles *structure/composition*. Saying "archway ahead" when the depth already encodes it is redundant at best; if it conflicts with the depth it fights the controlnet and can warp the scene. Describe **materials and light**, let depth dictate walls/floor/vanishing point.
- A geometry-neutral noun ("corridor"/"chamber") is fine and helps — it tells the model what *kind* of surface to paint, not where. Avoid spatial directives ("door on the left", "stairs ahead").
- **Control strength:** 0.6 is the documented sweet spot; your 0.65 is good. 0.4 = more style freedom / loose geometry; 0.8 = near-locked geometry; 1.0 = stiff/over-constrained; 0.2 = barely controls. For a clean architectural atlas you want geometry honored, so **0.60.7** is right. If renders look flat/over-rigid, drop toward 0.55.
- **Depth map hygiene = anatomy control.** Near=white/far=black is correct. The bottom-center near region is exactly where hands spontaneously appear; keep that area as unambiguous *floor* (use `--floor-bias` to push the vanishing point up so the lower frame reads as continuous ground, not a vague near object). Smooth falloff (your default `bands=0`) avoids hard ring seams that the model misreads as objects.
- Prompt-vs-control balance: with strength ~0.65 and cfg 1.0, prompt adherence is gentle — which is *good* for empty scenes (less chance of hallucinating a subject), but means you must front-load the most important tokens (empty/no-people/material) early in the prompt.
---
## 5. Open tension to test (flagged, not resolved)
Your brief assumes "turbo → short concise prompts beat long flowery ones." The **official Tongyi-MAI prompting guide says the opposite for Z-Image**: it favors **long, detailed, structured prompts (80250 words)** and "responds very strongly to lighting keywords." These can both be true — *concrete* beats *flowery* regardless of length; Z-Image likes detail but not purple prose. The templates above are concrete-detailed (not flowery, not terse). Recommendation: A/B the long corridor template vs the tighter variant at fixed seed and keep whichever yields cleaner empty frames in your atlas. Detail seems to *help* this model; just keep every token concrete and avoid character words.
---
## Sources
- Z-Image-Turbo ControlNet Guide (control strength sweet spot, "fix the reference not the prompt"): https://wavespeed.ai/blog/posts/blog-z-image-turbo-controlnet-guide/
- Negative prompts inert at cfg 1 / distilled turbo models: https://github.com/lllyasviel/stable-diffusion-webui-forge/issues/1115 , https://stable-diffusion-art.com/sdxl-turbo/
- Qwen/Z-Image ignore negatives → use positive engineering: https://blog.promptmaster.pro/posts/qwen-image-negative-prompts/
- Z-Image distilled, guidance 0/low, 8 steps, negatives unused, control via positive prompt: https://www.apatero.com/blog/z-image-turbo-complete-guide-comfyui-2025 , https://zimageturbo.com/blog/z-image-turbo-comfyui-workflow-guide
- Official Z-Image-Turbo prompting style (long detailed structured, lighting-sensitive, English+Chinese): https://gist.github.com/illuminatianon/c42f8e57f1e3ebf037dd58043da9de32 , https://huggingface.co/Tongyi-MAI/Z-Image-Turbo/discussions/8
- Seed hardwires composition (consistency lever): https://github.com/Tongyi-MAI/Z-Image/issues/27
- Seed/consistency + fixed sampler/res for batch: https://fal.ai/learn/devs/z-image-turbo-developer-guide
- Avoid hands by not framing perspectives that require them: https://learnprompting.org/docs/image_prompting/fix_deformed_generations

236
reikhelm-web/depth.js Normal file
View file

@ -0,0 +1,236 @@
// 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 };
}

View file

@ -0,0 +1,145 @@
// explore-viewer.js — walk a pre-baked first-person dungeon.
//
// Loads an atlas manifest (room graph + tiles) and the baked frames from
// /out/atlas/<seed>/, and lets you move through it Eye-of-the-Beholder style:
// you occupy a room and a cardinal facing; W/S step forward/back to the room in
// that direction, A/D turn. Each frame is the pre-rendered pixel-art view for the
// current (room, facing). Movement is just swapping the image — the dungeon was
// "burned off" ahead of time by tools/bake_atlas.py.
const params = new URLSearchParams(location.search);
const SEED = params.get('seed') || '7';
const BASE = `/out/atlas/${SEED}`;
const DIRS = ['N', 'E', 'S', 'W'];
const VEC = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
const img = document.getElementById('frame');
const hud = document.getElementById('hud');
const miss = document.getElementById('miss');
const missDetail = document.getElementById('miss-detail');
const mini = document.getElementById('minimap');
const mctx = mini.getContext('2d');
let manifest, byId;
const state = { room: null, facing: 'N' };
// One cache-bust token per page load: re-bakes show fresh art on reload, but
// turning/stepping within a session still hits the browser cache (snappy).
const BUST = `?t=${Date.now()}`;
const frameURL = (id, dir) => `${BASE}/r${id}_${dir}.png${BUST}`;
const room = () => byId.get(state.room);
const opposite = (d) => DIRS[(DIRS.indexOf(d) + 2) % 4];
async function boot() {
try {
manifest = await (await fetch(`${BASE}/manifest.json`)).json();
} catch {
hud.innerHTML = `no atlas for seed <b>${SEED}</b> — bake one with <span class="dim">tools/bake_atlas.py --seed ${SEED}</span>`;
return;
}
byId = new Map(manifest.rooms.map((r) => [r.id, r]));
// ?room=&facing= jump straight to a room (demo/debug); else start at the entrance.
const wantRoom = Number(params.get('room'));
state.room = (params.has('room') && byId.has(wantRoom)) ? wantRoom : manifest.startRoom;
const wantFacing = (params.get('facing') || '').toUpperCase();
state.facing = DIRS.includes(wantFacing) ? wantFacing : (firstOpenFacing(state.room) || 'N');
show();
preloadAround();
}
function firstOpenFacing(id) {
const nav = byId.get(id).nav;
return DIRS.find((d) => nav[d] != null);
}
function show() {
miss.classList.remove('show');
img.src = frameURL(state.room, state.facing);
updateHud();
drawMinimap();
}
function updateHud() {
const r = room();
const exits = DIRS.filter((d) => r.nav[d] != null);
const ahead = r.nav[state.facing] != null ? `→ room ${r.nav[state.facing]}` : 'wall';
hud.innerHTML =
`seed <b>${manifest.seed}</b> · room <b>${r.id}</b>${r.theme ? ` · ${r.theme}` : ''} · ` +
`facing <b>${state.facing}</b> <span class="dim">(${ahead})</span><br>` +
`<span class="dim">exits</span> ${exits.join(' ') || '—'}`;
}
function turn(delta) {
state.facing = DIRS[(DIRS.indexOf(state.facing) + delta + 4) % 4];
show();
}
function step(forward) {
const dir = forward ? state.facing : opposite(state.facing);
const target = room().nav[dir];
if (target != null) {
state.room = target;
show();
preloadAround();
} else {
img.animate(
[{ filter: 'brightness(1)' }, { filter: 'brightness(0.55)' }, { filter: 'brightness(1)' }],
{ duration: 150 },
);
}
}
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'w' || k === 'arrowup') step(true);
else if (k === 's' || k === 'arrowdown') step(false);
else if (k === 'a' || k === 'arrowleft') turn(-1);
else if (k === 'd' || k === 'arrowright') turn(1);
else return;
e.preventDefault();
});
// A frame that 404s just hasn't been baked yet — show a hint instead of a broken image.
img.addEventListener('error', () => {
if (!img.getAttribute('src')) return;
miss.classList.add('show');
missDetail.textContent = `room ${state.room}, facing ${state.facing}`;
});
// Warm the browser cache for instant turning/stepping.
function preloadAround() {
const seen = new Set();
const pre = (id) => DIRS.forEach((d) => {
const u = frameURL(id, d);
if (!seen.has(u)) { seen.add(u); new Image().src = u; }
});
pre(state.room);
const nav = room().nav;
DIRS.forEach((d) => { if (nav[d] != null) pre(nav[d]); });
}
function drawMinimap() {
const { tiles, width, height } = manifest;
const s = Math.max(2, Math.floor(170 / Math.max(width, height)));
mini.width = width * s; mini.height = height * s;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const t = tiles[y][x];
mctx.fillStyle = t === 0 ? '#15151c' : t === 2 ? '#caa24a' : '#34343f';
mctx.fillRect(x * s, y * s, s, s);
}
}
for (const r of manifest.rooms) { // every room as a faint node
mctx.fillStyle = 'rgba(122,160,220,0.45)';
mctx.fillRect(r.vantage[0] * s, r.vantage[1] * s, s, s);
}
const [vx, vy] = room().vantage; // the player + facing arrow
const cx = vx * s + s / 2, cy = vy * s + s / 2;
mctx.fillStyle = '#e8dcc8';
mctx.beginPath(); mctx.arc(cx, cy, Math.max(2, s * 0.7), 0, Math.PI * 2); mctx.fill();
const [dx, dy] = VEC[state.facing];
mctx.strokeStyle = '#d4a54a'; mctx.lineWidth = Math.max(1.5, s * 0.5);
mctx.beginPath(); mctx.moveTo(cx, cy); mctx.lineTo(cx + dx * s * 2.4, cy + dy * s * 2.4); mctx.stroke();
}
boot();

42
reikhelm-web/explore.html Normal file
View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reikhelm · dungeon explorer</title>
<style>
:root { --accent: #d4a54a; --ink: #e0d4cc; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #000; color: var(--ink);
font: 13px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace; overflow: hidden; }
/* The pre-rendered frame, integer-pixel scaled to fill the screen. */
#frame { position: fixed; inset: 0; width: 100%; height: 100%;
object-fit: contain; image-rendering: pixelated; background: #000;
transition: filter .12s; }
.vignette { position: fixed; inset: 0; pointer-events: none;
box-shadow: inset 0 0 220px 60px rgba(0,0,0,0.7); }
#hud { position: fixed; top: 14px; left: 16px; padding: 8px 12px;
background: rgba(8,8,11,0.62); border: 1px solid rgba(212,165,74,0.35);
border-radius: 6px; backdrop-filter: blur(2px); }
#hud b { color: var(--accent); }
#hud .dim { color: #8a8a98; }
#minimap { position: fixed; bottom: 14px; left: 16px; image-rendering: pixelated;
background: rgba(8,8,11,0.55); border: 1px solid rgba(212,165,74,0.30);
border-radius: 4px; padding: 6px; }
#miss { position: fixed; inset: 0; display: none; place-items: center; text-align: center;
color: #8a8a98; }
#miss.show { display: grid; }
.keys { position: fixed; bottom: 16px; right: 18px; color: #6a6a76; text-align: right; }
.keys b { color: var(--ink); }
</style>
</head>
<body>
<img id="frame" alt="dungeon view">
<div class="vignette"></div>
<div id="hud">loading atlas…</div>
<div id="miss"><div>⛏ this view isn't baked yet<br><span id="miss-detail"></span></div></div>
<canvas id="minimap" width="180" height="120"></canvas>
<p class="keys"><b>W</b>/<b>S</b> forward·back · <b>A</b>/<b>D</b> turn</p>
<script type="module" src="explore-viewer.js?v=3"></script>
</body>
</html>

127
reikhelm-web/explore.js Normal file
View file

@ -0,0 +1,127 @@
// explore.js — turn a dungeon envelope into a navigable room graph for the
// pre-baked first-person explorer.
//
// The player occupies a ROOM and a cardinal FACING. Moving forward (W) steps to
// the neighbouring room in the faced direction; A/D rotate the facing. To support
// that we need, per room: a vantage cell (where the camera stands), and which
// room lies to its N/E/S/W. Rooms connect through corridors, so we contract the
// region graph (rooms = nodes, corridors = edges to traverse through) and bin
// each room-neighbour into the cardinal of its bearing.
//
// Renderer-side, like depth.js/fpv.js: derived from regions + edges, nothing
// stored in the core. Room ids here are the region ARRAY INDEX (== the wire id;
// edges already reference regions by that index), used as the atlas frame key.
import { getStage, distanceToWall } from './render.js';
import { mostOpenCell, centroidOf } from './fpv.js';
const WALL = 0;
const DIRV = [[0, -1, 'N'], [1, 0, 'E'], [0, 1, 'S'], [-1, 0, 'W']]; // y grows downward = south
// Build the room navigation graph from the ACTUAL tile openings (not centroid
// bearings, which disagree with what the views render). For each room we scan its
// boundary for real gaps, bin each gap by the cardinal it sits on, and flood the
// corridor behind it to find the room it truly leads to. nav + open then share a
// single source of truth with the rendered passages.
//
// Returns { seed, width, height, startRoom, rooms }; each room =
// { id, theme, vantage:[x,y], centroid:[x,y], bounds, nav:{N,E,S,W}, open:{N,E,S,W} }
// (nav = neighbour room id or null; open = a real gap exists that way).
export function dungeonGraph(env, opts = {}) {
const { tiles, regions } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const dist = distanceToWall(tiles, w, h);
const isRoom = (i) => regions[i] && regions[i].kind === 'Room' && regions[i].cells && regions[i].cells.length > 0;
const inBounds = (x, y) => x >= 0 && y >= 0 && x < w && y < h;
const open = (x, y) => inBounds(x, y) && tiles[y][x] !== WALL; // floor/door/pillar/water/lava all pass
// cell → owning room id (region index), -1 for corridors/walls.
const cellRoom = new Int32Array(w * h).fill(-1);
for (let i = 0; i < regions.length; i++) {
if (!isRoom(i)) continue;
for (const [x, y] of regions[i].cells) if (inBounds(x, y)) cellRoom[y * w + x] = i;
}
// From a gap cell just outside `home`, flood through non-room corridor cells
// until we reach a different room; return its id (or null for a dead end/loop).
const destFrom = (sx, sy, home) => {
const seen = new Set([sx + ',' + sy]);
const q = [[sx, sy]];
while (q.length) {
const [x, y] = q.shift();
const rid = cellRoom[y * w + x];
if (rid !== -1 && rid !== home) return rid; // arrived at another room
for (const [dx, dy] of DIRV) {
const nx = x + dx, ny = y + dy, k = nx + ',' + ny;
if (seen.has(k) || !open(nx, ny)) continue;
if (cellRoom[ny * w + nx] === home) continue; // never wander back into home
seen.add(k); q.push([nx, ny]);
}
}
return null;
};
const rooms = [];
for (let i = 0; i < regions.length; i++) {
if (!isRoom(i)) continue;
const r = regions[i];
const centroid = centroidOf(r.cells);
const van = mostOpenCell(dist, w, r.cells);
const cellSet = new Set(r.cells.map(([x, y]) => x + ',' + y));
// Collect boundary gaps per cardinal: a room cell whose neighbour that way is
// outside the room and not a wall.
const gaps = { N: [], E: [], S: [], W: [] };
for (const [x, y] of r.cells) {
for (const [dx, dy, dir] of DIRV) {
const nx = x + dx, ny = y + dy;
if (!inBounds(nx, ny) || cellSet.has(nx + ',' + ny) || tiles[ny][nx] === WALL) continue;
gaps[dir].push([nx, ny]);
}
}
const nav = { N: null, E: null, S: null, W: null };
const openDir = { N: false, E: false, S: false, W: false };
for (const dir of ['N', 'E', 'S', 'W']) {
if (gaps[dir].length === 0) continue;
openDir[dir] = true;
for (const [gx, gy] of gaps[dir]) { // first gap that reaches a room wins
const d = destFrom(gx, gy, i);
if (d != null) { nav[dir] = d; break; }
}
}
rooms.push({
id: i,
theme: r.theme || null,
vantage: van,
centroid: [Math.round(centroid[0]), Math.round(centroid[1])],
bounds: r.bounds,
nav,
open: openDir,
});
}
// Start room: the one holding the Entrance entity, else the largest.
let startRoom = rooms.length ? rooms[0].id : null;
const ent = (env.entities || []).find((e) => e.kind === 'Entrance');
if (ent) {
const hit = rooms.find((rm) => regions[rm.id].cells.some(([x, y]) => x === ent.at[0] && y === ent.at[1]));
if (hit) startRoom = hit.id;
} else {
let bestArea = 0;
for (const rm of rooms) {
const a = regions[rm.id].cells.length;
if (a > bestArea) { bestArea = a; startRoom = rm.id; }
}
}
return { seed: env.seed, width: w, height: h, startRoom, rooms };
}
// Full manifest = the nav graph + the tile grid (for the viewer's minimap).
export function dungeonManifest(env, opts = {}) {
const g = dungeonGraph(env, opts);
const { tiles } = getStage(env, opts.stage);
return { ...g, tiles };
}

157
reikhelm-web/fpv.js Normal file
View file

@ -0,0 +1,157 @@
// 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);
}
// 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? }
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 ?? 2.0; // >1 deepens the falloff to black (matches the proven corridor look)
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
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;
if (x === centerX) ahead = dist;
// 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 / maxView, 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 };
}

View file

@ -4,6 +4,9 @@
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
import { renderMap, getStage, THEMES } from './render.js';
import { downloadDepth, exportDepthDataURL, exportDepthToServer } from './depth.js';
import { exportFPVDataURL, exportFPVToServer, vantage } from './fpv.js';
import { dungeonManifest } from './explore.js';
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
@ -131,7 +134,8 @@ function buildControls() {
seedRow.innerHTML = `
<label>seed</label>
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
<button id="reroll"> reroll</button>`;
<button id="reroll"> reroll</button>
<button id="depth" title="export top-down depth map PNG (for the AI-render pipeline)"> depth</button>`;
panel.appendChild(seedRow);
seedRow.querySelector('#seed').addEventListener('input', (e) => {
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
@ -142,6 +146,9 @@ function buildControls() {
document.getElementById('seed').value = String(state.seed);
regenerate();
});
seedRow.querySelector('#depth').addEventListener('click', () => {
if (state.env) downloadDepth(state.env, { stage: state.stage });
});
// Sliders.
for (const [path, label, min, max, step] of SLIDERS) {
@ -211,6 +218,35 @@ window.reikhelm = {
setStage(i) { stageSlider.value = String(i); stageSlider.dispatchEvent(new Event('input')); },
setOpt(k, v) { state.opts[k] = v; const cb = document.querySelector(`[data-opt="${k}"]`); if (cb) cb.checked = v; draw(); },
setConfig(path, v) { setPath(state.config, path, v); regenerate(); },
// Depth-map export (drives the AI-render pipeline). exportDepth → data URL +
// dims (for headless capture); exportDepthToServer → POST the PNG to serve.py.
exportDepth(opts) { return state.env ? exportDepthDataURL(state.env, opts) : null; },
downloadDepth(opts) { return state.env ? downloadDepth(state.env, opts) : null; },
exportDepthToServer(name, opts) { return state.env ? exportDepthToServer(state.env, name, opts) : null; },
// First-person (Eye-of-the-Beholder style) depth — raycast from a room centre.
exportFPV(opts) { return state.env ? exportFPVDataURL(state.env, opts) : null; },
exportFPVToServer(name, opts) { return state.env ? exportFPVToServer(state.env, name, opts) : null; },
vantage(opts) { return state.env ? vantage(state.env, opts) : null; },
dungeonManifest() { return state.env ? dungeonManifest(state.env) : null; },
// Save a JSON blob to the dev server (used to drop an atlas manifest to disk).
async saveJSON(name, obj) {
const r = await fetch('/save/' + name, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) });
if (!r.ok) throw new Error('saveJSON failed: ' + r.status);
return r.json();
},
// Export every room × 4 cardinal FPV depth maps + the manifest for a baked atlas.
async bakeAtlasDepths(seed) {
if (seed != null) this.setSeed(seed);
const m = dungeonManifest(state.env); // nav + open already derived from real tile openings
const sd = state.env.seed;
for (const r of m.rooms) {
for (const dir of ['N', 'E', 'S', 'W']) {
await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at: r.vantage, dir });
}
}
await this.saveJSON(`atlas/${sd}/manifest.json`, m);
return { seed: sd, rooms: m.rooms.length, frames: m.rooms.length * 4, themes: m.rooms.map((r) => `${r.id}:${r.theme}`) };
},
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
pillarTiles: state.env ? countTiles(state.env, 3) : 0,

View file

@ -94,8 +94,9 @@ export function getStage(env, stage) {
// --- precomputed fields --------------------------------------------------
// Multi-source BFS distance (in cells) from every floor cell to the nearest
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion.
function distanceToWall(tiles, w, h) {
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion
// here, and crevice AO in the depth-map exporter (depth.js) — hence exported.
export function distanceToWall(tiles, w, h) {
const dist = new Float32Array(w * h).fill(Infinity);
const q = new Int32Array(w * h);
let tail = 0;

View file

@ -84,7 +84,8 @@ input[type="range"] {
height: 4px;
}
.seed-row { grid-template-columns: 40px 1fr auto; }
.seed-row { grid-template-columns: 40px 1fr auto auto; }
.seed-row button { padding: 5px 7px; white-space: nowrap; }
.seed-row input[type="number"] {
background: #0c0c11;
border: 1px solid var(--panel-edge);

8
tools/.gitignore vendored
View file

@ -6,8 +6,14 @@ __pycache__/
# macOS
.DS_Store
# Ad-hoc generated render / dither outputs (regenerable). Curated keepers live in pixelart/samples/.
# All pipeline outputs live here — depth maps, renders, dither, baked atlases,
# and the local gallery of keepers. Regenerable; organized; never committed.
out/
# Legacy ad-hoc outputs (pre-tools/out/ layout). Curated keepers live in pixelart/samples/.
comfy-spike/out_*.png
comfy-spike/depth_*.png
comfy-spike/fpv_*.png
pixelart/p_*.png
pixelart/crypt_*.png
pixelart/godray_*.png

View file

@ -1,33 +1,73 @@
# reikhelm tools — AI-render → pixel-art pipeline
Post-processing tools that turn a depth map into atmospheric, palette-locked pixel
art via a local diffusion render. Two stages, glued by PNG files.
art via a local diffusion render. Three stages, glued by PNG files.
```
depth map ──► Stage 1: ComfyUI/Z-Image render ──► Stage 2: dither ──► pixel art
(synthetic now; (comfy-spike/comfy.py) (pixelart/pixelate.py)
real reikhelm
geometry next)
real geometry ──► depth map ──► Stage 1: ComfyUI/Z-Image ──► Stage 2: dither ──► pixel art
(reikhelm-web/ (top-down (comfy-spike/comfy.py) (pixelart/pixelate.py)
depth.js export) heightfield)
```
**Now rendering real generated dungeons** (top-down). The depth exporter lives
renderer-side in `reikhelm-web/depth.js` (derives a height field from the same
envelope render.js paints — walls raised, floors mid, pools recessed, pillars as
bumps, plus subtle per-theme relief; near=white/far=black). It honors the "map
holds no rendering info" contract: height, like color, is derived from
tiles+regions+theme, never stored in the core.
## Setup
The pixelart tools share a venv (Pillow + numpy + opencv-headless):
```
python3 -m venv tools/pixelart/.venv
tools/pixelart/.venv/bin/pip install pillow numpy opencv-python-headless
```
`comfy-spike/comfy.py` is pure stdlib (no venv). LAN calls to ComfyUI need the Bash sandbox disabled.
`comfy-spike/comfy.py` and `serve.py` are pure stdlib (no venv). LAN calls to ComfyUI need the Bash sandbox disabled.
## Stage 0 — export a depth map from a real dungeon (`serve.py` + `reikhelm-web/{depth,fpv}.js`)
Serve the playground and let it POST depth PNGs back to disk:
```
python3 tools/serve.py # http://127.0.0.1:8000, saves → tools/comfy-spike/
```
Two depth modes, both derived from the same envelope (renderer-side, core stays clean):
**Top-down** (`depth.js`) — a height field, walls raised / pools recessed. Hit the
playground's **⬇ depth** button, or headless:
```js
// in the page (e.g. via Playwright browser_evaluate):
window.reikhelm.setSeed(7);
await window.reikhelm.exportDepthToServer('depth_seed7.png'); // → tools/comfy-spike/depth_seed7.png
// window.reikhelm.exportDepth({stage,scale,bevel,ao,normalize}) → {url,width,height} for in-context capture
```
≈1024px on the long edge (16px/cell at the default 64×40 map), aspect-matched to the render.
**First-person** (`fpv.js`) — an Eye-of-the-Beholder / Daggerfall view via a Wolfenstein
DDA raycaster over the grid (wall slice + floor/ceiling ramp, near=white). Camera at a
room's most-open cell, one of 4 cardinal facings:
```js
window.reikhelm.setSeed(7);
const at = window.reikhelm.vantage(); // most-open cell of the largest room
for (const dir of ['N','E','S','W'])
await window.reikhelm.exportFPVToServer(`fpv_s7_${dir}.png`, { dir });
// exportFPV({at:[x,y],dir,width,height,fov,maxView,gamma}) → {url,width,height,at,dir}
```
1280×720 (16:9 — integer-scales to 1080p/1440p). Doorways are non-blocking, so they read as dark recesses leading deeper.
## Stage 1 — depth → diffusion render (`comfy-spike/comfy.py`)
Talks to ComfyUI over HTTP (workstation `http://192.168.1.26:8188`).
```
python3 comfy-spike/comfy.py depth --kind corridor --width 1536 --height 864 --out d.png # synthetic test depth
python3 comfy-spike/comfy.py zrender http://192.168.1.26:8188 --depth d.png --prompt "..." --prefix scene
# real dungeon (Stage 0 export), top-down prompt, geometry-honoring strength:
python3 comfy-spike/comfy.py zrender http://192.168.1.26:8188 --depth comfy-spike/depth_seed7.png \
--width 1024 --height 640 --strength 0.85 --prefix rk_s7 \
--prompt "overhead top-down view of an ancient stone dungeon, rock-cut chambers linked by corridors, flagstone floors, molten lava pool, torchlight, dark fantasy, highly detailed, cinematic"
# synthetic test depth (no real geometry): comfy.py depth --kind room --width 1024 --height 640 --out d.png
# also: probe <url> | nodeinfo <url> <Node...> for introspection
```
Proven Z-Image depth recipe: `ModelPatchLoader` + `ZImageFunControlnet`, CLIP type `qwen_image`,
`EmptySD3LatentImage`, `res_multistep`/`simple`, 812 steps, cfg 1.0, `ModelSamplingAuraFlow` shift 3.0.
Depth strength ~0.6 for freeform; **~0.650.85 to honor real geometry**.
Depth strength ~0.6 for freeform; **0.800.85 is the sweet spot for real geometry** (confirmed on
seeds 7/33: <0.6 drifts off the layout, 1.0 goes rigid and flattens the pools). Set `--width/--height`
to the depth PNG's dimensions and use a **top-down/overhead** prompt to match the geometry.
## Stage 2 — render → pixel art (`pixelart/pixelate.py`)
LOCKED "Primordyn" recipe = the defaults:
@ -39,11 +79,42 @@ tools/pixelart/.venv/bin/python pixelart/pixelate.py render.png --palette pixela
Outputs a true indexed PNG (+ optional `--preview-scale` nearest-neighbor preview). Palette loader
auto-detects .json / .gpl / JASC .pal / hex / Paint.NET / .png / `adaptive:N`.
## Stage 3 — bake & explore a first-person dungeon (`bake_atlas.py` + `reikhelm-web/explore.html`)
Pre-render every room's 4 cardinal views into an atlas, then walk it Eye-of-the-Beholder style.
```
# 1) export the depth maps + room-graph manifest for a seed (browser, via Playwright):
# window.reikhelm.bakeAtlasDepths(7) → tools/out/atlas/7/_work/r<id>_<dir>_depth.png + manifest.json
# 2) render every view through comfy → pixelate (FIXED diffusion seed = style lock across frames):
python3 tools/bake_atlas.py --seed 7 # → tools/out/atlas/7/r<id>_<dir>.png (640x360 pixel art)
# 3) walk it: http://127.0.0.1:8000/explore.html?seed=7
```
The explorer (`explore.html` + `explore-viewer.js`) loads the manifest + frames: you occupy a room and a
cardinal facing; **W/S** step forward/back to the room in that direction, **A/D** turn. `explore.js` builds
the room graph (corridors contracted; each neighbour binned to a cardinal) and per-room vantage points.
Prompt is tank's "empty abandoned ruin" recipe — **no** *first person / POV / dungeon-crawler* trigger
words (they summon player hands; at cfg 1.0 the negative prompt is inert, so the positive must stay clean).
See `.dev/2026-06-01-fpv-prompts.md`. The baker then varies the body **per room theme** (forge→lava,
throne→throne+banners, library→shelves, crypt→niches…) and adds a **wall-vs-passage clause per facing**
(from `open[dir]` in the manifest) so rooms read distinctly and solid walls don't grow phantom doors.
Strength **0.85** honors the footprint. `--rooms 6,7,9` re-bakes a subset.
## Helpers & references
- `pixelart/montage.py` — tile labeled images into a contact sheet.
- `pixelart/samples/` — curated reference: before/after pair + the kernel/size & palette-stretch contact sheets.
- `out/gallery/` — local keepers (top-down + first-person hero shots). Everything under `out/` is git-ignored.
## Next
Wire **real reikhelm geometry** → depth-map export (top-down from the renderer's distance field, then a
height layer) into Stage 1, so we render actual generated dungeons. Later: port the locked Stage-2 dither
into a Rust crate so the engine owns the whole chain. See project memory.
- **Top-down real-geometry export — done** (`reikhelm-web/depth.js`, Stage 0). Per-tile heights + subtle
per-theme relief; the BFS distance field is reused only for crevice AO (it's an openness map, not a
depth map — feeding it raw would dome the floors).
- **First-person depth — working** (`reikhelm-web/fpv.js`). A grid raycaster, NOT the heavy 3D path once
assumed. 16:9 dungeon-crawler views from a room centre; strength **0.550.70** gives the richest results.
- **Playable EoB explorer — shipped** (Stage 3). Per-room ×4-facing atlas bake + a WASD viewer. Movement is
room-to-room along the contracted corridor graph.
- **Next for the explorer:** per-*cell* baking (smooth step-by-step movement, not just room-to-room);
entity sprites (monsters/treasure) composited on frames; door cells rendered as doors vs open archways;
a "bake this seed" button in the playground. Walk-forward render sequence lives in `out/walk/`.
- **Richer relief (top-down Stage B+):** push per-theme height harder (throne dais, deeper cisterns) if
top-down renders feel uniform.
- **Rust port:** move depth export (both modes) + the locked Stage-2 dither into a crate so the engine
owns the whole chain. See project memory.

155
tools/bake_atlas.py Normal file
View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Bake a first-person dungeon atlas: every room's 4 cardinal views → pixel art.
Input (written by the browser, see explore-bake hook):
tools/out/atlas/<seed>/_work/r<id>_<dir>_depth.png FPV depth maps (1280x720)
tools/out/atlas/<seed>/manifest.json room graph (nav + vantages + tiles)
For each depth map this runs the proven chain comfy.py zrender (Z-Image depth
ControlNet) pixelate.py (locked Primordyn dither) and writes the explorer frame:
tools/out/atlas/<seed>/r<id>_<dir>.png 640x360 indexed pixel art
The prompt is tank's "empty abandoned ruin" recipe: NO first-person/POV/crawler
trigger words (they summon player hands), a fixed style-anchor block, and a FIXED
seed across every frame so the palette + lighting stay coherent across the atlas.
python3 tools/bake_atlas.py --seed 7 [--url http://192.168.1.26:8188]
[--strength 0.65] [--size 640x360] [--limit N]
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COMFY = os.path.join(REPO, "tools", "comfy-spike", "comfy.py")
PIXELATE = os.path.join(REPO, "tools", "pixelart", "pixelate.py")
VENV_PY = os.path.join(REPO, "tools", "pixelart", ".venv", "bin", "python")
PALETTE = os.path.join(REPO, "tools", "pixelart", "primordyn_v2.json")
RUN = os.getpid() # unique per invocation → ComfyUI never reuses a SaveImage prefix (re-bakes stay clean)
# Tank's anti-hands framing ("empty abandoned ruin, no people, architectural
# photography" — never "first person / POV / dungeon crawler"), but the room's
# CHARACTER now comes from its procgen theme and the view's actual openings, so
# the atlas stops being a mashup of one generic hall. The style anchor stays
# byte-identical across frames (consistency lever); the theme body + opening
# clause vary per (room, facing).
STYLE = ("dark fantasy, ancient torchlit stone, warm orange torchlight, deep black shadows, "
"wet stone, volumetric haze, architectural photography of an empty ruin, "
"empty, deserted, no people, uninhabited, atmospheric, highly detailed, ")
THEME_BODY = {
"forge": "molten forge chamber, channels of glowing orange lava, iron anvils and hanging chains, soot-blackened granite, fiery glow",
"cistern": "flooded stone cistern, still dark water with mirror reflections, dripping wet walls, cold teal light",
"crypt": "ancient crypt, carved stone sarcophagi and bone-filled wall niches, cobwebs, cold pale light",
"library": "ruined library, tall stone shelves of rotting books and scrolls, drifting dust, warm dim light",
"throne": "vast throne hall, a raised dais with a great stone throne, tattered banners, towering carved columns",
"vault": "treasure vault, iron-bound chests and stone strongboxes, scattered gold, heavy locked stone",
"hall": "grand pillared hall, rows of carved stone columns, high vaulted ceiling",
"den": "foul beast den, gnawed bones and filth, claw-scratched walls, a feral lair, dim red light",
"threshold": "dungeon gatehouse, a great iron portcullis and worn stone steps, torchlit entrance",
"stone": "rough-hewn stone chamber, bare granite walls, plain and ancient",
}
DEFAULT_BODY = THEME_BODY["stone"]
# These reinforce the depth (a deep recess vs a near wall straight ahead) so the
# model paints a passage only where one actually exists — killing phantom doors.
AHEAD_OPEN = "a dark arched passage leads onward into shadow ahead"
AHEAD_WALL = "a solid carved stone wall closes the way ahead"
NEGATIVE = ("person, people, human, figure, character, hands, fingers, hand, arm, arms, "
"holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern")
def prompt_for(theme, is_open):
body = THEME_BODY.get(theme or "stone", DEFAULT_BODY)
return f"{STYLE}{body}, {AHEAD_OPEN if is_open else AHEAD_WALL}"
NAME_RE = re.compile(r"^r(\d+)_([NESW])_depth\.png$")
def run(cmd):
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
sys.stderr.write(p.stdout + p.stderr + "\n")
return p.returncode == 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--seed", type=int, required=True, help="dungeon seed (selects the atlas dir)")
ap.add_argument("--url", default="http://192.168.1.26:8188")
ap.add_argument("--strength", type=float, default=0.85, help="depth control strength (0.85 honors the room footprint)")
ap.add_argument("--render-seed", type=int, default=7, help="FIXED diffusion seed for every frame (style lock)")
ap.add_argument("--size", default="640x360", help="pixel-art frame size (16:9; 320x180 for chunky)")
ap.add_argument("--width", type=int, default=1280)
ap.add_argument("--height", type=int, default=720)
ap.add_argument("--rooms", default="", help="comma-separated room ids to bake (default all)")
ap.add_argument("--limit", type=int, default=0, help="bake only the first N frames (smoke test)")
a = ap.parse_args()
atlas = os.path.join(REPO, "tools", "out", "atlas", str(a.seed))
work = os.path.join(atlas, "_work")
# Per-room theme + per-facing openness come from the manifest the browser wrote.
rooms = {}
mpath = os.path.join(atlas, "manifest.json")
if os.path.exists(mpath):
with open(mpath) as f:
for r in json.load(f).get("rooms", []):
rooms[int(r["id"])] = r
only = {int(x) for x in a.rooms.split(",") if x.strip()} if a.rooms else None
def order(p):
m = NAME_RE.match(os.path.basename(p))
return (int(m.group(1)), "NESW".index(m.group(2))) if m else (1 << 30, 0)
depths = sorted(glob.glob(os.path.join(work, "r*_*_depth.png")), key=order)
if only is not None:
depths = [p for p in depths if NAME_RE.match(os.path.basename(p)) and int(NAME_RE.match(os.path.basename(p)).group(1)) in only]
if not depths:
sys.exit(f"!! no depth maps to bake in {work} — run the browser export first")
if a.limit:
depths = depths[:a.limit]
print(f"baking {len(depths)} frames → {os.path.relpath(atlas, REPO)} "
f"(strength {a.strength}, render-seed {a.render_seed}, {a.size}, per-theme prompts)")
ok = 0
for i, depth in enumerate(depths, 1):
m = NAME_RE.match(os.path.basename(depth))
if not m:
continue
rid, d = m.group(1), m.group(2)
room = rooms.get(int(rid), {})
theme = room.get("theme")
is_open = bool(room.get("open", {}).get(d, False))
prompt = prompt_for(theme, is_open)
prefix = f"r{rid}_{d}_{RUN}" # per-run unique; avoids ComfyUI prefix-counter collisions on re-bake
frame = os.path.join(atlas, f"r{rid}_{d}.png")
tag = f"[{i}/{len(depths)}] r{rid} {d} {theme or '?'}{'·open' if is_open else '·wall'}"
if not run(["python3", COMFY, "zrender", a.url, "--depth", depth,
"--width", str(a.width), "--height", str(a.height),
"--strength", str(a.strength), "--seed", str(a.render_seed),
"--prompt", prompt, "--negative", NEGATIVE,
"--prefix", prefix, "--wait", "300"]):
print(f" {tag} RENDER FAILED"); continue
renders = glob.glob(os.path.join(work, f"out_{prefix}_*.png"))
if not renders:
print(f" {tag} no render output"); continue
render = renders[0]
if not run([VENV_PY, PIXELATE, render, "--palette", PALETTE,
"--size", a.size, "--preview-scale", "0", "--out", frame]):
print(f" {tag} DITHER FAILED"); continue
os.remove(render) # keep the depth, drop the big intermediate render
ok += 1
print(f" {tag}{os.path.relpath(frame, REPO)}")
print(f"done: {ok}/{len(depths)} frames baked into {os.path.relpath(atlas, REPO)}")
if __name__ == "__main__":
main()

105
tools/serve.py Normal file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Dev server for the reikhelm playground, headless depth/FPV export, and the
pre-baked first-person explorer. Pure stdlib no deps.
python3 tools/serve.py # serve reikhelm-web on :8000
Three things, one server:
GET /<path> static file from the web root (reikhelm-web/)
GET /out/<path> static file from the output tree (tools/out/) lets the
explorer load baked atlas frames + manifests
POST /save/<path>.png write the raw request body (a PNG) under tools/out/<path>;
sub-dirs allowed and created (e.g. atlas/7/r3_N.png)
{"ok": true, "path": "...", "bytes": N}
All generated images live under tools/out/ (git-ignored, organized):
out/depth/ top-down + one-off depth maps out/fpv/ first-person stills
out/atlas/<seed>/ a baked explorable dungeon (frames + manifest.json)
"""
import argparse
import json
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def safe_join(base, rel):
"""Join rel under base, collapsing any '..' so it can't escape base."""
rel = os.path.normpath("/" + rel).lstrip("/")
return os.path.join(base, rel)
class Handler(SimpleHTTPRequestHandler):
out_dir = "."
extensions_map = {
**SimpleHTTPRequestHandler.extensions_map,
".js": "text/javascript",
".mjs": "text/javascript",
".wasm": "application/wasm",
".json": "application/json",
}
# Route /out/<path> to the output tree; everything else from the web root (cwd).
def translate_path(self, path):
p = unquote(path.split("?", 1)[0].split("#", 1)[0])
if p == "/out" or p.startswith("/out/"):
return safe_join(self.out_dir, p[len("/out/"):]) if p != "/out" else self.out_dir
return super().translate_path(path)
def end_headers(self):
self.send_header("Cache-Control", "no-store") # dev server: always serve fresh modules
super().end_headers()
def do_POST(self):
if not self.path.startswith("/save/"):
self.send_error(404, "only /save/<path>.png is supported")
return
rel = unquote(self.path[len("/save/"):])
if not (rel.endswith(".png") or rel.endswith(".json")):
self.send_error(400, "path must end in .png or .json")
return
dest = safe_join(self.out_dir, rel)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(body)
payload = json.dumps({"ok": True, "path": dest, "bytes": len(body)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
sys.stderr.write(f"saved {os.path.relpath(dest, REPO)} ({len(body)} bytes)\n")
def log_message(self, fmt, *args): # quiet GETs; POSTs/errors print via do_POST/send_error
pass
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--root", default=os.path.join(REPO, "reikhelm-web"), help="static web root")
ap.add_argument("--out", default=os.path.join(REPO, "tools", "out"), help="output tree (served at /out, target of /save)")
args = ap.parse_args()
Handler.out_dir = os.path.abspath(args.out)
os.makedirs(Handler.out_dir, exist_ok=True)
root = os.path.abspath(args.root)
os.chdir(root)
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
print(f"serving {os.path.relpath(root, REPO)} at http://127.0.0.1:{args.port}")
print(f" /out/ → {os.path.relpath(Handler.out_dir, REPO)} POST /save/<path>.png writes there")
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()
if __name__ == "__main__":
main()