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.
171 lines
5.9 KiB
Python
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())
|