0%

【Unity Shader】(八)高级屏幕特效

在上一节中,我们学习的屏幕后处理效果都只是在屏幕颜色图像上进行各种操作来实现的。然而,很多时候我们不仅需要当前屏幕的颜色信息,还希望得到深度和法线信息。例如,在进行边缘检测时,直接利用颜色信息会使检测到的边缘信息受物体纹理和光照等外部因素的影响,得到很多我们不需要的边缘点。一种更好的方法是,我们可以在深度纹理和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。在本节中,我们将学习如何在 Unity 中获取深度纹理和法线纹理来实现特定的屏幕后处理效果。

1 获取深度和法线纹理

虽然在 Unity 里获取深度和法线纹理的代码非常简单,但是我们有必要在这之前首先了解它们背后的实现原理。

1.1 原理

深度纹理实际就是一张渲染纹理,只不过他里面存储的像素值不是颜色值,而是一个高精度的深度值。由于被存储在一张纹理中,深度纹理里的深度值范围是 [0, 1],而且通常是非线性分布的。这些深度值来自于顶点变换后得到的归一化设备坐标 NDC,回顾之前学习过的投影变换,下图显示了正交投影过程:

image-20220409142854239

下图显示了透视投影过程:

image-20220409142931627

深度值就来自于经过透视除法后的 NDC 中顶点坐标的 z 分量的值,由于 NDC 中 z 分量的范围在 [-1, 1],因此还要把它映射到 [0, 1] 以存储在纹理中。

那么 Unity 是怎么得到这样一张深度纹理的呢?在 Unity 中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的 Pass 渲染而得,这取决于使用的渲染路径和硬件。通常来讲,当使用延迟渲染路径时,深度纹理理所当然可以访问到,因为延迟渲染会把这些信息渲染到 G-buffer 中 。而当无法直接获取深度缓存时,深度和法线纹理是通过一个单独的 Pass 渲染而得的 。具体实现是,Unity 会使用着色器替换(Shader Replacement)技术选择那些渲染类型(即 SubShader 的 RenderType 标签)为 Opaque 的物体,判断他们使用的渲染队列是否小于等于 2500((内置的 Background、Geometry 和 AlphaTest 渲染队列均在此范围内),如果满足条件就把它渲染到深度和法线纹理中,因此,要想让物体能够出现在深度和法线纹理中,就必须正确设置 RenderType 标签。

在 Unity 中,我们可以选择让一个摄像机生成一张深度纹理或是一张深度+法线纹理。当选择前者,即只需要一张单独的深度纹理时, Unity 会直接获取深度缓存或是按之前讲到的着色器替换技术,选取需要的不透明物体,并使用它投射阴影时使用的 Pass(即 LightMode 被设置为ShadowCaster 的 Pass)来得到深度纹理。如果 Shader 中不包含这样一个 Pass, 那么这个物体就不会出现在深度纹理中(当然,它也不能向其他物体投射阴影)。深度纹理的精度通常是 24 位或 16 位,这取决于使用的深度缓存的精度。如果选择生成一张深度+法线纹理, Unity 会创建一张和屏幕分辨率相同、精度为 32 位(每个通道为 8 位)的纹理,其中观察空间下的法线信息会被编码进纹理的 R 和 G 通道,而深度信息会被编码进 B 和 A 通道。

法线信息的获取在延迟渲染中是可以非常容易就得到的, Unity 只需要合并深度和法线缓存即可。而在前向渲染中,默认情况下是不会创建法线缓存的,因此 Unity 底层使用了一个单独的 Pass 把整个场景再次渲染一遍来完成。这个 Pass 被包含在 Unity 内置的一个 Unity Shader 中,我们可以在内置的 builtin_ shaders-xxx/DefaultResources/Camera-DepthNormaITexture.shader 文件中找到这个用于渲染深度和法线信息的 Pass。

1.2 获取

在 Unity 中,获取深度纹理是非常简单的,只需要在在脚本中设置摄像机的 depthTextureMode 即可,然后就可以在 Shader 中直接访问特定的纹理属性了。我们可以使用下面的代码获取深度纹理:

1
camera.depthTextureMode = DepthTextureMode.Depth;

一旦设置好了上面的摄像机模式后,我们就可以在 Shader 中通过声明 _CameraDepthTexture 变量来访问它。

同理,如果想要获取深度+法线纹理,我们只需要在代码中这样设置:

1
camera.depthTextureMode = DepthTextureMode.DepthNormals;

然后在 Shader 中通过声明 _CameraDepthNormalsTexture 变量来访问它。

我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度 + 法线纹理:

1
2
camera.depthTextureMode |= DepthTextureMode.Depth;
camera.depthTextureMode |= DepthTextureMode.DepthNormals;

当在 Shader 中访问到深度纹理 _CameraDepthTexture 后,我们就可以使用当前像素的纹理坐标对它进行采样,绝大多数情况下直接使用 Tex2D 即可,但在某些平台可能需要一些特殊处理, Unity 为我们提供了一个统一的宏 SAMPLE_DEPTH_TEXTURE,用来处理这些由于平台差异造成的问题。而我们只需要在 Shader 中使用即可:

1
float d = SAMPLE_DEPTH_TEXTURE (_CameraDepthTexture, i.uv);

当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于透视投影使 用的裁剪矩阵。 然而,在我们的计算过程中通常是需要线性的深度值,也就是说,我们需要把投 影后的深度值变换到线性空间下,例如视角空间下的深度值。Unity 提供了两个辅助函数来进行转换,LinearEyeDepth 负责把深度纹理的采样结果转换到视角空间下的深度值,Linear01Depth 则会返回一个范围在 [0, 1] 的线性深度值。转换这部分的推导可以查看《Unity Shader 入门精要》13.1.2 节的内容。

如果我们需要获取深度+法线纹理,可以直接使用 tex2D 函数对 _CameraDepthNormalsTexture 进行采样,得到里面存储的深度和法线信息。Unity 提供了辅助函数来为我们对这个采样结果进行解码,从而得到深度值和法线方向。这个函数是 DecodeDepthNormal,该函数的第一个参数是对深度+法线纹理的采样结果,这个采样结果是 Unity 对深度和法线信息编码后的结果,它的 xy 分量存储的是视角空间下的法线信息,而深度信息被编码进了 zw 分量。通过调用 DecodeDepthNormal 函数对采样结果解码后,我们就可以得到解码后的深度值和法线。这个深度值是范围在 [0, 1] 的线性深度值(这与单独的深度纹理中存储的深度值不同),而得到的法线则是视角空间下的法线方向。DecodeDepthNormal 函数如下:

1
2
3
4
5
inline void DecodeDepthNormal (float4 enc, out float depth, out float3 normal)
{
depth = DecodeFloatRG(enc.ze);
normal = DecodeViewNormalStereo(enc);
}

我们也可以自己用 DecodeFloatRGDecodeViewNormalStereo 函数解码。

2 再谈运动模糊

在上一节中,我们学习了如何通过混合多张屏幕图像来模拟运动模糊的效果。但是,另一种应用更加广泛的技术则是使用速度映射图。速度映射图中存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小。速度缓冲的生成有多种方法,一种方法是把场景中所有物体的速度渲染到一张纹理中。但这种方法的缺点在于需要修改场景中所有物体的 Shader 代码,使其添加计算速度的代码并输出到一个渲染纹理中。

《GPU Gems3》在第 27 章中介绍了一种生成速度映射图的方法。这种方法利用深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,这是通过使用当前的视角*投影矩阵的逆矩阵对 NDC 下的顶点坐标进行变换得到的,当得到世界空间中的顶点坐标后,我们使用前一帧的视角*投影矩阵对其进行变换, 得到该位置在前一帧中的 NDC 坐标。然后,我们计算前一帧和当前帧的位置差,生成该像素的速度。这种方法的优点是可以在一个屏幕后处理步骤中完成整个效果的模拟,但缺点是需要在片元着色器中进行两次矩阵乘法的操作,对性能有所影响。

我们首先来编写 cs 脚本:

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
using UnityEngine;
using System.Collections;

public class MotionBlurWithDepthTexture : PostEffectsBase {

public Shader motionBlurShader;
private Material motionBlurMaterial = null;
public Material material {
get {
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}

//由于要用到摄像机的视角和投影矩阵,因此要定义一个Cmaera类型的变量以获取该脚本所在的摄像机组件
private Camera myCamera;
public Camera camera {
get {
if (myCamera == null) {
myCamera = GetComponent<Camera>();
}
return myCamera;
}
}

//定义运动模糊时模糊图像使用的大小
[Range(0.0f, 1.0f)]
public float blurSize = 0.5f;

//用于保存前一帧的视角*投影矩阵
private Matrix4x4 previousViewProjectionMatrix;

//当脚本可用时,设置摄像机的状态以获取深度纹理
void OnEnable() {
camera.depthTextureMode |= DepthTextureMode.Depth;
previousViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
}

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_BlurSize", blurSize);
// 给材质传递前一帧的视角 * 投影矩阵
material.SetMatrix("_PreviousViewProjectionMatrix", previousViewProjectionMatrix);
// 计算当前帧的视角 * 投影矩阵
Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
// 求逆
Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
// 给材质传递前帧的视角 * 投影矩阵的逆矩阵
material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
// 更新前一帧的视角 * 投影矩阵
previousViewProjectionMatrix = currentViewProjectionMatrix;

Graphics.Blit (src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}

然后编写 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
Shader "Unity Shaders Book/Chapter 13/Motion Blur With Depth Texture" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader {
CGINCLUDE

#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
// Unity提供的深度纹理
sampler2D _CameraDepthTexture;
// 两个矩阵由脚本传递而来
float4x4 _CurrentViewProjectionInverseMatrix;
float4x4 _PreviousViewProjectionMatrix;
half _BlurSize;

struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
};

v2f vert(appdata_img v) {
v2f o;
o.pos = 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 d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
// 构造该像素的NDC坐标,通过对深度纹理进行反映射得到
// 因为深度纹理的深度是从NDC的z坐标映射到[0, 1]范围的,所以再映射回去作为z分量
// NDC的xy分量通过纹理坐标的xy映射而来
float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);
// 对NDC进行VP变换的逆变换并将结果除以w分量,以得到世界空间下的顶点坐标
float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
float4 worldPos = D / D.w;

// 当前屏幕空间坐标
float4 currentPos = H;
// 用计算得到的世界空间坐标乘以前一帧的VP变换矩阵得到前一帧的屏幕空间坐标,同样要记得除以w分量
float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
previousPos /= previousPos.w;

// 计算前一帧和后一帧屏幕坐标的差以得到该像素的速度
float2 velocity = (currentPos.xy - previousPos.xy)/2.0f;

float2 uv = i.uv;
float4 c = tex2D(_MainTex, uv);
// 利用该速度值对该像素的邻域像素进行采样,_BlurSize控制采样距离
// 采样结果相加后取平均得到模糊效果
uv += velocity * _BlurSize;
for (int it = 1; it < 3; it++, uv += velocity * _BlurSize) {
float4 currentColor = tex2D(_MainTex, uv);
c += currentColor;
}
c /= 3;

return fixed4(c.rgb, 1.0);
}

ENDCG

Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

ENDCG
}
}
FallBack Off
}

没有运动模糊的效果如下:

motion1

使用上面的方法得到的运动模糊效果如下:

motion2

3 全局雾效

雾效(Fog)是游戏里经常使用的一种效果。 Unity 内置的雾效可以产生基于距离的线性或指数雾效。然而,要想在自己编写的顶点/片元着色器中实现这些雾效,我们需要在 Shader 中添加 #pragma multi_compile_fog 指令,同时还需要使用相关的内置宏,例如 UNITY_FOG_COORDSUNITY_TRANSFER_FOGUNTTY_APPLY_FOG 等。这种方法的缺点在于,我们不仅需要为场景中所有物体添加相关的渲染代码,而且能够实现的效果也非常有限。当我们需要对雾效进行一些个性化操作时,例如使用基于高度的雾效等,仅仅使用 Unity 内置的雾效就变得不再可行。

在本节中,我们将会学习一种基于屏幕后处理的全局雾效的实现。使用这种方法,我们不需要更改场景内渲染的物体所使用的 Shader 代码,而仅仅依靠一次屏幕后处理的步骤即可。这种方法的自由性很高,我们可以方便地模拟各种雾效,例如均匀的雾效、基于距离的线性/指数雾效、基于高度的雾效等。

基于屏幕后处理的全局雾效的关键是,根据深度纹理来重建每个像素在世界空间下的位置。尽管我们在模拟运动模糊时已经实现了这个要求,即构建出当前像素的 NDC 坐标,再通过当前摄像机的视角*投影矩阵的逆矩阵来得到世界空间下的像素坐标,但是这样的实现需要在片元着色器中进行矩阵乘法的操作,而这通常会影响游戏性能。在本节中,我们将会学习一个快速从深度纹理中重建世界坐标的方法。

这种方法首先对图像空间下的视锥体射线(从摄像机出发,指向图像上的某点的射线)进行插值,这条射线存储了该像素在世界空间下到摄像机的方向信息。然后,我们把该射线和线性化后的视角空间下的深度值相乘,再加上摄像机的世界位置,就可以得到该像素在世界空间下的位置。当我们得到世界坐标后,就可以轻松地使用各个公式来模拟全局雾效了。

3.1 重建世界坐标

我们知道,坐标系中的一个顶点坐标可以通过它相对于另一个顶点坐标的偏移量来求得建像素的世界坐标就是基于这样的思想。我们只需要知道摄像机在世界空间下的位置,以及世界空间下该像素相对于摄像机的偏移量,把它们相加就可以得到该像素的世界坐标。整个过程可以使用下面的代码来表示:

1
float4 worldPos = _WorldSpaceCameraPos + linearDepth * interpolatedRay;

其中,_WorldSpaceCameraPos 可以通过内置变量直接访问,linearDepth * interpolatedRay 则可以计算得到该像素相对于摄像机的偏移量,linearDepth 是由深度纹理得到的线性深度值, interpolatedRay 是由顶点着色器输出并插值后得到的射线,它不仅包含了该像素到摄像机的方向,也包含了距离信息。linearDepth 的获取我们已经学习过了,因此,现在来看 interpolatedRay 的求法。

interpolatedRay 来源于对近裁剪平面的 4 个角的某个特定向量的插值,这 4 个向量包含了它们到摄像机的方向和距离信息,我们可以利用摄像机的近裁剪平面距离、 FOV、横纵比计算而得,如下图:

image-20220409154810612

为了方便计算,我们可以先计算两个辅助向量—— $toTop$ 和 $toRight$,他们是起点位于近裁剪平面中心,分别指向摄像机正上方和正右方的向量,计算公式如下:
$$
halfHeight = Near \times tan(\frac{FOV}{2}) \
toTop = HalfHeight · camera.up \
toRight = HalfHeight · camera.right
$$
其中,$Near$ 是近裁剪平面的距离,$FOV$ 是竖直方向的视角范围,$camera.up$ 、 $camera.right$ 分别对应了摄像机的正上方和正右方。当得到这两个辅助向量后,我们就可以计算 4 个角相对于摄像机的方向了。以左上角TL为例:
$$
TL = camera.forward · Near + toTop - toRight
$$
通过看图,上式很容易理解,同理其他三个角的向量:
$$
TR = camera.forward · Near + toTop + toRight \
BL = camera.forward · Near - toTop - toRight \
BR = camera.forward · Near - toTop + toRight
$$
注意,上面求得的 4 个向量不仅包含了方向信息,它们的模对应了 4 个点到摄像机的空间距离。由于我们得到的线性深度值并非是点到摄像机的欧式距离,而是在 z 方向上的距离,如下图:

image-20220409155500256

因此我们不能直接使用深度值和 4 个角的单位方向的乘积来计算它们到摄像机的偏移量,不过想要把深度值转换成到摄像机的欧式距离也很简单,我们以 TL 点为例,根据相似三角形原理,TL 所在的射线上,像素的深度值和它到摄像机的实际距离的比等于近裁剪平面的距离和 TL 向量的模的比,即:
$$
\frac{depth}{dist} = \frac{Near}{|TL|}
$$
由此即可得到 TL 射线上的点距离摄像机的欧氏距离:
$$
dist = depth \times \frac{|TL|}{Near}
$$
由于 4 个点相互对称,因此其他 3 个向量的模和 TL 相等,即我们可以使用同一个因子:
$$
scale = \frac{|TL|}{|Near|}
$$
和单位向量相乘,得到它们对应的向量值:
$$
Ray_{TL} = \frac{TL}{|TL|}\times scale \
Ray_{TR} = \frac{TR}{|TR|}\times scale \
Ray_{BL} = \frac{BL}{|BL|}\times scale \
Ray_{BR} = \frac{BR}{|BR|}\times scale
$$
屏幕后处理的原理是使用特定的材质去渲染一个刚好填充整个屏幕的四边形面片。这个四边形面片的 4 个顶点就对应了近裁剪平面的 4 个角。因此,我们可以把上面的计算结果传递给顶点着色器,顶点着色器根据当前的位置选择它所对应的向量,然后再将其输出,经插值后传递给片元着色器得到 interpolatedRay,我们就可以直接利用一开始提到的公式重建该像素在世界空间的位置了。

3.2 雾的计算

在简单的雾效实现中,我们需要计算一个雾效系数 f,作为混合原颜色和雾的颜色的混合系数:

1
float3 afterFog = f * fogColor + (1 - f) * oriColor

这个雾效系数 f 有很多计算方法。在 Unity 内置的雾效实现中,支待三种雾的计算方式,给定距离 z 后,f 的计算方式如下:

  • 线性:

$$
f = \frac{d_{max} - |z|}{d_{max} - d_{min}}
$$

其中 $d_{min}$ 和 $d_{max}$ 分别表示受雾影响的最小距离和最大距离。

  • 指数:

$$
f = e^{-d·|z|}
$$

其中 d 是控制雾的浓度的参数。

  • 指数平方:

$$
f = e^{-(d-|z|)^2}
$$

其中 d 是控制雾的浓度的参数。

在本节中,我们将使用类似线性雾的计算方式,计算基于高度的雾效。具体方法是,用给定一点在世界空间下的高度 y 后替换线性计算公式中的距离 z。

3.3 实现

首先是 cs 脚本:

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
using UnityEngine;
using System.Collections;

public class FogWithDepthTexture : PostEffectsBase {

public Shader fogShader;
private Material fogMaterial = null;
public Material material {
get {
fogMaterial = CheckShaderAndCreateMaterial(fogShader, fogMaterial);
return fogMaterial;
}
}

//我们需要获取摄像机的相关参数,如近裁剪平面的距离、FOV等,同时还需要获取摄像机在世界空间下的前方、上方和右方等方向
//因此我们用两个变量存储摄像机的Camera组件和Transform组件
private Camera myCamera;
public Camera camera {
get {
if (myCamera == null) {
myCamera = GetComponent<Camera>();
}
return myCamera;
}
}

private Transform myCameraTransform;
public Transform cameraTransform {
get {
if (myCameraTransform == null) {
myCameraTransform = camera.transform;
}

return myCameraTransform;
}
}

//定义模拟雾效的各个参数
[Range(0.0f, 3.0f)]
public float fogDensity = 1.0f;

public Color fogColor = Color.white;
//受雾效影响的最小高度和最大高度
public float fogStart = 0.0f;
public float fogEnd = 2.0f;
//设置相机状态获取深度纹理
void OnEnable() {
camera.depthTextureMode |= DepthTextureMode.Depth;
}

void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
//计算近裁剪平面4个角对应的向量
Matrix4x4 frustumCorners = Matrix4x4.identity;

float fov = camera.fieldOfView;
float near = camera.nearClipPlane;
float aspect = camera.aspect;

float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
Vector3 toRight = cameraTransform.right * halfHeight * aspect;
Vector3 toTop = cameraTransform.up * halfHeight;

Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
float scale = topLeft.magnitude / near;

topLeft.Normalize();
topLeft *= scale;

Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
topRight.Normalize();
topRight *= scale;

Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
bottomLeft.Normalize();
bottomLeft *= scale;

Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
bottomRight.Normalize();
bottomRight *= scale;

frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);

material.SetMatrix("_FrustumCornersRay", frustumCorners);

material.SetFloat("_FogDensity", fogDensity);
material.SetColor("_FogColor", fogColor);
material.SetFloat("_FogStart", fogStart);
material.SetFloat("_FogEnd", fogEnd);

Graphics.Blit (src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}

然后编写 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
Shader "Unity Shaders Book/Chapter 13/Fog With Depth Texture" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_FogDensity ("Fog Density", Float) = 1.0
_FogColor ("Fog Color", Color) = (1, 1, 1, 1)
_FogStart ("Fog Start", Float) = 0.0
_FogEnd ("Fog End", Float) = 1.0
}
SubShader {
CGINCLUDE

#include "UnityCG.cginc"

float4x4 _FrustumCornersRay;

sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture;
half _FogDensity;
fixed4 _FogColor;
float _FogStart;
float _FogEnd;

struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
float4 interpolatedRay : TEXCOORD2;
};

v2f vert(appdata_img v) {
v2f o;
o.pos = 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
// 找到对应的顶点的向量,虽然有很多if判断,但实际上我们渲染的四边形只有4个顶点,因此对性能不会有太大影响
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;
}

#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
index = 3 - index;
#endif

o.interpolatedRay = _FrustumCornersRay[index];

return o;
}

fixed4 frag(v2f i) : SV_Target {
//得到像素的线性深度
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
//按照公式计算该像素对应的世界空间坐标
float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
// 按照公式计算雾效系数f,使用_FogDensity控制雾效浓度
float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);
fogDensity = saturate(fogDensity * _FogDensity);

fixed4 finalColor = tex2D(_MainTex, i.uv);
//利用雾效系数混合原颜色和雾效颜色
finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);

return finalColor;
}

ENDCG

Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

ENDCG
}
}
FallBack Off
}

渲染效果如下:

fog

4 再谈边缘检测

之前我们使用 Sobel 算子对屏幕图像进行边缘检测,实现描边的效果。但是,这种直接利用颜色信息进行边缘检测的方法会产生很多我们不希望得到的边缘线,如下图:

image-20220409163748909

可以看出,物体的纹理、阴影等位置也被描上黑边,而这往往不是我们希望看到的。在本节中我们将学习如何在深度和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。如下图:

image-20220409163828336

本节使用 Roberts 算子来进行边缘检测,它使用的卷积核如下图:

image-20220409163911506

Roberts 算子的本质就是计算左上角和右下角的差值,乘以右上角和左下角的差值,作为评估边缘的依据。我们在实现中取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阈值(可由参数控制),就认为它们之间存在一条边。

首先是 cs 脚本:

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
using UnityEngine;
using System.Collections;

public class EdgeDetectNormalsAndDepth : PostEffectsBase {

public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}

[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;

public Color edgeColor = Color.black;

public Color backgroundColor = Color.white;
//采样距离
public float sampleDistance = 1.0f;
//邻域的深度相差多少会认为存在一条边,灵敏度如果很大,那么很小的变化也会认为是边缘
public float sensitivityDepth = 1.0f;
//邻域的法线相差多少会认为存在一条边,灵敏度如果很大,那么很小的变化也会认为是边缘
public float sensitivityNormals = 1.0f;
//设置摄像机产生深度+法线纹理
void OnEnable() {
GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}

//正常情况下OnRenderImage函数会在所有不透明和透明物体渲染完后被调用
//但我们希望边缘检测只对不透明物体描边,因此使用下面的ImageEffectOpaque声明
//使得OnRenderImage函数在不透明物体渲染完后立即调用
[ImageEffectOpaque]
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
material.SetFloat("_SampleDistance", sampleDistance);
material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));

Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}

重点是 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
Shader "Unity Shaders Book/Chapter 13/Edge Detection Normals And Depth" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
_SampleDistance ("Sample Distance", Float) = 1.0
_Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)
}
SubShader {
CGINCLUDE

#include "UnityCG.cginc"

sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
//深度+法线纹理
sampler2D _CameraDepthNormalsTexture;

struct v2f {
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};

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

half2 uv = v.texcoord;
o.uv[0] = uv;

#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
uv.y = 1 - uv.y;
#endif
//四个对角线邻域的纹理坐标,在顶点着色器计算出坐标之后插值传递给片元着色器以减少性能开销
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1,1) * _SampleDistance;
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1,-1) * _SampleDistance;
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1,1) * _SampleDistance;
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1,-1) * _SampleDistance;

return o;
}

//用来判断深度+法线纹理上的两个采样点的深度和法线的差异
half CheckSame(half4 center, half4 sample) {
//解码得到两个采样点的法线和深度值
//法线并没有解码成真正的法线值,直接使用了xy分量
//这是因为我们只需要两个法线的差异,不需要知道真正的法线是什么
half2 centerNormal = center.xy;
float centerDepth = DecodeFloatRG(center.zw);
half2 sampleNormal = sample.xy;
float sampleDepth = DecodeFloatRG(sample.zw);

// 计算法线的差异乘以灵敏度
half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
// 差异的xy分量相加和阈值比较,如果小于该阈值返回1,否则返回0
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
// 计算深度差异乘以灵敏度
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
// 同上对比两个深度值差异是否满足条件
int isSameDepth = diffDepth < 0.1 * centerDepth;

// 将法线和深度差异的结果相乘返回
return isSameNormal * isSameDepth ? 1.0 : 0.0;
}

fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target {
half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);

half edge = 1.0;

edge *= CheckSame(sample1, sample2);
edge *= CheckSame(sample3, sample4);

fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);

return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}

ENDCG

Pass {
ZTest Always Cull Off ZWrite Off

CGPROGRAM

#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal

ENDCG
}
}
FallBack Off
}

渲染效果如下:

image-20220409170023348

只显示描边:

image-20220409170127466

当我们增大采样距离,得到的效果如下:

image-20220409170051494

视觉上就是描边更粗了。

本节实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但有时,我们希望只对特定的物体进行描边,例如当玩家选中场景中的某个物体后, 我们想要在该物体周围添加一层描边效果。这时,我们可以使用 Unity 提供的 Graphics.DrawMeshGraphics.DrawMeshNow 函数把需要描边的物体再次渲染一遍(在所有不透明物体渲染完毕之后),然后再使用本节提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阈值,如果是,就在 Shader 中使用 clip 函数将该像素剔除掉,从而显示出原来的物体颜色。

---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。