在UE5的延迟渲染管线中,DecodeGBufferData 这个函数的存在,核心原因可以概括为:GBuffer里存储的都是精心压缩、编码过的数据,不解码就无法直接用于光照计算。
你可以把GBuffer想象成一份"速记稿"------为了节省显存和带宽,几何和材质信息被高度压缩打包进几张纹理。渲染器在光照阶段必须先把这些速记稿还原成完整的句子,这个过程就是解码。
FGBufferData DecodeGBufferData(
float4 InGBufferA,
float4 InGBufferB,
float4 InGBufferC,
float4 InGBufferD,
float4 InGBufferE,
float4 InGBufferF,
float4 InGBufferVelocity,
float CustomNativeDepth,
uint CustomStencil,
float SceneDepth,
bool bGetNormalizedNormal,
bool bChecker)
{
FGBufferData GBuffer;
GBuffer.WorldNormal = DecodeNormal( InGBufferA.xyz );
if(bGetNormalizedNormal)
{
GBuffer.WorldNormal = normalize(GBuffer.WorldNormal);
}
GBuffer.PerObjectGBufferData = InGBufferA.a;
GBuffer.Metallic = InGBufferB.r;
GBuffer.Specular = InGBufferB.g;
GBuffer.Roughness = InGBufferB.b;
// Note: must match GetShadingModelId standalone function logic
// Also Note: SimpleElementPixelShader directly sets SV_Target2 ( GBufferB ) to indicate unlit.
// An update there will be required if this layout changes.
GBuffer.ShadingModelID = DecodeShadingModelId(InGBufferB.a);
GBuffer.SelectiveOutputMask = DecodeSelectiveOutputMask(InGBufferB.a);
GBuffer.BaseColor = DecodeBaseColor(InGBufferC.rgb);
#if GBUFFER_HAS_DIFFUSE_SAMPLE_OCCLUSION
GBuffer.DiffuseIndirectSampleOcclusion = 255 * InGBufferC.a;
GBuffer.GBufferAO = saturate(1.0 - float(countbits(GBuffer.DiffuseIndirectSampleOcclusion)) * rcp(float(INDIRECT_SAMPLE_COUNT)));
GBuffer.IndirectIrradiance = 1;
#elif ALLOW_STATIC_LIGHTING
GBuffer.GBufferAO = 1;
GBuffer.DiffuseIndirectSampleOcclusion = 0x0;
GBuffer.IndirectIrradiance = DecodeIndirectIrradiance(InGBufferC.a);
#else
GBuffer.GBufferAO = InGBufferC.a;
GBuffer.DiffuseIndirectSampleOcclusion = 0x0;
GBuffer.IndirectIrradiance = 1;
#endif
GBuffer.CustomData = HasCustomGBufferData(GBuffer.ShadingModelID) ? InGBufferD : 0;
GBuffer.CustomMask = (GBuffer.ShadingModelID == SHADINGMODELID_DEFAULT_LIT) ? InGBufferD.a : 0;
// FirstPerson uses a bit in SelectiveOutputMask that is aliased with ZERO_PRECSHADOW_MASK when !ALLOW_STATIC_LIGHTING, so we explicitly skip this logic here.
#if ALLOW_STATIC_LIGHTING
GBuffer.PrecomputedShadowFactors = HasPrecShadowMask(GBuffer) ? InGBufferE : (HasZeroPrecShadowMask(GBuffer) ? 0 : 1);
#else
GBuffer.PrecomputedShadowFactors = half(1.0f);
#endif
GBuffer.CustomDepth = ConvertFromDeviceZ(CustomNativeDepth);
GBuffer.CustomStencil = CustomStencil;
GBuffer.Depth = SceneDepth;
GBuffer.StoredBaseColor = GBuffer.BaseColor;
GBuffer.StoredMetallic = GBuffer.Metallic;
GBuffer.StoredSpecular = GBuffer.Specular;
具体来说,有以下几个决定性因素:
1. 极致的带宽与显存压缩
延迟渲染的本质,是把复杂的材质计算先跑一遍,把结果(法线、颜色、粗糙度等)写入多张GBuffer纹理,光照阶段再读取。如果有大量像素,GBuffer的读写带宽就是性能瓶颈。
因此,UE5会对数据做非破坏性或近似无损的压缩编码,例如:
-
法线 :通常使用 八面体映射编码 。原本需要
float3(12字节)的世界空间法线,被映射到一个二维向量,打包进RGBA8或R10G10B10A2纹理的两个通道。解码时需要调用类似DecodeNormal的函数,反向展开回单位法线。 -
基础颜色 :可能舍弃不必要的高精度,使用sRGB/BC压缩,或存储时去掉与金属度重复的信息,解码时再还原。
-
粗糙度、金属度、AO :往往共享一个8位通道(如
GBufferB.A),通过简单的乘法和范围映射解码。 -
自定义深度/模板 :
CustomNativeDepth和CustomStencil并不是直接存于GBuffer,而是从深度缓冲和模板缓冲中取出,解码函数会利用它们重构像素的世界位置,或判断像素是否属于特定标记物体。
如果不解码,你拿到的是一个已经被投影、量化、打包成奇怪数值的"压缩包",直接当做法线或颜色会得到完全错误的结果。
UE5通常使用八面体映射(Octahedral Mapping) 将3D单位法线压缩成2D坐标,再量化存入8位通道(如RGBA8纹理的两个通道)。这样每个像素的法线只占16位,而直接存RGB法线要24甚至48位。
编码过程
假设我们有一个世界空间法线,已经归一化:
n=(0.267, 0.534, 0.802)n=(0.267, 0.534, 0.802)
(容易验证 0.2672+0.5342+0.8022≈10.2672+0.5342+0.8022≈1)
1. 投影到八面体
先计算L1范数(曼哈顿距离):
d=∣0.267∣+∣0.534∣+∣0.802∣=1.603d=∣0.267∣+∣0.534∣+∣0.802∣=1.603
将法线分量除以 dd,得到投影:
n′=(0.1666, 0.3333, 0.5006)n′=(0.1666, 0.3333, 0.5006)
此时三个分量的绝对值之和等于1。
2. 折叠到二维(八面体展开)
因为 nz′≥0nz′≥0(法线在上半球),直接取前两个分量作为编码结果:
o=(0.1666, 0.3333)o=(0.1666, 0.3333)
(若 nz′<0nz′<0,则需要绕中心折叠,公式不同)
3. 映射到 0,10,1 存储空间
将 −1,1−1,1 范围的八面体坐标映射到 0,10,1:
cx=0.1666×0.5+0.5=0.5833cx=0.1666×0.5+0.5=0.5833cy=0.3333×0.5+0.5=0.66665cy=0.3333×0.5+0.5=0.66665
4. 量化为8位整数存入GBuffer
假设GBuffer使用RGBA8格式,A和B通道分别存入 cx,cycx,cy:
Vx=round(0.5833×255)=149Vx=round(0.5833×255)=149Vy=round(0.66665×255)=170Vy=round(0.66665×255)=170
所以,GBuffer中这两个通道的最终存储值为 149 和 170。
总结basecolor压缩:先平方根,让数值变大,再乘以255,那么丢掉的小数更靠后,精度损失越小
因为人眼对暗部变化更敏感,直接用线性值存储,暗部(如0.1)只分配到25个灰度级(0.1×255≈26),很容易出现色带。而平方根编码会把暗部数值"拉高",分配更多码位,亮部则被压缩,完美匹配视觉特性。
具体数值例子
假设GBuffer使用RGBA8(每通道0-255整数)格式,基础颜色需要存储三个通道。
例子1:中等暗度的灰色
原始线性颜色:
C=(0.30, 0.30, 0.30)C=(0.30, 0.30, 0.30)
① 编码(平方根)
E=C=(0.30,0.30,0.30)≈(0.5477, 0.5477, 0.5477)E=C=(0.30,0.30,0.30)≈(0.5477, 0.5477, 0.5477)
② 量化到0-255整数
V=round(0.5477×255)=round(139.66)=140V=round(0.5477×255)=round(139.66)=140
GBuffer中存储的三个通道值就是 140, 140, 140。
解码过程(在DecodeGBufferData中)
① 归一化回0,1
E′=140/255≈0.5490E′=140/255≈0.5490
② 逆运算(平方)
C′=(0.5490)2≈(0.3014, 0.3014, 0.3014)C′=(0.5490)2≈(0.3014, 0.3014, 0.3014)
误差 :
原始 0.3000 → 解码 0.3014,误差 +0.0014,相对误差仅0.47%。
如果不编码(线性存储)
-
0.010 × 255 = 2.55 → 存成整数 3 -
0.015 × 255 = 3.825 → 存成整数 4
这两个暗色被强塞进了相邻的两个整数,解码后只能还原成 3/255≈0.0118 和 4/255≈0.0157,中间的细腻变化全部丢失,而且台阶感极重。
用平方根编码
-
√0.010 = 0.10.1 × 255 = 25.5 → 存成整数 26 -
√0.015 ≈ 0.12250.1225 × 255 = 31.2 → 存成整数 31
看这里,原本挤在两个整数里的值,现在跨了 5 个整数(26→31)。
解码时平方回去:
-
(26/255)² ≈ 0.0102 -
(31/255)² ≈ 0.0148
不仅还原了原始数值,中间还多出了 3 个可用整数(27,28,29,30),能表达 0.010 到 0.015 之间的其他暗部层次。
乘以255,就是为了把一个0到1的小数,塞进一个"8位整数格子"里。
1. 存储格式:整数 vs 浮点
GPU的纹理有不同格式。UE5的GBuffer在很多模式下用的是 RGBA8 UNORM 格式。
-
UNORM 的意思是:纹理里存的真是整数(0-255),但采样器帮你自动除以255,变回0-1的小数给着色器。
-
所以,如果你要把一个值写进这张纹理,你必须先乘以255,转成整数,再存进去。
如果你不乘255,直接把0.5477这种小数写进去,硬件会把它截断为0或1(因为整数纹理只接受整数),颜色就完全错了。
因此,"乘以255"是把着色器里的浮点数,翻译成纹理能懂的整数语言的必要步骤。
2. 为什么不能跳过这步,直接存浮点?
可以直接存浮点,但那就得用 浮点纹理格式(如R16G16B16A16_FLOAT)。代价是:
-
带宽翻倍:RGBA8是32位/像素,RGBA16F是64位/像素。GBuffer有好几张,每一张都翻倍,整个延迟管线的读写带宽压力就会暴增,帧率会大幅下降。
-
显存占用翻倍:同样分辨率,显存多占一倍。
在游戏主机和多数PC上,带宽和显存是极其宝贵的资源。所以引擎选择用8位整数来存大部分数据,然后用 编码技巧(平方根、法线八面体、位打包) 弥补整数精度不足的问题。