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

前言

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

作为 Path-tracing 的基建工具,没有快速高效的 BVH 构建显然是行不通的,在网上搜了一圈发现了这个 BVH(Bounding Volume Hierarchy) 加速结构工具 three-mesh-bvh,建构于 three.js 之上,生成速度非常快,且支持 变形目标(MorphTarget,蒙皮 (SkinnedMesh) 的动态 mesh 构建,同样的一个模型 30ms 就构建完了,那接下来就仔细地拜读下它的源码。

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

说明

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

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

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

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

启动

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

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

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

解析

regenerateMesh

这个例子包含各种帮助器,调整 BVH 结构的可视化深度,还能展示随动画实时构建,几何体的处理起始于 regenerateMesh 函数,看下源码注释:

js 复制代码
// 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)}%`;


    }

}

generate

接着往下,在 meshHelper.geometry.computeBoundsTree() 这句打上断点我们进入内部看下执行顺序:

javascript 复制代码
// 结果输出到可读写入参 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;

    }

简单画下流程图梳理下逻辑:

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

  • _convertToStaticGeometry
  • mergeBufferGeometries

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

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

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

_convertToStaticGeometry

js 复制代码
    // 转换为静态 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;

  
    }

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

js 复制代码
// 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

接下来看下几何体合并:

js 复制代码
// 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;

}

继续祭出流程图:

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

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

相关推荐
2401_882727571 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder2 小时前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂2 小时前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand2 小时前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL2 小时前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿2 小时前
react防止页面崩溃
前端·react.js·前端框架
z千鑫2 小时前
【前端】详解前端三大主流框架:React、Vue与Angular的比较与选择
前端·vue.js·react.js
m0_748256143 小时前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小白学前端6664 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react
web130933203984 小时前
前端下载后端文件流,文件可以下载,但是打不开,显示“文件已损坏”的问题分析与解决方案
前端