Effigy/MeshBVH.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// AABB tree over faces. Ray hits and radius queries against a dense sculpt mesh, where the
/// linear scan in <see cref="MeshRaycast"/> is no longer viable.
///
/// Built once; <see cref="Refit"/> updates bounds after a stroke sample. Sculpting never
/// changes topology, so the tree structure stays valid and only the boxes move. That is the
/// payoff for refusing dyntopo, and it is the reason this is a refittable BVH rather than a
/// rebuild-every-sample grid.
///
/// Faces are triangulated the same way <see cref="MeshRaycast"/> triangulates them, so a BVH
/// hit and a linear hit name the same face.
/// </summary>
public sealed class MeshBVH
{
const int LeafSize = 4;
const float Pad = 1e-5f;
struct Node
{
public Vec3 Min, Max;
public int Left; // child index; -1 if leaf
public int Right;
public int FaceStart;
public int FaceCount;
}
readonly Node[] _nodes;
readonly int[] _faces;
readonly int _faceCount;
readonly bool[] _seen;
MeshBVH( Node[] nodes, int[] faces, int faceCount, int vertexCount )
{
_nodes = nodes;
_faces = faces;
_faceCount = faceCount;
_seen = new bool[Math.Max( vertexCount, 1 )];
}
public int NodeCount => _nodes.Length;
public int FaceCount => _faceCount;
public bool IsEmpty => _nodes.Length == 0;
public static MeshBVH Build( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.FaceCount == 0 )
return new MeshBVH( Array.Empty<Node>(), Array.Empty<int>(), 0, mesh.VertexCount );
var faces = new int[mesh.FaceCount];
var centroids = new Vec3[mesh.FaceCount];
// PER-FACE BOXES, COMPUTED ONCE.
//
// Every node needs the bounds of its range, and the range at the root is every face. Asking
// the mesh for them walks each face's vertices again at each of the ~log n levels, so a
// vertex is read log n times over a build. Boxing each face up front makes a node's bounds
// a union of count boxes instead - the same total number of unions, but over 6 floats
// already in a flat array rather than an indirection through Faces[i].Indices into
// Positions.
var faceMin = new Vec3[mesh.FaceCount];
var faceMax = new Vec3[mesh.FaceCount];
for ( var i = 0; i < mesh.FaceCount; i++ )
{
faces[i] = i;
var f = mesh.Faces[i];
var sum = Vec3.Zero;
var lo = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
var hi = new Vec3( float.MinValue, float.MinValue, float.MinValue );
foreach ( var vi in f.Indices )
{
var p = mesh.Positions[vi];
sum += p;
lo = CMin( lo, p );
hi = CMax( hi, p );
}
centroids[i] = sum / f.Count;
faceMin[i] = lo;
faceMax[i] = hi;
}
var nodes = new List<Node>( mesh.FaceCount * 2 );
BuildNode( faces, centroids, faceMin, faceMax, 0, mesh.FaceCount, nodes );
return new MeshBVH( nodes.ToArray(), faces, mesh.FaceCount, mesh.VertexCount );
}
static int BuildNode( int[] faces, Vec3[] centroids, Vec3[] faceMin, Vec3[] faceMax, int start, int count, List<Node> nodes )
{
var index = nodes.Count;
nodes.Add( default );
BoundsOfBoxes( faces, faceMin, faceMax, start, count, out var min, out var max );
if ( count <= LeafSize || AllCentroidsEqual( centroids, faces, start, count ) )
{
SortLeaf( faces, start, count );
nodes[index] = new Node
{
Min = min, Max = max,
Left = -1, Right = -1,
FaceStart = start, FaceCount = count
};
return index;
}
var axis = LongestAxis( min, max );
var mid = count / 2;
// PARTITION AROUND THE MEDIAN, DO NOT SORT.
//
// This used to be Array.Sort over the range with a Comparer<int>.Create closure, which is
// the obvious way to write "split at the median" and is quietly the most expensive line in
// the kernel. Two separate costs, both paid at EVERY node:
//
// - Sorting is O(k log k) where selecting the median is O(k). Summed over a whole tree
// that is the difference between O(n log n) and O(n log^2 n).
// - Comparer<int>.Create allocates a comparer per node (~2n of them) and turns every
// comparison into an interface call through a delegate, which does not inline.
//
// Nothing downstream wants the range sorted - BuildNode only ever asks which half a face
// falls in. Select is the operation that was actually meant.
//
// The ordering is the same total order the comparer used, centroid along the split axis
// with the face index breaking ties, so each child gets exactly the same SET of faces the
// sorting version gave it and the tree shape is unchanged. Only the order within a range
// differs, and the leaf sort below puts that back for the ranges anyone can observe.
SelectNth( faces, centroids, axis, start, count, mid );
if ( mid == 0 || mid == count )
{
SortLeaf( faces, start, count );
nodes[index] = new Node
{
Min = min, Max = max,
Left = -1, Right = -1,
FaceStart = start, FaceCount = count
};
return index;
}
var left = BuildNode( faces, centroids, faceMin, faceMax, start, mid, nodes );
var right = BuildNode( faces, centroids, faceMin, faceMax, start + mid, count - mid, nodes );
nodes[index] = new Node
{
Min = min, Max = max,
Left = left, Right = right,
FaceStart = start, FaceCount = 0
};
return index;
}
/// <summary>
/// Recompute every box from the mesh's current positions. Face membership does not change.
/// Call after vertices move; do not call after topology changes — build a new tree.
/// </summary>
public void Refit( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Refit needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
if ( _nodes.Length == 0 )
return;
// FLAT AND BACKWARDS, NOT RECURSIVE.
//
// BuildNode appends a node before recursing into its children, so a parent's index is
// always lower than either child's. Walking the array from the end therefore visits every
// child before its parent, which is exactly the order a refit needs - and it does so as a
// straight sequential pass over the node array instead of two million nested calls, each
// touching the array at an index the prefetcher cannot guess.
for ( var i = _nodes.Length - 1; i >= 0; i-- )
{
ref var node = ref _nodes[i];
if ( node.Left < 0 )
{
BoundsOfFaces( mesh, _faces, node.FaceStart, node.FaceCount, out node.Min, out node.Max );
continue;
}
node.Min = CMin( _nodes[node.Left].Min, _nodes[node.Right].Min );
node.Max = CMax( _nodes[node.Left].Max, _nodes[node.Right].Max );
}
}
/// <summary>
/// Refit only the part of the tree a brush could have touched: the sphere at
/// <paramref name="center"/> of <paramref name="radius"/>, the region the caller just moved
/// vertices inside.
///
/// WHY THIS IS SOUND. A node's current box was computed from where its vertices were BEFORE
/// the edit, and every vertex that moved was inside the sphere at that point. So a node whose
/// box misses the sphere cannot contain a moved vertex, and its bounds - and those of
/// everything below it - are still correct. Descending only into nodes that DO intersect
/// visits the handful of leaves under the brush plus their ancestors, and skips the rest.
///
/// A vertex is free to land outside the sphere; the leaf holding it is recomputed from its new
/// position and the boxes grow on the way back up. What must not happen is MISSING a node that
/// needed recomputing, and the argument above is about the old positions, which is the side
/// that decides that.
///
/// The full <see cref="Refit"/> is for when the whole mesh moved - a transform, a pose, an
/// undo. This is the interactive case, where a stroke touches a thousand vertices of a million
/// and rebuilding every box costs the frame.
/// </summary>
public void RefitRegion( PolyMesh mesh, Vec3 center, float radius )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"RefitRegion needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
if ( _nodes.Length == 0 || radius <= 0f )
return;
RefitRegionNode( mesh, 0, center, radius );
}
/// <summary>Returns whether anything under this node was touched, so a parent only recombines
/// when a child below it actually changed.</summary>
bool RefitRegionNode( PolyMesh mesh, int index, Vec3 center, float radius )
{
ref var node = ref _nodes[index];
if ( !SphereHitsBox( node.Min, node.Max, center, radius ) )
return false;
if ( node.Left < 0 )
{
BoundsOfFaces( mesh, _faces, node.FaceStart, node.FaceCount, out node.Min, out node.Max );
return true;
}
// Both sides, deliberately not short-circuited - a stroke straddling the split touches each.
var left = RefitRegionNode( mesh, node.Left, center, radius );
var right = RefitRegionNode( mesh, node.Right, center, radius );
if ( !left && !right )
return false;
node.Min = CMin( _nodes[node.Left].Min, _nodes[node.Right].Min );
node.Max = CMax( _nodes[node.Left].Max, _nodes[node.Right].Max );
return true;
}
static bool SphereHitsBox( Vec3 min, Vec3 max, Vec3 center, float radius )
{
var dx = center.x < min.x ? min.x - center.x : center.x > max.x ? center.x - max.x : 0f;
var dy = center.y < min.y ? min.y - center.y : center.y > max.y ? center.y - max.y : 0f;
var dz = center.z < min.z ? min.z - center.z : center.z > max.z ? center.z - max.z : 0f;
return dx * dx + dy * dy + dz * dz <= radius * radius;
}
/// <summary>
/// Nearest face hit, same contract as <see cref="MeshRaycast.Raycast(PolyMesh, Vec3, Vec3)"/>.
/// </summary>
public MeshHit? Raycast( PolyMesh mesh, Vec3 origin, Vec3 direction )
{
if ( mesh is null || _nodes.Length == 0 )
return null;
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Raycast needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
var dir = direction.Normal;
if ( dir.LengthSquared < 0.5f )
return null;
var inv = new Vec3( SafeInv( dir.x ), SafeInv( dir.y ), SafeInv( dir.z ) );
MeshHit? best = null;
RaycastNode( mesh, 0, origin, dir, inv, ref best );
return best;
}
void RaycastNode( PolyMesh mesh, int index, Vec3 origin, Vec3 dir, Vec3 inv, ref MeshHit? best )
{
var node = _nodes[index];
var tMax = best is { } current ? current.Distance : float.MaxValue;
if ( !RayHitsBounds( origin, inv, 0f, tMax, node.Min, node.Max ) )
return;
if ( node.Left < 0 )
{
for ( var i = 0; i < node.FaceCount; i++ )
{
var fi = _faces[node.FaceStart + i];
if ( !MeshRaycast.HitFace( mesh, fi, origin, dir, out var t, out var point ) )
continue;
if ( best is { } held && t >= held.Distance )
continue;
best = new MeshHit( point, fi, mesh.FaceNormal( mesh.Faces[fi] ), t );
}
return;
}
RaycastNode( mesh, node.Left, origin, dir, inv, ref best );
RaycastNode( mesh, node.Right, origin, dir, inv, ref best );
}
/// <summary>
/// Vertices whose positions lie inside the sphere. The tree prunes faces whose boxes miss
/// the sphere; the returned set is then filtered by actual distance, so it matches a
/// brute-force scan of every vertex.
/// </summary>
public void VerticesInRadius( PolyMesh mesh, Vec3 point, float radius, List<int> results )
{
if ( results is null )
throw new ArgumentNullException( nameof( results ) );
results.Clear();
if ( mesh is null || _nodes.Length == 0 || radius < 0f )
return;
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Query needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
if ( _seen.Length < mesh.VertexCount )
throw new ArgumentException(
$"Query needs the same vertex count (built for {_seen.Length}, mesh has {mesh.VertexCount})" );
Array.Clear( _seen, 0, mesh.VertexCount );
var r2 = radius * radius;
Collect( mesh, 0, point, radius, r2, results );
}
void Collect( PolyMesh mesh, int index, Vec3 point, float radius, float r2, List<int> results )
{
var node = _nodes[index];
if ( !SphereHitsBounds( point, radius, node.Min, node.Max ) )
return;
if ( node.Left < 0 )
{
for ( var i = 0; i < node.FaceCount; i++ )
{
var face = mesh.Faces[_faces[node.FaceStart + i]];
foreach ( var vi in face.Indices )
{
if ( _seen[vi] )
continue;
_seen[vi] = true;
var d = mesh.Positions[vi] - point;
if ( d.LengthSquared <= r2 )
results.Add( vi );
}
}
return;
}
Collect( mesh, node.Left, point, radius, r2, results );
Collect( mesh, node.Right, point, radius, r2, results );
}
/// <summary>
/// Faces any part of which lies inside the sphere, named the way a raycast names them.
///
/// The box test is only the pruning step; the verdict is the closest point on the triangulated
/// face, using the same triangulation <see cref="MeshRaycast"/> does so a hit here and a hit
/// there agree. A face whose box overlaps the sphere but whose surface does not is not just a
/// loose result — the caller turns every returned face into rasterised texels, so a false
/// positive is visible paint where the brush never touched.
/// </summary>
public void FacesInRadius( PolyMesh mesh, Vec3 point, float radius, List<int> results )
{
if ( results is null )
throw new ArgumentNullException( nameof( results ) );
results.Clear();
if ( mesh is null || _nodes.Length == 0 || radius < 0f )
return;
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Query needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
var r2 = radius * radius;
CollectFaces( mesh, 0, point, radius, r2, results );
}
void CollectFaces( PolyMesh mesh, int index, Vec3 point, float radius, float r2, List<int> results )
{
var node = _nodes[index];
if ( !SphereHitsBounds( point, radius, node.Min, node.Max ) )
return;
if ( node.Left < 0 )
{
for ( var i = 0; i < node.FaceCount; i++ )
{
var fi = _faces[node.FaceStart + i];
if ( FaceTouchesSphere( mesh, mesh.Faces[fi], point, r2 ) )
results.Add( fi );
}
return;
}
CollectFaces( mesh, node.Left, point, radius, r2, results );
CollectFaces( mesh, node.Right, point, radius, r2, results );
}
static bool FaceTouchesSphere( PolyMesh mesh, Face face, Vec3 point, float r2 )
{
if ( face.Count < 3 )
return false;
var corners = new List<Vec3>( face.Count );
for ( var c = 0; c < face.Count; c++ )
corners.Add( mesh.Positions[face.Indices[c]] );
foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
{
if ( SphereTouchesTriangle( point, r2, corners[ia], corners[ib], corners[ic] ) )
return true;
}
return false;
}
/// <summary>
/// Whether the sphere reaches a triangle, by clamping the closest point on the triangle to the
/// vertex, then edge, then face regions in turn. The box pass above is a loose gate; this is the
/// distance that actually decides, so a sphere grazing a box corner far from the triangle it
/// contains does not drag a face in.
/// </summary>
static bool SphereTouchesTriangle( Vec3 p, float r2, Vec3 a, Vec3 b, Vec3 c )
{
var ab = b - a;
var ac = c - a;
var ap = p - a;
var d1 = Vec3.Dot( ab, ap );
var d2 = Vec3.Dot( ac, ap );
if ( d1 <= 0f && d2 <= 0f )
return ap.LengthSquared <= r2;
var bp = p - b;
var d3 = Vec3.Dot( ab, bp );
var d4 = Vec3.Dot( ac, bp );
if ( d3 >= 0f && d4 <= d3 )
return bp.LengthSquared <= r2;
var vc = d1 * d4 - d3 * d2;
if ( vc <= 0f && d1 >= 0f && d3 <= 0f )
{
var v = d1 / (d1 - d3);
return (a + ab * v - p).LengthSquared <= r2;
}
var cp = p - c;
var d5 = Vec3.Dot( ab, cp );
var d6 = Vec3.Dot( ac, cp );
if ( d6 >= 0f && d5 <= d6 )
return cp.LengthSquared <= r2;
var vb = d5 * d2 - d1 * d6;
if ( vb <= 0f && d2 >= 0f && d6 <= 0f )
{
var w = d2 / (d2 - d6);
return (a + ac * w - p).LengthSquared <= r2;
}
var va = d3 * d6 - d5 * d4;
if ( va <= 0f && (d4 - d3) >= 0f && (d5 - d6) >= 0f )
{
var w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
return (b + (c - b) * w - p).LengthSquared <= r2;
}
var denom = 1f / (va + vb + vc);
return (a + ab * (vb * denom) + ac * (vc * denom) - p).LengthSquared <= r2;
}
/// <summary>
/// Reorder faces[start..start+count) so the element that would land at <paramref name="n"/>
/// under a full sort is at n, everything ordered before it is left of it, and everything
/// ordered after it is right of it. Quickselect - average O(count), no allocation.
///
/// The order is (centroid along axis, then face index), which is a strict total order because
/// face indices are distinct. That matters: it means the split never depends on which equal
/// element the partition happened to land on, so a build is reproducible.
/// </summary>
static void SelectNth( int[] faces, Vec3[] centroids, int axis, int start, int count, int n )
{
var lo = start;
var hi = start + count - 1;
var target = start + n;
while ( lo < hi )
{
// Median of three, so the already-sorted runs a mesh generator produces constantly do
// not hit quickselect's quadratic case.
var mid = lo + ((hi - lo) >> 1);
if ( Before( faces[mid], faces[lo], centroids, axis ) ) Swap( faces, lo, mid );
if ( Before( faces[hi], faces[lo], centroids, axis ) ) Swap( faces, lo, hi );
if ( Before( faces[hi], faces[mid], centroids, axis ) ) Swap( faces, mid, hi );
// The median of the three is now at mid; park it at hi as the pivot.
Swap( faces, mid, hi );
var pivot = faces[hi];
var store = lo;
for ( var i = lo; i < hi; i++ )
{
if ( Before( faces[i], pivot, centroids, axis ) )
{
Swap( faces, i, store );
store++;
}
}
Swap( faces, store, hi );
// Before is a STRICT TOTAL order - the face index breaks every centroid tie - so no
// two elements compare equal and the pivot always lands strictly between the two
// halves. That is what rules out the all-equal input that makes Lomuto quadratic.
if ( store == target ) return;
if ( store < target ) lo = store + 1;
else hi = store - 1;
}
}
/// <summary>
/// Leaves are at most <see cref="LeafSize"/> faces, so an insertion sort by face index is
/// nothing. It exists for determinism rather than speed: a radius query returns faces in leaf
/// order, and without this that order would depend on how quickselect happened to partition.
/// </summary>
static void SortLeaf( int[] faces, int start, int count )
{
for ( var i = start + 1; i < start + count; i++ )
{
var v = faces[i];
var j = i - 1;
while ( j >= start && faces[j] > v )
{
faces[j + 1] = faces[j];
j--;
}
faces[j + 1] = v;
}
}
static bool Before( int a, int b, Vec3[] centroids, int axis )
{
var ca = Component( centroids[a], axis );
var cb = Component( centroids[b], axis );
return ca != cb ? ca < cb : a < b;
}
static void Swap( int[] a, int i, int j )
{
(a[i], a[j]) = (a[j], a[i]);
}
/// <summary>
/// Union of precomputed per-face boxes over a range. The build's version of
/// <see cref="BoundsOfFaces"/>; the padding matches, because the minimum of (v - Pad) over a
/// set is the same as (minimum of v) - Pad.
/// </summary>
static void BoundsOfBoxes( int[] faces, Vec3[] faceMin, Vec3[] faceMax, int start, int count, out Vec3 min, out Vec3 max )
{
min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
max = new Vec3( float.MinValue, float.MinValue, float.MinValue );
for ( var i = 0; i < count; i++ )
{
var f = faces[start + i];
min = CMin( min, faceMin[f] );
max = CMax( max, faceMax[f] );
}
min = new Vec3( min.x - Pad, min.y - Pad, min.z - Pad );
max = new Vec3( max.x + Pad, max.y + Pad, max.z + Pad );
}
/// <summary>Bounds straight from the mesh. Refit's version - positions have moved, so the
/// boxes the build cached are stale and the vertices are the only truth.</summary>
static void BoundsOfFaces( PolyMesh mesh, int[] faces, int start, int count, out Vec3 min, out Vec3 max )
{
min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
max = new Vec3( float.MinValue, float.MinValue, float.MinValue );
for ( var i = 0; i < count; i++ )
{
var face = mesh.Faces[faces[start + i]];
foreach ( var vi in face.Indices )
{
var p = mesh.Positions[vi];
min = CMin( min, p );
max = CMax( max, p );
}
}
min = new Vec3( min.x - Pad, min.y - Pad, min.z - Pad );
max = new Vec3( max.x + Pad, max.y + Pad, max.z + Pad );
}
static bool AllCentroidsEqual( Vec3[] centroids, int[] faces, int start, int count )
{
var first = centroids[faces[start]];
for ( var i = 1; i < count; i++ )
{
if ( !centroids[faces[start + i]].AlmostEquals( first, 1e-8f ) )
return false;
}
return true;
}
static int LongestAxis( Vec3 min, Vec3 max )
{
var e = max - min;
if ( e.x >= e.y && e.x >= e.z )
return 0;
return e.y >= e.z ? 1 : 2;
}
static float Component( Vec3 v, int axis ) => axis == 0 ? v.x : axis == 1 ? v.y : v.z;
static Vec3 CMin( Vec3 a, Vec3 b ) =>
new( MathF.Min( a.x, b.x ), MathF.Min( a.y, b.y ), MathF.Min( a.z, b.z ) );
static Vec3 CMax( Vec3 a, Vec3 b ) =>
new( MathF.Max( a.x, b.x ), MathF.Max( a.y, b.y ), MathF.Max( a.z, b.z ) );
static float SafeInv( float d )
{
if ( d > 1e-12f || d < -1e-12f )
return 1f / d;
return d >= 0f ? 1e12f : -1e12f;
}
static bool RayHitsBounds( Vec3 origin, Vec3 inv, float tMin, float tMax, Vec3 bmin, Vec3 bmax )
{
var t0 = (bmin.x - origin.x) * inv.x;
var t1 = (bmax.x - origin.x) * inv.x;
if ( t0 > t1 )
(t0, t1) = (t1, t0);
tMin = MathF.Max( tMin, t0 );
tMax = MathF.Min( tMax, t1 );
if ( tMin > tMax )
return false;
t0 = (bmin.y - origin.y) * inv.y;
t1 = (bmax.y - origin.y) * inv.y;
if ( t0 > t1 )
(t0, t1) = (t1, t0);
tMin = MathF.Max( tMin, t0 );
tMax = MathF.Min( tMax, t1 );
if ( tMin > tMax )
return false;
t0 = (bmin.z - origin.z) * inv.z;
t1 = (bmax.z - origin.z) * inv.z;
if ( t0 > t1 )
(t0, t1) = (t1, t0);
tMin = MathF.Max( tMin, t0 );
tMax = MathF.Min( tMax, t1 );
return tMin <= tMax;
}
static bool SphereHitsBounds( Vec3 point, float radius, Vec3 bmin, Vec3 bmax )
{
var dx = point.x < bmin.x ? bmin.x - point.x : point.x > bmax.x ? point.x - bmax.x : 0f;
var dy = point.y < bmin.y ? bmin.y - point.y : point.y > bmax.y ? point.y - bmax.y : 0f;
var dz = point.z < bmin.z ? bmin.z - point.z : point.z > bmax.z ? point.z - bmax.z : 0f;
return dx * dx + dy * dy + dz * dz <= radius * radius;
}
}