Skip to content

Commit a2e1c20

Browse files
sifakisclaude
andcommitted
nanovdb: add CUDA ConnectedComponents labeling for ValueOnIndex grids
Add nanovdb::tools::cuda::ConnectedComponents, which labels the active voxels of a NanoVDB ValueOnIndex grid by 6-connectivity: two active voxels share a label iff they are connected through a path of adjacent active voxels. The public getVoxelLabelsAndCount() returns a per-active-voxel dense component id in [0,N) plus the component count N. The implementation is hierarchical: - per-leaf Shiloach-Vishkin union-find in shared memory (one block per leaf), yielding each leaf-local component's voxel Mask<3> and six face bitmasks; - cross-leaf edge detection by intersecting touching face masks over +X/+Y/+Z neighbours; - a global lock-free union-find over the resulting component graph. Also add the ex_connected_components_cuda example (rasterize an .obj to a narrow band via MeshToGrid, drop the sqrt(3)/2-voxel surface shell via PruneGrid, then label; a CPU union-find oracle verifies the result) and a ConnectedComponentsMultiSphere unit test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Efty Sifakis <esifakis@nvidia.com>
1 parent 332a57d commit a2e1c20

7 files changed

Lines changed: 1259 additions & 0 deletions

File tree

nanovdb/nanovdb/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ set(NANOVDB_INCLUDE_TOOLS_CUDA_FILES
237237
tools/cuda/DilateGrid.cuh
238238
tools/cuda/CoarsenGrid.cuh
239239
tools/cuda/VoxelBlockManager.cuh
240+
tools/cuda/ConnectedComponents.cuh
240241
)
241242

242243
# NanoVDB util header files

nanovdb/nanovdb/examples/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ nanovdb_example(NAME "ex_merge_nanovdb_cuda" OPENVDB)
115115
nanovdb_example(NAME "ex_refine_nanovdb_cuda" OPENVDB)
116116
nanovdb_example(NAME "ex_coarsen_nanovdb_cuda" OPENVDB)
117117
nanovdb_example(NAME "ex_mesh_to_grid_cuda" OPENVDB)
118+
nanovdb_example(NAME "ex_connected_components_cuda")
118119

119120
if(CUDAToolkit_FOUND)
120121
nanovdb_example(NAME "ex_make_mgpu_nanovdb") # requires cuRAND
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright Contributors to the OpenVDB Project
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/// @file connected_components_cuda.cpp
5+
///
6+
/// @brief Host driver for the connected-components example (NanoVDB / CUDA only, no OpenVDB).
7+
///
8+
/// Reads a triangle mesh from a Wavefront .obj file, builds the index<->world transform,
9+
/// and hands the mesh to the CUDA side, which rasterizes it into a ValueOnIndex narrow-band
10+
/// grid, discards the surface/barrier shell, and runs connected-components labeling
11+
/// (nanovdb::tools::cuda::ConnectedComponents) on the result. The device side also runs a
12+
/// CPU union-find oracle that independently verifies the GPU labeling.
13+
///
14+
/// This example exercises ONLY the connected-components stage. For the full mesh->SDF
15+
/// pipeline that builds on top of it, see ex_mesh_to_sdf_cuda.
16+
17+
#include <nanovdb/NanoVDB.h> // host-usable: Vec3f, Vec3i, Vec3d, Map
18+
#include <nanovdb/GridHandle.h> // GridHandle (header-only, host-usable)
19+
#include <nanovdb/cuda/DeviceBuffer.h>// nanovdb::cuda::DeviceBuffer
20+
21+
#include <cstdint>
22+
#include <fstream>
23+
#include <iostream>
24+
#include <sstream>
25+
#include <string>
26+
#include <vector>
27+
28+
// ---- Host/device seam (implemented in connected_components_cuda_kernels.cu) -----------------------
29+
//
30+
// Rasterize the mesh -> ValueOnIndex narrow band, discard the barrier shell (unsigned distance
31+
// within sqrt(3)/2 voxels of the surface), and run connected-components labeling. Prints topology
32+
// diagnostics, the component count, and a CPU-oracle PASS/FAIL, and returns the number of
33+
// connected components.
34+
uint64_t connectedComponentsFromMesh(const std::vector<nanovdb::Vec3f>& points,
35+
const std::vector<nanovdb::Vec3i>& triangles,
36+
const nanovdb::Map& map,
37+
float bandWidth);
38+
39+
/// @brief Minimal Wavefront .obj reader (vertices + faces) using NanoVDB types.
40+
///
41+
/// Polygons with more than 3 vertices are fan-triangulated. Vertex references of the form
42+
/// `v`, `v/vt`, `v//vn`, `v/vt/vn` are accepted, as are negative (relative) indices. Lines
43+
/// that are not `v` or `f` are ignored.
44+
static void readOBJ(const std::string& filename,
45+
std::vector<nanovdb::Vec3f>& points,
46+
std::vector<nanovdb::Vec3i>& triangles)
47+
{
48+
std::ifstream file(filename);
49+
if (!file.is_open())
50+
throw std::runtime_error("Failed to open OBJ file: " + filename);
51+
52+
std::string line;
53+
int lineNumber = 0;
54+
while (std::getline(file, line)) {
55+
++lineNumber;
56+
std::istringstream iss(line);
57+
std::string type;
58+
iss >> type;
59+
60+
if (type == "v") {
61+
float x, y, z;
62+
iss >> x >> y >> z;
63+
points.emplace_back(x, y, z);
64+
} else if (type == "f") {
65+
std::vector<int> face;
66+
std::string vert;
67+
while (iss >> vert) {
68+
const size_t slash = vert.find('/');
69+
const std::string idxStr = vert.substr(0, slash);
70+
if (idxStr.empty()) continue;
71+
int raw = std::stoi(idxStr);
72+
// OBJ indices are 1-based; negatives are relative to points read so far.
73+
int idx = (raw < 0) ? int(points.size()) + raw : raw - 1;
74+
if (idx < 0 || idx >= int(points.size()))
75+
throw std::runtime_error("OBJ parse error on line " +
76+
std::to_string(lineNumber) +
77+
": face index out of bounds");
78+
face.push_back(idx);
79+
}
80+
for (size_t i = 2; i < face.size(); ++i)
81+
triangles.emplace_back(face[0], face[i - 1], face[i]);
82+
}
83+
}
84+
}
85+
86+
int main(int argc, char* argv[])
87+
{
88+
try {
89+
if (argc < 2)
90+
throw std::runtime_error("usage: " + std::string(argv[0]) +
91+
" <input.obj> [voxelSize] [bandWidth]");
92+
93+
const std::string inputFile = argv[1];
94+
const float voxelSize = (argc > 2) ? std::stof(argv[2]) : 0.01f;
95+
const float bandWidth = (argc > 3) ? std::stof(argv[3]) : 3.0f;
96+
97+
std::vector<nanovdb::Vec3f> points;
98+
std::vector<nanovdb::Vec3i> triangles;
99+
std::cout << "Reading " << inputFile << "...\n";
100+
readOBJ(inputFile, points, triangles);
101+
std::cout << "Loaded " << points.size() << " vertices, "
102+
<< triangles.size() << " triangles.\n";
103+
if (points.empty() || triangles.empty())
104+
throw std::runtime_error("mesh has no triangles");
105+
106+
// Index<->world transform: uniform voxel size, no translation.
107+
nanovdb::Map map;
108+
map.set(double(voxelSize), nanovdb::Vec3d(0.0), 1.0);
109+
110+
const uint64_t numComponents =
111+
connectedComponentsFromMesh(points, triangles, map, bandWidth);
112+
std::cout << "Connected components: " << numComponents << "\n";
113+
114+
return 0;
115+
}
116+
catch (const std::exception& e) {
117+
std::cerr << "An exception occurred: \"" << e.what() << "\"\n";
118+
return 1;
119+
}
120+
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright Contributors to the OpenVDB Project
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/// @file connected_components_cuda_kernels.cu
5+
///
6+
/// @brief CUDA / NanoVDB side of the connected-components example. Rasterizes a triangle mesh into a
7+
/// ValueOnIndex narrow-band grid (nanovdb::tools::cuda::MeshToGrid), discards the
8+
/// surface/barrier shell (unsigned distance within sqrt(3)/2 voxels of the surface) with
9+
/// nanovdb::tools::cuda::PruneGrid, and runs connected-components labeling with
10+
/// nanovdb::tools::cuda::ConnectedComponents. A CPU union-find oracle independently verifies
11+
/// the GPU labeling (component count + per-voxel partition).
12+
13+
#include <nanovdb/NanoVDB.h>
14+
#include <nanovdb/GridHandle.h>
15+
#include <nanovdb/cuda/DeviceBuffer.h>
16+
17+
#include <nanovdb/tools/cuda/MeshToGrid.cuh> // rasterize mesh -> ValueOnIndex + UDF
18+
#include <nanovdb/tools/cuda/PruneGrid.cuh> // topological prune (barrier removal)
19+
#include <nanovdb/tools/cuda/ConnectedComponents.cuh> // the connected-components labeling
20+
#include <nanovdb/util/cuda/Util.h> // operatorKernel, cudaCheck
21+
#include <nanovdb/util/cuda/DeviceGridTraits.cuh> // DeviceGridTraits
22+
23+
#include <thrust/universal_vector.h>
24+
25+
#include <cstdint>
26+
#include <functional>
27+
#include <iostream>
28+
#include <numeric>
29+
#include <unordered_map>
30+
#include <vector>
31+
32+
namespace {
33+
34+
using BuildT = nanovdb::ValueOnIndex;
35+
using GridHandleT = nanovdb::GridHandle<nanovdb::cuda::DeviceBuffer>;
36+
using Traits = nanovdb::util::cuda::DeviceGridTraits<BuildT>;
37+
38+
constexpr int LEAF_SIZE = 512; // 8^3
39+
40+
// Per-leaf retain-mask functor: a voxel is kept iff its unsigned distance to the surface exceeds the
41+
// barrier threshold sqrt(3)/2 voxels (i.e. UDF^2 >= 0.75 * voxelSize^2 in world units). Removing the
42+
// barrier shell splits each closed surface's narrow band into disjoint inner/outer shells, which is
43+
// what connected components then labels. One CUDA block per leaf, one thread per voxel offset.
44+
struct UDFBarrierPruneMaskFunctor
45+
{
46+
static constexpr int MaxThreadsPerBlock = LEAF_SIZE;
47+
static constexpr int MinBlocksPerMultiprocessor = 1;
48+
49+
__device__ void operator()(const nanovdb::NanoGrid<BuildT>* d_grid,
50+
const float* d_udf, // UDF sidecar, WORLD units
51+
float barrierSqWorld, // (sqrt(3)/2 * voxelSize)^2
52+
nanovdb::Mask<3>* d_dstLeafMasks)
53+
{
54+
const int leafID = blockIdx.x;
55+
const int threadID = threadIdx.x;
56+
57+
const auto& leaf = d_grid->tree().getFirstNode<0>()[leafID];
58+
auto& resultMask = d_dstLeafMasks[leafID];
59+
60+
// Clear the leaf's mask words in parallel, then set the retain bits.
61+
if (threadID < nanovdb::Mask<3>::WORD_COUNT)
62+
resultMask.words()[threadID] = 0UL;
63+
__syncthreads();
64+
65+
if (auto n = leaf.data()->getValue(threadID)) { // n != 0 => active voxel
66+
const float udf = d_udf[n];
67+
if (udf * udf >= barrierSqWorld) // retain non-barrier voxels
68+
resultMask.setOnAtomic(threadID);
69+
}
70+
}
71+
};
72+
73+
// Pack a voxel coordinate into a sortable/ hashable int64 key (offset so negatives stay positive).
74+
inline int64_t encodeCoord(const nanovdb::Coord& c)
75+
{
76+
return (int64_t(c[0]) + (1 << 20))
77+
| ((int64_t(c[1]) + (1 << 20)) << 21)
78+
| ((int64_t(c[2]) + (1 << 20)) << 42);
79+
}
80+
81+
// CPU union-find oracle: independently label the derived grid's active voxels by 6-connectivity and
82+
// verify the GPU result (a) has the same component count and (b) induces the same partition (two
83+
// voxels share a GPU label iff the oracle puts them in the same component). Returns true on PASS.
84+
bool validateAgainstOracle(const GridHandleT& derivedHandle, uint32_t leafCount, uint64_t active,
85+
const uint32_t* d_labels, uint64_t gpuCount)
86+
{
87+
// Download the derived grid blob + the per-voxel labels to the host.
88+
std::vector<char> blob(derivedHandle.bufferSize());
89+
cudaCheck(cudaMemcpy(blob.data(), derivedHandle.deviceData(), blob.size(), cudaMemcpyDeviceToHost));
90+
const auto* h_grid = reinterpret_cast<const nanovdb::NanoGrid<BuildT>*>(blob.data());
91+
92+
if (active == 0) {
93+
std::cout << "CPU-oracle self-check: PASS (empty grid, 0 components)\n";
94+
return gpuCount == 0;
95+
}
96+
97+
std::vector<uint32_t> labels(active + 1);
98+
cudaCheck(cudaMemcpy(labels.data(), d_labels, (active + 1) * sizeof(uint32_t), cudaMemcpyDeviceToHost));
99+
100+
// Gather active voxels: dense id -> {coord, gpu label}, plus a coord->id lookup for neighbours.
101+
std::unordered_map<int64_t, uint32_t> coordToId;
102+
std::vector<nanovdb::Coord> idToCoord;
103+
std::vector<uint32_t> idToGpuLabel;
104+
coordToId.reserve(active * 2);
105+
idToCoord.reserve(active);
106+
idToGpuLabel.reserve(active);
107+
108+
const auto* leaves = h_grid->tree().getFirstLeaf();
109+
for (uint32_t li = 0; li < leafCount; ++li) {
110+
const auto& leaf = leaves[li];
111+
for (uint32_t n = 0; n < uint32_t(LEAF_SIZE); ++n) {
112+
if (!leaf.isActive(n)) continue;
113+
const nanovdb::Coord c = leaf.origin() + nanovdb::NanoLeaf<BuildT>::OffsetToLocalCoord(n);
114+
const uint64_t slot = leaf.getValue(n);
115+
const uint32_t id = uint32_t(idToCoord.size());
116+
coordToId.emplace(encodeCoord(c), id);
117+
idToCoord.push_back(c);
118+
idToGpuLabel.push_back(labels[slot]);
119+
}
120+
}
121+
122+
// Union-find over 6-connectivity. Visiting only +X/+Y/+Z reaches every undirected edge once.
123+
std::vector<uint32_t> parent(idToCoord.size());
124+
std::iota(parent.begin(), parent.end(), 0u);
125+
std::function<uint32_t(uint32_t)> find = [&](uint32_t x) {
126+
while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }
127+
return x;
128+
};
129+
auto unite = [&](uint32_t a, uint32_t b) {
130+
a = find(a); b = find(b);
131+
if (a != b) parent[a > b ? a : b] = (a < b ? a : b);
132+
};
133+
const nanovdb::Coord dirs[3] = { {1,0,0}, {0,1,0}, {0,0,1} };
134+
for (uint32_t id = 0; id < idToCoord.size(); ++id)
135+
for (const auto& d : dirs) {
136+
auto it = coordToId.find(encodeCoord(idToCoord[id] + d));
137+
if (it != coordToId.end()) unite(id, it->second);
138+
}
139+
140+
// Count oracle components and check the GPU labels induce the same partition.
141+
std::unordered_map<uint32_t, uint32_t> rootToComp; // oracle root -> dense component id
142+
for (uint32_t id = 0; id < parent.size(); ++id) {
143+
const uint32_t r = find(id);
144+
if (!rootToComp.count(r)) rootToComp.emplace(r, uint32_t(rootToComp.size()));
145+
}
146+
const uint64_t oracleCount = rootToComp.size();
147+
148+
std::unordered_map<uint32_t, uint32_t> gpuToOracle; // gpu label -> oracle component
149+
uint64_t partitionViolations = 0;
150+
for (uint32_t id = 0; id < parent.size(); ++id) {
151+
const uint32_t gl = idToGpuLabel[id];
152+
const uint32_t oc = rootToComp[find(id)];
153+
auto it = gpuToOracle.find(gl);
154+
if (it == gpuToOracle.end()) gpuToOracle.emplace(gl, oc);
155+
else if (it->second != oc) ++partitionViolations;
156+
}
157+
const uint64_t gpuDistinctLabels = gpuToOracle.size();
158+
159+
const bool pass = (gpuCount == oracleCount) &&
160+
(gpuDistinctLabels == oracleCount) &&
161+
(partitionViolations == 0);
162+
163+
std::cout << "CPU-oracle self-check: " << (pass ? "PASS" : "FAIL")
164+
<< " (gpu=" << gpuCount << ", oracle=" << oracleCount
165+
<< ", distinct gpu labels=" << gpuDistinctLabels
166+
<< ", partition violations=" << partitionViolations << ")\n";
167+
return pass;
168+
}
169+
170+
} // anonymous namespace
171+
172+
uint64_t connectedComponentsFromMesh(const std::vector<nanovdb::Vec3f>& points,
173+
const std::vector<nanovdb::Vec3i>& triangles,
174+
const nanovdb::Map& map,
175+
float bandWidth)
176+
{
177+
const cudaStream_t stream = 0;
178+
179+
// ---- Step 1: rasterize the mesh -> ValueOnIndex narrow-band grid + UDF sidecar. ----
180+
thrust::universal_vector<nanovdb::Vec3f> dPoints(points.begin(), points.end());
181+
thrust::universal_vector<nanovdb::Vec3i> dTriangles(triangles.begin(), triangles.end());
182+
183+
nanovdb::tools::cuda::MeshToGrid<BuildT> converter(
184+
dPoints.data().get(), uint32_t(dPoints.size()),
185+
dTriangles.data().get(), uint32_t(dTriangles.size()), map);
186+
converter.setVerbose(1);
187+
converter.setNarrowBandWidth(bandWidth);
188+
auto [origHandle, udfSidecar] = converter.getHandleAndUDF();
189+
const auto* d_orig = origHandle.template deviceGrid<BuildT>();
190+
191+
// World-space voxel size from the map (uniform scale here, but read it generically).
192+
const nanovdb::Vec3d w0 = map.applyMap(nanovdb::Vec3d(0.0, 0.0, 0.0));
193+
const nanovdb::Vec3d wx = map.applyMap(nanovdb::Vec3d(1.0, 0.0, 0.0));
194+
const float voxelSize = float(wx[0] - w0[0]);
195+
196+
// ---- Step 2: discard the surface/barrier shell -> derived topology. ----
197+
const float barrierSqWorld = 0.75f * voxelSize * voxelSize; // (sqrt(3)/2 * voxelSize)^2
198+
const uint32_t srcLeafCount = Traits::getTreeData(d_orig).mNodeCount[0];
199+
200+
auto retainMask = nanovdb::cuda::DeviceBuffer::create(
201+
std::size_t(srcLeafCount) * sizeof(nanovdb::Mask<3>), nullptr, false);
202+
auto* d_retainMask = static_cast<nanovdb::Mask<3>*>(retainMask.deviceData());
203+
204+
nanovdb::util::cuda::operatorKernel<UDFBarrierPruneMaskFunctor>
205+
<<<srcLeafCount, UDFBarrierPruneMaskFunctor::MaxThreadsPerBlock, 0, stream>>>(
206+
d_orig, static_cast<const float*>(udfSidecar.deviceData()), barrierSqWorld, d_retainMask);
207+
cudaCheckError();
208+
209+
nanovdb::tools::cuda::PruneGrid<BuildT> pruner(d_orig, d_retainMask, stream);
210+
auto derivedHandle = pruner.getHandle();
211+
const auto* d_derived = derivedHandle.template deviceGrid<BuildT>();
212+
213+
// ---- Step 3: connected-components labeling on the derived grid. ----
214+
nanovdb::tools::cuda::ConnectedComponents<BuildT> cc(d_derived, stream);
215+
auto [d_labels, numComponents] = cc.getVoxelLabelsAndCount();
216+
cudaCheck(cudaStreamSynchronize(stream));
217+
218+
// Diagnostics.
219+
const uint64_t derivedActive = Traits::getActiveVoxelCount(d_derived);
220+
const uint32_t derivedLeaves = Traits::getTreeData(d_derived).mNodeCount[0];
221+
std::cout << "Derived (barrier-removed) grid: " << derivedActive << " active voxels, "
222+
<< derivedLeaves << " leaves.\n";
223+
224+
// CPU-oracle self-check.
225+
validateAgainstOracle(derivedHandle, derivedLeaves, derivedActive, d_labels, numComponents);
226+
227+
return numComponents;
228+
}

0 commit comments

Comments
 (0)