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

+42
View File
@@ -0,0 +1,42 @@
# Blender vs in-game
Generated by `Tools/build_comparison_md.py` — do not edit by hand.
- In-game captures: `Screenshots/demo/` (9 files)
- Blender renders: `Screenshots/blender/` (22 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 |
| --- | --- | --- |
| <img src="demo/review-demo-crossing.png" width="520"> | — | `review-demo-crossing.png` |
| <img src="demo/review-demo-north-buildings.png" width="520"> | — | `review-demo-north-buildings.png` |
| <img src="demo/review-demo-retail-frontage.png" width="520"> | — | `review-demo-retail-frontage.png` |
| <img src="demo/review-demo-spawn-view.png" width="520"> | — | `review-demo-spawn-view.png` |
| <img src="demo/review-demo-street-level.png" width="520"> | — | `review-demo-street-level.png` |
| <img src="demo/review-demo-third-person-front.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person-front.png` |
| <img src="demo/review-demo-third-person-walk.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person-walk.png` |
| <img src="demo/review-demo-third-person.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person.png` |
| <img src="demo/review-demo-vehicle.png" width="520"> | <img src="blender/vehicle-angle-035.png" width="520"> | `review-demo-vehicle.png` |
4 of 9 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 |
| --- | --- | --- | --- | --- |
| 1 | `review-demo-crossing.png` | — | ☐ | |
| 2 | `review-demo-north-buildings.png` | — | ☐ | |
| 3 | `review-demo-retail-frontage.png` | — | ☐ | |
| 4 | `review-demo-spawn-view.png` | — | ☐ | |
| 5 | `review-demo-street-level.png` | — | ☐ | |
| 6 | `review-demo-third-person-front.png` | `character-angle-035.png` | ☐ | |
| 7 | `review-demo-third-person-walk.png` | `character-angle-035.png` | ☐ | |
| 8 | `review-demo-third-person.png` | `character-angle-035.png` | ☐ | |
| 9 | `review-demo-vehicle.png` | `vehicle-angle-035.png` | ☐ | |
Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays in [README.md](README.md); this page exists to compare Blender against the engine.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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())