# Conflicts: # docs/usr/modules/procgen.md # docs/usr/modules/voxel.md # examples/terrain-editor/main.nut # src/modules/procgen/Procgen.cpp # src/modules/procgen/Procgen.h # src/modules/voxel/VoxelWorld.cpp # src/modules/voxel/VoxelWorld.h # test/procgen.cpp # test/voxel.cpp
2517 lines
92 KiB
C++
2517 lines
92 KiB
C++
#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 <cmath>
|
||
#include <cstring>
|
||
#include <memory>
|
||
#include <string>
|
||
#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.solid_cube") {
|
||
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
|
||
chunk->fill(1);
|
||
chunk->remesh();
|
||
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::PosX), 1);
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::NegX), 1);
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::NegY), 1);
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::PosZ), 1);
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::NegZ), 1);
|
||
CHECK_EQ(chunk->totalRectCount(), 6);
|
||
|
||
const PackedRect &top = chunk->faceRects(FaceDir::PosY)[0];
|
||
CHECK_EQ(top.width(), 32);
|
||
CHECK_EQ(top.height(), 32);
|
||
CHECK_EQ(top.tex(), 1);
|
||
CHECK_EQ(top.y(), 31);
|
||
}
|
||
|
||
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.faceDir.name_aliases") {
|
||
FaceDir d;
|
||
CHECK(faceDirFromName("posX", d));
|
||
CHECK_EQ(int(d), int(FaceDir::PosX));
|
||
CHECK(faceDirFromName("+x", d));
|
||
CHECK_EQ(int(d), int(FaceDir::PosX));
|
||
CHECK(faceDirFromName("-y", d));
|
||
CHECK_EQ(int(d), int(FaceDir::NegY));
|
||
CHECK(faceDirFromName("posZ", d));
|
||
CHECK_EQ(int(d), int(FaceDir::PosZ));
|
||
CHECK(!faceDirFromName("forward", d));
|
||
CHECK(!faceDirFromName("", d));
|
||
CHECK_EQ(std::string(faceDirName(FaceDir::NegZ)), std::string("negZ"));
|
||
}
|
||
|
||
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.greedy.stripe_horizontal_one_rect") {
|
||
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
|
||
for (int x = 0; x < 16; ++x) chunk->set(x, 0, 3, 4);
|
||
chunk->remesh();
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
|
||
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].width(), 16);
|
||
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].height(), 1);
|
||
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].z(), 3);
|
||
}
|
||
|
||
TEST_CASE("voxel.greedy.stripe_vertical_depth") {
|
||
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
|
||
for (int z = 0; z < 12; ++z) chunk->set(5, 0, z, 2);
|
||
chunk->remesh();
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir::PosY), 1);
|
||
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].width(), 1);
|
||
CHECK_EQ(chunk->faceRects(FaceDir::PosY)[0].height(), 12);
|
||
}
|
||
|
||
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.faceDir.fromName_rejects_garbage") {
|
||
FaceDir d = FaceDir::PosX;
|
||
CHECK(!faceDirFromName("", d));
|
||
CHECK(!faceDirFromName("PosX", d)); // case-sensitive
|
||
CHECK(!faceDirFromName("x+", d));
|
||
CHECK(!faceDirFromName("forward", d));
|
||
}
|
||
|
||
TEST_CASE("voxel.greedy.full_chunk_six_32x32") {
|
||
std::unique_ptr<Chunk> chunk(new Chunk(0, 0, 0));
|
||
chunk->fill(7);
|
||
chunk->remesh();
|
||
CHECK_EQ(chunk->totalRectCount(), 6);
|
||
for (int i = 0; i < faceDirCount(); ++i) {
|
||
CHECK_EQ(chunk->faceRectCount(FaceDir(i)), 1);
|
||
CHECK_EQ(chunk->faceRects(FaceDir(i))[0].width(), 32);
|
||
CHECK_EQ(chunk->faceRects(FaceDir(i))[0].height(), 32);
|
||
CHECK_EQ(chunk->faceRects(FaceDir(i))[0].tex(), 7);
|
||
}
|
||
}
|
||
|
||
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.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);
|
||
}
|