Files
EVEngine/test/particles_dynamic_bones.cpp
T
西风逍遥游 bed4ce5cd2 fix(graphics): rebuild Vulkan surface/swapchain and ImGui context when native window is recreated
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.
2026-08-15 00:26:06 +08:00

624 lines
21 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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();
}