Added: Initial world generation tool
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
"""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.
|
||||
"""Builds /Game/Maps/L_World: a world-partitioned level with the landscape that RawContent/World/World.json
|
||||
describes, imported from the PNGs in RawContent/World/Heightmaps/ and dressed as Elite_RockyMeadows dresses its
|
||||
demo maps: the pack's landscape material and three paint layers, its sun with the moving cloud shadows, its
|
||||
skybox dome, sky light, height fog and post-process settings (numbers read from the pack's maps with
|
||||
dump_level.py), plus a sea plane at sea level and two player starts on the spawn pad. Rebuilds from scratch
|
||||
every run; the level is a product of this script, the manifest and the PNGs, never hand-edited.
|
||||
|
||||
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py
|
||||
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py -AllowCommandletRendering
|
||||
|
||||
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.
|
||||
-AllowCommandletRendering matters: the landscape's render heightmaps come from the edit-layer merge on the GPU,
|
||||
and a commandlet without it silently skips that, leaving a landscape with collision but no visible surface.
|
||||
|
||||
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.
|
||||
Swapping the terrain: point the manifest's source at a real heightmap (or change the seed), rerun
|
||||
generate_heightmap.py, then rerun this. If the PNGs are missing or the wrong size this script generates them
|
||||
first. The landscape's component layout is the engine's choice for the resolution: 4081 vertices a side gives
|
||||
16x16 components of 255 quads, each its own streaming proxy (see RawContent/World/README.md before changing it).
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
@@ -20,20 +24,53 @@ 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
|
||||
from world_manifest import LAYER_FILES, load_manifest # noqa: E402
|
||||
|
||||
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",
|
||||
LAYER_INFOS = { # paint layer name -> the pack's layer info asset; the weightmap file comes from the manifest module
|
||||
"Base_Layer": f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo",
|
||||
"Layer_02": f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo",
|
||||
"Layer_03": f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo",
|
||||
}
|
||||
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)
|
||||
SKYBOX_MESH = f"{PACK}/Materials/Skybox/Skybox_Mesh"
|
||||
SKYBOX_MATERIAL = f"{PACK}/Materials/Skybox/M_Skybox_Inst_RockyMeadows"
|
||||
CLOUD_SHADOWS = f"{PACK}/Materials/Light_Material/M_Cloud_Shadows_Inst02"
|
||||
SEA_MESH = "/Engine/BasicShapes/Plane"
|
||||
SEA_MATERIAL = "/Engine/EngineMaterials/WaterMaterial"
|
||||
|
||||
# The pack's Rocky_Meadows_01 demo map, as dump_level.py read it. Distances are scaled up where the demo's
|
||||
# 8 km scene would otherwise cut the effect short on a 14 km world.
|
||||
PACK_SUN = {
|
||||
"rotation": unreal.Rotator(roll=-51.273, pitch=-31.342, yaw=36.413), # keyword arguments: positional order is roll, pitch, yaw
|
||||
"intensity": 9.2368,
|
||||
"light_color": unreal.Color(r=223, g=245, b=255, a=255),
|
||||
"light_function_scale": unreal.Vector(1024.0, 1024.0, 1024.0),
|
||||
"light_function_fade_distance": 2000000.0, # the demo fades its cloud shadows out at 2 km; keep them to 20 km here
|
||||
"dynamic_shadow_distance_movable_light": 200000.0,
|
||||
"cascade_distribution_exponent": 3.0,
|
||||
"light_source_angle": 0.5357,
|
||||
"shadow_bias": 0.5,
|
||||
}
|
||||
PACK_SKY_LIGHT = {"intensity": 1.5, "lower_hemisphere_color": unreal.LinearColor(0.0, 0.0, 0.0, 1.0), "sky_distance_threshold": 150000.0}
|
||||
PACK_FOG = {
|
||||
"fog_density": 0.027143,
|
||||
"fog_height_falloff": 0.039076,
|
||||
"fog_inscattering_luminance": unreal.LinearColor(0.238715, 0.329426, 0.458333, 1.0),
|
||||
"directional_inscattering_luminance": unreal.LinearColor(0.25, 0.20832, 0.154948, 1.0),
|
||||
"directional_inscattering_exponent": 4.0,
|
||||
"directional_inscattering_start_distance": 10000.0,
|
||||
}
|
||||
PACK_POST_PROCESS = { # FPostProcessSettings field -> value; the override flag of each is set alongside
|
||||
"auto_exposure_min_brightness": 1.0,
|
||||
"auto_exposure_max_brightness": 1.0,
|
||||
"auto_exposure_bias": 0.263034,
|
||||
"color_saturation": unreal.Vector4(1.0, 1.0, 1.0, 1.25),
|
||||
}
|
||||
|
||||
manifest = load_manifest()
|
||||
LEVEL_PATH = manifest.level
|
||||
|
||||
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
@@ -41,14 +78,42 @@ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
asset_lib = unreal.EditorAssetLibrary
|
||||
|
||||
|
||||
def load_or_raise(asset_path):
|
||||
asset = unreal.load_asset(asset_path)
|
||||
if not asset:
|
||||
raise RuntimeError(f"{asset_path} not found; is the pack in Content/?")
|
||||
return asset
|
||||
|
||||
|
||||
def set_properties(target, values):
|
||||
for name, value in values.items():
|
||||
target.set_editor_property(name, value)
|
||||
|
||||
|
||||
def keep_always_loaded(actor):
|
||||
"""World partition streams actors by their bounds; the sky dome and the sea are the whole world and must
|
||||
not stream at all."""
|
||||
try:
|
||||
actor.set_editor_property("is_spatially_loaded", False)
|
||||
except Exception as error:
|
||||
unreal.log_warning(f"{actor.get_actor_label()}: could not clear is_spatially_loaded ({error}); it will stream by bounds")
|
||||
|
||||
|
||||
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:
|
||||
files = [manifest.heightmap_path] + [manifest.weightmap_path(name) for name in LAYER_FILES]
|
||||
reason = None
|
||||
missing = [f for f in files if not os.path.isfile(f)]
|
||||
if missing:
|
||||
reason = f"missing {missing}"
|
||||
else:
|
||||
width, height, depth, _ = heightmap_io.read_png_header(manifest.heightmap_path)
|
||||
if (width, height, depth) != (manifest.vertices_per_side, manifest.vertices_per_side, 16):
|
||||
reason = f"{manifest.heightmap_path} is {width}x{height} at {depth} bit, the manifest wants {manifest.vertices_per_side} at 16"
|
||||
if reason is None:
|
||||
return
|
||||
unreal.log(f"generating heightmaps, missing {missing}")
|
||||
unreal.log(f"generating heightmaps: {reason}")
|
||||
import generate_heightmap
|
||||
sys.argv = ["generate_heightmap.py"]
|
||||
generate_heightmap.main()
|
||||
generate_heightmap.main([])
|
||||
|
||||
|
||||
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
|
||||
@@ -57,39 +122,84 @@ def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.R
|
||||
return actor
|
||||
|
||||
|
||||
def recreate_level():
|
||||
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
|
||||
|
||||
|
||||
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 prepare_level():
|
||||
"""An empty level to build into. An existing level is loaded and emptied rather than deleted and recreated:
|
||||
recreating it resaves its two 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 (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):
|
||||
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
|
||||
if not level_subsystem.load_level(LEVEL_PATH):
|
||||
raise RuntimeError(f"could not load {LEVEL_PATH}")
|
||||
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:
|
||||
folder = external_actor_dir()
|
||||
if os.path.isdir(folder):
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 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() # /Game/__ExternalActors__/Maps/L_World/1/YM/XXXX
|
||||
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_landscape():
|
||||
material = unreal.load_asset(LANDSCAPE_MATERIAL)
|
||||
if not material:
|
||||
raise RuntimeError(f"landscape material {LANDSCAPE_MATERIAL} not found; is the pack in Content/?")
|
||||
material = load_or_raise(LANDSCAPE_MATERIAL)
|
||||
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")
|
||||
for layer_name, asset_path in LAYER_INFOS.items():
|
||||
entry = unreal.LandscapeAuthoringWeightmap()
|
||||
entry.set_editor_property("layer_info", layer_info)
|
||||
entry.set_editor_property("file", os.path.join(HEIGHTMAP_DIR, file_name))
|
||||
entry.set_editor_property("layer_info", load_or_raise(asset_path))
|
||||
entry.set_editor_property("file", manifest.weightmap_path(layer_name))
|
||||
weightmaps.append(entry)
|
||||
|
||||
world = editor_subsystem.get_editor_world()
|
||||
unreal.log(f"landscape: {manifest.describe()}")
|
||||
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)
|
||||
world, manifest.heightmap_path, weightmaps, material,
|
||||
unreal.Vector(0.0, 0.0, manifest.landscape_z_cm),
|
||||
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
|
||||
manifest.streaming_grid_components)
|
||||
if not landscape:
|
||||
raise RuntimeError("landscape creation failed; see LogSaltyEditor")
|
||||
return landscape
|
||||
@@ -97,7 +207,8 @@ def create_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)
|
||||
reach = manifest.elevation_max_m * 100.0 + 100000.0
|
||||
start, end = unreal.Vector(x, y, reach), unreal.Vector(x, y, -reach)
|
||||
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]:
|
||||
@@ -106,24 +217,62 @@ def ground_height_at(x, y, fallback):
|
||||
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")
|
||||
def dress_with_rocky_meadows():
|
||||
"""The pack's sky, sun, fog and grade, so L_World reads like its demo maps."""
|
||||
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), PACK_SUN["rotation"])
|
||||
light = sun.light_component
|
||||
light.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
|
||||
set_properties(light, {k: v for k, v in PACK_SUN.items() if k != "rotation"})
|
||||
light.set_editor_property("light_function_material", load_or_raise(CLOUD_SHADOWS))
|
||||
|
||||
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)
|
||||
sky_light.light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
|
||||
set_properties(sky_light.light_component, PACK_SKY_LIGHT)
|
||||
|
||||
# The pack's skybox is a textured dome mesh, not a sky atmosphere. Scale it so the whole world sits inside
|
||||
# with room to spare, and sink its centre so the dome's equator is below the sea from any shore.
|
||||
mesh = load_or_raise(SKYBOX_MESH)
|
||||
native_radius = max(mesh.get_bounds().sphere_radius, 1.0)
|
||||
radius = manifest.side_m * 100.0 * 1.1
|
||||
skybox = spawn(unreal.StaticMeshActor, "World_Skybox", unreal.Vector(0.0, 0.0, -radius * 0.25))
|
||||
skybox.static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
|
||||
skybox.static_mesh_component.set_static_mesh(mesh)
|
||||
skybox.static_mesh_component.set_material(0, load_or_raise(SKYBOX_MATERIAL))
|
||||
skybox.static_mesh_component.set_editor_property("cast_shadow", False)
|
||||
skybox.static_mesh_component.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION)
|
||||
skybox.set_actor_scale3d(unreal.Vector(radius / native_radius, radius / native_radius, radius / native_radius))
|
||||
keep_always_loaded(skybox)
|
||||
unreal.log(f"skybox dome radius {radius / 100000:.1f} km (mesh radius {native_radius:g} cm, scale {radius / native_radius:g})")
|
||||
|
||||
fog = spawn(unreal.ExponentialHeightFog, "World_Fog", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm))
|
||||
set_properties(fog.component, PACK_FOG)
|
||||
|
||||
post = spawn(unreal.PostProcessVolume, "World_PostProcess")
|
||||
post.set_editor_property("unbound", True)
|
||||
settings = post.get_editor_property("settings")
|
||||
for field, value in PACK_POST_PROCESS.items():
|
||||
settings.set_editor_property(f"override_{field}", True)
|
||||
settings.set_editor_property(field, value)
|
||||
post.set_editor_property("settings", settings)
|
||||
|
||||
|
||||
def ensure_sea():
|
||||
"""A flat plane at sea level with the engine's water material: enough to read as sea until a water body
|
||||
replaces it. It keeps collision so a walk off the coast is a walk, not a fall to the sea floor."""
|
||||
mesh = load_or_raise(SEA_MESH)
|
||||
material = unreal.load_asset(SEA_MATERIAL) or load_or_raise("/Engine/BasicShapes/BasicShapeMaterial")
|
||||
side = manifest.side_m * 100.0 * 1.5 / 100.0 # the plane is 100 cm; cover the world and the sea beyond its edge
|
||||
sea = spawn(unreal.StaticMeshActor, "World_Sea_Proto", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm))
|
||||
sea.static_mesh_component.set_static_mesh(mesh)
|
||||
sea.static_mesh_component.set_material(0, material)
|
||||
sea.static_mesh_component.set_editor_property("cast_shadow", False)
|
||||
sea.set_actor_scale3d(unreal.Vector(side, side, 1.0))
|
||||
keep_always_loaded(sea)
|
||||
|
||||
|
||||
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
|
||||
fallback = manifest.sea_level_z_cm + 15000.0
|
||||
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))
|
||||
@@ -131,12 +280,15 @@ def ensure_player_starts():
|
||||
|
||||
def main():
|
||||
ensure_heightmaps()
|
||||
recreate_level()
|
||||
prepare_level()
|
||||
landscape = create_landscape()
|
||||
ensure_daylight()
|
||||
dress_with_rocky_meadows()
|
||||
ensure_sea()
|
||||
ensure_player_starts()
|
||||
level_subsystem.save_current_level()
|
||||
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)
|
||||
sweep_stale_actor_packages()
|
||||
unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user