2-光照

接下来我们将接触简单的PBR,实现Cook-Torrance的PBR光照模型。

知识迁移

在上一篇文章中,我们学习了PBR的基础知识,这里我们将实现简易的PBR渲染器,这个渲染器将使用直接的/解析的光源,例如点光源,定向光或聚光灯。

先看看上一篇文章中得到的Cook-Torrance反射方程: \[ \nonumber L_o(p,\omega_o)=\intop_\Omega(k_d\frac c\pi+\frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)})L_i(p,\omega_i)n\cdot\omega_id\omega_i \] 仍然有些小问题没有解决。例如如何表示场景中的辐射照度(Irradiance)和辐射亮度(radiance)?

辐射亮度是一个拥有辐射通量\(\Phi\)的光源在单位面积\(A\),单位立体角\(\omega\)上辐射出的总能量。不妨假设立体角\(\omega\)无限小,这样辐射亮度就表示光源在一条光线或单个方向向量上的辐射通量。

有了以上假设,接下来我们尝试将之前学过的光照知识转化为PBR版本的光照。假设有一个点光源,它的辐射通量\(\Phi\)是RGB形式的(23.47, 21.31, 20.79),那么该光源的辐射强度就是它所有出射光线的辐射通量。当我们为表面上着色点\(p\)着色时,在其半球领域\(\Omega\)的所有入射方向上,只有一个入射方向向量\(\omega_i\)直接来自于该点光源:

如果不考虑光线传播的衰减,那么无论把光源放在哪儿,入射光线的辐射亮度总是相同(除去入射角\(\cos\theta\)对辐射亮度的影响)。这是因为无论从哪个角度观察它,点光源总有相同的辐射强度,可以有效地将其辐射强度建模为其辐射通量:一个向量常量(23.47, 21.31, 20.79)。

然而辐射亮度也需要将位置\(p\)作为输入,因为所有现实中的点光源有光线衰减。点光源的辐射强度应该根据点\(p\)所在的位置和光源的位置做一些缩放。因此,根据辐射亮度的计算公式,还需要用表面法向量\(n\)和入射光方向\(w_i\)的点乘结果进行缩放。

更实际的说,对于直接点光源的情况,辐射亮度\(L\)先获取光源的颜色值,然后根据\(p\)的距离做衰减,并按照\(n\cdot\omega_i\)缩放。根据上面的假设,最终只有一条入射角为\(\omega_i\)的光线打在点\(p\)上,这个\(\omega_i\)也是着色点\(p\)的入射光方向向量。那么就能写出如下代码:

vec3 lightColor = vec3(23.47, 21.31, 20.79);
vec3 Wi = normalize(lightPos - fragPos);
float cosTheta = max(dot(N, Wi), 0.0);
float attenuation = calcAttenuation(fragPos, lightPos);
vec3 radiance = lightColor * attenuation * cosTheta;

可以发现,除了一些术语上的差异外,这段代码和之前计算漫反射光照差不多。那么可以举一反三了,例如定向光拥有定值\(\omega_i\)且没有衰减;聚光灯可能没有固定的辐射强度,而是根据它的方向向量来缩放的。

最后看看如何对半球领域\(\Omega\)进行积分。由于我们事先知道所有贡献光源的位置,那么不需要求积分,只需遍历这些光源然后求总辐射照度即可。如果在之后也要考虑环境光照(IBL),那么就得求积分了,因为光线可能在任何一个方向入射。

PBR表面模型

接下来就能尝试实现PBR着色模型的片段着色器了。首先我们要获取相应PBR输入:

#version 330 core
out vec4 FragColor;

in vec3 WorldPos;
in vec3 Normal;
in vec2 TexCoords;

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);
	// ...
}

求辐射亮度Radiance

这里用4个点光源渲染,用它们直接表示场景的辐射照度。为了满足反射方程,需要对每个光源进行遍历,计算每个光源的辐射亮度,然后累加被BRDF和入射角缩放的辐射亮度:

vec3 Lo = vec3(0.0);
for (int i = 0; i <4; ++i)
{
	vec3 L = normalize(lightPositions[i] - WorldPos);	// lightDir
	vec3 H = normalize(V + L);							// halfwayVec

	float distance = length(lightPositions[i] - WorldPos);
	float attenuation = 1.0 / (distance *distance);
	vec3 radiance = lightColors[i] *attenuation;
	// ...
}

由于我们在线性空间内计算光照(将在最后进行Gamma衰减),使用物理上更准确的平方倒数作为衰减。

求BRDF项

对每个光源都要计算完整的Cook-Torrance镜面反射BRDF项: \[ \nonumber \frac{DFG}{4(\omega_o\cdot n)(\omega_i\cdot n)} \] 首先要计算\(k_s\)\(k_d\),由于该模型中F项就包含\(k_s\)了,因此可以通过算出F项来求\(k_s\)。需要注意的是,这里使用clamp()来避免黑点:

// F项: Fresnel-Schlick近似
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
	return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

该函数接受一个参数\(F_0\),代表0°入射角的反射率。别忘了在之前说过,金属材质的\(F_0\)还需要特殊指定,因为它有颜色:

// 特殊指定F0
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
// 计算F项
vec3 F = fresnelSchlick(clamp(dot(H, V), 0.0, 1.0), F0);

可以发现,非金属表面的\(F_0\)始终为0.04;金属表面的\(F_0\)则通过初始\(F_0\)albedometallic进行线性差值。

接下来就剩下求法线分布函数D项和几何函数G项了:

// D项: Trowbridge-Reitz GGX
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;
}

// G项: Schlick-GGX
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;
}
// G项: Smith's method
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 ggx1 = GeometrySchlickGGX(NdotV, roughness);
	float ggx2 = GeometrySchlickGGX(NdotL, roughness);

	return ggx1 *ggx2;
}

需要注意的是,和上一节内容相比,我们直接将粗糙度roughness当做参数传入。这样可以用粗糙度来更改每一项内容。此外,根据文献,采用粗糙度的平方作为\(\alpha\)会使得光照看起来更自然。

那么就能在反射方程循环中求出NDF和G项了:

float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);

那么Cook-Torrance BRDF就能被求出:

vec3 numerator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) *max(dot(N, L), 0.0) + 0.0001;
vec3 specular = numerator / denominator;

需要注意的是,这里的denominator加了0.0001,防止出现除以0的异常。

在求Lambertian漫反射BRDF之前,需要求\(k_S\)\(k_D\)

vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic;

\(k_S\)是光能中被反射的能量所占比例,就是菲涅尔项算出来的结果;\(k_D\)则是光能中被折射能量所占比例,由于BRDF能量守恒,剩下的就是\(k_D\)。对于金属材质,它没有漫反射,因此它的\(k_D\)应该是0。

最后便能计算出单个光源的辐射亮度了,我们需要将其累加到最终“积分”结果里:

// 求Lambertian漫反射BRDF
vec3 diffuse = albedo / PI;

// 最终结果
float NdotL = max(dot(N, L), 0.0);
Lo += (kD * diffuse + specular) * radiance * NdotL;

环境光照项

最后给Lo添加一个环境光照项就完工了:

// 环境光照项(后续通过IBL采样)
vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color = ambient + Lo; 

色调映射和Gamma校正

由于上述计算均假设在线性空间进行,我们需要在着色器最后做Gamma校正。在线性空间进行计算是十分重要的,因为PBR要求所有的输入都是线性的。

此外,我们希望所有光照的输入都尽可能基于物理,这会导致最终的\(L_o\)取值超过LDR范围,需要进行色调映射,将LDR范围值映射到HDR中。

顺序是先色调映射,再Gamma校正:

// HDR的色调映射: Rainhard方法
color = color / (color + vec3(1.0));
// Gamma校正
color = pow(color, vec3(1.0 / 2.2));

最终结果如下:

从下往上球体的金属性从0.0变到1.0, 从左到右球体的粗糙度从0.0变到1.0。

带贴图的PBR

只需将对应的材质量用纹理代替就行:

参考资料

  • LearnOpenGL - Lighting
  • 光照 - LearnOpenGL CN (learnopengl-cn.github.io)