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(着色器变体采集)

Unity创建Shader

固定渲染管线

固定渲染管线是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", 2D) = "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", 2D) = "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", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100

Pass
{
//标记CG程序的起始位置
CGPROGRAM
//执行顶点着色的方法
#pragma vertex vert
//执行片元着色的方法
#pragma fragment frag
// make fog work
#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)
{
//输出几何图形顶点以及UV信息
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
{
//输出自定义着色信息
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
//标记CG程序块的结束
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", 2D) = "white" {}
}
SubShader
{
//设置光照类型为前向渲染
Tags { "LightMode"="ForwardBase" }
LOD 100

Pass
{
//标记CG程序的起始位置
CGPROGRAM
//执行顶点着色的方法
#pragma vertex vert
//执行片元着色的方法
#pragma fragment frag
// make fog work
#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)
{
//输出几何图形顶点以及UV信息
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
{
//输出自定义着色信息
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
//添加漫反射以及环境光
fixed3 lighting = i.diff + i.ambient;
col.rgb *= lighting;
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
//标记CG程序块的结束
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)", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200

CGPROGRAM
// Physically based Standard lighting model, and enable shadows on all light types
#pragma surface surf Standard fullforwardshadows

// Use shader model 3.0 target, to get nicer looking lighting
#pragma target 3.0

sampler2D _MainTex;

struct Input
{
float2 uv_MainTex;
};

half _Glossiness;
half _Metallic;
fixed4 _Color;

// Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
// See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
// #pragma instancing_options assumeuniformscaling
UNITY_INSTANCING_BUFFER_START(Props)
// put more per-instance properties here
UNITY_INSTANCING_BUFFER_END(Props)

void surf (Input IN, inout SurfaceOutputStandard o)
{
// Albedo comes from a texture tinted by color
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
// Metallic and smoothness come from slider variables
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进行二次编辑,也可以自定义一个新的数值

Render Queue

可以在Frame Debug窗口中依次查看当前的渲染顺序。

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)", 2D) = "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", 2D) = "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", 2D) = "white" {}
}
SubShader
{

Pass
{
Blend SrcAlpha OneMinusSrcAlpha
SetTexture[_MainTex]
{
Combine texture * primary //正面赋予贴图
}
}
}
FallBack "Diffuse"
}

左侧为AB,右侧为AT

上图中发黑的部分与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", 2D) = "white" {}
//添加ID*********************
_ID("Mask ID",int) = 1
//***************************
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
//添加模板********************
Stencil
{
Ref [_ID]
Comp equal
}
//****************************
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#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
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
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,后续也可以灵活地进行修改

创建 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中。

变体