How the Modly UV Unwrapping System Uses BVH Acceleration Structures for Fast Triangle Queries

The Modly UV unwrapping system builds a Bounding-Volume-Hierarchy (BVH) for each face's UV triangles to accelerate ray-triangle intersection tests from O(N) to O(log N), dramatically speeding up island stitching, seam detection, and projection operations.

Modly's open-source 3D pipeline implements a high-performance UV unwrapping module written in C++. At its core, the system relies on BVH acceleration structures to handle the geometric queries required for quality UV generation. This article examines the BVH implementation in lightningpixel/modly, covering construction, traversal, and integration with the unwrapper.

BVH Architecture in Modly

The BVH system follows a classic top-down construction pattern optimized for UV-space triangle queries. Three components work together: the BVH class manages the hierarchy, BVHNode stores spatial partitions, and Triangle represents the leaf geometry.

Core Data Structures

Two files define the BVH interface. In api/uv_unwrapper/uv_unwrapper/csrc/bvh.h, the BVHNode struct occupies 32 bytes with tight packing:

// bvh.h - 32-byte aligned node for cache efficiency
struct BVHNode {
    float min[3];
    float max[3];
    union {
        struct { unsigned int left; unsigned int right; } inner;  // 8 bytes
        unsigned int triangleIndex;                               // 4 bytes
    };
    unsigned int isLeaf;
    // padding to 32 bytes total
};

The BVH class constructor in bvh.cpp pre-allocates a flat buffer using new BVHNode[triCount * 2 + 64]. This contiguous layout prevents pointer chasing and enables SIMD-friendly traversal.

Building the Hierarchy with Binned SAH

Construction uses Surface-Area-Heuristic binning rather than exhaustive evaluation. The BVH::FindBestSplitPlane method tests candidate splits along each axis:

// bvh.cpp - SAH split evaluation
void BVH::FindBestSplitPlane(BVHNode& node, int& axis, float& splitPos, float& bestCost) {
    bestCost = 1e30f;
    for (int a = 0; a < 3; a++) {           // test x, y, z axes
        float boundsMin = node.min[a];
        float boundsMax = node.max[a];
        if (boundsMin == boundsMax) continue; // degenerate dimension
        
        // binning: 8-16 bins typically used
        const int NUM_BINS = 8;
        // ... bin triangle centroids, evaluate SAH cost per bin boundary
    }
}

The BVH::Subdivide method recursively partitions until fewer than 3 triangles remain per node or SAH predicts higher cost for splitting. Each split triggers BVH::UpdateNodeBounds to recompute child AABBs.

Integrating BVH with the UV Unwrapper

The unwrapper instantiates one BVH per face in unwrapper.cpp. This matches Modly's assumption that UV islands map to mesh faces requiring independent acceleration structures.

Per-Face BVH Construction

// unwrapper.cpp - creating BVH instances for UV triangle sets
#include "bvh.h"

// triangles_per_face: array of Triangle* per face
// indices_per_face: corresponding index buffers
// triangle_counts: number of triangles per face

BVH* bvhs = new BVH[face_count];           // one BVH per face

for (int face_idx = 0; face_idx < face_count; ++face_idx) {
    bvhs[face_idx] = BVH(
        triangles_per_face[face_idx],      // Triangle* data
        actual_indices[face_idx],          // int* index remapping
        triangle_counts[face_idx]          // size_t triangle count
    );
    // constructor builds full hierarchy via Subdivide()
}

The BVH constructor signature enforces type safety:

// bvh.h - explicit constructor prevents misuse
BVH(Triangle* tri, int* actual_idx, const size_t& num_indices);

Intersection Queries for UV Operations

The BVH::Intersect method implements iterative depth-first traversal using an explicit stack. This avoids recursion overhead and enables predictable memory behavior:

// bvh.cpp - BVH traversal for triangle intersection
std::vector<int> BVH::Intersect(Triangle& tri_intersect) {
    std::vector<int> hits;
    unsigned int stack[64];                // small stack, sufficient for log2(N) depth
    unsigned int stack_ptr = 0;
    stack[stack_ptr++] = rootNodeIdx;      // push root
    
    while (stack_ptr > 0) {
        unsigned int node_idx = stack[--stack_ptr];
        BVHNode& node = bvhNode[node_idx];
        
        // AABB culling: skip branch if query triangle misses bounds
        if (!AABBIntersects(node.min, node.max, tri_intersect)) continue;
        
        if (node.isLeaf) {
            if (TriangleIntersect(triangles[node.triangleIndex], tri_intersect)) {
                hits.push_back(node.triangleIndex);
            }
        } else {
            stack[stack_ptr++] = node.inner.left;
            stack[stack_ptr++] = node.inner.right;
        }
    }
    return hits;
}

The query reduces triangle tests from O(N) for brute-force to O(log N) for balanced hierarchies. For a 10,000-triangle UV island, this typically means ~14 node tests versus 10,000 triangle tests.

Performance Characteristics and Design Decisions

Memory Layout Optimizations

Modly's BVH makes several cache-conscious choices:

  • 32-byte nodes: Exactly one cache line on x86_64, eliminating false sharing
  • Flat array storage: bvhNode pointer to contiguous memory, not tree of pointers
  • Morton-order consideration: Implicit spatial locality though not full Z-curve

Parallel Usage Patterns

The BVH structure is read-only after construction, enabling safe concurrent queries. The unwrapper exploits this during:

  1. Island stitching: Multiple threads query separate BVHs simultaneously
  2. Seam detection: Parallel edge tests against face BVHs
  3. Texture baking: The same bvh.h implementation accelerates baker.cpp point-triangle lookups

Trade-offs and Limitations

Aspect Implementation Choice Rationale
Rebuild cost Full rebuild per unwrap Expected mesh changes; no incremental update
Split heuristic Binned SAH (8 bins) Balance between quality and build speed
Traversal stack Fixed 64-element array Prevents allocation; handles 2^64 triangles theoretically
Leaf threshold 2-3 triangles Tuned for UV triangle size distribution

Code Paths and File References

All BVH functionality resides in the UV unwrapper's C++ source:

Summary

  • The Modly UV unwrapping system constructs one BVH object per face using binned SAH splitting in bvh.cpp
  • 32-byte BVHNode structs stored in flat arrays optimize cache performance during traversal
  • O(log N) intersection queries via BVH::Intersect replace brute-force O(N) triangle testing
  • Read-only post-construction design permits thread-safe parallel queries across island stitching and seam detection
  • Identical BVH code accelerates both UV unwrapping and texture baking pipelines

Frequently Asked Questions

How does the BVH handle degenerate UV triangles?

The BVH::FindBestSplitPlane method skips axes where boundsMin == boundsMax, preventing division by zero in binning. Degenerate triangles are still inserted into leaf nodes; intersection tests in TriangleIntersect include epsilon-tolerant edge checks.

Can the BVH be reused across multiple unwrap operations?

Yes. The BVH destructor frees node memory, but the class supports move semantics. For static meshes, users could maintain persistent BVH instances, though unwrapper.cpp currently rebuilds per unwrap to handle input changes.

What is the typical memory overhead of the BVH?

Memory usage is approximately 64 bytes × triangle count — the node array allocates 2N + 64 nodes at 32 bytes each, plus the original triangle array. For a 100,000-triangle mesh, expect roughly 6-7 MB total BVH overhead.

Why does Modly use one BVH per face instead of one global BVH?

Per-face BVHs match the UV island structure where topological connectivity matters more than global proximity. This also enables embarrassingly parallel processing: each face's unwrapping runs independently on its own BVH without synchronization.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →