Post

景深光线的弯曲(翻译二十五)

景深光线的弯曲(翻译二十五)
  • 确定弥散圆(Circle of Confusion,CoC)。
  • 创建 Bokeh(散景)。
  • 对图像进行聚焦和去焦。
  • 分离和合并前景与背景。

1 搭建场景

我们感知光线是因为我们感觉到光子撞击我们的视网膜。同样,摄像机可以记录光线,是因为光子撞击其胶片或图像传感器。在所有情况下,光线都被聚焦以产生清晰的图像,但并非所有东西都能同时处于焦点上。只有特定距离的东西才是清晰的,而所有更近或更远的东西看起来都是模糊的。这种视觉效果被称为景深(Depth-of-Field)。关于失焦投影如何表现的细节被称为 Bokeh(散景),这是日语中模糊的意思。

通常,我们用自己的眼睛并不会注意到景深,因为我们关注的是焦点所在,而不是焦点之外的东西。它在照片和视频中可能更加明显,因为我们可以观察图像中不在摄像机焦点的部分。尽管这是一种物理限制,但 Bokeh 可以产生巨大的效果来引导观众的注意力。因此,它是一种艺术工具。

GPU 不需要聚焦光线,它们表现得像完美的摄像机,拥有无限的焦点。如果你想创建锐利的图像,这很棒,但如果你想将景深用于艺术目的,这就不太走运了。但是有很多方法可以伪造它。在本教程中,我们将创建一个类似于 Unity Post-processing Stack v2 中的景深效果,尽管会尽可能简化。

1.1 搭建场景

为了测试我们自己的景深效果,创建一个包含不同距离物体的小场景。我使用了一个 10×10 的平面,其电路材料平铺了五次作为地面。它为我们提供了一个具有大跨度、锐利、高频颜色变化的表面。这对于测试来说非常棒。我在上面放了一堆物体,还让四个物体漂浮在摄像机附近。

Test scene
Test scene

我们将为新的 DepthOfField shader 使用与 Bloom shader 相同的设置。你可以复制它,并将其缩减为目前仅执行 blit 的单个 pass。不过,这一次我们将把 shader 放在 Hidden 菜单类别中,这会将其从 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
Shader "Hidden/DepthOfField" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
    }
    CGINCLUDE
        #include "UnityCG.cginc"
        sampler2D _MainTex;
        float4 _MainTex_TexelSize;

        struct VertexData {
            float4 vertex : POSITION;
            float2 uv : TEXCOORD0;
        };

        struct Interpolators {
            float4 pos : SV_POSITION;
            float2 uv : TEXCOORD0;
        };

        Interpolators VertexProgram (VertexData v) {
            Interpolators i;
            i.pos = UnityObjectToClipPos(v.vertex);
            i.uv = v.uv;
            return i;
        }
    ENDCG

    SubShader {
        Cull Off
        ZTest Always
        ZWrite Off
        Pass {
            CGPROGRAM
                #pragma vertex VertexProgram
                #pragma fragment FragmentProgram
                half4 FragmentProgram (Interpolators i) : SV_Target {
                    return tex2D(_MainTex, i.uv);
                }
            ENDCG
        }
    }
}

创建一个极简的 DepthOfFieldEffect 组件,再次使用与 bloom 效果相同的方法,但隐藏 shader 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using UnityEngine;
using System;

[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class DepthOfFieldEffect : MonoBehaviour {
    [HideInInspector]
    public Shader dofShader;

    [NonSerialized]
    Material dofMaterial;

    void OnRenderImage (RenderTexture source, RenderTexture destination) {
        if (dofMaterial == null) {
            dofMaterial = new Material(dofShader);
            dofMaterial.hideFlags = HideFlags.HideAndDontSave;
        }
        Graphics.Blit(source, destination, dofMaterial);
    }
}

为了方便,我们不在 editor 中手动分配 shader,而是将其定义为组件的默认值。为此,在 editor 中选中脚本,并将 shader 字段挂在 inspector 顶部。

Default shader reference
Default shader reference

将我们的新效果作为唯一的后期处理添加到摄像机。再次强调,我们假设在线性 HDR 空间中渲染,因此请相应配置项目和摄像机。此外,因为我们需要读取深度缓冲,此效果在开启 MSAA 时无法正确工作。因此请禁用摄像机的 MSAA。同时请注意,由于我们将依赖深度缓冲,该效果不会考虑透明几何体。

HDR camera without MSAA and with depth-of-field
HDR camera without MSAA and with depth-of-field

那我们就不能将它与透明物体一起使用了吗? 透明物体看起来也会受到影响,但使用的是它们背后任何东西的深度信息。这是所有使用深度缓冲技术的共同局限。你仍然可以使用透明,但只有当这些物体背后有足够近的固体表面时,看起来才勉强可以接受。

2 弥散圆 (Circle of Confusion)

最简单的摄像机形式是完美的针孔摄像机。像所有摄像机一样,它有一个记录光线的图像平面。在图像平面前面有一个极小的孔——被称为光圈(Aperture)——刚好大到允许单根光线通过。摄像机前面的物体会向多个方向发射或反射光线。对于每一个点,只有单根光线能够通过孔并被记录。

Recording three points
Recording three points

投影图像是翻转的吗? 的确如此。所有用摄像机记录的图像,包括你的眼睛,都是翻转的。图像在进一步处理过程中会再次翻转,所以你不需要担心。

因为每个点只捕获单根光线,所以图像总是清晰的。不幸的是,单根光线并不亮,所以产生的图像几乎看不见。你必须等待很长时间才能积累足够的光线以获得清晰的图像,这意味着这种摄像机需要很长的曝光时间。对于移动的物体会产生大量运动模糊。因此,它不是一个实用的摄像机。

为了能够缩短曝光时间,必须更快地积累光线。唯一的方法是同时记录多根光线。这可以通过增大光圈半径来实现。假设孔是圆形的,这意味着每个点都将以一锥光线而不是一根线投射到图像平面上。所以我们接收到了更多的光,但它不再落在一个点上,而是投射成一个圆盘(Disc)。覆盖多大面积取决于点、孔和图像平面之间的距离。结果是得到了一个更亮但模糊的图像。

Using a larger aperture
Using a larger aperture

为了再次聚焦光线,我们必须以某种方式获取进入的光锥并将其带回一个点。这是通过在摄像机孔中放置一个透镜(Lens)来完成的。透镜以这样一种方式弯曲光线,使发散的光再次聚焦。这可以产生明亮且锐利的投影,但仅限于距离摄像机固定距离的点。距离更远的点发出的光线不会被足够聚焦,而距离太近的点发出的光线会被过度聚焦。在两种情况下,我们再次将点投射为圆盘,其大小取决于失焦程度。这种失焦投影被称为弥散圆(circle of confusion,简称 CoC)

Only one point is in focus
Only one point is in focus

2.1 可视化 CoC

弥散圆的半径是衡量点失焦程度的一个指标。让我们从可视化这个值开始。在 DepthOfFieldEffect 中添加一个常量来指示我们的第一个 pass,即 CoC pass。

1
2
3
4
5
6
const int circleOfConfusionPass = 0;
...
void OnRenderImage (RenderTexture source, RenderTexture destination) {
    ...
    Graphics.Blit(source, destination, dofMaterial, circleOfConfusionPass);
}

因为 CoC 取决于到摄像机的距离,我们需要读取深度缓冲。采样深度纹理,转换为线性深度并渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CGINCLUDE
    #include "UnityCG.cginc"
    sampler2D _MainTex, _CameraDepthTexture;
    ...
ENDCG

SubShader {
    ...
    Pass { // 0 circleOfConfusionPass
        CGPROGRAM
            #pragma vertex VertexProgram
            #pragma fragment FragmentProgram
            half4 FragmentProgram (Interpolators i) : SV_Target {
                half depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
                depth = LinearEyeDepth(depth);
                return depth;
            }
        ENDCG
    }
}

2.2 选择简单的 CoC

我们对原始深度值不感兴趣,而是对 CoC 值感兴趣。为此,我们需要确定一个焦距(focus distance)。这是摄像机与焦平面之间的距离,在这个平面上一切都是完美的。添加一个公共字段。

1
2
[Range(0.1f, 100f)]
public float focusDistance = 10f;

CoC 的大小随着点到焦平面的距离而增大。确切的关系取决于摄像机及其配置,这可能变得相当复杂。模拟真实摄像机是可能的,但我们将使用一个简单的焦距范围(focus range),以便更容易理解和控制。我们的 CoC 将在这个范围内从零增加到最大值。

1
2
[Range(0.1f, 10f)]
public float focusRange = 3f;
Sliders for focus distance and range
Sliders for focus distance and range

在 blit 之前设置这些配置。

1
2
3
dofMaterial.SetFloat("_FocusDistance", focusDistance);
dofMaterial.SetFloat("_FocusRange", focusRange);
Graphics.Blit(source, destination, dofMaterial, circleOfConfusionPass);

添加变量到 shader。我们使用深度 $d$,焦距 $f$ 和焦距范围 $r$,可以通过 $CoC = \frac{d - f}{r}$ 找到 CoC。

1
2
3
4
5
6
7
8
float _FocusDistance, _FocusRange;

half4 FragmentProgram (Interpolators i) : SV_Target {
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
    depth = LinearEyeDepth(depth);
    float coc = (depth - _FocusDistance) / _FocusRange;
    return coc;
}
Raw CoC
Raw CoC

这会导致焦距之外的点出现正的 CoC 值,而焦距之前的点出现负的 CoC 值。值 -1 和 1 代表最大 CoC,因此我们应该通过 clamp 确保 CoC 值不超过这个范围。

1
2
3
    float coc = (depth - _FocusDistance) / _FocusRange;
    coc = clamp(coc, -1, 1);
    return coc;

我们保留负的 CoC 值,以便区分前景和背景点。为了看到负的 CoC 值,你可以用红色着色。

Negative CoC is red
Negative CoC is red

2.3 缓冲 CoC

我们需要 CoC 来缩放点的投影,但我们将在另一个 pass 中执行。所以我们将 CoC 值存储在一个临时缓冲中。因为我们只需要存储单个值,我们可以使用单通道纹理 RenderTextureFormat.RHalf。此外,此缓冲包含 CoC 数据而不是颜色值,因此它应该始终被视为线性数据。

1
2
3
4
5
6
7
RenderTexture coc = RenderTexture.GetTemporary(
    source.width, source.height, 0,
    RenderTextureFormat.RHalf, RenderTextureReadWrite.Linear
);
Graphics.Blit(source, coc, dofMaterial, circleOfConfusionPass);
Graphics.Blit(coc, destination);
RenderTexture.ReleaseTemporary(coc);

3 Bokeh (散景)

虽然 CoC 决定了每个点的散景效果强度,但光圈决定了它的外观。基本上,图像是由许多光圈形状在图像平面上的投影构成的。因此,创建散景的一种方法是根据其 CoC 的大小和不透明度,使用其颜色为每个纹素(texel)渲染一个 sprite。由于大量的过度绘制,这种方法成本非常高。

另一种方法是反向工作。与其将单个片元投射到许多片元上,不如让每个片元从所有可能影响它的纹素中累积颜色。这种技术不需要生成额外的几何体,但需要进行大量的纹理采样。我们将使用这种方法。

3.1 累积散景

创建一个用于生成散景效果的新 pass。假设整个图像完全失焦,我们需要平均当前片元周围 9×9 纹素块的颜色。这总共需要 81 次采样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Pass { // 1 bokehPass
    CGPROGRAM
        #pragma vertex VertexProgram
        #pragma fragment FragmentProgram
        half4 FragmentProgram (Interpolators i) : SV_Target {
            half3 color = 0;
            for (int u = -4; u <= 4; u++) {
                for (int v = -4; v <= 4; v++) {
                    float2 o = float2(u, v) * _MainTex_TexelSize.xy;
                    color += tex2D(_MainTex, i.uv + o).rgb;
                }
            }
            color *= 1.0 / 81;
            return half4(color, 1);
        }
    ENDCG
}
Square bokeh
Square bokeh

结果是一个更块状的图像。实际上,我们使用的是方形光圈。

3.2 圆形散景

理想的光圈是圆形的,产生由许多重叠圆盘组成的散景。我们可以通过简单地丢弃那些偏移量太大的样本,将散景形状变成直径为 4 步的圆盘。

Round bokeh
Round bokeh

为了不限于规则网格,我们使用旋转或同心圆模式。我们将使用 Unity Post-processing Stack v2 中的采样核(Kernel)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define BOKEH_KERNEL_MEDIUM
#if defined(BOKEH_KERNEL_SMALL)
    static const int kernelSampleCount = 16;
    ...
#elif defined (BOKEH_KERNEL_MEDIUM)
    static const int kernelSampleCount = 22;
    static const float2 kernel[kernelSampleCount] = {
        float2(0, 0),
        float2(0.53333336, 0),
        ...
    };
#endif

half4 FragmentProgram (Interpolators i) : SV_Target {
    half3 color = 0;
    for (int k = 0; k < kernelSampleCount; k++) {
        float2 o = kernel[k];
        o *= _MainTex_TexelSize.xy * 8;
        color += tex2D(_MainTex, i.uv + o).rgb;
    }
    color *= 1.0 / kernelSampleCount;
    return half4(color, 1);
}
Using the medium kernel
Using the medium kernel

3.3 模糊散景

为了用相同数量的样本覆盖更多区域,我们可以像 bloom 效果一样,在半分辨率下创建该效果。

1
2
3
4
5
6
7
8
9
10
int width = source.width / 2;
int height = source.height / 2;
RenderTextureFormat format = source.format;
RenderTexture dof0 = RenderTexture.GetTemporary(width, height, 0, format);
RenderTexture dof1 = RenderTexture.GetTemporary(width, height, 0, format);

Graphics.Blit(source, coc, dofMaterial, circleOfConfusionPass);
Graphics.Blit(source, dof0);
Graphics.Blit(dof0, dof1, dofMaterial, bokehPass);
Graphics.Blit(dof1, destination);

为了保持散景大小一致,我们必须将采样偏移减半:o *= _MainTex_TexelSize.xy * 4;。同时,我们会在生成散景后添加一个额外的模糊 pass(postfilter pass),使用 3×3 帐篷滤波器(tent filter)。

With a tent filter
With a tent filter

3.4 散景大小

让散景半径通过一个字段可配置,范围为 1–10,默认值为 4(以半分辨率纹素表示)。

1
2
[Range(1f, 10f)]
public float bokehRadius = 4f;
Configurable bokeh radius
Configurable bokeh radius

4 聚焦

现在我们可以确定 CoC 的大小并创建最大尺寸的散景。下一步是结合这些来渲染可变的散景,模拟摄像机聚焦。

4.1 降采样 CoC

因为我们在半分辨率下创建散景,所以我们也需要半分辨率的 CoC 数据。我们必须自己进行降采样,在一个自定义的 prefilter pass 中进行。

1
2
Graphics.Blit(source, coc, dofMaterial, circleOfConfusionPass);
Graphics.Blit(source, dof0, dofMaterial, preFilterPass);

在 prefilter pass 中,我们采用四个高分辨率纹素中极端的 CoC 值(绝对值最大)。

1
2
3
4
5
6
half coc0 = tex2D(_CoCTex, i.uv + o.xy).r;
...
half cocMin = min(min(min(coc0, coc1), coc2), coc3);
half cocMax = max(max(max(coc0, coc1), coc2), coc3);
half coc = cocMax >= -cocMin ? cocMax : cocMin;
return half4(color, coc);

4.2 使用正确的 CoC

在第一个 pass 中计算 CoC 时,将其乘以散景半径:coc = clamp(coc, -1, 1) * _BokehRadius;。 在散景 pass 中,如果样本的 CoC 至少与用于其偏移的核半径(kernel radius)一样大,那么该点的投影就会重叠该片元。

1
2
3
4
5
6
7
8
9
10
for (int k = 0; k < kernelSampleCount; k++) {
    float2 o = kernel[k] * _BokehRadius;
    half radius = length(o);
    ...
    half4 s = tex2D(_MainTex, i.uv + o);
    if (abs(s.a) >= radius) {
        color += s.rgb;
        weight += 1;
    }
}
Bokeh based on CoC
Bokeh based on CoC

4.3 平滑采样

与其完全丢弃样本,我们根据 CoC 和偏移半径分配 0–1 范围内的权重。

1
2
3
half Weigh (half coc, half radius) {
    return saturate((coc - radius + 2) / 2);
}
Smoothed sampling threshold
Smoothed sampling threshold

4.4 保持对焦

半分辨率渲染强制执行了最小程度的模糊。为了让焦点区域保持锐利,我们将半分辨率效果与全分辨率源图像混合。

1
2
half dofStrength = smoothstep(0.1, 1, abs(coc));
half3 color = lerp(source.rgb, dof.rgb, dofStrength);
Sharp in-focus region
Sharp in-focus region

4.5 分离前景和背景

当失焦的前景位于对焦的背景前面时,简单的混合会产生错误。为了解决这个问题,我们需要分离前景和背景。背景样本的权重基于 max(0, min(s.a, coc)),而前景样本基于 -s.a

Cutting out the foreground
Cutting out the foreground

4.6 重新合并前景和背景

我们将前景和背景数据保存在单个缓冲中。在 combine pass 中,根据 CoC 和前景权重进行插值。

1
2
3
4
5
half dofStrength = smoothstep(0.1, 1, abs(coc));
half3 color = lerp(
    source.rgb, dof.rgb,
    dofStrength + dof.a - dofStrength * dof.a
);
Foreground edge without artifacts
Foreground edge without artifacts

4.7 降低散景强度

最后,通过在 prefilter pass 中使用加权平均值来降低散景强度,以防止整体亮度发生太大变化。加权公式为 $w = \frac{1}{1 + \max(r, g, b)}$。

Weighed bokeh e!ect
Weighed bokeh e!ect

你现在拥有了一个简单的景深效果,大致相当于 Unity Post-processing Stack v2 中的效果。

This post is licensed under CC BY 4.0 by the author.