图形学基础|景深效果(Depth of Field/DOF)

图形学基础|景深效果(Depth of Field/DOF)图形学基础 景深效果 DepthofField DOF 文章目录图形学基础 景深效果 DepthofField DOF 一 前言二 景深效果 2 1 物理原理 2 2 弥散圆量化三 景深实现 3 1 非物

大家好,欢迎来到IT知识分享网。

图形学基础|景深效果(Depth of Field/DOF)

一、前言

景深(DOF) 是模拟摄像机镜头对焦特性的一种常见的后处理效果。

在现实生活中,相机只能对特定距离内的物体进行锐利的聚焦,离相机较近或较远的物体会有些失焦。

模糊不仅提供了一个关于物体距离的视觉提示,还引入了焦外成像(Bokeh,散景)。

Bokeh,亦称焦外,是一个摄影术语,一般表示在景深较浅的摄影成像中,落在景深以外的画面,会有逐渐产生松散模糊的效果。

景深示意图:

请添加图片描述

散景示意图:

请添加图片描述

二、景深效果

2.1 物理原理

景深,指的是相机对焦点前后相对清晰的成像范围。在景深之内的图像比较清楚,在这个范围之前或是之后的图像则比较模糊。

相机的最简单形式是完美的针孔相机,它有一个用于记录光的图像平面。在此平面之前是一个被称为光圈(aperture) 的小孔:仅允许一个光束通过。

相机前面的物体会向多个方向发射或反射光,从而产生大量光线。对于每个点,只有一条光线能够穿过孔并被记录。

如下图,记录了3个点。

请添加图片描述

因为每个点只会产生一个光束,因此记录是产生的图像总是锐利的。

但单一的光束不够亮,需要很长的时间积累(这意味着摄像机需要一个长曝光时间)才能得到一张清晰的图像。

为减少曝光时间,光需要积累得足够快。唯一的方法是同时记录多个光线。这能通过增加光圈的半径来实现。

假设光圈为圆形,这意味着每一个点都会在图片平面上投影光束,而不是光线。所以我们会接收更多的光线,但不再是一个点的,而是一个圆盘的。

如下图,使用更大的光圈。

请添加图片描述

为了重新聚焦光线,我们需要将光束还原为一个点,这可以通过在光圈前加一个镜头来实现。镜头可以弯曲光线,使其重新聚焦。这能形成一个亮且锐利的成像,但是只在一个范围内。

对于远处的点,聚焦不够,而对于近处的点,聚焦又过了。两者都让成像变为了光束,也就变模糊了,而此模糊投影正是弥散圆(circle of confusion),简称为CoC

请添加图片描述

2.2 弥散圆量化

当成像平面不在焦点上时,光线无法聚焦在一点,就会朝四周分散开。散开的范围被称为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=XXI

请添加图片描述

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=AXXI=ADFDFDFDFPFPF=AD(PF)F(PD)

其中,图中的各个符号为:

C C C:弥散圆直径;

  • 在计算中通常使用用CoC表示弥散圆半径。

A A A:光圈直径;

F F F:焦距;

  • 焦距属于镜头的固有属性,不因其他因素的变化而变化,是平行光入射镜头后聚焦的位置与镜头中心的距离。

P P P :聚焦位置;

  • 指的是一个特定的物距,指在确定了镜头和成像平面位置之后,镜头前有一个刚好可以在成像面上聚焦的位置,我们称这个位置与镜头中心的距离为 P P P
  • 图上的Plane in focus直译可能会有些误导,其实图上的本意是站在一个空间的角度来看,能够在镜头前聚焦的位置应该是一个平行于镜头、距离固定的平面,平面上每个点反射的光都可以在成像面上汇聚成一个点

D D D:物距;

  • 物体距离镜头的距离,即要求这个距离的物体在镜头上的COC弥散圆。

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。

前景后景随着距离增大,弥散圆半径逐渐增大,而且前景的变化趋势更为陡峭。

至此,我们就可以描述整个屏幕上每一个点成像的弥散范围了。

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

三、景深实现

3.1 非物理的简单实现

这个部分比较简单,笔者就没有实现。

最简单的实现如下图所示,从摄像机位置开始,按照距离分为三个部分:

  • 近距离模糊;
  • 焦点范围清晰;
  • 远距离模糊;

在这里插入图片描述

渲染的时候按深度(即距离)进行判断,在焦点范围内则是清晰的,否则就进行模糊处理。

整个过程共分为三个Pass:

  1. 将场景渲染到一个RenderTarget,作为清晰版;
  2. 将上一步得到的RenderTarget进行模糊处理,得到BluredRT(模糊版);
  3. 合成!跟据距离来判断是否应该模糊,如果不在焦点范围内则绘制BluredRT,否则就绘制RenderTarget。

示例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.2 基于物理的景深效果

3.1 仅通过不加区分的模糊操作来实现景深DOF效果,效果比较一般。

比较符合物理的做法,是基于2.2推导的弥散圆Circle Of Confusion(COC),将某点的像素值均匀地沿着弥散圈扩散。

但在GPU中实现向外扩散的操作是非常困难的。

因而,通常将该过程反过来,计算某个像素点的颜色时,根据周围像素点弥散圈的大小从周围的像素点中搜集 (gather) 像素颜色。

动视暴雪在Siggraph2014上分享了他们的Scatter As Gather方法。

在这里插入图片描述

搜集的过程,就是在周围的像素点中,寻找处于周围像素点弥散圈范围内的像素点,将其颜色按照一定的权重累加。

如图所示:

  • 左边,五个点:红色和蓝色处于近处,黄色和紫色点处于远处,绿色点是正确对焦处。
  • 右边,展示了每个像素点的弥散圈范围,弥散圈的范围越大,对其他像素点的影响越小,中间黑色的点的位置受到两个弥散圈的影响。

在这里插入图片描述

先给出实现的效果图:

在这里插入图片描述

笔者的实现共分为5个Pass,分别为:

  • CoC(弥散圆计算);
  • DownSample and Prefilter(下采样和预滤波);
  • Bokeh Filter(散景滤波);
  • Postfilter(后滤波);
  • Combine(混合);

实现基于DX12的ComputeShader。

3.2.1 CoC计算

为了实现的简单方便,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=FoucusRangeSceneDepthFoucusDistance

其中, S c e n e D e p t h SceneDepth SceneDepth表示像素在相机空间的深度。

如下图所示,横轴表示深度的变化,纵轴表示Coc的量化值(这里将其截断为-1到1之间的值)。

在这里插入图片描述

具体的Shader代码如下:

RWTexture2D<float> CoCBuffer : register(u0); Texture2D<float> 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); } 
3.2.2 下采样和预滤波

我们将在半分辨率的情况下创建散景的效果。所以需要一个下采样的Pass。

在这个Pass中,我们将获取邻近四纹素中最显著的CoC值,而非进行平均(这样并没有意义)。\

对于颜色,进行了基于亮度和coc绝对值的加权。

最后,输出的四通道中rgb存储了下采样滤波后的颜色值,alpha通道存储了coc值。

注意,这里的coc值乘以了BokehRadius(散景半径),转换为了散景距离值。

在这里插入图片描述

具体的Shader代码如下:

RWTexture2D<float4> PrefilterColor : register(u0); Texture2D<float3> SceneColor : register(t0); Texture2D<float> 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); } 
3.2.3 散景滤波

在半分辨率预过滤后,我们需要创建散景图。采用的方法即前面所介绍的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.,0), float2(0.,0.), float2(-0.0,0.), float2(-0.,0.), float2(-0.,-0.), float2(-0.0,-0.), float2(0.,-0.), float2(0.,0), float2(0.,0.), float2(0.,0.), float2(0.,0.), float2(-0.,0.), float2(-0.,0.), float2(-0.,0.), float2(-0.,0), float2(-0.,-0.), float2(-0.,-0.), float2(-0.,-0.), float2(0.,-0.), float2(0.,-0.), float2(0.,-0.), float2(1,0), float2(0.,0.), float2(0.,0.), float2(0.,0.), float2(0.,0.), float2(0.07473,0.), float2(-0.,0.), float2(-0.,0.), float2(-0.,0.), float2(-0.,0.), float2(-0.,0.), float2(-0.,-0.), float2(-0.,-0.), float2(-0.,-0.), float2(-0.,-0.), float2(-0.,-0.), float2(0.0,-0.), float2(0.,-0.), float2(0.,-0.), float2(0.,-0.56332), float2(0.,-0.), }; #endif 

具体的Shader代码如下:

#define KERNEL_LARGE #include "DiskKernels.hlsl" RWTexture2D<float4> BokehColor : register(u0); Texture2D<float4> 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. / kSampleCount; // Alpha premultiplying float alpha = saturate(fgAcc.a); // 前散景和后散景融合 float3 rgb = lerp(bgAcc.rgb, fgAcc.rgb, alpha); // alpha存储的是前散景的权重 BokehColor[DTid.xy] = float4(rgb, alpha); } 
3.2.4 后滤波

在创建散景效果之后再添加一个额外的模糊pass,这是一个后处理pass。采用3×3被称为帐篷滤波 (tent filter) 的卷积核。

在这里插入图片描述

在这里插入图片描述

通过一个半纹素偏移的方形滤波器,基于GPU的双线性插值,实现了一个小高斯模糊。

具体的Shader代码如下:

RWTexture2D<float4> PostfilterColor : register(u0); Texture2D<float4> 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; } 
3.2.5 混合

使用后滤波的散景图和原始的场景图进行混合。

基于coc值进行非线性的插值。

具体的Shader代码如下:

RWTexture2D<float3> CombineColor : register(u0); Texture2D<float3> TempSceneColor : register(t0); Texture2D<float> CoCBuffer : register(t1); Texture2D<float4> 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; } 

参考博文

  • 基于物理的景深效果
  • DOF的改进算法
  • 渲染中的景深(Depth of Field/DOF)
  • CatlikeCoding-DOF
  • 景深效果(译)
  • Unity-DOF-Shader
  • UE4景深后处理效果学习笔记(一)基本原理
  • UE4景深后处理效果学习笔记(二)效果实现

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/113782.html

(0)
上一篇 2025-12-12 19:45
下一篇 2025-12-12 20:10

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信