"普通 Unlit 材质保持 0"
这个结论还需要再看一层,不能从 GBufferD 存在直接推出。
你要查:
SHADINGMODELID_UNLIT
以及 GBuffer 初始化/编码路径,确认究竟是哪一种:
Unlit shader└─ 显式写 GBufferD.x = 0
还是:
Unlit└─ 根本不写这个 attachment └─ 依赖 GBuffer clear value = 0
还是:
某些 permutation└─ MRT mask 根本不包含 D
这三个在最后 Texture Viewer 里可能都"看起来是黑的",但机制完全不同。
所以"Unlit 保持 0"属于需要 shader permutation + render target load/clear 行为共同证明的结论。
至于:
"这样头发只画一次"
这个反而很好验证。
看:
BasePassPixelShader└─ 原来的 Hair Mesh Draw └─ Pixel Shader 同一次 invocation ├─ 写原来的 GBuffer └─ 顺便写你的数据
如果实现最终确实长这样,那:
Draw Hair└─ Base Pass ├─ BaseColor ├─ Normal ├─ Roughness └─ YourMask
就是一次 rasterization / 一份 mesh draw。
而不是:
Base Pass└─ Draw HairCustom Mask Pass└─ Draw Hair 第二次
最直接根本不用猜源码,用 RenderDoc 看:
BasePass└─ 找到这个 Hair draw call └─ Output Merger ├─ RT0 ├─ RT1 ├─ RT2 ├─ RT3 └─ RT4 / GBufferD
然后确认:
Packed R 改变└─ 发生在原 Hair BasePass draw
而 Frame Event List 里没有第二次 Hair mask draw。
这就把"只画一次"实锤了。
官方 UE 5.8 API 本身也把 GBuffer A--F 作为 Scene Texture 集合暴露,因此"复用现有 GBuffer"与"新建另一张 RDG Texture"在资源层面本来就是两件不同的事。dev.epicgames.com/documentation/unreal-engine/API/Runtime/Renderer/ESceneTextureSetupMode

Material IR,这里的 IR 就是 Intermediate Representation,中间表示。
它的位置不是"材质"和"最终画面"之间某个 GPU buffer,而是在材质编译器内部:
Material Graph
├ Texture Sample
├ Multiply
├ Substrate BSDF
├ Mix / Layer
└ Material Output
↓ 解析
Material IR
└ 把上面的节点图
转成编译器更容易分析的一套内部表示
↓ translation
HLSL / Preshader opcodes
↓
Shader compiler
↓
DXIL / SPIR-V / machine code
UE 5.8 官方的 FMaterialIRModule 定义得很明确:它代表一次 material build 的 intermediate representation,内部包含一个由 expression analysis 得到的 IR value graph ,以及 resource usage、reflection 等 metadata;之后还必须翻译到 HLSL 或 Preshader opcode 这样的 target backend 才能执行。dev.epicgames.com/documentation/unreal-engine/API/Runtime/Engine/FMaterialIRModule

为什么不直接:
Material Node
→ HLSL
因为中间插一个 IR 后,可以集中做很多事情:
Material Graph
↓
Material IR
├ 类型/值分析
├ expression dependency
├ resource usage
├ reflection
├ 常量传播/简化的空间
├ 判断哪些东西进入 shader
├ 判断哪些东西能成为 preshader
└ 针对不同 backend 再翻译
这就是编译器普遍使用 IR 的原因:前端只负责理解原始语言,后端只负责生成目标代码,中间统一成一种 representation。
在 Substrate 体系里尤其容易碰到它。Substrate 本身首先是一棵 material/BSDF 结构:
Substrate Material
└ Operator
├ Slab / BSDF
└ Operator
├ Slab / BSDF
└ Slab / BSDF
Epic 5.8 的 API 也直接描述 Substrate material 是一棵 tree:FrontMaterial 是 root,BSDF 是 leaves,operators 在中间;这些 expression 同样参与 Material IR 的构建。dev.epicgames.com/documentation/unreal-engine/API/Runtime/Engine/UMaterialExpressionSubstrateEyeB-

它为什么叫 Relative Strength,可以从公式理解。经典 RSI 默认通常看最近 14 个周期:
RS = Average Gain / Average Loss
RSI = 100 - 100 / (1 + RS)
比如最近上涨力量明显大于下跌力量:
平均上涨 = 3
平均下跌 = 1
RS = 3 / 1 = 3
RSI = 100 - 100/(1+3)
= 75
所以 RSI 真正压缩的是:
最近这段时间里,上涨幅度相对于下跌幅度有多强。
这也是为什么只记"相对强弱指数"很容易忘------中文名没告诉具体在 relative 什么。可以直接记成:
RSI = Recent Up Strength ÷ Recent Down Strength,再压成 0--100。
至于 Binance Futures:有 RSI。 Binance Futures 的 K 线图支持技术指标,RSI 是其中常见指标;Binance 自己也有 Futures 场景下的 RSI 内容。www.binance.com/en/square/post/31445029732921

加进去以后,它一般出现在 K 线下方的独立 panel,而不是画在 candle 上。
RSI 是由所选 K 线周期计算的。
最基础的关系是:
Expression
└ 描述"计算出什么值 / 发生什么计算"的结构
Function
└ 一段被定义好的可调用计算
└ 调用它时,会形成一个 function-call expression
C/C++ 对 expression 的定义很直接:由 operands 和 operators 构成,指定一个 computation;求值可能产生一个值,也可能产生副作用。en.cppreference.com/cpp/language/expressions
例如:
C++
a + b
这是 expression。
C++
a * b + c
也是 expression。
C++
sin(x)
还是 expression,而且更具体地说,这是一个 function-call expression 。C++ 标准语境本身就使用这个术语。en.cppreference.com/cpp/language/operator_other
plumbing 这个词为什么会被这么用,也很好理解。它原本就是:
water source
↓
pipe
↓
valve
↓
destination
软件里借来以后变成:
data producer
↓
API / structs / bindings
↓
renderer
↓
shader
重点不是水本身,而是:
这些东西有没有被正确接通。


Foo() 明确告诉读者:
名字没有语义,不要浪费时间理解这个名字。
这就是为什么程序员很爱用它。它其实解决了一个很实际的问题:如果示例写成
C++
Car car;
Engine engine;
读者很可能开始思考:
为什么是 Car?
为什么有 Engine?
这里是不是在讲 composition?
但:
C++
Foo foo;
Bar bar;
几乎就是在说:
这些名字都是空气,只看结构。
历史倒很奇怪,foo 并不是计算机行业凭空发明的。
目前最完整的经典整理是 RFC 3092《Etymology of "Foo"》。它追到 1930 年代美国漫画 Smokey Stover。漫画作者 Bill Holman 会到处塞毫无意义的 FOO,例如车牌、背景笑话、文字游戏;1930 年代末甚至形成过一阵 "Foo" 流行文化。1938 年 Warner Bros. 的《The Daffy Doc》里也出现过 SILENCE IS FOO!。www.rfc-editor.org/info/rfc3092
大致传播链可以看成:
1930s 美国漫画
└ FOO = 故意荒唐、没有明确意义的词
↓
美国流行文化
↓
二战时期美国军队
├ foo fighters
└ FUBAR 等相近用法
↓
战后工程师 / hacker culture
↓
早期计算机文档
↓
foo / bar
成为程序示例占位符
RFC 是一套文档系列的名字,历史全称是 Request for Comments 。现在互联网里的很多协议规范、技术说明、历史记录都会以 RFC 的形式正式归档。IETF 官方就把 RFC 称为自己的主要技术文档形式。www.ietf.org/process/rfcs
组织关系大概是:
Internet technical community
├ IETF
│ └ 制定大量互联网协议和标准
│
├ IRTF
│ └ 偏长期研究
│
├ IAB
│ └ Internet 架构与监督
│
└ Independent submissions
↓
RFC Series
↓
RFC Editor 负责正式出版和归档
RFC Editor 是这套文档系列的正式出版/编辑机构,而不是"制定所有 RFC 的组织"。RFC Editor 官方说明,RFC 可以来自 IETF、IRTF、IAB 和 Independent Submission Editor 等不同 stream。www.rfc-editor.org
最容易混淆的是:
IETF ≈ 标准制定社区/组织
RFC ≈ 最终发布出来的技术文档
RFC Editor ≈ 管理、编辑、出版 RFC Series
例如:
HTTP/1.1
TLS 1.3
QUIC
IPv6
这些互联网技术都有对应 RFC。IETF 明确列出 TLS 1.3、QUIC、WebRTC 等协议都是通过 RFC 规范的。www.ietf.org/process/rfcs
但还有一个重要点:
RFC ≠ 一定是互联网标准。
最早 1969 年 ARPANET 刚发展时,这些文档真的比较像:
"这里有个网络设计想法,
大家看看,
有什么意见?"
所以叫:
Request for Comments
请求评论 / 征求意见
后来整个体系越来越正式,但名字没换。
还有一个非常重要的制度特点:RFC 发布后是不可修改的 archival document。 IETF 明确说 RFC 一旦发布,连一个字符都不会直接修改;如果规范后来改变,通常发布新的 RFC 来更新或取代旧 RFC。www.ietf.org/blog/how-read-rfc
所以经常看到:
RFC 1234
↓
Updated by RFC 5678
↓
Obsoleted by RFC 9012
而不是打开 RFC 1234 直接编辑掉。
词汇借用(lexical borrowing)
社会语言学会区分这种稳定借词和真正的 code-switching。
也就是说 Codex 的实现确实是在:
UE 正常完成 GBuffer encoding 以后,强行覆盖 D.x / D.y,然后用同一个 Base Pass MRT 写出去。
因此它没有创建第二张 Hair RT,也没有第二次画 Hair。就这个"只画一次、复用已有 GBufferD"的机制,现在源码已经能直接证明。
UE 5.8 真正正在工作的路径是:
BasePassPixelShader.usf
└─ EncodeGBufferToMRT(...)
└─ 这个函数不是普通 .ush 里手写的
└─ ShaderGenerationUtil.cpp 动态生成
我也把这点查到了。
Source/Runtime/Engine/Private/ShaderCompiler/ShaderGenerationUtil.cpp 约 770:
C++
static FString CreateGBufferEncodeFunction(const FGBufferInfo& BufferInfo)
然后约 774:
C++
FullStr += TEXT(
"void EncodeGBufferToMRT("
"inout FPixelShaderOut Out, "
"FGBufferData GBuffer, "
"float QuantizationBias)\n");
并且同文件约 1939:
C++
static bool bUseRefactor = true;
所以不是猜测:
UE 5.8 当前已经切到新的 GBuffer shader-generation/refactor 路径了。
对于你要用的这个条件:
Desktop
└─ Deferred
└─ 非 Adaptive Substrate
└─ Unlit 材质
GBufferD.xy 确实非常适合拿来放这两个数据。
为什么?
首先,UE 明确定义:
Source/Runtime/RenderCore/Public/GBufferInfo.h
C++
GBS_CustomData, // RGBA8, no compression
然后 GBufferInfo.cpp 约 536:
C++
// GBufferD
Info.Slots[GBS_CustomData] =
FGBufferItem(
GBS_CustomData,
GBC_Raw_Unorm_8_8_8_8,
GBCH_Both);
Info.Slots[GBS_CustomData].Packing[0] =
FGBufferPacking(TargetGBufferD, 0, 0);
Info.Slots[GBS_CustomData].Packing[1] =
FGBufferPacking(TargetGBufferD, 1, 1);
Info.Slots[GBS_CustomData].Packing[2] =
FGBufferPacking(TargetGBufferD, 2, 2);
Info.Slots[GBS_CustomData].Packing[3] =
FGBufferPacking(TargetGBufferD, 3, 3);
这已经把物理关系说死:
GBufferD
├─ R = CustomData.x
├─ G = CustomData.y
├─ B = CustomData.z
└─ A = CustomData.w
格式
└─ RGBA8 UNORM
不是"类似 CustomData"。
它就是 CustomData 的那个 RT。
第二个关键问题:"普通 Unlit 是否真的保持 0?"
是。
而且 UE 5.8 当前真正运行的新路径就明确这么干。
BasePassPixelShader.usf 约 2386:
hlsl
if (
GBuffer.ShadingModelID == SHADINGMODELID_UNLIT &&
(!SUBSTRATE_ENABLED || SUBSTRATE_GBUFFER_FORMAT==0)
)
{
Out.MRT[1] = 0;
SetGBufferForUnlit(Out.MRT[2]);
Out.MRT[3] = 0;
Out.MRT[GBUFFER_HAS_VELOCITY ? 5 : 4] = 0;
Out.MRT[GBUFFER_HAS_VELOCITY ? 6 : 5] = 0;
}
这里:
hlsl
Out.MRT[GBUFFER_HAS_VELOCITY ? 5 : 4]
就是对应的 GBufferD target。
因此 UE 原生行为就是:
Unlit Base Pass
└─ GBufferD = float4(0,0,0,0)
这一次不是"推测普通 Unlit 应该不用"。
是源码直接明确清零。
旧的 EncodeGBuffer() 其实也是完全一样:
DeferredShadingCommon.ush 约 984:
hlsl
if (GBuffer.ShadingModelID == SHADINGMODELID_UNLIT)
{
OutGBufferA = 0;
SetGBufferForUnlit(OutGBufferB);
OutGBufferC = 0;
OutGBufferD = 0;
OutGBufferE = 0;
}
所以新旧两条路径行为一致。
即使你把 raw GBufferD.x/y 改成自己的数据,UE 后面的正常 deferred lighting 也不会把它误认为 Unlit 的材质 CustomData。
因为解码时有:
DeferredShadingCommon.ush 约 1124:
hlsl
GBuffer.CustomData =
HasCustomGBufferData(GBuffer.ShadingModelID)
? InGBufferD
: 0;
然后 HasCustomGBufferData():
hlsl
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_SUBSTRATE_TOON;
注意:
UNLIT
不在里面。
所以对于你的 Unlit 刘海:
物理 GBufferD
└─ R = 你的 Hair Mask
└─ G = 你的 Subject Mask
正常 UE GBuffer Decode
└─ 发现 ShadingModel == Unlit
└─ GBuffer.CustomData = 0
这形成了一个相当漂亮的隔离:
Raw GBufferD.xy
└─ 你的自定义 Pass 可以读取
UE 正常 Deferred Lighting
└─ 看不到它
└─ 因为 Unlit → CustomData 强制解码为 0
所以"会不会污染 UE 原来的 Unlit lighting"这件事,从源码来看,答案基本可以定为:
不会,只要你严格限定在 Unlit 像素上使用。
UE 默认用了 Unity Build,所以你明明只改一个 .cpp,有时日志会显示一个比较大的:
Module.Renderer.XX.cpp
被重新编译。
因为几个 .cpp 被合并成一个 unity translation unit:
A.cpp ─┐
B.cpp ─┼→ Module.Renderer.7.cpp → 一个 obj
C.cpp ─┘
你改 A:
Module.Renderer.7.cpp
整个 unity blob 要重编。
但这仍然不等于整个 Renderer,更不等于整个 Engine。Epic 甚至提供 Adaptive Unity,就是专门减少这种迭代成本。dev.epicgames.com/documentation/unreal-engine/unreal-engine-build-tool-target-reference
最后一个实际区别:
Compile
.cpp → .obj
和:
Link
.obj → UnrealEditor-Renderer.dll
是两件事。
所以如果你在:
[800/1200] Compile Renderer...
的时候停掉,已经完成的 .obj 往往还在。
如果你 Renderer 已经全部 compile 完:
[1200/1200] Compile ...
↓
Link UnrealEditor-Renderer.dll
那再停就更可惜一点,因为下次主要就是重新确认 action graph + link。
所以你完全不需要抱着:
"我改两行 Renderer 源码,就必须重新等几个小时编整个 UE。"
这个模型。
更准确的是:
第一次把源码版 UE 建起来很重;之后修改 Renderer 的 Private 实现,正常工作方式就是增量编译 Renderer。
而且 Epic 早就把 Renderer 独立成模块,历史文档甚至直接写过这样做的目的之一就是:"修改 rendering code 时,不必重新链接整个 application"。dev.epicgames.com/documentation/en-us/unreal-engine/graphics-programming-overview
键不是"第一次改 Renderer 这个动作特殊",而是:
第一次本机编译时,UE 没有可复用的中间产物;第一次成功 Build 之后,这些产物已经存在,UBT 就能做增量编译。
具体是这条链:
第一次源码 Build
Renderer/A.cpp
Renderer/B.cpp
Renderer/C.cpp
...
↓
大量 C++ 编译
↓
A.obj
B.obj
C.obj
...
↓
UnrealEditor-Renderer.dll
第一次很重,是因为本地可能还没有:
Intermediate/
├─ .obj
├─ PCH
├─ Unity translation units
├─ generated headers
└─ dependency records
Binaries/
└─ UnrealEditor-Renderer.dll
成功一次以后这些都留下来了。
例如第二天你只改:
Renderer/Private/BasePassRendering.cpp
UBT 检查整个 Build Action Graph:
A.cpp 没变
└─ A.obj 继续用
B.cpp 没变
└─ B.obj 继续用
BasePassRendering.cpp 变了
└─ 重新生成 BasePassRendering.obj
其他 300 个 obj
└─ 继续用
最后:
旧 obj + 新 obj
↓
重新 Link Renderer.dll
所以真正发生的是:
第一次:
.cpp × 很多
→ .obj × 很多
→ Renderer.dll
以后小改:
1~几个 .cpp
→ 1~几个新 .obj
→ Renderer.dll
这就是标准的 C/C++ incremental build。
至于"能不能直接下别人的 Intermediate":
理论上不是物理上绝对不行,但工程上通常不这么干,因为 .obj/PCH 对构建环境非常敏感:
必须高度一致:
├─ 完全相同 UE commit
├─ 完全相同源码修改
├─ Visual Studio / MSVC toolchain
├─ Windows SDK
├─ build configuration
├─ compiler flags / defines
├─ Target
└─ 某些路径/PCH状态
只要依赖签名对不上,UBT 就会判定这些 action 失效,然后重新编。
所以团队真正采用的不是:
把同事的 Engine/Intermediate 整个压缩给你
而是:
某台 Build Machine
↓
把源码 Engine 完整编好
↓
BuildGraph
↓
生成 Installed Build
↓
分发给整个团队
Epic 官方就是专门支持这个流程的,并明确说这种 Installed Build 可以重新分发给团队,让其他人不必从源码编 Engine。dev.epicgames.com/documentation/en-us/unreal-engine/installed-build-reference-guide-for-unreal-engine
应该直接塞进当前活动的 Unlit 清零块:
hlsl
if (
GBuffer.ShadingModelID == SHADINGMODELID_UNLIT &&
(!SUBSTRATE_ENABLED || SUBSTRATE_GBUFFER_FORMAT==0)
)
{
Out.MRT[1] = 0;
SetGBufferForUnlit(Out.MRT[2]);
Out.MRT[3] = 0;
Out.MRT[GBUFFER_HAS_VELOCITY ? 5 : 4] = 0;
// 就应该接在这里
#if NUM_MATERIAL_OUTPUTS_GETHAIREYEREVEALMASK > 0
Out.MRT[GBUFFER_HAS_VELOCITY ? 5 : 4].x =
saturate(GetHairEyeRevealMask0(MaterialParameters));
#endif
#if NUM_MATERIAL_OUTPUTS_GETHAIREYEREVEALSUBJECT > 0
Out.MRT[GBUFFER_HAS_VELOCITY ? 5 : 4].y =
saturate(GetHairEyeRevealSubject0(MaterialParameters));
#endif
Out.MRT[GBUFFER_HAS_VELOCITY ? 6 : 5] = 0;
}
练的不是「看到中文以后想英文翻译」,而是让英文 lexical item / phrase 在真正产生意义的时候参与竞争。双语词汇研究本来就发现,两套语言并不是完全独立开关;词汇访问存在 cross-language interaction,而语言切换本身也有可测量的 switching cost。pmc.ncbi.nlm.nih.gov/articles/PMC9544540
word → collocation → phrase → clause → sentence → several connected sentences
如果你只是:
Launcher UE
↓
打开 Engine/Source/Runtime/Renderer/...
↓
改两行代码
但没有成功重新构建 Renderer DLL,那么运行时根本不会使用你的修改。
如果你现在确实已经做到了:
改 Renderer 源码
↓
UBT 编译 Renderer
↓
生成新的 UnrealEditor-Renderer.dll
↓
Editor 使用这个 DLL
那么情况就特殊了:你实际上已经开始维护一个自己编译的 Engine binary。工程意义上已经在做"源码引擎修改",但这不等于这个安装目录从官方定义上自动变成标准的 GitHub Source Build。
现在 Codex 写进去的 HairEyeReveal 那两行,放在 UE 5.8 已经不执行的旧编码分支里。也就是说,按目前源码,它大概率根本没有真正写进 Base Pass 的 GBufferD。
Shaders/Private/BasePassPixelShader.usf 约 2371:
// this is the new encode, the older encode is the #else,
// keeping it around briefly until the new version is confirmed stable.
而 UE 5.8 真正正在工作的路径是:
BasePassPixelShader.usf
└─ EncodeGBufferToMRT(...)
└─ 这个函数不是普通 .ush 里手写的
└─ ShaderGenerationUtil.cpp 动态生成
我也把这点查到了。
Source/Runtime/Engine/Private/ShaderCompiler/ShaderGenerationUtil.cpp 约 770:
static FString CreateGBufferEncodeFunction(const FGBufferInfo& BufferInfo)
然后约 774:
FullStr += TEXT(
"void EncodeGBufferToMRT("
"inout FPixelShaderOut Out, "
"FGBufferData GBuffer, "
"float QuantizationBias)\n");
并且同文件约 1939:
static bool bUseRefactor = true;
这也意味着你的 Custom Render Pass 读取时有一个非常重要的限制。
不要这样读:
GetScreenSpaceData(UV).GBuffer.CustomData.x
对于 Unlit:
DecodeGBuffer
└─ CustomData = 0
你永远读到 0。
SceneTextures.GBufferD
└─ .r / .g
也就是 raw texture。
还有两个不能跨过去的边界。
一是 Substrate Adaptive。
源码 ShaderGenerationUtil.cpp 约 1815:
C++
bool bIsSubstrateMaterial = Mat.SUBSTRATE_ENABLED;
bool bIsSubstrateNewGBuffer =
Mat.SUBSTRATE_GBUFFER_FORMAT == 1;
// Substrate doesn't use gbuffer,
// and thus doesn't need CustomData
const bool bUseCustomData =
!bIsSubstrateMaterial || !bIsSubstrateNewGBuffer;
所以:
SUBSTRATE_GBUFFER_FORMAT == 0
└─ 这套 GBufferD 思路成立
SUBSTRATE_GBUFFER_FORMAT == 1
└─ Adaptive / new GBuffer
└─ 不成立
而 BasePass 自己的条件也正好写的是:
hlsl
(!SUBSTRATE_ENABLED || SUBSTRATE_GBUFFER_FORMAT==0)
这不是巧合。
UE 自己也明确把 Adaptive 排除在这条传统 GBuffer 路径之外。
第二是 Mobile。
不能把桌面这个结论复制到 Mobile。
DeferredShadingCommon.ush 里面:
hlsl
#if SHADING_PATH_MOBILE
MobileEncodeGBuffer(...)
#endif
而 Mobile 对 GBufferD 还有:
hlsl
#if MOBILE_EXTENDED_GBUFFER
GBufferD = ...
#else
GBufferD = 0;
#endif
甚至 GLES framebuffer-fetch 路径直接写着:
hlsl
GBufferD = 0; // PLS is limited to 128bits
所以:
Desktop Deferred
└─ 可以按当前方案做
Mobile Deferred
├─ Extended GBuffer:另查 packing
├─ framebuffer fetch:不同
├─ PLS:可能根本没有 D
└─ 不能复用这个 patch
这是一个明确的桌面/移动分歧。
方案本身在 Desktop Deferred + Unlit + 非 Adaptive GBuffer 下是成立的;Codex 现在把真正的写入补丁接错了 UE 5.8 的分支。
不要要求「英文比例逐渐增加」。比例是很差的 proxy。真正想提升的是 英文承担的结构复杂度和自主生成程度。
比如:
20 个孤立英文名词 / 100 个词
未必比
1 个自然完整的 English clause / 100 个词
更有训练价值。
HLSL 里声明的 shader parameters,可以在 C++ 侧用一个对应的 parameter struct 来描述,然后 UE 根据这两边的名字和类型建立 binding。
你截图里上下两段是"一一对应关系":
hlsl
// .usf / .ush
float2 ViewportSize;
float4 Hello;
float World;
Texture2D BlueNoiseTexture;
SamplerState BlueNoiseSampler;
RWTexture2D<float4> SceneColorOutput;
C++ 侧概念上对应:
C++
struct FMyShaderParameters
{
FVector2D ViewportSize;
FVector4 Hello;
float World;
FRHITexture* BlueNoiseTexture;
FRHISamplerState* BlueNoiseSampler;
FRHIUnorderedAccessView* SceneColorOutput;
};
Epic 紧接着就说明:实际 UE 不是让你普通地手写这个 struct,而是使用:
C++
BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
SHADER_PARAMETER(FVector2D, ViewportSize)
SHADER_PARAMETER(FVector4, Hello)
SHADER_PARAMETER(float, World)
SHADER_PARAMETER_TEXTURE(Texture2D, BlueNoiseTexture)
SHADER_PARAMETER_SAMPLER(SamplerState, BlueNoiseSampler)
SHADER_PARAMETER_UAV(RWTexture2D, SceneColorOutput)
END_SHADER_PARAMETER_STRUCT()
这些宏会生成等价的 C++ 数据结构,同时生成 UE 需要的 reflection metadata,包括参数名、C++ type、HLSL type、byte offset 等,之后才能让 shader binding / RHI / RDG 使用。dev.epicgames.com/documentation/unreal-engine/render-dependency-graph-in-unreal-engine
不是"只有 Unlit 不用 GBufferD"。
Default Lit也不是CustomData/GBufferD的使用者。真正占用GBufferD = CustomData的,是 Subsurface、ClearCoat、Hair、Cloth、Eye、TwoSidedFoliage 等那批 shading model。
只是 Unlit 对我们特别好用,因为 UE 5.8 当前 Base Pass 里直接有一个非常明确的:
hlsl
if (GBuffer.ShadingModelID == SHADINGMODELID_UNLIT ...)
{
...
Out.MRT[GBufferD] = 0;
}
所以它给了我们一个几乎写着"这里原本没有 payload"的位置。
而且你最后那个理解需要反过来:
Unlit 下
GBufferD != 0,并不会让 UE 自动把它拿去做某个效果。
恰恰相反,UE 后面会因为这是 Unlit,主动把它当成"无语义数据"丢掉。

真正决定它能不能离开 Pixel Shader、进入 GPU Render Target 的,是 PixelShaderOutputCommon.ush:
hlsl
#if PIXELSHADEROUTPUT_MRT4
, out float4 OutTarget4 : SV_Target4
#endif
也就是说:
Out.MRT[4]
只是 shader 内部变量
↓ 必须存在这个桥
PIXELSHADEROUTPUT_MRT4 = 1
↓
SV_Target4
↓
D3D12 PS output
↓
绑定的 MRT4 / GBufferD
现在你的 Unlit 缺的就是中间这座桥。
ShaderGenerationUtil.cpp 现在会先分析这个 material permutation 实际用了哪些 GBuffer slot:
C++
DetermineUsedMaterialSlots(...)
然后得到:
C++
TargetUsage[]
最后只有:
C++
if (TargetUsage[Iter] >= EGBufferSlotUsage::Written)
{
OutEnvironment.SetDefine(
"PIXELSHADEROUTPUT_MRT%d",
1);
}
才会产生对应的 SV_TargetN。
而 Unlit 在 DetermineUsedMaterialSlots() 中调用:
C++
SetStandardGBufferSlots(...)
但没有:
C++
Slots[GBS_CustomData] = ...
所以逻辑就是:
Unlit
↓
不需要 GBS_CustomData
↓
GBufferD 对这个 material permutation 不属于 Written
↓
PIXELSHADEROUTPUT_MRT4 / MRT5 不开启
↓
PixelShaderOutputCommon 不生成对应 SV_Target
↓
最终编译出来的 PS 只有 SV_Target0...3
这就是截图里看到的现象。
UE shader permutation/output-mask 先决定"不导出 MRT4",然后 shader compiler 把无效的 Out.MRT[4] 写入消掉。
这也是为什么 Codex 现在说还必须改 RenderCore,而不仅仅改:
hlsl
BasePassPixelShader.usf
因为我们真正要告诉 UE 的不是:
"请算一个值。"
而是:
"这个特殊 Unlit material permutation 现在真的需要 CustomData/GBufferD 这个物理 MRT,因此不要把这个输出目标从 Pixel Shader signature 里删掉。"
因為 Windows DLL 模型允許:
新的 RenderCore.obj
+
舊的 Core.lib
舊的 RHI.lib
舊的其他 Engine import libs
↓
重新 link
↓
新的 UnrealEditor-RenderCore.dll
所以你其實是在做:
module-level binary replacement
而不是:
rebuild entire Unreal Engine
這兩件事差很多。
RenderCore
└─ 足夠完整
└─ 可以局部重建
Core
└─ 所需某些低層 source/dependency 沒有隨 Installed Build 分發
└─ 完整重建失敗
immutable 是 UBT 的構建策略,不是 Windows 或 CPU 的物理限制。
所以你修改:
C++
ShaderGenerationUtil.cpp
它屬於:
RenderCore
最終只需要得到:
新的 UnrealEditor-RenderCore.dll
只要它的 ABI 和其他現有 DLL 仍然兼容:
舊 Core.dll
舊 Engine.dll
舊 Renderer.dll
↕
新 RenderCore.dll
Editor 就可以正常啟動。
這跟你在 Windows 上替換某個普通程序 DLL,本質上沒有區別。
GBS 幾乎可以直接讀成:
GBuffer Slot
所以:
GBS_CustomData
= GBuffer Slot --- CustomData
我查了 UE 5.8 源碼,它確實屬於 enum EGBufferSlot,同一組還有:
C++
GBS_BaseColor
GBS_Roughness
GBS_WorldTangent
GBS_Anisotropy
GBS_CustomData
GBS_SubsurfaceColor
GBS_Opacity
Epic 5.8 官方 API 也把它列在 EGBufferSlot 裡。dev.epicgames.com/documentation/unreal-engine/API/Runtime/RenderCore/EGBufferSlot
GBS_CustomData
= GBuffer Slot --- CustomData
我查了 UE 5.8 源碼,它確實屬於 enum EGBufferSlot,同一組還有:
C++
GBS_BaseColor
GBS_Roughness
GBS_WorldTangent
GBS_Anisotropy
GBS_CustomData
GBS_SubsurfaceColor
GBS_Opacity
Epic 5.8 官方 API 也把它列在 EGBufferSlot 裡。dev.epicgames.com/documentation/unreal-engine/API/Runtime/RenderCore/EGBufferSlot
例如 UE 5.8 源碼中:
C++
GBS_CustomData // RGBA8, no compression
而後面的 GBuffer layout / packing 系統再決定這個 logical slot 最終被 pack 到哪個 render target/channel。也就是:
GBS_CustomData
→ 「需要 CustomData 這項資料」
不是:
GBS_CustomData
→ 「直接代表 GBufferD」
後者只是某些 legacy GBuffer layout 下最終的 physical packing 結果。
另外我查到 UE 5.8 的 ShaderGenerationUtil.cpp 中,像 Subsurface、ClearCoat、Hair、Eye、Toon 等 shading model 都會按需做:
C++
Slots[GBS_CustomData] = GetGBufferSlotUsage(bUseCustomData);
這正好能看出 Slot 的含義:它是 shader/GBuffer codegen 用來描述「這個材質需要哪些 semantic fields」的一層抽象,而不是 MRT 本身。這個區分後面讀 GBufferInfo.cpp 時非常有用。
前面的問題其實跟 Launcher 無關:
Hair Custom Output
↓
BasePass 裡 Out.MRT[GBufferD].x = PackedR
↓
最終 shader 沒有 SV_Target4/5
這是 UE 5.8 本身的 shader output-mask 機制。GitHub 源碼版也一樣會遇到。因為 UE 5.8 已經把原來 BasePass .usf 裡這種判斷:
hlsl
#define PIXELSHADEROUTPUT_MRT4 ...
#define PIXELSHADEROUTPUT_MRT5 ...
基本移到了 C++ 的 GBuffer usage 分析。
真正的 Launcher 限制出現在這裡:
想重編 ShaderCompileWorker
↓
發現 ShaderCompileWorker.Target.cs 不存在
↓
Build.bat ShaderCompileWorker ...
無法建立完整 target
我剛又直接查了你這份 E:\UE_5.8\Engine:
class ShaderCompileWorkerTarget
在你安裝版 Source 裡確實搜不到。
但是這裡又出現了一個新的工程問題:
它現在這個條件其實不是:
「只有 RevealHair Unlit 才保留 GBufferD」。
而是:
C++
OutEnvironment.SetDefine(
"HAIR_EYE_REVEAL_FORCE_UNLIT_GBUFFERD_OUTPUT",
1);
對所有 Base Pass shader 都設成 1。
然後 HLSL 只過濾:
hlsl
MATERIAL_SHADINGMODEL_UNLIT
所以實際效果是:
所有 BasePass
↓
宏 = 1
其中所有 Unlit material
↓
全部強制導出 GBufferD
不是只有你的 Hair Reveal。
這和我們上一輪討論的最理想版本有差別。
現在是:
普通 Unlit A
→ SV_Target4/5
普通 Unlit B
→ SV_Target4/5
Hair Reveal Unlit
→ SV_Target4/5
只是普通 Unlit 最後仍然寫:
GBufferD = 0
所以功能上大概率沒錯,但失去了 UE 原本對普通 Unlit 的 MRT output pruning。
真正漂亮的版本仍然是:
Material 有 HairEyeReveal Custom Output
↓
產生一個 compile-time material property
↓
只有這個 material:
HAIR_EYE_REVEAL_OUTPUT = 1
↓
才強制 GBufferD output
這部分才需要更深入地把 Custom Output 的存在傳到 compilation environment。
Renderer 在前面准备"这次 shader 要怎么编";ShaderCompileWorker 在后面真正执行 HLSL 编译。
时间顺序其实是:
Renderer
↓
Derived shader rules
↓
ShaderCompileWorker
不是:
ShaderCompileWorker
↓
Renderer
你之所以感觉 Worker 更"上游",主要是因为它名字叫 Compiler Worker。
重新编译 ShaderCompileWorker
这句话实际意思是:
UE C++ 源码
↓
MSVC
↓
重新生成 ShaderCompileWorker.exe
这是"编译一个 Windows C++ 程序"。
而 ShaderCompileWorker 平时做的是:
BasePassPixelShader.usf
↓
ShaderCompileWorker.exe
↓
DXC
↓
GPU shader bytecode
这是"编译 HLSL Shader"。
完全不是同一个 compile。
BasePassRendering.cpp 中:
C++
ModifyBasePassCSPSCompilationEnvironment(...)
属于 Renderer。
它做的事情类似:
C++
OutEnvironment.SetDefine("IS_BASE_PASS", 1);
OutEnvironment.SetDefine("GBUFFER_LAYOUT", ...);
现在 Codex 又加:
C++
OutEnvironment.SetDefine(
"HAIR_EYE_REVEAL_FORCE_UNLIT_GBUFFERD_OUTPUT",
1);
它是在说:
我是 Base Pass Renderer,我给即将发生的 shader compilation 提供一组事实和开关。
所以这是:
Renderer
= compile-job 的需求提出者
UE 5.8 這段 source 的實際資料流是:
FShaderCompilerEnvironment 裡已存在的 #define
↓ 讀出
MaterialDefines / LightmapDefines / GlobalDefines / CompilerDefines
↓
CalculateDerivedMaterialParameters(...)
↓
FShaderMaterialDerivedDefines
所以這個函數不是「計算材質參數」,而是在做:
把一組已有的 shader compile defines,經過 UE 的規則,推導成另一組 compile-time decisions。
例如已有:
MATERIALBLENDING_SOLID = 1
FORWARD_SHADING = 0
FeatureLevel 足夠
函數才推導:
USES_GBUFFER = 1
因此 FShaderMaterialDerivedDefines 最精確的身份是:
由現有 shader compile defines 推導出的第二級 defines 集合。
derived 的對立面就在這裡非常清楚:
已有 define
MATERIALBLENDING_SOLID
FORWARD_SHADING
...
↓ derive
派生 define
USES_GBUFFER
WRITES_CUSTOMDATA_TO_GBUFFER
PIXELSHADEROUTPUT_MRT...
...
而 Parameters 這個函數名用詞其實相當鬆。它不是 ShaderParameter,也不是材質裡的 Scalar/Vector Parameter。若只看實際資料,CalculateDerivedMaterialDefines() 會比現在的名字更接近它做的事情。
static void DetermineUsedMaterialSlots(...)
最重要的部分。對 function 而言這裡不是「函數一直活著」------function 本來就不是用 object lifetime 那套描述。static 在這裡真正關鍵的是:
internal linkage:其他 translation unit 不能透過這個名字引用它。
scope 最核心就是「名字有效的範圍」。
在 C++ 裡更精確地說:
scope= 一個 name 可以被直接找到、引用的程式文字範圍。
例如:
C++
void F()
{
int X = 0;
}
X 的 scope 就在 F() 這個 block 裡。
出了這個 {}:
C++
X = 1; // 找不到 X
所以可以先把它記成:
scope管的是「這個名字在哪裡看得見」。
不要先把它和 lifetime 混在一起。
例如:
C++
void F(){ static int X = 0; } X 的 scope 仍然只在 F() 內,但它的 lifetime 很長。
真正決定 C++ 這個 member「是什麼東西」的,主要是前面的宏:
SHADER_PARAMETER
└─ RDG
└─ TEXTURE
└─ UAV
UE 5.8 源碼裡這個宏最後實際展開的 C++ member type 是:
C++
FRDGTextureUAV* Output;
也就是說,Output 在 C++ 世界裡並不是:
C++
RWTexture2D<float4> Output; // ❌
而是:
C++
FRDGTextureUAV* Output; // ✅
所以你的感覺是對的:這裡首先是在描述一個「RDG Texture 的 UAV view parameter」。
它描述的是 shader-side type,也就是 HLSL 那一側應當把這個 binding 看成什麼:
hlsl
RWTexture2D<float4> Output;
而 UE C++ 側則是:
C++
FRDGTextureUAV* Output;
兩邊形成:
C++ / RDG HLSL
────────────────────────────────────────────────
FRDGTextureUAV* Output ↔ RWTexture2D<float4> Output
↑ ↑
RDG resource view shader type
例如宏:
C++
#define TEST(ShaderType) TEXT(#ShaderType)
你调用:
C++
TEST(RWTexture2D<float4>)
宏参数实际收到的是:
ShaderType =
RWTexture2D<float4>
其中 <float4> 本来就是你传进去的内容。
然后:
C++
#ShaderType
得到:
C++
"RWTexture2D<float4>"
UE 5.8 的实际源码,这个文件自己就写了相当实质的逻辑。它开头甚至直接说明用途:
To allow PS input/output passed into functions through a single struct
也就是把各种 Pixel Shader 的输入输出统一包装起来,减少到处写 #ifdef。
它的关系更接近:
BasePassPixelShader.usf
DeferredDecal.usf
MeshDecals.usf
VirtualTextureMaterial.usf
│
│ #include
↓
PixelShaderOutputCommon.ush
│
├─ 根据前面定义好的 PIXELSHADEROUTPUT_* 宏
│
├─ 生成 MainPS(...) 的参数签名
│
├─ 决定有没有 SV_Target0
├─ 决定有没有 SV_Target1
├─ ...
├─ 决定各种 interpolants
│
└─ 包装并调用各个 shader 自己的
FPixelShaderInOut_MainPS(...)
比如 Base Pass 在 include 它之前会先准备:
hlsl
#define PIXELSHADEROUTPUT_MRT...
#define PIXELSHADEROUTPUT_BASEPASS ...
...
#include "PixelShaderOutputCommon.ush"
然后 PixelShaderOutputCommon.ush 里面真的会生成:
hlsl
void MainPS(
...
#if PIXELSHADEROUTPUT_MRT0
, out float4 OutTarget0 : SV_Target0
#endif
#if PIXELSHADEROUTPUT_MRT1
, out float4 OutTarget1 : SV_Target1
#endif
...
)
所以它其实很像一个 macro-parameterized shader wrapper/template:
调用者 shader
└─ 先定义「我要什么」
MRT 数量
BasePass / Decal
Interpolants
Coverage
...
↓
PixelShaderOutputCommon.ush
└─ 根据这些配置
生成真正的 Pixel Shader entry-point/output interface
它确实也有:
hlsl
#include "ShaderOutputCommon.ush"
但这不是它名字叫 Common 的主要原因。
你可以暂时把 UE shader 文件命名里的 Common 理解为:
这一层代码不是某一个具体 Pass 独占,而是被多个 Shader/Pass 共享。


JPEG、PNG,它们有很好的压缩率和压缩品质,但必須CPU端解压整张贴图,才能读取贴图上的像素值(也就是不支持随机读取)---所以uav就是強調了讀的東西解壓了的可以unorder access了
JPEG和PNG都是熵编码压缩(霍夫曼编码、算术编码),压缩数据是连续流式存储的,像素之间互相依赖,如果你只需要贴图上某一个局部像素,必须从头解压整个文件才能拿到对应的位置,根本做不到"拿到哪个位置就直接读哪个位置"。
解决方案是GPU专用块纹理压缩,比如BC系列、ASTC、ETC这些格式,它们把整张贴图切成一个个独立的4x4/8x8的小块,每个小块单独压缩,GPU可以只解压需要用到的那几个小块,完美支持随机访问,不需要处理整张图,非常适配GPU渲染的需求。
- 传统着色器用的着色器资源视图SRV,只允许GPU只读,而且访问是对齐到纹理采样规则的,不能随意改写内存中任意位置的数据。
- UAV放开了这个限制,允许GPU的多个线程同时对同一个缓冲区/纹理的任意位置做读写,只要你处理好同步就行。
"CPU端解压、不适合GPU解压",现在其实已经有成熟的解决方案了:如果要动态加载压缩贴图,可以用CPU按需解压小块,或者直接让GPU做并行解压,
T 和 U / A / F 有一個很重要的差別:
T ─ template class
└─ 描述 C++ declaration 的形式
U ─ UObject-derived class
└─ 描述 inheritance
A ─ AActor-derived class
└─ 描述 inheritance
S ─ SWidget-derived class
└─ 描述 inheritance
I ─ abstract interface
└─ 描述 class role / interface convention
F ─ 其他大多數普通 UE class / struct
└─ 特別是非 UObject 類型
所以看到 TWhatever 時,最應該形成的第一反應不是「這是一個 UE 特殊對象」,而是:
先找它的
template <...>declaration。
例如本質上會是這類結構:
C++
template<typename InElementType, typename InAllocatorType>
class TArray
{
...
};
而不是:
C++
class TArray : public Something
T 不代表 inheritance、ownership、lifetime,也不代表 container 。TArray/TMap/TSet 恰好都是 container,容易造成「T = container」的錯覺;但 TSharedPtr、TOptional、TFunction 立即可以排除這個理解。
還有一個很好用的反例是:
C++
FString
FString 內部建立在 TArray<TCHAR> 之上,但 FString 自己不是以 template class 的形式暴露,因此叫 FString,不是 TString。dev.epicgames.com/documentation/unreal-engine/fstring-in-unreal-engine
當重力可以忽略、時空近似平坦時,廣義相對論局部退化成狹義相對論。
-
狹義相對論 Special Relativity, SR:1905。處理沒有重力,主要研究慣性參考系之間的時空關係。核心是洛倫茲變換、光速不變、時間膨脹、長度收縮、。
-
廣義相對論 General Relativity, GR:1915。把重力納入,核心變成「重力不是普通力,而是時空幾何的曲率」。
GPS 裡面,狹義和廣義相對論是在修正兩種完全不同的效應:
-
狹義相對論:因為衛星在高速運動,所以衛星上的鐘相對地面鐘會變慢。
-
廣義相對論:因為衛星所在位置重力比較弱,所以衛星上的鐘相對地面鐘會變快。
GPS 剛好同時具有:
-
衛星相對地面高速運動 → SR。
-
衛星和地面處在不同重力勢 → GR。
所以兩個效應必須一起算。
General Relativity
└─ spacetime 可以彎曲、可以有重力
└─ 當 spacetime 是平坦的
└─ 就退化成 Special Relativity
時空沒有曲率,那麼廣義相對論使用的局部物理就回到 Minkowski spacetime,也就是狹義相對論。
還有一個更重要的說法:即使整個宇宙的時空是彎的,在一個足夠小的自由落體區域內,仍然可以近似成狹義相對論。
普通人不是一個數學上的質點。身體有:
皮膚
└─ 骨骼
└─ 肌肉
└─ 血液
└─ 大腦
力只能透過材料內部逐步傳播。
假設某個裝置先推你的背:
背部先開始加速 →
└─ 胸腔被壓縮
└─ 內臟受到剪切
└─ 血液產生巨大壓力梯度
└─ 頭部稍後才跟上
如果加速度足夠大,人體會先被機械應力破壞,而不是「完整地瞬間變成 」。
這個人中間發生了加速、減速,因而更換了 inertial frame。這時就不能只拿一句「彼此都看對方變慢」處理,而要沿各自的 worldline 積分 proper time。


C++ / permutation / shader environment
└─ 決定這次 shader 需要哪些能力
↓
BasePassPixelShader.usf
└─ 定義 PIXELSHADEROUTPUT_MRT0/1/2/... 之類的宏
↓
#include "PixelShaderOutputCommon.ush"
↓
PixelShaderOutputCommon.ush
└─ 根據宏真正生成
SV_Target0
SV_Target1
SV_Target2
...
也就是你現在看到的:
hlsl
#if PIXELSHADEROUTPUT_MRT4
, out float4 OutTarget4 : SV_Target4
#endif
這裡才是「HLSL 語法層面,這個 Pixel Shader 到底有沒有 SV_Target4」的最直接位置。
但「拼接」這個詞不要泛化。真正叫 token concatenation / token pasting 的只有 ##。
例如 UE 那裡:
C++
zzMemberId##MemberName
如果:
MemberName = Output
才是真的拼成:
C++
zzMemberIdOutput
而:
C++
#MemberName
不是拼接,是 stringify:
C++
TEXT(#MemberName)
變成:
C++
TEXT("Output")
##:
C++
zzMemberId##MemberName
代入後:
C++
zzMemberId##Output
## 把左右兩個 token 黏成一個新的 token:
C++
zzMemberIdOutput
所以 compiler 看到的是一個真正的 C++ identifier:
而 #:
C++
#MemberName
代入:
C++
#Output
結果不是 identifier,而是字串:
C++
"Output"
## = token pasting,造新名字。
# = stringification,把 token 變成字串。
# 和 ## 都是標準的巨集預處理機制;## 官方名稱就是 token-pasting operator,把兩個 preprocessing tokens 合成一個新的 token。gcc.gnu.org/onlinedocs/cpp/Concatenation.html
用一個宏自動生成 setter:
C++
#define DECLARE_PROPERTY(Name) \
int Name; \
void Set##Name(int Value) \
{ \
Name = Value; \
}
寫:
C++
DECLARE_PROPERTY(Health)
preprocessor 展開:
C++
int Health;
void SetHealth(int Value)
{
Health = Value;
}
這個 SetHealth 是真正的 C++ function identifier。Compiler 後面真的可以解析:
C++
SetHealth(100);
如果你用 #:
C++
#Name
只會得到:
C++
"Health"
你不能:
C++
"SetHealth"(100); // 完全不是函數
所以兩者最大的用途差別就是:
#
Health
↓
"Health"
↓
產生資料
可以拿去 log / metadata / reflection name
##
Set + Health
↓
SetHealth
↓
產生程式碼中的 symbol / identifier
可以成為 variable / function / type 名字
Microsoft 和 GCC 都給了一個很典型的實際例子:假設一個 command 同時需要文字名稱和函數名稱,可以只寫一次 quit:
C++
#define COMMAND(NAME) \
{ #NAME, NAME##_command }
然後:
C++
COMMAND(quit)
展開:
C++
{ "quit", quit_command }
左邊的 "quit" 是資料;右邊的 quit_command 是實際函數 identifier。這就是 # 和 ## 經常一起出現的原因。gcc.gnu.org/onlinedocs/cpp/Concatenation.html
Renderer / BasePass C++
└─ 决定 Base Pass 的 rendering policy
├─ 哪些 Mesh 进入这个 Pass
├─ 每个 Mesh/Material 选哪个 shader permutation
├─ compile-time defines
├─ GBuffer layout / velocity / platform 等条件
└─ 实际绑定哪些 Render Targets
Material / Shading Model
└─ 是 Renderer + Shader 编译系统判断输出需求的重要输入
├─ Unlit / DefaultLit / Hair ...
└─ 影响是否需要 CustomData、GBuffer 等
代价是:目前会让符合该编译条件的 Unlit Base Pass 都保留 GBufferD 输出,而不是只让含 HairEyeRevealMask 的单个材质保留。
- Renderer 在编译 Base Pass 时传入专用宏
PixelShaderOutputCommon.ush根据这个宏,让 Unlit Base Pass 声明并写出 GBufferD 对应的 MRTBasePassPixelShader.usf在 UE 5.8 当前执行的编码路径中,把HairEyeRevealMask写进这个输出
你寫一個 CustomPass.cpp,並不是說:
GPU/CPU 執行時把
CustomPass.cpp這個文件整份拿來調用。
更準確是:
CustomPass.cpp
└─ C++ compiler 把它當成一個 translation unit
├─ 展開它 #include 的 .h
├─ 展開那些 .h 再 include 的其他 .h
├─ 處理 template / macro / inline code
└─ 編譯成 .obj
└─ linker 再和其他 .obj / library 組成 UE module / DLL
所以 .cpp 是「源碼組織邊界」,不是 runtime 的調用單位。
PixelShaderOutputCommon.ush,而且它確實存在,並且是被 BasePassPixelShader.usf 明確 #include 進去的。
UE 5.8 源碼就是:
hlsl
// BasePassPixelShader.usf
// all PIXELSHADEROUTPUT_ and "void FPixelShaderInOut_MainPS()"
// need to be setup before this include
// this include generates the wrapper code to call
// MainPS(inout FPixelShaderOutput PixelShaderOutput)
#if COMPUTE_SHADED
#include "ComputeShaderOutputCommon.ush"
#else
#include "PixelShaderOutputCommon.ush"
#endif
所以你之前接觸的那條鏈實際是:
BasePass 的 C++ Renderer code
└─ 選擇 / 編譯 BasePass Pixel Shader
└─ BasePassPixelShader.usf
├─ 前面定義大量 PIXELSHADEROUTPUT_xxx
├─ 寫 Base Pass 自己的 shader 邏輯
│
└─ #include PixelShaderOutputCommon.ush
└─ 根據前面那些 PIXELSHADEROUTPUT_xxx
生成最終 MainPS 輸入/輸出 wrapper
PixelShaderOutputCommon.ush 自己開頭又有:
hlsl
#include "ShaderOutputCommon.ush"
所以還會繼續形成:
BasePassPixelShader.usf
└─ PixelShaderOutputCommon.ush
└─ ShaderOutputCommon.ush