作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记21:表面着色器
原文发布时间:2020年9月7日
文章类型: 授权转载
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新发行版:“2019.3.7”
《Shaders must die》是Unity的渲染工程师Aras所发表的博客,他认为顶点着色器和片元着色器是一种非常反人类的设计方式,虽然对硬件来说非常友好,但是不便于学习和理解。所以他设计了一种新的着色器结构,将原本的顶点着色器和片元着色器合并为同一个着色器,称之为表面着色器。
相比于顶点和片元着色器,表面着色器为着色器增加了一层新的抽象。相比于之前的顶点和片元着色器,表面着色器更为高级(这里的高级不是好与不好,类似于Python是一门高级语言而汇编是一门低级语言)
我们将使用表面着色器来实现一个使用了法线纹理的漫反射效果。
Shader "UShaderMagicBook/SurfaceShaderSample"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_BumpMap("Normal Map",2D) = "bump"{}
}
SubShader{
Tags{"RenderType"="Opaque"}
LOD 200
CGPROGRAM
#include "UnityCG.cginc"
#include "Lighting.cginc"
#pragma surface Surface Lambert
#pragma target 3.0
sampler2D _MainTex;
sampler2D _BumpMap;
fixed4 _BaseColor;
struct Input{
float2 uv_MainTex;
float2 uv_BumpMap;
};
void Surface(Input IN,inout SurfaceOutput o){
/* 表面着色器的主要结构 */
fixed4 tex = tex2D(_MainTex,IN.uv_MainTex);
o.Albedo = tex.rgb * _BaseColor;
o.Alpha = tex.a * _BaseColor.a;
o.Normal = UnpackNormal(tex2D(_BumpMap,IN.uv_BumpMap));
}
ENDCG
}
Fallback "Diffuse"
}
下面是这个着色器最终的效果。
可以看见,在表面着色器,代码量是非常少的。这是可以研究的第一个线索,即表面着色器省略了什么计算内容?
首先,这里面最复杂的法线纹理计算似乎被移除了,我们只是把法线纹理赋值给了o.Normal,其次,我们似乎并没有计算光照。这是一个非常大的问题,因为计算光照的方式众多,并不是只有我们所使用的BlinnPhong或者Lambert,还有庞大的光追算法族。
这是简化后的着色器模型,也就是我们需要关注的内容。其中,表面函数也就是我们所编写的这个Surface函数,在表面函数中,它规定了非常多的输入,比如纹理输入,法线输入等。但是没有计算光照的部分,因为由于我们最常使用的光照模型就是Lambert(漫反射)和Specular(高光反射),所以直接把这两个模型预先定义好,然后直接考虑把参数传递给它就完事了。于是就有了光照函数,我们可以通过以下编译指令来指定我们所使用的表面函数和光照函数。
#pragma surface Surface Lambert
//指定表面函数为Surface,光照函数为Lambert
Surface是需要自己定义的,而光照函数则由Unity内部定义好了,不过如果觉得Unity所使用的光照函数不好用,也可以自己定义光照函数。
我们所使用的用于指定表面和光照函数的命令就是UnityShader所定义的一条编译指令。它就像是各种各样的开关一样,用于控制各类函数和代码。它的完整的格式如下
#pragma surface surfaceFunction lightModel [optionalparams]
其中surfaceFunction就是表面函数,lightModel就是光照函数,optionalparams用于提供一些额外的参数用于控制表面着色器的一些行为。
表面着色器的优点在于抽象出了“表面”的概念,与之前遇到的顶点/片元着色器抽象层不同,一个对象的表面属性定义了它的反射率,光滑度,透明度等值。而编译指令中的surfaceFunction就用于定义这些表面属性。它有以下固定的签名
void surf(Input IN,inout SurfaceOutput o)
void surf(Input IN,inout SurfaceOutputStandard o)
void surf(Input IN,inout SurfaceOutputStandardSpecular o)
除了表面函数,我们还需要指定另一个非常重要的函数,光照函数。光照函数会使用表面函数中设置的各种属性,来计算光照。Unity提供了很多不同的光照函数,包括基于物理的光照模型函数Standard和StandardSpecular(在UnityPBSLighting.cginc文件中被定义),以及更加简单的基于非物理光照模型函数Lambert和BlinnPhong(在Lighting.cginc文件中被定义)。
在上面的案例中我们就使用了Lambert来作为光照函数。当然啦,我们也可以自己定义光照函数,例如,可以使用下面的函数来定义用于前向渲染中的光照函数
half4 Lighting(SurfaceOutput s,half3 lightDir,half atten);
half4 Lighting(SurfaceOutput s,half3 lightDir,half3 viewDir,half atten);
在编译指令的最后,我们还可以自己指定一些新的参数,比如,开启/设置透明度混合,透明度测试,指明自定义的顶点和颜色修改函数,控制生成的代码等。以下是一些比较重要的参数
表面着色器最多定义4个函数,包括表面函数(设置输入),光照函数(计算光照部分),顶点修改函数(对顶点进行变动),最后颜色修改函数(对最后的颜色进行修改)。而这些函数的联系就通过两个结构体,即
Input结构体包含了许多表面属性的数据来源,另外,如果我们定义了顶点修改函数,那么它还会是顶点修改函数的输出结构体。在我们上面的案例中,Input结构体包含了两个采样坐标,uv_MainTex和uv_BumpMap。
很明显,可以通过"uv"+纹理变量名来获得纹理的uv坐标。(你也可以通过uv2来获得次级纹理坐标)
需要注意的是,我们并不需要自己计算这些变量,只需要在Input结构体中声明即可,Unity会填充这些变量。一个例外的情况是,我们定义了顶点修改函数,并需要向表面函数中传递一些自定义的数据,例如,为了自定义雾效,我们可能需要在顶点修改函数根据顶点在视角空间下的位置信息计算雾效混合系数,这样我们就可以在Input结构体中定义一个名为half fog的变量。
有了Input结构体来提供所需要的数据后,我们就可以据此来计算各种表面属性。因此,另外一个结构体就是用于存储这些表面属性的结构体,即SurfaceOutput,SurfaceOutputStandard和SurfaceOutputStandardSpecular。它会作为表面函数的输出,随后会作为光照函数的输入来进行各种光照计算。相比于Input结构体的自由性,这个结构体的变量是提前定义好的。不可以增加也不会减少。它的定义大概是这样的(你可以在Lighting.cginc中找到)
struct SurfaceOutput{
fixed3 Albedo;
fixed3 Normal;
fixed3 Emission;
half Specular;
fixed Gloss;
fixed Alpha;
};
而SurfaceOutputStandard和SurfaceOutputStandardSpecular的声明可以在UnityPBSLighting.cginc中找到。
struct SurfaceOutputStandard{
fixed3 Albedo;
fixed3 Normal;
half3 Emission;
half Smoothness;
half Occlusion;
fixed Alpha;
}
struct SurfaceOutputStandardSpecular{
fixed3 Albedo;
fixed3 Specular;
fixed3 Normal;
half3 Emission;
half Smoothness;
half Occlusion;
fixed Alpha;
}
在一个表面着色器当中,我们只要选择其中之一即可,另外注意,SurfaceOutput主要是针对非物理光照,即Lambert和BlinnPhong光照,而SurfaceOutputStandard和SurfaceOutputStandardSpecular主要对应了光照函数Standard和StandardSpecular。一个是金属工作流程(Metallic Workflow)一个是高光工作流(Specular Workflow)
下面着重介绍一下SurfaceOutput结构体中的变量的含义。
o.rgb += o.Emission;
float spec = pow(nh,s.Specular * 128) * s.Gloss;
如果你和我一样不太喜欢极高度的封装(希望了解底层)并且你刻苦又努力(愿意去研究),那么你肯定会好奇为什么表面着色器可以这样,比如场景中明明没有灯光,但是表面着色器却并不是全黑的。
前面说过,表面着色器是顶点和片元着色器的一个更加高级的抽象。本质上,它还是会被编译成顶点/片元着色器(以下用VF代替,Vertex&Fragment Shader),这里会提一下表面着色器中的参数和变量与VF着色器的关系。
表面着色器会生成一个包含多个Pass的VF着色器,为什么会有多个pass呢?因为表面着色器并不知道我们要使用什么类型的渲染路径,所以它会为前向渲染路径生成两个pass,即ForwardBase和ForwardAdd。为Unity5之前的延迟渲染路径生成PrePassBase和PrePassFinal,为Unity5之后的延迟渲染路径生成LightMode为Deferred的Pass,还有一些pass用于产生额外的信息。
比如,为了给光照映射和动态全局光照提取表面信息,Unity会生成一个LightMode为Meta的Pass。有些表面着色器由于修改了顶点位置,因此,我们可以利用addshadow编译指令为它生成相应的LightMode为ShadowCaster的阴影投射Pass。
我们可以通过Unity所提供的“Show Generated Code”来生成VF着色器的具体代码。虽然实际代码内容比较多,但是大多数代码都是差不多的,只不过很多代码是在不同的情境下,不同的渲染路径下运行的,因而为此定义了非常多的宏编译处理。
从表面着色器生成VF着色器的的流程如下图所示
第一步,Unity会直接将表面着色器中CGPROGRAM和ENDCG之间的代码赋值过来,这些代码包括了我们对Input结构体,表面函数,光照函数等变量和函数的定义。这些函数和变量会在之后的处理过程中被当成正常的结构体和函数进行调用。
第二步,Unity会分析上述代码,并生成顶点着色器的输出,即v2f_surf结构体,Unity会分析我们在自定义函数中所使用的变量。而且,即便有时我们在Input中定义了某些变量(如某些纹理坐标),但是并没用到,Unity会舍弃这些变量,这相当于一种自动的优化。
第三步,生成顶点着色器,生成顶点着色器又可以分为以下几步。
第四步,生成片元着色器,生成片元着色器又可以分为以下几步。
在这部分我们来看一个沿法线对模型进行膨胀的Shader
Shader "UShaderMagicBook/NormalExtrusion"{
Properties{
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_MainTex("Main Texture",2D) = "white"{}
_BumpMap("Normalmap",2D) = "bump"{}
_Amount("Extrusion Amount",Range(-0.5,0.5)) = 0.1
}
SubShader{
Tags{"RenderType"="Opaque"}
LOD 300
CGPROGRAM
#pragma surface Surface MyLambert vertex:Vertex finalColor:FinalColor addshadow exclude_path:deferred exclude_path:prepass nometa
/*内容比较多,解释起来就是,定义名为Surface的表面着色器,
使用自己定义的函数MyLambert来计算光照,定义顶点修改函数 Vertex,
定义最终颜色函数FinalColor,添加阴影,不要生成延迟渲染路径和被舍
弃的延迟渲染路径,没有meta Pass*/
#pragma target 3.0
fixed4 _BaseColor;
sampler2D _MainTex;
sampler2D _BumpMap;
half _Amount;
struct Input{
float2 uv_MainTex;
float2 uv_BumpMap;
};
void Vertex(inout appdata_full v){
/* 顶点修改函数 */
v.vertex.xyz += v.normal * _Amount;
//把顶点沿法线方向向外拓展,由_Amount参数来控制
}
void Surface(Input IN,inout SurfaceOutput o){
/* 表面着色器 */
fixed4 tex = tex2D(_MainTex,IN.uv_MainTex);
o.Albedo = tex.xyz;
o.Alpha = tex.a;
o.Normal = UnpackNormal(tex2D(_BumpMap,IN.uv_BumpMap));
}
half4 LightingMyLambert(SurfaceOutput s,half3 lightDir,half atten){
/*自定义的光照函数*/
half NdotL = dot(s.Normal,lightDir);
half4 c;
c.xyz = s.Albedo * _LightColor0.xyz * NdotL * atten;
c.a = s.Alpha;
return c;
}
void FinalColor(Input IN,SurfaceOutput o,inout fixed4 color){
color *= _BaseColor;
}
ENDCG
}
}
非常复杂,但是代码量很少,因而Unity实在是做了太多工作了。它的最终效果如下
终于到了我最喜欢的部分了,我们花了非常长的时间来学习VF结构的着色器,但是现在却有了如此方便的工具,于是就产生了一个选择。为了在表面着色器和VF着色器之间权衡,我们需要了解表面着色器的缺点。
我们首先要确定一个基调,表面着色器是对VF着色器的一个高级封装,这说明,任何在表面着色器中完成的事情,我们都可以用VF着色器重现,但是很不幸的是,反过来就不一定了。
另外,高度封装代表着丧失了自由,我们失去了对各种优化和各种特效实现的更加细微的控制,因而使用表面着色器所产生的代码效率要比VF结构更低一点,这不仅是着色器的特点,更是几乎所有编程语言都有的毛病。(高级语言的运行速度要低于低级语言,只不过并不是绝对的)
同样,表面着色器在一些特定的效果是无法实现的,比如我们之前所做的透明玻璃的效果。
所以有以下三条建议:
本章就到此结束了,另外其实关于表面着色器的学习是不能如此肤浅的,因而后面的章节中会给出更加完整和详细的学习笔记,另外,后面所涉及到的教程会慢慢的从built-in管线转向URP管线,如果不清楚也没有关系,在遇到的时候会给出详细的介绍的,因为URP我也会非常认真的对待,URP系列的笔记会比UnityShader入门精要系列的笔记更为详细。
粗糙的学习主要也是因为本书所涉及的内容已经有点过时了,另外就是,不得不说,Unity所做的操作,目前来说是让我不太理解的。比如表面着色器与VF着色器的差异,我并没有感受到非常夸张的编写上的优化,因为大部分的操作我们可以通过自己编写很多固有函数来简化操作。
当然,这并不是核心,毕竟我是程序出身,要考虑转到TA或者图程一行的人还有美术的存在。所以表面着色器是有存在的必要的。但是Built-In管线项目无法平滑的过渡到URP和HDRP也是一个大问题。着色器无法共用,让我一瞬间想离开Unity3D,转向UE4了。但是一想到UE4主要是做PC端的,我就只能暂时忍一忍了,不过同时也期待Unity3D官方可以把SRP做的更加完美一点。
另外,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎关注给我的github贡献一颗⭐,感谢阅读。