three-mesh-bvh 源码阅读(2) buildTree

前提

BVH 加速结构的构建通常采用两种方式,一种是简单的对半算术平均 分割构建,另一种则是基于 **SAH(表面积启发算法)的算法, 这两种方式的构建都可以直观地理解为基于不同策略的二叉树的构建,也是这个 lib 所使用的方法。而另一类基于 Morten Code 的线性构建法不在本系列的讨论范围内。

废话不多说,在 GPT 的帮助下快速写出伪代码来理解这一过程:

  • 二叉树构建
js 复制代码
function buildBVH(objects):
	// 满足子节点构建条件,通常是达到预设最大深度或剩余三角形图元数量小于等于设置叶子节点数量
    if objects.length < leafSize ||  other conditions reached:
        createLeafNode(objects[current index])
    else:
        // 选择分割轴
        axis = selectSplitAxis(objects)
        
        // 根据分割轴对物体进行排序
        sortObjects(objects, axis)
        
        // 执行分割操作
        // 使用直接对半分割或者元素算术平均策略(每个图元位置的平均)
        mid = getCenter(objects) || getAverage(objects)
        leftObjects = objects[0:mid]
        rightObjects = objects[mid:]
        
        // 递归构建左右子树
        leftChild = buildBVH(leftObjects)
        rightChild = buildBVH(rightObjects)
        
        // 创建父节点
        createInternalNode(leftChild, rightChild)
    
    return root
    }
  • SAH 构建
js 复制代码
function buildBVH(objects):
    if objects.length < leafSize ||  other conditions reached:
        createLeafNode(objects[current index])
    else:
        bestCost = infinity
        bestSplitPlane = null
        
        // 尝试不同的分割平面, BVH 节点是 aabb 包围盒,plane 是沿(x || y || z)的值
        for each plane in candidateSplitPlanes:
            // 执行分割操作
            leftObjects = objects on one side of the plane
            rightObjects = objects on the other side of the plane
            
            // 计算 SAH 成本
            cost = calculateSAHCost(leftObjects, rightObjects)
            // 全排列求解最小的值,表面积 和 光线碰撞效率直接相关
            if cost < bestCost:
                bestCost = cost
                bestSplitPlane = plane
        
        // 根据最佳分割平面进行分割
        leftObjects = objects on one side of the bestSplitPlane
        rightObjects = objects on the other side of the bestSplitPlane
        
        // 递归构建左右子树
        leftChild = buildBVH(leftObjects)
        rightChild = buildBVH(rightObjects)
        
        // 创建父节点
        createInternalNode(leftChild, rightChild)
    
    return root
}

以上就是 BVH 构建的基本思想,在上一节我们已经详细分析过如何实时地将网格体(mesh)转换成静态的单一几何体,本节就可以直接拿这个来进行计算了。为了实现高效地计算,需要在迭代的时候做预计算,尽可能保证不需要重复遍历和计算的步骤,另外,需要使用内存连续的二进制数据操作类型 TypedArray 来进行操作,关于 TypedArray 的概念和操作,请参阅 MDN阮一峰博客

接下来对预计算和计算过程采用的数据结构进行解析。

  • 预计算三角形包围盒 computeTriangleBounds

每轮迭代里需要获取 [offset, offset + count - 1] 范围内的三角形的包围盒,如果预先计算好这个数据,只需要读取相应索引位置的数据,避免重复计算,同时算出总的包围盒 fullBounds。在存储的时候使用 [x_center, x_delta, y_center, y_delta, z_center, z_delta] 的布局把重心也存了起来。

js 复制代码
// 预计算每个三角形包围盒
// precomputes the bounding box for each triangle; required for quickly calculating tree splits.
// result is an array of size tris.length * 6 where triangle i maps to a
// [x_center, x_delta, y_center, y_delta, z_center, z_delta] tuple starting at index i * 6,
// representing the center and half-extent in each dimension of triangle i

export function computeTriangleBounds(geo, fullBounds) {
    // clear the bounds to empty

    makeEmptyBounds(fullBounds);

    const posAttr = geo.attributes.position;
    const index = geo.index ? geo.index.array : null;
    const triCount = getTriCount(geo);
    const triangleBounds = new Float32Array(triCount * 6);
    const normalized = posAttr.normalized;

    // used for non-normalized positions
    const posArr = posAttr.array;

    // support for an interleaved position buffer
    const bufferOffset = posAttr.offset || 0;

    let stride = 3;

	// 所有属性写到一个 buffer 里,stride 不一定是 3
    if (posAttr.isInterleavedBufferAttribute) {

        stride = posAttr.data.stride;

    }

    // used for normalized positions

    const getters = ['getX', 'getY', 'getZ'];

    for (let tri = 0; tri < triCount; tri++) {

        const tri3 = tri * 3;
        const tri6 = tri * 6;

  
        let ai = tri3 + 0;
        let bi = tri3 + 1;
        let ci = tri3 + 2;


        if (index) {
			// 每个三角形的索引
            ai = index[ai];
            bi = index[bi];
            ci = index[ci];

        }

  
        // we add the stride and offset here since we access the array directly
        // below for the sake of performance
        if (!normalized) {
			// 和 oepngl 的内存 layout 一样的方式
            ai = ai * stride + bufferOffset;
            bi = bi * stride + bufferOffset;
            ci = ci * stride + bufferOffset;

        }
		// x, y, z 三个值
        for (let el = 0; el < 3; el++) {

            let a, b, c;
            if (normalized) {
				// 归一化属性取值
                a = posAttr[getters[el]](ai);
                b = posAttr[getters[el]](bi);
                c = posAttr[getters[el]](ci);

            } else {

                a = posArr[ai + el];
                b = posArr[bi + el];
                c = posArr[ci + el];

            }


            let min = a;

            if (b < min) min = b;
            if (c < min) min = c;

            let max = a;

            if (b > max) max = b;
            if (c > max) max = c;

  
            // Increase the bounds size by float32 epsilon to avoid precision errors when
            // converting to 32 bit float. Scale the epsilon by the size of the numbers being
            // worked with.
            const halfExtents = (max - min) / 2;
            const el2 = el * 2;
			// 0,2,4 || 1,3,5 , 看不懂的话看下上面关于数据布局的解析
            triangleBounds[tri6 + el2 + 0] = min + halfExtents;
            triangleBounds[tri6 + el2 + 1] = halfExtents + (Math.abs(min) + halfExtents) * FLOAT32_EPSILON;
  
            if (min < fullBounds[el]) fullBounds[el] = min;
            if (max > fullBounds[el + 3]) fullBounds[el + 3] = max;

        }

    }

    return triangleBounds;

}
  • BVH 节点的数据结构。按照递归算法构建出来的 BVH 加速结构是天然的一棵二叉树,由于 Shader 里无法运行递归函数,在构建完以后需要将其拍平。也可以将其改造成循环,我试过手动管理栈信息的改造,除非模型非常大不然体会不到优势,甚至更慢,如果要优化的话应该需要粒度更高的流程控制(并不会),有兴趣可以看下附录里的非递归构建的代码 [[#改造代码]]。回到构建结果,对于每个节点我们需要几个基本信息,节点的包围盒数据,节点包含的三角形范围,左子节点索引,右子节点索引。进一步分析,我们还可以将 splitAxis 包含进去,它的三个取值** 0,1,2**分别代表 x,y,z 轴,在 shader 里可以通过判断光线的轴分量是否大于 0 ,如果大于 0,意味着左子节点范围较大,有更大概率碰撞,那么就先迭代左子节点,反之亦然(可以想下 2D 包围盒的情况)。另外,是否是叶子节点可以通过判定 node.left || node.right 是否存在,也可以不写入。那么每个节点包含的信息总结如下:
    • boundingData
    • offset, count
    • left , right (如果有)
    • splitAxis

封装

核心方法的封装

在分析构建函数核心代码之前,我们先看一下代码的封装。在 /core 目录下有一个 MeshBVH.js 的文件把构建过程和其他功能一起聚合在一起,类本身虽然本身并没有显式声明继承自 Object3D,但封装风格高度相似,实现了 traverse 方法,该方法能方便地遍历构建出来的扁平 BVH 节点数据。最后在 /core/utils 下的 ExtensionUtilities.js 里通过 computeBoundsTree 方法暴露出来,使用的时候通过 THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; 的方式挂在原型链上使用,同时使用上也保持了和其他方法的高度兼容。
## 代码构建

indirect 配置项

在上一章提到了项目启动方法,但当翻看这个目录的时候会发现后缀含有 generated, indirect_generated字段的文件,这是由 roll-up 里使用 preprocess插件把模板文件通过条件编译自动生成的,至于为什么要这样做,和实验性功能 indirect buffer 有关系,上一节的代码注释里有提到,这里做下扩展。我记得在之前的版本里默认所有的 geometry都会处理成索引(indexed)几何体,构建的时候使用这个 index 来维持 BVH 结构,同时每轮迭代对 range 内的 index 进行排序,而新版本添加了 options.indirect 选项,可在几何体 index 属性不可更改等情况下通过创建一个单独的 indirect buffer 来使用,让我们看下生成 indirect buffer 的代码:

js 复制代码
function generateIndirectBuffer(geometry, useSharedArrayBuffer) {
    const triCount = (geometry.index ? geometry.index.count : geometry.attributes.position.count) / 3;
    const useUint32 = triCount > 2 ** 16;
    const byteCount = useUint32 ? 4 : 2;
    const buffer = useSharedArrayBuffer ? new SharedArrayBuffer(triCount * byteCount) : new ArrayBuffer(triCount * byteCount);

    const indirectBuffer = useUint32 ? new Uint32Array(buffer) : new Uint16Array(buffer);

    for (let i = 0, l = indirectBuffer.length; i < l; i++) {

        indirectBuffer[i] = i;

    }

    return indirectBuffer;
}

可以清楚地看到 indirectBuffer 元素长度等于三角形数量,与 index 记录顶点不同,它只记录了每个三角形的索引,因此对范围内三角形 Array Buffer 排序时候有所不同,因此需要两个版本的处理函数,这也是为什么需要从 template 代码编译两份代码分别对应使用 indirect buffer 和不使用这个特性的情况。

排序函数(partition)

明白了 indirect 配置项后,这里把位于 /core/build/sortUtils.template.js 里的排序函数进行分析。排序函数的作用是在找到分割轴后需要对 triangleBounds 范围内三角形数据进行排序,这是构建过程中非常重要的一个步骤,关系到了整体的计算效率。由于使用 geometry.index 或者 indirect buffer 进行 BVH 结构的构建,TypedArray 的排序需要自行实现,作者这里对这两个不同的 buffer 均实现了快排,然后用上一小节提到的方法分别生成不同的结果代码。具体看代码:

js 复制代码
// reorders `tris` such that for `count` elements after `offset`, elements on the left side of the split
// will be on the left and elements on the right side of the split will be on the right. returns the index
// of the first element on the right side, or offset + count if there are no elements on the right side.
export function partition/* @echo INDIRECT_STRING */( indirectBuffer, index, triangleBounds, offset, count, split ) {

	let left = offset;
	let right = offset + count - 1;
	const pos = split.pos;
	// 0 , 2 , 4 对应 triangleBounds 里 x_center, y_center, z_center 的值
	const axisOffset = split.axis * 2;

	// hoare partitioning, see e.g. https://en.wikipedia.org/wiki/Quicksort#Hoare_partition_scheme
	// 快速排序使用左右两个指针分别从最左侧和最右侧的元素开始向中间移动,实时进行元素排序swap,当左指针指向的值等于右指针的时候完成排序
	while ( true ) {
		// 分割轴和 triangleBounds 中心进行比较
		while ( left <= right && triangleBounds[ left * 6 + axisOffset ] < pos ) {

			left ++;

		}

		// if a triangle center lies on the partition plane it is considered to be on the right side
		while ( left <= right && triangleBounds[ right * 6 + axisOffset ] >= pos ) {

			right --;

		}

		if ( left < right ) {
			// we need to swap all of the information associated with the triangles at index
			// left and right; that's the verts in the geometry index, the bounds,
			// and perhaps the SAH planes
			/* @if INDIRECT */
			// indirectBuffer 存储三角形索引
			let t = indirectBuffer[ left ];
			indirectBuffer[ left ] = indirectBuffer[ right ];
			indirectBuffer[ right ] = t;

			/* @else */
			// index 存储顶点索引,每三个构成三角形
			for ( let i = 0; i < 3; i ++ ) {

				let t0 = index[ left * 3 + i ];
				index[ left * 3 + i ] = index[ right * 3 + i ];
				index[ right * 3 + i ] = t0;

			}

			/* @endif */
			// swap bounds
			for ( let i = 0; i < 6; i ++ ) {

				let tb = triangleBounds[ left * 6 + i ];
				triangleBounds[ left * 6 + i ] = triangleBounds[ right * 6 + i ];
				triangleBounds[ right * 6 + i ] = tb;

			}

			left ++;
			right --;

		} else {

			return left;

		}
	}
}

最后进入构建过程。

构建

先从 buildTree 函数的构建过程来看:

js 复制代码
function buildTree(bvh, options) {
    // Compute the full bounds of the geometry at the same time as triangle bounds because
    // we'll need it for the root bounds in the case with no groups and it should be fast here.
    // We can't use the geometry bounding box if it's available because it may be out of date.

    const geometry = bvh.geometry;
    const indexArray = geometry.index ? geometry.index.array : null;
    const maxDepth = options.maxDepth;
    const verbose = options.verbose;
    const maxLeafTris = options.maxLeafTris;
    const strategy = options.strategy;
    const onProgress = options.onProgress;
    const totalTriangles = getTriCount(geometry);
    const indirectBuffer = bvh._indirectBuffer;
    let reachedMaxDepth = false;
    const fullBounds = new Float32Array(6);

    // 临时变量,每轮迭代直接使用上次的结果(父节点包围盒数据)
    const cacheCentroidBoundingData = new Float32Array(6);

    // 预计算所有三角形包围盒
    const triangleBounds = computeTriangleBounds(geometry, fullBounds);

    // 根据 indirect 选择不同的排序函数
    const partionFunc = options.indirect ? partition_indirect : partition;

    const roots = [];
    const ranges = options.indirect ? getFullGeometryRange(geometry) : getRootIndexRanges(geometry);

    if (ranges.length === 1) {

        const range = ranges[0];
        const root = new MeshBVHNode();
        root.boundingData = fullBounds;
        getCentroidBounds(triangleBounds, range.offset, range.count, cacheCentroidBoundingData);
        splitNode(root, range.offset, range.count, cacheCentroidBoundingData);
        roots.push(root);

    } else {

        // 有多个 group 的 geometry 就有多个根节点
        for (let range of ranges) {
            const root = new MeshBVHNode();
            root.boundingData = new Float32Array(6);
            // 多个 group 有多个根节点,需要计算根节点完整包围盒
            getBounds(triangleBounds, range.offset, range.count, root.boundingData, cacheCentroidBoundingData);

            splitNode(root, range.offset, range.count, cacheCentroidBoundingData);
            roots.push(root);
        }

    }

    return roots;
    
   ...
	
}

过程实现得优雅直观,和开篇提到的原理基本一致,继续拆开看 splitNode 函数:

js 复制代码
	// recording the offset and count of its triangles and writing them into the reordered geometry index.
	function splitNode(node, offset, count, centroidBoundingData = null, depth = 0) {
		// 到达最大深度停止递归
		if (!reachedMaxDepth && depth >= maxDepth) {
			reachedMaxDepth = true;
			if (verbose) {

				console.warn(`MeshBVH: Max depth of ${maxDepth} reached when generating BVH. Consider increasing maxDepth.`);
				console.warn(geometry);

			}

		}
		// early out if we've met our capacity
		// 终止条件,生成叶子节点
		if (count <= maxLeafTris || depth >= maxDepth) {
			// 计算进度
			triggerProgress(offset + count);
			node.offset = offset;
			node.count = count;
			return node;
		}
		
		// Find where to split the volume
		// 按照策略计算分割平面
		const split = getOptimalSplit(node.boundingData, centroidBoundingData, triangleBounds, offset, count, strategy);
		// 没有找到分割轴,创建叶子节点
		if (split.axis === - 1) {
			triggerProgress(offset + count);
			node.offset = offset;
			node.count = count;
			return node;

		}

		// 找到分割平面后需要对 triangleBounds 范围内三角形数据进行排序
		const splitOffset = partionFunc(indirectBuffer, indexArray, triangleBounds, offset, count, split);

		// create the two new child nodes
		if (splitOffset === offset || splitOffset === offset + count) {

			triggerProgress(offset + count);
			node.offset = offset;
			node.count = count;

		} else {

			node.splitAxis = split.axis;

			// create the left child and compute its bounding box
			const left = new MeshBVHNode();
			const lstart = offset;
			const lcount = splitOffset - offset;
			node.left = left;
			left.boundingData = new Float32Array(6);

			getBounds(triangleBounds, lstart, lcount, left.boundingData, cacheCentroidBoundingData);
			// 左子节点
			splitNode(left, lstart, lcount, cacheCentroidBoundingData, depth + 1);

			// repeat for right
			const right = new MeshBVHNode();
			const rstart = splitOffset;
			const rcount = count - lcount;
			node.right = right;
			right.boundingData = new Float32Array(6);

			getBounds(triangleBounds, rstart, rcount, right.boundingData, cacheCentroidBoundingData);
			// 右子节点
			splitNode(right, rstart, rcount, cacheCentroidBoundingData, depth + 1);
		}

		return node;

	}

最后,把 getOptimalSplit 方法搞明白,整个构建过程就清楚了。

使用 CenterAverage 策略的分割方法都很好理解,重点在于 SAH 策略的分割。SAH 的分割使用一种名为 binning 的方法来细分,它的做法是:

在 binning 策略中,首先选择一个轴(通常是X、Y或Z轴),然后将物体按照该轴上的位置进行排序。接下来,将物体分成多个连续的区间,每个区间称为一个 "bin"。每个 bin 中包含一定数量的物体。 然后,对于每个 bin,计算两个子节点的表面积和。在 SAH 中,目标是选择一个分割平面,使得两个子节点的表面积和最小。因此,在 binning 策略中,对于每个 bin,可以分别计算从该 bin 到左子节点和右子节点的表面积和。然后,通过遍历不同的分割位置(即不同的 bin),选择使得表面积和最小的分割位置作为最佳分割平面。 binning 策略的关键在于如何选择 bin 的数量和范围。选择合适的 bin 数量和范围可以平衡分割的质量和计算的效率。通常,可以基于场景中物体的数量和分布来确定 bin 的数量,并确保每个 bin 包含适当数量的物体。 -- 来自 GPT 的解释

实际的处理为了加速分为两种情况,一种是待分割三角形数量很小的时候(8个),根据三角形数量直接动态缩小 bins 的数量,然后就是常规的分 32bins 来计算。为了兼顾遍历速度和表面积得分,计算时把 TRAVERSAL_COST(遍历损失)TRIANGLE_INTERSECT_COST(相交损失) 做了叠加,看代码:

js 复制代码
// 分 32 个 bin
const BIN_COUNT = 32;
const binsSort = (a, b) => a.candidate - b.candidate;
const sahBins = new Array(BIN_COUNT).fill().map(() => {

	return {

		count: 0,
		bounds: new Float32Array(6),
		rightCacheBounds: new Float32Array(6),
		leftCacheBounds: new Float32Array(6),
		candidate: 0,

	};

});
const leftBounds = new Float32Array(6);

export function getOptimalSplit(nodeBoundingData, centroidBoundingData, triangleBounds, offset, count, strategy) {

	let axis = - 1;
	let pos = 0;

	// Center
	if (strategy === CENTER) {

		axis = getLongestEdgeIndex(centroidBoundingData);
		if (axis !== - 1) {

			pos = (centroidBoundingData[axis] + centroidBoundingData[axis + 3]) / 2;

		}

	} else if (strategy === AVERAGE) {

		axis = getLongestEdgeIndex(nodeBoundingData);
		if (axis !== - 1) {

			pos = getAverage(triangleBounds, offset, count, axis);

		}

	} else if (strategy === SAH) {
		// 计算根节点的表面积
		const rootSurfaceArea = computeSurfaceArea(nodeBoundingData);
		// TRIANGLE_INTERSECT_COST(1.25) 和 TRAVERSAL_COST(1) 是两个常量,
		// 代表了三角形相交测试和节点遍历的时间,作者 TODO 提到这两参数需要继续优化

		// 最佳的相交代价
		let bestCost = TRIANGLE_INTERSECT_COST * count;

		// iterate over all axes
		const cStart = offset * 6;
		const cEnd = (offset + count) * 6;
		// 计算三个轴
		for (let a = 0; a < 3; a++) {

			const axisLeft = centroidBoundingData[a];
			const axisRight = centroidBoundingData[a + 3];
			const axisLength = axisRight - axisLeft;
			// 每小格当前轴向的长度
			const binWidth = axisLength / BIN_COUNT;

			// If we have fewer triangles than we're planning to split then just check all
			// the triangle positions because it will be faster.
			// 当这轮三角形数量小于要分割的数量(8个),在这之前
			// 虽然 leafSize 可以设置大于这个值但是有可能提前到达了预设最大深度所以还没退出
			if (count < BIN_COUNT / 4) {

				// initialize the bin candidates
				const truncatedBins = [...sahBins];
				// 改变可迭代范围 
				truncatedBins.length = count;

				// set the candidates
				let b = 0;
				for (let c = cStart; c < cEnd; c += 6, b++) {

					const bin = truncatedBins[b];
					// x_center || y_center || z_center
					bin.candidate = triangleBounds[c + 2 * a];
					bin.count = 0;

					const {
						bounds,
						leftCacheBounds,
						rightCacheBounds,
					} = bin;
					for (let d = 0; d < 3; d++) {

						rightCacheBounds[d] = Infinity;
						rightCacheBounds[d + 3] = - Infinity;

						leftCacheBounds[d] = Infinity;
						leftCacheBounds[d + 3] = - Infinity;

						bounds[d] = Infinity;
						bounds[d + 3] = - Infinity;

					}

					expandByTriangleBounds(c, triangleBounds, bounds);

				}
				// 从小到大排
				truncatedBins.sort(binsSort);

				// remove redundant splits
				let splitCount = count;
				for (let bi = 0; bi < splitCount; bi++) {

					const bin = truncatedBins[bi];
					// 很小的三角形中心趋近于相等的情况,之前在 computeTriangleBounds 对数值做了 halfExtent 的精度处理  
					while (bi + 1 < splitCount && truncatedBins[bi + 1].candidate === bin.candidate) {

						truncatedBins.splice(bi + 1, 1);
						splitCount--;

					}

				}

				// find the appropriate bin for each triangle and expand the bounds.
				// 把范围内的三角形分配到处理过的 bin 里
				for (let c = cStart; c < cEnd; c += 6) {

					const center = triangleBounds[c + 2 * a];
					for (let bi = 0; bi < splitCount; bi++) {

						const bin = truncatedBins[bi];
						if (center >= bin.candidate) {

							expandByTriangleBounds(c, triangleBounds, bin.rightCacheBounds);

						} else {

							expandByTriangleBounds(c, triangleBounds, bin.leftCacheBounds);
							bin.count++;

						}

					}

				}

				// expand all the bounds
				for (let bi = 0; bi < splitCount; bi++) {

					const bin = truncatedBins[bi];
					const leftCount = bin.count;
					const rightCount = count - bin.count;

					// check the cost of this split
					const leftBounds = bin.leftCacheBounds;
					const rightBounds = bin.rightCacheBounds;

					let leftProb = 0;
					if (leftCount !== 0) {

						leftProb = computeSurfaceArea(leftBounds) / rootSurfaceArea;

					}

					let rightProb = 0;
					if (rightCount !== 0) {

						rightProb = computeSurfaceArea(rightBounds) / rootSurfaceArea;

					}

					const cost = TRAVERSAL_COST + TRIANGLE_INTERSECT_COST * (
						leftProb * leftCount + rightProb * rightCount
					);

					// 找到最佳的 bin
					if (cost < bestCost) {

						axis = a;
						bestCost = cost;
						pos = bin.candidate;

					}

				}

			} else {

				// reset the bins
				for (let i = 0; i < BIN_COUNT; i++) {

					const bin = sahBins[i];
					bin.count = 0;
					// 当前节点的 min + 均分长度 * i
					bin.candidate = axisLeft + binWidth + i * binWidth;

					const bounds = bin.bounds;
					for (let d = 0; d < 3; d++) {

						bounds[d] = Infinity;
						bounds[d + 3] = - Infinity;

					}

				}

				// iterate over all center positions
				for (let c = cStart; c < cEnd; c += 6) {

					const triCenter = triangleBounds[c + 2 * a];
					const relativeCenter = triCenter - axisLeft;

					// in the partition function if the centroid lies on the split plane then it is
					// considered to be on the right side of the split
					// 转换成整数并向下取整
					let binIndex = ~ ~(relativeCenter / binWidth);
					if (binIndex >= BIN_COUNT) binIndex = BIN_COUNT - 1;

					// 根据范围内 (某个三角形的中心 - 当前节点 min)/ binWidth 适配到某个 bin
					const bin = sahBins[binIndex];
					bin.count++;
					// binIndex 越大 bounds 范围越小,相当于从右到左
					expandByTriangleBounds(c, triangleBounds, bin.bounds);

				}

				// cache the unioned bounds from right to left so we don't have to regenerate them each time
				// 这样每个 bin 的右节点 bound 就算出来了
				const lastBin = sahBins[BIN_COUNT - 1];
				copyBounds(lastBin.bounds, lastBin.rightCacheBounds);
				for (let i = BIN_COUNT - 2; i >= 0; i--) {

					const bin = sahBins[i];
					const nextBin = sahBins[i + 1];
					unionBounds(bin.bounds, nextBin.rightCacheBounds, bin.rightCacheBounds);

				}

				// 左子节点元素数量
				let leftCount = 0;
				// 每个 bin 内的计算
				for (let i = 0; i < BIN_COUNT - 1; i++) {

					const bin = sahBins[i];
					const binCount = bin.count;
					const bounds = bin.bounds;

					const nextBin = sahBins[i + 1];
					const rightBounds = nextBin.rightCacheBounds;

					// don't do anything with the bounds if the new bounds have no triangles
					// 与上面 unionBounds 方法类似,只不过是从左到右来算出左子节点的 Bound
					if (binCount !== 0) {

						if (leftCount === 0) {

							copyBounds(bounds, leftBounds);

						} else {

							unionBounds(bounds, leftBounds, leftBounds);

						}

					}

					leftCount += binCount;

					// check the cost of this split
					// 接下来算最小 cost 就好理解了
					let leftProb = 0;
					let rightProb = 0;

					if (leftCount !== 0) {

						leftProb = computeSurfaceArea(leftBounds) / rootSurfaceArea;

					}

					const rightCount = count - leftCount;
					if (rightCount !== 0) {

						rightProb = computeSurfaceArea(rightBounds) / rootSurfaceArea;

					}

					const cost = TRAVERSAL_COST + TRIANGLE_INTERSECT_COST * (
						leftProb * leftCount + rightProb * rightCount
					);
					// 更新 bestCost
					if (cost < bestCost) {

						axis = a;
						bestCost = cost;
						// 位置是三角形包围盒的重心
						pos = bin.candidate;

					}

				}

			}

		}

	} else {

		console.warn(`MeshBVH: Invalid build strategy value ${strategy} used.`);
	}
	return { axis, pos };
}

示意图展示了 UnionBounds 方法的过程,将每个 bin 与下一个 bin 结合算出这个 bin 的右子节点的 Bound, 左子节点的计算也是类似:

最后我们看下计算结果的处理。buildPackedTree 函数调用 buildTree 算出 BVH 二叉树然后由 populateBuffer 来处理成线性节点数组。

js 复制代码
export function buildPackedTree(bvh, options) {

	const geometry = bvh.geometry;
	if (options.indirect) {

		bvh._indirectBuffer = generateIndirectBuffer(geometry, options.useSharedArrayBuffer);

		if (hasGroupGaps(geometry) && !options.verbose) {

			console.warn(
				'MeshBVH: Provided geometry contains groups that do not fully span the vertex contents while using the "indirect" option. ' +
				'BVH may incorrectly report intersections on unrendered portions of the geometry.'
			);

		}

	}

	if (!bvh._indirectBuffer) {
		// 不适用 indirectBuffer 要预先把 geometry 处理成索引类型 geometry
		ensureIndex(geometry, options);

	}

	// boundingData  				: 6 float32
	// right / offset 				: 1 uint32
	// splitAxis / isLeaf + count 	: 1 uint32 / 2 uint16
	const roots = buildTree(bvh, options);

	let float32Array;
	let uint32Array;
	let uint16Array;
	const packedRoots = [];
	// SharedArrayBuffer 可用于 webworker 
	const BufferConstructor = options.useSharedArrayBuffer ? SharedArrayBuffer : ArrayBuffer;
	for (let i = 0; i < roots.length; i++) {

		const root = roots[i];
		let nodeCount = countNodes(root);

		/**
		 * 节点的存储结构:使用32个字节
		 * 				|byte1|byte2|byte3|byte4|byte5|byte6|byte7|byte8
		 * 非叶子节点:[ x_center, x_delta, y_center, y_delta, z_center, z_delta, offset, splitAxis] 
		 * 叶子节点:[ x_center, x_delta, y_center, y_delta, z_center, z_delta, offset, count && flag ] 
		 **/
		const buffer = new BufferConstructor(BYTES_PER_NODE * nodeCount);

		// 偏移量,前 6 字节
		float32Array = new Float32Array(buffer);
		// 偏移值,第 7 字节
		uint32Array = new Uint32Array(buffer);
		// 容量, 第 8 字节前半段, 叶子节点标志位,第 7 字节后半段
		uint16Array = new Uint16Array(buffer);
		populateBuffer(0, root);
		packedRoots.push(buffer);

	}

	bvh._roots = packedRoots;

	return;

	...
}

populateBuffer 的源码,所有的数据处理到了一个 buffer 里,存储的位置看上面的注释:

js 复制代码
	// 树形结构转换成无深度数组
	function populateBuffer(byteOffset, node) {
		// 视图是 32 位类型的偏移(float32Array || uint32Array)
		const stride4Offset = byteOffset / 4;
		// 视图是 32 位类型的偏移
		const stride2Offset = byteOffset / 2;
		// 没有子节点
		const isLeaf = ! !node.count;
		const boundingData = node.boundingData;
		for (let i = 0; i < 6; i++) {

			float32Array[stride4Offset + i] = boundingData[i];

		}

		if (isLeaf) {

			const offset = node.offset;
			const count = node.count;
			uint16Array[stride2Offset + 14] = count;
			uint16Array[stride2Offset + 15] = IS_LEAFNODE_FLAG;
			return byteOffset + BYTES_PER_NODE;

		} else {

			const left = node.left;
			const right = node.right;
			const splitAxis = node.splitAxis;

			let nextUnusedPointer;
			nextUnusedPointer = populateBuffer(byteOffset + BYTES_PER_NODE, left);

			if ((nextUnusedPointer / 4) > Math.pow(2, 32)) {

				throw new Error('MeshBVH: Cannot store child pointer greater than 32 bits.');

			}

			uint32Array[stride4Offset + 6] = nextUnusedPointer / 4;
			nextUnusedPointer = populateBuffer(nextUnusedPointer, right);

			uint32Array[stride4Offset + 7] = splitAxis;
			return nextUnusedPointer;

		}

	}

结语

核心构建逻辑解析完成,说实话还是挺复杂的,可见一种技术要在工程上实现可用是要做很多设计优化,过程可能复杂几百倍。下篇对结果可视化类 MeshBVHVisualizer 进行分析。

附录

版本

three-mesh-bvh: 1.0.0

上一篇链接:

three-mesh-bvh 源码阅读(1) StaticGeometryGenerator

下一篇链接(暂无)

改造代码

js 复制代码
export function buildFlatTree(bvh, options) {

	const treeRootNodes = [];
	const geometry = bvh.geometry;
	ensureIndex(geometry);
	const ranges = getRootIndexRanges(geometry);
	const fullBounds = new Float32Array(6);
	const triangleBounds = computeTriangleBounds(geometry, fullBounds);

	const totalTriangles = getTriCount(geometry);
	let cacheCentroidBoundingData = new Float32Array(6);

	if (ranges.length === 1) {

		const range = ranges[0];
		let treeNodes = buildIteratively(geometry, range, options);

		treeRootNodes.push(treeNodes);

	} else {
		for (let range of ranges) {

			let treeNodes = buildIteratively(geometry, range, options);

			treeRootNodes.push(treeNodes);
		}
	}

	return treeRootNodes;

	function triggerProgress(trianglesProcessed, onProgress) {

		if (onProgress) {

			onProgress(trianglesProcessed / totalTriangles);

		}

	}

	function buildIteratively(geometry, range, options) {

		function _push(info) {
			// offset,count
			_stacks.push(info);
		}

		// use first in first out so that nodeId will be consecutive 
		function _shift() {
			return _stacks.shift();
		}

		const strategy = CENTER;
		const indexArray = geometry.index ? geometry.index.array : null;
		const maxLeafTris = options.maxLeafTris;
		const maxDepth = options.maxDepth;
		const onProgress = options.onProgress;
		let { offset, count } = range;

		let tree = [];
		let _stacks = [];
		// -1 stands for root
		let nodeId = -1;
		// 0 is left, 1 is right
		let flag = -1;
		// Tree depth
		let depth = 0;

		_push([
			offset,
			count,
			nodeId,
			flag,
			depth
		]);

		while (_stacks.length > 0) {

			let stackInfo = _shift();
			++nodeId;

			let offset = stackInfo[0];
			let count = stackInfo[1];
			let parentId = stackInfo[2];
			let leftFlag = stackInfo[3];
			let depth = stackInfo[4];

			let parentNode = tree[parentId];

			if (leftFlag === 0) {

				parentNode.left = nodeId;

			} else if (leftFlag === 1) {

				parentNode.right = nodeId;
			}

			let node = new MeshBVHNode(nodeId);
			tree.push(node);

			node.offset = offset;
			node.count = count;
			node.depth = depth;

			node.boundingData = new Float32Array(6);
			getBounds(triangleBounds, offset, count, node.boundingData, cacheCentroidBoundingData);

			// early out if we've met our capacity
			if (count <= maxLeafTris || depth >= maxDepth) {
				triggerProgress(offset + count, onProgress);
				node.isLeaf = 1;
				continue;

			}

			// Find where to split the volume
			const split = getOptimalSplit(node.boundingData, cacheCentroidBoundingData, triangleBounds, offset, count, strategy);
			if (split.axis === - 1) {
				triggerProgress(offset + count, onProgress);
				continue;
			}

			// Sort after split is found
			const splitOffset = partition(indexArray, triangleBounds, offset, count, split);

			if (splitOffset === offset || splitOffset === offset + count) {

				triggerProgress(offset + count, onProgress);
				node.isLeaf = 1;
				continue;

			} else {

				++depth;

				node.splitAxis = split.axis;
				// create the left child and compute its bounding box
				const lstart = offset;
				const lcount = splitOffset - offset;
				flag = 0;
				_push([
					lstart,
					lcount,
					nodeId,
					flag,
					depth
				]);

				// repeat for right
				const rstart = splitOffset;
				const rcount = count - lcount;
				flag = 1;
				_push([
					rstart,
					rcount,
					nodeId,
					flag,
					depth
				]);

			}
		}

		return tree;

	}
}
相关推荐
Morpheon5 小时前
Cursor 1.0 版本 GitHub MCP 全面指南:从安装到工作流增强
ide·github·cursor·mcp
LinXunFeng8 小时前
Flutter - GetX Helper 助你规范应用 tag
flutter·github·visual studio code
草梅友仁9 小时前
AI 图片文字翻译与视频字幕翻译工具推荐 | 2025 年第 23 周草梅周报
开源·github·aigc
qianmoQ13 小时前
GitHub 趋势日报 (2025年06月04日)
github
abcnull14 小时前
github中main与master,master无法合并到main
git·github
星哥说事15 小时前
使用VuePress2.X构建个人知识博客,并且用个人域名部署到GitHub Pages中
开源·github
勤劳打代码16 小时前
步步为营 —— Github Connection refused 分层诊断
github
寻月隐君17 小时前
深入解析 Rust 的面向对象编程:特性、实现与设计模式
后端·rust·github
qianmoQ1 天前
GitHub 趋势日报 (2025年05月31日)
github
油泼辣子多加1 天前
2025年06月06日Github流行趋势
github