第四十一章
PBR • 光照
在上一章中,我們為一個逼真的物理基礎渲染器奠定了基礎。在本章中,我們將專注於將先前討論的理論轉化為一個實際的渲染器,該渲染器使用直接(或解析)光源:例如點光源(point lights)、定向光源(directional lights)和/或聚光燈(spotlights)。
讓我們從回顧上一章的最終反射方程開始:
\[ L_o(p,\omega_o) = \int\limits_{\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_i d\omega_i \]
我們現在大致了解發生了什麼,但仍然是一個巨大的未知數是,我們究竟要如何表示輻照度(irradiance),也就是場景的總輻射(total radiance)\(L\)。我們知道,輻射(radiance)\(L\)(在電腦圖學領域的解釋)是測量給定立體角 \(\omega\) 上光源的輻射通量(radiant flux)\(\phi\) 或光能。在我們的案例中,我們假設立體角 \(\omega\) 是無限小的,在這種情況下,輻射測量的是光源在單一光線或方向向量上的通量。
有了這些知識,我們該如何將其轉化為我們從先前章節中累積的一些光照知識呢?好吧,想像我們有一個點光源(point light source,一個在所有方向上都發出相同亮度的光源),其輻射通量轉換為 RGB 三元組為 (23.47, 21.31, 20.79)
。這個光源的輻射強度(radiant intensity)等於它在所有出射方向上的輻射通量。然而,當我們對表面上的一個特定點 \(p\) 進行著色時,在其半球 \(\Omega\) 上所有可能的入射光方向中,只有一個入射方向向量 \(w_i\) 是直接來自點光源的。因為我們的場景中只有一個光源,並且假設它是一個空間中的單點,所以在表面點 \(p\) 上觀察到的所有其他可能的入射光方向的輻射都為零:
如果我們一開始假設光線衰減(光線隨距離變暗)不影響點光源,那麼無論我們將光源放置在哪裡,入射光線的輻射都是相同的(不包括用入射角 \(\cos \theta\) 對輻射進行縮放)。這是因為點光源的輻射強度與我們觀察它的角度無關,有效地將其輻射強度模擬為其輻射通量:一個常數向量 (23.47, 21.31, 20.79)
。
然而,輻射也將位置 \(p\) 作為輸入,並且由於任何逼真的點光源都會考慮光線衰減,因此點光源的輻射強度會根據點 \(p\) 和光源之間距離的一些測量值進行縮放。然後,根據從原始輻射方程中提取的內容,結果會根據表面法線 \(n\) 和入射光方向 \(w_i\) 之間的點積進行縮放。
用更實際的術語來說:在直接點光源的情況下,輻射函數 \(L\) 測量的是光的顏色,根據其與 \(p\) 的距離進行衰減,並根據 \(n \cdot w_i\) 進行縮放,但僅限於撞擊 \(p\) 的單一光線 \(w_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 = calculateAttenuation(fragPos, lightPos);
vec3 radiance = lightColor * attenuation * cosTheta;
除了術語不同之外,這段程式碼對您來說應該非常熟悉:這正是我們到目前為止一直在做的漫射照明。對於直接照明,輻射的計算方式與我們之前計算照明的方式類似,因為只有一個光線方向向量會影響表面的輻射。
請注意,這個假設成立是因為點光源是無限小且僅為空間中的單個點。如果我們要模擬一個具有面積或體積的光源,它的輻射在多個入射光方向上將是非零的。
對於源自單個點的其他類型光源,我們也以類似的方式計算輻射。例如,定向光源具有恆定的 \(w_i\) 而沒有衰減因子。而聚光燈的輻射強度則不是恆定的,而是會根據聚光燈的前向方向向量進行縮放。
這也讓我們回到表面半球 \(\Omega\) 上的積分 \(\int\)。由於我們在著色單個表面點時,事先知道所有貢獻光源的單一位置,因此不需要嘗試求解積分。我們可以直接取得(已知)光源的數量,並計算它們的總輻照度,前提是每個光源只有一個光線方向會影響表面的輻射。這使得在直接光源上的 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 和光的入射角縮放)相加。我們可以將這個迴圈視為針對直接光源求解 \(\Omega\) 上的積分 \(\int\)。首先,我們計算每個光源的相關變數:
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 校正),我們使用更符合物理的「平方反比定律」來衰減光源。
雖然這符合物理,但您可能仍然希望使用 constant-linear-quadratic 衰減方程,它(雖然不符合物理)可以讓您對光的能量衰減有更多控制。
然後,對於每個光源,我們需要計算完整的 Cook-Torrance 高光 BRDF 項:
\[ \frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)} \]
我們想做的第一件事是計算高光反射和漫射反射之間的比例,或者說表面反射光線的比例與折射光線的比例。我們從上一章知道,Fresnel 方程正好計算了這個值(請注意這裡的 clamp
是為了防止出現黑點):
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 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
。對於金屬表面,我們根據 metallic
屬性在原始 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)參數直接傳遞給這些函數;透過這種方式,我們可以對原始粗糙度值進行一些特定於項目的修改。根據 Disney 的觀察並被 Epic Games 採納,在幾何(geometry)和法線分佈(normal distribution)函數中將粗糙度平方,光照看起來會更正確。
定義了這兩個函數後,在反射迴圈中計算 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;
請注意,我們在分母中加上了 0.0001
,以防止在任何點積結果為 0.0
時發生除以零的錯誤。
現在我們終於可以計算每個光源對反射方程的貢獻了。由於 Fresnel 值直接對應於 \(k_S\),我們可以使用 F
來表示任何擊中表面的光的鏡面反射貢獻。然後,我們可以從 \(k_S\) 計算折射的比例 \(k_D\):
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
值,也就是出射輻射(outgoing radiance),實際上是反射方程在 \(\Omega\) 上的積分 \(\int\) 的結果。我們不需要試圖求解所有可能入射光方向的積分,因為我們確切地知道有 4 個入射光方向會影響這個片段(fragment)。因此,我們可以直接遍歷這些入射光方向,也就是場景中的光源數量。
剩下的就是給直接照明結果 Lo
添加一個(臨時的)環境光項,然後我們就得到了片段的最終光照顏色:
vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color = ambient + Lo;
線性與 HDR 渲染
到目前為止,我們假設所有的計算都在線性色彩空間中進行,為此我們需要在著色器的末尾進行 gamma 校正。在線性空間中計算光照至關重要,因為 PBR 要求所有輸入都是線性的。不考慮這一點將導致不正確的光照結果。此外,我們希望光照輸入接近其物理等效值,這樣它們的輻射或顏色值可以在高光譜範圍內劇烈變化。結果,Lo
的值可能會迅速變得非常高,然後因為預設的低動態範圍(LDR)輸出而被限制在 0.0
和 1.0
之間。我們透過在 gamma 校正之前,對 Lo
進行色調或曝光映射,將 高動態範圍(HDR)值正確地對應到 LDR 來解決這個問題:
color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));
在這裡,我們使用 Reinhard 運算子對 HDR 顏色進行色調映射,保留了可能高度變化的輻照度所帶來的高動態範圍,然後我們對顏色進行 gamma 校正。我們沒有獨立的幀緩衝區或後處理階段,所以我們可以直接在前向片段著色器的末尾同時應用色調映射和 gamma 校正步驟。
在 PBR 管線中,考慮線性色彩空間和高動態範圍至關重要。如果沒有這些,就不可能正確地捕捉不同光強度的細節,您的計算會出錯,導致視覺上不討喜。
完整的直接光照 PBR 著色器
現在剩下的就是將最終經過色調映射和 gamma 校正的顏色傳遞到片段著色器的輸出通道,我們就有了一個直接 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 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) + 0.0001;
vec3 specular = numerator / 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)值從 0.0
到 1.0
,粗糙度(roughness)從左到右從 0.0
增加到 1.0
。您可以看到,僅僅透過改變這兩個簡單易懂的參數,我們就可以展示各種不同的材質。
您可以在這裡找到這個範例的完整原始碼。
紋理化 PBR (Textured PBR)
將系統擴展為現在可以將其表面參數作為紋理(textures)而不是 uniform 值來接收,這使我們能夠針對每個片段(per-fragment)來控制表面材質的屬性:
[...]
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 textures)通常是在 sRGB 空間中製作的,這就是為什麼我們在使用反照率進行光照計算之前,先將它們轉換為線性空間。根據藝術家用於生成環境遮蔽貼圖(ambient occlusion maps)的系統,您可能也需要將它們從 sRGB 轉換為線性空間。而金屬度和粗糙度貼圖幾乎總是在線性空間中製作。
用紋理替換上一組球體的材質屬性,已經顯示出比我們之前使用的光照演算法有顯著的視覺提升:
您可以在這裡找到帶有紋理的範例完整原始碼,並在這裡找到使用的紋理集(帶有一個白色的 ao 貼圖)。請記住,金屬表面在直接光照環境中往往看起來太暗,因為它們沒有漫反射。當考慮環境的鏡面反射環境光照(specular ambient lighting)時,它們看起來會更正確,這正是我們在接下來的章節中將重點關注的內容。
儘管我們還沒有內建基於影像的光照(image based lighting),因此我們的渲染器不像您在網路上找到的一些 PBR 渲染範例那樣令人印象深刻,但我們現在擁有的系統仍然是一個物理基礎的渲染器,即使沒有 IBL,您也會看到您的光照看起來真實很多。
- 1. 介绍
- 2. 开始 • 认识 OpenGL
- 3. 开始 • 创建一个窗口
- 4. 开始 • Hello, 窗口
- 5. 开始 • Hello, 三角形
- 6. 开始 • 著色器
- 7. 开始 • 紋理
- 8. 开始 • 轉換
- 9. 开始 • 座標系統
- 10. 开始 • 相機
- 11. 光 • 顏色
- 12. 光 • 基本光照
- 13. 光 • 材質
- 14. 光 • 光照貼圖
- 15. 光 • 光源
- 16. 光 • 多重光源
- 17. 模型載入 • Assimp
- 18. 模型載入 • Mesh
- 19. 模型載入 • Model
- 20. 高級 OpenGL • 深度測試
- 21. 高級 OpenGL • 模板測試
- 22. 高級 OpenGL • 混合
- 23. 高級 OpenGL • 面剔除
- 24. 高級 OpenGL • Framebuffers
- 25. 高級 OpenGL • Cubemaps
- 26. 高級 OpenGL • 高級 Data
- 27. 高級 OpenGL • 高級 GLSL
- 28. 高級 OpenGL • 實例化
- 29. 高級 OpenGL • Anti-Aliasing
- 30. 高級光照
- 31. 高級光照 • 伽馬矯正
- 32. 高級光照 • 陰影貼圖
- 33. 高級光照 • 點光源陰影
- 34. 高級光照 • 法線貼圖 (Normal Mapping)
- 35. 高級光照 • 視差貼圖(Parallax Mapping)
- 36. 高級光照 • HDR
- 37. 高級光照 • 輝光(Bloom)
- 38. 高級光照 • 延遲著色
- 39. 高級光照 • SSAO
- 40. PBR • 理論
- 41. PBR • 光照
- 42. PBR • 漫射輻照度 (Diffuse-irradiance)
- 43. PBR • Specular-IBL