357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""Run inside Unreal (Python console / Execute Python Script).
|
|
|
|
Reads terrain_config.json (next to this script) and:
|
|
1. builds a heightfield mesh with GeometryScript (fractal value noise),
|
|
bakes it to a StaticMesh, and drops it into the level;
|
|
2. scatters tree/rock StaticMeshActors across it per "scatter_layers",
|
|
using the same noise field so foliage height always matches the
|
|
ground beneath it (no raycasting needed).
|
|
|
|
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.environ.get(
|
|
"TERRAIN_CONFIG",
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config_variants_demo.json"),
|
|
)
|
|
|
|
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
|
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fractal value noise (pure Python, no engine dependency) -------------------
|
|
# ---------------------------------------------------------------------------
|
|
class ValueNoise2D:
|
|
"""Seeded, smooth, tileable-enough value noise for terrain-style height fields."""
|
|
|
|
def __init__(self, seed):
|
|
rng = random.Random(seed)
|
|
table = list(range(256))
|
|
rng.shuffle(table)
|
|
self._perm = table + table # avoid overflow when indexing i+1
|
|
|
|
@staticmethod
|
|
def _fade(t):
|
|
return t * t * t * (t * (t * 6 - 15) + 10)
|
|
|
|
@staticmethod
|
|
def _lerp(a, b, t):
|
|
return a + t * (b - a)
|
|
|
|
def _gradient(self, ix, iy):
|
|
h = self._perm[(self._perm[ix & 255] + iy) & 255]
|
|
angle = (h / 255.0) * 2.0 * math.pi
|
|
return math.cos(angle), math.sin(angle)
|
|
|
|
def sample(self, x, y):
|
|
x0, y0 = math.floor(x), math.floor(y)
|
|
x1, y1 = x0 + 1, y0 + 1
|
|
sx, sy = x - x0, y - y0
|
|
|
|
def dot_grid(ix, iy, px, py):
|
|
gx, gy = self._gradient(ix, iy)
|
|
return gx * (px - ix) + gy * (py - iy)
|
|
|
|
n00 = dot_grid(x0, y0, x, y)
|
|
n10 = dot_grid(x1, y0, x, y)
|
|
n01 = dot_grid(x0, y1, x, y)
|
|
n11 = dot_grid(x1, y1, x, y)
|
|
|
|
u, v = self._fade(sx), self._fade(sy)
|
|
return self._lerp(self._lerp(n00, n10, u), self._lerp(n01, n11, u), v)
|
|
|
|
|
|
class FractalHeightField:
|
|
def __init__(self, noise_cfg, height_scale):
|
|
self.height_scale = height_scale
|
|
self.frequency = noise_cfg["frequency"]
|
|
self.octaves = noise_cfg["octaves"]
|
|
self.lacunarity = noise_cfg["lacunarity"]
|
|
self.gain = noise_cfg["gain"]
|
|
self.noise = ValueNoise2D(noise_cfg["seed"])
|
|
|
|
def height(self, x, y):
|
|
amplitude, frequency, total, max_amp = 1.0, self.frequency, 0.0, 0.0
|
|
for _ in range(self.octaves):
|
|
total += amplitude * self.noise.sample(x * frequency, y * frequency)
|
|
max_amp += amplitude
|
|
amplitude *= self.gain
|
|
frequency *= self.lacunarity
|
|
return (total / max_amp) * self.height_scale
|
|
|
|
def normal(self, x, y, step=200.0):
|
|
h_l = self.height(x - step, y)
|
|
h_r = self.height(x + step, y)
|
|
h_d = self.height(x, y - step)
|
|
h_u = self.height(x, y + step)
|
|
# tangents along X and Y, cross product gives the surface normal.
|
|
tangent_x = unreal.Vector(2 * step, 0.0, h_r - h_l)
|
|
tangent_y = unreal.Vector(0.0, 2 * step, h_u - h_d)
|
|
nx = tangent_x.y * tangent_y.z - tangent_x.z * tangent_y.y
|
|
ny = tangent_x.z * tangent_y.x - tangent_x.x * tangent_y.z
|
|
nz = tangent_x.x * tangent_y.y - tangent_x.y * tangent_y.x
|
|
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
|
|
return unreal.Vector(nx / length, ny / length, nz / length)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Terrain mesh ----------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
def build_terrain_mesh(cfg, field):
|
|
steps = max(2, cfg["resolution"])
|
|
mesh = unreal.DynamicMesh()
|
|
primitive_options = unreal.GeometryScriptPrimitiveOptions()
|
|
mesh = unreal.GeometryScript_Primitives.append_rectangle_xy(
|
|
mesh, primitive_options, unreal.Transform(),
|
|
dimension_x=cfg["size_x"], dimension_y=cfg["size_y"],
|
|
steps_width=steps, steps_height=steps,
|
|
)
|
|
|
|
mesh, position_list, _has_gaps = unreal.GeometryScript_MeshQueries.get_all_vertex_positions(mesh, False)
|
|
positions = unreal.GeometryScript_List.convert_vector_list_to_array(position_list)
|
|
|
|
min_x = min_y = float("inf")
|
|
max_x = max_y = float("-inf")
|
|
displaced = []
|
|
for p in positions:
|
|
z = field.height(p.x, p.y)
|
|
displaced.append(unreal.Vector(p.x, p.y, z))
|
|
min_x, max_x = min(min_x, p.x), max(max_x, p.x)
|
|
min_y, max_y = min(min_y, p.y), max(max_y, p.y)
|
|
|
|
new_list = unreal.GeometryScript_List.convert_array_to_vector_list(displaced)
|
|
mesh = unreal.GeometryScript_MeshEdits.set_all_mesh_vertex_positions(mesh, new_list)
|
|
mesh = unreal.GeometryScript_Normals.set_per_vertex_normals(mesh)
|
|
|
|
dest_path = cfg["dest_path"]
|
|
asset_name = cfg["asset_name"]
|
|
asset_path = f"{dest_path}/{asset_name}"
|
|
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
|
unreal.EditorAssetLibrary.delete_asset(asset_path)
|
|
|
|
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_bounds_cache = {}
|
|
|
|
|
|
def mesh_local_bounds(mesh):
|
|
path = mesh.get_path_name()
|
|
if path not in _mesh_bounds_cache:
|
|
box = mesh.get_bounding_box()
|
|
_mesh_bounds_cache[path] = (box.min, box.max)
|
|
return _mesh_bounds_cache[path]
|
|
|
|
|
|
def ground_offset_for(mesh, quat, scale_vec):
|
|
"""World-space Z distance from the actor's pivot down to the lowest point of
|
|
its (scaled, rotated) mesh, so the base can be planted exactly on the ground
|
|
no matter where the source asset's pivot was authored, or how it's tilted."""
|
|
box_min, box_max = mesh_local_bounds(mesh)
|
|
min_z = None
|
|
for x in (box_min.x, box_max.x):
|
|
for y in (box_min.y, box_max.y):
|
|
for z in (box_min.z, box_max.z):
|
|
corner = unreal.Vector(x * scale_vec.x, y * scale_vec.y, z * scale_vec.z)
|
|
world_z = quat.rotate_vector(corner).z
|
|
if min_z is None or world_z < min_z:
|
|
min_z = world_z
|
|
return -min_z
|
|
|
|
|
|
def align_and_yaw_quat(normal, yaw_degrees, align_to_normal):
|
|
"""Random yaw about the object's own up axis, then (optionally) tilt that
|
|
up axis to match the terrain normal - in that order, since tilting first
|
|
would rotate the yaw axis out from under the random spin."""
|
|
yaw_quat = unreal.Rotator(roll=0.0, pitch=0.0, yaw=yaw_degrees).quaternion()
|
|
if not align_to_normal:
|
|
return yaw_quat
|
|
align_quat = unreal.Vector(0.0, 0.0, 1.0).find_quat_between_normals(normal)
|
|
return align_quat * yaw_quat
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Foliage scatter --------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
|
|
min_x, max_x, min_y, max_y = bounds
|
|
rng = random.Random(layer_cfg.get("seed", 0))
|
|
meshes = [unreal.load_asset(path) for path in layer_cfg["meshes"]]
|
|
meshes = [m for m in meshes if m is not None]
|
|
if not meshes:
|
|
print(f" [{layer_cfg['name']}] no valid meshes found, skipping")
|
|
return 0
|
|
|
|
max_slope_cos = math.cos(math.radians(layer_cfg.get("max_slope_degrees", 90.0)))
|
|
min_spacing = layer_cfg.get("min_spacing", 0.0)
|
|
min_spacing_sqr = min_spacing * min_spacing
|
|
|
|
placed_points = []
|
|
spawned = 0
|
|
max_attempts = layer_cfg["count"] * 25
|
|
|
|
attempts = 0
|
|
while spawned < layer_cfg["count"] and attempts < max_attempts:
|
|
attempts += 1
|
|
x = rng.uniform(min_x, max_x)
|
|
y = rng.uniform(min_y, max_y)
|
|
|
|
if min_spacing_sqr > 0.0 and any((x - px) ** 2 + (y - py) ** 2 < min_spacing_sqr for px, py in placed_points):
|
|
continue
|
|
|
|
z = field.height(x, y)
|
|
if not (layer_cfg["min_height"] <= z <= layer_cfg["max_height"]):
|
|
continue
|
|
|
|
normal = field.normal(x, y)
|
|
if normal.z < max_slope_cos:
|
|
continue
|
|
|
|
placed_points.append((x, y))
|
|
|
|
mesh = rng.choice(meshes)
|
|
scale = rng.uniform(layer_cfg["min_scale"], layer_cfg["max_scale"])
|
|
scale_vec = unreal.Vector(scale, scale, scale)
|
|
|
|
yaw = rng.uniform(0.0, 360.0)
|
|
quat = align_and_yaw_quat(normal, yaw, layer_cfg.get("align_to_normal", False))
|
|
rotation = quat.rotator()
|
|
|
|
ground_offset = ground_offset_for(mesh, quat, scale_vec)
|
|
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z + ground_offset)
|
|
|
|
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
|
|
actor.set_actor_scale3d(scale_vec)
|
|
actor.set_folder_path(f"{base_folder}/{layer_cfg['name']}")
|
|
actor.set_actor_label(f"{layer_cfg['name']}_{spawned:04d}")
|
|
spawned += 1
|
|
|
|
print(f" [{layer_cfg['name']}] placed {spawned}/{layer_cfg['count']} ({attempts} attempts)")
|
|
return spawned
|
|
|
|
|
|
def place_explicit(entries, field, origin, base_folder):
|
|
"""Places an exact, hand-authored (or externally-generated) list of instances.
|
|
|
|
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)
|
|
|
|
roll, pitch, yaw = entry.get("rotation", [0.0, 0.0, 0.0])
|
|
rotation = unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw)
|
|
quat = rotation.quaternion()
|
|
|
|
z = z_offset
|
|
if entry.get("snap_to_terrain", True):
|
|
z += field.height(x, y) + ground_offset_for(mesh, quat, scale_vec)
|
|
|
|
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z)
|
|
|
|
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(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(base_folder)
|
|
terrain_actor.set_actor_label(f"Terrain_{tile_label}")
|
|
|
|
for layer_cfg in config.get("scatter_layers", []):
|
|
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.")
|
|
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()
|