好久没写博客,我都不知道怎么开头了。
由于比较忙而且本人数学较差,有关于数学方面的推导和解释我就不详细地写了,因为即使写了也是借鉴(Copy)大佬们的文章内容。这里推荐一个知乎大佬topameng的文章,已经是把PBR解析得非常清楚了,每个公式都有详细的推导。照例先上效果图,感觉和Unity的Standard差别很大,左边是Unity的Standard。
再来个动图,调得有点快
PBR光照模型无非分两大部分,直接光照部分+间接光照部分,前面我以及实现了直接光照部分Unity Shader 简单实现一个PBR模型(不含IBL)接下来需要实现的无非是间接光照部分,而和直接光照部分类似,间接光照的计算也分成漫反射部分和高光部分,大致情况如下
F
i
n
a
l
C
o
l
o
r
=
L
i
g
h
t
D
i
r
e
c
t
+
L
i
g
h
t
I
n
d
i
r
e
c
t
\boldsymbol{FinalColor={Light}_{\color{Red}Direct}+Light}_{{\color{Red}Indirect}}
FinalColor=LightDirect+LightIndirect
L
i
g
h
t
D
i
r
e
c
t
=
D
i
f
f
u
s
e
D
i
r
e
c
t
+
S
p
e
c
u
l
a
r
D
i
r
e
c
t
\boldsymbol{Light_{\color{Red}Direct}=Diffuse_{\color{Red}Direct}+Specular}_{{\color{Red}Direct}}
LightDirect=DiffuseDirect+SpecularDirect
L
i
g
h
t
I
n
d
i
r
e
c
t
=
D
i
f
f
u
s
e
I
n
d
i
r
e
c
t
+
S
p
e
c
u
l
a
r
I
n
d
i
r
e
c
t
\boldsymbol{Light_{\color{Red}Indirect}=Diffuse_{\color{Red}Indirect}+Specular}_{{\color{Red}Indirect}}
LightIndirect=DiffuseIndirect+SpecularIndirect
IBL(Image Base Light),基于图像的光照,在PBR里就我目前自己的理解就是使用CubeMap代替间接照明部分的光照,将贴图对的每一个像素作为一个光源,这可以在节省性能的情况下营造一种全局光照的氛围,让渲染的物件更好地融入周围的环境。
接下来我们来看反射方程
L
o
(
p
,
ω
o
)
=
∫
Ω
(
k
d
c
π
+
k
s
D
F
G
4
(
ω
o
⋅
n
)
(
ω
i
⋅
n
)
)
L
i
(
p
,
ω
i
)
n
⋅
ω
i
d
ω
i
\boldsymbol{L_{o}(p,ω_{o})=\int_{Ω}(k_{d}\frac{c}{π}+k_{s}\frac{DFG}{4(ω_{o}⋅n)(ω_{i}⋅n)})L_{i}(p,ω_{i})n⋅ω_{i}dω_{i}}
Lo(p,ωo)=∫Ω(kdπc+ks4(ωo⋅n)(ωi⋅n)DFG)Li(p,ωi)n⋅ωidωi
BRDF 的漫反射
k
d
k_{d}
kd 和镜面
k
s
k_{s}
ks 项是相互独立的,这就对应了我上面所说的间接光照的计算也分成漫反射部分和高光部分,于是我们可以将积分分成两部分:
L
o
(
p
,
ω
o
)
=
∫
Ω
(
k
d
c
π
)
L
i
(
p
,
ω
i
)
n
⋅
ω
i
d
ω
i
+
∫
Ω
k
s
D
F
G
4
(
ω
o
⋅
n
)
(
ω
i
⋅
n
)
L
i
(
p
,
ω
i
)
n
⋅
ω
i
d
ω
i
\boldsymbol{L_{o}(p,ω_{o})=\int_{Ω}(k_{d}\frac{c}{π})L_{i}(p,ω_{i})n⋅ω_{i}dω_{i}+\int_{Ω}k_{s}\frac{DFG}{4(ω_{o}⋅n)(ω_{i}⋅n)}L_{i}(p,ω_{i})n⋅ω_{i}dω_{i}}
Lo(p,ωo)=∫Ω(kdπc)Li(p,ωi)n⋅ωidωi+∫Ωks4(ωo⋅n)(ωi⋅n)DFGLi(p,ωi)n⋅ωidωi
仔细观察漫反射积分,我们发现漫反射兰伯特项是一个常数项(颜色 c c c 、折射率 k d k_{d} kd 和 π π π在整个积分是常数),不依赖于任何积分变量。基于此,我们可以将常数项移出漫反射积分: L o ( p , ω o ) = k d c π ∫ Ω L i ( p , ω i ) n ⋅ ω i d ω i \boldsymbol{L_{o}(p,ω_{o})=k_{d}\frac{c}{π}\int_{Ω}L_{i}(p,ω_{i})n⋅ω_{i}dω_{i}} Lo(p,ωo)=kdπc∫ΩLi(p,ωi)n⋅ωidωi 这给了我们一个只依赖于 w i w_{i} wi的积分(假设 p p p位于环境贴图的中心)。有了这些知识,我们就可以计算或预计算一个新的立方体贴图,它在每个采样方向——也就是纹素——中存储漫反射积分的结果,这些结果是通过卷积计算出来的。
这段话来自LearnOpenGLCN,在间接光照部分由于每一个半球方向都可能有光照,所以这个式子的积分我们约不掉,而在实时渲染中即时解积分显然是不现实的,LearnOpenGL通过预卷积环境贴图的方式得到一张叫做辐照度图的立方体贴图来预计算积分,类似于下面右边的一张图片:
这一部分在Unity里的实现方式是球谐函数,球谐函数我没研究——看不懂,网上大佬们的文章很多,想深入了解可以自己去找下资料。具体的用法如下:
half3 irradiance=ShadeSH9(float4(normal,1));
具体细节我就不多说了,我也解释不清楚,ShadeSH9定义在UnityCG.cginc中,接受一个单位法线,normal.w为1,返回球谐函数的计算结果。
那么漫反射的计算可为如下代码,我给它加上了一个基本的环境颜色,再乘上albedo:
half3 irradiance=ShadeSH9(float4(normal,1));
half3 ambient=UNITY_LIGHTMODEL_AMBIENT;
half3 diffuse=max(half3(0,0,0),ambient+irradiance)*albedo;
第二部分只剩下高光:
L
o
(
p
,
ω
o
)
=
∫
Ω
k
s
D
F
G
4
(
ω
o
⋅
n
)
(
ω
i
⋅
n
)
L
i
(
p
,
ω
i
)
n
⋅
ω
i
d
ω
i
L_{o}(p,ω_{o})=\int_{Ω}k_{s}\frac{DFG}{4(ω_{o}⋅n)(ω_{i}⋅n)}L_{i}(p,ω_{i})n⋅ω_{i}dω_{i}
Lo(p,ωo)=∫Ωks4(ωo⋅n)(ωi⋅n)DFGLi(p,ωi)n⋅ωidωi
该部分不仅受入射光方向影响,还受视角影响,实时解积分也是不可能的。Epic Games 提出了一个解决方案,他们预计算高光部分的卷积,为实时计算作了一些妥协,这种方案被称为分割求和近似法(split sum approximation)。
L
o
(
p
,
ω
o
)
=
∫
Ω
k
s
D
F
G
4
(
ω
o
⋅
n
)
(
ω
i
⋅
n
)
d
ω
i
∗
∫
Ω
L
i
(
p
,
ω
i
)
n
⋅
ω
i
d
ω
i
L_{o}(p,ω_{o})=\int_{Ω}k_{s}\frac{DFG}{4(ω_{o}⋅n)(ω_{i}⋅n)}dω_{i}\ast\int_{Ω}L_{i}(p,ω_{i})n⋅ω_{i}dω_{i}
Lo(p,ωo)=∫Ωks4(ωo⋅n)(ωi⋅n)DFGdωi∗∫ΩLi(p,ωi)n⋅ωidωi
这样子积分又成为可单独计算的两个部分,第二部分类似于漫反射部分的辐照度图,是预先计算的环境卷积贴图,但这次考虑了粗糙度。因为随着粗糙度的增加,参与环境贴图卷积的采样向量会更分散,导致反射更模糊,所以对于卷积的每个粗糙度级别,我们将按顺序把模糊后的结果存储在预滤波贴图的mipmap 中。例如,预过滤的环境贴图在其 5 个 mipmap 级别中存储 5 个不同粗糙度值的预卷积结果,如下图所示:
在Unity中,这张图片Unity也给我们准备好了,就是unity_SpecCube0,具体的用法是:
//unity用的是感性粗糙度
float roughness=1- _Roughness;
...
//roughness的计算参考Unity的Unity_GlossyEnvironment的实现
//Unity_GlossyEnvironment在UnityImageBaseLight.cginc定义
roughness=roughness*(1.7-0.7*roughness);
//UNITY_SPECCUBE_LOD_STEPS是定义在UnityImageBaseLight.cginc里的常量
//默认值为6,此值为感性粗糙度和立方体贴图mipmap层级之间的系数
half mip=roughness*UNITY_SPECCUBE_LOD_STEPS;
float3 R=reflect(-viewDir,normal);
//UNITY_SAMPLE_TEXCUBE_LOD根据反射向量和mipmap层级对立方体纹理贴图进行采样,采样的纹理是一个HDR值
half4 rgbm=UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,R,mip);
//对HDR解码得到RGB值
half3 preColor=DecodeHDR(rgbm,unity_SpecCube0_HDR);
第一部分涉及到入射光方向 ω i ω_{i} ωi和视角方向 ω o ω_{o} ωo影响,但是我们在卷积环境贴图时事先不知道视角方向,因此 Epic Games
假设视角方向——也就是高光反射方向——总是等于输出采样方向ωo,以作进一步近似。翻译成代码如下:
vec3 N = normalize(w_o);
vec3 R = N;
vec3 V = R;
我感觉这部分说得是真的绕,我理解的是计算时假设一个 ω o ω_{o} ωo,例如 ω o = f l o a t 3 ( 0 , 0 , 1 ) ω_{o}=float3(0,0,1) ωo=float3(0,0,1),然后 ω o ω_{o} ωo=N=R=V。我不知道这样理解对不对。
这样,预过滤的环境卷积就不需要关心视角方向了。这意味着当从如下图的角度观察表面的镜面反射时,得到的掠角镜面反射效果不是很好(图片来自文章《Moving Frostbite to PBR》)。然而,通常可以认为这是一个体面的妥协:
在解决了
ω
o
ω_{o}
ωo方向问题后,我们就可以在给定粗糙度、光线
ω
i
ω_{i}
ωi法线
n
n
n 夹角
n
⋅
ω
i
n⋅ω_{i}
n⋅ωi 的情况下,预计算第一项积分的响应结果,该结果存储在一张 2D 查找纹理(LUT)上,称为BRDF积分贴图。2D 查找纹理存储是菲涅耳响应的系数(R 通道)和偏差值(G 通道),它为我们提供了分割版高光反射积分的第一个部分:
这张图可以直接在LearnOpenGL找到,可以直接拿来用。
该图的具体用法如下代码所示:
half NdotV=saturate(dot(normal,viewDir));
//限制范围在0-0.99是为了防止出现特殊情况
half2 brdf=tex2D(_BRDF,half2(lerp(0, 0.99,NdotV),lerp(0, 0.99,_Roughness))).rg;
specular=preColor*(F*brdf.x+brdf.y);
Lo+=Kd*diffuse+specular;
我的Shader的完整代码如下(6月22日修改——添加切线空间计算部分):
Shader "MyShader/PBR"
{
Properties
{
_Color("Color",Color)=(1,1,1,1)
_MainTex ("Albedo(RGB)", 2D) = "white" {}
[NoScaleOffset]_BumpTex("Normal Map(RGB)",2D)="bump"{}
[NoScaleOffset]_Metallic("Metallic(RGB)",2D)="metallic"{}
[NoScaleOffset]_BRDF("Lut(RG)",2D)="metallic"{}
_BumpSacle("Bump Sacle",Range(-1,1))=1
_Roughness("Roughness",Range(0,1))=0.1
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode"="ForwardBase"}
LOD 100
CGINCLUDE
#include "UnityCG.cginc"
#include "Lighting.cginc"
float3 FresnelSchlick(float cosTheta,float3 F0)
{
return F0+(1.0-F0)*pow(1.0-cosTheta,5.0);
}
float DistributionGGX(float3 N,float3 H,float roughness)
{
float a2=roughness*roughness;
a2=a2*a2;
float NdotH=saturate(dot(N,H));
float NdotH2=NdotH*NdotH;
float denom=(NdotH2*(a2-1.0)+1.0);
denom=UNITY_PI*denom*denom;
return a2/denom;
}
float3 GeometrySchlickGGX(float NdotV,float roughness)
{
float r=roughness+1.0;
float k=r*r/8.0;
float denom=NdotV*(1.0-k)+k;
return NdotV/denom;
}
float GeometrySmith(float3 N,float3 V,float3 L,float roughness)
{
float NdotV=saturate(dot(N,V));
float NdotL=saturate(dot(N,L));
float ggx1=GeometrySchlickGGX(NdotV,roughness);
float ggx2=GeometrySchlickGGX(NdotL,roughness);
return ggx1*ggx2;
}
ENDCG
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal:NORMAL;
float4 tangent:TANGENT;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 lightDir:TEXCOORD1;
float3 viewDir:TEXCOORD2;
float3 TtoW0:TEXCOORD3;
float3 TtoW1:TEXCOORD4;
float3 TtoW2:TEXCOORD5;
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpTex;
float _BumpSacle;
float _Roughness;
sampler2D _Metallic;
sampler2D _BRDF;
float _AO;
fixed4 _Color;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
//用法线贴图要用的,基本操作了
float3 tangent=UnityObjectToWorldDir(v.tangent);
float3 normal=UnityObjectToWorldNormal(v.normal);
float3 binormal=cross(normal,tangent)*v.tangent.w;
o.TtoW0=float3(tangent.x,binormal.x,normal.x);
o.TtoW1=float3(tangent.y,binormal.y,normal.y);
o.TtoW2=float3(tangent.z,binormal.z,normal.z);
float3 worldPos=mul(unity_ObjectToWorld,v.vertex);
o.lightDir=UnityWorldSpaceLightDir(worldPos);
o.viewDir=UnityWorldSpaceViewDir(worldPos);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float4 packNormal=tex2D(_BumpTex,i.uv);
float3 normal=UnpackNormal(packNormal);
normal.xy*=_BumpSacle;
normal.z=sqrt(1.0- saturate(dot(normal.xy,normal.xy)));
normal=normalize(float3(dot(i.TtoW0.xyz,normal),dot(i.TtoW1.xyz,normal),dot(i.TtoW2.xyz,normal)));
float3 lightDir=normalize(i.lightDir);
float3 viewDir=normalize(i.viewDir);
float3 halfDir=normalize(lightDir+viewDir);
fixed4 albedo =tex2D(_MainTex, i.uv)*_LightColor0*_Color;
float3 F0=float3(0.01,0.01,0.01);
float3 metallic=tex2D(_Metallic,i.uv).r;
F0=lerp(F0,albedo,metallic);
float3 Lo=float3(0,0,0);
float roughness=1- _Roughness;
float NDF=DistributionGGX(normal,halfDir,roughness);
float G=GeometrySmith(normal,viewDir,lightDir,roughness);
float3 F=FresnelSchlick(clamp(dot(halfDir,viewDir),0,1),F0);
float3 nom=NDF*G*F;
float3 denom=4*max(dot(normal,viewDir),0)*saturate(dot(normal,lightDir));
float3 specular=nom/max(denom,0.001);//max避免denom为零
float3 Ks=F;
float3 Kd=1-Ks;
Kd*=1.0-metallic;
float NdotL=max(dot(normal,lightDir),0.0);
Lo=(Kd*albedo+specular)*NdotL;//
half3 irradiance=ShadeSH9(float4(normal,1));
half3 ambient=UNITY_LIGHTMODEL_AMBIENT;
half3 diffuse=max(half3(0,0,0),ambient+irradiance)*albedo;
roughness=roughness*(1.7-0.7*roughness);
//half mip=perceptualRoughnessToMipmapLevel(roughness);
half mip=roughness*UNITY_SPECCUBE_LOD_STEPS;
float3 R=reflect(-viewDir,normal);
half4 rgbm=UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,R,mip);
half3 preColor=DecodeHDR(rgbm,unity_SpecCube0_HDR);
half NdotV=saturate(dot(normal,viewDir));
half2 brdf=tex2D(_BRDF,half2(lerp(0, 0.99,NdotV),lerp(0, 0.99,_Roughness))).rg;
specular=preColor*(F*brdf.x+brdf.y);
Lo+=Kd*diffuse+specular;
float4 finalColor=float4(Lo,1.0);
return finalColor;
}
ENDCG
}
}
}
最后我贴一下在Unity里生成查找Lut贴图,主要参考基于物理的环境光渲染一。
Shader代码,我自己的函数写在cginc里了,这里是基于物理的环境光渲染一的Shader代码。
Shader "topameng/Unlit/IntegrateBRDF"
{
Properties
{
}
SubShader
{
Cull Off
ZWrite Off
ZTest Always
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
float RadicalInverse_VdC(uint bits)
{
bits = (bits << 16) | (bits >> 16);
bits = ((bits & 0x00ff00ff) << 8) | ((bits & 0xff00ff00) >> 8);
bits = ((bits & 0x0f0f0f0f) << 4) | ((bits & 0xf0f0f0f0) >> 4);
bits = ((bits & 0x33333333) << 2) | ((bits & 0xcccccccc) >> 2);
bits = ((bits & 0x55555555) << 1) | ((bits & 0xaaaaaaaa) >> 1);
return float(bits) * 2.3283064365386963e-10;
// 0x100000000
}
float2 Hammersley(uint i, uint N)
{
return float2(float(i) / float(N), RadicalInverse_VdC(i));
}
float4 ImportanceSampleGGX(float2 u, float roughness)
{
float m = roughness * roughness;
float m2 = m * m;
float phi = 2 * UNITY_PI * u.x;
float cosTheta = sqrt((1 - u.y) / (1 + (m2 - 1) * u.y));
float sinTheta = sqrt(1 - cosTheta * cosTheta);
float3 H;
H.x = sinTheta * cos(phi);
H.y = sinTheta * sin(phi);
H.z = cosTheta;
//float d = ( cosTheta * m2 - cosTheta ) * cosTheta + 1;
//float D = m2 / ( UNITY_PI * d *d );
//float PDF = D * cosTheta;
//return float4( H, PDF );
return float4(H, 1.0);
}
float3x3 GetTangentBasis(float3 tangentZ)
{
float3 up = abs(tangentZ.z) < 0.999 ? float3(0, 0, 1) : float3(1, 0, 0);
float3 tangentX = normalize(cross(up, tangentZ));
float3 tangentY = cross(tangentZ, tangentX);
return float3x3(tangentX, tangentY, tangentZ);
}
float3 TangentToWorld(float3 vec, float3 tangentZ)
{
return mul(vec, GetTangentBasis(tangentZ));
}
inline float Vis_SmithJointApprox(float roughness, float nv, float nl)
{
float a = roughness * roughness;
float Vis_SmithV = nl * (nv * (1 - a) + a);
float Vis_SmithL = nv * (nl * (1 - a) + a);
// Note: will generate NaNs with roughness = 0. MinRoughness is used to prevent this
return 0.5 / (Vis_SmithV + Vis_SmithL + 1e-5f);
}
float2 IntegrateBRDF(float NoV, float roughness)
{
const uint SAMPLE_COUNT = 1024u;
float3 N = float3(0, 0, 1);
float3 V;
V.x = sqrt(1.0 - NoV * NoV);
V.y = 0.0;
V.z = NoV;
float scale = 0;
float bias = 0;
for(uint i = 0; i < SAMPLE_COUNT; i++)
{
float2 Xi = Hammersley(i, SAMPLE_COUNT);
float3 H = TangentToWorld(ImportanceSampleGGX(Xi, roughness).xyz, N);
float3 L = 2 * dot(V, H) * H - V;
float NoL = max(L.z, 0.0);
float NoH = max(H.z, 0.0);
float VoH = max(dot(V, H), 0.0);
if (NoL > 0)
{
//1 / NumSample * \int[L * fr * (N.L) / pdf] with pdf = D(H) * (N.H) / (4 * (V.H)) and fr = F(H) * G(V, L) * D(H) / (4 * (N.L) * (N.V))
float Vis = Vis_SmithJointApprox(roughness, NoV, NoL) * 4 * NoL * VoH / NoH;
float Fc = pow(1.0 - VoH, 5);
scale += (1.0 - Fc) * Vis;
bias += Fc * Vis;
}
}
scale /= float(SAMPLE_COUNT);
bias /= float(SAMPLE_COUNT);
return float2(scale, bias);
}
float4 frag(v2f i) : SV_Target
{
return float4(IntegrateBRDF(i.uv.x, i.uv.y), 0, 1);
}
ENDCG
}
}
}
C#代码,放在Editor文件夹,用上面的shader就可以生成一张LUT图
using System.IO;
using UnityEditor;
using UnityEngine;
public class MyEditorLut : EditorWindow
{
[MenuItem("MyTools/GeneraterLut")]
static void AddWindow()
{
Rect wr = new Rect(0, 0, 300, 300);
MyEditorLut window = (MyEditorLut)EditorWindow.GetWindowWithRect(typeof(MyEditorLut), wr, true, "GeneraterLut");
window.Show();
}
public Shader lutShader;
private Texture texture;
private void OnGUI()
{
lutShader = EditorGUILayout.ObjectField("Shader", lutShader, typeof(Shader), true) as Shader;
if (GUILayout.Button("生成LUT贴图", GUILayout.Width(290)))
{
if(lutShader==null)
{
this.ShowNotification(new GUIContent("Shader 不能为空!"));
}
else
{
Export();
}
}
if (texture != null)
{
Rect rect = new Rect(10, 40, texture.width*4, texture.height*4);
GUI.DrawTexture(rect, texture);
}
}
void Export()
{
Material mat = new Material(lutShader);
RenderTexture rt=RenderTexture.GetTemporary(64, 64, 0, RenderTextureFormat.ARGBFloat);
Graphics.Blit(null, rt, mat);
Texture2D lutTex = new Texture2D(64, 64, TextureFormat.RGBAFloat, true, true);
Graphics.SetRenderTarget(rt);
lutTex.ReadPixels(new Rect(0, 0, 64, 64), 0, 0);
lutTex.Apply();
texture = lutTex;
byte[] bytes = lutTex.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat);
File.WriteAllBytes(Application.dataPath + "/Texture/Lut.exr", bytes);
RenderTexture.ReleaseTemporary(rt);
Graphics.SetRenderTarget(null);
AssetDatabase.Refresh();
}
}
我讲的都是比较简单,大部分都是我自己的理解,我相信还是有错误的地方的,如果发现错误十分欢迎指正,感谢你的阅读。
参考文章:
基于物理的环境光渲染
LearnOpenGL
如何在Unity中造一个PBR Shader轮子
下次可能将一下屏幕空间反射(Screen Space Reflection),今早起床拍的照片,完美的反射,hh,手机太垃圾,放大就糊了。