343 lines
16 KiB
Python
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()
|