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:
1 parent
bb8ae3eb4b
commit
cb94438e0d
26 files changed
+439
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user