workshop rework
This commit is contained in:
1 parent
a3a80dfa16
commit
fb39e290c0
115 files changed
+1865
-497
No files matched your search
@@ -1,130 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1,5 +0,0 @@
|
||||
import unreal
|
||||
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actors = sub.get_all_level_actors()
|
||||
unreal.log(f"{len(actors)} actors in level")
|
||||
@@ -1,16 +0,0 @@
|
||||
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")
|
||||
@@ -1,19 +0,0 @@
|
||||
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")
|
||||
@@ -1,288 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,159 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,160 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import unreal
|
||||
|
||||
print("Hello World!")
|
||||
@@ -1,66 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1,48 +0,0 @@
|
||||
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)
|
||||
@@ -1,86 +0,0 @@
|
||||
"""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
|
||||
@@ -1,252 +0,0 @@
|
||||
"""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
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"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]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"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]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Python rendering workshop for Unreal Engine 5.7."""
|
||||
import importlib
|
||||
|
||||
__all__ = ["Scene", "Sequence", "Renderer", "camera", "lighting", "materials", "assets", "blender", "render", "video"]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
# Plain Python launchers can import paths/video/blender without importing unreal.
|
||||
if name in {"Scene", "Sequence"}:
|
||||
return getattr(importlib.import_module("." + name.lower(), __name__), name)
|
||||
if name == "Renderer":
|
||||
return importlib.import_module(".render", __name__).Renderer
|
||||
if name in __all__:
|
||||
return importlib.import_module("." + name, __name__)
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Synchronize basic glTF PBR properties preserved by UE mesh reimports."""
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
import struct
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import PROJECT
|
||||
|
||||
|
||||
def link_textures(source, mesh, destination):
|
||||
if source.suffix.lower() == ".glb":
|
||||
data = source.read_bytes()
|
||||
size, kind = struct.unpack_from("<II", data, 12)
|
||||
if kind != 0x4E4F534A:
|
||||
raise ValueError("GLB nie zawiera poczatkowego chunka JSON.")
|
||||
document = json.loads(data[20:20 + size])
|
||||
binary = data[28 + size:]
|
||||
else:
|
||||
document = json.loads(source.read_text(encoding="utf-8"))
|
||||
binary = b""
|
||||
|
||||
def sanitized(value):
|
||||
return re.sub(r"[^A-Za-z0-9_]", "_", value)
|
||||
|
||||
textures = {}
|
||||
|
||||
def load_texture(info, parameter):
|
||||
index = document["textures"][info["index"]]["source"]
|
||||
role = "Normal" if parameter == "NormalTexture" else (
|
||||
"Color" if parameter in {"BaseColorTexture", "EmissiveTexture"} else "Linear")
|
||||
key = (index, role)
|
||||
if key in textures:
|
||||
return textures[key]
|
||||
image = document["images"][index]
|
||||
name = f"T_{index:03d}_{sanitized(image.get('name', 'Image'))}_{role}"
|
||||
uri = image.get("uri", "")
|
||||
if "bufferView" in image:
|
||||
view = document["bufferViews"][image["bufferView"]]
|
||||
offset = view.get("byteOffset", 0)
|
||||
payload = binary[offset:offset + view["byteLength"]]
|
||||
elif uri.startswith("data:"):
|
||||
payload = base64.b64decode(uri.split(",", 1)[1])
|
||||
else:
|
||||
from urllib.parse import unquote
|
||||
payload = (source.parent / unquote(uri)).read_bytes()
|
||||
extension = ".png" if payload.startswith(b"\x89PNG") else ".jpg" if payload.startswith(b"\xff\xd8") else None
|
||||
if extension is None:
|
||||
raise ValueError("Tekstury warsztatowe musza byc zapisane jako PNG lub JPEG.")
|
||||
file = PROJECT / "build/GltfTextures" / source.stem / (name + extension)
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_bytes(payload)
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = str(file)
|
||||
task.destination_path = destination + "/WorkshopTextures"
|
||||
task.destination_name = name
|
||||
task.automated = True
|
||||
task.replace_existing = True
|
||||
task.save = True
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
imported = [obj for obj in task.get_objects() if isinstance(obj, unreal.Texture2D)]
|
||||
if len(imported) != 1:
|
||||
raise RuntimeError("Nie mozna zaimportowac tekstury " + name)
|
||||
texture = imported[0]
|
||||
texture.set_editor_property("srgb", role == "Color")
|
||||
texture.set_editor_property("flip_green_channel", role == "Normal")
|
||||
texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP
|
||||
if role == "Normal" else unreal.TextureCompressionSettings.TC_DEFAULT
|
||||
if role == "Color" else unreal.TextureCompressionSettings.TC_MASKS)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(texture)
|
||||
textures[key] = texture
|
||||
return texture
|
||||
materials = {sanitized(item.get("name", "")): item for item in document.get("materials", [])}
|
||||
lib = unreal.MaterialEditingLibrary
|
||||
for slot in mesh.static_materials:
|
||||
material = slot.material_interface
|
||||
if material is None or material.get_name() not in materials:
|
||||
continue
|
||||
spec = materials[material.get_name()]
|
||||
pbr = spec.get("pbrMetallicRoughness", {})
|
||||
lib.set_material_instance_vector_parameter_value(
|
||||
material, "BaseColorFactor", unreal.LinearColor(*pbr.get("baseColorFactor", [1, 1, 1, 1])))
|
||||
lib.set_material_instance_vector_parameter_value(
|
||||
material, "EmissiveFactor", unreal.LinearColor(*spec.get("emissiveFactor", [0, 0, 0]), 1))
|
||||
lib.set_material_instance_scalar_parameter_value(material, "MetallicFactor", pbr.get("metallicFactor", 1))
|
||||
lib.set_material_instance_scalar_parameter_value(material, "RoughnessFactor", pbr.get("roughnessFactor", 1))
|
||||
maps = {"BaseColorTexture": pbr.get("baseColorTexture"),
|
||||
"MetallicRoughnessTexture": pbr.get("metallicRoughnessTexture"),
|
||||
"NormalTexture": spec.get("normalTexture"),
|
||||
"OcclusionTexture": spec.get("occlusionTexture"),
|
||||
"EmissiveTexture": spec.get("emissiveTexture")}
|
||||
for parameter, info in maps.items():
|
||||
lib.set_material_instance_static_switch_parameter_value(material, "bHas" + parameter, info is not None)
|
||||
lib.update_material_instance(material)
|
||||
for parameter, info in maps.items():
|
||||
if info is None:
|
||||
continue
|
||||
texture = load_texture(info, parameter)
|
||||
# UE 5.7's setter returns False even when it succeeds; verify by reading.
|
||||
lib.set_material_instance_texture_parameter_value(material, parameter, texture)
|
||||
if lib.get_material_instance_texture_parameter_value(material, parameter) != texture:
|
||||
raise RuntimeError(f"Nie mozna przypisac {parameter} w {material.get_name()}.")
|
||||
lib.update_material_instance(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Automated static mesh imports using the engine's enabled import pipelines."""
|
||||
from pathlib import Path
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, slug
|
||||
|
||||
|
||||
def import_model(filename, name="Character"):
|
||||
source = Path(filename).resolve()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"Brak modelu: {source}")
|
||||
if source.suffix.lower() not in {".glb", ".gltf", ".fbx", ".obj"}:
|
||||
raise ValueError("Model musi byc plikiem GLB, glTF, FBX lub OBJ.")
|
||||
destination = f"{GENERATED}/Imports/{slug(name)}"
|
||||
task = unreal.AssetImportTask()
|
||||
task.set_editor_property("filename", str(source))
|
||||
task.set_editor_property("destination_path", destination)
|
||||
task.set_editor_property("destination_name", name)
|
||||
task.set_editor_property("automated", True)
|
||||
task.set_editor_property("replace_existing", True)
|
||||
task.set_editor_property("replace_existing_settings", True)
|
||||
task.set_editor_property("save", True)
|
||||
task.set_editor_property("async_", False)
|
||||
if source.suffix.lower() in {".glb", ".gltf"}:
|
||||
# Defaults reuse existing materials without updating them. Our generated
|
||||
# import folder belongs to the source file, so apply source properties.
|
||||
template = unreal.load_asset("/Interchange/Pipelines/DefaultGLTFAssetsPipeline")
|
||||
pipeline = unreal.load_asset(GENERATED + "/Pipelines/GLTFWorkshop")
|
||||
if pipeline is None:
|
||||
pipeline = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(
|
||||
"GLTFWorkshop", GENERATED + "/Pipelines", template)
|
||||
pipeline.set_editor_property("reimport_strategy", unreal.ReimportStrategyFlags.APPLY_PIPELINE_PROPERTIES)
|
||||
pipeline.material_pipeline.set_editor_property("import_materials", True)
|
||||
# PBR images are imported explicitly into WorkshopTextures below. This
|
||||
# avoids Interchange's dependency-preserving material reimport behavior.
|
||||
pipeline.material_pipeline.texture_pipeline.set_editor_property("import_textures", False)
|
||||
pipeline.set_editor_property("scene_name_sub_folder", True)
|
||||
pipeline.set_editor_property("asset_type_sub_folders", True)
|
||||
pipeline.material_pipeline.set_editor_property(
|
||||
"search_location", unreal.InterchangeMaterialSearchLocation.DO_NOT_SEARCH)
|
||||
pipeline.material_pipeline.set_editor_property(
|
||||
"material_import", unreal.InterchangeMaterialImportOption.IMPORT_AS_MATERIAL_INSTANCES)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(pipeline)
|
||||
options = unreal.InterchangePipelineStackOverride()
|
||||
options.set_editor_property("override_pipelines", [
|
||||
unreal.SoftObjectPath(pipeline.get_path_name()),
|
||||
unreal.SoftObjectPath("/Interchange/Pipelines/DefaultGLTFPipeline.DefaultGLTFPipeline")])
|
||||
task.set_editor_property("options", options)
|
||||
if source.suffix.lower() == ".fbx":
|
||||
options = unreal.FbxImportUI()
|
||||
options.set_editor_property("import_as_skeletal", False)
|
||||
options.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_STATIC_MESH)
|
||||
options.set_editor_property("automated_import_should_detect_type", False)
|
||||
options.static_mesh_import_data.set_editor_property("combine_meshes", True)
|
||||
task.set_editor_property("options", options)
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
meshes = [obj for obj in task.get_objects() if isinstance(obj, unreal.StaticMesh)]
|
||||
if len(meshes) != 1:
|
||||
raise RuntimeError(
|
||||
f"Import {source.name}: oczekiwano jednego StaticMesh, otrzymano {len(meshes)}. "
|
||||
"Polacz obiekty przed eksportem albo uzyj workshop.blender.import_character().")
|
||||
if source.suffix.lower() in {".glb", ".gltf"}:
|
||||
# UE preserves material edits on mesh reimport. In this workflow Blender
|
||||
# owns the look; explicitly synchronize the supported PBR parameters.
|
||||
from ._gltf import link_textures
|
||||
link_textures(source, meshes[0], destination)
|
||||
return meshes[0]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Export saved Blender files in a separate process, then import them in UE."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from .paths import PROJECT, executable, slug
|
||||
|
||||
|
||||
def export(filename, name="Character", collection="Character", blender=None):
|
||||
source = Path(filename).resolve()
|
||||
if not source.is_file() or source.suffix.lower() != ".blend":
|
||||
raise FileNotFoundError(f"Brak pliku .blend: {source}")
|
||||
destination = PROJECT / "build" / slug(name) / (name + ".glb")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [executable("blender", blender), "--background", str(source),
|
||||
"--python-exit-code", "1", "--python", str(PROJECT / "tools" / "blender_export.py"),
|
||||
"--", "--output", str(destination), "--collection", collection]
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=180)
|
||||
if result.returncode or not destination.is_file():
|
||||
raise RuntimeError("Eksport Blendera nie powiodl sie:\n" + (result.stdout + result.stderr)[-6000:])
|
||||
return destination
|
||||
|
||||
|
||||
def import_character(filename, name="Character", collection="Character", blender=None):
|
||||
from .assets import import_model
|
||||
return import_model(export(filename, name, collection, blender), name)
|
||||
@@ -0,0 +1,30 @@
|
||||
import unreal
|
||||
|
||||
|
||||
def look_at(actor, target):
|
||||
actor.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(
|
||||
actor.get_actor_location(), unreal.Vector(*target)), False)
|
||||
|
||||
|
||||
def create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45):
|
||||
actor = scene.spawn(unreal.CineCameraActor, "Camera", location)
|
||||
component = actor.get_cine_camera_component()
|
||||
filmback = component.get_editor_property("filmback")
|
||||
filmback.set_editor_property("sensor_width", 36.0)
|
||||
filmback.set_editor_property("sensor_height", 20.25)
|
||||
component.set_editor_property("filmback", filmback)
|
||||
component.set_editor_property("current_focal_length", float(focal_length))
|
||||
focus = component.get_editor_property("focus_settings")
|
||||
focus.set_editor_property("focus_method", unreal.CameraFocusMethod.DISABLE)
|
||||
component.set_editor_property("focus_settings", focus)
|
||||
settings = component.get_editor_property("post_process_settings")
|
||||
settings.set_editor_property("override_auto_exposure_bias", True)
|
||||
settings.set_editor_property("auto_exposure_bias", 0.0)
|
||||
settings.set_editor_property("override_auto_exposure_min_brightness", True)
|
||||
settings.set_editor_property("override_auto_exposure_max_brightness", True)
|
||||
# Extended luminance range interprets these as EV100, not linear brightness.
|
||||
settings.set_editor_property("auto_exposure_min_brightness", 8.0)
|
||||
settings.set_editor_property("auto_exposure_max_brightness", 8.0)
|
||||
component.set_editor_property("post_process_settings", settings)
|
||||
look_at(actor, target)
|
||||
return actor
|
||||
@@ -0,0 +1,23 @@
|
||||
import unreal
|
||||
|
||||
from .camera import look_at
|
||||
|
||||
|
||||
def area(scene, name="Light", location=(200, -200, 350), target=(0, 0, 100),
|
||||
intensity=12000, color=(1, 1, 1), size=180):
|
||||
actor = scene.spawn(unreal.RectLight, name, location)
|
||||
light = actor.get_component_by_class(unreal.RectLightComponent)
|
||||
light.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
light.set_editor_property("intensity", float(intensity))
|
||||
light.set_editor_property("source_width", float(size))
|
||||
light.set_editor_property("source_height", float(size))
|
||||
light.set_editor_property("attenuation_radius", 2000.0)
|
||||
light.set_light_color(unreal.LinearColor(*color, 1.0))
|
||||
look_at(actor, target)
|
||||
return actor
|
||||
|
||||
|
||||
def studio(scene, key_color=(1.0, 0.82, 0.65), fill_color=(0.5, 0.7, 1.0)):
|
||||
return [area(scene, "Key", (200, -250, 350), color=key_color),
|
||||
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
|
||||
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Small opaque PBR materials, generated without external assets."""
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, slug
|
||||
|
||||
|
||||
def color(name, rgb=(0.15, 0.5, 0.8), roughness=0.55, metallic=0.0):
|
||||
path = f"{GENERATED}/Materials/M_{slug(name)}"
|
||||
material = unreal.load_asset(path)
|
||||
if material is None:
|
||||
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
"M_" + name, GENERATED + "/Materials", unreal.Material, unreal.MaterialFactoryNew())
|
||||
if not isinstance(material, unreal.Material):
|
||||
raise TypeError(f"{path} nie jest materialem.")
|
||||
lib = unreal.MaterialEditingLibrary
|
||||
lib.delete_all_material_expressions(material)
|
||||
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -300, 0)
|
||||
base.set_editor_property("constant", unreal.LinearColor(*rgb, 1.0))
|
||||
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
for offset, value, prop in [(160, roughness, unreal.MaterialProperty.MP_ROUGHNESS),
|
||||
(280, metallic, unreal.MaterialProperty.MP_METALLIC)]:
|
||||
node = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -300, offset)
|
||||
node.set_editor_property("r", float(value))
|
||||
lib.connect_material_property(node, "", prop)
|
||||
lib.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
return material
|
||||
|
||||
|
||||
def assign(actor, material, slot=0):
|
||||
component = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if component is None:
|
||||
raise TypeError("Material mozna przypisac do aktora ze StaticMeshComponent.")
|
||||
component.set_material(slot, material)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Paths and process helpers shared by UE, Blender launchers and plain Python."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
PROJECT = Path(__file__).resolve().parents[3]
|
||||
GENERATED = "/Game/Workshop/Generated"
|
||||
|
||||
|
||||
def slug(value):
|
||||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", value):
|
||||
raise ValueError("Nazwa musi zaczynac sie litera i zawierac tylko litery ASCII, cyfry lub _.")
|
||||
return value
|
||||
|
||||
|
||||
def executable(name, explicit=None):
|
||||
candidate = explicit or os.environ.get("WORKSHOP_" + name.upper()) or shutil.which(name)
|
||||
if candidate and Path(candidate).is_file():
|
||||
return str(Path(candidate).resolve())
|
||||
raise FileNotFoundError(
|
||||
f"Nie znaleziono {name}. Dodaj program do PATH lub ustaw WORKSHOP_{name.upper()} "
|
||||
"na pelna sciezke do pliku wykonywalnego.")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Small append-only progress events shared with the external launcher."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def report(state, **details):
|
||||
target = os.environ.get("WORKSHOP_PROGRESS")
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
with Path(target).open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(dict(state=state, **details), ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
# Progress reporting must not interrupt a render or hide its result.
|
||||
pass
|
||||
@@ -0,0 +1,183 @@
|
||||
"""MRQ rendering with retained delegates, isolated output and completion status."""
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import PROJECT, executable
|
||||
from .video import encode
|
||||
from .progress import report
|
||||
|
||||
_active = None
|
||||
PRESETS = {"preview": (640, 360, 1, 8), "final": (1280, 720, 4, 32)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Renderer:
|
||||
"""Reusable render settings. Dimensions in pixels; warmup in frames."""
|
||||
|
||||
width: int = 640
|
||||
height: int = 360
|
||||
samples: int = 1
|
||||
warmup: int = 8
|
||||
mp4: bool = True
|
||||
ffmpeg: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
self._validate()
|
||||
|
||||
def _validate(self):
|
||||
for name, minimum in (("width", 1), ("height", 1), ("samples", 1), ("warmup", 0)):
|
||||
value = getattr(self, name)
|
||||
if type(value) is not int or value < minimum:
|
||||
raise ValueError(f"{name} musi byc liczba calkowita >= {minimum}.")
|
||||
|
||||
@classmethod
|
||||
def from_preset(cls, name="preview", **overrides):
|
||||
if name not in PRESETS:
|
||||
raise ValueError("Nieznany preset. Dostepne: " + ", ".join(PRESETS))
|
||||
settings = dict(zip(("width", "height", "samples", "warmup"), PRESETS[name]))
|
||||
settings.update(overrides)
|
||||
return cls(**settings)
|
||||
|
||||
def render(self, sequence, *, single_frame=None):
|
||||
self._validate()
|
||||
return _start(sequence, self, single_frame=single_frame)
|
||||
|
||||
|
||||
def is_rendering():
|
||||
return _active is not None
|
||||
|
||||
|
||||
def _write_status(data):
|
||||
target = os.environ.get("WORKSHOP_STATUS")
|
||||
if target:
|
||||
path = Path(target)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _on_error(executor, pipeline, fatal, message):
|
||||
if _active is not None:
|
||||
_active["errors"].append(str(message))
|
||||
unreal.log_error("[Workshop] MRQ: " + str(message))
|
||||
|
||||
|
||||
def _on_finished(executor, success):
|
||||
global _active
|
||||
state = _active
|
||||
if state is None:
|
||||
return
|
||||
result = {"success": False, "output": str(state["output"]), "settings": state["settings"]}
|
||||
try:
|
||||
if not success or state["errors"]:
|
||||
raise RuntimeError("Render nie powiodl sie: " + "; ".join(state["errors"]))
|
||||
frames = state["output"] / "frames"
|
||||
count = len(list(frames.glob("frame_*.png")))
|
||||
if count != state["frames"]:
|
||||
raise RuntimeError(f"Zapisano {count} klatek zamiast {state['frames']}.")
|
||||
if state["mp4"]:
|
||||
report("encoding")
|
||||
result["video"] = str(encode(frames, state["fps"], ffmpeg=state["ffmpeg"],
|
||||
expected_frames=state["frames"]))
|
||||
result["success"] = True
|
||||
unreal.log("[Workshop] Gotowe: " + str(state["output"]))
|
||||
except Exception as exc:
|
||||
result["error"] = str(exc)
|
||||
unreal.log_error("[Workshop] " + str(exc))
|
||||
finally:
|
||||
(state["output"] / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
||||
_write_status(result)
|
||||
_active = None
|
||||
if os.environ.get("WORKSHOP_BATCH") == "1":
|
||||
report("closing")
|
||||
unreal.EditorPythonScripting.set_keep_python_script_alive(False)
|
||||
|
||||
|
||||
def _begin_render(delta_seconds):
|
||||
"""Start after the script and pending Slate window updates have returned."""
|
||||
if _active is None:
|
||||
return
|
||||
_active["ticks"] -= 1
|
||||
if _active["ticks"] > 0:
|
||||
return
|
||||
unreal.unregister_slate_post_tick_callback(_active.pop("tick_handle"))
|
||||
try:
|
||||
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
|
||||
report("rendering", output=str(_active["output"]), frames=_active["frames"])
|
||||
# This installs the Sequencer playback-context guard needed during PIE.
|
||||
subsystem.render_queue_instance_with_executor_instance(_active["queue"], _active["executor"])
|
||||
except Exception as exc:
|
||||
_active["errors"].append(str(exc))
|
||||
_on_finished(_active["executor"], False)
|
||||
|
||||
|
||||
def start(sequence, preset="preview", mp4=True, ffmpeg=None, single_frame=None):
|
||||
"""Shortcut preserved for existing workshop scripts."""
|
||||
return Renderer.from_preset(preset, mp4=mp4, ffmpeg=ffmpeg).render(sequence, single_frame=single_frame)
|
||||
|
||||
|
||||
def _start(sequence, renderer, single_frame=None):
|
||||
global _active
|
||||
if _active is not None:
|
||||
raise RuntimeError("Trwa juz render warsztatowy.")
|
||||
if single_frame is not None and not 0 <= single_frame < sequence.frames:
|
||||
raise ValueError("Klatka poza zakresem sekwencji.")
|
||||
mp4 = renderer.mp4 and single_frame is None
|
||||
ffmpeg_path = executable("ffmpeg", renderer.ffmpeg) if mp4 else None
|
||||
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
|
||||
if subsystem.is_rendering():
|
||||
raise RuntimeError("Movie Render Queue jest zajete.")
|
||||
report("preparing_render")
|
||||
# Own queue: do not clear the user's Movie Render Queue.
|
||||
queue = unreal.MoviePipelineQueue()
|
||||
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
|
||||
sequence.save(open_editor=False)
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
|
||||
job.map = unreal.SoftObjectPath(sequence.scene.map_path)
|
||||
job.sequence = unreal.SoftObjectPath(sequence.asset.get_path_name())
|
||||
job.job_name = sequence.scene.name
|
||||
width, height, samples, warmup = renderer.width, renderer.height, renderer.samples, renderer.warmup
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
|
||||
output = PROJECT / "renders" / sequence.scene.name / stamp
|
||||
(output / "frames").mkdir(parents=True)
|
||||
config = job.get_configuration()
|
||||
settings = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
|
||||
settings.output_directory = unreal.DirectoryPath(str(output / "frames"))
|
||||
settings.file_name_format = "frame_{frame_number_rel}"
|
||||
settings.zero_pad_frame_numbers = 4
|
||||
settings.output_resolution = unreal.IntPoint(width, height)
|
||||
settings.use_custom_frame_rate = True
|
||||
settings.output_frame_rate = unreal.FrameRate(sequence.fps, 1)
|
||||
settings.use_custom_playback_range = True
|
||||
settings.custom_start_frame = single_frame if single_frame is not None else 0
|
||||
settings.custom_end_frame = single_frame + 1 if single_frame is not None else sequence.frames
|
||||
settings.flush_disk_writes_per_shot = True
|
||||
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
|
||||
config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
|
||||
aa = config.find_or_add_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
|
||||
aa.spatial_sample_count = samples
|
||||
aa.temporal_sample_count = 1
|
||||
aa.engine_warm_up_count = warmup
|
||||
aa.render_warm_up_count = warmup
|
||||
aa.render_warm_up_frames = True
|
||||
executor = unreal.MoviePipelinePIEExecutor()
|
||||
executor.on_executor_errored_delegate.add_callable_unique(_on_error)
|
||||
executor.on_executor_finished_delegate.add_callable_unique(_on_finished)
|
||||
_active = dict(executor=executor, queue=queue, output=output, errors=[], fps=sequence.fps,
|
||||
frames=1 if single_frame is not None else sequence.frames,
|
||||
mp4=mp4, ffmpeg=ffmpeg_path, ticks=2,
|
||||
settings=dict(width=width, height=height, samples=samples, warmup=warmup, fps=sequence.fps))
|
||||
try:
|
||||
_write_status({"state": "rendering", "output": str(output)})
|
||||
_active["tick_handle"] = unreal.register_slate_post_tick_callback(_begin_render)
|
||||
except Exception:
|
||||
_active = None
|
||||
raise
|
||||
unreal.log("[Workshop] Render: " + str(output))
|
||||
return output
|
||||
@@ -0,0 +1,86 @@
|
||||
"""An isolated generated map, simple meshes and a ready-to-use character."""
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, slug
|
||||
|
||||
|
||||
class Scene:
|
||||
"""Owns one map. Building again replaces only that generated map's actors."""
|
||||
|
||||
def __init__(self, name="MyShot"):
|
||||
from . import render
|
||||
if render.is_rendering() or unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem).is_rendering():
|
||||
raise RuntimeError("Poczekaj na zakonczenie renderu przed przebudowa sceny.")
|
||||
# Discard Sequencer's live bindings before replacing their actors.
|
||||
current_sequence = unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence()
|
||||
if current_sequence and current_sequence.get_path_name().startswith(GENERATED + "/"):
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
|
||||
self.name = slug(name)
|
||||
self.map_path = f"{GENERATED}/{name}/Map"
|
||||
self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
current = editor.get_current_level()
|
||||
current_path = current.get_outer().get_path_name().split(".")[0] if current else ""
|
||||
if current_path == self.map_path:
|
||||
# This map is wholly generated by this exercise, never a user's other map.
|
||||
for actor in self.subsystem.get_all_level_actors():
|
||||
if "Workshop" in [str(tag) for tag in actor.tags]:
|
||||
self.subsystem.destroy_actor(actor)
|
||||
else:
|
||||
if unreal.EditorLoadingAndSavingUtils.get_dirty_map_packages():
|
||||
raise RuntimeError("Zapisz zmiany aktualnej mapy przed utworzeniem sceny warsztatowej.")
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(self.map_path):
|
||||
if not editor.load_level(self.map_path):
|
||||
raise RuntimeError("Nie mozna otworzyc " + self.map_path)
|
||||
for actor in self.subsystem.get_all_level_actors():
|
||||
if "Workshop" in [str(tag) for tag in actor.tags]:
|
||||
self.subsystem.destroy_actor(actor)
|
||||
elif not editor.new_level(self.map_path):
|
||||
raise RuntimeError("Nie mozna utworzyc " + self.map_path)
|
||||
|
||||
def spawn(self, actor_class, name, location=(0, 0, 0), rotation=(0, 0, 0)):
|
||||
actor = self.subsystem.spawn_actor_from_class(actor_class, unreal.Vector(*location),
|
||||
unreal.Rotator(*rotation))
|
||||
if actor is None:
|
||||
raise RuntimeError("Nie mozna utworzyc aktora " + name)
|
||||
actor.set_actor_label(name)
|
||||
actor.tags = ["Workshop"]
|
||||
actor.set_folder_path("Workshop")
|
||||
return actor
|
||||
|
||||
def mesh(self, asset, name="Model", location=(0, 0, 0), scale=(1, 1, 1), material=None):
|
||||
mesh = unreal.load_asset(asset) if isinstance(asset, str) else asset
|
||||
if not isinstance(mesh, unreal.StaticMesh):
|
||||
raise TypeError(f"Oczekiwano StaticMesh: {asset}")
|
||||
actor = self.spawn(unreal.StaticMeshActor, name, location)
|
||||
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
actor.static_mesh_component.set_static_mesh(mesh)
|
||||
actor.set_actor_scale3d(unreal.Vector(*scale))
|
||||
if material is not None:
|
||||
actor.static_mesh_component.set_material(0, material)
|
||||
return actor
|
||||
|
||||
def primitive(self, shape="Cube", **kwargs):
|
||||
if shape not in {"Cube", "Sphere", "Cylinder", "Cone", "Plane"}:
|
||||
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
|
||||
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs)
|
||||
|
||||
def character(self, material=None, head_scale=1.0):
|
||||
root = self.spawn(unreal.StaticMeshActor, "Character")
|
||||
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
for shape, name, position, scale in [
|
||||
("Cube", "Body", (0, 0, 90), (0.55, 0.8, 0.9)),
|
||||
("Sphere", "Head", (0, 0, 175), (0.7 * head_scale,) * 3),
|
||||
("Cube", "LeftLeg", (0, -23, 25), (0.35, 0.3, 0.5)),
|
||||
("Cube", "RightLeg", (0, 23, 25), (0.35, 0.3, 0.5)),
|
||||
("Sphere", "Nose", (34, 0, 177), (0.24, 0.2, 0.2)),
|
||||
]:
|
||||
actor = self.primitive(shape, name=name, location=position, scale=scale, material=material)
|
||||
actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
|
||||
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
|
||||
return root
|
||||
|
||||
def save(self):
|
||||
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
if not editor.save_current_level():
|
||||
raise RuntimeError("Nie mozna zapisac mapy " + self.map_path)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, slug
|
||||
|
||||
|
||||
class Sequence:
|
||||
def __init__(self, scene, name="Shot", fps=24, frames=48):
|
||||
if not isinstance(fps, int) or fps < 1 or not isinstance(frames, int) or frames < 1:
|
||||
raise ValueError("FPS i liczba klatek musza byc dodatnimi liczbami calkowitymi.")
|
||||
self.scene, self.fps, self.frames = scene, fps, frames
|
||||
path = f"{GENERATED}/{scene.name}/LS_{slug(name)}"
|
||||
self.asset = unreal.load_asset(path)
|
||||
if self.asset and unreal.LevelSequenceEditorBlueprintLibrary.get_current_level_sequence() == self.asset:
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
|
||||
if self.asset is None:
|
||||
self.asset = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
"LS_" + name, f"{GENERATED}/{scene.name}", unreal.LevelSequence,
|
||||
unreal.LevelSequenceFactoryNew())
|
||||
for binding in self.asset.get_bindings():
|
||||
binding.remove()
|
||||
for track in self.asset.get_tracks():
|
||||
self.asset.remove_track(track)
|
||||
self.asset.set_display_rate(unreal.FrameRate(fps, 1))
|
||||
self.asset.set_playback_start(0)
|
||||
self.asset.set_playback_end(frames)
|
||||
self._bindings = {}
|
||||
self._transforms = {}
|
||||
|
||||
def bind(self, actor):
|
||||
key = actor.get_path_name()
|
||||
if key not in self._bindings:
|
||||
self._bindings[key] = self.asset.add_possessable(actor)
|
||||
return self._bindings[key]
|
||||
|
||||
def camera(self, actor):
|
||||
binding = self.bind(actor)
|
||||
tracks = self.asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)
|
||||
track = tracks[0] if tracks else self.asset.add_track(unreal.MovieSceneCameraCutTrack)
|
||||
for old in track.get_sections():
|
||||
track.remove_section(old)
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
binding_id = unreal.MovieSceneObjectBindingID()
|
||||
binding_id.set_editor_property("guid", binding.get_id())
|
||||
section.set_camera_binding_id(binding_id)
|
||||
|
||||
def transform_key(self, actor, frame, location=None, rotation=None, scale=None, interpolation="linear"):
|
||||
if not isinstance(frame, int) or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
modes = {"linear": unreal.MovieSceneKeyInterpolation.LINEAR,
|
||||
"auto": unreal.MovieSceneKeyInterpolation.AUTO,
|
||||
"constant": unreal.MovieSceneKeyInterpolation.CONSTANT}
|
||||
if interpolation not in modes:
|
||||
raise ValueError("Interpolacja: linear, auto lub constant.")
|
||||
key = actor.get_path_name()
|
||||
if key not in self._transforms:
|
||||
track = self.bind(actor).add_track(unreal.MovieScene3DTransformTrack)
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
channels = section.get_all_channels()
|
||||
initial = actor.get_actor_transform()
|
||||
r = initial.rotation.rotator()
|
||||
defaults = [*initial.translation.to_tuple(), r.roll, r.pitch, r.yaw,
|
||||
*initial.scale3d.to_tuple()]
|
||||
for channel, value in zip(channels, defaults):
|
||||
channel.set_default(float(value))
|
||||
self._transforms[key] = channels
|
||||
channels = self._transforms[key]
|
||||
for offset, values in [(0, location), (3, rotation), (6, scale)]:
|
||||
if values is not None:
|
||||
if len(values) != 3:
|
||||
raise ValueError("Transformacja wymaga trzech wartosci.")
|
||||
for channel, value in zip(channels[offset:offset + 3], values):
|
||||
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=modes[interpolation])
|
||||
|
||||
def save(self, open_editor=True):
|
||||
self.scene.save()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(self.asset)
|
||||
if open_editor:
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(self.asset)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""FFmpeg encoding; this module also works outside Unreal."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from .paths import executable
|
||||
|
||||
|
||||
def encode(frames, fps, output=None, ffmpeg=None, expected_frames=None):
|
||||
frames = Path(frames).resolve()
|
||||
if fps <= 0:
|
||||
raise ValueError("FPS musi byc dodatni.")
|
||||
images = sorted(frames.glob("frame_*.png"))
|
||||
count = len(images)
|
||||
if not count or any(p.name != f"frame_{i:04d}.png" for i, p in enumerate(images)):
|
||||
raise RuntimeError(f"Brak kompletnej numeracji frame_0000.png, frame_0001.png... w {frames}")
|
||||
if expected_frames is not None and count != expected_frames:
|
||||
raise RuntimeError(f"Render zapisal {count} klatek zamiast {expected_frames}.")
|
||||
output = Path(output) if output else frames.parent / "film.mp4"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [executable("ffmpeg", ffmpeg), "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-framerate", str(fps), "-start_number", "0", "-i", str(frames / "frame_%04d.png"),
|
||||
"-frames:v", str(count), "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-movflags", "+faststart", str(output)]
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError("FFmpeg: " + result.stderr[-4000:])
|
||||
return output.resolve()
|
||||
Reference in new issue
Block a user