docs(review): the Blender-vs-in-game comparison lives in Screenshots/README.md

Merged COMPARISON.md into the review README as a generated section between
<!-- blender-comparison:begin/end --> markers, so there is one place to look and one place
to sign off. Tools/build_comparison_md.py rewrites only that block - the hand-written
sections around it are untouched - and formats the file with prettier afterwards, because a
generated table is never byte-identical to prettier's output and the repo gates on
--check.

Run it after every capture or Blender render; it is the routine that keeps the page current
with new images rather than a one-off export.
This commit is contained in:
zwitschi committed 2026-11-09 16:10:47 +01:00
1 parent cb94438e0d
commit 046e2c8b9e
12 files changed
+317 -61

No files matched your search

+1 -1
View File
@@ -98,7 +98,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| `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` |
| `build_comparison_md.py` | Regenerates the `Blender vs in-game` section of `Screenshots/README.md` between `<!-- blender-comparison:begin/end -->` markers: 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. Run it after every capture or Blender render. | (no arguments) | `python3 Tools/build_comparison_md.py` |
## Tests
+41 -8
View File
@@ -19,10 +19,14 @@ from __future__ import annotations
import os
import re
import shutil
import subprocess
DEMO_DIR = "Screenshots/demo"
BLENDER_DIR = "Screenshots/blender"
OUT = "Screenshots/COMPARISON.md"
OUT = "Screenshots/README.md"
BEGIN = "<!-- blender-comparison:begin -->"
END = "<!-- blender-comparison:end -->"
# 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
@@ -94,9 +98,10 @@ def build():
shots = demo_shots()
renders = blender_renders()
lines = [
"# Blender vs in-game",
"## Blender vs in-game",
"",
"Generated by `Tools/build_comparison_md.py` — do not edit by hand.",
"Regenerated by `Tools/build_comparison_md.py`; everything between the markers is",
"rewritten, so edit outside them.",
"",
f"- In-game captures: `{DEMO_DIR}/` ({len(shots)} files)",
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender_screenshots.py`",
@@ -105,7 +110,7 @@ def build():
"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",
"### Side by side",
"",
"| In-game | Blender | Shot |",
"| --- | --- | --- |",
@@ -128,7 +133,7 @@ def build():
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",
"### Signoff",
"",
"One row per in-game screen. `—` in Blender means there is nothing to compare against.",
"",
@@ -148,10 +153,38 @@ def build():
"in [README.md](README.md); this page exists to compare Blender against the engine.",
"",
]
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
block = "\n".join([BEGIN, ""] + lines + ["", END])
if os.path.exists(OUT):
with open(OUT) as handle:
existing = handle.read()
else:
existing = "# Screenshots\n"
# The comparison lives inside the main review README, between markers, so it is
# regenerated in place: the rest of the README is hand-written and must not be
# rewritten by a tool.
if BEGIN in existing and END in existing:
head = existing[:existing.index(BEGIN)]
tail = existing[existing.index(END) + len(END):]
updated = head + block + tail
else:
updated = existing.rstrip("\n") + "\n\n" + block + "\n"
with open(OUT, "w") as handle:
handle.write("\n".join(lines))
print(f"wrote {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
handle.write(updated)
# The README is checked by `prettier --check` in the repo's gates, and a generated
# markdown table is never byte-identical to what prettier produces. Format it here so
# regenerating the section cannot leave the tree failing a format check.
formatter = shutil.which("npx")
if formatter:
try:
subprocess.run([formatter, "--no-install", "prettier", "--write", OUT],
check=False, capture_output=True, timeout=120)
except (OSError, subprocess.SubprocessError):
pass
print(f"updated {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
return 0
+105
View File
@@ -0,0 +1,105 @@
# Rebuild the vehicle's materials from the Blender colours (0.3.0).
#
# blender -b "Art/blender/vehicle/hot hatch.blend" \
# --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_vehicle_materials.py -nullrhi -unattended -nopause -stdout
#
# Why this exists: the template's car paint is a **legacy node graph**, so its colour never
# reached the exported FBX and the car arrived in Unreal near-black while Blender showed it
# red. `Tools/vehicle_from_template.py` now reads those graphs and records the colours; this
# replaces the imported material assets in place with plain constant-colour materials, so
# the mesh slots pick them up by path without anything being re-imported.
#
# Result file: /tmp/gigline/vehicle-materials.txt
import json
import os
import unreal
DEST = "/Game/Hollywood/Vehicle"
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
REPORT = os.path.join(_REPO, "..", "..", "..", "tmp", "gigline", "hatch", "report.json")
REPORT = "/tmp/gigline/hatch/report.json"
RESULT = "/tmp/gigline/vehicle-materials.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def existing_material_name(name):
"""Find the asset the importer actually created for a Blender material name.
Blender names may contain spaces ("car paint"), which are not valid asset names, so the
importer sanitises them. Guessing the sanitised form is fragile; this searches the
folder instead and reports when nothing matches.
"""
registry = unreal.AssetRegistryHelpers.get_asset_registry()
wanted = name.lower().replace(" ", "").replace("_", "")
for asset in registry.get_assets_by_path(DEST, recursive=False):
candidate = str(asset.asset_name).lower().replace(" ", "").replace("_", "")
if candidate == wanted:
return str(asset.asset_name)
return None
def replace_material(name, spec):
resolved = existing_material_name(name)
if resolved is None:
log(f"{name}: no imported asset matches - skipped")
return None
name = resolved
full = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(full):
unreal.EditorAssetLibrary.delete_asset(full)
tools = unreal.AssetToolsHelpers.get_asset_tools()
material = tools.create_asset(name, DEST, unreal.Material, unreal.MaterialFactoryNew())
if material is None:
log(f"{name}: could not create")
return None
lib = unreal.MaterialEditingLibrary
colour = spec["base_color"]
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -400, 0)
base.set_editor_property("constant", unreal.LinearColor(colour[0], colour[1], colour[2], 1.0))
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 180)
rough.set_editor_property("r", max(0.05, float(spec.get("roughness", 0.5))))
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
metallic = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 260)
metallic.set_editor_property("r", float(spec.get("metallic", 0.0)))
lib.connect_material_property(metallic, "", unreal.MaterialProperty.MP_METALLIC)
lib.recompile_material(material)
log(f"{name}: base {tuple(round(c, 3) for c in colour)} "
f"roughness {spec.get('roughness')} metallic {spec.get('metallic')} "
f"({spec.get('source')})")
return material
def main():
with open(REPORT) as handle:
report = json.load(handle)
materials = report.get("materials", {})
log(f"{len(materials)} materials in the Blender report")
made = 0
for name, spec in materials.items():
if replace_material(name, spec):
made += 1
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
lines.append(f"VERDICT: {'PASS' if made == len(materials) else 'FAIL'}")
log(f"{made}/{len(materials)} materials replaced")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+110
View File
@@ -55,6 +55,102 @@ SYSTEM_RULES = (
)
def _colour_rank(colour):
"""Sort key for picking a paint colour: saturation first, then brightness."""
return (max(colour) - min(colour), sum(colour))
def read_material(material):
"""Base colour, roughness and metallic for a material, Principled or legacy.
The template's car paint is a **legacy node graph** - mix shaders over glossy and
diffuse BSDFs - not a Principled shader. Reading only Principled skipped it, which is
exactly why the car arrived in Unreal near-black: the paint colour never left Blender.
For those graphs the colour is taken from the mix nodes, which is where the red lives.
"""
if not material.use_nodes:
return None
nodes = material.node_tree.nodes
bsdf = nodes.get("Principled BSDF")
if bsdf:
colour = bsdf.inputs["Base Color"].default_value
return {
"base_color": [round(colour[0], 4), round(colour[1], 4), round(colour[2], 4)],
"roughness": round(bsdf.inputs["Roughness"].default_value, 4),
"metallic": round(bsdf.inputs["Metallic"].default_value, 4),
"source": "principled",
}
# Legacy graph: the largest colour found on a mix or diffuse node is the paint.
best = None
roughness = 0.5
metallic = 0.0
for node in nodes:
if node.type in ("MIX", "MIX_RGB"):
for socket in node.inputs:
try:
value = socket.default_value
except Exception: # noqa: BLE001 - not every socket has a default
continue
if not hasattr(value, "__len__") or len(value) < 3:
continue
candidate = (value[0], value[1], value[2])
if max(candidate) < 0.03:
continue
# Prefer the most saturated colour, not the brightest: the paint's red
# (0.49, 0, 0) is darker than the 0.8 grey diffuse beside it, and a
# "brightest wins" rule picked the grey - which is how a red car reaches
# Unreal as a grey one.
if best is None or _colour_rank(candidate) > _colour_rank(best):
best = candidate
elif node.type == "BSDF_DIFFUSE":
colour = node.inputs["Color"].default_value
candidate = (colour[0], colour[1], colour[2])
if best is None or _colour_rank(candidate) > _colour_rank(best):
best = candidate
elif node.type == "BSDF_GLOSSY":
try:
roughness = round(node.inputs["Roughness"].default_value, 4)
except Exception: # noqa: BLE001
pass
if best is None:
return None
return {
"base_color": [round(best[0], 4), round(best[1], 4), round(best[2], 4)],
"roughness": roughness,
"metallic": metallic,
"source": "legacy graph",
}
def ensure_principled(material):
"""Rewire a legacy graph to a Principled BSDF so the FBX export carries a colour.
The template's car paint is a mix-shader graph, and the FBX exporter cannot map that -
which is why SM_Vehicle_Body arrived in Unreal holding `WorldGridMaterial` (the default
fallback) and rendered black while Blender showed red. Converting in place keeps the
colour and makes the export uniform.
"""
if not material.use_nodes:
return False
nodes = material.node_tree.nodes
if nodes.get("Principled BSDF"):
return False
spec = read_material(material)
if not spec:
return False
nodes.clear()
output = nodes.new("ShaderNodeOutputMaterial")
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
colour = spec["base_color"]
bsdf.inputs["Base Color"].default_value = (colour[0], colour[1], colour[2], 1.0)
bsdf.inputs["Roughness"].default_value = max(0.05, spec.get("roughness", 0.5))
bsdf.inputs["Metallic"].default_value = spec.get("metallic", 0.0)
material.node_tree.links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
log(f"converted {material.name} to Principled, base "
f"{tuple(round(c, 3) for c in colour)}")
return True
def classify(obj):
"""Assign an object to a system by its materials first, then by its name."""
materials = [m.name.lower() for m in obj.data.materials if m]
@@ -234,8 +330,22 @@ def main(argv=None) -> int:
log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles")
bpy.data.objects.remove(lod, do_unlink=True)
converted = sum(1 for m in bpy.data.materials if ensure_principled(m))
log(f"legacy materials converted for export: {converted}")
materials = {}
for material in bpy.data.materials:
found = read_material(material)
if found:
materials[material.name] = found
principled = sum(1 for m in bpy.data.materials
if m.use_nodes and m.node_tree.nodes.get("Principled BSDF"))
log(f"materials: {len(materials)} read, {principled} of them Principled, "
f"{len(materials) - principled} from legacy graphs")
report = {
"template": bpy.data.filepath,
"materials": materials,
"target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M,
"height": TARGET_HEIGHT_M},
"systems": {system: {"object": joined[system].name,