光线追踪(四)——运动模糊与纹理映射

Illustrated by 夜蓝星炫(Pixiv),Source

前三篇中的射线只携带空间信息,材质也只有一个固定反照率。本篇沿着 《Ray Tracing: The Next Week》 的路线扩展这两个限制:首先把射线提升到时空查询,使不同样本观察到运动物体在快门期间的不同位置;然后把“颜色”抽象为一个可查询函数,让材质随空间位置或表面坐标变化。

配套 Rust 程序位于 src/bin/chapter4.rs。本篇四幅结果均由该程序以 1280×720 分辨率、每像素 48 个样本生成。

一、运动模糊

1. 从瞬时成像到快门时间积分

此前默认所有像素都在同一瞬间拍摄。真实相机的快门会在区间 [𝑡0,𝑡1] 内保持开启,因此一个像素记录的是空间采样与时间采样的共同平均。若暂时只写出时间维度,像素值可以表示为

𝐿avg=1𝑡1−𝑡0∫𝑡0𝑡1𝐿(𝑡)d𝑡.

蒙特卡洛方法不需要显式求这个积分:对每条摄像机射线独立采样 𝑇𝑘∈U(𝑡0,𝑡1),再取平均

𝐿̂𝑁=1𝑁∑𝑘=1𝑁𝐿(𝑇𝑘).
定理 1.(均匀快门采样的无偏性)
若 𝑇 在 [𝑡0,𝑡1] 上均匀分布,且 𝐿(𝑡) 可积,则 𝐿̂𝑁 是式 (1) 的无偏估计,即 𝐸[𝐿̂𝑁]=𝐿avg。

证明. 均匀分布的概率密度为 𝑝(𝑡)=1𝑡1−𝑡0,因此

𝐸[𝐿(𝑇)]=∫𝑡0𝑡1𝐿(𝑡)𝑝(𝑡)d𝑡=𝐿avg.
样本均值的期望等于各样本期望的平均,故结论成立。
□
图 1 快门区间内的不同射线样本观察到不同物体位置

2. 带时间的射线与摄像机

定义 1.(时空射线)

带时间的射线由起点 𝑶、方向 𝒅 和采样时刻 𝜏 构成。其空间参数仍为

𝒓(𝑠;𝜏)=𝑶+𝑠𝒅,𝑠≥0,
但场景求交必须在时刻 𝜏 的几何状态下进行。𝑠 是沿射线的位置参数,𝜏 是快门时间,两者不能混用。

Rust 中保留原来的双参数构造器,使旧代码默认在 𝜏=0 渲染;需要运动模糊时再显式指定时间:

pub struct Ray {
    pub origin: Point3,
    pub direction: Vec3,
    pub time: f64,
}

impl Ray {
    pub const fn new(origin: Point3, direction: Vec3) -> Self {
        Self::with_time(origin, direction, 0.0)
    }

    pub const fn with_time(origin: Point3, direction: Vec3, time: f64) -> Self {
        Self { origin, direction, time }
    }
}

摄像机保存 shutter_open 与 shutter_close。生成射线时,除像素抖动和孔径圆盘采样外,再增加一次时间采样:

let time = if self.shutter_open == self.shutter_close {
    self.shutter_open
} else {
    rng.range(self.shutter_open, self.shutter_close)
};
Ray::with_time(origin, direction, time)
注 1.(反弹射线不能重新抽时间)
一条路径代表同一光学事件在同一场景时刻的传播。材质散射生成后继射线时必须复制 incoming.time;若每次反弹重新抽取时间,单条路径会在运动场景的多个状态之间跳跃,得到没有物理意义的几何组合。

3. 线性运动球体

令球心在 𝑡0 时为 𝑪0、在 𝑡1 时为 𝑪1。最简单的运动模型是线性插值:

𝑪(𝑡)=𝑪0+𝑡−𝑡0𝑡1−𝑡0(𝑪1−𝑪0).

射线—球体求交的二次方程完全不变,只需把固定球心替换为 𝑪(𝜏)。也就是说,时间维度被封装在“查询当前球心”这一步中。

pub fn center(&self, time: f64) -> Point3 {
    self.center0
        + ((time - self.time0) / (self.time1 - self.time0))
            * (self.center1 - self.center0)
}

let center = self.center(ray.time);
let oc = ray.origin - center;

运动图元仍要进入 BVH。节点包围盒必须覆盖整个快门区间;对于线性运动球,只需合并两个端点球的 AABB:

𝐵motion=hull(𝐵(𝑡0),𝐵(𝑡1)).
命题 1.(线性运动球的端点并盒覆盖整段运动)
半径固定、球心按式 (5) 线性运动时,端点 AABB 的最小公共 AABB 包含任意 𝑡∈[𝑡0,𝑡1] 时刻的球体。
证明. 球心的每个坐标都是两个端点坐标的凸组合,因此位于对应端点坐标的闭区间内。向三个方向各扩张固定半径后,任意时刻球体的坐标范围仍包含于端点盒逐分量合并得到的范围。
□

4. 静止快门与运动模糊对照

下面两幅图使用完全相同的五个线性运动球。第一幅把快门固定在 𝑡=0.5,所有样本观察同一个瞬间,因此轮廓清晰:

图 2 固定快门时刻得到的瞬时画面

第二幅在 [0,1] 内均匀采样。每个像素累计了物体沿轨迹处于不同位置时的贡献,于是运动方向被拉成连续的模糊带:

图 3 在完整快门区间采样得到的运动模糊

运动模糊并不是对最终二维图片做滤镜。遮挡、阴影、反射和材质查询都发生在各自采样时刻;这正是把时间放进射线而不是在渲染后处理的价值。

二、纹理映射

1. 把颜色提升为纹理函数

固定反照率可以写成常函数 𝑻(𝑢,𝑣,𝒑)=𝒄。更一般地,纹理是一个查询:

𝑻:[0,1]2×ℝ3→[0,1]3,(𝑢,𝑣,𝒑)↦𝒄.

保留 (𝑢,𝑣) 与三维位置 𝒑 两组输入,可以同时表达表面纹理和空间纹理。

定义 2.(空间纹理与表面纹理)
空间纹理直接以三维点 𝒑 为输入,仿佛整个空间已经被颜色函数填充;表面纹理先把物体表面映射到二维坐标 (𝑢,𝑣),再查询二维图像或函数。

一个三维棋盘纹理可令

𝑞(𝒑)=⌊𝑥𝑠⌋+⌊𝑦𝑠⌋+⌊𝑧𝑠⌋,

根据 𝑞 的奇偶选择两种颜色。这里必须使用向下取整而非向零截断,否则原点两侧的格子会不对称。

let x = (point.x / scale).floor() as i64;
let y = (point.y / scale).floor() as i64;
let z = (point.z / scale).floor() as i64;
if (x + y + z).rem_euclid(2) == 0 { even } else { odd }
图 4 三维棋盘纹理同时作用于小球和大球地面

棋盘线条在两个不同球面上连续弯曲,说明颜色来自同一个三维函数,而不是分别贴上的二维图片。

2. 球面 UV 坐标

图像纹理需要把单位球面点 𝒑=(𝑥,𝑦,𝑧) 转成二维坐标。按照本系列采用的球面朝向,定义

𝜑=atan2(−𝑧,𝑥)+𝜋,𝜃=arccos(−𝑦),𝑢=𝜑2𝜋,𝑣=𝜃𝜋.

于是 𝑢,𝑣∈[0,1]。𝑢 沿纬线方向绕球一周,𝑣 从南极走向北极。

图 5 球面经纬参数展开为单位正方形 UV 域
注 2.(接缝与极点不可避免)
球面不能无撕裂地展平为矩形:𝑢=0 与 𝑢=1 表示同一条接缝,而极点附近许多不同的 𝑢 会汇聚到同一点。纹理绘制时应把接缝放在不显眼处,并在极点附近避免过密细节。

3. 从 UV 到图像像素

设纹理图宽高为 𝑊,𝐻。最近邻采样先把 UV 限制在单位区间,再把坐标换成整数像素:

𝑖=min(⌊𝑢𝑊⌋,𝑊−1),𝑗=min(⌊(1−𝑣)𝐻⌋,𝐻−1).

式中翻转 𝑣,是因为数学纹理坐标通常向上增长,而图像数组的行号通常从顶部向下增长。

let u = u.clamp(0.0, 1.0);
let v = 1.0 - v.clamp(0.0, 1.0);
let i = ((u * width as f64) as usize).min(width - 1);
let j = ((v * height as f64) as usize).min(height - 1);
pixels[j * width + i]

配套程序不引入图像依赖,而是先用 Rust 生成一幅 1440×720 的 PPM 经纬测试图,再由 ImageTexture::load_ppm 读回像素。源纹理如下:

图 6 Rust 生成并重新读取的二维 UV 经纬测试纹理

映射到球面后,粗黑线标出主要经纬区块,细白线帮助观察极点压缩、球面弯曲和接缝位置:

图 7 使用球面 UV 坐标查询图像纹理的结果

最近邻实现最容易验证,但放大时会出现块状边缘。若要使用双线性过滤,可读取 (𝑖,𝑗) 周围四个像素并做两次线性插值;其数学形式与第一篇的单线性插值相同,只是分别沿 𝑢、𝑣 两个方向执行。

三、实现检查清单

完成这些改动后,路径追踪器不再只能渲染静态、纯色的球:时间成为场景查询的一部分,颜色也成为可组合的函数。下一步可以自然扩展到 Perlin 噪声、凹凸映射、发光纹理与更复杂的动画变换。