三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

GAMES101作业8:光线追踪渲染器实现详解与核心算法解析

GAMES101作业8:光线追踪渲染器实现详解与核心算法解析

1. 项目概述:从光线追踪到渲染方程

如果你正在学习计算机图形学,那么“games101——作业8”这个标题对你来说一定不陌生。这通常指的是由闫令琪教授主讲的《现代计算机图形学入门》(GAMES101)课程中的第八次编程作业。这个作业是整个课程的一个关键转折点,它标志着我们从光栅化(Rasterization)的“旧世界”,正式迈入了光线追踪(Ray Tracing)的“新世界”。对于很多同学来说,这是第一次亲手实现一个基础但完整的光线追踪渲染器,从简单的光线生成、与场景求交,到最终渲染出带有阴影和反射效果的图像,整个过程充满了挑战与成就感。

这个作业的核心目标,是让你理解并实践光线追踪的基本框架。它解决的正是光栅化难以处理的全局光照效果问题,比如软阴影、光滑表面的镜面反射、以及光线在透明物体中的折射。通过完成这个作业,你不仅能巩固对光线、材质、相交等基础概念的理解,更能亲手搭建起一个微型渲染管线,看着自己写的代码将一个个简单的几何体(如球体、三角形)渲染成具有真实感图像。无论你是图形学新手,还是有一定基础想深入理解渲染原理,这个作业都是一块极佳的“敲门砖”。接下来,我将以一个过来人的视角,拆解这个作业的完整实现思路、核心代码细节以及那些容易踩坑的地方。

2. 作业框架与核心思路拆解

2.1 光线追踪的基本流程

在动手写代码之前,我们必须先理清光线追踪的“流水线”。与光栅化逐个处理三角形不同,光线追踪是从相机出发,穿过屏幕上的每一个像素,向场景发射一条光线(Ray),然后追踪这条光线的命运。作业8通常会提供一个基础的框架,我们需要填充几个关键函数。整个流程可以概括为以下几个步骤:

  1. 生成主光线(Primary Ray):对于图像上的每个像素(i, j),我们需要计算一条从相机原点(Eye Position)出发,穿过该像素对应虚拟屏幕位置的光线。这涉及到相机坐标系、世界坐标系和屏幕空间之间的转换。你需要理解视锥体(Frustum)的概念,以及如何利用相机的参数(位置、朝向、视野角FOV)来计算光线的方向。
  2. 场景求交(Scene Intersection):得到光线后,我们需要判断这条光线是否与场景中的任何物体相交。作业场景通常由球体(Sphere)和三角形(Triangle)组成。因此,你需要实现光线与球体的求交算法,以及光线与三角形的求交算法(通常使用Möller-Trumbore算法,因为它高效且稳定)。
  3. 着色计算(Shading):如果光线击中了某个物体,我们就需要计算该交点处的颜色。这就是着色。作业8通常要求实现基础的布林-冯(Blinn-Phong)光照模型。这意味着你需要考虑环境光(Ambient)、漫反射(Diffuse)和高光(Specular)三个分量。计算这些分量需要知道交点位置、法线、材质属性(环境光系数、漫反射系数、高光系数、反光度)以及光源信息。
  4. 递归追踪(Recursive Tracing):为了实现反射效果,光线追踪需要进行递归。当光线击中一个具有反射属性的物体时(比如一个镜面球),我们不能简单地停止。相反,我们需要在交点处,根据入射方向和法线,计算出完美的反射方向,然后生成一条新的反射光线(Recursive Ray),并继续追踪它。这个过程会重复,直到达到设定的最大递归深度,或者光线的贡献变得微不足道。递归追踪是光线追踪实现全局效果(如镜面反射、折射)的灵魂。

2.2 框架代码结构解析

典型的作业8框架代码会包含以下几个核心类,理解它们的关系至关重要:

  • Ray:表示一条光线,通常包含两个成员变量:原点Vector3f orig和方向Vector3f dir。方向向量最好是单位向量(归一化),这能简化很多后续计算。
  • Scene:场景的容器,存储了所有的物体(std::vector<Object*>)和光源(std::vector<Light*>)。你的rayCastrender函数会遍历场景中的所有物体进行求交测试。
  • Object基类及其派生类(Sphere,Triangle:所有可被光线击中的物体的基类。它必须定义一个虚函数intersect(const Ray& ray, float& tnear, uint32_t& index, Vector2f& uv)或其变体。tnear用于存储最近的相交距离,indexuv用于三角形网格中获取具体的三角形和重心坐标。
  • Material:定义物体的材质属性,如环境光颜色Ka、漫反射颜色Kd、高光颜色Ks、反光度Ns,以及反射系数Kr等。
  • Renderer:渲染器核心,包含主要的Render函数。在这个函数里,你会遍历每个像素,生成主光线,调用castRay函数,并将返回的颜色值写入帧缓冲区。

你的主要工作,就是将这些类之间的逻辑串联起来,实现上述流程中的关键算法。

3. 核心算法实现细节与难点

3.1 光线与球体求交

这是第一个需要实现的求交算法。给定一条光线Ray(orig, dir)和一个球体Sphere(center, radius),求交公式基于几何关系。将光线方程P(t) = orig + t * dir代入球面方程(P - center)^2 = R^2,得到一个关于t的一元二次方程。解这个方程:

  • 如果判别式小于0,无实根,光线与球不相交。
  • 如果判别式等于0,光线与球相切,有一个重根。
  • 如果判别式大于0,有两个根t0t1(假设t0 < t1)。我们需要的是沿光线方向第一个有效的交点,即t值大于某个微小阈值(如EPSILON,用于避免自相交)的最小正根。如果t0大于阈值,则取t0;否则检查t1

求交成功后,除了返回t值,还需要计算交点处的法线:normal = (hitPoint - center).normalized()。法线方向需要小心处理,通常我们让法线指向外侧,这对于光照计算正确性很重要。

3.2 光线与三角形求交(Möller-Trumbore算法)

这是实现三角形网格渲染的基础。Möller-Trumbore算法直接利用重心坐标(Barycentric Coordinates)进行求解,非常高效。算法将求交问题转化为求解一个线性方程组。

给定光线Ray(O, D)和三角形三个顶点V0, V1, V2。 设E1 = V1 - V0,E2 = V2 - V0,T = O - V0。 令P = D.cross(E2),Q = T.cross(E1)

计算标量:det = P.dot(E1)。如果det的绝对值接近0(小于EPSILON),说明光线与三角形平面平行,无交点。 否则,计算:t = Q.dot(E2) / detu = P.dot(T) / detv = Q.dot(D) / det

交点的有效性条件t > EPSILONu >= 0v >= 0(u + v) <= 1。 其中(u, v)就是交点在该三角形上的重心坐标,第三个坐标w = 1 - u - v。交点位置为P = w*V0 + u*V1 + v*V2,法线可以通过三个顶点的法线插值得到(如果提供了顶点法线),或者直接使用三角形面法线(E1.cross(E2)).normalized()

注意EPSILON的选择很重要。太大可能会错过有效交点,太小则容易因浮点数精度问题导致错误。通常选择1e-41e-6。在求交函数中,判断det是否为0时,应使用std::fabs(det) < EPSILON

3.3 布林-冯光照模型实现

castRay函数中,当找到最近的交点后,就需要计算该点的颜色。布林-冯模型是作业的标准要求。

对于每个光源(假设是点光源Light(pos, intensity)),计算步骤如下:

  1. 阴影测试:这是实现阴影的关键。从交点hitPoint向光源位置light.pos发射一条新的光线(阴影光线shadowRay)。注意,阴影光线的原点需要沿着法线方向稍微偏移一点(例如hitPoint + EPSILON * N),以避免自遮挡(Shadow Acne)。然后判断这条阴影光线是否与场景中的任何物体相交。如果相交距离小于到光源的距离,则说明该点在阴影中,该光源的漫反射和高光贡献为0。
  2. 计算光照向量L = (light.pos - hitPoint).normalized()N是交点处的单位法线。
  3. 漫反射项diffuse = Kd * light.intensity * std::max(0.0f, N.dot(L))Kd是材质的漫反射系数(颜色)。
  4. 计算高光项:需要半程向量H = (L + V).normalized(),其中V是视线方向(从交点到相机原点的反向,即-ray.dir(eyePos - hitPoint).normalized())。specular = Ks * light.intensity * std::pow(std::max(0.0f, N.dot(H)), Ns)Ks是高光系数,Ns是反光度,控制高光点的集中程度。
  5. 环境光项ambient = Ka * ambientLight,其中ambientLight是一个全局的环境光强度。环境光项通常与光源无关。

最终该光源的贡献是diffuse + specular。将所有光源的贡献累加,再加上环境光项,就得到了该交点的直接光照颜色。

3.4 递归反射的实现

递归反射让光线追踪有了“灵魂”。在计算完直接光照颜色color_direct后,我们需要检查当前物体的材质是否具有反射属性(即反射系数Kr > 0)。

  1. 计算反射方向:根据入射方向I(即-ray.dir,注意是朝向交点的方向)和法线N,计算完美的反射方向R。公式为:R = I - 2 * (I.dot(N)) * N。确保IN都是单位向量。
  2. 生成反射光线:和阴影光线一样,反射光线的原点也需要偏移:reflectionRayOrig = hitPoint + EPSILON * N。方向就是R
  3. 递归调用:以reflectionRay为参数,递归调用castRay函数,并传入当前递归深度depth + 1。在函数入口,必须判断depth是否超过最大递归深度(如5或8),如果超过,则直接返回黑色或背景色,终止递归。
  4. 颜色混合:递归调用返回的是反射光线看到的颜色color_reflect。最终该像素的颜色是直接光照颜色和反射颜色的加权和:color_final = color_direct + Kr * color_reflect。这里Kr是一个0到1之间的系数,可以理解为反射率。如果Kr=1,就是完美的镜面;如果Kr<1,则物体会吸收一部分光,反射一部分。

实操心得:递归深度不宜设置过大,否则渲染时间会指数级增长。通常4-5层就能得到很好的镜面反射效果。调试时,可以先设置深度为1(只计算直接光照),确保基础颜色正确,再开启递归。

4. 代码实现与调试实录

4.1 主渲染循环与castRay函数

Render函数是入口,它通常是一个嵌套循环,遍历图像的每个像素(i, j)。对于每个像素,你需要:

  1. 将像素坐标(i, j)转换为屏幕空间坐标(x, y),范围通常是[-1, 1][0, 1],具体取决于框架定义。
  2. 利用相机参数(位置、朝向、上向量、视野角FOV、宽高比),计算该像素在相机空间中的方向向量。这是一个标准过程:先计算相机空间的基向量(前front、右right、上up),然后像素方向dir = front + (x * right) + (y * up),最后归一化。
  3. 用相机位置和计算出的方向,构造主光线Ray
  4. 调用castRay(ray, scene, depth)函数,初始深度为0。
  5. castRay返回的颜色值(通常是一个Vector3f,代表RGB)进行可能的伽马校正(如pow(color, 1/2.2))后,写入图像缓冲区。

castRay函数是核心,伪代码如下:

Vector3f castRay(const Ray& ray, const Scene& scene, int depth) { if (depth > maxDepth) return Vector3f(0); // 终止递归 Intersection inter; // 一个结构体,存储最近的交点信息 if (!scene.intersect(ray, inter)) { return backgroundColor; // 未击中任何物体,返回背景色 } // 计算直接光照(布林-冯模型) Vector3f hitPoint = ray.orig + ray.dir * inter.tNear; Vector3f N = inter.normal; // 交点法线 Vector3f V = -ray.dir; // 视线方向 Vector3f colorDirect(0); for (auto& light : scene.lights) { // 阴影测试 Vector3f lightDir = (light.position - hitPoint).normalized(); Ray shadowRay(hitPoint + EPSILON * N, lightDir); Intersection shadowInter; if (scene.intersect(shadowRay, shadowInter)) { float lightDistance = (light.position - hitPoint).norm(); if (shadowInter.tNear < lightDistance) { continue; // 在阴影中,跳过该光源 } } // 计算该光源的漫反射和高光贡献,累加到 colorDirect // ... 布林-冯计算代码 ... } colorDirect += inter.material->Ka * scene.ambientLight; // 加上环境光 // 递归计算反射 Vector3f colorReflect(0); if (depth < maxDepth && inter.material->Kr > 0) { Vector3f R = reflect(ray.dir, N); // 计算反射方向 Ray reflectionRay(hitPoint + EPSILON * N, R); colorReflect = castRay(reflectionRay, scene, depth + 1); } // 最终颜色 return colorDirect + inter.material->Kr * colorReflect; }

4.2 常见Bug与调试技巧

在实现过程中,你几乎一定会遇到下面这些问题:

  1. 图像全黑或颜色异常

    • 检查光线方向:确保生成的主光线方向向量是单位向量,并且方向正确(从相机指向屏幕)。一个快速调试方法是,在castRay中,如果未击中物体,不返回背景色,而是返回(ray.dir + 1.0f) * 0.5f(将方向向量从[-1,1]映射到[0,1])。这样你应该能看到一个彩色的渐变背景,这能验证你的光线生成和相机坐标系转换是否正确。
    • 检查求交函数:确保求交函数返回正确的t值和法线。可以单独渲染一个球体,并用法线作为颜色输出(color = (N + 1.0f) * 0.5f),看看球体表面颜色渐变是否平滑、符合预期。
  2. 阴影错误(Shadow Acne)

    • 现象:物体表面出现密密麻麻的黑色噪点或条纹。
    • 原因:阴影光线的起点恰好落在物体表面上,由于浮点数精度问题,它可能会立刻“击中”自己,误判为处于阴影中。
    • 解决:必须对阴影光线和反射光线的起点进行偏移。沿着法线方向偏移一个微小的量EPSILON * NEPSILON的值需要权衡,太小可能无法避免自相交,太大会导致阴影与物体分离(Peter Panning现象)。通常1e-31e-4是个不错的起点。
  3. 反射效果缺失或错误

    • 检查反射方向计算reflect函数是否正确?可以手动计算验证:R = I - 2 * dot(I, N) * N,其中I指向交点的入射方向(即-ray.dir)。
    • 检查递归终止条件:确保设置了最大递归深度maxDepth,并在函数开头判断。没有这个条件,程序会陷入无限递归直至栈溢出。
    • 检查颜色混合:反射颜色colorReflect需要乘以材质的反射系数Kr后再与直接光照颜色相加。
  4. 性能问题

    • 最简单的光线追踪器对每个像素的每条光线(主光线、阴影光线、反射光线)都会与场景中的所有物体进行求交测试,复杂度是O(像素数 * 光线数 * 物体数)。当场景复杂时,渲染会非常慢。
    • 作业级优化:在求交函数中,尽早进行快速拒绝测试。例如,对于球体,可以先计算光线原点到球心的向量,并判断其长度与光线方向的关系,进行粗略的剔除。但这只是杯水车薪。
    • 理解瓶颈:作业的目的在于理解原理,而非追求极致性能。真正的生产级渲染器会使用加速结构(如BVH、KD-Tree)来将求交复杂度从O(N)降到O(log N),这是后续课程或自学的重要内容。

5. 效果验证与进阶思考

当你成功实现上述所有功能后,渲染出的图像应该包含:正确着色的物体、清晰的阴影(硬阴影)、以及光滑球体上的镜面反射效果。你可以通过对比课程提供的标准结果图来验证。

完成基础要求后,你可以尝试一些扩展,这能极大地加深理解:

  • 实现折射与透明材质:这需要计算折射方向(使用斯涅尔定律),并处理全反射情况。颜色混合会变得更复杂,需要考虑反射和折射的菲涅尔效应(Fresnel Effect)。
  • 实现抗锯齿:在每个像素内随机采样多个点(比如4个),分别发射光线,然后将颜色平均。这能有效消除图像的锯齿边缘。
  • 实现景深:模拟真实相机光圈,让相机原点在一个小圆盘内随机分布,这能产生背景虚化的效果。
  • 替换更真实的光照模型:尝试实现微表面模型(如Cook-Torrance BRDF)来代替布林-冯模型。

完成作业8,就像是亲手搭建了一个微型宇宙的物理规则。从发射第一道光线开始,到最终汇聚成一幅图像,每一个像素都承载着光线与物质相互作用的物理模拟。这个过程可能会充满调试的艰辛,但当第一张带有正确阴影和反射的图片呈现在你面前时,那种透过代码窥见物理世界运行规律的喜悦,是无与伦比的。图形学的魅力,就在于这种在数学、物理与艺术之间的精准舞蹈。这个作业是你舞蹈的第一步,走稳了,后面的路才会更顺畅。

← 返回列表