#include "action/ActionBlockRuntime.h" #include "action/ActionNotifyRegistry.h" #include "action/ActionPreview.h" #include "action/ActionVfxBlock.h" #include "action/ActionVfxDuration.h" #include "common/Capability.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "particles/ParticleEffect.h" #include "particles/ParticleEmitter.h" #include "particles/Particles.h" #include "filesystem/Filesystem.h" #include "scene/NodeDesc.h" #include "scene/Scene.h" #include "scene/SceneObject.h" #include #include #include #include using namespace eve::particles; namespace { eve::LogicalId logicalId(std::string_view value) { auto parsed = eve::LogicalId::parse(value); REQUIRE(parsed.has_value()); return std::move(*parsed); } class TestVfxAttachmentSource final : public eve::IAttachmentPointSource { public: eve::Result sampleAttachmentPoint( std::string_view name, eve::AttachmentPoint localOffset) const override { ++calls; if (name != "hand_r") return eve::Result::failure( eve::Diagnostic::error(eve::DiagnosticCode::NotFound, "missing test bone", "bone")); return eve::Result::success( {50.f + localOffset.x, 60.f + localOffset.y, 70.f + localOffset.z}); } mutable int calls = 0; }; int liveParticleCount() { int count = 0; auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [config] = *it; if (config->entity) count += config->entity->getCount(); } return count; } float firstPlaybackSpeed() { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [config] = *it; if (config->entity) return config->entity->getPlaybackSpeed(); } return 0.0f; } } // namespace TEST_CASE("particles.effectAsset.versionedMultiEmitterContract") { const char* json = R"({ "type": "eve.particle-effect", "version": 1, "parameters": { "intensity": 2.0 }, "emitters": [ { "name": "core", "offset": [10.0, 0.0], "rotation": 0.1, "parameters": { "intensity": 3.0 }, "emitter": { "buffer": 32, "direction": 0.25, "layer": 3, "emissionRate": 0.0, "parameterBindings": [ { "parameter": "intensity", "target": "spawnRate", "scale": 0.5 } ] } }, { "name": "haze", "enabled": false, "offset": [-4.0, 6.0], "emitter": { "buffer": 8, "emissionRate": 0.0, "layer": -2 } } ] })"; auto* particles = Particles::create(); const int before = particles->getEmitterCount(); auto* effect = particles->newEffectFromText(json); REQUIRE(effect != nullptr); CHECK(particles->getLastEffectError().empty()); CHECK_EQ(effect->getVersion(), 1); CHECK_EQ(effect->getEmitterCount(), 2); CHECK_EQ(effect->getEmitterName(0), std::string("core")); CHECK_EQ(effect->getEmitterName(1), std::string("haze")); CHECK(effect->getEmitter(2) == nullptr); auto* core = effect->getEmitterByName("core"); auto* haze = effect->getEmitterByName("haze"); REQUIRE(core != nullptr); REQUIRE(haze != nullptr); CHECK_EQ(core->getBufferSize(), 32); CHECK_EQ(particles->getEmitterCount(), before + 2); effect->setPosition(100.f, 200.f); effect->setScale(2.f); effect->setRotation(1.57079632679f); CHECK(std::abs(core->getX() - 100.f) < 0.001f); CHECK(std::abs(core->getY() - 220.f) < 0.001f); CHECK(std::abs(core->getDirection() - 1.9207963f) < 0.001f); CHECK(std::abs(haze->getX() - 88.f) < 0.001f); CHECK(std::abs(haze->getY() - 192.f) < 0.001f); effect->setLayer(10); CHECK_EQ(core->getLayer(), 13); CHECK_EQ(haze->getLayer(), 8); effect->setVisible(false); CHECK(!core->isVisible()); CHECK(!haze->isVisible()); effect->setVisible(true); CHECK(core->isVisible()); CHECK(!haze->isVisible()); CHECK(effect->hasFloatParameter("intensity")); CHECK(std::abs(effect->getFloatParameter("intensity") - 2.f) < 0.001f); CHECK(std::abs(core->getFloatParameter("intensity") - 3.f) < 0.001f); effect->setFloatParameter("intensity", 4.f); CHECK(std::abs(core->getFloatParameter("intensity") - 4.f) < 0.001f); CHECK(std::abs(haze->getFloatParameter("intensity") - 4.f) < 0.001f); CHECK(std::abs(core->getResolvedParameterScale("spawnRate") - 2.f) < 0.001f); effect->start(); CHECK(core->isActive()); CHECK(!haze->isActive()); CHECK(effect->emit("core", 3)); CHECK(!effect->emit("missing", 3)); effect->pause(); CHECK(core->isPaused()); effect->stop(); CHECK(core->isStopped()); delete effect; CHECK_EQ(particles->getEmitterCount(), before); } TEST_CASE("particles.effectAsset.rejectsUnknownVersionAndDuplicateNames") { auto* particles = Particles::create(); auto* unsupported = particles->newEffectFromText( R"({"type":"eve.particle-effect","version":2,"emitters":[{"name":"a","emitter":{}}]})"); CHECK(unsupported == nullptr); CHECK(particles->getLastEffectError().find("unsupported") != std::string::npos); auto* duplicate = particles->newEffectFromText(R"({ "type":"eve.particle-effect", "version":1, "emitters":[{"name":"a","emitter":{}},{"name":"a","emitter":{}}] })"); CHECK(duplicate == nullptr); CHECK(particles->getLastEffectError().find("duplicate") != std::string::npos); } TEST_CASE("particles.effectAsset.playbackLabAssetIsConsumable") { const std::string path = std::string(EVENGINE_SOURCE_DIR) + "/examples/particle-playback-lab/impact.effect.json"; std::ifstream input(path, std::ios::binary); REQUIRE(input.good()); std::ostringstream text; text << input.rdbuf(); std::string error; auto* effect = ParticleEffect::fromText(text.str(), path, &error); REQUIRE(effect != nullptr); CHECK(error.empty()); CHECK_EQ(effect->getSourcePath(), path); CHECK_EQ(effect->getEmitterCount(), 2); CHECK(effect->getEmitterByName("core") != nullptr); CHECK(effect->getEmitterByName("sparks") != nullptr); CHECK(effect->hasFloatParameter("intensity")); auto duration = effect->naturalDuration(); REQUIRE(duration.ok()); CHECK(std::abs(duration.value().seconds() - 1.8) < 0.001); delete effect; auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); (void)Particles::create(); auto* provider = eve::cap::query(); REQUIRE(provider != nullptr); auto providedDuration = provider->naturalDuration("examples/particle-playback-lab/impact.effect.json"); REQUIRE(providedDuration.ok()); CHECK(std::abs(providedDuration.value().seconds() - 1.8) < 0.001); } TEST_CASE("particles.effectAsset.naturalDurationUsesFiniteEmissionAndRejectsUnboundedAssets") { std::string error; auto* finite = ParticleEffect::fromText(R"({ "type":"eve.particle-effect", "version":1, "emitters":[ {"name":"short", "emitter":{"emitterLife":0.3,"particleLifetime":[0.2,1.2]}}, {"name":"long", "emitter":{"emitterLife":0.8,"particleLifetime":[0.2,0.9]}} ] })", "", &error); REQUIRE(finite != nullptr); auto finiteDuration = finite->naturalDuration(); REQUIRE(finiteDuration.ok()); CHECK(std::abs(finiteDuration.value().seconds() - 1.7) < 0.001); delete finite; auto* unbounded = ParticleEffect::fromText(R"({ "type":"eve.particle-effect", "version":1, "emitters":[{"name":"loop", "emitter":{"emitterLife":-1,"looping":true}}] })", "", &error); REQUIRE(unbounded != nullptr); auto unboundedDuration = unbounded->naturalDuration(); CHECK(!unboundedDuration.ok()); REQUIRE(unboundedDuration.error() != nullptr); CHECK_EQ(unboundedDuration.error()->code(), eve::DiagnosticCode::Unsupported); delete unbounded; } TEST_CASE("particles.actionBlockProviderOwnsRealVfxEnterUpdateExit") { auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto* particles = Particles::create(); auto* scene = eve::scene::Scene::create(); auto mounted = scene->mountAs("action-vfx", eve::scene::node("source").withPosition(5.f, 6.f, 7.f)); REQUIRE(mounted.ok()); auto source = eve::scene::SceneObject::createObject("action-vfx", "source"); REQUIRE(source.ok()); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); const int before = particles->getEmitterCount(); eve::action::ActionAdvance advance; advance.id = eve::action::ActionExecutionId{91}; advance.totalElapsed = eve::Duration::fromNanoseconds(10); eve::Value::Object payload{ {"uri", "examples/particle-playback-lab/impact.effect.json"}, {"stopBehavior", "clear_immediately"}, {"clipStartTime", 0.2}, {"clipEndTime", 1.2}, {"bone", "hand_r"}, {"positionOffset", eve::Value::Array{12.0, 34.0, 0.0}}, {"rotationOffsetDegrees", eve::Value::Array{0.0, 0.0, 90.0}}, {"scale", eve::Value::Array{2.0, 2.0, 2.0}}, }; advance.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateEnter, logicalId("presentation-track:vfx"), logicalId("presentation-vfx:impact"), logicalId("presentation:vfx-state"), advance.totalElapsed, payload}); advance.activeBlocks.push_back({logicalId("presentation-track:vfx"), logicalId("presentation-vfx:impact"), logicalId("presentation:vfx-state"), eve::Duration::zero(), eve::Duration::fromNanoseconds(1'000'000'000), payload}); eve::action::ActionNotifyContext context; context.executionId = advance.id; context.playbackRate = 2.0; context.source = ecs::handle_of(source.value()); TestVfxAttachmentSource attachmentSource; context.sourceAttachment = std::cref(attachmentSource); auto applied = runtime.apply(advance, context); REQUIRE(applied.ok()); CHECK_EQ(attachmentSource.calls, 2); CHECK_EQ(particles->getEmitterCount(), before + 2); CHECK(std::abs(firstPlaybackSpeed() - 2.0f) < 0.001f); context.time = eve::Duration::fromNanoseconds(20); REQUIRE(runtime.interrupt(context).ok()); CHECK_EQ(particles->getEmitterCount(), before); } TEST_CASE("particles.actionVfxStopEmittingPreservesThenReapsResiduals") { auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto* particles = Particles::create(); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); const int before = particles->getEmitterCount(); eve::action::ActionAdvance enter; enter.id = eve::action::ActionExecutionId{95}; enter.totalElapsed = eve::Duration::fromNanoseconds(100'000'000); eve::Value::Object payload{{"uri", "examples/particle-playback-lab/impact.effect.json"}, {"stopBehavior", "stop_emitting"}}; enter.timelineEvents.push_back({eve::action::ActionTimelineEventKind::StateEnter, logicalId("presentation-track:vfx"), logicalId("presentation-vfx:residual"), logicalId("presentation:vfx-state"), enter.totalElapsed, payload}); enter.activeBlocks.push_back({logicalId("presentation-track:vfx"), logicalId("presentation-vfx:residual"), logicalId("presentation:vfx-state"), enter.totalElapsed, eve::Duration::fromNanoseconds(2'000'000'000), payload}); eve::action::ActionNotifyContext context; context.executionId = enter.id; REQUIRE(runtime.apply(enter, context).ok()); particles->update(0.1f); CHECK(liveParticleCount() > 0); context.time = eve::Duration::fromNanoseconds(200'000'000); REQUIRE(runtime.interrupt(context).ok()); CHECK_EQ(particles->getEmitterCount(), before + 2); for (int i = 0; i < 100; ++i) particles->update(0.1f); CHECK_EQ(liveParticleCount(), 0); eve::action::ActionAdvance reap; reap.id = enter.id; reap.totalElapsed = eve::Duration::fromNanoseconds(4'000'000'000); REQUIRE(runtime.apply(reap, context).ok()); CHECK_EQ(particles->getEmitterCount(), before); } TEST_CASE("particles.instantActionNotifyRetainsThenDeterministicallyReleasesEffect") { auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto* particles = Particles::create(); auto registry = eve::action::ActionNotifyRegistry::withBuiltins(); REQUIRE(registry.ok()); eve::action::ActionBlockRuntime runtime(registry.value()); const int before = particles->getEmitterCount(); eve::action::ActionAdvance trigger; trigger.id = eve::action::ActionExecutionId{94}; trigger.timelineEvents.push_back({eve::action::ActionTimelineEventKind::Notify, logicalId("presentation-track:vfx"), logicalId("presentation-vfx:hit"), logicalId("presentation:vfx"), eve::Duration::zero(), {{"uri", "examples/particle-playback-lab/impact.effect.json"}, {"lifetimeSeconds", 1.5}}}); eve::action::ActionNotifyContext context; context.executionId = trigger.id; REQUIRE(runtime.apply(trigger, context).ok()); CHECK_EQ(particles->getEmitterCount(), before + 2); eve::action::ActionAdvance expired; expired.id = trigger.id; expired.totalElapsed = eve::Duration::fromNanoseconds(2'000'000'000LL); REQUIRE(runtime.apply(expired, context).ok()); CHECK_EQ(particles->getEmitterCount(), before); } TEST_CASE("particles.actionPreviewRetainsScrubsRebuildsAndCleansVfx") { auto* filesystem = eve::filesystem::Filesystem::create(); REQUIRE(filesystem->mountRealDirectory(EVENGINE_SOURCE_DIR, "/", false)); auto* particles = Particles::create(); REQUIRE_EQ(eve::cap::listenerCount(), 1u); auto* provider = eve::cap::listenerAt(0); REQUIRE(provider != nullptr); auto sink = provider->createActionPreviewSink(); REQUIRE(sink.ok()); const int before = particles->getEmitterCount(); eve::Value::Object payload{{"uri", "examples/particle-playback-lab/impact.effect.json"}, {"clipStartTime", 0.2}, {"clipEndTime", 1.2}, {"stopBehavior", "clear_immediately"}}; eve::action::ActionPreviewFrame frame; frame.reason = eve::action::ActionPreviewReason::Seek; frame.current = eve::Duration::fromNanoseconds(500'000'000); frame.activeBlocks.push_back({logicalId("presentation-track:vfx"), logicalId("presentation-vfx:preview"), logicalId("presentation:vfx-state"), eve::Duration::fromNanoseconds(500'000'000), eve::Duration::fromNanoseconds(1'000'000'000), payload}); REQUIRE(sink.value()->prepare(frame).ok()); CHECK_EQ(particles->getEmitterCount(), before + 2); sink.value()->present(frame); const int seekParticles = liveParticleCount(); CHECK(seekParticles > 0); frame.reason = eve::action::ActionPreviewReason::Advance; frame.current = eve::Duration::fromNanoseconds(750'000'000); frame.activeBlocks.front().localTime = frame.current; REQUIRE(sink.value()->prepare(frame).ok()); CHECK_EQ(particles->getEmitterCount(), before + 2); sink.value()->present(frame); const int advancedParticles = liveParticleCount(); CHECK(advancedParticles > 0); frame.reason = eve::action::ActionPreviewReason::Refresh; REQUIRE(sink.value()->prepare(frame).ok()); CHECK_EQ(particles->getEmitterCount(), before + 4); sink.value()->present(frame); CHECK_EQ(particles->getEmitterCount(), before + 2); CHECK_EQ(liveParticleCount(), advancedParticles); auto invalid = frame; invalid.activeBlocks.front().payload["clipEndTime"] = 0.1; CHECK(!sink.value()->prepare(invalid).ok()); CHECK_EQ(particles->getEmitterCount(), before + 2); eve::action::ActionPreviewFrame empty; empty.reason = eve::action::ActionPreviewReason::Seek; empty.current = eve::Duration::fromNanoseconds(800'000'000); REQUIRE(sink.value()->prepare(empty).ok()); sink.value()->present(empty); CHECK_EQ(particles->getEmitterCount(), before); eve::action::ActionPreviewCue instant; instant.kind = eve::action::ActionPreviewCueKind::Vfx; instant.itemId = logicalId("presentation-vfx:instant-preview"); instant.type = logicalId("presentation:vfx"); instant.time = eve::Duration::fromNanoseconds(800'000'000); instant.payload = {{"uri", "examples/particle-playback-lab/impact.effect.json"}, {"lifetimeSeconds", 1.0}}; empty.cues.push_back(std::move(instant)); REQUIRE(sink.value()->prepare(empty).ok()); sink.value()->present(empty); CHECK_EQ(particles->getEmitterCount(), before + 2); empty.reason = eve::action::ActionPreviewReason::Advance; empty.current = eve::Duration::fromNanoseconds(2'000'000'000); empty.cues.clear(); REQUIRE(sink.value()->prepare(empty).ok()); sink.value()->present(empty); CHECK_EQ(particles->getEmitterCount(), before); sink.value().reset(); CHECK_EQ(particles->getEmitterCount(), before); }