Render a proper skybox: a huge sphere centered on the camera (raised far plane so it isn't clipped) shaded purely by the env cubemap, so the sky is clearly visible behind the water plane. Use a vertical-gradient cubemap (pale horizon -> deep blue zenith) instead of a flat blue. The frame now reads as bright sky (top) over a sky-reflecting water plane (bottom), with no dark background.
2292 lines
83 KiB
C++
2292 lines
83 KiB
C++
#include "zeroerr/assert.h"
|
||
#include "zeroerr/unittest.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/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/RenderSystem.h"
|
||
#include "graphics/RenderSystem3D.h"
|
||
#include "window/Window.h"
|
||
#include "image/Image.h"
|
||
#include "filesystem/FileData.h"
|
||
#include "RenderImageAudit.h"
|
||
|
||
#include <SDL2/SDL.h>
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstdlib>
|
||
#include <cstring>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <memory>
|
||
#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.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.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.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.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.5f);
|
||
water->setRippleAmplitude(0.9f);
|
||
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();
|
||
}
|