US3S11L4——利用深度纹理实现运动模糊效果

知识回顾 运动模糊效果

运动模糊效果,是一种 用于模拟真实世界中快速移动物体产生的模糊现象 的图像处理技术。
一般有两种常用方式:

  1. 累积缓存:物体快速移动时存储多帧图像信息,取它们之间的加权平均值作为最后的运动模糊图像

    • 优点:质量高、效果好
    • 缺点:计算量大,存储开销大
  2. 速度缓存:物体快速移动时存储多帧运动速度信息,利用速度来决定模糊的方向和大小

    • 优点:性能较累积缓存好
    • 缺点:效果较差,可能产生重影和伪影

我们之前学习的方式是基于累积缓存的,但是并没有存储多张场景信息,而是采用一张渲染纹理 RenderTexture​ 保存之前的渲染结果,
不断把当前渲染图像叠加到之前的渲染图像中,从而产生一种运动轨迹视觉效果

详见:US3S10L9——基于累积缓存的运动模糊

基于速度缓存的运动模糊效果

这节课是利用深度纹理实现运动模糊,它其实就是基于我们之前提到的 速度缓存 的方式来进行。
但是和我们之前学习的运动模糊效果一样,我们会基于速度缓存基本规则进行修改,即:

只需要用当前帧位置和上一帧位置进行计算,得到位置差,从而得到该像素的速度矢量。想要得到位置差,我们可以利用深度纹理中的信息来进行计算。

利用 深度纹理实现运动模糊效果 的这种方式有以下两点需要注意:

  1. 这种实现方式 只适合 场景静止,即摄像机快速移动的情况

    它不太适用于物体快速移动产生的运动模糊效果,只有摄像机移动时才能看到运动模糊效果

  2. 这种实现方式 并不是基于真实的物理运动规律来计算的,只是一种近似计算!

    它符合图形学基本规则:看起来对那么就是对的

虽然它使用上有局限性,并且实现上也不符合物理计算规则,但是由于它实现出来的效果和性能消耗都还不错,因此它也是一种常用的运动模糊处理方案。

利用深度纹理实现运动模糊基本原理

首先,一句话描述它的基本原理:得到像素当前帧和上一帧中在裁剪空间下的位置,利用两个位置计算出物体的运动方向,从而模拟出运动模糊的效果。

其中的关键点:

  1. 如何得到像素当前帧和上一帧在裁剪空间下的位置
  2. 如何得到运动方向
  3. 如何模拟运动模糊效果

如何得到像素当前帧和上一帧在裁剪空间下的位置

关键步骤:

  1. 利用 UV 坐标和深度值组合成一个裁剪空间下的组合坐标 nowClipPosnowClipPos

    image

    1
    float4 nowClipPos = float4(uv.x, uv.y, depth, 1);

    我们知道 UV 坐标空间下的值是 0~1,通过宏取出来的深度值也是0~1,而裁剪空间下的坐标范围是-1~1
    因此,我们需要将它利用简单的公式转换到裁剪空间坐标系下:

    1
    float4 nowClipPos = float4(uv.x * 2 - 1, uv.y * 2 - 1, depth * 2 - 1, 1);

    可以认为这一步是把带有深度值的 UV 坐标转换到了裁剪空间下(范围从 0~1 转换到 -1~1 之间),
    之所以加入深度值是为了让之后的裁剪空间转到世界空间或其他坐标空间更合理,相当于是把 UV 的 2D 坐标系转换到了 3D 坐标系中

  2. 利用 这一帧 世界空间 —> 裁剪空间 的变换矩阵 nowMnowM 的逆矩阵 nowM1nowM^{−1}

    将刚才裁剪空间下的组合坐标 nowClipPosnowClipPos 转换到世界空间中

    image

    C# 代码中获取空间变换矩阵的方式(逆矩阵的几何性质之一就是可以用它来进行逆向变换):

    1
    2
    3
    4
    5
    6
    camera.projectionMatrix;        // 相机投影矩阵到裁剪空间的变换矩阵
    camera.worldToCameraMatrix; // 世界空间到观察空间的变换矩阵
    //世界空间 到 裁剪空间的变换矩阵
    Matrix4x4 worldToClipMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
    //裁剪空间 到 世界空间的变换矩阵 (是worldToClipMatrix的逆矩阵)
    Matrix4x4 clipToWorldMatrix = worldToclipMatrix.inverse;

    我们只需要利用上面的这个裁剪空间到世界空间的变换矩阵,就可以将刚才得到的裁剪空间下的组合坐标变换到世界空间下了。

  3. 再利用 上一帧的 世界空间 —> 裁剪空间 的变换矩阵 oldMoldM 将上一步得到的世界空间的坐标进行转换

    即可得到上一帧该世界空间下的组合坐标 oldClipPosoldClipPos 在裁剪空间下的位置

    image

如何得到运动方向

我们已经知道如何得到像素当前帧和上一帧在裁剪空间下的位置,直接用 当前位置.xy – 上一帧位置.xy​ 便可以得到移动方向

imageimage

如何模拟运动模糊效果

有了像素在裁剪空间的移动方向,相当于知道了像素在“UV纹理空间的移动方向”,
那么我们只需要利用这个方向在纹理中进行多次 UV 坐标偏移采样后,将得到的颜色累加起来,最后进行算数平均值计算即可
我们会加入一个 模糊偏移量 来控制模糊程度,只需要在每次采样时进行 方向 * 模糊偏移量​ 的偏移采样即可

1
2
3
4
5
6
7
8
9
10
11
12
float2 uv = i.uv;                        // uv初始值
float4 c = float4(0,0,0,0); // 用于累加的颜色变量
// for循环内进行3次采样,每次采样结束后进行一次偏移,3次采样的uv坐标对应为:
// 第一次:uv
// 第二次:uv + 移动方向 * 模糊偏移量
// 第三次:uv + 2 * 移动方向 * 模糊偏移量
for (int it = 0; it < 3; it++)
{
c += tex2D(_MainTex, uv); // 颜色累加
uv += 移动方向 + 模糊偏移量; // uv偏移
}
c /= 3; // 计算颜色平均值

利用深度纹理实现运动模糊具体实现

在C#中为Shader内直接设置Shader变量

我们需要通过 C# 代码为 Shader 设置矩阵变量,但是 ShaderLab 语法中的属性中并没有矩阵类型的变量,
因此我们只需要在 CG 语句中声明矩阵属性即可,这样 C# 中通过矩阵的属性名同样可以进行设置
举例:

  • CG 代码中声明 4*4 矩阵 —— float4x4 _ClipToWorldMatrix;
  • C# 代码中声明 4*4 矩阵 —— Matrix4x4 frontClipToWorldMatrix;

通过材质球指明变量使用 SetMatrix​ 进行设置即可:

1
2
3
4
5
6
7
public Material material;
private Matrix4x4 frontClipToWorldMatrix;

void Start()
{
material.SetMatrix("_ClipToWorldMatrix", frontClipToWorldMatrix);
}

实现 利用深度纹理实现运动模糊屏幕后期处理效果 对应 Shader

  1. 新建 Shader 文件,取名 MotionBlurWithDepthTexture​,即深度纹理运动模糊效果

  2. 声明属性,进行属性映射

    • 主纹理 _MainTex
    • 模糊偏移量 _BlurSize
    • 深度纹理 _CameraDepthTexture​(不需要声明对应 ShaderLab 属性)
    • 当前帧裁剪到世界空间变换矩阵 float4x4 _ClipToWorldMatrix​(不需要声明对应 ShaderLab 属性)
    • 上一帧世界到裁剪空间变换矩阵 float4x4 _FrontWorldToClipMatrix​(不需要声明对应 ShaderLab 属性)
    1
    2
    3
    4
    5
    Properties
    {
    _MainTex("Texture", 2D) = "white"{}
    _BlurSize("BlurSize" Float) = 0.5 // 用于控制模糊程度的模糊偏移量
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    #include "UnityCG.cginc"

    sampler2D _MainTex; // 屏幕主纹理
    float4 _MainTex_ST;
    fixed _BlurSize; // 用于控制模糊程度的模糊偏移量
    sampler2D _CameraDepthTexture; // 深度纹理
    float4x4 _ClipToWorldMatrix; // 裁剪空间到世界空间的变换矩阵
    float4x4 _FrontWorldToClipMatrix; // 上一帧裁剪空间到世界空间的变换矩阵
    //...
    ENDCG
  3. 屏幕后处理标配设置

    ZTest Always​、Cull Off​、ZWrite Off

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Pass
    {
    ZTest Always
    Cull Off
    ZWrite Off

    CGPROGRAM
    /*...*/
    ENDCG
    }
  4. 结构体

    顶点和 UV 坐标

    1
    2
    3
    4
    5
    struct v2f
    {
    float2 uv : TEXCOORD0;
    float4 vertex : SV_POSITION;
    };
  5. 顶点着色器

    坐标转换,UV 坐标赋值

    1
    2
    3
    4
    5
    6
    7
    v2f vert (appdata_base v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord;
    return o;
    }
  6. 片元着色器

    1. 得到裁剪空间下的两个点

      • 得到点一:深度值获取,构建裁剪空间下组合坐标 uv 和 深度
      • 得到点二:裁剪空间坐标转世界空间(注意进行齐次除法),利用上一帧变换矩阵将世界空间坐标转裁剪空间(注意进行齐次除法)
    2. 得到运动方向

      当前帧点 - 上一帧点​ 得到运动方向

    3. 进行模糊处理

      利用模糊偏移量变量进行 3 次偏移采样颜色后进行平均值计算

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    fixed4 frag (v2f i) : SV_Target
    {
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); // 采集深度值
    // 将UV坐标和深度值作为组合坐标,并从0~1范围扩大到-1~1范围
    float4 nowClipPos = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1);
    float4 worldPos = mul(_ClipToWorldMatrix, nowClipPos); // 计算世界空间下的点
    worldPos /= worldPos.w; // 透视除法,保证w分量为1,为标准坐标
    float4 oldClipPos = mul(_FrontWorldToClipMatrix, worldPos); // 计算上一帧的裁剪空间下的点
    oldClipPos /= worldPos.w; // 透视除法,保证w分量为1,为标准坐标
    float2 moveDir = nowClipPos.xy - oldClipPos.xy; // 得到运动方向
    // 进行两次偏移采样叠加到原颜色上,取平均,以达到模糊效果
    float2 uv = i.uv;
    fixed4 color = float4(0, 0, 0, 0);
    for (int index = 0; index < 3; index++)
    {
    color += tex2D(_MainTex, uv);
    uv += moveDir * _BlurSize; // 每次循环就偏移采样坐标,叠加偏移后的颜色
    }
    color /= 3;

    return fixed4(color.rgb, 1);
    }
  7. FallBack Off

其他注意点

  1. 考虑不同平台可能存在的垂直翻转问题

    1
    2
    3
    4
    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
    o.uv_depth.y = 1 - o.uv_depth.y;
    #endif
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    struct v2f
    {
    float2 uv : TEXCOORD0;
    float2 uv_depth : TEXCOORD1;
    float4 vertex : SV_POSITION;
    };

    sampler2D _MainTex; // 屏幕主纹理
    float4 _MainTex_ST;
    float4 _MainTex_TexelSize; // 屏幕主纹理纹素

    v2f vert (appdata_base v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord;
    o.uv_depth = v.texcoord;
    // 多平台时需要判断纹理是否翻转
    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
    o.uv_depth.y = 1 - o.uv_depth.y;
    #endif
    return o;
    }
  2. 让移动方向向量除以 2

    从而降低运动模糊效果的强度,不要过于强烈

    1
    float2 moveDir = (nowClipPos.xy - oldClipPos.xy) / 2;           // 得到运动方向

完整 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
Shader "PostEffect/MotionBlurWithDepthTexture"
{
Properties
{
_MainTex("Texture", 2D) = "white"{}
_BlurSize("BlurSize", Float) = 0.5 // 用于控制模糊程度的模糊偏移量
}

SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

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

sampler2D _MainTex; // 屏幕主纹理
float4 _MainTex_ST;
float4 _MainTex_TexelSize; // 屏幕主纹理纹素
fixed _BlurSize; // 用于控制模糊程度的模糊偏移量
sampler2D _CameraDepthTexture; // 深度纹理
float4x4 _ClipToWorldMatrix; // 裁剪空间到世界空间的变换矩阵
float4x4 _FrontWorldToClipMatrix; // 上一帧裁剪空间到世界空间的变换矩阵

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
// 多平台时需要判断纹理是否翻转
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv_depth.y = 1 - o.uv_depth.y;
#endif
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); // 采集深度值
// 将UV坐标和深度值作为组合坐标,并从0~1范围扩大到-1~1范围
float4 nowClipPos = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1);
float4 worldPos = mul(_ClipToWorldMatrix, nowClipPos); // 计算世界空间下的点
worldPos /= worldPos.w; // 透视除法,保证w分量为1,为标准坐标
float4 oldClipPos = mul(_FrontWorldToClipMatrix, worldPos); // 计算上一帧的裁剪空间下的点
oldClipPos /= oldClipPos.w; // 透视除法,保证w分量为1,为标准坐标
float2 moveDir = (nowClipPos.xy - oldClipPos.xy) / 2; // 得到运动方向
// 进行两次偏移采样叠加到原颜色上,取平均,以达到模糊效果
float2 uv = i.uv;
fixed4 color = float4(0, 0, 0, 0);
for (int index = 0; index < 3; index++)
{
color += tex2D(_MainTex, uv);
uv += moveDir * _BlurSize; // 每次循环就偏移采样坐标,叠加偏移后的颜色
}
color /= 3;

return fixed4(color.rgb, 1);
}
ENDCG
}
}

Fallback Off
}

实现 利用深度纹理实现运动模糊屏幕后期处理效果 对应 C# 脚本

  1. 创建 C# 代码,命名和Shader一样

  2. 继承屏幕后处理基类 PostEffect

    具体代码详见:US3S10L4——屏幕后处理基类

  3. 声明模糊偏移量变量和用于记录上一次变换矩阵的变量

  4. 重写 OnRenderImage​ 函数

    在其中进行属性设置,变换矩阵计算,屏幕后处理

  5. 在生命周期函数中启用深度纹理,初始化上一帧变换矩阵

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
using UnityEngine;

public class MotionBlurWithDepthTexture : PostEffect
{
[Range(0f, 1f)]
public float blurSize = 0.5f;
private Matrix4x4 frontWorldToClipMatrix = Matrix4x4.identity;

private void Start()
{
Camera.main.depthTextureMode = DepthTextureMode.Depth;
}

private void OnEnable()
{
// 初始化上一次的变换矩阵,用观察到裁剪变换矩阵(摄像机的透视矩阵)* 世界到观察变换矩阵 得到世界空间到裁剪空间的变换矩阵
frontWorldToClipMatrix = Camera.main.projectionMatrix * Camera.main.worldToCameraMatrix;
}

protected override void OnRenderImage(RenderTexture source, RenderTexture destination)
{
// 如果材质不存在,说明Shader有问题,将原屏幕纹理复制到目标纹理,相对于无后处理效果
if (PostEffectMaterial == null)
{
Graphics.Blit(source, destination);
return;
}

PostEffectMaterial.SetFloat("_BlurSize", blurSize); // 设置模糊程度
PostEffectMaterial.SetMatrix("_FrontWorldToClipMatrix", frontWorldToClipMatrix); // 设置上一帧世界到裁剪空间的矩阵
frontWorldToClipMatrix = Camera.main.projectionMatrix * Camera.main.worldToCameraMatrix;
PostEffectMaterial.SetMatrix("_ClipToWorldMatrix", frontWorldToClipMatrix.inverse); // 设置本帧裁剪到空间空间的矩阵(通过逆矩阵得到)
Graphics.Blit(source, destination, PostEffectMaterial);
}
}

效果展示

摄像机旋转效果:

image

物体移动时不会产生模糊效果,摄像机旋转时会产生模糊效果,
但是,这种方法实现的动态模糊在摄像机位移时会存在抖动、鬼影的问题(尤其是速度发生变化时),效果不是很好

补充

如果要让越之前的帧看上去效果越淡,呈现轨迹逐渐消失的效果,可以使用加权平均的方法来计算颜色叠加
注意!权数加起来必须等于1,否则就要进行额外的除法使其让颜色的范围回到 [-1,1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fixed4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth); // 采集深度值
// 将UV坐标和深度值作为组合坐标,并从0~1范围扩大到-1~1范围
float4 nowClipPos = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1);
float4 worldPos = mul(_ClipToWorldMatrix, nowClipPos); // 计算世界空间下的点
worldPos /= worldPos.w; // 透视除法,保证w分量为1,为标准坐标
float4 oldClipPos = mul(_FrontWorldToClipMatrix, worldPos); // 计算上一帧的裁剪空间下的点
oldClipPos /= oldClipPos.w; // 透视除法,保证w分量为1,为标准坐标
float2 moveDir = (nowClipPos.xy - oldClipPos.xy) / 2; // 得到运动方向
float velColRate[3] = { 0.6, 0.3, 0.1 }; // 颜色权数
// 进行两次偏移采样叠加到原颜色上,取加权平均,以达到模糊效果
float2 uv = i.uv;
fixed4 color = float4(0, 0, 0, 0);
for (int index = 0; index < 3; index++)
{
color += tex2D(_MainTex, uv) * velColRate[index]; // 为了让越之前的帧看上去效果越淡,需要让颜色乘以权数
uv += moveDir * _BlurSize; // 每次循环就偏移采样坐标,叠加偏移后的颜色
}

return fixed4(color.rgb, 1);
}

完整 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
Shader "PostEffect/MotionBlurWithDepthTexture"
{
Properties
{
_MainTex("Texture", 2D) = "white"{}
_BlurSize("BlurSize", Float) = 0.5 // 用于控制模糊程度的模糊偏移量
}

SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

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

sampler2D _MainTex; // 屏幕主纹理
float4 _MainTex_ST;
float4 _MainTex_TexelSize; // 屏幕主纹理纹素
fixed _BlurSize; // 用于控制模糊程度的模糊偏移量
sampler2D _CameraDepthTexture; // 深度纹理
float4x4 _ClipToWorldMatrix; // 裁剪空间到世界空间的变换矩阵
float4x4 _FrontWorldToClipMatrix; // 上一帧裁剪空间到世界空间的变换矩阵

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
// 多平台时需要判断纹理是否翻转
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv_depth.y = 1 - o.uv_depth.y;
#endif
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth); // 采集深度值
// 将UV坐标和深度值作为组合坐标,并从0~1范围扩大到-1~1范围
float4 nowClipPos = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, depth * 2 - 1, 1);
float4 worldPos = mul(_ClipToWorldMatrix, nowClipPos); // 计算世界空间下的点
worldPos /= worldPos.w; // 透视除法,保证w分量为1,为标准坐标
float4 oldClipPos = mul(_FrontWorldToClipMatrix, worldPos); // 计算上一帧的裁剪空间下的点
oldClipPos /= oldClipPos.w; // 透视除法,保证w分量为1,为标准坐标
float2 moveDir = (nowClipPos.xy - oldClipPos.xy) / 2; // 得到运动方向
float velColRate[3] = { 0.6, 0.3, 0.1 }; // 颜色权数
// 进行两次偏移采样叠加到原颜色上,取加权平均,以达到模糊效果
float2 uv = i.uv;
fixed4 color = float4(0, 0, 0, 0);
for (int index = 0; index < 3; index++)
{
color += tex2D(_MainTex, uv) * velColRate[index]; // 为了让越之前的帧看上去效果越淡,需要让颜色乘以权数
uv += moveDir * _BlurSize; // 每次循环就偏移采样坐标,叠加偏移后的颜色
}

return fixed4(color.rgb, 1);
}
ENDCG
}
}

Fallback Off
}