This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+342
View File
@@ -0,0 +1,342 @@
"""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()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Builds the world level a few tiles at a time. Which level that is comes from Region.json's `level`
# (/Game/Maps/L_World today), never from a name written here.
#
# Scripts/Authoring/build_region.sh # the whole grid from Region.json, rebuilding the level first
# Scripts/Authoring/build_region.sh --append # add whatever is still missing, keep what is there
# BATCH=6 Scripts/Authoring/build_region.sh # bigger batches, if the machine has the memory
#
# One process per batch, because a landscape of a hundred components costs about a gigabyte that the editor
# does not give back while the level is open. Thirty-six in one process reached 14.7 GB by the ninth tile
# and would have exhausted this machine's commit long before the last; six at a time peaks at 13.6 GB, which
# left only 1.2 GB of commit free. Three is the default for that reason. The script itself skips tiles that are
# already in the level, so a batch that is rerun costs only the editor's start-up.
#
# Everything is passed as an absolute path and the log goes to a file of its own. With the editor open and a
# relative project path, UnrealEditor-Cmd exits immediately having written nothing at all, which looks exactly
# like success: the exit code is zero and there is no output to read.
#
# The script's own arguments go *inside* the quoted -script= value, not after it. UPythonScriptCommandlet::Main
# reads -Script= as one quoted string and hands the whole thing to the Python plugin, which splits it into a
# filename and arguments and sets sys.argv from them; anything put after it on the command line is parsed by
# the engine and never reaches Python. Passed the wrong way the script sees no arguments and quietly builds
# every tile in the grid in one process, which is exactly the run this batching exists to avoid.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
UE_ROOT="${UE_ROOT:-D:/UE_5.8}"
CMD="$UE_ROOT/Engine/Binaries/Win64/UnrealEditor-Cmd.exe"
PROJECT="$ROOT/Salty.uproject"
SCRIPT="$ROOT/Scripts/Authoring/create_region_world.py"
LOGS="$ROOT/Saved/Logs"
BATCH="${BATCH:-3}"
# The grid comes from the manifest, never from a number written here: Region.json is the contract, and a
# driver that assumed a 6x6 square would quietly build thirty-six of the ninety-eight tiles and report success.
MANIFEST="$ROOT/RawContent/World/Region.json"
grid() { grep -oE "\"$1\"[[:space:]]*:[[:space:]]*[0-9]+" "$MANIFEST" | grep -oE '[0-9]+$'; }
COLUMNS="${COLUMNS:-$(grid columns)}"
ROWS="${ROWS:-$(grid rows)}"
if [ -z "$COLUMNS" ] || [ -z "$ROWS" ]; then
echo "could not read tiles.columns and tiles.rows from $MANIFEST" >&2
exit 1
fi
append=0
if [ "${1:-}" = "--append" ]; then
append=1
fi
# An editor with the level open holds a write lock on the .umap. The save is the last thing a batch does and
# --rebuild is the first, so a locked file does not fail the run harmlessly: it empties the level and *then*
# fails, which on 2026-09-20 left twelve of ninety-eight tiles and looked exactly like a corrupted world.
# create_region_world.py refuses to start for the same reason; this is the same question asked before thirty
# editor start-ups pay for the answer. Opening r+b writes nothing.
PYTHON="$UE_ROOT/Engine/Binaries/ThirdParty/Python3/Win64/python.exe"
# The level comes from the manifest, like the grid does. A name written here would go stale the moment
# Region.json's `level` changed, and the probe would then cheerfully clear a file nothing is about to touch.
LEVEL_PATH="$(grep -oE '"level"[[:space:]]*:[[:space:]]*"[^"]+"' "$MANIFEST" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
LEVEL_FILE="$ROOT/Content/Maps/${LEVEL_PATH##*/}.umap"
if [ -f "$LEVEL_FILE" ] && [ -x "$PYTHON" ]; then
if ! "$PYTHON" -c "import sys; open(sys.argv[1], 'r+b').close()" "$LEVEL_FILE" 2>/dev/null; then
echo "$LEVEL_FILE is locked: an editor with that level open holds it." >&2
echo "Close the editor, or load another level in it, then run this again." >&2
exit 1
fi
fi
tiles=()
for ((ty = 0; ty < ROWS; ty++)); do
for ((tx = 0; tx < COLUMNS; tx++)); do
tiles+=("$tx,$ty")
done
done
mkdir -p "$LOGS"
total=${#tiles[@]}
echo "=== ${COLUMNS}x${ROWS} = $total tiles, $BATCH a batch"
added_all=0
batch_index=0
for ((i = 0; i < total; i += BATCH)); do
batch=("${tiles[@]:i:BATCH}")
# The first batch of a full run starts the level over, which is also what sweeps any stale actor package an
# interrupted run left behind. --append never rebuilds.
rebuild=""
if [ "$i" -eq 0 ] && [ "$append" -eq 0 ]; then
rebuild=" --rebuild"
fi
log="$LOGS/region_batch_$batch_index.log"
echo "=== batch $batch_index: ${batch[*]}${rebuild}"
"$CMD" "$PROJECT" -run=pythonscript \
-script="$SCRIPT$rebuild --tiles ${batch[*]}" \
-AllowCommandletRendering -unattended -nopause -abslog="$log" >/dev/null 2>&1
# Not the exit code: UnrealEditor-Cmd returns non-zero whenever anything logged an Error, and this project
# logs three on every start (no GameFeatureData asset rule, and the editor already holds MCP's port 8000).
# The line the script prints on a successful save is the honest signal.
saved=$(grep -c "saved: .* landscape(s) added this run" "$log" 2>/dev/null || true)
if [ "${saved:-0}" -eq 0 ]; then
echo "batch $batch_index did not save; see $log" >&2
grep -E "LogPython: Error|Python script executed with errors|Traceback" "$log" 2>/dev/null | tail -5 >&2
exit 1
fi
added=$(grep -cE "LogPython: \[" "$log" 2>/dev/null || true)
added_all=$((added_all + ${added:-0}))
echo " $added landscape(s) added"
batch_index=$((batch_index + 1))
done
echo "done: $added_all landscape(s) added over $batch_index batches; $total tiles in the grid"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Builds the world map: renders the art from the planet images, then imports it and writes the definition asset.
#
# Scripts/Authoring/build_world_map.sh # render the art and import it
# Scripts/Authoring/build_world_map.sh --art-only # render the PNGs, import nothing (no editor needed)
# Scripts/Authoring/build_world_map.sh --dry-run # say what would be imported, change nothing
#
# Two stages because they need two languages. Rendering reads 33-megapixel RGB PNGs, which the engine's Python
# cannot decode at any useful speed (heightmap_io.py is greyscale-only and unfilters a byte at a time), so it is
# a Go tool: Tools/MapArt, a few seconds for the lot. Importing has to happen inside the editor, so it is
# Python. The contract between them is RawContent/World/MapArt/, which holds both the manifest and the rendered
# PNGs.
#
# Unlike build_region.sh this touches no level and needs no batching: it writes four textures and one data
# asset. It does check for a lock, though, for the same reason - an editor holding those assets open would make
# the import look like it worked and change nothing on disk.
#
# The script's own arguments go *inside* the quoted -script= value. Anything after it is eaten by the engine
# and never reaches Python.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
UE_ROOT="${UE_ROOT:-D:/UE_5.8}"
CMD="$UE_ROOT/Engine/Binaries/Win64/UnrealEditor-Cmd.exe"
PYTHON="$UE_ROOT/Engine/Binaries/ThirdParty/Python3/Win64/python.exe"
PROJECT="$ROOT/Salty.uproject"
SCRIPT="$ROOT/Scripts/Authoring/create_world_map.py"
LOG="$ROOT/Saved/Logs/world_map.log"
art_only=0
script_args=""
case "${1:-}" in
--art-only) art_only=1 ;;
--dry-run) script_args=" --dry-run" ;;
"") ;;
*) echo "usage: $(basename "$0") [--art-only|--dry-run]" >&2; exit 2 ;;
esac
echo "=== rendering the map art"
if ! (cd "$ROOT/Tools/MapArt" && go run . build); then
echo "Tools/MapArt failed; nothing imported." >&2
exit 1
fi
echo "=== checking the art is of the world L_World was cut from"
# Advisory, not a gate: a layer of ice or cloud legitimately scores lower, and only a person can tell that from
# a layer of the wrong planet. A number in the eighties or above is ordinary; one in the fifties is not.
(cd "$ROOT/Tools/MapArt" && go run . check) || true
if [ "$art_only" -eq 1 ]; then
echo "done: art only, nothing imported"
exit 0
fi
# An editor with these assets loaded holds their files. The import would appear to succeed and save nothing.
for name in DA_WorldMap_L_World T_WorldMap_Relief T_WorldMap_Colour T_WorldMap_Satellite T_WorldMap_Climate; do
file="$ROOT/Content/World/Maps/$name.uasset"
if [ -f "$file" ] && [ -x "$PYTHON" ]; then
if ! "$PYTHON" -c "import sys; open(sys.argv[1], 'r+b').close()" "$file" 2>/dev/null; then
echo "$file is locked: an editor has it open." >&2
echo "Close the editor and run this again." >&2
exit 1
fi
fi
done
mkdir -p "$ROOT/Saved/Logs"
echo "=== importing"
"$CMD" "$PROJECT" -run=pythonscript \
-script="$SCRIPT$script_args" \
-unattended -nopause -abslog="$LOG" >/dev/null 2>&1
# Not the exit code: UnrealEditor-Cmd returns non-zero whenever anything logged an Error, and this project logs
# several on every start. The line the script prints on success is the honest signal.
if grep -q "world map saved:" "$LOG" 2>/dev/null; then
grep -E "LogPython: (world map| )" "$LOG" | sed 's/.*LogPython: //'
echo "done"
exit 0
fi
if [ -n "$script_args" ] && grep -q "dry run: nothing written" "$LOG" 2>/dev/null; then
grep -E "LogPython: " "$LOG" | sed 's/.*LogPython: //'
exit 0
fi
echo "the import did not report success; see $LOG" >&2
grep -E "LogPython: Error|Python script executed with errors|Traceback|RuntimeError" "$LOG" 2>/dev/null | tail -10 >&2
exit 1
+358
View File
@@ -0,0 +1,358 @@
"""Builds Content/Terrain/: the project's own ground, copied out of the asset packs so there is one place that
says what this world is made of. Driven entirely by RawContent/Terrain/ground.json; adding a substance is a line
in that file and a rerun.
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/collect_terrain_assets.py
... --dry-run # say what would be copied and repointed, change nothing
Idempotent: an asset already at its destination is left alone, so a rerun after adding one line copies one
asset. Nothing is ever deleted from a pack, and nothing is moved: a pack stays exactly as it shipped.
The hard part is that a copy is not ownership. Duplicating a material function gives a function that still
samples the *pack's* textures, because the reference lives inside the graph and duplication does not rewrite
it. So after everything is copied, every copy is walked and repointed at its siblings: a texture sample gets
the copied texture, a function call gets the copied function, an instance gets the copied parent and an
override for every texture parameter that still points into a pack. Then the whole set is checked, and
anything still referencing outside Content/Terrain is reported rather than passed over in silence - that
report is the only honest answer to "can the pack be deleted yet".
"""
import json
import os
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
GROUND_PATH = os.path.join(PROJECT_ROOT, "RawContent", "Terrain", "ground.json")
asset_lib = unreal.EditorAssetLibrary
def load_ground(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
destination = data.get("destination", "/Game/Terrain").rstrip("/")
entries = []
for entry in data["assets"]:
source = entry["source"]
dest = f"{destination}/{entry.get('dest', source.rsplit('/', 1)[1])}"
entries.append((source, dest, entry.get("parameters") or {}))
return destination, entries
def copy_assets(entries, dry_run):
"""Every source to its destination, skipping what is already there. Returns {source path: dest path} for
the whole set, copied or not, which is the map everything downstream is repointed through."""
mapping = {}
copied, skipped = 0, 0
for source, dest, _ in entries:
mapping[source] = dest
if asset_lib.does_asset_exist(dest):
skipped += 1
continue
if not asset_lib.does_asset_exist(source):
raise RuntimeError(f"{source} not found; is the pack still in Content/?")
if dry_run:
unreal.log(f" would copy {source} -> {dest}")
copied += 1
continue
if not asset_lib.duplicate_asset(source, dest):
raise RuntimeError(f"could not copy {source} to {dest}")
unreal.log(f" copied {source} -> {dest}")
copied += 1
unreal.log(f"{copied} copied, {skipped} already there")
return mapping
def material_expressions(asset):
"""Every expression node in a Material or MaterialFunction.
It has to come from MaterialEditingLibrary: the `Expressions` property is protected on UMaterial and absent
on UMaterialFunction, so `get_editor_property` cannot reach either of them ("Property 'Expressions' ... is
protected and cannot be read"), and a first attempt that went that way found nothing and reported nothing
repointed. And a function needs its own call - `get_material_expressions` takes a Material and refuses a
MaterialFunction outright, which is what left the three layer functions still sampling the pack."""
getter = (unreal.MaterialEditingLibrary.get_material_function_expressions
if isinstance(asset, unreal.MaterialFunction)
else unreal.MaterialEditingLibrary.get_material_expressions)
try:
nodes = getter(asset)
except Exception as error:
unreal.log_warning(f"{asset.get_path_name()}: could not read its expressions ({error})")
return []
return [node for node in (nodes or []) if node is not None]
def repoint_property(node, property_name, mapping, dry_run):
"""One object-valued property of an expression, if it points at something we copied."""
try:
current = node.get_editor_property(property_name)
except Exception:
return None
if current is None:
return None
source = current.get_path_name().split(".")[0]
dest = mapping.get(source)
if dest is None or dest == source:
return None
if dry_run:
return f"{property_name}: {source} -> {dest}"
replacement = unreal.load_asset(dest)
if replacement is None:
raise RuntimeError(f"{dest} was copied but will not load")
node.set_editor_property(property_name, replacement)
return f"{property_name}: {source} -> {dest}"
# Expression property names that hold a reference to another asset. A texture sample and its parameter
# variants keep theirs in `texture`; a function call keeps its function in `material_function`.
REFERENCE_PROPERTIES = ("texture", "material_function")
def repoint_graphs(mapping, dry_run):
"""Every copied Material and MaterialFunction, repointed at the copies of whatever it used to reference."""
changed = 0
for dest in sorted(set(mapping.values())):
asset = unreal.load_asset(dest)
if not isinstance(asset, (unreal.Material, unreal.MaterialFunction)):
continue
nodes = material_expressions(asset)
if not nodes:
unreal.log_warning(f"{dest}: no expressions found, so nothing could be repointed inside it; "
f"check it by hand before trusting the ownership report")
continue
edits = []
for node in nodes:
for name in REFERENCE_PROPERTIES:
edit = repoint_property(node, name, mapping, dry_run)
if edit:
edits.append(edit)
if not edits:
continue
changed += len(edits)
unreal.log(f" {dest}: {len(edits)} reference(s) repointed")
for edit in edits:
unreal.log(f" {edit}")
if dry_run:
continue
if isinstance(asset, unreal.MaterialFunction):
unreal.MaterialEditingLibrary.update_material_function(asset)
else:
unreal.MaterialEditingLibrary.recompile_material(asset)
asset_lib.save_asset(dest)
unreal.log(f"{changed} graph reference(s) repointed")
def repoint_override_array(instance, mapping, dry_run):
"""Texture overrides stored on the instance, read straight off `texture_parameter_values` rather than
through the parent's parameter list, so an orphaned override is reached too. Returns the edits made."""
try:
values = instance.get_editor_property("texture_parameter_values")
except Exception as error:
unreal.log_warning(f"{instance.get_path_name()}: could not read its texture overrides ({error})")
return []
edits, rewritten = [], []
for entry in values or []:
current = entry.get_editor_property("parameter_value")
replacement = mapping.get(current.get_path_name().split(".")[0]) if current else None
if replacement and replacement != current.get_path_name().split(".")[0]:
edits.append(f"override {entry.get_editor_property('parameter_info').get_editor_property('name')}: "
f"{current.get_path_name().split('.')[0]} -> {replacement}")
if not dry_run:
entry.set_editor_property("parameter_value", unreal.load_asset(replacement))
rewritten.append(entry)
if edits and not dry_run:
instance.set_editor_property("texture_parameter_values", rewritten)
return edits
def repoint_instances(mapping, dry_run):
"""Every copied material instance: its parent, and an override for each texture parameter that still points
into a pack. An instance is the one place the engine lets Python set a texture reference directly, so this
is also the belt to the graph repointing's braces."""
changed = 0
for dest in sorted(set(mapping.values())):
asset = unreal.load_asset(dest)
if not isinstance(asset, unreal.MaterialInstanceConstant):
continue
edits = []
edit = repoint_property(asset, "parent", mapping, dry_run)
if edit:
edits.append(edit)
parent = asset.get_editor_property("parent")
if parent is not None:
for name in unreal.MaterialEditingLibrary.get_texture_parameter_names(parent):
current = unreal.MaterialEditingLibrary.get_material_instance_texture_parameter_value(asset, name)
if current is None:
continue
source = current.get_path_name().split(".")[0]
replacement = mapping.get(source)
if replacement is None or replacement == source:
continue
edits.append(f"parameter {name}: {source} -> {replacement}")
if not dry_run:
unreal.MaterialEditingLibrary.set_material_instance_texture_parameter_value(
asset, name, unreal.load_asset(replacement))
# And the override array itself. The loop above can only see parameters the *parent* still exposes, so
# an override left over for one it no longer does is invisible to it - which is how the instance went
# on referencing the pack's T_Rock_Shade_Variation after every parameter it shares with its parent had
# been repointed. Walking the array catches those too.
edits.extend(repoint_override_array(asset, mapping, dry_run))
if not edits:
continue
changed += len(edits)
unreal.log(f" {dest}: {len(edits)} reference(s) repointed")
for edit in edits:
unreal.log(f" {edit}")
if not dry_run:
unreal.MaterialEditingLibrary.update_material_instance(asset)
asset_lib.save_asset(dest)
unreal.log(f"{changed} instance reference(s) repointed")
def apply_parameters(entries, dry_run):
"""The `parameters` block of an entry, written onto the copy. A copy is the project's own asset, so this is
where it stops being a duplicate of the pack and starts being ours: the pack's instance is left exactly as
it shipped and the numbers that differ live in ground.json, next to the reason they differ.
Idempotent by comparison, not by memory: a value already set is not written again, so a rerun after an
unrelated edit reports nothing.
Every write is read back and checked. A misspelled parameter name is not an error to the engine - the
getter answers with a zero colour for a name that does not exist and the setter does nothing at all - so
without the read-back a typo here would look exactly like a successful run and leave the ground the colour
it already was."""
changed = 0
for _, dest, parameters in entries:
if not parameters:
continue
instance = unreal.load_asset(dest)
if not isinstance(instance, unreal.MaterialInstanceConstant):
raise RuntimeError(f"{dest} has parameters in ground.json but is a {type(instance).__name__}, "
f"not a material instance")
for kind, names in (("vector", parameters.get("vector") or {}), ("scalar", parameters.get("scalar") or {})):
for name, value in names.items():
wanted = unreal.LinearColor(*value) if kind == "vector" else float(value)
if read_parameter(instance, kind, name) == quantise(wanted):
continue
before = read_parameter(instance, kind, name)
if not dry_run:
write_parameter(instance, kind, name, wanted)
after = read_parameter(instance, kind, name)
if after != quantise(wanted):
raise RuntimeError(f"{dest}: setting {kind} parameter {name!r} to {quantise(wanted)} "
f"left it at {after}; is that the name the parent exposes?")
unreal.log(f" {dest}: {name} {before} -> {quantise(wanted)}")
changed += 1
if changed and not dry_run:
unreal.MaterialEditingLibrary.update_material_instance(instance)
asset_lib.save_asset(dest, only_if_is_dirty=False)
unreal.log(f"{changed} parameter(s) set from ground.json")
def quantise(value):
"""A value rounded to what a comparison should care about, so a float that survived a round trip through the
asset still equals what was asked for."""
if isinstance(value, unreal.LinearColor):
return tuple(round(getattr(value, c), 4) for c in ("r", "g", "b", "a"))
return round(float(value), 4)
def read_parameter(instance, kind, name):
getter = (unreal.MaterialEditingLibrary.get_material_instance_vector_parameter_value if kind == "vector"
else unreal.MaterialEditingLibrary.get_material_instance_scalar_parameter_value)
return quantise(getter(instance, name))
def write_parameter(instance, kind, name, value):
setter = (unreal.MaterialEditingLibrary.set_material_instance_vector_parameter_value if kind == "vector"
else unreal.MaterialEditingLibrary.set_material_instance_scalar_parameter_value)
setter(instance, name, value)
def refresh_all(mapping):
"""Recompile every copy, functions first, then materials, then instances, whether or not this run changed
anything. A material caches the textures its graph and its called functions reference, and an instance
caches the defaults it inherits; both caches were captured while those still pointed into the pack, and
repointing a function does not rewrite them. Without this pass the asset registry went on reporting five
pack textures under the master and one under the instance after every reference in both had been
repointed, and a rerun could not clear it because a rerun has nothing left to change."""
order = (unreal.MaterialFunction, unreal.Material, unreal.MaterialInstanceConstant)
refreshed = 0
for kind in order:
for dest in sorted(set(mapping.values())):
asset = unreal.load_asset(dest)
if not isinstance(asset, kind):
continue
if kind is unreal.MaterialFunction:
unreal.MaterialEditingLibrary.update_material_function(asset)
elif kind is unreal.Material:
unreal.MaterialEditingLibrary.recompile_material(asset)
else:
unreal.MaterialEditingLibrary.update_material_instance(asset)
# only_if_is_dirty=False: the point of the pass is to rewrite the saved package's import table, and
# a recompile does not always mark the package dirty, so the default would skip the one save that
# matters.
asset_lib.save_asset(dest, only_if_is_dirty=False)
refreshed += 1
unreal.log(f"{refreshed} material asset(s) recompiled and saved so their cached references are rebuilt")
def report_ownership(destination, mapping):
"""What Content/Terrain still needs from outside itself, read from the asset registry rather than from what
this script believes it did. A clean report means the packs could be deleted and the ground would still
render; anything listed is a reference that did not get repointed."""
registry = unreal.AssetRegistryHelpers.get_asset_registry()
# Without this the newly copied assets are not in the registry yet and every one of them comes back with no
# dependencies at all - which an earlier version read as "clean" and reported as standing on its own while
# the master material was still calling the pack's functions. An empty answer is unknown, not clean.
registry.scan_paths_synchronous([destination], force_rescan=True)
options = unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
include_hard_package_references=True)
outside, unknown = {}, []
for dest in sorted(set(mapping.values())):
dependencies = registry.get_dependencies(unreal.Name(dest), options)
if dependencies is None:
unknown.append(dest)
continue
leaks = sorted({str(name) for name in dependencies
if str(name).startswith("/Game/") and not str(name).startswith(destination + "/")})
if leaks:
outside[dest] = leaks
if unknown:
unreal.log_warning(f"{len(unknown)} asset(s) are not in the asset registry, so nothing can be said "
f"about what they reference: {', '.join(unknown)}")
if not outside:
verdict = "stands on its own" if not unknown else "has no known references outside itself, but see above"
unreal.log(f"{destination} {verdict}")
return
unreal.log_warning(f"{destination}: {len(outside)} asset(s) still reference something outside it")
for dest, leaks in outside.items():
unreal.log_warning(f" {dest} -> {', '.join(leaks)}")
unreal.log_warning("the packs cannot be deleted while those remain")
def main():
# UE passes its own arguments through sys.argv under -run=pythonscript, so only the flags this script knows
# are read out of it rather than handing the lot to argparse.
argv = sys.argv[1:]
dry_run = "--dry-run" in argv
ground = GROUND_PATH
if "--ground" in argv:
ground = argv[argv.index("--ground") + 1]
destination, entries = load_ground(ground)
unreal.log(f"{len(entries)} ground assets from {os.path.relpath(ground, PROJECT_ROOT)} into {destination}"
+ (" (dry run)" if dry_run else ""))
mapping = copy_assets(entries, dry_run)
repoint_graphs(mapping, dry_run)
repoint_instances(mapping, dry_run)
apply_parameters(entries, dry_run)
if not dry_run:
refresh_all(mapping)
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(False, True)
report_ownership(destination, mapping)
main()
+319
View File
@@ -0,0 +1,319 @@
"""Builds the world level - /Game/Maps/L_World, as Region.json's `level` says (D-72): a world-partitioned
level holding the grid of landscapes that
RawContent/World/Region.json describes, imported from the tiles in RawContent/World/RegionTiles/ and dressed
with Elite_RockyMeadows' kit through rocky_meadows.py. The level is a product of this script, the manifest and
the PNGs, never hand-edited; `--rebuild` throws away what is there and starts again.
Scripts/Authoring/build_region.sh # the whole thing, one batch of tiles at a time
-AllowCommandletRendering matters: the landscapes' render heightmaps come from the edit-layer merge on the GPU,
and a commandlet without it silently skips that, leaving landscapes with collision but no visible surface.
**Build it in batches.** A landscape of a hundred components costs about 1.5 GB of resident memory that is
never given back while the level is open, so a single process asked for all thirty-six reached 14.7 GB by the
ninth and would have run the machine out of commit long before the last. The script is therefore incremental:
it adds the tiles it is told to, saves, and exits, and a later run adds more to the same level. Six at a time
fits comfortably.
... -script=.../create_region_world.py -AllowCommandletRendering -abslog=<abs>/region.log -- --rebuild --tiles 0,0 1,0 2,0 3,0 4,0 5,0
... the same again with --tiles 0,1 1,1 ... and no --rebuild, once per row
`--rebuild` empties the level first and sweeps its stale actor packages afterwards; it belongs on the *first*
batch only. Without it the level is loaded and added to, tiles that are already in it are skipped, and the
sweep is not run — it deletes any actor package that does not belong to a currently loaded actor, which in an
append is every landscape the earlier batches built. Scripts/Authoring/build_region.sh runs the whole sequence.
One landscape per tile, rather than one landscape of 15301 vertices, because 234 M vertices is not an import
the engine will take in one piece. The tiles share their edge vertices and the tile files are bit-identical
along those edges (generate_region_tiles.py samples every vertex from its global position), so the landscapes
meet exactly; nothing here blends or stitches. Each tile is placed by its own centre, which is what the
landscape library takes as its Location.
"""
import os
import shutil
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import heightmap_io # noqa: E402
import rocky_meadows # noqa: E402
from region_manifest import load_manifest # noqa: E402
manifest = load_manifest()
LEVEL_PATH = manifest.level
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
asset_lib = unreal.EditorAssetLibrary
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation)
actor.set_actor_label(label)
return actor
def parse_args():
"""--tiles TX,TY ..., --rebuild and --level, read out of sys.argv because UE puts its own arguments there
too. `--level /Game/Maps/L_Something` builds the same tiles into a level of its own, which is how a
dressing or material change is looked at on four tiles rather than on all ninety-eight."""
global LEVEL_PATH
argv = sys.argv[1:]
rebuild = "--rebuild" in argv
if "--level" in argv:
LEVEL_PATH = argv[argv.index("--level") + 1]
chosen = list(manifest.tiles())
if "--tiles" in argv:
spec = []
for item in argv[argv.index("--tiles") + 1:]:
if item.startswith("-"):
break
spec.append(item)
chosen = []
for item in spec:
tx, ty = (int(part) for part in item.split(","))
if not (0 <= tx < manifest.tiles_x and 0 <= ty < manifest.tiles_y):
raise RuntimeError(f"tile {item} is outside the {manifest.tiles_x}x{manifest.tiles_y} grid")
chosen.append((tx, ty))
return chosen, rebuild
def ensure_tiles(chosen):
"""The chosen tiles' files present and the right size, or generate the missing ones. A tile whose height map
is the wrong resolution means the manifest changed since it was written."""
wanted = manifest.vertices_per_tile
missing = []
for tx, ty in chosen:
if any(not os.path.isfile(f) for f in manifest.tile_files(tx, ty)):
missing.append((tx, ty))
continue
width, height, depth, _ = heightmap_io.read_png_header(manifest.height_path(tx, ty))
if (width, height, depth) != (wanted, wanted, 16):
unreal.log(f"{manifest.tile_name(tx, ty)} is {width}x{height} at {depth} bit, the manifest wants "
f"{wanted} at 16: regenerating it")
missing.append((tx, ty))
if not missing:
return
unreal.log(f"generating {len(missing)} tiles")
import generate_region_tiles
generate_region_tiles.main(["--tiles"] + [f"{tx},{ty}" for tx, ty in missing])
def existing_labels():
"""Actor labels already in the level, including actors world partition has not loaded. The unloaded ones
are read out of the asset registry rather than the world: an append must not create a second landscape for
a tile an earlier batch already built, and in a fresh commandlet almost none of them is loaded."""
labels = {actor.get_actor_label() for actor in actor_subsystem.get_all_level_actors()}
folder = "/Game/__ExternalActors__/" + LEVEL_PATH.replace("/Game/", "", 1)
try:
packages = asset_lib.list_assets(folder, recursive=True, include_folder=False) or []
except Exception as error:
unreal.log_warning(f"could not list {folder} ({error}); only loaded actors will be seen, so an append "
f"may duplicate a landscape")
return labels
for path in packages:
try:
data = asset_lib.find_asset_data(path)
label = data.get_tag_value("ActorLabel") if data else None
except Exception:
label = None
if label:
labels.add(str(label))
return labels
def content_path():
return os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
def external_actor_dir():
return os.path.join(content_path(), "__ExternalActors__", LEVEL_PATH.replace("/Game/", "", 1))
def level_file():
return os.path.join(content_path(), LEVEL_PATH.replace("/Game/", "", 1) + ".umap")
def require_level_writable():
"""Refuse to start if another process holds the .umap, because the save is the *last* thing this script
does and `--rebuild` is the first.
An editor with the level open keeps a Windows write lock on it. The save then fails with `MoveFile ...
(Error Code 32)`, the run raises, and the level has already been emptied: on 2026-09-20 a full rebuild
left twelve of ninety-eight tiles in the level and everything else gone, which reads exactly like a
corrupted world. Checked here, before anything is destroyed, an open editor costs a message instead.
`r+b` is the probe rather than a test save: it asks the operating system the same question a save asks,
without writing a byte. A level that does not exist yet is not locked, and a first build is what creates
it. Close the editor, or load another level in it, and run again."""
path = level_file()
if not os.path.exists(path):
return
try:
open(path, "r+b").close()
except OSError as error:
raise RuntimeError(
f"{path} is locked by another process ({error.__class__.__name__}): an editor with "
f"{LEVEL_PATH} open holds it, and the save at the end of this run would fail after the level "
f"had already been emptied. Close the editor or load another level in it, then run again."
) from error
def prepare_level(rebuild):
"""The level to build into: emptied when rebuilding, loaded and kept when appending.
An existing level is loaded and emptied rather than deleted and recreated:
recreating it resaves the HLOD layer assets, and an open editor that has had the level loaded keeps those
files locked, so the recreation ends in a nameless temp world whose save goes nowhere (see create_world.py,
2026-09-16). Streaming proxies the editor does not load cannot be destroyed here; sweep_stale_actor_packages
removes their files after the save."""
if asset_lib.does_asset_exist(LEVEL_PATH):
if not level_subsystem.load_level(LEVEL_PATH):
raise RuntimeError(f"could not load {LEVEL_PATH}")
if rebuild:
removed = 0
for actor in actor_subsystem.get_all_level_actors():
if actor.get_class().get_name() in KEEP_CLASSES:
continue
actor_subsystem.destroy_actor(actor)
removed += 1
unreal.log(f"{LEVEL_PATH} loaded and emptied: {removed} loaded actors removed")
else:
unreal.log(f"{LEVEL_PATH} loaded to be added to")
else:
folder = external_actor_dir()
if os.path.isdir(folder):
shutil.rmtree(folder, ignore_errors=True)
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 936 km2 must stream
world_path = editor_subsystem.get_editor_world().get_path_name()
if not world_path.startswith(LEVEL_PATH):
raise RuntimeError(f"the editor world is {world_path}, not {LEVEL_PATH}; a save would go nowhere")
def sweep_stale_actor_packages():
"""After the save: every file under the level's external actor folder that belongs to none of the level's
actors is a leftover of an earlier build (a proxy the editor never loaded, so it could not be destroyed).
Left there, it would come back as a second landscape the next time the level loads."""
keep = set()
for actor in actor_subsystem.get_all_level_actors():
package = actor.get_outermost().get_path_name()
keep.add(os.path.normcase(os.path.join(content_path(), package.replace("/Game/", "", 1).replace("/", os.sep) + ".uasset")))
removed, failed = 0, 0
for root, _, files in os.walk(external_actor_dir()):
for name in files:
path = os.path.join(root, name)
if os.path.normcase(path) in keep:
continue
try:
os.remove(path)
removed += 1
except OSError as error:
failed += 1
unreal.log_warning(f"stale actor package {path} could not be removed: {error}")
unreal.log(f"stale actor packages: {removed} removed, {failed} left, {len(keep)} kept")
if failed:
raise RuntimeError(f"{failed} stale actor packages remain under {external_actor_dir()}; the level would load duplicates")
def create_landscapes(chosen, already):
"""One landscape per tile, each centred on its own square of the window. A tile whose landscape is already
in the level is skipped, so a batch can be rerun."""
material = rocky_meadows.load_or_raise(rocky_meadows.LANDSCAPE_MATERIAL)
world = editor_subsystem.get_editor_world()
unreal.log(f"landscapes: {manifest.describe()}")
todo = [(tx, ty) for tx, ty in chosen if manifest.tile_name(tx, ty) not in already]
skipped = len(chosen) - len(todo)
if skipped:
unreal.log(f"{skipped} of {len(chosen)} tiles are already in the level and are left alone")
built = []
for index, (tx, ty) in enumerate(todo, 1):
weightmaps = rocky_meadows.weightmap_entries(
lambda name, tx=tx, ty=ty: manifest.weight_path(tx, ty, name),
[layer.name for layer in manifest.enabled_layers])
cx, cy = manifest.tile_centre_cm(tx, ty)
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
world, manifest.height_path(tx, ty), weightmaps, material,
unreal.Vector(cx, cy, manifest.landscape_z_cm),
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
manifest.streaming_grid_components)
if not landscape:
raise RuntimeError(f"landscape {manifest.tile_name(tx, ty)} failed; see LogSaltyEditor")
landscape.set_actor_label(manifest.tile_name(tx, ty))
built.append(landscape)
unreal.log(f" [{index:2d}/{len(todo)}] {manifest.tile_name(tx, ty)} at "
f"({cx / 100000:+.2f}, {cy / 100000:+.2f}) km")
return built
def pad_height_cm():
"""World Z of the spawn pad, read out of the heightmap rather than traced for it.
A trace is the wrong instrument here and it put the starts 180 m underground. The landscape's collision is
not reliably present in a commandlet, the sea plane at Z 0 *is* - it keeps collision so a walk off the coast
is a walk - so the trace hit the sea and returned 0.0, which is not None and so sailed past the guard meant
to catch exactly this. The heightmap cannot be wrong about its own pad. Which tile the centre falls in, and
where inside it, is worked out rather than assumed: on an odd grid like 14 x 7 the centre is halfway down a
tile rather than on a corner, and a tile's PNG has world X across its columns and world Y down its rows."""
tx, ty, i, j = manifest.centre_vertex()
path = manifest.height_path(tx, ty)
value = int(heightmap_io.read_png(path)[j, i])
metres = manifest.elevation_min_m + value / 65535.0 * manifest.elevation_span_m
unreal.log(f"spawn pad at {metres:.2f} m, from {os.path.basename(path)} at [row {j}, col {i}]")
return metres * 100.0
def ensure_player_starts(already):
"""Two starts on the pad at the centre of the window, so two PIE clients spawn without a warning. Existing
ones are moved rather than left alone, so a rerun repairs a level whose starts are in the wrong place."""
del already # the starts are found by class below; a label in `already` may belong to an unloaded actor
z = pad_height_cm() + 120.0
existing = {actor.get_actor_label(): actor for actor in actor_subsystem.get_all_level_actors()
if actor.get_class().get_name() == "PlayerStart"}
for index, y in enumerate((-200.0, 200.0)):
label = f"World_PlayerStart_{index}"
location = unreal.Vector(0.0, y, z)
actor = existing.get(label)
if actor is None:
spawn(unreal.PlayerStart, label, location)
unreal.log(f" {label} placed at {z / 100:.2f} m")
elif abs(actor.get_actor_location().z - z) > 1.0:
was = actor.get_actor_location().z / 100.0
actor.set_actor_location(location, False, True)
unreal.log(f" {label} moved from {was:.2f} m to {z / 100:.2f} m")
def ensure_dressing(already):
"""The sky kit and the sea, once. Every actor the dressing spawns carries a label, so an append recognises
what is already there by label and does not spawn a second sun."""
if "World_Sun" not in already:
rocky_meadows.dress(spawn, manifest)
if "World_Sea_Proto" not in already:
rocky_meadows.ensure_sea(spawn, manifest)
def main():
chosen, rebuild = parse_args()
unreal.log(f"{len(chosen)} tile(s) requested{', rebuilding the level' if rebuild else ', appending'}")
require_level_writable() # before prepare_level, which is what empties it
ensure_tiles(chosen)
prepare_level(rebuild)
already = set() if rebuild else existing_labels()
landscapes = create_landscapes(chosen, already)
ensure_dressing(already)
ensure_player_starts(already)
if not level_subsystem.save_current_level():
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
if rebuild:
sweep_stale_actor_packages() # an append would sweep away every landscape it did not load
unreal.log(f"{LEVEL_PATH} saved: {len(landscapes)} landscape(s) added this run, "
f"{manifest.tile_side_m / 1000:.2f} km each")
main()
+7 -124
View File
@@ -25,49 +25,11 @@ HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import heightmap_io # noqa: E402
import rocky_meadows # noqa: E402
from world_manifest import LAYER_FILES, load_manifest # noqa: E402
PACK = "/Game/Elite_RockyMeadows"
LANDSCAPE_MATERIAL = f"{PACK}/Materials/M_Landscape_Main_Inst_RockyMeadows02"
LAYER_INFOS = { # paint layer name -> the pack's layer info asset; the weightmap file comes from the manifest module
"Base_Layer": f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo",
"Layer_02": f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo",
"Layer_03": f"{PACK}/Materials/Material_Layers/Layer_03_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"
SEA_MATERIAL = "/Engine/EngineMaterials/WaterMaterial"
# The pack's Rocky_Meadows_01 demo map, as dump_level.py read it. Distances are scaled up where the demo's
# 8 km scene would otherwise cut the effect short on a 14 km 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": 2000000.0, # the demo fades its cloud shadows out at 2 km; keep them to 20 km here
"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}
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),
}
# The pack's kit — its landscape material, layer infos, sun, skybox, fog and grade — is in rocky_meadows.py,
# shared with create_region_world.py so one set of numbers dresses both worlds.
manifest = load_manifest()
LEVEL_PATH = manifest.level
@@ -78,27 +40,6 @@ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
asset_lib = unreal.EditorAssetLibrary
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 ensure_heightmaps():
files = [manifest.heightmap_path] + [manifest.weightmap_path(name) for name in LAYER_FILES]
reason = None
@@ -185,13 +126,8 @@ def sweep_stale_actor_packages():
def create_landscape():
material = load_or_raise(LANDSCAPE_MATERIAL)
weightmaps = []
for layer_name, asset_path in LAYER_INFOS.items():
entry = unreal.LandscapeAuthoringWeightmap()
entry.set_editor_property("layer_info", load_or_raise(asset_path))
entry.set_editor_property("file", manifest.weightmap_path(layer_name))
weightmaps.append(entry)
material = rocky_meadows.load_or_raise(rocky_meadows.LANDSCAPE_MATERIAL)
weightmaps = rocky_meadows.weightmap_entries(manifest.weightmap_path)
world = editor_subsystem.get_editor_world()
unreal.log(f"landscape: {manifest.describe()}")
@@ -217,59 +153,6 @@ def ground_height_at(x, y, fallback):
return fallback
def dress_with_rocky_meadows():
"""The pack's sky, sun, fog and grade, so L_World 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, PACK_FOG)
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 ensure_sea():
"""A flat plane at sea level with the engine's water material: enough to read as sea 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."""
mesh = load_or_raise(SEA_MESH)
material = unreal.load_asset(SEA_MATERIAL) or load_or_raise("/Engine/BasicShapes/BasicShapeMaterial")
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 ensure_player_starts():
# The heightmap has a flat pad at its centre; two starts so two PIE clients spawn without a warning.
fallback = manifest.sea_level_z_cm + 15000.0
@@ -282,8 +165,8 @@ def main():
ensure_heightmaps()
prepare_level()
landscape = create_landscape()
dress_with_rocky_meadows()
ensure_sea()
rocky_meadows.dress(spawn, manifest)
rocky_meadows.ensure_sea(spawn, manifest)
ensure_player_starts()
if not level_subsystem.save_current_level():
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
+215
View File
@@ -0,0 +1,215 @@
"""Imports the world map's art and writes the definition asset the map view reads.
Scripts/Authoring/build_world_map.sh # the whole thing: render the art, then this
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="<abs>/create_world_map.py --dry-run"
Two inputs and nothing else: RawContent/World/MapArt/layers.json (which pictures, how big) and
RawContent/World/Region.json (how big the world is, where its heights sit). The PNGs themselves are written by
Tools/MapArt, which is a Go tool because the engine's Python has no image library that can decode 33 megapixels
of RGB. This script does not render anything; if a layer's PNG is missing it says so and stops rather than
importing a stale one.
Idempotent. A rerun re-imports the textures in place and rewrites the definition, which is what makes changing
a layer a line in layers.json and one command. Nothing is ever deleted: a layer removed from the manifest
leaves its texture behind, unreferenced, for a person to decide about.
The projection is copied out of Region.json rather than typed here, so the map and the landscape can never
disagree about how big the world is - the one bug in a map view that looks like a plausible map.
"""
import os
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from world_map_manifest import load_manifest # noqa: E402
asset_lib = unreal.EditorAssetLibrary
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
DRY_RUN = "--dry-run" in sys.argv
def log(message):
unreal.log(message)
def import_texture(png_path, package, asset_name, wraps_x):
"""One PNG to one UTexture2D, replacing whatever is at that path. Returns the texture, or None."""
asset_path = f"{package}/{asset_name}"
if DRY_RUN:
verb = "would re-import" if asset_lib.does_asset_exist(asset_path) else "would import"
log(f" {verb} {os.path.basename(png_path)} -> {asset_path}")
return None
task = unreal.AssetImportTask()
task.set_editor_property("filename", png_path)
task.set_editor_property("destination_path", package)
task.set_editor_property("destination_name", asset_name)
task.set_editor_property("automated", True)
task.set_editor_property("replace_existing", True)
task.set_editor_property("save", False) # saved once at the end, with the definition
asset_tools.import_asset_tasks([task])
texture = asset_lib.load_asset(asset_path)
if texture is None:
raise RuntimeError(f"{png_path} did not import to {asset_path}")
# Mips matter here more than anywhere: the map is almost always minified - the whole 4096 px world fitted
# into a panel a thousand pixels wide - and an unmipped map shimmers with every pan.
texture.set_editor_property("mip_gen_settings", unreal.TextureMipGenSettings.TMGS_SIMPLE_AVERAGE)
texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_DEFAULT)
texture.set_editor_property("srgb", True)
texture.set_editor_property("filter", unreal.TextureFilter.TF_BILINEAR)
# X wraps when the art is a whole cylinder, so bilinear at the seam samples the other side of the world
# instead of clamping to the edge column. Y never wraps: there is no route across a pole.
texture.set_editor_property("address_x", unreal.TextureAddress.TA_WRAP if wraps_x else unreal.TextureAddress.TA_CLAMP)
texture.set_editor_property("address_y", unreal.TextureAddress.TA_CLAMP)
size = (texture.blueprint_get_size_x(), texture.blueprint_get_size_y())
log(f" {os.path.basename(png_path)} -> {asset_path} {size[0]}x{size[1]}")
return texture
def make_projection(values):
projection = unreal.WorldMapProjection()
projection.set_editor_property("width_m", values["width_m"])
projection.set_editor_property("height_m", values["height_m"])
projection.set_editor_property("centre_m", unreal.Vector2D(values["centre_m"][0], values["centre_m"][1]))
projection.set_editor_property("wraps_x", values["wraps_x"])
projection.set_editor_property("elevation_min_m", values["elevation_min_m"])
projection.set_editor_property("elevation_max_m", values["elevation_max_m"])
projection.set_editor_property("sea_level_m", values["sea_level_m"])
return projection
def ensure_definition(manifest):
"""The definition asset, created if it is not there. Never deleted and recreated: a Blueprint or a level
that already points at it would lose the reference."""
path = f"{manifest.package}/{manifest.definition_name}"
if asset_lib.does_asset_exist(path):
return asset_lib.load_asset(path)
if DRY_RUN:
log(f" would create {path}")
return None
factory = unreal.DataAssetFactory()
factory.set_editor_property("data_asset_class", unreal.WorldMapDefinition)
asset = asset_tools.create_asset(manifest.definition_name, manifest.package, None, factory)
if asset is None:
raise RuntimeError(f"could not create {path}")
log(f" created {path}")
return asset
def verify(definition, manifest, projection, textures):
"""Reads every property back off the asset and refuses to go on if one of them did not take.
This exists because a property set that silently does nothing looks exactly like one that worked, and this
asset has already had that failure once: every property here was EditDefaultsOnly, which Python declines to
write on an instance, and a UDataAsset is an instance. A save is not evidence; the value is.
`level` is read with export_text() rather than str(): an FSoftObjectPath's fields are not UPROPERTYs, so
Python reprs it as an empty struct whether it holds a path or not. str() here reports a correct asset as
broken, which cost an afternoon once and should not cost another.
"""
problems = []
def check(what, got, want):
if got != want:
problems.append(f"{what}: the asset says {got!r}, the manifests say {want!r}")
stored = definition.get_editor_property("projection")
for key in ("width_m", "height_m", "wraps_x", "elevation_min_m", "elevation_max_m", "sea_level_m"):
check(f"projection.{key}", stored.get_editor_property(key), projection[key])
centre = stored.get_editor_property("centre_m")
check("projection.centre_m", (centre.x, centre.y), tuple(projection["centre_m"]))
level = definition.get_editor_property("level")
check("level", level.export_text(), f"{manifest.level}.{manifest.level.rsplit('/', 1)[1]}")
check("default_layer", str(definition.get_editor_property("default_layer")), manifest.default_layer_id)
stored_layers = definition.get_editor_property("layers")
check("layer count", len(stored_layers), len(manifest.layers))
want_address = unreal.TextureAddress.TA_WRAP if projection["wraps_x"] else unreal.TextureAddress.TA_CLAMP
for entry, want in zip(stored_layers, manifest.layers):
check(f"layer[{want.id}].id", str(entry.get_editor_property("id")), want.id)
texture = entry.get_editor_property("texture")
check(f"layer[{want.id}].texture", texture.get_name() if texture else None, want.asset_name)
if texture:
check(f"layer[{want.id}].size",
(texture.blueprint_get_size_x(), texture.blueprint_get_size_y()),
(manifest.output_width, manifest.output_height))
# Compared as the enum, not as a string: str() on a Python enum is "<TextureAddress.TA_WRAP: 0>".
check(f"layer[{want.id}].address_x", texture.get_editor_property("address_x"), want_address)
if problems:
for line in problems:
unreal.log_error(f" {line}")
raise RuntimeError(f"the definition did not take {len(problems)} of its values; nothing saved")
log(f" verified: projection, level, {len(stored_layers)} layer(s), "
f"{'wrapping' if projection['wraps_x'] else 'non-wrapping'} textures")
def main():
manifest = load_manifest()
region = manifest.region()
report = manifest.report()
projection = manifest.projection(region, report)
log(f"world map: {manifest.describe(region)}")
log(f" projection: {projection['width_m'] / 1000:.2f} x {projection['height_m'] / 1000:.2f} km, "
f"elevation {projection['elevation_min_m']:.0f}..{projection['elevation_max_m']:.0f} m, "
f"{'wraps in X' if projection['wraps_x'] else 'does not wrap'}")
if report is None:
log(" (no mapart.json: the art has not been rendered, so the map is assumed not to wrap)")
missing = [layer for layer in manifest.layers if not os.path.exists(manifest.output_path(layer))]
if missing:
names = ", ".join(layer.output_name for layer in missing)
raise RuntimeError(
f"{names} not in {manifest.output_dir}. Render the art first:\n"
f" cd Tools/MapArt && go run . build")
textures = {}
for layer in manifest.layers:
textures[layer.id] = import_texture(
manifest.output_path(layer), manifest.package, layer.asset_name, projection["wraps_x"])
definition = ensure_definition(manifest)
if definition is None:
log("dry run: nothing written")
return
layers = []
for layer in manifest.layers:
entry = unreal.WorldMapLayer()
entry.set_editor_property("id", unreal.Name(layer.id))
entry.set_editor_property("display_name", unreal.Text(layer.name))
entry.set_editor_property("texture", textures[layer.id])
entry.set_editor_property("note", layer.note)
layers.append(entry)
definition.set_editor_property("projection", make_projection(projection))
definition.set_editor_property("level", unreal.SoftObjectPath(f"{manifest.level}.{manifest.level.rsplit('/', 1)[1]}"))
definition.set_editor_property("layers", layers)
definition.set_editor_property("default_layer", unreal.Name(manifest.default_layer_id))
definition.set_editor_property("built_from", (
f"Scripts/Authoring/create_world_map.py from {os.path.relpath(manifest.path, manifest.resolve('.'))} "
f"and {manifest.region_path}; art at {manifest.output_width}x{manifest.output_height}"))
verify(definition, manifest, projection, textures)
saved = [f"{manifest.package}/{manifest.definition_name}"]
saved += [f"{manifest.package}/{layer.asset_name}" for layer in manifest.layers]
for path in saved:
if not asset_lib.save_asset(path, only_if_is_dirty=False):
raise RuntimeError(f"could not save {path}")
log(f"world map saved: {len(manifest.layers)} layer(s), default '{manifest.default_layer_id}', "
f"definition {manifest.package}/{manifest.definition_name}")
main()
+87
View File
@@ -0,0 +1,87 @@
"""Puts the current sea material on a level that already exists, and saves it.
UnrealEditor-Cmd.exe <abs>/Salty.uproject -run=pythonscript \
-script="<abs>/Scripts/Authoring/fix_sea_material.py --level /Game/Maps/L_World" \
-AllowCommandletRendering -unattended -nopause -abslog=<abs>/Saved/Logs/sea.log
Why this is a script of its own rather than a flag on the world builders. `ensure_dressing` spawns the sea
only when the level has none, which is the right rule - a rerun must not leave a second sun behind it - and
the consequence is that changing `rocky_meadows.SEA_GREY` cannot reach a world that already has a sea. The
alternative to this file is rebuilding ninety-eight landscapes to change one material reference, which is
about forty minutes to avoid writing twenty lines.
It changes exactly one thing: the material on every actor labelled `World_Sea_Proto`. The plane's size and
height are a product of the manifest and are already correct, so they are not touched, and neither is
anything else in the level.
The arguments go *inside* the quoted `-script=` value. UPythonScriptCommandlet::Main reads `-Script=` as one
string and hands it to the Python plugin, which splits it into a filename and arguments; anything put after
it on the command line is parsed by the engine and never reaches Python, so a level named the wrong way round
is silently ignored and the default is used instead.
"""
import os
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import rocky_meadows # noqa: E402
DEFAULT_LEVELS = ("/Game/Maps/L_World",)
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
def parse_levels():
"""`--level A --level B`, or every level in DEFAULT_LEVELS. Read out of sys.argv because the engine puts
its own arguments there too."""
argv = sys.argv[1:]
levels = [argv[i + 1] for i, item in enumerate(argv) if item == "--level" and i + 1 < len(argv)]
return levels or list(DEFAULT_LEVELS)
def level_file(level_path):
content = os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
return os.path.join(content, level_path.replace("/Game/", "", 1) + ".umap")
def require_writable(level_path):
"""The same probe create_region_world.py makes, and for the same reason: the save is the last thing this
does, an editor holding the level makes it fail with a sharing violation, and finding that out at the end
is finding it out too late. `r+b` asks the operating system what a save asks and writes nothing."""
path = level_file(level_path)
if not os.path.exists(path):
raise RuntimeError(f"{path} does not exist; build the level before repairing its sea")
try:
open(path, "r+b").close()
except OSError as error:
raise RuntimeError(
f"{path} is locked by another process ({error.__class__.__name__}): an editor with {level_path} "
f"open, or playing it, holds it. Load another level in the editor and run again."
) from error
def main():
levels = parse_levels()
unreal.log(f"sea material: {'grey placeholder' if rocky_meadows.SEA_GREY else 'the engine water material'}")
for level_path in levels:
require_writable(level_path)
for level_path in levels:
if not level_subsystem.load_level(level_path):
raise RuntimeError(f"could not load {level_path}")
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
if not world.get_path_name().startswith(level_path):
raise RuntimeError(f"the editor world is {world.get_path_name()}, not {level_path}; "
f"a save would go nowhere")
changed = rocky_meadows.repair_sea(None)
if not changed:
continue
if not level_subsystem.save_current_level():
raise RuntimeError(f"saving {level_path} failed; see the log above")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
unreal.log(f"{level_path} saved: {changed} sea plane(s) repainted")
main()
+1 -1
View File
@@ -159,7 +159,7 @@ def main(argv=None):
layers = derive_layers(metres, derived, manifest, np.random.default_rng(int(manifest.source.get("seed", 0)) + 1))
os.makedirs(args.out, exist_ok=True)
heightmap_io.write_png(os.path.join(args.out, "L_World_Height.png"), height)
heightmap_io.write_png(os.path.join(args.out, "L_Canvas_Proto_Height.png"), height)
for name, data in layers.items():
heightmap_io.write_png(os.path.join(args.out, LAYER_FILES[name]), data)
for name, data in derived.items():
+453
View File
@@ -0,0 +1,453 @@
"""Cuts the window that RawContent/World/Region.json describes out of a cylindrical planet heightmap and writes
it as a grid of Unreal-ready tiles in RawContent/World/RegionTiles/: a 16-bit height and one 8-bit weightmap
per enabled paint layer, sized for one Landscape actor each.
Pure numpy, no engine: run it with the engine's Python (numpy lives in Scripts/Authoring/.pylib, see
bootstrap-pylib.sh), or let create_region_world.py call it when the tiles are missing.
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_region_tiles.py
... --scout # measure the window and print what it holds, write nothing
... --tiles 2,2 3,2 # just these tiles, for a look before committing to all ninety-eight
... --all-layers # build every paint layer, including the biomes nothing can import yet
Seams. Neighbouring tiles share their edge vertices, and every vertex is sampled from the source by its
*global* position in the window, so the shared column is computed from the same source coordinates twice and
comes out bit-identical. The paint-layer break-up noise is sampled the same way, through fbm_at at global
coordinates, for the same reason. Nothing here is per-tile except which slice of the window it covers, which is
what makes a tile's interior what it would have been had the whole window been done in one piece.
Resampling. The window is the whole 8192-pixel export and the grid is 35701 vertices, so this is a fourfold
*upsample* and the filter shows. It is Catmull-Rom, clamped to the two central taps: a plain cubic overshoots
wherever the source has a step, and the source's steps are its coastlines, where it falls from land to abyss in
a single pixel. Unclamped, every shore would get a raised lip on the land side and a trench on the sea side.
Bilinear would not ring but would crease, leaving a visible facet edge along every source pixel boundary.
Paint layers. Three read the height alone - rock by slope, high rock by altitude, and one layer that is the
remainder - and the rest read a *biome*: a whole-planet mask that `mapart biomes` rendered from the painting's
classes and the Koppen climate, sampled here at the same global coordinates as the height. Which layers exist
is Region.json's `layers.paint`, and a layer that is not enabled is not built (D-74), so the three-layer world
this began as is exactly what an unchanged manifest still produces.
What this does not do. It does not erode, and there is no wear, flow or deposit map here to paint from, which
is why the shore layer is an approximation from height and slope rather than a reading of where the coast pass
actually laid a beach. The source's own detail is about 200 m (Orogen solves on a 204 K-region sphere mesh), so
below that scale the ground is smooth, and it will stay smooth until the tiles come from `terrain tiles`
instead. See Docs/World-Pipeline.md for the routes and Docs/Terrain-Next.md for where the real ground comes from.
"""
import argparse
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import numpy as np # noqa: E402
import heightmap_io # noqa: E402
import heightmap_noise # noqa: E402
from region_manifest import MANIFEST_PATH, TILE_DIR, load_manifest # noqa: E402
# Vertices sampled beyond a tile on every side before the paint layers are derived, and thrown away after.
# The layers read slope, np.gradient takes a one-sided difference at an array edge, and a one-sided difference
# is not what the neighbouring tile computes for the same vertex: without this every tile boundary came out as
# a one-vertex line of different paint. One vertex is all a central difference needs.
LAYER_MARGIN = 1
LAYER_DEFAULTS = {
"rock_slope_start": 0.55, # rise over run where rock starts to show through the meadow (about 29 degrees)
"rock_slope_full": 1.05, # and where it is all rock (about 46 degrees)
"high_altitude_start_m": 1400, # where the high rock layer starts
"high_altitude_full_m": 2000, # and where it has taken over
"breakup_m": 18, # noise added to the altitude before the rules, so boundaries are not contour lines
"breakup_cells": 24, # the break-up noise's coarsest lattice across the whole window
"breakup_seed": 7,
}
# The shore rule. Height alone would run sand up every cliff that meets the sea, so `max_slope` is what makes
# it a beach and not a contour band.
BEACH_DEFAULTS = {
"above_sea_m": 12.0, # full strength up to here
"fade_m": 25.0, # and gone this much above it
"max_slope": 0.18, # rise over run, about 10 degrees
}
def _cubic_weights(t):
"""Catmull-Rom, for taps at -1, 0, +1, +2."""
t2 = t * t
t3 = t2 * t
return (-0.5 * t3 + t2 - 0.5 * t,
1.5 * t3 - 2.5 * t2 + 1.0,
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
0.5 * t3 - 0.5 * t2)
def resample_axis(src, coords, axis, wrap):
"""One separable pass of clamped Catmull-Rom along `axis` at float `coords`. `wrap` takes the index modulo
the axis length, for the source's east-west seam; otherwise it clamps to the edge. The result is held
between the two central taps, which is what stops the filter ringing at a coastline."""
i = np.floor(coords).astype(np.int64)
weights = _cubic_weights((coords - i).astype(np.float32))
length = src.shape[axis]
indices = [i - 1, i, i + 1, i + 2]
indices = [k % length if wrap else np.clip(k, 0, length - 1) for k in indices]
taps = [np.take(src, k, axis=axis) for k in indices]
shape = [1, 1]
shape[axis] = -1
out = sum(tap * weight.reshape(shape) for tap, weight in zip(taps, weights))
return np.clip(out, np.minimum(taps[1], taps[2]), np.maximum(taps[1], taps[2]))
def load_source_metres(manifest):
"""The whole source map in metres, with everything below sea level scaled by `sea_scale`."""
path = manifest.source_path
if not os.path.isfile(path):
raise FileNotFoundError(f"{manifest.path}: source {path} not found")
values = heightmap_io.read_png(path)
low, high = manifest.source_elevation
metres = (low + values.astype(np.float32) / 65535.0 * (high - low)).astype(np.float32)
scale = manifest.sea_scale
if scale != 1.0:
below = metres < 0.0
metres[below] *= scale
return metres
def window_coords(manifest, indices, axis):
"""Global vertex indices along one axis to source-pixel coordinates. The window's first and last pixel
centres land on the window's first and last vertices, so the whole rectangle is used and no edge is
extrapolated. Axis 0 is world X (the source's columns), axis 1 world Y (its rows)."""
_, _, width, height = manifest.source_window
span = (width if axis == 0 else height) - 1
return indices.astype(np.float64) * span / manifest.quads_along(axis)
def tile_vertices(manifest, tile, margin):
"""Global vertex indices along one axis for a tile, with `margin` extra on each side. A margin runs off the
window at the grid's outside edge, which is well defined: the source coordinate simply lands just outside
the window, where the source still has pixels."""
return np.arange(-margin, manifest.vertices_per_tile + margin) + tile * manifest.quads_per_tile
def tile_metres(manifest, source, tx, ty, margin=0):
"""One tile's height in metres, sampled from the source by global position."""
x0, y0, _, _ = manifest.source_window
width = source.shape[1]
sx = x0 + window_coords(manifest, tile_vertices(manifest, tx, margin), 0)
sy = y0 + window_coords(manifest, tile_vertices(manifest, ty, margin), 1)
# Only the source rows this tile reaches, with all columns kept so the x wrap is a plain modulo.
row0 = int(np.floor(sy[0])) - 1
row1 = int(np.floor(sy[-1])) + 3
rows = np.clip(np.arange(row0, row1), 0, source.shape[0] - 1)
band = source[rows]
band = resample_axis(band, sx % width, axis=1, wrap=True)
return resample_axis(band, sy - row0, axis=0, wrap=False).astype(np.float32)
def apply_spawn_pad(manifest, metres, tx, ty, margin=0):
"""The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
computed from global position, so where it crosses a tile boundary the two tiles agree."""
if manifest.spawn_pad_m <= 0:
return metres
quad_m = manifest.quad_cm / 100.0
dx = (tile_vertices(manifest, tx, margin) - manifest.quads_x / 2.0) * quad_m
dy = (tile_vertices(manifest, ty, margin) - manifest.quads_y / 2.0) * quad_m
dist = np.sqrt(dx[None, :] ** 2 + dy[:, None] ** 2)
radius = manifest.spawn_pad_m
weight = heightmap_noise.smoothstep(np.clip(1.0 - (dist - radius) / radius, 0.0, 1.0)).astype(np.float32)
if not weight.any():
return metres
return (metres * (1.0 - weight) + pad_height(manifest) * weight).astype(np.float32)
def pad_height(manifest):
"""Height of the spawn pad, in metres. Read from the source at the window's exact centre rather than from a
tile, so every tile the pad touches lifts to the same level."""
if not hasattr(manifest, "_pad_height"):
raise RuntimeError("pad height not measured; measure_pad_height first")
return manifest._pad_height
def measure_pad_height(manifest, source):
x0, y0, win_w, win_h = manifest.source_window
sx = np.array([x0 + (win_w - 1) / 2.0], dtype=np.float64) % source.shape[1]
sy = np.array([y0 + (win_h - 1) / 2.0], dtype=np.float64)
row0 = int(np.floor(sy[0])) - 1
rows = np.clip(np.arange(row0, row0 + 4), 0, source.shape[0] - 1)
band = resample_axis(source[rows], sx, axis=1, wrap=True)
height = float(resample_axis(band, sy - row0, axis=0, wrap=False)[0, 0])
manifest._pad_height = max(height, manifest.sea_level_m + 30.0) # never a pad in the sea
return manifest._pad_height
def load_biome_masks(manifest, layers):
"""The blurred 0..1 masks `mapart biomes` wrote, one per class- or climate-driven layer.
Read whole and kept in memory: the largest is 7738x3761 of uint8, 29 MB, and every tile samples all of it.
They are 8-bit greyscale, which is the only kind of PNG heightmap_io decodes quickly - the classification
that produced them had to happen in Go because the painting is RGB (D-74)."""
masks = {}
for layer in layers:
if not layer.reads_mask:
continue
path = manifest.mask_path(layer)
if not os.path.isfile(path):
raise FileNotFoundError(
f"paint layer {layer.name!r} reads a {layer.rule} mask and {path} is not there.\n"
f" Render the masks first: cd Tools/MapArt && go run . biomes")
masks[layer.name] = heightmap_io.read_png(path)
return masks
def sample_planet_map(manifest, planet, source_shape, tx, ty, margin=0):
"""A whole-planet map sampled at one tile's vertices, by the same global coordinates the height uses.
`planet` may be any resolution: it is addressed in normalised u,v, which is what lets a 7738-wide painting
and an 8192-wide heightmap describe the same ground without either being resampled to match the other.
Same filter as the height, so a mask's edge lands where the slope under it does, and seam-exact for the
same reason: a shared vertex is computed from the same global coordinates in both tiles."""
src_h, src_w = source_shape
ph, pw = planet.shape
x0, y0, _, _ = manifest.source_window
sx = (x0 + window_coords(manifest, tile_vertices(manifest, tx, margin), 0)) / src_w * pw
sy = (y0 + window_coords(manifest, tile_vertices(manifest, ty, margin), 1)) / src_h * ph
row0 = int(np.floor(sy[0])) - 1
row1 = int(np.floor(sy[-1])) + 3
rows = np.clip(np.arange(row0, row1), 0, ph - 1)
band = planet[rows].astype(np.float32)
band = resample_axis(band, sx % pw, axis=1, wrap=True)
return resample_axis(band, sy - row0, axis=0, wrap=False).astype(np.float32)
def beach_weight(metres, slope, rules):
"""The shore layer: low ground that is also flat. Height alone would put sand up every cliff that happens
to meet the sea, which is most of a rocky coast, so the slope term is what makes it a beach rather than a
contour band. An approximation, and knowingly so - the bake's coast pass knows where beaches actually are
and this route does not carry it (D-74)."""
beach = dict(BEACH_DEFAULTS, **rules.get("beach", {}))
above = float(beach["above_sea_m"])
fade = max(float(beach["fade_m"]), 1e-6)
by_height = np.clip(1.0 - (metres - above) / fade, 0.0, 1.0)
by_height = np.where(metres < 0.0, 0.0, by_height) # underwater is not a beach
by_slope = np.clip(1.0 - slope / max(float(beach["max_slope"]), 1e-6), 0.0, 1.0)
return (heightmap_noise.smoothstep(by_height) * heightmap_noise.smoothstep(by_slope)).astype(np.float32)
def derive_layers(manifest, metres, tx, ty, margin=0, masks=None, source_shape=None, layers=None):
"""Every enabled paint layer's weight for one tile, as uint8 summing to 255.
Three kinds of rule. `slope` and `altitude` read the tile's own height, as they always have, with an fBm
break-up so neither boundary is a contour line. `beach` reads height and slope together. `class` and
`climate` read a whole-planet mask sampled at the same global coordinates as the height.
The composition is a priority, not a blend: rock takes steep ground whatever biome it is in, high rock
takes altitude, the shore takes what is left near the sea, the biomes divide what remains, and one layer
is the remainder and absorbs everything nobody claimed. A layer that is not enabled is simply not in the
competition, so today's three-layer world is exactly what it was before the biomes existed.
`metres` carries `margin` vertices of its neighbours on every side; everything is computed over the lot
and the margin cropped at the end, so the slope at a tile's edge is the central difference its neighbour
computes there too."""
rules = {**LAYER_DEFAULTS, **manifest.layers}
layers = list(layers if layers is not None else manifest.enabled_layers)
quad_m = manifest.quad_cm / 100.0
# Both axes are divided by the *longer* one, so the noise stays square on the ground and, because neither
# coordinate then exceeds 1, it never repeats across the window - fbm_at is periodic with period 1, so
# dividing by anything smaller (a tile, say) would stamp the same pattern out every few kilometres.
span = float(max(manifest.quads_x, manifest.quads_y))
u = (tile_vertices(manifest, tx, margin) / span).astype(np.float32)
v = (tile_vertices(manifest, ty, margin) / span).astype(np.float32)
u, v = np.broadcast_arrays(u[None, :], v[:, None])
rng = np.random.default_rng(int(rules["breakup_seed"]))
noise = heightmap_noise.fbm_at(u, v, rng, base_cells=int(rules["breakup_cells"]), octaves=4)
breakup = (noise - 0.5) * 2.0 * float(rules["breakup_m"])
gy, gx = np.gradient(metres, quad_m)
slope = np.sqrt(gx * gx + gy * gy)
slope_breakup = breakup / float(rules["breakup_m"]) * 0.12 if rules["breakup_m"] else 0.0
rock = heightmap_noise.smoothstep(np.clip(
(slope + slope_breakup - rules["rock_slope_start"])
/ (rules["rock_slope_full"] - rules["rock_slope_start"]), 0.0, 1.0))
high = heightmap_noise.smoothstep(np.clip(
(metres + breakup - rules["high_altitude_start_m"])
/ (rules["high_altitude_full_m"] - rules["high_altitude_start_m"]), 0.0, 1.0))
high = high * (1.0 - rock * 0.5)
weights = {}
for layer in layers:
if layer.rule == "slope":
weights[layer.name] = rock
elif layer.rule == "altitude":
weights[layer.name] = high
# What no rule has claimed yet. Everything below takes its share out of this and never out of thin air,
# which is what keeps the weights summing to one without a final renormalise changing anyone's meaning.
free = np.ones_like(metres)
for value in weights.values():
free = free - value
free = np.clip(free, 0.0, 1.0)
for layer in layers:
if layer.rule == "beach":
beach = beach_weight(metres + breakup, slope, rules) * free
weights[layer.name] = beach
free = np.clip(free - beach, 0.0, 1.0)
# The biome layers divide what is left. They can overlap - a tropical desert is painted desert inside a
# Koppen savanna band - so where they sum past one they are scaled down together rather than one of them
# being picked; that keeps a boundary a blend instead of a decision.
biome_layers = [layer for layer in layers if layer.reads_mask]
if biome_layers:
raw = {}
for layer in biome_layers:
value = sample_planet_map(manifest, masks[layer.name], source_shape, tx, ty, margin) / 255.0
raw[layer.name] = np.clip(value, 0.0, 1.0)
stack = sum(raw.values())
scale = np.where(stack > 1.0, 1.0 / np.maximum(stack, 1e-6), 1.0).astype(np.float32)
for name, value in raw.items():
weights[name] = value * scale * free
free = np.clip(free - sum(weights[name] for name in raw), 0.0, 1.0)
for layer in layers:
if layer.rule == "remainder":
weights[layer.name] = free
total = np.maximum(sum(weights.values()), 1e-6)
inside = slice(margin, metres.shape[0] - margin) if margin else slice(None)
scaled = {name: w[inside, inside] / total[inside, inside] * 255.0 for name, w in weights.items()}
rounded = {name: np.rint(value).astype(np.int32) for name, value in scaled.items()}
# The weights sum to exactly 255 before rounding and to 255 give or take a couple after it, because eight
# layers round independently. The remainder layer absorbs the difference: it is the one whose meaning is
# "whatever is left", so a unit of rounding error belongs to it and to nobody else. Where it is already
# zero there is nothing to take the error out of, which is the one place a tile can still be a unit short.
remainder = next((layer.name for layer in layers if layer.rule == "remainder"), None)
if remainder is not None:
residual = 255 - sum(rounded.values())
rounded[remainder] = np.clip(rounded[remainder] + residual, 0, 255)
return {name: value.astype(np.uint8) for name, value in rounded.items()}
def write_tile(manifest, metres, layers, tx, ty, out_dir):
clipped = float(((metres < manifest.elevation_min_m) | (metres > manifest.elevation_max_m)).mean())
bounded = np.clip(metres, manifest.elevation_min_m, manifest.elevation_max_m)
height = np.rint(manifest.metres_to_value(bounded)).clip(0, 65535).astype(np.uint16)
os.makedirs(out_dir, exist_ok=True)
heightmap_io.write_png(os.path.join(out_dir, os.path.basename(manifest.height_path(tx, ty))), height)
for name, data in layers.items():
heightmap_io.write_png(os.path.join(out_dir, os.path.basename(manifest.weight_path(tx, ty, name))), data)
return clipped
def scout(manifest, source):
"""What the window holds, without writing anything: the numbers that decide whether it is the right window."""
x0, y0, win_w, win_h = manifest.source_window
height, width = source.shape
columns = (np.arange(x0, x0 + win_w) % width)
window = source[y0:y0 + win_h][:, columns]
land = window > manifest.sea_level_m
mx, my = manifest.metres_per_pixel(0), manifest.metres_per_pixel(1)
print(f"window {win_w}x{win_h} px at ({x0}, {y0}) over "
f"{manifest.width_m / 1000:.2f} x {manifest.height_m / 1000:.2f} km of landscape")
print(f" {mx:.4f} m a pixel in X, {my:.4f} in Y"
+ (" -- EQUAL, so the ground is not stretched" if abs(mx - my) < 1e-6 else
f" -- UNEQUAL: the ground is stretched {abs(mx / my - 1) * 100:.1f}% in X against Y; "
f"make columns/rows match the window's width/height"))
if win_w == width and win_h == height:
print(" this is the whole export, not a crop of it")
else:
latitude = (0.5 - (y0 + win_h / 2.0) / height) * 180.0
stretch = 1.0 / np.cos(np.deg2rad(latitude)) - 1.0
print(f" centre latitude {latitude:+.2f} on the source, so a flat reading stretches it "
f"{stretch * 100:.1f}% east-west against the globe it came from")
print(f" land {land.mean() * 100:.2f}% = {land.mean() * manifest.area_km2:.0f} km2 "
f"of {manifest.area_km2:.0f} km2")
print(f" elevation {window.min():.0f}..{window.max():.0f} m "
f"(land median {np.median(window[land]):.0f} m, 99th {np.percentile(window[land], 99):.0f} m)")
outside = float(((window < manifest.elevation_min_m) | (window > manifest.elevation_max_m)).mean())
print(f" {outside * 100:.3f}% of the source window falls outside elevation_m and would clip")
step = np.abs(np.diff(window, axis=1)).max()
print(f" steepest single-pixel step {step:.0f} m over {mx:.1f} m: the filter is clamped so it does not ring")
print(f" {mx:.2f} m a pixel resampled to {manifest.quad_cm / 100:.0f} m quads: "
f"a {mx / (manifest.quad_cm / 100):.1f}x upsample")
print(f" {manifest.tile_count} tiles, {manifest.tile_count * (manifest.quads_per_tile // 255) ** 2} "
f"components, {manifest.vertices_x * manifest.vertices_y / 1e6:.0f} M vertices")
def parse_tiles(spec, manifest):
if not spec:
return list(manifest.tiles())
chosen = []
for item in spec:
tx, ty = (int(part) for part in item.split(","))
if not (0 <= tx < manifest.tiles_x and 0 <= ty < manifest.tiles_y):
raise SystemExit(f"tile {item} is outside the {manifest.tiles_x}x{manifest.tiles_y} grid")
chosen.append((tx, ty))
return chosen
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--manifest", default=MANIFEST_PATH)
parser.add_argument("--out", default=TILE_DIR)
parser.add_argument("--scout", action="store_true", help="measure the window and print what it holds, write nothing")
parser.add_argument("--tiles", nargs="*", metavar="TX,TY", help="only these tiles; all of them by default")
parser.add_argument("--all-layers", action="store_true",
help="build every paint layer, not only the enabled ones. For looking at a biome "
"before there is a substance for it; the level cannot import what the material "
"does not blend, so this is a preview switch and not a build one.")
args = parser.parse_args(argv)
manifest = load_manifest(args.manifest)
print(manifest.describe())
started = time.time()
source = load_source_metres(manifest)
print(f"source {os.path.basename(manifest.source_path)}: {source.shape[1]}x{source.shape[0]}, "
f"{source.min():.0f}..{source.max():.0f} m after sea_scale {manifest.sea_scale:g} "
f"({time.time() - started:.0f} s)")
if args.scout:
scout(manifest, source)
return
measure_pad_height(manifest, source)
paint = manifest.paint_layers if args.all_layers else manifest.enabled_layers
masks = load_biome_masks(manifest, paint)
waiting = [layer.name for layer in manifest.paint_layers if layer not in paint]
print("layers: " + ", ".join(f"{layer.name}({layer.rule})" for layer in paint)
+ (f"; not enabled: {', '.join(waiting)}" if waiting else ""))
if masks:
print(f"biome masks: {', '.join(sorted(masks))} from {manifest.masks_dir} "
f"({manifest.biome_blend_m:g} m blend)")
chosen = parse_tiles(args.tiles, manifest)
lowest, highest, land_total, clipped_worst = 1e9, -1e9, 0.0, 0.0
for index, (tx, ty) in enumerate(chosen, 1):
tile_started = time.time()
margined = tile_metres(manifest, source, tx, ty, LAYER_MARGIN)
margined = apply_spawn_pad(manifest, margined, tx, ty, LAYER_MARGIN)
layers = derive_layers(manifest, margined, tx, ty, LAYER_MARGIN,
masks=masks, source_shape=source.shape, layers=paint)
metres = margined[LAYER_MARGIN:-LAYER_MARGIN, LAYER_MARGIN:-LAYER_MARGIN]
clipped = write_tile(manifest, metres, layers, tx, ty, args.out)
land = float((metres > manifest.sea_level_m).mean())
lowest, highest = min(lowest, float(metres.min())), max(highest, float(metres.max()))
land_total += land
clipped_worst = max(clipped_worst, clipped)
print(f" [{index:2d}/{len(chosen)}] {manifest.tile_name(tx, ty)}: "
f"{metres.min():7.1f}..{metres.max():7.1f} m, {land * 100:5.1f}% land, "
f"{clipped * 100:.3f}% clipped, {time.time() - tile_started:.1f} s")
area = manifest.area_km2 * len(chosen) / manifest.tile_count
print(f"{len(chosen)} tiles: {lowest:.0f}..{highest:.0f} m, "
f"{land_total / len(chosen) * 100:.1f}% land = {land_total / len(chosen) * area:.0f} km2 of {area:.0f} km2, "
f"worst tile {clipped_worst * 100:.3f}% clipped; {time.time() - started:.0f} s; written to {args.out}")
if clipped_worst > 0.001:
print(" clipping above 0.1% means elevation_m is too narrow for this window; widen it and rerun")
if __name__ == "__main__":
main()
+350
View File
@@ -0,0 +1,350 @@
"""The region manifest: RawContent/World/Region.json, which says how a window of a cylindrical planet map
becomes a tiled Unreal landscape. Pure Python (no numpy, no engine), shared by generate_region_tiles.py (writes
the PNGs) and create_region_world.py (imports them), so both agree without either knowing about the other.
Why a second manifest beside World.json. World.json describes one landscape built by the numpy pipeline from
noise; this describes a *window cut from a finished planet map* and laid out as a grid of landscapes, because
2549 km2 at a 2 m quad is 637 M vertices and no single Landscape actor is going to take that in one import. The
two share the height contract (`elevation_m` spans the 16-bit range, world Z 0 is elevation 0 m) and nothing
else.
**The grid is rectangular.** `tiles.columns` run along world X and `tiles.rows` along world Y, because a
cylindrical planet map is 2:1 and forcing it into a square either crops it or stretches it. Set the two so that
columns/rows matches the window's width/height in pixels and the ground is undistorted;
`generate_region_tiles.py --scout` prints the metres-per-pixel of each axis and complains when they disagree.
The window. The source is read as a *flat* image: `source.metres_per_pixel` says what one of its pixels is worth
and `source.window` is a rectangle of pixels in it (the whole image, by default). A cylindrical map read flat is
stretched east-west by 1/cos(latitude), which is a few per cent at middle latitudes and severe near the poles;
reading it flat is deliberate, because unprojecting needs a circumference the map does not carry and every
projection of a window this size distorts more than the flat reading does.
The tiles. `columns` x `rows` landscapes of `tiles.vertices` a side, each its own actor at its own place in one
world-partitioned level. Neighbours *share* their edge vertices: tile (tx, ty) covers global vertices
[tx * quads_per_tile, tx * quads_per_tile + quads_per_tile], so the last column of one tile is the first column
of the next and the seam is closed by construction rather than by blending. Pick `tiles.vertices` as 255 * N + 1
so the engine gives every tile N x N components of 255 quads; see RawContent/World/README.md for why the
component count is what matters.
"""
import json
import os
from world_manifest import ENGINE_SPAN_M_AT_SCALE_100, PROJECT_ROOT, WORLD_DIR
MANIFEST_PATH = os.path.join(WORLD_DIR, "Region.json")
TILE_DIR = os.path.join(WORLD_DIR, "RegionTiles")
# The paint layers a manifest gets when it names none, which is every manifest written before D-74: the three
# Elite_RockyMeadows' landscape material blends. The names mislead and are the pack's - Base_Layer is the rock,
# Layer_02 the meadow grass, Layer_03 the high rock - and they are kept because the material blends by name.
LEGACY_PAINT = (
{"name": "Base_Layer", "rule": "slope"},
{"name": "Layer_03", "rule": "altitude"},
{"name": "Layer_02", "rule": "remainder"},
)
# Still exported: region_overview.py and create_region_world.py imported it before the paint list existed, and
# a manifest with no `paint` block still produces exactly this.
LAYER_SUFFIXES = {entry["name"]: entry["name"] for entry in LEGACY_PAINT}
# Every rule a paint layer may carry. `slope`, `altitude` and `beach` are read off the tile's own height;
# `class` and `climate` are read off a biome mask that Tools/MapArt wrote; `remainder` is whatever no other
# layer claimed, and exactly one layer must be it or the weights do not sum to 255.
PAINT_RULES = ("slope", "altitude", "beach", "class", "climate", "remainder")
class PaintLayer:
"""One paint layer: what the landscape material blends, what drives it, and whether it is built yet.
`enabled` is what lets the biome layers land before the substances do. A layer that is not enabled is
written by nobody and imported by nobody, so today's three-layer world is untouched, but `mapart biomes`
still renders its mask - which is what makes a biome inspectable before there is any material for it.
"""
def __init__(self, data):
self.name = data["name"]
self.rule = data.get("rule", "remainder")
# The file suffix defaults to the layer's name, so L_World_x0_y0_Sand.png needs no second spelling.
self.suffix = data.get("suffix", self.name)
self.enabled = bool(data.get("enabled", True))
self.classes = list(data.get("classes", []))
self.koppen = list(data.get("koppen", []))
self.note = data.get("note", "")
if self.rule not in PAINT_RULES:
raise ValueError(f"paint layer {self.name!r}: unknown rule {self.rule!r}; expected one of {PAINT_RULES}")
if self.rule == "class" and not self.classes:
raise ValueError(f"paint layer {self.name!r}: rule 'class' names no classes")
if self.rule == "climate" and not self.koppen:
raise ValueError(f"paint layer {self.name!r}: rule 'climate' names no Koppen codes")
@property
def reads_mask(self):
"""True when this layer's weight comes from a mask file rather than from the tile's own height."""
return self.rule in ("class", "climate")
def __repr__(self):
return f"PaintLayer({self.name!r}, {self.rule!r}, enabled={self.enabled})"
HEIGHT_SUFFIX = "Height"
MARKS_SUFFIX = "Marks" # reserved for the overlay's 8-bit mark index (D-57). Nothing writes one yet
class RegionManifest:
def __init__(self, data, path=MANIFEST_PATH):
self.path = path
# The one world level. Tile files are named after its last segment, so changing `level` renames every
# tile the manifest expects - rename the PNGs in RegionTiles/ to match or they are all regenerated.
self.level = data.get("level", "/Game/Maps/L_World")
self.quad_cm = float(data["quad_cm"])
tiles = data["tiles"]
# `count` is the old square spelling; columns/rows supersede it.
square = int(tiles["count"]) if "count" in tiles else None
self.tiles_x = int(tiles.get("columns", square))
self.tiles_y = int(tiles.get("rows", square))
self.vertices_per_tile = int(tiles["vertices"])
self.elevation_min_m = float(data["elevation_m"]["min"])
self.elevation_max_m = float(data["elevation_m"]["max"])
self.sea_level_m = float(data.get("sea_level_m", 0.0))
self.spawn_pad_m = float(data.get("spawn_pad_m", 150.0))
self.streaming_grid_components = int(data.get("streaming_grid_components", 4))
self.source = dict(data["source"])
self.overlay = dict(data.get("overlay", {}))
self.layers = dict(data.get("layers", {}))
# The paint layers, in order. A manifest with no `paint` block gets the three the pack's material
# blends, which is what every manifest written before D-74 means.
self.paint_layers = [PaintLayer(entry) for entry in self.layers.get("paint", LEGACY_PAINT)]
names = [layer.name for layer in self.paint_layers]
if len(set(names)) != len(names):
raise ValueError(f"{path}: two paint layers share a name: {names}")
remainders = [layer.name for layer in self.paint_layers if layer.rule == "remainder"]
if len(remainders) != 1:
# Not a warning. With none the weights do not reach 255 and the ground shows the material's first
# layer wherever nothing claimed it; with two they fight over the same leftover.
raise ValueError(f"{path}: exactly one paint layer must have rule 'remainder', found {remainders}")
if not any(layer.enabled for layer in self.paint_layers):
raise ValueError(f"{path}: no paint layer is enabled, so the landscape would have no weightmaps")
# Where the biome masks come from and where Tools/MapArt puts them.
self.biomes = dict(self.layers.get("biomes", {}))
if min(self.tiles_x, self.tiles_y) < 1 or self.vertices_per_tile < 2:
raise ValueError(f"{path}: tiles.columns/rows must be at least 1 and tiles.vertices at least 2")
if self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0:
raise ValueError(f"{path}: quad_cm must be positive and elevation_m ordered")
# Geometry: one tile, then the whole window.
@property
def quads_per_tile(self):
return self.vertices_per_tile - 1
@property
def tile_side_m(self):
return self.quads_per_tile * self.quad_cm / 100.0
@property
def quads_x(self):
return self.quads_per_tile * self.tiles_x
@property
def quads_y(self):
return self.quads_per_tile * self.tiles_y
@property
def vertices_x(self):
"""Distinct vertices across the window in X. Tiles share their edges, so it is not columns * vertices."""
return self.quads_x + 1
@property
def vertices_y(self):
return self.quads_y + 1
@property
def width_m(self):
return self.quads_x * self.quad_cm / 100.0
@property
def height_m(self):
return self.quads_y * self.quad_cm / 100.0
@property
def area_km2(self):
return (self.width_m / 1000.0) * (self.height_m / 1000.0)
@property
def side_m(self):
"""How big this world is, for anything that only needs one number: the sky dome's radius, the sea
plane's size, the fog's density. The longer axis, so those all still cover the whole world.
rocky_meadows.py reads this and World.json's manifest has it too."""
return max(self.width_m, self.height_m)
def centre_vertex(self):
"""(tx, ty, i, j) of the vertex at the centre of the window: which tile it is in and where in that
tile's PNG, columns then rows. On an odd grid the centre is mid-tile rather than on a corner, which is
why this is worked out rather than assumed to be tile (n/2, n/2) at [0, 0]."""
gi, gj = self.quads_x // 2, self.quads_y // 2
tx, i = divmod(gi, self.quads_per_tile)
ty, j = divmod(gj, self.quads_per_tile)
if tx >= self.tiles_x: # the far edge belongs to the last tile's last vertex
tx, i = self.tiles_x - 1, self.quads_per_tile
if ty >= self.tiles_y:
ty, j = self.tiles_y - 1, self.quads_per_tile
return tx, ty, i, j
@property
def tile_count(self):
return self.tiles_x * self.tiles_y
def quads_along(self, axis):
"""Quads across the whole window along axis 0 (world X, the columns) or 1 (world Y, the rows)."""
return self.quads_x if axis == 0 else self.quads_y
def tiles_along(self, axis):
return self.tiles_x if axis == 0 else self.tiles_y
# The height contract, identical to World.json's.
@property
def elevation_span_m(self):
return self.elevation_max_m - self.elevation_min_m
@property
def elevation_mid_m(self):
return (self.elevation_max_m + self.elevation_min_m) / 2.0
@property
def z_scale(self):
"""The landscape actor's Z scale, so the 16-bit range spans exactly the manifest's elevation range."""
return self.elevation_span_m / ENGINE_SPAN_M_AT_SCALE_100 * 100.0
@property
def landscape_z_cm(self):
"""Every tile's Z: value 32768 sits at elevation_mid, so elevation 0 m lands on world Z 0."""
return self.elevation_mid_m * 100.0
@property
def sea_level_z_cm(self):
return self.sea_level_m * 100.0
def metres_to_value(self, metres):
return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0
# The source window.
@property
def source_path(self):
"""The image the tiles are cut from. Not every manifest has one: World Orogen's Unreal export
renders the tiles itself and records where they came from instead of naming a file, so `kind` is
`orogen_render` and there is nothing here to re-cut from. Only generate_region_tiles.py asks for
this, and it is the one thing that cannot run against such a manifest - said plainly here, because
the path into it is `create_region_world.ensure_tiles` noticing a *missing tile file*, and a bare
KeyError three frames down does not explain that the tiles have to come from Orogen again."""
if "path" not in self.source:
raise RuntimeError(
f"{self.path}: source.kind is {self.source.get('kind', 'unset')!r} and names no file, so the "
f"tiles cannot be cut here - they were written by World Orogen's Unreal landscape export. "
f"Re-export them from Orogen into {TILE_DIR}, or point source.path at a planet heightmap to "
f"use generate_region_tiles.py instead."
)
return self.resolve(self.source["path"])
@property
def source_elevation(self):
"""What the source's 0 and 65535 mean in metres. Orogen's heightmap export is a fixed -5000..6000 ramp
whatever the planet, which is why this is a manifest number rather than something read from the file."""
elevation = self.source.get("elevation_m", {"min": -5000.0, "max": 6000.0})
return float(elevation["min"]), float(elevation["max"])
@property
def source_window(self):
"""(x, y, width, height) in source pixels. x is taken modulo the image width, so a window may cross the
map's seam. `size` is the old square spelling."""
window = self.source["window"]
if "size" in window:
return int(window["x"]), int(window["y"]), int(window["size"]), int(window["size"])
return int(window["x"]), int(window["y"]), int(window["width"]), int(window["height"])
@property
def sea_scale(self):
"""Everything below sea level in the source is multiplied by this. The Orogen export puts its abyss at
-5000 m on a fixed ramp built for a whole planet; left alone it would either clip against
`elevation_m.min` or force an elevation range so wide the land loses its precision."""
return float(self.source.get("sea_scale", 1.0))
def metres_per_pixel(self, axis):
"""Ground metres one source pixel is worth along axis 0 (X) or 1 (Y). Derived from the window and the
world, so the two can never drift apart; the manifest's own `metres_per_pixel` is documentation."""
_, _, width, height = self.source_window
return (self.width_m / width) if axis == 0 else (self.height_m / height)
# Files.
def tile_name(self, tx, ty):
return f"{os.path.basename(self.level)}_x{tx}_y{ty}"
def tile_path(self, tx, ty, suffix):
return os.path.join(TILE_DIR, f"{self.tile_name(tx, ty)}_{suffix}.png")
def height_path(self, tx, ty):
return self.tile_path(tx, ty, HEIGHT_SUFFIX)
# The paint layers.
@property
def enabled_layers(self):
"""The layers that are written, imported and blended. The others exist in the manifest and have masks,
and are waiting for a substance (D-74)."""
return [layer for layer in self.paint_layers if layer.enabled]
def find_layer(self, layer_name):
for layer in self.paint_layers:
if layer.name == layer_name:
return layer
raise KeyError(f"{self.path}: no paint layer named {layer_name!r}")
def weight_path(self, tx, ty, layer_name):
return self.tile_path(tx, ty, self.find_layer(layer_name).suffix)
def marks_path(self, tx, ty):
return self.tile_path(tx, ty, MARKS_SUFFIX)
def tile_files(self, tx, ty):
"""Every file a tile needs to exist before it can be imported. Only the enabled layers: a tile is not
missing because a biome nobody has a substance for has no weightmap."""
return ([self.height_path(tx, ty)]
+ [self.weight_path(tx, ty, layer.name) for layer in self.enabled_layers])
# The biome masks, which Tools/MapArt writes and generate_region_tiles.py samples.
@property
def masks_dir(self):
return self.resolve(self.biomes.get("masks_dir", "RawContent/World/Biomes"))
def mask_path(self, layer):
"""The mask for a class- or climate-driven layer. Named after the layer, not after what it reads, so
two layers reading the same class still get one file each."""
return os.path.join(self.masks_dir, f"mask_{layer.name.lower()}.png")
@property
def biome_blend_m(self):
return float(self.biomes.get("blend_m", 400.0))
def tiles(self):
for ty in range(self.tiles_y):
for tx in range(self.tiles_x):
yield tx, ty
# Placement. The landscape library centres a landscape on the Location it is given, so a tile's location is
# its own centre, measured from the window's centre so the whole grid straddles the origin.
def tile_centre_cm(self, tx, ty):
cx = (tx * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_x / 2.0
cy = (ty * self.quads_per_tile + self.quads_per_tile / 2.0) - self.quads_y / 2.0
return cx * self.quad_cm, cy * self.quad_cm
def resolve(self, relative):
"""A manifest path is relative to the project root unless it is absolute."""
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
def describe(self):
return (f"{self.tiles_x}x{self.tiles_y} tiles of {self.vertices_per_tile} vertices at {self.quad_cm:g} cm: "
f"{self.tile_side_m / 1000:.3f} km a tile, {self.width_m / 1000:.2f} x {self.height_m / 1000:.2f} km, "
f"{self.area_km2:.0f} km2; elevation {self.elevation_min_m:g}..{self.elevation_max_m:g} m "
f"(Z scale {self.z_scale:g}, actor Z {self.landscape_z_cm:g} cm)")
def load_manifest(path=MANIFEST_PATH):
with open(path, "r", encoding="utf-8") as f:
return RegionManifest(json.load(f), path)
+218
View File
@@ -0,0 +1,218 @@
"""Draws an overview of the region straight from the tiles, so there is something to navigate by that does not
depend on the editor.
python Scripts/Authoring/region_overview.py # height, shaded
python Scripts/Authoring/region_overview.py --mode paint # the three paint layers as colour
python Scripts/Authoring/region_overview.py --mode both # writes both
Writes RawContent/World/Region_Overview.png (and Region_Paint.png for the paint mode).
**Orientation.** A tile's PNG has world X across its columns and world Y down its rows - verified, not assumed:
tile (0,0)'s last column is tile (1,0)'s first column, and its last row is tile (0,1)'s first row. The mosaic is
therefore laid out rows = ty, columns = tx, with no transpose anywhere, which also means the result is the same
way up as the crop it came from in the source planet map. An earlier version indexed the mosaic rows by tx and
columns by ty, which transposed every tile inside its own square and scrambled the map.
**Scale.** The hypsometric ramp runs to `--top-m`, an absolute ceiling in metres, not to the map's own maximum.
Normalising by the maximum is how a 30 km window whose median ground is 151 m comes out uniformly dark green
with the whole ramp spent on one peak - the same trap Docs record for the generator's preview.png. Every run
prints the ceiling it used.
"""
import argparse
import os
import struct
import sys
import zlib
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import numpy as np # noqa: E402
import heightmap_io # noqa: E402
from region_manifest import MANIFEST_PATH, load_manifest # noqa: E402
# Height ramp, as fractions of the ceiling: water, shore, lowland, upland, rock, snow.
HEIGHT_RAMP = [
(0.00, (58, 84, 120)),
(0.04, (128, 150, 96)),
(0.22, (108, 138, 80)),
(0.45, (150, 142, 96)),
(0.70, (146, 132, 120)),
(0.88, (200, 198, 196)),
(1.00, (248, 248, 250)),
]
# What each paint layer is drawn as. The pack's names mislead: Base_Layer is rock, Layer_02 meadow grass,
# Layer_03 high rock.
PAINT_COLOURS = {
"Base_Layer": (150, 128, 108),
"Layer_02": (96, 138, 74),
"Layer_03": (214, 214, 218),
}
def write_rgb_png(path, rgb):
"""An 8-bit colour PNG; heightmap_io only writes the greyscale the landscape wants."""
height, width, _ = rgb.shape
raw = bytearray()
for row in rgb:
raw.append(0)
raw.extend(row.tobytes())
def chunk(kind, payload):
return (struct.pack(">I", len(payload)) + kind + payload
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF))
with open(path, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
f.write(chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)))
f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 6)))
f.write(chunk(b"IEND", b""))
def ramp_colour(t, ramp):
stops = np.array([s for s, _ in ramp], dtype=np.float32)
cols = np.array([c for _, c in ramp], dtype=np.float32)
out = np.empty(t.shape + (3,), dtype=np.float32)
for channel in range(3):
out[..., channel] = np.interp(t, stops, cols[:, channel])
return out
def mosaic(manifest, per_tile, reader):
"""Every tile laid out rows = ty, columns = tx, which is the orientation the tiles are actually in."""
wide, high = per_tile * manifest.tiles_x, per_tile * manifest.tiles_y
out = None
for tx, ty in manifest.tiles():
block = reader(tx, ty)
if block is None:
continue
step = max(1, block.shape[0] // per_tile)
small = block[::step, ::step][:per_tile, :per_tile]
if out is None:
out = np.zeros((high, wide) + small.shape[2:], dtype=np.float32)
out[ty * per_tile:(ty + 1) * per_tile, tx * per_tile:(tx + 1) * per_tile] = small
return out
def hillshade(metres, metres_per_px):
"""A proper hillshade: the cosine between the surface normal and a light from the north-west, 45 degrees
up. The previous version multiplied by the raw gradient, which clipped to black and white wherever the
ground was steep."""
gy, gx = np.gradient(metres.astype(np.float32), metres_per_px)
nz = 1.0 / np.sqrt(gx * gx + gy * gy + 1.0)
nx, ny = -gx * nz, -gy * nz
lx, ly, lz = -0.5, -0.5, 0.7071
norm = np.sqrt(lx * lx + ly * ly + lz * lz)
shade = (nx * lx + ny * ly + nz * lz) / norm
return np.clip(0.45 + 0.85 * shade, 0.25, 1.35)[..., None]
def draw_line(img, axis, index, colour, width=1):
lo = max(0, index - width // 2)
hi = min(img.shape[axis], lo + width)
if lo >= hi:
return
if axis == 0:
img[lo:hi, :, :] = colour
else:
img[:, lo:hi, :] = colour
def decorate(img, manifest, per_tile):
high, wide = img.shape[0], img.shape[1]
px_per_km_x = wide / (manifest.width_m / 1000.0)
px_per_km_y = high / (manifest.height_m / 1000.0)
for km in range(1, int(manifest.width_m / 1000.0) + 1):
draw_line(img, 1, int(km * px_per_km_x), (110, 110, 120))
for km in range(1, int(manifest.height_m / 1000.0) + 1):
draw_line(img, 0, int(km * px_per_km_y), (110, 110, 120))
for i in range(manifest.tiles_x + 1):
draw_line(img, 1, min(wide - 1, i * per_tile), (250, 214, 84), 2)
for j in range(manifest.tiles_y + 1):
draw_line(img, 0, min(high - 1, j * per_tile), (250, 214, 84), 2)
draw_line(img, 1, wide // 2, (255, 86, 86), 3)
draw_line(img, 0, high // 2, (255, 86, 86), 3)
def read_metres(manifest, tx, ty):
path = manifest.height_path(tx, ty)
if not os.path.isfile(path):
return None
v = heightmap_io.read_png(path).astype(np.float32)
return manifest.elevation_min_m + v / 65535.0 * manifest.elevation_span_m
def colour_height(manifest, metres, land, top_m):
img = ramp_colour(np.clip(metres / max(top_m, 1e-6), 0.0, 1.0), HEIGHT_RAMP)
img[~land] = (46, 62, 88)
return np.clip(img * hillshade(metres, manifest.width_m / img.shape[1]), 0, 255)
def paint_map(manifest, per_tile):
"""The three weightmaps composited by weight: what the landscape is painted with, whatever the material
ends up rendering."""
layers = {}
for name in (layer.name for layer in manifest.enabled_layers):
def read(tx, ty, name=name):
path = manifest.weight_path(tx, ty, name)
if not os.path.isfile(path):
return None
return heightmap_io.read_png(path).astype(np.float32) / 255.0
layers[name] = mosaic(manifest, per_tile, read)
total = np.zeros_like(next(iter(layers.values())))
img = np.zeros(total.shape + (3,), dtype=np.float32)
for name, weight in layers.items():
img += weight[..., None] * np.array(PAINT_COLOURS[name], dtype=np.float32)
total += weight
img /= np.maximum(total, 1e-6)[..., None]
return np.clip(img, 0, 255), {k: float(v.mean()) for k, v in layers.items()}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--manifest", default=MANIFEST_PATH)
parser.add_argument("--pixels", type=int, default=2048)
parser.add_argument("--mode", choices=("height", "paint", "both"), default="height")
parser.add_argument("--top-m", type=float, help="ceiling of the height ramp in metres; default is the "
"99th percentile of the land, printed either way")
args = parser.parse_args(argv)
manifest = load_manifest(args.manifest)
folder = os.path.dirname(manifest.path)
per_tile = max(1, args.pixels // max(manifest.tiles_x, manifest.tiles_y))
wide, high = per_tile * manifest.tiles_x, per_tile * manifest.tiles_y
hx, hy = manifest.width_m / 2.0, manifest.height_m / 2.0
print(f"{wide}x{high} px, {manifest.width_m / 1000:.2f} x {manifest.height_m / 1000:.2f} km, "
f"{wide / manifest.width_m * 1000:.1f} px/km")
print(f" columns are world X {-hx:+.0f}..{+hx:+.0f} m, rows are world Y {-hy:+.0f}..{+hy:+.0f} m")
print(f" world X cm = col * {manifest.width_m * 100 / wide:.2f} - {hx * 100:.0f}, "
f"Y cm = row * {manifest.height_m * 100 / high:.2f} - {hy * 100:.0f}")
print(" yellow lines are tile edges, grey is a kilometre, red is the origin")
if args.mode in ("height", "both"):
metres = mosaic(manifest, per_tile, lambda tx, ty: read_metres(manifest, tx, ty))
land = metres > manifest.sea_level_m
top = args.top_m if args.top_m else float(np.percentile(metres[land], 99.0))
img = colour_height(manifest, metres, land, top)
decorate(img, manifest, per_tile)
out = os.path.join(folder, "Region_Overview.png")
write_rgb_png(out, np.ascontiguousarray(np.rint(img).astype(np.uint8)))
print(f" height -> {out}")
print(f" ramp ceiling {top:.0f} m; land {land.mean() * 100:.1f}%, "
f"height {metres[land].min():.0f}..{metres.max():.0f} m, median {np.median(metres[land]):.0f} m")
if args.mode in ("paint", "both"):
img, means = paint_map(manifest, per_tile)
decorate(img, manifest, per_tile)
out = os.path.join(folder, "Region_Paint.png")
write_rgb_png(out, np.ascontiguousarray(np.rint(img).astype(np.uint8)))
print(f" paint -> {out}")
for name, mean in means.items():
print(f" {name:11s} {mean * 100:5.1f}% drawn as {PAINT_COLOURS[name]}")
if __name__ == "__main__":
main()
+270
View File
@@ -0,0 +1,270 @@
"""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)
+21 -10
View File
@@ -1,8 +1,14 @@
"""The world manifest: RawContent/World/World.json, the one place that says how big L_World is, what a
"""The canvas manifest: RawContent/World/World.json, the one place that says how big L_Canvas_Proto is, what a
heightmap value means in metres, and where the height comes from. Pure Python (no numpy, no engine), shared by
generate_heightmap.py (writes the PNGs) and create_world.py (imports them), so both agree without either
knowing about the other.
**This is not the world.** `L_World` is the planet-map region in Region.json (D-72); this pipeline is the numpy
square canvas, legacy by D-47 and kept only because it is still the one path that carries the erosion pass's
flow, wear and deposit maps into Unreal. The level name is deliberately not the real world's: create_world.py
empties whatever level it is handed, so a manifest pointing at L_World would replace ninety-eight landscapes
with a 14 km square on one run.
The height contract. The landscape is `vertices_per_side` vertices a side at `quad_cm` a quad. The 16-bit
heightmap spans `elevation_m.min` (value 0) to `elevation_m.max` (value 65535), and the level places the
landscape so that world Z 0 is elevation 0 m: sea level, when `sea_level_m` is 0. From that the engine's
@@ -22,22 +28,25 @@ WORLD_DIR = os.path.join(PROJECT_ROOT, "RawContent", "World")
MANIFEST_PATH = os.path.join(WORLD_DIR, "World.json")
HEIGHTMAP_DIR = os.path.join(WORLD_DIR, "Heightmaps")
HEIGHTMAP_FILE = "L_World_Height.png"
# Named for the level this pipeline builds, which is L_Canvas_Proto and not L_World (D-72). The region
# tiles in RegionTiles/ are L_World_x0_y0_Height.png and the like; two files a folder apart differing only
# in a tile suffix is a confusion worth one rename.
HEIGHTMAP_FILE = "L_Canvas_Proto_Height.png"
# Paint layer name (as the Elite_RockyMeadows landscape material calls it) -> weightmap file. The names mislead:
# in this pack Base_Layer is the rock, Layer_02 the grass (the meadow) and Layer_03 the high rock.
LAYER_FILES = {
"Base_Layer": "L_World_Base_Layer.png",
"Layer_02": "L_World_Layer_02.png",
"Layer_03": "L_World_Layer_03.png",
"Base_Layer": "L_Canvas_Proto_Base_Layer.png",
"Layer_02": "L_Canvas_Proto_Layer_02.png",
"Layer_03": "L_Canvas_Proto_Layer_03.png",
}
# Derivative maps the erosion pass leaves behind, 8-bit, for painting and for a material that wants them later:
# how much water passed (log scaled), how much bedrock was scraped, how much sediment was laid down, and the
# curvature (128 flat, brighter convex, darker concave).
DERIVED_FILES = {
"flow": "L_World_Flow.png",
"wear": "L_World_Wear.png",
"deposit": "L_World_Deposit.png",
"curvature": "L_World_Curvature.png",
"flow": "L_Canvas_Proto_Flow.png",
"wear": "L_Canvas_Proto_Wear.png",
"deposit": "L_Canvas_Proto_Deposit.png",
"curvature": "L_Canvas_Proto_Curvature.png",
}
# The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
@@ -47,7 +56,9 @@ ENGINE_SPAN_M_AT_SCALE_100 = 512.0
class WorldManifest:
def __init__(self, data, path=MANIFEST_PATH):
self.path = path
self.level = data.get("level", "/Game/Maps/L_World")
# Not L_World: that is the region's now. A manifest with no `level` must not default to the world the
# game uses, because create_world.py empties whatever level it is handed before rebuilding it.
self.level = data.get("level", "/Game/Maps/L_Canvas_Proto")
self.vertices_per_side = int(data["vertices_per_side"])
self.quad_cm = float(data["quad_cm"])
self.elevation_min_m = float(data["elevation_m"]["min"])
+128
View File
@@ -0,0 +1,128 @@
"""The world map manifest: RawContent/World/MapArt/layers.json, which says which planet-wide images become the
layers of the map view. Pure Python (no numpy, no engine), shared by create_world_map.py and anything else that
needs to know where the art is.
Why a third manifest. World.json describes the numpy pipeline's square canvas and Region.json describes the
window of a planet map that becomes L_World's landscapes. This describes a *picture* of that same window, which
is a different thing again: it has a resolution rather than a vertex count, it carries no elevation contract of
its own, and adding a layer to it changes nothing about the ground. It reads Region.json for the geometry so
the map and the landscape can never disagree about how big the world is.
"""
import json
import os
from region_manifest import MANIFEST_PATH as REGION_PATH, load_manifest as load_region
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
MAPART_DIR = os.path.join(PROJECT_ROOT, "RawContent", "World", "MapArt")
MANIFEST_PATH = os.path.join(MAPART_DIR, "layers.json")
REPORT_PATH = os.path.join(MAPART_DIR, "mapart.json")
class Layer:
def __init__(self, data):
self.id = data["id"]
self.name = data.get("name", data["id"])
self.file = data["file"]
self.render = data.get("render", "copy")
self.is_default = bool(data.get("default", False))
self.note = data.get("note", "")
@property
def output_name(self):
"""What Tools/MapArt writes. Named after the layer rather than the source, because the source is an
Orogen export whose filename carries an export number nobody chose."""
return f"map_{self.id}.png"
@property
def asset_name(self):
return f"T_WorldMap_{self.id[:1].upper()}{self.id[1:]}"
class WorldMapManifest:
def __init__(self, data, path=MANIFEST_PATH):
self.path = path
self.source_dir = data.get("source_dir", "RawContent/World/Orogen Gens")
self.output_dir = data.get("output_dir", "RawContent/World/MapArt")
self.region_path = data.get("region", "RawContent/World/Region.json")
output = data.get("output", {})
self.output_width = int(output.get("width", 4096))
self.output_height = int(output.get("height", 2048))
self.package = data.get("package", "/Game/World/Maps").rstrip("/")
self.definition_name = data.get("definition", "DA_WorldMap_L_World")
self.level = data.get("level", "/Game/Maps/L_World")
self.layers = [Layer(entry) for entry in data["layers"]]
# None means "work it out from the window": see wraps_x below.
self.wraps_x_override = data.get("wraps_x", None)
if not self.layers:
raise ValueError(f"{path}: no layers, so there is no map to build")
ids = [layer.id for layer in self.layers]
if len(set(ids)) != len(ids):
raise ValueError(f"{path}: two layers share an id: {ids}")
@property
def default_layer_id(self):
for layer in self.layers:
if layer.is_default:
return layer.id
return self.layers[0].id
def resolve(self, relative):
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
def output_path(self, layer):
return os.path.join(self.resolve(self.output_dir), layer.output_name)
def region(self):
return load_region(self.resolve(self.region_path))
def report(self):
"""What the last Tools/MapArt run wrote, or None if it has not run. Carries each source's own size,
which is the one number needed to tell a whole cylinder from a crop of one."""
if not os.path.exists(REPORT_PATH):
return None
with open(REPORT_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def wraps_x(self, region, report):
"""Does the map's left edge join its right? Only when the window is the whole width of the source: a
crop of a cylinder has two edges, and panning across one of those would jump halfway round the world.
Explicit in the manifest when `wraps_x` is set; otherwise the window is compared with the source's own
width, which only the mapart report knows - a PNG's size is not in any manifest and should not be.
"""
if self.wraps_x_override is not None:
return bool(self.wraps_x_override)
x0, _, width, _ = region.source_window
if report is None:
# No report means the art has not been built, and guessing "it wraps" would put a seam artefact in
# the middle of a crop. Say no; a rerun of Tools/MapArt settles it properly.
return False
widths = {entry.get("source_width") for entry in report.get("layers", [])}
return x0 == 0 and len(widths) == 1 and width in widths
def projection(self, region, report):
"""The numbers FWorldMapProjection wants, all derived from Region.json so they cannot drift from the
ground. The centre is the world origin because region_manifest.tile_centre_cm lays the grid out
straddling it; if that ever changes this is the other place that has to."""
return {
"width_m": region.width_m,
"height_m": region.height_m,
"centre_m": (0.0, 0.0),
"wraps_x": self.wraps_x(region, report),
"elevation_min_m": region.elevation_min_m,
"elevation_max_m": region.elevation_max_m,
"sea_level_m": region.sea_level_m,
}
def describe(self, region):
return (f"{len(self.layers)} layers at {self.output_width}x{self.output_height} over "
f"{region.width_m / 1000:.2f} x {region.height_m / 1000:.2f} km "
f"({region.width_m / self.output_width:.2f} m a pixel)")
def load_manifest(path=MANIFEST_PATH):
with open(path, "r", encoding="utf-8") as f:
return WorldMapManifest(json.load(f), path)