背景
剛來公司的第一個項目就是通過OpenGL加載汽車的3D模型,UI設計提供了基礎色(Base_Color)、粗糙度(roughness)、金屬度(metallic),就是按照PBR提供的材質。之前的加載鞋子模型采用的方案是Blinn-Phone光照模型,Blinn-Phone光照模型也可以實作汽車3D模型的效果,實作過程中需要調整漫反射、高光的參數達到一個比較滿意效果,與真實的效果相比較很難令人信服。是以汽車3D模型采用PBR技術實作,下面就介紹下Android端PBR Shader的實作,由于PBR理論知識相對複雜,這裡隻介紹帶貼圖的PBR的實作對于文章中涉及的其他概念請自行查閱資料。
PBR基本原理和實作
滿足以下條件的光照模型才能稱之為PBR光照模型:
- 基于微平面模型(Be based on the microfacet surface model)。
- 能量守恒(Be energy conserving)。
- 使用基于實體的BRDF(Use a physically based BRDF)。
微平面模型
大多數PBR技術都是基于微平面理論。在此理論下,認為在微觀上所有材質表面都是由很多朝向不一的微小平面組成,有的材質表面光滑一些,有的粗糙一些。
當光線射入這些微平面後,通常會産生鏡面反射。對于越粗糙的表面,由于其朝向更無序,反射的光線更雜亂,反之,平滑的微平面,反射的光線更平齊。

從微觀角度來說,沒有任何表面是完全光滑的。由于這些微平面已經微小到無法逐像素地繼續對其進行細分,是以我們隻有假設一個粗糙度(Roughness)參數,然後用統計學的方法來概略的估算微平面的粗糙程度。
是以,粗糙度越高的表面,表面光線反射越分散,反之則越集中:
在實際的PBR實作中,粗糙度(Roughness)是用來調整PBR效果的一個重要參數之一
我們可以基于一個平面的粗糙度來計算出某個向量的方向與微平面平均取向方向一緻的機率。這個向量便是位于光線向量𝑙和視線向量𝑣之間的中間向量,被稱為半角向量(Halfway Vector)。
半角向量ℎ是視線𝑣和入射光𝑙的中間機關向量。
半角向量計算公式如下:
半角向量計算GLSL實作:
// lightPos是光源位置,viewPos是錄影機位置,FragPos是像素位置
vec3 lightDir = normalize(lightPos - FragPos);
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
能量守恒
在PBR中,能量守恒定律認為,出射的光線能量,永遠不能超過入射光線的能量。在微平面模型中,對于一束光進入到進入到物體表面之後,我們分成了幾個部分:
- 折射部分,指的是被吸收到物體表面的那部分光線能量,具體表現出來就是物體的漫反射(Diffuse)
- 反射部分,指的是光線反射出來離開物體表面的那部分光照,具體指的就是鏡面高光(Specular)
對于非金屬物質來說,光線射進物體都會産生折射跟反射部分,而對于金屬物質來說,微平面理論認為金屬表面不會顯示出漫反射部分,所有光線都會被處理成鏡面高光
而能量守恒的要求,則是要求漫反射+高光部分占比加起來為1,實際實作中,我們往往先算出高光部分占比,然後在算出漫反射部分占比:
float kS = calculateSpecularComponent(); // 反射/鏡面 部分
float kD = 1.0 - ks; // 折射/漫反射 部分
對比傳統的Blinn-Phong模型,由于起沒有考慮能量守恒,是以往往很難得出令人信服的效果
使用基于實體的BRDF
渲染方程(Render Equation)是用來模拟光的視覺效果最好的模型。而PBR的渲染方程是用以抽象地描述PBR光照計算過程的特化版本的渲染方程,被稱為反射方程。
PBR的反射方程可抽象成下面的形式:
反射方程看似很複雜,但如果拆分各個部分加以解析,就可以揭開其神秘的面紗。
我知道這個公式不在說人話,是以把它翻譯一下是這樣的
- 法線分布函數(Normal Distribution Function):用于估算在收到表面粗糙度的影響下,取向方向與半程向量一緻的微平面向量
- 幾何函數(Geometry Function):用于描述微平面自成陰影的的函數,在表面粗糙度比較大的時候,平面上的微表面可能擋住了其他微表面的光線
- 菲涅爾方程(Fresnel Rquation):用于描述光線在不同的入射角度下表面反射光線所占的比率
以上每一種函數都描述了對應不同的實體現象,而實際渲染中,我們都會采用某種近似的函數,接下來我們來說下這幾種近似函數的公式:
法線分布函數(Normal Distribution Function)的近似法線分布函數,從統計學上近似的表示了與某些(如中間)向量ℎ取向一緻的微平面的比率。
目前有很多種NDF都可以從統計學上來估算微平面的總體取向度,隻要給定一些粗糙度的參數以及一個我們馬上将會要用到的參數Trowbridge-Reitz GGX(GGXTR):
這裡的h是用來測量微平面的半角向量,𝛼是表面的粗糙度,𝑛是表面法線。 如果将ℎ放到表面法線和光線方向之間,并使用不同的粗糙度作為參數,可以得到下面的效果:
成一個非常明亮的斑點。但是當表面比較粗糙的時候,微平面的取向方向會更加的随機,與向量ℎ取向一緻的微平面分布在一個大得多的半徑範圍内,但是較低的集中性也會讓最終效果顯得更加灰暗。
使用glsl的實作則如下:
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 saturateMediump(nom / denom);
}
幾何函數(Geometry Function)的近似
幾何函數模拟微平面互相遮擋導緻光線的能量減少或丢失的現象。
類似NDF,幾何函數也使用粗糙度作為輸入參數,更粗糙意味着微平面産生自陰影的機率更高。幾何函數使用由GGX和Schlick-Beckmann組合而成的模拟函數Schlick-GGX:
這裡的𝑘是使用粗糙度𝛼計算而來的,用于直接光照和IBL光照的幾何函數的參數:
需要注意的是這裡𝛼的值取決于你的引擎怎麼将粗糙度轉化成𝛼,在接下來的教程中我們将會進一步讨論如何和在什麼地方進行這個轉換。
為了有效地模拟幾何體,我們需要同時考慮兩個視角,視線方向(幾何遮擋)跟光線方向(幾何陰影),我們可以用Smith函數将兩部分放到一起:
其中𝑣表示視線向量,𝐺𝑠𝑢𝑏(𝑛,𝑣,𝑘)表示視線方向的幾何遮擋;𝑙表示光線向量,𝐺𝑠𝑢𝑏(𝑛,𝑙,𝑘)表示光線方向的幾何陰影。使用Smith函數與Schlick-GGX作為𝐺𝑠𝑢𝑏Gsub可以得到如下所示不同粗糙度R的視覺效果:
幾何函數是一個值域為[0.0, 1.0]的乘數,其中白色(1.0)表示沒有微平面陰影,而黑色(0.0)則表示微平面徹底被遮蔽。
使用GLSL編寫的幾何函數代碼如下:
float GeometrySchlickGGX(float NdotV, float k)
{
float nom = NdotV;
float denom = NdotV * (1.0 - k) + k;
return nom / denom;
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float k)
{
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx1 = GeometrySchlickGGX(NdotV, k); // 視線方向的幾何遮擋
float ggx2 = GeometrySchlickGGX(NdotL, k); // 光線方向的幾何陰影
return ggx1 * ggx2;
}
菲涅爾方程(Fresnel Rquation)的近似
菲涅爾方程描述了光線被反射部分的比率,因而會受到觀察方向的影響,結合能量守恒,我們可以得出剩下的漫反射的比率。而要計算出菲涅爾方程,我們需要一個基礎反射率(F0)的參數,描述的是在表面的掠射角方向望過去(此時表面法線跟視線方向成90度),不同材料的表面反射率都不太一樣:
我們這裡取0.04作為近似的基礎反射率,另外,這裡我們還需要額外引入一個叫做金屬度(Metallic)的參數,結合F0,一般我們這樣子來計算出材質的真實F0:
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
有了F0之後,我們使用Fresnel-Schlick近似來計算菲涅爾方程的近似:
Fresnel Schlick近似可以用GLSL代碼實作:
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
其中cosTheta是表面法向量𝑛與觀察方向𝑣的點乘的結果。
PBR的光照實作
在得到了BRDF的真實公式之後,我們嘗試基于公式直接計算直接光的照明,我們會在場景中添加若幹個點光源,以及一個方向光源,首先我們定義光源的一些基本變量:
#define POINT_LIGHT_NUMBER 3
precision highp float;
// lights
uniform vec3 pointLightPositions[POINT_LIGHT_NUMBER];
uniform vec3 pointLightColors[POINT_LIGHT_NUMBER];
uniform vec3 directionLightDir;
uniform vec3 directionLightColor;
我們将點光源的位置以及顔色分别存儲在數組中,而對于方向光源,則分别需要聲明起方向以及顔色,而實際在光照計算,我們需要對周遊每一個光源,分别使用BRDF函數計算其最終反射的顔色并相加起來
我們首先來看下點光源的計算,由于點光源強度會随着距離而進行衰減,這裡我們使用這裡教程介紹到的平方衰減函數,單個點光源的完整計算邏輯如下:
// calculate per-light radiance
vec3 L = normalize(pointLightPositions[i] - WorldPos);
vec3 H = normalize(V + L);
float distance = length(pointLightPositions[i] - WorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = pointLightColors[i] * attenuation;
// Cook-Torrance BRDF
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(clamp(dot(H, V), 0.0, 1.0), F0);
vec3 nominator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0);
vec3 specular = nominator / max(denominator, 0.001); // prevent divide by zero for NdotV=0.0 or NdotL=0.0
// kS is equal to Fresnel
vec3 kS = F;
// for energy conservation, the diffuse and specular light can't
// be above 1.0 (unless the surface emits light); to preserve this
// relationship the diffuse component (kD) should equal 1.0 - kS.
vec3 kD = vec3(1.0) - kS;
// multiply kD by the inverse metalness such that only non-metals
// have diffuse lighting, or a linear blend if partly metal (pure metals
// have no diffuse light).
kD *= 1.0 - metallic;
// scale light by NdotL
float NdotL = max(dot(N, L), 0.0);
// add to outgoing radiance Lo
Lo += (kD * albedo / PI + specular) * radiance * NdotL; // note that we already multiplied the BRDF by the Fresnel (kS) so we won't multiply by kS again
對于方向光源,我們也是類似這樣子計算:
vec3 L = normalize(-directionLightDir);
vec3 H = normalize(V + L);
vec3 radiance = directionLightColor;
// Cook-Torrance BRDF
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(clamp(dot(H, V), 0.0, 1.0), F0);
vec3 nominator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0);
vec3 specular = nominator / max(denominator, 0.001); // prevent divide by zero for NdotV=0.0 or NdotL=0.0
// kS is equal to Fresnel
vec3 kS = F;
// for energy conservation, the diffuse and specular light can't
// be above 1.0 (unless the surface emits light); to preserve this
// relationship the diffuse component (kD) should equal 1.0 - kS.
vec3 kD = vec3(1.0) - kS;
// multiply kD by the inverse metalness such that only non-metals
// have diffuse lighting, or a linear blend if partly metal (pure metals
// have no diffuse light).
kD *= 1.0 - metallic;
// scale light by NdotL
float NdotL = max(dot(N, L), 0.0);
// add to outgoing radiance Lo
Lo += (kD * albedo + specular) * radiance * NdotL; // note that we already multiplied the BRDF by the Fresnel (kS) so we won't multiply by kS again
注意跟點光源計算不同,我們在計算的時候并沒有根據距離來衰減光照能量
最後,在計算出光照顔色之後,由于我們整個計算過程都是線上性空間中計算,并且沒有對輸出顔色歸一,是以最後我們還需要進行Gamma校正以及色調映射(Tone Mapping):
// HDR tonemapping
color = color / (color + vec3(1.0));
// gamma correct
color = pow(color, vec3(1.0/2.2));
此處涉及到 平行光源、點光源、HDR、Gamma矯正的知識點,請自行查找資料。
完整的PBR直接光照着色器
Vertex Shader
#version 300 es
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoords;
layout (location = 2) in vec3 aNormal;
out vec2 TexCoords;
out vec3 WorldPos;
out vec3 Normal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
TexCoords = aTexCoords;
WorldPos = vec3(model * vec4(aPos, 1.0));
Normal = mat3(model) * aNormal;
gl_Position = projection * view * vec4(WorldPos, 1.0);
}
Fragment Shader
#version 300 es
#define POINT_LIGHT_NUMBER 3
precision highp float;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;
// material parameters
uniform sampler2D albedoMap;
uniform sampler2D normalMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D aoMap;
// IBL
//uniform samplerCube irradianceMap;
//uniform samplerCube radianceMap;
// lights
uniform vec3 pointLightPositions[POINT_LIGHT_NUMBER];
uniform vec3 pointLightColors[POINT_LIGHT_NUMBER];
uniform vec3 directionLightDir;
uniform vec3 directionLightColor;
uniform vec3 camPos;
out vec4 FragColor;
const float PI = 3.14159265359;
#define MEDIUMP_FLT_MAX 65504.0
#define MEDIUMP_FLT_MIN 0.00006103515625
#define saturateMediump(x) min(x, MEDIUMP_FLT_MAX)
// ----------------------------------------------------------------------------
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 saturateMediump(nom / denom);
// better ndf with spot light shape
vec3 NxH = cross(N, H);
float oneMinusNoHSquared = dot(NxH, NxH);
float NoH = max(dot(N, H), 0.0);
float a = NoH * roughness;
float k = roughness / (oneMinusNoHSquared + a * a);
float d = k * k * (1.0 / PI);
// return saturateMediump(d,MEDIUMP_FLT_MAX);
return min(d, MEDIUMP_FLT_MAX);
}
// ----------------------------------------------------------------------------
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 saturateMediump(nom / denom,MEDIUMP_FLT_MAX);
return min(nom / denom, MEDIUMP_FLT_MAX);
}
// ----------------------------------------------------------------------------
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);
// saturateMediump(ggx1 * ggx2,MEDIUMP_FLT_MAX);
return min(ggx1 * ggx2, MEDIUMP_FLT_MAX);
}
// ----------------------------------------------------------------------------
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
// ----------------------------------------------------------------------------
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
{
return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}
vec3 EnvDFGLazarov( vec3 specularColor, float gloss, float ndotv ) {
//# [ Lazarov 2013, "Getting More Physical in Call of Duty: Black Ops II" ]
//# Adaptation to fit our G term.
vec4 p0 = vec4( 0.5745, 1.548, -0.02397, 1.301 );
vec4 p1 = vec4( 0.5753, -0.2511, -0.02066, 0.4755 );
vec4 t = gloss * p0 + p1;
float bias = clamp( t.x * min( t.y, exp2( -7.672 * ndotv ) ) + t.z, 0.0, 1.0);
float delta = clamp( t.w, 0.0, 1.0);
float scale = delta - bias;
bias *= clamp( 50.0 * specularColor.y, 0.0, 1.0);
return specularColor * scale + bias;
}
void main()
{
vec3 albedo = pow(texture(albedoMap, TexCoords).rgb, vec3(2.2));
float metallic = texture(metallicMap, TexCoords).r;
float roughness = texture(roughnessMap, TexCoords).r;
// float ao = texture(aoMap, TexCoords).r;
float ao = 1.0;
// vec3 N = getNormalFromMap();
vec3 N = normalize(Normal);
vec3 V = normalize(camPos - WorldPos);
vec3 R = reflect(-V, N);
// calculate reflectance at normal incidence; if dia-electric (like plastic) use F0
// of 0.04 and if it's a metal, use the albedo color as F0 (metallic workflow)
vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);
// reflectance equation
vec3 Lo = vec3(0.0);
// point light
for(int i = 0; i < POINT_LIGHT_NUMBER; ++i) {
// calculate per-light radiance
vec3 L = normalize(pointLightPositions[i] - WorldPos);
vec3 H = normalize(V + L);
float distance = length(pointLightPositions[i] - WorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = pointLightColors[i] * attenuation;
// Cook-Torrance BRDF
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(clamp(dot(H, V), 0.0, 1.0), F0);
vec3 nominator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0);
vec3 specular = nominator / max(denominator, 0.001); // prevent divide by zero for NdotV=0.0 or NdotL=0.0
// kS is equal to Fresnel
vec3 kS = F;
// for energy conservation, the diffuse and specular light can't
// be above 1.0 (unless the surface emits light); to preserve this
// relationship the diffuse component (kD) should equal 1.0 - kS.
vec3 kD = vec3(1.0) - kS;
// multiply kD by the inverse metalness such that only non-metals
// have diffuse lighting, or a linear blend if partly metal (pure metals
// have no diffuse light).
kD *= 1.0 - metallic;
// scale light by NdotL
float NdotL = max(dot(N, L), 0.0);
// add to outgoing radiance Lo
Lo += (kD * albedo / PI + specular) * radiance * NdotL; // note that we already multiplied the BRDF by the Fresnel (kS) so we won't multiply by kS again
}
// directional light
{
vec3 L = normalize(-directionLightDir);
vec3 H = normalize(V + L);
vec3 radiance = directionLightColor;
// Cook-Torrance BRDF
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(clamp(dot(H, V), 0.0, 1.0), F0);
vec3 nominator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0);
vec3 specular = nominator / max(denominator, 0.001); // prevent divide by zero for NdotV=0.0 or NdotL=0.0
// kS is equal to Fresnel
vec3 kS = F;
// for energy conservation, the diffuse and specular light can't
// be above 1.0 (unless the surface emits light); to preserve this
// relationship the diffuse component (kD) should equal 1.0 - kS.
vec3 kD = vec3(1.0) - kS;
// multiply kD by the inverse metalness such that only non-metals
// have diffuse lighting, or a linear blend if partly metal (pure metals
// have no diffuse light).
kD *= 1.0 - metallic;
// scale light by NdotL
float NdotL = max(dot(N, L), 0.0);
// add to outgoing radiance Lo
Lo += (kD * albedo + specular) * radiance * NdotL; // note that we already multiplied the BRDF by the Fresnel (kS) so we won't multiply by kS again
}
// ambient lighting (note that the next IBL tutorial will replace
// this ambient lighting with environment lighting).
vec3 ambient = vec3(0.1,0.1,0.1);
vec3 ambientColor = ambient * albedo * ao;
vec3 color = ambientColor + Lo;
// HDR tonemapping
color = color / (color + vec3(1.0));
// gamma correct
color = pow(color, vec3(1.0/2.2));
FragColor = vec4(color, 1.0);
}
Sceneform 實作PBR
利用 Sceneform,您可以輕松地在 AR 和非 AR 應用中渲染逼真的 3D 場景,而無需學習 OpenGL。Sceneform底層PBR的實作由Filament提供。
不過Sceneform及Filament對于加載模型的檔案類型不同。Sceneform支援sfb、glb、gltf。Filament支援filamat、glb、gltf。
參考文章:
- https://learnopengl.com/PBR/Theory
- https://www.cnblogs.com/timlly/p/10631718.html?from=timeline&isappinstalled=0
- https://www.jianshu.com/p/9f47d5334fd3