源码版本UE5.8
一、MaterialExpressionSubstrate.h(第1657行)
UCLASS(MinimalAPI, collapsecategories, hidecategories = Object, DisplayName = "Substrate Thin-Film") class UMaterialExpressionSubstrateThinFilm : public UMaterialExpressionSubstrateUtilityBase { GENERATED_UCLASS_BODY() /** * The normal of the surface to consider. This input respects the normal space setup on the root node (tangent or world) */ UPROPERTY() FExpressionInput Normal; /** * Defines the color and brightness of the specular highlight where the surface is facing the camera. Each specular contribution will fade to black as F0 drops below 0.02. (type = float3, unit = unitless, defaults to plastic 0.04) */ UPROPERTY() FExpressionInput F0; /** * Defines the color of the specular highlight where the surface normal is 90 degrees from the view direction. Only the hue and saturation are preserved, the brightness is fixed at 1.0. Fades to black as F0 drops below 0.02. (type = float3, unit = unitless, defaults to 1.0f) */ UPROPERTY() FExpressionInput F90; /** * Controls the thickness of the thin film layer coating the current slab. 0 means disabled and 1 means a coating layer of 10 micrometer. (type = float, unitless, default = 0) */ UPROPERTY() FExpressionInput Thickness; /** * Thin film IOR */ UPROPERTY() FExpressionInput IOR; //~ Begin UMaterialExpression Interface #if WITH_EDITOR virtual int32 Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) override; virtual void Build(MIR::FEmitter& Emitter) override; virtual void GetCaption(TArray<FString>& OutCaptions) const override; virtual EMaterialValueType GetOutputValueType(int32 OutputIndex) override; virtual EMaterialValueType GetInputValueType(int32 InputIndex) override; virtual void GetConnectorToolTip(int32 InputIndex, int32 OutputIndex, TArray<FString>& OutToolTip) override; virtual void GetExpressionToolTip(TArray<FString>& OutToolTip) override; #endif //~ End UMaterialExpression Interface };声明变量以及编译入口,这里的Compile是传统的编译路线
Build是MIR的新式编译路线
二、MaterialExpressionSubstrate.cpp(第4376行)
UMaterialExpressionSubstrateThinFilm::UMaterialExpressionSubstrateThinFilm(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { struct FConstructorStatics { FText NAME_Substrate; FConstructorStatics() : NAME_Substrate(LOCTEXT("Substrate Helpers", "Substrate Helpers")) { } }; static FConstructorStatics ConstructorStatics; #if WITH_EDITORONLY_DATA MenuCategories.Add(ConstructorStatics.NAME_Substrate); #endif bShowOutputNameOnPin = true; Outputs.Reset(); Outputs.Add(FExpressionOutput(TEXT("Specular Color"))); Outputs.Add(FExpressionOutput(TEXT("Edge Specular Color"))); }构造函数定义了两个输出:
Outputs.Add(FExpressionOutput(TEXT("Specular Color"))); Outputs.Add(FExpressionOutput(TEXT("Edge Specular Color")));对应:
输出 0 = 新的 F0 输出 1 = 新的 F90Thin Film 节点没有输出一个 Substrate BSDF,也没有输出一个 Closure。它只输出两个float3,也就是经过薄膜干涉修正的 F0 和 F90
三、MaterialExpressionSubstrate.cpp(第4396行)
#if WITH_EDITOR int32 UMaterialExpressionSubstrateThinFilm::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) { int32 NormalCodeChunk = CompileWithDefaultNormalWS(Compiler, Normal); int32 F0CodeChunk = F0.GetTracedInput().Expression ? F0.Compile(Compiler) : Compiler->Constant3(0.04f, 0.04f, 0.04f); int32 F90CodeChunk = F90.GetTracedInput().Expression ? F90.Compile(Compiler) : Compiler->Constant3(1.0f, 1.0f, 1.0f); int32 ThicknessCodeChunk = Thickness.GetTracedInput().Expression ? Thickness.Compile(Compiler) : Compiler->Constant(1.0f); int32 IORCodeChunk = IOR.GetTracedInput().Expression ? IOR.Compile(Compiler) : Compiler->Constant(1.44f); }这里给Substrate Thin Film输入给到指定默认值
四、传统编译和MIR编译 HLSLMaterialTranslator.cpp(第14027行) MaterialExpressionsToMIR.cpp(第7952行)
int32 FHLSLMaterialTranslator::SubstrateThinFilm(int32 NormalCodeChunk, int32 SpecularColorCodeChunk, int32 EdgeSpecularColorCodeChunk, int32 ThicknessCodeChunk, int32 IORCodeChunk, int32 OutputIndex) { if (NormalCodeChunk == INDEX_NONE || SpecularColorCodeChunk == INDEX_NONE || EdgeSpecularColorCodeChunk == INDEX_NONE || ThicknessCodeChunk == INDEX_NONE || IORCodeChunk == INDEX_NONE) { Errorf(TEXT("Substrate error: one of SubstrateThinFilm node input is invalid (asset: %s).\r\n"), *Material->GetDebugName(), *Material->GetAssetPath().ToString()); return INDEX_NONE; } if (OutputIndex < 0 || OutputIndex > 1) { Errorf(TEXT("Substrate error: SubstrateThinFilm output index is invalid (asset: %s).\r\n"), *Material->GetDebugName(), *Material->GetAssetPath().ToString()); return INDEX_NONE; } const int32 CameraVectorCode = CameraVector(); if (CameraVectorCode == INDEX_NONE) { return INDEX_NONE; } return AddCodeChunk( MCT_Float3, TEXT("SubstrateGetThinFilmF0F90(%s, %s, %s, %s, %s)%s"), *GetParameterCode(Dot(NormalCodeChunk, CameraVectorCode)), *GetParameterCode(SpecularColorCodeChunk), *GetParameterCode(EdgeSpecularColorCodeChunk), *GetParameterCode(ThicknessCodeChunk), *GetParameterCode(IORCodeChunk), OutputIndex == 0 ? TEXT(".F0") : TEXT(".F90") ); }void UMaterialExpressionSubstrateThinFilm::Build(MIR::FEmitter& Em) { UMaterial* DummyMaterial = nullptr; bool bConvertToRequestedSpace = false; FValueRef NormalArg = CompileWithDefaultNormalWS(Em, DummyMaterial, Normal, bConvertToRequestedSpace); FValueRef F0Arg = F0.GetTracedInput().Expression ? Em.TryInput(&F0) : Em.ConstantFloat(0.04f); FValueRef F90Arg = F90.GetTracedInput().Expression ? Em.TryInput(&F90) : Em.ConstantFloat(1.0f); FValueRef ThicknessArg = Thickness.GetTracedInput().Expression ? Em.TryInput(&Thickness) : Em.ConstantFloat(1.0f); FValueRef IORArg = IOR.GetTracedInput().Expression ? Em.TryInput(&IOR) : Em.ConstantFloat(1.44f); FValueRef CameraVector = Em.Extern<MIR::FExternFromMaterialDecl>(NAME_CameraVector); MIR::FValueRef OutF0Arg = Em.Extern<MIR::FExternSimpleHLSL>({ MIR::EExternSimpleType::Float3, TEXT("SubstrateGetThinFilmF0F90($0, $1, $2, $3, $4).F0") }, Em.Dot(NormalArg, CameraVector), F0Arg, F90Arg, ThicknessArg, IORArg); // Calling SubstrateGetThinFilmF0F90 a second time. SUBSTRATE_TODO: create a structure to pull the data from the first call instead MIR::FValueRef OutF90Arg = Em.Extern<MIR::FExternSimpleHLSL>({ MIR::EExternSimpleType::Float3, TEXT("SubstrateGetThinFilmF0F90($0, $1, $2, $3, $4).F90") }, Em.Dot(NormalArg, CameraVector), F0Arg, F90Arg, ThicknessArg, IORArg); Em.Output(0, OutF0Arg); Em.Output(1, OutF90Arg); }传统编译路线和 MIR 的主要区别不是 Thin Film 算法不同,而是:
它们用两种不同的内部表示,把材质节点图转换成 HLSL。
最终通常都会生成:
SubstrateGetThinFilmF0F90(...)传统路线可以理解为:
递归编译上游节点 ↓ 每个表达式得到一个 CodeChunk 编号 ↓ 从 CodeChunk 取出对应 HLSL 文本 ↓ 使用格式字符串拼成更大的 HLSL 表达式MIR路线:
Epic 增加 MIR 的根本原因,是旧材质编译器过早地把材质语义变成了 HLSL CodeChunk,导致后面很难再从整体上理解和优化材质图。
MIR 不是为了让同一个材质再编译一次,也不是为了给 Thin Film 增加一种新算法。它的目标是逐步替代旧材质编译器内部架构:
旧架构: 材质节点 → CodeChunk/HLSL 字符串 → DXC 新架构: 材质节点 → 有类型的 MIR 运算图 → 分析与优化 → HLSL/Preshader → DXC核心问题可以概括成一句话:
旧编译器太早丢失了“这段代码在表达什么”,只剩下“这段 HLSL 字符串长什么样”。
旧路线的核心问题
还是以 Thin Film 为例:
return AddCodeChunk( MCT_Float3, TEXT("SubstrateGetThinFilmF0F90(%s, %s, %s, %s, %s)%s"), ... );一旦形成这种表达式:
SubstrateGetThinFilmF0F90(Local12, Local5, Local6, Local8, Local9).F0编译器后续看到的主要是一段 HLSL 表达式和 CodeChunk 引用。
它很难从通用的编译器角度判断:
- 这五个输入分别是什么语义;
- 这是一个有两个输出的计算;
- F0 与 F90 来自同一次 Thin Film 求值;
- 哪些输入是常量;
- 哪些输入是 Uniform;
- 计算应该发生在 Vertex、Pixel 还是 Compute Stage;
- 哪些分支最终没有连接到任何材质输出;
- 两段表达式是否代表同一个计算;
- 哪些运算可以转移到 CPU Preshader;
- 如果未来不输出 HLSL,还能不能输出其他后端。
旧编译器也做了很多缓存和优化,不能说它完全无法处理这些问题。但这些能力往往分散在 Translator、CodeChunk、Uniform Expression、Preshader 和各节点的特殊逻辑里,继续扩展和维护会越来越困难。
MIR 解决的第一个问题:保留完整运算关系
MIR 不立即把表达式压成字符串,而是保存为运算图:
Normal ─────────┐ ├─ Dot ─ NoV ─────────┐ CameraVector ───┘ │ F0 ────────────────────────────────────┤ F90 ───────────────────────────────────┤ ThinFilm Thickness ─────────────────────────────┤ IOR ───────────────────────────────────┘五、看Substrate 如何调用薄膜核心(Substrate.ush第3579行)
void SubstrateGetThinFilmF0F90(float NoV, float ThinFilmNormalizedThickness, float ThinFilmIOR, inout float3 OutF0, inout float3 OutF90) { const float3 F0 = OutF0; const float3 F90 = OutF90; const float NoL = NoV; const float VoH = NoV; float3 RThinFilm = F_ThinFilm(NoV, NoL, VoH, F0, F90, ThinFilmIOR, ThinFilmNormalizedThickness); // Compute a F0 which match evaluation Thin-Film reflectance for the current view angle using Schlick's Fresnel const float FReference = min(0.99f, F_Schlick(0, 1.0f, NoV).x); RThinFilm = max(RThinFilm, FReference); float3 F0_Thin = (RThinFilm - F90 * FReference) / (1 - FReference); // Ensure the generated F0 doesn't cause micro-oclusion F0_Thin = max(GetF0MicroOcclusionThreshold(), F0_Thin); // If micro-occlusion was set, re-apply it. const float3 MicroOcclusion = F0RGBToMicroOcclusion(F0); F0_Thin *= MicroOcclusion; OutF0 = F0_Thin; OutF90 = max(F0_Thin, F90); }UE 假设正在计算镜面高光中心,也就是理想反射方向。在这个位置,三个值恰好相等:
NoV = NoL = VoH我前面把它说成单纯的“统一角度控制量”不够准确。它有明确的几何依据。
看这张图
N ↑ │ H = N │ │ L ↖ │ ↗ V \ │ / \ θ │ θ / \ │ / \ │ / ───────────────●─────┴──────────────── 表面这里:
V是表面指向相机的方向。L是表面指向灯光的方向。L和V关于法线N对称。- 这正是理想镜面反射、高光中心的几何关系。
- 此时半角向量
H正好等于表面法线N。
为什么 Thin Film 要选择“高光中心”计算
薄膜干涉主要表现为镜面反射颜色,例如:
- 肥皂泡彩色反光
- 油膜彩虹色
- 镀膜玻璃
- 氧化金属表面
- 珍珠、甲虫壳一类视角色彩
这些效果最明显的区域通常就是镜面反射区域。因此 UE 选择一个最有代表性的情况:
假设灯光正好位于当前观察方向的理想反射位置(ThinFilmBSDF.ush第189行)
float3 F_ThinFilm(float NoV, float NoL, float VoH, float3 F0, float3 F90, float ThinFilmIOR, float ThinFilmTickness) { // Thin film is limited (for now) to the top most layer (i.e., the upper layer need to be air) // | // n1=1 / k1=0 | Air // ----------------- // n2 / k2=0 | Thin film // ----------------- // n3 / k3 | Regular material // | float Dinc = ThinFilmTickness * 10; // ThinFilmTickness is in micrometer. Range is 0 - 10um i.e, 0 - 10 000nm // Force eta2 -> 1.0 when Dinc -> 0.0 const float eta2 = lerp(1.0, ThinFilmIOR, smoothstep(0.0, 0.03, Dinc)); // Compute dot products const bool bIsValid = NoL > 0 && NoV > 0; float3 I = 0; if (bIsValid) { float cosTheta1 = VoH; float cosTheta2 = sqrt(1.0 - Pow2(1.0 / eta2) * (1 - Pow2(cosTheta1))); // First interface is dieletric so it is achromatic float R12 = F_Schlick(DielectricIorToF0(ThinFilmIOR), VoH).x; float phi12 = 0; float R21 = R12; float T121 = 1 - R12; float phi21 = PI - phi12; // Second interface is either conductor or dieletric, so can be chromatic. // Ideally we should recompute F0 (R23) by taking into account the thin layer IOR float3 R23 = F0; float phi23 = 0; // Phase shift float OPD = Dinc * cosTheta2; float phi2 = phi21 + phi23; // Compound terms float3 R123 = R12 * R23; float3 r123 = sqrt(R123); float3 Rs = Pow2(T121) * R23 / (1 - R123); // Reflectance term for m=0 (DC term amplitude) float3 C0 = R12 + Rs; float3 S0 = ThinFilmEvalSensitivity(0.0, 0.0); I += C0 * S0; // Reflectance term for m>0 (pairs of diracs) // Simplified based as we don't take into account polarization float3 Cm = Rs - T121; for (int m = 1; m <= 3; ++m) { Cm *= r123; I += Cm * 2.0 * ThinFilmEvalSensitivity(m * OPD, m * phi2); } // Convert back to RGB reflectance // XYZ to CIE 1931 RGB color space (using neutral E illuminant) const float3x3 ThinFilm_XYZ_TO_RGB = float3x3(2.3706743, -0.5138850, 0.0052982, -0.9000405, 1.4253036, -0.0146949, -0.4706338, 0.0885814, 1.0093968); I = saturate(mul(I, ThinFilm_XYZ_TO_RGB)); } return I; }把归一化厚度转换为微米
float Dinc = ThinFilmTickness * 10;例如:
Thickness = 0.05 Dinc = 0.5 μm = 500 nm500 nm 已经和可见光波长处于相同数量级,所以能够产生明显的彩色干涉。
厚度接近零时,让薄膜逐渐消失
const float eta2 = lerp( 1.0, ThinFilmIOR, smoothstep(0.0, 0.03, Dinc));先了解一个前提知识:Snell:
已知这个信息,那么久可以看懂cosTheta2是如何计算出来的了:
那么R12理解成空气/薄膜第一个界面基于F0的菲涅尔遮罩
float R21 = R12; float T121 = 1 - R12;介质1:空气 ↑ R21:光从薄膜内部撞向上界面 ────────────── 界面 1/2 ↓ R12:光从空气撞向薄膜 介质2:薄膜 ────────────── 界面 2/3 ↓ R23:光从薄膜撞向基底 介质3:基底T121画出来是:
R12 = 4% ↗ 入射光 100% ─────●──────── 空气 \ ───────────────────\────── 空气/薄膜界面 ↓ T121 = 96% ↓ 薄膜其实就是透射薄膜里面的能量百分比
R23 = F0是什么意思
F_ThinFilm()接收的F0是基底材质原本的正视反射颜色:
float3 F_ThinFilm( ..., float3 F0, float3 F90, ... );phi23 = 0是什么意思
float phi23 = 0;phi23表示:
光从薄膜撞到基底并发生反射时,附加了多少反射相位。
介质2:薄膜 ↓ 入射 ───────●─────── 界面23 ↗ 反射 R23 phi23 介质3:基底真实情况下:
- 介电质基底可能产生
0或π相位变化。 - 金属基底可能产生更复杂、与波长有关的相位。
- S/P 偏振的相位也可能不同。
但是简化实现直接设置:
phi23 = 0;也就是:
忽略基底界面复杂的 Fresnel 相位,把它当作没有额外相位偏移。
完整比较到图中远处的C₁截面时:
A:O → P → S₀ → S₁ B:O → P → Q → R → R₁因此,保留PS₀的原因不是它代表 A 的完整路径,而是:
PS₀是 A 路径中与 B 不同、无法被共同部分抵消的那一段。
可以把完整路径拆成:
共同入射段 + 各自不同段 + 共同出射增量做差后:
共同入射段:抵消 共同出射增量:抵消 只剩各自不同段所以推导里最终只写:
A 独有:P→S₀ B 独有:P→Q→R并不代表 A 只传播到S₀,而是因为其他部分在B−A中已经全部抵消了。
OPD = Dinc * cosTheta2本身没有问题;UE 的特殊之处在于,它把节点的 Thickness 缩放后直接当作 Dinc,没有严格计算Dinc = 2η₂d。
float phi2 = phi21 + phi23;
这行是在计算:光在薄膜内部每多完成一次往返时,两个界面反射共同造成的相位跳变。
这里的phi不是几何角度,而是光波波峰、波谷的相位。
上方介质 n₁ ──────────────────────── 上界面 ↑ │ 光从薄膜内部撞击上界面 │ 反射相位变化:phi21 ↓ 薄膜 n₂ ↓ │ 光撞击下界面 │ 反射相位变化:phi23 ↑ ──────────────────────── 下界面 基底 n₃光在薄膜中多走一个完整往返,会经历:
- 上界面内部反射一次:增加
phi21 - 下界面反射一次:增加
phi23
相位是可以直接相加的,所以:
float phi2 = phi21 + phi23;它表示:
float3 C0 = R12 + Rs; float3 S0 = ThinFilmEvalSensitivity(0.0, 0.0); I += C0 * S0;后面的代码:
float3 Cm = Rs - T121; for (int m = 1; m <= 3; ++m) { Cm *= r123; I += Cm * 2.0 * ThinFilmEvalSensitivity( m * OPD, m * phi2 ); }就是在加入第 1、2、3 阶的相位干涉项。
因此可以记成:
C0:所有光线自身的能量,不包含互相干涉 S0:零频率下的眼睛颜色响应 C0 × S0:薄膜反射的基础亮度/基础颜色 m > 0:在基础亮度上增加彩色干涉变化I在这个位置还是 XYZ 感光空间中的累积结果,后面经过:
I = saturate(mul(I, ThinFilm_XYZ_TO_RGB));才转换成最终使用的 RGB 薄膜反射颜色。
float3 Cm = Rs - T121; for (int m = 1; m <= 3; ++m) { Cm *= r123; I += Cm * 2.0 * ThinFilmEvalSensitivity(m * OPD, m * phi2); }void SubstrateGetThinFilmF0F90(float NoV, float ThinFilmNormalizedThickness, float ThinFilmIOR, inout float3 OutF0, inout float3 OutF90) { const float3 F0 = OutF0; const float3 F90 = OutF90; const float NoL = NoV; const float VoH = NoV; float3 RThinFilm = F_ThinFilm(NoV, NoL, VoH, F0, F90, ThinFilmIOR, ThinFilmNormalizedThickness); // Compute a F0 which match evaluation Thin-Film reflectance for the current view angle using Schlick's Fresnel const float FReference = min(0.99f, F_Schlick(0, 1.0f, NoV).x); RThinFilm = max(RThinFilm, FReference); float3 F0_Thin = (RThinFilm - F90 * FReference) / (1 - FReference); // Ensure the generated F0 doesn't cause micro-oclusion F0_Thin = max(GetF0MicroOcclusionThreshold(), F0_Thin); // If micro-occlusion was set, re-apply it. const float3 MicroOcclusion = F0RGBToMicroOcclusion(F0); F0_Thin *= MicroOcclusion; OutF0 = F0_Thin; OutF90 = max(F0_Thin, F90); }看了那么多,回到这里F_ThinFilm函数进行了薄膜干涉的颜色信息的计算
完整流程是:
原始 F0/F90 ↓ F_ThinFilm(F0, F90, IOR, Thickness) ↓ RThinFilm ↓ 反解 Schlick ↓ 新的等效 F0_Thin ↓ OutF0 = F0_Thin六、Substrate 的 Thin Film 包装函数(Substrate.ush第3610行)
FSubstrateThinFilmOutput SubstrateGetThinFilmF0F90(float NoV, float3 F0, float3 F90, float ThinFilmNormalizedThickness, float ThinFilmIOR) { FSubstrateThinFilmOutput Output; Output.F0 = F0; Output.F90 = F90; SubstrateGetThinFilmF0F90(NoV, ThinFilmNormalizedThickness, ThinFilmIOR, Output.F0, Output.F90); return Output; }所以这里最后也看得懂了,上层已经讲清楚了