BVH
See Bounding volume hierarchies for the conceptual picture of bounding volume hierarchies (node types, partitioning, tree pruning during traversal) before reading the concrete API below.
The BVH functionality is encapsulated in the namespace EBGeometry::BVH
(Source/EBGeometry_BVH.hpp / EBGeometry_BVHImplem.hpp).
For the full API, see the doxygen API.
There are two representations of a BVH:
BVH::TreeBVH<T, P, BV, K>, a pointer-based tree used while building and partitioning the hierarchy. See the doxygen page for TreeBVH.BVH::PackedBVH<T, P, K, StoragePolicy>, where the nodes are stored in depth-first order in a flat array and contain index offsets to children and primitives rather than pointers. This is the representation used for fast queries, see PackedBVH. See the doxygen page for PackedBVH.
The template parameters shared by both are:
TFloating-point precision.PPrimitive type. Neither representation imposes an interface requirement of its own:TreeBVHconstruction/partitioning only ever callsgetCentroid()on it, andPackedBVHholds primitives opaquely, handing them back only to whatever leaf-visit callback a caller supplies totraverse()orpruneTraverse()(see below). WhetherPneeds asignedDistance(Vec3T<T>)member (or anything else) is entirely up to that callback – seeMeshSDF/TriMeshSDF::signedDistance()in Mesh SDF classes for the signed-distance case; a callback could equally perform, say, a nearest-neighbor search over a point cloud whose primitive carries nosignedDistance()at all.BVBounding volume type (TreeBVHonly —PackedBVHalways usesBoundingVolumes::AABBT<T>internally).KBVH degree.K=2will yield a binary tree,K=3yields a tertiary tree and so on.
TreeBVH is the BVH builder node, i.e. it is the class through which we recursively build the
BVH, see Construction. TreeBVH has no constraint that P be wrapped in a
shared_ptr itself, but the containers that hold primitives during construction
(PrimitiveList<P>, PrimAndBV<P, BV>) always wrap each primitive in a
std::shared_ptr<const P>, so that primitives can be safely shared between the tree and any
higher-level object that still refers to them by pointer (e.g. a DCEL::FaceT).
Bounding volumes
EBGeometry supports the following bounding volumes, which are defined in Source/EBGeometry_BoundingVolumes.hpp:
Bounding sphere, templated as
EBGeometry::BoundingVolumes::SphereT<T>. Various constructors are available. See the doxygen page for SphereT.Axis-aligned bounding box, which is templated as
EBGeometry::BoundingVolumes::AABBT<T>. See the doxygen page for AABBT.
For full API details, see the doxygen API.
Other types of bounding volumes can in principle be added, with the only requirement being that they conform to the same interface as the AABB and BoundingSphere volumes. Note that
PackedBVH hard-codes AABBT<T> as its bounding volume (see above), so a custom bounding
volume can only be used with TreeBVH while building, and must still be convertible to an AABB
before the tree is packed.
Construction
Building a TreeBVH always starts from a list of (primitive, bounding volume) pairs; at
that point the tree is a single, unpartitioned leaf holding every primitive. Partitioning it into
an actual hierarchy is then a separate step, done by calling one of the partitioning member
functions described below – this recursively subdivides the tree in place.
Tip
The default construction methods perform the hierarchical subdivision by only considering the bounding volumes. Consequently, the build process is identical regardless of what type of primitives (e.g., triangles or analytic spheres) are contained in the BVH.
Top-down construction
Top-down construction is done through the member function topDownSortAndPartition(), which
takes two optional arguments: a partitioner and a leaf predicate.
The partitioner is a functor that splits a list of (primitive, BV) pairs into K new
lists whenever a leaf is subdivided. Four ready-made partitioners are provided:
BVCentroidPartitioner (splits on bounding-volume centroids along the longest axis – the
default), PrimitiveCentroidPartitioner (the same idea, but splits on primitive centroids
instead), BinnedSAHPartitioner (a Surface-Area-Heuristic partitioner, used automatically
when building via BVH::Build::SAH – see below – and typically producing the
best-performing trees at a higher construction cost), and MidpointPartitioner (splits on the
midpoint of the bounding-volume centroids’ extent along the longest axis, with a single
std::partition pass – no sorting and no per-plane cost evaluation, making it the fastest of
the four to build, at the cost of not adapting to the primitive distribution the way the other
three do). BinnedSAHPartitioner and MidpointPartitioner both produce K groups by
recursively splitting into two (std::floor(K/2) and std::ceil(K/2)) halves, exact for
power-of-two K. BinnedSAHPartitioner takes an optional final template argument
LongestAxisOnly (default false); setting it true bins candidate planes on only the
longest centroid-bounding-box axis instead of all three, cutting roughly a third of the binning
work (measured ~20% faster SAH builds on point clouds) for a tree-quality cost that is negligible
on near-uniform inputs. The leaf predicate takes a TreeBVH
node and decides whether it should become a leaf (i.e. not be split any further); a default is
provided, but callers are free to supply their own of either kind.
Bottom-up construction
The bottom-up construction uses a space-filling curve (e.g., a Morton curve) for first building the leaf nodes.
This construction is done such that each leaf node contains approximately the number of primitives, and all leaf nodes exist on the same level.
To use bottom-up construction, one may use the member function template
bottomUpSortAndPartition<S>() (no arguments).
The template argument is the space-filling curve that the user wants to apply, from namespace
EBGeometry::SFC (Source/EBGeometry_SFC.hpp, see the doxygen API).
Currently, we support Morton codes, Hilbert curves, and nested indices.
For Morton curves, one would e.g. call bottomUpSortAndPartition<SFC::Morton>; the Hilbert curve
(better spatial locality than Morton, since consecutive codes are always spatially adjacent) is
bottomUpSortAndPartition<SFC::Hilbert>; while for nested indices (which are not recommended) the
signature is likewise bottomUpSortAndPartition<SFC::Nested>.
Build times for SFC-based bottom-up construction are generally speaking faster than top-down construction, but it tends to produce worse trees such that traversal becomes slower. For the full API, see the Doxygen reference for TreeBVH.
Direct construction (no TreeBVH)
Both construction methods above build a TreeBVH first, then require a separate pack()/
packWith() call to obtain a PackedBVH. For workloads with many small, cheaply-copyable
primitives (points, particles) built and rebuilt often, the per-node shared_ptr<TreeBVH>
allocation this implies can dominate build time. PackedBVH has a constructor that skips
TreeBVH entirely:
BVH::PackedBVH<T, P, K> packed(std::move(primsAndBVs), targetLeafSize);
It takes primitives by value (std::vector<std::pair<P, BV>>, a sink parameter the caller
can std::move in) rather than requiring a shared_ptr-wrapped list, regardless of this
PackedBVH’s StoragePolicy (see PackedBVH’s “Storage policy” section) — combined
with BVH::ValueStorage<P>, this is genuinely pointer-free from input to final storage.
Internally, this constructor:
Sorts primitives along a space-filling curve (
SFC::Mortonby default; pass e.g.SFC::Hilbert{}orSFC::Nested{}as an optional trailing argument to select another curve — a constructor template’s own parameters can’t be explicitly named the way a regular function template’s can, so this is a stateless tag value purely to let the curve type be deduced).Cuts leaves via a single linear left-to-right scan at a caller-chosen target leaf size, rather than deriving a leaf count purely from primitive count and
Kthe waybottomUpSortAndPartition()does — giving direct control over leaf occupancy.Merges the resulting leaves upward in groups of
K, padding the leaf count up to the next power ofK(by re-using the last real leaf’s node in place of any missing child, rather than inventing an empty placeholder) whenever it isn’t already one, so every interior node still has exactlyKchildren — no change toNode’s shape or totraverse()/pruneTraverse().
Since this still produces an ordinary PackedBVH, every existing traversal/query facility
(traverse(), pruneTraverse(), the SIMD dispatch) works with it identically, unchanged.
PackedBVH also has a second, overloaded direct constructor covering top-down (and SAH)
construction rather than the SFC-based one above:
BVH::PackedBVH<T, P, K> packed(std::move(primsAndBVs)); // top-down
BVH::PackedBVH<T, P, K> packed(std::move(primsAndBVs), BVH::BinnedSAHPartitioner<T, P, AABBT<T>, K>, stopCrit); // SAH
It reuses TreeBVH’s own Partitioner/LeafPredicate machinery unchanged (any of
BVCentroidPartitioner, BinnedSAHPartitioner, PrimitiveCentroidPartitioner, or a
caller-supplied one), so it accepts the same arguments topDownSortAndPartition() does — but
writes nodes directly into the flat node array in depth-first pre-order as the recursion unwinds,
rather than building a persistent, shared_ptr-linked TreeBVH first. Since top-down
recursion visits the root before its children, this needs no relayout pass (unlike the SFC-build
constructor above, where a bottom-up merge naturally produces the root last). Each split still
shared_ptr-wraps primitives once, up front (to reuse the existing Partitioner/LeafPredicate
signatures) and constructs one lightweight, stack-local TreeBVH per split purely to evaluate
the stop criterion and read off its primitive list — proportionate to what
topDownSortAndPartition() already does at every node, and immediately discarded rather than
kept alive as part of a persistent tree. What this constructor avoids is exactly the persistent
shared_ptr<TreeBVH> node allocation kept alive for the tree’s lifetime, which the Examples/BuildBVH
benchmark measures as the traditional path’s dominant build-time cost.
A third direct constructor builds via ClusterSAH, a fast approximation of a full SAH tree:
BVH::PackedBVH<T, P, K> packed(std::move(primsAndBVs), BVH::ClusterSpec{maxClusterSize});
It first groups the primitives into small, spatially-tight clusters (buckets of at most
maxClusterSize primitives, formed by a cheap density-adaptive midpoint subdivision that stops
early), then runs binned SAH top-down over those clusters — so SAH partitions roughly
N / maxClusterSize boxes instead of all N primitives. The result is near-SAH tree quality at
a fraction of the single-threaded SAH build cost, and it stays robust across uniform, surface, and
clustered primitive distributions (a fixed Cartesian grid, by contrast, overcrowds on non-uniform
data). BVH::ClusterSpec::maxClusterSize trades build time (larger → fewer, cheaper SAH units)
against query quality (larger → coarser leaves); Examples/BuildBVH benchmarks its build time
against the other strategies.
Tip
Higher-level entry points such as Parser::readIntoPackedBVH don’t require you to
call topDownSortAndPartition/bottomUpSortAndPartition directly — they take a single
BVH::Build enum value (TopDown, Morton, Nested, or SAH) and dispatch to the
corresponding construction method internally. See Reading data.
PackedBVH
In addition to the standard BVH node TreeBVH<T, P, BV, K>, EBGeometry provides a PackedBVH where nodes are stored in depth-first order in a flat array.
The PackedBVH can be automatically constructed from a TreeBVH but not vice versa.
Fig. 8 PackedBVH representation. The original BVH is traversed from top-to-bottom along the branches and laid out in linear memory. Each interior node stores index offsets to its children and primitives.
The rationale for the PackedBVH is its tighter memory footprint and depth-first ordering, which allows more efficient traversal, particularly when primitives are sorted in the same order.
PackedBVH<T, P, K, StoragePolicy> is templated on the same T, P, K as TreeBVH
(its bounding volume is always AABBT<T>), plus a fourth StoragePolicy parameter described
below, and internally stores two things: a flat, depth-first array of nodes, and a global
primitive array holding every primitive in leaf order.
Each entry of the node array plays the same role a TreeBVH node plays, but stores offsets
into the flat arrays rather than pointers to children: a bounding volume for the node’s subtree,
a primitive offset and count identifying its range in the global primitive array (used only if
the node is a leaf), and the depth-first indices of its K children. A node is a leaf exactly
when its primitive count is non-zero; the root node is always at index 0 of the node array. See
the doxygen page for PackedBVH::Node for the exact member list.
Constructing a PackedBVH is simply a matter of flattening an already-partitioned TreeBVH,
via one of two TreeBVH member functions:
pack()performs a straight flatten: the resultingPackedBVH<T, P, K>stores exactly the same primitive typePthat the source tree held. The original tree is left untouched and can simply be discarded (or allowed to go out of scope) once it is no longer needed.packWith<Q, Converter>()additionally converts the primitive type while flattening: the source tree holds primitives of typeP, and a user-suppliedConverteris called once per leaf to produce theQvalues that the resultingPackedBVH<T, Q, K>will store. This is howTriMeshSDFturns a tree of individualTriangle<T, Meta>primitives into aPackedBVHwhose leaves hold SIMD-widthTriangleSoAT<T, W>groups instead — see Mesh SDF classes.
See the doxygen page for TreeBVH for
the exact signatures of pack() and packWith().
Storage policy
pack()/packWith() both take an optional StoragePolicy template argument controlling
how the resulting PackedBVH’s global primitive array stores each primitive. A storage
policy is a stateless struct exposing a StorageType type alias plus the static methods
get(), appendTreeLeaf(), and appendAliased() that PackedBVH calls internally when
packing and querying; see the BVH namespace’s doxygen page for the exact member list each one must provide.
Two are provided out of the box:
BVH::SharedPtrStorage<P>(the default for bothpack()andpackWith()) stores each primitive as astd::shared_ptr<const P>, matching the pre-existing behavior of every caller that does not name aStoragePolicyexplicitly. Primitives are shared with whatever else still holds a pointer to them (e.g. aDCEL::FaceTreferenced by aTreeBVH), at the cost of one pointer indirection per access during traversal.BVH::ValueStorage<P>stores each primitive inline, by value:StorageTypeisPitself, with no indirection at all. This trades away sharing (thePackedBVHnow owns an independent copy of every primitive) for a smaller, more cache-friendly primitive array — most useful whenPis a small, cheaply-copyable value type (e.g. a point or particle) rather than something large or already reference-counted elsewhere.
Both policies are drop-in compatible with every existing PackedBVH consumer: swapping the
policy only changes the element type of getPrimitives() and the leaf-primitive vector handed
to LeafEvaluator/PackedLeafEvaluator callbacks, never the tree structure, traversal order,
or query results. A caller that wants ValueStorage instead of the default simply names it
explicitly, e.g. tree->pack<BVH::ValueStorage<P>>().
Copy and move semantics
TreeBVH and PackedBVH differ in whether copying is allowed, precisely because of the
storage-sharing question above:
TreeBVHdeletes its copy constructor and copy assignment operator. It is a recursive structure ofshared_ptr-linked children, so a naive (compiler-generated) copy would only alias the same child subtrees rather than cloning them, whichtopDownSortAndPartition()/bottomUpSortAndPartition()could then mutate out from under a supposedly independent “copy”. Copying is disallowed outright rather than silently doing the wrong thing. Its move constructor and move assignment operator are explicitly defaulted and fully supported. To replicate a tree independently – e.g. to build once and then partition two copies with different strategies, or to keep a pristine copy alongside one you go on to mutate – usedeepCopy(), which recursively clones the node hierarchy (returning a newstd::shared_ptr<TreeBVH>) while still sharing the immutablestd::shared_ptr<const P>primitives by handle. (Copying astd::shared_ptr<TreeBVH>is, of course, always fine – that is shared ownership of the same tree, not a replica.)PackedBVHallows both copying and moving. Its members (the flattened node array, the primitive array, and the SIMD AABB cache) are all owned value containers with no shared mutable substructure, so the compiler-generated deep copy is correct and safe under bothBVH::SharedPtrStorage(primitives are aliasedshared_ptr, the same sharing model asTreeBVH) andBVH::ValueStorage(primitives are copied by value – safe as long as the primitive type’s own copy constructor is complete; see the note onDCEL::FaceTin Mesh SDF classes for a case where it deliberately is not).
Both classes’ destructors are non-virtual: neither is intended to be subclassed or used polymorphically.
When ValueStorage is the wrong choice
There are two situations where ValueStorage should not be used and SharedPtrStorage (the
default) must be kept:
Polymorphic primitives.
ValueStorage<P>storesPby value, so it requiresPto be a concrete, copyable value type. IfPis an abstract base (or you rely on virtual dispatch through a base pointer), value storage either fails to compile or slices the object to its static type. This is exactly the situation of the BVH-accelerated CSG unions (Geometries), whose primitive isImplicitFunction<T>and whose leaf evaluator calls a virtualvalue()through astd::shared_ptr<const ImplicitFunction<T>>; those classes therefore always useSharedPtrStorageand do not expose the policy. UseValueStorageonly whenPis a self-contained value type (a point, a particle, an SoA triangle group), never for a polymorphic hierarchy.Nesting a BVH inside a BVH. A
PackedBVHwhose primitive is itself anotherPackedBVH(or a mesh SDF that owns one) is a supported construction, and nothing stops it recursing further —PackedBVHofPackedBVHofPackedBVH, to any depth. At every level the outerPackedBVHshould stay onSharedPtrStorageso it shares each inner BVH by pointer. NamingValueStorageon an outer level instead copies every innerPackedBVHwholesale — its node, SoA, and primitive arrays — into the outer array: packing draws each primitive from the source tree as astd::shared_ptr<const P>, so each inner BVH is copied (not moved) into place, even thoughPackedBVHis otherwise fully movable (see Copy and move semantics above). The cost of that copy is proportional to the entire memory footprint reachable below the primitive, so it becomes exceedingly expensive whenever an inner BVH is large, and compounds with nesting depth: each by-value level duplicates everything beneath it, which may itself be duplicating everything beneath it.SharedPtrStorageat every outer level avoids this for no loss of correctness. The common realisation of nesting — aBVHUnionover several mesh SDFs, each holding its own innerPackedBVH— is exactly the polymorphic-primitive case above, so it already sits onSharedPtrStorageat the outer level and shares each mesh SDF by pointer.
Tree traversal
Both TreeBVH (full BVH) and PackedBVH (flattened BVH) include routines for traversing the
BVH with user-specified criteria. For both BVH representations, tree traversal is done through a
single traverse() member function taking four caller-supplied callbacks, and uses a
stack-based traversal pattern driven by those callbacks. The full type signatures for the four
callback roles below are documented on the BVH namespace’s doxygen page (look for PrunePredicate, ChildOrderer/
PackedChildOrderer, LeafEvaluator/PackedLeafEvaluator, and NodeKeyFactory).
Node visit
The prune-predicate callback decides, for a given node and its associated node key (see below),
whether that node’s subtree should be investigated or pruned from the traversal: it is a
predicate taking the node and its node key, returning true to visit the subtree and
false to prune it. Typically, the node key will contain the necessary information
that determines whether or not to visit the subtree.
Child ordering
If a subtree is visited in the traversal, there is a question of which of the child nodes to visit first.
The child-orderer callback determines this order by letting the user sort the K children (each
paired with its node key) in-place based on order of importance – for PackedBVH the
children are identified by their node index rather than a pointer, halving the per-entry stack
size relative to TreeBVH. Note that a correct visitation pattern can yield large performance
benefits. Ordering the child nodes is completely optional; the user can leave this function empty
if it does not matter which subtrees are visited first.
Leaf evaluation
If a leaf node is visited in the traversal, distance or other types of queries to the geometric
primitive(s) in the node are usually made. These are done by the leaf-evaluator callback. For
PackedBVH this callback receives an offset and count into the BVH’s global primitive array,
rather than a freshly-allocated sub-list, avoiding a heap allocation per leaf visit; for
TreeBVH it receives the leaf’s primitive list directly. Typically, the leaf-evaluator will modify
parameters that appear in a local scope outside of the tree traversal (e.g. updating the minimum
distance to a DCEL mesh).
Node key
During the traversal, it might be necessary to compute a per-node key that is helpful during the traversal, and this key is attached to each node that is queried. This key is usually, but not necessarily, equal to the distance to the nodes’ bounding volumes. The node-key-factory callback produces this key for a node’s children, given the node itself. The biggest difference between the leaf-evaluator and the node-key-factory is that the leaf-evaluator is only called on leaf nodes whereas the node-key-factory is also called for internal nodes. One typical example for DCEL meshes is that the leaf-evaluator computes the distance from an input point to the triangles in a leaf node, whereas the node-key-factory computes the distance from the input point to the bounding volumes of a child node. This information is then used by the child-orderer in order to determine a preferred child visit pattern when descending along subtrees.
Traversal algorithm
PackedBVH::traverse() implements this with a non-recursive, vector-backed stack rather than
recursion. Each stack entry holds a node index together with that node’s already-computed
node key. The root is pushed first; then, until the stack is empty, the traversal pops an entry,
asks the prune-predicate whether to visit it, and if so either runs the leaf-evaluator (if it is a leaf) or
computes the node-key-factory for each of its K children, lets the child-orderer reorder them, and
pushes them all onto the stack. For the full API, see the Doxygen reference for
PackedBVH.
Distance-pruned traversal: pruneTraverse()
PackedBVH has no signedDistance() of its own, and does not privilege any one query.
Alongside the generic traverse() described above, it exposes a second traversal,
PackedBVH::pruneTraverse(), that implements the same “closest bounding volume first, prune
anything already known to be farther than the best answer so far” strategy, but with two
differences: the box-vs-point distance test is SIMD-batched across all K children at once
(see SIMD-accelerated classes for exactly which instructions run for which (K, T), and the
list-table below for the ISA-to-K mapping), and – unlike traverse()’s four independent
callbacks – the search is expressed through exactly three cooperating pieces supplied by the
caller:
State – whatever the search needs to remember between leaf visits. Often just “the best value found so far”, but it can be richer (e.g. a running best paired with the primitive that produced it). This is the only thing that persists across the whole traversal.
Leaf-eval – called once per leaf, with the leaf’s offset and count into the BVH’s global primitive array (never a freshly-allocated sub-list). It is the only place primitives are actually touched, and the only place
Stateis allowed to change.Pruning rule – called on the current
Stateto produce a squared-distance bound: a node farther than this (in squared distance) is skipped without being visited. It never touches primitives directly, only whateverLeaf-evalhas already written intoState.
Precisely, the traversal seeds a stack with the root node (at distance zero, so it is never
pruned), then repeatedly pops the top entry and: skips it outright if its already-known squared
distance to the query point exceeds the pruning rule’s current bound; otherwise, if it is a
leaf, calls leaf-eval once for the whole leaf; otherwise (an interior node), computes all K
children’s squared distances to the query point in a single SIMD batch, sorts them so the
closest child is visited next, and pushes every child still within the (freshly re-evaluated)
pruning bound. Because the bound is re-read from the current State at every node visited –
never cached from the start of the traversal – a leaf visited anywhere earlier on the stack
immediately tightens the pruning applied to every node visited afterwards, regardless of which
subtree it came from.
Splitting the pruning rule apart from the leaf-eval like this is what lets a primitive with no
notion of “signed distance” reuse the same SIMD box test: a nearest-neighbor search over a point
cloud can track a plain running squared distance as its State (no abs(), no extra
squaring, no square root anywhere in the hot path) with a pruning rule that returns the state
unchanged, whereas MeshSDF/TriMeshSDF::signedDistance() (see Mesh SDF classes)
track a signed distance and square its magnitude for the bound – both are ordinary
instantiations of the same pruneTraverse(), not special cases hardcoded into PackedBVH.
The former needs nothing more than a bare point struct with no signedDistance() member at all,
searched for its nearest neighbor via pruneTraverse() against a running squared distance.
For the exact template signature and callback contracts, see the doxygen page for PackedBVH::pruneTraverse.
Traversal examples
Below, we consider two examples for BVH traversal. The examples show how we compute the signed distance from a DCEL mesh, and how to perform a smooth CSG union where the search for the two closest objects is done by BVH traversal.
Signed distance
The DCEL mesh distance fields use a traversal pattern based on
Only visit bounding volumes that are closer than the minimum distance computed (so far).
When visiting a subtree, investigate the closest bounding volume first.
When visiting a leaf node, check if the primitives are closer than the minimum distance computed so far.
MeshSDF::signedDistance() implements these rules directly as the four traversal callbacks:
the leaf-evaluator scans a leaf’s faces and keeps the signed distance with the smallest magnitude seen so
far; the prune-predicate prunes any node whose bounding-volume distance already exceeds that magnitude;
the child-orderer visits the closest child first; and the node-key-factory supplies each node’s distance to
its bounding volume. For the full API, see the Doxygen reference for
MeshSDF.
CSG Union
Combinations of implicit functions in EBGeometry into aggregate objects can be done by means of CSG unions. One such union is known as the smooth union, in which the transition between two objects is gradual rather than abrupt.
BVHSmoothUnionIF::value() traverses the tree while tracking the two smallest values seen so
far, a and b (a the closest, b the second-closest): the leaf-evaluator updates both as
leaves are scanned, the prune-predicate prunes any node whose bounding-volume distance exceeds both,
the child-orderer visits the closest child first, and the node-key-factory again supplies each node’s
distance to its bounding volume. Once traversal completes, the two values are blended with the
stored smooth-minimum operator. See Geometries for the CSG combinators themselves, and
the Doxygen reference for
BVHSmoothUnionIF /
BVHUnionIF for the exact API.
Mesh SDF classes
EBGeometry provides three concrete classes for evaluating signed distances to surface meshes. They share the same sign convention (negative inside, positive outside) but differ in data layout, BVH type, and supported geometry:
Class |
Input |
BVH type |
Traversal |
Notes |
|---|---|---|---|---|
|
DCEL mesh |
None |
O(N) scan |
Debug / tiny meshes only; no build cost |
|
DCEL mesh |
|
|
Any polygon mesh; not restricted to triangles |
|
DCEL mesh or triangle soup |
|
|
Triangle meshes only; highest throughput |
FlatMeshSDF is useful for correctness checks and tiny meshes. See its doxygen page.
MeshSDF handles arbitrary polygon meshes; its signedDistance() builds the traversal
criteria shown above (a leaf-eval and a pruning rule, not the full four-callback traverse()
shape) and drives them through PackedBVH::pruneTraverse(), picking up SIMD node pruning
whenever (K, T) matches a compiled ISA path and falling back to the generic, scalar
traverse() otherwise. See its doxygen page.
TriMeshSDF is the recommended default for triangle meshes: it packs triangles into
Structure-of-Arrays groups of width W (via TreeBVH::packWith(), see above) and builds the
same kind of thin pruneTraverse() wrapper as MeshSDF, over TriangleSoAT<T, W> leaves
instead of individual faces, so that on a matching (K, T) combination each BVH leaf evaluates
W triangles with a single SIMD register operation, and even the AABB-vs-running-best
comparisons during descent are done on squared distances (no square root) until the very last
step. See its doxygen page, and the doxygen
page for TriangleSoAT for the SoA storage
itself.
What is actually vectorised in TriMeshSDF/PackedBVH is covered in
SIMD-accelerated classes – see that page for the full detail rather than repeating it here.
Primitive storage: Facets or triangles
Both classes’ underlying PackedBVH accepts the StoragePolicy axis described above, but
they default – and, for MeshSDF, are restricted – differently:
MeshSDFalways usesBVH::SharedPtrStorage<DCEL::FaceT<T, Meta>>and does not expose aStoragePolicytemplate parameter at all. This is not merely a default:DCEL::FaceT’s copy constructor deliberately does not copy its cached 2D polygon embedding (used bysignedDistance()’s point-in-face test) or its inside/outside algorithm choice, sinceFaceT’s half-edge back-reference is only topologically meaningful relative to a specific mesh (the same reasoning documented forVertexT/EdgeT’s copy constructors). ABVH::ValueStorage-style plain copy would therefore leave every packed face with an uninitialized embedding, crashing on the first query rather than merely losing sharing – soMeshSDFsimply never offers that choice.TriMeshSDFdefaults toBVH::ValueStorage<TriangleSoAT<T, W>>instead ofBVH::SharedPtrStorage, and does exposeStoragePolicyas an overridable template parameter (its 5th, afterW). UnlikeDCEL::FaceT, eachTriangleSoAT<T, W>group is a plain aggregate of coordinate arrays with no cached derived state or back-references, built fresh bygroupTrianglesIntoSoA()during packing and shared with nothing else – so storing it inline is both safe and, avoiding one heap allocation and pointer indirection per group, the better default.
Neither default is affected by instancing the same mesh multiple times (e.g. placing several
Translate/Rotate/Scale-wrapped copies of one mesh into a Union): those wrappers
hold a shared_ptr to the whole MeshSDF/TriMeshSDF object (see Geometries),
so its packed data is shared once per wrapper regardless of how that one object’s own
PackedBVH stores its primitives internally.
Parser::readIntoPackedBVH mirrors MeshSDF (no StoragePolicy parameter);
Parser::readIntoTriangleBVH mirrors TriMeshSDF (StoragePolicy as its 5th template
parameter, defaulting to BVH::ValueStorage<TriangleSoAT<T, W>>). See Reading data.
SIMD-optimal K and W by ISA
The helper BVH::DefaultBranchingRatio<T>() returns the SIMD-optimal branching factor for
the current compilation target. EBGeometry::TriangleSoA::DefaultWidth<T>() gives the
matching SoA width. Both are used as template defaults for TriMeshSDF and
Parser::readIntoTriangleBVH.
ISA |
Precision |
|
|
|---|---|---|---|
AVX-512F |
|
16 |
16 |
AVX-512F |
|
8 |
8 |
AVX (256-bit) |
|
8 |
8 |
AVX (256-bit) |
|
4 |
4 |
SSE4.1 / scalar |
|
4 |
4 |
The K=16/float and K=8/double paths use 512-bit-wide SIMD loads and require the ChildAABBSoA
struct to be 64-byte aligned, which is guaranteed by alignas(sizeof(T)*K) on the struct. The
K=8/float and K=4/double paths use 256-bit-wide loads instead. All other (K, T) combinations fall
back to a scalar loop that goes through the generic traverse() described above.
Each TriangleSoAT<T, W> block is likewise alignas-aligned to its own SIMD register width
(64 bytes for <float, 16>/<double, 8>, 32 bytes for <float, 8>, 16 bytes for
<float, 4>/<double, 4>), and the library inserts static_assert checks that fire at
compile time if the alignment invariant is violated.
Choosing W and K explicitly
W and the BVH branching factor K are explicit template parameters on TriMeshSDF and
Parser::readIntoTriangleBVH – both default to BVH::DefaultBranchingRatio<T>() and
TriangleSoA::DefaultWidth<T>() respectively, but either can be overridden by supplying them
explicitly (e.g. requesting an 8-wide SoA packing together with a 4-ary BVH, regardless of what
the current compilation target would otherwise default to). See the doxygen page for
Parser::readIntoTriangleBVH for the exact
signature.
Rules of thumb:
Keep
Wequal toEBGeometry::TriangleSoA::DefaultWidth<T>()unless you have a specific reason to deviate. The library is tuned for this default.a_maxLeafSize(the maximum number of raw triangles per BVH leaf, before SoA packing) defaults to2 * W: leaves land on up to two full SoA blocks, while the SAH/TopDown partitioner is still free to split down to smaller, tighter leaves wherever the geometry calls for it. A leaf smaller thanWsimply pads its SoA block’s unused lanes.K = BVH::DefaultBranchingRatio<T>()is a good default. With AVX-512F available you can tryK = 16(float) — the child-AABB test is evaluated in a single SIMD batch, and the wider fan-out reduces tree depth.