diff --git a/reikhelm-web/fpv.js b/reikhelm-web/fpv.js index f8c42f1..8c54e76 100644 --- a/reikhelm-web/fpv.js +++ b/reikhelm-web/fpv.js @@ -53,6 +53,59 @@ export function roomVantage(env, region, opts = {}) { 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 = {}) { diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js index 1dafabd..b358e76 100644 --- a/reikhelm-web/main.js +++ b/reikhelm-web/main.js @@ -5,7 +5,7 @@ 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 { exportFPVDataURL, exportFPVToServer, vantage, directedVantage } from './fpv.js'; import { dungeonManifest } from './explore.js'; const canvas = document.getElementById('view'); @@ -241,7 +241,10 @@ window.reikhelm = { 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 }); + // Per-direction camera: gap-aligned when the way is open, near-wall + // when it's closed — so the depth map can't be misread by the model. + const at = directedVantage(state.env, state.env.regions[r.id], dir); + await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at, dir }); } } await this.saveJSON(`atlas/${sd}/manifest.json`, m); diff --git a/tools/bake_atlas.py b/tools/bake_atlas.py index e220cfd..350ad0c 100644 --- a/tools/bake_atlas.py +++ b/tools/bake_atlas.py @@ -56,7 +56,11 @@ 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" +# cfg 1.0 makes negatives inert, so the closed case is carried by emphatic +# positive description — themes like the vault say "locked", which otherwise +# summons phantom doors on blank walls. +AHEAD_WALL = ("the way ahead ends at a blank unbroken wall of carved stone blocks, " + "sealed solid masonry with no door and no opening, a dead end") NEGATIVE = ("person, people, human, figure, character, hands, fingers, hand, arm, arms, " "holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern") @@ -124,13 +128,18 @@ def main(): theme = room.get("theme") is_open = bool(room.get("open", {}).get(d, False)) prompt = prompt_for(theme, is_open) + strength = a.strength + # Walls render under their own FIXED seed: the open-frame seed's + # favorite composition is a centered doorway — exactly what a closed + # wall must not grow. One class, one seed → style stays coherent. + render_seed = a.render_seed if is_open else a.render_seed + 13 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), + "--strength", str(strength), "--seed", str(render_seed), "--prompt", prompt, "--negative", NEGATIVE, "--prefix", prefix, "--wait", "300"]): print(f" {tag} RENDER FAILED"); continue @@ -141,7 +150,8 @@ def main(): render = renders[0] if not run([VENV_PY, PIXELATE, render, "--palette", PALETTE, - "--size", a.size, "--preview-scale", "0", "--out", frame]): + "--size", a.size, "--preview-scale", "0", "--overshoot", "6", + "--out", frame]): print(f" {tag} DITHER FAILED"); continue os.remove(render) # keep the depth, drop the big intermediate render diff --git a/tools/pixelart/pixelate.py b/tools/pixelart/pixelate.py index 995095c..47820ec 100644 --- a/tools/pixelart/pixelate.py +++ b/tools/pixelart/pixelate.py @@ -7,10 +7,14 @@ Pipeline: load -> resize-to-target (any dims/aspect, fill/fit/pad + focus) Run with the sibling venv: .venv/bin/python pixelate.py [opts] -LOCKED Primordyn recipe (2026-06-01): the DEFAULTS are the look. Running - pixelate.py render.png --palette primordyn_v2.json +LOCKED Primordyn recipe (2026-06-01, rev 2026-06-11): the DEFAULTS are the look. + pixelate.py render.png --palette primordyn_v2.json --overshoot 6 is exactly --size 640x360 --dither floyd --space oklab --mode fill (serpentine, -strength 1.0). Override --size 320x180 for the chunkier cut. +strength 1.0) plus the chroma-overshoot guard. Override --size 320x180 for the +chunkier cut. Rev 2 fixed dark-region hue speckle: the nearest-color LUT is now +sRGB-spaced (linear spacing crushed all shadows into ~4 cells), diffusion error +is gamut-clamped, and --overshoot forbids picking colors more saturated than +the source (no more green/teal confetti in near-black warm gradients). The quality knobs that matter: perceptual matching in OKLab (right color, not just RGB-nearest), error diffusion done in LINEAR light (gamma-correct, no @@ -127,7 +131,18 @@ def linear_to_oklab(c): ], -1) -def to_match_fn(space): +def to_match_fn(space, chroma=1.0): + """Map linear RGB into the color-match space. + + `chroma` scales OKLab's a/b axes before distance is taken. At low lightness + plain OKLab ΔE barely charges for hue, so dark olive/teal palette entries win + matches inside warm near-black gradients and error diffusion sprays them as + speckle ("green spots"). Weighting chroma up makes hue infidelity expensive + while leaving lightness ramps untouched. + """ + if space == "oklab" and chroma != 1.0: + w = np.array([1.0, chroma, chroma], np.float32) + return lambda c: linear_to_oklab(c) * w return {"oklab": linear_to_oklab, "linear": lambda c: c, "srgb": linear_to_srgb}[space] @@ -136,15 +151,33 @@ def to_match_fn(space): # Lets the sequential error-diffusion loop do O(1) lookups instead of an # argmin-over-256 per pixel. # --------------------------------------------------------------------------- # -def build_lut(palette_match, to_match, grid=64): +def build_lut(palette_match, to_match, grid=64, overshoot=0.0): + """Nearest-palette-index LUT over the linear cube. + + `overshoot` adds an asymmetric chroma penalty: a candidate that is MORE + saturated than the source pixel pays `overshoot * (C_pal - C_src)^2` extra. + This is the green-speckle killer — a 256 palette is sparse in the dark + range, and plain ΔE happily swaps a muted near-black brown for a vivid + dark green. Saturated sources (torch flames) still match saturated + entries; muted sources can only fall DOWN in chroma, never up. + Assumes the match space is OKLab-like (channels 1,2 = chroma plane). + """ + # The grid is spaced in sRGB (gamma) coordinates, not linear: linear spacing + # crams every dark color into the bottom few cells (sRGB 60 ≈ linear 0.05), + # which quantizes the whole shadow range into a handful of arbitrary picks. axis = np.linspace(0.0, 1.0, grid, dtype=np.float32) gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij") - pts_lin = np.stack([gx, gy, gz], -1).reshape(-1, 3) - pts = to_match(pts_lin) + pts_srgb = np.stack([gx, gy, gz], -1).reshape(-1, 3) + pts = to_match(srgb_to_linear(pts_srgb).astype(np.float32)) + pal_c = np.sqrt((palette_match[:, 1] ** 2 + palette_match[:, 2] ** 2)) out = np.empty(pts.shape[0], dtype=np.int32) step = 4096 for s in range(0, pts.shape[0], step): d = ((pts[s:s + step, None, :] - palette_match[None, :, :]) ** 2).sum(2) + if overshoot > 0.0: + src_c = np.sqrt((pts[s:s + step, 1] ** 2 + pts[s:s + step, 2] ** 2)) + over = np.clip(pal_c[None, :] - src_c[:, None], 0.0, None) + d += overshoot * over * over out[s:s + step] = d.argmin(1) return out.reshape(grid, grid, grid) @@ -171,7 +204,9 @@ BAYER = { def _lut_lookup(lin_vals, lut, gscale): - gi = np.clip((lin_vals * gscale + 0.5).astype(np.int32), 0, gscale) + # LUT axes are sRGB-spaced (see build_lut) — gamma-encode before indexing. + g = linear_to_srgb(np.clip(lin_vals, 0.0, 1.0)) + gi = np.clip((g * gscale + 0.5).astype(np.int32), 0, gscale) return lut[gi[..., 0], gi[..., 1], gi[..., 2]] @@ -185,8 +220,13 @@ def error_diffuse(lin, lut, palette_lin, kernel, serpentine, strength): rev = serpentine and (y & 1) xr = range(w - 1, -1, -1) if rev else range(w) for x in xr: - v = work[y, x] - gi = np.clip((v * gscale + 0.5).astype(np.int32), 0, gscale) + # Clamp to the gamut before matching AND before computing the + # residual: unbounded accumulated error walks out of gamut and + # then "pays itself back" as smeared blue/green blobs around + # saturated highlights. + v = np.clip(work[y, x], 0.0, 1.0) + g = linear_to_srgb(v) # LUT axes are sRGB-spaced + gi = np.clip((g * gscale + 0.5).astype(np.int32), 0, gscale) i = int(lut[gi[0], gi[1], gi[2]]) idxmap[y, x] = i err = (v - palette_lin[i]) * strength @@ -255,6 +295,10 @@ def main(): p.add_argument("--focus", default="center", help=f"crop focus: {list(FOCI)} or 'fx,fy' (0..1)") p.add_argument("--dither", choices=list(KERNELS) + ["bayer4", "bayer8", "none"], default="floyd") p.add_argument("--space", choices=["oklab", "linear", "srgb"], default="oklab", help="color-match space") + p.add_argument("--chroma", type=float, default=1.0, + help="hue-fidelity weight for oklab matching (scales a/b symmetrically)") + p.add_argument("--overshoot", type=float, default=0.0, + help="asymmetric penalty for picking a MORE saturated color than the source (6 = atlas recipe; kills dark green speckle)") p.add_argument("--strength", type=float, default=1.0, help="dither amount (error-diffusion fraction / bayer scale)") p.add_argument("--no-serpentine", action="store_true") p.add_argument("--resample", choices=["lanczos", "box", "bilinear", "hamming", "nearest"], default="lanczos") @@ -274,17 +318,17 @@ def main(): palette_srgb = load_palette(args.palette, ref_image=small) pal01 = palette_srgb / 255.0 palette_lin = srgb_to_linear(pal01).astype(np.float32) - to_match = to_match_fn(args.space) + to_match = to_match_fn(args.space, args.chroma) palette_match = to_match(palette_lin).astype(np.float32) lin = srgb_to_linear(np.asarray(small, np.float32) / 255.0) if args.dither in KERNELS: - lut = build_lut(palette_match, to_match, args.lut) + lut = build_lut(palette_match, to_match, args.lut, args.overshoot) idxmap = error_diffuse(lin, lut, palette_lin, KERNELS[args.dither], not args.no_serpentine, args.strength) elif args.dither.startswith("bayer"): - lut = build_lut(palette_match, to_match, args.lut) + lut = build_lut(palette_match, to_match, args.lut, args.overshoot) idxmap = ordered(lin, lut, BAYER[int(args.dither[5:])], args.strength) else: # none -> hard nearest, vectorized flat = to_match(lin).reshape(-1, 3)