#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 #include #include #include #include #include #include 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); 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(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 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 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 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 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 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; @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 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 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 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 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 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 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 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 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 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 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 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(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 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); }