Unreal Engine - Tonemapping 定制化
预览
ACES

NAES

Log2

Gran Turismo

前言
最近在深挖 Unreal Engine (UE) 的卡通渲染(NPR)管线,发现 UE 自带的 Tonemapping(色调映射)方案使用的是 Filmic ACES 算法。虽然 ACES 在写实渲染中表现优异,拥有极高的动态范围拟真度,但在风格化的卡通渲染中,它往往会使画面“脏”且颜色过于写实。
如果不对其进行修改,目前常见的做法只能是完全关闭 Tonemapping,但这又会导致高光溢出处理不好。参考了市面上其他成熟的二次元渲染引擎,我决定对 UE 的 PostProcess 管线进行定制,接入几种更适合卡渲的 Tonemapping 算法。
算法选型与分析
拟引入的算法
为了给美术提供更多样化的风格选择,我在 PostProcess 中新增以下三种算法:
- NAESTonemap: 许多二次元项目中常用的算法,色彩还原度较好。
- Log2Tonemap: 基于对数空间的映射,过渡较为柔和。终末地在用
- GTTonemap (Gran Turismo): 参数可控性强,高光过渡自然。原神、米家游戏
源码分析
在动手修改之前,我们需要梳理 UE 渲染管线中 Tonemap 的执行逻辑。主要涉及的文件如下:
- C++ 层面:
PostProcessTonemap.h/cppPostProcessCombineLUTs.h/cpp
- Shader 层面:
PostProcessTonemap.usfPostProcessCombineLUTs.usfTonemapCommon.ush
渲染管线追踪
通过 PIX 截帧分析,我们可以观察到 PostProcess 的 Pass 分布:

关键点在于 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 gamutfloat3 ToneMappedColorAP1 = FilmToneMap( ColorAP1 );ColorAP1 = lerp(ColorAP1, ToneMappedColorAP1, ToneCurveAmount);这里的 FilmToneMap 就是 UE 默认的 ACES 算法入口。我们要做的,就是在这里截获并替换为我们自定义的算法。
引擎修改实施
1. 扩展 C++ 参数 (Parameter)
我们需要让上层逻辑(PostProcessVolume)能够传递“使用哪种 Tonemap”的参数给 Shader。
PostProcessCombineLUTs.cpp
首先在 Shader 参数结构体 FCombineLUTParameters 中添加字段:
SHADER_PARAMETER(uint32, TonemapperType)然后在 GetCombineLUTParameters 函数中绑定数据更新:
// 找到 Parameters.ToneCurveAmount 附近,添加:UPDATE_CACHE_SETTINGS(Parameters.TonemapperType, Settings.TonemapType, bHasChanged);Scene.h (定义枚举)
在 Scene.h 中定义 Tonemap 的类型枚举,方便在编辑器中选择:
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 - FPostProcessSettingsUPROPERTY(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 视图)生效,还需要修改以下几处:
-
OpenColorIO 渲染准备 (
FOpenColorIORendering::PrepareView):InView.FinalPostProcessSettings.bOverride_TonemapType = 1;InView.FinalPostProcessSettings.TonemapType = ETonemapType::TT_ACES; -
Debug 绘制 (
FPostProcessSettingsDebugBlock::OnDebugDraw):UE_DRAW_PP(TonemapType); -
混合逻辑 (
FPostProcessUtils::BlendPostProcessSettings):UE_SET_PP(TonemapType); -
构造函数初始化 (
FPostProcessSettings::FPostProcessSettings):TonemapType = ETonemapType::TT_ACES; -
View 设置覆盖 (
FSceneView::OverridePostProcessSettings):SET_PP(TonemapType); -
ShowFlags 定义 (
ShowFlagsValues.inl):SHOWFLAG_ALWAYS_ACCESSIBLE(TonemapType, SFG_PostProcess, NSLOCTEXT("UnrealEd", "TonemapTypeSF", "Tonemap Type")) -
最终设置兜底 (
FSceneView::EndFinalPostprocessSettings):if (!Family->EngineShowFlags.TonemapType){FinalPostProcessSettings.TonemapType = ETonemapType::TT_ACES;}
传递参数到 Shader
最后回到 PostProcessTonemap.cpp,将 View 中的设置传给 Shader 参数:
// 在 AllocParameters<FLUTBlenderPS::FParameters> 处PassParameters->TonemapperType = View.FinalPostProcessSettings.TonemapType;
// 在 AllocParameters<FLUTBlenderCS::FParameters> 处PassParameters->TonemapperType = View.FinalPostProcessSettings.TonemapType;2. Shader 实现
新建核心算法文件
我们在 Shader 目录下新建 Toon/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. 引入文件与定义参数
#include "Toon/ToonPostProcess_Tonemap.ush" // Added
uint TonemapperType; // 对应 C++ 的 Parameter2. 修改函数签名与调用
修改 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 == 1void MainPS(FWriteToSliceGeometryOutput Input, out float4 OutColor : SV_Target0){ OutColor = CombineLUTsCommon(Input.Vertex.UV, Input.LayerIndex, TonemapperType);}#elsevoid MainPS(noperspective float4 InUV : TEXCOORD0, out float4 OutColor : SV_Target0){ OutColor = CombineLUTsCommon(InUV.xy, 0, TonemapperType);}#endif到此 也就修改结束了
修改的文件与Commit

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