UNPKG

three-mesh-bvh

Version:

A BVH implementation to speed up raycasting against three.js meshes.

709 lines (503 loc) 23.9 kB
/** @import { Object3D, BufferGeometry, Vector4 } from 'three' */ import { Matrix4, Mesh, Group } from 'three'; import { StorageBufferAttribute, StructTypeNode } from 'three/webgpu'; import { storage } from 'three/tsl'; import { MeshBVH } from '../core/MeshBVH.js'; import { SkinnedMeshBVH } from '../core/SkinnedMeshBVH.js'; import { GeometryBVH } from '../core/GeometryBVH.js'; import { BYTES_PER_NODE, UINT32_PER_NODE } from '../core/Constants.js'; import { proxy, proxyFn } from './nodes/NodeProxy.js'; import { bvhNodeStruct, transformStruct, } from './tsl/structs.js'; import { appendBVHData, appendBVHSubtree, appendIndexData, appendGeometryData, getSubtreeNodeCount, getMaxNodeDepth } from './utils/packBVHBufferUtils.js'; import { getShapecastFn } from './shapecastFns/getShapecastFn.js'; import { getRaycastFirstHitFn } from './shapecastFns/getRaycastFirstHitFn.js'; import { getSampleTrianglePointFn } from './shapecastFns/getSampleTrianglePointFn.js'; import { getClosestPointToPointFn } from './shapecastFns/getClosestPointToPointFn.js'; import { SAH } from '../core/Constants.js'; import { ClusteredBVH } from './ClusteredBVH.js'; import { BVH_STACK_DEPTH } from './tsl/constants.js'; // TODO: add ability to easily update a single matrix / scene rearrangement (partial update) // TODO: add material support w/ function to easily update material // - add a callback for writing a property for a geometry to a range // TODO: Add support for other geometry types (tris, lines, custom BVHs etc) // scratch const _matrix = /* @__PURE__ */ new Matrix4(); const _inverseMatrix = /* @__PURE__ */ new Matrix4(); const _range = { start: 0, count: 0, vertexStart: 0, vertexCount: 0 }; // functions function isObjectVisible( object ) { let curr = object; while ( curr ) { if ( curr.visible === false ) { return false; } curr = curr.parent; } return true; } function getTotalBVHByteLength( bvh ) { return bvh._roots.reduce( ( v, root ) => v + root.byteLength, 0 ); } function getTransformKey( compositeId, root ) { return `${ compositeId }_${ root }`; } /** * Packs one or more scene objects into GPU-accessible BVH buffers (TLAS + BLAS) for use * in WebGPU compute shaders via the Three.js TSL node system. After construction, call * {@link BVHComputeData#update} to populate the storage buffers, then reference * `this.storage` and `this.fns` in your compute shader nodes. * * @note This API is unstable and subject to change in future releases. * * @note This class requires three.js r185 or higher. */ export class BVHComputeData { /** * @param {Object3D|BufferGeometry|GeometryBVH|Array} objects * Scene objects to include. A single item or array of Object3D, BufferGeometry, or GeometryBVH instances are * all accepted and wrapped automatically in a BVH. * @param {Object} [options] * @param {Record<string,string>} [options.attributes={ position: 'vec4f' }] * WGSL type map for the interleaved per-vertex attribute buffer. Keys are geometry * attribute names; values are WGSL type strings (e.g. `'vec3f'`, `'vec4f'`). * @param {boolean} [options.autogenerateBvh=true] * When true, a {@link MeshBVH} is automatically built for any object that does not * already have `geometry.boundsTree` set. */ constructor( objects, options = {} ) { const { attributes = { position: 'vec4f' }, autogenerateBvh = true, } = options; // convert the arguments to a list of objects if ( ! Array.isArray( objects ) ) { objects = [ objects ]; } objects = objects.map( item => { if ( item.isObject3D ) { return item; } else if ( item.isBufferGeometry ) { return new Mesh( item ); } else if ( item instanceof GeometryBVH ) { const dummy = new Mesh(); dummy.geometry.boundsTree = item; return dummy; } } ); this._bvhCache = new Map(); this.autogenerateBvh = autogenerateBvh; this.attributes = attributes; this.objects = objects; this.bvh = null; // storage buffers and structs are populated in "update"; their members are accessed through // proxy nodes so the functions below can reference them up front and keep working across rebuilds this.storage = new NodeProxyObject(); this.structs = new NodeProxyObject( { transform: transformStruct } ); this.fns = new NodeProxyObject( { raycastFirstHit: getRaycastFirstHitFn( this ), closestPointToPoint: getClosestPointToPointFn( this ), sampleTrianglePoint: null, }, proxyFn ); } /** * Returns the representative root object for the scene to be constructed. * @returns {Object3D} */ getRootObject() { // convert the arguments to a list of objects let { objects } = this; if ( objects.isObject3D ) { return objects; } if ( ! Array.isArray( objects ) ) { objects = [ objects ]; } objects = objects.map( item => { if ( item.isObject3D ) { return item; } else if ( item.isBufferGeometry ) { return new Mesh( item ); } else if ( item instanceof GeometryBVH ) { const dummy = new Mesh(); dummy.geometry.boundsTree = item; return dummy; } } ); const result = new Group(); result.children = objects; return result; } /** * Builds a WGSL shapecast function that traverses the TLAS and per-cluster BLAS in a single * merged stack/loop for a custom shape type. The returned function signature is: * `fn name( shape: ShapeStruct[, result: ptr<function, ResultStruct>] ) -> bool` * * @param {Object} options * @param {string} [options.name] - Function name. Defaults to a random identifier. * @param {StructTypeNode} options.shapeStruct - TSL struct or definition describing the query shape. * @param {StructTypeNode|null} [options.resultStruct] - TSL struct for the accumulated result, or null. * @param {Function|null} [options.prefixFn] - function node that runs before the bvh traversal - useful for resetting or initializing necessary module variables. * @param {Function|null} [options.boundsOrderFn] - function node controlling left/right child traversal order. * @param {Function} options.intersectsBoundsFn - function node testing the shape against a BVH node's bounds. * @param {Function} options.intersectRangeFn - function node testing the shape against a leaf triangle range. * @param {Function|null} [options.transformShapeFn] - function node that transforms the shape into object local space. * @param {Function|null} [options.transformResultFn] - function node that transforms a hit result back to world space. * @param {Function|null} [options.resetShapeFn] - function node called after each BLAS traversal to reset any per-object state set by `transformShapeFn`. * @returns {Function} TSL function node for the traversal. */ getShapecastFn( options ) { return getShapecastFn( this, options ); } /** * Rebuilds all GPU storage buffers from the current scene state. Must be called at least * once before using `this.storage` or `this.fns` in a shader, and again whenever the * scene topology changes (objects added/removed, geometry modified). */ update() { // TODO // - check if the total object geometries have changed somehow. We should sort the objects to // a deterministic order and then check the BVH. Anything different in this case will trigger // a full refresh (detecting batched mesh / instance differences by geometry id + count). // - If the geometries are the same then we check whether they've changed (attribute versions, // skinned mesh bone tex versions, morph target versions). If a geometry _has_ changed then // the BVH needs to be refit (get BVH needs to continue to return consistent objects that are // of the same structure - how to confirm this? requires caching?), then it should be written // to the bvh nodes while refitting the TLAS. // - If only non-structural attributes have changed then we can just write those (eg normals) // TODO: we should include some kind of heuristic here for using a clustered or non-clustered // BVH. Something like number of leaf objects, etc? // "objects" may be a single item rather than an array const root = this.getRootObject(); let total = 0; root.traverse( c => { // TODO: this needs to be in-sync with how clustered bvh totals count if ( c.isMesh ) { total ++; } } ); this.bvh = new ClusteredBVH( root, { strategy: SAH, getBVH: ( object, instance ) => this.getBVH( object, instance, _range ), primitiveLimit: total < 3 ? Infinity : 64, } ); // free any buffers from a previous update before swapping in the new ones this.dispose(); const { attributes, structs, bvh } = this; // collect the BVHs const bvhInfo = []; // per referenced cluster subtree (deduped by bvh + root + node): { data, root, node, size, base }. // only these subtrees are copied into the node buffer - the upper nodes above the cluster cuts, // which no TLAS leaf enters, are never uploaded. const subtreeInfo = []; const subtreeMap = new Map(); // accumulate the sizes of the bvh nodes buffer, number of objects, and geometry buffers let bvhNodesBufferLength = getTotalBVHByteLength( bvh ); let indexBufferLength = 0; let attributesBufferLength = 0; // per primitive (in final tree order): the { transformSlot, subtree } for its TLAS leaf. The // leaf's node offset is resolved to the subtree's packed base once all subtrees are laid out. const primitiveInfo = []; // tracks the deepest packed cluster subtree for the traversal depth bound below let maxSubtreeDepth = 0; // the transform slots, derived from the same primitive buffer walk "updateTransforms" uses const transformMap = this._getTransformMap( bvh ); const { primitiveBuffer, primitiveBufferStride } = bvh; for ( let i = 0, l = primitiveBuffer.length; i < l; i += primitiveBufferStride ) { const compositeId = primitiveBuffer[ i ]; const compositeNodeId = primitiveBuffer[ i + 1 ]; const object = bvh.objects[ bvh.getObjectId( compositeId ) ]; const instanceId = bvh.getInstanceId( compositeId ); const range = { start: 0, count: 0, vertexStart: 0, vertexCount: 0 }; const primBvh = this.getBVH( object, instanceId, range ); if ( ! primBvh ) { throw new Error( 'BVHComputeData: BVH not found.' ); } // dedupe the geometry ( index + attributes ) once per bvh let data = bvhInfo.find( info => info.bvh === primBvh ); if ( ! data ) { data = { index: bvhInfo.length, bvh: primBvh, range: range, geometryOffset: 0, }; // the whole geometry is packed once per bvh; only referenced subtrees ( below ) contribute // to the node buffer indexBufferLength += data.range.count; attributesBufferLength += data.range.vertexCount; bvhInfo.push( data ); } const root = bvh.getBVHRootIndex( compositeNodeId ); const node = bvh.getBVHNodeIndex( compositeNodeId ) / UINT32_PER_NODE; // dedupe the referenced cluster subtree - the TLAS leaf only enters this subtree, so packing // just its contiguous node range skips the unreferenced upper nodes above the cut const subtreeKey = `${ data.index }_${ root }_${ node }`; let subtree = subtreeMap.get( subtreeKey ); if ( subtree === undefined ) { const size = getSubtreeNodeCount( primBvh._roots[ root ], node ); subtree = { data, root, node, size, base: 0 }; subtreeMap.set( subtreeKey, subtree ); subtreeInfo.push( subtree ); bvhNodesBufferLength += size * BYTES_PER_NODE; maxSubtreeDepth = Math.max( maxSubtreeDepth, getMaxNodeDepth( primBvh._roots[ root ], node ) ); } // nodeOffset is resolved to the subtree's packed base after the subtrees are laid out primitiveInfo.push( { transformSlot: transformMap.get( getTransformKey( compositeId, root ) ).slot, subtree, } ); } // Get the max depth of the tlas traversal and infer the needed depth for the compute stack. Subtract one since // the TLAS leaf pushes one node instead of two. const tlasDepth = getMaxNodeDepth( bvh._roots[ 0 ] ); const maxTraversalDepth = tlasDepth + maxSubtreeDepth - 1; if ( maxTraversalDepth > BVH_STACK_DEPTH.value ) { throw new Error( 'BVHComputeData: BVH depth overruns the compute stack depth.' ); } // // @note These buffer lengths are increased to a minimum size of 2 to avoid TSL converting storage buffers // with length 1 being converted to a scalar value. // TODO: remove this when fixed in three const transformBufferLength = Math.max( transformMap.size, 2 ); indexBufferLength = Math.max( indexBufferLength, 2 ); attributesBufferLength = Math.max( attributesBufferLength, 2 ); // construct the attribute struct const attributeStruct = new StructTypeNode( attributes, 'bvh_GeometryStruct' ); // write the geometry buffer attributes & bvh data let attributesOffset = 0; let indexOffset = 0; const indexBuffer = new Uint32Array( indexBufferLength ); const attributesBuffer = new ArrayBuffer( attributesBufferLength * attributeStruct.getLength() * 4 ); const bvhNodesBuffer = new ArrayBuffer( bvhNodesBufferLength ); // pack each unique geometry ( index + attributes ) once, recording its triangle base so the // referenced subtrees' leaves can be rebased into it bvhInfo.forEach( info => { info.geometryOffset = indexOffset / 3; // A non-indirect bvh built over a sub-range stores absolute leaf offsets, so shift the // base back by the range's triangle start. if ( ! info.bvh.indirect ) { info.geometryOffset -= info.range.start / 3; } appendIndexData( info.bvh, info.range, attributesOffset, indexOffset, indexBuffer ); appendGeometryData( info.bvh, info.range, attributesOffset, attributesBuffer, attributeStruct, this ); indexOffset += info.range.count; attributesOffset += info.range.vertexCount; } ); // pack only the referenced cluster subtrees into the node buffer, after the TLAS region. Each // subtree's write base becomes the node offset written into its TLAS leaves. let nodeWriteOffset = getTotalBVHByteLength( bvh ) / BYTES_PER_NODE; subtreeInfo.forEach( subtree => { subtree.base = nodeWriteOffset; appendBVHSubtree( subtree.data.bvh._roots[ subtree.root ], subtree.node, subtree.size, subtree.data.geometryOffset, nodeWriteOffset, bvhNodesBuffer ); nodeWriteOffset += subtree.size; } ); // resolve each TLAS leaf's node offset now that the subtree bases are known, then pack the TLAS primitiveInfo.forEach( info => info.nodeOffset = info.subtree.base ); appendBVHData( bvh, primitiveInfo, 0, bvhNodesBuffer ); // const transformArrayBuffer = new ArrayBuffer( structs.transform.getLength() * transformBufferLength * 4 ); // // set up the storage buffers // if itemSize for StorageBufferAttribute == arraySize, // then buffer is treated not as array of structs, but as a single struct // And that breaks code. For now itemSize = 1 does not seem to break anything const bvhNodesStorage = storage( new StorageBufferAttribute( new Uint32Array( bvhNodesBuffer ), 1 ), bvhNodeStruct ).toReadOnly().setName( 'bvh_nodes' ); const transformsBuffer = new StorageBufferAttribute( new Uint32Array( transformArrayBuffer ), 1 ); const transformsStorage = storage( transformsBuffer, structs.transform ).toReadOnly().setName( 'bvh_transforms' ); const indexStorage = storage( new StorageBufferAttribute( indexBuffer, 1 ), 'uint' ).toReadOnly().setName( 'bvh_index' ); const attributesStorage = storage( new StorageBufferAttribute( new Uint32Array( attributesBuffer ), attributeStruct.getLength() ), attributeStruct ).toReadOnly().setName( 'bvh_attributes' ); this.storage.transforms = transformsStorage; this.storage.nodes = bvhNodesStorage; this.storage.index = indexStorage; this.storage.attributes = attributesStorage; this.structs.attributes = attributeStruct; // writes every transform _inverseMatrix.copy( bvh.matrixWorld ).invert(); transformMap.forEach( info => { this.writeTransformData( info, _inverseMatrix, info.slot, transformArrayBuffer ); } ); // depends on the resolved attribute struct, so it must be built here rather than up front this.fns.sampleTrianglePoint = getSampleTrianglePointFn( this ); // clear our cache for now. In the future we will need to keep this around. this._bvhCache.clear(); } /** * Refits the clustered BVH and rewrites every entry in the transform buffer from the objects' * current world matrices. Call this when object transforms or visibility change but the scene * topology does not. The transform slots are derived from the clustered BVH's primitive buffer, * so they match those written by {@link BVHComputeData#update}. */ updateTransforms() { const { bvh, storage } = this; bvh.refit(); // the TLAS occupies the head of the node buffer - rewrite just those nodes' bounds. A null // "primitiveInfo" leaves the leaf encodings, and the cluster subtrees that follow them, in place. const nodesAttribute = storage.nodes.proxyNode.value; appendBVHData( bvh, null, 0, nodesAttribute.array.buffer ); nodesAttribute.needsUpdate = true; const transformsAttribute = storage.transforms.proxyNode.value; const transformArrayBuffer = transformsAttribute.array.buffer; _inverseMatrix.copy( bvh.matrixWorld ).invert(); this._getTransformMap( bvh ).forEach( info => { this.writeTransformData( info, _inverseMatrix, info.slot, transformArrayBuffer ); } ); transformsAttribute.needsUpdate = true; } /** * Writes the world/inverse-world matrices and visibility flag for one transform entry * into a raw ArrayBuffer. Override this in a subclass to inject additional per-object * data (e.g. material index). * * @private * @param {Object} info - Transform entry from the internal transform map. * @param {Matrix4} premultiplyMatrix - Matrix pre-multiplied onto the object's world matrix (usually the inverse TLAS root matrix). * @param {number} writeOffset - Index of the transform slot to write into. * @param {ArrayBuffer} targetBuffer - Destination buffer. */ writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer ) { const { structs } = this; const transformBufferF32 = new Float32Array( targetBuffer ); const transformBufferU32 = new Uint32Array( targetBuffer ); const { object, instanceId } = info; if ( object.isInstancedMesh || object.isBatchedMesh ) { object.getMatrixAt( instanceId, _matrix ); _matrix.premultiply( object.matrixWorld ); } else { _matrix.copy( object.matrixWorld ); } // write transform _matrix.premultiply( premultiplyMatrix ); _matrix.toArray( transformBufferF32, writeOffset * structs.transform.getLength() ); // write inverse transform _matrix.invert(); _matrix.toArray( transformBufferF32, writeOffset * structs.transform.getLength() + 16 ); let visible = isObjectVisible( object ); if ( object.isBatchedMesh ) { visible = visible && object.getVisibleAt( instanceId ); } transformBufferU32[ writeOffset * structs.transform.getLength() + 32 ] = visible ? 1 : 0; } /** * Returns the BVH for a given object/instance, populating `rangeTarget` with the * corresponding index and vertex ranges within the packed geometry buffers. Override * to support custom BVH types or caching strategies. * * @private * @param {Object3D} object - The object to generate a BVH for. * @param {number} instanceId - Instance index (relevant for InstancedMesh / BatchedMesh). * @param {{start:number,count:number,vertexStart:number,vertexCount:number}} rangeTarget - Populated with the object's geometry range. * @returns {MeshBVH|SkinnedMeshBVH|null} */ getBVH( object, instanceId, rangeTarget ) { const { autogenerateBvh, _bvhCache } = this; let bvh = null; if ( object.boundsTree || object.isSkinnedMesh ) { // this is a case where a mesh has morph targets and skinned meshes const geometry = object.geometry; rangeTarget.count = geometry.index ? geometry.index.count : geometry.attributes.position.count; rangeTarget.vertexCount = geometry.attributes.position.count; bvh = object.boundsTree || null; if ( bvh === null && autogenerateBvh ) { const id = object.uuid; bvh = _bvhCache.get( id ) || new SkinnedMeshBVH( object ); _bvhCache.set( id, bvh ); } } else if ( object.isBatchedMesh ) { const geometryId = object.getGeometryIdAt( instanceId ); const range = object.getGeometryRangeAt( geometryId ); Object.assign( rangeTarget, range ); bvh = object.boundsTrees[ geometryId ] || null; if ( bvh === null && autogenerateBvh ) { const id = `batched_${ object.geometry.uuid }_${ range.start }_${ range.count }`; bvh = _bvhCache.get( id ) || new MeshBVH( object.geometry, { range: { ...rangeTarget } } ); _bvhCache.set( id, bvh ); } } else { const geometry = object.geometry; rangeTarget.count = geometry.index ? geometry.index.count : geometry.attributes.position.count; rangeTarget.vertexCount = geometry.attributes.position.count; bvh = object.geometry.boundsTree || null; if ( bvh === null && autogenerateBvh ) { const id = geometry.uuid; bvh = _bvhCache.get( id ) || new MeshBVH( geometry ); _bvhCache.set( id, bvh ); } } return bvh; } /** * Returns the default vec4 value written to the attribute buffer for vertices that lack * a given attribute. Override to change per-attribute defaults. * * @private * @param {string} key - Attribute name (e.g. `'position'`, `'normal'`). * @param {Vector4} target - Receives the default value. * @returns {Vector4} */ getDefaultAttributeValue( key, target ) { switch ( key ) { case 'position': case 'color': target.set( 1, 1, 1, 1 ); break; default: target.set( 0, 0, 0, 0 ); } return target; } /** * Releases GPU resources held by this instance. */ dispose() { const { storage } = this; for ( const key in storage ) { storage[ key ].value?.dispose(); } } // Provides a consistent, deduplicated list of the transforms from the clustered BVH _getTransformMap( bvh ) { const { primitiveBuffer, primitiveBufferStride } = bvh; const transformMap = new Map(); for ( let i = 0, l = primitiveBuffer.length; i < l; i += primitiveBufferStride ) { const compositeId = primitiveBuffer[ i ]; const root = bvh.getBVHRootIndex( primitiveBuffer[ i + 1 ] ); const key = getTransformKey( compositeId, root ); // each bvh root gets its own transform so per-group data like materials can // be attached. The many cluster primitives of a root all share it, so matrices // are not duplicated. if ( transformMap.has( key ) ) { continue; } const slot = transformMap.size; const object = bvh.objects[ bvh.getObjectId( compositeId ) ]; const instanceId = bvh.getInstanceId( compositeId ); transformMap.set( key, { object, instanceId, compositeId, root, slot } ); } return transformMap; } } // A container whose string members are returned as stable proxy nodes. Assigning a member stores // the underlying node. // TODO: we should automatically infer a proxy node vs fn. Perhaps in r185 we won't need the difference? class NodeProxyObject { constructor( initialization = {}, createProxy = proxy ) { const proxies = {}; // the raw backing object holds the underlying nodes and is the proxy target. "createProxy" // selects the proxy variant - "proxy" for plain nodes, "proxyFn" for callable function nodes. return new Proxy( { ...initialization }, { get( target, property ) { if ( ! proxies[ property ] ) { proxies[ property ] = createProxy( property, target ); } return proxies[ property ]; }, set( target, property, value ) { target[ property ] = value; return true; }, } ); } }