Files
EVEngine/test/virtualgeometry_builder.cpp
T
sunxfancy 2f13169b68 feat(virtualgeometry): add Nanite-style virtualized geometry rendering
Add a virtualgeometry module implementing a Nanite-inspired virtualized
geometry pipeline on the Vulkan backend:

- Cluster DAG preprocess (CPU): greedy meshletization + QEM edge-collapse
  hierarchical LOD, with per-cluster bounding spheres and geometric error.
- GPU-driven culling (compute): frustum + screen-space-error LOD selection
  that emits exactly one cluster per DAG branch (no gaps/overlaps).
- Software cluster rasterizer (compute): fills a visibility buffer
  (packed depth<<16 | clusterId) via atomicMin.
- Visibility-buffer resolve (CPU readback + resolve shader reference).
- Module facade (VirtualGeometry / VirtualGeometryRenderer) reusing the
  gpgpu compute backend; LodSelection CPU reference for tests.
- 14 unit tests (CPU preprocess + LOD-transition sweep + GPU end-to-end),
  shader compile script, example, and design doc.
2026-08-16 21:49:40 +08:00

283 lines
11 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "virtualgeometry/Builder.h"
#include "virtualgeometry/LodSelection.h"
#include <cmath>
#include <cstdint>
#include <vector>
using namespace eve::virtualgeometry;
namespace {
// Build a unit icosphere with `subdiv` levels (3 => 1280 tris, 4 => 5120).
struct IcoMesh {
std::vector<float> positions;
std::vector<std::uint32_t> indices;
int addVtx(float x, float y, float z) {
float l = std::sqrt(x * x + y * y + z * z);
positions.push_back(x / l);
positions.push_back(y / l);
positions.push_back(z / l);
return static_cast<int>(positions.size() / 3) - 1;
}
void tri(int a, int b, int c) {
indices.push_back(static_cast<std::uint32_t>(a));
indices.push_back(static_cast<std::uint32_t>(b));
indices.push_back(static_cast<std::uint32_t>(c));
}
int mid(int i0, int i1) {
float x = (positions[3 * i0] + positions[3 * i1]) * 0.5f;
float y = (positions[3 * i0 + 1] + positions[3 * i1 + 1]) * 0.5f;
float z = (positions[3 * i0 + 2] + positions[3 * i1 + 2]) * 0.5f;
return addVtx(x, y, z);
}
void build(int subdiv) {
const float t = (1.f + std::sqrt(5.f)) * 0.5f;
std::vector<std::array<float, 3>> base = {{-1, t, 0}, {1, t, 0}, {-1, -t, 0}, {1, -t, 0},
{0, -1, t}, {0, 1, t}, {0, -1, -t}, {0, 1, -t},
{t, 0, -1}, {t, 0, 1}, {-t, 0, -1}, {-t, 0, 1}};
int idx[12];
for (int i = 0; i < 12; ++i) idx[i] = addVtx(base[i][0], base[i][1], base[i][2]);
int faces[20][3] = {{0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11},
{1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8},
{3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9},
{4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1}};
struct FT { int a, b, c; };
std::vector<FT> cur;
for (auto &f : faces) cur.push_back({idx[f[0]], idx[f[1]], idx[f[2]]});
for (int d = 0; d < subdiv; ++d) {
std::vector<FT> nf;
for (auto &x : cur) {
int ab = mid(x.a, x.b), ac = mid(x.a, x.c), bc = mid(x.b, x.c);
nf.push_back({x.a, ab, ac});
nf.push_back({ab, x.b, bc});
nf.push_back({ac, bc, x.c});
nf.push_back({ab, bc, ac});
}
cur.swap(nf);
}
indices.clear();
for (auto &x : cur) tri(x.a, x.b, x.c);
}
};
VirtualGeometryAsset buildIco(int subdiv) {
IcoMesh m;
m.build(subdiv);
VirtualGeometryBuilder b;
VirtualGeometryBuilder::MeshInput in;
in.vertexCount = static_cast<int>(m.positions.size() / 3);
in.positions = m.positions.data();
in.indices = m.indices.data();
in.indexCount = static_cast<int>(m.indices.size());
VirtualGeometryBuilder::Options opt;
opt.minLodLevels = 5;
opt.mergeFactor = 4;
opt.maxTrianglesPerCluster = 124;
opt.maxVerticesPerCluster = 64;
VirtualGeometryAsset a;
REQUIRE(b.build(in, opt, a));
return a;
}
} // namespace
TEST_CASE("virtualgeometry.builder.rejectsEmptyInput") {
VirtualGeometryBuilder b;
VirtualGeometryBuilder::MeshInput in; // all null / zero
VirtualGeometryAsset a;
CHECK(!b.build(in, VirtualGeometryBuilder::Options(), a));
}
TEST_CASE("virtualgeometry.builder.icosphereCoversAllTriangles") {
auto a = buildIco(4); // 5120 triangles
long l0 = 0;
for (const auto &c : a.clusters)
if (c.lodLevel == 0) l0 += static_cast<long>(c.triCount);
// Every LOD0 triangle is covered exactly once.
CHECK_EQ(l0, 5120);
// All triangle indices are in range.
bool inRange = true;
for (std::uint32_t t : a.triangles)
if (t >= static_cast<std::uint32_t>(a.vertexCount)) inRange = false;
CHECK(inRange);
}
TEST_CASE("virtualgeometry.builder.clusterSizeBounds") {
auto a = buildIco(4);
for (const auto &c : a.clusters)
if (c.lodLevel == 0) CHECK(c.triCount <= 124);
}
TEST_CASE("virtualgeometry.builder.dagParentChildConsistent") {
auto a = buildIco(4);
int roots = 0;
for (const auto &c : a.clusters) {
if (c.parent == 0xFFFFFFFFu) {
++roots;
} else {
const VgCluster &p = a.clusters[c.parent];
bool isChild = false;
for (std::uint32_t k = 0; k < p.childCount; ++k)
if (p.children[k] != 0xFFFFFFFFu && a.clusters.size() > p.children[k] &&
&a.clusters[p.children[k]] == &c)
isChild = true;
CHECK(isChild);
}
}
CHECK(roots >= 1);
}
TEST_CASE("virtualgeometry.builder.lodMonotonicReduction") {
auto a = buildIco(4);
int maxLod = 0;
for (const auto &c : a.clusters) maxLod = std::max(maxLod, static_cast<int>(c.lodLevel));
long prev = 1L << 30;
for (int l = 0; l <= maxLod; ++l) {
long tris = 0;
float err = 0.f;
for (const auto &c : a.clusters)
if (static_cast<int>(c.lodLevel) == l) {
tris += static_cast<long>(c.triCount);
err = std::max(err, c.errorR);
}
if (l > 0) {
CHECK(tris < prev); // each level is coarser
CHECK(err > 0.f); // and carries positive geometric error
}
prev = tris;
}
}
// ---------------------------------------------------------------------------
// LOD transition tests. Sweep camera distance and drive the CPU reference of
// the GPU cull accept-rule (LodSelection::selectClusters) to verify that the
// visible LOD changes continuously: fine LOD0 near, coarse LOD far, with a
// gradual mix in between and no gaps/overlaps (a full, hole-free cover).
// ---------------------------------------------------------------------------
namespace {
constexpr float kProjScale = 540.f; // e.g. 1080px / (2*tan(30deg))
constexpr float kErrorPx = 1.0f;
struct SelectionStats {
int selected = 0;
std::vector<int> hist; // per-LOD counts
};
SelectionStats selectAt(const VirtualGeometryAsset &a, float dist) {
std::vector<std::uint32_t> sel;
selectClusters(a, dist, kProjScale, kErrorPx, sel);
SelectionStats s;
s.selected = static_cast<int>(sel.size());
lodHistogram(a, sel, s.hist);
return s;
}
// Highest LOD level that has any selected cluster (0..maxLod); -1 if none.
int maxSelectedLod(const SelectionStats &s) {
for (int l = static_cast<int>(s.hist.size()) - 1; l >= 0; --l)
if (s.hist[l] > 0) return l;
return -1;
}
} // namespace
TEST_CASE("virtualgeometry.lod.selectionFineNearCoarseFar") {
auto a = buildIco(4); // 5 LOD levels
// Very near: needs full detail -> mostly LOD0.
auto near_ = selectAt(a, 0.9f);
CHECK(maxSelectedLod(near_) == 0); // only finest LOD selected
// Very far: only coarse detail -> only top LOD.
auto far = selectAt(a, 200.f);
CHECK(maxSelectedLod(far) > 0); // some coarse level
// The sphere is on-screen at all these distances (frustum cull disabled).
CHECK(near_.selected >= far.selected); // near sees at least as many
}
TEST_CASE("virtualgeometry.lod.monotonicCoarsening") {
auto a = buildIco(4);
std::vector<int> countPerDist;
std::vector<int> maxLodPerDist;
for (int d = 1; d <= 200; d += 2) {
auto s = selectAt(a, static_cast<float>(d));
countPerDist.push_back(s.selected);
maxLodPerDist.push_back(maxSelectedLod(s));
}
// Selected count never increases with distance (LOD only gets coarser).
for (std::size_t i = 1; i < countPerDist.size(); ++i)
CHECK(countPerDist[i] <= countPerDist[i - 1]);
// Max LOD never decreases with distance.
for (std::size_t i = 1; i < maxLodPerDist.size(); ++i)
CHECK(maxLodPerDist[i] >= maxLodPerDist[i - 1]);
}
TEST_CASE("virtualgeometry.lod.gradualTransitionNoAbruptJump") {
auto a = buildIco(4);
// A fine sweep over the transition range: the selected-LOD histogram should
// evolve gradually, not flip wholesale. Track the LOD-0 share per step.
int prevLod0 = -1;
for (int d = 1; d <= 120; ++d) {
auto s = selectAt(a, static_cast<float>(d));
int lod0 = s.hist.empty() ? 0 : s.hist[0];
if (prevLod0 >= 0) {
// LOD0 count decreases monotonically as we move away.
CHECK(lod0 <= prevLod0);
}
prevLod0 = lod0;
}
// Some intermediate distance must contain BOTH LOD0 and a coarser LOD
// (a genuine in-transition mixture, not an on/off switch).
bool foundMix = false;
for (int d = 1; d <= 120; ++d) {
auto s = selectAt(a, static_cast<float>(d));
int nonLod0 = 0;
for (std::size_t l = 1; l < s.hist.size(); ++l) nonLod0 += s.hist[l];
if (s.hist.size() >= 2 && s.hist[0] > 0 && nonLod0 > 0) foundMix = true;
}
CHECK(foundMix);
}
TEST_CASE("virtualgeometry.lod.coverHasNoGapsOrOverlaps") {
auto a = buildIco(4);
// At every distance, the selected clusters must exactly cover the mesh:
// every cluster is either selected or has a selected ancestor (no gaps),
// and no cluster has both itself and an ancestor selected (no overlap).
for (int d = 1; d <= 200; d += 3) {
std::vector<std::uint32_t> sel;
selectClusters(a, static_cast<float>(d), kProjScale, kErrorPx, sel);
std::vector<char> isSelected(a.clusters.size(), 0);
for (std::uint32_t c : sel) isSelected[c] = 1;
for (std::size_t c = 0; c < a.clusters.size(); ++c) {
bool covered = isSelected[c];
// Walk up the DAG: covered if a selected ancestor exists.
std::uint32_t p = a.clusters[c].parent;
while (p != 0xFFFFFFFFu && !covered) {
covered = isSelected[p];
p = a.clusters[p].parent;
}
CHECK(covered); // every branch has exactly one selected node
// Overlap check: a selected cluster must not have a selected parent.
if (isSelected[c] && a.clusters[c].parent != 0xFFFFFFFFu)
CHECK(!isSelected[a.clusters[c].parent]);
}
}
}
TEST_CASE("virtualgeometry.lod.errorThresholdSelectsMoreDetailCloser") {
auto a = buildIco(4);
// Lower errorPx demands finer detail -> more clusters selected at the same
// distance.
std::vector<std::uint32_t> strictSel, looseSel;
selectClusters(a, 5.f, kProjScale, 0.5f, strictSel);
selectClusters(a, 5.f, kProjScale, 2.0f, looseSel);
CHECK(strictSel.size() >= looseSel.size());
}