Post

自定义渲染管线:HDR (翻译十二)

实现高动态范围渲染、散射和高动态范围映射

自定义渲染管线:HDR (翻译十二)
  • 渲染到 HDR 纹理。
  • 减少泛光火花(fireflies)。
  • 添加散射泛光(scattering bloom)。
  • 支持多种色调映射模式。

高动态范围

到目前为止,当渲染相机时,我们一直使用低动态颜色范围(LDR,Low Dynamic Range),这是默认模式。这意味着每个颜色通道的值被钳制在 0–1 范围内。在这种模式下,(0,0,0) 表示黑色,(1,1,1) 表示白色。虽然我们的着色器可能产生超出此范围的结果,但 GPU 在存储颜色时会将其钳制,就像我们在每个片段函数的末尾使用了 saturate 一样。

你可以使用帧调试器(Frame Debugger)检查每个绘制调用的渲染目标类型。普通相机的目标描述为 B8G8R8A8_SRGB。这意味着它是一个 RGBA 缓冲区,每个通道 8 位,因此每个像素 32 位。同时,RGB 通道以 sRGB 色彩空间存储。由于我们在线性色彩空间中工作,GPU 在从缓冲区读取和写入时会自动在两种空间之间进行转换。渲染完成后,缓冲区被发送到显示器,显示器将其解释为 sRGB 颜色数据。

只要光照强度不超过每个颜色通道最大值 1,这种方式就可以正常工作。但入射光的强度并没有固有的上限。太阳就是一个极其明亮的光源的例子,这就是为什么你不应该直接看它。它的强度远远超过我们在眼睛受损之前能够感知的强度。但许多常规光源也会产生强度超过观察者极限的光,特别是在近距离观察时。为了正确处理这种强度,我们必须渲染到支持大于 1 值的高动态范围(HDR)缓冲区。

HDR 反射探针

HDR 渲染需要 HDR 渲染目标。这不仅适用于普通相机,对于反射探针(reflection probes)也是如此。反射探针是否包含 HDR 或 LDR 数据可以通过其 HDR 切换选项控制,该选项默认启用。

启用 HDR 的反射探针
启用 HDR 的反射探针

当反射探针使用 HDR 时,它可以包含高强度颜色,主要是它捕获的镜面反射。你可以通过它们在场景中引起的反射间接观察它们。不完美的反射会削弱探针的颜色,这使得 HDR 值脱颖而出。

有无 HDR 的反射对比
有无 HDR 的反射对比
有无 HDR 的反射对比

HDR 相机

相机也有 HDR 配置选项,但它本身不做任何事情。它可以设置为 OffUse Graphics Settings

相机 HDR 依赖于图形设置
相机 HDR 依赖于图形设置

Use Graphics Settings 模式仅表示相机允许 HDR 渲染。这是否发生取决于 RP(渲染管线)。我们将通过向 CustomRenderPipelineAsset 添加一个切换开关来控制它,以允许 HDR,并将其传递给管线构造函数。

1
2
3
4
5
6
7
8
9
[SerializeField]
bool allowHDR = true;
....
protected override RenderPipeline CreatePipeline () {
    return new CustomRenderPipeline(
        allowHDR, useDynamicBatching, useGPUInstancing, useSRPBatcher,
        useLightsPerObject, shadows, postFXSettings
    );
}

CustomRenderPipeline 跟踪它,并将其与其他选项一起传递给相机渲染器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool allowHDR;
....
public CustomRenderPipeline (
    bool allowHDR,
    ....
) {
    this.allowHDR = allowHDR;
    ....
}
....
protected override void Render (
    ScriptableRenderContext context, List<Camera> cameras
) {
    for (int i = 0; i < cameras.Count; i++) {
        renderer.Render(
            context, cameras[i], allowHDR,
            useDynamicBatching, useGPUInstancing, useLightsPerObject,
            shadowSettings, postFXSettings
        );
    }
}

然后 CameraRenderer 跟踪是否应该使用 HDR,即当相机和 RP 都允许时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool useHDR;

public void Render (
    ScriptableRenderContext context, Camera camera, bool allowHDR,
    bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,
    ShadowSettings shadowSettings, PostFXSettings postFXSettings
) {
    ....
    if (!Cull(shadowSettings.maxDistance)) {
        return;
    }
    useHDR = allowHDR && camera.allowHDR;
    ....
}
允许 HDR
允许 HDR

HDR 渲染纹理

HDR 渲染只有在结合后处理时才有意义,因为我们无法改变最终帧缓冲区的格式。所以当我们在 CameraRenderer.Setup 中创建自己的中间帧缓冲区时,我们将在适当时使用默认 HDR 格式,而不是用于 LDR 的常规默认格式。

1
2
3
4
5
buffer.GetTemporaryRT(
    frameBufferId, camera.pixelWidth, camera.pixelHeight,
    32, FilterMode.Bilinear, useHDR ?
        RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default
);

帧调试器将显示默认 HDR 格式为 R16G16B16A16_SFloat,这意味着它是一个 RGBA 缓冲区,每个通道 16 位,因此每个像素 64 位,是 LDR 缓冲区大小的两倍。在这种情况下,每个值都是线性空间中的有符号浮点数,不会被钳制到 0–1。

当逐步执行绘制调用时,你会注意到场景看起来比最终结果暗。这是因为这些步骤存储在 HDR 纹理中。它看起来很暗,因为线性颜色数据按原样显示,因此被错误地解释为 sRGB。

通过帧调试器查看后处理前的 HDR 和 LDR
通过帧调试器查看后处理前的 HDR 和 LDR
通过帧调试器查看后处理前的 HDR 和 LDR

HDR 后处理

在这一点上,结果看起来与之前没有任何不同,因为我们没有对扩展范围做任何处理,并且一旦渲染到 LDR 目标,它就会被钳制。泛光可能看起来会稍微亮一点,但不会太多,因为颜色在预过滤通道后被钳制。我们还必须在 HDR 中执行后处理才能充分利用它。所以让我们在 CameraRenderer.Render 中调用 PostFXStack.Setup 时传递是否使用 HDR。

1
postFXStack.Setup(context, camera, postFXSettings, useHDR);

现在 PostFXStack 也可以跟踪是否应该使用 HDR。

1
2
3
4
5
6
7
8
9
bool useHDR;
....
public void Setup (
    ScriptableRenderContext context, Camera camera, PostFXSettings settings,
    bool useHDR
) {
    this.useHDR = useHDR;
    ....
}

我们可以在 DoBloom 中使用适当的纹理格式。

1
2
RenderTextureFormat format = useHDR ?
    RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default;

HDR 和 LDR 泛光之间的差异可能非常显著或不明显,这取决于场景的亮度。通常泛光阈值设置为 1,因此只有 HDR 颜色会对其产生贡献。这样,发光表示对于显示器来说太亮的颜色。

HDR 泛光;阈值 1,膝盖 0
HDR 泛光;阈值 1,膝盖 0

因为泛光会平均颜色,即使单个非常亮的像素最终也会在视觉上影响非常大的区域。你可以通过比较预过滤步骤与最终结果来看到这一点。即使是单个像素也可以产生一个大的圆形光晕。

HDR 泛光预过滤步骤
HDR 泛光预过滤步骤

例如,当一个 2×2 块的值 0、0、0 和 1 由于下采样而被平均时,结果将是 0.25。但如果 HDR 版本平均的是 0、0、0 和 10,那么结果将是 2.5。与 LDR 相比,看起来就像 0.25 的结果被提升到了 1。

对抗火花

HDR 的一个缺点是它可能会产生比周围区域亮得多的小图像区域。当这些区域大约为一个像素或更小时,它们可以显著改变相对大小,并在运动过程中突然出现和消失,从而导致闪烁。这些区域被称为火花(fireflies)。当泛光应用于它们时,效果可能会变得频闪。

HDR 泛光火花
HDR 泛光火花

要完全消除这个问题需要无限分辨率,这是不可能的。我们能做的下一件最好的事情是在预过滤期间更积极地模糊图像,以淡化火花。让我们在 PostFXSettings.BloomSettings 中为此添加一个切换选项。

1
public bool fadeFireflies;
启用淡化火花
启用淡化火花

为此目的添加一个新的预过滤火花通道(pass)。我不会再展示向 PostFxStack 着色器和 PostFXStack.Pass 枚举添加通道的过程。在 DoBloom 中为预过滤选择适当的通道。

1
2
3
4
Draw(
    sourceId, bloomPrefilterId, bloom.fadeFireflies ?
        Pass.BloomPrefilterFireflies : Pass.BloomPrefilter
);

淡化火花最直接的方法是将预过滤通道的 2×2 下采样滤波器扩展为一个大的 6×6 盒式滤波器(box filter)。我们可以用九个采样来做到这一点,在平均之前对每个采样单独应用泛光阈值。为此添加所需的 BloomPrefilterFirefliesPassFragment 函数到 PostFXStackPasses

6×6 盒式滤波器
6×6 盒式滤波器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float4 BloomPrefilterFirefliesPassFragment (Varyings input) : SV_TARGET {
    float3 color = 0.0;
    float2 offsets[] = {
        float2(0.0, 0.0),
        float2(-1.0, -1.0), float2(-1.0, 1.0), float2(1.0, -1.0), float2(1.0, 1.0),
        float2(-1.0, 0.0), float2(1.0, 0.0), float2(0.0, -1.0), float2(0.0, 1.0)
    };
    for (int i = 0; i < 9; i++) {
        float3 c =
            GetSource(input.screenUV + offsets[i] * GetSourceTexelSize().xy * 2.0).rgb;
        c = ApplyBloomThreshold(c);
        color += c;
    }
    color *= 1.0 / 9.0;
    return float4(color, 1.0);
}

但这还不足以解决问题,因为非常亮的像素只是被分散到更大的区域。要淡化火花,我们将使用基于颜色亮度(luminance)的加权平均。颜色的亮度是其感知亮度。我们将使用 Core Library 的 Color HLSL 文件中定义的 Luminance 函数。

1
2
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Filtering.hlsl"

采样的权重为 $\frac{1}{l + 1}$,其中 $l$ 是其亮度。因此,对于亮度 0,权重为 1;对于亮度 1,权重为 ½;对于 3,权重为 ¼;对于 7,权重为 ⅛,依此类推。

基于亮度的权重
基于亮度的权重

最后,我们将采样总和除以这些权重的总和。这有效地将火花的亮度分散到所有其他采样中。如果那些其他采样是暗的,火花就会淡化。例如,0、0、0 和 10 的加权平均值为 $\frac{10}{11} \div (3 + \frac{1}{11}) = \frac{110}{374} \approx 0.29$。

1
2
3
4
5
6
7
8
9
10
11
12
13
float4 BloomPrefilterFirefliesPassFragment (Varyings input) : SV_TARGET {
    float3 color = 0.0;
    float weightSum = 0.0;
    ....
    for (int i = 0; i < 9; i++) {
        ....
        float w = 1.0 / (Luminance(c) + 1.0);
        color += c * w;
        weightSum += w;
    }
    color /= weightSum;
    return float4(color, 1.0);
}
基于亮度的加权平均
基于亮度的加权平均

因为我们在初始预过滤步骤后执行高斯模糊,我们可以跳过直接邻近中心的四个采样,将采样数量从九个减少到五个。

6×6 十字滤波器
6×6 十字滤波器
1
2
3
4
5
6
float2 offsets[] = {
    float2(0.0, 0.0),
    float2(-1.0, -1.0), float2(-1.0, 1.0), float2(1.0, -1.0), float2(1.0, 1.0)//,
    //float2(-1.0, 0.0), float2(1.0, 0.0), float2(0.0, -1.0), float2(0.0, 1.0)
};
for (int i = 0; i < 5; i++) { .... }

这将在预过滤步骤中将单像素火花变成 × 形图案,并将单像素水平或垂直线分成两条独立的线,但在第一个模糊步骤后,这些图案就会消失。

预过滤步骤,五个和九个采样;半分辨率
预过滤步骤,五个和九个采样;半分辨率
预过滤步骤,五个和九个采样;半分辨率

这并没有完全消除火花,但大大降低了它们的强度,使它们不再明显突出,除非泛光强度设置得远高于 1。

淡化的火花
淡化的火花

散射泛光

现在我们有了 HDR 泛光,让我们考虑它的更现实的应用。想法是相机并不完美。它们的镜头不能正确聚焦所有光线。一部分光线会散射到更大的区域,有点像我们当前的泛光效果所做的。相机越好,散射越少。与我们的加法泛光效果的最大区别在于,散射不会增加光,它只会扩散光。散射在视觉上可以从轻微的光晕变化到覆盖整个图像的光雾。

眼睛也不完美,光线会以复杂的方式在眼内散射。这发生在所有入射光上,但只有在光线明亮时才真正明显。例如,当看着暗背景下的小型亮光源时,比如夜晚的灯笼,或明亮日子里太阳的反射时,这是显而易见的。

我们看到的不是均匀的圆形模糊光晕,而是许多点的不对称星形图案,它们也有色调偏移,这对我们自己的眼睛是独特的。但我们的泛光效果将代表一个具有均匀散射的无特征相机。

相机中散射引起的泛光
相机中散射引起的泛光

泛光模式

我们将支持经典的加法(additive)和能量守恒的散射(scattering)泛光。为 PostFXSettings.BloomSettings 添加这些模式的枚举选项。还添加一个 0–1 滑块来控制光散射的程度。

1
2
3
4
5
6
public enum Mode { Additive, Scattering }

public Mode mode;

[Range(0f, 1f)]
public float scatter;
选择散射模式并设置为 0.5
选择散射模式并设置为 0.5

将现有的 BloomCombine 通道重命名为 BloomAdd,并引入一个新的 BloomScatter 通道。确保枚举和通道顺序保持字母顺序。然后在 DoBloom 的组合阶段使用适当的通道。在散射的情况下,我们将使用散射量作为强度而不是 1。我们仍然为最终绘制使用配置的强度。

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
Pass combinePass;
if (bloom.mode == PostFXSettings.BloomSettings.Mode.Additive) {
    combinePass = Pass.BloomAdd;
    buffer.SetGlobalFloat(bloomIntensityId, 1f);
}
else {
    combinePass = Pass.BloomScatter;
    buffer.SetGlobalFloat(bloomIntensityId, bloom.scatter);
}

if (i > 1) {
    buffer.ReleaseTemporaryRT(fromId - 1);
    toId -= 5;
    for (i -= 1; i > 0; i--) {
        buffer.SetGlobalTexture(fxSource2Id, toId + 1);
        Draw(fromId, toId, combinePass);
        ....
    }
}
else {
    buffer.ReleaseTemporaryRT(bloomPyramidId);
}
buffer.SetGlobalFloat(bloomIntensityId, bloom.intensity);
buffer.SetGlobalTexture(fxSource2Id, sourceId);
Draw(fromId, BuiltinRenderTextureType.CameraTarget, combinePass);

BloomScatter 通道的函数与 BloomAdd 的函数相同,只是它基于强度在高分辨率和低分辨率源之间插值,而不是将它们相加。因此,散射量为零意味着只使用最低的泛光金字塔级别,而散射 1 意味着只使用最高的级别。在 0.5 时,连续级别的贡献最终为 0.5、0.25、0.125、0.125(在四个级别的情况下)。

1
2
3
4
5
6
7
8
9
10
11
float4 BloomScatterPassFragment (Varyings input) : SV_TARGET {
    float3 lowRes;
    if (_BloomBicubicUpsampling) {
        lowRes = GetSourceBicubic(input.screenUV).rgb;
    }
    else {
        lowRes = GetSource(input.screenUV).rgb;
    }
    float3 highRes = GetSource2(input.screenUV).rgb;
    return float4(lerp(highRes, lowRes, _BloomIntensity), 1.0);
}
变化的泛光散射
变化的泛光散射

散射泛光不会使图像变亮。它可能看起来会使上面的例子变暗,但这是因为它只显示了原始图像的裁剪部分。然而,能量守恒并不完美,因为高斯滤波器在图像边缘被钳制,这意味着边缘像素的贡献被放大了。我们可以补偿这一点,但不会这样做,因为它通常并不明显。

内部光照强度为20;最大迭代次数为16

散射限制

因为散射值 0 和 1 会消除除一个金字塔级别之外的所有级别,所以使用这些值没有意义。所以让我们将散射滑块的范围减少到 0.05–0.95。这使得零的默认值无效,所以用一个值显式初始化 BloomSettings。让我们使用 0.7,这与 URP 和 HDRP 使用的默认散射值相同。

1
2
3
4
5
6
7
8
9
10
public struct BloomSettings {
    ....
    [Range(0.05f, 0.95f)]
    public float scatter;
}

[SerializeField]
BloomSettings bloom = new BloomSettings {
    scatter = 0.7f
};

此外,对于散射泛光,强度大于 1 是不合适的,因为那会增加光。所以我们将在 DoBloom 中将其钳制,将最大值限制为 0.95,以便原始图像始终对结果有所贡献。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float finalIntensity;
if (bloom.mode == PostFXSettings.BloomSettings.Mode.Additive) {
    combinePass = Pass.BloomAdd;
    buffer.SetGlobalFloat(bloomIntensityId, 1f);
    finalIntensity = bloom.intensity;
}
else {
    combinePass = Pass.BloomScatter;
    buffer.SetGlobalFloat(bloomIntensityId, bloom.scatter);
    finalIntensity = Mathf.Min(bloom.intensity, 0.95f);
}

if (i > 1) {
    ....
}
else {
    buffer.ReleaseTemporaryRT(bloomPyramidId);
}
buffer.SetGlobalFloat(bloomIntensityId, finalIntensity);
强度 0.5 和散射 0.7
强度 0.5 和散射 0.7

阈值

散射泛光比加法泛光微妙得多。它通常也与低强度一起使用。这意味着——就像真实相机一样——泛光效果只对非常明亮的光真正明显,即使所有光都被散射。

虽然这不现实,但仍然可以应用阈值来消除较暗像素的散射。这可以在使用更强泛光时保持图像清晰。然而,这会消除光线,从而使图像变暗。

阈值 1,膝盖 0,强度 1
阈值 1,膝盖 0,强度 1

我们必须补偿缺失的散射光。我们通过创建一个额外的 BloomScatterFinal 通道来做到这一点,我们将其用于散射泛光的最终绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Pass combinePass, finalPass;
float finalIntensity;
if (bloom.mode == PostFXSettings.BloomSettings.Mode.Additive) {
    combinePass = finalPass = Pass.BloomAdd;
    buffer.SetGlobalFloat(bloomIntensityId, 1f);
    finalIntensity = bloom.intensity;
}
else {
    combinePass = Pass.BloomScatter;
    finalPass = Pass.BloomScatterFinal;
    buffer.SetGlobalFloat(bloomIntensityId, bloom.scatter);
    finalIntensity = Mathf.Min(bloom.intensity, 1f);
}
....
Draw(fromId, BuiltinRenderTextureType.CameraTarget, finalPass);
}

此通道的函数是另一个散射通道函数的副本,有一个区别。它通过添加高分辨率光然后再次减去它但应用了泛光阈值来向低分辨率通道添加缺失的光。这不是一个完美的重建——它不是加权平均,并且忽略了由于淡化火花而损失的光——但已经足够接近,并且不会向原始图像添加光。

1
2
3
4
5
6
7
8
9
10
11
12
float4 BloomScatterFinalPassFragment (Varyings input) : SV_TARGET {
    float3 lowRes;
    if (_BloomBicubicUpsampling) {
        lowRes = GetSourceBicubic(input.screenUV).rgb;
    }
    else {
        lowRes = GetSource(input.screenUV).rgb;
    }
    float3 highRes = GetSource2(input.screenUV).rgb;
    lowRes += highRes - ApplyBloomThreshold(highRes);
    return float4(lerp(highRes, lowRes, _BloomIntensity), 1.0);
}
使用散射最终通道的阈值
使用散射最终通道的阈值

色调映射

尽管我们可以在 HDR 中渲染,但对于常规相机,最终帧缓冲区始终是 LDR。因此,颜色通道在 1 处被截断。实际上,最终图像的白点在 1。极其明亮的颜色最终看起来与完全饱和的颜色没有区别。例如,我创建了一个具有多个光照级别和具有各种发射量的对象的场景,远远超过 1。最强的发射是 8,最亮的光具有强度 200。

没有后处理的场景;仅实时照明
没有后处理的场景;仅实时照明

在不应用任何后处理的情况下,很难甚至不可能分辨哪些对象和光是非常明亮的。我们可以使用泛光来使这一点变得明显。例如,我使用了阈值 1,膝盖 0.5,强度 0.2 和散射 0.7 以及最大迭代次数。

加法和散射泛光
加法和散射泛光
加法和散射泛光

发光的对象显然应该是明亮的,但我们仍然无法感觉到它们相对于场景其余部分有多亮。为此,我们需要调整图像的亮度——增加其白点——以便最亮的颜色不再超过 1。我们可以通过均匀地使整个图像变暗来做到这一点,但这会使它的大部分变得如此之暗,以至于我们无法清楚地看到它。理想情况下,我们大幅调整非常明亮的颜色,同时只稍微调整暗颜色。因此,我们需要一个非均匀的颜色调整。这种颜色调整不代表光本身的物理变化,而是它如何被观察。例如,我们的眼睛对较暗的色调比较亮的色调更敏感。

从 HDR 到 LDR 的转换被称为色调映射(tone mapping),它来自摄影和胶片冲洗。传统照片和胶片也有有限的范围和非均匀的光敏感性,因此已经开发了许多技术来执行转换。没有单一正确的色调映射方法。可以使用不同的方法来设定最终结果的氛围,比如经典的电影外观。

额外的后处理步骤

我们在泛光之后的新后处理步骤中执行色调映射。为此向 PostFXStack 添加一个 DoToneMapping 方法,最初只是将源复制到相机目标。

1
2
3
void DoToneMapping(int sourceId) {
    Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Copy);
}

我们需要调整泛光的结果,所以获取一个新的全分辨率临时渲染纹理,并在 DoBloom 中将其用作最终目标。还让它返回是否绘制了任何内容,而不是在跳过效果时直接绘制到相机目标。

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
int
    bloomBicubicUpsamplingId = Shader.PropertyToID("_BloomBicubicUpsampling"),
    bloomIntensityId = Shader.PropertyToID("_BloomIntensity"),
    bloomPrefilterId = Shader.PropertyToID("_BloomPrefilter"),
    bloomResultId = Shader.PropertyToID("_BloomResult"),
    ....;
....
bool DoBloom (int sourceId) {
    //buffer.BeginSample("Bloom");
    PostFXSettings.BloomSettings bloom = settings.Bloom;
    int width = camera.pixelWidth / 2, height = camera.pixelHeight / 2;

    if (
        bloom.maxIterations == 0 || bloom.intensity <= 0f ||
        height < bloom.downscaleLimit * 2 || width < bloom.downscaleLimit * 2
    ) {
        //Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Copy);
        //buffer.EndSample("Bloom");
        return false;
    }
    buffer.BeginSample("Bloom");
    ....
    buffer.SetGlobalFloat(bloomIntensityId, finalIntensity);
    buffer.SetGlobalTexture(fxSource2Id, sourceId);
    buffer.GetTemporaryRT(
        bloomResultId, camera.pixelWidth, camera.pixelHeight, 0,
        FilterMode.Bilinear, format
    );
    Draw(fromId, bloomResultId, finalPass);
    buffer.ReleaseTemporaryRT(fromId);
    buffer.EndSample("Bloom");
    return true;
}

调整 Render 使其在泛光激活时对泛光结果执行色调映射,然后释放泛光结果纹理。否则,让它直接对原始源应用色调映射,完全跳过泛光。

1
2
3
4
5
6
7
8
9
10
11
public void Render (int sourceId) {
    if (DoBloom(sourceId)) {
        DoToneMapping(bloomResultId);
        buffer.ReleaseTemporaryRT(bloomResultId);
    }
    else {
        DoToneMapping(sourceId);
    }
    context.ExecuteCommandBuffer(buffer);
    buffer.Clear();
}

色调映射模式

有多种色调映射方法,我们将支持其中一些,所以向 PostFXSettings 添加一个 ToneMappingSettings 配置结构,其中包含一个最初只包含 NoneMode 枚举选项。

1
2
3
4
5
6
7
8
9
10
11
[System.Serializable]
public struct ToneMappingSettings {
    public enum Mode { None }

    public Mode mode;
}

[SerializeField]
ToneMappingSettings toneMapping = default;

public ToneMappingSettings ToneMapping => toneMapping;
色调映射模式设置为无
色调映射模式设置为无

Reinhard

我们色调映射的目标是降低图像的亮度,以便原本均匀的白色区域显示各种颜色,揭示否则会丢失的细节。这就像当你的眼睛适应突然明亮的环境,直到你能再次清楚地看到。但我们不想均匀地缩小整个图像,因为这会使较暗的颜色无法区分,用过度曝光换取曝光不足。所以我们需要一个非线性转换,它不会大幅降低暗值,但会大幅降低高值。在极端情况下,零保持为零,而接近无穷大的值被减少到 1。一个实现这一点的简单函数是 $\frac{c}{1 + c}$,其中 $c$ 是颜色通道。这个函数被称为最简单形式的 Reinhard 色调映射操作,最初由 Mark Reinhard 提出,只是他将其应用于亮度,而我们将其单独应用于每个颜色通道。

Reinhard 色调映射
Reinhard 色调映射

None 之后,向 ToneMappingSettings.Mode 添加 Reinhard 选项。然后让枚举从 −1 开始,以便 Reinhard 值为零。

1
public enum Mode { None = -1, Reinhard }

接下来,添加一个 ToneMappingReinhard 通道,并让 PostFXStack.DoTonemapping 在适当时使用它。具体来说,如果模式为负,则执行简单复制,否则应用 Reinhard 色调映射。

1
2
3
4
5
void DoToneMapping(int sourceId) {
    PostFXSettings.ToneMappingSettings.Mode mode = settings.ToneMapping.mode;
    Pass pass = mode < 0 ? Pass.Copy : Pass.ToneMappingReinhard;
    Draw(sourceId, BuiltinRenderTextureType.CameraTarget, pass);
}

ToneMappingReinhardPassFragment 着色器函数简单地应用该函数。

1
2
3
4
5
float4 ToneMappingReinhardPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb /= color.rgb + 1.0;
    return color;
}
顶部无色调映射,底部 Reinhard,两者都有加法和散射泛光
顶部无色调映射,底部 Reinhard,两者都有加法和散射泛光
顶部无色调映射,底部 Reinhard,两者都有加法和散射泛光

这可以工作,但由于精度限制,对于非常大的值可能会出错。出于同样的原因,非常大的值在无穷大之前就已经达到 1。所以让我们在执行色调映射之前钳制颜色。60 的限制可以避免我们将支持的所有模式的任何潜在问题。

1
2
color.rgb = min(color.rgb, 60.0);
color.rgb /= color.rgb + 1.0;

Neutral

Reinhard 色调映射的白点理论上是无限的,但可以调整它,使最大值更早达到,从而削弱调整。这个替代函数是 $\frac{c}{1 + \frac{c}{w^2}(1 + c)}$,其中 $w$ 是白点。

白点在无穷大和 4 的 Reinhard
白点在无穷大和 4 的 Reinhard

我们可以为此添加一个配置选项,但 Reinhard 不是我们可以使用的唯一函数。一个更有趣且经常使用的函数是 $t(x) = \frac{x(ax + cb) + de}{x(ax + b) + df} - \frac{e}{f}$。在这种情况下,$x$ 是输入颜色通道,其他值是配置曲线的常数。最终颜色是 $\frac{t(ce)}{t(w)}$,其中 $c$ 是颜色通道,$e$ 是曝光偏差,$w$ 是白点。它可以产生一条 S 曲线,具有从黑色向上弯曲到中间线性部分的脚趾(toe)区域,以 shoulder 区域结束,该区域在接近白色时变平。

上述函数由 John Hable 设计。它首次用于《神秘海域 2》(参见幻灯片 142 和 143)。

Reinhard 和 Uncharted 2 色调映射
Reinhard 和 Uncharted 2 色调映射

URP 和 HDRP 使用此函数的变体,具有自己的配置值和 5.3 的白点,但它们也使用白标度作为曝光偏差,因此最终曲线是 $\frac{t(\frac{c}{t(w)})}{t(w)}$。这导致有效白点约为 4.035。它用于中性(neutral)色调映射选项,并通过 Color Core Library HLSL 文件中的 NeutralTonemap 函数可用。

白点为无穷大和 4 的 Reinhard,以及中性色调映射
白点为无穷大和 4 的 Reinhard,以及中性色调映射

让我们为这个色调映射模式添加一个选项。在 Mode 枚举中将其放在 None 之后和 Reinhard 之前。

1
public enum Mode { None = -1, Neutral, Reinhard }

然后为它创建另一个通道。如果模式不是 NonePostFXStack.DoToneMapping 现在可以通过将模式添加到中性选项来找到正确的通道。

1
2
Pass pass =
    mode < 0 ? Pass.Copy : Pass.ToneMappingNeutral + (int)mode;

然后 ToneMappingNeutralPassFragment 函数只需调用 NeutralTonemap

1
2
3
4
5
6
float4 ToneMappingNeutralPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = min(color.rgb, 60.0);
    color.rgb = NeutralTonemap(color.rgb);
    return color;
}
Image
Image
顶部 Reinhard,底部neutral
顶部 Reinhard,底部neutral
顶部 Reinhard,底部neutral

你可以添加配置选项来调整你自己的曲线,但我们将继续进行最后的色调映射模式。

ACES

我们在本教程中将支持的最后一种模式是 ACES 色调映射,URP 和 HDRP 也使用它。ACES 是 Academy Color Encoding System(学院色彩编码系统)的缩写,是交换数字图像文件、管理颜色工作流程以及创建用于交付和归档的母版的全球标准。我们只会使用它的色调映射方法,如 Unity 实现的那样。

首先,将其添加到 Mode 枚举中,直接在 None 之后,以保持其余部分按字母顺序排列。

1
public enum Mode { None = -1, ACES, Neutral, Reinhard }

添加通道并调整 PostFXStack.DoToneMapping 使其从 ACES 开始。

1
2
Pass pass =
    mode < 0 ? Pass.Copy : Pass.ToneMappingACES + (int)mode;

新的 ToneMappingACESPassFragment 函数可以简单地使用 Core Library 中的 AcesTonemap 函数。它通过 Color 包含,但还有一个单独的 ACES HLSL 文件,你可以研究。函数的输入颜色必须在 ACES 色彩空间中,为此我们可以使用 unity_to_ACES 函数。

1
2
3
4
5
6
float4 ToneMappingACESPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = min(color.rgb, 60.0);
    color.rgb = AcesTonemap(unity_to_ACES(color.rgb));
    return color;
}
Image
Image
Image
Image
顶部neutral,中间ACES,底部无色调映射
顶部neutral,中间ACES,底部无色调映射
顶部neutral,中间ACES,底部无色调映射

ACES 与其他模式之间最明显的区别是它向非常明亮的颜色添加了色调偏移,颜色更偏向白色。当相机或眼睛被过多的光淹没时,也会发生这种情况。结合泛光,现在很清楚哪些表面最亮。此外,ACES 色调映射会稍微降低较暗的颜色,从而增强对比度。结果是电影般的外观。


下一个教程是颜色分级(Color Grading)

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