在本节中,我们将会学习如何向 Unity Shader 中引入时间变量,以实现各种动画效果。并分别学习纹理动画和顶点动画,并动手实现一些简单的动画效果。
1 Unity Shder 中的内置时间变量 动画效果往往都是把时间添加到一些变量的计算中,以便在时间变化时画面也可以随之变化。Unity Shader 提供了一系列关于时间的内置变量来允许我们方便地在 Shader 中访问运行时间,实现各种动画效果。下表给出了这些内置的时间变量:
名称
类型
描述
_Time
float4
t 是自该场景加载开始所经过的时间,4 个分量的值分别是 (t/20, t, 2t, 3t)
_SinTime
float4
t 是时间的正弦值,4 个分量的值分别是(t/8, t/4, t/2, t)
_CosTime
float4
t 是时间的余弦值,4 个分量的值分别是(t/8, t/4, t/2, t)
unity_DeltaTime
float4
dt 是时间增量,4 个分量的值分别是 (dt, 1/dt, smoothDt, 1/smoothDt)
2 纹理动画 纹理动画在游戏中的应用非常广泛。尤其在各种资源都比较局限的移动平台上,我们往往会使用纹理动画来代替复杂的粒子系统等模拟各种动画效果。
2.1 序列帧动画 最常见的纹理动画之一就是序列帧动画。序列帧动画的原理非常简单,它像放电影一样,依次播放一系列关键帧图像,当播放速度达到一定数值时,看起来就是一个连续的动画。它的优点在于灵活性很强,我们不需要进行任何物理计算就可以得到非常细腻的动画效果。而它的缺点也很明显,由于序列帧中每张关键帧图像都不一样,因此,要制作一张出色的序列帧纹理所需要的美术工程狱也比较大。
要想实现序列帧动画,我们先要提供一张包含了关键帧图像的图像。如下图:
上述图像包含了 8 x 8 张关键帧图像,它们的大小相同,而且播放顺序为从左到右、从上到下。要序列帧动画的精髓在于,我们需要在每个时刻计算该时刻下应该播放的关键帧的位置,并对该关键桢进行纹理采样。
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 79 80 81 82 83 84 85 86 87 88 89 90 Shader "Unity Shaders Book/Chapter 11 /Image Sequence Animation" { Properties { _Color ("Color Tint", Color) = (1 , 1 , 1 , 1 ) _MainTex ("Image Sequence", 2 D) = "white" {} _HorizontalAmount ("Horizontal Amount", Float) = 4 _VerticalAmount ("Vertical Amount", Float) = 4 _Speed ("Speed", Range(1 , 100 )) = 30 } SubShader { Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"} Pass { Tags { "LightMode"="ForwardBase" } ZWrite Off Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" fixed4 _Color; sampler2D _MainTex; float4 _MainTex_ST; float _HorizontalAmount; float _VerticalAmount; float _Speed; struct a2v { float4 vertex : POSITION; float2 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { float time = floor (_Time.y * _Speed); float row = floor (time / _HorizontalAmount); float column = time - row * _HorizontalAmount; half2 uv = i.uv + half2(column, -row); uv.x /= _HorizontalAmount; uv.y /= _VerticalAmount; fixed4 c = tex2D(_MainTex, uv); c.rgb *= _Color; return c; } ENDCG } } FallBack "Transparent/VertexLit" }
效果如下:
2.2 滚动背景 很多 2D 游戏都使用了不断滚动的背景来模拟游戏角色在场景中的穿梭,这些背景往往包含了多个层(layers)来模拟一种视差效果。而这些背景的实现往往就是利用了纹理动画。接下来我们将实现一个包含了两层的无限滚动的 2D 游戏背景。纹理资源来自 OpenGameArt 网站。
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 Shader "Unity Shaders Book/Chapter 11 /Scrolling Background" { Properties { _MainTex ("Base Layer (RGB)", 2 D) = "white" {} _DetailTex ("2 nd Layer (RGB)", 2 D) = "white" {} _ScrollX ("Base layer Scroll Speed", Float) = 1.0 _Scroll2X ("2 nd layer Scroll Speed", Float) = 1.0 _Multiplier ("Layer Multiplier", Float) = 1 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry"} Pass { Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; sampler2D _DetailTex; float4 _MainTex_ST; float4 _DetailTex_ST; float _ScrollX; float _Scroll2X; float _Multiplier; struct a2v { float4 vertex : POSITION; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float4 uv : TEXCOORD0; }; v2f vert (a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex) + frac(float2(_ScrollX, 0.0 ) * _Time.y); o.uv.zw = TRANSFORM_TEX(v.texcoord, _DetailTex) + frac(float2(_Scroll2X, 0.0 ) * _Time.y); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 firstLayer = tex2D(_MainTex, i.uv.xy); fixed4 secondLayer = tex2D(_DetailTex, i.uv.zw); fixed4 c = lerp(firstLayer, secondLayer, secondLayer.a); c.rgb *= _Multiplier; return c; } ENDCG } } FallBack "VertexLit" }
渲染效果如下:
3 顶点动画 如果一个游戏中所有的物体都是静止的,这样枯燥的世界恐怕很难引起玩家的兴趣。顶点动画可以让我们的场景变得更加生动有趣。在游戏中,我们常常使用顶点动画来模拟飘动的旗帜、湍流的小溪等效果。
3.1 流动的河流 河流的模拟是顶点动画最常见的应用之一。它的原理通常就是使用正弦函数等来模拟水流的波动效果。
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 Shader "Unity Shaders Book/Chapter 11 /Water" { Properties { _MainTex ("Main Tex", 2 D) = "white" {} _Color ("Color Tint", Color) = (1 , 1 , 1 , 1 ) _Magnitude ("Distortion Magnitude", Float) = 1 _Frequency ("Distortion Frequency", Float) = 1 _InvWaveLength ("Distortion Inverse Wave Length", Float) = 10 _Speed ("Speed", Float) = 0.5 } SubShader { Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"} Pass { Tags { "LightMode"="ForwardBase" } ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Magnitude; float _Frequency; float _InvWaveLength; float _Speed; struct a2v { float4 vertex : POSITION; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(a2v v) { v2f o; float4 offset ; offset .yzw = float3(0.0 , 0.0 , 0.0 ); offset .x = sin (_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude; o.pos = UnityObjectToClipPos(v.vertex + offset ); o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); o.uv += float2(0.0 , _Time.y * _Speed); return o; } fixed4 frag(v2f i) : SV_Target { fixed4 c = tex2D(_MainTex, i.uv); c.rgb *= _Color.rgb; return c; } ENDCG } } FallBack "Transparent/VertexLit" }
效果如下:
3.2 广告牌 另一种常见的顶点动画就是广告牌技术(Billboarding)。广告牌技术会根据视角方向来旋转一个被纹理着色的多边形(通常就是简单的四边形,这个多边形就是广告牌),使得多边形看起来好像总是面对着摄像机。广告牌技术被用于很多应用,比如渲染烟雾、云朵、闪光效果等。
广告牌技术的本质就是构建旋转矩阵,而我们知道一个变换矩阵需要 3 个基向量。广告牌技术使用的基向量通常就是表面法线(normal)、指向上的方向(up)以及指向右的方向(right)。除此之外,我们还需要指定一个锚点(anchor location),这个锚点在旋转过程中是固定不变的,以此来确定多边形在空间中的位置。
广告牌技术的难点在于,如何根据需求来构建 3 个相互正交的基向量。计算过程通常是,我们首先会通过初始计算得到目标的表面法线(例如就是视角方向)和指向上的方向,而两者往往是不垂直的。但是,两者其中之一是固定的,例如当模拟草丛时,我们希望广告牌的指向上的方向永远是 (0, 1, 0),而法线方向应该随视角变化;而当模拟粒子效果时,我们希望广告牌的法线方向是固定的,即总是指向视角方向,指向上的方向则可以变化。
我们假设法线是固定的,首先,根据初始的表面法线和指向上的方向来计算出目标方向的指向右的方向: $$ right = up \times normal $$ 对其归一化后,再有法线方向和指向右的方向计算出正交的指向上的方向: $$ up’=noraml \times right $$ 这样就可以得到用于旋转的 3 个正交基了,下图描述了上面的过程:
如果指向上的方向是固定的,计算过程也是类似的。
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 79 80 81 82 83 84 85 86 Shader "Unity Shaders Book/Chapter 11 /Billboard" { Properties { _MainTex ("Main Tex", 2 D) = "white" {} _Color ("Color Tint", Color) = (1 , 1 , 1 , 1 ) _VerticalBillboarding ("Vertical Restraints", Range(0 , 1 )) = 1 } SubShader { Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"} Pass { Tags { "LightMode"="ForwardBase" } ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include "Lighting.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; fixed _VerticalBillboarding; struct a2v { float4 vertex : POSITION; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (a2v v) { v2f o; float3 center = float3(0 , 0 , 0 ); float3 viewer = mul(unity_WorldToObject,float4(_WorldSpaceCameraPos, 1 )); float3 normalDir = viewer - center; normalDir.y = normalDir.y * _VerticalBillboarding; normalDir = normalize (normalDir); float3 upDir = abs (normalDir.y) > 0.999 ? float3(0 , 0 , 1 ) : float3(0 , 1 , 0 ); float3 rightDir = normalize (cross (upDir, normalDir)); upDir = normalize (cross (normalDir, rightDir)); float3 centerOffs = v.vertex.xyz - center; float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z; o.pos = UnityObjectToClipPos(float4(localPos, 1 )); o.uv = TRANSFORM_TEX(v.texcoord,_MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 c = tex2D (_MainTex, i.uv); c.rgb *= _Color.rgb; return c; } ENDCG } } FallBack "Transparent/VertexLit" }
当 Vertical Restraints 设置为 1 时,相当于法线方向固定为视线方向,因此我们看到的星星都是正对我们的:
当 Vertical Restraints 设置为 0 时,相当于固定向上方向为(0, 1, 0),可以看出,广告牌虽然最大限度地面朝摄像机,但其指向上的方向并未发生改变:
需要说明的是,在上面的例子中,我们使用的是 Unity 自带的四边形 (Quad) 来作为广告牌,而不能使用自带的平面 (Plane) 。这是因为,我们的代码是建立在一个竖直摆放的多边形的基础上的,也就是说,这个多边形的项点结构需要满足在模型空间下是竖直排列的。只有这样,我们才能使用 v.vertex 来计算得到正确的相对于中心的位置偏移量。
3.3 注意事项 顶点动画虽然非常灵活,但有一些事情需要格外注意:
如果我们在模型空间下进行了一些顶点动画,那么批处理往往就会破坏这种动画效果。这时,我们可以通过 SubShader 的 DisableBatching 标签来强制取消对该 Unity Shader 的批处理。然而,取消批处理会带来一定的性能下降,增加了 Draw Call, 因此我们应该尽量避免使用模型空间下的一些绝对位置和方向来进行计算。在上面的广告牌的例子中,为了避免显式使用模型空间的中心来作为锚点,我们可以利用顶点颜色来存储每个顶点到锚点的距离值,这种做法在商业游戏中很常见。
如果我们想要对包含了顶点动画的物体添加阴影,像之前一样使用内置的 Diffuse 等包含的阴影 Pass 来渲染,就得不到正确的阴影效果(这里指的是无法向其他物体正确地投射阴影)。这是因为,我们讲过 Unity 的阴影绘制需要调用一个 ShadowCaster Pass, 而如果直接使用这些内置的 ShadowCaster Pass,这个 Pass 中并没有进行相关的顶点动画,因此 Unity 会仍然按照之前的顶点计算阴影,所以此时需要我们自己提供计算阴影的 Pass,在这个 Pass 中,我们将进行同样的顶点变换过程。需要注意的是,在前面的其他动画实现中,涉及半透明物体我们使用的 FallBack 都是 Transparent/VertexLit ,而 Transparent/VertexLit 没有定义 ShadowCaster Pass, 因此也就不会产生阴影。