The [GLB] hop, closed: headless Blender ortho-render turns TRELLIS meshes into props

tools/glb_to_sprite.py completes SPACES.md's 3D route — fixed ortho camera fitted
to the mesh bounds, flat light, transparent film, straight into props_import.
A/B'd on one pool table: TRELLIS wins for objects with volume (worn felt, real
pockets, rails), flux stays for flat things. Rake is 60 not 90 — straight down
turns a fridge into a grey box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 13:37:36 +10:00
parent 9b6bc6355c
commit 433bf77a78
4 changed files with 206 additions and 0 deletions

View File

@ -2048,3 +2048,44 @@ caller already got, and the ID photo does not breathe.
twice, so every frame breathed 2px instead of 1. Also fixed the band check to
read the ORIGINAL y — applying the breath first let a rect fall into the shift
band because it had just inhaled.
## SESSION — FABLE-SOLO-24 · 2026-07-22
**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (783 tests)
**Deployed** + verified.
### Furniture audit — the answer was no
Counted props per space against the map. The **dunnies were the biggest room in
the venue (17x14 tiles) with ONE prop in it** — and the stall minigame happens
there. Pool corner had only the table; the bar had 4 props across 31 tiles of
counter. Nine new kinds generated and placed: urinal / handDryer / cubicleDoor /
mopBucket (dunnies), cueRack (pool), barFridge x2 + iceWell (bar), patioHeater
(yard), truss x2 (over the dance floor). 37 generated props live.
### The prop pipeline is now settled by an A/B, and the [GLB] hop EXISTS
`tools/glb_to_sprite.py` — the missing half of SPACES.md's [GLB] route. Runs
Blender **headless** (no addon, no GUI, no human): imports the mesh, fits an
ORTHO camera to its bounding box so any mesh at any scale frames identically,
flat 3-sun lighting (props are lit by the game's own LIGHTS, so baked shadows
would fight the room), transparent film, then hands the PNG straight to
props_import.py like any flux raw.
**Verdict from the A/B (same pool table, both routes): TRELLIS wins for volume.**
Worn felt, real pockets, rails and legs, clean alpha, consistent light — vs
flux's flat green rectangle. Rule recorded in SPACES.md: flux for FLAT things
(posters, decals, signage), TRELLIS for objects with VOLUME (furniture,
machines, fridges).
### Two gotchas that cost time, recorded so they do not again
1. **`trellis2_mlx` takes `asset_id` as a TOP-LEVEL job field**, not inside
`params`. Nested, it fails with a flat "no input image". The verified shape
is in `~/Documents/MESHGOD/scripts/mb_recon.py`.
2. **Blender 5.x moved EEVEE's identifier** and `RenderEngine.__subclasses__()`
contains entries without `bl_idname` (HydraRenderEngine). Assign the engine
in a try/except chain instead of probing the registry.
### House style, learned the hard way
**Props are raked three-quarter top-down, NOT strict orthographic.** flux gave a
front-on fridge; the strict-overhead re-roll was physically correct and visually
dead — a grey box, because the glowing bottles that identify a fridge are on its
front. Kept the readable one. `glb_to_sprite.py --rake 60` encodes this.

View File

@ -113,3 +113,29 @@ palette/density once.
*(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text,
no lettering" in every prompt EXCEPT the posters, where mangled text reads as
authentic gig-poster grot.)*
## Prop pipeline — settled 2026-07-22 (A/B'd, not guessed)
Two routes, both free and unlimited on MODELBEAST. Pick by object, not by habit.
**[MB] flux direct** — `flux_local``tools/props_import.py`. Fast (~9s).
Use for FLAT things: posters, decals, graffiti, signage, floor markings.
**[GLB] TRELLIS → Blender** — `flux_local` (product shot) → `trellis2_mlx`
(`asset_id` is a TOP-LEVEL job field, NOT inside `params`) →
`tools/glb_to_sprite.py <mesh.glb> <kind>` → sprite + manifest, headless, no
GUI. Use for objects with real VOLUME: tables, fridges, machines, furniture.
Why the split, from the A/B (same pool table, both routes): flux picks its own
camera every generation — the same prompt style gave a perfectly top-down
urinal and a front-on fridge in one batch, and mismatched camera angles never
read as one room. The mesh route renders every prop from one fixed ortho
camera, and TRELLIS carries detail flux flattens: worn felt, real pockets,
rails and legs, clean alpha.
**RAKE = 60°, and it is not negotiable upward.** The venue's props are
three-quarter top-down, not straight down. Re-rolled from directly overhead, a
bar fridge is a grey rectangle — physically correct and visually dead, because
the glowing bottles that say "fridge" are on its front. 60° keeps the top
surface dominant while leaving enough face to identify the object at 32px.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

139
tools/glb_to_sprite.py Normal file
View File

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""glb_to_sprite.py — turn a generated 3D mesh into a game-ready prop sprite.
python3 tools/glb_to_sprite.py <mesh.glb> <kind> [--rake 60] [--px 512]
The missing hop in the [GLB] pipeline of docs/SPACES.md. TRELLIS.2 on
MODELBEAST gives us unlimited meshes from generated art; this renders one from
a FIXED orthographic camera onto transparent film, then hands the PNG to
props_import.py exactly like a flux sprite.
Why bother, when flux can draw a prop directly: flux picks its own camera every
generation. The 2026-07-22 batch produced a perfectly top-down urinal and a
front-on fridge from the same prompt style, and a tileset of mismatched camera
angles never reads as one room. A mesh renders from the same camera every time.
RAKE, and why it is not 90: the venue's props are three-quarter top-down, not
straight down. Rendered from directly overhead a bar fridge is a grey
rectangle physically correct, visually dead, because the glowing bottles that
say "fridge" are on its front. 60 degrees keeps the top surface dominant while
leaving enough face to identify the object at 32px. Match this to the existing
art, not to orthographic purity.
Runs Blender headless; no GUI, no addon, no human in the loop.
"""
import subprocess
import sys
from pathlib import Path
BLENDER = "/Applications/Blender.app/Contents/MacOS/Blender"
REPO = Path(__file__).resolve().parent.parent
# Executed INSIDE Blender's python. Kept as a string so this file stays a
# normal script you can run from the repo root.
BLENDER_SCRIPT = r'''
import bpy, sys, math
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
glb_path, out_png, rake_deg, px = argv[0], argv[1], float(argv[2]), int(argv[3])
# Empty the factory scene: the default cube would render as a prop.
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=glb_path)
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
if not meshes:
raise SystemExit("no mesh in glb")
# Fit an ortho camera to the whole model's bounding box, so any mesh at any
# scale fills the frame identically — that is the consistency we came for.
lo = Vector((1e9, 1e9, 1e9))
hi = Vector((-1e9, -1e9, -1e9))
for o in meshes:
for corner in o.bound_box:
w = o.matrix_world @ Vector(corner)
lo = Vector((min(lo[i], w[i]) for i in range(3)))
hi = Vector((max(hi[i], w[i]) for i in range(3)))
centre = (lo + hi) / 2
span = max((hi - lo)[i] for i in range(3)) or 1.0
cam_data = bpy.data.cameras.new("cam")
cam_data.type = "ORTHO"
cam_data.ortho_scale = span * 1.25 # a little air around the object
cam = bpy.data.objects.new("cam", cam_data)
bpy.context.scene.collection.objects.link(cam)
rake = math.radians(rake_deg)
dist = span * 3
cam.location = centre + Vector((0, -math.cos(rake) * dist, math.sin(rake) * dist))
cam.rotation_euler = (math.pi / 2 - rake, 0, 0)
bpy.context.scene.camera = cam
# Flat, even light. Props are lit by the GAME (venueMap LIGHTS), so baked-in
# shadows and a hero key light would fight the room's own lighting.
world = bpy.data.worlds.new("w")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs[1].default_value = 1.6
bpy.context.scene.world = world
for vec in ((0, 0, 1), (1, -1, 0.5), (-1, -1, 0.5)):
ld = bpy.data.lights.new("l", type="SUN")
ld.energy = 2.0
lo_ = bpy.data.objects.new("l", ld)
lo_.location = centre + Vector(vec) * span * 4
lo_.rotation_euler = (math.atan2(math.hypot(vec[0], vec[1]), vec[2]), 0, math.atan2(vec[1], vec[0]) + math.pi / 2)
bpy.context.scene.collection.objects.link(lo_)
sc = bpy.context.scene
# EEVEE's identifier moved between Blender 4.x and 5.x, so ask the property
# what it will accept rather than guessing from the engine registry.
for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"):
try:
sc.render.engine = engine
break
except TypeError:
continue
sc.render.resolution_x = sc.render.resolution_y = px
sc.render.film_transparent = True # props_import keys off alpha
sc.render.image_settings.file_format = "PNG"
sc.render.image_settings.color_mode = "RGBA"
sc.render.filepath = out_png
bpy.ops.render.render(write_still=True)
print("RENDERED", out_png)
'''
def main() -> None:
if len(sys.argv) < 3:
sys.exit(__doc__)
glb, kind = Path(sys.argv[1]).resolve(), sys.argv[2]
rake = sys.argv[sys.argv.index("--rake") + 1] if "--rake" in sys.argv else "60"
px = sys.argv[sys.argv.index("--px") + 1] if "--px" in sys.argv else "512"
if not glb.exists():
sys.exit(f"no such mesh: {glb}")
if not Path(BLENDER).exists():
sys.exit(f"Blender not found at {BLENDER}")
script = REPO / "art_incoming" / "_glb_render.py"
script.parent.mkdir(exist_ok=True)
script.write_text(BLENDER_SCRIPT)
raw = REPO / "art_incoming" / f"{kind}.png"
proc = subprocess.run(
[BLENDER, "-b", "--factory-startup", "-P", str(script), "--",
str(glb), str(raw), rake, px],
capture_output=True, text=True, timeout=900,
)
if "RENDERED" not in proc.stdout:
print(proc.stdout[-2000:])
print(proc.stderr[-2000:], file=sys.stderr)
sys.exit("render failed")
script.unlink(missing_ok=True)
print(f"rendered {raw}")
# Straight into the existing sprite pipeline — same as any flux raw.
subprocess.run([sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), kind], check=True)
if __name__ == "__main__":
main()