diff --git a/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset b/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset index 398cb84..123b1b7 100644 --- a/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset +++ b/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0818338ad583276d9fa0a64aa82028d2c054140ca9b0f6dcf3c2fb4a22948d2d -size 56169 +oid sha256:8183bd1c290808cecc469ba3781bcb120f2ad36b3f08e1c2c07b2e70c19c4f55 +size 5696 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset b/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset index 470689f..b0a3cb5 100644 --- a/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset +++ b/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e6c800341a8580c2c977681f7af4154bdef3b19ef11e444d30f5e741c222f9c4 -size 56201 +oid sha256:5b5dc2e7e92947d72d91cf4218f0144c96ce380cc0004e9c96bea86612301c3b +size 5677 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset b/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset index 03f2170..454ec22 100644 --- a/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset +++ b/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7de690fff9efda596b4e5dae71a19a52aff8fed5d9033669587668247c7d3ba0 -size 56195 +oid sha256:0b1e1d68e7e6454642b9cde51fdd27d97fe4fd61a2b4588556b76ef9e50e7592 +size 5677 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset b/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset index b65a6f4..32f4bbc 100644 --- a/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset +++ b/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b9d0fac0b3cbe13c6806ec256169e1ff45d2b7164f467a4de335fecb5749d6c9 -size 56147 +oid sha256:603d4b5fbe4b7d1c60f6a5cc7ae24cb3902e584c0add28e7a9597c6d011d22ca +size 5662 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset b/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset index d51cd23..d1d5383 100644 --- a/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset +++ b/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cfaa02a65d2dff743d9e36c6c4f2dcebc9a26cf51f72b72d138c8425acdefa6 -size 56089 +oid sha256:1c3f71638dd90b0933ed297a37ca2b3ab9a66422ce47f33eaa6093f15c6537dc +size 5642 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/windows.uasset b/Game/GigLine/Content/Hollywood/Vehicle/windows.uasset new file mode 100644 index 0000000..81bcf76 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/windows.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbd59ae1a1dc7f4ab6dc2fcf4342de7f65ba6bdf4b184fb4c6c7d32890c50f1d +size 5676 diff --git a/Screenshots/COMPARISON.md b/Screenshots/COMPARISON.md deleted file mode 100644 index 1aee619..0000000 --- a/Screenshots/COMPARISON.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 | -| --- | --- | --- | -| | — | `review-demo-crossing.png` | -| | — | `review-demo-north-buildings.png` | -| | — | `review-demo-retail-frontage.png` | -| | — | `review-demo-spawn-view.png` | -| | — | `review-demo-street-level.png` | -| | | `review-demo-third-person-front.png` | -| | | `review-demo-third-person-walk.png` | -| | | `review-demo-third-person.png` | -| | | `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. diff --git a/Screenshots/README.md b/Screenshots/README.md index 3e2d684..400ee95 100644 --- a/Screenshots/README.md +++ b/Screenshots/README.md @@ -196,3 +196,50 @@ Resolved and removed from this list on 2026-11-09: - "The El Capitan Theatre has no marquee or blade sign" — `Tools/ue_stage_theatre_trim.py` stages all six trim actors; verified in `/tmp/gigline/theatre-import.txt`. - "Materials are not yet created in Blender or fully integrated" — contradicted by the scene conventions above: every surface carries a look-dev material built in `Tools/ue_build_demo_map.py`. + + + +## Blender vs in-game + +Regenerated by `Tools/build_comparison_md.py`; everything between the markers is rewritten, so edit outside them. + +- 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 | +| --- | --- | --- | +| | — | `review-demo-crossing.png` | +| | — | `review-demo-north-buildings.png` | +| | — | `review-demo-retail-frontage.png` | +| | — | `review-demo-spawn-view.png` | +| | — | `review-demo-street-level.png` | +| | | `review-demo-third-person-front.png` | +| | | `review-demo-third-person-walk.png` | +| | | `review-demo-third-person.png` | +| | | `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. + + diff --git a/Tools/README.md b/Tools/README.md index 940982b..9bb74f9 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -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 -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 `` 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 diff --git a/Tools/build_comparison_md.py b/Tools/build_comparison_md.py index bc75d44..ad8e7cd 100644 --- a/Tools/build_comparison_md.py +++ b/Tools/build_comparison_md.py @@ -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 = "" +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 diff --git a/Tools/ue_vehicle_materials.py b/Tools/ue_vehicle_materials.py new file mode 100644 index 0000000..ef0fe1d --- /dev/null +++ b/Tools/ue_vehicle_materials.py @@ -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/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ +# -script=/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") diff --git a/Tools/vehicle_from_template.py b/Tools/vehicle_from_template.py index 8705368..cf4f7b4 100644 --- a/Tools/vehicle_from_template.py +++ b/Tools/vehicle_from_template.py @@ -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,