"""Extends the landscape material with the biome layers, and points them at the Fab substances. UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="/build_ground_material.py" ... --dry-run # say what would change, change nothing Phase 2 of Docs/World-Dressing.md. Phase 1 already puts a weightmap per biome in every tile; this is the other half - the material that blends them and the substances they are made of. **It extends the pack's material rather than replacing it.** `M_Ground_Landscape` is our copy (D-69a) of Elite RockyMeadows' landscape material, and its shape is worth keeping: one `LandscapeLayerBlend` blending *MaterialAttributes*, one MaterialFunction per layer, and inside each function a camera-distance blend between a near and a far colour. That last part is load-bearing at this scale - `MI_Ground_RockyMeadows`' far colours are what stop a world tens of kilometres across reading as tiling mush from the air (D-69a), and a material built fresh would have thrown it away. So a new layer is a *copy of an existing layer function* with its parameters renamed, which inherits the distance blend for free. Why the parameters have to be renamed. A MaterialFunction's parameters are named inside the function, and two calls to the same function in one material collide on those names - the instance would then have one "Base Texture" driving every layer. The pack avoids this by shipping three near-identical functions whose parameters are prefixed by layer: "Base Texture", "Layer 02 Texture", "Layer 03 Texture". New layers follow it. Idempotent. Everything is checked for before it is made, so a rerun after adding one substance does one substance. Nothing is ever deleted: a layer removed from the manifest leaves its function and layer info behind, unreferenced, for a person to decide about. """ import json import os import sys import unreal HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from region_manifest import load_manifest as load_region # noqa: E402 PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) GROUND_JSON = os.path.join(PROJECT_ROOT, "RawContent", "Terrain", "ground.json") TERRAIN = "/Game/Terrain" TEXTURES = f"{TERRAIN}/Textures" FUNCTIONS = f"{TERRAIN}/Materials/Functions" LAYERS = f"{TERRAIN}/Layers" MATERIAL = f"{TERRAIN}/Materials/M_Ground_Landscape" INSTANCE = f"{TERRAIN}/Materials/MI_Ground_RockyMeadows" # The layer function every new one is copied from, and the parameters it carries. The keys are exactly what is # in the pack's asset, typo included: "Baser Layer Normal Far" is theirs, and renaming by pattern rather than by # this table would leave one parameter unrenamed on every new layer and one silent collision in the instance. TEMPLATE_FUNCTION = f"{FUNCTIONS}/MF_Ground_Rock" TEMPLATE_PARAMETERS = { "Base Layer Normal": "{layer} Normal", "Base Texture": "{layer} Texture", "Base Texture Distant": "{layer} Texture Distant", "Base Texture Color": "{layer} Texture Color", "Baser Layer Normal Far": "{layer} Normal Far", "Base Texture Color Far": "{layer} Texture Color Far", } # The texture settings the import does not get right on its own. A normal map the engine recognises by its # `_Normal` suffix and handles; roughness it does not, and left as sRGB it is a linear quantity read through a # gamma curve - subtly too glossy everywhere, and not obviously a bug. TEXTURE_SETTINGS = { "BaseColor": {"srgb": True, "compression_settings": unreal.TextureCompressionSettings.TC_DEFAULT}, "Normal": {"srgb": False, "compression_settings": unreal.TextureCompressionSettings.TC_NORMALMAP}, "Roughness": {"srgb": False, "compression_settings": unreal.TextureCompressionSettings.TC_GRAYSCALE}, } DRY_RUN = "--dry-run" in sys.argv asset_lib = unreal.EditorAssetLibrary asset_tools = unreal.AssetToolsHelpers.get_asset_tools() mat_lib = unreal.MaterialEditingLibrary def log(message): unreal.log(message) def load_ground(): with open(GROUND_JSON, "r", encoding="utf-8") as f: return json.load(f)["substances"] def substance_for(ground, layer_name): for entry in ground["sets"]: if entry.get("layer") == layer_name: return entry return None # --------------------------------------------------------------------------------------------------------- # 1. the textures def fix_texture_settings(ground): """What the import got wrong. Roughness is the one that matters; the rest is confirmation.""" fixed = [] for entry in ground["sets"]: for kind, settings in TEXTURE_SETTINGS.items(): path = f"{TEXTURES}/T_{entry['name']}_{kind}" if not asset_lib.does_asset_exist(path): raise RuntimeError( f"{path} is not imported. Run `cd Tools/MapArt && go run . substances` and import the " f"result, or rerun this after the textures are in.") texture = asset_lib.load_asset(path) changed = False for prop, value in settings.items(): if texture.get_editor_property(prop) != value: if not DRY_RUN: texture.set_editor_property(prop, value) changed = True if changed: fixed.append(f"T_{entry['name']}_{kind}") log(f" textures: {len(fixed)} corrected" + (f" ({', '.join(fixed)})" if fixed else "")) return fixed # --------------------------------------------------------------------------------------------------------- # 2. the layer infos def ensure_layer_info(layer_name): """The layer info asset, whose LayerName is what the material blends by and what a weightmap is imported against. Not the asset's name - that is only documentation. There is no Python path to this. `LayerName` is VisibleAnywhere, so it cannot be set, and there is no LayerInfo factory in the bindings; duplicating an existing one produces an asset that silently keeps the name it was copied from and paints *that* substance wherever the new layer should be. So this goes through `ULandscapeAuthoringLibrary::CreateLayerInfo`, which is in the editor module for exactly the same reason `CreateLandscapeFromHeightmap` is.""" path = f"{LAYERS}/{layer_name}_LayerInfo" if asset_lib.does_asset_exist(path): return asset_lib.load_asset(path) if DRY_RUN: log(f" would create {path}") return None if not hasattr(unreal.LandscapeAuthoringLibrary, "create_layer_info"): # Said rather than worked around, because every workaround here is wrong in a way that only shows up # as the wrong substance on the ground. The rest of the run still does its work. log(f" SKIPPED {path}: this build of SaltyEditor has no CreateLayerInfo. Rebuild the editor module " f"and rerun; the material is still extended, it just has no layer info to paint with yet.") return None info = unreal.LandscapeAuthoringLibrary.create_layer_info(LAYERS, f"{layer_name}_LayerInfo", layer_name) if info is None: raise RuntimeError(f"CreateLayerInfo returned nothing for {path}; see the log for why") log(f" created {path} (LayerName {layer_name})") return info # --------------------------------------------------------------------------------------------------------- # 3. the layer functions def ensure_layer_function(layer_name): path = f"{FUNCTIONS}/MF_Ground_{layer_name}" if asset_lib.does_asset_exist(path): return asset_lib.load_asset(path) if DRY_RUN: log(f" would create {path} from {TEMPLATE_FUNCTION}") return None if not asset_lib.duplicate_asset(TEMPLATE_FUNCTION, path): raise RuntimeError(f"could not create {path} from {TEMPLATE_FUNCTION}") function = asset_lib.load_asset(path) renamed, unknown = 0, [] for expression in mat_lib.get_material_function_expressions(function): if not hasattr(expression, "get_editor_property"): continue try: current = str(expression.get_editor_property("parameter_name")) except Exception: continue pattern = TEMPLATE_PARAMETERS.get(current) if pattern is None: unknown.append(current) continue expression.set_editor_property("parameter_name", pattern.format(layer=layer_name)) renamed += 1 if unknown: # Loud, because an unrenamed parameter collides with the template's in the instance and silently # drives two layers from one value. raise RuntimeError( f"{path}: {len(unknown)} parameter(s) not in TEMPLATE_PARAMETERS: {unknown}. The template " f"function has changed; update the table rather than renaming by pattern.") mat_lib.update_material_function(function) log(f" created {path}, {renamed} parameters renamed to '{layer_name} ...'") return function # --------------------------------------------------------------------------------------------------------- # 4. the master material def find_blend(material): for expression in mat_lib.get_material_expressions(material): if isinstance(expression, unreal.MaterialExpressionLandscapeLayerBlend): return expression raise RuntimeError(f"{MATERIAL} has no LandscapeLayerBlend; is this the pack's landscape material?") def ensure_material_layers(layer_names): material = asset_lib.load_asset(MATERIAL) blend = find_blend(material) existing = [entry.get_editor_property("layer_name") for entry in blend.get_editor_property("layers")] missing = [name for name in layer_names if unreal.Name(name) not in existing and name not in [str(e) for e in existing]] if not missing: log(f" material already blends {len(existing)} layers: {[str(e) for e in existing]}") return material, [] if DRY_RUN: log(f" would add {missing} to the LandscapeLayerBlend (has {[str(e) for e in existing]})") return material, missing added = [] for index, name in enumerate(missing): function = asset_lib.load_asset(f"{FUNCTIONS}/MF_Ground_{name}") call = mat_lib.create_material_expression( material, unreal.MaterialExpressionMaterialFunctionCall, -1400, -600 + index * 320) call.set_editor_property("material_function", function) entry = unreal.LayerBlendInput() entry.set_editor_property("layer_name", name) entry.set_editor_property("blend_type", unreal.LandscapeLayerBlendType.LB_WEIGHT_BLEND) entry.set_editor_property("preview_weight", 0.0) blend.set_editor_property("layers", list(blend.get_editor_property("layers")) + [entry]) # The blend's input pins are named after its layers, so the pin only exists once the entry above does. if not mat_lib.connect_material_expressions(call, "", blend, f"Layer {name}"): raise RuntimeError(f"could not connect MF_Ground_{name} to the blend's 'Layer {name}' pin") added.append(name) mat_lib.recompile_material(material) log(f" material: added {added}, now blending {len(existing) + len(added)} layers") return material, added # --------------------------------------------------------------------------------------------------------- # 5. the instance: which substance each layer is made of # Which function draws which of the pack's layers. Their names say what the substance is rather than which # layer it is, and `LayerBlendInput.layer_input` is not exposed to Python - MCP can read it, the reflection # cannot - so this is the one thing that has to be written down. Every layer added since is MF_Ground_. PACK_LAYER_FUNCTIONS = { "Base_Layer": "MF_Ground_Rock", "Layer_02": "MF_Ground_Meadow", "Layer_03": "MF_Ground_RockHigh", } def function_path(layer_name): return f"{FUNCTIONS}/{PACK_LAYER_FUNCTIONS.get(layer_name, 'MF_Ground_' + layer_name)}" def parameter_prefix(function): """What this function calls its parameters, taken from the function itself. Not derivable from the layer name, which is the trap: the pack prefixes by how it *displays* a layer, so Base_Layer's parameters are "Base ..." and Layer_02's are "Layer 02 ...". Setting a parameter that does not exist is silent - the instance stores an override that drives nothing - so this is read rather than guessed. """ for expression in mat_lib.get_material_function_expressions(function): try: name = str(expression.get_editor_property("parameter_name")) except Exception: continue if name.endswith(" Texture"): return name[: -len(" Texture")] return None def set_instance_textures(ground, layer_names): instance = asset_lib.load_asset(INSTANCE) set_count, missing, unresolved = 0, [], [] for layer_name in layer_names: entry = substance_for(ground, layer_name) if entry is None: missing.append(layer_name) continue path = function_path(layer_name) function = asset_lib.load_asset(path) if asset_lib.does_asset_exist(path) else None prefix = parameter_prefix(function) if function else None if prefix is None: unresolved.append(layer_name) continue colour = asset_lib.load_asset(f"{TEXTURES}/T_{entry['name']}_BaseColor") normal = asset_lib.load_asset(f"{TEXTURES}/T_{entry['name']}_Normal") # Near and far get the same texture. The pack uses two so a layer can fade to a lower-frequency # variant with distance; there is only one scan per substance here, and the distance *colour* blend - # which is the part that matters at this scale - still works. for suffix, texture in (("Texture", colour), ("Texture Distant", colour), ("Normal", normal), ("Normal Far", normal)): if not DRY_RUN: mat_lib.set_material_instance_texture_parameter_value(instance, f"{prefix} {suffix}", texture) set_count += 1 log(f" {layer_name} -> {entry['name']} via '{prefix} ...'") if missing: log(f" WARNING: no substance in ground.json for {missing}; those layers keep the template's textures") if unresolved: raise RuntimeError( f"could not read a parameter prefix for {unresolved}; their layer function has no '... Texture' " f"parameter, so any override would be silently ignored") log(f" instance: {set_count} texture parameter(s) set on {INSTANCE}") return instance def main(): ground = load_ground() region = load_region() # The layers the tiles actually carry, in the manifest's order, minus the three the pack already blends. pack_layers = {"Base_Layer", "Layer_02", "Layer_03"} biome_layers = [layer.name for layer in region.enabled_layers if layer.name not in pack_layers] log(f"ground material: {len(biome_layers)} biome layer(s) to add: {biome_layers}") if not biome_layers: log("nothing to do; Region.json enables no layer the material does not already blend") return fix_texture_settings(ground) for name in biome_layers: ensure_layer_info(name) ensure_layer_function(name) material, added = ensure_material_layers(biome_layers) # The rock swap (D-76) is a parameter override and not a graph edit, because the pack made its textures # parameters. Done for every layer that names a substance, the pack's three included. set_instance_textures(ground, ["Base_Layer"] + biome_layers) if DRY_RUN: log("dry run: nothing written") return to_save = [MATERIAL, INSTANCE] to_save += [f"{FUNCTIONS}/MF_Ground_{n}" for n in biome_layers] to_save += [p for p in (f"{LAYERS}/{n}_LayerInfo" for n in biome_layers) if asset_lib.does_asset_exist(p)] to_save += [f"{TEXTURES}/T_{e['name']}_{k}" for e in ground["sets"] for k in TEXTURE_SETTINGS] for path in to_save: if not asset_lib.save_asset(path, only_if_is_dirty=False): raise RuntimeError(f"could not save {path}") missing_infos = [n for n in biome_layers if not asset_lib.does_asset_exist(f"{LAYERS}/{n}_LayerInfo")] log(f"ground material built: {len(biome_layers)} layer(s) added, {len(to_save)} asset(s) saved") if missing_infos: log(f" STILL OWED: layer infos for {missing_infos}. Rebuild SaltyEditor for CreateLayerInfo and rerun; " f"until then the material blends those layers and nothing paints them.") main()