光线追踪渲染实战(五):低差异序列与重要性采样,加速收敛!
作者:互联网
目录
- 前言
- 1. 低差异序列介绍
- 2. sobol 序列及其实现
- 3. 在着色器中使用 sobol 序列
- 4. 重要性采样简介
- 5. 漫反射重要性采样
- 6. BRDF 重要性采样
- 7. 清漆重要性采样
- 8. 按照辐射度混合采样
- 9. HRD 环境贴图重要性采样
- 10. 多重重要性采样
- 11. 总结与后记
- 12. 引用与参考
前言
使用低差异序列与重要性采样来加速光线追踪的收敛!下图是仅 1000 spp 的结果,且找不到任何噪点。而要知道以往博客的示例图样都是 4000 spp 起步的:
来看看我们究竟干了什么事情:
- 低差异序列降噪:
- 重要性采样(diffuse,specular)降噪:
- 对 hdr 贴图重要性采样(从左到右依次是原图,样本,结果):
- 多重(chong)重(zhong)要性采样:
在上一篇博客 微平面理论与迪士尼 BRDF,严格遵循物理! 中,我们学习了了微平面理论,并且通过给出的公式实现了完整的迪士尼原则的 BRDF,实现了基于物理的渲染
但是现阶段我们是通过半球均匀采样来计算光线追踪的。尽管对于漫反射来说,均匀采样能够得到很好的结果,但是对于 BRDF,尤其是粗糙度非常低的时候,均匀采样很难达到收敛,因为我们浪费了很多采样:
如图,对于上图的 10 个采样,真正有用的只有 3 个,效率很低。在这篇博客中我们将通过一些数学方法来强化采样策略:
- 从样本上:用空间上更加均匀的 低差异序列 代替伪随机序列
- 从分布上:用概率论中的 重要性采样 策略来加速渲染方程的收敛过程
不多说了,开始把
1. 低差异序列介绍
渲染方程是一个困难积分:
通常情况下,我们使用蒙特卡洛采样,来对困难积分的值进行估计,而对于一个函数的估计,需要这么几个步骤:
- 选取积分域内的任意样本 x x x
- 计算选取该样本的概率 p d f ( x ) pdf(x) pdf(x)
- 计算样本 x x x 对应的函数值 f ( x ) f(x) f(x)
- 本次采样的贡献为 f ( x ) p d f ( x ) \frac{f(x)}{pdf(x)} pdf(x)f(x)
这里有一个结论,就是如果我们使用的样本,在样本空间内的分布越 均匀 ,那么积分的估计值就会越准确。先入为主的想,原因是因为均匀的样本 不会放过被积函数的任何一个角落 ,所以估计的值会更加准确
以 y = x y=x y=x 为例,选取 5 个样本,假设 p d f ( x ) pdf(x) pdf(x) 恒为 1 1.0 − 0.0 \frac{1}{1.0 - 0.0} 1.0−0.01,可以看到均匀的选取 0.2,0.4,0.6,0.8,1.0 得到的估计值,要准于不均匀的选取 0.1,0.7,0.8,0.9,1.0 的采样:
选取均匀的样本,对于采样的效果至关重要。于是我们引入差异(discrepancy)来衡量一组样本 X X X 的均匀程度,差异越低的样本越均匀。对于一个拥有 N N N 个样本和 V V V 单位体积的样本空间的的点集 X X X,我们任取一体积为 v v v 的子空间,其中包含 n n n 个点,那么有:
D i s c ( X ) = ∣ n / N v / V − 1.0 ∣ Disc(X) = \left| \frac{n/N}{v/V} -1.0 \right| Disc(X)=∣∣∣∣v/Vn/N−1.0∣∣∣∣
比如一杯水 100 ml,有 3000 个水分子,你一勺子打了 10 ml,那么你理应获得 300 个水分子,这说明水的分布是绝对均匀的,没有差异的
更加直观的,下面是二维平面上,使用较为均匀的低差异序列,和普通的随机数序列生成的点。可以直观的看到,在样本数量同为 256 个的时候,低差异序列对于空间的填充更加均匀:
可以看到低差异序列均匀的覆盖了样本区域,而对于伪随机数来说,存在很多的孔洞
注:
蒙特卡洛积分是无偏的,但是积分域的某些角落,在采样次数较少的时候难以达到,采样次数上去了还是可以采到的
而对于低差异序列,非常少量的采样就可以均匀照顾到区域,因此低差异序列在采样次数低的时候能够更快收敛
2. sobol 序列及其实现
关于 sobol sequence 的原理我看了 3 遍相也没懂,毕竟是数学的东西,都是怪物搞出来的。。。好在最后是勉强搞懂了那些参数和符号的意义,能够拼凑出代码实现了
这里 是讲 sobol 原理的论文地址,而 这里 是一篇不错的笔记,对代码实现有帮助。最后 这里 则是计算生成矩阵的生成多项式参数
原文比较复杂,下面仅挑一些重要的稍微概括一下,然后是代码实现
2.1. 生成矩阵与递推式
sobol 序列是一个递推序列,序列的第 i 个数,取决于序列的第 i-1 个数。此外 sobol 序列需要一个生成矩阵 v v v,它是一个 二进制 矩阵,通常以 一维数组 的形式出现:
v = [ 0 1 0 0 0 0 1 1 1 0 0 1 0 1 1 0 ] = [ 4 3 9 6 ] = [ v ⃗ 1 v ⃗ 2 v ⃗ 3 v ⃗ 4 ] v =\begin{bmatrix} 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 1 \\ 1 & 0 & 0 & 1 \\ 0 & 1 & 1 & 0 \\ \end{bmatrix} = \begin{bmatrix} 4 \\ 3 \\ 9 \\ 6\\ \end{bmatrix} = \begin{bmatrix} \vec v_1 \\ \vec v_2 \\ \vec v_3 \\ \vec v_4 \\ \end{bmatrix} v=⎣⎢⎢⎡0010100101010110⎦⎥⎥⎤=⎣⎢⎢⎡4396⎦⎥⎥⎤=⎣⎢⎢⎡v 1v 2v 3v 4⎦⎥⎥⎤
此外 sobol 的表达式是基于按位异或的操作(bit xor),其中 x i , j x_{i,j} xi,j 表示第 i 个 sobol 数的第 j 个 bit,而 i j i_j ij 表示整数 i 的第 j 个 bit, v i , j v_{i,j} vi,j 则表示生成矩阵的第 i 行第 j 列。第 i 个 sobol 数的第 j 个 bit 的表达式如下:
x i , j = i 1 v 1 , j ⊕ i 2 v 2 , j ⊕ i 3 v 3 , j ⋯ x_{i,j}= i_1v_{1,j} \ \oplus \ i_2v_{2,j} \ \oplus \ i_3v_{3,j} \ \cdots xi,j=i1v1,j ⊕ i2v2,j ⊕ i3v3,j ⋯
对于每个 x i x_i xi 的每一 bit 都要和所有的 v i v_i vi 按位异或显得有些麻烦,但是存在一种特殊情况:如果 i 和 i-1 的二进制表示 只有 1 bit 的差异,比如 5 = ( 101 ) 2 5=(101)_2 5=(101)2 和 7 = ( 111 ) 2 7=(111)_2 7=(111)2,那么通过 x i − 1 x_{i-1} xi−1 异或上那一个差异的 bit 就可以得到 x i x_i xi,相当于我们得到了递推式:
x i , j = x i − 1 , j ⊕ v c i − 1 , j x_{i,j}=x_{i-1,j}\oplus v_{c_{i-1},j} xi,j=xi−1,j⊕vci−1,j
其中 c i − 1 c_{i-1} ci−1 是 i 和 i-1 的 发生差异 的那一 bit。对于一个整数从 0 变成 i,需要改变 k 次,k 为 i 的二进制表示中 1 的个数,下面以 0 变成 11 (二进制 1011)为例:
- 改变第 1 个bit,由 0000 变为 0001, c 0 = 0 c_0=0 c0=0
- 改变第 2 个bit,由 0000 变为 0011, c 1 = 1 c_1=1 c1=1
- 改变第 4 个bit,由 0000 变为 1011, c 2 = 3 c_2=3 c2=3
于是对于递推式有:
x i , j = x i − 1 , j ⊕ v c i − 1 , j = ( x i − 2 , j ⊕ v c i − 2 , j ) ⊕ v c i − 1 , j = ⋯ x_{i,j} =x_{i-1,j}\oplus v_{c_{i-1},j} =(x_{i-2,j}\oplus v_{c_{i-2},j})\oplus v_{c_{i-1},j} = \cdots xi,j=xi−1,j⊕vci−1,j=(xi−2,j⊕vci−2,j)⊕vci−1,j=⋯
此外规定 x 0 , j = 0 x_{0,j}=0 x0,j=0,而 0 异或任何数字都不影响结果,于是最终变成了:
x i , j = v c i − 1 , j ⊕ v c i − 1 , j ⊕ ⋯ ⊕ v 2 , j ⊕ v 1 , j x_{i,j}= v_{c_{i-1},j}\oplus v_{c_{i-1},j} \oplus \cdots \oplus v_{2,j} \oplus v_{1,j} xi,j=vci−1,j⊕vci−1,j⊕⋯⊕v2,j⊕v1,j
这仅是算出
x
i
x_i
xi 的第
j
j
j 个 bit,而对于整个
x
i
x_i
xi,因为异或操作满足 可交换性,即 a^b = b^a
,这里假设我们的生成矩阵是通过长度为 32 的,内容为 32 bit 的 uint 数组来表示的 32 x 32 的生成矩阵,即:
uint v[32] = {v1, v2, .... v32}
将 vi 看作是行向量,于是我们可以通过一次 整数的异或操作 来对 x i x_i xi 的 32 bit 都执行一次异或!于是递推式变为:
x i = v c i − 1 ⊕ v c i − 1 ⊕ ⋯ ⊕ v 2 ⊕ v 1 x_{i}= v_{c_{i-1}}\oplus v_{c_{i-1}} \oplus \cdots \oplus v_{2} \oplus v_{1} xi=vci−1⊕vci−1⊕⋯⊕v2⊕v1
如果序列 0 ∼ i 0 \sim i 0∼i 都满足 i i i 和 i − 1 i-1 i−1 的二进制表示只有 1 bit 的不同,那么我们只需要扫描 i i i 的每个 bit,在第 j j j 个位置遇到 1 ,我们就和生成矩阵的第 j j j 行做异或,于是获得第 i i i 个 sobol 数的代码为:
// 第 i 个 sobol 数
// v 是某一维度的生成矩阵
float sobol(unsigned int v[32], unsigned int i) {
unsigned int result = 0;
for(unsigned int j = 0; i; i >>= 1, j++)
if(i & 1)
result ^= v[j];
return result * (1.0f/(float)0xFFFFFFFF);
}
遗憾的是从
0
∼
n
0 \sim n
0∼n 的自然数并不都满足
i
i
i 和
i
−
1
i-1
i−1 的二进制表示只有 1 bit 的不同。于是引入格雷码。格雷码是一种乱序的映射,其中 gray(i-1)
和 gray(i)
的二进制表达仅相差一个 bit
于是我们可以用 gray(i) 代替 i i i ,进而使用递推版的 sobol 公式生成第 i i i 个 sobol 数。其中 gray code 的表达式如下:
g r a y ( i ) = i ⊕ [ i 2 ] gray(i)=i \ \oplus \left[ \frac{i}{2} \right] gray(i)=i ⊕[2i]
其中方括号为取整操作。对于编程语言,直接除以二就行
2.2. sobol 序列的生成
因为 sobol 是定义在 N 维空间的点的序列,那么 每个维度都有自己的生成矩阵 ,下面的代码使用预计算的 3 x 32 x 32 的生成矩阵(维度下标从 1 开始),可以生成最多 2 32 2^{32} 232 个三维空间的 sobol 点,上文的二维散点图也是用这个程序生成的:
#include <bits/stdc++.h>
using namespace std;
typedef unsigned int uint;
// V[d] 表示第 d 维度的生成矩阵
uint V[4][32] = {
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
// dimension 1
2147483648, 1073741824, 536870912, 268435456, 134217728, 67108864, 33554432, 16777216, 8388608, 4194304, 2097152, 1048576, 524288, 262144, 131072, 65536, 32768, 16384, 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1,
// dimension 2
2147483648, 3221225472, 2684354560, 4026531840, 2281701376, 3422552064, 2852126720, 4278190080, 2155872256, 3233808384, 2694840320, 4042260480, 2290614272, 3435921408, 2863267840, 4294901760, 2147516416, 3221274624, 2684395520, 4026593280, 2281736192, 3422604288, 2852170240, 4278255360, 2155905152, 3233857728, 2694881440, 4042322160, 2290649224, 3435973836, 2863311530, 4294967295,
// dimension 3
2147483648, 3221225472, 1610612736, 2415919104, 3892314112, 1543503872, 2382364672, 3305111552, 1753219072, 2629828608, 3999268864, 1435500544, 2154299392, 3231449088, 1626210304, 2421489664, 3900735488, 1556135936, 2388680704, 3314585600, 1751705600, 2627492864, 4008611328, 1431684352, 2147543168, 3221249216, 1610649184, 2415969680, 3892340840, 1543543964, 2382425838, 3305133397
};
// 格林码
uint grayCode(uint i) {
return i ^ (i>>1);
}
// 生成第 dimension 维度的第 i 个 sobol 数
float sobol(unsigned int vectors[][32], unsigned int dimension, unsigned int i) {
unsigned int result = 0;
for(unsigned int j = 0; i; i >>= 1, j++)
if(i & 1)
result ^= vectors[dimension][j];
return result * (1.0f/(float)0xFFFFFFFF);
}
int main() {
// 生成 第 D 维度的 sobol sequence
int N = 20;
for(int i=0; i<N; i++) {
float x = sobol(V, 1, grayCode(i)); cout<<x<<" ";
float y = sobol(V, 2, grayCode(i)); cout<<y<<" ";
float z = sobol(V, 3, grayCode(i)); cout<<z<<" ";
cout<<endl;
}
return 0;
}
代码是参考了开源的渲染器 blender 关于 sobol sequence 的操作。在 这里 可以查看 blender 的该部分的源码。也可以和 sobol 官网 的例程的结果对比下。左侧是例程是 linux 下的结果,右侧是我们的程序的输出:
嗯 结果是正确的!
2.3. 生成矩阵的计算
简单说一下就是 emmm ,生成矩阵由数组表示, v k , j v_{k,j} vk,j 表示第 k k k 行的第 j j j 列的元素。的每一个 bit 都有递推式,然后 a a a 是一个有 s i s_i si 个 bit 的整数, a j a_j aj 表示 a 的第 j 个 bit,公式如下:
但是因为我们 v 矩阵存的都是 32 位 整数,所以可以在等式两边同时乘以 2 32 2^{32} 232,然后将最终生成的 sobol 数通过除以 2 32 2^{32} 232 映射回 0 ~ 1 的 uniform float 范围。于是最终的 v 矩阵存储的实际上是 v ∗ 2 32 v * 2^{32} v∗232,所以有:
v k , j = m k , j ∗ 2 32 − k v_{k,j}=m_{k,j} * 2^{32-k} vk,j=mk,j∗232−k
然后就是暴力的套公式环节了。这里直接放我写好的代码。代码可以根据输入的参数(这些参数可以在 sobol 官网 上面下载)生成对应维度的生成矩阵,不过仅支持 32 bit 的,每个维度的二进制生成矩阵都是 32 x 32 的,然后压缩为 32 长度的数组。以第三个维度为例,参数应该这么输入:
生成第 D 个维度的生成矩阵的例程的代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef unsigned int uint;
float sobol(unsigned int vectors[][32], unsigned int dimension, unsigned int i) {
unsigned int result = 0;
for(unsigned int j = 0; i; i >>= 1, j++)
if(i & 1)
result ^= vectors[dimension][j];
return result * (1.0f/(float)0xFFFFFFFF);
}
// 格林码
uint grayCode(uint i) {
return i ^ (i>>1);
}
// 生成第 k 个方向数 M_k
void generate_M_k(vector<uint>& M, uint S, uint k, uint a[]) {
unsigned int M_k = 0;
for(int i=1; i<=S-1; i++) {
M_k ^= (1<<i) * a[i] * M[k-i];
}
M_k ^= ((1<<S) * M[k-S]) ^ M[k-S];
M.push_back(M_k);
}
int main() {
// 参数
int D = 3;
int S = 2;
int A = 1;
vector<uint> M = {0, 1, 3}; // 下标从 1 开始
// 根据 A 的值生成多项式
uint a[S]; // 下标从 1 开始, 共 S-1 个元素
uint A_ = A;
uint i = S-1;
do {
a[i--] = A_ & 1;
A_ = A_>>1;
} while(A_);
cout<<"多项式数组 a[1] ~ a[S-1]"<<endl;
for(int i=1; i<S; i++) {
cout<<a[i]<<" ";
}
cout<<endl<<endl;
// 生成方向数
for(int k=S+1; k<=32; k++) {
generate_M_k(M, S, k, a);
}
cout<<"方向数数组 m[1] ~ m[32]"<<endl;
for(int i=1; i<=32; i++) {
cout<<M[i]<<" ";
}
cout<<endl<<endl;
// 将 m 换算成 v 同时写第 d 维度的生成矩阵
uint V[33][32];
for(int k=1; k<=32; k++) {
V[D][k-1] = M[k] * (1<<(32-k));
}
cout<<"生成矩阵 v[1] ~ v[32]"<<endl;
for(int k=1; k<=32; k++) {
cout<<V[D][k-1]<<", ";
}
cout<<endl<<endl;
// 生成 第 D 维度的 sobol sequence
cout<<"维度为 "<<D<<" 的 sobol 序列"<<endl;
int N = 30;
for(int i=0; i<N; i++) {
float x_i = sobol(V, D, grayCode(i));
cout<<x_i<<endl;
}
return 0;
}
其中可以和标准的 sobol 例程对比结果。以第三个维度为例,可以看到结果正确:
注:
我的代码没有给出第一个维度的生成矩阵怎么求,事实上有:
vi = 2^i
即:
V[1][] = {2147483648, 1073741824, 536870912 ... 4, 2, 1}
使用这个矩阵(其实就是单位矩阵)得到的序列(也要用 gray code 做 i)就是一维的 sobol number
3. 在着色器中使用 sobol 序列
至此我们掌握了生成 N 维度的 sobol sequence 的方法。对于每个维度的 0 ~ n 个数都是一个单独的随机数序列,但是因为 sobol 具有均匀填充空间的特性,只需要用很少的几个维度填充积分域即可
还有一些问题:对于 N 个 D 维的 sobol 向量总共有 N 个数,对于每一帧,每个像素,光线的每次反弹,该如何选取这些数字作为随机数呢?
- 对于第 i 帧,我们选取第 i 个 sobol 向量
- 假设一次反弹需要 2 个随机数,对于第 i 帧的第 1 次反弹,选取第 i 个 sobol 向量的第 1,2 维度的数字作为随机数。第 2 次反弹则选择 3,4 维度,以此类推…
注:
上文给出的网站最多可以生成 21201 维度的 sobol 向量,如果你能理解上面我在说什么,就能够理解为啥每次反弹需要 8 个随机数的 blender 的光线最大深度是 21201 ÷ 8 了(下图来自 blender 文档):
在着色器中使用 sobol sequence 也很简单,因为我们的半球面积分需要两个随机变量,分别是 ϕ \phi ϕ 和 θ \theta θ,我们只需要两个维度的 sobol 序列即可。假设我们最大支持 4 次反弹,我们就需要最高为 8 维的 sobol 生成矩阵。在着色器中加入:
// 1 ~ 8 维的 sobol 生成矩阵
const uint V[8*32] = {
2147483648, 1073741824, 536870912, 268435456, 134217728, 67108864, 33554432, 16777216, 8388608, 4194304, 2097152, 1048576, 524288, 262144, 131072, 65536, 32768, 16384, 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1,
2147483648, 3221225472, 2684354560, 4026531840, 2281701376, 3422552064, 2852126720, 4278190080, 2155872256, 3233808384, 2694840320, 4042260480, 2290614272, 3435921408, 2863267840, 4294901760, 2147516416, 3221274624, 2684395520, 4026593280, 2281736192, 3422604288, 2852170240, 4278255360, 2155905152, 3233857728, 2694881440, 4042322160, 2290649224, 3435973836, 2863311530, 4294967295,
2147483648, 3221225472, 1610612736, 2415919104, 3892314112, 1543503872, 2382364672, 3305111552, 1753219072, 2629828608, 3999268864, 1435500544, 2154299392, 3231449088, 1626210304, 2421489664, 3900735488, 1556135936, 2388680704, 3314585600, 1751705600, 2627492864, 4008611328, 1431684352, 2147543168, 3221249216, 1610649184, 2415969680, 3892340840, 1543543964, 2382425838, 3305133397,
2147483648, 3221225472, 536870912, 1342177280, 4160749568, 1946157056, 2717908992, 2466250752, 3632267264, 624951296, 1507852288, 3872391168, 2013790208, 3020685312, 2181169152, 3271884800, 546275328, 1363623936, 4226424832, 1977167872, 2693105664, 2437829632, 3689389568, 635137280, 1484783744, 3846176960, 2044723232, 3067084880, 2148008184, 3222012020, 537002146, 1342505107,
2147483648, 1073741824, 536870912, 2952790016, 4160749568, 3690987520, 2046820352, 2634022912, 1518338048, 801112064, 2707423232, 4038066176, 3666345984, 1875116032, 2170683392, 1085997056, 579305472, 3016343552, 4217741312, 3719483392, 2013407232, 2617981952, 1510979072, 755882752, 2726789248, 4090085440, 3680870432, 1840435376, 2147625208, 1074478300, 537900666, 2953698205,
2147483648, 1073741824, 1610612736, 805306368, 2818572288, 335544320, 2113929216, 3472883712, 2290089984, 3829399552, 3059744768, 1127219200, 3089629184, 4199809024, 3567124480, 1891565568, 394297344, 3988799488, 920674304, 4193267712, 2950604800, 3977188352, 3250028032, 129093376, 2231568512, 2963678272, 4281226848, 432124720, 803643432, 1633613396, 2672665246, 3170194367,
2147483648, 3221225472, 2684354560, 3489660928, 1476395008, 2483027968, 1040187392, 3808428032, 3196059648, 599785472, 505413632, 4077912064, 1182269440, 1736704000, 2017853440, 2221342720, 3329785856, 2810494976, 3628507136, 1416089600, 2658719744, 864310272, 3863387648, 3076993792, 553150080, 272922560, 4167467040, 1148698640, 1719673080, 2009075780, 2149644390, 3222291575,
2147483648, 1073741824, 2684354560, 1342177280, 2281701376, 1946157056, 436207616, 2566914048, 2625634304, 3208642560, 2720006144, 2098200576, 111673344, 2354315264, 3464626176, 4027383808, 2886631424, 3770826752, 1691164672, 3357462528, 1993345024, 3752330240, 873073152, 2870150400, 1700563072, 87021376, 1097028000, 1222351248, 1560027592, 2977959924, 23268898, 437609937
};
// 格林码
uint grayCode(uint i) {
return i ^ (i>>1);
}
// 生成第 d 维度的第 i 个 sobol 数
float sobol(uint d, uint i) {
uint result = 0;
uint offset = d * 32;
for(uint j = 0; i; i >>= 1, j++)
if(i & 1)
result ^= V[j+offset];
return float(result) * (1.0f/float(0xFFFFFFFFU));
}
// 生成第 i 帧的第 b 次反弹需要的二维随机向量
vec2 sobolVec2(uint i, uint b) {
float u = sobol(b*2, grayCode(i));
float v = sobol(b*2+1, grayCode(i));
return vec2(u, v);
}
对于第 i 帧,我们取第 i 个 sobol 向量,对于第 b 次反弹,我们取 [ b ∗ 2 , b ∗ 2 + 1 ] [b*2, b*2+1] [b∗2,b∗2+1] 维度的数即可,将帧计数器 frameCounter 变量传入,然后生成二维平面上的点看看效果,可以看到 sobol 的点正在均匀地填满整个空间:
当然你也可以尝试不同的维度,你会发现他们的填充图样各不相同…
将二维 sobol 向量作为随机变量 ξ 1 \xi_1 ξ1 和 ξ 2 \xi_2 ξ2,修改 pathTracing 中生成半球向量的代码如下,其中 bounce 是光线反弹次数,从 0 开始:
vec3 SampleHemisphere(float xi_1, float xi_2) {
float z = xi_1;
float r = max(0, sqrt(1.0 - z*z));
float phi = 2.0 * PI * xi_2;
return vec3(r * cos(phi), r * sin(phi), z);
}
...
vec3 pathTracing(HitResult hit, int maxBounce) {
for(int bounce=0; bounce<maxBounce; bounce++) {
...
vec2 uv = sobolVec2(frameCounter+1, bounce);
vec3 L = SampleHemisphere(uv.x, uv.y);
L = toNormalHemisphere(L, hit.normal);
...
}
}
别忘了将 L 投影回法向半球:
// 将向量 v 投影到 N 的法向半球
vec3 toNormalHemisphere(vec3 v, vec3 N) {
vec3 helper = vec3(1, 0, 0);
if(abs(N.x)>0.999) helper = vec3(0, 0, 1);
vec3 tangent = normalize(cross(N, helper));
vec3 bitangent = normalize(cross(N, tangent));
return v.x * tangent + v.y * bitangent + v.z * N;
}
然后我们看看效果左图是前 200 次迭代,效果很差,像溜冰之后看到的幻觉一样。而右边 4000 次迭代的结果已经非常棒了,看不到任何噪点,展示了低差异序列强大的威力:
在没有拟合的时候为啥会出现这些类似 “反射” 的图形呢?这是因为 所有像素都使用同一序列 进行光线弹射,就像光洁的镜面一样,这当然是不好的。一种解决方案是以像素坐标作为种子生成二维随机向量,然后对该帧的序列都加上这个向量,如果超出了 [ 0 , 1 ] [0,1] [0,1] 的值就 取模 回去,这个策略叫做 Cranley Patterson Rotation,实现简单效果好:
uint wang_hash(inout uint seed) {
seed = uint(seed ^ uint(61)) ^ uint(seed >> uint(16));
seed *= uint(9);
seed = seed ^ (seed >> 4);
seed *= uint(0x27d4eb2d);
seed = seed ^ (seed >> 15);
return seed;
}
...
vec2 CranleyPattersonRotation(vec2 p) {
uint pseed = uint(
uint((pix.x * 0.5 + 0.5) * width) * uint(1973) +
uint((pix.y * 0.5 + 0.5) * height) * uint(9277) +
uint(114514/1919) * uint(26699)) | uint(1);
float u = float(wang_hash(pseed)) / 4294967296.0;
float v = float(wang_hash(pseed)) / 4294967296.0;
p.x += u;
if(p.x>1) p.x -= 1;
if(p.x<0) p.x += 1;
p.y += v;
if(p.y>1) p.y -= 1;
if(p.y<0) p.y += 1;
return p;
}
其中 pix 是 NDC 坐标系下的范围 ±1 的平面像素坐标,weight 和 height 则是屏幕分辨率,我们用 像素坐标 做随机种子。再来看看效果,可以看到在 50 spp 的情况下,左侧是使用伪随机数的结果,右侧是使用 sobol + Cranley Patterson Rotation 的结果:
可以看到 sobol 序列的结果明显更加平滑,注意右图的地面
注:
对于同一帧的多次弹射,必须使用不同维度的 sobol 数,否则多次弹射使用同一方向,会出现奇怪的 bug,下图是我意外产生的一个视觉效果非常… 意识流的 bug,像是来自异世界的茶壶闪烁着不属于这个次元的光芒
4. 重要性采样简介
在上文中我们仅优化了产生随机数的方式,但是本质上我们还是在使用 均匀分布 的样本进行采样。而概率统计告诉我们,相比于各个位置均匀采样,如果 在函数值大的地方多采样几次 就能够更好的拟合,这个策略叫做重要性采样:
事实上 “在函数值大的地方多采样几次” 仅仅是一个直觉上的描述。数学的表述是:如果随机变量的概率密度函数 p d f ( x ) pdf(x) pdf(x) 能够正比于被估计函数的函数值 f ( x ) f(x) f(x),那么估计的结果最好
举个例子, f ( 1 ) = 1 , f ( 2 ) = 7 , f ( 3 ) = 2 f(1)=1, f(2)=7, f(3)=2 f(1)=1,f(2)=7,f(3)=2,那么 x 取 1,2,3 的概率应该分别为:0.1,0.7,0.2
对于光线追踪的重要性采样,因为 BRDF 函数的不同,而分为如下几个部分:
- 对漫反射的重要性采样(diffuse importance sampling)
- 对镜面反射的重要性采样(specularimportance sampling)
- 对清漆的重要性采样(clearcoat importance sampling)
- 对 HDR 环境贴图的重要性采样(HDR importance sampling)
而对于支持重要性采样的路径追踪,则需要实现这几个步骤:
- 根据表面材质,随机采样一个方向 L
- 获取 L 方向上的 BRDF 值
- 获取 L 方向上采样的概率密度
- 计算该条路径的贡献
5. 漫反射重要性采样
对于 Disney BRDF 的漫反射,我们粗糙的认为 F d = b a s e C o l o r π ∗ cos ( w i ) F_d = \frac{baseColor}{\pi} * \cos(w_i) Fd=πbaseColor∗cos(wi),也就是 Lambert diffuse 中的 N dot L ,然后计算半球积得到算归一化因子 c = 1 π c=\frac{1}{\pi} c=π1,最终得到:
p d f ( x ) ∼ cos ( w i ) π pdf(x) \sim \frac{\cos(w_i)}{\pi} pdf(x)∼πcos(wi)
推导过程见:PBRT 13.6 2D Sampling with Multidimensional Transformations ,对于这种样本的生成,PBRT 给了我们使用一种简单的巧取法:
- 生成两个随机数 xi_1,xi_2
- 根据随机数得到均匀圆盘上的二维点 xy
- 将二维点投影到 z 轴半球,得到三维点 xyz
这种方法叫做 Malley’s Method,具体原理不明。比起探究规律本身,我们对规律的应用更感兴趣 ---- 这句话出自 A 岛的原创科幻短篇小说《灯神精灵》,相较于不切实际的灯神观测者和它的三个愿望,我们还是来看余弦加权的重要性采样的代码吧,同样要注意将随机向量投影到法向半球:
// 余弦加权的法向半球采样
vec3 SampleCosineHemisphere(float xi_1, float xi_2, vec3 N) {
// 均匀采样 xy 圆盘然后投影到 z 半球
float r = sqrt(xi_1);
float theta = xi_2 * 2.0 * PI;
float x = r * cos(theta);
float y = r * sin(theta);
float z = sqrt(1.0 - x*x - y*y);
// 从 z 半球投影到法向半球
vec3 L = toNormalHemisphere(vec3(x, y, z), N);
return L;
}
其中 xi_1,xi_2 是两个随机变量,这意味这重要性采样也支持我们使用来自 sobol sequence 的样本。首先采样一个方向,然后获取对应的 BRDF 值:
xi_1, xi_2 is 2 random number
vec3 L = SampleCosineHemisphere(xi_1, xi_2, N)
vec3 f_r = ...
float pdf = NdotH / PI;
来看看 5 spp 下的效果:
其中左侧为均匀采样,右侧为余弦半球采样的结果,可以看到重要性采样的噪点更少更加平滑
注:
该测试图片使用伪随机数而非 sobol 数,因为后者本身就有降噪效果
故 控制变量 仅使用伪随机数
6. BRDF 重要性采样
这一项主要是针对 镜面反射 的重要性采样,镜面反射使用 GTR 函数进行近似,而这个函数粗糙度越低尖峰越大,BRDF 较大的值都出现在镜面反射光线附近,这就导致很难通过一般的 均匀采样 去拟合。尽管我们使用了 sobol sequence,但是还是不可避免地出现噪点:
如果你还记得 Disney BRDF 中 GTR2 的复杂表达式,那么你一定不会想自己手动推导一个符合它的分布:
好在 Disney 在论文中帮我们推导过了:要想生成符合 GTR 函数的一个分布,我们取半角向量 h 的概率密度为 p d f ( θ h ) = D ( θ h ) cos h pdf(\theta_h)=D(\theta_h)\cos_h pdf(θh)=D(θh)cosh,因为双向反射分布函数已经是归一化的。此外对于重要性采样,我们的 pdf 是针对 θ l \theta_l θl 也就是出射光线的,所以还有一步额外的转换:
至此我们可以确定生成重要性采样方向的流程了:
- 首先 roll 两个均匀分布的随机向量 ξ 1 , ξ 2 \xi_1, \ \xi_2 ξ1, ξ2
- 根据 ξ 1 , ξ 2 \xi_1, \ \xi_2 ξ1, ξ2 计算半球坐标的 θ h , ϕ h \theta_h, \ \phi_h θh, ϕh 从而确定半角向量 H
- 将半角向量 H 投影到反射点的法向半球
- 将半角向量 H 作为反射镜面的法线,反射入射光线 V,得到出射光线 L
即:
其中最难的步骤就是 2,因为我们要根据随机变量生成符合:
p d f ( θ h ) ∼ D ( θ h ) cos h pdf(\theta_h)\sim D(\theta_h)\cos_h pdf(θh)∼D(θh)cosh
这一分布的立体角 θ h \theta_h θh,而对于旋转角 ϕ h \phi_h ϕh 因为是 各向同性 的重要性采样,它和 θ h \theta_h θh 不相关,我们取均匀分布即可。于是立即推:直接摆烂抄论文里的公式就完事了,对于 GTR2 的 θ h \theta_h θh 我们有:
注意我们的计算仍然是 z 向上的几何坐标系下计算的,而不是 y 向上的标准 OpenGL 坐标:
按照上述公式生成向量,可以看到粗糙度越低,向量的分布越靠近镜面反射的方向,左侧和右侧分别是 0.3 和 0.1 的粗糙度,写个 python 脚本试一下效果:
在理解了原理之后,在着色器下对应的代码实现如下,我们首先计算 H 然后以 H 做法向反射光线得到 L 即可:
// GTR2 重要性采样
vec3 SampleGTR2(float xi_1, float xi_2, vec3 V, vec3 N, float alpha) {
float phi_h = 2.0 * PI * xi_1;
float sin_phi_h = sin(phi_h);
float cos_phi_h = cos(phi_h);
float cos_theta_h = sqrt((1.0-xi_2)/(1.0+(alpha*alpha-1.0)*xi_2));
float sin_theta_h = sqrt(max(0.0, 1.0 - cos_theta_h * cos_theta_h));
// 采样 "微平面" 的法向量 作为镜面反射的半角向量 h
vec3 H = vec3(sin_theta_h*cos_phi_h, sin_theta_h*sin_phi_h, cos_theta_h);
H = toNormalHemisphere(H, N); // 投影到真正的法向半球
// 根据 "微法线" 计算反射光方向
vec3 L = reflect(-V, H);
return L;
}
注意此处是 各向同性 的 GTR2 的重要性采样。此外 alpha 是粗糙度的平方,在光线追踪主循环采样的时候,我们传入的时候要做一次映射,别忘了 clamp 一个 0.001 防止粗糙度过小:
float alpha = max(0.001, sqr(hit.material.roughness));
假设全部的采样都来自镜面反射,我们直接将上文的余弦半球采样替换为 BRDF 采样,然后看看效果:
金属度为 1.0,粗糙度为 0.2,50 spp 的情况下,因为粗糙度太低难以采样到尖峰,左侧几乎不能形成反射,而右侧能够形成完美的反射
注:
这里因为 pdf 涉及除法,就不得不考虑除数等于 0 的情况
稍有不慎就会抛出 divided by zero 的 exception 从而 down 掉你的 fragment shader 同时屏幕出现黑点,这些黑点永远不会消除,直到你关闭程序
7. 清漆重要性采样
Disney BRDF 使用 GTR1 进行清漆的镜面波瓣拟合,而 Disney 的论文中也给出了 各向同性 的GTR1 的重要性采样的 θ h \theta_h θh 和 ϕ h \phi_h ϕh 的取值:
于是如法炮制即可,和 GTR2 相同,只是 cos_theta 改一下即可:
// GTR1 重要性采样
vec3 SampleGTR1(float xi_1, float xi_2, vec3 V, vec3 N, float alpha) {
...
float cos_theta_h = sqrt((1.0-pow(alpha*alpha, 1.0-xi_2))/(1.0-alpha*alpha));
...
}
在取 BRDF 的时候注意清漆的粗糙度的 alpha 是这么算的,和 clearcoatGloss 参数有关而和表面粗糙度无关:
float alpha = mix(0.1, 0.001, hit.material.clearcoatGloss);
在仅有清漆项的情况下看一看效果:
太棒了,完全没有任何噪点
8. 按照辐射度混合采样
前面我们分别实现了使用单独的分布来单独采样 BRDF 的三个分部,现在我们要将三种采样的结果叠加起来得到最终的结果。那么问题就来了,在一次采样中,我们不可能同时采样三个 BRDF 项,这样做每次弹射要发射 3 条光线 时间开销 太大,并且大多数材质都不同时具有三个项,我们会浪费许多采样
8.1. 获取采样方向 L
一种启发性的方法是根据三种 BRDF 项的 能量贡献 来分配采样的次数。比如我有 100 次采样的开销。假设漫反射,镜面反射,清漆分别占最终颜色亮度的 0.6,0.3,0.1,那么我就要分配 60,30,10 次采样到漫反射,镜面反射,清漆上
而根据 Disney BRDF 的表达式:
diffuse * (1.0 - material.metallic) + specular + clearcoat;
我们可以大概推算出每一项的占比,注意这里我们认为镜面反射的亮度永远是 1.0,而清漆亮度受材质的 clearcoat 参数控制:
// 辐射度统计
float r_diffuse = (1.0 - material.metallic);
float r_specular = 1.0;
float r_clearcoat = 0.25 * material.clearcoat;
float r_sum = r_diffuse + r_specular + r_clearcoat;
// 根据辐射度计算概率
float p_diffuse = r_diffuse / r_sum;
float p_specular = r_specular / r_sum;
float p_clearcoat = r_clearcoat / r_sum;
如果单看 diffuse 和 specular 这两项你会发现我们有这样的一个函数:
p d i f f u s e = 1.0 − m e t a l l i c 2.0 − m e t a l l i c , p s p e c u l a r = 1.0 2.0 − m e t a l l i c p_{diffuse} = \frac{1.0 - metallic}{2.0 - metallic} , \ p_{specular} = \frac{1.0}{2.0 - metallic} pdiffuse=2.0−metallic1.0−metallic, pspecular=2.0−metallic1.0
以 metallic 为 x,画出漫反射占比 diffuse ratio 关于 x 的图像,你会发现这其实和我们常用的经验公式不谋而合:
float diffuseRatio = 0.5 * (1.0 - Metallic);
互联网上很少有对这个 diffuseRatio 的公式做详细解释的,都是直接由经验公式得。我想,通过【按照辐射度采样】这一思路能够对它做出解释:
我们 roll 一个随机数 ξ 3 \xi_3 ξ3,根据三种 BRDF 的辐射度贡献来做概率来采样。假设漫反射,镜面反射,清漆的贡献分别为 0.6,0.3,0.1,那么我们的随机数落在 [ 0 , 0.6 ] [0,0.6] [0,0.6] 就是漫反射,落在 [ 0.6 , 0.9 ] [0.6,0.9] [0.6,0.9] 就是镜面反射,落在 [ 0.9 , 1.0 ] [0.9,1.0] [0.9,1.0] 就是清漆反射:
// 按照辐射度分布分别采样三种 BRDF
vec3 SampleBRDF(float xi_1, float xi_2, float xi_3, vec3 V, vec3 N, in Material material) {
float alpha_GTR1 = mix(0.1, 0.001, material.clearcoatGloss);
float alpha_GTR2 = max(0.001, sqr(material.roughness));
// 辐射度统计
float r_diffuse = (1.0 - material.metallic);
float r_specular = 1.0;
float r_clearcoat = 0.25 * material.clearcoat;
float r_sum = r_diffuse + r_specular + r_clearcoat;
// 根据辐射度计算概率
float p_diffuse = r_diffuse / r_sum;
float p_specular = r_specular / r_sum;
float p_clearcoat = r_clearcoat / r_sum;
// 按照概率采样
float rd = xi_3;
// 漫反射
if(rd <= p_diffuse) {
return SampleCosineHemisphere(xi_1, xi_2, N);
}
// 镜面反射
else if(p_diffuse < rd && rd <= p_diffuse + p_specular) {
return SampleGTR2(xi_1, xi_2, V, N, alpha_GTR2);
}
// 清漆
else if(p_diffuse + p_specular < rd) {
return SampleGTR1(xi_1, xi_2, V, N, alpha_GTR1);
}
return vec3(0, 1, 0);
}
8.2. 获取方向 L 上的概率密度
采样 diffuse 路径我们应该用 diffuse 的 pdf 去计算该条路径的贡献,比起分开采样三种 pdf,一种更好的策略是 按照采样的概率 直接叠加三种 pdf 即可。和采样的时候类似,先计算辐射度,然后归一化得到各个部分的概率,最后按照概率叠加三种不同的 pdf 并返回:
// 获取 BRDF 在 L 方向上的概率密度
float BRDF_Pdf(vec3 V, vec3 N, vec3 L, in Material material) {
float NdotL = dot(N, L);
float NdotV = dot(N, V);
if(NdotL < 0 || NdotV < 0) return 0;
vec3 H = normalize(L + V);
float NdotH = dot(N, H);
float LdotH = dot(L, H);
// 镜面反射 -- 各向同性
float alpha = max(0.001, sqr(material.roughness));
float Ds = GTR2(NdotH, alpha);
float Dr = GTR1(NdotH, mix(0.1, 0.001, material.clearcoatGloss)); // 清漆
// 分别计算三种 BRDF 的概率密度
float pdf_diffuse = NdotL / PI;
float pdf_specular = Ds * NdotH / (4.0 * dot(L, H));
float pdf_clearcoat = Dr * NdotH / (4.0 * dot(L, H));
// 辐射度统计
float r_diffuse = (1.0 - material.metallic);
float r_specular = 1.0;
float r_clearcoat = 0.25 * material.clearcoat;
float r_sum = r_diffuse + r_specular + r_clearcoat;
// 根据辐射度计算选择某种采样方式的概率
float p_diffuse = r_diffuse / r_sum;
float p_specular = r_specular / r_sum;
float p_clearcoat = r_clearcoat / r_sum;
// 根据概率混合 pdf
float pdf = p_diffuse * pdf_diffuse
+ p_specular * pdf_specular
+ p_clearcoat * pdf_clearcoat;
pdf = max(1e-10, pdf);
return pdf;
}
然后魔改一下路径追踪的主代码。首先获取一个方向 L,然后计算该方向上的 BRDF 和 pdf 的值,最后叠加这条路径的贡献。其中 hdrColor 是获取方向 L 上 hdr 贴图的颜色:
// 路径追踪 -- 重要性采样版本
vec3 pathTracingImportanceSampling(HitResult hit, int maxBounce) {
vec3 Lo = vec3(0); // 最终的颜色
vec3 history = vec3(1); // 递归积累的颜色
for(int bounce=0; bounce<maxBounce; bounce++) {
vec3 V = -hit.viewDir;
vec3 N = hit.normal;
// 获取 3 个随机数
float xi_1, xi_2, xi_3 = ...
// 采样 BRDF 得到一个方向 L
vec3 L = SampleBRDF(xi_1, xi_2, xi_3, V, N, hit.material);
float NdotL = dot(N, L);
if(NdotL <= 0.0) break;
// 发射光线
Ray randomRay;
randomRay.startPoint = hit.hitPoint;
randomRay.direction = L;
HitResult newHit = hitBVH(randomRay);
// 获取 L 方向上的 BRDF 值和概率密度
vec3 f_r = BRDF_Evaluate(V, N, L, hit.material);
float pdf_brdf = BRDF_Pdf(V, N, L, hit.material);
if(pdf_brdf <= 0.0) break;
// 未命中
if(!newHit.isHit) {
vec3 color = hdrColor(L);
Lo += history * color * f_r * NdotL / pdf_brdf;
break;
}
// 命中光源积累颜色
vec3 Le = newHit.material.emissive;
Lo += history * Le * f_r * NdotL / pdf_brdf;
// 递归(步进)
hit = newHit;
history *= f_r * NdotL / pdf_brdf; // 累积颜色
}
return Lo;
}
来看看效果。粗糙度为 0.1,金属度为 0,这样既有漫反射,也有镜面反射。在同样的采样数下,漫反射采样的效果差不多。而重要性采样的镜面反射在噪点这一块则获得了压制性的胜利:
太棒了,再来一张迭代久一点的:
这张图片几乎能够以假乱真,也是我最喜欢的一张图
9. HRD 环境贴图重要性采样
目前为止我们都使用这一张环境贴图:
并非其他的贴图不好看,而是其他的贴图他们存在许多 高频光源,如图,每个小路灯都是亮度高达几百的像素:
对 HDR 贴图的均匀采样使得光线追踪在 漫反射 的时候能够偶发性的积累一次很大的亮度,导致噪点:
不难想到用 hdr 贴图的亮度作为概率密度函数。于是我们需要生成 符合 HDR 贴图的亮度分布的样本 来采样 HDR 贴图,这样才能避免噪点!
注:
看懂下面的内容需要一定的概率论基础,至少要知道概率密度,分布是什么…
不过相信我,真的不难,真的
9.1. 以亮度做 pdf 生成样本
回顾概率论,对于生成符合某一概率密度函数 p d f ( x ) pdf(x) pdf(x) 的样本,在连续的情况下,我们可以求出概率分布函数 c d f ( x ) cdf(x) cdf(x) 的 解析解,然后取其反函数 c d f − 1 ( x ) cdf^{-1}(x) cdf−1(x),根据概率分布的定义:
c d f ( x ) = P { X ≤ x } = ξ cdf(x) = P\{ X \le x\}=\xi cdf(x)=P{X≤x}=ξ
两边取反函数得到:
c d f − 1 ( c d f ( x ) ) = c d f − 1 ( ξ ) = x cdf^{-1}(cdf(x)) =cdf^{-1}(\xi)= x cdf−1(cdf(x))=cdf−1(ξ)=x
我们随机 roll 一个随机数 ξ \xi ξ,然后带入 c d f − 1 ( ξ ) cdf^{-1}(\xi) cdf−1(ξ) 即可得到一个服从 p d f ( x ) pdf(x) pdf(x) 分布的样本 x x x 的值。对于 Disney BRDF 的重要性采样的 θ h \theta_h θh 的分布函数就是这么推导出来的
这么说有些抽象,我们再来看离散的情况:假设随机变量 x 有三个取值 5,6,7,而取他们的概率分别是 0.2,0.3,0.5,即
p d f ( 5 ) = 0.2 , p d f ( 6 ) = 0.3 , p d f ( 7 ) = 0.5 pdf(5)=0.2, \ pdf(6)=0.3, \ pdf(7)=0.5 pdf(5)=0.2, pdf(6)=0.3, pdf(7)=0.5
我们通过数组的前缀和操作,求随机变量 x 的概率分布函数:
x=5 | x=6 | x=7 |
---|---|---|
cdf(5)=0.2 | cdf(6)=0.5 | cdf(7)=1.0 |
那么我们 roll 一个 0 到 1 的随机数 ξ \xi ξ,有三种情况:
- 如果 ξ ∈ [ 0 , 0.2 ] \xi \in [0, 0.2] ξ∈[0,0.2],那么 x 取 5
- 如果 ξ ∈ [ 0.2 , 0.5 ] \xi \in [0.2, 0.5] ξ∈[0.2,0.5],那么 x 取 6
- 如果 ξ ∈ [ 0.5 , 1.0 ] \xi \in [0.5, 1.0] ξ∈[0.5,1.0],那么 x 取 7
假设我们的 ξ \xi ξ 取 0.34,我们在离散的 cdf 表格里面进行一次 lower bound 操作,即找到 第一个大于等于自己的 cdf 取值,这里也可以使用二分搜索,因为 cdf 是递增的
这里我们找到 c d f ( 6 ) = 0.5 > 0.34 cdf(6)=0.5 > 0.34 cdf(6)=0.5>0.34,于是立即推 ξ ∈ [ 0.2 , 0.5 ] \xi \in [0.2, 0.5] ξ∈[0.2,0.5] ,所以 x 取 6
大功告成!至此我们掌握了生成符合任意分布的 一维 离散随机变量
对于 HRD 贴图,它的概率密度函数就是它的亮度,拥有 两个维度 的随机变量,这就要用到二维联合分布和边缘分布了。首先对每一列做前缀和,得到 X 的 一维的 边缘概率密度函数:
用上文的一维离散情况下的 lower bound 方法,生成符合 X X X 概率密度的样本 x x x,然后选定第 x x x 列。这里假设我们采样选定了 x = 2 x=2 x=2 这一列,我们就得到了 在 x=2 条件下的联合概率密度 即 f X Y ( x , y ) f_{XY}(x, y) fXY(x,y) 如图:
而对于二维分布,我们假定 X , Y X,Y X,Y 独立,那么有:
f X Y ( x , y ) = f Y ∣ X ( y ) ∗ f X ( x ) f_{XY}(x, y) = f_{Y | X}(y) * f_X(x) fXY(x,y)=fY∣X(y)∗fX(x)
而我们刚刚是以 0.35 0.35 0.35 的概率选中 x = 2 x=2 x=2 这一样本的,那么有 f X ( x ) = 0.35 f_X(x)=0.35 fX(x)=0.35,于是立即将联合分布 f X Y ( x , y ) f_{XY}(x, y) fXY(x,y) 除以 X 的边缘分布 f X ( x ) = 0.35 f_X(x)=0.35 fX(x)=0.35 得到 Y Y Y 在 X = x X=x X=x 条件下的 条件概率密度 即 f Y ∣ X ( y ) f_{Y | X}(y) fY∣X(y) 如图 :
然后在用上文的一维离散情况下的 lower bound 方法,生成符合 f Y ∣ X ( y ) f_{Y | X}(y) fY∣X(y) 分布的样本点 y y y,而且前面我们也取了 x = 2 x=2 x=2,所以最终得到一个二维样本 (x,y),并且这个样本是符合二维联合概率密度函数的分布的!
ok 说了这么多,总的来说分为这么几个步骤:
- 将 HDR 贴图归一化为 概率密度
- 对每一列做累加,得到 X 的 边缘 概率密度 f X ( x ) f_X(x) fX(x),同时前缀和计算 F X ( x ) F_X(x) FX(x)
- 对每一列所有元素,除以那一列的 f X ( x ) f_X(x) fX(x) 得到 条件 概率密度 f Y ∣ X ( y ) f_{Y | X}(y) fY∣X(y)
- 对每个 f Y ∣ X ( y ) f_{Y | X}(y) fY∣X(y) 做前缀和得到 分布 函数 F Y ∣ X ( y ) F_{Y | X}(y) FY∣X(y)
然后生成随机变量 x,y 的时候有:
- 选取 2 个随机数 ξ 1 , ξ 2 \xi_1, \xi_2 ξ1,ξ2
- 用 ξ 1 \xi_1 ξ1 在 F X ( x ) F_X(x) FX(x) 中 lower bound 得到 x
- 选取第 x 列,用 ξ 2 \xi_2 ξ2 在第 x 列的条件分布 F Y ∣ X ( y ) F_{Y | X}(y) FY∣X(y) 中 lower bound 得到 y
不过每次 lower bound 似乎有些浪费时间,我们可以 预设 一些 ξ 1 , ξ 2 \xi_1, \xi_2 ξ1,ξ2 的值,通常让他们取遍整个 [ 0 , 1 ] 2 [0,1]^2 [0,1]2 的二维平面然后 预计算 每个 ξ 1 , ξ 2 \xi_1, \xi_2 ξ1,ξ2 对应的 (x, y) 的值,存储到一张 hdrCache 纹理
height 行 width 列的 cache 纹理是这么定义的: c a c h e [ i ] [ j ] cache[i][j] cache[i][j] 表示 ξ 1 = i h e i g h t , ξ 2 = j w i d t h \xi_1=\frac{i}{height}, \xi_2=\frac{j}{width} ξ1=heighti,ξ2=widthj 的时候,对应的样本着 (x, y) 的值。色器每次生成随机的 μ 1 , μ 2 \mu_1, \mu_2 μ1,μ2 做纹理坐标然后直接 O(1) 时间查 cache 获得样本 (x, y) 即可
取原图像分辨率作为 cache 分辨率,用 python 简单模拟一下,代码放到 这里 了,结果如下:
看上去完全没有问题,接着我们在 c++ 和着色器中实现它!
9.2. 在 c++ 中预计算 hdr cache
我们使用的是 HDR Image Loader,这是一个轻量级的库,能够以 float 数组的形式返回 HDR 图片的浮点数值,它的返回结构是这么定义的:
它的浮点数组是按行存储的,于是我们可以通过:
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
float R = cols[3 * (i * width + j)];
float G = cols[3 * (i * width + j) + 1];
float B = cols[3 * (i * width + j) + 2];
}
}
来访问像素 i,j 的 RGB 分量。其中 i 是行 j 是列。我们需要传送 3 个数字到着色器,分别是 cache 的 xy 样本坐标,和该位置对应的概率密度 pdf 的值,于是一块 RGB32F 的纹理完全够用!
下面是根据原始的 HDR 像素值生成 HRD cache 纹理的函数,pdf 和 cdf 的计算和上文一致,最后返回的 float 数组可以直接传入 OpenGL 中做纹理,cache 纹理的 RG 通道是 xy 的值,用随机数 ξ 1 , ξ 2 \xi_1, \xi_2 ξ1,ξ2 做纹理坐标得到 xy,然后 B 通道是该像素对应的概率密度 pdf :
// 计算 HDR 贴图相关缓存信息
float* calculateHdrCache(float* HDR, int width, int height) {
float lumSum = 0.0;
// 初始化 h 行 w 列的概率密度 pdf 并 统计总亮度
std::vector<std::vector<float>> pdf(height);
for (auto& line : pdf) line.resize(width);
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
float R = HDR[3 * (i * width + j)];
float G = HDR[3 * (i * width + j) + 1];
float B = HDR[3 * (i * width + j) + 2];
float lum = 0.2 * R + 0.7 * G + 0.1 * B;
pdf[i][j] = lum;
lumSum += lum;
}
}
// 概率密度归一化
for (int i = 0; i < height; i++)
for (int j = 0; j < width; j++)
pdf[i][j] /= lumSum;
// 累加每一列得到 x 的边缘概率密度
std::vector<float> pdf_x_margin;
pdf_x_margin.resize(width);
for (int j = 0; j < width; j++)
for (int i = 0; i < height; i++)
pdf_x_margin[j] += pdf[i][j];
// 计算 x 的边缘分布函数
std::vector<float> cdf_x_margin = pdf_x_margin;
for (int i = 1; i < width; i++)
cdf_x_margin[i] += cdf_x_margin[i - 1];
// 计算 y 在 X=x 下的条件概率密度函数
std::vector<std::vector<float>> pdf_y_condiciton = pdf;
for (int j = 0; j < width; j++)
for (int i = 0; i < height; i++)
pdf_y_condiciton[i][j] /= pdf_x_margin[j];
// 计算 y 在 X=x 下的条件概率分布函数
std::vector<std::vector<float>> cdf_y_condiciton = pdf_y_condiciton;
for (int j = 0; j < width; j++)
for (int i = 1; i < height; i++)
cdf_y_condiciton[i][j] += cdf_y_condiciton[i - 1][j];
// cdf_y_condiciton 转置为按列存储
// cdf_y_condiciton[i] 表示 y 在 X=i 下的条件概率分布函数
std::vector<std::vector<float>> temp = cdf_y_condiciton;
cdf_y_condiciton = std::vector<std::vector<float>>(width);
for (auto& line : cdf_y_condiciton) line.resize(height);
for (int j = 0; j < width; j++)
for (int i = 0; i < height; i++)
cdf_y_condiciton[j][i] = temp[i][j];
// 穷举 xi_1, xi_2 预计算样本 xy
// sample_x[i][j] 表示 xi_1=i/height, xi_2=j/width 时 (x,y) 中的 x
// sample_y[i][j] 表示 xi_1=i/height, xi_2=j/width 时 (x,y) 中的 y
// sample_p[i][j] 表示取 (i, j) 点时的概率密度
std::vector<std::vector<float>> sample_x(height);
for (auto& line : sample_x) line.resize(width);
std::vector<std::vector<float>> sample_y(height);
for (auto& line : sample_y) line.resize(width);
std::vector<std::vector<float>> sample_p(height);
for (auto& line : sample_p) line.resize(width);
for (int j = 0; j < width; j++) {
for (int i = 0; i < height; i++) {
float xi_1 = float(i) / height;
float xi_2 = float(j) / width;
// 用 xi_1 在 cdf_x_margin 中 lower bound 得到样本 x
int x = std::lower_bound(cdf_x_margin.begin(), cdf_x_margin.end(), xi_1) - cdf_x_margin.begin();
// 用 xi_2 在 X=x 的情况下得到样本 y
int y = std::lower_bound(cdf_y_condiciton[x].begin(), cdf_y_condiciton[x].end(), xi_2) - cdf_y_condiciton[x].begin();
// 存储纹理坐标 xy 和 xy 位置对应的概率密度
sample_x[i][j] = float(x) / width;
sample_y[i][j] = float(y) / height;
sample_p[i][j] = pdf[i][j];
}
}
// 整合结果到纹理
// R,G 通道存储样本 (x,y) 而 B 通道存储 pdf(i, j)
float* cache = new float[width * height * 3];
//for (int i = 0; i < width * height * 3; i++) cache[i] = 0.0;
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
cache[3 * (i * width + j)] = sample_x[i][j]; // R
cache[3 * (i * width + j) + 1] = sample_y[i][j]; // G
cache[3 * (i * width + j) + 2] = sample_p[i][j]; // B
}
}
return cache;
}
诚然上面的代码是无聊且死板的,但不得不承认 python 的 numpy 对多维数组的处理比 c++ 要来的优雅的多,在这一块 py 上分了
然后将生成 HDR 的代码改为:
// 生成纹理...
GLuint hdrMap = ...
GLuint hdrCache = ...
// hdr 全景图
HDRLoaderResult hdrRes;
bool r = HDRLoader::load("./HDR/circus_arena_4k.hdr", hdrRes);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB32F, hdrRes.width, hdrRes.height, 0, GL_RGB, GL_FLOAT, hdrRes.cols);
// hdr 重要性采样 cache
float* cache = calculateHdrCache(hdrRes.cols, hdrRes.width, hdrRes.height);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB32F, hdrRes.width, hdrRes.height, 0, GL_RGB, GL_FLOAT, cache);
然后在着色器中我们取 1,2 维度的 100 个 sobol 数做随机数 ξ 1 , ξ 2 \xi_1, \xi_2 ξ1,ξ2 (当然也可以取真正的随机数)得到对应的 (x,y) 描一下点看看效果:
左侧是原始 HDR 图像,中间是它的概率密度(乘以 1w 倍以明显),右边是我们生成的采样点。可以看到采样点都击中了光源很亮的区域,比如灯和中间的舞台,效果太棒了!
9.3. 在着色器中采样 hdr 贴图
对于 hdr 贴图的重要性采样往往用来计算 直接光照。我们先根据重要性采样得到 2D 的样本点 (x, y),然后通过 球坐标 将 xy 转三维向量 L 并发射一条射线,判断是否命中,如果射线 miss 则应该获取 L 方向上的 hdr 贴图值并计算该条路径的贡献
球坐标和 hdr 贴图的坐标定义如下, φ \varphi φ 的范围是 ± PI 而 θ \theta θ 的范围是 ± 0.5 PI,注意这和常规的定义略有不同:
于是摇两个随机数,然后采样 hdr cache 获得二维点的坐标 xy 并转 3D 坐标的代码如下:
// 采样预计算的 HDR cache
vec3 SampleHdr(float xi_1, float xi_2) {
vec2 xy = texture2D(hdrCache, vec2(xi_1, xi_2)).rg; // x, y
xy.y = 1.0 - xy.y; // flip y
// 获取角度
float phi = 2.0 * PI * (xy.x - 0.5); // [-pi ~ pi]
float theta = PI * (xy.y - 0.5); // [-pi/2 ~ pi/2]
// 球坐标计算方向
vec3 L = vec3(cos(theta)*cos(phi), sin(theta), cos(theta)*sin(phi));
return L;
}
有了出射方向,还需要知道 hdr 在该方向上的 pdf 是多少。hdr cache 的 b 通道就存储了这个值。对于一个三维的坐标 L ∈ [ − 1 , 1 ] 3 L \in [-1, 1]^3 L∈[−1,1]3 我们能够将其转换为二维纹理坐标 u v ∈ [ 0 , 1 ] 2 uv \in [0, 1]^2 uv∈[0,1]2,然后采样 cache.b 获得 pdf,注意纹理的 flip y 即可
值得注意的是我们直接采样像素得到的 pdf 的积分域是在整个 2D 图片上面,而最终我们的半球采样的积分域是法向半球,于是需要进行积分域的转换。对于 2D 纹理我们是直接 覆盖 到半球面的:
首先要根据球面和 2D 图像的 面积 进行一次积分的归一化,然后对于图像上的每个面积微元,包围到球面上的时候还要乘以 sin ( θ ) \sin(\theta) sin(θ) 做投影,这也很好理解,因为越靠近球的两极,图像就会被 squeeze,如图:
于是有最终的转换因子为:
w h 2 π 2 sin ( θ ) \frac{wh}{2 \pi ^2 \sin(\theta)} 2π2sin(θ)wh
于是通过方向向量得到该位置的 hdr 贴图的 pdf 的代码如下,toSphericalCoord 返回采样纹理的时候已经 flip y 了所以计算 theta 的时候要在 flip 回去,此外 hdrResolution 需要 c++ 通过 uniform 变量传入:
// 将三维向量 v 转为 HDR map 的纹理坐标 uv
vec2 toSphericalCoord(vec3 v) {
vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
uv /= vec2(2.0 * PI, PI);
uv += 0.5;
uv.y = 1.0 - uv.y;
return uv;
}
// 输入光线方向 L 获取 HDR 在该位置的概率密度
// hdr 分辨率为 4096 x 2048 --> hdrResolution = 4096
float hdrPdf(vec3 L, int hdrResolution) {
vec2 uv = toSphericalCoord(normalize(L)); // 方向向量转 uv 纹理坐标
float pdf = texture2D(hdrCache, uv).b; // 采样概率密度
float theta = PI * (0.5 - uv.y); // theta 范围 [-pi/2 ~ pi/2]
float sin_theta = max(sin(theta), 1e-10);
// 球坐标和图片积分域的转换系数
float p_convert = float(hdrResolution * hdrResolution / 2) / (2.0 * PI * PI * sin_theta);
return pdf * p_convert;
}
于是在主循环一开始加入直接采样 hdr 重要性采样的代码。首先采样 hdr cache 得到一个出射方向 L,然后做一次 可见性判断 如果 L 没有命中任何物体,那么计算这条光路的贡献,注意这里使用的是 hdr 贴图的 pdf 而不是 brdf 的 pdf,因为我们是按照 hdr 亮度的分布来采样的:
// HDR 环境贴图重要性采样
Ray hdrTestRay;
hdrTestRay.startPoint = hit.hitPoint;
hdrTestRay.direction = SampleHdr(rand(), rand());
// 进行一次求交测试 判断是否有遮挡
if(dot(N, hdrTestRay.direction) > 0.0) { // 如果采样方向背向点 p 则放弃测试, 因为 N dot L < 0
HitResult hdrHit = hitBVH(hdrTestRay);
// 天空光仅在没有遮挡的情况下积累亮度
if(!hdrHit.isHit) {
vec3 L = hdrTestRay.direction;
vec3 color = hdrColor(L);
float pdf_light = hdrPdf(L, hdrResolution);
vec3 f_r = BRDF_Evaluate(V, N, L, hit.material);
Lo += history * color * f_r * dot(N, L) / pdf_light; // 累计亮度
}
}
对比一下看看效果,1 次弹射仅有直接光照,可以看到因为光源过小,左侧的均匀采样无法有效命中所以很嘈杂,而右侧的 hdr 重要性采样能够有效的命中光源,并且投下真实的阴影:
10. 多重重要性采样
直接对 hdr 光源采样解决了均匀采样时小光源难以命中的问题。但仍然存在缺陷。如果 hdr 贴图中有较大的光源,而反弹表面的粗糙度很低,这意味着 BRDF 是个 delta function,几乎只在完美镜面反射方向有值,其他地方都是 0,这就导致了采样大面积光源的时候,射线方向 L 很难取到有 BRDF 值的方向
如图,红色射线是标准镜面反射,只有红色方向有 BRDF,而蓝色射线则是对大面积光源采样的射线,于是很难得到正确的估计:
这就导致了光滑平面无法有效采样大面积光源的镜面反射,产生噪点。下图是仅对光源直接采样的结果:
这里引用一下这张经典的图片,平板的粗糙度从上往下递增。直接采样 BRDF 在低粗糙度时表现良好,粗糙度一高光线就散开,就不容易命中小光源。而直接采样光源在高粗糙度,光源面积较大的时候表现良好,但粗糙度变低,BRDF 函数的尖峰更加锐利,就不容易命取到 BRDF 有值的方向:
对 BRDF 采样和对光源采样的效果是 互补 的,于是需要按照某种策略将他们的结果混合。一种启发性的方法是根据两种采样的概率密度的比率来进行混合。因为重要性采样是根据亮度贡献来采样,于是认为 在该点的 pdf 越大则,这条路径的贡献越大,越可信 ,根据概率密度计算最终的权重,一种启发性的公式是根据 pdf 的平方来计算:
w l i g h t = p d f l i g h t 2 p d f l i g h t 2 + p d f b r d f 2 w_{light} = \frac{pdf_{light}^2}{pdf_{light}^2+pdf_{brdf}^2} \\ wlight=pdflight2+pdfbrdf2pdflight2
w b r d f = p d f b r d f 2 p d f l i g h t 2 + p d f b r d f 2 w_{brdf} = \frac{pdf_{brdf}^2}{pdf_{light}^2+pdf_{brdf}^2} wbrdf=pdflight2+pdfbrdf2pdfbrdf2
说通俗点就是 diffuse 主要靠光源直接采样,specular 则主要靠 BRDF 采样。或者说粗糙表面主要靠光源重要性采样,光滑表面主要靠 BRDF 采样。用一个简单的函数就能根据两种概率密度得到混合权重,注意第一个参数是分子:
float misMixWeight(float a, float b) {
float t = a * a;
return t / (b*b + t);
}
在 直接对光源采样 的时候,同时需要计算采样方向 L 上的 BRDF 的 pdf,然后和 hdr 贴图的 pdf 进行混合,最后得到 mis_weight 并修正这条路径的贡献:
// 获取采样方向 L 上的: 1.光照贡献, 2.环境贴图在该位置的 pdf, 3.BRDF 函数值, 4.BRDF 在该方向的 pdf
vec3 color = hdrColor(L);
float pdf_light = hdrPdf(L, hdrResolution);
vec3 f_r = BRDF_Evaluate(V, N, L, hit.material);
float pdf_brdf = BRDF_Pdf(V, N, L, hit.material);
// 多重重要性采样
float mis_weight = misMixWeight(pdf_light, pdf_brdf);
Lo += mis_weight * history * color * f_r * dot(N, L) / pdf_light;
而在 间接光照 中,如果射线 miss 了,也要从积累 hdr 贴图积累亮度,这里也要进行多重重要性采样,和上面类似,只是注意这里计算 mis_weight 时,分子应该是 pdf_brdf 才对,因为间接光照是根据 BRDF 的分布发射的光线:
// 未命中
if(!newHit.isHit) {
vec3 color = hdrColor(L);
float pdf_light = hdrPdf(L, hdrResolution);
// 多重重要性采样
float mis_weight = misMixWeight(pdf_brdf, pdf_light);
Lo += mis_weight * history * color * f_r * NdotL / pdf_brdf;
break;
}
来看看 50 spp 的效果,右侧是多重重要性采样的结果,不多说了爆杀了好吧:
此外,其实还有一个问题,那就是在 hdr 背景极端高频的情况下,第一次 diffuse 第二次 specular 的间接光照路径会产生一些噪点,因为光滑 specular 的 BRDF 太大了,一旦取到光源,那么就会带来即使是 multi importance sampling 也难以消除的 firefly,如图:
我被这个问题困扰了很久,终于在网上搜到了 这篇博客 ,作者说在间接光照中忽略 specular 的贡献即可,但是会导致金属物体的反射失常(因为金属只有 specular)博客的原话是这样的:
而作者自己的解决方案则是手动设置一个 indirect specular factor 以控制 specular 在间接光中的亮度:
出于正确性我并没有加入这段代码,这里仅记录一种解决的思路:
11. 总结与后记
光线追踪的优化就是不断在和噪点斗争的过程。我们使用 sobol sequence 生成更加均匀的样本,使用 importance sampling 来优化抽样的策略,最后启发性地使用多重重要性采样进行微调。正是这些数学,概率论,统计学知识结合起来,才能呈现一个色彩缤纷的茶壶:
这也是 graphic & rendering 被人们称之为 hard core 的地方,但是探索学习的过程确非常的 attractive,我查阅了众多 blog 和 code,天天蕃襁去外国学校的网站偷 ptt 来看,上 GitHub 偷代码… 经常性的十点半回宿舍洗澡原批吃东西看 b 站到 12 点,然后查资料,看博客,写代码,debug 到 2 点,第二天早上又八点半润去图书馆和看张宇大叔,这样断断续续地完成了这篇博客的写作
诚然在这种 high push 的环境下写出的代码好似 BS,并且有一些 fatal 的 bug,比如一开头的 Stanford Dragon,那个模型的法向插值就有问题,我不得不使用三角形的法线代替插值的法线,好在模型的面数高达 202520 以至于看不出区别:
不出意外后面还有一篇降噪和实时的文章,可能要等 12 月底之后在说了,就这样吧先(摆
12. 引用与参考
[1] Brent Burley, Walt Disney Animation Studios 2012, “Physically-Based Shading at Disney”
[2] Stephen Joe and Frances Kuo, “Sobol sequence generator”
[3] Stephen Joe and Frances Y. Kuo, “Notes on generating Sobol
sequences”
[4] cs184.eecs.berkeley.edu, “Project 3-2, Part 3: Environment Map Lights”
[5] blender (document), “Cycles Sampling Patterns, Sobol”
[6] Shih-Chin, “Implementing Disney Principled BRDF in Arnold”
[7] Matt Pharr, Wenzel Jakob, and Greg Humphreys, “Physically Based Rendering: From Theory To Implementation”
[8] knightcrawler25 (GitHub), “GLSL-PathTracer”
[9] 文刀秋二 (知乎), “低差异序列(一)- 常见序列的定义及性质”
[10] ksg fk (知乎), “【Nori】任务5:路径追踪,微表面模型和多重重要性采样”
[11] Image Synthesis (CS474 F16), “Assignment 4 – Image-based Lighting”
[12] Dezeming (CSDN), “多重重要性采样(MIS)与光线追踪技术”
标签:采样,xi,sobol,渲染,float,vec3,pdf,追踪 来源: https://blog.csdn.net/weixin_44176696/article/details/119988866