基于物理的渲染 – 实现篇

上一个教程里我们为基于现实物理的渲染打下了基础,在这个教程里我们将会着重介绍如何将前面讲到的理论转化成一个基于直接光照的渲染器:比如点光源,方向光和聚光灯。

让我们一起回忆一下前面的教程推导出来的最终反射方程:

Lo(p,ωo)=Ω(kdcπ+ksDFG4(ωon)(ωin))Li(p,ωi)nωidωi

(原文:https://learnopengl.com/#!PBR/Lighting, 翻译:coldkaweh)

我们知道了大部分是怎么回事,但是还剩下一个巨大的未知就是,我们具体要怎么处理辐照度呢?我们知道在计算机领域,场景的辐射度L度量的是来自光源光线的辐射通量ϕ穿过指定的立体角ω,在这里我们假设立体角无限小,小到辐射度衡量的是光源射出的一束经过指定方向向量的光线的通量。

有了这个假设,我们又要怎么将之融合到之前教程讲的光照计算里去呢?Well,想象我们有一个辐射通量以RGB表示为(23.47, 21.31, 20.79)的点光源,这个光源的辐射强度等于辐射通量除以所有出射方向。当为平面上某个特定的点p着色的时候,所有可能的入射光方向都会经过半球Ω,但只有一个入射方向wi是直接来自点光源的,又因为我们的场景中只包含有一个光源,且这个光源只是一个点,所以p点所有其他的入射光方向的辐射度都应该是0.

基于物理的渲染 – 实现篇_第1张图片

如果我们暂时先不考虑点光源的距离衰减问题,且无论光源放在什么地方入射光线的辐射度都一样大(忽略角度对辐射度的影响),又因为点光源朝各个方向的辐射强度都是一样的,那么有效的辐射强度就跟辐射通量完全一样:恒定值(23.47, 21.31, 20.79)。

然而,辐射度需要使用位置p作为输入参数,因为现实中的灯光根据点p和光源之间距离的不同,辐射强度多少都会有一定的衰减。另外,从原始的辐射方程中我们可以发现,面法线n于入射光方向向量wi的点积也会影响结果。

(原文:https://learnopengl.com/#!PBR/Lighting, 翻译:coldkaweh)

用更精炼的话来描述:在点光源直接光照的情况里,辐射度函数L计算的是灯光颜色,经过到p点距离的衰减之后,再经过n⋅wi缩放。能击中点p的光线方向wi就是从p点看向光源的方向。把这些写成代码:

vec3  lightColor  = vec3(23.47f, 21.31f, 20.79f);
vec3  wi          = normalize(lightPos - fragWorldPos);
float cosTheta    = max(dot(n, wi), 0.0f);
float attenuation = calculateAttenuation(fragWorldPos, lightPos);
float radiance    = lightColor * attenuation * cosTheta;

你应该非常非常熟悉这段代码:这就是以前我们计算漫反射光的算法!在只有单光源直接光照的情况下,辐射度的计算方法跟我们以前的光照算法是类似的。

要注意我们这里假设点光源无限小,只是空间中的一个点。如果我们使用有体积的光源模型,那么就有很多的入射光方向的辐射度是非0的。
对那些基于点的其他类型光源我们可以用类似的方法计算辐射度,比如平行光源的入射角的恒定的且没有衰减因子,聚光灯没有一个固定的辐射强度,而是围绕一个正前方向量来进行缩放的。

我们知道,多个单一位置的光源对同一个表面的同一个点进行光照着色并不需要用到积分,我们可以直接拿出这些数目已知的光源来,分别计算这些光源的辐照度后再加到一起,毕竟每个光源只有一束方向光能影响物体表面的辐射度。这样只需要通过相对简单的循环计算每个光源的贡献就能完成整个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; 
    [...]  

由于我们是在线性空间进行的计算,所以光源的衰减会更符合物理上的反平方律。
然后,我们对每个光源计算所有的Cook-Torrance BRDF分量:

DFG4(ωon)(ωin)

我们要做的第一件事是计算高光跟漫反射之间的比例,有多少光被反射出去了又有多少产生了折射。前面的教程我们讲到过这个菲涅尔方程:

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

这个函数返回的是光在表面上被反射的比例,在反射方程中以ks表示。Fresnel-Schlick算法需要的F0参数就是我们之前说的基础反射率,即以0度角照射在表面上的光被反射的比例。不同材质的F0的值都不一样,可以根据材质到那张非常大的材质表里去找。在PBR金属度流水线中我们做了一个简单的假设,我们认为大部分的电介质表面的F0用0.04效果看起来很不错。而金属表面我们将F0放到albedo纹理内,这些可以写成代码如下:

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

如你所见,非金属的F0永远是0.04,除非我们通过金属度属性在F0跟abedo之间进行线性插值,才能得到一个不同的非金属F0。

(原文:https://learnopengl.com/#!PBR/Lighting, 翻译:coldkaweh)

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

这里值得注意的是,相较于理论篇教程,我们直接传入了粗糙度参数进函数。这样我们就可以对原始粗糙度做一些特殊操作。根据迪士尼的观察和Epic Games的用法,在正态分布函数跟几何函数中使用粗糙度的平方替代原始粗糙度进行计算光照效果会更正确一些。

当这些都定义好了之后,在计算NDF和G分量就是很简单的事情了:

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

然后就可以计算Cook-Torrance BRDF了:

vec3 nominator    = NDF * G * F;
float denominator = 4 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001; 
vec3 brdf         = nominator / denominator;  

denominator项里的0.001是为了防止除0情况而特意加上的。

到这里,我们终于可以计算每一盏灯对反射方程的贡献了,因为菲涅尔值就是kS,代表的是任意光击中表面后被反射的部分,根据能量守恒定律我们可以用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 + brdf) * radiance * NdotL;
}

最终结果Lo,或者说出射辐射度,是有效反射方程半球积分的结果。这里要特别注意的是,我们将kS移除方程式,是因为我们已经在BRDF中乘过菲涅尔参数F了,我们不需要再乘一次。
我们没有真正的对所有可能的入射光方向进行积分,因为我们已经清楚的知道只有4个入射方向可以影响这个片元,所以我们只需要直接用循环处理这些入射光就行了。

剩下的就是要将AO运用到光照结果Lo上,我们就可以得到这个片元的最终颜色了:

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

以上我们假设所有计算都在线性空间,为了使用这个结果我们还需要在着色器的最后进行伽马校正,在线性空间计算光照对于PBR是非常非常重要的,所有输入参数同样要求是线性的,不考虑这一点将会得到错误的光照结果。另外,我们希望输入的灯光参数更贴近实际的物理参数,比如他们的辐射度或者颜色值可以是一个非常宽广的值域。这样作为结果输出的Lo也将变得很大,如果我们不做处理默认会直接Clamp到0.0至1.0之间以适配低动态范围(LDR)输出方式。我们可以使用色调映射(Tone Map)和曝光控制(Exposure Map)来将高动态范围(HDR)映射到LDR之后再做伽马校正:

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

这里我们使用的是莱因哈特算法(Reinhard operator)对HDR进行Tone Map操作,尽量在伽马矫正之后还保持高动态范围。我们并没有分开帧缓冲或者使用后处理,所以我们可以直接将Tone Mapping和伽马矫正放在前片元着色阶段(forward fragment shader)。

基于物理的渲染 – 实现篇_第2张图片

对于PBR渲染管线来说,线性空间跟高动态范围有着超乎寻常的重要性,没有这些就不可能绘制出不同灯光强度下的高光低光细节,错误的计算结果会产生难看的渲染效果。

完整的PBR光照着色器
现在唯一剩下的就是将最终的色调映射和伽玛校正的颜色传递给片段着色器的输出通道,我们就拥有了一个PBR直接光照着色器。基于完整性考虑,下面列出完整的main函数:

#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 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001; 
        vec3 brdf = nominator / denominator;

        // add to outgoing radiance Lo
        float NdotL = max(dot(N, L), 0.0);                
        Lo += (kD * albedo / PI + brdf) * 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);
}  

希望在学习了前面教程的反射方程的理论知识之后,这个shader不再会让大家苦恼。使用这个shader,4个点光源照射在金属度和粗糙度不同的球上的效果大概类似这样:

基于物理的渲染 – 实现篇_第3张图片

从下往上金属度的值从0.0到1.0,粗糙度从左往右从0.0增加到1.0.你可以通过观察小球之间的区别理解金属度和粗糙度参数的作用。
示例的源码可以从这里找到。

(原文:https://learnopengl.com/#!PBR/Lighting, 翻译:coldkaweh)

使用纹理的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     = getNormalFromMap();
    float metallic  = texture(metallicMap, TexCoords).r;
    float roughness = texture(roughnessMap, TexCoords).r;
    float ao        = texture(aoMap, TexCoords).r;
    [...]
}

要注意美工制作的albedo纹理一般都是sRGB空间的,因此我们要先转换到线性空间再进行后面的计算。根据美工不同,AO纹理也许同样需要从sRGB转换到线性空间。

将前面那些小球的材质属性替换成纹理之后,对比以前用的光照算法,PBR有了一个质的提升:

基于物理的渲染 – 实现篇_第4张图片

你可以在这里找到带纹理的Demo源码,所有用到的纹理在这里(用了白色的AO贴图)。记住金属表面在直接光照环境中更暗是因为他们没有漫反射。在环境使用环境高光进行光照计算的情况下看起来也是正常的,这个我们在下一个教程里再说。

这里没有其他PBR渲染示例中那样令人惊艳的效果,因为我们还没有加入基于图片的光照(image based lighting)技术。尽管如此,这个shader任然算是一个基于物理的渲染,即使没有IBL你也可以法线光照看起来真实了很多。

原文链接:https://learnopengl.com/#!PBR/Lighting

你可能感兴趣的:(三维渲染技术)