在前面的文章中我们已经给出了基于物理的渲染方程:

并介绍了直接光照的实现。然而在自然界中,一个物体不会单独存在,光源会照射到其他的物体上,反射的光会有一部分反射到物体上。为了模拟这种环境光照的形式,我们通过环境贴图来保存周围的环境信息,从而实现间接环境光照,因此这种光照也叫基于图像的光照 (Image Based Lighting,简称IBL)。

与直接光照不同,间接光照的入射方向位于法线的半球的各个方向,想要求的出射方向$omega_o$的辐射率,需要对半球方向上所有的入射光线求辐射率的积分。

这要求给定一定入射方向,能获取这个方向的辐射率,并且能在实时渲染中求解积分方程。下面我们来解决这两个问题。

一、蒙特卡洛方法

在实时渲染中求解积分是不太现实的,所以计算机图形学引入了蒙特卡洛方法来获得近似解。蒙特卡洛方法主要运用了概率论和统计学的知识,我们来简单了解一下原理。

概率密度函数

随机变量X用来表示随机事件,它的值用小些字母表示,如x,称为随机数。随机变量的输入集合也可以是另一种随机变量,这时我们将服从一种分布的随机变量转换为服从另一种分布的随机变量,如$Y = f(X)$。

随机变量$X$的每个值$x$都关联着每次样本抽样时这个值出现的概率,随机变量所有可能值组成的概率分布函数称为概率密度函数(probability density function,简称为$pdf$),用$p$表示。

在渲染方程中我们用到的随机变量都是连续型随机变量。随机连续变量$X$期望的期望为:

通常我们求的是与随机变量相关的函数$Y = f(X)$,Y的期望为:

大数定律

我们对随机变量$X$重复N次抽样,形成一系列的独立同分布的随机数,然后通过对这些随机数进行平均来近似上述的期望模型,这就是估计:

随着随机抽象数目N的增大,估计的方差逐渐减小。当N无限大时,随机变量的统计平均值趋近于期望的值:

大数定律是蒙特卡洛方法的基础,用随机抽样和统计试验求出近似解。

蒙特卡洛积分

假设$f(x)$服从概率密度函数$p(x)$,对下面的积分,有:

随意上述问题转化为了求$frac{f(x)}{p(x)}$的期望。根据大数定律,可得估计值。

在实时渲染中,PBR的积分方程主要由这种估计思想来求解。

二、立方体贴图

我们解决了实时渲染中求解积分的问题,接下来我们解决给定一个方向$w_i$,获取环境辐射率的问题。

我们可以将辐射率保存到环境贴图中,通过给定的方向,可以很方便地采样出环境的辐射率:

1
vec3 radiance = textureCube(cubemap, w_i).rgb;

在渲染中美术制作的环境贴图通常是HDR球形贴图,常见的有RGBE格式。我们需要将HDR贴图转换为CubeMap,然后对CubeMap计算积分。

将SphereMap转换为CubeMap

立方体贴图的面展开如下:

将立方体的各个面作为RenderTarget,把SphereMap的内容画到立方体的各个面中,就可以完成从SphereMap到CubeMap的转换。

从原点看向立方体的各个面,并设置相机以保证各个面采集的顺序是按照上图的方式排布的(上图中的X面和Z面的是以Y轴为摄像机的上方向采集的,采集Y面需要以X轴或Z轴为上方向采集,我在代码里选取的是Z轴)。代码如下:

1
2
3
4
5
6
7
8
9
Matrix4 cubeviews[] = 
{
Matrix4::CreateLookAtLH( Vector3::cOrigin, Vector3( 1.0f, 0.0f, 0.0f ), Vector3( 0.0f, 1.0f, 0.0f ) ),
Matrix4::CreateLookAtLH( Vector3::cOrigin, Vector3( -1.0f, 0.0f, 0.0f ), Vector3( 0.0f, 1.0f, 0.0f ) ), // x-
Matrix4::CreateLookAtLH( Vector3::cOrigin, Vector3( 0.0f, 1.0f, 0.0f ), Vector3( 0.0f, 0.0f, -1.0f ) ), // y+
Matrix4::CreateLookAtLH( Vector3::cOrigin, Vector3( 0.0f, -1.0f, 0.0f ), Vector3( 0.0f, 0.0f, 1.0f ) ), // y-
Matrix4::CreateLookAtLH( Vector3::cOrigin, Vector3( 0.0f, 0.0f, 1.0f ), Vector3( 0.0f, 1.0f, 0.0f ) ), // z+
Matrix4::CreateLookAtLH( Vector3::cOrigin, Vector3( 0.0f, 0.0f, -1.0f ), Vector3( 0.0f, 1.0f, 0.0f ) ), // z-
};

相机设置好之后,立方体的顶点归一化便可作为立方体贴图的采样方向。对每个CubeMap的面而言,在像素着色器中,将这个采样方向转换为SphereMap的UV,采样SphereMap,变可以把CubeMap转换为SphereMap。
Wiki给出了将3D向量转换为UV的方法:

对这样的一张球形HDR贴图:

在我使用的引擎中,上方向为$Z$轴,需要在shader中将$Y$轴和$Z$轴做了置换,转换后的CubeMap如下图:

GLSL代码如下。
vs:

1
2
3
4
5
6
7
8
attribute vec4 position;
varying vec4 opos;
uniform mat4 wvp;
void main()
{
gl_Position = wvp * vec4(position.xyz, 1.0);
opos = position;
}

ps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
varying vec4 opos;
uniform sampler2D layer0;
const float PI = 3.14159265359;
const float doublePI = 2 * PI;
vec2 SampleSphereMap(vec3 v)
{
v = normalize(v);
vec2 uv = vec2(atan2(v.y, v.x), acos(v.z));
uv /= vec2(doublePI, PI);
uv.x += 0.5;
return uv;
}
void main()
{
vec2 uv = SampleSphereMap(opos.xyz);
vec3 color = texture2D(layer0, uv).rgb;
gl_FragColor = vec4(color, 1.0);
}

将SphereMap转换为SphereMap后,我们再看在直接光照中使用的PBR渲染公式:

现在我们已经知道了求解积分需要的蒙特卡洛方法,但是在像素着色器里求解是不切实际的。所以我们会把这个积分预处理,将结果保存到贴图中。渲染方程分为两个部分:diffuse和specular,接下来对它们分别进行计算。

三、Diffuse IBL

Diffuse部分的渲染方程:

其中$L_i$是环境贴图的采样,$theta_i$是入射光线与法线的夹角。

将这个积分方程的计算结果保存到一张辐射率图中,由于$L_o$是在法线半球上的积分,所以在求环境光照的时候,用某个点的法线采样这个辐射率贴图得到的结果就是diffuse渲染方程的积分结果。

1
vec3 envdiffuse = textureCube(radianceMap, N).rgb;

下面我们来解这个积分方程。

立体角的微分$domega = sintheta dtheta dvarphi$,带入到渲染方程:

根据蒙特卡洛方法,将$phi$和$theta$离散化,分别分成$n1$和$n2$个采样。

同转换SphereMap一样,利用立方体的顶点当做法线,计算CubeMap在法线半球上的积分。结果如下图:

GLSL代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
varying vec4 opos;
uniform samplerCube layer0;
const float PI = 3.14159265359;
void main()
{
vec3 N = normalize(opos.xyz);
vec3 irradiance = vec3(0.0);
vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 T = normalize(cross(up, N));
vec3 B = normalize(cross(N, T));
const float sampleDelta = 0.025;
int samplenum = 0;
for (float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
{
for (float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
{
vec3 tagentnormal = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
vec3 worldnormal = tagentnormal.x * T + tagentnormal.y * B + tagentnormal.z * N;
irradiance += textureCube(layer0, worldnormal).rgb * cos(theta) * sin(theta);
samplenum ++;
}
}
irradiance = PI * irradiance / float(samplenum);
gl_FragColor = vec4(irradiance, 1.0);
}

同直接光照中一样,diffuse部分的着色要考虑金属度和菲涅尔效果。由于环境光照来自于法线半球的各个方向,不像直接光照一样有一个确定的半向量$h$来确定菲涅尔效果。还记得粗糙度roughness会影响微平面的半向量$h$吗?同样地,我们在菲涅尔公式里引入粗糙度,表面粗糙,菲涅尔效果要弱一点。下面是引入roughness的菲涅尔公式:

1
2
3
4
vec3 fresnelSchlickRoughness(vec3 F0, float cosa, float roughness)
{
return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(1.0 - cosa, 5.0);
}

结合金属度,可以计算出环境光照的diffuse分量:

1
2
3
4
5
vec3 IBLF0 = fresnelSchlickRoughness(F0, NDotV, roughness);
vec3 ambientKS = IBLF0;
vec3 ambientKD = (vec3(1.0) - ambientKS) * vec3(1.0 - metallic);
vec3 diffuseradiance = textureCube(layer9, normal).rgb;
vec3 envdiffuse = ambientKD * diffuseradiance * albedo;

与直接光照的部分相加得到的效果如下,可以看到,比直接光照相比有了非常大的提升。

四、Specular IBL

specular部分的渲染方程:

其中$f_{cook-torrance}frac{D(h)G(omega_i, omega_o,h)F(omega_o,h)}{4(ncdot omega_i)(ncdot omega_o)}$。

由于diffuse的辐射率积分只与入射立体角$omega_i$有关,可以把diffuse的常量部分移到积分外求解。但对specular而言,BDRF还依赖于出射立体角$omega_o$。我们已经知道只有光线方向在视线方向的反射方向的附近的光,才能够被人眼看到,但是环境光来自法线半球的各个方向, 无法确定入射方向$L$,但是我们可以通过得到半向量$h$来求入射光的方向$L$。接下来掉微平面模型详细分析推导出$h$。

法线分布函数


我们假设微平面(蓝色部分)是光滑平面(黑色部分)沿法线$n$做随机扰动后形成的不平整表面,其中红色部分是朝向半向量$h$附近的微面元集合。假设扰动前光滑平面的表面积和为$A$,扰动之后的表面积和为$A’$。定义$D(omega_h)domega_h$为所有朝向$omega_h$附近的微面元面积之和占A的比例。于是有$Acdot D(omega_h)domega_h$为朝向$omega_h$附近的所有微面元的面积之和。可以得到:

$D(omega_h)$有以下性质:

  1. $D(omega_h) ge 0$。
  2. 由于$A’ = int_{Omega} A cdot D(omega_h)domega_h ge A$,得 $$int_{Omega} D(omega_h)domega_h ge 1$。
  3. 光滑表面在任意方向$bf{v}$的投影面积,等于微平面在该方向的投影面积。于是有即$int_{Omega} D(omega_h)(mathbf{v}cdot mathbf{h}) domega_h = mathbf{v}cdot mathbf{n}$。特殊地取$mathbf{v} = mathbf{n} $,则有其中$theta$是$mathbf{h}$与$mathbf{n}$的夹角,这就满足了法线分布函数$D(omega_h)$的归一化条件,可以得到其概率密度函数为$p(omega_h) = D(omega_h)costheta$。

我们知道了在立体角下的概率密度函数,将其转换为球坐标系下基于$theta$和$phi$的$pdf$。由于$domega = sintheta dtheta dphi$,有

将我们采用的Trowbridge-Reitz GGX发现分布函数代入,得

由于$p(theta, phi)$式子中没有$phi$,很容易得到$p(theta)$:

再求$p(phi|theta)$,有

对$p(theta)$积分,得到其概率分布函数:

对$p(phi|theta)$求其概率分布函数:

这时我们可以假设随机数 $xi_0 = cdf_h(theta)$,$xi_1 = cdf_h(phi|theta)$,分别求得$theta$和$phi$。

得到$theta$和$phi$后,就可以得到笛卡尔坐标系下的$h$,之后便可以进行渲染方程的计算。

Hammersley Sequence

我们还有随机数$xi_0$和$xi_1$没有解决。蒙特卡洛传统的抽样方式是采用电脑的伪随机数来进行的,这种随机的特征导致每个随机数并不知道其他随机数的任何信息,所以其分布可能会出现丛聚,减慢了收敛速度。但如果使用某些 大专栏  基于物理的渲染——间接光照方法使随机数均匀地分布,那么蒙特卡洛方法的模拟过程不会受到影响,并且收敛速度可以加快。

产生较均匀分布的随机数生成方法叫低差异序列(Low Discrepancy Sequence),而我们使用的是其中的一种——Hammersley。伪随机数和Hammersley序列生成的点集对比如下图:

有兴趣了解原理的可以阅读这篇文章,在这里我们直接给出Hammersley序列的生成代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float RadicalInverse_VdC(uint bits) 
{
bits = (bits << 16u) | (bits >> 16u);
bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}
// ----------------------------------------------------------------------------
vec2 Hammersley(uint i, uint N)
{
return vec2(float(i)/float(N), RadicalInverse_VdC(i));
}

到这里随机数$xi_0$和$xi_1$的生成也搞定了。

GGX重要度采样

基于蒙特卡洛积分的求解方法,我们在半球$Omega$上生成一系列偏向半向量$h$的采样向量。与diffuse部分的求解类似,在一个循环内生成一系列低差异化的随机数,

1
2
3
4
5
const uint SAMPLE_COUNT = 1024u;
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
}

用这些随机数生成切线空间的向量,再将其转换到世界空间,就得到了半向量$h$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness)
{
float a = roughness * roughness;
float phi = 2.0 * PI * Xi.x;
float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a * a - 1.0) * Xi.y));
float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
vec3
H.x = cos(phi) * sinTheta;
H.y = sin(phi) * sinTheta;
H.z = cosTheta;
vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 T = normalize(cross(up, N));
vec3 B = normalize(cross(N, T));
vec3 normal = H.x * T + H.y * B + H.z * N;
return normalize(normal);
}

至此,半向量h的求解方法已经给出。再来看specular渲染方程:

用模特卡洛方法估计这个积分:

UE4用Split Sum Approximation将估计分为两个部分:

第一部分的计算结果存储到一张叫做Pre-Filter Envionment Map的CubeMap上。
第二部分的计算结果存储到一张叫做Environment BRDF的2D贴图上。

Pre-Filter Envionment Map

Split Sum的第一部分

可以简单地根据不同的粗糙度对CubeMap取均值,然后将不同粗糙度的结果存储到不同的MipMap中,也能取得不错的效果。这里我们采用UE4的方案,使用重要度采样的法线分布函数对CubeMap卷积。由于我们不知道视线方向,UE4假设其与采样的方向相同。

1
2
vec3 N = R;
vec3 V = R;

对CubeMap做不同roughness的卷积得到Pre-Filter Environment Map,glsl代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void main()
{
vec3 R = normalize(opos.xyz);
vec3 N = R;
vec3 V = R;
vec3 preFilter = vec3(0.0);
const uint sampleNum = 1024;
float totalWeight = 0.0;
for (uint i = 0u; i < sampleNum; i ++)
{
vec2 Xi = Hammersley(i, sampleNum);
vec3 H = ImportanceSampleGGX(Xi, N, weight.x);
vec3 L = normalize(2 * dot(V, H) * H - V);
float NDotL = max(dot(N, L), 0.0); if (NDotL > 0.0)
{
preFilter += textureCube(layer0, L).rgb * NDotL;
totalWeight += NDotL;
}
} preFilter /= totalWeight;
gl_FragColor = vec4(preFilter, 1.0);
}

由于在工具里没法看全Mipmap的CubeMap,用Learn OpenGL里的Pre-Filter Environment Map展示一下效果。

Environment BRDF

Split Sum的第二部分

式子里包含了一个概率密度函数,在前面我们已经给出$p(omega_h) = D(omega_h)(n cdot h)$,引用自Surface Reflection: Physical and Geometrical Perspectives

可以得到

将Cook-Torrance BRDF:$ f_{cook-torrance} = frac{D(h)G(omega_i, omega_o,h)F(omega_o,h)}{4(ncdot omega_i)(ncdot omega_o)}$ ,菲涅尔函数:$F(n, v, F_0) = F_0 + (1 - F_0) ( 1 - (n cdot v))^5$以及$p(omega_i,omega_o)$代入,有

所以蒙特卡洛估计被转换为与粗糙度和$omega_o cdot h$相关的式子,由于二者都在[0, 1]范围内,可以用这两个值作为UV,将上式的求和结果保存到一张2D的LUT里,也就是Environment BRDF。
UE中,IBL中计算几何遮蔽因子$G$的系数与直接光照的不同,在IBL中有$k = frac{alpha^2}{2}$,

1
2
3
4
5
6
7
8
float GeometrySchlickGGX(float NDotV, float roughness)
{
float a = roughness;
float k = (a * a) / 2.0;
float nom = NDotV;
float denom = NDotV * (1.0 - k) + k;
return nom / denom;
}

生成Environment BRDF的GLSL代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
varying vec2 otex0;
const float PI = 3.14159265359;
varying vec2 otex0;
const float PI = 3.14159265359; vec2 IntegrateBRDF(float NDotV, float roughness)
{
vec3 V;
V.x = sqrt(1.0 - NDotV * NDotV);
V.y = 0.0;
V.z = NDotV;
float A = 0.0;
float B = 0.0;
vec3 N = vec3(0.0, 0.0, 1.0);
const uint SAMPLE_COUNT = 1024u;
for (uint i = 0u; i < SAMPLE_COUNT; i ++)
{
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
vec3 H = ImportanceSampleGGX(Xi, N, roughness);
vec3 L = normalize(2.0 * dot(V, H) * H - V);
float NDotL = max(L.z, 0.0);
float NDotH = max(H.z, 0.0);
float VDotH = max(dot(V, H), 0.0); if (NDotL > 0.0);
{
float G = GeometrySmith(NDotL, NDotV, roughness);
float G_Vis = G * VDotH / (NDotH * NDotV + 0.001);
float Fc = pow(1.0 - VDotH + 0.001, 5.0);
A += (1.0 - Fc) * G_Vis;
B += Fc * G_Vis;
}
} A /= float(SAMPLE_COUNT);
B /= float(SAMPLE_COUNT);
return vec2(A, B);
}
void main()
{
vec2 brdf = IntegrateBRDF(otex0.x, otex0.y);
gl_FragColor = vec4(brdf.x, brdf.y, 0.0, 1.0);
}

计算出来的LUT如下图。

我们已经将IBL的diffuse部分的预计算结果加到材质shader里,现在处理specular部分的预计算结果Pre-Filter Environment Map和Environment BRDF。先根据roughness取出Pre-Filter部分,再与Environment BRDF部分结合。写成GLSL代码如下:

1
2
3
4
5
6
vec3 IBLF0 = fresnelSchlickRoughness(F0, NDotV, roughness);
const float MAX_REFLECTION_LOD = 4.0; vec3 preFilter = textureCubeLod(layerpreFilter, R, roughness * MAX_REFLECTION_LOD).rgb;
vec2 brdf = texture2D(layerbrdf, vec2(NDotV, roughness)).rg;
vec3 envspecular = preFilter * (IBLF0 * brdf.x + brdf.y);

最后将IBL的diffuse部分和specular部分相加就完全地实现了PBR,效果如下:

五、结语

完整地实现PBR之后,可以看出材质很真实。我写的PBR的实现主要参考了Learn OpenGL的PBR教程,这是一个很棒的系列教程,能够帮助新手对渲染方面有较为全面的认识。PBR实现中的公式我都有尽量去理解,去推导,但是能感觉到还有很多知识如辐射度量学、概率论、统计学、光学等知识需要补足,希望以后有机会深入了解。
没有在引擎里为项目实现PBR,很多坑没有踩到,在这里就从网上摘一些PBR的优点作为结尾吧。

  • 渲染效果很真实。
  • 金属工作流,能够很好的区分金属和非金属,去除浓浓的塑料感
  • 基于物理世界的真实参数,方便美术制作,不用依靠经验调参数
  • 同样的材质能够在不同的环境下表现。下雨天,黄昏等。

引用

  1. https://learnopengl.com/PBR/IBL/Diffuse-irradiance
  2. https://learnopengl.com/PBR/IBL/Specular-IBL
  3. https://interplayoflight.wordpress.com/2013/12/30/readings-on-physically-based-rendering/
  4. http://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/monte-carlo-methods-in-practice/monte-carlo-integration
  5. https://blog.csdn.net/baimafujinji/article/details/53869358
  6. http://www.codinglabs.net/article_physically_based_rendering_cook_torrance.aspx
  7. https://schuttejoe.github.io/post/ggximportancesamplingpart1/
  8. 全局光照技术
  9. https://www.tobias-franke.eu/log/2014/03/30/notes_on_importance_sampling.html
  10. https://agraphicsguy.wordpress.com/2015/11/01/sampling-microfacet-brdf/
  11. https://www.cnblogs.com/time-flow1024/p/10209093.html

最新文章

  1. mysql高并发和表类型
  2. Eclipse快捷键大全(转载)
  3. D3.js 力导向图
  4. CENTOS修改主机名
  5. linux 中的快捷键
  6. Mac nginx PCRE install ngnix
  7. 快速建立Linux c/c++编译环境
  8. java数据结构--线性结构
  9. 一个Tomcat及一个ip,绑定不同的域名,各个域名访问各自不同应用程序的配置方法
  10. Visual Studio写的项目在 IIS 服务器上运行的两种简单方法
  11. 匹配图片src正则
  12. SSDB是一个开源的高性能数据库服务器
  13. Openlayer 3 的画图测量面积
  14. Windows系统字体与文件对照表
  15. mobile meta iphone
  16. 利用whoosh对mongoDB的中文文档建立全文检索
  17. English Voice of &lt;&lt;Way Back Into Love&gt;&gt;
  18. python获取文件夹的大小(即取出所有文件计算大小)
  19. Win10+Ubuntu1604双系统
  20. Elasticsearch 基础入门

热门文章

  1. spring 事物面试题
  2. Python笔记_第四篇_高阶编程_GUI编程之Tkinter_5.鼠标事件
  3. selector.xml的使用
  4. Java快速输入输出
  5. Android 5.0 5.1 webview 闪退问题
  6. Python数据分析与展示第0&amp;1周学习笔记(北理工 嵩天)
  7. 关于JDBC、JdbcTemplate使用遇到的坑
  8. day32-socketserver
  9. 把Java代码转成c#可用的dll
  10. 动态指定log4net日志文件名称