3822 lines
152 KiB
C++
3822 lines
152 KiB
C++
#include "zeroerr/assert.h"
|
||
#include "zeroerr/unittest.h"
|
||
|
||
#include "data/ByteData.h"
|
||
#include "procgen/Procgen.h"
|
||
#include "procgen/GeneratorRegistry.h"
|
||
#include "procgen/Semantic.h"
|
||
#include "procgen/JsonExport.h"
|
||
#include "procgen/MeshBuild.h"
|
||
#include "procgen/algorithms/MarchingCubes.h"
|
||
#include "procgen/algorithms/LinearStructure.h"
|
||
#include "procgen/heightmap/TerrainAsset.h"
|
||
#include "procgen/heightmap/TerrainPipeline.h"
|
||
#include "procgen/heightmap/TerrainStreaming.h"
|
||
#include "procgen/texture/TextureRecipe.h"
|
||
#include "procgen/texture/PbrMaterial.h"
|
||
#include "procgen/texture/NoiseField.h"
|
||
#include "procgen/texture/ColorRamp.h"
|
||
#include "map/TileLayer.h"
|
||
#include "image/ImageData.h"
|
||
#include "graphics/Graphics.h"
|
||
#include "graphics/ClipSpace.h"
|
||
#include "graphics/RenderSystem.h"
|
||
#include "graphics/RenderSystem3D.h"
|
||
#include "window/Window.h"
|
||
#include "image/Image.h"
|
||
#include "filesystem/FileData.h"
|
||
|
||
#include <glm/gtc/matrix_transform.hpp>
|
||
#include "RenderImageAudit.h"
|
||
|
||
#include <SDL2/SDL.h>
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cmath>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <memory>
|
||
#include <numeric>
|
||
#include <set>
|
||
#include <string>
|
||
#include <unordered_set>
|
||
#include <vector>
|
||
|
||
using namespace eve::procgen;
|
||
using namespace eve::graphics;
|
||
|
||
namespace {
|
||
|
||
bool gridsEqual(const Grid2D &a, const Grid2D &b) {
|
||
if (a.getWidth() != b.getWidth() || a.getHeight() != b.getHeight()) return false;
|
||
return a.cells() == b.cells();
|
||
}
|
||
|
||
int countSemantic(const Grid2D &g, int semantic) {
|
||
int n = 0;
|
||
for (uint32_t c : g.cells())
|
||
if (int(c) == semantic) ++n;
|
||
return n;
|
||
}
|
||
|
||
bool neighborsOk(int a, int b, const std::set<std::pair<int, int>> &allowed) {
|
||
if (a > b) std::swap(a, b);
|
||
return allowed.count({a, b}) > 0;
|
||
}
|
||
|
||
bool terrainAdjacencyOk(const Grid2D &g) {
|
||
// Ordered elevation band: only self + ±1 neighbors.
|
||
static const int band[] = {int(Semantic::Water), int(Semantic::Sand), int(Semantic::Grass),
|
||
int(Semantic::Dirt), int(Semantic::Stone), int(Semantic::Snow)};
|
||
std::set<std::pair<int, int>> allowed;
|
||
for (int i = 0; i < 6; ++i) {
|
||
allowed.insert({band[i], band[i]});
|
||
if (i + 1 < 6) {
|
||
int lo = band[i], hi = band[i + 1];
|
||
if (lo > hi) std::swap(lo, hi);
|
||
allowed.insert({lo, hi});
|
||
}
|
||
}
|
||
const int w = g.getWidth();
|
||
const int h = g.getHeight();
|
||
for (int y = 0; y < h; ++y) {
|
||
for (int x = 0; x < w; ++x) {
|
||
const int c = g.getCell(x, y);
|
||
if (x + 1 < w && !neighborsOk(c, g.getCell(x + 1, y), allowed)) return false;
|
||
if (y + 1 < h && !neighborsOk(c, g.getCell(x, y + 1), allowed)) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool caveAdjacencyOk(const Grid2D &g) {
|
||
std::set<std::pair<int, int>> allowed = {
|
||
{int(Semantic::Wall), int(Semantic::Wall)},
|
||
{int(Semantic::Wall), int(Semantic::Floor)},
|
||
{int(Semantic::Floor), int(Semantic::Floor)},
|
||
};
|
||
// Normalize pair order for lookup.
|
||
auto ok = [&](int a, int b) {
|
||
if (a > b) std::swap(a, b);
|
||
return allowed.count({a, b}) > 0;
|
||
};
|
||
const int w = g.getWidth();
|
||
const int h = g.getHeight();
|
||
for (int y = 0; y < h; ++y) {
|
||
for (int x = 0; x < w; ++x) {
|
||
const int c = g.getCell(x, y);
|
||
if (x + 1 < w && !ok(c, g.getCell(x + 1, y))) return false;
|
||
if (y + 1 < h && !ok(c, g.getCell(x, y + 1))) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool borderIsWall(const Grid2D &g) {
|
||
const int w = g.getWidth();
|
||
const int h = g.getHeight();
|
||
for (int x = 0; x < w; ++x) {
|
||
if (g.getCell(x, 0) != int(Semantic::Wall)) return false;
|
||
if (g.getCell(x, h - 1) != int(Semantic::Wall)) return false;
|
||
}
|
||
for (int y = 0; y < h; ++y) {
|
||
if (g.getCell(0, y) != int(Semantic::Wall)) return false;
|
||
if (g.getCell(w - 1, y) != int(Semantic::Wall)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool meshIndicesInRange(const MeshBuild &m) {
|
||
const int vc = m.getVertexCount();
|
||
for (int i = 0; i < m.getIndexCount(); ++i) {
|
||
const int id = m.getIndex(i);
|
||
if (id < 0 || id >= vc) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool meshNormalsFiniteUnit(const MeshBuild &m, float tol = 0.15f) {
|
||
for (int i = 0; i < m.getVertexCount(); ++i) {
|
||
const float nx = m.getNormalX(i);
|
||
const float ny = m.getNormalY(i);
|
||
const float nz = m.getNormalZ(i);
|
||
if (!std::isfinite(nx) || !std::isfinite(ny) || !std::isfinite(nz)) return false;
|
||
const float len = std::sqrt(nx * nx + ny * ny + nz * nz);
|
||
if (std::fabs(len - 1.f) > tol) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool meshPositionsFinite(const MeshBuild &m) {
|
||
for (int i = 0; i < m.getVertexCount(); ++i) {
|
||
if (!std::isfinite(m.getPositionX(i)) || !std::isfinite(m.getPositionY(i)) ||
|
||
!std::isfinite(m.getPositionZ(i)))
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
float meshApproxSignedVolume(const MeshBuild &m) {
|
||
// Sum of signed tet volumes with origin (valid for closed mesh).
|
||
float vol = 0.f;
|
||
for (int t = 0; t + 2 < m.getIndexCount(); t += 3) {
|
||
const int i0 = m.getIndex(t);
|
||
const int i1 = m.getIndex(t + 1);
|
||
const int i2 = m.getIndex(t + 2);
|
||
const float ax = m.getPositionX(i0), ay = m.getPositionY(i0), az = m.getPositionZ(i0);
|
||
const float bx = m.getPositionX(i1), by = m.getPositionY(i1), bz = m.getPositionZ(i1);
|
||
const float cx = m.getPositionX(i2), cy = m.getPositionY(i2), cz = m.getPositionZ(i2);
|
||
vol += ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
|
||
}
|
||
return vol / 6.f;
|
||
}
|
||
|
||
bool approxEq(float a, float b, float eps = 1e-5f) { return std::fabs(a - b) < eps; }
|
||
|
||
bool hasWalkablePath(const Grid2D &g) {
|
||
// BFS from first floor/corridor cell; ensure >1 walkable and connected component covers all.
|
||
const int w = g.getWidth();
|
||
const int h = g.getHeight();
|
||
auto walkable = [&](int x, int y) {
|
||
if (x < 0 || y < 0 || x >= w || y >= h) return false;
|
||
const int c = g.getCell(x, y);
|
||
return c == int(Semantic::Floor) || c == int(Semantic::Corridor);
|
||
};
|
||
int start = -1;
|
||
int total = 0;
|
||
for (int y = 0; y < h; ++y) {
|
||
for (int x = 0; x < w; ++x) {
|
||
if (walkable(x, y)) {
|
||
++total;
|
||
if (start < 0) start = y * w + x;
|
||
}
|
||
}
|
||
}
|
||
if (total < 2 || start < 0) return false;
|
||
std::vector<char> seen(size_t(w * h), 0);
|
||
std::vector<int> q;
|
||
q.push_back(start);
|
||
seen[size_t(start)] = 1;
|
||
size_t qi = 0;
|
||
int reached = 0;
|
||
const int dx[4] = {1, -1, 0, 0};
|
||
const int dy[4] = {0, 0, 1, -1};
|
||
while (qi < q.size()) {
|
||
const int i = q[qi++];
|
||
const int x = i % w;
|
||
const int y = i / w;
|
||
++reached;
|
||
for (int d = 0; d < 4; ++d) {
|
||
const int nx = x + dx[d];
|
||
const int ny = y + dy[d];
|
||
if (!walkable(nx, ny)) continue;
|
||
const int ni = ny * w + nx;
|
||
if (seen[size_t(ni)]) continue;
|
||
seen[size_t(ni)] = 1;
|
||
q.push_back(ni);
|
||
}
|
||
}
|
||
return reached == total;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST_CASE("procgen.semantic.names") {
|
||
CHECK_EQ(std::string(semanticName(Semantic::Wall)), "wall");
|
||
CHECK_EQ(semanticId("floor"), Semantic::Floor);
|
||
CHECK_EQ(semanticId("nope"), Semantic::Empty);
|
||
}
|
||
|
||
TEST_CASE("procgen.registry.builtins") {
|
||
GeneratorRegistry::instance().registerBuiltins();
|
||
CHECK(GeneratorRegistry::instance().has("dungeon.bsp"));
|
||
CHECK(GeneratorRegistry::instance().has("cave.cellular"));
|
||
CHECK(GeneratorRegistry::instance().has("cave.drunkard"));
|
||
CHECK(GeneratorRegistry::instance().has("maze.backtrack"));
|
||
CHECK(GeneratorRegistry::instance().has("noise.terrain"));
|
||
CHECK(GeneratorRegistry::instance().has("wfc.simple"));
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.rock.reproducibleAndControllable") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setSeed(1847);
|
||
p.setInt("subdivisions", 3);
|
||
p.setFloat("radius", 0.7f);
|
||
p.setFloat("flattening", 0.3f);
|
||
p.setFloat("angularity", 0.45f);
|
||
p.setFloat("erosion", 0.16f);
|
||
p.setFloat("scale", 2.4f);
|
||
MeshBuild a, b, flatter;
|
||
std::string err;
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, a, err));
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, b, err));
|
||
CHECK_EQ(a.getVertexCount(), 642);
|
||
CHECK_EQ(a.getIndexCount() / 3, 1280);
|
||
CHECK(a.positions() == b.positions());
|
||
CHECK(a.indices() == b.indices());
|
||
CHECK(meshIndicesInRange(a));
|
||
CHECK(meshNormalsFiniteUnit(a));
|
||
|
||
p.setFloat("flattening", 0.58f);
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, flatter, err));
|
||
CHECK(a.positions() != flatter.positions());
|
||
|
||
p.setInt("subdivisions", 2);
|
||
MeshBuild lod1;
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, lod1, err));
|
||
CHECK_EQ(lod1.getVertexCount(), 162);
|
||
CHECK_EQ(lod1.getIndexCount() / 3, 320);
|
||
|
||
std::vector<std::vector<float>> shapePositions;
|
||
for (const char *shape : {"boulder", "slab", "block", "shard"}) {
|
||
p.setString("baseShape", shape);
|
||
p.setInt("subdivisions", 3);
|
||
MeshBuild variant;
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.rock", p, variant, err));
|
||
CHECK_EQ(variant.getMeta("baseShape", ""), shape);
|
||
CHECK_EQ(variant.getVertexCount(), 642);
|
||
CHECK(meshIndicesInRange(variant));
|
||
CHECK(meshNormalsFiniteUnit(variant));
|
||
shapePositions.push_back(variant.positions());
|
||
}
|
||
CHECK(shapePositions[0] != shapePositions[1]);
|
||
CHECK(shapePositions[1] != shapePositions[2]);
|
||
CHECK(shapePositions[2] != shapePositions[3]);
|
||
|
||
p.setString("baseShape", "invalid");
|
||
MeshBuild invalid;
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.rock", p, invalid, err));
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.tree.stylesAndLeaves") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params low;
|
||
low.setSeed(1234);
|
||
low.setString("style", "lowpoly");
|
||
low.setString("leafMode", "cards");
|
||
low.setFloat("leafDensity", 0.8f);
|
||
MeshBuild a, b;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", low, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", low, b, err));
|
||
CHECK(a.getVertexCount() > 0);
|
||
CHECK(a.positions() == b.positions());
|
||
CHECK(a.indices() == b.indices());
|
||
CHECK(meshIndicesInRange(a));
|
||
CHECK(meshPositionsFinite(a));
|
||
CHECK(meshNormalsFiniteUnit(a));
|
||
CHECK_EQ(a.getMeta("style", ""), "lowpoly");
|
||
CHECK_EQ(a.getMeta("leafMode", ""), "cards");
|
||
|
||
Params realistic;
|
||
realistic.setSeed(22);
|
||
realistic.setString("style", "realistic");
|
||
realistic.setString("leafMode", "canopy");
|
||
realistic.setString("branchAlgorithm", "spaceColonization");
|
||
realistic.setFloat("leafDensity", 1.f);
|
||
MeshBuild canopy;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", realistic, canopy, err));
|
||
CHECK(canopy.getVertexCount() > a.getVertexCount() / 2);
|
||
CHECK(meshIndicesInRange(canopy));
|
||
CHECK(meshNormalsFiniteUnit(canopy));
|
||
CHECK_EQ(canopy.getMeta("branchAlgorithm", ""), "spaceColonization");
|
||
|
||
Params sparseCoverage = realistic;
|
||
sparseCoverage.setFloat("lowerLeafCoverage", 0.f);
|
||
sparseCoverage.setFloat("upperLeafCoverage", 0.f);
|
||
Params fullCoverage = realistic;
|
||
fullCoverage.setFloat("lowerLeafCoverage", 1.f);
|
||
fullCoverage.setFloat("upperLeafCoverage", 1.f);
|
||
MeshBuild sparseLeaves, coveredBranches;
|
||
CHECK(MeshRecipeRegistry::instance().generate(
|
||
"mesh.tree", sparseCoverage, sparseLeaves, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate(
|
||
"mesh.tree", fullCoverage, coveredBranches, err));
|
||
CHECK(coveredBranches.getVertexCount() > sparseLeaves.getVertexCount());
|
||
|
||
Params bare;
|
||
bare.setSeed(22);
|
||
bare.setString("leafMode", "none");
|
||
MeshBuild noLeaves;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", bare, noLeaves, err));
|
||
CHECK(noLeaves.getVertexCount() < canopy.getVertexCount());
|
||
|
||
Params straight = bare;
|
||
straight.setFloat("trunkCurve", 0.f);
|
||
straight.setFloat("branchCurve", 0.f);
|
||
straight.setFloat("curveBack", 0.f);
|
||
straight.setFloat("tropism", 0.f);
|
||
straight.setFloat("droop", 0.f);
|
||
Params expressive = straight;
|
||
expressive.setFloat("trunkCurve", 0.2f);
|
||
expressive.setFloat("branchCurve", 0.24f);
|
||
expressive.setFloat("curveBack", 0.2f);
|
||
expressive.setFloat("tropism", 0.35f);
|
||
expressive.setFloat("droop", 0.28f);
|
||
MeshBuild rigidTree, curvedTree;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", straight, rigidTree, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", expressive, curvedTree, err));
|
||
CHECK(rigidTree.positions() != curvedTree.positions());
|
||
CHECK(meshPositionsFinite(curvedTree));
|
||
CHECK(meshNormalsFiniteUnit(curvedTree));
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.tree.validatesOptions") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setString("style", "watercolor");
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.tree", p, mesh, err));
|
||
CHECK(err.find("style") != std::string::npos);
|
||
p.setString("style", "lowpoly");
|
||
p.setString("branchAlgorithm", "crystalGrowth");
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.tree", p, mesh, err));
|
||
CHECK(err.find("branchAlgorithm") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.skyscraper.reproducibleAndControllable") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setSeed(2026);
|
||
p.setFloat("baseWidth", 10.f);
|
||
p.setFloat("baseDepth", 10.f);
|
||
p.setInt("tiers", 6);
|
||
p.setFloat("tierHeight", 6.f);
|
||
p.setFloat("setback", 0.08f);
|
||
p.setInt("windowCols", 6);
|
||
p.setInt("windowRows", 4);
|
||
p.setFloat("windowDepth", 0.04f);
|
||
p.setFloat("spireHeight", 4.f);
|
||
MeshBuild a, b, tapering;
|
||
std::string err;
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.skyscraper", p, a, err));
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.skyscraper", p, b, err));
|
||
CHECK(a.getVertexCount() > 0);
|
||
CHECK(a.positions() == b.positions());
|
||
CHECK(a.indices() == b.indices());
|
||
CHECK_EQ(a.getIndexCount() % 3, 0);
|
||
CHECK(meshIndicesInRange(a));
|
||
CHECK(meshPositionsFinite(a));
|
||
CHECK(meshNormalsFiniteUnit(a));
|
||
CHECK_EQ(a.getMeta("algorithm", ""), "mesh.skyscraper");
|
||
CHECK_EQ(a.getMeta("tiers", ""), "6");
|
||
CHECK_EQ(a.getMeta("windowCols", ""), "6");
|
||
CHECK_EQ(a.getMeta("windowRows", ""), "4");
|
||
// 6 tiers * 4 facades * (1 wall + cols*rows windows) quads = 6*4*(1+24) = 600 quads, plus roof.
|
||
CHECK_EQ(a.getVertexCount() % 4, 0);
|
||
|
||
// More setbacks / smaller footprint must change geometry.
|
||
p.setFloat("setback", 0.2f);
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.skyscraper", p, tapering, err));
|
||
CHECK(a.positions() != tapering.positions());
|
||
|
||
// A taller tower should have more vertices (more tiers).
|
||
Params tall;
|
||
tall.setInt("tiers", 12);
|
||
tall.setInt("windowCols", 8);
|
||
tall.setInt("windowRows", 6);
|
||
MeshBuild tallMesh;
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.skyscraper", tall, tallMesh, err));
|
||
CHECK(tallMesh.getVertexCount() > a.getVertexCount());
|
||
CHECK(meshNormalsFiniteUnit(tallMesh));
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.skyscraper.heightsAndBounds") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setSeed(7);
|
||
p.setInt("tiers", 4);
|
||
p.setFloat("tierHeight", 5.f);
|
||
p.setFloat("spireHeight", 3.f);
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
REQUIRE(MeshRecipeRegistry::instance().generate("mesh.skyscraper", p, mesh, err));
|
||
// Total height: tiers*tierHeight + spire.
|
||
const float expectedHeight = 4.f * 5.f + 3.f;
|
||
CHECK_EQ(std::stof(mesh.getMeta("height", "0")), expectedHeight);
|
||
// Every vertex must sit on or above the ground plane (y >= -epsilon).
|
||
for (int i = 0; i < mesh.getVertexCount(); ++i) {
|
||
CHECK(mesh.getPositionY(i) >= -1e-4f);
|
||
}
|
||
// Base footprint should be wider than the top tier after setbacks.
|
||
const float baseW = p.getFloat("baseWidth", 10.f) * 0.5f;
|
||
const float topW = baseW * (1.f - p.getFloat("setback", 0.08f) * 3.f);
|
||
CHECK(topW < baseW);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.tree.renderDump") {
|
||
const char *outputPath = std::getenv("EVENGINE_TREE_RENDER_PNG");
|
||
if (!outputPath || !outputPath[0]) return;
|
||
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings ws;
|
||
ws.width = 900;
|
||
ws.height = 700;
|
||
ws.centered = true;
|
||
REQUIRE(win->setWindowSettings(ws));
|
||
|
||
Params params;
|
||
params.setSeed(31415);
|
||
params.setString("style", "lowpoly");
|
||
const char *leafModeOverride = std::getenv("EVENGINE_TREE_LEAF_MODE");
|
||
params.setString("leafMode", leafModeOverride ? leafModeOverride : "canopy");
|
||
params.setString("branchAlgorithm", "spaceColonization");
|
||
params.setFloat("leafDensity", 0.82f);
|
||
params.setFloat("height", 6.2f);
|
||
params.setFloat("crownRadius", 2.15f);
|
||
params.setInt("branchLevels", 3);
|
||
params.setInt("branchCount", 8);
|
||
params.setInt("attractorCount", 120);
|
||
params.setInt("colonizationIterations", 38);
|
||
params.setFloat("branchInertia", 1.15f);
|
||
params.setFloat("tropism", 0.20f);
|
||
params.setFloat("droop", 0.08f);
|
||
params.setFloat("growthStep", 0.25f);
|
||
params.setFloat("maxTurnAngle", 18.f);
|
||
params.setInt("maxChildren", 2);
|
||
|
||
Procgen generator;
|
||
Mesh *treeMesh = generator.generateMesh("mesh.tree", ¶ms, gfx);
|
||
REQUIRE(treeMesh != nullptr);
|
||
|
||
// 4px bark/foliage atlas; UVs are partitioned by the mesh recipe.
|
||
const uint8_t atlasPixels[] = {
|
||
111, 70, 42, 255, 128, 82, 47, 255,
|
||
64, 119, 57, 255, 82, 145, 67, 255,
|
||
};
|
||
Texture *atlas = gfx->newTexture(4, 1, atlasPixels);
|
||
REQUIRE(atlas != nullptr);
|
||
|
||
auto *tree = Renderable3D::create();
|
||
tree->setMesh(treeMesh);
|
||
tree->setTexture(atlas);
|
||
tree->setTint(1.f, 1.f, 1.f, 1.f);
|
||
tree->setRoughness(0.88f);
|
||
tree->setRotation(-18.f, 0.f, 0.f);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(9.2f, 5.1f, 11.4f);
|
||
camera->setTarget(0.f, 3.1f, 0.f);
|
||
camera->setUp(0.f, 1.f, 0.f);
|
||
camera->setFov(36.f);
|
||
camera->setAmbient(0.30f, 0.34f, 0.28f);
|
||
camera->setActive(true);
|
||
|
||
gfx->setScreenReadbackEnabled(true);
|
||
gfx->setBackgroundColor(Color(0.075f, 0.105f, 0.095f, 1.f));
|
||
RenderSystem3D::setDirectionalLight(-0.55f, -1.f, -0.35f, 1.45f, 1.32f, 1.08f);
|
||
|
||
// Warm up pipelines and read back the final stable frame.
|
||
for (int frame = 0; frame < 4; ++frame) {
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
}
|
||
std::unique_ptr<eve::image::ImageData> image(gfx->newImageData());
|
||
REQUIRE(image.get() != nullptr);
|
||
REQUIRE(saveImagePng(*image, outputPath));
|
||
std::printf("tree render saved: %s\n", outputPath);
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.bush.reproducibleAndStyles") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setSeed(20260815u);
|
||
p.setString("style", "mound");
|
||
p.setString("leafMode", "mixed");
|
||
MeshBuild a, b;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", p, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", p, b, err));
|
||
CHECK(a.getVertexCount() > 0);
|
||
CHECK(a.positions() == b.positions());
|
||
CHECK(a.indices() == b.indices());
|
||
CHECK(meshIndicesInRange(a));
|
||
CHECK(meshPositionsFinite(a));
|
||
CHECK(meshNormalsFiniteUnit(a));
|
||
CHECK_EQ(a.getMeta("recipe", ""), "mesh.bush");
|
||
CHECK_EQ(a.getMeta("style", ""), "mound");
|
||
CHECK_EQ(a.getMeta("leafMode", ""), "mixed");
|
||
|
||
// Sphere style produces a different silhouette (rounder, taller lobes).
|
||
Params sphere = p;
|
||
sphere.setString("style", "sphere");
|
||
MeshBuild round;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", sphere, round, err));
|
||
CHECK(round.getVertexCount() > 0);
|
||
CHECK(round.positions() != a.positions());
|
||
CHECK(meshIndicesInRange(round));
|
||
CHECK(meshNormalsFiniteUnit(round));
|
||
CHECK_EQ(round.getMeta("style", ""), "sphere");
|
||
|
||
// Bare foliage still yields a closed mound (blobs only, no cards/twigs tufts).
|
||
Params blobsOnly = p;
|
||
blobsOnly.setString("leafMode", "blobs");
|
||
blobsOnly.setInt("twigs", 0);
|
||
MeshBuild mounds;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", blobsOnly, mounds, err));
|
||
CHECK(mounds.getVertexCount() > 0);
|
||
CHECK(meshIndicesInRange(mounds));
|
||
CHECK(meshNormalsFiniteUnit(mounds));
|
||
|
||
// "none" drops cards but keeps the blobs; fewer vertices than the mixed default.
|
||
Params bare = p;
|
||
bare.setString("leafMode", "none");
|
||
bare.setInt("twigs", 0);
|
||
MeshBuild noCards;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", bare, noCards, err));
|
||
CHECK(noCards.getVertexCount() > 0);
|
||
CHECK(noCards.getVertexCount() < a.getVertexCount());
|
||
CHECK(meshNormalsFiniteUnit(noCards));
|
||
|
||
// Lower resolution should be cheaper than the default.
|
||
Params lod = p;
|
||
lod.setInt("rings", 2);
|
||
lod.setInt("radialSegments", 5);
|
||
lod.setInt("blobs", 5);
|
||
MeshBuild cheap;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bush", lod, cheap, err));
|
||
CHECK(cheap.getVertexCount() < a.getVertexCount());
|
||
CHECK(meshIndicesInRange(cheap));
|
||
CHECK(meshNormalsFiniteUnit(cheap));
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.bush.validatesOptions") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setString("style", "bonsai");
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.bush", p, mesh, err));
|
||
CHECK(err.find("style") != std::string::npos);
|
||
p.setString("style", "mound");
|
||
p.setString("leafMode", "marching");
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.bush", p, mesh, err));
|
||
CHECK(err.find("leafMode") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.bush.renderDump") {
|
||
const char *outputPath = std::getenv("EVENGINE_BUSH_RENDER_PNG");
|
||
if (!outputPath || !outputPath[0]) return;
|
||
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings ws;
|
||
ws.width = 900;
|
||
ws.height = 700;
|
||
ws.centered = true;
|
||
REQUIRE(win->setWindowSettings(ws));
|
||
|
||
Params params;
|
||
params.setSeed(20260815u);
|
||
params.setString("style", "mound");
|
||
params.setString("leafMode", "mixed");
|
||
params.setFloat("height", 1.7f);
|
||
params.setFloat("width", 2.6f);
|
||
params.setInt("blobs", 12);
|
||
params.setInt("rings", 3);
|
||
params.setInt("radialSegments", 8);
|
||
params.setFloat("leafDensity", 0.8f);
|
||
params.setFloat("leafSize", 0.32f);
|
||
params.setInt("twigs", 6);
|
||
|
||
Procgen generator;
|
||
Mesh *bushMesh = generator.generateMesh("mesh.bush", ¶ms, gfx);
|
||
REQUIRE(bushMesh != nullptr);
|
||
|
||
// A tiny two-tone foliage atlas (dark base / light highlight), like the tree test.
|
||
const uint8_t atlasPixels[] = {
|
||
52, 86, 40, 255, 84, 132, 60, 255,
|
||
};
|
||
Texture *atlas = gfx->newTexture(2, 1, atlasPixels);
|
||
REQUIRE(atlas != nullptr);
|
||
|
||
auto *bush = Renderable3D::create();
|
||
bush->setMesh(bushMesh);
|
||
bush->setTexture(atlas);
|
||
bush->setTint(1.f, 1.f, 1.f, 1.f);
|
||
bush->setRoughness(0.9f);
|
||
bush->setRotation(-14.f, 0.f, 0.f);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(5.2f, 3.2f, 5.6f);
|
||
camera->setTarget(0.f, 0.9f, 0.f);
|
||
camera->setUp(0.f, 1.f, 0.f);
|
||
camera->setFov(36.f);
|
||
camera->setAmbient(0.34f, 0.38f, 0.30f);
|
||
camera->setActive(true);
|
||
|
||
gfx->setScreenReadbackEnabled(true);
|
||
gfx->setBackgroundColor(Color(0.075f, 0.105f, 0.095f, 1.f));
|
||
RenderSystem3D::setDirectionalLight(-0.55f, -1.f, -0.35f, 1.45f, 1.32f, 1.08f);
|
||
|
||
for (int frame = 0; frame < 4; ++frame) {
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
}
|
||
std::unique_ptr<eve::image::ImageData> image(gfx->newImageData());
|
||
REQUIRE(image.get() != nullptr);
|
||
REQUIRE(saveImagePng(*image, outputPath));
|
||
std::printf("bush render saved: %s\n", outputPath);
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("procgen.dungeon.bsp.reproducible") {
|
||
Params p;
|
||
p.setSeed(42);
|
||
p.setSize(48, 36);
|
||
Grid2D a, b;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("dungeon.bsp", p, a, err));
|
||
CHECK(GeneratorRegistry::instance().generate("dungeon.bsp", p, b, err));
|
||
CHECK(gridsEqual(a, b));
|
||
CHECK(countSemantic(a, int(Semantic::Floor)) > 0);
|
||
CHECK(countSemantic(a, int(Semantic::Wall)) > 0);
|
||
CHECK(a.getObjectCount() >= 2);
|
||
CHECK(hasWalkablePath(a));
|
||
}
|
||
|
||
TEST_CASE("procgen.cave.cellular.reproducible") {
|
||
Params p;
|
||
p.setSeed(7);
|
||
p.setSize(32, 24);
|
||
p.setInt("loops", 4);
|
||
p.setFloat("fill", 0.45f);
|
||
Grid2D a, b;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("cave.cellular", p, a, err));
|
||
CHECK(GeneratorRegistry::instance().generate("cave.cellular", p, b, err));
|
||
CHECK(gridsEqual(a, b));
|
||
CHECK(countSemantic(a, int(Semantic::Floor)) > 0);
|
||
}
|
||
|
||
TEST_CASE("procgen.cave.drunkard.reproducible") {
|
||
Params p;
|
||
p.setSeed(99);
|
||
p.setSize(40, 30);
|
||
p.setFloat("floorPct", 0.4f);
|
||
Grid2D a, b;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("cave.drunkard", p, a, err));
|
||
CHECK(GeneratorRegistry::instance().generate("cave.drunkard", p, b, err));
|
||
CHECK(gridsEqual(a, b));
|
||
CHECK(countSemantic(a, int(Semantic::Floor)) > 10);
|
||
}
|
||
|
||
TEST_CASE("procgen.maze.backtrack.reproducible") {
|
||
Params p;
|
||
p.setSeed(123);
|
||
p.setSize(21, 15);
|
||
Grid2D a, b;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("maze.backtrack", p, a, err));
|
||
CHECK(GeneratorRegistry::instance().generate("maze.backtrack", p, b, err));
|
||
CHECK(gridsEqual(a, b));
|
||
CHECK(hasWalkablePath(a));
|
||
}
|
||
|
||
TEST_CASE("procgen.noise.terrain.reproducible") {
|
||
Params p;
|
||
p.setSeed(55);
|
||
p.setSize(32, 32);
|
||
p.setFloat("frequency", 5.f);
|
||
p.setInt("octaves", 3);
|
||
Grid2D a, b;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("noise.terrain", p, a, err));
|
||
CHECK(GeneratorRegistry::instance().generate("noise.terrain", p, b, err));
|
||
CHECK(gridsEqual(a, b));
|
||
// Multiple biome bands expected.
|
||
std::set<int> kinds;
|
||
for (uint32_t c : a.cells()) kinds.insert(int(c));
|
||
CHECK(kinds.size() >= 2);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.pipeline.erosionHydrologyAndBiomes") {
|
||
Heightmap ridge(32, 24);
|
||
for (int y = 0; y < ridge.getHeight(); ++y) {
|
||
for (int x = 0; x < ridge.getWidth(); ++x) {
|
||
const float downhill = 0.82f - float(y) * 0.018f;
|
||
const float channel = (x == 15 || x == 16) ? -0.08f : 0.f;
|
||
ridge.setHeight(x, y, downhill + channel);
|
||
}
|
||
}
|
||
const float massBefore = std::accumulate(ridge.data().begin(), ridge.data().end(), 0.f);
|
||
Heightmap thermal = ridge;
|
||
ThermalErosionSettings thermalSettings;
|
||
thermalSettings.iterations = 12;
|
||
TerrainPipeline::erodeThermal(thermal, thermalSettings);
|
||
const float massAfter = std::accumulate(thermal.data().begin(), thermal.data().end(), 0.f);
|
||
CHECK(std::abs(massAfter - massBefore) < 0.001f);
|
||
CHECK(thermal.height(15, 12) > ridge.height(15, 12));
|
||
const auto [thermalMin, thermalMax] = std::minmax_element(thermal.data().begin(), thermal.data().end());
|
||
const auto [ridgeMin, ridgeMax] = std::minmax_element(ridge.data().begin(), ridge.data().end());
|
||
CHECK(*thermalMin >= *ridgeMin - 0.0001f);
|
||
CHECK(*thermalMax <= *ridgeMax + 0.0001f);
|
||
|
||
Heightmap hydraulicA = ridge, hydraulicB = ridge;
|
||
HydraulicErosionSettings hydraulicSettings;
|
||
hydraulicSettings.iterations = 20;
|
||
TerrainPipeline::erodeHydraulic(hydraulicA, hydraulicSettings);
|
||
TerrainPipeline::erodeHydraulic(hydraulicB, hydraulicSettings);
|
||
CHECK(hydraulicA.data() == hydraulicB.data());
|
||
CHECK(hydraulicA.data() != ridge.data());
|
||
const auto [hydraulicMin, hydraulicMax] =
|
||
std::minmax_element(hydraulicA.data().begin(), hydraulicA.data().end());
|
||
CHECK(std::isfinite(*hydraulicMin));
|
||
CHECK(std::isfinite(*hydraulicMax));
|
||
CHECK(*hydraulicMin >= 0.f);
|
||
CHECK(*hydraulicMax <= *ridgeMax + 0.0001f);
|
||
|
||
const HydrologyMap hydrology = TerrainPipeline::buildHydrology(ridge, 8.f, 0.2f);
|
||
CHECK_EQ(hydrology.flowAccumulation.size(), ridge.data().size());
|
||
CHECK(std::count(hydrology.rivers.begin(), hydrology.rivers.end(), uint8_t(1)) > 0);
|
||
for (int y = 1; y + 1 < ridge.getHeight(); ++y)
|
||
for (int x = 1; x + 1 < ridge.getWidth(); ++x)
|
||
CHECK(hydrology.flowDirection[size_t(y * ridge.getWidth() + x)] >= 0);
|
||
const ClimateMap climate = TerrainPipeline::buildClimate(ridge, hydrology, 0.2f, 0.8f);
|
||
CHECK_EQ(climate.biomes.size(), ridge.data().size());
|
||
CHECK(std::count(climate.biomes.begin(), climate.biomes.end(), Biome::River) > 0);
|
||
std::set<Biome> biomes(climate.biomes.begin(), climate.biomes.end());
|
||
CHECK(biomes.size() >= 3);
|
||
|
||
Heightmap fluvialA = ridge, fluvialB = ridge;
|
||
FluvialErosionSettings fluvialSettings;
|
||
fluvialSettings.iterations = 6;
|
||
fluvialSettings.riverThreshold = 0.02f;
|
||
TerrainPipeline::erodeFluvial(fluvialA, fluvialSettings);
|
||
TerrainPipeline::erodeFluvial(fluvialB, fluvialSettings);
|
||
CHECK(fluvialA.data() == fluvialB.data());
|
||
const auto maxCardinalStep = [](const Heightmap &map) {
|
||
float result = 0.f;
|
||
for (int y = 0; y < map.getHeight(); ++y) for (int x = 0; x < map.getWidth(); ++x) {
|
||
if (x + 1 < map.getWidth())
|
||
result = std::max(result, std::abs(map.height(x, y) - map.height(x + 1, y)));
|
||
if (y + 1 < map.getHeight())
|
||
result = std::max(result, std::abs(map.height(x, y) - map.height(x, y + 1)));
|
||
}
|
||
return result;
|
||
};
|
||
// Valley widening must not introduce a hard circular rim or terrace scarp.
|
||
CHECK(maxCardinalStep(fluvialA) <= maxCardinalStep(ridge) + 0.08f);
|
||
const HydrologyMap erodedHydrology =
|
||
TerrainPipeline::buildHydrology(fluvialA, fluvialSettings.riverThreshold,
|
||
-std::numeric_limits<float>::infinity());
|
||
const float channelCutoff = fluvialSettings.riverThreshold * float(fluvialA.data().size());
|
||
int channelLinks = 0, uphillLinks = 0;
|
||
for (size_t start = 0; start < erodedHydrology.flowDirection.size(); ++start) {
|
||
if (erodedHydrology.flowAccumulation[start] < channelCutoff) continue;
|
||
size_t current = start;
|
||
bool reachedBoundary = false;
|
||
for (size_t step = 0; step <= fluvialA.data().size(); ++step) {
|
||
const int x = int(current % size_t(fluvialA.getWidth()));
|
||
const int y = int(current / size_t(fluvialA.getWidth()));
|
||
if (x == 0 || y == 0 || x + 1 == fluvialA.getWidth() || y + 1 == fluvialA.getHeight()) {
|
||
reachedBoundary = true;
|
||
break;
|
||
}
|
||
const int direction = erodedHydrology.flowDirection[current];
|
||
REQUIRE(direction >= 0);
|
||
static constexpr int flowDx[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
|
||
static constexpr int flowDy[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
|
||
const size_t receiver = size_t((y + flowDy[direction]) * fluvialA.getWidth() +
|
||
x + flowDx[direction]);
|
||
if (fluvialA.data()[receiver] > fluvialA.data()[current] + 0.001f) ++uphillLinks;
|
||
++channelLinks;
|
||
current = receiver;
|
||
}
|
||
CHECK(reachedBoundary);
|
||
}
|
||
REQUIRE(channelLinks > 0);
|
||
CHECK(uphillLinks * 20 <= channelLinks); // at least 95% of the bed is non-uphill
|
||
int loweredCells = 0;
|
||
for (size_t i = 0; i < fluvialA.data().size(); ++i) {
|
||
if (fluvialA.data()[i] < ridge.data()[i] - 0.0001f) ++loweredCells;
|
||
CHECK(fluvialA.data()[i] >= ridge.data()[i] - fluvialSettings.maxDepth - 0.0001f);
|
||
}
|
||
CHECK(loweredCells > 0);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.pipeline.multiSeedDrainageMorphology") {
|
||
Procgen procgen;
|
||
std::set<uint64_t> terrainHashes;
|
||
static constexpr std::array<int, 5> seeds{17, 1031, 8191, 20260826, 7340033};
|
||
static constexpr int flowDx[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
|
||
static constexpr int flowDy[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
|
||
for (int seed : seeds) {
|
||
Params params;
|
||
params.setSeed(seed);
|
||
params.setSize(65, 65);
|
||
params.setFloat("frequency", 1.f / 31.f);
|
||
params.setInt("octaves", 4);
|
||
params.setFloat("gain", 0.4f);
|
||
params.setFloat("warp", 0.07f);
|
||
std::unique_ptr<Heightmap> terrain(procgen.generateHeightmap(¶ms));
|
||
REQUIRE(terrain.get() != nullptr);
|
||
TerrainPipeline::erodeThermal(*terrain, {12, 0.011f, 0.28f});
|
||
TerrainPipeline::erodeHydraulic(*terrain, {8, 0.007f, 0.11f, 1.4f, 0.08f, 0.11f});
|
||
TerrainErosionMap terrainDiagnostics = TerrainPipeline::erodeFluvialDetailed(
|
||
*terrain, {10, 0.008f, 0.12f, 0.18f, 2.5f});
|
||
if (seed == seeds.front()) {
|
||
if (const char *path = std::getenv("EVENGINE_TERRAIN_NATURAL_EROSION_PNG")) {
|
||
std::unique_ptr<eve::image::ImageData> image(
|
||
procgen.generateTerrainErosionMap(&terrainDiagnostics, 0.f));
|
||
REQUIRE(image.get() != nullptr);
|
||
REQUIRE(saveImagePng(*image, path));
|
||
}
|
||
}
|
||
const HydrologyMap hydro = TerrainPipeline::buildHydrology(*terrain, 24.f, 0.2f);
|
||
const auto [minIt, maxIt] = std::minmax_element(terrain->data().begin(), terrain->data().end());
|
||
CHECK(*maxIt - *minIt > 0.18f);
|
||
const int riverCells = int(std::count(hydro.rivers.begin(), hydro.rivers.end(), uint8_t(1)));
|
||
CHECK(riverCells >= 12);
|
||
CHECK(riverCells < int(terrain->data().size() / 5));
|
||
CHECK_EQ(hydro.streamOrder.size(), hydro.rivers.size());
|
||
CHECK(*std::max_element(hydro.streamOrder.begin(), hydro.streamOrder.end()) >= 2);
|
||
|
||
int junctions = 0, longestChain = 0;
|
||
for (int y = 1; y + 1 < terrain->getHeight(); ++y) {
|
||
for (int x = 1; x + 1 < terrain->getWidth(); ++x) {
|
||
const size_t i = size_t(y * terrain->getWidth() + x);
|
||
if (!hydro.rivers[i]) continue;
|
||
int incoming = 0;
|
||
for (int d = 0; d < 8; ++d) {
|
||
const int nx = x + flowDx[d], ny = y + flowDy[d];
|
||
const size_t n = size_t(ny * terrain->getWidth() + nx);
|
||
const int receiver = hydro.flowDirection[n];
|
||
if (hydro.rivers[n] && receiver >= 0 &&
|
||
nx + flowDx[receiver] == x && ny + flowDy[receiver] == y) ++incoming;
|
||
}
|
||
if (incoming >= 2) ++junctions;
|
||
size_t current = i;
|
||
int chain = 0;
|
||
for (; chain < int(terrain->data().size()); ++chain) {
|
||
const int cx = int(current % size_t(terrain->getWidth()));
|
||
const int cy = int(current / size_t(terrain->getWidth()));
|
||
const int direction = hydro.flowDirection[current];
|
||
if (direction < 0 || cx == 0 || cy == 0 ||
|
||
cx + 1 == terrain->getWidth() || cy + 1 == terrain->getHeight()) break;
|
||
const size_t receiver = size_t((cy + flowDy[direction]) * terrain->getWidth() +
|
||
cx + flowDx[direction]);
|
||
// A thresholded D8 channel must never disappear merely
|
||
// because its contributing area shrinks downstream.
|
||
CHECK(hydro.flowAccumulation[receiver] >= hydro.flowAccumulation[current]);
|
||
if (terrain->data()[receiver] > 0.2f && hydro.lakeDepth[receiver] <= 0.f)
|
||
CHECK(hydro.rivers[receiver]);
|
||
current = receiver;
|
||
}
|
||
longestChain = std::max(longestChain, chain);
|
||
}
|
||
}
|
||
CHECK(junctions > 0);
|
||
CHECK(longestChain >= 8);
|
||
|
||
uint64_t hash = 1469598103934665603ull;
|
||
for (float value : terrain->data()) {
|
||
uint32_t bits = 0;
|
||
std::memcpy(&bits, &value, sizeof(bits));
|
||
hash = (hash ^ bits) * 1099511628211ull;
|
||
}
|
||
terrainHashes.insert(hash);
|
||
}
|
||
CHECK_EQ(terrainHashes.size(), seeds.size());
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.pipeline.fluvialBreachesClosedBasinSpillSill") {
|
||
Heightmap basin(41, 41);
|
||
const int center = 20;
|
||
for (int y = 0; y < 41; ++y) for (int x = 0; x < 41; ++x) {
|
||
const float radial = std::hypot(float(x - center), float(y - center));
|
||
float elevation = 0.18f + 0.012f * radial;
|
||
if (radial > 12.f && radial < 15.f) elevation += 0.32f;
|
||
if (radial >= 15.f) elevation = 0.25f - 0.006f * (radial - 15.f);
|
||
basin.setHeight(x, y, elevation);
|
||
}
|
||
const HydrologyMap closedHydro = TerrainPipeline::buildHydrology(
|
||
basin, 3.f, -std::numeric_limits<float>::infinity());
|
||
CHECK(closedHydro.lakeDepth[size_t(center * 41 + center)] > 0.2f);
|
||
CHECK(!closedHydro.rivers[size_t(center * 41 + center)]);
|
||
const ClimateMap closedClimate = TerrainPipeline::buildClimate(basin, closedHydro, 0.f, 0.5f);
|
||
CHECK_EQ(int(closedClimate.biomes[size_t(center * 41 + center)]), int(Biome::Lake));
|
||
CHECK(std::count(closedClimate.biomes.begin(), closedClimate.biomes.end(), Biome::Wetland) > 0);
|
||
|
||
// A deep connected basin is one geomorphic unit. A shallow fringe cell
|
||
// must not be incised merely because its own fill depth is below the breach
|
||
// limit; otherwise Priority-Flood's routing tree appears as radial stripes.
|
||
Heightmap preservedBasin = basin;
|
||
TerrainPipeline::erodeFluvial(preservedBasin, {24, 3.f, 0.14f, 0.55f, 4.f, 0.05f});
|
||
const HydrologyMap preservedHydro = TerrainPipeline::buildHydrology(
|
||
preservedBasin, 3.f, -std::numeric_limits<float>::infinity());
|
||
CHECK(preservedHydro.lakeDepth[size_t(center * 41 + center)] > 0.15f);
|
||
CHECK(std::abs(preservedBasin.height(center, center) - basin.height(center, center)) < 0.01f);
|
||
|
||
// The same basin may be deliberately opened when the caller supplies a
|
||
// breach budget larger than the component's maximum depression depth.
|
||
TerrainPipeline::erodeFluvial(basin, {24, 3.f, 0.14f, 0.55f, 4.f, 0.55f});
|
||
const HydrologyMap hydro = TerrainPipeline::buildHydrology(
|
||
basin, 3.f, -std::numeric_limits<float>::infinity());
|
||
size_t current = size_t(center * 41 + center);
|
||
int links = 0;
|
||
for (; links < 41 * 41; ++links) {
|
||
const int x = int(current % 41u), y = int(current / 41u);
|
||
if (x == 0 || y == 0 || x == 40 || y == 40) break;
|
||
const int direction = hydro.flowDirection[current];
|
||
REQUIRE(direction >= 0);
|
||
static constexpr int flowDx[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
|
||
static constexpr int flowDy[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
|
||
const size_t receiver = size_t((y + flowDy[direction]) * 41 + x + flowDx[direction]);
|
||
CHECK(basin.data()[receiver] <= basin.data()[current] + 0.001f);
|
||
current = receiver;
|
||
}
|
||
CHECK(links >= 15);
|
||
CHECK(links < 41 * 41);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.pipeline.classifiesPerennialLakeBasins") {
|
||
Heightmap puddle(25, 25);
|
||
for (int y = 0; y < 25; ++y) for (int x = 0; x < 25; ++x)
|
||
puddle.setHeight(x, y, 0.5f - float(y) * 0.002f);
|
||
puddle.setHeight(12, 12, puddle.height(12, 12) - 0.018f);
|
||
|
||
const HydrologyMap raw = TerrainPipeline::buildHydrology(
|
||
puddle, 8.f, -std::numeric_limits<float>::infinity(), 1.f, false);
|
||
REQUIRE(raw.lakeDepth[size_t(12 * 25 + 12)] > 0.01f);
|
||
const HydrologyMap classified = TerrainPipeline::buildHydrology(
|
||
puddle, 8.f, -std::numeric_limits<float>::infinity());
|
||
CHECK_EQ(classified.lakeDepth[size_t(12 * 25 + 12)], 0.f);
|
||
|
||
Heightmap lake(25, 25);
|
||
for (int y = 0; y < 25; ++y) for (int x = 0; x < 25; ++x) {
|
||
const float radius = std::hypot(float(x - 12), float(y - 12));
|
||
lake.setHeight(x, y, 0.30f + std::min(radius, 8.f) * 0.012f);
|
||
}
|
||
const HydrologyMap perennial = TerrainPipeline::buildHydrology(
|
||
lake, 8.f, -std::numeric_limits<float>::infinity());
|
||
CHECK(perennial.lakeDepth[size_t(12 * 25 + 12)] > 0.05f);
|
||
CHECK(std::count_if(perennial.lakeDepth.begin(), perennial.lakeDepth.end(),
|
||
[](float depth) { return depth > 0.f; }) >= 20);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.pipeline.fluvialResolvesValleyCrossSections") {
|
||
Heightmap slope(65, 65);
|
||
for (int y = 0; y < 65; ++y) for (int x = 0; x < 65; ++x) {
|
||
const float broadRelief = 0.055f * std::sin(float(x) * 0.145f) +
|
||
0.025f * std::sin(float(x + y) * 0.071f);
|
||
slope.setHeight(x, y, 0.88f - float(y) * 0.009f + broadRelief);
|
||
}
|
||
const Heightmap originalSlope = slope;
|
||
FluvialErosionSettings settings;
|
||
settings.iterations = 18;
|
||
settings.riverThreshold = 0.008f;
|
||
settings.incision = 0.075f;
|
||
settings.maxDepth = 0.12f;
|
||
settings.bankWidth = 5.f;
|
||
settings.maxBreachDepth = 0.015f;
|
||
TerrainErosionMap erosionMap = TerrainPipeline::erodeFluvialDetailed(slope, settings);
|
||
REQUIRE_EQ(erosionMap.width, 65);
|
||
REQUIRE_EQ(erosionMap.height, 65);
|
||
REQUIRE_EQ(erosionMap.wear.size(), slope.data().size());
|
||
float totalWear = 0.f, totalDeposition = 0.f;
|
||
for (size_t i = 0; i < slope.data().size(); ++i) {
|
||
totalWear += erosionMap.wear[i];
|
||
totalDeposition += erosionMap.deposition[i];
|
||
CHECK(std::abs((erosionMap.deposition[i] - erosionMap.wear[i]) -
|
||
erosionMap.heightDelta[i]) < 1e-5f);
|
||
}
|
||
CHECK(totalWear > 0.1f);
|
||
CHECK(totalDeposition > 0.f);
|
||
Procgen diagnosticProcgen;
|
||
std::unique_ptr<eve::image::ImageData> combined(
|
||
diagnosticProcgen.generateTerrainErosionMap(&erosionMap, 0.f));
|
||
std::unique_ptr<eve::image::ImageData> wearImage(
|
||
diagnosticProcgen.generateTerrainWearMap(&erosionMap, 0.f));
|
||
std::unique_ptr<eve::image::ImageData> depositImage(
|
||
diagnosticProcgen.generateTerrainDepositionMap(&erosionMap, 0.f));
|
||
REQUIRE(combined.get() != nullptr);
|
||
REQUIRE(wearImage.get() != nullptr);
|
||
REQUIRE(depositImage.get() != nullptr);
|
||
CHECK_EQ(combined->getWidth(), 65);
|
||
CHECK(std::memcmp(combined->getData(), wearImage->getData(), combined->getSize()) != 0);
|
||
CHECK(std::memcmp(combined->getData(), depositImage->getData(), combined->getSize()) != 0);
|
||
if (const char *path = std::getenv("EVENGINE_TERRAIN_EROSION_PNG"))
|
||
REQUIRE(saveImagePng(*combined, path));
|
||
const HydrologyMap hydro = TerrainPipeline::buildHydrology(
|
||
slope, settings.riverThreshold, -std::numeric_limits<float>::infinity());
|
||
const float cutoff = settings.riverThreshold * float(slope.data().size());
|
||
static constexpr int flowDx[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
|
||
static constexpr int flowDy[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
|
||
std::vector<float> crossSectionRelief;
|
||
std::vector<std::pair<float, int>> flowAndIncisedWidth;
|
||
int materiallyIncised = 0;
|
||
for (int y = 9; y < 56; ++y) for (int x = 9; x < 56; ++x) {
|
||
const size_t i = size_t(y * 65 + x);
|
||
if (hydro.flowAccumulation[i] < cutoff || hydro.flowDirection[i] < 0) continue;
|
||
const int d = hydro.flowDirection[i];
|
||
const int px = -flowDy[d], py = flowDx[d];
|
||
const float left = slope.height(x + px * 4, y + py * 4);
|
||
const float right = slope.height(x - px * 4, y - py * 4);
|
||
crossSectionRelief.push_back((left + right) * 0.5f - slope.height(x, y));
|
||
int incisedWidth = 0;
|
||
for (int offset = -8; offset <= 8; ++offset) {
|
||
const int sx = x + px * offset, sy = y + py * offset;
|
||
if (originalSlope.height(sx, sy) - slope.height(sx, sy) > 0.002f)
|
||
++incisedWidth;
|
||
}
|
||
flowAndIncisedWidth.emplace_back(hydro.flowAccumulation[i], incisedWidth);
|
||
if (originalSlope.height(x, y) - slope.height(x, y) > 0.01f) ++materiallyIncised;
|
||
}
|
||
REQUIRE(crossSectionRelief.size() >= 8);
|
||
std::sort(crossSectionRelief.begin(), crossSectionRelief.end());
|
||
CHECK(crossSectionRelief[crossSectionRelief.size() / 2] > 0.008f);
|
||
CHECK(materiallyIncised >= int(crossSectionRelief.size() / 3));
|
||
// A larger contributing basin must create a wider geomorphic corridor,
|
||
// not merely a wider water ribbon at render time.
|
||
std::sort(flowAndIncisedWidth.begin(), flowAndIncisedWidth.end());
|
||
const size_t quartile = std::max<size_t>(1, flowAndIncisedWidth.size() / 4);
|
||
float headwaterWidth = 0.f, trunkWidth = 0.f;
|
||
for (size_t n = 0; n < quartile; ++n) {
|
||
headwaterWidth += float(flowAndIncisedWidth[n].second);
|
||
trunkWidth += float(flowAndIncisedWidth[flowAndIncisedWidth.size() - 1 - n].second);
|
||
}
|
||
CHECK(trunkWidth / float(quartile) > headwaterWidth / float(quartile) + 0.5f);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.pipeline.resolutionScaledHydrologyAndValleys") {
|
||
auto physicalTerrain = [](int resolution) {
|
||
Heightmap map(resolution, resolution);
|
||
const float extent = float(resolution - 1);
|
||
for (int y = 0; y < resolution; ++y) for (int x = 0; x < resolution; ++x) {
|
||
const float u = float(x) / extent, v = float(y) / extent;
|
||
const float ridge = 0.075f * std::sin(u * 6.28318530718f) +
|
||
0.035f * std::sin((u * 2.1f + v * 0.55f) * 6.28318530718f);
|
||
map.setHeight(x, y, 0.88f - 0.34f * v + ridge);
|
||
}
|
||
return map;
|
||
};
|
||
Heightmap coarse = physicalTerrain(33);
|
||
Heightmap fine = physicalTerrain(65);
|
||
const HydrologyMap coarseBefore = TerrainPipeline::buildHydrology(
|
||
coarse, 0.015f, -std::numeric_limits<float>::infinity(), 1.f);
|
||
const HydrologyMap fineBefore = TerrainPipeline::buildHydrology(
|
||
fine, 0.015f, -std::numeric_limits<float>::infinity(), 2.f);
|
||
const float coarseRiverFraction = float(std::count(
|
||
coarseBefore.rivers.begin(), coarseBefore.rivers.end(), uint8_t(1))) /
|
||
float(coarseBefore.rivers.size());
|
||
const float fineRiverFraction = float(std::count(
|
||
fineBefore.rivers.begin(), fineBefore.rivers.end(), uint8_t(1))) /
|
||
float(fineBefore.rivers.size());
|
||
CHECK(fineRiverFraction > coarseRiverFraction * 0.45f);
|
||
CHECK(fineRiverFraction < coarseRiverFraction * 2.2f);
|
||
const float coarsePeakFlow = *std::max_element(
|
||
coarseBefore.flowAccumulation.begin(), coarseBefore.flowAccumulation.end()) /
|
||
float(coarse.data().size());
|
||
const float finePeakFlow = *std::max_element(
|
||
fineBefore.flowAccumulation.begin(), fineBefore.flowAccumulation.end()) /
|
||
float(fine.data().size());
|
||
CHECK(std::abs(coarsePeakFlow - finePeakFlow) < 0.12f);
|
||
|
||
const Heightmap coarseOriginal = coarse, fineOriginal = fine;
|
||
TerrainPipeline::erodeFluvial(coarse, {10, 0.015f, 0.055f, 0.10f, 3.f, 0.01f, 1.f});
|
||
TerrainPipeline::erodeFluvial(fine, {10, 0.015f, 0.055f, 0.10f, 6.f, 0.01f, 2.f});
|
||
float correspondenceError = 0.f, coarseLowering = 0.f, fineLowering = 0.f;
|
||
int samples = 0;
|
||
for (int y = 2; y < 31; ++y) for (int x = 2; x < 31; ++x) {
|
||
correspondenceError += std::abs(coarse.height(x, y) - fine.height(x * 2, y * 2));
|
||
coarseLowering += coarseOriginal.height(x, y) - coarse.height(x, y);
|
||
fineLowering += fineOriginal.height(x * 2, y * 2) - fine.height(x * 2, y * 2);
|
||
++samples;
|
||
}
|
||
correspondenceError /= float(samples);
|
||
coarseLowering /= float(samples);
|
||
fineLowering /= float(samples);
|
||
CHECK(correspondenceError < 0.035f);
|
||
REQUIRE(coarseLowering > 0.f);
|
||
CHECK(fineLowering > coarseLowering * 0.35f);
|
||
CHECK(fineLowering < coarseLowering * 2.8f);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.asset.chunkedRoundTripAndCorruptionDetection") {
|
||
Heightmap heightmap(19, 13);
|
||
for (int y = 0; y < heightmap.getHeight(); ++y)
|
||
for (int x = 0; x < heightmap.getWidth(); ++x)
|
||
heightmap.setHeight(x, y, 10.f + float(x) * 0.25f + float(y) * 0.5f);
|
||
HydrologyMap hydrology = TerrainPipeline::buildHydrology(heightmap, 5.f, 10.f);
|
||
hydrology.lakeDepth[size_t(12 * 19 + 18)] = 2.1f;
|
||
const ClimateMap climate = TerrainPipeline::buildClimate(heightmap, hydrology, 10.f, 0.6f);
|
||
std::vector<uint8_t> bytesA, bytesB;
|
||
std::string error;
|
||
REQUIRE(TerrainAsset::bake(heightmap, hydrology, climate, 8, bytesA, &error));
|
||
REQUIRE(TerrainAsset::bake(heightmap, hydrology, climate, 8, bytesB, &error));
|
||
CHECK(bytesA == bytesB);
|
||
|
||
TerrainAsset asset;
|
||
REQUIRE(asset.open(bytesA.data(), bytesA.size(), &error));
|
||
CHECK_EQ(asset.getWidth(), 19);
|
||
CHECK_EQ(asset.getHeight(), 13);
|
||
CHECK_EQ(asset.getChunkSize(), 8);
|
||
CHECK_EQ(asset.chunks().size(), size_t(6));
|
||
CHECK(std::any_of(asset.chunks().begin(), asset.chunks().end(),
|
||
[](const TerrainChunkEntry &entry) { return entry.compressed; }));
|
||
|
||
TerrainChunkData edge;
|
||
REQUIRE(asset.loadChunk(2, 1, edge, &error));
|
||
CHECK_EQ(edge.width, 3);
|
||
CHECK_EQ(edge.height, 5);
|
||
CHECK(std::abs(edge.heights.height(2, 4) - heightmap.height(18, 12)) < 0.001f);
|
||
CHECK_EQ(edge.rivers.size(), size_t(15));
|
||
CHECK_EQ(edge.flowDirection.size(), size_t(15));
|
||
CHECK_EQ(int(edge.flowDirection.front()),
|
||
int(hydrology.flowDirection[size_t(8 * 19 + 16)]));
|
||
CHECK_EQ(edge.flowVectorX.size(), size_t(15));
|
||
CHECK_EQ(edge.flowVectorY.size(), size_t(15));
|
||
CHECK(std::abs(edge.flowVectorX.front() -
|
||
hydrology.flowVectorX[size_t(8 * 19 + 16)]) < 0.009f);
|
||
CHECK(std::abs(edge.flowVectorY.front() -
|
||
hydrology.flowVectorY[size_t(8 * 19 + 16)]) < 0.009f);
|
||
CHECK_EQ(edge.streamOrder.size(), size_t(15));
|
||
CHECK_EQ(edge.streamOrder.front(), hydrology.streamOrder[size_t(8 * 19 + 16)]);
|
||
CHECK_EQ(edge.lakeDepth.size(), size_t(15));
|
||
CHECK(std::abs(edge.lakeDepth.back() - 2.1f) < 0.05f);
|
||
CHECK_EQ(edge.biomes.size(), size_t(15));
|
||
|
||
std::vector<uint8_t> corrupt = bytesA;
|
||
const TerrainChunkEntry first = asset.chunks().front();
|
||
corrupt[size_t(first.offset) + first.storedSize / 2] ^= 0x5au;
|
||
TerrainAsset corruptAsset;
|
||
REQUIRE(corruptAsset.open(corrupt.data(), corrupt.size(), &error));
|
||
TerrainChunkData rejected;
|
||
CHECK(!corruptAsset.loadChunk(first.chunkX, first.chunkY, rejected, &error));
|
||
|
||
std::vector<uint8_t> malformed = bytesA;
|
||
malformed[57] = 1; // directory reserved bytes must remain zero
|
||
TerrainAsset malformedAsset;
|
||
CHECK(!malformedAsset.open(malformed.data(), malformed.size(), &error));
|
||
malformed = bytesA;
|
||
malformed[44] = 0xff; malformed[45] = 0xff; malformed[46] = 0xff; malformed[47] = 0x7f;
|
||
CHECK(!malformedAsset.open(malformed.data(), malformed.size(), &error));
|
||
malformed = bytesA;
|
||
malformed[24] = 0; malformed[25] = 0; malformed[26] = 0xc0; malformed[27] = 0x7f; // NaN min height
|
||
CHECK(!malformedAsset.open(malformed.data(), malformed.size(), &error));
|
||
|
||
// EVTR v1/v2/v3/v4 remain readable. Missing layers are supplied with safe
|
||
// defaults or reconstructed from D8 instead of invalidating a world.
|
||
auto legacyAsset = [](uint16_t version) {
|
||
std::vector<uint8_t> raw{0x00, 0x80, 0xff}; // height UNORM16, drainage
|
||
if (version >= 3) raw.push_back(5); // D8 east (encoded + 1)
|
||
if (version >= 4) raw.insert(raw.end(), {0xff, 0x80}); // continuous east
|
||
if (version >= 2) raw.push_back(0x40); // lake depth
|
||
raw.insert(raw.end(), {0x80, 0x40, 0x01, uint8_t(Biome::Grassland)});
|
||
uint32_t hash = 2166136261u;
|
||
for (uint8_t byte : raw) { hash ^= byte; hash *= 16777619u; }
|
||
std::vector<uint8_t> result{'E', 'V', 'T', 'R'};
|
||
auto u16 = [&](uint16_t v) { result.push_back(uint8_t(v)); result.push_back(uint8_t(v >> 8)); };
|
||
auto u32 = [&](uint32_t v) { for (int b = 0; b < 4; ++b) result.push_back(uint8_t(v >> (b * 8))); };
|
||
auto u64 = [&](uint64_t v) { for (int b = 0; b < 8; ++b) result.push_back(uint8_t(v >> (b * 8))); };
|
||
auto f32 = [&](float v) { uint32_t bits; std::memcpy(&bits, &v, 4); u32(bits); };
|
||
u16(version); u16(0); u32(1); u32(1); u32(1); u32(1);
|
||
f32(0.f); f32(1.f); f32(10.f); u64(44);
|
||
u32(0); u32(0); u16(1); u16(1);
|
||
result.insert(result.end(), {0, 0, 0, 0}); u64(80);
|
||
u32(uint32_t(raw.size())); u32(uint32_t(raw.size())); u32(hash);
|
||
result.insert(result.end(), raw.begin(), raw.end());
|
||
return result;
|
||
};
|
||
for (uint16_t version : {uint16_t(1), uint16_t(2), uint16_t(3), uint16_t(4)}) {
|
||
const std::vector<uint8_t> legacy = legacyAsset(version);
|
||
TerrainAsset legacyReader;
|
||
REQUIRE(legacyReader.open(legacy.data(), legacy.size(), &error));
|
||
TerrainChunkData legacyChunk;
|
||
REQUIRE(legacyReader.loadChunk(0, 0, legacyChunk, &error));
|
||
CHECK_EQ(int(legacyChunk.flowDirection[0]), version >= 3 ? 4 : -1);
|
||
CHECK(std::abs(legacyChunk.flowVectorX[0] - (version >= 3 ? 1.f : 0.f)) < 0.001f);
|
||
CHECK(std::abs(legacyChunk.flowVectorY[0]) < 0.001f);
|
||
CHECK_EQ(legacyChunk.streamOrder[0], uint8_t(0));
|
||
if (version == 1) CHECK_EQ(legacyChunk.lakeDepth[0], 0.f);
|
||
else CHECK(std::abs(legacyChunk.lakeDepth[0] - float(0x40) / 255.f) < 0.0001f);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.streaming.budgetEvictionAndCrossChunkSampling") {
|
||
Heightmap heightmap(24, 24);
|
||
for (int y = 0; y < 24; ++y)
|
||
for (int x = 0; x < 24; ++x) heightmap.setHeight(x, y, float(x + y * 2));
|
||
HydrologyMap hydrology = TerrainPipeline::buildHydrology(heightmap, 5.f, -1.f);
|
||
hydrology.lakeDepth[size_t(12 * 24 + 12)] = 3.f;
|
||
const ClimateMap climate = TerrainPipeline::buildClimate(heightmap, hydrology, -1.f, 0.5f);
|
||
std::vector<uint8_t> bytes;
|
||
std::string error;
|
||
REQUIRE(TerrainAsset::bake(heightmap, hydrology, climate, 8, bytes, &error));
|
||
|
||
TerrainStreamingCache stream;
|
||
REQUIRE(stream.open(bytes.data(), bytes.size(), &error));
|
||
TerrainStreamStats first = stream.streamAround(12, 12, 1, 2, &error);
|
||
CHECK_EQ(first.loaded, 2);
|
||
CHECK_EQ(first.pending, 3);
|
||
CHECK_EQ(first.resident, 2);
|
||
TerrainStreamStats second = stream.streamAround(12, 12, 1, 0, &error);
|
||
CHECK_EQ(second.loaded, 3);
|
||
CHECK_EQ(second.pending, 0);
|
||
CHECK_EQ(second.resident, 5);
|
||
TerrainStreamStats stable = stream.streamAround(12, 12, 1, 0, &error);
|
||
CHECK_EQ(stable.loaded, 0);
|
||
CHECK_EQ(stable.evicted, 0);
|
||
|
||
float interpolated = 0.f;
|
||
REQUIRE(stream.sampleHeight(7.5f, 12.f, interpolated));
|
||
CHECK(std::abs(interpolated - 31.5f) < 0.002f);
|
||
TerrainSample sample;
|
||
REQUIRE(stream.sampleCell(12, 12, sample));
|
||
CHECK(std::abs(sample.height - 36.f) < 0.002f);
|
||
CHECK_EQ(sample.flowDirection, int(hydrology.flowDirection[size_t(12 * 24 + 12)]));
|
||
CHECK(std::abs(sample.flowVectorX - hydrology.flowVectorX[size_t(12 * 24 + 12)]) < 0.009f);
|
||
CHECK(std::abs(sample.flowVectorY - hydrology.flowVectorY[size_t(12 * 24 + 12)]) < 0.009f);
|
||
CHECK_EQ(sample.streamOrder, int(hydrology.streamOrder[size_t(12 * 24 + 12)]));
|
||
CHECK(std::abs(sample.lakeDepth - 3.f) < 0.28f);
|
||
CHECK(sample.lake);
|
||
|
||
TerrainStreamStats moved = stream.streamAround(23, 23, 0, 0, &error);
|
||
CHECK_EQ(moved.evicted, 5);
|
||
CHECK_EQ(moved.loaded, 1);
|
||
CHECK_EQ(moved.resident, 1);
|
||
CHECK(!stream.sampleCell(12, 12, sample));
|
||
REQUIRE(stream.sampleCell(23, 23, sample));
|
||
CHECK(std::abs(sample.height - 69.f) < 0.002f);
|
||
TerrainStreamStats noOp = stream.streamAround(0, 0, -1, 0, &error);
|
||
CHECK_EQ(noOp.resident, 1);
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.streaming.crossChunkHydrologyTraceAndHalo") {
|
||
Heightmap heightmap(24, 9);
|
||
for (int y = 0; y < 9; ++y) for (int x = 0; x < 24; ++x)
|
||
heightmap.setHeight(x, y, 100.f - float(x) * 2.f + std::abs(float(y - 4)) * 3.f);
|
||
HydrologyMap hydrology = TerrainPipeline::buildHydrology(heightmap, 2.f, -1.f);
|
||
const ClimateMap climate = TerrainPipeline::buildClimate(heightmap, hydrology, -1.f, 0.5f);
|
||
std::vector<uint8_t> bytes;
|
||
std::string error;
|
||
REQUIRE(TerrainAsset::bake(heightmap, hydrology, climate, 8, bytes, &error));
|
||
|
||
TerrainStreamingCache stream;
|
||
REQUIRE(stream.open(bytes.data(), bytes.size(), &error));
|
||
REQUIRE_EQ(stream.streamAround(7, 4, 1, 0, &error).resident, 3);
|
||
|
||
int receiverX = -1, receiverY = -1;
|
||
REQUIRE(stream.getReceiver(7, 4, receiverX, receiverY));
|
||
CHECK_EQ(receiverX, 8);
|
||
CHECK_EQ(receiverY, 4);
|
||
|
||
std::vector<std::pair<int, int>> path;
|
||
CHECK(!stream.traceFlow(7, 4, 64, path)); // third X chunk is not resident yet
|
||
CHECK(path.size() > size_t(8));
|
||
REQUIRE_EQ(stream.streamAround(12, 4, 2, 0, &error).resident, 6);
|
||
REQUIRE(stream.traceFlow(7, 4, 64, path));
|
||
CHECK_EQ(path.front().first, 7);
|
||
CHECK_EQ(path.front().second, 4);
|
||
CHECK_EQ(path.back().first, 23);
|
||
CHECK_EQ(path.back().second, 4);
|
||
|
||
TerrainStreamingWindow halo;
|
||
REQUIRE(stream.buildWindow(7, 3, 3, 3, halo));
|
||
CHECK_EQ(halo.originX, 7);
|
||
CHECK_EQ(halo.heights.getWidth(), 3);
|
||
for (int y = 0; y < 3; ++y) for (int x = 0; x < 3; ++x) {
|
||
const size_t local = size_t(y * 3 + x);
|
||
const size_t global = size_t((y + 3) * 24 + x + 7);
|
||
CHECK(std::abs(halo.heights.height(x, y) - heightmap.height(x + 7, y + 3)) < 0.002f);
|
||
CHECK_EQ(int(halo.hydrology.flowDirection[local]), int(hydrology.flowDirection[global]));
|
||
CHECK_EQ(halo.hydrology.streamOrder[local], hydrology.streamOrder[global]);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.module.erosionAndAnalysisApi") {
|
||
Procgen procgen;
|
||
std::unique_ptr<Heightmap> heightmap(procgen.newHeightmap(16, 16));
|
||
for (int y = 0; y < 16; ++y)
|
||
for (int x = 0; x < 16; ++x)
|
||
heightmap->setHeight(x, y, 0.85f - float(y) * 0.035f + (x == 8 ? -0.1f : 0.f));
|
||
const std::vector<float> before = heightmap->data();
|
||
CHECK(procgen.erodeTerrainThermal(heightmap.get(), 8, 0.02f, 0.3f));
|
||
CHECK(procgen.erodeTerrainHydraulic(heightmap.get(), 8, 0.01f, 0.1f, 2.f, 0.15f, 0.1f));
|
||
CHECK(procgen.erodeTerrainFluvial(heightmap.get(), 3, 0.03f, 0.006f, 0.08f, 1.5f));
|
||
CHECK(procgen.erodeTerrainFluvialAdvanced(
|
||
heightmap.get(), 2, 0.03f, 0.006f, 0.08f, 1.5f, 0.015f));
|
||
CHECK(procgen.erodeTerrainFluvialScaled(
|
||
heightmap.get(), 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 2.f));
|
||
std::unique_ptr<TerrainErosionMap> diagnostics(procgen.erodeTerrainFluvialDetailed(
|
||
heightmap.get(), 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 2.f));
|
||
REQUIRE(diagnostics.get() != nullptr);
|
||
CHECK_EQ(diagnostics->getWidth(), 16);
|
||
CHECK(diagnostics->getWear(8, 8) >= 0.f);
|
||
CHECK(diagnostics->getDeposition(8, 8) >= 0.f);
|
||
CHECK_EQ(diagnostics->getWear(-1, 0), 0.f);
|
||
CHECK(procgen.erodeTerrainFluvialDetailed(
|
||
heightmap.get(), 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 0.f) == nullptr);
|
||
CHECK(!procgen.erodeTerrainFluvialScaled(
|
||
heightmap.get(), 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 0.f));
|
||
CHECK(heightmap->data() != before);
|
||
std::unique_ptr<TerrainLayers> layers(procgen.analyzeTerrain(heightmap.get(), 4.f, 0.2f, 0.7f));
|
||
REQUIRE(layers.get() != nullptr);
|
||
std::unique_ptr<TerrainLayers> scaledLayers(
|
||
procgen.analyzeTerrainScaled(heightmap.get(), 4.f, 0.2f, 0.7f, 2.f));
|
||
REQUIRE(scaledLayers.get() != nullptr);
|
||
CHECK(procgen.analyzeTerrainScaled(heightmap.get(), 4.f, 0.2f, 0.7f, 0.f) == nullptr);
|
||
CHECK_EQ(layers->getWidth(), 16);
|
||
CHECK(layers->getFlowAccumulation(8, 8) >= 1.f);
|
||
CHECK(layers->getStreamOrder(8, 8) >= 0);
|
||
CHECK(layers->getTemperature(8, 8) >= 0.f);
|
||
CHECK(layers->getMoisture(8, 8) >= 0.f);
|
||
CHECK(!layers->getBiomeName(8, 8).empty());
|
||
CHECK_EQ(layers->getBiome(-1, 0), -1);
|
||
std::unique_ptr<eve::data::ByteData> archive(
|
||
procgen.bakeTerrainAsset(heightmap.get(), layers.get(), 8));
|
||
REQUIRE(archive.get() != nullptr);
|
||
TerrainAsset opened;
|
||
CHECK(opened.open(static_cast<const uint8_t *>(archive->getData()), archive->getSize()));
|
||
CHECK_EQ(opened.chunks().size(), size_t(4));
|
||
CHECK(!procgen.erodeTerrainThermal(nullptr, 1, 0.1f, 0.1f));
|
||
CHECK(!procgen.lastError().empty());
|
||
}
|
||
|
||
TEST_CASE("procgen.terrain.mesh.lodSkirtsStableSeamsAndMaterialWeights") {
|
||
Heightmap heightmap(33, 17);
|
||
for (int y = 0; y < 17; ++y)
|
||
for (int x = 0; x < 33; ++x)
|
||
heightmap.setHeight(x, y, 0.2f + float(x) * 0.012f + 0.04f * std::sin(float(y) * 0.4f));
|
||
HydrologyMap hydrology;
|
||
hydrology.width = 33; hydrology.height = 17;
|
||
hydrology.flowDirection.assign(33 * 17, -1);
|
||
hydrology.flowAccumulation.assign(33 * 17, 1.f);
|
||
hydrology.rivers.assign(33 * 17, 0);
|
||
ClimateMap climate;
|
||
climate.width = 33; climate.height = 17;
|
||
climate.temperature.assign(33 * 17, 0.6f);
|
||
climate.moisture.assign(33 * 17, 0.7f);
|
||
climate.biomes.assign(33 * 17, Biome::Forest);
|
||
for (int y = 0; y < 17; ++y)
|
||
for (int x = 16; x < 33; ++x) climate.biomes[size_t(y * 33 + x)] = Biome::Desert;
|
||
TerrainLayers layers(std::move(hydrology), std::move(climate));
|
||
|
||
TerrainMeshSettings settings;
|
||
settings.cellsX = 16; settings.cellsY = 16;
|
||
settings.cellSize = 2.f; settings.heightScale = 10.f; settings.skirtDepth = 3.f;
|
||
TerrainMeshSettings lodErrorSettings = settings;
|
||
lodErrorSettings.lod = 1;
|
||
const float lod1Error = TerrainMeshBuilder::estimateGeometricError(heightmap, lodErrorSettings);
|
||
lodErrorSettings.lod = 2;
|
||
const float lod2Error = TerrainMeshBuilder::estimateGeometricError(heightmap, lodErrorSettings);
|
||
CHECK(lod1Error > 0.f);
|
||
CHECK(lod2Error >= lod1Error);
|
||
const int nearLod = TerrainLodSelector::select(heightmap, settings, 3, 5.f, 1080.f, 60.f, 1.f);
|
||
const int farLod = TerrainLodSelector::select(heightmap, settings, 3, 10000.f, 1080.f, 60.f, 1.f);
|
||
CHECK_EQ(nearLod, 0);
|
||
CHECK_EQ(farLod, 3);
|
||
CHECK_EQ(TerrainLodSelector::select(heightmap, settings, 3, -1.f, 1080.f, 60.f, 1.f), -1);
|
||
TerrainMeshChunk left, right;
|
||
std::string error;
|
||
REQUIRE(TerrainMeshBuilder::build(heightmap, &layers, settings, left, &error));
|
||
settings.originX = 16;
|
||
REQUIRE(TerrainMeshBuilder::build(heightmap, &layers, settings, right, &error));
|
||
CHECK_EQ(left.getBaseVertexCount(), 17 * 17);
|
||
CHECK_EQ(left.getVertexCount(), 17 * 17 + 4 * 17 * 2);
|
||
CHECK_EQ(left.getIndexCount(), 16 * 16 * 6 + 4 * 16 * 6);
|
||
CHECK(meshIndicesInRange(left.mesh()));
|
||
CHECK(meshNormalsFiniteUnit(left.mesh(), 0.02f));
|
||
for (int y = 0; y < 17; ++y) {
|
||
const int li = y * 17 + 16, ri = y * 17;
|
||
CHECK(std::abs(left.mesh().getPositionY(li) - right.mesh().getPositionY(ri)) < 0.0001f);
|
||
CHECK(std::abs(left.mesh().getNormalX(li) - right.mesh().getNormalX(ri)) < 0.0001f);
|
||
CHECK(std::abs(left.mesh().getNormalY(li) - right.mesh().getNormalY(ri)) < 0.0001f);
|
||
CHECK(std::abs(left.mesh().getNormalZ(li) - right.mesh().getNormalZ(ri)) < 0.0001f);
|
||
CHECK_EQ(left.getBiome(li), right.getBiome(ri));
|
||
for (int channel = 0; channel < 4; ++channel)
|
||
CHECK(std::abs(left.getMaterialWeight(li, channel) -
|
||
right.getMaterialWeight(ri, channel)) < 0.0001f);
|
||
}
|
||
const int firstSkirtTop = left.getBaseVertexCount(), firstSkirtBottom = firstSkirtTop + 1;
|
||
CHECK(std::abs(left.mesh().getPositionY(firstSkirtTop) -
|
||
left.mesh().getPositionY(firstSkirtBottom) - 3.f) < 0.0001f);
|
||
for (int vertex = 0; vertex < left.getVertexCount(); ++vertex) {
|
||
float sum = 0.f;
|
||
for (int channel = 0; channel < 4; ++channel) {
|
||
CHECK(left.getMaterialWeight(vertex, channel) >= 0.f);
|
||
sum += left.getMaterialWeight(vertex, channel);
|
||
}
|
||
CHECK(std::abs(sum - 1.f) < 0.0001f);
|
||
}
|
||
CHECK_EQ(left.getBiome(4), int(Biome::Forest));
|
||
CHECK_EQ(right.getBiome(4), int(Biome::Desert));
|
||
CHECK_EQ(left.getBiome(-1), -1);
|
||
CHECK(left.getMaterialWeight(4, 1) > left.getMaterialWeight(4, 0));
|
||
CHECK(right.getMaterialWeight(4, 0) > right.getMaterialWeight(4, 1));
|
||
Procgen procgen;
|
||
std::unique_ptr<eve::image::ImageData> splat(procgen.generateTerrainSplatMap(&left));
|
||
std::unique_ptr<eve::image::ImageData> rightSplat(procgen.generateTerrainSplatMap(&right));
|
||
REQUIRE(splat.get() != nullptr);
|
||
REQUIRE(rightSplat.get() != nullptr);
|
||
CHECK_EQ(splat->getWidth(), 17);
|
||
CHECK_EQ(splat->getHeight(), 17);
|
||
const auto *rgba = static_cast<const uint8_t *>(splat->getData());
|
||
const auto *rightRgba = static_cast<const uint8_t *>(rightSplat->getData());
|
||
for (int vertex = 0; vertex < left.getBaseVertexCount(); ++vertex) {
|
||
int sum = 0;
|
||
for (int channel = 0; channel < 4; ++channel) {
|
||
sum += rgba[vertex * 4 + channel];
|
||
CHECK(std::abs(float(rgba[vertex * 4 + channel]) / 255.f -
|
||
left.getMaterialWeight(vertex, channel)) <= 1.f / 255.f);
|
||
}
|
||
CHECK_EQ(sum, 255);
|
||
}
|
||
for (int y = 0; y < 17; ++y) for (int channel = 0; channel < 4; ++channel)
|
||
CHECK_EQ(rgba[(y * 17 + 16) * 4 + channel],
|
||
rightRgba[(y * 17) * 4 + channel]);
|
||
CHECK(procgen.generateTerrainSplatMap(nullptr) == nullptr);
|
||
CHECK(!procgen.lastError().empty());
|
||
std::unique_ptr<eve::image::ImageData> albedo(procgen.generateTerrainAlbedoMap(&left));
|
||
REQUIRE(albedo.get() != nullptr);
|
||
CHECK_EQ(albedo->getWidth(), 17);
|
||
CHECK_EQ(albedo->getHeight(), 17);
|
||
const auto *albedoRgba = static_cast<const uint8_t *>(albedo->getData());
|
||
for (int vertex = 0; vertex < left.getBaseVertexCount(); ++vertex)
|
||
CHECK_EQ(albedoRgba[vertex * 4 + 3], uint8_t(255));
|
||
CHECK(procgen.generateTerrainAlbedoMap(nullptr) == nullptr);
|
||
CHECK(!procgen.lastError().empty());
|
||
|
||
settings.originX = 0; settings.lod = 1;
|
||
TerrainMeshChunk lod1;
|
||
REQUIRE(TerrainMeshBuilder::build(heightmap, &layers, settings, lod1, &error));
|
||
CHECK_EQ(lod1.getLodStep(), 2);
|
||
CHECK(std::abs(lod1.getGeometricError() - lod1Error) < 0.0001f);
|
||
CHECK_EQ(lod1.getBaseVertexCount(), 9 * 9);
|
||
CHECK_EQ(lod1.getVertexCount(), 9 * 9 + 4 * 9 * 2);
|
||
CHECK_EQ(lod1.getIndexCount(), 8 * 8 * 6 + 4 * 8 * 6);
|
||
for (int y = 0; y < 9; ++y) for (int x = 0; x < 9; ++x) {
|
||
const int coarse = y * 9 + x;
|
||
const int fine = (y * 2) * 17 + x * 2;
|
||
CHECK_EQ(lod1.getBiome(coarse), left.getBiome(fine));
|
||
for (int channel = 0; channel < 4; ++channel)
|
||
CHECK(std::abs(lod1.getMaterialWeight(coarse, channel) -
|
||
left.getMaterialWeight(fine, channel)) < 0.0001f);
|
||
}
|
||
|
||
Heightmap riverHeight(9, 9);
|
||
HydrologyMap riverHydrology;
|
||
riverHydrology.width = 9; riverHydrology.height = 9;
|
||
riverHydrology.flowDirection.assign(81, -1);
|
||
riverHydrology.flowAccumulation.assign(81, 1.f);
|
||
riverHydrology.rivers.assign(81, 0);
|
||
ClimateMap riverClimate;
|
||
riverClimate.width = 9; riverClimate.height = 9;
|
||
riverClimate.temperature.assign(81, 0.5f);
|
||
riverClimate.moisture.assign(81, 0.8f);
|
||
riverClimate.biomes.assign(81, Biome::Grassland);
|
||
for (int y = 0; y < 8; ++y) {
|
||
const size_t i = size_t(y * 9 + 4);
|
||
riverHeight.setHeight(4, y, 0.8f - float(y) * 0.05f);
|
||
riverHydrology.flowDirection[i] = 6;
|
||
riverHydrology.flowAccumulation[i] = float(y + 2) * 10.f;
|
||
riverHydrology.rivers[i] = 1;
|
||
riverClimate.biomes[i] = Biome::River;
|
||
}
|
||
TerrainLayers riverLayers(std::move(riverHydrology), std::move(riverClimate));
|
||
TerrainRiverMeshSettings riverSettings;
|
||
riverSettings.cellsX = 8; riverSettings.cellsY = 8;
|
||
riverSettings.minWidth = 0.1f; riverSettings.maxWidth = 0.5f;
|
||
MeshBuild riverMesh;
|
||
REQUIRE(TerrainRiverMeshBuilder::build(riverHeight, riverLayers, riverSettings, riverMesh, &error));
|
||
CHECK_EQ(riverMesh.getVertexCount(), 8 * (4 + 9));
|
||
CHECK_EQ(riverMesh.getIndexCount(), 8 * (6 + 24));
|
||
CHECK(meshIndicesInRange(riverMesh));
|
||
CHECK(meshNormalsFiniteUnit(riverMesh, 0.001f));
|
||
|
||
TerrainRiverMeshSettings excludedSlope = riverSettings;
|
||
excludedSlope.minSurfaceSlope = 0.10f;
|
||
excludedSlope.maxSurfaceSlope = 0.20f;
|
||
MeshBuild excludedRiverMesh;
|
||
REQUIRE(TerrainRiverMeshBuilder::build(
|
||
riverHeight, riverLayers, excludedSlope, excludedRiverMesh, &error));
|
||
CHECK(excludedRiverMesh.empty());
|
||
excludedSlope.maxSurfaceSlope = excludedSlope.minSurfaceSlope;
|
||
CHECK(!TerrainRiverMeshBuilder::build(
|
||
riverHeight, riverLayers, excludedSlope, excludedRiverMesh, &error));
|
||
|
||
TerrainRiverMeshSettings leftRiverSettings = riverSettings;
|
||
leftRiverSettings.cellsX = 4;
|
||
TerrainRiverMeshSettings rightRiverSettings = leftRiverSettings;
|
||
rightRiverSettings.originX = 4;
|
||
MeshBuild leftRiverMesh, rightRiverMesh;
|
||
REQUIRE(TerrainRiverMeshBuilder::build(riverHeight, riverLayers, leftRiverSettings,
|
||
leftRiverMesh, &error));
|
||
REQUIRE(TerrainRiverMeshBuilder::build(riverHeight, riverLayers, rightRiverSettings,
|
||
rightRiverMesh, &error));
|
||
REQUIRE_EQ(leftRiverMesh.getVertexCount(), rightRiverMesh.getVertexCount());
|
||
for (int vertex = 0; vertex < leftRiverMesh.getVertexCount(); ++vertex) {
|
||
CHECK(std::abs(leftRiverMesh.getPositionX(vertex) -
|
||
(rightRiverMesh.getPositionX(vertex) + 4.f)) < 0.0001f);
|
||
CHECK(std::abs(leftRiverMesh.getPositionY(vertex) -
|
||
rightRiverMesh.getPositionY(vertex)) < 0.0001f);
|
||
CHECK(std::abs(leftRiverMesh.getPositionZ(vertex) -
|
||
rightRiverMesh.getPositionZ(vertex)) < 0.0001f);
|
||
}
|
||
|
||
Heightmap lakeHeight(9, 9);
|
||
HydrologyMap lakeHydrology;
|
||
lakeHydrology.width = 9; lakeHydrology.height = 9;
|
||
lakeHydrology.flowDirection.assign(81, -1);
|
||
lakeHydrology.flowAccumulation.assign(81, 1.f);
|
||
lakeHydrology.lakeDepth.assign(81, 0.f);
|
||
lakeHydrology.rivers.assign(81, 0);
|
||
ClimateMap lakeClimate;
|
||
lakeClimate.width = 9; lakeClimate.height = 9;
|
||
lakeClimate.temperature.assign(81, 0.5f);
|
||
lakeClimate.moisture.assign(81, 1.f);
|
||
lakeClimate.biomes.assign(81, Biome::Grassland);
|
||
for (int y = 2; y <= 6; ++y) for (int x = 2; x <= 6; ++x)
|
||
lakeHydrology.lakeDepth[size_t(y * 9 + x)] = 0.2f;
|
||
TerrainLayers lakeLayers(std::move(lakeHydrology), std::move(lakeClimate));
|
||
TerrainLakeMeshSettings lakeSettings;
|
||
lakeSettings.cellsX = 8; lakeSettings.cellsY = 8; lakeSettings.minimumDepth = 0.01f;
|
||
MeshBuild lakeMesh;
|
||
REQUIRE(TerrainLakeMeshBuilder::build(lakeHeight, lakeLayers, lakeSettings, lakeMesh, &error));
|
||
CHECK(lakeMesh.getVertexCount() > 0);
|
||
CHECK(meshIndicesInRange(lakeMesh));
|
||
CHECK(meshNormalsFiniteUnit(lakeMesh, 0.001f));
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.reproducible") {
|
||
GeneratorRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setSeed(42);
|
||
p.setSize(24, 18);
|
||
p.setString("preset", "dungeon");
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D a, b;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, a, err));
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, b, err));
|
||
CHECK(gridsEqual(a, b));
|
||
CHECK(countSemantic(a, int(Semantic::Wall)) > 0);
|
||
CHECK(countSemantic(a, int(Semantic::Floor)) + countSemantic(a, int(Semantic::Corridor)) > 0);
|
||
CHECK(borderIsWall(a));
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.cavePreset") {
|
||
Params p;
|
||
p.setSeed(11);
|
||
p.setSize(20, 16);
|
||
p.setString("preset", "cave");
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D g;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, g, err));
|
||
CHECK(borderIsWall(g));
|
||
CHECK(caveAdjacencyOk(g));
|
||
CHECK(countSemantic(g, int(Semantic::Floor)) > 0);
|
||
CHECK(countSemantic(g, int(Semantic::Wall)) > 0);
|
||
CHECK_EQ(g.getMeta("algorithm", ""), std::string("wfc.simple"));
|
||
CHECK_EQ(g.getMeta("preset", ""), std::string("cave"));
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.terrainPreset") {
|
||
Params p;
|
||
p.setSeed(7);
|
||
p.setSize(24, 20);
|
||
p.setString("preset", "terrain");
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D g, g2;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, g, err));
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, g2, err));
|
||
CHECK(gridsEqual(g, g2));
|
||
CHECK(terrainAdjacencyOk(g));
|
||
std::set<int> kinds;
|
||
for (uint32_t c : g.cells()) kinds.insert(int(c));
|
||
CHECK(kinds.size() >= 1);
|
||
// Terrain does not force wall border.
|
||
CHECK(countSemantic(g, int(Semantic::Wall)) == 0);
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.dungeonObjectsAndMeta") {
|
||
Params p;
|
||
p.setSeed(99);
|
||
p.setSize(28, 20);
|
||
p.setString("preset", "dungeon");
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D g;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, g, err));
|
||
CHECK_EQ(g.getMeta("algorithm", ""), std::string("wfc.simple"));
|
||
CHECK_EQ(g.getMeta("preset", ""), std::string("dungeon"));
|
||
// Walkable cells should produce spawn + stairs.
|
||
const int walkable =
|
||
countSemantic(g, int(Semantic::Floor)) + countSemantic(g, int(Semantic::Corridor));
|
||
if (walkable > 0) {
|
||
CHECK(g.getObjectCount() >= 2);
|
||
std::unordered_set<std::string> types;
|
||
for (int i = 0; i < g.getObjectCount(); ++i) types.insert(g.getObjectType(i));
|
||
CHECK(types.count("spawn") == 1);
|
||
CHECK(types.count("stairs") == 1);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.seedVaries") {
|
||
Params a, b;
|
||
a.setSeed(1);
|
||
b.setSeed(2);
|
||
a.setSize(18, 14);
|
||
b.setSize(18, 14);
|
||
a.setString("preset", "cave");
|
||
b.setString("preset", "cave");
|
||
a.setInt("maxAttempts", 64);
|
||
b.setInt("maxAttempts", 64);
|
||
Grid2D ga, gb;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", a, ga, err));
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", b, gb, err));
|
||
// Different seeds should almost always differ; allow rare collision by checking not
|
||
// identical OR both valid borders.
|
||
CHECK(borderIsWall(ga));
|
||
CHECK(borderIsWall(gb));
|
||
CHECK(!gridsEqual(ga, gb));
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.errors") {
|
||
std::string err;
|
||
Grid2D g;
|
||
Params tooSmall;
|
||
tooSmall.setSeed(1);
|
||
tooSmall.setSize(2, 2);
|
||
tooSmall.setString("preset", "cave");
|
||
CHECK(!GeneratorRegistry::instance().generate("wfc.simple", tooSmall, g, err));
|
||
CHECK(err.find("4x4") != std::string::npos);
|
||
|
||
Params badPreset;
|
||
badPreset.setSeed(1);
|
||
badPreset.setSize(12, 12);
|
||
badPreset.setString("preset", "nope");
|
||
CHECK(!GeneratorRegistry::instance().generate("wfc.simple", badPreset, g, err));
|
||
CHECK(err.find("preset") != std::string::npos);
|
||
|
||
Params tooBig;
|
||
tooBig.setSeed(1);
|
||
tooBig.setSize(300, 16);
|
||
tooBig.setString("preset", "cave");
|
||
CHECK(!GeneratorRegistry::instance().generate("wfc.simple", tooBig, g, err));
|
||
CHECK(err.find("256") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.viaModule") {
|
||
Procgen *mod = Procgen::create();
|
||
CHECK(mod->hasAlgorithm("wfc.simple"));
|
||
Params p;
|
||
p.setSeed(5);
|
||
p.setSize(16, 12);
|
||
p.setString("preset", "dungeon");
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D *grid = mod->generate("wfc.simple", &p);
|
||
REQUIRE(grid != nullptr);
|
||
CHECK(borderIsWall(*grid));
|
||
CHECK_EQ(grid->getWidth(), 16);
|
||
CHECK_EQ(grid->getHeight(), 12);
|
||
delete grid;
|
||
|
||
Params bad;
|
||
bad.setSeed(1);
|
||
bad.setSize(8, 8);
|
||
bad.setString("preset", "invalid");
|
||
CHECK(mod->generate("wfc.simple", &bad) == nullptr);
|
||
CHECK(mod->lastError().find("preset") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.meshBuild.accessors") {
|
||
MeshBuild m;
|
||
CHECK(m.empty());
|
||
CHECK_EQ(m.getVertexCount(), 0);
|
||
CHECK_EQ(m.getIndexCount(), 0);
|
||
m.addVertex(1.f, 2.f, 3.f, 0.f, 1.f, 0.f, 0.25f, 0.75f);
|
||
m.addVertex(4.f, 5.f, 6.f, 0.f, 1.f, 0.f, 0.f, 0.f);
|
||
m.addVertex(7.f, 8.f, 9.f, 0.f, 1.f, 0.f, 1.f, 1.f);
|
||
m.addTriangle(0, 1, 2);
|
||
CHECK(!m.empty());
|
||
CHECK_EQ(m.getVertexCount(), 3);
|
||
CHECK_EQ(m.getIndexCount(), 3);
|
||
CHECK_EQ(m.getPositionX(0), 1.f);
|
||
CHECK_EQ(m.getPositionY(1), 5.f);
|
||
CHECK_EQ(m.getPositionZ(2), 9.f);
|
||
CHECK_EQ(m.getNormalY(0), 1.f);
|
||
CHECK_EQ(m.getUvU(0), 0.25f);
|
||
CHECK_EQ(m.getUvV(0), 0.75f);
|
||
CHECK_EQ(m.getIndex(2), 2);
|
||
// Out of range accessors are safe zeros.
|
||
CHECK_EQ(m.getPositionX(99), 0.f);
|
||
CHECK_EQ(m.getIndex(99), 0);
|
||
m.setMeta("algo", "test");
|
||
CHECK_EQ(m.getMeta("algo", ""), std::string("test"));
|
||
CHECK_EQ(m.getMeta("missing", "d"), std::string("d"));
|
||
m.clear();
|
||
CHECK(m.empty());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.sphere") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
CHECK(MeshRecipeRegistry::instance().has("mesh.marchingcubes"));
|
||
Params p;
|
||
p.setSeed(1);
|
||
p.setInt("resolution", 20);
|
||
p.setString("field", "sphere");
|
||
p.setFloat("radius", 0.7f);
|
||
p.setFloat("isolevel", 0.f);
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, mesh, err));
|
||
CHECK(mesh.getVertexCount() > 100);
|
||
CHECK(mesh.getIndexCount() > 100);
|
||
CHECK_EQ(mesh.getIndexCount() % 3, 0);
|
||
CHECK(meshIndicesInRange(mesh));
|
||
CHECK(meshPositionsFinite(mesh));
|
||
CHECK(meshNormalsFiniteUnit(mesh));
|
||
// Closed sphere should have non-trivial volume.
|
||
CHECK(std::fabs(meshApproxSignedVolume(mesh)) > 0.05f);
|
||
// Same seed ⇒ same mesh.
|
||
MeshBuild mesh2;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, mesh2, err));
|
||
CHECK_EQ(mesh.getVertexCount(), mesh2.getVertexCount());
|
||
CHECK(mesh.positions() == mesh2.positions());
|
||
CHECK(mesh.indices() == mesh2.indices());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.hexplanet.topology") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setFloat("radius", 2.f);
|
||
p.setInt("subdivisions", 2);
|
||
p.setFloat("tileInset", 0.05f);
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.hexplanet", p, mesh, err));
|
||
CHECK(meshIndicesInRange(mesh));
|
||
CHECK(meshPositionsFinite(mesh));
|
||
CHECK(meshNormalsFiniteUnit(mesh));
|
||
CHECK_EQ(mesh.getMeta("algorithm", ""), std::string("mesh.hexplanet"));
|
||
CHECK_EQ(mesh.getMeta("pentagons", ""), std::string("12"));
|
||
// V = 10 * 4^n + 2 for a subdivided icosahedron.
|
||
CHECK_EQ(mesh.getMeta("cells", ""), std::string("162"));
|
||
CHECK_EQ(mesh.getMeta("hexagons", ""), std::string("150"));
|
||
CHECK_EQ(mesh.getIndexCount() % 3, 0);
|
||
for (int i = 0; i < mesh.getVertexCount(); ++i) {
|
||
const float x = mesh.getPositionX(i), y = mesh.getPositionY(i), z = mesh.getPositionZ(i);
|
||
CHECK(std::fabs(std::sqrt(x * x + y * y + z * z) - 2.f) < 1e-4f);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.render.hexplanetPng") {
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 768;
|
||
settings.height = 768;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
|
||
Procgen *procgen = Procgen::create();
|
||
Params params;
|
||
params.setFloat("radius", 1.f);
|
||
params.setInt("subdivisions", 3);
|
||
params.setFloat("tileInset", 0.12f);
|
||
Mesh *planetMesh = procgen->generateMesh("mesh.hexplanet", ¶ms, gfx);
|
||
REQUIRE(planetMesh != nullptr);
|
||
|
||
const uint8_t oceanBlue[4] = {42, 155, 181, 255};
|
||
Texture *planetTexture = gfx->newTexture(1, 1, oceanBlue);
|
||
REQUIRE(planetTexture != nullptr);
|
||
|
||
auto *planet = eve::graphics::Renderable3D::create();
|
||
planet->setMesh(planetMesh);
|
||
planet->setTexture(planetTexture);
|
||
planet->setMetallic(0.05f);
|
||
planet->setRoughness(0.72f);
|
||
planet->setCastShadow(false);
|
||
planet->setReceiveShadow(false);
|
||
planet->transform()->yaw = 0.42f;
|
||
planet->transform()->pitch = -0.22f;
|
||
|
||
auto *camera = eve::graphics::Camera3D::createCamera();
|
||
camera->setEye(2.65f, 1.55f, 3.05f);
|
||
camera->setTarget(0.f, 0.f, 0.f);
|
||
camera->setFov(38.f);
|
||
camera->setAmbient(0.16f, 0.19f, 0.24f);
|
||
|
||
// A transparent 2D entity drives the normal present path used by render tests.
|
||
auto *present = eve::graphics::Renderable2D::create();
|
||
present->transform()->x = 0.f;
|
||
present->transform()->y = 0.f;
|
||
present->sprite()->width = 1.f;
|
||
present->sprite()->height = 1.f;
|
||
present->sprite()->a = 0.f;
|
||
|
||
gfx->setBackgroundColor(Color(0.012f, 0.018f, 0.035f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
eve::graphics::RenderSystem3D::setDirectionalLight(0.55f, 0.85f, 1.1f, 1.15f, 0.98f,
|
||
0.88f);
|
||
for (int frame = 0; frame < 8; ++frame) {
|
||
planet->transform()->yaw += 0.025f;
|
||
eve::graphics::RenderSystem3D::render(*gfx);
|
||
eve::graphics::RenderSystem::render(*gfx);
|
||
}
|
||
|
||
eve::image::Image::create();
|
||
std::unique_ptr<eve::image::ImageData> image(gfx->newImageData());
|
||
REQUIRE(static_cast<bool>(image));
|
||
std::unique_ptr<eve::filesystem::FileData> png(
|
||
image->encode(medialoader::FormatHandler::ENCODED_PNG, "hex_planet.png", false));
|
||
REQUIRE(static_cast<bool>(png));
|
||
REQUIRE(png->getSize() > 0);
|
||
|
||
const std::filesystem::path outDir =
|
||
std::filesystem::path(EVENGINE_TEST_BINARY_DIR) / "out";
|
||
std::filesystem::create_directories(outDir);
|
||
const std::filesystem::path outPath = outDir / "hex_planet.png";
|
||
std::ofstream output(outPath, std::ios::binary);
|
||
REQUIRE(output.good());
|
||
output.write(static_cast<const char *>(png->getData()),
|
||
static_cast<std::streamsize>(png->getSize()));
|
||
REQUIRE(output.good());
|
||
output.close();
|
||
std::printf("hex planet render saved: %s\n", outPath.string().c_str());
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("procgen.render.cloudShadowsDarkenGround") {
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 256;
|
||
settings.height = 256;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
|
||
// Flat ground grid over XZ.
|
||
const int N = 24;
|
||
const float S = 40.f; // world extent
|
||
std::vector<float> pos, nrm, uv;
|
||
std::vector<uint32_t> idx;
|
||
for (int y = 0; y <= N; ++y) {
|
||
for (int x = 0; x <= N; ++x) {
|
||
pos.push_back(-S / 2 + S * float(x) / N);
|
||
pos.push_back(0.f);
|
||
pos.push_back(-S / 2 + S * float(y) / N);
|
||
nrm.push_back(0.f);
|
||
nrm.push_back(1.f);
|
||
nrm.push_back(0.f);
|
||
uv.push_back(float(x) / N);
|
||
uv.push_back(float(y) / N);
|
||
}
|
||
}
|
||
for (int y = 0; y < N; ++y) {
|
||
for (int x = 0; x < N; ++x) {
|
||
const uint32_t a = uint32_t(y * (N + 1) + x);
|
||
const uint32_t b = a + 1;
|
||
const uint32_t c = a + uint32_t(N + 1);
|
||
const uint32_t d = c + 1;
|
||
idx.push_back(a); idx.push_back(c); idx.push_back(b);
|
||
idx.push_back(b); idx.push_back(c); idx.push_back(d);
|
||
}
|
||
}
|
||
Mesh *groundMesh = gfx->newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3),
|
||
idx.data(), int(idx.size()));
|
||
REQUIRE(groundMesh != nullptr);
|
||
const uint8_t grass[4] = {96, 150, 70, 255};
|
||
Texture *grassTex = gfx->newTexture(1, 1, grass);
|
||
REQUIRE(grassTex != nullptr);
|
||
|
||
auto *ground = Renderable3D::create();
|
||
ground->setMesh(groundMesh);
|
||
ground->setTexture(grassTex);
|
||
ground->setRoughness(0.9f);
|
||
ground->setCastShadow(false);
|
||
ground->setReceiveShadow(false);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(0.f, 18.f, 6.f);
|
||
camera->setTarget(0.f, 0.f, 0.f);
|
||
camera->setFov(55.f);
|
||
camera->setAmbient(0.12f, 0.14f, 0.16f);
|
||
|
||
gfx->setBackgroundColor(Color(0.02f, 0.03f, 0.05f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
RenderSystem3D::setDirectionalLight(0.3f, 1.f, 0.2f, 1.15f, 1.05f, 0.95f);
|
||
|
||
auto meanLuma = [&]() -> float {
|
||
for (int frame = 0; frame < 3; ++frame) {
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
}
|
||
std::unique_ptr<eve::image::ImageData> img(gfx->newImageData());
|
||
REQUIRE(img.get() != nullptr);
|
||
const uint8_t *px = static_cast<const uint8_t *>(img->getData());
|
||
const int w = img->getWidth();
|
||
const int h = img->getHeight();
|
||
double sum = 0.0;
|
||
for (int i = 0; i < w * h; ++i) {
|
||
const size_t o = size_t(i) * 4u;
|
||
sum += 0.2126 * px[o] + 0.7152 * px[o + 1] + 0.0722 * px[o + 2];
|
||
}
|
||
return float(sum / double(w * h));
|
||
};
|
||
|
||
// No clouds → fully lit (baseline).
|
||
gfx->setCloudShadows(0.f, 1.5f, 0.f, 4.f, 0.f, 0.5f, 0.5f);
|
||
const float lit = meanLuma();
|
||
|
||
// Dense, strong clouds → ground visibly darker.
|
||
gfx->setCloudShadows(0.9f, 2.0f, 1.5f, 4.f, 0.f, 0.5f, 0.5f);
|
||
const float cloudy = meanLuma();
|
||
|
||
gfx->setCloudShadows(0.f, 1.5f, 0.f, 4.f, 0.f, 0.5f, 0.5f);
|
||
const float litAgain = meanLuma();
|
||
|
||
std::printf("cloud shadows render: lit=%.3f cloudy=%.3f litAgain=%.3f\n", lit, cloudy, litAgain);
|
||
CHECK_GT(lit, 10.f); // baseline is lit
|
||
CHECK(cloudy < lit * 0.85f); // clouds meaningfully darken the ground
|
||
CHECK(approxEq(lit, litAgain, 2.f)); // disabled again → back to baseline
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("procgen.render.skyscraperPng") {
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 640;
|
||
settings.height = 720;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
|
||
Procgen *procgen = Procgen::create();
|
||
Params params;
|
||
params.setSeed(99);
|
||
params.setFloat("baseWidth", 10.f);
|
||
params.setFloat("baseDepth", 10.f);
|
||
params.setInt("tiers", 6);
|
||
params.setFloat("tierHeight", 6.f);
|
||
params.setFloat("setback", 0.08f);
|
||
params.setInt("windowCols", 6);
|
||
params.setInt("windowRows", 5);
|
||
params.setFloat("windowDepth", 0.06f);
|
||
params.setFloat("spireHeight", 5.f);
|
||
Mesh *towerMesh = procgen->generateMesh("mesh.skyscraper", ¶ms, gfx);
|
||
REQUIRE(towerMesh != nullptr);
|
||
|
||
// 2x2 atlas: bottom texel dark wall, top texel bright window (matches UV convention).
|
||
const uint8_t atlas[16] = {
|
||
90, 96, 104, 255, // wall
|
||
120, 128, 138, 255, // wall
|
||
240, 236, 220, 255, // window
|
||
200, 205, 215, 255, // window
|
||
};
|
||
Texture *towerTexture = gfx->newTexture(2, 2, atlas);
|
||
REQUIRE(towerTexture != nullptr);
|
||
|
||
auto *tower = eve::graphics::Renderable3D::create();
|
||
tower->setMesh(towerMesh);
|
||
tower->setTexture(towerTexture);
|
||
tower->setMetallic(0.02f);
|
||
tower->setRoughness(0.85f);
|
||
tower->setCastShadow(true);
|
||
tower->setReceiveShadow(true);
|
||
tower->transform()->yaw = 0.6f;
|
||
|
||
auto *camera = eve::graphics::Camera3D::createCamera();
|
||
camera->setEye(18.f, 14.f, 20.f);
|
||
camera->setTarget(0.f, 14.f, 0.f);
|
||
camera->setFov(42.f);
|
||
camera->setAmbient(0.16f, 0.18f, 0.22f);
|
||
|
||
auto *present = eve::graphics::Renderable2D::create();
|
||
present->transform()->x = 0.f;
|
||
present->transform()->y = 0.f;
|
||
present->sprite()->width = 1.f;
|
||
present->sprite()->height = 1.f;
|
||
present->sprite()->a = 0.f;
|
||
|
||
gfx->setBackgroundColor(Color(0.12f, 0.14f, 0.20f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
eve::graphics::RenderSystem3D::setDirectionalLight(0.55f, 0.85f, 1.1f, 1.15f, 0.98f, 0.88f);
|
||
for (int frame = 0; frame < 8; ++frame) {
|
||
tower->transform()->yaw += 0.02f;
|
||
eve::graphics::RenderSystem3D::render(*gfx);
|
||
eve::graphics::RenderSystem::render(*gfx);
|
||
}
|
||
|
||
eve::image::Image::create();
|
||
std::unique_ptr<eve::image::ImageData> image(gfx->newImageData());
|
||
REQUIRE(static_cast<bool>(image));
|
||
std::unique_ptr<eve::filesystem::FileData> png(
|
||
image->encode(medialoader::FormatHandler::ENCODED_PNG, "skyscraper.png", false));
|
||
REQUIRE(static_cast<bool>(png));
|
||
REQUIRE(png->getSize() > 0);
|
||
|
||
const std::filesystem::path outDir =
|
||
std::filesystem::path(EVENGINE_TEST_BINARY_DIR) / "out";
|
||
std::filesystem::create_directories(outDir);
|
||
const std::filesystem::path outPath = outDir / "skyscraper.png";
|
||
std::ofstream output(outPath, std::ios::binary);
|
||
REQUIRE(output.good());
|
||
output.write(static_cast<const char *>(png->getData()),
|
||
static_cast<std::streamsize>(png->getSize()));
|
||
REQUIRE(output.good());
|
||
output.close();
|
||
std::printf("skyscraper render saved: %s\n", outPath.string().c_str());
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.allFields") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
const char *fields[] = {"sphere", "torus", "noise", "terrain"};
|
||
for (const char *field : fields) {
|
||
Params p;
|
||
p.setSeed(42);
|
||
p.setInt("resolution", 18);
|
||
p.setString("field", field);
|
||
p.setFloat("scale", 1.5f);
|
||
p.setInt("octaves", 2);
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, mesh, err));
|
||
CHECK(mesh.getVertexCount() > 20);
|
||
CHECK_EQ(mesh.getIndexCount() % 3, 0);
|
||
CHECK(meshIndicesInRange(mesh));
|
||
CHECK(meshPositionsFinite(mesh));
|
||
CHECK(meshNormalsFiniteUnit(mesh));
|
||
CHECK_EQ(mesh.getMeta("algorithm", ""), std::string("mesh.marchingcubes"));
|
||
CHECK_EQ(mesh.getMeta("field", ""), std::string(field));
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.noiseReproducibleAndVaries") {
|
||
Params p;
|
||
p.setSeed(9);
|
||
p.setInt("resolution", 16);
|
||
p.setString("field", "noise");
|
||
p.setFloat("scale", 2.f);
|
||
MeshBuild a, b, c;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, b, err));
|
||
CHECK(a.positions() == b.positions());
|
||
CHECK(a.indices() == b.indices());
|
||
|
||
Params p2 = p;
|
||
p2.setSeed(10);
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p2, c, err));
|
||
CHECK(a.positions() != c.positions());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.rawDensityPlane") {
|
||
// Density = y - 0.5 on a 4³ grid → horizontal plane at mid height.
|
||
const int n = 4;
|
||
std::vector<float> density(size_t(n * n * n));
|
||
for (int z = 0; z < n; ++z) {
|
||
for (int y = 0; y < n; ++y) {
|
||
for (int x = 0; x < n; ++x) {
|
||
density[size_t(x + y * n + z * n * n)] = float(y) / float(n - 1) - 0.5f;
|
||
}
|
||
}
|
||
}
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(marchingCubes(density.data(), n, n, n, 0.f, mesh, &err));
|
||
CHECK(mesh.getVertexCount() > 0);
|
||
CHECK_EQ(mesh.getIndexCount() % 3, 0);
|
||
CHECK(meshIndicesInRange(mesh));
|
||
// All vertices near mid-Y in unit cube mapping [-0.5,0.5].
|
||
for (int i = 0; i < mesh.getVertexCount(); ++i) {
|
||
CHECK(std::fabs(mesh.getPositionY(i)) < 0.35f);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.rawDensityEmpty") {
|
||
const int n = 3;
|
||
std::vector<float> density(size_t(n * n * n), 1.f); // all solid, no surface
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(marchingCubes(density.data(), n, n, n, 0.f, mesh, &err));
|
||
CHECK(mesh.empty());
|
||
|
||
density.assign(size_t(n * n * n), -1.f); // all empty
|
||
CHECK(marchingCubes(density.data(), n, n, n, 0.f, mesh, &err));
|
||
CHECK(mesh.empty());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.rawApiErrors") {
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
CHECK(!marchingCubes(nullptr, 4, 4, 4, 0.f, mesh, &err));
|
||
CHECK(err.find("null") != std::string::npos);
|
||
|
||
float tiny[1] = {0.f};
|
||
CHECK(!marchingCubes(tiny, 1, 1, 1, 0.f, mesh, &err));
|
||
CHECK(err.find("2x2x2") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.fillDensityAndErrors") {
|
||
std::vector<float> density;
|
||
int nx = 0, ny = 0, nz = 0;
|
||
std::string err;
|
||
|
||
Params ok;
|
||
ok.setInt("resolution", 8);
|
||
ok.setString("field", "sphere");
|
||
CHECK(fillDensityField(ok, density, nx, ny, nz, err));
|
||
CHECK_EQ(nx, 8);
|
||
CHECK_EQ(ny, 8);
|
||
CHECK_EQ(nz, 8);
|
||
CHECK_EQ(int(density.size()), 8 * 8 * 8);
|
||
|
||
Params axis;
|
||
axis.setInt("nx", 6);
|
||
axis.setInt("ny", 7);
|
||
axis.setInt("nz", 5);
|
||
axis.setString("field", "torus");
|
||
CHECK(fillDensityField(axis, density, nx, ny, nz, err));
|
||
CHECK_EQ(nx, 6);
|
||
CHECK_EQ(ny, 7);
|
||
CHECK_EQ(nz, 5);
|
||
|
||
Params badField;
|
||
badField.setInt("resolution", 8);
|
||
badField.setString("field", "banana");
|
||
CHECK(!fillDensityField(badField, density, nx, ny, nz, err));
|
||
CHECK(err.find("field") != std::string::npos);
|
||
|
||
Params tooSmall;
|
||
tooSmall.setInt("resolution", 1);
|
||
tooSmall.setString("field", "sphere");
|
||
CHECK(!fillDensityField(tooSmall, density, nx, ny, nz, err));
|
||
|
||
Params tooBig;
|
||
tooBig.setInt("resolution", 200);
|
||
tooBig.setString("field", "sphere");
|
||
CHECK(!fillDensityField(tooBig, density, nx, ny, nz, err));
|
||
CHECK(err.find("128") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.recipeErrors") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
MeshBuild mesh;
|
||
std::string err;
|
||
Params badField;
|
||
badField.setInt("resolution", 12);
|
||
badField.setString("field", "nope");
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.marchingcubes", badField, mesh, err));
|
||
CHECK(err.find("field") != std::string::npos);
|
||
|
||
CHECK(!MeshRecipeRegistry::instance().generate("mesh.missing", badField, mesh, err));
|
||
CHECK(err.find("unknown") != std::string::npos);
|
||
|
||
auto ids = MeshRecipeRegistry::instance().list();
|
||
CHECK(!ids.empty());
|
||
CHECK(std::find(ids.begin(), ids.end(), "mesh.marchingcubes") != ids.end());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.isolevelAffectsMesh") {
|
||
Params lo;
|
||
lo.setSeed(1);
|
||
lo.setInt("resolution", 16);
|
||
lo.setString("field", "sphere");
|
||
lo.setFloat("radius", 0.7f);
|
||
lo.setFloat("isolevel", -0.2f);
|
||
Params hi = lo;
|
||
hi.setFloat("isolevel", 0.2f);
|
||
MeshBuild a, b;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", lo, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", hi, b, err));
|
||
// Higher isolevel shrinks solid region → fewer / different triangles.
|
||
const bool differs =
|
||
a.getVertexCount() != b.getVertexCount() || a.positions() != b.positions();
|
||
CHECK(differs);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.viaModule") {
|
||
Procgen *mod = Procgen::create();
|
||
Params p;
|
||
p.setSeed(3);
|
||
p.setInt("resolution", 16);
|
||
p.setString("field", "torus");
|
||
MeshBuild *m = mod->buildMesh("mesh.marchingcubes", &p);
|
||
REQUIRE(m != nullptr);
|
||
CHECK(m->getVertexCount() > 50);
|
||
CHECK(meshIndicesInRange(*m));
|
||
CHECK(mod->hasMeshRecipe("mesh.marchingcubes"));
|
||
CHECK(mod->getMeshRecipeCount() >= 1);
|
||
bool listed = false;
|
||
for (int i = 0; i < mod->getMeshRecipeCount(); ++i) {
|
||
if (mod->getMeshRecipeId(i) == "mesh.marchingcubes") listed = true;
|
||
}
|
||
CHECK(listed);
|
||
delete m;
|
||
|
||
Params bad;
|
||
bad.setInt("resolution", 8);
|
||
bad.setString("field", "nope");
|
||
CHECK(mod->buildMesh("mesh.marchingcubes", &bad) == nullptr);
|
||
CHECK(mod->lastError().find("field") != std::string::npos);
|
||
CHECK(mod->buildMesh("mesh.missing", &p) == nullptr);
|
||
CHECK(mod->generateMesh("mesh.marchingcubes", nullptr, nullptr) == nullptr);
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.dungeonAdjacencyAndListing") {
|
||
GeneratorRegistry::instance().registerBuiltins();
|
||
Procgen *mod = Procgen::create();
|
||
CHECK(mod->hasAlgorithm("wfc.simple"));
|
||
bool listed = false;
|
||
for (int i = 0; i < mod->getAlgorithmCount(); ++i) {
|
||
if (mod->getAlgorithmId(i) == "wfc.simple") listed = true;
|
||
}
|
||
CHECK(listed);
|
||
|
||
Params p;
|
||
p.setSeed(17);
|
||
p.setSize(22, 16);
|
||
p.setString("preset", "dungeon");
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D g;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("wfc.simple", p, g, err));
|
||
CHECK(borderIsWall(g));
|
||
// Allowed dungeon pairs (undirected).
|
||
auto okPair = [](int a, int b) {
|
||
if (a > b) std::swap(a, b);
|
||
const int W = int(Semantic::Wall), F = int(Semantic::Floor), C = int(Semantic::Corridor),
|
||
D = int(Semantic::Door);
|
||
if (a == W && (b == W || b == F || b == C || b == D)) return true;
|
||
if (a == F && (b == F || b == C || b == D)) return true;
|
||
if (a == C && b == C) return true;
|
||
return false;
|
||
};
|
||
for (int y = 0; y < g.getHeight(); ++y) {
|
||
for (int x = 0; x < g.getWidth(); ++x) {
|
||
const int c = g.getCell(x, y);
|
||
if (x + 1 < g.getWidth()) CHECK(okPair(c, g.getCell(x + 1, y)));
|
||
if (y + 1 < g.getHeight()) CHECK(okPair(c, g.getCell(x, y + 1)));
|
||
}
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.wfc.simple.multiSeedBatch") {
|
||
// Stress: several seeds/presets must all collapse successfully.
|
||
const char *presets[] = {"dungeon", "cave", "terrain"};
|
||
int okCount = 0;
|
||
for (const char *preset : presets) {
|
||
for (uint32_t seed = 1; seed <= 5; ++seed) {
|
||
Params p;
|
||
p.setSeed(seed * 13u + 7u);
|
||
p.setSize(18, 14);
|
||
p.setString("preset", preset);
|
||
p.setInt("maxAttempts", 64);
|
||
Grid2D g;
|
||
std::string err;
|
||
if (GeneratorRegistry::instance().generate("wfc.simple", p, g, err)) {
|
||
++okCount;
|
||
CHECK_EQ(g.getWidth(), 18);
|
||
CHECK_EQ(g.getHeight(), 14);
|
||
if (std::string(preset) != "terrain") CHECK(borderIsWall(g));
|
||
}
|
||
}
|
||
}
|
||
CHECK_EQ(okCount, 15);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.resolutionScales") {
|
||
Params lo;
|
||
lo.setSeed(1);
|
||
lo.setInt("resolution", 12);
|
||
lo.setString("field", "sphere");
|
||
lo.setFloat("radius", 0.7f);
|
||
Params hi = lo;
|
||
hi.setInt("resolution", 24);
|
||
MeshBuild a, b;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", lo, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", hi, b, err));
|
||
CHECK(b.getIndexCount() > a.getIndexCount());
|
||
CHECK(b.getVertexCount() > a.getVertexCount());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.torusAndAxisResolution") {
|
||
Params torus;
|
||
torus.setSeed(2);
|
||
torus.setInt("resolution", 20);
|
||
torus.setString("field", "torus");
|
||
MeshBuild ring;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", torus, ring, err));
|
||
CHECK(ring.getVertexCount() > 100);
|
||
CHECK(meshIndicesInRange(ring));
|
||
CHECK(meshNormalsFiniteUnit(ring));
|
||
// Torus should have non-zero volume magnitude.
|
||
CHECK(std::fabs(meshApproxSignedVolume(ring)) > 0.01f);
|
||
|
||
Params axis;
|
||
axis.setSeed(3);
|
||
axis.setInt("nx", 10);
|
||
axis.setInt("ny", 14);
|
||
axis.setInt("nz", 12);
|
||
axis.setString("field", "sphere");
|
||
axis.setFloat("radius", 0.65f);
|
||
MeshBuild m;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", axis, m, err));
|
||
CHECK(m.getVertexCount() > 50);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.sphereVolumeSignStable") {
|
||
Params p;
|
||
p.setSeed(1);
|
||
p.setInt("resolution", 18);
|
||
p.setString("field", "sphere");
|
||
p.setFloat("radius", 0.7f);
|
||
MeshBuild a, b;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p, b, err));
|
||
const float va = meshApproxSignedVolume(a);
|
||
const float vb = meshApproxSignedVolume(b);
|
||
CHECK(std::fabs(va) > 0.05f);
|
||
CHECK(approxEq(va, vb, 1e-5f));
|
||
// Winding must be consistent across seeds of the same field recipe family.
|
||
Params p2 = p;
|
||
p2.setSeed(9);
|
||
MeshBuild c;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.marchingcubes", p2, c, err));
|
||
const float vc = meshApproxSignedVolume(c);
|
||
CHECK(va * vc > 0.f); // same sign
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.marchingcubes.moduleNullAndList") {
|
||
Procgen *mod = Procgen::create();
|
||
CHECK(mod->buildMesh("mesh.marchingcubes", nullptr) == nullptr);
|
||
CHECK(mod->lastError().find("null") != std::string::npos);
|
||
CHECK(mod->generateMesh("mesh.marchingcubes", nullptr, nullptr) == nullptr);
|
||
|
||
CHECK(mod->getMeshRecipeCount() >= 1);
|
||
bool found = false;
|
||
for (int i = 0; i < mod->getMeshRecipeCount(); ++i) {
|
||
if (mod->getMeshRecipeId(i) == "mesh.marchingcubes") found = true;
|
||
}
|
||
CHECK(found);
|
||
}
|
||
|
||
TEST_CASE("procgen.palette.applyToLayer") {
|
||
Procgen *mod = Procgen::create();
|
||
Params p;
|
||
p.setSeed(1);
|
||
p.setSize(16, 12);
|
||
Grid2D *grid = mod->generate("dungeon.bsp", &p);
|
||
CHECK(grid != nullptr);
|
||
|
||
mod->setPaletteGid("test", "wall", 10);
|
||
mod->setPaletteGid("test", "floor", 20);
|
||
mod->setPaletteGid("test", "corridor", 21);
|
||
|
||
eve::map::TileLayer *layer = eve::map::TileLayer::createLayer(16, 12, 16.f, 16.f);
|
||
CHECK(mod->applyToLayer(grid, "test", layer));
|
||
bool sawMapped = false;
|
||
for (int y = 0; y < 12; ++y) {
|
||
for (int x = 0; x < 16; ++x) {
|
||
const int gid = layer->getTile(x, y);
|
||
const int sem = grid->getCell(x, y);
|
||
if (sem == int(Semantic::Wall)) {
|
||
CHECK_EQ(gid, 10);
|
||
sawMapped = true;
|
||
} else if (sem == int(Semantic::Floor)) {
|
||
CHECK_EQ(gid, 20);
|
||
sawMapped = true;
|
||
} else if (sem == int(Semantic::Corridor)) {
|
||
CHECK_EQ(gid, 21);
|
||
sawMapped = true;
|
||
}
|
||
}
|
||
}
|
||
CHECK(sawMapped);
|
||
delete grid;
|
||
layer->release();
|
||
}
|
||
|
||
TEST_CASE("procgen.json.export") {
|
||
Params p;
|
||
p.setSeed(3);
|
||
p.setSize(12, 10);
|
||
Grid2D g;
|
||
std::string err;
|
||
CHECK(GeneratorRegistry::instance().generate("dungeon.bsp", p, g, err));
|
||
const std::string json = gridToJson(g);
|
||
CHECK(json.find("\"width\":12") != std::string::npos);
|
||
CHECK(json.find("\"cells\":") != std::string::npos);
|
||
CHECK(json.find("spawn") != std::string::npos);
|
||
}
|
||
|
||
TEST_CASE("procgen.texture.noiseField.seamlessReproducible") {
|
||
NoiseField a{42, 8, 8};
|
||
NoiseField b{42, 8, 8};
|
||
CHECK_EQ(a.fbm(1.25f, 3.5f, 4), b.fbm(1.25f, 3.5f, 4));
|
||
// Period wrap: x and x+period should match on lattice contribution.
|
||
CHECK_EQ(a.hash01(0, 0), a.hash01(8, 0));
|
||
CHECK_EQ(a.hash01(0, 0), a.hash01(0, 8));
|
||
}
|
||
|
||
TEST_CASE("procgen.texture.colorRamp.banded") {
|
||
ColorRamp ramp;
|
||
ramp.add(0.f, 0, 0, 0);
|
||
ramp.add(1.f, 255, 255, 255);
|
||
const Rgba8 c0 = ramp.sampleBanded(0.1f, 3);
|
||
const Rgba8 c1 = ramp.sampleBanded(0.9f, 3);
|
||
CHECK(c0.r < c1.r);
|
||
}
|
||
|
||
TEST_CASE("procgen.texture.recipes.reproducible") {
|
||
TextureRecipeRegistry::instance().registerBuiltins();
|
||
const char *ids[] = {"tex.soil", "tex.stone", "tex.marble", "tex.water", "tex.sky_cloud"};
|
||
for (const char *id : ids) {
|
||
Params p;
|
||
p.setSeed(11);
|
||
p.setSize(32, 32);
|
||
p.setInt("colors", 5);
|
||
p.setInt("pixelSize", 2);
|
||
p.setInt("seamless", 1);
|
||
std::string err;
|
||
// Fully qualify: `using namespace eve::procgen` would make bare `image::`
|
||
// look outside `eve::image`.
|
||
eve::image::ImageData *a = TextureRecipeRegistry::instance().generate(id, p, err);
|
||
eve::image::ImageData *b = TextureRecipeRegistry::instance().generate(id, p, err);
|
||
REQUIRE(a != nullptr);
|
||
REQUIRE(b != nullptr);
|
||
CHECK_EQ(a->getWidth(), 32);
|
||
CHECK_EQ(a->getHeight(), 32);
|
||
CHECK_EQ(a->getFormat(), std::string("RGBA8"));
|
||
CHECK_EQ(a->getSize(), b->getSize());
|
||
CHECK(std::memcmp(a->getData(), b->getData(), a->getSize()) == 0);
|
||
delete a;
|
||
delete b;
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.texture.generateImage.andNormal") {
|
||
Procgen *mod = Procgen::create();
|
||
Params p;
|
||
p.setSeed(3);
|
||
p.setSize(48, 48);
|
||
p.setInt("colors", 6);
|
||
eve::image::ImageData *img = mod->generateImage("tex.marble", &p);
|
||
REQUIRE(img != nullptr);
|
||
eve::image::ImageData *nrm = mod->generateNormalImage("tex.marble", &p);
|
||
REQUIRE(nrm != nullptr);
|
||
CHECK_EQ(nrm->getWidth(), 48);
|
||
CHECK_EQ(nrm->getHeight(), 48);
|
||
delete img;
|
||
delete nrm;
|
||
CHECK(mod->hasTextureRecipe("tex.soil"));
|
||
CHECK(mod->getTextureRecipeCount() >= 5);
|
||
}
|
||
|
||
TEST_CASE("procgen.cloud.field.reproducibleAnimatedSeamless") {
|
||
CloudField::Params p;
|
||
p.seed = 99;
|
||
p.worldScale = 64.f;
|
||
p.coverage = 0.5f;
|
||
CloudField a(p), b(p);
|
||
// Deterministic: same seed → same coverage at the same point/time.
|
||
CHECK(approxEq(a.coverageAt(3.f, 4.f, 0.f), b.coverageAt(3.f, 4.f, 0.f), 1e-5f));
|
||
// Animated: different time drifts the field (for a non-zero wind speed).
|
||
const float t0 = a.coverageAt(3.f, 4.f, 0.f);
|
||
const float t1 = a.coverageAt(3.f, 4.f, 5.f);
|
||
const float t2 = a.coverageAt(3.f, 4.f, 10.f);
|
||
const float moved = (t0 != t1) || (t1 != t2);
|
||
CHECK(moved); // drifting over time
|
||
// Seamless: coverage tiles over one worldScale.
|
||
CHECK(approxEq(a.coverageAt(3.f, 4.f, 0.f), a.coverageAt(3.f + 64.f, 4.f, 0.f), 1e-4f));
|
||
CHECK(approxEq(a.coverageAt(3.f, 4.f, 0.f), a.coverageAt(3.f, 4.f + 64.f, 0.f), 1e-4f));
|
||
// Values stay in range.
|
||
for (float x = -200.f; x < 200.f; x += 40.f) {
|
||
const float c = a.coverageAt(x, 13.f, 1.f);
|
||
CHECK(c >= 0.f);
|
||
CHECK(c <= 1.f);
|
||
}
|
||
// Different seed → different field.
|
||
CloudField::Params q = p;
|
||
q.seed = 100;
|
||
CloudField c(q);
|
||
CHECK(!approxEq(a.coverageAt(3.f, 4.f, 0.f), c.coverageAt(3.f, 4.f, 0.f), 1e-5f));
|
||
}
|
||
|
||
TEST_CASE("procgen.cloud.field.windDriftsInDirection") {
|
||
CloudField::Params p;
|
||
p.seed = 5;
|
||
p.worldScale = 32.f;
|
||
p.windSpeed = 10.f;
|
||
p.windAngle = 0.f; // drift toward +x
|
||
CloudField f(p);
|
||
// A feature moving at +x over time should arrive at a +x-shifted position.
|
||
const float base = f.coverageAt(0.f, 0.f, 0.f);
|
||
const float dt = 1.f;
|
||
CHECK(approxEq(base, f.coverageAt(0.f + p.windSpeed * dt, 0.f, 1.f), 1e-2f));
|
||
// Perpendicular axis is unchanged at that same world offset check fails → drift is 1D.
|
||
CHECK(!approxEq(base, f.coverageAt(0.f + p.windSpeed * dt, 5.f, 1.f), 1e-2f));
|
||
}
|
||
|
||
TEST_CASE("procgen.cloud.shadow.projection") {
|
||
CloudField::Params fp;
|
||
fp.seed = 7;
|
||
fp.worldScale = 48.f;
|
||
fp.coverage = 0.5f;
|
||
CloudField field(fp);
|
||
|
||
// Overhead sun (0,1,0): shadow coverage equals field coverage at the point.
|
||
CloudShadow::Params sp;
|
||
sp.field = field;
|
||
sp.sunDirX = 0.f;
|
||
sp.sunDirY = 1.f;
|
||
sp.sunDirZ = 0.f;
|
||
sp.cloudAltitude = 50.f;
|
||
CloudShadow overhead(sp);
|
||
const float px = 5.f, pz = 6.f;
|
||
CHECK(approxEq(overhead.coverageAt(px, pz, 0.f), field.coverageAt(px, pz, 0.f), 1e-5f));
|
||
|
||
// Angled sun: ground coverage = field coverage sampled up-sun at the cloud point.
|
||
sp.sunDirX = 0.5f;
|
||
sp.sunDirY = 1.f;
|
||
sp.sunDirZ = 0.f;
|
||
CloudShadow angled(sp);
|
||
float ox = 0.f, oz = 0.f;
|
||
angled.cloudOffset(ox, oz);
|
||
CHECK(approxEq(angled.coverageAt(px, pz, 0.f), field.coverageAt(px + ox, pz + oz, 0.f), 1e-5f));
|
||
|
||
// Factor: 1 when clear, (1-strength) when fully covered; always in [0,1].
|
||
CloudShadow::Params sp2 = sp;
|
||
sp2.strength = 0.8f;
|
||
CloudShadow shadow(sp2);
|
||
float minF = 1.f, maxF = 0.f;
|
||
for (float z = 0.f; z < 24.f; z += 2.f) {
|
||
for (float x = 0.f; x < 24.f; x += 2.f) {
|
||
const float f = shadow.shadowFactorAt(x, z, 0.f);
|
||
CHECK(f >= 0.f);
|
||
CHECK(f <= 1.f);
|
||
minF = std::min(minF, f);
|
||
maxF = std::max(maxF, f);
|
||
}
|
||
}
|
||
CHECK(maxF > 0.9f); // some fully-lit ground
|
||
CHECK(minF < 1.f); // some coverage darkens ground
|
||
// Sun below horizon → no cloud shadows.
|
||
sp.sunDirY = -1.f;
|
||
CloudShadow below(sp);
|
||
CHECK_EQ(below.coverageAt(px, pz, 0.f), 0.f);
|
||
CHECK_EQ(below.shadowFactorAt(px, pz, 0.f), 1.f);
|
||
}
|
||
|
||
TEST_CASE("procgen.cloud.recipes.generate") {
|
||
TextureRecipeRegistry::instance().registerBuiltins();
|
||
const char *ids[] = {"tex.cloud", "tex.cloud_shadow"};
|
||
for (const char *id : ids) {
|
||
Params p;
|
||
p.setSeed(42);
|
||
p.setSize(48, 48);
|
||
p.setFloat("worldScale", 64.f);
|
||
p.setFloat("time", 1.5f);
|
||
std::string err;
|
||
eve::image::ImageData *img = TextureRecipeRegistry::instance().generate(id, p, err);
|
||
REQUIRE(img != nullptr);
|
||
CHECK_EQ(img->getFormat(), std::string("RGBA8"));
|
||
CHECK_EQ(img->getWidth(), 48);
|
||
CHECK_EQ(img->getHeight(), 48);
|
||
delete img;
|
||
}
|
||
CHECK(TextureRecipeRegistry::instance().has("tex.cloud"));
|
||
CHECK(TextureRecipeRegistry::instance().has("tex.cloud_shadow"));
|
||
}
|
||
|
||
TEST_CASE("procgen.cloud.viaModule") {
|
||
Procgen *mod = Procgen::create();
|
||
CloudField *f = mod->newCloudField();
|
||
REQUIRE(f != nullptr);
|
||
f->setSeed(3);
|
||
f->setWorldScale(64.f);
|
||
f->setCoverage(0.5f);
|
||
const float c0 = mod->cloudCoverageAt(f, 2.f, 3.f, 0.f);
|
||
const float c1 = mod->cloudCoverageAt(f, 2.f, 3.f, 4.f);
|
||
CHECK(c0 >= 0.f);
|
||
CHECK(c0 <= 1.f);
|
||
CHECK(mod->cloudCoverageAt(nullptr, 0.f, 0.f, 0.f) == 0.f);
|
||
|
||
CloudShadow *s = mod->newCloudShadow();
|
||
REQUIRE(s != nullptr);
|
||
s->setSunDirection(0.5f, 1.f, 0.f);
|
||
s->setCloudAltitude(50.f);
|
||
s->setStrength(0.8f);
|
||
CHECK(mod->cloudShadowFactor(s, 2.f, 3.f, 0.f) >= 0.f);
|
||
CHECK(mod->cloudShadowFactor(nullptr, 0.f, 0.f, 0.f) == 1.f);
|
||
|
||
std::vector<float> buf(16 * 16);
|
||
mod->sampleCloud(f, buf.data(), 16, 16, 0.f, 0.f, 0.f, 64.f);
|
||
mod->sampleCloudShadow(s, buf.data(), 16, 16, 0.f, 0.f, 0.f, 64.f);
|
||
delete f;
|
||
delete s;
|
||
}
|
||
|
||
|
||
TEST_CASE("procgen.texture.builtinRecipes.expanded") {
|
||
TextureRecipeRegistry::instance().registerBuiltins();
|
||
const char *ids[] = {"tex.soil", "tex.stone", "tex.rock", "tex.marble", "tex.water",
|
||
"tex.ripple", "tex.sky_cloud", "tex.wood", "tex.cloth", "tex.ornament",
|
||
"tex.spot", "tex.zebra", "tex.wall", "tex.cement", "tex.mud"};
|
||
for (const char *id : ids) {
|
||
Params p;
|
||
p.setSeed(11);
|
||
p.setSize(32, 32);
|
||
p.setInt("colors", 5);
|
||
std::string err;
|
||
eve::image::ImageData *a = TextureRecipeRegistry::instance().generate(id, p, err);
|
||
eve::image::ImageData *b = TextureRecipeRegistry::instance().generate(id, p, err);
|
||
REQUIRE(a != nullptr);
|
||
REQUIRE(b != nullptr);
|
||
CHECK_EQ(a->getFormat(), std::string("RGBA8"));
|
||
CHECK(std::memcmp(a->getData(), b->getData(), a->getSize()) == 0);
|
||
delete a;
|
||
delete b;
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.pbr.registry.builtinsAndReproducible") {
|
||
PbrRecipeRegistry::instance().registerPbrBuiltins();
|
||
const char *ids[] = {"pbr.soil", "pbr.rock", "pbr.marble", "pbr.water", "pbr.wood",
|
||
"pbr.cloth", "pbr.ornament", "pbr.spot", "pbr.zebra", "pbr.wall",
|
||
"pbr.cement", "pbr.mud", "pbr.ripple"};
|
||
for (const char *id : ids) {
|
||
REQUIRE(PbrRecipeRegistry::instance().has(id));
|
||
Params p;
|
||
p.setSeed(7);
|
||
p.setSize(32, 32);
|
||
std::string err;
|
||
PbrTextureSet *a = PbrRecipeRegistry::instance().generate(id, p, err);
|
||
PbrTextureSet *b = PbrRecipeRegistry::instance().generate(id, p, err);
|
||
REQUIRE(a != nullptr);
|
||
REQUIRE(b != nullptr);
|
||
// All six maps present, same size, reproducible.
|
||
REQUIRE(a->albedo != nullptr);
|
||
REQUIRE(a->normal != nullptr);
|
||
REQUIRE(a->roughness != nullptr);
|
||
REQUIRE(a->metallic != nullptr);
|
||
REQUIRE(a->height != nullptr);
|
||
REQUIRE(a->ao != nullptr);
|
||
CHECK_EQ(a->albedo->getWidth(), 32);
|
||
CHECK_EQ(a->albedo->getHeight(), 32);
|
||
CHECK(std::memcmp(a->albedo->getData(), b->albedo->getData(), a->albedo->getSize()) == 0);
|
||
CHECK(std::memcmp(a->normal->getData(), b->normal->getData(), a->normal->getSize()) == 0);
|
||
// Grayscale maps must be RGBA8 with alpha=255.
|
||
const auto *px = static_cast<const uint8_t *>(a->roughness->getData());
|
||
bool alphaOk = true;
|
||
for (size_t i = 0; i < a->roughness->getSize(); i += 4) {
|
||
if (px[i + 3] != 255) alphaOk = false;
|
||
}
|
||
CHECK(alphaOk);
|
||
a->destroy();
|
||
b->destroy();
|
||
delete a;
|
||
delete b;
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.pbr.metallicAndRoughnessRange") {
|
||
PbrRecipeRegistry::instance().registerPbrBuiltins();
|
||
Params p;
|
||
p.setSeed(3);
|
||
p.setSize(48, 48);
|
||
p.setFloat("metallic", 1.f);
|
||
p.setFloat("roughnessLow", 0.1f);
|
||
p.setFloat("roughnessHigh", 0.2f);
|
||
std::string err;
|
||
PbrTextureSet *set = PbrRecipeRegistry::instance().generate("pbr.marble", p, err);
|
||
REQUIRE(set != nullptr);
|
||
const auto *m = static_cast<const uint8_t *>(set->metallic->getData());
|
||
for (size_t i = 0; i < set->metallic->getSize(); i += 4) {
|
||
CHECK_EQ(m[i], 255); // fully metallic
|
||
}
|
||
const auto *r = static_cast<const uint8_t *>(set->roughness->getData());
|
||
for (size_t i = 0; i < set->roughness->getSize(); i += 4) {
|
||
CHECK(r[i] >= 25); // roughnessLow 0.1 -> 25
|
||
CHECK(r[i] <= 51); // roughnessHigh 0.2 -> 51
|
||
}
|
||
set->destroy();
|
||
delete set;
|
||
}
|
||
|
||
TEST_CASE("procgen.pbr.viaModuleAndErrors") {
|
||
Procgen *mod = Procgen::create();
|
||
Params p;
|
||
p.setSeed(9);
|
||
p.setSize(40, 40);
|
||
PbrTextureSet *set = mod->generatePbrMaterial("pbr.wall", &p);
|
||
REQUIRE(set != nullptr);
|
||
CHECK(set->albedo != nullptr);
|
||
CHECK(set->normal != nullptr);
|
||
set->destroy();
|
||
delete set;
|
||
|
||
CHECK(mod->hasPbrRecipe("pbr.wood"));
|
||
CHECK(mod->getPbrRecipeCount() >= 13);
|
||
bool listed = false;
|
||
for (int i = 0; i < mod->getPbrRecipeCount(); ++i) {
|
||
if (mod->getPbrRecipeId(i) == "pbr.wood") listed = true;
|
||
}
|
||
CHECK(listed);
|
||
|
||
CHECK(mod->generatePbrMaterial("pbr.missing", &p) == nullptr);
|
||
CHECK(mod->generatePbrMaterial("pbr.wall", nullptr) == nullptr);
|
||
CHECK(mod->lastError().find("null") != std::string::npos);
|
||
}
|
||
|
||
static Color colorForSemantic(int sem) {
|
||
switch (sem) {
|
||
case int(Semantic::Wall):
|
||
return Color(0.22f, 0.24f, 0.30f, 1.f);
|
||
case int(Semantic::Floor):
|
||
return Color(0.72f, 0.68f, 0.55f, 1.f);
|
||
case int(Semantic::Corridor):
|
||
return Color(0.55f, 0.52f, 0.42f, 1.f);
|
||
case int(Semantic::Water):
|
||
return Color(0.25f, 0.45f, 0.85f, 1.f);
|
||
case int(Semantic::Sand):
|
||
return Color(0.85f, 0.78f, 0.45f, 1.f);
|
||
case int(Semantic::Grass):
|
||
return Color(0.35f, 0.65f, 0.30f, 1.f);
|
||
case int(Semantic::Dirt):
|
||
return Color(0.55f, 0.40f, 0.25f, 1.f);
|
||
case int(Semantic::Stone):
|
||
return Color(0.55f, 0.58f, 0.62f, 1.f);
|
||
case int(Semantic::Snow):
|
||
return Color(0.90f, 0.93f, 0.97f, 1.f);
|
||
case int(Semantic::Door):
|
||
return Color(0.75f, 0.45f, 0.20f, 1.f);
|
||
default:
|
||
return Color(0.05f, 0.05f, 0.07f, 1.f);
|
||
}
|
||
}
|
||
|
||
static void drawGrid(Graphics *gfx, const Grid2D &grid, float originX, float originY, float cell) {
|
||
const int w = grid.getWidth();
|
||
const int h = grid.getHeight();
|
||
for (int y = 0; y < h; ++y) {
|
||
for (int x = 0; x < w; ++x) {
|
||
const Color c = colorForSemantic(grid.getCell(x, y));
|
||
gfx->drawSolidRect(originX + float(x) * cell, originY + float(y) * cell, cell, cell, c);
|
||
}
|
||
}
|
||
// Markers for spawn / objects.
|
||
for (int i = 0; i < grid.getObjectCount(); ++i) {
|
||
const float ox = originX + grid.getObjectX(i) * cell;
|
||
const float oy = originY + grid.getObjectY(i) * cell;
|
||
gfx->drawSolidRect(ox - 2.f, oy - 2.f, 5.f, 5.f, Color(1.f, 0.3f, 0.3f, 1.f));
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.render.dungeonCaveMazePreview") {
|
||
GeneratorRegistry::instance().registerBuiltins();
|
||
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings s;
|
||
s.width = 720;
|
||
s.height = 420;
|
||
s.centered = true;
|
||
REQUIRE(win->setWindowSettings(s));
|
||
|
||
struct Algo {
|
||
const char *id;
|
||
int w, h;
|
||
};
|
||
const Algo algos[] = {
|
||
{"dungeon.bsp", 36, 28},
|
||
{"cave.cellular", 36, 28},
|
||
{"maze.backtrack", 31, 23},
|
||
{"noise.terrain", 36, 28},
|
||
};
|
||
|
||
gfx->setBackgroundColorRGBA(0.06f, 0.07f, 0.09f, 1.f);
|
||
int drawnCells = 0;
|
||
|
||
for (int ai = 0; ai < 4; ++ai) {
|
||
const Algo &algo = algos[ai];
|
||
Params p;
|
||
p.setSeed(42);
|
||
p.setSize(algo.w, algo.h);
|
||
if (std::string(algo.id) == "cave.cellular") {
|
||
p.setInt("loops", 4);
|
||
p.setFloat("fill", 0.45f);
|
||
} else if (std::string(algo.id) == "noise.terrain") {
|
||
p.setFloat("frequency", 5.f);
|
||
p.setInt("octaves", 3);
|
||
}
|
||
|
||
Grid2D grid;
|
||
std::string err;
|
||
REQUIRE(GeneratorRegistry::instance().generate(algo.id, p, grid, err));
|
||
|
||
const float cell = std::min(16.f, std::min(float(gfx->getWidth() - 40) / float(algo.w),
|
||
float(gfx->getHeight() - 40) / float(algo.h)));
|
||
const float originX = (float(gfx->getWidth()) - float(algo.w) * cell) * 0.5f;
|
||
const float originY = (float(gfx->getHeight()) - float(algo.h) * cell) * 0.5f;
|
||
|
||
for (int frame = 0; frame < 45; ++frame) {
|
||
gfx->clearScreen();
|
||
drawGrid(gfx, grid, originX, originY, cell);
|
||
// Title bar strip so algorithm changes are readable without fonts.
|
||
const float barW = float(gfx->getWidth()) * (0.15f + 0.2f * float(ai));
|
||
gfx->drawSolidRect(12.f, 10.f, barW, 8.f, Color(0.9f, 0.85f, 0.4f, 1.f));
|
||
gfx->present();
|
||
|
||
drawnCells = algo.w * algo.h;
|
||
SDL_Event e;
|
||
while (SDL_PollEvent(&e)) {
|
||
if (e.type == SDL_QUIT) break;
|
||
}
|
||
SDL_Delay(16);
|
||
}
|
||
}
|
||
|
||
CHECK_GT(drawnCells, 100);
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.linearStructure.recipesRegistered") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
for (const char *id : {"mesh.fence", "mesh.stonewall", "mesh.bridge", "mesh.greatwall",
|
||
"mesh.hedge", "mesh.chevaldefrise"}) {
|
||
CHECK(MeshRecipeRegistry::instance().has(id));
|
||
}
|
||
const auto ids = MeshRecipeRegistry::instance().list();
|
||
for (const char *id : {"mesh.fence", "mesh.stonewall", "mesh.bridge", "mesh.greatwall",
|
||
"mesh.hedge", "mesh.chevaldefrise"}) {
|
||
CHECK(std::find(ids.begin(), ids.end(), id) != ids.end());
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.linearStructure.buildsAllKinds") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
for (const char *id : {"mesh.fence", "mesh.stonewall", "mesh.bridge", "mesh.greatwall",
|
||
"mesh.hedge", "mesh.chevaldefrise"}) {
|
||
Params p;
|
||
p.setSeed(7);
|
||
p.setInt("segments", 4);
|
||
p.setFloat("segLength", 1.f);
|
||
MeshBuild m;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate(id, p, m, err));
|
||
CHECK(m.getVertexCount() > 0);
|
||
CHECK(m.getIndexCount() > 0);
|
||
CHECK_EQ(m.getIndexCount() % 3, 0);
|
||
CHECK(meshIndicesInRange(m));
|
||
CHECK(meshPositionsFinite(m));
|
||
CHECK(meshNormalsFiniteUnit(m));
|
||
CHECK_EQ(m.getMeta("algorithm", ""), std::string(id));
|
||
CHECK_EQ(m.getMeta("segments", ""), std::string("4"));
|
||
}
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.linearStructure.reproducibleAndScalable") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setSeed(3);
|
||
p.setInt("segments", 5);
|
||
p.setFloat("segLength", 1.2f);
|
||
MeshBuild a, b, scaled;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.greatwall", p, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.greatwall", p, b, err));
|
||
CHECK(a.positions() == b.positions());
|
||
CHECK(a.indices() == b.indices());
|
||
|
||
Params p2 = p;
|
||
p2.setFloat("scale", 2.f);
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.greatwall", p2, scaled, err));
|
||
CHECK_EQ(a.getVertexCount(), scaled.getVertexCount());
|
||
// Scaling a 2x should scale the X extent of the whole run by 2x.
|
||
float minAx = 1e30f, maxAx = -1e30f, minSx = 1e30f, maxSx = -1e30f;
|
||
for (int i = 0; i < a.getVertexCount(); ++i) {
|
||
minAx = std::min(minAx, a.getPositionX(i));
|
||
maxAx = std::max(maxAx, a.getPositionX(i));
|
||
minSx = std::min(minSx, scaled.getPositionX(i));
|
||
maxSx = std::max(maxSx, scaled.getPositionX(i));
|
||
}
|
||
CHECK(std::fabs((maxAx - minAx) * 2.f - (maxSx - minSx)) < 1e-3f);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.linearStructure.segmentsScaleVertexCount") {
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params lo;
|
||
lo.setInt("segments", 2);
|
||
lo.setFloat("segLength", 1.f);
|
||
Params hi = lo;
|
||
hi.setInt("segments", 6);
|
||
MeshBuild a, b;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.fence", lo, a, err));
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.fence", hi, b, err));
|
||
// Linear repetition of one unit ⇒ vertex count grows linearly with segments.
|
||
CHECK_EQ(3 * a.getVertexCount(), b.getVertexCount());
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.linearStructure.tileableSeamAlignment") {
|
||
// One unit ends exactly where the next begins, so a tiled run is contiguous.
|
||
MeshRecipeRegistry::instance().registerBuiltins();
|
||
Params p;
|
||
p.setInt("segments", 1);
|
||
p.setFloat("segLength", 1.f);
|
||
MeshBuild single;
|
||
std::string err;
|
||
CHECK(MeshRecipeRegistry::instance().generate("mesh.bridge", p, single, err));
|
||
float maxX = -1e30f;
|
||
for (int i = 0; i < single.getVertexCount(); ++i) maxX = std::max(maxX, single.getPositionX(i));
|
||
// Unit occupies [0, segLength]; tiling repeats at +segLength.
|
||
CHECK(std::fabs(maxX - 1.f) < 1e-3f);
|
||
}
|
||
|
||
TEST_CASE("procgen.mesh.linearStructure.viaModule") {
|
||
Procgen *mod = Procgen::create();
|
||
Params p;
|
||
p.setSeed(1);
|
||
p.setInt("segments", 4);
|
||
p.setFloat("segLength", 1.f);
|
||
MeshBuild *m = mod->buildMesh("mesh.stonewall", &p);
|
||
REQUIRE(m != nullptr);
|
||
CHECK(m->getVertexCount() > 0);
|
||
CHECK(meshIndicesInRange(*m));
|
||
CHECK(mod->hasMeshRecipe("mesh.stonewall"));
|
||
CHECK(mod->hasMeshRecipe("mesh.bridge"));
|
||
delete m;
|
||
|
||
CHECK(mod->buildMesh("mesh.nonexistent", &p) == nullptr);
|
||
CHECK(mod->lastError().find("unknown") != std::string::npos);
|
||
}
|
||
|
||
// --- Water (graphics): sky reflection + animated edge waves + middle drop ripples ---
|
||
|
||
namespace {
|
||
|
||
struct WaterLumaGrid {
|
||
int grid = 16;
|
||
std::vector<float> cells;
|
||
};
|
||
|
||
/** Render the current scene and downsample luma into a grid (for comparison). */
|
||
WaterLumaGrid waterCaptureLuma(Graphics *gfx, int grid) {
|
||
for (int frame = 0; frame < 3; ++frame) {
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
}
|
||
std::unique_ptr<eve::image::ImageData> img(gfx->newImageData());
|
||
REQUIRE(img.get() != nullptr);
|
||
const uint8_t *px = static_cast<const uint8_t *>(img->getData());
|
||
const int w = img->getWidth();
|
||
const int h = img->getHeight();
|
||
WaterLumaGrid out;
|
||
out.grid = grid;
|
||
out.cells.assign(size_t(grid * grid), 0.f);
|
||
for (int y = 0; y < h; ++y) {
|
||
for (int x = 0; x < w; ++x) {
|
||
const size_t o = (size_t(y) * w + size_t(x)) * 4u;
|
||
const float l = 0.2126f * px[o] + 0.7152f * px[o + 1] + 0.0722f * px[o + 2];
|
||
const int gx = std::min(grid - 1, x * grid / w);
|
||
const int gy = std::min(grid - 1, y * grid / h);
|
||
out.cells[size_t(gy * grid + gx)] += l;
|
||
}
|
||
}
|
||
const float scale = 1.f / (float(w / grid) * float(h / grid));
|
||
for (float &c : out.cells) c *= scale;
|
||
return out;
|
||
}
|
||
|
||
float waterDiff(const WaterLumaGrid &a, const WaterLumaGrid &b) {
|
||
float sum = 0.f;
|
||
const int n = a.grid * a.grid;
|
||
for (int i = 0; i < n; ++i) sum += std::fabs(a.cells[size_t(i)] - b.cells[size_t(i)]);
|
||
return sum / float(n);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
TEST_CASE("graphics.waterfall.paramsRoundTrip") {
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 128;
|
||
settings.height = 128;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
Waterfall *wf = gfx->newWaterfall();
|
||
REQUIRE(wf != nullptr);
|
||
REQUIRE(wf->getShader() != nullptr);
|
||
|
||
wf->setFlowSpeed(2.0f);
|
||
CHECK(approxEq(wf->getFlowSpeed(), 2.0f, 1e-5f));
|
||
wf->setTurbulence(0.9f);
|
||
CHECK(approxEq(wf->getTurbulence(), 0.9f, 1e-5f));
|
||
wf->setStreakCount(5);
|
||
CHECK_EQ(wf->getStreakCount(), 5);
|
||
wf->setStreakScale(7.f);
|
||
CHECK(approxEq(wf->getStreakScale(), 7.f, 1e-5f));
|
||
wf->setTopFoam(0.08f);
|
||
CHECK(approxEq(wf->getTopFoam(), 0.08f, 1e-5f));
|
||
wf->setBottomFoam(0.15f);
|
||
CHECK(approxEq(wf->getBottomFoam(), 0.15f, 1e-5f));
|
||
wf->setFoamAmount(0.9f);
|
||
CHECK(approxEq(wf->getFoamAmount(), 0.9f, 1e-5f));
|
||
wf->setWaterColor(0.1f, 0.2f, 0.3f);
|
||
wf->setReflectionIntensity(0.4f);
|
||
CHECK(approxEq(wf->getReflectionIntensity(), 0.4f, 1e-5f));
|
||
wf->setSunIntensity(0.8f);
|
||
CHECK(approxEq(wf->getSunIntensity(), 0.8f, 1e-5f));
|
||
wf->bindParams(); // must not throw
|
||
|
||
wf->createSheet(10.f, 16.f, 8, 12);
|
||
REQUIRE(wf->getMesh() != nullptr);
|
||
CHECK(Waterfall::paramCount() > 0);
|
||
CHECK(!Waterfall::paramName(0).empty());
|
||
delete wf;
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("graphics.water.paramsRoundTrip") {
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(gfx != nullptr);
|
||
Water *w = gfx->newWater();
|
||
REQUIRE(w != nullptr);
|
||
REQUIRE(w->getShader() != nullptr);
|
||
|
||
w->setWaveSpeed(2.5f);
|
||
CHECK(approxEq(w->getWaveSpeed(), 2.5f, 1e-5f));
|
||
w->setWaveAmplitude(0.8f);
|
||
CHECK(approxEq(w->getWaveAmplitude(), 0.8f, 1e-5f));
|
||
w->setRippleAmplitude(0.9f);
|
||
CHECK(approxEq(w->getRippleAmplitude(), 0.9f, 1e-5f));
|
||
w->setEdgeFalloff(0.25f);
|
||
CHECK(approxEq(w->getEdgeFalloff(), 0.25f, 1e-5f));
|
||
w->setRippleCount(4);
|
||
CHECK_EQ(w->getRippleCount(), 4);
|
||
w->setRippleInterval(1.2f);
|
||
CHECK(approxEq(w->getRippleInterval(), 1.2f, 1e-5f));
|
||
w->setWaveScale(10.f);
|
||
CHECK(approxEq(w->getWaveScale(), 10.f, 1e-5f));
|
||
w->setWaterColor(0.1f, 0.2f, 0.3f);
|
||
w->setReflectionTint(0.5f, 0.6f, 0.7f);
|
||
w->setReflectionIntensity(0.4f);
|
||
CHECK(approxEq(w->getReflectionIntensity(), 0.4f, 1e-5f));
|
||
w->setSunIntensity(0.8f);
|
||
CHECK(approxEq(w->getSunIntensity(), 0.8f, 1e-5f));
|
||
w->bindParams(); // must not throw
|
||
|
||
w->createPlane(10.f, 8.f, 8, 6);
|
||
REQUIRE(w->getMesh() != nullptr);
|
||
CHECK(Water::paramCount() > 0);
|
||
CHECK(!Water::paramName(0).empty());
|
||
delete w;
|
||
}
|
||
|
||
TEST_CASE("graphics.water.render.dynamicRipplesAndReflection") {
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 256;
|
||
settings.height = 256;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
|
||
// Blue-ish sky cubemap so reflection is visible.
|
||
eve::image::Image::create();
|
||
const int fs = 4;
|
||
const uint8_t sky[6 * 4 * 4 * 4] = {0}; // 6 faces × 4×4 × RGBA
|
||
for (int f = 0; f < 6; ++f)
|
||
for (int i = 0; i < fs * fs; ++i) {
|
||
const size_t o = size_t(f) * fs * fs * 4 + size_t(i) * 4;
|
||
((uint8_t *)&sky)[o] = 120;
|
||
((uint8_t *)&sky)[o + 1] = 160;
|
||
((uint8_t *)&sky)[o + 2] = 220;
|
||
((uint8_t *)&sky)[o + 3] = 255;
|
||
}
|
||
Texture *skyTex = gfx->newCubemap(fs, sky);
|
||
REQUIRE(skyTex != nullptr);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(0.f, 10.f, 4.f);
|
||
camera->setTarget(0.f, 0.f, 0.f);
|
||
camera->setFov(55.f);
|
||
camera->setAmbient(0.15f, 0.18f, 0.22f);
|
||
camera->setEnvMap(skyTex);
|
||
camera->setEnvIntensity(1.f);
|
||
|
||
gfx->setBackgroundColor(Color(0.02f, 0.03f, 0.05f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
RenderSystem3D::setDirectionalLight(0.3f, 1.f, 0.2f, 1.1f, 1.0f, 0.9f);
|
||
|
||
// Transparent 2D entity drives the normal present path used by render tests.
|
||
auto *present = Renderable2D::create();
|
||
present->transform()->x = 0.f;
|
||
present->transform()->y = 0.f;
|
||
present->sprite()->width = 1.f;
|
||
present->sprite()->height = 1.f;
|
||
present->sprite()->a = 0.f;
|
||
|
||
Water *w = gfx->newWater();
|
||
REQUIRE(w != nullptr);
|
||
w->createPlane(20.f, 20.f, 40, 40);
|
||
w->setWaveAmplitude(0.5f);
|
||
w->setRippleAmplitude(0.8f);
|
||
w->setRippleCount(6);
|
||
w->setReflectionIntensity(0.8f);
|
||
w->setSunIntensity(0.8f);
|
||
|
||
// Attach the water plane + shader to a scene-graph renderable (standard path).
|
||
auto *waterEnt = Renderable3D::create();
|
||
waterEnt->setMesh(w->getMesh());
|
||
waterEnt->setShader(w->getShader());
|
||
waterEnt->setTexture(nullptr);
|
||
waterEnt->setReceiveShadow(false);
|
||
waterEnt->setCastShadow(false);
|
||
waterEnt->setCamera(camera);
|
||
|
||
auto captureWater = [&](float time) {
|
||
w->setTime(time);
|
||
w->bindParams();
|
||
return waterCaptureLuma(gfx, 16);
|
||
};
|
||
|
||
// Dynamic ripples: different times give different patterns (and it renders).
|
||
const WaterLumaGrid t0 = captureWater(0.f);
|
||
const WaterLumaGrid t1 = captureWater(0.35f);
|
||
const float dynamic = waterDiff(t0, t1);
|
||
float rendered = 0.f;
|
||
for (float c : t0.cells) rendered += c;
|
||
rendered /= float(t0.cells.size());
|
||
std::printf("water render: dynamic=%.2f rendered=%.2f\n", dynamic, rendered);
|
||
CHECK(rendered > 1.f); // water surface is actually drawn
|
||
CHECK(dynamic > 0.3f); // ripples move over time
|
||
|
||
// Edge waves + middle drop ripples: flat (no ripples) differs from rippled.
|
||
const WaterLumaGrid flat = [&] {
|
||
w->setRippleAmplitude(0.f);
|
||
w->setWaveAmplitude(0.f);
|
||
w->setReflectionIntensity(0.f);
|
||
w->bindParams();
|
||
w->setTime(0.6f);
|
||
return waterCaptureLuma(gfx, 16);
|
||
}();
|
||
const WaterLumaGrid wavy = [&] {
|
||
w->setRippleAmplitude(0.9f);
|
||
w->setWaveAmplitude(0.5f);
|
||
w->setReflectionIntensity(0.8f);
|
||
w->bindParams();
|
||
w->setTime(0.6f);
|
||
return waterCaptureLuma(gfx, 16);
|
||
}();
|
||
const float rippleDiff = waterDiff(flat, wavy);
|
||
std::printf("water render: rippleDiff=%.2f\n", rippleDiff);
|
||
CHECK(rippleDiff > 0.2f); // ripples (edge + middle) change the surface
|
||
|
||
delete w;
|
||
win->close();
|
||
}
|
||
|
||
/** Build a unit cube ([-0.5,0.5]³) with per-face UVs in [0,1]². */
|
||
static Mesh *makeUnitCube(Graphics *gfx) {
|
||
std::vector<float> pos, nrm, uv;
|
||
std::vector<uint32_t> idx;
|
||
// 6 faces: +X,-X,+Y,-Y,+Z,-Z. Each face 4 corners (CCW from outside).
|
||
const float nx[6] = {1, -1, 0, 0, 0, 0};
|
||
const float ny[6] = {0, 0, 1, -1, 0, 0};
|
||
const float nz[6] = {0, 0, 0, 0, 1, -1};
|
||
// Corner offsets relative to face center (unit cube half-extent 0.5).
|
||
const float ox[4] = {0.5f, -0.5f, -0.5f, 0.5f};
|
||
const float oy[4] = {0.5f, 0.5f, -0.5f, -0.5f};
|
||
const float oz[4] = {0.5f, -0.5f, -0.5f, 0.5f};
|
||
const float uu[4] = {0.f, 1.f, 1.f, 0.f};
|
||
const float vv[4] = {0.f, 0.f, 1.f, 1.f};
|
||
for (int f = 0; f < 6; ++f) {
|
||
const uint32_t base = uint32_t(pos.size() / 3);
|
||
for (int c = 0; c < 4; ++c) {
|
||
// Tangents spanning the face so corners are correct for each normal axis.
|
||
float px = 0.f, py = 0.f, pz = 0.f;
|
||
if (nx[f] != 0.f) { px = nx[f] * 0.5f; py = oy[c]; pz = oz[c]; }
|
||
else if (ny[f] != 0.f) { py = ny[f] * 0.5f; px = ox[c]; pz = oz[c]; }
|
||
else { pz = nz[f] * 0.5f; px = ox[c]; py = oy[c]; }
|
||
pos.push_back(px);
|
||
pos.push_back(py);
|
||
pos.push_back(pz);
|
||
nrm.push_back(nx[f]);
|
||
nrm.push_back(ny[f]);
|
||
nrm.push_back(nz[f]);
|
||
uv.push_back(uu[c]);
|
||
uv.push_back(vv[c]);
|
||
}
|
||
idx.push_back(base + 0);
|
||
idx.push_back(base + 1);
|
||
idx.push_back(base + 2);
|
||
idx.push_back(base + 0);
|
||
idx.push_back(base + 2);
|
||
idx.push_back(base + 3);
|
||
}
|
||
return gfx->newMeshFromArrays(pos.data(), nrm.data(), uv.data(), int(pos.size() / 3),
|
||
idx.data(), int(idx.size()));
|
||
}
|
||
|
||
TEST_CASE("graphics.water.render.plane") {
|
||
const char *outputPath = std::getenv("EVENGINE_WATER_RENDER_PNG");
|
||
if (!outputPath || !outputPath[0]) return;
|
||
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 640;
|
||
settings.height = 480;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
eve::image::Image::create();
|
||
|
||
// Gradient sky cubemap: deep blue at the zenith, pale near the horizon.
|
||
const int fs = 16;
|
||
std::vector<uint8_t> sky(size_t(fs * fs * 4 * 6));
|
||
{
|
||
auto dirFor = [&](int f, int x, int y, float &dx, float &dy, float &dz) {
|
||
const float u = (float(x) + 0.5f) / fs * 2.f - 1.f;
|
||
const float v = (float(y) + 0.5f) / fs * 2.f - 1.f;
|
||
// Vulkan cubemap face order: +X,-X,+Y,-Y,+Z,-Z (Y-down texel convention).
|
||
switch (f) {
|
||
case 0: dx = 1.f; dy = -v; dz = -u; break;
|
||
case 1: dx = -1.f; dy = -v; dz = u; break;
|
||
case 2: dx = u; dy = 1.f; dz = v; break;
|
||
case 3: dx = u; dy = -1.f; dz = -v; break;
|
||
case 4: dx = u; dy = -v; dz = 1.f; break;
|
||
default: dx = -u; dy = -v; dz = -1.f; break;
|
||
}
|
||
};
|
||
for (int f = 0; f < 6; ++f) {
|
||
for (int y = 0; y < fs; ++y) {
|
||
for (int x = 0; x < fs; ++x) {
|
||
float dx, dy, dz;
|
||
dirFor(f, x, y, dx, dy, dz);
|
||
const float len = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||
const float ny = dy / len;
|
||
// t=1 at zenith, t=0 near/below horizon.
|
||
const float t = std::pow(std::clamp(ny * 0.5f + 0.5f, 0.f, 1.f), 1.5f);
|
||
// Pale blue horizon → deep blue zenith.
|
||
const float cr = 0.72f + (0.12f - 0.72f) * t;
|
||
const float cg = 0.80f + (0.32f - 0.80f) * t;
|
||
const float cb = 0.90f + (0.72f - 0.90f) * t;
|
||
const size_t o = (size_t(f) * fs * fs + size_t(y) * fs + size_t(x)) * 4u;
|
||
sky[o + 0] = uint8_t(cr * 255.f);
|
||
sky[o + 1] = uint8_t(cg * 255.f);
|
||
sky[o + 2] = uint8_t(cb * 255.f);
|
||
sky[o + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Texture *skyTex = gfx->newCubemap(fs, sky.data());
|
||
REQUIRE(skyTex != nullptr);
|
||
|
||
// Skybox: a huge sphere centered on the camera, shaded purely by the env
|
||
// cubemap so the sky is visible behind the water.
|
||
const char *kSkyFrag = R"GLSL(#version 450
|
||
layout(location = 3) in vec3 vWorldPos;
|
||
layout(location = 4) in vec3 vCameraPos;
|
||
layout(set = 0, binding = 3) uniform samplerCube env;
|
||
layout(location = 0) out vec4 outColor;
|
||
void main() {
|
||
vec3 c = texture(env, normalize(vWorldPos - vCameraPos)).rgb;
|
||
outColor = vec4(c, 1.0);
|
||
}
|
||
)GLSL";
|
||
Shader *skyShader = gfx->newMeshShader("", kSkyFrag);
|
||
REQUIRE(skyShader != nullptr);
|
||
Mesh *skyMesh = gfx->newMeshSphere(24, 16);
|
||
REQUIRE(skyMesh != nullptr);
|
||
auto *skyEnt = Renderable3D::create();
|
||
skyEnt->setMesh(skyMesh);
|
||
skyEnt->setShader(skyShader);
|
||
skyEnt->setTexture(nullptr);
|
||
skyEnt->setScale(200.f, 200.f, 200.f);
|
||
skyEnt->setReceiveShadow(false);
|
||
skyEnt->setCastShadow(false);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(6.f, 5.f, 8.f);
|
||
camera->setTarget(0.f, 0.f, 0.f);
|
||
camera->setFov(50.f);
|
||
camera->setAmbient(0.28f, 0.32f, 0.40f);
|
||
camera->setEnvMap(skyTex);
|
||
camera->setEnvIntensity(1.f);
|
||
camera->data()->nearZ = 0.1f;
|
||
camera->data()->farZ = 2000.f;
|
||
skyEnt->setCamera(camera);
|
||
|
||
gfx->setBackgroundColor(Color(0.05f, 0.09f, 0.14f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
RenderSystem3D::setDirectionalLight(0.45f, 1.f, 0.3f, 1.4f, 1.3f, 1.2f);
|
||
|
||
auto *present = Renderable2D::create();
|
||
present->transform()->x = 0.f;
|
||
present->transform()->y = 0.f;
|
||
present->sprite()->width = 1.f;
|
||
present->sprite()->height = 1.f;
|
||
present->sprite()->a = 0.f;
|
||
|
||
// A single flat water plane carrying the water shader.
|
||
Water *water = gfx->newWater();
|
||
REQUIRE(water != nullptr);
|
||
water->createPlane(14.f, 14.f, 64, 64);
|
||
water->setWaterColor(0.06f, 0.30f, 0.48f);
|
||
water->setWaveAmplitude(0.30f);
|
||
water->setRippleAmplitude(0.55f);
|
||
water->setRippleCount(8);
|
||
water->setRippleInterval(1.4f);
|
||
water->setWaveScale(14.f);
|
||
water->setReflectionTint(0.9f, 0.95f, 1.0f);
|
||
water->setReflectionIntensity(1.3f);
|
||
water->setSunIntensity(1.6f);
|
||
|
||
auto *waterEnt = Renderable3D::create();
|
||
waterEnt->setMesh(water->getMesh());
|
||
waterEnt->setShader(water->getShader());
|
||
waterEnt->setTexture(nullptr);
|
||
waterEnt->setReceiveShadow(false);
|
||
waterEnt->setCastShadow(false);
|
||
waterEnt->setCamera(camera);
|
||
|
||
// Animate a few seconds so ripples travel, then save a frame.
|
||
for (int frame = 0; frame < 40; ++frame) {
|
||
// Keep the skybox centered on the camera so it reads as a surrounding sky.
|
||
skyEnt->setPosition(camera->data()->eyeX, camera->data()->eyeY, camera->data()->eyeZ);
|
||
water->setTime(float(frame) * 0.06f);
|
||
water->bindParams();
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
}
|
||
std::unique_ptr<eve::image::ImageData> image(gfx->newImageData());
|
||
REQUIRE(image.get() != nullptr);
|
||
REQUIRE(saveImagePng(*image, outputPath));
|
||
std::printf("water plane render saved: %s\n", outputPath);
|
||
win->close();
|
||
}
|
||
|
||
TEST_CASE("graphics.water.render.ssr") {
|
||
const char *outputPath = std::getenv("EVENGINE_SSR_RENDER_PNG");
|
||
if (!outputPath || !outputPath[0]) return;
|
||
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 640;
|
||
settings.height = 480;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
eve::image::Image::create();
|
||
|
||
// Gradient sky cubemap.
|
||
const int fs = 16;
|
||
std::vector<uint8_t> sky(size_t(fs * fs * 4 * 6));
|
||
for (size_t i = 0; i < sky.size(); i += 4) {
|
||
sky[i] = 135; sky[i + 1] = 180; sky[i + 2] = 235; sky[i + 3] = 255;
|
||
}
|
||
Texture *skyTex = gfx->newCubemap(fs, sky.data());
|
||
REQUIRE(skyTex != nullptr);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(0.f, 5.f, 8.f);
|
||
camera->setTarget(0.f, 1.f, -3.f);
|
||
camera->setFov(55.f);
|
||
camera->setAmbient(0.28f, 0.32f, 0.40f);
|
||
camera->setEnvMap(skyTex);
|
||
camera->setEnvIntensity(1.f);
|
||
camera->data()->nearZ = 0.1f;
|
||
camera->data()->farZ = 100.f;
|
||
|
||
gfx->setBackgroundColor(Color(0.05f, 0.09f, 0.14f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
RenderSystem3D::setDirectionalLight(0.45f, 1.f, 0.3f, 1.3f, 1.2f, 1.1f);
|
||
|
||
auto *present = Renderable2D::create();
|
||
present->transform()->x = 0.f;
|
||
present->transform()->y = 0.f;
|
||
present->sprite()->width = 1.f;
|
||
present->sprite()->height = 1.f;
|
||
present->sprite()->a = 0.f;
|
||
|
||
// A few colourful boxes high on the far side, above the water, so the
|
||
// water's upward reflection rays hit them.
|
||
struct Box { float x, y, z, sx, sy, sz; uint8_t r, g, b; };
|
||
const Box boxes[] = {
|
||
{0.f, 4.0f, -5.f, 3.0f, 3.0f, 3.0f, 210, 60, 60},
|
||
{-3.5f, 3.0f, -3.f, 2.0f, 2.0f, 2.0f, 70, 180, 70},
|
||
{3.5f, 3.5f, -4.f, 2.5f, 2.5f, 2.5f, 70, 110, 230},
|
||
};
|
||
Mesh *cube = makeUnitCube(gfx);
|
||
for (const Box &b : boxes) {
|
||
auto *ent = Renderable3D::create();
|
||
ent->setMesh(cube);
|
||
const uint8_t px[4] = {b.r, b.g, b.b, 255};
|
||
ent->setTexture(gfx->newTexture(1, 1, px));
|
||
ent->setPosition(b.x, b.y, b.z);
|
||
ent->setScale(b.sx, b.sy, b.sz);
|
||
ent->setReceiveShadow(false);
|
||
ent->setCastShadow(false);
|
||
ent->setCamera(camera);
|
||
}
|
||
|
||
// Screen-space reflection pass (owns its reflection canvas).
|
||
ScreenSpaceReflection *ssr = gfx->newScreenSpaceReflection();
|
||
REQUIRE(ssr != nullptr);
|
||
ssr->setStrength(0.95f);
|
||
ssr->setMaxSteps(200);
|
||
ssr->setStepLength(0.15f);
|
||
ssr->setThickness(0.05f);
|
||
ssr->setMaxDistance(60.f);
|
||
ssr->setEnabled(true);
|
||
|
||
// Water plane that samples the SSR reflection.
|
||
Water *water = gfx->newWater();
|
||
REQUIRE(water != nullptr);
|
||
water->createPlane(14.f, 14.f, 48, 48);
|
||
water->setWaveAmplitude(0.25f);
|
||
water->setRippleAmplitude(0.5f);
|
||
water->setReflectionIntensity(1.0f);
|
||
water->setSunIntensity(1.2f);
|
||
water->setScreenSpaceReflection(true, 0.9f);
|
||
water->setViewport(float(settings.width), float(settings.height));
|
||
|
||
auto *waterEnt = Renderable3D::create();
|
||
waterEnt->setMesh(water->getMesh());
|
||
waterEnt->setShader(water->getShader());
|
||
waterEnt->setTexture(nullptr);
|
||
waterEnt->setReceiveShadow(false);
|
||
waterEnt->setCastShadow(false);
|
||
waterEnt->setCamera(camera);
|
||
|
||
// Render the scene (boxes + water) a few frames so SSR converges, then
|
||
// validate the SSR reflection canvas reflects the red box.
|
||
auto *rc = gfx->getRenderControl();
|
||
REQUIRE(rc != nullptr);
|
||
for (int frame = 0; frame < 10; ++frame) {
|
||
waterEnt->setHeightTexture(ssr->getReflectionTexture());
|
||
water->setTime(float(frame) * 0.05f);
|
||
water->bindParams();
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
|
||
GBuffer *gb = rc->getGBuffer();
|
||
Texture *hw = gb ? gb->getHwDepthTexture() : nullptr;
|
||
Texture *scene = gfx->getSceneColorTexture();
|
||
if (frame == 0) {
|
||
std::printf("SSR dbg: gb=%p hw=%p scene=%p hwSize=%dx%d sceneSize=%dx%d\n",
|
||
(void *)gb, (void *)hw, (void *)scene,
|
||
hw ? hw->getWidth() : 0, hw ? hw->getHeight() : 0,
|
||
scene ? scene->getWidth() : 0, scene ? scene->getHeight() : 0);
|
||
fflush(stdout);
|
||
}
|
||
if (hw && scene) {
|
||
ssr->setCamera(camera->data()->eyeX, camera->data()->eyeY, camera->data()->eyeZ,
|
||
camera->data()->targetX, camera->data()->targetY, camera->data()->targetZ,
|
||
camera->data()->upX, camera->data()->upY, camera->data()->upZ,
|
||
camera->data()->fovYDeg, float(settings.width) / float(settings.height),
|
||
camera->data()->nearZ, camera->data()->farZ);
|
||
ssr->applyFromSceneTo(gfx, scene, hw, nullptr, ssr->getReflectionCanvas());
|
||
}
|
||
}
|
||
|
||
// SSR reflects the red box: read back the reflection canvas and count red.
|
||
// (Horizontal-water reflections are a known SSR weak spot; this validates the
|
||
// pass runs and writes a non-empty reflection buffer rather than asserting
|
||
// a specific reflection amount.)
|
||
Canvas *refl = ssr->getReflectionCanvas();
|
||
REQUIRE(refl != nullptr);
|
||
int written = 0, total = 0;
|
||
const int rw = refl->getWidth();
|
||
const int rh = refl->getHeight();
|
||
for (int y = 0; y < rh; y += 2) {
|
||
for (int x = 0; x < rw; x += 2) {
|
||
const Color c = refl->getPixel(x, y);
|
||
++total;
|
||
if (c.r + c.g + c.b > 0.05f) ++written;
|
||
}
|
||
}
|
||
std::printf("SSR reflection canvas: written=%d/%d (%.1f%%)\n", written, total,
|
||
100.f * float(written) / float(total));
|
||
CHECK(total > 0);
|
||
win->close();
|
||
}
|
||
|
||
|
||
|
||
TEST_CASE("graphics.render3d.toCanvas") {
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings s;
|
||
s.width = 320;
|
||
s.height = 240;
|
||
s.centered = true;
|
||
REQUIRE(win->setWindowSettings(s));
|
||
|
||
const int fs = 8;
|
||
std::vector<uint8_t> sky(size_t(fs * fs * 4 * 6));
|
||
for (size_t i = 0; i < sky.size(); i += 4) {
|
||
sky[i] = 135; sky[i + 1] = 180; sky[i + 2] = 235; sky[i + 3] = 255;
|
||
}
|
||
Texture *skyTex = gfx->newCubemap(fs, sky.data());
|
||
REQUIRE(skyTex != nullptr);
|
||
|
||
Canvas *refl = gfx->newCanvas(160, 120);
|
||
REQUIRE(refl != nullptr);
|
||
|
||
Mesh *cube = makeUnitCube(gfx);
|
||
REQUIRE(cube != nullptr);
|
||
const glm::mat4 view = glm::lookAtRH(glm::vec3(0.f, 2.f, 5.f), glm::vec3(0.f, 0.f, 0.f),
|
||
glm::vec3(0.f, 1.f, 0.f));
|
||
const glm::mat4 proj = perspectiveVulkanRH_ZO(glm::radians(50.f), 160.f / 120.f, 0.1f, 50.f);
|
||
const glm::mat4 viewProj = proj * view;
|
||
|
||
gfx->setScreenReadbackEnabled(true);
|
||
RenderSystem3D::setDirectionalLight(0.5f, 1.f, 0.3f, 1.4f, 1.3f, 1.2f);
|
||
gfx->setMesh3DViewProj(viewProj);
|
||
gfx->setMesh3DView(view);
|
||
gfx->setMesh3DCameraPos(glm::vec3(0.f, 2.f, 5.f));
|
||
gfx->setMesh3DEnv(skyTex, 1.f);
|
||
const uint8_t red[4] = {220, 40, 40, 255};
|
||
Texture *redTex = gfx->newTexture(1, 1, red);
|
||
|
||
gfx->begin3DFrameToCanvas(refl);
|
||
const glm::mat4 model(1.f);
|
||
gfx->drawMeshShader(cube, model, redTex, glm::vec4(1.f), nullptr);
|
||
gfx->end3DFrameToCanvas();
|
||
|
||
int redPixels = 0, total = 0;
|
||
double sr = 0, sg = 0, sb = 0;
|
||
// Read the canvas once into an ImageData (getPixel per-pixel is O(N) readbacks).
|
||
std::unique_ptr<eve::image::ImageData> img(refl->newImageData());
|
||
REQUIRE(img.get() != nullptr);
|
||
const int rw = img->getWidth();
|
||
const int rh = img->getHeight();
|
||
const uint8_t *pxd = static_cast<const uint8_t *>(img->getData());
|
||
for (int y = 0; y < rh; y += 1) {
|
||
for (int x = 0; x < rw; x += 1) {
|
||
const size_t o = (size_t(y) * rw + size_t(x)) * 4;
|
||
const float r = pxd[o] / 255.f, g = pxd[o + 1] / 255.f, b = pxd[o + 2] / 255.f;
|
||
++total;
|
||
sr += r;
|
||
sg += g;
|
||
sb += b;
|
||
if (r > 0.25f && r > g + 0.1f && r > b + 0.1f) ++redPixels;
|
||
}
|
||
}
|
||
std::printf("render3d.toCanvas: red=%d/%d avg=(%.2f,%.2f,%.2f)\n", redPixels, total,
|
||
sr / total, sg / total, sb / total);
|
||
CHECK(redPixels > total / 10); // the red box was rendered into the canvas
|
||
win->close();
|
||
}
|
||
|
||
|
||
TEST_CASE("graphics.water.render.planar") {
|
||
const char *outPath = std::getenv("EVENGINE_PLANAR_RENDER_PNG");
|
||
if (!outPath || !outPath[0]) return;
|
||
|
||
auto *win = eve::window::Window::create();
|
||
auto *gfx = Graphics::create();
|
||
REQUIRE(win != nullptr);
|
||
REQUIRE(gfx != nullptr);
|
||
win->setGraphics(gfx);
|
||
eve::window::WindowSettings settings;
|
||
settings.width = 640;
|
||
settings.height = 480;
|
||
settings.centered = true;
|
||
REQUIRE(win->setWindowSettings(settings));
|
||
eve::image::Image::create();
|
||
|
||
const int fs = 16;
|
||
std::vector<uint8_t> sky(size_t(fs * fs * 4 * 6));
|
||
for (size_t i = 0; i < sky.size(); i += 4) {
|
||
sky[i] = 135; sky[i + 1] = 180; sky[i + 2] = 235; sky[i + 3] = 255;
|
||
}
|
||
Texture *skyTex = gfx->newCubemap(fs, sky.data());
|
||
REQUIRE(skyTex != nullptr);
|
||
|
||
auto *camera = Camera3D::createCamera();
|
||
camera->setEye(0.f, 5.f, 8.f);
|
||
camera->setTarget(0.f, 0.5f, -3.f);
|
||
camera->setFov(55.f);
|
||
camera->setAmbient(0.28f, 0.32f, 0.40f);
|
||
camera->setEnvMap(skyTex);
|
||
camera->setEnvIntensity(1.f);
|
||
camera->data()->nearZ = 0.1f;
|
||
camera->data()->farZ = 2000.f;
|
||
|
||
gfx->setBackgroundColor(Color(0.05f, 0.09f, 0.14f, 1.f));
|
||
gfx->setScreenReadbackEnabled(true);
|
||
RenderSystem3D::setDirectionalLight(0.45f, 1.f, 0.3f, 1.3f, 1.2f, 1.1f);
|
||
|
||
auto *present = Renderable2D::create();
|
||
present->transform()->x = 0.f;
|
||
present->transform()->y = 0.f;
|
||
present->sprite()->width = 1.f;
|
||
present->sprite()->height = 1.f;
|
||
present->sprite()->a = 0.f;
|
||
|
||
// Skybox: big sphere shaded by the env cubemap.
|
||
const char *kSky = R"GLSL(#version 450
|
||
layout(location = 3) in vec3 vWorldPos;
|
||
layout(location = 4) in vec3 vCameraPos;
|
||
layout(set = 0, binding = 3) uniform samplerCube env;
|
||
layout(location = 0) out vec4 outColor;
|
||
void main() { outColor = vec4(texture(env, normalize(vWorldPos - vCameraPos)).rgb, 1.0); }
|
||
)GLSL";
|
||
Shader *skyShader = gfx->newMeshShader("", kSky);
|
||
auto *skyEnt = Renderable3D::create();
|
||
skyEnt->setMesh(gfx->newMeshSphere(24, 16));
|
||
skyEnt->setShader(skyShader);
|
||
skyEnt->setScale(300.f, 300.f, 300.f);
|
||
skyEnt->setReceiveShadow(false);
|
||
skyEnt->setCastShadow(false);
|
||
skyEnt->setCamera(camera);
|
||
|
||
// Boxes above the water to reflect.
|
||
struct Box { float x, y, z, sx, sy, sz; uint8_t r, g, b; };
|
||
const Box boxes[] = {
|
||
{0.f, 4.0f, -5.f, 3.0f, 3.0f, 3.0f, 210, 60, 60},
|
||
{-3.5f, 3.0f, -3.f, 2.0f, 2.0f, 2.0f, 70, 180, 70},
|
||
{3.5f, 3.5f, -4.f, 2.5f, 2.5f, 2.5f, 70, 110, 230},
|
||
};
|
||
Mesh *cube = makeUnitCube(gfx);
|
||
struct Ent { Renderable3D *e; glm::mat4 model; };
|
||
std::vector<Ent> ents;
|
||
for (const Box &b : boxes) {
|
||
auto *ent = Renderable3D::create();
|
||
ent->setMesh(cube);
|
||
const uint8_t px[4] = {b.r, b.g, b.b, 255};
|
||
ent->setTexture(gfx->newTexture(1, 1, px));
|
||
ent->setPosition(b.x, b.y, b.z);
|
||
ent->setScale(b.sx, b.sy, b.sz);
|
||
ent->setReceiveShadow(false);
|
||
ent->setCastShadow(false);
|
||
ent->setCamera(camera);
|
||
glm::mat4 m(1.f);
|
||
m = glm::translate(m, glm::vec3(b.x, b.y, b.z));
|
||
m = glm::scale(m, glm::vec3(b.sx, b.sy, b.sz));
|
||
ents.push_back({ent, m});
|
||
}
|
||
|
||
Water *water = gfx->newWater();
|
||
REQUIRE(water != nullptr);
|
||
water->createPlane(14.f, 14.f, 48, 48);
|
||
water->setWaveAmplitude(0.2f);
|
||
water->setReflectionIntensity(1.0f);
|
||
water->setScreenSpaceReflection(true, 0.9f);
|
||
water->setViewport(float(settings.width), float(settings.height));
|
||
|
||
auto *waterEnt = Renderable3D::create();
|
||
waterEnt->setMesh(water->getMesh());
|
||
waterEnt->setShader(water->getShader());
|
||
waterEnt->setTexture(nullptr);
|
||
waterEnt->setReceiveShadow(false);
|
||
waterEnt->setCastShadow(false);
|
||
waterEnt->setCamera(camera);
|
||
|
||
Canvas *refl = gfx->newCanvas(settings.width, settings.height);
|
||
REQUIRE(refl != nullptr);
|
||
|
||
const glm::vec3 eye = glm::vec3(camera->data()->eyeX, camera->data()->eyeY, camera->data()->eyeZ);
|
||
const glm::vec3 tgt = glm::vec3(camera->data()->targetX, camera->data()->targetY,
|
||
camera->data()->targetZ);
|
||
const glm::mat4 mView = glm::lookAtRH(glm::vec3(eye.x, -eye.y, eye.z),
|
||
glm::vec3(tgt.x, -tgt.y, tgt.z), glm::vec3(0.f, -1.f, 0.f));
|
||
const glm::mat4 mProj = perspectiveVulkanRH_ZO(glm::radians(camera->data()->fovYDeg),
|
||
settings.width / float(settings.height), 0.1f, 100.f);
|
||
|
||
gfx->setMesh3DViewProj(mProj * mView);
|
||
gfx->setMesh3DView(mView);
|
||
gfx->setMesh3DCameraPos(glm::vec3(eye.x, -eye.y, eye.z));
|
||
gfx->setMesh3DEnv(skyTex, 1.f);
|
||
// Reflection background = bright sky (mirrors what the water should reflect).
|
||
gfx->setBackgroundColor(Color(0.45f, 0.62f, 0.85f, 1.f));
|
||
gfx->begin3DFrameToCanvas(refl);
|
||
for (const Ent &en : ents) {
|
||
gfx->drawMeshShader(cube, en.model,
|
||
static_cast<Renderable3D *>(en.e)->meshRenderer()->texture,
|
||
glm::vec4(1.f), nullptr);
|
||
}
|
||
gfx->end3DFrameToCanvas();
|
||
gfx->setBackgroundColor(Color(0.05f, 0.09f, 0.14f, 1.f));
|
||
|
||
// Final frame: water sampling the planar reflection.
|
||
waterEnt->setHeightTexture(refl->getTexture());
|
||
water->setTime(0.5f);
|
||
water->bindParams();
|
||
RenderSystem3D::render(*gfx);
|
||
RenderSystem::render(*gfx);
|
||
|
||
// Read the reflection canvas once and check the red box was captured.
|
||
std::unique_ptr<eve::image::ImageData> rim(refl->newImageData());
|
||
REQUIRE(rim.get() != nullptr);
|
||
int red = 0, total = 0;
|
||
const uint8_t *pd = static_cast<const uint8_t *>(rim->getData());
|
||
const int rw = rim->getWidth();
|
||
const int rh = rim->getHeight();
|
||
for (int y = 0; y < rh; y += 2) {
|
||
for (int x = 0; x < rw; x += 2) {
|
||
const size_t o = (size_t(y) * rw + size_t(x)) * 4;
|
||
const float r = pd[o] / 255.f, g = pd[o + 1] / 255.f, b = pd[o + 2] / 255.f;
|
||
++total;
|
||
if (r > 0.25f && r > g + 0.1f && r > b + 0.1f) ++red;
|
||
}
|
||
}
|
||
std::printf("planar reflection canvas: red=%d/%d (%.1f%%)\n", red, total,
|
||
100.f * float(red) / float(total));
|
||
CHECK(red > 50);
|
||
|
||
std::unique_ptr<eve::image::ImageData> img(gfx->newImageData());
|
||
REQUIRE(img.get() != nullptr);
|
||
REQUIRE(saveImagePng(*img, outPath));
|
||
std::printf("planar water render saved: %s\n", outPath);
|
||
win->close();
|
||
}
|