8

PBR中的金属度和粗糙度以及BRDF中的FDG项

 3 years ago
source link: https://zhuanlan.zhihu.com/p/304191958
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

因为一直以来对BRDF的了解仅仅是大概知道

导致很多细节的控制力有限

所以干脆抽一个周末,好好动手把直接光部分的算法都做一遍,加深理解

间接光的部分核心是球谐算法和IBL, 以后有机会在仔细搞一遍间接光的部分需要的算法

这篇文章因为太长,并且太琐碎写的我欲生欲死, 后面几度不想写了

所以如果有任何错误请告诉我, 我会及时跟进修改

老规矩 先放上效果图:

qMnQNj7.jpg!mobile

GIT地址:

https://github.com/lingzerg/LingzergDemo github.com

概念 - PBR中的金属度, 粗糙度, 和 反射率-F0

PBR中一个比较典型的实现-BRDF, 就是表达一束光照射在一个表面(微面元)上之后反射出去的结果

而微面元我觉得是体现在两个函数上: 法线分布函数 以及几何遮蔽函数

我们通过学习BRDF可以更加深入的理解PBR,对于工作中如何设计, 调整材质表现有很大帮助,

而在此之后, 还可以进一步学习其他不同的公式,例如 BSSRDF

BRDF有三个参数构成了整个运算的基础

金属度, 粗糙度, 反射率-F0

我想尽量不要用代码和公式描述这三个东西,第一部分可以让美术看懂

第二部分, 在这个三个参数的描述之后, 我们可以讨论下BRDF

以及FDG的具体运算方式

并且在unity中以GGX那套公式为基础实现一套完整的BRDF

至于微面元理论和辐射度量学可以单独再开一篇文章详细记录

金属度

金属度代表了有多少光子是直接被反射出去, 有多少光子被吸收后成了漫反射

金属度等于0的情况下, 光子会被完全吸收, 直接反射会变得非常弱, 只有漫反射

具体漫反射会有多弱? 我们会在F0的地方提到

金属度如果等于1的情况下, 所有的光子都会被反射出去, 会完全没有漫反射

并且当金属度等于1的情况下, base color就会成为反射率, 所以base color其实是不能乱设置的

我们常常可以看见一个表:

ANBnMfA.jpg!mobile

因为在高金属度的情况下F0和base color的这个关系, 所以高金属度的这个情况下,base color更加重要

那假如金属度等于0.5呢? 那么反射率就是 F0 和 Basecolor的平均值

同理, 你的反射率就由这三个东西决定

F0, baseColor , 而金属度决定更靠近那个值

jEJbyqR.jpg!mobile

粗糙度

粗糙度代表表面的粗糙程度, 越粗糙的表面光的散射就会越厉害

A3umMjj.jpg!mobile

还有一个额外的影响,就是越粗糙的表面可能会产生更多的遮蔽

所谓遮蔽是这个意思:

反射率-F0

F0就是反射率, 当我们90度直视一个表面的时候, 看到的光子回弹的比例

正如前面金属度提到的那样反射率基于F0和base color之间

所以base color设置多少就很重要了

在金属度等于1的情况,很容易分析, 漫反射等于0, 而F0就会等于base color

而在金属度等于0的情况下呢?

这时候F0就等于我们最开始设置的那个F0, 一般会设置为0.04, 甚至更高

大部分非金属的F0都很小, 例如0.04, 0.08

所以在UE中, 如果你给Specular设置成1, 那么F0的默认值就会是0.08

设置成0, 就是0,默认Specular应该是0.5, 也就是说F0等于0.04

iQVryqE.jpg!mobile

unity中无法设置这个F0, 少了一个控制的维度, 可能需要我们手动添加, 后面我们会在代码中添加这个

PBR方程/BRDF函数

先看看光传输方程:

NNBNzi6.png!mobile

其实外面这个积分并不是BRDF的一部分

积分的意义在于计算这个角度上, 微面元反射出去全部的 irradiance, 毕竟还有环境光, 多光源

但是BRDF是定义 给定入射方向上的辐射照度如何影响给定出射方向上的辐射率

说白了就是 6FZrEf.png!mobile

有位大佬指点我, 说的很有道理

BRDF可以看成两个函数

UfABryf.png!mobile

Avmiye.png!mobile

但是在大部分的实现中, 实际上是把这两个函数内联了

如果你看的有点晕,不要紧, 直接跟着下面实现一遍, 然后在扭回头在看这个公式

其次, (这里忘了...其次啥来着..., 想起来就补上, 想不起来这里就删了)

然后我们拆开看这个公式:

V3qeiqU.png!mobile

其实核心就是DGF的计算, imURJz2.png!mobile 是光照方向这样不太方便看, 我后面改成 7Jzi2ei.png!mobile

memyamF.png!mobile 是反射方向, 也可以理解成视角,观察角,我们用 vUnINnU.png!mobile

外面的 rIvIV3i.png!mobile 是LightColor, 后面的 NBrEBbj.png!mobile 就是光照方向点积法线

而Ks 就是fresnel,所以直接可以去掉了

我们把不太容易看明白的符号都换掉再看一眼:

RjqUBv.png!mobile

kd, ks

ARZJJnU.png!mobile

DGF的公式我们先看一眼:

这一坨看起来确实很费劲, 如果你不想看也没问题, 我们后面一个一个实现一遍就好了

这里面NDF的: 3MF3Ejq.png!mobile 就是粗糙度, 一般在实际开发中, 为了让使用者更舒服, 粗糙度会等于 输入粗糙度的平方

然后我们在解释几个基本概念

微面元与辐射度量学

这玩意展开讲真的要再开一篇文章

我今年有一半的时间都在跟灯光打交道,所以让我们把这些概念的详细描述放到另一篇文章里好了

简单的说辐射度量学和光传输方程最大的关联在于:

muueMbz.png!mobile

radiance是每单位角的辐射通量密度, 6FrYzaA.png!mobile 就是这个

而irradiance是辐照度,求的就是这个东西

PBR那个公式L 则是整个半球的辐照度积分

微面元理论 则是说我们入射的 radiance 照射对象是一个概念上的表面

微面元最大的相关项是D和G, 在后面也会讲到

如果你想简单的理解, 辐射度量学就是入射光 , 和 反射结果

微面元就是照射的表面

大概就是这样

DOT - 点积

点积是代表两个向量的相似程度, 或者说两个向量的夹角大小, 或者说两个向量夹角的cos

如果两个向量完全重合, 点积等于1

如果两个向量夹角等于90度 则点积等于0, 大于90度则等于负数, 我们会归一化,所以在向量运算的时候 往往忽略这个情况.

vM3eQfb.jpg!mobile

有没有发现这个恰好可以描述光 视角 表面法线三者之间的关系?

例如光如果垂直于法线照射表面, 那么点积则等于1 , 而光越倾斜, 则点积值越小

当等于大于90度 则灯光照射不到这个表面了

而视角也是同样的道理, 点积广泛用于渲染中来计算两个向量之间的关系

知道上面的内容后, 我们来用手轮一个基于pbr的直接光shader

我们在Unity里, 创建一个新的unlit的shader , 删除多余的部分, 开始试着实现一下

Shader "Unlit/MyBRDF"
{
    Properties
    {
        _Color ("Base Color", Color) = (1,1,1,1)
        [Gamma] _Metallic ("Metallic", Range(0,1)) = 0.5
        _Roughness ("Roughness", Range(0,1)) = 0.5
        _BaseF0 ("BaseF0",Range(0,1)) = 0.04
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityStandardBRDF.cginc" 
            
            #define PI 3.14159274f

            struct VertexInput
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float2 uv : TEXCOORD0;
                
            };

            struct Interpolators
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 worldPos : TEXCOORD1;
                fixed3 normal : TEXCOORD2;
				
                
            };

            fixed4 _Color;
            fixed _Metallic,_Roughness,_BaseF0;

            Interpolators vert (VertexInput v)
            {
                Interpolators i;
                i.vertex = UnityObjectToClipPos(v.vertex);
				i.worldPos = mul(unity_ObjectToWorld, v.vertex);
                
                i.normal = UnityObjectToWorldNormal(v.normal);
                i.normal = normalize(i.normal);
                return i;
            }

            fixed4 frag (Interpolators i) : SV_Target
            {
                
                return 0;
            }
            ENDCG
        }
    }
}

然后我们先实现最基本的漫反射

7NvI7vI.png!mobile

我们先把外面的 7byA73u.png!mobile 写好放边上, 等下DGF部分最后的结果也要乘以这个

我们定义一个最后的输出FinalColor

接着我们需要计算kd, 而KD和F相关, 所以我们要先算出F

F的公式:

iaAVRj3.png!mobile

菲涅尔反射是一个光学效应, 当你的视角越贴近湖面, 反射就会越强

而当你垂直于湖面的时候菲涅尔就等于F0

所以这里用 M7zqYzN.png!mobile 来作为视角和法线的倾斜权重, ,任何时刻F均大于F0

根据公式增加一个F的方法:

//F项 fresnel
fixed3 fresnelSchlick(float cosTheta, fixed3 F0)
{
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

在frag里增加代码:

fixed3 F0 = _BaseF0;
F0 = lerp(F0, _Color.rgb, _Metallic);
fixed3 F = fresnelSchlick(VdotH, F0);

然后把NdotL单独给一个变量, 代码如下:

fixed4 frag (Interpolators i) : SV_Target
{
    fixed4 FinalColor = 0;
    float3 lightColor = _LightColor0.rgb;
    float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
    
    float3 normal = normalize(i.normal);
    float VdotH = max(saturate(dot(viewDir, halfVector)), 0.000001);
    float NdotL = max(saturate(dot(normal, lightDir)), 0.000001);

    fixed3 F0 = _BaseF0;
    F0 = lerp(F0, _Color.rgb, _Metallic);
    fixed3 F = fresnelSchlick(VdotH, F0);
    
    fixed kd = (1-F)*(1-_Metallic);
    float3 diffuse = _Color/PI * kd;

    FinalColor.rgb = diffuse * lightColor * NdotL;                
    FinalColor.a;
    return FinalColor;
}

然后你就可以看到场景里的球变成了这样:

zYBB7ne.jpg!mobile

感觉漫反射弱了很多? 因为unity很神奇的给结果乘了个PI,

但是这样其实是不对的, 等于unity中光照等于1的时候 实际上和ue的3.14一样

我们现在结果上乘一个π , 让结果看起来正确,

等最后完整着色器后我们可以根据这个仔细调试下

return FinalColor * PI;

乘了π之后,果然结果就正确多了:

eINnMf.jpg!mobile

接着我们实现DGF中的D

公式如下:

bqyUf2u.png!mobile

同时D,项目和视角相关, 所以我们还需要view ,以及半角向量, 我们在程序中增加两个变量:

float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
float3 halfVector = normalize(lightDir + viewDir);  //半角向量

3MF3Ejq.png!mobile 就是粗糙度, 但是我们要让输入的粗糙度先做一个平方操作, 这样的目的是为了让滑条和粗糙度的映射关系等于下面这个曲线:

E3IzEbv.jpg!mobile

这样美术在拉动粗糙度的滑动条时, 得到的值会偏小, 更容易控制高光

然后为了方便,我们单独建一个方法, 在下面调用,

方法的内容就是公式的内容:

//D项
fixed DistributionGGX(fixed3 NdotH, fixed a) {

    fixed a2 = a*a;
    fixed denom = (NdotH*NdotH * (a2-1)+1);
    denom = PI * denom * denom;

    return a2/denom;
}

接着我们输出一下D看下效果:

fixed D = DistributionGGX(NdotH,roughness);
return D;
rIzaiei.jpg!mobile

嗯 稍微有点大不过不要紧, 我们后面会增加几何遮蔽项, 以及配平的除数

几何遮蔽 - G

先放公式:

QJRfqm6.png!mobile

IvUbmyb.png!mobile

IjaIrqA.png!mobile

也就是说几何遮蔽等于要调用两次 uYN3Ijb.png!mobile 函数,

一次计算灯光的遮蔽情况, 一次计算视角的遮蔽情况,最后乘到一起

几何遮蔽描述的是微面元中两个物理情况:

YzYFbuF.jpg!mobile

就是说在微面元上, 不仅要光能照到, 并且眼睛也要能看到

所以我们要求两次G, 然后把他们乘起来, 几何遮蔽会减弱我们看到的灯光效果

所以老规矩, 我们先创建一个函数:

//G项
fixed SchlickGGX(float cosTheta, fixed k) {
    return cosTheta/(cosTheta* (1-k)+k);
}

然后我们先计算 vEJJJfE.png!mobile

因为是直接光, 所以我们直接用直接光的公式:

emaUFzj.png!mobile

fixed k_dir = pow((squareRoughness+1),2)/8;

接着我们计算两次G项 并乘到一起, 然后输出到颜色看下效果:

fixed ggx1 = SchlickGGX(NdotL,k_dir);
fixed ggx2 = SchlickGGX(NdotV,k_dir);
fixed G = ggx1 * ggx2;
return G;

结果如下:

iEna6vr.jpg!mobile

现在我们已经有了FDG, 我们先输出一下FDG的乘积看下效果:

return fixed4(F*G*D,1);
Zzieuyf.jpg!mobile

这时候会发现高光变小了, 这是因为G项的遮蔽作用

然后我们把最后的配平参数写上:

FDG /= 4*NdotV*NdotL;

这个配平参数的推导, 推荐大家看这篇文章中镜面反射如何推导的部分:

TC130:彻底看懂PBR/BRDF方程 zhuanlan.zhihu.com muYJnuB.jpg!mobile

实在过于繁琐, 我这里就不展开讲解了

如果你此时输出FDG, 会发现FDG非常小, 这依旧是由于unity那个缩放的PI 导致的 TC130:彻底看懂PBR/BRDF方程 如果你此时输出FDG, 会发现FDG非常小, 这依旧是由于unity那个缩放的PI 导致的

我们直接把漫反射和高光项加到一起, 然后乘以外部的乘数

FinalColor.rgb = diffuse;
FinalColor.rgb += +FDG;
                
FinalColor.rgb *=  lightColor * NdotL * PI;
FinalColor.a = 1;
return FinalColor;

最后输出看下结果:

EjMzMzA.jpg!mobile

然后我们可以看到, 如果在F0 = 0.04的情况下 和unity的standard材质高光还是不同, 我实际测试, 如果把F0换成:

mMrm6f.jpg!mobile

那么unity的F0 显然不等于0.04了

我实测大概是0.15左右, 除了π 这里也是个让人哭笑不得的地方

而最后和unity的standard还是有一些出入

主要是我是严格按公式里的方式算的, 比如ue的F用的是一个近似算法:

float3 F = F0 + (1 - F0) * exp2((-5.55473 * vh - 6.98316) * vh);

unity肯定也会有一定的改动, 所以略有出入并不要紧,并且我觉得我实现的效果更接近公式的效果,毕竟unity是一个需要结果乘π的引擎...

我们也可以通过拉动参数调整

我也推荐你试试去掉结尾的π, 然后把光强拉到3.14试试

我在shader中加一个开关方便你测试

3eu6nyZ.jpg!mobile

到这里就全部结束了, 我写的几度崩溃, 因为太长了...

相信看到这里的你也是个猛士

谢谢你的阅读, 并且如果你有任何建议或者吐槽欢迎留言

对于理论我主要参考的文章是这篇:

https://learnopengl.com/PBR/Theory learnopengl.com

中文还有一篇很不错的分析 也推荐大家看下:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK