Files
EVEngine/test/voxel.cpp
T
2026-09-11 20:39:21 +08:00

2480 lines
91 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "data/ByteData.h"
#include "procgen/heightmap/TerrainAsset.h"
#include "procgen/heightmap/TerrainSampler.h"
#include "voxel/Chunk.h"
#include "voxel/CubeTypeRegistry.h"
#include "voxel/FaceDir.h"
#include "voxel/Frustum.h"
#include "voxel/GreedyMesher.h"
#include "voxel/Voxel.h"
#include "voxel/VoxelPack.h"
#include "voxel/VoxelRectDecode.h"
#include "voxel/VoxelWorld.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using namespace eve::voxel;
static void lookAtRH(float eyeX, float eyeY, float eyeZ, float cx, float cy, float cz, float ux,
float uy, float uz, float out[16]) {
float fx = cx - eyeX, fy = cy - eyeY, fz = cz - eyeZ;
float fl = std::sqrt(fx * fx + fy * fy + fz * fz);
fx /= fl;
fy /= fl;
fz /= fl;
float sx = fy * uz - fz * uy;
float sy = fz * ux - fx * uz;
float sz = fx * uy - fy * ux;
float sl = std::sqrt(sx * sx + sy * sy + sz * sz);
sx /= sl;
sy /= sl;
sz /= sl;
float rx = sy * fz - sz * fy;
float ry = sz * fx - sx * fz;
float rz = sx * fy - sy * fx;
out[0] = sx;
out[1] = rx;
out[2] = -fx;
out[3] = 0;
out[4] = sy;
out[5] = ry;
out[6] = -fy;
out[7] = 0;
out[8] = sz;
out[9] = rz;
out[10] = -fz;
out[11] = 0;
out[12] = -(sx * eyeX + sy * eyeY + sz * eyeZ);
out[13] = -(rx * eyeX + ry * eyeY + rz * eyeZ);
out[14] = -(-fx * eyeX - fy * eyeY - fz * eyeZ);
out[15] = 1;
}
static void perspectiveRH_ZO(float fovyRad, float aspect, float zNear, float zFar, float out[16]) {
const float f = 1.f / std::tan(fovyRad * 0.5f);
std::memset(out, 0, sizeof(float) * 16);
out[0] = f / aspect;
out[5] = f;
out[10] = zFar / (zNear - zFar);
out[11] = -1.f;
out[14] = (zFar * zNear) / (zNear - zFar);
}
static void mul4(const float a[16], const float b[16], float out[16]) {
float t[16];
for (int c = 0; c < 4; ++c) {
for (int r = 0; r < 4; ++r) {
t[c * 4 + r] = a[0 * 4 + r] * b[c * 4 + 0] + a[1 * 4 + r] * b[c * 4 + 1] +
a[2 * 4 + r] * b[c * 4 + 2] + a[3 * 4 + r] * b[c * 4 + 3];
}
}
std::memcpy(out, t, sizeof(t));
}
TEST_CASE("voxel.module.name") {
auto *mod = Voxel::create();
CHECK_EQ(mod->getName(), std::string("Voxel"));
CHECK_EQ(Voxel::create(), mod);
CHECK_EQ(mod->getChunkSize(), 32);
CubeTypeRegistry *types = mod->newCubeTypes();
REQUIRE(types != nullptr);
CHECK_EQ(types->count(), 0);
CHECK_EQ(types->variantCount(), 0);
delete types;
VoxelWorld *world = mod->newWorld();
REQUIRE(world != nullptr);
CHECK_EQ(world->getChunkCount(), 0);
delete world;
}
TEST_CASE("voxel.pack.roundtrip") {
const uint32_t bits = PackedRect::pack(3, 7, 11, 5, 9, 42).bits;
CHECK_EQ(PackedRect{bits}.x(), 3);
CHECK_EQ(PackedRect{bits}.y(), 7);
CHECK_EQ(PackedRect{bits}.z(), 11);
CHECK_EQ(PackedRect{bits}.width(), 5);
CHECK_EQ(PackedRect{bits}.height(), 9);
CHECK_EQ(PackedRect{bits}.tex(), 42);
const uint32_t full = PackedRect::pack(31, 31, 31, 32, 32, 127).bits;
CHECK_EQ(PackedRect{full}.x(), 31);
CHECK_EQ(PackedRect{full}.width(), 32);
CHECK_EQ(PackedRect{full}.height(), 32);
CHECK_EQ(PackedRect{full}.tex(), 127);
CHECK_EQ(sizeof(PackedRect), size_t(4));
// Field isolation / bit disjointness: each field must survive on its own.
const uint32_t a = PackedRect::pack(31, 0, 0, 1, 1, 0).bits;
const uint32_t b = PackedRect::pack(0, 31, 0, 1, 1, 0).bits;
const uint32_t c = PackedRect::pack(0, 0, 31, 1, 1, 0).bits;
const uint32_t d = PackedRect::pack(0, 0, 0, 32, 1, 0).bits;
const uint32_t e = PackedRect::pack(0, 0, 0, 1, 32, 0).bits;
const uint32_t f = PackedRect::pack(0, 0, 0, 1, 1, 127).bits;
CHECK_EQ(PackedRect{a}.x(), 31);
CHECK_EQ(PackedRect{a}.y(), 0);
CHECK_EQ(PackedRect{b}.y(), 31);
CHECK_EQ(PackedRect{b}.x(), 0);
CHECK_EQ(PackedRect{c}.z(), 31);
CHECK_EQ(PackedRect{d}.width(), 32);
CHECK_EQ(PackedRect{e}.height(), 32);
CHECK_EQ(PackedRect{f}.tex(), 127);
CHECK_EQ(a & b, 0u);
// Minimum width/height encode zero in their bit fields.
const PackedRect min = PackedRect::pack(0, 0, 0, 1, 1, 0);
CHECK_EQ(min.width(), 1);
CHECK_EQ(min.height(), 1);
CHECK_EQ((min.bits >> 15) & 31u, 0u);
CHECK_EQ((min.bits >> 20) & 31u, 0u);
// tex field is independent of xyz across its full range.
for (int tex = 0; tex <= 127; tex += 17) {
const PackedRect t = PackedRect::pack(3, 5, 7, 2, 4, tex);
CHECK_EQ(t.x(), 3);
CHECK_EQ(t.y(), 5);
CHECK_EQ(t.z(), 7);
CHECK_EQ(t.width(), 2);
CHECK_EQ(t.height(), 4);
CHECK_EQ(t.tex(), tex);
}
// Out-of-range inputs are masked to the field width, not rejected.
const PackedRect masked = PackedRect::pack(33, 40, 50, 40, 50, 200);
CHECK_EQ(masked.x(), 1); // 33 & 31
CHECK_EQ(masked.y(), 8); // 40 & 31
CHECK_EQ(masked.z(), 18); // 50 & 31
CHECK_EQ(masked.width(), 8); // (40-1)&31 + 1
CHECK_EQ(masked.height(), 18); // (49&31)+1
CHECK_EQ(masked.tex(), 72); // 200 & 127
const PackedRect neg = PackedRect::pack(100, -1, 64, 40, -3, 200);
CHECK(neg.x() >= 0);
CHECK(neg.x() <= 31);
CHECK(neg.y() >= 0);
CHECK(neg.y() <= 31);
CHECK(neg.z() >= 0);
CHECK(neg.z() <= 31);
CHECK(neg.tex() >= 0);
CHECK(neg.tex() <= 127);
// Grid sweep of representative field values.
for (int x = 0; x < 32; x += 7)
for (int y = 0; y < 32; y += 11)
for (int z = 0; z < 32; z += 13)
for (int tex = 0; tex < 128; tex += 31) {
const PackedRect g = PackedRect::pack(x, y, z, 1 + (x % 5), 1 + (y % 7), tex);
CHECK_EQ(g.x(), x);
CHECK_EQ(g.y(), y);
CHECK_EQ(g.z(), z);
CHECK_EQ(g.tex(), tex);
}
}
TEST_CASE("voxel.greedy.single_voxel") {
// Chunk-based meshing: one 1×1 rect per face with packed coords/tex.
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->set(4, 5, 6, 3);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 6);
for (int i = 0; i < faceDirCount(); ++i) {
CHECK_EQ(chunk->faceRectCount(FaceDir(i)), 1);
const PackedRect &r = chunk->faceRects(FaceDir(i))[0];
CHECK_EQ(r.tex(), 3);
CHECK_EQ(r.width(), 1);
CHECK_EQ(r.height(), 1);
CHECK_EQ(r.x(), 4);
CHECK_EQ(r.y(), 5);
CHECK_EQ(r.z(), 6);
}
// Max tex id and corner voxel (31,31,31) survive packing.
std::unique_ptr<Chunk> corner(new Chunk(0, 0, 0));
corner->set(31, 31, 31, 127);
corner->remesh();
CHECK_EQ(corner->totalRectCount(), 6);
for (int i = 0; i < faceDirCount(); ++i) {
const PackedRect &r = corner->faceRects(FaceDir(i))[0];
CHECK_EQ(r.tex(), 127);
CHECK_EQ(r.x(), 31);
CHECK_EQ(r.y(), 31);
CHECK_EQ(r.z(), 31);
}
// Direct GreedyMesher::meshChunk path.
std::vector<uint8_t> voxels(size_t(32 * 32 * 32), 0);
voxels[size_t(2 + 3 * 32 + 4 * 32 * 32)] = 7;
std::vector<PackedRect> faces[6];
GreedyMesher::meshChunk(voxels.data(), faces);
CHECK_EQ(int(faces[int(FaceDir::PosX)].size()), 1);
CHECK_EQ(int(faces[int(FaceDir::NegX)].size()), 1);
CHECK_EQ(int(faces[int(FaceDir::PosY)].size()), 1);
const uint32_t bits = faces[int(FaceDir::PosZ)][0].bits;
CHECK_EQ(PackedRect{bits}.tex(), 7);
CHECK_EQ(PackedRect{bits}.x(), 2);
CHECK_EQ(PackedRect{bits}.y(), 3);
CHECK_EQ(PackedRect{bits}.z(), 4);
}
TEST_CASE("voxel.greedy.flat_slab_merges") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 4; ++z)
for (int x = 0; x < 8; ++x) chunk->set(x, 0, z, 2);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
const PackedRect &top = chunk->faceRects(FaceDir::PosY)[0];
CHECK_EQ(top.x(), 0);
CHECK_EQ(top.y(), 0);
CHECK_EQ(top.z(), 0);
CHECK_EQ(top.width(), 8);
CHECK_EQ(top.height(), 4);
CHECK_EQ(top.tex(), 2);
}
TEST_CASE("voxel.world.selectVisible.face_and_range") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
Chunk *c = world->getOrCreateChunk(0, 0, 0);
c->fill(1);
world->remeshDirty();
const float eyeX = 40.f, eyeY = 16.f, eyeZ = 16.f;
float view[16], proj[16], vp[16];
lookAtRH(eyeX, eyeY, eyeZ, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, eyeX, eyeY, eyeZ, 100.f, true);
CHECK(world->getVisibleChunkCount() >= 1);
CHECK(world->getVisibleBatchCount() >= 1);
bool sawNegX = false;
bool sawPosX = false;
for (int i = 0; i < world->getVisibleBatchCount(); ++i) {
const DrawBatch &b = world->getVisibleBatch(i);
if (b.dir == FaceDir::NegX) sawNegX = true;
if (b.dir == FaceDir::PosX) sawPosX = true;
}
CHECK(sawPosX);
CHECK(!sawNegX);
world->selectVisible(vp, eyeX, eyeY, eyeZ, 1.f, true);
CHECK_EQ(world->getVisibleChunkCount(), 0);
CHECK_EQ(world->getVisibleBatchCount(), 0);
}
TEST_CASE("voxel.world.frustum_rejects_behind") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(10, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(0.f, 16.f, 16.f, -10.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(45.f * 3.14159265f / 180.f, 1.f, 0.1f, 50.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 0.f, 16.f, 16.f, 500.f, false);
CHECK_EQ(world->getVisibleChunkCount(), 0);
}
TEST_CASE("voxel.world.setVoxel_cross_chunk") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(33, 1, 2, 7);
CHECK_EQ(int(world->getVoxel(33, 1, 2)), 7);
CHECK(world->hasChunk(1, 0, 0));
Chunk *c = world->getChunk(1, 0, 0);
CHECK(c != nullptr);
CHECK_EQ(int(c->get(1, 1, 2)), 7);
}
TEST_CASE("voxel.decode.planes_extents_winding") {
// Winding, outward normal, tex and size for every face direction.
for (int d = 0; d < faceDirCount(); ++d) {
const FaceDir dir = FaceDir(d);
const PackedRect r = PackedRect::pack(2, 3, 4, 5, 7, 9);
const DecodedRect q = decodePackedRect(r, dir, 0.f, 0.f, 0.f, 16);
CHECK(decodedWindingMatchesNormal(q));
CHECK(decodedSecondTriangleMatchesNormal(q));
float nx, ny, nz;
faceNormal(dir, nx, ny, nz);
CHECK_EQ(q.normal[0], nx);
CHECK_EQ(q.normal[1], ny);
CHECK_EQ(q.normal[2], nz);
CHECK_EQ(q.tex, 9);
CHECK_EQ(q.width, 5.f);
CHECK_EQ(q.height, 7.f);
}
// Plane position for all six faces (outward side = coord + 1).
{
const PackedRect r = PackedRect::pack(3, 4, 5, 2, 3, 1);
struct PlaneCase {
FaceDir dir;
int axis;
float expect;
};
const PlaneCase planes[] = {
{FaceDir::PosX, 0, 4.f}, {FaceDir::NegX, 0, 3.f},
{FaceDir::PosY, 1, 5.f}, {FaceDir::NegY, 1, 4.f},
{FaceDir::PosZ, 2, 6.f}, {FaceDir::NegZ, 2, 5.f},
};
for (const auto &c : planes) {
auto q = decodePackedRect(r, c.dir, 0, 0, 0);
for (int i = 0; i < 4; ++i) CHECK_EQ(q.corners[i][c.axis], c.expect);
CHECK(decodedWindingMatchesNormal(q));
}
}
// Extents (width along the "secondary" axes) per direction.
{
const DecodedRect px = decodePackedRect(PackedRect::pack(4, 5, 6, 1, 1, 1),
FaceDir::PosX, 10.f, 20.f, 30.f, 8);
float minY = px.corners[0][1], maxY = px.corners[0][1];
float minZ = px.corners[0][2], maxZ = px.corners[0][2];
for (int i = 1; i < 4; ++i) {
minY = std::min(minY, px.corners[i][1]);
maxY = std::max(maxY, px.corners[i][1]);
minZ = std::min(minZ, px.corners[i][2]);
maxZ = std::max(maxZ, px.corners[i][2]);
}
CHECK_EQ(minY, 20.f + 5.f);
CHECK_EQ(maxY, 20.f + 6.f);
CHECK_EQ(minZ, 30.f + 6.f);
CHECK_EQ(maxZ, 30.f + 7.f);
}
{
const DecodedRect nx = decodePackedRect(PackedRect::pack(4, 5, 6, 2, 3, 1),
FaceDir::NegX, 0, 0, 0);
float minY = nx.corners[0][1], maxY = nx.corners[0][1];
float minZ = nx.corners[0][2], maxZ = nx.corners[0][2];
for (int i = 1; i < 4; ++i) {
minY = std::min(minY, nx.corners[i][1]);
maxY = std::max(maxY, nx.corners[i][1]);
minZ = std::min(minZ, nx.corners[i][2]);
maxZ = std::max(maxZ, nx.corners[i][2]);
}
CHECK_EQ(minY, 5.f);
CHECK_EQ(maxY, 8.f);
CHECK_EQ(minZ, 6.f);
CHECK_EQ(maxZ, 8.f);
}
{
const DecodedRect pz = decodePackedRect(PackedRect::pack(2, 3, 4, 5, 6, 1),
FaceDir::PosZ, 0, 0, 0);
float minX = pz.corners[0][0], maxX = pz.corners[0][0];
float minY = pz.corners[0][1], maxY = pz.corners[0][1];
for (int i = 1; i < 4; ++i) {
minX = std::min(minX, pz.corners[i][0]);
maxX = std::max(maxX, pz.corners[i][0]);
minY = std::min(minY, pz.corners[i][1]);
maxY = std::max(maxY, pz.corners[i][1]);
}
CHECK_EQ(minX, 2.f);
CHECK_EQ(maxX, 7.f);
CHECK_EQ(minY, 3.f);
CHECK_EQ(maxY, 9.f);
const DecodedRect ny = decodePackedRect(PackedRect::pack(2, 3, 4, 5, 6, 1),
FaceDir::NegY, 10, 20, 30);
for (int i = 0; i < 4; ++i) CHECK_EQ(ny.corners[i][1], 23.f);
CHECK(decodedWindingMatchesNormal(ny));
CHECK(decodedSecondTriangleMatchesNormal(ny));
}
{
const DecodedRect nz = decodePackedRect(PackedRect::pack(1, 2, 3, 4, 5, 6),
FaceDir::NegZ, 0, 0, 0);
float minX = nz.corners[0][0], maxX = nz.corners[0][0];
float minY = nz.corners[0][1], maxY = nz.corners[0][1];
for (int i = 1; i < 4; ++i) {
minX = std::min(minX, nz.corners[i][0]);
maxX = std::max(maxX, nz.corners[i][0]);
minY = std::min(minY, nz.corners[i][1]);
maxY = std::max(maxY, nz.corners[i][1]);
}
CHECK_EQ(minX, 1.f);
CHECK_EQ(maxX, 5.f);
CHECK_EQ(minY, 2.f);
CHECK_EQ(maxY, 7.f);
}
// Max 32×32 extent.
{
const DecodedRect mx = decodePackedRect(PackedRect::pack(0, 0, 0, 32, 32, 1),
FaceDir::PosY, 0, 0, 0);
CHECK(decodedWindingMatchesNormal(mx));
for (int i = 0; i < 4; ++i) CHECK_EQ(mx.corners[i][1], 1.f);
float minX = mx.corners[0][0], maxX = mx.corners[0][0];
float minZ = mx.corners[0][2], maxZ = mx.corners[0][2];
for (int i = 1; i < 4; ++i) {
minX = std::min(minX, mx.corners[i][0]);
maxX = std::max(maxX, mx.corners[i][0]);
minZ = std::min(minZ, mx.corners[i][2]);
maxZ = std::max(maxZ, mx.corners[i][2]);
}
CHECK_EQ(minX, 0.f);
CHECK_EQ(maxX, 32.f);
CHECK_EQ(minZ, 0.f);
CHECK_EQ(maxZ, 32.f);
}
}
TEST_CASE("voxel.decode.uv_tiling") {
// tex 5 with 4 tiles/row → tile col=1,row=1 (atlas lookup).
{
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, 5);
const DecodedRect q = decodePackedRect(r, FaceDir::PosZ, 0, 0, 0, 4);
const float tile = 0.25f;
CHECK(std::fabs(q.uv[0][0] - (1.f * tile)) < 1e-5f);
CHECK(std::fabs(q.uv[0][1] - (1.f * tile)) < 1e-5f);
CHECK(std::fabs(q.uv[2][0] - (2.f * tile)) < 1e-5f);
CHECK(std::fabs(q.uv[2][1] - (2.f * tile)) < 1e-5f);
}
// One tile per row → v spans [tex, tex+1].
{
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, 3);
const DecodedRect q = decodePackedRect(r, FaceDir::PosZ, 0, 0, 0, 1);
CHECK(std::fabs(q.uv[0][0] - 0.f) < 1e-5f);
CHECK(std::fabs(q.uv[0][1] - 3.f) < 1e-5f);
CHECK(std::fabs(q.uv[2][0] - 1.f) < 1e-5f);
CHECK(std::fabs(q.uv[2][1] - 4.f) < 1e-5f);
}
// All four corners monotonic inside the tile.
{
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, 5);
const DecodedRect q = decodePackedRect(r, FaceDir::PosY, 0, 0, 0, 4);
CHECK_EQ(q.uv[0][0], 0.25f);
CHECK_EQ(q.uv[0][1], 0.25f);
CHECK_EQ(q.uv[1][0], 0.5f);
CHECK_EQ(q.uv[1][1], 0.25f);
CHECK_EQ(q.uv[2][0], 0.5f);
CHECK_EQ(q.uv[2][1], 0.5f);
CHECK_EQ(q.uv[3][0], 0.25f);
CHECK_EQ(q.uv[3][1], 0.5f);
}
// Sweep many tex indices across the atlas grid.
{
const int tiles = 8;
for (int tex = 0; tex < 32; ++tex) {
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, tex);
const DecodedRect q = decodePackedRect(r, FaceDir::PosY, 0, 0, 0, tiles);
const float tile = 1.f / float(tiles);
const float col = float(tex % tiles);
const float row = float(tex / tiles);
CHECK(std::fabs(q.uv[0][0] - col * tile) < 1e-5f);
CHECK(std::fabs(q.uv[0][1] - row * tile) < 1e-5f);
CHECK(std::fabs(q.uv[2][0] - (col + 1.f) * tile) < 1e-5f);
CHECK(std::fabs(q.uv[2][1] - (row + 1.f) * tile) < 1e-5f);
CHECK_EQ(q.tex, tex);
}
}
// Max tex id with 16 tiles/row.
{
const PackedRect r = PackedRect::pack(0, 0, 0, 2, 2, 127);
const DecodedRect q = decodePackedRect(r, FaceDir::PosX, 0, 0, 0, 16);
CHECK(std::fabs(q.uv[0][0] - (15.f / 16.f)) < 1e-5f);
CHECK(std::fabs(q.uv[0][1] - (7.f / 16.f)) < 1e-5f);
CHECK(std::fabs(q.uv[2][0] - 1.f) < 1e-5f);
CHECK(std::fabs(q.uv[2][1] - (8.f / 16.f)) < 1e-5f);
}
// tilesPerRow < 1 clamps to 1.
{
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, 0);
const DecodedRect q = decodePackedRect(r, FaceDir::PosY, 0, 0, 0, -3);
CHECK_EQ(q.uv[0][0], 0.f);
CHECK_EQ(q.uv[1][0], 1.f);
}
}
TEST_CASE("voxel.greedy.internal_face_hidden") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->set(1, 1, 1, 1);
chunk->set(2, 1, 1, 1);
chunk->remesh();
int posXAt1 = 0, negXAt2 = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosX)) {
if (r.x() == 1 && r.y() == 1 && r.z() == 1) ++posXAt1;
}
for (const auto &r : chunk->faceRects(FaceDir::NegX)) {
if (r.x() == 2 && r.y() == 1 && r.z() == 1) ++negXAt2;
}
CHECK_EQ(posXAt1, 0);
CHECK_EQ(negXAt2, 0);
CHECK(chunk->totalRectCount() <= 10);
CHECK(chunk->totalRectCount() >= 6);
}
TEST_CASE("voxel.greedy.texture_split_blocks_merge") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int x = 0; x < 4; ++x) chunk->set(x, 0, 0, x < 2 ? 1 : 2);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 2);
const auto &faces = chunk->faceRects(FaceDir::PosY);
CHECK_EQ(faces[0].width() + faces[1].width(), 4);
CHECK(faces[0].tex() != faces[1].tex());
}
TEST_CASE("voxel.greedy.hollow_shell_counts") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->fill(1);
for (int z = 1; z < 31; ++z)
for (int y = 1; y < 31; ++y)
for (int x = 1; x < 31; ++x) chunk->set(x, y, z, 0);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosX), 2);
CHECK_EQ(chunk->faceRectCount(FaceDir::NegX), 2);
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 2);
CHECK_EQ(chunk->faceRectCount(FaceDir::NegY), 2);
CHECK_EQ(chunk->faceRectCount(FaceDir::PosZ), 2);
CHECK_EQ(chunk->faceRectCount(FaceDir::NegZ), 2);
CHECK_EQ(chunk->totalRectCount(), 12);
}
TEST_CASE("voxel.world.face_cull_all_six_axes") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->remeshDirty();
struct Case {
float ex, ey, ez;
FaceDir keep;
FaceDir drop;
};
const Case cases[] = {
{40.f, 16.f, 16.f, FaceDir::PosX, FaceDir::NegX},
{-8.f, 16.f, 16.f, FaceDir::NegX, FaceDir::PosX},
{16.f, 40.f, 16.f, FaceDir::PosY, FaceDir::NegY},
{16.f, -8.f, 16.f, FaceDir::NegY, FaceDir::PosY},
{16.f, 16.f, 40.f, FaceDir::PosZ, FaceDir::NegZ},
{16.f, 16.f, -8.f, FaceDir::NegZ, FaceDir::PosZ},
};
for (const auto &c : cases) {
float view[16], proj[16], vp[16];
if (std::fabs(c.ey - 16.f) > 20.f)
lookAtRH(c.ex, c.ey, c.ez, 16.f, 16.f, 16.f, 0, 0, 1, view);
else
lookAtRH(c.ex, c.ey, c.ez, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, c.ex, c.ey, c.ez, 200.f, true);
bool sawKeep = false, sawDrop = false;
for (int i = 0; i < world->getVisibleBatchCount(); ++i) {
const FaceDir d = world->getVisibleBatch(i).dir;
if (d == c.keep) sawKeep = true;
if (d == c.drop) sawDrop = true;
}
CHECK(sawKeep);
CHECK(!sawDrop);
}
}
TEST_CASE("voxel.world.perspective_face_cull_keeps_far_half_faces") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
// The camera is on the +X side of the chunk centre, but on the -X side of this
// voxel. Its -X face is therefore front-facing and must not be dropped with the
// whole direction batch.
world->getOrCreateChunk(0, 0, 0)->set(31, 16, 16, 1);
world->remeshDirty();
const float eyeX = 20.f, eyeY = 16.5f, eyeZ = 16.5f;
float view[16], proj[16], vp[16];
lookAtRH(eyeX, eyeY, eyeZ, 31.5f, 16.5f, 16.5f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 100.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, eyeX, eyeY, eyeZ, 100.f, true);
bool sawNegX = false;
for (int i = 0; i < world->getVisibleBatchCount(); ++i) {
if (world->getVisibleBatch(i).dir == FaceDir::NegX) sawNegX = true;
}
CHECK(sawNegX);
}
TEST_CASE("voxel.world.visible_batches_decode_on_chunk_origin") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
Chunk *c = world->getOrCreateChunk(1, 2, 3);
c->set(0, 0, 0, 4);
world->remeshDirty();
float view[16], proj[16], vp[16];
const float eyeX = 32.f + 40.f, eyeY = 64.f + 0.5f, eyeZ = 96.f + 0.5f;
lookAtRH(eyeX, eyeY, eyeZ, 32.5f, 64.5f, 96.5f, 0, 1, 0, view);
perspectiveRH_ZO(50.f * 3.14159265f / 180.f, 1.f, 0.1f, 500.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, eyeX, eyeY, eyeZ, 500.f, true);
CHECK(world->getVisibleBatchCount() >= 1);
CHECK(world->getVisibleRectCount() >= 1);
for (int i = 0; i < world->getVisibleBatchCount(); ++i) {
const DrawBatch &b = world->getVisibleBatch(i);
CHECK(b.chunk != nullptr);
CHECK(b.packed != nullptr);
CHECK(b.count > 0);
CHECK_EQ(b.chunk->originX(), 32.f);
CHECK_EQ(b.chunk->originY(), 64.f);
CHECK_EQ(b.chunk->originZ(), 96.f);
for (int j = 0; j < b.count; ++j) {
const PackedRect r{b.packed[j]};
const DecodedRect q =
decodePackedRect(r, b.dir, b.chunk->originX(), b.chunk->originY(), b.chunk->originZ());
CHECK(decodedWindingMatchesNormal(q));
for (int k = 0; k < 4; ++k) {
CHECK(q.corners[k][0] >= 32.f - 0.01f);
CHECK(q.corners[k][0] <= 32.f + 33.f);
CHECK(q.corners[k][1] >= 64.f - 0.01f);
CHECK(q.corners[k][1] <= 64.f + 33.f);
CHECK(q.corners[k][2] >= 96.f - 0.01f);
CHECK(q.corners[k][2] <= 96.f + 33.f);
}
}
}
// Rect count equals the sum of batch counts, and face culling keeps
// the visible batch set small.
int sum = 0;
for (int i = 0; i < world->getVisibleBatchCount(); ++i) sum += world->getVisibleBatch(i).count;
CHECK_EQ(sum, world->getVisibleRectCount());
CHECK(world->getVisibleBatchCount() >= 1);
CHECK(world->getVisibleBatchCount() <= 5);
}
TEST_CASE("voxel.world.multi_chunk_range_filter") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->getOrCreateChunk(5, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(16.f, 16.f, 80.f, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 400.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 16.f, 16.f, 80.f, 80.f, false);
CHECK_EQ(world->getVisibleChunkCount(), 1);
int cx = -1, cy = -1, cz = -1;
world->getVisibleChunkCoord(0, cx, cy, cz);
CHECK_EQ(cx, 0);
CHECK_EQ(cy, 0);
CHECK_EQ(cz, 0);
}
TEST_CASE("voxel.world.face_cull_off_keeps_both_sides") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(40.f, 16.f, 16.f, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 40.f, 16.f, 16.f, 200.f, false);
bool sawPosX = false, sawNegX = false;
for (int i = 0; i < world->getVisibleBatchCount(); ++i) {
if (world->getVisibleBatch(i).dir == FaceDir::PosX) sawPosX = true;
if (world->getVisibleBatch(i).dir == FaceDir::NegX) sawNegX = true;
}
CHECK(sawPosX);
CHECK(sawNegX);
CHECK_EQ(world->getVisibleRectCount(), 6);
}
TEST_CASE("voxel.frustum.intersects") {
// AABB in front of the camera intersects; out-of-frustum AABBs do not.
{
float view[16], proj[16], vp[16];
lookAtRH(0, 0, 5, 0, 0, 0, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 100.f, proj);
mul4(proj, view, vp);
const Frustum f = Frustum::fromViewProjColumnMajor(vp);
CHECK(f.intersectsAABB(-1, -1, -1, 1, 1, 1));
CHECK(!f.intersectsAABB(50, 50, 50, 60, 60, 60));
CHECK(!f.intersectsAABB(-1, -1, 10, 1, 1, 20));
}
// Huge AABB containing the camera.
{
float view[16], proj[16], vp[16];
lookAtRH(0, 0, 0, 0, 0, -1, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 100.f, proj);
mul4(proj, view, vp);
const Frustum f = Frustum::fromViewProjColumnMajor(vp);
CHECK(f.intersectsAABB(-50, -50, -50, 50, 50, 50));
}
// Far to the left of the frustum.
{
float view[16], proj[16], vp[16];
lookAtRH(0.f, 0.f, 10.f, 0.f, 0.f, 0.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 100.f, proj);
mul4(proj, view, vp);
const Frustum f = Frustum::fromViewProjColumnMajor(vp);
CHECK(!f.intersectsAABB(-1000.f, -1.f, -1.f, -900.f, 1.f, 1.f));
CHECK(f.intersectsAABB(-1.f, -1.f, -1.f, 1.f, 1.f, 1.f));
}
// Above the frustum and behind the camera.
{
float view[16], proj[16], vp[16];
lookAtRH(0.f, 0.f, 5.f, 0.f, 0.f, 0.f, 0, 1, 0, view);
perspectiveRH_ZO(50.f * 3.14159265f / 180.f, 1.f, 0.5f, 20.f, proj);
mul4(proj, view, vp);
const Frustum f = Frustum::fromViewProjColumnMajor(vp);
CHECK(!f.intersectsAABB(-1.f, 500.f, -1.f, 1.f, 501.f, 1.f));
CHECK(!f.intersectsAABB(-1.f, -1.f, -500.f, 1.f, 1.f, -400.f));
CHECK(f.intersectsAABB(-0.5f, -0.5f, -0.5f, 0.5f, 0.5f, 0.5f));
}
// Tiny AABB on axis, and far off-axis rejection.
{
float view[16], proj[16], vp[16];
lookAtRH(0.f, 0.f, 3.f, 0.f, 0.f, 0.f, 0, 1, 0, view);
perspectiveRH_ZO(45.f * 3.14159265f / 180.f, 1.f, 0.1f, 50.f, proj);
mul4(proj, view, vp);
const Frustum f = Frustum::fromViewProjColumnMajor(vp);
CHECK(f.intersectsAABB(-0.01f, -0.01f, -0.01f, 0.01f, 0.01f, 0.01f));
CHECK(!f.intersectsAABB(100.f, -0.01f, -0.01f, 101.f, 0.01f, 0.01f));
}
}
TEST_CASE("voxel.chunk.empty_and_dirty_flags") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
CHECK(chunk->isDirty());
chunk->remesh();
CHECK(!chunk->isDirty());
CHECK_EQ(chunk->totalRectCount(), 0);
chunk->set(0, 0, 0, 1);
CHECK(chunk->isDirty());
chunk->ensureMeshed();
CHECK(!chunk->isDirty());
CHECK_EQ(chunk->totalRectCount(), 6);
chunk->clear();
CHECK(chunk->isDirty());
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 0);
// ensureMeshed is idempotent.
chunk->set(0, 0, 0, 1);
CHECK(chunk->isDirty());
chunk->ensureMeshed();
CHECK(!chunk->isDirty());
const int n = chunk->totalRectCount();
chunk->ensureMeshed();
CHECK(!chunk->isDirty());
CHECK_EQ(chunk->totalRectCount(), n);
// fill() dirties; clear() dirties; both resolve on remesh.
std::unique_ptr<Chunk> other(new Chunk(1, 2, 3));
other->remesh();
CHECK(!other->isDirty());
other->fill(9);
CHECK(other->isDirty());
other->remesh();
CHECK(!other->isDirty());
other->clear();
CHECK(other->isDirty());
other->ensureMeshed();
CHECK_EQ(other->totalRectCount(), 0);
}
TEST_CASE("voxel.chunk.full_layer_merges_to_one") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 32; ++z)
for (int x = 0; x < 32; ++x) chunk->set(x, 5, z, 3);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
CHECK_EQ(chunk->faceRectCount(FaceDir::NegY), 1);
const PackedRect &top = chunk->faceRects(FaceDir::PosY)[0];
CHECK_EQ(top.width(), 32);
CHECK_EQ(top.height(), 32);
CHECK_EQ(top.y(), 5);
CHECK_EQ(top.tex(), 3);
}
TEST_CASE("voxel.greedy.column_merges_on_side_face") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
// Vertical column at x=0 → +X face should be one 1×8 rect (w along Z=1, h along Y=8).
for (int y = 0; y < 8; ++y) chunk->set(0, y, 0, 2);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosX), 1);
const PackedRect &r = chunk->faceRects(FaceDir::PosX)[0];
CHECK_EQ(r.x(), 0);
CHECK_EQ(r.z(), 0);
CHECK_EQ(r.y(), 0);
CHECK_EQ(r.width(), 1);
CHECK_EQ(r.height(), 8);
}
TEST_CASE("voxel.greedy.L_shape_top_is_two_rects_or_merged_strip") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int x = 0; x < 4; ++x) chunk->set(x, 0, 0, 1);
for (int z = 1; z < 4; ++z) chunk->set(0, 0, z, 1);
chunk->remesh();
// Top faces: greedy typically emits 2 rects (row + stub) or similar; never 7.
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 2);
CHECK(chunk->faceRectCount(FaceDir::PosY) <= 4);
int area = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosY)) area += r.width() * r.height();
CHECK_EQ(area, 7);
}
TEST_CASE("voxel.world.negative_coords_and_remove") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(-1, -1, -1, 5);
CHECK_EQ(int(world->getVoxel(-1, -1, -1)), 5);
CHECK(world->hasChunk(-1, -1, -1));
Chunk *c = world->getChunk(-1, -1, -1);
REQUIRE(c != nullptr);
// Local coords within that chunk: -1 - (-1)*32 = 31
CHECK_EQ(int(c->get(31, 31, 31)), 5);
world->remeshDirty();
CHECK(c->totalRectCount() > 0);
world->removeChunk(-1, -1, -1);
CHECK(!world->hasChunk(-1, -1, -1));
CHECK_EQ(int(world->getVoxel(-1, -1, -1)), 0);
CHECK_EQ(world->getChunkCount(), 0);
// Removing a chunk makes voxel reads return air for that region.
world->setVoxel(1, 1, 1, 2);
CHECK(world->hasChunk(0, 0, 0));
world->removeChunk(0, 0, 0);
CHECK(!world->hasChunk(0, 0, 0));
CHECK_EQ(int(world->getVoxel(1, 1, 1)), 0);
CHECK_EQ(world->getChunkCount(), 0);
}
TEST_CASE("voxel.world.remeshDirty_counts_only_dirty") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->getOrCreateChunk(1, 0, 0)->fill(1);
int n = world->remeshDirty();
CHECK_EQ(n, 2);
n = world->remeshDirty();
CHECK_EQ(n, 0);
n = world->remeshDirty();
CHECK_EQ(n, 0);
world->setVoxel(1, 1, 1, 2);
n = world->remeshDirty();
CHECK_EQ(n, 1);
n = world->remeshDirty();
CHECK_EQ(n, 0);
}
TEST_CASE("voxel.world.viewRange_nonpositive_disables_distance") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(20, 0, 0)->fill(1); // center ~656
world->remeshDirty();
float view[16], proj[16], vp[16];
// Look toward the far chunk so frustum can include it.
lookAtRH(0.f, 16.f, 16.f, 640.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 2000.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 0.f, 16.f, 16.f, 10.f, false);
CHECK_EQ(world->getVisibleChunkCount(), 0);
world->selectVisible(vp, 0.f, 16.f, 16.f, 0.f, false); // disable range
CHECK_EQ(world->getVisibleChunkCount(), 1);
world->selectVisible(vp, 0.f, 16.f, 16.f, -1.f, false);
CHECK_EQ(world->getVisibleChunkCount(), 1);
}
TEST_CASE("voxel.world.null_viewProj_clears_visible") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->remeshDirty();
float vp[16];
std::memset(vp, 0, sizeof(vp));
vp[0] = vp[5] = vp[10] = vp[15] = 1.f;
world->selectVisible(vp, 16.f, 16.f, 80.f, 200.f, false);
// May or may not be visible with identity-ish matrix; force clear:
world->selectVisible(nullptr, 0, 0, 0, 100.f, true);
CHECK_EQ(world->getVisibleBatchCount(), 0);
CHECK_EQ(world->getVisibleChunkCount(), 0);
CHECK_EQ(world->getVisibleRectCount(), 0);
}
TEST_CASE("voxel.world.getVisibleChunkCoord_oob") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
int cx = 9, cy = 9, cz = 9;
world->getVisibleChunkCoord(-1, cx, cy, cz);
CHECK_EQ(cx, 0);
CHECK_EQ(cy, 0);
CHECK_EQ(cz, 0);
world->getVisibleChunkCoord(0, cx, cy, cz);
CHECK_EQ(cx, 0);
}
TEST_CASE("voxel.world.clear_resets_everything") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(16, 16, 80, 16, 16, 16, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 16, 16, 80, 200, true);
CHECK(world->getVisibleBatchCount() > 0);
world->clear();
CHECK_EQ(world->getChunkCount(), 0);
CHECK_EQ(world->getVisibleBatchCount(), 0);
CHECK_EQ(world->getVisibleChunkCount(), 0);
}
TEST_CASE("voxel.world.drawVisible_null_gfx_safe") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(40, 16, 16, 16, 16, 16, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 40, 16, 16, 200, true);
world->drawVisible(nullptr, nullptr, 16); // must not crash
CHECK(world->getVisibleBatchCount() > 0);
}
TEST_CASE("voxel.decode.chunk_origin_offsets") {
// Positive origin shifts every corner.
{
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, 1);
const DecodedRect q = decodePackedRect(r, FaceDir::PosZ, 100.f, 200.f, 300.f);
for (int i = 0; i < 4; ++i) {
CHECK(q.corners[i][0] >= 100.f);
CHECK(q.corners[i][0] <= 101.f);
CHECK(q.corners[i][1] >= 200.f);
CHECK(q.corners[i][1] <= 201.f);
CHECK_EQ(q.corners[i][2], 301.f);
}
}
// Negative origin (negative chunk coords).
{
const PackedRect r = PackedRect::pack(0, 0, 0, 1, 1, 1);
const DecodedRect q = decodePackedRect(r, FaceDir::PosX, -32.f, -64.f, -96.f);
for (int i = 0; i < 4; ++i) {
CHECK_EQ(q.corners[i][0], -31.f); // originX + x+1
}
CHECK_EQ(q.corners[0][1], -64.f);
CHECK_EQ(q.corners[0][2], -96.f);
}
}
TEST_CASE("voxel.greedy.checkerboard_same_tex_still_many_faces") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 8; ++z)
for (int x = 0; x < 8; ++x)
if (((x + z) & 1) == 0) chunk->set(x, 0, z, 1);
chunk->remesh();
// 32 filled cells on y=0; top faces cannot fully merge into one.
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 8);
int area = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosY)) area += r.width() * r.height();
CHECK_EQ(area, 32);
}
TEST_CASE("voxel.world.edit_then_remesh_updates_batches") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(0, 0, 0, 1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(0.5f, 0.5f, 8.f, 0.5f, 0.5f, 0.5f, 0, 1, 0, view);
perspectiveRH_ZO(50.f * 3.14159265f / 180.f, 1.f, 0.1f, 100.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 0.5f, 0.5f, 8.f, 100.f, true);
const int before = world->getVisibleRectCount();
CHECK(before >= 1);
// Fill a 4³ block — more surface area / larger merged faces still ≥ before.
for (int z = 0; z < 4; ++z)
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x) world->setVoxel(x, y, z, 1);
world->remeshDirty();
world->selectVisible(vp, 2.f, 2.f, 12.f, 100.f, true);
CHECK(world->getVisibleRectCount() >= 1);
// Solid 4³ has 6 faces; single voxel also 6 — merged counts equal, but batches exist.
CHECK(world->getVisibleBatchCount() >= 1);
(void)before;
}
TEST_CASE("voxel.chunk.oob_set_ignored") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->set(-1, 0, 0, 9);
chunk->set(0, -1, 0, 9);
chunk->set(0, 0, -1, 9);
chunk->set(32, 0, 0, 9);
chunk->set(0, 32, 0, 9);
chunk->set(0, 0, 32, 9);
CHECK_EQ(int(chunk->get(-1, 0, 0)), 0);
CHECK_EQ(int(chunk->get(32, 0, 0)), 0);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 0);
}
TEST_CASE("voxel.chunk.facePackedData_null_when_empty") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->remesh();
for (int i = 0; i < faceDirCount(); ++i) {
CHECK(chunk->facePackedData(FaceDir(i)) == nullptr);
CHECK_EQ(chunk->faceRectCount(FaceDir(i)), 0);
}
chunk->set(1, 1, 1, 1);
chunk->remesh();
for (int i = 0; i < faceDirCount(); ++i) {
CHECK(chunk->facePackedData(FaceDir(i)) != nullptr);
CHECK_EQ(chunk->faceRectCount(FaceDir(i)), 1);
}
}
TEST_CASE("voxel.chunk.border_ring_merges") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
// Only the outer ring on y=0 (hollow square).
for (int x = 0; x < 8; ++x) {
chunk->set(x, 0, 0, 1);
chunk->set(x, 0, 7, 1);
}
for (int z = 1; z < 7; ++z) {
chunk->set(0, 0, z, 1);
chunk->set(7, 0, z, 1);
}
chunk->remesh();
int area = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosY)) area += r.width() * r.height();
CHECK_EQ(area, 8 * 2 + 6 * 2); // 28
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 4);
}
TEST_CASE("voxel.world.many_chunks_visibility_subset") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
for (int cx = 0; cx < 4; ++cx)
for (int cz = 0; cz < 4; ++cz) world->getOrCreateChunk(cx, 0, cz)->fill(1);
CHECK_EQ(world->getChunkCount(), 16);
world->remeshDirty();
float view[16], proj[16], vp[16];
// Eye near origin chunk, short range → only nearby chunks.
lookAtRH(16.f, 16.f, 80.f, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(50.f * 3.14159265f / 180.f, 1.f, 0.1f, 500.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 16.f, 16.f, 80.f, 50.f, false);
CHECK(world->getVisibleChunkCount() >= 1);
CHECK(world->getVisibleChunkCount() < 16);
}
TEST_CASE("voxel.world.remove_updates_visibility") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->getOrCreateChunk(1, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(32.f, 16.f, 100.f, 32.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 400.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 32.f, 16.f, 100.f, 200.f, false);
const int before = world->getVisibleChunkCount();
CHECK(before >= 1);
world->removeChunk(0, 0, 0);
world->selectVisible(vp, 32.f, 16.f, 100.f, 200.f, false);
CHECK(world->getVisibleChunkCount() <= before);
CHECK(world->hasChunk(1, 0, 0));
CHECK(!world->hasChunk(0, 0, 0));
}
TEST_CASE("voxel.world.getOrCreate_idempotent") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
Chunk *a = world->getOrCreateChunk(3, 4, 5);
Chunk *b = world->getOrCreateChunk(3, 4, 5);
CHECK_EQ(a, b);
CHECK_EQ(world->getChunkCount(), 1);
CHECK_EQ(a->cx(), 3);
CHECK_EQ(a->cy(), 4);
CHECK_EQ(a->cz(), 5);
}
TEST_CASE("voxel.world.air_get_missing_chunk") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
CHECK_EQ(int(world->getVoxel(0, 0, 0)), 0);
CHECK_EQ(int(world->getVoxel(1000, -50, 7)), 0);
CHECK(!world->hasChunk(0, 0, 0));
}
TEST_CASE("voxel.registry.add_and_lookup") {
CubeTypeRegistry reg;
CubeType grass;
grass.name = "grass";
grass.faceTex[0] = 1;
grass.faceTex[1] = 1;
grass.faceTex[2] = 2;
grass.faceTex[3] = 3;
grass.faceTex[4] = 1;
grass.faceTex[5] = 1;
const uint8_t gid = reg.add(grass);
CHECK(gid >= 1);
CHECK_EQ(reg.count(), 1);
CHECK_EQ(reg.variantCount(), 1);
const CubeType *t = reg.find("grass");
REQUIRE(t != nullptr);
CHECK_EQ(t->id, gid);
CHECK_EQ(int(t->faceTex[2]), 2);
CHECK(!t->directional);
CHECK(reg.find("nope") == nullptr);
CHECK(reg.find(0) == nullptr);
CHECK(reg.find(uint8_t(200)) == nullptr);
reg.clear();
CHECK_EQ(reg.count(), 0);
CHECK_EQ(reg.variantCount(), 0);
// faceTex 0 is a valid atlas tile (top face keeps tex 0 after meshing).
{
CubeTypeRegistry reg2;
CubeType t;
t.name = "zeroTop";
t.faceTex[2] = 0;
for (int i = 0; i < 6; ++i)
if (i != 2) t.faceTex[i] = 1;
reg2.add(t);
VoxelWorld world(reg2);
world.setVoxelByName(0, 0, 0, "zeroTop");
world.remeshDirty();
Chunk *c = world.getChunk(0, 0, 0);
REQUIRE(c != nullptr);
CHECK_EQ(c->faceRects(FaceDir::PosY)[0].tex(), 0);
CHECK_EQ(c->faceRectCount(FaceDir::PosY), 1);
}
// Empty registry keeps the voxel id as the texture id.
{
VoxelWorld world2;
world2.setVoxel(1, 2, 3, 7);
world2.remeshDirty();
Chunk *c2 = world2.getChunk(0, 0, 0);
REQUIRE(c2 != nullptr);
CHECK_EQ(c2->faceRects(FaceDir::PosY)[0].tex(), 7);
CHECK_EQ(world2.getCubeTypeName(1, 2, 3), std::string(""));
CHECK_EQ(int(world2.getCubeTypeTex(1, 2, 3, "posY")), 7);
}
}
TEST_CASE("voxel.greedy.two_separated_slabs") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int x = 0; x < 4; ++x) chunk->set(x, 0, 0, 1);
for (int x = 0; x < 4; ++x) chunk->set(x, 0, 5, 1);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 2);
for (const auto &r : chunk->faceRects(FaceDir::PosY)) {
CHECK_EQ(r.width(), 4);
CHECK_EQ(r.height(), 1);
}
}
TEST_CASE("voxel.world.selectVisible_empty_world") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
float vp[16];
std::memset(vp, 0, sizeof(vp));
vp[0] = vp[5] = vp[10] = vp[15] = 1.f;
world->selectVisible(vp, 0, 0, 5, 100, true);
CHECK_EQ(world->getVisibleBatchCount(), 0);
CHECK_EQ(world->getVisibleRectCount(), 0);
CHECK_EQ(world->getChunkCount(), 0);
}
TEST_CASE("voxel.chunk.rawVoxels_matches_get") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->set(2, 3, 4, 11);
chunk->set(10, 0, 0, 22);
const uint8_t *raw = chunk->rawVoxels();
CHECK_EQ(int(raw[2 + 3 * 32 + 4 * 32 * 32]), 11);
CHECK_EQ(int(raw[10]), 22);
CHECK_EQ(int(chunk->get(2, 3, 4)), 11);
}
TEST_CASE("voxel.world.cross_chunk_seam_culled") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(31, 0, 0, 1);
world->setVoxel(32, 0, 0, 1);
CHECK(world->hasChunk(0, 0, 0));
CHECK(world->hasChunk(1, 0, 0));
world->remeshDirty();
// Solid neighbor on the other side of the seam → both boundary faces culled.
CHECK_EQ(world->getChunk(0, 0, 0)->faceRectCount(FaceDir::PosX), 0);
CHECK_EQ(world->getChunk(1, 0, 0)->faceRectCount(FaceDir::NegX), 0);
CHECK(world->getChunk(0, 0, 0)->totalRectCount() > 0); // other faces remain
// Breaking the neighbor open again exposes the seam face after remesh.
world->setVoxel(32, 0, 0, 0);
world->remeshDirty();
CHECK(world->getChunk(0, 0, 0)->faceRectCount(FaceDir::PosX) >= 1);
CHECK_EQ(world->getChunk(1, 0, 0)->totalRectCount(), 0); // empty chunk
}
TEST_CASE("voxel.mesher.api_behavior") {
uint8_t voxels[32 * 32 * 32];
std::memset(voxels, 0, sizeof(voxels));
voxels[0] = 1;
// meshFace fills only the requested direction and appends (does not clear).
std::vector<PackedRect> out;
GreedyMesher::meshFace(voxels, FaceDir::PosY, out);
CHECK_EQ(int(out.size()), 1);
CHECK_EQ(out[0].tex(), 1);
GreedyMesher::meshFace(voxels, FaceDir::PosY, out);
CHECK_EQ(int(out.size()), 2);
out.clear();
GreedyMesher::meshFace(voxels, FaceDir::NegZ, out);
CHECK_EQ(int(out.size()), 1);
// meshChunk clears each face list, then emits one rect per face.
std::vector<PackedRect> faces[6];
for (int i = 0; i < 6; ++i) faces[i].push_back(PackedRect::pack(0, 0, 0, 1, 1, 99));
GreedyMesher::meshChunk(voxels, faces);
for (int i = 0; i < 6; ++i) {
CHECK_EQ(int(faces[i].size()), 1);
CHECK_EQ(faces[i][0].tex(), 1);
}
// Distinct textures stay distinct after a re-mesh.
voxels[0] = 3;
voxels[1] = 11;
voxels[2] = 22;
GreedyMesher::meshChunk(voxels, faces);
const auto &top = faces[int(FaceDir::PosY)];
CHECK_EQ(int(top.size()), 3);
bool saw3 = false, saw11 = false, saw22 = false;
for (const auto &r : top) {
if (r.tex() == 3) saw3 = true;
if (r.tex() == 11) saw11 = true;
if (r.tex() == 22) saw22 = true;
}
CHECK(saw3);
CHECK(saw11);
CHECK(saw22);
}
TEST_CASE("voxel.greedy.remesh_replaces_previous_faces") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->fill(1);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 6);
chunk->clear();
chunk->set(0, 0, 0, 2);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 6);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].tex(), 2);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].width(), 1);
}
TEST_CASE("voxel.greedy.staircase_no_full_merge") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int i = 0; i < 6; ++i) chunk->set(i, i, 0, 1);
chunk->remesh();
// Each step has its own top face.
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 6);
for (const auto &r : chunk->faceRects(FaceDir::PosY)) {
CHECK_EQ(r.width(), 1);
CHECK_EQ(r.height(), 1);
}
}
TEST_CASE("voxel.greedy.solid_3x3x3") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 3; ++z)
for (int y = 0; y < 3; ++y)
for (int x = 0; x < 3; ++x) chunk->set(x, y, z, 5);
chunk->remesh();
// Six outer faces, each a single 3×3 rect.
for (int i = 0; i < faceDirCount(); ++i) {
CHECK_EQ(chunk->faceRectCount(FaceDir(i)), 1);
CHECK_EQ(chunk->faceRects(FaceDir(i))[0].width(), 3);
CHECK_EQ(chunk->faceRects(FaceDir(i))[0].height(), 3);
CHECK_EQ(chunk->faceRects(FaceDir(i))[0].tex(), 5);
}
}
TEST_CASE("voxel.greedy.carve_center_increases_faces") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 3; ++z)
for (int y = 0; y < 3; ++y)
for (int x = 0; x < 3; ++x) chunk->set(x, y, z, 1);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 6);
chunk->set(1, 1, 1, 0); // carve center
chunk->remesh();
// Outer 6 remain + inner cavity exposes 6 more faces.
CHECK_EQ(chunk->totalRectCount(), 12);
}
TEST_CASE("voxel.greedy.stacked_textures_split_side_merge") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int y = 0; y < 4; ++y) chunk->set(0, y, 0, (y < 2) ? 1 : 2);
chunk->remesh();
// +X side: two rects (tex split), not one 1×4.
CHECK_EQ(chunk->faceRectCount(FaceDir::PosX), 2);
int hSum = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosX)) {
CHECK_EQ(r.width(), 1);
hSum += r.height();
}
CHECK_EQ(hSum, 4);
}
TEST_CASE("voxel.chunk.worldAABB_matches_origin") {
std::unique_ptr<Chunk> chunk(new Chunk(2, -1, 3));
float minX, minY, minZ, maxX, maxY, maxZ;
chunk->worldAABB(minX, minY, minZ, maxX, maxY, maxZ);
CHECK_EQ(minX, 64.f);
CHECK_EQ(minY, -32.f);
CHECK_EQ(minZ, 96.f);
CHECK_EQ(maxX, 96.f);
CHECK_EQ(maxY, 0.f);
CHECK_EQ(maxZ, 128.f);
CHECK_EQ(chunk->originX(), 64.f);
CHECK_EQ(chunk->originY(), -32.f);
CHECK_EQ(chunk->originZ(), 96.f);
}
TEST_CASE("voxel.world.overwrite_tex_updates_packed") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(1, 1, 1, 3);
world->remeshDirty();
Chunk *c = world->getChunk(0, 0, 0);
REQUIRE(c != nullptr);
CHECK_EQ(c->faceRects(FaceDir::PosY)[0].tex(), 3);
world->setVoxel(1, 1, 1, 9);
world->remeshDirty();
CHECK_EQ(c->faceRects(FaceDir::PosY)[0].tex(), 9);
}
TEST_CASE("voxel.world.ensureMeshed_via_selectVisible") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
Chunk *c = world->getOrCreateChunk(0, 0, 0);
c->fill(1);
CHECK(c->isDirty());
float view[16], proj[16], vp[16];
lookAtRH(16.f, 16.f, 80.f, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 16.f, 16.f, 80.f, 200.f, false);
CHECK(!c->isDirty());
CHECK(c->totalRectCount() > 0);
}
TEST_CASE("voxel.world.three_axis_negative_chunks") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(-33, -1, -64, 4);
CHECK(world->hasChunk(-2, -1, -2));
CHECK_EQ(int(world->getVoxel(-33, -1, -64)), 4);
Chunk *c = world->getChunk(-2, -1, -2);
REQUIRE(c != nullptr);
// local: wx - cx*32
const int lx = -33 - (-2) * 32; // -33 + 64 = 31
const int ly = -1 - (-1) * 32; // -1 + 32 = 31
const int lz = -64 - (-2) * 32; // -64 + 64 = 0
CHECK_EQ(lx, 31);
CHECK_EQ(ly, 31);
CHECK_EQ(lz, 0);
CHECK_EQ(int(c->get(lx, ly, lz)), 4);
}
TEST_CASE("voxel.faceDir.normals_are_unit_axis") {
for (int i = 0; i < faceDirCount(); ++i) {
float nx, ny, nz;
faceNormal(FaceDir(i), nx, ny, nz);
CHECK_EQ(nx * nx + ny * ny + nz * nz, 1.f);
const int nonzero = (nx != 0.f) + (ny != 0.f) + (nz != 0.f);
CHECK_EQ(nonzero, 1);
}
}
TEST_CASE("voxel.greedy.x_tunnel_hides_internal_yz") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
// Solid wall with a 1-cell tunnel along X through center.
for (int z = 0; z < 5; ++z)
for (int y = 0; y < 5; ++y)
for (int x = 0; x < 5; ++x) {
if (y == 2 && z == 2) continue; // tunnel
chunk->set(x, y, z, 1);
}
chunk->remesh();
// Outer shell still present; tunnel adds internal faces on ±Y/±Z.
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 2);
CHECK(chunk->faceRectCount(FaceDir::NegY) >= 2);
CHECK(chunk->faceRectCount(FaceDir::PosZ) >= 2);
CHECK(chunk->faceRectCount(FaceDir::NegZ) >= 2);
}
TEST_CASE("voxel.greedy.top_layer_y31") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 4; ++z)
for (int x = 0; x < 4; ++x) chunk->set(x, 31, z, 3);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].y(), 31);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].width(), 4);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].height(), 4);
}
TEST_CASE("voxel.world.set_air_clears_and_remeshes") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(2, 2, 2, 5);
world->remeshDirty();
CHECK(world->getChunk(0, 0, 0)->totalRectCount() > 0);
world->setVoxel(2, 2, 2, 0);
CHECK(world->getChunk(0, 0, 0)->isDirty());
world->remeshDirty();
CHECK_EQ(world->getChunk(0, 0, 0)->totalRectCount(), 0);
CHECK_EQ(int(world->getVoxel(2, 2, 2)), 0);
}
TEST_CASE("voxel.greedy.ring_in_layer_multiple_top_rects") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 5; ++z)
for (int x = 0; x < 5; ++x) {
if (x == 0 || x == 4 || z == 0 || z == 4) chunk->set(x, 0, z, 1);
}
chunk->remesh();
// Hollow ring: top faces are not a single 5×5.
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 4);
int area = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosY)) area += r.width() * r.height();
CHECK_EQ(area, 5 * 5 - 3 * 3);
}
TEST_CASE("voxel.greedy.four_textures_in_a_row") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int x = 0; x < 4; ++x) chunk->set(x, 0, 0, uint8_t(x + 1));
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 4);
for (const auto &r : chunk->faceRects(FaceDir::PosY)) {
CHECK_EQ(r.width(), 1);
CHECK_EQ(r.height(), 1);
}
}
TEST_CASE("voxel.greedy.same_tex_gap_no_merge_across_air") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->set(0, 0, 0, 5);
chunk->set(2, 0, 0, 5); // gap at x=1
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 2);
for (const auto &r : chunk->faceRects(FaceDir::PosY)) {
CHECK_EQ(r.tex(), 5);
CHECK_EQ(r.width(), 1);
}
}
TEST_CASE("voxel.greedy.many_tex_ids_preserved") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int i = 1; i <= 16; ++i) chunk->set(i, 0, 0, uint8_t(i));
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 16);
bool seen[128]{};
for (const auto &r : chunk->faceRects(FaceDir::PosY)) {
CHECK(r.tex() >= 1);
CHECK(r.tex() <= 16);
seen[r.tex()] = true;
}
for (int i = 1; i <= 16; ++i) CHECK(seen[i]);
}
TEST_CASE("voxel.world.multi_tex_remesh_updates_all") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(0, 0, 0, 1);
world->setVoxel(2, 0, 0, 2);
world->setVoxel(4, 0, 0, 3);
world->remeshDirty();
Chunk *c = world->getChunk(0, 0, 0);
REQUIRE(c != nullptr);
CHECK_EQ(c->faceRectCount(FaceDir::PosY), 3);
world->setVoxel(0, 0, 0, 7);
world->setVoxel(2, 0, 0, 8);
world->setVoxel(4, 0, 0, 9);
world->remeshDirty();
bool saw[128]{};
for (const auto &r : c->faceRects(FaceDir::PosY)) saw[r.tex()] = true;
CHECK(saw[7]);
CHECK(saw[8]);
CHECK(saw[9]);
CHECK(!saw[1]);
}
TEST_CASE("voxel.greedy.eight_corners_of_chunk") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
const int corners[8][3] = {{0, 0, 0}, {31, 0, 0}, {0, 31, 0}, {31, 31, 0},
{0, 0, 31}, {31, 0, 31}, {0, 31, 31}, {31, 31, 31}};
for (int i = 0; i < 8; ++i) chunk->set(corners[i][0], corners[i][1], corners[i][2], 1);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 8 * 6);
}
TEST_CASE("voxel.greedy.adjacent_same_tex_merges_width") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->set(0, 0, 0, 2);
chunk->set(1, 0, 0, 2);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].width(), 2);
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].height(), 1);
}
TEST_CASE("voxel.greedy.vertical_stack_merges_side_height") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int y = 0; y < 5; ++y) chunk->set(0, y, 0, 3);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosX), 1);
CHECK_EQ(chunk->faceRects(FaceDir::PosX)[0].width(), 1); // along Z
CHECK_EQ(chunk->faceRects(FaceDir::PosX)[0].height(), 5); // along Y
}
TEST_CASE("voxel.greedy.diagonal_plane_no_side_merge") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int i = 0; i < 8; ++i) chunk->set(i, 0, i, 1);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 8);
for (const auto &r : chunk->faceRects(FaceDir::PosY)) {
CHECK_EQ(r.width(), 1);
CHECK_EQ(r.height(), 1);
}
}
TEST_CASE("voxel.greedy.fill_then_carve_layer") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
chunk->fill(1);
chunk->remesh();
CHECK_EQ(chunk->totalRectCount(), 6);
for (int z = 0; z < 32; ++z)
for (int x = 0; x < 32; ++x) chunk->set(x, 16, z, 0);
chunk->remesh();
// Split solid → two slabs → more than 6 outer faces.
CHECK(chunk->totalRectCount() > 6);
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 2);
}
TEST_CASE("voxel.world.selectVisible_replaces_previous") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->getOrCreateChunk(10, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(16.f, 16.f, 48.f, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 200.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 16.f, 16.f, 48.f, 80.f, true);
const int first = world->getVisibleChunkCount();
CHECK(first >= 1);
bool sawOrigin = false;
for (int i = 0; i < world->getVisibleChunkCount(); ++i) {
int cx, cy, cz;
world->getVisibleChunkCoord(i, cx, cy, cz);
if (cx == 0 && cy == 0 && cz == 0) sawOrigin = true;
}
CHECK(sawOrigin);
// Move eye near far chunk only (cx=10 → center x=336).
lookAtRH(336.f, 16.f, 48.f, 336.f, 16.f, 16.f, 0, 1, 0, view);
mul4(proj, view, vp);
world->selectVisible(vp, 336.f, 16.f, 48.f, 80.f, true);
CHECK(world->getVisibleChunkCount() >= 1);
bool sawFar = false, stillOrigin = false;
for (int i = 0; i < world->getVisibleChunkCount(); ++i) {
int cx, cy, cz;
world->getVisibleChunkCoord(i, cx, cy, cz);
if (cx == 10) sawFar = true;
if (cx == 0 && cy == 0 && cz == 0) stillOrigin = true;
}
CHECK(sawFar);
CHECK(!stillOrigin);
}
TEST_CASE("voxel.world.setVoxel_marks_dirty_and_creates") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
CHECK_EQ(world->getChunkCount(), 0);
world->setVoxel(40, 5, -3, 6);
CHECK(world->hasChunk(1, 0, -1));
CHECK(world->getChunk(1, 0, -1)->isDirty());
CHECK_EQ(int(world->getVoxel(40, 5, -3)), 6);
}
TEST_CASE("voxel.world.visible_chunk_coords_consistent") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(-1, 0, 2)->fill(1);
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(0.f, 16.f, 100.f, 0.f, 16.f, 0.f, 0, 1, 0, view);
perspectiveRH_ZO(70.f * 3.14159265f / 180.f, 1.f, 0.1f, 400.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 0.f, 16.f, 100.f, 400.f, false);
CHECK(world->getVisibleChunkCount() >= 1);
for (int i = 0; i < world->getVisibleChunkCount(); ++i) {
int cx, cy, cz;
world->getVisibleChunkCoord(i, cx, cy, cz);
CHECK(world->hasChunk(cx, cy, cz));
}
}
TEST_CASE("voxel.greedy.shell_vs_solid_rect_delta") {
std::unique_ptr<Chunk> solid(new Chunk(0, 0, 0));
solid->fill(1);
solid->remesh();
CHECK_EQ(solid->totalRectCount(), 6);
std::unique_ptr<Chunk> shell(new Chunk(0, 0, 0));
for (int z = 0; z < 32; ++z)
for (int y = 0; y < 32; ++y)
for (int x = 0; x < 32; ++x) {
const bool border = (x == 0 || x == 31 || y == 0 || y == 31 || z == 0 || z == 31);
if (border) shell->set(x, y, z, 1);
}
shell->remesh();
// Hollow shell has inner faces too.
CHECK(shell->totalRectCount() > solid->totalRectCount());
}
TEST_CASE("voxel.greedy.plus_shaped_floorplan") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
// Plus shape on y=0: arms along X and Z through center.
for (int x = 0; x < 7; ++x) chunk->set(x, 0, 3, 1);
for (int z = 0; z < 7; ++z) chunk->set(3, 0, z, 1);
chunk->remesh();
int area = 0;
for (const auto &r : chunk->faceRects(FaceDir::PosY)) area += r.width() * r.height();
CHECK_EQ(area, 7 + 7 - 1); // center counted once
CHECK(chunk->faceRectCount(FaceDir::PosY) >= 2);
}
TEST_CASE("voxel.greedy.full_xy_slab_at_each_z") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int y = 0; y < 32; ++y)
for (int x = 0; x < 32; ++x) chunk->set(x, y, 0, 2);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::NegZ), 1);
CHECK_EQ(chunk->faceRects(FaceDir::NegZ)[0].width(), 32);
CHECK_EQ(chunk->faceRects(FaceDir::NegZ)[0].height(), 32);
CHECK_EQ(chunk->faceRectCount(FaceDir::PosZ), 1);
}
TEST_CASE("voxel.greedy.checkerboard_two_tex_no_merge") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 4; ++z)
for (int x = 0; x < 4; ++x) chunk->set(x, 0, z, ((x + z) & 1) ? 1 : 2);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 16);
}
TEST_CASE("voxel.world.three_chunks_range_picks_nearest") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->getOrCreateChunk(0, 0, 0)->fill(1);
world->getOrCreateChunk(5, 0, 0)->fill(1);
world->getOrCreateChunk(10, 0, 0)->fill(1);
world->remeshDirty();
float view[16], proj[16], vp[16];
// Eye near chunk 0; short range excludes far ones.
lookAtRH(16.f, 16.f, 40.f, 16.f, 16.f, 16.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 500.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 16.f, 16.f, 40.f, 60.f, true);
CHECK_EQ(world->getVisibleChunkCount(), 1);
int cx, cy, cz;
world->getVisibleChunkCoord(0, cx, cy, cz);
CHECK_EQ(cx, 0);
}
TEST_CASE("voxel.world.drawVisible_null_atlas_safe") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(0, 0, 0, 1);
world->remeshDirty();
float view[16], proj[16], vp[16];
lookAtRH(0.5f, 0.5f, 8.f, 0.5f, 0.5f, 0.5f, 0, 1, 0, view);
perspectiveRH_ZO(50.f * 3.14159265f / 180.f, 1.f, 0.1f, 100.f, proj);
mul4(proj, view, vp);
world->selectVisible(vp, 0.5f, 0.5f, 8.f, 100.f, true);
// CPU-only: gfx null already covered; ensure batches exist for null-atlas GPU path.
CHECK(world->getVisibleBatchCount() >= 1);
world->drawVisible(nullptr, nullptr, 1);
}
TEST_CASE("voxel.chunk.coords_and_getters") {
std::unique_ptr<Chunk> chunk(new Chunk(-3, 4, -5));
CHECK_EQ(chunk->cx(), -3);
CHECK_EQ(chunk->cy(), 4);
CHECK_EQ(chunk->cz(), -5);
CHECK_EQ(chunk->originX(), -96.f);
CHECK_EQ(chunk->originY(), 128.f);
CHECK_EQ(chunk->originZ(), -160.f);
}
TEST_CASE("voxel.greedy.meshFace_empty_volume") {
uint8_t voxels[32 * 32 * 32];
std::memset(voxels, 0, sizeof(voxels));
std::vector<PackedRect> out;
for (int d = 0; d < faceDirCount(); ++d) {
out.clear();
GreedyMesher::meshFace(voxels, FaceDir(d), out);
CHECK_EQ(int(out.size()), 0);
}
}
TEST_CASE("voxel.world.clear_then_rebuild") {
std::unique_ptr<VoxelWorld> world(new VoxelWorld());
world->setVoxel(1, 1, 1, 2);
world->setVoxel(40, 1, 1, 3);
world->remeshDirty();
CHECK_EQ(world->getChunkCount(), 2);
world->clear();
CHECK_EQ(world->getChunkCount(), 0);
CHECK_EQ(world->getVisibleBatchCount(), 0);
world->setVoxel(1, 1, 1, 4);
world->remeshDirty();
CHECK_EQ(world->getChunkCount(), 1);
CHECK_EQ(int(world->getVoxel(1, 1, 1)), 4);
}
TEST_CASE("voxel.module.newCubeTypes_and_newWorld") {
auto *mod = Voxel::create();
CubeTypeRegistry *types = mod->newCubeTypes();
REQUIRE(types != nullptr);
types->loadFromJson("[{\"name\":\"stone\",\"faceTex\":[9,9,9,9,9,9]}]");
CHECK_EQ(types->count(), 1);
VoxelWorld *w = mod->newWorld(types);
REQUIRE(w != nullptr);
w->setVoxelByName(0, 0, 0, "stone");
CHECK(w->hasChunk(0, 0, 0));
CHECK_EQ(w->getCubeTypeName(0, 0, 0), std::string("stone"));
delete w;
VoxelWorld *empty = mod->newWorld();
REQUIRE(empty != nullptr);
CHECK_EQ(empty->getChunkCount(), 0);
delete empty;
delete types;
}
TEST_CASE("voxel.greedy.border_only_x0_plane") {
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
for (int z = 0; z < 32; ++z)
for (int y = 0; y < 32; ++y) chunk->set(0, y, z, 5);
chunk->remesh();
CHECK_EQ(chunk->faceRectCount(FaceDir::NegX), 1);
CHECK_EQ(chunk->faceRects(FaceDir::NegX)[0].width(), 32);
CHECK_EQ(chunk->faceRects(FaceDir::NegX)[0].height(), 32);
CHECK_EQ(chunk->faceRectCount(FaceDir::PosX), 1);
}
TEST_CASE("voxel.registry.loadFromJson") {
CubeTypeRegistry reg;
const int n = reg.loadFromJson(
R"([
{"name":"grass","faceTex":[1,1,2,3,1,1]},
{"name":"furnace","faceTex":[4,4,4,4,5,4],"directional":true,"composeGroup":"furnace"}
])");
CHECK_EQ(n, 2);
CHECK_EQ(reg.count(), 2);
// furnace 方向性展开成 4 个变体
CHECK_EQ(reg.variantCount(), 5); // grass + 4 furnace variants
const CubeType *grass = reg.find("grass");
REQUIRE(grass != nullptr);
CHECK(!grass->directional);
CHECK_EQ(int(grass->faceTex[2]), 2);
const CubeType *furnace = reg.find("furnace");
REQUIRE(furnace != nullptr);
CHECK(furnace->directional);
CHECK_EQ(furnace->composeGroup, std::string("furnace"));
std::string err;
CHECK_EQ(reg.loadFromJson("not json", &err), 0);
CHECK(!err.empty());
}
TEST_CASE("voxel.registry.directional_variants_rotate_faces") {
CubeTypeRegistry reg;
CubeType furnace;
furnace.name = "furnace";
// 前脸在 +X(id=4),其它面 id=1,顶/底 ±Y 分别 5/6
furnace.faceTex[0] = 4; // PosX
furnace.faceTex[1] = 1; // NegX
furnace.faceTex[2] = 5; // PosY
furnace.faceTex[3] = 6; // NegY
furnace.faceTex[4] = 1; // PosZ
furnace.faceTex[5] = 1; // NegZ
furnace.directional = true;
const uint8_t base = reg.add(furnace);
CHECK(base >= 1);
CHECK_EQ(reg.variantCount(), 4);
// 0 度变体:+X = 前脸
CHECK_EQ(int(reg.find(base)->faceTex[0]), 4);
// 90 度变体:前脸转到 +Z(绕 Y:+X→-Z→-X→+Z,因此 +X 的纹理会出现在 90° 变体的 -Z,
// 而 +Z 由 -X 转来 = 1)。这里校验 variantId + resolveFaceTex 的一致性。
const uint8_t v90 = reg.variantId("furnace", 1);
CHECK_EQ(int(v90), int(base + 1));
// 顶/底不随绕 Y 旋转变化
CHECK_EQ(int(reg.find(v90)->faceTex[2]), 5);
CHECK_EQ(int(reg.find(v90)->faceTex[3]), 6);
// resolveFaceTex:未注册 id 退化为 id 本身
CHECK_EQ(int(resolveFaceTex(reg, 200, FaceDir::PosX)), 200);
// 已注册:按变体面纹理解析
CHECK_EQ(int(resolveFaceTex(reg, base, FaceDir::PosX)), 4);
CHECK_EQ(int(resolveFaceTex(reg, base, FaceDir::PosY)), 5);
}
TEST_CASE("voxel.world.registry_resolves_per_face_tex") {
CubeTypeRegistry reg;
CubeType grass;
grass.name = "grass";
grass.faceTex[0] = 1;
grass.faceTex[1] = 1;
grass.faceTex[2] = 2; // 顶
grass.faceTex[3] = 3; // 底
grass.faceTex[4] = 1;
grass.faceTex[5] = 1;
reg.add(grass);
VoxelWorld world(reg);
world.setVoxelByName(4, 5, 6, "grass");
world.remeshDirty();
Chunk *c = world.getChunk(0, 0, 0);
REQUIRE(c != nullptr);
// 顶面纹理解析为 2,其它面为 1
CHECK_EQ(c->faceRects(FaceDir::PosY)[0].tex(), 2);
CHECK_EQ(c->faceRects(FaceDir::NegY)[0].tex(), 3);
CHECK_EQ(c->faceRects(FaceDir::PosX)[0].tex(), 1);
CHECK_EQ(world.getCubeTypeName(4, 5, 6), std::string("grass"));
CHECK_EQ(int(world.getCubeTypeTex(4, 5, 6, "posY")), 2);
CHECK_EQ(int(world.getCubeTypeTex(4, 5, 6, "posX")), 1);
}
TEST_CASE("voxel.world.set_air_does_not_create_chunk") {
VoxelWorld world;
world.setVoxel(100, 100, 100, 0);
CHECK_EQ(world.getChunkCount(), 0);
CHECK_EQ(int(world.getVoxel(100, 100, 100)), 0);
}
TEST_CASE("voxel.world.border_edit_marks_neighbor_dirty") {
VoxelWorld world;
world.setVoxel(30, 5, 5, 1); // chunk (0,0,0), not on border
world.setVoxel(32, 5, 5, 1); // chunk (1,0,0)
world.remeshDirty();
Chunk *a = world.getChunk(0, 0, 0);
Chunk *b = world.getChunk(1, 0, 0);
REQUIRE(a != nullptr);
REQUIRE(b != nullptr);
CHECK(!a->isDirty());
CHECK(!b->isDirty());
// Interior edit: only the edited chunk is invalidated.
world.setVoxel(10, 5, 5, 2);
CHECK(a->isDirty());
CHECK(!b->isDirty());
world.remeshDirty();
CHECK(!a->isDirty());
// Border edit on the shared face: both chunks must re-mesh.
world.setVoxel(31, 5, 5, 2);
CHECK(a->isDirty());
CHECK(b->isDirty());
}
TEST_CASE("voxel.world.selectVisible_defers_remesh_out_of_range") {
VoxelWorld world;
Chunk *near = world.getOrCreateChunk(0, 0, 0);
near->fill(1);
Chunk *far = world.getOrCreateChunk(0, 0, -100); // ~3200 world units ahead (-Z)
far->fill(1);
float view[16], proj[16], vp[16];
lookAtRH(16.f, 16.f, 16.f, 16.f, 16.f, 0.f, 0, 1, 0, view);
perspectiveRH_ZO(60.f * 3.14159265f / 180.f, 1.f, 0.1f, 20000.f, proj);
mul4(proj, view, vp);
world.selectVisible(vp, 16.f, 16.f, 16.f, 100.f, false);
CHECK(!near->isDirty());
CHECK(far->isDirty()); // out-of-range dirty chunk was NOT remeshed
CHECK_EQ(world.getVisibleChunkCount(), 1);
world.selectVisible(vp, 16.f, 16.f, 16.f, 10000.f, false);
CHECK(!far->isDirty()); // remeshed once it enters range
CHECK_EQ(world.getVisibleChunkCount(), 2);
}
TEST_CASE("voxel.world.raycast_hits_first_solid_and_face") {
VoxelWorld world;
world.setVoxel(5, 3, 2, 1);
world.setVoxel(6, 3, 2, 1);
int hx, hy, hz, px, py, pz, fx, fy, fz;
// +X: enters voxel (5,3,2) through its -X face.
CHECK(world.raycast(0.f, 3.5f, 2.5f, 1.f, 0.f, 0.f, 20.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
CHECK_EQ(hx, 5);
CHECK_EQ(hy, 3);
CHECK_EQ(hz, 2);
CHECK_EQ(px, 4);
CHECK_EQ(py, 3);
CHECK_EQ(pz, 2);
CHECK_EQ(fx, -1);
CHECK_EQ(fy, 0);
CHECK_EQ(fz, 0);
// -X from the other side: first solid is (6,3,2) through its +X face.
CHECK(world.raycast(10.f, 3.5f, 2.5f, -1.f, 0.f, 0.f, 20.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
CHECK_EQ(hx, 6);
CHECK_EQ(px, 7);
CHECK_EQ(fx, 1);
}
TEST_CASE("voxel.world.raycast_negative_coords_and_miss") {
VoxelWorld world;
world.setVoxel(-3, 2, 2, 1); // chunk (-1,0,0), local x = 29
int hx, hy, hz, px, py, pz, fx, fy, fz;
CHECK(world.raycast(0.f, 2.5f, 2.5f, -1.f, 0.f, 0.f, 20.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
CHECK_EQ(hx, -3);
CHECK_EQ(px, -2);
CHECK_EQ(fx, 1); // entered through the +X face
// Miss: wrong lane.
CHECK(!world.raycast(0.f, 30.f, 30.f, 1.f, 0.f, 0.f, 20.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
// Miss: beyond maxDist (voxel at t=5, maxDist=4).
CHECK(!world.raycast(0.f, 3.5f, 2.5f, 1.f, 0.f, 0.f, 4.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
// Degenerate ray.
CHECK(!world.raycast(0.f, 2.5f, 2.5f, 0.f, 0.f, 0.f, 20.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
// Zero maxDist.
CHECK(!world.raycast(0.f, 2.5f, 2.5f, -1.f, 0.f, 0.f, 0.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
}
TEST_CASE("voxel.world.raycast_start_inside_solid") {
VoxelWorld world;
world.setVoxel(5, 3, 2, 1);
int hx, hy, hz, px, py, pz, fx, fy, fz;
CHECK(world.raycast(5.2f, 3.4f, 2.1f, 1.f, 0.f, 0.f, 10.f, hx, hy, hz, px, py, pz, fx, fy,
fz));
CHECK_EQ(hx, 5);
CHECK_EQ(hy, 3);
CHECK_EQ(hz, 2);
CHECK_EQ(px, 5);
CHECK_EQ(fx, 0);
}
TEST_CASE("voxel.world.raycastScript_stores_result") {
VoxelWorld world;
world.setVoxel(2, 4, 6, 1);
CHECK(world.raycastScript(0.f, 4.5f, 6.5f, 1.f, 0.f, 0.f, 20.f));
CHECK(world.lastRaycastHit());
CHECK_EQ(world.lastRaycastHitX(), 2);
CHECK_EQ(world.lastRaycastHitY(), 4);
CHECK_EQ(world.lastRaycastHitZ(), 6);
CHECK_EQ(world.lastRaycastPrevX(), 1);
CHECK_EQ(world.lastRaycastFaceX(), -1);
CHECK(!world.raycastScript(0.f, 40.f, 40.f, 1.f, 0.f, 0.f, 20.f));
CHECK(!world.lastRaycastHit());
}
TEST_CASE("voxel.world.remeshDirty_parallel_equals_serial") {
auto build = []() {
auto w = std::make_unique<VoxelWorld>();
for (int cz = -1; cz <= 1; ++cz)
for (int cy = -1; cy <= 1; ++cy)
for (int cx = -1; cx <= 1; ++cx) {
Chunk *c = w->getOrCreateChunk(cx, cy, cz);
c->fill(uint8_t(1 + ((cx * 7 + cy * 5 + cz * 3) & 3)));
// Carve holes (incl. on seams) to make neighbor culling exercise paths.
w->setVoxel(cx * 32 + 15, cy * 32 + 15, cz * 32 + 15, 0);
w->setVoxel(cx * 32 + 16, cy * 32 + 16, cz * 32 + 16, 0);
}
return w;
};
auto serial = build();
auto parallel = build();
CHECK_EQ(serial->remeshDirty(1), 27);
CHECK_EQ(parallel->remeshDirty(4), 27);
CHECK_EQ(parallel->remeshDirty(4), 0); // nothing left dirty
for (int cz = -1; cz <= 1; ++cz)
for (int cy = -1; cy <= 1; ++cy)
for (int cx = -1; cx <= 1; ++cx) {
const Chunk *a = serial->getChunk(cx, cy, cz);
const Chunk *b = parallel->getChunk(cx, cy, cz);
REQUIRE(a != nullptr);
REQUIRE(b != nullptr);
CHECK_EQ(a->totalRectCount(), b->totalRectCount());
for (int d = 0; d < faceDirCount(); ++d) {
const auto &fa = a->faceRects(FaceDir(d));
const auto &fb = b->faceRects(FaceDir(d));
const uint32_t *aoa = a->faceAOPackedData(FaceDir(d));
const uint32_t *aob = b->faceAOPackedData(FaceDir(d));
CHECK_EQ(int(fa.size()), int(fb.size()));
for (size_t i = 0; i < fa.size() && i < fb.size(); ++i)
CHECK_EQ(fa[i].bits, fb[i].bits);
if (aoa && aob)
for (size_t i = 0; i < fa.size() && i < fb.size(); ++i)
CHECK_EQ(aoa[i], aob[i]);
}
}
}
TEST_CASE("voxel.mesher.sampler_culls_seam_directly") {
// Two fully-solid chunks meshed with a neighbor sampler: the shared face
// must disappear on both sides; outer faces remain.
uint8_t voxels[6][32 * 32 * 32];
for (auto &v : voxels) std::memset(v, 0, sizeof(v));
std::memset(voxels[0], 1, sizeof(voxels[0]));
std::memset(voxels[1], 1, sizeof(voxels[1]));
struct Ctx {
uint8_t (*volumes)[32 * 32 * 32];
};
Ctx ctx{voxels};
auto sampler = [](void *userData, int chunkX, int chunkY, int chunkZ, int localX, int localY,
int localZ) -> uint8_t {
auto *c = static_cast<Ctx *>(userData);
const int wx = chunkX * kChunkSize + localX;
const int wy = chunkY * kChunkSize + localY;
const int wz = chunkZ * kChunkSize + localZ;
if (wx < 0 || wx >= 64 || wy < 0 || wy >= 32 || wz < 0 || wz >= 32) return 0;
const int cx = wx / 32;
return c->volumes[cx][(wx - cx * 32) + wy * 32 + wz * 32 * 32];
};
std::vector<PackedRect> faces[6];
GreedyMesher::meshChunk(voxels[0], faces, CubeTypeRegistry::empty(), sampler, &ctx, 0, 0, 0);
CHECK_EQ(int(faces[int(FaceDir::PosX)].size()), 0); // seam culled
CHECK_EQ(int(faces[int(FaceDir::NegX)].size()), 1); // outer face remains
std::vector<PackedRect> facesB[6];
GreedyMesher::meshChunk(voxels[1], facesB, CubeTypeRegistry::empty(), sampler, &ctx, 1, 0, 0);
CHECK_EQ(int(facesB[int(FaceDir::NegX)].size()), 0); // seam culled
CHECK_EQ(int(facesB[int(FaceDir::PosX)].size()), 1); // outer face remains
}
TEST_CASE("voxel.ao.flat_ground_full_bright") {
Chunk chunk;
for (int z = 0; z < 32; ++z)
for (int x = 0; x < 32; ++x) chunk.set(x, 0, z, 1);
chunk.remesh();
// Flat layer: outer layer y=1 is air → every corner AO=3 (word 0xFF).
CHECK_EQ(chunk.faceRectCount(FaceDir::PosY), 1);
const uint32_t *ao = chunk.faceAOPackedData(FaceDir::PosY);
REQUIRE(ao != nullptr);
CHECK_EQ(ao[0], 0xFFu);
}
TEST_CASE("voxel.ao.raised_neighbor_darkens_corner") {
VoxelWorld world;
world.setVoxel(0, 0, 0, 1); // base
world.setVoxel(1, 1, 0, 1); // above-east → darkens the NE corner of the top face
world.remeshDirty();
const Chunk *c = world.getChunk(0, 0, 0);
REQUIRE(c != nullptr);
CHECK(c->faceRectCount(FaceDir::PosY) >= 1);
const uint32_t *ao = c->faceAOPackedData(FaceDir::PosY);
REQUIRE(ao != nullptr);
// Pick the top face of the base voxel (y=0 slice), not the raised one.
int rectIdx = -1;
for (int i = 0; i < c->faceRectCount(FaceDir::PosY); ++i)
if (c->faceRects(FaceDir::PosY)[size_t(i)].y() == 0) rectIdx = i;
REQUIRE(rectIdx >= 0);
const DecodedRect q = decodePackedRect(c->faceRects(FaceDir::PosY)[size_t(rectIdx)],
FaceDir::PosY, 0.f, 0.f, 0.f, 16,
ao[size_t(rectIdx)]);
CHECK_EQ(q.ao[0], 3);
CHECK_EQ(q.ao[1], 3);
CHECK(q.ao[2] < 3); // NE corner adjacent to the raised voxel
CHECK_EQ(q.ao[3], 3);
}
TEST_CASE("voxel.ao.nonuniform_cells_do_not_merge_into_brightness_blocks") {
Chunk chunk;
chunk.set(0, 0, 0, 1);
chunk.set(1, 0, 0, 1);
chunk.set(2, 1, 0, 1); // Darkens only the outer corners of the second top face.
chunk.remesh();
int baseTopRects = 0;
int baseTopArea = 0;
for (const PackedRect &rect : chunk.faceRects(FaceDir::PosY)) {
if (rect.y() != 0) continue;
++baseTopRects;
baseTopArea += rect.width() * rect.height();
}
CHECK_EQ(baseTopArea, 2);
CHECK_EQ(baseTopRects, 2);
}
TEST_CASE("voxel.world.serialize_roundtrip") {
VoxelWorld world;
world.setVoxel(0, 0, 0, 1);
world.setVoxel(33, -5, 100, 7); // chunk (1,-1,3)
world.setVoxel(-40, 0, 0, 3); // chunk (-2,0,0)
world.remeshDirty();
std::vector<uint8_t> bytes;
world.serializeWorld(bytes);
CHECK_EQ(bytes.size(), size_t(9 + 3 * (12 + 32 * 32 * 32)));
VoxelWorld loaded;
CHECK(loaded.deserializeWorld(bytes.data(), bytes.size()));
CHECK_EQ(loaded.getChunkCount(), 3);
CHECK_EQ(int(loaded.getVoxel(0, 0, 0)), 1);
CHECK_EQ(int(loaded.getVoxel(33, -5, 100)), 7);
CHECK_EQ(int(loaded.getVoxel(-40, 0, 0)), 3);
CHECK(loaded.hasChunk(1, -1, 3));
CHECK(loaded.hasChunk(-2, 0, 0));
// Corrupt data is rejected without touching the world.
bytes[5] = 0xFF;
VoxelWorld bad;
CHECK(!bad.deserializeWorld(bytes.data(), bytes.size()));
CHECK_EQ(bad.getChunkCount(), 0);
}
TEST_CASE("voxel.world.save_load_ByteData") {
VoxelWorld a, b;
a.setVoxel(10, 10, 10, 5);
eve::data::ByteData *save = a.saveWorld();
REQUIRE(save != nullptr);
CHECK(b.loadWorld(save));
CHECK_EQ(int(b.getVoxel(10, 10, 10)), 5);
CHECK(b.getChunk(0, 0, 0)->isDirty()); // loaded voxels are meshed on demand
b.remeshDirty();
CHECK_EQ(b.getChunk(0, 0, 0)->totalRectCount(), 6);
delete save;
CHECK(!b.loadWorld(nullptr));
}
TEST_CASE("voxel.world.unload_chunks_outside") {
VoxelWorld world;
world.getOrCreateChunk(0, 0, 0)->fill(1);
world.getOrCreateChunk(1, 0, 0)->fill(1);
world.getOrCreateChunk(-3, 0, 0)->fill(1);
world.getOrCreateChunk(0, 2, 0)->fill(1);
world.remeshDirty();
CHECK_EQ(world.getChunkCount(), 4);
CHECK_EQ(world.unloadChunksOutside(0, 0, 0, 2), 1);
CHECK(world.hasChunk(0, 0, 0));
CHECK(world.hasChunk(1, 0, 0));
CHECK(world.hasChunk(0, 2, 0));
CHECK(!world.hasChunk(-3, 0, 0));
CHECK_EQ(world.unloadChunksOutside(0, 0, 0, -1), 0); // negative = no-op
CHECK_EQ(world.getChunkCount(), 3);
CHECK_EQ(world.unloadChunksOutside(0, 0, 0, 0), 2); // radius 0 keeps center
CHECK_EQ(world.getChunkCount(), 1);
CHECK(world.hasChunk(0, 0, 0));
}
TEST_CASE("voxel.terrain.deterministic_and_ranged") {
VoxelWorld a, b;
a.setTerrainParams(1234, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
b.setTerrainParams(1234, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
for (int i = 0; i < 200; ++i)
CHECK_EQ(a.terrainHeightAt(i * 7, i * 13), b.terrainHeightAt(i * 7, i * 13));
// Range sanity: base=8, amplitude=14, elevation in [0,1] → [8, 22].
for (int i = 0; i < 200; ++i) {
const int h = a.terrainHeightAt(i * 3, i * 5);
CHECK(h >= 8);
CHECK(h <= 22);
}
VoxelWorld c;
c.setTerrainParams(9999, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
bool anyDiff = false;
for (int i = 0; i < 800 && !anyDiff; ++i)
if (a.terrainHeightAt(i, i * 2) != c.terrainHeightAt(i, i * 2)) anyDiff = true;
CHECK(anyDiff);
}
TEST_CASE("voxel.terrain.fill_columns") {
VoxelWorld world;
world.setTerrainParams(7, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
StreamStats s = world.streamAround(0, 0, 0, 0); // just the center chunk
CHECK_EQ(s.created, 1);
Chunk *chunk = world.getChunk(0, 0, 0);
REQUIRE(chunk != nullptr);
const int h = world.terrainHeightAt(5, 5);
CHECK_EQ(int(chunk->get(5, h, 5)), 1); // top layer
if (h + 1 < 32) CHECK_EQ(int(chunk->get(5, h + 1, 5)), 0); // air above
if (h - 1 >= 0) CHECK_EQ(int(chunk->get(5, h - 1, 5)), 2); // sub layer
if (h - 4 >= 0) CHECK_EQ(int(chunk->get(5, h - 4, 5)), 3); // stone
// A chunk entirely above the terrain (cy=1 → world y 32..63) stays air.
VoxelWorld highWorld;
highWorld.setTerrainParams(7, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
highWorld.streamAround(0, 1, 0, 0);
CHECK_EQ(highWorld.getChunk(0, 1, 0)->totalRectCount(), 0);
}
TEST_CASE("voxel.terrain.evtr_streams_height_and_biome_surface_materials") {
eve::procgen::Heightmap heightmap(32, 32);
std::fill(heightmap.data().begin(), heightmap.data().end(), 8.f);
eve::procgen::HydrologyMap hydrology;
hydrology.width = 32;
hydrology.height = 32;
hydrology.flowDirection.assign(32 * 32, -1);
hydrology.flowAccumulation.assign(32 * 32, 1.f);
hydrology.rivers.assign(32 * 32, 0);
eve::procgen::ClimateMap climate;
climate.width = 32;
climate.height = 32;
climate.temperature.assign(32 * 32, 0.6f);
climate.moisture.assign(32 * 32, 0.5f);
climate.biomes.assign(32 * 32, eve::procgen::Biome::Grassland);
for (int z = 0; z < 32; ++z) {
for (int x = 16; x < 32; ++x)
climate.biomes[size_t(z * 32 + x)] = eve::procgen::Biome::Desert;
climate.biomes[size_t(z * 32 + 8)] = eve::procgen::Biome::River;
hydrology.rivers[size_t(z * 32 + 8)] = 1;
}
std::vector<uint8_t> archive;
std::string error;
REQUIRE(eve::procgen::TerrainAsset::bake(heightmap, hydrology, climate, 8, archive, &error));
eve::data::ByteData bytes(archive.data(), archive.size());
VoxelWorld world;
REQUIRE(world.loadTerrainAsset(&bytes, 2.f, 1.f));
world.setTerrainAssetMaterials(11, 14, 15, 13, 16);
StreamStats streamed = world.streamAround(0, 0, 0, 0);
CHECK_EQ(streamed.created, 1);
CHECK(world.getTerrainAssetResidentCount() > 0);
CHECK_EQ(world.terrainHeightAt(5, 5), 10);
Chunk *chunk = world.getChunk(0, 0, 0);
REQUIRE(chunk != nullptr);
CHECK_EQ(int(chunk->get(5, 10, 5)), 11); // grassland vegetation
CHECK_EQ(int(chunk->get(20, 10, 5)), 14); // desert sand
CHECK_EQ(int(chunk->get(8, 10, 5)), 16); // river bed
CHECK_EQ(int(chunk->get(5, 9, 5)), 2); // existing subsurface contract
CHECK_EQ(int(chunk->get(5, 6, 5)), 3); // existing stone contract
}
TEST_CASE("voxel.terrain.param_api_sand_band") {
VoxelWorld world;
world.setTerrainParam("seed", 123);
world.setTerrainParam("base", 0.f);
world.setTerrainParam("amplitude", 36.f);
world.setTerrainParam("scale", 1.f / 48.f);
world.setTerrainParam("island", 0.40f);
world.setTerrainParam("worldWidth", 512);
world.setTerrainParam("worldHeight", 512);
world.setTerrainParam("top", 1);
world.setTerrainParam("sub", 2);
world.setTerrainParam("stone", 3);
world.setTerrainParam("sand", 5);
world.setTerrainParam("sandLevel", 0.40f);
world.setTerrainParam("enable", 1.f);
CHECK(world.terrainEnabled());
const int created = world.streamAround(8, 0, 8, 0).created;
CHECK_EQ(created, 1);
Chunk *c = world.getChunk(8, 0, 8);
REQUIRE(c != nullptr);
// Same sampler config as the world uses (base/amplitude are the voxel-side
// height mapping; the sampler itself stays in normalized [0, 1]).
eve::procgen::TerrainSampler sampler;
sampler.setSeed(123);
sampler.setFrequency(1.f / 48.f);
sampler.setIsland(0.40f);
sampler.setWorldSize(512, 512);
int sandSeen = 0;
int grassSeen = 0;
for (int lx = 0; lx < 32; ++lx) {
for (int lz = 0; lz < 32; ++lz) {
const float e = sampler.sample(float(8 * 32 + lx), float(8 * 32 + lz));
const int h = int(std::floor(0.f + 36.f * e));
if (h < 0 || h >= 32) continue;
const uint8_t top = c->get(lx, h, lz);
if (e <= 0.40f) {
CHECK_EQ(int(top), 5); // sand band
++sandSeen;
} else {
CHECK_EQ(int(top), 1); // grass elsewhere
++grassSeen;
}
}
}
CHECK(sandSeen > 0);
CHECK(grassSeen > 0);
// Disabling the sand band restores the configured top texture everywhere.
VoxelWorld noSand;
noSand.setTerrainParam("seed", 123);
noSand.setTerrainParam("base", 0.f);
noSand.setTerrainParam("amplitude", 36.f);
noSand.setTerrainParam("scale", 1.f / 48.f);
noSand.setTerrainParam("island", 0.40f);
noSand.setTerrainParam("worldWidth", 512);
noSand.setTerrainParam("worldHeight", 512);
noSand.setTerrainParam("top", 1);
noSand.setTerrainParam("sub", 2);
noSand.setTerrainParam("stone", 3);
noSand.setTerrainParam("sand", 5);
noSand.setTerrainParam("sandLevel", 0.f); // off
noSand.setTerrainParam("enable", 1.f);
noSand.streamAround(8, 0, 8, 0);
Chunk *ns = noSand.getChunk(8, 0, 8);
REQUIRE(ns != nullptr);
for (int lx = 0; lx < 32; ++lx)
for (int lz = 0; lz < 32; ++lz) {
const int h = noSand.terrainHeightAt(8 * 32 + lx, 8 * 32 + lz);
if (h < 0 || h >= 32) continue;
CHECK_EQ(int(ns->get(lx, h, lz)), 1);
}
}
TEST_CASE("voxel.terrain.param_api_deterministic_and_distinct") {
VoxelWorld a, b;
a.setTerrainParam("seed", 7);
a.setTerrainParam("island", 0.4f);
a.setTerrainParam("ridge", 0.7f);
a.setTerrainParam("worldWidth", 512);
a.setTerrainParam("worldHeight", 512);
a.setTerrainParam("enable", 1.f);
b.setTerrainParam("seed", 7);
b.setTerrainParam("island", 0.4f);
b.setTerrainParam("ridge", 0.7f);
b.setTerrainParam("worldWidth", 512);
b.setTerrainParam("worldHeight", 512);
b.setTerrainParam("enable", 1.f);
for (int i = 0; i < 100; ++i)
CHECK_EQ(a.terrainHeightAt(i * 5, i * 9), b.terrainHeightAt(i * 5, i * 9));
// Island falloff (worldSize + island) changes the shape vs the simple
// base/amplitude mapping of setTerrainParams.
VoxelWorld plain;
plain.setTerrainParams(7, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
bool anyDiff = false;
for (int i = 0; i < 400 && !anyDiff; ++i)
if (a.terrainHeightAt(i, i * 2) != plain.terrainHeightAt(i, i * 2)) anyDiff = true;
CHECK(anyDiff);
}
TEST_CASE("voxel.world.streamAround_evicts_and_creates") {
VoxelWorld world;
StreamStats s1 = world.streamAround(0, 0, 0, 2);
CHECK_EQ(s1.created, 33); // sphere radius 2 in chunk coords
CHECK_EQ(s1.evicted, 0);
CHECK_EQ(world.getChunkCount(), 33);
StreamStats s2 = world.streamAround(0, 0, 0, 2); // idempotent
CHECK_EQ(s2.created, 0);
CHECK_EQ(s2.evicted, 0);
StreamStats s3 = world.streamAround(10, 0, 0, 2); // jump far away
CHECK_EQ(s3.created, 33);
CHECK_EQ(s3.evicted, 33);
CHECK_EQ(world.getChunkCount(), 33);
CHECK(!world.hasChunk(0, 0, 0));
CHECK(world.hasChunk(10, 0, 0));
StreamStats s4 = world.streamAround(0, 0, 0, -1);
CHECK_EQ(s4.created, 0);
CHECK_EQ(s4.evicted, 0);
}
TEST_CASE("voxel.world.streamAround_cache_keeps_recent_chunks") {
VoxelWorld world;
world.setStreamCacheChunks(4);
StreamStats s1 = world.streamAround(0, 0, 0, 2);
CHECK_EQ(s1.created, 33);
CHECK_EQ(world.getChunkCount(), 33);
StreamStats s2 = world.streamAround(1, 0, 0, 2);
CHECK_EQ(s2.evicted, 0);
CHECK(world.hasChunk(0, 0, 0));
CHECK(world.getChunkCount() >= 33);
StreamStats s3 = world.streamAround(10, 0, 0, 2);
CHECK(!world.hasChunk(0, 0, 0));
CHECK(world.hasChunk(10, 0, 0));
CHECK(s3.evicted > 0);
}
TEST_CASE("voxel.world.streamAround_create_budget_spreads_across_calls") {
VoxelWorld world;
world.setTerrainParams(42, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
StreamStats first = world.streamAround(0, 0, 0, 2, {}, 4);
CHECK(first.created + world.getInflightStreamCount() >= 4);
CHECK(world.getChunkCount() <= 4);
CHECK_EQ(first.pending + world.getChunkCount(), 33);
for (int i = 0; i < 400 && world.getChunkCount() < 33; ++i) {
world.streamAround(0, 0, 0, 2, {}, 8);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
CHECK_EQ(world.getChunkCount(), 33);
CHECK_EQ(world.getInflightStreamCount(), 0);
}
TEST_CASE("voxel.world.streamTerrain_and_persist") {
VoxelWorld world;
world.setTerrainParams(42, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);
StreamStats s = world.streamAround(0, 0, 0, 2);
CHECK_EQ(s.created, 33);
CHECK_EQ(world.getChunkCount(), 33);
// All streamed chunks are meshed (not dirty). Buried chunks may have zero
// faces (fully enclosed solid → seam culling), so sum across the world.
int worldRects = 0;
for (int dz = -2; dz <= 2; ++dz)
for (int dy = -2; dy <= 2; ++dy)
for (int dx = -2; dx <= 2; ++dx) {
const int d2 = dx * dx + dy * dy + dz * dz;
if (d2 > 4) continue;
Chunk *c = world.getChunk(dx, dy, dz);
REQUIRE(c != nullptr);
CHECK(!c->isDirty());
worldRects += c->totalRectCount();
}
CHECK(worldRects > 0);
// The chunk containing the surface (cy=0) always has exposed top faces.
CHECK(world.getChunk(0, 0, 0)->totalRectCount() > 0);
// Streaming + persistence compose: saved world reloads with terrain.
std::vector<uint8_t> bytes;
world.serializeWorld(bytes);
VoxelWorld loaded;
CHECK(loaded.deserializeWorld(bytes.data(), bytes.size()));
CHECK_EQ(loaded.getChunkCount(), 33);
const int h = world.terrainHeightAt(0, 0);
CHECK_EQ(int(loaded.getVoxel(0, h, 0)), 1);
CHECK_EQ(int(loaded.getVoxel(0, h + 1, 0)), 0);
}