Files
UnrealPrototyping/Scripts/Authoring/create_region_world.py
T
2026-09-25 17:02:24 +03:00

320 lines
16 KiB
Python

"""Builds the world level - /Game/Maps/L_World, as Region.json's `level` says (D-72): a world-partitioned
level holding the grid of landscapes that
RawContent/World/Region.json describes, imported from the tiles in RawContent/World/RegionTiles/ and dressed
with Elite_RockyMeadows' kit through rocky_meadows.py. The level is a product of this script, the manifest and
the PNGs, never hand-edited; `--rebuild` throws away what is there and starts again.
Scripts/Authoring/build_region.sh # the whole thing, one batch of tiles at a time
-AllowCommandletRendering matters: the landscapes' render heightmaps come from the edit-layer merge on the GPU,
and a commandlet without it silently skips that, leaving landscapes with collision but no visible surface.
**Build it in batches.** A landscape of a hundred components costs about 1.5 GB of resident memory that is
never given back while the level is open, so a single process asked for all thirty-six reached 14.7 GB by the
ninth and would have run the machine out of commit long before the last. The script is therefore incremental:
it adds the tiles it is told to, saves, and exits, and a later run adds more to the same level. Six at a time
fits comfortably.
... -script=.../create_region_world.py -AllowCommandletRendering -abslog=<abs>/region.log -- --rebuild --tiles 0,0 1,0 2,0 3,0 4,0 5,0
... the same again with --tiles 0,1 1,1 ... and no --rebuild, once per row
`--rebuild` empties the level first and sweeps its stale actor packages afterwards; it belongs on the *first*
batch only. Without it the level is loaded and added to, tiles that are already in it are skipped, and the
sweep is not run — it deletes any actor package that does not belong to a currently loaded actor, which in an
append is every landscape the earlier batches built. Scripts/Authoring/build_region.sh runs the whole sequence.
One landscape per tile, rather than one landscape of 15301 vertices, because 234 M vertices is not an import
the engine will take in one piece. The tiles share their edge vertices and the tile files are bit-identical
along those edges (generate_region_tiles.py samples every vertex from its global position), so the landscapes
meet exactly; nothing here blends or stitches. Each tile is placed by its own centre, which is what the
landscape library takes as its Location.
"""
import os
import shutil
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import heightmap_io # noqa: E402
import rocky_meadows # noqa: E402
from region_manifest import load_manifest # noqa: E402
manifest = load_manifest()
LEVEL_PATH = manifest.level
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
asset_lib = unreal.EditorAssetLibrary
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
actor.set_actor_label(label)
return actor
def parse_args():
"""--tiles TX,TY ..., --rebuild and --level, read out of sys.argv because UE puts its own arguments there
too. `--level /Game/Maps/L_Something` builds the same tiles into a level of its own, which is how a
dressing or material change is looked at on four tiles rather than on all ninety-eight."""
global LEVEL_PATH
argv = sys.argv[1:]
rebuild = "--rebuild" in argv
if "--level" in argv:
LEVEL_PATH = argv[argv.index("--level") + 1]
chosen = list(manifest.tiles())
if "--tiles" in argv:
spec = []
for item in argv[argv.index("--tiles") + 1:]:
if item.startswith("-"):
break
spec.append(item)
chosen = []
for item in spec:
tx, ty = (int(part) for part in item.split(","))
if not (0 <= tx < manifest.tiles_x and 0 <= ty < manifest.tiles_y):
raise RuntimeError(f"tile {item} is outside the {manifest.tiles_x}x{manifest.tiles_y} grid")
chosen.append((tx, ty))
return chosen, rebuild
def ensure_tiles(chosen):
"""The chosen tiles' files present and the right size, or generate the missing ones. A tile whose height map
is the wrong resolution means the manifest changed since it was written."""
wanted = manifest.vertices_per_tile
missing = []
for tx, ty in chosen:
if any(not os.path.isfile(f) for f in manifest.tile_files(tx, ty)):
missing.append((tx, ty))
continue
width, height, depth, _ = heightmap_io.read_png_header(manifest.height_path(tx, ty))
if (width, height, depth) != (wanted, wanted, 16):
unreal.log(f"{manifest.tile_name(tx, ty)} is {width}x{height} at {depth} bit, the manifest wants "
f"{wanted} at 16: regenerating it")
missing.append((tx, ty))
if not missing:
return
unreal.log(f"generating {len(missing)} tiles")
import generate_region_tiles
generate_region_tiles.main(["--tiles"] + [f"{tx},{ty}" for tx, ty in missing])
def existing_labels():
"""Actor labels already in the level, including actors world partition has not loaded. The unloaded ones
are read out of the asset registry rather than the world: an append must not create a second landscape for
a tile an earlier batch already built, and in a fresh commandlet almost none of them is loaded."""
labels = {actor.get_actor_label() for actor in actor_subsystem.get_all_level_actors()}
folder = "/Game/__ExternalActors__/" + LEVEL_PATH.replace("/Game/", "", 1)
try:
packages = asset_lib.list_assets(folder, recursive=True, include_folder=False) or []
except Exception as error:
unreal.log_warning(f"could not list {folder} ({error}); only loaded actors will be seen, so an append "
f"may duplicate a landscape")
return labels
for path in packages:
try:
data = asset_lib.find_asset_data(path)
label = data.get_tag_value("ActorLabel") if data else None
except Exception:
label = None
if label:
labels.add(str(label))
return labels
def content_path():
return os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
def external_actor_dir():
return os.path.join(content_path(), "__ExternalActors__", LEVEL_PATH.replace("/Game/", "", 1))
def level_file():
return os.path.join(content_path(), LEVEL_PATH.replace("/Game/", "", 1) + ".umap")
def require_level_writable():
"""Refuse to start if another process holds the .umap, because the save is the *last* thing this script
does and `--rebuild` is the first.
An editor with the level open keeps a Windows write lock on it. The save then fails with `MoveFile ...
(Error Code 32)`, the run raises, and the level has already been emptied: on 2026-09-20 a full rebuild
left twelve of ninety-eight tiles in the level and everything else gone, which reads exactly like a
corrupted world. Checked here, before anything is destroyed, an open editor costs a message instead.
`r+b` is the probe rather than a test save: it asks the operating system the same question a save asks,
without writing a byte. A level that does not exist yet is not locked, and a first build is what creates
it. Close the editor, or load another level in it, and run again."""
path = level_file()
if not os.path.exists(path):
return
try:
open(path, "r+b").close()
except OSError as error:
raise RuntimeError(
f"{path} is locked by another process ({error.__class__.__name__}): an editor with "
f"{LEVEL_PATH} open holds it, and the save at the end of this run would fail after the level "
f"had already been emptied. Close the editor or load another level in it, then run again."
) from error
def prepare_level(rebuild):
"""The level to build into: emptied when rebuilding, loaded and kept when appending.
An existing level is loaded and emptied rather than deleted and recreated:
recreating it resaves the HLOD layer assets, and an open editor that has had the level loaded keeps those
files locked, so the recreation ends in a nameless temp world whose save goes nowhere (see create_world.py,
2026-09-16). Streaming proxies the editor does not load cannot be destroyed here; sweep_stale_actor_packages
removes their files after the save."""
if asset_lib.does_asset_exist(LEVEL_PATH):
if not level_subsystem.load_level(LEVEL_PATH):
raise RuntimeError(f"could not load {LEVEL_PATH}")
if rebuild:
removed = 0
for actor in actor_subsystem.get_all_level_actors():
if actor.get_class().get_name() in KEEP_CLASSES:
continue
actor_subsystem.destroy_actor(actor)
removed += 1
unreal.log(f"{LEVEL_PATH} loaded and emptied: {removed} loaded actors removed")
else:
unreal.log(f"{LEVEL_PATH} loaded to be added to")
else:
folder = external_actor_dir()
if os.path.isdir(folder):
shutil.rmtree(folder, ignore_errors=True)
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 936 km2 must stream
world_path = editor_subsystem.get_editor_world().get_path_name()
if not world_path.startswith(LEVEL_PATH):
raise RuntimeError(f"the editor world is {world_path}, not {LEVEL_PATH}; a save would go nowhere")
def sweep_stale_actor_packages():
"""After the save: every file under the level's external actor folder that belongs to none of the level's
actors is a leftover of an earlier build (a proxy the editor never loaded, so it could not be destroyed).
Left there, it would come back as a second landscape the next time the level loads."""
keep = set()
for actor in actor_subsystem.get_all_level_actors():
package = actor.get_outermost().get_path_name()
keep.add(os.path.normcase(os.path.join(content_path(), package.replace("/Game/", "", 1).replace("/", os.sep) + ".uasset")))
removed, failed = 0, 0
for root, _, files in os.walk(external_actor_dir()):
for name in files:
path = os.path.join(root, name)
if os.path.normcase(path) in keep:
continue
try:
os.remove(path)
removed += 1
except OSError as error:
failed += 1
unreal.log_warning(f"stale actor package {path} could not be removed: {error}")
unreal.log(f"stale actor packages: {removed} removed, {failed} left, {len(keep)} kept")
if failed:
raise RuntimeError(f"{failed} stale actor packages remain under {external_actor_dir()}; the level would load duplicates")
def create_landscapes(chosen, already):
"""One landscape per tile, each centred on its own square of the window. A tile whose landscape is already
in the level is skipped, so a batch can be rerun."""
material = rocky_meadows.load_or_raise(rocky_meadows.LANDSCAPE_MATERIAL)
world = editor_subsystem.get_editor_world()
unreal.log(f"landscapes: {manifest.describe()}")
todo = [(tx, ty) for tx, ty in chosen if manifest.tile_name(tx, ty) not in already]
skipped = len(chosen) - len(todo)
if skipped:
unreal.log(f"{skipped} of {len(chosen)} tiles are already in the level and are left alone")
built = []
for index, (tx, ty) in enumerate(todo, 1):
weightmaps = rocky_meadows.weightmap_entries(
lambda name, tx=tx, ty=ty: manifest.weight_path(tx, ty, name),
[layer.name for layer in manifest.enabled_layers])
cx, cy = manifest.tile_centre_cm(tx, ty)
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
world, manifest.height_path(tx, ty), weightmaps, material,
unreal.Vector(cx, cy, manifest.landscape_z_cm),
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
manifest.streaming_grid_components)
if not landscape:
raise RuntimeError(f"landscape {manifest.tile_name(tx, ty)} failed; see LogSaltyEditor")
landscape.set_actor_label(manifest.tile_name(tx, ty))
built.append(landscape)
unreal.log(f" [{index:2d}/{len(todo)}] {manifest.tile_name(tx, ty)} at "
f"({cx / 100000:+.2f}, {cy / 100000:+.2f}) km")
return built
def pad_height_cm():
"""World Z of the spawn pad, read out of the heightmap rather than traced for it.
A trace is the wrong instrument here and it put the starts 180 m underground. The landscape's collision is
not reliably present in a commandlet, the sea plane at Z 0 *is* - it keeps collision so a walk off the coast
is a walk - so the trace hit the sea and returned 0.0, which is not None and so sailed past the guard meant
to catch exactly this. The heightmap cannot be wrong about its own pad. Which tile the centre falls in, and
where inside it, is worked out rather than assumed: on an odd grid like 14 x 7 the centre is halfway down a
tile rather than on a corner, and a tile's PNG has world X across its columns and world Y down its rows."""
tx, ty, i, j = manifest.centre_vertex()
path = manifest.height_path(tx, ty)
value = int(heightmap_io.read_png(path)[j, i])
metres = manifest.elevation_min_m + value / 65535.0 * manifest.elevation_span_m
unreal.log(f"spawn pad at {metres:.2f} m, from {os.path.basename(path)} at [row {j}, col {i}]")
return metres * 100.0
def ensure_player_starts(already):
"""Two starts on the pad at the centre of the window, so two PIE clients spawn without a warning. Existing
ones are moved rather than left alone, so a rerun repairs a level whose starts are in the wrong place."""
del already # the starts are found by class below; a label in `already` may belong to an unloaded actor
z = pad_height_cm() + 120.0
existing = {actor.get_actor_label(): actor for actor in actor_subsystem.get_all_level_actors()
if actor.get_class().get_name() == "PlayerStart"}
for index, y in enumerate((-200.0, 200.0)):
label = f"World_PlayerStart_{index}"
location = unreal.Vector(0.0, y, z)
actor = existing.get(label)
if actor is None:
spawn(unreal.PlayerStart, label, location)
unreal.log(f" {label} placed at {z / 100:.2f} m")
elif abs(actor.get_actor_location().z - z) > 1.0:
was = actor.get_actor_location().z / 100.0
actor.set_actor_location(location, False, True)
unreal.log(f" {label} moved from {was:.2f} m to {z / 100:.2f} m")
def ensure_dressing(already):
"""The sky kit and the sea, once. Every actor the dressing spawns carries a label, so an append recognises
what is already there by label and does not spawn a second sun."""
if "World_Sun" not in already:
rocky_meadows.dress(spawn, manifest)
if "World_Sea_Proto" not in already:
rocky_meadows.ensure_sea(spawn, manifest)
def main():
chosen, rebuild = parse_args()
unreal.log(f"{len(chosen)} tile(s) requested{', rebuilding the level' if rebuild else ', appending'}")
require_level_writable() # before prepare_level, which is what empties it
ensure_tiles(chosen)
prepare_level(rebuild)
already = set() if rebuild else existing_labels()
landscapes = create_landscapes(chosen, already)
ensure_dressing(already)
ensure_player_starts(already)
if not level_subsystem.save_current_level():
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
if rebuild:
sweep_stale_actor_packages() # an append would sweep away every landscape it did not load
unreal.log(f"{LEVEL_PATH} saved: {len(landscapes)} landscape(s) added this run, "
f"{manifest.tile_side_m / 1000:.2f} km each")
main()