sync
This commit is contained in:
1 parent
dda27af15c
commit
2a4da34fe9
3 files changed
+202
-20
No files matched your search
@@ -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
|
||||
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
|
||||
|
||||
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()
|
||||
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
@@ -125,19 +131,33 @@ def build_terrain_mesh(cfg, field):
|
||||
|
||||
dest_path = cfg["dest_path"]
|
||||
asset_name = cfg["asset_name"]
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(f"{dest_path}/{asset_name}"):
|
||||
unreal.EditorAssetLibrary.delete_asset(f"{dest_path}/{asset_name}")
|
||||
static_mesh = asset_tools.create_asset(asset_name, dest_path, unreal.StaticMesh, unreal.StaticMeshFactoryNew())
|
||||
asset_path = f"{dest_path}/{asset_name}"
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
unreal.EditorAssetLibrary.delete_asset(asset_path)
|
||||
|
||||
copy_options = unreal.GeometryScriptCopyMeshToAssetOptions()
|
||||
target_lod = unreal.GeometryScriptMeshWriteLOD()
|
||||
unreal.GeometryScript_AssetUtils.copy_mesh_to_static_mesh(mesh, static_mesh, copy_options, target_lod)
|
||||
create_options = unreal.GeometryScriptCreateNewStaticMeshAssetOptions()
|
||||
static_mesh, _outcome = unreal.GeometryScript_NewAssetUtils.create_new_static_mesh_asset_from_mesh(
|
||||
mesh, asset_path, create_options,
|
||||
)
|
||||
unreal.EditorAssetLibrary.save_asset(static_mesh.get_path_name())
|
||||
|
||||
bounds = (min_x, max_x, min_y, max_y)
|
||||
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 --------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -178,7 +198,11 @@ def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
|
||||
placed_points.append((x, y))
|
||||
|
||||
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)
|
||||
if layer_cfg.get("align_to_normal", False):
|
||||
# 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)
|
||||
|
||||
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_folder_path(f"{base_folder}/{layer_cfg['name']}")
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
config = json.load(f)
|
||||
def place_explicit(entries, field, origin, base_folder):
|
||||
"""Places an exact, hand-authored (or externally-generated) list of instances.
|
||||
|
||||
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"])
|
||||
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)
|
||||
|
||||
loc = terrain_cfg["location"]
|
||||
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.set_folder_path("ProceduralTerrain")
|
||||
terrain_actor.set_actor_label("ProceduralTerrain")
|
||||
terrain_actor.set_folder_path(base_folder)
|
||||
terrain_actor.set_actor_label(f"Terrain_{tile_label}")
|
||||
|
||||
print("Scattering foliage 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)
|
||||
|
||||
print("Done.")
|
||||
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.")
|
||||
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()
|
||||
@@ -51,5 +51,23 @@
|
||||
"min_spacing": 150.0,
|
||||
"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]
|
||||
}
|
||||
Reference in new issue
Block a user