Comparison with other libraries
The comparison suite in compare/ runs the same geometry and the same seeded rays, boxes, spheres and points through
every library it covers. Each answer is checked against ours. A library that disagrees with ours on a measurement
gets no ratio on it: a faster wrong answer is not faster. The full tables, the method and every caveat are in
compare/RESULTS.md, and the raw rows are in
bench/results/compare.csv.
Every number here is from one Apple M5 Pro (Node 22 and headless Chrome), and is indicative. The suite has not run
on the RTX 3060 yet; our own levers' RTX 3060 numbers are in Builds and tracing.
The GPU rows were taken with f32 nodes, the default before 2026-11-13 (quantized nodes are the default now). The CPU
box, closest-point and refit numbers are from the CPU query pass (2026-11-13), measured while other projects loaded the
machine (load average 8–10): ranges over two full runs before and two after.
The libraries
- three-mesh-bvh 0.9.18:
MeshBVH(CENTER and SAH),ObjectBVH, andthree-mesh-bvh/webgpu. - three 0.187:
Mesh.raycast, the no-BVH baseline. - bvh-tree 1.0.1: a plain-JS triangle BVH.
- Babylon.js 9.35: an engine's own picking, with and without the submesh octree.
- bvh.js 0.0.13: a scene-level box BVH (objects, not triangles).
- Rapier 0.21: a physics engine's ray, point and shape queries on a trimesh collider.
Headline results
Where other libraries win
Measured on the same harness. These are open items, not footnotes:
- Rapier ties our closest point on the torus knot (0.99–1.04×). Until the CPU query pass of 2026-11-13 it answered box queries 2.3–2.7× and closest point 1.7–2.4× faster than ours; ours now leads both everywhere else (Rapier 0.56–0.88× on boxes, 0.64–0.77× on closest point). Both queries became flat walks over the typed arrays with no objects per node or triangle, giving the same answers bit for bit (
compare/RESULTS.md, "compare/queries"). - three-mesh-bvh, CPU: refits Sponza (5.7M triangles) 1.3–1.5× faster. It was 6–7× faster until our refit stopped sorting and allocating per node, and 1.6–2× until the CPU query pass made it one pass. The knot and the room are now level (0.93–1.06×). We still write 48-byte triangle records and 3 million 64-byte nodes, where three-mesh-bvh writes 1.7 million 32-byte nodes over the app's positions. Its box queries (1.2–1.4× ours before) and closest point are 0.23–0.54× ours now. With SAH its sphere queries are a little faster than ours on the knot (1.05–1.13×) and Sponza (0.95–1.24×).
- three-mesh-bvh, GPU: its closest-hit walk is 1.1–1.2× ours on the furnished room. Its any-hit from outside the room was 3.2× our default kernel's: our any-hit enters the farther child first (right for shadow rays from surfaces), and a few rays per SIMD group of 32 went deep into the far side's furniture. With
anyHitOrder: 'larger'(enter the larger child first) ours is 1.73× theirs on those rays; it stays a setting because it costs 0.78× on merged surface rays on the M5 and 0.80–0.87× on the RTX 3060 (trace design), and merged trees are the default. - bvh.js builds its scene tree about 3× faster.
- Memory: our CPU trees (
CpuMeshBvh, what the suite measures) hold about 9–10× more bytes beyond the app's geometry than three-mesh-bvh, because our triangle records copy the corners. On the GPU the default scene now has quantized nodes: Sponza 355.5 MiB beyond the geometry against three-mesh-bvh's 51.2, about 7×. Indexed leaves take it to 225.5 (memory page). Building one huge mesh with tinybvh grows its WebAssembly memory to about 230 bytes per triangle while the build runs (Sponza as one mesh: 1.3 GiB); the builder now drops that memory afterwards and the worker pool ends such a worker, so the build leaves 391 MiB behind instead of 1.7 GiB (workers).
The compare pages
The suite also runs live in the examples site (examples/compare/): each page puts the same
geometry through both libraries, checks every answer against ours, shows the tables and the CSV rows, and has a "what
else you can do" box with one thing only our pipeline does. Smoke runs use small scenes and short timings; the numbers
for this page come from full runs on the RTX 3060 and the M5, which are still to be taken for the two newest pages.
Method: compare/skinned
Nine skinned characters (tubes on a chain of six bones, ?characters=, ?rings=). In each measured frame both
libraries see the same pose (the bones are set from the frame's time), in alternating order:
- three-mesh-bvh (its
skinnedMeshexample): oneSkinnedMeshBVHper character andrefit()each frame, which skins every vertex on the CPU (SkinnedMesh.getVertexPosition). CPU ms is the refits' wall time. Its trees stay on the CPU; to trace the pose on the GPU it re-packs them withBVHComputeData.update(), which skins every vertex again. That is a row of its own (the upload itself happens at the next compute and is not counted). - ours:
SceneTreesObject3DSyncwith the renderer poses eachSkinnedMeshon the GPU (BvhPosePass) andSceneTrees.setPoserefits its tree from that buffer, in the frame's encoder. CPU ms is the sync and the recording; GPU ms is the pose passes (three's compute timestamps) plus whatSceneTrees.updaterecords (itstimestampWrites), or withouttimestamp-querythe wall time to the queue being done (an upper bound).
Medians over the measured frames. Tree quality is reported beside the times: three-mesh-bvh's summed SAH score against
its fresh build (getBVHExtremes), and our refit and drift-rebuild counts. The check: on a frozen pose, a top-down grid
of rays through our GPU-refit trees agrees with three-mesh-bvh's refit trees (CPU raycast) on every ray away from a
silhouette.
Method: compare/pathtracer
One kernel (examples/three/pathtracer/kernel.ts: jittered camera rays, PCG random numbers, diffuse bounces, one sun
shadow ray per hit, accumulation) over two walks: ours through BvhTraceNodes (traceClosest, traceAny, the
library's defaults: merged static meshes, quantized nodes), and three-mesh-bvh's BVHComputeData (a MeshBVH with SAH
per geometry) through bvh_RaycastFirstHit and bvh_RaycastAnyHit in a wgslFn. Both read one palette colour per
mesh, so the shading is the same. Measured in paired rounds (the two back to back, the order reversed every other
round, the ratio taken within each round):
- path: every ray of the full tracer, closest and any hit;
- closest: the tracer with its shadow rays compiled out (the same closest-hit rays, since the shadow ray draws no random number);
- any: the shadow rays alone, derived as their count over the time the full tracer takes beyond the closest-only one (noisier than the other two).
Rays are counted in the kernel (one atomic per pixel); time is three's compute timestamps, else the wall time of one dispatch with the queue drained before and after. Each pixel's first hit is checked against ours; a pixel through a silhouette may graze either way between two different triangle tests, so up to 0.1% of pixels may differ.
three-mesh-bvh's examples, ported
These are three-mesh-bvh's own examples on our API (@pmndrs/bvh/three), not timed comparisons. Each page says where it
departs from the original, and its smoke run checks our answers (against three-mesh-bvh's where the example asks a
query both answer).