1719 字
9 分钟

Unreal Engine - Tonemapping 定制化

预览#

ACES

NAES

Log2

Gran Turismo

前言#

最近在深挖 Unreal Engine (UE) 的卡通渲染(NPR)管线,发现 UE 自带的 Tonemapping(色调映射)方案使用的是 Filmic ACES 算法。虽然 ACES 在写实渲染中表现优异,拥有极高的动态范围拟真度,但在风格化的卡通渲染中,它往往会使画面“脏”且颜色过于写实。

如果不对其进行修改,目前常见的做法只能是完全关闭 Tonemapping,但这又会导致高光溢出处理不好。参考了市面上其他成熟的二次元渲染引擎,我决定对 UE 的 PostProcess 管线进行定制,接入几种更适合卡渲的 Tonemapping 算法。

算法选型与分析#

拟引入的算法#

为了给美术提供更多样化的风格选择,我在 PostProcess 中新增以下三种算法:

  1. NAESTonemap: 许多二次元项目中常用的算法,色彩还原度较好。
  2. Log2Tonemap: 基于对数空间的映射,过渡较为柔和。终末地在用
  3. GTTonemap (Gran Turismo): 参数可控性强,高光过渡自然。原神、米家游戏

源码分析#

在动手修改之前,我们需要梳理 UE 渲染管线中 Tonemap 的执行逻辑。主要涉及的文件如下:

  • C++ 层面:
    • PostProcessTonemap.h/cpp
    • PostProcessCombineLUTs.h/cpp
  • Shader 层面:
    • PostProcessTonemap.usf
    • PostProcessCombineLUTs.usf
    • TonemapCommon.ush

渲染管线追踪#

通过 PIX 截帧分析,我们可以观察到 PostProcess 的 Pass 分布:

Pasted image 20260219175327

关键点在于 Tonemap Pass 实际上非常依赖于 CombineLUTs Pass 生成的一张 32x32x32 的 LUT(Look-Up Table)图。

PostProcessTonemap.usf 中可以印证这一点:

half3 OutDeviceColor = ColorLookupTable(FinalLinearColor);

而在 ColorLookupTable 函数中,核心逻辑是采样 ColorGradingLUT

half3 ColorLookupTable( half3 LinearColor )
{
// ... (Log/Linear 转换逻辑)
float3 UVW = LUTEncodedColor * LUTScale + LUTOffset;
#if USE_VOLUME_LUT == 1
half3 OutDeviceColor = Texture3DSample( ColorGradingLUT, ColorGradingLUTSampler, UVW ).rgb;
#else
half3 OutDeviceColor = UnwrappedTexture3DSample( ColorGradingLUT, ColorGradingLUTSampler, UVW, LUTSize, InvLUTSize ).rgb;
#endif
return OutDeviceColor * 1.05;
}

这意味着真正的 ToneMapping 计算逻辑发生在该 LUT 的生成阶段,也就是 CombineLUTs Pass。

深入 PostProcessCombineLUTs.usf,在 CombineLUTsCommon 函数中找到了核心计算行:

// Tonemapped color in the AP1 gamut
float3 ToneMappedColorAP1 = FilmToneMap( ColorAP1 );
ColorAP1 = lerp(ColorAP1, ToneMappedColorAP1, ToneCurveAmount);

这里的 FilmToneMap 就是 UE 默认的 ACES 算法入口。我们要做的,就是在这里截获并替换为我们自定义的算法。

引擎修改实施#

1. 扩展 C++ 参数 (Parameter)#

我们需要让上层逻辑(PostProcessVolume)能够传递“使用哪种 Tonemap”的参数给 Shader。

PostProcessCombineLUTs.cpp#

首先在 Shader 参数结构体 FCombineLUTParameters 中添加字段:

PostProcessCombineLUTs.cpp
SHADER_PARAMETER(uint32, TonemapperType)

然后在 GetCombineLUTParameters 函数中绑定数据更新:

// 找到 Parameters.ToneCurveAmount 附近,添加:
UPDATE_CACHE_SETTINGS(Parameters.TonemapperType, Settings.TonemapType, bHasChanged);

Scene.h (定义枚举)#

Scene.h 中定义 Tonemap 的类型枚举,方便在编辑器中选择:

Scene.h
UENUM()
enum ETonemapType : int
{
TT_None UMETA(DisplayName = "None"),
TT_ACES UMETA(DisplayName = "ACES (Default)"),
TT_NAES UMETA(DisplayName = "NAES"),
TT_LOG2 UMETA(DisplayName = "Log2"),
TT_GT UMETA(DisplayName = "Gran Turismo")
};

同时在 FPostProcessSettings 结构体中添加属性:

// Scene.h - FPostProcessSettings
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Overrides, meta = (PinHiddenByDefault, InlineEditConditionToggle))
uint8 bOverride_TonemapType : 1;
/** Select the tonemapper to use. */
UPROPERTY(interp, BlueprintReadWrite, Category = "Color Grading|Misc", meta = (editcondition = "bOverride_TonemapType"))
TEnumAsByte<ETonemapType> TonemapType;

其他必要的注册与初始化#

为了让参数在引擎各个环节(如各种 View、Debug 视图)生效,还需要修改以下几处:

  1. OpenColorIO 渲染准备 (FOpenColorIORendering::PrepareView):

    InView.FinalPostProcessSettings.bOverride_TonemapType = 1;
    InView.FinalPostProcessSettings.TonemapType = ETonemapType::TT_ACES;
  2. Debug 绘制 (FPostProcessSettingsDebugBlock::OnDebugDraw):

    UE_DRAW_PP(TonemapType);
  3. 混合逻辑 (FPostProcessUtils::BlendPostProcessSettings):

    UE_SET_PP(TonemapType);
  4. 构造函数初始化 (FPostProcessSettings::FPostProcessSettings):

    TonemapType = ETonemapType::TT_ACES;
  5. View 设置覆盖 (FSceneView::OverridePostProcessSettings):

    SET_PP(TonemapType);
  6. ShowFlags 定义 (ShowFlagsValues.inl):

    SHOWFLAG_ALWAYS_ACCESSIBLE(TonemapType, SFG_PostProcess, NSLOCTEXT("UnrealEd", "TonemapTypeSF", "Tonemap Type"))
  7. 最终设置兜底 (FSceneView::EndFinalPostprocessSettings):

    if (!Family->EngineShowFlags.TonemapType)
    {
    FinalPostProcessSettings.TonemapType = ETonemapType::TT_ACES;
    }

传递参数到 Shader#

最后回到 PostProcessTonemap.cpp,将 View 中的设置传给 Shader 参数:

PostProcessTonemap.cpp
// 在 AllocParameters<FLUTBlenderPS::FParameters> 处
PassParameters->TonemapperType = View.FinalPostProcessSettings.TonemapType;
// 在 AllocParameters<FLUTBlenderCS::FParameters> 处
PassParameters->TonemapperType = View.FinalPostProcessSettings.TonemapType;

2. Shader 实现#

新建核心算法文件#

我们在 Shader 目录下新建 Toon/ToonPostProcess_Tonemap.ush,集中管理我们的算法。

ToonPostProcess_Tonemap.ush
#pragma once
#include "../TonemapCommon.ush"
#define TOON_TONEMAPPING_TYPE_NONE 0
#define TOON_TONEMAPPING_TYPE_ACES 1
#define TOON_TONEMAPPING_TYPE_NAES 2
#define TOON_TONEMAPPING_TYPE_LOG2 3
#define TOON_TONEMAPPING_TYPE_GT 4
// ========================== NAES Tonemap ==========================
float3 NAESTonemap(float3 input, float MaxBrightness)
{
// 按最大亮度进行缩放,防止 HDR 高光在曲线末端被错误截断
input = input / MaxBrightness;
input = (1.36 * input + 0.047) * input / ((0.93 * input + 0.56) * input + 0.14);
return input * MaxBrightness;
}
// ========================== Gran Turismo Tonemap ==========================
float W_f(float x, float e0, float e1)
{
if (x <= e0) return 0;
if (x >= e1) return 1;
float a = (x - e0) / max(e1 - e0, 0.00001);
return a * a * (3 - 2 * a);
}
float H_f(float x, float e0, float e1)
{
if (x <= e0) return 0;
if (x >= e1) return 1;
return (x - e0) / max(e1 - e0, 0.00001);
}
float GranTurismoTonemap(float x, float MaxBrightness)
{
float P = MaxBrightness;
float a = 1;
float m = 0.22;
float l = 0.4;
float c = 1.33;
float b = 0;
float l0 = (P - m) * l / a;
float L0 = m - m / a;
float L1 = m + (1 - m) / a;
float L_x = m + a * (x - m);
float T_x = m * pow(x / m, c) + b;
float S0 = m + l0;
float S1 = m + a * l0;
float C2 = a * P / max(P - S1, 0.00001);
float S_x = P - (P - S1) * exp(-(C2 * (x - S0) / P));
float w0_x = 1 - W_f(x, 0, m);
float w2_x = H_f(x, m + l0, m + l0 + 0.00001);
float w1_x = 1 - w0_x - w2_x;
return T_x * w0_x + L_x * w1_x + S_x * w2_x;
}
float3 GranTurismoTonemap(float3 color, float MaxBrightness)
{
return float3(
GranTurismoTonemap(color.r, MaxBrightness),
GranTurismoTonemap(color.g, MaxBrightness),
GranTurismoTonemap(color.b, MaxBrightness)
);
}
// ========================== Log2 Tonemap ==========================
float3 Log2Tonemap(float3 color, float MaxBrightness) {
color = color / MaxBrightness;
float3 logColor = log2(max(color, 0.0001));
float3 compressed = exp2(logColor * 0.33) * 1.4938 - 0.7;
float3 tonemapped = lerp(color, compressed, step(0.3, color));
return saturate(tonemapped) * MaxBrightness;
}
// ========================== 入口函数 ==========================
float3 ApplyTonemapper(uint TonemapperType, float3 LinearColor, float MaxBrightness)
{
if (TonemapperType == TOON_TONEMAPPING_TYPE_NONE)
{
return LinearColor;
}
else if (TonemapperType == TOON_TONEMAPPING_TYPE_NAES)
{
return NAESTonemap(LinearColor, MaxBrightness);
}
else if (TonemapperType == TOON_TONEMAPPING_TYPE_LOG2)
{
return Log2Tonemap(LinearColor, MaxBrightness);
}
else if (TonemapperType == TOON_TONEMAPPING_TYPE_GT)
{
return GranTurismoTonemap(LinearColor, MaxBrightness);
}
return FilmToneMap(LinearColor);
}

修改 CombineLUTs Shader#

PostProcessCombineLUTs.usf 中引入我们的新文件,并修改 CombineLUTsCommon 逻辑。

1. 引入文件与定义参数

PostProcessCombineLUTs.usf
#include "Toon/ToonPostProcess_Tonemap.ush" // Added
uint TonemapperType; // 对应 C++ 的 Parameter

2. 修改函数签名与调用

修改 CombineLUTsCommon 签名,增加 InTonemapperType 参数:

float4 CombineLUTsCommon(float2 InUV, uint InLayerIndex, uint InTonemapperType)

3. 替换 SDR/默认路径的 Tonemap 调用

将原本的 FilmToneMap 替换为 ApplyTonemapper

// 原代码:
// float3 ToneMappedColorAP1 = FilmToneMap( ColorAP1 );
// 修改后:
float3 ToneMappedColorAP1 = ApplyTonemapper( InTonemapperType, ColorAP1, 1.0f);
ColorAP1 = lerp(ColorAP1, ToneMappedColorAP1, ToneCurveAmount);

4. 适配 HDR 路径 (ST2084 & ScRGB)

UE 对 HDR 输出有单独的处理逻辑,我们需要把 ACESOutputTransform 的部分替换掉,同时保留 HDR 的亮度处理。

针对 TONEMAPPER_OUTPUT_ACES1000nitST2084 等情况:

else if( GetOutputDevice() == TONEMAPPER_OUTPUT_ACES1000nitST2084 || GetOutputDevice() == TONEMAPPER_OUTPUT_ACES2000nitST2084)
{
float3 ODTColor;
float PeakBrightness = OutputMaxLuminance / 100.0f;
// 如果是 None 或 ACES,保持原样
if (InTonemapperType == TOON_TONEMAPPING_TYPE_NONE || InTonemapperType == TOON_TONEMAPPING_TYPE_ACES)
{
#if USE_ACES_2
ODTColor = ACESOutputTransform(...);
#else
FACESTonemapParams AcesParams = ComputeACESTonemapParams(...);
ODTColor = ACESOutputTransform(GradedColor, (float3x3)WorkingColorSpace.ToAP0, AcesParams);
#endif
}
else
{
// 应用自定义 Tonemap
ODTColor = ApplyTonemapper(InTonemapperType, GradedColor, PeakBrightness);
}
ODTColor = mul(AP1_2_Output, ODTColor);
OutDeviceColor = LinearToST2084(ODTColor);
}

针对 ScRGB 的修改同理,将 ACESOutputTransform 替换为 ApplyTonemapper 并除以 UE_SCRGB_WHITE_NITS

5. 修改 MainPS 入口

最后更新 Pixel Shader 的入口调用:

// MainPS
#if USE_VOLUME_LUT == 1
void MainPS(FWriteToSliceGeometryOutput Input, out float4 OutColor : SV_Target0)
{
OutColor = CombineLUTsCommon(Input.Vertex.UV, Input.LayerIndex, TonemapperType);
}
#else
void MainPS(noperspective float4 InUV : TEXCOORD0, out float4 OutColor : SV_Target0)
{
OutColor = CombineLUTsCommon(InUV.xy, 0, TonemapperType);
}
#endif

到此 也就修改结束了

修改的文件与Commit#

CommitLink - MOON ENGINE

Pasted image 20260219174543

参考#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Unreal Engine - Tonemapping 定制化
https://dev.64hz.cn/posts/unreal-engine-tonemapping/
作者
Type Dream Moon
发布于
2026-02-19
许可协议
CC BY 4.0

评论区

Profile Image of the Author
Type Dream Moon
Write code, create dreams.
公告
大部分文章已从原来的博客迁移到新站点,欢迎访问!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
11
分类
3
标签
20
总字数
2,981
运行时长
0
最后活动
0 天前

目录