屏幕空间后处理(Screen Space Post-Processing)之所以在3D场景中频繁成为性能瓶颈,原因不在于算法本身复杂度高,而是它对像素数量的敏感度远超普通几何渲染。几何管线只处理覆盖三角形的那部分像素,且深度测试会拒绝大量无效片元;而后处理pass一轮就能覆盖屏幕上每一个像素,如果有SSAO、SSR、运动模糊、色调映射等多个效果串联,每一帧需要执行的着色器调用次数可能达到分辨率乘以效果数量的量级。在2560×1440分辨率下关闭SSAO与开启SSAO的GPU耗时差常常超过2毫秒,在4K下则更容易突破4毫秒。因此屏幕空间优化的第一件事,就是把后处理从全分辨率、全采样、逐帧重算的固定模式中解放出来。

全文会围绕三类手段展开:分辨率降采样与边缘保持的重建、时间性复用与噪声控制、基于深度数据的提前剔除。这些方法可以单独使用,也可以叠加,落地成本很低,不需要重写渲染管线。
为什么全屏Pass会迅速吃掉GPU预算
一个典型的SSAO实现会在每个像素处构造视图空间位置,再围绕该点采样16到64个半球方向,每次采样都要访问深度纹理并做范围检测。以16个采样点、1080p分辨率为例,假设场景中需要计算AO的像素约为200万,那么每帧至少要执行3200万次深度纹理读取。深度纹理读取虽然带宽不算高,但随机方向采样会破坏纹理缓存,实际延迟远高于顺序读取。
更隐蔽的成本来自多个后处理效果之间的重复计算。很多项目会分别渲染AO、SSR、软阴影,每个pass都各自重建一遍视图空间位置和法线。这种重复计算在CPU端几乎感觉不到,但在GPU上就是成倍的ALU与纹理操作。先看一段未优化的全分辨率SSAO片段着色器:
float occlusion = 0.0;
float3 viewPos = reconstructViewPos(uv, depth);
float3 normal = reconstructNormal(uv);
for (int i = 0; i < 16; i++)
{
float3 sampleDir = viewPos + kernel[i] * radius;
float4 proj = mul(projMatrix, float4(sampleDir, 1.0));
proj.xyz /= proj.w;
float2 sampleUV = proj.xy * 0.5 + 0.5;
float sampleDepth = depthTex.SampleLevel(pointClamp, sampleUV, 0).r;
float sampleViewZ = reconstructViewZ(sampleUV, sampleDepth);
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(viewPos.z - sampleViewZ));
occlusion += (sampleViewZ >= sampleDir.z ? 1.0 : 0.0) * rangeCheck;
}
return 1.0 - occlusion / 16.0;
这段代码在1080p下执行一次就需要大约3200万次随机深度采样,而且每个采样点都要执行矩阵乘法与除法,寄存器压力也不低。如果把采样数增加到32或64,耗时会线性上升。真正可行的优化不是把循环次数从16降到8,而是让循环执行的次数不再对应全分辨率像素数。
降采样与联合双边上采样
屏幕空间效果的一大特点是低频信息占比很高。环境光遮蔽、间接反射、辉光等效果在空间上变化缓慢,不需要逐像素精确计算。把AO、SSR等pass渲染到半分辨率甚至四分之一分辨率的目标上,着色器调用次数可以降到原来的四分之一或十六分之一。代价是边缘位置会丢失高频细节,但可以通过深度和法线加权的联合双边上采样补回来。
半分辨率SSAO的着色器与全分辨率版本几乎相同,只是输入UV对应的是半尺寸纹理。真正的难点在上采样阶段。如果直接用双线性插值,物体边缘会出现AO溢出或漏光,因为低分辨率AO图在深度不连续处会把背景和前景的颜色混在一起。联合双边滤波的思路是:对于全分辨率目标像素,采集周围若干个低分辨率AO值,根据中心像素与采样像素的深度差、法线差动态调整权重。深度差越大权重越小,这样前景和背景就不会互相污染。
float depthCenter = depthTex.SampleLevel(pointClamp, fullUV, 0).r;
float3 normalCenter = normalTex.SampleLevel(pointClamp, fullUV, 0).xyz;
float aoSum = 0.0;
float weightSum = 0.0;
float2 texelSize = 1.0 / halfResTexSize;
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
float2 sampleUV = fullUV + float2(x, y) * texelSize;
float depthSample = depthTex.SampleLevel(pointClamp, sampleUV, 0).r;
float3 normalSample = normalTex.SampleLevel(pointClamp, sampleUV, 0).xyz;
float depthWeight = 1.0 / (1.0 + abs(depthCenter - depthSample) * depthScale);
float normalWeight = pow(saturate(dot(normalCenter, normalSample)), normalPower);
float weight = depthWeight * normalWeight;
float aoSample = aoTex.SampleLevel(linearClamp, sampleUV, 0).r;
aoSum += aoSample * weight;
weightSum += weight;
}
}
return aoSum / weightSum;
这段上采样代码虽然看起来比直接双线性插值复杂,但它的计算量只有全屏一次3×3采样,相比省下的全分辨率AO计算非常划算。depthScale与normalPower两个参数需要根据场景尺度调节:depthScale过大,边缘会太硬;过小,则漏光明显。normalPower通常取8到32之间。
降采样策略还适用于SSR。反射信息在粗糙表面本来就模糊,可以先在半分辨率下做光线步进,再通过深度感知上采样恢复边缘。与SSAO不同的是,SSR的步进次数与反射距离有关,降采样后步进次数可以相应减少或保持,但像素数变少,最终耗时依然显著下降。
时间性复用与抖动采样
静态或慢速移动的场景中,相邻两帧的屏幕空间信息高度重合。时间性复用把上一帧的计算结果通过运动矢量映射到当前帧,与当前帧的结果进行混合。这样每帧只需要做少量新采样,就能获得等效于多次采样的收敛质量。对于SSAO,每帧只计算4到8个采样方向,配合每帧变化的旋转噪声,再利用历史帧累积,噪点会逐渐消失。
实现时间性复用需要三样东西:运动矢量纹理、历史结果纹理、以及当前帧的采样结果。运动矢量可以从几何渲染阶段输出,也可以根据当前帧与上一帧的深度重投影计算。如果场景中有大量动态物体,运动矢量不准确会导致历史结果拖影,此时可以增加当前帧混合权重或做邻域裁剪。下面是一个基本的AO时间累积混合:
float currentAO = aoTex.SampleLevel(pointClamp, uv, 0).r; float2 motionUV = motionTex.SampleLevel(pointClamp, uv, 0).xy; float historyAO = historyAoTex.SampleLevel(linearClamp, uv - motionUV, 0).r; float velocityLength = length(motionUV); float blendFactor = saturate(0.05 + velocityLength * 0.25); float finalAO = lerp(historyAO, currentAO, blendFactor); return finalAO;
blendFactor决定了历史帧与当前帧的权重。物体移动越快,当前帧权重越高,防止拖影;静态区域则更多依赖历史结果,降低单帧采样压力。还可以在混合前对历史AO做邻域裁剪,把与当前AO差得过远的历史值截断,进一步减少鬼影。
时间性复用的另一个好处是与降采样天然兼容。半分辨率AO配上时间累积,每帧只需要4个采样就能达到接近全分辨率16采样的稳定度,总成本可能只有原来的十分之一。运动模糊、SSR、体积光等效果也可以套用相同的思路,不过运动矢量的精度会直接影响最终画面。
基于深度的提前剔除与Tile裁剪
屏幕空间后处理并不是每个像素都需要完整计算。天空区域、极远平面、完全被遮挡的深度值都可以提前跳过。以SSAO为例,当中心像素的深度接近远平面时,环境光遮蔽的贡献几乎可以忽略,直接返回1.0即可。深度提前剔除可以用极低的成本减少大量无效计算,尤其在开放场景中,天空占比经常达到20%到40%。
float centerDepth = depthTex.SampleLevel(pointClamp, uv, 0).r;
if (centerDepth >= farPlaneLimit)
{
return 1.0;
}
float3 viewPos = reconstructViewPos(uv, centerDepth);
float3 normal = reconstructNormal(uv);
if (viewPos.z > distanceCull)
{
return 1.0;
}
// 继续正常SSAO计算
farPlaneLimit一般取0.999到0.9999,distanceCull则根据场景尺寸调节。除了天空,还可以使用屏幕空间Tile划分,对每个16×16像素块统计深度范围,如果整个Tile的深度都在阈值之外,就跳过该Tile的后续计算。这种Tile级别的裁剪在Compute Shader中实现非常自然,可以配合group共享内存进一步减少带宽。
SSR也可以用深度提前剔除。反射光线步进过程中,如果当前步进深度超出场景包围盒,或者光线离开了屏幕边界,就可以立即终止步进。很多反射性能问题都源于光线在屏幕边缘或天空区域继续无效步进。优化后的SSR步进函数通常会把屏幕边界判断放在内层循环之前,每次步进先判断UV是否在0到1之间,再决定是否继续。
工程落地顺序与效果验证
实际项目中,建议先统计各后处理pass的GPU耗时,确定真正的瓶颈。不要一上来就优化SSAO,也许主要耗时在SSR的降采样不足或Bloom的高斯模糊层数太多。用RenderDoc或Nsight Graphics截帧后,按耗时从高到低排序,优先处理占比较大的pass。
优化屏幕空间后处理时,可以先做降采样和深度提前剔除,这两项改动小、收益明显;然后再加入时间性复用,因为它需要运动矢量,接入成本稍高。降采样倍数不是越大越好,四分之一分辨率以下容易出现明显的边缘锯齿和细节丢失,通常半分辨率是画质与性能的平衡点。
多数情况下,屏幕空间优化能把后处理总耗时降低40%到60%,而肉眼几乎看不出画质差异。如果项目已经使用了TAA,时间性复用还可以与TAA共享运动矢量,进一步减少额外开销。最终目标不是把后处理算得一模一样,而是在相同帧预算下留出资源给更重要的几何和光照。