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 核心构建源码的面纱。

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

相关推荐
糕冷小美n7 小时前
elementuivue2表格不覆盖整个表格添加固定属性
前端·javascript·elementui
小哥不太逍遥7 小时前
Technical Report 2024
java·服务器·前端
沐墨染7 小时前
黑词分析与可疑对话挖掘组件的设计与实现
前端·elementui·数据挖掘·数据分析·vue·visual studio code
anOnion7 小时前
构建无障碍组件之Disclosure Pattern
前端·html·交互设计
threerocks7 小时前
前端将死,Agent 永生
前端·人工智能·ai编程
问道飞鱼8 小时前
【前端知识】Vite用法从入门到实战
前端·vite·项目构建
爱上妖精的尾巴8 小时前
8-10 WPS JSA 正则表达式:贪婪匹配
服务器·前端·javascript·正则表达式·wps·jsa
Aliex_git9 小时前
浏览器 API 兼容性解决方案
前端·笔记·学习
独泪了无痕9 小时前
useStorage:本地数据持久化利器
前端·vue.js
程序员林北北10 小时前
【前端进阶之旅】JavaScript 一些常用的简写技巧
开发语言·前端·javascript