#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "particles/Particles.h" #include "particles/ParticleEmitter.h" #include "particles/ParticleSystem.h" #include "animation/Animation.h" #include "animation/SpineSkeleton.h" #include "animation/SpineSkeletonData.h" #include "animation/SpineAnim.h" #include "ik/Skeleton2D.h" #include "ik/Skeleton3D.h" #include "ik/Solver2D.h" #include "ik/Solver3D.h" #include "animation/AnimPose.h" #include "animation/AnimSkeleton.h" #include #include #include #include using namespace eve::particles; using namespace eve::animation; using namespace eve::ik; #ifndef M_PI #define M_PI 3.14159265358979323846 #endif namespace { const char *kSpineJson = R"JSON({ "skeleton": { "spine": "4.1.00" }, "bones": [ { "name": "root" }, { "name": "body", "parent": "root", "y": 40 }, { "name": "hand", "parent": "body", "x": 30, "y": 10 } ], "slots": [ { "name": "body", "bone": "body", "attachment": "body" } ], "skins": [ { "name": "default", "attachments": { "body": { "body": { "width": 20, "height": 40 } } }} ], "animations": { "wave": { "bones": { "hand": { "translate": [ { "time": 0, "x": 0, "y": 0 }, { "time": 0.5, "x": 20, "y": 0 }, { "time": 1.0, "x": 0, "y": 0 } ] } } } } })JSON"; } // namespace TEST_CASE("particles.attach.spineBoneFollowsAndEmits") { auto *animMod = Animation::create(); std::unique_ptr data(animMod->newSpineSkeletonDataFromJson(kSpineJson)); REQUIRE(data.get() != nullptr); std::unique_ptr spine(animMod->newSpineSkeleton(data.get())); REQUIRE(spine.get() != nullptr); spine->setToSetupPose(); spine->updateWorldTransform(); const int hand = data->findBone("hand"); REQUIRE(hand >= 0); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(32); e->setParticleLifetime(2.f, 2.f); e->setSpeed(0.f, 0.f); e->setAttachScale(1.f); e->attachToSpineBoneByName(spine.get(), "hand"); CHECK(e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("spine")); CHECK_EQ(e->getAttachBone(), hand); CHECK(std::fabs(e->getX() - spine->getBoneWorldX(hand)) < 1e-3f); CHECK(std::fabs(e->getY() - spine->getBoneWorldY(hand)) < 1e-3f); e->setAttachOffset(5.f, 0.f, 0.f); e->syncAttach(); // Offset in bone local +X, transformed by world matrix. float a, b, c, d; spine->getBoneWorldMatrix(hand, a, b, c, d); const float expectX = spine->getBoneWorldX(hand) + a * 5.f; const float expectY = spine->getBoneWorldY(hand) + c * 5.f; CHECK(std::fabs(e->getX() - expectX) < 1e-3f); CHECK(std::fabs(e->getY() - expectY) < 1e-3f); e->setEmissionRate(100.f); e->start(); ParticleSimSystem::update(0.05f); CHECK_GE(e->getCount(), 4); // Missing bone name disables attach. e->attachToSpineBoneByName(spine.get(), "Missing"); CHECK(!e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("none")); e->detach(); } TEST_CASE("particles.attach.spineDynamicEmitAcrossAnim") { auto *animMod = Animation::create(); std::unique_ptr data(animMod->newSpineSkeletonDataFromJson(kSpineJson)); std::unique_ptr spine(animMod->newSpineSkeleton(data.get())); std::unique_ptr anim(animMod->newSpineAnim(spine.get())); REQUIRE(anim.get() != nullptr); const int hand = data->findBone("hand"); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(64); e->setParticleLifetime(5.f, 5.f); e->setSpeed(0.f, 0.f); e->setEmissionRate(0.f); e->attachToSpineBone(spine.get(), hand); float prevX = -1e9f; int moved = 0; CHECK(anim->play("wave")); for (int i = 0; i < 6; ++i) { const float t = float(i) * 0.1f; anim->setTime(t); anim->apply(); e->emit(1); ParticleSimSystem::update(0.016f); const float x = e->sim()->particles[size_t(e->getCount() - 1)].x; if (i > 0 && std::fabs(x - prevX) > 0.5f) ++moved; prevX = x; } CHECK_EQ(e->getCount(), 6); CHECK_GE(moved, 1); // hand translate keyframes should move spawn X e->detach(); } TEST_CASE("particles.attach.spineFollowRotation") { auto *animMod = Animation::create(); std::unique_ptr data(animMod->newSpineSkeletonDataFromJson(kSpineJson)); std::unique_ptr spine(animMod->newSpineSkeleton(data.get())); const int body = data->findBone("body"); spine->setToSetupPose(); spine->setBoneLocalRotation(body, 90.f); // degrees spine->updateWorldTransform(); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->setFollowBoneRotation(true); e->attachToSpineBone(spine.get(), body); CHECK(std::fabs(e->getDirection() - float(M_PI * 0.5)) < 0.05f); e->detach(); } TEST_CASE("particles.attach.ik2dBoneEmit") { std::unique_ptr sk(new Skeleton2D()); int b1 = sk->createBone(0, 50.f); int tip = sk->createBone(b1, 50.f); sk->setPosition(0, 100.f, 200.f); sk->setRotation(0, 0.f); sk->setRotation(b1, 0.f); sk->setRotation(tip, float(M_PI / 2.0)); sk->forwardKinematics(); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(16); e->setParticleLifetime(2.f, 2.f); e->setSpeed(0.f, 0.f); e->setAttachScale(1.f); e->attachToSkeleton2D(sk.get(), tip); CHECK(e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("ik2d")); CHECK(std::fabs(e->getX() - sk->getX(tip)) < 1e-2f); CHECK(std::fabs(e->getY() - sk->getY(tip)) < 1e-2f); e->setEmissionRate(80.f); e->start(); ParticleSimSystem::update(0.05f); CHECK_GE(e->getCount(), 3); auto sim = e->sim(); for (int i = 0; i < sim->alive; ++i) { CHECK(std::fabs(sim->particles[size_t(i)].x - sk->getX(tip)) < 1e-1f); CHECK(std::fabs(sim->particles[size_t(i)].y - sk->getY(tip)) < 1e-1f); } // Move chain and verify emitter follows via ParticleSimSystem. sk->setPosition(0, 150.f, 200.f); sk->forwardKinematics(); ParticleSimSystem::update(0.016f); CHECK(std::fabs(e->getX() - sk->getX(tip)) < 1e-2f); e->detach(); } TEST_CASE("particles.attach.ik2dFollowRotationAndSolver") { std::unique_ptr sk(new Skeleton2D()); int b1 = sk->createBone(0, 40.f); int tip = sk->createBone(b1, 40.f); sk->initStraightPose(0.f, 0.f); std::unique_ptr solver(new Solver2D()); solver->setMaxIterations(32); solver->addTarget(tip, 40.f, 40.f, 1.f); CHECK(solver->solve(sk.get())); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(8); e->setFollowBoneRotation(true); e->attachToSkeleton2D(sk.get(), tip); CHECK_EQ(e->getAttachKind(), std::string("ik2d")); CHECK(std::fabs(e->getX() - sk->getX(tip)) < 1.f); CHECK(std::fabs(e->getY() - sk->getY(tip)) < 1.f); // Direction should align with bone forward. const float fx = sk->getOrientationX(tip); const float fy = sk->getOrientationY(tip); const float expect = std::atan2(fy, fx); CHECK(std::fabs(e->getDirection() - expect) < 0.1f); e->detach(); } TEST_CASE("particles.attach.ik3dBoneEmitProjected") { std::unique_ptr sk(new Skeleton3D()); int b1 = sk->createBone(0, 1.f); int tip = sk->createBone(b1, 1.f); sk->initStraightPose(10.f, 20.f, 5.f); CHECK(std::fabs(sk->getX(tip) - 12.f) < 1e-3f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(16); e->setParticleLifetime(1.f, 1.f); e->setSpeed(0.f, 0.f); e->setAttachPlane("xy"); e->setAttachScale(2.f); e->attachToSkeleton3D(sk.get(), tip); CHECK(e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("ik3d")); // tip (12,20,5) × scale 2 on xy → (24, 40) CHECK(std::fabs(e->getX() - 24.f) < 1e-2f); CHECK(std::fabs(e->getY() - 40.f) < 1e-2f); e->setAttachPlane("xz"); e->syncAttach(); // (12,20,5) on xz ×2 → (24, 10) CHECK(std::fabs(e->getX() - 24.f) < 1e-2f); CHECK(std::fabs(e->getY() - 10.f) < 1e-2f); e->setEmissionRate(60.f); e->start(); ParticleSimSystem::update(0.05f); CHECK_GE(e->getCount(), 2); e->detach(); } TEST_CASE("particles.attach.switchKindsClearsPrevious") { std::unique_ptr sk2(new Skeleton2D()); sk2->initStraightPose(1.f, 2.f); std::unique_ptr sk3(new Skeleton3D()); sk3->initStraightPose(3.f, 4.f, 5.f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->attachToSkeleton2D(sk2.get(), 0); CHECK_EQ(e->getAttachKind(), std::string("ik2d")); e->attachToSkeleton3D(sk3.get(), 0); CHECK_EQ(e->getAttachKind(), std::string("ik3d")); CHECK(e->attach()->ik2d == nullptr); CHECK(e->attach()->ik3d == sk3.get()); e->detach(); } TEST_CASE("particles.attach.nullAndOutOfRangeDisable") { auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->attachToBone(nullptr, 0); CHECK(!e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("none")); e->attachToSpineBone(nullptr, 0); CHECK(!e->isAttached()); e->attachToSkeleton2D(nullptr, 0); CHECK(!e->isAttached()); e->attachToSkeleton3D(nullptr, 0); CHECK(!e->isAttached()); std::unique_ptr sk2(new Skeleton2D()); sk2->initStraightPose(0.f, 0.f); e->attachToSkeleton2D(sk2.get(), 99); // only root bone exists CHECK(!e->isAttached()); CHECK_EQ(e->getAttachKind(), std::string("none")); std::unique_ptr sk(new AnimSkeleton()); const int root = sk->addBone("root", -1); std::unique_ptr pose(new AnimPose()); sk->applyBindPose(pose.get()); pose->computeWorld(sk.get()); e->attachToBone(pose.get(), root); CHECK(e->isAttached()); e->attachToBone(pose.get(), -1); CHECK(!e->isAttached()); } TEST_CASE("particles.attach.invalidPlaneFallsBackToXy") { std::unique_ptr sk(new AnimSkeleton()); const int root = sk->addBone("root", -1); std::unique_ptr pose(new AnimPose()); sk->applyBindPose(pose.get()); pose->setLocalPosition(root, 3.f, 4.f, 5.f); pose->computeWorld(sk.get()); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->setAttachPlane("nope"); e->setAttachScale(1.f); e->attachToBone(pose.get(), root); CHECK_EQ(e->attach()->plane, std::string("xy")); CHECK(std::fabs(e->getX() - 3.f) < 1e-3f); CHECK(std::fabs(e->getY() - 4.f) < 1e-3f); e->detach(); } TEST_CASE("particles.attach.multiEmittersOnSameSkeleton") { std::unique_ptr sk(new Skeleton2D()); int mid = sk->createBone(0, 30.f); int tip = sk->createBone(mid, 30.f); sk->setPosition(0, 10.f, 20.f); sk->setRotation(0, 0.f); sk->setRotation(mid, 0.f); sk->setRotation(tip, 0.f); sk->forwardKinematics(); auto *mod = Particles::create(); ParticleEmitter *rootFx = mod->newEmitter(8); ParticleEmitter *tipFx = mod->newEmitter(8); rootFx->setParticleLifetime(2.f, 2.f); tipFx->setParticleLifetime(2.f, 2.f); rootFx->setSpeed(0.f, 0.f); tipFx->setSpeed(0.f, 0.f); rootFx->attachToSkeleton2D(sk.get(), 0); tipFx->attachToSkeleton2D(sk.get(), tip); CHECK(std::fabs(rootFx->getX() - sk->getX(0)) < 1e-2f); CHECK(std::fabs(tipFx->getX() - sk->getX(tip)) < 1e-2f); CHECK(std::fabs(tipFx->getX() - rootFx->getX()) > 1.f); rootFx->setEmissionRate(40.f); tipFx->setEmissionRate(40.f); rootFx->start(); tipFx->start(); ParticleSimSystem::update(0.1f); CHECK_GE(rootFx->getCount(), 3); CHECK_GE(tipFx->getCount(), 3); // Spawn clusters stay near their respective bones. CHECK(std::fabs(rootFx->sim()->particles[0].x - sk->getX(0)) < 0.5f); CHECK(std::fabs(tipFx->sim()->particles[0].x - sk->getX(tip)) < 0.5f); rootFx->detach(); tipFx->detach(); } TEST_CASE("particles.attach.rateEmitTracksMovingIk2d") { std::unique_ptr sk(new Skeleton2D()); int tip = sk->createBone(0, 1.f); sk->initStraightPose(0.f, 0.f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(128); e->setParticleLifetime(10.f, 10.f); e->setSpeed(0.f, 0.f); e->setEmissionRate(100.f); // ~1 particle per 0.01s e->attachToSkeleton2D(sk.get(), tip); e->start(); std::vector xs; for (int step = 0; step < 5; ++step) { sk->setPosition(0, float(step) * 10.f, 0.f); sk->forwardKinematics(); const int before = e->getCount(); ParticleSimSystem::update(0.02f); // ~2 particles CHECK_GT(e->getCount(), before); xs.push_back(e->sim()->particles[size_t(e->getCount() - 1)].x); } // Tip world x ≈ root.x + length; root moves 0,10,20,30,40 → tip ~1,11,21,31,41 CHECK(xs.back() - xs.front() > 20.f); e->detach(); } TEST_CASE("particles.attach.ik2dOffsetAlongOrientation") { std::unique_ptr sk(new Skeleton2D()); int tip = sk->createBone(0, 10.f); sk->setPosition(0, 0.f, 0.f); sk->setRotation(0, 0.f); sk->setRotation(tip, 0.f); sk->forwardKinematics(); // tip at (10, 0), orientation +X auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->setAttachOffset(5.f, 0.f, 0.f); // further along bone forward e->attachToSkeleton2D(sk.get(), tip); CHECK(std::fabs(e->getX() - 15.f) < 1e-2f); CHECK(std::fabs(e->getY() - 0.f) < 1e-2f); e->setAttachOffset(0.f, 3.f, 0.f); // perpendicular (+Y when facing +X) e->syncAttach(); CHECK(std::fabs(e->getX() - 10.f) < 1e-2f); CHECK(std::fabs(e->getY() - 3.f) < 1e-2f); e->detach(); } TEST_CASE("particles.attach.ik3dYzPlaneAndFollowRotation") { std::unique_ptr sk(new Skeleton3D()); int tip = sk->createBone(0, 2.f); sk->initStraightPose(0.f, 1.f, 3.f); // tip at (2, 1, 3); default orientation along +X auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(8); e->setAttachPlane("yz"); e->setAttachScale(1.f); e->setFollowBoneRotation(true); e->attachToSkeleton3D(sk.get(), tip); // yz of (2,1,3) → (1, 3) CHECK(std::fabs(e->getX() - 1.f) < 1e-2f); CHECK(std::fabs(e->getY() - 3.f) < 1e-2f); const float fx = sk->getOrientationX(tip); const float fy = sk->getOrientationY(tip); const float fz = sk->getOrientationZ(tip); // Project orientation onto yz → (fy, fz) const float expect = std::atan2(fz, fy); if (fy * fy + fz * fz > 1e-6f) CHECK(std::fabs(e->getDirection() - expect) < 0.15f); else (void)fx; // orientation collapsed on yz — direction unchanged is fine e->detach(); } TEST_CASE("particles.attach.ik3dSolverMovesEmitter") { std::unique_ptr sk(new Skeleton3D()); int b1 = sk->createBone(0, 1.f); int tip = sk->createBone(b1, 1.f); sk->initStraightPose(0.f, 0.f, 0.f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(8); e->setAttachPlane("xy"); e->setAttachScale(1.f); e->attachToSkeleton3D(sk.get(), tip); const float x0 = e->getX(); const float y0 = e->getY(); std::unique_ptr solver(new Solver3D()); solver->setMaxIterations(48); solver->addTarget(tip, 1.f, 1.f, 0.f, 1.f); CHECK(solver->solve(sk.get())); ParticleSimSystem::update(0.016f); CHECK(std::fabs(e->getX() - sk->getX(tip)) < 5e-2f); CHECK(std::fabs(e->getY() - sk->getY(tip)) < 5e-2f); CHECK(std::fabs(e->getX() - x0) + std::fabs(e->getY() - y0) > 0.2f); e->detach(); } TEST_CASE("particles.attach.spineScaleAndModuleUpdate") { auto *animMod = Animation::create(); std::unique_ptr data(animMod->newSpineSkeletonDataFromJson(kSpineJson)); std::unique_ptr spine(animMod->newSpineSkeleton(data.get())); spine->setToSetupPose(); spine->updateWorldTransform(); const int hand = data->findBone("hand"); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(16); e->setParticleLifetime(2.f, 2.f); e->setSpeed(0.f, 0.f); e->setAttachScale(2.f); e->attachToSpineBone(spine.get(), hand); CHECK(std::fabs(e->getX() - spine->getBoneWorldX(hand) * 2.f) < 1e-2f); CHECK(std::fabs(e->getY() - spine->getBoneWorldY(hand) * 2.f) < 1e-2f); e->setEmissionRate(50.f); e->start(); mod->update(0.1f); // module path must sync attach + sim CHECK_GE(e->getCount(), 4); CHECK(std::fabs(e->sim()->particles[0].x - e->getX()) < 1e-2f); e->detach(); } TEST_CASE("particles.attach.detachKeepsAliveParticles") { std::unique_ptr sk(new Skeleton2D()); sk->initStraightPose(50.f, 60.f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(32); e->setParticleLifetime(5.f, 5.f); e->setSpeed(0.f, 0.f); e->attachToSkeleton2D(sk.get(), 0); e->emit(5); CHECK_EQ(e->getCount(), 5); const float px = e->sim()->particles[0].x; const float py = e->sim()->particles[0].y; e->detach(); CHECK(!e->isAttached()); ParticleSimSystem::update(0.016f); CHECK_EQ(e->getCount(), 5); CHECK(std::fabs(e->sim()->particles[0].x - px) < 1e-3f); CHECK(std::fabs(e->sim()->particles[0].y - py) < 1e-3f); // After detach, new emits use last Config.x/y (still at old bone pos until moveTo). e->emit(1); CHECK_EQ(e->getCount(), 6); } TEST_CASE("particles.attach.pauseStopsSpawnButStillSyncs") { std::unique_ptr sk(new Skeleton2D()); sk->initStraightPose(0.f, 0.f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(32); e->setParticleLifetime(5.f, 5.f); e->setSpeed(0.f, 0.f); e->setEmissionRate(100.f); e->attachToSkeleton2D(sk.get(), 0); e->start(); ParticleSimSystem::update(0.05f); const int n = e->getCount(); CHECK_GE(n, 4); e->pause(); sk->setPosition(0, 100.f, 0.f); sk->forwardKinematics(); ParticleSimSystem::update(0.05f); CHECK_EQ(e->getCount(), n); // paused: no new particles CHECK(std::fabs(e->getX() - sk->getX(0)) < 1e-2f); // but attach still syncs e->detach(); } TEST_CASE("particles.attach.switchSpineToAnimClearsSpine") { auto *animMod = Animation::create(); std::unique_ptr data(animMod->newSpineSkeletonDataFromJson(kSpineJson)); std::unique_ptr spine(animMod->newSpineSkeleton(data.get())); spine->setToSetupPose(); spine->updateWorldTransform(); std::unique_ptr sk(new AnimSkeleton()); const int root = sk->addBone("root", -1); std::unique_ptr pose(new AnimPose()); sk->applyBindPose(pose.get()); pose->setLocalPosition(root, 7.f, 8.f, 0.f); pose->computeWorld(sk.get()); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->attachToSpineBoneByName(spine.get(), "hand"); CHECK_EQ(e->getAttachKind(), std::string("spine")); CHECK(e->attach()->spine != nullptr); e->attachToBone(pose.get(), root); CHECK_EQ(e->getAttachKind(), std::string("anim")); CHECK(e->attach()->spine == nullptr); CHECK(e->attach()->pose == pose.get()); CHECK(std::fabs(e->getX() - 7.f) < 1e-3f); CHECK(std::fabs(e->getY() - 8.f) < 1e-3f); e->detach(); } TEST_CASE("particles.attach.animOffsetRotatedByBone") { std::unique_ptr sk(new AnimSkeleton()); const int root = sk->addBone("root", -1); std::unique_ptr pose(new AnimPose()); sk->applyBindPose(pose.get()); // 90° about Z: local +X → world +Y const float half = 0.70710678f; pose->setLocalRotation(root, 0.f, 0.f, half, half); pose->setLocalPosition(root, 0.f, 0.f, 0.f); pose->computeWorld(sk.get()); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(4); e->setAttachOffset(2.f, 0.f, 0.f); e->setAttachPlane("xy"); e->setAttachScale(1.f); e->attachToBone(pose.get(), root); CHECK(std::fabs(e->getX() - 0.f) < 1e-2f); CHECK(std::fabs(e->getY() - 2.f) < 1e-2f); e->detach(); } TEST_CASE("particles.attach.skinTakesPriorityOverBoneOrigin") { // When SkinSource is enabled, continuous spawn samples skin, not attach origin. // Use a synthetic path: attach to a far bone, but without a real skin the // spawn falls back to Config — here we only assert attach sync still runs // and clearSkinSource restores bone-origin emission. std::unique_ptr sk(new Skeleton2D()); sk->initStraightPose(100.f, 200.f); auto *mod = Particles::create(); ParticleEmitter *e = mod->newEmitter(16); e->setParticleLifetime(2.f, 2.f); e->setSpeed(0.f, 0.f); e->attachToSkeleton2D(sk.get(), 0); CHECK(std::fabs(e->getX() - 100.f) < 1e-2f); e->emit(3); CHECK_EQ(e->getCount(), 3); for (int i = 0; i < 3; ++i) { CHECK(std::fabs(e->sim()->particles[size_t(i)].x - 100.f) < 1e-2f); CHECK(std::fabs(e->sim()->particles[size_t(i)].y - 200.f) < 1e-2f); } CHECK(!e->hasSkinSource()); e->detach(); }