1. 程式人生 > >opengl學習之路三十八,光照

opengl學習之路三十八,光照

Note

本節暫未進行完全的重寫,錯誤可能會很多。如果可能的話,請對照原文進行閱讀。如果有報告本節的錯誤,將會延遲至重寫之後進行處理。

譯者注: 閱讀本節請熟悉上一節提到的幾個名詞:

  • 輻射通量(Radiant flux)
  • 輻射率(Radiance)
  • 輻照度(Irradiance)
  • 輻射強度(Radiant Intensity)

在上一個教程中,我們討論了一些PBR渲染的基礎知識。 在本章節中,我們將重點放在把以前討論過的理論轉化為實際的渲染器,這個渲染器將使用直接的(或解析的)光源:比如點光源,定向燈或聚光燈。

我們先來看看上一個章提到的反射方程的最終版:

在這裡插入圖片描述 L o (p,ω o )=∫ Ω (k d cπ +k s DFG4(ω o ⋅n)(ω i ⋅n) )L i (p,ω i )n⋅ω i dω i Lo(p,ωo)=∫Ω(kdcπ+ksDFG4(ωo⋅n)(ωi⋅n))Li(p,ωi)n⋅ωidωi

我們大致上清楚這個反射方程在幹什麼,但我們仍然留有一些迷霧尚未揭開。比如說我們究竟將怎樣表示場景上的輻照度(Irradiance), 輻射率(Radiance) L L ? 我們知道輻射率L L (在計算機圖形領域中)表示在給定立體角ω ω 的情況下光源的輻射通量(Radiant flux)ϕ ϕ 或光源在角度ω ω 下發送出來的光能。 在我們的情況下,不妨假設立體角ω ω 無限小,這樣輻射度就表示光源在一條光線或單個方向向量上的輻射通量。

基於以上的知識,我們如何將其轉化為以前的教程中積累的一些光照知識呢? 那麼想象一下,我們有一個點光源(一個光源在所有方向具有相同的亮度),它的輻射通量為用RBG表示為(23.47,21.31,20.79)。該光源的輻射強度(Radiant Intensity)等於其在所有出射光線的輻射通量。 然而,當我們為一個表面上的特定的點p p 著色時,在其半球領域Ω Ω 的所有可能的入射方向上,只有一個入射方向向量ω i ωi 直接來自於該點光源。 假設我們在場景中只有一個光源,位於空間中的某一個點,因而對於p p 點的其他可能的入射光線方向上的輻射率為0:

在這裡插入圖片描述

如果從一開始,我們就假設點光源不受光線衰減(光照強度會隨著距離變暗)的影響,那麼無論我們把光源放在哪,入射光線的輻射率總是一樣的(除去入射角cosθ cosθ 對輻射率的影響之外)。 正正是因為無論我們從哪個角度觀察它,點光源總具有相同的輻射強度,我們可以有效地將其輻射強度建模為其輻射通量: 一個常量向量(23.47,21.31,20.79)。

然而,輻射率也需要將位置p p 作為輸入,正如所有現實的點光源都會受光線衰減影響一樣,點光源的輻射強度應該根據點p p 所在的位置和光源的位置以及他們之間的距離而做一些縮放。 因此,根據原始的輻射方程,我們會根據表面法向量n n 和入射角度w i wi 來縮放光源的輻射強度。

在實現上來說:對於直接點光源的情況,輻射率函式L L 先獲取光源的顏色值, 然後光源和某點p p 的距離衰減,接著按照n⋅w i n⋅wi 縮放,但是僅僅有一條入射角為w i wi 的光線打在點p p 上, 這個w i wi 同時也等於在p 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);
float radiance    = lightColor * attenuation * cosTheta;

除了一些叫法上的差異以外,這段程式碼對你們來說應該很TM熟悉:這正是我們一直以來怎麼計算(漫反射(diffuse))光照的!當涉及到直接照明(direct lighting)時,輻射率的計算方式和我們之前計算當只有一個光源照射在物體表面的時候非常相似。

請注意,這個假設是成立的條件是點光源體積無限小,相當於在空間中的一個點。如果我們認為該光源是具有體積的,它的輻射會在一個以上的入射光的方向不等於零。

對於其它型別的從單點發出來的光源我們類似地計算出輻射率。比如,定向光(directional light)擁有恆定的w i wi 而不會有衰減因子;而一個聚光燈光源則沒有恆定的輻射強度,其輻射強度是根據聚光燈的方向向量來縮放的。

這也讓我們回到了對於表面的半球領域(hemisphere)Ω Ω 的積分∫ ∫ 上。由於我們事先知道的所有貢獻光源的位置,因此對物體表面上的一個點著色並不需要我們嘗試去求解積分。我們可以直接拿光源的(已知的)數目,去計算它們的總輻照度,因為每個光源僅僅只有一個方向上的光線會影響物體表面的輻射率。這使得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 specular BRDF項:

首先我們想計算的是鏡面反射和漫反射的係數, 或者說發生表面反射和折射的光線的比值。 我們從上一個教程知道可以使用菲涅爾方程計算: vec3 fresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }

菲涅爾方程返回的是一個物體表面光線被反射的百分比, 也就是我們反射方程中的引數k s ks 。Fresnel-Schlick近似接受一個引數F0,被稱為0°入射角的反射(surface reflection at zero incidence)表示如果直接(垂直)觀察表面的時候有多少光線會被反射。 這個引數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和反射率中插值計算F0。

我們已經算出F F , 剩下的項就是計算正態分佈函式D D 和幾何遮蔽函式G G 了。

因此一個直接PBR光照著色器中D D 和G G 的計算程式碼類似於:

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)引數給上述的函式;通過這種方式,我們可以針對每一個不同的項對粗糙度做一些修改。根據迪士尼公司給出的觀察以及後來被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.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001; vec3 specular = nominator / denominator;

注意我們在分母項中加了一個0.001為了避免出現除零錯誤。

現在我們終於可以計算每個光源在反射率方程中的貢獻值了!因為菲涅爾方程直接給出了k S kS , 我們可以使用F表示鏡面反射在所有打在物體表面上的光線的貢獻。 從k S kS 我們很容易計算折射的比值k D kD : vec3 kS = F; vec3 kD = vec3(1.0) - kS;

kD *= 1.0 - metallic;

我們可以看作k S kS 表示光能中被反射的能量的比例, 而剩下的光能會被折射, 比值即為k D kD 。更進一步來說,因為金屬不會折射光線,因此不會有漫反射。所以如果表面是金屬的,我們會把係數k D kD 變為0。 這樣,我們終於集齊所有變數來計算我們出射光線的值: 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乘進去我們的反射率方程中,這是因為我們已經在specualr BRDF中乘了菲涅爾係數F了,因為kS等於F,因此我們不需要再乘一次。

剩下的工作就是加一個環境光照項給Lo,然後我們就擁有了片段的最後顏色: vec3 ambient = vec3(0.03) * albedo * ao; vec3 color = ambient + Lo;

線性空間和HDR渲染

直到現在,我們假設的所有計算都線上性的顏色空間中進行的,因此我們需要在著色器最後做伽馬矯正。 線上性空間中計算光照是非常重要的,因為PBR要求所有輸入都是線性的,如果不是這樣,我們就會得到不正常的光照。另外,我們希望所有光照的輸入都儘可能的接近他們在物理上的取值,這樣他們的反射率或者說顏色值就會在色譜上有比較大的變化空間。Lo作為結果可能會變大得很快(超過1),但是因為預設的LDR輸入而取值被截斷。所以在伽馬矯正之前我們採用色調對映使Lo從LDR的值對映為HDR的值。 color = color / (color + vec3(1.0)); color = pow(color, vec3(1.0/2.2));

這裡我們採用的色調對映方法為Reinhard 操作,使得我們在伽馬矯正後可以保留儘可能多的輻照度變化。 我們沒有使用一個獨立的幀緩衝或者採用後期處理,所以我們需要直接在每一步光照計算後採用色調對映和伽馬矯正。

在這裡插入圖片描述

採用線性顏色空間和HDR在PBR渲染管線中非常重要。如果沒有這些操作,幾乎是不可能正確地捕獲到因光照強度變化的細節,這最終會導致你的計算變得不正確,在視覺上看上去非常不自然。

完整的直接光照PBR著色器

現在剩下的事情就是把做好色調對映和伽馬矯正的顏色值傳給片段著色器的輸出,然後我們就擁有了自己的直接光照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)沿垂直方向和水平方向分別變化,我們會得到這樣的結果:

在這裡插入圖片描述 (上述圖片)從下往上球體的金屬性從0.0變到1.0, 從左到右球體的粗糙度從0.0變到1.0。你可以看到僅僅改變這兩個值,顯示的效果會發生巨大的改變!

你可以在這裡找到整個demo的完整程式碼。

帶貼圖的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)貼圖大多數時間都會保證線上性空間中。

只是把之前的球體的材質性質換成紋理屬性,就在視覺上有巨大的提升: 在這裡插入圖片描述

你可以在這裡找到紋理貼圖過的全部程式碼, 以及我用的紋理(記得加上一張全白色的ao Map)。注意金屬表面會在場景中看起來有點黑,因為他們沒有漫反射。它們會在考慮環境鏡面光照的時候看起來更加自然,不過這是我們下一個教程的事情了。

相比起在網上找到的其他PBR渲染結果來說,儘管在視覺上不算是非常震撼,因為我們還沒考慮到基於圖片的關照,IBL。我們現在也算是有了一個基於物理的渲染器了(雖然還沒考慮IBL)!你會發現你的光照看起來更加真實了。