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>
140 lines
5.4 KiB
Python
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()
|