#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "graphics/Graphics.h" #include "graphics/Light.h" #include "particles/ParticleEmitter.h" #include "particles/ParticleRuntime.h" #include "particles/ParticleSystem.h" #include "particles/Particles.h" #include "window/Window.h" #include #include #include #include #include #include #include using namespace eve::particles; namespace { double median(std::vector values) { std::sort(values.begin(), values.end()); return values[values.size() / 2]; } eve::graphics::Mesh* makeLeftHalfDepthPlane(eve::graphics::Graphics* gfx) { const std::array positions = { -1.f, -1.f, 0.5f, 0.f, -1.f, 0.5f, 0.f, 1.f, 0.5f, -1.f, 1.f, 0.5f, }; const std::array normals = { 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, }; const std::array uvs = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f}; const std::array indices = {0, 2, 1, 0, 3, 2}; return gfx->newMeshFromArrays(positions.data(), normals.data(), uvs.data(), 4, indices.data(), 6); } eve::graphics::Mesh* makeFullscreenPlane(eve::graphics::Graphics* gfx) { const std::array positions = { -1.f, -1.f, 0.5f, 1.f, -1.f, 0.5f, 1.f, 1.f, 0.5f, -1.f, 1.f, 0.5f, }; const std::array normals = { 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f, }; const std::array uvs = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f}; const std::array indices = {0, 2, 1, 0, 3, 2}; return gfx->newMeshFromArrays(positions.data(), normals.data(), uvs.data(), 4, indices.data(), 6); } float colorDistance(const Color& a, const Color& b) { const float dr = a.r - b.r; const float dg = a.g - b.g; const float db = a.b - b.b; return std::sqrt(dr * dr + dg * dg + db * db); } } // namespace TEST_CASE("particles.gpu.unsupportedFeaturesFallBackToCpu") { auto* emitter = Particles::create()->newEmitter(256); emitter->setRandomSeed(8128); emitter->setGpuSimulation(true); emitter->setCollision("bounce", 2.f, 0.5f, 0.f); emitter->setEmissionRate(120.f); emitter->setParticleLifetime(2.f, 2.f); emitter->start(); ParticleSimSystem::update(0.25f); CHECK(emitter->getGpuSimulation()); CHECK(!emitter->gpuSim()->residentActive); CHECK_EQ(emitter->gpuSim()->residentHandle, std::uint64_t(0)); REQUIRE_GT(emitter->getCount(), 0); REQUIRE_EQ(emitter->getCount(), emitter->sim()->alive); } TEST_CASE("particles.gpu.residentSimulationAndIndirectRendering") { auto* window = eve::window::Window::create(); auto* gfx = eve::graphics::Graphics::create(); REQUIRE(window != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 800; settings.height = 520; settings.centered = true; REQUIRE(window->setWindowSettings(settings)); REQUIRE(gfx->supportsGpuParticles()); gfx->setScreenReadbackEnabled(true); const std::uint8_t whitePixel[4] = {255, 255, 255, 255}; auto* whiteTexture = gfx->newTexture(1, 1, whitePixel); REQUIRE(whiteTexture != nullptr); const std::array, 6> colors{{ {0.2f, 0.75f, 1.f}, {0.45f, 0.25f, 1.f}, {1.f, 0.18f, 0.5f}, {1.f, 0.55f, 0.08f}, {0.3f, 1.f, 0.5f}, {0.95f, 0.95f, 0.3f}, }}; std::vector emitters; emitters.reserve(colors.size()); for (std::size_t i = 0; i < colors.size(); ++i) { auto* emitter = Particles::create()->newEmitter(8192); emitter->setTexture(whiteTexture); emitter->setRandomSeed(20260826 + int(i)); emitter->setGpuSimulation(true); emitter->setPosition(115.f + float(i) * 114.f, 400.f); emitter->setEmissionRate(1500.f); emitter->setMaxSpawnPerFrame(32); emitter->setParticleLifetime(0.9f, 1.45f); emitter->setParticleSize(9.f, 18.f); emitter->setSizes(1.f, 0.12f); emitter->setSpeed(95.f, 245.f); emitter->setSpread(6.2831853f); emitter->setGravity(0.f, 105.f); emitter->setDamping(0.16f); emitter->setSpin(-4.f, 4.f); if (i % 3 == 1) { emitter->setRenderMode("axis"); emitter->setRenderAxis(i % 2 == 0 ? 35.f : -35.f); } else if (i % 3 == 2) { emitter->setRenderMode("velocity", 0.075f); } emitter->setBlendMode("additive"); emitter->setColorStart(colors[i][0], colors[i][1], colors[i][2], 0.95f); emitter->setColorEnd(colors[i][0] * 0.25f, colors[i][1] * 0.12f, colors[i][2] * 0.3f, 0.f); emitter->start(); emitters.push_back(emitter); } gfx->setBackgroundColorRGBA(0.008f, 0.012f, 0.035f, 1.f); constexpr float dt = 1.f / 60.f; for (int frame = 0; frame < 150; ++frame) { ParticleSimSystem::update(dt); gfx->clearScreen(); gfx->drawSolidRectRGBA(24.f, 22.f, 752.f, 476.f, 0.015f, 0.025f, 0.075f, 1.f); ParticleRenderSystem::render(gfx); gfx->present(); } std::uint64_t submittedFrames = 0; std::uint32_t delayedAlive = 0; std::uint32_t indirectInstances = 0; int estimatedAlive = 0; for (auto* emitter : emitters) { auto gpu = emitter->gpuSim(); REQUIRE(gpu->residentActive); REQUIRE(emitter->isGpuSimulationActive()); REQUIRE_NE(gpu->residentHandle, std::uint64_t(0)); REQUIRE_EQ(emitter->sim()->alive, 0); const auto gpuStats = gfx->getGpuParticleStats(gpu->residentHandle); submittedFrames += gpuStats.submittedFrames; delayedAlive += gpuStats.alive; indirectInstances += gpuStats.instances; estimatedAlive += gpu->estimatedAlive; } REQUIRE_GT(submittedFrames, std::uint64_t(800)); REQUIRE_GT(delayedAlive, std::uint32_t(1000)); REQUIRE_GT(indirectInstances, std::uint32_t(1000)); REQUIRE_GT(estimatedAlive, 1000); REQUIRE_GT(particleFrameStats().renderedParticles, 1000); REQUIRE_EQ(particleFrameStats().gpuResidentEmitters, 6); REQUIRE_GT(particleFrameStats().gpuResidentParticles, 1000); const std::string output = std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_gpu_resident.png"; CHECK(gfx->saveFramePng(output)); CHECK(std::filesystem::exists(output)); REQUIRE(std::filesystem::file_size(output) > std::uintmax_t(4096)); // Enabling a gameplay-coupled feature at runtime must not be silently // ignored by a previously activated GPU emitter. It resets to CPU safely. emitters.front()->setCollision("bounce", 2.f, 0.5f, 0.f); ParticleSimSystem::update(dt); CHECK(!emitters.front()->isGpuSimulationActive()); REQUIRE_GT(emitters.front()->sim()->alive, 0); for (auto* emitter : emitters) { emitter->setVisible(false); emitter->setGpuSimulation(false); } window->close(); } TEST_CASE("particles.gpu.multipleResidentEmittersKeepIndependentDrawState") { auto* window = eve::window::Window::create(); auto* gfx = eve::graphics::Graphics::create(); REQUIRE(window != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 640; settings.height = 360; settings.centered = true; REQUIRE(window->setWindowSettings(settings)); REQUIRE(gfx->supportsGpuParticles()); gfx->setScreenReadbackEnabled(true); const std::uint8_t whitePixel[4] = {255, 255, 255, 255}; auto* texture = gfx->newTexture(1, 1, whitePixel); REQUIRE(texture != nullptr); const std::array positions{160.f, 320.f, 480.f}; const std::array, 3> colors{{ {1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}, }}; std::array emitters{}; for (std::size_t i = 0; i < emitters.size(); ++i) { auto* emitter = Particles::create()->newEmitter(8); emitter->setTexture(texture); emitter->setRandomSeed(7200 + int(i)); emitter->setGpuSimulation(true); emitter->setPosition(positions[i], 180.f); emitter->setEmissionRate(0.f); emitter->setParticleLifetime(10.f, 10.f); emitter->setParticleSize(64.f, 64.f); emitter->setSpeed(0.f, 0.f); emitter->setSpread(0.f); emitter->setBlendMode("alpha"); emitter->setColorStart(colors[i][0], colors[i][1], colors[i][2], 1.f); emitter->setColorEnd(colors[i][0], colors[i][1], colors[i][2], 1.f); emitter->emit(1); emitters[i] = emitter; } gfx->setBackgroundColorRGBA(0.f, 0.f, 0.f, 1.f); for (int frame = 0; frame < 5; ++frame) { ParticleSimSystem::update(1.f / 60.f); gfx->clearScreen(); ParticleRenderSystem::render(gfx); gfx->present(); } for (std::size_t i = 0; i < emitters.size(); ++i) { REQUIRE(emitters[i]->isGpuSimulationActive()); const auto stats = gfx->getGpuParticleStats(emitters[i]->gpuSim()->residentHandle); REQUIRE_EQ(stats.instances, std::uint32_t(1)); } const auto red = gfx->getPixel(160, 180); const auto green = gfx->getPixel(320, 180); const auto blue = gfx->getPixel(480, 180); REQUIRE_GT(red.r, 0.8f); REQUIRE_LT(red.g + red.b, 0.2f); REQUIRE_GT(green.g, 0.8f); REQUIRE_LT(green.r + green.b, 0.2f); REQUIRE_GT(blue.b, 0.8f); REQUIRE_LT(blue.r + blue.g, 0.2f); const std::string output = std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_gpu_multi_emitter.png"; CHECK(gfx->saveFramePng(output)); for (auto* emitter : emitters) { emitter->setGpuSimulation(false); emitter->release(); } window->close(); } TEST_CASE("particles.renderer.normalMappedLitMaterialUsesSceneLights") { auto* window = eve::window::Window::create(); auto* gfx = eve::graphics::Graphics::create(); REQUIRE(window != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 480; settings.height = 280; settings.centered = true; REQUIRE(window->setWindowSettings(settings)); gfx->setScreenReadbackEnabled(true); const std::uint8_t albedoPixel[4] = {80, 80, 80, 255}; const std::uint8_t normalPixel[4] = {128, 128, 255, 255}; auto* albedo = gfx->newTexture(1, 1, albedoPixel); auto* normal = gfx->newTexture(1, 1, normalPixel); REQUIRE(albedo != nullptr); REQUIRE(normal != nullptr); auto makeEmitter = [&](float x, bool lit) { auto* emitter = Particles::create()->newEmitter(8); emitter->setTexture(albedo); emitter->setNormalTexture(normal); emitter->setMaterialMode(lit ? "lit" : "unlit"); emitter->setGpuSimulation(lit); emitter->setPosition(x, 140.f); emitter->setEmissionRate(0.f); emitter->setParticleLifetime(10.f, 10.f); emitter->setParticleSize(104.f, 104.f); emitter->setSpeed(0.f, 0.f); emitter->setSpread(0.f); emitter->setBlendMode("alpha"); emitter->setColorStart(1.f, 1.f, 1.f, 1.f); emitter->setColorEnd(1.f, 1.f, 1.f, 1.f); emitter->emit(1); return emitter; }; auto* lit = makeEmitter(150.f, true); auto* unlit = makeEmitter(330.f, false); auto* light = eve::graphics::Light2D::createLight("point"); light->setPosition(150.f, 140.f); light->setColor(1.f, 0.75f, 0.45f, 4.f); light->setRadius(180.f); light->setEnabled(true); gfx->setBackgroundColorRGBA(0.005f, 0.007f, 0.012f, 1.f); for (int frame = 0; frame < 4; ++frame) { ParticleSimSystem::update(1.f / 60.f); gfx->clearScreen(); ParticleRenderSystem::render(gfx); gfx->present(); } REQUIRE(!lit->isGpuSimulationActive()); const auto litPixel = gfx->getPixel(150, 140); const auto unlitPixel = gfx->getPixel(330, 140); REQUIRE_GT(litPixel.r, unlitPixel.r + 0.2f); REQUIRE_GT(litPixel.r, litPixel.b + 0.1f); const std::string output = std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_lit_material.png"; CHECK(gfx->saveFramePng(output)); CHECK(std::filesystem::exists(output)); lit->release(); unlit->release(); light->setEnabled(false); window->close(); } TEST_CASE("particles.renderer.distortionRefractsResolvedSceneColor") { auto* window = eve::window::Window::create(); auto* gfx = eve::graphics::Graphics::create(); REQUIRE(window != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 640; settings.height = 360; settings.centered = true; REQUIRE(window->setWindowSettings(settings)); gfx->setScreenReadbackEnabled(true); constexpr int width = 640; constexpr int height = 360; constexpr int effectX = 304; std::vector stripePixels(std::size_t(width) * 4u); for (int x = 0; x < width; ++x) { const bool cyan = (x / 32) % 2 == 0; auto* pixel = &stripePixels[std::size_t(x) * 4u]; pixel[0] = cyan ? 12 : 218; pixel[1] = cyan ? 188 : 18; pixel[2] = cyan ? 218 : 184; pixel[3] = 255; } auto* backgroundTexture = gfx->newTexture(width, 1, stripePixels.data()); auto* scenePlane = makeFullscreenPlane(gfx); REQUIRE(backgroundTexture != nullptr); REQUIRE(scenePlane != nullptr); eve::graphics::Lighting3DPack sceneLighting; sceneLighting.ambient = glm::vec4(1.f, 1.f, 1.f, 0.f); gfx->setMesh3DLighting(sceneLighting); gfx->setMesh3DMaterial(0.f, 1.f); constexpr int fieldSize = 96; std::vector fieldPixels(std::size_t(fieldSize * fieldSize) * 4u); for (int y = 0; y < fieldSize; ++y) { for (int x = 0; x < fieldSize; ++x) { const float nx = (float(x) + 0.5f) / float(fieldSize) * 2.f - 1.f; const float ny = (float(y) + 0.5f) / float(fieldSize) * 2.f - 1.f; const float feather = std::clamp((1.f - std::sqrt(nx * nx + ny * ny)) * 5.f, 0.f, 1.f); auto* pixel = &fieldPixels[std::size_t(y * fieldSize + x) * 4u]; pixel[0] = 255; pixel[1] = 128; pixel[2] = 128; pixel[3] = std::uint8_t(feather * 255.f); } } auto* displacement = gfx->newTexture(fieldSize, fieldSize, fieldPixels.data()); REQUIRE(displacement != nullptr); auto renderScene = [&] { gfx->begin3DFrame(); gfx->drawMesh(scenePlane, glm::mat4(1.f), backgroundTexture, Color(1.f, 1.f, 1.f, 1.f)); gfx->clearScreen(); }; renderScene(); gfx->present(); CHECK(gfx->saveFramePng(std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_scene_distortion_control.png")); const Color source = gfx->getPixel(effectX, height / 2); const Color target = gfx->getPixel(effectX + 32, height / 2); REQUIRE_GT(colorDistance(source, target), 0.08f); auto* emitter = Particles::create()->newEmitter(4); emitter->setTexture(displacement); emitter->setMaterialMode("distortion"); emitter->setDistortionStrength(32.f); emitter->setPosition(float(effectX), float(height / 2)); emitter->setEmissionRate(0.f); emitter->setParticleLifetime(10.f, 10.f); emitter->setParticleSize(180.f, 180.f); emitter->setSpeed(0.f, 0.f); emitter->setSpread(0.f); emitter->setColorStart(1.f, 1.f, 1.f, 1.f); emitter->setColorEnd(1.f, 1.f, 1.f, 1.f); emitter->emit(1); ParticleSimSystem::update(1.f / 60.f); renderScene(); ParticleRenderSystem::render(gfx); gfx->present(); const Color refracted = gfx->getPixel(effectX, height / 2); CHECK_LT(colorDistance(refracted, target), colorDistance(refracted, source)); CHECK_LT(colorDistance(refracted, target), 0.35f); const std::string output = std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_scene_distortion.png"; CHECK(gfx->saveFramePng(output)); CHECK(std::filesystem::exists(output)); REQUIRE(std::filesystem::file_size(output) > std::uintmax_t(4096)); emitter->release(); window->close(); } TEST_CASE("particles.gpu.mainThreadCrossoverAtSixteenThousandParticles") { auto* window = eve::window::Window::create(); auto* gfx = eve::graphics::Graphics::create(); REQUIRE(window != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 640; settings.height = 360; settings.centered = true; REQUIRE(window->setWindowSettings(settings)); REQUIRE(gfx->supportsGpuParticles()); gfx->setVSync(false); const std::uint8_t whitePixel[4] = {255, 255, 255, 255}; auto* whiteTexture = gfx->newTexture(1, 1, whitePixel); REQUIRE(whiteTexture != nullptr); auto makeEmitters = [&](bool gpuEnabled) { std::vector result; for (int i = 0; i < 4; ++i) { auto* emitter = Particles::create()->newEmitter(4096); emitter->setTexture(whiteTexture); emitter->setRandomSeed(4100 + i); emitter->setGpuSimulation(gpuEnabled); emitter->setPosition(150.f + 110.f * float(i), 180.f); emitter->setEmissionRate(0.f); emitter->setParticleLifetime(30.f, 30.f); emitter->setParticleSize(4.f, 4.f); emitter->setSpeed(2.f, 8.f); emitter->setSpread(6.2831853f); emitter->emit(4096); result.push_back(emitter); } return result; }; auto measureMainThread = [&](int warmupFrames, int measuredFrames) { std::vector samples; for (int frame = 0; frame < warmupFrames + measuredFrames; ++frame) { ParticleSimSystem::update(1.f / 60.f); gfx->clearScreen(); ParticleRenderSystem::render(gfx); if (frame >= warmupFrames) { const auto& stats = particleFrameStats(); samples.push_back(stats.simulationMs + stats.renderMs); } gfx->present(); } return median(std::move(samples)); }; auto cpuEmitters = makeEmitters(false); const double cpuMs = measureMainThread(3, 12); REQUIRE_EQ(particleFrameStats().particlesAfter, 16384); for (auto* emitter : cpuEmitters) emitter->release(); auto gpuEmitters = makeEmitters(true); const double gpuCpuMs = measureMainThread(5, 12); REQUIRE_EQ(particleFrameStats().gpuResidentEmitters, 4); REQUIRE_EQ(particleFrameStats().gpuResidentParticles, 16384); std::fprintf(stdout, "[particles.perf] cpu-main=%.3fms gpu-main=%.3fms particles=16384\n", cpuMs, gpuCpuMs); REQUIRE_LT(gpuCpuMs, cpuMs); for (auto* emitter : gpuEmitters) { emitter->setGpuSimulation(false); emitter->release(); } window->close(); } TEST_CASE("particles.gpu.softDepthFadeUsesSceneLinearDepth") { auto* window = eve::window::Window::create(); auto* gfx = eve::graphics::Graphics::create(); REQUIRE(window != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 640; settings.height = 360; settings.centered = true; REQUIRE(window->setWindowSettings(settings)); REQUIRE(gfx->supportsGpuParticles()); gfx->setScreenReadbackEnabled(true); const std::uint8_t whitePixel[4] = {255, 255, 255, 255}; const std::uint8_t darkPixel[4] = {5, 8, 14, 255}; auto* particleTex = gfx->newTexture(1, 1, whitePixel); auto* planeTex = gfx->newTexture(1, 1, darkPixel); auto* depthPlane = makeLeftHalfDepthPlane(gfx); REQUIRE(particleTex != nullptr); REQUIRE(planeTex != nullptr); REQUIRE(depthPlane != nullptr); auto makeEmitter = [&](float x) { auto* emitter = Particles::create()->newEmitter(16); emitter->setTexture(particleTex); emitter->setRandomSeed(20260826); emitter->setGpuSimulation(true); emitter->setPosition(x, 180.f); emitter->setEmissionRate(480.f); emitter->setMaxSpawnPerFrame(8); emitter->setParticleLifetime(10.f, 10.f); emitter->setParticleSize(112.f, 112.f); emitter->setSpeed(0.f, 0.f); emitter->setSpread(0.f); emitter->setBlendMode("alpha"); emitter->setColorStart(1.f, 0.05f, 0.75f, 1.f); emitter->setColorEnd(1.f, 0.05f, 0.75f, 1.f); emitter->setSoftParticles(false, 0.5f, 0.05f); emitter->start(); return emitter; }; auto* occluded = makeEmitter(160.f); gfx->setBackgroundColorRGBA(0.004f, 0.006f, 0.014f, 1.f); for (int frame = 0; frame < 8; ++frame) { ParticleSimSystem::update(1.f / 60.f); gfx->beginGBufferPass(640, 360); gfx->drawMeshGBuffer(depthPlane, glm::mat4(1.f), glm::mat4(1.f), 0.1f, 100.f, planeTex); gfx->endGBufferPass(); gfx->clearScreen(); ParticleRenderSystem::render(gfx); gfx->present(); } REQUIRE(occluded->isGpuSimulationActive()); const auto controlPixel = gfx->getPixel(160, 180); const float controlMagenta = controlPixel.r + controlPixel.b - controlPixel.g; const std::string controlOutput = std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_soft_depth_control.png"; CHECK(gfx->saveFramePng(controlOutput)); CHECK(std::filesystem::exists(controlOutput)); occluded->setSoftParticles(true, 0.5f, 0.05f); bool observedSoftDepth = false; for (int frame = 0; frame < 4; ++frame) { ParticleSimSystem::update(1.f / 60.f); gfx->beginGBufferPass(640, 360); gfx->drawMeshGBuffer(depthPlane, glm::mat4(1.f), glm::mat4(1.f), 0.1f, 100.f, planeTex); gfx->endGBufferPass(); gfx->clearScreen(); ParticleRenderSystem::render(gfx); observedSoftDepth = observedSoftDepth || occluded->isSoftParticlesActive(); gfx->present(); } REQUIRE(observedSoftDepth); const auto fadedPixel = gfx->getPixel(160, 180); const float fadedMagenta = fadedPixel.r + fadedPixel.b - fadedPixel.g; CHECK_GT(controlMagenta, fadedMagenta + 0.7f); const std::string output = std::string(EVENGINE_TEST_BINARY_DIR) + "/particle_soft_depth.png"; CHECK(gfx->saveFramePng(output)); CHECK(std::filesystem::exists(output)); REQUIRE(std::filesystem::file_size(output) > std::uintmax_t(100)); occluded->setGpuSimulation(false); occluded->release(); window->close(); }