光线追踪
需要预先学习:
1. 光线追踪
光栅化 处理的是"每个三角形覆盖哪些像素",从几何走向图像;着色与渲染管线 里的着色是局部的——每个点只依赖光源与自身材质,不知道其他物体的存在。于是阴影要靠 ShadowMapping 单独补救,镜面反射要靠额外的 pass 画,多次反弹的间接光只能用一个常数环境光糊弄过去。
光线追踪(ray tracing)换一个方向思考:既然光从光源出发,经过物体的反射、折射,最终进入人眼,那么从摄像机出发,沿像素方向把这条路径倒着走一遍,同样能算出像素的颜色。它模拟的是光的传播本身,全局光照的效果(阴影、反射、折射、多次反弹)自然地包含在流程里,代价是计算量大得多。本篇以经典的 Whitted 风格光线追踪(Whitted-style ray tracing)为主线,介绍光线与物体的求交、递归追踪与加速结构。
2. 光线的定义
2.1 光线的数学表示
一条光线(ray)由一个起点和一个方向确定,写成参数形式:
- :起点(origin)
- :方向(direction),通常归一化
- :参数,表示沿方向走了多远
限制在非负区间,是因为光线只有一个端点—— 的解位于起点"身后",没有意义。光线与物体的求交(intersection),就是解方程 等于物体表面方程的最小正 ; 最小的命中点就是光线最先碰到的表面。
2.2 从像素发射光线
光栅化的逆过程:屏幕上有 个像素,每个像素从摄像机位置发出一条光线,穿过该像素的中心。这条光线称为主光线(primary ray,也称视线光线)。主光线命中场景中某个表面后,从命中点出发再派生出阴影光线、反射光线、折射光线(见后文)。
注:每个像素发射一条光线,得到的是像素中心的颜色,与光栅化"用像素中心采样"是一致的——它同样是一次采样,同样有走样问题。多发几条光线取平均(每条对应像素内的一个子采样点)就是光线追踪版的 MSAA,称为超采样。
3. 光线与物体求交
求交是光线追踪的核心操作,一条光线要依次与场景中每个物体求交,取最近的命中点。先从最简单的球开始。
3.1 光线与球求交
球心在 、半径为 的球面方程为:
把 代入,展开得到关于 的一元二次方程:
| 判别式 | 含义 | 结果 |
|---|---|---|
| 光线与球不相交 | 无解 | |
| 光线与球相切 | 一个解 | |
| 光线穿过球 | 两个解,取最小正根 |
求根后取最小的正根:两个交点中先碰到的是进点,后碰到的是出点,光线最先命中的是进点。若两个根都小于 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 光线与三角形求交
三角形是场景的基本图元(见 光栅化),求交通常分两步:先与三角形所在平面求交,再判断交点是否在三角形内部。
两步法
平面由法线 和面上一点 确定,平面方程为 。代入光线方程解出:
分母为 0 表示光线与平面平行(不相交,或整条在平面内)。求出交点后,用 光栅化 中介绍的重心坐标判断点是否在三角形内——但这里有一个效率问题:每个三角形都要算一次平面方程与点内测试,开销不小。
Möller–Trumbore 算法
Möller–Trumbore 算法把两步合并成一步:交点用三角形三个顶点的重心坐标(barycentric coordinates)表示:
三个未知数 、、,三条方程(、、 分量各一条),用克莱姆法则(Cramer’s rule)直接解出:
其中 、、。命中条件: 且 、、——后三个条件正是"点在三角形内"的重心坐标表述。
// 伪代码:光线与三角形求交(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。阴影光线的 只关心"是否命中、命中的 是否小于光源距离",不关心具体命中点。
4.2 反射与折射
光滑表面还要考虑镜面反射:沿命中点关于法线的反射方向(reflection direction)继续发射反射光线(reflection ray),递归地追踪它看到什么颜色。反射方向的求法沿用 Phong 模型中的公式(见 Phong模型与Blinn-Phong模型):
透明介质(玻璃、水面)还有折射(refraction):光线进入介质时方向发生偏折,由 斯涅耳定律(Snell’s law)决定:
、 是两侧介质的折射率(refractive index,真空为 1,空气约 1.0,水约 1.33,玻璃约 1.5)。注意全反射:当光线从密介质射向疏介质且入射角超过临界角时, 无解,此时不折射、全部反射。
每层递归的贡献按比例加权:命中点颜色 = 直接光照 + 反射系数 × 反射光线颜色 + 折射系数 × 折射光线颜色。系数由菲涅尔效应(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,各面分别平行于坐标平面),因为它的求交最简单:对三个轴分别计算光线进入、离开盒子的 区间,取交集:
// 伪代码:光线与 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 光栅化与光线追踪对比
| 维度 | 光栅化 | 光线追踪 |
|---|---|---|
| 基本问题 | 每个三角形覆盖哪些像素 | 每个像素被哪些物体覆盖 |
| 绘制顺序 | 按深度测试决定可见性 | 按 最小的交点决定可见性 |
| 全局光照 | 需要 ShadowMapping 等额外手段 | 阴影、反射、折射天然包含 |
| 计算量 | 与三角形数量线性相关,适合实时 | 与光线数 × 求交代价相关,适合离线 |
| 硬件加速 | 专用光栅化管线(GPU) | RTX 等专用光追核心(近年才进入实时领域) |
| 典型应用 | 游戏、实时预览 | 电影特效、离线渲染 |
两条路线近年正在融合:实时渲染用光栅化做基础着色,再对反射、阴影等少量光线做硬件加速的光线追踪(如 NVIDIA RTX);离线渲染则用光栅化做光线追踪的预处理(如光子映射)。"哪个更好"没有答案,它们是同一问题的两种采样方式。


