Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
934067f154 | ||
|
|
1530b74d04 | ||
|
|
bd8edf5afb | ||
|
|
6fe57915e8 | ||
|
|
8c0a639fba | ||
|
|
fb39e290c0 | ||
|
|
a3a80dfa16 | ||
|
|
86f54deb01 | ||
|
|
e523a5e1fe | ||
|
|
5284a0f5df | ||
|
|
3a9b791a6a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.analysis.extraPaths": ["${workspaceFolder}/Content/Python"],
|
||||
"editor.hover.enabled": true,
|
||||
"editor.inlayHints.enabled": "on",
|
||||
"python.analysis.inlayHints.callArgumentNames": "all"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[/Script/PythonScriptPlugin.PythonScriptPluginSettings]
|
||||
bDeveloperMode=True
|
||||
bRemoteExecution=False
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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."""
|
||||
|
||||
|
||||
class MatTestRigAnimations(AnimationClip):
|
||||
"""Clip names and durations: type MatTestRigAnimations. for editor completion."""
|
||||
MATTESTRIG_ANIM = ('MatTestRig_Anim', 10.375)
|
||||
"""10.375 seconds at speed=1; clip: MatTestRig_Anim."""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Automated static mesh imports using the engine's enabled import pipelines."""
|
||||
from pathlib import Path
|
||||
|
||||
import unreal
|
||||
|
||||
from .paths import GENERATED, PROJECT, slug
|
||||
|
||||
|
||||
def _disable_nanite(mesh):
|
||||
# Workshop renders showed Nanite's coarse fallback mesh instead of the full model.
|
||||
settings = mesh.get_editor_property("nanite_settings")
|
||||
if settings.enabled:
|
||||
settings.enabled = False
|
||||
unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem).set_nanite_settings(mesh, settings, True)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
|
||||
|
||||
|
||||
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)]
|
||||
if len(meshes) != 1:
|
||||
return None
|
||||
_disable_nanite(meshes[0])
|
||||
return meshes[0]
|
||||
|
||||
|
||||
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.")
|
||||
_disable_nanite(meshes[0])
|
||||
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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import unreal
|
||||
|
||||
from .camera import look_at
|
||||
|
||||
# Calibrated in tools/smoke_basics.py against the fixed exposure from camera.create.
|
||||
_CUBEMAP_AMBIENT = 30.0
|
||||
_FOG_LUMINANCE = 30.0
|
||||
|
||||
|
||||
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)
|
||||
light = _setup(actor, unreal.DirectionalLightComponent, intensity, color)
|
||||
light.set_atmosphere_sun_light(True)
|
||||
look_at(actor, target)
|
||||
return actor
|
||||
|
||||
|
||||
def environment(scene, sky=True, sun_intensity=500, sun_angle=45, sun_yaw=-45,
|
||||
ambient=1.0, fog=0.0, fog_color=(0.6, 0.7, 0.9)):
|
||||
"""Add a world around the scene: sky, sun, ambient light and height fog.
|
||||
|
||||
sun_angle is the height above the horizon in degrees (low = sunset), sun_yaw
|
||||
its compass direction. sky=False keeps a black background with ambient light.
|
||||
The sky is lit only by the sun: for night use sky=False, sun_intensity=0, ambient=0.3.
|
||||
Returns .sun (None when sun_intensity=0), usable with shot.light_key.
|
||||
"""
|
||||
for name, value in (("sun_intensity", sun_intensity), ("ambient", ambient), ("fog", fog)):
|
||||
if value < 0:
|
||||
raise ValueError(f"{name} nie moze byc ujemne.")
|
||||
sun_actor = None
|
||||
if sun_intensity > 0:
|
||||
height, compass = math.radians(sun_angle), math.radians(sun_yaw)
|
||||
direction = (math.cos(height) * math.cos(compass), math.cos(height) * math.sin(compass), math.sin(height))
|
||||
sun_actor = sun(scene, location=tuple(1000 * value for value in direction), intensity=sun_intensity)
|
||||
sun_actor.get_component_by_class(unreal.DirectionalLightComponent).set_atmosphere_sun_light(sky)
|
||||
# Also for sky=False: an unlit atmosphere keeps the background black, not white.
|
||||
scene.spawn(unreal.SkyAtmosphere, "Sky")
|
||||
ambient_actor = scene.spawn(unreal.SkyLight, "Ambient")
|
||||
light = ambient_actor.light_component
|
||||
light.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
if sky:
|
||||
light.set_editor_property("real_time_capture", True)
|
||||
light.set_intensity(float(ambient))
|
||||
else:
|
||||
light.set_editor_property("source_type", unreal.SkyLightSourceType.SLS_SPECIFIED_CUBEMAP)
|
||||
light.set_cubemap(unreal.load_asset("/Engine/MapTemplates/Sky/DaylightAmbientCubemap"))
|
||||
light.set_intensity(float(ambient) * _CUBEMAP_AMBIENT)
|
||||
if fog > 0:
|
||||
component = scene.spawn(unreal.ExponentialHeightFog, "Fog").component
|
||||
component.set_fog_density(float(fog))
|
||||
component.set_editor_property("fog_inscattering_luminance", unreal.LinearColor(
|
||||
*(value * _FOG_LUMINANCE for value in fog_color), 1.0))
|
||||
return SimpleNamespace(sun=sun_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)]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Small opaque PBR materials, generated without external assets."""
|
||||
import unreal
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import GENERATED, PROJECT, 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)
|
||||
material.set_editor_property("used_with_skeletal_mesh", True)
|
||||
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=None):
|
||||
"""Set a material on an actor and all attached meshes; slot=None replaces every slot."""
|
||||
components = [component for part in [actor, *actor.get_attached_actors(True, True)]
|
||||
for component in part.get_components_by_class(unreal.MeshComponent)
|
||||
if component.get_num_materials() > 0]
|
||||
if not components:
|
||||
raise TypeError("Material mozna przypisac do aktora z siatka.")
|
||||
for component in components:
|
||||
for index in range(component.get_num_materials()) if slot is None else [slot]:
|
||||
component.set_material(index, material)
|
||||
|
||||
|
||||
def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, emissive=None, tiling=1):
|
||||
"""Create one atlas material from adjacent PBR textures (PNG/JPEG).
|
||||
|
||||
tiling repeats the textures: one number, or (u, v) for separate directions.
|
||||
"""
|
||||
slug(name)
|
||||
u_tiling, v_tiling = tiling if isinstance(tiling, (tuple, list)) else (tiling, tiling)
|
||||
if u_tiling <= 0 or v_tiling <= 0:
|
||||
raise ValueError("tiling musi byc dodatnie.")
|
||||
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)
|
||||
material.set_editor_property("used_with_skeletal_mesh", True)
|
||||
coordinates = lib.create_material_expression(material, unreal.MaterialExpressionTextureCoordinate, -600, 0)
|
||||
coordinates.set_editor_property("u_tiling", float(u_tiling))
|
||||
coordinates.set_editor_property("v_tiling", float(v_tiling))
|
||||
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).expanduser()
|
||||
source = (source if source.is_absolute() else PROJECT / source).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_expressions(coordinates, "", node, "UVs")
|
||||
lib.connect_material_property(node, channel, prop)
|
||||
lib.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
return material
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Small append-only progress events shared with the external launcher."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def report(state, **details):
|
||||
target = os.environ.get("WORKSHOP_PROGRESS")
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
with Path(target).open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(dict(state=state, **details), ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
# Progress reporting must not interrupt a render or hide its result.
|
||||
pass
|
||||
@@ -0,0 +1,192 @@
|
||||
"""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
|
||||
audio: 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}.")
|
||||
if self.audio is not None and not self.mp4:
|
||||
raise ValueError("audio wymaga mp4=True.")
|
||||
|
||||
@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"], audio=state["audio"]))
|
||||
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
|
||||
audio = None
|
||||
if mp4 and renderer.audio is not None:
|
||||
audio = Path(renderer.audio).expanduser()
|
||||
audio = (audio if audio.is_absolute() else PROJECT / audio).resolve()
|
||||
if not audio.is_file():
|
||||
raise FileNotFoundError(audio)
|
||||
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, audio=audio, ticks=2,
|
||||
settings=dict(width=width, height=height, samples=samples, warmup=warmup, fps=sequence.fps))
|
||||
try:
|
||||
_write_status({"state": "rendering", "output": str(output)})
|
||||
_active["tick_handle"] = unreal.register_slate_post_tick_callback(_begin_render)
|
||||
except Exception:
|
||||
_active = None
|
||||
raise
|
||||
unreal.log("[Workshop] Render: " + str(output))
|
||||
return output
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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, rotation=(0, 0, 0)):
|
||||
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, rotation)
|
||||
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, location=(0, 0, 0)):
|
||||
"""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)
|
||||
root.set_actor_location(unreal.Vector(*location), False, 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, name="Character", location=(0, 0, 0)):
|
||||
root = self.spawn(unreal.StaticMeshActor, name)
|
||||
root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE)
|
||||
for shape, part, 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 + part, location=position, scale=scale, material=material)
|
||||
actor.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)
|
||||
return root
|
||||
|
||||
def save(self):
|
||||
editor = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
if not editor.save_current_level():
|
||||
raise RuntimeError("Nie mozna zapisac mapy " + self.map_path)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""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 = {}
|
||||
self._camera_cuts = []
|
||||
self._visibility = {}
|
||||
|
||||
def seconds(self, seconds) -> int:
|
||||
"""Convert seconds to the nearest frame number at this sequence's FPS."""
|
||||
if not isinstance(seconds, (int, float)) or not math.isfinite(seconds) or seconds < 0:
|
||||
raise ValueError("Czas musi byc nieujemna liczba sekund.")
|
||||
return round(seconds * self.fps)
|
||||
|
||||
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, start=0, end=None):
|
||||
"""Use a camera over [start, end); a later call overrides earlier cameras in its range.
|
||||
|
||||
shot.camera(cam_a) followed by shot.camera(cam_b, start=60) cuts at frame 60.
|
||||
"""
|
||||
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 kamery: 0 <= start < end <= {self.frames}.")
|
||||
binding = self.bind(actor)
|
||||
cuts = [(start, end, binding)]
|
||||
for old_start, old_end, old_binding in self._camera_cuts:
|
||||
if old_start < start:
|
||||
cuts.append((old_start, min(old_end, start), old_binding))
|
||||
if old_end > end:
|
||||
cuts.append((max(old_start, end), old_end, old_binding))
|
||||
self._camera_cuts = sorted(cuts, key=lambda cut: cut[0])
|
||||
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)
|
||||
for cut_start, cut_end, cut_binding in self._camera_cuts:
|
||||
section = track.add_section()
|
||||
section.set_range(cut_start, cut_end)
|
||||
binding_id = unreal.MovieSceneObjectBindingID()
|
||||
binding_id.set_editor_property("guid", cut_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 then(self, actor, clip: AnimationClip | str | unreal.AnimSequence | None = None, overlap=0, **options):
|
||||
"""Play a clip right after the actor's latest clip; overlap frames crossfade them.
|
||||
|
||||
Takes the same options as animation(), except start.
|
||||
"""
|
||||
if type(overlap) is not int or overlap < 0:
|
||||
raise ValueError("overlap musi byc nieujemna liczba klatek.")
|
||||
entry = self.scene._skeletal_models.get(actor.get_path_name())
|
||||
if entry is None:
|
||||
raise ValueError("Animacja wymaga aktora utworzonego przez scene.skeletal_model().")
|
||||
sections = self._animations.get(entry[0].get_path_name())
|
||||
previous_end = max(item[1] for item in sections) if sections else 0
|
||||
return self.animation(actor, clip, start=max(previous_end - overlap, 0), **options)
|
||||
|
||||
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 visible(self, actor, frame, visible=True):
|
||||
"""Show or hide an actor and everything attached to it from a frame onward.
|
||||
|
||||
The first call decides the earlier state: shot.visible(actor, 60) keeps
|
||||
the actor hidden before frame 60, shot.visible(actor, 60, False) visible.
|
||||
"""
|
||||
if type(frame) is not int or not 0 <= frame < self.frames:
|
||||
raise ValueError(f"Klatka musi nalezec do 0..{self.frames - 1}.")
|
||||
if type(visible) is not bool:
|
||||
raise ValueError("visible musi byc True lub False.")
|
||||
for part in [actor, *actor.get_attached_actors(True, True)]:
|
||||
key = part.get_path_name()
|
||||
if key not in self._visibility:
|
||||
track = self.bind(part).add_track(unreal.MovieSceneVisibilityTrack)
|
||||
track.set_property_name_and_path("bHidden", "bHidden")
|
||||
section = track.add_section()
|
||||
section.set_range(0, self.frames)
|
||||
self._visibility[key] = section.get_all_channels()[0]
|
||||
if frame > 0:
|
||||
self._visibility[key].add_key(unreal.FrameNumber(0), not visible)
|
||||
channel = self._visibility[key]
|
||||
for old in list(channel.get_keys()):
|
||||
if old.get_time().frame_number.value == frame:
|
||||
channel.remove_key(old)
|
||||
channel.add_key(unreal.FrameNumber(frame), visible)
|
||||
|
||||
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.
|
||||
"""
|
||||
return self._read_transform(actor, frame, 0, lambda: actor.get_actor_location().to_tuple())
|
||||
|
||||
def rotation(self, actor, frame: int) -> tuple[float, float, float]:
|
||||
"""Actor (roll, pitch, yaw) at a sequence frame, evaluated like position()."""
|
||||
def initial():
|
||||
rotation = actor.get_actor_rotation()
|
||||
return (rotation.roll, rotation.pitch, rotation.yaw)
|
||||
return self._read_transform(actor, frame, 3, initial)
|
||||
|
||||
def _read_transform(self, actor, frame, first_channel, initial):
|
||||
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 initial()
|
||||
interval = self.asset.make_range(frame, 1)
|
||||
rate = unreal.FrameRate(self.fps, 1)
|
||||
values = [channel.evaluate_keys(interval, rate)
|
||||
for channel in channels[first_channel:first_channel + 3]]
|
||||
if any(not axis for axis in values):
|
||||
raise RuntimeError("Nie mozna odczytac 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.
|
||||
"""
|
||||
return self._relative_keys(actor, start, end, offset, interpolation, "location", 0, self.position)
|
||||
|
||||
def rotate(self, actor, start: int, end: int, offset: tuple[float, float, float],
|
||||
interpolation="auto") -> tuple[float, float, float]:
|
||||
"""Rotate by offset (roll, pitch, yaw degrees) from the actor's rotation at start.
|
||||
|
||||
Works like move() for rotation keys; offset yaw=360 is one full turn.
|
||||
keep_end_position still follows the direction from before the rotation.
|
||||
Returns the target rotation. Position/scale keys are kept.
|
||||
"""
|
||||
return self._relative_keys(actor, start, end, offset, interpolation, "rotation", 3, self.rotation)
|
||||
|
||||
def _relative_keys(self, actor, start, end, offset, interpolation, keyword, first_channel, read):
|
||||
if (type(start) is not int or type(end) is not int or
|
||||
not 0 <= start < end < self.frames):
|
||||
raise ValueError(f"Zakres 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.")
|
||||
origin = read(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 = read(actor, end)
|
||||
for axis, channel in enumerate(channels[first_channel:first_channel + 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, interpolation=interpolation, **{keyword: origin})
|
||||
self.transform_key(actor, end, interpolation=interpolation, **{keyword: target})
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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, audio=None):
|
||||
"""Encode PNG frames to MP4. Audio is cut to the film; shorter audio ends in silence."""
|
||||
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)
|
||||
audio_input, audio_output = [], []
|
||||
if audio is not None:
|
||||
if not Path(audio).is_file():
|
||||
raise FileNotFoundError(audio)
|
||||
audio_input = ["-i", str(audio)]
|
||||
audio_output = ["-map", "0:v:0", "-map", "1:a:0", "-c:a", "aac", "-b:a", "192k", "-t", str(count / fps)]
|
||||
command = [executable("ffmpeg", ffmpeg), "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-framerate", str(fps), "-start_number", "0", "-i", str(frames / "frame_%04d.png"), *audio_input,
|
||||
"-frames:v", str(count), "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", *audio_output, "-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()
|
||||
|
After Width: | Height: | Size: 27 MiB |
|
After Width: | Height: | Size: 780 KiB |
|
After Width: | Height: | Size: 25 MiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 11 MiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
@@ -0,0 +1,128 @@
|
||||
# Ś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)` |
|
||||
| Kilka prostych postaci | `scene.character(material=mat, name="Bob", location=(200, 0, 0))` |
|
||||
| Bryła | `scene.primitive("Sphere", name="Ball", location=(0, 100, 50), material=mat)` |
|
||||
| Pochyła bryła | `scene.primitive("Cube", name="Ramp", scale=(2, 2, 0.2), rotation=(0, 30, 0))` |
|
||||
| Zmiana materiału, także modelu | `materials.assign(character, mat)` |
|
||||
| Powtarzana tekstura | `materials.texture_set("Floor", base_color="textures/floor.png", tiling=8)` |
|
||||
| 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, location=(0, 100, 0))` |
|
||||
| Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` |
|
||||
| Niebo, słońce, światło otoczenia | `lighting.environment(scene)` |
|
||||
| Zachód słońca z mgłą | `lighting.environment(scene, sun_angle=5, sun_yaw=90, fog=0.02)` |
|
||||
| Noc | `lighting.environment(scene, sky=False, sun_intensity=0, ambient=0.3)` |
|
||||
| Ś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)` |
|
||||
| Cięcie na drugą kamerę | `shot.camera(cam2, start=60)` albo `shot.camera(cam2, start=60, end=90)` |
|
||||
| Sekundy na klatki | `shot.seconds(2.5)` |
|
||||
| Następny klip po poprzednim | `shot.then(character, clip)` |
|
||||
| To samo z przejściem 10 klatek | `shot.then(character, clip, overlap=10)` |
|
||||
| Obrót względny | `shot.rotate(actor, start=0, end=24, offset=(0, 0, 90))` |
|
||||
| Odczyt rotacji | `shot.rotation(actor, frame=24)` |
|
||||
| Pojawienie się od klatki 60 | `shot.visible(actor, 60)` |
|
||||
| Zniknięcie od klatki 60 | `shot.visible(actor, 60, False)` |
|
||||
| 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)` |
|
||||
| Muzyka w MP4 (przycięta do długości filmu) | `Renderer(audio="source_assets/music.mp3")` |
|
||||
| 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
|
||||
```
|
||||
|
||||
`shot.rotate` obraca aktora, ale `keep_end_position` dalej przesuwa go w kierunku
|
||||
sprzed obrotu. Po skręcie użyj klipu In Place i `shot.move` w nowym kierunku.
|
||||
Muzyka trafia tylko do MP4; Sequencer odtwarza film bez dźwięku.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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.
|
||||
|
||||
## Podstawowe operacje sceny
|
||||
|
||||
- 86 testów poza UE: cięcia kamer, widoczność z dołączonymi aktorami, względna
|
||||
rotacja, `then`, `seconds`, `materials.assign` oraz MP4 z dźwiękiem przyciętym do filmu.
|
||||
- `tools/smoke_basics.py` w UE: 48 klatek, trzy odcinki dwóch kamer, render MP4
|
||||
ze ścieżką AAC. Wizualnie sprawdzono cięcia, pojawianie i znikanie obiektów,
|
||||
czerwony materiał na fighterze, `tiling=8`, pochyłą bryłę i obrót postaci.
|
||||
- `lighting.environment` sprawdzone wizualnie: dzień, mgła, zachód, noc i `sky=False`.
|
||||
- Bez `lighting.environment` odsłonięte tło może być białe, np. przy samej żarówce.
|
||||
Przy niebie na małej podłodze widać ciemny pas pod horyzontem; mgła go ukrywa.
|
||||
- `shot.visible` wyłącza też światło: sprawdzone na żarówce między dwiema klatkami.
|
||||
- Nanite jest wyłączany przy `assets.import_model` i przy użyciu istniejącego assetu.
|
||||
Z włączonym render pokazywał siatkę zastępczą: około 1064 z 26 496 trójkątów.
|
||||
@@ -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`.
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Dwie postacie Mixamo: osobne szkielety, klipy i pozycje na jednej scenie."""
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.characters import FIGHTER_MODEL, CREATURE_MODEL
|
||||
|
||||
REIMPORT = False # True: ponowny import modelow i klipow; False: istniejace assety UE
|
||||
|
||||
scene = Scene("MixamoCharacters")
|
||||
fighter = scene.skeletal_model(
|
||||
FIGHTER_MODEL, name="Fighter", location=(-160, 0, 0),
|
||||
rotation=(0, 0, 180), reimport=REIMPORT)
|
||||
creature = scene.skeletal_model(
|
||||
CREATURE_MODEL, name="Creature", location=(160, 0, 0),
|
||||
rotation=(0, 0, 180), reimport=REIMPORT)
|
||||
floor = materials.color("Floor", (0.07, 0.08, 0.11))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(500, -850, 350), target=(0, 0, 100), fov=50)
|
||||
shot = Sequence(scene, fps=24, frames=120)
|
||||
shot.camera(cam)
|
||||
shot.animation(fighter, "great_sword_attack", start=0, end=60)
|
||||
shot.animation(fighter, "great_sword_idle", start=60, end=120)
|
||||
shot.animation(creature, "mutant_idle", start=0, end=60)
|
||||
shot.animation(creature, "mutant_swiping", start=60, end=120)
|
||||
shot.save()
|
||||
renderer = Renderer()
|
||||
# Po obejrzeniu w Sequencerze: renderer.render(shot)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Zadanie: stworz ciekawa 15-sekundowa scene. Punkt wyjscia: jedna postac i dwa klipy.
|
||||
|
||||
Uruchom w UE: File > Execute Python Script. Zrob wlasna kopie pliku przed edycja.
|
||||
"""
|
||||
from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials
|
||||
from ue_workshop.characters import CREATURE_MODEL, _clips
|
||||
|
||||
REIMPORT = False # True: ponowny import modelu i klipow; False: istniejace assety UE
|
||||
|
||||
scene = Scene("AnimationStarter")
|
||||
clips = _clips(CREATURE_MODEL)
|
||||
character = scene.skeletal_model(
|
||||
CREATURE_MODEL, name="Creature", height=200, rotation=(0, 0, 180), reimport=REIMPORT,
|
||||
animations={name: clips[name] for name in ("mutant_idle", "mutant_swiping")},
|
||||
)
|
||||
floor = materials.color("Floor", (0.07, 0.08, 0.11))
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor)
|
||||
lighting.studio(scene)
|
||||
cam = camera.create(scene, location=(450, -550, 300), fov=45)
|
||||
|
||||
# 360 klatek przy 24 FPS = 15 sekund. Koniec zakresu jest wylaczny.
|
||||
shot = Sequence(scene, fps=24, frames=360)
|
||||
shot.camera(cam)
|
||||
# 12 wspolnych klatek = plynne przejscie przez 0.5 sekundy.
|
||||
shot.animation(character, "mutant_idle", start=0, end=132)
|
||||
shot.animation(character, "mutant_swiping", start=120, end=360)
|
||||
|
||||
# Dodaj ruch postaci, kamery, kolejne klipy lub druga postac.
|
||||
# shot.transform_key(character, 120, location=(0, 0, 0))
|
||||
# shot.transform_key(character, 239, location=(150, 0, 0))
|
||||
# Wszystkie klipy: docs/animations.md. Usun animations=... powyzej, aby udostepnic wszystkie.
|
||||
shot.save()
|
||||
|
||||
renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True)
|
||||
# renderer.render(shot)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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 = True # True po zmianie modelu w Blenderze; False uzywa assetu z UE
|
||||
SKY = True # True: niebo i slonce; False: ciemne studio z trzema swiatlami
|
||||
|
||||
# 1. SCENA. Zmien model, podloge i swiatla.
|
||||
scene = Scene("Basics")
|
||||
character = scene.model(
|
||||
PROJECT / "source_assets/character/character_custom.blend",
|
||||
name="CustomCharacter", height=200, reimport=REIMPORT,
|
||||
)
|
||||
# tiling=40: tekstura powtorzona 40 razy na kazdej krawedzi podlogi (200 x 200 m).
|
||||
floor = materials.texture_set("TiledFloor", base_color="source_assets/textures/floor_tiles.png", tiling=40)
|
||||
scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(200, 200, 0.2), material=floor)
|
||||
if SKY:
|
||||
# Zachod slonca: dodaj sun_angle=5. Mocniejsza mgla: fog=0.05.
|
||||
lighting.environment(scene, sun_intensity=1500, fog=0.01)
|
||||
else:
|
||||
lighting.studio(scene)
|
||||
|
||||
# 2. KAMERA. Jednostki: centymetry; FOV w stopniach.
|
||||
radius = 550
|
||||
height = 140 # nisko, zeby w kadrze byl horyzont
|
||||
target = (0, 0, 110)
|
||||
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. Muzyke uslyszysz dopiero w MP4.
|
||||
renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True,
|
||||
audio="source_assets/audio/basics_music.wav")
|
||||
# renderer.render(shot)
|
||||
# Zamiast filmu: renderer.render(shot, single_frame=96)
|
||||
@@ -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)
|
||||
@@ -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("basics.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)
|
||||
@@ -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" "$@"
|
||||
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 5.9 MiB |