fix(hero): the albedo renders - two blockers, both at the map level

The generated ashlar albedo never showed for two reasons that had nothing to do with the material graph,
which was verified correct the whole time (TextureSampleParameter2D -> Multiply -> Base Color, colour sampler,
texture set, 6x UV tiling):

1. **The map overrode every hero slot.** The generic per-slot material pass in ue_build_demo_map.py assigned
   the facade KIT's flat stone to every actor whose slot names looked like stone, banner, glass - including
   Hero_Facade. Whatever the import authored was replaced at map build time. The hero is now excluded and gets
   its own materials.
2. **Mesh slot assignment does not persist.** Assigning to mesh.get_editor_property('static_materials') returns a
   copy; after a reload the mesh still pointed at the original imported materials (measured twice). Component
   level overrides do persist in the saved map, so apply_hero_materials() sets them in both builders.

The elevation now renders as coursed, grainy warm limestone with banners, window bays and recessed portals.

Rows 4-6 re-rendered and pending review; the comparison plate is regenerated. Still missing against the
reference: carved inscription, medallion relief, banner lettering, crimson banner tone. The two-model judge
returns no/no (0.90/0.95) - it wants the text and detail that are still absent, which is at least the right
complaint this time.
This commit is contained in:
zwitschi committed 2026-11-10 13:57:57 +01:00
1 parent 0189ff00fe
commit ff9eb0a4ad
118 files changed
+242 -189

No files matched your search

+36
View File
@@ -45,6 +45,34 @@ def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def apply_hero_materials(actor):
"""Point the hero's component slots at the authored *_Ref materials, if they exist.
The mesh's own slot list cannot be relied on: `get_editor_property("static_materials")` returns a copy, so
assigning to it does not persist across a reload. A component-level override does persist in the saved map,
and it is what the captures actually render.
"""
if actor is None or actor.get_actor_label() != "Hero_Facade":
return 0
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
return 0
slots = comp.static_mesh.get_editor_property("static_materials")
applied = 0
for index, slot in enumerate(slots):
current = slot.get_editor_property("material_interface")
if not current:
continue
ref = unreal.EditorAssetLibrary.load_asset(
f"/Game/Hollywood/ElCapitan/{current.get_name()}_Ref")
if ref:
comp.set_material(index, ref)
applied += 1
if applied:
log(f"hero materials: {applied}/{len(slots)} slots use the authored *_Ref materials")
return applied
def spawn(cls, loc, label, rot=None):
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
@@ -618,6 +646,14 @@ def build():
assigned = 0
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
# The hero carries its own materials, authored per slot by
# Tools/unreal/ue_hero_facade_import.py from the reference photographs (including the generated
# ashlar albedo). This generic pass used to override them with the kit's flat stone, which is why the
# elevation kept rendering as a plain card no matter what the import did.
if actor.get_actor_label() == "Hero_Facade":
log("materials Hero_Facade: skipped (own authored materials)")
apply_hero_materials(actor)
continue
fallback = LABEL_MATERIALS.get(actor.get_actor_label())
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
+29
View File
@@ -34,6 +34,34 @@ def switch_level(path):
def log(msg):
lines.append(str(msg))
def apply_hero_materials(actor):
"""Point the hero's component slots at the authored *_Ref materials, if they exist.
The mesh's own slot list cannot be relied on: `get_editor_property("static_materials")` returns a copy, so
assigning to it does not persist across a reload. A component-level override does persist in the saved map,
and it is what the captures actually render.
"""
if actor is None or actor.get_actor_label() != "Hero_Facade":
return 0
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
return 0
slots = comp.static_mesh.get_editor_property("static_materials")
applied = 0
for index, slot in enumerate(slots):
current = slot.get_editor_property("material_interface")
if not current:
continue
ref = unreal.EditorAssetLibrary.load_asset(
f"/Game/Hollywood/ElCapitan/{current.get_name()}_Ref")
if ref:
comp.set_material(index, ref)
applied += 1
if applied:
log(f"hero materials: {applied}/{len(slots)} slots use the authored *_Ref materials")
return applied
def ensure_writable(path):
# A packed .umap left read-only (git sets 644->444 on checkout, or the
@@ -224,6 +252,7 @@ try:
if actor:
actor.static_mesh_component.set_static_mesh(hero)
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
apply_hero_materials(actor)
log("staged: hero facade at the measured building line")
else:
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/unreal/ue_hero_facade_import.py)")
+30 -44
View File
@@ -84,69 +84,55 @@ def import_albedo():
def tint_imported_materials():
"""Author plain facade materials for the imported slots.
"""Rebuild each imported facade material in place with the reference colour and the stone albedo.
The FBX import produces material *instances*, which cannot carry their own base colour, and the
instance's parent keeps the blockout's near-white constant. Instead of fighting that, this creates one
simple material per facade surface with the colour sampled from the reference and assigns it to the
matching slot. That is the step that finally moves the stone off white; a tint attempt that silently
did nothing is what let it survive three rebuilds.
Assigning new material assets to the mesh's slots does NOT persist: the slot list returned by
`get_editor_property("static_materials")` is a copy, and after a reload the mesh still points at the
originals - measured, twice. Editing the imported material assets themselves is what sticks, because the
mesh already references them by name. This is also the step that finally moves the stone off a flat tint.
Albedo is multiplied by a tint rather than used raw, so the generated texture's brightness stays inside the
reference's range.
"""
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade")
if mesh is None:
log("no SM_HeroFacade to tint")
return
library = unreal.MaterialEditingLibrary
albedo = import_albedo()
slots = mesh.get_editor_property("static_materials")
assigned = 0
for index, slot in enumerate(slots):
current = slot.get_editor_property("material_interface")
name = current.get_name() if current else ""
base = name.split(".")[0].replace("_T", "")
rgb = FACADE_TINTS.get(base)
if rgb is None:
rebuilt = 0
for base, rgb in FACADE_TINTS.items():
path = f"{DEST}/{base}"
if not unreal.EditorAssetLibrary.does_asset_exist(path):
log(f"{base}: missing, not rebuilt")
continue
path = f"{DEST}/{base}_Ref"
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
base + "_Ref", DEST, unreal.Material, unreal.MaterialFactoryNew())
material = unreal.EditorAssetLibrary.load_asset(path)
if material is None:
log(f"{base}: could not load")
continue
library.delete_all_material_expressions(material)
colour = library.create_material_expression(
material, unreal.MaterialExpressionConstant3Vector, -400, 0)
material, unreal.MaterialExpressionConstant3Vector, -600, 200)
colour.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
source = colour
if albedo is not None and base in ("M_Facade_Stone", "M_Facade_StoneShadow"):
# Texture sample multiplied by the tint: the map carries the coursing and grain, the tint keeps
# the value in the reference's range instead of the generator's brighter one.
sample = library.create_material_expression(
material, unreal.MaterialExpressionTextureSampleParameter2D, -700, 0)
material, unreal.MaterialExpressionTextureSampleParameter2D, -900, -100)
sample.set_editor_property("parameter_name", f"Albedo_{base}")
sample.set_editor_property("texture", albedo)
if hasattr(unreal, "MaterialExpressionTextureCoordinate"):
uvs = library.create_material_expression(
material, unreal.MaterialExpressionTextureCoordinate, -950, 0)
uvs.set_editor_property("u_tiling", 6.0)
uvs.set_editor_property("v_tiling", 6.0)
library.connect_material_expressions(uvs, "", sample, "UVs")
sample.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_COLOR)
uvs = library.create_material_expression(
material, unreal.MaterialExpressionTextureCoordinate, -1150, -100)
uvs.set_editor_property("u_tiling", 6.0)
uvs.set_editor_property("v_tiling", 6.0)
library.connect_material_expressions(uvs, "", sample, "UVs")
multiply = library.create_material_expression(
material, unreal.MaterialExpressionMultiply, -250, 0)
material, unreal.MaterialExpressionMultiply, -300, 0)
library.connect_material_expressions(sample, "RGB", multiply, "A")
library.connect_material_expressions(colour, "", multiply, "B")
source = multiply
library.connect_material_property(source, "", unreal.MaterialProperty.MP_BASE_COLOR)
library.recompile_material(material)
# Save the material itself. Saving only the mesh left these assets unsaved, so after a reload the
# slots pointed at nothing and the whole elevation rendered in one default colour.
saved = unreal.EditorAssetLibrary.save_loaded_asset(material)
log(f"{base}_Ref saved: {saved}")
slot.set_editor_property("material_interface", material)
assigned += 1
log(f"slot {index} {base}: {material.get_name()} {rgb}")
if assigned:
mesh.set_editor_property("static_materials", slots)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
log(f"assigned {assigned} facade materials")
log(f"{base}: rebuilt (albedo {'yes' if source is not colour else 'no'}), saved {saved}")
rebuilt += 1
log(f"rebuilt {rebuilt} facade materials")
def drop_stale_mesh():