This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+270
View File
@@ -0,0 +1,270 @@
"""Elite_RockyMeadows' kit, as its own demo maps use it: the landscape material and its three layer infos, the
sun with the moving cloud shadows, the skybox dome, sky light, height fog and post-process grade. The numbers
were read out of the pack's Rocky_Meadows_01 demo map with Scripts/Authoring/dump_level.py.
Shared by create_world.py and create_region_world.py so the two worlds are dressed by one set of numbers rather
than two copies of them. Everything here is editor-side and imports `unreal`; the callers pass in their own
`spawn`, because how an actor is created and labelled is theirs, and their manifest, because the distances
scale with how big the world is.
A manifest passed in here needs `side_m`, `sea_level_m` and `sea_level_z_cm`. Both manifests have them.
"""
import unreal
PACK = "/Game/Elite_RockyMeadows"
# The ground is the project's own now (D-69a), so a world is built from Content/Terrain and not from the pack.
# That is not bookkeeping: MI_Ground_RockyMeadows carries the colour corrections in RawContent/Terrain/
# ground.json, and the pack's instance it was copied from still tints the meadow layer blue at every distance.
# The sky kit below stays in the pack, which is what ground.json's `_comment_not_here` says and why PACK remains.
TERRAIN = "/Game/Terrain"
LANDSCAPE_MATERIAL = f"{TERRAIN}/Materials/MI_Ground_RockyMeadows"
# Paint layer name -> the layer info asset. The names mislead: Base_Layer samples the rock textures,
# Layer_02 the grass, Layer_03 the high rock, so the meadow weightmap is the Layer_02 one. The copies keep the
# pack's `LayerName` property, which is the half that has to match what the master material blends.
LAYER_INFOS = {
"Base_Layer": f"{TERRAIN}/Layers/Base_Layer_LayerInfo",
"Layer_02": f"{TERRAIN}/Layers/Layer_02_LayerInfo",
"Layer_03": f"{TERRAIN}/Layers/Layer_03_LayerInfo",
# The biome layers (D-76). Built by Scripts/Authoring/build_ground_material.py, which duplicates a layer
# info per layer and renames its LayerName - the property the material blends by, which the asset's own
# name only documents. A layer enabled in Region.json with no entry here stops the build with a message
# rather than being dropped, because a dropped layer paints the material's first substance everywhere.
"Beach": f"{TERRAIN}/Layers/Beach_LayerInfo",
"Sand": f"{TERRAIN}/Layers/Sand_LayerInfo",
"Ice": f"{TERRAIN}/Layers/Ice_LayerInfo",
"Regolith": f"{TERRAIN}/Layers/Regolith_LayerInfo",
}
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"
# The package is WaterMaterial and the material inside it is DefaultWaterMaterial, which is why every spelling
# of "WaterMaterial.WaterMaterial" resolved to nothing: does_asset_exist answers on the object name, not the
# package's. It is the engine's single-layer water (MSM_SingleLayerWater). Getting this wrong cost a world
# whose sea rendered as a flat white shape material out to the horizon, bright enough to read as an ice sheet,
# for as long as the failure was only a warning. It is an error now.
SEA_MATERIALS = ("/Engine/EngineMaterials/WaterMaterial.DefaultWaterMaterial",
"/Engine/EngineMaterials/WaterMaterial.WaterMaterial")
# What the sea actually wears. The engine's single-layer water is a whole-planet sheet here rather than a lake:
# it reads at every scale as something it is not, and until a real water body replaces it, a plane pretending
# to be the ocean is worth less than a plane that is honestly a placeholder. `SEA_GREY` is the switch - set it
# False and the water material above comes back, unchanged and still the first thing tried.
SEA_GREY = True
SEA_MATERIAL = "/Game/World/M_Sea_Proto"
# 0.18 linear is the classic mid-grey card: dark enough that two thirds of the world does not blow the auto
# exposure the way the white shape material did, light enough to read as a surface rather than a hole.
SEA_GREY_COLOR = (0.18, 0.185, 0.19)
SEA_GREY_ROUGHNESS = 0.6
# Distances are scaled up where the demo's 8 km scene would otherwise cut the effect short on a bigger 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": 200000.0, # the pack's: cloud shadows within 2 km of the camera
# Past the fade distance the light function is replaced by this flat factor, and the engine's default is
# 0.5 - half sun over everything further away than 2 km, which on a world tens of kilometres across is
# almost all of it. That one number was most of why the world came out dark, and stretching the fade
# distance to cover the world instead only traded the darkness for a mottle: the cloud texture is tens of
# metres across, so from a kilometre up it stops reading as cloud and becomes the dirty streaking over
# every slope. The engine's own note says this should be the average brightness of the light function's
# emissive, and M_Cloud_Shadows is white with dark blobs in it, not mid grey. Measured on four tiles: the
# pack's distances with 1.0 here are as bright as deleting the light function outright, and keep the
# clouds where they were designed to be seen.
"disabled_brightness": 1.0,
"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}
# The demo map these numbers came from is about 8 km across. Fog density is per unit of distance, so the same
# number over a bigger world is proportionally more fog: on a 30.6 km world it was opaque, and a capture
# looking straight down from 25 km saw nothing but white. `scale_fog` divides the density by how much bigger
# the world is than the demo, and raises the height falloff to the engine's own 0.2 so the fog thins with
# altitude instead of reaching to the top of the sky.
PACK_DEMO_SIDE_M = 8000.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),
}
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 weightmap_entries(weight_path, layer_names=None):
"""The three paint layers as the landscape library wants them. `weight_path(layer_name)` returns the file
for that layer, which is the one thing that differs between a single world and a tile of one."""
entries = []
for layer_name in (LAYER_INFOS if layer_names is None else layer_names):
asset_path = LAYER_INFOS.get(layer_name)
if asset_path is None:
# A paint layer was enabled in Region.json before it had a substance. Said here rather than
# further down, where the landscape library would drop the layer with a warning and the ground
# would come out as the material's first layer everywhere (D-74, phase 2).
raise RuntimeError(
f"paint layer {layer_name!r} has no layer info in rocky_meadows.LAYER_INFOS. Add the "
f"substance to RawContent/Terrain/ground.json, run collect_terrain_assets.py, and give it "
f"a layer the landscape material blends, or set its `enabled` false in Region.json.")
entry = unreal.LandscapeAuthoringWeightmap()
entry.set_editor_property("layer_info", load_or_raise(asset_path))
entry.set_editor_property("file", weight_path(layer_name))
entries.append(entry)
return entries
def scale_fog(manifest):
"""The pack's fog numbers, thinned for how big this world is. Returns a copy; PACK_FOG is left as read."""
fog = dict(PACK_FOG)
ratio = PACK_DEMO_SIDE_M / max(manifest.side_m, 1.0)
fog["fog_density"] = round(PACK_FOG["fog_density"] * ratio, 6)
fog["fog_height_falloff"] = 0.2
unreal.log(f"fog density {PACK_FOG['fog_density']:g} -> {fog['fog_density']:g} "
f"({manifest.side_m / 1000:.2f} km world against the pack's {PACK_DEMO_SIDE_M / 1000:g} km demo)")
return fog
def dress(spawn, manifest):
"""The pack's sky, sun, fog and grade, so the level 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("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, scale_fog(manifest))
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 sea_grey_material():
"""The placeholder grey, authored here rather than picked out of /Engine.
Nothing the engine ships is the right grey: `BasicShapeMaterial` is the near-white that once read as an
ice sheet out to the horizon, and `WorldGridMaterial` puts a metre grid on a plane seventy kilometres
across. Twenty lines of material graph buys an exact value instead, and it is built on demand so a fresh
clone needs no extra step - if the asset is there it is used, and it is only ever created once."""
existing = unreal.load_asset(SEA_MATERIAL)
if existing:
return existing
package, name = SEA_MATERIAL.rsplit("/", 1)
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, package, unreal.Material, unreal.MaterialFactoryNew())
if not material:
raise RuntimeError(f"could not create {SEA_MATERIAL}")
lib = unreal.MaterialEditingLibrary
colour = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -400, 0)
colour.set_editor_property("constant", unreal.LinearColor(*SEA_GREY_COLOR, 1.0))
lib.connect_material_property(colour, "", unreal.MaterialProperty.MP_BASE_COLOR)
roughness = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 160)
roughness.set_editor_property("r", SEA_GREY_ROUGHNESS)
lib.connect_material_property(roughness, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
unreal.log(f"created {SEA_MATERIAL}")
return material
def sea_material():
if SEA_GREY:
return sea_grey_material()
material = next((asset for asset in (unreal.load_asset(path) for path in SEA_MATERIALS) if asset), None)
if material is None:
raise RuntimeError(f"none of {SEA_MATERIALS} loaded, so two thirds of this world would be an "
f"untextured plane; find what the engine calls its water material before building "
f"the level, or set SEA_GREY to use the placeholder deliberately")
return material
def ensure_sea(spawn, manifest):
"""A flat plane at sea level, at present wearing a placeholder grey: enough to read as *a surface* 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.
The material is re-applied on every run rather than only on the run that spawns the plane, so changing
SEA_GREY and re-running a level's dressing is enough to change the sea. Without that a rerun would find
the actor already there by label, leave it alone, and the switch would do nothing on any world that
already exists."""
mesh = load_or_raise(SEA_MESH)
material = sea_material()
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 repair_sea(manifest):
"""Put the current sea material on a `World_Sea_Proto` that is already in the level.
`ensure_dressing` spawns the sea only when it is missing, which is right - a rerun must not leave two suns
- but it means a material change cannot reach a world that already has one. This is the other half, and it
is a separate function because it is the *only* thing a caller may want to do to a finished level."""
del manifest # the plane's size and height are already right; only its material is in question
material = sea_material()
actors = unreal.get_editor_subsystem(unreal.EditorActorSubsystem).get_all_level_actors()
seas = [actor for actor in actors if actor.get_actor_label() == "World_Sea_Proto"]
for sea in seas:
sea.static_mesh_component.set_material(0, material)
unreal.log(f"sea material set to {material.get_path_name()}")
if not seas:
unreal.log_warning("no World_Sea_Proto in the loaded level; nothing to repair")
return len(seas)