Root cause of the empty review frames: EditorLevelLibrary.load_level/new_level are deprecated no-ops in -nullrhi commandlets, so staged actors landed in the current world and got saved over the wrong map. Switched both map builders to the LevelEditorSubsystem and reordered the review builder to save ReviewMap before the DemoMap section (the switch-back to a map that only exists in memory silently failed, writing demo content over ReviewMap). Rebuilt both maps, re-rendered every screenshot; user visual check passed. Screenshots/README.md cube naming corrected (A grounded, B floating), approval states updated; TODO.md visual-review notes closed. MEMORY.md records the deprecated-call and save-order traps.
287 lines
15 KiB
Python
287 lines
15 KiB
Python
# Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a
|
|
# map asset. Run before the screenshot pass (Tools/render_screenshots.sh does
|
|
# this automatically).
|
|
#
|
|
# Scene: metric floor, key light, fill sky, sky atmosphere, MPFB character,
|
|
# the three 1 m scale cubes, and an AGigLineShotActor with the camera list.
|
|
#
|
|
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/ue_build_review_map.py \
|
|
# -nullrhi -unattended -nopause -stdout
|
|
#
|
|
# Do NOT load_map here: after a map load the commandlet's spawn context dies
|
|
# (every spawn_actor call returns None). Everything is spawned into the
|
|
# default world and saved as a new map asset.
|
|
|
|
import os
|
|
|
|
import unreal
|
|
|
|
RESULT = "/tmp/ue-review-map-result.txt"
|
|
MAP_PATH = "/Game/Test/ReviewMap"
|
|
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))
|
|
|
|
|
|
def ensure_writable(path):
|
|
# A packed .umap left read-only (git sets 644->444 on checkout, or the
|
|
# editor re-flags it) makes save_map silently return False with no error.
|
|
# chmod u+w it so the staged camera list and actors actually persist.
|
|
p = os.path.join(unreal.Paths.project_dir(), path.replace("/Game/", "Content/") + ".umap")
|
|
try:
|
|
mode = os.stat(p).st_mode & 0o777
|
|
if mode & 0o200 == 0:
|
|
os.chmod(p, 0o644)
|
|
log(f"map writable: chmod u+w {p} (was {oct(mode)})")
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def spawn(cls, loc, label=None, scale=None, rot=None):
|
|
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
|
|
if a is None:
|
|
log(f"spawn {cls.__name__} returned None")
|
|
return None
|
|
if rot is not None:
|
|
a.set_actor_rotation(rot, False)
|
|
if scale is not None:
|
|
a.set_actor_scale3d(scale)
|
|
if label:
|
|
a.set_actor_label(label)
|
|
return a
|
|
|
|
|
|
# --- scene -------------------------------------------------------------------
|
|
|
|
try:
|
|
# Neutral gray floor material (the scale-test mesh ships red)
|
|
floor_mat = None
|
|
if not unreal.EditorAssetLibrary.does_asset_exist("/Game/Test/M_ReviewFloor"):
|
|
floor_mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
|
"M_ReviewFloor", "/Game/Test", unreal.Material, unreal.MaterialFactoryNew())
|
|
n = unreal.MaterialEditingLibrary.create_material_expression(
|
|
floor_mat, unreal.MaterialExpressionConstant3Vector)
|
|
n.set_editor_property("constant", unreal.LinearColor(0.35, 0.35, 0.35, 1.0))
|
|
unreal.MaterialEditingLibrary.connect_material_property(
|
|
n, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
|
unreal.MaterialEditingLibrary.recompile_material(floor_mat)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(floor_mat)
|
|
else:
|
|
floor_mat = unreal.EditorAssetLibrary.load_asset("/Game/Test/M_ReviewFloor")
|
|
|
|
floor_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/ScaleTest/ScaleTest_A")
|
|
if floor_mesh and floor_mat:
|
|
# 1 m reference cube scaled to 90 m x 90 m x 0.1 m, top face at Z 0. The
|
|
# scale is applied on the actor, so Z is -5 cm (+/- 5 cm around centre).
|
|
# Covers the review scene AND the hero blockout 100 m away, so facade shots
|
|
# no longer end at the black horizon band (160 m square, top face at Z 0).
|
|
floor = spawn(unreal.StaticMeshActor, unreal.Vector(-5000, 450, -5.0), "ReviewFloor",
|
|
scale=(160.0, 160.0, 0.1))
|
|
if floor:
|
|
floor.static_mesh_component.set_static_mesh(floor_mesh)
|
|
floor.static_mesh_component.set_material(0, floor_mat)
|
|
log("staged: floor (160 m square, gray, top face Z 0)")
|
|
else:
|
|
log("floor spawn failed")
|
|
|
|
if spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 900), "RenderKeyLight",
|
|
rot=unreal.Rotator(pitch=-38.0, yaw=35.0, roll=0.0)):
|
|
log("staged: DirectionalLight")
|
|
if spawn(unreal.SkyLight, unreal.Vector(0, 0, 1200), "RenderFillSky"):
|
|
log("staged: SkyLight")
|
|
if spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "RenderSky"):
|
|
log("staged: SkyAtmosphere")
|
|
|
|
char_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/Character/character")
|
|
if char_mesh:
|
|
ch = spawn(unreal.SkeletalMeshActor, unreal.Vector(0, 0, 0), "ReviewCharacter",
|
|
rot=unreal.Rotator(pitch=0.0, yaw=180.0, roll=0.0))
|
|
if ch:
|
|
comp = ch.skeletal_mesh_component
|
|
comp.set_skinned_asset_and_update(char_mesh)
|
|
# Root at the soles, not the world origin: the composes put feet on the
|
|
# floor's top face (Z = 0). If the skeletal root is not at the soles the
|
|
# character would float or sink, so report the bounds for the review.
|
|
if hasattr(ch, "set_actor_location"):
|
|
ch.set_actor_location(unreal.Vector(0, 0, 0), False, False)
|
|
mn, mx = (None, None)
|
|
try:
|
|
_origin, _box = ch.get_actor_bounds(False)
|
|
log(f"staged: MPFB character at origin, bounds z {_box.z:.1f} cm")
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"character bounds unavailable: {exc!r}")
|
|
log("staged: MPFB character at origin, facing -Y")
|
|
else:
|
|
log("MISSING: /Game/Test/Character/character")
|
|
|
|
# Two cubes only, on purpose: one resting on the floor gives the ground-contact
|
|
# and scale reference, one held at 2 m gives a clean cast shadow to compare
|
|
# against. Their positions are chosen so both are in the overview frame and
|
|
# neither overlaps the character.
|
|
cube_specs = (
|
|
("ScaleTest_A", -250.0, 600.0, 50.0, "on the ground: base at Z 0"),
|
|
("ScaleTest_B", 250.0, 600.0, 250.0, "floating at 2 m: casts a separation shadow"),
|
|
)
|
|
for name, x, y, z, why in cube_specs:
|
|
m = unreal.EditorAssetLibrary.load_asset(f"/Game/Test/ScaleTest/{name}")
|
|
if m:
|
|
c = spawn(unreal.StaticMeshActor, unreal.Vector(x, y, z), f"Cube_{name}")
|
|
if c:
|
|
c.static_mesh_component.set_static_mesh(m)
|
|
log(f"staged: {name} at ({x}, {y}, {z}) - {why}")
|
|
|
|
# Hero facade, if the import has run: two views a reviewer needs — straight on
|
|
# from the north sidewalk, and the oblique street view from further west.
|
|
# Hero placement in Unreal centimetres (see Geo/manifests/local-origin.md):
|
|
# front plane on the building line, body behind it.
|
|
hero_x = -12024.5 + 1302.0
|
|
hero_y = -1450.0 + 7.0
|
|
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
|
|
# The imported mesh spans Y -7..+1250 cm in local space (front plane at -7, the
|
|
# body behind it) and is centred on X, so the actor sits at building line + 7 cm.
|
|
if hero:
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(hero_x, hero_y, 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 measured building line")
|
|
else:
|
|
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/ue_hero_facade_import.py)")
|
|
|
|
# --- player pawn ---------------------------------------------------------
|
|
# The review rounds must show the pawn the game actually spawns, not only the
|
|
# mannequin. Spawn the real character class so its capsule, camera height and
|
|
# ground contact are what gets rendered.
|
|
pawn = None
|
|
char_class = unreal.load_class(None, "/Script/GigLine.GigLineCharacter")
|
|
if char_class:
|
|
pawn = spawn(char_class, unreal.Vector(0, 0, 90), "PlayerPawn")
|
|
if pawn:
|
|
log("staged: PlayerPawn (AGigLineCharacter) at the spawn point")
|
|
else:
|
|
log("MISSING: /Script/GigLine.GigLineCharacter")
|
|
|
|
# --- NPC prefab -----------------------------------------------------------
|
|
# The reusable NPC person: a collision capsule + visible MPFB body, standing
|
|
# feet on Z 0, placed clear of the pawn so the prefab reads on its own.
|
|
npc = None
|
|
npc_class = unreal.load_class(None, "/Script/GigLine.GigLineNPCPerson")
|
|
if npc_class:
|
|
npc = spawn(npc_class, unreal.Vector(-4500, 300, 0), "NPC_Person")
|
|
if npc:
|
|
log("staged: NPC_Person (AGigLineNPCPerson) at Z 0")
|
|
else:
|
|
log("MISSING: /Script/GigLine.GigLineNPCPerson")
|
|
|
|
# --- camera list ---------------------------------------------------------
|
|
# Every camera is a (name, location, target) triple; the look-at rotation is
|
|
# computed from the pair so a camera cannot drift out of aim when one number
|
|
# changes. Keep retired shots out of this list entirely.
|
|
cams = [
|
|
# wide scene: character, both cubes, floor
|
|
("overview", unreal.Vector(700, -900, 620), unreal.Vector(0, 300, 80)),
|
|
# the MPFB mannequin, mid shot
|
|
("character", unreal.Vector(200, -300, 160), unreal.Vector(0, 0, 85)),
|
|
# both cubes from the south: contact shadow vs cast shadow
|
|
("scale-cubes", unreal.Vector(0, -900, 200), unreal.Vector(0, 600, 80)),
|
|
# the pawn the game spawns, with the grounded cube beside it
|
|
("player-placement", unreal.Vector(320, -520, 140), unreal.Vector(0, 0, 80)),
|
|
# the NPC prefab: capsule + visible body, feet on Z 0, at double scale
|
|
# distance because it stands off to the west of the main staging
|
|
("npc-prefab", unreal.Vector(-4200, 600, 160), unreal.Vector(-4500, 300, 90)),
|
|
# low, close on feet and cube base: ground contact must be obvious
|
|
("ground-contact", unreal.Vector(180, -260, 35), unreal.Vector(0, 40, 20)),
|
|
# shadow comparison, low angle so both shadows read
|
|
("shadow-compare", unreal.Vector(-650, -700, 120), unreal.Vector(0, 600, 60)),
|
|
# the three entry portals at street level
|
|
# street level, aimed at the base of the centre portal: the portals sit at
|
|
# Z 0..340 cm behind a 145 cm plinth, so the camera looks slightly down and
|
|
# targets the portal sill rather than the centre of the elevation.
|
|
# Standing 25 m out at eye height frames the whole elevation, which is what
|
|
# the portal detail needs: the portals are small against a 14.5 m façade.
|
|
# On the pavement in front of the flight: a pedestrian's own eye line, the
|
|
# angle the photographs that established the portals were taken from.
|
|
# eye height at 8 m: fills the frame with the three doorways, the flights and
|
|
# the plinth, which is what "portal detail" means here.
|
|
("hero-portals", unreal.Vector(hero_x + 100, -2250, 170), unreal.Vector(hero_x + 100, hero_y, 230)),
|
|
# straight on from the north sidewalk; the building body spans Y -1443..+250
|
|
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 420), unreal.Vector(hero_x, hero_y + 400, 760)),
|
|
# oblique from the north-west, reading the colonnade in depth
|
|
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
|
|
]
|
|
# --- review shot cameras + save -------------------------------------------
|
|
# Order matters: the review scene is staged in the default (unsaved) world.
|
|
# Save it as ReviewMap BEFORE touching DemoMap — switching back to a map
|
|
# that only exists in memory fails silently, and the final save would then
|
|
# write the demo world over ReviewMap (this exact corruption happened once).
|
|
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
|
|
if shots:
|
|
shots.set_editor_property("shot_locations", [c[1] for c in cams])
|
|
shots.set_editor_property(
|
|
"shot_rotations",
|
|
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
|
|
shots.set_editor_property("shot_names", [c[0] for c in cams])
|
|
shots.set_editor_property("quit_when_done", True)
|
|
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
|
|
|
|
ensure_writable(MAP_PATH)
|
|
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
|
unreal.EditorLevelLibrary.get_editor_world(), MAP_PATH)
|
|
log(f"save_map {MAP_PATH}: {saved}")
|
|
|
|
# --- demo map views: what the player actually sees ----------------------
|
|
demo = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/DemoMap")
|
|
if demo and unreal.EditorAssetLibrary.does_asset_exist("/Game/Hollywood/DemoMap"):
|
|
# The demo map has terrain, street, sidewalks and the facade; the review
|
|
# floor does not. Load it, stage the shot cameras, save. No switch back:
|
|
# the review map is already saved, and the run ends here.
|
|
switch_level("/Game/Hollywood/DemoMap")
|
|
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
|
if actor.get_actor_label() == "ShotSequencer":
|
|
unreal.EditorLevelLibrary.destroy_actor(actor)
|
|
# Unreal signs, measured from the imported site meshes: north sidewalk at
|
|
# Y -1912..-1053, road -1382..+1047, south sidewalk +718..+1447, facade at
|
|
# Y -1450. So "opposite the facade" is Y -1650 and the view faces -Y.
|
|
demo_shots = [
|
|
# 14 m back so the 14.5 m elevation fits: at 2 m the camera sees only
|
|
# the colonnade's underside.
|
|
("demo-spawn-view", unreal.Vector(-10722.5, -3500, 165), unreal.Vector(-10722.5, -1450, 700)),
|
|
("demo-street-level", unreal.Vector(-11000, -400, 165), unreal.Vector(-10722.5, -1450, 500)),
|
|
("demo-crossing", unreal.Vector(-23750, -1800, 480), unreal.Vector(-24291, -1400, 300)),
|
|
# the two north-side background buildings (TCL, Tussaud's) seen from
|
|
# the south sidewalk, across Hollywood Blvd: their frontage and mass.
|
|
("demo-north-buildings", unreal.Vector(-22800, -1000, 400), unreal.Vector(-22800, 3000, 300)),
|
|
]
|
|
seq = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
|
|
if seq:
|
|
seq.set_editor_property("shot_locations", [c[1] for c in demo_shots])
|
|
seq.set_editor_property("shot_rotations",
|
|
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots])
|
|
seq.set_editor_property("shot_names", [c[0] for c in demo_shots])
|
|
# quit after the demo shots so a dedicated DemoMap capture run
|
|
# terminates cleanly (render_screenshots.sh runs both maps).
|
|
seq.set_editor_property("quit_when_done", True)
|
|
ensure_writable("/Game/Hollywood/DemoMap")
|
|
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
|
unreal.EditorLevelLibrary.get_editor_world(), "/Game/Hollywood/DemoMap")
|
|
log(f"demo map staged: {len(demo_shots)} cameras, save {saved}")
|
|
else:
|
|
log("MISSING: /Game/Hollywood/DemoMap (run Tools/ue_build_demo_map.py)")
|
|
except Exception as exc: # noqa
|
|
log(f"staging failed: {exc!r}")
|
|
|
|
with open(RESULT, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|