Lighting

openGL高级光照部分目录 见 openGL高级光照部分目录

在前一章中,我们为从地面获得一个真实的基于物理的渲染器奠定了基础。在本章中,我们将着重于将前面讨论的理论转化为使用直接(或分析)光源的实际渲染器:考虑点光源、平行光和/或聚光灯。

让我们重新访问上一章中的最终反射率方程:

我们现在基本上知道发生了什么,但是仍然是一个很大的未知数,就是我们将如何准确地表示场景的总辐射率L。我们知道,辐亮度L(在计算机图形学领域中解释)测量给定立体角ω上光源的辐射通量ψ或光能。在我们的例子中,我们假设立体角ω是无穷小的,在这种情况下,辐射度测量光源在单个光线或方向向量上的通量。

有了这些知识,我们如何将其转化为我们从前面章节中积累的一些照明知识?好吧,假设我们有一个单点光(一个在所有方向上都同样明亮的光源),其辐射通量为(23.47,21.31,20.79),转化为RGB三原色。这个光源的辐射强度等于它在所有出射方向的辐射通量。然而,当对表面上的特定点p进行着色时,在其半球Ω上所有可能的入射光方向中,只有一个入射方向向量wi直接来自点光源。由于场景中只有一个光源,假设是空间中的一个点,因此在曲面点p上观察到的所有其他可能的入射光方向的辐射度为零:

果一开始,我们假设光衰减(光在距离上变暗)不影响点光源,那么不管我们将光线放置在哪里,入射光线的辐射度都是相同的(不包括通过入射角cosθ缩放辐射)。这是因为点光源的辐射强度与我们观察它的角度无关,所以有效地将其辐射强度建模为其辐射通量:一个常数向量(23.47,21.31,20.79)。

然而,辐射度也将位置p作为输入,并且由于任何真实的点光源都考虑了光衰减,因此点光源的辐射强度通过点p与光源之间的距离来缩放。然后,从原始的辐射方程中提取,用表面法线n入射光方向wi之间的点积来缩放结果。

用更实际的术语来说:在直射点光源的情况下,辐射函数L测量光的颜色,在距离p的距离上衰减并按n⋅wi进行缩放,但仅在照射到p的单个光线wi上,其等于来自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 = calculateAttenuation(fragPos, lightPos);
vec3  radiance    = lightColor * attenuation * cosTheta;

除了一些叫法上的差异以外,这段代码对你们来说应该很熟悉:这正是我们一直以来怎么计算(漫反射(diffuse))光照的!当涉及到直接照明(direct lighting)时,辐射率的计算方式和我们之前计算当只有一个光源照射在物体表面的时候非常相似。

请注意,这个假设是成立的条件是点光源体积无限小,相当于在空间中的一个点。如果我们认为该光源是具有体积的,它的辐射会在一个以上的入射光的方向不等于零。

对于其它类型的从单点发出来的光源我们类似地计算出辐射率。比如,定向光(directional light)拥有恒定的wi而不会有衰减因子;而一个聚光灯光源则没有恒定的辐射强度,其辐射强度是根据聚光灯的方向向量来缩放的。

这也让我们回到曲面半球Ω上的积分∫。正如我们预先知道的所有贡献光源的单一位置,而阴影的单一表面点,这是不需要尝试和解决积分。我们可以直接取(已知)光源的数量并计算它们的总辐照度,因为每个光源只有一个影响表面辐射的光方向。这使得直接光源上的PBR变得相对简单,因为我们只需要在贡献光源上循环。当我们稍后在IBL章节中考虑环境照明时,我们必须考虑到整体性,因为光线可以来自任何方向。

PBR表面模型

让我们从编写一个片段着色器开始,它实现了前面描述的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);
    [...]
}

直接照明

在本章的示例演示中,我们总共有4个点光源,它们一起表示场景的辐照度。为了满足反射方程,我们在每个光源上循环,计算其各自的辐射度,并将其按BRDF和光的入射角缩放的贡献相加。我们可以把循环当作在物体的半球领域对直接光源求积分。首先我们来计算一些可以预计算的光照变量:

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镜面反射BRDF项:

我们要做的第一件事是计算镜面反射和漫反射之间的比率,也就是说,曲面反射光与折射光的比率。从上一章我们知道,菲涅耳方程只计算:

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}  

Fresnel-Schlick近似需要一个F0参数,该参数被称为零入射时的表面反射,或者如果直接观察表面,则曲面反射的程度。F0随材料的不同而变化,如我们在大型材料数据库中发现的那样,在金属上着色。在PBR金属工作流程中,我们简化假设大多数电介质表面在视觉上是正确的,常数F0为0.04,而我们确实为金属表面指定F0,然后由反照率值给出。这转换为如下代码:

vec3 F0 = vec3(0.04); 
F0      = mix(F0, albedo, metallic);
vec3 F  = fresnelSchlick(max(dot(H, V), 0.0), F0);

如您所见,对于非金属表面,F0始终为0.04。对于金属表面,我们通过在原始F0和给定金属特性的反照率值之间线性插值来改变F0。

给定F,剩下的计算项是法线分布函数D几何函数G

在直接PBR照明着色器中,它们的代码等效项为:

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;
}

这里比较重要的是和上一个教程不同的是,我们直接传了粗糙度(roughness)参数给上述的函数;通过这种方式,我们可以针对每一个不同的项对粗糙度做一些修改。根据迪士尼公司给出的观察以及后来被Epic Games公司采用的光照模型,光照在几何遮蔽函数和法线分布函数中采用粗糙度的平方会让光照看起来更加自然。

现在两个函数都给出了定义,在计算反射的循环中计算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);
vec3 specular     = numerator / max(denominator, 0.001);  

注意我们在分母项中加了一个0.001为了避免出现除零错误。

现在我们终于可以计算每个光源在反射率方程中的贡献值了!因为菲涅尔方程直接给出了kS, 我们可以使用F表示镜面反射在所有打在物体表面上的光线的贡献。 从kS我们很容易计算折射的比值kD:

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

因为kS代表被反射的光的能量,剩余的光能比就是被折射的光,我们把它存储为kD。此外,由于金属表面不会折射光,因此没有漫反射,因此如果表面是金属的,我们通过消除kD来增强这种特性。这为我们提供了计算每个光的输出反射值所需的最终数据:

    const float PI = 3.14159265359;
  
    float NdotL = max(dot(N, L), 0.0);        
    Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}

最终的结果Lo,或者说是出射光线的辐射率,实际上是反射率方程的在半球领域Ω的积分的结果。但是我们实际上不需要去求积分,因为对于所有可能的入射光线方向我们知道只有4个方向的入射光线会影响片段(像素)的着色。因为这样,我们可以直接循环N次计算这些入射光线的方向(N也就是场景中光源的数目)。

比较重要的是我们没有把kS乘进去我们的反射率方程中,这是因为我们已经在镜面BRDF中乘了菲涅尔系数F了,因为kS等于F,因此我们不需要再乘一次。

vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color   = ambient + Lo;  

线性和HDR渲染

到目前为止,我们假设所有的计算都是在线性颜色空间中进行的,因此我们需要在着色器的末尾进行gamma校正。计算线性空间中的照明非常重要,因为PBR要求所有输入都是线性的。不考虑这一点将导致不正确的照明。此外,我们希望光输入接近其物理等效值,以便其辐射或颜色值可以在高光谱值范围内变化很大。因此,由于默认的低动态范围(LDR)输出,Lo可以快速增长到非常高的值,然后在0.0和1.0之间被钳制。我们通过在gamma校正之前将Lo和tone或exposure将高动态范围(HDR)值正确映射到LDR来解决这个问题:

color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2)); 

在这里,我们使用Reinhard操作符对HDR颜色进行色调映射,保持可能高度变化的辐照度的高动态范围,之后我们对颜色进行gamma校正。我们没有单独的帧缓冲区或后处理阶段,因此我们可以直接在前向片段着色器的末尾应用色调映射和gamma校正步骤。

同时考虑线性颜色空间和高动态范围在PBR管道中是非常重要的。如果没有这些,就不可能正确地捕捉不同光强度的高低细节,而你的计算结果将是错误的,因此视觉上不愉快。

完全直接照明PBR着色器

现在只需将最终色调映射和gamma校正的颜色传递到片段着色器的输出通道,我们就拥有了一个直接的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 fresnelSchlick(float cosTheta, vec3 F0);

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 numerator    = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0);
        vec3 specular     = numerator / max(denominator, 0.001);  
            
        // 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个点光源,以及很多球体,在它们的垂直轴和水平轴上分别改变它们的金属值和粗糙度值,我们会得到这样的结果:

从下到上,金属值的范围为0.0到1.0,粗糙度从左到右从0.0增加到1.0。您可以看到,只需更改这两个简单易懂的参数,我们就可以显示大量不同的材质。

你可以在这里找到演示的完整源代码。

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;
    [...]
}

请注意,来自艺术家的反照率纹理通常是在sRGB空间中编写的,这就是为什么我们首先将它们转换为线性空间,然后再在照明计算中使用反照率。根据美工人员用于生成环境光遮挡贴图的系统,您可能还需要将这些贴图从sRGB转换为线性空间。金属和粗糙度贴图几乎总是在线性空间中编写的。

用纹理替换前一组球体的材质属性,已经显示出与我们以前使用的照明算法相比视觉上的重大改进:

你可以在这里找到纹理演示的完整源代码和这里使用的纹理集(以及全白色的ao Map)。请记住,金属表面在直接照明环境中看起来太暗,因为它们没有漫反射。当考虑到环境的高光环境照明时,它们看起来确实更正确,这是我们在下一章中重点讨论的内容。

虽然视觉效果不如你在那里发现的一些PBR渲染演示那样令人印象深刻,但考虑到我们还没有内置基于图像的照明,我们现在的系统仍然是基于物理的渲染器,即使没有IBL,你也会看到光照看起来更真实。

猜你喜欢

转载自blog.csdn.net/tiao_god/article/details/107346089