Coming from three-mesh-bvh

The API mapping from three-mesh-bvh 0.9, what is missing here, what won't be, and when to stay with three-mesh-bvh.

This compares @pmndrs/bvh with three-mesh-bvh 0.9.18. Of the 52 features audited, 45 have an equivalent here, 2 have part of one and 5 won't be done. The full audit, with the code behind each row, is docs/internals/PARITY.md.

CPU: MeshBVH and friends

Most code switches by changing the import:

three-mesh-bvh@pmndrs/bvh/three
import { MeshBVH, acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'The same names from @pmndrs/bvh/three, after await prepareBoundsTrees() once (it loads tinybvh)
new MeshBVH(geometry, options)new MeshBVH(geometry, options) (a BvhBoundsTree), or geometry.computeBoundsTree(options)
strategy, maxLeafSize, maxDepth, indirect, ...Accepted; see Build options for what each does here
raycast, raycastFirst, shapecast, intersectsBox, intersectsSphere, intersectsGeometry, bvhcastThe same arguments and answers
closestPointToPoint, closestPointToGeometryThe same; both faceIndexes are original triangles
refit()refit(): rereads the position attribute; the index must not have changed
traverse(callback) (the node walk)traverseNodes(callback). Our traverse is the shapecast box walk
resolveTriangleIndex(i)The same. The geometry is never reordered, as with indirect: true
MeshBVH.serialize(bvh), MeshBVH.deserialize(data, geometry, { setIndex })The same, in three-mesh-bvh's format both ways; deserialize also reads our bytes (boundsTree.serialize()) (Save and load)
ExtendedTriangle, OrientedBox, SeparatingAxisBounds, CONTAINED, INTERSECTED, NOT_INTERSECTEDThe same, from /three. OrientedBox is its own class, not a Box3 subclass
getTriangleHitPointInfo, estimateMemoryInBytes, getBVHExtremes, validateBounds, getJSONStructureThe same functions
MeshBVHHelper, BVHHelperThe same classes (no deprecation warning on MeshBVHHelper)
GenerateMeshBVHWorker().generate(geometry, options)new GenerateMeshBvhWorker().generate(geometry, options): no cross-origin isolation needed
ObjectBVH.raycast(raycaster), frustum cullingBvhSceneRaycaster, raycastScene(picker, raycaster), picker.intersectsFrustum(frustum, layers)
ObjectBVH.shapecast({ intersectsBounds, intersectsObject, ... })picker.shapecast(...): the same callbacks over the objects' world boxes (Shapecast over the objects)
BatchedMesh.prototype.computeBoundsTree = computeBatchedBoundsTreeThe same: a tree per geometry in batchedMesh.boundsTrees (BatchedMesh)
StaticGeometryGeneratorThe same class: skinned and morphed meshes baked on the CPU (On the CPU)
new SkinnedMeshBVH(mesh, options), bvh.refit()The same class: a tree over the mesh's posed triangles, refitted to the pose (A tree that follows the pose)
PointsBVH, LineSegmentsBVH, LineLoopBVH, LineBVH, computeBoundsTree({ type })The same classes and option; acceleratedRaycast on Points and the line objects answers as three's raycast, draw range read at each raycast (Points and lines)

Without three, the same queries are on the core's CpuMeshBvh with plain points (CPU queries).

GPU: three-mesh-bvh/webgpu

three-mesh-bvh 0.9 (three-mesh-bvh/webgpu)@pmndrs/bvh/three
new BVHComputeData(root, { attributes })SceneTrees.create(deviceFromRenderer(renderer)), new SceneTreesObject3DSync(trees, root, options), new BvhTraceNodes(renderer, trees)
bvh.update() (repack everything)sync.sync(): only the meshes and instances that changed
bvh.updateTransforms()sync.update() each frame, then trees.update(encoder), then bvh.update()
await bvh.updateAsync()await bvh.updateAsync({ sync }), or updateSceneTreesAsync(trees, { sync, nodes }) (Waiting for every tree)
The clustered top level (ClusteredBVH, primitiveLimit 64; always on)clusterTraceScene(scene, { primitiveLimit }) on an assembled scene, opt-in; not in SceneTrees or the sync (Clustered top level)
bvh.fns.raycastFirstHit(ray, &hit) -> boolWGSL bvhTraceClosest(ray) -> BvhHit (hit.t < 0 on a miss); TSL bvh.traceClosest(origin, direction, tMin, tMax)
bvh.fns.raycastAnyHit(ray) -> boolWGSL bvhTraceAny(ray) -> bool; TSL bvh.traceAny(...)
(none)Ray masks: bvhTraceClosestMasked, bvhTraceAnyMasked; rayMaskFromLayers(camera.layers)
wgslTagFn with ${bvh.fns.raycastFirstHit}three's wgslFn(code, [bvh.module]), calling the walk by name
bvh.getShapecastFn({ shapeStruct, intersectsBoundsFn, intersectRangeFn, ... })bvh.getShapecastFn({ shape, intersectsBounds, intersectsTriangle, keep }), hooks as TSL Fns or wgslFns (Shapecasts with TSL hooks)
rayStruct (origin, direction, maxDist; 0 is unbounded)BvhRay(origin, tMin, direction, tMax), an explicit interval
hit.dist, hit.didHithit.t, and hit.t >= 0.0
hit.normal and hit.sidehit.normal (world, geometric, authored front side) and hit.frontFace
hit.barycoord (weights of a, b, c)hit.barycentrics: weights of corners 1 and 2; corner 0 is 1 - u - v
hit.indiceshit.triangle, the triangle's index in its mesh
hit.objectIndexhit.instance; sync.sourceOf(hit.instance) gives { object, instanceId, group }
Per-vertex attribute buffersNone: use hit.triangle and hit.barycentrics against your own attributes, or hit.material
writeTransformData override (material in the pad words)material(object, info) in the sync's options, which becomes hit.material

Both libraries in one TypeScript program

Both packages add the same members to three's types, typed with their own classes: BufferGeometry.boundsTree, computeBoundsTree and disposeBoundsTree, and BatchedMesh.boundsTrees, computeBoundsTree and disposeBoundsTree. TypeScript can't merge two different types for one member, so it keeps the declaration it reads first and the other doesn't apply. A program that imports both (while migrating, or to compare them) can then refuse BatchedMesh.prototype.computeBoundsTree = computeBatchedBoundsTree, or type batchedMesh.boundsTrees as three-mesh-bvh's trees.

Our declarations can't be made to match three-mesh-bvh's: its types name its own classes (GeometryBVH), whose traverse and refit take other arguments than ours (our traverse is the shapecast box walk; our refit takes the new positions). The way around it:

  • Keep three-mesh-bvh's types out of the program that uses ours. Importing anything from three-mesh-bvh, types included, brings its declarations in. If you need both, put the code that uses three-mesh-bvh in its own tsconfig project, as this repository does for its comparison suite.
  • Or cast where the two meet: assign through BatchedMesh.prototype as unknown as { computeBoundsTree: typeof computeBatchedBoundsTree }, and read batchedMesh.boundsTrees as (BvhBoundsTree | null)[] | null and geometry.boundsTree as BvhBoundsTree | undefined.
  • Either way, set skipLibCheck: the second declaration reports TS2717 in the other package's .d.ts.

At run time nothing clashes: whichever functions you assign to the prototypes are the ones that run.

Partly there

  • maxDepth: accepted, not honoured.
  • setBoundingBox, verbose, onProgress: onProgress reports 0 and 1 only: tinybvh builds in one WebAssembly call and has no progress callback (its task hooks run only in threaded builds; ours is single-threaded). verbose is ignored.

There, with differences:

  • serialize: three-mesh-bvh's format is read and written. Reading keeps the tree's shape and recomputes exact boxes; a tree built over a draw range or groups that leave triangles out can't be read.
  • getShapecastFn: the triangle test gets one world-space triangle at a time, not a leaf's index range, and there is no result pointer: the shape goes in by value, and results come back through a wgslFn's private state.
  • StaticGeometryGenerator: it also bakes a mesh again when only a morph weight changed (three-mesh-bvh keeps the old pose then).
  • Points and lines: CPU only (SceneTrees and the GPU walk take triangles), as three-mesh-bvh's are. Where three-mesh-bvh departs from three's raycast (no bounding-sphere test, the draw range and groups fixed at build time, the index reordered outside indirect mode), we follow three (Points and lines).

Missing

Nothing on the CPU. On the GPU nothing is missing either. SDF generation exists (Signed distance fields, generateSdfTexture in /three), and so do closest point, sphere and box tests, shapecasts (with WGSL or TSL hooks) and the WGSL helpers intersectRayTriangle and closestPointToTriangle (Spatial queries on the GPU); the queries run over the whole scene in world space rather than over one mesh. ClusteredBVH's cut is clusterTraceScene (Clustered top level), for scenes assembled without SceneTrees.

Won't be done

  • GLSL and WebGL2 (MeshBVHUniformStruct, BVHShaderGLSL, the VertexAttributeTexture classes): WebGL2 isn't a target.
  • ParallelMeshBVHWorker: it needs SharedArrayBuffer and cross-origin isolation. A large mesh goes to the GPU H-PLOC builder instead.
  • useSharedArrayBuffer: our trees are copied to the GPU, and the worker pool needs no shared memory.
  • sampleTrianglePoint and ndcToCameraRay: the package ends at the hit. A hit gives the triangle and its barycentrics; interpolating attributes and making camera rays belong to the renderer above it.

What you get that three-mesh-bvh doesn't have

  • GPU builds (H-PLOC and LBVH) and GPU refits (top level and per mesh), with nothing read back per frame.
  • The same trees for three, for raw WebGPU passes, for workers and for other libraries on the device.
  • The ray oracle on every builder and walk, and a watertight triangle test.
  • Instance masks, a triangle filter and per-instance materials in the walk.

When to stay with three-mesh-bvh

  • You need WebGL2 GPU traversal, or GLSL.
  • Memory matters more than ray speed. Our GPU scenes use about 7 times three-mesh-bvh's bytes beyond the geometry on Sponza (356 MiB against 51 MiB with defaults on both sides; 6.5 times on Bistro exterior). Indexed leaves, off by default, bring Sponza to 226 MiB. Our CPU trees (computeBoundsTree) keep 64-byte nodes: 8-9 times. See Memory.
  • You rely on years of other people's use: this package is new.