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

+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,