US3S11L6——深度纹理实现全局雾效

知识回顾 —— 全局雾效的三种计算方式

  • Linear(线性):f=enddendstartf = \frac{end - |d|}{end-start}
  • Exponential(指数):f=1edensitydf = 1 - e^{-density \cdot |d|}
  • Exponential Squared(指数平方):f=1e(densityd)2f=1-e^{-(density-|d|)^2}

它们都是用来计算雾的混合因子 ff 的, 有了混合因子,会用雾的颜色和物体本来的颜色进行混合计算:

最终的颜色=(1f)×物体的颜色+f×雾的颜色最终的颜色 = (1-f) \times 物体的颜色 + f \times 雾的颜色

为什么要实现屏幕后处理效果的全局雾效

既然 Unity 中已经提供了全局雾效,那为什么我们还要自己来实现呢?主要是因为 Unity 自带的全局雾效有以下几个缺点

  1. 需要为每个自定义 Shader 按规则书写雾效处理代码

  2. 自带的全局雾效无法实现一些自定义效果

    比如:

    • 基于高度的雾效- 可以用来做出悬浮的水雾效果
    • 不规则的雾效(结合噪声图实现)- 可以为雾增加随机性和不规则形
    • 动态变化的雾、基于纹理的雾等等

总体而言,就是 Unity 自带的全局雾效只能满足最基础的效果,较为局限。

因此我们通过:结合深度纹理来制作屏幕后处理的全局雾效,来让大家感受同一种效果的不同实现思路。
而基于深度纹理的全局雾效,它相对于Unity自带的全局雾效的好处是:

  1. 一次屏幕后处理便可以得到雾的效果,不用为每个自定义 Shader 添加雾效代码
  2. 可以基于该全局雾效拓展出多种雾效,可以方便的模拟出线性、指数、指数平方雾效,甚至实现一些基于高度的雾效、使用噪声图的雾效、动态变化的雾效等等

利用深度纹理实现全局雾效的基本原理

  • Linear(线性):f=enddendstartf = \frac{end - |d|}{end-start}
  • Exponential(指数):f=1edensitydf = 1 - e^{-density \cdot |d|}
  • Exponential Squared(指数平方):f=1e(densityd)2f=1-e^{-(density-|d|)^2}

通过这三个公式,我们发现,想要计算全局雾效关键点是得到离摄像机的距离
因为对于:雾开始的位置 startstart、雾最浓的位置 endend、雾的浓度 densitydensity、自然对数的底 ee,它们都是已知的或自定义的。

因此我们想要实现基于深度纹理的屏幕后处理的全局雾效的关键点就是:如何利用深度纹理来获得每个像素在世界空间下的位置?

这样才能计算出物体离摄像机的距离,才能利用雾的计算公式计算混合因子来实现雾效!
之前学习的基于深度纹理实现的运动模糊相关知识中,我们也利用了深度纹理获取了像素点在世界空间下的位置

1
2
3
4
5
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,为标准坐标

但是这种做法有一个很大的缺点,那就是性能消耗较高,原因主要有以下两点:

  • 在片元着色器函数中进行计算,计算次数较大
  • 每次都进行了矩阵变换计算,计算量较大

因此在实现全局雾效时,我们将不会使用这种方式,而是使用一种性能更好的计算方式!

这种性能更好的新方法的主要思路,还是利用深度纹理来获得每个像素在世界空间下的位置,
除此以外我们还需要获得摄像机指向像素对应世界坐标的方向向量,并利用坐标偏移的方式得到像素的世界坐标!

像素的世界坐标=摄像机位置+观察空间线性深度值×摄像机指向像素世界坐标的方向向量像素的世界坐标 = 摄像机位置 + 观察空间线性深度值 \times 摄像机指向像素世界坐标的方向向量

image

简单来说,就是通过对 摄像机坐标 向 指向像素对应世界坐标的方向向量 偏移 此像素的线性深度值 的距离,即可得到像素点在世界空间下的位置

而其中摄像机位置已知,观察空间线性深度值已知(从深度纹理中采样后计算),
那么关键点就是:如何计算出摄像机指向像素世界坐标的方向向量

据此得到关键思路:

  • 顶点着色器中:

    1. 屏幕后处理中处理的内容是一张抓取的屏幕图像,相当于是一个面片,它具有 4 个顶点(四个角)

      我们其实可以认为屏幕后处理中处理的屏幕图像的四个顶点,就是摄像机视锥体中近裁剪面的四个角
      因为在裁剪空间变换中,我们是将 观察空间 变换到了 裁剪空间 再到 NDC空间(归一化的设备坐标空间) 中,
      最终又变换到了屏幕空间中,可以理解相当于把近裁剪面变换到了屏幕空间中。
      因此近裁剪面的四个角相当于是屏幕图像四个顶点在世界空间下的位置

      image

      这也就是说,我们可以在顶点着色器阶段将四个点的射线方向计算好,
      剩下的每个像素的射线方向都会在顶点到片元着色器阶段插值计算完毕,这样大大降低了计算量

    2. 通过 C# 代码计算四个顶点在世界坐标系下的射线方向后传递给顶点着色器

      这一步我们可以在 C# 中计算好,然后将结果作为参数传递到 Shader 的变量中,在顶点着色器中使用即可

      image​​

      四个顶点在世界坐标系下的射线方向的推导步骤如下:

      其中,FOV 是摄像机视野角度,Near 是摄像机到近裁剪面的距离,aspect 是屏幕宽高比

      image

      1
      2
      3
      4
      5
      6
      7
      8
      9
      float halfH = Near * tan(FOV/2);            // 通过摄像机到近裁剪面的距离乘以tan(视锥角度/2),得到近裁剪面的高的一半
      float halfW = halfH * aspect; // 通过近裁剪面的高的一半乘以屏幕的宽高比,得到近裁剪面的宽的一半
      Vector3 toTop = Camera.up * halfH; // 得到近裁剪面正中央到近裁剪面的顶部中心点的向量
      Vector3 toRight = Camera.Right * halfW; // 得到近裁剪面正中央到近裁剪面的右侧中心点的向量
      // 通过对摄像机坐标到近裁剪面的中心点的方向向量的偏移,即可得到四个顶点在世界坐标系下的射线方向
      Vector3 TopLeft = Camera.forward * Near + toTop - toRight;
      Vector3 TopRight = Camera.forward * Near + toTop - toRight;
      Vector3 BottomLeft = Camera.forward * Near - toTop + toRight;
      Vector3 BottomRight = Camera.forward * Near - toTop + toRight;

      推导出来了四个顶点的方向向量,我们是不是就可以利用它们得到四个顶点的世界空间下坐标了呢?
      比如得到左上角的方向向量的单位向量,然后乘以左上角顶点对应像素点的深度值:

      1
      左上角像素点对应世界坐标 = 摄像机位置 + TopLeft.Normalized * Depth;

      注意,如果这样去计算,那么得到的结果是错误的!!!!
      因为深度值 Depth​ 即使我们将其转换为观察空间下的线性值,它表示的也是离摄像机在 Z 轴方向的距离,
      并不是两点之间的距离(欧式距离),因此我们还需要对该向量进行处理!

      image

      我们可以利用相似三角形的原理,推导出深度值和两点之间距离(欧式距离)的关系:

      image

      DepthNear=disTL dis=TLNear×Depth\frac{Depth}{Near} = \frac{dis}{TL} \\ \ \\ dis=\frac{|TL|}{Near} \times Depth

      因此,左上角像素点对应世界坐标 = 摄像机位置 + TL.Normalized * Depth​ ,
      就变为了:左上角像素点对应世界坐标 = 摄像机位置 + TL.Normalized * |TL| / Near * Depth​,这里 |TL|​ 指的是 TL​ 的模长

      那也就意味着,真正最终和深度一起计算的确定世界坐标位置的方向向量其实就是 TL.Normalized *|TL| / Near
      由于近裁剪面 4 个点是对称的,|TL| / Near​ 可以通用 ,只需要变换前面的单位向量即可

      通过推导,我们已经得到了四个顶点对应的方向向量信息了

      1
      2
      3
      4
      5
      float Scale = TopLeft.magnitude / Near
      Vector3 RayTL = TopLeft.Normalized * Scale
      Vector3 RayTR = TopRight.Normalized * Scale
      Vector3 RayBL = BottomLeft.Normalized * Scale
      Vector3 RayBR = BottomRight.Normalized * Scale

      我们只需要在顶点着色器中根据顶点的位置设置对应的向量即可!

  • 片元着色器中:

    1. 当数据传递到片元着色器要处理每个像素时,像素对应的射线方向是基于 4 个顶点的射线插值计算而来(无需我们自己计算)

    2. 利用 像素世界坐标 = 摄像机位置 + 深度值 * 世界空间下射线方向 得到 对应像素在世界空间下位置

      我们已经有了对应的射线,直接从深度纹理中采样获取深度值,并利用 LinearEyeDepth​ 内置函数得到像素到摄像机的实际距离
      便可以利用上面的公式进行计算,得到每个像素在世界空间下的位置了

    3. 利用得到的世界空间下位置利用雾的公式计算出对应雾效颜色

      有了世界空间下的位置,我们就可以利用雾的计算公式进行雾效的混合因子计算,
      利用算出的雾效混合因子参与雾颜色和像素颜色的混合运算即可

      注意:在之后的具体实现中,我们将基于线性公式实现基于高度的全局雾效

实现 利用深度纹理实现全局雾效屏幕后期处理效果 对应 C# 脚本

  1. 新建 C# 代码,取名 FogWithDepthTexture

  2. 继承 PostEffect​,重写 OnRenderImage

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

  3. Start​ 中开启深度纹理

  4. 声明雾相关属性

    颜色、浓度、开始距离、最浓距离

  5. 根据上节课的原理,计算四个顶点的四个射线向量

  6. 通过 4*4 的矩阵装载各摄像向量,传递给材质

    注意:为了方便之后考虑 UV 翻转问题,我们按左下、右下、右上、左上的逆时针顺序存储

  7. 将定义好的颜色、浓度、开始距离、最浓距离传递给材质球

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

public class FogWithDepthTexture : PostEffect
{
public Color fogColor = Color.gray; // 雾的颜色
[Range(0f, 1f)] public float fogDensity = 1f; // 雾的浓度
public float fogStart = 0f; // 雾开始的距离
public float fogEnd = 5f; // 雾最浓时的距离
private Matrix4x4 rayMatrix; // 用于传递4个向量的参数

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

protected override void UpdateProperty()
{
if (PostEffectMaterial == null)
return;

float fov = Camera.main.fieldOfView / 2f; // 摄像机视口夹角
float near = Camera.main.nearClipPlane; // 摄像机到其近裁剪面的距离
float aspect = Camera.main.aspect; // 窗口比例
// 计算近裁剪面宽高的一半
float halfH = near * Mathf.Tan(fov * Mathf.Deg2Rad);
float halfW = halfH * aspect;
// 计算竖直向上和水平向右的偏移向量
Vector3 toTop = Camera.main.transform.up * halfH;
Vector3 toRight = Camera.main.transform.right * halfW;
// 计算指向四个顶点的向量
Vector3 topLeft = Camera.main.transform.forward * near + toTop - toRight;
Vector3 topRight = Camera.main.transform.forward * near + toTop + toRight;
Vector3 bottomLeft = Camera.main.transform.forward * near - toTop - toRight;
Vector3 bottomRight = Camera.main.transform.forward * near - toTop + toRight;
// 为了让深度值计算出来的是两点间距离,所以需要乘以一个缩放值,得到真正的需要的四条射线的向量
float scale = topLeft.magnitude / near;
topLeft = topLeft.normalized * scale;
topRight = topRight.normalized * scale;
bottomLeft = bottomLeft.normalized * scale;
bottomRight = bottomRight.normalized * scale;
// 注意:为了方便之后考虑 UV 翻转问题,我们按左下、右下、右上、左上的逆时针顺序存储
rayMatrix.SetRow(0, bottomLeft);
rayMatrix.SetRow(1, bottomRight);
rayMatrix.SetRow(2, topLeft);
rayMatrix.SetRow(3, topRight);
// 设置材质球相关属性(Shader属性)
PostEffectMaterial.SetColor("_FogColor", fogColor);
PostEffectMaterial.SetFloat("_FogDensity", fogDensity);
PostEffectMaterial.SetFloat("_FogStart", fogStart);
PostEffectMaterial.SetFloat("_FogEnd", fogEnd);
PostEffectMaterial.SetMatrix("_RayMatrix", rayMatrix);
}
}

实现 利用深度纹理实现全局雾效屏幕后期处理效果 对应 Shader

这里我们将基于线性公式实现基于高度的全局雾效:

  1. 新建 Shader 文件,取名和 C# 脚本相同,删除无用代码

  2. 声明属性,映射属性,注意属性和C# 脚本中命名相同

    包括深度纹理、矩阵、纹素属性

    1
    2
    3
    4
    5
    6
    7
    8
    Properties
    {
    _MainTex("Texture", 2D) = "white"{}
    _FogColor("FogColor", Color) = (1, 1, 1, 1) // 雾的颜色
    _FogDensity("FogDensity", Float) = 1 // 雾的浓度
    _FogStart("FogStart", Float) = 0 // 雾开始的距离
    _FogEnd("FogEnd", Float) = 10 // 雾最浓时的距离
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    sampler2D _MainTex;
    half4 _MainTex_TexelSize; // 纹素,用于判断翻转
    sampler2D _CameraDepthTexture;
    fixed4 _FogColor;
    fixed _FogDensity;
    float _FogStart;
    float _FogEnd;
    // 存储了摄像机坐标到其近裁剪面4个顶点方法向量的矩阵,其中0-左下,1-右下,2-右上,3-左上
    float4x4 _RayMatrix;
  3. 屏幕后处理标配设置

    ZTest Always​、Cull Off​、ZWrite Off

    1
    2
    3
    4
    5
    6
    7
    8
    SubShader
    {
    ZTest Always
    Cull Off
    ZWrite Off

    Pass {/*...*/}
    }
  4. v2f​ 结构体

    • 考虑翻转的深度纹理 half2 uv_depth : TEXCOORD1
    • 射线向量 float4 ray : TEXCOORD2
    1
    2
    3
    4
    5
    6
    7
    struct v2f
    {
    float2 uv : TEXCOORD0;
    float2 uv_depth : TEXCOORD1; // 深度纹理UV
    float4 ray : TEXCOORD2; // 顶点射线,指向四个角的方向向量
    float4 vertex : SV_POSITION;
    };
  5. 顶点着色器(图片有四个顶点,会进入四次,我们需要判断每一个顶点使用哪一个射线向量)

    坐标转换、UV赋值(需要考虑深度纹理翻转),根据 UV 坐标判断顶点位置,决定赋值哪一个向量,同样需要考虑翻转

    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
    v2f vert (appdata_base v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord;
    o.uv_depth = v.texcoord;
    // 因为屏幕贴图只有四个顶点,因此顶点着色器只会执行4次,
    // 因此,我们可以通过顶点的UV坐标和中心点坐标比较,以确认当前的顶点位置
    int index = 0;
    if (v.texcoord.x < 0.5 && v.texcoord.y < 0.5)
    index = 0; // 左下
    else if (v.texcoord.x > 0.5 && v.texcoord.y < 0.5)
    index = 1; // 右下
    else if (v.texcoord.x > 0.5 && v.texcoord.y > 0.5)
    index = 2; // 右上
    else
    index = 3; // 左上

    // 用宏判断uv坐标是否被翻转
    #if UNITY_UV_STARTS_AT_TOP
    // 如果纹素的y小于0,为负数,表示需要对深度的UV和顶点Y轴进行翻转
    if (_MainTex_TexelSize.y < 0)
    {
    o.uv_depth.y = 1 - o.uv_depth.y;
    index = 3 - index;
    }
    #endif

    // 根据顶点的位置,决定少用哪一个射线向量
    o.ray = _RayMatrix[index];
    return o;
    }
  6. 片元着色器

    深度纹理采样,转换到观察空间下离摄像机的实际距离:

    • 利用 摄像机位置 + 深度值 * 射线向量 得到世界空间坐标
    • 利用雾公式 算出混合因子,我们这里不使用传统雾公式

    我们实现一种特殊的基于高度的线性雾效,并且把浓度也用上,利用混合因子,进行颜色混合

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    fixed4 frag (v2f i) : SV_Target
    {
    // 获取观察空间下离摄像机的实际距离(Z分量)并计算世界空间下的像素坐标
    float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
    float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.ray;
    // 雾相关的计算,可以根据自己需求修改计算方法,这里是基于高度的全局雾效
    float f = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart); // 计算混合因子
    f = saturate(f * _FogDensity); // 乘以雾的浓度,取0~1之间,超过则取极值
    // 利用插值,在两个颜色之间进行融合
    fixed3 color = lerp(tex2D(_MainTex, i.uv).rgb, _FogColor.rgb, f);

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

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
Shader "PostEffect/FogWithDepthTexture"
{
Properties
{
_MainTex("Texture", 2D) = "white"{}
_FogColor("FogColor", Color) = (1, 1, 1, 1) // 雾的颜色
_FogDensity("FogDensity", Float) = 1 // 雾的浓度
_FogStart("FogStart", Float) = 0 // 雾开始的距离
_FogEnd("FogEnd", Float) = 10 // 雾最浓时的距离
}
SubShader
{
ZTest Always
Cull Off
ZWrite Off

Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
float2 uv : TEXCOORD0;
float2 uv_depth : TEXCOORD1; // 深度纹理UV
float4 ray : TEXCOORD2; // 顶点射线,指向四个角的方向向量
float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
half4 _MainTex_TexelSize; // 纹素,用于判断翻转
sampler2D _CameraDepthTexture;
fixed4 _FogColor;
fixed _FogDensity;
float _FogStart;
float _FogEnd;
// 存储了摄像机坐标到其近裁剪面4个顶点方法向量的矩阵,其中0-左下,1-右下,2-右上,3-左上
float4x4 _RayMatrix;

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
// 因为屏幕贴图只有四个顶点,因此顶点着色器只会执行4次,
// 因此,我们可以通过顶点的UV坐标和中心点坐标比较,以确认当前的顶点位置
int index = 0;
if (v.texcoord.x < 0.5 && v.texcoord.y < 0.5)
index = 0; // 左下
else if (v.texcoord.x > 0.5 && v.texcoord.y < 0.5)
index = 1; // 右下
else if (v.texcoord.x > 0.5 && v.texcoord.y > 0.5)
index = 2; // 右上
else
index = 3; // 左上

// 用宏判断uv坐标是否被翻转
#if UNITY_UV_STARTS_AT_TOP
// 如果纹素的y小于0,为负数,表示需要对深度的UV和顶点Y轴进行翻转
if (_MainTex_TexelSize.y < 0)
{
o.uv_depth.y = 1 - o.uv_depth.y;
index = 3 - index;
}
#endif

// 根据顶点的位置,决定少用哪一个射线向量
o.ray = _RayMatrix[index];
return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 获取观察空间下离摄像机的实际距离(Z分量)并计算世界空间下的像素坐标
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.ray;
// 雾相关的计算,可以根据自己需求修改计算方法,这里是基于高度的全局雾效
float f = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart); // 使用世界空间的像素坐标高度计算混合因子
f = saturate(f * _FogDensity); // 乘以雾的浓度,取0~1之间,超过则取极值
// 利用插值,在两个颜色之间进行融合
fixed3 color = lerp(tex2D(_MainTex, i.uv).rgb, _FogColor.rgb, f);

return fixed4(color.rgb, 1);
}

ENDCG
}
}

Fallback Off
}

显示效果:

image

可见,这里实现了一个基于物体高度的雾效(一种水雾效果),物体在世界空间下高度越低,雾效越浓烈