Back
Featured image of post Shader入门精要-画面动起来

Shader入门精要-画面动起来

纹理动画,顶点动画

技术美术——动画

Unity Shader 提供了一系列关于时间的内置变量,可以方便地在 Shader 中访问,实现各种动画效果。下表给出了这些内置的时间变量。

名称类型描述
_Timefloat4t 是自该场景加载开始所经过的时间,4 个分量的值分别是 ( t/20, t, 1t, 3t)
_SinTimefloat4t 是时间的正弦值,4 个分量的值分别是 ( t/8, t/4, t/2, t)
_CosTimefloat4t 是时间的余弦值,4 个分量的值分别是 ( t/8, t/4, t/2, t)
unity_DeltaTimefloat4dt 是时间增量,4 个分量的值分别是 ( dt, 1/dt, smoothDt, 1/smoothDt)

纹理动画

纹理动画在游戏中应该非常广泛,尤其是在资源比较局限的移动平台上,往往会使用纹理动画来代替复杂的粒子系统等模拟的各种动画

序列帧动画

做序列帧动画,我们先要提供一张包含了关键帧图像的图像。例如:8 X 8 的关键帧图像,他们的大小相同,而且播放顺序从左到右、从上到下。

在该着色器的实现上我们需要注意以下几个步骤:

  1. 声明四个属性,_MainTex 是包含所有关键帧的纹理,_HorizontalAmount_VerticalAmount 分别代表了该图像在水平方向和垂直方向上包含的关键帧图像个数,_Speed 用于控制序列帧动画的播放速度。
  2. 在顶点着色器中进行基本的顶点变换,并把顶点纹理坐标存储到结构体中。我们通过 TRANSFORM_TEX 函数来获取初始纹理坐标。
  3. 在片元着色器中,我们使用 _Time.y 函数和速度属性 _Speed 进行相乘来模拟时间,然后使用 floor 函数对结果取整来得到整数时间。
  4. 使用时间除以 _HorizontalAmount 来得到当前时间下对应的 行索引 ,而 列索引 则为余数。
  5. 后面使用行列索引构建采样坐标。在计算竖直的偏移值时需要注意:由于 Unity 中纹理坐标竖直方向的顺序(从下到上逐渐增大)和序列帧纹理中的顺序(播放顺序从上到下)时相反的,所以计算竖直方向偏移量时要使用减法。
Shader "Unlit/ImgAni"
{
    Properties
    {
        _Color("Color", Color) = (1,1,1,1)
        _MainTex ("MainTex", 2D) = "white" {}
        _HorizontalCount("Horizontal Count", Float) = 4
        _VerticalCount("Vertical Count", Float) = 4
        _Speed("Speed", Float) = 1.0
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" }

        Pass
        {
            Tags {"LightMode"="ForwardBase"}

            ZWrite Off

            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            float _HorizontalCount;
            float _VerticalCount;
            float _Speed;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            v2f vert (appdata 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 / _HorizontalCount);
                float column = time - row * _HorizontalCount;

                half2 uv = i.uv + half2(column, -row);
                uv.x /= _HorizontalCount;
                uv.y /= _VerticalCount;

                fixed4 c = tex2D(_MainTex, uv);
                c.rgb *= _Color;

                return c;
            }
            ENDCG
        }
    }
    Fallback "Transparent/VertexLit"
}

滚动背景

很多 2D 游戏都使用了不断滚动的背景来模拟游戏角色在场景中的穿梭,这些背景往往包含了多个层来模拟一种视觉效果。而这些背景的实现往往就是利用了纹理动画。我们将实现一个包含了两层的无限滚动的2D游戏背景。

Shader "Unlit/ScrollBackGround"
{
    Properties
    {
        _MainTex ("MainTex", 2D) = "white" {}
        _SecTex ("DetailTex", 2D) = "white" {}
        _SpeedMain("Main Speed", Float) = 1
        _SpeedSec("Second Speed", Float) = 1
        _Multiplier("Layer Multiplier", Float) = 1
        _Alpha("Alpha", Range(0.0, 1)) = 0.5
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" }
        
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _SecTex;
            float4 _SecTex_ST;
            float _SpeedMain;
            float _SpeedSec;
            float _Multiplier;
            float _Alpha;

            struct appdata
            {
                float4 vertex : POSITION;
                float4 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);

                o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw + frac(float2(_SpeedMain, 0.0) * _Time.y);
                o.uv.zw = v.texcoord.xy * _SecTex_ST.xy + _SecTex_ST.zw + frac(float2(_SpeedSec, 0.0) * _Time.y);

                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                half4 firstLayer = tex2D(_MainTex, i.uv.xy);
                half4 secondLayer = tex2D(_SecTex, i.uv.zw);

                half4 c = lerp(firstLayer, secondLayer, secondLayer.a);
                c.rgb *= _Multiplier;

                return c;
            }
            ENDCG
        }
    }
    Fallback "VertexLit"
}

顶点动画

流动的河流

该效果的原理是使用正弦函数来模拟水流的波动效果,在顶点着色器中使用正弦值对顶点坐标进行偏移得到对纹理扭曲效果。

‼ 由于Unity的 批处理 会合并所有相关的模型,所以模型各自的 模型空间 就会丢失,但我们使用的顶点偏移需要在物体的模型空间下进行偏移。所以我们需要声明一个标签—— DisableBatching ,告诉 Unity 需要取消对该 Shader 的批处理操作。

Shader "Unlit/RiverAni"
{
    Properties
    {
        _Color("Color", Color) = (1,1,1,1)
        _MainTex ("MainTex", 2D) = "white" {}
        _Magnitude("Magnitude", Float) = 1
        _Frequency("Frequency", Float) = 1
        _InWaveLength("Wave Length", Float) = 10
        _Speed("Speed", Float) = 0.5

    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" "DisableBatching"="True"}
        LOD 100

        Pass
        {
            Tags {"LightMode"="ForwardBase"}

            Cull Off
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            float _Magnitude;
            float _Frequency;
            float _InWaveLength;
            float _Speed;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            v2f vert (appdata v)
            {
                v2f o;
                float4 offset;
                offset.yzw = float3(0.0, 0.0, 0.0);
                offset.x = sin(_Frequency * _Time.y // 波动频率
                                + v.vertex.x * _InWaveLength // _InWaveLength 控制波长大小
                                + v.vertex.y * _InWaveLength
                                + v.vertex.z * _InWaveLength) * _Magnitude; // _Magnitud 控制波动大小

                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 col = tex2D(_MainTex, i.uv);
                col.rgb *= _Color.rgb;
                return col;
            }
            ENDCG
        }
    }
    Fallback "Transparent/vertexLit"
}

广告牌技术

另一种常见的顶点动画就是 广告牌技术 。广告牌技术会根据视角方向来旋转一个被纹理着色的多边形(通常就是简单的四边形,这个多边形就是广告牌),是的多边形看起来好像总是面对着摄像机。广告牌技术被用于很多应用,比如渲染烟雾、运毒、闪光效果等。

原理

广告牌技术的本质就是构建旋转矩阵,而我们知道一个变换矩阵需要 3 个 基向量 。除此之外,我们还需要指定一个 锚点 。这个锚点在旋转的过程中是固定不变的,以此来确定多边形在空间中的位置。广告牌技术使用的基向量通常就是:

  1. 表面法线(normal)
  2. 指向上的方向(up)
  3. 指向右的方向(right)

广告牌技术的难点在于,如何根据需要来构建 3 个相互正交的基向量。计算过程通常是,我们首先会通过初始计算得到目标的表面法线(例如就是视角方向)和指向上的方向,而两者往往是不垂直的。但是,两者其中之一是固定的。例如:

  • 当模拟草丛时,我们希望广告牌的指向上的方向永远是(0,1,0),而法线方向应该随视角变化。
  • 当模拟粒子效果时,我们希望广告牌的法线方向是固定的,即总是指向视角方向,指向上的方向则可以发生变化。

计算 (以法线方向固定为视角方向为例)

假设法线方向是固定的,首先,我们根据初始的表面法线和指向上的方向来计算出目标方向的指向右的方向(通过叉积操作):

right = up × normal

对其归一化后,再由法线方向和指向右的方向计算出正交的指向上的方向即可:

up' = normal × right

至此,我们就可以得到用于旋转的 3 个正交基了。下图给出了上述计算过程的图示。如果指向上的方向是固定的,计算过程也是类似的。

实现

  1. 首先我们在着色器中定义一个 _VerticalBillboarding 标签用于调整是固定法线还是固定指向上的方向
  2. 法线方向:将相机坐标转换到模型空间下后减去物体中心
  3. 向右方向:通过法线方向和指向上的方向(0,1,0)叉乘得到
  4. 向上方向:通过叉乘法线和向右方向后归一化得到
  5. 通过上面三个方向旋转正方形
Shader "Unlit/Billboard"
{
    Properties
    {
        _Color("Color", Color) = (1,1,1,1)
        _MainTex ("MainTex", 2D) = "white" {}
        _VeticalBillboarding("Vertical Billboarding", Range(0.0, 1)) = 1

    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" "DisableBatching"="True" }

        Cull Off
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            Tags {"LightMode"="ForwardBase"}

            CGPROGRAM

            #pragma multi_compile_fwdbase
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _Color;
            float _VeticalBillboarding;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            v2f vert (appdata v)
            {
                v2f o;

                float3 center = float3(0,0,0); // 固定锚点
                float3 viewDir = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1));
                float3 normalDir = viewDir - center; // 以视角方向作为法线方向

                normalDir.y = normalDir.y * _VeticalBillboarding;
                normalDir = normalize(normalDir);

                float3 upDir = abs(normalDir.y) > 0.999 ? float3(0,0,1) : float3(0,1,0); // 暂取一个 upDir 方向
                float3 rightDir = normalize(cross(normalDir, upDir)); // 计算向右的方向
                upDir = normalize(cross(normalDir, rightDir));        // 计算向上的方向

                // 以锚点位置和新的基向量重新计算顶点位置
                float3 centerOff = v.vertex.xyz - center;
                float3 localPos = center + rightDir * centerOff.x + upDir * centerOff.y + normalDir * centerOff.z;

                o.pos = UnityObjectToClipPos(float4(localPos, 1.0));
                o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;

                return o;
            }

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

                return col;
            }
            ENDCG
        }
    }
    Fallback "Transparent/VertexLit"
}

顶点动画的阴影

问题:

原因:

因为 Unity 的阴影绘制需要调用一个 ShadowCaster Pass ,而如果直接使用这些内置的 ShadowCasterPass ,这个 Pass 中并没有进行相关的顶点动画。

解决方法:

在 Unity 中自定义的 ShadowCaster Pass ,这个 Pass 中,我们将进行统一的顶点变换过程。需要注意的是,在前面的实现中,如果涉及半透明物体我们都把 Fallback 设置成了 Transparent/VertexLit ,而 Transparent/VertexLit 没有定义 ShadowCaster Pass ,因此也就不会产生阴影。需要注意的是,我们在 ShadowCaster Pass 采用的 渲染通道编译指令 都与之前 标准光照着色器 中采用的有所不同,所以对应的 阴影内置宏 也不一样。

Shader "Unity Shaders Book/Chapter 11/Vertex Animation With Shadow"
{
    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
    {
        // 		禁用批处理	=	True
        Tags { "DisableBatching" = "True" }
        
        // 该Pass同流动效果Shader中的Pass
        Pass
        {
            Tags { "LightMode" = "ForwardBase" }
            
            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
            
        }
        
        // 以阴影投射的方式渲染物体的Pass
        Pass
        {
            //		渲染通道 = 阴影投射
            Tags { "LightMode" = "ShadowCaster" }
            
            CGPROGRAM
            
            #pragma vertex vert
            #pragma fragment frag
            
            // 编译指令会保证Unity可以为相应类型的光照Pass生成所需的Shader变种
            // 注意该指令与之前光照渲染中Pass中的"multi_compile_fwdbase"有所不同
            #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"
}

补充说明

  1. 在之前看到的那样,如果我们在模型空间下进行了一些顶点动画,那么 批处理 往往就会破坏这种动画效果。这时,我们可以通过 SubShaderDisableBatching 标签来强制取消对该 Unity Shader 的 批处理 。然而,取消批处理会带来一定的性能下降,增加了 Draw Call ,因此我们应该尽量避免使用模型空间下的一些绝对位置和方向来进行计算。
  2. 在广告牌的例子中,为了避免显示使用模型空间的中心来作为锚点,我们可以利用顶点颜色来存储每个顶点到锚点的距离值,这种做法在商业游戏中很常见。

总结

纹理动画 中主要是对贴图的 UV 进行操作。

顶点动画 中是对顶点进行编辑,讲到了两个技术:流动的河流 和 广告牌技术。

顶点动画的阴影 中需要根据顶点动画自定义 ShadowCaster Pass