UE5 之 GBuffer - DecodeGBuffer

在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字节)的世界空间法线,被映射到一个二维向量,打包进RGBA8R10G10B10A2纹理的两个通道。解码时需要调用类似DecodeNormal的函数,反向展开回单位法线。

  • 基础颜色 :可能舍弃不必要的高精度,使用sRGB/BC压缩,或存储时去掉与金属度重复的信息,解码时再还原。

  • 粗糙度、金属度、AO :往往共享一个8位通道(如GBufferB.A),通过简单的乘法和范围映射解码。

  • 自定义深度/模板CustomNativeDepthCustomStencil并不是直接存于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中这两个通道的最终存储值为 149170


总结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.01184/255≈0.0157,中间的细腻变化全部丢失,而且台阶感极重。


用平方根编码

  • √0.010 = 0.1

    0.1 × 255 = 25.5 → 存成整数 26

  • √0.015 ≈ 0.1225

    0.1225 × 255 = 31.2 → 存成整数 31

看这里,原本挤在两个整数里的值,现在跨了 5 个整数(26→31)。

解码时平方回去:

  • (26/255)² ≈ 0.0102

  • (31/255)² ≈ 0.0148

不仅还原了原始数值,中间还多出了 3 个可用整数(27,28,29,30),能表达 0.0100.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位整数来存大部分数据,然后用 编码技巧(平方根、法线八面体、位打包) 弥补整数精度不足的问题。