Files
gig-line/Tools/blender_screenshots.py
T
zwitschi cb94438e0d feat(review): Blender screenshots beside the in-game captures, with signoff
Two tools for the comparison the review was missing.

- Tools/blender_screenshots.py renders out of a .blend: one image per mesh object, and
  four turntable angles of the whole scene, framed from the scene's own bounds so adding
  geometry never silently crops a shot. Output lands in Screenshots/blender/, beside the
  in-game captures.
- Tools/build_comparison_md.py writes Screenshots/COMPARISON.md: a table with the in-game
  capture and its Blender render side by side per row, then a signoff table with one row
  per in-game screen. Unmatched shots show an em dash rather than disappearing, because a
  missing Blender render is information too.

The first run already earns its keep: the Blender render of the owner's hot hatch is red
with clear glazing, while the in-game capture of the same asset is near-black, so the
material colour is being lost on import - visible in one glance in the new page.
2026-11-09 16:04:14 +01:00

171 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Render review screenshots straight out of Blender.
blender -b "Art/blender/vehicle/hot hatch.blend" \
--python Tools/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/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())