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:
parent
9b6bc6355c
commit
433bf77a78
@ -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.
|
||||
|
||||
@ -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
139
tools/glb_to_sprite.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user