光线追踪渲染技术能听懂的介绍

作者:培之


光线追踪渲染技术综述

本文主要参照该篇文章:An Overview of the Ray-Tracing Rendering Technique 。

关键词 ray-tracing, primary ray, camera ray, eye ray, secondary ray, diffuse ray, reflection ray, refraction ray, shader, ray, illumination model, triangle, recursion, light transport, visibility, Whitted-style ray-tracing.

光线追踪是用于计算点与点之间可见性的一种技术。Light transport algorithms 是用来模拟光在空间中的传播方式(当与物体相互作用时)。 简言之,它们用于计算场景中某个点的颜色。 不要将光线追踪与light transport algorithms 混淆。 他们是两个不同的东西。 光线追踪不是 light transport algorithms 。 它只是一种计算点之间可见性的技术。

Given a set of obstacles in the Euclidean space, two points in the space are said to be visible to each other, if the line segment that joins them does not intersect any obstacles. (definition of visibility on Wikipedia)

光线追踪技术

光线追踪是关于光线(rays)的,光线是本文的主要主题之一。 众所周知,光栅图像(raster image)是由像素组成的。 制作3D场景的图像的一种方法是沿着虚拟相机的图像平面“滑动”这个栅格图像(图1),并通过图像中的每个像素发射射线,以找到每个像素所覆盖的场景的哪个部分。

图1. 在相机图像平面前“滑动”光栅图像

做到这一点的方法是,简单地从相机中心投射一束光线,并穿过每个像素的中心。 然后我们从场景中找出这些光线相交的单个物体(或多个物体)。 如果一个像素确实“看到”了某物,那么它会在该光线指向的方向上看到它前方的物体。正如我们刚才提到的,光线方向可以简单地通过从相机的原点到像素中心,然后延伸到场景中的线来构建(图2)。

图2. 光线线可能会击中或错过场景中的几何体

现在我们知道一个像素看到的是“什么”,我们需要做的就是对图像中的每个像素重复这个过程。 通过使用通过每个像素中心的光线所相交的物体的颜色来设置像素颜色,然后我们就可以形成一个从特定视点看到的场景的图像。 注意,此方法需要循环遍历图像中的所有像素,并为每个像素向场景中投射光线。 第二步,intersection step,需要循环遍历场景中的所有物体,以测试光线是否与这些对象相交。

下面是这个技术的伪代码实现:

// loop over all pixels
Vec3f *framebuffer = new Vec3f[imageWidth * imageHeight]; 
for (int j = 0; j < imageHeight; ++j) { 
    for (int i = 0; i < imageWidth; ++i) { 
        for (int k = 0; k < numObjectsInScene; ++k) { 
            Ray ray = buildCameraRay(i, j); 
            if (intersect(ray, objects[k]) { 
                // do complex shading here but for now basic (just constant color)
                framebuffer[j * imageWidth + i] = objects[k].color; 
            } 
            else { 
                // or don't do anything and leave it black
                framebuffer[j * imageWidth + i] = backgroundColor; 
            } 
        } 
    } 
} 

请注意,某些光线可能根本不与任何物体相交。 例如在图 2中,其中一条射线不与球体相交。 在这种特殊情况下,我们通常将像素的颜色保留为黑色或将其设置任何一种我们希望的背景颜色(上述代码第 13 行)。

还要注意,在上面的代码中,我们用光线与物体相交的点的物体颜色设置像素颜色。 虽然现实世界的物体看起来并不平坦(flatten)。 它们有复杂的外观(appearances or looks)。 它们的亮度(brightness)取决于它们接收到的光的数量,有些是闪亮的(shiny),有些是哑光的(matte),等等。 真实感(photorealistic )渲染的目标不仅是从给定的视角准确地描绘几何(并解决可视性问题),而且要模拟物体的外观(appearance)。 因此,这个过程很可能涉及到比仅仅返回一个颜色(这是我们在上面的代码中所做的)更复杂的事情。 在计算机图形学中,定义物体表面上任何给定点的实际颜色的任务被称为着色(shading)。

图3. 试图重建现实是困难的

光线追踪是以图像为中心(image centric)的。 外循环遍历图像中的所有像素,内循环遍历场景中的物体。 相比之下,光栅化(rasterization)算法是以物体为中心的。 它需要循环遍历场景中的所有几何图元(外循环),将这些图元投影到屏幕上,然后循环遍历图像中的所有像素,以找出这些像素中的哪一个与屏幕投影几何重叠(内循环) . 两种算法中的内循环和外循环互换。

下面给出一个用伪代码表达的光栅化循环:

// loop over all pixels
Vec3f *framebuffer = new Vec3f[imageWidth * imageHeight]; 
for (int k = 0; k < numObjectsInScene; ++k) { 
    // project object onto the scene using perspective projection
    ... 
    for (int j = 0; j < imageHeight; ++j) { 
        for (int i = 0; i < imageWidth; ++i) { 
            if (pixelCoversGeometry(i, j, objects[k]) { 
                framebuffer[j * imageWidth + i] = objects[k].color; 
            } 
        } 
    } 
} 

光栅化和光线追踪都可以用来解决可见性问题。渲染过程分为两步:可见性(visibility)和 着色(shading)。 光栅化有利于可见性步骤(并且比光线追踪快得多),但在着色方面不如光线追踪。

下文将要解释的内容对于渲染(rendering)非常重要,因此请花点时间仔细阅读。 在教科书上,针对着色,通常你会看到光线追踪比光栅化效果更好,但大多数教科书都无法解释其原因。(几乎)渲染中的一切都是关于计算空间中的一个点与给定方向上的第一个可见表面之间的可见性,或两点之间的可见性。 前者用于解决可见性问题,后者用于解决着色等问题。光栅化(结合深度缓冲算法)对于找到第一个可见表面非常好,但在解决两点之间的可见性方面效率低下。而光线追踪可以有效地处理这两种情况。找到第一个可见表面对于解决可见性问题很有用。 光线追踪和光栅化在这方面都做得很好。 另一方面,着色需要解决表面之间的可见性。 这用于计算阴影、使用区域光时的柔和阴影,以及更普遍的全局照明效果,例如反射(reflection)、折射(refraction)、间接反射和间接漫反射。对于渲染过程的这个特定部分,光线追踪因此比光栅化更有效。 但请记住,任何计算点之间可见性的技术都可以用于着色和解决可见性问题。 它不一定是光线追踪或光栅化,但光线追踪在某种程度上是一种懒惰的方式,它是有代价的,正如我们将在本文末尾看到的那样。

图4.使用光线追踪可以轻松计算点之间的可见性

这样看,光线追踪比光栅化更好,尽管光线追踪的主要问题是它涉及计算光线与几何图形的交集,这是一项开销很大的操作(即缓慢)。 在实时图形应用程序中,速度比真实感更重要,这就是 GPU 使用光栅化的原因。 当照片真实感比速度更重要时,光线追踪是一个更好的选择,尽管在交互式帧速率(interactive frame rate)下使用光线追踪生成图像是困难的,在实时帧速率下更是如此。

“Ray tracing isn’t too slow; computers are too slow.” (Kajiya 1986)

总而言之,使用光线追踪计算 3D 物体的真实感图像基本上可以分为三个步骤:

  • 投射光线(Casting Rays):为图像中的每个像素投射一条光线。
  • Ray-Geometry Intersection:测试光线是否与场景中的任何物体相交(这需要循环遍历每条投射光线,再循环遍历所有物体)。
  • 着色(Shading):在射线和物体的交点处找出物体的“样子”(如果有交点)。

接下来逐一介绍这些步骤:

向场景投射光线

为了在光线追踪中创建图像,我们需要做的第一件事是为图像中的每个像素投射光线。 这些光线称为相机或主光线(camera or primary rays)(因为它们只是我们将投射到场景中的第一条光线)。正如将在下文看到的,更多的光线可以从主光线中产生。 这些其他射线称为二次光线(secondary rays)。 它们用于查找场景中的给定点是否处于阴影中或计算阴影效果,例如反射或折射。 当主光线投射到场景中时,下一步是确定它是否与场景中的任何物体相交。

当用主光线来解决可见性问题时,我们可以使用光线投射(ray-casting)这个术语。

测试光线几何相交

测试光线是否与场景中的任何物体相交需要遍历场景中的所有物体,测试该光线是否与被遍历的物体相交。

Ray ray = buildCameraRay(i, j); 
for (int k = 0; k < numObjects; ++k) { 
    if (intersect(ray, objects[k]) { 
        // this ray intersects objects[k]
    } 
} 

3D 中的几何图形可以用许多不同的方式定义。 简单的形状,如球体、圆盘、平面,可以用数学方法或参数定义。 更复杂对象的形状只能使用多边形网格(polygon meshes)、细分曲面(subdivision surface)或 NURBS 曲面(或等效的东西)来描述。

图5. 三角网格比其他类型的几何体更容易进行光线追踪

第一类对象(以数学方式表示的对象)通常可以使用几何或分析方法测试光线相交。 例如,射线-球体相交测试可以使用这两种方法来解决。

第二类对象的主要问题是需要为每种支持的表面类型实现光线-几何相交方法。 例如,可以直接对 NURBS 表面进行光线追踪,尽管这样做的解决方案与用于测试光线和多边形网格之间相交的解决方案非常不同。 因此,如果要支持所有几何类型(细分曲面、NURBS、多边形网格),则需要为每种支持的几何类型编写一个射线-几何表面相交程序。 这可能会给程序代码增加相当程度的复杂性。 另一种解决方案(这是几乎所有专业程序都选择的方法)是将每种几何类型转换为相同的内部表示,在几乎 99% 的情况下,这将是三角多边形网格。

光线追踪喜欢三角形!

为什么是三角形? 首先,将不同的几何类型转换为相同的内部几何表示(这个过程也称为镶嵌(tessellation))是一种更好的方法,而且将几乎任何类型的表面转换为多边形网格通常都很容易。 将多边形网格转换为三角网格也非常简单(唯一的困难是凹(concave)多边形)。 三角形可以用作光线追踪和光栅化中的基本几何图元。 为什么这两种算法都喜欢三角形,因为它们具有其他类型所没有的有趣的几何特性。 它们是共面的(co-planar),这不一定是具有三个以上顶点的面的情况。 使用边缘函数(edge function)方法计算它们的重心(barycentric)坐标也很简单。 重心坐标在着色中起着重要作用。

出于这个原因,许多研究都在寻找测试光线是否与三角形相交的最佳方法。 这个问题可以用几何方法解决,但这不一定是最快的方法。 随着时间的推移,已经开发了使用代数解决方案的其他方法。 一个好的光线-三角形相交程序需要是一个既快速又数值稳定的程序。

多边形网格或其他类型的表面被转换为三角形,这可以在将几何体加载到程序内存之前(在建模工具中,在将网格导出到渲染器之前)或在渲染时加载几何体时完成。 请注意,现在不仅需要针对场景中的每个物体测试每个相机光线,还需要针对构成场景中每个多边形物体的每个单独的三角形进行测试。

换句话说,循环现在如下所示:

for (int j = 0; j < imageHeight; ++j) { 
    for (int i = 0; i < imageWidth; ++i) { 
        for (int k = 0; k < numObjects; ++k) { 
            for (int n = 0; n < objects[k].numTriangles; ++n) { 
                Ray ray = buildCameraRay(i, j); 
                if (intersect(ray, objects[k].triangles[n])) { 
                    framebuffer[j * imageWidth + i] = shade(ray, objects[k], n); 
                } 
            } 
        } 
    } 
} 

上面的代码实际意味着,在光线追踪中渲染场景所需的时间与场景包含的三角形数量成正比。 光栅化不一定是这种情况。 当然,对于光栅化,同样是三角形越多,渲染帧所需的时间就越长,尽管其中许多三角形甚至可以在渲染开始之前就被丢弃 . 这在光线追踪中是不可能的,原因我们将在本章的着色部分进行解释。 所有三角形都需要存储在内存中,并且需要在将光线投射到场景中时对它们中的每一个进行测试。 光线追踪的高计算成本(以及渲染时间随场景包含的三角形数量线性增长的事实)是算法的主要诅咒之一。

追踪函数

在光线追踪中,内部循环(所有物体和每个物体包含的所有三角形的循环)通常被移动到通常称为 trace() 的函数中。 如果光线与物体/三角形相交,则该函数返回 true,否则返回 false。

代码如下:

bool trace( 
    const Vec3f& rayOrigin, const Vec3f &rayDirection, 
    const Object* objects, const uint32_t &numObjects, 
    uint32_t &objectIndex, 
    uint32_t &triangleIndex ) { 
    
    bool intersect = false; 
    for (uint32_t k = 0; k < numObjects; ++k) { 
        for (uint32_t n = 0; n < objects[k].numTriangles; ++n)) { 
            if (rayTriangleIntersect(rayOrigin, rayDirection, objects[k].triangles[n]) { 
                intersect |= true; 
                objectIndex = k; 
                triangleIndex = n; 
            } 
        } 
    } 
 
    return intersect; 
} 
 
int main(...) 
{ 
    ... 
    uint32_t objectIndeix, triangleIndex; 
    for (int j = 0; j < imageHeight; ++j) { 
        for (int i = 0; i < imageWidth; ++i) { 
            if (trace(rayOrigin, rayDirection,   objects, numObjects, objectIndex, triangleIndex)) { 
                framebuffer[j * imageWidth + i] = shade(rayOrigin, rayDirection, objects[objectIndex], triangleIndex); 
            } 
        } 
    } 
    ... 
   return 0; 
} 

如图 6 所示,一条射线可能与多个三角形相交。 通过光栅化,我们使用深度缓冲区解决了这个问题。 在光线追踪中,我们通过简单地在 trace() 函数中创建一个变量来解决这个问题,该变量维护光线原点和交点之间的最近距离。该变量命名为 tnearest,初值设置为无穷大(一个非常大的数字)。当找到一个交点时,测试光线原点到三角形的距离 t。 如果 t 小于 tnearest ,则将 tnearest 设置为 t。 最后,当所有物体的所有三角形都被测试过时, tnearest包含到射线相交的最近三角形的距离(如果有的话,我们将需要在着色阶段再次访问这个三角形,因此我们还存储了关于该三角形的一些信息 ,例如它所属的物体以及它在该对象的三角形列表中的索引)。

图6. 一条射线可以与多个物体相交

假设一条射线是由它的原点和它的(归一化的)方向定义的。 然后距离 t 定义了从沿射线的点到射线原点的距离。 可以使用以下代码找到该点在射线上的 3D 位置:

Vec3f P = rayOrigin + t * rayDirection; 
bool trace( 
    const Vec3f& rayOrigin, const Vec3f &rayDirection, 
    const Object* objects, const uint32_t &numObjects, 
    uint32_t &objectIndex, 
    uint32_t &triangleIndex, 
    float &tNearest 
) 
{ 
    tNearest = INFINITY;  //initialize to infinity 
    bool intersect = false; 
    for (uint32_t k = 0; k < numObjects; ++k) { 
        for (uint32_t n = 0; n < objects[k].numTriangles; ++n) { 
            float t; 
            if (rayTriangleIntersect(rayOrigin, rayDirection, objects[k].triangles[n], t) && t < tNearest) { 
                objectIndex = k; 
                triangleIndex = n; 
                tNearest = t;  //update tNearest with the distance to the closest intersect point found so far 
                intersect |= true; 
            } 
        } 
    } 
 
    return intersect; 

在着色阶段,我们需要获取交点处表面拓扑的一些信息,比如交点本身的位置(一般记为 P ),还有交点处表面的法线( 通常用 N 表示的表面方向及其纹理坐标( st 或 uv 坐标)。还可能需要其他量,例如交点处的表面导数,但这是一个高级主题。 在交点处找到曲面的法线通常很简单。 对于三角形,我们只需要计算相交三角形两条边之间的叉积。 对于球体,如果我们知道球体中心的位置(我们称之为 C ),我们只需要计算向量 P − C 。

图7. 几何信息(例如法线或纹理坐标)用于着色

着色

尽管光线追踪与光栅化等其他方法相比具有较高的计算成本,但它也具有使其比其他渲染技术更具吸引力的优势。 在着色方面尤其如此。

图8. 畸变效应是由折射现象引起的

一旦我们找到了与光线相交的对象,我们就需要找出相交点处对象的颜色。 物体的颜色或它们的强度在物体的表面上可能变化很大。 这是由于光照的变化以及物体的纹理在其表面上发生变化。 物体表面上任何一点的颜色“只是”物体返回或反射撞向其表面光线的方式的结果。

交点的颜色取决于:

  • 有多少光(在现实世界中,光的颜色和强度(intensity)的概念实际上是相同的)撞击物体表面(在交点处,但如果表面是透明或半透明的,也会远离该点,但这是一个高级话题)。
  • 光的方向。
  • 表面本身的属性,特别是它的颜色。
  • 观察者位置。 大多数表面不会在所有方向上均匀地反射光。 因此,反射光的数量很可能会随着相机或观察者的位置而改变。

图9. 落到表面P 处的所有光中,有多少光反射到观察者?

现在,需要注意的是,除了对象的属性(例如它的颜色)之外,着色与“收集”有关落在对象表面的光的信息有很大关系。在自然界中,这个过程可以描述为从光源发出的“光线”(或通过场景中的其他表面间接将光源的光线反射到其他表面,这个过程称为间接照明),落在物体表面物体并被反射回场景中。这些反射光线中的一些会撞击更多的表面,并会依次再次反射。其他光线可能会到达观察者的眼睛或相机。实际上,光从光源传播到眼睛(图 8)。虽然在光线追踪中,我们通常选择追踪光线从眼睛到表面,然后从表面到光源的路径,这个过程我们称之为反向追踪(backward tracing),因为它遵循光线向后的自然传播(图 9)。

在光线追踪的一种显而易见的方法中,从光源发出的光线通过它们的路径被追踪,直到它们到达观察者。 由于只有少数光会到达观察者,因此这种方法是浪费的。 在 Appel 建议的第二种方法中,光线在相反的方向上被追踪——从观察者到场景中的对象。” 一种改进的阴影显示照明模型。Turner Whitted,1980。

Light transport algorithm 的功能是模拟光(以能量的形式)在场景中分布的方式。 给定的材料如何将这种光反射回环境中,由数学模型或计算机图形学中的照明模型(illumination model)定义(这或多或少地就是BRDF 的作用)。 Phong 模型是照明模型的一个示例。 照明模型通常在我们所说的着色器(shader)中实现。 着色器通常被实现为某种函数,它将入射光方向 ωi、如身高强度和观察方向 ωo 作为输入,并根据材料属性和光照模型返回在方向 ωo反射的入射光量。

图10. 照明模型的函数是基于例如表面颜色,光的强度和入射方向$w_i$以定义在$w_0$ 方向反射的光的多少

需要注意的重要一点是,着色涉及模拟光能在场景中的分布方式。看待这个问题的最自然的方法之一是光线。如图 11 所示,我们可能想要跟踪由光源发出的光线的路径,然后被漫反射物体反射到镜子,然后最终被镜子反射到眼睛。由于在计算机图形学 中我们更喜欢使用反向追踪,因此我们实际上会以相反的方式跟随光线的路径。我们将光线从眼睛追踪到镜子,然后投射反射光线(反射光线的方向可以很容易地使用众所周知的反射定律来计算)。该光线将与漫反射对象相交。我们最终会将光线从漫反射对象投射到灯光上。我们在主光线(primary rays)之后投射的所有光线(从镜面到漫射物体的光线和从漫射物体到光线的光线)都称为二次光线(secondary rays)。二次光线通常有自己的名称:例如从镜子投射的光线称为反射光线,而投射到光的光线称为阴影光线(shadow rays)。这条射线的主要目标是确定漫反射物体上的点是否在光的阴影中(如果阴影射线在到达灯光的途中与物体相交,则该点在阴影中)。

图11. 主要和二次光线(反射和阴影光线)

图12. 阴影光线

请注意,这些二次光线中的每一条都涉及计算两点之间的可见性。 例如,当我们将反射光线从镜子投射到场景中时,我们想要计算反射光线最终将相交的第一个可见表面(在我们的示例图 11 中是漫反射对象)。 当我们投射阴影光线时,我们想知道这个阴影光线在到达光线之前是否与另一个物体相交。计算两点之间可见性的最简单方法当然是使用光线追踪。

光线追踪一直被认为是生成 3D 对象真实感图像的最佳解决方案,但请记住,直到最近(直到 1990 年代末 2000 年代初),该技术本身被认为开销过大而无法使用 (除了在一些研究项目中)。 还要记住,计算机资源增加了,但正在渲染的场景的复杂性也增加了。 因此,渲染这些场景所需的实际时间多年来并没有太大变化。 这种效应被称为布林定律(Blinn’s law)。

总之,我们可以说,任何需要大量计算可见性的 light transport algorithm(例如当反射或阴影光线投射到场景中时)都可能在光线追踪中更有效。

光线追踪:挑战

光线追踪的原理非常简单,而且“容易”实现。 该过程中最复杂的部分是编写代码来计算光线与物体的交点。 这个问题通常使用几何或解析解决方案来解决,两者都意味着大量的数学。 除了数学之外,主要问题是计算这些交叉测试通常也是一个开销昂贵的过程。 尽管有这个主要缺点,但该技术很优雅,可以在一个统一的框架中解决可见性和着色。

当我们说光线追踪很容易实现时,尽管开发生产光线追踪器是一项非常复杂的任务,但我们可能应该准确地说。实现一个基本的原型很简单,但要使该技术变得健壮以便可以用于大型生产,这是一个非常具有挑战性的问题。生产中的光线追踪需要支持许多功能,例如位移、运动模糊、可编程着色等。其中许多功能很难在光线追踪中有效地发挥作用。光线追踪带来了光栅化算法所没有的一整套问题。这些问题很难有效解决。光线追踪的主要问题之一是它需要在渲染图像时将所有场景几何图形存储在内存中。光栅化不一定是这种情况:一旦我们知道一个对象不会出现在屏幕上的其他任何地方,我们就可以将其丢弃(并将其从内存中删除)。如果开启背面剔除,背对相机的三角形也可以被丢弃。在光线追踪中,即使一个对象在场景中不可见,它也可能会在相机可见的对象上投射阴影,因此需要将其保存在内存中,直到处理完图像中的最后一个像素。对于包含数百万个三角形的生产场景,存储数据所需的内存量可能会成为一个问题。

本文中提到的光线相交测试步骤是光线追踪中渲染过程中开销最昂贵的部分。 最后,您还需要存储与对象运动相关的信息。 这进一步增加了存储场景数据所需的内存量。 总而言之,光线追踪器通常比光栅器使用更多的内存用于相同的场景。 光线追踪对于生成更逼真的图像很有用,但代价是比基于光栅化算法的程序更慢且使用更多内存。 该算法在概念上很简单,但很难在具有复杂着色的非常大和复杂的场景中可靠有效地工作。

在GPU上运行光线追踪

光线追踪算法在 90 年代中期首次成功移植到 GPU 上。 尽管大多数消费类显卡尚未原生支持光线追踪算法(加速光线追踪的硬件已经存在了很长时间),但在这方面已经进行了相当多的研究。 未来几年,GPU 很可能会原生支持光线追踪。 尽管如上所述,它需要克服内存管理等问题,正如您所知,光线追踪比光栅化更困难。


版权声明:本文为CSDN博主「培之」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/OrdinaryMatthew/article/details/125690986

最新文章