materials: look-developed surface materials for every exported asset

Completes "Materials and look development for all exported assets". Every
exported surface previously carried a flat Constant3Vector fill, which read as
plastic. ensure_material() in Tools/ue_build_demo_map.py now builds a
procedural material per surface:

- base colour blended between two shades by world-space noise
  (WorldPosition -> Multiply -> Noise -> Lerp -> BaseColor) for large-scale
  variation,
- an explicit roughness and specular so the surface type is readable.

  M_Asphalt 0.92, M_Concrete 0.88, M_KerbConcrete 0.85, M_Ground 0.98,
  M_RoadPaint 0.62, M_FacadeStone 0.72, M_Stucco 0.86

Site meshes take their material by mesh name; the hero facade and the two
north-side buildings take theirs by actor label, so they are covered even
though other scripts stage them. Materials are deleted and rebuilt on every
run, so a look change cannot be masked by a stale asset.

Result: the facade reads as warm stone with surface variation, asphalt as dark
asphalt with grain, concrete and kerbs lighter and matte, and the north
buildings in stucco instead of flat grey.

Screenshots re-rendered and approved by the owner. The richer texture-based
pass (baked/procedural brick_facade, seamless_concrete, glass, material
instances) stays in the 0.1.4 facade patch.

Squash of branch materials-lookdev-0.1.0 (PR #5).
This commit is contained in:
zwitschi committed 2026-11-07 20:34:57 +01:00
1 parent c626b379d9
commit 62a41bf904
27 files changed
+181 -71

No files matched your search

+115 -25
View File
@@ -50,26 +50,78 @@ def spawn(cls, loc, label, rot=None):
return actor
def ensure_material(asset_name, rgb):
"""Create/reuse a flat-colour material under /Game/Hollywood/Site.
def ensure_material(mat_name, spec):
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
Used where the FBX material round-trip does not survive import: the terrain
arrives with WorldGridMaterial (UE's bright default grid), which reads as a
blown-out white field under the demo sun.
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
terrain even lands with WorldGridMaterial), so the demo has always assigned
its own material per surface. Those were flat colour only, which read as
plastic. Each material here gets:
* base colour with a subtle world-space noise blend between two shades,
so asphalt and concrete have large-scale variation instead of a fill,
* an explicit roughness (and specular) so the surface type is readable,
wet-looking stone vs matte concrete vs chalky road paint.
spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale
(world units per noise cycle; smaller = larger features).
"""
path = f"/Game/Hollywood/Site/{asset_name}"
path = f"/Game/Hollywood/Site/{mat_name}"
if unreal.EditorAssetLibrary.does_asset_exist(path):
return unreal.EditorAssetLibrary.load_asset(path)
# Rebuilt every run so a look change cannot be masked by a stale asset.
unreal.EditorAssetLibrary.delete_asset(path)
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
asset_name, "/Game/Hollywood/Site", unreal.Material, unreal.MaterialFactoryNew())
node = unreal.MaterialEditingLibrary.create_material_expression(
mat, unreal.MaterialExpressionConstant3Vector)
node.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
unreal.MaterialEditingLibrary.connect_material_property(
node, "", unreal.MaterialProperty.MP_BASE_COLOR)
unreal.MaterialEditingLibrary.recompile_material(mat)
mat_name, "/Game/Hollywood/Site", unreal.Material, unreal.MaterialFactoryNew())
lib = unreal.MaterialEditingLibrary
def node(cls):
return lib.create_material_expression(mat, cls)
def c3(rgb):
n = node(unreal.MaterialExpressionConstant3Vector)
n.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
return n
def scalar(value):
n = node(unreal.MaterialExpressionConstant)
n.set_editor_property("r", float(value))
return n
# world position -> noise -> lerp(dark, bright) -> base colour
wp = node(unreal.MaterialExpressionWorldPosition)
scale = scalar(spec.get("noise_scale", 0.004))
mul = node(unreal.MaterialExpressionMultiply)
lib.connect_material_expressions(wp, "", mul, "A")
lib.connect_material_expressions(scale, "", mul, "B")
noise = node(unreal.MaterialExpressionNoise)
for prop, value in (("scale", 1.0), ("quality", 1), ("levels", 2),
("output_min", 0.0), ("output_max", 1.0)):
try:
noise.set_editor_property(prop, value)
except Exception: # noqa: BLE001
pass
lib.connect_material_expressions(mul, "", noise, "Position")
lerp = node(unreal.MaterialExpressionLinearInterpolate)
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)
try:
lib.connect_material_property(
scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR)
except Exception: # noqa: BLE001 - specular input is optional in UE5
pass
lib.recompile_material(mat)
unreal.EditorAssetLibrary.save_loaded_asset(mat)
log(f"created material {asset_name} rgb={rgb}")
log(f"material {mat_name}: rough={spec.get('roughness')} "
f"dark={spec['dark']} bright={spec['bright']}")
return mat
@@ -209,17 +261,38 @@ def build():
# builds a material with a default white base colour, and the terrain even
# lands with WorldGridMaterial. Every site surface therefore rendered white,
# which is what washed out the demo. Assign explicit colours per mesh here.
SURFACE_MATERIALS = {
"Terrain_DEM": ("M_Ground", (0.30, 0.28, 0.25)),
"Road_Hollywood": ("M_Asphalt", (0.055, 0.055, 0.06)),
"Sidewalk_South": ("M_Concrete", (0.52, 0.50, 0.47)),
"Sidewalk_North": ("M_Concrete", (0.52, 0.50, 0.47)),
"Kerb_South": ("M_Concrete", (0.52, 0.50, 0.47)),
"Kerb_North": ("M_Concrete", (0.52, 0.50, 0.47)),
"Crossing_Markings": ("M_RoadPaint", (0.80, 0.80, 0.78)),
# 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),
"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),
"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),
}
SURFACE_MATERIALS = {
"Terrain_DEM": "M_Ground",
"Road_Hollywood": "M_Asphalt",
"Sidewalk_South": "M_Concrete",
"Sidewalk_North": "M_Concrete",
"Kerb_South": "M_KerbConcrete",
"Kerb_North": "M_KerbConcrete",
"Crossing_Markings": "M_RoadPaint",
}
built = {}
for mat_name, spec in MATERIAL_SPECS.items():
built[mat_name] = ensure_material(mat_name, spec)
imported = 0
for name, (mat_name, rgb) in SURFACE_MATERIALS.items():
for name, mat_name in SURFACE_MATERIALS.items():
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if mesh is None:
continue
@@ -227,12 +300,29 @@ def build():
if actor:
actor.static_mesh_component.set_static_mesh(mesh)
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
mat = ensure_material(mat_name, rgb)
mat = built.get(mat_name)
if mat:
actor.static_mesh_component.set_material(0, mat)
imported += 1
log(f"staged {imported} site meshes with explicit surface materials")
# The hero facade and the two north-side buildings are staged by other
# scripts (or reused between rebuilds), so assign their look materials by
# actor label after everything is in the level.
LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone",
"North_TCL_Chinese": "M_Stucco",
"North_Madame_Tussauds": "M_Stucco"}
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
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh:
comp.set_material(0, built[mat_name])
assigned += 1
log(f"assigned look materials to {assigned} facade/background actors")
# --- player start: north sidewalk, facing the hero frontage -------------
# Signs were measured from the imported meshes rather than assumed: the FBX
# preset flips north, so in Unreal the road spans Y -1382..+1047, the NORTH