unity build-in管线中的PBR材质Shader分析研究

前言

近来,用到了几次Surface Shader,对于其封装好的PBR计算部分,如果不是复杂的效果其实是挺方便实用的了,但是如果我们想要实现更复杂的效果,还是不能依赖Surface Shader。我终于研究了PBR,但是发现很多国内论坛的资料语焉不详,或是单纯的公式推导分析,或是单纯的源码分析。但是有一些资料还是不错的,作者亲自写了pbr Shader,但是大部分也是只写了直接光漫反射和镜面反射两个部分,意思一下就结束了,少有写全间接光照的,而且我实践下来却发现与unity自带的pbr shader有相当大的出入,这些都驱使我更加深入地研究unity PBR的具体实现,在最后,我完成了一个PBR Shader,函数都与Unity基本相同,我的宗旨是: 最终画面效果一定要与unity默认的shader分毫不差,存在一点误差也就意味着失去了意义。

我理解的PBR

或许是因为我是美术生,我喜欢用美术的思维去理解事物,例如PBR,我将它分为四个部分:直接光漫反射、直接光镜面反射、间接光漫反射和间接光镜面反射。我认为直接光漫反射就像画素描时,先铺的大的明暗关系;直接光镜面反射其实可以理解为高光;间接光漫反射类似于我们画完阴影的暗部,会给它用橡皮泥或是纸巾提亮,给它一个环境光的反射,让阴影区域不至于死黑;间接光镜面反射类似于我们画一些光洁的物体如瓷瓶,我们会最后在上面勾画一些反射上去的窗户什么的。这样理解以后,事情就变得容易一些了。
首先我阅读了很多PBR的资料,了解了BRDF,然后看了一些实现,例如一篇知乎文章,作者的思路给了我很大启发,但是遗憾的是作者将unity与unreal的实现方法杂糅在了一起,最终效果也与standard Shader有一些出入,刚好也是两位TA同事入职了,他们在写PBR,于是我决定仔细阅读源码,力求彻底搞清Untiy build-in管线下的PBR实现。

PBR组成部分

注意:Unity的PBR分三档,分别对应性能不同的机器。我这里分析的都是按照BRDF1_Unity_PBS顶配方式分析的。

直接光漫反射

这里Unity使用的是Disney漫反射模型,为什么没有用lambert模型呢?因为Disney做了实验,认为lambert模型边缘地区太暗,与真实测量值不符,所以拟合了新的漫反射模型。公式如下:
公式
实现代码:

// Note: Disney diffuse must be multiply by diffuseAlbedo / PI. This is done outside of this function.
half DisneyDiffuse(half NdotV, half NdotL, half LdotH, half perceptualRoughness)
{
    half fd90 = 0.5 + 2 * LdotH * LdotH * perceptualRoughness;
    // Two schlick fresnel term
    half lightScatter   = (1 + (fd90 - 1) * Pow5(1 - NdotL));
    half viewScatter    = (1 + (fd90 - 1) * Pow5(1 - NdotV));
    return lightScatter * viewScatter;
}

效果(可以看到Disney漫反射模型的确要比lambert更亮一些):
对比

直接光镜面反射(高光)

直接关镜面反射由三个部分组成:D项法线分布函数、G项几何函数和F项菲涅尔项。
D项法线分布函数
这部分主要是希望得到一个漂亮的高光效果。传统的Blinn-phong高光缺乏真实度,研究发现高光是带有拖尾的,例如铬金属的高光带有显著的拖尾,GGX模型就是为了把这个拖尾模拟出来,虽然还不能完全模拟,但是比之前的模型已经好了很多。
高光
黑色曲线表示MERL 铬金属(chrome)真实的高光曲线,红色曲线表示 GGX分布(α= 0.006),绿色曲线表示Beckmann分布(m = 0.013),蓝色曲线表示 Blinn Phong(n = 12000),其中,绿色曲线和蓝色曲线基本重合。可以发现,GGX相对于传统的模型,更接近真实了。公式如下:
在这里插入图片描述
实现代码:

//D项 NDF
inline float GGXTerm (float NdotH, float roughness)
{
    float a2 = roughness * roughness;
    float d = (NdotH * a2 - NdotH) * NdotH + 1.0f; // 2 mad
    return UNITY_INV_PI * a2 / (d * d + 1e-7f); 
}

效果:
高光

G项几何函数
这是由微表面模型引出的。微表面理论认为物体表面的微小凹凸也会形成微阴影,进而导致物体的受光面没有那么亮,会更暗一些。而且越粗糙的物体微阴影越多,也就越暗。
Unity没有使用常见的G项公式:G项
而是采用了这篇论文的成果:
论文公式

Unity在论文公式的基础上又简化了实现,所以最终代码如下:

inline half SmithJointGGXVisibilityTerm1 (half NdotL, half NdotV, half roughness)
{
    half a = roughness;
    half lambdaV = NdotL * (NdotV * (1 - a) + a);
    half lambdaL = NdotV * (NdotL * (1 - a) + a);
    return 0.5f / (lambdaV + lambdaL + 1e-5f);
}

结果:
微表面

F项菲涅尔项
菲涅尔项在Unity中比较特殊,分为了两个部分,一个是FresnelTerm函数控制反射占的比重,一个是FresnelLerp控制飞掠角的镜面反射强度。FresnelTerm函数非常复杂,因为每个金属对不同波段的光响应曲线也不一样,现在实时渲染使用的都是简化过的,如下:
f项
实现代码如下:

/F项
half3 FresnelTerm(half3 F0,half cosA){
    half t=Pow5(1-cosA);// ala Schlick interpoliation
    return F0+(1-F0)*t;
}

这里,F0的计算如下:

float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, Albedo, _Metallic);

因此,对于金属来讲,它的Albedo其实就是F0的颜色,对于塑料这种非金属来讲,它的F0就是unity_ColorSpaceDielectricSpec,这是一个Unity内部设置的默认值,非常暗,颜色值为float3(0.04, 0.04, 0.04),算是一个非金属的默认F0了。以下是F0更多参考:
F0
结果:
f

FresnelLerp函数代码如下:

half3 FresnelLerp(half3 F0,half3 F90,half cosA){
half t=Pow5(1-cosA);
return lerp(F0,F90,t);
}

fresnelLerp
它的结果是边缘很强,视线直视的地方很暗,符合我们对菲涅尔的认知。这个值最终是与间接光镜面反射相乘,作为其系数。

间接光漫反射

在这里插入图片描述
我们导入一张cubemap贴图进unity,unity会自动帮我们把cubemap预积分处理成这样的一张模糊的图片。这个图片就是用来做间接光漫反射用的。看到这个图我们会有两个问题:1.是怎么做的?2.为什么糊到这种程度而不是更模糊或是更清晰呢?我的回答是:
1.Unity用的基函数叫三阶的伴随勒让德多项式,将cubemap采样后滤波做出的。具体做法可以在《 Real-time Rendering》得到。
2.因为Unity使用的是三阶伴随勒让德多项式,类似傅里叶变换后只取了几个低频部分,得到的结果自然是非常糊的一张图,丢失了太多高频信息。不过对于漫反射来讲,其实也已经够了。
我们可以非常方便的取出漫反射信息,只需要在片元着色器中加一句:

half3 ambient_contrib = ShadeSH9(float4(i.normal,1));//注意输入为WorldSpace的法线

结果:
ibl漫反射

间接光镜面反射

不同粗糙度的物体,反射的图像有的粗糙,有的清晰,难道我们需要很多张不同模糊程度的图输入进去吗?当然不需要,Unity帮我们处理好了cubemap的LOD,当物体越粗糙,就调用更高的LOD层级,这样内存占用只增加了百分之三十,但是效果非常好。
LOD
需要注意的是,粗糙度与LOD层级并非线性关系,而是一条曲线,如下:
mip
代码如下:

float mip_roughness = perceptualRoughness * (1.7 - 0.7*perceptualRoughness );

我们可以把视线方向取个负,然后根据法线把它镜像一下,去取cubemap.可以使用UNITY_SAMPLE_TEXCUBE_LOD函数。其中,unity_SpecCube0是unity内部维护的cubemap,我们可以直接取它,reflectVec就是视线方向的法线镜像,mip就是我们根据光滑度算出的mip层级。

half mip = mip_roughness * UNITY_SPECCUBE_LOD_STEPS;//得出mip层级。默认UNITY_SPECCUBE_LOD_STEPS=6(定义在UnityStandardConfig.cginc)
half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVec, mip);//视线方向的反射向量,去取样,同时考虑mip层级
half3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR);//使用DecodeHDR将颜色从HDR编码下解码。可以看到采样出的rgbm是一个4通道的值,
half surfaceReduction=1.0/(roughness*roughness+1.0);

需要注意的是直接取出的cubemap由于是HDR格式,颜色会超过1,导致材质非常亮,需要DecodeHDR一下,看起来才是正常的。

最终加和

最终,我们需要把上面四部分的值加和在一起。
其中直接光部分:

//漫反射系数kd
float3 kd = OneMinusReflectivityFromMetallic(_Metallic);
kd*=Albedo;
float3 specular = D * G * F ;
float3 specColor = specular * lightColor*nl*UNITY_PI;//直接光镜面反射部分。镜面反射的系数就是F。漫反射之前少除π了,所以为了保证漫反射和镜面反射的比例,这里还得乘一个π
float3 diffColor = kd * rawDiffColor;//直接光漫反射部分。
float3 directLightResult = diffColor + specColor;

间接光部分:

float3 iblDiffuseResult = iblDiffuse*kd;//乘间接光漫反射系数
half surfaceReduction=1.0/(roughness*roughness+1.0);
float oneMinusReflectivity = unity_ColorSpaceDielectricSpec.a-unity_ColorSpaceDielectricSpec.a*_Metallic;	//grazingTerm压暗非金属的边缘异常高亮
half grazingTerm=saturate(_Smoothness+(1-oneMinusReflectivity));
float3 iblSpecularResult = surfaceReduction*iblSpecular*FresnelLerp(F0,grazingTerm,nv);
float3 indirectResult = (iblDiffuseResult + iblSpecularResult)*occlusion;

值得一提的是surfaceReduction项,这是一个拟合项,如果没有它间接光镜面反射在粗糙物体上可能会过亮,导致不真实,这个我倒是没有特别找到合适的解释,按照实现代码看,应该如此。顺便一提,最终的遮蔽也要都乘上去。

最终加和:

float3 finalResult = directLightResult + indirectResult;

结果

Unity的pbr实现是非常繁琐的,往往需要套娃一样地去层层检索,如果没有VS Code这个得力工具,将更难以阅读。最终我还是实现了基础的PBR shader,不过具体的实现我不再表述,因为我已经将函数的调用直接写出来了,所以大家不必像我一样层层套娃,只需要一次就可以看到公式的实现。可以看到,效果已经和Standard Shader没有任何区别了,即便是快速替换材质,也难以发现区别,说明基本还原了Standard Shader的实现。通过层层检索unity对pbr几张贴图的处理,我添加了贴图的输入,确保它是一个实用的shader。此外,我还添加了lightProbe和reflection Probe以及烘焙lightmap的支持。
非金属材质表现:
非金属材质的表现
金属材质表现:
金属材质的表现
注意:需要在build-in管线中将gama空间改为linear空间!

Shader "Custom/myPBR"
{
	Properties
	{
		_Tint("Tint",Color)=(1,1,1,1)
		_MainTex ("Texture", 2D) = "white" {}
		//金属度要经过gama,否则即便是linear空间下渲染,unity也不会对一个滑条做操作的
		[Gamma]_Metallic("Metallic",Range(0,1))=0
		_MetallicGlossMap("Metallic", 2D) = "white" {}
		_Smoothness("Smoothness(Metallic.a)",Range(0,1))=0.5
		_BumpMap("Normal Map", 2D) = "bump" {}
		_Parallax ("Height Scale", Range (0.00, 0.08)) = 0.0
        _ParallaxMap ("Height Map", 2D) = "black" {}
		_OcclusionMap("Occlusion", 2D) = "white" {}

	}
	SubShader
	{
		Pass
		{
		Tags{"LightMode"="ForwardBase"}
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			//添加lightmap支持
			#pragma multi_compile LIGHTMAP_OFF LIGHTMAP_ON
			#include "UnityStandardBRDF.cginc"

			struct appdata
			{
				float4 vertex : POSITION;
				float3 normal:NORMAL;
				float2 uv : TEXCOORD0;
				float2 uv1:TEXCOORD1;
				fixed4 tangent : TANGENT;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				#ifndef LIGHTMAP_OFF 
				half2 uv1:TEXCOORD1;
				#endif
				float4 vertex : SV_POSITION;
				float3 normal:TEXCOORD2;
				float3 worldPos:TEXCOORD3;
				float4 tangent:TEXCOORD4;
				float3x3 tangentToWorld : TEXCOORD5; 
				float3 viewDir:COLOR1;
            	float3x3 tangentMatrix: TEXCOORD8; 
				float3 objectspaceViewdir:COLOR2;
			};

			sampler2D _MainTex;
			float4 _Tint;
			float _Metallic;
			float _Smoothness;
			float4 _MainTex_ST;
			sampler2D _MetallicGlossMap;
			sampler2D _BumpMap;
			sampler2D _OcclusionMap;
			float _Parallax;
			sampler2D _ParallaxMap;

inline half OneMinusReflectivityFromMetallic(half metallic)
{
    // We'll need oneMinusReflectivity, so
    //   1-reflectivity = 1-lerp(dielectricSpec, 1, metallic) = lerp(1-dielectricSpec, 0, metallic)
    // store (1-dielectricSpec) in unity_ColorSpaceDielectricSpec.a, then
    //   1-reflectivity = lerp(alpha, 0, metallic) = alpha + metallic*(0 - alpha) =
    //                  = alpha - metallic * alpha
    half oneMinusDielectricSpec = unity_ColorSpaceDielectricSpec.a;
    return oneMinusDielectricSpec - metallic * oneMinusDielectricSpec;
}

inline half3 DiffuseAndSpecularFromMetallic (half3 albedo, half metallic, out half3 specColor, out half oneMinusReflectivity)
{
    specColor = lerp (unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);
    oneMinusReflectivity = OneMinusReflectivityFromMetallic(metallic);
    return albedo * oneMinusReflectivity;
}
			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.worldPos = mul(unity_ObjectToWorld, v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				o.normal = UnityObjectToWorldNormal(v.normal);
				o.normal = normalize(o.normal);
				o.tangent=v.tangent;
				float3 normalWorld = UnityObjectToWorldNormal(v.normal);
				float4 tangentWorld = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
				// 对于奇怪的负缩放,我们需要sign取反(flip the sign)
				half sign = tangentWorld.w * unity_WorldTransformParams.w;
				half3 binormal = cross(normalWorld, tangentWorld) * sign;
				float3x3 tangentToWorld = half3x3(tangentWorld.xyz, binormal, normalWorld);
				o.tangentToWorld=tangentToWorld;
				o.viewDir=normalize(UnityWorldSpaceViewDir(o.worldPos));
				//Parallax viewDir need to changed from ObjectSpace to Tangent
				fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(o.worldPos));
                fixed3 objectspaceViewdir= mul(unity_WorldToObject, worldViewDir);
				o.objectspaceViewdir =normalize(objectspaceViewdir);
                float3 objectSpaceBinormal = normalize(cross(v.normal,v.tangent.xyz) * v.tangent.w);
                float3x3 tangentMatrix = float3x3(v.tangent.xyz, objectSpaceBinormal, v.normal);
				o.tangentMatrix = tangentMatrix;
				#ifndef LIGHTMAP_OFF
				o.uv1 = v.uv1.xy*unity_LightmapST.xy + unity_LightmapST.zw;
				#endif
				return o;
			}
			fixed4 frag (v2f i) : SV_Target
			{
				#ifndef LIGHTMAP_OFF
				fixed3 lm = DecodeLightmap(UNITY_SAMPLE_TEX2D(unity_Lightmap,i.uv1));
				float3 albedo = _Tint*tex2D(_MainTex,i.uv);
				float3 finalRes = albedo * lm;
				return float4( finalRes,1);
				#endif
				half height = tex2D(_ParallaxMap, i.uv).g;
                float3 tangentspaceViewDir =normalize( mul(i.tangentMatrix, i.objectspaceViewdir));
                i.uv += ParallaxOffset(height,_Parallax,tangentspaceViewDir);

				_Metallic=tex2D(_MetallicGlossMap,i.uv).r*_Metallic;
				_Smoothness=tex2D(_MetallicGlossMap,i.uv).a*_Smoothness;
				float occlusion=tex2D(_OcclusionMap,i.uv).r;
				float3 normal = normalize(i.normal);//没有加normalize操作,导致其还是取的顶点法线,没有进行插值
	//	#ifdef _NORMALMAP
				half3 tangent1 = i.tangentToWorld[0].xyz;
				half3 binormal1 = i.tangentToWorld[1].xyz;
				half3 normal1 = i.tangentToWorld[2].xyz;
				float3 normalTangent =UnpackNormal(tex2D(_BumpMap,i.uv));
				//return float4(1,0,0,1);
	//			normal= normalize(float3(dot(i.TtoW0.xyz, normalTangent), dot(i.TtoW1.xyz, normalTangent), dot(i.TtoW2.xyz, normalTangent)));//矩阵变换
				normal=normalize((float3)(tangent1 * normalTangent.x + binormal1 * normalTangent.y + normal1 * normalTangent.z));
	 // #endif
				float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
				//float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
		//		float3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
				float3 viewDir=i.viewDir;
				float3 lightColor = _LightColor0.rgb;
				float3 halfVector = normalize(lightDir + viewDir);
				//roughness相关
				float perceptualRoughness = 1 - _Smoothness;
				float roughness = perceptualRoughness * perceptualRoughness;
				roughness=max(roughness,0.002);//即便smoothness为1,也要有点高光在
				float squareRoughness = roughness * roughness;
				float nl = max(saturate(dot(normal , lightDir ) ) , 0.0000001);//防止除0
				float nv = max(saturate(dot(normal, viewDir)), 0.0000001);
				float vh = max(saturate(dot(viewDir, halfVector)), 0.0000001);
				float lh = max(saturate(dot(lightDir, halfVector)), 0.0000001);
				float nh = max(saturate(dot(normal, halfVector)), 0.0000001);
			
				//1.1直接光漫反射部分.兰伯特光照。没有除以π,是因为会显得太暗。
				float3 Albedo = _Tint*tex2D(_MainTex,i.uv);
				float3 rawDiffColor = DisneyDiffuse(nv,nl,lh,perceptualRoughness)*nl*lightColor;
				
				//1.2.直接光镜面反射部分
				// 1.2.1 D项(GGX)
				float D=GGXTerm(nh,roughness);
				// 1.2.2 G项 几何函数,遮蔽变暗一些
				//      直接光照和间接光照时的k都在逼近二分之一,只不过直接光照时这个值最小为八分之一而不是0。这是为了保证在表面绝对光滑时
				//      也会吸收一部分光线,毕竟完全不吸收光线的物体在现实中不存在
				float G=SmithJointGGXVisibilityTerm(nl,nv,roughness);
				//1.2.3 F项 菲涅尔反射 金属反射强边缘反射强
				float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, Albedo, _Metallic);
				float3 F=FresnelTerm(F0,lh);
				//漫反射系数kd
				float3 kd = OneMinusReflectivityFromMetallic(_Metallic);
				kd*=Albedo;
			
				float3 specular = D * G * F ;
				float3 specColor = specular * lightColor*nl*UNITY_PI;//直接光镜面反射部分。镜面反射的系数就是F。漫反射之前少除π了,所以为了保证漫反射和镜面反射的比例,这里还得乘一个π
				float3 diffColor = kd * rawDiffColor;//直接光漫反射部分。
				float3 directLightResult = diffColor + specColor;
				//至此,直接光部分结束

				//2.开始间接光部分
				//	2.1间接光漫反射
				half3 iblDiffuse = ShadeSH9(float4(normal,1));
				float3 iblDiffuseResult = iblDiffuse*kd;//乘间接光漫反射系数
				//	2.2间接光镜面反射
				float mip_roughness = perceptualRoughness * (1.7 - 0.7*perceptualRoughness );
				float3 reflectVec = reflect(-viewDir, normal);
				half mip = mip_roughness * UNITY_SPECCUBE_LOD_STEPS;//得出mip层级。默认UNITY_SPECCUBE_LOD_STEPS=6(定义在UnityStandardConfig.cginc)
				half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVec, mip);//视线方向的反射向量,去取样,同时考虑mip层级
				half3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR);//使用DecodeHDR将颜色从HDR编码下解码。可以看到采样出的rgbm是一个4通道的值,
				half surfaceReduction=1.0/(roughness*roughness+1.0);
				float oneMinusReflectivity = unity_ColorSpaceDielectricSpec.a-unity_ColorSpaceDielectricSpec.a*_Metallic;	//grazingTerm压暗非金属的边缘异常高亮
				half grazingTerm=saturate(_Smoothness+(1-oneMinusReflectivity));
				float3 iblSpecularResult = surfaceReduction*iblSpecular*FresnelLerp(F0,grazingTerm,nv);
				float3 indirectResult = (iblDiffuseResult + iblSpecularResult)*occlusion;
				//至此,结束间接光部分
				
				
				//最终加和
				float3 finalResult = directLightResult + indirectResult;
				return float4(finalResult,1);
			}
			ENDCG
		}
	}
	FallBack"Diffuse"
}

不足之处:本次研究只是侧重了Unity对于pbr的实现,对于Unity和标准pbr的实现的一些不同,源码中没有更多的解释,因此还有待于厘清,知其然还要知其所以然,基础才能足够扎实。
如果有帮到你的话,请点个赞吧!☺

参考资料:如何在Unity中造一个PBR Shader轮子

  • 29
    点赞
  • 62
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值