前言
从本次实验开始,将会一步一步地新增一个 Toon Shading Model,在 UE 5.3.2 中实现卡通渲染的效果。
首先来看看什么是 Shading model:虚幻引擎官方文档的说法是,shading model 控制材质反射入射光线的方式,也就是控制构成材质的那些输入数据,最终决定外观长什么样。物理上,它是一套关于光和表面怎么互动的数学规则,比如漫反射、镜面高光、次表面散射、透射这些。工程上,它更像一份数据约定,规定了材质要往渲染管线里交哪些参数,以及这些参数在光照计算里会被怎么解读。
同一个基础色值,放在 Default Lit 下就是个普通塑料或者金属表面,换成 Clear Coat 就会多一层清漆似的高光,换成 Subsurface 又可能变成皮肤或蜡那种透光感。差别不在贴图或者数值上,而在于这个 shading model 背后那套 BRDF 或 BSDF 逻辑:高光的形状和强度怎么随粗糙度变,要不要考虑光折射进表面之后的散射,能量在多层之间怎么分。UE 自带的 Default Lit、Clear Coat、Hair、Cloth、Subsurface 这些,说白了就是 Epic 给常见视觉类型提前封装好的几套规则。

本次实验还是针对 PC 端的延迟管线。我们知道延迟管线将渲染拆分成了两个阶段:「几何 + 材质」和「光照」。
这两个阶段之间没有别的通信手段。Base Pass 不知道场景里有几盏灯,光照 Pass 也不知道场景里有哪些 Shading Model,它们唯一的交集就是一块显存------GBuffer。
所以在动手加 Shading Model 之前,需要先把 GBuffer 结构了解一下。能加什么,不取决于想加什么,而取决于 GBuffer 还剩多少空位。
在实验开始之前,需要关闭 Lumen:
text
Project Settings
Dynamic Global Illumination Method = None
Reflection Method = None
关闭 Visual Shadow Map:
text
Project Settings
Shadow Map Method = Shadow Maps
GBuffer 结构
PC 延迟渲染路径下,GBuffer 不是一个结构体,而是一组 Render Target。最简情形(无 Velocity、无 WorldTangent、无静态光照)是 5 个,索引、格式和通道分配全部由 Engine/Source/Runtime/RenderCore/Private/GBufferInfo.cpp 的 FetchLegacyGBufferInfo() 声明:
| Target | 名字 | 格式 | 通道分配 |
|---|---|---|---|
| 0 | Lighting | UNORM 11:11:10 | 光照明亮部分(就是 SceneColor) |
| 1 | GBufferA | UNORM 10:10:10:2 | RGB = WorldNormal(八面体压缩);A = PerObjectGBufferData(2 bit) |
| 2 | GBufferB | UNORM 8:8:8:8 | R = Metallic,G = Specular,B = Roughness,A = ShadingModelId + SelectiveOutputMask |
| 3 | GBufferC | UNORM 8:8:8:8 (sRGB) | RGB = BaseColor;A = AO / 间接光相关 |
| 4 | GBufferD | UNORM 8:8:8:8 | CustomData |
两个格式细节值得留意。GBufferA 的 alpha 只有 2 bit,因为整个 RT 是 10_10_10_2------法线占满了 30 位,剩下的 2 位装的是每物体的接触阴影数据(PerObjectGBufferData)。GBufferC 默认带 sRGB 标志。
索引不是固定的。项目一旦开启 TSR 或运动模糊,就会多出 Velocity 这个 RT,GBufferD 跟着后移到 5;开静态光照还会再多一个 GBufferE 装 PrecomputedShadowFactors。所以永远不要硬编码 GBuffer 索引 ,要用 GBufferBindings 去查。
Shading Model ID
如上表所示 Shading Model ID 存储于 GBufferB 的 alpha 通道,但只占了 4 个 bit。GBufferInfo.cpp 的注释写得很直白:
cpp
// pack it into bits [0:3] of alpha
Info.Slots[GBS_ShadingModelId] = FGBufferItem(GBS_ShadingModelId, GBC_Bits_4, GBCH_Both);
Info.Slots[GBS_ShadingModelId].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 3, 0, 0, 4);
// pack it into bits [4:7] of alpha
Info.Slots[GBS_SelectiveOutputMask] = FGBufferItem(GBS_SelectiveOutputMask, GBC_Bits_4, GBCH_Both);
Info.Slots[GBS_SelectiveOutputMask].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 3, 0, 4, 4);
FGBufferPacking 最后三个参数依次是:通道 3(alpha)、起始 bit、长度。低 4 位给 Shading Model ID,高 4 位定义 SelectiveOutputMask。
HLSL 侧的编解码也把这个约束写死了(DeferredShadingCommon.ush):
hlsl
float EncodeShadingModelIdAndSelectiveOutputMask(uint ShadingModelId, uint SelectiveOutputMask)
{
uint Value = (ShadingModelId & SHADINGMODELID_MASK) | SelectiveOutputMask;
return (float)Value / (float)0xFF;
}
uint DecodeShadingModelId(float InPackedChannel)
{
return ((uint)round(InPackedChannel * (float)0xFF)) & SHADINGMODELID_MASK;
}
SelectiveOutputMask 定义了四个 flag(ShadingCommon.ush):
hlsl
// The flags are defined so that 0 value has no effect!
// These occupy the 4 high bits in the same channel as the SHADINGMODELID_*
#define HAS_ANISOTROPY_MASK (1 << 4)
#define SKIP_PRECSHADOW_MASK (1 << 5)
#define ZERO_PRECSHADOW_MASK (1 << 6)
#define SKIP_VELOCITY_MASK (1 << 7)
所以新增一个 Shading Model 并不只是加一行枚举,它同时还受 GBuffer 数据布局的约束。
SHADINGMODELID_MASK = 0xF,4 bit,16 个槽位。这是硬容量。
目前用掉多少?ShadingCommon.ush 里从 SHADINGMODELID_UNLIT = 0 一路排到 SHADINGMODELID_STRATA = 12。
hlsl
#define SHADINGMODELID_STRATA 12 // Temporary while we convert everything to Strata
#define SHADINGMODELID_NUM 13
#define SHADINGMODELID_MASK 0xF // 4 bits reserved for ShadingModelID
0 到 12 共 13 个已被占用,剩下 13、14、15 三个空位。也就是说------在不改动 GBuffer 布局、不挤掉任何现有 Shading Model 的前提下,还能再加 3 个。
GPU 侧那 16 个槽位也不是唯一的约束。C++ 侧还有第二道墙(EngineTypes.h):
cpp
static_assert(MSM_NUM <= 16, "Do not exceed 16 shading models without expanding FMaterialShadingModelField to support uint32 instead of uint16!");
该上限的成因写在断言之后的私有成员中:
cpp
private:
UPROPERTY()
uint16 ShadingModelField = 0;
FMaterialShadingModelField 用第 N 位表示第 N 个 model,一个 uint16 就是 16 位。当前 MSM_NUM = 13,所以最多还能再加 3 个,第 4 个就会触发断言。
CustomData
ID 只解决了「这是哪个 shading model」,但每个 shading model 可能还有自定义数据:皮肤的次表面颜色、清漆的粗糙度、毛发的 backlit、卡通的色阶阈值等等。
于是就有了 GBufferD:整块四个字节,在 HLSL 里叫 CustomData,作为一个 half4 挂在 FGBufferData 上。编码侧只有一行,无条件地整块拷贝(DeferredShadingCommon.ush):
hlsl
OutGBufferD = GBuffer.CustomData;
「哪个 Shading Model 占用哪个分量」,GBuffer 本身完全不知道 。它完全靠消费者在光照阶段用 switch (ShadingModelID) 重新解释:生产者按约定写,消费者按同一个约定读,中间没有任何校验。
至此,GBuffer 中两块关键区域已经说明完毕,下面进入具体实现。
Toon Shading Model
phase 1:注册 Toon Shading Model
从前文可以得知,Toon 可以使用还没有被占用的 ID 13。接下来先让 Material 系统认识它。完成本阶段后,新建一个 Material 就能在 Shading Model 下拉框里选到 Toon,保存并重新打开后这个选择依然保留。

UE 中一份 Material 最终会参与生成不同的 Shader。编辑器里选择的 Shading Model 是 C++ 枚举,Shader 不能直接读取这个枚举对象,因此需要经过 Material 编译器。这里有两项不同的工作:一项是把选中的枚举值生成到 HLSL 中,作为材质数据;另一项是设置编译宏,告诉公共 Shader 代码这份 Material 包含哪些 Shading Model。
第一项可以复用引擎已有的生成逻辑,本阶段主要补齐枚举注册和第二项所需的编译环境。
1. 添加 Material 可以选择的枚举
在 Engine/Source/Runtime/Engine/Classes/Engine/EngineTypes.h 中找到 EMaterialShadingModel,在 MSM_Strata 后、MSM_NUM 前插入 MSM_Toon:
cpp
UENUM()
enum EMaterialShadingModel : int
{
// 前面的已有枚举保持原样。
MSM_SingleLayerWater UMETA(DisplayName="SingleLayerWater"),
MSM_ThinTranslucent UMETA(DisplayName="Thin Translucent"),
MSM_Strata UMETA(DisplayName="Substrate", Hidden),
MSM_Toon UMETA(DisplayName="Toon"),
/** Number of unique shading models. */
MSM_NUM UMETA(Hidden),
MSM_FromMaterialExpression UMETA(DisplayName="From Material Expression"),
MSM_MAX
};
这里给出了枚举的上下文,实际只需要新增 MSM_Toon 这一行。
MSM_Strata 的值是 12,新插入的 MSM_Toon 为 13,MSM_NUM 随之变为 14。MSM_NUM 是有效 Shading Model 的数量边界,并不是一种实际可用的 Shading Model。也就是说,前文算出的 3 个空位被 Toon 占用一个,此后还能继续无损扩展的只剩 14、15 两个。
插入位置需要谨慎。已有 Shading Model 的枚举数值涉及资产中的保存数据,也与后面的 GPU ID 对应。如果把 Toon 插到 Default Lit 等已有成员前面,就会挪动后面成员的数值。这里把它追加在已有有效成员之后,保留 0~12 的对应关系;MSM_NUM、MSM_FromMaterialExpression 等尾部成员则会随之顺延。
到这里,C++ 已经有了 Toon 的身份,但 Shader 编译器还不知道该为它启用哪些条件。
2. 补充名称映射和编译统计
打开 Engine/Source/Runtime/Engine/Private/Materials/MaterialShader.cpp,在 GetShadingModelString() 的 switch 中增加:
cpp
case MSM_Toon: ShadingModelName = TEXT("MSM_Toon"); break;
这个函数把枚举转换为字符串,供诊断、日志等代码使用。不补充这个分支,调用该函数只能得到 Unknown,排查材质问题时会很麻烦。
同一个文件中的 UpdateMaterialShaderCompilingStats() 会分别统计 Unlit 和 Lit Material 的 Shader 编译数量。只需在原有 Lit 判断集合末尾补上 MSM_Toon:
cpp
else if (ShadingModels.HasAnyShadingModel({
MSM_DefaultLit, MSM_Subsurface, MSM_PreintegratedSkin,
MSM_ClearCoat, MSM_Cloth, MSM_SubsurfaceProfile,
MSM_TwoSidedFoliage, MSM_SingleLayerWater,
MSM_ThinTranslucent, MSM_Toon }))
{
INC_DWORD_STAT_BY(STAT_ShaderCompiling_NumLitMaterialShaders, 1);
}
Toon 需要接收灯光,因此把它计入 Lit 统计。这一处只是补齐统计,并不会赋予 Toon 光照能力。
3. 把 Toon 写进 Material 的 Shader 编译环境
Material Editor 中连接的节点最终要转换成 HLSL,但生成的代码并不是完全独立的一份 Shader。它还会与引擎中的 Material Template、Base Pass 等公共 Shader 代码组合。公共代码需要知道当前 Material 使用了哪些功能,才能决定保留哪些分支。
UE 通过 FShaderCompilerEnvironment 传递这些编译条件。这里要做的是:当正在编译的 Material 包含 Toon 时,向编译环境加入 MATERIAL_SHADINGMODEL_TOON=1。
UE 5.3.2 中需要检查两条 Material HLSL 生成路径,对应的修改位置分别是:
Engine/Source/Runtime/Engine/Private/Materials/HLSLMaterialTranslator.cpp:FHLSLMaterialTranslator::GetMaterialEnvironment()。Engine/Source/Runtime/Engine/Private/Materials/MaterialHLSLEmitter.cpp:文件内的GetMaterialEnvironment()。
前者是传统的 HLSL Translator 路径,后者配合新的 HLSL Tree 生成路径使用。它们是不同的生成入口,并不是每次编译都会先后执行两个函数。两处都有一组根据 Shading Model 设置编译宏的代码,在这组判断中加入相同的 Toon 分支:
cpp
if (ShadingModels.HasShadingModel(MSM_Toon))
{
OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_TOON"), TEXT("1"));
NumSetMaterials++;
}
这段代码放在已有 Shading Model 判断之后、处理 NumSetMaterials 逻辑之前。笔者将其放在 Thin Translucent 分支的后面。
这里的 ShadingModels 是一个 FMaterialShadingModelField,代表 Material 可能使用的 Shading Model 集合。普通的下拉框选择通常只有一个 Shading Model,但 From Material Expression 路径可以让一个 Material 包含多个 Shading Model,因此引擎使用集合判断,并为每种 Shading Model 分别设置宏。新增分支也应沿用独立的 if,不要把这一组判断改成只能命中一个 Shading Model 的 else if。
SetDefine() 为这次 Shader 编译提供宏定义,不会向磁盘上的 .ush、.usf 文件插入文本,也不是每帧传给 GPU 的 Uniform 参数。对于包含 Toon 的 Material,其效果相当于在预处理时提供了 #define MATERIAL_SHADINGMODEL_TOON 1。后续在 HLSL 中写:
hlsl
#if MATERIAL_SHADINGMODEL_TOON
// 当前 Material 包含 Toon 时才编译的代码。
#endif
预处理阶段就能决定这段代码是否进入编译。它解决了「这份 Material Shader 是否需要 Toon 代码」的问题。这里的宏值是布尔标记 1,不是 Toon 的 ID 13;设置这个宏,也不等于向 GBuffer 写入了 13。Shading Model 编号会通过 Material 编译器的另一段已有逻辑生成,下面的 Phase 2 会继续追踪。
NumSetMaterials++ 也不能漏。往下看原有代码,会发现这个计数参与了两个判断:
cpp
if (NumSetMaterials == 1)
{
OutEnvironment.SetDefine(TEXT("MATERIAL_SINGLE_SHADINGMODEL"), TEXT("1"));
}
ensure(NumSetMaterials != 0);
if (NumSetMaterials == 0)
{
UE_LOG(LogMaterial, Warning, TEXT("Unknown material shading model(s). Setting to MSM_DefaultLit"));
OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_DEFAULT_LIT"), TEXT("1"));
}
当计数为 1,引擎可以标记这个 Material 只有一种 Shading Model;当计数为 0,则意味着没有识别到有效 Shading Model,会走错误提示和 Default Lit 的兜底。如果只添加 Toon 宏却忘记增加计数,一个只使用 Toon 的 Material 仍可能落到这段兜底中。下拉框里能选到 Toon,并不能证明编译环境已经正确认识了它。
4. 给 Shader 宏提供默认值
C++ 只会在 Material 包含 Toon 时将宏设置为 1,而引擎中的公共 Shader 文件还会被其他 Material 和 Shader 使用。因此,需要在 Engine/Shaders/Private/Definitions.usf 的 Shading Model 宏定义区域补上:
hlsl
#ifndef MATERIAL_SHADINGMODEL_TOON
#define MATERIAL_SHADINGMODEL_TOON 0
#endif
这样,如果 Toon Material 的编译环境已经定义了值 1,#ifndef 不会覆盖它;若没有设置该宏则得到默认值 0。后面的公共 Shader 代码便可以统一用这个宏表达编译条件。这一处属于 Shader 源码修改,但还没有增加 Toon 的 GBuffer 输出或光照算法。
5. 结果
完成以上五个文件的修改后,关闭 Editor,构建 Editor Target,再重新打开项目。新建一个 Material,在 Shading Model 下拉框中应当可以选择 Toon。选择后等待 Shader 编译完成,保存并关闭 Material,再重新打开,确认仍然是 Toon;最后重启 Editor,确认这个选择能够保留下来。
phase 2:把 Toon ID 写入 GBuffer
这一阶段补齐从材质数据到 GBuffer 纹理的输出链路。先看 C++ 中选定的 MSM_Toon 如何传入 Shader,再修改 GPU 侧定义和 Base Pass 输出声明。
1. C++ 中的 Toon 如何传递到 Shader
在 Material Editor 中选择 Toon 后,这个选择保存在 Material 中,C++ 用枚举 MSM_Toon 表示它。Material 编译器会读取这个选择,将枚举数值生成到 HLSL 中。
在传统 Material 编译路径中,MaterialAttributeDefinitionMap.cpp 的 FMaterialAttributeDefintion::CompileDefaultValue() 会为 MP_ShadingModel 生成默认值:
cpp
return Compiler->ShadingModel(Compiler->GetMaterialShadingModels().GetFirstShadingModel());
当 Material 在下拉框中选择 Toon 时,这里取得的就是 MSM_Toon。随后进入 FHLSLMaterialTranslator::ShadingModel(),其中生成 HLSL 表达式的代码是:
cpp
return AddInlinedCodeChunk(MCT_ShadingModel, TEXT("%d"), InSelectedShadingModel);
对当前 Toon Material 来说,这段代码生成的整数就是 13。新的 HLSL Tree 路径也会为单一 Shading Model 生成对应的默认数值,因此不需要另外添加一个「Toon 转 ID」的转换函数。可以把生成结果近似理解为下面这句 HLSL,它用于说明数据含义,不是需要手动添加的代码:
hlsl
PixelMaterialInputs.ShadingModel = 13;
它与 Phase 1 中的 MATERIAL_SHADINGMODEL_TOON=1 是两件不同的事。前者是 Material 计算结果中的 Shading Model 编号,后者是控制编译条件的开关。
生成的 Material 代码将结果放入 FPixelMaterialInputs.ShadingModel。Base Pass 通过 MaterialTemplate.ush 中的函数访问它:
hlsl
uint GetMaterialShadingModel(FPixelMaterialInputs PixelMaterialInputs)
{
return PixelMaterialInputs.ShadingModel;
}
BasePassPixelShader.usf 取得这个值后,会调用 ShadingModelsMaterial.ush 中的 SetGBufferForShadingModel()。该函数先填写通用材质数据,其中包含:
hlsl
GBuffer.ShadingModelID = ShadingModel;
这里的 GBuffer 是 Shader 内部的 FGBufferData 结构体变量,不是显存中的 GBufferB 纹理。对于 Toon,这句赋值能够得到 GBuffer.ShadingModelID = 13;但只有后续的编码和 MRT 输出都正常,数值才会真正写到 Render Target。前文介绍的低 4 bit 打包规则就在这个输出过程中起作用。
所以本阶段不需要在 SetGBufferForShadingModel() 中再加一句 GBuffer.ShadingModelID = SHADINGMODELID_TOON。通用赋值已经能处理 Toon,真正未完成的是:引擎是否为这份 Toon Material Shader 保留了相应的 GBuffer 输出。
2. 定义 GPU 侧的 Toon ID
数值已经可以沿用上述流程传入 Shader,接下来给它补充统一的符号名称。打开 Engine/Shaders/Private/ShadingCommon.ush,在现有 Shading Model ID 的尾部添加 Toon,并更新数量:
hlsl
#define SHADINGMODELID_STRATA 12
#define SHADINGMODELID_TOON 13
#define SHADINGMODELID_NUM 14
#define SHADINGMODELID_MASK 0xF
SHADINGMODELID_TOON 要与上一阶段的 MSM_Toon 数值一致。SHADINGMODELID_NUM 表示 Shading Model 数量,新增 Toon 后应为 14;因为 13 还在低 4 bit 能表示的范围内,并不需要扩展 GBuffer 布局,SHADINGMODELID_MASK 仍是 0xF。
这条 #define 本身不会执行赋值,也不会触发 Render Target 写入。它让后续的 Shader 可以用 SHADINGMODELID_TOON 判断 Shading Model 身份,而不必到处写数字 13。C++ 通过枚举表示 Material 的选择,HLSL 通过这个符号识别同一个编号,两侧遵守一致的数值约定。
继续在这个文件中找到 GetShadingModelColor(),给 Toon 增加一个紫色调试值:
hlsl
#if PS4_PROFILE
// 在已有的 if / else if 链中追加。
else if (ShadingModelID == SHADINGMODELID_TOON) return float3(0.8f, 0.2f, 1.0f); // Purple
#else
// 在已有的 switch 中追加。
case SHADINGMODELID_TOON: return float3(0.8f, 0.2f, 1.0f); // Purple
#endif
这个紫色用于打开 Buffer Visualization → Shading Model 时,快速查看像素的 Shading Model。这是在读取 GBuffer 后将 ID 转成调试颜色,不是给 Toon Material 设置 Base Color,也不参与真实光照。
3. 为 Toon 声明 Base Pass 所需的 GBuffer 输出
前面已经解释了 ID 从哪里来,以及 Shader 如何称呼它。现在还差输出端:Pixel Shader 内部计算出的结构体数据,需要通过声明好的 MRT 输出才能写进纹理。
UE 5.3.2 的 Legacy GBuffer 路径会在 C++ 侧分析 Material 需要写入哪些数据,再结合 FGBufferInfo 中的布局确定需要输出的 Render Target。这些工作发生在 Shader 编译准备阶段。Toon 的数值生成可以复用原有流程,但这个输出分析未修改前只认识已有的 Shading Model,需要补充对 Toon 的判断。
首先,在 Engine/Source/Runtime/RenderCore/Public/ShaderMaterial.h 的 FShaderMaterialPropertyDefines 中,紧接现有 Shading Model 字段添加:
cpp
uint8 MATERIAL_SHADINGMODEL_TOON : 1;
这是一个 C++ bit field,用于保存编译宏的布尔状态,并不是传给 GPU 的参数,也不会在 GBuffer 中新增 1 bit。它必须和上一阶段的 Shader 宏同名------FETCH_COMPILE_BOOL 正是按这个字符串去编译环境里查找,名称不一致则无法读取。
接着打开 Engine/Source/Runtime/Engine/Private/ShaderCompiler/ShaderGenerationUtil.cpp,找到接收 FShaderMaterialPropertyDefines& 的 FShaderCompileUtilities::ApplyFetchEnvironment() 重载。在现有 Shading Model 宏的读取代码后追加:
cpp
FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_TOON);
FETCH_COMPILE_BOOL 会在 FShaderCompilerEnvironment 中查找同名定义,读取数值,再写到刚才增加的字段中。这里读到的是 Phase 1 设置的布尔值 1,不是 Shading Model 编号 13。它让 C++ 的 GBuffer 输出分析能够判断「当前 Material 包含 Toon」。只添加字段却忘记读取宏,后面看到的 Toon 标志位仍然是 0。
然后在同一文件的 DetermineUsedMaterialSlots() 中增加 Toon 分支,本次放在 Default Lit 分支后面:
cpp
if (Mat.MATERIAL_SHADINGMODEL_TOON)
{
SetStandardGBufferSlots(Slots,
bWriteEmissive,
bHasTangent,
bHasVelocity,
bHasStaticLighting,
bIsStrataMaterial);
}
这里的 Slot 指的是一种逻辑上的 GBuffer 数据,例如 World Normal、Base Color、Shading Model ID,而不是整张 Render Target。同一张 Render Target 可以承载多个 Slot,具体通道分配规则由 FGBufferInfo 决定。
SetStandardGBufferSlots() 会声明标准 Lit Material 所需的数据。对本文的 Legacy Material 路径,它会启用 World Normal、Metallic、Specular、Roughness、Base Color、Shading Model ID 等 Slot。其中与 Shading Model 直接相关的是:
cpp
Slots[GBS_ShadingModelId] = bIsStrataMaterial ? false : true;
Velocity、Tangent 和静态阴影等额外输出则沿用当前编译配置,不需要为 Toon 硬编码 Render Target 索引。这里也继续使用独立的 if,以兼容一个 Material 包含多种 Shading Model 的情况。
调用关系往上看,ApplyDerivedDefines() 会先取得这些 Slot 的使用情况,再通过 BufferInfo.Slots[Index] 查找它们对应的实际 Render Target,设置 bTargetUsage,最终生成 PIXELSHADEROUTPUT_MRTn 等输出定义。Base Pass 据此声明相应的 MRT 输出,才能把 FGBufferData 中的数据写进目标纹理。
两个环节由此衔接:Material Shader 负责算出「要写什么值」,C++ 的 Slot 分析负责声明「需要哪些输出」:
| 环节 | 发生时机 | 得到的结果 |
|---|---|---|
| Material 代码生成 | Shader 编译准备时 | 生成表示 Shading Model 编号的 HLSL,Toon Material 对应数值 13 |
| Slot 与 MRT 输出分析 | Shader 编译准备时 | 声明包含 Shading Model ID 在内的标准 GBuffer 输出 |
| Base Pass 计算与输出 | GPU 执行绘制时 | 填写 FGBufferData,编码后将 ID 写入 GBufferB.alpha |
本阶段只调用标准 Slot 设置,不启用 GBS_CustomData。
4. 更新生成逻辑的缓存版本
然后在 ShaderGenerationUtil.cpp 中,将生成器版本从 5 更新为 6:
cpp
// if we change the logic, increment this number to force a DDC key change
static const int32 GBufferGeneratorVersion = 6;
UE 会把派生数据放进 DDC(Derived Data Cache),避免每次启动都重新生成和编译。前面的改动发生在 C++ 的 Slot 判断中,影响的是最终生成的输出定义。把生成器版本递增,能让这批生成逻辑使用新的缓存键,避免继续沿用修改前的结果。
这里的 6 对应本文 Phase 2 的修改。如果是在已有其他修改的引擎上继续实验,应在原版本基础上递增。
5. 结果
完成这三个文件的修改后,构建 Editor Target,再打开测试场景等待 Shader 编译完成。给一个 Static Mesh 指定 Toon Material,另放一个 Default Lit Mesh 作为对照。
将视口切换到 Buffer Visualization → Shading Model,预期 Toon 表面显示为新增的紫色,Default Lit 表面仍然是原来的绿色。这个视图根据 GBuffer 中解码后的 ID 选取调试颜色,而不是 Material 的 Base Color。

如果使用 RenderDoc,可以在 Base Pass 完成后找到 GBufferB,选择 Toon Shading Model 内部、没有被遮挡的像素,读取 alpha 通道。由于 alpha 同时存放 Shading Model ID 和 SelectiveOutputMask,需要按前文的规则还原:
text
PackedValue = round(GBufferB.a * 255)
ShadingModelID = PackedValue & 0xF
Toon 的预期结果:ShadingModelID = 13

text
alpha = 0.55294 ≈ 1000 1101
高4位 = 1000 = 8
低4位 = 1101 = 13
因为 Deferred Lighting 还没有为 ID 13 提供光照分支,此时普通 Lit 视图中的 Toon 仍可能是黑色。
phase 3:让 Deferred Lighting 调用 ToonBxDF
上一阶段结束时,Shading Model ID 已经写入 GBuffer,但普通 Lit 视图中的 Toon 仍然呈现为黑色。原因位于光照阶段:Deferred Lighting 依据 ID 查找对应的光照函数,而当前分派表中没有 Toon 对应的分支。
这一阶段只修改 Engine/Shaders/Private/ShadingModels.ush,为 Toon 补上一条分支。首次实现返回常量色,用于确认「ID → 光照函数」这条链路成立。
1. 找到分派点
分派发生在 IntegrateBxDF() 中。它是光照阶段唯一按 Shading Model 分叉的入口:输入 FGBufferData、法线、视线、光线方向等参数,输出该表面沿观察方向反射出的光。BxDF 是 BRDF、BTDF 等表面散射函数的统称,含义是「给定表面数据与光线方向,计算表面如何响应这束光」。
引擎中存在同名重载,需要修改的是参数含 L、Falloff、NoL、FAreaLight 的版本:
hlsl
FDirectLighting IntegrateBxDF(
FGBufferData GBuffer,
half3 N,
half3 V,
half3 L,
float Falloff,
half NoL,
FAreaLight AreaLight,
FShadowTerms Shadow)
函数内部按 GBuffer.ShadingModelID 进行 switch:Default Lit 进入 DefaultLitBxDF(),Cloth 进入 ClothBxDF(),其余已有 Shading Model 各有对应分支。此前没有 Toon 的分支,ID 13 会落到 default,而 default 会返回零值 FDirectLighting,这就是黑色的来源。
2. 新增 ToonBxDF
先给出一个返回常量色的实现,用于验证链路是否成立。函数位置在 IntegrateBxDF() 之前:
hlsl
FDirectLighting ToonBxDF(
FGBufferData GBuffer,
half3 N,
half3 V,
half3 L,
float Falloff,
half NoL,
FAreaLight AreaLight,
FShadowTerms Shadow)
{
FDirectLighting Lighting = (FDirectLighting)0;
Lighting.Diffuse = AreaLight.FalloffColor * Falloff * float3(1.0f, 0.0f, 1.0f);
return Lighting;
}
函数签名与同层的 DefaultLitBxDF() 保持一致。
返回值 FDirectLighting 分三部分,对应漫反射、高光、透射:
hlsl
struct FDirectLighting
{
float3 Diffuse;
float3 Specular;
float3 Transmission;
};
ToonBxDF() 的首行 (FDirectLighting)0 将整个结构体清零,随后只给 Diffuse 赋值,因此 Specular 与 Transmission 均保持为零。
八个参数中本阶段只使用其中两个,后续再进行扩展:
| 参数 | 含义 |
|---|---|
GBuffer |
当前表面的材质数据,包括 Base Color、Roughness、Shading Model ID 等 |
N |
表面法线,可包含 Normal Map 的扰动 |
V |
表面指向观察者的方向 |
L |
表面指向光源的方向 |
Falloff |
当前光源积分路径传入的衰减或权重 |
NoL |
上层算好的 N·L,部分光源路径会修正它 |
AreaLight |
当前光源整理后的面积光源参数,其中包含 FalloffColor |
Shadow |
已算好的表面阴影、透射阴影等信息 |
3. 把 Tone 接进 switch
函数就位后,将其接入分派表:
hlsl
case SHADINGMODELID_TOON:
return ToonBxDF(GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow);
4. 注意事项
链路至此已经打通,但值得一提的是:ToonBxDF() 返回的不是最终颜色 ,而是当前光源对表面的一份贡献。返回之后,DeferredLightingCommon.ush 仍要继续乘以灯光颜色与阴影,再累加到 LightAccumulator。常规 PC 路径上对应下面这次调用:
hlsl
LightAccumulator_AddSplit(
LightAccumulator,
Lighting.Diffuse,
Lighting.Specular,
Lighting.Diffuse,
MaskedLightColor * Shadow.SurfaceShadow,
bNeedsSeparateSubsurfaceLightAccumulation);
对当前只有 Diffuse 输出的 Toon 而言,这一层关系可以近似表示为:
text
当前直接光贡献
= ToonBxDF 返回的 Diffuse
× MaskedLightColor
× Shadow.SurfaceShadow
因此「固定洋红色」仅指 BxDF 中用于调试的颜色,光源颜色、强度、衰减与阴影依然生效,最终屏幕颜色还会经过曝光与 Tonemapper 处理。
5. 结果
保存 Shader 文件。若 Editor 正在运行,可在控制台执行 RecompileShaders Changed;若未运行,重新打开 Editor 后编译会自动开始。
测试时使用一盏白色的 Movable Directional Light,固定曝光,关闭 Lumen GI,并为 Sphere 指定 Toon Material。在材质编辑器预览窗口可得到如下结果:

phase 4:把 Material 参数传到 ToonBxDF
上一阶段的洋红色说明 Deferred Lighting 已经能调用 ToonBxDF(),只是颜色还写死在 Shader 里。真正用起来,至少要让不同 Material 各自设置暗部色调和明暗阈值。
这一阶段先把参数接通,二分光照留到后面。目标是在 Material Editor 里填好颜色和标量,由 Base Pass 写进 GBuffer,再由 Deferred Lighting 读出来。
1. 约定 Material 输入和 CustomData 布局
UE 里已经有几个和 Shading Model 绑定的 Material 输入,可以直接复用:用 Subsurface Color 传颜色,用 Custom Data 0 传标量,再给 Toon 配上对应的显示名称:
| Material 属性 | Toon 中的显示名称 | 用途 | GBuffer 存储 |
|---|---|---|---|
MP_SubsurfaceColor |
Toon Shadow Tint | 后续调节环境光的色调 | CustomData.rgb,颜色编码后存储 |
MP_CustomData0 |
Toon Threshold | 后续控制直接光明暗分界 | CustomData.a |
颜色占三个分量,阈值占一个,正好填满 CustomData 的 RGBA。全程没有新建 Material 属性,也没有增加 Render Target,只是让 Toon 对已有的输入和 CustomData 作出自己的解释。
2. 开放输入,并设置 Toon 的引脚名称
先打开 Engine/Source/Runtime/Engine/Private/Materials/Material.cpp,找到 IsPropertyActive_Internal()。这个函数根据 Shading Model、Blend Mode 等条件,决定某个属性是否可用。在下面两个 case 的 Shading Model 集合里追加 MSM_Toon:
cpp
case MP_SubsurfaceColor:
Active = ShadingModels.HasAnyShadingModel({
MSM_Subsurface, MSM_PreintegratedSkin,
MSM_TwoSidedFoliage, MSM_Cloth, MSM_Toon });
break;
case MP_CustomData0:
Active = ShadingModels.HasAnyShadingModel({
MSM_ClearCoat, MSM_Hair, MSM_Cloth,
MSM_Eye, MSM_SubsurfaceProfile, MSM_Toon });
break;
改完之后,使用 Toon 的 Material 就能正常连接这两个输入了。
接着打开同目录下的 MaterialAttributeDefinitionMap.cpp,找到 FMaterialAttributeDefinitionMap::GetAttributeOverrideForMaterial()。它负责按 Shading Model 覆盖引脚名称,比如 Cloth 会把 Subsurface Color 显示成 Fuzz Color。沿用同一套机制,在对应 case 里追加:
cpp
// MP_SubsurfaceColor:放在返回引脚名称之前。
CustomPinNames.Add({ MSM_Toon, "Toon Shadow Tint" });
// MP_CustomData0:放在返回引脚名称之前。
CustomPinNames.Add({ MSM_Toon, "Toon Threshold" });
改的只是显示名称,底层属性仍然是 MP_SubsurfaceColor 和 MP_CustomData0,原有的类型、连接和保存机制都不受影响。
3. 让 Material 编译器生成颜色数据
引脚能连了,不代表编译器就会为它生成 HLSL,还得补充后一半逻辑。
打开 HLSLMaterialTranslator.cpp 的 FHLSLMaterialTranslator::Translate()。Subsurface Color 原本只在 Volume,或者少数几个 Surface Shading Model 下才会编译,需要扩展判断条件为:
cpp
if (Domain == MD_Volume ||
(Domain == MD_Surface &&
(IsSubsurfaceShadingModel(MaterialShadingModels) ||
MaterialShadingModels.HasShadingModel(MSM_Toon))))
{
// 保留原有的颜色编译与 Subsurface 数据组装代码。
}
只改条件,里面的代码保持原样。负责取颜色的代码为:
cpp
int32 SubsurfaceColor = Material->CompilePropertyAndSetMaterialProperty(MP_SubsurfaceColor, this);
SubsurfaceColor = ForceCast(
SubsurfaceColor,
FMaterialAttributeDefinitionMap::GetValueType(MP_SubsurfaceColor),
MFCF_ExactMatch | MFCF_ReplicateValue);
CompilePropertyAndSetMaterialProperty() 沿着输入连接生成表达式,ForceCast() 把结果转成这个属性要求的类型。后面的代码还会把颜色和 Subsurface Profile 数据打包成一个四分量值;对 Toon 来说,下游只会用到 RGB,Profile 那一分量用不上。
MP_CustomData0 在后续代码里本来就有一份独立的编译入口,不需要为 Toon 再补一份:
cpp
Chunk[MP_CustomData0] = Material->CompilePropertyAndSetMaterialProperty(MP_CustomData0, this);
还需要核对 Phase 1 提到的新 HLSL Tree 路径。MaterialHLSLGenerator.cpp 的 CompileMaterialInput() 会检查 Material->IsPropertyActive(),而 MaterialHLSLEmitter.cpp 里已经有把颜色组装进 PixelMaterialInputs.Subsurface 的代码。属性开放之后,这条链路可以直接复用,既不用改 Emitter,也不用为 Toon 单独造一份 Subsurface Profile 的处理分支。
到这里,颜色和标量已经能进入 Material 的 Shader 计算结果,但还没写入 GBuffer 的 CustomData 上。
4. 为 Toon 启用 CustomData 输出
Phase 2 为 Toon 声明了标准 GBuffer Slot,但当时没有启用 CustomData。现在要往这块存储里写数据,需要把输出声明补上。
先打开 Engine/Source/Runtime/RenderCore/Private/ShaderMaterialDerivedHelpers.cpp 的 CalculateDerivedMaterialParameters(),把 Toon 加进 WRITES_CUSTOMDATA_TO_GBUFFER 的条件里:
cpp
Dst.WRITES_CUSTOMDATA_TO_GBUFFER = Dst.USES_GBUFFER && (
Mat.MATERIAL_SHADINGMODEL_SUBSURFACE ||
Mat.MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN ||
Mat.MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE ||
Mat.MATERIAL_SHADINGMODEL_CLEAR_COAT ||
Mat.MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE ||
Mat.MATERIAL_SHADINGMODEL_HAIR ||
Mat.MATERIAL_SHADINGMODEL_CLOTH ||
Mat.MATERIAL_SHADINGMODEL_EYE ||
Mat.MATERIAL_SHADINGMODEL_TOON);
BasePassCommon.ush 里有同名宏,也要一起加上:
hlsl
#define WRITES_CUSTOMDATA_TO_GBUFFER (USES_GBUFFER && ( \
MATERIAL_SHADINGMODEL_SUBSURFACE || \
MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN || \
MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE || \
MATERIAL_SHADINGMODEL_CLEAR_COAT || \
MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE || \
MATERIAL_SHADINGMODEL_HAIR || \
MATERIAL_SHADINGMODEL_CLOTH || \
MATERIAL_SHADINGMODEL_EYE || \
MATERIAL_SHADINGMODEL_TOON))
再到 ShaderGenerationUtil.cpp 的 DetermineUsedMaterialSlots(),给已有的 Toon 分支补一行:
cpp
if (Mat.MATERIAL_SHADINGMODEL_TOON)
{
SetStandardGBufferSlots(
Slots, bWriteEmissive, bHasTangent,
bHasVelocity, bHasStaticLighting, bIsStrataMaterial);
Slots[GBS_CustomData] = bUseCustomData;
}
bUseCustomData 是函数里已有的变量,在 Legacy 路径下为 true。输出生成器会根据当前布局去查 CustomData 落在哪张 Render Target,Toon 不需要写死任何 MRT 索引。
Slot 的使用情况变了,所以同一文件里的缓存版本要从 6 递增到 7:
cpp
static const int32 GBufferGeneratorVersion = 7;
到这里,编译准备阶段已经知道 Toon 需要 CustomData。剩下的就是 Base Pass 执行时把值算出来,并填进去。
5. Base Pass 读取参数并写入 CustomData
打开 Engine/Shaders/Private/BasePassPixelShader.usf,找到局部变量 SubsurfaceColor、SubsurfaceProfile 初始化的地方,在它们后面追加:
hlsl
#if MATERIAL_SHADINGMODEL_TOON && !STRATA_ENABLED
if (ShadingModel == SHADINGMODELID_TOON)
{
SubsurfaceColor = GetMaterialSubsurfaceData(PixelMaterialInputs).rgb;
}
#endif
外层的宏判断这份 Material Shader 里有没有 Toon,内层的 ID 判断当前表面是不是 Toon。GetMaterialSubsurfaceData() 取到的是上一小节生成的材质数据,RGB 已经过 saturate(),所以 Tint 的范围是 [0, 1]。
接下来,Base Pass 把 SubsurfaceColor 交给 ShadingModelsMaterial.ush 的 SetGBufferForShadingModel()。在这个函数已有的判断链里加一条 Toon 分支:
hlsl
#if MATERIAL_SHADINGMODEL_TOON
else if (ShadingModel == SHADINGMODELID_TOON)
{
GBuffer.CustomData.rgb = EncodeSubsurfaceColor(SubsurfaceColor);
GBuffer.CustomData.a = saturate(GetMaterialCustomData0(MaterialParameters));
}
#endif
这段代码接在 if (false) 引出的 else if 链里,本次放在 Cloth 分支前面。
EncodeSubsurfaceColor() 是现成的函数,实现在 DeferredShadingCommon.ush:
hlsl
float3 EncodeSubsurfaceColor(float3 SubsurfaceColor)
{
return sqrt(saturate(SubsurfaceColor));
}
CustomData 走的是有限精度的 UNORM 通道。存之前先开平方,可以把更多精度让给暗部颜色;读出来再平方,就还原成线性颜色。
所以 GBuffer 里的 RGB 不一定等于 Material 里填的 Tint。比如输入 (0.25, 0.09, 0.64),编码后接近 (0.5, 0.3, 0.8)。Threshold 没有这层编码,只做一次 saturate() 就写进 A。
结构体赋值完成后,原有的 GBuffer 编码和 MRT 输出会把 CustomData 写进纹理。前一步负责声明 Slot,这一步负责填值,两边缺一不可。
6. 让读取端保留 Toon CustomData
Deferred Lighting 读 GBuffer 时,会先按 Shading Model 判断要不要 CustomData。打开 DeferredShadingCommon.ush,在 HasCustomGBufferData() 的返回条件末尾加上 Toon:
hlsl
bool HasCustomGBufferData(int ShadingModelID)
{
return ShadingModelID == SHADINGMODELID_SUBSURFACE
|| ShadingModelID == SHADINGMODELID_PREINTEGRATED_SKIN
|| ShadingModelID == SHADINGMODELID_CLEAR_COAT
|| ShadingModelID == SHADINGMODELID_SUBSURFACE_PROFILE
|| ShadingModelID == SHADINGMODELID_TWOSIDED_FOLIAGE
|| ShadingModelID == SHADINGMODELID_HAIR
|| ShadingModelID == SHADINGMODELID_CLOTH
|| ShadingModelID == SHADINGMODELID_EYE
|| ShadingModelID == SHADINGMODELID_TOON;
}
原因就在同一个文件的读取代码里:
hlsl
GBuffer.CustomData = HasCustomGBufferData(GBuffer.ShadingModelID) ? InGBufferD : 0;
如果这里漏了 Toon,那么即便 RenderDoc 里能看到纹理已经写对,重新构造出来的 FGBufferData.CustomData 还是会被清零。
7. 在 ToonBxDF 中使用参数
最后改 ShadingModels.ush。这一步先用一个宏切换调试输出,默认显示解码后的 Tint:
hlsl
// 0: fixed magenta, 1: decoded Shadow Tint, 2: Threshold grayscale.
#ifndef TOON_DEBUG_OUTPUT
#define TOON_DEBUG_OUTPUT 1
#endif
FDirectLighting ToonBxDF(
FGBufferData GBuffer,
half3 N, half3 V, half3 L,
float Falloff, half NoL,
FAreaLight AreaLight, FShadowTerms Shadow)
{
FDirectLighting Lighting = (FDirectLighting)0;
#if TOON_DEBUG_OUTPUT == 0
const float3 DebugColor = float3(1.0f, 0.0f, 1.0f);
#elif TOON_DEBUG_OUTPUT == 1
const float3 DebugColor = ExtractSubsurfaceColor(GBuffer);
#elif TOON_DEBUG_OUTPUT == 2
const float3 DebugColor = GBuffer.CustomData.aaa;
#else
#error Unsupported TOON_DEBUG_OUTPUT value
#endif
Lighting.Diffuse = AreaLight.FalloffColor * Falloff * DebugColor;
return Lighting;
}
ExtractSubsurfaceColor() 的实现是:
hlsl
float3 ExtractSubsurfaceColor(FGBufferData BufferData)
{
return Square(BufferData.CustomData.rgb);
}
8. 结果
C++ 改完,重新构建 Editor Target。用 Toon 的 Material 应该能看到 Toon Shadow Tint 和 Toon Threshold 两个可连接的输入,分别接一个 Vector Parameter 和一个 Scalar Parameter。
先测 TOON_DEBUG_OUTPUT = 1,改一改 Tint,受光区域应该会跟着变色。

再把宏改成 2,依次把 Threshold 设成 0.2、0.5、0.8,受光区域会逐渐变亮。

如果用 RenderDoc,选择一个 Toon 表面的像素,按下面这组参数测试 GBufferD 的颜色编码:
text
Material 输入:
Tint = (0.25, 0.09, 0.64)
Threshold = 0.5
预期 GBufferD CustomData:
RGB ≈ (0.5, 0.3, 0.8)
A ≈ 0.5

phase 5:实现二分光照与环境暗部色调
上一阶段已经确认 Tint 和 Threshold 能从 Material 一路传到光照 Shader,现在可以把调试输出换成真正的光照计算。这一阶段做两件事:用 Threshold 切分直接光的明暗,再用 Tint 调节 SkyLight 漫反射,让没有直接光的地方也有一定的色调。
直接光有明确的光照方向,可以拿法线和它做比较;SkyLight 则是来自四面八方的环境光。所以 Threshold 在 ToonBxDF() 里用,Tint 则加在原有 SkyLight 计算之后。
本阶段只改 ShadingModels.ush 和 ReflectionEnvironmentPixelShader.usf,Material 输入、Shading Model ID 和 CustomData 布局等沿用上一阶段的逻辑。
1. 用 Threshold 控制直接光色阶
普通 Lambert 漫反射用的是 saturate(dot(N, L)),表面越朝向光源越亮,过渡比较平滑。二分 Toon 则是希望大部分区域都落在亮档或暗档上,只有很窄的一段负责过渡。
找到 Engine/Shaders/Private/ShadingModels.ush 的 ToonBxDF(),先得到 Threshold,再算法线和光照方向的点积:
hlsl
const float ToonThreshold = GBuffer.CustomData.a * 2.0f - 1.0f;
const float ToonSoftness = 0.02f;
const float RawNoL = dot(N, L);
上一阶段写入的 Threshold 值域为 [0, 1],而两个单位方向的点积为 [-1, 1],两者没法直接进行比较,所以先用 * 2 - 1 转化到同一区间:
| Material 中的 Threshold | 比较阈值 | 分界含义 |
|---|---|---|
| 0.2 | -0.6 | 允许部分背向光源的法线进入亮档 |
| 0.5 | 0 | 分界接近法线与光照方向垂直的位置 |
| 0.8 | 0.6 | 更朝向光源的区域才能进入亮档 |
这里没有直接用函数参数 NoL,而是重新算了一遍 dot(N, L)。上层给不同光源准备的 NoL 可能已经做过修正;而 Threshold 要覆盖负值区间,必须拿到原始的带符号点积。
接着用 smoothstep() 把它变成色阶权重:
hlsl
const float ToonBand = smoothstep(
ToonThreshold - ToonSoftness,
ToonThreshold + ToonSoftness,
RawNoL);
RawNoL 低于下边界得 0,高于上边界得 1,中间平滑过渡。这样大部分表面都落在两档里,只有阈值附近那一小段是渐变的。
最后把色阶权重乘上材质漫反射颜色:
hlsl
const float3 DiffuseResponse = ToonBand * Diffuse_Lambert(GBuffer.DiffuseColor);
Lighting.Diffuse = AreaLight.FalloffColor * Falloff * DiffuseResponse;
Diffuse_Lambert() 在 BRDF.ush 里,实现很直接:
hlsl
half3 Diffuse_Lambert(half3 DiffuseColor)
{
return DiffuseColor * (1 / PI);
}
2. ToonBxDF
三个调试模式照旧保留,再加上模式 3,并把它设为默认。同时把 DebugColor 改名为 DiffuseResponse。合并后的完整实现如下:
hlsl
// 0: fixed magenta, 1: decoded Shadow Tint, 2: Threshold grayscale, 3: Toon diffuse.
#ifndef TOON_DEBUG_OUTPUT
#define TOON_DEBUG_OUTPUT 3
#endif
FDirectLighting ToonBxDF(
FGBufferData GBuffer,
half3 N, half3 V, half3 L,
float Falloff, half NoL,
FAreaLight AreaLight, FShadowTerms Shadow)
{
FDirectLighting Lighting = (FDirectLighting)0;
#if TOON_DEBUG_OUTPUT == 0
const float3 DiffuseResponse = float3(1.0f, 0.0f, 1.0f);
#elif TOON_DEBUG_OUTPUT == 1
const float3 DiffuseResponse = ExtractSubsurfaceColor(GBuffer);
#elif TOON_DEBUG_OUTPUT == 2
const float3 DiffuseResponse = GBuffer.CustomData.aaa;
#elif TOON_DEBUG_OUTPUT == 3
const float ToonThreshold = GBuffer.CustomData.a * 2.0f - 1.0f;
const float ToonSoftness = 0.02f;
const float RawNoL = dot(N, L);
const float ToonBand = smoothstep(
ToonThreshold - ToonSoftness,
ToonThreshold + ToonSoftness,
RawNoL);
const float3 DiffuseResponse = ToonBand * Diffuse_Lambert(GBuffer.DiffuseColor);
#else
#error Unsupported TOON_DEBUG_OUTPUT value
#endif
Lighting.Diffuse = AreaLight.FalloffColor * Falloff * DiffuseResponse;
return Lighting;
}
3. 用 Tint 调节 SkyLight 漫反射
目前暗档的贡献为零,如果没有其他光照,背光侧便接近纯黑。为了让暗部保留一点颜色,接下来使用 Material 的 Toon Shadow Tint 调节环境光。
打开 Engine/Shaders/Private/ReflectionEnvironmentPixelShader.usf,找到 ReflectionEnvironmentSkyLighting() 里走 Legacy Material 的那条路径。它先从 Scene Textures 读出 GBuffer,准备好 AO 和 Bent Normal,然后在 ENABLE_DYNAMIC_SKY_LIGHT 分支里算:
hlsl
float3 SkyLighting = CloudVolumetricAOShadow * SkyLightDiffuse(
GBuffer, AmbientOcclusion, BufferUV,
ScreenPosition, BentNormal, DiffuseColor);
SkyLightDiffuse() 已经把环境光里的漫反射部分算出来了,其中包含 SkyLight 颜色、表面漫反射颜色和原有的 AO 处理。这部分逻辑不需要更改,只是在这行之后、FLightAccumulator 累加之前插一段 Toon 分支:
hlsl
if (ShadingModelID == SHADINGMODELID_TOON)
{
const float3 ToonShadowTint = ExtractSubsurfaceColor(GBuffer);
SkyLighting *= ToonShadowTint;
}
4. 结果
进行上述修改之后,当前渲染效果可以近似理解为:
text
直接光部分:
ToonBand × 漫反射颜色 × 光源权重 × 灯光颜色 × 阴影可见性
SkyLight 漫反射部分:
原有环境漫反射结果 × Toon Shadow Tint
有个现象需要注意:Threshold 小于 0.5 时,虽然公式上允许一部分背光面进入亮档,但这些位置可能被自阴影压暗。
Toon Material 的节点连接如下图:

场景里放一盏 Movable 的 Directional Light 和 Sky Light,再摆一个 Default Lit 的 Sphere 作对照。可以看到亮部整体上移,明暗交界比较生硬,暗部颜色偏绿:

总结
至此已经实现了自定义一个 Toon Shading Model,写了一个简单的二分光照,后续会对光照算法进行完善,并真实渲染一个卡通人物模型。