景深(DOF) 是模拟摄像机镜头对焦特性的一种常见的后处理效果。
在现实生活中,相机只能对特定距离内的物体进行锐利的聚焦,离相机较近或较远的物体会有些失焦。
模糊不仅提供了一个关于物体距离的视觉提示,还引入了焦外成像(Bokeh,散景)。
Bokeh,亦称焦外,是一个摄影术语,一般表示在景深较浅的摄影成像中,落在景深以外的画面,会有逐渐产生松散模糊的效果。
景深示意图:
散景示意图:
景深,指的是相机对焦点前后相对清晰的成像范围。在景深之内的图像比较清楚,在这个范围之前或是之后的图像则比较模糊。
相机的最简单形式是完美的针孔相机,它有一个用于记录光的图像平面。在此平面之前是一个被称为光圈(aperture) 的小孔:仅允许一个光束通过。
相机前面的物体会向多个方向发射或反射光,从而产生大量光线。对于每个点,只有一条光线能够穿过孔并被记录。
如下图,记录了3个点。
因为每个点只会产生一个光束,因此记录是产生的图像总是锐利的。
但单一的光束不够亮,需要很长的时间积累(这意味着摄像机需要一个长曝光时间)才能得到一张清晰的图像。
为减少曝光时间,光需要积累得足够快。唯一的方法是同时记录多个光线。这能通过增加光圈的半径来实现。
假设光圈为圆形,这意味着每一个点都会在图片平面上投影光束,而不是光线。所以我们会接收更多的光线,但不再是一个点的,而是一个圆盘的。
如下图,使用更大的光圈。
为了重新聚焦光线,我们需要将光束还原为一个点,这可以通过在光圈前加一个镜头来实现。镜头可以弯曲光线,使其重新聚焦。这能形成一个亮且锐利的成像,但是只在一个范围内。
对于远处的点,聚焦不够,而对于近处的点,聚焦又过了。两者都让成像变为了光束,也就变模糊了,而此模糊投影正是弥散圆(circle of confusion),简称为CoC。
当成像平面不在焦点上时,光线无法聚焦在一点,就会朝四周分散开。散开的范围被称为Circle Of Confusion,简称COC。
那么如何量化这个弥散圆的大小呢?
根据下图:
镜头(光圈直径为 A A A)和成像平面位置( 成像平面距光圈的距离为 I I I)。
在距镜头距离为 P P P的点,刚好可以落在成像平面上。
根据凸透镜成像公式:
1 u + 1 v = 1 F \frac{1}{u} + \frac{1}{v} = \frac{1}{F} u1+v1=F1
其中, u u u表示物距, v v v表示像距, F F F表示焦距。
则有:
1 P + 1 I = 1 F \frac{1}{P} + \frac{1}{I} = \frac{1}{F} P1+I1=F1
在距镜头距离为 D D D的物体,在成像平面上形成直径为 C C C的弥散圆,其经凸透镜聚焦为投影点,假设其距离光圈的距离为 X X X。
1 D + 1 X = 1 F \frac{1}{D} + \frac{1}{X} = \frac{1}{F} D1+X1=F1
如下图所示,根据相似三角形有:
C A = X − I X \frac{C}{A} = \frac{X-I}{X} AC=XX−I
C = A ⋅ X − I X = A ⋅ D F D − F − P F P − F D F D − F = ∣ A F ( P − D ) D ( P − F ) ∣ \begin{aligned} C &= A \cdot \frac{X-I}{X} \\ &= A \cdot \frac{\frac{DF}{D-F}-\frac{PF}{P-F}}{\frac{DF}{D-F}} \\ &= \left | A \frac{F(P-D)}{D(P-F)} \right | \end{aligned} C=A⋅XX−I=A⋅D−FDFD−FDF−P−FPF=∣∣∣∣AD(P−F)F(P−D)∣∣∣∣
其中,图中的各个符号为:
C C C:弥散圆直径;
A A A:光圈直径;
F F F:焦距;
P P P :聚焦位置;
D D D:物距;
I I I:成像面距离;
P P P可以通过 I I I和 F F F确定,在镜头和成像面确定的情况下也可以称之为常量。
因此,我们就得到了一个以 C C C为因变量, D D D为自变量的函数,这个函数的图像(不加绝对值)是这样的:
MaxBgdCoC,指背景最大弥散圆半径,可以通过左边的这个算式得到。
在函数图像中,z即为上面公式的 D D D,也就是自变量,纵轴即为CoC值,可以看到在P点,弥散圆半径为0。
前景后景随着距离增大,弥散圆半径逐渐增大,而且前景的变化趋势更为陡峭。
至此,我们就可以描述整个屏幕上每一个点成像的弥散范围了。
这也就是我们后续进行屏幕后处理计算的基础:获取屏幕上的点,以及它距离摄像机的距离,其他常量都可以通过参数进行调整。(成像面距离不要小于焦距,否则无法聚焦)。
这个部分比较简单,笔者就没有实现。
最简单的实现如下图所示,从摄像机位置开始,按照距离分为三个部分:
渲染的时候按深度(即距离)进行判断,在焦点范围内则是清晰的,否则就进行模糊处理。
整个过程共分为三个Pass:
示例Shader代码:
sampler RenderTarget;
sampler BluredRT;
// 焦点范围
float fNearDis;
float fFarDis;
float4 ps_main( float2 TexCoord : TEXCOORD0 ) : COLOR0
{
float4 color = tex2D( RenderTarget, TexCoord );
if( color.a > fNearDis && color.a < fFarDis )
return color;
else
return tex2D( BluredRT, TexCoord );
}
效果如下:
可以看出,上图看起来很不自然。
原因就是DOF在清晰与模糊的交界处过渡太生硬。
可以通过增加两个过渡带实现一个简单的渐变。
示例Shader代码:
sampler RenderTarget;
sampler BluredRT;
// 焦点范围
float fNearDis;
float fFarDis;
float fNearRange;
float fFarRange;
float4 ps_main( float2 TexCoord : TEXCOORD0 ) : COLOR0
{
float4 sharp = tex2D( RenderTarget, TexCoord );
float4 blur= tex2D( BluredRT, TexCoord );
// sharp.a存储的
float percent = max(saturate(sharp.a-fNearDis)/fNearRange),saturate((sharp.a-(fFarDis-fFarRange))/fFarRange));
return lerp( sharp, blur, percent );
}
效果如下:
3.1 仅通过不加区分的模糊操作来实现景深DOF效果,效果比较一般。
比较符合物理的做法,是基于2.2推导的弥散圆Circle Of Confusion(COC),将某点的像素值均匀地沿着弥散圈扩散。
但在GPU中实现向外扩散的操作是非常困难的。
因而,通常将该过程反过来,计算某个像素点的颜色时,根据周围像素点弥散圈的大小从周围的像素点中搜集 (gather) 像素颜色。
动视暴雪在Siggraph2014上分享了他们的Scatter As Gather方法。
搜集的过程,就是在周围的像素点中,寻找处于周围像素点弥散圈范围内的像素点,将其颜色按照一定的权重累加。
如图所示:
左边,五个点:红色和蓝色处于近处,黄色和紫色点处于远处,绿色点是正确对焦处。
右边,展示了每个像素点的弥散圈范围,弥散圈的范围越大,对其他像素点的影响越小,中间黑色的点的位置受到两个弥散圈的影响。
整个景深后处理的过程大致是这样的:
笔者参考Unity-Technologies/PostProcessing/DepthOfField和CatlikeCoding-DOF实现一个稍微有点基于物理的景深DOF效果。
先给出实现的效果图:
笔者的实现共分为5个Pass,分别为:
实现基于DX12的ComputeShader。
为了实现的简单方便,CoC的公式并没有选择2.2中介绍的公式。而采用了下面的一种方法。
基于一个焦点距离(FoucusDistance)一个简单的焦点区域(FoucusRange):
正如CatlikeCoding-DOF中使用的:
CoC值采用如下公式:
c o c = S c e n e D e p t h − F o u c u s D i s t a n c e F o u c u s R a n g e coc = \frac{SceneDepth - FoucusDistance}{FoucusRange} coc=FoucusRangeSceneDepth−FoucusDistance
其中, S c e n e D e p t h SceneDepth SceneDepth表示像素在相机空间的深度。
如下图所示,横轴表示深度的变化,纵轴表示Coc的量化值(这里将其截断为-1到1之间的值)。
具体的Shader代码如下:
RWTexture2D CoCBuffer : register(u0);
Texture2D DepthBuffer : register(t0);
cbuffer CB0 : register(b0)
{
float FoucusDistance;
float FoucusRange;
float2 ClipSpaceNearFar;
};
[numthreads(8, 8, 1)]
void cs_FragCoC
(
uint3 DTid : SV_DispatchThreadID
)
{
// screenPos
const uint2 ScreenST = DTid.xy;
// non-linear depth
float Depth = DepthBuffer[ScreenST];
// 转到相机空间的深度
float SceneDepth = LinearEyeDepth(Depth, ClipSpaceNearFar.x, ClipSpaceNearFar.y);
// compute simple coc
float coc = (SceneDepth - FoucusDistance) / FoucusRange;
coc = clamp(-1, 1, coc);
// 将[-1,1]转换为[0,1]
CoCBuffer[ScreenST] = saturate(coc * 0.5f + 0.5f);
}
我们将在半分辨率的情况下创建散景的效果。所以需要一个下采样的Pass。
在这个Pass中,我们将获取邻近四纹素中最显著的CoC值,而非进行平均(这样并没有意义)。\
对于颜色,进行了基于亮度和coc绝对值的加权。
最后,输出的四通道中rgb存储了下采样滤波后的颜色值,alpha通道存储了coc值。
注意,这里的coc值乘以了BokehRadius(散景半径),转换为了散景距离值。
具体的Shader代码如下:
RWTexture2D PrefilterColor : register(u0);
Texture2D SceneColor : register(t0);
Texture2D CoCBuffer : register(t1);
SamplerState LinearSampler : register(s0);
cbuffer CB0 : register(b0)
{
float BokehRadius;
};
void GetSampleUV(uint2 ScreenCoord, inout float2 UV, inout float2 HalfPixelSize)
{
float2 ScreenSize;
PrefilterColor.GetDimensions(ScreenSize.x, ScreenSize.y);
float2 InvScreenSize = rcp(ScreenSize);
HalfPixelSize = 0.5 * InvScreenSize;
UV = ScreenCoord * InvScreenSize + HalfPixelSize;
}
[numthreads(8, 8, 1)]
void cs_FragPrefilter(uint3 DTid : SV_DispatchThreadID)
{
float2 HalfPixelSize, UV;
GetSampleUV(DTid.xy, UV, HalfPixelSize);
// screenPos
float2 uv0 = UV - HalfPixelSize;
float2 uv1 = UV + HalfPixelSize;
float2 uv2 = UV + float2(HalfPixelSize.x, -HalfPixelSize.y);
float2 uv3 = UV + float2(-HalfPixelSize.x, HalfPixelSize.y);
// Sample source colors
float3 c0 = SceneColor.SampleLevel(LinearSampler, uv0, 0).xyz;
float3 c1 = SceneColor.SampleLevel(LinearSampler, uv1, 0).xyz;
float3 c2 = SceneColor.SampleLevel(LinearSampler, uv2, 0).xyz;
float3 c3 = SceneColor.SampleLevel(LinearSampler, uv3, 0).xyz;
float3 result = (c0 + c1 + c2 + c3) * 0.25;
// Sample CoCs
// convert [0,1] to [-1,1]
float coc0 = CoCBuffer.SampleLevel(LinearSampler, uv0, 0).r * 2 - 1;
float coc1 = CoCBuffer.SampleLevel(LinearSampler, uv1, 0).r * 2 - 1;
float coc2 = CoCBuffer.SampleLevel(LinearSampler, uv2, 0).r * 2 - 1;
float coc3 = CoCBuffer.SampleLevel(LinearSampler, uv3, 0).r * 2 - 1;
// Apply CoC and luma weights to reduce bleeding and flickering
float w0 = abs(coc0) / (Max3(c0.r, c0.g, c0.b) + 1.0);
float w1 = abs(coc1) / (Max3(c1.r, c1.g, c1.b) + 1.0);
float w2 = abs(coc2) / (Max3(c2.r, c2.g, c2.b) + 1.0);
float w3 = abs(coc3) / (Max3(c3.r, c3.g, c3.b) + 1.0);
// Weighted average of the color samples
half3 avg = c0 * w0 + c1 * w1 + c2 * w2 + c3 * w3;
avg /= max(w0 + w1 + w2 + w3, 1e-4);
// Select the largest CoC value
float cocMin = min(min(min(coc0, coc1), coc2), coc3);
float cocMax = max(max(max(coc0, coc1), coc2), coc3);
float coc = (-cocMin >= cocMax ? cocMin : cocMax) * BokehRadius;
// Premultiply CoC again
avg *= smoothstep(0, HalfPixelSize.y * 4, abs(coc));
// alpha通道存储了coc值
PrefilterColor[DTid.xy] = float4(avg, coc);
}
在半分辨率预过滤后,我们需要创建散景图。采用的方法即前面所介绍的Gather的方法。
通过圆盘采样(DiskKernel)来查看采样点的Coc是否覆盖了当前中心点。
并且分别累积前散景和后散景。
圆盘采样提供了好几个,不同的采样数量,所形成环不同。
笔者这里选择了KERNEL_LARGE
。
#if defined(KERNEL_LARGE)
// rings = 4
// points per ring = 7
static const int kSampleCount = 43;
static const float2 kDiskKernel[kSampleCount] = {
float2(0,0),
float2(0.36363637,0),
float2(0.22672357,0.28430238),
float2(-0.08091671,0.35451925),
float2(-0.32762504,0.15777594),
float2(-0.32762504,-0.15777591),
float2(-0.08091656,-0.35451928),
float2(0.22672352,-0.2843024),
float2(0.6818182,0),
float2(0.614297,0.29582983),
float2(0.42510667,0.5330669),
float2(0.15171885,0.6647236),
float2(-0.15171883,0.6647236),
float2(-0.4251068,0.53306687),
float2(-0.614297,0.29582986),
float2(-0.6818182,0),
float2(-0.614297,-0.29582983),
float2(-0.42510656,-0.53306705),
float2(-0.15171856,-0.66472363),
float2(0.1517192,-0.6647235),
float2(0.4251066,-0.53306705),
float2(0.614297,-0.29582983),
float2(1,0),
float2(0.9555728,0.2947552),
float2(0.82623875,0.5633201),
float2(0.6234898,0.7818315),
float2(0.36534098,0.93087375),
float2(0.07473,0.9972038),
float2(-0.22252095,0.9749279),
float2(-0.50000006,0.8660254),
float2(-0.73305196,0.6801727),
float2(-0.90096885,0.43388382),
float2(-0.98883086,0.14904208),
float2(-0.9888308,-0.14904249),
float2(-0.90096885,-0.43388376),
float2(-0.73305184,-0.6801728),
float2(-0.4999999,-0.86602545),
float2(-0.222521,-0.9749279),
float2(0.07473029,-0.99720377),
float2(0.36534148,-0.9308736),
float2(0.6234897,-0.7818316),
float2(0.8262388,-0.56332),
float2(0.9555729,-0.29475483),
};
#endif
具体的Shader代码如下:
#define KERNEL_LARGE
#include "DiskKernels.hlsl"
RWTexture2D BokehColor : register(u0);
Texture2D PrefilterColor : register(t0);
SamplerState LinearSampler : register(s0);
cbuffer CB0 : register(b0)
{
float BokehRadius;
};
//------------------------------------------------------- HELP FUNCTIONS
void GetSampleUV(uint2 ScreenCoord, inout float2 UV, inout float2 PixelSize)
{
float2 ScreenSize;
BokehColor.GetDimensions(ScreenSize.x, ScreenSize.y);
float2 InvScreenSize = rcp(ScreenSize);
PixelSize = InvScreenSize;
UV = ScreenCoord * InvScreenSize + 0.5 * InvScreenSize;
}
//------------------------------------------------------- ENTRY POINT
// Bokeh filter with disk-shaped kernels
[numthreads(8, 8, 1)]
void cs_FragBokehFilter(uint3 DTid : SV_DispatchThreadID)
{
float2 PixelSize, UV;
GetSampleUV(DTid.xy, UV, PixelSize);
float4 center = PrefilterColor.SampleLevel(LinearSampler, UV, 0);
float4 bgAcc = 0.0; // Background: far field bokeh
float4 fgAcc = 0.0; // Foreground: near field bokeh
for (int k = 0; k < kSampleCount; ++k)
{
float2 offset = kDiskKernel[k] * BokehRadius;
float dist = length(offset);
offset *= PixelSize;
float4 samp = PrefilterColor.SampleLevel(LinearSampler, UV + offset, 0);
// BG: Compare CoC of the current sample and the center sample and select smaller one.
float bgCoC = max(min(center.a, samp.a), 0.0);
// Compare the CoC to the sample distance. Add a small margin to smooth out.
const float margin = PixelSize.y * 2;
float bgWeight = saturate((bgCoC - dist + margin) / margin);
// Foregound's coc is negative
float fgWeight = saturate((-samp.a - dist + margin) / margin);
// Cut influence from focused areas because they're darkened by CoC premultiplying. This is only needed for near field.
// 减少聚焦区域的影响,它们因CoC预乘而变暗。这仅适用于近场。
fgWeight *= step(PixelSize.y, -samp.a);
// Accumulation
bgAcc += half4(samp.rgb, 1.0) * bgWeight;
fgAcc += half4(samp.rgb, 1.0) * fgWeight;
}
// Get the weighted average.
bgAcc.rgb /= bgAcc.a + (bgAcc.a == 0.0); // zero-div guard
fgAcc.rgb /= fgAcc.a + (fgAcc.a == 0.0);
// FG: Normalize the total of the weights.
// 归一化前散景权重
fgAcc.a *= 3.14159265359 / kSampleCount;
// Alpha premultiplying
float alpha = saturate(fgAcc.a);
// 前散景和后散景融合
float3 rgb = lerp(bgAcc.rgb, fgAcc.rgb, alpha);
// alpha存储的是前散景的权重
BokehColor[DTid.xy] = float4(rgb, alpha);
}
在创建散景效果之后再添加一个额外的模糊pass,这是一个后处理pass。采用3x3被称为帐篷滤波 (tent filter) 的卷积核。
通过一个半纹素偏移的方形滤波器,基于GPU的双线性插值,实现了一个小高斯模糊。
具体的Shader代码如下:
RWTexture2D PostfilterColor : register(u0);
Texture2D BokehColor : register(t0);
SamplerState LinearSampler : register(s0);
//------------------------------------------------------- HELP FUNCTIONS
void GetSampleUV(uint2 ScreenCoord, inout float2 UV, inout float2 HalfPixelSize)
{
float2 ScreenSize;
PostfilterColor.GetDimensions(ScreenSize.x, ScreenSize.y);
float2 InvScreenSize = rcp(ScreenSize);
HalfPixelSize = 0.5 * InvScreenSize;
UV = ScreenCoord * InvScreenSize + HalfPixelSize;
}
//------------------------------------------------------- ENTRY POINT
// tent filter
/**
* 1 2 1
* 2 4 2
* 1 2 1
*/
[numthreads(8, 8, 1)]
void cs_FragPostfilter(uint3 DTid : SV_DispatchThreadID)
{
// 9 tap tent filter with 4 bilinear samples
float2 HalfPixelSize, UV;
GetSampleUV(DTid.xy, UV, HalfPixelSize);
float2 uv0 = UV - HalfPixelSize;
float2 uv1 = UV + HalfPixelSize;
float2 uv2 = UV + float2(HalfPixelSize.x, -HalfPixelSize.y);
float2 uv3 = UV + float2(-HalfPixelSize.x, HalfPixelSize.y);
float4 acc = 0;
acc += BokehColor.SampleLevel(LinearSampler, uv0, 0);
acc += BokehColor.SampleLevel(LinearSampler, uv1, 0);
acc += BokehColor.SampleLevel(LinearSampler, uv2, 0);
acc += BokehColor.SampleLevel(LinearSampler, uv3, 0);
PostfilterColor[DTid.xy] = acc / 4.0f;
}
使用后滤波的散景图和原始的场景图进行混合。
基于coc值进行非线性的插值。
具体的Shader代码如下:
RWTexture2D CombineColor : register(u0);
Texture2D TempSceneColor : register(t0);
Texture2D CoCBuffer : register(t1);
Texture2D FilterColor : register(t2);
SamplerState LinearSampler : register(s0);
cbuffer CB0 : register(b0)
{
float BokehRadius;
};
//------------------------------------------------------- HELP FUNCTIONS
void GetSampleUV(uint2 ScreenCoord, inout float2 UV, inout float2 PixelSize)
{
float2 ScreenSize;
CombineColor.GetDimensions(ScreenSize.x, ScreenSize.y);
float2 InvScreenSize = rcp(ScreenSize);
PixelSize = InvScreenSize;
UV = ScreenCoord * InvScreenSize + 0.5 * PixelSize;
}
//------------------------------------------------------- ENTRY POINT
[numthreads(8, 8, 1)]
void cs_FragCombine(uint3 DTid : SV_DispatchThreadID)
{
// screenPos
float2 PixelSize, UV;
GetSampleUV(DTid.xy, UV, PixelSize);
float3 source = TempSceneColor.SampleLevel(LinearSampler, UV, 0);
// 采样当前片段的CoC值
float coc = CoCBuffer.SampleLevel(LinearSampler, UV, 0);
// 转换为散景距离
coc = (coc - 0.5) * 2.0 * BokehRadius;
// 采样散景
float4 dof = FilterColor.SampleLevel(LinearSampler, UV, 0);
// Convert CoC to far field alpha value.
// 对CoC正值进行插值,用于获取后散景
float ffa = smoothstep(PixelSize.y * 2.0, PixelSize.y * 4.0, coc);
// 非线性插值
float3 color = lerp(source, dof.rgb, ffa + dof.a - ffa * dof.a);
CombineColor[DTid.xy] = color;
}