not-tonight/tools/glb_to_sprite.py
type-two 433bf77a78 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>
2026-07-27 13:37:36 +10:00

140 lines
5.4 KiB
Python

#!/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()