每日编程实践: Depth of Field Renderer — 薄透镜景深渲染器
每日编程实践: Depth of Field Renderer — 薄透镜景深渲染器
今天实现了一个基于薄透镜模型(Thin Lens Model)的物理正确景深渲染器,配合路径追踪产生自然的散景(Bokeh)效果。

① 背景与动机
为什么景深很重要?
如果你玩过《赛博朋克 2077》《荒野大镖客2》或《对马岛之魂》,一定注意过游戏里的镜头语言——当主角站在前景,背景的城市灯光化成一圈圈朦胧的光晕,这就是景深(Depth of Field, DoF)效果。这个效果来自真实相机和人眼的光学特性:光圈有物理尺寸,只有焦平面附近的物体才能在感光元件上聚焦成点,其他距离的物体会形成弥散圆(Circle of Confusion),俗称散景(Bokeh)。
没有景深的渲染器——包括我们之前做的大多数渲染器——使用的都是**针孔相机(Pinhole Camera)**模型。针孔相机假设光圈无穷小,所有距离的物体都完全清晰。这对学习渲染基础够用,但就视觉真实性而言有明显缺陷:
- 画面缺乏层次感:前景、中景、背景同样清晰,空间深度感弱
- 缺少导演感:真实摄影中,摄影师用景深引导观众视线——清晰的地方才是重点
- 不够真实:人眼本身就是个光学系统,有景深的画面更接近我们看世界的方式
工业界实际使用
- 光路追踪渲染器(离线):V-Ray、Arnold、Cycles 全部支持薄透镜/实体透镜模型。Pixar 的 RenderMan 甚至支持多层透镜组。这里今天实现的薄透镜是离线渲染的标准方案。
- 实时渲染(游戏引擎):由于路径追踪过于耗时,实时 DoF 通常用屏幕空间后处理模拟。Unreal Engine 的 Cinematic DoF 使用了散焦圆分离(Separate Translucency + Bokeh 卷积);Unity 的 HDRP 用了 Physically Based DoF(基于 CoC 的 Tile-based 高斯模糊)。
- 电影工业:Weta Digital 在《指环王》系列中用景深深度控制观众注意力,是”隐形特效”的经典案例。
今天的实现是离线路径追踪版本,结果最为物理正确,也最能展示薄透镜原理本身。
② 核心原理
2.1 针孔相机 vs 薄透镜相机
针孔相机:假设相机只有一个无穷小的点作为光圈。每个像素对应唯一一条光线,方向确定,所有深度的物体同样清晰。
$$
\text{Ray}(s, t) = \text{origin} + \text{direction}(s, t)
$$
薄透镜相机(Thin Lens Model):相机有真实尺寸的光圈(直径 = aperture)。根据高斯薄透镜公式,焦距 $f$ 、物距 $d_o$ 、像距 $d_i$ 满足:
$$
\frac{1}{f} = \frac{1}{d_o} + \frac{1}{d_i}
$$
直觉解释:平行光(无穷远处的物体)经过透镜后汇聚在焦点(距离 $f$)。对于有限距离的物体,成像位置比焦点更远($d_i > f$)。
2.2 弥散圆(Circle of Confusion)
对于不在焦平面的物体,它在感光元件上形成一个圆而不是点,这就是弥散圆(CoC)。CoC 的半径公式:
$$
r_{CoC} = \frac{A}{2} \cdot \frac{|d - d_f|}{d}
$$
其中:
- $A$ = 光圈直径
- $d$ = 物体到镜头的距离
- $d_f$ = 焦距(焦平面距离)
直觉解释:光圈越大($A$ 大)→ 散景越明显;物体离焦平面越远($|d - d_f|$ 大)→ 模糊越严重;物体离相机越近($d$ 小)→ 相同离焦量产生更大 CoC。这就是为什么近景虚化比远景虚化更明显。
2.3 路径追踪中的薄透镜实现
在路径追踪里,我们不需要显式计算 CoC。薄透镜的物理行为可以直接编码在光线生成阶段:
步骤 1:在透镜平面(光圈圆盘)上随机采样一点 $P_{lens}$
$$
P_{lens} = \text{center} + r \cdot U + r’ \cdot V
$$
其中 $(r, r’)$ 是单位圆内的随机点,乘以光圈半径,$(U, V)$ 是相机坐标系的切平面基向量。
步骤 2:计算焦平面上的目标点 $P_{focus}$
这里用”聚焦射线”的方式:先发射一条从相机中心穿过像素的参考射线,把它延伸到焦距处:
$$
P_{focus} = \text{origin} + \text{direction}(s, t) \cdot d_{focus}
$$
直觉:所有穿过透镜不同位置但指向同一焦点的光线,在焦平面上汇聚到同一点——这正是薄透镜的核心光学特性。
步骤 3:从 $P_{lens}$ 向 $P_{focus}$ 发射真正的光线
$$
\text{Ray}{real} = (P{lens},\ \overrightarrow{P_{lens} \to P_{focus}})
$$
这条光线携带了”光从哪个方向穿过透镜上的这一点”的信息。对同一像素重复多次采样(不同的 $P_{lens}$),累加的结果就是物理正确的景深模糊。
为什么这是正确的?
薄透镜光学告诉我们:所有穿过透镜、指向焦平面上同一点的光线,经过透镜折射后确实汇聚到同一点。我们的实现直接绕过了折射计算,利用这个几何性质:让光线”已经汇聚好了”——即从透镜上随机点直接射向焦点。等价于真实光学,但效率更高。
2.4 光圈形状与散景形状的关系
真实相机的光圈是多边形叶片构成的,所以散景有时是六边形或八边形。我们的实现用圆形光圈(在单位圆内均匀采样),产生圆形散景——这是最理想的光学散景形状,实际上高端定焦镜头追求的就是接近圆形的散景。
单位圆内均匀采样(拒绝采样法):
1 | Vec3 randomInUnitDisk() { |
直觉:随机生成 $[-1,1]^2$ 的正方形采样点,只接受落在单位圆内的(约 78.5% 接受率)。这比变换采样略慢但代码最简单,对于光圈采样频率来说完全可以接受。
③ 实现架构
3.1 整体数据流
1 | 像素 (col, row) |
3.2 相机结构体设计
1 | struct Camera { |
关键设计决策:
lower_left / horizontal / vertical定义了焦平面上的视口矩形,尺寸随focusDist线性缩放——这保证了无论焦距多远,视角(vfov)保持不变w指向相机后方(lookFrom - lookAt),u指向右,v指向上。这是标准的右手相机坐标系lensRadius = 0时退化为针孔相机,不产生任何模糊
3.3 场景布局设计
1 | 相机位置: (0, 0.6, 4.5) → lookAt: (0, 0, 1.5) |
这个布局专为展示景深效果设计:三个深度层次对比明显,前中后景都有足够的视觉内容。
④ 关键代码解析
4.1 薄透镜光线生成(核心)
1 | Ray Camera::getRay(double s, double t) const { |
为什么 lower_left 要乘以 focusDist?
这是整个薄透镜实现最容易搞错的地方。在相机构造函数里:
1 | lower_left = pos |
原因:视口(ViewPort)必须放置在焦平面上(距离相机 focusDist),而不是距离 1 的默认位置。这样,所有从不同光圈位置射出、但指向视口同一点的光线,都真正汇聚在焦平面上——符合薄透镜光学特性。
如果不乘以 focusDist,视口在距离1处,光线朝向的”汇聚点”不在焦平面上,产生错误的模糊效果。
4.2 材质系统
漫反射(Lambertian):
1 | if (mat.type == MatType::DIFFUSE) { |
toWorld() 把局部坐标系(Z轴=法线)的方向转换到世界坐标系,使用 Gram-Schmidt 正交化构造切线帧。
金属(Phong 镜面反射 + 粗糙度):
1 | if (mat.type == MatType::METAL) { |
粗糙度(rough)控制反射的模糊程度:0 = 完美镜面;1 = 类漫反射。这与 PBR 中的 roughness 参数语义一致。
玻璃(Schlick 菲涅尔 + Snell 折射):
1 | if (mat.type == MatType::GLASS) { |
关键点:
h.front判断光线从外部还是内部击中表面,决定折射率比值方向- Schlick 公式 $R(\theta) = R_0 + (1-R_0)(1-\cos\theta)^5$ 用单次随机数实现了概率性的反射/折射分支,等效于对菲涅尔积分的无偏蒙特卡洛估计
- 全内反射条件:$\sin^2\theta_t > 1$(光从密介质射向疏介质时可能发生)
4.3 ACES Filmic 色调映射
1 | uint8_t toU8(double v) { |
ACES 色调映射比简单的 Reinhard(v/(v+1))更好:
- 暗部细节更丰富(S 曲线的底部斜率更大)
- 高光过渡更自然(不会像 Reinhard 那样整体偏灰)
- 色彩饱和度保持得更好
与 Linear 映射对比:不用 ACES 直接截断的话,发光球周围的高光会变成难看的死白,细节全丢。
⑤ 踩坑实录
Bug 1:视口未缩放导致景深效果错误
症状:渲染出来虽然有些模糊,但模糊程度与光圈设置不对应——光圈开大了,背景却只有轻微模糊,不是预期的大散景。
错误假设:以为光线生成逻辑正确,模糊效果应该随光圈线性增强。
真实原因:相机构造时忘记了视口坐标要乘以 focusDist。视口被放在距离 1 的位置,而不是焦平面(距离 ~3)上。光圈偏移量相对于这个错误距离来说”太大”,导致散射角度异常,不是物理正确的景深。
修复:在 Camera 构造函数中,lower_left、horizontal、vertical 全部乘以 focusDist:
1 | // 错误(视口在距离1): |
教训:薄透镜相机最反直觉的地方就是”视口必须在焦平面上”。把这个原则写进注释里,不然下次还会忘。
Bug 2:Y 轴翻转导致天空在下
症状:渲染结果天空在下面,地面在上面,整幅图上下颠倒。
错误假设:像素坐标 (row, col) 和图像坐标是一致的,row=0 对应图像顶部。
真实原因:图像写入时 row=0 是顶部,但在计算射线方向时,sv = row/H 使得 row=0 对应了视口底部(天空方向),row=H 对应了视口顶部(地面方向)。
修复:翻转 Y 坐标映射:
1 | // 错误: |
这是一个经典的坐标系问题,也是 SKILL.md 里”历史教训”特别提到的——以后所有图形学项目都要先检查坐标系方向。
Bug 3:玻璃球内部反射无限循环
症状:渲染玻璃球时极慢,甚至有个别像素永远不结束(实际上是超出深度限制,但早期版本没有深度限制)。
错误假设:玻璃球内部光线会自然衰减,不需要特别处理。
真实原因:光线进入玻璃球后,由于全内反射,可能在球内部来回反弹很多次。如果没有最大深度限制,会无限循环。即使有深度限制(本实现 depth=6),深度不够时玻璃球会显示为暗色(能量被截断)。
修复:
- 保持最大深度限制(depth=6),这已经足够处理大多数情况
- 深度耗尽时返回黑色(能量守恒:未追踪完的能量视为被吸收)
深度6对玻璃球来说通常足够——真实场景中光线很少在玻璃球内部反弹超过3-4次就射出了。
Bug 4:随机数种子固定导致散景图案有规律
症状:128 SPP 的结果散景看起来有轻微的网格感,特别是在强高光区域。
错误假设:固定种子 42 可以让结果可复现,同时质量不受影响。
真实原因:固定种子意味着每次渲染完全相同的随机序列。对于每个像素 128 次采样,序列分布均匀性足够,但相邻像素使用了连续的序列段,导致局部相关性。
当前处理:本次实现保留了固定种子(保证可复现性优先),但对于生产级渲染可以改用 PCG 哈希或 Blue Noise 采样提升质量。实际效果来看,128 SPP 已经足够平滑。
⑥ 效果验证与数据
像素统计验证
1 | 文件大小: 1.1MB(远大于 10KB 最低要求) |
所有验证通过,没有全黑/全白/无内容的问题。
渲染性能数据
1 | 分辨率: 800 × 600 = 480,000 像素 |
单线程 28 秒对于 128SPP 的路径追踪加景深采样是合理的。路径追踪开销主要在递归求交,景深额外增加了每次光线生成时的单位圆采样(拒绝采样,平均 ~1.27 次尝试),对总时间影响约 1%,可以忽略。
景深效果验证
| 深度层 | Z 位置 | 到焦平面距离 | 预期效果 |
|---|---|---|---|
| 前景 | z=+1.5 | 0(焦点) | 清晰 |
| 中景 | z=-0.5 | 2.0 单位 | 轻微模糊 |
| 背景 | z=-4.0 | 5.5 单位 | 明显散景 |
理论上,光圈直径 0.25、焦距 ~3.06 时:
- 中景 CoC 半径 ≈ 0.125 × 2.0/3.06 ≈ 0.082(屏幕空间)
- 背景 CoC 半径 ≈ 0.125 × 5.5/3.06 ≈ 0.225(屏幕空间)
在 800px 宽度下,背景 CoC 约 90px 半径,符合渲染结果中背景球的模糊圈大小(目视约 80-100px)。
⑦ 总结与延伸
技术局限性
当前薄透镜实现的限制:
圆形光圈:真实相机光圈是多边形叶片(5叶、7叶、9叶等),散景形状相应为多边形。可以通过在多边形内均匀采样而非圆盘来改变散景形状。
均匀圆盘采样:使用了最简单的拒绝采样法。更高效的方法是 Shirley 同心圆映射(Concentric Sample Disk),避免拒绝浪费。
无色差(Chromatic Aberration):真实透镜对不同波长折射率不同,导致散景边缘出现彩色晕圈(紫边/蓝移)。可以通过对 RGB 三通道分别用略不同的焦距渲染来模拟。
薄透镜近似:假设透镜无穷薄,没有厚度和像差(球差、彗差等)。实体透镜模型可以更准确地模拟高端镜头的特性散景。
渐晕(Vignetting):真实相机边角进光量减少,图像边缘变暗。薄透镜模型里没有这个效应。
可优化方向
采样效率提升:
- 换用 Halton 序列或 Sobol 序列替代伪随机数,在相同 SPP 下显著减少噪声
- 重要性采样光源(显式光源采样)——当前实现靠路径随机碰到光源,对小光源非常低效
- 多重重要性采样(MIS)平衡直接照明与 BRDF 采样
实时化方向:
- 为了实时游戏,通常改用屏幕空间 CoC 计算 + 卷积 DoF
- Unreal Engine 5 的 Lumen 全局光照也支持 Temporal DoF,每帧只渲染 1-4SPP,靠时序累积
- 也可以用光线追踪 + Denoiser(DLSS 3/XeSS 等)接近今天的离线质量
与本系列其他技术的关联:
- 今天的薄透镜相机可以与 03-21 实现的 SPPM、03-22 的 BDPT 无缝结合——它们的差别只在光线追踪策略,相机模型完全兼容
- 03-26 实现的 TAA 里有时序抖动,景深的多帧累积与 TAA 思路相通,可以结合实现 Temporal DoF
- 03-27 的运动模糊和今天的景深都是”采样范围的扩展”——运动模糊在时间维度采样,景深在空间(光圈)维度采样,完全可以同时启用
今日收获
薄透镜景深是路径追踪器中少有的”一次实现就物理正确”的特性——只要把视口放到焦平面上、在光圈盘上随机采样,蒙特卡洛的威力会自动处理其余一切。这种优雅让它成为了我最喜欢的渲染技术之一。
代码仓库: https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-28-depth-of-field











