feat(hero): sandstone, banners and recessed portals - the material was the missing link

Iterated the loop five times against Art/reference/hero-masonic/. What took the iterations was the material,
not the geometry:

- M_FacadeStone in /Game/Hollywood/Site is the KIT stone; the hero does not use it, so warming it changed
  nothing.
- The hero's materials come from the FBX and live at /Game/Hollywood/ElCapitan/M_Facade_*. Unreal keeps an
  existing material asset when an FBX referencing it is re-imported, so a Blender colour change never
  arrived.
- Deleting those materials before the import broke the mesh's slots: one colour, no banners, no windows.

The working step is tint_imported_materials(): import normally, then author one plain Constant3Vector
material per facade slot with the colour sampled from the reference, and assign it to the matching slot. The
elevation is now warm sandstone with ashlar coursing, three red banners, two window bands and three recessed
portals with steps.

Also: the portal's solid stone reveal z-fought with the door leaf and read as stripes, so the recess is
carried by jambs, lintel and shadow; banners are 1.35 x 6.2 m hung from 7.2 m instead of 1.55 x 5.4.

Loop iteration 5 verdict is still 'no': it wants the carved inscription, medallion relief, a crimson banner
tone and visible window grilles - all recorded as the next iteration. Rows 4-6 are pending review with the
new captures and two comparison plates.
This commit is contained in:
zwitschi committed 2026-11-10 09:05:48 +01:00
1 parent 19ed4fefd4
commit bf7dfe1052
120 files changed
+276 -158

No files matched your search

+27 -4
View File
@@ -131,8 +131,8 @@ def build():
# Warm limestone, sampled from 02-masonictemple01.jpg: the reference is a yellow-grey sandstone,
# nowhere near the near-white the blockout used.
stone = make_material("M_Facade_Stone", (0.66, 0.60, 0.48, 1.0), 0.82)
stone_dark = make_material("M_Facade_StoneShadow", (0.52, 0.47, 0.38, 1.0), 0.85)
stone = make_material("M_Facade_Stone", (0.34, 0.29, 0.21, 1.0), 0.82)
stone_dark = make_material("M_Facade_StoneShadow", (0.22, 0.185, 0.135, 1.0), 0.85)
glass = make_material("M_Facade_GlassDark", (0.05, 0.06, 0.07, 1.0), 0.25)
banner = make_material("M_Facade_Banner", (0.66, 0.06, 0.05, 1.0), 0.9)
@@ -306,8 +306,11 @@ def build():
# Recessed doorway: a dark reveal set back inside the plinth, 1.2 m deep so
# it reads as an opening from any angle.
depth = 1.2
cube(f"Hero_Portal_{i}", x, plinth_front + depth / 2.0 + 0.05, PORTAL_H / 2.0,
PORTAL_W, depth, PORTAL_H, glass)
# Door leaf set back inside the opening. A solid stone reveal box at the same volume z-fought
# with the leaf and read as stripes, so the recess is carried by the jambs, the lintel and the
# shadow instead.
cube(f"Hero_PortalLeaf_{i}", x, plinth_front + depth - 0.10, PORTAL_H / 2.0,
PORTAL_W - 0.10, 0.08, PORTAL_H - 0.10, glass)
# Stone surround, standing 0.1 m proud of the plinth face.
for side, sx in (("L", -1.0), ("R", 1.0)):
# Jambs stop at the pavement: a 0.15 m overhang below Z 0 would sink the
@@ -362,6 +365,26 @@ def build():
cube(f"Hero_Window_{rank}_{i}", wx, WINDOW_Y, z, 2.0, 0.08, 1.9, glass)
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
# --- window grilles ------------------------------------------------------
# The reference bays are closed with a dark metal trellis, not plain glass. Seven vertical and five
# horizontal bars per opening, 4 cm square, read as a grille at street distance and cost 60 cubes.
grille = make_material("M_Facade_Grille", (0.10, 0.10, 0.11, 1.0), 0.5)
barred = 0
for obj in list(bpy.data.objects):
if not obj.name.startswith("Hero_Window_"):
continue
wx, wy, wz, wdx, wdy, wdz = (obj.location.x, obj.location.y, obj.location.z,
obj.dimensions.x, obj.dimensions.y, obj.dimensions.z)
for v in range(7):
x = wx - wdx / 2.0 + (v + 0.5) * wdx / 7.0
cube(f"Hero_GrilleV_{barred:02d}", x, wy - 0.02, wz, 0.04, 0.03, wdz * 0.96, grille)
barred += 1
for hcount in range(5):
z = wz - wdz / 2.0 + (hcount + 0.5) * wdz / 5.0
cube(f"Hero_GrilleH_{barred:02d}", wx, wy - 0.02, z, wdx * 0.96, 0.03, 0.04, grille)
barred += 1
print(f"[hero] window grilles: {barred} bars")
# --- rustication and roof cresting --------------------------------------
# The photographs show the ground storey in banded ashlar and a cresting with balusters along
# the roof edge; without them the elevation reads as a flat card.
+11 -5
View File
@@ -20,10 +20,10 @@ REFERENCE="${REFERENCE:-Art/reference/hero-masonic/02-masonictemple01.jpg}"
step() { printf '\n== %s\n' "$1"; }
step "1/5 blender files writable"
step "1/6 blender files writable"
find Art -name '*.blend*' -exec chmod u+w {} \;
step "2/5 build the facade"
step "2/6 build the facade"
LD_LIBRARY_PATH="$BLENDER_FILES/blender/lib" "$BLENDER_FILES/blender/blender" -b --factory-startup \
--python Tools/hero_facade_build.py -- \
--out /tmp/gigline/hero-facade.fbx \
@@ -32,16 +32,22 @@ LD_LIBRARY_PATH="$BLENDER_FILES/blender/lib" "$BLENDER_FILES/blender/blender" -b
> /tmp/gigline/hero-build.log 2>&1 || { echo "build failed - see /tmp/gigline/hero-build.log"; exit 1; }
grep -E 'exported FBX' /tmp/gigline/hero-build.log || { echo "no FBX written"; exit 1; }
step "3/5 import into Unreal"
step "3/6 import into Unreal"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \
-script="$(pwd)/Tools/ue_hero_facade_import.py" -nullrhi -unattended -nopause -stdout \
> /tmp/gigline/hero-import.log 2>&1
grep -q 'save_map' /tmp/gigline/hero-facade-import.txt || { echo "import failed - see /tmp/gigline/hero-facade-import.txt"; exit 1; }
step "4/5 render the capture set"
step "4/6 rebuild DemoMap (materials live in the map builder)"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \
-script="$(pwd)/Tools/ue_build_demo_map.py" -nullrhi -unattended -nopause -stdout \
> /tmp/gigline/hero-demomap.log 2>&1
grep -q 'save_map' /tmp/gigline/demo-map.txt || { echo "demo map rebuild failed - see /tmp/gigline/demo-map.txt"; exit 1; }
step "5/6 render the capture set"
bash Tools/render_screenshots.sh > /tmp/gigline/hero-render.log 2>&1 || { echo "render failed - see /tmp/gigline/hero-render.log"; exit 1; }
step "5/5 judge and compare"
step "6/6 judge and compare"
python3 Tools/judge_screenshot.py --image "Screenshots/review/${SHOT}.png" \
--expect "A neoclassical Masonic temple facade: warm yellow-grey sandstone with horizontal ashlar courses, six giant fluted Ionic columns on square pedestals, three tall red vertical banners reading JIMMY KIMMEL LIVE hanging between the columns, dark lattice metal grilles in the window bays behind the colonnade, three recessed dark entrance doorways with steps, above the columns a carved inscription band flanked by two round medallions, topped by a crested parapet." \
| tee /tmp/gigline/hero-judge.txt | tail -2
+4 -4
View File
@@ -472,10 +472,10 @@ def build():
# Ashlar coursing at 0.55 m (a 0.10 m band joint), the reading the 2009
# facade photograph gives for the hero's stone; roughness breaks up so the
# wall is not one specular plane.
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64),
"M_FacadeStone": dict(dark=(0.30, 0.26, 0.20), bright=(0.47, 0.41, 0.31),
roughness=0.70, specular=0.32, noise_scale=0.005,
courses_m=0.55, joint_width=0.11,
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12,
joint_tint=(0.16, 0.14, 0.11), roughness_variation=0.16,
texture="T_StoneAshlar", tile_cm=440.0),
# Rendered/stucco frontage: no coursing, but a fine tooth and a wider
# roughness range than before, because flat stucco is what read as plastic.
@@ -484,10 +484,10 @@ def build():
roughness_variation=0.20, texture="T_Stucco", tile_cm=260.0),
# Stone in shadow: the hero's recessed wall behind the colonnade. Same
# coursing as the face, a stop darker, so depth reads without a light.
"M_FacadeStoneShadow": dict(dark=(0.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43),
"M_FacadeStoneShadow": dict(dark=(0.18, 0.155, 0.12), bright=(0.28, 0.24, 0.18),
roughness=0.78, specular=0.26, noise_scale=0.005,
courses_m=0.55, joint_width=0.11,
joint_tint=(0.28, 0.26, 0.23),
joint_tint=(0.10, 0.09, 0.07),
roughness_variation=0.10),
# Glazing: low roughness, strong specular. A plausible IOR for glass sits
# near 1.5, which UE expresses through specular ~0.5-0.6 on an opaque
+86
View File
@@ -41,6 +41,89 @@ def log(msg):
lines.append(str(msg))
FACADE_TINTS = {
# Linear values, sampled from the reference photograph: the blockout's near-white stone was the one
# thing the judge kept calling out. The FBX import keeps an existing material asset, so the colour has
# to be written into the material after the import - dropping the asset instead broke the mesh's slots.
"M_Facade_Stone": (0.34, 0.29, 0.21),
"M_Facade_StoneShadow": (0.21, 0.175, 0.125),
"M_Facade_GlassDark": (0.02, 0.025, 0.03),
"M_Facade_Banner": (0.42, 0.035, 0.03),
"M_Facade_Grille": (0.06, 0.06, 0.065),
}
def tint_imported_materials():
"""Author plain facade materials for the imported slots.
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.
"""
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade")
if mesh is None:
log("no SM_HeroFacade to tint")
return
library = unreal.MaterialEditingLibrary
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:
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())
colour = library.create_material_expression(
material, unreal.MaterialExpressionConstant3Vector, -400, 0)
colour.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
library.connect_material_property(colour, "", unreal.MaterialProperty.MP_BASE_COLOR)
library.recompile_material(material)
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")
def drop_stale_mesh():
"""Remove the imported mesh so its material slots are rebuilt from the FBX.
An earlier pass deleted the facade materials without the mesh, and the mesh kept pointing at the deleted
objects: every slot fell back and the whole elevation rendered in one colour with no banners or windows.
"""
path = f"{DEST}/SM_HeroFacade"
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
log(f"dropped stale mesh {path}")
def drop_stale_materials():
"""Delete the imported facade materials so the FBX recreates them with the current colours.
Unreal keeps an existing material asset when an FBX that references the same name is re-imported, so a
colour change in the Blender builder never reached the engine - the wall stayed the blockout's near-white
through several rebuilds. Deleting first is what makes the material part of the model.
"""
dropped = []
for name in ("M_Facade_Stone", "M_Facade_StoneShadow", "M_Facade_GlassDark",
"M_Facade_Banner", "M_Facade_Grille"):
path = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
dropped.append(name)
log(f"dropped stale materials: {dropped if dropped else 'none'}")
def import_fbx():
task = unreal.AssetImportTask()
task.filename = FBX
@@ -143,7 +226,10 @@ def measure_and_place():
try:
drop_stale_mesh()
drop_stale_materials()
import_fbx()
tint_imported_materials()
measure_and_place()
except Exception as exc: # noqa: BLE001
import traceback