diff --git a/Content/Python/ue_workshop/animations.py b/Content/Python/ue_workshop/animations.py index 452709f..e1cfcd6 100644 --- a/Content/Python/ue_workshop/animations.py +++ b/Content/Python/ue_workshop/animations.py @@ -138,3 +138,9 @@ class FighterAnimations(AnimationClip): """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.""" diff --git a/Content/Python/ue_workshop/assets.py b/Content/Python/ue_workshop/assets.py index ee86f66..f8276ac 100644 --- a/Content/Python/ue_workshop/assets.py +++ b/Content/Python/ue_workshop/assets.py @@ -6,13 +6,25 @@ 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)] - return meshes[0] if len(meshes) == 1 else None + if len(meshes) != 1: + return None + _disable_nanite(meshes[0]) + return meshes[0] def import_model(filename, name="Character", reimport=True): @@ -80,6 +92,7 @@ def import_model(filename, name="Character", reimport=True): 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. diff --git a/docs/validation.md b/docs/validation.md index 9229f56..db75683 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -185,3 +185,5 @@ Nie sprawdzano retargetingu, Animation Blueprintów ani eksportu wielu akcji/NLA - 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. diff --git a/exercises/basics.py b/exercises/basics.py index d757cc2..52b40c3 100644 --- a/exercises/basics.py +++ b/exercises/basics.py @@ -7,22 +7,28 @@ import math from ue_workshop import Scene, Sequence, Renderer, camera, lighting, materials from ue_workshop.paths import PROJECT -REIMPORT = False # True po zmianie modelu w Blenderze; False uzywa assetu z UE +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, kolor podlogi i swiatla. +# 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, ) -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) +# 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 = 260 -target = (0, 0, 100) +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. @@ -37,7 +43,8 @@ for frame in range(shot.frames): shot.transform_key(cam, frame, location=(x, y, height), rotation=(0, pitch, angle + 180)) shot.save() -# 4. RENDER. Najpierw obejrzyj scene w Sequencerze. -renderer = Renderer(width=1280, height=720, samples=4, warmup=16, mp4=True) +# 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) diff --git a/exercises/my_shot_headless.py b/exercises/my_shot_headless.py index cc05d4d..ce62d66 100644 --- a/exercises/my_shot_headless.py +++ b/exercises/my_shot_headless.py @@ -13,7 +13,7 @@ def main(): raise RuntimeError("Ten plik uruchom z terminala zwyklym Pythonem, poza edytorem UE.") # --render zapewnia film takze wtedy, gdy w my_shot.py jest RENDER=False. # Argumenty launchera, np. --editor i --timeout, mozna dopisac w terminalu. - script = Path(__file__).with_name("advanced_example.py") + script = Path(__file__).with_name("basics.py") return run_workshop([str(script), "--render", *sys.argv[1:]]) diff --git a/source_assets/audio/basics_music.wav b/source_assets/audio/basics_music.wav new file mode 100644 index 0000000..189fe48 Binary files /dev/null and b/source_assets/audio/basics_music.wav differ diff --git a/source_assets/textures/floor_tiles.png b/source_assets/textures/floor_tiles.png new file mode 100644 index 0000000..ea50e4f Binary files /dev/null and b/source_assets/textures/floor_tiles.png differ diff --git a/tests/test_basics.py b/tests/test_basics.py index 2995275..f0ac942 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -227,6 +227,27 @@ class MaterialAssignTests(unittest.TestCase): self.module.assign(actor, "red") +class NaniteTests(unittest.TestCase): + def setUp(self): + self.unreal = MagicMock() + self.module = load(self.unreal, "assets.py") + self.mesh = MagicMock() + self.settings = self.mesh.get_editor_property.return_value + self.subsystem = self.unreal.get_editor_subsystem.return_value + + def test_enabled_nanite_is_disabled_and_saved(self): + self.settings.enabled = True + self.module._disable_nanite(self.mesh) + self.assertFalse(self.settings.enabled) + self.subsystem.set_nanite_settings.assert_called_once_with(self.mesh, self.settings, True) + self.unreal.EditorAssetLibrary.save_loaded_asset.assert_called_once_with(self.mesh) + + def test_disabled_mesh_is_not_rebuilt(self): + self.settings.enabled = False + self.module._disable_nanite(self.mesh) + self.subsystem.set_nanite_settings.assert_not_called() + + class RendererAudioTests(unittest.TestCase): def test_audio_requires_mp4(self): module = load(MagicMock(), "render.py") diff --git a/tools/create_basics_assets.py b/tools/create_basics_assets.py new file mode 100644 index 0000000..c8260fc --- /dev/null +++ b/tools/create_basics_assets.py @@ -0,0 +1,69 @@ +"""Generate the floor texture and music used by exercises/basics.py (no licensed sources).""" +import array +import binascii +import math +from pathlib import Path +import random +import struct +import wave +import zlib + +ROOT = Path(__file__).resolve().parents[1] + + +def save_png(path, size, pixels): + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", binascii.crc32(kind + data) & 0xffffffff) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(pixels, 9)) + chunk(b"IEND", b"")) + + +def floor_tiles(path, size=512, tiles=4, grout=5): + """Slate tiles that repeat seamlessly: 4x4 in one texture, each with its own shade.""" + rng = random.Random(7) + step = size // tiles + shades = [[rng.uniform(0.75, 1.15) for _ in range(tiles)] for _ in range(tiles)] + pixels = bytearray() + for y in range(size): + pixels.append(0) + for x in range(size): + if x % step < grout or y % step < grout: + pixels += bytes((24, 24, 26)) + else: + shade = shades[y // step][x // step] * rng.uniform(0.94, 1.06) + pixels += bytes(min(255, int(value * shade)) for value in (92, 100, 112)) + save_png(path, size, bytes(pixels)) + + +def music(path, seconds=8.0, rate=44100): + """Minor-key pad, bass and arpeggio: two bars at 120 BPM, fading out at the end.""" + chords = [(220.00, 261.63, 329.63), (174.61, 220.00, 261.63), + (261.63, 329.63, 392.00), (196.00, 246.94, 293.66)] + beat = 0.5 + samples = array.array("h") + for index in range(int(seconds * rate)): + time = index / rate + chord = chords[int(time / (beat * 4)) % len(chords)] + pad = sum(math.sin(2 * math.pi * frequency * time) for frequency in chord) / 3 + bass = math.sin(2 * math.pi * chord[0] / 2 * time) + step = int(time / (beat / 2)) + note = chord[step % 3] * 2 + arpeggio = math.sin(2 * math.pi * note * time) * math.exp(-6 * (time - step * beat / 2)) + kick_time = time % beat if int(time / beat) % 2 == 0 else 1.0 + kick = math.sin(2 * math.pi * 60 * kick_time) * math.exp(-18 * kick_time) + fade = min(1.0, time / 0.3, (seconds - time) / 1.0) + value = 0.28 * pad + 0.22 * bass + 0.2 * arpeggio + 0.3 * kick + samples.append(int(32767 * 0.8 * fade * max(-1.0, min(1.0, value)))) + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as target: + target.setnchannels(1) + target.setsampwidth(2) + target.setframerate(rate) + target.writeframes(samples.tobytes()) + + +if __name__ == "__main__": + floor_tiles(ROOT / "source_assets/textures/floor_tiles.png") + music(ROOT / "source_assets/audio/basics_music.wav")