"""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)