Ray-Tracing: Generating Camera Rays(翻译)

目录

Generating Camera Rays

Source Code

Summary: We Are Almost Ready to Render our First Image


Generating Camera Rays

首先让我们回想一下渲染器的目的是为帧的每个像素分配一种颜色。 结果必须是从特定 3D 视点看到的场景几何图形的准确表示。 我们从上一章也知道,视野等参数会改变我们看到的场景的多少。 我们还在第 1 课和本课中解释了光线追踪图像是通过为帧中的每个像素生成一条光线来创建的。 当光线与场景中的对象相交并将像素的颜色设置为相交点处的对象颜色时。 我们在第 1 课中已经讨论过但将在下一课中再次详细介绍的这个过程被称为 backward- or eye-tracing(因为我们沿着光线从相机到物体以及从物体到物体的路径)。 而不是从光源到物体,再从物体到相机)。

自然地,创建图像的过程将从构建我们称为主光线或相机光线的这些光线开始(主光线是因为这些是我们将投射到场景中的第一条光线。Secondary rays are shadows rays for example which we will talk about later)。我们对这些射线有什么了解可以帮助我们构建它们?我们知道它们是从相机的起源开始的。在几乎所有的 3D 应用程序中,相机创建时的默认位置是世界的原点,由坐标为 (0, 0, 0) 的点定义。记得在课程 3D Viewing:针孔相机模型中,相机的原点可以被视为针孔相机的光圈(也是投影的中心)。真实世界针孔相机的胶片位于光圈后面,通过几何构造使光线形成场景的倒像。但是,如果胶片平面与场景位于同一侧(在光圈前面而不是后面),则可以避免这种反转。按照惯例,在光线追踪中,它通常放置在距离相机原点 1 个单位的位置(这个距离永远不会改变,我们将解释为什么更远)。按照惯例,我们还将沿负 z 轴定向相机(相机默认方向由开发人员选择,但通常相机沿正 z 轴或负 z 轴定向。RenderMan、Maya、PBRT 和OpenGL 沿负 z 轴对齐相机,我们建议开发人员遵循相同的约定)。最后,为了让演示开始更简单,我们将假设我们渲染的图像是方形的(以像素为单位的图像宽度和高度相同)。

 我们的任务包括为帧的每个像素创建主光线。 这可以通过跟踪从相机原点开始并穿过每个像素中间的线来轻松完成(图 1)。 我们可以用一条射线的形式来表示这条线,它的原点是相机的原点,方向是从相机的原点到像素中心的向量。 为了计算一个点在一个像素中心的位置,我们需要转换原来在栅格空间中表示的像素坐标(点坐标以像素表示,坐标(0,0)为左上角 的框架)到世界空间。

 为什么是世界空间? 世界空间基本上是场景中所有对象、几何图形、灯光和相机的坐标都表达到其中的空间。 例如,如果一个圆盘位于沿负 z 轴距世界原点 5 个单位的位置,则圆盘的世界空间坐标为 (0, 0, -5)。 如果我们希望计算射线与该圆盘的交点的数学运算起作用,则射线的原点和方向也需要在同一空间中定义。 例如,如果一条射线具有原点 (0, 0, 0) 和方向 (0, 0, -1),其中这些数字代表世界空间坐标系中的坐标,那么射线和圆盘将在 (0, 0, - 5)相交。 如图 3 所示。

您可以在计算 3D 点的像素坐标一课中找到有关空间的更多信息。
看待我们试图解决的问题的另一种方式是从相机开始。 我们知道图像平面距离世界原点正好一个单位,并沿负 z 轴对齐。 我们也知道图像是方形的,因此图像投影到的图像平面部分也必然是方形的。 由于我们将很快解释的原因,该投影区域的尺寸为 2 x 2 个单位(图 2)。 我们也知道光栅图像是由像素组成的。 我们需要的是找到这些像素在栅格空间中的坐标与在世界空间中表示的相同像素的坐标之间的关系。 此过程需要几个步骤,如图 5 所示。

我们首先需要使用帧的尺寸来标准化这个像素位置。 像素的新归一化坐标据说是在 NDC 空间(代表归一化设备坐标)中定义的:

\large PixelNDC_{x}=\frac{Pixel_{x}^{}+ 0.5}{ImageWidth}, PixelNDC_{y}=\frac{Pixel_{y}^{}+ 0.5}{ImageHidth},

请注意,我们向像素位置添加了一个小的偏移 (0.5),因为我们希望最终的相机光线穿过像素的中间。 NDC 空间中表示的像素坐标在 [0,1] 范围内(是的,光线追踪中的 NDC 空间与光栅化世界中的 NDC 空间不同,后者通常映射到 [-1,1] 范围内)。 正如您在图 2 中所见,胶片或图像平面以世界原点为中心。 换句话说,位于图像左侧的像素应具有负 x 坐标,而位于右侧的像素应具有正 x 坐标。 相同的逻辑适用于 y 轴。 位于 x 轴定义的线上方的像素应具有正 y 坐标,而位于下方的像素应具有负 y 坐标。 我们可以通过将当前在 [0:1] 范围内的归一化像素坐标重新映射到 [-1:1] 范围来纠正这一点:

PixelScreen_{x} = 2* PixelNDC_{x}-1 ; PixelScreen_{y} = 2* PixelNDC_{y}-1

但是请注意,使用此等式,  Pixel Remapped_{y}对于位于 x 轴上方的像素为负,对于位于下方的像素为正(而应该相反)。 下面的公式将纠正这个问题:

PixelScreen_{y} = 1- 2* PixelNDC_{y}

该值现在从 1 到 -1 不等,因为 Pixely从 0 到 ImageWidth不等。 以这种方式表示的坐标被称为在屏幕空间中定义的。

(figure5:将像素中间点的坐标转换为世界坐标需要几个步骤。 该点的坐标首先在光栅空间表示(像素坐标加上0.5的偏移量),然后转换到NDC空间(坐标重新映射到范围[0,1]),然后转换到屏幕空间(NDC坐标 被重新映射到 [-1,1])。 应用最终的相机到世界转换 4x4 矩阵将屏幕空间中的坐标转换为世界空间。)

直到现在,我们都假设图像是方形的。 考虑图像纵横比非常简单。 现在让我们看一下图像尺寸为 7 x 5 像素的情况(它是一个小图像,但仍然是图像)。 将宽度除以图像的高度给出值 1.4。 当像素坐标在屏幕空间中定义时,它们在 [-1, 1] 范围内。 然而,沿 x 轴 (7) 的像素多于沿 y 轴 (5) 的像素,因此像素沿垂直轴被挤压和拉长(见图 6)。 为了使它们再次方形(像素应该是),我们需要将像素的 x 坐标乘以图像纵横比,在这种情况下,为 1.4(再次参见图 6)。 请注意,此操作使 y 像素坐标(在屏幕空间中)保持不变。 它们仍然在 [-1,1] 范围内,但 x 像素坐标现在在 [-1.4, 1.4] 范围内(更普遍的是 [-纵横比,纵横比])

最后,我们需要考虑视野(field of view)。 请注意,到目前为止,屏幕空间中定义的任何点的 y 坐标都在 [-1, 1] 范围内。 我们还知道图像平面距离相机原点 1 个单位。 如果我们从侧视图查看相机设置,我们可以通过将相机的原点连接到胶片平面的顶部和底部边缘来绘制一个三角形。 因为我们知道从相机原点到胶片平面的距离(1 个单位)和胶片平面的高度(2 个单位,因为它从 y=1 到 y=-1)我们可以使用一些简单的三角函数来找到角度 直角三角形 ABC 是垂直角 α (alpha) 的一半,我们感兴趣的角是:

 换言之,特定情况下的视场或角度α为90度。 现在请注意,要计算直线 BC 的长度,我们只需要计算角度 α 除以 2 的切线:

 记住几何课中的符号 ||V|| 表示向量 V 的长度。我们还可以观察到,对于大于 90 度的 α 值,||BC|| 大于 1,对于小于 90 度的值,||BC|| 小于1。例如,如果α=60,则tan(60/2)=0.57,如果α=110,则tan(110/2)=1.43。 因此,我们可以将屏幕像素坐标(目前包含在 [-1, 1] 范围内)乘以这个数字来放大或缩小它们。 你可能已经猜到了,这个操作改变了我们看到的场景的多少,相当于放大(当视野减小时我们看到的场景变少)和缩小(当 视野增加)。 总之,我们可以用角度α来定义相机的视野,并将屏幕像素坐标乘以这个角度的切线除以二的结果(如果这个角度用度数表示,不要忘记 将其转换为弧度):

此时,原始像素坐标相对于相机的图像平面表示。 它们已经被归一化,在 [-1:1] 之间重新映射,乘以图像纵横比,乘以视场角 α 除以 2 的切线。 这个点被称为在相机空间中,因为它的坐标是 以相机的坐标系表示。 当相机处于默认位置时,相机的坐标系和世界坐标系是对齐的。 该点位于距相机原点 1 个单位的图像平面上,但请记住,相机也沿负 z 轴对齐。 因此,我们可以将像素在图像平面上的最终坐标表示为: 

这为我们提供了图像中像素在相机图像平面上的位置 P (PcameraSpace)。 从那里,我们可以通过将光线的原点定义为相机的原点(我们称之为点 O)并将光线的方向定义为归一化向量 OP(图 8)来计算该像素的光线。 矢量 OP 只是图像平面上点的位置减去相机原点。 当相机处于默认位置时,相机原点和世界笛卡尔坐标系是相同的,因此点 O 只是 (0, 0, 0)。

在伪代码中我们得到(检查最终的 C++ 实现):

float imageAspectRatio = imageWidth / (float)imageHeight; // assuming width > height 
float Px = (2 * ((x + 0.5) / imageWidth) - 1) * tan(fov / 2 * M_PI / 180) * imageAspectRatio; 
float Py = (1 - 2 * ((y + 0.5) / imageHeight) * tan(fov / 2 * M_PI / 180); 
Vec3f rayOrigin(0); 
Vec3f rayDirection = Vec3f(Px, Py, -1) - rayOrigin; // note that this just equal to Vec3f(Px, Py, -1); 
rayDirection = normalize(rayDirection); // it's a direction so don't forget to normalize 

最后,我们希望能够从任何特定的角度渲染场景图像。将相机从其原始位置(以世界坐标系的原点为中心并沿 z 轴负方向对齐)移动后,您可以使用 4x4 矩阵表示相机的平移和旋转值。通常这个矩阵称为相机到世界矩阵(它的逆矩阵称为世界到相机矩阵)。如果我们将这个相机到世界矩阵应用于我们的点 O 和 P 那么向量 ||O'P'|| (其中 O' 是点 O,P' 是由相机到世界矩阵变换的点 P)表示世界空间中光线的归一化方向(图 8)。将相机到世界变换应用于 O 和 P 将这两个点从相机空间转换到世界空间。另一种选择是在相机处于其默认位置(向量 OP)时计算光线方向,并将相机到世界矩阵应用于该向量。

请注意相机坐标系如何随相机移动。我们的伪代码可以很容易地修改以解决相机变换(旋转和平移,不特别推荐缩放相机):

 (我们可以在空间中移动相机以根据需要对场景进行构图。 相机的最终位置和方向可以用一个 4x4 矩阵表示,我们通常称之为相机到世界的变换矩阵。 如果我们知道 0(相机的原点,也是世界坐标系的原点)和 P(光线穿过的像素在字空间中的位置),我们可以通过乘以 O 和 P 由相机到世界相机矩阵。 最后,光线方向可以计算为 P-O)

float imageAspectRatio = imageWidth / imageHeight; // assuming width > height 
float Px = (2 * ((x + 0.5) / imageWidth) - 1) * tan(fov / 2 * M_PI / 180) * imageAspectRatio; 
float Py = (1 - 2 * ((y + 0.5) / imageHeight) * tan(fov / 2 * M_PI / 180); 
Vec3f rayOrigin = Point3(0, 0, 0); 
Matrix44f cameraToWorld; 
cameraToWorld.set(...); // set matrix 
Vec3f rayOriginWorld, rayPWorld; 
cameraToWorld.multVectMatrix(rayOrigin, rayOriginWorld); 
cameraToWorld.multVectMatrix(Vec3f(Px, Py, -1), rayPWorld); 
Vec3f rayDirection = rayPWorld - rayOriginWorld; 
rayDirection.normalize(); // it's a direction so don't forget to normalize 

为了计算最终图像,我们需要使用我们刚刚描述的方法为帧的每个像素创建一条光线,并测试这些光线中的任何一条是否与场景中的几何体相交。 不幸的是,在本系列课程中,我们还没有到可以计算光线和对象之间的交点的程度,但这将是接下来两课的主题。

Source Code

本课的源代码只是一个简单的例子,说明如何为图像的每个像素生成光线。该代码遍历图像的所有像素(第 13-14 行),并计算当前像素的射线。我们在一行代码中组合了本章中描述的所有重新映射步骤。原始 x 像素坐标除以图像宽度以将初始坐标重新映射到范围 [0,1]。然后将结果值重新映射到范围 [-1,1],由比例变量(第 9 行)和图像纵横比(第 10 行)缩放。像素 y 坐标以类似的方式转换,但请记住,y 归一化坐标需要翻转(第 16 行)。在此过程结束时,我们可以使用转换后的点 x 和 y 坐标创建一个向量。这个向量的 z 坐标设置为负一(第 18 行):默认情况下,相机向下看负 z 轴。得到的向量最终由相机到世界的相机进行转换并归一化。相机的原点也由相机到世界的矩阵转换(第 12 行)。我们最终可以将转换到世界空间的光线方向和原点传递给 rayCast 函数。

void render( 
    const Options &options, 
    const std::vector> &objects, 
    const std::vector> &lights) 
{ 
    Matrix44f cameraToWorld; 
    Vec3f *framebuffer = new Vec3f[options.width * options.height]; 
    Vec3f *pix = framebuffer; 
    float scale = tan(deg2rad(options.fov * 0.5)); 
    float imageAspectRatio = options.width / (float)options.height; 
    Vec3f orig; 
    cameraToWorld.multVecMatrix(Vec3f(0), orig); 
    for (uint32_t j = 0; j < options.height; ++j) { 
        for (uint32_t i = 0; i < options.width; ++i) { 
            float x = (2 * (i + 0.5) / (float)options.width - 1) * imageAspectRatio * scale; 
            float y = (1 - 2 * (j + 0.5) / (float)options.height) * scale; 
            Vec3f dir; 
            cameraToWorld.multDirMatrix(Vec3f(x, y, -1), dir); 
            dir.normalize(); 
            *(pix++) = castRay(orig, dir, objects, lights, options, 0); 
        } 
    } 
 
    // Save result to a PPM image (keep these flags if you compile under Windows)
    std::ofstream ofs("./out.ppm", std::ios::out | std::ios::binary); 
    ofs << "P6\n" << options.width << " " << options.height << "\n255\n"; 
    for (uint32_t i = 0; i < options.height * options.width; ++i) { 
        char r = (char)(255 * clamp(0, 1, framebuffer[i].x)); 
        char g = (char)(255 * clamp(0, 1, framebuffer[i].y)); 
        char b = (char)(255 * clamp(0, 1, framebuffer[i].z)); 
        ofs << r << g << b; 
    } 
 
    ofs.close(); 
 
    delete [] framebuffer; 
} 

在接下来的课程中,我们将展示如何通过调用函数 rayCast 将主光线投射到场景中,该函数将光线原点和方向作为参数(以及其他内容,例如对象和灯光列表等)并返回一种颜色。如果光线没有击中任何物体,则该函数将返回背景的颜色,否则返回相交点处对象的颜色。请注意,在遍历图像中的所有像素以计算它们的颜色之前,我们创建了一个帧缓冲区,用于存储 rayCast 函数的结果(第 7 行)。一旦图像中所有像素的所有光线都被跟踪,我们就可以将图像的结果存储到磁盘。不幸的是,在进入下一课之前,我们将无法实现 rayCast 函数。与此同时,我们将把光线方向转换成一种颜色,并为当前像素存储这种颜色(下面的第 8-9 行)。最终图像以 ppm rabbits 格式保存到磁盘(上面的第 25-34 行)。

Vec3f castRay( 
    const Vec3f &orig, const Vec3f &dir, 
    const std::vector<std::unique_ptr<Object>> &objects, 
    const std::vector<std::unique_ptr<Light>> &lights, 
    const Options &options, 
    uint32_t depth) 
{ 
    Vec3f hitColor = (dir + Vec3f(1)) * 0.5; 
    return hitColor; 
} 

在计算机图形学中,它们通常是使用不同方法获得相同结果的不同方式。 如果您查看其他渲染引擎的源代码,您可能会发现将光线从图像空间转换到世界空间的问题可以通过多种不同的方式解决。 然而,无论采用何种方法,结果都应该始终相同。

比如我们可以这样看问题:像素坐标不需要归一化(换言之,从像素坐标转换到NDC再到屏幕空间)。 我们可以使用以下等式计算射线方向:

 其中 x 和 y 是像素的坐标,fov 是垂直视野。 请记住,dz 是负数,因为默认情况下相机沿负 z 轴定向。 然后如果我们对这个向量进行归一化,我们得到的结果就好像我们已经通过光栅到 NDC,以及 NDC 到屏幕的变换一样。 如果我们将此光线方向转换到世界空间(我们将向量 d 乘以相机到世界的矩阵),我们得到:

 Which we can re-write as (equation 1):

换句话说,如果我们知道相机到世界的矩阵,我们可以预先计算 w' 向量,并使用等式 1 计算词空间中的光线方向,然后对结果向量进行归一化。 在伪代码中:

向量 w' 只需要计算一次,并且每次我们需要计算新的光线方向时都会重新使用。 向量 u、v 和 w 只是相机到世界矩阵的第一个、第二个和第三个向量(如果使用行主矩阵,则为前三行)。 

Summary: We Are Almost Ready to Render our First Image

随着我们在这一系列课程中的进展,将能够将我们迄今为止学到的所有不同技术用于创建一个基本的功能性光线追踪器(功能性我们的意思是渲染一些几何图形并将生成的图像保存到磁盘上的文件 )。 为了得到这个结果,我们现在所缺少的就是学习光线-几何相交,这是下一课的主题。

猜你喜欢

转载自blog.csdn.net/Vpn_zc/article/details/121388748