Skip to content

WIP: Replace collision code with faster, vectorisable tests and cache-friendly datastructures. - #7762

Draft
qazwsxal wants to merge 44 commits into
scp-fs2open:masterfrom
qazwsxal:feat/fast-collisions
Draft

qazwsxal wants to merge 44 commits into
scp-fs2open:masterfrom
qazwsxal:feat/fast-collisions

Conversation

@qazwsxal

@qazwsxal qazwsxal commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Hello all, finally getting around to this idea after having it pinging around in my head for a few years. There's some pretty major bottlenecks in the collision code that I think we could massively improve, so I went ahead and clauded up a bunch of optimisations:

BSP node traversal and leaf layout are both cache-unfriendly

First of all, let's look at the structs, and how data gets laid out in them:

// code/model/model.h (master)
struct bsp_collision_node {
    vec3d min;
    vec3d max;
    int   back;
    int   front;
    int   leaf;   // >= 0 if this is a leaf, index into leaf_list
};                // 36 bytes

struct bsp_collision_tree {
    bsp_collision_node *node_list;
    int                 n_nodes;
    bsp_collision_leaf *leaf_list;
    int                 n_leaves;
    model_tmap_vert    *vert_list;
    vec3d              *point_list;
    SCP_vector<vec3d>   poly_centers;
    int                 n_verts;
    bool                used;
};

and the current BSP leaf data format:

// code/model/model.h (master)
struct bsp_collision_leaf {
    vec3d plane_norm;
    int   vert_start;   // index into tree->vert_list
    ubyte num_verts;
    ubyte tmap_num;
    int   next;         // linked list -- next == index+1 in practice, pure pointer-chase
};

BSP traversal is branchy, and doesn't cull when possible

And the BSP bbox traversal code, I've stripped out a bit for brevity:

// code/model/modelcollide.cpp (master), model_collide_bsp() — shape, not verbatim
void model_collide_bsp(int node_index)
{
    bsp_collision_node *node = &tree->node_list[node_index];

    if (!mc_ray_boundingbox(&node->min, &node->max, ...))
        return;

    if (node->leaf >= 0) {
        model_collide_bsp_poly(node->leaf);
        return;
    }

    model_collide_bsp(node->front);   // always both children --
    model_collide_bsp(node->back);    // no tmin vs. best-hit-so-far cutoff
}

So there's a few issues here:

  1. The way we load the BSP means that we append nodes breadth first, so node->front and node->back end up indexing deep into the array, vs hopefully one of them being nicely cache local.
  2. We're always checking both front and back, why aren't they stored close together in memory? If a node contained its childs bounding boxes, we'd avoid having to index them seperately, and could load all of this into registers .
  3. There's no check to see if a closer intersection is actually possible, we shouldn't enter a bounding box if all possible collisions are worse than our current best hit.

Leaf nodes involve pointer chasing and aren't nicely vectoriseable

Testing one leaf means:
2. look up Mc_pm->maps[tmap_num] for the texture, if it's invisible, check submodel[].flags for the invisible-face path before bothering with anything else
3. for each vertex, index into tree->vert_list (its own array)
4. for each of those, index into a separate tree->point_list (a scatter, not a sequential read)
5. run the face/sphereline check against this leaf
6. follow next (pointer chase) to the next leaf in the chain, and repeat

And here's model_collide_bsp_poly() itself, which is what actually does all this per leaf:

// code/model/modelcollide.cpp (master), model_collide_bsp_poly() -- shape, not verbatim
void model_collide_bsp_poly(bsp_collision_tree *tree, int leaf_index)
{
    vec3d *points[TMAP_MAX_VERTS];

    while (leaf_index >= 0) {                          // walk the linked list
        bsp_collision_leaf *leaf = &tree->leaf_list[leaf_index];

        if (leaf->tmap_num < MAX_MODEL_TEXTURES) {
            if (texture_is_invisible(leaf->tmap_num) && !collide_invisible_flag_set())
                return;                                 // bail out of the WHOLE chain, not just this leaf
        }

        for (int i = 0; i < leaf->num_verts; ++i) {
            int vert_num = tree->vert_list[leaf->vert_start + i].vertnum;
            points[i] = &tree->point_list[vert_num];    // scatter: one lookup per vertex
            // ... uvlist[i] filled in from tree->vert_list[...] here too, omitted
        }

        // ... real code branches on tmap_num < MAX_MODEL_TEXTURES here (flat/textured poly),
        // passing either nullptr/-1 or uvlist/tmap_num into the calls below -- omitted
        if (Mc->flags & MC_CHECK_SPHERELINE)
            mc_check_sphereline_face(leaf->num_verts, points, points[0], &leaf->plane_norm, ...);
        else
            mc_check_face(leaf->num_verts, points, points[0], &leaf->plane_norm, ...);

        leaf_index = leaf->next;                        // on to the next leaf in the chain
    }
}

This is pretty horrible when it comes to vectorisation, what we'd really want to do here is ray/sphereline intersections with a bunch of triangles in a clean, branchless loop to encourage autovectorisation. However we don't have that here unfortunately!
I'd also note here the num_verts field. The existing collision leaf code handles n-gon collisions, but requires specifying the plane that the polygon will be projected onto, this results in quite odd collisions, particularly if the plane_norm ends up with a strange value, in particular I noticed turret02 in SC_Asura.pof has triangles with really odd plane_norm values that doesn't seem consistent between faces either. It doesn't look intentional.

New implementation:

The datastructures are as follows:

// code/model/modelbvh.h (this branch)
constexpr int BVH_N = 4;

struct bvh_node {  // BVH_N=4 ->128 Bytes
    float minx[BVH_N], miny[BVH_N], minz[BVH_N];
    float maxx[BVH_N], maxy[BVH_N], maxz[BVH_N];
    int32_t child[BVH_N];
    int32_t count[BVH_N];   // count[i] > 0 -> leaf; count[i] == 0 -> internal
};

struct bvh_tri_indices {
    uint32_t i0, i1, i2;
};

struct bvh_tree {
    SCP_vector<bvh_node> nodes;   // depth-first / pre-order layout

    SCP_vector<vec3d> verts;              // shared, deduplicated vertex pool -- interleaved x/y/z
    SCP_vector<bvh_tri_indices> tris;     // per-triangle index triple -- interleaved i0/i1/i2
    SCP_vector<int> tmap_num;
    SCP_vector<int> original_index;
    SCP_vector<int> leaf_index;
    SCP_vector<bvh_uv> uv0, uv1, uv2;
    SCP_vector<vec3d> normal;             // precomputed unit face normal per triangle
    SCP_vector<bvh_bsphere> bsphere;      // precomputed per-triangle bounding sphere

    int root = 0;
};

We parse the data the same way as before to pull out all information present in the BSP, and instead of using it for collision, build an N-way bounding volume hierarchy with a more friendly structure. This is a three pass construction over a "triangle soup". I use Surface Area Heuristic to determine how to split (this is a pretty standard offline construction approach).

// code/model/modelbvh.cpp (this branch) -- build shape, not verbatim
bvh_tree bvh_build(SCP_vector<bvh_triangle> triangles)
{
    // 1. binary SAH build: top-down, 
    BinaryNode *binary_root = sah_build_binary(triangles);

    // 2. greedily collapse pairs of binary nodes into BVH_N-wide nodes, lowest
    //    SAH-cost collapse first -- standard production technique, 
    // much simpler to implement correctly vs N-way SAH split
    collapse_to_n_wide(binary_root, BVH_N);

    // 3. flatten depth-first / pre-order -- a node's subtree immediately follows
    //    it in the output array, specifically to avoid the BSP tree's breadth-
    //    first cache problem
    bvh_tree tree;
    flatten_depth_first(binary_root, tree.nodes);
    return tree;
}

There's a few advantages to laying out the BVH tree like this. The major one, is that we can test all 4 children of a single node while their data is fresh in cache. The 4-wide layout and child-bbox packing means one node touches at most 2 cache lines with and is often fetched with a single burst vs. up to 4 separate scattered fetches for the BSP equivalent.

BVH Traversal

// code/model/modelbvh.h, bvh_visit_triangles() (this branch)
template <typename Visitor>
void bvh_visit_triangles(const bvh_tree& tree, const vec3d& origin, const vec3d& dir,
    float t_max, float radius, Visitor&& visit)
{
    struct StackEntry {
        int32_t index;
        int32_t count; // >0: leaf (index is a triangle-array start); ==0: internal node
        float tmin;    // the tmin this entry was queued with -- see below
    };
    StackEntry stack[64];
    int sp = 0;
    stack[sp++] = {tree.root, 0, 0.0f};

    while (sp > 0) {
        StackEntry entry = stack[--sp];
        if (entry.tmin > t_max)
            continue;                          // stale: a nearer hit tightened t_max
                                                 // after this entry was queued
        if (entry.count > 0) {
            visit(entry.index, entry.count, t_max);   // t_max passed by ref --
            continue;                                 // visitor can tighten it
        }

        const bvh_node& node = tree.nodes[entry.index];
        StackEntry candidates[BVH_N];
        int num_candidates = 0;
        for (int i = 0; i < BVH_N; ++i) {
            if (node.child[i] < 0) continue;
            float tmin;
            if (!bvh_detail::ray_aabb_visit_tmin(origin, inv_dir,
                    {node.minx[i], node.miny[i], node.minz[i]},
                    {node.maxx[i], node.maxy[i], node.maxz[i]}, t_max, radius, tmin))
                continue;                       // skip, no descent at all
            candidates[num_candidates++] = {node.child[i], node.count[i], tmin};
        }

        sort_ascending_by_tmin(candidates, num_candidates);      // <=4 elements
        for (int i = num_candidates - 1; i >= 0; --i)            // push farthest-first
            stack[sp++] = candidates[i];                         // so nearest pops next
    }
}

N.B. MC_COLLIDE_ALL is handled properly in the code, these snippets are modified not to include it to focus on runtime collision detection.

Visitor here is just a template argument for what kind of leaf intersection we're testing, ray or sphere. It means we don't have to check what kind every time we hit a leaf node, branch prediction is usually OK with this, but better safe than sorry. The main model_collide() function does dispatch based on the existing flags. Function pointers work too, but I'd rather the potential for inlining here for a little performance boost.
I've intentionally switched to using an explicit LIFO stack rather than relying on a recursive definition, this works fine and avoid the overhead from recursive calls. The max stack depth is 64 here, which is pretty big compared to what the real world use cases show (19 on BPC ships, worth checking on BTA's and MediaVPs though), but it's data dependent so I've left a large overhead in case someone builds pathological ships.

I investigated swapping out the looped box test for an auto-vectorisable form, but the differences were negilible.bvh_detail::ray_aabb_visit_tmin also contains an early out and doesn't count a bbox as a "hit" if t_max is already shorter than the bbox intersection.

I also sort the 4 child bboxes so that they're added to the stack nearest last, only 4 nodes here, so sorting is fast, and forces us to explore the best potential node first. This ensures we first visit the node that if hit, will result in the greatest culling of other nodes.

Leaf Traversal

Ray-triangle intersections in ray_triangle_leaf_simd use Möller-Trumbore over a SIMD_WIDTH-wide batch. This is unconditional/branchless so it autovectorizes; I checked the disassembly and got real packed xmm/ymm with widths of 4 and 8 respectively.

The sphereline (radius-swept) leaf test in mc_check_triangle_sphereline_face is scalar, not SIMD-batched — every attempt at vectorizing this one measured slower and was reverted (see below); it still gets the same early-out discipline the ray path gets from front-to-back traversal:

The bvh_tree struct above already shows the leaf-side representation. There's a few points to note here:

  • Leaf size isn't fixed, SAH construction can decide that splitting a node isn't worth it, there's a couple of very high (80-120) triangle count leaf nodes in edge-case geometry where large flat triangles next to a bunch of small ones don't align with axes.
  • Might be worth checking if there's any improvements to the algorithm that can be made there, this is a pretty standard arrangement when modelling.
  • Any node with <=16 triangles in it is automatically converted into a leaf node. At a certain point, linear scans are just worth doing, and 16 seemed the sweet spot for sphereline intersections.
  • N-gons are triangulated, we test ray and sphere collisions against the raw geometry, not n-gons projected onto .POF surface norms, triangulation matches generation for rendering.
  • This does lead to some minor hit-location differences.
  • Tree nodes store child and count, if count > 0, then the triangles are taken from tris in the [child, child+count) range.
  • tris corresponds to a densely packed vector of vertex indices, three per tri.
  • These then point into the verts array, three float32 vals per entry. We do a deduplication pass here, sharp edge rendering might need split edges and duplicated verts, but for collision purposes, duplicating data is pointless, and as these

Submodel walk: full linear search vs. top-level LBVH

  • Whole-model queries walk every submodel via a plain linked-list recursion, there's no spatial index or bounds check before recursing — aibig.cpp self-documents this as ~10% of AI frametime
  • A spatial index would be nice here! However, submodels can rotate/translate at runtime (turrets, animations, Lua), so a static once-at-load spatial tree isn't safe.
  • So instead, I use a top-level LBVH (Morton-code centroid BVH tree) rebuilt fresh once per frame per ship instance, This takes between 0.5-8.8μs to rebuild depending on submodel count (tested up to 200), and is re-used across all collision checks.
  • Synthetic tests showed a good performance, boost here, but real-mission testing needs to be done.
  • I also added skips to avoid doing a collision test entirely if a submodel's AABB is already farther than an existing hit.

Shields: just another subobject

  • Currently shields have a bespoke byte-blob walker mc_check_sldc() that uses hard-coded offset arithmetic, this has an order-dependent bug (front/back || short-circuit skips the back subtree)
  • Shields are pure triangles with no submodel hierarchy, so I build a bvh_tree as if they were just another submodel and go through the exact same code (model_collide_bvh_triangle())
  • No more collision bug, and now we dont have two subtly different implementations of tree-walking collision code

qazwsxal and others added 30 commits August 29, 2026 00:32
Introduces code/model/modelbvh.h/.cpp: a binary SAH-built, N-wide
SoA BVH over flat triangle soup, with a minimal ray traversal for
self-validation. Deliberately independent of POF/BSP/engine types so
it can be built and unit-tested against synthetic geometry only, with
no collision-pipeline integration yet.

Covered by test/src/model/test_modelbvh.cpp: degenerate inputs, a
cube structural/containment check, padding-slot sentinels, and ray
queries validated against a brute-force Moller-Trumbore oracle.
…data (stage 2)

Adds code/model/modelbvh_extract.h/.cpp, a bridge that extracts a flat,
fan-triangulated bvh_triangle soup for a submodel directly from its
already-parsed bsp_collision_tree, so it reuses the exact vertex/polygon
data the existing BSP collision tree is built from rather than re-walking
the raw BSP opcode stream.

Adds test/src/model/test_modelbvh_parity.cpp: a gtest that, when pointed
at a real external .pof via the FSO_BVH_PARITY_POF env var, loads it and
fires rays at every submodel comparing model_collide() (old BSP tree)
against the new BVH's nearest front-facing hit. Skipped by default (no
.pof ships in-repo or in CI), so it never runs in normal test invocations.

Requires code/cfile/cfilesystem.h/.cpp: cf_add_external_path_root(), the
one piece of plumbing missing to point cfile at an arbitrary on-disk
directory outside any configured mod/VP root, needed to load a poF file
living outside the repo.

Verified against a real ship POF (blueplanetcomplete): 31800 rays across
106 submodels, 90 hit mismatches (0.28%), all old-missed/new-found (the
old system's conservative bounding-sphere early-out occasionally skips a
ray the BVH's exact triangle test still catches) plus one close distance
mismatch on overlapping thin geometry -- no sign of a BVH defect.
Adds code/model/modelbvh_leafindex.h/.cpp: a sibling to stage 1's
triangle-soup modelbvh module, built over caller-supplied AABB+payload
primitives instead of raw geometry. This is the shape stage 3's engine
integration actually needs: the existing per-polygon test functions in
modelcollide.cpp operate on original n-gons via point-in-polygon tests,
not triangles, so only the spatial index (which leaf a ray's box
reaches) gets replaced -- this module provides that as a visitor-based
traversal (bvh_visit_leaves), leaving polygon-level testing untouched.

Along the way, found and fixed a real bug in the SAH build shared by
both this module and stage 1's modelbvh.cpp: AABB::grow(const AABB&)
merged empty/never-grown bins unconditionally, poisoning the running
union out to the +-FLT_MAX sentinel and making every candidate split's
cost evaluate to infinity -- silently collapsing the tree into one
giant leaf whenever a SAH bin was empty. Real (dense) geometry rarely
hit this, which is why stage 1/2's synthetic and real-POF tests didn't
catch it; the new module's sparse test data did. Fixed in both files
by skipping merge for a not-yet-grown AABB.

Also extends test_modelbvh_parity.cpp to accept multiple .pof files
and/or directories (';'-separated, directories searched recursively)
via FSO_BVH_PARITY_POF, ahead of running it against a broader real-POF
corpus for stage 3's Phase A2.
MC_CHECK_INVISIBLE_FACES, found while running against ~220 real POFs

The single-ship parity run (stage 2) looked clean (0.28% mismatch),
but running against the full blueplanetcomplete models folder exposed
much higher rates. Root causes were in the test harness, not the BVH:

1. model_collide() with MC_SUBMODEL correctly refuses to test any
   submodel flagged No_collisions/Nocollide_this_only (e.g. thruster
   glow-effect submodels) -- the harness didn't know about these flags
   and compared against them anyway, showing up as pure hit mismatches
   with zero distance mismatches. Now skipped, matching engine behavior.

2. model_collide() also refuses to collide with any polygon whose
   texture failed to load (mc_check_face's GetTexture()<0 check) --
   by design, not a bug. This test's headless setup only registers the
   mod's models directory with cfile, never its texture data, so
   textures never actually load here. Added MC_CHECK_INVISIBLE_FACES
   to bypass that check, so both sides compare pure geometry, which is
   what this harness is meant to validate in the first place.

After both fixes: hit-mismatch count on the full folder run dropped
from 21852 to 13485 (~1425000 rays across 219 files). Remaining
distance mismatches are consistently tiny (median 0.004, max 0.52,
none over 1.0 out of typical hit distances ~1.0) -- consistent with
genuinely near-tied nearest-hit ambiguity on closely-stacked mechanical
geometry (turret armatures etc.), not a bug. A residual chunk of hit
mismatches remains unexplained (e.g. SC_Asura, UED_Solaris, several
capital ships) and needs further investigation before Phase A2 is
considered complete -- see collision_bvh_rewrite_plan project notes.
bsp_collision_leaf::plane_norm is parsed verbatim from the .pof file
(exporter output) and used, unvalidated, for both the backface cull
and the ray/plane solve that produces the actual hit distance in
mc_check_face()/mc_check_sphereline_face(). On real content this can
diverge sharply from the polygon's true geometric plane -- confirmed
on SC_Asura.pof's turret02 (avg 21 degrees, worst leaf 103.77 degrees
off), which both flips cull decisions and solves hits against a
tilted, wrong plane. Full writeup: see the collision_bvh_rewrite_plan
and collision_bugs_found ("Bug 3") project notes.

Adds mc_compute_geometric_normal(), a single-pass Newell's-method
normal computed directly from each polygon's own vertices (robust for
n-gons, not just triangles), and shadows the plane_norm parameter with
it at the top of both check functions so every downstream use (cull,
plane solve, in-polygon test, reported hit_normal) is automatically
consistent -- including the shield sphere-check path, which shares
mc_check_sphereline_face(). Falls back to the stored normal only when
the computed one is degenerate (zero-area/collinear polygon).

Verified against the golden-parity harness across the full
blueplanetcomplete model set (219 POFs, 1.425M rays): hit mismatches
dropped from 13485 to 1635 (~88%), and distance mismatches from 61977
to 99 (~99.8%) -- confirming most of what had been characterized as
"benign near-tied geometry ambiguity" was actually this same root
cause. SC_Asura.pof itself, the original worst offender, is now 100%
clean (0/17100). Remaining mismatches are smaller-scale and likely a
mix of the already-documented Bug 1 (bad submodel rad) and other
causes not yet investigated.
bsp_info::rad is parsed verbatim from the .pof file and used by
model_collide()'s MC_SUBMODEL/MC_SUBMODEL_INSTANCE bounding-sphere
pre-check to reject a ray before any polygon test runs. On real
content this can undershoot the submodel's true geometric extent by a
wide margin -- confirmed cases from 2x up to 250x too small (e.g.
UEF_Uriel's "barrelclamp": rad=1.7 vs a true extent of 432.7) --
silently causing model_collide() to report a miss for rays that
demonstrably do hit real geometry. Full writeup: collision_bugs_found
("Bug 1").

Adds bsp_info::collision_rad, computed once at model-load time (same
pass that already builds the BSP collision tree, from the same vertex
data) as max(authored rad, true distance from the submodel's local
origin to its farthest vertex), and switches model_collide()'s
MC_SUBMODEL pre-check to use it. The authored `rad` field itself is
left untouched, since ~20 other files (rendering culling, radar, AI
targeting, HUD, mission UI) also read it and may depend on the
author-tuned value for non-collision purposes.

Verified against the golden-parity harness (219 POFs, 1.425M rays):
hit mismatches dropped from 1635 to 117 (~93% further reduction on
top of the plane-normal fix), and every ship previously carrying a
confirmed bad-rad submodel (UEF_Uriel, UEFg_KarunaMK2, UED_Toutatis,
skyscraper2) is now fully clean or reduced to single digits. Combined
with the plane-normal fix, total hit mismatches across the full model
set are down 21852 -> 117 (99.5%) from the original stage-2 baseline.

Note: Mc_pm->rad (the whole-model radius, used for the equivalent
pre-check on the normal non-MC_SUBMODEL collision path) is read from
the same untrusted file field and was not touched here -- the parity
harness only exercises MC_SUBMODEL, so this fix's validation doesn't
cover it. Flagged as a separate, not yet investigated, potentially
higher-impact instance of the same pattern in collision_bugs_found.md.
Adds the opt-in engine integration this whole rewrite has been
building toward -- a real BVH-based replacement for model_collide_bsp()'s
tree traversal, gated behind a new -use_bvh_collision cmdline flag so
it coexists with the legacy BSP path rather than replacing it outright.

- bsp_info::bvh (model.h): a shared_ptr<bvh_leaf_tree> alongside the
  existing collision_tree_index. shared_ptr, not unique_ptr -- bsp_info
  is explicitly copy-constructed in modelreplace.cpp (appending
  submodels between models), matching how bsp_data/outline_buffer
  already handle that.
- model_bvh_extract_leaf_primitives() (modelbvh_extract.cpp): one
  bvh_leaf_primitive per existing bsp_collision_leaf, AABB from that
  leaf's own vertices, payload = leaf index. No re-triangulation.
- Model-load hook (modelread.cpp): builds the BVH alongside the BSP
  tree, from the same already-parsed vertex data, only when
  Cmdline_use_bvh_collision is set (avoids the extra load-time
  cost/memory for everyone else while this is opt-in).
- mc_check_bvh_leaf()/model_collide_bvh() (modelcollide.cpp): the new
  traversal. Every visited leaf still goes through the exact same
  mc_check_face()/mc_check_sphereline_face() polygon-level logic as
  the legacy path, so MC_COLLIDE_ALL, sphere-line edge fallback,
  backface culling and all mc_info output fields behave identically --
  only the spatial index changes. Leaves are visited independently
  (no leaf->next chain), which deliberately fixes Bug 2's chain-abort-
  on-invisible-texture quirk for this path only, per the collision_bugs
  project notes; the legacy path is untouched and keeps the old
  behavior exactly.
- mc_check_subobj() branches on Cmdline_use_bvh_collision (including
  through LOD substitution) between the two traversals.

test/src/model/test_modelbvh_traversal_parity.cpp: a new gtest,
skipped by default like the existing golden-parity test, that runs the
exact same ray through both traversals (toggling the flag live around
each call) and compares them directly through the real model_collide()
entry point -- a stronger check than comparing against an independent
oracle, since it validates the actual swapped-in code path rather than
an approximation of it. Deliberately does not compare mc_info::num_hits
for equality (an order-dependent bookkeeping counter of the traversal's
internal history, not a stable result) -- only whether a hit was found
and the winning hit's own fields.

Verified against the full blueplanetcomplete model set (219 POFs,
1.425M rays): 619 mismatches (0.043%), all traced to genuine ties
(same hit_dist/hit_point, different attributed leaf) on either dense
real-ship geometry or, more visibly, background/skybox objects with
large flat panels sharing exact seams -- not a defect in the new path.
Full test suite has no regressions.
Cmdline_use_bvh_collision now defaults to true. The cmdline flag
becomes an opt-out (-no_bvh_collision, was -use_bvh_collision) rather
than an opt-in, so the legacy BSP traversal stays available as a
fallback for comparison/debugging, but every player runs the new
BVH-based collision path by default from here on.

Full test suite and both golden-parity tests (219 real POFs, 1.425M
rays each) re-verified against this default with no change in result
(619/1425000 mismatches, same already-characterized benign ties).
Performance has not been benchmarked yet -- that's the next step,
separate from this correctness validation.
test/src/model/test_modelbvh_benchmark.cpp: same real-POF loading
infrastructure as the golden-parity/traversal-parity tests, but times
model_collide() through both traversals (via the live
Cmdline_use_bvh_collision toggle) instead of checking correctness.
9 alternating trials (old/new/old/new/...) after a warm-up pass, to
reduce bias from transient system noise; reports median and min
wall-clock time and per-call cost for both paths. Skipped by default,
same FSO_BVH_PARITY_POF convention as the other real-content tests.

Measured against the full blueplanetcomplete model set (219 POFs,
95000 ray cases spanning 5.3M leaf/triangle primitives across all
built BVHs): the new BVH traversal is 1.71x faster (median), 1.62x
faster (min-of-trials) than the legacy BSP tree traversal.
…r-frame pruning

Replaces n-gon/leaf-chain collision testing with real per-triangle testing
(SIMD-batched Moller-Trumbore over the existing triangle-BVH), and removes
the intermediate stage-3 leaf-BVH hybrid entirely now that this supersedes
it. New per-triangle BVH path is opt-in via -use_new_collision.

Two real correctness bugs found and fixed via real-content parity testing:
- BVH AABB traversal wasn't inflated by sphere radius for MC_CHECK_SPHERELINE.
- The SIMD fast path's only retry mechanism was keyed to texture-invisibility
  specifically, not any rejection reason -- a geometrically-picked candidate
  that the scalar confirm rejected for being a backface or beyond the query's
  segment length silently dropped a valid farther hit in the same leaf,
  including on the hottest possible path (weapon-vs-ship-hull). Generalized
  the retry condition to "did the scalar confirm actually register a hit."

Two new pruning optimizations, both measured against real content:
- Nearest-hit traversal pruning within a submodel's own tree (t_max
  tightens as closer hits are found), plus inlining the AABB slab test so
  it can actually fold into the hot per-node loop instead of living
  out-of-line in a separate translation unit.
- Submodel-level pruning in mc_check_subobj(): skip a submodel's own
  geometry test entirely once a closer hit already exists elsewhere and
  this submodel's own bounding-box entry point can't beat it. This is the
  actual framerate lever for real per-frame queries (weapon-vs-capital-ship,
  AI attack-point casting, turret/beam checks all recurse through 100+
  submodels with no per-submodel narrowing) -- measured ~35-45%
  (legacy BSP) / ~20% (triangle-BVH) reduction in absolute query time on a
  new benchmark shaped like those real queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolves the one real conflict (duplicate include additions in
modelread.cpp's per-submodel load loop) by keeping both new includes.
No logical conflict: scp-fs2open#7754 clears bsp_info::bsp_data (the raw POF
opcode buffer) in a loop added *after* the existing per-submodel loop
that builds the BSP collision tree and triangle_bvh from it, so the
BVH build still runs before the buffer it (indirectly, via the
already-parsed bsp_collision_tree) depends on is freed.

This is prep for decoupling the BVH candidate-test path from
bsp_collision_leaf/bsp_collision_tree entirely, so that structure can
be torn down the same way bsp_data now is.
…y BSP path

The BVH candidate test no longer dereferences bsp_collision_leaf: tmap_num
is read from the triangle's own bvh_tree storage (already duplicated there
at extraction time), and mc_info::bsp_leaf becomes a plain hit_tmap_num
int, with shipfx.cpp's cockpit-glare-occlusion check updated to match.
This makes the BVH candidate path fully self-contained.

With that dependency gone, the legacy leaf-chain BSP traversal
(model_collide_bsp/model_collide_bsp_poly/mc_check_face) and the
-use_new_collision/Cmdline_use_triangle_collision toggle are removed
outright -- the BVH path is now unconditional. mc_check_sphereline_face
survives (simplified) since shield-mesh collision still uses it.

Adds model_bsp_collision_tree_release_leaf_data(), called right after
BVH build in the same load-time pass PR scp-fs2open#7754 added for freeing bsp_data,
to free node_list/leaf_list/vert_list once they're no longer needed --
point_list/poly_centers/n_verts deliberately survive for their
independent consumers (submodel_get_random_point() and friends,
aibig.cpp's attack-point picker, the Lua Submodel API).

Removes the six legacy-vs-BVH comparison test files, since there's no
longer a legacy path to diff against; test_modelbvh.cpp's synthetic-
geometry module tests are unaffected. Trims the BVH modules' in-code
bug-discovery/experiment-history comments down to the still-true
invariants -- that history lives in project memory notes instead.

Full test suite (249 tests) and a real full-object sphereline benchmark
against blueplanetcomplete-3.3.3 (219 POFs, 219k mixed-radius cases)
both verified: ~1.73x faster than master's unmodified BSP path (mean),
with a noticeably tighter trial-to-trial spread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iate leaf/node tree

model_collide_parse_bsp() now walks the POF BSP opcode stream once and
emits fan-triangulated bvh_triangles directly, instead of building a
bsp_collision_node/bsp_collision_leaf tree that model_bvh_extract_submodel_triangles()
immediately re-walked to do the same triangulation. The node topology that
design spent most of its work on was never read past that one re-walk.
bsp_collision_tree keeps only point_list/n_verts/poly_centers, which three
consumers unrelated to collision (modelinterp.cpp, aibig.cpp, the Lua
Submodel API) still read for a submodel's full lifetime. Verified with a
temporary old-vs-new parity check against real ship POFs (exact match,
since removed) plus the full unittests suite.

Also adds a real-POF sphereline profiling workload test
(test_modelbvh_profile.cpp, opt-in via FSO_BVH_PROFILE_POF_DIR) for future
collision perf work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…etry

collision_rad now always equals the submodel's authored rad rather than
max(rad, computed geometric extent) -- an author may intentionally tune
rad to make collisions behave differently from the visible mesh, and
collision shouldn't silently override that call. This only affects the
MC_SUBMODEL/MC_SUBMODEL_INSTANCE bounding-sphere pre-check (e.g. checking
one specific rotating submodel); the deeper per-submodel bounding-box
check (sm->min/max) was always author-only and is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…traversal

bvh_ray_intersect() was a scalar, unordered-stack reference query that
existed only to validate bvh_build()'s tree structure in tests -- nothing
in the live collision path called it. Its 4 dependent tests
(BvhRayTests.*) now query via bvh_visit_triangles() + ray_triangle_leaf_simd(),
the same combination model_collide() actually uses, tightening t_max on
each closer hit the way the live path does. Its private ray_triangle()
helper is also removed (no other callers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e scalar leaf loop

pad_leaves_to_simd_width() copied the real triangle's tmap_num onto its
degenerate padding copies, so mc_check_bvh_triangle_candidate()'s scalar
leaf loop (the live path for sphereline queries, and the ray fallback)
had no cheap way to reject them -- each one ran through the full
geometry/sphereline test before failing on its collapsed (zero-area)
shape. Padding now gets tmap_num = -1 (matching bvh_triangle's own
default, and Embree's sentinel-based invalid-lane convention), rejected
in O(1) by an early check before any texture-array indexing. This also
closes a latent Mc_pm->maps[-1] out-of-bounds read that copying the real
tmap_num would have produced the moment it happened to be -1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sphere_triangle_leaf_simd() already computed a batched fvi_sphere_plane()
face test per lane (active[]), but never used it to skip anything -- the
3 expensive test_edge() calls (2 quadratics + a vertex fallback each) ran
unconditionally for every chunk regardless, with their output only read
downstream when active[i] was true anyway. Now the 3 edge calls are
skipped entirely for a chunk where zero lanes are active, since nothing
in that case could ever read their result. Zero output change (confirmed
by the existing SphereTriangleLeafSimdTests, including a case built
specifically around an all-inactive chunk) -- this only removes work
whose result was already being discarded.

Still unwired from the live collision path pending a fresh benchmark
against the scalar reference now that this exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…af_simd()

Projects the sphere's sweep onto the plane perpendicular to its own
direction: since motion along that direction doesn't change the
perpendicular component, the swept sphere's projection is a single fixed
circle (center = sphere_p0's projection, radius = the query radius) for
the whole [0,1] sweep. Whether any point on a triangle is within that
circle is therefore a necessary condition for a hit, independent of
fvi_sphere_plane()'s time-window test above it (which can't see the
triangle's actual bounded extent, only its infinite plane) -- so ANDing
the two into one active[] mask rules out strictly more triangles before
paying for the three expensive test_edge() calls.

Pulled into its own function (sphere_triangle_active_mask_simd()) and
written in the same "every lane computed unconditionally, priority-order
ternary select instead of if/else" style the rest of this file uses --
the closest-point-on-2D-triangle computation (Ericson's RTCD 5.1.5
region test) is now a flat, fixed-trip-count, branch-free-per-lane loop
instead of a per-lane `if (!active[i]) continue` plus a nested 7-way
if/else cascade, matching this file's actual SIMD convention rather than
genuine scalar branching sitting in a function named _simd.

Verified against the existing SphereTriangleLeafSimdTests (all 4, incl.
the 200-triangle/300-query brute-force parity check) -- zero output
change, confirming the ternary cascade replicates the original
if/else-if priority exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rk status

Both gates (chunk-level pre-gate, projected-circle spatial gate) were
wired in and re-benchmarked; still slower than the scalar path, so the
comment no longer claims "not yet re-benchmarked". Numbers and root
cause live in the collision_bvh_rewrite_plan memory note, not here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r path

Two independent improvement attempts (a chunk-level pre-gate, a
branch-free projected-circle spatial gate) were tried and benchmarked
against the plain scalar sphereline path on real POF data; both together
still measured ~1.53x slower, worse than the original ~1.4x regression
that motivated the first attempt. No confirmed path to a net win found.
Stripped: sphere_triangle_leaf_simd(), sphere_triangle_active_mask_simd(),
SphereTriangleLeafSimdTests, and their brute-force oracle. The live
sphereline path (mc_check_bvh_triangle()'s plain scalar exhaustive scan)
is untouched -- it remains the fastest mode measured. Numbers and root
cause recorded in the collision_bvh_rewrite_plan memory note for anyone
who wants to pick this back up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…c with an exact projection

mc_check_triangle_sphereline_face()'s edge fallback called
fvi_polyedge_sphereline(nv=3), whose per-edge structure solves the
touch-time against the edge's infinite line (a quadratic, "stage 1"),
then re-solves a second quadratic for the edge parameter at that time
and cross-checks it against the sphere position with a loose distance
tolerance ("stage 2") to confirm the touch point actually falls on the
bounded segment. That second solve is redundant: once stage 1's time is
known, the exact edge parameter is a single line-point projection
(one division), not another quadratic -- no tolerance needed, since it's
an exact algebraic condition rather than an approximate one.

mc_triangle_edges_sphereline() (new, in modelcollide.cpp) reproduces
stage 1 and the vertex-point fallback unchanged, replacing only stage2
with the direct projection. Verified against fvi_polyedge_sphereline()
(still live and unchanged for shield collision, which uses its n-gon
form) across 1000+ random triangle/sphere configurations, including
grazing-radius cases exercising the vertex fallback and edge-boundary
transitions -- exact hit/miss agreement, times within 1e-2.

Isolated ns/call benchmark (geometry biased toward real near-edge
crossings, not uniformly-scattered misses that both versions reject
identically): ~1.05x faster (51.9 vs 54.6 ns/call median, 9-trial
alternating). Modest, since only the ~1-of-3 edges actually near the
sphere's path benefits -- but real and measured, unlike the two SIMD
attempts earlier this session.

This lands unconditionally (no runtime toggle) since it's a drop-in,
verified-equivalent replacement, not an experimental alternate path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the legacy SLDC/SLC2 byte-offset tree walker with a per-triangle
bvh_tree built over the shield mesh at load time, reusing hull's proven
traversal and triangle-test functions (model_collide_bvh_triangle(),
mc_check_triangle_face()/mc_check_triangle_sphereline_face()) via a
Mc_submodel = -1 sentinel. This gives shields correct nearest-hit tracking
(the legacy code overwrote the hit unconditionally per triangle, with no
comparison against a running best) and geometric normals, both as a side
effect of running through the same code hull already uses, plus SIMD/BVH
acceleration shields never had before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…), dedupe triangle vertex fallback

fvi_polyedge_sphereline()'s "stage 2" (a second quadratic solve for the
edge parameter, cross-checked against the sphere position with a loose
distance tolerance) is replaced with the same exact line-point projection
mc_triangle_edges_sphereline() already used, removing the redundant solve
and its tolerance fudge-factor.

mc_triangle_edges_sphereline() now caches each vertex's sphere-touch-time
lazily across the triangle's 3 edges, since each vertex is shared by 2
edges and a naive per-edge fallback would solve it twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…- dead code since the shield BVH port

mc_check_sphereline_face()'s only caller, mc_shield_check_common(), was
deleted when shield collision moved onto the BVH/triangle-test path; no
other n-gon sphereline consumer exists. mc_compute_geometric_normal()
(Newell's-method n-gon normal) was only used by that function. Also
removes now-unused TOL/DIST_TOL macros and a large block of already-dead
commented-out debug code carried along with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dges_sphereline()

Last remaining consumer (mc_check_sphereline_face(), deleted previously)
was already dead code, and mc_triangle_edges_sphereline() already carried
the same fix this function had just been given. Test coverage that
compared the two as an oracle pair is replaced with self-consistency
checks (the reported hit point must lie on one of the triangle's edges
and be exactly Rs from the sphere center at the reported time) and a
couple of extra targeted cases (vertex-only hit). The shield-collision
correctness test's independent oracle now calls
mc_triangle_edges_sphereline() directly for its edge fallback instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
qazwsxal and others added 14 commits September 5, 2026 01:40
…l queries

model_collide()'s whole-model query path (MC_CHECK_MODEL, no
MC_SUBMODEL/MC_SUBMODEL_INSTANCE) previously recursed linearly through
first_child/next_sibling to test each submodel's bounding box -- flagged
earlier this session as a real cost driver on ships with many submodels
(AI targeting, turret hull-blocking, beams). This adds a small binary
LBVH (Morton-code build, not SAH -- deliberately cheap enough to rebuild
every frame) over every submodel's box, letting a query visit only the
O(log n) subset a ray/sphere can actually reach.

For a model with no instance, submodels never move, so the tree is built
once at load time. For an instanced model (turrets, triggered animations,
Lua-driven rotation can all move submodels), it's rebuilt lazily on first
use each frame and reused by every collision query against that instance
for the rest of the frame -- mirroring how rendering already recomputes
submodel transforms fresh every frame with no persistent cache.

Verified against the existing recursive walk (kept, forced via a
temporary toggle) across real ships, including rotated, blown-off, and
collision_checked-excluded submodels. That verification surfaced a real,
separately-committable bug in the "already found a closer hit" prune
added earlier this session: it compared an absolute distance against
Mc->hit_dist, a parametric fraction of Mc_direction, so the prune could
fire far too aggressively (any hit already found would suppress testing
almost any later submodel) whenever the query direction's magnitude was
far from 1. Fixed by dividing by Mc_mag before comparing -- both paths
now agree exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l LBVH

Mc_force_legacy_submodel_walk existed only so
test_modelcollide_toplevel_bvh.cpp could force the old recursive walk for
comparison against the new LBVH path -- that verification is done (see
the previous commit), so both the toggle and the test are removed. The
whole-model query path now always uses the top-level LBVH.

The recursive first_child/next_sibling walk itself stays in
mc_check_subobj() -- MC_SUBMODEL_INSTANCE queries (a specific submodel
plus its own descendants, which may start at an arbitrary submodel, not
just the root) still use it; only the whole-model path's use of it was
made fully redundant by the LBVH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mirrors bvh_node's per-slot-box style: each node's two child slots carry
their own box directly, and a leaf is encoded as a negative child index
(submodel_index = -child - 1) rather than a separate node in the array
with its own item-table indirection. Halves node count (n-1 internal
nodes instead of 2n-1) and drops the items array entirely, since a
leaf's submodel index is now recoverable straight from the sign-encoded
child value. The root can itself be a leaf (single-item tree), so the
tree keeps its own root_min/root_max for that case, with root ==
INT32_MIN as the dedicated empty-tree sentinel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior binary lbvh_node meant a ray-vs-children test was two scalar
slab tests per level with no batching opportunity. Rebuilds each node
as a 4-wide SoA node (minx/miny/minz/maxx/maxy/maxz/child[BVH_N]),
identical in shape to modelbvh.h's bvh_node, by splitting the
Morton-sorted range once and then splitting each half again wherever
it still holds more than one item -- collapsing two binary LBVH levels
into one 4-wide node, the same strategy bvh_build() already uses for
the triangle module. Leaves keep the same sign-encoded child[i] trick
(submodel_index = -child[i] - 1); a dedicated INT32_MIN sentinel now
marks a genuinely unused slot (2- or 3-child nodes), padded with the
same impossible min>max box bvh_node uses for its own padding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
objcollide.cpp's queue_mp_collision() load-balances obj_pairs across
worker threads by queue length, not by target object, so two pairs
that both involve the same ship instance (e.g. two weapons converging
on one target) can be processed on different threads within the same
frame. polymodel_instance::submodel_bvh_cache is shared, mutable state
on the instance itself (not thread_local like Mc_pm/Mc_pmi), so two
threads racing to rebuild it in the same frame -- or one rebuilding
while another reads -- was a real data race.

submodel_bvh_cache_frame is now atomic, so the common-case "already
built this frame" check is well-defined without locking, and an actual
rebuild is guarded by a new per-instance mutex with double-checked
locking so only one thread ever rebuilds per frame and no reader can
observe a partially-built tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
vx/vy/vz and i0/i1/i2 were each split into three separate parallel
arrays, but a vertex's x/y/z (or a triangle's three indices) are
always fetched together and never independently -- splitting them
meant one logical 12-byte read cost three touches to three physically
distant memory regions instead of one contiguous read. Replaced with
SCP_vector<vec3d> verts and SCP_vector<bvh_tri_indices> tris (a small
{i0,i1,i2} struct). The SIMD leaf test's own destination arrays
(v0x[BVH_N] etc.) still need to be per-component for the vector math,
but that's a property of the destination shape, not a reason to store
the source that way -- gathering by index needs a per-lane scalar
read regardless of source layout (no HW gather on this project's SIMD
baseline), so an AoS source costs nothing extra to degather from.

Checked for alignment/padding surprises before changing anything:
vec3d and the new bvh_tri_indices are both plain float/uint32 structs
with no alignas and natural 4-byte alignment, so neither interleave
introduces padding.

Measured on the real 219-POF corpus, full-object sphereline queries,
9-trial median: ~4.7% faster (16,427 ns/call vs. 17,196 ns/call) and
~4.1% faster at the min (16,377 vs. 17,045 ns/call) -- modest, since
traversal still dominates total cost, but real and consistent in
direction on both statistics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The comment claimed the SoA layout let a ray-vs-N-children test run as
"a handful of SIMD compares" -- never actually true of the shipped
traversal, which tests each of the BVH_N slots with a plain scalar
loop. A genuine 4-wide vectorized rewrite of that box test was tried
(twice, after fixing a live-t_max pruning bug in the first attempt)
and measured slower on real content both times, so it was reverted,
not landed. Rewrote the comment to attribute the real, delivered
benefit correctly: one node fetch brings every child's box into cache
at once, turning "which children are worth pursuing" into a single
memory transaction instead of several scattered ones a binary tree
would need -- a locality win, independent of whether anything
vectorizes. Noted the failed experiment briefly so it isn't re-tried
blind, matching this file's existing practice on BVH_N itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
BVH_N was doing double duty: node branching factor (bvh_node's
BVH_N-wide child slots, a memory-locality decision -- see the recent
doc-comment fix confirming the box test doesn't vectorize across
children) and the leaf-intersection SIMD batch width (the one place
that genuinely vectorizes, in ray_triangle_leaf_simd()). They're
conceptually unrelated, matching the LEAF_THRESHOLD/BVH_N precedent
already established for the same reason. Introduced SIMD_WIDTH for
the latter; BVH_N now means only branching factor. Both are 4 today,
a tuning coincidence carried over from before the split, not a
coupling.

Updated every doc comment that conflated the two (including
ray_triangle_leaf_simd()'s own comment, which still claimed bvh_node's
box test "already uses" the same vectorized strategy -- false, per the
prior commit), LEAF_THRESHOLD's comment to reference all three
independent constants correctly, and the test suite's leaf-padding
assertions/test names (structural node-slot loops correctly stay
BVH_N; leaf-triangle-count-padding checks move to SIMD_WIDTH).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Measured on the real 219-POF corpus, full-object sphereline queries,
9 trials bracketing both values (run 4, then 8, then 4 again) to rule
out thermal/system drift rather than trusting a single A/B pair:
LEAF_THRESHOLD=8 consistently landed at ~16.7-16.9k ns/call across
both of its measurement rounds, with =4 sitting clearly higher
(~17.4-17.9k ns/call) in between them -- a genuine ~4-5% improvement,
not noise. Fewer, larger leaves means fewer total nodes and less
per-node traversal overhead, which outweighs the extra scalar
candidate-triangle work inside each bigger leaf.

Contrast with BVH_N=8, which measured as a clear loss (see that
constant's own doc comment) -- there's no general "bigger is better"
rule here, each knob needs its own real measurement, which is exactly
why LEAF_THRESHOLD was decoupled from BVH_N/SIMD_WIDTH in the first
place. Full unit suite (260 tests) unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same experiment as the 4->8 raise, one step further. Median showed
real thermal-drift noise across a long benchmarking session, so
min-of-trials (least distorted by that) is the trustworthy signal:
8's min stayed tightly clustered (~16.4-16.6k ns/call) across three
separate measurement sessions, while 16's min clustered consistently
lower (~15.9-16.2k ns/call) whenever not itself thermally
contaminated. Full unit suite (260 tests) unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The 4->8->16 pairwise progression showed real thermal-drift noise on
a long benchmarking session (documented in the prior two commits via
min-of-trials as the more trustworthy statistic). Settled it properly
instead: rebuilt and measured 2/4/8/16/32/64 in one continuous run,
15 trials each, real 219-POF full-object sphereline queries. Clean
U-shape with no drift within the sweep, minimum at 16 (median
ns/call: 2=17762, 4=16345, 8=16049, 16=15945, 32=16371, 64=17269) --
confirms 16 directly rather than by chaining pairwise comparisons,
and shows the win doesn't continue monotonically: past 16 the growing
per-leaf scalar cost starts outweighing the shallower-traversal
saving. Value unchanged (already 16); this replaces the doc comment's
rationale with the sweep, which is the trustworthy version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…query

The sphereline/ray scalar face tests recomputed cross(e1,e2)+normalize
(a real sqrt) for every triangle on every query, even though a
triangle's local-space geometry never changes between queries. Cache
it once in bvh_tree at build time instead.

Measured on the real 219-POF corpus with a dedicated per-call
benchmark (fixed through-model query set, median of 15 trials):
~16,517 -> ~15,442 ns/call, a ~6.5% speedup. Also adds
test_modelbvh_speed.cpp, a reusable dedicated benchmark reconstructing
the methodology used for the LEAF_THRESHOLD sweep (env-var gated, same
as test_modelbvh_profile.cpp -- skipped in normal test runs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ries

Five ideas from a deep rethink of the collision hot path, benchmarked
independently and in all 32 combinations against the real 219-POF
corpus:

- Front-to-back traversal (bvh_visit_triangles): sort each node's
  children by tmin and visit nearest-first instead of build order,
  discarding stale stack entries on pop. ~23% faster alone -- lets
  t_max tighten early, which is what makes every other prune below
  actually fire.
- Clear check_edges alongside check_face when a closer hit already
  exists (mc_check_triangle_sphereline_face) -- exact, not
  conservative, since every contact point on a triangle lies in its
  own plane. ~6% faster alone.
- Per-triangle bounding-sphere reject inside a leaf
  (mc_check_bvh_triangle_candidate), before the vertex-pool gather.
  ~8% faster alone.
- Defer fvi_sphere_plane's point projection until check_face is
  confirmed needed. ~3% faster alone.

Best combination: ~34% faster than baseline (~16,088 -> ~10,608
ns/call, real 219-POF corpus, 15-trial median). A fifth idea
(fan-triangulation diagonal edge-test dedup) measured a real win alone
but a net regression when combined with the deferred-projection
change, so it was dropped rather than shipped for a worse combined
result -- see COLLISION_BVH_NOTES.md for the full sweep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mc_check_triangle_face()/mc_check_triangle_sphereline_face() already
rejected degenerate and backfacing triangles using the precomputed
normal, but only after mc_check_bvh_triangle_candidate() had already
fetched all 3 vertices from the (non-contiguous, deduplicated) vertex
pool and made a function-pointer call to get there. The same check
only needs the normal, already a plain array read, so it's moved
earlier -- same condition, no new branch shape, just relocated.

Measured on the real 219-POF corpus: ~4.8% faster, tight trial
variance (9,937-9,947 ns/call across 5 runs). A companion idea
(extending front-to-back traversal to the top-level LBVH) measured a
real ~1.1% win alone but nothing on top of this change, so it was
dropped rather than shipped for zero combined benefit -- see
COLLISION_BVH_NOTES.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants