127 sprites had been generated and about eight had ever been looked at. The pipeline will happily render the wrong object, confidently and well-lit, and no assertion can tell — barrier and flightCase are both "a grey box with edges" as far as any test goes. tools/gen/contact_sheet.py puts every shipped sprite on one page at integer upscale on the venue's own floor colour. It immediately showed about twenty props reading as pale blobs. First instinct was that the vertex baker had lost the albedo. That was wrong: sat=0 on ashUrn, marbleSink and pillar is honest, because stainless and marble and concrete really are grey. The fault was BRIGHTNESS. Props that read sit at median luminance 23-123 (patioHeater 23, arcade 45, poolTable 53, cdj 68); the blobs were 150-190. The venue is painted at ~35 under a 50% black sheet and props are lit by the room's own LIGHTS, so a prop arriving at 180 doesn't read as a bright object — it reads as self-illuminated. Every prop that worked before worked by accident of subject matter; the first pale subjects exposed the gap. props_import --dim scales RGB until median opaque luminance is <=110. Only ever darkens, so anything already in band passes through untouched. Fixed 48 sprites with no regeneration at all — the 512px Blender renders were still in art_incoming, which is exactly why that directory is kept. Also: poster7 shipped with 29% of its pixels and poster9 with 42%, because the near-black-to-alpha pass was eating the dark half of a dark poster. Posters are rectangular plates on a wall, so 4-9 now keep their background and fill the frame. And cf_flux --probe was reporting EXHAUSTED on an account with a full 10,000 neurons: the probe prompt was "a grey square", Cloudflare's safety filter refused it as NSFW, and every non-429 fell through to SystemExit. A health check that can't tell "refused" from "empty" will eventually call a healthy system dead. Gate: lint clean, build clean, 821 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
6.1 KiB
Python
151 lines
6.1 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}")
|
|
|
|
# Per-kind, not a single shared `_glb_render.py`: the batch generator runs
|
|
# several of these at once, and on one shared path a finishing render
|
|
# unlinks the script out from under a starting one — which fails as a
|
|
# baffling "cannot read script" on a prop that has nothing wrong with it.
|
|
script = REPO / "art_incoming" / f"_glb_render_{kind.replace(':', '_')}.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.
|
|
# Forward the flags props_import owns. `--dim` matters for every mesh prop:
|
|
# a Blender render of a stainless bin is honestly pale, and honestly pale is
|
|
# wrong for a room painted at luminance 35 under a black sheet.
|
|
passthrough = [f for f in ("--dim", "--no-quantise", "--keep-bg") if f in sys.argv]
|
|
subprocess.run(
|
|
[sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), kind, *passthrough],
|
|
check=True,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|