Files
gig-line/Tools/ue_build_demo_map.py
T
zwitschi 52b1212cf3 qa: make the demo walkable — terrain collision root cause, spawn, scripted QA
The reported fall-through was a misdiagnosis: the player never fell. The
imported Terrain_DEM had only a bounding-box simple collision proxy, so a
downward trace anywhere in the block hit the terrain at Z 819 (the mesh
bbox max) and the street sat under an invisible 8 m lid. Standing on that
box looked plausible, which is why it read as working.

- add UGigLineAssetTools (C++): force CTF_UseComplexAsSimple on a static
  mesh. UE 5.8 dropped bUseComplexAsSimpleCollision and Python cannot set
  the flag; the builder also saves the asset, since the flag lives there
  and a map-only save silently reverts it.
- spawn (-13336, -1150, 600): inside the carved corridor (the cut is a
  10.7 m half-width around the road centreline), lands on the road at 456.
- fix ResetToStart returning to the mid-air PlayerStart captured in
  BeginPlay: StartLocation is now captured on the first grounded frame.
- HighResShot 1280x720 instead of "1280 720", which is a multiplier.
- add -qaselftest: scripted walk/reset/pause on a core ticker plus a
  swept ground transect. Results: walk 746 cm/2 s with Z steady, reset
  0 cm from spawn, pause/unpause 1/0, transect 84 samples 0 holes.
- rebase onto main, keeping the frontage materials and placement fixes.
2026-11-08 11:56:19 +01:00

420 lines
20 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
# The imported terrain carries a bounding-box simple collision proxy,
# which puts an invisible lid ~8 m over the whole block: the player
# walks on that box instead of the street. Force trace against the real
# triangles so the carved street corridor is actually open.
if "Terrain" in name:
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True)
# The flag lives on the asset, not the map: persist it or the
# next session reloads the bbox proxy and the street is buried.
saved_asset = unreal.EditorAssetLibrary.save_loaded_asset(asset)
log(f"terrain collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} "
f"(saved asset: {saved_asset})")
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.
# CRITICAL: the terrain is only carved inside the street corridor, and
# terrain_from_dem.py --corridor 10.7 is a HALF-width around the road
# centreline (Y ~= -167), so the cut band is Y -1237..+902. A PlayerStart at
# Y -1700 sits OUTSIDE it, under ~8 m of uncut terrain: the capsule spawns
# embedded, depenetration pops it to Z ~909 (terrain top 819 + half-height
# 87.5) and it never touches the sidewalk. Y -1150 is on the north sidewalk
# (Y -1912..-1053) AND inside the cut, so the pavement is the ground.
# Z 600 is clear air; the capsule drops onto the pavement and settles.
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1150.0, 600.0), "Demo_PlayerStart",
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
log("staged: Demo_PlayerStart on the cut north sidewalk, 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")