景深效果

前言

        本篇文章以Unity为工具,介绍了景深相关的知识。Unity在URP中已经提供了景深效果,本文中的代码自然是不能和Unity官方的代码相提并论。文章中的代码只是为了体现文章描述的知识点。

环境

windows10/windows11
Unity 2022.3.52f1c1
URP 14.0.1

什么是景深

        景深也是一种非常常见的后处理手段,它用来模拟相机拍摄画面的效果。对于⼀个给定设置的相机镜头,它有⼀个物体聚焦的范围,即它的景深(depth of field)。任何超出这个范围的物体都是模糊的,且距离越远越模糊。

实现方法

        在《Real-Time Rendering 4th Edition》(下面简称为《RTR4》)中,它提出了几种方法。

  1. 将景深和⾊调映射联系起来,使得失焦物体随着光照⽔平的降低⽽变得更加模糊。(这个方法书中和网络上,我都没找到具体的实现。说来惭愧,我也不知道该如何实现。所以这就当是一种思路的记录把。)

  2. 改变镜头上的观察位置并保持焦点固定,渲染出多张图片。然后我们对这些图片相对应的像素点进行加权求值(一般就是求平均值)。最终我们就可以实现景深效果。这个方法所需要的成本过高,我们需要对多张图片进行渲染,且最后还需要进行加权求值。在实际的游戏开发中,这样成本如此之高的算法是不可能被使用的,即使它确实可以收敛到正确的ground-truth图片。(其实书中并没有更加具体说明这个方法,因为要想收敛到ground-truth图片,那么必然要有一定规则的修改镜头观察位置,且能保证焦点范围才对。但是书中就只写了这么多。当然作为游戏开发者,这样的方法我们本身也不会去使用)

  3. 渲染3幅图像,分别是对焦点物体渲染⼀副图像,对距离⼤于焦距的物体(远场)渲染⼀副图像,对距离⼩于焦距的物体(近场)渲染⼀副图像。我们可以通过操作摄像机的远近平面来实现。然后我们模糊远场和进场的图像。最后我们按照距离顺序将这三幅图像进行整合。这种⽅法被称为 2.5 维⽅法,之所以这么叫,是因为这些⼆维图像被赋予了深度并据此组合在⼀起,这种⽅法在某些情况下可以提供合理的结果。但是当物体跨越多个图像时,这种⽅法就会失效,因为物体会突然从模糊变为聚焦。此外,所有过滤后的物体都具有均匀⼀致的模糊程度,这个模糊程度不会随着距离⽽发⽣任何变化,这样是不合理的。这不仅仅是在说远场图像和近场图像的模糊区别,而是它们自身所包含的物体应该也具有随着距离⽽发⽣变换的模糊。

        如果你有使用过Unity自带景深效果,你会发现它提供了两个效果,Guassian(高斯)和Bokeh(散景在摄影学中也被称为焦外成像。Bokeh是来⾃⽇语的单词,其意思是模糊,blur。bokeh 读作bow-ke,其中bow是bow and arrow中的bow,ke是kettle中的ke)。

        高斯即用高斯模糊的方法来实现景深效果,关于高斯模糊或许你早有听说,所以这里我着重说明一下散景。我们前面提出的方法都在做一件事情————考虑景深如何对整体场景产生影响。即使在方法3中,我们分了远场、焦点物体、近场,我们仍然是当做整体去考虑景深的影响。现在我们换一种思路,我们可以考虑景深如何对表⾯上的单个位置产⽣影响。表面的一小点(这个点一定是可见的),当我们聚焦到它时,它就会清晰的显示在我们的视角中。如果是失焦的情况下,这个点就会出现在附近的像素中。这取决于不同的观察视图。在极限情况下,这个⼩点将在像素⽹格上定义⼀个实⼼圆。这个实⼼圆被称为弥散圆(circle of confusion)。而散景就是要模拟出这样的弥散圆。在实际摄影中,形成散景的弥散圆不一定是圆形也有一些是六边形甚至是五边形。这是因为在摄影中,通过光圈的光线通常是均匀分布的,⽽不是符合某种⾼斯分布。模糊区域的形状与光圈叶⽚的数量、形状以及尺⼨有关。⼀些廉价的相机会产⽣五边形的模糊,⽽不是完美的圆形;⽽⽬前⼤多数新相机都有 7 个叶⽚,⾼端机型有 9 个或者更多叶⽚。还有⼀些更好的相机会使⽤圆形叶⽚,从⽽使得散景效果呈圆形。在《RTR4》中,其提出六边形是⼀种特别容易产⽣的形状,因此被⼴泛应⽤在许多游戏中。个人认为圆形更好看

具体实现的步骤

        下面我会讨论关于高斯模糊和散景的实践步骤,并结合Unity本身的实现去进行讨论。因为散景模糊过于复杂了,这里仅对高斯模糊做一些代码上的实现。散景模糊会直接讨论Unity官方本身的实现。

高斯模糊

        我们先从简单的高斯模糊开始。对一个场景来说,我们可以把场景分为三个部分⼤于焦距的物体(远场),距离⼩于焦距的物体(近场)和焦距内的物体(焦点物体)。近场和远场是我们需要模糊的景象,而焦点物体则保持不变。模糊近场和远场的方法便是高斯模糊。那剩下的问题就只有如何找到场景中哪些区域需要模糊。首先我们要先确定焦距的范围。然后我们对焦距范围外的所有物体进行模糊,而对焦距范围内的物体则保持不变。实际上,我们完全可以在后处理的时候对图片进行这样的操作,因为我们可以使用深度图来得到每个像素相对于摄像机的距离。我们通过深度图可以制作出一个遮罩,遮罩中白色的部分表示需要模糊的场景,而黑色部分则表示不需要模糊的场景。然后我们用遮罩对图片进行高斯模糊,就可以实现景深效果。接下来我们先设定一些后处理需要的参数:

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
public sealed class GaussianDOFVolume : VolumeComponent, IPostProcessComponent
{
// 焦距开始的位置(距离摄像机本地坐标系z轴方向)
public MinFloatParameter gaussianStart = new MinFloatParameter(10f, 0f);

// 焦距大小
public MinFloatParameter focalDistance = new MinFloatParameter(30f, 0f);

// 模糊大小
public MinFloatParameter blurSize = new MinFloatParameter(1,0);

// 模糊次数
public ClampedIntParameter blurTime = new ClampedIntParameter(1,0,10);

[Header("以2为倍数的降采样")]
// 降采样的程度
public ClampedIntParameter downSampleLv = new ClampedIntParameter(0, 0, 4);

public bool IsActive()
{
return focalDistance.value > 0.0000001f && focalDistance.overrideState;
}

public bool IsTileCompatible()
{
return false;
}
}

因为是后处理的方式,所以我这边走Unity后处理的流程使用了VolumeComponent来修改数据。接下来就是对应的特性,其代码如下:

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
public class GaussianDOFPass : ScriptableRendererFeature
{
class CustomRenderPass : ScriptableRenderPass
{
private GaussianDOFVolume gaussianDOFVolume;

private Material material;

private RTHandle maskTex;

private RTHandle blurTexH;
private RTHandle blurTexV;

public CustomRenderPass()
{
// 获取Shader并得创建对应的材质
var shader = Shader.Find("Unlit/GaussianDOF");
if (shader != null)
material = new Material(shader);
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// 材质为空或是没有启用对应的数据,则不进行操作
if (material == null)
return;

gaussianDOFVolume = VolumeManager.instance.stack.GetComponent<GaussianDOFVolume>();
if (gaussianDOFVolume == null || !gaussianDOFVolume.active || !gaussianDOFVolume.IsActive())
return;

// 分配贴图
RenderTextureDescriptor des = renderingData.cameraData.cameraTargetDescriptor;
des.useMipMap = false;
des.autoGenerateMips = false;
des.depthBufferBits = (int)DepthBits.None;
des.msaaSamples = 1;

RenderingUtils.ReAllocateIfNeeded(ref maskTex, des, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_MaskTex");

// 降采样
var downSampleLv = gaussianDOFVolume.downSampleLv.value;
des.width = des.width >> downSampleLv;
des.height = des.width >> downSampleLv;

RenderingUtils.ReAllocateIfNeeded(ref blurTexH, des, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_MidStatusTexH");
RenderingUtils.ReAllocateIfNeeded(ref blurTexV, des, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_MidStatusTexV");

var cameraData = renderingData.cameraData;
var cmd = CommandBufferPool.Get("GaussianDOFPass");
cmd.Clear();

// 计算遮罩
material.SetFloat("_GaussianStart", gaussianDOFVolume.gaussianStart.value);
material.SetFloat("_GaussianEnd", gaussianDOFVolume.focalDistance.value + gaussianDOFVolume.gaussianStart.value);
var blurSize = Mathf.Min(1, gaussianDOFVolume.blurSize.value / Mathf.Max(des.width, des.height));
material.SetFloat("_BlurSize", blurSize);
cmd.Blit(cameraData.renderer.cameraColorTargetHandle, maskTex, material, 0);

cmd.SetGlobalTexture("_MaskTex", maskTex);

// 模糊
var blurTime = gaussianDOFVolume.blurTime.value;
if (blurTime > 0)
{
cmd.Blit(cameraData.renderer.cameraColorTargetHandle, blurTexH);
for (int i = 0; i < blurTime; ++i)
{
cmd.SetGlobalTexture("_ColorTex", blurTexH);
cmd.Blit(blurTexH, blurTexV, material, 1);
cmd.SetGlobalTexture("_ColorTex", blurTexV);
cmd.Blit(blurTexV, blurTexH, material, 2);
}
cmd.Blit(blurTexH, cameraData.renderer.cameraColorTargetHandle);
}

context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}

public override void OnCameraCleanup(CommandBuffer cmd)
{
/*maskTex?.Release();
blurTex?.Release();*/
}
}

CustomRenderPass m_ScriptablePass;

/// <inheritdoc/>
public override void Create()
{
m_ScriptablePass = new CustomRenderPass();

m_ScriptablePass.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 只对主摄像机进行操作
if(renderingData.cameraData.camera == Camera.main)
renderer.EnqueuePass(m_ScriptablePass);
}
}

然后我们要在项目中的渲染设置(Unity查找URP设置的方法)中将这个特性加到渲染中。对应的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
110
111
112
113
114
115
Shader "Unlit/GaussianDOF"
{
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100

HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

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

const static int kTapCount = 5;
const static float kOffsets[] = {
-2,-1,0,1,2
};
const static half kCoeffs[] = {
0.0545, 0.2442, 0.4025, 0.2442, 0.0545
};

TEXTURE2D_X(_ColorTex);
TEXTURE2D_X(_MidStatusTex);
TEXTURE2D_X(_MaskTex);

float _GaussianStart;
float _GaussianEnd;
float _BlurSize;

v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex.xyz);
o.uv = v.uv;
return o;
}

half4 fragMask (v2f i) : SV_Target
{
float depth = SampleSceneDepth(i.uv);
depth = LinearEyeDepth(depth,_ZBufferParams);
half flag = step(_GaussianStart,depth) + step(depth,_GaussianEnd);
flag = step(flag,1.2);
return flag;
}

half4 Blur(v2f input, float2 dir)
{
float4 acc = 0.0;
float4 baseColor = 0;
float4 bCol = SAMPLE_TEXTURE2D_X(_ColorTex,sampler_LinearClamp, input.uv);
half mask = SAMPLE_TEXTURE2D_X(_MaskTex,sampler_LinearClamp, input.uv).x;
int maskF = step(0.5,mask);
dir *= maskF * _BlurSize;
UNITY_UNROLL
for(int i = 0; i < kTapCount; i++)
{
float2 uv = input.uv + dir * kOffsets[i];
float4 col = SAMPLE_TEXTURE2D_X(_ColorTex,sampler_LinearClamp, uv);
int curMaskF = step(0.5,SAMPLE_TEXTURE2D_X(_MaskTex,sampler_LinearClamp, uv).x);
acc += col * kCoeffs[i] * curMaskF;
baseColor += bCol * kCoeffs[i];
}
baseColor /= baseColor.w;
acc /= max(acc.w,1e-5);
return lerp(baseColor,acc,maskF);
}

half4 fragBlurH (v2f i) : SV_Target
{
return Blur(i, float2(1.0, 0.0));
}

half4 fragBlurV (v2f i) : SV_Target
{
return Blur(i, float2(0.0, 1.0));
}

ENDHLSL

Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment fragMask
ENDHLSL
}

Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment fragBlurH
ENDHLSL
}

Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment fragBlurV
ENDHLSL
}
}
}

整体的Shader分成两部分一个是获取遮罩,一个是模糊。关于获取遮罩,因为我们要传的焦距开始的位置是距离摄像机本地坐标系z轴方向,即在视角空间(View Space)下的深度值,因此我使用了LinearEyeDepth将深度值转换为视角空间下的深度值。而模糊这里我使用了简单的高斯模糊。这里有一些可能让你疑惑的代码,我这边单独提出来说明一下。

1
2
3
4
half mask = SAMPLE_TEXTURE2D_X(_MaskTex,sampler_LinearClamp, input.uv).x;
int maskF = step(0.5,mask);
int curMaskF = step(0.5,SAMPLE_TEXTURE2D_X(_MaskTex,sampler_LinearClamp, uv).x);
acc += col * kCoeffs[i] * curMaskF;

我们之前说遮罩部分是不参与模糊的。这不仅仅是要体现在模糊方向的计算中即代码中的dir *= maskF * _BlurSize;。同时这也表示在其他邻近像素在计算的模糊的时候,它们也不参与。因此这会导致一个问题,那就是acc.w可能为0,这会导致除法的结果为无穷大。所以这里我让其最小只能是1e-5。即使我这么做后,实际上也是会存在一些问题。在遮罩边缘的像素点会变成黑色,我猜测是有些点位还是在除运算的时候出现了问题。于是我让最后返回的时候进行一次插值,即lerp(baseColor,acc,maskF)。除此之外baseColor我也同样进行了算子的运算。这是因为我发现即使_BlurSize为,0在多次模糊过后,图像本身颜色也会产生改变,我认为这是精度引起的问题。所以为了让清晰部分和模糊部分的颜色保持一致,我让baseColor也参与运算。其实只要保证模糊的次数不多,那么颜色的差距就不大。

一些问题

        相对于Unity官方的实现,我自己实现的效果只能说是一言难尽。只能说可以用,但是不好看。而且在实践过程中,还会出现一些bug使得我不得不加一些莫名其妙的代码来防止。所以这篇文章只是过一遍景深的原理。在Unity官方的实现中,它在遮罩和模糊之间还存在一个Pass做了一个预处理的操作,这个操作用来防止部分地方过亮导致的,在散景中也有对应的操作。除此之外,官方的模糊代码也是和我们在网络上找到的高斯模糊不一样。首先我们的偏移都是等距的比如-2,-1,0,1,2,而Unity官方则是-3.23076923, -1.38461538, 0.00000000, 1.38461538, 3.23076923,且之后分配的数值也有所不同。我在网上找到说法结合源码中的注释,才明白结合双线性插值+降采样操作,使得我们可以使用较少的数据来模拟出更多采样效果的高斯模糊。比如上面这组数据就可以比较与9个采样点的数据(这个对比是从网上得来的,我自己无法证明)。我想正是因为使用了这个方法,所以在Unity官方实现中对于降采样的图片大小计算才会显得那么麻烦。在上面的代码中,我是直接将代码模糊结束后传给摄像机,而官方在这里则是用了一个pass来专门做这个事情。我非常建议各位阅读一下官方源码的实现。对应的文件:GaussianDepthOfField.shadercom.unity.render-pipelines.universal@14.0.11\Shaders\PostProcessing中,DepthOfField.cscom.unity.render-pipelines.universal@14.0.11\Runtime\Overrides中,PostProcessPass.cscom.unity.render-pipelines.universal@14.0.11\Runtime\Passes中。路径中的@14.0.11是URP的版本号,大家可以忽略的。当然如果找不到对应文件,那就是版本不适用了。

散景模糊

        相比于高斯模糊,散景模糊会显得更加的麻烦。如前文所述,我们主要实现的效果就是对弥散圆(circle of confusion)的模拟。所谓的弥散圆就是由于一些原因,相机的成像光束不能会聚于一点,在像平面上形成一个扩散的圆形投影(来自百度百科)。在《GPU Gems》有张形象的图片来解释这个现象。

我将其合并为下面这样的图片:

图片中:

  1. A表示光圈大小(Aperture : (照相机,望远镜等的)光圈,孔径)
  2. C表示弥散圆circle of confusion)
  3. V表示投影距离(Projected Distance)
  4. I表示像距(Image Distance)
  5. F表示焦长/焦距(Focal Length)
  6. D表示物距(Object Distance)
  7. P表示对焦平面(Plane in Focus)。

这里要说明一下,V这边官方的标识虽然是Projected Distance,但是实际上它仍然可以被称为像距。你可以在在《GPU Gems》的图中发现小树最终的成像处正是出于投影点的位置。所以说如果没有其他原因,小树应该是在此处成像才是,而像到透镜的距离就被称为像距。而I这边是我们将成像平面放到了这个位置,可以说是中间截胡了,所以像成在了ImagePlane上,既然像在这个平面上,那么这个平面到透镜的距离自然也可以被称为像距。之所以这样说明是因为在《GPU Gems》的图中,你还能看到下面几个公式:

公式1和公式2其实都是一个公式:成像公式(成像公式,又称透镜成像公式或高斯成像公式,是描述凸透镜成像规律的物理公式)。P表示对焦平面,它表示物体在这个平面的位置上,其投影就可以刚好在ImagePlane上,所以实际上P也算是物距。关于成像公式的更多信息,大家可以参考百度百科上的内容,其中它还做了关于成像公式的证明。

如果你不想去这个网页上看,下面我也写了对应的证明。且也证明了公式3。但是我思来想去觉得这个并不重要,因为公式一定是对的。这就像是一个扩展知识,明白证明或许只是加深印象?

参考图:

uvf必然不会等于0,这里u是物距,v是像距,f是焦距。关于公式3的证明如下,我们可以把图片中的部分图形分离出来如下图所示

图中DE = 弥散圆的直径,即C。BC = 光圈大小,即A。AO是投影距离,即V。FO是像距,即I。

公式3是有一个绝对值符号,这是因为有时候V-I会小于0,即当成像点的位置在投影点的后方。所以要加一个绝对值保证C一定为正数。

看着这些图片你大概就能理解为什么前文说“弥散圆就是由于一些原因,相机的成像光束不能会聚于一点,在像平面上形成一个扩散的圆形投影”。在实际的相机中,我们很难让物体的所有光束都聚于一点。你这时候或许会有疑问:那这样我们拍出来的照片不应该几乎都是模糊而只有少量的地方是清楚的吗?实际上也确实如此,这就是涉及到另外一个知识点:多大直径下的弥散圆是我们可以接受的,那么这个直径以下的弥散圆我们都认为是点。这就像是我们对一张图像进行放大处理,一开始图像保持原状时,你会觉得图像很清晰,随着你不断放大,图像的部分就会模糊起来。所以散景这里判断模糊的方法就是计算出最大的弥散圆的直径,然后根据深度反得出对应的弥散圆的直径。根据这些信息区分近景和远景,然后进行模糊。这里我们就需要考虑一个问题,如何通过深度得到弥散圆的直径,如何知道最大的弥散圆的直径为多少。前面的公式3就是计算弥散圆的直径的公式。

这里讨论为什么P一定大于F

如果我们要成像那么像距I就要在(F,2F)之间,根据成像公式:,所以

因为F>0,I>F,所以。所以P一定大于F。

我们选用左边的公式作为最大的弥散圆的直径的公式:

当然必须要说明的是,这个直径实际上是得不到的。因为只能趋近于1而不能等于1。

一点可能是没必要的补充

在我参考的文章中,有作者说这个公式实际上就是平行光线入射产生的最大弥散圆,但是我不能理解。唯一能让我可以理解的解释出自“A Life Of A Bokeh | Advances in real-time rendering in games SIGGRAPH2018 UE4”中的一个片段。图片如下所示:

图中称这个公式的得到结果为最大背景弥散圆。这里也并没有对后面的结果加上绝对值的符号。这个隐含的信息就是V(真实的投影距离)会小于I(像距)。我实在没找到为什么它是这样称呼的资料。我只能理解为当V小于 I 的时候,物体所在的位置在对焦平面的后方。所以是背景弥散圆。显然当物体处于无穷远处(物距也是无穷远),则趋近于0。此时coc值最大。从代码上来看,最终获取到的所谓MaxCoC也可以方便后面shader的计算。所以我还觉得这个单独拿出来就是为了做一个预处理的操作罢了。对于其意义,我烦恼许久,但是我想它仅仅作为一个预处理的操作也对。所以如果大家实在迷惑,不如放过自己就权当是一个预处理的操作就好,反正最后大家都是走公式,没必要钻这个牛角尖。当然我的说法也不一定是对的,不然我也不用隐藏起来了。

实际使用的时候,我们并不会考虑这个绝对值。以Unity官方代码为例子,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
half FragCoC(Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

float2 uv = UnityStereoTransformScreenSpaceTex(input.texcoord);
float depth = LOAD_TEXTURE2D_X(_CameraDepthTexture, _SourceSize.xy * uv).x;
float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams);

half coc = (1.0 - FocusDist / linearEyeDepth) * MaxCoC;
half nearCoC = clamp(coc, -1.0, 0.0);
half farCoC = saturate(coc);

return saturate((farCoC + nearCoC + 1.0) * 0.5);
}

这里使用的公式就是没有带绝对值的,然后使用正负来区分远景和近景,最后统一回归到[0,1]之间。区分前景和后景的好处是前景的信息和后景的信息不会互相影响,当然这个也是有对应代码的支持。确定好前后景后,我们就要决定弥散圆的形状了。(实际上,弥散圆的形状在我们参数定好后就预先处理完了。)

        关于弥散圆的形状,Unity官方是用固定的42个采样点分成3个部分排列,最内部是7个点,中部是14个点,最外围是21个点。根据变量Blade Count(叶片数量)和Blade Curvature(叶片曲率)的数值显示为不一样的图型。在我看到的其他教程中,有人使用黄金角(Golden Angle)公式来获取弥散圆。两种方法各有优势,Unity官方的做法实际上就是在模拟真实相机的效果,而黄金角做法则是经典的做法。下面是两个做法模拟代码如下所示:

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
// 黄金角
private void GoldenAngle(int count)
{
float angle = Mathf.Rad2Deg * 2.39996323f;

Vector2 point = new Vector2(0, 1);

float r = 1;

for (int i = 0; i < count; ++i)
{
Gizmos.DrawSphere((Vector3)point * (r - 1) + transform.position, 0.5f);
point = Quaternion.AngleAxis(angle,Vector3.forward) * point;
r += 1 / r;
}
}

// 这里我偷懒直接去拿Unity官方实现,只是做了数据拿取上的变化
private void PrepareBokehKernel(float expandRad)
{
const int kRings = 4;
const int kPointsPerRing = 7;

float maxRadius = 0.05f;

float rcpAspect = 1920f / 1080;

var m_BokehKernel = new Vector4[42];

var m_DepthOfField = VolumeManager.instance.stack.GetComponent<UnityEngine.Rendering.Universal.DepthOfField>();

// Fill in sample points (concentric circles transformed to rotated N-Gon)
int idx = 0;
float bladeCount = m_DepthOfField.bladeCount.value;
float curvature = 1f - m_DepthOfField.bladeCurvature.value;
float rotation = m_DepthOfField.bladeRotation.value * Mathf.Deg2Rad;
const float PI = Mathf.PI;
const float TWO_PI = Mathf.PI * 2f;

for (int ring = 1; ring < kRings; ring++)
{
float bias = 1f / kPointsPerRing;
float radius = (ring + bias) / (kRings - 1f + bias);
int points = ring * kPointsPerRing;

for (int point = 0; point < points; point++)
{
// Angle on ring
float phi = 2f * PI * point / points;

// Transform to rotated N-Gon
// Adapted from "CryEngine 3 Graphics Gems" [Sousa13]
float nt = Mathf.Cos(PI / bladeCount);
float dt = Mathf.Cos(phi - (TWO_PI / bladeCount) * Mathf.Floor((bladeCount * phi + Mathf.PI) / TWO_PI));
float r = radius * Mathf.Pow(nt / dt, curvature);
float u = r * Mathf.Cos(phi - rotation);
float v = r * Mathf.Sin(phi - rotation);

float uRadius = u * maxRadius;
float vRadius = v * maxRadius;
float uRadiusPowTwo = uRadius * uRadius;
float vRadiusPowTwo = vRadius * vRadius;
float kernelLength = Mathf.Sqrt((uRadiusPowTwo + vRadiusPowTwo));
float uRCP = uRadius * rcpAspect;

m_BokehKernel[idx] = new Vector4(uRadius, vRadius, kernelLength, uRCP);
idx++;
}
}

foreach(var i in m_BokehKernel)
{
if (i == Vector4.zero)
continue;
Gizmos.DrawSphere((Vector3)i * expandRad + transform.position, 0.1f);
}
}

黄金角公式在数量足够多的时候会越来越像圆。我个人觉得用Unity官方的方式会更好。因为黄金角公式所形成的形状并不是那么的整齐,而且如果要模拟圆形,黄金角公式需要更多的采样点。下面是黄金角公式在42个采样点时的样子。

很明显,这并不想是一个圆形的样子,这更像是一个8边型。所以我个人认为Unity官方的方式会更好一点。正如我前文所述,Unity官方将这个分成了3个环,每个环中点数分别是7,14,21。并且Unity官方在这些采样点上做了优化,这些采样点只有在信息改变的时候才会做修改。第一层for循环是用来计算每个圆环中的点数,和其对应的半径。第二层for则是设置分配点具体的位置。公式整体的推导,大家可以去看一下:URP Bokeh DOF 分析,其中有详细的解释。

不想看跳转文章看解释,可以展开

下图也是按文章给出参考来的。

首先我们要知道一下图中那些东西是我们已经知道的。线段OA是圆的半径,圆的半径是我们定的,这个是已知的。∠AOH的值即π/bladeCount。∠GOH实际上也是已知,其值就是上面代码中的phi。顺带一提,点H就是上述代码会得到的第一个值。

这个就是下面这段代码的证明

1
float r = radius * Mathf.Pow(nt / dt, curvature);

代码中的pow函数是用来作曲线的效果,如果curvature = 0,那么这就是一个圆了。那么接下来就只剩下一个问题了,我们如何证明下面这段代码:

1
float dt = Mathf.Cos(phi - (TWO_PI / bladeCount) * Mathf.Floor((bladeCount * phi + Mathf.PI) / TWO_PI));

显然我们要将点位固定在我们认定的三角形中,这样我们上面的证明才会成立。

在-180°和180°之间,cos是对称的,且三角形中角度只会在-180°和180°之间,即β只会在-180°和180°之间。

你会发现在△MOA的β被转到了△BHO中,而△FOM的β被转移到了△AOH中。我们按照这个思路继续往下推,你会发现我们最终要按phi所处的区域做下面这样的减法:

我们将系数按照区域下标单数和偶数(这里下标从0开始。△AOH为0,逆时针累加)提出我们可以得到,下标偶数系数:0,1,2,3,4,5。下标单数系数:1,2,3,4,5,6。这里我们就可以推断出单数下标系数就是其下标加一除二后向下取整,偶数下标直接除二。我们想知道代码中的phi属于哪个区域只需要

上面的公式也是要向下取值最终系数的公式应该是:

代码还求了kernelLength和uRCP。uRCP为了宽适配屏幕比例做出来数值改变,这样我们等同于对一个以正方形图片进行采样,其长宽等于屏幕的高度。因此我们真正在做采样的时候,是用(uRCP,vRadius)做uv偏移。而kernelLength则是为了后面计算时需要使用。(实际上kernelLength不就是r*maxRadius,Unity源代码里面有点复杂了)

        既然我们分出了前后景,又得到对应采样点,那么我们就应该做模糊操作了。Unity官方在模糊操作前还做了一个过滤处理和coc值的预乘操作。过滤处理主要是为了防止漏光和闪光。预乘操作是为了降低在聚焦区域中的颜色权重,因为散景效果在对焦距离附近最弱。(这个预乘的说法,我并没有找到对应的资料来说明,我倒是觉得这个解释挺对的。)预乘操作后就是进行景深的计算,主要代码如下:

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
void Accumulate(half4 samp0, float2 uv, half4 disp, inout half4 farAcc, inout half4 nearAcc)
{
half4 samp = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + disp.wy);

// Compare CoC of the current sample and the center sample and select smaller one
half farCoC = max(min(samp0.a, samp.a), 0.0);

// Compare the CoC to the sample distance & add a small margin to smooth out
half farWeight = saturate((farCoC - disp.z + _BokehConstants.y) / _BokehConstants.y);
half nearWeight = saturate((-samp.a - disp.z + _BokehConstants.y) / _BokehConstants.y);

// Cut influence from focused areas because they're darkened by CoC premultiplying. This is only
// needed for near field
nearWeight *= step(_BokehConstants.x, -samp.a);

// Accumulation
farAcc += half4(samp.rgb, 1.0h) * farWeight;
nearAcc += half4(samp.rgb, 1.0h) * nearWeight;
}

half4 FragBlur(Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float2 uv = UnityStereoTransformScreenSpaceTex(input.texcoord);

half4 samp0 = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);

half4 farAcc = 0.0; // Background: far field bokeh
half4 nearAcc = 0.0; // Foreground: near field bokeh

// Center sample isn't in the kernel array, accumulate it separately
Accumulate(samp0, uv, 0.0, farAcc, nearAcc);

UNITY_LOOP
for (int si = 0; si < SAMPLE_COUNT; si++)
{
Accumulate(samp0, uv, _BokehKernel[si], farAcc, nearAcc);
}

// Get the weighted average
farAcc.rgb /= farAcc.a + (farAcc.a == 0.0); // Zero-div guard
nearAcc.rgb /= nearAcc.a + (nearAcc.a == 0.0);

// Normalize the total of the weights for the near field
nearAcc.a *= PI / (SAMPLE_COUNT + 1);

// Alpha premultiplying
half alpha = saturate(nearAcc.a);
half3 rgb = lerp(farAcc.rgb, nearAcc.rgb, alpha);

return half4(rgb, alpha);
}

其中_BokehKernel是我们之前算出来的采样点,这段代码主要的想法就是分离出前后景的数据。然后做了一个前后景颜色的插值。这段代码,我大概明白意思,但是实际上我想了很久也没能理解为什么,前面的分离前后景并不难理解,但是后面的归一化我属实没看明白。这个pass后,为了加深模糊的效果,Unity官方又做了一个模糊操作。这个模糊操作很常见就不多说了。最后的步骤就是合并图像了。在我看的文章提到了先合并后景再合并前景。公式推导如下:

这个公式就是很像插值公式,所以最后Unity官方实现也是用插值来做处理。最后我仍然建议你去阅读一下官方源码的实现。对应的文件:BokehDepthOfField.shadercom.unity.render-pipelines.universal@14.0.11\Shaders\PostProcessing中,DepthOfField.cscom.unity.render-pipelines.universal@14.0.11\Runtime\Overrides中,PostProcessPass.cscom.unity.render-pipelines.universal@14.0.11\Runtime\Passes中。

结语

        诚然Unity中已经给出了实现,但是这并不妨碍我们去了解关于它们的知识。或许在未来某个需求中,它的思想或者做法可以给我们带来一些启发呢。文章中大部分是借鉴了URP Bokeh DOF 分析这篇文章,如果你不嫌麻烦可以去看看这篇文章。其中一些细节和文章给出的图片展示,我忽略掉了。因为这文章中所写,大部分是我有一定的了解且有点懂得的东西,那些完全看不懂的,我就没有写下去了。

参考资料

闲言碎语

        景深是真的麻烦,花了我好几周的时间去推敲打磨,看一堆的文章(不得不说网上同质化文章太多了,虽然我自己也在搞这种事情)。结果,我越是推敲越是不懂,有些知识点压根就找不到。Unity官方写的代码里公式满天飞,我也没找到几个来源。不过也算是对景深有一定的了解,且熟悉了Unity官方组件的一些用法也算是有所得吧。真希望我自己可以学得再快一点。真是我太菜了,原本的计划是把两个实现自己都做一下的。最后散景真的太难了,所以我放弃了对应的实现。包括对Unity官方代码的分析,幸好我找到了URP Bokeh DOF 分析这篇文章。不然很多东西,我真的很难懂。当然后面写文巨水的原因就是我能搞懂的都搞懂了,但是要解释太麻烦了。真要认真写真要花上我一两个月。我不想再写了。