feat: LOD mesh generation + incremental BVH

This commit is contained in:
茂之钳
2026-07-24 16:14:09 +00:00
parent 5a2f01f597
commit e02c5980b4
7 changed files with 455 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include "vde/mesh/halfedge_mesh.h"
#include <vector>
#include <string>
namespace vde::brep { class BrepModel; }
namespace vde::mesh {
/**
* @brief Level-of-detail mesh with multiple detail levels
*
* Stores a sequence of simplified meshes from highest detail (index 0)
* to lowest. Screen-space error thresholds determine which level to
* use for rendering.
*
* @ingroup mesh
*/
struct LODMesh {
std::string name;
/// Meshes from highest detail (index 0) to lowest
std::vector<HalfedgeMesh> levels;
/// Screen-space error thresholds for each level (in pixels)
std::vector<double> thresholds;
/**
* @brief Get the appropriate LOD level for a given screen-space error
*
* Selects the lowest-detail level whose threshold is ≤ error.
* Walks thresholds from coarsest to finest.
*
* @param error Screen-space error in pixels
* @return LOD level index (0 = highest detail)
*/
[[nodiscard]] int level_for_error(double error) const;
/**
* @brief Get the mesh at a specific LOD level (clamped)
*
* @param level Desired LOD level
* @return Reference to the mesh at that level
*/
[[nodiscard]] const HalfedgeMesh& mesh_at(int level) const;
};
/**
* @brief Generate LOD meshes from a high-detail mesh
*
* Iteratively applies QEM simplification, reducing face count
* by `reduction_ratio` at each level.
*
* @param mesh High-detail input mesh
* @param num_levels Number of LOD levels (including base)
* @param reduction_ratio Ratio of faces to keep at each level (0.5 = half)
* @return LOD meshes with thresholds proportional to level index
*
* @code{.cpp}
* auto lod = generate_lod(high_res, 4, 0.5);
* int level = lod.level_for_error(screen_error);
* render(lod.mesh_at(level));
* @endcode
*/
[[nodiscard]] LODMesh generate_lod(const HalfedgeMesh& mesh,
int num_levels = 3, double reduction_ratio = 0.5);
/**
* @brief Generate LOD from B-Rep body (tessellate + simplify)
*
* Tessellates the B-Rep body with default deflection, then
* generates LOD levels via QEM simplification.
*
* @param body B-Rep model
* @param num_levels Number of LOD levels
* @return LOD meshes
*/
[[nodiscard]] LODMesh generate_brep_lod(const brep::BrepModel& body,
int num_levels = 3);
} // namespace vde::mesh
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include "vde/spatial/bvh.h"
#include <functional>
#include <vector>
namespace vde::spatial {
/**
* @brief Incremental BVH that supports insert/remove without full rebuild
*
* Wraps the standard BVH with a dirty flag. Triangles can be inserted,
* removed, or updated incrementally. The tree is lazily rebuilt on the
* next query when dirty. Suitable for moderately dynamic scenes where
* queries are less frequent than modifications.
*
* @ingroup spatial
*/
class IncrementalBVH {
public:
/// @brief Build from initial set of triangles
void build(const std::vector<core::Triangle3D>& triangles);
/// @brief Insert a new triangle (marks dirty, no immediate rebuild)
void insert(const core::Triangle3D& tri);
/// @brief Remove a triangle by index (marks dirty)
void remove(size_t index);
/// @brief Update a triangle's geometry in-place (marks dirty)
void update(size_t index, const core::Triangle3D& tri);
/**
* @brief Ray query (rebuilds if dirty, then delegates to BVH)
*
* @param ray Query ray
* @return All hit results (t, triangle, hit point)
*/
std::vector<BVH::HitResult> ray_query(const Ray3Dd& ray) const;
/// @brief Number of triangles
[[nodiscard]] size_t size() const { return triangles_.size(); }
private:
std::vector<core::Triangle3D> triangles_;
std::vector<core::AABB3D> bboxes_;
BVH bvh_;
bool dirty_ = true;
void rebuild_if_dirty();
};
} // namespace vde::spatial
+2
View File
@@ -72,6 +72,7 @@ add_library(vde_mesh STATIC
mesh/geodesic.cpp
mesh/mesh_curvature.cpp
mesh/marching_cubes.cpp
mesh/mesh_lod.cpp
)
target_include_directories(vde_mesh
PUBLIC ${CMAKE_SOURCE_DIR}/include
@@ -87,6 +88,7 @@ add_library(vde_spatial STATIC
spatial/octree.cpp
spatial/kd_tree.cpp
spatial/r_tree.cpp
spatial/incremental_bvh.cpp
)
target_include_directories(vde_spatial
PUBLIC ${CMAKE_SOURCE_DIR}/include
+55
View File
@@ -0,0 +1,55 @@
#include "vde/mesh/mesh_lod.h"
#include "vde/mesh/mesh_simplify.h"
#include "vde/brep/brep.h"
#include <algorithm>
namespace vde::mesh {
LODMesh generate_lod(const HalfedgeMesh& mesh, int num_levels, double reduction_ratio) {
LODMesh result;
result.name = "lod";
result.levels.push_back(mesh);
result.thresholds.push_back(0.0); // base level: always use
HalfedgeMesh current = mesh;
for (int i = 1; i < num_levels; ++i) {
int current_faces = static_cast<int>(current.num_faces());
int target_faces = static_cast<int>(current_faces * reduction_ratio);
if (target_faces < 4) break;
SimplifyOptions opts;
opts.target_ratio = static_cast<double>(target_faces) / current_faces;
opts.max_iterations = 100;
opts.preserve_boundary = true;
current = simplify_mesh(current, opts);
result.levels.push_back(current);
result.thresholds.push_back(static_cast<double>(i) * 100.0);
}
return result;
}
LODMesh generate_brep_lod(const brep::BrepModel& body, int num_levels) {
auto mesh = body.to_mesh(0.1);
return generate_lod(mesh, num_levels);
}
int LODMesh::level_for_error(double error) const {
for (int i = static_cast<int>(thresholds.size()) - 1; i >= 0; --i) {
if (error >= thresholds[i]) return i;
}
return 0;
}
const HalfedgeMesh& LODMesh::mesh_at(int level) const {
if (levels.empty()) {
static HalfedgeMesh empty;
return empty;
}
if (level < 0) level = 0;
if (level >= static_cast<int>(levels.size())) level = static_cast<int>(levels.size()) - 1;
return levels[level];
}
} // namespace vde::mesh
+115
View File
@@ -0,0 +1,115 @@
#include "vde/spatial/incremental_bvh.h"
#include "vde/core/line.h"
#include <cmath>
#include <algorithm>
namespace vde::spatial {
using core::Point3D;
using core::Vector3D;
namespace {
/// Compute AABB from triangle vertices
AABB3D triangle_bounds(const Triangle3D& tri) {
double min_x = std::min({tri.v(0).x(), tri.v(1).x(), tri.v(2).x()});
double min_y = std::min({tri.v(0).y(), tri.v(1).y(), tri.v(2).y()});
double min_z = std::min({tri.v(0).z(), tri.v(1).z(), tri.v(2).z()});
double max_x = std::max({tri.v(0).x(), tri.v(1).x(), tri.v(2).x()});
double max_y = std::max({tri.v(0).y(), tri.v(1).y(), tri.v(2).y()});
double max_z = std::max({tri.v(0).z(), tri.v(1).z(), tri.v(2).z()});
return AABB3D(Point3D(min_x, min_y, min_z), Point3D(max_x, max_y, max_z));
}
/// MöllerTrumbore ray-triangle intersection
bool mt_intersect(const Ray3Dd& ray, const Triangle3D& tri,
double& t_out, Point3D& point_out) {
const auto& v0 = tri.v(0);
const auto& v1 = tri.v(1);
const auto& v2 = tri.v(2);
Vector3D e1 = v1 - v0;
Vector3D e2 = v2 - v0;
Vector3D pvec = ray.direction().cross(e2);
double det = e1.dot(pvec);
if (std::abs(det) < 1e-12) return false;
double inv_det = 1.0 / det;
Vector3D tvec = ray.origin() - v0;
double u = tvec.dot(pvec) * inv_det;
if (u < 0.0 || u > 1.0) return false;
Vector3D qvec = tvec.cross(e1);
double v = ray.direction().dot(qvec) * inv_det;
if (v < 0.0 || u + v > 1.0) return false;
t_out = e2.dot(qvec) * inv_det;
if (t_out <= 0.0) return false;
point_out = ray.origin() + ray.direction() * t_out;
return true;
}
} // anonymous namespace
void IncrementalBVH::build(const std::vector<Triangle3D>& triangles) {
triangles_ = triangles;
bboxes_.resize(triangles.size());
for (size_t i = 0; i < triangles.size(); ++i) {
bboxes_[i] = triangle_bounds(triangles_[i]);
}
bvh_.build(triangles_);
dirty_ = false;
}
void IncrementalBVH::insert(const Triangle3D& tri) {
triangles_.push_back(tri);
bboxes_.push_back(triangle_bounds(tri));
dirty_ = true;
}
void IncrementalBVH::remove(size_t index) {
if (index < triangles_.size()) {
triangles_.erase(triangles_.begin() + static_cast<ptrdiff_t>(index));
bboxes_.erase(bboxes_.begin() + static_cast<ptrdiff_t>(index));
dirty_ = true;
}
}
void IncrementalBVH::update(size_t index, const Triangle3D& tri) {
if (index < triangles_.size()) {
triangles_[index] = tri;
bboxes_[index] = triangle_bounds(tri);
dirty_ = true;
}
}
void IncrementalBVH::rebuild_if_dirty() {
if (dirty_ && !triangles_.empty()) {
bvh_ = BVH();
bvh_.build(triangles_);
dirty_ = false;
}
}
std::vector<BVH::HitResult> IncrementalBVH::ray_query(const Ray3Dd& ray) const {
// Rebuild tree if modifications have occurred since last query
const_cast<IncrementalBVH*>(this)->rebuild_if_dirty();
// Use BVH to find candidate triangles
auto candidates = bvh_.query_ray(ray);
// Compute exact intersections for each candidate
std::vector<BVH::HitResult> hits;
for (const auto& tri : candidates) {
double t;
Point3D point;
if (mt_intersect(ray, tri, t, point)) {
hits.push_back({t, tri, point});
}
}
return hits;
}
} // namespace vde::spatial
+1
View File
@@ -3,3 +3,4 @@ add_vde_test(test_delaunay)
add_vde_test(test_quality)
add_vde_test(test_smooth)
add_vde_test(test_delaunay_3d)
add_vde_test(test_mesh_lod)
+151
View File
@@ -0,0 +1,151 @@
#include <gtest/gtest.h>
#include "vde/mesh/mesh_lod.h"
#include "vde/mesh/halfedge_mesh.h"
#include <cmath>
using namespace vde::mesh;
/// Create a simple box mesh (12 triangles, 8 vertices)
static HalfedgeMesh make_box_mesh() {
// Unit cube vertices
std::vector<Point3D> verts = {
{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, // bottom
{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}, // top
};
// 12 triangles (2 per face)
std::vector<std::array<int, 3>> tris = {
{0, 2, 1}, {0, 3, 2}, // front
{1, 2, 6}, {1, 6, 5}, // right
{5, 6, 7}, {5, 7, 4}, // back
{4, 7, 3}, {4, 3, 0}, // left
{3, 7, 6}, {3, 6, 2}, // top
{4, 0, 1}, {4, 1, 5}, // bottom
};
HalfedgeMesh mesh;
mesh.build_from_triangles(verts, tris);
return mesh;
}
// ──────────────────────────────────────────────────────
// LOD generation
// ──────────────────────────────────────────────────────
TEST(LODMeshTest, GenerateLOD_DefaultLevels) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
// Default: 3 levels (base + 2 simplified)
EXPECT_EQ(lod.levels.size(), 3u);
EXPECT_EQ(lod.thresholds.size(), 3u);
// Level 0 is the original mesh (12 faces)
EXPECT_GE(lod.levels[0].num_faces(), 8u);
// Each level should have ≤ faces than previous
EXPECT_LE(lod.levels[1].num_faces(), lod.levels[0].num_faces());
EXPECT_LE(lod.levels[2].num_faces(), lod.levels[1].num_faces());
}
TEST(LODMeshTest, GenerateLOD_SingleLevel) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 1, 0.5);
// Only 1 level = base (no simplification)
EXPECT_EQ(lod.levels.size(), 1u);
EXPECT_EQ(lod.levels[0].num_faces(), 12u);
}
TEST(LODMeshTest, GenerateLOD_AggressiveReduction) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 5, 0.1); // keep only 10% each level
// Should stop early when < 4 faces remain
EXPECT_GE(lod.levels.size(), 1u);
EXPECT_LE(lod.levels.size(), 5u);
}
TEST(LODMeshTest, GenerateLOD_EmptyMesh) {
HalfedgeMesh empty;
auto lod = generate_lod(empty, 3, 0.5);
EXPECT_EQ(lod.levels.size(), 1u); // only base
EXPECT_EQ(lod.levels[0].num_faces(), 0u);
}
// ──────────────────────────────────────────────────────
// level_for_error
// ──────────────────────────────────────────────────────
TEST(LODMeshTest, LevelForError_ZeroError) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
// error 0 → base level (threshold 0.0)
EXPECT_EQ(lod.level_for_error(0.0), 0);
}
TEST(LODMeshTest, LevelForError_ThresholdBoundary) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
// thresholds: [0.0, 100.0, 200.0]
EXPECT_EQ(lod.level_for_error(50.0), 0);
EXPECT_EQ(lod.level_for_error(100.0), 1); // ≥ 100 → level 1
EXPECT_EQ(lod.level_for_error(150.0), 1);
EXPECT_EQ(lod.level_for_error(200.0), 2); // ≥ 200 → level 2
EXPECT_EQ(lod.level_for_error(999.0), 2);
}
TEST(LODMeshTest, LevelForError_NegativeError) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
// Negative error → level 0 (base)
EXPECT_EQ(lod.level_for_error(-1.0), 0);
}
// ──────────────────────────────────────────────────────
// mesh_at
// ──────────────────────────────────────────────────────
TEST(LODMeshTest, MeshAt_ValidLevels) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
EXPECT_EQ(lod.mesh_at(0).num_faces(), 12u);
EXPECT_LE(lod.mesh_at(1).num_faces(), 12u);
EXPECT_LE(lod.mesh_at(2).num_faces(), lod.mesh_at(1).num_faces());
}
TEST(LODMeshTest, MeshAt_ClampNegative) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
// Negative level → clamped to 0
EXPECT_EQ(lod.mesh_at(-1).num_faces(), lod.mesh_at(0).num_faces());
EXPECT_EQ(lod.mesh_at(-100).num_faces(), lod.mesh_at(0).num_faces());
}
TEST(LODMeshTest, MeshAt_ClampTooHigh) {
auto box = make_box_mesh();
auto lod = generate_lod(box, 3, 0.5);
int last = static_cast<int>(lod.levels.size()) - 1;
EXPECT_EQ(lod.mesh_at(999).num_faces(), lod.mesh_at(last).num_faces());
}
TEST(LODMeshTest, MeshAt_EmptyLOD) {
LODMesh empty_lod;
// mesh_at returns a static empty mesh for empty LOD
EXPECT_EQ(empty_lod.mesh_at(0).num_faces(), 0u);
EXPECT_EQ(empty_lod.mesh_at(5).num_faces(), 0u);
}
// ──────────────────────────────────────────────────────
// generate_brep_lod (integration)
// ──────────────────────────────────────────────────────
// Forward-declare for linking only (no brep::make_box in this TU's link deps)
// This test is compile-time only; runtime needs brep linked.
// We test through the LOD API directly above; brep path is tested
// in the brep test suite.