【转载】UE4 景深后处理效果学习笔记

1,558 阅读12分钟

原文链接:

前置知识

什么是景深?

景深本质上是一种光学现象,在摄影中会经常能够看到:

  • 景深示例 cr: pinterest

以中间这张图为例,除了我们聚焦的这个位置的物体,前景后景均产生模糊,模糊的程度随着与焦点的距离增加而(非线性)递增 —— 可以看到在前后偏移的距离相等时,前景的模糊程度更大。(*下文会对该关系作数学描述,这里留个感性印象即可)

景深背后的物理原理?

一言以蔽之 —— 景深是由于光线通过镜头后聚焦的距离不同而形成的

这句话比较抽象,我们可以通过模拟一次相机的成像过程来理解。

首先是一个点的成像

  • 图1 - 点成像示例 cr: 知乎 @TC130

图中 z 点是镜头前的一个点,由这一点反射出的光线(图中虚线)经过镜头(图中纺锤形)的折射,最终在 z' 点汇聚在一起,我们就能够在成像面(图中短直线)上看到 z 点的像,也就是我们在照片里看到的最终图像。

反射率不变条件下,当入射光角度改变,折射光角度随之改变 可知:

z 点在镜头前前后移动的时候,光线汇聚的点,也就是 z' 点的位置也会随之在镜头另一侧前后移动。

  • 图2 - 点移动后成像示例 cr: 知乎 @TC130

原来的 z 点能够恰好在成像面上汇聚成一点,所以我们把它标记成 zf 点,即能在该镜头成像中聚焦的位置。然后在更近的距离取一个新的 z 点,它透过镜头的成像在 z' 点。我们可以发现,它并未来得及在成像面 z'f 上聚成一点,而是分散在了一个范围内。如果我们从正面观察成像面,它应该是这样的:

  • 图3 - a 为聚焦状态,b 为未聚焦状态

可以看到,图 b 中的成像不仅范围大,颜色也较浅,这是因为光线的能量被分散了,同样一个点的成像就像是把面团铺开,范围大了但是也薄了。

从点成像类比到物体成像,相当于场景里每个点都进行一次上述步骤,每个点依据自己与镜头的距离(和镜头的焦距,这个点后文再细说,暂且认为镜头焦距是一个定值)在成像面上投射出一个点或者一个圆,最终聚焦范围就是清晰的,而非聚焦范围由于光线的弥散产生了模糊效果,模糊强度与弥散的大小相关。

补充说明:这也就是为什么散焦景深中背景的光点会形成圆形的原因,其实散焦的形状是与光圈形状一致的,其原理类似于小孔成像

那么这个范围应该如何量化?

弥散圆(Circle of Confusion,下文简写为 COC

为了能够描述弥散范围以及计算弥散效果,我们将图 3.b 中的圆称为弥散圆。(p.s. 在计算中通常用 CoC 表示弥散圆半径)

  • 图4 - 弥散圆计算 cr: wiki

图中符号:
C - 弥散圆
A - 光圈直径
F - 焦距,平行光入射镜头后聚焦的位置与镜头中心的距离,属于镜头的固有属性,不因其他因素的变化而变化
P - 聚焦位置,指的是一个特定的物距,指在确定了镜头和成像平面位置之后,镜头前有一个刚好可以在成像面上聚焦的位置,我们称这个位置与镜头中心的距离为 P
(图上的 Plane in focus 直译可能会有些误导,其实图上的本意是站在一个空间的角度来看,能够在镜头前聚焦的位置应该是一个平行于镜头、距离固定的平面,平面上每个点反射的光都可以在成像面上汇聚成一个点)
D - 物距,也就是我们预设的自变量,我们将我们想要求出 COC 的镜头前的位置定为 D
I - 成像面距离,成像面与镜头中心的距离

他们之间的关系满足一个等式:

其中 P 可以通过 IF 确定,在镜头和成像面确定的情况下也可以称之为常量,因此我们就得到了一个以 C 为因变量,D 为自变量的函数,这个函数的图像(不加绝对值)是这样的:

  • 图5 - CoC 变化趋势 cr: A Life of a Bokeh - SIGGRAPH2018

MaxBgdCoC 指背景最大弥散圆半径,可以通过左边的这个算式得到

在函数图像中,z 即为上面公式的 D,也就是自变量,纵轴即为 CoC 值,可以看到在 P 点,弥散圆半径为 0 ,前景后景随着距离增大,弥散圆半径逐渐增大,而且前景的变化趋势更为陡峭,与我们一开始的感性描述一致。

至此,我们就可以描述整个屏幕上每一个点成像的弥散范围了。 这也就是我们后续进行屏幕后处理计算的基础:获取屏幕上的点,以及它距离摄像机的距离,其他常量都可以通过参数进行调整。(成像面距离不要小于焦距,否则无法聚焦)

UE 中 DOF 计算的架构

UE 中关于 DOF 的 Shader 放在 /Engine/Shader/Private/DiaphragmDOF 文件夹中,有精力的话可以详细阅读。(附录部分文档备注)

在 Unreal 的源码中,计算 COC 的 HLSL 被放在 DepthOdFieldCommon.ush 中,该文档中提供了两种 COC 的计算方式,一种是之前提到过的计算 COC 方式 ComputeCircleOfConfusion另外一种是更加常规的计算方式 ComputeCircleOfConfusionNorm,Norm 版的 COC 仅仅通过一个 TransitionRegion 来做前景和背景之间的线性变换:

  • 图1 - ComputeCircleOfConfusionNorm

即,FocalRegion 范围内 COC 为 0,前后 Blurred 范围均为最大 COC ,Near/Far Transition 范围内是用距离均匀映射在(0,MaxCOC)范围内的一次函数,其变化趋势如图:

  • 图2 - 简化 COC 计算

通过这张图就可以明确地找到场景深度与 COC 之间的一一对应关系了,接下来就是利用这种关系去呈现景深效果。

模糊效果实现

这里我们首先采用的是比较简化的效果实现,更加仿真的细节效果需要在这个基础之上修改或是添加。

核心思想COC + 高斯模糊,利用 COC 控制高斯模糊的强度,达到模拟成像弥散的目的,高斯模糊的原理及实现不是说明重点,而且已经有很完善的讲解了,如果这部分理论知识缺失可以参考这个回答中关于高斯模糊和图像处理的部分:

高斯模糊的原理是什么,怎样在界面中实现?527 赞同 · 37 评论回答

在实现中,我们通过控制高斯模糊的 σ(也就是影响高斯分布形状的参数)来控制模糊程度

  • 图3 - 不同 σ 下的高斯分布图像

可以理解为,这个高斯分布描述了一种混合像素颜色的策略,当高斯模糊的采样来自于一个固定的区域(3x3 或者 5x5 不嫌麻烦且带得动运算量的话想 9x9 都没问题),μ 所在的这个位置即是我们在像素着色器中当前处理的像素,这里的值越高,说明这个像素提供的颜色在混合时的权重越高:

最终像素颜色 = 0号采样 x 0号采样权重 + 1号采样 x 1号采样权重 + …… + n号采样 x n号采样权重

其中权重之和为 1 ,采样和采样数显而易见,就不多做解释了。

我们可以以 图3σ 为 1 和 0.2 的两张图像举例,当 σ 为 1 时,图像矮而胖,如果我们都从 μ 两侧的相同点取值,中间像素的权重值会略高,在归一化(使权重值和为 1 )之后,权重的差距也不会太大,因此最终像素颜色受到周围像素的影响就会更明显,从图像的角度上来说,看上去这个像素就 被周围像素弥散的颜色影响 了。

而当 σ 为 0.2 时,图像看上去瘦长,按照上文的推理,当前像素值提供的权重比周围像素大得多,因此周围像素的影响就很小,甚至于没有影响(当图像极为瘦长时,归一化之后的周围像素权重小于运算精度,就被取 0 ,最终像素的颜色就完全由当前像素提供),因此在图像角度就没有被影响或者是被极小地影响。

推理过程简化表示:

图像矮胖 → 中心权重小,周围权重大 → 像素受到来自周围像素的弥散 → 看上去模糊

这个像素被影响的理念和上一篇文章中提到的光学上的原理是极为类似的,也就是在数据上对光学现象的模拟。

理解了这个推理过程,我们就会发现,用 COC 来操控图像的形状可以达到操控模糊程度的目的,所以从距离到模糊这个处理的链条就彻底补完了,接下来要做的就是在像素着色器中实现这个过程。

代码上对这种计算方式的实现是很简单的,因此我没有把代码放在 usf 文件里,而是在 UE 的材质编辑器里使用了 custom 节点,把代码放在文件里也是完全可行的。

只需要建立一个 PostProcess 材质,并且将节点适当地连接就可以了,要注意默认值的设置要合理,比如采样数要大于一、COC 的运算要符合物理之类的,才能得到符合预期的效果。

代码环节

#define SCENE_COLOR_TEX_ID 14
#define SCENE_DEPTH_TEX_ID 1
#define BLUR_SCALE 2

float2 lookupUV = GetDefaultSceneTextureUV(Parameters, SCENE_COLOR_TEX_ID);

float depth = sDepth;
depth = min(depth,maxDepth);
float center = P;
float BlurFactor = depth - center;
float FocalRegionFixed = FocalRegion + max(BlurFactor,0) * FocalRegionIncrease;
if(abs(BlurFactor) <= FocalRegionFixed / 2){
	BlurFactor = 0;
}
else{
	if(BlurFactor < 0){
		BlurFactor = abs(BlurFactor) - (FocalRegionFixed / 2) - NearFocalRegionBias;
		BlurFactor = min(max(BlurFactor,0), NearTransitionRegion);
		BlurFactor = BlurFactor / (NearBlurSize * NearTransitionRegion);
	}
	else{
		BlurFactor = min(BlurFactor - (FocalRegionFixed / 2), FarTransitionRegion);
		BlurFactor = BlurFactor / (FarBlurSize * FarTransitionRegion);
	}
}

float3 outputColor = {0.0,0.0,0.0};
float weightSum = 0;
float2 samplePos;
float3 sampleColor;
float sampleDepth;
float sampleCoc;
float weight = 0;

float sigma = sigmaIn * BlurFactor * Scale;
sigma = max(sigma,0.000001);
for(int i = 0; i <= 5; i++){
	for(int j = 0; j <= 5; j++){
		float2 posBias = float2(i-2.0f,j-2.0f);
		posBias = BLUR_SCALE * posBias / ScreenRes;
		samplePos = lookupUV + posBias;
		
		#if (SHADING_PATH_MOBILE || ES2_PROFILE || ES3_1_PROFILE || MOBILE_EMULATION)
		sampleColor = MobileSceneTextureLookup(Parameters, SCENE_COLOR_TEX_ID, samplePos).rgb;
		#else
		sampleColor = SceneTextureLookup(samplePos , SCENE_COLOR_TEX_ID, false).rgb;
		#endif
		
		weight = -1 * (posBias.x * posBias.x + posBias.y * posBias.y) / (2 * sigma * sigma);
		weight = exp(weight);
		outputColor += sampleColor * weight;
		weightSum += weight;
		
	}
}//for

return outputColor / weightSum;

为了方便效果调整和测试,有用的参数全都单独提出来了,最终的节点看上去是这个憨态可掬的样子:

其中点 P 采用的是屏幕中心的深度,有需要的话可以在蓝图里获取特定位置或者深度赋给材质,这就是后话了。

踩坑备注

在制作的时候踩了个深坑,之前的处理方式在电脑上 ES3.1 完全没问题,策划甚至已经进行了验收和调整,但是在手机上没有效果。经过漫长的打包测试,最终使用 RenderDoc 排查出,在手机上没有效果的原因是深度缓冲被 clear 掉了,材质通过 MobileSceneTextureLookup 出的值是 0 ,所以出现了错误的效果。

于是我将 P 点深度的获取提取到了节点外部,通过 Unreal 自带的深度节点进行获取,并且将 DOF 后处理材质的运算顺序提前到 Tonemapping 以前,确保深度缓冲不会被清除,打包出来的效果就不会出错了。

此外还需要注意,电脑上调整的效果一定要经过目标平台的验证,比如手机屏幕的分辨率和大小,像后处理这种与屏幕分辨率和大小强相关的内容,是务必需要实机验证的。

从腾讯的 2020 移动游戏质量白皮书里可以看到,目前主流的手机分辨率已经非常高了,不要有用屏幕大小直接等比分辨率的想法,容易造成效果不匹配。

其他叨叨

从 Unreal 官方的 DOF 原理解说 ppt 中有关于进一步物理仿真的几个关键点,基于这种基础的 DOF 模型就会更容易理解,

环形卷积核,也就是将我们普通的从邻接像素采样的方式扩展,环形地获取像素,并且根据这个卷积核重新计算模糊,这个过程被称为 Scatter-as-gathering (权重计算方式要复杂的多)。但是根据采样和权重计算结果的理念是不变的,举一反三出更精细的计算方式。

CoC Flatten & CoC Dilate,避免前景后景溢出,这里通俗来说的话就是将图像分成两组进行计算,而且进行降采样减少消耗。本文里简化的方法是直接取 COC 绝对值,直接交给模糊算法处理,这样节省了计算,但是实际上是不符合物理的,Unreal 则是根据 COC 区分前景和背景,因为在聚焦区域里,背景的模糊是不会溢出到聚焦区域里的,而前景的模糊是会溢出到聚焦区域里的(也就是聚焦区物体在与背景的交界处轮廓是锐利的,与前景的交界处轮廓是模糊的) 。这点在仿真里很关键,因为图形的轮廓锐利程度比较容易被视觉捕捉,产生违和感,通过对图像分区、取分区里 COC 最小值(带符号)可以粗略得到溢出的范围,在将前景背景分别模糊后再用前景覆盖背景合成,就能达到更好的效果。

更细节的还有混合散布(Hybrid Scattering)、光圈形状以及将背景焦散溢出到前景中等高级处理,这里我本人也并没有完全理解,就不更详细说明了 ,具体可以查看参考资料中的 ppt 。

参考资料

A Life of a Bokeh - SIGGRAPH 2018.pptx

附录

  • 部分相关文档笔记