Comparison with other libraries

The same geometry and the same seeded queries through @pmndrs/bvh, three-mesh-bvh, three, bvh-tree, Babylon.js, bvh.js and Rapier.

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.

Note

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, and three-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

WhatResult (ours = 1; above 1 means faster than ours)
CPU closest-hit rays, Sponza (5.74M triangles)ours 580k rays/s; three-mesh-bvh 0.75 (SAH) and 0.57 (CENTER), Rapier 0.95, bvh-tree 0.12
Tree quality, Sponza (SAH cost, lower is better)ours 90; three-mesh-bvh 122 (SAH) and 141 (CENTER); bvh-tree 205
GPU rays, against three-mesh-bvh/webgpu (paired rounds)ours 0.96–1.12× theirs closest-hit and 1.25–1.28× any-hit on Sponza and the knot; 0.85–0.91× closest on the furnished room. Any-hit from outside the room: 0.32× with the default order, 1.73× with anyHitOrder: 'larger'
Scene-level trees, 10k objectsours is ahead of three-mesh-bvh's ObjectBVH on every query

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.

PageWhat it comparesWhat else (live)
compare/raycastCPU first and all hits per second, every library; a GPU batch against three-mesh-bvh/webgputhe batch is a raw WebGPU pass three never sees
compare/buildbuild, refit, memory; main-thread stalls of builds on the main thread and in workersa GPU H-PLOC build of the same mesh
compare/tree-qualitySAH cost, depth, boxes, leaves, bytesthree builders, one layout
compare/queriesbox, sphere and closest point (three-mesh-bvh, Rapier)the GPU builder's tree answers the same CPU queries
compare/scenescene trees over thousands of moving objects (bvh.js, ObjectBVH)the GPU top-level refit every frame
compare/gpuGPU closest and any hit against three-mesh-bvh/webgpu, paired roundsa GPU-built tree traced by the same kernel
compare/sdfSDF generation on the GPU against three-mesh-bvh's webgpu_sdfGenerationthe scene's GPU-built tree straight into a three texture
compare/skinnedskinned meshes kept on their pose every frame: three-mesh-bvh's SkinnedMeshBVH.refit() on the CPU against our GPU pose pass and refit, CPU and GPU ms per framea plain WebGPU pass, never seen by three, traces the refit trees every frame
compare/pathtracerthe same small path tracer on three-mesh-bvh/webgpu's WGSL functions and on our walk: Mrays/s for the whole tracer, its closest-hit rays and its any-hit raysthe scene's GPU-built trees traced by the same kernel

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 skinnedMesh example): one SkinnedMeshBVH per character and refit() 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 with BVHComputeData.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: SceneTreesObject3DSync with the renderer poses each SkinnedMesh on the GPU (BvhPosePass) and SceneTrees.setPose refits 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 what SceneTrees.update records (its timestampWrites), or without timestamp-query the 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).

PageShowsDeparture from the original
three/shapecastintersectsSphere, intersectsBox, intersectsGeometry against a spinning knotnone
three/distancecastclosestPointToGeometry (tree against tree) and a marching-cubes volume of closestPointToPointthree's seeded SimplexNoise
three/characterMovementa capsule walked through a level baked by StaticGeometryGenerator, pushed out of what shapecast hands backa level of three's primitives instead of a downloaded dungeon
three/physicsspheres pushed out of an environment's triangles (shapecast, nearest boxes first)an environment of three's primitives instead of a downloaded jungle
three/sculptbrushes that find vertices with shapecast (contained boxes whole), then a refitour refit() is whole-tree; three-mesh-bvh refits only the visited nodes
three/selectionlasso and box selection narrowed by each box's screen hullthe knot stays indexed