End-to-end parity testing (gpuDriven on/off pixel comparison) exposed and fixed several shading-path bugs, and documented two hard AMD driver limitations on this machine (Radeon AI PRO R9700): 1. Missing ambient in the GPU-driven frame UBO: the per-frame UBO only set ambient.w; ambient.rgb came from stale state. Now mirrors the legacy draw path (mesh3dLighting.ambient). 2. Normal-map fallback: materials without a normal texture clamped to the bindless slot 0, which is the WHITE placeholder - the legacy path uses a (128,128,255) flat-normal texture. The fragment shader now skips applyNormalMap when the material has no normal texture. 3. AMD driver limitation A: vkUpdateDescriptorSets on the sampler array binding hangs for writes of >= 4 elements; placeholder fill uses single-element writes (~1.1k calls at init, ~3 ms). 4. AMD driver limitation B: descriptor-array dynamic indexing only returns correct results for element 0. Scenes with more than one distinct texture therefore fall back to the legacy path via the new gpuDrivenMaterialUsable() gate (RenderSystem3D eligibility check). The proper fix (texture-array/atlas with layer indexing) is Phase 3 work. Adds GpuDriven.opaqueForwardParity: renders the same single-texture scene with the legacy and GPU-driven paths and asserts per-pixel luma agreement (max delta < 0.03). Verified passing in a clean process; the AMD driver wedges intermittently under repeated test runs on this machine, which is tracked separately. The gpuDriven feature remains opt-in (RenderControl, default OFF), so default rendering is unchanged.
192 lines
5.9 KiB
C++
192 lines
5.9 KiB
C++
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include <SDL2/SDL.h>
|
|
|
|
#include "graphics/Graphics.h"
|
|
#include "graphics/Light.h"
|
|
#include "graphics/Material.h"
|
|
#include "graphics/Mesh.h"
|
|
#include "graphics/RenderControl.h"
|
|
#include "graphics/RenderSystem.h"
|
|
#include "graphics/RenderSystem3D.h"
|
|
#include "graphics/Texture.h"
|
|
#include "graphics/vulkan/Graphics.h"
|
|
#include "graphics/vulkan/GpuDriven.h"
|
|
#include "window/Window.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
using namespace eve::graphics;
|
|
|
|
namespace {
|
|
|
|
void openGfxWindow(eve::window::Window *&win, Graphics *&gfx, int w = 320, int h = 240) {
|
|
win = eve::window::Window::create();
|
|
gfx = Graphics::create();
|
|
REQUIRE(win != nullptr);
|
|
REQUIRE(gfx != nullptr);
|
|
win->setGraphics(gfx);
|
|
eve::window::WindowSettings s;
|
|
s.width = w;
|
|
s.height = h;
|
|
s.centered = true;
|
|
REQUIRE(win->setWindowSettings(s));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("GpuDriven.capsAvailable") {
|
|
eve::window::Window *win = nullptr;
|
|
Graphics *gfx = nullptr;
|
|
openGfxWindow(win, gfx);
|
|
auto *vg = dynamic_cast<eve::graphics::vulkan::Graphics *>(gfx);
|
|
REQUIRE(vg != nullptr);
|
|
CHECK(vg->gpuDrivenCaps().gpuDrivenAvailable());
|
|
win->close();
|
|
}
|
|
|
|
TEST_CASE("GpuDriven.bindlessTextureRegistration") {
|
|
eve::window::Window *win = nullptr;
|
|
Graphics *gfx = nullptr;
|
|
openGfxWindow(win, gfx);
|
|
auto *vg = dynamic_cast<eve::graphics::vulkan::Graphics *>(gfx);
|
|
REQUIRE(vg != nullptr);
|
|
if (!vg->gpuDrivenCaps().gpuDrivenAvailable()) {
|
|
win->close();
|
|
return; // environment without 1.2 features: nothing to validate
|
|
}
|
|
|
|
const uint8_t red[4] = {255, 0, 0, 255};
|
|
const uint8_t green[4] = {0, 255, 0, 255};
|
|
Texture *a = gfx->newTexture(1, 1, red);
|
|
Texture *b = gfx->newTexture(1, 1, green);
|
|
const uint32_t sa = vg->debugBindlessIndex(a);
|
|
const uint32_t sb = vg->debugBindlessIndex(b);
|
|
CHECK(sa != eve::graphics::vulkan::kInvalidBindlessSlot);
|
|
CHECK(sb != eve::graphics::vulkan::kInvalidBindlessSlot);
|
|
CHECK(sa != sb); // distinct slots
|
|
|
|
const std::vector<uint8_t> cubePx(6 * 4, 128);
|
|
Texture *cube = gfx->newCubemap(1, cubePx.data());
|
|
auto *gpuCube = static_cast<eve::graphics::vulkan::GpuTexture *>(cube->gpuHandle);
|
|
CHECK(gpuCube->bindlessIndexCube != eve::graphics::vulkan::kInvalidBindlessSlot);
|
|
win->close();
|
|
}
|
|
|
|
TEST_CASE("GpuDriven.meshTableRegistration") {
|
|
eve::window::Window *win = nullptr;
|
|
Graphics *gfx = nullptr;
|
|
openGfxWindow(win, gfx);
|
|
auto *vg = dynamic_cast<eve::graphics::vulkan::Graphics *>(gfx);
|
|
REQUIRE(vg != nullptr);
|
|
if (!vg->gpuDrivenCaps().gpuDrivenAvailable()) {
|
|
win->close();
|
|
return;
|
|
}
|
|
|
|
Mesh *m = gfx->newMeshSphere(8, 4);
|
|
REQUIRE(m != nullptr);
|
|
const uint32_t idx = vg->debugMeshRecordIndex(m);
|
|
CHECK(idx != eve::graphics::vulkan::kInvalidBindlessSlot);
|
|
auto *gpu = static_cast<eve::graphics::vulkan::GpuMesh *>(m->gpuHandle);
|
|
CHECK(gpu->record.vertexCount > 0);
|
|
CHECK(gpu->record.indexCount > 0);
|
|
CHECK(gpu->record.boundsCenterRadius.w > 0.f); // bounds computed at upload
|
|
win->close();
|
|
}
|
|
|
|
namespace {
|
|
|
|
float gdLuma(const Color &c) { return (c.r + c.g + c.b) / 3.f; }
|
|
|
|
void gdWarmPresent(Graphics *gfx) {
|
|
for (int i = 0; i < 4; ++i) {
|
|
RenderSystem3D::render(*gfx);
|
|
RenderSystem::render(*gfx);
|
|
}
|
|
}
|
|
|
|
std::vector<float> gdCaptureLuma(Graphics *gfx) {
|
|
const int w = gfx->getWidth();
|
|
const int h = gfx->getHeight();
|
|
std::vector<float> out(size_t(w) * size_t(h), 0.f);
|
|
for (int y = 0; y < h; y += 4) {
|
|
for (int x = 0; x < w; x += 4) {
|
|
out[size_t(y * w + x)] = gdLuma(gfx->getPixel(x, y));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Texture *gdSolid(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);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
/**
|
|
* @brief The opaque forward pass must produce the same image with the legacy
|
|
* per-draw path and the GPU-driven (indirect + bindless) path.
|
|
*/
|
|
TEST_CASE("GpuDriven.opaqueForwardParity") {
|
|
eve::window::Window *win = nullptr;
|
|
Graphics *gfx = nullptr;
|
|
openGfxWindow(win, gfx, 320, 240);
|
|
auto *vg = dynamic_cast<eve::graphics::vulkan::Graphics *>(gfx);
|
|
REQUIRE(vg != nullptr);
|
|
if (!vg->gpuDrivenCaps().gpuDrivenAvailable()) {
|
|
win->close();
|
|
return;
|
|
}
|
|
|
|
auto *cam = Camera3D::createCamera();
|
|
cam->setEye(0.f, 3.5f, 5.5f);
|
|
cam->setTarget(0.f, 0.f, 0.f);
|
|
cam->setAmbient(0.08f, 0.08f, 0.10f);
|
|
|
|
Material *ballMat = gfx->newMaterial();
|
|
ballMat->setAlbedoTexture(gdSolid(gfx, 205, 70, 60)); // single texture -> slot 0
|
|
ballMat->setNormalTexture(nullptr);
|
|
ballMat->setRoughness(0.5f);
|
|
ballMat->setMetallic(0.1f);
|
|
auto *ball = Renderable3D::create();
|
|
ball->setMesh(gfx->newMeshSphere(24, 16));
|
|
ball->setMaterial(ballMat);
|
|
ball->setPosition(0.f, 0.35f, 0.f);
|
|
ball->setScale(0.55f, 0.55f, 0.55f);
|
|
|
|
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); // isolate the direct-light path first
|
|
|
|
gfx->setScreenReadbackEnabled(true);
|
|
RenderControl *rc = gfx->getRenderControl();
|
|
rc->disable("gpuDriven");
|
|
gdWarmPresent(gfx);
|
|
const auto legacy = gdCaptureLuma(gfx);
|
|
|
|
rc->enable("gpuDriven");
|
|
gdWarmPresent(gfx);
|
|
const auto gpuDriven = gdCaptureLuma(gfx);
|
|
|
|
// Same shading source, different emission path: allow small float noise.
|
|
float maxDelta = 0.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 size_t i = size_t(y * w + x);
|
|
const float d = std::fabs(legacy[i] - gpuDriven[i]);
|
|
maxDelta = std::max(maxDelta, d);
|
|
}
|
|
}
|
|
rc->disable("gpuDriven");
|
|
CHECK(maxDelta < 0.03f);
|
|
win->close();
|
|
}
|