US3S9L6——顶点动画的注意事项

顶点动画的注意事项 —— 批处理相关

在之前的顶点动画相关课程中一再强调,我们需要在渲染标签中添加 "DisableBatching"="True"
来让该 Shader 渲染的对象不进行批处理,目的是让基于模型空间的计算能够正确进行,不会影响最终的渲染结果

为什么批处理会影响顶点动画

Unity 中默认有静态批处理和动态批处理,批处理的主要作用是 合并多个对象,将他们作为一个 DrawCall 进行处理
之所以批处理会对顶点动画带来影响,是因为:不同的对象会拥有不同的变换矩阵(位置、旋转、缩放)
而批处理后,他们的变换矩阵会进行统一处理:

举例:

  • 物体A:位于世界空间位置 (0, 0, 0),无旋转。
  • 物体B:位于世界空间位置 (5, 0, 0),无旋转。

他们是两个独立的对象,拥有不同的变换矩阵,于是批处理与否带来的区别就是:

  • 不进行批处理时:

    每个对象的变换矩阵会单独传递给 Shader,顶点的模型空间位置会根据各自的变换进行正确计算

  • 进行批处理时:

    启用批处理后,Unity 会将对象 A 和对象 B 合并为一个 Draw Call,并使用一个统一的变换矩阵
    比如在静态批处理中,Unity 会将对象 A 和对象 B 的顶点合并为一个网格,并使用统一的变换进行渲染

批处理后顶点位置是混合的,Shader 中无法区分不同对象的模型空间位置,因此可能带来的问题有:

  • 顶点动画失效:

    假设你希望顶点在模型空间的 x 方向上进行 sin​ 波动动画。
    如果对象 A 和对象 B 的模型空间位置被混合,波动动画会变得不可预测

  • 变换混淆:

    对象 A 和对象 B 有不同的变换矩阵。
    如果批处理后使用统一的变换矩阵,Shader 无法区分每个顶点属于哪个对象,导致所有顶点的动画效果混淆

总结:批处理会让对象失去独立性,相当于将多个对象之间独立的模型空间坐标系合并为一个坐标系
从而影响顶点的相对位置和变换矩阵等信息,导致顶点动画结果异常,因此我们通过渲染标签来关闭批处理

关闭批处理带来的问题

关闭批处理带来的最直接问题就是导致:

  • DrawCall的提升
  • DrawCall的提升可能会带来性能问题

如果 DrawCall 的增加并没有带来性能问题,那我们可以通过关闭批处理来解决顶点动画问题

如果带来了性能问题,并且必须优化带有顶点动画的Shader,我们应该如何解决呢?

如何解决关闭批处理带来的问题

首先 Shader 内需要开启批处理,然后采取如下的几种方案

  1. 顶点颜色

    利用顶点颜色来存储每个顶点的位置信息或相对位置信息,
    我们在 C# 代码中获取模型网格顶点数据,将数据存储到网格的颜色属性中
    在 Shader 中通过颜色属性获取顶点信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    void Start()
    {
    // 获取网格组件
    MeshFilter meshFilter = GetComponent<MeshFilter>();
    if (meshFilter != null)
    {
    // 通过网格组件,即可得到网格信息,进而得到所有的顶点信息,这些顶点信息可以存储到颜色数组内
    Mesh mesh = meshFilter.mesh;
    Vector3[] vertices = mesh.vertices;
    Color[] colors = new Color[vertices.Length];
    for (int i = 0; i < vertices.Length; i++)
    {
    // 将模型空间位置存储在顶点颜色中
    colors[i] = new Color(vertices[i].x, vertices[i].y, vertices[i].z, 1);
    }
    // 将颜色数组赋值到网格的颜色信息中即可
    mesh.colors = colors;
    }
    }

    之后在 Shader 中,直接在 appdata_full​ 结构体中点出颜色成员 color,即可利用它获取到顶点信息

    1
    2
    3
    4
    5
    6
    v2f vert(appdata_full v)
    {
    v2f o;
    float4 vertex = v.color;
    // ...
    }
  2. UV通道

    和上面的顶点颜色方案类似,只是把相关信息存储到 UV 通道中而已,
    但是一个 UV 通道只能存储 Vector2[]​ 的值,因此可能会使用两个 UV 通道,
    若使用两个 UV 通道,而第二个 UV 通道会只使用 Vector2[]​ 的一个分量,因此 UV 通道的方法一般在存储两个值时使用
    注意,Mesh​ 内可以点出多个 UV 通道,其中默认的 UV 通道 mesh.uv 不可使用,其他的 UV 通道只要未被占用就都可以使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    void Start()
    {
    // 获取网格组件
    MeshFilter meshFilter = GetComponent<MeshFilter>();
    if (meshFilter != null)
    {
    // 通过网格组件,即可得到网格信息,进而得到所有的顶点信息,这些顶点信息可以存储到Vector2数组内
    Mesh mesh = meshFilter.mesh;
    Vector3[] vertices = mesh.vertices;
    Vector2[] uvData1 = new Vector2[vertices.Length];
    Vector2[] uvData2 = new Vector2[vertices.Length];
    for (int i = 0; i < vertices.Length; i++)
    {
    // 将模型空间位置存储在Vector2s数据内中
    uvData1[i] = new Vector2(vertices[i].x, vertices[i].y);
    uvData2[i] = new Vector2(vertices[i].z, 1);
    }
    // 将两个Vector2数组赋值到网格的其他UV通道内即可(只要此UV通道未被占用即可使用,不能使用第一个通道)
    mesh.uv2 = new Vector2[uvData1.Length];
    mesh.uv3 = new Vector2[uvData2.Length];
    }
    }

    之后在 Shader 中,直接在 appdata_full 结构体中点出对应的 uv 通道,即可利用它获取到顶点信息

    1
    2
    3
    4
    5
    6
    v2f vert(appdata_full v)
    {
    v2f o;
    float4 vertex = float4(v.texcoord1.xy, v.texcoord2.xy);
    // ...
    }

等等

顶点动画的注意事项 —— 阴影相关

  • 如何让对象投射阴影,详见:US3S6L3——让物体投射阴影
  • 透明度混合物体投射阴影相关,详见:US3S6L8——透明度混合物体阴影实现

顶点动画物体投射阴影

我们可以为有顶点动画的物体,使用 LightMode​(灯光模式)为 ShadowCaster​(阴影投射)的 Pass​(渲染通道),这样它便能投射阴影
但是如果我们直接使用内置的这种 Pass​(默认 Shader 中的,通过 FallBack​ 寻找到的)投射的阴影会是不正确的,
因为默认 Pass 当中并不会使用新的顶点位置来投射,而是按照模型原来的顶点位置来计算阴影的,举例:

  1. 新建一个 Shader,复用流动的2D河流的 Shader 代码

    具体详见:US3S9L4——顶点波动效果——流动的河流

  2. 为其加上一个不透明的 FallBack Shader 比如 VertexLit

    1
    2
    3
    4
    5
    6
    7
    Shader "TeachShader/Lesson97"
    {
    Properties {/*...*/}
    SubShader {/*...*/}

    Fallback "VertexLit"
    }
  3. MeshRenderer 中开启双面投射阴影

    image

显示效果:

image

可见,这时我们使用该 Shader 投射出来的阴影是没有经过顶点动画变化的模型阴影

让顶点动画物体投射正确的阴影

想要让带有顶点动画的对象产生正确的阴影,我们需要自定义 投射阴影的 Pass(渲染通道),在其中加入对顶点的变换计算即可

我们需要自定义一个 LightMode​(灯光模式)为 ShadowCaster​(阴影投射)的 Pass(渲染通道),在顶点着色器函数中进行顶点相关的计算

  1. 基于上文创建的 Shader ,再复用基础阴影投射渲染通道代码中自行实现的阴影投射的Pass

    代码详见:US3S6L3——让物体投射阴影 的 让物体投射阴影的部分

    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
    // ShadowCaster Pass 投影阴影通道
    pass
    {
    Tags { "LightMode" = "ShadowCaster" }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    // 告诉Unity编译器生成多个着色器变体,用于支持不同类型的阴影(SM,SSSM等等),确保着色器能够在所有可能的阴影投射模式下正确渲染
    #pragma multi_compile_shadowcaster

    #include "UnityCG.cginc"

    struct v2f
    {
    V2F_SHADOW_CASTER; // 顶点到片元着色器阴影投射结构体数据宏,定义了一些标准的成员变量,这些变量用于在阴影投射路径中传递顶点数据到片元着色器
    };

    v2f vert(appdata_base v)
    {
    v2f data;
    TRANSFER_SHADOW_CASTER_NORMALOFFSET(data); // 转移阴影投射器法线偏移宏,用于在顶点着色器中计算和传递阴影投射所需的变量
    return data;
    }

    fixed4 frag(v2f i) : SV_Target
    {
    SHADOW_CASTER_FRAGMENT(i); //阴影投射片元宏,将深度值写入到阴影映射纹理中
    }
    ENDCG
    }
  2. 在该 Pass 中加入 波形频率、波长的倒数、波形幅度 属性的映射

  3. 在该 Pass 中的顶点着色器函数中 加入顶点的偏移计算(直接复制前面的代码)

  4. 直接对模型空间中顶点进行偏移,不用进行裁剪坐标空间变换以及 UV 相关计算

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    float _WaveAmplitude;
    float _WaveFrequency;
    float _InvWaveLength;

    struct v2f
    {
    V2F_SHADOW_CASTER; // 顶点到片元着色器阴影投射结构体数据宏,定义了一些标准的成员变量,这些变量用于在阴影投射路径中传递顶点数据到片元着色器
    };

    v2f vert(appdata_base v)
    {
    v2f data;
    // 模型空间下的偏移计算,假设此模型的横轴是Z轴,纵轴是Y轴,因此需要沿着模型的Z轴去偏移顶点Y轴上的位置
    float4 offset;
    offset.x = sin(_Time.y * _WaveFrequency + v.vertex.z * _InvWaveLength) * _WaveAmplitude;
    offset.yzw = float3(0, 0, 0);
    v.vertex += offset; // 由于下面的语句会自动执行裁剪空间的转换,因此这里只需要去偏移顶点即可
    TRANSFER_SHADOW_CASTER_NORMALOFFSET(data); // 转移阴影投射器法线偏移宏,用于在顶点着色器中计算和传递阴影投射所需的变量
    return data;
    }

显示效果:

image

可见,通过自定义投影阴影通道的逻辑,这时我们使用该 Shader 投射出来的阴影就是经过顶点动画变化的模型阴影

完整 Shader 如下:

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
Shader "TeachShader/Lesson97"
{
Properties
{
_MainTex("Texture", 2D) = "white" {} // 主纹理
_Color("Color", color) = (1, 1, 1, 1) // 叠加颜色
_WaveAmplitude("WaveAmplitude", Float) = 1 // 波动幅度
_WaveFrequency("WaveFrequency", Float) = 1 // 波动频率
_InvWaveLength("InvWaveLength", Float) = 1 // 波长倒数
_Speed("Speed", Float) = 1 // 纹理变化速度
}

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

Pass
{
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

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

sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
float _WaveAmplitude;
float _WaveFrequency;
float _InvWaveLength;
float _Speed;

v2f vert (appdata_base v)
{
v2f o;
// 模型空间下的偏移计算,假设此模型的横轴是Z轴,纵轴是Y轴,因此需要沿着模型的Z轴去偏移顶点Y轴上的位置
float4 offset;
offset.x = sin(_Time.y * _WaveFrequency + v.vertex.z * _InvWaveLength) * _WaveAmplitude;
offset.yzw = float3(0, 0, 0);
o.vertex = UnityObjectToClipPos(v.vertex + offset);
// 计算uv坐标
o.uv = v.texcoord * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv += float2(0, _Time.y * _Speed);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 从纹理采样并叠加颜色
fixed4 color = tex2D(_MainTex, i.uv);
color.rgb *= _Color.rgb;
return color;
}
ENDCG
}

// ShadowCaster Pass 投影阴影通道
pass
{
Tags { "LightMode" = "ShadowCaster" }

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// 告诉Unity编译器生成多个着色器变体,用于支持不同类型的阴影(SM,SSSM等等),确保着色器能够在所有可能的阴影投射模式下正确渲染
#pragma multi_compile_shadowcaster

#include "UnityCG.cginc"

float _WaveAmplitude;
float _WaveFrequency;
float _InvWaveLength;

struct v2f
{
V2F_SHADOW_CASTER; // 顶点到片元着色器阴影投射结构体数据宏,定义了一些标准的成员变量,这些变量用于在阴影投射路径中传递顶点数据到片元着色器
};

v2f vert(appdata_base v)
{
v2f data;
// 模型空间下的偏移计算,假设此模型的横轴是Z轴,纵轴是Y轴,因此需要沿着模型的Z轴去偏移顶点Y轴上的位置
float4 offset;
offset.x = sin(_Time.y * _WaveFrequency + v.vertex.z * _InvWaveLength) * _WaveAmplitude;
offset.yzw = float3(0, 0, 0);
v.vertex += offset; // 由于下面的语句会自动执行裁剪空间的转换,因此这里只需要去偏移顶点即可
TRANSFER_SHADOW_CASTER_NORMALOFFSET(data); // 转移阴影投射器法线偏移宏,用于在顶点着色器中计算和传递阴影投射所需的变量
return data;
}

fixed4 frag(v2f i) : SV_Target
{
SHADOW_CASTER_FRAGMENT(i); //阴影投射片元宏,将深度值写入到阴影映射纹理中
}
ENDCG
}
}

Fallback "VertexLit"
}