Files
EVEngine/test/particles_effect_asset.cpp

427 lines
18 KiB
C++

#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 <cmath>
#include <fstream>
#include <sstream>
#include <string>
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<eve::AttachmentPoint> sampleAttachmentPoint(
std::string_view name, eve::AttachmentPoint localOffset) const override {
++calls;
if (name != "hand_r")
return eve::Result<eve::AttachmentPoint>::failure(
eve::Diagnostic::error(eve::DiagnosticCode::NotFound, "missing test bone", "bone"));
return eve::Result<eve::AttachmentPoint>::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<ParticleEmitter, ParticleEmitter::Config>();
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<ParticleEmitter, ParticleEmitter::Config>();
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<eve::action::IActionVfxDurationProvider>();
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<eve::action::IActionPreviewSinkProvider>(), 1u);
auto* provider = eve::cap::listenerAt<eve::action::IActionPreviewSinkProvider>(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);
}