Files
UnrealPrototyping/Scripts/Authoring/build_ground_material.py
T
2026-09-25 17:02:24 +03:00

343 lines
16 KiB
Python

"""Extends the landscape material with the biome layers, and points them at the Fab substances.
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="<abs>/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_<layer>.
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()