feat(facades): 0.1.4 — module kit, textured materials, theatre trim (#30)

Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
zwitschi authored and zwitschi committed 2026-11-08 20:29:11 +01:00
1 parent d6d3d54810
commit 5e2c5e29c0
90 files changed
+2592 -413

No files matched your search

+223 -16
View File
@@ -16,10 +16,13 @@
# deterministically instead of stacking duplicates.
import os
import sys
import unreal
MAP = "/Game/Hollywood/DemoMap"
# Repository root, so the texture generator can be invoked by path.
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
RESULT = "/tmp/gigline/demo-map.txt"
lines = []
@@ -50,7 +53,46 @@ def spawn(cls, loc, label, rot=None):
return actor
def ensure_material(mat_name, spec):
TEXTURE_DIR = "/Game/Hollywood/Textures"
def import_texture(png_path, asset_name):
"""Import a generated PNG as a Texture2D (idempotent: replaces the asset)."""
if not os.path.exists(png_path):
log(f"texture missing: {png_path}")
return None
task = unreal.AssetImportTask()
task.filename = png_path
task.destination_path = TEXTURE_DIR
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.TextureImportUI() if hasattr(unreal, "TextureImportUI") else None
if ui:
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
return unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}")
def ensure_textures():
"""Generate/point at the facade maps and import them. Returns {name: asset}."""
out_dir = "/tmp/gigline/textures"
gen = os.path.join(ROOT, "Tools", "generate_facade_textures.py")
if os.path.exists(gen) and not os.path.exists(os.path.join(out_dir, "T_StoneAshlar_BC.png")):
import subprocess
subprocess.run([sys.executable, gen, "--out", out_dir, "--size", "512"], check=False)
imported = {}
for name in ("T_StoneAshlar_BC", "T_StoneAshlar_R", "T_Stucco_BC", "T_Stucco_R",
"T_Asphalt_BC", "T_Asphalt_R", "T_Concrete_BC", "T_Concrete_R"):
asset = import_texture(os.path.join(out_dir, name + ".png"), name)
if asset:
imported[name] = asset
log(f"textures imported: {len(imported)}/8")
return imported
def ensure_material(mat_name, spec, textures=None):
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
@@ -65,6 +107,13 @@ def ensure_material(mat_name, spec):
spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale
(world units per noise cycle; smaller = larger features).
Optional `courses_m` adds **masonry coursing**: horizontal joint lines at a
given course height in world metres, plus a per-course horizontal offset so
the vertical joints stagger like real ashlar. Optional `joint_tint` is the
colour the joints darken toward, and `roughness_variation` lerps roughness
between `roughness` and a rougher value using the same noise, so the surface
does not read as one flat specular value.
"""
path = f"/Game/Hollywood/Site/{mat_name}"
if unreal.EditorAssetLibrary.does_asset_exist(path):
@@ -88,6 +137,20 @@ def ensure_material(mat_name, spec):
n.set_editor_property("r", float(value))
return n
def link(src, dst, pin=""):
"""Connect, and say so when it fails.
`connect_material_expressions` returns False rather than raising, so an
unknown pin name silently leaves an input unconnected and the material
compiles with a default value — a coursing mask of 0 is invisible, which
is exactly how the first attempt failed.
"""
ok = lib.connect_material_expressions(src, "", dst, pin)
if not ok:
log(f"material {mat_name}: FAILED to connect {src.__class__.__name__} "
f"-> {dst.__class__.__name__}.{pin or '(default)'}")
return ok
# world position -> noise -> lerp(dark, bright) -> base colour
wp = node(unreal.MaterialExpressionWorldPosition)
scale = scalar(spec.get("noise_scale", 0.004))
@@ -108,10 +171,95 @@ def ensure_material(mat_name, spec):
lib.connect_material_expressions(c3(spec["dark"]), "", lerp, "A")
lib.connect_material_expressions(c3(spec["bright"]), "", lerp, "B")
lib.connect_material_expressions(noise, "", lerp, "Alpha")
lib.connect_material_property(lerp, "", unreal.MaterialProperty.MP_BASE_COLOR)
lib.connect_material_property(
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
# --- optional masonry coursing -----------------------------------------
# A world-space course mask: the fractional part of Z / course height is a
# sawtooth per course; zeroing it gives a joint line every course. The row
# index shifts each course sideways by half a brick, so vertical joints
# stagger. All of it is world-space, so every building shares the coursing
# without needing its own UVs.
base_color_expr = lerp
if spec.get("courses_m"):
# WorldPosition is in CENTIMETRES, so a course height given in metres must
# be scaled by 100 here; getting this wrong makes the coursing 100x too
# fine to see at all (the first attempt did exactly that).
course_cm = float(spec["courses_m"]) * 100.0
row = node(unreal.MaterialExpressionDivide)
link(wp, row, "A")
link(c3((course_cm, course_cm, course_cm)), row, "B")
saw = node(unreal.MaterialExpressionFrac)
link(row, saw)
# Joint mask without a comparison node (UE has no Less): 1 - saw/joint is
# 1 at the course boundary and 0 once past the joint width; clamped to 0..1
# so it is a linear band rather than a hard step.
div = node(unreal.MaterialExpressionDivide)
link(saw, div, "A")
# joint_width is a fraction of one course (0.11 ~= a 6 cm joint on a
# 55 cm course), which is the space `saw` lives in after the divide.
link(scalar(spec.get("joint_width", 0.11)), div, "B")
inv = node(unreal.MaterialExpressionOneMinus)
link(div, inv)
joint = node(unreal.MaterialExpressionClamp)
link(inv, joint)
tinted = node(unreal.MaterialExpressionLinearInterpolate)
link(lerp, tinted, "A")
link(c3(spec.get("joint_tint", (0.34, 0.31, 0.27))), tinted, "B")
link(joint, tinted, "Alpha")
base_color_expr = tinted
# If a texture set is supplied for this material, sample it world-aligned and
# use it instead of the graph-only coursing: the map carries the coursing and
# the grain, so overlaying both would double the pattern.
tex = (textures or {}).get(spec.get("texture", ""))
if tex:
bc = (textures or {}).get(spec.get("texture", "") + "_BC")
# world position (cm) -> UV: one texture tile every tile_cm centimetres
tile_cm = float(spec.get("tile_cm", 300.0))
uv_scale = node(unreal.MaterialExpressionMultiply)
link(wp, uv_scale, "A")
link(scalar(1.0 / tile_cm), uv_scale, "B")
mask_xy = node(unreal.MaterialExpressionComponentMask)
for pin in ("R", "G"):
try:
mask_xy.set_editor_property(pin, True)
except Exception: # noqa: BLE001
pass
try:
mask_xy.set_editor_property("B", False)
mask_xy.set_editor_property("A", False)
except Exception: # noqa: BLE001
pass
link(uv_scale, mask_xy)
if bc:
sample = node(unreal.MaterialExpressionTextureSample)
sample.set_editor_property("texture", bc)
link(mask_xy, sample, "UVs")
lib.connect_material_property(sample, "RGB",
unreal.MaterialProperty.MP_BASE_COLOR)
base_color_expr = sample
r_map = (textures or {}).get(spec.get("texture", "") + "_R")
if r_map:
r_sample = node(unreal.MaterialExpressionTextureSample)
r_sample.set_editor_property("texture", r_map)
link(mask_xy, r_sample, "UVs")
lib.connect_material_property(r_sample, "R",
unreal.MaterialProperty.MP_ROUGHNESS)
lib.connect_material_property(base_color_expr, "", unreal.MaterialProperty.MP_BASE_COLOR)
# Roughness: lerp(base, base + variation, noise) so the highlight breaks up.
variation = float(spec.get("roughness_variation", 0.0))
if variation > 0.0:
rough_lerp = node(unreal.MaterialExpressionLinearInterpolate)
lib.connect_material_expressions(
scalar(spec.get("roughness", 0.85)), "", rough_lerp, "A")
lib.connect_material_expressions(
scalar(min(1.0, spec.get("roughness", 0.85) + variation)), "", rough_lerp, "B")
lib.connect_material_expressions(noise, "", rough_lerp, "Alpha")
lib.connect_material_property(rough_lerp, "", unreal.MaterialProperty.MP_ROUGHNESS)
else:
lib.connect_material_property(
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
try:
lib.connect_material_property(
scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR)
@@ -269,19 +417,47 @@ def build():
# Look-developed surface materials (see ensure_material for the graph).
MATERIAL_SPECS = {
"M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078),
roughness=0.92, specular=0.22, noise_scale=0.006),
roughness=0.92, specular=0.22, noise_scale=0.006,
texture="T_Asphalt", tile_cm=900.0),
"M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50),
roughness=0.88, specular=0.28, noise_scale=0.004),
roughness=0.88, specular=0.28, noise_scale=0.004,
texture="T_Concrete", tile_cm=250.0),
"M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54),
roughness=0.85, specular=0.30, noise_scale=0.008),
"M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26),
roughness=0.98, specular=0.15, noise_scale=0.003),
"M_RoadPaint": dict(dark=(0.74, 0.74, 0.72), bright=(0.86, 0.86, 0.84),
roughness=0.62, specular=0.35, noise_scale=0.02),
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.74, 0.70, 0.62),
roughness=0.72, specular=0.32, noise_scale=0.005),
"M_Stucco": dict(dark=(0.52, 0.48, 0.42), bright=(0.66, 0.62, 0.55),
roughness=0.86, specular=0.25, noise_scale=0.004),
# 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),
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,
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.
"M_Stucco": dict(dark=(0.50, 0.46, 0.40), bright=(0.68, 0.64, 0.57),
roughness=0.76, specular=0.25, noise_scale=0.0025,
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),
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),
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
# surface (there is no refraction pass at blockout fidelity).
"M_FacadeGlass": dict(dark=(0.030, 0.045, 0.055), bright=(0.075, 0.095, 0.110),
roughness=0.14, specular=0.62, noise_scale=0.01,
roughness_variation=0.06),
# The JIMMY KIMMEL banners: the one saturated surface on the elevation.
"M_FacadeBanner": dict(dark=(0.32, 0.05, 0.07), bright=(0.55, 0.10, 0.12),
roughness=0.72, specular=0.22, noise_scale=0.02,
roughness_variation=0.12),
}
SURFACE_MATERIALS = {
"Terrain_DEM": "M_Ground",
@@ -292,9 +468,10 @@ def build():
"Kerb_North": "M_KerbConcrete",
"Crossing_Markings": "M_RoadPaint",
}
textures = ensure_textures()
built = {}
for mat_name, spec in MATERIAL_SPECS.items():
built[mat_name] = ensure_material(mat_name, spec)
built[mat_name] = ensure_material(mat_name, spec, textures)
imported = 0
for name, mat_name in SURFACE_MATERIALS.items():
@@ -337,15 +514,45 @@ def build():
LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone",
"North_TCL_Chinese": "M_Stucco",
"North_Madame_Tussauds": "M_Stucco"}
# Assign per SLOT, by what the slot's imported material is called, not just
# slot 0: the hero mesh's slot 0 is the banner, so the stone, the shadowed
# stone and the glazing were all still wearing their flat Blender materials.
SLOT_RULES = (
("glass", "M_FacadeGlass"),
("banner", "M_FacadeBanner"),
("stoneshadow", "M_FacadeStoneShadow"),
("stone", "M_FacadeStone"),
("roof", "M_Concrete"),
("wall", "M_Stucco"),
)
def slot_material_name(slot_mesh_material):
if not slot_mesh_material:
return None
key = slot_mesh_material.get_name().lower()
for needle, target in SLOT_RULES:
if needle in key:
return target
return None
assigned = 0
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
mat_name = LABEL_MATERIALS.get(actor.get_actor_label())
if not mat_name or mat_name not in built:
continue
fallback = LABEL_MATERIALS.get(actor.get_actor_label())
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh:
comp.set_material(0, built[mat_name])
if not comp or not comp.static_mesh:
continue
slots = comp.static_mesh.get_editor_property("static_materials")
touched = 0
for index, slot in enumerate(slots):
mesh_mat = slot.get_editor_property("material_interface")
target = slot_material_name(mesh_mat) or fallback
if target and target in built:
comp.set_material(index, built[target])
touched += 1
if touched:
assigned += 1
log(f"materials {actor.get_actor_label()}: {touched}/{len(slots)} slots")
log(f"assigned look materials to {assigned} facade/background actors")
# --- surrounding block frontage ---------------------------------------