Compare commits

...
9 Commits
Author SHA1 Message Date
Vysyr bd8edf5afb basic advanced example 2026-10-31 23:11:57 +01:00
Vysyr 6fe57915e8 advanced example 2026-10-31 20:16:32 +01:00
Vysyr 8c0a639fba blender customization 2026-10-31 17:21:00 +01:00
Vysyr fb39e290c0 workshop rework 2026-10-31 17:03:41 +01:00
Vysyr a3a80dfa16 a lot of stuff 2026-10-27 19:57:03 +01:00
Vysyr 86f54deb01 added presentation 2026-10-13 22:59:02 +02:00
Vysyr e523a5e1fe sync 2026-09-05 14:09:27 +02:00
Vysyr 5284a0f5df import 2026-09-05 13:44:04 +02:00
Vysyr 3a9b791a6a sync 2026-09-05 13:33:04 +02:00
186 changed files with 5749 additions and 328 deletions

No files matched your search

+6
View File
@@ -249,3 +249,9 @@ Plugins/**/Intermediate/*
# Cache files for the editor to use
DerivedDataCache/*
# Workshop output and machine-local tools
renders/
Content/Workshop/Generated/
*.blend1
*.blend2
+6
View File
@@ -0,0 +1,6 @@
{
"python.analysis.extraPaths": ["${workspaceFolder}/Content/Python"],
"editor.hover.enabled": true,
"editor.inlayHints.enabled": "on",
"python.analysis.inlayHints.callArgumentNames": "all"
}
Executable
+17
View File
@@ -0,0 +1,17 @@
{
"version": "1.0",
"components": [
"Component.Unreal.Debugger",
"Component.Unreal.Ide",
"Microsoft.Net.Component.4.6.2.TargetingPack",
"Microsoft.VisualStudio.Component.VC.14.44.17.14.ATL",
"Microsoft.VisualStudio.Component.VC.14.44.17.14.x86.x64",
"Microsoft.VisualStudio.Component.VC.Llvm.Clang",
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"Microsoft.VisualStudio.Component.Windows11SDK.22621",
"Microsoft.VisualStudio.Workload.CoreEditor",
"Microsoft.VisualStudio.Workload.ManagedDesktop",
"Microsoft.VisualStudio.Workload.NativeDesktop",
"Microsoft.VisualStudio.Workload.NativeGame"
]
}
+3
View File
@@ -0,0 +1,3 @@
[/Script/PythonScriptPlugin.PythonScriptPluginSettings]
bDeveloperMode=True
bRemoteExecution=False
+5 -72
View File
@@ -1,94 +1,27 @@
[/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Engine/Maps/Templates/OpenWorld
EditorStartupMap=/Engine/Maps/Entry
GameDefaultMap=/Engine/Maps/Entry
[/Script/Engine.RendererSettings]
r.AllowStaticLighting=False
r.Shadow.Virtual.Enable=1
r.GenerateMeshDistanceFields=True
r.DynamicGlobalIlluminationMethod=1
r.ReflectionMethod=1
r.SkinCache.CompileShaders=True
r.RayTracing=True
r.RayTracing.RayTracingProxies.ProjectEnabled=True
r.Substrate=True
r.Substrate.ProjectGBufferFormat=0
r.Shadow.Virtual.Enable=1
r.RayTracing=False
r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange=True
r.DefaultFeature.LocalExposure.HighlightContrastScale=0.8
r.DefaultFeature.LocalExposure.ShadowContrastScale=0.8
[/Script/WindowsTargetPlatform.WindowsTargetSettings]
DefaultGraphicsRHI=DefaultGraphicsRHI_DX12
DefaultGraphicsRHI=DefaultGraphicsRHI_DX12
-D3D12TargetedShaderFormats=PCD3D_SM5
+D3D12TargetedShaderFormats=PCD3D_SM6
-D3D11TargetedShaderFormats=PCD3D_SM5
+D3D11TargetedShaderFormats=PCD3D_SM5
Compiler=Default
AudioSampleRate=48000
AudioCallbackBufferFrameSize=1024
AudioNumBuffersToEnqueue=1
AudioMaxChannels=0
AudioNumSourceWorkers=4
SpatializationPlugin=
SourceDataOverridePlugin=
ReverbPlugin=
OcclusionPlugin=
CompressionOverrides=(bOverrideCompressionTimes=False,DurationThreshold=5.000000,MaxNumRandomBranches=0,SoundCueQualityIndex=0)
CacheSizeKB=65536
MaxChunkSizeOverrideKB=0
bResampleForDevice=False
MaxSampleRate=48000.000000
HighSampleRate=32000.000000
MedSampleRate=24000.000000
LowSampleRate=12000.000000
MinSampleRate=8000.000000
CompressionQualityModifier=1.000000
AutoStreamingThreshold=0.000000
SoundCueCookQualityIndex=-1
[/Script/LinuxTargetPlatform.LinuxTargetSettings]
-TargetedRHIs=SF_VULKAN_SM5
+TargetedRHIs=SF_VULKAN_SM6
[/Script/MacTargetPlatform.MacTargetSettings]
-TargetedRHIs=SF_METAL_SM5
+TargetedRHIs=SF_METAL_SM6
[/Script/HardwareTargeting.HardwareTargetingSettings]
TargetedHardwareClass=Desktop
AppliedTargetedHardwareClass=Desktop
DefaultGraphicsPerformance=Maximum
AppliedDefaultGraphicsPerformance=Maximum
[/Script/WorldPartitionEditor.WorldPartitionEditorSettings]
CommandletClass=Class'/Script/UnrealEd.WorldPartitionConvertCommandlet'
[/Script/Engine.UserInterfaceSettings]
bAuthorizeAutomaticWidgetVariableCreation=False
FontDPIPreset=Standard
FontDPI=72
[/Script/Engine.Engine]
+ActiveGameNameRedirects=(OldGameName="TP_BlankBP",NewGameName="/Script/ExampleProject")
+ActiveGameNameRedirects=(OldGameName="/Script/TP_BlankBP",NewGameName="/Script/ExampleProject")
[/Script/AndroidFileServerEditor.AndroidFileServerRuntimeSettings]
bEnablePlugin=True
bAllowNetworkConnection=True
SecurityToken=72FA219A42EF2331665DBAA277560BD5
SecurityToken=83CD9B53427AF31BB12CAE94F92BE8C6
bIncludeInShipping=False
bAllowExternalStartInShipping=False
bCompileAFSProject=False
+2 -10
View File
@@ -1,12 +1,4 @@
[/Script/CommonUI.CommonUISettings]
CommonButtonAcceptKeyHandling=TriggerClick
[ConsoleVariables]
CommonUI.CheckKeyboardFocusAndParentage=1
CommonUI.DisallowUserFocusedWidgetForPendingFocusRecipient=1
CommonUI.FallbackToDesiredOnAutoRestoreFailure=1
[/Script/EngineSettings.GeneralProjectSettings]
ProjectID=30537DB4496937438003FCA5BA29D5EF
ProjectName=Python Workshop
Description=Godzinny warsztat renderowania z Pythonem w UE 5.7
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
"""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"]
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)
+105
View File
@@ -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,89 @@
"""Maintain static editor enums as models and animations are imported."""
import ast
import importlib
import math
from pathlib import Path
import sys
import tempfile
from .paths import slug
CATALOG = Path(__file__).with_name("animations.py")
HEADER = '''"""Automatically generated clip names and durations for VS Code.
Updated by scene.skeletal_model() using imported UE animation assets.
This module works without Unreal. Do not edit durations manually.
"""
from .animation_clip import AnimationClip
'''
def _read_classes(text):
classes = {}
for node in ast.parse(text).body:
if not isinstance(node, ast.ClassDef):
continue
data = {}
for statement in node.body:
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
key, seconds = ast.literal_eval(statement.value)
data[key] = float(seconds)
classes[node.name] = data
return classes
def _class_source(name, clips):
lines = [f'class {name}(AnimationClip):',
f' """Clip names and durations: type {name}. for editor completion."""']
members = set()
for key, seconds in sorted(clips.items()):
member = slug(key).upper()
if member in members:
raise ValueError(f"Powtorzona nazwa enuma: {name}.{member}")
members.add(member)
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError(f"Nieprawidlowa dlugosc animacji {key}: {seconds}")
lines.extend([f' {member} = ({key!r}, {seconds!r})',
f' """{seconds:.3f} seconds at speed=1; clip: {key}."""'])
return '\n'.join(lines)
def _publish_class(source, filename):
# Refresh only this class, preserving other enum classes and existing imports.
# Names are validated and data are emitted with repr; source is generated here.
module = sys.modules.get(__package__ + ".animations")
if module is not None:
exec(compile(source, str(filename), "exec"), vars(module))
importlib.invalidate_caches()
def update_catalog(name, clips, *, replace=True, output=None):
"""Write NameAnimations. Partial selections merge; full discovery replaces it.
Other models are preserved. Identical metadata does not rewrite the file.
clips maps actual playback keys to durations in seconds, without Unreal objects.
"""
name = slug(name)
class_name = name[0].upper() + name[1:] + "Animations"
path = CATALOG if output is None else Path(output)
old_text = path.read_text(encoding="utf-8") if path.is_file() else ""
classes = _read_classes(old_text)
data = {} if replace else dict(classes.get(class_name, {}))
data.update({key: float(seconds) for key, seconds in clips.items()})
classes[class_name] = data
sources = {key: _class_source(key, value) for key, value in sorted(classes.items())}
text = HEADER + '\n\n' + '\n\n\n'.join(sources.values()) + '\n'
if text != old_text:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n",
dir=path.parent, prefix=path.name + ".", suffix=".tmp",
delete=False) as temporary:
temporary.write(text)
temporary_path = Path(temporary.name)
try:
temporary_path.replace(path)
finally:
temporary_path.unlink(missing_ok=True)
if output is None:
_publish_class(sources[class_name], path)
return class_name
@@ -0,0 +1,33 @@
"""Animation metadata usable in normal Python and VS Code without Unreal."""
from enum import Enum
import math
class AnimationClip(Enum):
"""A named clip with its exported duration. Subclasses list concrete clips."""
def __init__(self, key: str, seconds: float):
self._key = key
self._seconds = seconds
@property
def key(self) -> str:
"""Clip name used by shot.animation(), derived from the FBX filename."""
return self._key
@property
def seconds(self) -> float:
"""Duration of one playback at speed=1, exported from Unreal."""
return self._seconds
def frames(self, fps: int = 24, speed: float = 1.0) -> int:
"""Frames needed for one playback at this sequence FPS and speed."""
if type(fps) is not int or fps < 1:
raise ValueError("fps musi byc dodatnia liczba calkowita.")
if isinstance(speed, bool) or not isinstance(speed, (int, float)) or not math.isfinite(speed) or speed <= 0:
raise ValueError("speed musi byc dodatnia liczba.")
value = self.seconds * fps / speed
# Unreal stores durations as float32; avoid adding a whole extra frame
# for values such as 3.200000047683716 seconds at 30 FPS.
nearest = round(value)
return max(1, nearest if math.isclose(value, nearest, rel_tol=0, abs_tol=0.0001) else math.ceil(value))
+140
View File
@@ -0,0 +1,140 @@
"""Automatically generated clip names and durations for VS Code.
Updated by scene.skeletal_model() using imported UE animation assets.
This module works without Unreal. Do not edit durations manually.
"""
from .animation_clip import AnimationClip
class CreatureAnimations(AnimationClip):
"""Clip names and durations: type CreatureAnimations. for editor completion."""
LEFT_TURN_45 = ('left_turn_45', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: left_turn_45."""
MUTANT_BREATHING_IDLE = ('mutant_breathing_idle', 4.0333333015441895)
"""4.033 seconds at speed=1; clip: mutant_breathing_idle."""
MUTANT_DYING = ('mutant_dying', 4.599999904632568)
"""4.600 seconds at speed=1; clip: mutant_dying."""
MUTANT_FLEXING_MUSCLES = ('mutant_flexing_muscles', 4.666666507720947)
"""4.667 seconds at speed=1; clip: mutant_flexing_muscles."""
MUTANT_IDLE = ('mutant_idle', 5.300000190734863)
"""5.300 seconds at speed=1; clip: mutant_idle."""
MUTANT_JUMPING = ('mutant_jumping', 3.1666667461395264)
"""3.167 seconds at speed=1; clip: mutant_jumping."""
MUTANT_LEFT_TURN_45 = ('mutant_left_turn_45', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: mutant_left_turn_45."""
MUTANT_RIGHT_TURN_45 = ('mutant_right_turn_45', 1.9666666984558105)
"""1.967 seconds at speed=1; clip: mutant_right_turn_45."""
MUTANT_RIGHT_TURN_45_2 = ('mutant_right_turn_45_2', 1.3333333730697632)
"""1.333 seconds at speed=1; clip: mutant_right_turn_45_2."""
MUTANT_RUN = ('mutant_run', 0.8666666746139526)
"""0.867 seconds at speed=1; clip: mutant_run."""
MUTANT_SWIPING = ('mutant_swiping', 2.6666667461395264)
"""2.667 seconds at speed=1; clip: mutant_swiping."""
MUTANT_WALKING = ('mutant_walking', 1.4333332777023315)
"""1.433 seconds at speed=1; clip: mutant_walking."""
class FighterAnimations(AnimationClip):
"""Clip names and durations: type FighterAnimations. for editor completion."""
DRAW_A_GREAT_SWORD_1 = ('draw_a_great_sword_1', 0.5)
"""0.500 seconds at speed=1; clip: draw_a_great_sword_1."""
DRAW_A_GREAT_SWORD_2 = ('draw_a_great_sword_2', 0.800000011920929)
"""0.800 seconds at speed=1; clip: draw_a_great_sword_2."""
GREAT_SWORD_180_TURN = ('great_sword_180_turn', 0.800000011920929)
"""0.800 seconds at speed=1; clip: great_sword_180_turn."""
GREAT_SWORD_180_TURN_2 = ('great_sword_180_turn_2', 0.5666666626930237)
"""0.567 seconds at speed=1; clip: great_sword_180_turn_2."""
GREAT_SWORD_ATTACK = ('great_sword_attack', 1.2000000476837158)
"""1.200 seconds at speed=1; clip: great_sword_attack."""
GREAT_SWORD_BLOCKING = ('great_sword_blocking', 0.5)
"""0.500 seconds at speed=1; clip: great_sword_blocking."""
GREAT_SWORD_BLOCKING_2 = ('great_sword_blocking_2', 0.9666666388511658)
"""0.967 seconds at speed=1; clip: great_sword_blocking_2."""
GREAT_SWORD_BLOCKING_3 = ('great_sword_blocking_3', 0.5)
"""0.500 seconds at speed=1; clip: great_sword_blocking_3."""
GREAT_SWORD_CASTING = ('great_sword_casting', 4.800000190734863)
"""4.800 seconds at speed=1; clip: great_sword_casting."""
GREAT_SWORD_CROUCHING = ('great_sword_crouching', 0.6666666865348816)
"""0.667 seconds at speed=1; clip: great_sword_crouching."""
GREAT_SWORD_CROUCHING_2 = ('great_sword_crouching_2', 0.699999988079071)
"""0.700 seconds at speed=1; clip: great_sword_crouching_2."""
GREAT_SWORD_CROUCHING_3 = ('great_sword_crouching_3', 1.8333333730697632)
"""1.833 seconds at speed=1; clip: great_sword_crouching_3."""
GREAT_SWORD_CROUCHING_4 = ('great_sword_crouching_4', 0.36666667461395264)
"""0.367 seconds at speed=1; clip: great_sword_crouching_4."""
GREAT_SWORD_CROUCHING_5 = ('great_sword_crouching_5', 1.399999976158142)
"""1.400 seconds at speed=1; clip: great_sword_crouching_5."""
GREAT_SWORD_CROUCHING_6 = ('great_sword_crouching_6', 0.46666666865348816)
"""0.467 seconds at speed=1; clip: great_sword_crouching_6."""
GREAT_SWORD_HIGH_SPIN_ATTACK = ('great_sword_high_spin_attack', 1.8666666746139526)
"""1.867 seconds at speed=1; clip: great_sword_high_spin_attack."""
GREAT_SWORD_IDLE = ('great_sword_idle', 2.0)
"""2.000 seconds at speed=1; clip: great_sword_idle."""
GREAT_SWORD_IDLE_2 = ('great_sword_idle_2', 3.700000047683716)
"""3.700 seconds at speed=1; clip: great_sword_idle_2."""
GREAT_SWORD_IDLE_3 = ('great_sword_idle_3', 3.6666667461395264)
"""3.667 seconds at speed=1; clip: great_sword_idle_3."""
GREAT_SWORD_IDLE_4 = ('great_sword_idle_4', 3.7666666507720947)
"""3.767 seconds at speed=1; clip: great_sword_idle_4."""
GREAT_SWORD_IDLE_5 = ('great_sword_idle_5', 7.566666603088379)
"""7.567 seconds at speed=1; clip: great_sword_idle_5."""
GREAT_SWORD_IMPACT = ('great_sword_impact', 0.8666666746139526)
"""0.867 seconds at speed=1; clip: great_sword_impact."""
GREAT_SWORD_IMPACT_2 = ('great_sword_impact_2', 1.2000000476837158)
"""1.200 seconds at speed=1; clip: great_sword_impact_2."""
GREAT_SWORD_IMPACT_3 = ('great_sword_impact_3', 1.2666666507720947)
"""1.267 seconds at speed=1; clip: great_sword_impact_3."""
GREAT_SWORD_IMPACT_4 = ('great_sword_impact_4', 0.6666666865348816)
"""0.667 seconds at speed=1; clip: great_sword_impact_4."""
GREAT_SWORD_IMPACT_5 = ('great_sword_impact_5', 0.9666666388511658)
"""0.967 seconds at speed=1; clip: great_sword_impact_5."""
GREAT_SWORD_JUMP = ('great_sword_jump', 0.6333333253860474)
"""0.633 seconds at speed=1; clip: great_sword_jump."""
GREAT_SWORD_JUMP_2 = ('great_sword_jump_2', 0.8999999761581421)
"""0.900 seconds at speed=1; clip: great_sword_jump_2."""
GREAT_SWORD_JUMP_ATTACK = ('great_sword_jump_attack', 2.1666667461395264)
"""2.167 seconds at speed=1; clip: great_sword_jump_attack."""
GREAT_SWORD_KICK = ('great_sword_kick', 1.5)
"""1.500 seconds at speed=1; clip: great_sword_kick."""
GREAT_SWORD_KICK_2 = ('great_sword_kick_2', 1.7333333492279053)
"""1.733 seconds at speed=1; clip: great_sword_kick_2."""
GREAT_SWORD_POWER_UP = ('great_sword_power_up', 3.5)
"""3.500 seconds at speed=1; clip: great_sword_power_up."""
GREAT_SWORD_RUN = ('great_sword_run', 0.7333333492279053)
"""0.733 seconds at speed=1; clip: great_sword_run."""
GREAT_SWORD_RUN_2 = ('great_sword_run_2', 0.6000000238418579)
"""0.600 seconds at speed=1; clip: great_sword_run_2."""
GREAT_SWORD_SLASH = ('great_sword_slash', 1.2666666507720947)
"""1.267 seconds at speed=1; clip: great_sword_slash."""
GREAT_SWORD_SLASH_2 = ('great_sword_slash_2', 3.5333333015441895)
"""3.533 seconds at speed=1; clip: great_sword_slash_2."""
GREAT_SWORD_SLASH_3 = ('great_sword_slash_3', 1.8333333730697632)
"""1.833 seconds at speed=1; clip: great_sword_slash_3."""
GREAT_SWORD_SLASH_4 = ('great_sword_slash_4', 1.7999999523162842)
"""1.800 seconds at speed=1; clip: great_sword_slash_4."""
GREAT_SWORD_SLASH_5 = ('great_sword_slash_5', 1.4333332777023315)
"""1.433 seconds at speed=1; clip: great_sword_slash_5."""
GREAT_SWORD_SLIDE_ATTACK = ('great_sword_slide_attack', 2.133333444595337)
"""2.133 seconds at speed=1; clip: great_sword_slide_attack."""
GREAT_SWORD_STRAFE = ('great_sword_strafe', 1.100000023841858)
"""1.100 seconds at speed=1; clip: great_sword_strafe."""
GREAT_SWORD_STRAFE_2 = ('great_sword_strafe_2', 1.1666666269302368)
"""1.167 seconds at speed=1; clip: great_sword_strafe_2."""
GREAT_SWORD_STRAFE_3 = ('great_sword_strafe_3', 0.5666666626930237)
"""0.567 seconds at speed=1; clip: great_sword_strafe_3."""
GREAT_SWORD_STRAFE_4 = ('great_sword_strafe_4', 0.6333333253860474)
"""0.633 seconds at speed=1; clip: great_sword_strafe_4."""
GREAT_SWORD_TURN = ('great_sword_turn', 0.7333333492279053)
"""0.733 seconds at speed=1; clip: great_sword_turn."""
GREAT_SWORD_TURN_2 = ('great_sword_turn_2', 0.8333333134651184)
"""0.833 seconds at speed=1; clip: great_sword_turn_2."""
GREAT_SWORD_WALK = ('great_sword_walk', 1.3666666746139526)
"""1.367 seconds at speed=1; clip: great_sword_walk."""
GREAT_SWORD_WALK_2 = ('great_sword_walk_2', 1.2999999523162842)
"""1.300 seconds at speed=1; clip: great_sword_walk_2."""
SPELL_CAST = ('spell_cast', 1.1333333253860474)
"""1.133 seconds at speed=1; clip: spell_cast."""
TWO_HANDED_SWORD_DEATH = ('two_handed_sword_death', 2.4000000953674316)
"""2.400 seconds at speed=1; clip: two_handed_sword_death."""
TWO_HANDED_SWORD_DEATH_2 = ('two_handed_sword_death_2', 2.5999999046325684)
"""2.600 seconds at speed=1; clip: two_handed_sword_death_2."""
+101
View File
@@ -0,0 +1,101 @@
"""Automated static mesh imports using the engine's enabled import pipelines."""
from pathlib import Path
import unreal
from .paths import GENERATED, PROJECT, slug
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():
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.common_meshes_properties.set_editor_property(
"force_all_mesh_as_type", unreal.InterchangeForceMeshType.IFMT_STATIC_MESH)
pipeline.mesh_pipeline.set_editor_property("combine_static_meshes", True)
pipeline.mesh_pipeline.set_editor_property("import_skeletal_meshes", False)
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() in {".fbx", ".obj"}:
options = unreal.FbxImportUI()
options.set_editor_property("import_mesh", True)
options.set_editor_property("import_as_skeletal", False)
options.set_editor_property("import_animations", 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)}. "
"Plik musi zawierac statyczna geometrie modelu.")
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)
else:
# Optional single-atlas convention, also used by the bundled FBX demos.
files = {file.stem.lower(): file for file in source.parent.iterdir()
if file.suffix.lower() in {".png", ".jpg", ".jpeg"}}
maps = {parameter: files.get(source.stem.lower() + suffix)
for suffix, parameter in (("_bc", "base_color"), ("_n", "normal"),
("_r", "roughness"), ("_m", "metallic"), ("_e", "emissive"))}
if maps["base_color"] is not None:
from .materials import texture_set
material = texture_set(name + "Imported", **maps)
for index in range(len(meshes[0].static_materials)):
meshes[0].set_material(index, material)
unreal.EditorAssetLibrary.save_loaded_asset(meshes[0])
return meshes[0]
+31
View File
@@ -0,0 +1,31 @@
"""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, 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 + (".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, 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)
+38
View File
@@ -0,0 +1,38 @@
import math
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), 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", 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))
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
+23
View File
@@ -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
+57
View File
@@ -0,0 +1,57 @@
import unreal
from .camera import look_at
def _setup(actor, component_class, intensity, color):
light = actor.get_component_by_class(component_class)
light.set_mobility(unreal.ComponentMobility.MOVABLE)
light.set_editor_property("intensity", float(intensity))
light.set_light_color(unreal.LinearColor(*color, 1.0))
return light
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 = _setup(actor, unreal.RectLightComponent, intensity, color)
light.set_editor_property("source_width", float(size))
light.set_editor_property("source_height", float(size))
light.set_editor_property("attenuation_radius", 2000.0)
look_at(actor, target)
return actor
def point(scene, name="Point", location=(0, 0, 250), intensity=10000, color=(1, 1, 1)):
"""Bulb shining in all directions."""
actor = scene.spawn(unreal.PointLight, name, location)
light = _setup(actor, unreal.PointLightComponent, intensity, color)
light.set_editor_property("attenuation_radius", 2000.0)
return actor
def spot(scene, name="Spot", location=(200, -200, 400), target=(0, 0, 100),
intensity=20000, color=(1, 1, 1), angle=30):
"""Cone of light; angle is measured in degrees from the cone axis."""
actor = scene.spawn(unreal.SpotLight, name, location)
light = _setup(actor, unreal.SpotLightComponent, intensity, color)
light.set_editor_property("attenuation_radius", 2000.0)
light.set_editor_property("outer_cone_angle", float(angle))
light.set_editor_property("inner_cone_angle", float(angle) * 0.7)
look_at(actor, target)
return actor
def sun(scene, name="Sun", location=(300, -300, 500), target=(0, 0, 0),
intensity=500, color=(1, 1, 1)):
"""Parallel light for the whole scene; only the location-target direction matters."""
actor = scene.spawn(unreal.DirectionalLight, name, location)
_setup(actor, unreal.DirectionalLightComponent, intensity, color)
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, -500, 350), color=key_color),
area(scene, "Fill", (100, 300, 250), intensity=7000, color=fill_color),
area(scene, "Rim", (-200, 0, 300), intensity=15000)]
+91
View File
@@ -0,0 +1,91 @@
"""Small opaque PBR materials, generated without external assets."""
import unreal
from pathlib import Path
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)
def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, emissive=None):
"""Create one atlas material from adjacent PBR textures (PNG/JPEG)."""
slug(name)
tools = unreal.AssetToolsHelpers.get_asset_tools()
path = f"{GENERATED}/Materials/M_{name}"
material = unreal.load_asset(path)
if material is None:
material = tools.create_asset("M_" + name, GENERATED + "/Materials",
unreal.Material, unreal.MaterialFactoryNew())
lib = unreal.MaterialEditingLibrary
lib.delete_all_material_expressions(material)
entries = [("BaseColor", base_color, unreal.MaterialProperty.MP_BASE_COLOR, "RGB", "color"),
("Normal", normal, unreal.MaterialProperty.MP_NORMAL, "RGB", "normal"),
("Roughness", roughness, unreal.MaterialProperty.MP_ROUGHNESS, "R", "linear"),
("Metallic", metallic, unreal.MaterialProperty.MP_METALLIC, "R", "linear"),
("Emissive", emissive, unreal.MaterialProperty.MP_EMISSIVE_COLOR, "RGB", "color")]
for index, (label, filename, prop, channel, role) in enumerate(entries):
if filename is None:
if label in {"Roughness", "Metallic"}:
node = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -300, index * 180)
node.set_editor_property("r", 0.6 if label == "Roughness" else 0.0)
lib.connect_material_property(node, "", prop)
continue
source = Path(filename).resolve()
if not source.is_file():
raise FileNotFoundError(source)
task = unreal.AssetImportTask()
task.filename = str(source)
task.destination_path = f"{GENERATED}/Materials/Textures/{name}"
task.destination_name = "T_" + label
task.automated = True
task.replace_existing = True
task.save = True
task.set_editor_property("async_", False)
tools.import_asset_tasks([task])
textures = [obj for obj in task.get_objects() if isinstance(obj, unreal.Texture2D)]
if len(textures) != 1:
raise RuntimeError("Nie mozna zaimportowac tekstury " + str(source))
texture = textures[0]
texture.set_editor_property("srgb", role == "color")
texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP
if role == "normal" else unreal.TextureCompressionSettings.TC_MASKS
if role == "linear" else unreal.TextureCompressionSettings.TC_DEFAULT)
unreal.EditorAssetLibrary.save_loaded_asset(texture)
node = lib.create_material_expression(material, unreal.MaterialExpressionTextureSample, -300, index * 180)
node.set_editor_property("texture", texture)
node.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL
if role == "normal" else unreal.MaterialSamplerType.SAMPLERTYPE_MASKS
if role == "linear" else unreal.MaterialSamplerType.SAMPLERTYPE_COLOR)
lib.connect_material_property(node, channel, prop)
lib.recompile_material(material)
unreal.EditorAssetLibrary.save_loaded_asset(material)
return material
+29
View File
@@ -0,0 +1,29 @@
"""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 not candidate and name == "blender" and os.name == "nt":
# Standard Windows installs do not necessarily add Blender to PATH.
root = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Blender Foundation"
installed = [path for path in root.glob("Blender */blender.exe") if path.is_file()]
if installed:
candidate = max(installed, key=lambda path: tuple(int(part) for part in re.findall(r"\d+", path.parent.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.")
+16
View File
@@ -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
+183
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
"""Transfer horizontal root motion to Sequencer actor keys without changing source clips."""
import math
import hashlib
import unreal
from .paths import GENERATED
def prepare(clip, child, scene_name):
component = child.skeletal_mesh_component
bone = component.get_bone_name(0)
count = int(clip.get_editor_property("number_of_sampled_keys"))
duration = clip.get_play_length()
if count < 2 or duration <= 0:
raise ValueError("keep_end_position wymaga animacji z co najmniej dwiema probkami.")
poses = [unreal.AnimationLibrary.get_bone_pose_for_time(
clip, bone, duration * index / (count - 1), False) for index in range(count)]
first = poses[0].translation
# Extract only horizontal travel. Keep vertical motion (jumps/bobbing),
# every root rotation and scale, and all other bone tracks unchanged.
positions = [unreal.Vector(first.x, first.y, pose.translation.z) for pose in poses]
folder = f"{GENERATED}/{scene_name}/RootMotion"
identity = hashlib.sha1(clip.get_path_name().encode("utf-8")).hexdigest()[:8]
name = clip.get_name() + "_" + identity + "_InPlace"
path = folder + "/" + name
if unreal.EditorAssetLibrary.does_asset_exist(path):
# This path contains only our generated derivative for this scene.
if not unreal.EditorAssetLibrary.delete_asset(path):
raise RuntimeError("Nie mozna odswiezyc wygenerowanego klipu " + path)
copy = unreal.AssetToolsHelpers.get_asset_tools().duplicate_asset(name, folder, clip)
if copy is None:
raise RuntimeError("Nie mozna utworzyc kopii animacji dla keep_end_position.")
controller = copy.get_editor_property("controller")
if not controller.set_bone_track_keys(bone, positions, [pose.rotation for pose in poses],
[pose.scale3d for pose in poses], should_transact=False):
raise RuntimeError("Nie mozna przeniesc ruchu kosci glownej do Transform.")
unreal.EditorAssetLibrary.save_loaded_asset(copy)
return copy, poses, duration
def displacement(poses, duration, elapsed, reverse=False):
"""Interpolate horizontal root travel, accumulating complete playback loops."""
elapsed = max(0.0, elapsed)
loops = math.floor(elapsed / duration)
time = elapsed - loops * duration
if reverse:
time = duration - time
index = min(time / duration * (len(poses) - 1), len(poses) - 1)
lower = int(index)
upper = min(lower + 1, len(poses) - 1)
alpha = index - lower
a, b = poses[lower].translation, poses[upper].translation
start, end = poses[0].translation, poses[-1].translation
if reverse:
start, end = end, start
return unreal.Vector(a.x + (b.x - a.x) * alpha - start.x + loops * (end.x - start.x),
a.y + (b.y - a.y) * alpha - start.y + loops * (end.y - start.y), 0)
+166
View File
@@ -0,0 +1,166 @@
"""An isolated generated map, simple meshes and a ready-to-use character."""
import math
from pathlib import Path
import unreal
from .paths import GENERATED, PROJECT, 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._skeletal_models = {}
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 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
on its mesh child. Rotation is (roll, pitch, yaw), applied before fitting.
"""
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.")
source = Path(filename).expanduser()
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, reimport=reimport)
else:
from .assets import import_model
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)
origin, extent = actor.get_actor_bounds(False)
if extent.z <= 0:
self.subsystem.destroy_actor(actor)
raise ValueError("Model ma zerowa wysokosc. Sprawdz geometrie i MODEL_ROTATION.")
if height is not None:
factor = float(height) / (2 * extent.z)
actor.set_actor_scale3d(unreal.Vector(factor, factor, factor))
origin, extent = actor.get_actor_bounds(False)
actor.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)
actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD,
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.
Also writes NameAnimations with durations to ue_workshop.animations for
editor completion, including custom models (name="Ninja" -> NinjaAnimations).
"""
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)
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)
+345
View File
@@ -0,0 +1,345 @@
"""Frame-based Sequencer helpers. Ranges are [start, end), rotations roll/pitch/yaw."""
import math
import unreal
from .paths import GENERATED, slug
from .animation_clip import AnimationClip
def _key_mode(interpolation):
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.")
return modes[interpolation]
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 = {}
self._light_tracks = {}
self._animations = {}
self._root_motion = {}
self._root_motion_copies = {}
self._pose_holds = {}
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 _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: AnimationClip | str | unreal.AnimSequence | None = None,
start=0, end=None, speed=1.0, keep_end_position=False, reverse=False,
keep_end_pose=False):
"""Play/loop an imported clip over [start, end) in sequence frames.
Pass a FighterAnimations/CreatureAnimations member, a filename key, an
AnimSequence, or None for the only available animation. With an enum and
no end, play once using its stored duration, sequence FPS and speed.
Strings/AnimSequence/None keep looping to the sequence end if end is omitted.
An explicit end sets the section range and can repeat the clip.
Overlapping clips automatically crossfade over their entire shared range.
Add clips in timeline order; at most two clips can overlap at a time.
keep_end_position transfers horizontal root travel into actor Transform
keys, keeping the final position for subsequent clips. Defaults to False.
reverse=True plays the clip backwards, including transferred root travel.
keep_end_pose=True retains the final pose with a frozen section after
playback and Sequencer Keep State. A later clip ends this pose hold.
"""
if type(start) is not int:
raise ValueError("start musi byc numerem klatki (liczba calkowita).")
if isinstance(clip, AnimationClip):
if end is None:
end = start + clip.frames(self.fps, speed)
clip = clip.key
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.")
if type(keep_end_position) is not bool:
raise ValueError("keep_end_position musi byc True lub False.")
if type(reverse) is not bool:
raise ValueError("reverse musi byc True lub False.")
if type(keep_end_pose) is not bool:
raise ValueError("keep_end_pose musi byc True lub False.")
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)
hold = self._pose_holds.pop(key, None)
if hold is not None:
hold_start, hold_section = hold
if start <= hold_start:
track.remove_section(hold_section)
else:
hold_section.set_range(hold_start, start)
if keep_end_position:
from .root_motion import prepare
source_key = clip.get_path_name()
if source_key not in self._root_motion_copies:
self._root_motion_copies[source_key] = prepare(
clip, child, self.scene.name + "/" + self.asset.get_name())
clip, poses, duration = self._root_motion_copies[source_key]
section = track.add_section()
section.set_range(start, end)
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
params.set_editor_property("reverse", reverse)
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)
# UE may rescale the range when play_rate changes. Restore the requested
# display-frame bounds after applying parameters (including speed).
section.set_range(start, end)
if keep_end_pose:
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
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))
if keep_end_position:
self._keep_end_position(actor, child, start, end, speed, poses, duration, reverse)
if keep_end_pose and end < self.frames:
length = clip.get_play_length()
final_time = min((end - start) / self.fps * speed, length)
if reverse:
final_time = length - final_time
self._pose_holds[key] = (end, self._hold_pose(track, clip, end, final_time))
return section
def _hold_pose(self, track, clip, start, time):
section = track.add_section()
params = section.get_editor_property("params")
params.set_editor_property("animation", clip)
rate = unreal.MovieSceneTimeWarpVariant()
rate.set_fixed_play_rate(0.0)
params.set_editor_property("play_rate", rate)
ticks = self.asset.get_tick_resolution()
tick_rate = ticks.numerator / ticks.denominator
# Stay just inside the asset end to avoid modulo wrapping to frame zero.
time = max(0.0, min(time, clip.get_play_length() - 1 / tick_rate))
params.set_editor_property("first_loop_start_frame_offset", unreal.FrameNumber(round(time * tick_rate)))
params.set_editor_property("force_custom_mode", True)
section.set_editor_property("params", params)
section.set_range(start, self.frames)
section.set_completion_mode(unreal.MovieSceneCompletionMode.KEEP_STATE)
self._animation_blend(section, start, self.frames, 0, 0)
return section
def _keep_end_position(self, actor, child, start, end, speed, poses, duration, reverse=False):
from .root_motion import displacement
key = actor.get_path_name()
if key not in self._root_motion:
self._root_motion[key] = {
"origin": actor.get_actor_location(),
"mesh_transform": child.get_actor_transform(),
"clips": [],
}
motion = self._root_motion[key]
motion["clips"].append((start, end, speed, poses, duration, reverse))
mesh = motion["mesh_transform"]
zero = mesh.transform_location(unreal.Vector(0, 0, 0))
# These are ordinary Transform keys. The final key holds the position
# after playback; subsequent animation calls do not reset it.
first = min(item[0] for item in motion["clips"])
last = min(self.frames - 1, max(item[1] for item in motion["clips"]))
for frame in range(first, last + 1):
total = unreal.Vector(0, 0, 0)
for clip_start, clip_end, clip_speed, clip_poses, clip_duration, clip_reverse in motion["clips"]:
elapsed = min(max(frame - clip_start, 0), clip_end - clip_start) / self.fps * clip_speed
total += displacement(clip_poses, clip_duration, elapsed, reverse=clip_reverse)
world_delta = mesh.transform_location(total) - zero
self.transform_key(actor, frame, location=(motion["origin"] + world_delta).to_tuple())
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}.")
mode = _key_mode(interpolation)
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=mode)
def light_key(self, actor, frame, color=None, intensity=None, interpolation="linear"):
"""Key a light's color (linear RGB 0-1) and/or intensity at a sequence frame."""
if not isinstance(frame, int) or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
mode = _key_mode(interpolation)
if color is not None and len(color) != 3:
raise ValueError("Kolor wymaga trzech wartosci.")
light = actor.get_component_by_class(unreal.LightComponent)
if light is None:
raise ValueError("light_key wymaga aktora swiatla, np. z lighting.spot().")
if color is not None:
channels = self._light_channels(light, unreal.MovieSceneColorTrack, "LightColor")
for channel, value in zip(channels, color):
channel.add_key(unreal.FrameNumber(frame), float(value), interpolation=mode)
if intensity is not None:
channels = self._light_channels(light, unreal.MovieSceneFloatTrack, "Intensity")
channels[0].add_key(unreal.FrameNumber(frame), float(intensity), interpolation=mode)
def _light_channels(self, light, track_class, name):
key = (light.get_path_name(), name)
if key not in self._light_tracks:
track = self.bind(light).add_track(track_class)
track.set_property_name_and_path(name, name)
section = track.add_section()
section.set_range(0, self.frames)
channels = section.get_all_channels()
if name == "LightColor":
channels[3].set_default(1.0) # Alpha is never keyed.
self._light_tracks[key] = channels
return self._light_tracks[key]
def position(self, actor, frame: int) -> tuple[float, float, float]:
"""Actor Transform position at a sequence frame, including keep_end_position.
Evaluates Sequencer curves without moving the playhead. Does not include
motion of bones inside an animation that was not transferred to Transform.
"""
if type(frame) is not int or not 0 <= frame < self.frames:
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
channels = self._transforms.get(actor.get_path_name())
if channels is None:
return actor.get_actor_location().to_tuple()
interval = self.asset.make_range(frame, 1)
rate = unreal.FrameRate(self.fps, 1)
values = [channel.evaluate_keys(interval, rate) for channel in channels[:3]]
if any(not axis for axis in values):
raise RuntimeError("Nie mozna odczytac pozycji aktora z kanalow Transform.")
return tuple(float(axis[0]) for axis in values)
def move(self, actor, start: int, end: int, offset: tuple[float, float, float],
interpolation="auto") -> tuple[float, float, float]:
"""Move by offset (world cm) from the actor's position at start.
Replaces position keys in the range and shifts later position keys by
the resulting change, preserving later motion. Call after animation().
Returns the target position. Rotation/scale keys are kept.
"""
if (type(start) is not int or type(end) is not int or
not 0 <= start < end < self.frames):
raise ValueError(f"Ruch wymaga 0 <= start < end < {self.frames}.")
if interpolation not in {"linear", "auto", "constant"}:
raise ValueError("Interpolacja: linear, auto lub constant.")
if len(offset) != 3 or any(not math.isfinite(value) for value in offset):
raise ValueError("offset wymaga trzech skonczonych wartosci w centymetrach.")
origin = self.position(actor, start)
target = tuple(value + delta for value, delta in zip(origin, offset))
channels = self._transforms.get(actor.get_path_name())
if channels is not None:
previous_end = self.position(actor, end)
for axis, channel in enumerate(channels[:3]):
correction = target[axis] - previous_end[axis]
for key in list(channel.get_keys()):
time = key.get_time().frame_number.value
if start <= time <= end:
channel.remove_key(key)
elif time > end:
key.set_value(key.get_value() + correction)
self.transform_key(actor, start, location=origin, interpolation=interpolation)
self.transform_key(actor, end, location=target, interpolation=interpolation)
return target
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)
+138
View File
@@ -0,0 +1,138 @@
"""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
from .animation_catalog import update_catalog
@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()
discover_all = animations is None
if discover_all:
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)
update_catalog(name, {key: clip.get_play_length() for key, clip in clips.items()},
replace=discover_all)
return SkeletalImport(meshes[0], clips)
+27
View File
@@ -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()
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

+31 -82
View File
@@ -1,83 +1,32 @@
{
"FileVersion": 3,
"EngineAssociation": "5.8",
"Category": "",
"Description": "",
"Plugins": [
{
"Name": "Bridge",
"Enabled": true
},
{
"Name": "Dataflow",
"Enabled": true
},
{
"Name": "Fab",
"Enabled": true
},
{
"Name": "MeshPainting",
"Enabled": true
},
{
"Name": "PCG",
"Enabled": true
},
{
"Name": "PluginUtils",
"Enabled": true
},
{
"Name": "PluginBrowser",
"Enabled": true
},
{
"Name": "GLTFExporter",
"Enabled": true
},
{
"Name": "InterchangeEditor",
"Enabled": true,
"SupportedTargetPlatforms": [
"Win64",
"Linux",
"Mac"
]
},
{
"Name": "MetaHumanSDK",
"Enabled": false
},
{
"Name": "Interchange",
"Enabled": true,
"SupportedTargetPlatforms": [
"Win64",
"Linux",
"Mac"
]
},
{
"Name": "SkeletalMeshModelingTools",
"Enabled": true
},
{
"Name": "ChaosEditor",
"Enabled": true
},
{
"Name": "InterchangeTests",
"Enabled": true,
"SupportedTargetPlatforms": [
"Win64",
"Linux",
"Mac"
]
},
{
"Name": "EditorScriptingUtilities",
"Enabled": true
}
]
}
"FileVersion": 3,
"EngineAssociation": "5.7",
"Category": "Education",
"Description": "Python rendering workshop",
"Plugins": [
{
"Name": "PythonScriptPlugin",
"Enabled": true
},
{
"Name": "EditorScriptingUtilities",
"Enabled": true
},
{
"Name": "SequencerScripting",
"Enabled": true
},
{
"Name": "MovieRenderPipeline",
"Enabled": true
},
{
"Name": "Interchange",
"Enabled": true
},
{
"Name": "InterchangeEditor",
"Enabled": true
}
]
}
+378 -1
View File
@@ -1,2 +1,379 @@
# PythonInUnreal
# Python w Unrealu — warsztat na godzinę
UE **5.7**, Windows / Linux. Biblioteka `ue_workshop` automatyzuje scenę,
Sequencer, Movie Render Queue i składanie MP4. Nie wymaga kompilacji C++ ani pip.
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:
`Binaries/`, `Intermediate/Build/` oraz `Intermediate/TargetInfo.json`.
Nowy projekt nie ma własnego targetu C++ i nie wymaga aktualizacji `Target.cs`.
## Przygotowanie przed spotkaniem
1. Zainstaluj UE 5.7 i otwórz `ExampleProject.uproject`. Poczekaj na kompilację shaderów.
2. Zainstaluj FFmpeg z enkoderem `libx264` i dodaj do PATH, jeżeli chcesz MP4.
Bez FFmpeg można renderować PNG: `render.start(shot, mp4=False)`.
3. Zwykły Python 3.10+ jest potrzebny tylko do launchera z terminala.
4. Sprawdź `python tools/run_workshop.py --check` i wykonaj ćwiczenie próbnie.
W projekcie są już włączone Python Editor Script Plugin, Editor Scripting Utilities,
Sequencer Scripting, Movie Render Queue oraz Interchange. Python jest narzędziem
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/basics.py`. Możesz też wpisać w konsoli UE:
```text
py "C:/sciezka/do/projektu/exercises/basics.py"
```
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.
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)`.
Własne ustawienia wpisujesz bezpośrednio w skrypcie uczestnika:
```python
from ue_workshop import Renderer
renderer = Renderer(width=1920, height=1080, samples=8, warmup=32, mp4=True)
renderer.render(shot)
# Albo zamiast filmu: renderer.render(shot, single_frame=24)
```
`width` i `height` to piksele, `samples` to liczba próbek przestrzennych na klatkę,
a `warmup` to liczba klatek rozgrzewki. Wymiary i próbki muszą być dodatnimi
liczbami całkowitymi, rozgrzewka może wynosić zero. FPS pochodzi z sekwencji.
Można też użyć `Renderer.from_preset("final", width=1920, height=1080)`:
argumenty nadpisują wybrane wartości presetu. Obiektu użyjesz ponownie do kolejnych
sekwencji po zakończeniu poprzedniego renderu. Ustawienia są zapisane w `result.json`.
Dotychczasowe `render.start(...)` nadal działa jako skrót do presetów.
Wyniki: `renders/MyShot/<czas_i_id>/frames/`, `film.mp4`, `result.json`.
Każdy render ma nowy katalog. Nieudane kodowanie zachowuje klatki.
MP4 powstaje dopiero po zakończeniu MRQ; nie zamykaj wtedy edytora.
Na czas przebudowy sceny i renderowania biblioteka zamyka kartę Sequencera.
Render startuje po powrocie ze skryptu. Sekwencję można ponownie otworzyć
z Content Browser; ponowne uruchomienie ćwiczenia też ją otwiera.
## Z terminala, bez okna edytora
Drugi plik obok ćwiczenia uruchamia `my_shot.py` bez okna UE, czeka na zakończenie
renderu oraz kodowania MP4 i automatycznie zamyka uruchomiony proces edytora:
```text
python exercises/my_shot_headless.py
```
Uruchamiaj go **zwykłym Pythonem z terminala**, nie przez Execute Python Script
w Unreal. Korzysta z obiektu `renderer` i ustawień zapisanych w `my_shot.py`.
Wymusza render również przy `RENDER=False`. Wynik trafia do tego samego
`renders/MyShot/<czas_i_id>/film.mp4`. Można dopisać `--editor`, `--timeout`
lub `--check`; ścieżka skryptu jest ustalana względem pliku launchera.
Domyślnie konsola pokazuje etapy: uruchamianie UE, przygotowanie sceny,
renderowanie, składanie MP4 i zamykanie UE, a na końcu wynik i ścieżkę do logu.
Podczas renderowania podaje liczbę zapisanych PNG; przy dłuższym oczekiwaniu
co 10 sekund potwierdza, że proces nadal działa. To nie jest szacowany procent
całego zadania — rozgrzewka, zapis klatek i kodowanie mają różne czasy wykonania.
Pełne logi trafiają do `Saved/Workshop/<id>.log`, także przy błędzie lub timeout.
Żeby widzieć logi UE na żywo w terminalu, dodaj:
```text
python exercises/my_shot_headless.py --verbose
```
Ścieżki filmu i logu są klikalne w obsługujących to terminalach, m.in.
Windows Terminal i terminalu VS Code (zwykle Ctrl+klik). W pozostałych konsolach
oraz przy przekierowaniu wyjścia do pliku wypisywane są zwykłe ścieżki.
Można wymusić linki opcją `--links always` lub wyłączyć je przez `--links never`.
Ogólny launcher do pozostałych skryptów:
```text
python tools/run_workshop.py --check
python tools/run_workshop.py exercises/my_shot.py --render
python tools/run_workshop.py exercises/my_shot.py --single-frame 24
```
Bez `--render` launcher tylko wykonuje skrypt i zapisuje scenę (chyba że sam skrypt
uruchamia render). Zwraca kod 0 przy powodzeniu i 1 przy błędzie. Domyślny limit
to 900 sekund; można zmienić `--timeout`. Nie używaj `-nullrhi` do renderowania.
Tryb bez okna nadal wymaga GPU, sterowników i na Linuksie działającego Vulkan.
Obsługa Linuksa jest przygotowana w kodzie; trzeba sprawdzić ją na docelowej maszynie.
Ścieżki programów można ustawić zmiennymi środowiskowymi:
```powershell
$env:WORKSHOP_UNREAL = 'C:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
$env:WORKSHOP_BLENDER = 'C:\Program Files\Blender Foundation\Blender 5.1\blender.exe'
$env:WORKSHOP_FFMPEG = 'C:\tools\ffmpeg\bin\ffmpeg.exe'
```
```bash
export WORKSHOP_UNREAL=/opt/UnrealEngine/Engine/Binaries/Linux/UnrealEditor
export WORKSHOP_BLENDER=/usr/bin/blender
export WORKSHOP_FFMPEG=/usr/bin/ffmpeg
python3 tools/run_workshop.py --check --blender
```
Ścieżki nie są zapisane na sztywno w bibliotece. Launcher ma domyślną lokalizację
instalacji UE 5.7 na Windows; na Linuksie korzysta ze zmiennej lub PATH.
## Wybór postaci i model pobrany z internetu
W `exercises/my_shot.py` wybierz `MODEL`:
| Wartość | Postać |
|---|---|
| `"primitive"` | Dotychczasowa postać z brył, bez importu. |
| `"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/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)
```
Rozpakuj pobrany model razem z teksturami. Możesz użyć ścieżki absolutnej
albo ścieżki względem projektu. GLB z osadzonymi teksturami PNG/JPEG pozwala
przenieść podstawowe materiały PBR w jednym pliku. FBX i OBJ wymagają zachowania
plików tekstur i powiązanych FBM/MTL. Nie wczytujemy ZIP ani plików `.uasset`.
Import zawsze odbywa się do wydzielonego katalogu generowanych assetów.
Biblioteka łączy części modelu, ustawia jego wysokość w centymetrach i przesuwa
podstawę na Z=0. `MODEL_HEIGHT=None` zachowuje rozmiar źródłowy. Jeśli model leży
na boku, popraw `MODEL_ROTATION` (roll, pitch, yaw); obrót jest stosowany przed
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 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.
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`.
Do pisania scen w VS Code możesz używać enumów z gotowymi długościami — bez
szukania assetów ani uruchamiania komend w UE:
```python
from ue_workshop.animations import FighterAnimations
clip = FighterAnimations.GREAT_SWORD_ATTACK
clip.seconds # czas klipu w sekundach
clip.frames(shot.fps) # liczba klatek przy FPS sekwencji
shot.animation(character, clip, start=48) # bez end: jeden przebieg
```
Po wpisaniu `FighterAnimations.` edytor podpowiada nazwy. `CreatureAnimations`
udostępnia klipy stwora. [Dane offline, łączenie klipów i aktualizacja katalogu](docs/animation_catalog.md).
Własne modele również dostają katalog automatycznie: pierwszy import
`scene.skeletal_model(path, name="Ninja")` zapisze `NinjaAnimations` w
`ue_workshop.animations`. Kolejne klipy i długości są aktualizowane przy imporcie,
bez osobnego polecenia generowania. `REIMPORT=False` nadal wykorzystuje gotowe assety.
Gdy atak przesuwa postać, `shot.animation(character, clip, start=57,
keep_end_position=True)` automatycznie przenosi poziomy ruch do kluczy Transform
i zachowuje końcową pozycję na kolejne klipy. [Przykład slash_2](docs/animation_catalog.md).
Po śmierci `shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150,
keep_end_pose=True)` utrzymuje leżącą pozę do końca sekwencji lub następnej animacji.
```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`:
```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 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`,
jeśli Blender nie jest w PATH; `WORKSHOP_BLENDER` pozwala wybrać inną instalację.
Przykładowy model znajduje się w `source_assets/character/character.blend`.
Można go też odtworzyć (generator nie nadpisuje istniejącego pliku):
```text
blender --background --python-exit-code 1 --python tools/create_character.py
```
Otwórz model w Blenderze, powiększ obiekt `Head`, zmień kolor materiału i **zapisz**.
Uruchom `examples/blender_character.py` w UE albo:
```text
python tools/run_workshop.py examples/blender_character.py
```
Skrypt uruchamia osobny proces Blendera, eksportuje kolekcję `Character` do GLB,
aktualizuje model i materiały w UE, buduje sekwencję i renderuje MP4.
Blender nie musi być otwarty. Eksport czyta ostatni zapis z dysku.
Umowa dla modelu: statyczne siatki w kolekcji `Character`, metry w Blenderze,
stopy na Z=0, przód w +X. Modyfikatory są uwzględniane, obiekty łączone do jednej
siatki z zachowaniem slotów materiałów. Origin eksportu to zero świata.
Podstawowe PBR: Principled BSDF, kolory, roughness, metallic, obsługiwane tekstury.
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, 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/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.
- `Content/Workshop/Generated/` — mapy, sekwencje, importy i materiały generowane w UE.
- `build/`, `renders/` — pliki pośrednie i wyniki, pomijane przez Git.
- `archive/legacy_python/` — stare skrypty; poza ścieżką importu UE.
- `DemoAssets/` — zachowane źródłowe modele z poprzedniego projektu, opcjonalne.
`Scene("MyShot")` zarządza mapą `/Game/Workshop/Generated/MyShot/Map`.
Ponowne wykonanie usuwa w niej tylko aktorów z tagiem `Workshop`, a sekwencję
odbudowuje. Własną scenę nazwij inaczej, np. `Scene("AniaShot")`.
Generowana sekwencja jest w całości zastępowana: ręczne zmiany zachowaj w jej kopii.
Mapy spoza warsztatu nie są czyszczone; przy niezapisanej obcej mapie skrypt prosi o zapis.
Nie usuwaj całego folderu Generated podczas pracy — zawiera aktualnie używane assety.
## Weryfikacja
```text
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,
renderuje dwie klatki i koduje MP4. Drugi wymaga Blendera i sprawdza powtórny
import zmienionej geometrii oraz materiałów i render.
Opis sprawdzonego środowiska: `docs/validation.md`.
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
Stare skrypty zostaly zachowane jako material dodatkowy. Nie sa czescia
nowego warsztatu i moga wymagac assetow/uslug z poprzedniej wersji projektu.
Zachowano rowniez lokalna zmiane w legacy_python/basic_scripting_demo/3.py.
@@ -0,0 +1,5 @@
import unreal
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = sub.get_all_level_actors()
unreal.log(f"{len(actors)} actors in level")
@@ -0,0 +1,16 @@
import unreal
OLD = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree04.SM_tree04")
NEW = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree01.SM_tree01")
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = sub.get_all_level_actors()
old_mesh_count = 0
for actor in actors:
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh == OLD:
old_mesh_count += 1
print(f"Found {old_mesh_count} instances of the old mesh")
@@ -0,0 +1,20 @@
import unreal
OLD = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree04.SM_tree04")
NEW = unreal.load_asset("/Game/ImportedAssets/Trees/SM_tree01.SM_tree01")
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = sub.get_all_level_actors()
old_mesh_count = 0
with unreal.ScopedEditorTransaction("Swap meshes"):
for actor in actors:
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh == OLD:
comp.set_editor_property("static_mesh", NEW)
# comp.set_static_mesh(NEW) -- tak tez mozna ale undo nie zadziala
# bo zmiana nie jest robiona przez edytor wiec jej on nie rejestruje
old_mesh_count += 1
unreal.log(f"Swapped {old_mesh_count} meshes")
@@ -0,0 +1,288 @@
"""Generate turntable Level Sequences (and optionally queue Movie Render Queue jobs).
Run this from Unreal's Output Log with::
py ".../Content/Python/generate_blueprint_turntable.py" /Game/Blueprints/BP_MyAsset
Append ``--render-now`` to start Movie Render Queue and
``--all-animations`` to render every AnimSequence using the Blueprint's
skeleton.
The script creates/saves a small presentation map, so the bindings in the
sequences and MRQ jobs are reproducible. Open any generated sequence in
Sequencer to show the normal camera, camera-cut and transform tracks.
"""
import sys
import unreal
# Project-specific defaults. They can be overridden in generate_turntables().
DEFAULT_OUTPUT_ROOT = "/Game/Sequences/Turntables"
DEFAULT_STAGE_MAP = "/Game/Maps/TurntableStage"
DEFAULT_RENDER_DIRECTORY = "{project_dir}/Saved/TurntableRenders"
FPS = 30
TURN_DURATION_SECONDS = 5.0
CAMERA_Z = 40.0
QUEUE_RENDER_IMMEDIATELY = False # True starts MRQ immediately after queueing.
RENDER_ALL_SKELETON_ANIMATIONS = False
def _log(message):
unreal.log("[Turntable] " + message)
def _asset_name(path):
return path.rsplit("/", 1)[-1].split(".")[0]
def _ensure_folder(path):
if not unreal.EditorAssetLibrary.does_directory_exist(path):
unreal.EditorAssetLibrary.make_directory(path)
def _create_or_open_stage(stage_map):
"""Open the dedicated map; creating it only on the first run."""
if unreal.EditorAssetLibrary.does_asset_exist(stage_map):
unreal.EditorLoadingAndSavingUtils.load_map(stage_map)
else:
_ensure_folder(stage_map.rsplit("/", 1)[0])
unreal.EditorLevelLibrary.new_level(stage_map)
unreal.EditorLevelLibrary.save_current_level()
def _find_skeletal_components(actor):
components = actor.get_components_by_class(unreal.SkeletalMeshComponent)
return [component for component in components if component.get_skeletal_mesh_asset()]
def _animations_set_on_blueprint(actor):
"""Return AnimSequences explicitly selected on components in the Blueprint.
Anim Blueprints are procedural at runtime and do not expose a finite list of
clips, so they are deliberately not guessed here. A component using
"Use Animation Asset" is exported as one sequence per assigned clip.
"""
animations = []
for component in _find_skeletal_components(actor):
try:
animation_data = component.get_editor_property("animation_data")
animation = animation_data.get_editor_property("anim_to_play")
if animation and animation not in animations:
animations.append(animation)
except Exception:
pass
return animations
def _all_skeleton_animations(actor):
"""Optional broad mode: render every AnimSequence sharing the actor skeleton."""
skeletons = set()
for component in _find_skeletal_components(actor):
mesh = component.get_skeletal_mesh_asset()
skeleton = mesh.get_editor_property("skeleton") if mesh else None
if skeleton:
skeletons.add(skeleton.get_path_name())
animations = []
registry = unreal.AssetRegistryHelpers.get_asset_registry()
anim_sequence_class = unreal.TopLevelAssetPath("/Script/Engine", "AnimSequence")
for data in registry.get_assets_by_class(anim_sequence_class, True):
animation = data.get_asset()
if animation and animation.get_editor_property("skeleton").get_path_name() in skeletons:
animations.append(animation)
return animations
def _set_transform_keys(track, frames, start_location, start_rotation):
section = track.add_section()
section.set_range(0, frames)
# UE 5.8 exposes section channels through the SequencerScripting extension
# library (MovieScene3DTransformSection itself has no get_channels method).
channels = unreal.MovieSceneSectionExtensions.get_channels_by_type(
section, unreal.MovieSceneScriptingDoubleChannel)
if len(channels) != 9:
raise RuntimeError("Expected 9 transform channels, got {0}".format(len(channels)))
# Translation XYZ, rotation XYZ, scale XYZ. Key all channels explicitly so
# the result remains readable/editable in Sequencer.
values = [start_location.x, start_location.y, start_location.z,
start_rotation.roll, start_rotation.pitch, start_rotation.yaw,
1.0, 1.0, 1.0]
for index, value in enumerate(values):
channels[index].add_key(unreal.FrameNumber(0), value)
end_value = start_rotation.yaw + 360.0 if index == 5 else value
channels[index].add_key(unreal.FrameNumber(frames), end_value)
def _add_camera(sequence, camera, frames):
camera_binding = sequence.add_possessable(camera)
# UE 5.8 creates master tracks through MovieSceneSequence.add_track.
cut_track = sequence.add_track(unreal.MovieSceneCameraCutTrack)
cut = cut_track.add_section()
cut.set_range(0, frames)
# Camera cuts require a MovieSceneObjectBindingID, not the binding proxy.
cut.set_camera_binding_id(sequence.get_binding_id(camera_binding))
def _add_animation_track(sequence, actor, animation, frames):
"""Bind the matching skeletal component and play its animation clip."""
for component in _find_skeletal_components(actor):
mesh = component.get_skeletal_mesh_asset()
skeleton = mesh.get_editor_property("skeleton") if mesh else None
if skeleton != animation.get_editor_property("skeleton"):
continue
binding = sequence.add_possessable(component)
track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
section = track.add_section()
section.set_range(0, frames)
params = section.get_editor_property("params")
params.set_editor_property("animation", animation)
section.set_editor_property("params", params)
return True
return False
def _make_sequence(sequence_name, output_folder, actor, camera, animation=None):
tools = unreal.AssetToolsHelpers.get_asset_tools()
existing_path = output_folder + "/" + sequence_name
if unreal.EditorAssetLibrary.does_asset_exist(existing_path):
unreal.EditorAssetLibrary.delete_asset(existing_path)
sequence = tools.create_asset(sequence_name, output_folder, unreal.LevelSequence,
unreal.LevelSequenceFactoryNew())
frames = max(1, round(FPS * max(TURN_DURATION_SECONDS,
animation.get_play_length() if animation else 0.0)))
sequence.set_display_rate(unreal.FrameRate(FPS, 1))
sequence.set_playback_start(0)
sequence.set_playback_end(frames)
actor_binding = sequence.add_possessable(actor)
transform_track = actor_binding.add_track(unreal.MovieScene3DTransformTrack)
_set_transform_keys(transform_track, frames, actor.get_actor_location(), actor.get_actor_rotation())
_add_camera(sequence, camera, frames)
if animation:
_add_animation_track(sequence, actor, animation, frames)
unreal.EditorAssetLibrary.save_asset(sequence.get_path_name())
return sequence
def _queue_render(sequence, stage_map, directory):
# Use a concrete directory rather than an MRQ format token, so its location
# is deterministic both in the queue UI and during PIE execution.
if directory == DEFAULT_RENDER_DIRECTORY:
directory = unreal.Paths.project_saved_dir().rstrip("/\\") + "/TurntableRenders"
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
queue = subsystem.get_queue()
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
job.job_name = sequence.get_name()
job.sequence = unreal.SoftObjectPath(sequence.get_path_name())
job.map = unreal.SoftObjectPath(stage_map)
config = job.get_configuration()
output = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
output.output_directory = unreal.DirectoryPath(directory)
output.file_name_format = "{job_name}/{frame_number}"
output.output_resolution = unreal.IntPoint(1920, 1080)
output.override_existing_output = True
# A queue constructed in Python has no implicit render pass. Without a
# Deferred Pass MRQ has no frames to submit or write.
config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
return subsystem
def _remove_previous_turntable_jobs(bp_name):
"""Replace prior generated jobs without touching the user's unrelated MRQ jobs."""
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
queue = subsystem.get_queue()
prefix = "LS_TT_" + bp_name
for job in queue.get_jobs():
if job.job_name.startswith(prefix):
queue.delete_job(job)
def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT,
stage_map=DEFAULT_STAGE_MAP, render_directory=DEFAULT_RENDER_DIRECTORY,
render_immediately=QUEUE_RENDER_IMMEDIATELY,
render_all_skeleton_animations=RENDER_ALL_SKELETON_ANIMATIONS):
"""Create a turntable plus one turntable per explicitly assigned animation.
`blueprint_path` must be an asset path, e.g. /Game/Blueprints/BP_Robot.
The return value is the generated LevelSequence list.
"""
blueprint = unreal.load_asset(blueprint_path)
if not blueprint or not isinstance(blueprint, unreal.Blueprint):
raise RuntimeError("Expected a Blueprint asset path, got: " + blueprint_path)
bp_name = _asset_name(blueprint_path)
# A separate stage per asset prevents other turntable actors from appearing
# in the same render. Supplying a custom stage_map preserves that exact map.
if stage_map == DEFAULT_STAGE_MAP:
stage_map = stage_map + "_" + bp_name
_create_or_open_stage(stage_map)
folder = output_root + "/" + bp_name
_ensure_folder(folder)
# Re-running the script replaces only its own presentation actors in the
# selected stage map. This keeps repeated demos deterministic.
generated_labels = {"TT_" + bp_name, "TT_Camera_" + bp_name}
for existing_actor in unreal.EditorLevelLibrary.get_all_level_actors():
if existing_actor.get_actor_label() in generated_labels:
unreal.EditorLevelLibrary.destroy_actor(existing_actor)
actor = unreal.EditorLevelLibrary.spawn_actor_from_object(blueprint, unreal.Vector(0, 0, 0))
actor.set_actor_label("TT_" + bp_name)
origin, extent = actor.get_actor_bounds(False)
distance = max(extent.x, extent.y, extent.z) * 3.0 + 150.0
camera_location = unreal.Vector(origin.x - distance, origin.y, CAMERA_Z)
camera_target = unreal.Vector(origin.x, origin.y, CAMERA_Z)
camera = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.CineCameraActor, camera_location, unreal.Rotator(0, 0, 0))
camera.set_actor_label("TT_Camera_" + bp_name)
camera.set_actor_rotation(
unreal.MathLibrary.find_look_at_rotation(camera.get_actor_location(), camera_target), False)
cine_camera = camera.get_cine_camera_component()
cine_camera.set_editor_property("current_focal_length", 50.0)
# A library turntable must stay sharp across the whole asset, not only on a
# small cinematic focal plane.
cine_camera.set_editor_property(
"focus_settings",
unreal.CameraFocusSettings(focus_method=unreal.CameraFocusMethod.DISABLE),
)
sequences = [_make_sequence("LS_TT_" + bp_name, folder, actor, camera)]
animations = _all_skeleton_animations(actor) if render_all_skeleton_animations else _animations_set_on_blueprint(actor)
for animation in animations:
name = "LS_TT_{0}_{1}".format(bp_name, animation.get_name())
sequences.append(_make_sequence(name, folder, actor, camera, animation))
unreal.EditorLevelLibrary.save_current_level()
_remove_previous_turntable_jobs(bp_name)
subsystem = None
for sequence in sequences:
subsystem = _queue_render(sequence, stage_map, render_directory)
if render_immediately and subsystem:
subsystem.render_queue_with_executor(unreal.MoviePipelinePIEExecutor)
_log("Created {0} sequence(s) and queued {0} PNG job(s) for {1}.".format(len(sequences), bp_name))
return sequences
def _run_from_console_arguments():
"""Support a concise `py "script.py" /Game/Path/BP_Name [flags]` command."""
render_now = "--render-now" in sys.argv
all_animations = "--all-animations" in sys.argv
for argument in sys.argv[1:]:
# Dragging an asset into the Output Log can produce
# /Script/Engine.Blueprint'/Game/Foo/BP_X.BP_X'; accept that form too.
game_index = argument.find("/Game/")
if game_index < 0:
continue
asset_path = argument[game_index:].split("'", 1)[0].split(".", 1)[0]
generate_turntables(
asset_path,
render_immediately=render_now,
render_all_skeleton_animations=all_animations,
)
return
_run_from_console_arguments()
@@ -0,0 +1,159 @@
"""Run inside Unreal (Python console / Execute Python Script).
Reads terrain_config.json (next to this script) and:
1. builds a heightfield mesh with GeometryScript (fractal value noise),
bakes it to a StaticMesh, and drops it into the level;
2. scatters tree/rock StaticMeshActors across it per "scatter_layers",
using the same noise field so foliage height always matches the
ground beneath it (no raycasting needed).
Run import_environment_assets.py first so the mesh paths referenced in
terrain_config.json actually exist.
Set the TERRAIN_CONFIG env var to point at a different config file, e.g. to
run terrain_config_variants_demo.json without touching the default config.
See generate_terrain_from_heightmap.py for the same pipeline driven by a real
heightmap image instead of procedural noise - both share terrain_common.py.
"""
import os, json, math, random, unreal
import terrain_common as tc
CONFIG_PATH = os.environ.get(
"TERRAIN_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config_variants_demo.json"),
)
# ---------------------------------------------------------------------------
# Fractal value noise (pure Python, no engine dependency) -------------------
# ---------------------------------------------------------------------------
class ValueNoise2D:
"""Seeded, smooth, tileable-enough value noise for terrain-style height fields."""
def __init__(self, seed):
rng = random.Random(seed)
table = list(range(256))
rng.shuffle(table)
self._perm = table + table # avoid overflow when indexing i+1
@staticmethod
def _fade(t):
return t * t * t * (t * (t * 6 - 15) + 10)
@staticmethod
def _lerp(a, b, t):
return a + t * (b - a)
def _gradient(self, ix, iy):
h = self._perm[(self._perm[ix & 255] + iy) & 255]
angle = (h / 255.0) * 2.0 * math.pi
return math.cos(angle), math.sin(angle)
def sample(self, x, y):
x0, y0 = math.floor(x), math.floor(y)
x1, y1 = x0 + 1, y0 + 1
sx, sy = x - x0, y - y0
def dot_grid(ix, iy, px, py):
gx, gy = self._gradient(ix, iy)
return gx * (px - ix) + gy * (py - iy)
n00 = dot_grid(x0, y0, x, y)
n10 = dot_grid(x1, y0, x, y)
n01 = dot_grid(x0, y1, x, y)
n11 = dot_grid(x1, y1, x, y)
u, v = self._fade(sx), self._fade(sy)
return self._lerp(self._lerp(n00, n10, u), self._lerp(n01, n11, u), v)
class FractalHeightField:
def __init__(self, noise_cfg, height_scale):
self.height_scale = height_scale
self.frequency = noise_cfg["frequency"]
self.octaves = noise_cfg["octaves"]
self.lacunarity = noise_cfg["lacunarity"]
self.gain = noise_cfg["gain"]
self.noise = ValueNoise2D(noise_cfg["seed"])
def height(self, x, y):
amplitude, frequency, total, max_amp = 1.0, self.frequency, 0.0, 0.0
for _ in range(self.octaves):
total += amplitude * self.noise.sample(x * frequency, y * frequency)
max_amp += amplitude
amplitude *= self.gain
frequency *= self.lacunarity
return (total / max_amp) * self.height_scale
def normal(self, x, y, step=200.0):
return tc.finite_difference_normal(self.height, x, y, step)
# ---------------------------------------------------------------------------
def generate_tile(config, terrain_cfg, tile_label):
"""Builds one terrain + its scatter layers + its explicit placements, all
under a dedicated Outliner folder so multiple tiles can coexist in one level.
"""
field = FractalHeightField(terrain_cfg["noise"], terrain_cfg["height_scale"])
base_folder = f"ProceduralTerrain/{tile_label}"
print(f"[{tile_label}] building terrain mesh (seed={terrain_cfg['noise']['seed']})...")
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
tc.spawn_terrain_actor(static_mesh, origin, base_folder, f"Terrain_{tile_label}")
for layer_cfg in config.get("scatter_layers", []):
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
tc.place_explicit(explicit_entries, field, origin, base_folder)
def main():
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
base_terrain_cfg = config["terrain"]
variants = config.get("variants")
if not variants:
# Single-tile mode: unchanged behavior for existing configs.
generate_tile(config, base_terrain_cfg, "Main")
print("Done.")
return
# Variant mode: one JSON list of seeds becomes N independent, deterministic
# tiles laid out side by side in a single run - each a full reproducible
# world driven by nothing but that seed. There's no "batch-produce N baked,
# comparable layouts from one data file" workflow in the Foliage tool or a
# PCG graph; you'd re-author or re-seed each one by hand, one at a time.
gap = base_terrain_cfg["size_x"] * 1.2
base_seed = base_terrain_cfg["noise"]["seed"]
base_layer_seeds = {layer["name"]: layer.get("seed", 0) for layer in config.get("scatter_layers", [])}
for i, seed_offset in enumerate(variants):
tile_label = f"Variant_{seed_offset}"
terrain_cfg = json.loads(json.dumps(base_terrain_cfg)) # cheap deep copy
terrain_cfg["noise"]["seed"] = base_seed + seed_offset
terrain_cfg["asset_name"] = f"{base_terrain_cfg['asset_name']}_{tile_label}"
terrain_cfg["location"] = [
base_terrain_cfg["location"][0] + i * gap,
base_terrain_cfg["location"][1],
base_terrain_cfg["location"][2],
]
tile_config = json.loads(json.dumps(config))
tile_config["terrain"] = terrain_cfg
for layer_cfg in tile_config.get("scatter_layers", []):
layer_cfg["seed"] = base_layer_seeds.get(layer_cfg["name"], 0) + seed_offset
generate_tile(tile_config, terrain_cfg, tile_label)
print(f"Done: {len(variants)} variant(s) generated side by side.")
main()
@@ -0,0 +1,160 @@
"""Run inside Unreal (Python console / Execute Python Script).
Same pipeline as generate_procedural_terrain.py, but the height field comes
from an actual image (e.g. DemoAssets/poland_shaded.png) instead of procedural
noise. Reads heightmap_config.json (next to this script).
By default darker pixels = higher elevation and brighter pixels = lower, to
match shaded-relief style maps (set "invert": false in the config for the
opposite, standard-heightmap convention where white = high).
If the image has an alpha channel, pixels with alpha below "alpha_threshold"
(0-1, default 0.5) are cut out of the terrain mesh entirely (real holes, not
just low ground) and are excluded from foliage scatter/placement - useful for
a heightmap shaped like a country/island silhouette with a transparent
background.
Run import_environment_assets.py first so the mesh paths referenced in the
config actually exist. Set the HEIGHTMAP_CONFIG env var to point at a
different config file.
"""
import os, json, unreal
import terrain_common as tc
from png_reader import decode_png
CONFIG_PATH = os.environ.get(
"HEIGHTMAP_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "heightmap_config.json"),
)
class HeightmapField:
def __init__(self, cfg):
image_path = cfg["image_path"]
print(f"Decoding heightmap image: {image_path}")
width, height, channels, pixels = decode_png(image_path)
print(f" {width}x{height}, {channels} channel(s)")
self.width = width
self.height_px = height
self.invert = cfg.get("invert", True) # darker = higher, by default (shaded-relief convention)
self.height_scale = cfg["height_scale"]
self.size_x = cfg["size_x"]
self.size_y = cfg["size_y"]
self.alpha_threshold = cfg.get("alpha_threshold", 0.5)
self.exclude_white = cfg.get("exclude_white", True)
self.white_threshold = cfg.get("white_threshold", 0.98)
self._lum = self._build_luminance(pixels, width, height, channels)
self._alpha = self._build_alpha(pixels, width, height, channels)
self._min_channel = self._build_min_channel(pixels, width, height, channels)
@staticmethod
def _build_luminance(pixels, width, height, channels):
count = width * height
lum = [0.0] * count
if channels <= 2:
for i in range(count):
lum[i] = pixels[i * channels] / 255.0
else:
for i in range(count):
o = i * channels
lum[i] = (0.299 * pixels[o] + 0.587 * pixels[o + 1] + 0.114 * pixels[o + 2]) / 255.0
return lum
@staticmethod
def _build_min_channel(pixels, width, height, channels):
"""Per-pixel min(R,G,B) (or the sole channel, if grayscale) in [0,1] -
used to detect "fully white" fill pixels regardless of luminance
formula quirks (e.g. pure yellow has high luminance but isn't white)."""
count = width * height
arr = [0.0] * count
if channels <= 2:
for i in range(count):
arr[i] = pixels[i * channels] / 255.0
else:
for i in range(count):
o = i * channels
arr[i] = min(pixels[o], pixels[o + 1], pixels[o + 2]) / 255.0
return arr
@staticmethod
def _build_alpha(pixels, width, height, channels):
# Only grayscale+alpha (2) and RGBA (4) carry an alpha channel; anything
# else is fully opaque, so there is nothing to mask out.
if channels not in (2, 4):
return None
count = width * height
alpha = [0.0] * count
last = channels - 1
for i in range(count):
alpha[i] = pixels[i * channels + last] / 255.0
return alpha
def _world_to_image(self, x, y):
# Map world (x, y) in [-size/2, size/2] to continuous image space, with
# +Y world mapped toward row 0 (image top), matching a north-up map.
u = (x + self.size_x / 2.0) / self.size_x
v = (y + self.size_y / 2.0) / self.size_y
u = min(max(u, 0.0), 1.0)
v = min(max(v, 0.0), 1.0)
return u * (self.width - 1), (1.0 - v) * (self.height_px - 1)
def _sample_bilinear(self, channel_array, x, y):
px, py = self._world_to_image(x, y)
x0, y0 = int(px), int(py)
x1, y1 = min(x0 + 1, self.width - 1), min(y0 + 1, self.height_px - 1)
fx, fy = px - x0, py - y0
def S(xx, yy):
return channel_array[yy * self.width + xx]
s00, s10, s01, s11 = S(x0, y0), S(x1, y0), S(x0, y1), S(x1, y1)
s0 = s00 + (s10 - s00) * fx
s1 = s01 + (s11 - s01) * fx
return s0 + (s1 - s0) * fy
def height(self, x, y):
lum = self._sample_bilinear(self._lum, x, y)
if self.invert:
lum = 1.0 - lum
return lum * self.height_scale
def normal(self, x, y, step=200.0):
return tc.finite_difference_normal(self.height, x, y, step)
def is_masked(self, x, y):
if self._alpha is not None and self._sample_bilinear(self._alpha, x, y) < self.alpha_threshold:
return True
if self.exclude_white and self._sample_bilinear(self._min_channel, x, y) >= self.white_threshold:
return True
return False
def main():
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
terrain_cfg = config["terrain"]
field = HeightmapField(terrain_cfg)
base_folder = "HeightmapTerrain"
print("Building terrain mesh from heightmap...")
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
tc.spawn_terrain_actor(static_mesh, origin, base_folder, "HeightmapTerrain")
print("Scattering foliage layers...")
for layer_cfg in config.get("scatter_layers", []):
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
print("Placing explicit manifest entries...")
tc.place_explicit(explicit_entries, field, origin, base_folder)
print("Done.")
main()
@@ -0,0 +1,51 @@
{
"terrain": {
"image_path": "C:\\Users\\koleg\\Documents\\Unreal Projects\\ExampleProject\\DemoAssets\\poland_shaded.png",
"invert": true,
"alpha_threshold": 0.5,
"location": [0.0, 0.0, 0.0],
"size_x": 20000.0,
"size_y": 16580.0,
"resolution": 128,
"height_scale": 3000.0,
"dest_path": "/Game/ImportedAssets/Terrain",
"asset_name": "SM_HeightmapTerrain"
},
"scatter_layers": [
{
"name": "Trees",
"meshes": [
"/Game/ImportedAssets/Trees/SM_tree01",
"/Game/ImportedAssets/Trees/SM_tree02",
"/Game/ImportedAssets/Trees/SM_tree03",
"/Game/ImportedAssets/Trees/SM_tree04"
],
"count": 300,
"min_height": 0.0,
"max_height": 1500.0,
"max_slope_degrees": 30.0,
"min_scale": 0.85,
"max_scale": 1.3,
"align_to_normal": true,
"min_spacing": 250.0,
"seed": 7
},
{
"name": "Rocks",
"meshes": [
"/Game/ImportedAssets/Rocks/SM_rock1",
"/Game/ImportedAssets/Rocks/SM_rock2",
"/Game/ImportedAssets/Rocks/SM_rock3"
],
"count": 150,
"min_height": 800.0,
"max_height": 3000.0,
"max_slope_degrees": 50.0,
"min_scale": 0.5,
"max_scale": 2.0,
"align_to_normal": true,
"min_spacing": 200.0,
"seed": 42
}
]
}
File renamed without changes.
File renamed without changes.
+86
View File
@@ -0,0 +1,86 @@
"""Minimal pure-Python PNG decoder (stdlib only: zlib + struct).
Supports 8-bit, non-interlaced grayscale / grayscale+alpha / RGB / RGBA PNGs -
which covers ordinary exported heightmap/relief images. Deliberately does not
depend on Pillow or any other package, since Unreal's embedded Python has no
pip packages installed by default.
"""
import struct, zlib
_CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4} # 3 (palette) is unsupported
def _paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
return a
if pb <= pc:
return b
return c
def decode_png(path):
"""Returns (width, height, channels, pixels) where pixels is a flat
bytearray of width*height*channels bytes, row-major, top row first."""
with open(path, "rb") as f:
data = f.read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"Not a PNG file: {path}")
width = height = bit_depth = color_type = interlace = None
idat = bytearray()
pos = 8
while pos < len(data):
length = struct.unpack(">I", data[pos:pos + 4])[0]
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
if ctype == b"IHDR":
width, height, bit_depth, color_type, _comp, _filt, interlace = struct.unpack(">IIBBBBB", chunk)
elif ctype == b"IDAT":
idat.extend(chunk)
elif ctype == b"IEND":
break
pos += 8 + length + 4 # length + type + data + crc
if bit_depth != 8:
raise ValueError(f"Only 8-bit PNGs are supported (got bit depth {bit_depth}) - re-export as 8-bit: {path}")
if interlace != 0:
raise ValueError(f"Interlaced PNGs are not supported - re-export as non-interlaced: {path}")
if color_type not in _CHANNELS_BY_COLOR_TYPE:
raise ValueError(f"Unsupported PNG color type {color_type} (palette?) - re-export as grayscale/RGB/RGBA: {path}")
channels = _CHANNELS_BY_COLOR_TYPE[color_type]
raw = zlib.decompress(bytes(idat))
stride = width * channels
pixels = bytearray(width * height * channels)
prev_row = bytearray(stride)
row_start = 0
for y in range(height):
filter_type = raw[row_start]
row = bytearray(raw[row_start + 1: row_start + 1 + stride])
row_start += 1 + stride
if filter_type == 1: # Sub
for i in range(channels, stride):
row[i] = (row[i] + row[i - channels]) & 0xFF
elif filter_type == 2: # Up
for i in range(stride):
row[i] = (row[i] + prev_row[i]) & 0xFF
elif filter_type == 3: # Average
for i in range(stride):
a = row[i - channels] if i >= channels else 0
row[i] = (row[i] + ((a + prev_row[i]) >> 1)) & 0xFF
elif filter_type == 4: # Paeth
for i in range(stride):
a = row[i - channels] if i >= channels else 0
b = prev_row[i]
c = prev_row[i - channels] if i >= channels else 0
row[i] = (row[i] + _paeth(a, b, c)) & 0xFF
# filter_type == 0 (None): no-op
pixels[y * stride:(y + 1) * stride] = row
prev_row = row
return width, height, channels, pixels
@@ -1,103 +1,33 @@
"""Run inside Unreal (Python console / Execute Python Script).
"""Shared by generate_procedural_terrain.py and generate_terrain_from_heightmap.py.
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.
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 os, json, math, random, unreal
CONFIG_PATH = os.environ.get(
"TERRAIN_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config_variants_demo.json"),
)
import math, random, unreal
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
# ---------------------------------------------------------------------------
# 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):
h_l = self.height(x - step, y)
h_r = self.height(x + step, y)
h_d = self.height(x, y - step)
h_u = self.height(x, y + step)
# tangents along X and Y, cross product gives the surface normal.
tangent_x = unreal.Vector(2 * step, 0.0, h_r - h_l)
tangent_y = unreal.Vector(0.0, 2 * step, h_u - h_d)
nx = tangent_x.y * tangent_y.z - tangent_x.z * tangent_y.y
ny = tangent_x.z * tangent_y.x - tangent_x.x * tangent_y.z
nz = tangent_x.x * tangent_y.y - tangent_x.y * tangent_y.x
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
return unreal.Vector(nx / length, ny / length, nz / length)
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)
# ---------------------------------------------------------------------------
@@ -127,6 +57,22 @@ def build_terrain_mesh(cfg, field):
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"]
@@ -145,6 +91,16 @@ def build_terrain_mesh(cfg, field):
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 = {}
@@ -198,6 +154,7 @@ def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
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
@@ -209,6 +166,9 @@ def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
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
@@ -252,6 +212,7 @@ def place_explicit(entries, field, origin, base_folder):
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"])
@@ -260,6 +221,11 @@ def place_explicit(entries, field, origin, base_folder):
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)
@@ -284,73 +250,3 @@ def place_explicit(entries, field, origin, base_folder):
print(f" [explicit] placed {placed}/{len(entries)}")
return placed
def generate_tile(config, terrain_cfg, tile_label):
"""Builds one terrain + its scatter layers + its explicit placements, all
under a dedicated Outliner folder so multiple tiles can coexist in one level.
"""
field = FractalHeightField(terrain_cfg["noise"], terrain_cfg["height_scale"])
base_folder = f"ProceduralTerrain/{tile_label}"
print(f"[{tile_label}] building terrain mesh (seed={terrain_cfg['noise']['seed']})...")
static_mesh, bounds = build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
terrain_actor = actor_subsystem.spawn_actor_from_object(static_mesh, origin, unreal.Rotator(0, 0, 0))
terrain_actor.set_folder_path(base_folder)
terrain_actor.set_actor_label(f"Terrain_{tile_label}")
for layer_cfg in config.get("scatter_layers", []):
scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
place_explicit(explicit_entries, field, origin, base_folder)
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()
File renamed without changes.
+181
View File
@@ -0,0 +1,181 @@
# Nazwy i długości animacji w VS Code
W `ue_workshop.animations` są statycznie zapisane enumy `FighterAnimations` i
`CreatureAnimations`. Działają w zwykłym Pythonie, bez `unreal`. Po wpisaniu kropki
VS Code może podpowiedzieć wszystkie klipy. Opisy członków podają długość w sekundach.
Nazwy odpowiadają nazwom plików; nie ma skrótów typu `attack`.
```python
from ue_workshop.animations import FighterAnimations, CreatureAnimations
draw = FighterAnimations.DRAW_A_GREAT_SWORD_1
attack = FighterAnimations.GREAT_SWORD_ATTACK
draw.key # "draw_a_great_sword_1"
draw.seconds # 0.5 sekundy
draw.frames(24) # 12 klatek przy 24 FPS
attack.seconds # około 1.2 sekundy
attack.frames(24) # 29 klatek przy 24 FPS, zaokrąglone w górę
attack.frames(24, speed=2)
# Bez end klip wybrany enumem gra raz, z automatycznie policzonym końcem.
shot.animation(character_fighter, draw, start=0)
next_start = draw.frames(shot.fps) - 6
shot.animation(character_fighter, attack, start=next_start)
```
W tym przykładzie przejście trwa 6 wspólnych klatek. Przy zmianie klipu albo FPS
nie trzeba pamiętać jego długości. `speed` jest uwzględniane zarówno przez `frames`,
jak i przez automatyczny koniec w `shot.animation`.
Podanie `end` nadal ustawia własny zakres i pozwala zapętlić klip:
```python
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=0, end=120)
```
Dotychczasowe nazwy tekstowe nadal działają. Dla tekstu lub bez wskazania klipu
pominięte `end` zachowuje dotychczasowe znaczenie: gra do końca sekwencji.
Koniec sekcji musi mieścić się w `Sequence(..., frames=...)`.
## Zachowanie pozycji po ataku
Jeśli klip przesuwa postać do przodu, włącz opcję na tym klipie:
```python
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_2,
start=57, keep_end_position=True)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH_3, start=161)
```
`animation()` przenosi poziomy ruch kości głównej do zwykłych kluczy pozycji
aktora na ścieżce Transform. Ostatni klucz zachowuje osiągnięte przesunięcie,
więc następny atak zaczyna się w nowym miejscu. Klucze możesz edytować w Sequencerze.
Ruch pionowy i obroty kości pozostają w animacji.
### Pozostawienie postaci na ziemi po śmierci
```python
shot.animation(character_creature, CreatureAnimations.MUTANT_DYING,
start=150, keep_end_pose=True)
```
Enum bez `end` odtwarza klip raz. `keep_end_pose=True` dodaje po nim odcinek
z zamrożoną końcową pozą aż do końca sekwencji. Postać pozostaje na ziemi także
przy renderowaniu pojedynczej późniejszej klatki. Kolejna animacja tej samej
postaci kończy utrzymywanie pozy. Oryginalny klip nie jest zmieniany.
`keep_end_pose` dotyczy ułożenia kości; `keep_end_position` dotyczy przesunięcia
aktora. Można włączyć obie opcje. Jeśli jawnie wydłużysz zakres przez `end`,
klip nadal może się zapętlać w tym zakresie — dla śmierci pozostaw pojedynczy przebieg.
Aby uniknąć podwójnego przesuwania, sekcja korzysta z wygenerowanej kopii klipu
bez jego poziomego przesunięcia. Oryginalny asset animacji pozostaje bez zmian.
Opcja domyślnie jest wyłączona. Jeśli chcesz własne klucze pozycji, dodaj je po
wywołaniach `animation()` — zastąpią wygenerowane wartości na tych samych klatkach.
### Ruch względem aktualnej pozycji
Po `keep_end_position` nie musisz obliczać nowych współrzędnych ręcznie:
```python
# Po wywołaniach shot.animation(...): przesunięcie o 50 cm w osi Y.
shot.move(character_creature, start=80, end=120, offset=(0, 50, 0))
```
Helper odczyta pozycję aktora w klatce 80 z krzywych Sequencera, doda klucz
początkowy, a w klatce 120 klucz przesunięty o `offset`. To współrzędne świata
w centymetrach. Ostatnia klatka ruchu jest kluczem, więc musi być mniejsza od
`shot.frames`. Dodawaj ruch po animacjach. `move()` zastępuje klucze pozycji wewnątrz
odcinka, także wygenerowane przez `keep_end_position`. Późniejsze klucze pozycji są
przesuwane o wynikową różnicę, żeby zachować dalszy ruch. Klucze rotacji i skali zostają.
### Cofanie podczas chodzenia
`reverse=True` odtwarza klip od końca. W połączeniu z `keep_end_position=True`
odwraca także przesunięcie zapisane w klipie i zachowuje nową pozycję:
```python
shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING,
start=80, end=120, reverse=True, keep_end_position=True)
```
Postać odtwarza kroki do tyłu bez zmiany kierunku, w którym patrzy. Jeśli chcesz
ustalić odległość cofnięcia samodzielnie, dopisz po animacjach:
```python
shot.move(character_creature, start=80, end=120, offset=(0, -200, 0))
```
To ustawia cofnięcie o 200 cm w osi Y; `reverse` odpowiada za kierunek odtwarzania
ruchu nóg, a `move` zastępuje trasę pozycjami wybranymi przez autora sceny.
Do własnych obliczeń służy:
```python
x, y, z = shot.position(character_creature, frame=80)
```
Odczyt uwzględnia klucze Transform, `keep_end_position` i interpolację. Nie przestawia
kursora w Sequencerze. Dotyczy pozycji aktora; ruchy samych kości wewnątrz klipu
bez `keep_end_position` nie zmieniają pozycji jego aktora. Odczyt wykonuje się w UE
podczas uruchamiania skryptu, więc VS Code nie musi znać liczbowych współrzędnych.
Listę z danymi też można odczytać bez UE:
```python
for clip in FighterAnimations:
print(clip.key, clip.seconds, clip.frames(24))
```
## Własny model studenta — generowanie automatyczne
Każde `scene.skeletal_model(...)` automatycznie zapisuje nazwy i długości jego
zaimportowanych klipów do `Content/Python/ue_workshop/animations.py`. Dotyczy to
także modeli pobranych przez studentów, FBX z animacją w środku i `.blend`.
Umieść model i jego animacje FBX w osobnym folderze, np. `source_assets/ninja`.
Przy pierwszym uruchomieniu w UE:
```python
character = scene.skeletal_model(
PROJECT / "source_assets/ninja/model.fbx",
name="Ninja",
reimport=False,
)
```
Powstanie `NinjaAnimations` — nazwa enuma wynika z `name`. VS Code zobaczy nowy
statyczny kod i będzie mógł go podpowiadać. W kolejnej edycji skryptu możesz użyć:
```python
from ue_workshop.animations import NinjaAnimations
# Jeśli w folderze był walking.fbx:
clip = NinjaAnimations.WALKING
clip.seconds
clip.frames(shot.fps)
shot.animation(character, clip, start=0)
```
Pierwszy import w UE jest potrzebny do odczytania rzeczywistych długości.
Późniejsze pisanie sceny korzysta z zapisanego katalogu i nie wymaga komend w UE.
Różnym modelom nadaj różne `name` — to również nazwa ich assetów w projekcie.
Po dodaniu FBX do folderu uruchom swój skrypt ponownie: brakujący klip zostanie
zaimportowany i dopisany do enuma. Po zmianie istniejącego klipu użyj `REIMPORT=True`;
nowa długość zapisze się automatycznie. Nie trzeba uruchamiać osobnego generatora.
`REIMPORT=False` nadal używa istniejących assetów. Nie ma sprawdzania dat źródeł.
Katalogi innych modeli pozostają w pliku. Jawny wybór `animations={...}` aktualizuje
tylko wybrane wpisy, zachowując inne znane klipy tej postaci. Automatyczny odczyt
całego folderu odświeża pełną listę. Jeśli dane się nie zmieniły, plik nie jest
zapisywany ponownie.
Opcjonalna ręczna aktualizacja dołączonego fightera i creature:
```text
python tools/run_workshop.py tools/update_animation_catalog.py
```
To narzędzie również zachowuje katalogi własnych modeli studentów.
+111
View File
@@ -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` |
+108
View File
@@ -0,0 +1,108 @@
# Ściąga — ue_workshop
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ł.
Enumy i długości bez uruchamiania UE:
```python
from ue_workshop.animations import FighterAnimations
clip = FighterAnimations.GREAT_SWORD_ATTACK
clip.seconds
clip.frames(shot.fps, speed=1.0)
shot.animation(character, clip, start=48) # jeden przebieg; end obliczony z długości
```
Jawne `end` nadal pozwala zapętlić klip. `scene.skeletal_model(path, name="Ninja")`
automatycznie wygeneruje `NinjaAnimations` po pierwszym imporcie w UE, także dla
własnych plików studentów. [Katalog offline i jego odświeżanie](animation_catalog.md).
`shot.animation(character, clip, start=57, keep_end_position=True)` zapisuje poziomy
ruch z klipu jako klucze Transform; końcowy klucz utrzymuje pozycję po ataku.
Źródłowy klip pozostaje bez zmian. Opcja dotyczy ruchu kości głównej.
`shot.position(character, frame=80)` odczytuje pozycję aktora z krzywych sekwencji.
`shot.move(character, start=80, end=120, offset=(0, 50, 0))` dodaje klucze ruchu
o 50 cm względem pozycji w klatce 80, także po `keep_end_position`.
`move` zastępuje klucze pozycji w tym odcinku i odpowiednio przesuwa późniejsze.
`shot.animation(character, clip, reverse=True, keep_end_position=True, start=80, end=120)`
odtwarza kroki i przeniesiony ruch do tyłu. Odległość można nadpisać przez `shot.move`.
`shot.animation(character, CreatureAnimations.MUTANT_DYING, start=150, keep_end_pose=True)`
odtwarza śmierć raz i zachowuje końcową pozę na dalszą część sekwencji.
`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")` |
| Kolor PBR (wartości liniowe 0–1) | `mat = materials.color("Red", (0.8, 0.02, 0.01), roughness=0.4)` |
| Prosta postać | `actor = scene.character(material=mat, head_scale=1.3)` |
| Bryła | `scene.primitive("Sphere", name="Ball", location=(0, 100, 50), material=mat)` |
| Import statycznego modelu | `mesh = assets.import_model("C:/models/model.glb", name="MyModel")` |
| Eksport + import Blendera | `mesh = blender.import_character("C:/models/character.blend")` |
| Model na scenie | `actor = scene.mesh(mesh, name="Hero")` |
| Import z dopasowaniem rozmiaru i pivotu | `actor = scene.model("source_assets/models/hero.glb", name="Hero", height=200)` |
| 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))` |
| Żarówka | `lamp = lighting.point(scene, location=(0, 0, 250), intensity=10000, color=(1, 0.5, 0.2))` |
| Reflektor | `spot = lighting.spot(scene, location=(200, -200, 400), target=(0, 0, 100), angle=30)` |
| Słońce (liczy się tylko kierunek) | `sun = lighting.sun(scene, location=(300, -300, 500), target=(0, 0, 0), intensity=500)` |
| 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)` |
| Klucz pozycji | `shot.transform_key(actor, 0, location=(0, 0, 0))` |
| Klucz rotacji | `shot.transform_key(actor, 47, rotation=(0, 0, 360))` |
| Klucz skali | `shot.transform_key(actor, 24, scale=(1.2, 1.2, 1.2), interpolation="auto")` |
| Klucz koloru światła | `shot.light_key(spot, 0, color=(1, 0, 0))` |
| Klucz jasności światła | `shot.light_key(spot, 47, intensity=40000, interpolation="auto")` |
| Zapis + otwarcie Sequencera | `shot.save()` |
| Własny renderer | `renderer = Renderer(width=1920, height=1080, samples=8, warmup=32, mp4=True)` |
| Preset z własnymi wartościami | `renderer = Renderer.from_preset("final", samples=8)` |
| Render z własnymi ustawieniami | `renderer.render(shot)` |
| Podgląd tym samym rendererem | `renderer.render(shot, single_frame=24)` |
| PNG + MP4 | `render.start(shot, preset="preview")` |
| Tylko PNG | `render.start(shot, mp4=False)` |
| Jedna klatka | `render.start(shot, single_frame=24)` |
UE: centymetry, Z w górę. Rotacja w kluczach: **roll, pitch, yaw**, stopnie.
Klatki: **0..frames-1**, koniec zakresu wyłączny. Interpolacja: `linear`, `auto`, `constant`.
Cały obrót: klucze 0 i 360, nie 0 i 0. Kamera i modele to oryginalne obiekty UE:
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="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.
+175
View File
@@ -0,0 +1,175 @@
# Weryfikacja v1 — 31 października 2026
Sprawdzone lokalnie: Windows, Unreal Engine 5.7, Blender 5.1.2 i FFmpeg.
Nie uruchamiano testów na Linuksie ani na Blenderze 4.x.
- Testy poza UE: 7 zaliczonych, w tym rzeczywiste kodowanie dwóch PNG do MP4,
odczyt częściowo zapisanych komunikatów i raportowanie liczby klatek.
- Postęp headless: sprawdzone etapy od startu UE do jego zamknięcia,
licznik zapisanych PNG i cichy zapis pełnych logów do pliku.
- Obiekt `Renderer`: własne 320×180, dwie próbki, rozgrzewka 0; sprawdzone
właściwości MRQ i rzeczywiste wymiary PNG. Walidacja błędnych argumentów
i nadpisywanie presetu. Launcher używa obiektu ze skryptu: sprawdzone 400×224.
- Dwukrotne wykonanie ćwiczenia w UE: bez dodatkowych aktorów, bindingów i Camera Cuts.
- Render MRQ w procesie edytora uruchomionym bez okna: 48 PNG oraz MP4, 24 FPS.
- `python exercises/my_shot_headless.py`: pełne ćwiczenie 1920×1080, 4 próbki,
rozgrzewka 32, 48 PNG i MP4. UE zamknął się automatycznie; oba kody zakończenia 0.
- Podgląd pojedynczej klatki z terminala (`--single-frame 24`): zaliczony.
- Odroczone zamknięcie edytora po renderze: kod zakończenia UE i launchera 0.
- Zwykły `UnrealEditor.exe`: projekt otwiera się bez żądania aktualizacji targetu
po usunięciu pozostałych receiptów i cache kompilacji UE 5.8.
- Wizualnie sprawdzony kadr, ekspozycja i wygenerowana postać.
- Pełny edytor z działającym UI Sequencera: otwarcie sekwencji, ticki UI,
przebudowa sceny i dwa kolejne rendery po 48 klatek do MP4 w jednym procesie.
Renderer startuje przez MoviePipelineQueueSubsystem po powrocie ze skryptu;
Sequencer jest zamykany przed przebudową bindingów oraz rozpoczęciem PIE.
- Blender: eksport zapisanej kolekcji, trzy materiały, aktualizacja istniejącego
StaticMesh pod tą samą ścieżką po zmianie geometrii.
- Zmiana koloru materiału oraz dodanie osadzonej tekstury: sprawdzone wartości
parametrów UE, przypisanie tekstury i render do MP4.
- Import lokalnych RobotPolice i pilot z FBX, automatyczne dopasowanie wysokości
do 200 cm i ustawienie podstawy na Z=0. Odtworzenie materiałów z tekstur PBR.
- Pełny `my_shot.py` z robotem: 48 klatek 1920×1080, MP4 i poprawne zamknięcie UE.
- GLB po włączeniu łączenia części modelu: zaliczony test eksportu/importu
Blendera, aktualizacji materiałów i tekstury oraz renderu.
- `character_custom.blend`: dwukrotne wykonanie `my_shot.py`, dwa eksporty
Blendera do GLB i aktualizacja tego samego assetu UE; podgląd renderu zaliczony.
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.
## Katalog enumów i długości dostępnych offline
- 34 testy poza UE: statyczne enumy obejmują wszystkie pliki źródłowe, importują
się bez `unreal`, udostępniają sekundy i przeliczenie klatek z FPS i prędkością.
- Sprawdzone zaokrąglanie czasu float32 bez dodawania zbędnej klatki, automatyczny
koniec dla enuma, jawny koniec oraz odrzucenie sekcji dłuższej niż sekwencja.
- `tools/smoke_animation_catalog.py` w UE: porównane długości wszystkich 63 klipów
z rzeczywistym `get_play_length()`, odtworzone enumy, w tym `speed=2`, oraz render
klatki ze środka automatycznego przejścia. Katalog pochodzi z eksportu assetów UE.
- Po przypisaniu parametrów sekcji przywracany jest zakres podany w Pythonie,
ponieważ edytor może przeskalować go przy zmianie prędkości.
## Automatyczne katalogi własnych modeli
- 41 testów poza UE: generowanie nowej klasy, metadane bez `unreal`, zachowanie
innych modeli, aktualizacja podzbioru, usuniętych klipów i niezmienionych danych.
- Sprawdzone odrzucanie kolizji nazw i nieprawidłowych czasów bez naruszenia pliku
oraz dodanie nowej klasy do już załadowanego modułu.
- `tools/smoke_automatic_catalog.py` zaliczony w UE: pierwszy import pod nazwą
StudentCatalogSmoke, automatyczny zapis dwóch klipów, odczyt wygenerowanego enuma
i użycie go do animacji z automatycznym końcem. Katalog testowy zapisano w build.
- Zwykły hook odświeżył także pełne katalogi Fighter i Creature z istniejących assetów.
## keep_end_position
- 45 testów poza UE: dodatkowo pozioma interpolacja ruchu, sumowanie pętli,
włączenie przeniesienia do Transform i ponowne użycie jednej kopii klipu.
- `tools/smoke_keep_end_position.py` zaliczony w UE: slash_2 od klatki 57 przy
30 FPS, wejście slash_3 w klatce 161. Zapisane rzeczywiste klucze rodzica;
po klatce 163 aktor zachowuje około 354 cm przesunięcia dla modelu wysokości 200 cm.
- Sprawdzone ciągłe położenie bioder przy przejściu, brak podwojenia przesunięcia,
niezmieniona źródłowa animacja, ponowne wykonanie i render klatki po ataku.
- Przenoszony jest poziomy ruch pierwszej kości szkieletu (Hips w tym modelu).
Pionowe ruchy i obroty kości zostają w klipie; kopie są w wygenerowanym folderze RootMotion.
## Odczyt pozycji i ruch względny
- 49 testów poza UE: odczyt istniejących kanałów pozycji, pozycja startowa bez
ścieżki, dodanie względnego offsetu oraz walidacja bez tworzenia częściowych kluczy.
- `tools/smoke_relative_move.py` zaliczony w UE: creature wysokości 300 cm,
start `(0, -850, 0)`, MUTANT_RUN z keep_end_position, swiping i walking.
- Pozycja w klatce 80 odczytana z natywnych krzywych Sequencera. Ruch 80–120
zwiększył Y dokładnie o 50 cm, zachował punkt początkowy i pozycję po klatce 120.
Sprawdzono też interpolowaną pozycję w środku ruchu.
## Cofanie i zastępowanie trasy
- 53 testy poza UE: odwrócone odtwarzanie, cofanie ruchu po nierównomiernej krzywej,
sumowanie pętli do tyłu, usuwanie kluczy wewnątrz move i przesuwanie późniejszych.
- `tools/smoke_reverse_walk.py` zaliczony w UE: run → swiping → walking z reverse,
naturalny ruch wstecz oraz zastąpienie trasy cofnięciem o 200 cm.
- Sprawdzone pozycje początkowa, środkowa i końcowa, brak wewnętrznych kluczy
z poprzedniej trasy i zachowanie względnego ruchu późniejszego klucza.
## Zachowanie końcowej pozy
- 55 testów poza UE: natywne Keep State, zamrożony odcinek końcowej pozy oraz
skracanie tego odcinka, gdy pojawi się następny klip tej samej postaci.
- `tools/smoke_keep_end_pose.py`: śmierć mutanta od klatki 0 przy 30 FPS, render
klatki 299 po zakończeniu klipu. Wizualnie sprawdzono, że mutant leży na ziemi.
- Samo Keep State dawało w takim renderze pozę referencyjną; dodatkowy odcinek
z prędkością 0 i przesunięciem czasu do końca klipu utrzymuje właściwą pozę.
## Rodzaje świateł i klucze światła
- 60 testów poza UE: klucze koloru i jasności, ponowne użycie tracków oraz
odrzucanie błędnych argumentów bez tworzenia tracka.
- `tools/smoke_lights.py` w UE: point, spot i sun w jednej scenie, render klatek
0, 24 i 47. Wizualnie sprawdzono zmianę koloru reflektora z czerwonego na niebieski
oraz wzrost jasności reflektora i żarówki.
- Domyślne `intensity=500` słońca dobrano do stałej ekspozycji `camera.create`;
przy `intensity=1` słońce nie było widoczne na renderze.
+25
View File
@@ -0,0 +1,25 @@
# Spotkanie — 60 minut
Przed spotkaniem: UE 5.7, pobrany projekt, skompilowane shadery, sprawdzony render.
FFmpeg potrzebny do MP4, Blender opcjonalnie. Przygotuj gotowy film na wypadek
wolniejszego sprzętu. Nie poświęcaj części praktycznej na instalowanie narzędzi.
| Minuty | Działanie |
|---|---|
| 0–15 | Python w edytorze, wykonanie pliku, aktor i asset, pokaz Blender → film. |
| 15–23 | `my_shot.py`: model, scena, klucze, Camera Cuts, render. Pokaż wynik w UI. |
| 23–53 | Własna kopia ćwiczenia, zmiany i krótki render. |
| 53–60 | Wyniki, pytania, pokaz launchera bez okna. |
Propozycje zadań, bez obowiązku wykonania wszystkich:
1. Zmień kolory postaci i oświetlenia, powiększ głowę.
2. Zmień pozycję i ogniskową kamery. Zrób pojedynczy podgląd.
3. Dodaj klucz w środku sekwencji: podskok albo zmianę skali.
4. Animuj kamerę — przykład w `examples/animated_camera.py`.
5. Opcjonalnie: zmień model w Blenderze i wykonaj `examples/blender_character.py`.
Punkt ratunkowy: świeża kopia `exercises/my_shot.py` z Git. Do pierwszych prób
zostaw `RENDER=False` i oglądaj Sequencera. Do renderu włącz `RENDER=True`.
Przy powrocie do skryptu pamiętaj wyłączyć drugi render, jeżeli dodajesz wywołanie
`render.start` ręcznie. Pokaż gdzie są PNG oraz `result.json`.
+13
View File
@@ -0,0 +1,13 @@
"""Wariant: nieruchoma postac i animowana kamera."""
from ue_workshop import Scene, Sequence, camera, lighting, materials
scene = Scene("CameraShot")
scene.character(material=materials.color("Orange", (0.8, 0.16, 0.03)))
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))
shot = Sequence(scene)
shot.camera(cam)
shot.transform_key(cam, 0, location=(450, -550, 300), interpolation="auto")
shot.transform_key(cam, 47, location=(280, -340, 230), interpolation="auto")
shot.save()
+18
View File
@@ -0,0 +1,18 @@
"""Najpierw wygeneruj source_assets/character/character.blend; instrukcja w README."""
from ue_workshop import Scene, Sequence, Renderer, blender, camera, lighting, materials
from ue_workshop.paths import PROJECT
mesh = blender.import_character(PROJECT / "source_assets/character/character.blend")
scene = Scene("BlenderShot")
character = scene.mesh(mesh, name="Character")
floor = materials.color("Floor", rgb=(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)
shot = Sequence(scene, frames=48)
shot.camera(cam)
shot.transform_key(character, 0, rotation=(0, 0, 0))
shot.transform_key(character, 47, rotation=(0, 0, 360))
shot.save()
renderer = Renderer(width=640, height=360, samples=1, warmup=8)
renderer.render(shot)
+26
View File
@@ -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)
+78
View File
@@ -0,0 +1,78 @@
"""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", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "root_motion", "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
from ue_workshop.animations import FighterAnimations, CreatureAnimations
# 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, location=(30, -40, 0.16), rotation=(0,0,180))
character_creature = scene.skeletal_model(CREATURE_MODEL, name="Creature", height=300, location=(0,-850,0))
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
lights = [
lighting.area(scene, "Key", (200, -500, 350), color=(1.0, 0.75, 0.5)),
lighting.area(scene, "Fill", (100, 300, 250), intensity=7000, color=(0.3, 0.6, 1.0)),
lighting.area(scene, "Rim", (-200, 0, 300), intensity=15000)
]
cam = camera.create(scene, location=(200, -300, 200), target=(0, 0, 100), fov=90)
# 2. ANIMACJA. 120 klatek przy 24 FPS = 5 sekund. Ostatnia klatka ma numer 119.
shot = Sequence(scene, fps=30, frames=260)
# Fighter
shot.animation(character_fighter, FighterAnimations.DRAW_A_GREAT_SWORD_2, start=0)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_IDLE, start=22, end=55)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING, start=48)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_BLOCKING_2, start=61)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_SLASH, start=80)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_JUMP_ATTACK, start=117, keep_end_position=True)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_STRAFE, start=180, keep_end_position=True)
shot.animation(character_fighter, FighterAnimations.GREAT_SWORD_RUN_2, start=207, end=259, keep_end_position=True)
shot.transform_key(character_fighter,58, location=(30, -40, 0.16), interpolation="auto")
shot.transform_key(character_fighter,64, location=(30, 20, 0.16), interpolation="auto")
shot.move(character_fighter, start=134, end=146, offset=(0, -170, 0), interpolation="auto")
# Creature
shot.animation(character_creature, CreatureAnimations.MUTANT_RUN, start=0, end=60, keep_end_position=True)
shot.animation(character_creature, CreatureAnimations.MUTANT_SWIPING, start=20)
shot.animation(character_creature, CreatureAnimations.MUTANT_WALKING, start=95, end=160, keep_end_position=True, reverse=True)
shot.animation(character_creature, CreatureAnimations.MUTANT_DYING, start=130, end=259, keep_end_pose=True)
# Camera
shot.camera(cam)
r = cam.get_actor_rotation()
shot.transform_key(cam, 0, rotation=(r.roll, r.pitch, r.yaw))
shot.transform_key(cam, 10, location=(200, -300, 200), interpolation="auto")
shot.transform_key(cam, 75, location=(450, -550, 300), rotation=(0, -15, 150), 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)
+35
View File
@@ -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)
+43
View File
@@ -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)
+91
View File
@@ -0,0 +1,91 @@
"""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", "animation_clip", "animation_catalog", "animations", "progress", "video", "materials", "camera", "lighting", "assets",
"_gltf", "blender", "characters", "skeletal", "root_motion", "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
# 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, 180) # roll, pitch, yaw; obrot modeli Mixamo przodem do kamery
# 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore.
scene = Scene("MyShot")
floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11))
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:
presets = {
"fighter": (FIGHTER_MODEL, "Fighter"),
"creature": (CREATURE_MODEL, "Creature"),
}
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), 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)
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, 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, 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 = 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)
+25
View File
@@ -0,0 +1,25 @@
"""Uruchom zwyklym Pythonem: python exercises/my_shot_headless.py"""
from pathlib import Path
import sys
PROJECT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT / "tools"))
from run_workshop import main as run_workshop
def main():
if "unreal" in sys.modules:
raise RuntimeError("Ten plik uruchom z terminala zwyklym Pythonem, poza edytorem UE.")
# --render zapewnia film takze wtedy, gdy w my_shot.py jest RENDER=False.
# Argumenty launchera, np. --editor i --timeout, mozna dopisac w terminalu.
script = Path(__file__).with_name("advanced_example.py")
return run_workshop([str(script), "--render", *sys.argv[1:]])
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(str(exc), file=sys.stderr)
raise SystemExit(1)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Open ExampleProject.uproject in Unreal Editor on Linux.
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_PATH="$SCRIPT_DIR/ExampleProject.uproject"
show_error() {
local message="$1"
if command -v zenity >/dev/null 2>&1 && [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
zenity --error --title="ExampleProject - Unreal Editor" --width=520 --text="$message"
else
printf 'ERROR: %s\n' "$message" >&2
if [[ -t 0 ]]; then
read -r -p "Press Enter to close..." _
fi
fi
}
if [[ ! -f "$PROJECT_PATH" ]]; then
show_error "Project file was not found:\n$PROJECT_PATH"
exit 1
fi
find_editor() {
local candidate
# An explicit path takes priority. It may point to the UE root or the binary.
if [[ -n "${UE_ROOT:-}" ]]; then
for candidate in \
"$UE_ROOT" \
"$UE_ROOT/Engine/Binaries/Linux/UnrealEditor"; do
if [[ -x "$candidate" && ! -d "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
fi
if command -v UnrealEditor >/dev/null 2>&1; then
command -v UnrealEditor
return 0
fi
for candidate in \
"$HOME/Unreal/UE_5.7/Engine/Binaries/Linux/UnrealEditor" \
"$HOME/UnrealEngine/Engine/Binaries/Linux/UnrealEditor" \
/opt/UnrealEngine/Engine/Binaries/Linux/UnrealEditor \
/mnt/UnrealEngine/Engine/Binaries/Linux/UnrealEditor; do
if [[ -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
if ! UNREAL_EDITOR="$(find_editor)"; then
show_error "Unreal Editor was not found.\n\nSet UE_ROOT to the Unreal Engine directory, for example:\nexport UE_ROOT=/home/vysyr/Unreal/UE_5.7"
exit 1
fi
printf 'Opening project:\n %s\nwith:\n %s\n' "$PROJECT_PATH" "$UNREAL_EDITOR"
SDL_VIDEODRIVER=x11 exec "$UNREAL_EDITOR" "$PROJECT_PATH" "$@"
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more