Tooling
This commit is contained in:
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user