#!/usr/bin/env python3 """Render review screenshots straight out of Blender. blender -b "Art/blender/vehicle/hot hatch.blend" \ --python Tools/blender/blender_screenshots.py -- \ --label vehicle --mode both --out Screenshots/blender Two modes, because two questions get asked about a Blender asset: * **objects** - one render per mesh object, isolated. This is how a system split is checked: if a panel lands in the wrong group, it shows up as the only thing in its own picture. * **scene** - four turntable angles of everything together, for silhouette and proportion against the in-game shot. Outputs go beside the in-game captures (`Screenshots/blender/`), one folder layer below, so `Tools/review/build_comparison_md.py` can pair them and nothing has to be copied by hand. Every render is framed from the scene's own bounds rather than a hand-placed camera, so adding geometry does not silently crop the shot. """ from __future__ import annotations import argparse import json import math import os import sys import bpy from mathutils import Vector RESOLUTIONS = (1280, 720) PARTS: list[str] = [] def log(message: str) -> None: PARTS.append(message) print(f"[shots] {message}") def scene_bounds(objects): low = [1e9, 1e9, 1e9] high = [-1e9, -1e9, -1e9] for obj in objects: for corner in obj.bound_box: world = obj.matrix_world @ Vector(corner) for axis in range(3): low[axis] = min(low[axis], world[axis]) high[axis] = max(high[axis], world[axis]) return Vector(low), Vector(high) def setup_scene(): scene = bpy.context.scene scene.render.engine = "BLENDER_EEVEE" scene.render.resolution_x, scene.render.resolution_y = RESOLUTIONS scene.render.film_transparent = False world = bpy.data.worlds.new("shots") if not bpy.data.worlds else bpy.data.worlds[0] scene.world = world world.use_nodes = True world.node_tree.nodes["Background"].inputs[0].default_value = (0.55, 0.58, 0.62, 1.0) world.node_tree.nodes["Background"].inputs[1].default_value = 0.8 if not any(o.type == "LIGHT" for o in bpy.data.objects): bpy.ops.object.light_add(type="SUN", location=(6.0, -6.0, 12.0)) bpy.context.active_object.data.energy = 4.0 def frame_camera(low, high, azimuth_deg, elevation_deg=26.0, padding=1.9): centre = (low + high) * 0.5 size = max((high - low).length, 0.5) distance = size * padding azimuth = math.radians(azimuth_deg) elevation = math.radians(elevation_deg) offset = Vector(( math.cos(elevation) * math.cos(azimuth), math.cos(elevation) * math.sin(azimuth), math.sin(elevation), )) * distance bpy.ops.object.camera_add(location=centre + offset) camera = bpy.context.active_object camera.data.lens = 50.0 direction = (centre - camera.location).normalized() camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() bpy.context.scene.camera = camera return camera def render(path): bpy.context.scene.render.filepath = path bpy.ops.render.render(write_still=True) return os.path.exists(path) def render_scene_angles(meshes, out_dir, label, angles=(35, 125, 215, 305)): written = [] low, high = scene_bounds(meshes) for azimuth in angles: camera = frame_camera(low, high, azimuth) path = os.path.join(out_dir, f"{label}-angle-{azimuth:03d}.png") if render(path): written.append(path) log(f"scene angle {azimuth}: {path}") bpy.data.objects.remove(camera, do_unlink=True) return written def render_objects(meshes, out_dir, label): written = [] hidden = [] for target in meshes: for other in meshes: if other is target: continue if other.hide_render: continue other.hide_render = True hidden.append(other) low, high = scene_bounds([target]) camera = frame_camera(low, high, 40.0) safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in target.name) path = os.path.join(out_dir, f"{label}-object-{safe}.png") if render(path): written.append(path) tris = sum(len(p.vertices) - 2 for p in target.data.polygons) log(f"object {target.name}: {tris} triangles -> {path}") bpy.data.objects.remove(camera, do_unlink=True) for other in hidden: other.hide_render = False hidden = [] return written def main(argv=None): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--label", required=True, help="prefix for the file names, e.g. 'vehicle'") ap.add_argument("--mode", choices=("objects", "scene", "both"), default="both") ap.add_argument("--out", default="Screenshots/blender") ap.add_argument("--report", default=None) args = ap.parse_args(argv) out_dir = os.path.abspath(args.out) os.makedirs(out_dir, exist_ok=True) setup_scene() meshes = [o for o in bpy.data.objects if o.type == "MESH"] if not meshes: print("[shots] no meshes in the scene", file=sys.stderr) return 1 log(f"{len(meshes)} mesh objects in {bpy.data.filepath}") written = [] if args.mode in ("scene", "both"): written += render_scene_angles(meshes, out_dir, args.label) if args.mode in ("objects", "both"): written += render_objects(meshes, out_dir, args.label) report = {"blend": bpy.data.filepath, "label": args.label, "mode": args.mode, "outputs": written, "log": PARTS} if args.report: with open(os.path.abspath(args.report), "w") as handle: json.dump(report, handle, indent=2) log(f"{len(written)} screenshots written to {out_dir}") return 0 if written else 1 if __name__ == "__main__": raise SystemExit(main())