← ALL ARTICLES
渲染架构8 MIN READ

GBuffer布局

全库目录221
本文目录17

延迟光照要从GBuffer取出表面参数。如果给每个参数单独分配通道,布局很快会变大;若过度压缩,法线、分类或遮罩的误差又会改变光照。我们先看字段由谁消费,再用编码和复用减少成本。

附件带宽预算

一张 1080p 的 RGBA8 纹理约 8 MB。几何阶段写一次、光照阶段读一次,每帧就是 16 MB 的流量;60 帧就是接近 1 GB/s。这是完整读写的逻辑字节量,不包含缓存、附件压缩和片上驻留;实际外部流量由平台测量。减少附件的收益取决于当前瓶颈。

布局数量由材质字段、渲染路径和消费者决定。两张GBuffer可以作为受限材质集合的设计目标,但不是所有桌面或移动管线的标准。压缩手段主要有三类:编码降维、位域打包、通道复用。

字段编码

八面体法线

世界空间法线是单位向量,只有两个自由度,用三个通道存储是浪费。

最朴素的做法是只存 XY、由 z=1−x2−y2z=\sqrt{1-x^2-y^2} 推算。它有两个硬伤:zz 接近 0 时导数发散、精度崩塌;且丢失符号,无法表达背向的法线。

八面体映射(Octahedral Encoding)把球面先投影到八面体,再展开成正方形,用两个数覆盖整个球面方向。它的精度分布远比半球投影均匀,且不丢符号:

hlsl
float2 EncodeNormal(float3 normal)
{
    normal /= abs(normal.x) + abs(normal.y) + abs(normal.z);
    float2 f = normal.xy;
    if (normal.z < 0.0)
        f = (1.0 - abs(f.yx)) * float2(f.x >= 0 ? 1 : -1,
                                      f.y >= 0 ? 1 : -1);
    return f * 0.5 + 0.5;
}

完整编码/解码见 Packing.hlsl。输入是非零单位法线,解码后归一化;下半球折叠保存符号。省下的一个通道可以安置 Smoothness 或金属度。8 位量化下八面体编码的角度误差通常在可接受范围内,但低粗糙度的镜面反射对法线精度敏感,需要实测确认——高光在光滑表面上出现阶梯状色带就是精度不足的典型信号。

这份编码把分量映射到[0,1],使用UNORM视图按数值读写。法线字段需要保持编码约定,sRGB视图会对其中的颜色分量施加非线性转换,破坏解码。采用SNORM或浮点格式时,按其表示范围另行确定编码与解码。

一个字节装两个字段

当两个量各自都不需要完整 8 位时,可以切分同一个字节。例如用高 3 位存一个 0∼70\sim7 的整数、低 5 位存一个 [0,1][0,1] 的浮点:

输入浮点遮罩位于0–1,类别为0–7。先量化遮罩到0–31,再组成一个字节,写入R8_UNORM。读取时点采样,先恢复字节,再拆类别与遮罩:

hlsl
uint q = (uint)floor(saturate(mask) * 31.0 + 0.5);
float packed = ((identifier & 7u) * 32u + q) / 255.0;
uint byteValue = (uint)floor(saturate(packed) * 255.0 + 0.5);
uint restoredId = byteValue / 32u;
float restoredMask = (byteValue % 32u) / 31.0;

取类别5、遮罩0.5,低5位舍入得到16,打包字节为5*32+16=176,写入值176/255。点读恢复176后,类别为5,遮罩为16/31≈0.516。遮罩误差来自量化,但类别保持精确;若与类别0的像素线性混合,恢复字节可能落入另一类别区间,错误就变成光照模型跳变。

这里的0.5是恢复整数时的半步偏移,量化误差由字段位宽决定。不能把打包值当颜色线性过滤:相邻类别混合会生成第三个类别。MSAA resolve、贴花和后处理都要保持这个字段契约。示例使用float计算,半精度行为在目标编译器和GPU上另测。

reference.py遍历8种类别与32级遮罩,并检查端点和过滤反例,264项CPU检查通过。该结果支持R8_UNORM点读模型,不支持任意精度与过滤方式。5位遮罩误差上限约为1/(2×31),是否满足AO或阴影用途由画面对照决定。

字段复用

互斥属性共用位置

固定GBuffer布局限制了每个像素能携带的参数。皮肤的次表面散射、头发的各向异性高光需要专用字段,布局设计要同时说明这些字段由哪些材质消费,以及光照阶段如何辨认其含义。

解法是给每个像素打一个材质分类标记,光照阶段按分类走不同的 BRDF 分支。前面的 3 位整数正是用于此:

text
0 = Lit        普通 PBR
1 = Terrain    地形
2 = Plant      植被,需要透光
3 = Character  角色,布料等
4 = Skin       皮肤,需要次表面散射
5 = Hair       头发,需要各向异性高光

有了分类,同一个通道就可以在不同材质下表达不同含义:

hlsl
if (isLit || isCharacter || isSkin)
    surfaceData.metallic = gbuffer1.a;
else if (isHair)
    surfaceData.anisotropicSpecularMask = gbuffer1.a;   // 同一通道
else if (isPlant)
    surfaceData.translucency = gbuffer1.a;              // 又不同

复用的前提是当前着色模型确实不同时需要这些属性。示例中的头发和植被模型选择了互斥参数;其他材质体系可能同时需要它们。GBuffer 布局设计的典型思路就是先找出互斥的属性集合,再让它们共用通道。

代价是可读性。同一个通道的语义随材质分类变化,阅读与调试成本上升,必须在代码注释与文档中写明每种分类下的通道含义。

不同执行阶段复用字段

除了按材质复用,还可以按生命周期复用:如果某个通道的数据在某个阶段之后不再需要,就可以拿它存别的东西。

一个实用案例是环境光与主光之间复用 Occlusion 通道:

hlsl
// 环境光阶段末尾:AO 已经用完,写入主光阴影
surfaceData.occlusion = mainLightShadow;
gbuffer0 = real4(gbuffer0.rgb, PackFloat5UInt3(surfaceData.occlusion, identifier));
hlsl
// 主光阶段:直接读出,无需额外的全屏阴影纹理
Light mainLight = GetMainLight();
mainLight.shadowAttenuation = surfaceData.occlusion;

这省掉了一张全屏阴影 RT。前提是严格确认后续没有任何 Pass 还需要原始 AO 值——这类复用一旦判断错误,表现为某些效果在特定材质上莫名失真,且极难定位。

同样地,通道语义随执行阶段变化会显著增加维护成本。值得为每个复用点记录“从哪个 Pass 开始语义改变”。

Framebuffer Fetch 与 Subpass

前面所有压缩手段都在减少数据量。移动端还有一条更彻底的路径:减少GBuffer的外部内存往返。

在附件容量与合并条件满足时,分块式 GPU 可让中间结果驻留在片上 Tile 内存;超出容量或执行范围被拆开时,也可能在中途溢出到外部内存。如果几何阶段与光照阶段能合并成同一个 Render Pass 的多个 Subpass,GBuffer就有机会留在Tile 内存里,实际驻留由附件大小、设备和驱动决定。

Vulkan的input attachment可让后续子通道读取前序子通道写入的当前像素数据;光照写入独立颜色附件。Metal的Framebuffer Fetch采用其对应接口读取附件已有值。下面的HLSL签名是依赖引擎宏与跨平台编译器的接口示意,宿主须按目标后端生成合法的附件绑定和依赖:

hlsl
#if _FRAME_BUFFER_FETCH_ENABLED
    inout real4 shadingColor    : CoLoR0,   // inout:读写同一块 Tile 内存
    inout real4 geometryBuffer0 : CoLoR1,
#else
    out   real4 shadingColor    : SV_Target0,  // 常规 MRT 输出
    out   real4 geometryBuffer0 : SV_Target1,
#endif

局部附件读取可以减少外部读写,但仍有执行与片上访问成本。着色器签名是具体项目的表达示例,不能据此断言所有平台零成本。

约束也很明确:

  • 本地附件读取接口取得当前像素的数据。模糊等邻域效果需要可供邻域读取的资源与对应同步,普通纹理采样按其资源规则配置。
  • 本文的传统子通道复用路线把几何与光照放在同一Render Pass中,依赖表达帧缓冲局部消费;完整画面的跨像素依赖需要另行组织。
  • Tile存储预算由附件格式、样本数和设备共同决定,合并与驻留以驱动行为和计数器为准。
  • 引擎封装本地附件读取与普通采样两条路径时,分别维护着色器接口和资源状态。

Load 与 Store 操作

Subpass 的收益不只来自“数据留在片上”,还来自显式声明的 Load/Store 行为。每个附件在 Render Pass 开始与结束时各有一个操作:

操作含义带宽
LOAD_OP_LOAD保留已有内容可能增加加载,具体由实现决定
LOAD_OP_CLEAR清成常量可采用快速清除,实际计量
LOAD_OP_DONT_CARE允许丢弃旧内容不要求保留加载
STORE_OP_STORE保留附件结果可能增加存储,具体由实现决定
STORE_OP_DONT_CARE允许丢弃结果不要求保留存储

开始操作取决于是否完整写入或需要初始背景;结束操作取决于后续消费者。若同一渲染范围内光照已消费全部字段,范围外没有SSR、SSAO、贴花、调试或回读消费者,就可以丢弃对应附件。需要后续读取时保留STORE。

附件的初末操作应由真实消费者决定。对后续完全不用的 GBuffer 结果使用 DONT_CARE,可允许实现省去对应存储;节省量取决于分辨率、格式、压缩及驱动实际路径,需要捕获验证。深度附件同理:确认后续既不采样,也不继续做深度测试、复制或回读时,才允许丢弃。

依赖声明

Subpass 之间的关系必须显式声明:后一个 Subpass 读取哪些附件作为输入附件,以及两者之间的执行与内存依赖。

依赖声明不准确有两种后果。声明过松会出现竞态——光照阶段可能读到尚未写完的 GBuffer;声明过严则会引入不必要的同步,抵消合并的收益。

输入附件在着色器中是独立的资源类型,读取时不经过采样器,只能取当前片元位置的值。这是 input attachment 接口的局部读取语义,适合片上保留附件。具体是否驻留 Tile 内存由实现决定,普通邻域采样需要对应的纹理读取路径。

动态渲染与局部读取

Vulkan 1.3 的动态渲染在录制时指定附件,省去预先创建部分 Render Pass 和 Framebuffer 对象。需要在同一渲染范围内读取附件时,另核对动态渲染局部读取能力、扩展或相应核心版本,以及实际的依赖声明。

传统 input attachment 和局部读取都表达当前片元附近的受限附件消费,它们不等于普通任意纹理采样。接口是否合法由启用能力决定,能否减少外部流量由设备和驱动决定。捕获中确认附件与依赖,再用平台计数器验证收益。

特殊 Pass:贴花

贴花向已有表面写入颜色或材质参数。投影贴花的管线状态显式关闭深度写入,深度测试按覆盖网格与投影方式设置;着色器省略深度输出时,光栅深度仍可能被写入,所以这两个设置分别检查。贴花再按字段含义与已有GBuffer合成。

当目标 Alpha 保存遮罩与分类时,RGB 仍可使用来源 Alpha 作为硬件混合权重,并通过独立混合或写掩码保留目标 Alpha,具体能力按 API 核对。若项目采用合法的附件读取路径手动合成,可写成:

hlsl
half4 DecalBlendManually(half4 dst, half4 src)
{
    return half4(lerp(dst.xyz, src.xyz, src.w), dst.w);   // 保留 dst 的 Alpha
}

注意结果保留了 dst.w——目标的分类标记与 AO 不能被贴花破坏,否则该像素的光照分支会走错。

精度类型

移动端还有一层收益来自精度限定。real等别名由项目宏、包版本和编译目标定义,不是硬件通用类型规则。检查预处理结果后再确认实际使用half还是float。

半精度在支持有效窄精度执行的目标上可能减少寄存器或ALU成本,颜色、遮罩、法线等有界数值可先评估半精度误差;位置、深度和长期累加量对范围及舍入更敏感,应按误差预算选择精度。半精度在大数值范围上的精度损失可能表现为可见跳变。

字段预算与方案选择

收益代价
光照与几何解耦,多光源成本可控单层 GBuffer 无法完整表达多层透明,通常另设透明路径
在字段可容纳时压到两张GBuffer,减少附件逻辑读写量本页示例给法线两个8位通道、遮罩5位,质量取决于量化误差与实际材质
分类标记支持多种 BRDF光照着色器出现分支,需评估相邻像素分类与执行代价
满足附件与设备条件时,Subpass可减少GBuffer外存往返本地附件读取限于当前像素;邻域消费者需要相应纹理路径与同步
通道二次复用省下整张 RT通道语义随阶段变化,阅读与调试成本高

这些取舍没有普适答案。桌面与移动设备都应按实测瓶颈选择压缩程度。高分辨率、MSAA 和附件数量会改变带宽需求,压缩同时带来解码与精度成本。

验证方法

  • 逐张输出 GBuffer 内容为可视化颜色,确认每个通道在各类材质上取值合理。
  • 单独验证打包与解包:对分类标记从 0 到 7 逐个写入再读出,确认往返一致,尤其检查边界值。
  • 用一个粗糙度极低的金属球检查八面体编码精度,高光出现阶梯色带说明位数不足。
  • 统计 GBuffer 总字节数与每帧读写带宽,与目标平台的可用带宽对比。
  • 开关 Framebuffer Fetch 两条路径分别验证,两者结果应当一致。
  • 结合API捕获、图合并信息与厂商外部流量计数器,检查驻留和读写收益。
  • 检查贴花绘制后目标像素的分类标记是否保持不变。

相关主题

参考资料