Shader 着色器 - Unity 手册 (unity3d.com)
Shader(着色器)是一段渲染管线运行的小程序,它负责告诉GPU如何渲染图形。在游戏开发中,CPU一直是瓶颈,所以有些计算完全可以通过Shader放在GPU中来做。例如,UV动画,如果在CPU上做,需要每一帧向GPU传递移动的范围;如果直接在GPU中处理,就不需要传递数据这部分开销了,直接在Shader中通过时间函数来移动即可。类似的需求其实还有很多,比如水流、岩浆、天气、雾和溶解等效果都可以在Shader中完成。
在Unity中可以创建5种不同的Shader(基于Unity2021),除了Compute Shader以外,其余的Shader都属于可编程渲染管线以及Shader Variant Collection(着色器变体采集)
固定渲染管线 固定渲染管线是OpenGL ES 1.0所使用的渲染管线,属于最古老的一种渲染管线,无法自由灵活地控制渲染的每个片段,它只是提供了一些渲染功能的开关项,这就好比工厂生成零件的部分,相同模具生产的零件部分是完全一样的,只能选择是否生产这部分零件。例如,控制渲染管线打开漫反射功能,或者关闭环境光功能等。从OpenGL ES 2.0开始,Unity全面支持可编程渲染管线(Vertex and Fragment Shader),已经不建议使用固定渲染管线了,但是我们需要对它有一个简单的了解。
我们来写一个简单的固定渲染管线的Shader
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Shader "Unlit/TestFixedFunctionShader" { Properties { _MainTex ("Texture" , 2 D) = "white" {} } SubShader { Pass { SetTexture [_MainTex]{ Combine texture } } } }
简要介绍一下上述代码
Shader “Unlit/FixedFunctionShader” : 表示Shader的显示目录,以及Shader代码语块
Properties: 表示Shader的属性部分,这里配置渲染管线需要用到的参数,比如图片类型或者值类型等
SubShader: 表示子着色器。Shader可以包含多个SubShader,Unity会自动找到当前设备硬件支持的SubShader执行
Pass: 表示渲染通道。在SubShader中,可以添加多个Pass,但是每个Pass就是一个DrawCall,所以尽量使用一个Pass
SetTexture [_MainTex]{}: 设置纹理。其中Combine texture表示显示合并纹理。
将这个Shader绑定在待显示的Mesh Renderer组件的材质上,即可立即看到效果,
我们来给它加上光照信息。在Pass代码块内需要开启光照信息,在Material代码块内开启需要启动的光照类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 Shader "Unlit/TestFixedFunctionShader_Plus" { Properties { _MainTex ("Texture" , 2 D) = "white" {} _Color("Main Color" , Color) = (1 ,1 ,1 ,0 ) } SubShader { Pass { Lighting On Material{ Diffuse [_Color] Ambient [_Color] } SetTexture [_MainTex]{ Combine previous * texture } } } }
固定渲染管线还可以设置镜面高光反射(Specular)、镜面高光反射强度(Shininess)、自发光(Emission)等。在Material代码块内,也就是开启某项光照功能的开关,打开或者关闭它们,会应用到模型的全部。
假如只是希望模型身体的一部分接收漫反射而另一部分不接收,这种情况下固定渲染管线是无法做到的,所以真正灵活的方式应该是可编程渲染管线。
可编程渲染管线 在可编程渲染管线中,程序代码可以对每一个片元进行着色。如果需要对每一个片元像素点做特殊着色,那么在Shader中首先需要获取几何图形对应的顶点以及UV信息,然后通过UV以及贴图拿到当前片段的像素信息,最终就可以自定义着色了。
我们先来学习一个简单的可编程渲染管线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 Shader "Unlit/TestVertexandFragmentShader" { Properties { _MainTex ("Texture" , 2 D) = "white" {} } SubShader { Tags { "RenderType" ="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS (1 ) float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos (v.vertex); o.uv = TRANSFORM_TEX (v.uv, _MainTex); UNITY_TRANSFER_FOG (o,o.vertex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D (_MainTex, i.uv); UNITY_APPLY_FOG (i.fogCoord, col); return col; } ENDCG } } }
使用CGPROGRAM和ENDCG来标记CG程序块,这是标准的C++语法。
#pragma vertex vert
表示执行顶点着色方法,调用下方的vert()
函数输出几何图形顶点以及UV信息。接着执行#pragma fragment frag
,它表示片元着色方法,将调用下方的frag()
函数。v2f
就是刚刚顶点着色返回的数据。最终,将每个点的颜色返回去交给GPU渲染。
接下来我们在可编程渲染管线中实现漫反射环境光的光照
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 Shader "Unlit/TestVertexandFragmentShader_Plus" { Properties { _MainTex ("Texture" , 2 D) = "white" {} } SubShader { Tags { "LightMode" ="ForwardBase" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include "UnityCG.cginc" #include "UnityLightingCommon.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS (1 ) float4 vertex : SV_POSITION; float4 diff : COLOR0; float3 ambient : COLOR1; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos (v.vertex); o.uv = TRANSFORM_TEX (v.uv, _MainTex); half3 worldNormal = UnityObjectToWorldNormal (v.normal); half nl = max (0 ,dot (worldNormal,_WorldSpaceLightPos0.xyz)); o.diff = nl * _LightColor0; o.ambient = ShadeSH9 (half4 (worldNormal,1 )); UNITY_TRANSFER_FOG (o,o.vertex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D (_MainTex, i.uv); fixed3 lighting = i.diff + i.ambient; col.rgb *= lighting; UNITY_APPLY_FOG (i.fogCoord, col); return col; } ENDCG } } }
除了漫反射和环境光以外,光照模型还有很多。
光照模型的算法是一致的,然而都需要在vertex方法中实现。为了方便,我们还可以使用可编程渲染管线的表面着色器。
表面着色器 表面着色器(Surface Shader)可以省略编写#pragma vertex vert
方法,并且Shader中不需要写Pass
代码块。
#pragma surface surf Standard
表示执行光照模型
SurfaceOutputStandard
表示vertex输出的结构对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 Shader "Custom/TestSurfaceShader" { Properties { _Color ("Color" , Color) = (1 ,1 ,1 ,1 ) _MainTex ("Albedo (RGB)" , 2 D) = "white" {} _Glossiness ("Smoothness" , Range (0 ,1 )) = 0.5 _Metallic ("Metallic" , Range (0 ,1 )) = 0.0 } SubShader { Tags { "RenderType" ="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; UNITY_INSTANCING_BUFFER_START (Props) UNITY_INSTANCING_BUFFER_END (Props) void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" }
在Unity安装目录下,我们可以找到内置的CG代码,它位于CGIncludes文件夹下。打开Linghting.cginc文件后,就可以看到SurfaceOutput
的struct定义
1 2 3 4 5 6 7 8 struct SurfaceOutput { fixed3 Albedo; fixed3 Normal; fixed3 Emission; half Specular; fixed Gloss; fixed Alpha; };
这些数据不需要我们主动赋值,Surface Shader会返回给我们,最后只需处理片元着色即可。
深度排序 模型之间是有遮挡关系的,所以需要设置模型之间的渲染顺序,这可以在Shader中标明,例如Tags { "RenderType"="Opaque" }
。一共分为5个类型,代表的数值也不一样。
BackGround:代表1000,天空盒或者背景,其他元素都要盖在它前面。
Geometry:代表2000,几何体,地形、地上的房子、树木等并不需要带透明通道的模型。
AlphaTest:代表2450,透明测试。
Transparent:代表3000,透明或半透明的模型。
Overlay:代表4000,渲染在最前面,比如UI一类的。
数值越小,越先渲染,所以数值大的会挡住数值小的。此外,也可以直接加减对应的数值,例如Tags{"RenderType"="Geometry + 1"}
。如图所示,选择一个材质后,既可以对Render Queue进行二次编辑,也可以自定义一个新的数值
可以在Frame Debug窗口中依次查看当前的渲染顺序。
这里提供一个小技巧,在游戏中地形之上,还会绘制很多建筑一类的元素,如果先绘制地形再绘制建筑的话,那么很多建筑块重合的像素点就需要画多遍,所以可以将地形的RenderType
值设置得比建筑大,这样就会先绘制建筑,然后再绘制地形。
在Shader中,通常可以看到ZTest LEqual、ZWrite On的字样,其中ZTest表示开启深度测试。比如,前面的建筑挡住了地形,通过深度测试,CPU就能判断摄像机与物体的距离,从而判断先后。此时如果需要覆盖填充像素?,就需要开启ZWrite了。这两个参数在Shader中是默认开启的。
在游戏中,角色在场景中移动可能会被建筑挡住。为了凸显被挡住的部分,将其标记成另外一种颜色,实现这样的效果就需要用到ZTest
在Shader中,需要两个Pass来分别绘制被挡住与没被挡住的部分。被挡住的部分需要开启Ztest Greater和Zwrite Off,表示突出显示被挡住的地方;没被挡住的部分则设置ZTest Lequal,表示正常遮挡
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 Shader "Unlit/TestZTestShader" { Properties { _MainTex ("Base(RGB)" , 2 D) = "white" {} _NotVisibleColor("NotVisibleColor(RGB)" ,Color) = (0.3 ,0.3 ,0.3 ,1 ) } SubShader { Tags { "RenderType" ="Opaque" "Queue" ="Geometry+500" } LOD 200 Pass { ZTest Greater ZWrite Off Blend SrcAlpha OneMinusSrcAlpha SetTexture[_MainTex]{ ConstantColor[_NotVisibleColor] Combine constant * texture } } Pass { ZTest LEqual SetTexture[_MainTex]{Combine texture} } } FallBack "Diffuse" }
透明 在Shader中要实现透明效果,可以使用Alpha Test和Alpha Blend这两种方式。
Alpha Blend透明部分会和背景混合,而Alpha Test则不会,只会出现透明和不透明两种结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Shader "Unlit/TestAlphaTestShader" { Properties { _MainTex ("Texture" , 2 D) = "white" {} _CutOff("Alpha cutoff" ,Range (0 ,1 )) = 0.5 } SubShader { Pass { AlphaTest greater[_CutOff] SetTexture[_MainTex] { Combine texture * primary } } } FallBack "Diffuse" }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Shader "Unlit/TestAlphaBlendShader" { Properties { _MainTex ("Texture" , 2 D) = "white" {} } SubShader { Pass { Blend SrcAlpha OneMinusSrcAlpha SetTexture[_MainTex] { Combine texture * primary } } } FallBack "Diffuse" }
上图中发黑的部分与Shader 的Render Queue有关
Alpha Test无法做混合。而且由于移动平台下不支持Early-Z,它的效率会比Alpha Blend慢。游戏中有时候还需要用到它,比如类似自身溶解的效果。Alpha Blend的使用场景就非常多了,比如粒子特效、角色身体、翅膀等发光效果。
另外,还需要注意游戏中应当尽量减少使用透明通道。由于透明会出现混合现象,这样渲染队列必须是从后向前渲染,此时就会出现大量的过度绘制(overdraw)的现象。
如果是不透明的话,可以将它设置到Geometry上,这样渲染顺序就会从前向后渲染。由于Render Queue高的像素挡住了Render Queue低的像素,所以将大量降低过度绘制。
或者也可以像前面介绍的方法主动修改渲染顺序,从而降低过度绘制的现象。
所以在设计阶段产品经理就需要多和美术人员沟通,能不透明的地方就不要使用透明。
编写Shader时,需要注意复杂的数学函数。在片元着色器中,每个点都需要做运算,复杂的数学更会导致效率低下。
在移动平台中,Unity提供了Mobile的Shader,以获取更高的运行效率,但是减小了可选的参数。大部分情况下,Mobile下的Shader可以满足需求。如果有更复杂的需求,就再拓展一个特殊的Shader在这种情况下用,总之,要保证Shader的代码简单。
裁切 Shader中还提供了Stencil(模板),它的深度测试比较像,测试是否能写入像素,测试成功后即可写入像素。它还有个重要的功能,那就是可以做裁切,即设置裁切区域显示其中一部分,UGUI中的裁切其实也是这个原理。
我们先新建一个Unlit Shader,往其中添加一个Property来作为唯一标识ID,然后写入Stencil,其中Ref[_ID]
表示唯一标识符,Comp equal
用来和裁切区域比较是否显示这个像素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 Shader "Unlit/TestStencilShader" { Properties { _MainTex ("Texture" , 2 D) = "white" {} _ID("Mask ID" ,int ) = 1 } SubShader { Tags { "RenderType" ="Opaque" } LOD 100 Stencil { Ref [_ID] Comp equal } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS (1 ) float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos (v.vertex); o.uv = TRANSFORM_TEX (v.uv, _MainTex); UNITY_TRANSFER_FOG (o,o.vertex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D (_MainTex, i.uv); UNITY_APPLY_FOG (i.fogCoord, col); return col; } ENDCG } } }
我们再建一个Mask Shader,其中Ref[_ID]
和上面的Shader相匹配,Comp always 和 Pass replace 表示在这个区域内的像素永远显示,否则将被裁切掉
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 Shader "Unlit/TestMaskShader" { Properties { _ID ("Mask ID" , int ) = 1 } SubShader { Tags { "RenderType" ="Opaque" } ColorMask 0 ZWrite Off Stencil { Ref[_ID] Comp Always Pass replace } Pass { CGINCLUDE struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos (v.vertex); return o; } half4 frag (v2f i) : SV_Target { return half4 (1 ,1 ,1 ,1 ); } ENDCG } } }
着色器变体采集 着色器变体集合 - Unity 手册 (unity3d.com)
Unity内置了很多Shader,但是比较通用,效率上可能就不是最优的了。通常,我们会在它的基础上修改,去掉一些没用的东西。这样,本地工程里就会有一些自定义的Shader。通常Shader可以直接在Resources目录下,运行时可以这样读取
1 2 Shader a = Resources.Load<Shader>("shader name" ); Shader b = Shader.Find("shader name" );
这样就不会影响打包或者Assetbundle包了,这样加载出来的Shader第一次赋值给材质时,会进行解析,但会带来卡顿。
为了避免卡顿,可以将Shader放在Shader Variant Collection中提前进行预热。
打开Edit——Project Settings——Graphic选项卡,在最后,可以将自定义的一些Shader包含进来,并且点击Save to asset按钮,即可创建Shader Varient Collection,后续也可以灵活地进行修改
然后再初始化的地方预热就可以了
1 Resources.Load<ShaderVariantCollection>("NewShaderVarients" ).WarmUp();
这个API完全支持 DX11 和 OpenGL。部分支持 DX12、Vulkan 和 Metal;如果顶点布局和/或渲染目标设置与用于预热的数据不同,图形驱动程序可能仍需要执行工作。
此外,Unity还提供了一种将Shader预制再包体中的功能。具体操作方法是在Graphic选项卡中的Always Included Shaders处把需要的Shader放进来即可。这样做确实非常省心,但是会有一个巨大的隐患,那就是变体(Variant)
Unity - Manual: Shader variants (unity3d.com)
如果Shader预制在Always Included Shaders中,那么所有的变体组合都会打包,这会大幅度增加包体,并且在加载时会带来额外的内存开销。如果Shader不放在Always Included Shaders中,当需要使用它时,就会进行条件判断,找到符合的Shader,没有的话就不应用自定义Shader。
因此,要先确定Shader的变体到底有多少,再决定将它放在哪里。在Asset窗口中选择一个Shader后,点击Compile and Show code,即可看到它的变体数量,如果数量比较多的话,千万不要放进Always Included Shaders中。