Geometries
This page covers the concrete classes behind two Concepts pages: Geometry representations (signed distance fields and implicit functions in general) and Constructive solid geometry (transformations and CSG combinators) – read those first for the conceptual picture, and use this page for the class/function-level detail and Doxygen links.
Shapes
A shape is anything EBGeometry can evaluate and compose: a trivially copyable type with a const
signedDistance(const Vec3T<T>&) returning T – negative inside the object, positive outside
– and a const computeBoundingVolume() returning the AABBT<T> that encloses the object. The
compile-time trait isShape<S> checks exactly this, and ShapePrecision<S> reads T from the
box type (see EBGeometry_Expr.hpp). The analytic shapes
(Analytic shapes), the mesh distance fields (Mesh SDF classes), the BVH unions
(BVH-accelerated unions) and a compiled Tape (The tape) are all shapes, and so is any type
of your own with those two members. None of them has a base class or a virtual function, so each can
be passed to a GPU kernel by value.
A shape may also carry a static member distanceQuality that says how far its value can be trusted
as a distance (see Analytic shapes); a shape without one counts as Bound. The
difference matters because a signed distance promises more than the sign: its magnitude is the
distance to the surface (the Eikonal property, \(|\nabla S| = 1\); see
Geometry representations), which BVH pruning, sphere tracing and the octree bounding-volume
search rely on.
Every shape’s value goes by the one name signedDistance, whatever its distance quality, so the
name alone does not say how much the value promises; the name and the quality together do. The sign
is what every shape promises: negative inside, positive outside, zero on the surface. The magnitude is
what the distance quality says:
Exact: the distance to the surface;Bound: at most the distance to the surface, so it may underestimate but never overestimates;NotADistance: nothing; only the sign is meaningful.
A composed object’s quality is worked out when it is built (Expr::getDistanceQuality(),
Tape::getDistanceQuality()): a smooth union is a bound, for instance, and anything built from
PerlinSDF is not a distance.
For a function with no closed-form bounding volume, the free function
approximateBoundingVolumeOctree(function, lo, hi, depth, safety) estimates a bounding box by
octree subdivision of a caller-supplied initial box, and normal(function, point, delta) gives the
finite-difference normal of a function. Both take anything that gives a value at a point: a shape
(through its signedDistance()) or a callable such as a lambda. See Octree subdivision for both. The results are only meaningful when the function is
reasonably close to a true signed distance, since the safety margin and the normal are interpreted in
units of distance.
Analytic shapes
Source/EBGeometry_AnalyticDistanceFunctions.hpp declares twelve closed-form shapes:
Class |
Shape |
|---|---|
|
A plane through a point, with a normal |
|
A sphere |
|
An axis-aligned box |
|
A torus whose ring lies in the xz-plane, around the y axis |
|
A capped cylinder between two points; an infinite cylinder along a coordinate axis |
|
A cylinder with hemispherical end caps |
|
A finite and an infinite cone, opening along -y from the tip |
|
An axis-aligned box and a cylinder along y, with rounded edges |
|
Perlin noise (not a distance function, only its sign means anything) |
Each is a plain, trivially copyable value type with no base class and no virtual functions, in
the same way as the mesh distance fields. Its signedDistance() and accessors are callable on
the host and on a GPU, so a shape can be passed to a kernel by value and evaluated there; the
constructors run on the host. The Shapes example constructs every one of them.
The shapes share a few conventions:
Placement first. Each constructor takes where the shape is (a center, a corner, a tip or a point on it) before its sizes.
One axis. A shape with a built-in axis has it along y: the torus, both cones and the rounded cylinder, and also the default-constructed cylinder, infinite cylinder, capsule and plane.
CylinderSDFandCapsuleSDFtake two end points andInfiniteCylinderSDFan axis index, so they can point anywhere.Outer sizes. A size is the size of the finished shape, rounding included:
RoundedBoxSDFtakes the box it fits exactly, and its rounding radius must be less than half of every side.A bounding box.
computeBoundingVolume()returns the shape’s axis-aligned bounding box, on the host and on a GPU, ready to pass to a BVH union. Along a direction in which a shape is unbounded, the box extends to plus or minus the largest finite value ofT(not infinity, so that box arithmetic such as a center stays finite): the infinite cylinder along its axis, the infinite cone everywhere but above its tip, and the plane and the noise in every direction.A distance quality. A static member
distanceQualitysays how farsignedDistance()can be trusted as a distance, as one of three DistanceQuality levels:Exact(the magnitude is the Euclidean distance to the surface),Bound(the magnitude never exceeds it) orNotADistance(only the sign means anything). Every shape isExactexceptPerlinSDF, which isNotADistance. The mesh distance fields areExact. A BVH union isBound, orNotADistanceif its primitives are.distanceQualityOf<P>reads the member, and treats a type without one asBound.
Every shape can be composed with the expression builder and evaluated through a Tape
(The tape); many shapes of one type can also be joined directly by a BVH union
(BVH-accelerated unions).
Composing objects
Objects are composed with the expression builder in
EBGeometry_Expr.hpp. Every shape converts to an
Expr, an immutable tree of shapes and operations, and
the free functions take shapes or Exprs and return an Expr:
const SphereSDF<T> sphere(Vec3::zeros(), T(1));
const BoxSDF<T> box(-Vec3::ones(), Vec3::ones());
const auto shape = SmoothDifference(Union(Translate(sphere, Vec3(T(2), T(0), T(0))), box), mesh, T(0.1));
An Expr is cheap to copy (copies share the tree) and is never evaluated itself: it is a
description, which a Tape compiles (The tape). The precision is deduced from the operands.
A pool-resident shape, such as a mesh distance field, is held by its descriptor, so its pool must be
the one the tape is compiled into and must outlive the tape.
The operations, described mathematically in Constructive solid geometry:
Function |
Value of the result at \(\mathbf{x}\) |
|---|---|
|
\(a(\mathbf{x} - \mathbf{t})\) |
|
\(a(R^{-1}\mathbf{x})\), about a coordinate axis (0, 1, 2) or about any axis direction |
|
\(|s|\,a(\mathbf{x}/s)\), uniform; a negative factor also reflects through the origin |
|
\(a\) at \(\mathbf{x}\) with one coordinate negated |
|
\(-a(\mathbf{x})\) |
|
\(a(\mathbf{x}) - r\): grows the object by \(r\), or shrinks it if \(r < 0\) |
|
\(|a(\mathbf{x})| - \delta\): a shell of thickness \(2\delta\) around the surface |
|
\(a(\mathbf{x} - \mathrm{clamp}(\mathbf{x}, -\mathbf{h}, \mathbf{h}))\): inserts a box of half-lengths \(\mathbf{h}\) at the origin |
|
A weighted average of \(a\) at the 27 points \(\mathbf{x} + d(i, j, k)\),
\(i, j, k \in \{-1, 0, 1\}\), with weights \(\alpha^{3-m}((1-\alpha)/2)^m\) for a
point \(m\) steps from the center ( |
|
\(\sum_k w_k\,a(\mathbf{x} - \mathbf{o}_k)\) over an \(n^3\) grid of offsets spanning \([-r, r]^3\), with the weights of a smooth bump kernel, normalized to sum to one |
|
\(\min_k a(\mathbf{x} - \mathbf{c} \odot \mathbf{k})\) over the tiles \(\mathbf{k}\), \(-lo_i \le k_i \le hi_i\): the copy in the tile \(\mathbf{x}\) folds into (\(k_i = \mathrm{round}(\mathrm{clamp}(x_i/c_i, -lo_i, hi_i))\)) first, then only the copies whose box is nearer than the value so far |
|
\(\min_i a_i(\mathbf{x})\) |
|
\(\max_i a_i(\mathbf{x})\) |
|
\(\max(a, -\min_i b_i)\) |
|
A smooth minimum or maximum of the two extreme values, with smoothing length \(s\) and a
|
The n-ary smooth operations blend only the two extreme values, which differs from folding a binary
smooth minimum over the list. Polynomial uses SmoothMinOp/SmoothMaxOp and
Exponential uses ExpMinOp/ExpMaxOp, the blend operators in
EBGeometry_Blend.hpp: small, trivially copyable
function objects, callable on a GPU, with ready-made instances SmoothMin<T>, SmoothMax<T>,
ExpMin<T> and ExpMax<T>. ExpMin and ExpMax are evaluated in a form that cannot
overflow however far the inputs are from zero.
Each Expr knows a box that encloses its object (computeBoundingVolume()) and its distance
quality (getDistanceQuality()), worked out from its operands’ when it is made:
Boxes. A point map sends the operand’s box through its inverse (exactly for translations, scalings, reflections and quarter turns; through the eight corners for other rotations). Offset, Annular, Elongate, Blur (by \(\sqrt{3}d\)) and Mollify (by \(\sqrt{3}r\)) grow it; a repetition sweeps it over the tiles; a union merges the boxes (a smooth union grows the result by \(s/4\), or \(s\ln 2\) for the exponential blend), an intersection intersects them, and a difference keeps the first operand’s. A complement covers all of space.
Distance quality. Point maps and Complement keep the operand’s quality, as do Annular and a growing Offset; a shrinking Offset, Elongate, Blur, Mollify and every union, intersection and difference give at best
Bound, and so does a repetition, whether or not the operand fits inside its tile, since it is the minimum over all the copies. Anything containing aNotADistanceoperand isNotADistance.
Warning
Not every operation preserves the signed distance property; getDistanceQuality() says what is
left of it. Rotation, translation, reflection, scaling and the complement keep an exact distance
exact; the other operations generally do not produce an exact signed distance even when their
input is one.
The tape
A Tape compiles an Expr into a Pool and
evaluates it:
Pool pool(hostMemoryResource());
const Tape<T> tape(pool, shape); // shape is an Expr<T>, or any shape
const T d = tape.signedDistance(p);
What the tape is, how an expression is compiled into it, and the limits on what one tape can hold
are explained in The tape; this section lists the API. A chain of Translate, Rotate,
Reflect and Scale is folded into one affine map
(Affine3T) when the Expr is built.
Primitive types. The twelve analytic shapes and the three mesh distance fields (
FlatMeshSDF, andMeshSDFandTriMeshSDFwith the default branching factor and SIMD width) are built in. Any other shape type – a BVH union, a mesh distance field with another branching factor, another tape, or a type of your own – is listed afterT:Tape<T, BVHUnion<T, SphereSDF<T>, 4>, MyShape>. The constructor rejects a shape whose type is not listed, and a pool-resident shape that lives in another pool.Features. Code that only some tapes need is a feature (see Tape features), compiled only into a tape whose type lists it among the arguments after
T, before the user types by convention:Tape<T, TapeFeature::ExactOffsets, MyShape>. A plainTape<T>compiles none.Unions of many objects. A union or smooth union of at least
TapeOptions::m_bvhUnionThresholdoperands (8 by default) is evaluated with a BVH over the operands’ boxes, exactly asBVHUnionandBVHSmoothUnionevaluate their primitives (BVH-accelerated unions), including keeping operands with unbounded boxes out of the tree. The operands can be of any type and composed in any way, such as several translations of one mesh. WithExactoperands the value is the same as an operand-by-operand minimum. ABoundoperand (a blur, an elongation, a shrinking offset) can have a value below its box’s distance, so the BVH may skip an operand whose value would have been smaller: the result can then be larger than the plain minimum, but it keeps the sign and is still a bound on the distance. A union with an operand that isNotADistancecannot be pruned and is evaluated operand by operand. TapeOptions also chooses the BVH construction method.Limits. Blur, Mollify, FiniteRepetition and the operands of a BVH union are compiled into sub-programs, which nest at most
Tape::MaxCallDepth(4) deep, and a sub-program holds at mostTape::ValueStackSize(16) values andTape::PointStackSize(8) points at once. The constructor rejects an expression that needs more, with a message saying which limit it reached. How the shape of an expression uses these limits, and how to stay within them (build unions from a list rather than nesting them on the right), is in The tape.Metadata.
computeBoundingVolume()andgetDistanceQuality()return the expression’s box and quality. The quality is a runtime value, so a tape used as another object’s primitive counts asBoundthere.getNumPrograms(),getNumClauses(),getNumBVHUnions()andgetCallDepth()describe what was compiled.A pool handle. A tape is trivially copyable and holds offsets into its pool and one location (see Memory model):
rebasedView(mirror)evaluates it against a mirror of its pool, anddeepCopy(pool)copies it, with every pool-resident primitive and BVH it holds, into another pool. The pool must outlive the tape and every copy and view of it.On a GPU. A tape is evaluated in a kernel like any pool-backed type: freeze its pool, mirror it into device memory, and pass
rebasedView(devicePool)to the kernel by value. Every operation, primitive type and BVH union runs there. A kernel that evaluates a tape reserves stack for every call level, whatever the tape needs; a tape listed as another tape’s primitive adds its own levels on top.
A tape also answers ray queries, tape.firstHit(...) and tape.intersects(...), the same as the
free functions on any shape (see Ray queries). Beside the clauses, the compiler writes a
ray program for the tape and for each BVH union operand, which traces exactly, from the spans where
the ray is inside each operand (see Compositions), its unions, intersections, complements and
affine maps over the primitives that have ray forms, and, in a tape with TapeFeature::ExactOffsets, its
offsets and annuli of primitives whose level sets have a closed form (see Offsets and annuli). A BVH
union walks its BVH along the ray, nearest box first. A subtree with any other operation in it, or a
primitive without a ray form, is marched as a whole; so is a subtree that would hold more than
Tape::RayStackSize (4) span lists at once. The ray programs add little to the tape’s pool: one
small entry per expression node. A hit names the leaf crossed (see The deciding leaf), or for a marched subtree
the deciding leaf at the crossing.
The Basic CSG example composes analytic shapes through a tape, and NestedBVH unions several placements of one mesh with spheres.
Tape features
Some of a tape’s code is needed only by some expressions, and is compiled only into a tape whose type asks for it: a feature, listed among the tape’s template arguments (TapeFeature, whose page also gives the rules for adding one). A tape without a feature computes the same object, by the general path, and is not slowed by the feature’s code. The features are:
Feature |
What it compiles |
|---|---|
|
Ray queries trace |
Fused composition
A tape interprets its clauses one after another, which on a GPU costs several times what the same
solid written out by hand does for a few analytic shapes in float, and little in double, whose
arithmetic dominates there, or for a mesh, whose traversal dominates. The Fused probe measures the
ratio (see Performance testing). When the structure of a composition is known when the program is
compiled, the functions in namespace Fused build it as types instead
(EBGeometry_Fused.hpp):
const auto solid = Fused::Difference(Fused::Intersection(box, ball), drill0, drill1, drill2);
const T d = solid.signedDistance(p);
Each function returns a Fused::Node: a
trivially copyable shape (isShape) whose type spells out the whole composition, so the compiler
inlines its evaluation as it would code written by hand. The functions are those of the expression
builder, with the same arguments and checks:
Names. Call them qualified,
Fused::Union, notUnion: the builder’s functions of the same names take shapes too, so an unqualified call is ambiguous.Lists of operands. Where the builder takes a
std::vector, a fused function takes its operands as arguments (Fused::Union(a, b, c),Fused::Difference(a, b, c)), since their number is part of the type. The n-ary smooth operations, whose smoothing length follows the operands, take them asFused::operands(a, b, c). One operand is returned as it is.The same values. A fused shape and a tape take each operation’s arithmetic from the same functions, so a fused composition equals a tape of the same expression to rounding: the same operations in the same order, though the compiler may fuse a multiply and an add into one instruction in one and not the other, which changes the last bit. A chain of maps folds into one, as in the builder.
Boxes and quality.
computeBoundingVolume()follows the builder’s rules. The distance quality is the static memberdistanceQuality, so it cannot depend on run-time values and is at least as cautious as the builder’s: an offset is aBoundwhatever the sign of its distance.Pool-resident operands. A mesh distance field, a BVH union or a tape can be an operand (
Fused::Difference(mesh, drill)). The fused shape is then a pool handle itself: it stores its operands’ layouts and one location, offersrebasedView(),deepCopy(),layout()and a(Layout, PoolLocation)constructor, and requires all its pool-resident operands to share one pool, which must outlive it and every copy of it.With the other structures. A fused shape is a shape like any other: a
BVHUnionof many copies of it, or a primitive of a tape (listed afterT, as any other type) that combines it with what is only known at run time.
A fused composition is fixed when the program is compiled; geometry read from a file or chosen at run time is composed with the builder and a tape. The Fused CSG example builds the same solid both ways.
The deciding leaf
A tape’s closestPrimitive(p) names the leaf of the composition whose surface its value at p
comes from, which is not always the geometrically nearest primitive; Which part: the deciding primitive
explains which leaf each operation passes on and what it is for. It returns a
PrimitiveRef: the leaf’s number in expression
order (m_leaf, PrimitiveRef::None for none), the primitive’s type and index in the tape
(m_type, m_index), and the point in the leaf’s own frame (m_localPoint). The tape returns
the primitive itself with isPrimitive<P>(ref) (any P the tape cannot hold is a compile error),
primitive<P>(ref) (the shape, or a pool-resident one’s handle resolving against the tape’s pool) and
visitPrimitive(ref, f). All are device-callable, like signedDistance(); in a CUDA or HIP
translation unit, pass visitPrimitive() a functor whose call operator is EBGEOMETRY_HOST_DEVICE,
since the device compiler treats a lambda’s as a host function there. A fused composition is one leaf
and has no closestPrimitive().
const PrimitiveRef<T> leaf = tape.closestPrimitive(p);
if (leaf.m_leaf != PrimitiveRef<T>::None && tape.isPrimitive<TriMeshSDF<T, 4, 4>>(leaf)) {
const auto mesh = tape.primitive<TriMeshSDF<T, 4, 4>>(leaf); // resolves against the tape's pool
const auto face = mesh.getClosestFace(leaf.m_localPoint);
}
BVH-accelerated unions
Because a plain CSG union is evaluated as \(\min(I_1, \ldots, I_N)\), querying it costs
\(\mathcal{O}(N)\) per point for \(N\) objects. BVHUnion and BVHSmoothUnion
accelerate this by placing the objects’ bounding boxes in a PackedBVH and reducing a
closest-object query to an \(\mathcal{O}(\log N)\) tree traversal instead of a linear scan –
see BVH for how the BVH itself is built and traversed; this section only concerns
how the unions use it. BVHSmoothUnion blends the two nearest objects with a smooth-minimum
functor, SmoothMinOp<T> by default. A tape evaluates a union of many operands the same way
(The tape); the classes here are for many primitives of one type, without compiling an
expression. Each is a plain, trivially copyable value type, in the same way as the analytic shapes
and the mesh distance fields, whose signedDistance() can be called on the host or inside a GPU
kernel:
One primitive type per union.
BVHUnion<T, P, K>holds its primitives by value in thePackedBVH, so every primitive has the same typeP: an analytic shape, a mesh distance field, or another BVH union. No runtime dispatch is needed to evaluate them. For a union of objects of different types, compose them withUnionand compile the result into a tape.Bounding boxes that enclose their primitives. The traversal skips a primitive whose bounding box is further away than the best value found so far. If every box encloses its primitive’s object (where the primitive’s value is not positive), a skipped primitive is positive at the query point, so skipping it never changes the union’s sign, whatever the primitives’ values are. The value is then as good a distance as the primitives’: with
Exactprimitives it equals the plain minimum, withBoundprimitives it is still a bound, and withNotADistanceprimitives only its sign means anything, which the union’sdistanceQualityreports.Primitives with unbounded boxes. A primitive whose box reaches plus or minus the largest finite value of
Tin some direction, such as an infinite cylinder, would make the box of every BVH node above it unbounded and give the build heuristics nothing to work with. The union keeps such primitives out of the BVH and evaluates them at every query, before the traversal, so that their values can prune it: a union of many bounded primitives and a few unbounded ones costs the BVH query plus one evaluation per unbounded primitive.getBVH()holds the bounded primitives andgetUnboundedPrimitives()the others, andcomputeBoundingVolume()covers both.Built in a
Pool. The constructor takes thePoolto reserve the BVH from, the primitives, and one bounding box per primitive, which the BVH needs up front (an analytic shape’scomputeBoundingVolume(), for example), plus an optionalBVH::Constructionstrategy (SAH by default). A further constructor also takes theBVH::ConstructionOptionsthat set the chosen method’s leaf size (see Leaf sizes).Mirrored to a GPU like any pool-backed type. Freeze the pool, mirror it, and pass
rebasedView(devicePool)to a kernel;deepCopy(pool)duplicates the storage.Primitives that live in a pool. A mesh distance field, or a nested union, stored as a primitive must have been built in the same
Poolas the union; the constructor checks this in every build, not only when assertions are enabled. The union stores such a primitive as its layout, which holds offsets but no location, and rebuilds it with the union’s own location as it evaluates it (see Memory model and PoolHandle). That is what makes a union ofTriMeshSDFobjects – a BVH whose primitives are themselves BVHs – evaluable on a mirrored pool and on a GPU.getBVH().getPrimitives()therefore returns the layouts of such primitives.
The ManyObjects example times a BVH union of spheres and of boxes against a plain loop over every object, and NestedBVH builds a union of mesh distance fields.
Combinator |
Class |
Doxygen |
|---|---|---|
BVH-accelerated union |
|
|
BVH-accelerated smooth union |
|
For a single-page API listing, see the Doxygen pages for the headers this page covers: EBGeometry_Expr.hpp (the expression builder), EBGeometry_Tape.hpp (the tape), EBGeometry_Affine.hpp (the affine maps), EBGeometry_Blend.hpp (the blend operators) and EBGeometry_BVHUnion.hpp (the BVH unions). EBGeometry_CSG.hpp includes all of them.