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:
1 parent
cb94438e0d
commit
046e2c8b9e
12 files changed
+317
-61
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user