diff --git a/Content/Python/generate_procedural_terrain.py b/Content/Python/generate_procedural_terrain.py index 9f35c09..880f125 100644 --- a/Content/Python/generate_procedural_terrain.py +++ b/Content/Python/generate_procedural_terrain.py @@ -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() diff --git a/Content/Python/terrain_config.json b/Content/Python/terrain_config.json index ac58a8b..2ae1f15 100644 --- a/Content/Python/terrain_config.json +++ b/Content/Python/terrain_config.json @@ -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] + } ] } diff --git a/Content/Python/terrain_config_variants_demo.json b/Content/Python/terrain_config_variants_demo.json new file mode 100644 index 0000000..93ff598 --- /dev/null +++ b/Content/Python/terrain_config_variants_demo.json @@ -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] +}