GBuffer布局
全库目录221
本文目录17
延迟光照要从GBuffer取出表面参数。如果给每个参数单独分配通道,布局很快会变大;若过度压缩,法线、分类或遮罩的误差又会改变光照。我们先看字段由谁消费,再用编码和复用减少成本。
附件带宽预算
一张 1080p 的 RGBA8 纹理约 8 MB。几何阶段写一次、光照阶段读一次,每帧就是 16 MB 的流量;60 帧就是接近 1 GB/s。这是完整读写的逻辑字节量,不包含缓存、附件压缩和片上驻留;实际外部流量由平台测量。减少附件的收益取决于当前瓶颈。
布局数量由材质字段、渲染路径和消费者决定。两张GBuffer可以作为受限材质集合的设计目标,但不是所有桌面或移动管线的标准。压缩手段主要有三类:编码降维、位域打包、通道复用。
字段编码
八面体法线
世界空间法线是单位向量,只有两个自由度,用三个通道存储是浪费。
最朴素的做法是只存 XY、由 推算。它有两个硬伤: 接近 0 时导数发散、精度崩塌;且丢失符号,无法表达背向的法线。
八面体映射(Octahedral Encoding)把球面先投影到八面体,再展开成正方形,用两个数覆盖整个球面方向。它的精度分布远比半球投影均匀,且不丢符号:
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 位存一个 的整数、低 5 位存一个 的浮点:
输入浮点遮罩位于0–1,类别为0–7。先量化遮罩到0–31,再组成一个字节,写入R8_UNORM。读取时点采样,先恢复字节,再拆类别与遮罩:
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 位整数正是用于此:
0 = Lit 普通 PBR
1 = Terrain 地形
2 = Plant 植被,需要透光
3 = Character 角色,布料等
4 = Skin 皮肤,需要次表面散射
5 = Hair 头发,需要各向异性高光有了分类,同一个通道就可以在不同材质下表达不同含义:
if (isLit || isCharacter || isSkin)
surfaceData.metallic = gbuffer1.a;
else if (isHair)
surfaceData.anisotropicSpecularMask = gbuffer1.a; // 同一通道
else if (isPlant)
surfaceData.translucency = gbuffer1.a; // 又不同复用的前提是当前着色模型确实不同时需要这些属性。示例中的头发和植被模型选择了互斥参数;其他材质体系可能同时需要它们。GBuffer 布局设计的典型思路就是先找出互斥的属性集合,再让它们共用通道。
代价是可读性。同一个通道的语义随材质分类变化,阅读与调试成本上升,必须在代码注释与文档中写明每种分类下的通道含义。
不同执行阶段复用字段
除了按材质复用,还可以按生命周期复用:如果某个通道的数据在某个阶段之后不再需要,就可以拿它存别的东西。
一个实用案例是环境光与主光之间复用 Occlusion 通道:
// 环境光阶段末尾:AO 已经用完,写入主光阴影
surfaceData.occlusion = mainLightShadow;
gbuffer0 = real4(gbuffer0.rgb, PackFloat5UInt3(surfaceData.occlusion, identifier));// 主光阶段:直接读出,无需额外的全屏阴影纹理
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签名是依赖引擎宏与跨平台编译器的接口示意,宿主须按目标后端生成合法的附件绑定和依赖:
#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 核对。若项目采用合法的附件读取路径手动合成,可写成:
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捕获、图合并信息与厂商外部流量计数器,检查驻留和读写收益。
- 检查贴花绘制后目标像素的分类标记是否保持不变。
相关主题
参考资料
-
Khronos Vulkan Samples:Subpasses:Tile 存储预算、子通道合并条件与计数器验证;是否驻留需按设备检查。
-
Khronos:Render Pass:附件加载/存储与动态渲染;具体支持由设备扩展和特性确定。