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).
355 lines
16 KiB
Python
355 lines
16 KiB
Python
# Build /Game/Hollywood/DemoMap: the walkable demo level.
|
|
#
|
|
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
|
# -script=<repo>/Tools/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout
|
|
#
|
|
# Content this map expects (built by earlier steps):
|
|
# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/ue_hero_facade_import.py
|
|
# /Game/Hollywood/Site/... Tools/ue_import_site.py
|
|
#
|
|
# Map conventions, from Geo/manifests/local-origin.md:
|
|
# X east, Y south, Z up, centimetres, origin at Hollywood x Highland.
|
|
# The player starts on the north sidewalk opposite the hero frontage, looking
|
|
# south at it, which is the view the reference photographs were taken from.
|
|
#
|
|
# Every actor is (re)created by label, so rerunning the script rebuilds the map
|
|
# deterministically instead of stacking duplicates.
|
|
|
|
import os
|
|
|
|
import unreal
|
|
|
|
MAP = "/Game/Hollywood/DemoMap"
|
|
RESULT = "/tmp/gigline/demo-map.txt"
|
|
lines = []
|
|
|
|
|
|
# EditorLevelLibrary.load_level/new_level are deprecated no-ops in -nullrhi
|
|
# commandlets (they return without switching worlds, so staged actors land in
|
|
# whatever world is current and get saved over the wrong map). The
|
|
# LevelEditorSubsystem actually switches.
|
|
LES = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
|
|
|
|
def switch_level(path):
|
|
return LES.load_level(path)
|
|
|
|
def log(msg):
|
|
lines.append(str(msg))
|
|
unreal.log(str(msg))
|
|
|
|
|
|
def spawn(cls, loc, label, rot=None):
|
|
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
|
|
if actor is None:
|
|
log(f"spawn {cls} failed")
|
|
return None
|
|
if rot is not None:
|
|
actor.set_actor_rotation(rot, False)
|
|
actor.set_actor_label(label)
|
|
return actor
|
|
|
|
|
|
def ensure_material(mat_name, spec):
|
|
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
|
|
|
|
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/{mat_name}"
|
|
if unreal.EditorAssetLibrary.does_asset_exist(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(
|
|
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"material {mat_name}: rough={spec.get('roughness')} "
|
|
f"dark={spec['dark']} bright={spec['bright']}")
|
|
return mat
|
|
|
|
|
|
def clear(prefixes=()):
|
|
removed = 0
|
|
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
|
label = actor.get_actor_label()
|
|
if label.startswith(prefixes):
|
|
unreal.EditorLevelLibrary.destroy_actor(actor)
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def build():
|
|
# --- level ------------------------------------------------------------
|
|
if unreal.EditorAssetLibrary.does_asset_exist(MAP):
|
|
if not switch_level(MAP):
|
|
log(f"could not load {MAP}")
|
|
return
|
|
else:
|
|
if not switch_level(MAP):
|
|
log(f"could not create {MAP}")
|
|
return
|
|
|
|
removed = clear(("Demo_", "PlayerStart", "Hero_Facade"))
|
|
log(f"cleared {removed} previous demo actors")
|
|
|
|
# --- lighting: fixed daytime ------------------------------------------
|
|
# The demo is a fixed daytime scene, not a time-of-day simulation. Three
|
|
# things matter and each was wrong before:
|
|
#
|
|
# 1. Sun angle. The hero facade front faces -Y and the road faces +Z. A sun
|
|
# at yaw 120 / pitch -50 shines toward (-X, +Y, down), so it lights both
|
|
# the facade front and the carriageway instead of raking across them.
|
|
# 2. Exposure. Auto-exposure made the same road washed-out in one shot and
|
|
# near-black in the next, because each camera framed a different amount
|
|
# of sky. A manual-exposure post-process volume pins it.
|
|
# 3. Ambience. A height fog gives the street depth and hides the terrain
|
|
# edge that otherwise reads as a black band at the horizon.
|
|
sun = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 2000), "Demo_Sun",
|
|
unreal.Rotator(pitch=-50.0, yaw=120.0, roll=0.0))
|
|
if sun:
|
|
comp = sun.get_component_by_class(unreal.DirectionalLightComponent)
|
|
if comp:
|
|
for prop, value in (
|
|
("intensity", 4.0),
|
|
("light_color", unreal.Color(r=255, g=245, b=230, a=255)),
|
|
("atmosphere_sun_light", True),
|
|
):
|
|
try:
|
|
comp.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sun.{prop} not set: {exc!r}")
|
|
# Movable: static lighting is disabled (r.AllowStaticLighting=False),
|
|
# so nothing needs a lighting build.
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sun mobility not set: {exc!r}")
|
|
log("staged: Demo_Sun (pitch -50, yaw 120, intensity 4, warm)")
|
|
|
|
sky = spawn(unreal.SkyLight, unreal.Vector(0, 0, 2000), "Demo_SkyLight")
|
|
if sky:
|
|
comp = sky.get_component_by_class(unreal.SkyLightComponent)
|
|
if comp:
|
|
for prop, value in (("intensity", 2.6), ("real_time_capture", True)):
|
|
try:
|
|
comp.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sky.{prop} not set: {exc!r}")
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sky mobility not set: {exc!r}")
|
|
log("staged: Demo_SkyLight (intensity 2.6, real-time capture)")
|
|
|
|
spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "Demo_Sky")
|
|
log("staged: Demo_Sky (sky atmosphere)")
|
|
|
|
fog = spawn(unreal.ExponentialHeightFog, unreal.Vector(0, 0, 0), "Demo_Fog")
|
|
if fog:
|
|
comp = fog.get_component_by_class(unreal.ExponentialHeightFogComponent)
|
|
if comp:
|
|
for prop, value in (
|
|
("fog_density", 0.008),
|
|
("fog_height_falloff", 0.15),
|
|
("start_distance", 3000.0),
|
|
):
|
|
try:
|
|
comp.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"fog.{prop} not set: {exc!r}")
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"fog mobility not set: {exc!r}")
|
|
log("staged: Demo_Fog (height fog, density 0.008)")
|
|
|
|
# Fixed exposure: without this the same road renders washed out or black
|
|
# depending on how much sky the camera frames. Pinning min == max brightness
|
|
# freezes the exposure at one value (the classic fixed-exposure trick);
|
|
# AEM_MANUAL with a bias was tried first and blew the frame out.
|
|
ppv = spawn(unreal.PostProcessVolume, unreal.Vector(0, 0, 0), "Demo_PostProcess")
|
|
if ppv:
|
|
ppv.set_editor_property("unbound", True)
|
|
settings = ppv.get_editor_property("settings")
|
|
for prop, value in (
|
|
("override_auto_exposure_method", True),
|
|
("auto_exposure_method", unreal.AutoExposureMethod.AEM_HISTOGRAM),
|
|
("override_auto_exposure_min_brightness", True),
|
|
("auto_exposure_min_brightness", 1.0),
|
|
("override_auto_exposure_max_brightness", True),
|
|
("auto_exposure_max_brightness", 1.0),
|
|
("override_auto_exposure_bias", True),
|
|
("auto_exposure_bias", 0.0),
|
|
):
|
|
try:
|
|
settings.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"pp.{prop} not set: {exc!r}")
|
|
ppv.set_editor_property("settings", settings)
|
|
log("staged: Demo_PostProcess (fixed exposure, min=max brightness 1.0)")
|
|
|
|
# --- hero facade on the measured building line ------------------------
|
|
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
|
|
if hero:
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(-10722.5, -1443.0, 0.0), "Hero_Facade")
|
|
if actor:
|
|
actor.static_mesh_component.set_static_mesh(hero)
|
|
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
log("staged: Hero_Facade at the building line")
|
|
else:
|
|
log("MISSING: SM_HeroFacade — run Tools/ue_hero_facade_import.py")
|
|
|
|
# --- site meshes, if they have been imported --------------------------
|
|
# Blender's flat diffuse colours do not survive FBX -> Unreal: Interchange
|
|
# 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.
|
|
# 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 in SURFACE_MATERIALS.items():
|
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
|
|
if mesh is None:
|
|
continue
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Demo_{name}")
|
|
if actor:
|
|
actor.static_mesh_component.set_static_mesh(mesh)
|
|
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
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
|
|
# sidewalk is at Y -1912..-1053 and the SOUTH sidewalk at Y +718..+1447. The
|
|
# facade stands at Y -1450. Stand on the north sidewalk and look north (-Y).
|
|
spawn(unreal.PlayerStart, unreal.Vector(-10722.5, -3500.0, 120.0), "Demo_PlayerStart",
|
|
unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0))
|
|
log("staged: Demo_PlayerStart on the north sidewalk facing south")
|
|
|
|
# A lockable LFS .umap checks out read-only, which makes save_map silently
|
|
# return False — chmod u+w first or the staged actors never persist.
|
|
umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap")
|
|
if os.path.exists(umap) and not os.access(umap, os.W_OK):
|
|
os.chmod(umap, 0o644)
|
|
log(f"map writable: chmod u+w {umap}")
|
|
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
|
unreal.EditorLevelLibrary.get_editor_world(), MAP)
|
|
log(f"save_map {MAP}: {saved}")
|
|
|
|
|
|
try:
|
|
build()
|
|
except Exception as exc: # noqa: BLE001
|
|
import traceback
|
|
log(f"FAILED: {type(exc).__name__}: {exc}")
|
|
log(traceback.format_exc())
|
|
|
|
with open(RESULT, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|