Post

Unity自定义渲染管线13:色彩分级

通过色彩分级调整图像的颜色,复制 URP/HDRP 的多种色彩分级工具,并使用色彩查找表(LUT)来优化性能。

Unity自定义渲染管线13:色彩分级
  • 执行色彩分级。
  • 复制多个URP/HDRP色彩分级工具。
  • 使用色彩LUT。

色彩调整

目前我们只对最终图像应用色调映射,将 HDR 色彩映射到可见的 LDR 范围。但这并不是调整图像色彩的唯一原因。对于视频、照片和数字图像,色彩调整大致分为三个步骤:

  1. 色彩校正(Color Correction):旨在使图像与我们观察场景时看到的内容相匹配,补偿媒介的局限性。
  2. 色彩分级(Color Grading):为了达到所需的外观或感觉,这种外观不需要与原始场景匹配,也不需要是真实的。这两个步骤通常会合并成一个色彩分级步骤。
  3. 色调映射(Tone Mapping):将 HDR 色彩映射到显示范围。

仅应用色调映射时,图像往往会变得不那么丰富多彩,除非它非常明亮。ACES 会稍微增加暗色的对比度,但它不能替代色彩分级。本教程使用中性(Neutral)色调映射作为基础。

没有色彩调整的图像,使用中性色调映射
没有色彩调整的图像,使用中性色调映射

在色调映射之前进行色彩分级

色彩分级发生在色调映射之前。在 PostFXStackPasses 中,在色调映射通道之前添加一个函数。最初只需将色彩分量限制为 60。

1
2
3
4
float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    return color;
}

在色调映射通道中调用此函数,而不是在那里限制色彩。还要添加一个新的通道,用于无色调映射但有色彩分级的情况。

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
float4 ToneMappingNonePassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ColorGrade(color.rgb);
    return color;
}

float4 ToneMappingACESPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ColorGrade(color.rgb);
    color.rgb = AcesTonemap(unity_to_ACES(color.rgb));
    return color;
}

float4 ToneMappingNeutralPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ColorGrade(color.rgb);
    color.rgb = NeutralTonemap(color.rgb);
    return color;
}

float4 ToneMappingReinhardPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ColorGrade(color.rgb);
    color.rgb /= color.rgb + 1.0;
    return color;
}

在着色器和 PostFXStack.Pass 枚举中添加相同的通道,放在其他色调映射通道之前。然后调整 PostFXStack.DoToneMapping,使 None 模式使用自己的通道而不是 Copy。

1
2
3
4
5
void DoToneMapping(int sourceId) {
    PostFXSettings.ToneMappingSettings.Mode mode = settings.ToneMapping.mode;
    Pass pass = Pass.ToneMappingNone + (int)mode;
    Draw(sourceId, BuiltinRenderTextureType.CameraTarget, pass);
}

ToneMappingSettings.Mode 枚举现在必须从零开始。

1
2
3
4
public struct ToneMappingSettings {
    public enum Mode { None, ACES, Neutral, Reinhard }
    public Mode mode;
}

设置

我们将复制 URP 和 HDRP 的色彩调整(Color Adjustments)后处理工具的功能。第一步是在 PostFXSettings 中为其添加一个配置结构体。我添加了 using System,因为我们还需要多次添加 Serializable 特性。

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

[CreateAssetMenu(menuName = "Rendering/Custom Post FX Settings")]
public class PostFXSettings : ScriptableObject {
    ....

    [Serializable]
    public struct ColorAdjustmentsSettings {}

    [SerializeField]
    ColorAdjustmentsSettings colorAdjustments = default;

    public ColorAdjustmentsSettings ColorAdjustments => colorAdjustments;

    ....
}

URP 和 HDRP 的色彩分级功能是相同的。我们将按相同的顺序添加相同的配置选项:

  1. Post Exposure(后期曝光):无约束的浮点数
  2. Contrast(对比度):滑块范围从 -100 到 100
  3. Color Filter(色彩滤镜):不带 alpha 的 HDR 色彩
  4. Hue Shift(色调偏移):滑块范围从 -180° 到 +180°
  5. Saturation(饱和度):滑块范围从 -100 到 100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public struct ColorAdjustmentsSettings {
    public float postExposure;

    [Range(-100f, 100f)]
    public float contrast;

    [ColorUsage(false, true)]
    public Color colorFilter;

    [Range(-180f, 180f)]
    public float hueShift;

    [Range(-100f, 100f)]
    public float saturation;
}

默认值都是零,但色彩滤镜应该是白色。这些设置不会改变图像。

1
2
3
ColorAdjustmentsSettings colorAdjustments = new ColorAdjustmentsSettings {
    colorFilter = Color.white
};
色彩调整设置
色彩调整设置

我们同时进行色彩分级和色调映射,因此将 PostFXStack.DoToneMapping 重构重命名为 DoColorGradingAndToneMapping。我们还将在这里多次访问 PostFXSettings 的内部类型,因此添加 using static PostFXSettings 以保持代码简短。然后添加一个 ConfigureColorAdjustments 方法,在其中获取色彩调整设置,并在 DoColorGradingAndToneMapping 的开头调用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;
using UnityEngine.Rendering;
using static PostFXSettings;

public partial class PostFXStack {
    ....

    void ConfigureColorAdjustments () {
        ColorAdjustmentsSettings colorAdjustments = settings.ColorAdjustments;
    }

    void DoColorGradingAndToneMapping (int sourceId) {
        ConfigureColorAdjustments();
        ToneMappingSettings.Mode mode = settings.ToneMapping.mode;
        Pass pass = Pass.ToneMappingNone + (int)mode;
        Draw(sourceId, BuiltinRenderTextureType.CameraTarget, pass);
    }

    ....
}

我们可以为色彩调整设置一个着色器向量和色彩。色彩调整向量的分量是曝光、对比度、色调偏移和饱和度。曝光以档位(stops)为单位测量,这意味着我们必须将 2 提高到配置的曝光值的幂。还要将对比度和饱和度转换为 0–2 范围,将色调偏移转换为 -1–1。滤镜必须在线性色彩空间中。

1
2
3
4
5
6
7
8
ColorAdjustmentsSettings colorAdjustments = settings.ColorAdjustments;
buffer.SetGlobalVector(colorAdjustmentsId, new Vector4(
    Mathf.Pow(2f, colorAdjustments.postExposure),
    colorAdjustments.contrast * 0.01f + 1f,
    colorAdjustments.hueShift * (1f / 360f),
    colorAdjustments.saturation * 0.01f + 1f
));
buffer.SetGlobalColor(colorFilterId, colorAdjustments.colorFilter.linear);

后期曝光

在着色器端,添加向量和色彩。我们将把每个调整放在自己的函数中,从后期曝光开始。创建一个 ColorGradePostExposure 函数,将色彩与曝光值相乘。然后在 ColorGrade 中限制色彩后应用曝光。

1
2
3
4
5
6
7
8
9
10
11
12
float4 _ColorAdjustments;
float4 _ColorFilter;

float3 ColorGradePostExposure (float3 color) {
    return color * _ColorAdjustments.x;
}

float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    return color;
}
后期曝光 -2 和 2
后期曝光 -2 和 2
后期曝光 -2 和 2

后期曝光的理念是模仿相机的曝光,但在所有其他后处理效果之后、所有其他色彩分级之前应用。它是一个非真实的艺术工具,可用于在不影响其他效果(如光晕)的情况下调整曝光。

对比度

第二个调整是对比度。我们通过从色彩中减去均匀的中灰色,然后按对比度缩放,再将中灰色添加回去来应用它。使用 ACEScc_MIDGRAY 作为灰色值。

1
2
3
4
5
6
7
8
9
10
float3 ColorGradingContrast (float3 color) {
    return (color - ACEScc_MIDGRAY) * _ColorAdjustments.y + ACEScc_MIDGRAY;
}

float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    color = ColorGradingContrast(color);
    return color;
}

为了获得最佳效果,此转换在 Log C 而不是线性色彩空间中完成。我们可以使用 Color Core Library 文件中的 LinearToLogC 函数从线性转换到 Log C,并使用 LogCToLinear 函数转换回来。

1
2
3
4
5
float3 ColorGradingContrast (float3 color) {
    color = LinearToLogC(color);
    color = (color - ACEScc_MIDGRAY) * _ColorAdjustments.y + ACEScc_MIDGRAY;
    return LogCToLinear(color);
}
线性和 Log C
线性和 Log C
线性和 Log C

当对比度增加时,这可能会导致色彩分量为负,这会干扰后续的调整。因此,在 ColorGrade 中调整对比度后消除负值。

1
2
color = ColorGradingContrast(color);
color = max(color, 0.0);
对比度 -50 和 50
对比度 -50 和 50
对比度 -50 和 50

色彩滤镜

色彩滤镜接下来,只需将其与色彩相乘。它对负值也适用,因此我们可以在消除负值之前应用它。

1
2
3
4
5
6
7
8
9
10
11
12
float3 ColorGradeColorFilter (float3 color) {
    return color * _ColorFilter.rgb;
}

float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    color = ColorGradingContrast(color);
    color = ColorGradeColorFilter(color);
    color = max(color, 0.0);
    return color;
}
浅青色滤镜,消除大部分红光
浅青色滤镜,消除大部分红光

色调偏移

URP 和 HDRP 在色彩滤镜之后执行色调偏移,我们将使用相同的调整顺序。色彩的色调通过 RgbToHsv 将色彩格式从 RGB 转换为 HSV,将色调偏移添加到 H,然后通过 HsvToRgb 转换回来进行调整。因为色调定义在 0–1 色轮上,所以如果超出范围,我们必须将其环绕。我们可以使用 RotateHue,将调整后的色调、零和 1 作为参数传递。这必须在消除负值后进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float3 ColorGradingHueShift (float3 color) {
    color = RgbToHsv(color);
    float hue = color.x + _ColorAdjustments.z;
    color.x = RotateHue(hue, 0.0, 1.0);
    return HsvToRgb(color);
}

float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    color = ColorGradingContrast(color);
    color = ColorGradeColorFilter(color);
    color = max(color, 0.0);
    color = ColorGradingHueShift(color);
    return color;
}
180° 色调偏移
180° 色调偏移

饱和度

最后一个调整是饱和度。首先使用 Luminance 函数获取色彩的亮度。然后像对比度一样计算结果,但使用亮度而不是中灰色,并且不在 Log C 中。这同样会产生负值,因此从 ColorGrade 的最终结果中删除这些负值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 ColorGradingSaturation (float3 color) {
    float luminance = Luminance(color);
    return (color - luminance) * _ColorAdjustments.w + luminance;
}

float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    color = ColorGradingContrast(color);
    color = ColorGradeColorFilter(color);
    color = max(color, 0.0);
    color = ColorGradingHueShift(color);
    color = ColorGradingSaturation(color);
    return max(color, 0.0);
}
饱和度 -100 和 100
饱和度 -100 和 100
饱和度 -100 和 100

更多控制

色彩调整工具不是 URP 和 HDRP 提供的唯一色彩分级选项。我们将再次复制 Unity 的方法,添加对更多工具的支持。

白平衡

白平衡工具可以调整图像的感知温度。它有两个滑块,范围为 -100–100。第一个是 Temperature(温度),用于使图像更冷或更暖。第二个是 Tint(色调),用于调整温度偏移后的色彩。在 PostFXSettings 中为其添加一个设置结构体,默认值为零。

1
2
3
4
5
6
7
8
9
10
[Serializable]
public struct WhiteBalanceSettings {
    [Range(-100f, 100f)]
    public float temperature, tint;
}

[SerializeField]
WhiteBalanceSettings whiteBalance = default;

public WhiteBalanceSettings WhiteBalance => whiteBalance;
白平衡设置
白平衡设置

我们可以使用单个向量着色器属性,可以通过调用 Core Library 中的 ColorUtils.ColorBalanceToLMSCoeffs 来获取它,传入温度和色调。在 PostFXStack 中的专用配置方法中设置它,并在 DoColorGradingAndToneMapping 中的 ConfigureColorAdjustments 之后调用。

1
2
3
4
5
6
7
8
9
10
11
12
void ConfigureWhiteBalance () {
    WhiteBalanceSettings whiteBalance = settings.WhiteBalance;
    buffer.SetGlobalVector(whiteBalanceId, ColorUtils.ColorBalanceToLMSCoeffs(
        whiteBalance.temperature, whiteBalance.tint
    ));
}

void DoColorGradingAndToneMapping (int sourceId) {
    ConfigureColorAdjustments();
    ConfigureWhiteBalance();
    ....
}

在着色器端,我们通过在 LMS 色彩空间中将色彩与向量相乘来应用白平衡。我们可以使用 LinearToLMSLMSToLinear 函数进行转换。在后期曝光之后和对比度之前应用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
float4 _ColorAdjustments;
float4 _ColorFilter;
float4 _WhiteBalance;

float3 ColorGradePostExposure (float3 color) { .... }

float3 ColorGradeWhiteBalance (float3 color) {
    color = LinearToLMS(color);
    color *= _WhiteBalance.rgb;
    return LMSToLinear(color);
}

....

float3 ColorGrade (float3 color) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    color = ColorGradeWhiteBalance(color);
    color = ColorGradingContrast(color);
    ....
}

冷温度使图像变蓝,而暖温度使图像变黄。通常使用小调整,但我展示极端值以使效果明显。

温度 -100 和 100
温度 -100 和 100
温度 -100 和 100

色调可用于补偿不需要的色彩平衡,将图像推向绿色或洋红色。

色调 -100 和 100
色调 -100 和 100
色调 -100 和 100

分离色调

分离色调工具用于分别为图像的阴影和高光着色。一个典型的例子是将阴影推向冷蓝色,将高光推向暖橙色。

为其创建一个设置结构体,包含两个不带 alpha 的 LDR 色彩,用于阴影和高光。它们的默认值是灰色。还包括一个 -100–100 范围的 balance(平衡)滑块,默认值为零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Serializable]
public struct SplitToningSettings {
    [ColorUsage(false)]
    public Color shadows, highlights;

    [Range(-100f, 100f)]
    public float balance;
}

[SerializeField]
SplitToningSettings splitToning = new SplitToningSettings {
    shadows = Color.gray,
    highlights = Color.gray
};

public SplitToningSettings SplitToning => splitToning;
分离色调设置
分离色调设置

PostFXStack 中将两种色彩发送到着色器,保持它们在伽马空间中。balance 值可以存储在其中一种色彩的第四个分量中,缩放到 -1–1 范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void ConfigureSplitToning () {
    SplitToningSettings splitToning = settings.SplitToning;
    Color splitColor = splitToning.shadows;
    splitColor.a = splitToning.balance * 0.01f;
    buffer.SetGlobalColor(splitToningShadowsId, splitColor);
    buffer.SetGlobalColor(splitToningHighlightsId, splitToning.highlights);
}

void DoColorGradingAndToneMapping (int sourceId) {
    ConfigureColorAdjustments();
    ConfigureWhiteBalance();
    ConfigureSplitToning();
    ....
}

在着色器端,我们将在近似伽马空间中执行分离色调,事先将色彩提高到 2.2 的倒数,之后提高到 2.2。这样做是为了匹配 Adobe 产品的分离色调。调整在色彩滤镜之后、消除负值之后进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float4 _WhiteBalance;
float4 _SplitToningShadows, _SplitToningHighlights;

....

float3 ColorGradeSplitToning (float3 color) {
    color = PositivePow(color, 1.0 / 2.2);
    return PositivePow(color, 2.2);
}

....

float3 ColorGrade (float3 color) {
    ....
    color = ColorGradeColorFilter(color);
    color = max(color, 0.0);
    color = ColorGradeSplitToning(color);
    ....
}

我们通过在色彩和阴影色调之间执行柔光混合,然后是高光色调来应用色调。我们可以使用 SoftLight 函数两次。

1
2
3
4
5
6
7
8
float3 ColorGradeSplitToning (float3 color) {
    color = PositivePow(color, 1.0 / 2.2);
    float3 shadows = _SplitToningShadows.rgb;
    float3 highlights = _SplitToningHighlights.rgb;
    color = SoftLight(color, shadows);
    color = SoftLight(color, highlights);
    return PositivePow(color, 2.2);
}

我们通过在混合之前在中性 0.5 和它们自身之间插值色调来将色调限制在各自的区域。对于高光,我们基于饱和亮度加上 balance 来执行此操作,再次饱和。对于阴影,我们使用反向。

1
2
3
4
5
float t = saturate(Luminance(saturate(color)) + _SplitToningShadows.w);
float3 shadows = lerp(0.5, _SplitToningShadows.rgb, 1.0 - t);
float3 highlights = lerp(0.5, _SplitToningHighlights.rgb, t);
color = SoftLight(color, shadows);
color = SoftLight(color, highlights);
使用蓝色和橙色的分离色调,以及无调整的对比
使用蓝色和橙色的分离色调,以及无调整的对比

通道混合器

我们将支持的另一个工具是通道混合器。它允许你组合输入 RGB 值以创建新的 RGB 值。例如,你可以交换 R 和 G,从 G 中减去 B,或将 G 添加到 R 以将绿色推向黄色。

混合器本质上是一个 3×3 转换矩阵,默认为单位矩阵。我们可以使用三个 Vector3 值,用于红色、绿色和蓝色配置。Unity 的控件为每种色彩显示一个单独的选项卡,每个输入通道有 -100–100 滑块,但我们将直接显示向量。行用于输出色彩,XYZ 列是 RGB 输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
[Serializable]
public struct ChannelMixerSettings {
    public Vector3 red, green, blue;
}

[SerializeField]
ChannelMixerSettings channelMixer = new ChannelMixerSettings {
    red = Vector3.right,
    green = Vector3.up,
    blue = Vector3.forward
};

public ChannelMixerSettings ChannelMixer => channelMixer;
通道混合器设置为单位矩阵
通道混合器设置为单位矩阵

将这三个向量发送到 GPU。

1
2
3
4
5
6
7
8
9
10
11
12
13
void ConfigureChannelMixer () {
    ChannelMixerSettings channelMixer = settings.ChannelMixer;
    buffer.SetGlobalVector(channelMixerRedId, channelMixer.red);
    buffer.SetGlobalVector(channelMixerGreenId, channelMixer.green);
    buffer.SetGlobalVector(channelMixerBlueId, channelMixer.blue);
}

void DoColorGradingAndToneMapping (int sourceId) {
    ....
    ConfigureSplitToning();
    ConfigureChannelMixer();
    ....
}

在着色器中执行矩阵乘法。在分离色调之后执行此操作。之后再次消除负值,因为负权重可能会产生负色彩通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
float4 _SplitToningShadows, _SplitToningHighlights;
float4 _ChannelMixerRed, _ChannelMixerGreen, _ChannelMixerBlue;

....

float3 ColorGradingChannelMixer (float3 color) {
    return mul(
        float3x3(_ChannelMixerRed.rgb, _ChannelMixerGreen.rgb, _ChannelMixerBlue.rgb),
        color
    );
}

float3 ColorGrade (float3 color) {
    ....
    ColorGradeSplitToning(color);
    color = ColorGradingChannelMixer(color);
    color = max(color, 0.0);
    color = ColorGradingHueShift(color);
    ....
}
绿色在 GB 之间分割,蓝色在 RGB 之间分割
绿色在 GB 之间分割,蓝色在 RGB 之间分割
绿色在 GB 之间分割,蓝色在 RGB 之间分割

阴影、中间调、高光

我们将支持的最后一个工具是阴影、中间调、高光(Shadows Midtones Highlights)。它的工作方式类似于分离色调,但它还允许调整中间调,并将阴影和高光区域分离,使它们可配置。

Unity 的控件显示色轮和区域权重的可视化,但我们将使用三个 HDR 色彩字段和四个滑块,用于阴影和高光过渡区域的起点和终点。阴影强度从起点减少到终点,而高光强度从起点增加到终点。我们将使用 0–2 范围,以便可以稍微进入 HDR。色彩默认为白色,我们将使用与 Unity 相同的区域默认值,即阴影为 0–0.3,高光为 0.55–1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[Serializable]
public struct ShadowsMidtonesHighlightsSettings {
    [ColorUsage(false, true)]
    public Color shadows, midtones, highlights;

    [Range(0f, 2f)]
    public float shadowsStart, shadowsEnd, highlightsStart, highLightsEnd;
}

[SerializeField]
ShadowsMidtonesHighlightsSettings
    shadowsMidtonesHighlights = new ShadowsMidtonesHighlightsSettings {
    shadows = Color.white,
    midtones = Color.white,
    highlights = Color.white,
    shadowsEnd = 0.3f,
    highlightsStart = 0.55f,
    highLightsEnd = 1f
};

public ShadowsMidtonesHighlightsSettings ShadowsMidtonesHighlights =>
    shadowsMidtonesHighlights;
阴影、中间调、高光设置
阴影、中间调、高光设置

将三种色彩发送到 GPU,转换为线性空间。区域范围可以打包在单个向量中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void ConfigureShadowsMidtonesHighlights () {
    ShadowsMidtonesHighlightsSettings smh = settings.ShadowsMidtonesHighlights;
    buffer.SetGlobalColor(smhShadowsId, smh.shadows.linear);
    buffer.SetGlobalColor(smhMidtonesId, smh.midtones.linear);
    buffer.SetGlobalColor(smhHighlightsId, smh.highlights.linear);
    buffer.SetGlobalVector(smhRangeId, new Vector4(
        smh.shadowsStart, smh.shadowsEnd, smh.highlightsStart, smh.highLightsEnd
    ));
}

void DoColorGradingAndToneMapping (int sourceId) {
    ConfigureColorAdjustments();
    ConfigureWhiteBalance();
    ConfigureSplitToning();
    ConfigureChannelMixer();
    ConfigureShadowsMidtonesHighlights();
    ....
}

在着色器中,我们将色彩分别乘以三种色彩,每种色彩都按自己的权重缩放,然后求和结果。权重基于亮度。阴影权重从 1 开始,并在其起点和终点之间使用 smoothstep 函数减少到零。高光权重从零增加到一。中间调权重等于 1 减去其他两个权重。这个想法是阴影和高光区域不重叠——或者只是一点点——因此中间调权重永远不会变为负数。然而,我们在检查器中不强制执行此操作,就像我们不强制起点在终点之前一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
float4 _ChannelMixerRed, _ChannelMixerGreen, _ChannelMixerBlue;
float4 _SMHShadows, _SMHMidtones, _SMHHighlights, _SMHRange;

....

float3 ColorGradingShadowsMidtonesHighlights (float3 color) {
    float luminance = Luminance(color);
    float shadowsWeight = 1.0 - smoothstep(_SMHRange.x, _SMHRange.y, luminance);
    float highlightsWeight = smoothstep(_SMHRange.z, _SMHRange.w, luminance);
    float midtonesWeight = 1.0 - shadowsWeight - highlightsWeight;
    return
        color * _SMHShadows.rgb * shadowsWeight +
        color * _SMHMidtones.rgb * midtonesWeight +
        color * _SMHHighlights.rgb * highlightsWeight;
}

float3 ColorGrade (float3 color) {
    ....
    color = ColorGradingChannelMixer(color);
    color = max(color, 0.0);
    color = ColorGradingShadowsMidtonesHighlights(color);
    ....
}
蓝色阴影、粉色中间调和黄色高光
蓝色阴影、粉色中间调和黄色高光

Unity 控件的色轮工作方式相同,只是它们限制输入色彩并允许更精确的拖动。使用 HVS 色彩选择器模式调整色彩,以在没有约束的情况下模仿此功能。

ACES 色彩空间

当使用 ACES 色调映射时,Unity 在 ACES 色彩空间而不是线性色彩空间中执行大部分色彩分级,以产生更好的结果。让我们也这样做。

后期曝光和白平衡始终在线性空间中应用。对比度是它发散的地方。向 ColorGradingContrast 添加一个布尔参数 useACES。如果使用 ACES,首先从线性转换到 ACES,然后转换到 ACEScc 色彩空间,而不是转换到 Log C。我们可以通过 unity_to_ACESACES_to_ACEScc 来实现。调整对比度后,通过 ACEScc_to_ACESACES_to_ACEScg 转换到 ACEScg,而不是返回到线性空间。

1
2
3
4
5
float3 ColorGradingContrast (float3 color, bool useACES) {
    color = useACES ? ACES_to_ACEScc(unity_to_ACES(color)) : LinearToLogC(color);
    color = (color - ACEScc_MIDGRAY) * _ColorAdjustments.y + ACEScc_MIDGRAY;
    return useACES ? ACES_to_ACEScg(ACEScc_to_ACES(color)) : LogCToLinear(color);
}

从现在开始,在色彩分级对比度步骤之后,我们要么处于线性空间,要么处于 ACEScg 色彩空间。一切仍然以相同的方式工作,只是在 ACEScg 空间中应该使用 AcesLuminance 计算亮度。引入一个 Luminance 函数变体,该函数根据是否使用 ACES 调用正确的函数。

1
2
3
float Luminance (float3 color, bool useACES) {
    return useACES ? AcesLuminance(color) : Luminance(color);
}

ColorGradeSplitToning 使用亮度,给它一个 useACES 参数并将其传递给 Luminance

1
2
3
4
5
float3 ColorGradeSplitToning (float3 color, bool useACES) {
    color = PositivePow(color, 1.0 / 2.2);
    float t = saturate(Luminance(saturate(color), useACES) + _SplitToningShadows.w);
    ....
}

ColorGradingShadowsMidtonesHighlights 做同样的事情。

1
2
3
4
float3 ColorGradingShadowsMidtonesHighlights (float3 color, bool useACES) {
    float luminance = Luminance(color, useACES);
    ....
}

以及 ColorGradingSaturation

1
2
3
4
float3 ColorGradingSaturation (float3 color, bool useACES) {
    float luminance = Luminance(color, useACES);
    return luminance + _ColorAdjustments.w * (color - luminance);
}

然后也将参数添加到 ColorGrade,这次默认设置为 false。将其传递给需要它的函数。当适当时,最终色彩应通过 ACEScg_to_ACES 转换为 ACES 色彩空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 ColorGrade (float3 color, bool useACES = false) {
    color = min(color, 60.0);
    color = ColorGradePostExposure(color);
    color = ColorGradeWhiteBalance(color);
    color = ColorGradingContrast(color, useACES);
    color = ColorGradeColorFilter(color);
    color = max(color, 0.0);
    ColorGradeSplitToning(color, useACES);
    color = ColorGradingChannelMixer(color);
    color = max(color, 0.0);
    color = ColorGradingShadowsMidtonesHighlights(color, useACES);
    color = ColorGradingHueShift(color);
    color = ColorGradingSaturation(color, useACES);
    return max(useACES ? ACEScg_to_ACES(color) : color, 0.0);
}

现在调整 ToneMappingACESPassFragment,使其指示它使用 ACES。由于 ColorGrade 的结果将处于 ACES 色彩空间,因此可以直接传递给 ACESTonemap

1
2
3
4
5
6
float4 ToneMappingACESPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ColorGrade(color.rgb, true);
    color.rgb = AcesTonemap(color.rgb);
    return color;
}

为了说明差异,这里是使用 ACES 色调映射并增加对比度以及调整阴影、中间调和高光的比较。

ACES 色调映射,在 ACES 和线性色彩空间中进行色彩分级
ACES 色调映射,在 ACES 和线性色彩空间中进行色彩分级
ACES 色调映射,在 ACES 和线性色彩空间中进行色彩分级

LUT

对每个像素执行所有色彩分级步骤是很多工作。我们可以制作许多变体,仅应用改变某些东西的步骤,但这需要大量关键字或通道。我们可以做的是将色彩分级烘焙到查找表(LUT)中,并对其进行采样以转换色彩。LUT 是一个 3D 纹理,通常为 32×32×32。填充该纹理并稍后对其进行采样的工作量远少于直接对整个图像执行色彩分级。URP 和 HDRP 使用相同的方法。

LUT 分辨率

通常,色彩 LUT 分辨率为 32 就足够了,但让我们使其可配置。这是一个质量设置,我们将添加到 CustomRenderPipelineAsset,然后用于所有色彩分级。我们将使用一个枚举来提供 16、32 和 64 作为选项,然后将其作为整数传递给管线构造函数。

1
2
3
4
5
6
7
8
9
10
11
public enum ColorLUTResolution { _16 = 16, _32 = 32, _64 = 64 }

[SerializeField]
ColorLUTResolution colorLUTResolution = ColorLUTResolution._32;

protected override RenderPipeline CreatePipeline () {
    return new CustomRenderPipeline(
        allowHDR, useDynamicBatching, useGPUInstancing, useSRPBatcher,
        useLightsPerObject, shadows, postFXSettings, (int)colorLUTResolution
    );
}

CustomRenderPipeline 中跟踪色彩 LUT 分辨率,并将其传递给 CameraRenderer.Render 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int colorLUTResolution;

public CustomRenderPipeline (
    ....
    PostFXSettings postFXSettings, int colorLUTResolution
) {
    this.colorLUTResolution = colorLUTResolution;
    ....
}

....

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, colorLUTResolution
        );
    }
}

将其传递给 PostFXStack.Setup

1
2
3
4
5
6
7
8
9
10
public void Render (
    ScriptableRenderContext context, Camera camera, bool allowHDR,
    bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,
    ShadowSettings shadowSettings, PostFXSettings postFXSettings,
    int colorLUTResolution
) {
    ....
    postFXStack.Setup(context, camera, postFXSettings, useHDR, colorLUTResolution);
    ....
}

PostFXStack 跟踪它。

1
2
3
4
5
6
7
8
9
10
11
int colorLUTResolution;

....

public void Setup (
    ScriptableRenderContext context, Camera camera, PostFXSettings settings,
    bool useHDR, int colorLUTResolution
) {
    this.colorLUTResolution = colorLUTResolution;
    ....
}
色彩 LUT 分辨率
色彩 LUT 分辨率

渲染到 2D LUT 纹理

LUT 是 3D 的,但常规着色器无法渲染到 3D 纹理。因此,我们将使用宽 2D 纹理来模拟 3D 纹理,通过将 2D 切片放在一行中。因此,LUT 纹理的高度等于配置的分辨率,其宽度等于分辨率的平方。使用该大小获取临时渲染纹理,使用默认 HDR 格式。在 DoColorGradingAndToneMapping 中配置色彩分级后执行此操作。

1
2
3
4
5
6
7
8
ConfigureShadowsMidtonesHighlights();

int lutHeight = colorLUTResolution;
int lutWidth = lutHeight * lutHeight;
buffer.GetTemporaryRT(
    colorGradingLUTId, lutWidth, lutHeight, 0,
    FilterMode.Bilinear, RenderTextureFormat.DefaultHDR
);

从现在开始,我们将色彩分级和色调映射都渲染到 LUT。相应地重命名现有的色调映射通道,因此 ToneMappingNone 变为 ColorGradingNone,依此类推。然后使用适当的通道绘制到 LUT 而不是相机目标。之后,将源复制到相机目标以获取未调整的图像作为最终结果,并释放 LUT。

1
2
3
4
5
6
ToneMappingSettings.Mode mode = settings.ToneMapping.mode;
Pass pass = Pass.ColorGradingNone + (int)mode;
Draw(sourceId, colorGradingLUTId, pass);

Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Copy);
buffer.ReleaseTemporaryRT(colorGradingLUTId);

我们现在绕过了色彩分级和色调映射,但帧调试器显示我们在最终复制之前绘制了图像的扁平化版本。

扁平化图像
扁平化图像

LUT 色彩矩阵

要创建适当的 LUT,我们需要用色彩转换矩阵填充它。我们通过调整色彩分级通道函数来使用从 UV 坐标派生的色彩,而不是采样源纹理来实现。添加一个 GetColorGradedLUT,它获取色彩并立即执行色彩分级。然后通道函数只需在其上应用色调映射。

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
float3 GetColorGradedLUT (float2 uv, bool useACES = false) {
    float3 color = float3(uv, 0.0);
    return ColorGrade(color, useACES);
}

float4 ColorGradingNonePassFragment (Varyings input) : SV_TARGET {
    float3 color = GetColorGradedLUT(input.screenUV);
    return float4(color, 1.0);
}

float4 ColorGradingACESPassFragment (Varyings input) : SV_TARGET {
    float3 color = GetColorGradedLUT(input.screenUV, true);
    color = AcesTonemap(color);
    return float4(color, 1.0);
}

float4 ColorGradingNeutralPassFragment (Varyings input) : SV_TARGET {
    float3 color = GetColorGradedLUT(input.screenUV);
    color = NeutralTonemap(color);
    return float4(color, 1.0);
}

float4 ColorGradingReinhardPassFragment (Varyings input) : SV_TARGET {
    float3 color = GetColorGradedLUT(input.screenUV);
    color /= color + 1.0;
    return float4(color, 1.0);
}

我们可以通过 GetLutStripValue 函数找到 LUT 输入色彩。它需要 UV 坐标和一个我们需要发送到 GPU 的色彩分级 lut 参数向量。

1
2
3
4
5
6
float4 _ColorGradingLUTParameters;

float3 GetColorGradedLUT (float2 uv, bool useACES = false) {
    float3 color = GetLutStripValue(uv, _ColorGradingLUTParameters);
    return ColorGrade(color, useACES);
}

四个向量参数值是 LUT 高度、0.5 除以宽度、0.5 除以高度,以及高度除以其自身减一。

1
2
3
4
5
6
7
buffer.GetTemporaryRT(
    colorGradingLUTId, lutWidth, lutHeight, 0,
    FilterMode.Bilinear, RenderTextureFormat.DefaultHDR
);
buffer.SetGlobalVector(colorGradingLUTParametersId, new Vector4(
    lutHeight, 0.5f / lutWidth, 0.5f / lutHeight, lutHeight / (lutHeight - 1f)
));
没有色彩分级的 LUT,使用无、ACES 和 Reinhard 色调映射
没有色彩分级的 LUT,使用无、ACES 和 Reinhard 色调映射
没有色彩分级的 LUT,使用无、ACES 和 Reinhard 色调映射
没有色彩分级的 LUT,使用无、ACES 和 Reinhard 色调映射

Log C LUT

我们得到的 LUT 矩阵处于线性色彩空间中,并且仅覆盖 0–1 范围。为了支持 HDR,我们必须扩展此范围。我们可以通过将输入色彩解释为处于 Log C 空间中来实现。这将范围扩展到略低于 59。

存储的线性和 Log C 强度
存储的线性和 Log C 强度
1
2
3
4
float3 GetColorGradedLUT (float2 uv, bool useACES = false) {
    float3 color = GetLutStripValue(uv, _ColorGradingLUTParameters);
    return ColorGrade(LogCToLinear(color), useACES);
}
LogC 色彩,使用无、ACES 和 Reinhard 色调映射
LogC 色彩,使用无、ACES 和 Reinhard 色调映射
LogC 色彩,使用无、ACES 和 Reinhard 色调映射
LogC 色彩,使用无、ACES 和 Reinhard 色调映射

与线性空间相比,Log C 为最暗的值添加了更多分辨率。它在大约 0.5 处超过线性值。之后,强度迅速上升,因此矩阵分辨率大大降低。这是覆盖 HDR 值所需的,但如果我们不需要这些值,最好坚持使用线性空间,否则几乎一半的分辨率被浪费了。向着色器添加一个布尔值来控制这一点。

1
2
3
4
5
6
bool _ColorGradingLUTInLogC;

float3 GetColorGradedLUT (float2 uv, bool useACES = false) {
    float3 color = GetLutStripValue(uv, _ColorGradingLUTParameters);
    return ColorGrade(_ColorGradingLUTInLogC ? LogCToLinear(color) : color, useACES);
}

仅当使用 HDR 并应用色调映射时才启用 Log C 模式。

1
2
3
4
5
6
ToneMappingSettings.Mode mode = settings.ToneMapping.mode;
Pass pass = Pass.ColorGradingNone + (int)mode;
buffer.SetGlobalFloat(
    colorGradingLUTInLogId, useHDR && pass \!= Pass.ColorGradingNone ? 1f : 0f
);
Draw(sourceId, colorGradingLUTId, pass);

因为我们不再依赖渲染的图像,所以不再需要将范围限制为 60。它已经被 LUT 的范围限制了。

1
2
3
4
float3 ColorGrade (float3 color, bool useACES = false) {
    //color = min(color, 60.0);
    ....
}

最终通道

要应用 LUT,我们引入一个新的最终通道。它所需要做的就是获取源色彩并将色彩分级 LUT 应用于它。在单独的 ApplyColorGradingLUT 函数中执行此操作。

1
2
3
4
5
6
7
8
9
float3 ApplyColorGradingLUT (float3 color) {
    return color;
}

float4 FinalPassFragment (Varyings input) : SV_TARGET {
    float4 color = GetSource(input.screenUV);
    color.rgb = ApplyColorGradingLUT(color.rgb);
    return color;
}

我们可以通过 ApplyLut2D 函数应用 LUT,该函数负责将 2D LUT 条解释为 3D 纹理。它需要 LUT 纹理和采样器状态作为参数,然后是饱和输入色彩——根据需要在线性或 Log C 空间中——最后是一个参数向量,尽管这次只有三个分量。

1
2
3
4
5
6
7
8
9
TEXTURE2D(_ColorGradingLUT);

float3 ApplyColorGradingLUT (float3 color) {
    return ApplyLut2D(
        TEXTURE2D_ARGS(_ColorGradingLUT, sampler_linear_clamp),
        saturate(_ColorGradingLUTInLogC ? LinearToLogC(color) : color),
        _ColorGradingLUTParameters.xyz
    );
}

在这种情况下,参数值是 1 除以 LUT 宽度、1 除以高度以及高度减一。在最终绘制之前设置这些值,现在使用最终通道。

1
2
3
4
5
6
buffer.SetGlobalVector(colorGradingLUTParametersId,
    new Vector4(1f / lutWidth, 1f / lutHeight, lutHeight - 1f)
);

Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Final);
buffer.ReleaseTemporaryRT(colorGradingLUTId);

LUT 色带

虽然我们现在使用 LUT 进行色彩分级和色调映射,但结果应该与以前相同。但是,由于 LUT 具有有限的分辨率,并且我们使用双线性插值对其进行采样,因此它将原本平滑的色彩过渡转换为线性色带。这对于分辨率 32 的 LUT 通常不明显,但在具有极端 HDR 色彩梯度的区域中,色带可能会变得可见。一个例子是上一个教程的色调映射场景中强度为 200 的聚光灯的衰减,它照亮了一个均匀的白色表面。

色带,LUT 分辨率 16 和 32
色带,LUT 分辨率 16 和 32
色带,LUT 分辨率 16 和 32

可以通过暂时切换到 sampler_point_clamp 采样器状态来使色带非常明显。这会关闭我们 LUT 的 2D 切片内的插值。相邻切片之间仍然存在插值,因为 ApplyLut2D 通过采样两个切片并在它们之间混合来模拟 3D 纹理。

点采样,LUT 分辨率 16 和 32
点采样,LUT 分辨率 16 和 32
点采样,LUT 分辨率 16 和 32

如果色带太明显,你可以将分辨率提高到 64,但色彩的一点变化通常足以隐藏它。如果你在非常微妙的色彩过渡中寻找色带伪影,你更有可能发现由于 8 位帧缓冲区限制而导致的色带,这不是由 LUT 引起的,可以通过抖动来缓解,但那是另一个话题。


翻译完成! 本篇文章涵盖了:

  1. 色彩调整:后期曝光、对比度、色彩滤镜、色调偏移、饱和度
  2. 更多控制:白平衡、分离色调、通道混合器、阴影/中间调/高光
  3. ACES 色彩空间:在 ACES 色彩空间中进行更好的色彩分级
  4. LUT 优化:使用查找表(LUT)来提高性能,支持可配置的分辨率和 Log C 空间

下一个教程是多摄像机

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