initial commit

This commit is contained in:
DramaticShape
2026-07-26 16:43:53 -04:00
commit 702d8049f0
136 changed files with 31933 additions and 0 deletions
@@ -0,0 +1,62 @@
"""Stage 1: sample a GB sprite to native resolution, extract palette +
silhouette (light-only flood fill), write sprite_data.json.
Usage: python3 sprite_extract.py <sprite.png>"""
import json
from collections import deque, Counter
from PIL import Image
import sys
SRC = sys.argv[1] if len(sys.argv) > 1 else 'sprite.png'
img = Image.open(SRC).convert('RGB')
W, H = img.size
sx, sy = W // 64, H // 48 # 8x8 blocks
assert sx * 64 == W and sy * 48 == H, (W, H)
# sample block centers -> 64x48
px = img.load()
grid = [[px[x * sx + sx // 2, y * sy + sy // 2] for x in range(64)] for y in range(48)]
# palette
counts = Counter(c for row in grid for c in row)
pal = sorted(counts, key=lambda c: -counts[c])
print('palette:', [(('#%02x%02x%02x' % c), counts[c]) for c in pal])
idx = {c: i for i, c in enumerate(pal)}
lum = lambda c: 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
# flood fill "outside" from border pixels, blocked by the dark outline
outside = [[False] * 64 for _ in range(48)]
q = deque()
for x in range(64):
for y in (0, 47):
if lum(grid[y][x]) > 60 and not outside[y][x]:
outside[y][x] = True; q.append((x, y))
for y in range(48):
for x in (0, 63):
if lum(grid[y][x]) > 60 and not outside[y][x]:
outside[y][x] = True; q.append((x, y))
while q:
x, y = q.popleft()
for nx, ny in ((x+1,y),(x-1,y),(x,y+1),(x,y-1)):
if 0 <= nx < 64 and 0 <= ny < 48 and not outside[ny][nx] and lum(grid[ny][nx]) > 60:
outside[ny][nx] = True; q.append((nx, ny))
n_out = sum(r.count(True) for r in outside)
print('outside pixels:', n_out, '(doc says 218)')
# per-band outside count for sanity vs the doc's silhouette table
for r in range(6):
band = sum(outside[y][x] for y in range(r*8, r*8+8) for x in range(64))
print(f' r{r+1}: {band}')
# ascii mask (o = outside)
for y in range(48):
print(''.join('o' if outside[y][x] else '.' for x in range(64)))
rows = []
for y in range(48):
rows.append(''.join('.' if outside[y][x] else '%x' % idx[grid[y][x]] for x in range(64)))
json.dump({'pal': ['#%02x%02x%02x' % c for c in pal], 'rows': rows},
open('sprite_data.json', 'w'))
print('wrote sprite_data.json')
@@ -0,0 +1,216 @@
# Sprite → Voxel Methodology
## Translating Game Boy ¾-view overworld sprites into 3D voxel models
Worked example: Red's house exterior — `PALLET_TOWN` blocks 56/57/60/61, 64×48 px
(`reds_house_voxel.html`). The same pipeline applies to any GB building sprite.
---
## Core principle
A Game Boy overworld sprite is a fake-3D projection: it packs several different
3D facings into one flat image. Roof tops are drawn as if seen from above,
walls as if seen from the front, and sloped surfaces as diagonal silhouettes.
Voxelization is therefore **not** one operation — it is (1) classifying each
region of the sprite by which 3D surface it depicts, then (2) applying the
matching geometric operation per region.
Two invariants govern everything:
1. **Every visible voxel color comes from a sprite pixel** wherever the lookup
lands inside the silhouette. Synthesis is allowed only for geometry the
sprite implies but never paints (undersides, depth extensions, interiors),
and synthesized cells must continue the sprite's own periodic patterns and
4-shade palette.
2. **The sprite is ground truth, not the tile documentation.** The tile-grid
doc described 1px ground flanks; the actual sprite has 5px insets on the
ground floor and a 3-level wedding-cake silhouette. Always extract from the
real pixels and verify any doc claims against the extracted mask.
---
## Stage 1 — Extraction
Sample the sprite down to its native resolution (uploads are usually integer
upscales; sample the center of each scale×scale block, never bilinear).
Quantize to the palette — for GB art expect exactly `#ffffff / #aaaaaa /
#555555 / #000000`. Anything else in the histogram means the sampling grid is
misaligned.
Compute the silhouette with a flood fill from the image border that spreads
**only through light pixels (luminance > 130)**. This threshold is load-bearing:
the black outline (#000) and the dark shading (#555) together form the
boundary. A naive "not black" threshold lets the fill eat the #555 shaded
flanks and corrupts the silhouette — that bug produced a 398-vs-148 pixel
discrepancy on the first pass here.
Output: a JSON of the palette plus one string per row (`.` = outside the
outline, digit = palette index). Dump the mask as ASCII and read it — this is
where the building's real structure becomes visible, and it is the input to
Stage 2. Tooling: `sprite_extract.py`.
## Stage 2 — Band classification
Segment the sprite rows into horizontal bands and assign each a facing. The
cues generalize:
| Cue in the mask / pixels | Reads as | 3D treatment |
| --- | --- | --- |
| Top band, plain fill with full-width course lines | Roof top seen from above | Lay horizontal (flat) |
| Silhouette tapering at a constant rate (here 2 px per row) with slat/stripe texture | Sloped roof surface; the taper rate **is** the slope | Stepped diagonal surface, 1 down per (taper rate) out |
| Band containing window/door frames | Vertical facade | Straight extrusion |
| Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion |
| Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning |
The band table for Red's house, which Blue's house shares verbatim:
| Sprite rows | Content | Treatment |
| --- | --- | --- |
| 015, x1647 | Coursed grey panel, black rims, highlight course | Flat horizontal top |
| 015, flanks | White/dark slats, 2 px-per-row taper | Sloped ends, 2:1 |
| 1623 | Gable wall, 3 windows, slat flank pixels | Extrude; roof solid overwrites the corners |
| 2431 | Awning slab, double black underline | Extrude + 2-voxel ledge front/back |
| 3247 | Ground floor siding, door (own inner pane), 2 windows | Extrude; recess panes |
**Interpretation rules settled during this build — do not relearn them:**
a top-facing band must end up level everywhere (no synthesized skirts rising
through it); a tapering silhouette on a top band means *sloped surfaces in
elevation*, not chamfered corners in plan; and the slope's eave tips should
land where the sprite's taper pixels stop (here: just above the awning band).
## Stage 3 — Geometry construction
**Vertical bands** extrude straight back to depth `D` (26 here). Front and
back layers take the sprite pixel; interior layers take a **de-outlined**
color — if the pixel is black, walk inward up to 3 px for the first non-black
color. Without this, the side faces of the model are solid outline-black
slabs. With it, sides read as material with black front/back corner edges,
which is the correct GB-cartoon look. The back face becomes a mirror of the
front for free; that is sprite-pure and acceptable.
**Ledges** replicate the band's front pixels ±2 voxels in z past the walls.
The sprite's own black-underline rows become the visible dark underside.
**Recesses** delete the front voxel of every *non-black* pixel inside a
window/door rect. Frames stay proud; the identically-colored voxel behind
becomes the pane, one voxel deep. Rects with nested frames (the door's inner
window) produce layered relief automatically.
**The flat top** lays the top-facing rows horizontal. The band is shallower
than the house (16 rows vs 34+ of depth), so extend it by **cycling a mid-row
band whose period matches the course rhythm** (rows 58 here, period 4) —
this continues both course lines and slat columns seamlessly. Map the
outermost sprite rows to the front/back rims so the black-line/fascia trim
survives.
**Sloped ends** are driven by an elevation profile `T(x)`: flat at `YTOP`
over the plateau, dropping 1 voxel per (taper rate = 2) columns outward to
the eave tips. Build a solid of constant vertical thickness (4) following
`T(x)`, spanning the full roof depth including overhangs. Build order
matters: walls first, then the roof solid **overwrites** wall voxels it
intersects, then **trim** any wall voxel above `T(x)` so nothing pokes
through the surface. The wall strip left exposed beneath the slope shows the
sprite's own #555 shadow pixels — the sprite encodes this geometry.
**Outline pass**: cells on the roof's outer boundary get black (top layer),
grey (second layer), black (below) — reproducing the sprite's
black-grey-black fascia — and all interior undersides are dark.
Parameters used here, to tune per building: depth `D=26`; roof overhang 4 in
x and z beyond the walls; slab thickness 4; `YTOP=35`, derived so the eave
tips (after the 10-step drop) land one voxel above the awning band, matching
where the sprite's taper ends.
## Stage 4 — Color sourcing off the sprite
For any roof cell, map z to a sprite row (`roofSy`), then look up `(x, row)`.
If that lands inside the silhouette, use the pixel. If it lands outside
(overhang extensions), continue the sprite's periodic texture: the slat
rhythm is period 3 with a per-side phase (left flank white at `x % 3 == 1`,
right at `x % 3 == 2` — derive the phases from the actual pixels, and mind
negative-modulo semantics in JS). Plain top areas fall back to the mid grey.
Ground plane and path are presentation-only and the single place non-palette
colors are permitted.
## Stage 5 — Verification (non-negotiable)
Every bug in this build was caught by one of these, none by eyeballing alone:
1. **Dual implementation parity.** Build the identical algorithm in a
reference implementation (Python) and in the shipping runtime (JS in the
HTML). Diff total voxel count and post-cull shell count — they must match
exactly (final build: 58,356 / 14,169).
2. **Numeric asserts on intent.** Flatness: the set of y-layers above the
walls must be exactly the slab layers. Slope: the top-surface profile at
mid-depth must read tips → 1-per-2 steps → flat plateau, mirrored. Zero
wall voxels with `y > T(x)`. Full wall coverage by the roof footprint.
3. **Isometric preview.** A ~60-line painter's-algorithm render (sort by
`(x+z, y)`, draw top/left/right faces of shell voxels) catches texture and
layering mistakes cheaply before touching the runtime.
4. **Hidden-face culling.** Drop voxels whose 6 neighbors all exist before
instancing; render the shell as one InstancedMesh.
Tooling: `voxel_build_verify.py` (builds, asserts, renders previews).
## Repeat checklist
1. Obtain the sprite; sample to native resolution via block centers.
2. Extract palette + silhouette (light-only flood fill, threshold 130);
review the ASCII mask.
3. Segment rows into bands using the Stage-2 cues; write the band table
before writing any geometry code.
4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`.
5. Build: extrude verticals (de-outlined interiors) → ledges → recesses →
flat top (mid-row cycling) → sloped solids (overwrite, then trim) →
outline pass → ground presentation.
6. Verify: parity counts, profile/flatness/poke asserts, iso preview.
7. Ship: embed palette + row strings in the HTML; the builder runs
client-side and doubles as the reference implementation of the algorithm.
## Applying this in the mod
Because tilesets are shared, the band table can be keyed by tile id rather
than by sprite: each id gets a treatment record (extrude / lay-flat / slope,
plus ledge, recess, warp flags) in the spirit of
`mods/DRAMATIC_SHAPE/data/voxel_heights.lua`. Pinning blocks 56/57/60/61 with
the profile above voxelizes Blue's house identically for free and propagates
to the Fuchsia City, Route 25, and Saffron City instances of the same art.
The door's lower-left tile (27) is the warp/walkable tile — keep its recessed
front face aligned with the collision cell so the 3D doorway matches
`REDS_HOUSE_1F`'s warp.
### What shipping it settled
The mod implements this as `mods/DRAMATIC_SHAPE/lib/Buildings.lua`, driven by
a `buildings` list in the profile. Three things changed from the sketch
above, each for a reason worth keeping:
**Key the band table by the building's tile GRID, not by tile id.** A tile
id is not a band: tile 23 is the house's awning course *and* the top of its
roof *and* the eave course that ends Oak's lab's much taller roof. Matching
the exact grid (`../buildings/` catalogues one per building, with every
map that places it) is unambiguous, still shares one entry across all seven
placements of Red's house, and cost nothing to verify — a scan of all 222
maps returns exactly the catalogued placements.
**Measure everything measurable.** Only the band table needs a human to read
the drawing. The silhouette, the taper rate, the eave height and every
window and doorway come off the pixels: a pane is a non-black region the
drawing seals behind its own black frame, and `T(x) = YTOP - topRow(x)`
falls straight out of the mask — which is also what makes `YTOP` stop being
hand-tuned. The eave tips landing one voxel above the awning, tuned by hand
here, then happens by itself.
**Depth is the plot, and the drawn row → depth mapping has a direction.**
In a diorama `D` is free; on a map it is the building's footprint (48px for
Red's house, 64 for the lab), which is why the roof band has to be cycled so
far. And the drawing looks at the roof from the north: its top rows are the
FAR edge and its bottom rows the eave over the facade. Getting that backwards
is invisible in the counts and in a symmetric preview — it shows up in game
as a fascia along the wrong rim.
One colour note: the outline pass's grey fascia band (`GREY` at `t-1`) is
right for the raw GB palette but comes out white once the atlas is
recoloured, turning every sloped end into a black-and-white zip. The
drawing's own eave is black / `#555` / black, and using that reads correctly
under every palette.
@@ -0,0 +1,162 @@
"""Stages 3+5: reference voxel builder (mirrors the JS in the HTML),
hidden-face culling, and isometric painter previews for verification.
Run after sprite_extract.py in the same directory."""
import json
from PIL import Image, ImageDraw
data = json.load(open('sprite_data.json'))
rows = data['rows']
WHITE, GREY, DARK, BLACK = 0, 1, 2, 3
PAL = [(0xff, 0xff, 0xff), (0xaa, 0xaa, 0xaa), (0x55, 0x55, 0x55), (0x00, 0x00, 0x00),
(0xb7, 0xc7, 0x8d), (0xb1, 0xc1, 0x87), (0xd9, 0xd3, 0xc2)]
GROUND1, GROUND2, PATH = 4, 5, 6
inside = lambda x, y: 0 <= x < 64 and 0 <= y < 48 and rows[y][x] != '.'
col = lambda x, y: int(rows[y][x])
D = 26 # wall depth in voxels
vox = {}
def interior_color(sx, sy):
"""side faces shouldn't be solid outline-black: sample inward past the outline"""
c = col(sx, sy)
if c != BLACK:
return c
step = 1 if sx < 32 else -1
for d in range(1, 4):
nx = sx + step * d
if inside(nx, sy) and col(nx, sy) != BLACK:
return col(nx, sy)
return c
# ---- walls: sprite rows 16..47 extruded straight back --------------------
for sy in range(16, 48):
y = 47 - sy
for sx in range(64):
if not inside(sx, sy):
continue
front, mid = col(sx, sy), interior_color(sx, sy)
for z in range(D):
vox[(sx, y, z)] = front if (z == 0 or z == D - 1) else mid
# ---- awning ledge: rows 24..31 protrude 2 front and back -----------------
for sy in range(24, 32):
y = 47 - sy
for sx in range(64):
if inside(sx, sy):
c = col(sx, sy)
for z in (-2, -1, D, D + 1):
vox[(sx, y, z)] = c
# ---- recess panes: delete the front voxel of every non-black pixel -------
RECESS = [(16, 23, 17, 23), (32, 39, 17, 23), (40, 47, 17, 23), # gable windows
(32, 39, 33, 39), (40, 47, 33, 39), # ground windows
(19, 28, 34, 45)] # door interior
for x0, x1, y0, y1 in RECESS:
for sy in range(y0, y1 + 1):
for sx in range(x0, x1 + 1):
if inside(sx, sy) and col(sx, sy) != BLACK:
vox.pop((sx, 47 - sy, 0), None)
# ---- roof: flat top over x16..47, DIAGONAL sloped ends (1 down per 2 out)
ROOF_Z0, ROOF_Z1 = -4, 29
ROOF_X0, ROOF_X1 = -4, 67
YTOP = 35
def T(x): # elevation profile = the sprite's / \ taper
if x < 16: return YTOP - ((16 - x + 1) // 2)
if x > 47: return YTOP - ((x - 47 + 1) // 2)
return YTOP # [2,1]/[3,1]: horizontal, flat
def roof_sy(z):
df, db = z - ROOF_Z0, ROOF_Z1 - z
if db <= 6: return db # back rows 0..6 (rim + courses)
if df <= 8: return 15 - df # front rows 15..7 (fascia + rim)
return 5 + ((df - 9) % 4) # middle: cycle rows 5..8 (course rhythm)
def roof_col(x, z):
sy = roof_sy(z)
if 0 <= x < 64 and inside(x, sy):
return col(x, sy) # real sprite pixels: slats on ends, courses on top
if x < 16: return WHITE if x % 3 == 1 else DARK
if x > 47: return WHITE if x % 3 == 2 else DARK
return GREY
for x in range(ROOF_X0, ROOF_X1 + 1):
t = T(x)
for z in range(ROOF_Z0, ROOF_Z1 + 1):
outer = x in (ROOF_X0, ROOF_X1) or z in (ROOF_Z0, ROOF_Z1)
for y in range(t - 3, t + 1):
if y == t:
c = BLACK if outer else roof_col(x, z)
elif outer:
c = GREY if y == t - 1 else BLACK
else:
c = DARK
vox[(x, y, z)] = c
# trim wall corners that would poke above the sloped ends
for x in range(0, 64):
t = T(x)
if t < 31:
for y in range(t + 1, 32):
for z in range(0, D):
vox.pop((x, y, z), None)
# ---- ground plate + path -------------------------------------------------
for x in range(-10, 75):
for z in range(-12, 39):
vox[(x, -1, z)] = GROUND1 if (x + z) & 1 else GROUND2
for x in range(17, 31):
for z in range(-12, 0):
vox[(x, -1, z)] = PATH
# ---- cull to shell -------------------------------------------------------
shell = []
for (x, y, z), c in vox.items():
if all((x + dx, y + dy, z + dz) in vox for dx, dy, dz in
((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))):
continue
shell.append((x, y, z, c))
print('voxels:', len(vox), 'shell:', len(shell))
# ---- isometric preview (front = -z, so mirror z for the render) ----------
S = 4
ZM = 40
pts = []
for x, y, z, c in shell:
pts.append((x, y, ZM - z, c))
pts.sort(key=lambda p: (p[0] + p[2], p[1]))
def P(x, y, z):
return (2 * (x - z) * S + 1250, ((x + z) - 2 * y) * S + 300)
img = Image.new('RGB', (1800, 900), (0xca, 0xdc, 0x9f))
dr = ImageDraw.Draw(img)
has = {(x, y, z) for x, y, z, c in pts}
def shade(c, f):
r, g, b = PAL[c]
return (int(r * f), int(g * f), int(b * f))
for x, y, z, c in pts:
if (x, y + 1, z) not in has: # top
dr.polygon([P(x, y + 1, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 1.0))
if (x + 1, y, z) not in has: # right (+x)
dr.polygon([P(x + 1, y, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x + 1, y, z + 1)], fill=shade(c, 0.62))
if (x, y, z + 1) not in has: # toward viewer (original -z front)
dr.polygon([P(x, y, z + 1), P(x + 1, y, z + 1), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 0.82))
img.save('preview_front.png')
# second angle: from the back-right, no mirror
pts2 = sorted(((x, y, z, c) for x, y, z, c in shell), key=lambda p: (p[0] + p[2], p[1]))
img2 = Image.new('RGB', (1800, 900), (0xca, 0xdc, 0x9f))
dr2 = ImageDraw.Draw(img2)
has2 = {(x, y, z) for x, y, z, c in pts2}
for x, y, z, c in pts2:
if (x, y + 1, z) not in has2:
dr2.polygon([P(x, y + 1, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 1.0))
if (x + 1, y, z) not in has2:
dr2.polygon([P(x + 1, y, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x + 1, y, z + 1)], fill=shade(c, 0.62))
if (x, y, z + 1) not in has2:
dr2.polygon([P(x, y, z + 1), P(x + 1, y, z + 1), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 0.82))
img2.save('preview_back.png')
print('previews written')