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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| <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.
|
|
||||||
@@ -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`.
|
- "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`.
|
- "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-comparison:begin -->
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| <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.
|
||||||
|
|
||||||
|
<!-- blender-comparison:end -->
|
||||||
+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` |
|
| `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` |
|
| `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` |
|
| `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
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
DEMO_DIR = "Screenshots/demo"
|
DEMO_DIR = "Screenshots/demo"
|
||||||
BLENDER_DIR = "Screenshots/blender"
|
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
|
# 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
|
# building shots have no Blender counterpart because those meshes come from the Geo
|
||||||
@@ -94,9 +98,10 @@ def build():
|
|||||||
shots = demo_shots()
|
shots = demo_shots()
|
||||||
renders = blender_renders()
|
renders = blender_renders()
|
||||||
lines = [
|
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"- In-game captures: `{DEMO_DIR}/` ({len(shots)} files)",
|
||||||
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender_screenshots.py`",
|
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",
|
"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.",
|
"systems all show up here before they show up in a review.",
|
||||||
"",
|
"",
|
||||||
"## Side by side",
|
"### Side by side",
|
||||||
"",
|
"",
|
||||||
"| In-game | Blender | Shot |",
|
"| 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",
|
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.",
|
"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.",
|
"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.",
|
"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:
|
with open(OUT, "w") as handle:
|
||||||
handle.write("\n".join(lines))
|
handle.write(updated)
|
||||||
print(f"wrote {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
|
# 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
|
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):
|
def classify(obj):
|
||||||
"""Assign an object to a system by its materials first, then by its name."""
|
"""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]
|
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")
|
log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles")
|
||||||
bpy.data.objects.remove(lod, do_unlink=True)
|
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 = {
|
report = {
|
||||||
"template": bpy.data.filepath,
|
"template": bpy.data.filepath,
|
||||||
|
"materials": materials,
|
||||||
"target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M,
|
"target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M,
|
||||||
"height": TARGET_HEIGHT_M},
|
"height": TARGET_HEIGHT_M},
|
||||||
"systems": {system: {"object": joined[system].name,
|
"systems": {system: {"object": joined[system].name,
|
||||||
|
|||||||
Reference in new issue
Block a user