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