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>
155 lines
8 KiB
Python
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()
|