feat(hero): facade rework — inscription, medallions, motto band, banner lettering #39
No files matched your search
@@ -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.
@@ -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