- ue_import_site.py: auto_generate_collision=True so FBX static meshes get real collision geometry (they imported with none -> complex-as-simple had nothing to use, player fell through). - ue_build_demo_map.py: PlayerStart cap raised to Z300 so the capsule feet start above the ~Z99-138 terrain surface there (an embedded spawn point is rejected -> 'NO PLAYERSTART with positive rating' -> origin fallback). - Re-imported site meshes + rebuilt DemoMap with the above. KNOWN-UNRESOLVED: packaged plain launch still spawns the capsule around Z-123 and falls; downward trace finds no geometry. Map assets are all present in the pak and referenced; runtime collision behavior needs a visual/interactive check (headless commandlet traces are unreliable). Investigate on this branch; do not merge to main.
404 lines
19 KiB
Python
404 lines
19 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", "Frontage_", "ShotSequencer"))
|
|
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=-62.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 -62, yaw 120, intensity 4, warm; higher sun so both frontages read)")
|
|
|
|
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 placement, corrected 2026-11-07 when the neighbouring frontage was
|
|
# added: UE south is +Y (the south sidewalk is Y +718..+1447) and the block
|
|
# frontage sits at X -24276..-12082 with a gap at -14591..-12082 for 6840.
|
|
# The old (-10722.5, -1443) put the facade 26 m east of its footprint and on
|
|
# the NORTH side of the street, which only looked right while it stood alone.
|
|
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
|
|
if hero:
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(-13336.0, 1307.0, 0.0), "Hero_Facade")
|
|
if actor:
|
|
actor.static_mesh_component.set_static_mesh(hero)
|
|
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
|
|
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:
|
|
comp = actor.static_mesh_component
|
|
comp.set_static_mesh(mesh)
|
|
# Immutable level geometry must be STATIC to reliably expose its
|
|
# complex-as-simple collision body to CharacterMovement; staging
|
|
# these MOVABLE let the player fall through the world.
|
|
comp.set_mobility(unreal.ComponentMobility.STATIC)
|
|
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
|
|
comp.set_collision_object_type(unreal.CollisionChannel.ECC_WORLD_STATIC)
|
|
comp.set_collision_profile_name("BlockAll")
|
|
mat = built.get(mat_name)
|
|
if mat:
|
|
comp.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")
|
|
|
|
# --- surrounding block frontage ---------------------------------------
|
|
# The other five south-side buildings verified for the block
|
|
# (Geo/verification/2026-11-06-el-capitan-block.md), exported in place from
|
|
# the OSM blockout with a Y mirror so they sit on the south side with the
|
|
# hero. The 6840 site itself is the hand-modelled SM_HeroFacade above.
|
|
FRONTAGE = (
|
|
("SM_Hollywood_6922", "6922"),
|
|
("SM_Hollywood_6902", "6902"),
|
|
("SM_ElCapitanTheatre", "6838 El Capitan Theatre"),
|
|
("SM_Budget_6822", "6822 Budget"),
|
|
("SM_Hollywood_6806", "6806-6820"),
|
|
("SM_LeeDrug_6800", "6800 Lee Drug"),
|
|
)
|
|
staged_frontage = 0
|
|
for asset_name, label in FRONTAGE:
|
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}")
|
|
if mesh is None:
|
|
log(f"MISSING frontage {asset_name} — run Tools/ue_import_block_frontage.py")
|
|
continue
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Frontage_{asset_name}")
|
|
if actor:
|
|
comp = actor.static_mesh_component
|
|
comp.set_static_mesh(mesh)
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
# The two theatre buildings read as stone; the retail run as stucco.
|
|
mat_name = "M_FacadeStone" if "Theatre" in asset_name else "M_Stucco"
|
|
if mat_name in built:
|
|
comp.set_material(0, built[mat_name])
|
|
staged_frontage += 1
|
|
log(f"staged {staged_frontage} frontage buildings")
|
|
|
|
# --- 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 hero facade is on the SOUTH side at Y +1307. Stand on the north
|
|
# sidewalk and face south (+Y, yaw 90) across the road at it.
|
|
# Z: the terrain here is ~Z 99-138. The capsule half-height is 87.5 cm, so
|
|
# feet must start ABOVE the surface or the point is embedded and UE rejects
|
|
# the PlayerStart ("NO PLAYERSTART with positive rating"), dropping the pawn
|
|
# at the world origin to fall through. Cap feet ~80 cm above grade: centre
|
|
# ~138+80+87 = 305, so Z 300 lets it drop cleanly and gravity settles it.
|
|
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1700.0, 300.0), "Demo_PlayerStart",
|
|
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
|
|
log("staged: Demo_PlayerStart on the north sidewalk (cap above grade), facing south (+Y)")
|
|
|
|
# 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")
|