- 作者:老汪软件技巧
- 发表时间:2024-09-01 21:04
- 浏览量:
前言
通过前面的几篇分析,对于 BVH 节点树的构建过程应该已经很清楚了,本篇我们继续探索,看下有了几何体加速结构后如何加速计算,直观地感受下这种把问题规模缩小带来的计算速度提升。
本篇没有涉及到太多新的知识点,更多是技巧的使用,也就是之前在构建过程中反复提到的遍历,通过遍历已构建好的 加速结构 去实现加速,然后和之前一样需要从 TypedArray 里对数据进行操作。截图为这个库的封面图,在 BVH 核心算法完成的情况下这个效果其实也比较简单, 无非是根据碰撞结果实时绘制出线和点。最后花了一点篇幅介绍了两种碰撞检测的底层算法。
写作的时候使用的版本是最新的 "version": "0.7.3",, 事实上由于核心的东西不会变化,更多的是 API 形式 上的调整,因而系列写作过程中并没有去强调版本,系列的读者可以轻易地识别变化。
解析
入口文件是 /examples/raycast.html , 这个入口文件对应的 js:
作者通过在 ExtensionUtilitiess 写一个 acceleratedRaycast 方法来替换 Mesh 上的 raycast 方法,并和原来保持 API 的兼容来使用加速结构加速碰撞:
convertRaycastIntersect 这个函数的作用是把结果乘以模型的世界矩阵转换成正确的坐标,否则结果就是基于原点的结果。
再来看下 three 的 Mesh 代码的位置, 查看高频使用的 Raycaster 类,都是非常熟悉的 APIs,注册在类方法上:
点击跳转到 three 的 Raycaster.js, 查看最后的 intersectObject 方法:
原来根据参数,Raycaster 会决定是否递归调用 Mesh 类方法 .raycast。
最后经过梳理,把整个调用关系画出来:
这里面有一个 rayCastFirst 方法,字面意思就是首次碰撞,它和 rayCast 的区别,我们下文会分析。
碰撞检测算法
查看 three.js 源码的 Mesh 类, 最终你会找到检测碰撞的方法是 Ray 类的 intersectTriangle 方法,至此,你会发现 three.js 自带的碰撞检测低效的原因是因为没有使用任何加速结构,仅仅对点是否在几何体的 Box 和 Sphere 包围盒内做检测(Mesh.raycast),前者只需要判断点是否在 aabb内, 后者只需要判断点和圆心距离。这样虽然计算量小,但是显然对于高面数,复杂拓扑结构的几何体十分低效。
所以把加速结构应用到 three 原有的碰撞检测上那么效率应该就会大幅提升。通过前文的分析我们已经清楚地知道作者是通过保持兼容的方式替换了 Mesh 类的 raycast 方法从而应用已生成的加速结构。
接下来我们通过分析 MeshBVH 类上的 rayCast 和 rayCastFirst 方法然后一步步过渡到最基本的射线和三角形求交算法,这个算法被巧妙地设计过,相比于射线和平面方程求交再判断位于三角形内的算法,计算复杂度显著降低,这在许多场景非常重要,比如各种运用到光线追踪的渲染算法,检测算法等,在 AI 的帮助下我得以快速地了解这整个过程,由于篇幅原因,这个算法的详细介绍写在文末。
对比下rayCast 和 rayCastFirst :
一下子就能看出来两函数的作用和区别了,raycastFirst 是返回 射线和 bvh 的 buffer数据(三角形)的首个碰撞结果,而 raycast 是会寻找所有求交的结果。后面的 .face.materialIndex 是当该 Mesh 使用了 arrayMaterials (材质组)的时候把求教的结果(三角面)所使用的相应的材质编号记录下来,为后面的光线追踪程序提供便利。显然,文张开头的那个动画效果只需要求解首个结果,求解所有结果无非是遍历 buffer 记录所有结果,接下来我们把精力放到 raycastFirst 上,继续往下走。
我们定位到 raycastFirstFunc, 这里会判断 indirect, 看过系列文章的读者或许可以想起这个作用,它是为 BVH 数据额外生成了一个 buffer 避免直接在 geometryBuffer 里维持 BVH 结构。点击函数定位到如下位置,这个 generated 和 template 字样很熟悉,之前的我文章也有提过,作用是让 rollup 根据 template 内容条件编译不同的代码出来,作者大量使用这个方法来区分 direct 和 indirect 不同的代码。
这里对于 buffer 数据的遍历如果看过之前文章的朋友应该已经非常熟悉了,这里不做大篇幅重新分析,让我们来看看几个关键字: BufferStack, offset, count 分别代表当前 BVH数据(分组 Mesh 有多颗树),buffer 里的数据起点和计数(三角形)。这个闭包 _raycastFirst 函数对左右节点进行遍历,遇到第一个碰撞结果停止遍历。看下左右节点的遍历:
射线与 AABB 求交:常规做法
常规做法是对 AABB 的六个面分别做碰撞检测,那么本质上就是解是解线性方程组,需要解 6 次再根据射线方向俩判断 t_min 和 t_max。那么对于这样的计算,我们简单分析一下时间复杂度:
所以常规做法的复杂度是 O(n2)O(n^2)O(n2)。
高效的方法
标红的是射线与 AABB 包围盒的碰撞检测,这个方法我们看这个图:
写过比较多 3D 程序的读者比较熟悉的一个话题了,这里再复习一下。
上图里对于射线与 AABB 包围盒的碰撞检测,二维的规则可以拓展到三维。这个方法是怎么样的来简单分析下:以 XOY 平面为例,x 轴向的两个平面分别是 x=x0x = x0x=x0 和 x=x1x = x1x=x1, 这两平面称为 slabs 平面。y 轴同理。
首先,需要求 tminx,tminy,tmaxx,tmaxyt_{minx}, t_{miny}, t_{maxx}, t_{maxy}tminx,tminy,tmaxx,tmaxy ,由于是 AABB 轴向包围盒,那么很容易可以求得:
{xmin=xp+txminxdtxmin=(xmin−xp)/xdymin=yp+tyminydtymin=(ymin−yp)/yd\begin{cases}& x_{min} = x_p + t_{xmin} x_d \\& t_{xmin} = (x_{min} - x_p) / x_d \\& y_{min} = y_p + t_{ymin} y_d \\& t_{ymin} = (y_{min} - y_p) / y_d \\\end{cases}⎩⎨⎧xmin=xp+txminxdtxmin=(xmin−xp)/xdymin=yp+tyminydtymin=(ymin−yp)/yd
其中 (xp,yp)(x_{p}, y_{p})(xp,yp) 是射线原点的坐标。
由于如果与盒子碰撞,会穿过盒子产生两个交点,分别把离射线原点近的标注为 tmint_{min}tmin , 远端的标注为 tmaxt_{max}tmax ,那么可以观察到射线总是先碰到 slab 平面再接触到盒子,所以有:
{tmin=max(tymin,txmin)tmax=min(tymax,txmax)\begin{cases}t_{min} &= max(t_{ymin}, t_{xmin}) \\t_{max} &= min(t_{ymax}, t_{xmax}) \\\end{cases}{tmintmax=max(tymin,txmin)=min(tymax,txmax)
最后,判断出界的情况:
{iftmin>tmax,超出范围iftmax t_{max} , \space \text{超出范围} \\ & \text{ if } t_{max} < 0, \space \text{反方向出界} \\ \end{cases}{iftmin>tmax,超出范围iftmax 0 的情况,这种情况下左节点碰撞优先级高于右节点,所以由射线方向上距离射线原点更近的节点优先遍历,反之亦然。
还有一点非常重要,那就是当 c1Result 也就是优先遍历的这个节点如果判断产生了碰撞那么就不需要遍历另一个节点了,提升了遍历效率。
结合注释,这段代码也就搞明白了。
射线与三角形求交
最后来到判断交点的最后步骤,射线与三角形交点的计算。由于为了保持兼容和非侵入式设计,这个库的调用链还蛮长的,由 intersectClosestTri 函数点进去会一路到达 ThreeRayIntersectUtilities.js 文件,这本是 three.js 底层工具函数,作者导出来做了适配,而最终执行射线与三角形交点的计算是在 Ray.js 这个 three.js 的文件里。
传统办法
大致分为两步,第一步是求交点,也就是求方程 O+td⃗=Ax+By+CzO + t\vec{d} = Ax + By + CzO+td=Ax+By+Cz, 这个和上面的一样,这里更具体地给出步骤方便读者比较。步骤及其复杂度:
计算平面方程:O(1)求解 t 值:O(1)计算交点坐标:O(1)判断点是否在三角形内:O(1)
总体复杂度:O(1),大约需要45-50次乘法,30-35次加法,1次除法
Möller-Trumbore 算法推导
用重心坐标形式:
Q+tD=b1E1+b2E.....(1)\mathbf{Q} + t\mathbf{D} = b_1\mathbf{E}_1 + b_2\mathbf{E} \quad ..... \quad(1)Q+tD=b1E1+b2E.....(1)
其中:
写成行列式:
[−DE1E2][tb1b2]=Q.....(2)\left[\begin{array}{lll}-\mathbf{D} & \mathbf{E_1} & \mathbf{E_2}\end{array}\right]\left[\begin{array}{l}\mathbf{t} \\\mathbf{b1} \\\mathbf{b2}\end{array}\right]=\mathbf{Q} \quad ..... \quad (2)[−DE1E2]tb1b2=Q.....(2)
此时三个未知量,三个等式,可以用熟悉的克莱姆法则求解。这里通过变换来对 (1)(1)(1) 等式进行变换分别求出三个未知量,以 ttt 为例:
首先,等式两边同时叉乘 (E1×E2)(\mathbf{E_1} \times \mathbf{E_2})(E1×E2):
(Q+tD)×(E1×E2)=b1E1+b2E2×(E1×E2)(\mathbf{Q} + t\mathbf{D} ) \times (\mathbf{E_1} \times \mathbf{E_2}) = b_1\mathbf{E_1} + b_2\mathbf{E_2} \times (\mathbf{E_1} \times \mathbf{E_2})(Q+tD)×(E1×E2)=b1E1+b2E2×(E1×E2)
右边等于 0\mathbf{0}0 ,等式化为:
Q⋅(E1×E2)+t(D⋅(E1×E2))=0\mathbf{Q} \cdot (\mathbf{E_1} \times \mathbf{E_2}) + t(\mathbf{D} \cdot (\mathbf{E_1} \times \mathbf{E_2})) = \mathbf{0}Q⋅(E1×E2)+t(D⋅(E1×E2))=0
令
N=E1×E2\mathbf{N} = \mathbf{E_1} \times \mathbf{E_2}N=E1×E2则
t=−Q⋅N/D⋅Nt = - \mathbf{Q} \cdot \mathbf{N} / \mathbf{D} \cdot \mathbf{N}t=−Q⋅N/D⋅N
由于 D,N\mathbf{D} , \mathbf{N}D,N 可能不同号,引入 signsignsign 来表达,这个符号返回 1 或者 -1, 两向量同符号返回 1,不同号则返回 -1, 于是有:
∣D⋅N∣⋅t=−sign(D⋅N)⋅Q⋅N|\mathbf{D} \cdot \mathbf{N}| \cdot t = -\text{sign}(\mathbf{D} \cdot \mathbf{N}) \cdot \mathbf{Q} \cdot \mathbf{N}∣D⋅N∣⋅t=−sign(D⋅N)⋅Q⋅N
另外两个等式通过叉乘 E1\mathbf{E_1}E1 或者 E2\mathbf{E_2}E2 也可以变成以下形式:
∣D⋅N∣⋅b1=sign(D⋅N)⋅D⋅(Q×E2)|\mathbf{D} \cdot \mathbf{N}| \cdot b_1 = \text{sign}(\mathbf{D} \cdot \mathbf{N}) \cdot \mathbf{D} \cdot (\mathbf{Q} \times \mathbf{E_2})∣D⋅N∣⋅b1=sign(D⋅N)⋅D⋅(Q×E2)
∣D⋅N∣⋅b2=sign(D⋅N)⋅D⋅(E1×Q)|\mathbf{D} \cdot \mathbf{N}| \cdot b_2 = \text{sign}(\mathbf{D} \cdot \mathbf{N}) \cdot \mathbf{D} \cdot (\mathbf{E_1} \times \mathbf{Q})∣D⋅N∣⋅b2=sign(D⋅N)⋅D⋅(E1×Q)
这也就是 three.js 里这部分源码注释的公式来源:
它的步骤及其复杂度如下:
计算关键向量:O(1)计算 t, b1, b2:O(1)
总体复杂度:O(1),大约需要24次乘法,16次加法,3次除法
计算量这部分内容由 AI 辅助写作,如有错误,欢迎勘误,水平有限,不胜感激。
可以看到通过代数变换的 Möller-Trumbore 显著减少了计算量,避免使用计算量很大的克莱姆法则求解,这在数值稳定性很有帮助(浮点数算得步骤越多精度越不稳定)。
结语
前面的系列文章:
写文章不易,点赞收藏是最好的支持!