Files
UnrealPrototyping/Scripts/Authoring/create_world.py
T
Rainer LeitandClaude Fable 5.1 d64748f76f Fix the sun in L_World and L_Gym: positional unreal.Rotator is (roll, pitch, yaw)
Both scripts passed (pitch, yaw, roll) and pitched the sun upward, so every scene rendered as night and the
viewport's auto exposure sat at EV -8.5, which is what the 'cached lighting is going to be clipped' warning
reports. Keyword arguments now; the gym script re-applies the rotation on an existing sun; the world script
removes the old external actor folder on disk rather than through the asset library, which cannot load
streaming proxies on their own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 22:03:42 +03:00

144 lines
6.7 KiB
Python

"""Builds /Game/Maps/L_World: a world-partitioned level with a ~200 km2 landscape from the heightmap PNGs in
RawContent/World/Heightmaps/, the Elite_RockyMeadows landscape material, daylight and a player start. Rebuilds
from scratch every run; the level is a product of this script and the PNGs, never hand-edited.
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py
Swapping the terrain later: replace L_World_Height.png (16-bit greyscale, any resolution the landscape
supports) and the three L_World_<layer>.png weightmaps, or rerun generate_heightmap.py with another seed,
then rerun this. If the PNGs are missing this script generates them first.
Size: 4033 vertices a side at 350 cm a quad is 14.11 km a side, 199 km2. Z scale 500 spans -1280 m to
+1280 m, so 65535 in the PNG is 1280 m above the landscape origin and sea level (0.18 of the range) is -820 m.
"""
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"))
LEVEL_PATH = "/Game/Maps/L_World"
HEIGHTMAP_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "RawContent", "World", "Heightmaps"))
HEIGHTMAP = os.path.join(HEIGHTMAP_DIR, "L_World_Height.png")
PACK = "/Game/Elite_RockyMeadows"
LANDSCAPE_MATERIAL = f"{PACK}/Materials/M_Landscape_Main_Inst_RockyMeadows02"
LAYER_INFOS = { # layer info asset -> weightmap file
f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo": "L_World_Base_Layer.png",
f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo": "L_World_Layer_02.png",
f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo": "L_World_Layer_03.png",
}
QUAD_SCALE_CM = 350.0
Z_SCALE = 500.0
GRID_SIZE_COMPONENTS = 4 # streaming proxies of 4x4 components (4 x 126 quads x 3.5 m = 1.76 km a side)
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
def ensure_heightmaps():
missing = [f for f in [HEIGHTMAP] + [os.path.join(HEIGHTMAP_DIR, f) for f in LAYER_INFOS.values()] if not os.path.isfile(f)]
if not missing:
return
unreal.log(f"generating heightmaps, missing {missing}")
import generate_heightmap
sys.argv = ["generate_heightmap.py"]
generate_heightmap.main()
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 recreate_level():
if asset_lib.does_asset_exist(LEVEL_PATH):
unreal.log(f"{LEVEL_PATH} exists; deleting it and its external actors")
level_subsystem.new_level("/Temp/Untitled_Scratch", False) # do not delete the level we stand in
asset_lib.delete_asset(LEVEL_PATH)
# The external actor packages of streaming proxies cannot be loaded on their own, so the asset library
# refuses to delete the folder; remove what is left on disk instead.
content_dir = unreal.Paths.project_content_dir()
for folder in ("__ExternalActors__/Maps/L_World", "__ExternalObjects__/Maps/L_World"):
path = os.path.join(content_dir, folder)
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
def create_landscape():
material = unreal.load_asset(LANDSCAPE_MATERIAL)
if not material:
raise RuntimeError(f"landscape material {LANDSCAPE_MATERIAL} not found; is the pack in Content/?")
weightmaps = []
for asset_path, file_name in LAYER_INFOS.items():
layer_info = unreal.load_asset(asset_path)
if not layer_info:
raise RuntimeError(f"layer info {asset_path} not found")
entry = unreal.LandscapeAuthoringWeightmap()
entry.set_editor_property("layer_info", layer_info)
entry.set_editor_property("file", os.path.join(HEIGHTMAP_DIR, file_name))
weightmaps.append(entry)
world = editor_subsystem.get_editor_world()
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
world, HEIGHTMAP, weightmaps, material,
unreal.Vector(0, 0, 0), unreal.Vector(QUAD_SCALE_CM, QUAD_SCALE_CM, Z_SCALE), GRID_SIZE_COMPONENTS)
if not landscape:
raise RuntimeError("landscape creation failed; see LogSaltyEditor")
return landscape
def ground_height_at(x, y, fallback):
world = editor_subsystem.get_editor_world()
start, end = unreal.Vector(x, y, 200000.0), unreal.Vector(x, y, -200000.0)
hit = unreal.SystemLibrary.line_trace_single(world, start, end, unreal.TraceTypeQuery.ECC_VISIBILITY,
False, [], unreal.DrawDebugTrace.NONE, True)
if hit and hit.to_tuple()[0]:
return hit.to_tuple()[4].z
unreal.log_warning("no landscape hit under the spawn; using the fallback height")
return fallback
def ensure_daylight():
# Keyword arguments on purpose: positional unreal.Rotator is (roll, pitch, yaw), and a sun pitched upward is night.
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), unreal.Rotator(roll=0.0, pitch=-38.0, yaw=25.0))
sun.light_component.set_editor_property("intensity", 10.0)
sun.light_component.set_editor_property("atmosphere_sun_light", True)
sun.light_component.set_editor_property("dynamic_shadow_distance_movable_light", 60000.0)
spawn(unreal.SkyAtmosphere, "World_Sky")
sky_light = spawn(unreal.SkyLight, "World_SkyLight", unreal.Vector(0, 0, 50000))
sky_light.light_component.set_editor_property("real_time_capture", True)
spawn(unreal.VolumetricCloud, "World_Clouds")
fog = spawn(unreal.ExponentialHeightFog, "World_Fog")
fog.component.set_editor_property("fog_density", 0.005)
fog.component.set_editor_property("fog_height_falloff", 0.05)
def ensure_player_starts():
# The heightmap has a flat pad at its centre; two starts so two PIE clients spawn without a warning.
fallback = (0.24 * 65535.0 - 32768.0) / 128.0 * Z_SCALE
z = ground_height_at(0.0, 0.0, fallback) + 120.0
for index, y in enumerate((-200.0, 200.0)):
spawn(unreal.PlayerStart, f"World_PlayerStart_{index}", unreal.Vector(0.0, y, z))
def main():
ensure_heightmaps()
recreate_level()
landscape = create_landscape()
ensure_daylight()
ensure_player_starts()
level_subsystem.save_current_level()
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}")
main()