Repeatedly closing/reopening a window and re-initializing the render system (single-process unit_test run) crashed because: - The Vulkan surface/swapchain stayed bound to the destroyed native window. SDL can hand back the same pointer for a recreated window, so pointer identity could not detect the stale surface; present() then hit getSurfaceCapabilitiesKHR: ErrorUnknown. - The ImGui context and Vulkan present-overlay survived the window, so a later present() drew into an unrelated window via the stale overlay. - headlessImGuiWalk destroyed the ImGui context while the UI backend's real context was still current (CreateContext only auto-switches current when GImGui is NULL), leaving a dangling context for later present() calls. Fix: - Window::close() calls Graphics::onNativeWindowDestroyed(); the Vulkan backend drops the surface/swapchain and fires registered window-destroyed callbacks (UI backend tears down its ImGui context here). - initWithWindow() rebuilds the swapchain synchronously and no longer relies on pointer identity to detect a stale surface. - ImGuiBackend owns its ImGui context explicitly and shuts it down via the window-destroyed hook; shutdown() switches to its own context first. - headlessImGuiWalk switches to and from its private ImGui context. Test-only fixes exposed once the suite can run to completion: - animation_skinned: Animation::create() returns the process-wide singleton; do not wrap it in unique_ptr (delete left a dangling ModuleManager pointer). - particles attach tests: emitters live in a process-wide ECS; detach (and clear skin sources) before local skeletons are destroyed.
624 lines
21 KiB
C++
624 lines
21 KiB
C++
#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 <cmath>
|
||
#include <memory>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
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<SpineSkeletonData> data(animMod->newSpineSkeletonDataFromJson(kSpineJson));
|
||
REQUIRE(data.get() != nullptr);
|
||
std::unique_ptr<SpineSkeleton> 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<SpineSkeletonData> data(animMod->newSpineSkeletonDataFromJson(kSpineJson));
|
||
std::unique_ptr<SpineSkeleton> spine(animMod->newSpineSkeleton(data.get()));
|
||
std::unique_ptr<SpineAnim> 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<SpineSkeletonData> data(animMod->newSpineSkeletonDataFromJson(kSpineJson));
|
||
std::unique_ptr<SpineSkeleton> 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<Skeleton2D> 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<Skeleton2D> 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<Solver2D> 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<Skeleton3D> 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<Skeleton2D> sk2(new Skeleton2D());
|
||
sk2->initStraightPose(1.f, 2.f);
|
||
|
||
std::unique_ptr<Skeleton3D> 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<Skeleton2D> 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<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("root", -1);
|
||
std::unique_ptr<AnimPose> 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<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("root", -1);
|
||
std::unique_ptr<AnimPose> 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<Skeleton2D> 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<Skeleton2D> 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<float> 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<Skeleton2D> 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<Skeleton3D> 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<Skeleton3D> 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<Solver3D> 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<SpineSkeletonData> data(animMod->newSpineSkeletonDataFromJson(kSpineJson));
|
||
std::unique_ptr<SpineSkeleton> 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<Skeleton2D> 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<Skeleton2D> 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<SpineSkeletonData> data(animMod->newSpineSkeletonDataFromJson(kSpineJson));
|
||
std::unique_ptr<SpineSkeleton> spine(animMod->newSpineSkeleton(data.get()));
|
||
spine->setToSetupPose();
|
||
spine->updateWorldTransform();
|
||
|
||
std::unique_ptr<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("root", -1);
|
||
std::unique_ptr<AnimPose> 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<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("root", -1);
|
||
std::unique_ptr<AnimPose> 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<Skeleton2D> 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();
|
||
}
|