diff --git a/Content/Python/ue_workshop/assets.py b/Content/Python/ue_workshop/assets.py index aed8299..c5cc8b5 100644 --- a/Content/Python/ue_workshop/assets.py +++ b/Content/Python/ue_workshop/assets.py @@ -3,11 +3,12 @@ from pathlib import Path import unreal -from .paths import GENERATED, slug +from .paths import GENERATED, PROJECT, slug def import_model(filename, name="Character"): - source = Path(filename).resolve() + 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"}: @@ -31,6 +32,10 @@ def import_model(filename, name="Character"): 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. @@ -47,9 +52,11 @@ def import_model(filename, name="Character"): unreal.SoftObjectPath(pipeline.get_path_name()), unreal.SoftObjectPath("/Interchange/Pipelines/DefaultGLTFPipeline.DefaultGLTFPipeline")]) task.set_editor_property("options", options) - if source.suffix.lower() == ".fbx": + 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) @@ -59,10 +66,23 @@ def import_model(filename, name="Character"): if len(meshes) != 1: raise RuntimeError( f"Import {source.name}: oczekiwano jednego StaticMesh, otrzymano {len(meshes)}. " - "Polacz obiekty przed eksportem albo uzyj workshop.blender.import_character().") + "Plik musi zawierac statyczna geometrie modelu.") if source.suffix.lower() in {".glb", ".gltf"}: # UE preserves material edits on mesh reimport. In this workflow Blender # owns the look; explicitly synchronize the supported PBR parameters. from ._gltf import link_textures link_textures(source, meshes[0], destination) + else: + # Optional single-atlas convention, also used by the bundled FBX demos. + files = {file.stem.lower(): file for file in source.parent.iterdir() + if file.suffix.lower() in {".png", ".jpg", ".jpeg"}} + maps = {parameter: files.get(source.stem.lower() + suffix) + for suffix, parameter in (("_bc", "base_color"), ("_n", "normal"), + ("_r", "roughness"), ("_m", "metallic"), ("_e", "emissive"))} + if maps["base_color"] is not None: + from .materials import texture_set + material = texture_set(name + "Imported", **maps) + for index in range(len(meshes[0].static_materials)): + meshes[0].set_material(index, material) + unreal.EditorAssetLibrary.save_loaded_asset(meshes[0]) return meshes[0] diff --git a/Content/Python/ue_workshop/materials.py b/Content/Python/ue_workshop/materials.py index 83048af..0dc4054 100644 --- a/Content/Python/ue_workshop/materials.py +++ b/Content/Python/ue_workshop/materials.py @@ -1,6 +1,8 @@ """Small opaque PBR materials, generated without external assets.""" import unreal +from pathlib import Path + from .paths import GENERATED, slug @@ -32,3 +34,58 @@ def assign(actor, material, slot=0): if component is None: raise TypeError("Material mozna przypisac do aktora ze StaticMeshComponent.") component.set_material(slot, material) + + +def texture_set(name, *, base_color, normal=None, roughness=None, metallic=None, emissive=None): + """Create one atlas material from adjacent PBR textures (PNG/JPEG).""" + slug(name) + tools = unreal.AssetToolsHelpers.get_asset_tools() + path = f"{GENERATED}/Materials/M_{name}" + material = unreal.load_asset(path) + if material is None: + material = tools.create_asset("M_" + name, GENERATED + "/Materials", + unreal.Material, unreal.MaterialFactoryNew()) + lib = unreal.MaterialEditingLibrary + lib.delete_all_material_expressions(material) + entries = [("BaseColor", base_color, unreal.MaterialProperty.MP_BASE_COLOR, "RGB", "color"), + ("Normal", normal, unreal.MaterialProperty.MP_NORMAL, "RGB", "normal"), + ("Roughness", roughness, unreal.MaterialProperty.MP_ROUGHNESS, "R", "linear"), + ("Metallic", metallic, unreal.MaterialProperty.MP_METALLIC, "R", "linear"), + ("Emissive", emissive, unreal.MaterialProperty.MP_EMISSIVE_COLOR, "RGB", "color")] + for index, (label, filename, prop, channel, role) in enumerate(entries): + if filename is None: + if label in {"Roughness", "Metallic"}: + node = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -300, index * 180) + node.set_editor_property("r", 0.6 if label == "Roughness" else 0.0) + lib.connect_material_property(node, "", prop) + continue + source = Path(filename).resolve() + if not source.is_file(): + raise FileNotFoundError(source) + task = unreal.AssetImportTask() + task.filename = str(source) + task.destination_path = f"{GENERATED}/Materials/Textures/{name}" + task.destination_name = "T_" + label + task.automated = True + task.replace_existing = True + task.save = True + task.set_editor_property("async_", False) + tools.import_asset_tasks([task]) + textures = [obj for obj in task.get_objects() if isinstance(obj, unreal.Texture2D)] + if len(textures) != 1: + raise RuntimeError("Nie mozna zaimportowac tekstury " + str(source)) + texture = textures[0] + texture.set_editor_property("srgb", role == "color") + texture.set_editor_property("compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP + if role == "normal" else unreal.TextureCompressionSettings.TC_MASKS + if role == "linear" else unreal.TextureCompressionSettings.TC_DEFAULT) + unreal.EditorAssetLibrary.save_loaded_asset(texture) + node = lib.create_material_expression(material, unreal.MaterialExpressionTextureSample, -300, index * 180) + node.set_editor_property("texture", texture) + node.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL + if role == "normal" else unreal.MaterialSamplerType.SAMPLERTYPE_MASKS + if role == "linear" else unreal.MaterialSamplerType.SAMPLERTYPE_COLOR) + lib.connect_material_property(node, channel, prop) + lib.recompile_material(material) + unreal.EditorAssetLibrary.save_loaded_asset(material) + return material diff --git a/Content/Python/ue_workshop/paths.py b/Content/Python/ue_workshop/paths.py index 01cf372..a5c6ab7 100644 --- a/Content/Python/ue_workshop/paths.py +++ b/Content/Python/ue_workshop/paths.py @@ -16,6 +16,12 @@ def slug(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( diff --git a/Content/Python/ue_workshop/scene.py b/Content/Python/ue_workshop/scene.py index 1a3407d..4d58be7 100644 --- a/Content/Python/ue_workshop/scene.py +++ b/Content/Python/ue_workshop/scene.py @@ -1,7 +1,10 @@ """An isolated generated map, simple meshes and a ready-to-use character.""" +import math +from pathlib import Path + import unreal -from .paths import GENERATED, slug +from .paths import GENERATED, PROJECT, slug class Scene: @@ -65,6 +68,41 @@ class Scene: raise ValueError("Ksztalt: Cube, Sphere, Cylinder, Cone lub Plane.") return self.mesh(f"/Engine/BasicShapes/{shape}.{shape}", **kwargs) + def model(self, filename, name="Character", height=200, rotation=(0, 0, 0)): + """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) + else: + from .assets import import_model + mesh = import_model(source, name=name) + actor = self.mesh(mesh, name=name + "Mesh") + roll, pitch, yaw = rotation + actor.set_actor_rotation(unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw), False) + origin, extent = actor.get_actor_bounds(False) + if extent.z <= 0: + self.subsystem.destroy_actor(actor) + raise ValueError("Model ma zerowa wysokosc. Sprawdz geometrie i MODEL_ROTATION.") + if height is not None: + factor = float(height) / (2 * extent.z) + actor.set_actor_scale3d(unreal.Vector(factor, factor, factor)) + origin, extent = actor.get_actor_bounds(False) + actor.set_actor_location(unreal.Vector(-origin.x, -origin.y, extent.z - origin.z), False, False) + root = self.spawn(unreal.StaticMeshActor, name) + root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) + actor.attach_to_actor(root, "", unreal.AttachmentRule.KEEP_WORLD, + unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False) + return root + def character(self, material=None, head_scale=1.0): root = self.spawn(unreal.StaticMeshActor, "Character") root.static_mesh_component.set_mobility(unreal.ComponentMobility.MOVABLE) diff --git a/README.md b/README.md index 405a5ec..1398bd1 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,64 @@ 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. | +| `"robot"` | RobotPolice z dołączonego `DemoAssets`. | +| `"pilot"` | Humanoid z `DemoAssets/pilot`. | +| `"file"` | Własny plik wskazany w `MODEL_FILE`. | + +Przykład własnego modelu: + +```python +MODEL = "file" +MODEL_FILE = PROJECT / "source_assets/models/my_character.glb" +MODEL_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. Robot i pilot korzystają z tej konwencji. +Bez pliku `_BC` pozostają materiały zaimportowane przez UE. W GLB zachowywany +jest osobny materiał dla każdego slotu. + +Po zmianie postaci reszta skryptu działa tak samo: klucze `location`, `rotation` +i `scale` animują cały model. W przykładzie jest też opcjonalny podskok. +Modele z rigami są w tej wersji importowane jako statyczna geometria: +animacje kości, chodzenie z Mixamo i retargeting nie są jeszcze obsługiwane. +Wybrany model działa zarówno w edytorze, jak i przez `my_shot_headless.py`. + ## 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" +``` + +Zapisz zmiany w Blenderze i ponownie uruchom ćwiczenie. Za każdym razem 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): diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 0599b3f..b34a22a 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -11,6 +11,8 @@ Import: `from ue_workshop import Scene, Sequence, Renderer, camera, lighting, ma | Import statycznego modelu | `mesh = assets.import_model("C:/models/model.glb", name="MyModel")` | | Eksport + import Blendera | `mesh = blender.import_character("C:/models/character.blend")` | | Model na scenie | `actor = scene.mesh(mesh, name="Hero")` | +| Import z dopasowaniem rozmiaru i pivotu | `actor = scene.model("source_assets/models/hero.glb", name="Hero", height=200)` | +| Korekta osi modelu przed dopasowaniem | `actor = scene.model("hero.fbx", rotation=(0, 0, 90))` | | Światła studyjne | `lights = lighting.studio(scene)` | | Pojedyncze światło | `lighting.area(scene, location=(200, -200, 350), intensity=10000, color=(1, 0.5, 0.2))` | | Kamera | `cam = camera.create(scene, location=(450, -550, 300), target=(0, 0, 100), focal_length=45)` | @@ -35,5 +37,9 @@ Cały obrót: klucze 0 i 360, nie 0 i 0. Kamera i modele to oryginalne obiekty U możesz wywoływać ich metody, np. `actor.set_actor_label("MyHero")`. `shot.asset` to oryginalny LevelSequence, `shot.bind(actor)` zwraca binding UE. +W `my_shot.py`: `MODEL="robot"` lub `MODEL="pilot"` wybiera dołączony model. +`MODEL="file"` wczytuje `MODEL_FILE`. Obiekt z `scene.model()` ma pivot na podstawie +i zachowuje materiały; animacja dotyczy całego modelu, nie jego kości. + Przy pojedynczym podglądzie nie uruchamiaj dodatkowo pełnego renderu: jednocześnie może pracować tylko jeden renderer warsztatowy. diff --git a/docs/validation.md b/docs/validation.md index 540b4da..f994191 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -27,6 +27,13 @@ Nie uruchamiano testów na Linuksie ani na Blenderze 4.x. 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`. diff --git a/exercises/my_shot.py b/exercises/my_shot.py index 41e4c53..4cee837 100644 --- a/exercises/my_shot.py +++ b/exercises/my_shot.py @@ -17,12 +17,30 @@ if "ue_workshop" in sys.modules: importlib.reload(sys.modules["ue_workshop"]) from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials +from ue_workshop.paths import PROJECT + +# WYBOR POSTACI: "primitive", "robot", "pilot" lub "file". +MODEL = "file" +MODEL_FILE = PROJECT / "source_assets/character/character_custom.blend" +MODEL_HEIGHT = 200 # centymetry; None zachowuje oryginalny rozmiar +MODEL_ROTATION = (0, 0, 0) # roll, pitch, yaw; np. (0, 0, 90) obraca model # 1. MODEL I SCENA. Jednostki: centymetry. Os Z wskazuje gore. scene = Scene("MyShot") -blue = materials.color("Character", rgb=(0.04, 0.35, 0.8), metallic=0.2) floor = materials.color("Floor", rgb=(0.07, 0.08, 0.11)) -character = scene.character(material=blue, head_scale=1.0) +if MODEL == "primitive": + blue = materials.color("Character", rgb=(0.04, 0.35, 0.8), metallic=0.2) + character = scene.character(material=blue, head_scale=1.0) +else: + sources = { + "robot": (PROJECT / "DemoAssets/RobotPolice/RobotPolice.fbx", "RobotPolice"), + "pilot": (PROJECT / "DemoAssets/pilot/pilot.fbx", "Pilot"), + "file": (MODEL_FILE, "CustomCharacter"), + } + if MODEL not in sources: + raise ValueError("MODEL: primitive, robot, pilot lub file.") + source, name = sources[MODEL] + character = scene.model(source, name=name, height=MODEL_HEIGHT, rotation=MODEL_ROTATION) scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2), material=floor) lighting.studio(scene, key_color=(1.0, 0.75, 0.5), fill_color=(0.3, 0.6, 1.0)) cam = camera.create(scene, location=(450, -550, 300), focal_length=45) @@ -33,6 +51,10 @@ shot.camera(cam) # Rotacja: (roll, pitch, yaw), w stopniach. shot.transform_key(character, 0, rotation=(0, 0, 0)) shot.transform_key(character, 47, rotation=(0, 0, 360)) +# Opcjonalny podskok (dziala tak samo dla robota i wlasnego modelu): +# shot.transform_key(character, 0, location=(0, 0, 0), interpolation="auto") +# shot.transform_key(character, 24, location=(0, 0, 80), interpolation="auto") +# shot.transform_key(character, 47, location=(0, 0, 0), interpolation="auto") shot.save() # 3. WYNIK. Najpierw obejrzyj animacje w Sequencerze, potem zmien RENDER na True. diff --git a/source_assets/character/character_custom.blend b/source_assets/character/character_custom.blend new file mode 100644 index 0000000..e3480d9 Binary files /dev/null and b/source_assets/character/character_custom.blend differ diff --git a/source_assets/models/README.md b/source_assets/models/README.md new file mode 100644 index 0000000..1c24e12 --- /dev/null +++ b/source_assets/models/README.md @@ -0,0 +1,13 @@ +Tutaj możesz wrzucić rozpakowany model i jego tekstury. + +W `exercises/my_shot.py` ustaw `MODEL = "file"`, a w `MODEL_FILE` wskaż plik. +Ścieżka może być względem projektu albo absolutna, np. `"D:/Modele/hero.glb"`. +Najwygodniejszy jest GLB z osadzonymi teksturami PBR PNG/JPEG. +FBX i OBJ wymagają zachowania plików tekstur oraz powiązanych plików MTL/FBM. +ZIP trzeba wcześniej rozpakować. Plik `.blend` wymaga zainstalowanego Blendera +i kolekcji `Character`, zgodnie z przykładem w README projektu. + +Model jest importowany jako geometria statyczna. Można animować jego pozycję, +obrót i skalę. Rig, retargeting i animacje chodzenia nie są obecnie importowane. +Tekstury i nazwy materiałów powinny być zachowane w eksporcie; obsługujemy +podstawowe PBR, a nie dowolne shadery konkretnego programu. diff --git a/tools/run_workshop.py b/tools/run_workshop.py index 35b5b0f..ceb2e1e 100644 --- a/tools/run_workshop.py +++ b/tools/run_workshop.py @@ -66,7 +66,7 @@ def main(argv=None): command = [editor, str(ROOT / "ExampleProject.uproject"), "-ExecutePythonScript=" + (ROOT / "tools/ue_bootstrap.py").as_posix(), "-RenderOffscreen", "-unattended", "-nosplash", "-nosound", "-stdout", - "-FullStdOutLogOutput", "-NoLoadingScreen"] + "-FullStdOutLogOutput", "-NoLoadingScreen", "-nocef"] if args.verbose: # UE writes its own log when console output is streamed directly. command.append("-abslog=" + log_path.as_posix()) diff --git a/tools/smoke_models.py b/tools/smoke_models.py new file mode 100644 index 0000000..3573361 --- /dev/null +++ b/tools/smoke_models.py @@ -0,0 +1,31 @@ +"""Check local FBX characters, automatic fitting, stable pivots and MRQ.""" +import unreal + +from ue_workshop import Scene, Sequence, Renderer, camera, lighting +from ue_workshop.paths import PROJECT + +scene = Scene("ImportedModelsSmoke") +characters = [] +for name, file in (("RobotSmoke", "DemoAssets/RobotPolice/RobotPolice.fbx"), + ("PilotSmoke", "DemoAssets/pilot/pilot.fbx")): + root = scene.model(PROJECT / file, name=name, height=200) + child = root.get_attached_actors()[0] + origin, extent = child.get_actor_bounds(False) + assert abs(2 * extent.z - 200) < 0.1, (name, origin, extent) + assert abs(origin.z - extent.z) < 0.1, (name, origin, extent) + assert abs(origin.x) < 0.1 and abs(origin.y) < 0.1 + mesh = child.static_mesh_component.static_mesh + assert len(mesh.static_materials) > 0 + assert all(slot.material_interface for slot in mesh.static_materials) + assert all(slot.material_interface.get_name() == "M_" + name + "Imported" for slot in mesh.static_materials) + characters.append(root) +characters[1].set_actor_location(unreal.Vector(0, 160, 0), False, False) +scene.primitive("Cube", name="Floor", location=(0, 0, -10), scale=(20, 20, 0.2)) +lighting.studio(scene) +cam = camera.create(scene, location=(600, -850, 400), target=(0, 70, 100)) +shot = Sequence(scene, frames=2) +shot.camera(cam) +for character in characters: + shot.transform_key(character, 0, rotation=(0, 0, 0)) + shot.transform_key(character, 1, rotation=(0, 0, 45)) +Renderer(width=640, height=360, samples=2, warmup=8).render(shot)