#include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include #include "data/ByteData.h" #include "filesystem/FileData.h" #include "graphics/AmbientOcclusion.h" #include "graphics/AntiAliasing.h" #include "graphics/Canvas.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/TerrainFile.h" #include "procgen/heightmap/TerrainPipeline.h" #include "procgen/heightmap/TerrainStreaming.h" #include "procgen/algorithms/CastleMesh.h" #include "procgen/texture/TextureRecipe.h" #include "water_scene_fixture.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/DrawItem2D.h" #include "graphics/Font.h" #include "graphics/GBuffer.h" #include "graphics/GlobalIllumination.h" #include "graphics/Graphics.h" #include "graphics/Grass.h" #include "graphics/Light.h" #include "graphics/Material.h" #include "graphics/Mesh.h" #include "graphics/Outline.h" #include "graphics/Quad.h" #include "graphics/RenderControl.h" #include "graphics/RenderSystem.h" #include "graphics/RenderSystem3D.h" #include "graphics/ScreenSpaceReflection.h" #include "graphics/Shader.h" #include "graphics/Texture.h" #include "graphics/Volumetric.h" #include "graphics/Water.h" #include "graphics/Waterfall.h" #include "image/Image.h" #include "image/ImageData.h" #include "map/TileLayer.h" #include "procgen/GeneratorRegistry.h" #include "procgen/JsonExport.h" #include "procgen/MeshBuild.h" #include "procgen/Procgen.h" #include "procgen/Semantic.h" #include "procgen/algorithms/LinearStructure.h" #include "procgen/algorithms/MarchingCubes.h" #include "procgen/algorithms/HexTerrain.h" #include "procgen/texture/ColorRamp.h" #include "procgen/texture/NoiseField.h" #include "procgen/texture/PbrMaterial.h" #include "procgen/texture/TextureRecipe.h" #include "window/Window.h" #include #include "RenderImageAudit.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form. using eve::graphics::Color; using namespace eve::procgen; using namespace eve::graphics; namespace { class TrackingTerrainSource final : public ITerrainArchiveSource { public: explicit TrackingTerrainSource(std::vector bytes) : bytes_(std::move(bytes)) {} std::uint64_t size() const noexcept override { return bytes_.size(); } eve::Result> read(std::uint64_t offset, std::size_t length) const override { if (offset > bytes_.size() || length > bytes_.size() - std::size_t(offset)) return eve::Result>::failure(eve::Diagnostic::error( eve::DiagnosticCode::InvalidArgument, "tracking source out of bounds")); ++readCount; bytesRead += length; return eve::Result>::success(std::vector( bytes_.begin() + std::ptrdiff_t(offset), bytes_.begin() + std::ptrdiff_t(offset + length))); } mutable std::size_t readCount = 0; mutable std::size_t bytesRead = 0; private: std::vector bytes_; }; } // namespace TEST_CASE("procgen.hexTerrain.biomesRiversCliffsDeterministic") { Params p; p.setInt("width", 38); p.setInt("height", 28); p.setInt("seed", 20260825); p.setInt("riverCount", 8); p.setFloat("radius", 0.62f); p.setFloat("heightScale", 3.8f); MeshBuild a, b; std::string error; REQUIRE(generateHexTerrainMesh(p, a, error)); REQUIRE(generateHexTerrainMesh(p, b, error)); CHECK_EQ(a.positions(), b.positions()); CHECK_EQ(a.indices(), b.indices()); CHECK_EQ(a.getMeta("algorithm", ""), std::string("mesh.hexterrain")); CHECK_EQ(a.getMeta("shoreGeometry", ""), std::string("edge-bands")); CHECK_EQ(a.getMeta("hydrology", ""), std::string("drainage-rivers+basin-lakes+confluences")); CHECK_EQ(a.getMeta("riverGeometry", ""), std::string("seeded-quadratic-ribbons")); CHECK_EQ(a.getMeta("cliffGeometry", ""), std::string("seeded-segmented-rock-walls")); CHECK_EQ(a.getMeta("mountainGeometry", ""), std::string("seeded-offset-three-ring-peaks")); CHECK(std::stoi(a.getMeta("cells.deepOcean", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.ocean", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.coast", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.grassland", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.hills", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.mountain", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.forest", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.swamp", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.rainforest", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.ice", "0")) > 0); CHECK(std::stoi(a.getMeta("cells.river", "0")) > 0); CHECK(std::stoi(a.getMeta("edges.cliff", "0")) > 0); CHECK(a.getVertexCount() >= 38 * 28 * 7); CHECK(a.getIndexCount() >= 38 * 28 * 18); bool hasOcean = false, hasLand = false, hasRiver = false, hasCliff = false; for (int i = 0; i < a.getVertexCount(); ++i) { const int primary = int(std::floor(a.getUvU(i))); const int secondary = int(std::floor(a.getUvV(i))); hasOcean = hasOcean || primary <= 1; hasLand = hasLand || (primary >= 3 && primary <= 9); hasRiver = hasRiver || secondary == 11; hasCliff = hasCliff || primary == 10; } CHECK(hasOcean); CHECK(hasLand); CHECK(hasRiver); CHECK(hasCliff); } TEST_CASE("procgen.params.booleanRoundTrip") { Params p; CHECK(p.getBool("decorations", true)); p.setBool("decorations", false); CHECK(!p.getBool("decorations", true)); // Text is a distinct Value kind; getters never parse or stringify it. p.setString("textBool", "false"); CHECK(p.getBool("textBool", true)); CHECK_EQ(p.getInt("textBool", -1), -1); CHECK_EQ(p.getString("decorations", "missing"), "missing"); } namespace { struct ParamsLease { ProcgenParamsHandleRef handle{}; explicit ParamsLease(ProcgenParamsHandleRef value) : handle(value) {} ~ParamsLease() { if (!handle.isValid()) return; auto result = Procgen::release(handle); result.ignore("procgen test params cleanup"); } ParamsLease(const ParamsLease &) = delete; ParamsLease &operator=(const ParamsLease &) = delete; ParamsLease(ParamsLease &&other) noexcept : handle(other.handle) { other.handle = {}; } [[nodiscard]] eve::script::Borrowed view() const noexcept { return Procgen::resolve(handle); } }; ParamsLease requireParams(const Params &source) { auto result = Procgen::newParamsHandle(); REQUIRE(result.ok()); ParamsLease lease{std::move(result).takeValue()}; auto view = lease.view(); REQUIRE(view.isBound()); *view = source; return lease; } struct GridLease { ProcgenGridHandleRef handle{}; explicit GridLease(ProcgenGridHandleRef value) : handle(value) {} ~GridLease() { if (!handle.isValid()) return; auto result = Procgen::release(handle); result.ignore("procgen test grid cleanup"); } GridLease(const GridLease &) = delete; GridLease &operator=(const GridLease &) = delete; GridLease(GridLease &&other) noexcept : handle(other.handle) { other.handle = {}; } [[nodiscard]] eve::script::Borrowed view() const noexcept { return Procgen::resolve(handle); } }; GridLease requireGrid(Procgen &proc, const std::string &algorithm, ProcgenParamsHandleRef params) { auto result = proc.generateHandle(algorithm, params); REQUIRE(result.ok()); return GridLease{std::move(result).takeValue()}; } struct MeshLease { Procgen *owner = nullptr; ProcgenMeshBuildHandleRef handle{}; MeshLease(Procgen &proc, ProcgenMeshBuildHandleRef value) : owner(&proc), handle(value) {} ~MeshLease() { if (!owner || !handle.isValid()) return; auto result = owner->releaseMeshBuild(handle); result.ignore("procgen test mesh cleanup"); } MeshLease(const MeshLease &) = delete; MeshLease &operator=(const MeshLease &) = delete; MeshLease(MeshLease &&other) noexcept : owner(other.owner), handle(other.handle) { other.owner = nullptr; other.handle = {}; } [[nodiscard]] eve::script::Borrowed view() const noexcept { return owner ? owner->resolveMeshBuild(handle) : eve::script::Borrowed(); } }; MeshLease requireMesh(Procgen &proc, const std::string &recipe, ProcgenParamsHandleRef params) { auto result = proc.buildMeshHandle(recipe, params); REQUIRE(result.ok()); return MeshLease(proc, std::move(result).takeValue()); } struct PbrLease { Procgen *owner = nullptr; ProcgenPbrMaterialHandleRef handle{}; PbrLease(Procgen &proc, ProcgenPbrMaterialHandleRef value) : owner(&proc), handle(value) {} ~PbrLease() { if (!owner || !handle.isValid()) return; auto result = owner->releasePbrMaterial(handle); result.ignore("procgen test PBR cleanup"); } PbrLease(const PbrLease &) = delete; PbrLease &operator=(const PbrLease &) = delete; PbrLease(PbrLease &&other) noexcept : owner(other.owner), handle(other.handle) { other.owner = nullptr; other.handle = {}; } [[nodiscard]] eve::script::Borrowed view() const noexcept { return owner ? owner->resolvePbrMaterial(handle) : eve::script::Borrowed(); } }; 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> &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> 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> 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 meshWindingAgreesWithNormals(const MeshBuild &m, float minFraction = 0.9f) { int ok = 0, counted = 0; for (int t = 0; t + 2 < m.getIndexCount(); t += 3) { const int i0 = m.getIndex(t), i1 = m.getIndex(t + 1), i2 = m.getIndex(t + 2); const float ax = m.getPositionX(i1) - m.getPositionX(i0); const float ay = m.getPositionY(i1) - m.getPositionY(i0); const float az = m.getPositionZ(i1) - m.getPositionZ(i0); const float bx = m.getPositionX(i2) - m.getPositionX(i0); const float by = m.getPositionY(i2) - m.getPositionY(i0); const float bz = m.getPositionZ(i2) - m.getPositionZ(i0); const float gx = ay * bz - az * by, gy = az * bx - ax * bz, gz = ax * by - ay * bx; const float gLen = std::sqrt(gx * gx + gy * gy + gz * gz); if (gLen < 1e-8f) continue; const float nx = (m.getNormalX(i0) + m.getNormalX(i1) + m.getNormalX(i2)) / 3.f; const float ny = (m.getNormalY(i0) + m.getNormalY(i1) + m.getNormalY(i2)) / 3.f; const float nz = (m.getNormalZ(i0) + m.getNormalZ(i1) + m.getNormalZ(i2)) / 3.f; ++counted; if (gx * nx + gy * ny + gz * nz > 0.f) ++ok; } return counted > 0 && float(ok) / float(counted) >= minFraction; } 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 seen(size_t(w * h), 0); std::vector 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.registry.schemas.describe_every_grid_generator") { auto& registry = GeneratorRegistry::instance(); registry.registerBuiltins(); const auto ids = registry.list(); REQUIRE(!ids.empty()); for (const std::string& id : ids) { const GeneratorDescriptor* schema = registry.descriptor(id); REQUIRE(schema != nullptr); CHECK_EQ(schema->id, id); CHECK(!schema->displayName.empty()); CHECK(!schema->category.empty()); CHECK(!schema->params.empty()); for (const ParamDescriptor& param : schema->params) { CHECK(!param.key.empty()); CHECK(!param.displayName.empty()); if (param.hasMinimum && param.hasMaximum) CHECK_LE(param.minimum, param.maximum); if (param.kind == ParamKind::Choice) { CHECK(!param.choices.empty()); CHECK(std::find(param.choices.begin(), param.choices.end(), param.defaultValue) != param.choices.end()); } } } Params params; REQUIRE(registry.applyDefaults("cave.cellular", params)); CHECK(params.has("fill")); CHECK_LT(std::abs(params.getFloat("fill", 0.f) - 0.45f), 0.0001f); params.setInt("width", 96); params.setInt("height", 48); params.setInt("seed", 7); CHECK_EQ(params.getWidth(), 96); CHECK_EQ(params.getHeight(), 48); CHECK_EQ(params.getSeed(), 7u); } 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> 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); p.setString("branchAlgorithm", "weberPenn"); p.setString("leafMode", "confetti"); CHECK(!MeshRecipeRegistry::instance().generate("mesh.tree", p, mesh, err)); CHECK(err.find("leafMode") != std::string::npos); p.setString("leafMode", "clusters"); CHECK(MeshRecipeRegistry::instance().generate("mesh.tree", p, mesh, err)); } 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); 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); // Cluster knobs are env-overridable so the leaf look can be compared across // runs without rebuilding; unset variables leave the recipe defaults. const auto envFloat = [](const char *name, float fallback) { const char *raw = std::getenv(name); return raw && raw[0] ? std::stof(raw) : fallback; }; const auto envInt = [](const char *name, int fallback) { const char *raw = std::getenv(name); return raw && raw[0] ? std::stoi(raw) : fallback; }; params.setFloat("clusterSize", envFloat("EVENGINE_TREE_CLUSTER_SIZE", 0.30f)); params.setFloat("clusterLeafScale", envFloat("EVENGINE_TREE_CLUSTER_LEAF_SCALE", 0.85f)); params.setFloat("clusterSpacing", envFloat("EVENGINE_TREE_CLUSTER_SPACING", 0.80f)); params.setFloat("clusterSeparation", envFloat("EVENGINE_TREE_CLUSTER_SEPARATION", 0.55f)); params.setInt("clusterPlanes", envInt("EVENGINE_TREE_CLUSTER_PLANES", 10)); params.setInt("clusterCaps", envInt("EVENGINE_TREE_CLUSTER_CAPS", 2)); params.setInt("clusterLeaves", envInt("EVENGINE_TREE_CLUSTER_LEAVES", 28)); params.setInt("clusterLimit", envInt("EVENGINE_TREE_CLUSTER_LIMIT", 120)); params.setFloat("leafSize", envFloat("EVENGINE_TREE_LEAF_SIZE", 6.2f * 0.075f)); Procgen generator; auto treeParams = requireParams(params); auto treeMesh = generator.generateMeshBorrowed("mesh.tree", treeParams.handle, gfx); REQUIRE(treeMesh.isBound()); // Same parameters, CPU side: the uploaded Mesh keeps no metadata, but the // dump is most useful with the cost and cluster count next to it. { MeshBuild stats; std::string statsError; if (MeshRecipeRegistry::instance().generate("mesh.tree", params, stats, statsError)) { std::printf("tree mesh: %d vertices, %d triangles, clusters=%s\n", stats.getVertexCount(), stats.getIndexCount() / 3, stats.getMeta("clusters", "n/a").c_str()); } } // 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.get()); 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 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); 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", 5); params.setInt("radialSegments", 12); params.setFloat("leafDensity", 0.8f); params.setFloat("leafSize", 0.32f); params.setInt("twigs", 6); Procgen generator; auto bushParams = requireParams(params); auto bushMesh = generator.generateMeshBorrowed("mesh.bush", bushParams.handle, gfx); REQUIRE(bushMesh.isBound()); // A tiny two-tone foliage atlas (dark base / light highlight), like the tree test. const uint8_t atlasPixels[] = { 104, 67, 38, 255, 84, 132, 60, 255, }; Texture *atlas = gfx->newTexture(2, 1, atlasPixels); REQUIRE(atlas != nullptr); auto *bush = Renderable3D::create(); bush->setMesh(bushMesh.get()); 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 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 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 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::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 terrainHashes; static constexpr std::array 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); const TerrainSampler sampler = TerrainSampler::fromParams(params); auto terrain = std::make_unique(Heightmap::generate( sampler, params.getWidth(), params.getHeight())); 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 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::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::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::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::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::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::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 combined( diagnosticProcgen.generateTerrainErosionMap(&erosionMap, 0.f)); std::unique_ptr wearImage( diagnosticProcgen.generateTerrainWearMap(&erosionMap, 0.f)); std::unique_ptr 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::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 crossSectionRelief; std::vector> 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(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::infinity(), 1.f); const HydrologyMap fineBefore = TerrainPipeline::buildHydrology( fine, 0.015f, -std::numeric_limits::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 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 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 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 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 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 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 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.randomAccessReadsOnlyMetadataAndResidentChunks") { Heightmap heightmap(256, 256); for (int y = 0; y < 256; ++y) for (int x = 0; x < 256; ++x) heightmap.setHeight(x, y, float((x * 17 + y * 31) % 251) / 250.0F); HydrologyMap hydrology = TerrainPipeline::buildHydrology(heightmap, 0.45F, 0.5F); const ClimateMap climate = TerrainPipeline::buildClimate(heightmap, hydrology, 0.5F, 0.5F); std::vector archive; std::string error; REQUIRE(TerrainAsset::bake(heightmap, hydrology, climate, 16, archive, &error)); auto source = std::make_shared(std::move(archive)); TerrainStreamingCache stream; auto opened = stream.openSource(source); REQUIRE(opened.ok()); CHECK_EQ(source->readCount, std::size_t(2)); CHECK(source->bytesRead < source->size() / 4); const std::size_t metadataBytes = source->bytesRead; const auto loaded = stream.streamAround(128, 128, 1, 2, &error); CHECK_EQ(loaded.loaded, 2); CHECK_EQ(loaded.resident, 2); CHECK_EQ(source->readCount, std::size_t(4)); CHECK(source->bytesRead > metadataBytes); CHECK(source->bytesRead < source->size() / 2); const std::size_t stableReads = source->readCount; CHECK_EQ(stream.streamAround(128, 128, 1, 0, &error).loaded, 3); CHECK_EQ(source->readCount, stableReads + 3); CHECK_EQ(stream.streamAround(128, 128, 1, 0, &error).loaded, 0); CHECK_EQ(source->readCount, stableReads + 3); } 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 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> 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; auto heightmapResult = procgen.newHeightmapHandle(16, 16); REQUIRE(heightmapResult.ok()); const ProcgenHeightmapHandleRef heightmapHandle = std::move(heightmapResult).takeValue(); auto heightmapView = procgen.resolveHeightmap(heightmapHandle); REQUIRE(heightmapView.isBound()); Heightmap *heightmap = heightmapView.get(); 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 before = heightmap->data(); CHECK(procgen.erodeTerrainThermal(heightmap, 8, 0.02f, 0.3f)); CHECK(procgen.erodeTerrainHydraulic(heightmap, 8, 0.01f, 0.1f, 2.f, 0.15f, 0.1f)); CHECK(procgen.erodeTerrainFluvial(heightmap, 3, 0.03f, 0.006f, 0.08f, 1.5f)); CHECK(procgen.erodeTerrainFluvialAdvanced( heightmap, 2, 0.03f, 0.006f, 0.08f, 1.5f, 0.015f)); CHECK(procgen.erodeTerrainFluvialScaled( heightmap, 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 2.f)); std::unique_ptr diagnostics(procgen.erodeTerrainFluvialDetailed( heightmap, 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, 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 0.f) == nullptr); CHECK(!procgen.erodeTerrainFluvialScaled( heightmap, 2, 0.03f, 0.006f, 0.08f, 3.f, 0.015f, 0.f)); CHECK(heightmap->data() != before); std::unique_ptr layers(procgen.analyzeTerrain(heightmap, 4.f, 0.2f, 0.7f)); REQUIRE(layers.get() != nullptr); std::unique_ptr scaledLayers( procgen.analyzeTerrainScaled(heightmap, 4.f, 0.2f, 0.7f, 2.f)); REQUIRE(scaledLayers.get() != nullptr); CHECK(procgen.analyzeTerrainScaled(heightmap, 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 archive( procgen.bakeTerrainAsset(heightmap, layers.get(), 8)); REQUIRE(archive.get() != nullptr); TerrainAsset opened; CHECK(opened.open(static_cast(archive->getData()), archive->getSize())); CHECK_EQ(opened.chunks().size(), size_t(4)); CHECK(!procgen.erodeTerrainThermal(nullptr, 1, 0.1f, 0.1f)); auto heightmapRelease = procgen.releaseHeightmap(heightmapHandle); heightmapRelease.ignore("terrain module test cleanup"); } 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 splat(procgen.generateTerrainSplatMap(&left)); std::unique_ptr 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(splat->getData()); const auto *rightRgba = static_cast(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); std::unique_ptr albedo(procgen.generateTerrainAlbedoMap(&left)); REQUIRE(albedo.get() != nullptr); CHECK_EQ(albedo->getWidth(), 17); CHECK_EQ(albedo->getHeight(), 17); const auto *albedoRgba = static_cast(albedo->getData()); for (int vertex = 0; vertex < left.getBaseVertexCount(); ++vertex) CHECK_EQ(albedoRgba[vertex * 4 + 3], uint8_t(255)); CHECK(procgen.generateTerrainAlbedoMap(nullptr) == nullptr); 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 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 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); auto params = requireParams(p); auto gridLease = requireGrid(*mod, "wfc.simple", params.handle); auto grid = gridLease.view(); REQUIRE(grid.isBound()); CHECK(borderIsWall(*grid)); CHECK_EQ(grid->getWidth(), 16); CHECK_EQ(grid->getHeight(), 12); Params bad; bad.setSeed(1); bad.setSize(8, 8); bad.setString("preset", "invalid"); auto badParams = requireParams(bad); auto failed = mod->generateHandle("wfc.simple", badParams.handle); CHECK(!failed.ok()); const eve::Diagnostic *diagnostic = failed.error(); REQUIRE(diagnostic != nullptr); CHECK_EQ(diagnostic->code(), eve::DiagnosticCode::Failed); } 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); 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); auto planetParams = requireParams(params); auto planetMesh = procgen->generateMeshBorrowed("mesh.hexplanet", planetParams.handle, gfx); REQUIRE(planetMesh.isBound()); 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.get()); 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); } [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); std::unique_ptr image(gfx->newImageData()); REQUIRE(static_cast(image)); std::unique_ptr png( image->encode(medialoader::FormatHandler::ENCODED_PNG, "hex_planet.png", false)); REQUIRE(static_cast(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(png->getData()), static_cast(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); 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 pos, nrm, uv; std::vector 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 img(gfx->newImageData()); REQUIRE(img.get() != nullptr); const uint8_t *px = static_cast(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); 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); auto towerParams = requireParams(params); auto towerMesh = procgen->generateMeshBorrowed("mesh.skyscraper", towerParams.handle, gfx); REQUIRE(towerMesh.isBound()); // 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.get()); 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); } [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); std::unique_ptr image(gfx->newImageData()); REQUIRE(static_cast(image)); std::unique_ptr png( image->encode(medialoader::FormatHandler::ENCODED_PNG, "skyscraper.png", false)); REQUIRE(static_cast(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(png->getData()), static_cast(png->getSize())); REQUIRE(output.good()); output.close(); std::printf("skyscraper render saved: %s\n", outPath.string().c_str()); win->close(); } TEST_CASE("procgen.render.castlePng") { auto *win = eve::window::Window::create(); auto *gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 900; settings.height = 700; settings.centered = true; REQUIRE(win->setWindowSettings(settings)); Procgen *procgen = Procgen::create(); Params params; params.setSeed(20260826); params.setFloat("width", 48.f); params.setFloat("depth", 40.f); params.setInt("rings", 2); params.setInt("keepFloors", 4); params.setInt("detail", 2); params.setFloat("towerSpacing", 15.f); auto castleParams = requireParams(params); auto castleBuild = requireMesh(*procgen, "mesh.castle", castleParams.handle); auto castleView = castleBuild.view(); REQUIRE(castleView.isBound()); const uint8_t stone[16] = { 126, 116, 98, 255, 150, 139, 116, 255, 105, 96, 82, 255, 169, 157, 132, 255, }; Texture *stoneTexture = gfx->newTexture(2, 2, stone); REQUIRE(stoneTexture != nullptr); // Exercise the graph-style group filter + CPU-to-GPU upload path. Each // semantic component can now have independent material/collision policy. const float groupTint[][3] = { {.74f,.69f,.58f}, {.82f,.76f,.64f}, {.66f,.61f,.52f}, {.58f,.53f,.46f}, {.48f,.43f,.36f}, {.71f,.65f,.54f}, {.60f,.55f,.45f}, }; std::vector castleParts; for (int group = 0; group < castleView->getGroupCount(); ++group) { auto part = castleView->copyGroup(group); if (!part) continue; auto gpuPart = procgen->uploadMeshBorrowed(*part, *gfx); REQUIRE(gpuPart.isBound()); auto *renderable = eve::graphics::Renderable3D::create(); renderable->setMesh(gpuPart.get()); renderable->setTexture(stoneTexture); const int tint = std::min(group, 6); renderable->setTint(groupTint[tint][0], groupTint[tint][1], groupTint[tint][2], 1.f); renderable->setMetallic(0.01f); renderable->setRoughness(0.94f); renderable->setCastShadow(true); renderable->setReceiveShadow(true); castleParts.push_back(renderable); } CHECK_EQ(castleParts.size(), size_t(7)); auto *camera = eve::graphics::Camera3D::createCamera(); // View from the south-east so the gate opening and both wall stair systems // are part of the visual contract rather than hidden behind the north wall. camera->setEye(42.f, 32.f, -64.f); camera->setTarget(0.f, 5.5f, 0.f); camera->setFov(43.f); camera->setAmbient(0.19f, 0.20f, 0.23f); auto *present = eve::graphics::Renderable2D::create(); present->sprite()->width = 1.f; present->sprite()->height = 1.f; present->sprite()->a = 0.f; gfx->setBackgroundColor(Color(0.09f, 0.13f, 0.19f, 1.f)); gfx->setScreenReadbackEnabled(true); RenderSystem3D::setDirectionalLight(0.6f, 0.95f, 0.8f, 1.35f, 1.18f, 0.96f); for (int frame = 0; frame < 8; ++frame) { RenderSystem3D::render(*gfx); RenderSystem::render(*gfx); } [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); std::unique_ptr image(gfx->newImageData()); REQUIRE(static_cast(image)); RenderAuditConfig auditCfg; auditCfg.scene = "procgen"; auditCfg.phase = "castle"; auditCfg.width = image->getWidth(); auditCfg.height = image->getHeight(); const RenderAuditResult audit = auditImage(*image, auditCfg, {0.09f, 0.13f, 0.19f}, 2); CHECK(!audit.empty); CHECK(audit.occupancy > 0.08f); CHECK(audit.meanLuma > 12.f); const std::filesystem::path outDir = std::filesystem::path(EVENGINE_TEST_BINARY_DIR) / "out"; std::filesystem::create_directories(outDir); const std::filesystem::path outPath = outDir / "castle.png"; REQUIRE(saveImagePng(*image, outPath.string())); std::printf("castle render saved: %s occupancy=%.3f luma=%.3f\n", outPath.string().c_str(), audit.occupancy, audit.meanLuma); 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 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 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 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"); auto params = requireParams(p); auto meshLease = requireMesh(*mod, "mesh.marchingcubes", params.handle); auto m = meshLease.view(); REQUIRE(m.isBound()); 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); Params bad; bad.setInt("resolution", 8); bad.setString("field", "nope"); auto badParams = requireParams(bad); auto badResult = mod->buildMeshHandle("mesh.marchingcubes", badParams.handle); CHECK(!badResult.ok()); const eve::Diagnostic *badDiagnostic = badResult.error(); REQUIRE(badDiagnostic != nullptr); CHECK_EQ(badDiagnostic->code(), eve::DiagnosticCode::Failed); auto missingResult = mod->buildMeshHandle("mesh.missing", params.handle); CHECK(!missingResult.ok()); const eve::Diagnostic *missingDiagnostic = missingResult.error(); REQUIRE(missingDiagnostic != nullptr); CHECK_EQ(missingDiagnostic->code(), eve::DiagnosticCode::Failed); auto missingGraphicsResult = mod->generateMeshBorrowed("mesh.marchingcubes", {}, nullptr); CHECK(!missingGraphicsResult.isBound()); } 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(); auto failed = mod->buildMeshHandle("mesh.marchingcubes", {}); CHECK(!failed.ok()); const eve::Diagnostic *diagnostic = failed.error(); REQUIRE(diagnostic != nullptr); CHECK_EQ(diagnostic->code(), eve::DiagnosticCode::StaleHandle); auto failedUpload = mod->generateMeshBorrowed("mesh.marchingcubes", {}, nullptr); CHECK(!failedUpload.isBound()); 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); auto params = requireParams(p); auto gridLease = requireGrid(*mod, "dungeon.bsp", params.handle); auto grid = gridLease.view(); REQUIRE(grid.isBound()); 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); auto applyResult = mod->applyToLayer(gridLease.handle, "test", *layer); CHECK(applyResult.ok()); 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); 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`. auto a = TextureRecipeRegistry::instance().generate(id, p, err); auto b = TextureRecipeRegistry::instance().generate(id, p, err); const bool aPresent = static_cast(a); const bool bPresent = static_cast(b); REQUIRE(aPresent); REQUIRE(bPresent); 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); } } TEST_CASE("procgen.texture.generateImage.andNormal") { Procgen *mod = Procgen::create(); Params p; p.setSeed(3); p.setSize(48, 48); p.setInt("colors", 6); auto params = requireParams(p); auto imageResult = mod->generateImageHandle("tex.marble", params.handle); REQUIRE(imageResult.ok()); auto normalResult = mod->generateNormalImageHandle("tex.marble", params.handle); REQUIRE(normalResult.ok()); 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; auto img = TextureRecipeRegistry::instance().generate(id, p, err); REQUIRE(static_cast(img)); CHECK_EQ(img->getFormat(), std::string("RGBA8")); CHECK_EQ(img->getWidth(), 48); CHECK_EQ(img->getHeight(), 48); } CHECK(TextureRecipeRegistry::instance().has("tex.cloud")); CHECK(TextureRecipeRegistry::instance().has("tex.cloud_shadow")); } TEST_CASE("procgen.cloud.viaModule") { Procgen *mod = Procgen::create(); auto fieldResult = mod->newCloudFieldHandle(); REQUIRE(fieldResult.ok()); auto fieldHandle = std::move(fieldResult).takeValue(); auto field = mod->resolveCloudField(fieldHandle); REQUIRE(field.isBound()); field->setSeed(3); field->setWorldScale(64.f); field->setCoverage(0.5f); auto coverageResult = mod->cloudCoverageAt(fieldHandle, 2.f, 3.f, 0.f); REQUIRE(coverageResult.ok()); const float c0 = std::move(coverageResult).takeValue(); CHECK(c0 >= 0.f); CHECK(c0 <= 1.f); auto staleCoverage = mod->cloudCoverageAt({}, 0.f, 0.f, 0.f); CHECK(!staleCoverage.ok()); auto shadowResult = mod->newCloudShadowHandle(); REQUIRE(shadowResult.ok()); auto shadowHandle = std::move(shadowResult).takeValue(); auto shadow = mod->resolveCloudShadow(shadowHandle); REQUIRE(shadow.isBound()); shadow->setSunDirection(0.5f, 1.f, 0.f); shadow->setCloudAltitude(50.f); shadow->setStrength(0.8f); auto factorResult = mod->cloudShadowFactor(shadowHandle, 2.f, 3.f, 0.f); REQUIRE(factorResult.ok()); CHECK(std::move(factorResult).takeValue() >= 0.f); auto staleFactor = mod->cloudShadowFactor({}, 0.f, 0.f, 0.f); CHECK(!staleFactor.ok()); std::vector buf(16 * 16); auto sampleResult = mod->sampleCloud(fieldHandle, std::span(buf), 16, 16, 0.f, 0.f, 0.f, 64.f); CHECK(sampleResult.ok()); auto shadowSampleResult = mod->sampleCloudShadow(shadowHandle, std::span(buf), 16, 16, 0.f, 0.f, 0.f, 64.f); CHECK(shadowSampleResult.ok()); auto releaseField = mod->releaseCloudField(fieldHandle); CHECK(releaseField.ok()); auto releaseShadow = mod->releaseCloudShadow(shadowHandle); CHECK(releaseShadow.ok()); } 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; auto a = TextureRecipeRegistry::instance().generate(id, p, err); auto b = TextureRecipeRegistry::instance().generate(id, p, err); const bool aPresent = static_cast(a); const bool bPresent = static_cast(b); REQUIRE(aPresent); REQUIRE(bPresent); CHECK_EQ(a->getFormat(), std::string("RGBA8")); CHECK(std::memcmp(a->getData(), b->getData(), a->getSize()) == 0); } } TEST_CASE("procgen.recipeSchemas.textureAndPbrDefaults") { auto &textures = TextureRecipeRegistry::instance(); auto &materials = PbrRecipeRegistry::instance(); textures.registerBuiltins(); materials.registerPbrBuiltins(); for (const std::string &id : textures.list()) { const RecipeDescriptor *schema = textures.descriptor(id); REQUIRE(schema != nullptr); CHECK_EQ(schema->id, id); CHECK(!schema->displayName.empty()); CHECK(schema->find("seed") != nullptr); } for (const std::string &id : materials.list()) { const RecipeDescriptor *schema = materials.descriptor(id); REQUIRE(schema != nullptr); CHECK_EQ(schema->category, std::string("Material")); CHECK(schema->find("metallic") != nullptr); CHECK(schema->find("normalStrength") != nullptr); } Params defaults; REQUIRE(materials.applyDefaults("pbr.rock", defaults)); const RecipeDescriptor *rock = materials.descriptor("pbr.rock"); REQUIRE(rock != nullptr); REQUIRE(rock->find("metallic") != nullptr); CHECK_EQ(defaults.getFloat("metallic", -1.f), std::stof(rock->find("metallic")->defaultValue)); CHECK(!materials.applyDefaults("pbr.missing", defaults)); RecipeDescriptor copied = *rock; copied.displayName = "Project Rock"; CHECK_NE(copied.displayName, rock->displayName); } TEST_CASE("procgen.recipeSchemas.meshDefaults") { auto &meshes = MeshRecipeRegistry::instance(); meshes.registerBuiltins(); for (const std::string &id : meshes.list()) { const RecipeDescriptor *schema = meshes.descriptor(id); REQUIRE(schema != nullptr); CHECK_EQ(schema->id, id); CHECK(!schema->displayName.empty()); CHECK(!schema->category.empty()); CHECK(schema->find("seed") != nullptr); } Params defaults; REQUIRE(meshes.applyDefaults("mesh.fence", defaults)); CHECK_EQ(defaults.getInt("segments", 0), 6); CHECK_EQ(defaults.getFloat("height", 0.f), 1.1f); MeshBuild mesh; std::string error; REQUIRE(meshes.generate("mesh.fence", defaults, mesh, error)); CHECK_GT(mesh.getVertexCount(), 0); CHECK(!meshes.applyDefaults("mesh.missing", defaults)); } 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; auto a = PbrRecipeRegistry::instance().generate(id, p, err); auto b = PbrRecipeRegistry::instance().generate(id, p, err); const bool aPresent = static_cast(a); const bool bPresent = static_cast(b); REQUIRE(aPresent); REQUIRE(bPresent); // 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(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); } } 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; auto set = PbrRecipeRegistry::instance().generate("pbr.marble", p, err); REQUIRE(static_cast(set)); const auto *m = static_cast(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(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 } } TEST_CASE("procgen.pbr.viaModuleAndErrors") { Procgen *mod = Procgen::create(); Params p; p.setSeed(9); p.setSize(40, 40); auto params = requireParams(p); auto pbrResult = mod->generatePbrMaterialHandle("pbr.wall", params.handle); REQUIRE(pbrResult.ok()); auto setLease = PbrLease(*mod, std::move(pbrResult).takeValue()); auto set = setLease.view(); REQUIRE(set.isBound()); CHECK(set->albedo != nullptr); CHECK(set->normal != nullptr); 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); auto missingResult = mod->generatePbrMaterialHandle("pbr.missing", params.handle); CHECK(!missingResult.ok()); const eve::Diagnostic *missingDiagnostic = missingResult.error(); REQUIRE(missingDiagnostic != nullptr); CHECK_EQ(missingDiagnostic->code(), eve::DiagnosticCode::NotFound); auto staleResult = mod->generatePbrMaterialHandle("pbr.wall", {}); CHECK(!staleResult.ok()); const eve::Diagnostic *staleDiagnostic = staleResult.error(); REQUIRE(staleDiagnostic != nullptr); CHECK_EQ(staleDiagnostic->code(), eve::DiagnosticCode::StaleHandle); } 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); auto params = requireParams(p); auto meshLease = requireMesh(*mod, "mesh.stonewall", params.handle); auto m = meshLease.view(); REQUIRE(m.isBound()); CHECK(m->getVertexCount() > 0); CHECK(meshIndicesInRange(*m)); CHECK(mod->hasMeshRecipe("mesh.stonewall")); CHECK(mod->hasMeshRecipe("mesh.bridge")); auto missingResult = mod->buildMeshHandle("mesh.nonexistent", params.handle); CHECK(!missingResult.ok()); const eve::Diagnostic *missingDiagnostic = missingResult.error(); REQUIRE(missingDiagnostic != nullptr); CHECK_EQ(missingDiagnostic->code(), eve::DiagnosticCode::Failed); } TEST_CASE("procgen.mesh.castle.multilevelDeterministicAndComplete") { MeshRecipeRegistry::instance().registerBuiltins(); Params p; p.setSeed(8675309); p.setFloat("width", 48.f); p.setFloat("depth", 40.f); p.setInt("rings", 2); p.setInt("keepFloors", 4); p.setInt("detail", 2); MeshBuild a, b; std::string err; REQUIRE(MeshRecipeRegistry::instance().generate("mesh.castle", p, a, err)); REQUIRE(MeshRecipeRegistry::instance().generate("mesh.castle", p, b, err)); CHECK(a.positions() == b.positions()); CHECK(a.indices() == b.indices()); CHECK(meshIndicesInRange(a)); CHECK(meshPositionsFinite(a)); CHECK(meshNormalsFiniteUnit(a)); CHECK(a.getVertexCount() > 5000); CHECK_EQ(a.getMeta("algorithm", ""), "mesh.castle"); CHECK_EQ(a.getMeta("rings", ""), "2"); CHECK_EQ(a.getMeta("keepFloors", ""), "4"); CHECK(std::stoi(a.getMeta("towerCount", "0")) >= 12); // One stair per wall ring plus one between every pair of keep floors. CHECK_EQ(a.getMeta("stairFlights", ""), "5"); std::set groups; for (int i = 0; i < a.getGroupCount(); ++i) groups.insert(a.getGroupName(i)); for (const char *required : {"walls", "battlements", "towers", "gatehouses", "stairs", "keep", "courtyard"}) CHECK(groups.count(required) == 1); int stairGroup = -1; for (int i = 0; i < a.getGroupCount(); ++i) if (a.getGroupName(i) == "stairs") stairGroup = i; REQUIRE(stairGroup >= 0); auto stairs = a.copyGroup(stairGroup); REQUIRE(stairs.get() != nullptr); CHECK(stairs->getVertexCount() > 100); CHECK_EQ(stairs->getMeta("group", ""), "stairs"); CHECK(meshIndicesInRange(*stairs)); int towerGroup = -1; for (int i = 0; i < a.getGroupCount(); ++i) if (a.getGroupName(i) == "towers") towerGroup = i; REQUIRE(towerGroup >= 0); auto towers = a.copyGroup(towerGroup); REQUIRE(towers.get() != nullptr); CHECK(meshWindingAgreesWithNormals(*towers)); } TEST_CASE("procgen.mesh.castle.parametersControlTopologyAndBounds") { MeshRecipeRegistry::instance().registerBuiltins(); Params simple; simple.setInt("rings", 1); simple.setInt("keepFloors", 1); simple.setInt("detail", 0); simple.setFloat("width", 30.f); simple.setFloat("depth", 26.f); Params elaborate = simple; elaborate.setInt("rings", 3); elaborate.setInt("keepFloors", 5); elaborate.setInt("detail", 2); elaborate.setFloat("width", 58.f); elaborate.setFloat("depth", 50.f); MeshBuild a, b; std::string err; REQUIRE(generateCastleMesh(simple, a, err)); REQUIRE(generateCastleMesh(elaborate, b, err)); CHECK(b.getVertexCount() > a.getVertexCount() * 2); CHECK(std::stoi(b.getMeta("towerCount", "0")) > std::stoi(a.getMeta("towerCount", "0"))); CHECK_EQ(b.getMeta("stairFlights", ""), "7"); Params scaled = simple; scaled.setFloat("scale", 2.f); MeshBuild s; REQUIRE(generateCastleMesh(scaled, s, err)); float maxA = 0.f, maxS = 0.f; for (int i=0;ihasMeshRecipe("mesh.castle")); auto schemaResult = mod->getMeshRecipeSchema("mesh.castle"); REQUIRE(schemaResult.ok()); auto schema = std::move(schemaResult).takeValue(); CHECK_EQ(schema.getParamCount(), 25); const ParamDescriptor *rings = schema.find("rings"); REQUIRE(rings != nullptr); CHECK_EQ(rings->defaultValue, "2"); CHECK(rings->hasMinimum); CHECK(rings->hasMaximum); CHECK_EQ(rings->minimum, 1.0); CHECK_EQ(rings->maximum, 4.0); CHECK(!rings->description.empty()); auto missingSchema = mod->getMeshRecipeSchema("mesh.missing"); CHECK(!missingSchema.ok()); const eve::Diagnostic *schemaDiagnostic = missingSchema.error(); REQUIRE(schemaDiagnostic != nullptr); CHECK_EQ(schemaDiagnostic->code(), eve::DiagnosticCode::NotFound); Params p; p.setFloat("width", 16.f); p.setFloat("depth", 16.f); p.setFloat("towerRadius", 7.f); p.setFloat("wallThickness", 3.f); auto params = requireParams(p); auto failed = mod->buildMeshHandle("mesh.castle", params.handle); CHECK(!failed.ok()); const eve::Diagnostic *failedDiagnostic = failed.error(); REQUIRE(failedDiagnostic != nullptr); CHECK_EQ(failedDiagnostic->code(), eve::DiagnosticCode::Failed); } TEST_CASE("procgen.meshBuild.appendTransformedComposesRecipeNodes") { Params p; p.setInt("segments", 1); p.setFloat("segLength", 2.f); MeshBuild source, combined; std::string err; REQUIRE(generateLinearStructure("mesh.stonewall", p, source, err)); REQUIRE(combined.appendTransformed(&source, 3.f, 2.f, -4.f, 90.f, 2.f, 1.f, .5f)); CHECK_EQ(combined.getVertexCount(), source.getVertexCount()); CHECK_EQ(combined.getIndexCount(), source.getIndexCount()); CHECK(meshIndicesInRange(combined)); CHECK(meshNormalsFiniteUnit(combined)); REQUIRE(combined.appendTransformed(&source, -3.f, 0.f, 4.f, 0.f, -1.f, 1.f, 1.f)); CHECK_EQ(combined.getVertexCount(), source.getVertexCount() * 2); CHECK_EQ(combined.getIndexCount(), source.getIndexCount() * 2); CHECK(!combined.appendTransformed(&combined, 0, 0, 0, 0, 1, 1, 1)); CHECK(!combined.appendTransformed(nullptr, 0, 0, 0, 0, 1, 1, 1)); CHECK(!combined.appendTransformed(&source, 0, 0, 0, 0, 0, 1, 1)); } // --- Water (graphics): sky reflection + animated edge waves + middle drop ripples --- namespace { struct WaterLumaGrid { int grid = 16; std::vector 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 img(gfx->newImageData()); REQUIRE(img.get() != nullptr); const uint8_t *px = static_cast(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); 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.waterfall.render.flowAndFoam") { auto* win = eve::window::Window::create(); auto* gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 256; settings.height = 256; settings.centered = true; REQUIRE(win->setWindowSettings(settings)); auto* camera = Camera3D::createCamera(); camera->setEye(0.f, 0.f, 7.f); camera->setTarget(0.f, 0.f, 0.f); camera->setFov(50.f); camera->setAmbient(0.2f, 0.22f, 0.25f); gfx->setBackgroundColor(Color(0.01f, 0.015f, 0.025f, 1.f)); gfx->setScreenReadbackEnabled(true); RenderSystem3D::setDirectionalLight(0.2f, 0.8f, 0.5f, 1.f, 0.95f, 0.85f); auto* present = Renderable2D::create(); present->sprite()->width = 1.f; present->sprite()->height = 1.f; present->sprite()->a = 0.f; Waterfall* wf = gfx->newWaterfall(); REQUIRE(wf != nullptr); wf->createCurvedSheet(4.f, 6.f, 24, 32, 0.35f, 0.15f); wf->setWaterColor(0.05f, 0.28f, 0.5f); wf->setReflectionIntensity(0.65f); wf->setSunIntensity(0.8f); wf->setTurbulence(0.75f); wf->setStreakCount(7); auto* waterfallEnt = Renderable3D::create(); waterfallEnt->setMesh(wf->getMesh()); waterfallEnt->setShader(wf->getShader()); waterfallEnt->setReceiveShadow(false); waterfallEnt->setCastShadow(false); waterfallEnt->setCamera(camera); auto capture = [&](float time, float foam) { wf->setTime(time); wf->setFoamAmount(foam); wf->bindParams(); return waterCaptureLuma(gfx, 16); }; const WaterLumaGrid t0 = capture(0.f, 0.8f); const WaterLumaGrid t1 = capture(0.4f, 0.8f); float rendered = 0.f; for (float cell : t0.cells) rendered += cell; rendered /= float(t0.cells.size()); const float flowDiff = waterDiff(t0, t1); const WaterLumaGrid noFoam = capture(0.2f, 0.f); const WaterLumaGrid foam = capture(0.2f, 1.1f); const float foamDiff = waterDiff(noFoam, foam); std::printf("waterfall render: rendered=%.2f flowDiff=%.2f foamDiff=%.2f\n", rendered, flowDiff, foamDiff); REQUIRE(rendered > 1.f); REQUIRE(flowDiff > 0.15f); REQUIRE(foamDiff > 0.15f); delete wf; win->close(); } TEST_CASE("graphics.water.paramsRoundTrip") { auto* win = eve::window::Window::create(); auto* gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; settings.width = 128; settings.height = 128; settings.centered = true; REQUIRE(win->setWindowSettings(settings)); 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->setScreenSpaceReflection(true, 0.9f); CHECK(w->getScreenSpaceReflection()); CHECK(approxEq(w->getScreenSpaceReflectionStrength(), 0.9f, 1e-5f)); w->setScreenSpaceReflection(true, -1.f); CHECK(approxEq(w->getScreenSpaceReflectionStrength(), 0.f, 1e-5f)); w->setViewport(320.f, 180.f); CHECK(approxEq(w->getViewportWidth(), 320.f, 1e-5f)); CHECK(approxEq(w->getViewportHeight(), 180.f, 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; win->close(); } TEST_CASE("graphics.water.render.dynamicRipplesAndReflection") { auto *win = eve::window::Window::create(); auto *gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); 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. [[maybe_unused]] auto *const imageModule = 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); REQUIRE(rendered > 1.f); // water surface is actually drawn REQUIRE(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); REQUIRE(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 pos, nrm, uv; std::vector 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); eve::window::WindowSettings settings; settings.width = 640; settings.height = 480; settings.centered = true; REQUIRE(win->setWindowSettings(settings)); [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); // Gradient sky cubemap: deep blue at the zenith, pale near the horizon. const int fs = 64; std::vector 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. float cr = 0.72f + (0.12f - 0.72f) * t; float cg = 0.80f + (0.32f - 0.80f) * t; float cb = 0.90f + (0.72f - 0.90f) * t; const float nx = dx / len; const float nz = dz / len; const float cloudNoise = std::sin(nx * 8.0f + nz * 3.1f) + std::sin(nz * 11.3f - nx * 2.7f) * 0.55f + std::sin((nx + nz) * 17.0f) * 0.22f; const float cloudAltitude = std::clamp((ny - 0.03f) * 4.0f, 0.0f, 1.0f) * std::clamp((0.82f - ny) * 3.2f, 0.0f, 1.0f); const float cloud = std::clamp((cloudNoise - 0.42f) * 2.6f, 0.0f, 1.0f) * cloudAltitude; cr = cr + (0.96f - cr) * cloud; cg = cg + (0.97f - cg) * cloud; cb = cb + (1.00f - cb) * cloud; 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); 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 = 100.f; gfx->setBackgroundColor(Color(0.36f, 0.55f, 0.72f, 1.f)); gfx->setScreenReadbackEnabled(true); gfx->getRenderControl()->enable("gbuffer"); 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; WaterSceneFixture sceneFixture = createStylizedWaterScene(gfx, camera); // The water is drawn explicitly after the opaque GBuffer so it can sample // scene color and linear depth without a read/write feedback hazard. Water *water = gfx->newWater(); REQUIRE(water != nullptr); water->createPlane(14.f, 14.f, 64, 64); WaterStyleConfig style = water->config(); style.deepColor = {0.005f, 0.16f, 0.42f}; style.shallowColor = {0.025f, 0.50f, 0.62f}; style.waveAmplitude = 0.11f; style.waveSharpness = 1.65f; style.rippleAmplitude = 0.12f; style.rippleCount = 5; style.rippleInterval = 1.4f; style.waveScale = 1.35f; style.foamWidth = 0.58f; style.foamSoftness = 0.12f; style.foamStrength = 0.72f; style.reflectionIntensity = 0.82f; style.fresnelPower = 2.2f; style.sunIntensity = 0.65f; style.opacity = 0.68f; style.refractionStrength = 0.045f; style.causticsStrength = 0.55f; style.causticsScale = 3.8f; if (const char* layerMode = std::getenv("EVENGINE_WATER_LAYER_MODE")) { if (std::strcmp(layerMode, "transmission") == 0) { style.reflectionIntensity = 0.0f; style.sunIntensity = 0.0f; style.foamStrength = 0.12f; style.opacity = 0.52f; } else if (std::strcmp(layerMode, "reflection") == 0) { style.reflectionIntensity = 1.15f; style.sunIntensity = 0.75f; style.refractionStrength = 0.0f; style.causticsStrength = 0.0f; style.foamStrength = 0.08f; style.opacity = 0.82f; } } auto styleJson = style.toJson(); REQUIRE(static_cast(styleJson)); REQUIRE(static_cast(water->applyConfigJson(styleJson.value()))); // Animate a few seconds so ripples travel, then save a frame. for (int frame = 0; frame < 40; ++frame) { const float time = float(frame) * 0.06f; water->setTime(time); sceneFixture.update(time); RenderSystem3D::render(*gfx); water->draw(); RenderSystem::render(*gfx); } std::unique_ptr 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); eve::window::WindowSettings settings; settings.width = 640; settings.height = 480; settings.centered = true; REQUIRE(win->setWindowSettings(settings)); [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); // Gradient sky cubemap. const int fs = 16; std::vector 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); eve::window::WindowSettings s; s.width = 320; s.height = 240; s.centered = true; REQUIRE(win->setWindowSettings(s)); const int fs = 8; std::vector 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 img(refl->newImageData()); REQUIRE(img.get() != nullptr); const int rw = img->getWidth(); const int rh = img->getHeight(); const uint8_t *pxd = static_cast(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); eve::window::WindowSettings settings; settings.width = 640; settings.height = 480; settings.centered = true; REQUIRE(win->setWindowSettings(settings)); [[maybe_unused]] auto *const imageModule = eve::image::Image::create(); const int fs = 16; std::vector 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; // 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 { Texture* texture; glm::mat4 model; }; std::vector ents; for (const Box &b : boxes) { const uint8_t px[4] = {b.r, b.g, b.b, 255}; Texture* texture = gfx->newTexture(1, 1, px); 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({texture, 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)); 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, en.texture, glm::vec4(1.f), nullptr); } gfx->end3DFrameToCanvas(); gfx->setBackgroundColor(Color(0.05f, 0.09f, 0.14f, 1.f)); // Final frame: the public Water path borrows and samples the planar reflection. const glm::mat4 mainView = glm::lookAtRH(eye, tgt, glm::vec3(0.f, 1.f, 0.f)); const glm::mat4 mainProj = perspectiveVulkanRH_ZO(glm::radians(camera->data()->fovYDeg), settings.width / float(settings.height), camera->data()->nearZ, camera->data()->farZ); gfx->setMesh3DViewProj(mainProj * mainView); gfx->setMesh3DView(mainView); gfx->setMesh3DCameraPos(eye); gfx->setMesh3DClip(camera->data()->nearZ, camera->data()->farZ); gfx->setMesh3DEnv(skyTex, 1.f); water->setTime(0.5f); RenderSystem3D::render(*gfx); water->drawWithPlanarReflection(refl->getTexture(), 0.9F); RenderSystem::render(*gfx); // Read the reflection canvas once and check the red box was captured. std::unique_ptr rim(refl->newImageData()); REQUIRE(rim.get() != nullptr); REQUIRE(saveImagePng(*rim, std::string(outPath) + ".reflection.png")); int red = 0, total = 0; const uint8_t *pd = static_cast(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 img(gfx->newImageData()); REQUIRE(img.get() != nullptr); int reflectedRed = 0; const auto* finalPixels = static_cast(img->getData()); for (int y = 0; y < img->getHeight(); ++y) { for (int x = 0; x < img->getWidth(); ++x) { const size_t offset = (size_t(y) * size_t(img->getWidth()) + size_t(x)) * 4; const int r = finalPixels[offset]; const int g = finalPixels[offset + 1]; const int b = finalPixels[offset + 2]; if (r > 120 && r > g + 20 && r > b + 20) ++reflectedRed; } } CHECK(reflectedRed > 1000); REQUIRE(saveImagePng(*img, outPath)); std::printf("planar water render saved: %s\n", outPath); win->close(); } TEST_CASE("procgen.terrain.file.decodeEvtrAndEvtrn") { // EVTR: chunked archive, UNORM16-quantized heights, no metres-per-cell. 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); const HydrologyMap hydrology = TerrainPipeline::buildHydrology(heightmap, 5.f, 10.f); const ClimateMap climate = TerrainPipeline::buildClimate(heightmap, hydrology, 10.f, 0.6f); std::vector evtr; std::string error; REQUIRE(TerrainAsset::bake(heightmap, hydrology, climate, 8, evtr, &error)); auto decodedEvtr = decodeTerrainFile(std::span(evtr.data(), evtr.size()), TerrainFileFormat::Auto); REQUIRE(decodedEvtr.ok()); const DecodedTerrainFile evtrTerrain = std::move(decodedEvtr).takeValue(); CHECK_EQ(evtrTerrain.format, std::string("evtr")); CHECK_EQ(evtrTerrain.heightmap.getWidth(), 19); CHECK_EQ(evtrTerrain.heightmap.getHeight(), 13); // An EVTR archive carries no cell size, so the level owns the spacing. CHECK(!evtrTerrain.hasSpacing); CHECK(std::abs(evtrTerrain.minHeight - 10.f) < 0.01f); CHECK(std::abs(evtrTerrain.maxHeight - (10.f + 18 * 0.25f + 12 * 0.5f)) < 0.01f); // Quantization is UNORM16 over the archive range, so allow one step. for (int y = 0; y < 13; y += 4) for (int x = 0; x < 19; x += 3) CHECK(std::abs(evtrTerrain.heightmap.height(x, y) - heightmap.height(x, y)) < 0.01f); // EVTRN: raw float32 heightfield written by the asset importer. auto putU32 = [](std::vector& out, std::uint32_t value) { for (unsigned shift = 0; shift != 32; shift += 8) out.push_back(static_cast(value >> shift)); }; auto putF32 = [&putU32](std::vector& out, float value) { putU32(out, std::bit_cast(value)); }; constexpr int kWidth = 5, kHeight = 3; std::vector evtrn = {'E', 'V', 'T', 'R', 'N', 0, 1, 0}; putU32(evtrn, kWidth); putU32(evtrn, kHeight); putF32(evtrn, 2.5f); putF32(evtrn, 4.0f); for (int y = 0; y < kHeight; ++y) for (int x = 0; x < kWidth; ++x) putF32(evtrn, float(x) - float(y) * 0.5f); auto decodedEvtrn = decodeTerrainFile(std::span(evtrn.data(), evtrn.size()), TerrainFileFormat::Auto); REQUIRE(decodedEvtrn.ok()); const DecodedTerrainFile evtrnTerrain = std::move(decodedEvtrn).takeValue(); CHECK_EQ(evtrnTerrain.format, std::string("evtrn")); CHECK(evtrnTerrain.hasSpacing); CHECK_EQ(evtrnTerrain.spacingX, 2.5f); CHECK_EQ(evtrnTerrain.spacingZ, 4.0f); CHECK_EQ(evtrnTerrain.heightmap.getWidth(), kWidth); CHECK_EQ(evtrnTerrain.heightmap.getHeight(), kHeight); CHECK_EQ(evtrnTerrain.heightmap.height(4, 2), 3.f); CHECK_EQ(evtrnTerrain.heightmap.height(0, 2), -1.f); CHECK_EQ(evtrnTerrain.minHeight, -1.f); CHECK_EQ(evtrnTerrain.maxHeight, 4.f); // A caller that names the format still gets the magic checked. auto mismatched = decodeTerrainFile(std::span(evtrn.data(), evtrn.size()), TerrainFileFormat::Evtr); CHECK(!mismatched.ok()); const eve::Diagnostic* mismatchDiagnostic = mismatched.status().primaryDiagnostic(); REQUIRE(mismatchDiagnostic != nullptr); CHECK_EQ(int(mismatchDiagnostic->code()), int(eve::DiagnosticCode::ParseError)); // Unknown magic, truncated payload, and a size/dimension mismatch all fail // rather than returning a partially filled grid. std::vector unknown = evtrn; unknown[0] = 'X'; CHECK(!decodeTerrainFile(std::span(unknown.data(), unknown.size()), TerrainFileFormat::Auto) .ok()); CHECK(!decodeTerrainFile(std::span(evtrn.data(), evtrn.size() - 4), TerrainFileFormat::Evtrn) .ok()); std::vector wrongCount = evtrn; wrongCount[8] = 9; // claims nine columns for a payload holding five CHECK(!decodeTerrainFile(std::span(wrongCount.data(), wrongCount.size()), TerrainFileFormat::Evtrn) .ok()); CHECK(!decodeTerrainFile(std::span(), TerrainFileFormat::Auto).ok()); CHECK_EQ(int(parseTerrainFileFormat("EVTRN")), int(TerrainFileFormat::Evtrn)); CHECK_EQ(int(parseTerrainFileFormat("evtr")), int(TerrainFileFormat::Evtr)); CHECK_EQ(int(parseTerrainFileFormat("")), int(TerrainFileFormat::Auto)); CHECK_EQ(int(parseTerrainFileFormat("nonsense")), int(TerrainFileFormat::Auto)); }