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.
This commit is contained in:
zwitschi committed 2026-11-09 16:04:14 +01:00
1 parent bb8ae3eb4b
commit cb94438e0d
26 files changed
+439

No files matched your search

+2
View File
@@ -97,6 +97,8 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` |
| `ue_vehicle_import.py` | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd <uproject> -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` |
| `vehicle_from_template.py` | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` |
| `blender_screenshots.py` | Renders review screenshots out of a .blend: one per mesh object (`--mode objects`) and four turntable angles (`--mode scene`), framed from the scene's own bounds, into `Screenshots/blender/`. | `--label`, `--mode`, `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/blender_screenshots.py -- --label vehicle --mode both` |
| `build_comparison_md.py` | Writes `Screenshots/COMPARISON.md`: in-game captures beside their Blender renders in one table, plus a signoff table with one row per in-game screen. Pairs by name with an alias map for camera-named shots. | (no arguments) | `python3 Tools/build_comparison_md.py` |
## Tests
+170
View File
@@ -0,0 +1,170 @@
#!/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())
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Build the Blender-vs-in-game comparison page.
python3 Tools/build_comparison_md.py
Reads every in-game capture in `Screenshots/demo/` and every Blender render in
`Screenshots/blender/`, pairs them by name, and writes `Screenshots/COMPARISON.md`:
* one table with the two images **side by side per row**, so a mismatch is visible in a
single glance rather than by opening two files;
* one signoff table, one row per in-game screen, for the owner's verdict.
Pairing is by name: `review-demo-vehicle.png` pairs with `vehicle-angle-*.png` because
both mention `vehicle`; anything unmatched is shown with an em dash rather than being
quietly dropped, since a missing Blender render is itself information.
"""
from __future__ import annotations
import os
import re
DEMO_DIR = "Screenshots/demo"
BLENDER_DIR = "Screenshots/blender"
OUT = "Screenshots/COMPARISON.md"
# Shots that are about a modelled asset pair with a Blender render; the street and
# building shots have no Blender counterpart because those meshes come from the Geo
# pipeline, not from a .blend.
ASSET_KEYWORDS = ("vehicle",)
# Shots are named after the camera, renders after the asset. Without this the character
# shots never pair with the character renders, because "third-person-front" and
# "character-angle-035" share no words.
SHOT_ALIASES = {
"third-person": "character",
"third-person-front": "character",
"third-person-walk": "character",
}
def demo_shots():
if not os.path.isdir(DEMO_DIR):
return []
return sorted(f for f in os.listdir(DEMO_DIR) if f.endswith(".png"))
def blender_renders():
if not os.path.isdir(BLENDER_DIR):
return []
return sorted(f for f in os.listdir(BLENDER_DIR) if f.endswith(".png"))
def shot_label(filename):
name = os.path.splitext(filename)[0]
name = re.sub(r"^review-demo-", "", name)
return name.replace("-", " ")
def best_blender_match(shot, renders):
"""Pick the Blender render whose name shares the most words with the shot."""
stem = os.path.splitext(shot)[0].lower().replace("review-demo-", "")
words = set(re.split(r"[-_]", stem))
for alias, asset in SHOT_ALIASES.items():
if alias in stem:
words.add(asset)
scored = []
for render in renders:
render_words = set(re.split(r"[-_]", os.path.splitext(render)[0].lower()))
score = len(words & render_words)
if score:
scored.append((score, render))
if not scored:
return None
scored.sort(key=lambda item: (-item[0], item[1]))
return scored[0][1]
def relative_to_page(path):
"""Paths in the page are relative to Screenshots/, where COMPARISON.md lives."""
if not path:
return path
base = os.path.dirname(OUT)
return os.path.relpath(path, base).replace(os.sep, "/")
def image_cell(path, width=520):
if not path:
return "—"
return f'<img src="{relative_to_page(path)}" width="{width}">'
def build():
shots = demo_shots()
renders = blender_renders()
lines = [
"# Blender vs in-game",
"",
"Generated by `Tools/build_comparison_md.py` — do not edit by hand.",
"",
f"- In-game captures: `{DEMO_DIR}/` ({len(shots)} files)",
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender_screenshots.py`",
"",
"Left is what the engine draws, right is what Blender has. A gap between them is the",
"import problem, not a taste problem: scale, material colour, missing parts and split",
"systems all show up here before they show up in a review.",
"",
"## Side by side",
"",
"| In-game | Blender | Shot |",
"| --- | --- | --- |",
]
paired = {}
for shot in shots:
match = best_blender_match(shot, renders)
paired[shot] = match
lines.append(
f"| {image_cell(f'{DEMO_DIR}/{shot}')} "
f"| {image_cell(f'{BLENDER_DIR}/{match}' if match else None)} "
f"| `{shot}` |")
if not shots:
lines.append("| — | — | no captures |")
matched = sum(1 for v in paired.values() if v)
lines += [
"",
f"{matched} of {len(shots)} in-game captures have a Blender counterpart; the rest are",
"street and building shots whose meshes come from the Geo pipeline rather than a .blend.",
"",
"## Signoff",
"",
"One row per in-game screen. `—` in Blender means there is nothing to compare against.",
"",
"| # | In-game screen | Blender counterpart | Verdict | Notes |",
"| --- | --- | --- | --- | --- |",
]
for index, shot in enumerate(shots, start=1):
match = paired[shot]
lines.append(
f"| {index} | `{shot}` | {f'`{match}`' if match else '—'} | ☐ | |")
if not shots:
lines.append("| — | — | — | ☐ | no captures |")
lines += [
"",
"Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays",
"in [README.md](README.md); this page exists to compare Blender against the engine.",
"",
]
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
with open(OUT, "w") as handle:
handle.write("\n".join(lines))
print(f"wrote {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
return 0
if __name__ == "__main__":
raise SystemExit(build())