前言
之前在实现用 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.html 是 examples 目录下的入口文件。
为了更好地展示形变和动画目标的处理,跑起来后选择 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 核心构建源码的面纱。
写作不易,点赞收藏是对作者最好的支持👍