手写PBR
在又经过一个星期左右的学习下,我对PBR的了解也有了一定的进展,所以我尝试了一下手写一套较为基础的pbr,不过对比于Unity自带的Standard来说,自己所写的肯定是略显粗糙的。Unity平台自带的pbr里处理了很多更细节的东西,诸如平台问题、优化问题、贴图烘焙问题等等,下面便简单说说自己写的一套pbr,记录一下。
首先,我们先回顾一下在第一章的时候,我们提到了一个反射方程,里面的BRDF函数是基于物理来计算的,具体的公式这里就不多阐述了,这里提供了链接方便回忆。
我们把需要计算的光源分为三部分,直接光照、间接光照、自发光。在第一章里,我们所介绍的是直接光照,而间接光照基本没有提及,下面先介绍一下间接光照的概念。
间接光照也可以理解为全局光照GI,顾名思义,即指物体受到间接的光照,它一般分为物体经过反射后的光和环境光,这里面可以大体地细分为间接漫反射和间接镜面反射。对于间接漫反射来说,如果物体是静态的,则可以使用光照贴图lightmap来烘焙上去,如果是动态的,那么一般会使用一个光照探针。而对于镜面反射,我们一般会使用反射探针进行采样实现,这也可以被称为IBL的部分。
简单的介绍了一下间接光照的概念,所以在shader里,我们主要需要计算的便是五大部分,分别是直接光的漫反射、直接光的高光反射、间接光的漫反射、间接光的高光反射、自发光。在本文中,自发光暂不做考虑,考虑到在实时渲染下计算半球积分基本是无法实现的,所以我们把反射方程替换为以下的公式:
如果存在多个光源,我们最后在把光照的计算效果进行累加,下面为代码的编写。
首先我们定义了一些需要的基本的材质属性
Properties
{
_Albedo("Albedo",2D) = "white"{} //主贴图
_MainColor("Main Color",Color) = (1,1,1,1) //主色调
_Metallic("Metallic",2D) = "white"{} // 金属度贴图
_MetallicScale("MetallicScale",Range(0,1)) = 1 // 金属强度
_NormalMap("Normal Map",2D) = "white"{} // 法线贴图
_NormalScale("Scale",Float) = 1.0 // 凹凸程度
_Occlusion("Occlusion",2D) = "white"{} // Ao贴图
_Smoothness("Smothness",Range(0,1)) = 1 // 光滑度
_LUT("LUT",2D) = "white"{} // Lut贴图
}
输入结构体和输出结构体
//输入结构体用UnityCG.cginc里定义的,引入头文件即可
struct appdata_tan
{
float4 vertex : POSITION;
float4 tangent : TANGENT;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 m0 : TEXCOORD1;
float4 m1 : TEXCOORD2;
float4 m2 : TEXCOORD3;//xyz 存储着从切线空间到世界空间的变换矩阵,w存储着世界坐标
SHADOW_COORDS(4)
};
顶点着色器
v2f vv(appdata_tan v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);//把顶点转换到裁剪空间
o.uv = TRANSFORM_TEX(v.texcoord,_Albedo);//计算uv偏移量
float3 worldpos = mul(unity_ObjectToWorld,v.vertex).xyz;//在世界坐标的顶点
float3 worldnormal = UnityObjectToWorldNormal(v.normal);//在世界坐标的法线
float3 worldtangent = UnityObjectToWorldDir(v.tangent.xyz);//在世界坐标的切线
float3 worldbinormal = cross(worldnormal,worldtangent) * v.tangent.w;//在世界坐标的副法线
o.m0 = float4(worldtangent.x,worldbinormal.x,worldnormal.x,worldpos.x);//存储变换矩阵
o.m1 = float4(worldtangent.y,worldbinormal.y,worldnormal.y,worldpos.y);
o.m2 = float4(worldtangent.z,worldbinormal.z,worldnormal.z,worldpos.z);
TRANSFER_SHADOW(o);//阴影计算
return o;
}
顶点函数里需要计算的与平时所写的shader差别不多,关键在于片元函数的光照计算
片元着色器
我们把需要计算的光照拆分成上述四项,我们首先计算光照需要用到的所有变量。
float3 worldpos = float3(i.m0.w,i.m1.w,i.m2.w);//世界坐标下的顶点
UNITY_LIGHT_ATTENUATION(atten,i,worldpos);//通过内置宏计算光照衰减
float3 L = normalize(UnityWorldSpaceLightDir(worldpos));//光照方向
float3 V = normalize(UnityWorldSpaceViewDir(worldpos));//视角方向
float3 N = UnpackNormal(tex2D(_NormalMap,i.uv));
N.xy *= _NormalScale;
N.z = sqrt(1.0 - saturate(dot(N.xy,N.xy)));
N = normalize(half3(dot(i.m0.xyz,N),dot(i.m1.xyz,N),dot(i.m2.xyz,N)));//经过法线贴图采样转换到世界坐标的法线
float4 Metal = tex2D(_Metallic,i.uv);//金属度贴图采样
Metallic = Metal.r * _MetallicScale;//取贴图的r通道作为金属度
Roughness = (1 - Metal.a * _Smoothness);//取贴图的a通道作为光滑度,但我们需要的是粗糙度,所以需要1 - 光滑度
float3 H = normalize(V + L);//半角向量
float NV = saturate(dot(N,V));//分别对应的点乘项
float NL = saturate(dot(N,L));
float NH = saturate(dot(N,H));
float LV = saturate(dot(L,V));
float LH = saturate(dot(L,H));
然后我们定义所有需要用到的模型,诸如D、F、G项等
//Schlick菲涅耳近似等式F
float3 fresnelSchlick(float cosTheta,float3 F0)
{
return F0 + (1 - F0) * pow(1.0 - cosTheta,5.0);
}
//引入粗糙度的菲涅耳公式
float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness)
{
return F0 + (max(float3(1.0 - roughness,1.0 - roughness,1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}
//法线分布函数D 用到的是GGX模型
float DistributionGGX(float NdotH,float roughness)
{
float a = pow(roughness,2);
float a2 = pow(a,2);
float fenmu = UNITY_PI * pow(pow(NdotH,2) * (a2 - 1) + 1,2);
return a2 / max(fenmu,0.001);
}
//几何函数G 用到的是GGX模型
float GeometrySchlickGGX(float NdotV, float roughness)
{
float r = pow(roughness,2);
float k = pow(r + 1,2) / 8;
float fenmu = NdotV * (1 - k) + k;
return NdotV / max(fenmu,0.001);
}
//阴影-遮蔽的混合函数
float GeometrySmith(float NdotV,float NdotL,float roughness)
{
float ggx1 = GeometrySchlickGGX(NdotV,roughness);
float ggx2 = GeometrySchlickGGX(NdotL,roughness);
return ggx1 * ggx2;
}
//基于物理的BRDF公式的高光反射项
float3 BRDFspecular(float HdotL,float NdotH,float NdotV,float NdotL,float F0)
{
float3 F = fresnelSchlick(HdotL,F0);
float NDF = DistributionGGX(NdotH,Roughness);
float G = GeometrySmith(NdotV,NdotL,Roughness);
float3 specular = NDF * F * G / max((4 * NdotV * NdotL),0.001);
return specular;
}
★直接光漫反射
这里用到的是基础的兰伯特模型,在漫反射项中基本为定值,所以我们可以很快的可以算出
Albedo = tex2D(_Albedo,i.uv) * _MainColor;//对主帖图进行采样
float3 F0 = float3(0.04,0.04,0.04);//定义物体的基础反射率,大部分都为0.04
F0 = lerp(F0,Albedo,Metallic);//考虑到如果为金属的话,基础反射率需要带有颜色信息,所以使用lerp进行处理
这里我在网上查阅到资料发现,在漫反射系数上,可以使用两种方法得到近似的值,第一种即使用通过菲涅耳计算得到的F作为高光反射率,而漫反射率则为1 - F,而这样计算可以保证出射光的比率永远不大于1,遵循了能量守恒定律
float3 Ks = fresnelSchlick(max(dot(H,V),0.0),F0);
float3 Kd = (float3(1.0,1.0,1.0) - Ks) * (1 - Metallic);
第二种则为使用Unity内置宏OneMinusReflectivityFromMetallic,经过两个系数效果的对比和资料的查询,对于菲涅耳作为系数来说,它从公式上更遵循物理,但效果上并没有使用内置宏好,这里读者可以自行取舍,因为PBR的变式数不胜数,正如图形学那句经典老话,“如果它看上去是对的,那么它就是对的”
float3 Kd = OneMinusReflectivityFromMetallic(Metallic);
另外别忘了在最后乘上1 - Metallic,因为金属几乎没有漫反射,而万物基本都有高光反射,所以在漫反射系数上我们需要做处理,让金属度参与进计算。
最后可以算出直接光的漫反射项,至于函数里的分母π,因为在上述公式展开后就被消掉了,所以不需要考虑
float3 diffuse = Kd * Albedo;
★直接光高光反射项
对于高光反射项,我们直接使用BRDFspecualar函数,即可得到
float3 specular = BRDFspecular(LH,NH,NV,NL,F0);
另外对于菲涅耳项的参数cosTheta有一个小小的拓展,对于传统的菲涅耳公式上来说,我们使用的是N和V,即法线和视角方向做点乘。但在PBR里,因为我们引入了微平面理论,而微平面是从微观角度上看待的,所以我们使用了半角向量H,用H代替了N,而在效果上,我们也会发现使用V和H做点乘的效果会更好。而Unity使用的是L和H,经查阅资料后,其实这是一种对GGX shader渲染效果的优化方法,可以在这里找到答案。
最后整合得到直接光的部分
float3 Lo = (diffuse + specular * UNITY_PI) * _LightColor0.rgb * NL;
★IBL部分
接下来便是刚刚介绍过的间接光照部分,即IBL。我们一样把它分为两个部分计算,间接光漫反射和间接光镜面反射。
首先,我们先从IBL部分说起,IBL全名即Image-Based Lighting 意思是基于纹理的光照。顾名思义,我们是从贴图上得到它们的光照信息。如果我们按照直接光的算法,我们是不可能计算所有不同的光线的各种反射和光路,当然在目前的光线追踪技术上,虽然我也不太理解,但是理论上是可以实现的,但是也需要极好的硬件支持。
★间接光漫反射
对于漫反射的计算,我们把反射公式拆解成两部分,BRDF的漫反射项是一个定值,我们可以直接算出。而剩下的积分项,我们可以把它近似的看成对CubeMap(环境贴图) 的采样,但这里我们并不需要做这个操作,因为Unity通过内置的一个宏,把相关的采样数据计算在了宏里。这个就是球谐函数,我们使用不同的球谐基底,便可以得到天空盒或光照探针的数据,对光照进行还原。最后再乘以漫反射系数,即为间接光的漫反射,在Unity里,漫反射可能还涉及到光照贴图的计算,这里暂不作考虑,代码和公式如下:
float3 ambient = 0.03 * Albedo; //计算基础的环境光
float3 sh = ShadeSH9(float4(N,1));//内置宏ShadeSH9计算相应的采样数据
float3 iblDiffuse = max(float3(0,0,0),sh + ambient.rgb);
float3 Flast = fresnelSchlickRoughness(max(dot(N,V), 0.0), F0, Roughness);//引入了粗糙度的菲涅耳项计算高光反射比例 反推出漫反射比例
float kd = (1 - Flast) * (1 - Metallic);//这里也可以使用直接光计算时使用的内置宏
iblDiffuse = iblDiffuse * Kd * Albedo;//最后乘上漫反射系数和原颜色
//注意这里同样跟直接光一样把反射方程简化了出来 通过采样代替积分计算,所以不需要除系数π
★间接光镜面反射
对于镜面反射部分,因为数值不是恒定的,它依赖于入射光的方向和视角方向,所以同样很难做到实时计算。所以我们这里借鉴了UE4引擎里的近似算法split sum,并把它简化成两项Li和BRDF。对于Li即光照信息部分(左边的部分),我们同样使用采样CubeMap来计算,但与漫反射不同的是,因为镜面反射受到粗糙度影响,所以我们根据粗糙度而使用对应的多个模糊程度不同的mipmap进行采样,公式如下:
对于左边的Li括号部分,代码如下:
float3 reflectDir = normalize(reflect(-V,N));//计算反射向量 使用该方向对CubeMap进行取样
float percetualRoughness = Roughness * (1.7 - 0.7 * Roughness);//因为粗糙度和mipmap的等级关系不是线性的 所以我们需要进行处理
float mip = percetualRoughness * 6;// 把数值范围映射到0到6之间,Unity默认的mip层级为6,也可以改为内置宏UNITY_SPECCUBE_LOD_STEPS
float4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,reflectDir,mip);//对CubeMap进行采样,unity_SpecCube0为天空盒或最近的反射探针的数据
float4 iblSpecular = float4(DecodeHDR(rgbm,unity_SpecCube0_HDR),1);//最后把采样到的颜色进行HDR解码处理
这样我们就得到了左边括号的值,对于右边括号的BRDF项,我们有两种做法可以进行模拟。首先第一种是传统的做法,即把值放进一张LUT查找图中,然后通过采样得到,采样坐标分别是NV和粗糙度,代码如下:
float2 envBDRF = tex2D(_LUT, float2(lerp(0, 0.99, NV), lerp(0, 0.99, Roughness))).rg;//对LUT进行采样
float3 iblSpecularResult = iblSpecular * (Flast * envBDRF.r + envBDRF.g);//最后通过使用采样得到的r值进行缩放和g值进行偏移得到结果
但是我们都知道,采样操作的开销跟普通函数的计算是要大不少,而Unity内部的Standard也不是使用LUT图进行采样,而是使用了一个叫surfaceReduction的系数,以及一个在F0和grazingTerm之间插值的菲涅耳系数,可以理解为上述公式的拟合函数,具体代码如下:
float grazingTerm = saturate(1 - Roughness + OneMinusReflectivityFromMetallic(Metallic.r));
float surfaceReduction = 1 / (pow(Roughness,2) + 1);
float3 iblSpecular= surfaceReduction * iblSpecular * FresnelLerp(float4(F0,1.0),grazingTerm,NV);
这里我们还是使用常规的LUT采样,最后把四个计算好的光照进行叠加,返回颜色值
float ao = tex2D(_Occlusion,i.uv).r;//计算Ao环境光遮罩效果
float3 color = Lo + (iblSpecular + iblDiffuse) * ao;//Lo为直接计算的直接光部分,后面为IBL间接光部分,需要注意的是要乘上Ao贴图的系数
return fixed4(color,1.0);
最后贴一下整体的代码:
Shader "Unlit/PBR"
{
Properties
{
_Albedo("Albedo",2D) = "white"{} //主贴图
_MainColor("Main Color",Color) = (1,1,1,1) //主色调
_Metallic("Metallic",2D) = "white"{} // 金属度贴图
_MetallicScale("MetallicScale",Range(0,1)) = 1 // 金属强度
_NormalMap("Normal Map",2D) = "white"{} // 法线贴图
_NormalScale("Scale",Float) = 1.0 // 凹凸程度
_Occlusion("Occlusion",2D) = "white"{} // Ao贴图
_Smoothness("Smothness",Range(0,1)) = 1 // 光滑度
_LUT("LUT",2D) = "white"{} // Lut贴图
}
SubShader
{
Pass
{
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vv
#pragma fragment ff
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 m0 : TEXCOORD1;
float4 m1 : TEXCOORD2;
float4 m2 : TEXCOORD3;
SHADOW_COORDS(4)
};
sampler2D _Albedo;
float4 _Albedo_ST;
sampler2D _Metallic;
sampler2D _NormalMap;
sampler2D _Occlusion;
sampler2D _LUT;
float _NormalScale;
float _Smoothness;
float4 _MainColor;
float _MetallicScale;
uniform float3 Albedo;
uniform float Metallic;
uniform float Roughness;
v2f vv(appdata_tan v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord,_Albedo);
float3 worldpos = mul(unity_ObjectToWorld,v.vertex).xyz;
float3 worldnormal = UnityObjectToWorldNormal(v.normal);
float3 worldtangent = UnityObjectToWorldDir(v.tangent.xyz);
float3 worldbinormal = cross(worldnormal,worldtangent) * v.tangent.w;
o.m0 = float4(worldtangent.x,worldbinormal.x,worldnormal.x,worldpos.x);
o.m1 = float4(worldtangent.y,worldbinormal.y,worldnormal.y,worldpos.y);
o.m2 = float4(worldtangent.z,worldbinormal.z,worldnormal.z,worldpos.z);
TRANSFER_SHADOW(o);
return o;
}
float3 fresnelSchlick(float cosTheta,float3 F0)
{
return F0 + (1 - F0) * pow(1.0 - cosTheta,5.0);
}
float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness)
{
return F0 + (max(float3(1.0 - roughness,1.0 - roughness,1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}
float DistributionGGX(float NdotH,float roughness)
{
float a = pow(roughness,2);
float a2 = pow(a,2);
float fenmu = UNITY_PI * pow(pow(NdotH,2) * (a2 - 1) + 1,2);
return a2 / max(fenmu,0.001);
}
float GeometrySchlickGGX(float NdotV, float roughness)
{
float r = pow(roughness,2);
float k = pow(r + 1,2) / 8;
float fenmu = NdotV * (1 - k) + k;
return NdotV / max(fenmu,0.001);
}
float GeometrySmith(float NdotV,float NdotL,float roughness)
{
float ggx1 = GeometrySchlickGGX(NdotV,roughness);
float ggx2 = GeometrySchlickGGX(NdotL,roughness);
return ggx1 * ggx2;
}
float3 BRDFspecular(float HdotL,float NdotH,float NdotV,float NdotL,float F0)
{
float3 F = fresnelSchlick(HdotL,F0);
float NDF = DistributionGGX(NdotH,Roughness);
float G = GeometrySmith(NdotV,NdotL,Roughness);
float3 specular = NDF * F * G / max((4 * NdotV * NdotL),0.001);
return specular;
}
fixed4 ff(v2f i) : SV_TARGET
{
float3 worldpos = float3(i.m0.w,i.m1.w,i.m2.w);
UNITY_LIGHT_ATTENUATION(atten,i,worldpos);
float3 L = normalize(UnityWorldSpaceLightDir(worldpos));
float3 V = normalize(UnityWorldSpaceViewDir(worldpos));
float3 N = UnpackNormal(tex2D(_NormalMap,i.uv));
N.xy *= _NormalScale;
N.z = sqrt(1.0 - saturate(dot(N.xy,N.xy)));
N = normalize(half3(dot(i.m0.xyz,N),dot(i.m1.xyz,N),dot(i.m2.xyz,N)));
float4 Metal = tex2D(_Metallic,i.uv);
Metallic = Metal.r * _MetallicScale;
Roughness = (1 - Metal.a * _Smoothness);
float3 H = normalize(V + L);
float NV = saturate(dot(N,V));
float NL = saturate(dot(N,L));
float NH = saturate(dot(N,H));
float LV = saturate(dot(L,V));
float LH = saturate(dot(L,H));
Albedo = tex2D(_Albedo,i.uv) * _MainColor;//对主帖图进行采样
float3 F0 = float3(0.04,0.04,0.04);//定义物体的基础反射率,大部分都为0.04
F0 = lerp(F0,Albedo,Metallic);//考虑到如果为金属的话,基础反射率需要带有颜色信息,所以使用lerp进行处理
float3 Ks = fresnelSchlick(max(dot(H,V),0.0),F0);
float3 Kd = (float3(1.0,1.0,1.0) - Ks) * (1 - Metallic);
//另一种漫反射系数的算法
//float3 Kd = OneMinusReflectivityFromMetallic(Metallic);
float3 diffuse = Kd * Albedo;
float3 specular = BRDFspecular(LH,NH,NV,NL,F0);
float3 Lo = (diffuse + specular * UNITY_PI) * _LightColor0.rgb * NL;
float3 ambient = 0.03 * Albedo;//计算基础的环境光
float3 sh = ShadeSH9(float4(N,1));//内置宏ShadeSH9计算相应的采样数据
float3 iblDiffuse = max(float3(0,0,0),sh + ambient.rgb);
float3 Flast = fresnelSchlickRoughness(max(dot(N,V), 0.0), F0, Roughness);//引入了粗糙度的菲涅耳项计算高光反射比例 反推出漫反射比例
float kd = (1 - Flast) * (1 - Metallic);//这里也可以使用直接光计算时使用的内置宏
iblDiffuse *= Kd * Albedo;//最后乘上漫反射系数和兰伯特定值
//注意这里同样跟直接光一样把反射方程简化了出来 通过采样代替积分计算,所以不需要除系数π
float3 reflectDir = normalize(reflect(-V,N));//计算反射向量 使用该方向对CubeMap进行取样
float percetualRoughness = Roughness * (1.7 - 0.7 * Roughness);//因为粗糙度和mipmap的等级关系不是线性的 所以我们需要进行处理
float mip = percetualRoughness * 6;// 把数值范围映射到0到6之间,Unity默认的mip层级为6,也可以改为内置宏UNITY_SPECCUBE_LOD_STEPS
float4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,reflectDir,mip);//对CubeMap进行采样,unity_SpecCube0为天空盒或最近的反射探针的数据
float3 iblSpecular = DecodeHDR(rgbm,unity_SpecCube0_HDR);//最后把采样到的颜色进行HDR解码处理
float2 envBDRF = tex2D(_LUT, float2(lerp(0, 0.99, NV), lerp(0, 0.99, Roughness))).rg; // LUT采样
iblSpecular *= (Flast * envBDRF.r + envBDRF.g);//最后通过使用采样得到的r值进行缩放和g值进行偏移得到结果
//instead for LUT
// float grazingTerm = saturate(1 - Roughness + OneMinusReflectivityFromMetallic(Metallic.r));
// float surfaceReduction = 1 / (pow(Roughness,2) + 1);
// iblSpecular = surfaceReduction * iblSpecular * FresnelLerp(float4(F0,1.0),grazingTerm,NV);
float ao = tex2D(_Occlusion,i.uv).r;//计算Ao环境光遮罩效果
float3 color = Lo + (iblSpecular + iblDiffuse) * ao;//Lo为直接计算的直接光部分,后面为IBL间接光部分,需要注意的是要乘上Ao贴图的系数
return fixed4(color,1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}
最后也是基本上把一个pbr的雏形给写出来了,当然对比Untiy自带的standard,还有很多不足的地方,而且pbr的变式也非常非常的多,光是我阅读查阅资料时,就发现了好多不同的写法和模型公式,这里只是用了最为常用的一种写法。接下来还要继续深入学习pbr,做到可以灵活运用修改在不同的项目中。
最后附上这篇文章参考过和阅读过的资料: