Unity Shader入门精要学习笔记 - 第11章 让画面动起来
发表于2017-09-01
让画面动起来
没有动画的画面往往让人觉得很无趣。 在本篇中, 我们将会学习如何向 Unity Shader 中引入时间变量, 以实现各种动画效果。我们首先会介绍 Unity Shader 内置的时间变量,在随后的章节中我们会使用这些时间变量来实现动画。 接下来会介绍两种常见的纹理动画, 即序列帧动画和背景循环滚动动画。 最后,我们会学习使用顶点动画来实现流动的河流、 广告牌等动画效果。
动画效果往往都是把时间添加到一些变量的计算中,以便在时间变化时画面也可以随之变化。Unity Shader 提供了一系列关于时间的内置变量来允许我们方便地在Shader中访问允许时间,实现各种动画效果。下表给出了这些内置的时间变量。
纹理动画
纹理动画在游戏中的应用非常广泛。尤其在各种资源都比较局限的移动平台上,我们往往会使用纹理动画来代替复杂的例子系统等模拟各种动画效果。
最常用的纹理动画之一就是序列帧动画。序列帧动画的原理非常简单,它像放电影一样,依次播放一系列关键帧图像,当播放速度达到一定数值时,看起来就是一个连续的动画。它的有点在于灵活性很强,我们不需要进行任何物理计算就可以得到非常细腻的动画效果。而它的缺点也很明显,由于序列帧中每张关键帧图像都不一样,因此,要制作一张出色的序列帧纹理所需要的美术工程量比较大。
想要实现序列帧动画,我们先要提供一张包含了关键帧图像的图像。如下图所示。
上图包含了8×8张关键帧图像,它们的大小相同,而且播放顺序为从左到右、从上到下、下图给出了不同时刻播放的不同动画效果。
为了再Unity实现序列帧动画,我们做如下准备工作。
1)新建一个场景,去掉天空盒子。
2)新建一个材质,新建一个Shader,并赋给材质
3)新建一个Quad,调整它的位置使其正面朝向摄像机,并把上步材质赋给它
上述序列帧动画的精髓在于,我们需要在每个时刻计算该时刻下应该播放的关键帧的位置,并对该关键帧进行纹理采样。我们修改Shader代码:
Shader "Unity Shaders Book/Chapter 11/Image Sequence Animation" { Properties { _Color ("Color Tint", Color) = (1, 1, 1, 1) //关键帧纹理 _MainTex ("Image Sequence", 2D) = "white" {} //图像在水平方向上的关键帧个数 _HorizontalAmount ("Horizontal Amount", Float) = 4 //图像在垂直方向上的关键帧个数 _VerticalAmount ("Vertical Amount", Float) = 4 //控制序列帧的播放速度 _Speed ("Speed", Range(1, 100)) = 30 } SubShader { //由于序列帧图像通常都是透明纹理,我们需要设置Pass的相关状态,以渲染透明效果 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 = mul(UNITY_MATRIX_MVP, v.vertex); o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { //_Time.y 是自该场景后所经过的时间,与速度相乘来得到模拟的时间,再用floor函数取整 float time = floor(_Time.y * _Speed); //获得行索引 float row = floor(time / _HorizontalAmount); //获得列索引 float column = time - row * _HorizontalAmount; // half2 uv = float2(i.uv.x /_HorizontalAmount, i.uv.y / _VerticalAmount); // uv.x += column / _HorizontalAmount; // uv.y -= row / _VerticalAmount; //进行位置偏移 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" }
很多2D游戏都使用了不断滚动的背景来模拟游戏角色在场景中的穿梭,这些背景往往包含了多个层来模拟一种视觉效果。而这些背景的实现往往就是利用了纹理动画。我们将实现一个包含了两层的无限滚动的2D游戏背景。我们可以得到类似下图的效果。单击允许后,我们就可以得到一个无限滚动的背景效果。
为此,我们需要进行如下准备工作。
1)新建一个场景,去掉天空盒子,摄像机投影模式设置为正交投影。
2)新建一个材质,新建一个Shader,赋给材质
3)新建一个Quad,调整大小位置,使它充满摄像机的视野范围,然后把第2步的材质拖拽给它。
修改shader 代码
Shader "Unity Shaders Book/Chapter 11/Scrolling Background" { Properties { //第一层(较远)背景纹理 _MainTex ("Base Layer (RGB)", 2D) = "white" {} //第二层(较近)背景纹理 _DetailTex ("2nd Layer (RGB)", 2D) = "white" {} //第一层滚动速度 _ScrollX ("Base layer Scroll Speed", Float) = 1.0 //第二层滚动速度 _Scroll2X ("2nd 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 = mul(UNITY_MATRIX_MVP, v.vertex); //计算两层背景纹理的纹理坐标 //首先利用了TRANSFORM_TEX 来得到初始的纹理坐标 //再利用_Time.y变量在水平方向上对纹理坐标进行偏移 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" }
顶点动画
河流的模拟是顶点动画最常见的应用之一。它的原理通常就是使用正弦函数等来模拟水流波动效果。我们将学习如何模拟一个2D的河流效果,我们可以得到类似下图的效果。
为此,我们需要进行如下准备工作。
1)新建一个场景,去掉天空盒子,摄像机投影模式设置为正交投影。
2)新建一个材质,新建一个Shader,赋给材质
3)在场景中创建多个Water模型,调整它们的位置、大小和方向,把上步的材质赋给它
修改shader代码:
Shader "Unity Shaders Book/Chapter 11/Water" { Properties { //河流纹理 _MainTex ("Main Tex", 2D) = "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); //只在水平方向上偏移,利用_Frequency 和 内置的_Time.y 来控制正弦函数的频率 //为了让不同的位置具有不同的位移,我们对上述结果加上了模型空间下的位置分量,并乘以_InvWaveLength 来控制波长 //最后乘以_Magnitude 来控制波动幅度,得到最终的位移。 offset.x = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude; o.pos = mul(UNITY_MATRIX_MVP, 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个基向量。广告牌技术使用的基向量通常就是表面法线(normal)、指向上的方向(up)以及指向右的方向(right)。除此之外,我们还需要指定一个锚点。这个锚点在旋转的过程中是固定不变的,以此来确定多边形在空间中的位置。
广告牌技术的难点在于,如何根据需要来构建3个相互正交的基向量。计算过程通常是,我们首先会通过初始计算得到目标的表面法线(例如就是视角方向)和指向上的方向,而两者往往是不垂直的。但是,两者其中之一是固定的,例如当模拟草丛时,我们希望广告牌的法线方向是固定的,即总是指向视角方向,指向上的方向则可以发生变换。我们假设法线方向是固定的,首先,我们根据初始的表面法线和指向上的方向来计算出目标方向的指向右的方向(通过叉积操作):
right = up × normal
对其归一化后,再由法线方向和指向右的方向计算出正交的指向上的方向即可:
up' = normal × right
至此,我们就可以得到用于旋转的3个正交基了。下图给出了上述计算过程的图示。如果指向上的方向是固定的,计算过程也是类似的。
下面,我们将在Unity中实现上面提到的广告牌技术。我们可以得到类似下图中的效果。
为此,我们需要做如下准备工作。
1)新建一个场景,去掉天空盒子
2)新建一个材质,新建一个Shader,赋给材质
3)在场景中创建多个Quad,调整位置和大小,把上步材质赋给它们。
更改Shader代码:
Shader "Unity Shaders Book/Chapter 11/Billboard" { Properties { //广告牌显示的透明纹理 _MainTex ("Main Tex", 2D) = "white" {} //控制整体颜色 _Color ("Color Tint", Color) = (1, 1, 1, 1) //调整是固定法线还是固定指向上的方向 _VerticalBillboarding ("Vertical Restraints", Range(0, 1)) = 1 } SubShader { // Need to disable batching because of the vertex animation 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(_World2Object,float4(_WorldSpaceCameraPos, 1)); //计算3个正交矢量。首先,我们根据观察位置和锚点计算目标法线方向, //并根据_VerticalBillboarding 属性来控制垂直方向上的约束度。 float3 normalDir = viewer - center; // 如果 _VerticalBillboarding 等于 1, 意味着法线方向固定为视角方向 // 如果 _VerticalBillboarding 等于 0, 意味着向上方向固定为(0,1,0) normalDir.y =normalDir.y * _VerticalBillboarding; //归一化操作 normalDir = normalize(normalDir); //我们得到了粗略的向上方向。为了防止法线方向和向上方向平行 //我们对法线方向的y分量进行判断,以得到合适的向上方向。然后,根据法线方向 //和粗略的向上方向得到向右方向,并对结果进行归一化。但由于此时向上的方向还是不 //准确的,我们又根据准确的法线方向和向右方向得到最后的向上方向 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)); //我们根据原始的位置相对于锚点的偏移量以及3个正交基矢量,以计算得到新的顶点位置。 float3 centerOffs = v.vertex.xyz - center; float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z; //最后,把模型空间的顶点位置变换到裁剪空间中 o.pos = mul(UNITY_MATRIX_MVP, 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" }
需要说明的是,在上面的例子中,我们使用的是Unity自带的Quad来作为广告牌,而不能使用自带的Plane。这是因为,我们的代码是建立在一个竖直摆放的多边形的基础上的,也就是说,这个多边形的顶点结构需要满足在模型空间下是竖直排列的。只有这样,我们才能使用v.vertex来计算到正确的相对于中心的位置偏移量。
顶点动画虽然非常灵活有效,但有些注意事项需要注意。
首先,在之前看到的那样,如果我们在模型空间下进行了一些顶点动画,那么批处理往往就会破坏这种动画效果。这时,我们可以通过SubShader的DisableBatching标签来强制取消对该Unity Shader的批处理。然而,取消批处理会带来一定的性能下降,增加了Draw Call,因此我们应该尽量避免使用模型空间下的一些绝对位置和方向来进行计算。在广告牌的例子中,为了避免显示使用模型空间的中心来作为锚点,我们可以利用顶点颜色来存储每个顶点到锚点的距离值,这种做法在商业游戏中很常见。
其次,如果我们想要对包含了顶点动画的物体添加阴影,那么如果像之前那样使用内置的Diffuse等包含的阴影Pass来渲染,就得不到正确的阴影效果(这里指的是无法向其他物体正确地投射阴影)。这是因为,我们讲过Unity 的阴影绘制需要调用一个ShadowCaster Pass,而如果直接使用这些内置的ShadowCasterPass,这个Pass中并没有进行相关的顶点动画,因此Unity 自定义的ShadowCaster Pass,而这个Pass中,我们将进行统一的顶点变换过程。需要注意的是,在前面的实现中,如果涉及半透明物体我们都把Fallback设置成了Transparent/VertexLit ,而Transparent/VertexLit没有定义ShadowCaster Pass,因此也就不会产生阴影。
在之前的场景中,我们给出了计算顶点动画的阴影的一个例子。在这个例子中,我们使用了之前的大部分代码,模拟一个波动的水流。同时,我们开启了场景中平行光的阴影效果,并添加了一个平面来接收来自“水流”的阴影。我们还把这个Unity Shader 的Fallback 设置为内置的VertexLit,这样Unity将根据Fallback最终找到VertexLit 中的ShadowCaster Pass 来渲染阴影。下图给出了这样的结果。
可以看出,此时虽然Water模型发生了形变,但它的阴影并没有产生相应的动画效果。为了正确绘制变形对象的阴影,我们就需要提供自定义的ShadowCaster Pass。我们新建一个Shader来实现,效果如下图。
在这个Shader中,我们提供了一个ShadowCaster Pass,相关代码如下:
Shader "Unity Shaders Book/Chapter 11/Billboard" { Properties { //广告牌显示的透明纹理 _MainTex ("Main Tex", 2D) = "white" {} //控制整体颜色 _Color ("Color Tint", Color) = (1, 1, 1, 1) //调整是固定法线还是固定指向上的方向 _VerticalBillboarding ("Vertical Restraints", Range(0, 1)) = 1 } SubShader { // Need to disable batching because of the vertex animation 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(_World2Object,float4(_WorldSpaceCameraPos, 1)); //计算3个正交矢量。首先,我们根据观察位置和锚点计算目标法线方向, //并根据_VerticalBillboarding 属性来控制垂直方向上的约束度。 float3 normalDir = viewer - center; // 如果 _VerticalBillboarding 等于 1, 意味着法线方向固定为视角方向 // 如果 _VerticalBillboarding 等于 0, 意味着向上方向固定为(0,1,0) normalDir.y =normalDir.y * _VerticalBillboarding; //归一化操作 normalDir = normalize(normalDir); //我们得到了粗略的向上方向。为了防止法线方向和向上方向平行 //我们对法线方向的y分量进行判断,以得到合适的向上方向。然后,根据法线方向 //和粗略的向上方向得到向右方向,并对结果进行归一化。但由于此时向上的方向还是不 //准确的,我们又根据准确的法线方向和向右方向得到最后的向上方向 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)); //我们根据原始的位置相对于锚点的偏移量以及3个正交基矢量,以计算得到新的顶点位置。 float3 centerOffs = v.vertex.xyz - center; float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z; //最后,把模型空间的顶点位置变换到裁剪空间中 o.pos = mul(UNITY_MATRIX_MVP, 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" } Pass{ Tags{"LightMode"="ShadowCaster"} CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include "UnityCG.cginc" float _Magnitude; float _Frequency; float _InvaWaveLength; float _Speed; struct a2v{ float4 vertex : POSITION; float4 texcoord : TEXCOORD0; }; struct v2f{ V2F_SHADOW_CASTER; }; v2f vert(a2v i){ v2f o; float4 offset; offset.yzw = float3(0.0,0.0,0.0); //计算偏移 offset.x = sin(_Frequency*_Time.y+v.vertex.x*_InvaWaveLength+ v.vertex.y*_InvaWaveLength+v.vertex.z*_InvaWaveLength)*_Magnitude; //加上偏移 v.vertex = v.vertex + offset; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 frag(v2f i) : SV_Target{ //使用SHADOW_CASTER_FRAGMENT来让Unity自动完成阴影投射的部分,把结果输出到深度图和阴影映射纹理中 SHADOW_CASTER_FRAGMENT(i) } ENDCG }