OK,最开始,希望大家能打开Unity build-in shader中的Standard Shader配套着来阅读这篇文章,因为虽然我会一句句的聊,但是因为排版问题,所以把Standard.shader打开当做教材,把这篇文章当做注释,效果会更好。

这篇文章是基于Unity 2018.3.0f2的standard shader,如果你手边的版本不同,也无所谓,反正那么多版本以来,这个shader还真的没什么变化。另外,毕竟是一篇专业博客,语法我就不说了。Unity Standard Shader支持的三种WorkflowMode:Metallic、Specular、Roughness,我会选择Metallic。四种BlendMode:Opaque、Cutout、Fade、Transparent,我会选择Opaque。两种SmoothnessMapChannel :SpecularMetallicAlpha、AlbedoAlpha,我会选择SpecularMetallicAlpha。两个SubShader共5种pass:ForwardBase、ForwardAdd、ShadowCaster、Deferred、Meta,我会重点介绍ForwardBase,简单提一下Deferred。相信大家都懂的。

OK,下面开始开心的读代码了:

#pragma shader_feature _NORMALMAP

shader_feature的意思,以及它和multi_compile的区别我就不说了。

Standard Shader有一个很好的机制。因为这个Shade中涉及到了很多贴图,比如法线贴图、高度贴图、AO、细节贴图等。但是当没有贴这些贴图的时候,其实是可以把这个贴图相关的计算省略掉。这个好处就在于如果贴了很多贴图,那么就会走一个复杂的逻辑,把各个效果都计算出来,但是如果贴了很少的贴图,比如就贴了一个固有色贴图,shader就会用一个比较简单的版本,这样就节约了大量的计算。所以如果只贴了少量的,比如固有色贴图,然后这个材质球的性能其实和普通的build-in shader,比如mobile diffuse shader其实性能差距不大。但是如果把standard shader的贴图贴满,(因为这样效果最好,各种细节都有),对比起来,性能还是差距比较多的。

这套机制的实现方式就是借用了shader_feature的原理以及自定义Editor“StandardShaderGUI”这个文件,其中代码为:

SetKeyword(material, "_NORMALMAP", material.GetTexture("_BumpMap") || material.GetTexture("_DetailNormalMap"));

这个机制挺好的,项目中所有的shader都可以考虑借用这个机制。

但是一切事物都有利有弊,机制是个好机制,但是弊端也很明显,增加了ShaderVariants的数量,大家都知道,每增加一个ShaderVariants,shader变体的数量就会*2,在游戏运行时占用的shader binaray内存也就*2,所以要做好权衡,百分之百不需要的ShaderVariants一定要删掉

那么OK,通过以上顺带介绍原理的功夫,_NORMALMAP这个ShaderVariants的意思我们就说明白了:当使用_BumpMap或者_DetailNormalMap的时候,这个宏会被打开,否则则关闭。

#pragma shader_feature _ _ALPHATEST_ON _ALPHABLEND_ON _ALPHAPREMULTIPLY_ON

OK,这里有三个ShaderVariants,因为我们在介绍BlendMode.Opaque,所以这三个宏都是出于关闭状态。BlendMode.Cutout:打开_ALPHATEST_ON,其他两个关闭。BlendMode.Fade:打开_ALPHABLEND_ON,其他两个关闭。BlendMode.Transparent:打开_ALPHAPREMULTIPLY_ON,其他两个关闭。

#pragma shader_feature _EMISSION

这个就有意思了,_EMISSION这个ShaderVariants是否开关,与_EmissionMap这张贴图毫无关系,从StandardShaderGUI的代码中可以看到,其取决于GI的设置:

bool shouldEmissionBeEnabled = (material.globalIlluminationFlags & MaterialGlobalIlluminationFlags.EmissiveIsBlack) == 0;

SetKeyword(material, "_EMISSION", shouldEmissionBeEnabled);

也就是说,该ShaderVariant的开关与否取决于当前GI的设置,当GI的设定为:这个物件的自发光信息不会去影响GI(lightmap系统)的时候,_EMISSION会被打开,否则则关闭。

#pragma shader_feature _METALLICGLOSSMAP

当WorkflowMode为Metallic,且使用了_MetallicGlossMap的时候,这个宏会被打开,否则则关闭。

#pragma shader_feature ___ _DETAIL_MULX2

当使用了_DetailAlbedoMap或者_DetailNormalMap的时候,这个宏会被打开,否则则关闭。

#pragma shader_feature _ _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A

这个ShaderVariant的开关与否取决于用户的输入,我们在开头就设定了使用SpecularMetallicAlpha作为SmoothnessMapChannel,也就是以Metallic贴图的alpha通道作为smoothness,所以这个宏被打开了。

#pragma shader_feature _ _SPECULARHIGHLIGHTS_OFF

这个ShaderVariant就有意思了,我无论在哪里,都没找到Unity打开了它,所以,除非用户自己去打开,否则这个宏永远处于关闭状态,也就是个废了的ShaderVariant吧。

#pragma shader_feature _ _GLOSSYREFLECTIONS_OFF

同上

#pragma shader_feature _PARALLAXMAP

当使用了_ParallaxMap的时候,这个宏会被打开,否则则关闭。

#pragma multi_compile_fwdbase

这个大家都知道,我就不多说了...

#pragma multi_compile_fog

雾相关,这个大家也都知道,我就不多说了...

#pragma multi_compile_instancing

instance相关,这个很重要,2013年我做手机GPU驱动的时候就在写这个ES3.0的功能了,2013年的driver,2014年生产,最晚2015年手机上市。现在都2019年了,全世界应该都普及了,ES2.0的手机可以淘汰了,而ES2和ES3最大的区别之一就是这个特性,ES3的神技能之一,再也不要和我提static batch、dynamic batch了......

下面开始进入shader的正文,从VS的输入开始:

struct VertexInput
{
    float4 vertex   : POSITION;
    half3 normal    : NORMAL;
    float2 uv0      : TEXCOORD0;
    float2 uv1      : TEXCOORD1;
#if defined(DYNAMICLIGHTMAP_ON) || defined(UNITY_PASS_META)
    float2 uv2      : TEXCOORD2;
#endif
#ifdef _TANGENT_TO_WORLD
    half4 tangent   : TANGENT;
#endif
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

基础的就不说了,这里说三个点:

1. DYNAMICLIGHTMAP_ON,对应realtime GI,当realtime GI打开的时候,该宏会被打开,同时会有最多两张用于表示realtime GI lightmap的贴图被赋值(此LightMap非彼Lightmap,相信很多人从来没注意过这套realtime GI lightmap,下面会详细介绍),作为环境光的diffuse部分,参与渲染。

2. _TANGENT_TO_WORLD,当_NORMALMAP、DIRLIGHTMAP_COMBINED、_PARALLAXMAP其中一个宏被打开的时候会被打开。原理也比较简单,就是normalmap那一套,这里也就不多说了。

3. UNITY_VERTEX_INPUT_INSTANCE_ID,上文说了instance很重要,那么这里简单的介绍一下吧,instance是源于ES3最新的API:glDrawArraysInstanced/glDrawElementsInstanced,这两个API是ES2中API:glDrawArrays/glDrawElements的升级版,也就是当若干个mesh、shader相同的物件进行绘制的时候,之前只能通过static batch、dynamic batch这种原始方案合并DC,而ES3后就提供硬件层支持,使得可以1个DC绘制满足这种要求(mesh、shader一致)的多个物件,而这些物件可以位置不同,颜色不同,甚至贴图不同。因为在通过这套API进行绘制的时候,每个物件还会有一个属于自己的IP,SV_InstanceID,去通过一块包含所有物件差异化信息的特殊buffer中,获取属于自己的信息,在一个DC中,通过循环的方式,完成所有物件的绘制,避免了切换DC所带来的消耗。而这里的UNITY_VERTEX_INPUT_INSTANCE_ID,一般情况下,就是用于在VS的输入中声明:uint instanceID : SV_InstanceID;下图为153个cube在打开instance的时候,只有1个dc(全场景共3个dc,一个clear,一个绘制阴影,一个绘制物件),而如果没打开instance,则共307个dc:

standard standard

其实这里还出现了:UNITY_PASS_META,这个也很重要,但是它主要用于meta pass,用于烘焙Lightmap、GI,大多数人也不关心具体逻辑,所以在这里就不展开说了,否则又是洋洋洒洒三万字了...

下面开始进入VS的正文:

UNITY_SETUP_INSTANCE_ID(v);

instance一条线继续往下进行,刚才说了通过instanceID获取差异化信息,在这里就是通过代码具体介绍,如何通过instanceID获取差异化信息,在这里,还只是获取不同物件不同的MVP信息,开发者还可以通过刚才我提到的包含所有物件差异化信息的特殊buffer(在Unity中API为:UNITY_INSTANCING_BUFFER_START和UNITY_INSTANCING_BUFFER_END),自定义一些差异化信息,这是GPU instance版GPU Skin的基础。

下面是常规的初始化输出值,获取当前像素世界坐标、屏幕空间坐标、纹理坐标、摄像机看过来的视角方向、世界空间法线、使用法线贴图(切线空间)所需要的切线转世界空间的变换矩阵,这些比较简单,不多说了。

UNITY_TRANSFER_LIGHTING(o, v.uv1);

OK,复杂的来了,阴影计算,这里主要是去计算采样阴影贴图时所需要的纹理坐标。要分如下4种情况处理:

1. SHADOWS_SHADOWMASK,也就是如果使用了Shadowmask,那么问题就简单了,阴影直接从SHADOWS_SHADOWMASK这张贴图获取即可,所以需要做的就是获取该像素点在shadowmask贴图上的纹理坐标,即代码:

a._ShadowCoord.xy = coord * unity_LightmapST.xy + unity_LightmapST.zw;

2. UNITY_NO_SCREENSPACE_SHADOWS,也就是没有打开ScreenSpaceShadowmap,那么我们就需要直接从Shadowmap中获取Shadow信息,也就是需要先将该像素点转移到光源空间,即代码:

a._ShadowCoord = mul( unity_WorldToShadow[0], mul( unity_ObjectToWorld, v.vertex ) );

3. 如果打开了ScreenSpaceShadowMap,那么就简单了,阴影直接从SSSM这张贴图获取即可,而这个贴图的纹理坐标也就是屏幕空间,通过代码获取该点的屏幕空间坐标即可,即代码:

a._ShadowCoord = ComputeScreenPos(a.pos);

4. SHADOWS_CUBE,也就是说当前光照为点光源的时候,所生成的shadowmap是一张cube,那么对应的纹理坐标为三维的,即代码:

a._ShadowCoord.xyz = mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz;
o.ambientOrLightmapUV = VertexGIForward(v, posWorld, normalWorld);

下面是更有意思的GI了,共分4种情况

1. LIGHTMAP_ON,也就是当前绘制的物件打开了LightMap static,且场景中有光源的属性为Mixed或者Baked,且已经进行了光照烘焙,生成了若干张LightMap。在这种情况下,我们认为影响该物件的间接光diffuse部分是由Lightmap提供,所以需要获取到该像素点对应的lightmap中的信息,而该点对应的lightmap中的纹理坐标也就是大家熟知的2U,加上大家熟知的纹理坐标计算公式,即代码:

ambientOrLightmapUV.xy = v.uv1.xy * unity_LightmapST.xy + unity_LightmapST.zw;

2. UNITY_SHOULD_SAMPLE_SH,当以上所描述的情况都没有发生,即:要么物件没有打开LighMap static,要么场景中没有Mixed/Baked的光源,要么还没有进行光照烘焙。我们就会认为影响该物件的间接光diffuse部分来自于球谐光照,嗯,是的,传说中的球谐光照。球谐光照的作用就是通过公式表示出影响到该点的所有光照情况,然而光照的数量可能是无限的,不过没关系,这就好比傅里叶变化中的时域变频域,可以设定N个维度的球谐基,来表示它。然后,在这里选择性的使用其中的0、1、2维信息(Unity是这样的,当然维度越高,还原的数据越好),共(1+3+5)*3=27个数值,再加以公式计算,就可以还原出影响该物件的间接光diffuse部分。代码比较多,我就不粘贴了,反正大家都打开了Unity Build-in Shader,都可以看到的。下图就是N维球谐基,以及使用i维球谐基的还原图:

standard standard

3. VERTEXLIGHT_ON,当UNITY_SHOULD_SAMPLE_SH被打开,且场景中有可以影响到该物件的顶点光源的时候。这个宏会被打开,根据最基本的NdoL公式,计算出最多四展顶点光源对该点的影响,与同上球谐得到的结果相加,作为影响该物件的间接光diffuse部分。这个代码也挺多的,同理,我也就不粘贴了,很简单,仔细一看就会明白。

4. DYNAMICLIGHTMAP_ON,正如上文中提到的,如果打开了realtime GI,该宏会被打开,同时会有最多两张用于表示realtime GI lightmap的贴图被赋值,作为环境光的diffuse部分,参与渲染。那么,在这里,就要生成对应realtime GI lightmap的纹理坐标了,即代码:

ambientOrLightmapUV.zw = v.uv2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#ifdef _PARALLAXMAP
    TANGENT_SPACE_ROTATION;
    half3 viewDirForParallax = mul (rotation, ObjSpaceViewDir(v.vertex));
    o.tangentToWorldAndPackedData[0].w = viewDirForParallax.x;
    o.tangentToWorldAndPackedData[1].w = viewDirForParallax.y;
    o.tangentToWorldAndPackedData[2].w = viewDirForParallax.z;
#endif

视差贴图,又可以称之为高度贴图,法线贴图的改进版,一个经常被忽略的高级功能。大家都知道法线,可以将一个平面做成凹凸不平的效果,但是有个缺点,就是当视角方向水平于该平面的时候,就会发现理论上凸起的部分会遮挡住后面的部分,而法线贴图却没有这个效果。但是视差贴图,就能做到。其原理,就是根据该点的高度以及该点指向摄像机的向量,计算出一个UV偏移,来影响之后的采样。而在这里,就是先取到该点到摄像机的向量。下图为有和没有视差贴图的区别,虽然效果不算好,但是还是明显能看到一个是完全的平面,一个是有高低起伏的感觉:

standard standard

下面又是常规的在计算雾,很简单,不多说了。这样VS就结束了。总的来说,VS都是在做准备工作,重头戏还在后面的PS。下面开始进入PS的正文:

UNITY_APPLY_DITHER_CROSSFADE(i.pos.xy);

LOD group相关,这个特别容易被忽略,个人觉得这个功能确实弊大于利。

用法是:当物件被添加了LOD group,且Fade Mode选择“Cross Fade”,这个宏会被打开。然后会根据物件在屏幕空间的坐标,做一定的运算后去采样_DitherMaskLOD2D贴图,根据采样的alpha通道,进行alpha test。所以,作用也就是随着越来越远,最终消失。

然而想要实现这个功能的办法很多,这里还用到了传说中的alpha test,不管硬件使用的优化方案是early Z还是隐藏面消除,都会被disable。所以如果用LOD group的话,Fade Mode尽量还是不要选择“Cross Fade”吧。

FRAGMENT_SETUP(s)

在这里,首先,先根据是否有视差贴图,计算出最终版本的UV信息(这里就不详细聊视差贴图UV偏移的原理了),然后获取alpha值,并根据是否打开了Alpha Test,进行clip,这些都比较简单。

下面就是比较复杂的物件PBS信息计算,这里就要区分standard的三大工作流了,而在文章的开始,我们就选择使用了Metallic工作流,两种SmoothnessMapChannel ,我们选择了SpecularMetallicAlpha。所以,在这里,我们先根据刚才得到的UV信息,从_MetallicGlossMap贴图的r通道和a通道中获取到金属度值和smooth值。而在这里,我们一定要区分金属度和光滑度,因为这两个真的是完全不相关的概念。比如在现实生活中,我们就见过光滑的橡皮和粗糙的金属。所以金属度只是决定了物件最终渲染的结果是以高光为主还是漫反射为主。而光滑度则是决定了物件高光部分的清晰与否。下图为金属度从0到1的图:

standard

获取了物件的金属度和粗糙度并没有实际意义,因为我们知道PBS所需要的是物件的diffuseColor和specColor,从而能够跟光照的diffuse部分和specular部分通过BRDF得到最终的PBR渲染结果的。所以,下面,我们要去获取物件的diffuseColor和specColor。

specColor很简单,就是根据金属度的高低,直接从unity_ColorSpaceDielectricSpec和albedo中进行插值。可以想象,当金属度为1的时候,物件的specColor就是物件本身的albedo,这个没问题,比如黄金的specColor是黄色,白银的specColor是白色,就是这个道理。而金属度为0的时候,specColor就是unity_ColorSpaceDielectricSpec,在linear空间就是half4(0.04, 0.04, 0.04, 1.0 - 0.04),也就是几乎没有,这个也可以理解,毕竟非金属的东西,主要都是diffuseColor。

diffuseColor也很简单,albedo * (1 - metallic) * unity_ColorSpaceDielectricSpec.a。我们知道linear空间下,unity_ColorSpaceDielectricSpec.a为0.96,那么纯金属的diffuseColor为0,非金属的diffuseColor约等于albedo,这个也就很容易理解了。

UNITY_SETUP_INSTANCE_ID(i);

这里,我们又看到了熟悉的instance,其实在standard shader中,这里并没有任何意义,因为那个具有差异化信息的buffer中只包含了变化矩阵,而在PS中并没有差异化信息。但是用户可以根据自己的需要定义差异化信息,那样就很有意思了。

UnityLight mainLight = MainLight ();
UNITY_LIGHT_ATTENUATION(atten, i, s.posWorld);

这里将根据VS的输入,进行阴影计算。首先先获取当前光照的方向、颜色信息。然后,根据ShadowMask获取到的baked的阴影信息,以及根据是否有SSSM,采样_ShadowMapTexture获取实时阴影,然后将两种阴影进行混合。

得到阴影信息后,再根据光源种类为点光源、聚光灯还是方向光,计算衰减,乘以刚才得到的阴影,得到最终的atten。

half occlusion = Occlusion(i.tex.xy);

这里则根据AO贴图计算出该点的AO信息。

UnityGI gi = FragmentGI (s, occlusion, i.ambientOrLightmapUV, atten, mainLight);

这里就是计算传说中的GI了,因为前面准备好了物件的PBS信息、直接光照信息,那么在这里,就是在准备间接光照信息了。而间接光照信息部分,则分为两个部分,diffuse部分和sepcuar部分。其中diffuse部分,则是来自于lightmap/SH,specular部分,则是来自于reflectionProbe/skybox。

准备了一些变量后,直接进入核心代码

UnityGlobalIllumination (d, occlusion, s.normalWorld, g);

首先,先计算间接光照的diffuse部分,分为3种情况

1. UNITY_SHOULD_SAMPLE_SH,也就是物件没有使用Lightmap,则间接光的diffuse部分直接从球谐光照获取。

2. LIGHTMAP_ON,物件使用了lightmap,则间接光的diffuse部分从lightmap中获取。其中如果开启了DIRLIGHTMAP_COMBINED,也就是说LightMap的DirectionMode为Directional,则就会多一张包含了光照方向的光照贴图,然后在计算间接光的diffuse的时候会进行一次NdoL操作,这样的结果也就更加准确。其实,用户还可以根据自己的需要充分利用这张光照方向贴图,比如进行高光的计算。

3. DYNAMICLIGHTMAP_ON,也就是打开了realtime GI,这样的话,又会多一套realtime GI lightmap,计算方式同上,计算的结果也将加入间接光照的diffuse部分

之后,会再乘以得到的AO,也就是可以看到standard shader的AO并不影响直接光照,而只影响间接光照。

下面是间接光照的specular部分:

specular部分就相对简单了,来源就是reflectionprobe或者skybox,需要注意的只有三点:

1. 前面我们提到了光滑度,但是从来没有用上,但是原理我们已经说清楚了,光滑度高的物件,反射的物件会比较清晰。所以光滑度其实是在这里使用的。我们会该点的光滑度计算出一个mipmap等级,可以想象一下,一个粗糙的金属和一个光滑的金属,因为金属度的原因,specular占主要,diffuse占次要,但是由于光滑度的原因,光滑的金属可以清晰的反射周围环境,而粗糙的金属则不可以。那么清晰是什么概念,就是说mipmap趋于0,而在算法上,粗糙的金属反射的时候取得就是周围环境mipmap较低等级的cubemap值参与运算的。下图为光滑度从1到0的图片:

standard

2. 不管是reflection probe还是skybox,其本质上它使用的是cubemap,保存它周围的环境信息。然而如果把这个环境信息当做球来判断,在数学上,其实是把它当做一个无穷远来工作的。这个时候如果用反射探针来抓取一个室内场景,也就是一个空间有限的场景,则会得到一个错误的矫正结果,因为你本质上抓的这个cubemap是一个空间有限的范围,但是计算的时候是按照空间无限远的范围来计算的,这个时候就会带来精度误差。所以说如果想做到精确反射,则可以使用box projection,也就是打开了这里的宏UNITY_SPECCUBE_BOX_PROJECTION,通过数学上的小技巧进行校正。其中具体的数学技巧有兴趣的可以去看下GPU Gems中介绍IBL的一篇文章。

3. 如果打开了blend,则会打开宏UNITY_SPECCUBE_BLENDING,也就会采样2个cubemap的信息,进行blend。

之后,还是会再乘以得到的AO,所以间接光照完全受到了AO的影响。到这里,间接光部分也就全部结束了。

half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.smoothness, s.normalWorld, -s.eyeVec, gi.light, gi.indirect);

OK,我们获得了物件的PBS信息,直接光信息,间接光信息,下面,就是将所有信息放入这个传说中的BRDF算法中,即可得到最终的结果了。这个BRDF的算法经过这两年PBR的兴起,基本成了面试必面题目,现在到处都有其原理了,我们ArkClub的龙哥在三年前就写了一篇《猴子都能看懂的BRDF》文章,有兴趣的可以搜索一下,后面我也联系一下龙哥再这里在发一遍~我这里就不再重复了。

下面,再加上自发光、雾,这些就很简单了,整个Pass也就结束了。

ForwardAdd pass就是ForwardBase pass的子集,随便看一下就明白了。

Deferred pass也很简单,区别就是在Deferred中直接光的信息认为是0,PBS和间接光计算出的结果被保存在GBuffer3,也就是保存自发光的buffer中。其它的Gbuffer0保存的是diffuseColor和AO,GBuffer1保存的是specularColor和smoothness,GBuffer2保存的是Normal信息。最终的直接光运算,依然是使用了BRDF算法,所以懂了ForwardBase pass,一切就仅此而已了。

原创技术文章,撰写不易,转载请注明出处:电子设备中的画家|王烁 于 2019 年 1 月 27 日发表,原文链接(http://geekfaner.com/unity/blog16_UnityStandardShader.html)