光线追踪(一)——射线与摄像机

Illustrated by 夜蓝星炫(Pixiv),Source

光线追踪的核心问题可以说得很朴素:屏幕上的一个像素,应该呈现什么颜色?光栅化从三角形出发,把几何投影到屏幕;光线追踪则从像素出发,向场景发射查询射线,寻找最先遇到的表面,再沿反射、折射或阴影方向继续查询。后者很自然地表达了遮挡、镜面与透明介质,也为基于物理的渲染提供了统一框架。

真实光子从光源出发,只有极少数最终进入摄像机。若正向模拟全部光子,大部分计算与画面无关。几何光学满足光路可逆,因此基础渲染器通常反向从摄像机追踪路径。这种算法更准确的名字是“路径追踪”;“光线追踪”在本文中作为广义称呼使用。

注 1.(本系列的主要参考)
本系列的整体组织与实现路线主要参考 Peter Shirley、Trevor David Black 和 Steve Hollasch 的开源教程 Ray Tracing in One Weekend 系列。前三篇从基础实现出发重新推导;从第四篇开始重点参考 《Ray Tracing: The Next Week》,并用 Rust 独立实现和复现实验结果。文中的解释、公式组织与代码结构并非原文逐句翻译。

一、光线追踪基础

1. 渲染方程与蒙特卡洛估计

一个表面点 𝑥 沿方向 𝜔𝑜 离开的辐亮度满足渲染方程

𝐿𝑜(𝑥,𝜔𝑜)=𝐿𝑒(𝑥,𝜔𝑜)+∫Ω𝑓𝑟(𝑥,𝜔𝑖,𝜔𝑜)𝐿𝑖(𝑥,𝜔𝑖)|𝑛⋅𝜔𝑖|d𝜔𝑖.

其中 𝐿𝑒 是自发光,𝑓𝑟 是双向反射分布函数,𝐿𝑖 是来自半球 Ω 的入射辐亮度,𝑛 是表面法向量。积分很少有闭式解,于是我们用蒙特卡洛(Monte Carlo)方法:随机选择方向 𝜔𝑘,用样本均值近似:

∫Ω𝑔(𝜔)d𝜔≈1𝑁∑𝑘=1𝑁𝑔(𝜔𝑘)𝑝(𝜔𝑘),

其中 𝑝 是采样概率密度。样本越多,噪声通常越小;但蒙特卡洛误差只按 𝑂(𝑁−12) 收敛,把误差减半大约需要四倍样本。

注 2.(光线追踪与路径追踪)
经典 Whitted 光线追踪显式生成反射光、折射光与阴影光;路径追踪把式 (1) 的积分解释成随机路径上的期望。本文实现的是一个小型路径追踪器,但射线求交、摄像机和 BVH 同样适用于前者。

2. 向量、颜色与射线

三维点和向量都可以用三个实数存储,但语义不同:点表示位置,向量表示位移。若 𝒂=(𝑎𝑥,𝑎𝑦,𝑎𝑧)、𝒃=(𝑏𝑥,𝑏𝑦,𝑏𝑧),则

𝒂⋅𝒃=𝑎𝑥𝑏𝑥+𝑎𝑦𝑏𝑦+𝑎𝑧𝑏𝑧,‖𝒂‖=𝒂⋅𝒂,𝒂×𝒃=(𝑎𝑦𝑏𝑧−𝑎𝑧𝑏𝑦,𝑎𝑧𝑏𝑥−𝑎𝑥𝑏𝑧,𝑎𝑥𝑏𝑦−𝑎𝑦𝑏𝑥).

点积给出夹角关系 𝒂⋅𝒃=‖𝒂‖‖𝒃‖cos𝜃;叉积得到同时垂直于两向量的方向。归一化 𝒂̂=𝒂‖𝒂‖ 只改变长度,不改变方向。

颜色也可复用三分量向量,令 𝒄=(𝑟,𝑔,𝑏),颜色分量约定在 [0,1]。线性空间中的 0.5 并不等于显示器编码值的 0.5。本系列所有的中间计算都在线性空间完成,只有写出文件前才做一次编码;否则材质的乘性衰减、路径能量与像素平均都会在错误尺度上进行。简单教程常用 gamma 2 近似,在输出前计算

𝑐display=max(𝑐linear,0).
定义 1.(射线)

给定起点 𝑶 和方向 𝒅≠𝟎,射线是参数曲线

𝒓(𝑡)=𝑶+𝑡𝒅,𝑡≥0.
𝑡 不是距离,除非 𝒅 已归一化;但求交算法通常不要求单位方向。
图 1 射线参数 𝑡 与起点、方向的关系

图 图 1 也解释了为什么“射线”与“直线”不同:直线允许任意实数参数,而从 𝑶 出发的射线只保留 𝑡≥0 的半支。改变 𝒅 的长度会改变参数刻度,却不会改变射线经过的几何位置。

Rust 中只需保存两个向量,并实现沿射线取点:

#[derive(Clone, Copy, Debug)]
pub struct Ray {
    pub origin: Point3,
    pub direction: Vec3,
}

impl Ray {
    pub fn at(self, t: f64) -> Point3 {
        self.origin + t * self.direction
    }
}

二、射线与摄像机

1. 针孔摄像机

先看一个位于原点、朝 −𝑧 方向观察的摄像机。设视口高度为 ℎ、宽高比为 𝑎,则视口宽度为 𝑎ℎ。若视口左下角为 𝑸,水平边向量为 𝑯,竖直边向量为 𝑽,归一化像素坐标为 (𝑠,𝑡)∈[0,1]2,那么穿过视口采样点的射线为

𝒓𝑠,𝑡(𝜆)=𝑶+𝜆(𝑸+𝑠𝑯+𝑡𝑽−𝑶).

为了任意摆放摄像机,给定观察位置 𝑷、目标点 𝑻 与参考上方向 𝒗up,构造右手正交基

𝒘=𝑷−𝑻‖𝑷−𝑻‖,𝒖=𝒗up×𝒘‖𝒗up×𝒘‖,𝒗=𝒘×𝒖.

这里 −𝒘 是观察方向,𝒖 向右,𝒗 向上。垂直视场角为 𝜃、焦平面距离为 𝑑 时,视口高度与宽度分别是

ℎ=2𝑑tan(𝜃2),𝑤viewport=𝑎ℎ.
图 2 针孔摄像机从视点穿过成像平面采样场景
定理 1.(摄像机基的正交性)
只要参考上方向不与观察方向平行,式 (7) 得到的 𝒖,𝒗,𝒘 两两正交且均为单位向量。
证明. 叉积 𝒗up×𝒘 同时垂直于两个操作数,归一化后得到单位向量 𝒖。又因 𝒖⟂𝒘,所以 𝒘×𝒖 的长度为 1,且垂直于两者。由叉积顺序可知三者保持右手定向。
□

2. 像素采样与抗锯齿

若每个像素只在中心发射一条射线,物体边缘会出现阶梯。像素其实覆盖一个小面积,正确的像素值是该区域上辐亮度的积分。最简单的办法是在像素内加入均匀随机扰动:

let u = (x as f64 + rng.f64()) / (width - 1) as f64;
let v = (y as f64 + rng.f64()) / (height - 1) as f64;
color += ray_color(&camera.ray(u, v, &mut rng), world, depth, &mut rng);

累加 𝑁 个样本后一定要先除以 𝑁,再做 gamma 编码。忽略这一步会让颜色迅速饱和:

let scale = 1.0 / samples as f64;
let encode = |c: f64| {
    (256.0 * (scale * c).sqrt().clamp(0.0, 0.999)) as u8
};
注 3.(为什么把 𝑡min 设为一个小正数?)
后续反弹射线从浮点计算得到的表面点出发。若接受 𝑡=0 附近的交点,它可能立即再次命中原表面,产生“阴影痤疮”。小型渲染器常用 𝑡min=0.001 回避问题;更严谨的实现会按浮点误差和表面尺度偏移射线原点。

三、程序实现

1. 最小渲染循环

暂时没有物体时,可以用射线方向生成天空渐变。这一步用到单线性插值:给定两个端点值 𝑝0,𝑝1 与权重 𝛼∈[0,1],插值点为

𝑝(𝛼)=(1−𝛼)𝑝0+𝛼𝑝1.
图 3 单线性插值把线段按权重分割

当 𝛼=0 时得到 𝑝0,当 𝛼=1 时得到 𝑝1,中间取值沿两者之间匀速变化。令单位射线方向的 𝑦 分量映射为 𝛼=𝑑̂𝑦+12,再在白色与蓝色之间插值:

𝑪(𝒅)=(1−𝛼)(1,1,1)+𝛼(0.5,0.7,1.0).
图 4 Rust 阶段程序输出的渐变蓝天

图 图 4 是配套工程 stages 二进制程序生成的实际结果,而不是装饰性封面。它同时验证了摄像机射线方向、线性插值和 PPM 写出流程;在加入任何物体之前,先得到这幅图有助于把几何错误与图像输出错误分开。

渲染器的控制流由此确定:逐行访问像素;在像素内生成多个摄像机样本;查询场景;累加线性颜色;平均、编码并写入图片。下一篇只需要把“查询场景”替换为球体求交,并让命中点根据材质生成下一条射线。

2. 实现检查清单

配套 Rust 工程中的 vec3.rs、ray.rs、camera.rs 与 main.rs 实现了本篇全部结构。第二篇将在此基础上加入几何求交、材质散射与薄透镜景深。