Files
EVEngine/test/graphics_backend_parity.cpp

944 lines
39 KiB
C++

#include "zeroerr/unittest.h"
#include "GraphicsParitySupport.h"
#include "common/config.h"
#include "filesystem/FileData.h"
#include "graphics/AlphaMask.h"
#include "graphics/Canvas.h"
#include "graphics/ClipSpace.h"
#include "graphics/Graphics.h"
#include "graphics/Light.h"
#include "graphics/Mesh.h"
#include "graphics/PrimitiveDrawList.h"
#include "graphics/RenderControl.h"
#include "graphics/Shader.h"
#include "graphics/Texture.h"
#include "graphics/TextureSampler.h"
#include "image/Image.h"
#include "image/ImageData.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld/graphics/PixelWorldGraphics.h"
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <glm/ext/matrix_clip_space.hpp>
#include <memory>
using namespace eve::graphics;
using namespace eve::graphics::parity_test;
TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const std::string backend = gfx->getBackendName();
REQUIRE(backend == expectedBackendName());
const uint8_t red[4] = {255, 0, 0, 255};
const uint8_t green[4] = {0, 255, 0, 255};
Texture *texture = gfx->newTexture(1, 1, red);
REQUIRE(texture != nullptr);
Texture *stable = texture;
REQUIRE(gfx->updateTexture(texture, 1, 1, green));
REQUIRE(texture == stable);
// Same-size streaming updates must retain the Vulkan image and descriptor.
// The former replacement path exhausted the shared descriptor pool after
// a few hundred frames in the volume-fluid presentation loop.
for (int frame = 0; frame < 400; ++frame) REQUIRE(gfx->updateTexture(texture, 1, 1, (frame & 1) ? red : green));
REQUIRE(!gfx->updateTexture(texture, 2, 1, green));
REQUIRE(gfx->updateTextureRegion(texture, 0, 0, 1, 1, red).ok());
CHECK(!gfx->updateTextureRegion(texture, 1, 0, 1, 1, red).ok());
CHECK(!gfx->updateTextureRegion(texture, 0, 0, 1, 1,
std::span<const uint8_t>(red, 3)).ok());
const TextureRegionUpload rejectedBatch[] = {
{0, 0, 1, 1, green, 0},
{1, 0, 1, 1, green, 0},
};
CHECK(!gfx->updateTextureRegions(texture, rejectedBatch).ok());
Canvas *canvas = gfx->newCanvas(64, 64);
REQUIRE(canvas != nullptr);
gfx->setCanvas(canvas);
gfx->clear(Color(0.f, 0.f, 1.f, 1.f), std::nullopt, std::nullopt);
gfx->drawTexturedRect(texture, 8.f, 8.f, 24.f, 24.f, Color(1.f));
gfx->drawSolidRect(32.f, 8.f, 24.f, 24.f, Color(1.f, 0.f, 0.f, 0.5f),
BlendMode::Alpha);
gfx->setCanvas();
std::unique_ptr<eve::image::ImageData> image(canvas->newImageData());
REQUIRE(image.get() != nullptr);
const uint8_t *background = pixel(*image, 2, 2);
REQUIRE(background[0] < 8);
REQUIRE(background[1] < 8);
REQUIRE(background[2] > 247);
const uint8_t *updated = pixel(*image, 16, 16);
REQUIRE(updated[0] > 247);
REQUIRE(updated[1] < 8);
REQUIRE(updated[2] < 8);
const uint8_t *blended = pixel(*image, 40, 16);
const bool redBlended = blended[0] >= 126 && blended[0] <= 129;
REQUIRE(redBlended);
REQUIRE(blended[1] < 8);
const bool blueBlended = blended[2] >= 126 && blended[2] <= 129;
REQUIRE(blueBlended);
writeParityArtifact(*image, "texture_update_alpha_blend", backend);
}
TEST_CASE("graphics.backendParity.pixelWorldMillionPixelAtlas") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
eve::pixelworld::PixelWorld world(4242);
// Radius 724 covers every cell in [0,1024)^2, creating a genuine one-million
// pixel viewport while also proving off-viewport chunks are clipped by the adapter.
REQUIRE(world.paintCircleChecked(512, 512, 724, "stone").ok());
eve::pixelworld_graphics::PixelWorldAtlasRenderer renderer(0, 0, 1024, 1024);
CHECK(renderer.sync(&world, gfx) == 256);
REQUIRE(renderer.texture() != nullptr);
CHECK(renderer.texture()->width == 1024);
CHECK(renderer.texture()->height == 1024);
CHECK(renderer.uploadCount() == 1);
CHECK(renderer.totalUploadedChunks() == 256);
CHECK(renderer.renderedRevision() == world.revision());
world.setMaterial(1, 1, "sand");
world.setMaterial(1022, 1022, "water");
CHECK(renderer.sync(&world, gfx) == 2);
CHECK(renderer.uploadCount() == 2);
CHECK(renderer.totalUploadedChunks() == 258);
CHECK(renderer.renderedRevision() == world.revision());
}
TEST_CASE("graphics.backendParity.draw2dUvRotationAndBlendModes") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const std::string backend = gfx->getBackendName();
const bool supportedBackend = backend == "vulkan" || backend == "webgpu";
REQUIRE(supportedBackend);
const uint8_t stripes[] = {
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
255, 255, 255, 255,
};
Texture *texture = gfx->newTexture(4, 1, stripes);
REQUIRE(texture != nullptr);
gfx->setTextureSampler(texture, TextureSampler::nearest());
Canvas *canvas = gfx->newCanvas(64, 64);
REQUIRE(canvas != nullptr);
gfx->setCanvas(canvas);
gfx->clear(Color(0.f, 0.f, 0.f, 1.f), std::nullopt, std::nullopt);
// Crop the second texel. Nearest sampling makes the expected green region exact.
gfx->drawTexturedRectShaderUV(texture, nullptr, 2.f, 2.f, 18.f, 12.f, 0.25f, 0.f, 0.5f,
1.f, Color(1.f), false, BlendMode::Opaque);
// A 90-degree turn changes the wide rectangle into a narrow vertical footprint.
gfx->drawSolidRectRotated(34.f, 10.f, 18.f, 6.f, 90.f, Color(1.f, 1.f, 0.f, 1.f),
BlendMode::Opaque);
gfx->drawTexturedRectShaderUVRotated(texture, nullptr, 50.f, 10.f, 18.f, 6.f, 90.f, 0.f,
0.f, 0.25f, 1.f, Color(1.f), false,
BlendMode::Opaque);
gfx->drawSolidRect(2.f, 28.f, 12.f, 12.f, Color(0.f, 0.f, 0.5f, 1.f), BlendMode::Opaque);
gfx->drawSolidRect(2.f, 28.f, 12.f, 12.f, Color(0.5f, 0.f, 0.f, 1.f),
BlendMode::Additive);
gfx->drawSolidRect(18.f, 28.f, 12.f, 12.f, Color(0.f, 0.f, 1.f, 1.f), BlendMode::Opaque);
gfx->drawSolidRect(18.f, 28.f, 12.f, 12.f, Color(0.25f, 0.f, 0.f, 0.25f),
BlendMode::Premultiplied);
gfx->drawSolidRect(34.f, 28.f, 12.f, 12.f, Color(0.5f, 1.f, 0.25f, 1.f),
BlendMode::Opaque);
gfx->drawSolidRect(34.f, 28.f, 12.f, 12.f, Color(0.5f, 0.25f, 1.f, 1.f),
BlendMode::Multiply);
gfx->setCanvas();
std::unique_ptr<eve::image::ImageData> image(canvas->newImageData());
REQUIRE(image.get() != nullptr);
const uint8_t *crop = pixel(*image, 10, 8);
REQUIRE(crop[0] < 8);
REQUIRE(crop[1] > 247);
REQUIRE(crop[2] < 8);
const uint8_t *rotatedSolid = pixel(*image, 34, 3);
REQUIRE(rotatedSolid[0] > 247);
REQUIRE(rotatedSolid[1] > 247);
REQUIRE(rotatedSolid[2] < 8);
const uint8_t *outsideSolid = pixel(*image, 27, 10);
REQUIRE(outsideSolid[0] < 8);
REQUIRE(outsideSolid[1] < 8);
REQUIRE(outsideSolid[2] < 8);
const uint8_t *rotatedTexture = pixel(*image, 50, 3);
REQUIRE(rotatedTexture[0] > 247);
REQUIRE(rotatedTexture[1] < 8);
REQUIRE(rotatedTexture[2] < 8);
const uint8_t *additive = pixel(*image, 8, 34);
const bool additiveRed = additive[0] >= 126 && additive[0] <= 129;
REQUIRE(additiveRed);
REQUIRE(additive[1] < 8);
const bool additiveBlue = additive[2] >= 126 && additive[2] <= 129;
REQUIRE(additiveBlue);
const uint8_t *premultiplied = pixel(*image, 24, 34);
const bool premultipliedRed = premultiplied[0] >= 62 && premultiplied[0] <= 65;
REQUIRE(premultipliedRed);
REQUIRE(premultiplied[1] < 8);
const bool premultipliedBlue = premultiplied[2] >= 190 && premultiplied[2] <= 193;
REQUIRE(premultipliedBlue);
const uint8_t *multiply = pixel(*image, 40, 34);
const bool multiplyRed = multiply[0] >= 62 && multiply[0] <= 65;
const bool multiplyGreen = multiply[1] >= 62 && multiply[1] <= 65;
const bool multiplyBlue = multiply[2] >= 62 && multiply[2] <= 65;
REQUIRE(multiplyRed);
REQUIRE(multiplyGreen);
REQUIRE(multiplyBlue);
writeParityArtifact(*image, "draw2d_uv_rotation_blend_modes", backend);
}
TEST_CASE("graphics.backendParity.lighting2dNormalMapAndMultipleLights") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const std::string backend = gfx->getBackendName();
const bool supportedBackend = backend == "vulkan" || backend == "webgpu";
REQUIRE(supportedBackend);
const uint8_t white[] = {255, 255, 255, 255};
const uint8_t flatNormal[] = {128, 128, 255, 255};
const uint8_t rightNormal[] = {255, 128, 128, 255};
Texture *albedo = gfx->newTexture(1, 1, white);
Texture *flat = gfx->newTexture(1, 1, flatNormal);
Texture *right = gfx->newTexture(1, 1, rightNormal);
REQUIRE(albedo != nullptr);
REQUIRE(flat != nullptr);
REQUIRE(right != nullptr);
Lighting2DUBO directional{};
directional.ambient = Color(0.05f, 0.05f, 0.05f, 0.f);
directional.meta = Color(1.f, 64.f, 64.f, 0.f);
directional.lights[0].posRadius = Color(1.f, 0.f, 0.f, 0.f);
directional.lights[0].color = Color(0.5f, 0.5f, 0.5f, 0.f);
Canvas *normalCanvas = gfx->newCanvas(64, 64);
REQUIRE(normalCanvas != nullptr);
gfx->setCanvas(normalCanvas);
gfx->clear(Color(0.f, 0.f, 0.f, 1.f), std::nullopt, std::nullopt);
gfx->setLighting2D(directional);
gfx->drawTexturedRectLitUV(albedo, flat, 4.f, 8.f, 24.f, 24.f, 0.f, 0.f, 1.f, 1.f,
Color(1.f));
gfx->drawTexturedRectLitUV(albedo, right, 36.f, 8.f, 24.f, 24.f, 0.f, 0.f, 1.f, 1.f,
Color(1.f));
gfx->setCanvas();
std::unique_ptr<eve::image::ImageData> normalImage(normalCanvas->newImageData());
REQUIRE(normalImage.get() != nullptr);
const uint8_t *flatLit = pixel(*normalImage, 16, 20);
const uint8_t *rightLit = pixel(*normalImage, 48, 20);
REQUIRE(flatLit[0] > 32);
REQUIRE(rightLit[0] > flatLit[0] + 50);
REQUIRE(rightLit[1] > flatLit[1] + 50);
REQUIRE(rightLit[2] > flatLit[2] + 50);
writeParityArtifact(*normalImage, "lighting2d_normal_map", backend);
Lighting2DUBO points{};
points.ambient = Color(0.02f, 0.02f, 0.02f, 0.f);
points.meta = Color(2.f, 64.f, 64.f, 0.f);
points.lights[0].posRadius = Color(10.f, 48.f, 0.f, 28.f);
points.lights[0].color = Color(0.9f, 0.f, 0.f, 0.f);
points.lights[1].posRadius = Color(54.f, 48.f, 0.f, 28.f);
points.lights[1].color = Color(0.f, 0.f, 0.9f, 0.f);
Canvas *pointCanvas = gfx->newCanvas(64, 64);
REQUIRE(pointCanvas != nullptr);
gfx->setCanvas(pointCanvas);
gfx->clear(Color(0.f, 0.f, 0.f, 1.f), std::nullopt, std::nullopt);
gfx->setLighting2D(points);
gfx->drawTexturedRectLitUV(albedo, flat, 2.f, 36.f, 60.f, 24.f, 0.f, 0.f, 1.f, 1.f,
Color(1.f));
gfx->setCanvas();
std::unique_ptr<eve::image::ImageData> pointImage(pointCanvas->newImageData());
REQUIRE(pointImage.get() != nullptr);
const uint8_t *leftLight = pixel(*pointImage, 10, 48);
const uint8_t *rightLight = pixel(*pointImage, 54, 48);
REQUIRE(leftLight[0] > leftLight[2] + 80);
REQUIRE(rightLight[2] > rightLight[0] + 80);
REQUIRE(leftLight[0] > 120);
REQUIRE(rightLight[2] > 120);
writeParityArtifact(*pointImage, "lighting2d_multiple_lights", backend);
}
TEST_CASE("graphics.backendParity.textureSamplingRepeatAndMipmaps") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
REQUIRE(gfx->getMaxAnisotropy() >= 1.f);
const std::string backend = gfx->getBackendName();
const bool supportedBackend = backend == "vulkan" || backend == "webgpu";
REQUIRE(supportedBackend);
const uint8_t redBlue[] = {255, 0, 0, 255, 0, 0, 255, 255};
Texture *nearestTexture = gfx->newTexture(2, 1, redBlue);
Texture *repeatTexture = gfx->newTexture(2, 1, redBlue);
Texture *linearTexture = gfx->newTexture(2, 1, redBlue);
REQUIRE(nearestTexture != nullptr);
REQUIRE(repeatTexture != nullptr);
REQUIRE(linearTexture != nullptr);
uint8_t checker[4 * 4]{};
for (int x = 0; x < 4; ++x) {
uint8_t *p = checker + x * 4;
p[x % 2 == 0 ? 0 : 1] = 255;
p[3] = 255;
}
TextureCreateInfo mipInfo;
mipInfo.generateMipmaps = true;
Texture *mipped = gfx->newTexture(4, 1, checker, mipInfo);
REQUIRE(mipped != nullptr);
REQUIRE(mipped->getMipmapCount() == 3);
const bool mipSamplingEnabled = mipped->getSampler().mipmap == MipmapMode::Linear;
REQUIRE(mipSamplingEnabled);
Canvas *canvas = gfx->newCanvas(64, 64);
REQUIRE(canvas != nullptr);
gfx->setCanvas(canvas);
gfx->clear(Color(0.f, 0.f, 0.f, 1.f), std::nullopt, std::nullopt);
gfx->setTextureSampler(nearestTexture, TextureSampler::nearest());
gfx->drawTexturedRectShaderUV(nearestTexture, nullptr, 2.f, 2.f, 28.f, 12.f, 0.f, 0.f, 1.f, 1.f,
Color(1.f), false, BlendMode::Opaque);
TextureSampler repeat = TextureSampler::nearest();
repeat.repeatU = true;
gfx->setTextureSampler(repeatTexture, repeat);
gfx->drawTexturedRectShaderUV(repeatTexture, nullptr, 34.f, 2.f, 28.f, 12.f, 0.f, 0.f, 2.f, 1.f,
Color(1.f), false, BlendMode::Opaque);
gfx->setTextureSampler(linearTexture, TextureSampler::linear());
gfx->drawTexturedRectShaderUV(linearTexture, nullptr, 2.f, 20.f, 28.f, 12.f, 0.f, 0.f, 1.f, 1.f,
Color(1.f), false, BlendMode::Opaque);
TextureSampler lastMip = TextureSampler::nearest();
lastMip.mipmap = MipmapMode::Nearest;
lastMip.minLod = 2.f;
lastMip.maxLod = 2.f;
gfx->setTextureSampler(mipped, lastMip);
gfx->drawTexturedRect(mipped, 34.f, 20.f, 28.f, 28.f, Color(1.f));
gfx->setCanvas();
std::unique_ptr<eve::image::ImageData> image(canvas->newImageData());
REQUIRE(image.get() != nullptr);
const uint8_t *nearestLeft = pixel(*image, 8, 8);
const uint8_t *nearestRight = pixel(*image, 24, 8);
REQUIRE(nearestLeft[0] > 247);
REQUIRE(nearestLeft[2] < 8);
REQUIRE(nearestRight[0] < 8);
REQUIRE(nearestRight[2] > 247);
const uint8_t *repeat0 = pixel(*image, 38, 8);
const uint8_t *repeat1 = pixel(*image, 45, 8);
const uint8_t *repeat2 = pixel(*image, 52, 8);
const uint8_t *repeat3 = pixel(*image, 59, 8);
REQUIRE(repeat0[0] > 247);
REQUIRE(repeat1[2] > 247);
REQUIRE(repeat2[0] > 247);
REQUIRE(repeat3[2] > 247);
const uint8_t *linearBoundary = pixel(*image, 16, 26);
REQUIRE(linearBoundary[0] > 96);
REQUIRE(linearBoundary[2] > 96);
const uint8_t *mip = pixel(*image, 48, 34);
const bool mipRed = mip[0] >= 126 && mip[0] <= 129;
const bool mipGreen = mip[1] >= 126 && mip[1] <= 129;
REQUIRE(mipRed);
REQUIRE(mipGreen);
REQUIRE(mip[2] < 8);
writeParityArtifact(*image, "texture_sampling_repeat_mipmaps", backend);
}
TEST_CASE("graphics.backendParity.webgpuCustomShaderLifetime") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
REQUIRE(gfx->getBackendName() == expectedBackendName());
if (gfx->getBackendName() != "webgpu") {
return;
}
const char *frag = R"wgsl(
struct FSIn {
@location(0) color: vec4f,
@location(1) uv: vec2f,
};
@group(0) @binding(0) var mainTex: texture_2d<f32>;
@group(0) @binding(2) var mainSamp: sampler;
@fragment
fn fs_main(in: FSIn) -> @location(0) vec4f {
let sampled = textureSample(mainTex, mainSamp, in.uv);
return vec4f(sampled.r * 0.25, sampled.g * 0.5, sampled.b, sampled.a) * in.color;
}
)wgsl";
Shader *shader = gfx->newShaderFromWgsl({}, frag);
REQUIRE(shader != nullptr);
REQUIRE(shader->gpuHandle != nullptr);
const uint8_t white[4] = {255, 255, 255, 255};
Texture *texture = gfx->newTexture(1, 1, white);
Canvas *canvas = gfx->newCanvas(64, 64);
REQUIRE(texture != nullptr);
REQUIRE(canvas != nullptr);
gfx->setCanvas(canvas);
gfx->clear(Color(0.f, 0.f, 0.f, 1.f), std::nullopt, std::nullopt);
gfx->drawTexturedRectShader(texture, shader, 8.f, 8.f, 48.f, 48.f,
Color(1.f, 1.f, 1.f, 1.f));
gfx->setCanvas(nullptr);
const Color center = canvas->getPixel(32, 32);
REQUIRE(center.r > 0.20f);
REQUIRE(center.r < 0.30f);
REQUIRE(center.g > 0.45f);
REQUIRE(center.g < 0.55f);
REQUIRE(center.b > 0.95f);
}
TEST_CASE("graphics.backendParity.dynamicMeshUpdate") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const float positions[] = {
-1.f, -1.f, 0.f,
1.f, -1.f, 0.f,
0.f, 1.f, 0.f,
};
const uint32_t indices[] = {0, 1, 2};
Mesh *mesh = gfx->newMeshFromArrays(positions, nullptr, nullptr, 3, indices, 3);
REQUIRE(mesh != nullptr);
void *stableGpuHandle = mesh->gpuHandle;
const float moved[] = {
-2.f, -1.f, 0.f,
2.f, -1.f, 0.f,
0.f, 2.f, 0.f,
};
REQUIRE(gfx->updateMeshVertices(mesh, moved, nullptr, nullptr, 3, nullptr, 0));
REQUIRE(mesh->gpuHandle == stableGpuHandle);
REQUIRE(mesh->gpuVertexCount == 3);
REQUIRE(mesh->indexCount == 3);
const uint32_t invalid[] = {0, 1, 3};
REQUIRE(!gfx->updateMeshVertices(mesh, moved, nullptr, nullptr, 3, invalid, 3));
}
TEST_CASE("graphics.backendParity.gbufferAlphaCutout") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const float 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 float 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 float uvs[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f};
// These 3D parity fixtures use identity clip transforms rather than
// perspectiveVulkanRH_ZO, so reverse the quad to preserve the engine's
// framebuffer-space CCW front-face convention.
const uint32_t indices[] = {0, 2, 1, 2, 0, 3};
Mesh *mesh = gfx->newMeshFromArrays(positions, normals, uvs, 4, indices, 6);
REQUIRE(mesh != nullptr);
const uint8_t cutoutTexture[] = {255, 0, 0, 0, 255, 0, 0, 255};
Texture *albedo = gfx->newTexture(2, 1, cutoutTexture);
REQUIRE(albedo != nullptr);
gfx->beginGBufferPass(64, 64);
gfx->drawMeshGBufferAlpha(mesh, glm::mat4(1.f), glm::mat4(1.f), 0.1f, 100.f, albedo);
gfx->endGBufferPass();
std::unique_ptr<eve::image::ImageData> image(gfx->readGBufferToImageData("albedo"));
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, "gbuffer_alpha_cutout", gfx->getBackendName());
const uint8_t *discarded = pixel(*image, 16, 16);
REQUIRE(discarded[0] < 8);
const uint8_t *kept = pixel(*image, 48, 16);
REQUIRE(kept[0] > 247);
REQUIRE(kept[1] < 8);
REQUIRE(kept[2] < 8);
std::unique_ptr<eve::image::ImageData> normal(gfx->readGBufferToImageData("normal"));
REQUIRE(normal.get() != nullptr);
const uint8_t *encodedNormal = pixel(*normal, 48, 16);
const bool normalXCentered = encodedNormal[0] >= 127 && encodedNormal[0] <= 128;
const bool normalYCentered = encodedNormal[1] >= 127 && encodedNormal[1] <= 128;
REQUIRE(normalXCentered);
REQUIRE(normalYCentered);
REQUIRE(encodedNormal[2] > 247);
writeParityArtifact(*normal, "gbuffer_world_normal", gfx->getBackendName());
std::unique_ptr<eve::image::ImageData> depth(gfx->readGBufferToImageData("depth"));
REQUIRE(depth.get() != nullptr);
const uint8_t *linearDepth = pixel(*depth, 48, 16);
const bool expectedLinearDepth = linearDepth[0] <= 1;
REQUIRE(expectedLinearDepth);
REQUIRE(linearDepth[1] == linearDepth[0]);
REQUIRE(linearDepth[2] == linearDepth[0]);
writeParityArtifact(*depth, "gbuffer_linear_depth", gfx->getBackendName());
}
TEST_CASE("graphics.backendParity.decalLayerProjection") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const float 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 float 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 float uvs[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f};
const uint32_t indices[] = {0, 2, 1, 2, 0, 3};
Mesh *mesh = gfx->newMeshFromArrays(positions, normals, uvs, 4, indices, 6);
REQUIRE(mesh != nullptr);
const uint8_t white[] = {255, 255, 255, 255};
const uint8_t red[] = {200, 20, 20, 255};
Texture *base = gfx->newTexture(1, 1, white);
Texture *decal = gfx->newTexture(1, 1, red);
REQUIRE(base != nullptr);
REQUIRE(decal != nullptr);
gfx->beginGBufferPass(64, 64);
gfx->drawMeshGBuffer(mesh, glm::mat4(1.f), glm::mat4(1.f), 0.1f, 100.f, base);
gfx->endGBufferPass();
gfx->beginDecalPass(64, 64);
gfx->setDecalCamera(glm::mat4(1.f), 0.1f, 100.f);
glm::mat4 decalModel(1.f);
decalModel[2][2] = 2.f;
gfx->drawDecal(decalModel, decal, nullptr, nullptr, nullptr, 1.f, 0.f, 0.f, 0.f, 0.f);
gfx->endDecalPass();
std::unique_ptr<eve::image::ImageData> image(gfx->readDecalLayerToImageData("albedo"));
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, "decal_layer_projection", gfx->getBackendName());
const uint8_t *center = pixel(*image, 32, 32);
const bool decalCenterRed = center[0] > 150 && center[1] < 80;
REQUIRE(decalCenterRed);
const uint8_t *corner = pixel(*image, 2, 2);
REQUIRE(corner[0] < 20);
Lighting3DPack lighting{};
lighting.ambient = glm::vec4(1.f, 1.f, 1.f, 0.f);
gfx->setMesh3DLighting(lighting);
gfx->setMesh3DViewProj(glm::mat4(1.f));
gfx->setMesh3DView(glm::mat4(1.f));
gfx->setMesh3DCameraPos(glm::vec3(0.f, 0.f, 3.f));
Canvas *forwardTarget = gfx->newCanvas(64, 64);
REQUIRE(forwardTarget != nullptr);
gfx->begin3DFrameToCanvas(forwardTarget);
gfx->drawMesh(mesh, glm::mat4(1.f), base, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> composited(forwardTarget->newImageData());
REQUIRE(composited.get() != nullptr);
const uint8_t *compositedCenter = pixel(*composited, 32, 32);
const bool centerHasRedDecal = compositedCenter[0] > compositedCenter[1] + 80;
REQUIRE(centerHasRedDecal);
const uint8_t *compositedCorner = pixel(*composited, 2, 2);
const bool cornerHasBase = compositedCorner[0] > 180 && compositedCorner[1] > 180;
REQUIRE(cornerHasBase);
writeParityArtifact(*composited, "decal_forward_composite", gfx->getBackendName());
}
TEST_CASE("graphics.backendParity.pbrNormalParallaxAndCellBomb") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const std::string backend = gfx->getBackendName();
const bool supportedBackend = backend == "vulkan" || backend == "webgpu";
REQUIRE(supportedBackend);
const float 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 float 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 float uvs[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f};
const uint32_t indices[] = {0, 2, 1, 2, 0, 3};
Mesh *mesh = gfx->newMeshFromArrays(positions, normals, uvs, 4, indices, 6);
REQUIRE(mesh != nullptr);
const uint8_t stripes[] = {
255, 32, 16, 255, 255, 32, 16, 255, 16, 32, 255, 255, 16, 32, 255, 255,
255, 32, 16, 255, 255, 32, 16, 255, 16, 32, 255, 255, 16, 32, 255, 255,
};
const uint8_t flatNormal[] = {128, 128, 255, 255};
const uint8_t tiltedNormal[] = {255, 128, 128, 255};
const uint8_t raisedHeight[] = {64, 64, 64, 255};
Texture *albedo = gfx->newTexture(8, 1, stripes);
Texture *flat = gfx->newTexture(1, 1, flatNormal);
Texture *tilted = gfx->newTexture(1, 1, tiltedNormal);
Texture *height = gfx->newTexture(1, 1, raisedHeight);
REQUIRE(albedo != nullptr);
REQUIRE(flat != nullptr);
REQUIRE(tilted != nullptr);
REQUIRE(height != nullptr);
gfx->setTextureSampler(albedo, TextureSampler::nearest());
Lighting3DPack lighting{};
lighting.ambient = glm::vec4(0.02f, 0.02f, 0.02f, 0.f);
lighting.count = 1;
lighting.lights[0].posRadius = glm::vec4(1.f, 0.f, 0.15f, 0.f);
lighting.lights[0].color = glm::vec4(0.85f, 0.85f, 0.85f, 1.f);
gfx->setMesh3DLighting(lighting);
gfx->setMesh3DViewProj(glm::mat4(1.f));
gfx->setMesh3DView(glm::mat4(1.f));
gfx->setMesh3DCameraPos(glm::vec3(1.2f, 0.f, 3.f));
gfx->setMesh3DMaterial(0.f, 0.8f);
auto render = [&](Texture *normal, Texture *heightMap, float parallax, float bombStrength,
const char *artifact) {
gfx->setMesh3DNormalTexture(normal);
gfx->setMesh3DHeightTexture(heightMap);
gfx->setMesh3DParallax(parallax, 12.f, 12.f);
gfx->setMesh3DTexCellBomb(4.f, bombStrength, 1.f);
Canvas *target = gfx->newCanvas(64, 64);
REQUIRE(target != nullptr);
gfx->begin3DFrameToCanvas(target);
gfx->drawMesh(mesh, glm::mat4(1.f), albedo, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> image(target->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, backend);
return image;
};
auto baseline = render(flat, height, 0.f, 0.f, "pbr_material_baseline");
auto normalMapped = render(tilted, height, 0.f, 0.f, "pbr_material_normal_map");
auto parallaxMapped = render(flat, height, 0.3f, 0.f, "pbr_material_parallax");
auto cellBombed = render(flat, height, 0.f, 1.f, "pbr_material_cell_bomb");
// The albedo texture requests nearest filtering. The center boundary must
// stay a hard blue-to-red transition on both backends; using WebGPU's
// backend-global linear sampler here blends the two texels.
const uint8_t *nearestBlue = pixel(*baseline, 31, 32);
const uint8_t *nearestRed = pixel(*baseline, 32, 32);
REQUIRE(nearestBlue[2] > nearestBlue[0] + 50);
REQUIRE(nearestRed[0] > nearestRed[2] + 50);
REQUIRE(imageRgbDifference(*baseline, *normalMapped) > 100000u);
REQUIRE(imageRgbDifference(*baseline, *parallaxMapped) > 50000u);
REQUIRE(imageRgbDifference(*baseline, *cellBombed) > 50000u);
}
TEST_CASE("graphics.backendParity.pbrEnvironmentAndCloudShadow") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const std::string backend = gfx->getBackendName();
const bool supportedBackend = backend == "vulkan" || backend == "webgpu";
REQUIRE(supportedBackend);
const float 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 float 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 float uvs[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f};
const uint32_t indices[] = {0, 2, 1, 2, 0, 3};
Mesh *mesh = gfx->newMeshFromArrays(positions, normals, uvs, 4, indices, 6);
REQUIRE(mesh != nullptr);
const uint8_t white[] = {255, 255, 255, 255};
const uint8_t flatNormal[] = {128, 128, 255, 255};
const uint8_t greenCube[] = {
16, 220, 32, 255, 16, 220, 32, 255, 16, 220, 32, 255,
16, 220, 32, 255, 16, 220, 32, 255, 16, 220, 32, 255,
};
Texture *albedo = gfx->newTexture(1, 1, white);
Texture *normal = gfx->newTexture(1, 1, flatNormal);
Texture *environment = gfx->newCubemap(1, greenCube);
REQUIRE(albedo != nullptr);
REQUIRE(normal != nullptr);
REQUIRE(environment != nullptr);
gfx->setMesh3DNormalTexture(normal);
gfx->setMesh3DHeightTexture(nullptr);
gfx->setMesh3DParallax(0.f);
gfx->setMesh3DTexCellBomb(1.f, 0.f);
gfx->setMesh3DViewProj(glm::mat4(1.f));
gfx->setMesh3DView(glm::mat4(1.f));
gfx->setMesh3DCameraPos(glm::vec3(0.f, 0.f, 3.f));
auto render = [&](const char *artifact) {
Canvas *target = gfx->newCanvas(64, 64);
REQUIRE(target != nullptr);
gfx->begin3DFrameToCanvas(target);
gfx->drawMesh(mesh, glm::mat4(1.f), albedo, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> image(target->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, backend);
return image;
};
Lighting3DPack noLights{};
noLights.ambient = glm::vec4(0.f);
gfx->setMesh3DLighting(noLights);
gfx->setMesh3DMaterial(1.f, 0.1f);
gfx->setMesh3DEnv(nullptr, 0.f);
auto environmentOff = render("pbr_environment_off");
gfx->setMesh3DEnv(environment, 1.f);
auto environmentOn = render("pbr_environment_on");
const uint8_t *reflected = pixel(*environmentOn, 32, 32);
REQUIRE(reflected[1] > reflected[0] + 20);
REQUIRE(imageRgbDifference(*environmentOff, *environmentOn) > 20000u);
Lighting3DPack sun{};
sun.ambient = glm::vec4(0.01f, 0.01f, 0.01f, 0.f);
sun.count = 1;
sun.lights[0].posRadius = glm::vec4(0.f, 0.f, 1.f, 0.f);
sun.lights[0].color = glm::vec4(0.9f, 0.9f, 0.9f, 1.f);
gfx->setMesh3DLighting(sun);
gfx->setMesh3DMaterial(0.f, 0.8f);
gfx->setMesh3DEnv(nullptr, 0.f);
gfx->setCloudShadows(0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 0.f);
auto cloudOff = render("pbr_cloud_shadow_off");
gfx->setCloudShadows(1.f, 1.f, 0.f, 0.f, 0.f, 0.f, 0.f);
auto cloudOn = render("pbr_cloud_shadow_on");
const uint8_t *lit = pixel(*cloudOff, 32, 32);
const uint8_t *shadowed = pixel(*cloudOn, 32, 32);
REQUIRE(lit[0] > shadowed[0] + 80);
REQUIRE(imageRgbDifference(*cloudOff, *cloudOn) > 100000u);
}
TEST_CASE("graphics.backendParity.maskedMaterialTechniques") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const float 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 float 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 float uvs[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f};
const uint32_t indices[] = {0, 2, 1, 2, 0, 3};
Mesh *mesh = gfx->newMeshFromArrays(positions, normals, uvs, 4, indices, 6);
const uint8_t halfAlpha[] = {255, 255, 255, 128};
Texture *albedo = gfx->newTexture(1, 1, halfAlpha);
REQUIRE(mesh != nullptr);
REQUIRE(albedo != nullptr);
Lighting3DPack lighting{};
lighting.ambient = glm::vec4(1.f, 1.f, 1.f, 0.f);
gfx->setMesh3DLighting(lighting);
gfx->setMesh3DViewProj(glm::mat4(1.f));
gfx->setMesh3DView(glm::mat4(1.f));
gfx->setMesh3DCameraPos(glm::vec3(0.f, 0.f, 3.f));
auto render = [&](float cutoff, const char *technique, const char *artifact) {
gfx->setMesh3DSurface(SurfaceMode::Masked, BlendMode::Opaque, true, false, cutoff,
technique);
Canvas *target = gfx->newCanvas(64, 64);
REQUIRE(target != nullptr);
gfx->begin3DFrameToCanvas(target);
gfx->drawMesh(mesh, glm::mat4(1.f), albedo, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> image(target->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, gfx->getBackendName());
return image;
};
auto cutoffKept = render(0.4f, "cutoff", "masked_cutoff_kept");
auto cutoffDiscarded = render(0.6f, "cutoff", "masked_cutoff_discarded");
REQUIRE(pixel(*cutoffKept, 32, 32)[0] > 180);
REQUIRE(pixel(*cutoffDiscarded, 32, 32)[0] < 32);
auto visiblePixels = [](const eve::image::ImageData &image) {
int visible = 0;
for (int y = 0; y < 64; ++y)
for (int x = 0; x < 64; ++x)
if (pixel(image, x, y)[0] > 180) ++visible;
return visible;
};
const int fullyCovered = visiblePixels(*cutoffKept);
REQUIRE(fullyCovered > 3500);
for (const char *technique : {"dither", "coverage"}) {
auto dithered = render(0.5f, technique, technique);
const int visible = visiblePixels(*dithered);
// Alpha hashing is backend-specific at fragment-coordinate precision. Validate the
// material contract (partial coverage) relative to the rendered quad instead of a
// Vulkan-tuned absolute pixel count.
REQUIRE(visible * 5 > fullyCovered);
REQUIRE(visible * 20 < fullyCovered * 17);
}
}
TEST_CASE("graphics.backendParity.surfaceBlendDepthWriteAndCulling") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const float positions[] = {
-1.f, -1.f, 0.2f, 1.f, -1.f, 0.2f, 1.f, 1.f, 0.2f, -1.f, 1.f, 0.2f,
};
const float 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 float uvs[] = {0.f, 0.f, 1.f, 0.f, 1.f, 1.f, 0.f, 1.f};
const uint32_t frontIndices[] = {0, 2, 1, 2, 0, 3};
const uint32_t backIndices[] = {0, 1, 2, 2, 3, 0};
Mesh *front = gfx->newMeshFromArrays(positions, normals, uvs, 4, frontIndices, 6);
Mesh *back = gfx->newMeshFromArrays(positions, normals, uvs, 4, backIndices, 6);
const uint8_t halfRed[] = {255, 0, 0, 128};
const uint8_t green[] = {0, 255, 0, 255};
const uint8_t blue[] = {0, 0, 255, 255};
const uint8_t halfGray[] = {128, 128, 128, 128};
Texture *redTexture = gfx->newTexture(1, 1, halfRed);
Texture *greenTexture = gfx->newTexture(1, 1, green);
Texture *blueTexture = gfx->newTexture(1, 1, blue);
Texture *grayTexture = gfx->newTexture(1, 1, halfGray);
REQUIRE(front != nullptr);
REQUIRE(back != nullptr);
REQUIRE(redTexture != nullptr);
REQUIRE(greenTexture != nullptr);
REQUIRE(blueTexture != nullptr);
REQUIRE(grayTexture != nullptr);
Lighting3DPack lighting{};
lighting.ambient = glm::vec4(1.f, 1.f, 1.f, 0.f);
gfx->setMesh3DLighting(lighting);
gfx->setMesh3DViewProj(glm::mat4(1.f));
gfx->setMesh3DView(glm::mat4(1.f));
gfx->setMesh3DCameraPos(glm::vec3(0.f, 0.f, 3.f));
auto renderCulling = [&](bool doubleSided, const char *artifact) {
gfx->setMesh3DSurface(SurfaceMode::Opaque, BlendMode::Opaque, true, doubleSided, 0.5f,
"cutoff");
Canvas *target = gfx->newCanvas(64, 64);
gfx->begin3DFrameToCanvas(target);
gfx->drawMesh(back, glm::mat4(1.f), greenTexture, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> image(target->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, gfx->getBackendName());
return image;
};
auto culled = renderCulling(false, "surface_backface_culled");
auto twoSided = renderCulling(true, "surface_double_sided");
REQUIRE(pixel(*culled, 32, 32)[1] < 32);
REQUIRE(pixel(*twoSided, 32, 32)[1] > 180);
auto renderDepthWrite = [&](bool depthWrite, const char *artifact) {
Canvas *target = gfx->newCanvas(64, 64);
gfx->begin3DFrameToCanvas(target);
gfx->setMesh3DSurface(SurfaceMode::Transparent, BlendMode::Alpha, depthWrite, false, 0.5f,
"cutoff");
gfx->drawMesh(front, glm::mat4(1.f), redTexture, Color(1.f));
glm::mat4 farther(1.f);
farther[3][2] = 0.6f;
gfx->setMesh3DSurface(SurfaceMode::Opaque, BlendMode::Opaque, true, false, 0.5f, "cutoff");
gfx->drawMesh(front, farther, greenTexture, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> image(target->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, gfx->getBackendName());
return image;
};
auto writesDepth = renderDepthWrite(true, "surface_transparent_depth_write");
auto skipsDepth = renderDepthWrite(false, "surface_transparent_no_depth_write");
const uint8_t *blocked = pixel(*writesDepth, 32, 32);
const uint8_t *overwritten = pixel(*skipsDepth, 32, 32);
REQUIRE(blocked[0] > 80);
REQUIRE(blocked[0] > blocked[1] + 60);
REQUIRE(overwritten[1] > 180);
REQUIRE(overwritten[0] < 20);
auto renderBlend = [&](BlendMode blend, Texture *overlay, const char *artifact) {
Canvas *target = gfx->newCanvas(64, 64);
gfx->begin3DFrameToCanvas(target);
glm::mat4 farther(1.f);
farther[3][2] = 0.6f;
gfx->setMesh3DSurface(SurfaceMode::Opaque, BlendMode::Opaque, true, false, 0.5f, "cutoff");
gfx->drawMesh(front, farther, blueTexture, Color(1.f));
gfx->setMesh3DSurface(SurfaceMode::Transparent, blend, false, false, 0.5f, "cutoff");
gfx->drawMesh(front, glm::mat4(1.f), overlay, Color(1.f));
gfx->end3DFrameToCanvas();
std::unique_ptr<eve::image::ImageData> image(target->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, gfx->getBackendName());
return image;
};
auto additive = renderBlend(BlendMode::Additive, redTexture, "surface_additive");
auto premultiplied =
renderBlend(BlendMode::Premultiplied, redTexture, "surface_premultiplied");
auto multiply = renderBlend(BlendMode::Multiply, grayTexture, "surface_multiply");
const uint8_t *added = pixel(*additive, 32, 32);
REQUIRE(added[0] > 80);
REQUIRE(added[2] > 180);
const uint8_t *premul = pixel(*premultiplied, 32, 32);
REQUIRE(premul[0] > 180);
REQUIRE(premul[2] > 80);
const uint8_t *multiplied = pixel(*multiply, 32, 32);
REQUIRE(multiplied[0] < 20);
REQUIRE(multiplied[2] > 60);
REQUIRE(multiplied[2] < 200);
}
TEST_CASE("graphics.backendParity.alphaMaskThresholdSoftnessAndInversion") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
const uint8_t red[] = {255, 0, 0, 255};
const uint8_t ramp[] = {
0, 0, 0, 255,
128, 128, 128, 255,
255, 255, 255, 255,
};
Texture *color = gfx->newTexture(1, 1, red);
Texture *mask = gfx->newTexture(3, 1, ramp);
REQUIRE(color != nullptr);
REQUIRE(mask != nullptr);
gfx->setTextureSampler(mask, TextureSampler::nearest());
std::unique_ptr<AlphaMask> alphaMask(gfx->newAlphaMask());
REQUIRE(alphaMask.get() != nullptr);
alphaMask->setThreshold(0.5f);
alphaMask->setSoftness(0.15f);
auto render = [&](bool inverted, const char *artifact) {
alphaMask->setInverted(inverted);
Canvas *canvas = gfx->newCanvas(64, 64);
gfx->setCanvas(canvas);
gfx->clear(Color(0.f, 0.f, 1.f, 1.f), std::nullopt, std::nullopt);
alphaMask->draw(color, mask, 2.f, 8.f, 60.f, 48.f);
gfx->setCanvas();
std::unique_ptr<eve::image::ImageData> image(canvas->newImageData());
REQUIRE(image.get() != nullptr);
writeParityArtifact(*image, artifact, gfx->getBackendName());
return image;
};
auto normal = render(false, "alpha_mask_normal");
const uint8_t *normalLow = pixel(*normal, 10, 32);
const uint8_t *normalMid = pixel(*normal, 32, 32);
const uint8_t *normalHigh = pixel(*normal, 54, 32);
REQUIRE(normalLow[2] > 240);
REQUIRE(normalLow[0] < 15);
REQUIRE(normalMid[0] > 80);
REQUIRE(normalMid[2] > 80);
REQUIRE(normalHigh[0] > 240);
REQUIRE(normalHigh[2] < 15);
auto inverted = render(true, "alpha_mask_inverted");
const uint8_t *invertedLow = pixel(*inverted, 10, 32);
const uint8_t *invertedHigh = pixel(*inverted, 54, 32);
REQUIRE(invertedLow[0] > 240);
REQUIRE(invertedLow[2] < 15);
REQUIRE(invertedHigh[2] > 240);
REQUIRE(invertedHigh[0] < 15);
}