需要预先学习:


1. 光线追踪

光栅化 处理的是"每个三角形覆盖哪些像素",从几何走向图像;着色与渲染管线 里的着色是局部的——每个点只依赖光源与自身材质,不知道其他物体的存在。于是阴影要靠 ShadowMapping 单独补救,镜面反射要靠额外的 pass 画,多次反弹的间接光只能用一个常数环境光糊弄过去。

光线追踪(ray tracing)换一个方向思考:既然光从光源出发,经过物体的反射、折射,最终进入人眼,那么从摄像机出发,沿像素方向把这条路径倒着走一遍,同样能算出像素的颜色。它模拟的是光的传播本身,全局光照的效果(阴影、反射、折射、多次反弹)自然地包含在流程里,代价是计算量大得多。本篇以经典的 Whitted 风格光线追踪(Whitted-style ray tracing)为主线,介绍光线与物体的求交、递归追踪与加速结构。


2. 光线的定义


2.1 光线的数学表示

一条光线(ray)由一个起点和一个方向确定,写成参数形式:

P(t)=O+tD,t≥0\mathbf{P}(t) = \mathbf{O} + t\mathbf{D}, \quad t \ge 0

  • O\mathbf{O}:起点(origin)
  • D\mathbf{D}:方向(direction),通常归一化
  • tt:参数,表示沿方向走了多远

tt 限制在非负区间,是因为光线只有一个端点——t<0t < 0 的解位于起点"身后",没有意义。光线与物体的求交(intersection),就是解方程 P(t)\mathbf{P}(t) 等于物体表面方程的最小正 tt;tt 最小的命中点就是光线最先碰到的表面。


2.2 从像素发射光线

光栅化的逆过程:屏幕上有 W×HW \times H 个像素,每个像素从摄像机位置发出一条光线,穿过该像素的中心。这条光线称为主光线(primary ray,也称视线光线)。主光线命中场景中某个表面后,从命中点出发再派生出阴影光线、反射光线、折射光线(见后文)。

注:每个像素发射一条光线,得到的是像素中心的颜色,与光栅化"用像素中心采样"是一致的——它同样是一次采样,同样有走样问题。多发几条光线取平均(每条对应像素内的一个子采样点)就是光线追踪版的 MSAA,称为超采样。


3. 光线与物体求交

求交是光线追踪的核心操作,一条光线要依次与场景中每个物体求交,取最近的命中点。先从最简单的球开始。


3.1 光线与球求交

球心在 C\mathbf{C}、半径为 rr 的球面方程为:

(p−C)2=r2(\mathbf{p} - \mathbf{C})^2 = r^2

把 p=O+tD\mathbf{p} = \mathbf{O} + t\mathbf{D} 代入,展开得到关于 tt 的一元二次方程:

(D⋅D) t2+2D⋅(O−C) t+(O−C)2−r2=0(\mathbf{D} \cdot \mathbf{D})\,t^2 + 2\mathbf{D} \cdot (\mathbf{O} - \mathbf{C})\,t + (\mathbf{O} - \mathbf{C})^2 - r^2 = 0

判别式 Δ\Delta 含义 结果
Δ<0\Delta < 0 光线与球不相交 无解
Δ=0\Delta = 0 光线与球相切 一个解
Δ>0\Delta > 0 光线穿过球 两个解,取最小正根

求根后取最小的正根:两个交点中先碰到的是进点,后碰到的是出点,光线最先命中的是进点。若两个根都小于 0,球在光线起点身后,视为未命中。

// 伪代码:光线与球求交,命中返回 t,未命中返回 -1
float intersect_ray_sphere(const Ray& ray, const Sphere& sphere)
{
    Vec3 oc = ray.origin - sphere.center;

    float a = dot(ray.dir, ray.dir);             // 方向通常已归一化,a == 1
    float b = 2.0f * dot(ray.dir, oc);
    float c = dot(oc, oc) - sphere.radius * sphere.radius;

    float disc = b * b - 4.0f * a * c;           // 判别式
    if (disc < 0.0f) return -1.0f;

    float t = (-b - sqrt(disc)) / (2.0f * a);    // 较小根:进点
    if (t > 0.0f) return t;
    return -1.0f;
}

注:解二次方程有更稳定的数值写法,但思路不变——求最小正根。实践中的误差主要来自判别式接近 0 时开方引入的精度损失,可以用 quadratic discriminant 的标准技巧缓解,这里不展开。


3.2 光线与三角形求交

三角形是场景的基本图元(见 光栅化),求交通常分两步:先与三角形所在平面求交,再判断交点是否在三角形内部。

两步法

平面由法线 n\mathbf{n} 和面上一点 P0\mathbf{P_0} 确定,平面方程为 n⋅(p−P0)=0\mathbf{n} \cdot (\mathbf{p} - \mathbf{P_0}) = 0。代入光线方程解出:

t=n⋅(P0−O)n⋅Dt = \frac{\mathbf{n} \cdot (\mathbf{P_0} - \mathbf{O})}{\mathbf{n} \cdot \mathbf{D}}

分母为 0 表示光线与平面平行(不相交,或整条在平面内)。求出交点后,用 光栅化 中介绍的重心坐标判断点是否在三角形内——但这里有一个效率问题:每个三角形都要算一次平面方程与点内测试,开销不小。

Möller–Trumbore 算法

Möller–Trumbore 算法把两步合并成一步:交点用三角形三个顶点的重心坐标(barycentric coordinates)表示:

O+tD=(1−b1−b2) P0+b1P1+b2P2\mathbf{O} + t\mathbf{D} = (1 - b_1 - b_2)\,\mathbf{P_0} + b_1\mathbf{P_1} + b_2\mathbf{P_2}

三个未知数 tt、b1b_1、b2b_2,三条方程(xx、yy、zz 分量各一条),用克莱姆法则(Cramer’s rule)直接解出:

t=(E1×E2)⋅T(E1×D)⋅E2,b1=(T×E2)⋅D(E1×D)⋅E2,b2=(E1×T)⋅D(E1×D)⋅E2t = \frac{(\mathbf{E_1} \times \mathbf{E_2}) \cdot \mathbf{T}}{(\mathbf{E_1} \times \mathbf{D}) \cdot \mathbf{E_2}}, \quad b_1 = \frac{(\mathbf{T} \times \mathbf{E_2}) \cdot \mathbf{D}}{(\mathbf{E_1} \times \mathbf{D}) \cdot \mathbf{E_2}}, \quad b_2 = \frac{(\mathbf{E_1} \times \mathbf{T}) \cdot \mathbf{D}}{(\mathbf{E_1} \times \mathbf{D}) \cdot \mathbf{E_2}}

其中 E1=P1−P0\mathbf{E_1} = \mathbf{P_1} - \mathbf{P_0}、E2=P2−P0\mathbf{E_2} = \mathbf{P_2} - \mathbf{P_0}、T=O−P0\mathbf{T} = \mathbf{O} - \mathbf{P_0}。命中条件:t>0t > 0 且 b1≥0b_1 \ge 0、b2≥0b_2 \ge 0、b1+b2≤1b_1 + b_2 \le 1——后三个条件正是"点在三角形内"的重心坐标表述。

// 伪代码:光线与三角形求交(Möller–Trumbore)
// 命中返回 t,未命中返回 -1;b1、b2 为重心坐标(可选输出)
float intersect_ray_triangle(const Ray& ray,
                             const Vec3& p0, const Vec3& p1, const Vec3& p2,
                             float* b1_out, float* b2_out)
{
    Vec3 e1 = p1 - p0;
    Vec3 e2 = p2 - p0;
    Vec3 tvec = ray.origin - p0;

    Vec3 pvec = cross(ray.dir, e2);
    float det = dot(e1, pvec);              // 三个方程的分母
    if (fabs(det) < 1e-8f) return -1.0f;    // 与平面平行

    float inv_det = 1.0f / det;
    float b1 = dot(tvec, pvec) * inv_det;
    if (b1 < 0.0f || b1 > 1.0f) return -1.0f;

    Vec3 qvec = cross(tvec, e1);
    float b2 = dot(ray.dir, qvec) * inv_det;
    if (b2 < 0.0f || b1 + b2 > 1.0f) return -1.0f;

    float t = dot(e2, qvec) * inv_det;
    if (t <= 0.0f) return -1.0f;

    if (b1_out) *b1_out = b1;
    if (b2_out) *b2_out = b2;
    return t;
}

注:重心坐标在命中后还有用——顶点属性(法线、纹理坐标)按重心坐标插值,就能得到命中点的表面属性,这与光栅化中片元属性的插值原理相同(见 光栅化)。


4. Whitted 光线追踪

有了求交,就可以搭完整的追踪框架。Whitted 风格由 Turner Whitted 于 1980 年提出,是经典递归式光线追踪,也是绝大多数商业渲染器的基础。


4.1 直接光照与阴影光线

主光线命中表面后,在命中点做一次局部着色(如 着色与渲染管线 中的 Blinn-Phong),但直接照明的三项都要过一道阴影检查:从命中点向光源发射阴影光线(shadow ray),如果它先撞到了别的物体,说明光源被挡住,该点处于阴影中,只保留环境光项。

注:光栅化管线里 ShadowMapping 用"从光源渲染深度图再比较"实现同样的判断(见 阴影);光线追踪里阴影是求交的自然副产品——向光源发射一条光线即可,不需要额外的 pass。阴影光线的 tt 只关心"是否命中、命中的 tt 是否小于光源距离",不关心具体命中点。


4.2 反射与折射

光滑表面还要考虑镜面反射:沿命中点关于法线的反射方向(reflection direction)继续发射反射光线(reflection ray),递归地追踪它看到什么颜色。反射方向的求法沿用 Phong 模型中的公式(见 Phong模型与Blinn-Phong模型):

r=2(n⋅l) n−l\mathbf{r} = 2(\mathbf{n} \cdot \mathbf{l})\,\mathbf{n} - \mathbf{l}

透明介质(玻璃、水面)还有折射(refraction):光线进入介质时方向发生偏折,由 斯涅耳定律(Snell’s law)决定:

nisin⁡θi=ntsin⁡θtn_i \sin\theta_i = n_t \sin\theta_t

nin_i、ntn_t 是两侧介质的折射率(refractive index,真空为 1,空气约 1.0,水约 1.33,玻璃约 1.5)。注意全反射:当光线从密介质射向疏介质且入射角超过临界角时,sin⁡θt>1\sin\theta_t > 1 无解,此时不折射、全部反射。

每层递归的贡献按比例加权:命中点颜色 = 直接光照 + 反射系数 × 反射光线颜色 + 折射系数 × 折射光线颜色。系数由菲涅尔效应(Fresnel effect)决定——掠射角下反射更强,垂直入射时透射更多(更精确的公式见 PBR 的菲涅尔项)。递归必须有终止条件,常用两个:递归深度上限(如 5 层),或贡献权重衰减到阈值以下。

// 伪代码:Whitted 光线追踪主循环与递归
// 返回光线沿路径累积的颜色
Color trace(const Ray& ray, int depth)
{
    // 1. 与场景所有物体求交,取最近的命中点 hit
    //    Hit 包含命中标志 hit、命中距离 t、命中点与法线等
    Hit hit = scene.intersect(ray);
    if (!hit.hit) return backgroundColor;

    // 2. 直接光照:向每个光源发阴影光线,未被遮挡才计入
    Color color = shade(hit, ray);          // Blinn-Phong 等局部着色

    // 3. 递归:反射光线
    if (hit.material.reflective && depth < MAX_DEPTH)
    {
        Vec3 r = reflect(ray.dir, hit.normal);
        color += hit.material.kr * trace(Ray(hit.pos, r), depth + 1);
    }

    // 4. 递归:折射光线(全反射时跳过)
    if (hit.material.transparent && depth < MAX_DEPTH)
    {
        Vec3 t;
        if (refract(ray.dir, hit.normal, hit.material.ior, t))
            color += hit.material.kt * trace(Ray(hit.pos, t), depth + 1);
    }

    return color;
}

// 主循环:每个像素发一条主光线
for (int y = 0; y < height; ++y)
    for (int x = 0; x < width; ++x)
        image[x][y] = trace(Ray(camera.pos, pixel_dir(x, y)), 0);

5. 加速结构

上面伪代码里的 scene.intersect 是性能瓶颈:每个像素每条光线都要遍历场景中所有三角形。一帧画面有几十万条光线、场景有几百万个三角形,直接相乘的代价完全不可接受,必须让光线跳过肯定不相交的物体。


5.1 包围盒

包围盒(bounding box,见 光栅化 中的同名概念)是加速的基石:先用一个简单形状(球、长方体)粗略框住物体,光线先与包围盒求交,不命中就直接跳过整个物体,命中才进入精细的三角形求交。场景中常用轴对齐包围盒(AABB,Axis-Aligned Bounding Box,各面分别平行于坐标平面),因为它的求交最简单:对三个轴分别计算光线进入、离开盒子的 tt 区间,取交集:

// 伪代码:光线与 AABB 求交(slab 法)
// 对每个轴求光线进入/离开盒子的 t 区间,三者取交集
bool intersect_ray_aabb(const Ray& ray, const AABB& box, float* t_hit)
{
    float t_min = -INFINITY, t_max = INFINITY;

    for (int axis = 0; axis < 3; ++axis)
    {
        // 该轴上盒子的两个 slab 平面
        float t1 = (box.min[axis] - ray.origin[axis]) / ray.dir[axis];
        float t2 = (box.max[axis] - ray.origin[axis]) / ray.dir[axis];
        if (t1 > t2) swap(t1, t2);          // 进入与离开

        t_min = max(t_min, t1);             // 交集:取各区间的下界最大
        t_max = min(t_max, t2);             // 上界最小
        if (t_min > t_max) return false;    // 交集为空,光线错过盒子
    }

    *t_hit = t_min > 0.0f ? t_min : t_max;  // 最近的合法命中
    return true;
}

5.2 BVH

光靠单个包围盒还不够——场景里有几百万个三角形,每个都先测一次包围盒仍然是线性扫描。层次包围盒(BVH,Bounding Volume Hierarchy)把包围盒组织成树:先把所有三角形按空间邻近性分成两组,各自求包围盒,再递归分组,直到每组的三角形数量少于阈值(如叶节点装 1~2 个三角形)。

构建(自顶向下):

// 伪代码:BVH 构建(自顶向下,按最长轴的中点分割)
Node* build_bvh(vector<Triangle> tris, int depth)
{
    Node* node = new Node;
    node->box = bounding_box_of(tris);      // 本组所有三角形的总包围盒

    if (tris.size() <= LEAF_SIZE || depth >= MAX_DEPTH)
    {
        node->tris = tris;                  // 叶节点:直接存三角形
        return node;
    }

    // 选包围盒最长的轴,按该轴的中点分成两组(更优做法见注)
    int axis = longest_axis(node->box);
    sort(tris, axis);                       // 按该轴坐标排序
    size_t mid = tris.size() / 2;

    node->left  = build_bvh({tris.begin(), tris.begin() + mid}, depth + 1);
    node->right = build_bvh({tris.begin() + mid, tris.end()}, depth + 1);
    return node;
}

遍历(光线求交):

// 伪代码:BVH 遍历——与节点包围盒求交,命中才进入子树
bool intersect_bvh(const Ray& ray, const Node* node, Hit* hit)
{
    if (!intersect_ray_aabb(ray, node->box)) return false;   // 整组跳过

    if (node->is_leaf)
    {
        // 叶节点:与其中的每个三角形精细求交
        // (调用前需把 hit->t 初始化为无穷大,表示“尚未命中”)
        for (const Triangle& tri : node->tris)
        {
            float t = intersect_ray_triangle(ray, tri);
            if (t > 0 && t < hit->t) *hit = make_hit(t, tri);
        }
        return hit->hit;
    }

    // 内部节点:先近后远递归,命中近子树后可用 t 裁剪远子树
    bool hit_left  = intersect_bvh(ray, node->left, hit);
    bool hit_right = intersect_bvh(ray, node->right, hit);
    return hit_left || hit_right;
}

注:构建时按"中点分割"只是最简单的策略,效果与三角形分布关系很大。更常用的启发式是 SAH(Surface Area Heuristic,表面积启发式):用包围盒的表面积估计光线命中两侧的概率,在多个候选分割位置里挑期望求交代价最小的那个。


5.3 光栅化与光线追踪对比

维度 光栅化 光线追踪
基本问题 每个三角形覆盖哪些像素 每个像素被哪些物体覆盖
绘制顺序 按深度测试决定可见性 按 tt 最小的交点决定可见性
全局光照 需要 ShadowMapping 等额外手段 阴影、反射、折射天然包含
计算量 与三角形数量线性相关,适合实时 与光线数 × 求交代价相关,适合离线
硬件加速 专用光栅化管线(GPU) RTX 等专用光追核心(近年才进入实时领域)
典型应用 游戏、实时预览 电影特效、离线渲染

两条路线近年正在融合:实时渲染用光栅化做基础着色,再对反射、阴影等少量光线做硬件加速的光线追踪(如 NVIDIA RTX);离线渲染则用光栅化做光线追踪的预处理(如光子映射)。"哪个更好"没有答案,它们是同一问题的两种采样方式。