Files

416 lines
13 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "Fixtures.h"
#include <SDL2/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <vector>
#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<Renderable3D>() != nullptr) {
auto view = ecs::View<Renderable3D, Renderable3D::MeshRenderer>();
for (auto it = view.begin(); it != view.end(); ++it) {
auto [mr] = *it;
mr->visible = false;
}
}
if (ecs::current()->getManager<Camera3D>() != nullptr) {
auto camView = ecs::View<Camera3D, Camera3D::Data>();
for (auto it = camView.begin(); it != camView.end(); ++it) {
auto [data] = *it;
data->active = false;
}
}
if (ecs::current()->getManager<Light3D>() != nullptr) {
auto lightView = ecs::View<Light3D, Light3D::Data>();
for (auto it = lightView.begin(); it != lightView.end(); ++it) {
auto [d] = *it;
d->enabled = false;
}
}
if (ecs::current()->getManager<Renderable2D>() != nullptr) {
auto view = ecs::View<Renderable2D, Renderable2D::Sprite>();
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<float> &a, const std::vector<float> &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<float> captureLumaGrid(Graphics *gfx, int step = 4) {
const int w = gfx->getWidth();
const int h = gfx->getHeight();
std::vector<float> 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);
}