From aae9a3a6a5bbb1b6f686edf5775d9cf2abc46544 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 2 Nov 2026 06:06:18 +0000 Subject: [PATCH 1/2] Add scene effects framework, rendering and authoring workflows --- Engine/Cargo.lock | 4 + Engine/apps/agent-backend/src/editor_tools.rs | 4 +- Engine/apps/browser/src/main.rs | 5 +- Engine/apps/editor/src/agent_tools.rs | 73 +- Engine/apps/editor/src/effects.rs | 2019 ++++++++++++ Engine/apps/editor/src/main.rs | 132 +- Engine/apps/runtime/src/main.rs | 1 + Engine/crates/engine-assets/Cargo.toml | 1 + Engine/crates/engine-assets/src/artifacts.rs | 5 + Engine/crates/engine-assets/src/importers.rs | 36 + Engine/crates/engine-editor/Cargo.toml | 1 + Engine/crates/engine-editor/src/document.rs | 254 +- .../engine-editor/src/document/effects.rs | 84 + .../crates/engine-play/src/effect_services.rs | 388 +++ Engine/crates/engine-play/src/lib.rs | 1 + Engine/crates/engine-play/src/protocol.rs | 12 +- .../crates/engine-play/src/runtime_server.rs | 5 +- .../crates/engine-play/src/script_runtime.rs | 318 +- Engine/crates/engine-play/src/supervisor.rs | 2 +- Engine/crates/engine-project/Cargo.toml | 1 + .../examples/scene_effects_showcase.rs | 333 ++ .../engine-scripting/src/agent_authoring.md | 51 + .../engine-scripting/src/sdk/effects-api.d.ts | 43 + .../engine-scripting/src/sdk/effects-api.lua | 31 + .../engine-scripting/src/sdk/gameplay.js | 15 + .../engine-scripting/src/sdk/gameplay.lua | 13 + .../crates/engine-scripting/src/sdk/rustic.py | 14 + .../crates/engine-scripting/src/workspace.rs | 20 +- Engine/crates/engine-world/Cargo.toml | 1 + .../engine-world/src/effects/defaults.rs | 137 + Engine/crates/engine-world/src/effects/mod.rs | 238 ++ .../crates/engine-world/src/effects/model.rs | 1771 +++++++++++ .../engine-world/src/effects/modifiers.rs | 351 +++ .../engine-world/src/effects/presets.rs | 660 ++++ .../engine-world/src/effects/simulation.rs | 2185 +++++++++++++ .../crates/engine-world/src/effects/water.rs | 195 ++ Engine/crates/engine-world/src/lib.rs | 2 + Engine/crates/engine-world/src/scene.rs | 16 + Engine/crates/engine-world/src/undo.rs | 14 + Engine/crates/engine-world/src/world.rs | 263 ++ .../engine-world/tests/effects_persistence.rs | 191 ++ .../examples/effects_showcase.rs | 18 + Engine/crates/renderer-wgpu/src/decals.rs | 186 ++ Engine/crates/renderer-wgpu/src/depth.rs | 203 ++ Engine/crates/renderer-wgpu/src/effects.rs | 531 ++++ Engine/crates/renderer-wgpu/src/lib.rs | 691 ++++- Engine/crates/renderer-wgpu/src/post.rs | 439 +++ .../crates/renderer-wgpu/src/reflections.rs | 198 ++ Engine/crates/renderer-wgpu/src/sprites.rs | 165 + Engine/crates/renderer-wgpu/src/surface.rs | 2 +- Engine/docs/SCENE_EFFECTS.md | 179 ++ Engine/docs/SCENE_EFFECTS_REFERENCE.md | 517 ++++ Engine/docs/SCENE_EFFECTS_RESEARCH.md | 26 + Engine/docs/Scripting/API/effects.md | 5 + Engine/docs/Scripting/API/scene-effects.md | 86 + Engine/examples/scene-effects/README.md | 15 + .../examples/scene-effects/project/.gitignore | 14 + .../Scene Effects Showcase.code-workspace | 8 + .../scene-effects/project/assets/debris.obj | 8 + .../project/assets/debris.obj.rmeta | 11 + .../assets/effects/bullet-impact.effect | 111 + .../assets/effects/bullet-impact.effect.rmeta | 11 + .../project/assets/effects/campfire.effect | 103 + .../assets/effects/campfire.effect.rmeta | 11 + .../assets/effects/character-highlight.effect | 34 + .../effects/character-highlight.effect.rmeta | 11 + .../assets/effects/collision-sparks.effect | 111 + .../effects/collision-sparks.effect.rmeta | 11 + .../assets/effects/damage-flash.effect | 36 + .../assets/effects/damage-flash.effect.rmeta | 11 + .../project/assets/effects/dust.effect | 103 + .../project/assets/effects/dust.effect.rmeta | 11 + .../project/assets/effects/embers.effect | 103 + .../assets/effects/embers.effect.rmeta | 11 + .../assets/effects/energy-shield.effect | 125 + .../assets/effects/energy-shield.effect.rmeta | 11 + .../assets/effects/exhaust-smoke.effect | 103 + .../assets/effects/exhaust-smoke.effect.rmeta | 11 + .../project/assets/effects/explosion.effect | 325 ++ .../assets/effects/explosion.effect.rmeta | 11 + .../project/assets/effects/fire-sparks.effect | 103 + .../assets/effects/fire-sparks.effect.rmeta | 11 + .../project/assets/effects/fireflies.effect | 103 + .../assets/effects/fireflies.effect.rmeta | 11 + .../assets/effects/firework-burst.effect | 111 + .../effects/firework-burst.effect.rmeta | 11 + .../assets/effects/footstep-dust.effect | 111 + .../assets/effects/footstep-dust.effect.rmeta | 11 + .../assets/effects/glowing-trail.effect | 76 + .../assets/effects/glowing-trail.effect.rmeta | 11 + .../assets/effects/ground-ripple.effect | 83 + .../assets/effects/ground-ripple.effect.rmeta | 11 + .../assets/effects/healing-aura.effect | 103 + .../assets/effects/healing-aura.effect.rmeta | 11 + .../project/assets/effects/leaves.effect | 108 + .../assets/effects/leaves.effect.rmeta | 11 + .../assets/effects/lightning-beam.effect | 79 + .../effects/lightning-beam.effect.rmeta | 11 + .../assets/effects/magic-projectile.effect | 103 + .../effects/magic-projectile.effect.rmeta | 11 + .../project/assets/effects/mist.effect | 39 + .../project/assets/effects/mist.effect.rmeta | 11 + .../assets/effects/muzzle-flash.effect | 111 + .../assets/effects/muzzle-flash.effect.rmeta | 11 + .../assets/effects/object-dissolve.effect | 47 + .../effects/object-dissolve.effect.rmeta | 11 + .../project/assets/effects/portal.effect | 125 + .../assets/effects/portal.effect.rmeta | 11 + .../project/assets/effects/rain.effect | 108 + .../project/assets/effects/rain.effect.rmeta | 11 + .../assets/effects/rocket-exhaust.effect | 103 + .../effects/rocket-exhaust.effect.rmeta | 11 + .../project/assets/effects/shockwave.effect | 83 + .../assets/effects/shockwave.effect.rmeta | 11 + .../project/assets/effects/skid-mark.effect | 76 + .../assets/effects/skid-mark.effect.rmeta | 11 + .../project/assets/effects/smoke-plume.effect | 103 + .../assets/effects/smoke-plume.effect.rmeta | 11 + .../project/assets/effects/snow.effect | 108 + .../project/assets/effects/snow.effect.rmeta | 11 + .../assets/effects/speed-boost-trail.effect | 76 + .../effects/speed-boost-trail.effect.rmeta | 11 + .../project/assets/effects/sword-trail.effect | 76 + .../assets/effects/sword-trail.effect.rmeta | 11 + .../project/assets/effects/teleport.effect | 111 + .../assets/effects/teleport.effect.rmeta | 11 + .../project/assets/effects/tire-smoke.effect | 103 + .../assets/effects/tire-smoke.effect.rmeta | 11 + .../assets/effects/water-splash.effect | 111 + .../assets/effects/water-splash.effect.rmeta | 11 + .../scene-effects/project/assets/foliage.obj | 12 + .../project/assets/foliage.obj.rmeta | 11 + .../project/assets/showcase-probe.hdr | 5 + .../project/assets/showcase-probe.hdr.rmeta | 11 + .../scene-effects/project/builds/.gitkeep | 0 .../scene-effects/project/cache/.gitkeep | 0 .../project/config/project-template.ron | 5 + .../scene-effects/project/config/scripts.ron | 15 + .../scene-effects/project/logs/.gitkeep | 0 .../scene-effects/project/materials/.gitkeep | 0 .../scene-effects/project/plugins/.gitkeep | 0 .../scene-effects/project/project.engine | 27 + .../project/scene/showcase.scene | 2695 +++++++++++++++++ .../scene-effects/project/scripts/orbit.lua | 8 + .../scene-effects/project/settings.json | 4 + .../scene-effects/project/shaders/.gitkeep | 0 .../scene-effects/project/temp/.gitkeep | 0 .../scene-effects/project/ui/.gitkeep | 0 148 files changed, 19849 insertions(+), 35 deletions(-) create mode 100644 Engine/apps/editor/src/effects.rs create mode 100644 Engine/crates/engine-editor/src/document/effects.rs create mode 100644 Engine/crates/engine-play/src/effect_services.rs create mode 100644 Engine/crates/engine-project/examples/scene_effects_showcase.rs create mode 100644 Engine/crates/engine-scripting/src/sdk/effects-api.d.ts create mode 100644 Engine/crates/engine-scripting/src/sdk/effects-api.lua create mode 100644 Engine/crates/engine-world/src/effects/defaults.rs create mode 100644 Engine/crates/engine-world/src/effects/mod.rs create mode 100644 Engine/crates/engine-world/src/effects/model.rs create mode 100644 Engine/crates/engine-world/src/effects/modifiers.rs create mode 100644 Engine/crates/engine-world/src/effects/presets.rs create mode 100644 Engine/crates/engine-world/src/effects/simulation.rs create mode 100644 Engine/crates/engine-world/src/effects/water.rs create mode 100644 Engine/crates/engine-world/tests/effects_persistence.rs create mode 100644 Engine/crates/renderer-wgpu/examples/effects_showcase.rs create mode 100644 Engine/crates/renderer-wgpu/src/decals.rs create mode 100644 Engine/crates/renderer-wgpu/src/depth.rs create mode 100644 Engine/crates/renderer-wgpu/src/effects.rs create mode 100644 Engine/crates/renderer-wgpu/src/post.rs create mode 100644 Engine/crates/renderer-wgpu/src/reflections.rs create mode 100644 Engine/crates/renderer-wgpu/src/sprites.rs create mode 100644 Engine/docs/SCENE_EFFECTS.md create mode 100644 Engine/docs/SCENE_EFFECTS_REFERENCE.md create mode 100644 Engine/docs/SCENE_EFFECTS_RESEARCH.md create mode 100644 Engine/docs/Scripting/API/scene-effects.md create mode 100644 Engine/examples/scene-effects/README.md create mode 100644 Engine/examples/scene-effects/project/.gitignore create mode 100644 Engine/examples/scene-effects/project/Scene Effects Showcase.code-workspace create mode 100644 Engine/examples/scene-effects/project/assets/debris.obj create mode 100644 Engine/examples/scene-effects/project/assets/debris.obj.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/campfire.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/character-highlight.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/damage-flash.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/dust.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/embers.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/energy-shield.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/explosion.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/fireflies.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/firework-burst.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/healing-aura.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/leaves.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/mist.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/portal.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/rain.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/shockwave.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/skid-mark.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/snow.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/sword-trail.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/teleport.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/effects/water-splash.effect create mode 100644 Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/foliage.obj create mode 100644 Engine/examples/scene-effects/project/assets/foliage.obj.rmeta create mode 100644 Engine/examples/scene-effects/project/assets/showcase-probe.hdr create mode 100644 Engine/examples/scene-effects/project/assets/showcase-probe.hdr.rmeta create mode 100644 Engine/examples/scene-effects/project/builds/.gitkeep create mode 100644 Engine/examples/scene-effects/project/cache/.gitkeep create mode 100644 Engine/examples/scene-effects/project/config/project-template.ron create mode 100644 Engine/examples/scene-effects/project/config/scripts.ron create mode 100644 Engine/examples/scene-effects/project/logs/.gitkeep create mode 100644 Engine/examples/scene-effects/project/materials/.gitkeep create mode 100644 Engine/examples/scene-effects/project/plugins/.gitkeep create mode 100644 Engine/examples/scene-effects/project/project.engine create mode 100644 Engine/examples/scene-effects/project/scene/showcase.scene create mode 100644 Engine/examples/scene-effects/project/scripts/orbit.lua create mode 100644 Engine/examples/scene-effects/project/settings.json create mode 100644 Engine/examples/scene-effects/project/shaders/.gitkeep create mode 100644 Engine/examples/scene-effects/project/temp/.gitkeep create mode 100644 Engine/examples/scene-effects/project/ui/.gitkeep diff --git a/Engine/Cargo.lock b/Engine/Cargo.lock index da7b62aa..36c660a1 100644 --- a/Engine/Cargo.lock +++ b/Engine/Cargo.lock @@ -1263,6 +1263,7 @@ version = "0.1.0" dependencies = [ "base64", "engine-core", + "engine-world", "gltf", "hound", "image", @@ -1312,6 +1313,7 @@ dependencies = [ name = "engine-editor" version = "0.1.0" dependencies = [ + "engine-assets", "engine-core", "engine-platform", "engine-play", @@ -1368,6 +1370,7 @@ dependencies = [ name = "engine-project" version = "0.1.0" dependencies = [ + "engine-assets", "engine-core", "engine-platform", "engine-scripting", @@ -1416,6 +1419,7 @@ dependencies = [ "glam 0.33.6", "ron", "serde", + "serde_json", "sha2", "tempfile", "thiserror 2.0.20", diff --git a/Engine/apps/agent-backend/src/editor_tools.rs b/Engine/apps/agent-backend/src/editor_tools.rs index f1adb4c3..cdabaa4d 100644 --- a/Engine/apps/agent-backend/src/editor_tools.rs +++ b/Engine/apps/agent-backend/src/editor_tools.rs @@ -38,12 +38,12 @@ pub fn tools() -> Vec { ( "object_update", "Atomically update scene object properties through editor undo. Use editor_status for IDs and values. Rotation is quaternion xyzw; vectors are xyz. Components use the returned JSON structure.", - json!({"type": "object", "required": ["entity_id", "properties"], "properties": {"entity_id": {"type": "string"}, "properties": {"type": "object", "additionalProperties": false, "properties": {"name": {"type": "string"}, "parent": {"type": ["string", "null"]}, "part_attributes": {"type": "object"}, "camera": {"type": ["object", "null"]}, "light": {"type": ["object", "null"]}, "scripts": {"type": "array", "items": {"type": "object"}}, "translation": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, "rotation": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "scale": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}}}}}), + json!({"type": "object", "required": ["entity_id", "properties"], "properties": {"entity_id": {"type": "string"}, "properties": {"type": "object", "additionalProperties": false, "properties": {"name": {"type": "string"}, "parent": {"type": ["string", "null"]}, "part_attributes": {"type": "object"}, "camera": {"type": ["object", "null"]}, "light": {"type": ["object", "null"]}, "effect": {"type": ["object", "null"], "description":"Serialized EffectComponent: common plus kind with exactly one named variant, e.g. {kind:{Particle:{rate:30}}}; null removes. Read existing structure from editor_status."}, "scripts": {"type": "array", "items": {"type": "object"}}, "translation": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, "rotation": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "scale": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}}}}}), ), ( "object_create", "Create an authored folder, empty object, camera or light, optionally inside parent (null means scene root). Folders organize the scene explorer. For finished meshes use model_create; primitives are for deliberate blockouts/colliders.", - json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["empty","folder","camera","light","blockout"]},"parent":{"type":["string","null"],"description":"Existing destination object/folder UUID, or null for scene root; defaults to root."}}}), + json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["empty","folder","camera","light","blockout"]},"parent":{"type":["string","null"],"description":"Existing destination object/folder UUID, or null for scene root; defaults to root."},"effect":{"type":["object","null"],"description":"Optional typed EffectComponent: {kind:{Particle:{rate:30}}}; missing fields use validated defaults."}}}), ), ( "object_duplicate", diff --git a/Engine/apps/browser/src/main.rs b/Engine/apps/browser/src/main.rs index ced8a277..3022fd94 100644 --- a/Engine/apps/browser/src/main.rs +++ b/Engine/apps/browser/src/main.rs @@ -71,9 +71,10 @@ pub extern "C" fn rustic_tick(width: u32, height: u32, seconds: f64) { game.session .step(seconds, input.keys, input.pointers, input.reset)?; let [width, height] = engine_play::game_view_extent([width, height]); - let scene = game.session.render_world(|world| { - renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, &game.models) + let (scene, effects) = game.session.render_world(|world| { + (renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, &game.models), world.effect_frame().clone()) })?; + game.renderer.set_effect_frame(&effects).map_err(|error| error.to_string())?; #[cfg(target_os = "emscripten")] { let audio: Vec = game diff --git a/Engine/apps/editor/src/agent_tools.rs b/Engine/apps/editor/src/agent_tools.rs index 581c9461..5ec103c0 100644 --- a/Engine/apps/editor/src/agent_tools.rs +++ b/Engine/apps/editor/src/agent_tools.rs @@ -101,7 +101,7 @@ impl EditorApp { let objects = self.document.entity_snapshots().map_err(|e| e.to_string())?.iter().map(|s| json!({ "entity_id":s.id,"name":s.name,"folder":s.folder,"parent":s.parent,"translation":s.local_transform.translation.to_array(), "rotation":s.local_transform.rotation.to_array(),"scale":s.local_transform.scale.to_array(), - "part_attributes":s.part_attributes,"camera":s.camera,"light":s.light,"scripts":s.scripts, + "part_attributes":s.part_attributes,"camera":s.camera,"light":s.light,"effect":s.effect,"scripts":s.scripts, "mesh_asset":s.mesh.map(|m| m.asset),"material_asset":s.material.map(|m| m.asset),"primitive":s.primitive, "textures":s.textures,"resolved_textures":self.document.world().resolved_textures(s.id) })).collect::>(); @@ -234,6 +234,17 @@ impl EditorApp { before: before.light, after: decode(value)?, }), + "effect" => { + let after: Option = decode(value)?; + if let Some(effect) = &after { + effect.validate()?; + } + edits.push(engine_world::SceneEdit::Effect { + entity: id, + before: before.effect.clone(), + after, + }); + } "scripts" => edits.push(engine_world::SceneEdit::Scripts { entity: id, before: before.scripts.clone(), @@ -278,6 +289,12 @@ impl EditorApp { } _ => return Err("kind must be empty, folder, camera, light or blockout".into()), } + if let Some(value) = args.get("effect") { + object.effect = decode(value)?; + if let Some(effect) = &object.effect { + effect.validate()?; + } + } let id = object.id; self.document .edit_transaction( @@ -805,6 +822,60 @@ mod tests { mod authoring_flow_tests { use super::*; use engine_project::Project; + #[test] + fn effect_authoring_tools_validate_and_undo_component_edits() { + let temp = tempfile::tempdir().unwrap(); + let project = Project::create( + temp.path().join("effects"), + "Effects", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let context = egui::Context::default(); + let mut app = EditorApp::new(project, &context).unwrap(); + let object = app + .agent_tool( + "object_create", + &json!({"name":"Sparks","effect":{"kind":{"Particle":{"rate":60}}}}), + &context, + ) + .unwrap(); + let id: EntityId = decode(&object["entity_id"]).unwrap(); + let original = app.document.world().effect(id).unwrap(); + assert!(original.is_some()); + assert!( + app.agent_tool( + "object_update", + &json!({"entity_id":id,"properties":{"effect":{"common":{"duration":0}}}}), + &context + ) + .is_err() + ); + assert_eq!(app.document.world().effect(id).unwrap(), original); + app.agent_tool( + "object_update", + &json!({"entity_id":id,"properties":{"effect":null}}), + &context, + ) + .unwrap(); + assert!(app.document.world().effect(id).unwrap().is_none()); + app.agent_tool("editor_undo", &json!({}), &context).unwrap(); + assert_eq!(app.document.world().effect(id).unwrap(), original); + app.agent_tool("scene_save", &json!({}), &context).unwrap(); + let loaded = + engine_world::load_scene(&std::fs::read(app.document.scene_path()).unwrap()).unwrap(); + assert_eq!( + loaded + .document + .entities + .iter() + .find(|e| e.id == id) + .unwrap() + .effect, + original + ); + } + #[test] #[allow( clippy::too_many_lines, diff --git a/Engine/apps/editor/src/effects.rs b/Engine/apps/editor/src/effects.rs new file mode 100644 index 00000000..3945a873 --- /dev/null +++ b/Engine/apps/editor/src/effects.rs @@ -0,0 +1,2019 @@ +//! Native property editing for the serialized, typed effects schema. +//! +//! Edits are decoded back into the typed component and validated by the world +//! before they enter the document's undo history. + +use eframe::egui; +use num_traits::ToPrimitive as _; +use serde_json::Value; +use std::path::Path; + +fn label(name: &str) -> String { + let mut text = name.replace('_', " "); + if let Some(first) = text.get_mut(..1) { + first.make_ascii_uppercase(); + } + text +} + +/// Render the fields in a typed effect's serde representation with native egui +/// widgets. The representation is transient; the scene stores the typed value. +pub(super) fn properties( + ui: &mut egui::Ui, + value: &mut Value, + root: &Path, + references: &[(String, String)], +) -> bool { + edit_value(ui, "", value, root, references) +} + +#[allow( + clippy::too_many_lines, + reason = "native widget dispatch for recursive typed property data" +)] +fn edit_value( + ui: &mut egui::Ui, + name: &str, + value: &mut Value, + root: &Path, + references: &[(String, String)], +) -> bool { + ui.push_id(name, |ui| { + if let Value::Object(fields) = value + && fields.contains_key("rate") + && fields.contains_key("max_particles") + { + return egui::CollapsingHeader::new(label(name)) + .default_open(true) + .show(ui, |ui| edit_particle_modules(ui, fields, root, references)) + .body_returned + .unwrap_or(false); + } + if name == "parameters" + && let Value::Object(parameters) = value + { + return edit_parameters(ui, parameters, root, references); + } + if name == "bounds" && value.is_object() && ui.button("Clear simulation bounds").clicked() { + *value = Value::Null; + return true; + } + if name == "bounds" && value.is_null() { + if ui.button("Add simulation bounds").clicked() { + *value = serde_json::json!({"center":[0.0,0.0,0.0],"half_extents":[1.0,1.0,1.0]}); + return true; + } + return false; + } + if is_reference(name) { + let before = value.clone(); + ui.horizontal(|ui| { + ui.label(label(name)).on_hover_text(field_hint(name)); + egui::ComboBox::from_id_salt("entity-reference") + .selected_text( + references + .iter() + .find(|(id, _)| value.as_str() == Some(id)) + .map_or("None", |(_, name)| name.as_str()), + ) + .show_ui(ui, |ui| { + ui.selectable_value(value, Value::Null, "None"); + for (id, name) in references { + ui.selectable_value(value, Value::String(id.clone()), name); + } + }); + }); + return before != *value; + } + if is_asset(name) && (value.is_null() || value.is_string()) { + let before = value.clone(); + ui.horizontal(|ui| { + ui.label(label(name)).on_hover_text(field_hint(name)); + ui.label(value.as_str().unwrap_or("None")); + if ui.button("Choose…").clicked() + && let Some(path) = rfd::FileDialog::new().set_directory(root).pick_file() + && let (Ok(root), Ok(path)) = (root.canonicalize(), path.canonicalize()) + && path.starts_with(root) + && let Ok(asset_id) = ensure_asset_meta(&path) + && let Ok(id) = serde_json::to_value(asset_id) + { + *value = id; + } + if ui.button("Clear").clicked() { + *value = Value::Null; + } + }); + return before != *value; + } + match value { + Value::Bool(value) => ui.checkbox(value, label(name)).changed(), + Value::Number(value) => { + ui.horizontal(|ui| { + ui.label(label(name)).on_hover_text(field_hint(name)); + if let Some(mut number) = value.as_u64() { + let changed = ui + .add(egui::DragValue::new(&mut number).speed(1.0)) + .changed(); + if changed { + *value = number.into(); + } + changed + } else if let Some(mut number) = value.as_i64() { + let changed = ui + .add(egui::DragValue::new(&mut number).speed(1.0)) + .changed(); + if changed { + *value = number.into(); + } + changed + } else { + let mut number = value.as_f64().unwrap_or_default(); + let changed = ui + .add(egui::DragValue::new(&mut number).speed(0.01)) + .changed(); + if changed && let Some(number) = serde_json::Number::from_f64(number) { + *value = number; + } + changed + } + }) + .inner + } + Value::String(value) => { + ui.horizontal(|ui| { + ui.label(label(name)).on_hover_text(field_hint(name)); + let choices = enum_choices(name); + if !choices.is_empty() { + let before = value.clone(); + egui::ComboBox::from_id_salt("value") + .selected_text(label(value)) + .show_ui(ui, |ui| { + for choice in choices { + ui.selectable_value(value, (*choice).to_owned(), label(choice)); + } + }); + before != *value + } else { + let mut changed = ui.text_edit_singleline(value).changed(); + if is_asset(name) + && ui.button("Choose…").clicked() + && let Some(path) = + rfd::FileDialog::new().set_directory(root).pick_file() + && let (Ok(root), Ok(path)) = (root.canonicalize(), path.canonicalize()) + && let Ok(relative) = path.strip_prefix(root) + { + *value = relative.to_string_lossy().replace('\\', "/"); + changed = true; + } + changed + } + }) + .inner + } + Value::Array(values) + if values.len() >= 2 + && values.len() <= 4 + && values.iter().all(Value::is_number) => + { + let mut numbers: Vec = values + .iter() + .map(|v| v.as_f64().unwrap_or_default().to_f32().unwrap_or_default()) + .collect(); + let changed = ui + .horizontal(|ui| { + ui.label(label(name)).on_hover_text(field_hint(name)); + if is_color(name) && numbers.len() == 4 { + let mut color = [numbers[0], numbers[1], numbers[2], numbers[3]]; + let changed = + ui.color_edit_button_rgba_unmultiplied(&mut color).changed(); + numbers.copy_from_slice(&color); + changed + } else if is_color(name) && numbers.len() == 3 { + let mut color = [numbers[0], numbers[1], numbers[2]]; + let changed = ui.color_edit_button_rgb(&mut color).changed(); + numbers.copy_from_slice(&color); + changed + } else { + numbers.iter_mut().fold(false, |changed, number| { + ui.add(egui::DragValue::new(number).speed(0.01)).changed() | changed + }) + } + }) + .inner; + if changed { + for (value, number) in values.iter_mut().zip(numbers) { + *value = if value.as_u64().is_some() { + Value::from(number.max(0.0).to_u64().unwrap_or_default()) + } else { + Value::from(number) + }; + } + } + changed + } + Value::Array(values) => ui + .collapsing(format!("{} ({})", label(name), values.len()), |ui| { + let mut changed = false; + let mut remove = None; + let mut duplicate = None; + let mut reorder = None; + let values_len = values.len(); + for (index, value) in values.iter_mut().enumerate() { + ui.push_id(index, |ui| { + ui.horizontal(|ui| { + ui.label(format!( + "{} {}", + if name == "keys" { "Key" } else { "Item" }, + index + 1 + )); + if ui.small_button("Duplicate").clicked() { + duplicate = Some(index); + } + if index > 0 && ui.small_button("↑").clicked() { + reorder = Some(index); + } + if ui + .add_enabled( + name != "keys" || values_len > 1, + egui::Button::new("Remove").small(), + ) + .clicked() + { + remove = Some(index); + } + }); + changed |= edit_value( + ui, + if name == "targets" { + "target" + } else if name == "meshes" { + "mesh" + } else { + "" + }, + value, + root, + references, + ); + }); + } + if let Some(index) = remove { + values.remove(index); + changed = true; + } else if let Some(index) = duplicate { + values.insert(index + 1, values[index].clone()); + changed = true; + } else if let Some(index) = reorder { + values.swap(index - 1, index); + changed = true; + } + if name == "meshes" + && ui.button("Add mesh asset…").clicked() + && let Some(path) = rfd::FileDialog::new().set_directory(root).pick_file() + && let (Ok(root), Ok(path)) = (root.canonicalize(), path.canonicalize()) + && path.starts_with(root) + && let Ok(asset_id) = ensure_asset_meta(&path) + && let Ok(value) = serde_json::to_value(asset_id) + { + values.push(value); + changed = true; + } + if name != "meshes" && ui.button("Add key / item").clicked() { + if let Some(value) = values + .last() + .cloned() + .or_else(|| default_item(name, references)) + { + values.push(value); + changed = true; + } + } + changed + }) + .body_returned + .unwrap_or(false), + Value::Object(values) => { + let edit = |ui: &mut egui::Ui, values: &mut serde_json::Map| { + let graph_changed = values + .get_mut("keys") + .and_then(Value::as_array_mut) + .is_some_and(|keys| edit_key_graph(ui, keys)); + let particle_shape = values.contains_key("rate"); + values + .iter_mut() + .fold(graph_changed, |changed, (name, value)| { + if name == "shape" { + let before = value.clone(); + ui.horizontal(|ui| { + ui.label("Shape"); + egui::ComboBox::from_id_salt("shape") + .selected_text(value.as_str().unwrap_or_default()) + .show_ui(ui, |ui| { + let choices: &[&str] = if particle_shape { + &[ + "Point", + "Sphere", + "Hemisphere", + "Box", + "Cone", + "Circle", + "Cylinder", + "MeshSurface", + ] + } else { + &["Box", "Sphere", "Plane", "Cylinder"] + }; + for choice in choices { + ui.selectable_value( + value, + Value::String((*choice).into()), + *choice, + ); + } + }); + }); + (before != *value) | changed + } else if name == "source" { + changed + } else { + edit_value(ui, name, value, root, references) | changed + } + }) + }; + if name.is_empty() { + edit(ui, values) + } else { + egui::CollapsingHeader::new(label(name)) + .default_open( + name == "common" + || name == "kind" + || name.chars().next().is_some_and(char::is_uppercase), + ) + .show(ui, |ui| edit(ui, values)) + .body_returned + .unwrap_or(false) + } + } + Value::Null => { + ui.weak(format!("{}: none", label(name))); + false + } + } + }) + .inner +} + +fn is_color(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.contains("color") || name.contains("colour") || name.contains("tint") +} + +fn is_asset(name: &str) -> bool { + name.contains("texture") + || name.contains("material") + || name.contains("asset") + || name.contains("cubemap") + || name.ends_with("_map") + || name == "mesh" + || name == "sub_emitter" + || name == "impact_effect" +} + +fn enum_choices(name: &str) -> &'static [&'static str] { + match name { + "shape" => &[ + "Point", + "Sphere", + "Hemisphere", + "Box", + "Cone", + "Circle", + "Cylinder", + "MeshSurface", + ], + "quality" => &["Low", "Medium", "High"], + "blend" => &["Alpha", "Additive", "Multiply"], + "weather" => &["Rain", "Snow", "Hail", "Dust", "Sand", "Leaves"], + "force_type" => &[ + "Directional", + "Attraction", + "Repulsion", + "Vortex", + "Turbulence", + ], + "collision_response" | "response" => &["Bounce", "Stick", "Kill"], + "refresh" => &["Baked", "OnEnable", "Interval"], + "simulation_space" => &["Local", "World"], + "blend_mode" => &["Alpha", "Additive", "Multiply"], + _ => &[], + } +} + +fn is_reference(name: &str) -> bool { + name == "target" || name == "light" || name.ends_with("_target") +} + +impl crate::EditorViewer<'_> { + #[allow( + clippy::too_many_lines, + reason = "keep component editing and validated commit together" + )] + pub(super) fn effect_inspector( + &mut self, + ui: &mut egui::Ui, + snapshot: &engine_world::EntitySnapshot, + ) { + use engine_world::effects::{EffectComponent, EffectKind}; + ui.separator(); + ui.heading("Scene effects"); + let writable = !self.document.is_read_only() && !self.playing; + ui.add_enabled_ui(writable, |ui| self.composite_timeline(ui, snapshot)); + let mut next = snapshot.effect.clone(); + ui.add_enabled_ui(writable, |ui| { + ui.menu_button("+ Add / replace effect", |ui| { + for kind in EffectKind::all_defaults() { + if ui.button(kind.name()).clicked() { + next = Some(EffectComponent::new(kind)); + ui.close(); + } + } + }); + if ui.button("Assign single-effect asset…").clicked() + && let Some(path) = rfd::FileDialog::new() + .set_directory(self.project_root) + .add_filter("Effect asset", &["effect"]) + .pick_file() + { + match load_effect_asset(self.project_root, &path) { + Ok(asset) if asset.effects.len() == 1 => { + next = asset.effects.into_iter().next() + } + Ok(_) => self.effect_error( + "Composite assets must be added through the preset / asset browser".into(), + ), + Err(error) => self.effect_error(error), + } + } + if let Some(effect) = &mut next { + ui.strong(effect.kind.name()); + ui.small("Position, rotation and parenting use this entity's Transform."); + let references: Vec<_> = self + .document + .world() + .entity_ids() + .filter_map(|id| { + self.document.world().snapshot(id).ok().map(|entity| { + ( + id.to_string(), + entity.name.unwrap_or_else(|| id.to_string()), + ) + }) + }) + .collect(); + if let Some(source) = &effect.source { + let source_id = source.asset; + if let Some(path) = find_asset_path(self.project_root, source_id) { + ui.label(format!( + "Shared asset: {}", + path.file_name().unwrap_or_default().to_string_lossy() + )); + if ui.button("Restore asset defaults").clicked() { + let result = std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|bytes| { + engine_world::effects::EffectAsset::from_bytes(&bytes) + }) + .and_then(|asset| { + restore_instance_defaults( + &asset, + effect, + snapshot.id, + self.document, + ) + }); + if let Err(error) = result { + self.effect_error(error); + } + } + if ui + .button("Update shared asset from this instance") + .clicked() + { + let result = + update_shared_asset(&path, effect, snapshot.id, self.document); + if let Err(error) = result { + self.effect_error(error); + } else { + self.request_preview = true; + } + } + } else { + ui.weak("Shared effect asset is missing; the saved override still works."); + } + } + if let Ok(mut value) = serde_json::to_value(&*effect) + && properties(ui, &mut value, self.project_root, &references) + { + match serde_json::from_value::(value) { + Ok(edited) => *effect = edited, + Err(error) => self.console.push(crate::simple_console( + engine_core::logging::Severity::Error, + "effects.inspector", + error.to_string(), + )), + } + } + } + if next.is_some() && ui.button("Remove effect").clicked() { + next = None; + } + }); + if next != snapshot.effect { + let load_meshes = next + .as_ref() + .is_some_and(|effect| missing_effect_meshes(effect, self.model_assets)); + if load_meshes { + self.model_assets + .extend(crate::load_project_model_assets(self.project_root)); + } + if let Some(effect) = &mut next { + prepare_mesh_emission(effect, self.model_assets); + } + match self.document.set_effect(snapshot.id, next) { + Ok(()) => self.request_preview = true, + Err(error) => self.console.push(crate::simple_console( + engine_core::logging::Severity::Error, + "effects.inspector", + error.to_string(), + )), + } + } + } +} + +fn default_item(name: &str, references: &[(String, String)]) -> Option { + match name { + "bursts" => serde_json::to_value(engine_world::effects::Burst::default()).ok(), + "points" => Some(serde_json::json!([0.0, 0.0, 0.0])), + "keys" => Some(serde_json::json!({"time": 0.0, "value": 1.0})), + "targets" => references.first().map(|(id, _)| Value::String(id.clone())), + _ => None, + } +} + +#[derive(Clone)] +pub(super) struct PresetDrop(pub engine_world::effects::EffectAsset); + +/// A separate scene uses exactly the runtime sub-emitter, collision and asset +/// expansion path without mutating the user's authored document. +#[derive(Default)] +pub(super) struct PreviewRuntime { + world: engine_world::SceneWorld, + authored: Vec, + initialized: bool, +} +impl PreviewRuntime { + pub fn frame(&self) -> &engine_world::effects::EffectFrame { + self.world.effect_frame() + } + pub fn particle_count(&self) -> usize { + self.frame().particles.len() + } + fn clear(&mut self) { + *self = Self::default(); + } + fn synchronize(&mut self, document: &engine_editor::AuthoringDocument) -> bool { + let Ok(current) = document.entity_snapshots() else { + return false; + }; + if self.initialized && current == self.authored { + return false; + } + let compatible = self.initialized + && current.len() == self.authored.len() + && current.iter().zip(&self.authored).all(|(after, before)| { + if before + .effect + .as_ref() + .and_then(|effect| effect.source.as_ref()) + != after + .effect + .as_ref() + .and_then(|effect| effect.source.as_ref()) + { + return false; + } + let mut before = before.clone(); + before.local_transform = after.local_transform; + before.parent = after.parent; + before.effect.clone_from(&after.effect); + before.name.clone_from(&after.name); + before == *after + }); + if compatible { + let mut edits = Vec::new(); + for (after, before) in current.iter().zip(&self.authored) { + if after.local_transform != before.local_transform { + edits.push(engine_world::WorldCommand::SetLocalTransform { + entity: after.id, + value: after.local_transform, + }); + } + if after.parent != before.parent { + edits.push(engine_world::WorldCommand::SetParent { + child: after.id, + parent: after.parent, + }); + } + if after.effect != before.effect { + edits.push(engine_world::WorldCommand::SetEffect { + entity: after.id, + value: after.effect.clone().map(Box::new), + }); + } + } + if self.world.apply_commands(&edits).is_ok() { + self.world.propagate_transforms(); + self.authored = current; + return false; + } + } + if let Ok(world) = document.effect_preview_world() { + self.world = world; + self.authored = current; + self.initialized = true; + return true; + } + false + } +} + +pub(super) struct EffectPreview { + pub runtime: PreviewRuntime, + pub playing: bool, + pub speed: f32, + pub time: f32, + pub last_tick: std::time::Instant, + pub show_bounds: bool, + pub search: String, + pub category: String, + scene_path: std::path::PathBuf, + pub simulation_millis: f32, +} + +impl Default for EffectPreview { + fn default() -> Self { + Self { + runtime: Default::default(), + playing: false, + speed: 1.0, + time: 0.0, + last_tick: std::time::Instant::now(), + show_bounds: true, + search: String::new(), + category: "All".into(), + scene_path: Default::default(), + simulation_millis: 0.0, + } + } +} + +impl EffectPreview { + pub fn update(&mut self, document: &engine_editor::AuthoringDocument, camera: glam::Vec3) { + if self.scene_path != document.scene_path() { + self.runtime.clear(); + self.time = 0.0; + self.scene_path = document.scene_path().to_path_buf(); + } + let delta = if self.playing { + self.last_tick.elapsed().as_secs_f32().min(0.1) * self.speed + } else { + 0.0 + }; + self.last_tick = std::time::Instant::now(); + self.advance(document, camera, delta); + } + fn advance( + &mut self, + document: &engine_editor::AuthoringDocument, + camera: glam::Vec3, + delta: f32, + ) { + let start = std::time::Instant::now(); + if self.runtime.synchronize(document) { + self.time = 0.0; + } + let mut remaining = delta; + while remaining > 0.0 { + let step = remaining.min(1.0 / 60.0); + self.runtime + .world + .update_effects_from_camera(step, camera, None); + remaining -= step; + } + self.runtime + .world + .update_effects_from_camera(0.0, camera, None); + self.time += delta; + self.simulation_millis = start.elapsed().as_secs_f32() * 1_000.0; + } + fn control( + &mut self, + document: &engine_editor::AuthoringDocument, + command: engine_world::effects::PlaybackCommand, + ) { + if matches!( + command, + engine_world::effects::PlaybackCommand::Restart + | engine_world::effects::PlaybackCommand::Reset + | engine_world::effects::PlaybackCommand::Stop + ) { + self.runtime.clear(); + } + if self.runtime.synchronize(document) { + self.time = 0.0; + } + let roots: Vec<_> = self + .runtime + .world + .entity_ids() + .filter(|id| self.runtime.world.parent(*id).ok().flatten().is_none()) + .collect(); + for entity in roots { + let command = if command == engine_world::effects::PlaybackCommand::Resume + && self.runtime.world.effect_state(entity).ok().flatten() + == Some(engine_world::effects::PlaybackState::Stopped) + { + engine_world::effects::PlaybackCommand::Play + } else { + command + }; + let _ = self.runtime.world.control_effect(entity, command); + } + self.last_tick = std::time::Instant::now(); + } + fn scrub( + &mut self, + document: &engine_editor::AuthoringDocument, + camera: glam::Vec3, + seconds: f32, + ) { + self.runtime.clear(); + self.time = 0.0; + self.control(document, engine_world::effects::PlaybackCommand::Play); + self.advance(document, camera, seconds); + self.control(document, engine_world::effects::PlaybackCommand::Pause); + self.playing = false; + } + pub fn controls( + &mut self, + ui: &mut egui::Ui, + document: &engine_editor::AuthoringDocument, + camera: glam::Vec3, + ) -> bool { + use engine_world::effects::PlaybackCommand; + let mut changed = false; + ui.collapsing("Effect preview", |ui| { + ui.small("Preview has separate simulation state. Scene data is unchanged."); + ui.horizontal_wrapped(|ui| { + for (label, command) in [ + ("Play", PlaybackCommand::Play), + ("Pause", PlaybackCommand::Pause), + ("Resume", PlaybackCommand::Resume), + ("Stop", PlaybackCommand::Stop), + ("Restart", PlaybackCommand::Restart), + ("Reset", PlaybackCommand::Reset), + ] { + if ui.button(label).clicked() { + self.control(document, command); + self.playing = matches!( + command, + PlaybackCommand::Play + | PlaybackCommand::Resume + | PlaybackCommand::Restart + ); + if matches!( + command, + PlaybackCommand::Stop + | PlaybackCommand::Restart + | PlaybackCommand::Reset + ) { + self.time = 0.0; + } + changed = true; + } + } + if ui.button("Step 1/60 s").clicked() { + self.control(document, PlaybackCommand::Resume); + self.advance(document, camera, 1.0 / 60.0); + self.control(document, PlaybackCommand::Pause); + self.playing = false; + changed = true; + } + }); + ui.add(egui::Slider::new(&mut self.speed, 0.0..=4.0).text("Preview speed")); + let mut time = self.time; + if ui + .add(egui::Slider::new(&mut time, 0.0..=30.0).text("Time (seconds)")) + .changed() + { + self.scrub(document, camera, time); + changed = true; + } + changed |= ui + .checkbox(&mut self.show_bounds, "Show effect bounds") + .changed(); + ui.label(format!( + "{} visible particles · {:.2} ms CPU simulation", + self.runtime.particle_count(), + self.simulation_millis + )); + }); + changed + } +} + +pub(super) fn append_bounds( + scene: &mut renderer_wgpu::ViewportScene, + document: &engine_editor::AuthoringDocument, +) { + use engine_world::effects::EffectKind; + use glam::Vec3; + let Some(entity) = document.selected() else { + return; + }; + let Ok(snapshot) = document.world().snapshot(entity) else { + return; + }; + let Some(effect) = snapshot.effect else { + return; + }; + let Ok(transform) = document.world().world_transform(entity) else { + return; + }; + let bounds_center = effect + .common + .bounds + .as_ref() + .map_or(Vec3::ZERO, |bounds| Vec3::from(bounds.center)); + let dimensions = if let Some(bounds) = &effect.common.bounds { + (Vec3::from(bounds.half_extents) * 2.0).to_array() + } else { + match &effect.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => p.dimensions, + EffectKind::ForceField(v) | EffectKind::Wind(v) => v.dimensions, + EffectKind::CollisionVolume(v) => v.dimensions, + EffectKind::Fog(v) => v.dimensions, + EffectKind::Decal(v) => v.dimensions, + EffectKind::Weather(v) => v.emitter.dimensions, + EffectKind::FoliageScatter(v) => v.dimensions, + EffectKind::PostProcess(v) => v.dimensions, + EffectKind::ReflectionProbe(v) => v.dimensions, + EffectKind::HeatHaze(v) => [v.radius * 2.0; 3], + EffectKind::Beam(v) => { + scene.guides.push(renderer_wgpu::ViewportGuide { + key: crate::hash_value(&(entity, "effect-endpoints")), + vertices: vec![ + v.start_target + .and_then(|id| document.world().world_transform(id).ok()) + .map_or(transform.0, |t| t.0) + .transform_point3(Vec3::from(v.start)) + .to_array(), + v.end_target + .and_then(|id| document.world().world_transform(id).ok()) + .map_or(transform.0, |t| t.0) + .transform_point3(Vec3::from(v.end)) + .to_array(), + ], + color: [0.18, 0.82, 1.0, 1.0], + }); + return; + } + _ => return, + } + }; + let half = Vec3::from(dimensions) * 0.5; + let corners: Vec<_> = (0..8) + .map(|i| { + transform + .0 + .transform_point3( + bounds_center + + Vec3::new( + if i & 1 == 0 { -half.x } else { half.x }, + if i & 2 == 0 { -half.y } else { half.y }, + if i & 4 == 0 { -half.z } else { half.z }, + ), + ) + .to_array() + }) + .collect(); + let mut vertices = Vec::with_capacity(24); + for index in 0..8 { + for bit in [1, 2, 4] { + if index & bit == 0 { + vertices.extend([corners[index], corners[index | bit]]); + } + } + } + scene.guides.push(renderer_wgpu::ViewportGuide { + key: crate::hash_value(&(entity, "effect-bounds")), + vertices, + color: [0.18, 0.82, 1.0, 1.0], + }); +} + +impl crate::EditorViewer<'_> { + pub(super) fn effects_panel(&mut self, ui: &mut egui::Ui) { + self.request_preview |= + self.effect_preview + .controls(ui, self.document, self.camera.position()); + ui.collapsing("Effects presets & assets", |ui| { + ui.add_enabled_ui(!self.document.is_read_only() && !self.playing, |ui| { + ui.text_edit_singleline(&mut self.effect_preview.search) + .on_hover_text("Search built-in effect presets"); + egui::ComboBox::from_id_salt("effects-category") + .selected_text(&self.effect_preview.category) + .show_ui(ui, |ui| { + for category in [ + "All", + "Fire and Explosions", + "Magic and Energy", + "Vehicles", + "Environment", + "Character and Combat", + ] { + ui.selectable_value( + &mut self.effect_preview.category, + category.to_owned(), + category, + ); + } + }); + let search = self.effect_preview.search.to_lowercase(); + let category = self.effect_preview.category.clone(); + let mut chosen = None; + egui::ScrollArea::vertical() + .max_height(160.0) + .id_salt("effects-presets") + .show(ui, |ui| { + for asset in engine_world::effects::presets().into_iter().filter(|a| { + a.name.to_lowercase().contains(&search) + && (category == "All" || preset_category(&a.name) == category) + }) { + let response = ui + .button(&asset.name) + .interact(egui::Sense::click_and_drag()); + if response.drag_started() { + response.dnd_set_drag_payload(PresetDrop(asset.clone())); + } + if response.clicked() { + chosen = Some(asset); + } + } + }); + if ui.button("Load .effect asset…").clicked() + && let Some(path) = rfd::FileDialog::new() + .set_directory(self.project_root) + .add_filter("Effect asset", &["effect"]) + .pick_file() + { + match load_effect_asset(self.project_root, &path) { + Ok(asset) => chosen = Some(asset), + Err(error) => self.effect_error(error), + } + } + if let Some(asset) = chosen { + if asset + .effects + .iter() + .any(|effect| missing_effect_meshes(effect, self.model_assets)) + { + self.model_assets + .extend(crate::load_project_model_assets(self.project_root)); + } + match self + .document + .add_effect_asset(&asset, self.document.selected()) + { + Ok(_) => self.request_preview = true, + Err(error) => self.effect_error(error.to_string()), + } + } + if ui.button("Save selection as .effect asset…").clicked() { + let result = self.save_effect_selection(); + if let Err(error) = result { + self.effect_error(error); + } + } + }); + }); + } + + fn effect_error(&mut self, error: String) { + self.console.push(crate::simple_console( + engine_core::logging::Severity::Error, + "effects.asset", + error, + )); + } + + fn save_effect_selection(&mut self) -> Result<(), String> { + let entity = self + .document + .selected() + .ok_or("Select an effect or a composite group first")?; + let snapshot = self + .document + .world() + .snapshot(entity) + .map_err(|e| e.to_string())?; + let mut effects = Vec::new(); + let mut entity_ids = Vec::new(); + let mut transforms = Vec::new(); + if let Some(effect) = snapshot.effect { + effects.push(effect); + entity_ids.push(snapshot.id); + transforms.push(glam::Mat4::IDENTITY.to_cols_array()); + } + for child in self + .document + .world() + .children(entity) + .map_err(|e| e.to_string())? + { + let child = self + .document + .world() + .snapshot(*child) + .map_err(|e| e.to_string())?; + if let Some(effect) = child.effect { + effects.push(effect); + entity_ids.push(child.id); + transforms.push(child.local_transform.matrix().to_cols_array()); + } + } + for effect in &mut effects { + effect.source = None; + } + let mut asset = engine_world::effects::EffectAsset::new( + snapshot.name.unwrap_or_else(|| "Effect".into()), + effects, + ); + asset.entity_ids = entity_ids; + asset.transforms = transforms; + let bytes = asset.to_bytes()?; + if let Some(path) = rfd::FileDialog::new() + .set_directory(self.project_root.join("assets")) + .set_file_name(format!("{}.effect", asset.name)) + .add_filter("Effect asset", &["effect"]) + .save_file() + { + let parent = path + .parent() + .ok_or("Choose a folder inside the project")? + .canonicalize() + .map_err(|e| e.to_string())?; + if !parent.starts_with( + self.project_root + .canonicalize() + .map_err(|e| e.to_string())?, + ) { + return Err("Effect assets must be saved inside the project".into()); + } + engine_platform::AtomicFileService::new() + .write(&path, &bytes) + .map_err(|e| e.to_string())?; + ensure_effect_meta(&path)?; + } + Ok(()) + } +} + +fn ensure_effect_meta(path: &Path) -> Result { + let sidecar = engine_assets::sidecar_path(path); + let meta = if sidecar.is_file() { + engine_assets::load_meta(&sidecar).map_err(|e| e.to_string())? + } else { + let meta = engine_assets::AssetMeta::new("rustic.effect", 1); + engine_assets::save_meta(&sidecar, &meta).map_err(|e| e.to_string())?; + meta + }; + Ok(meta.asset_id) +} + +pub(super) fn load_effect_asset( + root: &Path, + path: &Path, +) -> Result { + let root = root.canonicalize().map_err(|e| e.to_string())?; + let path = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + let path = path.canonicalize().map_err(|e| e.to_string())?; + if !path.starts_with(&root) { + return Err("Copy effect assets into the project before loading them".into()); + } + let mut asset = engine_world::effects::EffectAsset::from_bytes( + &std::fs::read(&path).map_err(|e| e.to_string())?, + )?; + let id = ensure_effect_meta(&path)?; + asset.effects = asset.instantiate_from(id); + Ok(asset) +} + +fn asset_instance_map( + asset: &engine_world::effects::EffectAsset, + source: &engine_world::effects::EffectSource, + entity: engine_world::EntityId, + document: &engine_editor::AuthoringDocument, +) -> std::collections::BTreeMap { + let mut mapping = std::collections::BTreeMap::new(); + let parent = document.world().parent(entity).ok().flatten(); + for candidate in document.world().entity_ids() { + if document.world().parent(candidate).ok().flatten() != parent { + continue; + } + if let Ok(Some(effect)) = document.world().effect(candidate) + && let Some(other) = effect.source + && other.asset == source.asset + && let Ok(index) = usize::try_from(other.child) + && let Some(original) = asset.entity_ids.get(index) + { + mapping.insert(*original, candidate); + } + } + if let Ok(index) = usize::try_from(source.child) + && let Some(original) = asset.entity_ids.get(index) + { + mapping.insert(*original, entity); + } + mapping +} + +fn restore_instance_defaults( + asset: &engine_world::effects::EffectAsset, + effect: &mut engine_world::effects::EffectComponent, + entity: engine_world::EntityId, + document: &engine_editor::AuthoringDocument, +) -> Result<(), String> { + let mapping = asset_instance_map( + asset, + effect + .source + .as_ref() + .ok_or("This effect has no shared asset")?, + entity, + document, + ); + asset.restore(effect)?; + effect.remap_entities(&mapping); + let baseline = engine_world::effects::EffectDefaults::from_component(effect); + if let Some(source) = &mut effect.source { + source.baseline = Some(Box::new(baseline)); + } + Ok(()) +} + +fn update_shared_asset( + path: &Path, + effect: &engine_world::effects::EffectComponent, + entity: engine_world::EntityId, + document: &mut engine_editor::AuthoringDocument, +) -> Result<(), String> { + let source = effect + .source + .as_ref() + .ok_or("This effect has no shared asset")?; + let mut asset = engine_world::effects::EffectAsset::from_bytes( + &std::fs::read(path).map_err(|e| e.to_string())?, + )?; + let child_index = usize::try_from(source.child).map_err(|e| e.to_string())?; + let old_defaults = asset + .effects + .get(child_index) + .ok_or("Shared asset child no longer exists")? + .clone(); + // Persist asset-local target identities, rather than references to the scene + // instance from which the defaults were edited. + let inverse = asset_instance_map(&asset, source, entity, document) + .into_iter() + .map(|(original, live)| (live, original)) + .collect(); + let mut new_defaults = effect.clone(); + new_defaults.source = None; + new_defaults.remap_entities(&inverse); + asset.effects[child_index] = new_defaults.clone(); + let mut edits = Vec::new(); + for entity in document.world().entity_ids() { + if let Some(instance) = document.world().effect(entity).map_err(|e| e.to_string())? + && let Some(other) = &instance.source + && other.asset == source.asset + && other.child == source.child + { + let mapping = asset_instance_map(&asset, other, entity, document); + let mut live_defaults = new_defaults.clone(); + live_defaults.remap_entities(&mapping); + let baseline = other + .baseline + .as_ref() + .map(|baseline| baseline.as_ref().clone().into_component()) + .unwrap_or_else(|| { + let mut old = old_defaults.clone(); + old.remap_entities(&mapping); + old + }); + let mut merged = engine_world::effects::merge_instance_defaults( + &instance, + &baseline, + &live_defaults, + )?; + if let Some(source) = &mut merged.source { + source.baseline = Some(Box::new( + engine_world::effects::EffectDefaults::from_component(&live_defaults), + )); + } + if merged != instance { + edits.push(engine_world::SceneEdit::Effect { + entity, + before: Some(instance), + after: Some(merged), + }); + } + } + } + let bytes = asset.to_bytes()?; + engine_platform::AtomicFileService::new() + .write(path, &bytes) + .map_err(|e| e.to_string())?; + if !edits.is_empty() { + document + .edit_transaction("Refresh shared effect instances", edits) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn find_asset_path(root: &Path, id: engine_core::AssetId) -> Option { + let mut folders = vec![root.to_path_buf()]; + while let Some(folder) = folders.pop() { + let Ok(entries) = std::fs::read_dir(folder) else { + continue; + }; + for entry in entries.flatten() { + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_symlink() { + continue; + } + let path = entry.path(); + if kind.is_dir() && !crate::is_hidden_project_directory(&entry.file_name()) { + folders.push(path); + } else if kind.is_file() + && path.extension().is_some_and(|ext| ext == "effect") + && engine_assets::load_meta(&engine_assets::sidecar_path(&path)) + .is_ok_and(|meta| meta.asset_id == id) + { + return Some(path); + } + } + } + None +} + +impl crate::EditorViewer<'_> { + #[allow( + clippy::too_many_lines, + reason = "timeline controls share one undoable operation dispatch" + )] + fn composite_timeline(&mut self, ui: &mut egui::Ui, snapshot: &engine_world::EntitySnapshot) { + let mut children: Vec<_> = self + .document + .world() + .children(snapshot.id) + .unwrap_or_default() + .iter() + .filter_map(|id| self.document.world().snapshot(*id).ok()) + .filter(|child| child.effect.is_some()) + .collect(); + if children.is_empty() { + return; + } + children.sort_by(|a, b| { + a.effect + .as_ref() + .unwrap() + .common + .delay + .total_cmp(&b.effect.as_ref().unwrap().common.delay) + }); + ui.collapsing("Composite timeline (seconds)", |ui| { + let mut move_up = None; + let mut duplicate = None; + let mut delete = None; + for (index, child) in children.iter().enumerate() { + let mut effect = child.effect.clone().unwrap(); + let before = effect.clone(); + ui.push_id(child.id, |ui| { + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut effect.common.enabled, ""); + if ui + .selectable_label( + false, + child.name.as_deref().unwrap_or(effect.kind.name()), + ) + .clicked() + { + self.document.select(Some(child.id)); + } + if ui.small_button("Duplicate").clicked() { + duplicate = Some(child.id); + } + if index > 0 && ui.small_button("Earlier").clicked() { + move_up = Some(index); + } + if ui.small_button("Delete").clicked() { + delete = Some(child.id); + } + }); + ui.horizontal(|ui| { + ui.add( + egui::DragValue::new(&mut effect.common.delay) + .speed(0.01) + .range(0.0..=10000.0) + .prefix("Start ") + .suffix(" s"), + ); + ui.add( + egui::DragValue::new(&mut effect.common.duration) + .speed(0.01) + .range(0.01..=10000.0) + .prefix("Duration ") + .suffix(" s"), + ); + }); + let elapsed = ((self.effect_preview.time - effect.common.delay) + / effect.common.duration) + .clamp(0.0, 1.0); + ui.add(egui::ProgressBar::new(elapsed).text(format!( + "{:.2}–{:.2} s", + effect.common.delay, + effect.common.delay + effect.common.duration + ))); + }); + if effect != before { + match self.document.set_effect(child.id, Some(effect)) { + Ok(()) => self.request_preview = true, + Err(error) => self.effect_error(error.to_string()), + } + } + } + if let Some(index) = move_up { + let previous = &children[index - 1]; + let current = &children[index]; + let mut earlier = current.effect.clone().unwrap(); + let mut later = previous.effect.clone().unwrap(); + let prior = later.common.delay; + later.common.delay = earlier.common.delay.max(prior + 0.01); + earlier.common.delay = prior; + let result = self.document.edit_transaction( + "Reorder effect timeline", + vec![ + engine_world::SceneEdit::Effect { + entity: current.id, + before: current.effect.clone(), + after: Some(earlier), + }, + engine_world::SceneEdit::Effect { + entity: previous.id, + before: previous.effect.clone(), + after: Some(later), + }, + ], + ); + if let Err(error) = result { + self.effect_error(error.to_string()); + } else { + self.request_preview = true; + } + } + if let Some(entity) = duplicate { + let result = self + .document + .snapshot_tree(entity) + .and_then(|tree| self.document.paste_tree(&tree, Some(snapshot.id))); + if let Err(error) = result { + self.effect_error(error.to_string()); + } else { + self.request_preview = true; + } + } + if let Some(entity) = delete { + if let Err(error) = self.document.delete_tree(entity) { + self.effect_error(error.to_string()); + } else { + self.request_preview = true; + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_effect_configuration_renders_with_native_widgets_and_preserves_typed_data() { + let temporary = tempfile::tempdir().unwrap(); + let context = egui::Context::default(); + for dark in [true, false] { + context.set_visuals(if dark { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }); + for kind in engine_world::effects::EffectKind::all_defaults() { + let effect = engine_world::effects::EffectComponent::new(kind); + let before = serde_json::to_value(&effect).unwrap(); + let mut value = before.clone(); + let _ = context.run_ui(egui::RawInput::default(), |ui| { + assert!(!properties(ui, &mut value, temporary.path(), &[])); + }); + assert_eq!( + serde_json::from_value::(value) + .unwrap(), + effect + ); + } + } + } + + #[test] + fn deterministic_preview_scrub_preserves_the_saved_authoring_document() { + let temporary = tempfile::tempdir().unwrap(); + let project = engine_project::Project::create( + temporary.path().join("game"), + "Game", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = engine_editor::AuthoringDocument::open(project, false).unwrap(); + let id = document + .add_effect( + engine_world::effects::EffectKind::Particle(Default::default()), + None, + ) + .unwrap(); + document.save().unwrap(); + let before = document.world().snapshot(id).unwrap(); + let mut preview = EffectPreview::default(); + preview.scrub(&document, glam::Vec3::Z * 5.0, 1.0); + let positions: Vec<_> = preview + .runtime + .frame() + .particles + .iter() + .map(|p| p.position) + .collect(); + assert!(!positions.is_empty()); + preview.scrub(&document, glam::Vec3::Z * 5.0, 1.0); + assert_eq!( + positions, + preview + .runtime + .frame() + .particles + .iter() + .map(|p| p.position) + .collect::>() + ); + assert_eq!(document.world().snapshot(id).unwrap(), before); + assert!(!document.is_modified()); + } +} + +fn ensure_asset_meta(path: &Path) -> Result { + let sidecar = engine_assets::sidecar_path(path); + if sidecar.is_file() { + return engine_assets::load_meta(&sidecar) + .map(|meta| meta.asset_id) + .map_err(|e| e.to_string()); + } + let contract = engine_assets::ImporterRegistry::contract(path).map_err(|e| e.to_string())?; + let meta = engine_assets::AssetMeta::new(contract.id, contract.version); + engine_assets::save_meta(&sidecar, &meta).map_err(|e| e.to_string())?; + Ok(meta.asset_id) +} + +fn preset_category(name: &str) -> &'static str { + match name { + "Campfire" | "Fire sparks" | "Smoke plume" | "Explosion" | "Muzzle flash" | "Embers" + | "Firework burst" => "Fire and Explosions", + "Magic projectile" | "Lightning beam" | "Energy shield" | "Healing aura" | "Teleport" + | "Portal" | "Glowing trail" | "Shockwave" => "Magic and Energy", + "Tire smoke" | "Skid mark" | "Exhaust smoke" | "Speed boost trail" | "Collision sparks" + | "Rocket exhaust" => "Vehicles", + "Rain" | "Snow" | "Dust" | "Leaves" | "Fireflies" | "Mist" | "Water splash" + | "Ground ripple" => "Environment", + _ => "Character and Combat", + } +} + +/// Curves are scene data rather than a node graph. Native egui responses own +/// pointer capture, focus and context menus; paint uses the active widget style. +#[allow( + clippy::too_many_lines, + reason = "one native interaction pass owns curve interpolation, handles and key operations" +)] +fn edit_key_graph(ui: &mut egui::Ui, keys: &mut Vec) -> bool { + if keys.is_empty() { + return false; + } + let gradient = keys[0].get("color").is_some(); + if !gradient && keys[0].get("value").is_none() { + return false; + } + let (response, painter) = ui.allocate_painter( + egui::vec2(ui.available_width().clamp(120.0, 320.0), 100.0), + egui::Sense::click_and_drag(), + ); + let rect = response.rect.shrink(9.0); + painter.rect_filled( + response.rect, + ui.visuals().widgets.noninteractive.corner_radius, + ui.visuals().extreme_bg_color, + ); + let minimum = keys + .iter() + .filter_map(|key| key.get("value").and_then(Value::as_f64)) + .fold(0.0f64, f64::min) + .to_f32() + .unwrap_or(0.0); + let maximum = keys + .iter() + .filter_map(|key| key.get("value").and_then(Value::as_f64)) + .fold(1.0f64, f64::max) + .to_f32() + .unwrap_or(1.0); + let span = (maximum - minimum).max(0.001); + let mut samples: Vec<_> = keys + .iter() + .filter_map(|key| { + Some(( + key.get("time")?.as_f64()?.to_f32()?, + if gradient { + key.get("color")?.clone() + } else { + key.get("value")?.clone() + }, + )) + }) + .collect(); + samples.sort_by(|a, b| a.0.total_cmp(&b.0)); + let sample = |time: f32| -> Value { + let before = samples + .iter() + .rev() + .find(|(t, _)| *t <= time) + .unwrap_or(&samples[0]); + let after = samples + .iter() + .find(|(t, _)| *t >= time) + .unwrap_or(samples.last().unwrap()); + let weight = if after.0 > before.0 { + ((time - before.0) / (after.0 - before.0)).clamp(0.0, 1.0) + } else { + 0.0 + }; + let interpolate = |a: f64, b: f64| a + (b - a) * f64::from(weight); + if gradient { + Value::Array( + (0..4) + .map(|i| { + Value::from(interpolate( + before.1[i].as_f64().unwrap_or(1.0), + after.1[i].as_f64().unwrap_or(1.0), + )) + }) + .collect(), + ) + } else { + Value::from(interpolate( + before.1.as_f64().unwrap_or(1.0), + after.1.as_f64().unwrap_or(1.0), + )) + } + }; + let color = |value: &Value| { + let channel = |i| { + (value[i].as_f64().unwrap_or(1.0).clamp(0.0, 1.0) * 255.0) + .round() + .to_u8() + .unwrap_or(255) + }; + egui::Color32::from_rgba_unmultiplied(channel(0), channel(1), channel(2), channel(3)) + }; + if samples.is_empty() { + return false; + } + if gradient { + for index in 0..128_u16 { + let t = f32::from(index) / 128.0; + let x = rect.left() + rect.width() * t; + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(x, rect.top()), + egui::pos2(x + rect.width() / 128.0 + 1.0, rect.bottom()), + ), + 0.0, + color(&sample(t)), + ); + } + } else { + let points = (0..=64_u16) + .map(|index| { + let time = f32::from(index) / 64.0; + let value = sample(time).as_f64().unwrap_or(1.0).to_f32().unwrap_or(1.0); + egui::pos2( + rect.left() + time * rect.width(), + rect.bottom() - ((value - minimum) / span) * rect.height(), + ) + }) + .collect(); + painter.add(egui::Shape::line(points, ui.visuals().selection.stroke)); + } + let mut changed = false; + if response.double_clicked() + && let Some(pointer) = response.interact_pointer_pos() + { + let time = ((pointer.x - rect.left()) / rect.width()).clamp(0.0, 1.0); + let value = sample(time); + let key = if gradient { + serde_json::json!({"time":time,"color":value}) + } else { + serde_json::json!({"time":time,"value":value}) + }; + keys.push(key); + changed = true; + } + let mut remove = None; + let key_count = keys.len(); + for (index, key) in keys.iter_mut().enumerate() { + let time = key["time"].as_f64().unwrap_or(0.0).to_f32().unwrap_or(0.0); + let value = key["value"].as_f64().unwrap_or(0.0).to_f32().unwrap_or(0.0); + let center = egui::pos2( + rect.left() + time.clamp(0.0, 1.0) * rect.width(), + if gradient { + rect.center().y + } else { + rect.bottom() - ((value - minimum) / span) * rect.height() + }, + ); + let handle = ui.interact( + egui::Rect::from_center_size(center, egui::vec2(14.0, 14.0)), + response.id.with(index), + egui::Sense::drag(), + ); + let style = ui.style().interact(&handle); + painter.circle_filled( + center, + 5.0, + if gradient { + color(&key["color"]) + } else { + style.bg_fill + }, + ); + painter.circle_stroke(center, 5.0, style.fg_stroke); + if handle.dragged() + && let Some(pointer) = handle.interact_pointer_pos() + { + key["time"] = Value::from(((pointer.x - rect.left()) / rect.width()).clamp(0.0, 1.0)); + if !gradient { + key["value"] = Value::from( + minimum + ((rect.bottom() - pointer.y) / rect.height()).clamp(0.0, 1.0) * span, + ); + } + changed = true; + } + handle.context_menu(|ui| { + if ui + .add_enabled(key_count > 1, egui::Button::new("Remove key")) + .clicked() + { + remove = Some(index); + ui.close(); + } + }); + } + if let Some(index) = remove { + keys.remove(index); + changed = true; + } + response.on_hover_text( + "Drag keys to move them. Double-click to add a key. Right-click a key to remove it.", + ); + changed +} + +fn edit_parameters( + ui: &mut egui::Ui, + parameters: &mut serde_json::Map, + root: &Path, + references: &[(String, String)], +) -> bool { + ui.collapsing("Runtime parameters", |ui| { + ui.small("Bindings: enabled, visible, emission_rate, intensity, color, speed, size, gravity, force, density, progress, width, radius."); + let id = ui.make_persistent_id("new-effect-parameter"); + let mut name = ui.ctx().data_mut(|data|data.get_temp::(id)).unwrap_or_default(); + let mut changed = false; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut name).hint_text("Parameter name")); + ui.add_enabled_ui(!name.trim().is_empty() && !parameters.contains_key(name.trim()), |ui| { + ui.menu_button("Add parameter", |ui| { + for (label,value) in [("Scalar",serde_json::json!({"Scalar":1.0})),("Vector",serde_json::json!({"Vector":[0.0,0.0,0.0]})), + ("Color",serde_json::json!({"Color":[1.0,1.0,1.0,1.0]})),("Boolean",serde_json::json!({"Boolean":true}))] { + if ui.button(label).clicked() {parameters.insert(name.trim().into(),value);changed=true;ui.close();} + } + }); + }); + }); + ui.ctx().data_mut(|data|data.insert_temp(id,name)); + let mut remove = None; + for (name,value) in parameters.iter_mut() { + ui.horizontal(|ui| {ui.strong(name);if ui.small_button("Remove").clicked(){remove=Some(name.clone());}}); + changed |= edit_value(ui,name,value,root,references); + } + if let Some(name)=remove {parameters.remove(&name);changed=true;} + changed + }).body_returned.unwrap_or(false) +} + +fn field_hint(name: &str) -> &'static str { + match name { + "duration" | "delay" | "lifetime" | "fade_in" | "fade_out" | "interval" | "time" + | "update_interval" => "Seconds. Curve/gradient key times use normalized lifetime (0–1).", + "rate" | "emission_rate" => { + "Particles per second; global and per-emitter capacity still apply." + } + "rate_over_distance" => "Particles per world unit of emitter movement.", + "width" + | "radius" + | "dimensions" + | "length" + | "half_extents" + | "render_distance" + | "thickness" + | "min_vertex_distance" => { + "World units; entity parenting and transforms follow the scene hierarchy." + } + "gravity" | "force" => "Acceleration in world units per second squared.", + "random_seed" => { + "Stable deterministic seed. Changing the seed resets this simulation instance." + } + "opacity" | "weight" | "probability" | "progress" | "sub_emitter_probability" => { + "Normalized value from 0 to 1." + } + "visibility_layers" | "layers" | "collision_layers" => { + "Bit mask: each bit selects an affected scene/effect layer." + } + "time_scale" => "Simulation speed multiplier. Zero freezes this effect.", + "material" | "texture" | "mesh" | "cubemap" | "normal_map" | "orm_map" => { + "Project asset stored by stable .rmeta identity; use Choose to assign an asset." + } + _ => "Changes are validated and saved through the scene undo history.", + } +} + +#[cfg(test)] +mod curve_interaction_tests { + use super::*; + + #[test] + fn dragging_curve_key_changes_serialized_time_and_value() { + let context = egui::Context::default(); + let mut keys = vec![ + serde_json::json!({"time":0.0,"value":0.0}), + serde_json::json!({"time":1.0,"value":1.0}), + ]; + let screen = Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(640.0, 480.0), + )); + let first = context.run_ui( + egui::RawInput { + screen_rect: screen, + ..Default::default() + }, + |ui| { + edit_key_graph(ui, &mut keys); + }, + ); + let center = first + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Circle(circle) => Some(circle.center), + _ => None, + }) + .expect("curve graph paints key handles"); + let destination = center + egui::vec2(45.0, -20.0); + for events in [ + vec![ + egui::Event::PointerMoved(center), + egui::Event::PointerButton { + pos: center, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + vec![egui::Event::PointerMoved(destination)], + vec![egui::Event::PointerButton { + pos: destination, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ] { + let _ = context.run_ui( + egui::RawInput { + screen_rect: screen, + events, + ..Default::default() + }, + |ui| { + edit_key_graph(ui, &mut keys); + }, + ); + } + assert!(keys[0]["time"].as_f64().unwrap() > 0.05); + assert!(keys[0]["value"].as_f64().unwrap() > 0.05); + assert_eq!(keys[1], serde_json::json!({"time":1.0,"value":1.0})); + } +} + +#[cfg(test)] +mod preview_child_tests { + use super::*; + use engine_world::effects::*; + + #[test] + fn editor_preview_runs_real_subemitters_and_stop_releases_them() { + let temporary = tempfile::tempdir().unwrap(); + let project = engine_project::Project::create( + temporary.path().join("game"), + "Game", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let path = project.root().join("assets/impact.effect"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let meta = engine_assets::AssetMeta::new("rustic.effect", 1); + let asset = EffectAsset::new( + "Impact", + vec![EffectComponent::new(EffectKind::Shockwave( + Default::default(), + ))], + ); + std::fs::write(&path, asset.to_bytes().unwrap()).unwrap(); + engine_assets::save_meta(&engine_assets::sidecar_path(&path), &meta).unwrap(); + let mut document = engine_editor::AuthoringDocument::open(project, false).unwrap(); + document + .add_effect( + EffectKind::Particle(ParticleEmitter { + rate: 0.0, + bursts: vec![Burst { + count: 1, + ..Default::default() + }], + sub_emitter: Some(meta.asset_id), + ..Default::default() + }), + None, + ) + .unwrap(); + document.save().unwrap(); + let count = document.world().entity_count(); + let mut preview = EffectPreview::default(); + preview.control(&document, PlaybackCommand::Play); + preview.advance(&document, glam::Vec3::Z * 5.0, 0.1); + assert!( + preview.runtime.world.entity_count() > count, + "preview must instantiate child assets through the runtime world" + ); + assert_eq!(document.world().entity_count(), count); + assert!(!document.is_modified()); + preview.control(&document, PlaybackCommand::Stop); + assert_eq!(preview.runtime.world.entity_count(), count); + } +} + +fn missing_effect_meshes( + effect: &engine_world::effects::EffectComponent, + models: &std::collections::BTreeMap, +) -> bool { + use engine_world::effects::EffectKind; + match &effect.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => { + p.mesh.is_some_and(|id| !models.contains_key(&id)) + } + EffectKind::Weather(w) => w.emitter.mesh.is_some_and(|id| !models.contains_key(&id)), + EffectKind::FoliageScatter(f) => f.meshes.iter().any(|id| !models.contains_key(id)), + _ => false, + } +} + +fn particle_module(name: &str) -> &'static str { + match name { + "rate" + | "rate_over_distance" + | "bursts" + | "shape" + | "dimensions" + | "surface_only" + | "mesh_spawn_triangles" => "Emission", + "lifetime" | "speed" | "direction" | "start_size" | "angular_velocity" + | "inherit_velocity" => "Initialization", + "gravity" | "force" | "drag" | "wind_influence" | "noise" | "velocity_limit" => "Movement", + "appearance" | "mesh" | "flipbook" | "flipbook_fps" | "stretch" => "Appearance", + "collision" | "collision_layers" | "bounce" | "friction" | "collision_response" => { + "Collision" + } + "sub_emitter" | "sub_emitter_probability" | "max_generations" => "Sub-emitters", + _ => "Capacity and simulation", + } +} + +fn edit_particle_modules( + ui: &mut egui::Ui, + fields: &mut serde_json::Map, + root: &Path, + references: &[(String, String)], +) -> bool { + let mut changed = false; + for module in [ + "Emission", + "Initialization", + "Movement", + "Appearance", + "Collision", + "Sub-emitters", + "Capacity and simulation", + ] { + egui::CollapsingHeader::new(module) + .default_open(module == "Emission") + .show(ui, |ui| { + for (name, value) in fields + .iter_mut() + .filter(|(name, _)| particle_module(name) == module) + { + if name == "mesh_spawn_triangles" { + ui.small(format!( + "{} mesh-surface triangles loaded from the assigned mesh", + value.as_array().map_or(0, Vec::len) + )); + } else { + changed |= edit_value(ui, name, value, root, references); + } + } + }); + } + changed +} + +fn prepare_mesh_emission( + effect: &mut engine_world::effects::EffectComponent, + models: &std::collections::BTreeMap, +) { + use engine_world::effects::EffectKind; + let particle = match &mut effect.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => p, + EffectKind::Weather(w) => &mut w.emitter, + _ => return, + }; + if particle.shape != engine_world::effects::SpawnShape::MeshSurface { + return; + } + let Some(model) = particle.mesh.and_then(|id| models.get(&id)) else { + return; + }; + particle.mesh_spawn_triangles = model + .meshes + .iter() + .flat_map(|mesh| { + mesh.indices.chunks_exact(3).filter_map(move |face| { + Some([ + *mesh.positions.get(usize::try_from(face[0]).ok()?)?, + *mesh.positions.get(usize::try_from(face[1]).ok()?)?, + *mesh.positions.get(usize::try_from(face[2]).ok()?)?, + ]) + }) + }) + .take(100_000) + .collect(); +} diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index 9127c2ed..5e39582b 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -1,4 +1,5 @@ mod agent_tools; +mod effects; mod explorer_selection; mod play_session; mod pointer_input; @@ -168,6 +169,7 @@ struct EditorApp { scene_graph: scene_graph::SceneGraph, ui_designer: ui_designer::UiDesigner, render_world: RenderWorldBuffer, + effect_preview: effects::EffectPreview, model_assets: BTreeMap, detach_inspector: bool, game_settings: GameSettings, @@ -289,6 +291,7 @@ impl EditorApp { scene_graph: scene_graph::SceneGraph::default(), ui_designer: ui_designer::UiDesigner::default(), render_world: RenderWorldBuffer::new(), + effect_preview: effects::EffectPreview::default(), model_assets, detach_inspector: false, game_settings, @@ -361,18 +364,27 @@ impl EditorApp { } self.agent_frame = None; self.preview_dirty = true; - let scene = viewport_scene( + self.effect_preview + .update(&self.document, self.camera.position()); + let mut scene = viewport_scene( &self.document, self.camera, self.viewport_extent, &mut self.render_world, &self.model_assets, ); + let effects = self.effect_preview.runtime.frame().clone(); + renderer_wgpu::append_mesh_particle_models(&mut scene, &effects, &self.model_assets); + renderer_wgpu::append_effect_frame(&mut scene, &effects); + if self.effect_preview.show_bounds { + effects::append_bounds(&mut scene, &self.document); + } let request = ViewportRenderRequest { sequence: self.agent_frame_sequence + 1, width: self.viewport_extent[0], height: self.viewport_extent[1], scene, + effects, }; match self.viewport_request.try_send(request) { Ok(()) => { @@ -431,7 +443,9 @@ impl EditorApp { frame.adapter.backend, frame.digest_hex() ); - self.log(Severity::Info, "renderer", self.preview_status.clone()); + if !self.effect_preview.playing { + self.log(Severity::Info, "renderer", self.preview_status.clone()); + } } Ok(None) => { "Viewport suspended while minimized".clone_into(&mut self.preview_status); @@ -445,6 +459,12 @@ impl EditorApp { self.refresh_preview(); } } + if self.play.is_none() && self.effect_preview.playing { + context.request_repaint_after(Duration::from_millis(33)); + if self.effect_preview.last_tick.elapsed() >= Duration::from_millis(33) { + self.preview_dirty = true; + } + } if self.preview_dirty && self.play.is_none() { self.refresh_preview(); } @@ -1654,6 +1674,7 @@ impl eframe::App for EditorApp { gizmo_widget: &mut self.gizmo_widget, scene_graph: &mut self.scene_graph, ui_designer: &mut self.ui_designer, + effect_preview: &mut self.effect_preview, model_assets: &mut self.model_assets, project_file_action: &mut project_file_action, }; @@ -1696,6 +1717,7 @@ struct EditorViewer<'a> { gizmo_widget: &'a mut transform_widget::TransformWidget, scene_graph: &'a mut scene_graph::SceneGraph, ui_designer: &'a mut ui_designer::UiDesigner, + effect_preview: &'a mut effects::EffectPreview, model_assets: &'a mut BTreeMap, project_file_action: &'a mut Option, } @@ -1871,8 +1893,56 @@ impl EditorViewer<'_> { self.viewport_input(ui, &response); } } + if !is_2d + && !self.playing + && !self.document.is_read_only() + && let Some(payload) = response.dnd_release_payload::() + { + match self.document.add_effect_asset(&payload.0, None) { + Ok(id) => { + let position = ui + .input(|input| input.pointer.interact_pos()) + .and_then(|point| { + viewport_ground_position(*self.camera, response.rect, point) + }) + .unwrap_or(self.camera.focus); + let _ = self.document.set_transform( + id, + LocalTransform { + translation: position, + ..LocalTransform::IDENTITY + }, + ); + self.request_preview = true; + } + Err(error) => self.console.push(simple_console( + Severity::Error, + "effects.asset", + error.to_string(), + )), + } + } if let Some(ViewportDrop::File(path)) = dropped.as_deref() { - if path.extension().is_some_and(|extension| { + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("effect")) + && !is_2d + && !self.playing + { + let result = + effects::load_effect_asset(self.project_root, path).and_then(|asset| { + self.document + .add_effect_asset(&asset, selected_scene_folder(self.document)) + .map_err(|e| e.to_string()) + }); + match result { + Ok(_) => self.request_preview = true, + Err(error) => { + self.console + .push(simple_console(Severity::Error, "effects.asset", error)) + } + } + } else if path.extension().is_some_and(|extension| { extension.eq_ignore_ascii_case("scene") || extension.eq_ignore_ascii_case("rscene") }) { let relative = path @@ -2314,6 +2384,27 @@ impl EditorViewer<'_> { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.menu_button("+ Add", |ui| { + ui.menu_button("Effects", |ui| { + for kind in engine_world::effects::EffectKind::all_defaults() { + if ui + .add_enabled( + !self.document.is_read_only() && !self.playing, + egui::Button::new(kind.name()), + ) + .clicked() + { + match self.document.add_effect(kind, selected_folder) { + Ok(_) => self.request_preview = true, + Err(error) => self.console.push(simple_console( + Severity::Error, + "effects", + error.to_string(), + )), + } + ui.close(); + } + } + }); if ui .add_enabled(!self.document.is_read_only(), egui::Button::new("Folder")) .clicked() @@ -2479,6 +2570,23 @@ impl EditorViewer<'_> { self.assign_texture_files(entity, &paths); return; } + SceneTreeAction::EffectPreset(entity, asset) => self + .document + .add_effect_asset(&asset, Some(entity)) + .map(|_| ()), + SceneTreeAction::EffectFile(entity, path) => { + match effects::load_effect_asset(self.project_root, &path) { + Ok(asset) => self + .document + .add_effect_asset(&asset, Some(entity)) + .map(|_| ()), + Err(error) => { + self.console + .push(simple_console(Severity::Error, "effects.asset", error)); + return; + } + } + } SceneTreeAction::Rename(entity, name) => self.document.set_name(entity, name), SceneTreeAction::Delete(entity) => self.document.delete_tree(entity), action @ (SceneTreeAction::Copy(_) | SceneTreeAction::Cut(_)) => { @@ -2781,6 +2889,7 @@ impl EditorViewer<'_> { )] fn inspector_contents(&mut self, ui: &mut egui::Ui) { self.environment_inspector(ui); + self.effects_panel(ui); let Some(entity) = self.document.selected() else { ui.vertical_centered(|ui| { ui.add_space(48.0); @@ -2807,6 +2916,7 @@ impl EditorViewer<'_> { snapshot.local_transform.scale = live.scale.into(); snapshot.part_attributes = live.part_attributes; snapshot.scripts.clone_from(&live.scripts); + snapshot.effect.clone_from(&live.effect); } let mut name = snapshot.name.clone().unwrap_or_else(|| "Entity".into()); if ui.text_edit_singleline(&mut name).changed() @@ -2959,6 +3069,7 @@ impl EditorViewer<'_> { ); } self.texture_inspector(ui, &snapshot); + self.effect_inspector(ui, &snapshot); self.script_inspector(ui, entity, snapshot.scripts); } @@ -3296,6 +3407,7 @@ struct ViewportRenderRequest { width: u32, height: u32, scene: ViewportScene, + effects: engine_world::effects::EffectFrame, } fn spawn_viewport_renderer( @@ -3325,7 +3437,8 @@ fn spawn_viewport_renderer( request = newer; } let result = renderer - .render(request.width, request.height, &request.scene) + .set_effect_frame(&request.effects) + .and_then(|()| renderer.render(request.width, request.height, &request.scene)) .map_err(|error| error.to_string()); if result_sender.send((request.sequence, result)).is_err() { break; @@ -4131,6 +4244,8 @@ struct SceneTreeDrag { enum SceneTreeAction { Rename(EntityId, String), TextureFiles(EntityId, Vec), + EffectFile(EntityId, PathBuf), + EffectPreset(EntityId, engine_world::effects::EffectAsset), Copy(EntityId), Cut(EntityId), Paste(Option), @@ -4263,9 +4378,14 @@ fn scene_entry_row( response.dnd_set_drag_payload(SceneTreeDrag { entity }); } if can_edit { + if let Some(payload) = response.dnd_release_payload::() { + result.action = Some(SceneTreeAction::EffectPreset(entity, payload.0.clone())); + } if let Some(payload) = response.dnd_release_payload::() { let paths = payload.project_paths(); - if paths.iter().all(|p| textures::is_texture(p)) { + if paths.len() == 1 && paths[0].extension().is_some_and(|ext| ext == "effect") { + result.action = Some(SceneTreeAction::EffectFile(entity, paths[0].clone())); + } else if !paths.is_empty() && paths.iter().all(|p| textures::is_texture(p)) { result.action = Some(SceneTreeAction::TextureFiles(entity, paths)); } } @@ -5751,6 +5871,7 @@ mod explorer_tests { gizmo_widget: &mut transform_widget::TransformWidget::default(), scene_graph: &mut scene_graph::SceneGraph::default(), ui_designer: &mut ui_designer::UiDesigner::default(), + effect_preview: &mut effects::EffectPreview::default(), model_assets: &mut BTreeMap::new(), project_file_action: &mut action, }; @@ -6084,6 +6205,7 @@ mod explorer_tests { gizmo_widget: &mut transform_widget::TransformWidget::default(), scene_graph: &mut scene_graph::SceneGraph::default(), ui_designer: &mut ui_designer::UiDesigner::default(), + effect_preview: &mut effects::EffectPreview::default(), model_assets: &mut BTreeMap::new(), project_file_action: &mut action, }; diff --git a/Engine/apps/runtime/src/main.rs b/Engine/apps/runtime/src/main.rs index c6dd21f0..bb568dec 100644 --- a/Engine/apps/runtime/src/main.rs +++ b/Engine/apps/runtime/src/main.rs @@ -482,6 +482,7 @@ fn render_runtime( .as_mut() .ok_or("runtime renderer was not initialized")?; let scene = renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, models); + renderer.set_effect_frame(world.effect_frame()).map_err(|error| error.to_string())?; let frame = renderer .render(width, height, &scene) .map_err(|e| e.to_string())? diff --git a/Engine/crates/engine-assets/Cargo.toml b/Engine/crates/engine-assets/Cargo.toml index ebc67f3b..915b54a1 100644 --- a/Engine/crates/engine-assets/Cargo.toml +++ b/Engine/crates/engine-assets/Cargo.toml @@ -10,6 +10,7 @@ publish = false base64 = "0.22" ufbx = "0.11.5" engine-core = { path = "../engine-core", version = "0.1.0" } +engine-world = { path = "../engine-world", version = "0.1.0" } gltf.workspace = true hound.workspace = true image.workspace = true diff --git a/Engine/crates/engine-assets/src/artifacts.rs b/Engine/crates/engine-assets/src/artifacts.rs index 83c47665..49926d56 100644 --- a/Engine/crates/engine-assets/src/artifacts.rs +++ b/Engine/crates/engine-assets/src/artifacts.rs @@ -202,6 +202,8 @@ pub enum DerivedArtifact { Audio(AudioArtifact), /// Font face. Font(FontArtifact), + /// Versioned reusable scene effects. + Effect(engine_world::effects::EffectAsset), /// Deterministic placeholder used before a first successful import. Placeholder(PlaceholderArtifact), } @@ -229,6 +231,8 @@ pub enum AssetKind { Audio, /// Font source. Font, + /// Reusable scene effects asset. + Effect, } impl DerivedArtifact { @@ -239,6 +243,7 @@ impl DerivedArtifact { Self::Model(_) => AssetKind::Model, Self::Audio(_) => AssetKind::Audio, Self::Font(_) => AssetKind::Font, + Self::Effect(_) => AssetKind::Effect, Self::Placeholder(value) => value.kind, } } diff --git a/Engine/crates/engine-assets/src/importers.rs b/Engine/crates/engine-assets/src/importers.rs index 6bd9ad2e..92195562 100644 --- a/Engine/crates/engine-assets/src/importers.rs +++ b/Engine/crates/engine-assets/src/importers.rs @@ -131,6 +131,11 @@ impl ImporterRegistry { version: 1, kind: AssetKind::Font, }), + "effect" => Ok(ImporterContract { + id: "rustic.effect", + version: 1, + kind: AssetKind::Effect, + }), _ => Err(AssetError::UnsupportedFormat(extension)), } } @@ -153,6 +158,11 @@ impl ImporterRegistry { "rustic.image" => import_image(request).map(DerivedArtifact::Texture), "rustic.audio" => import_audio(request).map(DerivedArtifact::Audio), "rustic.font" => import_font(request).map(DerivedArtifact::Font), + "rustic.effect" => { + engine_world::effects::EffectAsset::from_bytes(&request.source_bytes) + .map(DerivedArtifact::Effect) + .map_err(AssetError::InvalidData) + } _ => Err(AssetError::UnsupportedFormat(contract.id.to_owned())), })) .map_err(|_| AssetError::Decode("decoder panic was contained".to_owned()))??; @@ -1488,3 +1498,29 @@ mod tests { )); } } + +#[cfg(test)] +mod effect_import_tests { + use super::{AssetKind, DerivedArtifact, ImportRequest, ImporterRegistry}; + use std::path::Path; + #[test] + fn effect_assets_use_stable_typed_import_contract() { + let asset = engine_world::effects::presets().remove(0); + let contract = ImporterRegistry::contract(Path::new("effects/campfire.effect")).unwrap(); + assert_eq!(contract.id, "rustic.effect"); + assert_eq!(contract.kind, AssetKind::Effect); + let result = ImporterRegistry::import(&ImportRequest::new( + "effects/campfire.effect", + asset.to_bytes().unwrap(), + )) + .unwrap(); + assert!(matches!(result,DerivedArtifact::Effect(value) if value==asset)); + assert!( + ImporterRegistry::import(&ImportRequest::new( + "bad.effect", + b"(version:2,name:\"x\",effects:[])".to_vec() + )) + .is_err() + ); + } +} diff --git a/Engine/crates/engine-editor/Cargo.toml b/Engine/crates/engine-editor/Cargo.toml index 3a33456a..4d423b73 100644 --- a/Engine/crates/engine-editor/Cargo.toml +++ b/Engine/crates/engine-editor/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true publish = false [dependencies] +engine-assets = { path = "../engine-assets", version = "0.1.0" } engine-core = { path = "../engine-core", version = "0.1.0" } engine-platform = { path = "../engine-platform", version = "0.1.0" } engine-play = { path = "../engine-play", version = "0.1.0" } diff --git a/Engine/crates/engine-editor/src/document.rs b/Engine/crates/engine-editor/src/document.rs index 1f7442a2..29499891 100644 --- a/Engine/crates/engine-editor/src/document.rs +++ b/Engine/crates/engine-editor/src/document.rs @@ -16,6 +16,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use thiserror::Error; +mod effects; mod scene_files; #[derive(Debug, Error)] @@ -160,7 +161,7 @@ impl AuthoringDocument { document, recovery_read_only, recovered_from_backup, - scene_diagnostics, + mut scene_diagnostics, checksum_refresh_pending, ) = if let Some(loaded) = loaded { let checksum_refresh_pending = loaded.needs_checksum_refresh(); @@ -174,7 +175,8 @@ impl AuthoringDocument { } else { (SceneDocument::new("Main"), false, false, Vec::new(), false) }; - let world = document.create_world()?; + let mut world = document.create_world()?; + scene_diagnostics.extend(effects::refresh_sources(&mut world, project.root())); let mut undo = UndoStack::new(); undo.mark_saved(); Ok(Self { @@ -352,6 +354,17 @@ impl AuthoringDocument { .collect() } + /// Builds an isolated cosmetic preview world with the same source assets. + /// Gameplay systems and scripts do not run in this world. + /// # Errors + /// Returns an error if the current authored scene cannot be copied safely. + pub fn effect_preview_world(&self) -> Result { + let scene = SceneDocument::from_world(self.scene_id, &self.scene_name, &self.world)?; + let mut world = scene.create_world()?; + let _ = effects::refresh_sources(&mut world, self.project.root()); + Ok(world) + } + /// Creates a primitive through the centralized undo stack. /// /// # Errors @@ -611,6 +624,90 @@ impl AuthoringDocument { Ok(()) } + /// Replaces an entity's effect as one validated, undoable authoring edit. + /// Preview simulation is intentionally kept outside the saved document. + /// + /// # Errors + /// Returns an error for read-only documents, missing entities, or invalid effects. + pub fn set_effect( + &mut self, + entity: EntityId, + after: Option, + ) -> Result<(), AuthoringError> { + self.ensure_writable()?; + let before = self.world.snapshot(entity)?.effect; + if before != after { + self.undo.execute( + &mut self.world, + SceneEdit::Effect { + entity, + before, + after, + }, + )?; + } + Ok(()) + } + + /// Instantiates an effect asset as a selectable group of timed child entities. + /// All children are created or removed together by undo/redo. + /// + /// # Errors + /// Rejects invalid assets, read-only documents, and invalid parent references. + pub fn add_effect_asset( + &mut self, + asset: &engine_world::effects::EffectAsset, + parent: Option, + ) -> Result { + self.ensure_writable()?; + asset + .validate() + .map_err(AuthoringError::SceneFileOperation)?; + let group = EntitySnapshot { + name: Some(asset.name.clone()), + folder: true, + parent, + ..EntitySnapshot::default() + }; + let id = group.id; + let mut edits = vec![SceneEdit::Create { snapshot: group }]; + for mut snapshot in asset.instances_with_ids(None) { + snapshot.parent = Some(id); + edits.push(SceneEdit::Create { snapshot }); + } + self.undo.execute( + &mut self.world, + SceneEdit::Transaction { + label: format!("Instantiate {}", asset.name), + edits, + }, + )?; + self.selected = Some(id); + Ok(id) + } + + /// Adds one effect entity through the usual hierarchy and undo workflow. + /// # Errors + /// Returns an error for read-only access or an invalid effect or parent. + pub fn add_effect( + &mut self, + kind: engine_world::effects::EffectKind, + parent: Option, + ) -> Result { + self.ensure_writable()?; + let snapshot = EntitySnapshot { + name: Some(kind.name().into()), + effect: Some(engine_world::effects::EffectComponent::new(kind)), + parent, + ..EntitySnapshot::default() + }; + let id = snapshot.id; + self.undo + .execute(&mut self.world, SceneEdit::Create { snapshot })?; + self.selected = Some(id); + Ok(id) + } + /// # Errors /// Returns an error for read-only state or an absent entity. pub fn set_textures( @@ -765,6 +862,9 @@ impl AuthoringDocument { .map(|snapshot| { let mut copy = snapshot.clone(); copy.id = ids[&snapshot.id]; + if let Some(effect) = &mut copy.effect { + effect.remap_entities(&ids); + } copy.parent = snapshot .parent .and_then(|id| ids.get(&id).copied()) @@ -1872,3 +1972,153 @@ mod agent_authoring_tests { assert!(document.add_texture_child(folder).is_err()); } } + +#[cfg(test)] +mod effect_authoring_tests { + use super::*; + use engine_project::ProjectTemplate; + use engine_world::effects::*; + + #[test] + fn all_effect_types_persist_and_edits_undo_without_changing_other_components() { + let temporary = tempfile::tempdir().unwrap(); + let project = Project::create( + temporary.path().join("game"), + "Game", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project.clone(), false).unwrap(); + let mut expected = Vec::new(); + for kind in EffectKind::all_defaults() { + let id = document.add_effect(kind, None).unwrap(); + let before = document.world().snapshot(id).unwrap(); + let mut effect = before.effect.clone().unwrap(); + effect.common.random_seed = 12345; + document.set_effect(id, Some(effect.clone())).unwrap(); + document.undo().unwrap(); + assert_eq!(document.world().snapshot(id).unwrap(), before); + document.redo().unwrap(); + expected.push((id, effect)); + } + document.save().unwrap(); + let mut reopened = AuthoringDocument::open(project, true).unwrap(); + for (id, effect) in expected { + assert_eq!(reopened.world().effect(id).unwrap(), Some(effect)); + assert!(reopened.set_effect(id, None).is_err()); + } + } + + #[test] + fn composite_create_undo_redo_and_duplicate_remap_internal_effect_targets() { + let temporary = tempfile::tempdir().unwrap(); + let project = Project::create( + temporary.path().join("game"), + "Game", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + let asset = EffectAsset::new( + "Composite", + vec![ + EffectComponent::new(EffectKind::Particle(Default::default())), + EffectComponent::new(EffectKind::Beam(Default::default())), + ], + ); + let group = document.add_effect_asset(&asset, None).unwrap(); + let children = document.world().children(group).unwrap().to_vec(); + assert_eq!(children.len(), 2); + document.undo().unwrap(); + assert!(!document.world().contains(group)); + for child in &children { + assert!(!document.world().contains(*child)); + } + document.redo().unwrap(); + let particle = children + .iter() + .copied() + .find(|id| { + matches!( + document.world().effect(*id).unwrap().unwrap().kind, + EffectKind::Particle(_) + ) + }) + .unwrap(); + let beam = children.iter().copied().find(|id| *id != particle).unwrap(); + let mut effect = document.world().effect(beam).unwrap().unwrap(); + if let EffectKind::Beam(config) = &mut effect.kind { + config.end_target = Some(particle); + } + document.set_effect(beam, Some(effect)).unwrap(); + let tree = document.snapshot_tree(group).unwrap(); + let duplicate = document.paste_tree(&tree, None).unwrap().unwrap(); + let copied = document.world().children(duplicate).unwrap(); + let copied_particle = copied + .iter() + .copied() + .find(|id| { + matches!( + document.world().effect(*id).unwrap().unwrap().kind, + EffectKind::Particle(_) + ) + }) + .unwrap(); + let copied_beam = copied + .iter() + .copied() + .find(|id| *id != copied_particle) + .unwrap(); + match document.world().effect(copied_beam).unwrap().unwrap().kind { + EffectKind::Beam(config) => assert_eq!(config.end_target, Some(copied_particle)), + _ => panic!("copied beam type changed"), + } + assert_ne!(copied_particle, particle); + } +} + +#[cfg(test)] +mod shared_effect_source_tests { + use super::*; + use engine_world::effects::*; + #[test] + fn reopening_scene_refreshes_shared_defaults_preserving_saved_overrides() { + let temporary = tempfile::tempdir().unwrap(); + let project = Project::create( + temporary.path().join("game"), + "Game", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let path = project.root().join("assets/shared.effect"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let meta = engine_assets::AssetMeta::new("rustic.effect", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&path), &meta).unwrap(); + let mut asset = EffectAsset::new("Shared", vec![EffectComponent::default()]); + std::fs::write(&path, asset.to_bytes().unwrap()).unwrap(); + let mut document = AuthoringDocument::open(project.clone(), false).unwrap(); + let id = document.add_effect(EffectKind::default(), None).unwrap(); + let mut instance = asset.instantiate_from(meta.asset_id).remove(0); + instance.common.duration = 7.0; + document.set_effect(id, Some(instance)).unwrap(); + document.save().unwrap(); + asset.effects[0].common.duration = 10.0; + asset.effects[0].common.render_distance = 200.0; + std::fs::write(&path, asset.to_bytes().unwrap()).unwrap(); + let reopened = AuthoringDocument::open(project.clone(), false).unwrap(); + let instance = reopened.world().effect(id).unwrap().unwrap(); + assert_eq!(instance.common.duration, 7.0); + assert_eq!(instance.common.render_distance, 200.0); + assert!(reopened.scene_diagnostics().is_empty()); + std::fs::remove_file(path).unwrap(); + let missing = AuthoringDocument::open(project, false).unwrap(); + assert!(missing.world().effect(id).unwrap().is_some()); + assert!(!missing.is_read_only()); + assert!( + missing + .scene_diagnostics() + .iter() + .any(|diagnostic| diagnostic.component.as_deref() == Some("Effect")) + ); + } +} diff --git a/Engine/crates/engine-editor/src/document/effects.rs b/Engine/crates/engine-editor/src/document/effects.rs new file mode 100644 index 00000000..d1be65b0 --- /dev/null +++ b/Engine/crates/engine-editor/src/document/effects.rs @@ -0,0 +1,84 @@ +//! Shared assets refresh authored defaults while saved instance overrides survive. +use engine_world::{SceneDiagnostic, SceneDiagnosticSeverity, SceneWorld}; +use std::{collections::BTreeSet, path::Path}; + +pub(super) fn refresh_sources(world: &mut SceneWorld, root: &Path) -> Vec { + let mut diagnostics = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(folder) = pending.pop() { + let Ok(entries) = std::fs::read_dir(folder) else { + continue; + }; + for entry in entries.flatten() { + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_symlink() { + continue; + } + let path = entry.path(); + if kind.is_dir() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with('.') + && !matches!( + name.as_ref(), + "target" | "node_modules" | "temp" | "cache" | "dist" + ) + { + pending.push(path); + } + } else if kind.is_file() + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("effect")) + { + files.push(path); + } + } + } + files.sort(); + let mut loaded = BTreeSet::new(); + for path in files { + let sidecar = engine_assets::sidecar_path(&path); + if !sidecar.is_file() { + continue; + } + let result = (|| { + let meta = engine_assets::load_meta(&sidecar).map_err(|error| error.to_string())?; + if !loaded.insert(meta.asset_id) { + return Err(format!( + "Duplicate stable effect asset identity {}", + meta.asset_id + )); + } + let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; + let asset = engine_world::effects::EffectAsset::from_bytes(&bytes)?; + world + .register_effect_asset(meta.asset_id, asset) + .map_err(|error| error.to_string()) + })(); + if let Err(error) = result { + diagnostics.push(SceneDiagnostic { severity:SceneDiagnosticSeverity::Warning, entity:None, component:Some("Effect".into()), + message:format!("Could not refresh effect asset {}: {error}; saved configuration remains available",path.display()) }); + } + } + for entity in world.entity_ids() { + if let Ok(Some(effect)) = world.effect(entity) + && let Some(source) = effect.source + && !world.has_effect_asset(source.asset) + { + diagnostics.push(SceneDiagnostic { + severity: SceneDiagnosticSeverity::Warning, + entity: Some(entity), + component: Some("Effect".into()), + message: format!( + "Shared effect asset {} is unavailable; using saved instance configuration", + source.asset + ), + }); + } + } + diagnostics +} diff --git a/Engine/crates/engine-play/src/effect_services.rs b/Engine/crates/engine-play/src/effect_services.rs new file mode 100644 index 00000000..153bc757 --- /dev/null +++ b/Engine/crates/engine-play/src/effect_services.rs @@ -0,0 +1,388 @@ +//! Effect requests use the same live-world boundary as other gameplay services. +use engine_world::effects::{EffectComponent, EffectKind, PlaybackCommand}; +use engine_world::{EntityId, SceneWorld, WorldCommand}; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[derive(Deserialize)] +#[serde(tag = "op", deny_unknown_fields)] +enum Query { + #[serde(rename = "effect_types")] + Types, + #[serde(rename = "effect_wind")] + Wind { + position: [f32; 3], + time: f32, + layers: u32, + }, + #[serde(rename = "effect_spawn")] + Spawn { + asset: engine_world::AssetId, + #[serde(default)] + parent: Option, + position: [f32; 3], + lifetime: f64, + }, + #[serde(rename = "effect_instantiate")] + Instantiate { + asset: engine_world::AssetId, + #[serde(default)] + parent: Option, + #[serde(default)] + position: [f32; 3], + #[serde(default)] + lifetime: Option, + }, + #[serde(rename = "effect_create")] + Create { entity: EntityId, kind: String }, + #[serde(rename = "effect_get")] + Get { entity: EntityId }, + #[serde(rename = "effect_configure")] + Configure { entity: EntityId, settings: Value }, + #[serde(rename = "effect_control")] + Control { entity: EntityId, action: String }, + #[serde(rename = "effect_state")] + State { entity: EntityId }, +} + +pub(crate) fn query(world: &mut SceneWorld, value: Value) -> Result { + let query: Query = + serde_json::from_value(value).map_err(|e| format!("invalid effect request: {e}"))?; + match query { + Query::Spawn { + asset, + parent, + position, + lifetime, + } => self::query( + world, + json!({"op":"effect_instantiate","asset":asset,"parent":parent,"position":position,"lifetime":lifetime}), + ), + Query::Instantiate { + asset, + parent, + position, + lifetime, + } => { + if lifetime.is_some_and(|v| !v.is_finite() || v <= 0.0) { + return Err("effect spawn lifetime must be finite and positive".into()); + } + if !position.iter().all(|v| v.is_finite()) { + return Err("effect position must be finite".into()); + } + let transform = engine_world::LocalTransform { + translation: glam::Vec3::from_array(position), + ..engine_world::LocalTransform::IDENTITY + }; + let id = world + .instantiate_effect_asset(asset, parent, transform) + .map_err(|e| e.to_string())?; + if let Some(lifetime) = lifetime { + world + .destroy_after(id, lifetime) + .map_err(|e| e.to_string())?; + } + Ok(json!(id)) + } + Query::Wind { + position, + time, + layers, + } => { + if !position.iter().all(|v| v.is_finite()) || !time.is_finite() { + return Err("wind sample position and time must be finite".into()); + } + world.propagate_transforms(); + Ok(json!( + world + .sample_effect_wind(glam::Vec3::from_array(position), time, layers) + .to_array() + )) + } + Query::Types => Ok(json!( + EffectKind::all_defaults() + .iter() + .map(kind_key) + .collect::>() + )), + Query::Get { entity } => { + serde_json::to_value(world.effect(entity).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) + } + Query::Create { entity, kind } => { + if world.effect(entity).map_err(|e| e.to_string())?.is_some() { + return Err( + "entity already has an effect; configure it or dispose it first".into(), + ); + } + let kind = EffectKind::all_defaults() + .into_iter() + .find(|k| kind_key(k) == kind) + .ok_or_else(|| { + format!("unknown effect kind `{kind}`; query effect_types for supported names") + })?; + save(world, entity, EffectComponent::new(kind)) + } + Query::Configure { entity, settings } => { + let effect = world + .effect(entity) + .map_err(|e| e.to_string())? + .ok_or("entity has no effect")?; + let mut value = serde_json::to_value(effect).map_err(|e| e.to_string())?; + patch(&mut value, &settings, "effect")?; + let effect = serde_json::from_value(value) + .map_err(|e| format!("invalid effect settings: {e}"))?; + save(world, entity, effect) + } + Query::Control { entity, action } => control(world, entity, &action), + Query::State { entity } => Ok(world + .effect_state(entity) + .map_err(|e| e.to_string())? + .map_or(Value::Null, |state| json!(format!("{state:?}")))), + } +} + +fn control(world: &mut SceneWorld, entity: EntityId, action: &str) -> Result { + let command = match action { + "play" => PlaybackCommand::Play, + "pause" => PlaybackCommand::Pause, + "resume" => PlaybackCommand::Resume, + "stop" => PlaybackCommand::Stop, + "restart" => PlaybackCommand::Restart, + "reset" => PlaybackCommand::Reset, + "dispose" => { + world + .control_effect(entity, PlaybackCommand::Dispose) + .map_err(|e| e.to_string())?; + let mut entities = vec![entity]; + entities.extend(world.descendants(entity).map_err(|e| e.to_string())?); + let commands = entities + .into_iter() + .filter(|id| world.effect(*id).ok().flatten().is_some()) + .map(|entity| WorldCommand::SetEffect { + entity, + value: None, + }) + .collect::>(); + world.apply_commands(&commands).map_err(|e| e.to_string())?; + return Ok(Value::Null); + } + _ => return Err(format!("unknown effect action `{action}`")), + }; + world + .control_effect(entity, command) + .map_err(|e| e.to_string())?; + Ok(Value::Null) +} + +fn kind_key(kind: &EffectKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|v| v.as_object().and_then(|o| o.keys().next().cloned())) + .unwrap_or_default() +} + +pub(crate) fn references(effect: &EffectComponent) -> Vec { + let mut references = effect + .source + .as_ref() + .map(|s| s.asset) + .into_iter() + .collect::>(); + match &effect.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => references.extend(p.sub_emitter), + EffectKind::Weather(w) => references.extend(w.emitter.sub_emitter), + EffectKind::CollisionVolume(c) => references.extend(c.impact_effect), + _ => {} + } + references +} + +fn save( + world: &mut SceneWorld, + entity: EntityId, + effect: EffectComponent, +) -> Result { + effect.validate()?; + for reference in references(&effect) { + if !world.has_effect_asset(reference) { + return Err(format!( + "effect asset `{reference}` is not loaded; add a valid .effect source and stable .rmeta sidecar before starting the scene" + )); + } + } + let value = serde_json::to_value(&effect).map_err(|e| e.to_string())?; + world + .apply_commands(&[WorldCommand::SetEffect { + entity, + value: Some(Box::new(effect)), + }]) + .map_err(|e| e.to_string())?; + Ok(value) +} + +/// Merge only known fields, rejecting typos before mutating the world. +fn patch(target: &mut Value, settings: &Value, path: &str) -> Result<(), String> { + let fields = settings + .as_object() + .ok_or("effect settings must be an object")?; + let slots = target + .as_object_mut() + .ok_or_else(|| format!("`{path}` is not an object"))?; + for (name, value) in fields { + let slot = slots + .get_mut(name) + .ok_or_else(|| format!("unknown effect property `{path}.{name}`"))?; + if value.is_object() && slot.is_object() { + patch(slot, value, &format!("{path}.{name}"))?; + } else { + *slot = value.clone(); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn effect_queries_configure_live_simulation_and_dispose_without_destroying_entity() { + let mut world = SceneWorld::new(); + let entity = engine_world::EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + assert_eq!( + query(&mut world, json!({"op":"effect_types"})) + .unwrap() + .as_array() + .unwrap() + .len(), + 26 + ); + query( + &mut world, + json!({"op":"effect_create","entity":id,"kind":"Particle"}), + ) + .unwrap(); + query(&mut world,json!({"op":"effect_configure","entity":id,"settings":{"kind":{"Particle":{"rate":60}}}})).unwrap(); + let original = world.effect(id).unwrap(); + for settings in [ + json!({"common":{"duration":0}}), + json!({"kind":{"Particle":{"raet":3}}}), + ] { + assert!( + query( + &mut world, + json!({"op":"effect_configure","entity":id,"settings":settings}) + ) + .is_err() + ); + assert_eq!(world.effect(id).unwrap(), original); + } + world.propagate_transforms(); + query( + &mut world, + json!({"op":"effect_control","entity":id,"action":"play"}), + ) + .unwrap(); + world.update_effects(0.1); + assert!(!world.effect_frame().particles.is_empty()); + query( + &mut world, + json!({"op":"effect_control","entity":id,"action":"pause"}), + ) + .unwrap(); + assert_eq!( + query(&mut world, json!({"op":"effect_state","entity":id})).unwrap(), + "Paused" + ); + query( + &mut world, + json!({"op":"effect_control","entity":id,"action":"dispose"}), + ) + .unwrap(); + world.update_effects(0.1); + assert!(world.effect_frame().particles.is_empty()); + assert!(world.effect(id).unwrap().is_none()); + assert!(world.contains(id)); + } + + #[test] + fn temporary_asset_instances_keep_independent_configuration_and_cleanup() { + let mut world = SceneWorld::new(); + let asset = engine_world::AssetId::new(); + world + .register_effect_asset( + asset, + engine_world::effects::EffectAsset::new("Sparks", vec![EffectComponent::default()]), + ) + .unwrap(); + assert!( + query( + &mut world, + json!({"op":"effect_spawn","asset":asset,"position":[0,0,0]}) + ) + .is_err() + ); + assert_eq!(world.entity_ids().count(), 0); + let first: EntityId = serde_json::from_value( + query( + &mut world, + json!({"op":"effect_spawn","asset":asset,"position":[1,2,3],"lifetime":0.5}), + ) + .unwrap(), + ) + .unwrap(); + let second: EntityId = serde_json::from_value( + query(&mut world, json!({"op":"effect_instantiate","asset":asset})).unwrap(), + ) + .unwrap(); + assert_ne!(first, second); + query( + &mut world, + json!({"op":"effect_control","entity":second,"action":"pause"}), + ) + .unwrap(); + assert_eq!( + query(&mut world, json!({"op":"effect_state","entity":second})).unwrap(), + "Paused" + ); + world.tick_entity_lifetimes(0.5).unwrap(); + assert!(!world.contains(first)); + assert!(world.contains(second)); + query( + &mut world, + json!({"op":"effect_control","entity":second,"action":"dispose"}), + ) + .unwrap(); + assert!(world.contains(second)); + assert!( + world + .descendants(second) + .unwrap() + .iter() + .all(|id| world.effect(*id).unwrap().is_none()) + ); + } + + #[test] + fn nested_patch_rejects_unknown_properties_without_touching_original() { + let original = json!({"common":{"duration":2.0},"kind":{"Particle":{"rate":10.0}}}); + let mut value = original.clone(); + patch( + &mut value, + &json!({"kind":{"Particle":{"rate":20.0}}}), + "effect", + ) + .unwrap(); + assert_eq!(value["kind"]["Particle"]["rate"], 20.0); + assert_eq!(original["kind"]["Particle"]["rate"], 10.0); + assert!( + patch(&mut value, &json!({"common":{"duraton":1}}), "effect") + .unwrap_err() + .contains("duraton") + ); + } +} diff --git a/Engine/crates/engine-play/src/lib.rs b/Engine/crates/engine-play/src/lib.rs index 97ef3360..45fbbaac 100644 --- a/Engine/crates/engine-play/src/lib.rs +++ b/Engine/crates/engine-play/src/lib.rs @@ -5,6 +5,7 @@ //! runtime, tests, and future command-line tooling. pub mod changes; +mod effect_services; mod entity_services; pub mod frame_ring; #[cfg(feature = "native")] diff --git a/Engine/crates/engine-play/src/protocol.rs b/Engine/crates/engine-play/src/protocol.rs index 48118922..518e5cc3 100644 --- a/Engine/crates/engine-play/src/protocol.rs +++ b/Engine/crates/engine-play/src/protocol.rs @@ -37,7 +37,7 @@ pub struct ProtocolVersion { } /// Protocol understood by this engine build. -pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 3, minor: 1 }; +pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 3, minor: 2 }; /// Current values for the selected entity while the isolated runtime is active. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -49,6 +49,8 @@ pub struct LiveEntityProperties { pub scale: [f32; 3], pub part_attributes: PartAttributes, pub scripts: Vec, + #[serde(default)] + pub effect: Option, } /// Process responsibility carried in every frame and authenticated handshake. @@ -218,7 +220,7 @@ pub enum ProtocolMessage { ResetPointerInput, ResizeViewport([u32; 2]), QueryEntity(String), - LiveEntity(Option), + LiveEntity(Option>), ControlAck(ControlAck), Console(Box), Frame(BgraFrame), @@ -554,6 +556,12 @@ fn unix_millis() -> u64 { #[cfg(test)] mod tests { + #[test] + fn older_live_entity_payloads_default_to_no_effect() { + let payload = serde_json::json!({"entity_id":"entity","name":null,"translation":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1],"part_attributes":engine_world::PartAttributes::default(),"scripts":[]}); + let properties: super::LiveEntityProperties = serde_json::from_value(payload).unwrap(); + assert!(properties.effect.is_none()); + } use super::*; use std::io::Cursor; diff --git a/Engine/crates/engine-play/src/runtime_server.rs b/Engine/crates/engine-play/src/runtime_server.rs index c8a47f8b..bec086c4 100644 --- a/Engine/crates/engine-play/src/runtime_server.rs +++ b/Engine/crates/engine-play/src/runtime_server.rs @@ -236,7 +236,10 @@ fn run_authenticated_session( .map_err(RuntimeServerError::Scripts)?, None => None, }; - connection.send(received.request_id, &ProtocolMessage::LiveEntity(live))?; + connection.send( + received.request_id, + &ProtocolMessage::LiveEntity(live.map(Box::new)), + )?; continue; } ProtocolMessage::ReloadScript { diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 15d617a0..b2ffa81a 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -297,6 +297,7 @@ impl SceneRuntime { scale: snapshot.local_transform.scale.to_array(), part_attributes: snapshot.part_attributes, scripts, + effect: snapshot.effect, })) } #[allow(clippy::too_many_lines)] @@ -324,6 +325,7 @@ impl SceneRuntime { { let mut world = world.lock().map_err(|_| "world lock poisoned")?; let mut context = gameplay.lock().map_err(|_| "gameplay lock poisoned")?; + load_effect_assets(snapshot_root, &mut world, &logs, &dropped_logs); let entities = world.entity_ids().collect::>(); for entity in entities { let Some(mesh) = world.mesh(entity).map_err(|e| e.to_string())? else { @@ -446,6 +448,15 @@ impl SceneRuntime { behaviors.push(behavior); } } + { + let mut world = world.lock().map_err(|_| "world lock poisoned")?; + world.propagate_transforms(); + world.update_effects(0.0); + let mut context = gameplay.lock().map_err(|_| "gameplay lock poisoned")?; + if let Err(error) = emit_effect_events(&world, &mut context.runtime.signals) { + push_log(&logs, &dropped_logs, "warn", &error); + } + } Ok(Self { gameplay, behaviors, @@ -511,14 +522,20 @@ impl SceneRuntime { context .physics .step(&mut world, scaled) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + world.propagate_transforms(); + #[allow(clippy::cast_possible_truncation, reason = "cosmetic simulation stores seconds as f32 and bounds its integration steps")] + let effect_delta = scaled as f32; + world.update_effects(effect_delta); + emit_effect_events(&world, &mut context.runtime.signals)?; + Ok(()) }) { push_log( &self.logs, &self.dropped_logs, "error", - &format!("physics step failed: {error}"), + &format!("scene simulation step failed: {error}"), ); } } @@ -1152,6 +1169,10 @@ impl GameplayHost for RuntimeHost { Some(EngineValue::Entity(Some(value))) => serde_json::json!(value), }); } + if op.starts_with("effect_") { + let mut world = self.world.lock().map_err(|_| "world lock poisoned")?; + return crate::effect_services::query(&mut world, query); + } if crate::entity_services::handles(op) { let mut world = self.world.lock().map_err(|_| "world lock poisoned")?; return crate::entity_services::query(&mut world, query); @@ -2315,6 +2336,135 @@ impl RuntimeHost { } } +fn emit_effect_events( + world: &SceneWorld, + signals: &mut engine_core::gameplay::Signals, +) -> Result<(), String> { + for event in &world.effect_frame().events { + let name = match event.kind { + engine_world::effects::EffectEventKind::Birth => "EffectBirth", + engine_world::effects::EffectEventKind::Collision => "EffectCollision", + engine_world::effects::EffectEventKind::Death => "EffectDeath", + engine_world::effects::EffectEventKind::Completed => "EffectCompleted", + }; + signals.emit( + &engine_core::gameplay::Signal { + name: name.into(), + source: Some(event.entity), + }, + vec![engine_core::gameplay::Payload::Value( + engine_core::gameplay::Value::Vector(event.position.as_dvec3().to_array()), + )], + )?; + } + Ok(()) +} + +/// Effect sources are indexed through the same authoritative stable sidecars as +/// other assets. Invalid or missing cosmetics log a warning and retain the scene. +fn load_effect_assets( + root: &Path, + world: &mut SceneWorld, + logs: &Arc>>, + dropped_logs: &Arc>, +) { + let scan = match engine_assets::scan_sidecars(root) { + Ok(scan) => scan, + Err(error) => { + push_log( + logs, + dropped_logs, + "warn", + &format!("effect asset scan skipped: {error}"), + ); + return; + } + }; + for (path, error) in &scan.corrupt { + if path.to_string_lossy().ends_with(".effect.rmeta") { + push_log( + logs, + dropped_logs, + "warn", + &format!("effect metadata {} skipped: {error}", path.display()), + ); + } + } + for paths in scan.duplicates.values() { + if paths.iter().any(|p| { + p.extension() + .and_then(|v| v.to_str()) + .is_some_and(|v| v.eq_ignore_ascii_case("effect")) + }) { + push_log( + logs, + dropped_logs, + "warn", + "effect assets with duplicate stable IDs were skipped; regenerate metadata for copied assets", + ); + } + } + let mut referenced_assets = BTreeSet::new(); + for entry in scan.entries { + if !entry + .source_relative + .extension() + .and_then(|v| v.to_str()) + .is_some_and(|v| v.eq_ignore_ascii_case("effect")) + { + continue; + } + let path = root.join(&entry.source_relative); + let loaded = (|| { + let metadata = std::fs::symlink_metadata(&path).map_err(|e| e.to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("effect source must be a regular file".to_string()); + } + if metadata.len() > 4 * 1024 * 1024 { + return Err("effect asset exceeds 4 MiB".to_string()); + } + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + let asset = engine_world::effects::EffectAsset::from_bytes(&bytes)?; + for effect in &asset.effects { + referenced_assets.extend(crate::effect_services::references(effect)); + } + world + .register_effect_asset(entry.meta.asset_id, asset) + .map_err(|e| e.to_string()) + })(); + if let Err(error) = loaded { + push_log( + logs, + dropped_logs, + "warn", + &format!( + "effect asset {} skipped: {error}", + entry.source_relative.display() + ), + ); + } + } + referenced_assets.extend( + world + .entity_ids() + .filter_map(|id| world.effect(id).ok().flatten()) + .flat_map(|effect| crate::effect_services::references(&effect)), + ); + let missing = referenced_assets + .into_iter() + .filter(|id| !world.has_effect_asset(*id)); + for id in missing { + push_log( + logs, + dropped_logs, + "warn", + &format!( + "scene references missing effect asset {id}; authored settings are retained, referenced child effects cannot spawn" + ), + ); + } +} + fn bind_model( world: &mut SceneWorld, context: &mut GameplayContext, @@ -2599,6 +2749,170 @@ rustic_run(['on_start'=>function(){global $rustic;Input::onClick($rustic->entity } } + #[test] + fn effect_runtime_configuration_is_visible_to_live_editor_inspection() { + let temp = tempfile::tempdir().unwrap(); + let entity = EntitySnapshot { + effect: Some(engine_world::EffectComponent::new( + engine_world::effects::EffectKind::Weather( + engine_world::effects::WeatherVolume::default(), + ), + )), + ..Default::default() + }; + let id = entity.id; + let mut scene = SceneDocument::new("Weather"); + scene.entities.push(entity); + let runtime = RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + let mut host = environment_host(&runtime.active.world); + host.gameplay_query(serde_json::json!({"op":"effect_configure","entity":id,"settings":{"kind":{"Weather":{"intensity":0.25}}}})).unwrap(); + let live = runtime.live_entity(id).unwrap().unwrap(); + let engine_world::effects::EffectKind::Weather(weather) = live.effect.unwrap().kind else { + panic!("weather component missing"); + }; + assert!((weather.intensity - 0.25).abs() < f32::EPSILON); + let engine_world::effects::EffectKind::Weather(authored) = + scene.entities[0].effect.as_ref().unwrap().kind.clone() + else { + panic!("authored component changed"); + }; + assert!((authored.intensity - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn effect_asset_sidecars_load_by_stable_id_and_invalid_cosmetics_only_warn() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("sparks.effect"); + let asset = engine_world::effects::EffectAsset::new( + "Sparks", + vec![engine_world::EffectComponent::default()], + ); + std::fs::write(&path, asset.to_bytes().unwrap()).unwrap(); + let meta = engine_assets::AssetMeta::new("rustic.effect", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&path), &meta).unwrap(); + let invalid = temp.path().join("invalid.effect"); + std::fs::write(&invalid, b"invalid RON").unwrap(); + engine_assets::save_meta( + &engine_assets::sidecar_path(&invalid), + &engine_assets::AssetMeta::new("rustic.effect", 1), + ) + .unwrap(); + let logs = Arc::new(Mutex::new(VecDeque::new())); + let dropped = Arc::new(Mutex::new(0)); + let mut world = SceneWorld::new(); + load_effect_assets(temp.path(), &mut world, &logs, &dropped); + assert!(world.has_effect_asset(meta.asset_id)); + assert!( + logs.lock() + .unwrap() + .iter() + .any(|(level, message)| level == "warn" && message.contains("invalid.effect")) + ); + let root = world + .instantiate_effect_asset(meta.asset_id, None, engine_world::LocalTransform::IDENTITY) + .unwrap(); + assert_eq!(world.descendants(root).unwrap().len(), 1); + } + + #[test] + fn non_looping_effect_completion_uses_existing_scene_signal_delivery() { + let mut effect = engine_world::EffectComponent::default(); + effect.common.looping = false; + effect.common.duration = 0.1; + let entity = EntitySnapshot { + effect: Some(effect), + ..Default::default() + }; + let id = entity.id; + let mut world = SceneWorld::new(); + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + world.propagate_transforms(); + let owner = Owner { + entity_bound: true, + scene: engine_core::SceneId::new(), + entity: id, + script: ScriptId::new(), + }; + let mut signals = engine_core::gameplay::Signals::default(); + signals + .connect( + owner, + "complete".into(), + engine_core::gameplay::Signal { + name: "EffectCompleted".into(), + source: Some(id), + }, + true, + ) + .unwrap(); + world.update_effects(0.2); + emit_effect_events(&world, &mut signals).unwrap(); + let delivered = signals.drain(owner); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].source, Some(id)); + world.update_effects(0.2); + emit_effect_events(&world, &mut signals).unwrap(); + assert!(signals.drain(owner).is_empty()); + } + + #[test] + fn scene_effects_wrappers_mutate_the_same_live_component() { + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + (ScriptLanguage::Lua54, "effects.lua", br"return {Start=function() + Effects.create(nil,'Particle') + Effects.configure(nil,{common={looping=false},kind={Particle={rate=55}}}) + assert(Effects.get(nil).kind.Particle.rate==55) + assert(#Effects.types()==26) + Effects.play(nil); Effects.pause(nil) + assert(Effects.state(nil)=='Paused') + end}"), + (ScriptLanguage::JavaScript, "effects.js", br"globalThis.behavior={Start(){ + Effects.create(null,'Particle'); + Effects.configure(null,{common:{looping:false},kind:{Particle:{rate:55}}}); + if(Effects.get(null).kind.Particle.rate!==55 || Effects.types().length!==26)throw Error('config'); + Effects.play(null);Effects.pause(null); + if(Effects.state(null)!=='Paused')throw Error('state'); + }};"), + (ScriptLanguage::Python, "effects.py", br"from rustic import * +def on_start(): + Effects.create(None,'Particle') + Effects.configure(None,{'common':{'looping':False},'kind':{'Particle':{'rate':55}}}) + assert Effects.get().get('kind').get('Particle').get('rate')==55 + assert len(Effects.types())==26 + Effects.play(); Effects.pause() + assert Effects.state()=='Paused' +run(globals())"), + ]; + for (language, name, source) in fixtures { + if *language == ScriptLanguage::Python + && !engine_scripting::probe_language_toolchain(*language).available + { + continue; + } + let mut world = SceneWorld::new(); + let entity = EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + let world = Arc::new(Mutex::new(world)); + let mut host = environment_host(&world); + host.entity = id; + let mut behavior = + RuntimeBehavior::load(*language, ScriptId::new(), source, name, Box::new(host)) + .unwrap(); + behavior + .on_start() + .unwrap_or_else(|e| panic!("{} effects: {e}", language.display_name())); + assert!(matches!( + world.lock().unwrap().effect(id).unwrap().unwrap().kind, + engine_world::effects::EffectKind::Particle(_) + )); + } + } + #[test] fn scene_lifecycle_works_in_every_available_language() { let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ diff --git a/Engine/crates/engine-play/src/supervisor.rs b/Engine/crates/engine-play/src/supervisor.rs index 0cbe9924..d712769c 100644 --- a/Engine/crates/engine-play/src/supervisor.rs +++ b/Engine/crates/engine-play/src/supervisor.rs @@ -292,7 +292,7 @@ impl SupervisedRuntime { let received = connection.receive()?; match received.message { ProtocolMessage::LiveEntity(entity) if received.request_id == request_id => { - return Ok(entity); + return Ok(entity.map(|entity| *entity)); } ProtocolMessage::Console(event) => self.console_events.push_back(*event), ProtocolMessage::Frame(frame) => publish_frame(&mut self.frames, &frame)?, diff --git a/Engine/crates/engine-project/Cargo.toml b/Engine/crates/engine-project/Cargo.toml index ac54c6ae..f906f4bc 100644 --- a/Engine/crates/engine-project/Cargo.toml +++ b/Engine/crates/engine-project/Cargo.toml @@ -18,6 +18,7 @@ thiserror.workspace = true uuid.workspace = true [dev-dependencies] +engine-assets = { path = "../engine-assets", version = "0.1.0" } tempfile.workspace = true [lints] diff --git a/Engine/crates/engine-project/examples/scene_effects_showcase.rs b/Engine/crates/engine-project/examples/scene_effects_showcase.rs new file mode 100644 index 00000000..c502db0b --- /dev/null +++ b/Engine/crates/engine-project/examples/scene_effects_showcase.rs @@ -0,0 +1,333 @@ +//! Generate an editable Rustic project using the public project/scene APIs. +//! cargo run -p engine-project --example scene_effects_showcase -- /tmp/effects-showcase +use engine_project::{Project, ProjectTemplate, VirtualDirectory}; +use engine_scripting::{ + ScriptApiVersion, ScriptId, ScriptLanguage, ScriptManifest, ScriptManifestEntry, + save_manifest_atomic, +}; +use engine_world::effects::{EffectComponent, EffectKind, Gradient, GradientKey, presets}; +use engine_world::{ + Camera, EntitySnapshot, Light, LightKind, LocalTransform, PartAttributes, Primitive, + SceneDocument, save_scene_atomic, +}; +use glam::{Quat, Vec3}; +use std::error::Error; +use std::path::PathBuf; + +fn transform(position: Vec3) -> LocalTransform { + LocalTransform { + translation: position, + ..Default::default() + } +} +fn pedestal(name: String, position: Vec3) -> EntitySnapshot { + EntitySnapshot { + name: Some(name), + local_transform: transform(position - Vec3::Y * 0.15), + primitive: Some(Primitive::Cylinder { + radius: 2.0, + height: 0.2, + segments: 48, + }), + part_attributes: PartAttributes { + color: [0.07, 0.09, 0.13, 1.0], + anchored: true, + ..Default::default() + }, + ..Default::default() + } +} +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "RGBE encoding intentionally quantizes bounded positive generated color channels to bytes" +)] +fn panorama_bytes() -> Vec { + let mut hdr = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 64 +X 128\n".to_vec(); + for y in 0_u8..64 { + for x in 0_u8..128 { + let horizon = 1.0 - ((f32::from(y) / 63.0 - 0.5) * 2.0).abs(); + let sun = if (i32::from(x) - 38).abs() < 4 && (i32::from(y) - 22).abs() < 4 { + 1.0 + } else { + 0.0 + }; + let rgb = [ + 0.08 + 0.2 * horizon + 3.0 * sun, + 0.12 + 0.3 * horizon + 2.4 * sun, + 0.2 + 0.5 * horizon + 1.5 * sun, + ]; + let maximum = rgb.into_iter().fold(0.0_f32, f32::max); + let exponent = maximum.log2().floor() as i32 + 1; + let scale = 256.0 / 2.0f32.powi(exponent); + hdr.extend([ + (rgb[0] * scale) as u8, + (rgb[1] * scale) as u8, + (rgb[2] * scale) as u8, + (exponent + 128) as u8, + ]); + } + } + hdr +} +#[expect( + clippy::too_many_lines, + reason = "this example keeps the 26 authored showcase configurations and their source references together" +)] +fn main() -> Result<(), Box> { + let destination = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp/rustic-effects-showcase")); + let mut project = Project::create( + &destination, + "Scene Effects Showcase", + ProjectTemplate::Blank, + )?; + let mut scene = SceneDocument::new("Scene Effects Showcase"); + // A locally generated equirectangular HDR panorama exercises baked probes. + let panorama = destination.join("assets/showcase-probe.hdr"); + let hdr = panorama_bytes(); + std::fs::write(&panorama, hdr)?; + let debris = destination.join("assets/debris.obj"); + std::fs::write( + &debris, + "v 0 0.7 0\nv -0.4 0 -0.3\nv 0.4 0 -0.3\nv 0 0 0.5\nf 1 3 2\nf 1 4 3\nf 1 2 4\nf 2 3 4\n", + )?; + let debris_meta = engine_assets::AssetMeta::new("rustic.obj", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&debris), &debris_meta)?; + let leaf = destination.join("assets/foliage.obj"); + std::fs::write( + &leaf, + "v 0 0 0\nv -0.35 0.5 0\nv 0 1.5 0\nv 0.35 0.5 0\nvt 0.5 0\nvt 0 0.5\nvt 0.5 1\nvt 1 0.5\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\nf 3/3 2/2 1/1\nf 4/4 3/3 1/1\n", + )?; + let leaf_meta = engine_assets::AssetMeta::new("rustic.obj", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&leaf), &leaf_meta)?; + let probe_meta = engine_assets::AssetMeta::new("rustic.image", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&panorama), &probe_meta)?; + + let orbit_script = ScriptId::new(); + std::fs::write( + destination.join("scripts/orbit.lua"), + r#"local origin, elapsed +return { + Start = function() local x,y,z=rustic.get_translation();origin={x,y,z};elapsed=0 end, + FixedUpdate = function(dt) + elapsed=elapsed+dt + rustic.set_translation(origin[1]+math.cos(elapsed*1.8)*1.1,origin[2]+1+math.sin(elapsed*2.0)*0.2,origin[3]+math.sin(elapsed*1.8)*1.1) + end, +} +"#, + )?; + save_manifest_atomic( + &destination.join("config/scripts.ron"), + &ScriptManifest { + format_version: engine_scripting::CURRENT_SCRIPT_MANIFEST_VERSION, + scripts: vec![ScriptManifestEntry { + id: orbit_script, + language: ScriptLanguage::Lua54, + relative_path: "scripts/orbit.lua".into(), + api_version: ScriptApiVersion::CURRENT, + public_properties: vec![], + }], + }, + )?; + scene.entities.push(EntitySnapshot { + name: Some("Showcase Camera".into()), + local_transform: LocalTransform { + translation: Vec3::new(12.5, 18.0, -25.0), + rotation: Quat::from_rotation_x(0.38), + ..Default::default() + }, + camera: Some(Camera::default()), + ..Default::default() + }); + scene.entities.push(EntitySnapshot { + name: Some("Key light".into()), + local_transform: LocalTransform { + rotation: Quat::from_euler(glam::EulerRot::XYZ, -0.7, -0.4, 0.0), + ..Default::default() + }, + light: Some(Light { + kind: LightKind::Directional, + color: Vec3::new(1.0, 0.9, 0.75), + intensity: 2.0, + ..Default::default() + }), + ..Default::default() + }); + scene.entities.push(EntitySnapshot { + name: Some("Showcase floor".into()), + local_transform: transform(Vec3::new(12.5, -0.3, 10.0)), + primitive: Some(Primitive::Plane { size: [45.0, 40.0] }), + part_attributes: PartAttributes { + color: [0.12, 0.14, 0.18, 1.0], + anchored: true, + ..Default::default() + }, + ..Default::default() + }); + for (index, mut kind) in EffectKind::all_defaults().into_iter().enumerate() { + let position = Vec3::new( + f32::from(u16::try_from(index % 6).unwrap_or_default()) * 5.0, + 0.0, + f32::from(u16::try_from(index / 6).unwrap_or_default()) * 5.0, + ); + let name = format!("{:02} {}", index + 1, kind.name()); + scene + .entities + .push(pedestal(format!("{name} pedestal"), position)); + let target = EntitySnapshot { + name: Some(format!("{name} target")), + local_transform: transform(position + Vec3::Y), + primitive: Some(Primitive::Sphere { + radius: 0.6, + segments: 24, + rings: 16, + }), + part_attributes: PartAttributes { + color: [0.2, 0.45, 0.65, 1.0], + anchored: true, + ..Default::default() + }, + ..Default::default() + }; + let target_id = target.id; + let light = EntitySnapshot { + name: Some(format!("{name} point light")), + local_transform: transform(position + Vec3::Y * 2.0), + light: Some(Light { + kind: LightKind::Point, + intensity: 4.0, + range: 5.0, + ..Default::default() + }), + ..Default::default() + }; + let light_id = light.id; + match &mut kind { + EffectKind::Beam(v) => { + v.end = [2.0, 2.0, 0.0]; + v.appearance.color = Gradient::constant([0.25, 0.5, 1.0, 1.0]); + } + EffectKind::ForceField(v) | EffectKind::Wind(v) => { + v.dimensions = [3.0; 3]; + v.strength = 2.0; + } + EffectKind::Weather(v) => { + v.emitter.dimensions = [3.0, 4.0, 3.0]; + v.emitter.max_particles = 300; + } + EffectKind::Fog(v) => { + v.dimensions = [3.0, 2.0, 3.0]; + v.density = 0.08; + } + EffectKind::Dissolve(v) => v.target = Some(target_id), + EffectKind::AnimatedSurface(v) => { + v.target = Some(target_id); + v.color = Gradient { + keys: vec![ + GradientKey { + time: 0.0, + color: [0.1, 0.4, 1.0, 1.0], + }, + GradientKey { + time: 1.0, + color: [1.0, 0.2, 0.5, 1.0], + }, + ], + }; + } + EffectKind::LightPulse(v) => v.target = Some(light_id), + EffectKind::LightShaft(v) => v.light = Some(light_id), + EffectKind::LensFlare(v) => v.light = Some(light_id), + EffectKind::Outline(v) => v.targets = vec![target_id], + EffectKind::PostProcess(v) => { + v.global = false; + v.dimensions = [3.0; 3]; + v.vignette = 0.2; + v.saturation = 1.1; + } + EffectKind::MeshParticle(v) => { + v.mesh = Some(debris_meta.asset_id); + v.start_size = [0.15, 0.35]; + v.speed = [2.0, 4.0]; + v.gravity = [0.0, -5.0, 0.0]; + v.rate = 12.0; + v.collision = true; + } + EffectKind::FoliageScatter(v) => { + v.meshes = vec![leaf_meta.asset_id]; + v.dimensions = [3.0, 1.0, 3.0]; + v.max_instances = 40; + v.appearance.color = Gradient::constant([0.2, 0.6, 0.12, 1.0]); + } + EffectKind::ReflectionProbe(v) => { + v.cubemap = Some(probe_meta.asset_id); + v.dimensions = [5.0; 3]; + v.parallax_correction = true; + } + EffectKind::ScreenFlash(v) => v.opacity = 0.03, + EffectKind::CameraShake(v) => { + v.position_amplitude = [0.015; 3]; + v.rotation_amplitude = [0.002; 3]; + } + _ => {} + } + scene.entities.extend([target, light]); + let mut component = EffectComponent::new(kind); + component.common.random_seed = 100 + u64::try_from(index).unwrap_or_default(); + let moving = matches!( + component.kind, + EffectKind::Trail(_) | EffectKind::WaterRipple(_) + ); + let effect = EntitySnapshot { + name: Some(name), + local_transform: transform(position), + effect: Some(component), + scripts: if moving { + vec![engine_world::ScriptComponent::new(orbit_script)] + } else { + vec![] + }, + ..Default::default() + }; + scene.entities.push(effect); + } + let asset_directory = project.directory(VirtualDirectory::Assets)?.join("effects"); + std::fs::create_dir_all(&asset_directory)?; + for asset in presets() { + let slug = asset.name.to_lowercase().replace([' ', '/'], "-"); + let source = asset_directory.join(format!("{slug}.effect")); + std::fs::write(&source, asset.to_bytes()?)?; + let meta = engine_assets::AssetMeta::new("rustic.effect", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&source), &meta)?; + } + // Validate generated geometry and HDR through the real importer contracts. + for path in [&panorama, &debris, &leaf] { + engine_assets::ImporterRegistry::import(&engine_assets::ImportRequest::new( + path.strip_prefix(&destination)?, + std::fs::read(path)?, + ))?; + } + let scene_path = project + .directory(VirtualDirectory::Scenes)? + .join("showcase.scene"); + save_scene_atomic(&scene_path, &scene)?; + project.set_start_scene("showcase.scene")?; + let loaded = engine_world::load_scene(&std::fs::read(&scene_path)?)?; + assert_eq!( + loaded + .document + .entities + .iter() + .filter(|e| e.effect.is_some()) + .count(), + 26 + ); + println!( + "Generated {} with 26 effects and 35 editable preset assets", + destination.display() + ); + Ok(()) +} diff --git a/Engine/crates/engine-scripting/src/agent_authoring.md b/Engine/crates/engine-scripting/src/agent_authoring.md index fbc0aa36..32413e6e 100644 --- a/Engine/crates/engine-scripting/src/agent_authoring.md +++ b/Engine/crates/engine-scripting/src/agent_authoring.md @@ -68,3 +68,54 @@ These are optional references, not installed dependencies or engine API specific - Game UI workflow and checklist: https://github.com/jeremylongworth-source/AgentSkills/blob/main/skills/game-ui-design/SKILL.md - Broader design/accessibility reference catalog: https://github.com/jeremylongworth-source/AgentSkills/tree/main/skills - Blender workflow reference: https://github.com/Chara-Xh/blender-modeling-skill/tree/main/skills/blender-modeling + +## Author scene effects + +Use `editor_status` to inspect each object's `effect` component. `object_create` +accepts an optional `effect`; `object_update` accepts `properties.effect`. These +values use the typed scene representation: `common` contains playback settings, +and `kind` contains exactly one variant such as `Particle`, `Trail`, `Beam`, +`Weather`, or `ScreenFlash`. For example, attach a particle emitter with +`{"entity_id":"","properties":{"effect":{"kind":{"Particle":{"rate":40}},"common":{"random_seed":42}}}}`. +Unspecified fields use the engine defaults. Update by reading the existing +component, changing its fields, then submitting the complete component. Set +`properties.effect` to null to remove it. Creation, edits and removal participate +in ordinary scene undo/redo; duplicate retains authored effect configuration. +Save with `scene_save`. Do not attach generators as gameplay scripts. + +Runtime Lua/Luau, JavaScript and Python expose `Effects.types()`, +`Effects.create(entity,kind)`, `Effects.get(entity)`, +`Effects.configure(entity,settings)`, `Effects.state(entity)`, and +`Effects.play/pause/resume/stop/restart/reset/dispose(entity)`. Existing +`Effects.fade/flash/pulse/shake` helpers remain available. Kind names are exact, +case-sensitive serialized enum variants returned by `Effects.types()`. +`configure` merges existing nested fields and rejects unknown property names; +for example `Effects.configure(id,{kind={Particle={rate=50}}})` in Lua, or +`Effects.configure(id,{kind:{Particle:{rate:50}}})` in JavaScript. Runtime effects +advance on the existing scaled scene clock. Runtime edits are temporary; use +editor component edits and save for authored persistence. Dispose removes the +effect component, retaining its entity. Other language SDKs can use the existing +query boundary with `effect_types`, `effect_create`, `effect_get`, +`effect_configure`, `effect_control`, and `effect_state` operations. + +Inspect actual viewport output in editor and Play. Do not imply every configured +advanced renderer option is supported on every backend; consult the Scene Effects +compatibility documentation before promising GPU simulation, captures, or depth +features. + +Reusable `.effect` assets retain standard adjacent `.rmeta` stable IDs. The runtime +loads these sources when opening a scene. `Effects.instantiate(assetUUID,parent, +position)` creates an independent group at local `position` (defaults to origin; +null parent means scene root). `Effects.spawn(assetUUID,position,lifetime,parent)` +creates a temporary instance and destroys its whole group after positive lifetime +seconds on the existing scene clock. Use an explicit lifetime long enough for +smoke/tails to finish. Group playback controls apply to descendant effects; +`dispose` removes their effect components while retaining scene entities. + +`Effects.sampleWind(position,time,layers)` samples the same global/local wind +fields as particles; time is seconds and layers defaults to all bits. Subscribe +to effect events with existing `Events.connect(entity,'EffectCompleted',callback)` +or `EffectBirth`, `EffectCollision`, `EffectDeath`. Each event supplies an xyz +position argument and uses the existing bounded signal queue. Completion is +meaningful for non-looping effects. Cosmetic particle contacts do not apply +authoritative damage or physics forces. diff --git a/Engine/crates/engine-scripting/src/sdk/effects-api.d.ts b/Engine/crates/engine-scripting/src/sdk/effects-api.d.ts new file mode 100644 index 00000000..ab2993a1 --- /dev/null +++ b/Engine/crates/engine-scripting/src/sdk/effects-api.d.ts @@ -0,0 +1,43 @@ +// Serialized scene effects use Rust enum variant names and snake_case fields. +type RusticEffectKind = 'Trail' | 'Particle' | 'Beam' | 'Shockwave' | 'MeshParticle' | 'Ribbon' | 'ForceField' | 'CollisionVolume' | 'ProjectileTracer' | 'Fog' | 'Decal' | 'Weather' | 'WaterRipple' | 'Wind' | 'HeatHaze' | 'LightShaft' | 'FoliageScatter' | 'Dissolve' | 'AnimatedSurface' | 'LensFlare' | 'LightPulse' | 'PostProcess' | 'Outline' | 'CameraShake' | 'ScreenFlash' | 'ReflectionProbe'; +interface RusticEffectCommon { + enabled: boolean; visible: boolean; auto_play: boolean; looping: boolean; + duration: number; delay: number; time_scale: number; + simulation_space: 'Local' | 'World'; attachment_target: string | null; + visibility_layers: number; render_distance: number; + quality: 'Low' | 'Medium' | 'High'; random_seed: number; priority: number; +} +interface RusticEffectComponent { + common: RusticEffectCommon; + /** Exactly one variant returned by Effects.types(), e.g. {Particle:{rate:30}}. */ + kind: Partial>>; +} +interface RusticEffectSettings { + common?: Partial; + /** Patch existing fields only; unknown properties throw an error. */ + kind?: Partial>>; +} +type RusticEffectEntity = string | RusticObjectPath | null; +declare const Effects: { + types(): RusticEffectKind[]; + /** Temporary composite; destroys the instance group after lifetime seconds on the scene clock. */ + spawn(asset: string, position: [number,number,number], lifetime: number, parent?: RusticEffectEntity): string; + instantiate(asset: string, parent?: RusticEffectEntity, position?: [number,number,number]): string; + sampleWind(position: [number,number,number], time: number, layers?: number): [number,number,number]; + create(entity: RusticEffectEntity, kind: RusticEffectKind): RusticEffectComponent; + get(entity?: RusticEffectEntity): RusticEffectComponent | null; + configure(entity: RusticEffectEntity, settings: RusticEffectSettings): RusticEffectComponent; + state(entity?: RusticEffectEntity): 'Stopped' | 'Playing' | 'Paused' | 'Completed' | 'Disposed' | null; + play(entity?: RusticEffectEntity): void; + pause(entity?: RusticEffectEntity): void; + resume(entity?: RusticEffectEntity): void; + stop(entity?: RusticEffectEntity): void; + restart(entity?: RusticEffectEntity): void; + reset(entity?: RusticEffectEntity): void; + dispose(entity?: RusticEffectEntity): void; + // Existing gameplay tween helpers remain available. + fade(entity: RusticEffectEntity, opacity: number, duration: number, easing?: string): unknown; + flash(entity: RusticEffectEntity, color: [number,number,number], duration: number, easing?: string): unknown; + pulse(entity: RusticEffectEntity, scale: [number,number,number], duration: number, easing?: string): unknown; + shake(entity: RusticEffectEntity, strength: number, duration: number, easing?: string): unknown; +}; diff --git a/Engine/crates/engine-scripting/src/sdk/effects-api.lua b/Engine/crates/engine-scripting/src/sdk/effects-api.lua new file mode 100644 index 00000000..4ace771d --- /dev/null +++ b/Engine/crates/engine-scripting/src/sdk/effects-api.lua @@ -0,0 +1,31 @@ +---@class RusticEffectCommon +---@field enabled boolean +---@field visible boolean +---@field auto_play boolean +---@field looping boolean +---@field duration number Seconds, positive. +---@field delay number Seconds, nonnegative. +---@field time_scale number Nonnegative. +---@field simulation_space 'Local'|'World' +---@field random_seed integer +---@class RusticEffectComponent +---@field common RusticEffectCommon +---@field kind table One enum variant, e.g. Particle={rate=30}. +---@class RusticEffectsApi +---@field types fun():string[] +---@field spawn fun(asset:string,position:number[],lifetime:number,parent:string|RusticObjectPath|nil):string +---@field instantiate fun(asset:string,parent:string|RusticObjectPath|nil,position:number[]|nil):string +---@field sampleWind fun(position:number[],time:number,layers:integer|nil):number[] +---@field create fun(entity:string|RusticObjectPath|nil,kind:string):RusticEffectComponent +---@field get fun(entity:string|RusticObjectPath|nil):RusticEffectComponent|nil +---@field configure fun(entity:string|RusticObjectPath|nil,settings:table):RusticEffectComponent +---@field state fun(entity:string|RusticObjectPath|nil):string +---@field play fun(entity:string|RusticObjectPath|nil) +---@field pause fun(entity:string|RusticObjectPath|nil) +---@field resume fun(entity:string|RusticObjectPath|nil) +---@field stop fun(entity:string|RusticObjectPath|nil) +---@field restart fun(entity:string|RusticObjectPath|nil) +---@field reset fun(entity:string|RusticObjectPath|nil) +---@field dispose fun(entity:string|RusticObjectPath|nil) +---@type RusticEffectsApi +Effects=Effects diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.js b/Engine/crates/engine-scripting/src/sdk/gameplay.js index 5a288bc9..b4e05ca7 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.js +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.js @@ -153,6 +153,21 @@ const _rusticEntity = object => { return object.startsWith('game.scene.')?rustic.query({op:'scene_object',source:object}):Game.scene.Find(object)||object; }; +// Scene effects share the entity/query boundary and preserve existing tween helpers. +Object.assign(Effects, { + spawn(asset,position,lifetime,parent=null) { return rustic.query({op:'effect_spawn',asset,parent:parent==null?null:_rusticEntity(parent),position,lifetime}); }, + instantiate(asset,parent=null,position=[0,0,0]) { return rustic.query({op:'effect_instantiate',asset,parent:parent==null?null:_rusticEntity(parent),position}); }, + types() { return rustic.query({op:'effect_types'}); }, + sampleWind(position,time,layers=4294967295) { return rustic.query({op:'effect_wind',position,time,layers}); }, + create(entity, kind) { return rustic.query({op:'effect_create', entity:_rusticEntity(entity), kind}); }, + get(entity) { return rustic.query({op:'effect_get', entity:_rusticEntity(entity)}); }, + configure(entity, settings) { return rustic.query({op:'effect_configure', entity:_rusticEntity(entity), settings}); }, + state(entity) { return rustic.query({op:'effect_state', entity:_rusticEntity(entity)}); }, +}); +for (const action of ['play','pause','resume','stop','restart','reset','dispose']) { + Effects[action] = entity => rustic.query({op:'effect_control', entity:_rusticEntity(entity), action}); +} + // Entity services use the live shared Rust world. globalThis.Tags={ add(entity,name){return rustic.query({op:"tags_add",entity:_rusticEntity(entity),name:name});}, diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.lua b/Engine/crates/engine-scripting/src/sdk/gameplay.lua index 33797531..36a23a1b 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.lua +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.lua @@ -387,6 +387,19 @@ World={} function World.find_by_tag(name) return rustic.query({op="world_find_by_tag",name=name}) end function World.find_in_radius(center,radius) return rustic.query({op="world_find_in_radius",center=center,radius=radius}) end +-- Scene effects extend the existing Effects tween helpers. +function Effects.spawn(asset,position,lifetime,parent) return rustic.query({op='effect_spawn',asset=asset,parent=parent and resolve_entity(parent) or nil,position=position,lifetime=lifetime}) end +function Effects.instantiate(asset,parent,position) return rustic.query({op='effect_instantiate',asset=asset,parent=parent and resolve_entity(parent) or nil,position=position or {0,0,0}}) end +function Effects.types() return rustic.query({op='effect_types'}) end +function Effects.sampleWind(position,time,layers) return rustic.query({op='effect_wind',position=position,time=time,layers=layers or 4294967295}) end +function Effects.create(entity,kind) return rustic.query({op='effect_create',entity=resolve_entity(entity),kind=kind}) end +function Effects.get(entity) return rustic.query({op='effect_get',entity=resolve_entity(entity)}) end +function Effects.configure(entity,settings) return rustic.query({op='effect_configure',entity=resolve_entity(entity),settings=settings}) end +function Effects.state(entity) return rustic.query({op='effect_state',entity=resolve_entity(entity)}) end +for _,action in ipairs({'play','pause','resume','stop','restart','reset','dispose'}) do + Effects[action]=function(entity) return rustic.query({op='effect_control',entity=resolve_entity(entity),action=action}) end +end + -- Game mirrors the existing rustic.game capture methods. function Game.startRecording(path,options) return rustic.game:startRecording(path,options) end function Game.stopRecording(handle) return rustic.game:stopRecording(handle) end diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.py b/Engine/crates/engine-scripting/src/sdk/rustic.py index 7e98282f..80d9ec8f 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.py +++ b/Engine/crates/engine-scripting/src/sdk/rustic.py @@ -678,3 +678,17 @@ def __getattr__(self,name): class CanonicalGame: def __init__(self): self.scene=ApiPath('game.scene','scene'); self.rustic=GlobalApi() game=CanonicalGame() + +# Scene effects extend the existing Effects tween helpers. +Effects.spawn=staticmethod(lambda asset,position,lifetime,parent=None:rustic.query(dict(op='effect_spawn',asset=asset,parent=None if parent is None else _entity(parent),position=position,lifetime=lifetime))) +Effects.instantiate=staticmethod(lambda asset,parent=None,position=None:rustic.query(dict(op='effect_instantiate',asset=asset,parent=None if parent is None else _entity(parent),position=[0,0,0] if position is None else position))) +Effects.types=staticmethod(lambda:rustic.query(dict(op='effect_types'))) +Effects.sampleWind=staticmethod(lambda position,time,layers=4294967295:rustic.query(dict(op='effect_wind',position=position,time=time,layers=layers))) +Effects.create=staticmethod(lambda entity,kind:rustic.query(dict(op='effect_create',entity=_entity(entity),kind=kind))) +Effects.get=staticmethod(lambda entity=None:rustic.query(dict(op='effect_get',entity=_entity(entity)))) +Effects.configure=staticmethod(lambda entity,settings:rustic.query(dict(op='effect_configure',entity=_entity(entity),settings=settings))) +Effects.state=staticmethod(lambda entity=None:rustic.query(dict(op='effect_state',entity=_entity(entity)))) +def _effect_control(action): + return staticmethod(lambda entity=None:rustic.query(dict(op='effect_control',entity=_entity(entity),action=action))) +for _effect_action in ('play','pause','resume','stop','restart','reset','dispose'): + setattr(Effects,_effect_action,_effect_control(_effect_action)) diff --git a/Engine/crates/engine-scripting/src/workspace.rs b/Engine/crates/engine-scripting/src/workspace.rs index 4d4c9338..c50de886 100644 --- a/Engine/crates/engine-scripting/src/workspace.rs +++ b/Engine/crates/engine-scripting/src/workspace.rs @@ -125,7 +125,7 @@ pub fn generate_programming_workspace( include_str!("sdk/entity-api.lua"), include_str!("sdk/media-api.lua") ); - let api_text = format!("{api_text}{}", include_str!("sdk/game-api.lua")); + let api_text = format!("{api_text}{}", game_effect_declarations(true)); files.write_with_options( &api, api_text.as_bytes(), @@ -140,7 +140,7 @@ pub fn generate_programming_workspace( include_str!("sdk/entity-api.d.ts"), include_str!("sdk/media-api.d.ts") ); - let javascript_api_text = format!("{javascript_api_text}{}", include_str!("sdk/game-api.d.ts")); + let javascript_api_text = format!("{javascript_api_text}{}", game_effect_declarations(false)); files.write_with_options( &javascript_api, javascript_api_text.as_bytes(), @@ -193,6 +193,22 @@ pub fn generate_programming_workspace( }) } +fn game_effect_declarations(lua: bool) -> String { + if lua { + format!( + "{}{}", + include_str!("sdk/game-api.lua"), + include_str!("sdk/effects-api.lua") + ) + } else { + format!( + "{}{}", + include_str!("sdk/game-api.d.ts"), + include_str!("sdk/effects-api.d.ts") + ) + } +} + fn generate_agent_integrations( project_root: &Path, project_name: &str, diff --git a/Engine/crates/engine-world/Cargo.toml b/Engine/crates/engine-world/Cargo.toml index 149c84b9..1b8e3ead 100644 --- a/Engine/crates/engine-world/Cargo.toml +++ b/Engine/crates/engine-world/Cargo.toml @@ -13,6 +13,7 @@ engine-scripting = { path = "../engine-scripting", version = "0.1.0" } glam.workspace = true ron.workspace = true serde.workspace = true +serde_json.workspace = true sha2.workspace = true thiserror.workspace = true uuid.workspace = true diff --git a/Engine/crates/engine-world/src/effects/defaults.rs b/Engine/crates/engine-world/src/effects/defaults.rs new file mode 100644 index 00000000..4f4cefee --- /dev/null +++ b/Engine/crates/engine-world/src/effects/defaults.rs @@ -0,0 +1,137 @@ +use super::EffectComponent; +use serde_json::Value; + +/// Applies changed asset defaults while preserving authored instance overrides. +/// Struct fields merge independently; arrays (including curve keys) are atomic +/// overrides. The instance retains its stable source identity. +/// +/// # Errors +/// Returns a serialization or validation diagnostic if any component is invalid. +pub fn merge_instance_defaults( + instance: &EffectComponent, + old_default: &EffectComponent, + new_default: &EffectComponent, +) -> Result { + fn merge(instance: Value, old: &Value, new: &Value) -> Value { + if &instance == old { + return new.clone(); + } + match (instance, old, new) { + (Value::Object(instance), Value::Object(old), Value::Object(new)) => { + let mut result = new.clone(); + for (key, value) in instance { + match (old.get(&key), new.get(&key)) { + (Some(old), Some(new)) => { + result.insert(key, merge(value, old, new)); + } + (Some(old), None) if &value == old => {} + _ => { + result.insert(key, value); + } + } + } + Value::Object(result) + } + (instance, _, _) => instance, + } + } + let mut merged = instance.clone(); + merged.common = serde_json::from_value(merge( + serde_json::to_value(&instance.common).map_err(|error| error.to_string())?, + &serde_json::to_value(&old_default.common).map_err(|error| error.to_string())?, + &serde_json::to_value(&new_default.common).map_err(|error| error.to_string())?, + )) + .map_err(|error| error.to_string())?; + merged.kind = if instance.kind == old_default.kind { + new_default.kind.clone() + } else if std::mem::discriminant(&instance.kind) != std::mem::discriminant(&old_default.kind) + || std::mem::discriminant(&old_default.kind) != std::mem::discriminant(&new_default.kind) + { + // An enum variant is an atomic override: combining variant object keys + // would make the externally tagged serialized enum invalid. + instance.kind.clone() + } else { + serde_json::from_value(merge( + serde_json::to_value(&instance.kind).map_err(|error| error.to_string())?, + &serde_json::to_value(&old_default.kind).map_err(|error| error.to_string())?, + &serde_json::to_value(&new_default.kind).map_err(|error| error.to_string())?, + )) + .map_err(|error| error.to_string())? + }; + merged.source.clone_from(&instance.source); + merged.validate()?; + Ok(merged) +} + +#[cfg(test)] +#[allow(clippy::float_cmp)] // These tests require exact transfer, without arithmetic. +mod tests { + use super::*; + use crate::effects::{EffectKind, ParticleEmitter}; + + #[test] + fn changed_defaults_update_unmodified_fields_and_preserve_overrides() { + let old = EffectComponent::default(); + let mut instance = old.clone(); + instance.common.duration = 7.0; + let EffectKind::Particle(ref mut particle) = instance.kind else { + unreachable!() + }; + particle.rate = 17.0; + let mut new = old.clone(); + new.common.duration = 12.0; + new.common.render_distance = 100.0; + new.kind = EffectKind::Particle(ParticleEmitter { + rate: 80.0, + drag: 2.0, + ..Default::default() + }); + let merged = merge_instance_defaults(&instance, &old, &new).unwrap(); + assert_eq!(merged.common.duration, 7.0); + assert_eq!(merged.common.render_distance, 100.0); + let EffectKind::Particle(particle) = merged.kind else { + unreachable!() + }; + assert_eq!(particle.rate, 17.0); + assert_eq!(particle.drag, 2.0); + assert_eq!(merge_instance_defaults(&old, &old, &new).unwrap(), new); + } + #[test] + fn changed_effect_variants_merge_atomically_and_preserve_common_overrides() { + use crate::effects::BeamRenderer; + let old = EffectComponent::default(); + let mut new = old.clone(); + new.kind = EffectKind::Beam(BeamRenderer::default()); + new.common.render_distance = 100.0; + assert_eq!( + merge_instance_defaults(&old, &old, &new).unwrap().kind, + new.kind + ); + let mut overridden = old.clone(); + overridden.common.duration = 9.0; + if let EffectKind::Particle(p) = &mut overridden.kind { + p.rate = 17.0; + } + let merged = merge_instance_defaults(&overridden, &old, &new).unwrap(); + assert_eq!(merged.kind, overridden.kind); + assert_eq!(merged.common.duration, 9.0); + assert_eq!(merged.common.render_distance, 100.0); + let mut different_override = old.clone(); + different_override.kind = EffectKind::Beam(BeamRenderer { + width: 0.9, + ..BeamRenderer::default() + }); + assert_eq!( + merge_instance_defaults(&different_override, &old, &new) + .unwrap() + .kind, + different_override.kind + ); + assert_eq!( + merge_instance_defaults(&different_override, &old, &old) + .unwrap() + .kind, + different_override.kind + ); + } +} diff --git a/Engine/crates/engine-world/src/effects/mod.rs b/Engine/crates/engine-world/src/effects/mod.rs new file mode 100644 index 00000000..d54868b8 --- /dev/null +++ b/Engine/crates/engine-world/src/effects/mod.rs @@ -0,0 +1,238 @@ +//! Shared authored effects, deterministic simulation, and renderer-neutral extraction. +mod model; +mod water; +pub use water::*; +mod defaults; +pub use defaults::merge_instance_defaults; +mod presets; +pub use presets::*; +mod modifiers; +mod simulation; +use crate::{AssetId, EntityId, PrimitiveMesh}; +use glam::{Mat4, Vec3}; +pub use model::*; +pub use simulation::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlaybackCommand { + Play, + Pause, + Resume, + Stop, + Restart, + Reset, + Dispose, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PlaybackState { + #[default] + Stopped, + Playing, + Paused, + Completed, + Disposed, +} +#[derive(Clone, Debug)] +pub struct EffectInput { + pub entity: EntityId, + pub transform: Mat4, + pub effect: EffectComponent, +} +#[derive(Clone, Debug)] +pub struct ParticleInstance { + pub entity: EntityId, + pub position: Vec3, + pub velocity: Vec3, + pub stretch: f32, + /// Sprite atlas rectangle: minimum UV followed by maximum UV. + pub uv_rect: [f32; 4], + pub rotation: f32, + pub size: f32, + pub color: [f32; 4], + pub mesh: Option, + pub material: Option, + pub texture: Option, + pub emission: f32, + pub blend: BlendMode, +} +#[derive(Clone, Debug)] +pub struct EffectGeometry { + pub entity: EntityId, + pub mesh: PrimitiveMesh, + pub colors: Vec<[f32; 4]>, + pub color: [f32; 4], + pub emission: f32, + pub material: Option, + pub texture: Option, + pub blend: BlendMode, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PostProcessSettings { + pub exposure: f32, + pub saturation: f32, + pub contrast: f32, + pub brightness: f32, + pub tint: [f32; 3], + pub bloom: f32, + pub bloom_threshold: f32, + pub bloom_radius: f32, + pub vignette: f32, + pub chromatic_aberration: f32, + pub film_grain: f32, + pub distortion: f32, + pub distortion_scale: f32, + pub distortion_speed: f32, + pub time: f32, + pub overlay: [f32; 4], + pub overlay_add: [f32; 3], + pub overlay_multiply: [f32; 3], + pub depth_of_field: f32, +} +impl Default for PostProcessSettings { + fn default() -> Self { + Self { + exposure: 0.0, + saturation: 1.0, + contrast: 1.0, + brightness: 0.0, + tint: [1.0; 3], + bloom: 0.0, + bloom_threshold: 0.8, + bloom_radius: 1.0, + vignette: 0.0, + chromatic_aberration: 0.0, + film_grain: 0.0, + distortion: 0.0, + distortion_scale: 10.0, + distortion_speed: 1.0, + time: 0.0, + overlay: [0.0; 4], + overlay_add: [0.0; 3], + overlay_multiply: [1.0; 3], + depth_of_field: 0.0, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EffectEventKind { + Birth, + Collision, + Death, + Completed, +} +#[derive(Clone, Debug)] +pub struct EffectEvent { + pub entity: EntityId, + pub kind: EffectEventKind, + pub position: Vec3, + pub child_effect: Option, +} +#[derive(Clone, Debug)] +pub struct LightModifier { + pub target: EntityId, + pub color: [f32; 3], + pub intensity: f32, +} +#[derive(Clone, Debug)] +pub struct SurfaceModifier { + pub target: EntityId, + pub uv_offset: [f32; 2], + pub uv_scale: [f32; 2], + pub uv_rotation: f32, + pub dissolve_noise_scale: f32, + pub color: [f32; 4], + pub emission: f32, + pub dissolve: f32, + pub edge_width: f32, + pub edge_color: [f32; 3], +} +#[derive(Clone, Debug, Default)] +pub struct EffectFrame { + pub geometry: Vec, + pub particles: Vec, + pub post: PostProcessSettings, + pub camera_offset: Vec3, + pub camera_rotation: Vec3, + pub lights: Vec, + pub surfaces: Vec, + pub events: Vec, + pub fog: Vec, + pub shafts: Vec, + pub probes: Vec, + pub decals: Vec, + pub outlines: Vec, + pub flares: Vec, + pub water: Vec, +} +#[derive(Clone, Debug)] +pub struct RenderFogVolume { + pub entity: EntityId, + pub inverse_transform: Mat4, + pub dimensions: [f32; 3], + pub shape: VolumeShape, + pub density: f32, + pub color: [f32; 3], + pub height_falloff: f32, + pub edge_falloff: f32, + pub noise_scale: f32, + pub noise_speed: f32, + pub steps: u32, +} +#[derive(Clone, Debug)] +pub struct RenderLightShaft { + pub light: Option, + pub position: Vec3, + pub color: [f32; 3], + pub intensity: f32, + pub density: f32, + pub samples: u32, + pub max_distance: f32, +} +#[derive(Clone, Debug)] +pub struct RenderReflectionProbe { + pub entity: EntityId, + pub position: Vec3, + pub dimensions: [f32; 3], + pub cubemap: AssetId, + pub blend_distance: f32, + pub priority: i32, +} +#[derive(Clone, Debug)] +pub struct RenderDecal { + pub entity: EntityId, + pub transform: Mat4, + pub dimensions: [f32; 3], + pub appearance: EffectAppearance, + pub normal_map: Option, + pub orm_map: Option, + pub roughness: f32, + pub metallic: f32, + pub edge_fade: f32, + pub angle_fade: f32, + pub layers: u32, +} +#[derive(Clone, Debug)] +pub struct RenderOutline { + pub targets: Vec, + pub color: [f32; 4], + pub thickness: f32, + pub through_walls: bool, +} +#[derive(Clone, Debug)] +pub struct RenderLensFlare { + pub light: Option, + pub position: Vec3, + pub intensity: f32, + pub scale: f32, + pub color: [f32; 3], + pub ghosts: u32, + pub ghost_spacing: f32, + pub streak_length: f32, +} + +#[derive(Clone, Debug)] +pub struct RenderWaterDisturbance { + pub entity: EntityId, + pub target: EntityId, + pub disturbance: WaterDisturbance, +} diff --git a/Engine/crates/engine-world/src/effects/model.rs b/Engine/crates/engine-world/src/effects/model.rs new file mode 100644 index 00000000..9a91137c --- /dev/null +++ b/Engine/crates/engine-world/src/effects/model.rs @@ -0,0 +1,1771 @@ +//! Versioned authored effect configuration. Runtime state is never serialized. +use crate::{AssetId, EntityId}; +use bevy_ecs::prelude::Component; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SimulationSpace { + Local, + #[default] + World, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BlendMode { + #[default] + Alpha, + Additive, + Multiply, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum QualityLevel { + Low, + #[default] + Medium, + High, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CurveKey { + pub time: f32, + pub value: f32, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Curve { + pub keys: Vec, +} +impl Default for Curve { + fn default() -> Self { + Self::constant(1.0) + } +} +impl Curve { + pub fn constant(value: f32) -> Self { + Self { + keys: vec![CurveKey { time: 0.0, value }, CurveKey { time: 1.0, value }], + } + } + pub fn linear(start: f32, end: f32) -> Self { + Self { + keys: vec![ + CurveKey { + time: 0.0, + value: start, + }, + CurveKey { + time: 1.0, + value: end, + }, + ], + } + } + pub fn sample(&self, t: f32) -> f32 { + let mut before: Option<&CurveKey> = None; + let mut after: Option<&CurveKey> = None; + for k in &self.keys { + if k.time <= t && before.is_none_or(|v| k.time > v.time) { + before = Some(k); + } + if k.time >= t && after.is_none_or(|v| k.time < v.time) { + after = Some(k); + } + } + match (before, after) { + (Some(a), Some(b)) if b.time > a.time => { + a.value + (b.value - a.value) * ((t - a.time) / (b.time - a.time)) + } + (Some(a), _) => a.value, + (_, Some(b)) => b.value, + _ => 1.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientKey { + pub time: f32, + pub color: [f32; 4], +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gradient { + pub keys: Vec, +} +impl Default for Gradient { + fn default() -> Self { + Self::constant([1.0; 4]) + } +} +impl Gradient { + pub fn constant(color: [f32; 4]) -> Self { + Self { + keys: vec![GradientKey { time: 0.0, color }], + } + } + pub fn sample(&self, t: f32) -> [f32; 4] { + let mut before: Option<&GradientKey> = None; + let mut after: Option<&GradientKey> = None; + for k in &self.keys { + if k.time <= t && before.is_none_or(|v| k.time > v.time) { + before = Some(k); + } + if k.time >= t && after.is_none_or(|v| k.time < v.time) { + after = Some(k); + } + } + match (before, after) { + (Some(a), Some(b)) if b.time > a.time => { + let f = (t - a.time) / (b.time - a.time); + std::array::from_fn(|i| a.color[i] + (b.color[i] - a.color[i]) * f) + } + (Some(a), _) => a.color, + (_, Some(b)) => b.color, + _ => [1.0; 4], + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ScalarParameter { + Constant(f32), + Range { min: f32, max: f32 }, + Curve(Curve), + BetweenCurves { min: Curve, max: Curve }, +} +impl ScalarParameter { + pub fn sample(&self, t: f32, random: f32) -> f32 { + match self { + Self::Constant(v) => *v, + Self::Range { min, max } => min + (max - min) * random, + Self::Curve(c) => c.sample(t), + Self::BetweenCurves { min, max } => { + min.sample(t) + (max.sample(t) - min.sample(t)) * random + } + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +// These independent authoring toggles mirror scene/editor lifecycle settings. +#[allow(clippy::struct_excessive_bools)] +pub struct EffectCommon { + pub parameters: std::collections::BTreeMap, + pub bounds: Option, + pub enabled: bool, + pub visible: bool, + pub auto_play: bool, + pub looping: bool, + pub duration: f32, + pub delay: f32, + pub time_scale: f32, + pub simulation_space: SimulationSpace, + pub attachment_target: Option, + pub visibility_layers: u32, + pub render_distance: f32, + pub quality: QualityLevel, + pub random_seed: u64, + pub priority: i32, +} +impl Default for EffectCommon { + fn default() -> Self { + Self { + parameters: std::collections::BTreeMap::default(), + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: SimulationSpace::World, + attachment_target: None, + visibility_layers: u32::MAX, + render_distance: 1000.0, + quality: QualityLevel::Medium, + random_seed: 1, + priority: 0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct EffectAppearance { + pub color: Gradient, + pub opacity: Curve, + pub size: Curve, + pub material: Option, + pub texture: Option, + pub emission: f32, + pub blend: BlendMode, + pub uv_scale: [f32; 2], + pub uv_scroll: [f32; 2], +} +impl Default for EffectAppearance { + fn default() -> Self { + Self { + color: Gradient::default(), + opacity: Curve::linear(1.0, 0.0), + size: Curve::constant(1.0), + material: None, + texture: None, + emission: 1.0, + blend: BlendMode::Alpha, + uv_scale: [1.0; 2], + uv_scroll: [0.0; 2], + } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SpawnShape { + #[default] + Point, + Sphere, + Hemisphere, + Box, + Cone, + Circle, + Cylinder, + MeshSurface, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Burst { + pub time: f32, + pub count: u32, + pub probability: f32, + pub cycles: u32, + pub interval: f32, +} +impl Default for Burst { + fn default() -> Self { + Self { + time: 0.0, + count: 20, + probability: 1.0, + cycles: 1, + interval: 1.0, + } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CollisionResponse { + #[default] + Bounce, + Stick, + Kill, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ParticleEmitter { + pub prewarm: bool, + pub prewarm_time: f32, + pub max_particles: u32, + pub rate: f32, + pub rate_over_distance: f32, + pub bursts: Vec, + pub shape: SpawnShape, + pub dimensions: [f32; 3], + pub surface_only: bool, + pub lifetime: [f32; 2], + pub speed: [f32; 2], + pub direction: [f32; 3], + pub start_size: [f32; 2], + pub gravity: [f32; 3], + pub force: [f32; 3], + pub drag: f32, + pub wind_influence: f32, + pub noise: f32, + pub angular_velocity: f32, + pub inherit_velocity: f32, + pub velocity_limit: f32, + pub appearance: EffectAppearance, + pub mesh: Option, + /// Local-space triangles supplied by a loaded mesh for surface emission. + pub mesh_spawn_triangles: Vec<[[f32; 3]; 3]>, + pub collision: bool, + pub collision_layers: u32, + pub bounce: f32, + pub friction: f32, + pub collision_response: CollisionResponse, + pub sub_emitter: Option, + pub sub_emitter_probability: f32, + pub max_generations: u8, + pub flipbook: [u32; 2], + pub flipbook_fps: f32, + /// Velocity stretch exposure in seconds; zero uses square billboards. + pub stretch: f32, +} +impl Default for ParticleEmitter { + fn default() -> Self { + Self { + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 30.0, + rate_over_distance: 0.0, + bursts: vec![], + shape: SpawnShape::Cone, + dimensions: [1.0; 3], + surface_only: false, + lifetime: [1.0, 2.0], + speed: [1.0, 3.0], + direction: [0.0, 1.0, 0.0], + start_size: [0.1, 0.25], + gravity: [0.0, -1.0, 0.0], + force: [0.0; 3], + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: EffectAppearance::default(), + mesh: None, + mesh_spawn_triangles: Vec::new(), + collision: false, + collision_layers: u32::MAX, + bounce: 0.5, + friction: 0.1, + collision_response: CollisionResponse::Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: [1, 1], + flipbook_fps: 0.0, + stretch: 0.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TrailRenderer { + pub emitting: bool, + pub lifetime: f32, + pub width: f32, + pub min_vertex_distance: f32, + pub max_points: u32, + pub teleport_distance: f32, + pub camera_facing: bool, + pub appearance: EffectAppearance, +} +impl Default for TrailRenderer { + fn default() -> Self { + Self { + emitting: true, + lifetime: 1.0, + width: 0.2, + min_vertex_distance: 0.05, + max_points: 256, + teleport_distance: 20.0, + camera_facing: true, + appearance: EffectAppearance::default(), + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BeamRenderer { + pub start_target: Option, + pub end_target: Option, + pub start: [f32; 3], + pub end: [f32; 3], + pub width: f32, + pub segments: u32, + pub noise_amplitude: f32, + pub noise_frequency: f32, + pub noise_speed: f32, + pub branch_count: u32, + pub appearance: EffectAppearance, +} +impl Default for BeamRenderer { + fn default() -> Self { + Self { + start_target: None, + end_target: None, + start: [0.0; 3], + end: [0.0, 3.0, 0.0], + width: 0.1, + segments: 24, + noise_amplitude: 0.2, + noise_frequency: 3.0, + noise_speed: 5.0, + branch_count: 0, + appearance: EffectAppearance::default(), + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RingEffect { + pub target: Option, + pub source_velocity: [f32; 3], + pub wake_angle: f32, + pub foam_intensity: f32, + pub foam_lifetime: f32, + pub normal_strength: f32, + pub max_disturbances: u32, + pub initial_radius: f32, + pub final_radius: f32, + pub thickness: f32, + pub vertical_offset: f32, + pub amplitude: f32, + pub wavelength: f32, + pub damping: f32, + pub appearance: EffectAppearance, +} +impl Default for RingEffect { + fn default() -> Self { + Self { + target: None, + source_velocity: [0.0; 3], + wake_angle: 0.6, + foam_intensity: 0.5, + foam_lifetime: 1.0, + normal_strength: 1.0, + max_disturbances: 32, + initial_radius: 0.1, + final_radius: 4.0, + thickness: 0.2, + vertical_offset: 0.01, + amplitude: 0.1, + wavelength: 1.0, + damping: 1.0, + appearance: EffectAppearance::default(), + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RibbonRenderer { + pub points: Vec<[f32; 3]>, + pub width: f32, + pub tube: bool, + pub radial_segments: u32, + pub subdivisions: u32, + pub twist: f32, + pub appearance: EffectAppearance, +} +impl Default for RibbonRenderer { + fn default() -> Self { + Self { + points: vec![[0.0; 3], [1.0, 1.0, 0.0], [2.0, 0.0, 0.0]], + width: 0.2, + tube: false, + radial_segments: 8, + subdivisions: 8, + twist: 0.0, + appearance: EffectAppearance::default(), + } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum VolumeShape { + #[default] + Box, + Sphere, + Plane, + Cylinder, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ForceType { + #[default] + Directional, + Attraction, + Repulsion, + Vortex, + Turbulence, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ForceField { + pub shape: VolumeShape, + pub dimensions: [f32; 3], + pub force_type: ForceType, + pub direction: [f32; 3], + pub strength: f32, + pub falloff: f32, + pub turbulence: f32, + pub frequency: f32, + pub layers: u32, + pub global: bool, +} +impl Default for ForceField { + fn default() -> Self { + Self { + shape: VolumeShape::Sphere, + dimensions: [5.0; 3], + force_type: ForceType::Directional, + direction: [1.0, 0.0, 0.0], + strength: 1.0, + falloff: 1.0, + turbulence: 0.0, + frequency: 1.0, + layers: u32::MAX, + global: false, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CollisionVolume { + pub shape: VolumeShape, + pub dimensions: [f32; 3], + pub layers: u32, + pub bounce: f32, + pub friction: f32, + pub response: CollisionResponse, + pub impact_effect: Option, +} +impl Default for CollisionVolume { + fn default() -> Self { + Self { + shape: VolumeShape::Plane, + dimensions: [10.0; 3], + layers: u32::MAX, + bounce: 0.5, + friction: 0.1, + response: CollisionResponse::Bounce, + impact_effect: None, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProjectileTracer { + pub velocity: [f32; 3], + pub length: f32, + pub width: f32, + pub appearance: EffectAppearance, +} +impl Default for ProjectileTracer { + fn default() -> Self { + Self { + velocity: [0.0, 0.0, 10.0], + length: 2.0, + width: 0.05, + appearance: EffectAppearance::default(), + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct FogVolume { + pub shape: VolumeShape, + pub dimensions: [f32; 3], + pub density: f32, + pub color: [f32; 3], + pub height_falloff: f32, + pub edge_falloff: f32, + pub noise_scale: f32, + pub noise_speed: f32, + pub anisotropy: f32, + pub steps: u32, +} +impl Default for FogVolume { + fn default() -> Self { + Self { + shape: VolumeShape::Box, + dimensions: [10.0, 3.0, 10.0], + density: 0.1, + color: [0.6, 0.7, 0.8], + height_falloff: 0.5, + edge_falloff: 1.0, + noise_scale: 1.0, + noise_speed: 0.1, + anisotropy: 0.0, + steps: 32, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DecalProjector { + pub dimensions: [f32; 3], + pub appearance: EffectAppearance, + pub normal_map: Option, + pub orm_map: Option, + pub roughness: f32, + pub metallic: f32, + pub edge_fade: f32, + pub angle_fade: f32, + pub layers: u32, +} +impl Default for DecalProjector { + fn default() -> Self { + Self { + dimensions: [1.0, 1.0, 1.0], + appearance: EffectAppearance::default(), + normal_map: None, + orm_map: None, + roughness: 0.8, + metallic: 0.0, + edge_fade: 0.1, + angle_fade: 0.5, + layers: u32::MAX, + } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum WeatherType { + #[default] + Rain, + Snow, + Hail, + Dust, + Sand, + Leaves, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct WeatherVolume { + pub weather: WeatherType, + pub emitter: ParticleEmitter, + pub intensity: f32, + pub camera_follow: bool, +} +impl Default for WeatherVolume { + fn default() -> Self { + let emitter = ParticleEmitter { + shape: SpawnShape::Box, + dimensions: [20.0, 10.0, 20.0], + direction: [0.0, -1.0, 0.0], + speed: [8.0, 10.0], + gravity: [0.0; 3], + rate: 200.0, + lifetime: [2.0, 3.0], + ..ParticleEmitter::default() + }; + Self { + weather: WeatherType::Rain, + emitter, + intensity: 1.0, + camera_follow: false, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DistortionEffect { + pub strength: f32, + pub noise_scale: f32, + pub speed: f32, + pub radius: f32, + pub edge_falloff: f32, +} +impl Default for DistortionEffect { + fn default() -> Self { + Self { + strength: 0.01, + noise_scale: 10.0, + speed: 1.0, + radius: 3.0, + edge_falloff: 1.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LightShaft { + pub light: Option, + pub intensity: f32, + pub color: [f32; 3], + pub density: f32, + pub anisotropy: f32, + pub max_distance: f32, + pub samples: u32, +} +impl Default for LightShaft { + fn default() -> Self { + Self { + light: None, + intensity: 0.5, + color: [1.0, 0.9, 0.6], + density: 0.1, + anisotropy: 0.5, + max_distance: 100.0, + samples: 32, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct FoliageScatter { + pub meshes: Vec, + pub density: f32, + pub dimensions: [f32; 3], + pub scale: [f32; 2], + pub max_instances: u32, + pub wind_response: f32, + pub appearance: EffectAppearance, +} +impl Default for FoliageScatter { + fn default() -> Self { + Self { + meshes: vec![], + density: 1.0, + dimensions: [10.0, 1.0, 10.0], + scale: [0.5, 1.5], + max_instances: 1000, + wind_response: 1.0, + appearance: EffectAppearance::default(), + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DissolveEffect { + pub target: Option, + pub progress: f32, + pub edge_width: f32, + pub edge_color: [f32; 3], + pub edge_emission: f32, + pub noise_scale: f32, + pub curve: Curve, +} +impl Default for DissolveEffect { + fn default() -> Self { + Self { + target: None, + progress: 0.0, + edge_width: 0.05, + edge_color: [1.0, 0.4, 0.0], + edge_emission: 3.0, + noise_scale: 10.0, + curve: Curve::linear(0.0, 1.0), + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AnimatedSurface { + pub target: Option, + pub uv_scroll: [f32; 2], + pub uv_scale: [f32; 2], + pub uv_rotation: f32, + pub color: Gradient, + pub emission: f32, + pub speed: f32, +} +impl Default for AnimatedSurface { + fn default() -> Self { + Self { + target: None, + uv_scroll: [0.2, 0.0], + uv_scale: [1.0; 2], + uv_rotation: 0.0, + color: Gradient::default(), + emission: 1.0, + speed: 1.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LensFlare { + pub light: Option, + pub intensity: f32, + pub scale: f32, + pub color: [f32; 3], + pub ghosts: u32, + pub ghost_spacing: f32, + pub streak_length: f32, + pub fade_speed: f32, + pub texture: Option, +} +impl Default for LensFlare { + fn default() -> Self { + Self { + light: None, + intensity: 1.0, + scale: 0.1, + color: [1.0, 0.8, 0.5], + ghosts: 4, + ghost_spacing: 0.2, + streak_length: 0.3, + fade_speed: 5.0, + texture: None, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LightPulse { + pub target: Option, + pub intensity: Curve, + pub color: Gradient, + pub frequency: f32, + pub randomness: f32, + pub phase: f32, +} +impl Default for LightPulse { + fn default() -> Self { + Self { + target: None, + intensity: Curve::linear(0.0, 1.0), + color: Gradient::default(), + frequency: 1.0, + randomness: 0.0, + phase: 0.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PostProcessVolume { + pub global: bool, + pub dimensions: [f32; 3], + pub blend_distance: f32, + pub weight: f32, + pub exposure: f32, + pub saturation: f32, + pub contrast: f32, + pub brightness: f32, + pub tint: [f32; 3], + pub bloom: f32, + pub vignette: f32, + pub chromatic_aberration: f32, + pub grain: f32, + pub depth_of_field: f32, +} +impl Default for PostProcessVolume { + fn default() -> Self { + Self { + global: true, + dimensions: [10.0; 3], + blend_distance: 1.0, + weight: 1.0, + exposure: 0.0, + saturation: 1.0, + contrast: 1.0, + brightness: 0.0, + tint: [1.0; 3], + bloom: 0.0, + vignette: 0.0, + chromatic_aberration: 0.0, + grain: 0.0, + depth_of_field: 0.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct OutlineEffect { + pub targets: Vec, + pub color: [f32; 4], + pub thickness: f32, + pub pulse_speed: f32, + pub through_walls: bool, +} +impl Default for OutlineEffect { + fn default() -> Self { + Self { + targets: vec![], + color: [1.0, 0.6, 0.0, 1.0], + thickness: 0.03, + pulse_speed: 0.0, + through_walls: false, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CameraShake { + pub target: Option, + pub position_amplitude: [f32; 3], + pub rotation_amplitude: [f32; 3], + pub frequency: f32, + pub falloff: Curve, + pub radius: f32, +} +impl Default for CameraShake { + fn default() -> Self { + Self { + target: None, + position_amplitude: [0.1; 3], + rotation_amplitude: [0.02; 3], + frequency: 20.0, + falloff: Curve::linear(1.0, 0.0), + radius: 20.0, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ScreenFlash { + pub target: Option, + pub color: [f32; 4], + pub opacity: f32, + pub fade_in: f32, + pub fade_out: f32, + pub vignette: f32, + pub blend: BlendMode, +} +impl Default for ScreenFlash { + fn default() -> Self { + Self { + target: None, + color: [1.0, 0.0, 0.0, 1.0], + opacity: 0.5, + fade_in: 0.05, + fade_out: 0.4, + vignette: 0.5, + blend: BlendMode::Alpha, + } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProbeRefresh { + #[default] + Baked, + OnEnable, + Interval, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ReflectionProbe { + pub dimensions: [f32; 3], + pub cubemap: Option, + pub resolution: u32, + pub refresh: ProbeRefresh, + pub blend_distance: f32, + pub update_interval: f32, + pub parallax_correction: bool, +} +impl Default for ReflectionProbe { + fn default() -> Self { + Self { + dimensions: [10.0; 3], + cubemap: None, + resolution: 128, + refresh: ProbeRefresh::Baked, + blend_distance: 1.0, + update_interval: 1.0, + parallax_correction: false, + } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum EffectKind { + Trail(TrailRenderer), + Particle(ParticleEmitter), + Beam(BeamRenderer), + Shockwave(RingEffect), + MeshParticle(ParticleEmitter), + Ribbon(RibbonRenderer), + ForceField(ForceField), + CollisionVolume(CollisionVolume), + ProjectileTracer(ProjectileTracer), + Fog(FogVolume), + Decal(DecalProjector), + Weather(WeatherVolume), + WaterRipple(RingEffect), + Wind(ForceField), + HeatHaze(DistortionEffect), + LightShaft(LightShaft), + FoliageScatter(FoliageScatter), + Dissolve(DissolveEffect), + AnimatedSurface(AnimatedSurface), + LensFlare(LensFlare), + LightPulse(LightPulse), + PostProcess(PostProcessVolume), + Outline(OutlineEffect), + CameraShake(CameraShake), + ScreenFlash(ScreenFlash), + ReflectionProbe(ReflectionProbe), +} +impl Default for EffectKind { + fn default() -> Self { + Self::Particle(ParticleEmitter::default()) + } +} +impl EffectKind { + pub fn all_defaults() -> Vec { + vec![ + Self::Trail(TrailRenderer::default()), + Self::Particle(ParticleEmitter::default()), + Self::Beam(BeamRenderer::default()), + Self::Shockwave(RingEffect::default()), + Self::MeshParticle(ParticleEmitter::default()), + Self::Ribbon(RibbonRenderer::default()), + Self::ForceField(ForceField::default()), + Self::CollisionVolume(CollisionVolume::default()), + Self::ProjectileTracer(ProjectileTracer::default()), + Self::Fog(FogVolume::default()), + Self::Decal(DecalProjector::default()), + Self::Weather(WeatherVolume::default()), + Self::WaterRipple(RingEffect::default()), + Self::Wind(ForceField::default()), + Self::HeatHaze(DistortionEffect::default()), + Self::LightShaft(LightShaft::default()), + Self::FoliageScatter(FoliageScatter::default()), + Self::Dissolve(DissolveEffect::default()), + Self::AnimatedSurface(AnimatedSurface::default()), + Self::LensFlare(LensFlare::default()), + Self::LightPulse(LightPulse::default()), + Self::PostProcess(PostProcessVolume::default()), + Self::Outline(OutlineEffect::default()), + Self::CameraShake(CameraShake::default()), + Self::ScreenFlash(ScreenFlash::default()), + Self::ReflectionProbe(ReflectionProbe::default()), + ] + } + pub fn name(&self) -> &'static str { + match self { + Self::Trail(_) => "Trail Renderer", + Self::Particle(_) => "Particle Emitter", + Self::Beam(_) => "Beam / Lightning", + Self::Shockwave(_) => "Shockwave", + Self::MeshParticle(_) => "Mesh Particle Emitter", + Self::Ribbon(_) => "Ribbon / Tube", + Self::ForceField(_) => "Particle Force Field", + Self::CollisionVolume(_) => "Particle Collision Volume", + Self::ProjectileTracer(_) => "Projectile Tracer", + Self::Fog(_) => "Fog Volume", + Self::Decal(_) => "Decal Projector", + Self::Weather(_) => "Weather Volume", + Self::WaterRipple(_) => "Water Ripple / Wake", + Self::Wind(_) => "Wind Zone", + Self::HeatHaze(_) => "Heat Haze", + Self::LightShaft(_) => "Light Shaft", + Self::FoliageScatter(_) => "Foliage Scatter", + Self::Dissolve(_) => "Dissolve", + Self::AnimatedSurface(_) => "Animated Surface", + Self::LensFlare(_) => "Lens Flare", + Self::LightPulse(_) => "Light Pulse / Flicker", + Self::PostProcess(_) => "Post-Processing Volume", + Self::Outline(_) => "Outline / Highlight", + Self::CameraShake(_) => "Camera Shake", + Self::ScreenFlash(_) => "Screen Flash", + Self::ReflectionProbe(_) => "Reflection Probe", + } + } +} +/// Stable reusable asset origin; authored fields are per-instance overrides. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EffectSource { + pub asset: AssetId, + pub child: u32, + #[serde(default)] + pub baseline: Option>, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EffectDefaults { + pub common: EffectCommon, + pub kind: EffectKind, +} +impl EffectDefaults { + pub fn from_component(effect: &EffectComponent) -> Self { + Self { + common: effect.common.clone(), + kind: effect.kind.clone(), + } + } + pub fn into_component(self) -> EffectComponent { + EffectComponent { + source: None, + common: self.common, + kind: self.kind, + } + } +} +#[derive(Component, Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct EffectComponent { + pub source: Option, + pub common: EffectCommon, + pub kind: EffectKind, +} +impl EffectComponent { + pub fn new(kind: EffectKind) -> Self { + Self { + kind, + ..Self::default() + } + } + /// Validates finite values, supported modes and bounded resource capacities. + /// + /// # Errors + /// Returns a property-specific diagnostic for invalid authored configuration. + pub fn validate(&self) -> Result<(), String> { + self.validate_values()?; + let c = &self.common; + if ![c.duration, c.delay, c.time_scale, c.render_distance] + .iter() + .all(|v| v.is_finite() && *v >= 0.0) + || c.duration <= 0.0 + { + return Err("effect duration must be positive; delay, speed and distance must be finite and nonnegative".into()); + } + match &self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => validate_particles(p), + EffectKind::Weather(w) => validate_particles(&w.emitter), + EffectKind::Trail(t) + if t.max_points > 65536 + || t.max_points < 2 + || t.lifetime <= 0.0 + || t.width <= 0.0 => + { + Err("invalid trail capacity, width or lifetime".into()) + } + EffectKind::Beam(b) if b.segments < 1 || b.segments > 4096 => { + Err("beam segments must be 1..4096".into()) + } + EffectKind::Ribbon(r) + if r.points.len() < 2 + || r.points.len() > 4096 + || r.radial_segments < 3 + || r.radial_segments > 64 + || r.subdivisions > 64 => + { + Err("invalid ribbon points or tessellation".into()) + } + _ => Ok(()), + } + } +} +fn validate_particles(p: &ParticleEmitter) -> Result<(), String> { + if p.max_particles > 100_000 + || ![ + p.rate, + p.rate_over_distance, + p.lifetime[0], + p.lifetime[1], + p.drag, + p.start_size[0], + p.start_size[1], + ] + .iter() + .all(|x| x.is_finite() && *x >= 0.0) + || p.lifetime[0] <= 0.0 + || p.lifetime[1] < p.lifetime[0] + || p.bursts.len() > 1024 + { + return Err("invalid particle limits, emission rate or lifetime".into()); + } + Ok(()) +} +impl EffectComponent { + /// Remaps authored object references when instantiating or duplicating a scene. + pub fn remap_entities(&mut self, map: &std::collections::BTreeMap) { + fn remap(id: &mut Option, map: &std::collections::BTreeMap) { + if let Some(value) = id.as_mut() + && let Some(replacement) = map.get(value) + { + *value = *replacement; + } + } + remap(&mut self.common.attachment_target, map); + match &mut self.kind { + EffectKind::Beam(p) => { + remap(&mut p.start_target, map); + remap(&mut p.end_target, map); + } + EffectKind::Shockwave(p) | EffectKind::WaterRipple(p) => remap(&mut p.target, map), + EffectKind::Dissolve(p) => remap(&mut p.target, map), + EffectKind::AnimatedSurface(p) => remap(&mut p.target, map), + EffectKind::LightPulse(p) => remap(&mut p.target, map), + EffectKind::CameraShake(p) => remap(&mut p.target, map), + EffectKind::ScreenFlash(p) => remap(&mut p.target, map), + EffectKind::LightShaft(p) => remap(&mut p.light, map), + EffectKind::LensFlare(p) => remap(&mut p.light, map), + EffectKind::Outline(p) => { + for target in &mut p.targets { + if let Some(replacement) = map.get(target) { + *target = *replacement; + } + } + } + _ => {} + } + } +} +impl EffectComponent { + // One dispatch keeps validation rules next to all 26 serialized variants. + #[allow(clippy::too_many_lines)] + fn validate_values(&self) -> Result<(), String> { + fn values(v: &[f32]) -> Result<(), String> { + if v.iter().all(|x| x.is_finite() && x.abs() <= 1.0e8) { + Ok(()) + } else { + Err("effect values must be finite and within +/-100000000".into()) + } + } + fn dimensions(v: &[f32]) -> Result<(), String> { + values(v)?; + if v.iter().all(|x| *x > 0.0) { + Ok(()) + } else { + Err("effect dimensions must be positive".into()) + } + } + fn curve(c: &Curve) -> Result<(), String> { + if c.keys.is_empty() || c.keys.len() > 1024 { + return Err("curves require 1..1024 keys".into()); + } + for key in &c.keys { + values(&[key.time, key.value])?; + if !(0.0..=1.0).contains(&key.time) { + return Err("curve times must be normalized to 0..1".into()); + } + } + Ok(()) + } + fn gradient(g: &Gradient) -> Result<(), String> { + if g.keys.is_empty() || g.keys.len() > 1024 { + return Err("gradients require 1..1024 keys".into()); + } + for k in &g.keys { + values(&k.color)?; + if !k.time.is_finite() || !(0.0..=1.0).contains(&k.time) { + return Err("gradient times must be normalized to 0..1".into()); + } + } + Ok(()) + } + fn appearance(a: &EffectAppearance) -> Result<(), String> { + gradient(&a.color)?; + curve(&a.opacity)?; + curve(&a.size)?; + values(&[a.emission])?; + values(&a.uv_scale)?; + values(&a.uv_scroll) + } + fn particles(p: &ParticleEmitter) -> Result<(), String> { + if p.mesh_spawn_triangles.len() > 100_000 { + return Err("mesh surface emission supports at most 100_000 triangles".into()); + } + if p.shape == SpawnShape::MeshSurface && p.mesh_spawn_triangles.is_empty() { + return Err("mesh surface emission requires loaded mesh_spawn_triangles".into()); + } + for triangle in &p.mesh_spawn_triangles { + for point in triangle { + values(point)?; + } + } + values(&[p.prewarm_time])?; + if p.prewarm_time < 0.0 || p.prewarm_time > 10.0 { + return Err("prewarm time must be 0..10 seconds".into()); + } + values(&p.dimensions)?; + values(&p.lifetime)?; + values(&p.speed)?; + values(&p.direction)?; + values(&p.start_size)?; + values(&p.gravity)?; + values(&p.force)?; + values(&[ + p.rate, + p.rate_over_distance, + p.drag, + p.wind_influence, + p.noise, + p.angular_velocity, + p.inherit_velocity, + p.velocity_limit, + p.bounce, + p.friction, + p.sub_emitter_probability, + p.flipbook_fps, + p.stretch, + ])?; + appearance(&p.appearance)?; + if p.lifetime[1] < p.lifetime[0] + || p.speed[1] < p.speed[0] + || p.start_size[1] < p.start_size[0] + || p.max_generations > 8 + || p.flipbook.contains(&0) + || p.flipbook.iter().any(|n| *n > 1024) + { + return Err( + "invalid particle range, sub-emitter generations or flipbook dimensions".into(), + ); + } + for b in &p.bursts { + values(&[b.time, b.probability, b.interval])?; + if b.time < 0.0 + || !(0.0..=1.0).contains(&b.probability) + || b.count > 100_000 + || b.cycles > 1024 + || b.interval < 0.0 + { + return Err("invalid timed burst".into()); + } + } + Ok(()) + } + if let Some(bounds) = &self.common.bounds { + values(&bounds.center)?; + dimensions(&bounds.half_extents)?; + } + if self.common.parameters.len() > 256 { + return Err("effect parameters are limited to 256 bindings".into()); + } + for (key, value) in &self.common.parameters { + if key.len() > 128 { + return Err("effect parameter name exceeds128 bytes".into()); + } + match value { + EffectParameter::Scalar(v) => values(&[*v])?, + EffectParameter::Vector(v) => values(v)?, + EffectParameter::Color(v) => values(v)?, + EffectParameter::Boolean(_) => {} + } + } + match &self.kind { + EffectKind::Trail(p) => { + values(&[ + p.lifetime, + p.width, + p.min_vertex_distance, + p.teleport_distance, + ])?; + appearance(&p.appearance) + } + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => particles(p), + EffectKind::Beam(p) => { + values(&p.start)?; + values(&p.end)?; + values(&[p.width, p.noise_amplitude, p.noise_frequency, p.noise_speed])?; + if p.branch_count > 32 { + return Err("beam branches must be <=32".into()); + } + appearance(&p.appearance) + } + EffectKind::Shockwave(p) | EffectKind::WaterRipple(p) => { + values(&p.source_velocity)?; + values(&[ + p.wake_angle, + p.foam_intensity, + p.foam_lifetime, + p.normal_strength, + ])?; + if p.max_disturbances == 0 || p.max_disturbances > 256 || p.foam_lifetime <= 0.0 { + return Err( + "water disturbances must be 1..256 and foam lifetime positive".into(), + ); + } + values(&[ + p.initial_radius, + p.final_radius, + p.thickness, + p.vertical_offset, + p.amplitude, + p.wavelength, + p.damping, + ])?; + if p.initial_radius < 0.0 || p.final_radius <= 0.0 || p.thickness <= 0.0 { + return Err("ring radius and thickness must be positive".into()); + } + appearance(&p.appearance) + } + EffectKind::Ribbon(p) => { + for v in &p.points { + values(v)?; + } + values(&[p.width, p.twist])?; + appearance(&p.appearance) + } + EffectKind::ForceField(p) | EffectKind::Wind(p) => { + dimensions(&p.dimensions)?; + values(&p.direction)?; + values(&[p.strength, p.falloff, p.turbulence, p.frequency]) + } + EffectKind::CollisionVolume(p) => { + dimensions(&p.dimensions)?; + values(&[p.bounce, p.friction]) + } + EffectKind::ProjectileTracer(p) => { + values(&p.velocity)?; + dimensions(&[p.length, p.width])?; + appearance(&p.appearance) + } + EffectKind::Fog(p) => { + dimensions(&p.dimensions)?; + values(&p.color)?; + values(&[ + p.density, + p.height_falloff, + p.edge_falloff, + p.noise_scale, + p.noise_speed, + p.anisotropy, + ])?; + if p.steps < 4 || p.steps > 128 || p.density < 0.0 { + return Err("fog requires 4..128 steps and nonnegative density".into()); + } + Ok(()) + } + EffectKind::Decal(p) => { + dimensions(&p.dimensions)?; + appearance(&p.appearance)?; + values(&[p.roughness, p.metallic, p.edge_fade, p.angle_fade]) + } + EffectKind::Weather(p) => { + values(&[p.intensity])?; + validate_particles(&p.emitter)?; + particles(&p.emitter) + } + EffectKind::HeatHaze(p) => { + values(&[p.strength, p.noise_scale, p.speed, p.radius, p.edge_falloff]) + } + EffectKind::LightShaft(p) => { + values(&p.color)?; + values(&[p.intensity, p.density, p.anisotropy, p.max_distance])?; + if p.samples < 4 || p.samples > 128 { + return Err("light shaft samples must be 4..128".into()); + } + Ok(()) + } + EffectKind::FoliageScatter(p) => { + dimensions(&p.dimensions)?; + values(&p.scale)?; + values(&[p.density, p.wind_response])?; + if p.max_instances > 100_000 || p.meshes.len() > 256 { + return Err("scatter capacity exceeds supported limits".into()); + } + appearance(&p.appearance) + } + EffectKind::Dissolve(p) => { + values(&p.edge_color)?; + values(&[p.progress, p.edge_width, p.edge_emission, p.noise_scale])?; + curve(&p.curve) + } + EffectKind::AnimatedSurface(p) => { + values(&p.uv_scroll)?; + values(&p.uv_scale)?; + values(&[p.uv_rotation, p.emission, p.speed])?; + gradient(&p.color) + } + EffectKind::LensFlare(p) => { + values(&p.color)?; + values(&[ + p.intensity, + p.scale, + p.ghost_spacing, + p.streak_length, + p.fade_speed, + ])?; + if p.ghosts > 16 { + return Err("lens flare ghosts must be <=16".into()); + } + Ok(()) + } + EffectKind::LightPulse(p) => { + curve(&p.intensity)?; + gradient(&p.color)?; + values(&[p.frequency, p.randomness, p.phase]) + } + EffectKind::PostProcess(p) => { + dimensions(&p.dimensions)?; + values(&p.tint)?; + values(&[ + p.blend_distance, + p.weight, + p.exposure, + p.saturation, + p.contrast, + p.brightness, + p.bloom, + p.vignette, + p.chromatic_aberration, + p.grain, + p.depth_of_field, + ]) + } + EffectKind::Outline(p) => { + values(&p.color)?; + values(&[p.thickness, p.pulse_speed])?; + if p.targets.len() > 100_000 { + return Err("too many outline targets".into()); + } + Ok(()) + } + EffectKind::CameraShake(p) => { + values(&p.position_amplitude)?; + values(&p.rotation_amplitude)?; + values(&[p.frequency, p.radius])?; + curve(&p.falloff) + } + EffectKind::ScreenFlash(p) => { + values(&p.color)?; + values(&[p.opacity, p.fade_in, p.fade_out, p.vignette]) + } + EffectKind::ReflectionProbe(p) => { + if p.refresh != ProbeRefresh::Baked { + return Err("dynamic reflection probe capture is unsupported; select Baked and assign a panorama asset".into()); + } + dimensions(&p.dimensions)?; + values(&[p.blend_distance, p.update_interval])?; + if !(16..=2048).contains(&p.resolution) || !p.resolution.is_power_of_two() { + return Err("probe resolution must be power-of-two 16..2048".into()); + } + Ok(()) + } + } + } +} +#[cfg(test)] +#[allow(clippy::float_cmp)] // Exact interpolation and zero-state invariants. +mod tests { + use super::*; + #[test] + fn every_effect_default_validates() { + let all = EffectKind::all_defaults(); + assert_eq!(all.len(), 26); + for kind in all { + let effect = EffectComponent::new(kind); + assert_eq!(effect.validate(), Ok(()), "{}", effect.kind.name()); + } + } + #[test] + fn curves_accept_unsorted_keys_and_clamp() { + let c = Curve { + keys: vec![ + CurveKey { + time: 1.0, + value: 10.0, + }, + CurveKey { + time: 0.0, + value: 0.0, + }, + ], + }; + assert_eq!(c.sample(0.25), 2.5); + assert_eq!(c.sample(-1.0), 0.0); + assert_eq!(c.sample(2.0), 10.0); + } + #[test] + fn gradients_interpolate_rgba() { + let g = Gradient { + keys: vec![ + GradientKey { + time: 0.0, + color: [0.0; 4], + }, + GradientKey { + time: 1.0, + color: [1.0; 4], + }, + ], + }; + assert_eq!(g.sample(0.5), [0.5; 4]); + } + #[test] + fn rejects_nonfinite_and_unbounded_configs() { + let mut e = EffectComponent::new(EffectKind::HeatHaze(DistortionEffect::default())); + if let EffectKind::HeatHaze(p) = &mut e.kind { + p.strength = f32::NAN; + } + assert!(e.validate().is_err()); + let mut e = EffectComponent::default(); + if let EffectKind::Particle(p) = &mut e.kind { + p.max_particles = 1_000_000; + } + assert!(e.validate().is_err()); + } + #[test] + fn scene_references_remap() { + let source = EntityId::new(); + let dest = EntityId::new(); + let mut e = EffectComponent::new(EffectKind::Beam(BeamRenderer { + end_target: Some(source), + ..Default::default() + })); + e.common.attachment_target = Some(source); + e.remap_entities(&[(source, dest)].into_iter().collect()); + assert_eq!(e.common.attachment_target, Some(dest)); + if let EffectKind::Beam(b) = e.kind { + assert_eq!(b.end_target, Some(dest)); + } else { + panic!("expected beam") + } + } +} +/// A reusable gradient source with a stable per-particle random factor. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ColorParameter { + Gradient(Gradient), + BetweenGradients { min: Gradient, max: Gradient }, +} +impl ColorParameter { + pub fn sample(&self, time: f32, random: f32) -> [f32; 4] { + match self { + Self::Gradient(g) => g.sample(time), + Self::BetweenGradients { min, max } => { + let a = min.sample(time); + let b = max.sample(time); + std::array::from_fn(|i| a[i] + (b[i] - a[i]) * random.clamp(0.0, 1.0)) + } + } + } +} +/// Independent, normalized curves for all three vector channels. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct VectorCurve { + pub x: Curve, + pub y: Curve, + pub z: Curve, +} +impl VectorCurve { + pub fn sample(&self, time: f32) -> [f32; 3] { + [ + self.x.sample(time), + self.y.sample(time), + self.z.sample(time), + ] + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum EffectParameter { + Scalar(f32), + Vector([f32; 3]), + Color([f32; 4]), + Boolean(bool), +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EffectBounds { + pub center: [f32; 3], + pub half_extents: [f32; 3], +} +impl EffectComponent { + /// Applies named instance parameters without altering shared asset defaults. + /// Supported bindings: enabled, visible, `emission_rate`, intensity, color, + /// speed, size, gravity, force, density, progress, width and radius. + pub fn resolve_parameters(&mut self) { + let parameters = std::mem::take(&mut self.common.parameters); + for (name, value) in ¶meters { + match (name.as_str(), value) { + ("enabled", EffectParameter::Boolean(v)) => self.common.enabled = *v, + ("visible", EffectParameter::Boolean(v)) => self.common.visible = *v, + ("emission_rate", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => p.rate = v.max(0.0), + EffectKind::Weather(p) => p.emitter.rate = v.max(0.0), + _ => {} + }, + ("gravity", EffectParameter::Vector(v)) => match &mut self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => p.gravity = *v, + EffectKind::Weather(p) => p.emitter.gravity = *v, + _ => {} + }, + ("force", EffectParameter::Vector(v)) => match &mut self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => p.force = *v, + EffectKind::Weather(p) => p.emitter.force = *v, + _ => {} + }, + ("speed", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => p.speed = [*v; 2], + EffectKind::Weather(p) => p.emitter.speed = [*v; 2], + _ => {} + }, + ("size", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => { + p.start_size = [v.max(0.0); 2]; + } + EffectKind::Weather(p) => p.emitter.start_size = [v.max(0.0); 2], + _ => {} + }, + ("intensity", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Weather(p) => p.intensity = v.max(0.0), + EffectKind::LightShaft(p) => p.intensity = v.max(0.0), + EffectKind::LensFlare(p) => p.intensity = v.max(0.0), + EffectKind::ForceField(p) | EffectKind::Wind(p) => p.strength = *v, + EffectKind::ScreenFlash(p) => p.opacity = v.clamp(0.0, 1.0), + _ => { + if let Some(a) = self.appearance_mut() { + a.emission = v.max(0.0); + } + } + }, + ("color", EffectParameter::Color(v)) => { + if let Some(a) = self.appearance_mut() { + a.color = Gradient::constant(*v); + } else { + match &mut self.kind { + EffectKind::ScreenFlash(p) => p.color = *v, + EffectKind::Outline(p) => p.color = *v, + EffectKind::Fog(p) => p.color = [v[0], v[1], v[2]], + _ => {} + } + } + } + ("density", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Fog(p) => p.density = v.max(0.0), + EffectKind::FoliageScatter(p) => p.density = v.max(0.0), + _ => {} + }, + ("progress", EffectParameter::Scalar(v)) => { + if let EffectKind::Dissolve(p) = &mut self.kind { + p.progress = v.clamp(0.0, 1.0); + } + } + ("width", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Trail(p) => p.width = v.max(0.0001), + EffectKind::Beam(p) => p.width = v.max(0.0001), + EffectKind::Ribbon(p) => p.width = v.max(0.0001), + EffectKind::ProjectileTracer(p) => p.width = v.max(0.0001), + _ => {} + }, + ("radius", EffectParameter::Scalar(v)) => match &mut self.kind { + EffectKind::Shockwave(p) | EffectKind::WaterRipple(p) => { + p.final_radius = v.max(0.0001); + } + EffectKind::HeatHaze(p) => p.radius = v.max(0.0001), + EffectKind::CameraShake(p) => p.radius = v.max(0.0001), + _ => {} + }, + _ => {} + } + } + self.common.parameters = parameters; + } + fn appearance_mut(&mut self) -> Option<&mut EffectAppearance> { + match &mut self.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => Some(&mut p.appearance), + EffectKind::Trail(p) => Some(&mut p.appearance), + EffectKind::Beam(p) => Some(&mut p.appearance), + EffectKind::Shockwave(p) | EffectKind::WaterRipple(p) => Some(&mut p.appearance), + EffectKind::Ribbon(p) => Some(&mut p.appearance), + EffectKind::ProjectileTracer(p) => Some(&mut p.appearance), + EffectKind::Decal(p) => Some(&mut p.appearance), + EffectKind::Weather(p) => Some(&mut p.emitter.appearance), + EffectKind::FoliageScatter(p) => Some(&mut p.appearance), + _ => None, + } + } +} +#[cfg(test)] +#[allow(clippy::float_cmp)] // Exact parameter transfer is part of the API. +mod parameter_tests { + use super::*; + #[test] + fn named_parameters_modify_instances_and_preserve_binding_values() { + let mut effect = EffectComponent::default(); + effect + .common + .parameters + .insert("emission_rate".into(), EffectParameter::Scalar(75.0)); + effect + .common + .parameters + .insert("color".into(), EffectParameter::Color([0.0, 0.5, 1.0, 1.0])); + effect.resolve_parameters(); + let EffectKind::Particle(p) = &effect.kind else { + unreachable!() + }; + assert_eq!(p.rate, 75.0); + assert_eq!(p.appearance.color.sample(0.5), [0.0, 0.5, 1.0, 1.0]); + assert_eq!(effect.common.parameters.len(), 2); + assert!(effect.validate().is_ok()); + } + #[test] + fn bounds_and_prewarm_are_validated() { + let mut effect = EffectComponent::default(); + effect.common.bounds = Some(EffectBounds { + center: [0.0; 3], + half_extents: [-1.0, 1.0, 1.0], + }); + assert!(effect.validate().is_err()); + effect.common.bounds = None; + if let EffectKind::Particle(p) = &mut effect.kind { + p.prewarm_time = 100.0; + } + assert!(effect.validate().is_err()); + } +} diff --git a/Engine/crates/engine-world/src/effects/modifiers.rs b/Engine/crates/engine-world/src/effects/modifiers.rs new file mode 100644 index 00000000..198bcd5d --- /dev/null +++ b/Engine/crates/engine-world/src/effects/modifiers.rs @@ -0,0 +1,351 @@ +use super::{ + BlendMode, EffectFrame, EffectInput, EffectKind, LightModifier, RenderDecal, RenderFogVolume, + RenderLensFlare, RenderLightShaft, RenderOutline, RenderReflectionProbe, SurfaceModifier, +}; +use glam::Vec3; + +/// Extract one active modifier without mutating authored transforms, lights or materials. +/// Call in ascending priority order so higher priority post volumes blend last. +// Variant dispatch follows the authored model. Cosmetic seed-to-phase conversion is intentional. +#[allow(clippy::too_many_lines, clippy::cast_precision_loss)] +pub(super) fn extract_modifier( + input: &EffectInput, + camera: Vec3, + time: f32, + progress: f32, + frame: &mut EffectFrame, +) { + let center = input.transform.transform_point3(Vec3::ZERO); + match &input.effect.kind { + EffectKind::PostProcess(p) => { + let local = input.transform.inverse().transform_point3(camera).abs() + - Vec3::from_array(p.dimensions) * 0.5; + let distance = local.max(Vec3::ZERO).length(); + let weight = if p.global { + p.weight + } else { + p.weight * (1.0 - distance / p.blend_distance.max(0.0001)).clamp(0.0, 1.0) + } + .clamp(0.0, 1.0); + let out = &mut frame.post; + mix(&mut out.exposure, p.exposure, weight); + mix(&mut out.saturation, p.saturation, weight); + mix(&mut out.contrast, p.contrast, weight); + mix(&mut out.brightness, p.brightness, weight); + mix(&mut out.bloom, p.bloom, weight); + mix(&mut out.vignette, p.vignette, weight); + mix( + &mut out.chromatic_aberration, + p.chromatic_aberration, + weight, + ); + mix(&mut out.film_grain, p.grain, weight); + mix(&mut out.depth_of_field, p.depth_of_field, weight); + for i in 0..3 { + mix(&mut out.tint[i], p.tint[i], weight); + } + } + EffectKind::HeatHaze(p) => { + let attenuation = (1.0 - center.distance(camera) / p.radius.max(0.001)) + .clamp(0.0, 1.0) + .powf(p.edge_falloff.max(0.001)); + frame.post.distortion += p.strength * attenuation; + frame.post.distortion_scale = p.noise_scale; + frame.post.distortion_speed = p.speed; + } + EffectKind::ScreenFlash(p) => { + let attack = if p.fade_in > 0.0 { + (time / p.fade_in).clamp(0.0, 1.0) + } else { + 1.0 + }; + let release = if p.fade_out > 0.0 { + ((input.effect.common.duration - time) / p.fade_out).clamp(0.0, 1.0) + } else { + 1.0 + }; + let alpha = (p.opacity * p.color[3] * attack * release).clamp(0.0, 1.0); + match p.blend { + BlendMode::Additive => { + for i in 0..3 { + frame.post.overlay_add[i] += p.color[i] * alpha; + } + frame.post.vignette += p.vignette * alpha; + return; + } + BlendMode::Multiply => { + for i in 0..3 { + frame.post.overlay_multiply[i] *= 1.0 + (p.color[i] - 1.0) * alpha; + } + frame.post.vignette += p.vignette * alpha; + return; + } + BlendMode::Alpha => {} + } + let old = frame.post.overlay; + let a = alpha + old[3] * (1.0 - alpha); + if a > 0.0 { + for (i, c) in p.color[..3].iter().enumerate() { + frame.post.overlay[i] = (*c * alpha + old[i] * old[3] * (1.0 - alpha)) / a; + } + } + frame.post.overlay[3] = a; + frame.post.vignette += p.vignette * alpha; + } + EffectKind::CameraShake(p) => { + let attenuation = if p.radius > 0.0 { + (1.0 - center.distance(camera) / p.radius).clamp(0.0, 1.0) + } else { + 1.0 + }; + let strength = p.falloff.sample(progress) * attenuation; + let seed = input.effect.common.random_seed as f32 * 0.001; + let phase = time * p.frequency * std::f32::consts::TAU; + let n = Vec3::new( + (phase + seed).sin(), + (phase * 1.173 + seed * 2.0).sin(), + (phase * 0.793 + seed * 3.0).sin(), + ) * strength; + frame.camera_offset += n * Vec3::from_array(p.position_amplitude); + frame.camera_rotation += n * Vec3::from_array(p.rotation_amplitude); + } + EffectKind::LightPulse(p) => { + if let Some(target) = p.target { + let phase = (time * p.frequency + p.phase).rem_euclid(1.0); + let jitter = 1.0 + + p.randomness + * (hash_noise(time * p.frequency * 10.0, input.effect.common.random_seed) + - 0.5) + * 2.0; + frame.lights.push(LightModifier { + target, + color: p.color.sample(phase)[..3].try_into().unwrap_or([1.0; 3]), + intensity: (p.intensity.sample(phase) * jitter).max(0.0), + }); + } + } + EffectKind::AnimatedSurface(p) => { + if let Some(target) = p.target { + frame.surfaces.push(SurfaceModifier { + target, + uv_offset: [ + p.uv_scroll[0] * time * p.speed, + p.uv_scroll[1] * time * p.speed, + ], + uv_scale: p.uv_scale, + uv_rotation: p.uv_rotation, + dissolve_noise_scale: 10.0, + color: p.color.sample(progress), + emission: p.emission, + dissolve: 0.0, + edge_width: 0.0, + edge_color: [0.0; 3], + }); + } + } + EffectKind::Dissolve(p) => { + if let Some(target) = p.target { + frame.surfaces.push(SurfaceModifier { + target, + uv_offset: [0.0; 2], + uv_scale: [1.0; 2], + uv_rotation: 0.0, + dissolve_noise_scale: p.noise_scale, + color: [1.0; 4], + emission: p.edge_emission, + dissolve: (p.progress + p.curve.sample(progress)).clamp(0.0, 1.0), + edge_width: p.edge_width, + edge_color: p.edge_color, + }); + } + } + EffectKind::Fog(p) => frame.fog.push(RenderFogVolume { + entity: input.entity, + inverse_transform: input.transform.inverse(), + dimensions: p.dimensions, + shape: p.shape, + density: p.density, + color: p.color, + height_falloff: p.height_falloff, + edge_falloff: p.edge_falloff, + noise_scale: p.noise_scale, + noise_speed: p.noise_speed, + steps: p.steps.clamp(4, 128), + }), + EffectKind::LightShaft(p) => frame.shafts.push(RenderLightShaft { + light: p.light, + position: center, + color: p.color, + intensity: p.intensity, + density: p.density, + samples: p.samples.clamp(4, 128), + max_distance: p.max_distance, + }), + EffectKind::ReflectionProbe(p) => { + if let Some(cubemap) = p.cubemap { + frame.probes.push(RenderReflectionProbe { + entity: input.entity, + position: center, + dimensions: p.dimensions, + cubemap, + blend_distance: p.blend_distance, + priority: input.effect.common.priority, + }); + } + } + EffectKind::Decal(p) => frame.decals.push(RenderDecal { + entity: input.entity, + transform: input.transform, + dimensions: p.dimensions, + appearance: p.appearance.clone(), + normal_map: p.normal_map, + orm_map: p.orm_map, + roughness: p.roughness, + metallic: p.metallic, + edge_fade: p.edge_fade, + angle_fade: p.angle_fade, + layers: p.layers, + }), + EffectKind::Outline(p) => { + let mut color = p.color; + if p.pulse_speed > 0.0 { + color[3] *= 0.5 + 0.5 * (time * p.pulse_speed * std::f32::consts::TAU).sin(); + } + frame.outlines.push(RenderOutline { + targets: p.targets.clone(), + color, + thickness: p.thickness, + through_walls: p.through_walls, + }); + } + EffectKind::LensFlare(p) => frame.flares.push(RenderLensFlare { + light: p.light, + position: center, + intensity: p.intensity * (time * p.fade_speed.max(0.0)).clamp(0.0, 1.0), + scale: p.scale, + color: p.color, + ghosts: p.ghosts.min(16), + ghost_spacing: p.ghost_spacing, + streak_length: p.streak_length, + }), + _ => {} + } + frame.post.time = time; +} +fn mix(a: &mut f32, b: f32, w: f32) { + *a += (b - *a) * w; +} +// Intentional wrapping integer hash and normalized floating point output. +#[allow( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + clippy::cast_precision_loss +)] +fn hash_noise(t: f32, seed: u64) -> f32 { + let n = (t.floor() as i64 as u64) ^ seed; + let n = n.wrapping_mul(0x9E37_79B9_7F4A_7C15); + ((n ^ (n >> 32)) as u32) as f32 / u32::MAX as f32 +} +#[cfg(test)] +#[allow(clippy::float_cmp)] // Exact compositing identities are intentional here. +mod tests { + use super::*; + use crate::EntityId; + use crate::effects::{ + CameraShake, EffectComponent, LightPulse, PostProcessVolume, ScreenFlash, + }; + use glam::Mat4; + fn input(kind: EffectKind) -> EffectInput { + EffectInput { + entity: EntityId::new(), + transform: Mat4::IDENTITY, + effect: EffectComponent::new(kind), + } + } + #[test] + fn local_post_volume_blends_at_border() { + let p = PostProcessVolume { + global: false, + dimensions: [2.0; 3], + blend_distance: 2.0, + exposure: 4.0, + ..Default::default() + }; + let mut frame = EffectFrame::default(); + extract_modifier( + &input(EffectKind::PostProcess(p)), + Vec3::new(2.0, 0.0, 0.0), + 0.0, + 0.0, + &mut frame, + ); + assert_eq!(frame.post.exposure, 2.0); + } + #[test] + fn overlay_composes_alpha_and_respects_attack_release() { + let i = input(EffectKind::ScreenFlash(ScreenFlash { + fade_in: 1.0, + fade_out: 1.0, + opacity: 0.5, + ..Default::default() + })); + let mut f = EffectFrame::default(); + extract_modifier(&i, Vec3::ZERO, 0.0, 0.0, &mut f); + assert_eq!(f.post.overlay[3], 0.0); + extract_modifier(&i, Vec3::ZERO, 2.0, 0.4, &mut f); + assert_eq!(f.post.overlay[3], 0.5); + extract_modifier(&i, Vec3::ZERO, 2.0, 0.4, &mut f); + assert_eq!(f.post.overlay[3], 0.75); + } + #[test] + fn shake_is_additive_and_bounded_by_radius() { + let i = input(EffectKind::CameraShake(CameraShake::default())); + let mut f = EffectFrame::default(); + extract_modifier(&i, Vec3::new(100.0, 0.0, 0.0), 0.123, 0.1, &mut f); + assert_eq!(f.camera_offset, Vec3::ZERO); + extract_modifier(&i, Vec3::ZERO, 0.123, 0.1, &mut f); + let first = f.camera_offset; + assert!(first.length() > 0.0); + extract_modifier(&i, Vec3::ZERO, 0.123, 0.1, &mut f); + assert!(f.camera_offset.abs_diff_eq(first * 2.0, 1e-6)); + } + #[test] + fn modifiers_leave_authored_properties_unchanged() { + let target = EntityId::new(); + let i = input(EffectKind::LightPulse(LightPulse { + target: Some(target), + ..Default::default() + })); + let original = i.effect.clone(); + let mut frame = EffectFrame::default(); + extract_modifier(&i, Vec3::ZERO, 0.25, 0.25, &mut frame); + assert_eq!(i.effect, original); + assert_eq!(frame.lights[0].target, target); + assert_eq!(frame.lights[0].intensity, 0.25); + } + #[test] + fn flash_add_and_multiply_have_distinct_output_channels() { + let mut frame = EffectFrame::default(); + let add = input(EffectKind::ScreenFlash(ScreenFlash { + color: [0.5, 0.25, 0.0, 1.0], + opacity: 0.5, + fade_in: 0.0, + fade_out: 0.0, + blend: BlendMode::Additive, + ..Default::default() + })); + extract_modifier(&add, Vec3::ZERO, 0.2, 0.2, &mut frame); + assert_eq!(frame.post.overlay_add, [0.25, 0.125, 0.0]); + let mul = input(EffectKind::ScreenFlash(ScreenFlash { + color: [0.0, 0.5, 1.0, 1.0], + opacity: 0.5, + fade_in: 0.0, + fade_out: 0.0, + blend: BlendMode::Multiply, + ..Default::default() + })); + extract_modifier(&mul, Vec3::ZERO, 0.2, 0.2, &mut frame); + assert_eq!(frame.post.overlay_multiply, [0.5, 0.75, 1.0]); + assert_eq!(frame.post.overlay[3], 0.0); + } +} diff --git a/Engine/crates/engine-world/src/effects/presets.rs b/Engine/crates/engine-world/src/effects/presets.rs new file mode 100644 index 00000000..f65c31a1 --- /dev/null +++ b/Engine/crates/engine-world/src/effects/presets.rs @@ -0,0 +1,660 @@ +//! Editable, versioned effect assets. A composite is an ordered set of delayed +//! effects; every child has the same configuration as an ordinary scene effect. +use super::{ + BeamRenderer, BlendMode, Burst, CameraShake, DissolveEffect, EffectAppearance, EffectComponent, + EffectDefaults, EffectKind, EffectSource, FogVolume, Gradient, OutlineEffect, ParticleEmitter, + RibbonRenderer, RingEffect, ScreenFlash, SpawnShape, TrailRenderer, WeatherType, WeatherVolume, +}; +use crate::{EntityId, EntitySnapshot, LocalTransform}; +use glam::Mat4; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EffectAsset { + pub version: u32, + pub name: String, + pub effects: Vec, + /// Original child identities, used only to remap internal entity references. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entity_ids: Vec, + /// Column-major affine child transforms relative to the asset group. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transforms: Vec<[f32; 16]>, +} +impl EffectAsset { + pub fn new(name: impl Into, effects: Vec) -> Self { + Self { + version: 1, + name: name.into(), + effects, + entity_ids: Vec::new(), + transforms: Vec::new(), + } + } + /// Checks the asset schema, metadata and child configuration. + /// # Errors + /// Returns a diagnostic for incompatible versions or invalid child data. + pub fn validate(&self) -> Result<(), String> { + if self.version != 1 { + return Err(format!("unsupported effect asset version {}", self.version)); + } + if self.name.is_empty() || self.name.len() > 256 { + return Err("effect asset name must have 1..256 bytes".into()); + } + if self.effects.is_empty() || self.effects.len() > 128 { + return Err("effect asset requires 1..128 child effects".into()); + } + for length in [self.entity_ids.len(), self.transforms.len()] { + if length != 0 && length != self.effects.len() { + return Err("effect child metadata length must match the effects list".into()); + } + } + if self.entity_ids.iter().collect::>().len() != self.entity_ids.len() { + return Err("effect asset child identities must be unique".into()); + } + for matrix in &self.transforms { + let matrix = Mat4::from_cols_array(matrix); + let (scale, rotation, translation) = matrix.to_scale_rotation_translation(); + let transform = LocalTransform { + translation, + rotation, + scale, + }; + if !matrix.is_finite() + || !transform.is_finite() + || (matrix - transform.matrix()) + .to_cols_array() + .iter() + .any(|v| v.abs() > 0.0001) + { + return Err("effect child transform must be a finite nonsingular translation/rotation/scale matrix".into()); + } + } + for effect in &self.effects { + effect.validate()?; + } + Ok(()) + } + /// Encodes the validated versioned asset. + /// # Errors + /// Returns validation or RON encoding errors. + pub fn to_bytes(&self) -> Result, String> { + self.validate()?; + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .map(String::into_bytes) + .map_err(|error| error.to_string()) + } + /// Reads and validates a bounded asset file. + /// # Errors + /// Returns size, schema, RON decoding or child validation errors. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > 4 * 1024 * 1024 { + return Err("effect asset exceeds 4 MiB".into()); + } + let asset: Self = ron::de::from_bytes(bytes).map_err(|error| error.to_string())?; + asset.validate()?; + Ok(asset) + } + /// Returns independent authored instances: editing a child cannot mutate the + /// library asset or another instance. Delays are relative to the group start. + pub fn instantiate(&self) -> Vec { + self.effects.clone() + } + /// Retains the existing asset system's stable source identity on every child. + /// Embedded configuration is an instance override until explicitly restored. + pub fn instantiate_from(&self, asset: crate::AssetId) -> Vec { + self.effects + .iter() + .enumerate() + .map(|(child, effect)| { + let mut instance = effect.clone(); + instance.source = Some(EffectSource { + asset, + child: u32::try_from(child).unwrap_or(u32::MAX), + baseline: Some(Box::new(EffectDefaults::from_component(&instance))), + }); + instance + }) + .collect() + } + /// Allocates independent child identities and preserves authored relative transforms. + /// Internal effect references and source baselines use the newly allocated IDs. + /// Legacy assets without metadata instantiate at the group origin. + pub fn instances_with_ids(&self, asset_id: Option) -> Vec { + let effects = asset_id.map_or_else(|| self.instantiate(), |id| self.instantiate_from(id)); + let ids: Vec<_> = effects.iter().map(|_| EntityId::new()).collect(); + let mapping: BTreeMap<_, _> = self + .entity_ids + .iter() + .copied() + .zip(ids.iter().copied()) + .collect(); + effects + .into_iter() + .enumerate() + .map(|(index, mut effect)| { + effect.remap_entities(&mapping); + let baseline = EffectDefaults::from_component(&effect); + if let Some(source) = &mut effect.source { + source.baseline = Some(Box::new(baseline)); + } + let local_transform = + self.transforms + .get(index) + .map_or_else(LocalTransform::default, |matrix| { + let (scale, rotation, translation) = + Mat4::from_cols_array(matrix).to_scale_rotation_translation(); + LocalTransform { + translation, + rotation, + scale, + } + }); + EntitySnapshot { + id: ids[index], + name: Some(format!("{} {}", self.name, index + 1)), + local_transform, + effect: Some(effect), + ..EntitySnapshot::default() + } + }) + .collect() + } + /// Restores one child resolved through authoritative `.rmeta` data. + /// # Errors + /// Returns a diagnostic when no source or matching child exists. + pub fn restore(&self, instance: &mut EffectComponent) -> Result<(), String> { + let mut source = instance + .source + .clone() + .ok_or("effect instance has no source asset")?; + let defaults = self + .effects + .get(source.child as usize) + .ok_or("effect source child no longer exists")?; + let mut restored = defaults.clone(); + source.baseline = Some(Box::new(EffectDefaults::from_component(defaults))); + restored.source = Some(source); + *instance = restored; + Ok(()) + } +} + +fn particle( + name: &str, + color: [f32; 4], + rate: f32, + lifetime: [f32; 2], + speed: [f32; 2], + size: [f32; 2], +) -> EffectAsset { + let emitter = ParticleEmitter { + rate, + lifetime, + speed, + start_size: size, + appearance: EffectAppearance { + color: Gradient::constant(color), + blend: BlendMode::Additive, + ..Default::default() + }, + ..Default::default() + }; + EffectAsset::new( + name, + vec![EffectComponent::new(EffectKind::Particle(emitter))], + ) +} +fn trail(name: &str, color: [f32; 4], width: f32, lifetime: f32) -> EffectAsset { + EffectAsset::new( + name, + vec![EffectComponent::new(EffectKind::Trail(TrailRenderer { + width, + lifetime, + appearance: EffectAppearance { + color: Gradient::constant(color), + ..Default::default() + }, + ..Default::default() + }))], + ) +} +fn one(name: &str, kind: EffectKind) -> EffectAsset { + EffectAsset::new(name, vec![EffectComponent::new(kind)]) +} + +/// Built-in presets contain authored configuration, never live simulation state +/// or external hotlinked resources. They can be serialized as `.effect` assets. +#[expect( + clippy::too_many_lines, + reason = "the preset library is one ordered table of authored configurations" +)] +pub fn presets() -> Vec { + let mut assets = vec![ + particle( + "Campfire", + [1.0, 0.25, 0.025, 0.8], + 65.0, + [0.5, 1.2], + [0.8, 2.0], + [0.12, 0.3], + ), + particle( + "Fire sparks", + [1.0, 0.55, 0.05, 1.0], + 25.0, + [0.4, 1.5], + [2.0, 5.0], + [0.015, 0.04], + ), + particle( + "Smoke plume", + [0.25, 0.28, 0.32, 0.25], + 18.0, + [3.0, 5.0], + [0.2, 0.6], + [0.3, 0.6], + ), + particle( + "Muzzle flash", + [1.0, 0.7, 0.15, 1.0], + 0.0, + [0.03, 0.08], + [2.0, 4.0], + [0.1, 0.2], + ), + particle( + "Embers", + [1.0, 0.18, 0.02, 0.9], + 12.0, + [2.0, 4.0], + [0.4, 0.8], + [0.02, 0.05], + ), + particle( + "Firework burst", + [0.4, 0.5, 1.0, 1.0], + 0.0, + [1.0, 2.0], + [4.0, 8.0], + [0.03, 0.06], + ), + particle( + "Magic projectile", + [0.25, 0.1, 1.0, 1.0], + 60.0, + [0.2, 0.5], + [0.2, 0.5], + [0.05, 0.12], + ), + one( + "Lightning beam", + EffectKind::Beam(BeamRenderer { + width: 0.06, + noise_amplitude: 0.35, + appearance: EffectAppearance { + color: Gradient::constant([0.3, 0.5, 1.0, 1.0]), + blend: BlendMode::Additive, + ..Default::default() + }, + ..Default::default() + }), + ), + one( + "Energy shield", + EffectKind::Ribbon(RibbonRenderer { + points: (0_u16..=48) + .map(|i| { + let t = f32::from(i) / 48.0 * std::f32::consts::TAU; + [t.cos() * 2.0, t.sin() * 2.0, 0.0] + }) + .collect(), + width: 0.08, + appearance: EffectAppearance { + color: Gradient::constant([0.1, 0.6, 1.0, 0.8]), + ..Default::default() + }, + ..Default::default() + }), + ), + particle( + "Healing aura", + [0.15, 1.0, 0.3, 0.5], + 35.0, + [1.0, 2.0], + [0.2, 0.5], + [0.04, 0.1], + ), + particle( + "Teleport", + [0.6, 0.1, 1.0, 0.8], + 0.0, + [0.5, 1.0], + [1.0, 4.0], + [0.08, 0.18], + ), + one( + "Portal", + EffectKind::Ribbon(RibbonRenderer { + points: (0_u16..=48) + .map(|i| { + let t = f32::from(i) / 48.0 * std::f32::consts::TAU; + [t.cos() * 1.4, t.sin() * 2.0, 0.0] + }) + .collect(), + width: 0.2, + tube: true, + ..Default::default() + }), + ), + trail("Glowing trail", [0.2, 0.7, 1.0, 0.9], 0.18, 1.0), + one("Shockwave", EffectKind::Shockwave(RingEffect::default())), + particle( + "Tire smoke", + [0.6, 0.6, 0.65, 0.2], + 40.0, + [0.5, 1.5], + [0.2, 0.6], + [0.15, 0.35], + ), + trail("Skid mark", [0.025, 0.025, 0.025, 0.9], 0.22, 15.0), + particle( + "Exhaust smoke", + [0.3, 0.3, 0.35, 0.25], + 20.0, + [0.8, 1.8], + [0.5, 1.0], + [0.08, 0.2], + ), + trail("Speed boost trail", [0.1, 0.4, 1.0, 1.0], 0.3, 0.5), + particle( + "Collision sparks", + [1.0, 0.6, 0.15, 1.0], + 0.0, + [0.2, 0.6], + [2.0, 6.0], + [0.02, 0.04], + ), + particle( + "Rocket exhaust", + [1.0, 0.35, 0.04, 0.9], + 100.0, + [0.1, 0.4], + [2.0, 5.0], + [0.08, 0.2], + ), + one("Rain", EffectKind::Weather(WeatherVolume::default())), + one( + "Snow", + EffectKind::Weather(WeatherVolume { + weather: WeatherType::Snow, + emitter: ParticleEmitter { + shape: SpawnShape::Box, + dimensions: [15.0, 10.0, 15.0], + direction: [0.0, -1.0, 0.0], + speed: [0.5, 1.2], + gravity: [0.0; 3], + rate: 80.0, + lifetime: [5.0, 8.0], + noise: 0.25, + ..Default::default() + }, + ..Default::default() + }), + ), + particle( + "Dust", + [0.6, 0.5, 0.3, 0.15], + 12.0, + [3.0, 6.0], + [0.05, 0.15], + [0.08, 0.15], + ), + one( + "Leaves", + EffectKind::Weather(WeatherVolume { + weather: WeatherType::Leaves, + emitter: ParticleEmitter { + shape: SpawnShape::Box, + dimensions: [10.0, 8.0, 10.0], + direction: [0.0, -1.0, 0.0], + speed: [0.2, 0.6], + gravity: [0.0, -0.1, 0.0], + rate: 8.0, + lifetime: [5.0, 8.0], + angular_velocity: 2.0, + appearance: EffectAppearance { + color: Gradient::constant([0.6, 0.3, 0.05, 1.0]), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }), + ), + particle( + "Fireflies", + [0.6, 1.0, 0.1, 0.7], + 8.0, + [3.0, 5.0], + [0.05, 0.2], + [0.02, 0.05], + ), + one( + "Mist", + EffectKind::Fog(FogVolume { + density: 0.05, + dimensions: [10.0, 2.0, 10.0], + ..Default::default() + }), + ), + particle( + "Water splash", + [0.4, 0.7, 1.0, 0.6], + 0.0, + [0.4, 1.0], + [2.0, 4.0], + [0.04, 0.09], + ), + one( + "Ground ripple", + EffectKind::WaterRipple(RingEffect::default()), + ), + trail("Sword trail", [0.7, 0.85, 1.0, 0.65], 0.6, 0.2), + particle( + "Footstep dust", + [0.55, 0.45, 0.3, 0.25], + 0.0, + [0.2, 0.6], + [0.3, 0.8], + [0.1, 0.2], + ), + particle( + "Bullet impact", + [1.0, 0.6, 0.2, 1.0], + 0.0, + [0.1, 0.4], + [1.0, 3.0], + [0.02, 0.05], + ), + one( + "Damage flash", + EffectKind::ScreenFlash(ScreenFlash::default()), + ), + one( + "Object dissolve", + EffectKind::Dissolve(DissolveEffect::default()), + ), + one( + "Character highlight", + EffectKind::Outline(OutlineEffect::default()), + ), + ]; + for asset in &mut assets { + for effect in &mut asset.effects { + if let EffectKind::Particle(p) = &mut effect.kind { + if p.rate == 0.0 { + p.bursts = vec![Burst { + count: if asset.name == "Firework burst" { + 150 + } else { + 30 + }, + ..Default::default() + }]; + effect.common.looping = false; + effect.common.duration = 2.0; + } + if asset.name.contains("smoke") + || asset.name == "Smoke plume" + || asset.name == "Dust" + || asset.name == "Footstep dust" + { + p.appearance.blend = BlendMode::Alpha; + p.gravity = [0.0, 0.2, 0.0]; + } + if matches!( + asset.name.as_str(), + "Firework burst" | "Collision sparks" | "Bullet impact" | "Water splash" + ) { + p.shape = SpawnShape::Hemisphere; + p.gravity = [0.0, -9.81, 0.0]; + p.collision = true; + } + if asset.name == "Fireflies" || asset.name == "Dust" { + p.shape = SpawnShape::Box; + p.dimensions = [8.0, 4.0, 8.0]; + p.gravity = [0.0; 3]; + p.noise = 0.3; + } + } + } + } + let mut explosion = particle( + "Explosion", + [1.0, 0.3, 0.025, 1.0], + 0.0, + [0.2, 0.8], + [2.0, 6.0], + [0.15, 0.45], + ); + if let EffectKind::Particle(p) = &mut explosion.effects[0].kind { + p.bursts = vec![Burst { + count: 100, + ..Default::default() + }]; + p.shape = SpawnShape::Sphere; + } + explosion.effects[0].common.looping = false; + explosion.effects[0].common.duration = 3.0; + let mut smoke = assets + .iter() + .find(|a| a.name == "Smoke plume") + .and_then(|asset| asset.effects.first()) + .cloned() + .unwrap_or_default(); + smoke.common.delay = 0.15; + smoke.common.duration = 2.0; + smoke.common.looping = false; + let mut ring = EffectComponent::new(EffectKind::Shockwave(RingEffect::default())); + ring.common.duration = 0.6; + ring.common.looping = false; + let mut shake = EffectComponent::new(EffectKind::CameraShake(CameraShake::default())); + shake.common.duration = 0.4; + shake.common.looping = false; + explosion.effects.extend([smoke, ring, shake]); + assets.push(explosion); + assets +} + +#[cfg(test)] +mod tests { + use super::{EffectAsset, presets}; + use crate::EntityId; + use crate::effects::{BeamRenderer, EffectComponent, EffectKind, ParticleEmitter}; + use glam::Mat4; + #[test] + fn composite_child_references_and_relative_transforms_survive_instances() { + let first = EntityId::new(); + let second = EntityId::new(); + let mut asset = EffectAsset::new( + "linked beam", + vec![ + EffectComponent::new(EffectKind::Particle(ParticleEmitter::default())), + EffectComponent::new(EffectKind::Beam(BeamRenderer { + end_target: Some(first), + ..BeamRenderer::default() + })), + ], + ); + asset.entity_ids = vec![first, second]; + asset.transforms = vec![ + Mat4::from_translation(glam::Vec3::X).to_cols_array(), + Mat4::from_translation(glam::Vec3::Y * 2.0).to_cols_array(), + ]; + let asset = EffectAsset::from_bytes(&asset.to_bytes().unwrap()).unwrap(); + let instances = asset.instances_with_ids(Some(crate::AssetId::new())); + let other = asset.instances_with_ids(None); + assert_ne!(instances[0].id, other[0].id); + assert_eq!(instances[0].local_transform.translation, glam::Vec3::X); + assert_eq!( + instances[1].local_transform.translation, + glam::Vec3::Y * 2.0 + ); + let effect = instances[1].effect.as_ref().unwrap(); + let EffectKind::Beam(beam) = &effect.kind else { + panic!("beam") + }; + assert_eq!(beam.end_target, Some(instances[0].id)); + assert_eq!( + effect + .source + .as_ref() + .unwrap() + .baseline + .as_ref() + .unwrap() + .kind, + effect.kind + ); + let mut invalid = asset; + invalid.entity_ids.pop(); + assert!(invalid.validate().is_err()); + } + #[test] + fn instance_restores_source_child_without_losing_stable_identity() { + let asset = presets().pop().unwrap(); + let id = crate::AssetId::new(); + let mut instances = asset.instantiate_from(id); + let source = instances[1].source.clone(); + instances[1].common.duration = 99.0; + asset.restore(&mut instances[1]).unwrap(); + assert_eq!(instances[1].common, asset.effects[1].common); + assert_eq!(instances[1].source, source); + assert_eq!(instances[1].source.as_ref().unwrap().asset, id); + instances[1].source.as_mut().unwrap().child = 999; + assert!(asset.restore(&mut instances[1]).is_err()); + } + #[test] + fn library_assets_validate_and_round_trip() { + let library = presets(); + assert_eq!(library.len(), 35); + for asset in library { + assert_eq!( + EffectAsset::from_bytes(&asset.to_bytes().unwrap()).unwrap(), + asset + ); + } + } + #[test] + fn composite_instances_are_independent_and_reject_future_versions() { + let asset = presets().pop().unwrap(); + let mut instance = asset.instantiate(); + instance[0].common.duration = 1.0; + assert_ne!(instance, asset.effects); + let mut invalid = asset; + invalid.version = 2; + assert!(invalid.to_bytes().is_err()); + } +} diff --git a/Engine/crates/engine-world/src/effects/simulation.rs b/Engine/crates/engine-world/src/effects/simulation.rs new file mode 100644 index 00000000..c070af13 --- /dev/null +++ b/Engine/crates/engine-world/src/effects/simulation.rs @@ -0,0 +1,2185 @@ +use super::{ + CollisionResponse, EffectAppearance, EffectComponent, EffectEvent, EffectEventKind, + EffectFrame, EffectGeometry, EffectInput, EffectKind, ForceField, ForceType, ParticleEmitter, + ParticleInstance, PlaybackCommand, PlaybackState, PostProcessSettings, QualityLevel, + RenderWaterDisturbance, RippleWaterSurface, SimulationSpace, SpawnShape, VolumeShape, + WaterDisturbance, WeatherType, water_surface_mesh, +}; +use crate::{EntityId, PrimitiveMesh}; +use glam::{Mat4, Vec3, Vec3Swizzles}; +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::f32::consts::TAU; + +const MAX_EVENTS: usize = 8192; + +/// Builds a camera-facing ribbon or a parallel-transport tube without creating +/// one draw call for every segment. Input points and output positions use the +/// same coordinate space. Degenerate points are removed before tangent creation. +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +pub fn ribbon_mesh( + points: &[Vec3], + widths: &[f32], + radial_segments: u16, + camera: Vec3, +) -> PrimitiveMesh { + let points: Vec<_> = points + .iter() + .copied() + .filter(|point| point.is_finite()) + .enumerate() + .filter(|(index, point)| *index == 0 || point.distance_squared(points[index - 1]) > 1.0e-10) + .collect(); + let mut mesh = PrimitiveMesh::default(); + if points.len() < 2 { + return mesh; + } + let sides = if radial_segments < 3 { + 2 + } else { + radial_segments.min(32) as usize + }; + let stride = if sides == 2 { 2 } else { sides + 1 }; + let mut distance = 0.0; + let mut previous_normal = Vec3::X; + for (index, (_, point)) in points.iter().enumerate() { + let before = points[index.saturating_sub(1)].1; + let after = points[(index + 1).min(points.len() - 1)].1; + let tangent = (after - before).normalize_or_zero(); + let toward_camera = (camera - *point).normalize_or_zero(); + let mut normal = if sides == 2 { + tangent.cross(toward_camera).normalize_or_zero() + } else { + (previous_normal - tangent * previous_normal.dot(tangent)).normalize_or_zero() + }; + if normal.length_squared() < 0.5 { + normal = tangent + .cross(if tangent.y.abs() > 0.9 { + Vec3::X + } else { + Vec3::Y + }) + .normalize_or_zero(); + } + previous_normal = normal; + let binormal = tangent.cross(normal).normalize_or_zero(); + if index > 0 { + distance += point.distance(points[index - 1].1); + } + let width = widths.get(points[index].0).copied().unwrap_or(1.0).max(0.0) * 0.5; + for side in 0..stride { + let (offset, surface_normal, v) = if sides == 2 { + let sign = if side == 0 { -1.0 } else { 1.0 }; + (normal * width * sign, toward_camera, side as f32) + } else { + let phase = side as f32 / sides as f32; + let radial = normal * (phase * TAU).cos() + binormal * (phase * TAU).sin(); + (radial * width, radial, phase) + }; + mesh.positions.push((*point + offset).to_array()); + mesh.normals.push(surface_normal.to_array()); + mesh.tex_coords.push([distance, v]); + } + if index > 0 { + for side in 0..(stride - 1) { + let a = ((index - 1) * stride + side) as u32; + let b = (index * stride + side) as u32; + mesh.indices + .extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]); + } + } + } + mesh +} + +/// Uniform Catmull-Rom interpolation preserving both endpoints. Subdivision and +/// input limits keep authored curves from producing unbounded frame allocations. +#[allow( + clippy::cast_precision_loss, + clippy::many_single_char_names, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +pub fn smooth_path(points: &[Vec3], subdivisions: u16) -> Vec { + if points.len() < 2 { + return points.to_vec(); + } + let subdivisions = subdivisions.clamp(1, 32) as usize; + let count = points.len().min(2048); + let mut result = Vec::with_capacity((count - 1) * subdivisions + 1); + for segment in 0..count - 1 { + let a = points[segment.saturating_sub(1)]; + let b = points[segment]; + let c = points[segment + 1]; + let d = points[(segment + 2).min(count - 1)]; + for step in 0..subdivisions { + let t = step as f32 / subdivisions as f32; + result.push( + 0.5 * ((2.0 * b) + + (-a + c) * t + + (2.0 * a - 5.0 * b + 4.0 * c - d) * t * t + + (-a + 3.0 * b - 3.0 * c + d) * t * t * t), + ); + } + } + result.push(points[count - 1]); + result +} + +/// An annulus in the XZ plane with continuous UVs and upward normals. +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +pub fn ring_mesh(radius: f32, thickness: f32, segments: u16) -> PrimitiveMesh { + let mut mesh = PrimitiveMesh::default(); + let segments = segments.clamp(8, 256) as usize; + for index in 0..=segments { + let u = index as f32 / segments as f32; + let direction = Vec3::new((u * TAU).cos(), 0.0, (u * TAU).sin()); + for edge in 0..2 { + let r = (radius + (edge as f32 - 0.5) * thickness).max(0.0); + mesh.positions.push((direction * r).to_array()); + mesh.normals.push(Vec3::Y.to_array()); + mesh.tex_coords.push([u, edge as f32]); + } + if index > 0 { + let a = ((index - 1) * 2) as u32; + mesh.indices + .extend_from_slice(&[a, a + 1, a + 2, a + 1, a + 3, a + 2]); + } + } + mesh +} + +/// Earliest segment contact with a solid axis-aligned box. Swept tests prevent +/// fast particles tunneling through thin cosmetic collision volumes. +pub fn sweep_box(start: Vec3, end: Vec3, half_size: Vec3) -> Option<(f32, Vec3)> { + let delta = end - start; + let mut entering = 0.0_f32; + let mut leaving = 1.0_f32; + let mut normal = Vec3::ZERO; + for axis in 0..3 { + if delta[axis].abs() < 1.0e-8 { + if start[axis].abs() > half_size[axis] { + return None; + } + continue; + } + let a = (-half_size[axis] - start[axis]) / delta[axis]; + let b = (half_size[axis] - start[axis]) / delta[axis]; + let near = a.min(b); + if near >= entering { + entering = near; + normal = Vec3::ZERO; + normal[axis] = -delta[axis].signum(); + } + leaving = leaving.min(a.max(b)); + if entering > leaving { + return None; + } + } + if normal == Vec3::ZERO { + return None; + } + Some((entering, normal)) +} + +/// Earliest segment contact with a solid sphere centered at the origin. +pub fn sweep_sphere(start: Vec3, end: Vec3, radius: f32) -> Option<(f32, Vec3)> { + let delta = end - start; + let a = delta.length_squared(); + if a < 1.0e-12 { + return None; + } + let b = start.dot(delta); + let c = start.length_squared() - radius * radius; + let discriminant = b * b - a * c; + if discriminant < 0.0 { + return None; + } + let time = (-b - discriminant.sqrt()) / a; + if !(0.0..=1.0).contains(&time) { + return None; + } + Some((time, (start + delta * time).normalize_or_zero())) +} + +/// Earliest hit against a closed vertical cylinder. Side and cap contacts are +/// considered separately, so corners do not behave like an enclosing box. +pub fn sweep_cylinder( + start: Vec3, + end: Vec3, + radius: f32, + half_height: f32, +) -> Option<(f32, Vec3)> { + let delta = end - start; + let mut best: Option<(f32, Vec3)> = None; + let mut accept = |time: f32, normal: Vec3| { + if (0.0..=1.0).contains(&time) && best.is_none_or(|(t, _)| time < t) { + best = Some((time, normal)); + } + }; + let a = delta.x * delta.x + delta.z * delta.z; + if a > 1.0e-12 { + let b = start.x * delta.x + start.z * delta.z; + let c = start.x * start.x + start.z * start.z - radius * radius; + let discriminant = b * b - a * c; + if discriminant >= 0.0 { + let time = (-b - discriminant.sqrt()) / a; + let hit = start + delta * time; + if hit.y.abs() <= half_height { + accept(time, Vec3::new(hit.x, 0.0, hit.z).normalize_or_zero()); + } + } + } + if delta.y.abs() > 1.0e-12 { + for sign in [-1.0, 1.0] { + if delta.y * sign < 0.0 { + let time = (sign * half_height - start.y) / delta.y; + let hit = start + delta * time; + if hit.x * hit.x + hit.z * hit.z <= radius * radius { + accept(time, Vec3::Y * sign); + } + } + } + } + best +} + +/// Reflects only the incoming normal component and damps tangential motion. +pub fn bounce_velocity(velocity: Vec3, normal: Vec3, bounce: f32, friction: f32) -> Vec3 { + let incoming = velocity.dot(normal); + if incoming >= 0.0 { + return velocity; + } + (velocity - normal * incoming) * (1.0 - friction.clamp(0.0, 1.0)) + - normal * incoming * bounce.clamp(0.0, 1.0) +} + +/// Deterministic `SplitMix64` stream. No dependency on process or thread RNG state. +#[derive(Clone, Debug)] +pub(crate) struct Random(u64); + +impl Random { + pub(crate) fn new(seed: u64) -> Self { + Self(seed) + } + #[allow( + clippy::cast_precision_loss, + reason = "the top 24 random bits are represented exactly by the f32 mantissa" + )] + pub(crate) fn unit(&mut self) -> f32 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut value = self.0; + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^= value >> 31; + ((value >> 40) as f32) / 16_777_216.0 + } + pub(crate) fn range(&mut self, minimum: f32, maximum: f32) -> f32 { + minimum + (maximum - minimum) * self.unit() + } + pub(crate) fn direction(&mut self) -> Vec3 { + let y = self.range(-1.0, 1.0); + let azimuth = self.unit() * TAU; + let radius = (1.0 - y * y).sqrt(); + Vec3::new(radius * azimuth.cos(), y, radius * azimuth.sin()) + } +} + +#[derive(Clone, Debug)] +struct Particle { + position: Vec3, + velocity: Vec3, + age: f32, + lifetime: f32, + size: f32, + rotation: f32, + stuck: bool, +} + +#[derive(Clone, Debug)] +struct Instance { + state: PlaybackState, + time: f32, + emission: f32, + rng: Random, + seed: u64, + particles: Vec, + points: VecDeque<(Vec3, f32)>, + previous: Option, + began: bool, + completed_event: bool, + delay: f32, + kind: std::mem::Discriminant, + prewarmed: bool, +} + +impl Instance { + fn new(effect: &EffectComponent) -> Self { + Self { + state: if effect.common.auto_play { + PlaybackState::Playing + } else { + PlaybackState::Stopped + }, + time: -effect.common.delay, + emission: 0.0, + rng: Random::new(effect.common.random_seed), + seed: effect.common.random_seed, + particles: Vec::new(), + points: VecDeque::new(), + previous: None, + began: false, + completed_event: false, + delay: effect.common.delay, + kind: std::mem::discriminant(&effect.kind), + prewarmed: false, + } + } + fn reset(&mut self) { + self.time = -self.delay; + self.emission = 0.0; + self.rng = Random::new(self.seed); + self.particles.clear(); + self.points.clear(); + self.previous = None; + self.began = false; + self.completed_event = false; + self.prewarmed = false; + } +} + +/// Retained CPU simulation state for one scene. Cosmetic collisions never modify +/// authoritative gameplay physics. Destroying an entity removes all its state on +/// the next update; `clear` releases it immediately at scene unload. +#[derive(Debug, Default)] +pub struct EffectRuntime { + instances: BTreeMap, + frame: EffectFrame, + time: f32, + camera_target: Option, +} + +impl EffectRuntime { + pub fn new() -> Self { + Self::default() + } + /// Adds a bounded scene-level event, including aggregate composite completion. + pub(crate) fn emit_event(&mut self, event: EffectEvent) { + if self.frame.events.len() < MAX_EVENTS { + self.frame.events.push(event); + } + } + pub fn frame(&self) -> &EffectFrame { + &self.frame + } + /// Selects camera-specific impulses and overlays. `None` previews all effects. + pub fn set_camera_target(&mut self, target: Option) { + self.camera_target = target; + } + pub fn remove(&mut self, entity: EntityId) { + self.instances.remove(&entity); + self.frame.geometry.retain(|v| v.entity != entity); + self.frame.particles.retain(|v| v.entity != entity); + self.frame.events.retain(|v| v.entity != entity); + self.frame.fog.retain(|v| v.entity != entity); + self.frame.probes.retain(|v| v.entity != entity); + self.frame.decals.retain(|v| v.entity != entity); + self.frame.water.retain(|v| v.entity != entity); + // Aggregated camera and material adjustments are regenerated on update. + self.frame.lights.clear(); + self.frame.surfaces.clear(); + self.frame.shafts.clear(); + self.frame.outlines.clear(); + self.frame.flares.clear(); + self.frame.post = PostProcessSettings::default(); + self.frame.camera_offset = Vec3::ZERO; + self.frame.camera_rotation = Vec3::ZERO; + } + /// Registers an authored component before a first-frame scripting command. + pub fn ensure(&mut self, input: &EffectInput) { + let current = self + .instances + .entry(input.entity) + .or_insert_with(|| Instance::new(&input.effect)); + if current.seed != input.effect.common.random_seed + || current.kind != std::mem::discriminant(&input.effect.kind) + { + *current = Instance::new(&input.effect); + } + current.delay = input.effect.common.delay; + } + pub fn clear(&mut self) { + self.instances.clear(); + self.frame = EffectFrame::default(); + self.time = 0.0; + } + pub fn state(&self, entity: EntityId) -> PlaybackState { + self.instances + .get(&entity) + .map_or(PlaybackState::Stopped, |s| s.state) + } + pub fn particle_count(&self) -> usize { + self.instances.values().map(|s| s.particles.len()).sum() + } + pub fn control(&mut self, entity: EntityId, command: PlaybackCommand) { + let Some(instance) = self.instances.get_mut(&entity) else { + return; + }; + match command { + PlaybackCommand::Play => { + if instance.state != PlaybackState::Playing { + instance.reset(); + instance.state = PlaybackState::Playing; + } + } + PlaybackCommand::Pause => { + if instance.state == PlaybackState::Playing { + instance.state = PlaybackState::Paused; + } + } + PlaybackCommand::Resume => { + if instance.state == PlaybackState::Paused { + instance.state = PlaybackState::Playing; + } + } + PlaybackCommand::Stop | PlaybackCommand::Reset => { + instance.reset(); + instance.state = PlaybackState::Stopped; + } + PlaybackCommand::Restart => { + instance.reset(); + instance.state = PlaybackState::Playing; + } + PlaybackCommand::Dispose => { + instance.reset(); + instance.state = PlaybackState::Disposed; + } + } + } + + pub fn update(&mut self, dt: f32, inputs: &[EffectInput], camera: Vec3) -> &EffectFrame { + self.update_with_targets(dt, inputs, camera, &BTreeMap::new()) + } + + /// Updates effects with transforms of ordinary scene entities for attachment + /// and moving beam endpoints. Targets do not need an effects component. + #[allow( + clippy::too_many_lines, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::float_cmp, + reason = "one exhaustive dispatch preserves update ordering; bounded numerical conversions and exact authored-default sentinel comparisons are intentional" + )] + pub fn update_with_targets( + &mut self, + dt: f32, + inputs: &[EffectInput], + camera: Vec3, + targets: &BTreeMap, + ) -> &EffectFrame { + let resolved_inputs: Cow<'_, [EffectInput]> = if inputs + .iter() + .any(|i| !i.effect.common.parameters.is_empty()) + { + let mut values = inputs.to_vec(); + for input in &mut values { + input.effect.resolve_parameters(); + } + Cow::Owned(values) + } else { + Cow::Borrowed(inputs) + }; + let inputs = resolved_inputs.as_ref(); + let dt = if dt.is_finite() { + dt.clamp(0.0, 1.0) + } else { + 0.0 + }; + self.time += dt; + self.frame.geometry.clear(); + self.frame.particles.clear(); + self.frame.events.clear(); + self.frame.lights.clear(); + self.frame.surfaces.clear(); + self.frame.fog.clear(); + self.frame.shafts.clear(); + self.frame.probes.clear(); + self.frame.decals.clear(); + self.frame.outlines.clear(); + self.frame.flares.clear(); + self.frame.water.clear(); + self.frame.post = PostProcessSettings::default(); + self.frame.camera_offset = Vec3::ZERO; + self.frame.camera_rotation = Vec3::ZERO; + let alive: BTreeSet<_> = inputs.iter().map(|i| i.entity).collect(); + self.instances.retain(|id, _| alive.contains(id)); + for input in inputs { + self.ensure(input); + } + let active_fields: BTreeSet<_> = inputs + .iter() + .filter(|input| { + let state = &self.instances[&input.entity]; + let common = &input.effect.common; + let predicted = state.time + + if state.state == PlaybackState::Playing { + dt * common.time_scale + } else { + 0.0 + }; + common.enabled + && matches!(state.state, PlaybackState::Playing | PlaybackState::Paused) + && predicted >= 0.0 + && (common.looping || state.time < common.duration) + }) + .map(|input| input.entity) + .collect(); + for input in inputs { + let instance = self + .instances + .entry(input.entity) + .or_insert_with(|| Instance::new(&input.effect)); + let common = &input.effect.common; + if !common.enabled || instance.state == PlaybackState::Disposed { + continue; + } + let transform = common + .attachment_target + .and_then(|id| { + targets + .get(&id) + .copied() + .or_else(|| inputs.iter().find(|i| i.entity == id).map(|i| i.transform)) + }) + .unwrap_or(input.transform); + let position = transform.transform_point3(Vec3::ZERO); + let (render_center, render_radius) = + common.bounds.as_ref().map_or((position, 0.0), |bounds| { + let center = transform.transform_point3(Vec3::from(bounds.center)); + let radius = transform + .transform_vector3(Vec3::from(bounds.half_extents)) + .length(); + (center, radius) + }); + let render_visible = common.visible + && render_center.distance(camera) - render_radius <= common.render_distance; + + let active_dt = if instance.state == PlaybackState::Playing { + dt * common.time_scale + } else { + 0.0 + }; + let previous_time = instance.time; + if active_dt > 0.0 { + instance.time += active_dt; + } + let emission_dt = (instance.time.max(0.0) - previous_time.max(0.0)).max(0.0); + let age = if common.looping { + instance.time.max(0.0) % common.duration.max(0.001) + } else { + instance.time.max(0.0).min(common.duration) + }; + let t = (age / common.duration.max(0.001)).clamp(0.0, 1.0); + let mut emitting = instance.state == PlaybackState::Playing + && instance.time >= 0.0 + && (common.looping || previous_time < common.duration); + if instance.time < 0.0 { + emitting = false; + } + let mesh_emitter = match &input.effect.kind { + EffectKind::Particle(p) | EffectKind::MeshParticle(p) => Some(Cow::Borrowed(p)), + EffectKind::Weather(w) => { + let mut p = w.emitter.clone(); + p.rate *= w.intensity.max(0.0); + match w.weather { + WeatherType::Snow => { + if p.speed == [8.0, 10.0] { + p.speed = [0.5, 1.0]; + } + if p.noise == 0.0 { + p.noise = 0.3; + } + if p.start_size == [0.1, 0.25] { + p.start_size = [0.03, 0.08]; + } + } + WeatherType::Hail => { + if p.bounce == 0.5 { + p.bounce = 0.7; + } + } + WeatherType::Dust | WeatherType::Sand => { + if p.direction == [0.0, -1.0, 0.0] { + p.direction = [1.0, -0.1, 0.0]; + } + if p.start_size == [0.1, 0.25] { + p.start_size = [0.01, 0.04]; + } + } + WeatherType::Leaves => { + if p.speed == [8.0, 10.0] { + p.speed = [0.5, 1.5]; + } + if p.angular_velocity == 0.0 { + p.angular_velocity = 2.0; + } + if p.noise == 0.0 { + p.noise = 1.0; + } + } + WeatherType::Rain => { + if p.stretch == 0.0 { + p.stretch = 0.025; + } + } + } + Some(Cow::Owned(p)) + } + _ => None, + }; + if let Some(emitter) = &mesh_emitter { + let emitter_position = if matches!(&input.effect.kind, EffectKind::Weather(w) if w.camera_follow) + { + camera + } else { + position + }; + if !instance.prewarmed && emitter.prewarm && common.looping && emitting { + let saved = instance.time; + let events = self.frame.events.len(); + let steps = (emitter.prewarm_time.clamp(0.0, 10.0) * 60.0).ceil() as usize; + instance.time = 0.0; + for _ in 0..steps { + let old = instance.time; + instance.time += 1.0 / 60.0; + simulate_particles( + instance, + emitter, + input, + transform, + emitter_position, + inputs, + &active_fields, + 1.0 / 60.0, + old, + true, + &mut self.frame, + ); + } + instance.time = saved; + self.frame.events.truncate(events); + instance.prewarmed = true; + } + simulate_particles( + instance, + emitter, + input, + transform, + emitter_position, + inputs, + &active_fields, + emission_dt, + previous_time, + emitting, + &mut self.frame, + ); + if render_visible { + let mut generated_mesh = make_geometry( + input.entity, + PrimitiveMesh::default(), + &emitter.appearance, + 0.0, + self.time, + ); + for p in &instance.particles { + let normalized = (p.age / p.lifetime).clamp(0.0, 1.0); + let mut color = emitter.appearance.color.sample(normalized); + color[3] *= emitter + .appearance + .opacity + .sample(normalized) + .clamp(0.0, 1.0); + let particle_position = if common.simulation_space == SimulationSpace::Local + { + transform.transform_point3(p.position) + } else { + p.position + }; + let size = p.size * emitter.appearance.size.sample(normalized).max(0.0); + if matches!(input.effect.kind, EffectKind::MeshParticle(_)) + && emitter.mesh.is_none() + { + append_cube( + &mut generated_mesh, + particle_position, + size, + p.rotation, + color, + ); + continue; + } + self.frame.particles.push(ParticleInstance { + entity: input.entity, + position: particle_position, + velocity: if common.simulation_space == SimulationSpace::Local { + transform.transform_vector3(p.velocity) + } else { + p.velocity + }, + stretch: emitter.stretch.max(0.0), + uv_rect: flipbook_rect(emitter.flipbook, emitter.flipbook_fps, p.age), + rotation: p.rotation, + size: p.size * emitter.appearance.size.sample(normalized).max(0.0), + color, + mesh: emitter.mesh, + material: emitter.appearance.material, + texture: emitter.appearance.texture, + emission: emitter.appearance.emission, + blend: emitter.appearance.blend, + }); + } + if !generated_mesh.mesh.positions.is_empty() { + self.frame.geometry.push(generated_mesh); + } + } + } + if instance.time >= 0.0 + && instance.state != PlaybackState::Stopped + && instance.state != PlaybackState::Disposed + { + match &input.effect.kind { + EffectKind::Trail(trail) => { + for (_, age) in &mut instance.points { + *age += active_dt; + } + while instance + .points + .front() + .is_some_and(|(_, age)| *age >= trail.lifetime) + { + instance.points.pop_front(); + } + if emitting && trail.emitting { + if instance.points.back().is_some_and(|(p, _)| { + p.distance(position) > trail.teleport_distance + }) { + instance.points.clear(); + } + if instance.points.back().is_none_or(|(p, _)| { + p.distance(position) >= trail.min_vertex_distance.max(0.001) + }) { + instance.points.push_back((position, 0.0)); + } + while instance.points.len() > trail.max_points.min(65536) as usize { + instance.points.pop_front(); + } + } + if render_visible { + let points: Vec<_> = instance.points.iter().map(|(p, _)| *p).collect(); + let widths: Vec<_> = instance + .points + .iter() + .map(|(_, age)| { + trail.width + * trail.appearance.size.sample(*age / trail.lifetime) + }) + .collect(); + let mesh = ribbon_mesh( + &points, + &widths, + 0, + if trail.camera_facing { + camera + } else { + position + transform.transform_vector3(Vec3::Z) * 1000.0 + }, + ); + let mut geometry = + make_geometry(input.entity, mesh, &trail.appearance, t, self.time); + geometry.colors = instance + .points + .iter() + .flat_map(|(_, age)| { + let fraction = (*age / trail.lifetime).clamp(0.0, 1.0); + let mut c = trail.appearance.color.sample(fraction); + c[3] *= trail.appearance.opacity.sample(fraction); + [c, c] + }) + .collect(); + self.frame.geometry.push(geometry); + } + } + EffectKind::Beam(beam) if render_visible => { + let endpoint = |target: Option, offset: [f32; 3]| { + target + .and_then(|id| { + targets.get(&id).copied().or_else(|| { + inputs.iter().find(|i| i.entity == id).map(|i| i.transform) + }) + }) + .unwrap_or(transform) + .transform_point3(Vec3::from(offset)) + }; + let start = endpoint(beam.start_target, beam.start); + let end = endpoint(beam.end_target, beam.end); + let n = beam.segments.clamp(1, 4096) as usize; + let direction = (end - start).normalize_or_zero(); + let side = direction + .cross(if direction.y.abs() > 0.9 { + Vec3::X + } else { + Vec3::Y + }) + .normalize_or_zero(); + let up = direction.cross(side); + let points: Vec<_> = (0..=n) + .map(|i| { + let f = i as f32 / n as f32; + let phase = + f * beam.noise_frequency * TAU + self.time * beam.noise_speed; + start.lerp(end, f) + + (side * phase.sin() + up * (phase * 1.73 + 1.0).sin()) + * beam.noise_amplitude + * (f * std::f32::consts::PI).sin() + }) + .collect(); + self.frame.geometry.push(make_path_geometry( + input.entity, + ribbon_mesh(&points, &vec![beam.width; points.len()], 0, camera), + &beam.appearance, + t, + self.time, + )); + for branch in 0..beam.branch_count.min(64) { + let index = ((branch as usize + 1) * n + / (beam.branch_count.min(64) as usize + 1)) + .min(n); + let a = points[index]; + let b = a + + side + * ((branch as f32 * 2.1 + self.time * beam.noise_speed).sin() + * beam.noise_amplitude + * 5.0) + + direction * (end - start).length() * 0.2; + self.frame.geometry.push(make_path_geometry( + input.entity, + ribbon_mesh(&[a, b], &[beam.width * 0.5, 0.0], 0, camera), + &beam.appearance, + t, + self.time, + )); + } + } + EffectKind::Shockwave(ring) if render_visible => { + let radius = + ring.initial_radius + (ring.final_radius - ring.initial_radius) * t; + let mut mesh = ring_mesh(radius, ring.thickness, 96); + for p in &mut mesh.positions { + let mut v = Vec3::from(*p); + v.y += ring.vertical_offset; + *p = transform.transform_point3(v).to_array(); + } + for normal in &mut mesh.normals { + *normal = transform + .inverse() + .transpose() + .transform_vector3(Vec3::from(*normal)) + .normalize_or_zero() + .to_array(); + } + self.frame.geometry.push(make_geometry( + input.entity, + mesh, + &ring.appearance, + t, + self.time, + )); + } + EffectKind::WaterRipple(ring) if render_visible => { + let disturbance = WaterDisturbance { + source: position, + elapsed: age, + duration: common.duration, + settings: ring.clone(), + }; + if let Some(target) = ring.target { + self.frame.water.push(RenderWaterDisturbance { + entity: input.entity, + target, + disturbance, + }); + } else { + let mut surface = RippleWaterSurface { + max_disturbances: ring.max_disturbances.max(1) as usize, + ..Default::default() + }; + surface.disturb(disturbance); + let (mut mesh, foam) = water_surface_mesh( + &surface, + position + Vec3::Y * ring.vertical_offset, + ring.final_radius.max(0.1) * 2.0, + 64, + ); + let mut geometry = make_geometry( + input.entity, + std::mem::take(&mut mesh), + &ring.appearance, + t, + self.time, + ); + geometry.colors = foam + .into_iter() + .map(|foam| { + let mut color = geometry.color; + for channel in &mut color[..3] { + *channel += (1.0 - *channel) * foam; + } + color + }) + .collect(); + self.frame.geometry.push(geometry); + } + } + EffectKind::Ribbon(ribbon) if render_visible => { + let points: Vec<_> = ribbon + .points + .iter() + .map(|p| transform.transform_point3(Vec3::from(*p))) + .collect(); + let points = smooth_path(&points, ribbon.subdivisions.min(32) as u16); + let widths: Vec<_> = (0..points.len()) + .map(|i| { + ribbon.width + * ribbon + .appearance + .size + .sample(i as f32 / (points.len() - 1).max(1) as f32) + }) + .collect(); + self.frame.geometry.push(make_path_geometry( + input.entity, + twisted_ribbon_mesh( + &points, + &widths, + if ribbon.tube { + ribbon.radial_segments.min(32) as u16 + } else { + 0 + }, + camera, + ribbon.twist, + ), + &ribbon.appearance, + t, + self.time, + )); + } + EffectKind::ProjectileTracer(tracer) if render_visible => { + let velocity = transform.transform_vector3(Vec3::from(tracer.velocity)); + let head = position + velocity * age; + let tail = head - velocity.normalize_or_zero() * tracer.length; + self.frame.geometry.push(make_geometry( + input.entity, + ribbon_mesh(&[tail, head], &[0.0, tracer.width], 0, camera), + &tracer.appearance, + t, + self.time, + )); + } + EffectKind::FoliageScatter(scatter) if render_visible => { + let mut rng = Random::new(common.random_seed); + let count = + (scatter.density * scatter.dimensions[0] * scatter.dimensions[2]) + .max(0.0) + .min(scatter.max_instances.min(100_000) as f32) + as usize; + let mut foliage = make_geometry( + input.entity, + PrimitiveMesh::default(), + &scatter.appearance, + 0.0, + self.time, + ); + for i in 0..count { + let local = Vec3::new( + rng.range(-0.5, 0.5) * scatter.dimensions[0], + 0.0, + rng.range(-0.5, 0.5) * scatter.dimensions[2], + ); + let world = transform.transform_point3(local); + if world.distance(camera) > common.render_distance { + continue; + } + let wind = sample_active_wind( + inputs, + &active_fields, + world, + self.time, + common.visibility_layers, + ) * scatter.wind_response; + let rotation = rng.unit() * TAU; + let size = rng.range(scatter.scale[0], scatter.scale[1]); + if scatter.meshes.is_empty() { + append_foliage( + &mut foliage, + world, + size, + rotation, + wind * 0.03, + scatter.appearance.color.sample(0.0), + ); + } else { + self.frame.particles.push(ParticleInstance { + entity: input.entity, + position: world + wind * 0.03, + velocity: Vec3::ZERO, + stretch: 0.0, + uv_rect: [0.0, 0.0, 1.0, 1.0], + rotation, + size, + color: scatter.appearance.color.sample(0.0), + mesh: scatter + .meshes + .get(i % scatter.meshes.len().max(1)) + .copied(), + material: scatter.appearance.material, + texture: scatter.appearance.texture, + emission: scatter.appearance.emission, + blend: scatter.appearance.blend, + }); + } + } + if !foliage.mesh.positions.is_empty() { + self.frame.geometry.push(foliage); + } + } + _ => {} + } + } + instance.previous = Some(position); + if !common.looping + && instance.time >= common.duration + && instance.state == PlaybackState::Playing + { + if !instance.completed_event { + self.frame.events.push(EffectEvent { + entity: input.entity, + kind: EffectEventKind::Completed, + position, + child_effect: None, + }); + instance.completed_event = true; + } + if instance.particles.is_empty() && instance.points.is_empty() { + instance.state = PlaybackState::Completed; + } + } + } + let mut ordered: Vec<_> = inputs.iter().collect(); + ordered.sort_by_key(|i| (i.effect.common.priority, i.entity)); + for input in ordered { + let target = match &input.effect.kind { + EffectKind::CameraShake(v) => v.target, + EffectKind::ScreenFlash(v) => v.target, + _ => None, + }; + if target.is_some() && self.camera_target.is_some() && target != self.camera_target { + continue; + } + if let Some(state) = self.instances.get(&input.entity) + && input.effect.common.enabled + && input.effect.common.visible + && state.time >= 0.0 + && matches!(state.state, PlaybackState::Playing | PlaybackState::Paused) + { + let elapsed = if input.effect.common.looping { + state.time % input.effect.common.duration.max(0.001) + } else { + state.time + }; + super::modifiers::extract_modifier( + input, + camera, + elapsed, + (elapsed / input.effect.common.duration.max(0.001)).clamp(0.0, 1.0), + &mut self.frame, + ); + } + } + &self.frame + } +} + +#[allow( + clippy::cast_precision_loss, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +fn twisted_ribbon_mesh( + points: &[Vec3], + widths: &[f32], + radial: u16, + camera: Vec3, + twist: f32, +) -> PrimitiveMesh { + let mut mesh = ribbon_mesh(points, widths, radial, camera); + if twist.abs() < 0.00001 || points.len() < 2 { + return mesh; + } + let stride = if radial < 3 { + 2 + } else { + radial.min(32) as usize + 1 + }; + for (index, center) in points.iter().enumerate() { + let tangent = (points[(index + 1).min(points.len() - 1)] - points[index.saturating_sub(1)]) + .normalize_or_zero(); + if tangent.length_squared() < 0.5 { + continue; + } + let rotation = + glam::Quat::from_axis_angle(tangent, twist * index as f32 / (points.len() - 1) as f32); + for side in 0..stride { + let vertex = index * stride + side; + if let Some(p) = mesh.positions.get_mut(vertex) { + *p = (*center + rotation * (Vec3::from(*p) - *center)).to_array(); + } + if let Some(n) = mesh.normals.get_mut(vertex) { + *n = (rotation * Vec3::from(*n)).to_array(); + } + } + } + mesh +} + +fn make_path_geometry( + entity: EntityId, + mesh: PrimitiveMesh, + appearance: &EffectAppearance, + t: f32, + time: f32, +) -> EffectGeometry { + let length = mesh + .tex_coords + .iter() + .map(|uv| uv[0]) + .fold(0.0f32, f32::max) + .max(0.00001); + let colors = mesh + .tex_coords + .iter() + .map(|uv| { + let mut c = appearance.color.sample(uv[0] / length); + c[3] *= appearance.opacity.sample(t).clamp(0.0, 1.0); + c + }) + .collect(); + let mut geometry = make_geometry(entity, mesh, appearance, t, time); + geometry.colors = colors; + geometry +} + +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +fn flipbook_rect(grid: [u32; 2], fps: f32, age: f32) -> [f32; 4] { + let columns = grid[0].clamp(1, 1024); + let rows = grid[1].clamp(1, 1024); + let frame = ((age * fps.max(0.0)).floor() as u32) % (columns * rows); + let x = (frame % columns) as f32 / columns as f32; + let y = (frame / columns) as f32 / rows as f32; + [x, y, x + 1.0 / columns as f32, y + 1.0 / rows as f32] +} + +fn make_geometry( + entity: EntityId, + mut mesh: PrimitiveMesh, + appearance: &EffectAppearance, + t: f32, + time: f32, +) -> EffectGeometry { + for uv in &mut mesh.tex_coords { + for (axis, value) in uv.iter_mut().enumerate() { + *value = *value * appearance.uv_scale[axis] + appearance.uv_scroll[axis] * time; + } + } + let mut color = appearance.color.sample(t); + color[3] *= appearance.opacity.sample(t).clamp(0.0, 1.0); + EffectGeometry { + entity, + mesh, + colors: Vec::new(), + color, + emission: appearance.emission, + material: appearance.material, + texture: appearance.texture, + blend: appearance.blend, + } +} + +fn append_cube( + geometry: &mut EffectGeometry, + position: Vec3, + size: f32, + rotation: f32, + color: [f32; 4], +) { + // One batched indexed mesh for the emitter's procedural debris. Asset-backed + // mesh particles continue through the existing mesh/material asset path. + static CUBE: std::sync::OnceLock = std::sync::OnceLock::new(); + let mesh = CUBE.get_or_init(|| { + crate::Primitive::Cube { size: 1.0 } + .mesh() + .expect("unit cube is valid") + }); + let transform = Mat4::from_translation(position) + * Mat4::from_rotation_z(rotation) + * Mat4::from_scale(Vec3::splat(size)); + let base = + u32::try_from(geometry.mesh.positions.len()).expect("bounded effect geometry fits u32"); + geometry.mesh.positions.extend( + mesh.positions + .iter() + .map(|p| transform.transform_point3(Vec3::from(*p)).to_array()), + ); + geometry.mesh.normals.extend(mesh.normals.iter().map(|n| { + Mat4::from_rotation_z(rotation) + .transform_vector3(Vec3::from(*n)) + .to_array() + })); + geometry.mesh.tex_coords.extend_from_slice(&mesh.tex_coords); + geometry + .mesh + .indices + .extend(mesh.indices.iter().map(|index| index + base)); + geometry + .colors + .extend(std::iter::repeat_n(color, mesh.positions.len())); +} + +fn append_foliage( + geometry: &mut EffectGeometry, + position: Vec3, + size: f32, + rotation: f32, + wind: Vec3, + color: [f32; 4], +) { + for angle in [rotation, rotation + std::f32::consts::FRAC_PI_2] { + let blade_width = Vec3::new(angle.cos(), 0.0, angle.sin()) * size * 0.2; + let top = position + Vec3::Y * size + wind; + let base = + u32::try_from(geometry.mesh.positions.len()).expect("bounded effect geometry fits u32"); + geometry.mesh.positions.extend( + [ + position - blade_width, + position + blade_width, + top + blade_width * 0.25, + top - blade_width * 0.25, + ] + .map(|v| v.to_array()), + ); + let normal = blade_width.cross(Vec3::Y).normalize_or_zero().to_array(); + geometry.mesh.normals.extend([normal; 4]); + geometry + .mesh + .tex_coords + .extend([[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]); + geometry + .mesh + .indices + .extend([base, base + 1, base + 2, base, base + 2, base + 3]); + geometry.colors.extend([color; 4]); + } +} + +/// Wind fields add in stable scene order. Masks select compatible consumers; +/// falloff is evaluated in the zone's local coordinates, including parent scale. +pub fn sample_wind(inputs: &[EffectInput], position: Vec3, time: f32, layers: u32) -> Vec3 { + inputs + .iter() + .filter_map(|i| match &i.effect.kind { + EffectKind::Wind(f) if i.effect.common.enabled && f.layers & layers != 0 => { + Some(field_force(f, i.transform, position, time)) + } + _ => None, + }) + .sum() +} + +fn sample_active_wind( + inputs: &[EffectInput], + active: &BTreeSet, + position: Vec3, + time: f32, + layers: u32, +) -> Vec3 { + inputs + .iter() + .filter(|i| active.contains(&i.entity)) + .filter_map(|i| match &i.effect.kind { + EffectKind::Wind(f) if f.layers & layers != 0 => { + Some(field_force(f, i.transform, position, time)) + } + _ => None, + }) + .sum() +} + +fn field_force(field: &ForceField, transform: Mat4, position: Vec3, time: f32) -> Vec3 { + let local = transform.inverse().transform_point3(position); + let half = Vec3::from(field.dimensions).abs() * 0.5; + let normalized = match field.shape { + VolumeShape::Sphere => local.length() / half.max_element().max(0.001), + VolumeShape::Cylinder => { + (local.xz().length() / half.x.max(0.001)).max(local.y.abs() / half.y.max(0.001)) + } + _ => (local.abs() / half.max(Vec3::splat(0.001))).max_element(), + }; + if !field.global && normalized > 1.0 { + return Vec3::ZERO; + } + let weight = if field.global { + 1.0 + } else { + (1.0 - normalized).max(0.0).powf(field.falloff.max(0.0)) + }; + let direction = transform + .transform_vector3(Vec3::from(field.direction)) + .normalize_or_zero(); + let radial = (position - transform.transform_point3(Vec3::ZERO)).normalize_or_zero(); + let noise = Vec3::new( + (position.z * field.frequency + time).sin(), + (position.x * field.frequency + time * 1.37).sin(), + (position.y * field.frequency - time * 0.93).cos(), + ); + let force = match field.force_type { + ForceType::Directional => direction, + ForceType::Attraction => -radial, + ForceType::Repulsion => radial, + ForceType::Vortex => direction.cross(radial), + ForceType::Turbulence => noise, + }; + (force * field.strength + noise * field.turbulence) * weight +} + +fn sample_triangles(triangles: &[[[f32; 3]; 3]], rng: &mut Random) -> Vec3 { + let area = |triangle: &[[f32; 3]; 3]| { + let a = Vec3::from(triangle[0]); + let b = Vec3::from(triangle[1]); + let c = Vec3::from(triangle[2]); + (b - a).cross(c - a).length() * 0.5 + }; + let total: f32 = triangles.iter().map(area).sum(); + if total <= f32::EPSILON { + return Vec3::ZERO; + } + let mut selection = rng.unit() * total; + let mut chosen = &triangles[triangles.len() - 1]; + for triangle in triangles { + selection -= area(triangle); + if selection <= 0.0 { + chosen = triangle; + break; + } + } + let u = rng.unit().sqrt(); + let v = rng.unit(); + Vec3::from(chosen[0]) * (1.0 - u) + + Vec3::from(chosen[1]) * u * (1.0 - v) + + Vec3::from(chosen[2]) * u * v +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +fn spawn_offset(shape: SpawnShape, dimensions: Vec3, surface: bool, rng: &mut Random) -> Vec3 { + let radius = if surface { 1.0 } else { rng.unit().cbrt() }; + match shape { + SpawnShape::Point | SpawnShape::MeshSurface => Vec3::ZERO, + SpawnShape::Sphere => rng.direction() * dimensions * 0.5 * radius, + SpawnShape::Hemisphere => { + let mut p = rng.direction(); + p.y = p.y.abs(); + p * dimensions * 0.5 * radius + } + SpawnShape::Box => { + let mut p = Vec3::new( + rng.range(-0.5, 0.5), + rng.range(-0.5, 0.5), + rng.range(-0.5, 0.5), + ); + if surface { + let axis = (rng.unit() * 3.0) as usize; + p[axis.min(2)] = if rng.unit() < 0.5 { -0.5 } else { 0.5 }; + } + p * dimensions + } + SpawnShape::Circle => { + let a = rng.unit() * TAU; + let r = if surface { + 0.5 + } else { + rng.unit().sqrt() * 0.5 + }; + Vec3::new(a.cos() * r * dimensions.x, 0.0, a.sin() * r * dimensions.z) + } + SpawnShape::Cylinder => { + let a = rng.unit() * TAU; + let r = if surface { + 0.5 + } else { + rng.unit().sqrt() * 0.5 + }; + Vec3::new( + a.cos() * r * dimensions.x, + rng.range(-0.5, 0.5) * dimensions.y, + a.sin() * r * dimensions.z, + ) + } + SpawnShape::Cone => { + let h = rng.unit().cbrt(); + let a = rng.unit() * TAU; + let r = if surface { h } else { h * rng.unit().sqrt() }; + Vec3::new( + a.cos() * r * dimensions.x * 0.5, + h * dimensions.y, + a.sin() * r * dimensions.z * 0.5, + ) + } + } +} + +#[allow(clippy::too_many_arguments)] +#[allow( + clippy::too_many_lines, + clippy::many_single_char_names, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated cosmetic capacities and tessellation bounds keep numerical conversions in range" +)] +fn simulate_particles( + instance: &mut Instance, + p: &ParticleEmitter, + input: &EffectInput, + transform: Mat4, + position: Vec3, + inputs: &[EffectInput], + active_fields: &BTreeSet, + dt: f32, + previous_time: f32, + emitting: bool, + frame: &mut EffectFrame, +) { + let common = &input.effect.common; + let local = common.simulation_space == SimulationSpace::Local; + let quality = match common.quality { + QualityLevel::Low => 0.5, + QualityLevel::Medium => 1.0, + QualityLevel::High => 1.5, + }; + let emitter_velocity = instance + .previous + .map_or(Vec3::ZERO, |old| (position - old) / dt.max(0.00001)); + if emitting { + let clipped_dt = if common.looping { + dt + } else { + dt.min((common.duration - previous_time.max(0.0)).max(0.0)) + }; + instance.emission += p.rate * quality * clipped_dt + + p.rate_over_distance + * quality + * instance.previous.map_or(0.0, |old| old.distance(position)); + let mut spawn = instance.emission.floor().clamp(0.0, 100_000.0) as usize; + instance.emission -= spawn as f32; + let duration = common.duration.max(0.001); + let first_cycle = (previous_time.max(0.0) / duration).floor() as u64; + let last_cycle = (instance.time.max(0.0) / duration).floor() as u64; + let mut burst_budget = 8192usize; + 'bursts: for cycle in first_cycle..=last_cycle.min(first_cycle + 100) { + if cycle > 0 && !common.looping { + break; + } + for burst in &p.bursts { + for repetition in 0..burst.cycles.min(1024) { + if burst_budget == 0 { + break 'bursts; + } + burst_budget -= 1; + let when = + cycle as f32 * duration + burst.time + repetition as f32 * burst.interval; + if when > cycle as f32 * duration + duration { + break; + } + if (when > previous_time || (when == 0.0 && !instance.began)) + && when <= instance.time + && instance.rng.unit() <= burst.probability + { + spawn = spawn.saturating_add(burst.count as usize); + } + } + } + } + instance.began = true; + let capacity = ((p.max_particles as f32 * quality.min(1.0)).min(100_000.0) as usize) + .saturating_sub(instance.particles.len()); + for _ in 0..spawn.min(capacity) { + let offset = if p.shape == SpawnShape::MeshSurface { + sample_triangles(&p.mesh_spawn_triangles, &mut instance.rng) + } else { + spawn_offset( + p.shape, + Vec3::from(p.dimensions), + p.surface_only, + &mut instance.rng, + ) + }; + let direction = if p.shape == SpawnShape::Cone { + (Vec3::from(p.direction).normalize_or_zero() + offset.normalize_or_zero() * 0.5) + .normalize_or_zero() + } else { + Vec3::from(p.direction).normalize_or_zero() + }; + let velocity = direction * instance.rng.range(p.speed[0], p.speed[1]); + let particle = Particle { + position: if local { + offset + } else { + position + transform.transform_vector3(offset) + }, + velocity: if local { + velocity + } else { + transform.transform_vector3(velocity) + emitter_velocity * p.inherit_velocity + }, + age: 0.0, + lifetime: instance.rng.range(p.lifetime[0], p.lifetime[1]).max(0.001), + size: instance.rng.range(p.start_size[0], p.start_size[1]), + rotation: instance.rng.unit() * TAU, + stuck: false, + }; + if frame.events.len() < MAX_EVENTS { + frame.events.push(EffectEvent { + entity: input.entity, + kind: EffectEventKind::Birth, + position: particle.position, + child_effect: if instance.rng.unit() <= p.sub_emitter_probability { + p.sub_emitter + } else { + None + }, + }); + } + instance.particles.push(particle); + } + } + let steps = (dt / (1.0 / 60.0)).ceil().clamp(1.0, 120.0) as usize; + let step = dt / steps as f32; + for _ in 0..steps { + for particle in &mut instance.particles { + particle.age += step; + if particle.age >= particle.lifetime { + continue; + } + if particle.stuck { + continue; + } + let world_position = if local { + transform.transform_point3(particle.position) + } else { + particle.position + }; + let mut force = Vec3::from(p.gravity) + + Vec3::from(p.force) + + sample_active_wind( + inputs, + active_fields, + world_position, + instance.time, + common.visibility_layers, + ) * p.wind_influence; + for field in inputs { + if let EffectKind::ForceField(f) = &field.effect.kind + && active_fields.contains(&field.entity) + && f.layers & common.visibility_layers != 0 + { + force += field_force(f, field.transform, world_position, instance.time); + } + } + force += Vec3::new( + (particle.position.y + instance.time * 1.7).sin(), + (particle.position.z + instance.time * 1.3).sin(), + (particle.position.x - instance.time * 1.1).cos(), + ) * p.noise; + if local { + force = transform.inverse().transform_vector3(force); + } + particle.velocity = + (particle.velocity + force * step) * (-p.drag.max(0.0) * step).exp(); + particle.velocity = particle + .velocity + .clamp_length_max(p.velocity_limit.max(0.0)); + let old = particle.position; + let next = old + particle.velocity * step; + particle.position = next; + particle.rotation += p.angular_velocity * step; + if p.collision && step > 0.0 { + let start = if local { + transform.transform_point3(old) + } else { + old + }; + let end = if local { + transform.transform_point3(next) + } else { + next + }; + for volume in inputs { + let EffectKind::CollisionVolume(c) = &volume.effect.kind else { + continue; + }; + if !active_fields.contains(&volume.entity) || c.layers & p.collision_layers == 0 + { + continue; + } + let inverse = volume.transform.inverse(); + let a = inverse.transform_point3(start); + let b = inverse.transform_point3(end); + let half = Vec3::from(c.dimensions).abs() * 0.5; + let contact = match c.shape { + VolumeShape::Sphere => sweep_sphere(a, b, half.max_element()), + VolumeShape::Box => sweep_box(a, b, half), + VolumeShape::Plane => { + if a.y >= 0.0 && b.y <= 0.0 && (a.y - b.y) != 0.0 { + let fraction = a.y / (a.y - b.y); + let hit = a.lerp(b, fraction); + if hit.x.abs() <= half.x && hit.z.abs() <= half.z { + Some((fraction, Vec3::Y)) + } else { + None + } + } else { + None + } + } + VolumeShape::Cylinder => sweep_cylinder(a, b, half.x.max(half.z), half.y), + }; + if let Some((fraction, normal)) = contact { + let normal = volume + .transform + .inverse() + .transpose() + .transform_vector3(normal) + .normalize_or_zero(); + let hit = start.lerp(end, fraction) + normal * 0.001; + particle.position = if local { + transform.inverse().transform_point3(hit) + } else { + hit + }; + let response = if p.collision_response == CollisionResponse::Kill + || c.response == CollisionResponse::Kill + { + CollisionResponse::Kill + } else if p.collision_response == CollisionResponse::Stick + || c.response == CollisionResponse::Stick + { + CollisionResponse::Stick + } else { + CollisionResponse::Bounce + }; + match response { + CollisionResponse::Kill => particle.age = particle.lifetime, + CollisionResponse::Stick => { + particle.velocity = Vec3::ZERO; + particle.stuck = true; + } + CollisionResponse::Bounce => { + let v = if local { + transform.transform_vector3(particle.velocity) + } else { + particle.velocity + }; + let v = bounce_velocity( + v, + normal, + p.bounce * c.bounce, + 1.0 - (1.0 - p.friction) * (1.0 - c.friction), + ); + particle.velocity = if local { + transform.inverse().transform_vector3(v) + } else { + v + }; + } + } + if frame.events.len() < MAX_EVENTS { + frame.events.push(EffectEvent { + entity: input.entity, + kind: EffectEventKind::Collision, + position: hit, + child_effect: c.impact_effect.or(p.sub_emitter), + }); + } + break; + } + } + } + } + } + instance.particles.retain(|particle| { + if particle.age >= particle.lifetime { + if frame.events.len() < MAX_EVENTS { + frame.events.push(EffectEvent { + entity: input.entity, + kind: EffectEventKind::Death, + position: particle.position, + child_effect: p.sub_emitter, + }); + } + false + } else { + true + } + }); +} + +#[cfg(test)] +#[allow( + clippy::float_cmp, + clippy::field_reassign_with_default, + reason = "tests check exact deterministic values and modify individual fixture controls" +)] +mod geometry_tests { + use super::*; + use crate::effects::{ + BeamRenderer, Burst, EffectParameter, FoliageScatter, ScreenFlash, TrailRenderer, + }; + #[test] + fn ribbon_is_indexed_and_camera_facing() { + let mesh = ribbon_mesh(&[Vec3::ZERO, Vec3::Y], &[2.0, 1.0], 0, Vec3::Z * 5.0); + assert_eq!(mesh.positions.len(), 4); + assert_eq!(mesh.indices.len(), 6); + assert!( + (Vec3::from(mesh.positions[0]).distance(Vec3::from(mesh.positions[1])) - 2.0).abs() + < 1.0e-5 + ); + assert!(mesh.positions.iter().flatten().all(|v| v.is_finite())); + } + #[test] + fn tube_closes_its_uv_seam_and_handles_parallel_tangents() { + let mesh = ribbon_mesh(&[Vec3::ZERO, Vec3::X, Vec3::X * 2.0], &[1.0; 3], 8, Vec3::Z); + assert_eq!(mesh.positions.len(), 27); + assert_eq!(mesh.indices.len(), 96); + assert!(Vec3::from(mesh.positions[0]).distance(Vec3::from(mesh.positions[8])) < 1.0e-5); + assert!(mesh.normals.iter().flatten().all(|v| v.is_finite())); + } + #[test] + fn random_directions_are_unit_and_repeatable() { + let mut a = Random::new(123); + let mut b = Random::new(123); + for _ in 0..1000 { + let direction = a.direction(); + assert_eq!(direction, b.direction()); + assert!((direction.length() - 1.0).abs() < 1.0e-5); + } + } + + fn emitter() -> EffectInput { + let mut p = ParticleEmitter::default(); + p.shape = SpawnShape::Point; + p.rate = 10.0; + p.speed = [0.0; 2]; + p.lifetime = [10.0; 2]; + p.gravity = [0.0; 3]; + p.drag = 0.0; + EffectInput { + entity: EntityId::new(), + transform: Mat4::IDENTITY, + effect: EffectComponent::new(EffectKind::Particle(p)), + } + } + + #[test] + fn lifecycle_pause_resume_restart_and_dispose_are_observable() { + let input = emitter(); + let id = input.entity; + let mut runtime = EffectRuntime::new(); + runtime.ensure(&input); + runtime.control(id, PlaybackCommand::Pause); + assert_eq!(runtime.state(id), PlaybackState::Paused); + assert_eq!( + runtime + .update(0.5, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 0 + ); + runtime.control(id, PlaybackCommand::Resume); + assert_eq!( + runtime + .update(0.5, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 5 + ); + runtime.control(id, PlaybackCommand::Pause); + assert_eq!( + runtime + .update(0.5, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 5 + ); + runtime.control(id, PlaybackCommand::Restart); + assert_eq!( + runtime + .update(0.2, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 2 + ); + runtime.control(id, PlaybackCommand::Dispose); + assert!(runtime.update(1.0, &[input], Vec3::Z).particles.is_empty()); + assert_eq!(runtime.state(id), PlaybackState::Disposed); + } + + #[test] + fn deterministic_seed_and_maximum_particle_budget() { + let mut input = emitter(); + if let EffectKind::Particle(p) = &mut input.effect.kind { + p.shape = SpawnShape::Sphere; + p.speed = [1.0, 3.0]; + p.max_particles = 4; + p.rate = 1000.0; + } + let mut a = EffectRuntime::new(); + let mut b = EffectRuntime::new(); + a.update(0.1, std::slice::from_ref(&input), Vec3::Z); + b.update(0.1, &[input], Vec3::Z); + assert_eq!(a.particle_count(), 4); + for (a, b) in a.frame().particles.iter().zip(&b.frame().particles) { + assert_eq!(a.position, b.position); + assert_eq!(a.size, b.size); + } + } + + #[test] + fn timed_bursts_do_not_repeat_during_pause_and_loop_crossing() { + let mut input = emitter(); + input.effect.common.duration = 1.0; + if let EffectKind::Particle(p) = &mut input.effect.kind { + p.rate = 0.0; + p.bursts = vec![Burst { + time: 0.25, + count: 3, + ..Burst::default() + }]; + } + let mut runtime = EffectRuntime::new(); + assert_eq!( + runtime + .update(0.2, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 0 + ); + assert_eq!( + runtime + .update(0.1, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 3 + ); + assert_eq!( + runtime + .update(1.0, std::slice::from_ref(&input), Vec3::Z) + .particles + .len(), + 6 + ); + runtime.control(input.entity, PlaybackCommand::Pause); + assert_eq!(runtime.update(1.0, &[input], Vec3::Z).particles.len(), 6); + } + + #[test] + fn completion_event_fires_once_and_unload_removes_allocated_state() { + let mut input = emitter(); + input.effect.common.duration = 0.25; + input.effect.common.looping = false; + if let EffectKind::Particle(p) = &mut input.effect.kind { + p.rate = 0.0; + } + let mut runtime = EffectRuntime::new(); + assert_eq!( + runtime + .update(0.5, std::slice::from_ref(&input), Vec3::Z) + .events + .iter() + .filter(|e| e.kind == EffectEventKind::Completed) + .count(), + 1 + ); + assert_eq!(runtime.state(input.entity), PlaybackState::Completed); + assert!( + runtime + .update(0.1, std::slice::from_ref(&input), Vec3::Z) + .events + .is_empty() + ); + runtime.update(0.1, &[], Vec3::Z); + assert!(runtime.instances.is_empty()); + } + + #[test] + fn ordinary_scene_targets_drive_beam_endpoints() { + let target = EntityId::new(); + let input = EffectInput { + entity: EntityId::new(), + transform: Mat4::IDENTITY, + effect: EffectComponent::new(EffectKind::Beam(BeamRenderer { + end_target: Some(target), + end: [0.0; 3], + noise_amplitude: 0.0, + ..Default::default() + })), + }; + let mut targets = BTreeMap::new(); + targets.insert(target, Mat4::from_translation(Vec3::Y * 5.0)); + let mut runtime = EffectRuntime::new(); + let frame = runtime.update_with_targets(0.1, &[input], Vec3::Z * 10.0, &targets); + assert!( + frame.geometry[0] + .mesh + .positions + .iter() + .any(|p| (p[1] - 5.0).abs() < 0.0001) + ); + } + + #[test] + fn swept_contacts_handle_thin_volumes_and_cylinder_corners() { + let hit = sweep_box(Vec3::Y * 10.0, -Vec3::Y * 10.0, Vec3::new(2.0, 0.01, 2.0)).unwrap(); + assert!((hit.0 - 0.4995).abs() < 0.0001); + assert_eq!(hit.1, Vec3::Y); + assert!( + sweep_cylinder( + Vec3::new(0.9, 2.0, 0.9), + Vec3::new(0.9, -2.0, 0.9), + 1.0, + 1.0 + ) + .is_none() + ); + let cylinder = sweep_cylinder(Vec3::X * 2.0, -Vec3::X * 2.0, 1.0, 1.0).unwrap(); + assert_eq!(cylinder.0, 0.25); + assert_eq!(cylinder.1, Vec3::X); + assert_eq!( + bounce_velocity(Vec3::new(2.0, -4.0, 0.0), Vec3::Y, 0.5, 0.25), + Vec3::new(1.5, 2.0, 0.0) + ); + } + + #[test] + fn shape_samples_stay_in_bounds_and_triangle_samples_stay_on_surface() { + let mut rng = Random::new(77); + for shape in [ + SpawnShape::Sphere, + SpawnShape::Hemisphere, + SpawnShape::Box, + SpawnShape::Cone, + SpawnShape::Circle, + SpawnShape::Cylinder, + ] { + for _ in 0..1000 { + let p = spawn_offset(shape, Vec3::splat(2.0), false, &mut rng); + assert!(p.is_finite()); + assert!(p.x.abs() <= 1.0001 && p.z.abs() <= 1.0001); + if shape == SpawnShape::Hemisphere { + assert!(p.y >= 0.0); + } + } + } + for _ in 0..1000 { + let p = sample_triangles( + &[[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]], + &mut rng, + ); + assert_eq!(p.y, 0.0); + assert!(p.x >= 0.0 && p.z >= 0.0 && p.x + p.z <= 1.00001); + } + } + + #[test] + fn trailing_points_follow_attachment_and_expire() { + let mut input = EffectInput { + entity: EntityId::new(), + transform: Mat4::IDENTITY, + effect: EffectComponent::new(EffectKind::Trail(TrailRenderer { + lifetime: 0.3, + ..Default::default() + })), + }; + let mut runtime = EffectRuntime::new(); + runtime.update(0.1, std::slice::from_ref(&input), Vec3::Z); + input.transform = Mat4::from_translation(Vec3::X); + assert_eq!( + runtime + .update(0.1, std::slice::from_ref(&input), Vec3::Z) + .geometry[0] + .mesh + .positions + .len(), + 4 + ); + if let EffectKind::Trail(t) = &mut input.effect.kind { + t.emitting = false; + } + assert!( + runtime.update(0.4, &[input], Vec3::Z).geometry[0] + .mesh + .positions + .is_empty() + ); + } + + #[test] + fn mesh_particles_and_foliage_generate_batched_triangles_without_asset() { + let mut input = emitter(); + if let EffectKind::Particle(p) = input.effect.kind.clone() { + input.effect.kind = EffectKind::MeshParticle(p); + } + let mut runtime = EffectRuntime::new(); + let frame = runtime.update(0.2, std::slice::from_ref(&input), Vec3::Z); + assert!(frame.particles.is_empty()); + assert_eq!(frame.geometry.len(), 1); + assert_eq!(frame.geometry[0].mesh.indices.len(), 72); + input.effect.kind = EffectKind::FoliageScatter(FoliageScatter { + dimensions: [1.0; 3], + density: 3.0, + ..Default::default() + }); + let frame = runtime.update(0.1, &[input], Vec3::Z); + assert!(frame.particles.is_empty()); + assert_eq!(frame.geometry[0].mesh.indices.len(), 36); + } + + #[test] + fn gravity_drag_and_field_stop_affect_real_particle_motion() { + let mut emitter = emitter(); + if let EffectKind::Particle(p) = &mut emitter.effect.kind { + p.rate = 0.0; + p.bursts = vec![Burst { + count: 1, + ..Default::default() + }]; + p.gravity = [0.0, -10.0, 0.0]; + p.drag = 1.0; + } + let wind = EffectInput { + entity: EntityId::new(), + transform: Mat4::IDENTITY, + effect: EffectComponent::new(EffectKind::Wind(ForceField { + global: true, + strength: 5.0, + ..Default::default() + })), + }; + let mut runtime = EffectRuntime::new(); + runtime.update(0.1, &[emitter.clone(), wind.clone()], Vec3::Z); + let first = runtime.frame().particles[0].clone(); + assert!(first.position.y < 0.0); + assert!(first.velocity.x > 0.0); + runtime.control(wind.entity, PlaybackCommand::Stop); + runtime.update(0.1, &[emitter, wind], Vec3::Z); + assert!(runtime.frame().particles[0].velocity.x < first.velocity.x); + } + + #[test] + fn atlas_frames_and_camera_targets_follow_configuration() { + assert_eq!(flipbook_rect([2, 2], 4.0, 0.75), [0.5, 0.5, 1.0, 1.0]); + let camera = EntityId::new(); + let flash = EffectInput { + entity: EntityId::new(), + transform: Mat4::IDENTITY, + effect: EffectComponent::new(EffectKind::ScreenFlash(ScreenFlash { + target: Some(camera), + ..Default::default() + })), + }; + let mut runtime = EffectRuntime::new(); + runtime.set_camera_target(Some(EntityId::new())); + assert_eq!( + runtime + .update(0.1, std::slice::from_ref(&flash), Vec3::ZERO) + .post + .overlay[3], + 0.0 + ); + runtime.set_camera_target(Some(camera)); + assert!(runtime.update(0.1, &[flash], Vec3::ZERO).post.overlay[3] > 0.0); + } + #[test] + #[ignore = "explicit performance observation; no timing threshold"] + fn ten_thousand_particle_performance_smoke() { + let mut input = emitter(); + if let EffectKind::Particle(p) = &mut input.effect.kind { + p.rate = 0.0; + p.max_particles = 10000; + p.lifetime = [60.0; 2]; + p.bursts = vec![Burst { + count: 10000, + ..Default::default() + }]; + } + let mut runtime = EffectRuntime::new(); + runtime.update(0.0, std::slice::from_ref(&input), Vec3::Z); + let start = std::time::Instant::now(); + for _ in 0..120 { + runtime.update(1.0 / 60.0, std::slice::from_ref(&input), Vec3::Z); + assert_eq!(runtime.particle_count(), 10000); + } + eprintln!( + "10,000 CPU particles, 120 frames: {:?}; final particle records: {} (renderer batches by blend)", + start.elapsed(), + runtime.frame().particles.len() + ); + } + + #[test] + fn prewarm_and_parameter_bindings_simulate_before_first_visible_frame() { + let mut input = emitter(); + if let EffectKind::Particle(p) = &mut input.effect.kind { + p.prewarm = true; + p.prewarm_time = 1.0; + p.speed = [1.0; 2]; + } + let mut runtime = EffectRuntime::new(); + let frame = runtime.update(0.0, std::slice::from_ref(&input), Vec3::Z); + assert!(frame.particles.len() >= 9); + assert!(frame.particles.iter().any(|p| p.position.y > 0.5)); + assert!(frame.events.is_empty()); + input + .effect + .common + .parameters + .insert("emission_rate".into(), EffectParameter::Scalar(100.0)); + let before = runtime.particle_count(); + runtime.update(0.1, &[input], Vec3::Z); + assert!(runtime.particle_count() >= before + 9); + } +} diff --git a/Engine/crates/engine-world/src/effects/water.rs b/Engine/crates/engine-world/src/effects/water.rs new file mode 100644 index 00000000..df483b5f --- /dev/null +++ b/Engine/crates/engine-world/src/effects/water.rs @@ -0,0 +1,195 @@ +//! Water-surface interface shared by procedural water and imported mesh surfaces. +use super::RingEffect; +use glam::{Vec2, Vec3}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WaterSample { + pub displacement: f32, + pub normal: Vec3, + pub foam: f32, +} +impl Default for WaterSample { + fn default() -> Self { + Self { + displacement: 0.0, + normal: Vec3::Y, + foam: 0.0, + } + } +} +/// A compatible water surface samples disturbances in its world XZ plane. +pub trait WaterSurface { + fn sample(&self, world_position: Vec3) -> WaterSample; +} +#[derive(Clone, Debug)] +pub struct WaterDisturbance { + pub source: Vec3, + pub elapsed: f32, + pub duration: f32, + pub settings: RingEffect, +} +impl WaterDisturbance { + pub fn sample(&self, world_position: Vec3) -> WaterSample { + let p = &self.settings; + let elapsed = self.elapsed.max(0.0); + if elapsed > self.duration { + return WaterSample::default(); + } + let speed = (p.final_radius - p.initial_radius) / self.duration.max(0.001); + let relative = world_position - self.source; + let radial = Vec2::new(relative.x, relative.z); + let distance = radial.length(); + let radius = p.initial_radius + speed * elapsed; + let band = ((distance - radius) / p.thickness.max(0.01)).abs(); + let envelope = (-band * band * 2.0).exp() * (-elapsed * p.damping.max(0.0)).exp(); + let k = std::f32::consts::TAU / p.wavelength.max(0.01); + let phase = k * (distance - radius); + let mut height = phase.sin() * p.amplitude * envelope; + let velocity = Vec2::new(p.source_velocity[0], p.source_velocity[2]); + let direction = velocity.normalize_or_zero(); + let along = -radial.dot(direction); + let sideways = radial.perp_dot(direction).abs(); + let wake_width = (along * p.wake_angle.tan().abs()).max(p.thickness); + let wake = if velocity.length_squared() > 0.001 && along > 0.0 { + (-((sideways - wake_width) / p.thickness.max(0.01)).powi(2) * 2.0).exp() + * (-along / p.final_radius.max(0.1)).exp() + * (-elapsed * p.damping).exp() + } else { + 0.0 + }; + height += wake * p.amplitude; + let slope = phase.cos() * k * p.amplitude * envelope * p.normal_strength; + let normal = Vec3::new( + -radial.x / distance.max(0.001) * slope, + 1.0, + -radial.y / distance.max(0.001) * slope, + ) + .normalize(); + let foam = + ((envelope + wake) * p.foam_intensity * (-elapsed / p.foam_lifetime.max(0.001)).exp()) + .clamp(0.0, 1.0); + WaterSample { + displacement: height, + normal, + foam, + } + } +} +/// Bounded disturbance collection suitable for persistent water components. +#[derive(Clone, Debug)] +pub struct RippleWaterSurface { + pub disturbances: std::collections::VecDeque, + pub max_disturbances: usize, +} +impl Default for RippleWaterSurface { + fn default() -> Self { + Self { + disturbances: std::collections::VecDeque::default(), + max_disturbances: 32, + } + } +} +impl RippleWaterSurface { + pub fn disturb(&mut self, disturbance: WaterDisturbance) { + let limit = self.max_disturbances.clamp(1, 256); + while self.disturbances.len() >= limit { + self.disturbances.pop_front(); + } + self.disturbances.push_back(disturbance); + } + pub fn update(&mut self, dt: f32) { + let dt = if dt.is_finite() { dt.max(0.0) } else { 0.0 }; + for d in &mut self.disturbances { + d.elapsed += dt; + } + self.disturbances.retain(|d| d.elapsed <= d.duration); + } +} +impl WaterSurface for RippleWaterSurface { + fn sample(&self, position: Vec3) -> WaterSample { + let mut result = WaterSample::default(); + let mut slope = Vec3::ZERO; + for d in &self.disturbances { + let s = d.sample(position); + result.displacement += s.displacement; + slope += Vec3::new( + s.normal.x / s.normal.y.max(0.001), + 0.0, + s.normal.z / s.normal.y.max(0.001), + ); + result.foam = 1.0 - (1.0 - result.foam) * (1.0 - s.foam); + } + result.normal = Vec3::new(slope.x, 1.0, slope.z).normalize(); + result + } +} +/// Produces a reusable displaced water grid with per-vertex foam coverage. +pub fn water_surface_mesh( + surface: &impl WaterSurface, + center: Vec3, + size: f32, + subdivisions: u16, +) -> (crate::PrimitiveMesh, Vec) { + let n = subdivisions.clamp(2, 128); + let mut mesh = crate::PrimitiveMesh::default(); + let mut foam = Vec::with_capacity(usize::from(n + 1).pow(2)); + for z in 0..=n { + for x in 0..=n { + let uv = [f32::from(x) / f32::from(n), f32::from(z) / f32::from(n)]; + let mut pos = center + Vec3::new((uv[0] - 0.5) * size, 0.0, (uv[1] - 0.5) * size); + let s = surface.sample(pos); + pos.y += s.displacement; + mesh.positions.push(pos.to_array()); + mesh.normals.push(s.normal.to_array()); + mesh.tex_coords.push(uv); + foam.push(s.foam); + if x < n && z < n { + let a = u32::from(z) * u32::from(n + 1) + u32::from(x); + let b = a + u32::from(n + 1); + mesh.indices + .extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]); + } + } + } + (mesh, foam) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn disturbance_displaces_and_expires() { + let mut water = RippleWaterSurface::default(); + water.disturb(WaterDisturbance { + source: Vec3::ZERO, + elapsed: 0.2, + duration: 2.0, + settings: RingEffect::default(), + }); + let s = water.sample(Vec3::new(0.55, 0.0, 0.0)); + assert!(s.displacement.abs() > 0.0001); + assert!((s.normal.length() - 1.0).abs() < 1e-5); + water.update(3.0); + assert_eq!(water.sample(Vec3::ZERO), WaterSample::default()); + } + #[test] + fn water_grid_has_displaced_normals_and_bounded_storage() { + let mut water = RippleWaterSurface { + max_disturbances: 2, + ..Default::default() + }; + for _ in 0..5 { + water.disturb(WaterDisturbance { + source: Vec3::ZERO, + elapsed: 0.2, + duration: 2.0, + settings: RingEffect::default(), + }); + } + assert_eq!(water.disturbances.len(), 2); + let (mesh, foam) = water_surface_mesh(&water, Vec3::ZERO, 4.0, 16); + assert_eq!(mesh.positions.len(), 289); + assert_eq!(foam.len(), 289); + assert!(mesh.positions.iter().any(|p| p[1].abs() > 0.001)); + assert_eq!(mesh.indices.len(), 16 * 16 * 6); + } +} diff --git a/Engine/crates/engine-world/src/lib.rs b/Engine/crates/engine-world/src/lib.rs index a558c41f..68cf5dd1 100644 --- a/Engine/crates/engine-world/src/lib.rs +++ b/Engine/crates/engine-world/src/lib.rs @@ -3,7 +3,9 @@ #![forbid(unsafe_code)] mod components; +pub mod effects; mod environment; +pub use effects::EffectComponent; mod foundations; pub use environment::SceneEnvironment; mod ids; diff --git a/Engine/crates/engine-world/src/scene.rs b/Engine/crates/engine-world/src/scene.rs index 16b10b22..b3e2fa00 100644 --- a/Engine/crates/engine-world/src/scene.rs +++ b/Engine/crates/engine-world/src/scene.rs @@ -27,6 +27,7 @@ const MATERIAL_SCHEMA: &str = "rustic.material"; const CAMERA_SCHEMA: &str = "rustic.camera"; const LIGHT_SCHEMA: &str = "rustic.light"; const PRIMITIVE_SCHEMA: &str = "rustic.primitive"; +const EFFECT_SCHEMA: &str = "rustic.effect"; const SCRIPTS_SCHEMA: &str = "rustic.scripts"; const PART_ATTRIBUTES_SCHEMA: &str = "rustic.part_attributes"; @@ -52,6 +53,7 @@ pub struct EntitySnapshot { pub camera: Option, pub light: Option, pub primitive: Option, + pub effect: Option, pub part_attributes: PartAttributes, pub scripts: Vec, pub unknown_components: Vec, @@ -71,6 +73,7 @@ impl Default for EntitySnapshot { camera: None, light: None, primitive: None, + effect: None, part_attributes: PartAttributes::default(), scripts: Vec::new(), unknown_components: Vec::new(), @@ -94,6 +97,9 @@ impl EntitySnapshot { if let Some(textures) = &self.textures { textures.validate().map_err(WorldError::InvalidArgument)?; } + if let Some(effect) = &self.effect { + effect.validate().map_err(WorldError::InvalidArgument)?; + } for script in &self.scripts { script .validate() @@ -430,6 +436,9 @@ impl EntityWire { if let Some(primitive) = snapshot.primitive { components.push(encode_component(PRIMITIVE_SCHEMA, &primitive)?); } + if let Some(effect) = snapshot.effect { + components.push(encode_component(EFFECT_SCHEMA, &effect)?); + } components.push(encode_component( PART_ATTRIBUTES_SCHEMA, &snapshot.part_attributes, @@ -744,6 +753,13 @@ fn decode_entity(entity: EntityWire, diagnostics: &mut Vec) -> &mut snapshot.unknown_components, |value| snapshot.primitive = Some(value), ), + EFFECT_SCHEMA => assign_or_preserve( + &raw, + entity.id, + diagnostics, + &mut snapshot.unknown_components, + |value| snapshot.effect = Some(value), + ), PART_ATTRIBUTES_SCHEMA => assign_or_preserve( &raw, entity.id, diff --git a/Engine/crates/engine-world/src/undo.rs b/Engine/crates/engine-world/src/undo.rs index f8f4bafa..794aeb60 100644 --- a/Engine/crates/engine-world/src/undo.rs +++ b/Engine/crates/engine-world/src/undo.rs @@ -7,6 +7,11 @@ use crate::{ /// command batch and therefore cannot leave a partial hierarchy edit behind. #[derive(Clone, Debug, PartialEq)] pub enum SceneEdit { + Effect { + entity: EntityId, + before: Option, + after: Option, + }, Environment { before: crate::SceneEnvironment, after: crate::SceneEnvironment, @@ -84,6 +89,7 @@ impl SceneEdit { pub fn label(&self) -> &str { match self { + Self::Effect { .. } => "Scene effect", Self::Environment { .. } => "Scene environment", Self::Camera { .. } => "Camera", Self::Light { .. } => "Light", @@ -102,6 +108,10 @@ impl SceneEdit { fn append_forward(&self, output: &mut Vec) { match self { + Self::Effect { entity, after, .. } => output.push(WorldCommand::SetEffect { + entity: *entity, + value: after.clone().map(Box::new), + }), Self::Environment { after, .. } => { output.push(WorldCommand::SetEnvironment(after.clone())); } @@ -157,6 +167,10 @@ impl SceneEdit { fn append_reverse(&self, output: &mut Vec) { match self { + Self::Effect { entity, before, .. } => output.push(WorldCommand::SetEffect { + entity: *entity, + value: before.clone().map(Box::new), + }), Self::Environment { before, .. } => { output.push(WorldCommand::SetEnvironment(before.clone())); } diff --git a/Engine/crates/engine-world/src/world.rs b/Engine/crates/engine-world/src/world.rs index 4c296008..ee9cfcb6 100644 --- a/Engine/crates/engine-world/src/world.rs +++ b/Engine/crates/engine-world/src/world.rs @@ -86,6 +86,10 @@ pub enum WorldCommand { entity: EntityId, value: Option, }, + SetEffect { + entity: EntityId, + value: Option>, + }, SetPartAttributes { entity: EntityId, value: PartAttributes, @@ -165,6 +169,10 @@ struct Slot { /// Live ECS world with stable identities, generational references, and a scene graph. pub struct SceneWorld { + effects_runtime: crate::effects::EffectRuntime, + effect_assets: BTreeMap, + temporary_effects: BTreeMap, + completed_effect_groups: BTreeSet, pub(crate) services: crate::foundations::EntityServices, environment: crate::SceneEnvironment, id: WorldId, @@ -202,6 +210,10 @@ impl Default for SceneWorld { impl SceneWorld { pub fn new() -> Self { Self { + effects_runtime: crate::effects::EffectRuntime::default(), + effect_assets: BTreeMap::new(), + temporary_effects: BTreeMap::new(), + completed_effect_groups: BTreeSet::new(), services: crate::foundations::EntityServices::default(), environment: crate::SceneEnvironment::default(), id: WorldId::new(), @@ -439,6 +451,12 @@ impl SceneWorld { } for command in commands { + if let WorldCommand::SetEffect { + value: Some(value), .. + } = command + { + value.validate().map_err(WorldError::InvalidArgument)?; + } if let WorldCommand::SetTextures { value: Some(value), .. } = command @@ -456,6 +474,7 @@ impl SceneWorld { | WorldCommand::SetCamera { entity, .. } | WorldCommand::SetLight { entity, .. } | WorldCommand::SetPrimitive { entity, .. } + | WorldCommand::SetEffect { entity, .. } | WorldCommand::SetPartAttributes { entity, .. } | WorldCommand::SetScripts { entity, .. } => Some(*entity), WorldCommand::SetParent { child, .. } => Some(*child), @@ -513,6 +532,12 @@ impl SceneWorld { WorldCommand::SetPrimitive { entity, value } => { self.insert_or_remove(*entity, value.clone()); } + WorldCommand::SetEffect { entity, value } => { + self.insert_or_remove(*entity, value.as_deref().cloned()); + if value.is_none() { + self.effects_runtime.remove(*entity); + } + } WorldCommand::SetPartAttributes { entity, value } => { self.insert_or_remove(*entity, Some(*value)); } @@ -535,6 +560,9 @@ impl SceneWorld { } for command in commands { if let WorldCommand::Despawn(entity) = command { + self.effects_runtime.remove(*entity); + self.temporary_effects.remove(entity); + self.completed_effect_groups.remove(entity); self.despawn_now(*entity); } } @@ -648,6 +676,7 @@ impl SceneWorld { camera: self.ecs.get::(entity).copied(), light: self.ecs.get::(entity).copied(), primitive: self.ecs.get::(entity).cloned(), + effect: self.ecs.get::(entity).cloned(), part_attributes: self .ecs .get::(entity) @@ -671,6 +700,234 @@ impl SceneWorld { Ok(self.ecs.get::(entity).copied()) } + /// Returns the authored scene effect, independent of playback state. + /// + /// # Errors + /// Returns [`WorldError::EntityNotFound`] when the entity is absent. + pub fn effect(&self, id: EntityId) -> Result, WorldError> { + let entity = self.ecs_entity(id)?; + Ok(self.ecs.get::(entity).cloned()) + } + + /// Advances cosmetic effects after transforms and physics. Authored data is unchanged. + pub fn update_effects(&mut self, delta: f32) { + let camera_entity = self + .entity_ids() + .filter_map(|entity| { + let camera = self.camera(entity).ok()??; + camera.active.then_some((camera.order, entity)) + }) + .max().map(|(_, entity)| entity); + let camera = camera_entity + .and_then(|entity| self.world_transform(entity).ok()) + .map_or(glam::Vec3::ZERO, |transform| transform.0.w_axis.truncate()); + self.update_effects_from_camera(delta, camera, camera_entity); + } + + /// Advances the shared simulation using an editor or custom viewport camera. + /// A missing target previews camera effects independently of game-camera assignment. + pub fn update_effects_from_camera(&mut self, delta: f32, camera: glam::Vec3, camera_entity: Option) { + let transforms: BTreeMap<_, _> = self.entity_ids().filter_map(|entity| { + self.world_transform(entity).ok().map(|transform| (entity, transform.0)) + }).collect(); + let inputs: Vec<_> = self + .entity_ids() + .filter_map(|entity| { + Some(crate::effects::EffectInput { + entity, + transform: self.world_transform(entity).ok()?.0, + effect: self.effect(entity).ok()??, + }) + }) + .collect(); + self.effects_runtime.set_camera_target(camera_entity); + self.effects_runtime.update_with_targets(delta, &inputs, camera, &transforms); + self.update_effect_children(delta); + self.update_effect_group_events(); + } + + fn update_effect_group_events(&mut self) { + let mut groups = BTreeSet::new(); + for event in &self.effects_runtime.frame().events { + if event.kind != crate::effects::EffectEventKind::Completed { continue; } + let mut ancestor = self.parent(event.entity).ok().flatten(); + while let Some(entity) = ancestor { + if self.ecs_entity(entity).is_ok_and(|entity| self.ecs.get::(entity).is_some()) { + groups.insert(entity); + } + ancestor = self.parent(entity).ok().flatten(); + } + } + for entity in groups { + if self.effect_state(entity).ok().flatten() == Some(crate::effects::PlaybackState::Completed) + && self.completed_effect_groups.insert(entity) { + let position = self.world_transform(entity).map_or(glam::Vec3::ZERO, |transform| transform.0.w_axis.truncate()); + self.effects_runtime.emit_event(crate::effects::EffectEvent { entity, kind: crate::effects::EffectEventKind::Completed, position, child_effect: None }); + } + } + } + + /// Registers a validated asset under the existing project sidecar identity. + /// + /// # Errors + /// Returns an error for an invalid asset or incompatible instance overrides. + pub fn register_effect_asset(&mut self, id: crate::AssetId, asset: crate::effects::EffectAsset) -> Result<(), WorldError> { + asset.validate().map_err(WorldError::InvalidArgument)?; + let mut commands = Vec::new(); + for entity in self.entity_ids() { + let Some(instance) = self.effect(entity)? else { continue; }; + let Some(source) = &instance.source else { continue; }; + if source.asset != id { continue; } + let Some(new_default) = asset.effects.get(source.child as usize) else { continue; }; + let mut new_default = new_default.clone(); + let parent = self.parent(entity)?; + let remap: BTreeMap<_, _> = self.entity_ids().filter_map(|sibling| { + if self.parent(sibling).ok()? != parent { return None; } + let source = self.effect(sibling).ok()??.source?; + if source.asset != id { return None; } + Some((*asset.entity_ids.get(source.child as usize)?, sibling)) + }).collect(); + new_default.remap_entities(&remap); + let Some(baseline) = &source.baseline else { continue; }; + let old_default = baseline.as_ref().clone().into_component(); + let mut merged = crate::effects::merge_instance_defaults(&instance, &old_default, &new_default).map_err(WorldError::InvalidArgument)?; + if let Some(source) = &mut merged.source { + source.baseline = Some(Box::new(crate::effects::EffectDefaults::from_component(&new_default))); + } + if merged != instance { commands.push(WorldCommand::SetEffect { entity, value: Some(Box::new(merged)) }); } + } + if !commands.is_empty() { self.apply_commands(&commands)?; } + self.effect_assets.insert(id, asset); + Ok(()) + } + + pub fn has_effect_asset(&self, id: crate::AssetId) -> bool { + self.effect_assets.contains_key(&id) + } + + /// Instantiates a reusable or composite asset as an ordinary parented scene subtree. + /// + /// # Errors + /// Returns an error for a missing asset, invalid parent or invalid transform. + pub fn instantiate_effect_asset(&mut self, asset_id: crate::AssetId, parent: Option, transform: LocalTransform) -> Result { + let asset = self.effect_assets.get(&asset_id).ok_or_else(|| WorldError::InvalidArgument(format!("effect asset {asset_id} is unavailable")))?; + let root = EntitySnapshot { name: Some(asset.name.clone()), folder: true, parent, local_transform: transform, ..Default::default() }; + let root_id = root.id; + let mut commands = vec![WorldCommand::Spawn(Box::new(root))]; + for mut snapshot in asset.instances_with_ids(Some(asset_id)) { + snapshot.parent = Some(root_id); + commands.push(WorldCommand::Spawn(Box::new(snapshot))); + } + self.apply_commands(&commands)?; + self.propagate_transforms(); + Ok(root_id) + } + + /// Samples global and local wind using the same fields as particle simulation. + pub fn sample_effect_wind(&self, position: glam::Vec3, time: f32, layers: u32) -> glam::Vec3 { + let inputs: Vec<_> = self.entity_ids().filter_map(|entity| Some(crate::effects::EffectInput { + entity, transform: self.world_transform(entity).ok()?.0, effect: self.effect(entity).ok()??, + })).collect(); + crate::effects::sample_wind(&inputs, position, time, layers) + } + + fn update_effect_children(&mut self, delta: f32) { + // Cosmetic child effects cannot recursively grow without a hard bound. + const MAX_CHILDREN: usize = 1024; + const MAX_SPAWNS_PER_TICK: usize = 128; + let mut expired = Vec::new(); + for (entity, (_, remaining)) in &mut self.temporary_effects { + *remaining -= delta.max(0.0); + if *remaining <= 0.0 { expired.push(*entity); } + } + if !expired.is_empty() { + let commands: Vec<_> = expired.into_iter().map(WorldCommand::Despawn).collect(); + let _ = self.apply_commands(&commands); + } + let events = self.effects_runtime.frame().events.clone(); + let mut commands = Vec::new(); + for event in events { + let Some(asset_id) = event.child_effect else { continue; }; + let generation = self.temporary_effects.get(&event.entity).map_or(0, |(generation, _)| *generation); + let maximum = self.effect(event.entity).ok().flatten().map_or(0, |effect| match effect.kind { + crate::effects::EffectKind::Particle(p) | crate::effects::EffectKind::MeshParticle(p) => p.max_generations.min(8), + crate::effects::EffectKind::Weather(w) => w.emitter.max_generations.min(8), + crate::effects::EffectKind::CollisionVolume(_) => 3, + _ => 0, + }); + if generation >= maximum { continue; } + let Some(asset) = self.effect_assets.get(&asset_id) else { continue; }; + if commands.len() + asset.effects.len() > MAX_SPAWNS_PER_TICK || self.temporary_effects.len() + asset.effects.len() > MAX_CHILDREN { continue; } + for mut snapshot in asset.instances_with_ids(Some(asset_id)) { + let Some(effect) = &mut snapshot.effect else { continue; }; + effect.common.looping = false; + let tail = match &effect.kind { + crate::effects::EffectKind::Particle(p) | crate::effects::EffectKind::MeshParticle(p) => p.lifetime[1], + crate::effects::EffectKind::Trail(t) => t.lifetime, + _ => 0.0, + }; + let lifetime = effect.common.delay + effect.common.duration + tail; + snapshot.name = Some(format!("{} (sub-emitter)", asset.name)); + snapshot.local_transform.translation += event.position; + self.temporary_effects.insert(snapshot.id, (generation + 1, lifetime)); + commands.push(WorldCommand::Spawn(Box::new(snapshot))); + } + } + if !commands.is_empty() { + let _ = self.apply_commands(&commands); + self.propagate_transforms(); + } + } + + /// Immutable render extraction and events for the latest simulation tick. + pub fn effect_frame(&self) -> &crate::effects::EffectFrame { + self.effects_runtime.frame() + } + + /// Controls every effect in an entity subtree. + /// + /// # Errors + /// Returns an error if the entity is absent or the subtree has no effects. + pub fn control_effect( + &mut self, + id: EntityId, + command: crate::effects::PlaybackCommand, + ) -> Result<(), WorldError> { + let mut targets = self.descendants(id)?; + targets.insert(0, id); + let mut found = false; + for entity in targets { + if matches!(command, crate::effects::PlaybackCommand::Play | crate::effects::PlaybackCommand::Restart | crate::effects::PlaybackCommand::Reset) { + self.completed_effect_groups.remove(&entity); + } + let Some(effect) = self.effect(entity)? else { continue; }; + found = true; + let transform = self.world_transform(entity)?.0; + self.effects_runtime.ensure(&crate::effects::EffectInput { entity, transform, effect }); + self.effects_runtime.control(entity, command); + } + if !found { return Err(WorldError::InvalidArgument("entity subtree has no scene effect".into())); } + Ok(()) + } + + /// Returns an aggregate playback state for an entity subtree. + /// + /// # Errors + /// Returns an error if the entity is absent. + pub fn effect_state( + &self, + id: EntityId, + ) -> Result, WorldError> { + use crate::effects::PlaybackState; + if self.effect(id)?.is_some() { return Ok(Some(self.effects_runtime.state(id))); } + let states: Vec<_> = self.descendants(id)?.into_iter().filter(|entity| self.effect(*entity).ok().flatten().is_some()).map(|entity| self.effects_runtime.state(entity)).collect(); + Ok(if states.is_empty() { None } else if states.contains(&PlaybackState::Playing) { Some(PlaybackState::Playing) } + else if states.contains(&PlaybackState::Paused) { Some(PlaybackState::Paused) } + else if states.iter().all(|state| *state == PlaybackState::Completed) { Some(PlaybackState::Completed) } + else if states.iter().all(|state| *state == PlaybackState::Disposed) { Some(PlaybackState::Disposed) } + else { Some(PlaybackState::Stopped) }) + } + /// Returns the optional surface texture component. /// /// # Errors @@ -825,6 +1082,9 @@ impl SceneWorld { .parent .and_then(|parent| entity_map.get(&parent).copied()) .or(root_parent); + if let Some(effect) = &mut entity.effect { + effect.remap_entities(&entity_map); + } commands.push(WorldCommand::Spawn(Box::new(entity))); } self.apply_commands(&commands)?; @@ -885,6 +1145,9 @@ impl SceneWorld { if let Some(primitive) = snapshot.primitive { entity.insert(primitive); } + if let Some(effect) = snapshot.effect { + entity.insert(effect); + } if !snapshot.scripts.is_empty() { entity.insert(ScriptComponents(snapshot.scripts)); } diff --git a/Engine/crates/engine-world/tests/effects_persistence.rs b/Engine/crates/engine-world/tests/effects_persistence.rs new file mode 100644 index 00000000..9efadfa7 --- /dev/null +++ b/Engine/crates/engine-world/tests/effects_persistence.rs @@ -0,0 +1,191 @@ +use engine_world::effects::*; +use engine_world::*; + +#[test] +fn every_effect_survives_scene_reload_and_world_capture() { + let mut document = SceneDocument::new("Effects compatibility"); + for kind in EffectKind::all_defaults() { + document.entities.push(EntitySnapshot { + name: Some(kind.name().into()), + effect: Some(EffectComponent::new(kind)), + ..Default::default() + }); + } + document.entities.sort_by_key(|entity| entity.id); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("effects.scene"); + save_scene_atomic(&path, &document).unwrap(); + let loaded = load_scene(&std::fs::read(&path).unwrap()).unwrap(); + assert!(!loaded.has_errors(), "{:?}", loaded.diagnostics); + assert_eq!(loaded.document, document); + let world = loaded.document.create_world().unwrap(); + for entity in document.entities { + assert_eq!(world.snapshot(entity.id).unwrap().effect, entity.effect); + } +} + +#[test] +fn effect_edits_delete_and_restore_use_existing_undo() { + let mut world = SceneWorld::new(); + let entity = EntitySnapshot::default(); + let id = entity.id; + world.apply_commands(&[WorldCommand::Spawn(Box::new(entity))]).unwrap(); + let mut undo = UndoStack::new(); + let effect = EffectComponent::new(EffectKind::Beam(Default::default())); + undo.execute(&mut world, SceneEdit::Effect { entity: id, before: None, after: Some(effect.clone()) }).unwrap(); + assert_eq!(world.effect(id).unwrap(), Some(effect.clone())); + undo.undo(&mut world).unwrap(); + assert!(world.effect(id).unwrap().is_none()); + undo.redo(&mut world).unwrap(); + let delete = SceneEdit::capture_delete(&world, id).unwrap(); + undo.execute(&mut world, delete).unwrap(); + assert!(world.effect(id).is_err()); + undo.undo(&mut world).unwrap(); + assert_eq!(world.effect(id).unwrap(), Some(effect)); +} + +#[test] +fn additive_instances_remap_effect_attachments_and_targets() { + let target = EntitySnapshot::default(); + let effect = EntitySnapshot { + effect: Some(EffectComponent { + source: None, + common: EffectCommon { attachment_target: Some(target.id), ..Default::default() }, + kind: EffectKind::Beam(BeamRenderer { start_target: Some(target.id), end_target: Some(target.id), ..Default::default() }), + }), + ..Default::default() + }; + let mut document = SceneDocument::new("Reusable effect"); + document.entities = vec![target.clone(), effect.clone()]; + let mut world = SceneWorld::new(); + let instance = world.instantiate_scene(&document, SceneInstanceId::new(), None).unwrap(); + let remapped = world.effect(instance.entity_map[&effect.id]).unwrap().unwrap(); + let target_id = instance.entity_map[&target.id]; + assert_eq!(remapped.common.attachment_target, Some(target_id)); + let EffectKind::Beam(beam) = remapped.kind else { panic!("wrong effect type") }; + assert_eq!(beam.start_target, Some(target_id)); + assert_eq!(beam.end_target, Some(target_id)); +} + +#[test] +fn invalid_effect_cannot_partially_commit_a_transaction() { + let mut world = SceneWorld::new(); + let entity = EntitySnapshot::default(); + let id = entity.id; + world.apply_commands(&[WorldCommand::Spawn(Box::new(entity))]).unwrap(); + let mut effect = EffectComponent::default(); + effect.common.duration = f32::NAN; + assert!(world.apply_commands(&[ + WorldCommand::SetName { entity: id, value: Some("must not commit".into()) }, + WorldCommand::SetEffect { entity: id, value: Some(Box::new(effect)) }, + ]).is_err()); + assert!(world.snapshot(id).unwrap().name.is_none()); +} + +#[test] +fn play_before_first_tick_and_pause_use_live_world_runtime() { + let mut entity = EntitySnapshot::default(); + let id = entity.id; + let mut effect = EffectComponent::default(); + effect.common.auto_play = false; + entity.effect = Some(effect); + let mut world = SceneWorld::new(); + world.apply_commands(&[WorldCommand::Spawn(Box::new(entity))]).unwrap(); + world.propagate_transforms(); + world.control_effect(id, PlaybackCommand::Play).unwrap(); + world.update_effects(0.1); + assert_eq!(world.effect_state(id).unwrap(), Some(PlaybackState::Playing)); + assert!(!world.effect_frame().particles.is_empty()); + world.control_effect(id, PlaybackCommand::Pause).unwrap(); + let count = world.effect_frame().particles.len(); + world.update_effects(0.5); + assert_eq!(world.effect_state(id).unwrap(), Some(PlaybackState::Paused)); + assert_eq!(world.effect_frame().particles.len(), count); +} + +#[test] +fn registered_subemitters_spawn_and_cleanup_with_bounded_generations() { + let child_id = AssetId::new(); + let child = EffectComponent { + source: None, + common: EffectCommon { duration: 0.01, looping: false, ..Default::default() }, + kind: EffectKind::Particle(ParticleEmitter { + rate: 0.0, lifetime: [0.01, 0.01], + bursts: vec![Burst { count: 1, ..Default::default() }], + sub_emitter: Some(child_id), max_generations: 2, + ..Default::default() + }), + }; + let mut world = SceneWorld::new(); + world.register_effect_asset(child_id, EffectAsset::new("recursive", vec![child.clone()])).unwrap(); + world.apply_commands(&[WorldCommand::Spawn(Box::new(EntitySnapshot { + effect: Some(child), ..Default::default() + }))]).unwrap(); + world.propagate_transforms(); + for _ in 0..20 { + world.update_effects(0.02); + assert!(world.entity_count() < 32); + } + assert_eq!(world.entity_count(), 1); +} + +#[test] +fn composite_asset_instances_keep_source_identity_and_control_as_a_group() { + let asset = presets().pop().unwrap(); + let asset_id = AssetId::new(); + let mut world = SceneWorld::new(); + world.register_effect_asset(asset_id, asset.clone()).unwrap(); + let root = world.instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY).unwrap(); + assert_eq!(world.children(root).unwrap().len(), asset.effects.len()); + for child in world.children(root).unwrap() { + let effect = world.effect(*child).unwrap().unwrap(); + assert_eq!(effect.source.unwrap().asset, asset_id); + } + world.control_effect(root, PlaybackCommand::Pause).unwrap(); + assert_eq!(world.effect_state(root).unwrap(), Some(PlaybackState::Paused)); + world.control_effect(root, PlaybackCommand::Restart).unwrap(); + world.update_effects(0.01); + assert_eq!(world.effect_state(root).unwrap(), Some(PlaybackState::Playing)); +} + +#[test] +fn loading_changed_assets_refreshes_defaults_without_losing_saved_overrides() { + let asset_id = AssetId::new(); + let mut asset = EffectAsset::new("shared", vec![EffectComponent::default()]); + let mut world = SceneWorld::new(); + world.register_effect_asset(asset_id, asset.clone()).unwrap(); + let root = world.instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY).unwrap(); + let entity = world.children(root).unwrap()[0]; + let mut effect = world.effect(entity).unwrap().unwrap(); + effect.common.duration = 12.0; + world.apply_commands(&[WorldCommand::SetEffect { entity, value: Some(Box::new(effect)) }]).unwrap(); + let document = SceneDocument::from_world(SceneId::new(), "saved overrides", &world).unwrap(); + let mut reopened = load_scene(&document.to_bytes().unwrap()).unwrap().document.create_world().unwrap(); + asset.effects[0].common.duration = 9.0; + asset.effects[0].common.render_distance = 50.0; + reopened.register_effect_asset(asset_id, asset).unwrap(); + let refreshed = reopened.effect(entity).unwrap().unwrap(); + assert_eq!(refreshed.common.duration, 12.0); + assert_eq!(refreshed.common.render_distance, 50.0); + let baseline = refreshed.source.unwrap().baseline.unwrap(); + assert_eq!(baseline.common.duration, 9.0); + assert_eq!(baseline.common.render_distance, 50.0); +} + +#[test] +fn composite_completion_emits_once_and_replays_after_restart() { + let mut effect = EffectComponent::new(EffectKind::ScreenFlash(Default::default())); + effect.common.looping = false; + effect.common.duration = 0.01; + let asset_id = AssetId::new(); + let mut world = SceneWorld::new(); + world.register_effect_asset(asset_id, EffectAsset::new("finite composite", vec![effect])).unwrap(); + let root = world.instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY).unwrap(); + world.update_effects(0.02); + assert_eq!(world.effect_frame().events.iter().filter(|event| event.entity == root && event.kind == EffectEventKind::Completed).count(), 1); + world.update_effects(0.02); + assert!(!world.effect_frame().events.iter().any(|event| event.entity == root)); + world.control_effect(root, PlaybackCommand::Restart).unwrap(); + world.update_effects(0.02); + assert_eq!(world.effect_frame().events.iter().filter(|event| event.entity == root && event.kind == EffectEventKind::Completed).count(), 1); +} diff --git a/Engine/crates/renderer-wgpu/examples/effects_showcase.rs b/Engine/crates/renderer-wgpu/examples/effects_showcase.rs new file mode 100644 index 00000000..9b0c5aed --- /dev/null +++ b/Engine/crates/renderer-wgpu/examples/effects_showcase.rs @@ -0,0 +1,18 @@ +//! Render the real serialized showcase without opening a native window. +use std::collections::BTreeMap; +use std::path::PathBuf; +fn main()->Result<(),Box> { + let root=PathBuf::from(std::env::args().nth(1).unwrap_or_else(||"examples/scene-effects/project".into())); + let output=PathBuf::from(std::env::args().nth(2).unwrap_or_else(||"/workspace/scratch/scene-effects.png".into())); + let loaded=engine_world::load_scene(&std::fs::read(root.join("scene/showcase.scene"))?)?; + let mut world=loaded.document.create_world()?; + let models:BTreeMap<_,_>=engine_assets::load_model_library(&root)?.into_iter().map(|(id,(_,model))|(id,model)).collect(); + world.propagate_transforms(); + for _ in 0..90 {world.update_effects(1./60.);} + let scene=renderer_wgpu::game_scene_with_models(&world,16./9.,&models); + let mut renderer=renderer_wgpu::SceneViewportRenderer::new(renderer_wgpu::BackendRequest::Auto)?; + renderer.set_environment_root(&root);renderer.set_effect_frame(world.effect_frame())?; + let frame=renderer.render(1280,720,&scene)?.ok_or("no render frame")?; + image::save_buffer(&output,&frame.rgba8,frame.width,frame.height,image::ColorType::Rgba8)?; + println!("Rendered {} meshes, {} particles with {:?}: {}",scene.meshes.len(),world.effect_frame().particles.len(),frame.adapter,output.display());Ok(()) +} diff --git a/Engine/crates/renderer-wgpu/src/decals.rs b/Engine/crates/renderer-wgpu/src/decals.rs new file mode 100644 index 00000000..27666bfe --- /dev/null +++ b/Engine/crates/renderer-wgpu/src/decals.rs @@ -0,0 +1,186 @@ +//! Mesh-clipped decal projection: clips receiver triangles to all six projector planes, +//! preserving the receiving surface rather than placing an unrelated floating quad. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated effect and GPU dimension budgets bound numeric conversions" +)] +#![allow( + clippy::too_many_lines, + reason = "GPU descriptor construction and effect extraction remain auditable transactions" +)] + +use super::{ViewportMesh, ViewportScene, ViewportVertex}; +use engine_world::effects::RenderDecal; +use glam::{Mat4, Vec3}; +use std::hash::{Hash, Hasher}; +#[derive(Clone, Copy)] +struct ClipVertex { + position: Vec3, + normal: Vec3, +} +pub(super) fn key(entity: engine_world::EntityId) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (entity, "decal").hash(&mut h); + h.finish() +} +pub(super) fn project(scene: &ViewportScene, decal: &RenderDecal) -> Option { + let inverse = decal.transform.inverse(); + if !inverse.is_finite() { + return None; + } + let half = Vec3::from_array(decal.dimensions).abs() * 0.5; + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + let projection_normal = decal + .transform + .transform_vector3(Vec3::Y) + .normalize_or_zero(); + for receiver in &scene.meshes { + let model = Mat4::from_cols_array(&receiver.model); + let normals = model.inverse().transpose(); + for triangle in receiver.indices.as_chunks::<3>().0.iter().take(200_000) { + let mut polygon: Vec = triangle + .iter() + .filter_map(|i| receiver.vertices.get(*i as usize)) + .map(|v| { + let position = inverse + .transform_point3(model.transform_point3(Vec3::from_array(v.position))); + let normal = normals + .transform_vector3(Vec3::from_array(v.normal)) + .normalize_or_zero(); + ClipVertex { position, normal } + }) + .collect(); + if polygon.len() != 3 + || polygon + .iter() + .all(|v| v.normal.dot(projection_normal) < decal.angle_fade) + { + continue; + } + for axis in 0..3 { + for sign in [-1., 1.] { + if polygon.is_empty() { + break; + } + let mut output = Vec::new(); + let mut previous = *polygon.last().unwrap(); + let mut previous_distance = half[axis] - previous.position[axis] * sign; + for current in &polygon { + let distance = half[axis] - current.position[axis] * sign; + if (distance >= 0.) != (previous_distance >= 0.) { + let t = previous_distance / (previous_distance - distance); + output.push(ClipVertex { + position: previous.position.lerp(current.position, t), + normal: previous.normal.lerp(current.normal, t).normalize_or_zero(), + }); + } + if distance >= 0. { + output.push(*current); + } + previous = *current; + previous_distance = distance; + } + polygon = output; + } + } + if polygon.len() < 3 { + continue; + } + let first = vertices.len() as u32; + for v in &polygon { + vertices.push(ViewportVertex { + position: (decal.transform.transform_point3(v.position) + v.normal * 0.002) + .to_array(), + normal: v.normal.to_array(), + uv: [ + v.position.x / (half.x * 2.).max(0.001) + 0.5, + v.position.z / (half.z * 2.).max(0.001) + 0.5, + ], + }); + } + for index in 1..polygon.len() - 1 { + indices.extend_from_slice(&[first, first + index as u32, first + index as u32 + 1]); + } + if vertices.len() > 1_000_000 { + break; + } + } + } + if indices.is_empty() { + return None; + } + Some(ViewportMesh { + textures: engine_world::SurfaceTextures::default(), + instance_key: key(decal.entity), + mesh_key: key(decal.entity), + vertices, + indices, + model: Mat4::IDENTITY.to_cols_array(), + color: decal.appearance.color.sample(0.), + selected: false, + }) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn projector_clips_receiver_triangles_and_offsets_along_surface() { + let mesh = ViewportMesh { + textures: engine_world::SurfaceTextures::default(), + instance_key: 1, + mesh_key: 1, + vertices: vec![ + ViewportVertex { + position: [-4., 0., -4.], + normal: [0., 1., 0.], + uv: [0.; 2], + }, + ViewportVertex { + position: [4., 0., -4.], + normal: [0., 1., 0.], + uv: [0.; 2], + }, + ViewportVertex { + position: [0., 0., 4.], + normal: [0., 1., 0.], + uv: [0.; 2], + }, + ], + indices: vec![0, 1, 2], + model: Mat4::IDENTITY.to_cols_array(), + color: [1.; 4], + selected: false, + }; + let scene = ViewportScene { + environment: engine_world::SceneEnvironment::default(), + camera_position: [0.; 3], + view_projection: Mat4::IDENTITY.to_cols_array(), + meshes: vec![mesh], + lights: vec![], + guides: vec![], + grid_vertices: vec![], + clear_color: [0.; 4], + }; + let decal = RenderDecal { + entity: engine_world::EntityId::new(), + transform: Mat4::IDENTITY, + dimensions: [2., 1., 2.], + appearance: engine_world::effects::EffectAppearance::default(), + normal_map: None, + orm_map: None, + roughness: 0.5, + metallic: 0., + edge_fade: 0.1, + angle_fade: 0., + layers: u32::MAX, + }; + let mesh = project(&scene, &decal).unwrap(); + assert!(!mesh.indices.is_empty()); + assert!(mesh.vertices.iter().all(|v| v.position[0].abs() <= 1.001 + && v.position[2].abs() <= 1.001 + && (v.position[1] - 0.002).abs() < 0.00001)); + } +} diff --git a/Engine/crates/renderer-wgpu/src/depth.rs b/Engine/crates/renderer-wgpu/src/depth.rs new file mode 100644 index 00000000..f526aca5 --- /dev/null +++ b/Engine/crates/renderer-wgpu/src/depth.rs @@ -0,0 +1,203 @@ +//! Portable RGB24 scene depth. Sampling multisampled depth is unsupported by +//! wgpu's GLES shader translator; this retained prepass works on both GPU paths. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated effect and GPU dimension budgets bound numeric conversions" +)] +#![allow( + clippy::too_many_lines, + reason = "GPU descriptor construction and effect extraction remain auditable transactions" +)] + +use super::{ + CachedViewportInstance, CachedViewportMesh, HashMap, RendererError, VIEWPORT_DEPTH_FORMAT, + ViewportScene, validate_and_reflect_wgsl, +}; +const SHADER: &str = r" +struct Camera {mvp:mat4x4}; +@group(0) @binding(0) var camera:Camera; +@vertex fn vs_main(@location(0) position:vec3)->@builtin(position) vec4 {return camera.mvp*vec4(position,1.);} +@fragment fn fs_main(@builtin(position) position:vec4)->@location(0) vec4 { + let depth=u32(clamp(position.z,0.,1.)*16777215.); + return vec4(f32(depth&255u),f32((depth>>8u)&255u),f32((depth>>16u)&255u),255.)/255.; +} +"; +struct Targets { + size: (u32, u32), + _color: wgpu::Texture, + view: wgpu::TextureView, + _depth: wgpu::Texture, + depth_view: wgpu::TextureView, +} +pub(super) struct DepthCapture { + pipeline: wgpu::RenderPipeline, + targets: Option, +} +impl DepthCapture { + pub fn new( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + ) -> Result { + validate_and_reflect_wgsl(SHADER)?; + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("effects-depth-shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effects-depth-layout"), + bind_group_layouts: &[Some(layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("effects-depth-pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: 32, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x3, + offset: 0, + shader_location: 0, + }], + })], + }, + primitive: wgpu::PrimitiveState { + front_face: wgpu::FrontFace::Cw, + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: VIEWPORT_DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + Ok(Self { + pipeline, + targets: None, + }) + } + pub fn prepare(&mut self, device: &wgpu::Device, width: u32, height: u32) { + if self + .targets + .as_ref() + .is_some_and(|t| t.size == (width, height)) + { + return; + } + let color = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effects-depth-color"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let depth = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effects-depth-test"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: VIEWPORT_DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = color.create_view(&wgpu::TextureViewDescriptor::default()); + let depth_view = depth.create_view(&wgpu::TextureViewDescriptor::default()); + self.targets = Some(Targets { + size: (width, height), + _color: color, + view, + _depth: depth, + depth_view, + }); + } + pub fn view(&self) -> Option<&wgpu::TextureView> { + self.targets.as_ref().map(|t| &t.view) + } + pub fn encode( + &self, + encoder: &mut wgpu::CommandEncoder, + scene: &ViewportScene, + meshes: &HashMap, + instances: &HashMap, + ) { + let Some(targets) = &self.targets else { + return; + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("effects-depth-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &targets.view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &targets.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.), + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + }), + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + for mesh in &scene.meshes { + if let (Some(gpu), Some(instance)) = ( + meshes.get(&mesh.mesh_key), + instances.get(&mesh.instance_key), + ) { + pass.set_bind_group(0, &instance.bind_group, &[]); + pass.set_vertex_buffer(0, gpu.vertices.slice(..)); + pass.set_index_buffer(gpu.indices.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..gpu.index_count, 0, 0..1); + } + } + } +} +#[cfg(test)] +mod tests { + #[test] + fn shader_validates() { + super::validate_and_reflect_wgsl(super::SHADER).unwrap(); + } +} diff --git a/Engine/crates/renderer-wgpu/src/effects.rs b/Engine/crates/renderer-wgpu/src/effects.rs new file mode 100644 index 00000000..7e71166e --- /dev/null +++ b/Engine/crates/renderer-wgpu/src/effects.rs @@ -0,0 +1,531 @@ +//! Shared dynamic vertex buffers for transparent effects; one draw per blend mode. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated effect and GPU dimension budgets bound numeric conversions" +)] +#![allow( + clippy::too_many_lines, + reason = "GPU descriptor construction and effect extraction remain auditable transactions" +)] + +use super::{ + RendererError, VIEWPORT_DEPTH_FORMAT, VIEWPORT_SAMPLE_COUNT, f32_bytes, + validate_and_reflect_wgsl, +}; +use engine_world::effects::{BlendMode, EffectFrame}; +use glam::{Mat4, Vec3}; +use wgpu::util::DeviceExt; + +const SHADER: &str = r" +struct Camera { view_projection:mat4x4 }; +@group(0) @binding(0) var camera:Camera; +@group(1) @binding(0) var sprites:texture_2d_array; +@group(1) @binding(1) var sprite_sampler:sampler; +struct Input { @location(0) position:vec3,@location(1) uv:vec2,@location(2) color:vec4,@location(3) sprite:f32 }; +struct Output { @builtin(position) position:vec4,@location(0) uv:vec2,@location(1) color:vec4,@location(2) sprite:f32 }; +@vertex fn vs_main(i:Input)->Output {var o:Output;o.position=camera.view_projection*vec4(i.position,1.);o.uv=i.uv;o.color=i.color;o.sprite=i.sprite;return o;} +@fragment fn fs_main(i:Output)->@location(0) vec4 { + let radius=length(i.uv*2.-vec2(1.)); + let softness=select(1.,pow(max(1.-radius*radius,0.),2.),i.sprite>0.5 && i.sprite<1.5); + var color=vec4(i.color.rgb,i.color.a*softness); + if i.sprite>1.5 {color*=textureSampleLevel(sprites,sprite_sampler,i.uv,i32(i.sprite-2.),0.);} + return color; +} +"; +#[derive(Clone, Copy, Debug, PartialEq)] +struct Vertex { + position: [f32; 3], + uv: [f32; 2], + color: [f32; 4], + sprite: f32, +} +fn vertex_bytes(vertices: &[Vertex]) -> Vec { + let mut bytes = Vec::with_capacity(vertices.len() * 40); + for v in vertices { + for f in v + .position + .into_iter() + .chain(v.uv) + .chain(v.color) + .chain([v.sprite]) + { + bytes.extend_from_slice(&f.to_le_bytes()); + } + } + bytes +} +struct Batch { + buffer: wgpu::Buffer, + capacity: usize, + count: u32, +} +pub(super) struct EffectsRenderer { + sprites: super::sprites::SpriteAtlas, + pipelines: [wgpu::RenderPipeline; 3], + uniform: wgpu::Buffer, + group: wgpu::BindGroup, + batches: [Batch; 3], +} +impl EffectsRenderer { + pub fn new(device: &wgpu::Device) -> Result { + validate_and_reflect_wgsl(SHADER)?; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effects-camera-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effects-camera"), + size: 64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("effects-camera-group"), + layout: &layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("effects-geometry-shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let sprites = super::sprites::SpriteAtlas::new(device); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effects-geometry-layout"), + bind_group_layouts: &[Some(&layout), Some(&sprites.layout)], + immediate_size: 0, + }); + let pipelines = std::array::from_fn(|blend| { + let blend = match blend { + 1 => wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::OVER, + }, + 2 => wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Dst, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent::OVER, + }, + _ => wgpu::BlendState::ALPHA_BLENDING, + }; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("effects-geometry-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: 40, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x3, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 12, + shader_location: 1, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 20, + shader_location: 2, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32, + offset: 36, + shader_location: 3, + }, + ], + })], + }, + primitive: wgpu::PrimitiveState { + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: VIEWPORT_DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: VIEWPORT_SAMPLE_COUNT, + ..Default::default() + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) + }); + let batches = std::array::from_fn(|_| Batch { + buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("effects-empty-vertices"), + contents: &[0; 40], + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + }), + capacity: 1, + count: 0, + }); + Ok(Self { + sprites, + pipelines, + uniform, + group, + batches, + }) + } + pub fn clear_textures(&mut self) { + self.sprites.clear(); + } + #[allow( + clippy::too_many_arguments, + reason = "prepare combines frame camera and project asset context without backend types leaking" + )] + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + frame: &EffectFrame, + view_projection: [f32; 16], + camera: [f32; 3], + root: &std::path::Path, + paths: &std::collections::HashMap, + ) -> Result<(), RendererError> { + self.sprites.prepare(device, queue, root, frame, paths)?; + let vertices = build_vertices( + frame, + Mat4::from_cols_array(&view_projection), + Vec3::from_array(camera), + &self.sprites.layers, + )?; + queue.write_buffer(&self.uniform, 0, &f32_bytes(&view_projection)); + for (batch, vertices) in self.batches.iter_mut().zip(vertices) { + batch.count = u32::try_from(vertices.len()).map_err(|_| { + RendererError::InvalidMesh("effect vertex count exceeds u32".into()) + })?; + if vertices.is_empty() { + continue; + } + let bytes = vertex_bytes(&vertices); + if vertices.len() > batch.capacity { + batch.capacity = vertices.len().next_power_of_two(); + batch.buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effects-dynamic-vertices"), + size: (batch.capacity as u64) * 40, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + queue.write_buffer(&batch.buffer, 0, &bytes); + } + Ok(()) + } + pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { + for (pipeline, batch) in self.pipelines.iter().zip(&self.batches) { + if batch.count == 0 { + continue; + } + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.group, &[]); + if let Some(group) = self.sprites.group() { + pass.set_bind_group(1, group, &[]); + } + pass.set_vertex_buffer(0, batch.buffer.slice(..)); + pass.draw(0..batch.count, 0..1); + } + } +} +fn blend_index(blend: BlendMode) -> usize { + match blend { + BlendMode::Alpha => 0, + BlendMode::Additive => 1, + BlendMode::Multiply => 2, + } +} +fn build_vertices( + frame: &EffectFrame, + vp: Mat4, + camera: Vec3, + layers: &std::collections::HashMap, +) -> Result<[Vec; 3], RendererError> { + let mut batches: [Vec; 3] = std::array::from_fn(|_| Vec::new()); + let inverse = vp.inverse(); + let center = inverse.project_point3(Vec3::new(0., 0., 0.5)); + let right = (inverse.project_point3(Vec3::new(1., 0., 0.5)) - center).normalize_or_zero(); + let up = (inverse.project_point3(Vec3::new(0., 1., 0.5)) - center).normalize_or_zero(); + if !right.is_finite() || !up.is_finite() { + return Err(RendererError::InvalidMesh( + "invalid effects camera projection".into(), + )); + } + let mut vertex_budget = 1_000_000usize; + for geometry in &frame.geometry { + if geometry.mesh.indices.len() > vertex_budget { + return Err(RendererError::InvalidMesh( + "effect frame exceeds one million geometry vertices".into(), + )); + } + vertex_budget -= geometry.mesh.indices.len(); + // PBR geometry is appended to the existing material pipeline by append_effect_frame. + if geometry.material.is_some() { + continue; + } + let out = &mut batches[blend_index(geometry.blend)]; + for &index in &geometry.mesh.indices { + let index = index as usize; + let Some(&position) = geometry.mesh.positions.get(index) else { + return Err(RendererError::InvalidMesh( + "effect index out of bounds".into(), + )); + }; + let mut color = geometry + .colors + .get(index) + .copied() + .unwrap_or(geometry.color); + for channel in &mut color[..3] { + *channel *= geometry.emission.max(0.0); + } + let uv = geometry + .mesh + .tex_coords + .get(index) + .copied() + .unwrap_or([0., 0.]); + out.push(Vertex { + position, + uv, + color, + sprite: geometry + .texture + .map_or(0., |id| layers.get(&id).copied().unwrap_or(0) as f32 + 2.), + }); + } + } + // Back-to-front particles inside each blend batch, without per-particle draw calls. + let mut particles: Vec<_> = frame + .particles + .iter() + .filter(|p| p.mesh.is_none()) + .collect(); + particles.sort_by(|a, b| { + b.position + .distance_squared(camera) + .total_cmp(&a.position.distance_squared(camera)) + }); + for particle in particles { + let (sin, cos) = particle.rotation.sin_cos(); + let base_right = right; + let base_up = up; + let mut right = (base_right * cos + base_up * sin) * particle.size * 0.5; + let mut up = (base_up * cos - base_right * sin) * particle.size * 0.5; + if particle.stretch > 0. && particle.velocity.length_squared() > 0.0001 { + let screen_velocity = Vec3::new( + particle.velocity.dot(base_right), + particle.velocity.dot(base_up), + 0., + ) + .normalize_or_zero(); + up = (base_right * screen_velocity.x + base_up * screen_velocity.y) + * (particle.size + particle.velocity.length() * particle.stretch) + * 0.5; + right = (base_right * screen_velocity.y - base_up * screen_velocity.x) + * particle.size + * 0.5; + } + let mut color = particle.color; + for channel in &mut color[..3] { + *channel *= particle.emission.max(0.0); + } + let positions = [ + particle.position - right - up, + particle.position + right - up, + particle.position + right + up, + particle.position - right + up, + ]; + let uv = [[0., 1.], [1., 1.], [1., 0.], [0., 0.]]; + for i in [0, 1, 2, 0, 2, 3] { + batches[blend_index(particle.blend)].push(Vertex { + position: positions[i].to_array(), + uv: if particle.texture.is_some() { + [ + particle.uv_rect[0] + + uv[i][0] * (particle.uv_rect[2] - particle.uv_rect[0]), + particle.uv_rect[1] + + uv[i][1] * (particle.uv_rect[3] - particle.uv_rect[1]), + ] + } else { + uv[i] + }, + color, + sprite: particle + .texture + .map_or(1., |id| layers.get(&id).copied().unwrap_or(0) as f32 + 2.), + }); + } + } + if batches.iter().flatten().any(|v| { + v.position + .into_iter() + .chain(v.uv) + .chain(v.color) + .any(|f| !f.is_finite()) + }) { + return Err(RendererError::InvalidMesh( + "non-finite effect vertices".into(), + )); + } + Ok(batches) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shader_validates() { + validate_and_reflect_wgsl(SHADER).unwrap(); + } + #[test] + fn particles_batch_into_camera_facing_quads() { + let mut frame = EffectFrame::default(); + frame + .particles + .push(engine_world::effects::ParticleInstance { + entity: engine_world::EntityId::new(), + position: Vec3::ZERO, + velocity: Vec3::ZERO, + stretch: 0., + uv_rect: [0., 0., 1., 1.], + rotation: 0., + size: 2., + color: [1.; 4], + mesh: None, + material: None, + texture: None, + emission: 1., + blend: BlendMode::Alpha, + }); + let vertices = build_vertices( + &frame, + Mat4::IDENTITY, + Vec3::Z, + &std::collections::HashMap::default(), + ) + .unwrap(); + assert_eq!(vertices[0].len(), 6); + assert!(vertices[1].is_empty()); + assert!( + Vec3::from_array(vertices[0][0].position).distance(Vec3::new(-1., -1., 0.)) + < f32::EPSILON + ); + assert!( + Vec3::from_array(vertices[0][2].position).distance(Vec3::new(1., 1., 0.)) + < f32::EPSILON + ); + } +} + +#[cfg(test)] +mod gpu_tests { + use super::*; + use crate::{BackendRequest, SceneViewportRenderer, ViewportScene}; + #[test] + #[ignore = "requires a physical or software graphics adapter"] + fn effects_frame_renders_particles_post_distortion_fog_and_cleanup() { + let mut renderer = SceneViewportRenderer::new(BackendRequest::Auto).unwrap(); + let scene = ViewportScene { + environment: engine_world::SceneEnvironment::default(), + camera_position: [0., 0., 0.], + view_projection: Mat4::IDENTITY.to_cols_array(), + meshes: vec![], + lights: vec![], + guides: vec![], + grid_vertices: vec![], + clear_color: [0., 0., 0.2, 1.], + }; + let base = renderer.render(64, 64, &scene).unwrap().unwrap(); + let mut frame = EffectFrame::default(); + frame + .particles + .push(engine_world::effects::ParticleInstance { + entity: engine_world::EntityId::new(), + position: Vec3::new(0., 0., 0.5), + velocity: Vec3::ZERO, + stretch: 0., + uv_rect: [0., 0., 1., 1.], + rotation: 0., + size: 1., + color: [1., 0., 0., 1.], + mesh: None, + material: None, + texture: None, + emission: 1., + blend: BlendMode::Alpha, + }); + renderer.set_effect_frame(&frame).unwrap(); + let particles = renderer.render(64, 64, &scene).unwrap().unwrap(); + let center = (32 * 64 + 32) * 4; + assert!(particles.rgba8[center] > 200); + assert_eq!(base.rgba8[center], 0); + frame.post.distortion = 0.15; + frame.post.time = 1.2; + renderer.set_effect_frame(&frame).unwrap(); + let warped = renderer.render(64, 64, &scene).unwrap().unwrap(); + assert_ne!(particles.rgba8, warped.rgba8); + frame = EffectFrame::default(); + frame.fog.push(engine_world::effects::RenderFogVolume { + entity: engine_world::EntityId::new(), + inverse_transform: Mat4::IDENTITY, + dimensions: [2.; 3], + shape: engine_world::effects::VolumeShape::Box, + density: 3., + color: [1., 0., 0.], + height_falloff: 0., + edge_falloff: 0.1, + noise_scale: 1., + noise_speed: 1., + steps: 32, + }); + renderer.set_effect_frame(&frame).unwrap(); + let fog = renderer.render(64, 64, &scene).unwrap().unwrap(); + assert!(fog.rgba8[center] > 150); + renderer.set_effect_frame(&EffectFrame::default()).unwrap(); + let cleared = renderer.render(64, 64, &scene).unwrap().unwrap(); + assert_eq!(base.rgba8, cleared.rgba8); + assert_eq!(renderer.stats().device_generations, 1); + assert_eq!(renderer.stats().target_generations, 1); + } +} diff --git a/Engine/crates/renderer-wgpu/src/lib.rs b/Engine/crates/renderer-wgpu/src/lib.rs index 3fa5de7b..6631130a 100644 --- a/Engine/crates/renderer-wgpu/src/lib.rs +++ b/Engine/crates/renderer-wgpu/src/lib.rs @@ -1,6 +1,12 @@ //! Concrete wgpu backend. No type from the implementation dependency appears in the public API. +mod decals; +mod depth; +mod effects; +mod post; +mod reflections; mod sky; +mod sprites; mod surface; use engine_rhi::{ @@ -69,6 +75,12 @@ struct Uniforms { sun_direction: vec4, haze: vec4, camera: vec4, + effect: vec4, + edge: vec4, + probe_position: vec4, + probe_dimensions: vec4, + surface_effect: vec4, + surface_uv: vec4, }; struct Light { position_kind: vec4, @@ -81,6 +93,8 @@ struct Surface { uv: vec4, strength: vec4, mask: vec4 }; @group(1) @binding(0) var maps: texture_2d_array; @group(1) @binding(1) var map_sampler: sampler; @group(1) @binding(2) var surface: Surface; +@group(2) @binding(0) var probe_map:texture_2d; +@group(2) @binding(1) var probe_sampler:sampler; fn has_map(layer: u32) -> bool { return (surface.mask.x & (1u << layer)) != 0u; } fn sample_map(layer: u32, uv: vec2, fallback: vec4) -> vec4 { if !has_map(layer) { return fallback; } @@ -99,7 +113,10 @@ struct VertexOutput { }; @vertex fn vs_main(input: VertexInput) -> VertexOutput { var output: VertexOutput; - output.uv = input.uv * surface.uv.xy + surface.uv.zw; + let sine=sin(uniforms.surface_effect.x);let cosine=cos(uniforms.surface_effect.x); + let uv=input.uv-vec2(0.5); + output.uv = vec2(uv.x*cosine-uv.y*sine,uv.x*sine+uv.y*cosine)+vec2(0.5); + output.uv = (output.uv * surface.uv.xy + surface.uv.zw) * uniforms.surface_uv.xy + uniforms.surface_uv.zw; var position = input.position; if uniforms.lighting.y < 0.5 && has_map(5u) { position += input.normal * (sample_map(5u, output.uv, vec4(0.5)).r - 0.5) * surface.strength.x; @@ -129,8 +146,8 @@ fn fresnel(f0: vec3, cosine: f32) -> vec3 { fn brdf(n: vec3, coat_n: vec3, v: vec3, l: vec3, uv: vec2, base: vec3) -> vec3 { let h = normalize(v + l + vec3(0.000001)); let nv = max(dot(n, v), 0.001); let nl = max(dot(n, l), 0.0); let nh = max(dot(n, h), 0.0); - let rough = clamp(sample_map(2u, uv, vec4(0.5)).r, 0.04, 1.0); - let metallic = clamp(sample_map(3u, uv, vec4(0.0)).r, 0.0, 1.0); + let rough = clamp(sample_map(2u, uv, vec4(select(0.5,uniforms.edge.z,uniforms.effect.z>0.5))).r, 0.04, 1.0); + let metallic = clamp(sample_map(3u, uv, vec4(select(0.0,uniforms.edge.w,uniforms.effect.z>0.5))).r, 0.0, 1.0); let specular = max(sample_map(14u, uv, vec4(1.0)).r, 0.0); let spec_color = sample_map(15u, uv, vec4(1.0)).rgb; let f = fresnel(mix(0.04 * specular * spec_color, base, metallic), max(dot(h, v), 0.0)); @@ -166,15 +183,18 @@ fn brdf(n: vec3, coat_n: vec3, v: vec3, l: vec3, uv: vec2 @location(0) vec4 { + let dissolve_noise = fract(sin(dot(floor(input.world_position * max(uniforms.surface_effect.y,1.0)), vec3(12.9898,78.233,37.719))) * 43758.5453); + if uniforms.effect.x > 0.0 && dissolve_noise < uniforms.effect.x { discard; } let geometric = normalize(input.normal); let n = mapped_normal(input.uv, geometric, input.world_position, 1u); let coat_n = mapped_normal(input.uv, geometric, input.world_position, 9u); if uniforms.lighting.y > 0.5 { return uniforms.color; } - let color = uniforms.color * sample_map(0u, input.uv, vec4(1.0)); + var color = uniforms.color * sample_map(0u, input.uv, vec4(1.0)); + if uniforms.effect.z > 0.5 {let edge=min(min(input.uv.x,1.-input.uv.x),min(input.uv.y,1.-input.uv.y));color.a*=smoothstep(0.,max(uniforms.effect.y,0.001),edge);} let v = normalize(uniforms.camera.xyz-input.world_position+vec3(0.000001)); let ao = clamp(sample_map(4u,input.uv,vec4(1.0)).r,0.0,1.0); var radiance = color.rgb * uniforms.ambient.rgb * uniforms.ambient.w * ao; - let pbr = surface.mask.x != 0u; + let pbr = surface.mask.x != 0u || uniforms.effect.z>0.5; var sun_response = color.rgb * max(dot(n,uniforms.sun_direction.xyz),0.0); if pbr { sun_response = brdf(n,coat_n,v,uniforms.sun_direction.xyz,input.uv,color.rgb); } radiance += uniforms.sun.rgb * uniforms.sun.w * sun_response; @@ -198,6 +218,22 @@ fn brdf(n: vec3, coat_n: vec3, v: vec3, l: vec3, uv: vec2(0.0)).rgb*surface.strength.y; + radiance += color.rgb * uniforms.effect.w; + if uniforms.effect.x > 0.0 && dissolve_noise < uniforms.effect.x + uniforms.effect.y { radiance += uniforms.edge.rgb * uniforms.edge.w; } + if uniforms.probe_position.w>0.0 { + let reflected=reflect(-v,n); + let box_min=uniforms.probe_position.xyz-uniforms.probe_dimensions.xyz*0.5; + let box_max=uniforms.probe_position.xyz+uniforms.probe_dimensions.xyz*0.5; + let safe=select(vec3(0.00001),reflected,abs(reflected)>vec3(0.00001)); + let distances=max((box_max-input.world_position)/safe,(box_min-input.world_position)/safe); + let distance=max(min(min(distances.x,distances.y),distances.z),0.); + let direction=normalize(input.world_position+reflected*distance-uniforms.probe_position.xyz); + let uv=vec2(atan2(direction.z,direction.x)/6.2831853+0.5,acos(clamp(direction.y,-1.,1.))/3.14159265); + let rough=clamp(sample_map(2u,input.uv,vec4(0.5)).r,0.04,1.); + let metallic=clamp(sample_map(3u,input.uv,vec4(0.)).r,0.,1.); + let reflection=textureSampleLevel(probe_map,probe_sampler,uv,rough*f32(textureNumLevels(probe_map)-1u)).rgb; + radiance+=reflection*fresnel(mix(vec3(0.04),color.rgb,metallic),max(dot(n,v),0.))*uniforms.probe_position.w; + } let distance = max(length(input.world_position-uniforms.camera.xyz)-uniforms.camera.w,0.0); let haze = 1.0-exp(-uniforms.haze.w*distance); let mapped = radiance/(vec3(1.0)+radiance); @@ -391,6 +427,53 @@ pub struct ViewportScene { pub clear_color: [f32; 4], } +pub use engine_world::effects::PostProcessSettings; +fn post_active(mut settings: PostProcessSettings) -> bool { + let default = PostProcessSettings::default(); + settings.time = default.time; + settings.bloom_radius = default.bloom_radius; + settings.bloom_threshold = default.bloom_threshold; + settings.distortion_scale = default.distortion_scale; + settings.distortion_speed = default.distortion_speed; + settings != default +} +fn post_values(settings: PostProcessSettings) -> [f32; 32] { + [ + settings.saturation, + settings.contrast, + settings.brightness, + settings.exposure, + settings.vignette, + settings.chromatic_aberration, + settings.film_grain, + settings.time, + settings.distortion, + settings.distortion_scale, + settings.distortion_speed, + 0., + settings.bloom, + settings.bloom_threshold, + settings.bloom_radius, + 0., + settings.tint[0], + settings.tint[1], + settings.tint[2], + 1., + settings.overlay[0], + settings.overlay[1], + settings.overlay[2], + settings.overlay[3], + settings.overlay_multiply[0], + settings.overlay_multiply[1], + settings.overlay_multiply[2], + 1., + settings.overlay_add[0], + settings.overlay_add[1], + settings.overlay_add[2], + settings.depth_of_field, + ] +} + /// Observable resource lifecycle counters used by diagnostics and tests. #[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] pub struct ViewportResourceStats { @@ -1056,9 +1139,16 @@ struct PendingViewportReadback { /// Device, queue, pipelines, bind groups, mesh buffers, and size-dependent targets are retained; /// no backend type crosses this crate's public API. pub struct SceneViewportRenderer { + effects: effects::EffectsRenderer, + effect_frame: engine_world::effects::EffectFrame, + post: post::PostRenderer, + depth_capture: depth::DepthCapture, + post_settings: PostProcessSettings, sky: sky::SkyRenderer, + reflections: reflections::ReflectionCache, surfaces: surface::SurfaceCache, environment_root: std::path::PathBuf, + effect_asset_paths: HashMap, request: BackendRequest, context: BackendContext, bind_layout: wgpu::BindGroupLayout, @@ -1085,10 +1175,12 @@ impl SceneViewportRenderer { let context = pollster::block_on(BackendContext::initialize(request))?; let bind_layout = viewport_bind_layout(&context.device); let surfaces = surface::SurfaceCache::new(&context.device); + let reflections = reflections::ReflectionCache::new(&context.device); let solid_pipeline = viewport_pipeline( &context.device, &bind_layout, &surfaces.layout, + &reflections.layout, ViewportPass::Solid, "rustic-viewport-solid-pipeline", ); @@ -1096,6 +1188,7 @@ impl SceneViewportRenderer { &context.device, &bind_layout, &surfaces.layout, + &reflections.layout, ViewportPass::Outline, "rustic-viewport-outline-pipeline", ); @@ -1103,14 +1196,25 @@ impl SceneViewportRenderer { &context.device, &bind_layout, &surfaces.layout, + &reflections.layout, ViewportPass::Lines, "rustic-viewport-grid-pipeline", ); let sky = sky::SkyRenderer::new(&context.device)?; + let post = post::PostRenderer::new(&context.device)?; + let depth_capture = depth::DepthCapture::new(&context.device, &bind_layout)?; + let effects = effects::EffectsRenderer::new(&context.device)?; Ok(Self { sky, + reflections, + post, + depth_capture, + effects, + effect_frame: engine_world::effects::EffectFrame::default(), + post_settings: PostProcessSettings::default(), surfaces, environment_root: std::path::PathBuf::new(), + effect_asset_paths: HashMap::new(), request, context, bind_layout, @@ -1134,7 +1238,47 @@ impl SceneViewportRenderer { /// Resolves scene sky image paths against a project or immutable play snapshot. pub fn set_environment_root(&mut self, root: impl Into) { self.environment_root = root.into(); + self.effect_asset_paths.clear(); + if let Ok(report) = engine_assets::scan_sidecars(&self.environment_root.join("assets")) { + for asset in report.entries { + self.effect_asset_paths.insert( + asset.meta.asset_id, + format!( + "assets/{}", + asset.source_relative.to_string_lossy().replace('\\', "/") + ), + ); + } + } self.surfaces.clear(); + self.reflections.clear(); + self.effects.clear_textures(); + } + + /// Installs the immutable simulation extraction consumed by the next render. + /// # Errors + /// Rejects non-finite camera post-processing inputs. + pub fn set_effect_frame( + &mut self, + frame: &engine_world::effects::EffectFrame, + ) -> Result<(), RendererError> { + self.set_post_process(frame.post)?; + self.effect_frame = frame.clone(); + Ok(()) + } + + /// Applies validated display-space camera adjustments on subsequent frames. + /// Bloom operates after the existing PBR tone mapper; it is not HDR bloom. + /// # Errors + /// Rejects non-finite post-processing inputs before uploading uniforms. + pub fn set_post_process(&mut self, settings: PostProcessSettings) -> Result<(), RendererError> { + if post_values(settings).iter().any(|v| !v.is_finite()) { + return Err(RendererError::InvalidMesh( + "post-process settings must be finite".into(), + )); + } + self.post_settings = settings; + Ok(()) } pub const fn stats(&self) -> ViewportResourceStats { @@ -1181,8 +1325,12 @@ impl SceneViewportRenderer { if self.context.take_device_loss().is_some() { let previous = self.stats; let root = self.environment_root.clone(); + let post_settings = self.post_settings; + let effect_frame = self.effect_frame.clone(); *self = Self::new(self.request)?; - self.environment_root = root; + self.post_settings = post_settings; + self.effect_frame = effect_frame; + self.set_environment_root(root); self.stats.device_generations = previous.device_generations.saturating_add(1); self.stats.pipeline_generations = previous.pipeline_generations.saturating_add(1); } @@ -1225,13 +1373,54 @@ impl SceneViewportRenderer { self.pending_readback = None; return Ok(Some(self.completed_frame(width, height, rgba8))); } + let mut resolved_scene; + let scene = if !self.effect_asset_paths.is_empty() + && (!self.effect_frame.decals.is_empty() || !self.effect_frame.geometry.is_empty()) + { + resolved_scene = scene.clone(); + resolve_effect_textures( + &mut resolved_scene, + &self.effect_frame, + &self.effect_asset_paths, + ); + &resolved_scene + } else { + scene + }; self.surfaces.prepare( &self.context.device, &self.context.queue, &self.environment_root, scene, )?; + self.reflections.prepare( + &self.context.device, + &self.context.queue, + &self.environment_root, + &self.effect_frame, + &self.effect_asset_paths, + )?; + self.effects.prepare( + &self.context.device, + &self.context.queue, + &self.effect_frame, + scene.view_projection, + scene.camera_position, + &self.environment_root, + &self.effect_asset_paths, + )?; self.ensure_targets(width, height); + self.depth_capture + .prepare(&self.context.device, width, height); + if let (Some(targets), Some(depth_view)) = (&self.targets, self.depth_capture.view()) { + self.post.prepare( + &self.context.device, + &targets.color_view, + depth_view, + width, + height, + ); + } self.ensure_grid(&scene.grid_vertices)?; for guide in &scene.guides { self.ensure_guide(guide)?; @@ -1248,8 +1437,37 @@ impl SceneViewportRenderer { self.ensure_instance(outline_key); self.write_instance( outline_key, - selection_outline_model(mesh, scene.view_projection, width, height), - [1.0, 0.48, 0.04, 1.0], + self.effect_frame + .outlines + .iter() + .find(|outline| { + outline + .targets + .iter() + .any(|id| effect_entity_matches(*id, mesh.instance_key)) + }) + .map_or_else( + || selection_outline_model(mesh, scene.view_projection, width, height), + |outline| { + selection_outline_model_with_width( + mesh, + scene.view_projection, + width, + height, + outline.thickness, + ) + }, + ), + self.effect_frame + .outlines + .iter() + .find(|outline| { + outline + .targets + .iter() + .any(|id| effect_entity_matches(*id, mesh.instance_key)) + }) + .map_or([1.0, 0.48, 0.04, 1.0], |o| o.color), true, scene, ); @@ -1263,6 +1481,23 @@ impl SceneViewportRenderer { scene, ); + let active_meshes: std::collections::HashSet<_> = scene + .meshes + .iter() + .map(|m| m.mesh_key) + .chain(scene.guides.iter().map(|g| g.key)) + .collect(); + self.meshes.retain(|key, _| active_meshes.contains(key)); + let active_instances: std::collections::HashSet<_> = scene + .meshes + .iter() + .flat_map(|m| [m.instance_key, m.instance_key ^ (1_u64 << 63)]) + .chain(scene.guides.iter().map(|g| g.key)) + .chain([u64::MAX]) + .collect(); + self.instances + .retain(|key, _| active_instances.contains(key)); + let targets = self.targets.as_ref().ok_or_else(|| { RendererError::Gpu("non-zero viewport target was not created".to_owned()) })?; @@ -1272,6 +1507,14 @@ impl SceneViewportRenderer { .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("rustic-viewport-command-encoder"), }); + if post_active(self.post_settings) + || !self.effect_frame.fog.is_empty() + || !self.effect_frame.shafts.is_empty() + || !self.effect_frame.flares.is_empty() + { + self.depth_capture + .encode(&mut encoder, scene, &self.meshes, &self.instances); + } { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("rustic-viewport-main-pass"), @@ -1309,6 +1552,7 @@ impl SceneViewportRenderer { self.sky.draw(&mut pass); } pass.set_bind_group(1, self.surfaces.default_group(), &[]); + pass.set_bind_group(2, self.reflections.get(None), &[]); if let Some(grid) = &self.grid { let key = u64::MAX; if let Some(instance) = self.instances.get(&key) { @@ -1351,6 +1595,12 @@ impl SceneViewportRenderer { }; pass.set_bind_group(0, &instance.bind_group, &[]); pass.set_bind_group(1, self.surfaces.group(&mesh.textures), &[]); + pass.set_bind_group( + 2, + self.reflections + .get(self.probe_for_mesh(mesh).map(|(p, _)| p.cubemap)), + &[], + ); pass.set_vertex_buffer(0, gpu_mesh.vertices.slice(..)); pass.set_index_buffer(gpu_mesh.indices.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..gpu_mesh.index_count, 0, 0..1); @@ -1359,6 +1609,7 @@ impl SceneViewportRenderer { pass.set_pipeline(&self.line_pipeline); pass.set_bind_group(1, self.surfaces.default_group(), &[]); + pass.set_bind_group(2, self.reflections.get(None), &[]); for guide in &scene.guides { let (Some(gpu_mesh), Some(instance)) = (self.meshes.get(&guide.key), self.instances.get(&guide.key)) @@ -1370,9 +1621,27 @@ impl SceneViewportRenderer { pass.set_index_buffer(gpu_mesh.indices.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..gpu_mesh.index_count, 0, 0..1); } + self.effects.draw(&mut pass); } + let output = if !post_active(self.post_settings) + && self.effect_frame.fog.is_empty() + && self.effect_frame.shafts.is_empty() + && self.effect_frame.flares.is_empty() + { + &targets.color + } else { + self.post + .render( + &self.context.queue, + &mut encoder, + &post_values(self.post_settings), + scene, + &self.effect_frame, + ) + .unwrap_or(&targets.color) + }; encoder.copy_texture_to_buffer( - targets.color.as_image_copy(), + output.as_image_copy(), wgpu::TexelCopyBufferInfo { buffer: &targets.readback, layout: wgpu::TexelCopyBufferLayout { @@ -1442,7 +1711,9 @@ impl SceneViewportRenderer { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); // Resolve into the single-sample color texture used by CPU readback. @@ -1631,7 +1902,7 @@ impl SceneViewportRenderer { self.instances.entry(key).or_insert_with(|| { let uniform = self.context.device.create_buffer(&wgpu::BufferDescriptor { label: Some("rustic-viewport-instance-uniform"), - size: 2352, + size: 2448, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -1653,6 +1924,39 @@ impl SceneViewportRenderer { }); } + fn probe_for_mesh( + &self, + mesh: &ViewportMesh, + ) -> Option<(&engine_world::effects::RenderReflectionProbe, f32)> { + let center = glam::Mat4::from_cols_array(&mesh.model).w_axis.truncate(); + self.effect_frame + .probes + .iter() + .filter_map(|p| { + if !self.reflections.has(p.cubemap) { + return None; + } + let delta = + (center - p.position).abs() - glam::Vec3::from_array(p.dimensions) * 0.5; + let outside = delta.max(glam::Vec3::ZERO).length(); + let weight = if outside == 0. { + 1. + } else { + (1. - outside / p.blend_distance.max(0.0001)).clamp(0., 1.) + }; + if weight <= 0. { + None + } else { + Some((p, weight)) + } + }) + .max_by(|(a, aw), (b, bw)| a.priority.cmp(&b.priority).then_with(|| aw.total_cmp(bw))) + } + + #[allow( + clippy::too_many_lines, + reason = "scene lights and effect modifiers share one explicit uniform layout" + )] fn write_instance( &self, key: u64, @@ -1727,6 +2031,65 @@ impl SceneViewportRenderer { values.push(env.haze_density); values.extend(scene.camera_position); values.push(env.haze_start); + if let Some(modifier) = self + .effect_frame + .surfaces + .iter() + .find(|m| effect_entity_matches(m.target, key)) + { + values.extend([ + modifier.dissolve, + modifier.edge_width, + 0.0, + modifier.emission, + ]); + values.extend(modifier.edge_color); + values.push(modifier.emission); + } else if let Some(decal) = self + .effect_frame + .decals + .iter() + .find(|d| decals::key(d.entity) == key) + { + values.extend([0.0, decal.edge_fade, 1.0, decal.appearance.emission]); + values.extend([0.0, 0.0, decal.roughness, decal.metallic]); + } else { + values.extend([0.0; 8]); + } + let probe = scene + .meshes + .iter() + .find(|m| m.instance_key == key) + .and_then(|mesh| self.probe_for_mesh(mesh)); + if let Some((probe, weight)) = probe { + values.extend(probe.position.to_array()); + values.push(weight); + values.extend(probe.dimensions); + values.push(0.); + } else { + values.extend([0.; 8]); + } + if let Some(modifier) = self + .effect_frame + .surfaces + .iter() + .find(|m| effect_entity_matches(m.target, key)) + { + values.extend([modifier.uv_rotation, modifier.dissolve_noise_scale, 0., 0.]); + } else { + values.extend([0., 24., 0., 0.]); + } + if let Some(modifier) = self + .effect_frame + .surfaces + .iter() + .find(|m| effect_entity_matches(m.target, key)) + { + values.extend(modifier.uv_scale); + values.extend(modifier.uv_offset); + } else { + values.extend([1., 1., 0., 0.]); + } if let Some(instance) = self.instances.get(&key) { self.context .queue @@ -1812,6 +2175,7 @@ fn viewport_pipeline( device: &wgpu::Device, bind_layout: &wgpu::BindGroupLayout, surface_layout: &wgpu::BindGroupLayout, + probe_layout: &wgpu::BindGroupLayout, pass: ViewportPass, label: &'static str, ) -> wgpu::RenderPipeline { @@ -1822,7 +2186,7 @@ fn viewport_pipeline( }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("rustic-viewport-pipeline-layout"), - bind_group_layouts: &[Some(bind_layout), Some(surface_layout)], + bind_group_layouts: &[Some(bind_layout), Some(surface_layout), Some(probe_layout)], immediate_size: 0, }); let attributes = [ @@ -2008,6 +2372,25 @@ fn selection_outline_model( view_projection: [f32; 16], width: u32, height: u32, +) -> [f32; 16] { + selection_outline_model_with_width( + mesh, + view_projection, + width, + height, + SELECTION_OUTLINE_WIDTH_PIXELS, + ) +} +#[allow( + clippy::cast_precision_loss, + reason = "viewport extents are bounded by GPU texture limits" +)] +fn selection_outline_model_with_width( + mesh: &ViewportMesh, + view_projection: [f32; 16], + width: u32, + height: u32, + padding: f32, ) -> [f32; 16] { let mut half_extents = glam::Vec3::ZERO; for vertex in &mesh.vertices { @@ -2031,7 +2414,7 @@ fn selection_outline_model( if !pixels_per_world_unit.is_finite() || pixels_per_world_unit <= f32::EPSILON { return mesh.model; } - let world_padding = SELECTION_OUTLINE_WIDTH_PIXELS / pixels_per_world_unit; + let world_padding = padding.clamp(0.0, 64.0) / pixels_per_world_unit; let mut outline_scale = glam::Vec3::ONE; for axis in 0..3 { let world_radius = half_extents[axis] * model.col(axis).truncate().length(); @@ -3443,9 +3826,183 @@ mod tests { } } +fn effect_geometry_key(entity: engine_world::EntityId, index: usize) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + (entity, index, "effect-geometry").hash(&mut hasher); + hasher.finish() +} +fn resolve_effect_textures( + scene: &mut ViewportScene, + frame: &engine_world::effects::EffectFrame, + paths: &HashMap, +) { + use engine_world::TextureMap; + for (index, geometry) in frame.geometry.iter().enumerate() { + if let Some(mesh) = scene + .meshes + .iter_mut() + .find(|m| m.instance_key == effect_geometry_key(geometry.entity, index)) + && let Some(path) = geometry.texture.and_then(|id| paths.get(&id)) + { + mesh.textures + .maps + .insert(TextureMap::BaseColor, path.clone()); + } + } + for decal in &frame.decals { + if let Some(mesh) = scene + .meshes + .iter_mut() + .find(|m| m.instance_key == decals::key(decal.entity)) + { + if let Some(path) = decal.appearance.texture.and_then(|id| paths.get(&id)) { + mesh.textures + .maps + .insert(TextureMap::BaseColor, path.clone()); + } + if let Some(path) = decal.normal_map.and_then(|id| paths.get(&id)) { + mesh.textures.maps.insert(TextureMap::Normal, path.clone()); + } + mesh.textures.emissive_strength = decal.appearance.emission; + } + } +} + +/// Applies extraction-time camera, lighting, surface and PBR geometry modifiers. +/// Call this once for custom editor viewports, then pass the same frame to +/// `SceneViewportRenderer::set_effect_frame` for transparent geometry and camera passes. +#[allow( + clippy::too_many_lines, + reason = "frame modifiers are applied together before immutable rendering" +)] +pub fn append_effect_frame(scene: &mut ViewportScene, frame: &engine_world::effects::EffectFrame) { + use glam::{Mat4, Quat, Vec3}; + let pivot = Vec3::from_array(scene.camera_position); + let offset = Mat4::from_translation(pivot + frame.camera_offset) + * Mat4::from_quat(Quat::from_euler( + glam::EulerRot::XYZ, + frame.camera_rotation.x, + frame.camera_rotation.y, + frame.camera_rotation.z, + )) + * Mat4::from_translation(-pivot); + scene.view_projection = + (Mat4::from_cols_array(&scene.view_projection) * offset.inverse()).to_cols_array(); + scene.camera_position = + (Vec3::from_array(scene.camera_position) + frame.camera_offset).to_array(); + for modifier in &frame.lights { + if let Some(light) = scene + .lights + .iter_mut() + .find(|light| light.entity == modifier.target) + { + light.color = modifier.color; + light.intensity = modifier.intensity; + } + } + for modifier in &frame.surfaces { + for mesh in &mut scene.meshes { + if !effect_entity_matches(modifier.target, mesh.instance_key) { + continue; + } + mesh.color = std::array::from_fn(|i| mesh.color[i] * modifier.color[i]); + } + } + for outline in &frame.outlines { + for mesh in &mut scene.meshes { + if outline + .targets + .iter() + .any(|id| effect_entity_matches(*id, mesh.instance_key)) + { + mesh.selected = true; + } + } + } + for water in &frame.water { + for mesh in &mut scene.meshes { + if !effect_entity_matches(water.target, mesh.instance_key) { + continue; + } + let model = Mat4::from_cols_array(&mesh.model); + let inverse = model.inverse(); + for vertex in &mut mesh.vertices { + let world = model.transform_point3(Vec3::from_array(vertex.position)); + let sample = water.disturbance.sample(world); + vertex.position = inverse + .transform_point3(world + Vec3::Y * sample.displacement) + .to_array(); + vertex.normal = model + .transpose() + .transform_vector3(sample.normal) + .normalize_or_zero() + .to_array(); + } + } + } + let projected: Vec<_> = frame + .decals + .iter() + .filter_map(|decal| decals::project(scene, decal)) + .collect(); + scene.meshes.extend(projected); + for (index, geometry) in frame + .geometry + .iter() + .enumerate() + .filter(|(_, g)| g.material.is_some()) + { + let key = effect_geometry_key(geometry.entity, index); + scene.meshes.push(ViewportMesh { + textures: engine_world::SurfaceTextures::default(), + instance_key: key, + mesh_key: key, + vertices: geometry + .mesh + .positions + .iter() + .enumerate() + .map(|(i, p)| ViewportVertex { + position: *p, + normal: geometry + .mesh + .normals + .get(i) + .copied() + .unwrap_or([0., 1., 0.]), + uv: geometry.mesh.tex_coords.get(i).copied().unwrap_or([0., 0.]), + }) + .collect(), + indices: geometry.mesh.indices.clone(), + model: Mat4::IDENTITY.to_cols_array(), + color: geometry.color, + selected: false, + }); + } +} +fn effect_entity_matches(entity: engine_world::EntityId, key: u64) -> bool { + let mut h = std::collections::hash_map::DefaultHasher::new(); + entity.hash(&mut h); + if h.finish() == key { + return true; + } + // Imported models use a stable (owner, source mesh index) key. + (0..256usize).any(|index| { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (entity, index).hash(&mut h); + h.finish() == key + }) +} + /// Builds a game frame from the live simulation world. Highest camera order wins. /// No active camera produces an empty frame instead of a fabricated viewpoint. pub fn game_scene(world: &engine_world::SceneWorld, aspect: f32) -> ViewportScene { + let mut scene = base_game_scene(world, aspect); + append_effect_frame(&mut scene, world.effect_frame()); + scene +} + +fn base_game_scene(world: &engine_world::SceneWorld, aspect: f32) -> ViewportScene { let mut buffer = engine_world::RenderWorldBuffer::new(); let extracted = buffer.extract(world); let mut scene = ViewportScene { @@ -3501,6 +4058,110 @@ pub fn game_scene(world: &engine_world::SceneWorld, aspect: f32) -> ViewportScen scene } +/// Batches rigid mesh particles by asset, source mesh and a bounded 16-step color +/// palette, retaining authored geometry and normals in the existing PBR pipeline. +/// Missing model assets produce no substitute geometry. +#[allow( + clippy::too_many_lines, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "mesh batches use bounded vertex and quantized color budgets" +)] +pub fn append_mesh_particle_models( + scene: &mut ViewportScene, + frame: &engine_world::effects::EffectFrame, + models: &std::collections::BTreeMap, +) { + use glam::{Mat4, Quat, Vec3}; + let mut batches: std::collections::BTreeMap< + (engine_world::AssetId, usize, [u8; 4]), + ViewportMesh, + > = std::collections::BTreeMap::default(); + let mut vertex_budget = 1_000_000usize; + for particle in &frame.particles { + let Some(asset) = particle.mesh else { + continue; + }; + let Some(model) = models.get(&asset) else { + continue; + }; + for (source_index, source) in model.meshes.iter().enumerate() { + if source.positions.len() > vertex_budget { + continue; + } + vertex_budget -= source.positions.len(); + let source_color = model + .materials + .get(source.material_index.unwrap_or(0)) + .map_or([1.; 4], |m| m.base_color); + let color: [u8; 4] = std::array::from_fn(|i| { + ((source_color[i] * particle.color[i]).clamp(0., 1.) * 15.).round() as u8 + }); + let batch = batches + .entry((asset, source_index, color)) + .or_insert_with(|| { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (asset, source_index, color, "mesh-particles").hash(&mut h); + let key = h.finish(); + ViewportMesh { + textures: engine_world::SurfaceTextures::default(), + instance_key: key, + mesh_key: key, + vertices: vec![], + indices: vec![], + model: Mat4::IDENTITY.to_cols_array(), + color: color.map(|v| f32::from(v) / 15.), + selected: false, + } + }); + let mut local = Mat4::IDENTITY; + let mut node = source.source_node; + let mut depth = 0; + while let Some(index) = node { + if depth >= model.nodes.len() { + break; + } + let Some(n) = model.nodes.get(index) else { + break; + }; + local = Mat4::from_scale_rotation_translation( + glam::DVec3::from_array(n.scale).as_vec3(), + glam::DQuat::from_array(n.rotation).as_quat(), + glam::DVec3::from_array(n.translation).as_vec3(), + ) * local; + node = n.parent; + depth += 1; + } + let transform = Mat4::from_scale_rotation_translation( + Vec3::splat(particle.size), + Quat::from_rotation_z(particle.rotation), + particle.position, + ) * local; + let normal_matrix = transform.inverse().transpose(); + let offset = batch.vertices.len() as u32; + batch + .vertices + .extend(source.positions.iter().enumerate().map(|(i, p)| { + ViewportVertex { + position: transform.transform_point3(Vec3::from_array(*p)).to_array(), + normal: normal_matrix + .transform_vector3(Vec3::from_array( + source.normals.get(i).copied().unwrap_or([0., 1., 0.]), + )) + .normalize_or_zero() + .to_array(), + uv: source.texcoords.get(i).copied().unwrap_or([0.; 2]), + } + })); + batch + .indices + .extend(source.indices.iter().map(|i| i + offset)); + } + } + scene.meshes.extend(batches.into_values()); +} + /// Render imported rigid and skinned models using source node poses from the runtime scene. #[allow( clippy::too_many_lines, @@ -3512,7 +4173,7 @@ pub fn game_scene_with_models( models: &std::collections::BTreeMap, ) -> ViewportScene { use glam::{Mat4, Vec3}; - let mut scene = game_scene(world, aspect); + let mut scene = base_game_scene(world, aspect); for entity in world.entity_ids() { let Ok(Some(mesh)) = world.mesh(entity) else { continue; @@ -3651,6 +4312,8 @@ pub fn game_scene_with_models( }); } } + append_mesh_particle_models(&mut scene, world.effect_frame(), models); + append_effect_frame(&mut scene, world.effect_frame()); scene } diff --git a/Engine/crates/renderer-wgpu/src/post.rs b/Engine/crates/renderer-wgpu/src/post.rs new file mode 100644 index 00000000..a22ca5e1 --- /dev/null +++ b/Engine/crates/renderer-wgpu/src/post.rs @@ -0,0 +1,439 @@ +//! Display-space postprocessing of the resolved scene. The source and destination are +//! distinct retained textures: distortion samples the image, never its own attachment. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated effect and GPU dimension budgets bound numeric conversions" +)] +#![allow( + clippy::too_many_lines, + reason = "GPU descriptor construction and effect extraction remain auditable transactions" +)] + +use super::{RendererError, ViewportScene, f32_bytes, validate_and_reflect_wgsl}; +use engine_world::effects::{EffectFrame, VolumeShape}; +use glam::{Mat4, Vec3}; + +const SHADER: &str = r" +struct Settings { grade: vec4, lens: vec4, warp: vec4, bloom: vec4, tint: vec4, overlay: vec4, multiply:vec4, add_dof:vec4 }; +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var image_sampler: sampler; +@group(0) @binding(2) var settings: Settings; +struct Fog { inverse:mat4x4, dimensions_density:vec4, color_height:vec4, options:vec4 }; +struct Shaft { screen:vec4,color_density:vec4,options:vec4 }; +struct Flare { screen:vec4,color_scale:vec4,options:vec4 }; +struct Scene { inverse:mat4x4,camera:vec4,counts:vec4,fog:array,shafts:array,flares:array }; +@group(0) @binding(3) var depth:texture_2d; +@group(0) @binding(4) var scene:Scene; +fn depth_at(uv:vec2)->f32 {let size=vec2(textureDimensions(depth));let encoded=textureLoad(depth,clamp(vec2(uv*vec2(size)),vec2(0),size-vec2(1)),0).rgb;return dot(encoded,vec3(255.,65280.,16711680.))/16777215.;} +fn fog_density(fog:Fog,p:vec3)->f32 { + let local=(fog.inverse*vec4(p,1.)).xyz / max(fog.dimensions_density.xyz*0.5,vec3(0.001)); + var distance=max(max(abs(local.x),abs(local.y)),abs(local.z)); + if fog.options.x>0.5 && fog.options.x<1.5 {distance=length(local);} + if fog.options.x>1.5 && fog.options.x<2.5 {distance=max(length(local.xz),abs(local.y));} + if distance>1. {return 0.;} + let edge=clamp((1.-distance)/max(fog.options.y,0.001),0.,1.); + let noise=0.65+0.35*sin(dot(p,vec3(1.7,2.3,1.1))*fog.options.z+settings.lens.w); + return fog.dimensions_density.w*edge*noise*exp(-max(local.y+1.,0.)*fog.color_height.w); +} +struct Output { @builtin(position) position: vec4, @location(0) uv: vec2 }; +@vertex fn vs_main(@builtin(vertex_index) index:u32) -> Output { + let positions = array,3>(vec2(-1.,-1.),vec2(3.,-1.),vec2(-1.,3.)); + var o:Output; o.position=vec4(positions[index],0.,1.); o.uv=positions[index]*vec2(0.5,-0.5)+vec2(0.5); return o; +} +fn image(uv:vec2) -> vec4 { return textureSampleLevel(source,image_sampler,clamp(uv,vec2(0.),vec2(1.)),0.); } +fn srgb_to_linear(c:vec3) -> vec3 { return select(pow((c+vec3(0.055))/1.055,vec3(2.4)),c/12.92,c<=vec3(0.04045)); } +fn linear_to_srgb(c:vec3) -> vec3 { return select(1.055*pow(max(c,vec3(0.)),vec3(1./2.4))-vec3(0.055),c*12.92,c<=vec3(0.0031308)); } +@fragment fn fs_main(input:Output) -> @location(0) vec4 { + let t=settings.lens.w; + let edge=clamp(1.-length((input.uv-vec2(0.5))*2.),0.,1.); + let wave=vec2(sin(input.uv.y*settings.warp.y+t*settings.warp.z),cos(input.uv.x*settings.warp.y+t*settings.warp.z*0.73)); + let uv=input.uv+wave*settings.warp.x*edge; + let base=image(uv); + let radial=(uv-vec2(0.5))*settings.lens.y; + var color=srgb_to_linear(vec3(image(uv+radial).r,base.g,image(uv-radial).b)); + let device_depth=depth_at(input.uv); + if settings.add_dof.w>0. { + let focus=depth_at(vec2(0.5)); + let blur=clamp(abs(device_depth-focus)*settings.add_dof.w*1000.,0.,8.); + let pixel=vec2(blur)/vec2(textureDimensions(source)); + var sum=vec3(0.); + for(var y=-2;y<=2;y+=1) {for(var x=-2;x<=2;x+=1) {sum+=srgb_to_linear(image(uv+vec2(f32(x),f32(y))*pixel).rgb);}} + color=sum/25.; + } + if settings.bloom.x>0. { + let pixel=vec2(settings.bloom.z)/vec2(textureDimensions(source)); + var glow=vec3(0.); + for(var y=-2;y<=2;y+=1) { for(var x=-2;x<=2;x+=1) { + let sample=srgb_to_linear(image(uv+vec2(f32(x),f32(y))*pixel).rgb); + glow+=max(sample-vec3(settings.bloom.y),vec3(0.)); + }} + color+=glow*(settings.bloom.x/25.); + } + let endpoint=scene.inverse*vec4(input.uv*vec2(2.,-2.)+vec2(-1.,1.),min(device_depth,0.9999),1.); + let world=endpoint.xyz/endpoint.w; + let ray=world-scene.camera.xyz; + let ray_length=min(length(ray),2000.); + let ray_direction=normalize(ray+vec3(0.000001)); + // Fixed integration limits are a compatibility path with no compute/storage dependencies. + for(var v=0u;v(scene.camera.xyz,1.)).xyz; + let direction=(fog.inverse*vec4(ray_direction,0.)).xyz; + let safe=select(vec3(0.000001),direction,abs(direction)>vec3(0.000001)); + let near=(-fog.dimensions_density.xyz*0.5-origin)/safe; + let far=(fog.dimensions_density.xyz*0.5-origin)/safe; + let entry=min(near,far);let exit=max(near,far); + let begin=max(max(max(entry.x,entry.y),entry.z),0.); + let end=min(min(min(exit.x,exit.y),exit.z),ray_length); + let step=max(end-begin,0.)/f32(steps); + var optical=0.; + for(var j=0u;j1. {continue;}let steps=u32(clamp(shaft.options.x,4.,64.));var glow=vec3(0.); + for(var j=0u;j0.999);glow+=srgb_to_linear(image(p).rgb)*open;} + color+=glow/f32(steps)*shaft.color_density.rgb*shaft.screen.w*shaft.color_density.w; + } + for(var v=0u;v0. && flare.screen.z<=1. && depth_at(flare.screen.xy)+0.001>=flare.screen.z { + let aspect=f32(textureDimensions(source).x)/f32(textureDimensions(source).y); + for(var j=0u;j<=u32(flare.options.x);j+=1u) { + let center=mix(flare.screen.xy,vec2(0.5),f32(j)*flare.options.y); + let d=(input.uv-center)*vec2(aspect,1.); + let radius=max(flare.color_scale.w*(1.+f32(j)*0.25),0.001); + let disc=pow(max(1.-dot(d,d)/(radius*radius),0.),3.); + let streak=exp(-abs(d.y)*800.)*exp(-abs(d.x)/max(flare.options.z,0.0001)); + color+=(disc+streak)*flare.color_scale.rgb*flare.screen.w/(1.+f32(j)); + } + } + } + color*=exp2(settings.grade.w)*settings.tint.rgb; + let luma=dot(color,vec3(0.2126,0.7152,0.0722)); + color=mix(vec3(luma),color,settings.grade.x); + color=(color-vec3(0.18))*settings.grade.y+vec3(0.18+settings.grade.z); + let radius=length((input.uv-vec2(0.5))*vec2(1.41421356)); + color*=1.-settings.lens.x*smoothstep(0.25,0.95,radius); + let grain=fract(sin(dot(input.position.xy+vec2(t*71.),vec2(12.9898,78.233)))*43758.5453)-0.5; + color+=vec3(grain*settings.lens.z); + return vec4(mix(linear_to_srgb(max(color,vec3(0.)))*settings.multiply.rgb+settings.add_dof.rgb,settings.overlay.rgb,clamp(settings.overlay.a,0.,1.)),base.a); +} +"; + +pub(super) struct PostRenderer { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + uniform: wgpu::Buffer, + sampler: wgpu::Sampler, + scene_uniform: wgpu::Buffer, + target: Option, +} +struct Target { + size: (u32, u32), + texture: wgpu::Texture, + view: wgpu::TextureView, + group: wgpu::BindGroup, +} +impl PostRenderer { + pub fn new(device: &wgpu::Device) -> Result { + validate_and_reflect_wgsl(SHADER)?; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effects-post-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("effects-post-shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effects-post-pipeline-layout"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("effects-post-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effects-post-settings"), + size: 128, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("effects-post-sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let scene_uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effects-atmosphere-settings"), + size: 1376, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Ok(Self { + scene_uniform, + pipeline, + layout, + uniform, + sampler, + target: None, + }) + } + pub fn prepare( + &mut self, + device: &wgpu::Device, + source: &wgpu::TextureView, + depth: &wgpu::TextureView, + width: u32, + height: u32, + ) { + // Rebind after viewport resize/device recovery; dimensions uniquely identify its targets. + if self + .target + .as_ref() + .is_some_and(|t| t.size == (width, height)) + { + return; + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effects-post-output"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("effects-post-input"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.uniform.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(depth), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: self.scene_uniform.as_entire_binding(), + }, + ], + }); + self.target = Some(Target { + size: (width, height), + texture, + view, + group, + }); + } + pub fn render<'a>( + &'a self, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + values: &[f32; 32], + scene: &ViewportScene, + frame: &EffectFrame, + ) -> Option<&'a wgpu::Texture> { + let target = self.target.as_ref()?; + queue.write_buffer(&self.uniform, 0, &f32_bytes(values)); + queue.write_buffer( + &self.scene_uniform, + 0, + &f32_bytes(&scene_values(scene, frame)), + ); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("effects-post-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target.view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &target.group, &[]); + pass.draw(0..3, 0..1); + drop(pass); + Some(&target.texture) + } +} +fn scene_values(scene: &ViewportScene, frame: &EffectFrame) -> Vec { + let mut values = Mat4::from_cols_array(&scene.view_projection) + .inverse() + .to_cols_array() + .to_vec(); + values.extend(scene.camera_position); + values.push(0.); + values.extend([ + frame.fog.len().min(8) as f32, + frame.shafts.len().min(4) as f32, + frame.flares.len().min(4) as f32, + 0., + ]); + for fog in frame.fog.iter().take(8) { + values.extend(fog.inverse_transform.to_cols_array()); + values.extend(fog.dimensions); + values.push(fog.density); + values.extend(fog.color); + values.push(fog.height_falloff); + values.extend([ + match fog.shape { + VolumeShape::Sphere => 1., + VolumeShape::Cylinder => 2., + _ => 0., + }, + fog.edge_falloff, + fog.noise_scale, + fog.steps.clamp(8, 64) as f32, + ]); + } + values.resize(24 + 8 * 28, 0.); + let vp = Mat4::from_cols_array(&scene.view_projection); + let project = |target: Option, position: Vec3| { + let position = target + .and_then(|id| scene.lights.iter().find(|l| l.entity == id)) + .map_or(position, |l| { + Mat4::from_cols_array(&l.transform).w_axis.truncate() + }); + let p = vp * position.extend(1.); + let ndc = p.truncate() / p.w; + [ + ndc.x.midpoint(1.), + (1. - ndc.y) * 0.5, + if p.w > 0. { ndc.z } else { -1. }, + ] + }; + for shaft in frame.shafts.iter().take(4) { + let p = project(shaft.light, shaft.position); + values.extend([p[0], p[1], p[2], shaft.intensity]); + values.extend(shaft.color); + values.push(shaft.density); + values.extend([ + shaft.samples.clamp(4, 64) as f32, + shaft.max_distance, + 0., + 0., + ]); + } + values.resize(24 + 8 * 28 + 4 * 12, 0.); + for flare in frame.flares.iter().take(4) { + let p = project(flare.light, flare.position); + values.extend([p[0], p[1], p[2], flare.intensity]); + values.extend(flare.color); + values.push(flare.scale * 0.025); + values.extend([ + flare.ghosts.min(16) as f32, + flare.ghost_spacing, + flare.streak_length * 0.01, + 0., + ]); + } + values.resize(24 + 8 * 28 + 8 * 12, 0.); + values +} +#[cfg(test)] +mod tests { + #[test] + fn shader_validates() { + super::validate_and_reflect_wgsl(super::SHADER).unwrap(); + } +} diff --git a/Engine/crates/renderer-wgpu/src/reflections.rs b/Engine/crates/renderer-wgpu/src/reflections.rs new file mode 100644 index 00000000..41cf5f5b --- /dev/null +++ b/Engine/crates/renderer-wgpu/src/reflections.rs @@ -0,0 +1,198 @@ +//! Local baked environment probes using the same project-relative HDR decoding as surfaces. +//! A 2:1 equirectangular image is sampled in reflected world direction with box projection. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated effect and GPU dimension budgets bound numeric conversions" +)] +#![allow( + clippy::too_many_lines, + reason = "GPU descriptor construction and effect extraction remain auditable transactions" +)] + +use super::{HashMap, RendererError, surface}; +use engine_world::effects::EffectFrame; +use std::path::Path; +pub(super) struct ReflectionCache { + pub layout: wgpu::BindGroupLayout, + groups: HashMap, + fallback: wgpu::BindGroup, +} +impl ReflectionCache { + pub fn new(device: &wgpu::Device) -> Self { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("probe-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("probe-neutral"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let fallback = group(device, &layout, &texture); + Self { + layout, + groups: HashMap::new(), + fallback, + } + } + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + root: &Path, + frame: &EffectFrame, + paths: &HashMap, + ) -> Result<(), RendererError> { + let active: std::collections::HashSet<_> = frame.probes.iter().map(|p| p.cubemap).collect(); + self.groups.retain(|id, _| active.contains(id)); + for probe in &frame.probes { + if self.groups.contains_key(&probe.cubemap) { + continue; + } + let Some(path) = paths.get(&probe.cubemap) else { + continue; + }; + let mut image = surface::load(root, path)?; + if image.width() != image.height() * 2 { + return Err(RendererError::InvalidMesh(format!( + "baked reflection probe {path} requires a 2:1 equirectangular image" + ))); + } + if !path.to_lowercase().ends_with(".hdr") { + for p in image.pixels_mut() { + for c in &mut p.0[..3] { + *c = if *c <= 0.04045 { + *c / 12.92 + } else { + ((*c + 0.055) / 1.055).powf(2.4) + }; + } + } + } + let width = image.width().min(1024); + let height = (width / 2).max(1); + image = image::imageops::resize( + &image, + width, + height, + image::imageops::FilterType::Triangle, + ); + let levels = width.max(height).ilog2() + 1; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("baked-probe-HDR"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: levels, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + for level in 0..levels { + let w = (width >> level).max(1); + let h = (height >> level).max(1); + let mip = + image::imageops::resize(&image, w, h, image::imageops::FilterType::Triangle); + let bytes: Vec<_> = mip + .as_raw() + .iter() + .flat_map(|f| half::f16::from_f32(*f).to_le_bytes()) + .collect(); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: level, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w * 8), + rows_per_image: Some(h), + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + } + self.groups + .insert(probe.cubemap, group(device, &self.layout, &texture)); + } + Ok(()) + } + pub fn clear(&mut self) { + self.groups.clear(); + } + pub fn has(&self, id: engine_world::AssetId) -> bool { + self.groups.contains_key(&id) + } + pub fn get(&self, id: Option) -> &wgpu::BindGroup { + id.and_then(|id| self.groups.get(&id)) + .unwrap_or(&self.fallback) + } +} +fn group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + texture: &wgpu::Texture, +) -> wgpu::BindGroup { + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("probe-sampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + ..Default::default() + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("probe-group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }) +} diff --git a/Engine/crates/renderer-wgpu/src/sprites.rs b/Engine/crates/renderer-wgpu/src/sprites.rs new file mode 100644 index 00000000..b3c70d34 --- /dev/null +++ b/Engine/crates/renderer-wgpu/src/sprites.rs @@ -0,0 +1,165 @@ +//! Shared bounded sprite texture array; particle flipbooks use authored UV rectangles. +#![allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated effect and GPU dimension budgets bound numeric conversions" +)] +#![allow( + clippy::too_many_lines, + reason = "GPU descriptor construction and effect extraction remain auditable transactions" +)] + +use super::{HashMap, RendererError, surface}; +use engine_world::effects::EffectFrame; +pub(super) struct SpriteAtlas { + pub layout: wgpu::BindGroupLayout, + group: Option, + ids: Vec, + pub layers: HashMap, +} +impl SpriteAtlas { + pub fn new(device: &wgpu::Device) -> Self { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effect-sprites-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + Self { + layout, + group: None, + ids: vec![], + layers: HashMap::new(), + } + } + pub fn clear(&mut self) { + self.group = None; + self.ids.clear(); + self.layers.clear(); + } + pub fn group(&self) -> Option<&wgpu::BindGroup> { + self.group.as_ref() + } + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + root: &std::path::Path, + frame: &EffectFrame, + paths: &HashMap, + ) -> Result<(), RendererError> { + let ids: Vec<_> = frame + .particles + .iter() + .filter_map(|p| p.texture) + .chain(frame.geometry.iter().filter_map(|g| g.texture)) + .collect::>() + .into_iter() + .collect(); + if ids.len() > 63 { + return Err(RendererError::InvalidMesh( + "effect frame exceeds 63 sprite textures".into(), + )); + } + if self.group.is_some() && ids == self.ids { + return Ok(()); + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effect-sprite-array"), + size: wgpu::Extent3d { + width: 256, + height: 256, + depth_or_array_layers: ids.len() as u32 + 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.layers.clear(); + for (i, id) in ids.iter().enumerate() { + let Some(path) = paths.get(id) else { + continue; + }; + let image = surface::load(root, path)?; + let image = + image::imageops::resize(&image, 256, 256, image::imageops::FilterType::Triangle); + let bytes: Vec<_> = image + .as_raw() + .iter() + .flat_map(|f| half::f16::from_f32(*f).to_le_bytes()) + .collect(); + let layer = i as u32 + 1; + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: wgpu::TextureAspect::All, + }, + &bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256 * 8), + rows_per_image: Some(256), + }, + wgpu::Extent3d { + width: 256, + height: 256, + depth_or_array_layers: 1, + }, + ); + self.layers.insert(*id, layer); + } + let view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("effect-sprite-sampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("effect-sprite-group"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + })); + self.ids = ids; + Ok(()) + } +} diff --git a/Engine/crates/renderer-wgpu/src/surface.rs b/Engine/crates/renderer-wgpu/src/surface.rs index d7b45056..f9f20edd 100644 --- a/Engine/crates/renderer-wgpu/src/surface.rs +++ b/Engine/crates/renderer-wgpu/src/surface.rs @@ -80,7 +80,7 @@ impl SurfaceCache { Ok(()) } } -fn load(root: &Path, relative: &str) -> Result { +pub(super) fn load(root: &Path, relative: &str) -> Result { let fail = |e| RendererError::InvalidMesh(format!("Texture {relative}: {e}")); let root = root.canonicalize().map_err(|e| fail(e.to_string()))?; let path = root diff --git a/Engine/docs/SCENE_EFFECTS.md b/Engine/docs/SCENE_EFFECTS.md new file mode 100644 index 00000000..573aa7ad --- /dev/null +++ b/Engine/docs/SCENE_EFFECTS.md @@ -0,0 +1,179 @@ +# Scene Effects and VFX + +Scene effects are authored entity components. They use the existing hierarchy, transforms, scene persistence and undo history. An entity has at most one effect component; use child entities to combine effects. Configuration is saved; live particles and playback clocks are transient. + +This guide describes the native editor and native Play path. CPU simulation is the supported particle path. The compatibility section identifies renderer features that remain unavailable. + +## Add and preview an effect + +1. Open a project and scene in the editor. +2. Use hierarchy **+ Add → Effects** to create an effect entity, or select an entity and open **Properties → Scene effects → + Add / replace effect**. +3. Select the new effect entity. Expand **Common** for playback, seed, duration and visibility, then the effect-specific section for simulation and appearance. +4. Open **Properties → Effect preview** to play, pause, resume, stop, restart, reset or step by 1/60 second. Preview speed ranges from 0 to 4; time scrubbing replays up to 30 seconds, and bounds/metrics are available. Move its entity with the normal transform gizmo. An emitting trail needs movement before it creates a ribbon. Preview runs a scratch SceneWorld through the same simulation path, including registered child-asset sub-emitters. Stopping preview clears transient child groups. +5. Save the scene, close it and reopen it. The configuration returns; playback starts from the configured initial state. + +Open **Effects presets & assets**, search the built-in library and click a preset to instantiate a group under the selection. Drag a preset onto the viewport or hierarchy to place it. Use **Load .effect** or **Save selection .effect** for custom assets. The preset is editable. For a selected single-effect component, **Assign single-effect asset…** changes its source. Composite assets are instantiated through the asset browser. Configure entity references such as a light pulse's target or a beam's endpoint; effects requiring targets cannot infer your intended object. + +## Common properties + +Distances use scene world units, times use seconds, rotations use radians, and rates use units per second unless stated otherwise. Entity transforms provide position, rotation, scale and parenting. Optional entity/asset references are stable UUIDs; do not substitute OS file paths for an asset identifier. + +| Property | Type | Default | Meaning | +| --- | --- | --- | --- | +| enabled / visible | boolean | true / true | Enable simulation and render visibility | +| auto_play | boolean | true | Start a newly encountered component automatically | +| looping | boolean | true | Repeat the duration | +| duration / delay | number | 5 / 0 | Positive cycle duration and initial delay, seconds | +| time_scale | number | 1 | Nonnegative effect clock multiplier | +| simulation_space | enum | World | World or Local particle coordinates | +| attachment_target | entity or null | null | Follow a scene entity | +| visibility_layers | unsigned integer | 4294967295 | Effect layer mask | +| render_distance | number | 1000 | Maximum camera distance | +| quality | enum | Medium | Low, Medium or High | +| random_seed | unsigned integer | 1 | Deterministic simulation seed | +| priority | integer | 0 | Ascending modifier order; higher priority blends last | +| parameters | typed map | empty | Named per-instance parameter bindings | +| bounds | optional bounds | null | Explicit culling region | + +Simulation remains cosmetic. Do not use particle collision events for authoritative damage or gameplay physics. Replaying the same seed requires the same transforms and time steps; a seed alone does not make arbitrary frame partitions identical. + +Named `common.parameters` bindings accept `Scalar`, `Vector`, `Color` or `Boolean` variants. Supported names include enabled, visible, emission_rate, intensity, color, speed, size, gravity, force, density, progress, width and radius on compatible effects. Unknown or incompatible names do not invent behavior; use a documented binding appropriate to the effect kind. Explicit bounds use a local center and positive half extents for camera culling. + +## Curves and gradients + +`Curve` stores `keys` with normalized `time` and scalar `value`. `Gradient` stores `keys` with normalized `time` and RGBA `color`. Evaluation interpolates linearly between surrounding keys and holds the nearest endpoint outside their range. Keep times between zero and one and use distinct times for predictable interpolation. The Inspector edits key times and values with native numeric controls, plus native color pickers for gradients. Key lists support add, duplicate, reorder and remove. The interactive curve/gradient graph supports dragging keys, double-clicking to add an interpolated key and right-clicking a key to remove it. The scalar parameter type also supports constants, ranges and a random interpolation between two curves. Color parameters can interpolate randomly between two gradients; vector curves keep separate normalized X/Y/Z channels. + +Appearance configuration is shared by particles, trails and other geometry effects: + +| Property | Default | Meaning | +| --- | --- | --- | +| color | opaque white gradient | Color over normalized lifetime | +| opacity | linear 1 to 0 | Alpha multiplier over lifetime | +| size | constant 1 | Size/width multiplier over lifetime | +| material / texture | null | Optional stable asset references | +| emission | 1 | Emissive strength | +| blend | Alpha | Alpha, Additive or Multiply | +| uv_scale | [1, 1] | Texture coordinate scaling | +| uv_scroll | [0, 0] | Texture movement per second | + +## Particle Emitter tutorial + +Create a **Particle Emitter**, keep auto-play enabled and expand the emitter settings. The Inspector groups its settings under Emission, Initialization, Movement, Appearance, Collision, Sub-emitters, and Capacity and simulation. For MeshSurface emission, first assign a mesh under Appearance; the editor extracts source triangles before validating the shape. Start with a rate of 30, maximum 1000, lifetime [1, 2] and speed [1, 3]. Cone emission, upward direction [0, 1, 0] and gravity [0, -1, 0] create a small fountain. Change the appearance gradient to orange and use Additive blending for sparks. For smoke, use Alpha blending, larger sizes and a longer lifetime. + +A burst contains `time`, `count`, `probability`, `cycles` and `interval`; a zero continuous rate plus a time-zero burst creates an impulse. Disable looping to play it once. Particle collision uses explicit collision volumes, with layer masks, bounce/friction and Bounce, Stick or Kill response. A force field and a wind zone can affect multiple compatible emitters without allocating rigid bodies per particle. + +For a looping emitter, `prewarm` simulates `prewarm_time` (bounded to ten seconds) before its first visible update. It uses 1/60-second steps and suppresses warmup event delivery. + +Select the Rain, Snow or Leaves preset for weather. Weather reuses the particle emitter; its intensity multiplies emission. Move a local weather volume to the desired region rather than creating an unbounded scene population. + +## Trail Renderer tutorial + +Add a **Trail Renderer** to a moving entity or create a child of a moving object. Set lifetime to 1 second, width to 0.2, minimum vertex distance to 0.05 and maximum points to 256. Move the object in Play or while previewing. The renderer records separated points, removes expired points and builds an indexed ribbon. Changing the appearance size curve changes width along the ribbon; the opacity curve fades the tail. + +A teleport farther than `teleport_distance` clears the previous strip so it does not connect unrelated positions. Stop emitting to let existing points expire. Use separate trail entities for left and right wheels. The generated showcase includes a Lua orbit behavior to exercise a moving trail. + +## Effect asset and composite tutorial + +An `.effect` file contains `EffectAsset { version, name, effects }`, with optional `entity_ids` and column-major `transforms` arrays for authored child identities and transforms relative to the group. Instantiation allocates fresh child IDs and remaps references between children, preserving their relative transforms. Legacy assets without those arrays instantiate at the group origin. References to scene objects outside the asset remain external references; assign them for the destination scene. Version 1 stores an ordered list of fully typed child configurations. `common.delay` and `common.duration` give each child its position in the composite timeline. The asset reader rejects unsupported versions, empty groups, groups above 128 children and files above 4 MiB. + +`presets()` returns 35 independent built-in assets; `instantiate()` clones a group's configuration. `instantiate_from(asset_id)` additionally retains the source UUID and child index; `restore(instance)` resets that child to its shared asset defaults. Editing one instance does not mutate another instance or the source asset. Explosion combines a time-zero burst, delayed smoke, an expanding shockwave and a camera impulse. The showcase generator writes all built-in assets under `assets/effects`. + +A composite's children are ordinary scene effects. Parenting preserves transform inheritance and standard duplication/deletion behavior. Reset individual instance values by reapplying the asset. Select the group and open **Scene effects → Composite timeline (seconds)** to change child enabled state, start and duration, select a child to edit, duplicate, move earlier or delete it. Select a source child for **Restore asset defaults** and **Update shared asset**. **Update shared asset** refreshes matching instances in the currently open authoring scene while preserving fields that differ from the old defaults; arrays are treated as complete overrides. That scene refresh is one undo transaction. The shared asset file write is a separate asset edit. When an asset is registered during scene loading, persisted source baselines merge current asset defaults while preserving per-field overrides; arrays remain atomic overrides. Legacy instances without a baseline retain embedded configuration until restored. + +## All effect types + +| # | Kind key | Intended behavior and setup | +| --- | --- | --- | +| 1 | Trail | Moving-object ribbon with bounded lifetime and point count | +| 2 | Particle | CPU particle emitter with emission, initialization, movement, appearance and collisions | +| 3 | Beam | Segmented beam/lightning between positions or scene targets | +| 4 | Shockwave | Expanding annulus aligned by its entity transform | +| 5 | MeshParticle | Particle simulation carrying a mesh asset for geometry rendering | +| 6 | Ribbon | Control-point ribbon or tube with spline subdivision | +| 7 | ForceField | Directional, attraction, repulsion, vortex or turbulence influence volume | +| 8 | CollisionVolume | Particle sphere, box, plane or cylinder interaction volume | +| 9 | ProjectileTracer | Velocity-driven visible streak | +| 10 | Fog | Localized depth-aware fog volume | +| 11 | Decal | Surface projection region with appearance configuration | +| 12 | Weather | Rain, Snow, Hail, Dust, Sand or Leaves using the particle infrastructure | +| 13 | WaterRipple | Bounded water disturbances, displaced grid/target surface, normals and wake foam | +| 14 | Wind | Global or local sampled wind influence | +| 15 | HeatHaze | Actual resolved-framebuffer UV distortion | +| 16 | LightShaft | Light scattering input linked to a light source | +| 17 | FoliageScatter | Deterministic decorative geometry distribution | +| 18 | Dissolve | Temporary target surface threshold and emissive edge modifier | +| 19 | AnimatedSurface | Temporary UV movement, color and emission modifier | +| 20 | LensFlare | Camera-projected flare ghosts and streak | +| 21 | LightPulse | Temporary light color/intensity animation; assign a light target | +| 22 | PostProcess | Priority-weighted global or local camera grading settings | +| 23 | Outline | Target object highlight; assign target entities | +| 24 | CameraShake | Additive render-camera translation and rotation impulse | +| 25 | ScreenFlash | Fading camera color overlay with vignette | +| 26 | ReflectionProbe | Authored local reflection influence and cubemap reference; see compatibility limits | + +The [configuration reference](/docs/guides/scene-effects/configuration) lists the exact typed fields. A registered component does not establish support for every advanced rendering option: consult the limitations below before choosing an effect for production. + +## Playback and scripting + +Attach a Lua Object Component Script to an entity through **Programming → New Script**, then attach it in the Inspector. The `Effects` global extends the existing fade/flash/shake/pulse tween helpers. It does not replace them. + +```lua +return { + Start = function() + Effects.create(nil, "Particle") + Effects.configure(nil, { + common = {looping = false, duration = 2.0}, + kind = {Particle = {rate = 0, bursts = { + {time = 0, count = 50, probability = 1, cycles = 1, interval = 1} + }}} + }) + Effects.restart(nil) + end +} +``` + +Run this on an entity that does not already have an effect. For a previously authored effect, omit `create`. `nil` means the script owner. Passing a real entity reference targets another object. Public signatures: + +| Call | Result | +| --- | --- | +| Effects.types() | Array of supported, case-sensitive kind keys | +| Effects.create(entity, kind) | Creates the one effect component and returns its configuration | +| Effects.get(entity) | Configuration or null/nil | +| Effects.configure(entity, settings) | Validated nested configuration patch; returns new configuration | +| Effects.play/pause/resume/stop/restart/reset(entity) | Playback operation | +| Effects.dispose(entity) | Removes runtime state and the component | +| Effects.state(entity) | Playback state string or null/nil | +| Effects.instantiate(asset, parent, position) | Instantiated composite root entity UUID | +| Effects.spawn(asset, position, lifetime, parent) | Temporary group; positive explicit lifetime schedules group destruction | + +JavaScript uses the same names and objects, with `null` instead of `nil`. Python also exposes the same `Effects` wrappers. Other generic-query adapters can use the existing `rustic.query` boundary, for example `{op:'effect_types'}`. Effect requests include `effect_create`, `effect_get`, `effect_configure`, `effect_control` and `effect_state`. Entity arguments in raw protocol requests must be stable UUIDs. Configuration uses the typed variant shape `kind: {Particle: {...}}`; unknown fields, unknown kinds and invalid values return errors before committing a scene mutation. + +Runtime edits affect the live Play world. Stop Play to return to the authored scene. Use the Inspector and Save for persistent authoring. `dispose` removes the effect component, not its entity. Completion and particle birth/collision/death records are available in Rust `EffectFrame.events` and the existing Events service. Subscribe with `Events.connect(entity, "EffectCompleted", callback)` or `EffectBirth`, `EffectCollision` and `EffectDeath`; event arguments contain the world position vector. A composite root emits EffectCompleted once when its descendants complete; restarting or resetting begins a new completion cycle. `Effects.sampleWind(position, time, layers)` samples compatible wind at a position, using the supplied time in seconds for gust phase. + +## Renderer compatibility and color management + +The native renderer supports up to eight fog volumes, four shafts and four flares per frame. Fog raymarching uses 8–64 steps. Shafts use radial framebuffer sampling with depth occlusion and remain available with fog because the current fog model does not already produce equivalent directional shafts. Fog has height, edge and procedural noise density; anisotropic phase functions and dynamic-light scattering are unavailable. Depth of field uses a 25-tap depth-dependent blur focused at the center of the image. + +The native wgpu renderer batches depth-tested transparent geometry and particles. It supports Alpha, Additive and Multiply effects without a separate draw call for every billboard. Empty texture slots use procedural sprites. Geometry-based effects should use the existing mesh/material conventions when selecting assets. Mesh particles use batched source geometry through the PBR path with a 16-step color palette; a missing source mesh is omitted. Custom particle sprite atlases support up to 63 textures per frame, resampled to 256-pixel layers, with flipbook UVs and velocity stretching. Decal projection clips receiver triangles and supports albedo/normal asset textures plus scalar roughness/metallicity; packed ORM, decal surface-layer filtering and explicit material-asset overrides are unavailable. Outline color and thickness use depth-occluded stencil silhouettes; through-wall outlines are unavailable. + +The existing material shader tone-maps before the resolved frame is post-processed. Effects grading decodes display colors for its calculations and re-encodes them. Bloom is a thresholded display-range filter; this is **not an HDR bloom pipeline**. Heat haze changes actual sampled framebuffer coordinates. GPU particle simulation is not implemented or advertised. CPU simulation is the supported path. + +Unsupported advanced options must not be inferred from a configuration field alone. Current boundaries include GPU particle simulation, runtime cubemap capture, six-face cubemap file layouts, temporal volumetric history, and motion-vector motion blur. Baked local probes use a 2:1 equirectangular HDR/PNG/JPEG asset registered through its `.rmeta` ID; their bounds blend local reflection input and support box parallax correction. Overlap chooses the highest-priority probe per mesh rather than a weighted sum of probes. `cubemap` names that panorama asset. The showcase supplies a generated HDR panorama and sidecar. Water uses the reusable Rust `WaterSurface::sample(world_position)` interface, returning displacement, normal and foam. `RippleWaterSurface` combines a bounded queue of disturbances; `water_surface_mesh` builds a displaced grid. Configure target/source velocity, wavelength, damping, wake angle, foam and normal strength. This is an analytical cosmetic surface model, not a fluid solver. Browser export must be checked independently; the native effect renderer does not establish browser support. + +## Performance and cleanup + +Use bounded particle/point/instance counts, shorter lifetimes and local volumes. Prefer one emitter containing many particles over thousands of effect entities. Keep transparent overdraw low: overlapping large smoke sprites and weather volumes can dominate frame time even with batching. Low quality tiers reduce cosmetic density; never use these tiers to gate gameplay damage or authoritative collisions. + +World effect state is keyed by stable entity IDs. Destroying entities or unloading scenes must release their simulation state. Playback does not permanently overwrite the authored camera or light configuration; frame modifiers are applied during rendering. Reuse configured entities for repeated bursts when practical. Asset instantiation clones authored data; it is not a GPU resource duplication guarantee. + +## Showcase and troubleshooting + +From the engine's `Engine/` directory: + +```sh +cargo run -p engine-project --example scene_effects_showcase -- /tmp/rustic-effects-showcase +``` + +The generator creates a normal project with a checksummed scene, all 26 component types, moving trail behavior and all 35 preset assets. Open that project in Project Manager. Scene entities have numbered names corresponding to the table above. Select a section and change its configuration, save and reopen to check persistence. Replay a one-shot effect with Restart. + +If no particles appear, check enabled, visible, auto-play, rate/bursts, lifetime, camera distance and the layer mask. If a trail is empty, move the emitting entity farther than its minimum vertex distance. If a target effect has no result, choose a valid target of the appropriate component type. If a preset cannot load, inspect its version and validation error; do not remove a scene checksum to force-load corrupted content. If a graphical backend is unavailable, CPU/configuration tests cannot replace visual verification. diff --git a/Engine/docs/SCENE_EFFECTS_REFERENCE.md b/Engine/docs/SCENE_EFFECTS_REFERENCE.md new file mode 100644 index 00000000..66a51436 --- /dev/null +++ b/Engine/docs/SCENE_EFFECTS_REFERENCE.md @@ -0,0 +1,517 @@ +# Effect configuration reference + +The table names match the typed scene configuration and scripting patches exactly. See [Scene Effects](/docs/guides/scene-effects) for attachment, tutorials, units, renderer support and limitations. Optional references default to `None` (null in JavaScript); vectors and colors use arrays. `Default::default()` refers to the shared default configuration described in that section. Values below are Rust default expressions; they are not JSON request syntax. + +Schema presence does not promise an advanced backend capability. Use the compatibility guide before relying on GPU simulation, water solvers, probe capture or temporal features. + +## Shared lifecycle + +Configuration type: `EffectCommon`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `parameters` | `std::collections::BTreeMap` | `Default::default()` | parameters | +| `bounds` | `Option` | `None` | bounds | +| `enabled` | `bool` | `true` | enabled | +| `visible` | `bool` | `true` | visible | +| `auto_play` | `bool` | `true` | auto play | +| `looping` | `bool` | `true` | looping | +| `duration` | `f32` | `5.0` | seconds | +| `delay` | `f32` | `0.0` | seconds | +| `time_scale` | `f32` | `1.0` | clock multiplier | +| `simulation_space` | `SimulationSpace` | `SimulationSpace::World` | simulation space | +| `attachment_target` | `Option` | `None` | attachment target | +| `visibility_layers` | `u32` | `u32::MAX` | unsigned bit mask | +| `render_distance` | `f32` | `1000.0` | scene units | +| `quality` | `QualityLevel` | `QualityLevel::Medium` | quality | +| `random_seed` | `u64` | `1` | deterministic unsigned seed | +| `priority` | `i32` | `0` | priority | + +## Shared appearance + +Configuration type: `EffectAppearance`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `color` | `Gradient` | `Gradient::default()` | RGBA linear color | +| `opacity` | `Curve` | `Curve::linear(1.0, 0.0)` | opacity | +| `size` | `Curve` | `Curve::constant(1.0)` | size | +| `material` | `Option` | `None` | material | +| `texture` | `Option` | `None` | texture | +| `emission` | `f32` | `1.0` | emission | +| `blend` | `BlendMode` | `BlendMode::Alpha` | blend | +| `uv_scale` | `[f32; 2]` | `[1.0; 2]` | uv scale | +| `uv_scroll` | `[f32; 2]` | `[0.0; 2]` | UV units/second | + +## Burst + +Configuration type: `Burst`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `time` | `f32` | `0.0` | time | +| `count` | `u32` | `20` | count | +| `probability` | `f32` | `1.0` | probability | +| `cycles` | `u32` | `1` | cycles | +| `interval` | `f32` | `1.0` | interval | + +## 1. Trail + +Configuration type: `TrailRenderer`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `emitting` | `bool` | `true` | emitting | +| `lifetime` | `f32` | `1.0` | seconds or [minimum, maximum] seconds | +| `width` | `f32` | `0.2` | scene units | +| `min_vertex_distance` | `f32` | `0.05` | min vertex distance | +| `max_points` | `u32` | `256` | max points | +| `teleport_distance` | `f32` | `20.0` | teleport distance | +| `camera_facing` | `bool` | `true` | camera facing | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 2. Particle + +Configuration type: `ParticleEmitter`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `prewarm` | `bool` | `false` | prewarm | +| `prewarm_time` | `f32` | `5.0` | prewarm time | +| `max_particles` | `u32` | `1000` | max particles | +| `rate` | `f32` | `30.0` | particles/second | +| `rate_over_distance` | `f32` | `0.0` | particles/scene unit | +| `bursts` | `Vec` | `vec![]` | bursts | +| `shape` | `SpawnShape` | `SpawnShape::Cone` | shape | +| `dimensions` | `[f32; 3]` | `[1.0; 3]` | scene units, XYZ | +| `surface_only` | `bool` | `false` | surface only | +| `lifetime` | `[f32; 2]` | `[1.0, 2.0]` | seconds or [minimum, maximum] seconds | +| `speed` | `[f32; 2]` | `[1.0, 3.0]` | scene units/second | +| `direction` | `[f32; 3]` | `[0.0, 1.0, 0.0]` | direction | +| `start_size` | `[f32; 2]` | `[0.1, 0.25]` | start size | +| `gravity` | `[f32; 3]` | `[0.0, -1.0, 0.0]` | gravity | +| `force` | `[f32; 3]` | `[0.0; 3]` | force | +| `drag` | `f32` | `0.1` | drag | +| `wind_influence` | `f32` | `1.0` | wind influence | +| `noise` | `f32` | `0.0` | noise | +| `angular_velocity` | `f32` | `0.0` | angular velocity | +| `inherit_velocity` | `f32` | `0.0` | inherit velocity | +| `velocity_limit` | `f32` | `1000.0` | velocity limit | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | +| `mesh` | `Option` | `None` | mesh | +| `mesh_spawn_triangles` | `Vec<[[f32; 3]; 3]>` | `Vec::new()` | mesh spawn triangles | +| `collision` | `bool` | `false` | collision | +| `collision_layers` | `u32` | `u32::MAX` | unsigned bit mask | +| `bounce` | `f32` | `0.5` | bounce | +| `friction` | `f32` | `0.1` | friction | +| `collision_response` | `CollisionResponse` | `CollisionResponse::Bounce` | collision response | +| `sub_emitter` | `Option` | `None` | sub emitter | +| `sub_emitter_probability` | `f32` | `1.0` | sub emitter probability | +| `max_generations` | `u8` | `3` | max generations | +| `flipbook` | `[u32; 2]` | `[1, 1]` | flipbook | +| `flipbook_fps` | `f32` | `0.0` | flipbook fps | +| `stretch` | `f32` | `0.0` | stretch | + +## 3. Beam + +Configuration type: `BeamRenderer`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `start_target` | `Option` | `None` | start target | +| `end_target` | `Option` | `None` | end target | +| `start` | `[f32; 3]` | `[0.0; 3]` | start | +| `end` | `[f32; 3]` | `[0.0, 3.0, 0.0]` | end | +| `width` | `f32` | `0.1` | scene units | +| `segments` | `u32` | `24` | segments | +| `noise_amplitude` | `f32` | `0.2` | noise amplitude | +| `noise_frequency` | `f32` | `3.0` | noise frequency | +| `noise_speed` | `f32` | `5.0` | noise speed | +| `branch_count` | `u32` | `0` | branch count | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 4. Shockwave + +Configuration type: `RingEffect`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `source_velocity` | `[f32; 3]` | `[0.0; 3]` | source velocity | +| `wake_angle` | `f32` | `0.6` | wake angle | +| `foam_intensity` | `f32` | `0.5` | foam intensity | +| `foam_lifetime` | `f32` | `1.0` | foam lifetime | +| `normal_strength` | `f32` | `1.0` | normal strength | +| `max_disturbances` | `u32` | `32` | max disturbances | +| `initial_radius` | `f32` | `0.1` | initial radius | +| `final_radius` | `f32` | `4.0` | final radius | +| `thickness` | `f32` | `0.2` | thickness | +| `vertical_offset` | `f32` | `0.01` | vertical offset | +| `amplitude` | `f32` | `0.1` | amplitude | +| `wavelength` | `f32` | `1.0` | wavelength | +| `damping` | `f32` | `1.0` | damping | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 5. MeshParticle + +Configuration type: `ParticleEmitter`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `prewarm` | `bool` | `false` | prewarm | +| `prewarm_time` | `f32` | `5.0` | prewarm time | +| `max_particles` | `u32` | `1000` | max particles | +| `rate` | `f32` | `30.0` | particles/second | +| `rate_over_distance` | `f32` | `0.0` | particles/scene unit | +| `bursts` | `Vec` | `vec![]` | bursts | +| `shape` | `SpawnShape` | `SpawnShape::Cone` | shape | +| `dimensions` | `[f32; 3]` | `[1.0; 3]` | scene units, XYZ | +| `surface_only` | `bool` | `false` | surface only | +| `lifetime` | `[f32; 2]` | `[1.0, 2.0]` | seconds or [minimum, maximum] seconds | +| `speed` | `[f32; 2]` | `[1.0, 3.0]` | scene units/second | +| `direction` | `[f32; 3]` | `[0.0, 1.0, 0.0]` | direction | +| `start_size` | `[f32; 2]` | `[0.1, 0.25]` | start size | +| `gravity` | `[f32; 3]` | `[0.0, -1.0, 0.0]` | gravity | +| `force` | `[f32; 3]` | `[0.0; 3]` | force | +| `drag` | `f32` | `0.1` | drag | +| `wind_influence` | `f32` | `1.0` | wind influence | +| `noise` | `f32` | `0.0` | noise | +| `angular_velocity` | `f32` | `0.0` | angular velocity | +| `inherit_velocity` | `f32` | `0.0` | inherit velocity | +| `velocity_limit` | `f32` | `1000.0` | velocity limit | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | +| `mesh` | `Option` | `None` | mesh | +| `mesh_spawn_triangles` | `Vec<[[f32; 3]; 3]>` | `Vec::new()` | mesh spawn triangles | +| `collision` | `bool` | `false` | collision | +| `collision_layers` | `u32` | `u32::MAX` | unsigned bit mask | +| `bounce` | `f32` | `0.5` | bounce | +| `friction` | `f32` | `0.1` | friction | +| `collision_response` | `CollisionResponse` | `CollisionResponse::Bounce` | collision response | +| `sub_emitter` | `Option` | `None` | sub emitter | +| `sub_emitter_probability` | `f32` | `1.0` | sub emitter probability | +| `max_generations` | `u8` | `3` | max generations | +| `flipbook` | `[u32; 2]` | `[1, 1]` | flipbook | +| `flipbook_fps` | `f32` | `0.0` | flipbook fps | +| `stretch` | `f32` | `0.0` | stretch | + +## 6. Ribbon + +Configuration type: `RibbonRenderer`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `points` | `Vec<[f32; 3]>` | `vec![[0.0; 3], [1.0, 1.0, 0.0], [2.0, 0.0, 0.0]]` | points | +| `width` | `f32` | `0.2` | scene units | +| `tube` | `bool` | `false` | tube | +| `radial_segments` | `u32` | `8` | radial segments | +| `subdivisions` | `u32` | `8` | subdivisions | +| `twist` | `f32` | `0.0` | twist | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 7. ForceField + +Configuration type: `ForceField`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `shape` | `VolumeShape` | `VolumeShape::Sphere` | shape | +| `dimensions` | `[f32; 3]` | `[5.0; 3]` | scene units, XYZ | +| `force_type` | `ForceType` | `ForceType::Directional` | force type | +| `direction` | `[f32; 3]` | `[1.0, 0.0, 0.0]` | direction | +| `strength` | `f32` | `1.0` | strength | +| `falloff` | `f32` | `1.0` | falloff | +| `turbulence` | `f32` | `0.0` | turbulence | +| `frequency` | `f32` | `1.0` | cycles/second | +| `layers` | `u32` | `u32::MAX` | unsigned bit mask | +| `global` | `bool` | `false` | global | + +## 8. CollisionVolume + +Configuration type: `CollisionVolume`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `shape` | `VolumeShape` | `VolumeShape::Plane` | shape | +| `dimensions` | `[f32; 3]` | `[10.0; 3]` | scene units, XYZ | +| `layers` | `u32` | `u32::MAX` | unsigned bit mask | +| `bounce` | `f32` | `0.5` | bounce | +| `friction` | `f32` | `0.1` | friction | +| `response` | `CollisionResponse` | `CollisionResponse::Bounce` | response | +| `impact_effect` | `Option` | `None` | impact effect | + +## 9. ProjectileTracer + +Configuration type: `ProjectileTracer`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `velocity` | `[f32; 3]` | `[0.0, 0.0, 10.0]` | velocity | +| `length` | `f32` | `2.0` | length | +| `width` | `f32` | `0.05` | scene units | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 10. Fog + +Configuration type: `FogVolume`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `shape` | `VolumeShape` | `VolumeShape::Box` | shape | +| `dimensions` | `[f32; 3]` | `[10.0, 3.0, 10.0]` | scene units, XYZ | +| `density` | `f32` | `0.1` | density | +| `color` | `[f32; 3]` | `[0.6, 0.7, 0.8]` | RGBA linear color | +| `height_falloff` | `f32` | `0.5` | height falloff | +| `edge_falloff` | `f32` | `1.0` | edge falloff | +| `noise_scale` | `f32` | `1.0` | noise scale | +| `noise_speed` | `f32` | `0.1` | noise speed | +| `anisotropy` | `f32` | `0.0` | anisotropy | +| `steps` | `u32` | `32` | steps | + +## 11. Decal + +Configuration type: `DecalProjector`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `dimensions` | `[f32; 3]` | `[1.0, 1.0, 1.0]` | scene units, XYZ | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | +| `normal_map` | `Option` | `None` | normal map | +| `orm_map` | `Option` | `None` | orm map | +| `roughness` | `f32` | `0.8` | roughness | +| `metallic` | `f32` | `0.0` | metallic | +| `edge_fade` | `f32` | `0.1` | edge fade | +| `angle_fade` | `f32` | `0.5` | angle fade | +| `layers` | `u32` | `u32::MAX` | unsigned bit mask | + +## 12. Weather + +Configuration type: `WeatherVolume`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `weather` | `WeatherType` | `WeatherType::Rain` | weather | +| `emitter` | `ParticleEmitter` | `Box, [20,10,20], downward [8,10], rate 200, lifetime [2,3]` | emitter | +| `intensity` | `f32` | `1.0` | intensity | +| `camera_follow` | `bool` | `false` | camera follow | + +## 13. WaterRipple + +Configuration type: `RingEffect`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `source_velocity` | `[f32; 3]` | `[0.0; 3]` | source velocity | +| `wake_angle` | `f32` | `0.6` | wake angle | +| `foam_intensity` | `f32` | `0.5` | foam intensity | +| `foam_lifetime` | `f32` | `1.0` | foam lifetime | +| `normal_strength` | `f32` | `1.0` | normal strength | +| `max_disturbances` | `u32` | `32` | max disturbances | +| `initial_radius` | `f32` | `0.1` | initial radius | +| `final_radius` | `f32` | `4.0` | final radius | +| `thickness` | `f32` | `0.2` | thickness | +| `vertical_offset` | `f32` | `0.01` | vertical offset | +| `amplitude` | `f32` | `0.1` | amplitude | +| `wavelength` | `f32` | `1.0` | wavelength | +| `damping` | `f32` | `1.0` | damping | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 14. Wind + +Configuration type: `ForceField`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `shape` | `VolumeShape` | `VolumeShape::Sphere` | shape | +| `dimensions` | `[f32; 3]` | `[5.0; 3]` | scene units, XYZ | +| `force_type` | `ForceType` | `ForceType::Directional` | force type | +| `direction` | `[f32; 3]` | `[1.0, 0.0, 0.0]` | direction | +| `strength` | `f32` | `1.0` | strength | +| `falloff` | `f32` | `1.0` | falloff | +| `turbulence` | `f32` | `0.0` | turbulence | +| `frequency` | `f32` | `1.0` | cycles/second | +| `layers` | `u32` | `u32::MAX` | unsigned bit mask | +| `global` | `bool` | `false` | global | + +## 15. HeatHaze + +Configuration type: `DistortionEffect`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `strength` | `f32` | `0.01` | strength | +| `noise_scale` | `f32` | `10.0` | noise scale | +| `speed` | `f32` | `1.0` | scene units/second | +| `radius` | `f32` | `3.0` | scene units | +| `edge_falloff` | `f32` | `1.0` | edge falloff | + +## 16. LightShaft + +Configuration type: `LightShaft`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `light` | `Option` | `None` | light | +| `intensity` | `f32` | `0.5` | intensity | +| `color` | `[f32; 3]` | `[1.0, 0.9, 0.6]` | RGBA linear color | +| `density` | `f32` | `0.1` | density | +| `anisotropy` | `f32` | `0.5` | anisotropy | +| `max_distance` | `f32` | `100.0` | max distance | +| `samples` | `u32` | `32` | samples | + +## 17. FoliageScatter + +Configuration type: `FoliageScatter`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `meshes` | `Vec` | `vec![]` | meshes | +| `density` | `f32` | `1.0` | density | +| `dimensions` | `[f32; 3]` | `[10.0, 1.0, 10.0]` | scene units, XYZ | +| `scale` | `[f32; 2]` | `[0.5, 1.5]` | scale | +| `max_instances` | `u32` | `1000` | max instances | +| `wind_response` | `f32` | `1.0` | wind response | +| `appearance` | `EffectAppearance` | `EffectAppearance::default()` | appearance | + +## 18. Dissolve + +Configuration type: `DissolveEffect`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `progress` | `f32` | `0.0` | progress | +| `edge_width` | `f32` | `0.05` | edge width | +| `edge_color` | `[f32; 3]` | `[1.0, 0.4, 0.0]` | edge color | +| `edge_emission` | `f32` | `3.0` | edge emission | +| `noise_scale` | `f32` | `10.0` | noise scale | +| `curve` | `Curve` | `Curve::linear(0.0, 1.0)` | curve | + +## 19. AnimatedSurface + +Configuration type: `AnimatedSurface`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `uv_scroll` | `[f32; 2]` | `[0.2, 0.0]` | UV units/second | +| `uv_scale` | `[f32; 2]` | `[1.0; 2]` | uv scale | +| `uv_rotation` | `f32` | `0.0` | uv rotation | +| `color` | `Gradient` | `Gradient::default()` | RGBA linear color | +| `emission` | `f32` | `1.0` | emission | +| `speed` | `f32` | `1.0` | scene units/second | + +## 20. LensFlare + +Configuration type: `LensFlare`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `light` | `Option` | `None` | light | +| `intensity` | `f32` | `1.0` | intensity | +| `scale` | `f32` | `0.1` | scale | +| `color` | `[f32; 3]` | `[1.0, 0.8, 0.5]` | RGBA linear color | +| `ghosts` | `u32` | `4` | ghosts | +| `ghost_spacing` | `f32` | `0.2` | ghost spacing | +| `streak_length` | `f32` | `0.3` | streak length | +| `fade_speed` | `f32` | `5.0` | fade speed | +| `texture` | `Option` | `None` | texture | + +## 21. LightPulse + +Configuration type: `LightPulse`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `intensity` | `Curve` | `Curve::linear(0.0, 1.0)` | intensity | +| `color` | `Gradient` | `Gradient::default()` | RGBA linear color | +| `frequency` | `f32` | `1.0` | cycles/second | +| `randomness` | `f32` | `0.0` | randomness | +| `phase` | `f32` | `0.0` | phase | + +## 22. PostProcess + +Configuration type: `PostProcessVolume`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `global` | `bool` | `true` | global | +| `dimensions` | `[f32; 3]` | `[10.0; 3]` | scene units, XYZ | +| `blend_distance` | `f32` | `1.0` | blend distance | +| `weight` | `f32` | `1.0` | weight | +| `exposure` | `f32` | `0.0` | exposure | +| `saturation` | `f32` | `1.0` | saturation | +| `contrast` | `f32` | `1.0` | contrast | +| `brightness` | `f32` | `0.0` | brightness | +| `tint` | `[f32; 3]` | `[1.0; 3]` | tint | +| `bloom` | `f32` | `0.0` | bloom | +| `vignette` | `f32` | `0.0` | vignette | +| `chromatic_aberration` | `f32` | `0.0` | chromatic aberration | +| `grain` | `f32` | `0.0` | grain | +| `depth_of_field` | `f32` | `0.0` | depth of field | + +## 23. Outline + +Configuration type: `OutlineEffect`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `targets` | `Vec` | `vec![]` | targets | +| `color` | `[f32; 4]` | `[1.0, 0.6, 0.0, 1.0]` | RGBA linear color | +| `thickness` | `f32` | `0.03` | thickness | +| `pulse_speed` | `f32` | `0.0` | pulse speed | +| `through_walls` | `bool` | `false` | through walls | + +## 24. CameraShake + +Configuration type: `CameraShake`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `position_amplitude` | `[f32; 3]` | `[0.1; 3]` | scene units, XYZ | +| `rotation_amplitude` | `[f32; 3]` | `[0.02; 3]` | radians, XYZ | +| `frequency` | `f32` | `20.0` | cycles/second | +| `falloff` | `Curve` | `Curve::linear(1.0, 0.0)` | falloff | +| `radius` | `f32` | `20.0` | scene units | + +## 25. ScreenFlash + +Configuration type: `ScreenFlash`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `target` | `Option` | `None` | target | +| `color` | `[f32; 4]` | `[1.0, 0.0, 0.0, 1.0]` | RGBA linear color | +| `opacity` | `f32` | `0.5` | opacity | +| `fade_in` | `f32` | `0.05` | fade in | +| `fade_out` | `f32` | `0.4` | fade out | +| `vignette` | `f32` | `0.5` | vignette | +| `blend` | `BlendMode` | `BlendMode::Alpha` | blend | + +## 26. ReflectionProbe + +Configuration type: `ReflectionProbe`. + +| Field | Type | Default | Units / interpretation | +| --- | --- | --- | --- | +| `dimensions` | `[f32; 3]` | `[10.0; 3]` | scene units, XYZ | +| `cubemap` | `Option` | `None` | cubemap | +| `resolution` | `u32` | `128` | resolution | +| `refresh` | `ProbeRefresh` | `ProbeRefresh::Baked` | refresh | +| `blend_distance` | `f32` | `1.0` | blend distance | +| `update_interval` | `f32` | `1.0` | update interval | +| `parallax_correction` | `bool` | `false` | parallax correction | + +## Source asset identity + +`EffectComponent.source` optionally stores `{asset, child}`: the existing stable asset UUID and zero-based child index. Embedded configuration is the instance override. Restore resolves the source through its adjacent `.rmeta` identity and reapplies the selected child defaults. Keep the sidecar when moving or renaming an asset; replacing the UUID breaks existing references. Runtime imports use importer `rustic.effect`, version 1. + +## Validated limits + +Effect assets support version 1, one to 128 children, and at most 4 MiB. Duration must be positive; delay, speed and distance must be finite and nonnegative. Particle capacity is bounded at 100,000 and burst arrays at 1024. Particle lifetimes must be positive and ordered. Trail capacity is 2 to 65,536 points; beam segments are 1 to 4096. Keep curve and gradient values finite. The runtime applies additional safety bounds to generated geometry, simulation and event recursion. + +## Composite child metadata + +EffectAsset version 1 optionally stores `entity_ids` and `transforms`, each empty or the same length as `effects`. IDs must be unique; transforms are column-major finite nonsingular TRS matrices relative to the group. `instances_with_ids(asset_id)` creates independent child snapshots, remaps internal targets to fresh IDs and records the remapped source baseline. Missing metadata preserves legacy identity transforms. diff --git a/Engine/docs/SCENE_EFFECTS_RESEARCH.md b/Engine/docs/SCENE_EFFECTS_RESEARCH.md new file mode 100644 index 00000000..a4b61ec2 --- /dev/null +++ b/Engine/docs/SCENE_EFFECTS_RESEARCH.md @@ -0,0 +1,26 @@ +# Scene Effects research and acceptance record + +## Architecture verified before implementation + +The scene model uses stable entity IDs, typed Bevy components, schema-specific component payloads, recoverable loading and a checksum-protected RON scene envelope. Inspector transactions operate on document commands and undo records, independently of widget state. Effect configuration follows that path; playback state belongs to an isolated runtime. + +The existing renderer has PBR mesh/surface-map support, a retained native wgpu viewport and per-fragment tone mapping into an LDR target. It did not provide an HDR postprocessing graph or GPU particle simulation. New display-range postprocessing therefore cannot be described as HDR bloom. Existing assets use adjacent authoritative `.rmeta` files, stable asset IDs and a disposable derived cache; `.effect` imports extend that registry. + +## Sources and lessons + +- [Official Rustic documentation source](https://github.com/Rustic-Game-Engine/docs/blob/main/content/user/editor.md): editor/Play isolation, transform conventions (+Z camera direction), hierarchy, Inspector and attachment workflows. Read using GitHub API after the hosted documentation returned HTTP 403. +- [Official Godot particle properties](https://github.com/godotengine/godot-docs/blob/master/tutorials/3d/particles/properties.rst): distinguish emission enablement from capacity, lifetime and playback speed; cap visible particles, use normalized lifetime appearance and explicit one-shot emission. Read through GitHub API because the documentation site returned HTTP 403. +- [Unreal Niagara overview](https://dev.epicgames.com/documentation/en-us/unreal-engine/overview-of-niagara-effects-for-unreal-engine): requested official endpoint returned HTTP 403; no claim is made that the remote text was successfully read. The public EpicGames/UnrealEngine repository was unavailable to this credential (HTTP 404). No proprietary source was copied. +- Checked-in `ARCHITECTURE.md`, `SURFACE_TEXTURES.md`, `SCENE_ENVIRONMENT.md`, `SCRIPTING_EXPANSION_STATUS.md`, scene/world/render/undo source, asset importer/metadata source and the native editor implementation establish actual target-branch behavior. + +Implementation uses the existing editor UI stack and material conventions. Specialized render extraction is shared with CPU simulation rather than forcing fog, overlays and ribbons through a single emitter backend. + +## Acceptance audit + +The user requested a production-quality implementation of every listed property, polished presets, all 26 effects, native editor tools, script controls, persistence, graphics verification and a Windows installer. Registration, serialization and configuration tests alone do not satisfy that whole specification. + +Implemented modules and their exact defaults are listed in `SCENE_EFFECTS_REFERENCE.md`. Renderer boundaries, unsupported advanced capabilities and setup instructions are listed in `SCENE_EFFECTS.md`. The generated showcase scene is produced by `cargo run -p engine-project --example scene_effects_showcase -- ` through public scene/project APIs; it is not a disconnected renderer mockup. + +Backend limitations to report in the final delivery include GPU particle simulation, runtime reflection capture, six-face cubemap layout, temporal volumetric history and motion-vector blur. These optional unsupported paths do not establish missing support for an unrelated implemented effect. The new reusable WaterSurface sampler supplies analytical displacement, normals and foam; a full fluid solver is not implied. Display-range bloom cannot satisfy a request for HDR bloom. Interactive editor workflows and native/device/browser visual coverage require their own evidence, and a Linux cargo test run cannot establish that a fresh Windows installer was produced. + +The final delivery must distinguish supported effects, optional backend limitations and required acceptance gates that remain unverified. In particular, component registration and CPU tests do not establish visual quality, complete interactive editor behavior or a working Windows installer. diff --git a/Engine/docs/Scripting/API/effects.md b/Engine/docs/Scripting/API/effects.md index 7333f273..8947742c 100644 --- a/Engine/docs/Scripting/API/effects.md +++ b/Engine/docs/Scripting/API/effects.md @@ -41,3 +41,8 @@ globalThis.behavior = { - Shake uses deterministic decaying offsets. Avoid overlapping actions or physics that write the same property. Camera.shake uses the same implementation. - Check script attachment, enabled state and the Console when an operation fails. Asynchronous errors also appear in operation state. Callback exceptions disable the owner and clean up its actions. - Owner destruction, disable, successful reload or Stop cleans up owned actions and subscriptions. Failed reload retains the previous behavior's operations. See [Operation handles](/docs/api/operation-handles) for controls and lifetime details. + + +## Typed scene effects + +The same `Effects` service also creates and controls typed particles, trails, weather and camera effects. See [Scene Effects API](/docs/api/scene-effects) for component controls, configuration patches and backend limitations. The tween helpers above retain their existing behavior. diff --git a/Engine/docs/Scripting/API/scene-effects.md b/Engine/docs/Scripting/API/scene-effects.md new file mode 100644 index 00000000..df096aaa --- /dev/null +++ b/Engine/docs/Scripting/API/scene-effects.md @@ -0,0 +1,86 @@ +# Scene Effects API + +Use `Effects` to control one typed effect component on an existing scene entity. The same global retains the existing `fade`, `flash`, `shake` and `pulse` tween helpers. Read the [authoring guide](/docs/guides/scene-effects) for editor steps, all 26 kind names, presets, composites and backend limitations. + +## Setup and attachment + +Create a Lua or JavaScript **Object Component Script** through Programming, attach it to an entity in the Inspector, and press Play. Python has equivalent `Effects` wrappers in the engine-provided SDK; import `Effects` from `rustic`. Other SDKs use the existing generic `rustic.query` boundary with the request operations below. Do not install a third-party package named rustic. + +## Signatures + +| Call | Result | +| --- | --- | +| `Effects.types()` | Array of the 26 case-sensitive kind keys | +| `Effects.create(entity, kind)` | Creates an effect and returns its configuration; existing effect is an error | +| `Effects.get(entity)` | Configuration, or null/nil when no component exists | +| `Effects.configure(entity, settings)` | Validated recursive patch and resulting configuration | +| `Effects.play(entity)` | Begins playback | +| `Effects.pause(entity)` | Freezes playback | +| `Effects.resume(entity)` | Resumes playback | +| `Effects.stop(entity)` | Stops playback | +| `Effects.restart(entity)` | Clears previous simulation and starts again | +| `Effects.reset(entity)` | Clears simulation and restores the initial playback state | +| `Effects.dispose(entity)` | Removes effect component and runtime state, preserving the entity | +| `Effects.state(entity)` | Playback state string, or null/nil | +| `Effects.instantiate(asset, parent, position)` | Composite root UUID, using registered asset UUID, optional parent and default zero position | +| `Effects.spawn(asset, position, lifetime, parent)` | Temporary root UUID; explicit positive lifetime schedules whole group destruction | +| `Effects.sampleWind(position, time, layers)` | XYZ wind vector; time in seconds; layers defaults to all bits | + +Entity arguments accept the existing resolved entity forms. Lua `nil` or JavaScript `null` chooses the script owner. UI/global script owners may be synthetic; pass a real scene entity for those scopes. Raw protocol requests require stable entity UUID strings. Playback states are `Stopped`, `Playing`, `Paused`, `Completed` and `Disposed`. Disposal removes the component, so subsequent component state reads return null/nil. + +## Complete Lua example + +This example requires an entity without an existing effect. For an authored emitter, remove `Effects.create`. + +```lua +return { + Start = function() + Effects.create(nil, "Particle") + Effects.configure(nil, { + common = {duration = 2, looping = false}, + kind = {Particle = { + rate = 40, + lifetime = {0.5, 1.0}, + gravity = {0, -2, 0} + }} + }) + Effects.restart(nil) + end +} +``` + +Expected result: an upward emitter runs for its configured duration. Restart repeats it from cleared simulation state. Changing settings is a live Play mutation; saving the authored scene requires Inspector changes outside Play. + +## JavaScript and Python + +```javascript +globalThis.behavior = { + Start() { + Effects.create(null, "Weather"); + Effects.configure(null, {kind: {Weather: {intensity: 0.3}}}); + Effects.restart(null); + } +}; +``` + +```python +from rustic import Effects + +def on_start(): + Effects.create(None, "Wind") + Effects.configure(None, {"kind": {"Wind": {"strength": 3.0}}}) +``` + +Use the generated Python starter's callback registration pattern if it differs from this callback body. The wind example changes compatible particles; a wind volume has no standalone visible mesh. + +## Events and generic requests + +The existing Events service carries `EffectBirth`, `EffectCollision`, `EffectDeath` and `EffectCompleted`. Subscribe with `Events.connect(entity, name, callback)`; payload includes a world-position vector. Composite roots emit `EffectCompleted` once per completion cycle after child effects complete. Restart/reset starts a new cycle. Keep callbacks small and avoid recursively creating unbounded effects. Sub-emitter recursion is separately bounded by the runtime generation limit. + +Generic operations are `effect_types`, `effect_create`, `effect_get`, `effect_configure`, `effect_control`, `effect_state` `effect_wind`, `effect_instantiate` and `effect_spawn`. For example, `rustic.query({op:'effect_create',entity:id,kind:'Particle'})` is equivalent to creation through the wrapper. Configuration requests use `settings`; controls use an `action` string such as `pause`. `effect_instantiate` accepts asset UUID, optional parent and position; `effect_spawn` requires position and a positive lifetime so a missing lifetime cannot silently create a persistent instance. Use the native query syntax for your language; do not construct private transport messages yourself. + +## Validation and lifetime + +Configuration has the shape `{source, common, kind}`. Kind settings are nested under the variant key, for example `{kind:{Particle:{rate:50}}}`. Unknown fields and invalid values return an error before the world transaction is committed. Use the [typed reference](/docs/guides/scene-effects/configuration) for exact fields, defaults and limits. + +Effect configuration survives scene serialization when authored. Runtime clocks, live particles, script mutations and event queues do not become permanent scene edits. Entity destruction and scene unload release effect state. One component is allowed per entity; composite assets instantiate children as ordinary scene effects. Controls and state can target the composite group root; disposal removes descendant effect components and preserves the entity hierarchy. This API does not expose GPU particle simulation or automatically make cosmetic particle collisions authoritative gameplay physics. diff --git a/Engine/examples/scene-effects/README.md b/Engine/examples/scene-effects/README.md new file mode 100644 index 00000000..0d228026 --- /dev/null +++ b/Engine/examples/scene-effects/README.md @@ -0,0 +1,15 @@ +# Scene Effects showcase + +Generate a normal Rustic project through the public scene/project APIs: + +```sh +cargo run -p engine-project --example scene_effects_showcase -- /tmp/rustic-effects-showcase +``` + +Run this command from `Engine/`. The destination must be empty; the generator never replaces an existing project. Open the generated `project.engine` through Project Manager. The scene contains numbered entities for all 26 component types, targets and lights, a moving Lua trail/ripple demonstration, procedural debris/foliage OBJ meshes, a baked HDR probe panorama and 35 editable `.effect` assets with authoritative `.rmeta` sidecars. The generator reloads its checksummed scene and verifies 26 effect components before succeeding. + +Select an effect in the hierarchy and use Properties to change settings. **Effect preview** contains Play/Pause/Resume/Stop/Restart/Reset, frame stepping, speed, deterministic time replay, bounds and metrics. Press Play to run the attached orbit behavior and scene effects. Restart one-shot effects from preview. Stop Play before saving authored changes. + +The Inspector's **Effects presets & assets** loads/saves assets and instantiates child groups. Select a composite group to edit its child timeline; source children expose restoring asset defaults and updating the shared source file. Built-in presets are editable authored data with no external downloads or hotlinked assets. + +Read `docs/SCENE_EFFECTS.md` and `docs/SCENE_EFFECTS_RESEARCH.md` for API setup, compatibility and the acceptance audit. A scene containing all 26 registered components does not prove every requested advanced renderer capability is implemented. GPU particle simulation, dynamic probe capture, temporal history, motion-vector blur remain outside the implemented paths. Native/device/browser visual validation is separate from scene load and CPU tests. diff --git a/Engine/examples/scene-effects/project/.gitignore b/Engine/examples/scene-effects/project/.gitignore new file mode 100644 index 00000000..312eb070 --- /dev/null +++ b/Engine/examples/scene-effects/project/.gitignore @@ -0,0 +1,14 @@ +/.rustic/generated/ +/project.engine.bak +!/logs/ +logs/* +!logs/.gitkeep +!/builds/ +builds/* +!builds/.gitkeep +!/cache/ +cache/* +!cache/.gitkeep +!/temp/ +temp/* +!temp/.gitkeep diff --git a/Engine/examples/scene-effects/project/Scene Effects Showcase.code-workspace b/Engine/examples/scene-effects/project/Scene Effects Showcase.code-workspace new file mode 100644 index 00000000..529be695 --- /dev/null +++ b/Engine/examples/scene-effects/project/Scene Effects Showcase.code-workspace @@ -0,0 +1,8 @@ +{ + "//": "Generated by Rustic Game Engine. Safe to regenerate.", + "folders": [{ "path": "." }], + "settings": { + "Lua.workspace.library": [".rustic/generated/programming"], + "C_Cpp.default.includePath": ["${workspaceFolder}/.rustic/generated/programming"] + } +} diff --git a/Engine/examples/scene-effects/project/assets/debris.obj b/Engine/examples/scene-effects/project/assets/debris.obj new file mode 100644 index 00000000..d1d112e4 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/debris.obj @@ -0,0 +1,8 @@ +v 0 0.7 0 +v -0.4 0 -0.3 +v 0.4 0 -0.3 +v 0 0 0.5 +f 1 3 2 +f 1 4 3 +f 1 2 4 +f 2 3 4 diff --git a/Engine/examples/scene-effects/project/assets/debris.obj.rmeta b/Engine/examples/scene-effects/project/assets/debris.obj.rmeta new file mode 100644 index 00000000..a45f775c --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/debris.obj.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "1591b699-988f-4eb1-82e9-298b6e41bfde", + importer_id: "rustic.obj", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect b/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect new file mode 100644 index 00000000..2c8ffd03 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Bullet impact", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 30, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Hemisphere, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.1, 0.4), + speed: (1.0, 3.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.02, 0.05), + gravity: (0.0, -9.81, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.6, 0.2, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: true, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta new file mode 100644 index 00000000..5895adf0 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "556e9cca-96d8-4e67-a254-e0b581baf7e4", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/campfire.effect b/Engine/examples/scene-effects/project/assets/effects/campfire.effect new file mode 100644 index 00000000..1153b519 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/campfire.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Campfire", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 65.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.5, 1.2), + speed: (0.8, 2.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.12, 0.3), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.25, 0.025, 0.8), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta new file mode 100644 index 00000000..de90ddd7 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "15830234-1f50-425b-b9d2-03146008756a", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect b/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect new file mode 100644 index 00000000..bb8c4bbf --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect @@ -0,0 +1,34 @@ +( + version: 1, + name: "Character highlight", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Outline(( + targets: [], + color: (1.0, 0.6, 0.0, 1.0), + thickness: 0.03, + pulse_speed: 0.0, + through_walls: false, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta new file mode 100644 index 00000000..d1ec52d8 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "fc36161e-1bc8-4fd5-b0ef-0046430e4881", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect b/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect new file mode 100644 index 00000000..0e239dee --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Collision sparks", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 30, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Hemisphere, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.2, 0.6), + speed: (2.0, 6.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.02, 0.04), + gravity: (0.0, -9.81, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.6, 0.15, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: true, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta new file mode 100644 index 00000000..17e4666e --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "3a940516-3702-4774-bd75-bded31dbf792", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect b/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect new file mode 100644 index 00000000..6f520695 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect @@ -0,0 +1,36 @@ +( + version: 1, + name: "Damage flash", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: ScreenFlash(( + target: None, + color: (1.0, 0.0, 0.0, 1.0), + opacity: 0.5, + fade_in: 0.05, + fade_out: 0.4, + vignette: 0.5, + blend: Alpha, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta new file mode 100644 index 00000000..9089a62b --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "1a2e3836-eba5-4186-bb44-66546e7b19f6", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/dust.effect b/Engine/examples/scene-effects/project/assets/effects/dust.effect new file mode 100644 index 00000000..f1d010fa --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/dust.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Dust", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 12.0, + rate_over_distance: 0.0, + bursts: [], + shape: Box, + dimensions: (8.0, 4.0, 8.0), + surface_only: false, + lifetime: (3.0, 6.0), + speed: (0.05, 0.15), + direction: (0.0, 1.0, 0.0), + start_size: (0.08, 0.15), + gravity: (0.0, 0.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.3, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.6, 0.5, 0.3, 0.15), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta new file mode 100644 index 00000000..35ada45d --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "f0a7fffa-9659-4045-9faf-4d05b81b91f1", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/embers.effect b/Engine/examples/scene-effects/project/assets/effects/embers.effect new file mode 100644 index 00000000..3d413d3e --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/embers.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Embers", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 12.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (2.0, 4.0), + speed: (0.4, 0.8), + direction: (0.0, 1.0, 0.0), + start_size: (0.02, 0.05), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.18, 0.02, 0.9), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta new file mode 100644 index 00000000..68d0d4e9 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "ec135fb4-526b-45d1-a994-c95bd7320ba5", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect b/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect new file mode 100644 index 00000000..5c93b10e --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect @@ -0,0 +1,125 @@ +( + version: 1, + name: "Energy shield", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Ribbon(( + points: [ + (2.0, 0.0, 0.0), + (1.9828898, 0.2610524, 0.0), + (1.9318516, 0.5176381, 0.0), + (1.847759, 0.7653669, 0.0), + (1.7320508, 1.0, 0.0), + (1.5867066, 1.2175229, 0.0), + (1.4142135, 1.4142135, 0.0), + (1.2175229, 1.5867066, 0.0), + (0.99999994, 1.7320509, 0.0), + (0.76536685, 1.847759, 0.0), + (0.51763815, 1.9318516, 0.0), + (0.26105225, 1.9828898, 0.0), + (-0.00000008742278, 2.0, 0.0), + (-0.26105267, 1.9828897, 0.0), + (-0.5176381, 1.9318516, 0.0), + (-0.76536703, 1.847759, 0.0), + (-1.0000001, 1.7320508, 0.0), + (-1.2175229, 1.5867066, 0.0), + (-1.4142135, 1.4142135, 0.0), + (-1.586707, 1.2175225, 0.0), + (-1.7320508, 1.0000001, 0.0), + (-1.8477592, 0.76536655, 0.0), + (-1.9318517, 0.51763785, 0.0), + (-1.9828898, 0.26105216, 0.0), + (-2.0, -0.00000017484555, 0.0), + (-1.9828897, -0.26105252, 0.0), + (-1.9318515, -0.5176386, 0.0), + (-1.847759, -0.76536685, 0.0), + (-1.7320508, -0.99999994, 0.0), + (-1.5867064, -1.2175232, 0.0), + (-1.4142133, -1.4142138, 0.0), + (-1.217523, -1.5867065, 0.0), + (-0.9999998, -1.7320509, 0.0), + (-0.76536626, -1.8477594, 0.0), + (-0.51763797, -1.9318517, 0.0), + (-0.26105183, -1.9828898, 0.0), + (0.000000023849761, -2.0, 0.0), + (0.26105282, -1.9828897, 0.0), + (0.5176389, -1.9318514, 0.0), + (0.7653672, -1.8477589, 0.0), + (0.9999998, -1.7320509, 0.0), + (1.2175231, -1.5867065, 0.0), + (1.414214, -1.4142131, 0.0), + (1.5867068, -1.2175227, 0.0), + (1.7320511, -0.9999995, 0.0), + (1.8477591, -0.76536685, 0.0), + (1.9318517, -0.51763767, 0.0), + (1.9828899, -0.2610515, 0.0), + (2.0, 0.0000003496911, 0.0), + ], + width: 0.08, + tube: false, + radial_segments: 8, + subdivisions: 8, + twist: 0.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.1, 0.6, 1.0, 0.8), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta new file mode 100644 index 00000000..864c28ee --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "288e00fa-5d76-41e8-af3b-3edd808cfdb3", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect b/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect new file mode 100644 index 00000000..d63e3a28 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Exhaust smoke", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 20.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.8, 1.8), + speed: (0.5, 1.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.08, 0.2), + gravity: (0.0, 0.2, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.3, 0.3, 0.35, 0.25), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta new file mode 100644 index 00000000..899f3f09 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "bb0f98a8-afbf-4f8b-adb4-849c5d799e4a", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/explosion.effect b/Engine/examples/scene-effects/project/assets/effects/explosion.effect new file mode 100644 index 00000000..e2cec859 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/explosion.effect @@ -0,0 +1,325 @@ +( + version: 1, + name: "Explosion", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 3.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 100, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Sphere, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.2, 0.8), + speed: (2.0, 6.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.15, 0.45), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.3, 0.025, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.15, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 18.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (3.0, 5.0), + speed: (0.2, 0.6), + direction: (0.0, 1.0, 0.0), + start_size: (0.3, 0.6), + gravity: (0.0, 0.2, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.25, 0.28, 0.32, 0.25), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 0.6, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Shockwave(( + target: None, + source_velocity: (0.0, 0.0, 0.0), + wake_angle: 0.6, + foam_intensity: 0.5, + foam_lifetime: 1.0, + normal_strength: 1.0, + max_disturbances: 32, + initial_radius: 0.1, + final_radius: 4.0, + thickness: 0.2, + vertical_offset: 0.01, + amplitude: 0.1, + wavelength: 1.0, + damping: 1.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 1.0, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 0.4, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: CameraShake(( + target: None, + position_amplitude: (0.1, 0.1, 0.1), + rotation_amplitude: (0.02, 0.02, 0.02), + frequency: 20.0, + falloff: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + radius: 20.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta new file mode 100644 index 00000000..04021094 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "8c263d4c-c2bb-476c-81c4-0f45156d6928", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect b/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect new file mode 100644 index 00000000..3babbd0d --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Fire sparks", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 25.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.4, 1.5), + speed: (2.0, 5.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.015, 0.04), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.55, 0.05, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta new file mode 100644 index 00000000..36ede48f --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "7cc64262-6e4f-4998-b0f9-07b60a2ba3bf", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/fireflies.effect b/Engine/examples/scene-effects/project/assets/effects/fireflies.effect new file mode 100644 index 00000000..a1f9e1a6 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/fireflies.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Fireflies", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 8.0, + rate_over_distance: 0.0, + bursts: [], + shape: Box, + dimensions: (8.0, 4.0, 8.0), + surface_only: false, + lifetime: (3.0, 5.0), + speed: (0.05, 0.2), + direction: (0.0, 1.0, 0.0), + start_size: (0.02, 0.05), + gravity: (0.0, 0.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.3, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.6, 1.0, 0.1, 0.7), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta new file mode 100644 index 00000000..cf5de573 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "4ab1e210-a39b-4aec-9949-ad88dd7d9441", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect b/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect new file mode 100644 index 00000000..1a2d6e5b --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Firework burst", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 150, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Hemisphere, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (1.0, 2.0), + speed: (4.0, 8.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.03, 0.06), + gravity: (0.0, -9.81, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.4, 0.5, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: true, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta new file mode 100644 index 00000000..59dc170f --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "607e3bd9-bc06-4bb9-95aa-f13df5c0c490", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect b/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect new file mode 100644 index 00000000..e10deb36 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Footstep dust", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 30, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.2, 0.6), + speed: (0.3, 0.8), + direction: (0.0, 1.0, 0.0), + start_size: (0.1, 0.2), + gravity: (0.0, 0.2, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.55, 0.45, 0.3, 0.25), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta new file mode 100644 index 00000000..f75c4f03 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "2605bfc2-0498-4625-8602-d0b183b56c0e", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect b/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect new file mode 100644 index 00000000..aea97c7d --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect @@ -0,0 +1,76 @@ +( + version: 1, + name: "Glowing trail", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Trail(( + emitting: true, + lifetime: 1.0, + width: 0.18, + min_vertex_distance: 0.05, + max_points: 256, + teleport_distance: 20.0, + camera_facing: true, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.2, 0.7, 1.0, 0.9), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta new file mode 100644 index 00000000..aad09877 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "9adbde75-9282-48f8-b3c4-48d68b3bb657", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect b/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect new file mode 100644 index 00000000..b5e58859 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect @@ -0,0 +1,83 @@ +( + version: 1, + name: "Ground ripple", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: WaterRipple(( + target: None, + source_velocity: (0.0, 0.0, 0.0), + wake_angle: 0.6, + foam_intensity: 0.5, + foam_lifetime: 1.0, + normal_strength: 1.0, + max_disturbances: 32, + initial_radius: 0.1, + final_radius: 4.0, + thickness: 0.2, + vertical_offset: 0.01, + amplitude: 0.1, + wavelength: 1.0, + damping: 1.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 1.0, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta new file mode 100644 index 00000000..66c5983b --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "393614d2-351f-4474-afdf-da024a3fa983", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect b/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect new file mode 100644 index 00000000..51b8102a --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Healing aura", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 35.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (1.0, 2.0), + speed: (0.2, 0.5), + direction: (0.0, 1.0, 0.0), + start_size: (0.04, 0.1), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.15, 1.0, 0.3, 0.5), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta new file mode 100644 index 00000000..7d561a4d --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "a00787aa-9d9f-417e-846d-c452cafd51ec", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/leaves.effect b/Engine/examples/scene-effects/project/assets/effects/leaves.effect new file mode 100644 index 00000000..542a8150 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/leaves.effect @@ -0,0 +1,108 @@ +( + version: 1, + name: "Leaves", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Weather(( + weather: Leaves, + emitter: ( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 8.0, + rate_over_distance: 0.0, + bursts: [], + shape: Box, + dimensions: (10.0, 8.0, 10.0), + surface_only: false, + lifetime: (5.0, 8.0), + speed: (0.2, 0.6), + direction: (0.0, -1.0, 0.0), + start_size: (0.1, 0.25), + gravity: (0.0, -0.1, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 2.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.6, 0.3, 0.05, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + ), + intensity: 1.0, + camera_follow: false, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta new file mode 100644 index 00000000..6e762716 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "cb14ec4c-7542-4997-b636-ac8581597d22", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect b/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect new file mode 100644 index 00000000..4e6d00de --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect @@ -0,0 +1,79 @@ +( + version: 1, + name: "Lightning beam", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Beam(( + start_target: None, + end_target: None, + start: (0.0, 0.0, 0.0), + end: (0.0, 3.0, 0.0), + width: 0.06, + segments: 24, + noise_amplitude: 0.35, + noise_frequency: 3.0, + noise_speed: 5.0, + branch_count: 0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.3, 0.5, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta new file mode 100644 index 00000000..358b26ea --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "907b00f9-2934-4c5e-a42e-94de5359f4be", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect b/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect new file mode 100644 index 00000000..31720260 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Magic projectile", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 60.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.2, 0.5), + speed: (0.2, 0.5), + direction: (0.0, 1.0, 0.0), + start_size: (0.05, 0.12), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.25, 0.1, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta new file mode 100644 index 00000000..34209744 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "2f86ab14-3024-42b8-9099-7614ad785c88", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/mist.effect b/Engine/examples/scene-effects/project/assets/effects/mist.effect new file mode 100644 index 00000000..cb711dce --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/mist.effect @@ -0,0 +1,39 @@ +( + version: 1, + name: "Mist", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Fog(( + shape: Box, + dimensions: (10.0, 2.0, 10.0), + density: 0.05, + color: (0.6, 0.7, 0.8), + height_falloff: 0.5, + edge_falloff: 1.0, + noise_scale: 1.0, + noise_speed: 0.1, + anisotropy: 0.0, + steps: 32, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta new file mode 100644 index 00000000..91311908 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "28878227-7506-4cf6-9235-bea1003f5c35", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect b/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect new file mode 100644 index 00000000..c51428ff --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Muzzle flash", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 30, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.03, 0.08), + speed: (2.0, 4.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.1, 0.2), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.7, 0.15, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta new file mode 100644 index 00000000..88297e76 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "9bcdfd6b-9f58-48c8-94c9-bdcb0c07536c", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect b/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect new file mode 100644 index 00000000..70500b20 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect @@ -0,0 +1,47 @@ +( + version: 1, + name: "Object dissolve", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Dissolve(( + target: None, + progress: 0.0, + edge_width: 0.05, + edge_color: (1.0, 0.4, 0.0), + edge_emission: 3.0, + noise_scale: 10.0, + curve: ( + keys: [ + ( + time: 0.0, + value: 0.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta new file mode 100644 index 00000000..457de9a3 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "051540dd-651c-4949-8efd-387e7e3be862", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/portal.effect b/Engine/examples/scene-effects/project/assets/effects/portal.effect new file mode 100644 index 00000000..ec0d9726 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/portal.effect @@ -0,0 +1,125 @@ +( + version: 1, + name: "Portal", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Ribbon(( + points: [ + (1.4, 0.0, 0.0), + (1.3880228, 0.2610524, 0.0), + (1.3522961, 0.5176381, 0.0), + (1.2934313, 0.7653669, 0.0), + (1.2124355, 1.0, 0.0), + (1.1106946, 1.2175229, 0.0), + (0.98994946, 1.4142135, 0.0), + (0.852266, 1.5867066, 0.0), + (0.6999999, 1.7320509, 0.0), + (0.53575677, 1.847759, 0.0), + (0.3623467, 1.9318516, 0.0), + (0.18273658, 1.9828898, 0.0), + (-0.00000006119594, 2.0, 0.0), + (-0.18273686, 1.9828897, 0.0), + (-0.36234665, 1.9318516, 0.0), + (-0.5357569, 1.847759, 0.0), + (-0.70000005, 1.7320508, 0.0), + (-0.852266, 1.5867066, 0.0), + (-0.98994946, 1.4142135, 0.0), + (-1.1106949, 1.2175225, 0.0), + (-1.2124355, 1.0000001, 0.0), + (-1.2934314, 0.76536655, 0.0), + (-1.3522962, 0.51763785, 0.0), + (-1.3880228, 0.26105216, 0.0), + (-1.4, -0.00000017484555, 0.0), + (-1.3880228, -0.26105252, 0.0), + (-1.352296, -0.5176386, 0.0), + (-1.2934313, -0.76536685, 0.0), + (-1.2124355, -0.99999994, 0.0), + (-1.1106944, -1.2175232, 0.0), + (-0.9899493, -1.4142138, 0.0), + (-0.8522661, -1.5867065, 0.0), + (-0.69999987, -1.7320509, 0.0), + (-0.53575635, -1.8477594, 0.0), + (-0.36234656, -1.9318517, 0.0), + (-0.18273628, -1.9828898, 0.0), + (0.000000016694832, -2.0, 0.0), + (0.18273696, -1.9828897, 0.0), + (0.36234725, -1.9318514, 0.0), + (0.53575706, -1.8477589, 0.0), + (0.69999987, -1.7320509, 0.0), + (0.85226613, -1.5867065, 0.0), + (0.9899498, -1.4142131, 0.0), + (1.1106948, -1.2175227, 0.0), + (1.2124357, -0.9999995, 0.0), + (1.2934314, -0.76536685, 0.0), + (1.3522962, -0.51763767, 0.0), + (1.3880229, -0.2610515, 0.0), + (1.4, 0.0000003496911, 0.0), + ], + width: 0.2, + tube: true, + radial_segments: 8, + subdivisions: 8, + twist: 0.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 1.0, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta new file mode 100644 index 00000000..8ae359b9 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "15e7a57d-8dcb-43e8-bc86-dc8ec9f2e257", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/rain.effect b/Engine/examples/scene-effects/project/assets/effects/rain.effect new file mode 100644 index 00000000..62056e9e --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/rain.effect @@ -0,0 +1,108 @@ +( + version: 1, + name: "Rain", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Weather(( + weather: Rain, + emitter: ( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 200.0, + rate_over_distance: 0.0, + bursts: [], + shape: Box, + dimensions: (20.0, 10.0, 20.0), + surface_only: false, + lifetime: (2.0, 3.0), + speed: (8.0, 10.0), + direction: (0.0, -1.0, 0.0), + start_size: (0.1, 0.25), + gravity: (0.0, 0.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 1.0, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + ), + intensity: 1.0, + camera_follow: false, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta new file mode 100644 index 00000000..4f03c356 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "4afc215e-826c-4e47-9e9e-ebbe813c67b9", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect b/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect new file mode 100644 index 00000000..b34d9792 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Rocket exhaust", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 100.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.1, 0.4), + speed: (2.0, 5.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.08, 0.2), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 0.35, 0.04, 0.9), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta new file mode 100644 index 00000000..ceb2711a --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "8fc5080f-f6a5-4db9-ba06-958b5802214d", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/shockwave.effect b/Engine/examples/scene-effects/project/assets/effects/shockwave.effect new file mode 100644 index 00000000..ab71a9c8 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/shockwave.effect @@ -0,0 +1,83 @@ +( + version: 1, + name: "Shockwave", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Shockwave(( + target: None, + source_velocity: (0.0, 0.0, 0.0), + wake_angle: 0.6, + foam_intensity: 0.5, + foam_lifetime: 1.0, + normal_strength: 1.0, + max_disturbances: 32, + initial_radius: 0.1, + final_radius: 4.0, + thickness: 0.2, + vertical_offset: 0.01, + amplitude: 0.1, + wavelength: 1.0, + damping: 1.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 1.0, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta new file mode 100644 index 00000000..3e7ffc58 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "f24fd45e-7bbe-4d4c-b492-708af934d68e", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect b/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect new file mode 100644 index 00000000..f7ad416f --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect @@ -0,0 +1,76 @@ +( + version: 1, + name: "Skid mark", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Trail(( + emitting: true, + lifetime: 15.0, + width: 0.22, + min_vertex_distance: 0.05, + max_points: 256, + teleport_distance: 20.0, + camera_facing: true, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.025, 0.025, 0.025, 0.9), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta new file mode 100644 index 00000000..98be8138 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "5145c102-b46c-4ffa-bce4-53afb0b613d4", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect b/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect new file mode 100644 index 00000000..2eb56b4d --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Smoke plume", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 18.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (3.0, 5.0), + speed: (0.2, 0.6), + direction: (0.0, 1.0, 0.0), + start_size: (0.3, 0.6), + gravity: (0.0, 0.2, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.25, 0.28, 0.32, 0.25), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta new file mode 100644 index 00000000..c03eb49a --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "afad56a9-96a5-41cb-90f5-c4d2047fabdc", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/snow.effect b/Engine/examples/scene-effects/project/assets/effects/snow.effect new file mode 100644 index 00000000..6d335e97 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/snow.effect @@ -0,0 +1,108 @@ +( + version: 1, + name: "Snow", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Weather(( + weather: Snow, + emitter: ( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 80.0, + rate_over_distance: 0.0, + bursts: [], + shape: Box, + dimensions: (15.0, 10.0, 15.0), + surface_only: false, + lifetime: (5.0, 8.0), + speed: (0.5, 1.2), + direction: (0.0, -1.0, 0.0), + start_size: (0.1, 0.25), + gravity: (0.0, 0.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.25, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (1.0, 1.0, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + ), + intensity: 1.0, + camera_follow: false, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta new file mode 100644 index 00000000..a7c0ec5d --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "4df8e805-6fcd-4dc9-b710-621e0108c9a6", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect b/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect new file mode 100644 index 00000000..07cd86da --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect @@ -0,0 +1,76 @@ +( + version: 1, + name: "Speed boost trail", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Trail(( + emitting: true, + lifetime: 0.5, + width: 0.3, + min_vertex_distance: 0.05, + max_points: 256, + teleport_distance: 20.0, + camera_facing: true, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.1, 0.4, 1.0, 1.0), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta new file mode 100644 index 00000000..9188ac34 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "000a34ce-1dee-45bc-bf88-e2807152851a", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect b/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect new file mode 100644 index 00000000..9a980d6f --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect @@ -0,0 +1,76 @@ +( + version: 1, + name: "Sword trail", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Trail(( + emitting: true, + lifetime: 0.2, + width: 0.6, + min_vertex_distance: 0.05, + max_points: 256, + teleport_distance: 20.0, + camera_facing: true, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.7, 0.85, 1.0, 0.65), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta new file mode 100644 index 00000000..ad28f779 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "0af25fef-26bc-49e5-ac56-d33d7fa17ce7", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/teleport.effect b/Engine/examples/scene-effects/project/assets/effects/teleport.effect new file mode 100644 index 00000000..aa14d5d9 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/teleport.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Teleport", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 30, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.5, 1.0), + speed: (1.0, 4.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.08, 0.18), + gravity: (0.0, -1.0, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.6, 0.1, 1.0, 0.8), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta new file mode 100644 index 00000000..5cad57d2 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "ffd059ce-5a62-422c-acfd-093d2d44f19e", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect b/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect new file mode 100644 index 00000000..a12f25bd --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect @@ -0,0 +1,103 @@ +( + version: 1, + name: "Tire smoke", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: true, + duration: 5.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 40.0, + rate_over_distance: 0.0, + bursts: [], + shape: Cone, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.5, 1.5), + speed: (0.2, 0.6), + direction: (0.0, 1.0, 0.0), + start_size: (0.15, 0.35), + gravity: (0.0, 0.2, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.6, 0.6, 0.65, 0.2), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Alpha, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: false, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta new file mode 100644 index 00000000..ab360c01 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "3fcfd87d-0223-43d1-be66-f28d51829fda", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/effects/water-splash.effect b/Engine/examples/scene-effects/project/assets/effects/water-splash.effect new file mode 100644 index 00000000..abfa8817 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/water-splash.effect @@ -0,0 +1,111 @@ +( + version: 1, + name: "Water splash", + effects: [ + ( + source: None, + common: ( + parameters: {}, + bounds: None, + enabled: true, + visible: true, + auto_play: true, + looping: false, + duration: 2.0, + delay: 0.0, + time_scale: 1.0, + simulation_space: World, + attachment_target: None, + visibility_layers: 4294967295, + render_distance: 1000.0, + quality: Medium, + random_seed: 1, + priority: 0, + ), + kind: Particle(( + prewarm: false, + prewarm_time: 5.0, + max_particles: 1000, + rate: 0.0, + rate_over_distance: 0.0, + bursts: [ + ( + time: 0.0, + count: 30, + probability: 1.0, + cycles: 1, + interval: 1.0, + ), + ], + shape: Hemisphere, + dimensions: (1.0, 1.0, 1.0), + surface_only: false, + lifetime: (0.4, 1.0), + speed: (2.0, 4.0), + direction: (0.0, 1.0, 0.0), + start_size: (0.04, 0.09), + gravity: (0.0, -9.81, 0.0), + force: (0.0, 0.0, 0.0), + drag: 0.1, + wind_influence: 1.0, + noise: 0.0, + angular_velocity: 0.0, + inherit_velocity: 0.0, + velocity_limit: 1000.0, + appearance: ( + color: ( + keys: [ + ( + time: 0.0, + color: (0.4, 0.7, 1.0, 0.6), + ), + ], + ), + opacity: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 0.0, + ), + ], + ), + size: ( + keys: [ + ( + time: 0.0, + value: 1.0, + ), + ( + time: 1.0, + value: 1.0, + ), + ], + ), + material: None, + texture: None, + emission: 1.0, + blend: Additive, + uv_scale: (1.0, 1.0), + uv_scroll: (0.0, 0.0), + ), + mesh: None, + mesh_spawn_triangles: [], + collision: true, + collision_layers: 4294967295, + bounce: 0.5, + friction: 0.1, + collision_response: Bounce, + sub_emitter: None, + sub_emitter_probability: 1.0, + max_generations: 3, + flipbook: (1, 1), + flipbook_fps: 0.0, + stretch: 0.0, + )), + ), + ], +) \ No newline at end of file diff --git a/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta new file mode 100644 index 00000000..d15fa742 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "c2b01912-ad7c-4339-bf4b-63c1442ef231", + importer_id: "rustic.effect", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/foliage.obj b/Engine/examples/scene-effects/project/assets/foliage.obj new file mode 100644 index 00000000..9ae3e995 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/foliage.obj @@ -0,0 +1,12 @@ +v 0 0 0 +v -0.35 0.5 0 +v 0 1.5 0 +v 0.35 0.5 0 +vt 0.5 0 +vt 0 0.5 +vt 0.5 1 +vt 1 0.5 +f 1/1 2/2 3/3 +f 1/1 3/3 4/4 +f 3/3 2/2 1/1 +f 4/4 3/3 1/1 diff --git a/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta b/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta new file mode 100644 index 00000000..cb8097fa --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "b3abde13-c11c-4504-9506-836e3a02547b", + importer_id: "rustic.obj", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/showcase-probe.hdr b/Engine/examples/scene-effects/project/assets/showcase-probe.hdr new file mode 100644 index 00000000..4566f01e --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/showcase-probe.hdr @@ -0,0 +1,5 @@ +#?RADIANCE +FORMAT=32-bit_rle_rgbe + +-Y 64 +X 128 +QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~QzÌ~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~X„Ý~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~^Ží~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~e˜ý~5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†5P†9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�9U�^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Fj±€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Eg­€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ce©€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€Ab¤€@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €@` €>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€>^œ€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€=[˜€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€;Y”€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€9V�€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€8TŒ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€6Qˆ€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€4O„€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€3M€€c•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•øc•ø`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð`�ð\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹è\‹èY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àY†àV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØV�ØS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐS|ÐOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇOwÇLs¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿Ls¿In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·In·Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Fi¯Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§Bd§?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ?_Ÿ Date: Mon, 2 Nov 2026 06:25:19 +0000 Subject: [PATCH 2/2] Validate effect lifecycles, editor interactions and runtime showcase --- Engine/apps/browser/src/main.rs | 9 +- Engine/apps/editor/src/effects.rs | 82 +- Engine/apps/editor/src/main.rs | 18 +- Engine/apps/runtime/src/main.rs | 4 +- Engine/crates/engine-editor/src/document.rs | 8 +- .../crates/engine-play/src/script_runtime.rs | 67 + .../examples/scene_effects_showcase.rs | 357 ++- .../engine-world/src/effects/simulation.rs | 10 +- Engine/crates/engine-world/src/world.rs | 292 ++- .../engine-world/tests/effects_persistence.rs | 328 ++- .../examples/effects_showcase.rs | 58 +- Engine/crates/renderer-wgpu/src/effects.rs | 28 +- Engine/docs/SCENE_EFFECTS.md | 2 + Engine/examples/scene-effects/README.md | 12 +- Engine/examples/scene-effects/preview.png | Bin 0 -> 88294 bytes .../project/assets/debris.obj.rmeta | 2 +- .../assets/effects/bullet-impact.effect.rmeta | 2 +- .../assets/effects/campfire.effect.rmeta | 2 +- .../effects/character-highlight.effect.rmeta | 2 +- .../effects/collision-sparks.effect.rmeta | 2 +- .../assets/effects/damage-flash.effect.rmeta | 2 +- .../project/assets/effects/dust.effect.rmeta | 2 +- .../assets/effects/embers.effect.rmeta | 2 +- .../assets/effects/energy-shield.effect.rmeta | 2 +- .../assets/effects/exhaust-smoke.effect.rmeta | 2 +- .../assets/effects/explosion.effect.rmeta | 2 +- .../assets/effects/fire-sparks.effect.rmeta | 2 +- .../assets/effects/fireflies.effect.rmeta | 2 +- .../effects/firework-burst.effect.rmeta | 2 +- .../assets/effects/footstep-dust.effect.rmeta | 2 +- .../assets/effects/glowing-trail.effect.rmeta | 2 +- .../assets/effects/ground-ripple.effect.rmeta | 2 +- .../assets/effects/healing-aura.effect.rmeta | 2 +- .../assets/effects/leaves.effect.rmeta | 2 +- .../effects/lightning-beam.effect.rmeta | 2 +- .../effects/magic-projectile.effect.rmeta | 2 +- .../project/assets/effects/mist.effect.rmeta | 2 +- .../assets/effects/muzzle-flash.effect.rmeta | 2 +- .../effects/object-dissolve.effect.rmeta | 2 +- .../assets/effects/portal.effect.rmeta | 2 +- .../project/assets/effects/rain.effect.rmeta | 2 +- .../effects/rocket-exhaust.effect.rmeta | 2 +- .../assets/effects/shockwave.effect.rmeta | 2 +- .../assets/effects/skid-mark.effect.rmeta | 2 +- .../assets/effects/smoke-plume.effect.rmeta | 2 +- .../project/assets/effects/snow.effect.rmeta | 2 +- .../effects/speed-boost-trail.effect.rmeta | 2 +- .../assets/effects/sword-trail.effect.rmeta | 2 +- .../assets/effects/teleport.effect.rmeta | 2 +- .../assets/effects/tire-smoke.effect.rmeta | 2 +- .../assets/effects/water-splash.effect.rmeta | 2 +- .../project/assets/energy-grid.tga | Bin 0 -> 3090 bytes .../project/assets/energy-grid.tga.rmeta | 11 + .../project/assets/foliage.obj.rmeta | 2 +- .../project/assets/showcase-probe.hdr.rmeta | 2 +- .../scene-effects/project/config/scripts.ron | 12 +- .../scene-effects/project/project.engine | 6 +- .../project/scene/showcase.scene | 1942 +++++------------ .../scene-effects/project/scripts/replay.lua | 3 + 59 files changed, 1607 insertions(+), 1718 deletions(-) create mode 100644 Engine/examples/scene-effects/preview.png create mode 100644 Engine/examples/scene-effects/project/assets/energy-grid.tga create mode 100644 Engine/examples/scene-effects/project/assets/energy-grid.tga.rmeta create mode 100644 Engine/examples/scene-effects/project/scripts/replay.lua diff --git a/Engine/apps/browser/src/main.rs b/Engine/apps/browser/src/main.rs index 3022fd94..0ca2dcff 100644 --- a/Engine/apps/browser/src/main.rs +++ b/Engine/apps/browser/src/main.rs @@ -72,9 +72,14 @@ pub extern "C" fn rustic_tick(width: u32, height: u32, seconds: f64) { .step(seconds, input.keys, input.pointers, input.reset)?; let [width, height] = engine_play::game_view_extent([width, height]); let (scene, effects) = game.session.render_world(|world| { - (renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, &game.models), world.effect_frame().clone()) + ( + renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, &game.models), + world.effect_frame().clone(), + ) })?; - game.renderer.set_effect_frame(&effects).map_err(|error| error.to_string())?; + game.renderer + .set_effect_frame(&effects) + .map_err(|error| error.to_string())?; #[cfg(target_os = "emscripten")] { let audio: Vec = game diff --git a/Engine/apps/editor/src/effects.rs b/Engine/apps/editor/src/effects.rs index 3945a873..b598d633 100644 --- a/Engine/apps/editor/src/effects.rs +++ b/Engine/apps/editor/src/effects.rs @@ -143,7 +143,9 @@ fn edit_value( ui.horizontal(|ui| { ui.label(label(name)).on_hover_text(field_hint(name)); let choices = enum_choices(name); - if !choices.is_empty() { + if choices.is_empty() { + ui.text_edit_singleline(value).changed() + } else { let before = value.clone(); egui::ComboBox::from_id_salt("value") .selected_text(label(value)) @@ -153,19 +155,6 @@ fn edit_value( } }); before != *value - } else { - let mut changed = ui.text_edit_singleline(value).changed(); - if is_asset(name) - && ui.button("Choose…").clicked() - && let Some(path) = - rfd::FileDialog::new().set_directory(root).pick_file() - && let (Ok(root), Ok(path)) = (root.canonicalize(), path.canonicalize()) - && let Ok(relative) = path.strip_prefix(root) - { - *value = relative.to_string_lossy().replace('\\', "/"); - changed = true; - } - changed } }) .inner @@ -278,15 +267,15 @@ fn edit_value( values.push(value); changed = true; } - if name != "meshes" && ui.button("Add key / item").clicked() { - if let Some(value) = values + if name != "meshes" + && ui.button("Add key / item").clicked() + && let Some(value) = values .last() .cloned() .or_else(|| default_item(name, references)) - { - values.push(value); - changed = true; - } + { + values.push(value); + changed = true; } changed }) @@ -392,7 +381,7 @@ fn enum_choices(name: &str) -> &'static [&'static str] { "MeshSurface", ], "quality" => &["Low", "Medium", "High"], - "blend" => &["Alpha", "Additive", "Multiply"], + "blend" | "blend_mode" => &["Alpha", "Additive", "Multiply"], "weather" => &["Rain", "Snow", "Hail", "Dust", "Sand", "Leaves"], "force_type" => &[ "Directional", @@ -404,7 +393,6 @@ fn enum_choices(name: &str) -> &'static [&'static str] { "collision_response" | "response" => &["Bounce", "Stick", "Kill"], "refresh" => &["Baked", "OnEnable", "Interval"], "simulation_space" => &["Local", "World"], - "blend_mode" => &["Alpha", "Additive", "Multiply"], _ => &[], } } @@ -446,7 +434,7 @@ impl crate::EditorViewer<'_> { { match load_effect_asset(self.project_root, &path) { Ok(asset) if asset.effects.len() == 1 => { - next = asset.effects.into_iter().next() + next = asset.effects.into_iter().next(); } Ok(_) => self.effect_error( "Composite assets must be added through the preset / asset browser".into(), @@ -664,7 +652,7 @@ pub(super) struct EffectPreview { impl Default for EffectPreview { fn default() -> Self { Self { - runtime: Default::default(), + runtime: PreviewRuntime::default(), playing: false, speed: 1.0, time: 0.0, @@ -672,7 +660,7 @@ impl Default for EffectPreview { show_bounds: true, search: String::new(), category: "All".into(), - scene_path: Default::default(), + scene_path: std::path::PathBuf::new(), simulation_millis: 0.0, } } @@ -1216,15 +1204,14 @@ fn update_shared_asset( let mapping = asset_instance_map(&asset, other, entity, document); let mut live_defaults = new_defaults.clone(); live_defaults.remap_entities(&mapping); - let baseline = other - .baseline - .as_ref() - .map(|baseline| baseline.as_ref().clone().into_component()) - .unwrap_or_else(|| { + let baseline = other.baseline.as_ref().map_or_else( + || { let mut old = old_defaults.clone(); old.remap_entities(&mapping); old - }); + }, + |baseline| baseline.as_ref().clone().into_component(), + ); let mut merged = engine_world::effects::merge_instance_defaults( &instance, &baseline, @@ -1440,9 +1427,10 @@ mod tests { let effect = engine_world::effects::EffectComponent::new(kind); let before = serde_json::to_value(&effect).unwrap(); let mut value = before.clone(); - let _ = context.run_ui(egui::RawInput::default(), |ui| { + let mut output = context.run_ui(egui::RawInput::default(), |ui| { assert!(!properties(ui, &mut value, temporary.path(), &[])); }); + output.textures_delta.clear(); assert_eq!( serde_json::from_value::(value) .unwrap(), @@ -1464,7 +1452,9 @@ mod tests { let mut document = engine_editor::AuthoringDocument::open(project, false).unwrap(); let id = document .add_effect( - engine_world::effects::EffectKind::Particle(Default::default()), + engine_world::effects::EffectKind::Particle( + engine_world::effects::ParticleEmitter::default(), + ), None, ) .unwrap(); @@ -1804,7 +1794,7 @@ mod curve_interaction_tests { egui::Pos2::ZERO, egui::vec2(640.0, 480.0), )); - let first = context.run_ui( + let mut first = context.run_ui( egui::RawInput { screen_rect: screen, ..Default::default() @@ -1813,6 +1803,7 @@ mod curve_interaction_tests { edit_key_graph(ui, &mut keys); }, ); + first.textures_delta.clear(); let center = first .shapes .iter() @@ -1840,7 +1831,7 @@ mod curve_interaction_tests { modifiers: egui::Modifiers::NONE, }], ] { - let _ = context.run_ui( + let mut output = context.run_ui( egui::RawInput { screen_rect: screen, events, @@ -1850,6 +1841,7 @@ mod curve_interaction_tests { edit_key_graph(ui, &mut keys); }, ); + output.textures_delta.clear(); } assert!(keys[0]["time"].as_f64().unwrap() > 0.05); assert!(keys[0]["value"].as_f64().unwrap() > 0.05); @@ -1877,7 +1869,7 @@ mod preview_child_tests { let asset = EffectAsset::new( "Impact", vec![EffectComponent::new(EffectKind::Shockwave( - Default::default(), + RingEffect::default(), ))], ); std::fs::write(&path, asset.to_bytes().unwrap()).unwrap(); @@ -2006,13 +1998,17 @@ fn prepare_mesh_emission( .meshes .iter() .flat_map(|mesh| { - mesh.indices.chunks_exact(3).filter_map(move |face| { - Some([ - *mesh.positions.get(usize::try_from(face[0]).ok()?)?, - *mesh.positions.get(usize::try_from(face[1]).ok()?)?, - *mesh.positions.get(usize::try_from(face[2]).ok()?)?, - ]) - }) + mesh.indices + .as_chunks::<3>() + .0 + .iter() + .filter_map(move |face| { + Some([ + *mesh.positions.get(usize::try_from(face[0]).ok()?)?, + *mesh.positions.get(usize::try_from(face[1]).ok()?)?, + *mesh.positions.get(usize::try_from(face[2]).ok()?)?, + ]) + }) }) .take(100_000) .collect(); diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index 702b2807..4f8191f9 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -459,12 +459,7 @@ impl EditorApp { self.refresh_preview(); } } - if self.play.is_none() && self.effect_preview.playing { - context.request_repaint_after(Duration::from_millis(33)); - if self.effect_preview.last_tick.elapsed() >= Duration::from_millis(33) { - self.preview_dirty = true; - } - } + self.poll_effect_preview(context); if self.preview_dirty && self.play.is_none() { self.refresh_preview(); } @@ -509,6 +504,15 @@ impl EditorApp { } } + fn poll_effect_preview(&mut self, context: &egui::Context) { + if self.play.is_none() && self.effect_preview.playing { + context.request_repaint_after(Duration::from_millis(33)); + if self.effect_preview.last_tick.elapsed() >= Duration::from_millis(33) { + self.preview_dirty = true; + } + } + } + fn reload_scene_if_changed(&mut self) { let path = self.document.scene_path().to_path_buf(); let Ok(saved) = std::fs::read(&path) else { @@ -1939,7 +1943,7 @@ impl EditorViewer<'_> { Ok(_) => self.request_preview = true, Err(error) => { self.console - .push(simple_console(Severity::Error, "effects.asset", error)) + .push(simple_console(Severity::Error, "effects.asset", error)); } } } else if path.extension().is_some_and(|extension| { diff --git a/Engine/apps/runtime/src/main.rs b/Engine/apps/runtime/src/main.rs index bb568dec..dfa5c8d9 100644 --- a/Engine/apps/runtime/src/main.rs +++ b/Engine/apps/runtime/src/main.rs @@ -482,7 +482,9 @@ fn render_runtime( .as_mut() .ok_or("runtime renderer was not initialized")?; let scene = renderer_wgpu::game_scene_with_models(world, 16.0 / 9.0, models); - renderer.set_effect_frame(world.effect_frame()).map_err(|error| error.to_string())?; + renderer + .set_effect_frame(world.effect_frame()) + .map_err(|error| error.to_string())?; let frame = renderer .render(width, height, &scene) .map_err(|e| e.to_string())? diff --git a/Engine/crates/engine-editor/src/document.rs b/Engine/crates/engine-editor/src/document.rs index 29499891..665ce164 100644 --- a/Engine/crates/engine-editor/src/document.rs +++ b/Engine/crates/engine-editor/src/document.rs @@ -2022,8 +2022,8 @@ mod effect_authoring_tests { let asset = EffectAsset::new( "Composite", vec![ - EffectComponent::new(EffectKind::Particle(Default::default())), - EffectComponent::new(EffectKind::Beam(Default::default())), + EffectComponent::new(EffectKind::Particle(ParticleEmitter::default())), + EffectComponent::new(EffectKind::Beam(BeamRenderer::default())), ], ); let group = document.add_effect_asset(&asset, None).unwrap(); @@ -2107,8 +2107,8 @@ mod shared_effect_source_tests { std::fs::write(&path, asset.to_bytes().unwrap()).unwrap(); let reopened = AuthoringDocument::open(project.clone(), false).unwrap(); let instance = reopened.world().effect(id).unwrap().unwrap(); - assert_eq!(instance.common.duration, 7.0); - assert_eq!(instance.common.render_distance, 200.0); + assert!((instance.common.duration - 7.0).abs() < f32::EPSILON); + assert!((instance.common.render_distance - 200.0).abs() < f32::EPSILON); assert!(reopened.scene_diagnostics().is_empty()); std::fs::remove_file(path).unwrap(); let missing = AuthoringDocument::open(project, false).unwrap(); diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 6285c6fc..eca5e8eb 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -2810,6 +2810,73 @@ rustic_run(['on_start'=>function(){global $rustic;Input::onClick($rustic->entity } } + #[test] + fn bundled_effects_showcase_runs_real_scripts_and_replays_without_saving_runtime_state() { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/scene-effects/project"); + let path = root.join("scene/showcase.scene"); + let bytes = std::fs::read(&path).unwrap(); + let scene = load_scene(&bytes).unwrap().document; + assert_eq!( + scene + .entities + .iter() + .filter_map(|e| e.effect.as_ref().map(|e| e.kind.name())) + .collect::>() + .len(), + 26 + ); + let flash = scene + .entities + .iter() + .find(|entity| { + entity.effect.as_ref().is_some_and(|e| { + matches!(e.kind, engine_world::effects::EffectKind::ScreenFlash(_)) + }) + }) + .unwrap(); + let config = flash.effect.as_ref().unwrap(); + assert!( + !config.common.looping, + "showcase needs an observable one-shot to verify replay" + ); + let mut runtime = RuntimeScripts::load(&root, &bytes).unwrap(); + for _ in 0..90 { + runtime.fixed_update(1.0 / 60.0); + runtime.frame_update(1.0 / 60.0); + } + { + let world = runtime.active.world.lock().unwrap(); + assert!(!world.effect_frame().particles.is_empty()); + assert_eq!( + world.effect_state(flash.id).unwrap(), + Some(engine_world::effects::PlaybackState::Completed) + ); + } + runtime.set_input_keys(vec!["ArrowUp".into()]); + runtime.fixed_update(1.0 / 60.0); + runtime.frame_update(1.0 / 60.0); + assert_eq!( + runtime + .active + .world + .lock() + .unwrap() + .effect_state(flash.id) + .unwrap(), + Some(engine_world::effects::PlaybackState::Playing) + ); + runtime.set_input_keys(Vec::new()); + runtime.stop(); + let errors = runtime + .drain_logs() + .into_iter() + .filter(|(level, _)| level == "error") + .collect::>(); + assert!(errors.is_empty(), "showcase runtime errors: {errors:?}"); + assert_eq!(std::fs::read(path).unwrap(), bytes); + } + #[test] fn effect_runtime_configuration_is_visible_to_live_editor_inspection() { let temp = tempfile::tempdir().unwrap(); diff --git a/Engine/crates/engine-project/examples/scene_effects_showcase.rs b/Engine/crates/engine-project/examples/scene_effects_showcase.rs index c502db0b..7524b7d5 100644 --- a/Engine/crates/engine-project/examples/scene_effects_showcase.rs +++ b/Engine/crates/engine-project/examples/scene_effects_showcase.rs @@ -1,19 +1,32 @@ //! Generate an editable Rustic project using the public project/scene APIs. -//! cargo run -p engine-project --example scene_effects_showcase -- /tmp/effects-showcase +//! `cargo run -p engine-project --example scene_effects_showcase -- /tmp/effects-showcase` use engine_project::{Project, ProjectTemplate, VirtualDirectory}; use engine_scripting::{ ScriptApiVersion, ScriptId, ScriptLanguage, ScriptManifest, ScriptManifestEntry, save_manifest_atomic, }; -use engine_world::effects::{EffectComponent, EffectKind, Gradient, GradientKey, presets}; +use engine_world::effects::{ + BlendMode, Curve, EffectAppearance, EffectComponent, EffectKind, ForceType, Gradient, + GradientKey, ParticleEmitter, SpawnShape, presets, +}; use engine_world::{ Camera, EntitySnapshot, Light, LightKind, LocalTransform, PartAttributes, Primitive, - SceneDocument, save_scene_atomic, + SceneDocument, SurfaceTextures, TextureMap, save_scene_atomic, }; use glam::{Quat, Vec3}; use std::error::Error; use std::path::PathBuf; +fn appearance(color: [f32; 4], emission: f32, blend: BlendMode) -> EffectAppearance { + EffectAppearance { + color: Gradient::constant(color), + opacity: Curve::linear(1.0, 0.0), + size: Curve::linear(1.0, 0.25), + emission, + blend, + ..EffectAppearance::default() + } +} fn transform(position: Vec3) -> LocalTransform { LocalTransform { translation: position, @@ -25,12 +38,12 @@ fn pedestal(name: String, position: Vec3) -> EntitySnapshot { name: Some(name), local_transform: transform(position - Vec3::Y * 0.15), primitive: Some(Primitive::Cylinder { - radius: 2.0, + radius: 1.6, height: 0.2, segments: 48, }), part_attributes: PartAttributes { - color: [0.07, 0.09, 0.13, 1.0], + color: [0.16, 0.2, 0.28, 1.0], anchored: true, ..Default::default() }, @@ -75,16 +88,26 @@ fn panorama_bytes() -> Vec { reason = "this example keeps the 26 authored showcase configurations and their source references together" )] fn main() -> Result<(), Box> { - let destination = std::env::args_os() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/tmp/rustic-effects-showcase")); + let destination = std::env::args_os().nth(1).map_or_else( + || PathBuf::from("/tmp/rustic-effects-showcase"), + PathBuf::from, + ); let mut project = Project::create( &destination, "Scene Effects Showcase", ProjectTemplate::Blank, )?; let mut scene = SceneDocument::new("Scene Effects Showcase"); + scene.environment = engine_world::SceneEnvironment { + enabled: true, + sky_color: [0.055, 0.08, 0.14], + ambient_color: [0.8, 0.88, 1.0], + ambient_intensity: 0.48, + sun_color: [1.0, 0.88, 0.72], + sun_intensity: 0.8, + sun_direction: [0.3, 0.9, -0.2], + ..Default::default() + }; // A locally generated equirectangular HDR panorama exercises baked probes. let panorama = destination.join("assets/showcase-probe.hdr"); let hdr = panorama_bytes(); @@ -106,10 +129,28 @@ fn main() -> Result<(), Box> { let probe_meta = engine_assets::AssetMeta::new("rustic.image", 1); engine_assets::save_meta(&engine_assets::sidecar_path(&panorama), &probe_meta)?; + let mut image = vec![0_u8; 18]; + image[2] = 2; + image[12] = 32; + image[14] = 32; + image[16] = 24; + image[17] = 0x20; + for y in 0..32 { + for x in 0..32 { + let bright = x % 8 == 0 || y % 8 == 0; + image.extend(if bright { [255, 180, 35] } else { [45, 20, 8] }); + } + } + let energy_path = destination.join("assets/energy-grid.tga"); + std::fs::write(&energy_path, image)?; + engine_assets::save_meta( + &engine_assets::sidecar_path(&energy_path), + &engine_assets::AssetMeta::new("rustic.image", 1), + )?; let orbit_script = ScriptId::new(); std::fs::write( destination.join("scripts/orbit.lua"), - r#"local origin, elapsed + r"local origin, elapsed return { Start = function() local x,y,z=rustic.get_translation();origin={x,y,z};elapsed=0 end, FixedUpdate = function(dt) @@ -117,7 +158,7 @@ return { rustic.set_translation(origin[1]+math.cos(elapsed*1.8)*1.1,origin[2]+1+math.sin(elapsed*2.0)*0.2,origin[3]+math.sin(elapsed*1.8)*1.1) end, } -"#, +", )?; save_manifest_atomic( &destination.join("config/scripts.ron"), @@ -135,8 +176,8 @@ return { scene.entities.push(EntitySnapshot { name: Some("Showcase Camera".into()), local_transform: LocalTransform { - translation: Vec3::new(12.5, 18.0, -25.0), - rotation: Quat::from_rotation_x(0.38), + translation: Vec3::new(11.0, 14.0, -17.0), + rotation: Quat::from_rotation_x(0.5), ..Default::default() }, camera: Some(Camera::default()), @@ -151,17 +192,17 @@ return { light: Some(Light { kind: LightKind::Directional, color: Vec3::new(1.0, 0.9, 0.75), - intensity: 2.0, + intensity: 1.4, ..Default::default() }), ..Default::default() }); scene.entities.push(EntitySnapshot { name: Some("Showcase floor".into()), - local_transform: transform(Vec3::new(12.5, -0.3, 10.0)), - primitive: Some(Primitive::Plane { size: [45.0, 40.0] }), + local_transform: transform(Vec3::new(11.0, -0.3, 8.8)), + primitive: Some(Primitive::Plane { size: [30.0, 29.0] }), part_attributes: PartAttributes { - color: [0.12, 0.14, 0.18, 1.0], + color: [0.2, 0.23, 0.3, 1.0], anchored: true, ..Default::default() }, @@ -169,22 +210,48 @@ return { }); for (index, mut kind) in EffectKind::all_defaults().into_iter().enumerate() { let position = Vec3::new( - f32::from(u16::try_from(index % 6).unwrap_or_default()) * 5.0, + f32::from(u16::try_from(index % 6).unwrap_or_default()) * 4.4, 0.0, - f32::from(u16::try_from(index / 6).unwrap_or_default()) * 5.0, + f32::from(u16::try_from(index / 6).unwrap_or_default()) * 4.4, ); let name = format!("{:02} {}", index + 1, kind.name()); scene .entities .push(pedestal(format!("{name} pedestal"), position)); + let needs_target = matches!( + kind, + EffectKind::Dissolve(_) + | EffectKind::AnimatedSurface(_) + | EffectKind::Outline(_) + | EffectKind::ReflectionProbe(_) + ); + let target_shape = match &kind { + EffectKind::Dissolve(_) => Primitive::Cube { size: 1.3 }, + EffectKind::AnimatedSurface(_) => Primitive::RectangularPrism { + size: [2.2, 0.25, 2.2], + }, + EffectKind::Outline(_) => Primitive::Capsule { + radius: 0.45, + height: 1.0, + segments: 24, + rings: 12, + }, + _ => Primitive::Sphere { + radius: 0.8, + segments: 32, + rings: 24, + }, + }; let target = EntitySnapshot { name: Some(format!("{name} target")), local_transform: transform(position + Vec3::Y), - primitive: Some(Primitive::Sphere { - radius: 0.6, - segments: 24, - rings: 16, + textures: matches!(kind, EffectKind::AnimatedSurface(_)).then(|| SurfaceTextures { + maps: [(TextureMap::BaseColor, "assets/energy-grid.tga".into())] + .into_iter() + .collect(), + ..Default::default() }), + primitive: Some(target_shape), part_attributes: PartAttributes { color: [0.2, 0.45, 0.65, 1.0], anchored: true, @@ -193,34 +260,122 @@ return { ..Default::default() }; let target_id = target.id; + let needs_light = matches!( + kind, + EffectKind::LightPulse(_) | EffectKind::LightShaft(_) | EffectKind::LensFlare(_) + ); let light = EntitySnapshot { name: Some(format!("{name} point light")), local_transform: transform(position + Vec3::Y * 2.0), light: Some(Light { kind: LightKind::Point, - intensity: 4.0, - range: 5.0, + intensity: 3.0, + range: 4.0, ..Default::default() }), ..Default::default() }; + let mut light = light; + light.primitive = Some(Primitive::Sphere { + radius: 0.16, + segments: 16, + rings: 10, + }); + light.part_attributes = PartAttributes { + color: [1.0, 0.76, 0.32, 1.0], + anchored: true, + ..Default::default() + }; let light_id = light.id; match &mut kind { + EffectKind::Particle(v) => { + v.rate = 70.0; + v.dimensions = [0.4; 3]; + v.lifetime = [0.5, 1.3]; + v.speed = [1.2, 2.8]; + v.start_size = [0.06, 0.16]; + v.gravity = [0.0, -0.4, 0.0]; + v.noise = 0.18; + v.appearance = appearance([1.0, 0.32, 0.035, 0.9], 1.5, BlendMode::Additive); + } + EffectKind::Trail(v) => { + v.width = 0.16; + v.lifetime = 1.2; + v.appearance = appearance([0.1, 0.8, 1.0, 0.9], 1.4, BlendMode::Additive); + } + EffectKind::Shockwave(v) => { + v.final_radius = 1.55; + v.thickness = 0.12; + v.appearance = appearance([0.1, 1.0, 0.65, 0.85], 1.2, BlendMode::Additive); + } + EffectKind::WaterRipple(v) => { + v.final_radius = 1.4; + v.thickness = 0.2; + v.wavelength = 0.45; + v.amplitude = 0.09; + v.appearance = appearance([0.08, 0.55, 0.7, 0.8], 0.3, BlendMode::Alpha); + } + EffectKind::Ribbon(v) => { + v.points = vec![ + [-1.2, 0.4, 0.0], + [-0.6, 1.8, 0.2], + [0.0, 0.6, 0.0], + [0.6, 1.8, -0.2], + [1.2, 0.4, 0.0], + ]; + v.width = 0.1; + v.tube = true; + v.appearance = appearance([0.6, 0.18, 1.0, 1.0], 1.1, BlendMode::Additive); + } + EffectKind::ProjectileTracer(v) => { + v.velocity = [0.35, 0.1, 0.0]; + v.length = 1.2; + v.width = 0.065; + v.appearance = appearance([1.0, 0.6, 0.08, 1.0], 1.8, BlendMode::Additive); + } + EffectKind::Decal(v) => { + v.dimensions = [2.0, 1.2, 2.0]; + v.appearance = appearance([0.12, 0.65, 0.95, 0.8], 0.35, BlendMode::Alpha); + } EffectKind::Beam(v) => { - v.end = [2.0, 2.0, 0.0]; - v.appearance.color = Gradient::constant([0.25, 0.5, 1.0, 1.0]); + v.end = [1.3, 2.2, 0.0]; + v.width = 0.05; + v.appearance.blend = BlendMode::Additive; + v.appearance.emission = 1.3; + v.appearance.color = Gradient::constant([0.5, 0.25, 1.0, 1.0]); } - EffectKind::ForceField(v) | EffectKind::Wind(v) => { + EffectKind::ForceField(v) => { v.dimensions = [3.0; 3]; - v.strength = 2.0; + v.layers = 1 << 28; + v.force_type = ForceType::Vortex; + v.direction = [0.0, 1.0, 0.0]; + v.strength = 3.0; + } + EffectKind::Wind(v) => { + v.dimensions = [3.0; 3]; + v.layers = 1 << 29; + v.direction = [1.0, 0.0, 0.2]; + v.strength = 1.4; + v.turbulence = 0.25; + } + EffectKind::CollisionVolume(v) => { + v.dimensions = [3.0; 3]; + v.layers = 1 << 30; + v.bounce = 0.8; + v.friction = 0.12; } EffectKind::Weather(v) => { v.emitter.dimensions = [3.0, 4.0, 3.0]; v.emitter.max_particles = 300; + v.emitter.start_size = [0.015, 0.03]; + v.emitter.stretch = 0.025; + v.emitter.rate = 90.0; + v.emitter.appearance = appearance([0.35, 0.65, 1.0, 0.6], 0.5, BlendMode::Alpha); } EffectKind::Fog(v) => { v.dimensions = [3.0, 2.0, 3.0]; - v.density = 0.08; + v.density = 0.11; + v.color = [0.24, 0.38, 0.58]; } EffectKind::Dissolve(v) => v.target = Some(target_id), EffectKind::AnimatedSurface(v) => { @@ -243,7 +398,9 @@ return { EffectKind::LensFlare(v) => v.light = Some(light_id), EffectKind::Outline(v) => v.targets = vec![target_id], EffectKind::PostProcess(v) => { - v.global = false; + v.global = true; + v.weight = 0.18; + v.bloom = 0.12; v.dimensions = [3.0; 3]; v.vignette = 0.2; v.saturation = 1.1; @@ -255,6 +412,7 @@ return { v.gravity = [0.0, -5.0, 0.0]; v.rate = 12.0; v.collision = true; + v.appearance = appearance([1.0, 0.55, 0.16, 1.0], 0.4, BlendMode::Alpha); } EffectKind::FoliageScatter(v) => { v.meshes = vec![leaf_meta.asset_id]; @@ -267,15 +425,92 @@ return { v.dimensions = [5.0; 3]; v.parallax_correction = true; } - EffectKind::ScreenFlash(v) => v.opacity = 0.03, + EffectKind::HeatHaze(v) => { + v.radius = 80.0; + v.strength = 0.002; + } + EffectKind::ScreenFlash(v) => v.opacity = 0.015, EffectKind::CameraShake(v) => { v.position_amplitude = [0.015; 3]; v.rotation_amplitude = [0.002; 3]; + v.radius = 0.0; } - _ => {} } - scene.entities.extend([target, light]); + if needs_target { + scene.entities.push(target); + } + if needs_light { + scene.entities.push(light); + } let mut component = EffectComponent::new(kind); + component.common.visibility_layers = 1; + if matches!( + component.kind, + EffectKind::ScreenFlash(_) | EffectKind::CameraShake(_) + ) { + component.common.looping = false; + component.common.duration = 1.0; + } + let companion = match &component.kind { + EffectKind::ForceField(_) => Some(( + 1_u32 << 28, + [1.0, 0.2, 0.75, 0.9], + "Vortex particles", + false, + Vec3::Y * 0.5, + )), + EffectKind::Wind(_) => Some(( + 1_u32 << 29, + [0.5, 1.0, 0.3, 0.9], + "Wind particles", + false, + Vec3::Y * 0.7, + )), + EffectKind::CollisionVolume(_) => Some(( + 1_u32 << 30, + [1.0, 0.65, 0.12, 1.0], + "Collision particles", + true, + Vec3::Y * 2.2, + )), + _ => None, + }; + if let Some((mask, color, label, collision, offset)) = companion { + let mut companion = EffectComponent::new(EffectKind::Particle(ParticleEmitter { + max_particles: 100, + rate: 30.0, + shape: SpawnShape::Sphere, + dimensions: [0.35; 3], + lifetime: [1.5, 2.0], + speed: if collision { [0.1, 0.2] } else { [0.05, 0.15] }, + direction: if collision { + [0.0, -1.0, 0.0] + } else { + [0.0, 1.0, 0.0] + }, + start_size: [0.025, 0.06], + gravity: if collision { + [0.0, -4.0, 0.0] + } else { + [0.0; 3] + }, + drag: 0.05, + wind_influence: 1.0, + appearance: appearance(color, 1.0, BlendMode::Additive), + collision, + collision_layers: mask, + bounce: 0.8, + ..ParticleEmitter::default() + })); + companion.common.visibility_layers = mask; + companion.common.random_seed = 500 + u64::try_from(index).unwrap_or_default(); + scene.entities.push(EntitySnapshot { + name: Some(label.into()), + local_transform: transform(position + offset), + effect: Some(companion), + ..EntitySnapshot::default() + }); + } component.common.random_seed = 100 + u64::try_from(index).unwrap_or_default(); let moving = matches!( component.kind, @@ -294,6 +529,41 @@ return { }; scene.entities.push(effect); } + let controller_id = ScriptId::new(); + let ids = scene + .entities + .iter() + .filter(|entity| entity.effect.is_some()) + .map(|entity| format!("'{}'", entity.id)) + .collect::>() + .join(","); + let controller = format!( + "local ids={{{ids}}}\nlocal was_held=false\nreturn {{FixedUpdate=function() local held=rustic.key('ArrowUp').held;if held and not was_held then for _,id in ipairs(ids) do Effects.restart(id) end end;was_held=held end}}\n" + ); + std::fs::write(destination.join("scripts/replay.lua"), &controller)?; + let mut manifest = + engine_scripting::load_manifest(&std::fs::read(destination.join("config/scripts.ron"))?)? + .manifest; + manifest.scripts.push(ScriptManifestEntry { + id: controller_id, + language: ScriptLanguage::Lua54, + relative_path: "scripts/replay.lua".into(), + api_version: ScriptApiVersion::CURRENT, + public_properties: vec![], + }); + save_manifest_atomic(&destination.join("config/scripts.ron"), &manifest)?; + scene.entities.push(EntitySnapshot { + name: Some("Replay all effects — ArrowUp".into()), + scripts: vec![engine_world::ScriptComponent::new(controller_id)], + ..EntitySnapshot::default() + }); + for relative in ["scripts/orbit.lua", "scripts/replay.lua"] { + engine_scripting::validate_lua( + &std::fs::read(destination.join(relative))?, + relative, + 1024 * 1024, + )?; + } let asset_directory = project.directory(VirtualDirectory::Assets)?.join("effects"); std::fs::create_dir_all(&asset_directory)?; for asset in presets() { @@ -316,17 +586,24 @@ return { save_scene_atomic(&scene_path, &scene)?; project.set_start_scene("showcase.scene")?; let loaded = engine_world::load_scene(&std::fs::read(&scene_path)?)?; + let loaded_effects: Vec<_> = loaded + .document + .entities + .iter() + .filter_map(|entity| entity.effect.as_ref()) + .collect(); + assert!(loaded_effects.len() >= 26); assert_eq!( - loaded - .document - .entities + loaded_effects .iter() - .filter(|e| e.effect.is_some()) - .count(), + .map(|effect| effect.kind.name()) + .collect::>() + .len(), 26 ); + assert_eq!(presets().len(), 35); println!( - "Generated {} with 26 effects and 35 editable preset assets", + "Generated {} with 29 effect components covering26 types and 35 editable preset assets", destination.display() ); Ok(()) diff --git a/Engine/crates/engine-world/src/effects/simulation.rs b/Engine/crates/engine-world/src/effects/simulation.rs index c070af13..e1a57eaa 100644 --- a/Engine/crates/engine-world/src/effects/simulation.rs +++ b/Engine/crates/engine-world/src/effects/simulation.rs @@ -384,7 +384,10 @@ impl EffectRuntime { self.camera_target = target; } pub fn remove(&mut self, entity: EntityId) { - self.instances.remove(&entity); + let completed = self + .instances + .remove(&entity) + .is_some_and(|instance| instance.state == PlaybackState::Completed); self.frame.geometry.retain(|v| v.entity != entity); self.frame.particles.retain(|v| v.entity != entity); self.frame.events.retain(|v| v.entity != entity); @@ -392,6 +395,11 @@ impl EffectRuntime { self.frame.probes.retain(|v| v.entity != entity); self.frame.decals.retain(|v| v.entity != entity); self.frame.water.retain(|v| v.entity != entity); + // Completed sources contributed no aggregate modifiers during the latest + // extraction. Preserve adjustments still owned by other active effects. + if completed { + return; + } // Aggregated camera and material adjustments are regenerated on update. self.frame.lights.clear(); self.frame.surfaces.clear(); diff --git a/Engine/crates/engine-world/src/world.rs b/Engine/crates/engine-world/src/world.rs index 11be7b8a..382f042e 100644 --- a/Engine/crates/engine-world/src/world.rs +++ b/Engine/crates/engine-world/src/world.rs @@ -724,7 +724,8 @@ impl SceneWorld { let camera = self.camera(entity).ok()??; camera.active.then_some((camera.order, entity)) }) - .max().map(|(_, entity)| entity); + .max() + .map(|(_, entity)| entity); let camera = camera_entity .and_then(|entity| self.world_transform(entity).ok()) .map_or(glam::Vec3::ZERO, |transform| transform.0.w_axis.truncate()); @@ -733,10 +734,20 @@ impl SceneWorld { /// Advances the shared simulation using an editor or custom viewport camera. /// A missing target previews camera effects independently of game-camera assignment. - pub fn update_effects_from_camera(&mut self, delta: f32, camera: glam::Vec3, camera_entity: Option) { - let transforms: BTreeMap<_, _> = self.entity_ids().filter_map(|entity| { - self.world_transform(entity).ok().map(|transform| (entity, transform.0)) - }).collect(); + pub fn update_effects_from_camera( + &mut self, + delta: f32, + camera: glam::Vec3, + camera_entity: Option, + ) { + let transforms: BTreeMap<_, _> = self + .entity_ids() + .filter_map(|entity| { + self.world_transform(entity) + .ok() + .map(|transform| (entity, transform.0)) + }) + .collect(); let inputs: Vec<_> = self .entity_ids() .filter_map(|entity| { @@ -748,7 +759,8 @@ impl SceneWorld { }) .collect(); self.effects_runtime.set_camera_target(camera_entity); - self.effects_runtime.update_with_targets(delta, &inputs, camera, &transforms); + self.effects_runtime + .update_with_targets(delta, &inputs, camera, &transforms); self.update_effect_children(delta); self.update_effect_group_events(); } @@ -756,20 +768,35 @@ impl SceneWorld { fn update_effect_group_events(&mut self) { let mut groups = BTreeSet::new(); for event in &self.effects_runtime.frame().events { - if event.kind != crate::effects::EffectEventKind::Completed { continue; } + if event.kind != crate::effects::EffectEventKind::Completed { + continue; + } let mut ancestor = self.parent(event.entity).ok().flatten(); while let Some(entity) = ancestor { - if self.ecs_entity(entity).is_ok_and(|entity| self.ecs.get::(entity).is_some()) { + if self + .ecs_entity(entity) + .is_ok_and(|entity| self.ecs.get::(entity).is_some()) + { groups.insert(entity); } ancestor = self.parent(entity).ok().flatten(); } } for entity in groups { - if self.effect_state(entity).ok().flatten() == Some(crate::effects::PlaybackState::Completed) - && self.completed_effect_groups.insert(entity) { - let position = self.world_transform(entity).map_or(glam::Vec3::ZERO, |transform| transform.0.w_axis.truncate()); - self.effects_runtime.emit_event(crate::effects::EffectEvent { entity, kind: crate::effects::EffectEventKind::Completed, position, child_effect: None }); + if self.effect_state(entity).ok().flatten() + == Some(crate::effects::PlaybackState::Completed) + && self.completed_effect_groups.insert(entity) + { + let position = self + .world_transform(entity) + .map_or(glam::Vec3::ZERO, |transform| transform.0.w_axis.truncate()); + self.effects_runtime + .emit_event(crate::effects::EffectEvent { + entity, + kind: crate::effects::EffectEventKind::Completed, + position, + child_effect: None, + }); } } } @@ -778,32 +805,64 @@ impl SceneWorld { /// /// # Errors /// Returns an error for an invalid asset or incompatible instance overrides. - pub fn register_effect_asset(&mut self, id: crate::AssetId, asset: crate::effects::EffectAsset) -> Result<(), WorldError> { + pub fn register_effect_asset( + &mut self, + id: crate::AssetId, + asset: crate::effects::EffectAsset, + ) -> Result<(), WorldError> { asset.validate().map_err(WorldError::InvalidArgument)?; let mut commands = Vec::new(); for entity in self.entity_ids() { - let Some(instance) = self.effect(entity)? else { continue; }; - let Some(source) = &instance.source else { continue; }; - if source.asset != id { continue; } - let Some(new_default) = asset.effects.get(source.child as usize) else { continue; }; + let Some(instance) = self.effect(entity)? else { + continue; + }; + let Some(source) = &instance.source else { + continue; + }; + if source.asset != id { + continue; + } + let Some(new_default) = asset.effects.get(source.child as usize) else { + continue; + }; let mut new_default = new_default.clone(); let parent = self.parent(entity)?; - let remap: BTreeMap<_, _> = self.entity_ids().filter_map(|sibling| { - if self.parent(sibling).ok()? != parent { return None; } - let source = self.effect(sibling).ok()??.source?; - if source.asset != id { return None; } - Some((*asset.entity_ids.get(source.child as usize)?, sibling)) - }).collect(); + let remap: BTreeMap<_, _> = self + .entity_ids() + .filter_map(|sibling| { + if self.parent(sibling).ok()? != parent { + return None; + } + let source = self.effect(sibling).ok()??.source?; + if source.asset != id { + return None; + } + Some((*asset.entity_ids.get(source.child as usize)?, sibling)) + }) + .collect(); new_default.remap_entities(&remap); - let Some(baseline) = &source.baseline else { continue; }; + let Some(baseline) = &source.baseline else { + continue; + }; let old_default = baseline.as_ref().clone().into_component(); - let mut merged = crate::effects::merge_instance_defaults(&instance, &old_default, &new_default).map_err(WorldError::InvalidArgument)?; + let mut merged = + crate::effects::merge_instance_defaults(&instance, &old_default, &new_default) + .map_err(WorldError::InvalidArgument)?; if let Some(source) = &mut merged.source { - source.baseline = Some(Box::new(crate::effects::EffectDefaults::from_component(&new_default))); + source.baseline = Some(Box::new(crate::effects::EffectDefaults::from_component( + &new_default, + ))); + } + if merged != instance { + commands.push(WorldCommand::SetEffect { + entity, + value: Some(Box::new(merged)), + }); } - if merged != instance { commands.push(WorldCommand::SetEffect { entity, value: Some(Box::new(merged)) }); } } - if !commands.is_empty() { self.apply_commands(&commands)?; } + if !commands.is_empty() { + self.apply_commands(&commands)?; + } self.effect_assets.insert(id, asset); Ok(()) } @@ -816,9 +875,22 @@ impl SceneWorld { /// /// # Errors /// Returns an error for a missing asset, invalid parent or invalid transform. - pub fn instantiate_effect_asset(&mut self, asset_id: crate::AssetId, parent: Option, transform: LocalTransform) -> Result { - let asset = self.effect_assets.get(&asset_id).ok_or_else(|| WorldError::InvalidArgument(format!("effect asset {asset_id} is unavailable")))?; - let root = EntitySnapshot { name: Some(asset.name.clone()), folder: true, parent, local_transform: transform, ..Default::default() }; + pub fn instantiate_effect_asset( + &mut self, + asset_id: crate::AssetId, + parent: Option, + transform: LocalTransform, + ) -> Result { + let asset = self.effect_assets.get(&asset_id).ok_or_else(|| { + WorldError::InvalidArgument(format!("effect asset {asset_id} is unavailable")) + })?; + let root = EntitySnapshot { + name: Some(asset.name.clone()), + folder: true, + parent, + local_transform: transform, + ..Default::default() + }; let root_id = root.id; let mut commands = vec![WorldCommand::Spawn(Box::new(root))]; for mut snapshot in asset.instances_with_ids(Some(asset_id)) { @@ -832,9 +904,16 @@ impl SceneWorld { /// Samples global and local wind using the same fields as particle simulation. pub fn sample_effect_wind(&self, position: glam::Vec3, time: f32, layers: u32) -> glam::Vec3 { - let inputs: Vec<_> = self.entity_ids().filter_map(|entity| Some(crate::effects::EffectInput { - entity, transform: self.world_transform(entity).ok()?.0, effect: self.effect(entity).ok()??, - })).collect(); + let inputs: Vec<_> = self + .entity_ids() + .filter_map(|entity| { + Some(crate::effects::EffectInput { + entity, + transform: self.world_transform(entity).ok()?.0, + effect: self.effect(entity).ok()??, + }) + }) + .collect(); crate::effects::sample_wind(&inputs, position, time, layers) } @@ -842,44 +921,94 @@ impl SceneWorld { // Cosmetic child effects cannot recursively grow without a hard bound. const MAX_CHILDREN: usize = 1024; const MAX_SPAWNS_PER_TICK: usize = 128; + // This fallback budget uses the same clock as simulation. Paused, + // disabled or zero-speed children retain their remaining authored time. + let clocks: BTreeMap<_, _> = self + .temporary_effects + .keys() + .filter_map(|entity| { + let mut effect = self.effect(*entity).ok()??; + effect.resolve_parameters(); + Some((*entity, (effect.common.enabled, effect.common.time_scale))) + }) + .collect(); + let delta = if delta.is_finite() { + delta.clamp(0.0, 1.0) + } else { + 0.0 + }; let mut expired = Vec::new(); for (entity, (_, remaining)) in &mut self.temporary_effects { - *remaining -= delta.max(0.0); - if *remaining <= 0.0 { expired.push(*entity); } - } - if !expired.is_empty() { - let commands: Vec<_> = expired.into_iter().map(WorldCommand::Despawn).collect(); - let _ = self.apply_commands(&commands); + use crate::effects::PlaybackState; + let state = self.effects_runtime.state(*entity); + let Some((enabled, time_scale)) = clocks.get(entity) else { + expired.push(*entity); + continue; + }; + if matches!(state, PlaybackState::Completed | PlaybackState::Disposed) { + expired.push(*entity); + } else if state == PlaybackState::Playing && *enabled { + *remaining -= delta * time_scale; + if *remaining <= 0.0 { + expired.push(*entity); + } + } } + // Preserve this frame's death/collision triggers until their source + // identity and generation have been resolved, even when it completes. let events = self.effects_runtime.frame().events.clone(); let mut commands = Vec::new(); for event in events { - let Some(asset_id) = event.child_effect else { continue; }; - let generation = self.temporary_effects.get(&event.entity).map_or(0, |(generation, _)| *generation); - let maximum = self.effect(event.entity).ok().flatten().map_or(0, |effect| match effect.kind { - crate::effects::EffectKind::Particle(p) | crate::effects::EffectKind::MeshParticle(p) => p.max_generations.min(8), - crate::effects::EffectKind::Weather(w) => w.emitter.max_generations.min(8), - crate::effects::EffectKind::CollisionVolume(_) => 3, - _ => 0, - }); - if generation >= maximum { continue; } - let Some(asset) = self.effect_assets.get(&asset_id) else { continue; }; - if commands.len() + asset.effects.len() > MAX_SPAWNS_PER_TICK || self.temporary_effects.len() + asset.effects.len() > MAX_CHILDREN { continue; } + let Some(asset_id) = event.child_effect else { + continue; + }; + let generation = self + .temporary_effects + .get(&event.entity) + .map_or(0, |(generation, _)| *generation); + let maximum = self + .effect(event.entity) + .ok() + .flatten() + .map_or(0, |effect| match effect.kind { + crate::effects::EffectKind::Particle(p) + | crate::effects::EffectKind::MeshParticle(p) => p.max_generations.min(8), + crate::effects::EffectKind::Weather(w) => w.emitter.max_generations.min(8), + crate::effects::EffectKind::CollisionVolume(_) => 3, + _ => 0, + }); + if generation >= maximum { + continue; + } + let Some(asset) = self.effect_assets.get(&asset_id) else { + continue; + }; + if commands.len() + asset.effects.len() > MAX_SPAWNS_PER_TICK + || self.temporary_effects.len() + asset.effects.len() > MAX_CHILDREN + { + continue; + } for mut snapshot in asset.instances_with_ids(Some(asset_id)) { - let Some(effect) = &mut snapshot.effect else { continue; }; + let Some(effect) = &mut snapshot.effect else { + continue; + }; effect.common.looping = false; let tail = match &effect.kind { - crate::effects::EffectKind::Particle(p) | crate::effects::EffectKind::MeshParticle(p) => p.lifetime[1], + crate::effects::EffectKind::Particle(p) + | crate::effects::EffectKind::MeshParticle(p) => p.lifetime[1], + crate::effects::EffectKind::Weather(w) => w.emitter.lifetime[1], crate::effects::EffectKind::Trail(t) => t.lifetime, _ => 0.0, }; let lifetime = effect.common.delay + effect.common.duration + tail; snapshot.name = Some(format!("{} (sub-emitter)", asset.name)); snapshot.local_transform.translation += event.position; - self.temporary_effects.insert(snapshot.id, (generation + 1, lifetime)); + self.temporary_effects + .insert(snapshot.id, (generation + 1, lifetime)); commands.push(WorldCommand::Spawn(Box::new(snapshot))); } } + commands.extend(expired.into_iter().map(WorldCommand::Despawn)); if !commands.is_empty() { let _ = self.apply_commands(&commands); self.propagate_transforms(); @@ -904,16 +1033,31 @@ impl SceneWorld { targets.insert(0, id); let mut found = false; for entity in targets { - if matches!(command, crate::effects::PlaybackCommand::Play | crate::effects::PlaybackCommand::Restart | crate::effects::PlaybackCommand::Reset) { + if matches!( + command, + crate::effects::PlaybackCommand::Play + | crate::effects::PlaybackCommand::Restart + | crate::effects::PlaybackCommand::Reset + ) { self.completed_effect_groups.remove(&entity); } - let Some(effect) = self.effect(entity)? else { continue; }; + let Some(effect) = self.effect(entity)? else { + continue; + }; found = true; let transform = self.world_transform(entity)?.0; - self.effects_runtime.ensure(&crate::effects::EffectInput { entity, transform, effect }); + self.effects_runtime.ensure(&crate::effects::EffectInput { + entity, + transform, + effect, + }); self.effects_runtime.control(entity, command); } - if !found { return Err(WorldError::InvalidArgument("entity subtree has no scene effect".into())); } + if !found { + return Err(WorldError::InvalidArgument( + "entity subtree has no scene effect".into(), + )); + } Ok(()) } @@ -926,13 +1070,31 @@ impl SceneWorld { id: EntityId, ) -> Result, WorldError> { use crate::effects::PlaybackState; - if self.effect(id)?.is_some() { return Ok(Some(self.effects_runtime.state(id))); } - let states: Vec<_> = self.descendants(id)?.into_iter().filter(|entity| self.effect(*entity).ok().flatten().is_some()).map(|entity| self.effects_runtime.state(entity)).collect(); - Ok(if states.is_empty() { None } else if states.contains(&PlaybackState::Playing) { Some(PlaybackState::Playing) } - else if states.contains(&PlaybackState::Paused) { Some(PlaybackState::Paused) } - else if states.iter().all(|state| *state == PlaybackState::Completed) { Some(PlaybackState::Completed) } - else if states.iter().all(|state| *state == PlaybackState::Disposed) { Some(PlaybackState::Disposed) } - else { Some(PlaybackState::Stopped) }) + if self.effect(id)?.is_some() { + return Ok(Some(self.effects_runtime.state(id))); + } + let states: Vec<_> = self + .descendants(id)? + .into_iter() + .filter(|entity| self.effect(*entity).ok().flatten().is_some()) + .map(|entity| self.effects_runtime.state(entity)) + .collect(); + Ok(if states.is_empty() { + None + } else if states.contains(&PlaybackState::Playing) { + Some(PlaybackState::Playing) + } else if states.contains(&PlaybackState::Paused) { + Some(PlaybackState::Paused) + } else if states + .iter() + .all(|state| *state == PlaybackState::Completed) + { + Some(PlaybackState::Completed) + } else if states.iter().all(|state| *state == PlaybackState::Disposed) { + Some(PlaybackState::Disposed) + } else { + Some(PlaybackState::Stopped) + }) } /// Returns the optional surface texture component. diff --git a/Engine/crates/engine-world/tests/effects_persistence.rs b/Engine/crates/engine-world/tests/effects_persistence.rs index 9efadfa7..2437610b 100644 --- a/Engine/crates/engine-world/tests/effects_persistence.rs +++ b/Engine/crates/engine-world/tests/effects_persistence.rs @@ -29,10 +29,20 @@ fn effect_edits_delete_and_restore_use_existing_undo() { let mut world = SceneWorld::new(); let entity = EntitySnapshot::default(); let id = entity.id; - world.apply_commands(&[WorldCommand::Spawn(Box::new(entity))]).unwrap(); + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); let mut undo = UndoStack::new(); - let effect = EffectComponent::new(EffectKind::Beam(Default::default())); - undo.execute(&mut world, SceneEdit::Effect { entity: id, before: None, after: Some(effect.clone()) }).unwrap(); + let effect = EffectComponent::new(EffectKind::Beam(BeamRenderer::default())); + undo.execute( + &mut world, + SceneEdit::Effect { + entity: id, + before: None, + after: Some(effect.clone()), + }, + ) + .unwrap(); assert_eq!(world.effect(id).unwrap(), Some(effect.clone())); undo.undo(&mut world).unwrap(); assert!(world.effect(id).unwrap().is_none()); @@ -50,19 +60,33 @@ fn additive_instances_remap_effect_attachments_and_targets() { let effect = EntitySnapshot { effect: Some(EffectComponent { source: None, - common: EffectCommon { attachment_target: Some(target.id), ..Default::default() }, - kind: EffectKind::Beam(BeamRenderer { start_target: Some(target.id), end_target: Some(target.id), ..Default::default() }), + common: EffectCommon { + attachment_target: Some(target.id), + ..Default::default() + }, + kind: EffectKind::Beam(BeamRenderer { + start_target: Some(target.id), + end_target: Some(target.id), + ..Default::default() + }), }), ..Default::default() }; let mut document = SceneDocument::new("Reusable effect"); document.entities = vec![target.clone(), effect.clone()]; let mut world = SceneWorld::new(); - let instance = world.instantiate_scene(&document, SceneInstanceId::new(), None).unwrap(); - let remapped = world.effect(instance.entity_map[&effect.id]).unwrap().unwrap(); + let instance = world + .instantiate_scene(&document, SceneInstanceId::new(), None) + .unwrap(); + let remapped = world + .effect(instance.entity_map[&effect.id]) + .unwrap() + .unwrap(); let target_id = instance.entity_map[&target.id]; assert_eq!(remapped.common.attachment_target, Some(target_id)); - let EffectKind::Beam(beam) = remapped.kind else { panic!("wrong effect type") }; + let EffectKind::Beam(beam) = remapped.kind else { + panic!("wrong effect type") + }; assert_eq!(beam.start_target, Some(target_id)); assert_eq!(beam.end_target, Some(target_id)); } @@ -72,13 +96,25 @@ fn invalid_effect_cannot_partially_commit_a_transaction() { let mut world = SceneWorld::new(); let entity = EntitySnapshot::default(); let id = entity.id; - world.apply_commands(&[WorldCommand::Spawn(Box::new(entity))]).unwrap(); + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); let mut effect = EffectComponent::default(); effect.common.duration = f32::NAN; - assert!(world.apply_commands(&[ - WorldCommand::SetName { entity: id, value: Some("must not commit".into()) }, - WorldCommand::SetEffect { entity: id, value: Some(Box::new(effect)) }, - ]).is_err()); + assert!( + world + .apply_commands(&[ + WorldCommand::SetName { + entity: id, + value: Some("must not commit".into()) + }, + WorldCommand::SetEffect { + entity: id, + value: Some(Box::new(effect)) + }, + ]) + .is_err() + ); assert!(world.snapshot(id).unwrap().name.is_none()); } @@ -90,11 +126,16 @@ fn play_before_first_tick_and_pause_use_live_world_runtime() { effect.common.auto_play = false; entity.effect = Some(effect); let mut world = SceneWorld::new(); - world.apply_commands(&[WorldCommand::Spawn(Box::new(entity))]).unwrap(); + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); world.propagate_transforms(); world.control_effect(id, PlaybackCommand::Play).unwrap(); world.update_effects(0.1); - assert_eq!(world.effect_state(id).unwrap(), Some(PlaybackState::Playing)); + assert_eq!( + world.effect_state(id).unwrap(), + Some(PlaybackState::Playing) + ); assert!(!world.effect_frame().particles.is_empty()); world.control_effect(id, PlaybackCommand::Pause).unwrap(); let count = world.effect_frame().particles.len(); @@ -108,19 +149,33 @@ fn registered_subemitters_spawn_and_cleanup_with_bounded_generations() { let child_id = AssetId::new(); let child = EffectComponent { source: None, - common: EffectCommon { duration: 0.01, looping: false, ..Default::default() }, + common: EffectCommon { + duration: 0.01, + looping: false, + ..Default::default() + }, kind: EffectKind::Particle(ParticleEmitter { - rate: 0.0, lifetime: [0.01, 0.01], - bursts: vec![Burst { count: 1, ..Default::default() }], - sub_emitter: Some(child_id), max_generations: 2, + rate: 0.0, + lifetime: [0.01, 0.01], + bursts: vec![Burst { + count: 1, + ..Default::default() + }], + sub_emitter: Some(child_id), + max_generations: 2, ..Default::default() }), }; let mut world = SceneWorld::new(); - world.register_effect_asset(child_id, EffectAsset::new("recursive", vec![child.clone()])).unwrap(); - world.apply_commands(&[WorldCommand::Spawn(Box::new(EntitySnapshot { - effect: Some(child), ..Default::default() - }))]).unwrap(); + world + .register_effect_asset(child_id, EffectAsset::new("recursive", vec![child.clone()])) + .unwrap(); + world + .apply_commands(&[WorldCommand::Spawn(Box::new(EntitySnapshot { + effect: Some(child), + ..Default::default() + }))]) + .unwrap(); world.propagate_transforms(); for _ in 0..20 { world.update_effects(0.02); @@ -134,33 +189,62 @@ fn composite_asset_instances_keep_source_identity_and_control_as_a_group() { let asset = presets().pop().unwrap(); let asset_id = AssetId::new(); let mut world = SceneWorld::new(); - world.register_effect_asset(asset_id, asset.clone()).unwrap(); - let root = world.instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY).unwrap(); + world + .register_effect_asset(asset_id, asset.clone()) + .unwrap(); + let root = world + .instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY) + .unwrap(); assert_eq!(world.children(root).unwrap().len(), asset.effects.len()); for child in world.children(root).unwrap() { let effect = world.effect(*child).unwrap().unwrap(); assert_eq!(effect.source.unwrap().asset, asset_id); } world.control_effect(root, PlaybackCommand::Pause).unwrap(); - assert_eq!(world.effect_state(root).unwrap(), Some(PlaybackState::Paused)); - world.control_effect(root, PlaybackCommand::Restart).unwrap(); + assert_eq!( + world.effect_state(root).unwrap(), + Some(PlaybackState::Paused) + ); + world + .control_effect(root, PlaybackCommand::Restart) + .unwrap(); world.update_effects(0.01); - assert_eq!(world.effect_state(root).unwrap(), Some(PlaybackState::Playing)); + assert_eq!( + world.effect_state(root).unwrap(), + Some(PlaybackState::Playing) + ); } #[test] +#[allow( + clippy::float_cmp, + reason = "saved authoring values and baseline defaults must transfer exactly" +)] fn loading_changed_assets_refreshes_defaults_without_losing_saved_overrides() { let asset_id = AssetId::new(); let mut asset = EffectAsset::new("shared", vec![EffectComponent::default()]); let mut world = SceneWorld::new(); - world.register_effect_asset(asset_id, asset.clone()).unwrap(); - let root = world.instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY).unwrap(); + world + .register_effect_asset(asset_id, asset.clone()) + .unwrap(); + let root = world + .instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY) + .unwrap(); let entity = world.children(root).unwrap()[0]; let mut effect = world.effect(entity).unwrap().unwrap(); effect.common.duration = 12.0; - world.apply_commands(&[WorldCommand::SetEffect { entity, value: Some(Box::new(effect)) }]).unwrap(); + world + .apply_commands(&[WorldCommand::SetEffect { + entity, + value: Some(Box::new(effect)), + }]) + .unwrap(); let document = SceneDocument::from_world(SceneId::new(), "saved overrides", &world).unwrap(); - let mut reopened = load_scene(&document.to_bytes().unwrap()).unwrap().document.create_world().unwrap(); + let mut reopened = load_scene(&document.to_bytes().unwrap()) + .unwrap() + .document + .create_world() + .unwrap(); asset.effects[0].common.duration = 9.0; asset.effects[0].common.render_distance = 50.0; reopened.register_effect_asset(asset_id, asset).unwrap(); @@ -174,18 +258,184 @@ fn loading_changed_assets_refreshes_defaults_without_losing_saved_overrides() { #[test] fn composite_completion_emits_once_and_replays_after_restart() { - let mut effect = EffectComponent::new(EffectKind::ScreenFlash(Default::default())); + let mut effect = EffectComponent::new(EffectKind::ScreenFlash(ScreenFlash::default())); effect.common.looping = false; effect.common.duration = 0.01; let asset_id = AssetId::new(); let mut world = SceneWorld::new(); - world.register_effect_asset(asset_id, EffectAsset::new("finite composite", vec![effect])).unwrap(); - let root = world.instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY).unwrap(); + world + .register_effect_asset(asset_id, EffectAsset::new("finite composite", vec![effect])) + .unwrap(); + let root = world + .instantiate_effect_asset(asset_id, None, LocalTransform::IDENTITY) + .unwrap(); world.update_effects(0.02); - assert_eq!(world.effect_frame().events.iter().filter(|event| event.entity == root && event.kind == EffectEventKind::Completed).count(), 1); + assert_eq!( + world + .effect_frame() + .events + .iter() + .filter(|event| event.entity == root && event.kind == EffectEventKind::Completed) + .count(), + 1 + ); world.update_effects(0.02); - assert!(!world.effect_frame().events.iter().any(|event| event.entity == root)); - world.control_effect(root, PlaybackCommand::Restart).unwrap(); + assert!( + !world + .effect_frame() + .events + .iter() + .any(|event| event.entity == root) + ); + world + .control_effect(root, PlaybackCommand::Restart) + .unwrap(); world.update_effects(0.02); - assert_eq!(world.effect_frame().events.iter().filter(|event| event.entity == root && event.kind == EffectEventKind::Completed).count(), 1); + assert_eq!( + world + .effect_frame() + .events + .iter() + .filter(|event| event.entity == root && event.kind == EffectEventKind::Completed) + .count(), + 1 + ); +} + +#[test] +fn paused_slow_sub_effect_uses_playback_time_and_cleans_up_after_resume() { + let child_asset = AssetId::new(); + let mut child = EffectComponent::new(EffectKind::ScreenFlash(ScreenFlash::default())); + child.common.duration = 0.2; + child.common.time_scale = 0.1; + let mut world = SceneWorld::new(); + world + .register_effect_asset(child_asset, EffectAsset::new("slow flash", vec![child])) + .unwrap(); + let source = EntitySnapshot { + effect: Some(EffectComponent::new(EffectKind::Particle( + ParticleEmitter { + rate: 0.0, + lifetime: [10.0, 10.0], + bursts: vec![Burst { + count: 1, + ..Burst::default() + }], + sub_emitter: Some(child_asset), + max_generations: 1, + ..ParticleEmitter::default() + }, + ))), + ..EntitySnapshot::default() + }; + let source_id = source.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(source))]) + .unwrap(); + world.propagate_transforms(); + world.update_effects(0.01); + let child_id = world + .entity_ids() + .find(|entity| *entity != source_id) + .expect("birth spawns child"); + assert_eq!(world.entity_count(), 2); + world + .control_effect(source_id, PlaybackCommand::Pause) + .unwrap(); + world.update_effects(0.1); + world + .control_effect(child_id, PlaybackCommand::Pause) + .unwrap(); + for _ in 0..3 { + world.update_effects(1.0); + } + assert_eq!( + world.effect_state(child_id).unwrap(), + Some(PlaybackState::Paused) + ); + world + .control_effect(child_id, PlaybackCommand::Resume) + .unwrap(); + world.update_effects(1.0); + assert_eq!( + world.effect_state(child_id).unwrap(), + Some(PlaybackState::Playing) + ); + world.update_effects(1.0); + assert!(world.snapshot(child_id).is_err()); + assert_eq!(world.entity_count(), 1); +} + +#[test] +fn completed_sub_effect_cleanup_preserves_other_post_and_light_modifiers() { + let child_asset = AssetId::new(); + let mut child = EffectComponent::new(EffectKind::ScreenFlash(ScreenFlash::default())); + child.common.duration = 0.05; + let mut world = SceneWorld::new(); + world + .register_effect_asset(child_asset, EffectAsset::new("brief flash", vec![child])) + .unwrap(); + let light = EntitySnapshot { + light: Some(Light::default()), + ..EntitySnapshot::default() + }; + let light_id = light.id; + let post = EntitySnapshot { + effect: Some(EffectComponent::new(EffectKind::PostProcess( + PostProcessVolume { + exposure: 2.0, + ..PostProcessVolume::default() + }, + ))), + ..EntitySnapshot::default() + }; + let pulse = EntitySnapshot { + effect: Some(EffectComponent::new(EffectKind::LightPulse(LightPulse { + target: Some(light_id), + intensity: Curve::constant(1.5), + ..LightPulse::default() + }))), + ..EntitySnapshot::default() + }; + let source = EntitySnapshot { + effect: Some(EffectComponent::new(EffectKind::Particle( + ParticleEmitter { + rate: 0.0, + lifetime: [10.0, 10.0], + bursts: vec![Burst { + count: 1, + ..Burst::default() + }], + sub_emitter: Some(child_asset), + max_generations: 1, + ..ParticleEmitter::default() + }, + ))), + ..EntitySnapshot::default() + }; + let source_id = source.id; + world + .apply_commands( + &[light, post, pulse, source] + .into_iter() + .map(|snapshot| WorldCommand::Spawn(Box::new(snapshot))) + .collect::>(), + ) + .unwrap(); + world.propagate_transforms(); + world.update_effects(0.01); + assert_eq!(world.entity_count(), 5); + world + .control_effect(source_id, PlaybackCommand::Pause) + .unwrap(); + world.update_effects(0.1); + assert_eq!(world.entity_count(), 4); + assert!((world.effect_frame().post.exposure - 2.0).abs() < f32::EPSILON); + let modifier = world + .effect_frame() + .lights + .iter() + .find(|modifier| modifier.target == light_id) + .expect("unrelated pulse remains after child cleanup"); + assert!((modifier.intensity - 1.5).abs() < f32::EPSILON); } diff --git a/Engine/crates/renderer-wgpu/examples/effects_showcase.rs b/Engine/crates/renderer-wgpu/examples/effects_showcase.rs index 9b0c5aed..c692cb70 100644 --- a/Engine/crates/renderer-wgpu/examples/effects_showcase.rs +++ b/Engine/crates/renderer-wgpu/examples/effects_showcase.rs @@ -1,18 +1,48 @@ //! Render the real serialized showcase without opening a native window. use std::collections::BTreeMap; use std::path::PathBuf; -fn main()->Result<(),Box> { - let root=PathBuf::from(std::env::args().nth(1).unwrap_or_else(||"examples/scene-effects/project".into())); - let output=PathBuf::from(std::env::args().nth(2).unwrap_or_else(||"/workspace/scratch/scene-effects.png".into())); - let loaded=engine_world::load_scene(&std::fs::read(root.join("scene/showcase.scene"))?)?; - let mut world=loaded.document.create_world()?; - let models:BTreeMap<_,_>=engine_assets::load_model_library(&root)?.into_iter().map(|(id,(_,model))|(id,model)).collect(); - world.propagate_transforms(); - for _ in 0..90 {world.update_effects(1./60.);} - let scene=renderer_wgpu::game_scene_with_models(&world,16./9.,&models); - let mut renderer=renderer_wgpu::SceneViewportRenderer::new(renderer_wgpu::BackendRequest::Auto)?; - renderer.set_environment_root(&root);renderer.set_effect_frame(world.effect_frame())?; - let frame=renderer.render(1280,720,&scene)?.ok_or("no render frame")?; - image::save_buffer(&output,&frame.rgba8,frame.width,frame.height,image::ColorType::Rgba8)?; - println!("Rendered {} meshes, {} particles with {:?}: {}",scene.meshes.len(),world.effect_frame().particles.len(),frame.adapter,output.display());Ok(()) +fn main() -> Result<(), Box> { + let root = PathBuf::from( + std::env::args() + .nth(1) + .unwrap_or_else(|| "examples/scene-effects/project".into()), + ); + let output = PathBuf::from( + std::env::args() + .nth(2) + .unwrap_or_else(|| "/workspace/scratch/scene-effects.png".into()), + ); + let loaded = engine_world::load_scene(&std::fs::read(root.join("scene/showcase.scene"))?)?; + let mut world = loaded.document.create_world()?; + let models: BTreeMap<_, _> = engine_assets::load_model_library(&root)? + .into_iter() + .map(|(id, (_, model))| (id, model)) + .collect(); + world.propagate_transforms(); + for _ in 0..90 { + world.update_effects(1. / 60.); + } + let scene = renderer_wgpu::game_scene_with_models(&world, 16. / 9., &models); + let mut renderer = + renderer_wgpu::SceneViewportRenderer::new(renderer_wgpu::BackendRequest::Auto)?; + renderer.set_environment_root(&root); + renderer.set_effect_frame(world.effect_frame())?; + let frame = renderer + .render(1280, 720, &scene)? + .ok_or("no render frame")?; + image::save_buffer( + &output, + &frame.rgba8, + frame.width, + frame.height, + image::ColorType::Rgba8, + )?; + println!( + "Rendered {} meshes, {} particles with {:?}: {}", + scene.meshes.len(), + world.effect_frame().particles.len(), + frame.adapter, + output.display() + ); + Ok(()) } diff --git a/Engine/crates/renderer-wgpu/src/effects.rs b/Engine/crates/renderer-wgpu/src/effects.rs index 7e71166e..fb6ba447 100644 --- a/Engine/crates/renderer-wgpu/src/effects.rs +++ b/Engine/crates/renderer-wgpu/src/effects.rs @@ -26,13 +26,15 @@ struct Camera { view_projection:mat4x4 }; struct Input { @location(0) position:vec3,@location(1) uv:vec2,@location(2) color:vec4,@location(3) sprite:f32 }; struct Output { @builtin(position) position:vec4,@location(0) uv:vec2,@location(1) color:vec4,@location(2) sprite:f32 }; @vertex fn vs_main(i:Input)->Output {var o:Output;o.position=camera.view_projection*vec4(i.position,1.);o.uv=i.uv;o.color=i.color;o.sprite=i.sprite;return o;} -@fragment fn fs_main(i:Output)->@location(0) vec4 { +fn shade(i:Output)->vec4 { let radius=length(i.uv*2.-vec2(1.)); let softness=select(1.,pow(max(1.-radius*radius,0.),2.),i.sprite>0.5 && i.sprite<1.5); var color=vec4(i.color.rgb,i.color.a*softness); if i.sprite>1.5 {color*=textureSampleLevel(sprites,sprite_sampler,i.uv,i32(i.sprite-2.),0.);} return color; } +@fragment fn fs_main(i:Output)->@location(0) vec4 {return shade(i);} +@fragment fn fs_multiply(i:Output)->@location(0) vec4 {let color=shade(i);return vec4(color.rgb*color.a,color.a);} "; #[derive(Clone, Copy, Debug, PartialEq)] struct Vertex { @@ -109,6 +111,7 @@ impl EffectsRenderer { immediate_size: 0, }); let pipelines = std::array::from_fn(|blend| { + let multiply = blend == 2; let blend = match blend { 1 => wgpu::BlendState { color: wgpu::BlendComponent { @@ -179,7 +182,7 @@ impl EffectsRenderer { }, fragment: Some(wgpu::FragmentState { module: &module, - entry_point: Some("fs_main"), + entry_point: Some(if multiply { "fs_multiply" } else { "fs_main" }), compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { format: wgpu::TextureFormat::Rgba8Unorm, @@ -500,6 +503,27 @@ mod gpu_tests { let center = (32 * 64 + 32) * 4; assert!(particles.rgba8[center] > 200); assert_eq!(base.rgba8[center], 0); + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join("assets")).unwrap(); + let image_path = project.path().join("assets/green.png"); + image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 255, 0, 255])) + .save(&image_path) + .unwrap(); + let meta = engine_assets::AssetMeta::new("rustic.image", 1); + engine_assets::save_meta(&engine_assets::sidecar_path(&image_path), &meta).unwrap(); + let mut textured = frame.clone(); + textured.particles[0].texture = Some(meta.asset_id); + textured.particles[0].color = [1.; 4]; + renderer.set_environment_root(project.path()); + renderer.set_effect_frame(&textured).unwrap(); + let sprite = renderer.render(64, 64, &scene).unwrap().unwrap(); + assert!(sprite.rgba8[center + 1] > 200 && sprite.rgba8[center] < 10); + let mut multiply = frame.clone(); + multiply.particles[0].blend = BlendMode::Multiply; + multiply.particles[0].color = [0.5; 4]; + renderer.set_effect_frame(&multiply).unwrap(); + let darkened = renderer.render(64, 64, &scene).unwrap().unwrap(); + assert!(darkened.rgba8[center + 2] < base.rgba8[center + 2] - 5); frame.post.distortion = 0.15; frame.post.time = 1.2; renderer.set_effect_frame(&frame).unwrap(); diff --git a/Engine/docs/SCENE_EFFECTS.md b/Engine/docs/SCENE_EFFECTS.md index 573aa7ad..b71b2ded 100644 --- a/Engine/docs/SCENE_EFFECTS.md +++ b/Engine/docs/SCENE_EFFECTS.md @@ -166,6 +166,8 @@ Use bounded particle/point/instance counts, shorter lifetimes and local volumes. World effect state is keyed by stable entity IDs. Destroying entities or unloading scenes must release their simulation state. Playback does not permanently overwrite the authored camera or light configuration; frame modifiers are applied during rendering. Reuse configured entities for repeated bursts when practical. Asset instantiation clones authored data; it is not a GPU resource duplication guarantee. +Sub-emitter `max_generations` is compared with the current absolute generation at each emitter, so a child may use a different limit from its source; the entire chain is capped at eight generations, 128 child spawns per tick and 1,024 active temporary child effects. + ## Showcase and troubleshooting From the engine's `Engine/` directory: diff --git a/Engine/examples/scene-effects/README.md b/Engine/examples/scene-effects/README.md index 0d228026..e66111b1 100644 --- a/Engine/examples/scene-effects/README.md +++ b/Engine/examples/scene-effects/README.md @@ -6,10 +6,18 @@ Generate a normal Rustic project through the public scene/project APIs: cargo run -p engine-project --example scene_effects_showcase -- /tmp/rustic-effects-showcase ``` -Run this command from `Engine/`. The destination must be empty; the generator never replaces an existing project. Open the generated `project.engine` through Project Manager. The scene contains numbered entities for all 26 component types, targets and lights, a moving Lua trail/ripple demonstration, procedural debris/foliage OBJ meshes, a baked HDR probe panorama and 35 editable `.effect` assets with authoritative `.rmeta` sidecars. The generator reloads its checksummed scene and verifies 26 effect components before succeeding. +Run this command from `Engine/`. The destination must be empty; the generator never replaces an existing project. Open the generated `project.engine` through Project Manager. The scene contains numbered entities for all 26 component types, targets and lights, a moving Lua trail/ripple demonstration, a replay controller, an animated energy-grid texture, procedural debris/foliage OBJ meshes, a baked HDR probe panorama and 35 editable `.effect` assets with authoritative `.rmeta` sidecars. The generator reloads its checksummed scene and verifies 26 distinct effect types before succeeding. -Select an effect in the hierarchy and use Properties to change settings. **Effect preview** contains Play/Pause/Resume/Stop/Restart/Reset, frame stepping, speed, deterministic time replay, bounds and metrics. Press Play to run the attached orbit behavior and scene effects. Restart one-shot effects from preview. Stop Play before saving authored changes. +Select an effect in the hierarchy and use Properties to change settings. **Effect preview** contains Play/Pause/Resume/Stop/Restart/Reset, frame stepping, speed, deterministic time replay, bounds and metrics. Press Play to run the attached orbit behavior and scene effects. Click the Play viewport to focus it, then press **ArrowUp** to restart all scene effects; holding the key triggers one restart until released. The replay controller uses the supported input API and caches the generated effect IDs. Restart one-shot effects from preview. Stop Play before saving authored changes. The Inspector's **Effects presets & assets** loads/saves assets and instantiates child groups. Select a composite group to edit its child timeline; source children expose restoring asset defaults and updating the shared source file. Built-in presets are editable authored data with no external downloads or hotlinked assets. Read `docs/SCENE_EFFECTS.md` and `docs/SCENE_EFFECTS_RESEARCH.md` for API setup, compatibility and the acceptance audit. A scene containing all 26 registered components does not prove every requested advanced renderer capability is implemented. GPU particle simulation, dynamic probe capture, temporal history, motion-vector blur remain outside the implemented paths. Native/device/browser visual validation is separate from scene load and CPU tests. + +The gallery uses a compact lit stage with amber particles/debris/tracer, cyan trails, violet beam/tube effects, teal waves and translucent blue rain. Target meshes appear only in the surface/highlight/probe demonstrations. + +Three companion emitters use dedicated layers to demonstrate vortex force, local wind and collision bounce within their stations. The gallery has 29 effect components covering all 26 types. Screen flash and camera impulse finish after one second; ArrowUp restarts their playback along with the other stations. + +Rendered gallery, captured from the serialized scene using the Vulkan renderer: + +![Scene Effects gallery](preview.png) diff --git a/Engine/examples/scene-effects/preview.png b/Engine/examples/scene-effects/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..be2021198113ee50fe3f85a55791ba22f53bc115 GIT binary patch literal 88294 zcmeEui$9cU`1dp8G)@_#5S77TI~tTyY9vIY+O|~^BSngk4rtpMQkg|W8zS0vtKG^e zrE`NOve9%-M>XxzVN27jLNoKc*Zn-t(Efh!U+{k3&!@F&?zx}qzK-APa6h7j!E=ob ztPC&=GY;_g`4+=;;8*%hhA#Y1y6x%r7;R}~fY0pjQfTiQv<`BX?Q53fTi)khYdg9j zFsds4aXs}XHm7{wyy|jSH@o-!o6oFGJF;uWx_i}^-!B(roqKcac-8s;{X^|+clLk( zE3r~t{r^82V%h(@+&Hx1-ja|No5&x6iE_O3Yh@7+`}r%yDK9+xhF91pT|LB@sx)|`BxVtyE6tLn3vbMYWg)!jo3hfhLO6;4%Y zKejEw^|CD5i1kPo_@&>6e%~Zng8wQyu0AfpJ-d$KG>IZ+@KCtb5M%JAvSax7=v+*E zGb~&s({URVjbPN!;l;Ti#shFZe2gqY8rEy$<0%;8!h>H2H9~bA9vunXR&{d1H+CHp zXvr@Q%B{C)(cr1d=ZEzsI3lVneonbnd|a&~>mPddh(Q|h@k#fxebCYH6_V`cu--6< z!j+=27dO@D+JHDXtk)Yf<@_7L40%(@aZIi(p|oK@oTHJFD#ETRZ44nuJQxItPZ=Ul z6){^Q3H2FP$jod)oC9Y-7X5+%PiKwDaO=TS;1eZyGTQC@8*zC`V;eqR<}vuxPT}BF z7jV=SEP`l!D+o9M30 z{k%X=r$Op|9^6h`)F<#Km7>=s;NqG#JWUqSqR|qu9>qJgz$|2@UJ|L0paQ;8YAJKj z9&s_XUoT$TkNYYO+weH@*#cdKc>mxjoChC^FOdnx65I9iDiBjrWT}Hp5oZlDC4#NC zpqMgM)Gbx!Iex{IcSGCx$poe(bt8l+>Ne2XuXwz3aJvOg>LMno7^>x2Xg~X}_G=y5 zk0TS%sM88`KuhpNUuhIiXHcVhIO<(y)P@USuYMcfuh_g|a6^HEoL5Ym{u{w~kQi?H z6@}H_gGyDGg*PHUgmn3VsC%_~E>n74BMyPC#@c#qe3d2+8hbTB8#2DyFwbjn!%7M6 zMd`tD^(Ht4{!WrSXdDTH>Ky)^OkhbJc?s@;7FK?>@X$ex`8sFy+4v1oWFs$Yg9DXm z>~|(_&??g1$}^_YXbsIjQtJlr`AQR8&A$qGXg|Zj{cwTCj&KV(FO%Zn zG5iaV28N|Mhzi%713tkq%~Pvb(pKderskmo;#^7-@XuM`@HCFS*Vz=-6`4s}DMJt# zYScb~iMJ1}>v1SA?00r5Z>QFYZ8Yn)u)bbbG2hck5ixI26YyD@!*2iXg>jQVB5j{BJworHhsNrMejvPT?x*TJ*3nCpQ1TP z#a!i7!s75?8E%dokgCTPh+F`aQhs5G$S4u6i-;VMI#mrkVB+V{cD)sI5wSx=*zD2} z@uN(fj#NV33jrl0OE3fNf9ci#pkjWRJZSv&!)mJ*Q)PzY#~`i>}u3PYFHXb$RZhVMWNBal_%dU9u2(JRPJaaU9X zheeX2OnaeO9Y<1*iwJru0+cOiId2D|B^MkRrJ4%~77d8!DNPXcI7h^;W8mEcKrVm{ z9XEWeL|jg-qTD9Fq)Wh5cuEA!qk4`#4X-U z&=ZenpE5|hUqGGk~yg0x||D zuLIju0x)~!@j=0>u4|~z5D^YX%_I0|Xs=!kEC)RaDHfOw64R|Q9BCkNRuCzA0{&29 zhz^Te!OBjbK|Kl9I8kqEwKuW4P(*+^eDi-OA37+=hW>W?` zn+W^86#?LaLNw7!edZS;81{-qeOth}^;Y;2l&|Q80D&+YZX`4KYWL+77dyR_)Ldp)m2vD2{Rh-Mj`06DZ!^1_SCaM->N=?;%o2_(h`wYhmNMtnn1#8>!8m%%r;z;q$1?!QG?G zrWc8`d{CrEN!Z^Dp#z~BRftALlm-AAH~5Nj4z>AFCP{gaX7fM-VIXz>iY*H$ED6V- zg*K0&;)O~;su#ZIYsv*qq}PQ+Oo31xZ$G8H#{dB$d?nz}u&!m25u`xHT9nI}C({9< z0oIb1wCP@UFae)%Q*Ep`X3+AEQ|eXC4#&Wyv#TI_F@*)L}9MBAWMB zP=HV1NQ(6HpngTn@kA8~!FZs?L&J7~IYs#y#GmEN?E0 zkdnYqgPia>B2Yjvp^7`Se3523lU!~*xO}UKAl~;s%fl%#%_NC|52;cc?nuOy@Zp41 zsiP+NlG@wp?P&u{Q9BHB1_yKtTs5@+nwC&%^C{%!JBC1j7D;*rFlA65vwtEXm!Vxa z1Bf01*aM%LxGO-B1ldC)Oq0TO69BCj&+7|e0o?lv#~MlmvtacwSa}^K>L>(?36ZLC z7kpk9Z)b{m(mer9ftPC3#^L~SsPrxxX&C|hniDFN*=1EGQLmT8E9ImAy)=$giwYVh_F##i zB173zGO3F^;n^lkqlz|ErVnNWM_hv2lCcqVs)tSZTEH|zt9vM;>kRTlB?iINK~=dx ziU%SC7q%y?MP*G{MF>fE=b#bc^GUj`zdj{Z^a@`k19%pqDg`zYiZy`GB~5PLR~*dN zknK+rGDSm3$jp2q?&Ahutu~~*oDPd%V8kFH2nM1oC*0?&)e35LKMMmI()N%c8d1|p zTBk)iu!K$uKADOXqqLFsg>y9OjlwC(KM)KUGZ80BNI|Y}#E?_58&Yn;2m((CU}HMg zCKyRW>jx?*g)JjQ&{~ofMZXAyo+L1bOI0UZnbt7maTdQ+9_xAQt0!B9e?Dg}n3)0f`16`V}Jq z6W+yHBP-kvopIcd+I%MK0=}FoE>KPAfq>G39D?RFL^$PgdD{u1o#}ErrgUXFQ7FKV zQhPHA{t-V&_@W9i4FRU5IVvc9+(A@NR6MrY7YHDJP&U`YA(U4I0kLyFy%aqVA5J(e zJW>{cT<4DerC&5~Z{lo8on-@Y#N#9xCZrgh$>JcQTYUyNaa=9s;=|}|Vo|#y|7;s^ z&IqF5+$+dR`2~1ASl0t*5MdY21qVG2)^7)exA7xM5{xEE`j(z_51&2ALn75fFbxVg z=pkTH41FvP9gV60;DICC#D#rZpdzj3h6^+nJ|Mn=6qrft>I@L}Ppw5-pbjCBri#Z= zKSh3Nm&R6l1(e1FL%c(NkFcxpq=m#t#Q(!Vib-8Z3(>ie8sTK>i0jLHP&IkTKQ1A> zGr=A>F0wpT1%bW?c)`jG2&)h3?WTJQvaSLphbH9=jd?F4$+$w1@l`Em9Z5$u(l3e* z0gFI8IGdy+f~@2t#0x+M#c_NdVmzg~N6EmLXNpfkO0#6hEGlNBP=`-Q-qDvwa-KM* zfI22a-h;x|SKb$2f&dquED;P#+R;Cih4Bd)k!1kr5eO{6IKEckEs)z+4e(k#o#Z`E z4RBnN>1)Z;UL{XU7$9jP>Z3^Gp(Y$s4Alt(1r)EyZ_FdV;fq{SFP=5zS$Sr7KlCRK z14;5jF3v(C99IoQq$8x!E6z&YQ`|jAc@zM|R2Q75vBZH2RGSvKDk_Ms2Hs8Tz73Or z_9~!)${%lxWjl zn_y&XgytC!VgXo9Dwu$*9pIMo3RD^ue4~kyIOQ8{Ak$ErX_RK9HnEYdj0Y`~V>p+t z5v>ZhLrJiJEwAuL&%q!5MupXBCtZvZvEiCD|(d- zFeeBAN+-gd;FKhR{lrqFVd%S{@efG9*y;Z@#nVYKu9gr-b9U9757GfMXDeVB2r#^h zmqi&-H4)hdBAS(NMHbhT-5^Dqm1^(;lyndjQ0uwixuo7&k!CSX-ibs4m28TbavlL1 z)TQA61zMz!<`JdJUO{nR0s?d5pEW84RR&^PYke+mtWCr$oI|>;*)s7C#Au?6MR-Q! zZ-InT@T3A4vc#ZXKp+g2jlFs~ide+<1R=a#B(GstT$d8~ps5ig3p_Lo!B3Mx$Qhaa zRVsE0W@ZqM9U~7Y7)#TfP`yDh!AZFHD5yKz%M~n1eKkkEwQ8D z*^%`8>qzY{Vu2aKk|FH}kl6^VRq2^Qi7s2Cc9akgw~5R908V(ocOun2l*y=;1Iq?@ ze=1E-8g&e}Aj7Ol7QP4VU7^WWKO@foK?-~~01Toyr(QsMZW||c3$hi#PD&=cY?5($ zz-G{LQrPCCu(@*HZX^>Z-4a>>4J%Yh9Eb=O_S^UoxIn#t$ZJ(L9CBZ=CYOXl2qEes z7Py<)geZaZ9fao)6RgKnbw|vEB!12Q-n%{sD-UuP%aO0`b{Ya{oZIzm;5!Sa@7*Mz}|u zYMb=h%mdRcqR6M@cs)yCtyxYMa*vIt`UwICh>}9WJfcns?ibv#ue};@3|OoHrP^=` zXV4}@1VoD+{SiKtj7Zr#L+@RJPbVm+Ivn6t$tGN;609#}#AJ&ZPb4@LrA|i1$!kRJkyM;N+VI&3)|@vWrD_62xc(y1@p7_= zOS&8=DEJBcd43cX4@gg$4#jgg96)n)u0aoIiq9qf1)oCt4=&`Z7SKD^6iUd2>UW@d zxFrQ0MbDAYY<$4u{m&;0jN}3Wm8m00g*LG*FG_tOS%PX(Lr;btsDVPDaiL<;g@VU^ zD}um{_)n$X0t-NB+F(uiFA{NXzfBOOCly2mU?9N}Ng|+2V2&I}B{Dk1D%IN(yrm6~ zA~^L%hh)`MQ)u@d!|gQgh)}V3aC1jV-XH5G3U0lfUeE_mg#^Y>6NnMJ)Bui(@m-|- zoqt0kd5SbStT%!z=2HG%u%k~vmX2p)g!zl}Q(m+wAS^gh?uVo?!Vzpq;DeE>)k&(B z1xch&mw4aT{7P?I;tZt+#Fzv!Ry(u9rztK%D`@W5RF>NyFBQQdTxN*Mnu#RaK6sjUmb9u z$KETfmq0QohLBRwV>*|mhRXy~lJ8M7Dul%hbizmtoQ{G`05OPyL2nNSX<;bRQqwl@ z1R6absAvl774Ia{z5z;ZTd1uk0*9b4p%Dm_GXV+r^2#wqf;}a@86@3)gf9Suiu(`p z4Mzp!5SURRpVdhM$pn(kh!TM(+JEg3DPlh~4WwSq;NsJDF-qeFT7=Du3y?66m*At| zJHxtgM~$N3a;?75>WVX!T2dfX=-=PNYj35KEL_ipLYk9k`~)F;SJGEm_7iN6* z%MuuEK&}uHFwhVvSVQX?HD`bUM9??=Y?BH#s-ISKPhqe;YVo=`;Ol@Pfl zwq#9<9q`B$O^`T@ee&8bsPG!qU3GGDOlRJr+Sr@{pSpWsb*=mzxk!?p&6j zgr=Z|Tr|3U{*4FJaT_kKk!0Tx(x{*rMr=!Ut28kSc#&FN6`0~DNk^M7RW==G~7&)&O?I*dhkRilc19%wgM_Q%C{gXLS1zTYXMa=0>0sY z3|bZBcoXm{9R!NLL2(2p zL#aiR5C#g=$^RjJz!@f~z{ecuJ43j6d^z%}Fi!=#vbY2fBV!T8zH$@AAyYsQ23j(h zSmIZ4+2I=@8*U4;tzwecXA5E_K2e?lTnYehh@bn=fmB;C}Wd~n; z)kc~i0ke8>Fltl$k2b+1g-swF1=oQ`4!VDVxuPP_vlLS_fo7!9jk1o90myiU#8EJN z21#2lzB{GSsJNS>zLSNA$#@V#j&)PW7%6*@OuVm8YTrPx2__;&>9&m^j6pzx)6N!U zw<}(Y?6Z^WJXl_aAv`pU^kpPd8UQNIgS`F$=y}?DdfN!FSZ6bS_(olADhD?tcsK%v zlDZLMY*4m97!|J!p9Uz7^isVzfq%k>ci|RVvQ&Mpb1_X01`94e9bCuEL8Vtq?co6U zROq1hML;1Y&!GW%0Dv*=7Zd4ia2!H5xQ!Mtvl$K*VFz`3X$8b?F=Vd&(3XKI3E!9o zjEEj;DGC*MH9^>FG>VD5&=G3ILN)eW_!3=+Ww# zS6bKIsOfm}ylvBieD~~JS=Cz$L$o1&%{-H3ROs=lhXAih8l zUlB}>!Ew4C8f97JQcgOE5ZYu=e5eYdF!TUng7PA$3V+e}^32AVS=S|63~k}r3Q01P zE+3`MF85_%+A@y-*|`8uAydkkWM;yK$r*{8K1R!eju5*O*pNp2>5Zr8boo)%GxQEhG`8;ZMEa!ppnsq?@D z2c(V-Q1pVF3pBWdhS8{O5*1zs1XlsLxuUp-9Q!fc6;&eZmqnyXCLTRo!Iauff;MVq zN;pgE%kc8llLygl%eQ-Z=3~?#<$|f59yk7x#CEkTa9izaWVYD-$i7vMl@9eTpon(g zm~K3r__1x|?)dg)D?G0cJZGJ1hEZDA_8ph(%YW?lRVSG8p%v~86R!|Jt4XMW6V*Gs z4onMzP>VyXooty;sfe++9=Yvif%D?a+c? zSF^_*!gE29qa5#?+PAT8zI)Z{r0;fSe+Jd6W+ZKh(K1>fertzswZxdt#hBtEcq{d< z0AwHxW;bI(i~tq`JOtlW70{_i=?30NSdbey&yY74nkGIN-QCeuMj{k`{kEKUZ`U7b zn_6-Wq_}m#)Oepk zebt4MlO2an@4VB|?0(XPEy_qN=2|9~G(x?>xFn#|>-5QK$@cMKcfOws$aTDTnP)w% z;ZD!_YXTcBIQ|V`3LLb1HDFi}2#r7rjt$DU2v$L8OGAEK08)v7sVh(#K&WHel36gG z81sk2*{-|h56Yf!sM4TL-Mgr}SIs9DPVUgvGG}|wUi8a8?}_S;Hczf{$A{)5{@=Qi_eaa z<4P;y&*zRl^y#n3iT4EJD)aT8by^J%4R!_o zCbsX5o>%B~B$chD8~3VyLCxh9XDCbEyw{W)vl*MV^+i6u0z6(2piEuRwMFFPS3?Ld zC!C`L=5C8qRNXA z>g>!{U<>VNd&~=SwcVw5n7;5;^kki@txNFae@`b~nKHw2+7@{}KC6qj3j@ao)@aY* zVtmYta99v>VdsH}UGM;PCqRx~d{?SFnk$~L!nkVY>8rqm-yNu2&Ew~XMggm=+E(kv zMdcXta~d62&C3e)nRv)>(VWc86c}aj7zd2M^Pp+d(fW?18$sj^%Ijz@L>1#gVxnI0 zutqTaYM5CAOFMQP0~mykDq?JQ?+!~|nH@@?Sb(!3uVeh1!Xm97cR0q!-EjrR7qqwF;O_j0HuI z`R-jaP0q=+7l;*hbC2&q%6wdX0dlogGFQ{c5apGsY6n2yxB&`6Dm^BQ*Gnh&jGr1_ zcjd%*G9m44n*Vl+LuO#Ud&wb>S~KgoD5rYbnvAyQTg*&$`o5L^h2{nMY`PJ{OSbG0 zn2VzQ{GWeBm5!?1+%JYi%^g56RUH?jQu9Cr07Do|6c|&;)fn=hemA^?Ja28C$4gRm z1rH~mkWc=0p2MA09D_Y(zZLFtbgRvEp@(jtO>dyv=P+~(OtsxD)4j0b`sqGUW5$0t ztbH^u0qgp-71Gvh8p;o)(HK-2cv_HQA#27YhE%~hhAcS8fK^3-J5`AxYfx?}{`_-k z)y(GFN)J;ixwgoC?EiY}y!~zL1JiunuYppXsx@OWCirjH@$;Xp{hdy82Y;lk5#Md} zJMe#FPU`{%^u#Zhiu!sS8ZN#9Hv$k5xe-j(3BQHwA_@p(k6hVOPN*M)s~#1#yIJ%XF6JU`@=Ri9b79OP9E zDY;>^x^Cl&7yApBm(8jT&6yszgQw{$5R!TH@qwvO+7f}R*6L}8f%1EG9~0^#LWS+8jimOXVzyU zg@Iu_35D|(SBxFbRa6{)YXB}x)+U;No<*skb3VqUF@&*k|2*4fanwe+(CC_@3&vnd zIH7@|j=@pZfEU?{33LuK|G3uo7VmYuSRKW9N!y6})Ptu|e=Ixr+*KY|000W$CjO-@ zX7tqZhqUKShaK>3MhvdA6{z42l%~u^xP3IOtKW@X4*08+s@}JtehR)0(iWA48}M&+ z6+o4K>FrHBAjBng^*K^5AHP-aF1g<{YSjMiKlgS1$y$hc(S#0OSjS3SsZ$?Z5qn`Jo?)Miyzvwk}ketw)uy%*aK1$Jf zLkLl_d1tg%eyrNJ)sVyX_NRM!`DbFNAt$YL+V_!327N!VG%q~Ols|1Mwd&(;k@+Nzt()Vi$8s6@9wDO_AT97i-YfS&qfIzYVk|)wXsoi9tFk5DQw!k0=2r* zwS5=?{tHK7Y$l!@-!{F9SGPSqyJ(;CvkQCBh}=O)<7m1PfiVasmWs(a zhick4B;*-xd3yDa()FwA&-wcMWIf8bUoduiP+_otLzk;4pE1JDXYB0VTDvTyF8}m= zdtrCsa{h;KQwADFi7;Zq^sBCdTng?Nc%QvQ@ z9+xK%^f^MrMZQ7(tA6o==Co9wb*(CiSA;G)7ARlh@Ej;_9Xi?m;{Bw}Cp(@+M|E4) zRZc_+!4$(1QGDF?M%}UbK4WPmCq7TAo%JNfvt#vr3-h|?&5Lv2O-lJ|j?LA@6-iDO z&hP9UcgN@aGimCYcssMUx`$o7&_K7-zdckgh`Mr@B@772#b)PvDWxZiH-#l{$o@NR z<>@i~omYhrQrR#!i+dPl;iJebr*rqm{JG+$7&se#6tzFZEuN*2gbQ}EWRe@YQ_CLy z(qll>G4Qe4E2fmzIlSyhad+t|`=!0FB4FC|Q8c(4;a2tigwLO2UxH&^V^()H!)o?e z@Q9~}&->$U)$N}%9$njbR$3#zp7z<(YG2%q8M_nO&%Xal*W_GM>4P8dwIqIgk#Ob8 zm?<$AH+uc?^WBWrQh#qcP4=TVOUB!292K~~9oLO5j19HoczY|Q?1Mey6wCfj^l&}> zmpBiqKhDJ|jglq0kPW50kSLtg+q8)iy?8Xy4FF-}aq(sIOr18npMCMKdtDWQe|syg z?!5Ht^CNCf%i2t)O%JUL%M&i0Z>?OgTIc%@HdozuUAPc*$LEiquiiI5Tyd*Cjq_si z%bMocBfSliPb}-{am!uDS>z26^A7_o5As1NH_Xt0I18Q|0Hjv+}l_ zC@0WA(0tCRVUdMsTaQi!dn#P<>^F0)4{7z+W|)r#JlvI{e)ye5+BjH zGh2^4c~O+#qZ5wmn1Y4FgFaOJ868?(a(`VoH%bw7=ppCG;bJS#{z5L0?i_ax#f8>cZi7a~vkQ;qEeCPCS-hlWTqA z_LI%kwT^Hgm+u3dL|gRDb$#^Yr5 z7m;AJq6uXTP)wz!w(t?q7wP5g?5{>ofR>fB8@?=h+u!>OOUtI$2ZT@wMfh;zk!*h* zxY(jqa@~j>{tXn0SM9e`Um32ymK!W`amRlN4gCJsSQjlPt0Ezckft+z4&K(<>CQG{ zV4|2k^m(%xCut&#gY9RRzFsS&2k`CNRzsN!BuS2m~6k;4!a&$5cbcOLFSy?)cmHo*%=1o!7Xi##Sf&^)k0p zV_JWog7io*^Bj$x>7aHi>E^tT3I8VBVxDI<=O1km?(b{2mg|1UKTdt+ADj8g-b(xK zw_|Hw>^^$v1r*@MTvLqRsLKB3_KU~3mGKw0+%x*IBb$r;x5nPPU6m}GE@%(7(lp@)_;A6$?j7ndr@zE^}HCt+A;yeH$zTjgX zxUgY-28!Zv%MB`|&TbglT{(@qCir+j%8;ZvmGcx8tM7*pz$D5VfJv_)F#qnF6K~Dq z>-lfGgW7Ylc29Au_6SW_$lW&P+cH9((hixr`xR$6sK*y~JCxffyq97AN3db?irK6c z>DLY(4#;U2KfYP<{PL)CANqJLd1+c%Z*RR5)8pF}n>sy|9cR3sPW-U9Vn^&%#ij>O z`&<%se;n?qI&;vtxI600q?EfZ9Vy&00jo^4wY=i*tEJOYWg|Q`#oZ9->eri(eo*mH zx9wTuev~-xBg$A{_JCF(_u%cMLC1^~WXLa`f8DD7@DLp~lxR($9J&W(A0efme_ppB zK53vs_p~nHv&HL9o^v^QA!+9E+ipRvH?3YyuC;1^KP~2A+p&^Ud*D&_uUuJY%)D3<{Kd!Yg(HTmC7%jtH zIPS>@7Bl(O7o!>)jjql3zUXF}N$0;C)+mOT3C3;s=(%&|(sx(>n0xT5`Sj1juZg-h z$8O1cddcCz(-f`jWgmd6+a~_IBC^=(%FVtWduWmxIzkB=Rp(Le1JU0Mu86?}Dsr+T zv+|5ok9fBcsIb1?f%y0uUGH%y&FLhDne?$zmqQybqfDsRjgo<(28 zHt`#0l$`sqF}$fmRJwB}cS>AlFi%c0WgKMWh)yJ+BA6CE+u?}Tow&_fUL>wm=@no~~rwPet zUtGP_xGhm;yU^m5?9M^s=eq0R&zf!em7hPf?lM);Z_G4(Wc8%M82&smI6|wh@;z#! zeSKSpSPT7QjrG~a#!G9aOU_($_0j9DFe*nYb0aTxDfW9PS>b5b})JK7#Cq$*D$eP|g*}wB1H8CDVdd1;&*-YgyKhBY;_Fpk<`DpBHAk zc+TB%YZ;t;FJUGJ+$u(sb>3hw1E%S-_FVG=pa;D1W7>M#aWGE0mn`-7;XAJcmf;sV zuinY3-a58JY;qutc_NRGuP?9u+jsJq7o`!j=oznMmCu`#@6_ztIp+u2FKg;)-3dEJ zovpy+K_zQ`WL0f73Nrrm{`ooME7#V}thsc$QDpWQvEzsPP#8V6e@%(92d>)p6^Sh1 z+AiD{1)tBvlfaOm)JIS+(D`%D7?nXqJ4)Q(;x4dA9pfTJcgZRa-+%sZEm3njaUA^P z;%^pN{IjX_!~^@6(_D6&K?#2G%2JDFVR~xTEJE`RaBTGhe6p+#Rj+)eH?{nk?B5$@ zyf-`|NnP!m^JdDtsy$2yDVwf_p0Je*>nIbmN z&|*?jO%=B0^Y<5%wcd0ms$b{ZODp(b9bJ*!;2MDwzr5um-74>cvGcC3(!>{k@D*Nk zOOwc@O>(_Pk@fRLi+$ax>-F>kH*fj;@X@2^5>Y(Z&`OuLzdK9|%{?_4{{k1Ap<#@A z?Bl_HQi5f^PT*o}0tt7&ZsC}ZuR{9JkTy(AHbIO-zYT-A8R*lO)VoKAZC#sv^~B3# z_=V^dQOWimo$kx3=Z3BeTKwy2iM#VLgSaI`kkrzLN}g>N^R)JgiES}E-uX(~AH4sN zRz2W9=1a}GWfMbkuJ~gG`qq&d94>ty%Pz_Kpi)ocQJv>P$nM$nBqn)N5PcN9dHVU|bz z>H?gmrTbXMaf@2v6(wHu;dabT$v5o7KVCie`-RjeJ?e3l4rM=Bo-lddT-5O0*^M(W z05a_7O_hnNsOPs5GI~U!*6CxDp7=fcz*>`J@P4ZS z6z{m6e=}wB!0?w2(W90Z;}f=poO^%QJ@#zk>nteUWOZ{IF7hE4%Y}B@TL5@7QMUwA zsu*7vl<%mK>cSxNIk9b;>9+gI10d*hpYak-+{%|1q2 z_Pl)SjXA-IiROn6U2Zxj5X!T<-zr@`WL683e%E>0-<@wJeef*)!?mdySLsg&QkEX* z?mxeE_xII16MppF_`bDoq*xDw9;xp8%coi^6)hjXb?#3DQ{Yx?dXkuC&9#^`u^(>~ zrrd%f{s?V|0~l(+2xdWA0VN-*xFZ_!o6v)YZZMLxs!f4zd&h*f%|KX6{tgv3@h{&Q zv~`^r0j}c8w2c0cQk_x935!ZM>d1GmEcx}`HT8=IuEWH_pc-0E`v|}W;1b|LbZ|<- z%RQD>Pqx0#dUBZ-U+$y#`NbWRUF*%>J=0z9nOEN&@7=OKBWmiwzsHO^@OS_8>AN;$ zzKyW@S!j5M-j$ltH1XD_O?_=wA}?ib-R8`884pzmqlg5ZS4`s=aUA1s^Rb+B-t+*5Z6?!u@JAN@ z`Fz#MqyfKu%S~iTxALD7R9YKPW!DE^nUd$Tczf@dyCd&<>>YV`%p0TmyYlsq+U(w& z_0VB_lW0j!wqQvJk>E+h|%>qr!HsiG7X+NJia1*yJ5|?G&-$t%1v%)VxX#?hH07%t|!0(kw1d!OwCFc?565|6PS2WTr zm_?*4=8D~@{J1h^_Qb{|xFFMNMbL>0fBx`HYvCcTk=C7#ns)JTcO-t34|byp65NBh zt}7Wqx5k)&OBmXGtWDgYtk}4rzRP85yT_L9+$US)uRF{_d*)2Nl9x7f~|JZD}^Ql2#4#kJ)zh1|`_c$YcvEjMEL`&uYqfV|+m`B}k8pJ*@ z+s81HWku`S0oB(+G?9i;!xM9f>?a0gKRq!pN*@s!^(?8qbz<;86Hr42FIqOn<+^C~ z%=RB<&0(3&9@{uw_ehc*wB2+bW{ILU8UIUY_dJ;gL!Qxr>B@g#K*6uh7)(y?obMvt z)gRHbKVDI@ZoEas$;OTUn7?w*x}c_2q=JZ=L0p;+7Fjbf#{(ZAY4X?n%Ws z=NmWt{^kW{PP;rs@A><>FQY1T)nB0YJRQoKFb{%zqLLEK2Rb2kU~*oerfB;W9$JU4 zOQGpg=*KEyKuhfwN7}XMfDV+PRk+*vsGH`SYT4(@nLgT!GD0)Hp|LvH4W4mcwt|!EHX+vAX66$<0pE1q z-@Ii(^4{c0&(xDYHo1YPU-NSKECploi5Jh;H+W91{CseJ;>SjAXZrT!HJ4fxn^K%l zzBpgxwC$d0Fjn^X$$`GidhK9*z3JHxA5{9&&!N@y--wOo8OY@r^4Y zXHIVZL*6-8`53_hXLwOn-x)I)WjAz!c9I9o)q%r<5~+9UwO=D%)IspVN07+}q+~ki z`hXkhAiBUoK-W-hQzr@?Yi<3zwyE8BS*ZSQwfEb6Z5GR)EwZb9+87+r<`w|gIgR+q zJgwN8M(B3H4=tHNfU?4o^oGDKE`K}CnrMZ+|EpH;uo)jXalEyzup+cZ=S^&(}BK*zJ41zr`+ZP0u7qVr&h5_tN%B@3q~Z@bYa0 zYxQ>31uR{g&cuQO*BA%SVg!3K_HBLjYQkr;kdA1a-@4M}<-f0AlDxh)R~>ZHTQP5K*t^?R`y-J`0^3ta~b65a=3vk&&el5#uD08;!5wSz zbk<^H;I*XfSn>5X#pLQsXq7@gfFyUJ#Zju@3+NAn!Xz!zcwh|ZaGZqxF)UJ8|k~EO5}bkg{|;n zY17lS)APNcFQcrEYH&vl8?1v5vrq}E%D1noVnVOY&KofwUKvnCvyQtqxxV&nQ`3_L z{BR%o_SA59HfCSNWYSofaA6%>RWI0qte*w*U%r^Prv)wR%g7oXM)r6P=7q7xc!6}H zF_;nj(GSz*>TMAPB zM^)}+s&(bghQXJnd4B9ovVPGj-o3fP2nNbV$X<2m?#gayl9%>N&24X#b=VolIFE7- z&czQvnJyPxb9Ic@#WmouQvidBD`;R3WNBwPYANGWP$OL$6Ar^cyuc7c=w$x&m)v-* zzItli{71k2@(>2YT(|dshCVI#q3~RU|6RMEGCAt0T)wqdiKB6SfOYenP)9?j+H(Om zJ069EhQwADERM3$-B8Tdn>-=Oy1%I7CKo2rSPWB^DHBO2Z*_zF#Q9b^wZ^)Nik7aD z9GX$nWJ4NjxCoZOo6ihaHRvz4ddvoHw+XDYEWL_ju4{8?K3)utd=$*?Sp<2)Esk9& zjwEdcPY=Vdhte@SKGe!Or;)GT`dd49q%i;0yoKJ-(js&{u+Uv9Jsalw?8h0KYWY9@ zW4XUw^zA{1{eC~B2u+54m>j8o7sqvsU&TbGl@D#bMj_gb^>T8TxQG^K#P=V0m>~-F z@mCg^I)CT*+x#Q74Z6%?{CjtyKie!*--~9O4+bGEg7R=?L$jwvQVCN!8?j$onBq7) zw4;e($Vtbb$IFH>Nw5wW&ES-8H^yWoIYzo(>;^xf>9vj~dSs@HpSw3Z>V3>AEWH7< zY;)63+Tnuk0$OUJCf5E_{Y7D0RI%&N|Ls@4AukJNk<46x--h z=IDQ(8oyl;{`-%^SdYY`zmG|sZI|h-3_0e2=84`$%9QU-p{c=SHz*%~1l~bgV;S_9 z0Z!oBL@m9%UkYsoxOW1hEhTtR%NdKZS(gt2n8se- z+g-j>CKipFP^1j0X)Hb!YwqA<3@W?AsN=}tSoh#4b@$0e%g4X@f{l2?1=EyY;#^i2 z#2N`<0*;Z{cjjcfSc2)=8|6E4e)4iHNcoY2nF=ADb6hGs*Jg>qU?i5Ag6TsohNT;~ z$K0G@OJmUd8KC4)ss=`Cv%}%)AH>8+uB8{k#r*d%hFS%tx;b3e5QdR(4^5k{#pf33 zC-JU_!TZ6CR_)(+#G^KKPyG6cDU$0KC%pUA++R#v7iJyW<|53cX?1iobeX>ye{RdL zi36u`U)s5WT0U;@?NGQLE;uoz8W3r3?XZ&RD#jM-zus+pIrb{%r! zsE^jouZxX^p@A+zF6+?v+5XBc{&TMXb~vCEkLNH%Rusig!Jel^v%6^QUKO7ktCA&fXnWI`hk1Exqe| zP6b#mXEZbeKV>uyCIP(ko3EXbkSPSkg|KG}+^0dr_rhf7;F_OwRaALtZK}Jq7C)}4 z#s$;e?&9k2XBk;T<4RX4yqe)9Loht6USf1A`pD$he|5fD!`N5ZGA8o0UOd*OGU7Kh z{9p|ZW39=3Jt2(?V>Q_?Z^k#KS=|59;b{`Ex9VtI+0NfnQfVf705`ap`zT&*`=|Sk zEd1}dauI+mr<8x~JBaZJoI6fU-?sR2u>t0bio@RS%#3H-C zYJO?-o!hId6ygK<2mS9dO=X?)JtOE|mTO9K;EG4eB7d29f4tg(&iTglt}s8}i{;=m zwz2ud$1J@AvgUPp$vyou%TwNZ3@b9LbeNbMZ)9l;IXDXxmh-;IJT@|&SrowV^2S(n znEC~0&3W%6(qjoF$#w0jIK`+q7)maVIf)dKYl(tFE5qK4Q+yry=xi6jV0*v$?c$$4rN8; zl~r0d^*QncyKuHGW^^j383x^*0bf6c)X=;Dt7$h}iTMO--L%NhU>(|fuqPwew?%$H z=jej&E1Z)8eyDv;cQ)dP85J^eJp8*LtG@Ljd+7}kfY6}0eYnQKUu&s;fO3s z8tJr-rQVqPzWiK=%vv4SlU`xb4dw;<^j5I;OdUR#LF2<4OjdhiOLYO#&MQ5Y8Vt7w zAiiGr)4%mIpdF_I$$dZRYcuPd%G&S+N*v8a!ni!X87vfTWoagch&xnx0?Js=z?31y zqw|ZG$0dBql;A$Vf>V=$C*U`Z?t&k>nE@V27qY2@aFFYDTzg@nz`j0QN z>s`FIPr2z4ki9Q@e?f0YO4A+bhV`GbV6Nr~jJU?myY1*w&lKkBK!gbZ8>KhsS~D45 zY%Cp1!i(%NnXVM#7 zi(^ciEaBck|DTgfF2dVxaK9nG9o-{oeW!zfuHFgga0-T8T_PmDd4QtOdo*CkiU3hb zP|`z<>5%}lCX`ywd~o2AtmA$es}c8^SP1UP1)ut`*}b4on(rPJ7`h)n9>+!f(mIN9 z@BWH@x%1_^N2j7Kou&uf=n9H&Tzf*fC16gV89m_QHMmZINmRFQ-tN$aaS~C=B8FtO zYzJIQ;L2RJiY@$D^{J7pL^td}gV&=|rtdE%RBV;nxSr;i<+xyUYc2n*m;#Imfm(QK zjjmy)7qnUkbvx@FW?Md=g|Rv10sa@6bT65QrM))DrwnGy*bPGTAEwo0xqRtDjAl+K zTnEO8q*)?;v)qm;JXuk2!=yo(aPPL)ZKP~R$2v^g7N;eI$JdN;RKxrLjGGkW;V^)p znc+n5c~RxrHNb5gKs^<6l`)T=yn-63c}soQUWWQ8rAzp!duf?f+_y8-zaEA%?migE zyAbWRdZnVL-o>p{RgkppT3Rtc_>vaMMeXCYA3Mj!-#!GFK2k0N9$DYz>ja$`X!?Sq zwDc0{P&_jFZN3%Vwjt|}l6}TT=5Rv-Jbrlq-3w!3^!!A}dN0Tw4LMc$XM^ZoSziJ? z3-ePZ(V@`*t=}&X{aBd0KTK2vbofDuBHfr}IU&E%^<+)Xgb7jEj^EH93TrTH+CFUi zY>aW84_8Q{;0{d8$fJH%-s!2cfeV$P`}gHmX`j}X3HE)Pai_%{JNsEl1F!-CtqV8N z!sx{O8!<&y?E_;t_^-vd6>6ZwllOQ=*1M-w%{bj&TdCuafycT6U;c0aD~xa3I&k?V z@58ecjgOy08!yoXDd?;iaUa4;~LY3t%H_4S-8e*#7Y^8^;x!Xn{HiX(&|1{BXYkmIW$ z?}utX@>c;ExI;*7!OJrsj56uz6K~Uz8)Gv;*FKxUw9>Lnga;4%XVSH0J52G%xf4r5 zSE{=Sec298lfS!%P@!0mZ5-__+sE^K&tV322_qz*xWuxKODsAkMEdB-`(!Qpa_ zdufa-dCP{Ak4chYl7e7Dm}622*rfrD0iCH;Y#Jw@2?bTcnKMxD0Ft%U+d^KiV{6Os zi?t4m1KV&<7y#ZKzX2`*3|#(zCV;DB)bHBFd2rh|q6G%%$Vr1K#H2wgieKB{Rvx6# z7n-2w3WG0WZPj({?o%InZqC{Mbj8kJ?RT$8Hm=JxE_c=!2HlB^%TL*-`26Jho9Dv9 z2P;}uZZ*W@UMo`*8=mzma;`Ms<_<7}BE5P0w{~2Q_3z<@c6}yJ=`fgMI_$xYM?lu= zCUv9f^?MCzmdl3y+m5y)E9QSqw}I8csL{4GqUU(&DuTQ%wHVw?AN@74@aisbBnc00 z@8uTsRUFy({9pO}`)@^xI|ch%@i8!^A{ebjjPjg;F(Me89Za^!!EOP1Sw;2@vrsF)kvD!uipQgQS-v2>Hkwr?IE4NfjO`%k_2%ZyJq z%KzS1loGHxxS|^P)&Kcq18V8)9SOIx=K^ubgD;N^jxyg%KPDA(Tl%MKUn$v z_Ks}NcM0m9@+KX%Q+9vWa%LqA@cBaGhTHfrv}cHLBe=+!x8XJ0<4mWb#(H|yingVd z_scHaohGQNu^Nv*U90UN5${NOF;BbsR8S6#i(A^mg>$a3=4=qW4+Cy(DPxL{hz>GO zf3O(~gT>9dQL7@;<0jZz2VQGtovhV%FR01o#Mx`Ry8F{}+THX;F|PGzn>?qM#<=0| zO0Z-zwtk!ucs2~!&7~U&G5dU!CIecgP02K!XxxRLI@LOP91IyQ4orj@Ue?)^*nsPQ z2}~3TQBYr0R7ZKCJDhNb5SWcY>t_}@L&+PH_VkRJiAE^j!fl&!UeY~$3k1=&LC$ZA z+a8*M8&rS#)A`D!3%kn1r=;7%l@4Aj!>T)`ClyRDO?up^8vUhPDnAHJKQz4jW4EB0 zoAdkIh7TW#Qe$@?@X%_wbvO`o`oMzJal48(pOe2ZiFw3t{QEX(ht(I0&8Z(N z@R3`)Z^(JeCio4k`y^7uPh0w-;+MlCmlwF>MF9)#f4AkxD^|xLa4g8mO|7Xjy}Q!2 z@b5>XPVme6oH^yojTZ$nA@D*L%ybTV1c=laY`_amV54{N(Ava1&!%S$9Q9 zsyU4zhtL8z7_wp-8*&Rqb?6i#ROgJ<{;Pk`z@N32^Oqv z#@;f)vBDv~o_o{Y%^%gd%w@T8oBPy1>PDJud~>99+UE9=(T|sJRORdvE5~zBJit6(JL5mK zsN@wl+tbGMyEr; zS<9HH`^vfTJN`X|%|yF}R&na;f`fhw=lIKbi}i0!fHCVAYknS8S%3$i3Fa*PJKII{ zGQKs_d2gd-09;bQx?sFeJ%v-;`!fr|8T&m{xLz4Cge*FLUK9jma61rzg*2LvEGgDaP`t`cnm5*B|6xcPtB`Vx4k z^Z)1LjilTEj9mVfr?f3irJs$tZJ{~=)t(f=mdcTh65%+8KBu_I%;F)BNct!sA+|Yme z==-XtR&U$#Sj~@|`~Wej1i~qs3VgDuAUcG0T4%kuFQ@Af8l2>)r|)v;hoc*dzWHjd z|DapbA*m^9FDw~aJ=$t5Eh)-t*WL8jMc)=aHBH}>rI8zPr*V(zcV1}cs$U26 zh-v&mU+wfYD)ban&(UA-b}$BhSD!v|lxb14#Ytd1%Y(7j- zDo3Z5_GU+Ks}!APx(()V*?<(eIUI>xcN;@*N=4%K@ScQxiMw7T*2vf}81 z&U#s1`tFsyML93RL_NB#yV@d+$r&G!v zhgpql>g8RgSJu?Od~Q_d!cwHgx_G&`upxBQ@oHq=(bBl_8KV?oE(y{gz_G`I7%`vW z9|_%aq@EIM*c*Y6m10!s>SQ0L;nSf_8;hd_A`V*h$>imY(;y zukceLIjJGZfxg^yaQlwBdvy^W4Z!bBeaM?QZS4dTvR| zvEwxz#MT1+&aR@EU7JgeC8wRs9Pe0~Z{L`=ZBTE?s8hb@!}sn)id^d-1cYs0vvzECeDLKbna4_^^c}D%@F5?H9ujkiu+~cXt`eo z)2$3HC=!~fi=`7fb&v?4aX131iTwJggjJZSv5ZNS4oZf;bVLFU5iBJOEbSHT2lTOuel{5c z`s#G4(1z?9>7kh+OcJveJ3nH%kPLD&NIpPtr%yM&lbw*tWO8bT$`#vXw@;x3sll~< zKN;BXBEa&zzn(;?{zFVoEoyD=CC73kEkI(9BJ~r7ME=>N6HQ6Ww~nOu3pW=O%rN01 zCA?l3@wWP=dS&jd19uX2@$ktfJ^Qos<>gQNuZ9T?<`ynGcJj+?Vx(@J?6u?2vXf&r zG*X0jUUs=Izc@9~uQL8=Pli`SByUmHi~i=4h=X5u3U7xeEuBJC5A8ntc>lSySHsc-kkGsof`I9}i)IEVonEvVrk$)D*q zftp5}37XJo_CxHS@8f*at$aP*=BmRo`$BZ_9AofWx`EpIWbQt3%$s{QykHxzCWxP? zA2O$C8FRVWX#Q&=#|D)gDfNGzR&qb0ifZDKp;(sk7_mhj&ipi*m_WA)Gp~_{KpC@1 z&m5U&+iS0yu_NJU6Br*JsZjB2t4rANe+q8+`So>!cMuG2`Hj?5ahM-TOW4SGm7 zl$9(>7LJ?Ov3uU6QiV=t2XN|WNua?GZ81EqJY}BquK)^~%oehfwu)zk(*nIkJ0%}$ z^*KF0FKcS273}^zX340OTkjk++agAs4SRh#XOQ+yGoAnCjL+VOy{+9J?6XTlx0X8` zXzlj39@x_Qd0om~aqW89B}?+LYI}Zm(`SoC{RNJ1_RixUs!f}iu(a>84(Bh1acqQE ze5ZrYo!Z{gkB!nT6{6EDYP2ng?1L$$gp(VxWsYi(;{rExmwQ(oDo&SnIA-=W+7g^4 zIJ~J$1;|&TgOn(Ob|#*6@=QLbfeY=6Zd25EvGHPbd>3sB9eNkS4XZES-BlcVZ@Otv zo(6-MML{HA>JrO^rdV9zd#ENk_7eZx&m#|^?&R{1&ztsFK8bnA&FAqETP=glqS%4 zU>Bs5S)nIUjQwu#i1F9VIfLD-|4hAXuDym52ub*bcUsFI&P^rBvtW!H%8aJ!!Mc>rM#ew$OElr0eA}RWOs4>KLTewkg zrKqg9WpiNf^Ig-!K5nzn{I=3)+zSu6y=#2#m<_6bu|O@>+NyD2ydZ?X?`f*|vHB`9 zgWo{o_~v3#<0Lp3&oo6taqePc26izGF$}0ZH#ZcLCBI-xH6A=ZzSmqUHnvaGEZdsG zUh7Y#u*_Y|&0R4_MM&%MKyO?Xd^P98jK2(OzL(X_1!B=Rs#+A55V%E*cP9L|Q;v??aHj;lBmwz7aezYPIJsd!xKbUXi0mqY4$_Ad z!wl%ackIFZjbcOSsWVJ4SF$|07t-rw>1?Yw<9ssqyeT7!#%9JVA~(YW^ z@^w#qh1ZPSm0G;WPL=Krifbg>fK;cGU$y~2o8(34lU(W@(4>t*f*zu)=QPcAIuf3=op`Ce;(Y2EudEAmrJ%y;IpBFGkD_#+y;}<)?mUnvSy6R>4RU(OyvuwGhy=4w-X+01!#AR7{=Geu)8oDtpVD zZZ0Ol<5yAzi@Ds?SVFa21VB-w9+O_4SdP&#s@*d*y$!a6WwJ&>n1LZvlbtOIAp{PR z@~g2IOkW*t-L_qG*1_`Gouiwct+p#H*I~uFT#B-#VlY~x7MSf>L7?KG`zK+CG^i#t zA+ok+Vg(g#Da_w+8HE%&PQA z;=q)yrXaKPjyERVfT%v0PaAPuP+h*a+&|hDpaan*#sgQ3Abr5+c$y{=7k8I&E}1IH za9%^{N0ywMEL7g9c39*K^mKpT9j=E{fA`^r^4K0;d%BILHiIZ%1$2(%eN0 z`=Czfvdm=TvcI9gYeove^K9MqzG23mXWP67Qun2Xv{#9~1zrtm*Sg%d-=gq(aa)?= z%cAK|Th6WjoSH6j{otQjy4Tk)a9`!krx$yQ+7v3Mh2eqwWM}T5O%PpVa0D#Q5-u|x zt~8Vk-SUaO!l^N8Evcno^HBsEZiX!mF%210k|K3v79El97&C)^tEMVuIg7b+>w^LY z%@g^j-8xF-N0skD+T;L6O48dh8G%)JLpMWYg1gh+OnPS4+^&U|XR~?+D z+OfY}n0=SWYofEcsi8+7BoA1P3%h#MJo?V#_$Vu?n#~G!QiN59PrWzCk4!OC^k{P# zQlf%IT%qLA#KEaH;$&h0QfU_1gjE-?Qzu17yQvYYr>l#ju2MKsUdNt_jz~aENz_cb zyHk9%Gqt8bSuT=;tg4Qc!GyZFUpKRJo&3~cM)n!OQ-1?7CSoe&UjB)_WMlB=^Z0Su zwTajBUVezfTwmch{Ja6dN%kezd{wIH{1FJj10P@cxt8XmVR#q{r|sn8g?s&VT4;mc zgz6_%X&-KcIyl?Ac^9@t7%du-Ij-LwQEsxfXKYbNspiW^wb$7%eifyh8~W;#)^HEM z|9ixji#I1X8?2tV=<{JmMfj6_BQ3UXpC0+j^TYGr^wv*rbx-+NyiJU#@EO$C-1Wxm zOk1meet1&l?e71xV#{a z#u{-vbmh~g3U6PTMJByx9G8>53{f-}>B&SEgFyDcs437xA6A+%k_A-IP%Mg|z6oXG zh}%h8m@i`3Q}2&bp5Wv%67bp?-2=>kn7--&JI{$#A$DSCC-(Q+d)*^Pxm833l}XK0 z?i^tel}s>olv~FjeD&4woQ(83 zms6X+G1tjEKm5DHj@j?r!D20~%5ONAU-aQcS>%Cl--`NeQ+x-WzK?KGOy0V>#bpxz z!`QV6=LUNww0jUn1tS)v`2ES^Ez0(PzN2Uge;P*I=v8^yD^42LmxVNz{nc5r%8lLg z+gPZ5voH^W$WnwH=rLp;e08yM&5_@Vi*qRCVxg^=o=JCyC})FEPm-Xar$wS6U*(jH096iKlq)J;O=#jq3G*88gn0i%+6 z@T{{+!7eZHaC1RkJ+Sp!X!W(q$couF#*NFxgCs9d4qX3GuAkx}ZbiOQM02x0-pSyO zlIlM%ql_KB+Rz?1A;^GEbGv!ZsDJ)tmxbB|wHnUe-(nrVn>?vD)!cm{{p7dViM5{G zh_VTC&ihj)M&FlOyH1nj@BU}Z1>v4S+MbdGDH(A?0)o1@X zMU_wZ_q%J4Zt_zRr}pJkvE}O%r_L-$?4GUar|jm~ut6jdqMY%vtzW-n2onHSXe(7v*2zPl*pLn(1+r+b zu%DIHw35k@UmXAOqpw7i?APdhr^;~C@xx_9+q-Y1B=)x6`F3F$nUb9xLs%V~UEQ_2 zH>~BAQRTY8edcit&Wlg&uGya}Mbe^GpYyzSuK(*Uy?Vh7zHgiN&4`3@!lH0TJVSn6 zRyBKT?TGWqpf+nO!Hw-AYn6@>c0y1vb^i}1^FQQF8=-tnl5GV*brpJkK+{QuNwyuC z>P>onQi4bRNDt1l6()t`Sm$sKiK3X~>ie^%TV& zM}P0jk$u&-EzZ1u+Zk^3HvjUs4TI0Rrv1-(7%re&Qz@54lxCl$NM9EK?)`}=`Lj#c7e|NlRri=h1DfEH!w z{w=nx5JLw~&o8N8_XlCPVapyuR$I+il6f6 zDZ#$~p1@c9ugI}ks?t}A zPnyLa>vlzaKlo1DEIc!WFzOvC5_5^QAEvI2dAhB&`|jGI$N9+wH~UkisAhT+|I06H zHE;$diTVckuiy9_yqo{3wHqQFDG`so0s|*XJ46XuH zqRAO{i9Z@lQ%Jcj5CV1U1~-QW@2b0Yt}x?koI({(=ZvRql|0R@?cd)=>Zpoi#%ya} z8LX1GtNRu|u~udLtnj@fcYa!MlO`HiT@n|P7y0)D`A=jl>(|_)>CPW~yf)_HHle-a z_N;-wj&tOTR{r^-v{!m*nsi0cKaQCZqp1S4O~SQ$NsqmiHDJxy)XxEGRh<+eZA70= z?n(!Y9Xf^Gf#6NVAOom3!vPS)cM1tV03-<#cZo;UAJ>mSHzk+=vT`I7Z?FVHN53`k zQzO|ikRf!8i5yLr!cJ|UZG9;!=*0;yfDS$IL_5&hXx+bc+8reZ!V6G;QdbRwRNdr$ zQW*Bwfsp!Qs00+;{_I#a<<^J9WgGAEMJcU-r_k$JP}V#&-UQ>?%Jb9pox z#ROljsYne@PLH_MXuRP~@yD#6Y8TwDdoO5w=`inxZshBWC1#8IPdRd1Xzg|_ZtE`X z50O{06g<3w;aY7wSZ5KKK!s%VI46w6QSott3TZgASG&HpJ(ENi8$)w7hU@_xAm_{h zU4e3Nw8nKb(<)jQ#iRe=Z$U~wsv{#{6JeQ786)lLxxhjLWo?Y<^BJ|Lsl?%x_n%ZL z&r7&XuYCs6eM}?z?UdK3@^?nA<1NppS#7h+&^aYf%g{b05vDUisb=if;pzvE^OA=A zk0T%*;v4>;$FWwU@z~3zy+zwJc=fN!zc#9TLfNwWsY6e1N$;+kinRjhj{-zYNwyQg zVl{~&3}Y>81A$*khII=>B`1U~rkDbF8=Bv(i|97i@f2v1g=^`(y*~M>f7w>UxSqV8 zR`Np0Y+yGx3{CeMa)dCLVO_9F2cVh%uFbGaku|Q_IXgw;xR$k{5SjrsU7#~gn?U+B{y~K z#U8J4BLsH@|QEe6R7hkdr?XA6~zk=Vb8VHDF=w zZFkS@co|Ab=Rx}hM(fb`N8;Jfs^`MvkiZRchK?tRW|wH;k)nu;YFg_FAr2LxgQ1a( zK&h-yT9rfP1c%PsqNb5^F06YI1t@vH9l45X{s8!66&>DB#=)Vy z_}^7j;_J_WkKFNxZUsnpXqO;j_kGJU%*7FV-sAYV`PuH=&FvpPUiXs>9J?V~7%UT< z+f+nxM?x?-x-fFnot}-6P4sx(4-cCQn&_%x$=Y_)$o6hIFJ4&`SbL8w^-b7^pM~AQdrAt&4~iNHoz8EFZD})?_rfu%wgF z335#86OlNt`0b2U$rup7e(@_iL_qkP(d{_4L?)e7rW-0BGG=oLXC@1>N`cyX zM7Sd=j!42U+Z~IX>t?8q>f6a+{@59r4)tCKe_giJB;)Msx1s)720Ra41LxL7RCjDH zI+50gZR_t0rb|}6>em8NQM=x?y;#*v5_phV?*nzbV6^9PRD-9N>>}mxNF10_L|YLF zA3bRGu0w&M&^WNUzWC+Q@x0FQinQ@vUB;>{1i!0yk7jUicu9K1$42@KUqkAP>mdX; ztfZvBw(a$mX%TDCY5CsZBOiOTc1uRYnQ4)J+@9+t^7l7izc2Yow`*2)oBaD_S$w)^ z1~=cx@KD63>YIFMBxU!V6O9tYGAMh8G4v&E6{C!PLk2fDFJg~@cJe{zeYSVUS2Pi4 zO}UsM4(^I|iLX|F#2~mplO!~2SMt!TI+U5T z?A5OsZW6%wgv6YugBvpkG6#yC(kKAQ9M_>Rfkc@A&!Z??@*&4-c8Es=soZ}^AgLyT z%ypV;)a<5ONsg#WXQ#Bi+H;6eJ~W0&b&sFo+#YA!5ecCBj;vXgj|}RaoNvZ+R~d*s zWPDs;u$AIjRPs!qILG-=GfNnFw)MM%LQ?bB-^q<1h|oQ~N?bU)jn9-B&&qa@kxKhy z0&1OrMHtou{yuz*WRA}q)W>yoZ7B)g(ORnF-u_x+8dJJ3&Lg~}WawjChpErNwfx%V z8(s7l$9sO8J-B}DQAhb1A14kVIeUjrO+_csPjYoeQAAX-_C!j=g=rCU2o!idjV0fw zoYoLaf~e|Th886XZPgy1xgCx*zloElj;o72Ug2W5W$lj81G8q-dR}#?{JgVu)YQqn z;rkVgptO#at9ZnGOmw5l*W<5&C$vDxRl#(Ggp5;ZCNH6b(-FbF@uQsE5dq_;q5vzKZjEBreXoY%E7)%#VF?WW<{-8K>_W|hmNN zWWlGBzh6>EceYx)o6+muZ?6j<**mN9BQwu%$CXot)AOghUm77^Q4ilRF+35)VQPHU z>o0sRo}v8OjJEd%8u&&qf+E=zz}hIeMq+{n?9D@h>NKAL-Dn2-_nAO7g2)p9(I`xU zi=4R?Iu)_(!@}HDv}sL<*t_=n|AK%CXR$4re3{YnY}qaanRYX-?*nH4^n_%Ck(>D2_A5N0 zQB*oD>&3erYy681lw~d8dr3&1*L-!(P>7&4c?>)Z`8i4b%uTnu^nd^zyzCqP?!(G1 zUC2IH7)I{lP55FX|9pK93hqf-gj@UTuZ=yH zt*-Y7B)V;TOPW2qc0XMfxkOKDGu5OfOtSDK&k?yf^u`}@qS|PE0Idi0bn{Q{9-1@rwA%H}`2FWLa zlp+phvIs4H%8@Gjt{W-G?nZx1){swY_Wr`}+pFEWv(fd)#e>Sa`l4J}`2C_u>TW^Q zx!nhVmPTmXlF9vMEzYLm;eFa{1|A}jpxwB3!GdKQ-n?1cc`b}5|KcSY4<5te+|Qpc z56o=6oNOZc_P)gZ!tY)YF^wkkU;f_qI{Cb$h3nZ=>L>vN`t5cSG`|v|qg2gLhB9J01{RFk>wCJ{zDD+v^WxW#p_ocg$%t z+JlKF#%~)O$aI>g9(HfZhbzqsA6^WsNSYxTg!QHtDAWRAFEN}XvA&jCg@hX>Vai<^ z9v+h!9wx~Mj4_y30s2gC77;U|Oz4}iw`vSW+YbGA8!APKKEy1az;>1LM{<%V0&%~W ztxe%+fRZgPWgej#${f>c*H|2UD1kt?v3>pB!Hw5&j0{a(+zvAd zPEVUBtHsbTtLbU`p!_F-cN*vngCLbu4bT*$1NF})RJQGzb-C~Syq=QUj+>oFwI%#l zU2A^Cy=i3JBdt)Bw2;jJje_CytVZE~h;00l_sA!}P;4G&Kkd1+XsxWXq-QmM@ia+~ zAhRlNSN9o7EsMQC1BUA8pP>htCR+mH476?8FE#;@4ckl1rlySITc#b9?GBt{+bo^W=( z*>vP9m3`pv88dZiZdHvwml>(T33&e6#}&sM3eNu$_hKV>3!02AEtt+I(y>QkC4USZ z_8tL#32%{L3$)qu1caW21(U9(7F!AeSzeyUs?)9sF63MU`@(A<1(>3@84tbw|Em-B8d>prf4Ne!9T7-t$nKMBdR|&@P!vL<&iYre( zx2RxOMEN5F%UVSMPcKcwnaZXve|1m0yC`Pfv#r_+iCgHpz?dgB<9pYT!at;3+0_4t z-C#08ietSeg%B=Jgg+I!5KxdsQ;Lu|u5kknAaUcuO<>!!oqzQAoVA}#i@%j>b{trp zQ~L7E?A+b=En63PpI|KX4&Q5N^qI*kyuO&$GVwro{iXUBD}(FW!ZmxJZ=W@d|GLzL zc5ZV~w#4Y0O^3vgNO~XTsJQ0S87fMV7!CdUbo!Q-SMsQNC4XPMf_Ad#%MDTa%Pn_B zG?gH2U>he?M8{}M-=SW({J!Vl#u+jE8*_}lqVoMm|FQiafj`%u&t5Y7)XuqIlYab0 z@6Y&e4E}@AqNmCGrdP+aUM;TGM;c*gGu6J3n>LcE(A5LC z-5rs7qG81m$nvB&r3O8D<&q#^xy37t4!zyDetvk*3$bDWvf6`tPk3%UlXq*yrxJHn z+njBOjlXF&{%G`ER{HMlv?7y>feaGjT#1H4V#VO1i3zVNb)X$8$)zg{A!C^QW=rig z%}7SWqv<;upYa{FN;3^Nzuu!&TD9@=n<%?9*^ah5zxk;UuA!fwZj3zs#B6l$H-HFk zS`dW^OPgj!9`Y@IeOq>8aNhhoj}9RQ-nm9yR6<#^i<0yFjnDeYNrBh6CcDp%H|C(iU{y}zz1y%p%S=9n z?AnE&){t({i~K&jml8Tul{UB;BjlPMPn*`r?J2|W&{}_zw)MwI2*`ARrP*kY<#!E~ zUrsGgI5wj(=jqvtr3xNEMGl6h<34sbe4TCG``6_;7ve-!d8_cK^bQ<;+OvOiADwNb zO&HIAS$xTB3Ykbqa^FYZo%Y+DRWy<8eaYO9JITB-5mV;gq}BcP_k$mrA=F)y_GY_? z7pA3;{?>a=#VMzXqy;P3R(K+kEV0f_IonQg<G&X#cz6}vl|GOVW;=D!f7 z-Y@LVv+eF{E0;bx6yRrQc=~myNb~oysn^j1O1?N&G%!-+=9nWpbotvK>eZ$q*?>r} zFYA9EI+L|_wu>++oNiIUrf>7S{5OyQ+NbC+@JVn z`{?T3eCwDI?RJJ-!cR>#FF#iOi2Jl}ulQ6K--e0JHGW%b-fpR@k-!6ng!KKuYSXy) zErNm0R`NkHTm-9yDq!`df$-8JI23V$hLkTAH9JLU$fcoK;v1YwBl&q+he&>29@x(6 zP0@sU8A;Kl%w@+of%yDmv^J87-^FM*V^W%8bMUV`m9y6D1O8zjk*l?t;U#Kw?d01#ensD1F<$Z*yy{(a!gb_|RU z{t&EUepR`0{J79c?VNi> zt9nXSP3imK&i!;@0mhD+zxTB2D75t(9PSqfnk=HAk5DkenGl1PN5m6H=N|ZHgg+MN zh)r_L^h)2XbG^|kgYynnu8ffUo%B!kNI2yvsaSwA_p zwSxAzD-?Axmk3rN=?ZRTJT}))1&l0?p-HSQRQ>(^eKQ?mov2JuN@+YYV8xI`25&Pd z7=B0&2uAR)s<=D~g}>P{+Ocxc2$dk2V#$KEg?|~(2ck;Mv8SBZ^4`@>L*;3iCel=z z$H_mgGV8Qj)9%jFEbf~57xN5FVd$1;d$BbXi54^_hr7ME7Lzwb0j`_!#Q+r-6S8fj z1P&Yoz%TUIBE_Capr-BnzP2&}RH?Zk**Q-PZgke==fg(i``h>u#VNj%qmEFu)dc>@iaN_+hldq^d~${Mf?X z=lvW|6RuWMpb9$OFkMb;`Y=_85eqad zuE&4(D5}VVye0*z;H}cwz*w?X4W*!yh`GTk;OvmVZ?S-`<`29BTjLiYq1rrrMZ^>) zId`s(g#(c)aD-8lYDvS;k_y2BZ9CU?M@dMqyzNSD6LE+Y)mQ)f!sT~d+I}&PMyT=L z(N5cu?N|>a3Yc zWi8S4p6E=`Y|%m_49r)TuSumQM^ar+O!Us!m3Fes zcn9Mb-fvlTv&kI0YvK=@?3`7kj~f^!0&uX?-3d4BBkXLz!Y139Bo4WjpH`ehV4K)c zwd)V%X@K?VCXAu+*vbn<(m(|j3{2R_8#I>H1j!sU_XESn`X5a={isaD3;#W&f}Z+_ zA-9irV=F%5zi#-OFh-5PnM`}{z8A0t0MH~nxt0nag3kf&pYj$l<+h4T1%BLJ=eYy52XHv-CNyRypWuf)9JDp}+)1<;XDqJ$S z$k$_o|E1kl@2rlzvSK`$NtEs>PJeFrrP!%kW9H`jkq5^8Tp#K5(B1Aqf$)gon6DN$ zJx^TuG}>wLvlzrgl%s)%&>ld5RE0_tK)2;%F>TP~T4AnIZ2)t5C5{$VC7p1&qF~%kkLtwcO zRRuMF9T@4eWBj69@2!GmZMC5M!YIP0Uix5X?IqwX{3d*1S9q`>giT(R&hcO_ue)Zj zQbQ?R2T5&BlG>txK=5Xm(GP$pAahLYFL7o`60=EF!i;T$n|&p=p4H_YKK_z7rJNmY zJEvT`NSck+JrA2mygy}1ynnNQ;$Xke;t@N3al2FMzkKhF?Cn$VyDTx!)4Qnmr>^TW zXSS<~KA-#Z!1BSswBtvP&;68Bwf911XU7Sf*08KAj;F>s8e4~aa*T7|JN>myT^O@7 z<0@cMI?Meo?S8U;g6jTV9g$hi>mMA@+IslIq^)DTy1Y(pNouGGPaH98jP4(S{EAeRb%tQP1|aDfFsCpIHlcz+AY53~;qUBKK5(RFr0 zt!uq!wxc3I^c47*w8?i~GgTif?JO7R!CPm}&(L`~vc{zL%#>#HExLu4pYxcS!} zgulX2Y+4}Jc}}%|4c6IJYwMaynO|ZEs(8Z9!9j!Mtn@Cqck6TiBWg3P z&u1_DdF8K8&<{}pwa`8R#0n_6VTv@*gT9*N_JVVd2Fh?P0d_DEi-!Tn77lN0W_=X~ z%QY&^fVCCpgjf|QRULTg&Hv@G)zOUZT~)(S`>om7BX50(VkMwao<#`Bs{hurPeT~DOgMf8F@1J=;YJp1JzG0E!&_zQ+S^;FW$M{;`;e)+x$6~_3zHU6&U|_xOV$W zvKUARBnPN8F|j}>GX~Z8t~g?01!Npz%I)<{%2o8$$s^DjjNXL=!U{u>n1U?~&kKY# zNRaD^mqtGwL9nH*P!xKgY6HT~IIc-l?JuzG9+P0^D^YYOxl5w^>VF=ymEvcHL;6Z0 z@eUuH-s@c?E%_Zzaq#CtC$Dp5vdNCD*=o`Y$HxD9_8t@-=o}aVjBYF&-@A6*)M3Nu zHQV%+4`rKluq9J56U!{_SNt)hH1m}tcyk>x{tPe-21OG=rLig(6*_nXtu?3US;%)~ zoIQB$TeIbk{QYazI?&ZX@9-B?=&*!&$8AG@wqlo`sC&4pv_@^`W*fhlpQfF!G5;Cn zh|b{D0nkVC&cYm3%6NweshBLXAiUz-UJSG@!PE4Y43I~QBBc{akyag}Vkk8QfVIa; zf3Tf0@y1wEOv@kDD0W=Z?{x9nDKBu1KeX;j@u(WnPpTWV*cA@M?#8g4f%^(>+_>r? zzXbU62yK*@ZFRhPccjmBt@3Oo`L>dSK_}xCmd53v)nI;AH1NS_IKf*p>_jw$RKCRH zB+FD>{>y-za`~+emru(-aRzGsI}V*UeG6fx<435G4gBkI=W_aaPOy1t# zXp8Y;uf3A8CG_|!{~=`{Ec@9O%^P8wet!y7(dH*B$M$A0$(w0n=3KsRcpN5}(D>jH zy!rqYfY5Sn3L|++eA%$XA6$(L0k1NIXdSP&09IRcX_L3FAE={30v9ai1nn@oZbsn3 zA7axE^(pGmJT8eAFLhDvQ5@4K?7z%b?%)tFzzdp)@K>m_e&LN{$j3lQ2U zo0l3--~C$*T3A0W(t2enC>kO2saM+j-TGAtI_1GlYVpQT;87@c{a6N0R3*FeMM~ol?o-R0PAqNBf9F?o(`kK0S<9H6FsN?^BNnBZ znlE9qVlF92p~pcIc~6gH2okfZN|PH^ibIlDi@GehBQBrvpRhyx=i}Sx#gyu)Yb}Nn zRsW)~EZ2Z`#r0e|Lc=fnX-52Sq&4tRjVLSV==5FhSfyV)q6D`=7dE2Ckv4#ha-ohga$ zZ$#4a6;A0#S4F2)>Of!0=@MEN6-^zAg4FY-=Q;8z?o3vYZ1A<->n2&-g`IT??{p5l zqnlkS9aO`(hut&ikZe&sVlg$KG^}IoecQl0l^41qbVvn|zI1mc$F3AOF1rk#N2hzYd>?W?Nm33;^KjB+B6@Z8KM(QR8}1wSZ@|I$EW|Yal%q+ z+Es^w+*e=fM(?{gHb^$13gkC%o@S{3S$S|^`;L`00p8)k`=H)r+dbvnqTnkAhPy^i zQFgE86NX*QH3Q@__~XLlLxIq$jTM?v?1~jy-l3SNI-mB7G1QKuC7z7=@kc5UUC<;3?ExY=Hf|M+V8~vRlm|NKDoEfF6QYs< zF9doO08EmJ2dJT+jUJ&bm}CsDrO`dUBPeOq$rLVHzY`C=1!Y$8n0A;E8x(XjIYfTB zc;I=$u5L*N>ug0hV}#aVx>`c64t#{PTUy5DE^poO4jDp?$g#y@U(_xuZ{e<=&b4H~ z%7&{`Z~!Ti3h5@H>=!F0A(sMq&ky9*1N|k(kGy!DKLP^4>z;e%es447Zcv@ifMU*? zr3=TGJ~61XT5;?njGY&A$DDE#M5wodAWt=RIpUw2t&bIey0TeMj;BCtg|RIU_aoRM zb%fpAd-N#FSSO*H4NDJ&7j3RkCZMJkEdB66CG<@rbFfSURSP9_AwfOtg6#zbKL8lf76sih!gY+=DLUc5LN}cu8 z@|YD=3*{0~nN8H1AsX9;D$%V0>aSJ^K$nuK2|zetfO>=po4O%Bl@t;LdKbs1ejTu? zX%rI7+`6B=AysvmnPu(R4uyg0hIj+#_L5@|cj3S?hzyv#izju$iWXK>$UxpI7hO+% zyYXdBe#r7y`kQX`{uC?|tqIuD^GQ6b?%1od4d?pX%$XZEsna8ap_8%+*GbW$%20Jj7@1676K6Oj%}$AXmu-1=S78MEj4G6?y3H1IW1Wug zIcel`^4Qovk~2K{*Nfgz*EicfyD{fRUes#P%V@}AV+Ab5CUB4LLFz|6eo7K|gP`D7 z)k2D2l!X-f8#X#ZC~>S1gEpiiM46%^MBxwsXfBqaD=_$9nx4=qkvY|cAw{eO!d^Hm z=LO~$8pXiz2HL{icpOQo8Am%zm;}2`R5ZGs+)GL=IL3)t#>}O5rcEHGC+pl=@ufYq zc0IO*$=RR^5rh4=moMD3Ic(47l%Y3pi1Jx)=vNWcetSWBQ%3Fd;#;+fxmxRX`O(i6 z2P;D1H$Yx36XmmXQJ!=De;3TlC4QalRESs6T%9x)Lw_fm?Ol!0#B^4$V#&|YU)+aIR0|X?qky(FNEETJ>evV?+ z3Jnb{RM={M%zxKPRb7as^1>lZM}Y>N7$w@4WHmzMuV%g0$-U@p`B~Wm^n?r;*m_`} z&3-IZy}#UZL%i{Wr5>5kWZ4c$OqTH&C}whxyRb7o(&&Z{J49*EPWku72WrRC5H^j0 zAM6OOm|D4g`l`!SG?l{s5;N!6Slf}+rq_=ly{q~dBA>RO*|V;zKz2XIOhim zL?MW6dY(ag(8A6{!Gg+%CA6v3K+iL$P68gs5?)q-CDOqUHOpFHGA%jJgH@0aRg6=N*IC*mky8Z1*)kWOQ=mU zAumOSX^J`(oFh`1i9$-07S{jsA{jYfMbdWTl_}bH?vpMQu(%fgN2uOw zNRtgeY%LDyOcoljkL;|v9>Q%zt+tzcUTdrCR?~O}{#nro_gJVV=SALrH0_^n`?u8o z^!4fNzw3@>c%J7fv>urrO=d)?EEV>Yxt(-7Y`lRs)g?ByJkdir9<>@S6iC@FmKZ$& zWvhUICY&|b*_h@G;WGMg9l>g+l$RXD&9a~ap-Yxf;h9*NT-sDN6|0BBYFHsaLyUVk z62Xn6ZlIbFF~IUnU~|V%k>=^Jt|4kc2nZ}FGb(8;p@qqufFW~ixp1t^^W5ZxwjMaL zkP-a8YLeEtKbL!4;y|cX%skZBHW^IRo^k39lHBFndAz>1`K&cw=g;JMr(6vfK4>XF zo_aWNr|5FF7Y4u`U>|B>#d^*O(?3l7`@p1|(n_n}D(`0Aw(; z*drRLF(-;CBy1qAf}}wi)If2-4gOKDX5S?t;v{&!R z9OxwstT$%rM%6-%2XoQ2Y^?(3BtKO7C&Z+Dx@@4`yMj6AU#T< zl17nKuQE^xdi5IqV~n25s93@Qn|6ZLB7=(o?J%2d_<2yKA(+iWs}x}|QU`SkNk>4V z&{tiU=UHHzilr=Zc4M2fv_QQqS7>4$Pj5M$tRZ|jn0`pWQ01DBLaZ5xE3$y51tg>k zs_;=kag4(HK{9f^q?24#6@&{?iGxE(foESx-7x9V(!RD$8>)8eY8K0$D_vQ7AL45U$$Xrs?lfa6&CMD2Ko`}N=_vARh8-;zTFF*q zSOrVtG~}NCV+2$Nr%RV*di-aw?Q^7n3hSYf0vVMa4}?7wx8URUcqmUJ0ZkPZYLu;= zSRhPN2P3q|!xTfRk$Pzzc9QB~KiOq7)ha!65Uf>wNwFZhAFepg8@Yh`QZN~stQD}+Z~T0R7~bjSnY~) zC~I2OfE!XvR~lyJes|nYLc8z^JHb--<2%a(o2~m4^U)#tIW`0m-yj_4L3LPu4aY$s zh=s2adPk{!qD2DLCCo9{_EJbnC*K%C65dySP zH4k|Sx(1-PC6b&r6+I>A=zn=x0J@4Jtt!BtTLds7IkP2fz@V$*)u+c|q%L4Hy=FUd zq6n9lqgwP}H$pMQnak*$2UR@8YAq_oEnc_QCR6bq=VV0KN|LC<34tBW-mCqMFmxwq z<}PfSn>e;;ln};)vx-MZ;}(!YWZ()+aQSf(Ga`MEHtWEX6Lt}!KqR+wVh1D4%YsQ( z2Zh}=kz}tze8TPId}#ONCoLNH;^Q2y!1(*#0F&6#XtU`hD(^MoUzEgA!ZK~0`B2qS zX{V62GofNPm(TiiJfUymWUx>0AQQQ3$|jlr791xIGpCY}d+CBNplqcv_n9 z`X)sZQh3XL>KnBB%N?)o5zDq z5g_H&dx{ZFvRzT#xvlei5caIf(#E@K){hcNPgx@UqSyy_XAn(L9W0(7`dBtmz{20z zr-gCFS08uQKL(q|jxGeM^J;OK;l`U*q0{H>T5U5{zFMD|jwV{T4PJl<@}n{!E^4Q$ z0wS)UOl+fqJX})8k_s(%GK#CMNA(L;zhXV9GQBmDVxbY1|7GiiDq<*G0e>h={>mj? zz|N(FkRPTb1#Z`|`6t5?J3C~v?k_S|Ir~Cn27eJ!ID-#K40HjTP?aFnWF!%(8U#e= zgE7m&Vgn=($RHu(1IjD|FTh7g{L7w`z=ZuuaZaQeelh@10IHUMzhQYx*3-#Js{1Y~ z{YaRs)rr|qTvQntH%{ZnxpVq)?oijEPf?nwO~Ru|j+4#La;Ojj?vSmC)g;AK{bC7d z5{aFJR3bAlzkJAF2|)!)98h8_ZGg8^(cQ96#c>g?CGfqBgq}(y`IG;WtNz0~^BTH- z<7+GFhO=Xe>GZ-GDk>jHJzug_`&C5zSiCpG;#+|WgYDic4~NKJrQHtuYee@~XY^Pg zvJ!qYF;h=Eyk>+OxWO_D76M~T1lt#S2v~>=-2_lpK;4O|A8;`gL$YFwL%o8rk5+jDbwuGCWb?`H^grSfylF8C7@( zWLx`X$NW3}GB<_eib~C`PR;4wqlfp$`o{|;x^NqyjQ{Qr{0Nh9!6`Pl;o)&f6i)U& zO40~60k0gA*^6(1pK6*NlN_-p_HIdZgwDkJl*pfg(eEG;jO2Gfe(|*=#rIzf0ls)4 z>|UPBMvk)@Ih@0#D=T)$K!N(z^eqeO-(d2P@1@9GrF~_R7O7vMr{XNeSHDZpqbN+4 zCiqigc*+H5OH8xVq2e4-3UNU~NG`)Zl2&Gfy^(?7U0ichP=W7cxHq48{>Tex<_68* zd%^ZVGWiw$P{fg#e4G#KWyaHQ+XDr;8a~ zU2g$Vu2Ll?#n#&+Gj$Kzbj;Al;GeJ|zv^%L$nYYglE4F3KKg%%pgf57-09j|x})_f zD~2C1yB}qwE;8P>m7&irSn@i?E1x!FI=b0XMh_nh*A;9KOUN6mF1`ys1Ri-CO@H)m z?GQ^uCgdy9zNcdk%3lw3B47(qkfD1IluqTc0=xbXj4*mMg2UV5=2TozwpG8D{2MV` z;g!@-ISmq9EdBX}p(i?-gI2 zM8T4>NTx2N<*~Vmcf;*;G=cPps!6Q5$tF|Zdn8q+o+||#oUcjBJ5H&YX`6KaA--pY z4x|M-`gAU3F8H_am;R&bERka5`&UEDxVG$N#>%gk0uC|x^{|al06?-P&PN-ZZRCdh zMjYv60fUjO{%MFS^`(P}N%*K3;WRe;n312#GnSm+90(z-#{W57ulb07e5z1<3osoA z59P2@L3WgkngLhFm_EmQ$21=r|a zYdv36W^7M%%*ap_JnYy5h;GLSR=rt|1qVkRfgem6WrGOb6tamlDk*P2%QgN|a`yWs z`iqC9`-czm%O3|>llOiKLK%>xP6;pm5M_pRr4#g)3=!t>x}dXMl;1+C;C+y%7wor| z#%kYBjB!Q{NU#*sGNy3JID8j-PxMeTJ5zZT(~B)af(aENh9M6aQVcnvk412`FwyYB zIRo=fUhi%wK2jk#8_D(~(6&OF*Lpb?9Ka>c@fX*^aRKddw1<-^Py#M5h{ZII23P^R z0q*ut9?MTIHV(Tw1)?6E8mthclvb)a!eKr&6PHg;iy z%L23~Tb^!TAAno}4-fd@R6(nCh+T4&Lla~$iJ-NFtoww6bL*n$Ra7Xm@Td7f)e^$+ z%O28?p9u=W90Y#=;E9ZZVtq{8CY^9|1A~F7DA5Q13zVW@8+=2X1GCTg8o9AJOq+$t zxjGwEY2wtPe<;BX!N_)-_#=Th=vWCt6y;DnL5W(-2z#vJ8h@--|58X;cbv+rX`enRdQ$Q+m_F~!%qAgN%p*}tz~Z7KGuFTjWlk(yKtx^(a7HOoM8y@k!*m<@$R96(W6htJvu5O% zUU($|OhWNHyy+xYZG|y#m_9>Ft4V_d9?0pT=BZE{hG{7L{EwEW+E7hNV+O7~9SW+Y z86FRUiQu%3QL@ck>EOKlQey964hK?ws!VJ0qb-NXyj?P^Nir1r=|@+>yikWk0;;SPbDM3 zE%AbrB_+dvlA-wcFkpVeL!qC12T(Q|r?kcR>+rf)V4r|wJ!h-<>TL5~ICXz;htI>{ zyi68q*ZBfBHb=fWCSrhuC59X={r6+-g5Nn_fkq$@a1r0Xu1Q{$Qyhz&4 zLi2FxhBqZ?Okv^Q^u&z(zoYJWZ*g=-)n2SG6(&iX*e?ZZIkvSsejs`zDBKj zV=H^>qzI$-{-tJ3{OkFdTZd)%?eE)FIe^JktY-oBh7<^{G*w8O)CC$nC8dX}nphG= z!$W$^v3M{d+Snmyfsuy`P_1Y`=#__Yo+)VSl{haT5u@@O`YNH1mh;zb*X}&K=G#-w zu~7*@3yFn~{r(?YZvs}++WrAINl7#yiY5(6(p-v?XreeBI+|yvG)huoC8d%I(X5Ph zh(d!>*`?G;lLiVU4R$CEHY&EgzTet8@AtpH>$~3fob$doto5w>xu5&F@857I=$&+` zz5)B2*`E3R$?{RG)9aRLfdT)&S8MqljoMI)3i^D@m+CdI``?Q-8y-~HNNGx^Xjv=V zUBReaf4)e!V3n!<;y5Bs-ciS8$_NsZ9v}tFih8WxXHutu-q=Ae>5t~fmO*Ge-DtT; zePtA6PcX-$ApbA#ohslVlLeKL-k7xWa9qfZqAX-6rw=tRuV!*Fd)t1HjeSQHr+E5c z4s6w7DoG3D?d}>{sbn`+vQ6^u!d2(e#Lz=4gE1Q#K1hj98thpw7sb6E>YA%H{D0rr zY3-EB#*jqw&u8Rz_mj-FNQ(8T6eEz_Q=y^i#@Ndu(IuMRgwzV z^|R}1>|eDdj!@=ael}2DdHY;AY8Dj)96E67MVM;{F^7;OR{y?*@1jhJfgt{zu!8LF zQ4nk~Q$P+{>SiQ(ZbnQq({(kTpotw&VwRD%Ucb(cg+IvL%0sR$7p{%X<=iFO!-U(# zH97N112YSCT`w;~roZgCqEK6ty~5ehfrUHMIjEO%{~MoxNX3WhxTaJ?$|)y%GRXbd zwa_aRiJFn}Fi)lv#qFY2_I;KzX(nw>pUKS?pCh2^Q#$t=)KPQc>77H!*2>9nCYsw* zb4)g94EMZF3+%h)o9NpTD@5A-?>#h?Wq4#9<&<~*3#GouV#G#E8nJ(e%+GAR#$h|GBY*XD6&<`0iHEuK)#RI9@XbSbFk>lJ-^>+tuWjS)Y5G)(6_tt zcN>j{J;)re-8x9fNtDE;?a)YEBf3%LOtG2b)ps;IYDcS*5@6C%)|~<_&|P7-CSJs^MWKZ}s%dNij7!N*Dyu6&hWm(@8-rHwQgCn9%H@j3g|+6G zdWw)@#;A;Rtu1ob?k_N5Uv60O!gJR)v5dBL-B~?@&?4ZQB2MDzDc^$*I{9FO;h@va zmqu%5CNw}kU z-P8HmFzpTUSbp|m;B@O#m?2FXq{PZM3uR|iUZ`P9&}lEiGC+RRHj{8;72(Xo3#zx& zGy8>P5`C3*(B`F#wyD~jwj4@T=vorzA;Z)Xrwow+sLzt~yws7-PK5^P&UL1MNICae%3_v1v zElRyVMS2f!Yd|T-=EperU`8czcphjPciAgIWIkSEG>pt|VSZy$HB4cK{GlMVfp6qvd=I#^MLOZkf-`Aaw zvhS=~Gb7cs0DtoirPI$t3`ULgzzMk5TR%}0FXqS>PC-_)F;IJO!9Eandze= zZYx)5vW8Xmxn~&sieE}*_G~PbTLLqv*B9ev`vWNfj9h*oY5_D@}3sdUht6_r{ zxdC(KH^G`Z zd>^JJ-UXLlesk|0nu`bHPSX{N8k!dyX-~VvkKbk5SY)QaXbkrAHliF{0O&`Ap7#)6 zK)y#tVcHz6|Gb*d@zc{0d&1%UUMDYb_Hur`KQs4t@*`D(Eyb#92EB;S_27DbNxF`> zl@QuSxv4-wqwaJH|03^`=Vtl~*vrpBRf^u5=PJb+KvAdzaDz5Sg}BawAM}jTQFgD#twXo zOTNg1g%`Ax1wrJ7&t%Sa4!%0t$)j3~oSKLkTI1>X3c-kY4UX~3aMvw%qMS*@V1{j7 z{ASPkkJiy9n_cU_r_3;Dw(a7~r%o!fIZxnKOWUVh(}Q<7tem`T_fn?KIvLvqZ|uK4 zS#htWM`Z1q|H7(9NB)3{M(!N-<(DFx4GUeSD=#Sr5KE~y>=&U1T^VTX#pY+{VDblN z!wg&3OhUk<=6DQJyxtu{X4_4lI*mVa`F9EWu$1L+Z_OmVtuscNB4)(WMy#0O5QUtv zp)0`S&DyC&_Q!9 zBU#^qu=Wy-?b1_D3UcOr+HPpq_jr2mn1?d7qJZ~6qW`&cs!a0!edcogK67h;iIM6b z2I05)$1`$XIu4u`IPw;RZRJ9-eknI)r?M)^%atl!f!JEP>W@i-ORDYS89Ou*jI~&I z&(skyI~h`KfMXT97%mr5*#zjlC9v5W$pwQn=q=Qq77&x*81XW__ZQE@{Y*K1ln6zx|IMY=~W+YQkMys0jP+6xxR$5{O__nC$%BL0bH~ zGilHh_jw*Nz57cWYClsly5(pga9!kszybLlt`ke$yE}hmV^8zfk@iUNam!6ySIQ(4 z`Iu5{du{Jxh#|$G0nDcgJq?*kY+eTZ{JbF{b4k#|q_JI?9O_{{TqA6F=mo9yqS-&} zWtEM}RJEyE11GlK&*$L~uh{RTTV7OiX!HI5FjofuW%nz%y`z1*aa4&EgI!~zhRv^0 zc-=wcr6Y~tK50?okW{f^1GSvCS{ey;I@f%dc4pU3&lGw(^6)l;FI)(vC%OUJnFe$i z7=2qWEK~5KyQqcGXnPonZBm>T+Itb*=gVdN))wUq5NR?w586z zV;?yN|F+J_wL_tTI@1;4ZUtv3z=TSuy#MeXS9kWyX?N{YYnVx%vA3>z;XG8EeRvKv z1tE|rmd?pd@2_DV#6V!S%>_>;BQ3XY^d#wh@|Ljux5CvNapWQgez}4lHns)x)mn%W zoYFQ`AD{&Q*wI#Yq4DdMa_O?ob7j&=syT*^8aLdgDX3nR>S}2E;>66r8Yxjs&Q{jy=^ct4i-TJXw)Qoe~3o%;Z?!749@nSNOM{7A^Nkhzv?2 zG`V@3X_2$t(0$ZOBcvd|I8U`LDPo|hi;OkrZF^q3guWWbu@fUwZB!4*6@&$h?s=$Mx{T|0jz_wT^!__Mu|$RVRvRw-fN^>~oxI2w=z4%Xu{ki; z#2^$S*)2!n`qRoGXhs4t09O~>+dClHdF_Mw^Hk|ba^LnrrzVm8>s|$yjmkulQ)+Qq zBAuqi{5~*MX}@&k^vs0ZmD9)@{ZFaAC_ss30%HtTHT5?rTGE}+*59}n)nA_xO7^{Q z3}ih1(tkJV1FmH@znaW(5WKnw_cu!2`y2v5jT=3xhfr1h)Wb`Sax(YM1>LIO!6%3Y zJFJJPk^oLX2iad*NXAq1A9)v*SnhSl9zh z%7U(W84bnO^Q(pKF?&v}Vvdm^z7+J9`yj?P4-bW_7aGY;JY7O1hy&`UBSmWVLQ{&m z2Hlujy((v}I&$T4{zu}7N+akWkz&E@b))jD*6%o!5SoieC7-!CjAO^4jTw$@h75x2 zxH3Sj$vP4}mH(`a9t-Pj3HZF5haB<{>5R=4ejkN90c9V&dL~D~sEZFhg2gV?kh5Ux zq2lz2K#yGwaQPBGGp3bj7eLRPGIFys~Ax*zVm}#}wD2mG+&0O5SlsWg$EQ4-!K2fX@s~<#hPN2 zmrj3jbM@~+qAGUo(pAOx$W|xFHQBlWWMLWb?WSsJbu3|4 zxR(N`o986f{qi+&Ow_PkxBYo_z?fM8ZO0jQjfEQ$Km5_ zj=3Q^Td2@PGxHOGrLSu8v4bW^8+;)?wmy>11eom&o!o+b_pfJPQ-7%@}b;_xKWr zSr>41KR;~wUUY3U>tQW3a0V2tx)slRQA#$oq!xf6tSk)$}G6n zsF4W_8tKG`9RhWyUCLY6Zas*u8H2PgJsod2NXDbgC@FF-7k_17KX+iM{GM%5t~%&( zQR70gKPpm0!FoA^L`FwT6U1(tBAw;Se7;+}yz3e-7L5hrs!xGqGJ}ZZFf)!tYn=5E z7BNxV`EorwpcI*%iY|j;)IY+;d{Z>1s1Hk;_rCg2svCYZk<16U1Q|q#thgjJuiYKC z*|>8y@nC1l1BPd)yfx1G8ILnOS;=Ty<1Tab^NUm`D3{H2dVucSfZb^BG8V>(wg~kz zkgk@kBem<7#4UTmkISbyd~evUA+@Y~9A|mFrV3@QC~;QzTp)umC5i>)7cDWFt$U=e z=u#gTY$^}<1V``zvn5-HY@q$gqTy7cL6n#tql5YVF0J*|sUU=g#Tnx$3j9l$q5D*< z0jxASN}3{vg{jOSjf0JYlfM|9`?hr=n$UzJ*BFiYv~re>j%|r)I>|oR#D0eeef&5y%>>$``SHpV+KM3}5kA^)7dCEr3P zie!al*v3v3aGAV@X;TNUGZ6_3+>yn-z&diZt8l!ND~DYp}2Lg^iIkJn?XA-M`6sUanQ)pxPPK=$-7ywv;lkb z-p*KY#^|Br)-Oc%M){~U92)qe@aMxn2&*b2jmb&YZ2!8;D;LeTw{;vouPF6&^Zbyh z;&`jA3vOTaaNSlk+kef}m>rB{lE}3vSSTWNI6?GKmJz|+c2#{Iv57CkQ z(ml!G0e3ei*s02%q$U#;^KbsT+UDUu1EmzP znY+~NlrIN09KY8>WnO!QH6k1`95N^6puMF==3M&6o)*kaeZ;^+CocR<9Dg5pfK1GD zIJT~>g+q(iXM@GrPL3NCHt;TwDHk|uuyEjq_5-}Ay}dXvQUjFa>GDiM4DJqPPEj1# z+=JyWBG#6FDu;@CM0&^adz8g;uq+Q{OM^AWRXf3Yh1~pxTrxsY(W+X@O))D2MiRtl_(||%*q_Re&uIV6A9=8b z`{phS?sHnoEwtz|StAa!K031L_It~&$rkOu))e-qO_TsC5Y&Gd zaV&vRT!+2|a^^1|)(dmKpzVVAV@Z*q7Yu+EIj$|^j5z7Kut!03p&W0uS2N8aiUX5n z9%{0W@3-uC9E?5X-Zu&`7$sG~-o@lW_RrMJML^B{*+oe@--ILsKgsl#9pC)19%*n* z_F>j7X6SXJ)27Dd*~;_y^9dUZ!zC&$<+FJ zQ-;(61TBao1o?fkZ_3+L@nIkj6$4oiWjVyM>oq|RmguM_t^;GzHtcECt}-S%3Ql@p-SjyH(k~KfyAl`-IBmaF&cdj;#06lZAfK; zrMD<%wc~O#XvU?kz>!z*BWDe6(*3|4_FsKpR~0ioS=^U*Xz2ScW;k$;D(C71WLd|5 z2#tE;1Ia-0$57u6WxIiejR4AZIo}OqlB&NSD4x30{vdAUM`OCDi~91hl6~;gQ;Gd` z*R2e)Iwcfmeim5`YiF~5GGA;as|Nz=ZY79TKHH_z)Z|Et_>djAGv&e^Yv9EHuXFpZ+9=9p`^vaR` zLu{mLkf*Ju>Xbp%P+?i{^rBWN2ltTRI2+pRQr+^vNSpGH9-K2x6=Y)Vz;BgQ$HSWI z8b%af4|TF%L4664MlCs$#vSam0~ujM?1>%)QbNTBlOVRLj(_UByB1(KCg>fQZQ^_?0@qulu<5_x- zs@cNcndEqDpTn8WqgmmL$6Yk3wc4 zUGciw+m>PQ^qXDxy?+F0K)U2vx@TUR&v6)jxrw&Rz z&FP!fUb)Bw3hlMukF{)E`qG+$bpA-PzCJWV5B!u;eq?lE;U7+`+&11wCUkyk+yCY0 zoc8joNBuS{{j9qu;`1i9T*j}TF1x2eN;c44L%gU+npg&=%LqqruwPE?b$0bd3eF`A z0jhSf@}&$1jhG>iyhg$t$A6@{A1rN*e!pRQRrb(YvFz5HFSd}E3*J0Fo#5AMynGR}|McXzf+)sd~NW(RW^8Q0(96i~EuhQ=>H-A6& zClze0p&Jq-i`u@JSpD2)*_u;lxw+=~%ii#S#mb6Dy2bWe9ptd|&l(O* z$-WI6zzO^1L~(US&RVOLxX+yfEGKrU{wwDps>dMj%%L|y-ux`RzT%+Gg+~}l=hA{q z#6DOwG})7by@$qjuqMA7481)S#0=5V>L2Lwi4lqJdm%aM^=@A1r>6%dr>8fcw<#OA zsZ*{~bI@RBuWrcE^Wlrehi;Rbd3q1~Br%!l6=O!XE$WWR zdZFtXykip{UGi^7F@5Inb-tyn?{8@ULc^XZg6eIP;ldc5MS21v$PHg>cEZ;GKazvQ zOEf2%Z~0pKw0$(6?Ek#iFHG0cKYLJReAbS|daHP-t>*Yy-7m47i{B3@N;aPp4Ie$8 z>%+7O`!_B#bS&NE-Yfq}lZT`(0y-WtL~9i{tXB#299^PFSJb)RbM@l8@n7NlcO67$ zYpj`9?(Y>$lh@gTS+DesVuO_`rLC8}wD$1dJaY2%Ej17JyN#%NK8s69n16du`TL$z zp&v&bQ*g*HuzECUjLm59Y%2Y)UHUPX)Bn<@j-JaRt}pk>)`eyL4Mm0JLR48o{(&5g zf9#iFWFNHj4RQ^)RIVW&W*#XR+1lVw_Y%vlxhv*B;;b{=+dhv)&u*AHX!Xk>rL!8x z$(hQ*1eMzRvyir37Hql4C8VSQ@uw`I(j5JMJL|_d;p|^EzdAlARAcd`Kv9Q}=taKl zuAfTKj6cDXPMXRDb>6qA>)GEe4w@b_TlnKPOeZm@S-*vb5-% zyR%E;{`{F;{i>GH>;J4CyX3&)wxSPo{V!eHJ~W*b$dA91 zcamI?w1p8Md5F1ed`nqsL(Xxvm?2^E!mojdV2LunQSs6PnORvwi?uE@ zar4mJ*HJ_3_I&$v$J@puTN3Us_1m)f{)Zi7PX?X`-5Y1`tpl0e^Y9QBUnY~2-hC9y zd6v9EAG`fWl2BwpKzg~Wql_RC?C+XwZDX7<;z9vE^OmoX@pOsoEEmvy5Xe{l#-9!q1Any)z$hWOa&D2Sp1T8eDac|Ec)*a zw*Ge638kKF*apmed~!r(yn9}onpO~;$@h}e?8#+K`rK!XX0OoiH?vV|YqW1?UmIX; zaZ4U09j)z7>tBgV`#Lkk+!1mU{j3AMRfLpEXrJ5B@=>QVZVX>mk@BZ3gN^kDkk{`96JE0Ga%^ARN+9zvm1!y0kmXM5#Z<1>WGBKrCyOTy)WzMqP8O1 zmf`5~1LT$yBP)oyzMs=m?C(Wm$?M{K88o3tJpPfR48I_;ekmz#RW-SgHU3yJKtnR{ zP+(SS`M}ej@rQV=ewW^VudITbak_n3K-_^W<=!G%V_&M)MwN^W3P(I+#+ny#;{B*$ z{Es&6!XU{LU(WxZ50ySr{7{SjD!VkZXE!u45M>i;ZB8~6LP|H-HQAP%YQX%GGGZs7 zLbq%icHuP)6yKwQNflMH1W7jGNtmtr`#Rci_OG8amk%(7LnitgEF7o*LWO&^*}IK6 zO;rT#$~_LB1HDSDn(ArwhwP{FLYPa%Qd2pO_Ds#666xNYk>H0$_x)vk|6aH3Z*ptx zt<}t&f91)Z-YDV4#rY=r(;l)?1%{krna{^!BbWZV%? zyM?~)ns-C@^c4|OG{#wj!Gd2uk48&4o@vMmGq^QDPdmu#ILfgf6NfrW%UmQDE=*{mW76=0IPh|*<@oxZwCTz<&GD^sKHYO| zY4Tv&$OQU#fRs~wb#XeawX*r|SIdJ2orb7SkRQ&l>CT zOOX2rVnQ^u{-)mYH+79q10CO|qz=s-i=k?Or&aUqdXIdRvsU6rd$-1~KK6Rq`BP={ ztY;qTh#$yu8V~TQqQ z9hyE4w+*Ctsaa-9F8dcyz#3{c%>KpH5253W%S#J2v5|o%89bq!Pr(Mt89e;nW9xNP z#oBy}#HN2%6eaYB)f=m~eHpS!OM}wq4<{(P7{>zgMNQ;j^&S6&9Sx`0JQ!AkI+fgq zaJMMc3Nsq)e+DSjK?G&*aVz7s0yD=y7y0z_Y&%~dS5m?4{}B# zkSp_Ds6Q~)8G64n1@m)?z~qw)qvr|j;*&4R zh6Sa*d+NJQd);|+#tv2aFCVunH_>VOxdL!`H%Zgj5fkwHWDWTugaBV5kI@&r(ck@= z!~M%j5r*N(qbI;#9G@O$@IyDu$4yGWB7~8lxlkh zi1UoCDxhkawk9~qQH-`9Dr^biAO0Tjj?4*Vv8P-1GYBHw(j69Y51;h9CO>+SV$WJ<>nK-Wwcu>NTykz)4+Ls>*wcdI0Bz)vO<} zw93)I-pbI;dD@m=CNIwqs(l%3|JS%`vG9}+sN8TD*h_9vm`S}M2o+c@j&ud@25+Cr zHRAO0goF_^n|TbC;c4^9;6k}9dN7o`oqY;!^dNLFc7jO>0cZ4LxX{YMw0pmmk|Z%c z@oG#fbn2jEhQvev!PA%RkRK~Tf85MF?`6IuvBlE0T8lUF^fsKd1|!>DKtkZ)mOcdr z1D}kv5!%KSW5QV?qL?!+1K)v2sM9yF{<0EHRXA6|e(Ki2$cD?>KM!J|D~s>%QrE#! zgB%6Brdg55`XZp;Ve?J(GWHlj`=S1*{)#;Ja!K}@1E>6Z&Dqv@Km6UYTcE8)pSAnl z-nu&v^2B_AJQaK^MxjdNzbK{`jz_zrL#Epr&~J-OoVOHH6u= zDLMcuJsE=fgI&5Q+u9uFzZom^OdGkVh4vX#bka$ClfGw?e57+pCMBBZ;knC*#Ko7OmM+7DPHqh7Ini926{!^Br@4 z@seZ7(nSBPcYo+L^!NT8p}_Q%5(mcZ{->v?sKQ|DP2aq~mIjw?Z$3(D3!Az|l>w-lgWQY7Y}$8gsZY96KTT%`hzF5mY*tL?-6zG${LVsKB%V%utl=qPveI(Z z&(Z9iU@n6K0S-ZZ3Re8%+=7R>P=s91WHcAi(3KG9-BueF#a2YYO8o^+g|bAyw6jV`uZd2@vM!>_k9?Y#;@_s*rnxG4GVJ--g}%IBk=g>YVDRkM+G){ zoZjE4Z;|kB_&Zlbk=P;~+XSY?LNGvJ6usT8fW68zuJu#t#*Uo2}Td2DRQ5zjYSj#)bFSx-S$8X=+QLMoW7c5@z zzK$JJ&5CR2ek@^&gV%seQfDmxhRHN`-=8nYW$bP z_n6JB{1wzu2C@nQ+J@@+uEYB+~UK`1x<@aP%ArI10&&y4LZWCrh)Y?`XH9s<6TNCE9)B4LbA#kKrV3kMR z+=zMq=c?`WOrPLtW;_HTkMcCK6F}2o`(XceX2YhU&@-Q3Ejy6ZT4b>D%S1`WOY~NT zzt;1kkgZv=v{gj~eGiUOv)YShScMzA#CO%v z(d$Z7k5BgbCeigrnHwod-72Cv-8!V9B~rlE;n_UQ9LOq~uU@Z!Q5MpP0*9~>lk}AC z+)0%P^}GyPvY=No$lbt0D^U~}&khAadh{DxpD(XfzKQF9$}kb?%=+b$v{q58LKz?S zs{W_1@#kOlI5-W{GENp#Om#j8f!Of+Bzx6+=7JW`wog$UHAAZpikG;&32}+%Qs3>5YTi&k-@b+cE8P9`M;79yv!Y! z3~pDYPmjYUPo&T~?1RE+n$T9JsF?xJzr+hB>&Mpzuz^y^wedawj)P-8chzh z(1YEz(3zSo^sI&ogEPbqSOzH8JiRfWVg0O{s$EcVP-o@QMM<0Xe&8D4qGA1IeG!*R zu9IuKI2h8(LR6ymPA6 zYB^^v4Kf?6-!v0@7&xRmDY+ikQvjZtp|zc+yPGF_u?m|s;=F!?#8WXZQHMGr?PZuf z!^Y~TQuh6_gae{S9%Lo1xs9TE_2SJ_0gh9--PMr?#N0q7-8uZX_+~+yAftHDAF}Cw%H$pf}S*4F;N( z0no&1g^4KEbX7A=;QwUX5LICQ8DK-T<-dFfh$foNW;Oz(0y`7vR0W>7j7*R!*5f2K zIpLA1u`}J?c2&Sc%{)sL%P;>XFTQF#z--^X&$mSY>GtvZ#d?7PA**^D&~CY0i*#GO zR`G1;5X2M)o_Ockya93pWX2pfa?)q@ z?pI-z?Eta%_hdOV6KB!tLTd&q!ABRC`xIr}dMcD4%QsKu?}OCdXGi*Z8g=baij_cL+B^X(0*t+qs& zP-=jo~U{qNHV@^SVq1oixg*Z;-fG-f}@Jzc< zJexz`qbT6^#5?k?W96`B^mSpsxBSo6%_dhguYMTm|EnnOdV8;bh{pvY6W@?ah|Rl1 zX+S46x$7~0=Hvu4)01_%nPvgLOSFskotQY1zYb(ugDHPEYTiZ|OsQPcA8s$41g{6} z8*dz0Fg>gQwiuQqo;-PZwdW(b>XYL!nisQD_GVI?4Ip^!s|w1 zvs5s-r$9xwN8GEmsAX09y{s0&H&Jalp3N-vr!V#+ed|}w=F~^{HZKKuYwzoY))%x$ z(aW3Fn&O%Y`WHIVU%%TcWq-6PW`4t?ciq`rrtGYI&o&Ze#rG2c!Xzp@JADemwA?zJ zPqEk18EZ+z+Me2Pv%>CyqykG(h}<;TZ;7QVS|LORQUJ2}x%nJC(!?xL$U;}mp773` zgY<~YI4%K~u1O9OOxGTjJ*t!H|7OEc`7A-DCBOHpn*Pj8g8l%r({NB#QL26$=BPt$ z2A32akBXx}AWGjC4EhK}#T=kijKr`r6=&Mo3vj$~DD!R)T%|3jlvDO-g0M6GI z5TqN{aS8*ON$feVD%(QxQR(F|EFP#+t91Cb*n05qD3`o9JsUmLz&3O$P*Il*{IaL_ z(a$A`_1jo^=G#i;~M~ACQC1*rlHqxz?}hUQJ)cD(xJ5Va~;yLjR37SNn@;rCI`=P!TedO<@GI zeZP2a3f$>%;6C?ZZ{_Gn(Rk~uzp@6WMp)UTAe@8v0e&YzL4*9@F3E6P=H$fL-g5ub zp7l|k60Y6>ULaN_-a?1VoBxqWEa|W?E#FayyQf$q{Tdrg=2XMfODA{B+%InrdgQyY zw7&fi?xl{0d8swz42%w?Xf|pLn7iS|lym?V%nPQt=K5KCskF#^v)*$Rlvt_84Br$s zz>K!KG)oy2MfQyji#R&yofMzH8J(;o+T)4kcE1!3EK@8idpq>Ks-nE?wcE3L;-*2> zsDGIsW1-3k`kt&bsiF6U;r!!06)`^(TN(bByjB8U2m~q>Z6jW5Q(jGV1ub2jedtJh z%E=ai7wb2YLpzn%f=mPhA9V3Bbi3)|Z6=NS=P)nDo6{nd&Hwc4eUzRe+SYS?9i?s# zN(_>pAce^3rypeb3@`OtQ`x9o^J*JoxS}P7cFaS0CdELayi6@$n7x1;4ycAC@2<$q zXU)@EKBPfWK*61aqJM}Qxz=c~hn}@FNM-&?WfRuw6++Gs@8*G<8i?QX)XDo;JeD2> zyFCE_s8Ss*dv@`5YRLQqPHpL8J0ZHdzj+wC;7{9vKK+(jOZ~L@t7zxhqZ05SYP3Lj-sq`T zQb(sn30p6SI6c#U2lv(OgTy1GZ~*!qUWgUd`!J*W?7lK!H-`3fjnI!AuZEvm43A5a zeH|M;JCvxhqjZIxYbHtEe+Rp+hrWKQ81((a7LWt6S1C9+&UQMnn*Z&)ni|c*m!g8% zKVnw5&}S^Z9qU-e{MadOFf~(udn(7%8+13WDFdh1u&mYuOv-EQ$`U^QDXAtZ{QkVj z1AP_gvgs$BCQau_)4AY-5}qM*6ydjtz5qRt2K3s9b=-pnIh<3+*bxX(566w@mk^n_ z`Cw)?ck^-j9fRMZj$WY%>zT!~ZKYYz5)c3M)Q;+n>uxcqzxKVdSu^SViq!Gp$F&Tm zox>&_*8^@_??#ii5EJQYz&iK;?QM1|nry@#gTiya(X}l79leLR1CPb%1V`!wXX)Ip z4HDt|S1x4w{4U2zko@}FPo%}jK3Xru!^|EJbjYijW{*P%w|OKh|H(h$Ou zBqd5T&QZh!@)nM4Qp@i0ayX|tG%6W94s6j{A2V!{lT!Jj)ih++K)cEF&1?5-!sEod zvFpNqHZuKYf+`3&ubyY!_#J|gR0ztO^IQ5T3k_xqAO`2NR;NQv6k^Fqp#w;KPHpfp z`B4Z3-g0Qgr;&d_v>k31-0Wcl%5}~urvv||<-|dq$$os-5^m)RPh?K4ay>WIO z*(W51to^R{h0P~d^2L7Y`0d~ zaw;#ImTVE3XCJm{dSzde@lgNm{H1vFE;c#$HYrVq&FexE7%AnE3o!J4|p zA?IYV>E+~)z?nhG&&&;>QzAE}o?37S88w7d8}ffTy%W^q;Ym*<(B|17RbKh%_P z=L93^?}u+|h!TxrjgnY0a3SEb6-zPa{_Z^9Taj=bJ%bioanm2RLL`F<)y2AwN8?O* zlC~UR4Q9Jvra!Tb+G*Nbn#v4Gw*?&(v_SO4aqKQ41=_Z?)}U|b(Dz=HJe!SFX~Rt97Vjoe?I#7TMRYZCh98#xS9oE zOf!m^J)qza$Zmh}PB)I+hYNY7GrQ={#V$7abdch@-0)EIywJFptl?P?YNec)`cCK$ zr1I_YD?iWjkhD17wJlb(;orW^xwPZ5wW2vY!k?+nOz-A@Rx4>t9#XNKR!f=}@p3t9 zY~nB0aN(rWT@|!jNjWzQihnLJuiMYDE!a*w;PcCGS#QfuTjV=v{Z!^Ksu^l!ts;)M zvM85c{Fpkq1#BpCB}D`IMxufNvpapZ zd(=As;zT%|ujSV;z@LHF2I>H&7=D@JyZ^UB{EJeR=)aZ?5{XnX>tuAT z_t0uaH?jBAYALR?Bcl!riP`Ce1hboI5@3<5*ndW4ob!2Av)k1rKT-+R*LOoUGT$3a zI(a?2k@AjM025h~=i~7BDZTRPNxuTU%4~Is?m!($4(Uj@BDYHBx5B!+6;>W0!6(_Z zVB?)-EJd%od%WKhqMVA$Tpe9@8V0bQ-Hrmr`zD|h;mNbv)5}6)i-AVS%ek?oKGm1@Oe-+Bh%SCwH+H%-~h`F2RD0raxi>w zH(`#vh7Lnt)u}FO2Tm(UKL?Jn!m23>PVW>KQ@0S|q z%nB3{>8b_|v z>e+##r3WJeMKeVTRp{_D^4@KET^g6tI!}j@8r4fyJ|dXu-){=SrF;BE?i!%VAlb1CKU>wn;F*eI;BPFtD+=DmK4PfessF+$9JJQC4tXV8+&kG#F52x13zd>J1 zpKZHer8`6cD{N8lky><^Nwxc&_B_4QCp)J6lL2&E$dyz|&u1}wHqb%tQY{@udMf|8 z>C<(|Cm!i{P7Ezh&;ELtW!;11^w+O?slezpib>XzheXLsP?09rVj3gxW;rKpGN495 zF7#sAWZ1O(^E4?3c1I6SWWQXl$Z8PpWP|XjndRwFOB8l$rFBqrBsrUj)r=?2KPmhb zQtV_u(nq(dG%Pr1mqWpTZm4tyrw08ICr>!kkgxF0b02;=1k0@=sJBxcI&H1zvtbj< z27XQs8R%&@)lpFZ?6M5fFOPC^arq~5F&gWvCO�dAql;FK}_EXihz`F7tcwn}7Q( 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 Y-e2Ad+HpS3!vDr4PaLy5DLwc90)9r)iU0rr literal 0 HcmV?d00001 diff --git a/Engine/examples/scene-effects/project/assets/debris.obj.rmeta b/Engine/examples/scene-effects/project/assets/debris.obj.rmeta index a45f775c..a5a44ac5 100644 --- a/Engine/examples/scene-effects/project/assets/debris.obj.rmeta +++ b/Engine/examples/scene-effects/project/assets/debris.obj.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "1591b699-988f-4eb1-82e9-298b6e41bfde", + asset_id: "45440509-633f-4fad-aa77-f00a47e0ba38", importer_id: "rustic.obj", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta index 5895adf0..97e8d33f 100644 --- a/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/bullet-impact.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "556e9cca-96d8-4e67-a254-e0b581baf7e4", + asset_id: "56576d02-c025-43c0-aa3a-1b94c80ae031", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta index de90ddd7..9bf746b0 100644 --- a/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/campfire.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "15830234-1f50-425b-b9d2-03146008756a", + asset_id: "552ceab4-686f-4fcd-abf5-3f2a716d27d4", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta index d1ec52d8..e76f1d07 100644 --- a/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/character-highlight.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "fc36161e-1bc8-4fd5-b0ef-0046430e4881", + asset_id: "2989fd15-3729-4e16-b3f8-5c930154d887", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta index 17e4666e..014e4b5c 100644 --- a/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/collision-sparks.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "3a940516-3702-4774-bd75-bded31dbf792", + asset_id: "070662c4-47eb-4810-9167-ee83ba450fba", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta index 9089a62b..2c48da1b 100644 --- a/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/damage-flash.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "1a2e3836-eba5-4186-bb44-66546e7b19f6", + asset_id: "6195abbf-7367-435e-9608-c03c249a0974", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta index 35ada45d..9272c0d2 100644 --- a/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/dust.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "f0a7fffa-9659-4045-9faf-4d05b81b91f1", + asset_id: "d9a371b2-c74b-4e64-8fec-e75d934df685", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta index 68d0d4e9..e2f25cbd 100644 --- a/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/embers.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "ec135fb4-526b-45d1-a994-c95bd7320ba5", + asset_id: "e52e50b7-04d2-43dc-a672-ceb54231b26f", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta index 864c28ee..a4e2bd6e 100644 --- a/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/energy-shield.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "288e00fa-5d76-41e8-af3b-3edd808cfdb3", + asset_id: "bff017a0-f4dd-4488-af2a-24febb435a37", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta index 899f3f09..c6a5ccdb 100644 --- a/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/exhaust-smoke.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "bb0f98a8-afbf-4f8b-adb4-849c5d799e4a", + asset_id: "7ed6b79b-684a-4c04-bcc8-5a16b72fe2f8", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta index 04021094..8e417a81 100644 --- a/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/explosion.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "8c263d4c-c2bb-476c-81c4-0f45156d6928", + asset_id: "e545528e-6b66-418d-9ec8-7ff5a3d205ca", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta index 36ede48f..653e917b 100644 --- a/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/fire-sparks.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "7cc64262-6e4f-4998-b0f9-07b60a2ba3bf", + asset_id: "bd563651-6862-424d-961f-548e82281cdc", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta index cf5de573..0d96b516 100644 --- a/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/fireflies.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "4ab1e210-a39b-4aec-9949-ad88dd7d9441", + asset_id: "5f774895-cbb4-48ed-8382-fc53f82c4890", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta index 59dc170f..158956d4 100644 --- a/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/firework-burst.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "607e3bd9-bc06-4bb9-95aa-f13df5c0c490", + asset_id: "4884c898-6dc2-49d2-9044-6b502bc27691", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta index f75c4f03..44a742d1 100644 --- a/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/footstep-dust.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "2605bfc2-0498-4625-8602-d0b183b56c0e", + asset_id: "c59fb1af-4308-460f-b76c-7a01001fb07b", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta index aad09877..bdb86a75 100644 --- a/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/glowing-trail.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "9adbde75-9282-48f8-b3c4-48d68b3bb657", + asset_id: "bb25262b-c79c-4156-b16d-cba4e90a525f", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta index 66c5983b..3c66a424 100644 --- a/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/ground-ripple.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "393614d2-351f-4474-afdf-da024a3fa983", + asset_id: "5e167bf7-e13a-445e-b7bb-ecab74bb4d61", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta index 7d561a4d..47af71c5 100644 --- a/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/healing-aura.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "a00787aa-9d9f-417e-846d-c452cafd51ec", + asset_id: "9504adef-f37a-45d9-b351-fea4492525e2", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta index 6e762716..113a5d4f 100644 --- a/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/leaves.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "cb14ec4c-7542-4997-b636-ac8581597d22", + asset_id: "609864ae-14da-496a-b8e8-6728dacdf1ab", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta index 358b26ea..e6e48c51 100644 --- a/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/lightning-beam.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "907b00f9-2934-4c5e-a42e-94de5359f4be", + asset_id: "a471034d-0428-41e2-bb41-f1c657ac5ec4", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta index 34209744..85560a2c 100644 --- a/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/magic-projectile.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "2f86ab14-3024-42b8-9099-7614ad785c88", + asset_id: "e03888ea-9fb5-41bd-981a-f2bc55148a86", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta index 91311908..2b46bdca 100644 --- a/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/mist.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "28878227-7506-4cf6-9235-bea1003f5c35", + asset_id: "d6477c93-91ea-4d54-83bd-bb3de1ef6acd", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta index 88297e76..7422b6b5 100644 --- a/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/muzzle-flash.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "9bcdfd6b-9f58-48c8-94c9-bdcb0c07536c", + asset_id: "95de0ebe-a8bc-4ec7-aa95-a5bc832c1fee", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta index 457de9a3..7025a029 100644 --- a/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/object-dissolve.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "051540dd-651c-4949-8efd-387e7e3be862", + asset_id: "fca31958-9307-45f1-952a-bb8553dcd56b", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta index 8ae359b9..3c06a475 100644 --- a/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/portal.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "15e7a57d-8dcb-43e8-bc86-dc8ec9f2e257", + asset_id: "9e2d17a8-dbd8-45a3-bdb8-2d5520ea5f26", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta index 4f03c356..03a44229 100644 --- a/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/rain.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "4afc215e-826c-4e47-9e9e-ebbe813c67b9", + asset_id: "2be823a8-d9fa-4274-ac31-935877709107", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta index ceb2711a..ca154cea 100644 --- a/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/rocket-exhaust.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "8fc5080f-f6a5-4db9-ba06-958b5802214d", + asset_id: "4cd2df6a-5128-4d0b-9a28-07f9cd8b39fd", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta index 3e7ffc58..1c91d9f8 100644 --- a/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/shockwave.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "f24fd45e-7bbe-4d4c-b492-708af934d68e", + asset_id: "3ba35917-1e64-4e53-b445-9c4022bcf55e", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta index 98be8138..e2d04b32 100644 --- a/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/skid-mark.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "5145c102-b46c-4ffa-bce4-53afb0b613d4", + asset_id: "e121df0a-6d3d-4e87-be3f-a0ae3c488d30", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta index c03eb49a..15f25578 100644 --- a/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/smoke-plume.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "afad56a9-96a5-41cb-90f5-c4d2047fabdc", + asset_id: "6c0f9c0c-30af-4005-8070-501539947bed", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta index a7c0ec5d..673d210c 100644 --- a/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/snow.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "4df8e805-6fcd-4dc9-b710-621e0108c9a6", + asset_id: "126a7d5b-63cb-4b87-8514-55289defd4d7", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta index 9188ac34..26d9cadc 100644 --- a/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/speed-boost-trail.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "000a34ce-1dee-45bc-bf88-e2807152851a", + asset_id: "fb23c419-dbf1-4d51-8328-339f150f3ce5", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta index ad28f779..b95b75bb 100644 --- a/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/sword-trail.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "0af25fef-26bc-49e5-ac56-d33d7fa17ce7", + asset_id: "e1bad48c-e8bc-42d0-88c2-5507b26b5c59", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta index 5cad57d2..3238207c 100644 --- a/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/teleport.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "ffd059ce-5a62-422c-acfd-093d2d44f19e", + asset_id: "1b820d9a-fc07-41b8-a1bd-44dd2c999c8d", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta index ab360c01..541e9ca0 100644 --- a/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/tire-smoke.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "3fcfd87d-0223-43d1-be66-f28d51829fda", + asset_id: "9153df1f-2991-486d-a783-fcc1cb089120", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta b/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta index d15fa742..179474af 100644 --- a/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta +++ b/Engine/examples/scene-effects/project/assets/effects/water-splash.effect.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "c2b01912-ad7c-4339-bf4b-63c1442ef231", + asset_id: "c0f77ab5-fe6c-40cf-b10c-e3e161f3a470", importer_id: "rustic.effect", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/energy-grid.tga b/Engine/examples/scene-effects/project/assets/energy-grid.tga new file mode 100644 index 0000000000000000000000000000000000000000..efc826f54ac06c10b2fb537dfe99dc5f48906c82 GIT binary patch literal 3090 zcmZQzU}As)1qKBM35EY#lxdB0MK~}K&{9l6pvb5=Bv?knfo|b2n*K-g9VD@h%y1Y@ b|06Tr;b|V3F#_wI(eyu>{^40*q@{lVf9;sb literal 0 HcmV?d00001 diff --git a/Engine/examples/scene-effects/project/assets/energy-grid.tga.rmeta b/Engine/examples/scene-effects/project/assets/energy-grid.tga.rmeta new file mode 100644 index 00000000..96f04767 --- /dev/null +++ b/Engine/examples/scene-effects/project/assets/energy-grid.tga.rmeta @@ -0,0 +1,11 @@ +( + format_version: 1, + asset_id: "5c571541-d32e-42ab-8f90-0f61daa8b177", + importer_id: "rustic.image", + importer_version: 1, + settings: ( + version: 0, + values: {}, + ), + dependencies: [], +) diff --git a/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta b/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta index cb8097fa..ebb07ae1 100644 --- a/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta +++ b/Engine/examples/scene-effects/project/assets/foliage.obj.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "b3abde13-c11c-4504-9506-836e3a02547b", + asset_id: "fd19ec7c-9b3c-4614-abe5-be18c032a6e9", importer_id: "rustic.obj", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/assets/showcase-probe.hdr.rmeta b/Engine/examples/scene-effects/project/assets/showcase-probe.hdr.rmeta index 4c84b3d5..a2ea35e3 100644 --- a/Engine/examples/scene-effects/project/assets/showcase-probe.hdr.rmeta +++ b/Engine/examples/scene-effects/project/assets/showcase-probe.hdr.rmeta @@ -1,6 +1,6 @@ ( format_version: 1, - asset_id: "afb9a09f-7c7b-40c3-a8da-a2ea29475a45", + asset_id: "bff6f758-bd68-4bd7-8574-d48bd41075b6", importer_id: "rustic.image", importer_version: 1, settings: ( diff --git a/Engine/examples/scene-effects/project/config/scripts.ron b/Engine/examples/scene-effects/project/config/scripts.ron index 35071653..57eb73f8 100644 --- a/Engine/examples/scene-effects/project/config/scripts.ron +++ b/Engine/examples/scene-effects/project/config/scripts.ron @@ -2,7 +2,7 @@ format_version: 2, scripts: [ ( - id: "6fc38a2e-9f27-44d0-a846-d6c8445448e9", + id: "0a2272f6-0304-4dc8-a66b-e31819988d7c", language: lua54, relative_path: "scripts/orbit.lua", api_version: ( @@ -11,5 +11,15 @@ ), public_properties: [], ), + ( + id: "2c6833c0-ce36-41c6-a889-a2d14f20ff6c", + language: lua54, + relative_path: "scripts/replay.lua", + api_version: ( + major: 1, + minor: 1, + ), + public_properties: [], + ), ], ) diff --git a/Engine/examples/scene-effects/project/project.engine b/Engine/examples/scene-effects/project/project.engine index 1b65190d..2e2ff669 100644 --- a/Engine/examples/scene-effects/project/project.engine +++ b/Engine/examples/scene-effects/project/project.engine @@ -1,13 +1,13 @@ ( format_version: 1, - project_id: "4da33382-6263-4aa3-b31c-5ff3c13250bc", + project_id: "bfd5c24b-cadc-4ad1-b61a-fec0ca91ef4f", template: blank, metadata: ( name: "Scene Effects Showcase", description: Some("A blank project with only the standard directory layout."), tags: [], - created_unix_seconds: 1793598472, - updated_unix_seconds: 1793598472, + created_unix_seconds: 1793599932, + updated_unix_seconds: 1793599932, ), directories: ( assets: "assets", diff --git a/Engine/examples/scene-effects/project/scene/showcase.scene b/Engine/examples/scene-effects/project/scene/showcase.scene index 344bdbf5..b0c0832d 100644 --- a/Engine/examples/scene-effects/project/scene/showcase.scene +++ b/Engine/examples/scene-effects/project/scene/showcase.scene @@ -1,2692 +1,1722 @@ ( + environment: ( + enabled: true, + sky_image: "", + sky_color: ( + 0.055, + 0.08, + 0.14, + ), + rotation_degrees: 0.0, + exposure: 0.0, + ambient_color: ( + 0.8, + 0.88, + 1.0, + ), + ambient_intensity: 0.48, + sun_color: ( + 1.0, + 0.88, + 0.72, + ), + sun_intensity: 0.8, + sun_direction: ( + 0.3, + 0.9, + -0.2, + ), + haze_color: ( + 0.6, + 0.7, + 0.8, + ), + haze_density: 0.0, + haze_start: 0.0, + ), resource_kind: "rustic_scene", schema_version: 2, - resource_id: "a0885823-28e9-4e2e-94a0-0887a80b3d04", + resource_id: "62493ea5-e7d4-42d0-8c42-b33541383d83", name: "Scene Effects Showcase", - checksum: Some("eac8ee48e18f9528449792d956a84839037c8da8c49d9cbe7411f6f0f1cda8a0"), + checksum: Some("7f132fd7bb993e5af730f781b329aa2de7cfd0df17baa430c6fccee4ce3eb894"), entities: [ /*[0]*/ ( - id: "0104e2c8-aac2-4dec-ad0b-21af9afeb104", + id: "03351eac-7373-48f4-851c-9fab6ae23f39", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"19 Animated Surface pedestal\"", + payload: "\"08 Particle Collision Volume pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,-0.15,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,-0.15,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[1]*/ ( - id: "01ce7f97-9d46-43e7-9bac-b5fa70b7e5ea", + id: "03a223d1-e0a7-4c21-9313-e39f9878fcdf", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"06 Ribbon / Tube target\"", + payload: "\"17 Foliage Scatter pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,1.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,-0.15,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[2]*/ ( - id: "061521cc-a34c-40fa-940b-56527eb02fb2", + id: "0517abf1-91fd-47b8-adf2-e566b403010e", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"07 Particle Force Field pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:125,priority:0),kind:ReflectionProbe((dimensions:(5.0,5.0,5.0),cubemap:Some(\"bff6f758-bd68-4bd7-8574-d48bd41075b6\"),resolution:128,refresh:Baked,blend_distance:1.0,update_interval:1.0,parallax_correction:true)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"26 Reflection Probe\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,-0.15,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,0.0,17.6),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[3]*/ ( - id: "0ab22307-bd19-4b03-80d0-a2c241bb7636", + id: "057eb873-5665-4c28-acaf-b14b77a70ccc", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:false,duration:1.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:123,priority:0),kind:CameraShake((target:None,position_amplitude:(0.015,0.015,0.015),rotation_amplitude:(0.002,0.002,0.002),frequency:20.0,falloff:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),radius:0.0)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"08 Particle Collision Volume point light\"", + payload: "\"24 Camera Shake\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,2.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,0.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[4]*/ ( - id: "0cd53a4e-2717-42ad-b1df-f002a59c360e", + id: "06358f3d-3c97-4d8b-a31c-bff2c0110cfc", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.camera", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:102,priority:0),kind:Beam((start_target:None,end_target:None,start:(0.0,0.0,0.0),end:(2.0,2.0,0.0),width:0.1,segments:24,noise_amplitude:0.2,noise_frequency:3.0,noise_speed:5.0,branch_count:0,appearance:(color:(keys:[(time:0.0,color:(0.25,0.5,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", + payload: "(projection:(kind:\"perspective\",vertical_fov_radians:1.0471976,near:0.1,far:1000.0),zoom:1.0,active:true,order:0)", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"03 Beam / Lightning\"", + payload: "\"Showcase Camera\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(11.0,14.0,-17.0),rotation:(0.24740396,0.0,0.0,0.9689124),scale:(1.0,1.0,1.0))", ), ], ), /*[5]*/ ( - id: "1385b241-997a-4214-b0cd-ccab1eee6bcc", + id: "0a9a764d-50c5-4b07-9e85-e829b681baa7", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"04 Shockwave pedestal\"", + payload: "\"01 Trail Renderer pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[6]*/ ( - id: "1901c611-505e-4a25-a66e-b439ab7052cf", + id: "0bcf646d-280f-45f4-b6bb-2fa4fd76ea58", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"05 Mesh Particle Emitter target\"", + payload: "\"23 Outline / Highlight target\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"capsule\",radius:0.45,height:1.0,segments:24,rings:12)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,1.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,1.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[7]*/ ( - id: "1d2d7847-da17-4e34-aea7-a6a287dcd80c", + id: "128a4aa0-41a8-42c3-9522-1371769e8d70", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"25 Screen Flash pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:114,priority:0),kind:HeatHaze((strength:0.002,noise_scale:10.0,speed:1.0,radius:80.0,edge_falloff:1.0)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"15 Heat Haze\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,-0.15,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,0.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[8]*/ ( - id: "2635f0b1-9c38-4dc7-aaaf-463bdb63720c", + id: "16e16bed-eb53-4497-8a86-09fc3eb06948", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:123,priority:0),kind:CameraShake((target:None,position_amplitude:(0.015,0.015,0.015),rotation_amplitude:(0.002,0.002,0.002),frequency:20.0,falloff:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),radius:20.0)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:112,priority:0),kind:WaterRipple((target:None,source_velocity:(0.0,0.0,0.0),wake_angle:0.6,foam_intensity:0.5,foam_lifetime:1.0,normal_strength:1.0,max_disturbances:32,initial_radius:0.1,final_radius:1.4,thickness:0.2,vertical_offset:0.01,amplitude:0.09,wavelength:0.45,damping:1.0,appearance:(color:(keys:[(time:0.0,color:(0.08,0.55,0.7,0.8))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:0.3,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"24 Camera Shake\"", + payload: "\"13 Water Ripple / Wake\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( + schema: "rustic.scripts", + version: 1, + payload: "[(format_version:1,script_id:\"0a2272f6-0304-4dc8-a66b-e31819988d7c\",enabled:true,properties:{})]", + ), + /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,0.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[9]*/ ( - id: "27677f27-05ab-47ef-ae9d-d5ee8a5d1e55", + id: "19cfb8ad-ed74-4ccd-854e-84db5c0e04a2", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"10 Fog Volume pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:268435456,render_distance:1000.0,quality:Medium,random_seed:506,priority:0),kind:Particle((prewarm:false,prewarm_time:5.0,max_particles:100,rate:30.0,rate_over_distance:0.0,bursts:[],shape:Sphere,dimensions:(0.35,0.35,0.35),surface_only:false,lifetime:(1.5,2.0),speed:(0.05,0.15),direction:(0.0,1.0,0.0),start_size:(0.025,0.06),gravity:(0.0,0.0,0.0),force:(0.0,0.0,0.0),drag:0.05,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,0.2,0.75,0.9))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.0,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:false,collision_layers:268435456,bounce:0.8,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"Vortex particles\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,-0.15,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.5,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[10]*/ ( - id: "2a6976e1-afd2-44cd-8199-21b27ae3eceb", + id: "1c41d221-25e3-4863-b4ed-e1d6844c8ed9", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:104,priority:0),kind:MeshParticle((prewarm:false,prewarm_time:5.0,max_particles:1000,rate:12.0,rate_over_distance:0.0,bursts:[],shape:Cone,dimensions:(1.0,1.0,1.0),surface_only:false,lifetime:(1.0,2.0),speed:(2.0,4.0),direction:(0.0,1.0,0.0),start_size:(0.15,0.35),gravity:(0.0,-5.0,0.0),force:(0.0,0.0,0.0),drag:0.1,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,0.55,0.16,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:0.4,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:Some(\"45440509-633f-4fad-aa77-f00a47e0ba38\"),mesh_spawn_triangles:[],collision:true,collision_layers:4294967295,bounce:0.5,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"17 Foliage Scatter point light\"", + payload: "\"05 Mesh Particle Emitter\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,2.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[11]*/ ( - id: "2abc8d6c-3882-44b2-829f-14bcc0c889c4", + id: "1deae1ed-6180-4f7f-afcb-98f13deaf4cc", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"24 Camera Shake pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:536870912,render_distance:1000.0,quality:Medium,random_seed:513,priority:0),kind:Particle((prewarm:false,prewarm_time:5.0,max_particles:100,rate:30.0,rate_over_distance:0.0,bursts:[],shape:Sphere,dimensions:(0.35,0.35,0.35),surface_only:false,lifetime:(1.5,2.0),speed:(0.05,0.15),direction:(0.0,1.0,0.0),start_size:(0.025,0.06),gravity:(0.0,0.0,0.0),force:(0.0,0.0,0.0),drag:0.05,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(0.5,1.0,0.3,0.9))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.0,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:false,collision_layers:536870912,bounce:0.8,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"Wind particles\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,-0.15,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,0.7,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[12]*/ ( - id: "2ad211f5-9f7d-40ee-86fa-7f0f54b74dbc", + id: "1e846b56-15f4-49a8-bad0-7365572121f5", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:110,priority:0),kind:Decal((dimensions:(1.0,1.0,1.0),appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),normal_map:None,orm_map:None,roughness:0.8,metallic:0.0,edge_fade:0.1,angle_fade:0.5,layers:4294967295)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:103,priority:0),kind:Shockwave((target:None,source_velocity:(0.0,0.0,0.0),wake_angle:0.6,foam_intensity:0.5,foam_lifetime:1.0,normal_strength:1.0,max_disturbances:32,initial_radius:0.1,final_radius:1.55,thickness:0.12,vertical_offset:0.01,amplitude:0.1,wavelength:1.0,damping:1.0,appearance:(color:(keys:[(time:0.0,color:(0.1,1.0,0.65,0.85))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.2,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"11 Decal Projector\"", + payload: "\"04 Shockwave\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,0.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[13]*/ ( - id: "3024d25a-1689-4c4e-a1c1-a6b970861124", + id: "33af8c14-ff5d-4ed1-8495-8ae1f9cbe2ee", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:109,priority:0),kind:Fog((shape:Box,dimensions:(3.0,2.0,3.0),density:0.11,color:(0.24,0.38,0.58),height_falloff:0.5,edge_falloff:1.0,noise_scale:1.0,noise_speed:0.1,anisotropy:0.0,steps:32)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"20 Lens Flare point light\"", + payload: "\"10 Fog Volume\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,2.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,0.0,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[14]*/ ( - id: "31d4b42e-4fe7-4ce4-836a-5946f388dc4f", + id: "37bcb0a2-9259-4435-bdf7-4287f8176e73", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"03 Beam / Lightning pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:111,priority:0),kind:Weather((weather:Rain,emitter:(prewarm:false,prewarm_time:5.0,max_particles:300,rate:90.0,rate_over_distance:0.0,bursts:[],shape:Box,dimensions:(3.0,4.0,3.0),surface_only:false,lifetime:(2.0,3.0),speed:(8.0,10.0),direction:(0.0,-1.0,0.0),start_size:(0.015,0.03),gravity:(0.0,0.0,0.0),force:(0.0,0.0,0.0),drag:0.1,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(0.35,0.65,1.0,0.6))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:0.5,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:false,collision_layers:4294967295,bounce:0.5,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.025),intensity:1.0,camera_follow:false)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"12 Weather Volume\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,0.0,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[15]*/ ( - id: "32e4c734-14f5-4b20-9f66-6ea3e281ee36", + id: "40bcb182-076e-4497-b40f-ec2ea51fa5f7", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:104,priority:0),kind:MeshParticle((prewarm:false,prewarm_time:5.0,max_particles:1000,rate:12.0,rate_over_distance:0.0,bursts:[],shape:Cone,dimensions:(1.0,1.0,1.0),surface_only:false,lifetime:(1.0,2.0),speed:(2.0,4.0),direction:(0.0,1.0,0.0),start_size:(0.15,0.35),gravity:(0.0,-5.0,0.0),force:(0.0,0.0,0.0),drag:0.1,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:Some(\"1591b699-988f-4eb1-82e9-298b6e41bfde\"),mesh_spawn_triangles:[],collision:true,collision_layers:4294967295,bounce:0.5,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:102,priority:0),kind:Beam((start_target:None,end_target:None,start:(0.0,0.0,0.0),end:(1.3,2.2,0.0),width:0.05,segments:24,noise_amplitude:0.2,noise_frequency:3.0,noise_speed:5.0,branch_count:0,appearance:(color:(keys:[(time:0.0,color:(0.5,0.25,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.3,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"05 Mesh Particle Emitter\"", + payload: "\"03 Beam / Lightning\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[16]*/ ( - id: "35e175df-a2db-4a61-a191-c94bed296218", + id: "433f79c6-9071-45d0-8375-aa02b3e9904e", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"13 Water Ripple / Wake target\"", + payload: "\"16 Light Shaft pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,1.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,-0.15,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[17]*/ ( - id: "390be792-6876-4e4c-a06d-376e67fe1c46", + id: "450e0a89-db4a-429e-908e-d2f7061f4678", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.name", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:107,priority:0),kind:CollisionVolume((shape:Plane,dimensions:(10.0,10.0,10.0),layers:4294967295,bounce:0.5,friction:0.1,response:Bounce,impact_effect:None)))", + payload: "\"Showcase floor\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"08 Particle Collision Volume\"", + payload: "(color:(0.2,0.23,0.3,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"plane\",size:(30.0,29.0))", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,0.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(11.0,-0.3,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[18]*/ ( - id: "3db923e9-daed-46f7-9640-356b779ee395", + id: "4626788d-32db-4d95-a3d7-19dce7489111", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"18 Dissolve target\"", + payload: "\"Replay all effects — ArrowUp\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.scripts", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "[(format_version:1,script_id:\"2c6833c0-ce36-41c6-a889-a2d14f20ff6c\",enabled:true,properties:{})]", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,1.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[19]*/ ( - id: "40d4c380-2411-4e7b-8c0a-236f03fca70b", + id: "4794fe58-d753-49f6-95f3-cbf58d00563f", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.name", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:119,priority:0),kind:LensFlare((light:Some(\"3024d25a-1689-4c4e-a1c1-a6b970861124\"),intensity:1.0,scale:0.1,color:(1.0,0.8,0.5),ghosts:4,ghost_spacing:0.2,streak_length:0.3,fade_speed:5.0,texture:None)))", + payload: "\"19 Animated Surface pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"20 Lens Flare\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,0.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,-0.15,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[20]*/ ( - id: "44ac0de7-61bf-4221-a451-a2054c110253", + id: "4a466263-b1e8-48ef-8978-1f6357cad6a1", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"11 Decal Projector target\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:101,priority:0),kind:Particle((prewarm:false,prewarm_time:5.0,max_particles:1000,rate:70.0,rate_over_distance:0.0,bursts:[],shape:Cone,dimensions:(0.4,0.4,0.4),surface_only:false,lifetime:(0.5,1.3),speed:(1.2,2.8),direction:(0.0,1.0,0.0),start_size:(0.06,0.16),gravity:(0.0,-0.4,0.0),force:(0.0,0.0,0.0),drag:0.1,wind_influence:1.0,noise:0.18,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,0.32,0.035,0.9))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.5,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:false,collision_layers:4294967295,bounce:0.5,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"02 Particle Emitter\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,1.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[21]*/ ( - id: "45dbb9ae-2e91-40c0-ad03-fafeacfe4d62", + id: "4a7b7f33-085d-4a02-a774-b3f8d39941db", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"10 Fog Volume pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"11 Decal Projector point light\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,2.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,-0.15,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[22]*/ ( - id: "466ebb6b-cd67-4c67-b656-3b7f4bbf9917", + id: "51ab324c-7e17-4918-bcd9-9174e638b92e", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"25 Screen Flash pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"09 Projectile Tracer point light\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,2.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,-0.15,17.6),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[23]*/ ( - id: "4773eceb-8b1e-4424-9c16-3f519e8eee55", + id: "54095cdb-2e80-457a-9bec-d35d96cecb90", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:115,priority:0),kind:LightShaft((light:Some(\"dbc021b7-1831-4176-8ba5-02aca63f91fc\"),intensity:0.5,color:(1.0,0.9,0.6),density:0.1,anisotropy:0.5,max_distance:100.0,samples:32)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"25 Screen Flash point light\"", + payload: "\"16 Light Shaft\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,2.0,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,0.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[24]*/ ( - id: "47bc3132-bb7f-40cf-9df6-a0f583d90324", + id: "574bbdb0-b98c-48a8-a601-05587215e36f", components: [ /*[0]*/ ( schema: "rustic.light", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(kind:point,color:(1.0,1.0,1.0),intensity:3.0,range:4.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"01 Trail Renderer point light\"", + payload: "\"20 Lens Flare point light\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(1.0,0.76,0.32,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[3]*/ ( + schema: "rustic.primitive", + version: 1, + payload: "(kind:\"sphere\",radius:0.16,segments:16,rings:10)", + ), + /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,2.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,2.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[25]*/ ( - id: "483ec965-1d51-4506-994a-6d4a89450dc8", + id: "5b4a602b-faea-4993-8ddb-c504ec077c04", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"12 Weather Volume pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:false,duration:1.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:124,priority:0),kind:ScreenFlash((target:None,color:(1.0,0.0,0.0,1.0),opacity:0.015,fade_in:0.05,fade_out:0.4,vignette:0.5,blend:Alpha)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"25 Screen Flash\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,-0.15,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,17.6),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[26]*/ ( - id: "495f498e-8b52-4ee3-b724-aecf840d7ee0", + id: "5d300a68-67b6-4d10-a758-54cd3124b716", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"14 Wind Zone pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:113,priority:0),kind:Wind((shape:Sphere,dimensions:(3.0,3.0,3.0),force_type:Directional,direction:(1.0,0.0,0.2),strength:1.4,falloff:1.0,turbulence:0.25,frequency:1.0,layers:536870912,global:false)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"14 Wind Zone\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,-0.15,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,0.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[27]*/ ( - id: "4c4e6fa6-e5b6-4b3b-999e-191790bc6b40", + id: "5db6eb5a-d0c1-4e78-8ffd-86f4b874dbb5", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"02 Particle Emitter target\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:108,priority:0),kind:ProjectileTracer((velocity:(0.35,0.1,0.0),length:1.2,width:0.065,appearance:(color:(keys:[(time:0.0,color:(1.0,0.6,0.08,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.8,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"09 Projectile Tracer\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,1.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,0.0,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[28]*/ ( - id: "4ccb0627-259f-43d3-bb4b-3c69239ef9a7", + id: "660170f6-2dba-49ef-a957-09753aee9dec", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:100,priority:0),kind:Trail((emitting:true,lifetime:1.0,width:0.2,min_vertex_distance:0.05,max_points:256,teleport_distance:20.0,camera_facing:true,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:122,priority:0),kind:Outline((targets:[\"0bcf646d-280f-45f4-b6bb-2fa4fd76ea58\"],color:(1.0,0.6,0.0,1.0),thickness:0.03,pulse_speed:0.0,through_walls:false)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"01 Trail Renderer\"", + payload: "\"23 Outline / Highlight\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( - schema: "rustic.scripts", - version: 1, - payload: "[(format_version:1,script_id:\"6fc38a2e-9f27-44d0-a846-d6c8445448e9\",enabled:true,properties:{})]", - ), - /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,0.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[29]*/ ( - id: "4e256006-6741-461c-9496-ccdebd7eae56", + id: "6651fdcc-e1ce-48cf-aacc-1f14e4405a37", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:118,priority:0),kind:AnimatedSurface((target:Some(\"822a4853-fa29-4274-aa2e-0de45f689176\"),uv_scroll:(0.2,0.0),uv_scale:(1.0,1.0),uv_rotation:0.0,color:(keys:[(time:0.0,color:(0.1,0.4,1.0,1.0)),(time:1.0,color:(1.0,0.2,0.5,1.0))]),emission:1.0,speed:1.0)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"18 Dissolve point light\"", + payload: "\"19 Animated Surface\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,2.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[30]*/ ( - id: "527c081b-4b08-45b3-8d75-f6ea72344cc2", + id: "6ef0e859-ebcd-447e-90c0-30f131f827b3", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"10 Fog Volume target\"", + payload: "\"02 Particle Emitter pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,1.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[31]*/ ( - id: "56f0f99a-33c2-480f-a155-fa773de62565", + id: "709de131-f7c8-41de-b294-9a7bacbb955f", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"14 Wind Zone target\"", + payload: "\"06 Ribbon / Tube pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,1.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[32]*/ ( - id: "59037f03-06b0-4e28-8320-f98fb655a214", + id: "7184bfc7-7c2f-4b48-92dd-b2c315a43850", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"13 Water Ripple / Wake pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:107,priority:0),kind:CollisionVolume((shape:Plane,dimensions:(3.0,3.0,3.0),layers:1073741824,bounce:0.8,friction:0.12,response:Bounce,impact_effect:None)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"08 Particle Collision Volume\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,-0.15,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,0.0,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[33]*/ ( - id: "5fbec516-35a8-4cef-9038-713a5ae59702", + id: "7698d495-ac32-48a0-9e06-c15dc8c507e2", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"09 Projectile Tracer pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:105,priority:0),kind:Ribbon((points:[(-1.2,0.4,0.0),(-0.6,1.8,0.2),(0.0,0.6,0.0),(0.6,1.8,-0.2),(1.2,0.4,0.0)],width:0.1,tube:true,radial_segments:8,subdivisions:8,twist:0.0,appearance:(color:(keys:[(time:0.0,color:(0.6,0.18,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.1,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"06 Ribbon / Tube\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,-0.15,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[34]*/ ( - id: "6534e18d-4439-4f18-a7ef-ce9c2a0983be", + id: "81062a5c-cbaf-4d82-b4cc-d14fd9b0a7f9", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"18 Dissolve pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"14 Wind Zone point light\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,2.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,-0.15,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[35]*/ ( - id: "66ab88b3-7bed-4da2-8458-6eb15baacf8d", + id: "822a4853-fa29-4274-aa2e-0de45f689176", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.name", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:105,priority:0),kind:Ribbon((points:[(0.0,0.0,0.0),(1.0,1.0,0.0),(2.0,0.0,0.0)],width:0.2,tube:false,radial_segments:8,subdivisions:8,twist:0.0,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", + payload: "\"19 Animated Surface target\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"06 Ribbon / Tube\"", + payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"rectangular_prism\",size:(2.2,0.25,2.2))", ), /*[3]*/ ( + schema: "rustic.surface_textures", + version: 1, + payload: "(maps:{BaseColor:\"assets/energy-grid.tga\"},uv_scale:(1.0,1.0),uv_offset:(0.0,0.0),height_scale:0.05,emissive_strength:1.0)", + ), + /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,1.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[36]*/ ( - id: "6ad30a03-f32b-4266-8a44-0768976d0c26", + id: "8f3e05da-002d-46e0-9f41-d5d9f32f22c8", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"20 Lens Flare target\"", + payload: "\"15 Heat Haze pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,1.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,-0.15,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[37]*/ ( - id: "6b5ef1bf-c98d-4d60-b61e-f43d7acfaaf1", + id: "9113e0ec-6676-4ae5-a41d-1561ba0df59c", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"18 Dissolve target\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"16 Light Shaft point light\"", + payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cube\",size:1.3)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,2.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,1.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[38]*/ ( - id: "6e98df0d-3fef-4dc6-96aa-10ee6dab47b7", + id: "92632534-0cb6-4eb6-932d-871365b5aee0", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:106,priority:0),kind:ForceField((shape:Sphere,dimensions:(3.0,3.0,3.0),force_type:Vortex,direction:(0.0,1.0,0.0),strength:3.0,falloff:1.0,turbulence:0.0,frequency:1.0,layers:268435456,global:false)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"15 Heat Haze point light\"", + payload: "\"07 Particle Force Field\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,2.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[39]*/ ( - id: "6eaf97bc-1b03-4087-8b2f-e4dd594a8776", + id: "950bf5c5-a62b-4e54-8ba6-1505da3ba42d", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"07 Particle Force Field target\"", + payload: "\"21 Light Pulse / Flicker pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,1.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,-0.15,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[40]*/ ( - id: "6ee6146b-50ba-43f0-b2c3-345138af955b", + id: "9f45cf5e-08d9-41f9-b939-f4a6a34a7316", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"26 Reflection Probe pedestal\"", + payload: "\"26 Reflection Probe target\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(kind:\"sphere\",radius:0.8,segments:32,rings:24)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,-0.15,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,1.0,17.6),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[41]*/ ( - id: "70d6965d-e28b-44ef-b471-19e952d6d228", + id: "a294d57c-8c6f-410c-bf5c-506c551054cf", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"22 Post-Processing Volume pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:121,priority:0),kind:PostProcess((global:true,dimensions:(3.0,3.0,3.0),blend_distance:1.0,weight:0.18,exposure:0.0,saturation:1.1,contrast:1.0,brightness:0.0,tint:(1.0,1.0,1.0),bloom:0.12,vignette:0.2,chromatic_aberration:0.0,grain:0.0,depth_of_field:0.0)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"22 Post-Processing Volume\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,-0.15,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,0.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[42]*/ ( - id: "71fa5443-37ae-4920-9de6-793fd4eba368", + id: "a87af00b-8afc-4c7d-86ca-fd9c199ea3c8", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:116,priority:0),kind:FoliageScatter((meshes:[\"fd19ec7c-9b3c-4614-abe5-be18c032a6e9\"],density:1.0,dimensions:(3.0,1.0,3.0),scale:(0.5,1.5),max_instances:40,wind_response:1.0,appearance:(color:(keys:[(time:0.0,color:(0.2,0.6,0.12,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"06 Ribbon / Tube point light\"", + payload: "\"17 Foliage Scatter\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,2.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,0.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[43]*/ ( - id: "72d407da-dfe7-48c3-9590-32757fe65b94", + id: "abae2233-d098-41d4-aeed-033c3cf36891", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.name", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:117,priority:0),kind:Dissolve((target:Some(\"3db923e9-daed-46f7-9640-356b779ee395\"),progress:0.0,edge_width:0.05,edge_color:(1.0,0.4,0.0),edge_emission:3.0,noise_scale:10.0,curve:(keys:[(time:0.0,value:0.0),(time:1.0,value:1.0)]))))", + payload: "\"24 Camera Shake pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"18 Dissolve\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,0.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,-0.15,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[44]*/ ( - id: "74894c42-cde7-4a88-8c34-f95d791486ee", + id: "b5c64c75-a092-4676-88a7-fd325b24f64b", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"03 Beam / Lightning target\"", + payload: "\"12 Weather Volume pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,1.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,-0.15,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[45]*/ ( - id: "76347871-0ac5-4b52-a6b9-bee258a04ee5", + id: "b7679114-90ab-4351-a785-845ed7c2cefe", components: [ /*[0]*/ ( - schema: "rustic.name", + schema: "rustic.effect", version: 1, - payload: "\"16 Light Shaft pedestal\"", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1073741824,render_distance:1000.0,quality:Medium,random_seed:507,priority:0),kind:Particle((prewarm:false,prewarm_time:5.0,max_particles:100,rate:30.0,rate_over_distance:0.0,bursts:[],shape:Sphere,dimensions:(0.35,0.35,0.35),surface_only:false,lifetime:(1.5,2.0),speed:(0.1,0.2),direction:(0.0,-1.0,0.0),start_size:(0.025,0.06),gravity:(0.0,-4.0,0.0),force:(0.0,0.0,0.0),drag:0.05,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,0.65,0.12,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.0,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:true,collision_layers:1073741824,bounce:0.8,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", ), /*[1]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.name", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"Collision particles\"", ), /*[2]*/ ( - schema: "rustic.primitive", + schema: "rustic.part_attributes", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,-0.15,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,2.2,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[46]*/ ( - id: "79197d23-911f-4e20-9c2c-c3e5dd8716ad", + id: "b987347c-eddf-4994-a9f0-36b4471539ea", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.effect", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:120,priority:0),kind:LightPulse((target:Some(\"edfb2571-f26b-4c93-89d2-e5022272c993\"),intensity:(keys:[(time:0.0,value:0.0),(time:1.0,value:1.0)]),color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),frequency:1.0,randomness:0.0,phase:0.0)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"23 Outline / Highlight point light\"", + payload: "\"21 Light Pulse / Flicker\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,2.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,0.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[47]*/ ( - id: "79917dbc-8c73-482c-815d-56e4b1841d07", + id: "be24de9d-4d17-41b8-9e39-d1b98d253687", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"21 Light Pulse / Flicker pedestal\"", + payload: "\"22 Post-Processing Volume pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,-0.15,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,-0.15,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[48]*/ ( - id: "7f0369c2-0ebe-498f-aff5-146155ecd137", + id: "be38c1fb-fced-4b90-8a69-ceb63d9e7e5c", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"26 Reflection Probe target\"", + payload: "\"20 Lens Flare pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,1.0,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,-0.15,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[49]*/ ( - id: "8084b246-10b0-4d5d-9936-978b20c8d2db", + id: "c6b35998-a2ea-4ce0-b1a6-8fa53195d9b6", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"19 Animated Surface target\"", + payload: "\"14 Wind Zone pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,1.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,-0.15,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[50]*/ ( - id: "8266ce87-1863-4be3-b0bd-d057ab190e3c", + id: "c8c54e01-bef5-4fbb-8900-53789e0ee974", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.name", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:124,priority:0),kind:ScreenFlash((target:None,color:(1.0,0.0,0.0,1.0),opacity:0.03,fade_in:0.05,fade_out:0.4,vignette:0.5,blend:Alpha)))", + payload: "\"11 Decal Projector pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"25 Screen Flash\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,0.0,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,-0.15,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[51]*/ ( - id: "82ae022f-5883-4459-960c-52914d79185f", + id: "ccc6531a-d9df-4611-bd35-03d90dc37770", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:directional,color:(1.0,0.9,0.75),intensity:2.0,range:10.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"03 Beam / Lightning pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"Key light\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,0.0,0.0),rotation:(-0.33606267,-0.18662454,0.06812327,0.9206478),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[52]*/ ( - id: "850e74c2-7381-4192-9cb6-eab176ec0fc1", + id: "d5964c7b-6731-45ad-81b4-948dbc7962ac", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"07 Particle Force Field pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"13 Water Ripple / Wake point light\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,2.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,-0.15,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[53]*/ ( - id: "862b460f-cc46-4d37-8186-ba5278c3f30e", + id: "d7e89f70-8f56-405d-a7d9-44080dcf4265", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:120,priority:0),kind:LightPulse((target:Some(\"f09c8799-9e00-4d34-8b5b-b9a51a4f854d\"),intensity:(keys:[(time:0.0,value:0.0),(time:1.0,value:1.0)]),color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),frequency:1.0,randomness:0.0,phase:0.0)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:117,priority:0),kind:Dissolve((target:Some(\"9113e0ec-6676-4ae5-a41d-1561ba0df59c\"),progress:0.0,edge_width:0.05,edge_color:(1.0,0.4,0.0),edge_emission:3.0,noise_scale:10.0,curve:(keys:[(time:0.0,value:0.0),(time:1.0,value:1.0)]))))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"21 Light Pulse / Flicker\"", + payload: "\"18 Dissolve\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,0.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(22.0,0.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[54]*/ ( - id: "876780c8-227e-4377-b720-5df0a6ee8238", + id: "db0beafd-384d-46e9-b5c9-93ef7ef87426", components: [ /*[0]*/ ( - schema: "rustic.light", + schema: "rustic.name", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"09 Projectile Tracer pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", + schema: "rustic.part_attributes", version: 1, - payload: "\"12 Weather Volume point light\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(25.0,2.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,-0.15,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), /*[55]*/ ( - id: "8e6d0237-94ec-4866-bfe5-c7f47f017ee2", + id: "dbc021b7-1831-4176-8ba5-02aca63f91fc", components: [ /*[0]*/ ( - schema: "rustic.effect", + schema: "rustic.light", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:125,priority:0),kind:ReflectionProbe((dimensions:(5.0,5.0,5.0),cubemap:Some(\"afb9a09f-7c7b-40c3-a8da-a2ea29475a45\"),resolution:128,refresh:Baked,blend_distance:1.0,update_interval:1.0,parallax_correction:true)))", + payload: "(kind:point,color:(1.0,1.0,1.0),intensity:3.0,range:4.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"26 Reflection Probe\"", + payload: "\"16 Light Shaft point light\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(1.0,0.76,0.32,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(5.0,0.0,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[56]*/ ( - id: "91a8cbd7-90fc-4175-b95b-be5352774d2c", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"01 Trail Renderer target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"sphere\",radius:0.16,segments:16,rings:10)", ), - /*[3]*/ ( + /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,1.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,2.0,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[57]*/ ( - id: "98fdde01-9b5a-4df8-b65b-72b81e2c76cf", + /*[56]*/ ( + id: "ea2ad6cc-57b0-4f34-b67b-dfc6e6347f09", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:101,priority:0),kind:Particle((prewarm:false,prewarm_time:5.0,max_particles:1000,rate:30.0,rate_over_distance:0.0,bursts:[],shape:Cone,dimensions:(1.0,1.0,1.0),surface_only:false,lifetime:(1.0,2.0),speed:(1.0,3.0),direction:(0.0,1.0,0.0),start_size:(0.1,0.25),gravity:(0.0,-1.0,0.0),force:(0.0,0.0,0.0),drag:0.1,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:false,collision_layers:4294967295,bounce:0.5,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:119,priority:0),kind:LensFlare((light:Some(\"574bbdb0-b98c-48a8-a601-05587215e36f\"),intensity:1.0,scale:0.1,color:(1.0,0.8,0.5),ghosts:4,ghost_spacing:0.2,streak_length:0.3,fade_speed:5.0,texture:None)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"02 Particle Emitter\"", + payload: "\"20 Lens Flare\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,0.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[58]*/ ( - id: "9e6bbf48-56b3-495f-a026-016af4dc3e45", + /*[57]*/ ( + id: "ea35b25d-b00d-44bc-ae6f-b7885d60be72", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:113,priority:0),kind:Wind((shape:Sphere,dimensions:(3.0,3.0,3.0),force_type:Directional,direction:(1.0,0.0,0.0),strength:2.0,falloff:1.0,turbulence:0.0,frequency:1.0,layers:4294967295,global:false)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:110,priority:0),kind:Decal((dimensions:(2.0,1.2,2.0),appearance:(color:(keys:[(time:0.0,color:(0.12,0.65,0.95,0.8))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:0.35,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),normal_map:None,orm_map:None,roughness:0.8,metallic:0.0,edge_fade:0.1,angle_fade:0.5,layers:4294967295)))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"14 Wind Zone\"", + payload: "\"11 Decal Projector\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,0.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,0.0,4.4),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[59]*/ ( - id: "a05387f8-6c77-4076-9a6d-2918c69d306f", + /*[58]*/ ( + id: "edfb2571-f26b-4c93-89d2-e5022272c993", components: [ /*[0]*/ ( schema: "rustic.light", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(kind:point,color:(1.0,1.0,1.0),intensity:3.0,range:4.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"19 Animated Surface point light\"", + payload: "\"21 Light Pulse / Flicker point light\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(1.0,0.76,0.32,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(0.0,2.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[60]*/ ( - id: "a16fe331-ba86-4ae8-b796-8005ce650715", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"08 Particle Collision Volume target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"sphere\",radius:0.16,segments:16,rings:10)", ), - /*[3]*/ ( + /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,1.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(8.8,2.0,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[61]*/ ( - id: "a5d6d77f-fa48-4ff1-85f2-3e3464d68cf2", + /*[59]*/ ( + id: "f0dbde55-e344-411a-8dd0-965f652fe690", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"25 Screen Flash target\"", + payload: "\"26 Reflection Probe pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,1.0,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(4.4,-0.15,17.6),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[62]*/ ( - id: "ad100edd-5c25-4474-868f-da70d48eaed0", + /*[60]*/ ( + id: "f110fd99-59a6-45f3-9cb2-a99c6d3e7027", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"09 Projectile Tracer target\"", + payload: "\"05 Mesh Particle Emitter pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(10.0,1.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[63]*/ ( - id: "aec968e4-3583-460b-bee5-b9d36e5e9080", + /*[61]*/ ( + id: "f2d08a23-2a95-44d4-8a06-9c56c212cc6e", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"23 Outline / Highlight pedestal\"", + payload: "\"04 Shockwave pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,-0.15,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[64]*/ ( - id: "b138bc4d-abd4-49b1-bdb8-86d9dbeebf12", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:115,priority:0),kind:LightShaft((light:Some(\"6b5ef1bf-c98d-4d60-b61e-f43d7acfaaf1\"),intensity:0.5,color:(1.0,0.9,0.6),density:0.1,anisotropy:0.5,max_distance:100.0,samples:32)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"16 Light Shaft\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(15.0,0.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(13.200001,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[65]*/ ( - id: "b38e3e49-6de2-4b5e-9fa3-0f1bad4c45f9", + /*[62]*/ ( + id: "f4acbbbf-b0f5-4807-bd73-ff4655162495", components: [ /*[0]*/ ( schema: "rustic.effect", version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:114,priority:0),kind:HeatHaze((strength:0.01,noise_scale:10.0,speed:1.0,radius:3.0,edge_falloff:1.0)))", + payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:1,render_distance:1000.0,quality:Medium,random_seed:100,priority:0),kind:Trail((emitting:true,lifetime:1.2,width:0.16,min_vertex_distance:0.05,max_points:256,teleport_distance:20.0,camera_facing:true,appearance:(color:(keys:[(time:0.0,color:(0.1,0.8,1.0,0.9))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.25)]),material:None,texture:None,emission:1.4,blend:Additive,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"15 Heat Haze\"", + payload: "\"01 Trail Renderer\"", ), /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,0.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[66]*/ ( - id: "b46a37cc-7252-4f63-820f-78dbd4c525e7", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:116,priority:0),kind:FoliageScatter((meshes:[\"b3abde13-c11c-4504-9506-836e3a02547b\"],density:1.0,dimensions:(3.0,1.0,3.0),scale:(0.5,1.5),max_instances:40,wind_response:1.0,appearance:(color:(keys:[(time:0.0,color:(0.2,0.6,0.12,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"17 Foliage Scatter\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.scripts", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "[(format_version:1,script_id:\"0a2272f6-0304-4dc8-a66b-e31819988d7c\",enabled:true,properties:{})]", ), - /*[3]*/ ( + /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,0.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[67]*/ ( - id: "b4e923b2-1fbc-4532-80eb-c6ad36123503", + /*[63]*/ ( + id: "f7613b8a-a608-4516-b086-e6404f5f5921", components: [ /*[0]*/ ( schema: "rustic.name", version: 1, - payload: "\"12 Weather Volume target\"", + payload: "\"23 Outline / Highlight pedestal\"", ), /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( schema: "rustic.primitive", version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(25.0,1.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[68]*/ ( - id: "baa911bc-c022-403d-91de-bbbdef7442f5", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:121,priority:0),kind:PostProcess((global:false,dimensions:(3.0,3.0,3.0),blend_distance:1.0,weight:1.0,exposure:0.0,saturation:1.1,contrast:1.0,brightness:0.0,tint:(1.0,1.0,1.0),bloom:0.0,vignette:0.2,chromatic_aberration:0.0,grain:0.0,depth_of_field:0.0)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"22 Post-Processing Volume\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,0.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[69]*/ ( - id: "bc09314e-b4d9-4118-8c85-6ae67d0ae5ff", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:122,priority:0),kind:Outline((targets:[\"dd587403-9c26-41de-a267-6bd18914290a\"],color:(1.0,0.6,0.0,1.0),thickness:0.03,pulse_speed:0.0,through_walls:false)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"23 Outline / Highlight\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(20.0,0.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(17.6,-0.15,13.200001),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[70]*/ ( - id: "be96d399-d095-4bca-bad4-4d56061e4620", + /*[64]*/ ( + id: "fc1d38ce-0820-46ee-b830-f6375e4a3091", components: [ /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:103,priority:0),kind:Shockwave((target:None,source_velocity:(0.0,0.0,0.0),wake_angle:0.6,foam_intensity:0.5,foam_lifetime:1.0,normal_strength:1.0,max_disturbances:32,initial_radius:0.1,final_radius:4.0,thickness:0.2,vertical_offset:0.01,amplitude:0.1,wavelength:1.0,damping:1.0,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", - ), - /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"04 Shockwave\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,0.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[71]*/ ( - id: "c006fe07-3ca7-40f4-9625-4231822b0b6d", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "\"13 Water Ripple / Wake pedestal\"", ), /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"26 Reflection Probe point light\"", - ), - /*[2]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(5.0,2.0,20.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[72]*/ ( - id: "c1df5b30-9559-4e62-ad6f-70a065914998", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:112,priority:0),kind:WaterRipple((target:None,source_velocity:(0.0,0.0,0.0),wake_angle:0.6,foam_intensity:0.5,foam_lifetime:1.0,normal_strength:1.0,max_disturbances:32,initial_radius:0.1,final_radius:4.0,thickness:0.2,vertical_offset:0.01,amplitude:0.1,wavelength:1.0,damping:1.0,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"13 Water Ripple / Wake\"", + payload: "(color:(0.16,0.2,0.28,1.0),can_touch:true,can_collide:true,anchored:true,friction:0.5)", ), /*[2]*/ ( - schema: "rustic.part_attributes", + schema: "rustic.primitive", version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", + payload: "(kind:\"cylinder\",radius:1.6,height:0.2,segments:48)", ), /*[3]*/ ( - schema: "rustic.scripts", - version: 1, - payload: "[(format_version:1,script_id:\"6fc38a2e-9f27-44d0-a846-d6c8445448e9\",enabled:true,properties:{})]", - ), - /*[4]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(0.0,0.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,-0.15,8.8),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", ), ], ), - /*[73]*/ ( - id: "c21b48c8-6fb0-4d89-a819-5224da6af92d", + /*[65]*/ ( + id: "fece33e9-bdb8-4e92-9ea3-74f200522655", components: [ /*[0]*/ ( schema: "rustic.light", version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", + payload: "(kind:directional,color:(1.0,0.9,0.75),intensity:1.4,range:10.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", ), /*[1]*/ ( schema: "rustic.name", version: 1, - payload: "\"02 Particle Emitter point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(5.0,2.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[74]*/ ( - id: "c3064273-2a95-4806-92c8-cb818b7eacab", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"17 Foliage Scatter target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", + payload: "\"Key light\"", ), /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,1.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[75]*/ ( - id: "c7ccb861-8a44-4969-a520-161398ed6f4d", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"20 Lens Flare pedestal\"", - ), - /*[1]*/ ( schema: "rustic.part_attributes", version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", + payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false,friction:0.5)", ), /*[3]*/ ( schema: "rustic.transform", version: 1, - payload: "(translation:(5.0,-0.15,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[76]*/ ( - id: "c871abfe-324a-4804-b49c-d2e97cce32f9", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:106,priority:0),kind:ForceField((shape:Sphere,dimensions:(3.0,3.0,3.0),force_type:Directional,direction:(1.0,0.0,0.0),strength:2.0,falloff:1.0,turbulence:0.0,frequency:1.0,layers:4294967295,global:false)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"07 Particle Force Field\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(0.0,0.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[77]*/ ( - id: "c9306f35-2f7e-4fdd-bbc7-b0dcbe417c64", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"04 Shockwave target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,1.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[78]*/ ( - id: "c9a0d950-db7d-4245-9d86-5114c6a9bc47", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:111,priority:0),kind:Weather((weather:Rain,emitter:(prewarm:false,prewarm_time:5.0,max_particles:300,rate:200.0,rate_over_distance:0.0,bursts:[],shape:Box,dimensions:(3.0,4.0,3.0),surface_only:false,lifetime:(2.0,3.0),speed:(8.0,10.0),direction:(0.0,-1.0,0.0),start_size:(0.1,0.25),gravity:(0.0,0.0,0.0),force:(0.0,0.0,0.0),drag:0.1,wind_influence:1.0,noise:0.0,angular_velocity:0.0,inherit_velocity:0.0,velocity_limit:1000.0,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)),mesh:None,mesh_spawn_triangles:[],collision:false,collision_layers:4294967295,bounce:0.5,friction:0.1,collision_response:Bounce,sub_emitter:None,sub_emitter_probability:1.0,max_generations:3,flipbook:(1,1),flipbook_fps:0.0,stretch:0.0),intensity:1.0,camera_follow:false)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"12 Weather Volume\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(25.0,0.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[79]*/ ( - id: "cb9ca10a-497a-438a-abf2-501b190d2e93", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"01 Trail Renderer pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(0.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[80]*/ ( - id: "cccfd505-954b-426c-8d52-88aa62a15e86", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"24 Camera Shake target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(25.0,1.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[81]*/ ( - id: "cf28f330-33be-4512-9392-0a421132571a", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"18 Dissolve pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(25.0,-0.15,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[82]*/ ( - id: "cf54cdfe-28bb-4124-ae71-1697cbf7b8f7", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"05 Mesh Particle Emitter point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,2.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[83]*/ ( - id: "d084f656-beba-4df2-8b6d-22afde07c522", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:109,priority:0),kind:Fog((shape:Box,dimensions:(3.0,2.0,3.0),density:0.08,color:(0.6,0.7,0.8),height_falloff:0.5,edge_falloff:1.0,noise_scale:1.0,noise_speed:0.1,anisotropy:0.0,steps:32)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"10 Fog Volume\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,0.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[84]*/ ( - id: "d1144650-d058-4f4e-a40a-0c38ea9bd1c6", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"24 Camera Shake point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(25.0,2.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[85]*/ ( - id: "d9da673f-61d4-40f7-900e-8cb056772f75", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"21 Light Pulse / Flicker target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,1.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[86]*/ ( - id: "d9f197ae-99d2-4bb1-99f9-90a4189c7160", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"15 Heat Haze pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,-0.15,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[87]*/ ( - id: "dae9238c-dbca-4260-8e38-061516986caa", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"04 Shockwave point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,2.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[88]*/ ( - id: "dbe4e477-6e4b-445c-ad57-1f4fc70ac9dc", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"16 Light Shaft target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,1.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[89]*/ ( - id: "dd587403-9c26-41de-a267-6bd18914290a", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"23 Outline / Highlight target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,1.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[90]*/ ( - id: "de4872c9-c957-4dfe-8f8a-b40c5218f648", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"Showcase floor\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.12,0.14,0.18,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"plane\",size:(45.0,40.0))", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(12.5,-0.3,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[91]*/ ( - id: "df42a82a-322f-4bb8-97fd-42ebf6e81eba", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"07 Particle Force Field point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(0.0,2.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[92]*/ ( - id: "e4ec4b1f-8a20-44ab-8dcd-1783adca7ea2", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"17 Foliage Scatter pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,-0.15,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[93]*/ ( - id: "e5d8d390-bd84-4610-84b1-6e31badd0d0b", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"06 Ribbon / Tube pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(25.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[94]*/ ( - id: "ed66fe47-2a73-46e4-9bcd-59ce033a7410", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"11 Decal Projector pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,-0.15,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[95]*/ ( - id: "ef400e19-1227-429a-a4e4-12a079fd6fbb", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"10 Fog Volume point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,2.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[96]*/ ( - id: "f09c8799-9e00-4d34-8b5b-b9a51a4f854d", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"21 Light Pulse / Flicker point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,2.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[97]*/ ( - id: "f10c2227-5280-4022-af04-7f42a4b0eabd", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"05 Mesh Particle Emitter pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(20.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[98]*/ ( - id: "f257339d-44e1-490f-9077-8b1310efdeb7", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"02 Particle Emitter pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(5.0,-0.15,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[99]*/ ( - id: "f8f20726-c8cd-4e22-8ebd-83bc5b0ed36e", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"15 Heat Haze target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,1.0,10.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[100]*/ ( - id: "faa5c19b-2cf4-4e2d-a045-60dc4603a1aa", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:108,priority:0),kind:ProjectileTracer((velocity:(0.0,0.0,10.0),length:2.0,width:0.05,appearance:(color:(keys:[(time:0.0,color:(1.0,1.0,1.0,1.0))]),opacity:(keys:[(time:0.0,value:1.0),(time:1.0,value:0.0)]),size:(keys:[(time:0.0,value:1.0),(time:1.0,value:1.0)]),material:None,texture:None,emission:1.0,blend:Alpha,uv_scale:(1.0,1.0),uv_scroll:(0.0,0.0)))))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"09 Projectile Tracer\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,0.0,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[101]*/ ( - id: "fb69078d-f696-4078-8a1c-50bdc3a944bb", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"22 Post-Processing Volume target\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.2,0.45,0.65,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"sphere\",radius:0.6,segments:24,rings:16)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,1.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[102]*/ ( - id: "fb99133b-fbab-48fd-a285-e092aca067a1", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"22 Post-Processing Volume point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(15.0,2.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[103]*/ ( - id: "fdb17b37-4e5a-4093-9bb9-f462fd37c321", - components: [ - /*[0]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"08 Particle Collision Volume pedestal\"", - ), - /*[1]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.07,0.09,0.13,1.0),can_touch:true,can_collide:true,anchored:true)", - ), - /*[2]*/ ( - schema: "rustic.primitive", - version: 1, - payload: "(kind:\"cylinder\",radius:2.0,height:0.2,segments:48)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(5.0,-0.15,5.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[104]*/ ( - id: "fdbd309f-5712-4d34-9b98-73a2af25987c", - components: [ - /*[0]*/ ( - schema: "rustic.camera", - version: 1, - payload: "(projection:(kind:\"perspective\",vertical_fov_radians:1.0471976,near:0.1,far:1000.0),zoom:1.0,active:true,order:0)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"Showcase Camera\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(12.5,18.0,-25.0),rotation:(0.1888589,0.0,0.0,0.9820042),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[105]*/ ( - id: "fdd6d2f2-b8e3-4c00-a0e1-bd6eeeb27586", - components: [ - /*[0]*/ ( - schema: "rustic.effect", - version: 1, - payload: "(source:None,common:(parameters:{},bounds:None,enabled:true,visible:true,auto_play:true,looping:true,duration:5.0,delay:0.0,time_scale:1.0,simulation_space:World,attachment_target:None,visibility_layers:4294967295,render_distance:1000.0,quality:Medium,random_seed:118,priority:0),kind:AnimatedSurface((target:Some(\"8084b246-10b0-4d5d-9936-978b20c8d2db\"),uv_scroll:(0.2,0.0),uv_scale:(1.0,1.0),uv_rotation:0.0,color:(keys:[(time:0.0,color:(0.1,0.4,1.0,1.0)),(time:1.0,color:(1.0,0.2,0.5,1.0))]),emission:1.0,speed:1.0)))", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"19 Animated Surface\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(0.0,0.0,15.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", - ), - ], - ), - /*[106]*/ ( - id: "fe8463b6-82c4-41bd-adb4-5c7b13fd02a3", - components: [ - /*[0]*/ ( - schema: "rustic.light", - version: 1, - payload: "(kind:point,color:(1.0,1.0,1.0),intensity:4.0,range:5.0,spot_outer_angle_radians:0.7853982,casts_shadows:true)", - ), - /*[1]*/ ( - schema: "rustic.name", - version: 1, - payload: "\"03 Beam / Lightning point light\"", - ), - /*[2]*/ ( - schema: "rustic.part_attributes", - version: 1, - payload: "(color:(0.55,0.62,0.72,1.0),can_touch:true,can_collide:true,anchored:false)", - ), - /*[3]*/ ( - schema: "rustic.transform", - version: 1, - payload: "(translation:(10.0,2.0,0.0),rotation:(0.0,0.0,0.0,1.0),scale:(1.0,1.0,1.0))", + payload: "(translation:(0.0,0.0,0.0),rotation:(-0.33606267,-0.18662454,0.06812327,0.9206478),scale:(1.0,1.0,1.0))", ), ], ), diff --git a/Engine/examples/scene-effects/project/scripts/replay.lua b/Engine/examples/scene-effects/project/scripts/replay.lua new file mode 100644 index 00000000..2ede5201 --- /dev/null +++ b/Engine/examples/scene-effects/project/scripts/replay.lua @@ -0,0 +1,3 @@ +local ids={'f4acbbbf-b0f5-4807-bd73-ff4655162495','4a466263-b1e8-48ef-8978-1f6357cad6a1','40bcb182-076e-4497-b40f-ec2ea51fa5f7','1e846b56-15f4-49a8-bad0-7365572121f5','1c41d221-25e3-4863-b4ed-e1d6844c8ed9','7698d495-ac32-48a0-9e06-c15dc8c507e2','19cfb8ad-ed74-4ccd-854e-84db5c0e04a2','92632534-0cb6-4eb6-932d-871365b5aee0','b7679114-90ab-4351-a785-845ed7c2cefe','7184bfc7-7c2f-4b48-92dd-b2c315a43850','5db6eb5a-d0c1-4e78-8ffd-86f4b874dbb5','33af8c14-ff5d-4ed1-8495-8ae1f9cbe2ee','ea35b25d-b00d-44bc-ae6f-b7885d60be72','37bcb0a2-9259-4435-bdf7-4287f8176e73','16e16bed-eb53-4497-8a86-09fc3eb06948','1deae1ed-6180-4f7f-afcb-98f13deaf4cc','5d300a68-67b6-4d10-a758-54cd3124b716','128a4aa0-41a8-42c3-9522-1371769e8d70','54095cdb-2e80-457a-9bec-d35d96cecb90','a87af00b-8afc-4c7d-86ca-fd9c199ea3c8','d7e89f70-8f56-405d-a7d9-44080dcf4265','6651fdcc-e1ce-48cf-aacc-1f14e4405a37','ea2ad6cc-57b0-4f34-b67b-dfc6e6347f09','b987347c-eddf-4994-a9f0-36b4471539ea','a294d57c-8c6f-410c-bf5c-506c551054cf','660170f6-2dba-49ef-a957-09753aee9dec','057eb873-5665-4c28-acaf-b14b77a70ccc','5b4a602b-faea-4993-8ddb-c504ec077c04','0517abf1-91fd-47b8-adf2-e566b403010e'} +local was_held=false +return {FixedUpdate=function() local held=rustic.key('ArrowUp').held;if held and not was_held then for _,id in ipairs(ids) do Effects.restart(id) end end;was_held=held end}