This commit is contained in:
Vysyr committed 2026-09-05 02:57:26 +02:00
1 parent dda27af15c
commit 2a4da34fe9
3 files changed
+201 -19

No files matched your search

+126 -19
View File
@@ -9,10 +9,16 @@ Reads terrain_config.json (next to this script) and:
Run import_environment_assets.py first so the mesh paths referenced in Run import_environment_assets.py first so the mesh paths referenced in
terrain_config.json actually exist. terrain_config.json actually exist.
Set the TERRAIN_CONFIG env var to point at a different config file, e.g. to
run terrain_config_variants_demo.json without touching the default config.
""" """
import os, json, math, random, unreal import os, json, math, random, unreal
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config.json") CONFIG_PATH = os.environ.get(
"TERRAIN_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config.json"),
)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools() asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
@@ -125,19 +131,33 @@ def build_terrain_mesh(cfg, field):
dest_path = cfg["dest_path"] dest_path = cfg["dest_path"]
asset_name = cfg["asset_name"] asset_name = cfg["asset_name"]
if unreal.EditorAssetLibrary.does_asset_exist(f"{dest_path}/{asset_name}"): asset_path = f"{dest_path}/{asset_name}"
unreal.EditorAssetLibrary.delete_asset(f"{dest_path}/{asset_name}") if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
static_mesh = asset_tools.create_asset(asset_name, dest_path, unreal.StaticMesh, unreal.StaticMeshFactoryNew()) unreal.EditorAssetLibrary.delete_asset(asset_path)
copy_options = unreal.GeometryScriptCopyMeshToAssetOptions() create_options = unreal.GeometryScriptCreateNewStaticMeshAssetOptions()
target_lod = unreal.GeometryScriptMeshWriteLOD() static_mesh, _outcome = unreal.GeometryScript_NewAssetUtils.create_new_static_mesh_asset_from_mesh(
unreal.GeometryScript_AssetUtils.copy_mesh_to_static_mesh(mesh, static_mesh, copy_options, target_lod) mesh, asset_path, create_options,
)
unreal.EditorAssetLibrary.save_asset(static_mesh.get_path_name()) unreal.EditorAssetLibrary.save_asset(static_mesh.get_path_name())
bounds = (min_x, max_x, min_y, max_y) bounds = (min_x, max_x, min_y, max_y)
return static_mesh, bounds return static_mesh, bounds
_mesh_base_z_cache = {}
def mesh_base_z(mesh):
"""Local-space Z of the mesh's lowest point, so we can plant its base on the
ground instead of its (often bounding-box-centered) pivot."""
path = mesh.get_path_name()
if path not in _mesh_base_z_cache:
box = mesh.get_bounding_box()
_mesh_base_z_cache[path] = box.min.z
return _mesh_base_z_cache[path]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Foliage scatter -------------------------------------------------------------- # Foliage scatter --------------------------------------------------------------
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -178,7 +198,11 @@ def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
placed_points.append((x, y)) placed_points.append((x, y))
mesh = rng.choice(meshes) mesh = rng.choice(meshes)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z) scale = rng.uniform(layer_cfg["min_scale"], layer_cfg["max_scale"])
# Shift up by -min_z*scale so the mesh's lowest vertex sits on the ground,
# regardless of where the source asset's pivot was authored.
ground_offset = -mesh_base_z(mesh) * scale
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z + ground_offset)
yaw = rng.uniform(0.0, 360.0) yaw = rng.uniform(0.0, 360.0)
if layer_cfg.get("align_to_normal", False): if layer_cfg.get("align_to_normal", False):
# Approximate tilt from the surface normal; good enough for gentle slopes. # Approximate tilt from the surface normal; good enough for gentle slopes.
@@ -189,7 +213,6 @@ def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
rotation = unreal.Rotator(roll=0.0, pitch=0.0, yaw=yaw) rotation = unreal.Rotator(roll=0.0, pitch=0.0, yaw=yaw)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation) actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
scale = rng.uniform(layer_cfg["min_scale"], layer_cfg["max_scale"])
actor.set_actor_scale3d(unreal.Vector(scale, scale, scale)) actor.set_actor_scale3d(unreal.Vector(scale, scale, scale))
actor.set_folder_path(f"{base_folder}/{layer_cfg['name']}") actor.set_folder_path(f"{base_folder}/{layer_cfg['name']}")
actor.set_actor_label(f"{layer_cfg['name']}_{spawned:04d}") actor.set_actor_label(f"{layer_cfg['name']}_{spawned:04d}")
@@ -199,28 +222,112 @@ def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
return spawned return spawned
# --------------------------------------------------------------------------- def place_explicit(entries, field, origin, base_folder):
def main(): """Places an exact, hand-authored (or externally-generated) list of instances.
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
terrain_cfg = config["terrain"] Unlike scatter_layer's density/slope rules, this reproduces a literal
manifest byte-for-byte: the same JSON always yields the same named
landmarks at the same coordinates - useful for hero props, quest markers,
or a layout exported from an external design tool, none of which a
rule-driven scatter (ours or a graph-based tool like PCG) is meant to pin down.
"""
placed = 0
for entry in entries:
mesh = unreal.load_asset(entry["mesh"])
if mesh is None:
print(f" [explicit] missing mesh asset: {entry['mesh']}")
continue
x, y, z_offset = entry.get("location", [0.0, 0.0, 0.0])
scale = entry.get("scale", 1.0)
if isinstance(scale, list):
scale_vec = unreal.Vector(*scale)
else:
scale_vec = unreal.Vector(scale, scale, scale)
z = z_offset
if entry.get("snap_to_terrain", True):
z += field.height(x, y) - mesh_base_z(mesh) * scale_vec.z
roll, pitch, yaw = entry.get("rotation", [0.0, 0.0, 0.0])
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z)
rotation = unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/Explicit")
actor.set_actor_label(entry.get("name", f"Explicit_{placed:04d}"))
placed += 1
print(f" [explicit] placed {placed}/{len(entries)}")
return placed
def generate_tile(config, terrain_cfg, tile_label):
"""Builds one terrain + its scatter layers + its explicit placements, all
under a dedicated Outliner folder so multiple tiles can coexist in one level.
"""
field = FractalHeightField(terrain_cfg["noise"], terrain_cfg["height_scale"]) field = FractalHeightField(terrain_cfg["noise"], terrain_cfg["height_scale"])
base_folder = f"ProceduralTerrain/{tile_label}"
print("Building terrain mesh...") print(f"[{tile_label}] building terrain mesh (seed={terrain_cfg['noise']['seed']})...")
static_mesh, bounds = build_terrain_mesh(terrain_cfg, field) static_mesh, bounds = build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"] loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2]) origin = unreal.Vector(loc[0], loc[1], loc[2])
terrain_actor = actor_subsystem.spawn_actor_from_object(static_mesh, origin, unreal.Rotator(0, 0, 0)) terrain_actor = actor_subsystem.spawn_actor_from_object(static_mesh, origin, unreal.Rotator(0, 0, 0))
terrain_actor.set_folder_path("ProceduralTerrain") terrain_actor.set_folder_path(base_folder)
terrain_actor.set_actor_label("ProceduralTerrain") terrain_actor.set_actor_label(f"Terrain_{tile_label}")
print("Scattering foliage layers...")
for layer_cfg in config.get("scatter_layers", []): for layer_cfg in config.get("scatter_layers", []):
scatter_layer(layer_cfg, field, bounds, origin, "ProceduralTerrain") scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
place_explicit(explicit_entries, field, origin, base_folder)
def main():
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
base_terrain_cfg = config["terrain"]
variants = config.get("variants")
if not variants:
# Single-tile mode: unchanged behavior for existing configs.
generate_tile(config, base_terrain_cfg, "Main")
print("Done.") print("Done.")
return
# Variant mode: one JSON list of seeds becomes N independent, deterministic
# tiles laid out side by side in a single run - each a full reproducible
# world driven by nothing but that seed. There's no "batch-produce N baked,
# comparable layouts from one data file" workflow in the Foliage tool or a
# PCG graph; you'd re-author or re-seed each one by hand, one at a time.
gap = base_terrain_cfg["size_x"] * 1.2
base_seed = base_terrain_cfg["noise"]["seed"]
base_layer_seeds = {layer["name"]: layer.get("seed", 0) for layer in config.get("scatter_layers", [])}
for i, seed_offset in enumerate(variants):
tile_label = f"Variant_{seed_offset}"
terrain_cfg = json.loads(json.dumps(base_terrain_cfg)) # cheap deep copy
terrain_cfg["noise"]["seed"] = base_seed + seed_offset
terrain_cfg["asset_name"] = f"{base_terrain_cfg['asset_name']}_{tile_label}"
terrain_cfg["location"] = [
base_terrain_cfg["location"][0] + i * gap,
base_terrain_cfg["location"][1],
base_terrain_cfg["location"][2],
]
tile_config = json.loads(json.dumps(config))
tile_config["terrain"] = terrain_cfg
for layer_cfg in tile_config.get("scatter_layers", []):
layer_cfg["seed"] = base_layer_seeds.get(layer_cfg["name"], 0) + seed_offset
generate_tile(tile_config, terrain_cfg, tile_label)
print(f"Done: {len(variants)} variant(s) generated side by side.")
main() main()
+18
View File
@@ -51,5 +51,23 @@
"min_spacing": 150.0, "min_spacing": 150.0,
"seed": 42 "seed": 42
} }
],
"explicit_placements": [
{
"name": "LandmarkTree_Overlook",
"mesh": "/Game/ImportedAssets/Trees/SM_tree01",
"location": [1200.0, 800.0, 0.0],
"snap_to_terrain": true,
"rotation": [0.0, 0.0, 30.0],
"scale": 2.5
},
{
"name": "RockCairn_Waypoint",
"mesh": "/Game/ImportedAssets/Rocks/SM_rock2",
"location": [-1500.0, -600.0, 0.0],
"snap_to_terrain": true,
"rotation": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.8]
}
] ]
} }
@@ -0,0 +1,57 @@
{
"_comment": "Presentation demo: run with TERRAIN_CONFIG env var pointed at this file. One 'variants' list turns this single config into 4 independent, deterministic worlds laid out side by side in one run - no hand re-authoring or re-painting per tile.",
"terrain": {
"location": [0.0, 0.0, 0.0],
"size_x": 3000.0,
"size_y": 3000.0,
"resolution": 48,
"height_scale": 500.0,
"dest_path": "/Game/ImportedAssets/Terrain",
"asset_name": "SM_TerrainDemo",
"noise": {
"seed": 1000,
"octaves": 5,
"frequency": 0.0006,
"lacunarity": 2.0,
"gain": 0.5
}
},
"scatter_layers": [
{
"name": "Trees",
"meshes": [
"/Game/ImportedAssets/Trees/SM_tree01",
"/Game/ImportedAssets/Trees/SM_tree02",
"/Game/ImportedAssets/Trees/SM_tree03",
"/Game/ImportedAssets/Trees/SM_tree04"
],
"count": 60,
"min_height": -50.0,
"max_height": 300.0,
"max_slope_degrees": 25.0,
"min_scale": 0.85,
"max_scale": 1.3,
"align_to_normal": true,
"min_spacing": 200.0,
"seed": 7
},
{
"name": "Rocks",
"meshes": [
"/Game/ImportedAssets/Rocks/SM_rock1",
"/Game/ImportedAssets/Rocks/SM_rock2",
"/Game/ImportedAssets/Rocks/SM_rock3"
],
"count": 30,
"min_height": -100.0,
"max_height": 500.0,
"max_slope_degrees": 45.0,
"min_scale": 0.5,
"max_scale": 2.0,
"align_to_normal": true,
"min_spacing": 150.0,
"seed": 42
}
],
"variants": [0, 1, 2, 3]
}