diff --git a/Content/Python/generate_blueprint_turntable.py b/Content/Python/generate_blueprint_turntable.py new file mode 100644 index 0000000..8aaa4fe --- /dev/null +++ b/Content/Python/generate_blueprint_turntable.py @@ -0,0 +1,225 @@ +"""Generate turntable Level Sequences (and optionally queue Movie Render Queue jobs). + +Run this from Unreal's Output Log with:: + + exec(open(r".../Content/Python/generate_blueprint_turntable.py").read()) + generate_turntables("/Game/Blueprints/BP_MyAsset") + +The script creates/saves a small presentation map, so the bindings in the +sequences and MRQ jobs are reproducible. Open any generated sequence in +Sequencer to show the normal camera, camera-cut and transform tracks. +""" +import unreal + + +# Project-specific defaults. They can be overridden in generate_turntables(). +DEFAULT_OUTPUT_ROOT = "/Game/Sequences/Turntables" +DEFAULT_STAGE_MAP = "/Game/Maps/TurntableStage" +DEFAULT_RENDER_DIRECTORY = "{project_dir}/Saved/TurntableRenders" +FPS = 30 +TURN_DURATION_SECONDS = 5.0 +QUEUE_RENDER_IMMEDIATELY = False # True starts MRQ immediately after queueing. +RENDER_ALL_SKELETON_ANIMATIONS = False + + +def _log(message): + unreal.log("[Turntable] " + message) + + +def _asset_name(path): + return path.rsplit("/", 1)[-1].split(".")[0] + + +def _ensure_folder(path): + if not unreal.EditorAssetLibrary.does_directory_exist(path): + unreal.EditorAssetLibrary.make_directory(path) + + +def _create_or_open_stage(stage_map): + """Open the dedicated map; creating it only on the first run.""" + if unreal.EditorAssetLibrary.does_asset_exist(stage_map): + unreal.EditorLoadingAndSavingUtils.load_map(stage_map) + else: + _ensure_folder(stage_map.rsplit("/", 1)[0]) + unreal.EditorLevelLibrary.new_level(stage_map) + unreal.EditorLevelLibrary.save_current_level() + + +def _find_skeletal_components(actor): + components = actor.get_components_by_class(unreal.SkeletalMeshComponent) + return [component for component in components if component.get_editor_property("skeletal_mesh")] + + +def _animations_set_on_blueprint(actor): + """Return AnimSequences explicitly selected on components in the Blueprint. + + Anim Blueprints are procedural at runtime and do not expose a finite list of + clips, so they are deliberately not guessed here. A component using + "Use Animation Asset" is exported as one sequence per assigned clip. + """ + animations = [] + for component in _find_skeletal_components(actor): + try: + animation_data = component.get_editor_property("animation_data") + animation = animation_data.get_editor_property("anim_to_play") + if animation and animation not in animations: + animations.append(animation) + except Exception: + pass + return animations + + +def _all_skeleton_animations(actor): + """Optional broad mode: render every AnimSequence sharing the actor skeleton.""" + skeletons = set() + for component in _find_skeletal_components(actor): + mesh = component.get_editor_property("skeletal_mesh") + skeleton = mesh.get_editor_property("skeleton") if mesh else None + if skeleton: + skeletons.add(skeleton.get_path_name()) + + animations = [] + registry = unreal.AssetRegistryHelpers.get_asset_registry() + for data in registry.get_assets_by_class("AnimSequence", True): + animation = data.get_asset() + if animation and animation.get_editor_property("skeleton").get_path_name() in skeletons: + animations.append(animation) + return animations + + +def _set_transform_keys(track, frames, start_location, start_rotation): + section = track.add_section() + section.set_range(0, frames) + channels = section.get_channels() + # Translation XYZ, rotation XYZ, scale XYZ. Key all channels explicitly so + # the result remains readable/editable in Sequencer. + values = [start_location.x, start_location.y, start_location.z, + start_rotation.roll, start_rotation.pitch, start_rotation.yaw, + 1.0, 1.0, 1.0] + for index, value in enumerate(values): + channels[index].add_key(unreal.FrameNumber(0), value) + end_value = start_rotation.yaw + 360.0 if index == 5 else value + channels[index].add_key(unreal.FrameNumber(frames), end_value) + + +def _add_camera(sequence, camera, frames): + camera_binding = sequence.add_possessable(camera) + cut_track = sequence.add_master_track(unreal.MovieSceneCameraCutTrack) + cut = cut_track.add_section() + cut.set_range(0, frames) + # Camera cuts require a MovieSceneObjectBindingID, not the binding proxy. + cut.set_camera_binding_id(sequence.get_binding_id(camera_binding)) + + +def _add_animation_track(sequence, actor, animation, frames): + """Bind the matching skeletal component and play its animation clip.""" + for component in _find_skeletal_components(actor): + mesh = component.get_editor_property("skeletal_mesh") + skeleton = mesh.get_editor_property("skeleton") if mesh else None + if skeleton != animation.get_editor_property("skeleton"): + continue + binding = sequence.add_possessable(component) + track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack) + section = track.add_section() + section.set_range(0, frames) + params = section.get_editor_property("params") + params.set_editor_property("animation", animation) + section.set_editor_property("params", params) + return True + return False + + +def _make_sequence(sequence_name, output_folder, actor, camera, animation=None): + tools = unreal.AssetToolsHelpers.get_asset_tools() + existing_path = output_folder + "/" + sequence_name + if unreal.EditorAssetLibrary.does_asset_exist(existing_path): + unreal.EditorAssetLibrary.delete_asset(existing_path) + sequence = tools.create_asset(sequence_name, output_folder, unreal.LevelSequence, + unreal.LevelSequenceFactoryNew()) + frames = max(1, round(FPS * max(TURN_DURATION_SECONDS, + animation.get_play_length() if animation else 0.0))) + sequence.set_display_rate(unreal.FrameRate(FPS, 1)) + sequence.set_playback_start(0) + sequence.set_playback_end(frames) + + actor_binding = sequence.add_possessable(actor) + transform_track = actor_binding.add_track(unreal.MovieScene3DTransformTrack) + _set_transform_keys(transform_track, frames, actor.get_actor_location(), actor.get_actor_rotation()) + _add_camera(sequence, camera, frames) + if animation: + _add_animation_track(sequence, actor, animation, frames) + + unreal.EditorAssetLibrary.save_asset(sequence.get_path_name()) + return sequence + + +def _queue_render(sequence, stage_map, directory): + subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem) + queue = subsystem.get_queue() + job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) + job.job_name = sequence.get_name() + job.sequence = unreal.SoftObjectPath(sequence.get_path_name()) + job.map = unreal.SoftObjectPath(stage_map) + config = job.get_configuration() + output = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting) + output.output_directory = unreal.DirectoryPath(directory) + output.file_name_format = "{job_name}/{frame_number}" + output.output_resolution = unreal.IntPoint(1920, 1080) + config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG) + return subsystem + + +def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT, + stage_map=DEFAULT_STAGE_MAP, render_directory=DEFAULT_RENDER_DIRECTORY, + render_immediately=QUEUE_RENDER_IMMEDIATELY, + render_all_skeleton_animations=RENDER_ALL_SKELETON_ANIMATIONS): + """Create a turntable plus one turntable per explicitly assigned animation. + + `blueprint_path` must be an asset path, e.g. /Game/Blueprints/BP_Robot. + The return value is the generated LevelSequence list. + """ + blueprint = unreal.load_asset(blueprint_path) + if not blueprint or not isinstance(blueprint, unreal.Blueprint): + raise RuntimeError("Expected a Blueprint asset path, got: " + blueprint_path) + + bp_name = _asset_name(blueprint_path) + # A separate stage per asset prevents other turntable actors from appearing + # in the same render. Supplying a custom stage_map preserves that exact map. + if stage_map == DEFAULT_STAGE_MAP: + stage_map = stage_map + "_" + bp_name + _create_or_open_stage(stage_map) + folder = output_root + "/" + bp_name + _ensure_folder(folder) + + # Re-running the script replaces only its own presentation actors in the + # selected stage map. This keeps repeated demos deterministic. + generated_labels = {"TT_" + bp_name, "TT_Camera_" + bp_name} + for existing_actor in unreal.EditorLevelLibrary.get_all_level_actors(): + if existing_actor.get_actor_label() in generated_labels: + unreal.EditorLevelLibrary.destroy_actor(existing_actor) + + actor = unreal.EditorLevelLibrary.spawn_actor_from_object(blueprint, unreal.Vector(0, 0, 0)) + actor.set_actor_label("TT_" + bp_name) + origin, extent = actor.get_actor_bounds(False) + distance = max(extent.x, extent.y, extent.z) * 3.0 + 150.0 + camera = unreal.EditorLevelLibrary.spawn_actor_from_class( + unreal.CineCameraActor, unreal.Vector(origin.x - distance, origin.y, origin.z), unreal.Rotator(0, 0, 0)) + camera.set_actor_label("TT_Camera_" + bp_name) + camera.set_actor_rotation((origin - camera.get_actor_location()).rotation(), False) + camera.get_cine_camera_component().set_editor_property("current_focal_length", 50.0) + + sequences = [_make_sequence("LS_TT_" + bp_name, folder, actor, camera)] + animations = _all_skeleton_animations(actor) if render_all_skeleton_animations else _animations_set_on_blueprint(actor) + for animation in animations: + name = "LS_TT_{0}_{1}".format(bp_name, animation.get_name()) + sequences.append(_make_sequence(name, folder, actor, camera, animation)) + + unreal.EditorLevelLibrary.save_current_level() + subsystem = None + for sequence in sequences: + subsystem = _queue_render(sequence, stage_map, render_directory) + if render_immediately and subsystem: + subsystem.render_queue_with_executor(unreal.MoviePipelinePIEExecutor) + + _log("Created {0} sequence(s) and queued {0} PNG job(s) for {1}.".format(len(sequences), bp_name)) + return sequences diff --git a/Content/Python/generate_procedural_terrain.py b/Content/Python/generate_procedural_terrain.py index dfeead9..1350f08 100644 --- a/Content/Python/generate_procedural_terrain.py +++ b/Content/Python/generate_procedural_terrain.py @@ -12,17 +12,18 @@ 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. + +See generate_terrain_from_heightmap.py for the same pipeline driven by a real +heightmap image instead of procedural noise - both share terrain_common.py. """ import os, json, math, random, unreal +import terrain_common as tc 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) ------------------- @@ -86,206 +87,10 @@ class FractalHeightField: 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) + return tc.finite_difference_normal(self.height, x, y, step) # --------------------------------------------------------------------------- -# 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. @@ -294,20 +99,18 @@ def generate_tile(config, terrain_cfg, tile_label): 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) + static_mesh, bounds = tc.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}") + tc.spawn_terrain_actor(static_mesh, origin, base_folder, f"Terrain_{tile_label}") for layer_cfg in config.get("scatter_layers", []): - scatter_layer(layer_cfg, field, bounds, origin, base_folder) + tc.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) + tc.place_explicit(explicit_entries, field, origin, base_folder) def main(): diff --git a/Content/Python/generate_terrain_from_heightmap.py b/Content/Python/generate_terrain_from_heightmap.py new file mode 100644 index 0000000..c2c458e --- /dev/null +++ b/Content/Python/generate_terrain_from_heightmap.py @@ -0,0 +1,160 @@ +"""Run inside Unreal (Python console / Execute Python Script). + +Same pipeline as generate_procedural_terrain.py, but the height field comes +from an actual image (e.g. DemoAssets/poland_shaded.png) instead of procedural +noise. Reads heightmap_config.json (next to this script). + +By default darker pixels = higher elevation and brighter pixels = lower, to +match shaded-relief style maps (set "invert": false in the config for the +opposite, standard-heightmap convention where white = high). + +If the image has an alpha channel, pixels with alpha below "alpha_threshold" +(0-1, default 0.5) are cut out of the terrain mesh entirely (real holes, not +just low ground) and are excluded from foliage scatter/placement - useful for +a heightmap shaped like a country/island silhouette with a transparent +background. + +Run import_environment_assets.py first so the mesh paths referenced in the +config actually exist. Set the HEIGHTMAP_CONFIG env var to point at a +different config file. +""" +import os, json, unreal +import terrain_common as tc +from png_reader import decode_png + +CONFIG_PATH = os.environ.get( + "HEIGHTMAP_CONFIG", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "heightmap_config.json"), +) + + +class HeightmapField: + def __init__(self, cfg): + image_path = cfg["image_path"] + print(f"Decoding heightmap image: {image_path}") + width, height, channels, pixels = decode_png(image_path) + print(f" {width}x{height}, {channels} channel(s)") + + self.width = width + self.height_px = height + self.invert = cfg.get("invert", True) # darker = higher, by default (shaded-relief convention) + self.height_scale = cfg["height_scale"] + self.size_x = cfg["size_x"] + self.size_y = cfg["size_y"] + self.alpha_threshold = cfg.get("alpha_threshold", 0.5) + self.exclude_white = cfg.get("exclude_white", True) + self.white_threshold = cfg.get("white_threshold", 0.98) + self._lum = self._build_luminance(pixels, width, height, channels) + self._alpha = self._build_alpha(pixels, width, height, channels) + self._min_channel = self._build_min_channel(pixels, width, height, channels) + + @staticmethod + def _build_luminance(pixels, width, height, channels): + count = width * height + lum = [0.0] * count + if channels <= 2: + for i in range(count): + lum[i] = pixels[i * channels] / 255.0 + else: + for i in range(count): + o = i * channels + lum[i] = (0.299 * pixels[o] + 0.587 * pixels[o + 1] + 0.114 * pixels[o + 2]) / 255.0 + return lum + + @staticmethod + def _build_min_channel(pixels, width, height, channels): + """Per-pixel min(R,G,B) (or the sole channel, if grayscale) in [0,1] - + used to detect "fully white" fill pixels regardless of luminance + formula quirks (e.g. pure yellow has high luminance but isn't white).""" + count = width * height + arr = [0.0] * count + if channels <= 2: + for i in range(count): + arr[i] = pixels[i * channels] / 255.0 + else: + for i in range(count): + o = i * channels + arr[i] = min(pixels[o], pixels[o + 1], pixels[o + 2]) / 255.0 + return arr + + @staticmethod + def _build_alpha(pixels, width, height, channels): + # Only grayscale+alpha (2) and RGBA (4) carry an alpha channel; anything + # else is fully opaque, so there is nothing to mask out. + if channels not in (2, 4): + return None + count = width * height + alpha = [0.0] * count + last = channels - 1 + for i in range(count): + alpha[i] = pixels[i * channels + last] / 255.0 + return alpha + + def _world_to_image(self, x, y): + # Map world (x, y) in [-size/2, size/2] to continuous image space, with + # +Y world mapped toward row 0 (image top), matching a north-up map. + u = (x + self.size_x / 2.0) / self.size_x + v = (y + self.size_y / 2.0) / self.size_y + u = min(max(u, 0.0), 1.0) + v = min(max(v, 0.0), 1.0) + return u * (self.width - 1), (1.0 - v) * (self.height_px - 1) + + def _sample_bilinear(self, channel_array, x, y): + px, py = self._world_to_image(x, y) + x0, y0 = int(px), int(py) + x1, y1 = min(x0 + 1, self.width - 1), min(y0 + 1, self.height_px - 1) + fx, fy = px - x0, py - y0 + + def S(xx, yy): + return channel_array[yy * self.width + xx] + + s00, s10, s01, s11 = S(x0, y0), S(x1, y0), S(x0, y1), S(x1, y1) + s0 = s00 + (s10 - s00) * fx + s1 = s01 + (s11 - s01) * fx + return s0 + (s1 - s0) * fy + + def height(self, x, y): + lum = self._sample_bilinear(self._lum, x, y) + if self.invert: + lum = 1.0 - lum + return lum * self.height_scale + + def normal(self, x, y, step=200.0): + return tc.finite_difference_normal(self.height, x, y, step) + + def is_masked(self, x, y): + if self._alpha is not None and self._sample_bilinear(self._alpha, x, y) < self.alpha_threshold: + return True + if self.exclude_white and self._sample_bilinear(self._min_channel, x, y) >= self.white_threshold: + return True + return False + + +def main(): + with open(CONFIG_PATH, "r") as f: + config = json.load(f) + + terrain_cfg = config["terrain"] + field = HeightmapField(terrain_cfg) + base_folder = "HeightmapTerrain" + + print("Building terrain mesh from heightmap...") + static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field) + + loc = terrain_cfg["location"] + origin = unreal.Vector(loc[0], loc[1], loc[2]) + tc.spawn_terrain_actor(static_mesh, origin, base_folder, "HeightmapTerrain") + + print("Scattering foliage layers...") + for layer_cfg in config.get("scatter_layers", []): + tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder) + + explicit_entries = config.get("explicit_placements", []) + if explicit_entries: + print("Placing explicit manifest entries...") + tc.place_explicit(explicit_entries, field, origin, base_folder) + + print("Done.") + + +main() diff --git a/Content/Python/heightmap_config.json b/Content/Python/heightmap_config.json new file mode 100644 index 0000000..767a48b --- /dev/null +++ b/Content/Python/heightmap_config.json @@ -0,0 +1,51 @@ +{ + "terrain": { + "image_path": "C:\\Users\\koleg\\Documents\\Unreal Projects\\ExampleProject\\DemoAssets\\poland_shaded.png", + "invert": true, + "alpha_threshold": 0.5, + "location": [0.0, 0.0, 0.0], + "size_x": 20000.0, + "size_y": 16580.0, + "resolution": 128, + "height_scale": 3000.0, + "dest_path": "/Game/ImportedAssets/Terrain", + "asset_name": "SM_HeightmapTerrain" + }, + "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": 300, + "min_height": 0.0, + "max_height": 1500.0, + "max_slope_degrees": 30.0, + "min_scale": 0.85, + "max_scale": 1.3, + "align_to_normal": true, + "min_spacing": 250.0, + "seed": 7 + }, + { + "name": "Rocks", + "meshes": [ + "/Game/ImportedAssets/Rocks/SM_rock1", + "/Game/ImportedAssets/Rocks/SM_rock2", + "/Game/ImportedAssets/Rocks/SM_rock3" + ], + "count": 150, + "min_height": 800.0, + "max_height": 3000.0, + "max_slope_degrees": 50.0, + "min_scale": 0.5, + "max_scale": 2.0, + "align_to_normal": true, + "min_spacing": 200.0, + "seed": 42 + } + ] +} diff --git a/Content/Python/png_reader.py b/Content/Python/png_reader.py new file mode 100644 index 0000000..6b1540e --- /dev/null +++ b/Content/Python/png_reader.py @@ -0,0 +1,86 @@ +"""Minimal pure-Python PNG decoder (stdlib only: zlib + struct). + +Supports 8-bit, non-interlaced grayscale / grayscale+alpha / RGB / RGBA PNGs - +which covers ordinary exported heightmap/relief images. Deliberately does not +depend on Pillow or any other package, since Unreal's embedded Python has no +pip packages installed by default. +""" +import struct, zlib + +_CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4} # 3 (palette) is unsupported + + +def _paeth(a, b, c): + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + if pb <= pc: + return b + return c + + +def decode_png(path): + """Returns (width, height, channels, pixels) where pixels is a flat + bytearray of width*height*channels bytes, row-major, top row first.""" + with open(path, "rb") as f: + data = f.read() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError(f"Not a PNG file: {path}") + + width = height = bit_depth = color_type = interlace = None + idat = bytearray() + pos = 8 + while pos < len(data): + length = struct.unpack(">I", data[pos:pos + 4])[0] + ctype = data[pos + 4:pos + 8] + chunk = data[pos + 8:pos + 8 + length] + if ctype == b"IHDR": + width, height, bit_depth, color_type, _comp, _filt, interlace = struct.unpack(">IIBBBBB", chunk) + elif ctype == b"IDAT": + idat.extend(chunk) + elif ctype == b"IEND": + break + pos += 8 + length + 4 # length + type + data + crc + + if bit_depth != 8: + raise ValueError(f"Only 8-bit PNGs are supported (got bit depth {bit_depth}) - re-export as 8-bit: {path}") + if interlace != 0: + raise ValueError(f"Interlaced PNGs are not supported - re-export as non-interlaced: {path}") + if color_type not in _CHANNELS_BY_COLOR_TYPE: + raise ValueError(f"Unsupported PNG color type {color_type} (palette?) - re-export as grayscale/RGB/RGBA: {path}") + channels = _CHANNELS_BY_COLOR_TYPE[color_type] + + raw = zlib.decompress(bytes(idat)) + stride = width * channels + pixels = bytearray(width * height * channels) + + prev_row = bytearray(stride) + row_start = 0 + for y in range(height): + filter_type = raw[row_start] + row = bytearray(raw[row_start + 1: row_start + 1 + stride]) + row_start += 1 + stride + + if filter_type == 1: # Sub + for i in range(channels, stride): + row[i] = (row[i] + row[i - channels]) & 0xFF + elif filter_type == 2: # Up + for i in range(stride): + row[i] = (row[i] + prev_row[i]) & 0xFF + elif filter_type == 3: # Average + for i in range(stride): + a = row[i - channels] if i >= channels else 0 + row[i] = (row[i] + ((a + prev_row[i]) >> 1)) & 0xFF + elif filter_type == 4: # Paeth + for i in range(stride): + a = row[i - channels] if i >= channels else 0 + b = prev_row[i] + c = prev_row[i - channels] if i >= channels else 0 + row[i] = (row[i] + _paeth(a, b, c)) & 0xFF + # filter_type == 0 (None): no-op + + pixels[y * stride:(y + 1) * stride] = row + prev_row = row + + return width, height, channels, pixels diff --git a/Content/Python/terrain_common.py b/Content/Python/terrain_common.py new file mode 100644 index 0000000..42f1040 --- /dev/null +++ b/Content/Python/terrain_common.py @@ -0,0 +1,252 @@ +"""Shared by generate_procedural_terrain.py and generate_terrain_from_heightmap.py. + +Everything here is agnostic to where the height values come from - it only +needs a "field" object with: + field.height(x, y) -> float + field.normal(x, y) -> unreal.Vector (unit length) +so noise-driven and heightmap-driven terrains can reuse the exact same mesh +building, ground-snapping, and scatter/placement logic. +""" +import math, random, unreal + +asset_tools = unreal.AssetToolsHelpers.get_asset_tools() +actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + + +def finite_difference_normal(height_fn, x, y, step=200.0): + """Surface normal from four height samples around (x, y). `step` should be + large enough to average over small-scale bumps - otherwise high-frequency + noise/heightmap detail produces exaggerated per-instance tilt.""" + h_l = height_fn(x - step, y) + h_r = height_fn(x + step, y) + h_d = height_fn(x, y - step) + h_u = height_fn(x, y + step) + 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) + + is_masked = getattr(field, "is_masked", None) + if is_masked is not None: + masked_vertex_ids = {vid for vid, p in enumerate(positions) if is_masked(p.x, p.y)} + if masked_vertex_ids: + mesh, triangle_list, _has_tri_gaps = unreal.GeometryScript_MeshQueries.get_all_triangle_indices(mesh, False) + triangles = unreal.GeometryScript_List.convert_triangle_list_to_array(triangle_list) + triangles_to_delete = [ + tid for tid, tri in enumerate(triangles) + if tri.x in masked_vertex_ids or tri.y in masked_vertex_ids or tri.z in masked_vertex_ids + ] + if triangles_to_delete: + index_list = unreal.GeometryScript_List.convert_array_to_index_list(triangles_to_delete) + mesh, num_deleted = unreal.GeometryScript_MeshEdits.delete_triangles_from_mesh(mesh, index_list) + print(f" cut {num_deleted} triangle(s) out of masked/transparent regions") + + 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 + + +def spawn_terrain_actor(static_mesh, origin, base_folder, label): + 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(label) + return terrain_actor + + +# --------------------------------------------------------------------------- +# Ground-snapping / alignment ------------------------------------------------ +# --------------------------------------------------------------------------- +_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 + is_masked = getattr(field, "is_masked", None) + + 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 is_masked is not None and is_masked(x, y): + continue + + 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. + """ + is_masked = getattr(field, "is_masked", None) + 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]) + + if is_masked is not None and is_masked(x, y): + print(f" [explicit] {entry.get('name', '?')} falls in a masked/transparent region, skipping") + continue + + 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 diff --git a/DemoAssets/poland_shaded.png b/DemoAssets/poland_shaded.png new file mode 100644 index 0000000..f6bc944 Binary files /dev/null and b/DemoAssets/poland_shaded.png differ diff --git a/ExampleProject.uproject b/ExampleProject.uproject index 1787fa3..2d8d7f3 100644 --- a/ExampleProject.uproject +++ b/ExampleProject.uproject @@ -78,6 +78,10 @@ { "Name": "EditorScriptingUtilities", "Enabled": true + }, + { + "Name": "MovieRenderPipeline", + "Enabled": true } ] -} \ No newline at end of file +}