本文继续对《UnityShader入门精要》——冯乐乐 第十一章 让画面动起来 进行学习
没有动画的画面往往让人觉得很无趣。在本章中,我们将会学习如何向Unity Shader 中引入时间变量,以实现各种动画效果。在 11.1 节中,我们首先会介绍Unity Shader 内置的时间变量,在随后的章节中我们会使用这些时间变量来实现动画。11.2 节会介绍两种常见的纹理动画,即序列帧动画和背景循环滚动动画。在11.3 节,我们会学习使用顶点动画来实现流动的河流、广告牌等动画效果,并在最后给出一些在实现顶点动画时的注意事项。
一、Unity Shader 中的内置变量(时间篇)
动画效果往往都是把时间添加到一些变量的计算中,以便在时间变化时画面也可以随之变化。Unity Shader 提供了一系列关于时间的内置变量来允许我们方便地在Shader 中访问运行时间, 实现各种动画效果。表11.1 给出了这些内置的时间变量。
image.png
二、纹理动画之序列帧动画
纹理动画在游戏中的应用非常广泛。尤其在各种资源都比较局限的移动平台上,我们往往会使用纹理动画来代替复杂的粒子系统等模拟各种动画效果。
最常见的纹理动画之一就是序列帧动画。序列帧动画的原理非常简单,它像放电影一样,依次播放一系列关键帧图像,当播放速度达到一定数值时,看起来就是一个连续的动画。它的优点在于灵活性很强,我们不需要进行任何物理计算就可以得到非常细腻的动画效果。而它的缺点也很明显,由于序列帧中每张关键帧图像都不一样,因此,要制作一张出色的序列帧纹理所需要的美术工程量也比较大。
图11.1 本节使用的序列帧图像
在Scene_11_2_1中可以看到效果,关键就是需要在每个时刻计算该时刻下应该播放的关键帧的位置,并对该关键帧进行纹理采样。参考Chapter11-ImageSequenceAnimation.shader
1.Properties
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 }
_Main_Tex 就是包含了所有关键帧图像的纹理。_HorizontalAmount 和 _VerticalAmount 分别代表了该图像在水平方向和坚直方向包含的关键帧图像的个数。而 _Speed 属性用于控制序列帧动画的播放速度。
2.序列帧图像通常是透明纹理,设置Pass 的相关状态
SubShader { Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"} Pass { Tags { "LightMode"="ForwardBase" } ZWrite Off Blend SrcAlpha OneMinusSrcAlpha
由于序列帧图像通常包含了透明通道,因此可以被当成是一个半透明对象。在这里我们使用半透明的“标配”来设置它的SubShader 标签,即把Queue 和RenderType 设置成Transparent,把IgnoreProjector 设置为True。在Pass 中,我们使用 _Blend 命令来开启并设置混合模式,同时关闭了深度写入。
3.顶点和片元
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 = 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;}
要播放帧动画,从本质来说,我们需要计算出每个时刻需要播放的关键帧在纹理中的位置。而由于序列帧纹理都是按行按列排列的,因此这个位置可以认为是该关键帧所在的行列索引数。
因此, 在上面的代码的前3 行中我们计算了行列数,其中使用了Unity 的内置时间变量 _Time。由11.1 节可以知道,_Time.y 就是自该场景加载后所经过的时间。我们首先把 _Time.y 和速度属性 _Speed 相乘来得到模拟的时间, 并使用CG 的floor 函数对结果值取整来得到整数时间time 。
然后, 我们使用time 除以 _HorizontalAmount 的结果值的商来作为当前对应的行索引,除法结果的余数则是列索引。接下来,我们需要使用行列索引值来构建真正的采样坐标。
由于序列帧图像包含了许多关键帧图像, 这意味着采样坐标需要映射到每个关键帧图像的坐标范围内。我们可以首先把原纹理坐标i.uv 按行数和列数进行等分,得到每个子图像的纹理坐标范围。然后, 我们需要使用当前的行列数对上面的结果进行偏移, 得到当前子图像的纹理坐标。需要注意的是,对竖直方向的坐标偏移需要使用减法, 这是因为在Unity 中纹理坐标竖直方向的顺序(从下到上运渐增大)和序列帧纹理中的顺序(播放顺序是从上到下〉是相反的。这对应了上面代码中注释掉的代码部分。我们可以把上述过程中的除法整合到一起, 就得到了注释下方的代码。这样, 我们就得到了真正的纹理来样坐标。
4.Fallback
最后, 我们把Fallback 设置为内置的Transparent/VertexLit (也可以选择关闭Fallback ):
FallBack "Transparent/VertexLit"三、纹理动画之滚动背景
很多2D 游戏都使用了不断滚动的背景来模拟游戏角色在场景中的穿梭, 这些背景往往包含了多个层( layers )来模拟一种视差效果。而这些背景的实现往往就是利用了纹理动画。在本节中,我们将实现一个包含了两层的无限滚动的2D 游戏背景。
图11.3 无限滚动的背景(纹理来源:forest-background 2012-2013 Julien Jorge julien.jorge@stuff-o-matic.com)
1.Properties
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 }
_MainTex 和 _DetailTex 分别是第一层(较远〉和第二层(较近〉的背景纹理,而 _ScrollX 和 _Scroll2X 对应了各自的水平滚动速度。_Multiplier 参数则用于控制纹理的整体亮度。
2.顶点和片元
v2f vert (a2v v) { v2f o; o.pos = mul(UNITY_MATRIX_MVP, 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;}
这里冯乐乐讲的有点快,可以参考之前的章节回顾一下:UnityShader精要笔记六 基础纹理
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;// Or just call the built-in function// o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
在顶点着色器中,我们使用纹理属性值_MainTex_ST来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是,首先使用缩放属性_MainTex_ST.xy对顶点纹理坐标进行缩放,然后再使用偏移属性_MainTex_ST.zw对结果进行偏移。Unity提供了一个内置宏TRANSFORM_TEX来帮我们计算上述过程。
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
这里使用Cg的tex2D函数对纹理进行采样。它的第一个参数是需要被采样的纹理,第二个参数是一个float2类型的纹理坐标,它将返回计算得到的纹素值。
3.代码分析
通过上面的回顾,可以知道tex2D的第二个参数实际需要的是float2类型的数值,毕竟UV采集的是一张2D图片。所以本章示例中,使用了uv.xy和uv.zw分别存了两个纹理的float2类型数值,其实是减少了占用的插值寄存器空间。
然后就是frac(float2(_ScrollX, 0.0) * _Time.y),因为只是水平方向的滚动,所以构建的float2值,y维度是0.然后就是frac了,参考HLSL 常用函数
frac(x) // 返回x的小数部分
其实不使用frac,效果也是一样的,毕竟当前背景图的wrapmode是repeat模式。uv采集时,超过1就直接取小数部分了。但是随着_Time.y越来越大,不使用frac的话,这个数值可能溢出或占用过多内存空间。
然后就是lerp:
lerp(x, y, s) // 使用s在x和y之间线性插值(x+s(y-x))
可以看出,当s=0时,使用的完全是x;当s=1时,使用的完全是y。所以:
fixed4 c = lerp(firstLayer, secondLayer, secondLayer.a);
secondLayer是近景层,当近景层的透明度是1时,就使用近景层。当近景层透明度是0时,就使用远景层。而透明度处于0至1之间,则是两层的混合。
四、顶点动画之流动河流
在游戏中,我们常常使用顶点动画来模拟飘动的旗帜、涓流的小溪等效果。在本节中,我们将学习两种常见的顶点动画的应用一一流动的河流以及广告牌技术。河流的模拟是顶点动画最常见的应用之一。它的原理通常就是使用正弦函数等来模拟水流的波动效果。
图11.4 使用顶点动画来模拟2D的河流
1.Properties
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 }
_MainTex 是河流纹理,_Color 用于控制整体颜色,_Magnitude 用于控制水流波动的幅度,_Frequency 用于控制波动频率,_InvWaveLength 用于控制波长的倒数(_InvWaveLength 越大,波长越小),_Speed 用于控制河流纹理的移动速度。
2.DisableBatching
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "DisableBatching"="True"}
在上面的设置中,我们除了为透明效果设置Queue 、IgnoreProjector 和RenderType 外,还设置了一个新的标签—— DisableBatching。我们在3.3.3 节中介绍过该标签的含义:一些SubShader 在使用Unity 的批处理功能时会出现问题,这时可以通过该标签来直接指明是否对该SubShader 使用批处理。而这些需要特殊处理的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 = 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;} 五、顶点动画之广告牌
这一节很重要,但冯乐乐讲的太快,下面的内容我会掺杂一些个人的理解,也可以阅读以下链接补充理解:
Unity Shader - Billboard 广告板/广告牌 - BB树,BB投影
另一种常见的顶点动画就是广告牌技术(Billboarding )。广告牌技术会根据视角方向来旋转一个被纹理着色的多边形(通常就是简单的四边形,这个多边形就是广告牌〉,使得多边形看起来好像总是面对着摄像机。广告牌技术应用很多,比如:
烟雾、云朵、闪光效果单位顶部的血条,名字等树,草3D中场景中的2D人物(如:《饥荒》)粒子特效热扭曲的面片
下面说说个人理解,如果摄像机发生变化时,一个面片仍能一直面对摄像机,意味着这个片元需要进行一些旋转。根据前面学习的矩阵知识,描述这个变换,可以转化为基坐标的变换。所以现在的问题就是如何构建面片的新坐标系,一个很明显的事实是,从面片中心向摄像机进行连线得到的向量,需要设定为面片的法向量,这样面片才能一直面对着摄像机。但是这样计算,仍然是有问题的,比如一棵树的面片:
20160410182937068.gif
上面动画只展示了平移角度,但是当我们跳起来,甚至飞起来时,如果仍然让面片树和摄像机连线作为法向量,意味着树会歪倒在地上,这肯定是不对的。所以像树,草这种,必须给定一个竖直向上的向量,保持不变。在冯乐乐书中,有如下总结:
广告牌技术的本质就是构建旋转矩阵,而我们知道一个变换矩阵需要3 个基向量。广告牌技术使用的基向量通常就是表面法线( normal )、指向上的方向( up )以及指向右的方向( right ) 。除此之外,我们还需要指定一个锚点(anchor location ), 这个锚点在旋转过程中是固定不变的,以此来确定多边形在空间中的位置。
广告牌技术的难点在于,如何根需求来构建3 个相互正交的基向量。计算过程通常是,我们首先会通过初始计算得到目标的表面法线(例如就是视角方向〉和指向上的方向,而两者往往是不垂直的。但是,两者其中之一是固定的,例如当模拟草丛时,我们希望广告牌的指向上的方向永远是(0, 1,0),而法线方向应该随视角变化;而当模拟粒子效果时,我们希望广告牌的法线方向是固定的,即总是指向视角方向,指向上的方向则可以发生变化。
1.以模拟粒子效果(法线方向固定)为例
首先,我们根据初始的表面法线和指向上的方向来计算出目标方向的指向右的方向(通过叉积操作),归一化后,再由法线和right叉积得到新的up方向
图11.5 法线固定(总是指向视角方向)时,计算广告牌技术中的三个正交基的过程
如上图,最左边那幅图可以理解为摄像机跳起来了,那么面片必然要向右倾斜,即最右边那张图的角度。但是,为什么第一步选的是up方向的向量呢,我没有想明白。
2.Properties
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 }
_MainTex 是广告牌显示的透明纹理, _Color 用于控制显示整体颜色, _VerticalBillboarding 则用于调整是固定法线坯是固定指向上的方向,即约束垂直方向的程度。
3.所有的计算都是在模型空间下进行
首先选择模型空间的原点作为广告牌的锚点,并利用内置变量获取模型空间下的视角位置:
// Suppose the center in object space is fixedfloat3 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// Which means the normal dir is fixed// Or if _VerticalBillboarding equals 0, the y of normal is 0// Which means the up dir is fixednormalDir.y =normalDir.y * _VerticalBillboarding;normalDir = normalize(normalDir);
当 _VerticalBillboarding 为1 时, 意味着法线方向固定为视角方向;当 _VerticalBillboarding 为0 时,意味着向上方向固定为(0, 1, 0)。这句话冯乐乐没有详细解释,可以这样理解,一个向量强制让y分量变0,意味着它只能在xz两个维度中变化,那么xz平面的垂直向量,必定就是y轴了,进行归一化后即(0,1,0)。
4.构建坐标系
接着,我们得到了粗略的向上方向。为了防止法线方向和向上方向平行(如果平行,那么叉积得到的结果将是错误的〕,我们对法线方向的y 分量进行判断,以得到合适的向上方向。然后,根据法线方向和粗略的向上方向得到向右方向,并对结果进行归一化。但由于此时向上的方向还是不准确的,我们又根据准确的法线方向和向右方向得到最后的向上方向:
// Get the approximate up dir// If normal dir is already towards up, then the up dir is towards frontfloat3 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));5.变换顶点坐标
//先平移float3 centerOffs = v.vertex.xyz - center;//再旋转,然后再平移回去//旋转矩阵:float3(rightDir , upDir , nDirOS)float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z;
image.png
6.把模型空间的顶点位置变换到裁剪空间
o.pos = UnityObjectToClipPos(float4(localPos, 1));o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);return o;6.片元着色器和Fallback
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 来计算得到正确的相对于中心的位置偏移量。
六、顶点动画注意事项
顶点动画虽然非常灵活有效,但有一些注意事项需要在此提醒读者。
1.DisableBatching
首先,如11.3.2 节看到的那样,如果我们在模型空间下进行了一些顶点动画,那么批处理往往就会破坏这种动画效果。这时,我们可以通过SubShader 的DisableBatching 标签来强制取消对该Unity Shader 的批处理。然而,取消批处理会带来一定的性能下降,增加了Draw Call,因此我们应该尽量避免使用模型空间下的一些绝对位置和方向来进行计算。在广告牌的例子中,为了避免显式使用模型空间的中心来作为锚点,我们可以利用顶点颜色来存储每个顶点到锚点的距离值,这种做法在商业游戏中很常见。
2.阴影
其次,如果我们想要对包含了顶点动画的物体添加阴影, 那么如果仍然像9.4 节中那样使用内置的Diffuse 等包含的阴影Pass 来渲染,就得不到正确的阴影效果(这里指的是无法向其他物体正确地投射阴影〉。这是因为,我们讲过Unity 的阴影绘制需要调用一个ShadowCaster Pass ,而如果直接使用这些内置的ShadowCaster Pass,这个Pass 中并没有进行相关的顶点动画, 因此Unity会仍然按照原来的顶点位置来计算阴影,这并不是我们希望看到的。这时,我们就需要提供一个自定义的ShadowCaster Pass, 在这个Pass 中,我们将进行同样的顶点变换过程。需要注意的是,在前面的实现中,如果涉及半透明物体我们都把Fall back 设置成了Transparent/VertexLit,而Transparent/VertexLit 没有定义ShadowCaster Pass , 因此也就不会产生阴影(详见9.4.5 节〉。
在本书资源的Scene 11_3_3 场景中,我们给出了计算顶点动画的阴影的一个例子。在这个例子中,我们使用了11.3.1 节中的大部分代码,模拟一个波动的水流。同时,我们开启了场景中平行光的阴影效果,并添加了一个平面来接收来自“水流”的阴影。我们还把这个Unity Shader 的Fall back 设置为了内置的VertexLit ,这样Unity 将根据Fallback 最终找到VertexLit 中的ShadowCaster Pass 来渲染阴影。图11.7 给出了这样的结果。
图11.7 当进行顶点动画时,如果仍然使用内置的ShadowCaster Pass来渲染阴影,可能会得到错误的阴影效果
可以看出, 此时虽然Water 模型发生了形变,但它的阴影并没有产生相应的动画效果。为了正确绘制变形对象的阴影, 我们就需要提供自定义的ShadowCaster Pass。读者可以在本书资源的Chapter11-VertexAnimation WithShadow 中找到对应的Unity Shader。使用该Shader得到的阴影效果如图11.8 所示。
图11.8 使用自定义的ShadowCaster Pass为变形物体绘制正确的阴影
在这个Shader 中,我们提供了一个ShadowCaster Pass , 相关代码如下:
// Pass to render object as a shadow casterPass { Tags { "LightMode" = "ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include "UnityCG.cginc" float _Magnitude; float _Frequency; float _InvWaveLength; float _Speed; struct v2f { V2F_SHADOW_CASTER; }; v2f vert(appdata_base 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; v.vertex = v.vertex + offset; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 frag(v2f i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG}}FallBack "VertexLit"
阴影投射的重点在于我们需要按正常Pass 的处理来剔除片元或进行顶点动画,以便阴影可以和物体正常渲染的结果相匹配。在自定义的阴影投射的Pass 中,我们通常会使用Unity 提供的内置宏V2F_SHADOW_CASTER 、
TRANSFER_SHADOW_CASTER_NORMALOFFSET ( 旧版本中会使用TRANSFER_SHADOW_CASTER )和SHADOW_CAST_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即可。在15.1 节中,我们还会看到如何在阴影投射的Pass 中剔除片元,以实现自定义的透明度测试效果。 |