workshop rework
This commit is contained in:
1 parent
a3a80dfa16
commit
fb39e290c0
115 files changed
+1865
-497
No files matched your search
@@ -0,0 +1,3 @@
|
||||
Stare skrypty zostaly zachowane jako material dodatkowy. Nie sa czescia
|
||||
nowego warsztatu i moga wymagac assetow/uslug z poprzedniej wersji projektu.
|
||||
Zachowano rowniez lokalna zmiane w legacy_python/basic_scripting_demo/3.py.
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Run inside Unreal (Python console / Execute Python Script).
|
||||
|
||||
Watches a disk folder for FBX + textures named by convention (Name.fbx,
|
||||
Name_BC/_N/_M/_R/_E.png), imports everything without opening the import
|
||||
dialog, and wires the textures into a Material Instance per mesh.
|
||||
"""
|
||||
import os, unreal
|
||||
|
||||
DEMO_ROOT = r"C:\Users\koleg\Documents\Unreal Projects\ExampleProject\DemoAssets"
|
||||
DEST_ROOT = "/Game/ImportedAssets"
|
||||
MASTER_MATERIAL = f"{DEST_ROOT}/M_Master"
|
||||
|
||||
# suffix -> (texture parameter name, material property, sampled channel)
|
||||
PARAMS = {
|
||||
"_BC": ("BaseColor", unreal.MaterialProperty.MP_BASE_COLOR, "RGB"),
|
||||
"_N": ("Normal", unreal.MaterialProperty.MP_NORMAL, "RGB"),
|
||||
"_M": ("Metallic", unreal.MaterialProperty.MP_METALLIC, "R"),
|
||||
"_R": ("Roughness", unreal.MaterialProperty.MP_ROUGHNESS, "R"),
|
||||
"_E": ("Emissive", unreal.MaterialProperty.MP_EMISSIVE_COLOR, "RGB"),
|
||||
}
|
||||
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
mel = unreal.MaterialEditingLibrary
|
||||
|
||||
|
||||
def get_or_create_master_material():
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(MASTER_MATERIAL):
|
||||
return unreal.EditorAssetLibrary.load_asset(MASTER_MATERIAL)
|
||||
|
||||
material = asset_tools.create_asset("M_Master", DEST_ROOT, unreal.Material, unreal.MaterialFactoryNew())
|
||||
for i, (suffix, (param, prop, channel)) in enumerate(PARAMS.items()):
|
||||
tex = mel.create_material_expression(material, unreal.MaterialExpressionTextureSampleParameter2D, -400, i * 200)
|
||||
tex.set_editor_property("parameter_name", param)
|
||||
mel.connect_material_property(tex, channel, prop)
|
||||
mel.recompile_material(material)
|
||||
return material
|
||||
|
||||
|
||||
def import_task(filename, name, dest_path, options=None):
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = filename
|
||||
task.destination_path = dest_path
|
||||
task.destination_name = name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
if options:
|
||||
task.options = options
|
||||
asset_tools.import_asset_tasks([task])
|
||||
return unreal.load_asset(f"{dest_path}/{name}")
|
||||
|
||||
|
||||
def import_fbx(filename, name, dest_path):
|
||||
options = unreal.FbxImportUI()
|
||||
options.mesh_type_to_import = unreal.FBXImportType.FBXIT_SKELETAL_MESH
|
||||
options.import_mesh = True
|
||||
options.import_as_skeletal = True
|
||||
options.import_animations = True
|
||||
options.create_physics_asset = False
|
||||
options.import_materials = False
|
||||
options.import_textures = False
|
||||
return import_task(filename, name, dest_path, options)
|
||||
|
||||
|
||||
def assign_material(skeletal_mesh, material):
|
||||
existing_slots = skeletal_mesh.get_editor_property("materials")
|
||||
new_slots = []
|
||||
for slot in existing_slots:
|
||||
new_slot = unreal.SkeletalMaterial()
|
||||
new_slot.set_editor_property("material_interface", material)
|
||||
new_slot.set_editor_property("material_slot_name", slot.get_editor_property("material_slot_name"))
|
||||
new_slots.append(new_slot)
|
||||
if not new_slots:
|
||||
# FBX had no material assigned to the mesh, so no slot was created on import.
|
||||
fallback = unreal.SkeletalMaterial()
|
||||
fallback.set_editor_property("material_interface", material)
|
||||
fallback.set_editor_property("material_slot_name", "Material")
|
||||
new_slots.append(fallback)
|
||||
skeletal_mesh.modify()
|
||||
skeletal_mesh.set_editor_property("materials", new_slots)
|
||||
unreal.EditorAssetLibrary.save_asset(skeletal_mesh.get_path_name())
|
||||
|
||||
|
||||
master = get_or_create_master_material()
|
||||
|
||||
asset_count = 0
|
||||
for asset_folder in os.listdir(DEMO_ROOT):
|
||||
src_dir = os.path.join(DEMO_ROOT, asset_folder)
|
||||
if not os.path.isdir(src_dir):
|
||||
continue
|
||||
dest_path = f"{DEST_ROOT}/{asset_folder}"
|
||||
|
||||
# group source files by base name: {"RobotPolice": {"fbx": "...", "_BC": "...", ...}}
|
||||
groups = {}
|
||||
for f in os.listdir(src_dir):
|
||||
stem, ext = os.path.splitext(f)
|
||||
if ext.lower() == ".fbx":
|
||||
groups.setdefault(stem, {})["fbx"] = f
|
||||
continue
|
||||
for suffix in PARAMS:
|
||||
if stem.endswith(suffix):
|
||||
groups.setdefault(stem[: -len(suffix)], {})[suffix] = f
|
||||
break
|
||||
|
||||
for base_name, files in groups.items():
|
||||
if "fbx" not in files:
|
||||
continue
|
||||
|
||||
mesh = import_fbx(os.path.join(src_dir, files["fbx"]), base_name, dest_path)
|
||||
|
||||
mi = asset_tools.create_asset(f"MI_{base_name}", dest_path, unreal.MaterialInstanceConstant,
|
||||
unreal.MaterialInstanceConstantFactoryNew())
|
||||
mel.set_material_instance_parent(mi, master)
|
||||
|
||||
textures = {}
|
||||
for suffix, (param, _, _) in PARAMS.items():
|
||||
if suffix not in files:
|
||||
continue
|
||||
texture = import_task(os.path.join(src_dir, files[suffix]), f"T_{base_name}{suffix}", dest_path)
|
||||
textures[suffix] = texture
|
||||
mel.set_material_instance_texture_parameter_value(mi, param, texture)
|
||||
|
||||
if "_E" not in files and "_BC" in textures:
|
||||
mel.set_material_instance_texture_parameter_value(mi, "Emissive", textures["_BC"])
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(mi.get_path_name())
|
||||
assign_material(mesh, mi)
|
||||
asset_count += 1
|
||||
|
||||
print(f"Imported {asset_count} asset(s) into {DEST_ROOT}")
|
||||
@@ -0,0 +1,5 @@
|
||||
import unreal
|
||||
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actors = sub.get_all_level_actors()
|
||||
unreal.log(f"{len(actors)} actors in level")
|
||||
@@ -0,0 +1,16 @@
|
||||
import unreal
|
||||
|
||||
OLD = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree04.SM_tree04")
|
||||
NEW = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree01.SM_tree01")
|
||||
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actors = sub.get_all_level_actors()
|
||||
|
||||
old_mesh_count = 0
|
||||
|
||||
for actor in actors:
|
||||
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if comp and comp.static_mesh == OLD:
|
||||
old_mesh_count += 1
|
||||
|
||||
print(f"Found {old_mesh_count} instances of the old mesh")
|
||||
@@ -0,0 +1,20 @@
|
||||
import unreal
|
||||
|
||||
OLD = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree04.SM_tree04")
|
||||
NEW = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree01.SM_tree01")
|
||||
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actors = sub.get_all_level_actors()
|
||||
|
||||
old_mesh_count = 0
|
||||
|
||||
with unreal.ScopedEditorTransaction("Swap meshes"):
|
||||
for actor in actors:
|
||||
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if comp and comp.static_mesh == OLD:
|
||||
comp.set_editor_property("static_mesh", NEW)
|
||||
# comp.set_static_mesh(NEW) -- tak tez mozna ale undo nie zadziala
|
||||
# bo zmiana nie jest robiona przez edytor wiec jej on nie rejestruje
|
||||
old_mesh_count += 1
|
||||
|
||||
unreal.log(f"Swapped {old_mesh_count} meshes")
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Generate turntable Level Sequences (and optionally queue Movie Render Queue jobs).
|
||||
|
||||
Run this from Unreal's Output Log with::
|
||||
|
||||
py ".../Content/Python/generate_blueprint_turntable.py" /Game/Blueprints/BP_MyAsset
|
||||
|
||||
Append ``--render-now`` to start Movie Render Queue and
|
||||
``--all-animations`` to render every AnimSequence using the Blueprint's
|
||||
skeleton.
|
||||
|
||||
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 sys
|
||||
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
|
||||
CAMERA_Z = 40.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_skeletal_mesh_asset()]
|
||||
|
||||
|
||||
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_skeletal_mesh_asset()
|
||||
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()
|
||||
anim_sequence_class = unreal.TopLevelAssetPath("/Script/Engine", "AnimSequence")
|
||||
for data in registry.get_assets_by_class(anim_sequence_class, 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)
|
||||
# UE 5.8 exposes section channels through the SequencerScripting extension
|
||||
# library (MovieScene3DTransformSection itself has no get_channels method).
|
||||
channels = unreal.MovieSceneSectionExtensions.get_channels_by_type(
|
||||
section, unreal.MovieSceneScriptingDoubleChannel)
|
||||
if len(channels) != 9:
|
||||
raise RuntimeError("Expected 9 transform channels, got {0}".format(len(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)
|
||||
# UE 5.8 creates master tracks through MovieSceneSequence.add_track.
|
||||
cut_track = sequence.add_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_skeletal_mesh_asset()
|
||||
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):
|
||||
# Use a concrete directory rather than an MRQ format token, so its location
|
||||
# is deterministic both in the queue UI and during PIE execution.
|
||||
if directory == DEFAULT_RENDER_DIRECTORY:
|
||||
directory = unreal.Paths.project_saved_dir().rstrip("/\\") + "/TurntableRenders"
|
||||
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)
|
||||
output.override_existing_output = True
|
||||
# A queue constructed in Python has no implicit render pass. Without a
|
||||
# Deferred Pass MRQ has no frames to submit or write.
|
||||
config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
|
||||
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
|
||||
return subsystem
|
||||
|
||||
|
||||
def _remove_previous_turntable_jobs(bp_name):
|
||||
"""Replace prior generated jobs without touching the user's unrelated MRQ jobs."""
|
||||
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
|
||||
queue = subsystem.get_queue()
|
||||
prefix = "LS_TT_" + bp_name
|
||||
for job in queue.get_jobs():
|
||||
if job.job_name.startswith(prefix):
|
||||
queue.delete_job(job)
|
||||
|
||||
|
||||
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_location = unreal.Vector(origin.x - distance, origin.y, CAMERA_Z)
|
||||
camera_target = unreal.Vector(origin.x, origin.y, CAMERA_Z)
|
||||
camera = unreal.EditorLevelLibrary.spawn_actor_from_class(
|
||||
unreal.CineCameraActor, camera_location, unreal.Rotator(0, 0, 0))
|
||||
camera.set_actor_label("TT_Camera_" + bp_name)
|
||||
camera.set_actor_rotation(
|
||||
unreal.MathLibrary.find_look_at_rotation(camera.get_actor_location(), camera_target), False)
|
||||
cine_camera = camera.get_cine_camera_component()
|
||||
cine_camera.set_editor_property("current_focal_length", 50.0)
|
||||
# A library turntable must stay sharp across the whole asset, not only on a
|
||||
# small cinematic focal plane.
|
||||
cine_camera.set_editor_property(
|
||||
"focus_settings",
|
||||
unreal.CameraFocusSettings(focus_method=unreal.CameraFocusMethod.DISABLE),
|
||||
)
|
||||
|
||||
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()
|
||||
_remove_previous_turntable_jobs(bp_name)
|
||||
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
|
||||
|
||||
|
||||
def _run_from_console_arguments():
|
||||
"""Support a concise `py "script.py" /Game/Path/BP_Name [flags]` command."""
|
||||
render_now = "--render-now" in sys.argv
|
||||
all_animations = "--all-animations" in sys.argv
|
||||
for argument in sys.argv[1:]:
|
||||
# Dragging an asset into the Output Log can produce
|
||||
# /Script/Engine.Blueprint'/Game/Foo/BP_X.BP_X'; accept that form too.
|
||||
game_index = argument.find("/Game/")
|
||||
if game_index < 0:
|
||||
continue
|
||||
asset_path = argument[game_index:].split("'", 1)[0].split(".", 1)[0]
|
||||
generate_turntables(
|
||||
asset_path,
|
||||
render_immediately=render_now,
|
||||
render_all_skeleton_animations=all_animations,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
_run_from_console_arguments()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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.
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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):
|
||||
return tc.finite_difference_normal(self.height, x, y, step)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
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 = 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, f"Terrain_{tile_label}")
|
||||
|
||||
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:
|
||||
tc.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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import unreal
|
||||
|
||||
print("Hello World!")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Run inside Unreal (Python console / Execute Python Script).
|
||||
|
||||
Imports the tree (FBX) and rock (OBJ) meshes from DemoAssets as StaticMesh
|
||||
assets, so generate_procedural_terrain.py has something to scatter.
|
||||
"""
|
||||
import os, unreal
|
||||
|
||||
DEMO_ROOT = r"C:\Users\koleg\Documents\Unreal Projects\ExampleProject\DemoAssets"
|
||||
DEST_ROOT = "/Game/ImportedAssets"
|
||||
|
||||
# folder under DEMO_ROOT -> (destination subfolder, file extension)
|
||||
SOURCES = {
|
||||
"trees": ("Trees", ".fbx"),
|
||||
"rocks": ("Rocks", ".obj"),
|
||||
}
|
||||
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def import_task(filename, name, dest_path, options=None):
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = filename
|
||||
task.destination_path = dest_path
|
||||
task.destination_name = name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
if options:
|
||||
task.options = options
|
||||
asset_tools.import_asset_tasks([task])
|
||||
return unreal.load_asset(f"{dest_path}/{name}")
|
||||
|
||||
|
||||
def import_static_mesh(filename, name, dest_path):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
options = None
|
||||
if ext == ".fbx":
|
||||
options = unreal.FbxImportUI()
|
||||
options.mesh_type_to_import = unreal.FBXImportType.FBXIT_STATIC_MESH
|
||||
options.import_mesh = True
|
||||
options.import_as_skeletal = False
|
||||
options.import_materials = True
|
||||
options.import_textures = True
|
||||
options.static_mesh_import_data.combine_meshes = True
|
||||
# OBJ has no import-options struct exposed to Python; Interchange picks sane defaults.
|
||||
return import_task(filename, name, dest_path, options)
|
||||
|
||||
|
||||
imported = []
|
||||
for src_folder, (dest_subfolder, ext) in SOURCES.items():
|
||||
src_dir = os.path.join(DEMO_ROOT, src_folder)
|
||||
if not os.path.isdir(src_dir):
|
||||
continue
|
||||
dest_path = f"{DEST_ROOT}/{dest_subfolder}"
|
||||
|
||||
for f in sorted(os.listdir(src_dir)):
|
||||
stem, file_ext = os.path.splitext(f)
|
||||
if file_ext.lower() != ext:
|
||||
continue
|
||||
name = f"SM_{stem}"
|
||||
mesh = import_static_mesh(os.path.join(src_dir, f), name, dest_path)
|
||||
imported.append(f"{dest_path}/{name}")
|
||||
|
||||
print(f"Imported {len(imported)} static mesh(es):")
|
||||
for path in imported:
|
||||
print(f" {path}")
|
||||
@@ -0,0 +1,48 @@
|
||||
import re, unreal
|
||||
|
||||
FOLDERS = {
|
||||
"World": "Maps",
|
||||
"LevelSequence": "Sequences",
|
||||
"Material": "Materials",
|
||||
"MaterialInstanceConstant": "Materials",
|
||||
"NiagaraSystem": "Effects",
|
||||
"Blueprint": "Blueprints",
|
||||
}
|
||||
PREFIXES = {
|
||||
"World": "",
|
||||
"Material": "M_",
|
||||
"MaterialInstanceConstant": "MI_",
|
||||
"StaticMesh": "SM_",
|
||||
"SkeletalMesh": "SK_",
|
||||
"Blueprint": "BP_",
|
||||
"NiagaraSystem": "NS_",
|
||||
"LevelSequence": "LS_",
|
||||
}
|
||||
|
||||
def to_pascal_case(name):
|
||||
parts = re.split(r"[_\-\s]+", name)
|
||||
return "".join(p[:1].upper() + p[1:] for p in parts if p)
|
||||
|
||||
|
||||
lib = unreal.EditorAssetLibrary
|
||||
for path in lib.list_assets("/Game", recursive=True):
|
||||
path = path.split(".")[0]
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
data = lib.find_asset_data(path)
|
||||
cls = str(data.asset_class_path.asset_name)
|
||||
|
||||
# instance assets are renamed after their parent, not their own name
|
||||
if cls == "MaterialInstanceConstant":
|
||||
parent = lib.load_asset(path).get_editor_property("parent")
|
||||
base = re.sub(r"^(M_|MI_)", "", parent.get_name()) if parent else name
|
||||
suffix = name[len(base):] if name.lower().startswith(base.lower()) else ""
|
||||
name = base + (f"_{suffix.capitalize()}" if suffix else "")
|
||||
|
||||
name = re.sub(r"^(" + "|".join(p for p in PREFIXES.values() if p) + ")", "", name)
|
||||
name = to_pascal_case(name)
|
||||
name = PREFIXES.get(cls, "") + name
|
||||
|
||||
folder = FOLDERS.get(cls, "Misc")
|
||||
dest = f"/Game/{folder}/{name}"
|
||||
if path != dest:
|
||||
lib.rename_asset(path, dest)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"terrain": {
|
||||
"location": [0.0, 0.0, 0.0],
|
||||
"size_x": 8000.0,
|
||||
"size_y": 8000.0,
|
||||
"resolution": 96,
|
||||
"height_scale": 600.0,
|
||||
"dest_path": "/Game/ImportedAssets/Terrain",
|
||||
"asset_name": "SM_ProceduralTerrain",
|
||||
"noise": {
|
||||
"seed": 1337,
|
||||
"octaves": 5,
|
||||
"frequency": 0.00035,
|
||||
"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": 250,
|
||||
"min_height": -50.0,
|
||||
"max_height": 350.0,
|
||||
"max_slope_degrees": 25.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"
|
||||
],
|
||||
"count": 150,
|
||||
"min_height": -100.0,
|
||||
"max_height": 600.0,
|
||||
"max_slope_degrees": 45.0,
|
||||
"min_scale": 0.5,
|
||||
"max_scale": 2.0,
|
||||
"align_to_normal": true,
|
||||
"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_rock1",
|
||||
"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,54 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"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