271 lines
16 KiB
Python
271 lines
16 KiB
Python
"""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)
|