* chore(ci): add clang-format gate, CODEOWNERS and agent collaboration rules * refactor(modules): clear remaining back-edges and split the largest TUs Three known dependency back-edges are gone (module_depgraph.py KNOWN_BACK_EDGES is now empty; --check passes with 0 violations, 0 cycles): - window -> graphics: window queries the new IWindowSurfaceHost capability (common/WindowSurfaceHost.h) instead of holding a Graphics pointer; Window::setGraphics was removed and all call sites updated. - window -> image: Window::setIcon was replaced by setIconRGBA(const uint8_t*, w, h); the window no longer decodes ImageData. - scene -> graphics: pickScreenAt / collectFrustumIdsAt implementations moved to graphics/ScenePicking.cpp (graphics -> scene is a legal downward edge); scene now depends only on spatial. TU splits (pure moves, verified identical method sets and brace balance): - vulkan/Graphics.cpp (5118 lines) -> Graphics.cpp (lifecycle), GraphicsPipeline, Graphics2D, Graphics3D, GraphicsMesh + shared GraphicsInternal.h. - scene/Scene.cpp -> Scene.cpp (script layer / host lifecycle / serialization) + SceneNodes.cpp + shared SceneInternal.h. Verified with a real build (MSVC 19.51, prebuilt third-party): full build green; window.* and Scene.* suites pass, including scene picking/culling and GPU cases. * refactor(graphics): narrow Graphics interfaces and clear public-header leaks Step 4 — interface segregation: - New lean interfaces: IGraphics2D, IGraphics3D, IResourceFactory, IPostFX (pure virtuals, forward-declared types only; graphics/Color.h extracted from Canvas.h so modules that only need the color type stop pulling Canvas.h). - Graphics now implements all four facets; consumers can depend on the narrow interface they use instead of the 129-method god class. - Migrated voxel (VoxelWorld::drawVisible -> IGraphics3D*) and model3d (ModelRenderer::buildRenderable(s) -> IResourceFactory&). - Graphics.h slims from 24 sub-header includes to a handful of small type headers + forward declarations; the destructor moved out-of-line so unique_ptr effect members are destroyed where types are complete. Step 5 — header leaks (module_depgraph.py '*' marks: 14 -> 7): - map/TileLayer.h, particles/ParticleEmitter.h, avatar/AvatarInstance.h, animation/SpineAnim.h, map/TileSystem.h, model3d/ModelData.h, model3d/ModelRenderer.h, audio/Audio.h + Source.h, image/ImageData.h + Image.h, dialogue/Dialogue.h, procgen Palette/OutputSpec/TextureRecipe/ PbrMaterial -> forward declarations, includes moved to .cpp. - voxel/VoxelWorld.h: terrain sampler Pimpl'd (unique_ptr + out-of-line destructor), removing the procgen header dependency. - gpgpu/vulkan backend headers stop pulling graphics/vulkan/Graphics.h. - Remaining '*' marks are deliberate: tiny POD/enum headers (GridConfig, MapObject, BlendMode, Color), an internal backend header (GraphicsInternal.h -> filesystem), and lean interface references. Verified with a full clean build (MSVC) and tests: window/Scene/voxel/ RenderSystem/Quad/shader/TextureSampler/resourceLifetime pass; broad suite only shows the pre-existing PHYSFS-init filesystem-environment failures. * feat(resources): revive ResourceManager as unified IAssetReloader-backed cache * fix(resources): leak ResourceManager singleton to avoid exit-time teardown crashes Cached CPU resources may own third-party handles (FreeType faces, Assimp scenes, image decode handlers) whose libraries are torn down at process exit in an unspecified TU order; destroying cached entries from the singleton destructor can segfault at exit (seen on CI Windows in model3d.materialApi.objMtl). Keep the singleton alive until OS reclaim; explicit unload()/clear() still release entries during the run. * refactor(devtools): consume business modules through capability interfaces DevTools / cmdline no longer include business-module headers, so trimming scene / physics / procgen / particles / audio / ui / graphics no longer requires disabling devtools, and devtools can be switched off with -DEVENGINE_MODULE_DEVTOOLS=OFF. New capability interfaces in common/: - ISceneQuery (scene) - host/node inspection + mutation + transform sync - IRenderCapture (graphics) - render status, PNG capture, camera control, visible-entity snapshot, entity-ID mask, G-buffer PNGs - IPhysicsQuery (physics) - 2D world lifecycle + ray casts - IProcgenQuery (procgen) - recipe listing, map/mesh generation - IParticlesQuery (particles) - emitter status + one-shot emit - IAudioQuery (audio) - volume / stop-all - IEditorHost (ui) - declarative editor host (used by cmdline too) Providers register in their module constructors. DevTools rewires McpServer (render/scene/procgen/physics/particles/audio/host tools), SceneInspect (now delegates to ISceneQuery + IRenderCapture) and RenderVision (uses IRenderCapture::capturePngDataUrl); cmdline/Mcp.cpp uses IEditorHost. manifest: devtools DEPS graphics scene physics procgen particles audio ui image event filesystem -> event; cmdline DEPS filesystem ui -> filesystem. Verified: full build green; devtools.mcp.* tests pass; broad suite shows only the pre-existing PHYSFS-init filesystem-environment failures. * style: apply clang-format to changed lines after dev merge * fix(webgpu): include RenderControl/TextureSampler explicitly after Graphics.h slimming * fix(webgpu): provide newFont/print stubs to satisfy IGraphics2D/IResourceFactory vtables * fix(common): make ProcgenQuery.h self-sufficient (cstdint/utility for GCC)
400 lines
14 KiB
C++
400 lines
14 KiB
C++
#include "zeroerr/assert.h"
|
||
#include "zeroerr/unittest.h"
|
||
|
||
#include "particles/Particles.h"
|
||
#include "particles/ParticleEmitter.h"
|
||
#include "particles/ParticleSystem.h"
|
||
|
||
#include "animation/AnimClip.h"
|
||
#include "animation/AnimImporter.h"
|
||
#include "animation/AnimPose.h"
|
||
#include "animation/AnimSkeleton.h"
|
||
#include "animation/AnimSkin.h"
|
||
|
||
#include "filesystem/Filesystem.h"
|
||
#include "model3d/Model3D.h"
|
||
#include "model3d/ModelData.h"
|
||
|
||
#include <cmath>
|
||
#include <cstdio>
|
||
#include <filesystem>
|
||
#include <memory>
|
||
#include <string>
|
||
|
||
using namespace eve::particles;
|
||
using namespace eve::animation;
|
||
|
||
namespace {
|
||
|
||
#include "PathBesideSource.h"
|
||
EVE_DEFINE_PATH_BESIDE_SOURCE()
|
||
|
||
bool fileExists(const std::string &path) { return std::filesystem::is_regular_file(path); }
|
||
|
||
std::string cesiumManDir() {
|
||
return pathBesideThisSource("assets/skinned/cesium_man/glTF");
|
||
}
|
||
|
||
std::string cesiumManGltfPath() { return cesiumManDir() + "/CesiumMan.gltf"; }
|
||
|
||
bool ensureSkinnedAssets() {
|
||
const std::string gltf = cesiumManGltfPath();
|
||
if (fileExists(gltf)) return true;
|
||
std::printf(
|
||
"particles.attach_skin: missing %s — run scripts/download_skinned_character.sh\n",
|
||
gltf.c_str());
|
||
return false;
|
||
}
|
||
|
||
eve::model3d::ModelData *loadCesiumMan(const char *fsIdentity) {
|
||
const std::string dir = cesiumManDir();
|
||
REQUIRE(fileExists(dir + "/CesiumMan.gltf"));
|
||
auto *fs = eve::filesystem::Filesystem::create();
|
||
REQUIRE(fs != nullptr);
|
||
REQUIRE(fs->setIdentity(fsIdentity, true));
|
||
REQUIRE(fs->setupWriteDirectory());
|
||
fs->allowMountingForPath(dir);
|
||
REQUIRE(fs->mount(dir, "", false));
|
||
auto *mod = eve::model3d::Model3D::create();
|
||
return mod->newModelDataFromFile("CesiumMan.gltf");
|
||
}
|
||
|
||
int findFirstSkinnedMesh(const eve::model3d::ModelData *model) {
|
||
for (int i = 0; i < model->getMeshCount(); ++i) {
|
||
if (model->hasBones(i)) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST_CASE("particles.attach.boneFollowsPose") {
|
||
std::unique_ptr<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("root", -1);
|
||
const int hand = sk->addBone("hand", root);
|
||
sk->setBindPosition(hand, 0.f, 1.f, 0.f);
|
||
|
||
std::unique_ptr<AnimPose> pose(new AnimPose());
|
||
sk->applyBindPose(pose.get());
|
||
pose->setLocalPosition(root, 10.f, 20.f, 5.f);
|
||
pose->computeWorld(sk.get());
|
||
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(32);
|
||
e->setAttachScale(1.f);
|
||
e->setAttachPlane("xy");
|
||
e->attachToBone(pose.get(), hand);
|
||
|
||
CHECK(e->isAttached());
|
||
CHECK_EQ(e->getAttachBone(), hand);
|
||
// hand world = root(10,20,5) + local(0,1,0) → (10, 21, 5); xy → (10, 21)
|
||
CHECK(std::fabs(e->getX() - 10.f) < 1e-3f);
|
||
CHECK(std::fabs(e->getY() - 21.f) < 1e-3f);
|
||
|
||
// Move bone and sync via ParticleSimSystem.
|
||
pose->setLocalPosition(root, 30.f, 40.f, 0.f);
|
||
pose->computeWorld(sk.get());
|
||
ParticleSimSystem::update(0.016f);
|
||
CHECK(std::fabs(e->getX() - 30.f) < 1e-3f);
|
||
CHECK(std::fabs(e->getY() - 41.f) < 1e-3f);
|
||
|
||
e->setAttachPlane("xz");
|
||
e->syncAttach();
|
||
// world (30, 41, 0) on xz → (30, 0)
|
||
CHECK(std::fabs(e->getX() - 30.f) < 1e-3f);
|
||
CHECK(std::fabs(e->getY() - 0.f) < 1e-3f);
|
||
|
||
// yz plane maps (x,y,z) → (y,z).
|
||
e->setAttachPlane("yz");
|
||
e->setAttachScale(1.f);
|
||
e->syncAttach();
|
||
CHECK(std::fabs(e->getX() - 41.f) < 1e-3f);
|
||
CHECK(std::fabs(e->getY() - 0.f) < 1e-3f);
|
||
|
||
e->setAttachScale(2.f);
|
||
e->setAttachPlane("xy");
|
||
e->syncAttach();
|
||
CHECK(std::fabs(e->getX() - 60.f) < 1e-3f);
|
||
CHECK(std::fabs(e->getY() - 82.f) < 1e-3f);
|
||
|
||
CHECK_EQ(e->getAttachKind(), std::string("anim"));
|
||
e->detach();
|
||
CHECK(!e->isAttached());
|
||
CHECK_EQ(e->getAttachKind(), std::string("none"));
|
||
}
|
||
|
||
TEST_CASE("particles.attach.byNameAndOffset") {
|
||
std::unique_ptr<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("Hips", -1);
|
||
const int tip = sk->addBone("Head", root);
|
||
sk->setBindPosition(tip, 0.f, 2.f, 0.f);
|
||
|
||
std::unique_ptr<AnimPose> pose(new AnimPose());
|
||
sk->applyBindPose(pose.get());
|
||
pose->computeWorld(sk.get());
|
||
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(8);
|
||
e->attachToBoneByName(pose.get(), sk.get(), "Head");
|
||
CHECK(e->isAttached());
|
||
CHECK_EQ(e->getAttachBone(), tip);
|
||
CHECK(std::fabs(e->getY() - 2.f) < 1e-3f);
|
||
|
||
e->setAttachOffset(1.f, 0.f, 0.f);
|
||
e->syncAttach();
|
||
CHECK(std::fabs(e->getX() - 1.f) < 1e-3f);
|
||
CHECK(std::fabs(e->getY() - 2.f) < 1e-3f);
|
||
|
||
e->attachToBoneByName(pose.get(), sk.get(), "MissingBone");
|
||
CHECK(!e->isAttached());
|
||
}
|
||
|
||
TEST_CASE("particles.attach.followRotation") {
|
||
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° around Z: +X maps to +Y in XY plane → direction ≈ π/2
|
||
const float half = 0.70710678f;
|
||
pose->setLocalRotation(root, 0.f, 0.f, half, half);
|
||
pose->computeWorld(sk.get());
|
||
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(4);
|
||
e->setDirection(0.f);
|
||
e->setFollowBoneRotation(true);
|
||
e->attachToBone(pose.get(), root);
|
||
CHECK(std::fabs(e->getDirection() - (3.14159265f * 0.5f)) < 0.05f);
|
||
e->detach();
|
||
|
||
// Off keeps the configured direction unchanged under the same rotation.
|
||
ParticleEmitter *e2 = mod->newEmitter(4);
|
||
e2->setDirection(0.25f);
|
||
e2->setFollowBoneRotation(false);
|
||
e2->attachToBone(pose.get(), root);
|
||
CHECK(std::fabs(e2->getDirection() - 0.25f) < 1e-4f);
|
||
e2->detach();
|
||
}
|
||
|
||
TEST_CASE("particles.attach.emitsAtBone") {
|
||
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, 50.f, 60.f, 0.f);
|
||
pose->computeWorld(sk.get());
|
||
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(16);
|
||
e->setParticleLifetime(2.f, 2.f);
|
||
e->setSpeed(0.f, 0.f);
|
||
e->setEmissionRate(100.f);
|
||
e->attachToBone(pose.get(), root);
|
||
e->start();
|
||
ParticleSimSystem::update(0.05f);
|
||
CHECK_GE(e->getCount(), 4);
|
||
auto sim = e->sim();
|
||
for (int i = 0; i < sim->alive; ++i) {
|
||
CHECK(std::fabs(sim->particles[size_t(i)].x - 50.f) < 1e-2f);
|
||
CHECK(std::fabs(sim->particles[size_t(i)].y - 60.f) < 1e-2f);
|
||
}
|
||
e->detach();
|
||
}
|
||
|
||
TEST_CASE("particles.skin.surfaceEmitCesiumMan") {
|
||
if (!ensureSkinnedAssets()) return;
|
||
|
||
eve::ref<eve::model3d::ModelData> model(
|
||
loadCesiumMan("ev_ut_particles_skin_surface"));
|
||
REQUIRE(model.get() != nullptr);
|
||
const int meshIndex = findFirstSkinnedMesh(model.get());
|
||
REQUIRE(meshIndex >= 0);
|
||
|
||
std::unique_ptr<AnimSkeleton> skeleton(AnimImporter::loadSkeletonFromModel(model.get()));
|
||
REQUIRE(skeleton.get() != nullptr);
|
||
std::unique_ptr<AnimClip> clip(
|
||
AnimImporter::loadClipFromModel(model.get(), skeleton.get(), 0));
|
||
REQUIRE(clip.get() != nullptr);
|
||
std::unique_ptr<AnimSkin> skin(AnimSkin::fromModel(model.get(), meshIndex, skeleton.get()));
|
||
REQUIRE(skin.get() != nullptr);
|
||
|
||
std::unique_ptr<AnimPose> pose(new AnimPose());
|
||
clip->sample(0.f, pose.get(), skeleton.get());
|
||
pose->computeWorld(skeleton.get());
|
||
CHECK(!skin->hasSkinnedPositions());
|
||
REQUIRE(skin->updateSkinnedPositions(pose.get()));
|
||
CHECK(skin->hasSkinnedPositions());
|
||
CHECK(std::isfinite(skin->getSkinnedPositionX(0)));
|
||
CHECK(std::isfinite(skin->getSkinnedPositionY(0)));
|
||
CHECK(std::isfinite(skin->getSkinnedPositionZ(0)));
|
||
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(256);
|
||
e->setParticleLifetime(1.f, 1.f);
|
||
e->setSpeed(0.f, 0.f);
|
||
e->setEmissionArea("none", 0.f, 0.f);
|
||
e->setSkinScale(100.f); // meters → pixels-ish
|
||
e->setSkinPlane("xy");
|
||
e->setSkinSource(skin.get(), pose.get());
|
||
CHECK(e->hasSkinSource());
|
||
|
||
e->emitFromSkin(64);
|
||
CHECK_EQ(e->getCount(), 64);
|
||
|
||
// Spawn positions should not all collapse to a single point.
|
||
auto sim = e->sim();
|
||
float minX = sim->particles[0].x, maxX = minX;
|
||
float minY = sim->particles[0].y, maxY = minY;
|
||
for (int i = 0; i < sim->alive; ++i) {
|
||
const auto &p = sim->particles[size_t(i)];
|
||
minX = std::min(minX, p.x);
|
||
maxX = std::max(maxX, p.x);
|
||
minY = std::min(minY, p.y);
|
||
maxY = std::max(maxY, p.y);
|
||
}
|
||
CHECK_GT(maxX - minX + maxY - minY, 1.f);
|
||
|
||
// Continuous emission via rate also samples the skin.
|
||
e->reset();
|
||
e->setEmissionRate(200.f);
|
||
e->start();
|
||
ParticleSimSystem::update(0.1f);
|
||
CHECK_GE(e->getCount(), 15);
|
||
|
||
// Bone filter should narrow candidate set (or keep emitting if bone unused).
|
||
const std::string boneName = skin->getSkinBoneName(0);
|
||
e->setSkinBoneFilterByName(skeleton.get(), boneName, 0.1f);
|
||
e->reset();
|
||
e->emitFromSkin(32);
|
||
CHECK_GE(e->getCount(), 1);
|
||
|
||
e->clearSkinSource();
|
||
CHECK(!e->hasSkinSource());
|
||
}
|
||
|
||
TEST_CASE("particles.attach.animPoseDynamicEmitAcrossFrames") {
|
||
// Emit while the bone is moving — particles should form a trail of spawn positions.
|
||
std::unique_ptr<AnimSkeleton> sk(new AnimSkeleton());
|
||
const int root = sk->addBone("root", -1);
|
||
const int tip = sk->addBone("tip", root);
|
||
sk->setBindPosition(tip, 1.f, 0.f, 0.f);
|
||
|
||
std::unique_ptr<AnimPose> pose(new AnimPose());
|
||
sk->applyBindPose(pose.get());
|
||
// The attach sync reads the pose's world transforms — compute them first
|
||
// (matches the documented frame order: computeWorld → attach/emit).
|
||
pose->computeWorld(sk.get());
|
||
|
||
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->setAttachScale(10.f);
|
||
e->setAttachPlane("xy");
|
||
e->attachToBone(pose.get(), tip);
|
||
CHECK_EQ(e->getAttachKind(), std::string("anim"));
|
||
|
||
for (int frame = 0; frame < 5; ++frame) {
|
||
pose->setLocalPosition(root, float(frame) * 2.f, 0.f, 0.f);
|
||
pose->computeWorld(sk.get());
|
||
e->emit(1);
|
||
ParticleSimSystem::update(0.016f);
|
||
}
|
||
CHECK_EQ(e->getCount(), 5);
|
||
auto sim = e->sim();
|
||
// tip world x = root.x + 1 → scaled ×10; frames 0..4 → x = 10,30,50,70,90
|
||
CHECK(std::fabs(sim->particles[0].x - 10.f) < 1e-2f);
|
||
CHECK(std::fabs(sim->particles[4].x - 90.f) < 1e-2f);
|
||
e->detach();
|
||
}
|
||
|
||
TEST_CASE("particles.attach.clipSampleMovesEmitter") {
|
||
if (!ensureSkinnedAssets()) return;
|
||
|
||
eve::ref<eve::model3d::ModelData> model(
|
||
loadCesiumMan("ev_ut_particles_attach_clip"));
|
||
REQUIRE(model.get() != nullptr);
|
||
std::unique_ptr<AnimSkeleton> skeleton(AnimImporter::loadSkeletonFromModel(model.get()));
|
||
REQUIRE(skeleton.get() != nullptr);
|
||
std::unique_ptr<AnimClip> clip(
|
||
AnimImporter::loadClipFromModel(model.get(), skeleton.get(), 0));
|
||
REQUIRE(clip.get() != nullptr);
|
||
REQUIRE(clip->getDuration() > 0.1f);
|
||
|
||
// Prefer a named limb bone; fall back to last bone.
|
||
int bone = skeleton->findBone("Left_arm");
|
||
if (bone < 0) bone = skeleton->findBone("LeftArm");
|
||
if (bone < 0) bone = skeleton->getBoneCount() - 1;
|
||
REQUIRE(bone >= 0);
|
||
|
||
std::unique_ptr<AnimPose> pose(new AnimPose());
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(64);
|
||
e->setParticleLifetime(5.f, 5.f);
|
||
e->setSpeed(0.f, 0.f);
|
||
e->setAttachScale(50.f);
|
||
e->setAttachPlane("xy");
|
||
e->attachToBone(pose.get(), bone);
|
||
CHECK_EQ(e->getAttachKind(), std::string("anim"));
|
||
|
||
float minX = 1e9f, maxX = -1e9f, minY = 1e9f, maxY = -1e9f;
|
||
for (int i = 0; i < 8; ++i) {
|
||
const float t = clip->getDuration() * (float(i) / 7.f);
|
||
clip->sample(t, pose.get(), skeleton.get());
|
||
pose->computeWorld(skeleton.get());
|
||
e->emit(1);
|
||
ParticleSimSystem::update(0.016f);
|
||
const auto &p = e->sim()->particles[size_t(e->getCount() - 1)];
|
||
minX = std::min(minX, p.x);
|
||
maxX = std::max(maxX, p.x);
|
||
minY = std::min(minY, p.y);
|
||
maxY = std::max(maxY, p.y);
|
||
}
|
||
CHECK_EQ(e->getCount(), 8);
|
||
// Animated bone should move enough that spawn positions spread out.
|
||
CHECK_GT(maxX - minX + maxY - minY, 1.f);
|
||
e->detach();
|
||
}
|
||
|
||
TEST_CASE("particles.skin.emptyBoneFilterFallsBack") {
|
||
if (!ensureSkinnedAssets()) return;
|
||
|
||
eve::ref<eve::model3d::ModelData> model(
|
||
loadCesiumMan("ev_ut_particles_skin_empty_filter"));
|
||
REQUIRE(model.get() != nullptr);
|
||
const int meshIndex = findFirstSkinnedMesh(model.get());
|
||
REQUIRE(meshIndex >= 0);
|
||
std::unique_ptr<AnimSkeleton> skeleton(AnimImporter::loadSkeletonFromModel(model.get()));
|
||
std::unique_ptr<AnimSkin> skin(AnimSkin::fromModel(model.get(), meshIndex, skeleton.get()));
|
||
std::unique_ptr<AnimPose> pose(new AnimPose());
|
||
skeleton->applyBindPose(pose.get());
|
||
pose->computeWorld(skeleton.get());
|
||
|
||
auto *mod = Particles::create();
|
||
ParticleEmitter *e = mod->newEmitter(32);
|
||
e->setParticleLifetime(1.f, 1.f);
|
||
e->setSpeed(0.f, 0.f);
|
||
e->setPosition(12.f, 34.f);
|
||
e->setSkinScale(1.f);
|
||
e->setSkinSource(skin.get(), pose.get());
|
||
// Impossible weight threshold → no candidates → spawn falls back to Config.x/y.
|
||
e->setSkinBoneFilter(0, 2.f);
|
||
e->emitFromSkin(5);
|
||
// emitFromSkin breaks when sample fails, so count may be 0.
|
||
CHECK_LE(e->getCount(), 5);
|
||
|
||
e->reset();
|
||
e->setEmissionRate(100.f);
|
||
e->start();
|
||
ParticleSimSystem::update(0.05f);
|
||
CHECK_GE(e->getCount(), 4);
|
||
// Fallback spawn at emitter position.
|
||
CHECK(std::fabs(e->sim()->particles[0].x - 12.f) < 1e-2f);
|
||
CHECK(std::fabs(e->sim()->particles[0].y - 34.f) < 1e-2f);
|
||
e->clearSkinSource();
|
||
e->detach();
|
||
}
|
||
|