GLSL 着色器路径追踪渲染器
基于 GLSL/OpenGL 的 GPU 加速路径追踪渲染器,支持 MIS 与 BVH
概述
这是一个用 GLSL 写的 GPU 路径追踪渲染器。整个积分器跑在片元着色器里:每一帧给每个像素追踪一条路径,结果平均进累积纹理,所以图像会在视口里实时收敛。支持带 MIS 的 next event estimation,漫反射、镜面、微表面和导体材质,带 BVH 的贴图三角网格,薄透镜景深,HDR 环境贴图,以及 Intel OpenImageDenoise。
封面渲染
128spp,1303x2000,无降噪,2 分 19 秒。ACES 色调映射

角色模型来自 YYB-Era,场景模型、构图与打光由我完成
积分器
积分器是一个最多 10 层的弹射循环,带着两个量:throughput,也就是路径到目前为止每一次 BSDF、余弦和 1/pdf 的乘积;以及 accumLight,送回相机的辐射度。每次求交时:
- 如果表面是自发光的,把它的
Le乘上 throughput 加进去,但只在这是相机光线、或者上一次弹射是镜面的情况下才加。然后停止。 - 如果表面是漫反射或微表面,在这一点用 MIS 计算直接光照,乘上 throughput 加进去。镜面表面跳过这一步。
- 用一组新的二维随机数采样 BSDF 得到新方向,throughput 乘上
bsdf * |cos| / pdf,从命中点发出下一条光线。 - 如果光线逃出场景,把环境颜色乘上 throughput 加进去,然后停止。
第 1 步是估计保持无偏的关键。漫反射表面上的直接光已经在第 2 步被 NEE 算过了,所以弹射光线再次打到光源时绝不能再加一次 Le。例外是镜面表面:它们的 BSDF 是 delta 分布,光源采样永远不可能选中镜面反射方向,所以在镜子里看到光源的唯一途径就是弹射光线。一个 prevSpecular 标记把这个判断带到下一次迭代。
带 MIS 的直接光照
直接光照函数用 power heuristic 把两个估计量合在一起:
- 光源策略。 均匀随机选一个光源(环境贴图算作额外的一个),在上面采样一个点或方向,发射阴影光线,再朝这个方向计算 BSDF。贡献按
pdf_light² / (pdf_light² + pdf_bsdf²)加权,其中pdf_bsdf是 BSDF 对同一方向的 pdf。 - BSDF 策略。 采样 BSDF,追踪光线,只有落在第一步选中的那个光源上才计入。它的权重用的是光源对这个方向的 pdf,也就是
Pdf_Li算的东西:矩形光的面积 pdf 换算到立体角,或者球形光的锥体 pdf。
点光源和聚光灯只用光源策略。BSDF 采样的光线打中一个点的概率为零,第二个估计量只会增加噪声。
俄罗斯轮盘
第三次弹射之后,路径以 q = max(throughput.rgb) 的概率存活,下限 0.05,存活的路径除以 q 以保持无偏。实现是实现了,但说实话我不确定能不能感觉到什么明显区别。
光源采样
每种光源有自己的采样器和 pdf:
- 矩形面光源。 在单位方形上均匀取一点,变换到世界空间。面积 pdf
1/area乘上r²再除以光源处的余弦,换算成立体角。背面的样本返回零。 - 球形面光源。 在球体所张的方向锥内均匀采样,
cosThetaMax由到球心的距离和半径得到,这样不会把样本浪费在背面。 - 点光源。 平方反比衰减,pdf 就是
1/numLights。 - 聚光灯。 和点光源一样,强度由外锥角和内锥角之间的
smoothstep决定。 - 环境光。 绕法线做余弦加权的半球采样。阴影光线必须完全逃出场景这个样本才算数,贴图通过把方向换算成等距柱状 UV 来查找。
材质
漫反射与 Oren-Nayar
Lambert 就是 albedo / pi。Oren-Nayar 在此基础上加入由两个角度和方位角差得到的粗糙度项,A 和 B 系数按标准公式由 sigma 算出。
512spp,1000x1000,已降噪。ACES 色调映射。Oren-Nayar 模型使用 sigma = 0.8。
| Lambertian | Oren-Nayar(能量守恒修正) | Oren-Nayar |
|---|---|---|
![]() | ![]() | ![]() |
可以看到常规的简化版 Oren-Nayar 明显比 Lambertian 暗。这是因为最常见的简化 Oren-Nayar 模型其实并不能量守恒:只要 sigma > 0 就有 A < 1,光能就会损失。中间那张是加了一个小的能量补偿系数 1 / (1 - 0.33 sigma² / (sigma² + 0.09)) 之后的结果,它把整个 lobe 缩放回 Lambert 应有的反射量。
我个人觉得差异更明显的地方是石头和龙的下巴,Oren-Nayar 在这些掠射角上把光能保留得好很多。不过差别小到让我有点担心是不是哪里写错了。
镜面反射、透射与玻璃
完美镜面和完美透射是 delta BSDF,所以 Sample_f 以 pdf 1 返回唯一有效的方向,而 f() 对其他任何方向都返回零。玻璃先对入射角计算介质的 Fresnel 项(光线在介质内部时交换折射率,全内反射时返回全反射),然后以等于这个 Fresnel 值的概率选择反射或折射。随机选一支而不是两支都追踪,让每像素每帧仍然只有一条路径。
Trowbridge-Reitz 微表面镜面反射与透射
反射从 Trowbridge-Reitz 分布采样一个微表面法线 wh,把 wo 绕它反射。透射则是把 wo 经过采样出的 wh 折射,用于求值的半向量变为 normalize(wo + eta * wi),eta 根据 wo 在表面哪一侧翻转。pdf 要乘上透射的雅可比 |wi·wh| eta² / (wo·wh + eta wi·wh)²,BTDF 则带上对应的分母和一个 (1 - F) 因子。
微表面玻璃把两者结合。它先采样 wh,对这个微表面法线而不是宏观法线计算 Fresnel,再以概率 F 选反射、1 - F 选折射,并把各自的 pdf 乘上相同的概率,这样 f / pdf 保持正确。如果经过采样微表面的折射遇到全内反射,这个样本退回到反射。
1024spp,1000x1000,无降噪。Reinhard 色调映射
| 粗糙度 0.01 | 粗糙度 0.05 | 粗糙度 0.25 |
|---|---|---|
![]() | ![]() | ![]() |
| 粗糙度 0.50 | 粗糙度 0.75 | 粗糙度 0.95 |
|---|---|---|
![]() | ![]() | ![]() |
导体材质的 Fresnel 反射率
导体使用完整的复折射率 Fresnel 方程,eta 和 k 按 RGB 逐通道给出,按完美镜面反射求值。下面三种金属用的是金、铜、铝的表格值。
金:eta [0.183, 0.421, 1.373],k [3.424, 2.346, 1.770]
铜:eta [0.271, 0.677, 1.316],k [3.609, 2.625, 2.292]
铝:eta [1.655, 0.879, 0.520],k [9.224, 6.270, 4.837]

贴图
材质可以引用一张反照率贴图(采样后从 sRGB 转到线性),一张在几何法线的切线空间中应用的法线贴图,以及一张覆盖材质标量粗糙度的粗糙度贴图。三者都是采样器数组的索引,所以一个网格可以任意组合使用。
网格与 BVH
三角网格在 CPU 上用表面积启发式(SAH)构建 BVH:
- 计算当前三角形范围的包围盒,以及它们质心的包围盒。
- 沿最长轴把质心包围盒分成 12 个桶,按质心把每个三角形放进对应的桶。
- 对 11 种可能的切分,代价 = 0.125(遍历)+ (count_left * area_left + count_right * area_right) / area_parent。
- 取代价最小的切分。如果退化了(全在一侧),退回到把范围对半分。
叶节点只放一个三角形。然后把树压平成每个节点两个 vec4:包围盒最小点加一个次级索引,包围盒最大点加三角形数量。左子节点永远是数组里的下一个节点,所以只需要存右子节点的索引。压平后的数组以 SSBO 送到 GPU,三角形本身存在一张纹理里,按索引读取。
着色器里的遍历用一个 64 个节点索引的显式栈。每个节点做 slab 测试,跳过没打中或完全在光线后方的盒子,也跳过入口距离已经超过当前最近命中的盒子。叶节点跑 Möller-Trumbore,用重心坐标插值法线和 UV。
我遇到过一个很明显的 bug:插值出来的网格法线还留在物体空间,所以只要网格带旋转或缩放,着色就全是错的。结果渲染出了一些相当有意思的渐变色,所以我觉得值得放在这里。修法就是老老实实对它应用网格变换的逆转置。
| 哑光材质,法线错误 | 玻璃材质,法线错误 | 修正后 |
|---|---|---|
![]() | ![]() | ![]() |
景深
相机是薄透镜模型。对每个像素,先把针孔光线延长到焦平面(t = focalDist / (dir · forward)),然后用同心圆盘采样把光线起点移到透镜圆盘上的一个随机点,新方向从这个透镜点指向焦点。变形宽银幕的散景只多一行:透镜样本的 x 坐标在乘上光圈之前先除以一个压缩系数,弥散圆就变成了竖长的椭圆。
128spp,1303x2000,无降噪。ACES 色调映射
| 无景深 | 景深 |
|---|---|
![]() | ![]() |
| 球面 | 变形宽银幕 |
|---|---|
![]() | ![]() |
环境贴图与程序化天空
逃逸光线和环境光查的是同一个函数。加载了 HDR 贴图时,方向用 atan 和 asin 换算成等距柱状 UV。封面渲染用的是程序化天空:从地平线的深青色到天顶的白色做渐变,再整体调暗,让它更像补光而不是光源。
1024spp,1000x1000,2 秒。Reinhard 色调映射
| 康奈尔盒 | 玻璃球 |
|---|---|
![]() | ![]() |
程序化天空见顶部的封面渲染。
Intel OpenImageDenoise
路径追踪器每帧写三个渲染目标:颜色、反照率和法线。反照率和法线在第一个非镜面命中处捕获,而不是第一个命中处,所以隔着玻璃球时 AOV 描述的是玻璃后面的东西,而不是玻璃表面。三者都和颜色一样累积。OIDN 的 RT 滤波器以 HDR 模式运行,用反照率和法线缓冲作为引导。
12spp,1303x2000。原始和降噪后:ACES 色调映射。反照率:线性反射率,只做 gamma。法线:世界空间,从 [-1, 1] 重映射到 [0, 1],不做色调曲线。两个引导缓冲都在第一个非镜面命中处捕获,所以玻璃材质的 “17th” 文字显示的是它后面的台阶,反射打到天空的地方则是黑色。
| 原始 | 降噪后 |
|---|---|
![]() | ![]() |
| 反照率 | 法线 |
|---|---|
![]() | ![]() |
离线命令行渲染
我实在受够了 Qt 在 Linux 上崩溃和文件对话框不工作,于是有了这个。传入 --scene 会完全跳过 UI:渲染器以指定分辨率把固定数量的样本渲染进离屏帧缓冲,可选降噪,写出文件,打印渲染时间,然后退出。
./ShaderPathtracer --scene <file.json> 无头渲染(命令行模式必需)
--samples <n> 每像素采样数(默认 256)
--width / --height 分辨率(默认 800x600)
--output <dir> path_tracer/render/ 下的输出目录
--oidn 对最终图像降噪
--aovs 同时输出反照率和法线通道
--png / --hdr 输出格式(都不指定则为 PNG)
--envmap <file.hdr> 环境贴图
--reinhard 使用 Reinhard 而非 ACES 色调映射
--sky 程序化天空(--envmap 优先)
由于追踪器需要真正的 OpenGL 上下文,offscreen 平台用不了。没有设置 DISPLAY 时,启动器会寻找正在运行的 Wayland socket 并接上去,这样就能通过 SSH 渲染。
NEE 演示渲染
1024spp,1000x1000
| 面光源 | 聚光灯 | 球形光源 |
|---|---|---|
![]() | ![]() | ![]() |
| 点光源 | 粗糙镜面 | Veach 场景 |
|---|---|---|
![]() | ![]() | ![]() |
使用工具
C++、GLSL、OpenGL、Qt、Intel OpenImageDenoise


























