Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
and the current BSP leaf data format:
BSP traversal is branchy, and doesn't cull when possible
And the BSP bbox traversal code, I've stripped out a bit for brevity:
So there's a few issues here:
node->frontandnode->backend up indexing deep into the array, vs hopefully one of them being nicely cache local.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: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_vertsfield. 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 noticedturret02inSC_Asura.pofhas 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:
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).
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
N.B.
MC_COLLIDE_ALLis handled properly in the code, these snippets are modified not to include it to focus on runtime collision detection.Visitorhere 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 mainmodel_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_tminalso contains an early out and doesn't count a bbox as a "hit" ift_maxis 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_simduse Möller-Trumbore over aSIMD_WIDTH-wide batch. This is unconditional/branchless so it autovectorizes; I checked the disassembly and got real packedxmm/ymmwith widths of 4 and 8 respectively.The sphereline (radius-swept) leaf test in
mc_check_triangle_sphereline_faceis 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_treestruct above already shows the leaf-side representation. There's a few points to note here:childandcount, ifcount> 0, then the triangles are taken fromtrisin the[child, child+count)range.triscorresponds to a densely packed vector of vertex indices, three per tri.vertsarray, 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 theseSubmodel walk: full linear search vs. top-level LBVH
aibig.cppself-documents this as ~10% of AI frametimeShields: just another subobject
mc_check_sldc()that uses hard-coded offset arithmetic, this has an order-dependent bug (front/back||short-circuit skips the back subtree)bvh_treeas if they were just another submodel and go through the exact same code (model_collide_bvh_triangle())