因为一直以来对BRDF的了解仅仅是大概知道
导致很多细节的控制力有限
所以干脆抽一个周末,好好动手把直接光部分的算法都做一遍,加深理解
间接光的部分核心是球谐算法和IBL, 以后有机会在仔细搞一遍间接光的部分需要的算法
这篇文章因为太长,并且太琐碎写的我欲生欲死, 后面几度不想写了
所以如果有任何错误请告诉我, 我会及时跟进修改
老规矩 先放上效果图:
GIT地址:
https://github.com/lingzerg/LingzergDemogithub.com
概念 - PBR中的金属度, 粗糙度, 和 反射率-F0
PBR在实时渲染中一个比较典型的实现是基于Cook-Torrance BRDF,
光传输方程中的BRDF就是表达一束光照射在一个表面(微面元)上之后反射出去的比率
而微面元我觉得是体现在两个函数上: 法线分布函数 以及几何遮蔽函数
我们通过学习BRDF可以更加深入的理解PBR,对于工作中如何设计, 调整材质表现有很大帮助,
而在此之后, 还可以进一步学习实时渲染中其他不同的公式,例如BSSRDF
BRDF有三个参数构成了整个运算的基础
金属度, 粗糙度, 反射率-F0
我想尽量不要用代码和公式描述这三个东西,第一部分可以让美术看懂
第二部分, 在这个三个参数的描述之后, 我们可以讨论下BRDF
以及FDG的具体运算方式
并且在unity中以GGX那套公式为基础实现一套完整的直接光材质
至于微面元理论和辐射度量学可以单独再开一篇文章详细记录
金属度
金属度代表了有多少光子是直接被反射出去, 有多少光子被吸收后成了漫反射
金属度等于0的情况下, 光子会被完全吸收, 直接反射会变得非常弱, 只有漫反射
具体漫反射会有多弱? 我们会在F0的地方提到
金属度如果等于1的情况下, 所有的光子都会被反射出去, 会完全没有漫反射
并且当金属度等于1的情况下, base color就会成为反射率, 所以base color其实是不能乱设置的
我们常常可以看见一个表:
因为在高金属度的情况下F0和base color的这个关系, 所以高金属度的这个情况下,base color更加重要
那假如金属度等于0.5呢? 那么反射率就是 F0 和 Basecolor的平均值
同理, 你的反射率就由这三个东西决定
F0, baseColor , 而金属度决定更靠近那个值
粗糙度
粗糙度代表表面的粗糙程度, 越粗糙的表面光的散射就会越厉害
还有一个额外的影响,就是越粗糙的表面可能会产生更多的遮蔽
所谓遮蔽是这个意思:
反射率-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
unity中无法设置这个F0, 少了一个控制的维度, 可能需要我们手动添加, 后面我们会在代码中添加这个
PBR方程/BRDF函数
先看看光传输方程:
其实外面这个积分并不是BRDF的一部分
积分的意义在于计算这个角度上, 微面元反射出去全部的 irradiance, 毕竟还有环境光, 多光源
但是BRDF是定义给定入射方向上的辐射照度如何影响给定出射方向上的辐射率
说白了就是
有位大佬指点我, 说的很有道理
BRDF可以看成两个函数
但是在大部分的实现中, 实际上是把这两个函数内联了
如果你看的有点晕,不要紧, 直接跟着下面实现一遍, 然后在扭回头在看这个公式
其次, (这里忘了...其次啥来着..., 想起来就补上, 想不起来这里就删了)
然后我们拆开看这个公式:
其实核心就是DGF的计算,
是光照方向这样不太方便看, 我后面改成
是反射方向, 也可以理解成视角,观察角,我们用
外面的
是LightColor, 后面的
就是光照方向点积法线
而Ks 就是fresnel,所以直接可以去掉了
我们把不太容易看明白的符号都换掉再看一眼:
kd, ks
DGF的公式我们先看一眼:
这一坨看起来确实很费劲, 如果你不想看也没问题, 我们后面一个一个实现一遍就好了
这里面NDF的:
就是粗糙度, 一般在实际开发中, 为了让使用者更舒服, 粗糙度会等于 输入粗糙度的平方
然后我们在解释几个基本概念
微面元与辐射度量学
这玩意展开讲真的要再开一篇文章
我今年有一半的时间都在跟灯光打交道,所以让我们把这些概念的详细描述放到另一篇文章里好了
简单的说辐射度量学和光传输方程最大的关联在于:
radiance是每单位角的辐射通量密度,
就是这个
而irradiance是辐照度,求的就是这个东西
PBR那个公式L 则是整个半球的辐照度积分
微面元理论则是说我们入射的 radiance 照射对象是一个概念上的表面
微面元最大的相关项是D和G, 在后面也会讲到
如果你想简单的理解, 辐射度量学就是入射光 , 和 反射结果
微面元就是照射的表面
大概就是这样
DOT - 点积
点积是代表两个向量的相似程度, 或者说两个向量的夹角大小, 或者说两个向量夹角的cos
如果两个向量完全重合, 点积等于1
如果两个向量夹角等于90度 则点积等于0, 大于90度则等于负数, 我们会归一化,所以在向量运算的时候 往往忽略这个情况.
有没有发现这个恰好可以描述光 视角 表面法线三者之间的关系?
例如光如果垂直于法线照射表面, 那么点积则等于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
}
}
}
然后我们先实现最基本的漫反射
我们先把外面的
写好放边上, 等下DGF部分最后的结果也要乘以这个
我们定义一个最后的输出FinalColor
接着我们需要计算kd, 而KD和F相关, 所以我们要先算出F
F的公式:
菲涅尔反射是一个光学效应, 当你的视角越贴近湖面, 反射就会越强
而当你垂直于湖面的时候菲涅尔就等于F0
所以这里用
来作为视角和法线的倾斜权重, ,任何时刻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);
fixed3 kd = (1-F)*(1-_Metallic);
float3 diffuse = _Color/PI * kd;
FinalColor.rgb = diffuse * lightColor * NdotL;
FinalColor.a;
return FinalColor;
}
然后你就可以看到场景里的球变成了这样:
感觉漫反射弱了很多? 因为unity很神奇的给结果乘了个PI,
但是这样其实是不对的, 等于unity中光照等于1的时候 实际上和ue的3.14一样
而UE中, 如果光照强度是1 , 粗糙度0.5 金属度0, 是这个样子:
为了和unity保持统一, 我们现在结果上乘一个π , 让结果看起来正确,
等最后完整着色器后我们可以根据这个仔细调试下
乘了π之后,果然结果就正确多了:
接着我们实现DGF中的D
公式如下:
同时D,项目和视角相关, 所以我们还需要view ,以及半角向量, 我们在程序中增加两个变量:
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
float3 halfVector = normalize(lightDir + viewDir); //半角向量
而
就是粗糙度, 但是我们要让输入的粗糙度先做一个平方操作, 这样的目的是为了让滑条和粗糙度的映射关系等于下面这个曲线:
这样美术在拉动粗糙度的滑动条时, 得到的值会偏小, 更容易控制高光
然后为了方便,我们单独建一个方法, 在下面调用,
方法的内容就是公式的内容:
//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;
嗯 稍微有点大不过不要紧, 我们后面会增加几何遮蔽项, 以及配平的除数
几何遮蔽 - G
先放公式:
也就是说几何遮蔽等于要调用两次
函数,
一次计算灯光的遮蔽情况, 一次计算视角的遮蔽情况,最后乘到一起
几何遮蔽描述的是微面元中两个物理情况:
就是说在微面元上, 不仅要光能照到, 并且眼睛也要能看到
所以我们要求两次G, 然后把他们乘起来, 几何遮蔽会减弱我们看到的灯光效果
还有一个问题我也很奇怪, 为啥直接光和间接光的k项不同呢?
看一下曲线:
这个在很多文章里都没有讲, 看曲线间接光好像就是k的取值根据相同的粗糙度会更弱一点
不过粗糙度等于1的情况下,k最终都等于0.5
所以老规矩, 我们先创建一个函数:
//G项
fixed SchlickGGX(float cosTheta, fixed k) {
return cosTheta/(cosTheta* (1-k)+k);
}
然后我们先计算
因为是直接光, 所以我们直接用直接光的公式:
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;
结果如下:
现在我们已经有了FDG, 我们先输出一下FDG的乘积看下效果:
这时候会发现高光变小了, 这是因为G项的遮蔽作用
然后我们把最后的配平参数写上:
这个配平参数的推导, 推荐大家看这篇文章中镜面反射如何推导的部分:
https://zhuanlan.zhihu.com/p/158025828zhuanlan.zhihu.com
实在过于繁琐, 我这里就不展开讲解了
如果你此时输出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;
最后输出看下结果:
然后我们可以看到, 如果在F0 = 0.04的情况下 和unity的standard材质高光还是不同, 我实际测试, 如果把F0换成:
那么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中加一个开关方便你测试
到这里就全部结束了, 我写的几度崩溃, 因为太长了...
相信看到这里的你也是个猛士
谢谢你的阅读, 并且如果你有任何建议或者吐槽欢迎留言
对于理论我主要参考的文章是这篇:
https://learnopengl.com/PBR/Theorylearnopengl.com
中文还有一篇很不错的分析 也推荐大家看下:
https://zhuanlan.zhihu.com/p/158025828zhuanlan.zhihu.com
曲线工具:
Desmos | Graphing Calculatorwww.desmos.com