#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include "Fixtures.h" #include "graphics/Graphics.h" #include "graphics/Light.h" #include "graphics/Material.h" #include "graphics/RenderControl.h" #include "graphics/RenderSystem.h" #include "graphics/RenderSystem3D.h" #include "graphics/Texture.h" #include "window/Window.h" #ifdef EVENGINE_WEBGPU #include "graphics/webgpu/Graphics.h" #endif #include #include #include #include using namespace eve::graphics; namespace { std::vector captureLuma(Graphics *gfx) { for (int i = 0; i < 4; ++i) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } const int width = gfx->getWidth(); const int height = gfx->getHeight(); std::vector pixels; pixels.reserve(size_t(width / 4) * size_t(height / 4)); for (int y = 0; y < height; y += 4) { for (int x = 0; x < width; x += 4) { const Color c = gfx->getPixel(x, y); pixels.push_back((c.r + c.g + c.b) / 3.f); } } return pixels; } } // namespace /** @brief Backend-neutral parity for opaque GPU culling and indirect submission. */ TEST_CASE("GpuDrivenParity.opaqueStage1") { eve::window::Window *window = nullptr; Graphics *gfx = nullptr; openGfxWindow(window, gfx, 320, 240); if (!gfx->supportsGpuDriven3D()) { window->close(); return; } auto *camera = Camera3D::createCamera(); camera->setEye(0.f, 3.5f, 5.5f); camera->setTarget(0.f, 0.f, 0.f); camera->setAmbient(0.08f, 0.08f, 0.1f); const uint8_t red[4] = {210, 65, 55, 255}; const uint8_t green[4] = {55, 205, 85, 255}; Mesh *mesh = gfx->newMeshSphere(24, 16); auto addBall = [&](float x, float y, float z, const uint8_t *rgba) { Material *material = gfx->newMaterial(); material->setAlbedoTexture(gfx->newTexture(1, 1, rgba)); material->setRoughness(0.5f); auto *object = Renderable3D::create(); object->setMesh(mesh); object->setMaterial(material); object->setPosition(x, y, z); object->setScale(0.55f, 0.55f, 0.55f); }; addBall(-0.9f, 0.35f, 0.f, red); addBall(0.9f, 0.35f, 0.f, green); // Continue the eye-to-front-sphere ray so the farther, perspective-smaller // sphere is completely occluded. This keeps HZB rejection pixel-neutral. addBall(-1.17f, -0.595f, -1.65f, red); addBall(0.f, 0.35f, 20.f, red); // Behind the camera; rejected by culling. auto *sun = Light3D::createLight("dir"); sun->setDirection(0.55f, 1.f, 0.35f); sun->setColor(1.f, 1.f, 1.f, 2.5f); sun->setCastShadow(false); gfx->setScreenReadbackEnabled(true); RenderControl *control = gfx->getRenderControl(); // Isolate opaque submission and reconstruction from temporal/post passes. control->disable("ao"); control->disable("gi"); control->disable("aa"); control->disable("atmosphere"); control->disable("msaa"); control->disable("gpuDriven"); const std::vector legacy = captureLuma(gfx); control->enable("gpuDriven"); const std::vector driven = captureLuma(gfx); REQUIRE(gfx->gpuDrivenEnabled()); #ifdef EVENGINE_WEBGPU auto *webgpu = dynamic_cast(gfx); REQUIRE(webgpu != nullptr); REQUIRE(webgpu->debugGpuDrivenVisibleCount() == 3); REQUIRE(webgpu->debugGpuDrivenHzbMipCount() > 1); REQUIRE(webgpu->debugGpuDrivenGpuVisibleCount() == 2); REQUIRE(webgpu->debugGpuDrivenDispatchCount() == 4); REQUIRE(webgpu->debugGpuDrivenIndirectDrawCount() == 2); #endif REQUIRE(legacy.size() == driven.size()); float maxDelta = 0.f; float legacySum = 0.f; float drivenSum = 0.f; float deltaSum = 0.f; size_t divergentPixels = 0; size_t maxIndex = 0; for (size_t i = 0; i < legacy.size(); ++i) { const float delta = std::abs(legacy[i] - driven[i]); if (delta > maxDelta) { maxDelta = delta; maxIndex = i; } legacySum += legacy[i]; drivenSum += driven[i]; deltaSum += delta; if (delta >= 0.03f) ++divergentPixels; } std::printf("GpuDrivenParity forward maxDelta=%f index=%zu legacy=%f driven=%f " "legacySum=%f drivenSum=%f meanDelta=%f divergent=%zu/%zu\n", maxDelta, maxIndex, legacy[maxIndex], driven[maxIndex], legacySum, drivenSum, deltaSum / float(legacy.size()), divergentPixels, legacy.size()); // Indirect and direct rasterization can select opposite samples along a // sub-pixel silhouette. Measure whole-frame agreement while still bounding // both aggregate error and the number of visibly divergent samples. REQUIRE(deltaSum / float(legacy.size()) < 0.01f); REQUIRE(divergentPixels * 100u < legacy.size() * 3u); // Stage 3: disable MSAA (visibility attachments are single-sample), then // compare the non-indexed vis pass + fullscreen reconstruction against // the stage-2 forward indirect path. control->disable("visResolve"); const std::vector forward1x = captureLuma(gfx); control->enable("visResolve"); const std::vector resolved = captureLuma(gfx); REQUIRE(gfx->gpuDrivenResolveWanted()); float resolveDelta = 0.f; for (size_t i = 0; i < forward1x.size(); ++i) resolveDelta = std::max(resolveDelta, std::abs(forward1x[i] - resolved[i])); REQUIRE(resolveDelta < 0.06f); control->disable("visResolve"); window->close(); } /** @brief Backend-neutral VG compute-cull, indirect-vis and resolve coverage. */ TEST_CASE("GpuDrivenParity.virtualGeometry") { eve::window::Window *window = nullptr; Graphics *gfx = nullptr; openGfxWindow(window, gfx, 320, 240); if (!gfx->supportsGpuDriven3D()) { window->close(); return; } const float positions[] = {-1.1f, -0.9f, 0.f, 1.1f, -0.9f, 0.f, 0.f, 1.1f, 0.f}; const float normals[] = {0.f, 0.f, 1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 1.f}; const uint32_t indices[] = {0, 1, 2}; GpuVgCluster cluster{}; auto bits = [](float value) { uint32_t out = 0; std::memcpy(&out, &value, sizeof(out)); return out; }; cluster.u0[0] = bits(0.f); cluster.u0[1] = bits(0.f); cluster.u0[2] = bits(0.f); cluster.u0[3] = bits(1.6f); cluster.u1[0] = 0; cluster.u1[1] = 1; cluster.u1[3] = 0xFFFFFFFFu; for (uint32_t &child : cluster.u3) child = 0xFFFFFFFFu; GpuVgAssetUpload upload{}; upload.positions = positions; upload.vertexCount = 3; upload.normals = normals; upload.triangles = indices; upload.triangleCount = 3; upload.clusters = &cluster; upload.clusterCount = 1; const uint32_t assetId = gfx->gpuDrivenVgUpload(upload); // Vulkan devices without drawIndirectCount still support the ordinary // GPU-driven path, but intentionally reject compacted VG command streams. if (assetId == kInvalidGpuDrivenSlot) { window->close(); return; } Mesh *mesh = gfx->newMeshFromArrays(positions, normals, nullptr, 3, indices, 3); REQUIRE(mesh != nullptr); REQUIRE(gfx->gpuDrivenVgAttachToMesh(mesh, assetId)); REQUIRE(gfx->gpuDrivenVgAssetId(mesh) == assetId); Material *material = gfx->newMaterial(); material->setTint(0.9f, 0.2f, 0.1f); auto *object = Renderable3D::create(); object->setMesh(mesh); object->setMaterial(material); object->setPosition(0.f, 0.f, 0.f); // A second copy lies directly behind the first one. It stays inside the // frustum but must disappear from the GPU-written indirect commands once // the previous-frame HZB is available. const uint32_t hiddenAssetId = gfx->gpuDrivenVgUpload(upload); REQUIRE(hiddenAssetId != kInvalidGpuDrivenSlot); Mesh *hiddenMesh = gfx->newMeshFromArrays(positions, normals, nullptr, 3, indices, 3); REQUIRE(gfx->gpuDrivenVgAttachToMesh(hiddenMesh, hiddenAssetId)); auto *hiddenObject = Renderable3D::create(); hiddenObject->setMesh(hiddenMesh); hiddenObject->setMaterial(material); hiddenObject->setPosition(0.f, 0.f, -4.f); auto *camera = Camera3D::createCamera(); camera->setEye(0.f, 0.f, 3.2f); camera->setTarget(0.f, 0.f, 0.f); camera->setAmbient(0.35f, 0.35f, 0.35f); auto *sun = Light3D::createLight("dir"); sun->setDirection(0.f, 0.f, -1.f); sun->setColor(1.f, 1.f, 1.f, 1.5f); sun->setCastShadow(false); gfx->setScreenReadbackEnabled(true); RenderControl *control = gfx->getRenderControl(); control->disable("ao"); control->disable("gi"); control->disable("aa"); control->disable("atmosphere"); control->disable("msaa"); control->enable("gpuDriven"); control->enable("visResolve"); captureLuma(gfx); REQUIRE(gfx->gpuDrivenResolveWanted()); #ifdef EVENGINE_WEBGPU const Color visible = gfx->getPixel(gfx->getWidth() / 2, gfx->getHeight() / 2); REQUIRE(visible.r > visible.b + 0.05f); auto *webgpu = dynamic_cast(gfx); REQUIRE(webgpu != nullptr); REQUIRE(webgpu->debugGpuDrivenVgDispatchCount() == 2); REQUIRE(webgpu->debugGpuDrivenVgGpuVisibleCount() == 1); REQUIRE(webgpu->debugGpuDrivenVgIndirectDrawCount() == 2); #endif control->disable("gpuDriven"); control->disable("visResolve"); window->close(); }