在这个教程里,我们将介绍并使用研究人员 Kay 和 Kajiya 于 1986 年开发的技术。 自那时以来,其他加速结构已被证明比他们的技术更好,但他们的解决方案可以帮助制定大多数加速结构的构建原则。 这是一个很好的起点,就像 Utah Teapot 一样,我们将有机会介绍许多其他计算机图形算法(例如八叉树结构)中出现的一些有趣的新技术。 我们强烈建议你阅读他们的论文。
推荐:用 NSDT场景设计器 快速搭建3D场景。
图1:松散与复杂的边界包围体
在上图中,当表示对象的边界体太松散地适合对象时,与范围相交的许多光线不会与形状相交并被浪费(顶部)。 使用更复杂的包围体(底部)可提供更好的结果,但光线追踪的成本更高。
之前我们直观地展示了可以使用简单的技术(例如针对边界体积的光线追踪)来加速光线追踪。 正如 Kay 和 Kajiya 在他们的论文中指出的那样,这些技术只有在光线追踪这些边界体积或他们所说的范围比光线追踪对象本身快得多的情况下才有效。 他们还指出,如果这个范围松散地适合一个物体,那么许多与这个包围体相交的光线很可能实际上错过了里面的物体。
另一方面,如果范围精确地描述了物体,那么所有与范围相交的光线也将与物体相交。 显然,符合这个标准的包围体就是物体本身。 因此,边界体积的一个好的选择是在紧密度(范围适合对象的接近程度)和速度(复杂的边界体积比简单形状的光线追踪成本更高)之间提供良好权衡的形状。
这个想法如图 1 所示,其中茶壶周围的盒子比下图中茶壶周围更紧密的边界体积更快地进行光线追踪。 然而,与更紧密地拟合模型的范围相比,与此框相交的更多光线将错过茶壶几何形状。 在大多数情况下,球体和盒子等形状会产生一些相当不错的结果,但利用在简单性和速度之间找到良好折衷的想法,Kay 和 Kajiya 提议进一步改进这些简单的形状。
图2:对象的边界框可以看作是平行于 x 轴和 y 轴的平面的相交
边界框可以看作是彼此相交的平面。 为了使这个演示更容易,我们只考虑二维情况。 假设我们需要计算茶壶的边界框。 从技术上讲,这可以看作是两个平行于 y 轴的平面与两个平行于 x 轴的平面的交点(图 2)。 我们现在需要找到一种计算这些平面的方法吗? 我们该怎么做? 我们已经在前面介绍了平面方程来计算射线与盒子的交点,以及如何计算射线与三角形的交点。 让我们回想一下平面方程(这次是三个维度)可以定义为:
其中 A、B、C 项定义平面的法线(垂直于平面的矢量),d是沿此向量从世界原点到此平面的距离。 x、y、z 定义了位于平面上的点的 3D 笛卡尔坐标。 这个方程标明任何一点的坐标乘以平面法线坐标,减去d等于零。 这个方程也可以用来将茶壶的顶点投影到一个平面上,并找到d的一个值。
对于给定点P(x,y,z)和一个给定的平面N(x,y,z),我们可以解方程得到:
图 3:在具有法线 (1,0,0) 的平面上投影点。
如果我们投影一个顶点P(x,y,z)在与法线N(1,0,0)平行的 y 轴平面上,给出沿 x 轴从原点到平行于 y 轴的平面的距离,如果我们对模型的所有顶点重复这个过程,我们可以证明具有最小值的点d值和具有最大值的点d值,分别对应物体x坐标的最小和最大范围。 这两个值dnear和dfar描述约束对象的两个平面(如图 3 所示)。 我们可以用下面的伪代码来实现这个过程:
// plane normal
vector N(1, 0, 0);
float Dnear = INFINITY;
float Dfar = -INFINITY;
for (each point in model) {
// project point
float D = N.x * P.x + N.y * P.y + N.z * P.z;
Dnear = min(D, Dnear);
Dfar = max(D, Dfar);
}
图 4:分别平行于 xz- yz- 和 xy- 平面的三个板的交点定义了一个轴对齐的边界框 (AABB)
在他们的论文中,Kay 和 Kajiya 将两个平面之间的空间区域称为平板,定义平板方向的法向量称为平面集法线。 他们观察到:
平面集法线的不同选择会为对象产生不同的边界板。 一组边界 slabs 的交集产生一个边界体积。 为了在 3 空间中创建一个封闭的边界体积,必须至少涉及三个边界板,并且必须选择它们以使定义的法向量跨越 3 空间。
该原理的最简单示例是轴对齐边界框 (AABB),它由分别平行于 xz、yz 和 xy 平面的三个平板定义(图 4)。 然而,Kay 和 Kajiya 提议不只是使用三个而是七个板来获得更紧密的边界体积。 这些平板的平面集法线是预先选择的,并且独立于要界定的对象。 为了更好地形象化这是如何工作的,让我们再次回到二维情况。 假设用于绑定对象的平面集法线是:
图 5:使用四个平面集法线界定的对象。
图 5 显示了一个由这些预先选择的平面集法线包围的对象(这是 Kay 和 Kajiya 论文中图 3 的再现)。
如你所见,生成的边界框比简单的边界框更适合对象。 对于三维情况,他们使用七个平面集法线:
前三个平面集法线定义了一个轴对齐的边界框,最后四个平面集法线定义了一个八边形平行六面体。 要构建对象的边界体积,我们只需找到的最小值和最大值
通过将模型的顶点投射到七个平面集法线上来为每个板。
接下来,我们需要编写一些代码来对体积进行光线追踪。 原理与射线盒相交的原理非常相似。 平板由两个相互平行的平面定义,如果光线不平行于这些平面,它将与它们相交产生两个值tmin和tfar。 要计算相交距离,我们只需代入射线方程A x + B y + C z − d = N i ⋅ P x , y , z − d = 0,导出(等式 2):
其中Ni是七个平面集法线之一,O和R分别是射线的原点和方向,d是从世界原点到具有法线Ni的平面的距离,其中有交点Pxyz。Ni.O和Ni.R可以重新用于计算在射线和两个平面之间的相交距离t。 替换预先计算的d值(dnear和dfar) 对于每个平面,测试板产生一个值。
// computing intersection of a given ray with slab
float NdotO = N . O;
float NdotR = N . R;
float tNear = (dNear - NdotO) / NdotR;
float tFar = (dFar - NdotO) / NdotR;
图 6:当 R 与 N 的乘积小于零时,near 值和 far 值的作用需要反转。
就像盒子的光线追踪一样,当分母Ni.R接近零时必须小心。 此外,当分母小于零时,我们还需要交换dnear和dfar(见图 6)。 至于射线盒相交,对包围对象的每个板执行此测试。 在所有计算的tnear中,我们将保留最大的值,所有计算出的tfar值
值,我们将保留最小的一个。如果射线与体积相交,则t值指示这些交点沿射线的位置。 结果间隔(由tnear和tfar定义)) 可用于估计物体沿射线的位置。 现在让我们尝试将所学付诸实践。
以下 C++ 代码实现了本章中描述的方法。 BVH 类派生自基础 AccelerationStructure 类。 我们在这个类中创建一个名为 Extents 的结构,它保存对于所有七个预定义的平面集法线(第 7-11 行)的dnear和dfar的值。
class BVH : public AccelerationStructure
{
static const uint8_t kNumPlaneSetNormals = 7;
static const Vec3f planeSetNormals[kNumPlaneSetNormals];
struct Extents
{
Extents()
{
for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i)
d[i][0] = kInfinity, d[i][1] = -kInfinity;
}
bool intersect(const Ray &ray, float &tNear, float &tFar, uint8_t &planeIndex);
float d[kNumPlaneSetNormals][2];
};
Extents *extents;
public:
BVH(const RenderContext *rcx);
const Object* intersect(const Ray &ray, IsectData &isectData) const;
~BVH();
};
在类的构造函数中,我们分配了一个范围数组来存储场景中所有对象的包围体数据(第 3 行)。 然后我们遍历所有对象并调用 Object 类的方法 computeBounds 来计算包围对象的每个板的值 dNear 和 dFar(第 4-8 行)。 在下面的代码片段中,我们只展示了 PolygonMesh 类的这个函数。 我们遍历网格的所有顶点并将它们投影到当前平面上(第 27-31 行)。 这结束了在类构造函数中完成的工作。
BVH::BVH(const RenderContext *rcx) : AccelerationStructure(rcx), extents(NULL)
{
extents = new Extents[rcx->objects.size()];
for (uint32_t i = 0; i < rcx->objects.size(); ++i) {
for (uint8_t j = 0; j < kNumPlaneSetNormals; ++j) {
rcx->objects[i]->computeBounds(planeSetNormals[j], extents[i].d[j][0], extents[i].d[j][1]);
}
}
}
const Vec3f BVH::planeSetNormals[BVH::kNumPlaneSetNormals] = {
Vec3f(1, 0, 0),
Vec3f(0, 1, 0),
Vec3f(0, 0, 1),
Vec3f( sqrtf(3) / 3.f, sqrtf(3) / 3.f, sqrtf(3) / 3.f),
Vec3f(-sqrtf(3) / 3.f, sqrtf(3) / 3.f, sqrtf(3) / 3.f),
Vec3f(-sqrtf(3) / 3.f, -sqrtf(3) / 3.f, sqrtf(3) / 3.f),
Vec3f( sqrtf(3) / 3.f, -sqrtf(3) / 3.f, sqrtf(3) / 3.f) };
}
class PolygonMesh : public Object
{
...
void computeBounds(const Vec3f &planeNormal, float &dnear, float &dfar) const
{
float d;
for (uint32_t i = 0; i < maxVertIndex; ++i) {
d = dot(planeNormal, P[i]);
if (d < dnear) dnear = d;
if (d > dfar) dfar = d;
}
}
...
};
一旦渲染函数被调用,我们就调用 BVH 类的 intersection 方法,而不是让场景中的每个对象相交。 首先,针对场景中所有对象的所有边界体积测试光线。 为此,我们使用当前测试对象的体积数据从 Extent 结构调用 intersect 方法(第 24 行)。 此方法简单地计算当前光线与包围对象的七个板中的每一个的交点,并跟踪 dNear 值中的最大值和计算出的 dFar 值中的最小值。 如果 dFar 大于 dFar,则与边界体积发生交集,函数返回 true。 在下面的代码版本中,如果光线与体积相交,我们将 IsectData 结构中的成员变量 N 设置为相交平面的法线。 这个向量 N 与光线方向的点积结果用于设置当前像素的颜色。 生成的图像如图 7 所示。这有助于可视化被相交和围绕对象的边界体积。
inline bool BVH::Extents::intersect(
const Ray &ray,
float *precomputedNumerator, float
*precomputeDenominator,
float &tNear, float &tFar, uint8_t
&planeIndex)
{
for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i) {
float tn = (d[i][0] - precomputedNumerator[i]) / precomputeDenominator[i];
float tf = (d[i][1] - precomputedNumerator[i]) / precomputeDenominator[i];
if (precomputeDenominator[i] < 0) std::swap(tn, tf);
if (tn > tNear) tNear = tn, planeIndex = i;
if (tf < tFar) tFar = tf;
if (tNear > tFar) return false;
}
return true;
}
const Object* BVH::intersect(const Ray &ray, IsectData &isectData) const
{
float tClosest = ray.tmax;
Object *hitObject = NULL;
float precomputedNumerator[BVH::kNumPlaneSetNormals], precomputeDenominator[BVH::kNumPlaneSetNormals];
for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i) {
precomputedNumerator[i] = dot(planeSetNormals[i], ray.orig);
precomputeDenominator[i] = dot(planeSetNormals[i], ray.dir);;
}
for (uint32_t i = 0; i < rc->objects.size(); ++i) {
__sync_fetch_and_add(&numRayVolumeTests, 1);
float tNear = -kInfinity, tFar = kInfinity;
uint8_t planeIndex;
if (extents[i].intersect(ray, precomputedNumerator, precomputeDenominator, tNear, tFar, planeIndex)) {
if (tNear < tClosest)
tClosest = tNear, isectData.N = planeSetNormals[planeIndex], hitObject = rc->objects[i];
}
}
return hitObject;
}
图 7:此图像显示了相交的包围体,包围了构成茶壶的 32 个贝塞尔补丁中的每一个。
但是在下面的 intersect 方法的最终版本中,如果包围体相交,我们将测试光线和被体积包围的对象(或者对象,如果它是三角形网格)之间是否存在交集 . 当测试成功时,如果相交距离是我们迄今为止找到的最小值,我们更新 tClosest 并保留指向相交对象的指针。
int main(int argc, char **argv)
{
clock_t timeStart = clock();
...
rc->accelStruct = new BVH(rc);//AccelerationStructure(rc);
render(rc, filename);
...
printf("Render time : %04.2f (sec)\n", (float)(timeEnd - timeStart) / CLOCKS_PER_SEC);
printf("Total number of triangles : %d\n", totalNumTris);
printf("Total number of primary rays : %llu\n", numPrimaryRays);
printf("Total number of ray-triangles tests : %llu\n", lrt::numRayTrianglesTests);
printf("Total number of ray-triangles intersections : %llu\n", lrt::numRayTrianglesIsect);
printf("Total number of ray-volume tests : %llu\n", lrt::numRayBoxTests);
return 0;
}
最后,如果我们使用新的加速结构编译并运行程序,会得到以下统计信息:
Render time : 2.92 (sec)
Total number of triangles : 16384
Total number of primary rays : 307200
Total number of ray-triangles tests : 80998400
Total number of ray-triangles intersections : 111889
Total number of ray-volume tests : 9830400
该技术比上一章的方法快 1.34 倍。 这可能看起来并不多,但在一个简单的场景中节省几秒钟可能会在一个复杂的场景中变成几个小时。
尽管我们改进了第 2 章的结果,但这种技术仍然存在渲染时间与场景中对象数量成正比的问题。 为了进一步提高这种方法的性能,Kay 和 Kajiya 建议使用卷的层次结构。 再次引用论文作者的话:
对于每个这样的节点,我们计算一个边界体积。 然后,如果射线错过了层次结构中给定节点的边界体积,我们可以拒绝进一步考虑该节点的整个子树。 使用层次结构会导致计算图像的成本与场景中的对象数量成对数关系。
我们将在下一章中实现这个思路。
原文链接:BVH RayCast算法 — BimAnt