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>
144 lines
6.7 KiB
Python
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()
|