第十章 让画面动起来(2)

顶点动画

如果一个游戏中所有的物体都是静止的,这样枯燥的世界恐怕很难引起玩家的兴趣。顶点动画可以让我们的场景变得更加生动有趣。在游戏中,我们常常使用顶点动画来模拟飘动的旗帜、湍流的小河等效果。在本节中,我们将学习两种常见的顶点动画的应用——流动的河流以及广告牌技术。在本节最后我们还将给出一些顶点动画中的注意事项及解决方法。

1. 流动的河流

河流的模拟是顶点动画最常见的应用之一。它的原理通常就是使用正弦函数等来模拟水流的波动效果。在本小节中,我们将学习如何模拟一个2D的河流效果。在学习完本节后,我们可以得到类似下图的效果。当单击运行后,可以观察到河流不断流动的效果。
第十章 让画面动起来(2)
(1)首先,我们声明了一些新的属性:

Properties{
_MainTex("Main Tex", 2D)="white"{}
_Color("Color Tint", Color)=(1,1,1,1)
_Magnitude("Distortion Magnitude",Float)=1
_Frequency("Distortion Frequency",Float)=1
_InWaveLength("Distortion Inverse Wave Length",Float)=10
_Speed("Speed",Float)=0.5
}

其中,_MainTex是河流纹理,_Color用于控制整体颜色,_Magnitude用于控制水流波动的幅度,_Frequency用于控制波动频率,_InWaveLength用于控制波长的倒数(_InWaveLength越大,波长越小),_Speed用于控制河流纹理的移动速度。
(2)在本例中,我们需要为透明效果设置合适的SunShader标签:

SubShader{
//Need to disable batching because of the vertex animation
Tags{"Queue"="Transparent""IgnoreProjector"="True""RenderType"="Transparent""DisableBatching"="True"}
}

在上面的设置中,我们除了为透明效果设置Queue、IgnoreProjector和RenderType外,还设置了一个新的标签——DisableBatching。我们在前面介绍过该标签的含义:一些SubShader在使用Unity的批处理功能时会出现问题,这时可以通过设置该标签直接指明是否对该Shader使用批处理。而这些需要特殊处理的Shader通常就是指包含了模型空间的顶点动画的Shader。这是因为,批处理会合并所有相关的模型,而这些模型的模型空间就会丢失。而在本例中,我们需要在物体的模型空间下对顶点的位置进行偏移。因此这里需要取消对该Shader的批处理操作。
(3)接着,我们设置了该Pass的渲染状态:

Pass{
Tags{"LightMode"="ForwardBase"}
Zwrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
}

这里关闭了深度写入,开启并设置了混合模式,并关闭了剔除功能,这是为了让水流的每个面都能显示。
(4)然后,我们在顶点着色器中进行了相关的顶点动画;

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=mul(UNITY_MATRIX_MVP,v.vertex+offset);
o.uv=TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv+=float2(0.0,_Time.y*_Speed);
return o;
}

我们首先计算顶点位移。我们只希望对顶点的x方向进行位移,因此yzw的位移量被设置为0。然后,我们利用_Frequency属性和内置的_Time.y变量来控制正弦函数的频率。为了让不同的位置具有不同的位移,我们对上述结果加上了模型空间下的位置分量,并乘以_InvWaveLength来控制波长。最后,我们对结果值乘以_Magnitude属性来控制波动幅度,得到最终的位移。剩下的工作,我们只需把位移量添加到顶点位置上,再进行正常的顶点变换即可。
在上面的代码中,我们还进行了纹理动画,即使用_Time.y和_Speed来控制在水平方向上的纹理动画。
(5)片元着色器的代码非常简单,我们只需要对纹理采样再添加颜色控制即可:

fixed4 frag(v2f i):SV_Target{
fixed4 c=tex2D(_MainTex,i.uv);
c.rgb*=_Color.rgb;
return c;
}

(6)最后我们把Fallback设置为内置的Transparent/VertexLit(也可以选择关闭Fallback):

Fallback"Transparent/VertexLit"

2.广告牌

另一种常见的顶点动画就是广告牌技术(Billboarding)。广告牌技术会根据视角方向来旋转一个被纹理着色的多边形(通常就是简单的四边形,这个多边形就是广告牌),使得多边形看起来好像总是面对着摄像机。广告牌技术被用于很多应用,比如渲染烟雾、云朵、闪光效果等。
广告牌技术的本质就是构造旋转矩阵,而我们知道一个变换矩阵需要3个基向量。广告牌技术使用的3个基向量通常就是表面法线(normal)、指向上的方向(up)以及指向右的方向(right)。除此之外,我们还需要指定一个锚点(anchor location),这个锚点在旋转过程中是固定不变的,以此确定多边形在空间中的位置。
广告牌技术的难点在于,如何根据需求来构建3个相互正交的基向量。计算过程通常是,我们首先会通过初始计算得到目标的表面法线(例如就是视角方向)和指向上的方向,而两者往往是不垂直的。但是,两者其中之一是固定的,例如当模拟草从时,我们希望广告牌指向上的方向永远是(0,1,0),而法线方向应该随视角变化;当模拟粒子效果时,我们希望广告牌的法线方向是固定的,即总是指向视角方向,指向上的方向则可以发生变化。我们假设法线方向是固定的,首先,我们根据初始的表面法线和指向上的方向来计算出目标方向的指向右的方向(通过叉积操作):
第十章 让画面动起来(2)
对其归一化后,再由法线方向和指向右的方向计算出正交的指向上的方向即可:
第十章 让画面动起来(2)
至此,我们就可以得到用于旋转的3个正交基了。下图给出了上述计算过程的图示。如果指向上的方向是固定的,计算过程也是类似的。
第十章 让画面动起来(2)
下面,我们将在Unity中实现上面提到的广告牌技术。在学习完本节后,我们可以得到类似下图的效果:
第十章 让画面动起来(2)
(1)我们首先声明几个新的变量:

Properties{
_MainTex("Main Tex",2D)="white"{}
_Color("Color Tint",Color)=(1,1,1,1)
_VerticalBillboarding("Vertical Restraints",Range(0,1))=1
}

其中,_MainTex是广告牌的透明纹理,_Color用于控制整体显示颜色,_VerticalBillboarding则用于调整是固定法线还是固定指向向上的方向,即约束垂直方向的程度。
(2)在本例中,我们需要为透明效果设置合适的SubShader标签:

SubShader{
//Need to disable batching because of the vertex animation
Tags{"Queue"="Transparent""IgnoreProjector"="True""Rendertype"="Transparent""DisableBatching"="True"}
}

在上面的设置中,我们除了透明效果设置Queue、IgnoreProjector和RenderType外,还设置了一个新的标签——DisableBatching。我们在前面讲过该标签的含义:一些SubShader在使用Unity的批处理功能时会出现问题,这时可以通过该标签来直接指明是否对该SubShader使用批处理。而这些需要特殊处理的Shader通常就是指包含了模型空间的顶点动画的Shader。这是因为批处理会合并所有相关的模型,而这些模型各自的模型空间就会被丢失。而在广告牌技术中,我们需要使用物体模型空间下的位置来作为锚点进行计算。因此这里需要取消对该Shader的批处理操作。
(3)接着,我们设置了Pass的渲染状态:

Pass{
Tags{"LightMode"="ForwardBase"} 
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
}

这里关闭了深度写入,开启并设置了混合模式,并关闭了提出功能。这是为了让广告牌的每个面都能显示。
(4)顶点着色器是我们的核心,所有的计算都是在模型空间下进行的。我们首先选择模型空间的原点作为广告牌的锚点,并利用内置变量获取模型空间下的视角位置:

//Suppose the center in object space is fixed
float3 center = float3(0,0,0);
float3 viewer = mul(_World2Object,float4(_WorldSpaceCameraPos,1));

然后,我们开始计算3个正交矢量。首选,我们根据观察位置和锚点计算目标法线方向,并根据__VerticalBillboarding属性来控制垂直方向上的约束度。

float3 normalDir = viewer-center;
//If __VerticalBillboarding equals 1,we use the desired view dir as the normal dir
//whitch means the normal dir is fixed
//Or if __VerticalBillboarding equals 0,the y of normal is 0
//whitch means the up dir is fixed
normalDir.y=normalDir.y*_ VerticalBillboarding;
normalDir=normalize(normalDir);

当_ VerticalBillboarding为1时,意味着法线方向为固定的视角方向;当_ VerticalBillboarding为0时,意味着向上方向固定为(0,1,0)。最后,我们需要对计算得到的法线方向进行归一化操作来得到单位矢量。
接着,我们得到了粗略的向上方向。为了防止法线方向和向上方向平行(如果平行,那么叉积得到的结果将是错误的),我们对法线方向的y分量进行判断,以得到合适的向上方向。然后,根据法线方向和粗略的向上方向得到向右的方向,并对结果进行归一化。但由于此时向上的方向还是不准确的,我们又根据准确的法线方向和向右方向得到最后的向上方向:

//Get the appropriate up dir
//If normal dir is already towards up,then the up dir is towards front
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个所需的正交矢量。我们根据原始的位置相对于锚点偏移量以及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));

(5)片元着色器的代码非常简单,我们只需要对纹理进行采样,再与颜色相乘即可:

fixed4 frag(v2f i):SV_Target{
fixed4 c=tex2D(_MainTex,i.uv);
c.rgb*=_Color.rgb;
return c;
}

(6)最后,我们把Fallback设置的内置的Transparent/VertexLit(也可以选择关闭Fallback):

Fallback"Transparent/VertexLit"

需要说明的是,在上面的例子中,我们使用的是Unity自带的四边形(Quad)来作为广告牌,而不能使用自带的平面(Plane)。这是因为,我们的代码是建立在一个竖直摆放的多边形基础上的,也就是说,这个多边形的顶点结构需要满足在模型空间下是竖直排列的。只有这样,我们才能使用v.vertex来计算得到正确的相对于中心位置的偏移量。

3. 注意事项

顶点动画虽然非常灵活有效,但是有一些注意事项需要在此提醒读者。
首先,如前面看到那样,如果我们在模型空间下进行了一些顶点动画,那么批处理往往就会破坏这种动画效果。这时我们就可以通过SubShader的DisableBatching标签来强制取消对该UnityShader的批处理。然而,取消批处理会带来一定的性能下降,增加了DrawCall,因此我们应该避免使用模型空间下的一些绝对值和方向来进行计算。在广告牌的例子中,为了避免显示使用模型空间的中心来作为锚点,我们可以利用顶点颜色来存储每个顶点到锚点的距离值,这种做法在商业游戏中和常见。
其次,如果我们想要对包含了顶点动画的物体添加阴影,那么如果仍然像前面那样使用内置的Diffuse等包含的阴影Pass来渲染,就得不到正确的阴影效果(这里指的是无法向其他物体正确的投射阴影)。这是因为,我们讲过Unity的阴影绘制需要调用一个ShadowCaster Pass,而如果直接使用这些内置的ShadowCaster Pass,这个Pass中并没有进行相关的顶点动画,因此Unity仍然会按照原来的顶点位置来计算阴影,这并不是我们希望看到的。这时,我们就需要提供一个自定义的ShadowCaster Pass,在这个Pass中,我们将进行同样的顶点变换过程。需要注意的是,在前面的实现中,如果涉及半透明物体我们都把Fallback设置成了Transparent/VertexLit,而Transparent/VertexLit没有定义ShadowCaster Pass,因此也就不会产生阴影。
下面我们给出了计算顶点动画的一个例子。我们开启了场景中平行光的阴影效果,并添加了一个平面来接收来自“水流”的阴影。我们还把Unity Shader的Fallback设置为了内置的VertexLit,这样Unity将根据Fallback最终找到VertexLit中的ShadowCaster Pass来渲染阴影。下图给出了这样的结果。
第十章 让画面动起来(2)
可以看出,此时虽然Water模型发生了形变,但它的阴影并没有产生相应的动画效果。为了正确绘制变形对象的阴影,我们就需要提供自定义的ShadowCaster Pass。得到的效果如下图所示:
第十章 让画面动起来(2)
相关代码如下:

//Pass to render object as a shadow caster
Pass{
Tags{"LightMode"="ShadowCaster"}
CGPROGRAM
#pragma vertex vert
#pragma fragement frag
#pragma multi_compile_shadowcaster
#include"UnityCG.cginc"
float _Magnitude;
float _Frequency;
float_InvWaveLength;
float _Speed;
struct a2v{
float4 vertex:POSITION;
float4 texcoord:TEXCOORD0;
};
struct v2f{
V2F_SHADOW_CASTER;
};
v2f vert(a2v i){
v2f o;
float4 offset;
offse.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;
v.vertex=v.vertex+offset;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
fixed4 frag(v2f i):SV_Target{
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}

阴影投射的重点在于我们需要按正常Pass的处理来剔除片元或进行顶点动画,以便阴影可以和物体正常渲染的结果相匹配。在自定义的阴影投射的Pass中,我们通常会使用Unity提供的内置宏V2F_SHADOW_CASTER、TRANSFER_SHADOW_CASTER_NORMALOFFSET(旧版本中会使用TRANSFER_SHADOW_CASTER)和SHADOW_CASTER_FRAGMENT来计算阴影投射时需要的各种变量,而我们可以只关注自定义计算部分。在上面的代码中,我们首先在v2f结构体中利用V2F_SHADOW_CASTER来定义阴影投射需要定义的变量。随后在顶点着色器中,我们首先按之前对顶点的处理方法计算顶点的偏移量,不同的是,我们直接把偏移值加到顶点位置变量中,再使用TRANSFER_SHADOW_CASTER_NORMALOFFSET来让Unity为我们完成剩下的事情。在片元着色器中,我们直接使用SHADOW_CASTER_FRAGMENT来让Unity自动完成阴影投射的部分,把结果输出到深度图和阴影映射纹理中。
通过Unity提供的这3个内置宏(在UnityCG.cginc文件中被定义),我们可以方便的自定义需要的阴影投射的Pass,但由于这些宏里需要使用一些特定的输入变量,因此我们需要保证为它们提供了这些变量。例如TRANSFER_SHADOW_CASTER_NORMALOFFSET会使用名称v作为结构输入体,v中需要包含顶点位置v.vertex和顶点法线v.normal的信息,我们可以直接使用内置的appdata_base结构体,它包含了这些必须的顶点变量。如果我们需要进行顶点动画,可以在顶点着色器中直接修改v.vertex,在传递给TRANSFER_SHADOW_CASTER_NORMALOFFSET即可。在后面,我们还会看到如何在阴影投射的Pass中剔除片元,以实现自定义的透明度测试效果。