PBR教程
转载自最早发布在formu.ziyuesinicization.site的文章
PBR,或者用更通俗一些的称呼是指基于物理的渲染(Physically Based Rendering),它指的是一些在不同程度上都基于与现实世界的物理原理更相符的基本理论所构成的渲染技术的集合。正因为基于物理的渲染目的便是为了使用一种更符合物理学规律的方式来模拟光线,因此这种渲染方式与我们原来的Phong或者Blinn-Phong光照算法相比总体上看起来要更真实一些。除了看起来更好些以外,由于它与物理性质非常接近,因此我们(尤其是美术师们)可以直接以物理参数为依据来编写表面材质,而不必依靠粗劣的修改与调整来让光照效果看上去正常。使用基于物理参数的方法来编写材质还有一个更大的好处,就是不论光照条件如何,这些材质看上去都会是正确的,而在非PBR的渲染管线当中有些东西就不会那么真实了。
虽然如此,基于物理的渲染仍然只是对基于物理原理的现实世界的一种近似,这也就是为什么它被称为基于物理的着色(Physically based Shading) 而非物理着色(Physical Shading)的原因。
这个教程使用的是最先由迪士尼(Disney)提出探讨并被Epic Games首先应用于实时渲染的PBR方案。
首先,PBR认为任何平面都可以用被称为微平面(Microfacets)的细小镜面来进行描绘。根据平面粗糙程度的不同,这些细小镜面的取向排列可以相当不一致。产生的效果就是:一个平面越是粗糙,这个平面上的微平面的排列就越混乱。这些微小镜面这样无序取向排列的影响就是,当我们特指镜面光/镜面反射时,入射光线更趋向于向完全不同的方向发散(Scatter)开来,进而产生出分布范围更广泛的镜面反射。而与之相反的是,对于一个光滑的平面,光线大体上会更趋向于向同一个方向反射,造成更小更锐利的反射。当我们去看这个平面时,就会看到一些被遮挡的地方,就像这样:
在微观尺度下,没有任何平面是完全光滑的。然而由于这些微平面已经微小到无法逐像素地继续对其进行区分,因此我们假设一个粗糙度(Roughness)参数,然后用统计学的方法来估计微平面的粗糙程度。我们可以基于一个平面的粗糙度来计算出众多微平面中,朝向方向沿着某个向量h方向的比例。这个向量h便是位于光线向量l和视线向量v之间的半程向量(Halfway Vector)。微平面的朝向方向与半程向量的方向越是一致,镜面反射的效果就越是强烈越是锐利。通过使用一个介于0到1之间的粗糙度参数,我们就能概略地估算微平面的取向情况了。
然后就是万变不离其宗的能量守恒了!
在PBR中,出射光线的能量永远不能超过入射光线的能量(发光面除外),随着粗糙度的上升,镜面反射区域会增加,但是镜面反射的亮度却会下降。如果每个像素的镜面反射强度都一样(不管反射轮廓的大小),那么粗糙的平面就会放射出过多的能量,而这样就违背了能量守恒定律。这也就是为什么正如我们看到的一样,光滑平面的镜面反射更强烈而粗糙平面的反射更昏暗。
为了遵守能量守恒定律,我们需要对漫反射光和镜面反射光做出明确的区分。当一束光线碰撞到一个表面的时候,它就会分离成一个折射部分和一个反射部分。反射部分就是会直接反射开而不进入平面的那部分光线,也就是我们所说的镜面光照。而折射部分就是余下的会进入表面并被吸收的那部分光线,也就是我们所说的漫反射光照。
这里还有一些细节需要处理,因为当光线接触到一个表面的时候折射光是不会立即就被吸收的。通过物理学我们可以得知,光线实际上可以被认为是一束没有耗尽就不停向前运动的能量,而光束是通过碰撞的方式来消耗能量。每一种材料都是由无数微小的粒子所组成,这些粒子都能如下图所示一样与光线发生碰撞。这些粒子在每次的碰撞中都可以吸收光线所携带的一部分或者是全部的能量而后转变成为热量。
一般来说,并非全部能量都会被吸收,而光线也会继续沿着(基本上)随机的方向发散,然后再和其他的粒子碰撞直至能量完全耗尽或者再次离开这个表面。而光线脱离物体表面后将会协同构成该表面的(漫反射)颜色。不过在基于物理的渲染之中我们进行了简化,假设对平面上的每一点所有的折射光都会被完全吸收而不会散开。而有一些被称为次表面散射(Subsurface Scattering)技术的着色器技术将这个问题考虑了进去,它们显著地提升了一些诸如皮肤,大理石或者蜡质这样材质的视觉效果,不过伴随而来的代价是性能的下降。
对于金属(Metallic)表面,当讨论到反射与折射的时候还有一个细节需要注意。金属表面对光的反应与非金属(也被称为介电质(Dielectrics))表面相比是不同的。它们遵从的反射与折射原理是相同的,但是所有的折射光都会被直接吸收而不会散开,只留下反射光或者说镜面反射光。亦即是说,金属表面只会显示镜面反射颜色,而不会显示出漫反射颜色。由于金属与电介质之间存在这样明显的区别,因此它们两者在PBR渲染管线中被区别处理,而我们将在文章的后面进一步详细探讨这个问题。
反射光与折射光之间的这个区别使我们得到了另一条关于能量守恒的经验结论:反射光与折射光它们二者之间是互斥的关系。无论何种光线,其被材质表面所反射的能量将无法再被材质吸收。因此,诸如折射光这样的余下的进入表面之中的能量正好就是我们计算完反射之后余下的能量。
我们按照能量守恒的关系,首先计算镜面反射部分,它的值等于入射光线被反射的能量所占的百分比。然后折射光部分就可以直接由镜面反射部分计算得出:
float kS = calculateSpecularComponent(...); // 反射/镜面 部分
float kD = 1.0 - ks; // 折射/漫反射 部分
这样我们就能在遵守能量守恒定律的前提下知道入射光线的反射部分与折射部分所占的总量了。按照这种方法折射/漫反射与反射/镜面反射所占的份额都不会超过1.0,如此就能保证它们的能量总和永远不会超过入射光线的能量。
然后我们来看一下,PBR用的渲染方程:
这个公式大概是说,出射光的值等于入射光的值利用BRDF函数和法线和入射光的夹角余弦值进行修正在半球区域内的个个方向的值累加得到的。
但是这个公式我们可以简化一下,$L_{o}(p, \omega_{o})$就是某一点经光照计算后的颜色值,BRDF函数使用的模型为UE4的模型,大概长这样: $$ k_{d} \frac{c}{\pi} + k_{s} \frac{D \cdot F \cdot G}{4 \cdot (\omega_{o} \cdot n) \cdot (\omega_{i} \cdot n)} $$
这里的kd是早先提到过的入射光线中被折射部分的能量所占的比率,而ks是被反射部分的比率。加号左侧为Lambertian漫反射模型,c表示表面颜色(回想一下漫反射表面纹理)。除以π是为了对漫反射光进行标准化。
加号右侧为Cook-Torrance模型,公式中的DFG,分别代表:
法线分布函数(Normal Distribution Function):估算在受到表面粗糙度的影响下,朝向方向与半程向量一致的微平面的数量。这是用来估算微平面的主要函数。
菲涅尔方程(Fresnel Rquation):描述了微平面自成阴影的属性。当一个平面相对比较粗糙的时候,平面表面上的微平面有可能挡住其他的微平面从而减少表面所反射的光线。
几何函数(Geometry Function):菲涅尔方程描述的是在不同的表面角下表面所反射的光线所占的比率。
每个函数又有不同的模型,我们再次采用UE4的模型:
D使用Trowbridge-Reitz GGX
F使用Fresnel-Schlick近似(Fresnel-Schlick Approximation)
G使用Smith’s Schlick-GGX
(下面的公式中n均指法向量)
Trowbridge-Reitz GGX
$$NDF_{GGXTR}(n, h, a) = \frac{a^2}{\pi \cdot ((n \cdot h)^2 \cdot (a^2 - 1) + 1)^2}$$
在这里h表示用来与平面上微平面做比较用的半程向量,而a表示表面粗糙度。
Schlick-GGX:
$$G_{SchlickGGX}(n, v, k) = \frac{n \cdot v}{(n \cdot v) * (1- k) + k}$$
其中v为方向,k是粗糙度a的重映射(Remapping),它有两套计算方法:
$$k_{direct} = \frac{(a + 1)^2}{8}$$
$$k_{IBL} = \frac{a^2}{2}$$
最后,我们把观察者-物体方向和光源-物体方向合在一起,也就是史密斯法(Smith’s method):
$$G_{Smith} = G_{SchlickGGX}(n, v_{view}, k) * G_{SchlickGGX}(n, v_{light}, k)$$
菲涅尔方程,Fresnel-Schlick:
$$F_{Schlick}(h, v, F_{0}) = F_{0} + (1 - F_{0}) * (1 - (h \dot v)) ^ 5$$
其中F0为平面的基础反射率,v为观察者-物体方向。
要说明的是F0要用RGB三原色来表示,因为导体或者金属表面而言基础反射率一般是带有色彩的。
最后,我们为大多数电介质表面定义了一个近似的基础反射率。F0
取最常见的电解质表面的平均值,这又是一个近似值。不过对于大多数电介质表面而言使用0.04作为基础反射率已经足够好了,而且可以在不需要输入额外表面参数的情况下得到物理可信的结果。然后,基于金属表面特性,我们要么使用电介质的基础反射率要么就使用F0来作为表面颜色。因为金属表面会吸收所有折射光线而没有漫反射,所以我们可以直接使用表面颜色纹理来作为它们的基础反射率。代码中我们可以这么写:
vec3 F0 = vec3(0.04);
F0 = mix(F0, surfaceColor.rgb, metalness);
metalness为金属度。
最后是$L_{i}(p, \omega_{i})$这个其实可以理解为光源发出的光的强度,也就是辐照度,这里我们简化为:
float distance = length(lightPositions[i] - WorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = lightColors[i] * attenuation;
if(window.hljsLoader && !document.currentScript.parentNode.hasAttribute('data-s9e-livepreview-onupdate')) {
window.hljsLoader.highlightBlocks(document.currentScript.parentNode);
}
没错利用距离的衰减乘光源颜色得到,radiance正是我们刚刚说的辐照度,它的一些严格定义可以去看辐射度量学的内容,这里不过多赘述,不过如果你想完全掌握PBR这个强大的工具,建议还是去看一下。
做完这些还没完,后处理我们需要HDR,不然画面会看得很奇怪。最后给出一个GLSL的参考代码:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;
// material parameters
uniform vec3 albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;
// lights
uniform vec3 lightPositions[4];
uniform vec3 lightColors[4];
uniform vec3 camPos;
const float PI = 3.14159265359;
float DistributionGGX(vec3 N, vec3 H, float roughness);
float GeometrySchlickGGX(float NdotV, float roughness);
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness);
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness);
void main()
{
vec3 N = normalize(Normal);
vec3 V = normalize(camPos - WorldPos);
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
// reflectance equation
vec3 Lo = vec3(0.0);
for(int i = 0; i < 4; ++i)
{
// calculate per-light radiance
vec3 L = normalize(lightPositions[i] - WorldPos);
vec3 H = normalize(V + L);
float distance = length(lightPositions[i] - WorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = lightColors[i] * attenuation;
// cook-torrance brdf
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic;
vec3 nominator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001;
vec3 specular = nominator / denominator;
// add to outgoing radiance Lo
float NdotL = max(dot(N, L), 0.0);
Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}
vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color = ambient + Lo;
color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));
FragColor = vec4(color, 1.0);
}
float DistributionGGX(vec3 N, vec3 H, float roughness)
{
float a = roughness*roughness;
float a2 = a*a;
float NdotH = max(dot(N, H), 0.0);
float NdotH2 = NdotH*NdotH;
float num = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;
return num / denom;
}
float GeometrySchlickGGX(float NdotV, float roughness)
{
float r = (roughness + 1.0);
float k = (r*r) / 8.0;
float num = NdotV;
float denom = NdotV * (1.0 - k) + k;
return num / denom;
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx2 = GeometrySchlickGGX(NdotV, roughness);
float ggx1 = GeometrySchlickGGX(NdotL, roughness);
return ggx1 * ggx2;
}
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
完~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
参考资料:
https://learnopengl-cn.github.io/07%20PBR/01%20Theory/
https://learnopengl-cn.github.io/07%20PBR/02%20Lighting/
http://blog.selfshadow.com/publications/s2013-shading-course/karis/s2013_pbs_epic_notes_v2.pdf