advanced example
No files matched your search
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.analysis.extraPaths": ["${workspaceFolder}/Content/Python"],
|
||||
"editor.hover.enabled": true,
|
||||
"editor.inlayHints.enabled": "on",
|
||||
"python.analysis.inlayHints.callArgumentNames": "all"
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
"""Python rendering workshop for Unreal Engine 5.7."""
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Static exports for Pylance; runtime imports remain lazy so launchers do
|
||||
# not need Unreal's Python module installed in their regular interpreter.
|
||||
from .scene import Scene as Scene
|
||||
from .sequence import Sequence as Sequence
|
||||
from .render import Renderer as Renderer
|
||||
from . import camera as camera
|
||||
from . import lighting as lighting
|
||||
from . import materials as materials
|
||||
from . import assets as assets
|
||||
from . import blender as blender
|
||||
from . import render as render
|
||||
from . import video as video
|
||||
|
||||
__all__ = ["Scene", "Sequence", "Renderer", "camera", "lighting", "materials", "assets", "blender", "render", "video"]
|
||||
|
||||
|
||||
@@ -6,7 +6,20 @@ import unreal
|
||||
from .paths import GENERATED, PROJECT, slug
|
||||
|
||||
|
||||
def import_model(filename, name="Character"):
|
||||
def _existing_model(name):
|
||||
destination = f"{GENERATED}/Imports/{slug(name)}"
|
||||
if not unreal.EditorAssetLibrary.does_directory_exist(destination):
|
||||
return None
|
||||
meshes = [unreal.load_asset(path) for path in unreal.EditorAssetLibrary.list_assets(destination, recursive=True)]
|
||||
meshes = [obj for obj in meshes if isinstance(obj, unreal.StaticMesh)]
|
||||
return meshes[0] if len(meshes) == 1 else None
|
||||
|
||||
|
||||
def import_model(filename, name="Character", reimport=True):
|
||||
if not reimport:
|
||||
mesh = _existing_model(name)
|
||||
if mesh is not None:
|
||||
return mesh
|
||||
source = Path(filename).expanduser()
|
||||
source = (source if source.is_absolute() else PROJECT / source).resolve()
|
||||
if not source.is_file():
|
||||
|
||||
@@ -5,21 +5,27 @@ import subprocess
|
||||
from .paths import PROJECT, executable, slug
|
||||
|
||||
|
||||
def export(filename, name="Character", collection="Character", blender=None):
|
||||
def export(filename, name="Character", collection="Character", blender=None, skeletal=False):
|
||||
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 = PROJECT / "build" / slug(name) / (name + (".fbx" if skeletal else ".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]
|
||||
if skeletal:
|
||||
command.append("--skeletal")
|
||||
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
|
||||
def import_character(filename, name="Character", collection="Character", blender=None, reimport=True):
|
||||
from .assets import import_model, _existing_model
|
||||
if not reimport:
|
||||
mesh = _existing_model(name)
|
||||
if mesh is not None:
|
||||
return mesh
|
||||
return import_model(export(filename, name, collection, blender), name)
|
||||
@@ -1,3 +1,5 @@
|
||||
import math
|
||||
|
||||
import unreal
|
||||
|
||||
|
||||
@@ -6,11 +8,17 @@ def look_at(actor, target):
|
||||
actor.get_actor_location(), unreal.Vector(*target)), False)
|
||||
|
||||
|
||||
def create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45):
|
||||
def create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6):
|
||||
"""Create a camera with a horizontal field of view in degrees (0 < fov < 180)."""
|
||||
fov = float(fov)
|
||||
if not 0 < fov < 180:
|
||||
raise ValueError("fov must be between 0 and 180 degrees (exclusive)")
|
||||
sensor_width = 36.0
|
||||
focal_length = sensor_width / (2 * math.tan(math.radians(fov) / 2))
|
||||
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_width", sensor_width)
|
||||
filmback.set_editor_property("sensor_height", 20.25)
|
||||
component.set_editor_property("filmback", filmback)
|
||||
component.set_editor_property("current_focal_length", float(focal_length))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Bundled characters and every animation FBX in their source folders."""
|
||||
import re
|
||||
|
||||
from .paths import PROJECT, slug
|
||||
|
||||
BASE = PROJECT / "source_assets/character"
|
||||
FIGHTER_MODEL = BASE / "fighter/Maria WProp J J Ong.fbx"
|
||||
CREATURE_MODEL = BASE / "creature/Maw J Laygo.fbx"
|
||||
|
||||
|
||||
def _clips(model):
|
||||
clips = {}
|
||||
for path in sorted(model.parent.glob("*.fbx")):
|
||||
if path == model:
|
||||
continue
|
||||
key = re.sub(r"[^a-z0-9]+", "_", path.stem.lower()).strip("_")
|
||||
slug(key)
|
||||
if key in clips:
|
||||
raise ValueError(f"Powtorzona nazwa animacji: {key}")
|
||||
clips[key] = path
|
||||
return clips
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class Scene:
|
||||
if current_sequence and current_sequence.get_path_name().startswith(GENERATED + "/"):
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
|
||||
self.name = slug(name)
|
||||
self._skeletal_models = {}
|
||||
self.map_path = f"{GENERATED}/{name}/Map"
|
||||
self.subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
@@ -68,7 +69,7 @@ class Scene:
|
||||
raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.")
|
||||
return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs)
|
||||
|
||||
def model(self, filename, name="Character", height=200, rotation=(0, 0, 0)):
|
||||
def model(self, filename, name="Character", height=200, rotation=(0, 0, 0), reimport=False):
|
||||
"""Import a model, fit its height (cm), and give it a floor-centered pivot.
|
||||
|
||||
Returns a parent actor for transform animation; imported materials stay
|
||||
@@ -81,10 +82,10 @@ class Scene:
|
||||
source = source if source.is_absolute() else PROJECT / source
|
||||
if source.suffix.lower() == ".blend":
|
||||
from .blender import import_character
|
||||
mesh = import_character(source, name=name)
|
||||
mesh = import_character(source, name=name, reimport=reimport)
|
||||
else:
|
||||
from .assets import import_model
|
||||
mesh = import_model(source, name=name)
|
||||
mesh = import_model(source, name=name, reimport=reimport)
|
||||
actor = self.mesh(mesh, name=name + "Mesh")
|
||||
roll, pitch, yaw = rotation
|
||||
actor.set_actor_rotation(unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw), False)
|
||||
@@ -103,6 +104,45 @@ class Scene:
|
||||
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
|
||||
return root
|
||||
|
||||
def skeletal_model(self, filename, name="Character", height=200, rotation=(0, 0, 0),
|
||||
location=(0, 0, 0), animations=None, collection="Character", reimport=False):
|
||||
"""Import an animated FBX/.blend; return a parent actor for transform keys.
|
||||
|
||||
By default all animation FBXs beside the model are available by filename:
|
||||
shot.animation(actor, "great_sword_walk"). Optional animations={...}
|
||||
selects specific files; animations={} keeps only embedded animations.
|
||||
"""
|
||||
from .skeletal import import_model
|
||||
if height is not None and (not isinstance(height, (int, float)) or
|
||||
not math.isfinite(height) or height <= 0):
|
||||
raise ValueError("Wysokosc modelu musi byc dodatnia; None zachowuje rozmiar pliku.")
|
||||
imported = import_model(filename, name, animations, collection, reimport=reimport)
|
||||
child = self.spawn(unreal.SkeletalMeshActor, name + "Mesh")
|
||||
component = child.skeletal_mesh_component
|
||||
component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
component.set_skeletal_mesh_asset(imported.mesh)
|
||||
component.set_update_animation_in_editor(True)
|
||||
component.set_editor_property("visibility_based_anim_tick_option",
|
||||
unreal.VisibilityBasedAnimTickOption.ALWAYS_TICK_POSE_AND_REFRESH_BONES)
|
||||
roll, pitch, yaw = rotation
|
||||
child.set_actor_rotation(unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw), False)
|
||||
origin, extent = child.get_actor_bounds(False)
|
||||
if extent.z <= 0:
|
||||
self.subsystem.destroy_actor(child)
|
||||
raise ValueError("Model ma zerowa wysokosc. Sprawdz geometrie i rotacje.")
|
||||
if height is not None:
|
||||
factor = float(height) / (2 * extent.z)
|
||||
child.set_actor_scale3d(unreal.Vector(factor, factor, factor))
|
||||
origin, extent = child.get_actor_bounds(False)
|
||||
child.set_actor_location(unreal.Vector(-origin.x, -origin.y, extent.z - origin.z), False, False)
|
||||
root = self.spawn(unreal.StaticMeshActor, name)
|
||||
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
child.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
|
||||
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
|
||||
root.set_actor_location(unreal.Vector(*location), False, False)
|
||||
self._skeletal_models[root.get_path_name()] = (child, imported)
|
||||
return root
|
||||
|
||||
def character(self, material=None, head_scale=1.0):
|
||||
root = self.spawn(unreal.StaticMeshActor, "Character")
|
||||
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
|
||||
import math
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, slug
|
||||
@@ -26,6 +28,7 @@ class Sequence:
|
||||
self.asset.set_playback_end(frames)
|
||||
self._bindings = {}
|
||||
self._transforms = {}
|
||||
self._animations = {}
|
||||
|
||||
def bind(self, actor):
|
||||
key = actor.get_path_name()
|
||||
@@ -45,6 +48,82 @@ class Sequence:
|
||||
binding_id.set_editor_property("guid", binding.get_id())
|
||||
section.set_camera_binding_id(binding_id)
|
||||
|
||||
def _animation_blend(self, section, start, end, fade_in, fade_out):
|
||||
# The skeletal animation section exposes its Weight channel to Python.
|
||||
# add_key uses display frames and converts them to the sequence tick rate.
|
||||
weight = section.get_all_channels()[0]
|
||||
for key in weight.get_keys():
|
||||
weight.remove_key(key)
|
||||
weight.set_default(1.0)
|
||||
if fade_in or fade_out:
|
||||
keys = {start: 0.0 if fade_in else 1.0}
|
||||
if fade_in:
|
||||
keys[start + fade_in] = 1.0
|
||||
if fade_out:
|
||||
keys[end - fade_out] = 1.0
|
||||
keys[end] = 0.0
|
||||
for frame, value in sorted(keys.items()):
|
||||
weight.add_key(unreal.FrameNumber(frame), value,
|
||||
interpolation=unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
|
||||
def animation(self, actor, clip=None, start=0, end=None, speed=1.0):
|
||||
"""Play/loop an imported clip over [start, end) in sequence frames.
|
||||
|
||||
clip is an alias from scene.skeletal_model(animations=...), an AnimSequence,
|
||||
or None for the only embedded animation. Transform keys still move the parent.
|
||||
Overlapping clips automatically crossfade over their entire shared range.
|
||||
Add clips in timeline order; at most two clips can overlap at a time.
|
||||
"""
|
||||
end = self.frames if end is None else end
|
||||
if type(start) is not int or type(end) is not int or not 0 <= start < end <= self.frames:
|
||||
raise ValueError(f"Zakres animacji: 0 <= start < end <= {self.frames}.")
|
||||
if not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
|
||||
raise ValueError("Predkosc animacji musi byc dodatnia.")
|
||||
entry = self.scene._skeletal_models.get(actor.get_path_name())
|
||||
if entry is None:
|
||||
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
|
||||
child, imported = entry
|
||||
if clip is None:
|
||||
if len(imported.animations) != 1:
|
||||
raise ValueError("Wybierz animacje: " + ", ".join(imported.animations))
|
||||
clip = next(iter(imported.animations.values()))
|
||||
elif isinstance(clip, str):
|
||||
if clip not in imported.animations:
|
||||
raise ValueError("Nieznana animacja. Dostepne: " + ", ".join(imported.animations))
|
||||
clip = imported.animations[clip]
|
||||
if not isinstance(clip, unreal.AnimSequence):
|
||||
raise TypeError("clip musi byc nazwa zaimportowanej animacji lub AnimSequence.")
|
||||
if clip.get_editor_property("skeleton") != imported.mesh.get_editor_property("skeleton"):
|
||||
raise ValueError("Animacja i model musza uzywac tego samego szkieletu.")
|
||||
key = child.get_path_name()
|
||||
sections = self._animations.setdefault(key, [])
|
||||
overlaps = [old for old in sections if start < old[1] and old[0] < end]
|
||||
previous = None
|
||||
blend_frames = 0
|
||||
if overlaps:
|
||||
if (len(overlaps) != 1 or not overlaps[0][0] < start or
|
||||
overlaps[0][1] >= end or
|
||||
overlaps[0][0] + overlaps[0][4] > start):
|
||||
raise ValueError("Dodawaj klipy w kolejnosci odtwarzania. Blend wymaga czesciowego "
|
||||
"nakladania dwoch klipow, bez trzech jednoczesnych animacji.")
|
||||
previous = overlaps[0]
|
||||
blend_frames = previous[1] - start
|
||||
track = sections[0][2] if sections else self.bind(child).add_track(unreal.MovieSceneSkeletalAnimationTrack)
|
||||
section = track.add_section()
|
||||
section.set_range(start, end)
|
||||
params = section.get_editor_property("params")
|
||||
params.set_editor_property("animation", clip)
|
||||
rate = unreal.MovieSceneTimeWarpVariant()
|
||||
rate.set_fixed_play_rate(float(speed))
|
||||
params.set_editor_property("play_rate", rate)
|
||||
params.set_editor_property("force_custom_mode", True)
|
||||
section.set_editor_property("params", params)
|
||||
self._animation_blend(section, start, end, blend_frames, 0)
|
||||
if previous:
|
||||
self._animation_blend(previous[3], previous[0], previous[1], previous[4], blend_frames)
|
||||
sections.append((start, end, track, section, blend_frames))
|
||||
return section
|
||||
|
||||
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}.")
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""FBX skeletal meshes and animation clips, including exports from Blender."""
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, PROJECT, slug
|
||||
from .characters import _clips
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkeletalImport:
|
||||
mesh: unreal.SkeletalMesh
|
||||
animations: dict[str, unreal.AnimSequence]
|
||||
|
||||
|
||||
def _source(filename):
|
||||
source = Path(filename).expanduser()
|
||||
source = (source if source.is_absolute() else PROJECT / source).resolve()
|
||||
if not source.is_file() or source.suffix.lower() != ".fbx":
|
||||
raise ValueError(f"Wymagany istniejacy FBX ze szkieletem: {source}")
|
||||
return source
|
||||
|
||||
|
||||
def _cached(destination, name, skeleton=None):
|
||||
asset = unreal.load_asset(destination + "/" + name)
|
||||
expected = unreal.SkeletalMesh if skeleton is None else unreal.AnimSequence
|
||||
if not isinstance(asset, expected):
|
||||
return None
|
||||
objects = [asset]
|
||||
if skeleton is None:
|
||||
for path in unreal.EditorAssetLibrary.list_assets(destination, recursive=False, include_folder=False):
|
||||
clip = unreal.load_asset(path)
|
||||
if isinstance(clip, unreal.AnimSequence):
|
||||
objects.append(clip)
|
||||
elif asset.get_editor_property("skeleton") != skeleton:
|
||||
return None
|
||||
return objects
|
||||
|
||||
|
||||
def _import(source, destination, name, skeleton=None, reimport=False):
|
||||
if not reimport:
|
||||
objects = _cached(destination, name, skeleton)
|
||||
if objects is not None:
|
||||
return objects
|
||||
options = unreal.FbxImportUI()
|
||||
options.set_editor_property("automated_import_should_detect_type", False)
|
||||
options.set_editor_property("import_as_skeletal", True)
|
||||
options.set_editor_property("import_mesh", skeleton is None)
|
||||
options.set_editor_property("import_animations", True)
|
||||
options.set_editor_property("create_physics_asset", False)
|
||||
options.set_editor_property("import_materials", skeleton is None)
|
||||
options.set_editor_property("import_textures", skeleton is None)
|
||||
options.set_editor_property("override_animation_name", name + "_Anim" if skeleton is None else name)
|
||||
options.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_SKELETAL_MESH
|
||||
if skeleton is None else unreal.FBXImportType.FBXIT_ANIMATION)
|
||||
if skeleton is not None:
|
||||
options.set_editor_property("skeleton", skeleton)
|
||||
options.anim_sequence_import_data.set_editor_property(
|
||||
"animation_length", unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME)
|
||||
options.anim_sequence_import_data.set_editor_property("snap_to_closest_frame_boundary", True)
|
||||
task = unreal.AssetImportTask()
|
||||
for key, value in {"filename": str(source), "destination_path": destination,
|
||||
"destination_name": name, "automated": True, "replace_existing": True,
|
||||
"replace_existing_settings": True, "save": True, "async_": False,
|
||||
"options": options, "factory": unreal.FbxFactory()}.items():
|
||||
task.set_editor_property(key, value)
|
||||
# UE 5.7 otherwise routes FBX reimports through Interchange, which does not
|
||||
# consistently honor FbxImportUI (notably the target animation skeleton).
|
||||
flag = "Interchange.FeatureFlags.Import.FBX"
|
||||
previous = unreal.SystemLibrary.get_console_variable_int_value(flag)
|
||||
unreal.SystemLibrary.execute_console_command(None, flag + " 0")
|
||||
try:
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
objects = list(task.get_objects())
|
||||
# Saving the mesh alone can leave its new skeleton/materials unsaved.
|
||||
unreal.EditorAssetLibrary.save_directory(destination, only_if_is_dirty=True, recursive=True)
|
||||
# Legacy FBX reports the primary mesh only; embedded animation assets
|
||||
# are registered alongside it rather than included in task.get_objects().
|
||||
paths = {obj.get_path_name() for obj in objects}
|
||||
embedded = (unreal.EditorAssetLibrary.list_assets(destination, recursive=False, include_folder=False)
|
||||
if skeleton is None else [])
|
||||
for path in embedded:
|
||||
obj = unreal.load_asset(path)
|
||||
if isinstance(obj, unreal.AnimSequence) and obj.get_path_name() not in paths:
|
||||
objects.append(obj)
|
||||
return objects
|
||||
finally:
|
||||
unreal.SystemLibrary.execute_console_command(None, f"{flag} {previous}")
|
||||
|
||||
|
||||
def import_animation(filename, skeleton, name="Clip", destination=None, reimport=False):
|
||||
"""Import a clip for this exact skeleton; no automatic retargeting."""
|
||||
source = _source(filename)
|
||||
name = slug(name)
|
||||
objects = _import(source, destination or f"{GENERATED}/Skeletal/{name}/Animations", name, skeleton,
|
||||
reimport=reimport)
|
||||
clips = [obj for obj in objects if isinstance(obj, unreal.AnimSequence)]
|
||||
if len(clips) != 1:
|
||||
raise RuntimeError(f"{source.name}: oczekiwano jednej animacji, otrzymano {len(clips)}.")
|
||||
if clips[0].get_editor_property("skeleton") != skeleton:
|
||||
raise ValueError("Animacja ma inny szkielet. Uzyj animacji dla tej samej postaci z Mixamo.")
|
||||
return clips[0]
|
||||
|
||||
|
||||
def import_model(filename, name="Character", animations=None, collection="Character", reimport=False):
|
||||
"""Import an FBX/.blend and discover animation FBXs beside the source model.
|
||||
|
||||
animations=None loads all neighboring clips using names derived from filenames.
|
||||
Pass a dictionary to select files explicitly; {} skips neighboring clips.
|
||||
"""
|
||||
source = Path(filename).expanduser()
|
||||
source = (source if source.is_absolute() else PROJECT / source).resolve()
|
||||
if animations is None:
|
||||
animations = _clips(source)
|
||||
name = slug(name)
|
||||
destination = f"{GENERATED}/Skeletal/{name}"
|
||||
objects = None if reimport else _cached(destination, name)
|
||||
if objects is None:
|
||||
if source.suffix.lower() == ".blend":
|
||||
from .blender import export
|
||||
source = export(source, name=name, collection=collection, skeletal=True)
|
||||
source = _source(source)
|
||||
objects = _import(source, destination, name, reimport=True)
|
||||
meshes = [obj for obj in objects if isinstance(obj, unreal.SkeletalMesh)]
|
||||
if len(meshes) != 1:
|
||||
raise RuntimeError(f"{source.name}: oczekiwano jednego SkeletalMesh, otrzymano {len(meshes)}. "
|
||||
"Eksportuj jedna postac ze szkieletem i wagami (Mixamo: With Skin).")
|
||||
clips = {obj.get_name(): obj for obj in objects if isinstance(obj, unreal.AnimSequence)}
|
||||
skeleton = meshes[0].get_editor_property("skeleton")
|
||||
for alias, filename in (animations or {}).items():
|
||||
clips[alias] = import_animation(filename, skeleton, name=slug(alias),
|
||||
destination=destination + "/Animations", reimport=reimport)
|
||||
return SkeletalImport(meshes[0], clips)
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
UE **5.7**, Windows / Linux. Biblioteka `ue_workshop` automatyzuje scenę,
|
||||
Sequencer, Movie Render Queue i składanie MP4. Nie wymaga kompilacji C++ ani pip.
|
||||
Pierwsze ćwiczenie korzysta wyłącznie z brył silnika. Blender jest opcjonalny.
|
||||
Plik do pokazu podstaw korzysta z własnego modelu z Blendera i obrotu kamery.
|
||||
Zadanie z animacjami korzysta z dołączonych postaci Mixamo i nie wymaga Blendera.
|
||||
|
||||
Jeśli używasz wcześniejszej kopii projektu z C++ i edytor zgłasza
|
||||
`Target Upgrade Required`, zamknij UE i usuń pozostałości starej kompilacji:
|
||||
@@ -23,19 +24,40 @@ automatyzacji edytora; nie służy tu do gameplayu.
|
||||
|
||||
## Pierwszy skrypt w edytorze
|
||||
|
||||
Pliki do prowadzenia zajęć:
|
||||
|
||||
- `exercises/basics.py` — prosty pokaz: `character_custom.blend`, podłoga, światła
|
||||
i kamera robiąca pełne 360° w 8 sekund. Postać pozostaje nieruchoma.
|
||||
- `exercises/animation_starter.py` — punkt wyjścia dla studentów: jedna creature,
|
||||
dwie animacje (`mutant_idle`, `mutant_swiping`) i 15 sekund na eksperymenty. Własną scenę walki
|
||||
tworzą przez dodanie klipów, ruchu, drugiej postaci i zmian kamery.
|
||||
- `exercises/my_shot.py` — dotychczasowy rozbudowany plik z wyborem modelu.
|
||||
- `examples/mixamo_characters.py` — fighter i creature z pełnymi słownikami klipów;
|
||||
zachowany układ sceny i dotychczasowe klipy, gotowe do własnej sceny walki.
|
||||
|
||||
W nowych plikach render jest zakomentowany. Najpierw obejrzyj scenę w Sequencerze,
|
||||
potem odkomentuj `renderer.render(shot)`. Każdy plik tworzy własną nazwaną mapę.
|
||||
Przed edycją zrób kopię pliku; ponowne uruchomienie przebudowuje jego scenę.
|
||||
|
||||
`REIMPORT = False` używa istniejących modeli i klipów w UE. Brakujące assety
|
||||
importowane są przy pierwszym uruchomieniu. `REIMPORT = True` wymusza ponowny
|
||||
import wszystkich wskazanych modeli i klipów, również eksport `.blend` z Blendera.
|
||||
Po odświeżeniu ustaw ponownie `False`. Zmiany plików nie są wykrywane automatycznie.
|
||||
|
||||
Zapisz aktualną mapę. Wybierz **File → Execute Python Script** i wskaż
|
||||
`exercises/my_shot.py`. Możesz też wpisać w konsoli UE:
|
||||
`exercises/basics.py`. Możesz też wpisać w konsoli UE:
|
||||
|
||||
```text
|
||||
py "C:/sciezka/do/projektu/exercises/my_shot.py"
|
||||
py "C:/sciezka/do/projektu/exercises/basics.py"
|
||||
```
|
||||
|
||||
Powstanie prosta postać, podłoga, trzy światła, kamera i dwusekundowa animacja.
|
||||
Obejrzyj ją w Sequencerze. Zmień kolor, `head_scale`, kamerę albo klucze i uruchom
|
||||
Powstanie własna postać z Blendera, podłoga, trzy światła i ośmiosekundowy objazd kamerą.
|
||||
Obejrzyj go w Sequencerze. Zmień model, kolor podłogi, kamerę albo klucze i uruchom
|
||||
plik ponownie. Skrypt jest odczytywany ponownie z dysku.
|
||||
|
||||
Ustaw `RENDER = True`, aby wyrenderować film. Domyślny preset `preview` to
|
||||
640×360, 24 FPS, 48 klatek. `final` to 1280×720 i więcej próbek.
|
||||
W `my_shot.py` ustaw `RENDER = True`, aby wyrenderować film. Preset `preview` to 640×360,
|
||||
a `final` to 1280×720 i więcej próbek. FPS i długość filmu pochodzą z `Sequence`.
|
||||
Ustawienia własnego renderera są na końcu ćwiczenia.
|
||||
Szybkie sprawdzenie obrazu: zamiast pełnego renderu użyj
|
||||
`renderer.render(shot, single_frame=24)`.
|
||||
|
||||
@@ -135,15 +157,17 @@ W `exercises/my_shot.py` wybierz `MODEL`:
|
||||
| Wartość | Postać |
|
||||
|---|---|
|
||||
| `"primitive"` | Dotychczasowa postać z brył, bez importu. |
|
||||
| `"robot"` | RobotPolice z dołączonego `DemoAssets`. |
|
||||
| `"pilot"` | Humanoid z `DemoAssets/pilot`. |
|
||||
| `"fighter"` | Wojowniczka z `source_assets/character/fighter`. Domyślna postać. |
|
||||
| `"creature"` | Stwór z `source_assets/character/creature`. |
|
||||
| `"file"` | Własny plik wskazany w `MODEL_FILE`. |
|
||||
|
||||
Przykład własnego modelu:
|
||||
|
||||
```python
|
||||
MODEL = "file"
|
||||
MODEL_FILE = PROJECT / "source_assets/models/my_character.glb"
|
||||
MODEL_FILE = PROJECT / "source_assets/character/my_character.fbx"
|
||||
SKELETAL = True
|
||||
ANIMATION_FILE = None # animacja zawarta w FBX; albo ścieżka do osobnego klipu FBX
|
||||
MODEL_HEIGHT = 200
|
||||
MODEL_ROTATION = (0, 0, 90)
|
||||
```
|
||||
@@ -161,16 +185,89 @@ dopasowaniem wysokości. Pivot animacji znajduje się na środku podstawy.
|
||||
|
||||
Dla pojedynczego atlasu FBX/OBJ można dołączyć mapy `NazwaModelu_BC.png`,
|
||||
`_N`, `_R`, `_M`, `_E` (również JPG/JPEG). Biblioteka odtworzy z nich jeden
|
||||
materiał dla wszystkich slotów. Robot i pilot korzystają z tej konwencji.
|
||||
materiał dla wszystkich slotów w statycznym przepływie `scene.model(...)`.
|
||||
Bez pliku `_BC` pozostają materiały zaimportowane przez UE. W GLB zachowywany
|
||||
jest osobny materiał dla każdego slotu.
|
||||
|
||||
Po zmianie postaci reszta skryptu działa tak samo: klucze `location`, `rotation`
|
||||
i `scale` animują cały model. W przykładzie jest też opcjonalny podskok.
|
||||
Modele z rigami są w tej wersji importowane jako statyczna geometria:
|
||||
animacje kości, chodzenie z Mixamo i retargeting nie są jeszcze obsługiwane.
|
||||
Dla `fighter` i `creature` użyj nazwy pliku bez rozszerzenia, ze spacjami zamienionymi
|
||||
na podkreślenia: np. `great_sword_walk` lub `mutant_swiping`. Dostępny jest
|
||||
[pełny katalog animacji](docs/animations.md): 51 klipów fighter i 12 klipów creature.
|
||||
`scene.skeletal_model(...)` samo znajduje wszystkie pliki FBX w folderze postaci
|
||||
poza modelem. Nie wymaga `animations` ani ręcznych aliasów. Import tworzy SkeletalMesh, szkielet,
|
||||
materiały i wszystkie klipy. Sequencer odtwarza
|
||||
animację kości przez 120 klatek (5 s); krótszy klip zapętla się w swojej sekcji.
|
||||
Klucze `location`, `rotation` i `scale` mogą dodatkowo poruszać całym modelem.
|
||||
Przykład dwóch postaci z różnymi klipami: `examples/mixamo_characters.py`.
|
||||
|
||||
```python
|
||||
character = scene.skeletal_model(
|
||||
PROJECT / "source_assets/character/creature/Maw J Laygo.fbx",
|
||||
name="Creature", height=200, location=(150, 0, 0),
|
||||
)
|
||||
shot.animation(character, "mutant_walking")
|
||||
```
|
||||
|
||||
`shot.animation(character, "mutant_walking", start=0, end=48, speed=0.8)` odtwarza klip
|
||||
w zakresie `[0, 48)` z prędkością 80%. Następny klip może zacząć się w klatce 48.
|
||||
Nakładające się klipy tej samej postaci automatycznie tworzą płynne przejście
|
||||
przez cały wspólny zakres. Helper ustawia narastanie wagi nowego klipu
|
||||
i zanikanie poprzedniego; nie trzeba podawać dodatkowego parametru:
|
||||
|
||||
```python
|
||||
shot.animation(character, "mutant_idle", start=0, end=60)
|
||||
shot.animation(character, "mutant_walking", start=48, end=120)
|
||||
```
|
||||
|
||||
Przy 24 FPS przejście na klatkach 48–60 trwa 0,5 sekundy. Dodawaj klipy w kolejności
|
||||
odtwarzania; naraz mogą mieszać się dwie animacje tej samej postaci.
|
||||
Klipy stykające się końcami przechodzą bez blendu. Nie trzeba ponownie importować animacji.
|
||||
Przy kolejnych przejściach środkowy klip zachowa zarówno wejście, jak i wyjście.
|
||||
Pełne zawieranie jednego klipu w drugim oraz identyczne zakresy są odrzucane.
|
||||
`shot.animation(character)`
|
||||
wybiera jedyny klip zawarty w modelu. Każda postać zachowuje swój szkielet;
|
||||
automatyczny retargeting między różnymi postaciami nie jest wykonywany.
|
||||
|
||||
W Mixamo pobierz FBX **With Skin**, aby mieć model, szkielet i animację w jednym
|
||||
pliku. Kolejne klipy pobieraj dla tej samej wybranej postaci; mogą być **Without Skin**.
|
||||
Umieść je obok pliku modelu — są wykrywane automatycznie, pod nazwami plików.
|
||||
Opcjonalne `animations={"mutant_idle": path1, "mutant_walking": path2}` ogranicza
|
||||
wybór do konkretnych klipów. Puste `animations={}` pomija animacje z folderu
|
||||
i pozostawia tylko klipy zawarte w pliku modelu.
|
||||
Jeśli samodzielnie animujesz trasę przez `transform_key`, wybierz **In Place**,
|
||||
gdy jest dostępne, aby animacja kości nie dodawała własnego przesuwania.
|
||||
|
||||
Importy szkieletowe trafiają do `Content/Workshop/Generated/Skeletal`.
|
||||
Przy `REIMPORT=True` aktualizowane są te same assety. Pivot jest na środku podstawy
|
||||
pozy referencyjnej; podczas animacji obrys i pozycja stóp mogą się zmieniać.
|
||||
Dla statycznego GLB, FBX, OBJ lub `.blend` ustaw `MODEL="file"`, `SKELETAL=False`.
|
||||
Dotychczasowe `scene.model(...)` zachowuje statyczny przepływ importu.
|
||||
Wybrany model działa zarówno w edytorze, jak i przez `my_shot_headless.py`.
|
||||
|
||||
### Mixamo → Blender → Unreal
|
||||
|
||||
Zaimportuj do Blendera FBX ze szkieletem i animacją. Zmieniaj geometrię, zachowując
|
||||
Armature, wagi wierzchołków i modyfikator Armature. Siatki umieść w kolekcji
|
||||
`Character`; muszą być przypisane do jednego Armature. Wybierz aktywną akcję
|
||||
i ustaw zakres klatek sceny obejmujący animację. Zapisz `.blend`, a w ćwiczeniu ustaw:
|
||||
|
||||
```python
|
||||
MODEL = "file"
|
||||
MODEL_FILE = PROJECT / "source_assets/character/my_animated_character.blend"
|
||||
SKELETAL = True
|
||||
ANIMATION_FILE = None
|
||||
```
|
||||
|
||||
Python uruchomi Blendera w tle, wyeksportuje siatki, szkielet i aktywną animację
|
||||
do FBX, a następnie zaimportuje je w UE. Eksport obejmuje aktywną akcję w zakresie
|
||||
sceny; nie eksportuje osobnych akcji i pasków NLA jako wielu klipów. Materiały FBX
|
||||
przenoszą podstawowe kolory i obsługiwane tekstury. Proceduralne materiały wypal
|
||||
do tekstur; statyczne modele z pełniejszym PBR nadal eksportuj do GLB.
|
||||
|
||||
Blender potrzebuje binarnego FBX 7.1 lub nowszego. Dołączone fighter i creature
|
||||
są w starym FBX 6.1: UE importuje je bezpośrednio, ale Blender 5.1 ich nie otworzy.
|
||||
Do edycji w Blenderze pobierz współczesny wariant FBX albo wyeksportuj SkeletalMesh
|
||||
i animację z UE do nowego FBX.
|
||||
|
||||
## Blender → UE → film
|
||||
|
||||
Własną kopię wybierzesz też bezpośrednio w `my_shot.py`:
|
||||
@@ -178,9 +275,10 @@ Własną kopię wybierzesz też bezpośrednio w `my_shot.py`:
|
||||
```python
|
||||
MODEL = "file"
|
||||
MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend"
|
||||
SKELETAL = False # ten przykład to statyczne siatki bez Armature
|
||||
```
|
||||
|
||||
Zapisz zmiany w Blenderze i ponownie uruchom ćwiczenie. Za każdym razem plik
|
||||
Zapisz zmiany w Blenderze i uruchom ćwiczenie z `REIMPORT=True`. Plik
|
||||
jest eksportowany w tle i aktualizuje ten sam asset `CustomCharacter` w UE.
|
||||
Nie trzeba ręcznie usuwać poprzedniego modelu. Eksport obejmuje kolekcję `Character`.
|
||||
Na Windows biblioteka wykrywa też standardowe instalacje w `Blender Foundation`,
|
||||
@@ -212,13 +310,16 @@ Tekstury PNG/JPEG są wyciągane z GLB i aktualizowane w wydzielonym katalogu
|
||||
`WorkshopTextures`. Parametry PBR synchronizujemy jawnie, ponieważ standardowy
|
||||
reimport siatki w UE zachowuje wcześniejsze materiały. Zmiany w tych importowanych
|
||||
materiałach wykonuj w Blenderze; skrypt nadpisuje wspierane parametry ich źródłem.
|
||||
Dowolne grafy proceduralne, rig, animacje szkieletowe, włosy i shader skóry nie
|
||||
wchodzą w zakres v1. Materiały proceduralne należy wypalić do tekstur.
|
||||
Dowolne grafy proceduralne, włosy i shader skóry nie są przenoszone w tym przepływie.
|
||||
Materiały proceduralne należy wypalić do tekstur. Modele z Armature obsługuje
|
||||
`scene.skeletal_model(...)`, opisane wyżej.
|
||||
|
||||
## Układ i zasady
|
||||
|
||||
- `Content/Python/ue_workshop/` — biblioteka, automatycznie dostępna w Pythonie UE.
|
||||
- `exercises/my_shot.py` — jedyny wymagany plik uczestnika; zrób kopię przed edycją.
|
||||
- `exercises/basics.py` — pokaz podstaw na własnym modelu z Blendera.
|
||||
- `exercises/animation_starter.py` — plik startowy zadania z dwiema animacjami.
|
||||
- `exercises/my_shot.py` — rozbudowany wariant z wyborem postaci.
|
||||
- `examples/` — dodatkowa kamera i Blender; ćwiczenie startowe jest też rozwiązaniem bazowym.
|
||||
- `tools/` — launcher, eksport/generator Blendera i testy integracyjne.
|
||||
- `docs/` — ściąga API i scenariusz spotkania.
|
||||
@@ -241,6 +342,8 @@ python -m unittest discover -s tests -v
|
||||
python tools/run_workshop.py tools/smoke_scene.py
|
||||
python tools/run_workshop.py tools/smoke_blender.py
|
||||
python tools/run_workshop.py tools/smoke_renderer.py
|
||||
python tools/run_workshop.py tools/smoke_skeletal.py --render
|
||||
python tools/run_workshop.py tools/smoke_workshop_files.py
|
||||
```
|
||||
|
||||
Pierwszy test integracyjny wykonuje ćwiczenie dwa razy, sprawdza duplikaty,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Animacje postaci
|
||||
|
||||
`scene.skeletal_model(...)` automatycznie znajduje każdy FBX z folderu postaci
|
||||
poza plikiem samego modelu: 51 klipów fighter i 12 klipów creature.
|
||||
Wykorzystuje funkcję `_clips()` z `Content/Python/ue_workshop/characters.py`.
|
||||
Nowy FBX dodany do folderu zostanie uwzględniony przy następnym uruchomieniu skryptu.
|
||||
Trzymaj model i animacje tej samej postaci w osobnym folderze.
|
||||
|
||||
Nazwy poniżej są kluczami do `animations` i `shot.animation(...)`.
|
||||
Spacje i nawiasy z nazw plików są zamieniane na podkreślenia. Nie ma skrótów
|
||||
`idle`, `walk` i `attack`; używaj nazw takich jak `great_sword_idle` czy `mutant_swiping`.
|
||||
|
||||
Pełny przykład `examples/mixamo_characters.py` importuje wszystkie klipy.
|
||||
Plik zadania `exercises/animation_starter.py` importuje tylko dwie wybrane animacje.
|
||||
Aby udostępnić wszystkie klipy, pomiń argument `animations`:
|
||||
|
||||
```python
|
||||
character = scene.skeletal_model(CREATURE_MODEL, name="Creature", reimport=False)
|
||||
shot.animation(character, "mutant_jumping", start=240, end=360)
|
||||
```
|
||||
|
||||
Opcjonalne `animations={...}` ogranicza wybór do wskazanych plików;
|
||||
`animations={}` pozostawia tylko klipy osadzone w samym modelu.
|
||||
Krótsze klipy zapętlają się w swoim zakresie. Nakładanie zakresów automatycznie
|
||||
tworzy płynne przejście z Pythona:
|
||||
|
||||
```python
|
||||
shot.animation(character, "mutant_idle", start=0, end=60)
|
||||
shot.animation(character, "mutant_swiping", start=48, end=120)
|
||||
```
|
||||
|
||||
Wspólny zakres 48–60 miesza obie animacje przez 12 klatek (0,5 s przy 24 FPS).
|
||||
Nowy klip narasta od wagi 0 do 1, a poprzedni zanika od 1 do 0.
|
||||
Dodawaj klipy w kolejności odtwarzania. Długość blendu wynika wyłącznie z zakresów.
|
||||
Klipy stykające się końcami przechodzą bez blendu; zakresy nie mogą być identyczne
|
||||
ani całkowicie zawierać się jeden w drugim.
|
||||
Obsługiwane są przejścia między dwiema animacjami; trzy jednoczesne klipy są odrzucane.
|
||||
|
||||
|
||||
## Fighter
|
||||
|
||||
| Clip | FBX |
|
||||
|---|---|
|
||||
| `draw_a_great_sword_1` | `draw a great sword 1.fbx` |
|
||||
| `draw_a_great_sword_2` | `draw a great sword 2.fbx` |
|
||||
| `great_sword_180_turn` | `great sword 180 turn.fbx` |
|
||||
| `great_sword_180_turn_2` | `great sword 180 turn (2).fbx` |
|
||||
| `great_sword_attack` | `great sword attack.fbx` |
|
||||
| `great_sword_blocking` | `great sword blocking.fbx` |
|
||||
| `great_sword_blocking_2` | `great sword blocking (2).fbx` |
|
||||
| `great_sword_blocking_3` | `great sword blocking (3).fbx` |
|
||||
| `great_sword_casting` | `great sword casting.fbx` |
|
||||
| `great_sword_crouching` | `great sword crouching.fbx` |
|
||||
| `great_sword_crouching_2` | `great sword crouching (2).fbx` |
|
||||
| `great_sword_crouching_3` | `great sword crouching (3).fbx` |
|
||||
| `great_sword_crouching_4` | `great sword crouching (4).fbx` |
|
||||
| `great_sword_crouching_5` | `great sword crouching (5).fbx` |
|
||||
| `great_sword_crouching_6` | `great sword crouching (6).fbx` |
|
||||
| `great_sword_high_spin_attack` | `great sword high spin attack.fbx` |
|
||||
| `great_sword_idle` | `great sword idle.fbx` |
|
||||
| `great_sword_idle_2` | `great sword idle (2).fbx` |
|
||||
| `great_sword_idle_3` | `great sword idle (3).fbx` |
|
||||
| `great_sword_idle_4` | `great sword idle (4).fbx` |
|
||||
| `great_sword_idle_5` | `great sword idle (5).fbx` |
|
||||
| `great_sword_impact` | `great sword impact.fbx` |
|
||||
| `great_sword_impact_2` | `great sword impact (2).fbx` |
|
||||
| `great_sword_impact_3` | `great sword impact (3).fbx` |
|
||||
| `great_sword_impact_4` | `great sword impact (4).fbx` |
|
||||
| `great_sword_impact_5` | `great sword impact (5).fbx` |
|
||||
| `great_sword_jump` | `great sword jump.fbx` |
|
||||
| `great_sword_jump_2` | `great sword jump (2).fbx` |
|
||||
| `great_sword_jump_attack` | `great sword jump attack.fbx` |
|
||||
| `great_sword_kick` | `great sword kick.fbx` |
|
||||
| `great_sword_kick_2` | `great sword kick (2).fbx` |
|
||||
| `great_sword_power_up` | `great sword power up.fbx` |
|
||||
| `great_sword_run` | `great sword run.fbx` |
|
||||
| `great_sword_run_2` | `great sword run (2).fbx` |
|
||||
| `great_sword_slash` | `great sword slash.fbx` |
|
||||
| `great_sword_slash_2` | `great sword slash (2).fbx` |
|
||||
| `great_sword_slash_3` | `great sword slash (3).fbx` |
|
||||
| `great_sword_slash_4` | `great sword slash (4).fbx` |
|
||||
| `great_sword_slash_5` | `great sword slash (5).fbx` |
|
||||
| `great_sword_slide_attack` | `great sword slide attack.fbx` |
|
||||
| `great_sword_strafe` | `great sword strafe.fbx` |
|
||||
| `great_sword_strafe_2` | `great sword strafe (2).fbx` |
|
||||
| `great_sword_strafe_3` | `great sword strafe (3).fbx` |
|
||||
| `great_sword_strafe_4` | `great sword strafe (4).fbx` |
|
||||
| `great_sword_turn` | `great sword turn.fbx` |
|
||||
| `great_sword_turn_2` | `great sword turn (2).fbx` |
|
||||
| `great_sword_walk` | `great sword walk.fbx` |
|
||||
| `great_sword_walk_2` | `great sword walk (2).fbx` |
|
||||
| `spell_cast` | `spell cast.fbx` |
|
||||
| `two_handed_sword_death` | `two handed sword death.fbx` |
|
||||
| `two_handed_sword_death_2` | `two handed sword death (2).fbx` |
|
||||
|
||||
## Creature
|
||||
|
||||
| Clip | FBX |
|
||||
|---|---|
|
||||
| `left_turn_45` | `left turn 45.fbx` |
|
||||
| `mutant_breathing_idle` | `mutant breathing idle.fbx` |
|
||||
| `mutant_dying` | `mutant dying.fbx` |
|
||||
| `mutant_flexing_muscles` | `mutant flexing muscles.fbx` |
|
||||
| `mutant_idle` | `mutant idle.fbx` |
|
||||
| `mutant_jumping` | `mutant jumping.fbx` |
|
||||
| `mutant_left_turn_45` | `mutant left turn 45.fbx` |
|
||||
| `mutant_right_turn_45` | `mutant right turn 45.fbx` |
|
||||
| `mutant_right_turn_45_2` | `mutant right turn 45 (2).fbx` |
|
||||
| `mutant_run` | `mutant run.fbx` |
|
||||
| `mutant_swiping` | `mutant swiping.fbx` |
|
||||
| `mutant_walking` | `mutant walking.fbx` |
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials, assets, blender, render`
|
||||
|
||||
`REIMPORT=False` w plikach ćwiczeń używa istniejących assetów UE; brakujące są importowane.
|
||||
`REIMPORT=True` ponownie importuje modele i klipy, także po edycji `.blend`.
|
||||
Własny skrypt: `scene.skeletal_model(..., reimport=True)` lub `scene.model(..., reimport=True)`.
|
||||
Nie ma automatycznego wykrywania zmian źródeł.
|
||||
|
||||
`camera.create(..., fov=60)` ustawia poziomy kąt widzenia w stopniach: większy FOV
|
||||
daje szerszy kadr. Zakres: `0 < fov < 180`. Domyślne `43.6` odpowiada obiektywowi
|
||||
45 mm na sensorze o szerokości 36 mm.
|
||||
|
||||
| Operacja | Przykład |
|
||||
|---|---|
|
||||
| Własna mapa | `scene = Scene("MyShot")` |
|
||||
@@ -15,7 +24,7 @@ Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, ma
|
||||
| Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` |
|
||||
| Światła studyjne | `lights = lighting.studio(scene)` |
|
||||
| Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` |
|
||||
| Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45)` |
|
||||
| Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), fov=43.6)` |
|
||||
| Zmiana kierunku | `camera.look_at(cam, (0, 0, 150))` |
|
||||
| Sekwencja | `shot = Sequence(scene, fps=24, frames=48)` |
|
||||
| Aktywna kamera | `shot.camera(cam)` |
|
||||
@@ -37,9 +46,31 @@ Cały obrót: klucze 0 i 360, nie 0 i 0. Kamera i modele to oryginalne obiekty U
|
||||
możesz wywoływać ich metody, np. `actor.set_actor_label("MyHero")`.
|
||||
`shot.asset` to oryginalny LevelSequence, `shot.bind(actor)` zwraca binding UE.
|
||||
|
||||
W `my_shot.py`: `MODEL="robot"` lub `MODEL="pilot"` wybiera dołączony model.
|
||||
`MODEL="file"` wczytuje `MODEL_FILE`. Obiekt z `scene.model()` ma pivot na podstawie
|
||||
i zachowuje materiały; animacja dotyczy całego modelu, nie jego kości.
|
||||
W `my_shot.py`: `MODEL="fighter"` lub `MODEL="creature"` wybiera postać ze szkieletem.
|
||||
Nazwy klipów pochodzą z plików, np. `great_sword_walk` i `mutant_swiping`.
|
||||
`scene.skeletal_model(...)` samo znajduje wszystkie FBX obok modelu — bez `animations`.
|
||||
Dostępne jest 51 animacji fighter i 12 animacji creature; [pełny katalog](animations.md).
|
||||
Własny FBX lub `.blend`
|
||||
z Armature: `MODEL="file"`, `SKELETAL=True`, `MODEL_FILE=...`.
|
||||
`ANIMATION_FILE=None` odtwarza animację z pliku modelu; można wskazać osobny FBX
|
||||
z animacją dla tego samego szkieletu. Dla statycznego modelu ustaw `SKELETAL=False`.
|
||||
|
||||
```python
|
||||
character = scene.skeletal_model("source_assets/character/my_character.fbx", name="Hero")
|
||||
shot.animation(character) # tylko gdy postac ma jedna dostepna animacje
|
||||
# Gdy jest ich wiecej: shot.animation(character, "nazwa_pliku_animacji")
|
||||
# Albo nazwane klipy: scene.skeletal_model(..., animations={"walk": walk_fbx, "idle": idle_fbx})
|
||||
# shot.animation(character, "walk", start=0, end=48, speed=1.0)
|
||||
# shot.animation(character, "idle", start=48, end=120)
|
||||
# Plynne przejscie: pierwszy klip 0..60, drugi 48..120:
|
||||
# shot.animation(character, "walk", start=0, end=60)
|
||||
# shot.animation(character, "idle", start=48, end=120) # automatyczny blend 12 klatek
|
||||
```
|
||||
|
||||
Krótsza animacja zapętla się w swojej sekcji. `transform_key` porusza całym aktorem
|
||||
równolegle z animacją kości. Do własnych tras wybieraj klipy In Place.
|
||||
Blender: kolekcja `Character`, siatki przypisane do jednego Armature, aktywna akcja
|
||||
i zakres klatek sceny. Zapisz plik przed ponownym uruchomieniem skryptu w UE.
|
||||
|
||||
Przy pojedynczym podglądzie nie uruchamiaj dodatkowo pełnego renderu:
|
||||
jednocześnie może pracować tylko jeden renderer warsztatowy.
|
||||
@@ -38,3 +38,65 @@ Nie uruchamiano testów na Linuksie ani na Blenderze 4.x.
|
||||
Uruchamianie sprawdzeń jest opisane w README. Wyniki każdego renderu są zapisane
|
||||
w jego `result.json`; logi UE znajdują się w `Saved/Logs`.
|
||||
Skrypty integracyjne generują własne mapy i assety w `Workshop/Generated`.
|
||||
|
||||
## Postacie szkieletowe — 31 października 2026
|
||||
|
||||
- 13 testów poza UE: w tym zakresy klipów, wykrywanie nakładania, prędkość,
|
||||
wybór klipu i odrzucanie animacji z innym szkieletem.
|
||||
- UE 5.7: import fighter i creature jako SkeletalMesh z materiałami i teksturami,
|
||||
własny szkielet każdej postaci oraz klipy idle, walk i attack z osobnych FBX.
|
||||
- W klipach walk sprawdzone rzeczywiste zmiany rotacji kości między klatkami.
|
||||
- Ponowny import tych samych assetów po restarcie UE oraz zapis zależności
|
||||
(szkielet, materiały, tekstury). FBX używa importera zgodnego z FbxImportUI;
|
||||
poprzednia wartość flagi Interchange jest przywracana po imporcie.
|
||||
- Sequencer: klipy w kolejnych zakresach, prędkość 0.75 i jednoczesne klucze
|
||||
transformacji rodzica. Render 48 klatek, 640×360, dla obu postaci.
|
||||
- Blender 5.1.2: wczytanie fighter i chodzenia z nowych FBX wyeksportowanych
|
||||
przez UE, zmiana geometrii z zachowaniem wag, zapis `.blend`, eksport Armature
|
||||
i aktywnej akcji, import do UE i render 24 klatek, 640×360.
|
||||
- Dołączone źródła są w FBX 6.1, nieobsługiwanym przez importer Blendera 5.1;
|
||||
bezpośredni import w UE działa. Test Blendera korzysta z eksportu UE do FBX 7.3.
|
||||
|
||||
Odtworzenie testu Blendera (po teście `smoke_skeletal.py`):
|
||||
|
||||
```text
|
||||
blender --background --python-exit-code 1 --python tools/create_skeletal_fixture.py
|
||||
python tools/run_workshop.py tools/smoke_skeletal_blender.py --render
|
||||
```
|
||||
|
||||
Eksport szkieletowy obejmuje jedną postać i aktywną akcję w zakresie sceny.
|
||||
Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA.
|
||||
|
||||
## Pliki na zajęcia i pełne katalogi
|
||||
|
||||
- `tools/smoke_workshop_files.py`: w UE zaimportowano wszystkie 51 klipów fighter
|
||||
i 12 klipów creature; sprawdzono kompletność słowników i dodatnią długość klipów.
|
||||
- `exercises/basics.py`: eksport własnego `.blend`, 192 klatki kamery na okręgu,
|
||||
stały promień, zgodne końce trasy i ciągły yaw obejmujący pełne 360°.
|
||||
- `exercises/animation_starter.py`: jedna postać, dokładnie dwa klipy, 360 klatek.
|
||||
- Wszystkie trzy sceny zostały zbudowane i zapisane w oddzielnych mapach.
|
||||
|
||||
## Blendowanie z Pythona
|
||||
|
||||
- 17 testów poza UE: dodatkowo poprawne nakładanie, wagi obu klipów, zachowanie
|
||||
wejścia i wyjścia środkowego klipu oraz odrzucanie trzech równoczesnych klipów.
|
||||
- `tools/smoke_blending.py`: w UE 5.7 utworzono przejścia idle → attack → walk,
|
||||
po 12 klatek każde. Sprawdzone zapisane klucze Weight i wyrenderowana klatka 54,
|
||||
w środku pierwszego przejścia, równolegle z kluczami pozycji rodzica.
|
||||
- Przejście korzysta z kanałów wag; chronione pola MovieSceneEasingSettings
|
||||
nie są modyfikowane. Modele i klipy są ponownie używane z `reimport=False`.
|
||||
|
||||
## Automatyczne wykrywanie bez aliasów
|
||||
|
||||
- 23 testy poza UE: dodatkowo pomijanie pliku modelu, nazwy z plików, wykrywanie
|
||||
kolizji nazw, automatyczny wybór wszystkich klipów, jawny wybór i puste `animations={}`.
|
||||
- Sprawdzone przekazywanie `reimport=False`/`True` oraz użycie istniejącego modelu.
|
||||
- `tools/smoke_workshop_files.py` zaliczony w UE: obie postacie wczytane bez
|
||||
argumentu `animations`, pełne 51 + 12 klipów i plik zadania z dwoma pełnymi nazwami.
|
||||
|
||||
## Automatyczny blend
|
||||
|
||||
- 26 testów poza UE: blend wynika ze wspólnego zakresu, także przy jednej klatce
|
||||
nakładania; stykające się klipy nie dostają fade. Sprawdzone łańcuchy trzech
|
||||
klipów oraz odrzucanie niejednoznacznych zakresów i dodawania w odwrotnej kolejności.
|
||||
- Parametr `blend_in` usunięty z API i przykładów. Zakresy scen pozostają takie same.
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Dwie postacie Mixamo: osobne szkielety, klipy i pozycje na jednej scenie."""
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
|
||||
REIMPORT = False # True: ponowny import modelow i klipow; False: istniejace assety UE
|
||||
|
||||
scene = Scene("MixamoCharacters")
|
||||
fighter = scene.skeletal_model(
|
||||
FIGHTER_MODEL, name="Fighter", location=(-160, 0, 0),
|
||||
rotation=(0, 0, 180), reimport=REIMPORT)
|
||||
creature = scene.skeletal_model(
|
||||
CREATURE_MODEL, name="Creature", location=(160, 0, 0),
|
||||
rotation=(0, 0, 180), reimport=REIMPORT)
|
||||
floor = materials.color("Floor", (0.07, 0.08, 0.11))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(500, -850, 350), target=(0, 0, 100), fov=50)
|
||||
shot = Sequence(scene, fps=24, frames=120)
|
||||
shot.camera(cam)
|
||||
shot.animation(fighter, "great_sword_attack", start=0, end=60)
|
||||
shot.animation(fighter, "great_sword_idle", start=60, end=120)
|
||||
shot.animation(creature, "mutant_idle", start=0, end=60)
|
||||
shot.animation(creature, "mutant_swiping", start=60, end=120)
|
||||
shot.save()
|
||||
renderer = Renderer()
|
||||
# Po obejrzeniu w Sequencerze: renderer.render(shot)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Uruchom plik w UE: File > Execute Python Script. Zmieniaj i uruchamiaj ponownie."""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
# Ponowny import bibliotek (ue cachuje zaimportowaną biblioteke i jeśli nie bedzie
|
||||
# jawnego reloadu/restartu edytora to będzie wykonywał stary kod)
|
||||
if "ue_workshop" in sys.modules:
|
||||
_render_module = sys.modules.get("ue_workshop.render")
|
||||
if _render_module is not None and _render_module.is_rendering():
|
||||
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
|
||||
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
|
||||
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
|
||||
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
|
||||
_module = sys.modules.get("ue_workshop." + _name)
|
||||
if _module is not None:
|
||||
importlib.reload(_module)
|
||||
importlib.reload(sys.modules["ue_workshop"])
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.paths import PROJECT
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
|
||||
|
||||
# POSTACIE
|
||||
|
||||
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
|
||||
scene = Scene("MyShot")
|
||||
floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11))
|
||||
character_fighter = scene.skeletal_model(FIGHTER_MODEL, name="Fighter", height=200)
|
||||
#character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300)
|
||||
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0))
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=40)
|
||||
|
||||
# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
|
||||
shot = Sequence(scene, fps=24, frames=120)
|
||||
shot.camera(cam)
|
||||
|
||||
shot.animation(character_fighter, "draw_a_great_sword_1", start=0, end=60)
|
||||
|
||||
|
||||
|
||||
# shot.animation(character_fighter, "great_sword_idle", start=0, end=60)
|
||||
# shot.animation(character_fighter, "great_sword_attack", start=48, end=120)
|
||||
# shot.transform_key(character_fighter, 70, location=(0, 0, 0), interpolation="auto")
|
||||
# shot.transform_key(character_fighter, 100, location=(0, 200, 0), interpolation="auto")
|
||||
shot.save()
|
||||
|
||||
# 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True.
|
||||
RENDER = False
|
||||
renderer = Renderer(width=1920, height=1080, samples=4, warmup=32, mp4=True)
|
||||
# Mozesz tez zaczac od presetu i nadpisac wybrane wartosci:
|
||||
# renderer = Renderer.from_preset("final", width=1920, height=1080)
|
||||
if RENDER:
|
||||
renderer.render(shot)
|
||||
# Szybki podglad zamiast filmu: renderer.render(shot, single_frame=24)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Zadanie: stworz ciekawa 15-sekundowa scene. Punkt wyjscia: jedna postac i dwa klipy.
|
||||
|
||||
Uruchom w UE: File > Execute Python Script. Zrob wlasna kopie pliku przed edycja.
|
||||
"""
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.characters import CREATURE_MODEL, _clips
|
||||
|
||||
REIMPORT = False # True: ponowny import modelu i klipow; False: istniejace assety UE
|
||||
|
||||
scene = Scene("AnimationStarter")
|
||||
clips = _clips(CREATURE_MODEL)
|
||||
character = scene.skeletal_model(
|
||||
CREATURE_MODEL, name="Creature", height=200, rotation=(0, 0, 180), reimport=REIMPORT,
|
||||
animations={name: clips[name] for name in ("mutant_idle", "mutant_swiping")},
|
||||
)
|
||||
floor = materials.color("Floor", (0.07, 0.08, 0.11))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=45)
|
||||
|
||||
# 360 klatek przy 24 FPS = 15 sekund. Koniec zakresu jest wylaczny.
|
||||
shot = Sequence(scene, fps=24, frames=360)
|
||||
shot.camera(cam)
|
||||
# 12 wspolnych klatek = plynne przejscie przez 0.5 sekundy.
|
||||
shot.animation(character, "mutant_idle", start=0, end=132)
|
||||
shot.animation(character, "mutant_swiping", start=120, end=360)
|
||||
|
||||
# Dodaj ruch postaci, kamery, kolejne klipy lub druga postac.
|
||||
# shot.transform_key(character, 120, location=(0, 0, 0))
|
||||
# shot.transform_key(character, 239, location=(150, 0, 0))
|
||||
# Wszystkie klipy: docs/animations.md. Usun animations=... powyzej, aby udostepnic wszystkie.
|
||||
shot.save()
|
||||
|
||||
renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True)
|
||||
# renderer.render(shot)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Podstawy: wlasny model z Blendera i kamera robiaca pelne 360 stopni.
|
||||
|
||||
Uruchom w UE: File > Execute Python Script. Zapisz model w Blenderze przed uruchomieniem.
|
||||
"""
|
||||
import math
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.paths import PROJECT
|
||||
|
||||
REIMPORT = False # True po zmianie modelu w Blenderze; False uzywa assetu z UE
|
||||
|
||||
# 1. SCENA. Zmien model, kolor podlogi i swiatla.
|
||||
scene = Scene("Basics")
|
||||
character = scene.model(
|
||||
PROJECT / "source_assets/character/character_custom.blend",
|
||||
name="CustomCharacter", height=200, reimport=REIMPORT,
|
||||
)
|
||||
floor = materials.color("Floor", (0.07, 0.08, 0.11))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene)
|
||||
|
||||
# 2. KAMERA. Jednostki: centymetry; FOV w stopniach.
|
||||
radius = 550
|
||||
height = 260
|
||||
target = (0, 0, 100)
|
||||
cam = camera.create(scene, location=(radius, 0, height), target=target, fov=45)
|
||||
|
||||
# 3. ANIMACJA. 192 klatki przy 24 FPS = 8 sekund.
|
||||
shot = Sequence(scene, fps=24, frames=192)
|
||||
shot.camera(cam)
|
||||
pitch = -math.degrees(math.atan2(height - target[2], radius))
|
||||
for frame in range(shot.frames):
|
||||
angle = 360 * frame / (shot.frames - 1)
|
||||
x = radius * math.cos(math.radians(angle))
|
||||
y = radius * math.sin(math.radians(angle))
|
||||
# Rotacja: roll, pitch, yaw. Ciagly yaw zapobiega obrotowi wstecz przy 180 stopniach.
|
||||
shot.transform_key(cam, frame, location=(x, y, height), rotation=(0, pitch, angle + 180))
|
||||
shot.save()
|
||||
|
||||
# 4. RENDER. Najpierw obejrzyj scene w Sequencerze.
|
||||
renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True)
|
||||
# renderer.render(shot)
|
||||
# Zamiast filmu: renderer.render(shot, single_frame=96)
|
||||
@@ -10,7 +10,7 @@ if "ue_workshop" in sys.modules:
|
||||
raise RuntimeError("Poczekaj na zakonczenie renderu przed przeladowaniem biblioteki.")
|
||||
# Najpierw zaleznosci, potem moduly, ktore z nich korzystaja.
|
||||
for _name in ("paths", "progress", "video", "materials", "camera", "lighting", "assets",
|
||||
"_gltf", "blender", "scene", "sequence", "render"):
|
||||
"_gltf", "blender", "characters", "skeletal", "scene", "sequence", "render"):
|
||||
_module = sys.modules.get("ue_workshop." + _name)
|
||||
if _module is not None:
|
||||
importlib.reload(_module)
|
||||
@@ -18,12 +18,17 @@ if "ue_workshop" in sys.modules:
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.paths import PROJECT
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
|
||||
# WYBOR POSTACI: "primitive", "robot", "pilot" lub "file".
|
||||
MODEL = "file"
|
||||
MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend"
|
||||
# WYBOR POSTACI: "fighter", "creature", "primitive" lub "file".
|
||||
MODEL = "fighter"
|
||||
REIMPORT = False # True: ponowny import modelu i klipow; False: istniejace assety UE
|
||||
ANIMATION = "great_sword_attack" # wszystkie nazwy: docs/animations.md; creature: mutant_swiping
|
||||
MODEL_FILE = PROJECT / "source_assets/character/my_mixamo_character.fbx"
|
||||
SKELETAL = True # dla MODEL="file"; False dla statycznego GLB/FBX/OBJ/.blend
|
||||
ANIMATION_FILE = None # None: animacja w MODEL_FILE; albo FBX Without Skin tej samej postaci
|
||||
MODEL_HEIGHT = 200 # centymetry; None zachowuje oryginalny rozmiar
|
||||
MODEL_ROTATION = (0, 0, 0) # roll, pitch, yaw; np. (0, 0, 90) obraca model
|
||||
MODEL_ROTATION = (0, 0, 180) # roll, pitch, yaw; obrot modeli Mixamo przodem do kamery
|
||||
|
||||
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
|
||||
scene = Scene("MyShot")
|
||||
@@ -32,33 +37,52 @@ if MODEL == "primitive":
|
||||
blue = materials.color("Character", rgb=(0.04, 0.35, 0.8), metallic=0.2)
|
||||
character = scene.character(material=blue, head_scale=1.0)
|
||||
else:
|
||||
sources = {
|
||||
"robot": (PROJECT / "DemoAssets/RobotPolice/RobotPolice.fbx", "RobotPolice"),
|
||||
"pilot": (PROJECT / "DemoAssets/pilot/pilot.fbx", "Pilot"),
|
||||
"file": (MODEL_FILE, "CustomCharacter"),
|
||||
presets = {
|
||||
"fighter": (FIGHTER_MODEL, "Fighter"),
|
||||
"creature": (CREATURE_MODEL, "Creature"),
|
||||
}
|
||||
if MODEL not in sources:
|
||||
raise ValueError("MODEL: primitive, robot, pilot lub file.")
|
||||
source, name = sources[MODEL]
|
||||
character = scene.model(source, name=name, height=MODEL_HEIGHT, rotation=MODEL_ROTATION)
|
||||
if MODEL in presets:
|
||||
source, name = presets[MODEL]
|
||||
character = scene.skeletal_model(source, name=name, height=MODEL_HEIGHT,
|
||||
rotation=MODEL_ROTATION,
|
||||
reimport=REIMPORT)
|
||||
elif MODEL == "file":
|
||||
if SKELETAL:
|
||||
character = scene.skeletal_model(MODEL_FILE, name="CustomCharacter", height=MODEL_HEIGHT,
|
||||
rotation=MODEL_ROTATION,
|
||||
animations={"custom": ANIMATION_FILE} if ANIMATION_FILE else None,
|
||||
reimport=REIMPORT)
|
||||
else:
|
||||
character = scene.model(MODEL_FILE, name="CustomCharacter", height=MODEL_HEIGHT,
|
||||
rotation=MODEL_ROTATION, reimport=REIMPORT)
|
||||
else:
|
||||
raise ValueError("MODEL: fighter, creature, primitive lub file.")
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0))
|
||||
cam = camera.create(scene, location=(450, -550, 300), focal_length=45)
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=40)
|
||||
|
||||
# 2. ANIMACJA. 48 klatek przy 24 FPS = 2 sekundy. Ostatnia klatka ma numer 47.
|
||||
shot = Sequence(scene, fps=24, frames=48)
|
||||
# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
|
||||
shot = Sequence(scene, fps=24, frames=120)
|
||||
shot.camera(cam)
|
||||
# Rotacja: (roll, pitch, yaw), w stopniach.
|
||||
if MODEL in ("fighter", "creature"):
|
||||
idle = "great_sword_idle" if MODEL == "fighter" else "mutant_idle"
|
||||
shot.animation(character, idle, start=0, end=60)
|
||||
shot.animation(character, ANIMATION, start=48, end=120)
|
||||
elif MODEL == "file" and SKELETAL:
|
||||
shot.animation(character, "custom" if ANIMATION_FILE else None)
|
||||
else:
|
||||
shot.transform_key(character, 0, rotation=(0, 0, 0))
|
||||
shot.transform_key(character, 47, rotation=(0, 0, 360))
|
||||
# Opcjonalny podskok (dziala tak samo dla robota i wlasnego modelu):
|
||||
shot.transform_key(character, 119, rotation=(0, 0, 360))
|
||||
# Rotacja: (roll, pitch, yaw), w stopniach.
|
||||
# Klucze transformacji dzialaja tez razem z animacja kosci.
|
||||
# Opcjonalny podskok:
|
||||
# shot.transform_key(character, 0, location=(0, 0, 0), interpolation="auto")
|
||||
# shot.transform_key(character, 24, location=(0, 0, 80), interpolation="auto")
|
||||
# shot.transform_key(character, 47, location=(0, 0, 0), interpolation="auto")
|
||||
# shot.transform_key(character, 60, location=(0, 0, 80), interpolation="auto")
|
||||
# shot.transform_key(character, 119, location=(0, 0, 0), interpolation="auto")
|
||||
shot.save()
|
||||
|
||||
# 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True.
|
||||
RENDER = True
|
||||
RENDER = False
|
||||
renderer = Renderer(width=1920, height=1080, samples=4, warmup=32, mp4=True)
|
||||
# Mozesz tez zaczac od presetu i nadpisac wybrane wartosci:
|
||||
# renderer = Renderer.from_preset("final", width=1920, height=1080)
|
||||
|
||||
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,81 @@
|
||||
"""Automatic folder discovery, explicit selection and the manual reimport switch."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
|
||||
from ue_workshop.characters import _clips
|
||||
|
||||
|
||||
class Mesh:
|
||||
def get_editor_property(self, name):
|
||||
return "Skeleton"
|
||||
|
||||
|
||||
class Clip:
|
||||
def get_name(self):
|
||||
return "EmbeddedTake"
|
||||
|
||||
|
||||
class DiscoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.model = Path(self.folder.name) / "Hero.fbx"
|
||||
self.walk = self.model.with_name("great sword walk.fbx")
|
||||
self.attack = self.model.with_name("great sword attack (2).fbx")
|
||||
for path in (self.model, self.walk, self.attack):
|
||||
path.touch()
|
||||
unreal = MagicMock()
|
||||
unreal.SkeletalMesh, unreal.AnimSequence = Mesh, Clip
|
||||
name = "ue_workshop._skeletal_discovery_test"
|
||||
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/skeletal.py"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
self.module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"unreal": unreal, name: self.module}):
|
||||
spec.loader.exec_module(self.module)
|
||||
self.cache = patch.object(self.module, "_cached", return_value=[Mesh()]).start()
|
||||
self.importer = patch.object(self.module, "_import", return_value=[Mesh()]).start()
|
||||
self.animation = patch.object(self.module, "import_animation", side_effect=lambda *a, **kw: Clip()).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_discovery_excludes_model_and_derives_names_without_aliases(self):
|
||||
self.assertEqual(_clips(self.model), {
|
||||
"great_sword_attack_2": self.attack,
|
||||
"great_sword_walk": self.walk,
|
||||
})
|
||||
|
||||
def test_omitted_animations_loads_every_neighbor_and_reuses_model(self):
|
||||
result = self.module.import_model(self.model, name="Hero")
|
||||
self.assertEqual(set(result.animations), {"great_sword_attack_2", "great_sword_walk"})
|
||||
self.importer.assert_not_called()
|
||||
self.assertEqual(self.animation.call_count, 2)
|
||||
for call in self.animation.call_args_list:
|
||||
self.assertFalse(call.kwargs["reimport"])
|
||||
|
||||
def test_empty_dictionary_disables_discovery_and_keeps_embedded_name(self):
|
||||
self.cache.return_value = [Mesh(), Clip()]
|
||||
result = self.module.import_model(self.model, name="Hero", animations={})
|
||||
self.assertEqual(set(result.animations), {"EmbeddedTake"})
|
||||
self.animation.assert_not_called()
|
||||
|
||||
def test_explicit_selection_ignores_other_files(self):
|
||||
result = self.module.import_model(self.model, name="Hero", animations={"selected": self.walk})
|
||||
self.assertEqual(set(result.animations), {"selected"})
|
||||
self.animation.assert_called_once()
|
||||
self.assertEqual(self.animation.call_args.args[0], self.walk)
|
||||
|
||||
def test_reimport_true_reimports_model_and_discovered_clips(self):
|
||||
self.module.import_model(self.model, name="Hero", reimport=True)
|
||||
self.cache.assert_not_called()
|
||||
self.importer.assert_called_once()
|
||||
self.assertTrue(self.importer.call_args.kwargs["reimport"])
|
||||
self.assertTrue(all(call.kwargs["reimport"] for call in self.animation.call_args_list))
|
||||
|
||||
def test_colliding_normalized_names_are_rejected(self):
|
||||
self.model.with_name("great-sword-walk.fbx").touch()
|
||||
with self.assertRaises(ValueError):
|
||||
_clips(self.model)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Validate clip scheduling without an Unreal installation."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Content/Python"))
|
||||
|
||||
|
||||
class Clip:
|
||||
def __init__(self, skeleton):
|
||||
self.skeleton = skeleton
|
||||
|
||||
def get_editor_property(self, name):
|
||||
assert name == "skeleton"
|
||||
return self.skeleton
|
||||
|
||||
|
||||
class AnimationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
unreal = MagicMock()
|
||||
unreal.AnimSequence = Clip
|
||||
unreal.FrameNumber.side_effect = lambda frame: frame
|
||||
path = Path(__file__).resolve().parents[1] / "Content/Python/ue_workshop/sequence.py"
|
||||
spec = importlib.util.spec_from_file_location("ue_workshop._sequence_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"unreal": unreal}):
|
||||
spec.loader.exec_module(module)
|
||||
self.unreal = unreal
|
||||
self.shot = module.Sequence.__new__(module.Sequence)
|
||||
self.shot.frames = 120
|
||||
self.shot.fps = 24
|
||||
self.shot.asset = MagicMock()
|
||||
self.shot.asset.get_tick_resolution.return_value = SimpleNamespace(numerator=24000, denominator=1)
|
||||
self.shot._animations = {}
|
||||
self.shot.bind = MagicMock()
|
||||
self.actor = MagicMock()
|
||||
self.actor.get_path_name.return_value = "Parent"
|
||||
self.child = MagicMock()
|
||||
self.child.get_path_name.return_value = "SkeletalChild"
|
||||
self.skeleton = object()
|
||||
self.clip = Clip(self.skeleton)
|
||||
self.imported = SimpleNamespace(mesh=MagicMock(), animations={"walk": self.clip})
|
||||
self.imported.mesh.get_editor_property.return_value = self.skeleton
|
||||
self.shot.scene = SimpleNamespace(_skeletal_models={"Parent": (self.child, self.imported)})
|
||||
|
||||
def test_adjacent_clips_share_child_track_and_set_speed(self):
|
||||
self.shot.animation(self.actor, "walk", start=0, end=48)
|
||||
self.shot.animation(self.actor, "walk", start=48, end=120, speed=0.8)
|
||||
self.shot.bind.assert_called_once_with(self.child)
|
||||
track = self.shot.bind.return_value.add_track.return_value
|
||||
self.assertEqual(track.add_section.call_count, 2)
|
||||
track.add_section.return_value.set_range.assert_called_with(48, 120)
|
||||
self.unreal.MovieSceneTimeWarpVariant.return_value.set_fixed_play_rate.assert_called_with(0.8)
|
||||
|
||||
def test_nested_clip_is_rejected_without_creating_section(self):
|
||||
self.shot.animation(self.actor, "walk", start=0, end=48)
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, "walk", start=12, end=36)
|
||||
self.assertEqual(self.shot.bind.return_value.add_track.return_value.add_section.call_count, 1)
|
||||
|
||||
def test_invalid_ranges_and_speeds_do_not_create_track(self):
|
||||
for options in ({"start": -1}, {"end": 121}, {"start": 48, "end": 48},
|
||||
{"start": True}, {"speed": 0}, {"speed": float("nan")},
|
||||
{"speed": float("inf")}):
|
||||
with self.subTest(options=options), self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, "walk", **options)
|
||||
self.shot.bind.assert_not_called()
|
||||
|
||||
def test_crossfade_sets_complementary_weight_curves(self):
|
||||
track = self.shot.bind.return_value.add_track.return_value
|
||||
first, second = MagicMock(), MagicMock()
|
||||
track.add_section.side_effect = [first, second]
|
||||
self.shot.animation(self.actor, "walk", start=0, end=60)
|
||||
self.shot.animation(self.actor, "walk", start=48, end=120)
|
||||
outgoing = first.get_all_channels.return_value[0].add_key
|
||||
incoming = second.get_all_channels.return_value[0].add_key
|
||||
outgoing.assert_any_call(48, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
outgoing.assert_any_call(60, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
incoming.assert_any_call(48, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
incoming.assert_any_call(60, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
self.shot.bind.assert_called_once_with(self.child)
|
||||
|
||||
def test_ambiguous_overlaps_are_rejected_before_creating_section(self):
|
||||
self.shot.animation(self.actor, start=0, end=60)
|
||||
for start, end in ((0, 60), (0, 120), (12, 36), (30, 60)):
|
||||
with self.subTest(start=start, end=end), self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, start=start, end=end)
|
||||
self.assertEqual(self.shot.bind.return_value.add_track.return_value.add_section.call_count, 1)
|
||||
|
||||
def test_three_clip_chain_preserves_middle_fade_in_and_adds_fade_out(self):
|
||||
track = self.shot.bind.return_value.add_track.return_value
|
||||
first, middle, last = MagicMock(), MagicMock(), MagicMock()
|
||||
track.add_section.side_effect = [first, middle, last]
|
||||
self.shot.animation(self.actor, start=0, end=48)
|
||||
self.shot.animation(self.actor, start=36, end=84)
|
||||
self.shot.animation(self.actor, start=72, end=120)
|
||||
weight = middle.get_all_channels.return_value[0].add_key
|
||||
weight.assert_any_call(36, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
weight.assert_any_call(48, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
weight.assert_any_call(72, 1.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
weight.assert_any_call(84, 0.0, interpolation=self.unreal.MovieSceneKeyInterpolation.LINEAR)
|
||||
|
||||
def test_triple_overlap_is_rejected(self):
|
||||
self.shot.animation(self.actor, start=0, end=60)
|
||||
self.shot.animation(self.actor, start=48, end=100)
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, start=54, end=120)
|
||||
|
||||
def test_single_frame_overlap_automatically_blends(self):
|
||||
self.shot.animation(self.actor, start=0, end=60)
|
||||
self.shot.animation(self.actor, start=59, end=120)
|
||||
self.assertEqual(self.shot._animations["SkeletalChild"][1][4], 1)
|
||||
|
||||
def test_adjacent_clips_do_not_fade(self):
|
||||
self.shot.animation(self.actor, start=0, end=60)
|
||||
self.shot.animation(self.actor, start=60, end=120)
|
||||
weight = self.shot.bind.return_value.add_track.return_value.add_section.return_value.get_all_channels.return_value[0]
|
||||
weight.add_key.assert_not_called()
|
||||
|
||||
def test_out_of_order_overlap_is_rejected(self):
|
||||
self.shot.animation(self.actor, start=30, end=90)
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, start=0, end=60)
|
||||
|
||||
def test_unknown_clip_and_foreign_skeleton_are_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, "missing")
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, Clip(object()))
|
||||
self.shot.bind.assert_not_called()
|
||||
|
||||
def test_default_requires_exactly_one_clip(self):
|
||||
self.shot.animation(self.actor)
|
||||
self.imported.animations["idle"] = Clip(self.skeleton)
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor)
|
||||
|
||||
def test_static_actor_cannot_receive_skeletal_animation(self):
|
||||
self.shot.scene._skeletal_models.clear()
|
||||
with self.assertRaises(ValueError):
|
||||
self.shot.animation(self.actor, "walk")
|
||||
self.shot.bind.assert_not_called()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Executed by Blender, never by Unreal. Export an evaluated, joined static mesh."""
|
||||
"""Executed by Blender: static GLB or skinned FBX with the active animation."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -10,6 +10,7 @@ def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--collection", default="Character")
|
||||
parser.add_argument("--skeletal", action="store_true")
|
||||
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
|
||||
collection = bpy.data.collections.get(args.collection)
|
||||
if collection is None:
|
||||
@@ -18,6 +19,26 @@ def main():
|
||||
if not meshes:
|
||||
raise RuntimeError("Kolekcja Character nie zawiera siatek.")
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
if args.skeletal:
|
||||
armatures = {modifier.object for obj in meshes for modifier in obj.modifiers
|
||||
if modifier.type == "ARMATURE" and modifier.object is not None}
|
||||
if len(armatures) != 1:
|
||||
raise RuntimeError("Eksport szkieletowy wymaga jednego Armature przypisanego do siatek.")
|
||||
armature = next(iter(armatures))
|
||||
for obj in [*meshes, armature]:
|
||||
obj.hide_set(False)
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
output = Path(args.output).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=str(output), use_selection=True, object_types={"MESH", "ARMATURE"},
|
||||
add_leaf_bones=False, use_armature_deform_only=False, bake_anim=True,
|
||||
bake_anim_use_all_actions=False, bake_anim_use_nla_strips=False,
|
||||
bake_anim_force_startend_keying=True, bake_anim_simplify_factor=0.0,
|
||||
axis_forward="-Y", axis_up="Z", path_mode="COPY", embed_textures=True)
|
||||
print("WORKSHOP_EXPORTED=" + str(output))
|
||||
return
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
copies = []
|
||||
for obj in meshes:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Run in Blender: edit a copy of the Mixamo fighter and save an animated .blend."""
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
bpy.ops.import_scene.fbx(filepath=str(root / "build/SkeletalFixture/fighter.fbx"))
|
||||
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
||||
rig = next(obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE")
|
||||
original_objects = set(bpy.context.scene.objects)
|
||||
bpy.ops.import_scene.fbx(filepath=str(root / "build/SkeletalFixture/walk.fbx"))
|
||||
animation_rig = next(obj for obj in bpy.context.scene.objects
|
||||
if obj not in original_objects and obj.type == "ARMATURE")
|
||||
rig.animation_data_create()
|
||||
rig.animation_data.action = animation_rig.animation_data.action
|
||||
if hasattr(animation_rig.animation_data, "action_slot"):
|
||||
rig.animation_data.action_slot = animation_rig.animation_data.action_slot
|
||||
for obj in list(bpy.context.scene.objects):
|
||||
if obj not in original_objects:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
collection = bpy.data.collections.new("Character")
|
||||
bpy.context.scene.collection.children.link(collection)
|
||||
for obj in [*meshes, rig]:
|
||||
if obj.name not in collection.objects:
|
||||
collection.objects.link(obj)
|
||||
# A small mesh edit while retaining the imported weights/armature modifier.
|
||||
for vertex in meshes[0].data.vertices:
|
||||
vertex.co.x *= 1.02
|
||||
action = rig.animation_data.action
|
||||
bpy.context.scene.frame_start = int(action.frame_range[0])
|
||||
bpy.context.scene.frame_end = int(action.frame_range[1])
|
||||
bpy.context.scene.frame_set(bpy.context.scene.frame_start)
|
||||
output = root / "build/SkeletalFixture/edited_fighter.blend"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output))
|
||||
print("WORKSHOP_SKELETAL_FIXTURE=" + str(output))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Real UE test of Python-authored crossfades using existing character assets."""
|
||||
import json
|
||||
|
||||
import unreal
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
|
||||
from ue_workshop.characters import CREATURE_MODEL
|
||||
from ue_workshop.paths import PROJECT
|
||||
|
||||
scene = Scene("BlendingSmoke")
|
||||
character = scene.skeletal_model(
|
||||
CREATURE_MODEL, name="Creature", rotation=(0, 0, 180), reimport=False)
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=45)
|
||||
shot = Sequence(scene, frames=144, fps=24)
|
||||
shot.camera(cam)
|
||||
first = shot.animation(character, "mutant_idle", start=0, end=60)
|
||||
middle = shot.animation(character, "mutant_swiping", start=48, end=108)
|
||||
last = shot.animation(character, "mutant_walking", start=96, end=144)
|
||||
def keys(section):
|
||||
return [(key.get_time().frame_number.value, key.get_value())
|
||||
for key in section.get_all_channels()[0].get_keys()]
|
||||
|
||||
assert keys(first) == [(0, 1.0), (48, 1.0), (60, 0.0)]
|
||||
assert keys(middle) == [(48, 0.0), (60, 1.0), (96, 1.0), (108, 0.0)]
|
||||
assert keys(last) == [(96, 0.0), (108, 1.0)]
|
||||
shot.transform_key(character, 48, location=(0, 0, 0))
|
||||
shot.transform_key(character, 107, location=(100, 0, 0))
|
||||
shot.save(open_editor=False)
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(shot.asset)
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.set_current_time(54)
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.refresh_current_level_sequence()
|
||||
(PROJECT / "Saved/Workshop/blending_smoke.json").write_text(
|
||||
json.dumps({"blend_display_frames": 12, "clips": 3}), encoding="utf-8")
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||
@@ -22,6 +22,10 @@ assert len(second["shot"].asset.get_bindings()) == 2
|
||||
assert len(second["shot"].asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)) == 1
|
||||
short = Sequence(second["scene"], "Smoke", fps=24, frames=2)
|
||||
short.camera(second["cam"])
|
||||
if second["character"].get_path_name() in second["scene"]._skeletal_models:
|
||||
clip = second["ANIMATION"] if second["MODEL"] in ("fighter", "creature") else (
|
||||
"custom" if second["ANIMATION_FILE"] else None)
|
||||
short.animation(second["character"], clip)
|
||||
short.transform_key(second["character"], 0, rotation=(0, 0, 0))
|
||||
short.transform_key(second["character"], 1, rotation=(0, 0, 30))
|
||||
render.start(short, mp4=True)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Real UE check: both Mixamo skeletons, animation-only FBXs and Sequencer poses."""
|
||||
import json
|
||||
|
||||
import unreal
|
||||
|
||||
from ue_workshop import Scene, Sequence, camera, lighting, Renderer
|
||||
from ue_workshop.paths import PROJECT
|
||||
|
||||
scene = Scene("SkeletalSmoke")
|
||||
base = PROJECT / "source_assets/character"
|
||||
characters = []
|
||||
for name, model, walk, idle, attack, position in (
|
||||
("FighterSkeletalSmoke", "fighter/Maria WProp J J Ong.fbx", "fighter/great sword walk.fbx",
|
||||
"fighter/great sword idle.fbx", "fighter/great sword attack.fbx", (-160, 0, 0)),
|
||||
("CreatureSkeletalSmoke", "creature/Maw J Laygo.fbx", "creature/mutant walking.fbx",
|
||||
"creature/mutant idle.fbx", "creature/mutant swiping.fbx", (160, 0, 0)),
|
||||
):
|
||||
actor = scene.skeletal_model(base / model, name=name, location=position,
|
||||
animations={"walk": base / walk, "idle": base / idle, "attack": base / attack})
|
||||
child, imported = scene._skeletal_models[actor.get_path_name()]
|
||||
assert isinstance(child, unreal.SkeletalMeshActor)
|
||||
assert isinstance(imported.mesh, unreal.SkeletalMesh)
|
||||
for alias in ("walk", "idle", "attack"):
|
||||
clip = imported.animations[alias]
|
||||
assert clip.get_editor_property("skeleton") == imported.mesh.get_editor_property("skeleton")
|
||||
assert clip.get_editor_property("sequence_length") > 0
|
||||
if alias == "walk":
|
||||
tracks = unreal.AnimationLibrary.get_animation_track_names(clip)
|
||||
assert tracks, "Walk has no bone tracks"
|
||||
poses_differ = False
|
||||
for bone in tracks:
|
||||
first = unreal.AnimationLibrary.get_bone_pose_for_frame(clip, bone, 0, False)
|
||||
later = unreal.AnimationLibrary.get_bone_pose_for_frame(clip, bone, 10, False)
|
||||
if not first.rotation.equals(later.rotation, 0.001):
|
||||
poses_differ = True
|
||||
break
|
||||
assert poses_differ, "Walk has no changing bone rotations"
|
||||
characters.append(actor)
|
||||
if name == "FighterSkeletalSmoke":
|
||||
# Bundled sources are FBX 6.1, too old for Blender's FBX importer.
|
||||
# Export modern FBXs for the Blender round-trip fixture.
|
||||
folder = PROJECT / "build/SkeletalFixture"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
for obj, filename in ((imported.mesh, "fighter.fbx"), (imported.animations["walk"], "walk.fbx")):
|
||||
task = unreal.AssetExportTask()
|
||||
task.set_editor_property("object", obj)
|
||||
task.set_editor_property("filename", str(folder / filename))
|
||||
task.set_editor_property("automated", True)
|
||||
task.set_editor_property("prompt", False)
|
||||
task.set_editor_property("replace_identical", True)
|
||||
task.set_editor_property("options", unreal.FbxExportOption())
|
||||
assert unreal.Exporter.run_asset_export_task(task)
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(600, -850, 400), target=(0, 0, 100), fov=55)
|
||||
shot = Sequence(scene, frames=48)
|
||||
shot.camera(cam)
|
||||
for actor in characters:
|
||||
first = shot.animation(actor, "walk", start=0, end=16)
|
||||
second = shot.animation(actor, "idle", start=16, end=32, speed=0.75)
|
||||
shot.animation(actor, "attack", start=32, end=48)
|
||||
assert first.get_editor_property("params").animation is not None
|
||||
assert abs(second.get_editor_property("params").play_rate.to_fixed_play_rate() - 0.75) < 1e-6
|
||||
shot.transform_key(actor, 0, rotation=(0, 0, 0))
|
||||
shot.transform_key(actor, 47, rotation=(0, 0, 90))
|
||||
shot.save(open_editor=False)
|
||||
summary = {"characters": [actor.get_actor_label() for actor in characters], "frames": shot.frames}
|
||||
(PROJECT / "Saved/Workshop/skeletal_smoke.json").write_text(json.dumps(summary), encoding="utf-8")
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""UE integration test after tools/create_skeletal_fixture.py was run in Blender."""
|
||||
import unreal
|
||||
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting
|
||||
from ue_workshop.paths import PROJECT
|
||||
|
||||
scene = Scene("SkeletalBlenderSmoke")
|
||||
actor = scene.skeletal_model(PROJECT / "build/SkeletalFixture/edited_fighter.blend",
|
||||
name="EditedFighterSmoke", animations={})
|
||||
child, imported = scene._skeletal_models[actor.get_path_name()]
|
||||
assert isinstance(imported.mesh, unreal.SkeletalMesh)
|
||||
assert len(imported.animations) == 1
|
||||
assert next(iter(imported.animations.values())).get_editor_property("sequence_length") > 0
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2))
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene)
|
||||
shot = Sequence(scene, frames=24)
|
||||
shot.camera(cam)
|
||||
shot.animation(actor)
|
||||
shot.save(open_editor=False)
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""UE integration: teaching orbit, two-clip starter and complete animation catalogs."""
|
||||
import json
|
||||
import math
|
||||
import runpy
|
||||
|
||||
from ue_workshop import Renderer
|
||||
from ue_workshop.paths import PROJECT
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL, _clips
|
||||
|
||||
fighter_clips, creature_clips = _clips(FIGHTER_MODEL), _clips(CREATURE_MODEL)
|
||||
|
||||
basics = runpy.run_path(str(PROJECT / "exercises/basics.py"))
|
||||
shot = basics["shot"]
|
||||
assert shot.frames == 192
|
||||
channels = shot._transforms[basics["cam"].get_path_name()]
|
||||
x = [key.get_value() for key in channels[0].get_keys()]
|
||||
y = [key.get_value() for key in channels[1].get_keys()]
|
||||
yaw = [key.get_value() for key in channels[5].get_keys()]
|
||||
assert len(x) == shot.frames == len(yaw)
|
||||
assert abs(yaw[-1] - yaw[0] - 360) < 0.001
|
||||
assert all(b > a for a, b in zip(yaw, yaw[1:])), "Camera reverses direction"
|
||||
assert abs(x[0] - x[-1]) < 0.001 and abs(y[0] - y[-1]) < 0.001
|
||||
assert all(abs(math.hypot(px, py) - basics["radius"]) < 0.01 for px, py in zip(x, y))
|
||||
|
||||
starter = runpy.run_path(str(PROJECT / "exercises/animation_starter.py"))
|
||||
assert starter["shot"].frames == 360
|
||||
models = starter["scene"]._skeletal_models
|
||||
assert len(models) == 1
|
||||
assert set(next(iter(models.values()))[1].animations) == {"mutant_idle", "mutant_swiping"}
|
||||
|
||||
example = runpy.run_path(str(PROJECT / "examples/mixamo_characters.py"))
|
||||
for actor_name, clips in (("fighter", fighter_clips), ("creature", creature_clips)):
|
||||
actor = example[actor_name]
|
||||
child, imported = example["scene"]._skeletal_models[actor.get_path_name()]
|
||||
assert set(imported.animations) == set(clips)
|
||||
assert all(clip.get_editor_property("sequence_length") > 0 for clip in imported.animations.values())
|
||||
|
||||
summary = {"fighter_clips": len(fighter_clips), "creature_clips": len(creature_clips),
|
||||
"orbit_frames": basics["shot"].frames, "starter_frames": starter["shot"].frames}
|
||||
(PROJECT / "Saved/Workshop/workshop_files_smoke.json").write_text(json.dumps(summary), encoding="utf-8")
|
||||
shot = example["shot"]
|
||||
renderer = Renderer(width=640, height=360, samples=1, warmup=0, mp4=False)
|
||||