Filament中的anisotropic specular和isotropic specular

Tags:

很久前总结了pbr公式内容:PBR渲染原理,但没有说到各向异性的问题。

最近发现Filament详细介绍了pbr相关的知识,也有源码可读,可以好好学习一番。

pbr理论中,高光项的公式如下:

\[ f_ r(v,l) = \frac { D(h,\alpha ) G(v,l,\alpha ) F(v,h,F_0) } { 4 (n \cdot v)(n \cdot l) } \]

Filament用一个specularLobe统一描述了各向同性和各向异性的高光项:

vec3 specularLobe(const PixelParams pixel, const Light light, const vec3 h,
        float NoV, float NoL, float NoH, float LoH) {
#if defined(MATERIAL_HAS_ANISOTROPY)
    return anisotropicLobe(pixel, light, h, NoV, NoL, NoH, LoH);
#else
    return isotropicLobe(pixel, light, h, NoV, NoL, NoH, LoH);
#endif
}

本文的目标是理解这里面的代码。

Filament的着色流程

代码都在shaders/src目录,根据宏变体,会组合这里面的代码,最终生成目标shader。

main.fs的main是入口:

void main() {
    filament_lodBias = frameUniforms.lodBias;

    // See shading_parameters.fs
    // Computes global variables we need to evaluate material and lighting
    computeShadingParams(); // 计算一些全局参数

    // Initialize the inputs to sensible default values, see material_inputs.fs
    MaterialInputs inputs;
    initMaterial(inputs); // 初始化默认值

    // Invoke user code
    material(inputs); //调用.mat文件里的用户代码

    fragColor = evaluateMaterial(inputs); // 着色
    ...
}

其中的evaluateMaterial具体是哪一个实现,得看选择的shading model。以基本的pbr为例(即LIT模型),代码在shading_lit.fs:

vec4 evaluateLights(const MaterialInputs material) {
    PixelParams pixel;
    getPixelParams(material, pixel); // 从材质参数转换得到后续计算用的PixelParams

    vec3 color = vec3(0.0);

    evaluateIBL(material, pixel, color); // 默认都有IBL间接光

#if defined(HAS_DIRECTIONAL_LIGHTING)
    evaluateDirectionalLight(material, pixel, color); // 下面会分析的重点接口
#endif

    ...

    return vec4(color, computeDiffuseAlpha(material.baseColor.a));
}

vec4 evaluateMaterial(const MaterialInputs material) {
    vec4 color = evaluateLights(material);
    addEmissive(material, color); // 自发光,先忽略
    return color;
}

然后就看evaluateDirectionalLight,代码在light_directional.fs:

void evaluateDirectionalLight(const MaterialInputs material,
        const PixelParams pixel, inout vec3 color) {

    Light light = getDirectionalLight();
    ...
    float visibility = 1.0;
#if defined(HAS_SHADOWING)
    //算阴影 得到visibility
    ...
#endif

#if defined(MATERIAL_HAS_CUSTOM_SURFACE_SHADING)
    color.rgb += customSurfaceShading(material, pixel, light, visibility);
#else
    color.rgb += surfaceShading(pixel, light, visibility); // 重点
#endif
}

surfaceShading的代码在shading_model_standard.fs,这个就已经是pbr的核心代码了。

几个材质关键参数的计算

MaterialInputs是用户端的参数,即给美术编辑用的:

struct MaterialInputs {
    vec4  baseColor;
    float roughness;
    float metallic;
    float reflectance;
    float ior;
    ...
}

PixelParams存了每个着色点的基本系数,一般这些系数和具体的着色算法无关:

struct PixelParams {
    vec3  diffuseColor;
    float perceptualRoughness;
    float perceptualRoughnessUnclamped;
    vec3  f0;
    float roughness;
    vec3  dfg;
    vec3  energyCompensation;
    ...
}

根据MaterialInputs计算PixelParams的代码,主要是算了pixel.diffuseColor和pixel.f0 :

vec3 computeDiffuseColor(const vec4 baseColor, float metallic) {
    return baseColor.rgb * (1.0 - metallic);
}

void getCommonPixelParams(const MaterialInputs material, inout PixelParams pixel) {
    vec4 baseColor = material.baseColor;
    pixel.diffuseColor = computeDiffuseColor(baseColor, material.metallic);

#if !defined(SHADING_MODEL_SUBSURFACE) && (!defined(MATERIAL_HAS_REFLECTANCE) && defined(MATERIAL_HAS_IOR))
    float reflectance = iorToF0(max(1.0, material.ior), 1.0);
#else
    // Assumes an interface from air to an IOR of 1.5 for dielectrics
    float reflectance = computeDielectricF0(material.reflectance);
#endif
    pixel.f0 = computeF0(baseColor, material.metallic, reflectance);

    ...
}

pixel.roughness和pixel.perceptualRoughness

原文解释 Roughness remapping and clamping

material.roughness被叫做用户设置的perceptualRoughness,pixel.roughness是真正用于pbr计算的Roughness。

\[pixel.roughness = material.roughness ^2 \]

直观理解就是,当美术设置了0.5的粗糙度时,实际的渲染是用的0.25。0.25渲染出来的效果,会让美术好理解。

如果直接让美术控制实际的粗糙度,那么当渲染比较光滑的物体时,粗糙度范围会小到很难设置,大约在[0, 0.05]之间。

代码:

float perceptualRoughnessToRoughness(float perceptualRoughness) {
    return perceptualRoughness * perceptualRoughness;
}

void getRoughnessPixelParams(const MaterialInputs material, inout PixelParams pixel) {
    float perceptualRoughness = material.roughness;
    pixel.perceptualRoughnessUnclamped = perceptualRoughness;
    pixel.perceptualRoughness = clamp(perceptualRoughness, MIN_PERCEPTUAL_ROUGHNESS, 1.0);
    pixel.roughness = perceptualRoughnessToRoughness(pixel.perceptualRoughness);
}

pixel.diffuseColor

从computeDiffuseColor可见,metallic越大,pixel.diffuseColor就越接近黑色,黑色即表示几乎没有漫反射光。

pixel.f0

首先是理解什么是F0, F0等于 Fresnel Reflectance at 0 Degrees,是一个介于0到1的数,它表示当入射光线和表面垂直时,有x%的radiance变成镜面反射出去(剩下的1-x%发生折射)。

为什么要知道F0呢,因为pbr计算过程中有一个费涅尔项F(Fresnel term),需要用到F0。

有几个重要知识点要记一下:

  1. 电介质(dielectrics)的F0是非彩色的(achromatic),即认为RGB各分量相等
  2. 金属(metallic)的F0是彩色的(chromatic),RGB分量不相等
  3. F90必然等于1.0,F90表示当光线以grazing angles打到表面时,100%发生镜面反射,无论是电介质还是金属都一样
  4. 一般来说,大部分电介质的F0是4%;水是2%;F0最高的电介质是宝石(gemstones),16%左右。

7.png

F0计算方法1

为了方便美术调节F0,于是业界大佬发明了映射公式:

\[ f_0 = 0.16 \cdot f_{linear} ^2 \]

\(f_{linear}\)是个0到1的线性值,其中0.5对应水的F0(2%),1.0对应宝石的F0(16%)。Filament shader里的material.reflectance即是\(f_{linear}\),computeDielectricF0就是做这个映射的接口:

float computeDielectricF0(float reflectance) {
    return 0.16 * reflectance * reflectance;
}

F0计算方法2

另一种算法是基于材质的ior。ior是指光穿过两种介质界面时的折射率,例如空气\(\rightarrow \)水界面的ior是1.33。

ior也是一个给美术调节的系数,即Filament shader里的material.ior。

ior涉及到两种介质,但在游戏里一般其中一种介质是空气,因此可以认为,各种介质相对空气的ior,即为该介质的ior,记为\(n_{ior}\)。空气的(n_{ior}\)即为1。

由ior算F0的公式如下:

\[ f_0 ( n_{ior} ) = ( \frac {n_{ior} - 1}{n_{ior} + 1} ) ^2 \]

这条公式不是随便拟合出的,是真实的物理公式简化得到的。详情可阅读:

涉及到极化性质什么的,就不细究了。

总之能简化成上述公式的原因是,\(f_{0}\)描述的是垂直角度的反射率。

另外补充一下,当其中一种介质并不是空气时,此时也可以根据这两种介质相对空气的ior,来算\(f_{0}\),公式如下:

\[ f_0 = ( \frac { n_{1} - n_{2} }{ n_{1} + n_{2} } ) ^2 \]

分子分母同时除以\(n_{2}\),就和前一条公式差不多了:

\[ f_0 = ( \frac { \frac {n_{1} } { n_{2} } - 1 }{ \frac {n_{1} } { n_{2} } + 1 } ) ^2 \]

当\(n_{2}\)为空气的ior 1.0时,就是第一条公式了。

Filament中的实现代码如下:

float iorToF0(float transmittedIor, float incidentIor) {
    return sq((transmittedIor - incidentIor) / (transmittedIor + incidentIor));
}

transmittedIor即\(n_{1}\),incidentIor即\(n_{2}\)。

调用代码:

float reflectance = iorToF0(max(1.0, material.ior), 1.0);

可见,Filament直接约束了transmittedIor不低于1,且让incidentIor固定为空气ior 1.0。

根据material.metallic插值

上面算出来的\(f_{0}\)是个标量(scalar)(非彩色),只限定于电介质,为了和金属材质统一起来,所以还需要根据材质的metallic(金属度),做插值:

vec3 computeF0(const vec4 baseColor, float metallic, float reflectance) {
    return baseColor.rgb * metallic + (reflectance * (1.0 - metallic));
}

pixel.f0 = computeF0(baseColor, material.metallic, reflectance);

可见,金属度越高,pixel.f0越接近于材质属性里的baseColor(彩色);金属度越低,越接近前面算出来的电介质reflectance(非彩色)。

isotropic specular

先从各向同性开始:

vec3 isotropicLobe(const PixelParams pixel, const Light light, const vec3 h,
        float NoV, float NoL, float NoH, float LoH) {

    float D = distribution(pixel.roughness, NoH, h);
    float V = visibility(pixel.roughness, NoV, NoL);
    vec3  F = fresnel(pixel.f0, LoH);

    return (D * V) * F;
}

发现代码里少了\( 4 (n \cdot v)(n \cdot l) \)部分,这是因为Filament把specular公式拆成三部分:

\[ f_ r(v,l) = D(h,\alpha ) \cdot \frac { G(v,l,\alpha ) } { 4 (n \cdot v)(n \cdot l) } \cdot F(v,h,F_0) = D \cdot V \cdot F \]

\( 4 (n \cdot v)(n \cdot l) \)被放进了V项中了。

isotropic-D项

Filament里只有GGX一种(说明够用):

7.png

做一些变换,方便和代码对应:

\[ D = \frac {1}{\pi } \cdot (\frac {\alpha } { (n\cdot h)^ 2(\alpha ^ 2 - 1) + 1 })^ 2 \]

\[ = \frac {1}{\pi } (\frac {\alpha } { (n\cdot h)^ 2 \alpha ^ 2 - (n\cdot h)^ 2 + 1 })^ 2 \]

\[ = \frac {1}{\pi } (\frac {\alpha } { ( (n\cdot h) \alpha) ^ 2 + (1 - (n\cdot h)^ 2) })^ 2 \]

代码如下:

float D_GGX(float roughness, float NoH, const vec3 h) {
    float oneMinusNoHSquared = 1.0 - NoH * NoH;
    float a = NoH * roughness;
    float k = roughness / (oneMinusNoHSquared + a * a);
    float d = k * k * (1.0 / PI);
    return saturateMediump(d);
}

float distribution(float roughness, float NoH, const vec3 h) {
#if BRDF_SPECULAR_D == SPECULAR_D_GGX
    return D_GGX(roughness, NoH, h);
#endif
}

isotropic-G项

G项的话,Filament用了这篇论文里的equation 99: Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs。这条公式的好处推导相当复杂,直接用即可。

G.png

里面的符号的解释:

\(\omega _o\), \(\omega _i\), \(\omega _m\)分别表示出射方向、入射方向、微平面法线方向。

\(\chi ^+ \) 是指heaviside function,也叫unit step function,单位阶跃函数,值要么是0要么是1。

X.png

因为specular只考虑反射光,所以出入射方向和微平面法线夹角小于180°,分子等于1。

另外因为\( 4 (n \cdot v)(n \cdot l) \)被合并到了G项里(所以就换了个名字叫V项)。

最终的V公式如下:

V.png

float V_SmithGGXCorrelated(float roughness, float NoV, float NoL) {
    float a2 = roughness * roughness;
    float lambdaV = NoL * sqrt((NoV - a2 * NoV) * NoV + a2);
    float lambdaL = NoV * sqrt((NoL - a2 * NoL) * NoL + a2);
    float v = 0.5 / (lambdaV + lambdaL);
    return saturateMediump(v);
}

这里又有个经典技巧,牺牲精度换时间。因为开平方和平方操作都比较耗,直接干掉,公式变成:

V2.png

这个只是近似公式,数学上是错的,但不用深究,用就是了。

float V_SmithGGXCorrelated_Fast(float roughness, float NoV, float NoL) {
    /*
    float a = roughness;
    float GGXV = NoL * (NoV * (1.0 - a) + a);
    float GGXL = NoV * (NoL * (1.0 - a) + a);
    return 0.5 / (GGXV + GGXL);
    */
    float v = 0.5 / mix(2.0 * NoL * NoV, NoL + NoV, roughness);
    return saturateMediump(v);
}

float visibility(float roughness, float NoV, float NoL) {
#if BRDF_SPECULAR_V == SPECULAR_V_SMITH_GGX
    return V_SmithGGXCorrelated(roughness, NoV, NoL);
#elif BRDF_SPECULAR_V == SPECULAR_V_SMITH_GGX_FAST
    return V_SmithGGXCorrelated_Fast(roughness, NoV, NoL);
#endif
}

isotropic-F项

F.png

\(f_{0}\)、\(f_{90}\)上面已经分析过了,不再介绍。

当\(f_{90}\)为1时,有以下等价公式:

\[ f_{90} = 1\]

\[ f = (1 - v\cdot h)^ 5 \]

\[ F = f_{90}(1 - v\cdot h)^ 5 + f_{0}( 1 - (1 - v\cdot h)^ 5 ) \]

\[ = (1 - v\cdot h)^ 5 + f_{0}( 1 - (1 - v\cdot h)^ 5 ) \]

\[ = f + f_{0}( 1 - f ) \]

vec3 F_Schlick(const vec3 f0, float VoH) {
    float f = pow(1.0 - VoH, 5.0);
    return f + f0 * (1.0 - f);
}

float F_Schlick(float f0, float f90, float VoH) {
    return f0 + (f90 - f0) * pow5(1.0 - VoH);
}

vec3 fresnel(const vec3 f0, float LoH) {
#if FILAMENT_QUALITY == FILAMENT_QUALITY_LOW
    return F_Schlick(f0, LoH); // f90 = 1.0
#else
    float f90 = saturate(dot(f0, vec3(50.0 * 0.33)));
    return F_Schlick(f0, f90, LoH);
#endif
}

代码里实现了一种更准确的F项,具体细节看这里: Specular occlusion

anisotropic specular

各向异性的specular,也是基于pbr公式改的。

anisotropic-D项

首先是D项发生了变化:

AD.png

其中多了2个新的标量参数\( \alpha _{t}\)和\( \alpha _{b}\), 即\( \alpha _{tangent}\)和\( \alpha _{bitangent}\),切线方向的粗糙度和副切线方向的粗糙度。

以及多了2个向量t和b,即切线和副切线。

\( \alpha _{t}\)和\( \alpha _{b}\)的计算方法如下:

AA.png

    float at = max(pixel.roughness * (1.0 + pixel.anisotropy), MIN_ROUGHNESS);
    float ab = max(pixel.roughness * (1.0 - pixel.anisotropy), MIN_ROUGHNESS);

切线pixel.anisotropicT和副切线pixel.anisotropicB是在世界空间下的,它们的计算代码如下:

void getAnisotropyPixelParams(const MaterialInputs material, inout PixelParams pixel) {
    vec3 direction = material.anisotropyDirection;
    pixel.anisotropy = material.anisotropy;
    pixel.anisotropicT = normalize(shading_tangentToWorld * direction);
    pixel.anisotropicB = normalize(cross(getWorldGeometricNormalVector(), pixel.anisotropicT));
}

切线空间下的切线方向direction,本来应该是从纹理读的,但性能考虑,直接用材质参数material.anisotropyDirection了。

再来看D项公式的化简:

\[ D_{aniso}(h,\alpha ) = \frac{1}{\pi \alpha _t \alpha _b} \frac{1}{ ((\frac {t\cdot h}{\alpha _t})^2 + (\frac {b\cdot h}{\alpha _b})^2 + (n\cdot h)^2)^2 } \]

令:

\[ d = (\frac {t\cdot h}{\alpha _t}, \frac {b\cdot h}{\alpha _b}, n\cdot h) \]

得到:

\[ D_{aniso}(h,\alpha ) = \frac{1}{\pi \alpha _t \alpha _b} \frac{1}{ (d\cdot d)^2 } \]

再设d':

\[ d' = \alpha _t \alpha _b d = (\alpha _b t\cdot h,\alpha _t b\cdot h, \alpha _t \alpha _b n\cdot h) \]

于是:

\[ d = \frac {d'}{\alpha _t \alpha _b} = \frac {(\alpha _b t\cdot h,\alpha _t b\cdot h, \alpha _t \alpha _b n\cdot h)}{\alpha _t \alpha _b} \]

\[ d\cdot d = \frac {d'\cdot d'}{(\alpha _t \alpha _b)^2} \]

\[ (d\cdot d)^2 =\frac { (d'\cdot d')^2 }{(\alpha _t \alpha _b)^4} \]

\[ \frac { 1 } { (d\cdot d)^2 } = \frac { (\alpha _t \alpha _b)^4 }{ (d'\cdot d')^2 } \]

\[ D_{aniso}(h,\alpha ) = \frac{1}{\pi \alpha _t \alpha _b} \frac { (\alpha _t \alpha _b)^4 }{ (d'\cdot d')^2 } = \frac{1}{\pi } \frac { (\alpha _t \alpha _b)^3 }{ (d'\cdot d')^2 } \]

完整的D项代码如下,和推导出来的公式对得上:

float D_GGX_Anisotropic(float at, float ab, float ToH, float BoH, float NoH) {
    float a2 = at * ab;
    highp vec3 d = vec3(ab * ToH, at * BoH, a2 * NoH);
    highp float d2 = dot(d, d);
    float b2 = a2 / d2;
    return a2 * b2 * b2 * (1.0 / PI);
}

anisotropic-V项

各向异性的V项公式贼复杂,就不研究了,直接套代码即可。需要的参数上面都介绍过了:

float visibilityAnisotropic(float roughness, float at, float ab,
        float ToV, float BoV, float ToL, float BoL, float NoV, float NoL) {
#if BRDF_ANISOTROPIC_V == SPECULAR_V_SMITH_GGX
    return V_SmithGGXCorrelated(roughness, NoV, NoL);
#elif BRDF_ANISOTROPIC_V == SPECULAR_V_GGX_ANISOTROPIC
    return V_SmithGGXCorrelated_Anisotropic(at, ab, ToV, BoV, ToL, BoL, NoV, NoL);
#endif
}

从代码里看出,可以直接用各向同性的V项。

anisotropic-F项

各向异性的F项和各向同性的F项一样。

(未经授权禁止转载)
Written on January 18, 2022

写作不易,您的支持是我写作的动力!