#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "Fixtures.h" #include #include #include #include #include #include "graphics/AmbientOcclusion.h" #include "graphics/AntiAliasing.h" #include "graphics/Canvas.h" #include "graphics/DrawItem2D.h" #include "graphics/Font.h" #include "graphics/GBuffer.h" #include "graphics/GlobalIllumination.h" #include "graphics/Graphics.h" #include "graphics/Grass.h" #include "graphics/Light.h" #include "graphics/Material.h" #include "graphics/Mesh.h" #include "graphics/Outline.h" #include "graphics/Quad.h" #include "graphics/RenderControl.h" #include "graphics/RenderSystem.h" #include "graphics/RenderSystem3D.h" #include "graphics/ScreenSpaceReflection.h" #include "graphics/Shader.h" #include "graphics/Shadow.h" #include "graphics/Texture.h" #include "graphics/Volumetric.h" #include "graphics/Water.h" #include "graphics/Waterfall.h" #include "window/Window.h" // Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form. using eve::graphics::Color; using namespace eve::graphics; namespace { void resetScene3D() { if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [mr] = *it; mr->visible = false; } } if (ecs::current()->getManager() != nullptr) { auto camView = ecs::View(); for (auto it = camView.begin(); it != camView.end(); ++it) { auto [data] = *it; data->active = false; } } if (ecs::current()->getManager() != nullptr) { auto lightView = ecs::View(); for (auto it = lightView.begin(); it != lightView.end(); ++it) { auto [d] = *it; d->enabled = false; } } if (ecs::current()->getManager() != nullptr) { auto view = ecs::View(); for (auto it = view.begin(); it != view.end(); ++it) { auto [sp] = *it; sp->visible = false; } } } Texture *makeSolid(Graphics *gfx, uint8_t r, uint8_t g, uint8_t b) { const uint8_t px[4] = {r, g, b, 255}; return gfx->newTexture(1, 1, px); } Mesh *makeGroundPlane(Graphics *gfx) { return gfx->newMeshSphere(16, 8); } void addHud() { auto *hud = Renderable2D::create(); hud->transform()->x = 0; hud->transform()->y = 0; hud->sprite()->width = 2; hud->sprite()->height = 2; } float luma(const Color &c) { return (c.r + c.g + c.b) / 3.f; } void warmPresent(Graphics *gfx) { for (int i = 0; i < 3; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } } /** Live-render the shadow scene to the window for ~1s. */ void previewScene(Graphics *gfx, int ms = 1000) { const int frames = (ms >= 16) ? (ms / 16) : 1; for (int i = 0; i < frames; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); SDL_Event e; while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) return; } SDL_Delay(16); } } /** Max (a - b) over a coarse pixel grid. Used because Vulkan FB Y / RH_ZO place * this scene's umbra off the naive "center + below" sample point. */ float maxLumaDelta(Graphics *gfx, const std::vector &a, const std::vector &b, int step = 4) { float best = -1.f; const int w = gfx->getWidth(); const int h = gfx->getHeight(); for (int y = 0; y < h; y += step) { for (int x = 0; x < w; x += step) { const size_t i = size_t(y * w + x); best = std::max(best, a[i] - b[i]); } } return best; } std::vector captureLumaGrid(Graphics *gfx, int step = 4) { const int w = gfx->getWidth(); const int h = gfx->getHeight(); std::vector out(size_t(w * h), 0.f); for (int y = 0; y < h; y += step) { for (int x = 0; x < w; x += step) { out[size_t(y * w + x)] = luma(gfx->getPixel(x, y)); } } return out; } void setupShadowScene(Graphics *gfx, Renderable3D *&ground, Light3D *&sun, bool groundReceive, Renderable3D **caster = nullptr) { auto *cam = Camera3D::createCamera(); cam->setEye(0.f, 3.5f, 5.5f); cam->setTarget(0.f, 0.f, 0.f); cam->setAmbient(0.05f, 0.05f, 0.05f); ground = Renderable3D::create(); ground->setMesh(makeGroundPlane(gfx)); ground->setTexture(makeSolid(gfx, 220, 220, 220)); ground->setPosition(0.f, -1.2f, 0.f); ground->setScale(4.f, 0.15f, 4.f); ground->setMetallic(0.f); ground->setRoughness(0.85f); ground->setCastShadow(false); ground->setReceiveShadow(groundReceive); auto *box = Renderable3D::create(); box->setMesh(gfx->newMeshSphere(20, 12)); box->setTexture(makeSolid(gfx, 200, 200, 200)); box->setPosition(0.f, 0.35f, 0.f); box->setScale(0.55f, 0.55f, 0.55f); box->setMetallic(0.f); box->setRoughness(0.6f); box->setCastShadow(true); box->setReceiveShadow(false); if (caster) *caster = box; sun = Light3D::createLight("dir"); sun->setDirection(0.55f, 1.f, 0.35f); sun->setColor(1.f, 1.f, 1.f, 2.5f); sun->setCastShadow(true); sun->setShadowStrength(1.f); sun->setShadowBias(0.003f); addHud(); } } // namespace TEST_CASE("Shadow3D.dirLightDarkensOccludedGround") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Renderable3D *ground = nullptr; Light3D *sun = nullptr; setupShadowScene(gfx, ground, sun, true); (void)ground; gfx->setScreenReadbackEnabled(true); sun->setCastShadow(false); warmPresent(gfx); const auto L_off = captureLumaGrid(gfx); sun->setCastShadow(true); warmPresent(gfx); const auto L_on = captureLumaGrid(gfx); // Enabling CSM must darken some receive-shadow texels (umbra under the occluder). REQUIRE(maxLumaDelta(gfx, L_off, L_on) > 0.03f); // With shadows on, some lit ground should still be brighter than some umbra texel. float brightest = 0.f, darkestGeom = 1.f; const int w = gfx->getWidth(); const int h = gfx->getHeight(); for (int y = 0; y < h; y += 4) { for (int x = 0; x < w; x += 4) { const float L = L_on[size_t(y * w + x)]; if (L < 0.15f) continue; // skip clear / fully ambient brightest = std::max(brightest, L); darkestGeom = std::min(darkestGeom, L); } } REQUIRE(brightest > darkestGeom + 0.02f); previewScene(gfx); win->close(); } TEST_CASE("Shadow3D.pcgLodTwoSidedCastsBackFacingGeometry") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Renderable3D *ground = nullptr; Renderable3D *caster = nullptr; Light3D *sun = nullptr; setupShadowScene(gfx, ground, sun, true, &caster); (void)ground; const float pos[] = {-0.8f, 0.f, -0.8f, 0.8f, 0.f, -0.8f, 0.8f, 0.f, 0.8f, -0.8f, 0.f, 0.8f}; const float nrm[] = {0.f, -1.f, 0.f, 0.f, -1.f, 0.f, 0.f, -1.f, 0.f, 0.f, -1.f, 0.f}; const float uv[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f}; const uint32_t idxA[] = {0, 1, 2, 0, 2, 3}; const uint32_t idxB[] = {0, 2, 1, 0, 3, 2}; Mesh *windingA = gfx->newMeshFromArrays(pos, nrm, uv, 4, idxA, 6); Mesh *windingB = gfx->newMeshFromArrays(pos, nrm, uv, 4, idxB, 6); REQUIRE(windingA != nullptr); REQUIRE(windingB != nullptr); caster->setPosition(0.f, 0.35f, 0.f); caster->setScale(1.f, 1.f, 1.f); caster->setMeshLod(0, windingA); gfx->setScreenReadbackEnabled(true); auto shadowDelta = [&](Mesh *mesh, int shadowMode) { caster->setMeshLod(0, mesh); REQUIRE(caster->setMeshLodRendererState(0, 0, shadowMode, true, 1, true, 1, 1).ok()); sun->setCastShadow(false); warmPresent(gfx); const auto off = captureLumaGrid(gfx); sun->setCastShadow(true); warmPresent(gfx); const auto on = captureLumaGrid(gfx); return maxLumaDelta(gfx, off, on); }; const float doubleA = shadowDelta(windingA, 2); const float doubleB = shadowDelta(windingB, 2); const float singleA = shadowDelta(windingA, 1); const float singleB = shadowDelta(windingB, 1); REQUIRE(std::min(doubleA, doubleB) > 0.02f); REQUIRE(std::min(singleA, singleB) + 0.015f < std::min(doubleA, doubleB)); win->close(); } TEST_CASE("Shadow3D.pcgSpeedTreeLodKeepsComplementaryCasterCoverage") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Renderable3D *ground = nullptr; Renderable3D *caster = nullptr; Light3D *sun = nullptr; setupShadowScene(gfx, ground, sun, true, &caster); (void)ground; Mesh *sphere = caster->getMesh(); caster->setMeshLod(0, sphere); caster->setMeshLod(1, sphere, 10.f); REQUIRE(caster->setMeshLodFadeWidth(0, 1.f).ok()); REQUIRE(caster->setMeshLodFadePolicy(1, false, .5f).ok()); gfx->setScreenReadbackEnabled(true); sun->setCastShadow(false); warmPresent(gfx); const auto off = captureLumaGrid(gfx); sun->setCastShadow(true); warmPresent(gfx); const auto on = captureLumaGrid(gfx); CHECK(maxLumaDelta(gfx, off, on) > .03f); win->close(); } TEST_CASE("Shadow3D.receiveShadowFalseIgnoresMap") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Renderable3D *ground = nullptr; Light3D *sun = nullptr; setupShadowScene(gfx, ground, sun, false); (void)sun; gfx->setScreenReadbackEnabled(true); warmPresent(gfx); const auto L_offRecv = captureLumaGrid(gfx); ground->setReceiveShadow(true); warmPresent(gfx); const auto L_onRecv = captureLumaGrid(gfx); // Turning receiveShadow on must apply the shadow map and darken umbra texels. REQUIRE(maxLumaDelta(gfx, L_offRecv, L_onRecv) > 0.02f); previewScene(gfx); win->close(); } TEST_CASE("Shadow3D.shadowStrengthInterpolatesVisibility") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Renderable3D *ground = nullptr; Light3D *sun = nullptr; setupShadowScene(gfx, ground, sun, true); (void)ground; gfx->setScreenReadbackEnabled(true); sun->setShadowStrength(0.f); warmPresent(gfx); const auto off = captureLumaGrid(gfx); sun->setShadowStrength(0.5f); warmPresent(gfx); const auto half = captureLumaGrid(gfx); sun->setShadowStrength(1.f); warmPresent(gfx); const auto full = captureLumaGrid(gfx); size_t strongest = 0; float strongestDelta = 0.f; for (size_t i = 0; i < off.size(); ++i) { const float delta = off[i] - full[i]; if (delta > strongestDelta) { strongestDelta = delta; strongest = i; } } REQUIRE(strongestDelta > 0.02f); const float halfDelta = off[strongest] - half[strongest]; REQUIRE(halfDelta > 0.f); REQUIRE(halfDelta < strongestDelta); win->close(); } TEST_CASE("Shadow3D.maskedCasterUsesAlbedoAlpha") { eve::window::Window *win = nullptr; Graphics *gfx = nullptr; openGfxWindow(win, gfx); resetScene3D(); Renderable3D *ground = nullptr; Renderable3D *caster = nullptr; Light3D *sun = nullptr; setupShadowScene(gfx, ground, sun, true, &caster); (void)ground; (void)sun; REQUIRE(caster != nullptr); const uint8_t transparentPx[4] = {220, 220, 220, 0}; Texture *transparent = gfx->newTexture(1, 1, transparentPx); Texture *opaque = makeSolid(gfx, 220, 220, 220); REQUIRE(transparent != nullptr); REQUIRE(opaque != nullptr); Material masked; masked.setSurfaceMode("masked"); masked.setAlphaCutoff(0.5f); masked.setCastShadow(true); masked.setReceiveShadow(false); caster->setMaterial(&masked); gfx->setScreenReadbackEnabled(true); masked.setAlbedoTexture(transparent); warmPresent(gfx); const auto transparentCaster = captureLumaGrid(gfx); masked.setAlbedoTexture(opaque); warmPresent(gfx); const auto opaqueCaster = captureLumaGrid(gfx); REQUIRE(maxLumaDelta(gfx, transparentCaster, opaqueCaster) > 0.02f); win->close(); } TEST_CASE("Shadow.directionalCSM.clampsFarAndSplitsAreViewZ") { const glm::vec3 dir(0.3f, 1.f, 0.2f); const glm::vec3 eye(0.f, 2.f, 8.f); const glm::vec3 target(0.f, 1.f, 0.f); const glm::vec3 up(0.f, 1.f, 0.f); ShadowUpload u = buildDirectionalCSM(dir, eye, target, up, 1.047f, 16.f / 9.f, 0.1f, 500.f, 0.002f, 1.f); REQUIRE(u.active); // Far used for splits must be the practical shadow distance, not camera far=500. CHECK(u.ubo.splits.x > 0.1f); CHECK(u.ubo.splits.x < u.ubo.splits.y); CHECK(u.ubo.splits.y <= u.ubo.splits.z + 1e-4f); CHECK(u.ubo.splits.z <= ShadowConfig::kMaxDistance + 1e-3f); CHECK(u.ubo.splits.z < 100.f); CHECK(u.ubo.bias.x >= 0.002f); }