reikhelm/tools/bake_atlas.py
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

155 lines
8 KiB
Python

#!/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()