← 返回图形编程

GLSL 着色器路径追踪渲染器

基于 GLSL/OpenGL 的 GPU 加速路径追踪渲染器,支持 MIS 与 BVH

日期 2026年3月
标签
glslopenglc++

概述

这是一个用 GLSL 写的 GPU 路径追踪渲染器。整个积分器跑在片元着色器里:每一帧给每个像素追踪一条路径,结果平均进累积纹理,所以图像会在视口里实时收敛。支持带 MIS 的 next event estimation,漫反射、镜面、微表面和导体材质,带 BVH 的贴图三角网格,薄透镜景深,HDR 环境贴图,以及 Intel OpenImageDenoise。

封面渲染

128spp,1303x2000,无降噪,2 分 19 秒。ACES 色调映射

封面渲染

角色模型来自 YYB-Era,场景模型、构图与打光由我完成

积分器

积分器是一个最多 10 层的弹射循环,带着两个量:throughput,也就是路径到目前为止每一次 BSDF、余弦和 1/pdf 的乘积;以及 accumLight,送回相机的辐射度。每次求交时:

  1. 如果表面是自发光的,把它的 Le 乘上 throughput 加进去,但只在这是相机光线、或者上一次弹射是镜面的情况下才加。然后停止。
  2. 如果表面是漫反射或微表面,在这一点用 MIS 计算直接光照,乘上 throughput 加进去。镜面表面跳过这一步。
  3. 用一组新的二维随机数采样 BSDF 得到新方向,throughput 乘上 bsdf * |cos| / pdf,从命中点发出下一条光线。
  4. 如果光线逃出场景,把环境颜色乘上 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 乘上 再除以光源处的余弦,换算成立体角。背面的样本返回零。
  • 球形面光源。 在球体所张的方向锥内均匀采样,cosThetaMax 由到球心的距离和半径得到,这样不会把样本浪费在背面。
  • 点光源。 平方反比衰减,pdf 就是 1/numLights
  • 聚光灯。 和点光源一样,强度由外锥角和内锥角之间的 smoothstep 决定。
  • 环境光。 绕法线做余弦加权的半球采样。阴影光线必须完全逃出场景这个样本才算数,贴图通过把方向换算成等距柱状 UV 来查找。

材质

漫反射与 Oren-Nayar

Lambert 就是 albedo / pi。Oren-Nayar 在此基础上加入由两个角度和方位角差得到的粗糙度项,AB 系数按标准公式由 sigma 算出。

512spp,1000x1000,已降噪。ACES 色调映射。Oren-Nayar 模型使用 sigma = 0.8。

LambertianOren-Nayar(能量守恒修正)Oren-Nayar
LambertianOren-Nayar 能量守恒修正Oren-Nayar

可以看到常规的简化版 Oren-Nayar 明显比 Lambertian 暗。这是因为最常见的简化 Oren-Nayar 模型其实并不能量守恒:只要 sigma > 0 就有 A < 1,光能就会损失。中间那张是加了一个小的能量补偿系数 1 / (1 - 0.33 sigma² / (sigma² + 0.09)) 之后的结果,它把整个 lobe 缩放回 Lambert 应有的反射量。

我个人觉得差异更明显的地方是石头和龙的下巴,Oren-Nayar 在这些掠射角上把光能保留得好很多。不过差别小到让我有点担心是不是哪里写错了。

模型来自 Benedikt Bitterli 的渲染资源

镜面反射、透射与玻璃

完美镜面和完美透射是 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.01粗糙度 0.05粗糙度 0.25
粗糙度 0.50粗糙度 0.75粗糙度 0.95
粗糙度 0.50粗糙度 0.75粗糙度 0.95

导体材质的 Fresnel 反射率

导体使用完整的复折射率 Fresnel 方程,etak 按 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:

  1. 计算当前三角形范围的包围盒,以及它们质心的包围盒。
  2. 沿最长轴把质心包围盒分成 12 个桶,按质心把每个三角形放进对应的桶。
  3. 对 11 种可能的切分,代价 = 0.125(遍历)+ (count_left * area_left + count_right * area_right) / area_parent。
  4. 取代价最小的切分。如果退化了(全在一侧),退回到把范围对半分。

叶节点只放一个三角形。然后把树压平成每个节点两个 vec4:包围盒最小点加一个次级索引,包围盒最大点加三角形数量。左子节点永远是数组里的下一个节点,所以只需要存右子节点的索引。压平后的数组以 SSBO 送到 GPU,三角形本身存在一张纹理里,按索引读取。

着色器里的遍历用一个 64 个节点索引的显式栈。每个节点做 slab 测试,跳过没打中或完全在光线后方的盒子,也跳过入口距离已经超过当前最近命中的盒子。叶节点跑 Möller-Trumbore,用重心坐标插值法线和 UV。

我遇到过一个很明显的 bug:插值出来的网格法线还留在物体空间,所以只要网格带旋转或缩放,着色就全是错的。结果渲染出了一些相当有意思的渐变色,所以我觉得值得放在这里。修法就是老老实实对它应用网格变换的逆转置。

哑光材质,法线错误玻璃材质,法线错误修正后
哑光材质初音,网格法线错误玻璃材质初音,网格法线错误网格法线修正后的初音

景深

相机是薄透镜模型。对每个像素,先把针孔光线延长到焦平面(t = focalDist / (dir · forward)),然后用同心圆盘采样把光线起点移到透镜圆盘上的一个随机点,新方向从这个透镜点指向焦点。变形宽银幕的散景只多一行:透镜样本的 x 坐标在乘上光圈之前先除以一个压缩系数,弥散圆就变成了竖长的椭圆。

128spp,1303x2000,无降噪。ACES 色调映射

无景深景深
无景深景深
球面变形宽银幕
球面变形宽银幕

环境贴图与程序化天空

逃逸光线和环境光查的是同一个函数。加载了 HDR 贴图时,方向用 atanasin 换算成等距柱状 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 场景
点光源粗糙镜面Veach 场景

使用工具

C++、GLSL、OpenGL、Qt、Intel OpenImageDenoise