three-mesh-bvh 源码阅读(1) 合批处理成静态几何体-StaticGeometryGenerator

1,227 阅读12分钟

开篇

背景

之前在实现用 three.js 来做 光线追踪的时候,碰到了计算加速结构慢的问题,图中是 10w 面的 Stanford Dragon 模型,在 13 代 i9-12900 笔电 CPU 上花费了 10s+ 的时间🤣,碰到更大的模型更是直接趴窝。

Pasted image 20240201011029.png

介绍

three-mesh-bvh.png

作为 Pathtracing 的基建工具, 没有快速高效的 BVH 构建显然是行不通的, 在网上搜了一圈发现了这个由 gkjohnson 写的 BVH(Bounding Volume Hierarchy 加速结构工具 three-mesh-bvh, 生成速度非常快,且支持 变形目标(MorphTarget)蒙皮 (SkinnedMesh) 的动态网格体构建,同样的一个模型瞬间就构建完了,除此之外这个库还提供了不少基于 BVH 的特性支持,功能丰富:

Examples.png

都是我感兴趣并且相当实用的东西,那么就盘它。这个系列就专门来尽量详尽地分析阅读这个库。

作为开篇,我们从geometry 预处理开始,一步步地解析这个库,这个过程中也会涉及到不少 three 源码,图形学相关的知识。

我在阅读的过程中会部分地改造,调试代码,如果想深入仔细地掌握这些内容,需要把仓库克隆下来,打上断点耐心地测试。

另外,本人也是初次写这种类型的文章,写作水平有限,请读者海涵。有错漏,模糊的部分请积极指出。

前置知识

为了能更好地理解接下来的内容,需要对 three 的动画系统,Mesh 等内容有一定了解。

BVH 是一种针对空间稀疏几何体的结构,初次接触的朋友需要和另一个 BVH (Biovision Hierarchy ) 做下区分,后者是一种动作数据标准。构建好场景 BVH 结构以后能实现许多事情,比如前面提到的作为 Path-Tracing 的加速结构,在此基础上能进一步实现 碰撞检测体素化csg 等特性。

既然是对场景的几何数据进行紧致空间分割,那么第一步是需要统一将场景几何体数据进行处理,包括

  • 属性兼容
  • 应用动画和形变数据
  • 材质数据收集

等,这一系列的处理在 /core/utils/StaticGeometryGenerator.js 这里。

说明

为了能更好地理解接下来的内容,需要对 three 的动画系统,geometry 属性等内容有一定了解。

BVH 是一种针对空间稀疏几何体的结构,初次接触的读者需要和另一个 BVH (Biovision Hierarchy ) 做下区分,后者是一种动作数据格式。构建好场景 BVH 结构以后能实现许多事情,比如前面提到的作为 Path-Tracing 的加速结构,在此基础上能进一步实现碰撞检测,场景体素化,csg 等特性,作者对此做了许多实例,还有在此基础上的其他扩展,详见作者主页 gkjohnson

这个系列关注 BVH 结构的快速构建,并尽可能地把相关的所有代码进行注释。

既然是对场景的几何数据进行紧致空间分割,那么第一步是需要统一将场景几何体数据进行处理,包括 属性兼容性,动画和形变的实时解析,材质数据收集 等。作者把这些逻辑写在了 StaticGeometryGenerator.js 这份代码里。

启动

把仓库 three-mesh-bvh 拉到本地,根据 README.md 跑起来项目:

Pasted image 20240205233816.png

其中 demo-name.htmlexamples 目录下的入口文件。

为了更好地展示形变和动画目标的处理,跑起来后选择 http://localhost:1234/skinnedMesh.html 这个示例来解析:

Pasted image 20240205234339.png

解析

regenerateMesh

这个示例通过控制不同参数来实时构建 BVH,我们关注 regenerateMesh 这个函数,这个函数被实时调用,实现了对动态场景的实时构建,看下是如何调用的:

// regenerate the mesh and bvh
function regenerateMesh() {
	// 将几何数据注册到一个新 Mesh,该 Mesh 拥有多种材质切换,BVH 计算使用
	// 通过 staticGeometryGenerator.generate(meshHelper.geometry) 将合并处理后的几何数据注册到 Mesh 上
    if (meshHelper) {

        let generateTime, refitTime, startTime;
        // time the geometry generation
        startTime = window.performance.now();
        staticGeometryGenerator.generate(meshHelper.geometry);
        generateTime = window.performance.now() - startTime;

        // time the bvh refitting
        startTime = window.performance.now();
        // 场景如果还没构建好 BVH 则计算 BVH
        if (!meshHelper.geometry.boundsTree) {
			
            meshHelper.geometry.computeBoundsTree();
            refitTime = '-';


        } else {

            // 对于这个动画场景如果构建完了 BVH 结构,则使用 refit 方法让节点重新
            // 适配到运动状态改变的几何数据上
            meshHelper.geometry.boundsTree.refit();
            refitTime = (window.performance.now() - startTime).toFixed(2);
            meshHelper.geometry.computeBoundsTree();

        }

        // 可视化更新节点包围盒的
        bvhHelper.update();

        timeSinceUpdate = 0;
        // TODO: 这部分是关于 BVH 结构的性能检测,后续章节补充
        const extremes = getBVHExtremes(meshHelper.geometry.boundsTree);

        if (initialExtremes === null) {

            initialExtremes = extremes;

        }
  
        let score = 0;
        let initialScore = 0;
        for (const i in extremes) {
  
            score += extremes[i].surfaceAreaScore;
            initialScore += initialExtremes[i].surfaceAreaScore;

        }

  
        const degradation = (score / initialScore) - 1.0;
        // update time display
        outputContainer.innerHTML =

            `mesh generation time: ${generateTime.toFixed(2)} ms\n` +
            `refit time: ${refitTime} ms\n` +
            `bvh degradation: ${(100 * degradation).toFixed(2)}%`;


    }

}

接着往下,在 staticGeometryGenerator.generate(meshHelper.geometry); 这里打上断点 =>

generate

// 结果输出到可读写入参 targetGeometry 上
generate(targetGeometry = new BufferGeometry()) {

        // track which attributes have been updated and which to skip to avoid unnecessary attribute copies
        // 对每个 mesh 对象进行记录,未变化的 mesh 跳过处理
        let skipAttributes = [];
        // 详见构造器里初始化,_intermediateGeometry 是与 meshes 等长的 BufferGeometry 数组
        const { meshes, useGroups, _intermediateGeometry, _diffMap } = this;
        for (let i = 0, l = meshes.length; i < l; i++) {

            const mesh = meshes[i];
            const geom = _intermediateGeometry[i];
            // weakMap 来记录每个 Mesh 的状态变更(通过 GeometryDiff)
            const diff = _diffMap.get(mesh);
			
            if (!diff || diff.didChange(mesh)) {、
                // 将 mesh 几何数据转换为静态数据,
                // 意味着每个顶点信息会随骨骼矩阵写入到 geometry attributes 的 array 里
                // 转换结果记录到可读可写入参 geom 上
                this._convertToStaticGeometry(mesh, geom);
                // 初次处理或改变的 Mesh 需要后续处理
                skipAttributes.push(false);

                if (!diff) {
                    // 初次处理通过该 Mesh 实例化一个 GeometryDiff
                    _diffMap.set(mesh, new GeometryDiff(mesh));

                } else {
                    // 更新的内容包括总共图元(三角形)的数量 mesh.geometry.version 和 
                    // skeleton 状态(skinnedMesh 靠skeleton 驱动变化)
                    diff.update();

                }

            } else {
                // 跳过后续处理
                skipAttributes.push(true);

            }

        }
        // 合并几何体数据,处理结果输出到 targetGeometry
        mergeBufferGeometries(_intermediateGeometry, { useGroups, skipAttributes }, targetGeometry);

  
        for (const key in targetGeometry.attributes) {
            // 数据更新到 GPU
            targetGeometry.attributes[key].needsUpdate = true;

        }
  
        return targetGeometry;

    }

画下流程图梳理下逻辑:

Sequence.png

这里面有关键的两步处理:

  • _convertToStaticGeometry
  • mergeBufferGeometries

先看第一个如何将网格体处理成静态数据,在 three 里,SkinnedMesh 是实时通过 mesh.applyBoneTransform() 对每个顶点数据进行

  • 骨骼世界坐标变换
  • 转换到骨骼坐标系
  • 缩放变换

来实现蒙皮动画的,每个顶点的变换是记录在一个 Vector3 类型变量里,并不会覆盖原有 geometry.attributes下的原始数据。其他 attributes 的运算和 position 属性是类似的,而将 Mesh 转换为静态网格体的思路就是把每个运算结果写入到新的 BufferGeometry ,从而将其转换成静态网格体。而 three 在每帧动画里,由 AnimationMixerAnimationClip 根据 delta 时间运算每根骨骼的变换矩阵,从而实现整个动画,这部分会自动更新到 mesh.skeleton 里,就不需要再处理了。知道这个机制后再阅读代码就容易了一些,当然我们还需要对 geometry.indexattributes 分别做处理。

_convertToStaticGeometry

处理成静态网格体:

    // 转换为静态 geometry, 结果输出到 targetGeometry
    _convertToStaticGeometry(mesh, targetGeometry = new BufferGeometry()) {

        const geometry = mesh.geometry;
        const applyWorldTransforms = this.applyWorldTransforms;
        const includeNormal = this.attributes.includes('normal');
        const includeTangent = this.attributes.includes('tangent');
        const attributes = geometry.attributes;
        const targetAttributes = targetGeometry.attributes;

        // initialize the attributes if they don't exist
        // geometry 是已经处理过有 index 的
        if (!targetGeometry.index) {

            targetGeometry.index = geometry.index;

        }

        if (!targetAttributes.position) {

            targetGeometry.setAttribute('position', createAttributeClone(attributes.position));

        }

        if (includeNormal && !targetAttributes.normal && attributes.normal) {

            targetGeometry.setAttribute('normal', createAttributeClone(attributes.normal));

        }

        if (includeTangent && !targetAttributes.tangent && attributes.tangent) {

            targetGeometry.setAttribute('tangent', createAttributeClone(attributes.tangent));

        }


        // ensure the attributes are consistent
        // 验证各个属性是否一致
        validateAttributes(geometry.index, targetGeometry.index);
        validateAttributes(attributes.position, targetAttributes.position);

        if (includeNormal) {

            validateAttributes(attributes.normal, targetAttributes.normal);

        }
  
        if (includeTangent) {

            validateAttributes(attributes.tangent, targetAttributes.tangent);

        }

        // generate transformed vertex attribute data
        const position = attributes.position;
        const normal = includeNormal ? attributes.normal : null;
        const tangent = includeTangent ? attributes.tangent : null;
        const morphPosition = geometry.morphAttributes.position;
        const morphNormal = geometry.morphAttributes.normal;
        const morphTangent = geometry.morphAttributes.tangent;
        const morphTargetsRelative = geometry.morphTargetsRelative;
        const morphInfluences = mesh.morphTargetInfluences;
        const normalMatrix = new Matrix3();
        normalMatrix.getNormalMatrix(mesh.matrixWorld);
        
        // 对于每一个 vertex
        for (let i = 0, l = attributes.position.count; i < l; i++) {

            _positionVector.fromBufferAttribute(position, i);

            if (normal) {

                _normalVector.fromBufferAttribute(normal, i);

            }

			
            if (tangent) {
                // 注:切空间坐标第四个元素表示与纹理坐标的对齐方向
                _tangentVector4.fromBufferAttribute(tangent, i);
                _tangentVector.fromBufferAttribute(tangent, i);

            }

            // apply morph target transform
            // 类似骨骼动画的处理方式。morphInfluences 是一个浮点数数组,表示
            // 每个形变目标的权重,而 morphTargetsRelative 是一个布尔值,表示
            // 是否相对于模型初始位置进行计算,当为 false 的时候,形变可以叠加
            if (morphInfluences) {

                if (morphPosition) {

                    applyMorphTarget(morphPosition, morphInfluences, morphTargetsRelative, i, _positionVector);

                }

                if (morphNormal) {

                    applyMorphTarget(morphNormal, morphInfluences, morphTargetsRelative, i, _normalVector);

  
                }

  
                if (morphTangent) {

                    applyMorphTarget(morphTangent, morphInfluences, morphTargetsRelative, i, _tangentVector);

                }

            }


            // apply bone transform
            // 结合上面对于 three 骨骼动画的介绍来看
            if (mesh.isSkinnedMesh) {
                mesh.applyBoneTransform(i, _positionVector);
                // 法线也需要按照类似的方法来做处理
                if (normal) {

                    boneNormalTransform(mesh, i, _normalVector);

                }
                // 切线也需要按照类似的方法来做处理
                if (tangent) {

                    boneNormalTransform(mesh, i, _tangentVector);

                }


            }

  

            // update the vectors of the attributes

            if (applyWorldTransforms) {

                _positionVector.applyMatrix4(mesh.matrixWorld);

            }

            // 写入 position
            targetAttributes.position.setXYZ(i, _positionVector.x, _positionVector.y, _positionVector.z);

            if (normal) {

                if (applyWorldTransforms) {
                
                    _normalVector.applyNormalMatrix(normalMatrix)

                }

  
                // 写入 normal
                targetAttributes.normal.setXYZ(i, _normalVector.x, _normalVector.y, _normalVector.z);

            }

            if (tangent) {
  
                if (applyWorldTransforms) {

                    _tangentVector.transformDirection(mesh.matrixWorld);

                }
			  
                // 写入 tangent
                targetAttributes.tangent.setXYZW(i, _tangentVector.x, _tangentVector.y, _tangentVector.z, _tangentVector4.w);

            }

        }

        // copy other attributes over
        for (const i in this.attributes) {

            const key = this.attributes[i];
            if (key === 'position' || key === 'tangent' || key === 'normal' || !(key in attributes)) {

                continue;

            }

  
            if (!targetAttributes[key]) {


                targetGeometry.setAttribute(key, createAttributeClone(attributes[key]));


            }


            validateAttributes(attributes[key], targetAttributes[key]);
            copyAttributeContents(attributes[key], targetAttributes[key]);


        }

  
        return targetGeometry;

  
    }

继续用流程图来帮助理解:

Sequence1.png

// Modified version of BufferGeometryUtils.mergeBufferGeometries that ignores morph targets and updates a attributes in place
// 根据输入的 geometries 和 options 进行合并,合并的结果返回到 targetGeometry
function mergeBufferGeometries(geometries, options = { useGroups: false, updateIndex: false, skipAttributes: [] }, targetGeometry = new BufferGeometry()) {
    // 是否是索引几何体,options 里有一个实验性属性 indirect, 
    // 该属性指定是否为 几何体生成一个 单独的 buffer 来维持一个单一 BVH 结构,
    // 可在 geometry 的 index 和 group 用作他用的时候使用,该属性为 false 的时候则 geometry 会被处理成 indexed
    const isIndexed = geometries[0].index !== null;
    const { useGroups = false, updateIndex = false, skipAttributes = [] } = options;
    // 记录几何体的 attributes,用来判断所有 geometries 拥有必要的 attributes 进行后续处理
    const attributesUsed = new Set(Object.keys(geometries[0].attributes));
    const attributes = {};
    let offset = 0;
    targetGeometry.clearGroups();

    // 对 geometries 逐一进行处理
    for (let i = 0; i < geometries.length; ++i) {

        const geometry = geometries[i];
        let attributesCount = 0;

        // ensure that all geometries are indexed, or none
        // 必须全部是 indexed 几何体或者全部是 non-indexed 几何体
        // 在后续的处理里有 ensureIndex 方法来把 non-indexed 的几何体处理成 indexed 的
        if (isIndexed !== (geometry.index !== null)) {
  
            throw new Error('StaticGeometryGenerator: All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.');
  
        }

        // gather attributes, exit early if they're different
        // 属性合法性检验
        for (const name in geometry.attributes) {
  
            if (!attributesUsed.has(name)) {

                throw new Error('StaticGeometryGenerator: All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.');

            }

            if (attributes[name] === undefined) {

                attributes[name] = [];

            }

            attributes[name].push(geometry.attributes[name]);
            attributesCount++;
        }


        // ensure geometries have the same number of attributes
        // 属性数量计数,双重保障属性的兼容
        if (attributesCount !== attributesUsed.size) {

            throw new Error('StaticGeometryGenerator: Make sure all geometries have the same number of attributes.');

        }

        // 如果 geometry 分组
        if (useGroups) {
            let count;
            if (isIndexed) {

                count = geometry.index.count;

            } else if (geometry.attributes.position !== undefined) {

                count = geometry.attributes.position.count;

            } else {

                throw new Error('StaticGeometryGenerator: The geometry must have either an index or a position attribute');

            }


            targetGeometry.addGroup(offset, count, i);
            offset += count;

        }

    }


    // merge indices
    // 如果有索引
    if (isIndexed) {

        let forceUpdateIndex = false;
        // 这种情况就按顶点顺序把几何体进行索引,没有顶点复用
        if (!targetGeometry.index) {
        
            let indexCount = 0;
            for (let i = 0; i < geometries.length; ++i) {

                indexCount += geometries[i].index.count;

            }

            targetGeometry.setIndex(new BufferAttribute(new Uint32Array(indexCount), 1, false));

            forceUpdateIndex = true;

        }

  
        if (updateIndex || forceUpdateIndex) {
        
            const targetIndex = targetGeometry.index;
            let targetOffset = 0;
            let indexOffset = 0;
            for (let i = 0; i < geometries.length; ++i) {

                const geometry = geometries[i];
                const index = geometry.index;
                if (skipAttributes[i] !== true) {

                    for (let j = 0; j < index.count; ++j) {
                        // 写入具体的 index 的索引值,targetOffset 代表当前 geometry 的偏移量
                        targetIndex.setX(targetOffset, index.getX(j) + indexOffset);

                        targetOffset++;

                    }

                }

                // 按每个合并的 geometry 计数增加偏移量
                indexOffset += geometry.attributes.position.count;

            }

        }

    }

  
    // merge attributes
	// 对属性进行合并
    for (const name in attributes) {
        // geometries 长度可能大于 1
        const attrList = attributes[name];

        if (!(name in targetGeometry.attributes)) {

            let count = 0;
            for (const key in attrList) {

                count += attrList[key].count;

            }

            // 先把每个属性根据 attrList 的 size 创建出 array (详见createAttributeClone)
            targetGeometry.setAttribute(name, createAttributeClone(attributes[name][0], count));

        }

        const targetAttribute = targetGeometry.attributes[name];

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

            const attr = attrList[i];

            if (skipAttributes[i] !== true) {
                // 再写入属性值
                copyAttributeContents(attr, targetAttribute, offset);

            }

            offset += attr.count;

        }

    }

    return targetGeometry;

    }

mergeBufferGeometries

接下来看下几何体合并:


// Modified version of BufferGeometryUtils.mergeBufferGeometries that ignores morph targets and updates a attributes in place
// 根据输入的 geometries 和 options 进行合并,合并的结果返回到 targetGeometry
function mergeBufferGeometries(geometries, options = { useGroups: false, updateIndex: false, skipAttributes: [] }, targetGeometry = new BufferGeometry()) {

	// 是否是索引几何体,options 里有一个实验性属性 indirect, 
	// 该属性指定是否为 几何体生成一个 单独的 buffer 来维持一个单一 BVH 结构,
	// 可在 geometry 的 index 和 group 用作他用的时候使用,该属性为 false 的时候则 geometry 会被处理成 indexed
    const isIndexed = geometries[0].index !== null;

    const { useGroups = false, updateIndex = false, skipAttributes = [] } = options;

	// 记录几何体的 attributes,用来判断所有 geometries 拥有必要的 attributes 进行后续处理
    const attributesUsed = new Set(Object.keys(geometries[0].attributes));
    const attributes = {};
    let offset = 0;

    targetGeometry.clearGroups();

    // 对 geometries 逐一进行处理
    for (let i = 0; i < geometries.length; ++i) {

        const geometry = geometries[i];
        let attributesCount = 0;

        // ensure that all geometries are indexed, or none
        // 必须全部是 indexed 几何体或者全部是 non-indexed 几何体
        // 在后续的处理里有 ensureIndex 方法来把 non-indexed 的几何体处理成 indexed 的
        if (isIndexed !== (geometry.index !== null)) {
  
            throw new Error('StaticGeometryGenerator: All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.');
  
        }

        // gather attributes, exit early if they're different
        // 属性合法性检验
        for (const name in geometry.attributes) {
  
            if (!attributesUsed.has(name)) {

                throw new Error('StaticGeometryGenerator: All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.');

  
            }

  
            if (attributes[name] === undefined) {

                attributes[name] = [];

            }

  
            attributes[name].push(geometry.attributes[name]);
            attributesCount++;
        }


        // ensure geometries have the same number of attributes
        // 属性数量计数,双重保障属性的兼容
        if (attributesCount !== attributesUsed.size) {

            throw new Error('StaticGeometryGenerator: Make sure all geometries have the same number of attributes.');


        }

        // 如果 geometry 分组
        if (useGroups) {

            let count;

            if (isIndexed) {

                count = geometry.index.count;
                
            } else if (geometry.attributes.position !== undefined) {
            
                count = geometry.attributes.position.count;
            } else {
            
                throw new Error('StaticGeometryGenerator: The geometry must have either an index or a position attribute');
                
            }
            targetGeometry.addGroup(offset, count, i);
            offset += count;
        }
    }


    // merge indices
    // 如果有索引
    if (isIndexed) {
    
        let forceUpdateIndex = false;
        
        // 这种情况就按顶点顺序把几何体进行索引,没有顶点复用
        if (!targetGeometry.index) {
            let indexCount = 0;
            for (let i = 0; i < geometries.length; ++i) {
            
                indexCount += geometries[i].index.count;
                
            }
            targetGeometry.setIndex(new BufferAttribute(new Uint32Array(indexCount), 1, false));
            forceUpdateIndex = true;
        }

  
        if (updateIndex || forceUpdateIndex) {
        
            const targetIndex = targetGeometry.index;
            let targetOffset = 0;
            let indexOffset = 0;
            for (let i = 0; i < geometries.length; ++i) {
            
                const geometry = geometries[i];
                const index = geometry.index;
                if (skipAttributes[i] !== true) {
                    for (let j = 0; j < index.count; ++j) {
                        // 写入具体的 index 的索引值,targetOffset 代表当前 geometry 的偏移量
                        targetIndex.setX(targetOffset, index.getX(j) + indexOffset);
                        targetOffset++;
                    }
                }
                // 按每个合并的 geometry 计数增加偏移量
                indexOffset += geometry.attributes.position.count;
            }
        }
    }
  
    // merge attributes
    // 对属性进行合并
    for (const name in attributes) {
        // geometries 长度可能大于 1
        const attrList = attributes[name];
        
        if (!(name in targetGeometry.attributes)) {
        
            let count = 0;
            for (const key in attrList) {
                count += attrList[key].count;

            }

            // 先把每个属性根据 attrList 的 size 创建出 array (详见createAttributeClone)
            targetGeometry.setAttribute(name, createAttributeClone(attributes[name][0], count));

        }
        const targetAttribute = targetGeometry.attributes[name];
        let offset = 0;
        for (let i = 0, l = attrList.length; i < l; i++) {
            const attr = attrList[i];
            if (skipAttributes[i] !== true) {
                // 再写入属性值
                copyAttributeContents(attr, targetAttribute, offset);

            }

            offset += attr.count;

        }

    }

    return targetGeometry;

}

继续祭出流程图:

Sequence2.png

结语

至此,targetGeometry 生成,可以看到这期间对 geometry 的属性做了大量遍历处理,通过 three 自带的 applyBoneTransform 处理了骨骼数据,用类似方法处理了 morphTarget ,并对动画对象的 normal, tangent 一并进行了处理,最后把所有处理好的几何体数据合并到同一个结果 geometry 里。下一次我将继续通过流程图和源码注释的方式揭开 BVH 节点树构建(buildTrees)的面纱:

three-mesh-bvh 源码阅读(2) 构建BVH节点树-buildTree

写作不易,点赞收藏是对作者最好的支持👍