


光照
在本章节中,我们将重点放在把以前讨论过的理论转化为实际的渲染器,这个渲染器将使用直接的(或解析的)光源:比如点光源,定向灯或聚光灯。
我们先来看看上一个反射方程:

基于以上的知识,我们如何将其转化为以前的教程中积累的一些光照知识呢?
那么想象一下,我们有一个点光源(一个光源在所有方向具有相同的亮度),它的辐射通量为用RBG表示为(23.47,21.31,20.79)。
该光源的辐射强度(Radiant Intensity)等于其在所有出射光线的辐射通量。
然而,当我们为一个表面上的特定的点p着色时,在其半球领域Ω的所有可能的入射方向上,只有一个入射方向向量ωi直接来自于该点光源。

假设点光源不受光线衰减,无论我们把光源放在哪,入射光线的辐射率总是一样的(除去入射角cosθ对辐射率的影响之外)。
对于均匀点光源来说:

这里用一个简化的做法,正是因为无论我们从哪个角度观察它,点光源总具有相同的辐射强度,我们可以有效地将其辐射强度建模为其辐射通量: 一个常量向量(23.47,21.31,20.79)。
辐射率也需要将位置p作为输入,点光源的辐射强度应该根据点p所在的位置和光源的位置以及他们之间的距离而做一些缩放。
根据原始的辐射方程,我们会根据表面法向量n和入射角度wi来缩放光源的辐射强度。
在实现上来说,对于直接点光源的情况:
vec3 lightColor = vec3(23.47, 21.31, 20.79);
vec3 wi = normalize(lightPos - fragPos);
float cosTheta = max(dot(N, Wi), 0.0);
float attenuation = calculateAttenuation(fragPos, lightPos);
float radiance = lightColor * attenuation * cosTheta; lightColor是辐射强度(这里假设都是点,所以我们忽略面积这个参数),当涉及到直接照明(direct lighting)时,radiance的计算方式和我们之前计算当只有一个光源照射在物体表面的时候非常相似。(个人理解,这里按理来说应该是Irradiance,但是假定只有1个光源,且只有1束光打到了这个点上,所以irradiance = radiance)

请注意,这个假设是成立的条件是点光源体积无限小,相当于在空间中的一个点。如果我们认为该光源是具有体积的,它的辐射会在一个以上的入射光的方向不等于零。
对于其它类型的从单点发出来的光源我们类似地计算出radiance。
比如,定向光(directional light)拥有恒定的wiwi而不会有衰减因子;而一个聚光灯光源则没有恒定的辐射强度,其辐射强度是根据聚光灯的方向向量来缩放的。
由于我们事先知道的所有贡献光源的位置,因此对物体表面上的一个点着色并不需要我们尝试去求解积分。我们可以直接拿光源的(已知的)数目,去计算它们的总辐照度,因为每个光源仅仅只有一个方向上的光线会影响物体表面的辐射率。
由于我们事先知道的所有贡献光源的位置,因此对物体表面上的一个点着色并不需要我们尝试去求解积分。
我们可以直接拿光源的(已知的)数目,去计算它们的总辐照度(Irradiance),因为每个光源仅仅只有一个方向上的光线会影响物体表面的辐射率。
这使得PBR对直接光源的计算相对简单,因为我们只需要有效地遍历所有有贡献的光源。
而当我们后来把环境照明也考虑在内的IBL教程中,我们就必须采取积分去计算了,这是因为光线可能会在任何一个方向入射。
当然,现阶段只讲直接光。
PBR表面模型
首先我们需要把PBR相关的输入放进片段着色器。
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;
uniform vec3 camPos;
uniform vec3 albedo;
uniform float metallic;
uniform float roughness;
uniform float ao; 然后再片段着色器的开始部分我们做一下任何光照算法都需要做的计算:
void main()
{
vec3 N = normalize(Normal);
vec3 V = normalize(camPos - WorldPos);
[...]
}
#1 直接光照明
在本教程的例子中我们会采用总共4个点光源来直接表示场景的辐照度。
首先我们来计算一些可以预计算的光照变量:
vec3 Lo = vec3(0.0);
for(int i = 0; i < 4; ++i)
{
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;
[...] 由于我们线性空间内计算光照(我们会在着色器的尾部进行Gamma校正),我们使用在物理上更为准确的平方倒数作为衰减。
对于每一个光源我们都想计算完整的 Cook-Torrance specular BRDF项。
首先我们想计算的是镜面反射和漫反射的系数, 我们从上一个教程知道可以使用菲涅尔方程计算:
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
} 菲涅尔方程返回的是一个物体表面光线被反射的百分比, 也就是我们反射方程中的参数ks。
这个参数F0会因为材料不同而不同,绝缘体F0为0.04,材质是金属发生变色。
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0); 我们已经算出FF, 剩下的项就是计算法线分布函数D和几何遮蔽函数G了。
直接PBR光照着色器中DD和GG的计算代码类似于:
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 nom = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;
return nom / denom;
}
float GeometrySchlickGGX(float NdotV, float roughness)
{
float r = (roughness + 1.0);
float k = (r*r) / 8.0;
float nom = NdotV;
float denom = NdotV * (1.0 - k) + k;
return nom / 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;
} 这里比较重要的是和上一个教程不同的是,我们直接传了粗糙度(roughness)参数给上述的函数;
通过这种方式,我们可以针对每一个不同的项对粗糙度做一些修改。
光照在几何遮蔽函数和正太分布函数中采用粗糙度的平方会让光照看起来更加自然。
现在两个函数都给出了定义:
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness); 这样我们就凑够了足够的项来计算Cook-Torrance BRDF:
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; 因为菲涅尔方程直接给出了kSkS, 我们可以使用表示镜面反射在所有打在物体表面上的光线的贡献。
从kS我们很容易计算折射的比值kD:
vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic; 更进一步来说,因为金属不会折射光线,因此不会有漫反射。所以如果表面是金属的,我们会把系数kDkD变为0。
这样,我们终于集齐所有变量来计算我们出射光线的值:
const float PI = 3.14159265359;
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;
#2 线性空间和HDR渲染
在线性空间中计算光照是非常重要的,因此我们需要在着色器最后做伽马矫正。
另外,我们希望所有光照的输入都尽可能的接近他们在物理上的取值,这样他们的反射率或者说颜色值就会在色谱上有比较大的变化空间。
Lo作为结果可能会变大得很快(超过1),但是因为默认的LDR输入而取值被截断。所以在伽马矫正之前我们采用色调映射使Lo从LDR的值映射为HDR的值。
color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2)); 采用线性颜色空间和HDR在PBR渲染管线中非常重要。如果没有这些操作,几乎是不可能正确地捕获到因光照强度变化的细节,这最终会导致你的计算变得不正确,在视觉上看上去非常不自然。
#3 完整的直接光PBR着色器
完整代码如下:
#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);
} 如果我们采用这个着色器,加上4个点光源和一些球体,同时我们令这些球体的金属性(metallic)和粗糙度(roughness)沿垂直方向和水平方向分别变化,我们会得到这样的结果:

完整代码看这里:https://learnopengl.com/code_viewer_gh.php?code=src/6.pbr/1.1.lighting/lighting.cpp
#4 带贴图的PBR
[...]
uniform sampler2D albedoMap;
uniform sampler2D normalMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D aoMap;
void main()
{
vec3 albedo = pow(texture(albedoMap, TexCoords).rgb, 2.2);
vec3 normal = getNormalFromNormalMap();
float metallic = texture(metallicMap, TexCoords).r;
float roughness = texture(roughnessMap, TexCoords).r;
float ao = texture(aoMap, TexCoords).r;
[...]
} 不过需要注意的是一般来说反射率(albedo)纹理在美术人员创建的时候就已经在sRGB空间了,因此我们需要在光照计算之前先把他们转换到线性空间。
一般来说,环境光遮蔽贴图(ambient occlusion maps)也需要我们转换到线性空间。不过金属性(Metallic)和粗糙度(Roughness)贴图大多数时间都会保证在线性空间中。
完整代码请看这里:https://learnopengl.com/code_viewer_gh.php?code=src/6.pbr/1.2.lighting_textured/lighting_textured.cpp