diff --git a/inc/cvc/gl/LodGraphicsNode.h b/inc/cvc/gl/LodGraphicsNode.h new file mode 100644 index 000000000..9aa3d1970 --- /dev/null +++ b/inc/cvc/gl/LodGraphicsNode.h @@ -0,0 +1,80 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// LodGraphicsNode.h -- a scene-graph node that owns a mesh LOD ladder and draws +// exactly one rung, chosen each frame by camera distance. +// +// It holds one GeometryNode child per rung (rung 0 = finest) plus the pyramid's +// monotone world_error_m ladder, and is itself a prop-less container. select(view) +// runs the pure math in -- bound-nearest distance to the node's +// world bounding sphere, then the hysteretic select_rung over the ladder -- and +// sets exactly the chosen rung's child visible. A SceneGraph-level pass (or the +// caller) invokes select() per frame; nothing here draws, so the selection is +// testable with no GL context. +// +// LOD is a render proxy only: a rung is never read by a nav/material/RF path. + +#ifndef __CVC_GL_LOD_GRAPHICS_NODE_H__ +#define __CVC_GL_LOD_GRAPHICS_NODE_H__ + +#include +#include +#include +#include +#include + +namespace cvc { +namespace gl { + +class GeometryNode; + +class LodGraphicsNode : public GraphicsNode { +public: + LodGraphicsNode(cvc::app &ctx, const std::string &statePath, const std::string &name = "lod"); + ~LodGraphicsNode() override; + + // Populate from a built mesh pyramid: one GeometryNode child per rung, and the + // ladder select() uses. Replaces any prior rungs. Rung 0 starts visible. + void setPyramid(const cvc::lod::mesh_pyramid &pyr); + + int rungCount() const { return static_cast(m_rungs.size()); } + int selectedRung() const { return m_current; } + double rungError(int k) const; + + // Choose the affordable rung for `view` and show only it. Returns the rung + // index (0 = finest). A no-op returning 0 when empty. + int select(const cvc::lod::view_params &view); + + // Untransformed extent of rung 0 (the full-detail mesh) -- what distance and + // culling should see regardless of which rung currently draws. + cvc::bounding_box getBoundingBox() const override; + +protected: + vtkProp *getProp() override; // prop-less container; the rungs are children + +private: + std::vector> m_rungs; + std::vector m_worldError; // monotone; matches m_rungs + int m_current = 0; +}; + +} // namespace gl +} // namespace cvc + +#endif // __CVC_GL_LOD_GRAPHICS_NODE_H__ diff --git a/inc/cvc/gl/SceneGraph.h b/inc/cvc/gl/SceneGraph.h index 5de0f83f4..9394dbe9a 100644 --- a/inc/cvc/gl/SceneGraph.h +++ b/inc/cvc/gl/SceneGraph.h @@ -25,6 +25,9 @@ class app; class geometry; class volume; class state; +namespace lod { +struct view_params; +} namespace gl { class state_publisher; class ShadowSettings; @@ -35,6 +38,7 @@ class NullGraphicNode; class GridNode; class AxisNode; class BBoxNode; +class LodGraphicsNode; class SceneGraph { public: @@ -279,6 +283,12 @@ class SceneGraph { } size_t getGeometryGraphicsCount() const { return getAllGeometryGraphics().size(); } + // Per-frame LOD pass: choose the rung for every LodGraphicsNode in the scene + // from this camera view (cvc::lod::view_params: eye, viewport height, fov, error + // budget), toggling each node's visible rung. Call once per frame before render. + // Returns the number of LOD nodes visited. A no-op (0) when there are none. + int selectLOD(const cvc::lod::view_params &view); + // Multi-volume rendering control void enableMultiVolumeRendering(bool enable); bool isMultiVolumeRenderingEnabled() const; diff --git a/inc/cvc/lod/store.h b/inc/cvc/lod/store.h index b522a98ec..a1c044ce8 100644 --- a/inc/cvc/lod/store.h +++ b/inc/cvc/lod/store.h @@ -134,6 +134,16 @@ std::vector read_lod_index(app &ctx, const std::string &h5file) bool has_pyramid(app &ctx, const std::string &h5file, const std::string &name, const std::string &source_hash); +// ── bake ──────────────────────────────────────────────────────────────────── +// Build a mesh's LOD pyramid and write it into `h5file` under `name`, SKIPPING the +// work when a current pyramid (one whose source_hash matches this mesh) is already +// present -- unless `force`. `pool` fans the pyramid build over the app workers. +// Returns true if it (re)baked, false if it skipped an up-to-date pyramid. This is +// the reusable core of the cvc-lod-bake tool. +bool bake_mesh_asset(app &ctx, const std::string &h5file, const std::string &name, + const geometry &src, const pyramid_params ¶ms = pyramid_params(), + bool force = false, thread_pool *pool = nullptr); + } // namespace lod } // namespace cvc diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index c9e0f763c..a6a744ba3 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1705,6 +1705,13 @@ if(CVC_BUILD_LSYS_TOOLS) add_executable(cvc-worldgen ${CMAKE_CURRENT_SOURCE_DIR}/tools/cvc_worldgen_main.cpp) target_link_libraries(cvc-worldgen PRIVATE cvc) target_compile_features(cvc-worldgen PRIVATE cxx_std_17) + # cvc-lod-bake caches a mesh's LOD pyramid in a scene.cvch5 (needs the HDF5 store). + if(CVC_USING_HDF5) + add_executable(cvc-lod-bake ${CMAKE_CURRENT_SOURCE_DIR}/tools/cvc_lod_bake_main.cpp) + target_link_libraries(cvc-lod-bake PRIVATE cvc) + target_include_directories(cvc-lod-bake PRIVATE ${HDF5_INCLUDE_DIRS}) + target_compile_features(cvc-lod-bake PRIVATE cxx_std_17) + endif() endif() # Windows-specific batch file diff --git a/src/cvc/lod/store.cpp b/src/cvc/lod/store.cpp index 409ee0f33..16d285daa 100644 --- a/src/cvc/lod/store.cpp +++ b/src/cvc/lod/store.cpp @@ -28,6 +28,7 @@ #include #include #include +#include namespace cvc { namespace lod { @@ -416,5 +417,25 @@ bool has_pyramid(app &ctx, const std::string &h5file, const std::string &name, } } +bool bake_mesh_asset(app &ctx, const std::string &h5file, const std::string &name, + const geometry &src, const pyramid_params ¶ms, bool force, + thread_pool *pool) { + const std::string hash = mesh_content_hash(src); + if (!force) { + std::ifstream probe(h5file.c_str(), std::ios::binary); + if (probe.good()) { + probe.close(); + try { + if (scene_reader(ctx, h5file).has(name, hash)) + return false; // an up-to-date pyramid is already baked + } catch (...) { + } + } + } + mesh_pyramid pyr = build_mesh_pyramid(src, params, pool); + scene_writer(ctx, h5file).write_mesh_pyramid(name, pyr, hash); + return true; +} + } // namespace lod } // namespace cvc diff --git a/src/cvc/tests/lod_store_test.cpp b/src/cvc/tests/lod_store_test.cpp index 98222fea0..808c061ee 100644 --- a/src/cvc/tests/lod_store_test.cpp +++ b/src/cvc/tests/lod_store_test.cpp @@ -178,3 +178,22 @@ TEST(LodStore, InMemoryBlobRoundTrips) { EXPECT_TRUE(r.has("buildings", cvc::lod::mesh_content_hash(m))); EXPECT_FALSE(r.has("buildings", "nope")); } + +TEST(LodStore, BakeMeshAssetSkipsWhenCurrent) { + cvc::app ctx; + geometry m = bumpy_grid(ctx, 20); + const std::string file = tmp_h5(); + std::remove(file.c_str()); + + EXPECT_TRUE(cvc::lod::bake_mesh_asset(ctx, file, "buildings", m)); // first -> baked + EXPECT_TRUE(cvc::lod::has_pyramid(ctx, file, "buildings", cvc::lod::mesh_content_hash(m))); + EXPECT_GE(cvc::lod::read_mesh_pyramid(ctx, file, "buildings").rungs.size(), 2u); + + EXPECT_FALSE(cvc::lod::bake_mesh_asset(ctx, file, "buildings", m)); // same mesh -> skipped + EXPECT_TRUE(cvc::lod::bake_mesh_asset(ctx, file, "buildings", m, cvc::lod::pyramid_params(), + /*force=*/true)); // forced -> rebaked + + geometry m2 = bumpy_grid(ctx, 26); // different content + EXPECT_TRUE(cvc::lod::bake_mesh_asset(ctx, file, "buildings", m2)); // new hash -> rebaked + std::remove(file.c_str()); +} diff --git a/src/cvc/tools/cvc_lod_bake_main.cpp b/src/cvc/tools/cvc_lod_bake_main.cpp new file mode 100644 index 000000000..b42ae01b0 --- /dev/null +++ b/src/cvc/tools/cvc_lod_bake_main.cpp @@ -0,0 +1,116 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// cvc-lod-bake -- build a mesh's LOD pyramid and cache it in a scene.cvch5. +// +// cvc-lod-bake INPUT.{off,raw} OUTPUT.cvch5 [--name NAME] [--rungs N] +// [--ratio R] [--force] +// +// INPUT is read with cvc::geometry (OFF / cvc-raw). A pyramid is decimated with +// the app compute pool and written under NAME (default: the input basename), +// skipping the work when an up-to-date pyramid is already present unless --force. +// Model formats (glTF/OBJ) are baked from code via cvc::lod::bake_mesh_asset on a +// mesh the caller has already loaded (that is how the demo bundle bake works); +// this CLI stays hermetic and covers the native mesh formats. Requires HDF5. + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::string basename_noext(const std::string &path) { + std::size_t slash = path.find_last_of("/\\"); + std::string base = (slash == std::string::npos) ? path : path.substr(slash + 1); + std::size_t dot = base.find_last_of('.'); + return (dot == std::string::npos) ? base : base.substr(0, dot); +} + +void usage() { + std::fprintf(stderr, "usage: cvc-lod-bake INPUT.{off,raw} OUTPUT.cvch5 " + "[--name NAME] [--rungs N] [--ratio R] [--force]\n"); +} + +} // namespace + +int main(int argc, char **argv) { + std::string input, output, name; + cvc::lod::pyramid_params pp; + bool force = false; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : std::string(); }; + if (a == "-h" || a == "--help") { + usage(); + return 0; + } else if (a == "--name") { + name = next(); + } else if (a == "--rungs") { + pp.max_rungs = std::atoi(next().c_str()); + } else if (a == "--ratio") { + pp.mesh_ratio = std::atof(next().c_str()); + } else if (a == "--force") { + force = true; + } else if (input.empty()) { + input = a; + } else if (output.empty()) { + output = a; + } else { + std::fprintf(stderr, "cvc-lod-bake: unexpected argument '%s'\n", a.c_str()); + usage(); + return 2; + } + } + if (input.empty() || output.empty()) { + usage(); + return 2; + } + if (name.empty()) + name = basename_noext(input); + + cvc::app app; + cvc::geometry g(app); + try { + g.read(input); + } catch (const std::exception &e) { + std::fprintf(stderr, "cvc-lod-bake: cannot read '%s': %s\n", input.c_str(), e.what()); + return 1; + } + if (g.num_tris() == 0) { + std::fprintf(stderr, "cvc-lod-bake: '%s' has no triangles\n", input.c_str()); + return 1; + } + + try { + const bool baked = + cvc::lod::bake_mesh_asset(app, output, name, g, pp, force, &app.computePool()); + std::printf("%s '%s' (%llu tris) -> %s\n", baked ? "baked" : "up to date, skipped", + name.c_str(), static_cast(g.num_tris()), output.c_str()); + } catch (const std::exception &e) { + std::fprintf(stderr, "cvc-lod-bake: bake failed: %s\n", e.what()); + return 1; + } + return 0; +} diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index 9f576911b..7816eb085 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -245,6 +245,13 @@ add_executable(cvcgl_smoke test/cvcgl_smoke.cpp) target_link_libraries(cvcgl_smoke PRIVATE cvcGL) add_test(NAME cvcgl_smoke COMMAND cvcgl_smoke) +# LodGraphicsNode distance-based rung selection (the cvc::lod -> scene-graph seam). +# Headless: null renderer, no GL context; asserts close->finest, far->coarsest, +# monotone coarsening with distance. +add_executable(cvcgl_lod_node test/cvcgl_lod_node.cpp) +target_link_libraries(cvcgl_lod_node PRIVATE cvcGL) +add_test(NAME cvcgl_lod_node COMMAND cvcgl_lod_node) + # Spectral GPU FFT ocean (the ABYSSAL port used by lsystem_coast). Checks the # invariants a broken butterfly IFFT / bit-reversal / conjugate-symmetry would # violate: zero-mean height, unit-mean Jacobian, determinism, time evolution and diff --git a/src/cvcGL/LodGraphicsNode.cpp b/src/cvcGL/LodGraphicsNode.cpp new file mode 100644 index 000000000..6b81b09a3 --- /dev/null +++ b/src/cvcGL/LodGraphicsNode.cpp @@ -0,0 +1,78 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include +#include +#include + +namespace cvc { +namespace gl { + +LodGraphicsNode::LodGraphicsNode(cvc::app &ctx, const std::string &statePath, + const std::string &name) + : GraphicsNode(ctx, statePath, name) {} + +LodGraphicsNode::~LodGraphicsNode() = default; + +void LodGraphicsNode::setPyramid(const cvc::lod::mesh_pyramid &pyr) { + m_rungs.clear(); + m_worldError.clear(); + m_current = 0; + for (std::size_t k = 0; k < pyr.rungs.size(); ++k) { + auto child = addGraphicsChild("lod" + std::to_string(k)); + child->setGeometry(pyr.rungs[k]); + child->setVisible(k == 0); // rung 0 (finest) shows until the first select() + m_rungs.push_back(child); + m_worldError.push_back(k < pyr.world_error_m.size() ? pyr.world_error_m[k] : 0.0); + } +} + +double LodGraphicsNode::rungError(int k) const { + return (k >= 0 && k < static_cast(m_worldError.size())) ? m_worldError[k] : 0.0; +} + +cvc::bounding_box LodGraphicsNode::getBoundingBox() const { + if (m_rungs.empty()) + return cvc::bounding_box(); + return m_rungs.front()->getBoundingBox(); // rung 0 = the full-detail extent +} + +vtkProp *LodGraphicsNode::getProp() { + return nullptr; // prop-less container; the visible rung is a child GeometryNode +} + +int LodGraphicsNode::select(const cvc::lod::view_params &view) { + const int n = static_cast(m_rungs.size()); + if (n == 0) + return 0; + const cvc::bounding_box bb = getWorldBoundingBox(); + const double centre[3] = {0.5 * (bb.minx + bb.maxx), 0.5 * (bb.miny + bb.maxy), + 0.5 * (bb.minz + bb.maxz)}; + const double dx = bb.maxx - bb.minx, dy = bb.maxy - bb.miny, dz = bb.maxz - bb.minz; + const double radius = 0.5 * std::sqrt(dx * dx + dy * dy + dz * dz); + const double dist = cvc::lod::bound_distance_m(centre, radius, view); + const int k = cvc::lod::select_rung(dist, m_worldError.data(), n, m_current, view); + for (int i = 0; i < n; ++i) + m_rungs[i]->setVisible(i == k); + m_current = k; + return k; +} + +} // namespace gl +} // namespace cvc diff --git a/src/cvcGL/SceneGraph.cpp b/src/cvcGL/SceneGraph.cpp index e762c5a9c..b564603d3 100644 --- a/src/cvcGL/SceneGraph.cpp +++ b/src/cvcGL/SceneGraph.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -1157,5 +1158,16 @@ bool SceneGraph::setShadowsEnabled(bool enabled) { return true; } +int SceneGraph::selectLOD(const cvc::lod::view_params &view) { + int n = 0; + for (const auto &node : getAllGraphicsOfType()) { + if (node) { + node->select(view); + ++n; + } + } + return n; +} + } // namespace gl } // namespace cvc diff --git a/src/cvcGL/test/cvcgl_lod_node.cpp b/src/cvcGL/test/cvcgl_lod_node.cpp new file mode 100644 index 000000000..0de4423a4 --- /dev/null +++ b/src/cvcGL/test/cvcgl_lod_node.cpp @@ -0,0 +1,94 @@ +// cvcgl_lod_node -- LodGraphicsNode picks the right rung by camera distance. +// +// Headless: builds a 3-rung LOD node (a unit box at each rung, with a +// world-error ladder 0 / 1 / 4 m) and calls select(view) from a series of +// eye distances. No renderer, so it exercises the whole selection path without a +// GL context. Asserts: a close camera picks the finest rung (0), a very far one +// picks the coarsest (2), the choice coarsens monotonically with distance, and +// exactly one rung is selected. select() is the seam between cvc::lod's pure math +// and the scene graph, so this is the integration gate for that seam. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +cvc::geometry unit_box(cvc::app &app) { + cvc::geometry g(app); + cvc::geometry::points_t &P = g.points(); + for (int i = 0; i < 8; ++i) + P.push_back({(i & 1) ? 0.5 : -0.5, (i & 2) ? 0.5 : -0.5, (i & 4) ? 0.5 : -0.5}); + const int faces[12][3] = {{0, 1, 3}, {0, 3, 2}, {4, 6, 7}, {4, 7, 5}, {0, 4, 5}, {0, 5, 1}, + {2, 3, 7}, {2, 7, 6}, {0, 2, 6}, {0, 6, 4}, {1, 5, 7}, {1, 7, 3}}; + cvc::geometry::tris_t &T = g.tris(); + for (const auto &f : faces) + T.push_back({std::uint64_t(f[0]), std::uint64_t(f[1]), std::uint64_t(f[2])}); + g.set_geometry_type(cvc::geometry::SURFACE_TRI); + return g; +} + +} // namespace + +int main() { + cvc::app app; + + // 3 rungs (all the same unit box; only the error ladder drives selection). + cvc::lod::mesh_pyramid pyr; + for (int k = 0; k < 3; ++k) + pyr.rungs.push_back(unit_box(app)); + pyr.world_error_m = {0.0, 1.0, 4.0}; + + auto node = std::make_shared(app, "test.lod", "lod"); + node->setPyramid(pyr); + assert(node->rungCount() == 3 && "three rungs installed"); + + cvc::lod::view_params view = cvc::lod::preset_view(cvc::lod::quality_preset::balanced); + auto pick = [&](double dist) { + view.eye[0] = 0.0; + view.eye[1] = 0.0; + view.eye[2] = dist; // box centre is the origin + return node->select(view); + }; + + const int at_near = pick(1.0); + const int at_far = pick(1.0e6); + assert(at_near == 0 && "a close camera must draw the finest rung"); + assert(at_far == 2 && "a very far camera must drop to the coarsest rung"); + assert(node->selectedRung() == at_far && "selectedRung tracks the last select()"); + + // Monotone coarsening as the camera retreats. + int prev = pick(0.6); // right at the surface + const double dists[] = {2.0, 10.0, 50.0, 200.0, 1000.0, 20000.0, 1.0e6}; + for (double d : dists) { + int r = pick(d); + assert(r >= prev && "rung must not get FINER as the camera retreats"); + assert(r >= 0 && r < node->rungCount()); + prev = r; + } + + // SceneGraph::selectLOD drives every LodGraphicsNode in the scene at once. + { + cvc::gl::SceneGraph sg(app, "lodtest"); + auto lod = sg.getGraphicsRoot()->addGraphicsChild("lodnode"); + lod->setPyramid(pyr); + view.eye[0] = 0.0; + view.eye[1] = 0.0; + view.eye[2] = 1.0e6; // far + const int visited = sg.selectLOD(view); + assert(visited == 1 && "selectLOD visits the one LOD node"); + assert(lod->selectedRung() == 2 && "far view -> coarsest rung via selectLOD"); + view.eye[2] = 1.0; // near + sg.selectLOD(view); + assert(lod->selectedRung() == 0 && "near view -> finest rung via selectLOD"); + } + + std::printf("cvcgl_lod_node OK\n"); + return 0; +}