多Pass渲染
Dionysen

多 pass 渲染就像制作一部电影的后期制作流程,每一步都专门处理一种效果,最后合成最终画面。

每个 pass 专注一种效果,互不干扰:阴影 pass 只关心深度;几何 pass 只关心基础着色;后处理 pass 只关心特效。

DepthTest

让我详细解释深度缓冲相关的概念和在渲染管线中的作用:

核心概念

深度缓冲区 (Depth Buffer/Z-Buffer)
  • 一个与颜色缓冲区同样大小的缓冲区
  • 每个像素存储一个深度值(通常是0.0到1.0的浮点数)
  • 0.0 = 最近距离(相机近平面)
  • 1.0 = 最远距离(相机远平面)
深度写入 (Depth Write)
pipelineSpec.DepthWrite = true;   // 启用深度写入
pipelineSpec.DepthWrite = false; // 禁用深度写入
  • 启用时:片段着色器执行完成后,将计算出的深度值写入深度缓冲区
  • 禁用时:不写入深度值,深度缓冲区保持不变
  • 用途:预深度渲染中,只有预深度通道写入,几何体通道只读取
深度测试 (Depth Test)
pipelineSpec.DepthTest = true;   // 启用深度测试
pipelineSpec.DepthTest = false; // 禁用深度测试(如天空盒)
  • 启用时:根据深度测试函数比较当前片段深度值与深度缓冲区中的值
  • 禁用时:所有片段都会被渲染,无论深度如何
深度测试函数 (Depth Compare Function)
enum class DepthCompareOperator
{
Never, // 永远不通过 (不渲染任何东西)
Less, // 当前深度 < 缓冲区深度时通过
LessOrEqual, // 当前深度 <= 缓冲区深度时通过
Equal, // 当前深度 == 缓冲区深度时通过
GreaterOrEqual, // 当前深度 >= 缓冲区深度时通过
Greater, // 当前深度 > 缓冲区深度时通过
NotEqual, // 当前深度 != 缓冲区深度时通过
Always, // 永远通过 (等同于禁用深度测试)
};

渲染管线中的位置

完整的渲染管线流程
顶点数据

┌─────────────────┐
│ 顶点着色器 │ ← 计算顶点位置、变换到裁剪空间
└─────────────────┘

┌─────────────────┐
│ 图元装配 │ ← 组装三角形
└─────────────────┘

┌─────────────────┐
│ 光栅化 │ ← 生成像素片段,计算深度值
└─────────────────┘

┌─────────────────┐
│ Early-Z 测试 │ ← **深度测试在这里!**(可选)
└─────────────────┘

┌─────────────────┐
│ 片段着色器 │ ← 计算像素颜色
└─────────────────┘

┌─────────────────┐
│ Late-Z 测试 │ ← **深度测试在这里!**(如果没有Early-Z)
└─────────────────┘

┌─────────────────┐
│ 深度写入 │ ← **深度写入在这里!**
└─────────────────┘

┌─────────────────┐
│ 颜色混合 │ ← 与帧缓冲区混合
└─────────────────┘

┌─────────────────┐
│ 帧缓冲区 │ ← 最终输出
└─────────────────┘

深度测试的执行位置

Early-Z 测试 (提前深度测试)
  • 位置:片段着色器之前
  • 条件
    • 片段着色器不修改深度值
    • 不使用 discard 指令
    • 不使用Alpha测试
  • 优势:剔除被遮挡的片段,避免昂贵的片段着色器计算
Late-Z 测试 (延迟深度测试)
  • 位置:片段着色器之后
  • 条件:当不满足Early-Z条件时
  • 劣势:片段着色器已经执行,无法节省计算

深度测试的具体执行过程

// 伪代码展示深度测试过程
for (每个像素片段) {
float currentDepth = 片段的深度值;
float bufferDepth = depthBuffer[x][y]; // 深度缓冲区中的值

bool testResult = false;
switch (depthCompareOperator) {
case Less:
testResult = (currentDepth < bufferDepth);
break;
case LessOrEqual:
testResult = (currentDepth <= bufferDepth);
break;
case Equal:
testResult = (currentDepth == bufferDepth);
break;
// ... 其他比较函数
}

if (testResult) {
// 深度测试通过
执行片段着色器(); // 或者已经执行了(Late-Z情况)

if (depthWrite) {
depthBuffer[x][y] = currentDepth; // 写入新的深度值
}

写入颜色到帧缓冲区();
} else {
// 深度测试失败,丢弃该片段
丢弃片段();
}
}

预深度渲染的深度测试流程

第一步:预深度通道
// 深度缓冲区初始状态:全部为 1.0 (最远)
depthBuffer[所有像素] = 1.0f;

// 渲染鸭子模型
DepthOperator = LessOrEqual; // 小于等于时通过
DepthWrite = true; // 启用深度写入

for (鸭子的每个片段) {
float duckDepth = 0.6f; // 假设鸭子在这个深度

if (duckDepth <= 1.0f) { // 0.6 <= 1.0,测试通过
// 只执行简单的预深度着色器(不计算颜色)
depthBuffer[x][y] = 0.6f; // 写入鸭子的深度
}
}
第二步:几何体通道
// 深度缓冲区现在存储着鸭子的深度值 0.6f
// 现在渲染完整的鸭子(带材质、光照等)

DepthOperator = LessOrEqual; // 小于等于时通过
DepthWrite = false; // 禁用深度写入

for (鸭子的每个片段) {
float duckDepth = 0.6f; // 相同的几何体,相同的深度

if (duckDepth <= 0.6f) { // 0.6 <= 0.6,测试通过
执行完整的PBR片段着色器(); // 计算最终颜色
// 不写入深度值(保持预深度的结果)
写入颜色到帧缓冲区();
}
}

为什么预深度渲染能提高性能?

传统渲染
渲染所有物体 → 执行昂贵的片段着色器 → 深度测试 → 丢弃被遮挡的片段

问题:被遮挡的片段浪费了片段着色器的计算

预深度渲染
预深度通道 → 建立深度缓冲区 → 几何体通道 → Early-Z剔除大部分片段 → 只对可见片段执行昂贵的着色器

优势:昂贵的片段着色器只在必要时执行

ShadowMap

阴影贴图,通过深度信息来判断物体距离光源的前后遮挡关系,

ShadowMapPass();   // 阴影贴图
SpotShadowMapPass(); // 手电筒阴影贴图

层级阴影贴图

传统的阴影贴图使用单一的深度纹理来存储整个场景的阴影信息,但会遇到以下问题:

  1. 近处阴影质量差:由于一张贴图要覆盖整个场景,近处物体分配到的像素很少
  2. 远处浪费精度:远处不重要的区域却占用了大量贴图空间
  3. 透视混叠:在视角变化时会出现明显的阴影锯齿

CSM将相机的视锥体(viewing frustum)分割成多个层级,每个层级使用独立的阴影贴图:

  1. 分层渲染:
  • 第1层:覆盖相机附近0-50米,使用高精度阴影
  • 第2层:覆盖50-200米,使用中等精度
  • 第3层:覆盖200-800米,使用较低精度
  • 第4层:覆盖800米以上,使用最低精度
  1. 动态调整:
  • 根据相机位置动态调整每层的覆盖范围
  • 近处获得更多的阴影细节
  • 远处保持基本的阴影效果
  1. 平滑过渡:
  • 在层级边界处进行混合,避免突兀的质量跳跃
  • 使用淡入淡出效果

PreDepth

预深度:确定哪些像素是可见的,先画一遍,只记录深度,不进行着色。

目的: 解决传统绘制重复渲染的问题, 比如一个像素的位置上,先绘制了远处的山,再绘制近处的树,这样远处的山的着色计算就浪费了。

解决方案: 第一遍,只记录深度信息,确定每个像素最终显示哪个物体;后续只给真正可见的像素上色。

void SceneRenderer::Init()
{
// 创建预深度的帧缓冲
FramebufferSpecification preDepthFramebufferSpec;
preDepthFramebufferSpec.Width = m_Specification.ViewportWidth;
preDepthFramebufferSpec.Height = m_Specification.ViewportHeight;
preDepthFramebufferSpec.DebugName = "PreDepth-Opaque";

// 关键:只需要深度缓冲,不需要颜色缓冲
preDepthFramebufferSpec.Attachments = { ImageFormat::DEPTH32FSTENCIL8UINT };
preDepthFramebufferSpec.DepthClearValue = 0.0f; // 使用反向Z,远处是0,近处是1

// 创建两个版本:一个清除深度,一个保持深度
Ref<Framebuffer> clearFramebuffer = Framebuffer::Create(preDepthFramebufferSpec);
preDepthFramebufferSpec.ClearDepthOnLoad = false;
Ref<Framebuffer> loadFramebuffer = Framebuffer::Create(preDepthFramebufferSpec);
}

预深度管线:

// 静态物体的预深度管线
PipelineSpecification pipelineSpec;
pipelineSpec.DebugName = "PreDepth-Opaque";
pipelineSpec.TargetFramebuffer = clearFramebuffer;
pipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("PreDepth"); // 简单的深度着色器
pipelineSpec.Layout = vertexLayout;
pipelineSpec.InstanceLayout = instanceLayout;
m_PreDepthPipeline = Pipeline::Create(pipelineSpec);

// 动画物体的预深度管线
pipelineSpec.DebugName = "PreDepth-Anim";
pipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("PreDepth_Anim");
pipelineSpec.BoneInfluenceLayout = boneInfluenceLayout; // 支持骨骼动画
m_PreDepthPipelineAnim = Pipeline::Create(pipelineSpec);

预深度渲染:

void SceneRenderer::PreDepthPass()
{
uint32_t frameIndex = Renderer::GetCurrentFrameIndex();

// 开始性能计时
m_GPUTimeQueries.DepthPrePassQuery = m_CommandBuffer->BeginTimestampQuery();

// 开始预深度渲染(静态物体)
Renderer::BeginRenderPass(m_CommandBuffer, m_PreDepthPass);

// 渲染所有静态网格,但只写入深度
for (auto& [mk, dc] : m_StaticMeshDrawList)
{
const auto& transformData = m_MeshTransformMap.at(mk);
// 关键:使用预深度材质,只计算深度
Renderer::RenderStaticMeshWithMaterial(
m_CommandBuffer,
m_PreDepthPipeline,
dc.StaticMesh,
dc.MeshSource,
dc.SubmeshIndex,
m_SubmeshTransformBuffers[frameIndex].Buffer,
transformData.TransformOffset,
dc.InstanceCount,
m_PreDepthMaterial // 简单的深度材质
);
}

// 渲染动态物体(非骨骼动画)
for (auto& [mk, dc] : m_DrawList)
{
const auto& transformData = m_MeshTransformMap.at(mk);
if (!dc.IsRigged) // 非骨骼动画物体
Renderer::RenderMeshWithMaterial(..., m_PreDepthMaterial);
}

Renderer::EndRenderPass(m_CommandBuffer);

// 处理骨骼动画物体
Renderer::BeginRenderPass(m_CommandBuffer, m_PreDepthAnimPass);
for (auto& [mk, dc] : m_DrawList)
{
if (dc.IsRigged) // 骨骼动画物体
{
const auto& boneTransformsData = m_MeshBoneTransformsMap.at(mk);
Renderer::RenderMeshWithMaterial(
m_CommandBuffer,
m_PreDepthPipelineAnim, // 支持骨骼动画的管线
dc.Mesh,
dc.MeshSource,
dc.SubmeshIndex,
m_SubmeshTransformBuffers[frameIndex].Buffer,
transformData.TransformOffset,
boneTransformsData.BoneTransformsBaseIndex, // 骨骼变换数据
boneTransformsData.BoneTransformsStride,
dc.InstanceCount,
m_PreDepthMaterial
);
}
}
Renderer::EndRenderPass(m_CommandBuffer);

m_CommandBuffer->EndTimestampQuery(m_GPUTimeQueries.DepthPrePassQuery);
}

预深度 Early-Z 优化:

// 在几何Pass中,深度测试设置变为:
pipelineSpecification.DepthOperator = DepthCompareOperator::Equal; // 只处理深度相等的像素
pipelineSpecification.DepthWrite = false; // 不再写入深度,只读取

// 这意味着:
// - 如果像素深度不等于预深度的结果 → 直接丢弃,不执行片段着色器
// - 只有深度完全匹配的像素才会执行复杂的光照计算

也就是说,预深度 Pass 会写一张帧缓冲,此缓冲只有深度信息,即最前面的(需要被着色的)物体的深度信息,计算着色时,将物体的深度值与预深度贴图对应的深度值进行匹配,如果匹配成功,则进行着色,匹配失败,跳过着色。

预深度 Pass 的核心思想就是用少量的计算(只算深度)换取大量的节省(避免无效的复杂光照计算),这在复杂场景中效果非常明显,特别是有很多重叠物体的情况下。

精度问题

由于预深度要将场景中的所有物体先渲染一边深度,然后在几何pass使用此深度输出,因此必须保证两次渲染的一致性,尤其是在着色器中计算gl_Position时。

在顶点着色器中,标记precise invariant gl_Position;来使用精度限定符,否则会出现一下情况,渲染时深度测试不稳定。

image

这个 precise invariant 声明确保:

  • precise:使用高精度浮点运算
  • invariant:保证相同输入产生完全相同的输出

invariant vs precise invariant 的区别:

  1. **invariant gl_Position;**:

    • 只保证相同输入产生相同输出
    • 但允许编译器进行优化重排序
    • 可能导致不同着色器间的微小差异
  2. **precise invariant gl_Position;**:

    • **precise**:禁止编译器重排序和某些优化
    • **invariant**:保证跨着色器的一致性
    • 确保完全相同的计算顺序和精度

Reversed-Z

最初由Brano Kemen在2012年提出,现在已被广泛应用于现代游戏引擎和渲染器中,包括Unreal Engine 4/5、Unity等。

传统的Z缓冲使用标准透视投影矩阵,将深度值从[near, far]映射到[0, 1]。由于IEEE 754浮点数的非线性分布特性,精度主要集中在接近0的区域,而远离相机的物体精度较低。

传统Z映射: [near, far] → [0, 1]
- 近平面 (near) → 0.0
- 远平面 (far) → 1.0

当两个面非常接近时,由于精度不足,深度测试可能产生不一致的结果,导致Z-Fighting闪烁现象。这在远距离渲染中尤为明显。

对于典型的相机设置(near=0.1, far=1000):

  • 50%的精度集中在[0.1, 0.2]范围内
  • 仅有很少的精度分配给[100, 1000]范围
  • 远距离物体几乎没有可用精度

反向Z通过将深度映射反转,使得:

反向Z映射: [near, far] → [1, 0]
- 近平面 (near) → 1.0
- 远平面 (far) → 0.0

由于浮点数在接近1.0时具有更高的精度,反向Z能够:

  1. 提高近距离精度:大部分渲染内容位于相机附近,获得更好的精度分配
  2. 改善远距离表现:远距离物体也能获得足够的精度
  3. 减少Z-Fighting:整体精度提升显著减少深度冲突

标准的透视投影矩阵为:

P_standard = [
[2n/(r-l), 0, (r+l)/(r-l), 0 ]
[ 0, 2n/(t-b), (t+b)/(t-b), 0 ]
[ 0, 0, -(f+n)/(f-n), -2fn/(f-n) ]
[ 0, 0, -1, 0 ]
]

其中:

  • n = near平面距离
  • f = far平面距离
  • l,r,t,b = 视锥体边界

反向Z投影矩阵推导

要实现反向Z映射,需要修改标准矩阵的第3行:

目标变换

  • 原始:z ∈ [n, f] → z_ndc ∈ [-1, 1] → z_depth ∈ [0, 1]
  • 反向:z ∈ [n, f] → z_ndc ∈ [1, -1] → z_depth ∈ [1, 0]

修改的矩阵元素

// 标准投影矩阵的[2][2]和[3][2]元素
P_standard[2][2] = -(f+n)/(f-n)
P_standard[3][2] = -2fn/(f-n)

// 反向Z投影矩阵的对应元素
P_reversed[2][2] = -n/(f-n) // 修改后
P_reversed[3][2] = fn/(f-n) // 修改后

反向Z的一个额外优势是天然支持无限远平面:

// 当 f → ∞ 时
P_reversed[2][2] = 0
P_reversed[3][2] = n

实现实例

相机中的投影矩阵生成反向z的矩阵:

void PerspectiveCamera::UpdateProjectionMatrix()
{
// 生成标准投影矩阵
m_Data.UnReversedDepthProjectionMatrix =
glm::perspectiveFov(glm::radians(m_Data.FOV),
(float)m_Data.Width,
(float)m_Data.Height,
m_Data.NearClip,
m_Data.FarClip);

// 复制为反向Z矩阵
m_Data.ProjectionMatrix = m_Data.UnReversedDepthProjectionMatrix;

// 修改Z映射:[near,far] → [0,1] 变为 [near,far] → [1,0]
m_Data.ProjectionMatrix[2][2] = -m_Data.NearClip / (m_Data.FarClip - m_Data.NearClip);
m_Data.ProjectionMatrix[3][2] = (m_Data.FarClip * m_Data.NearClip) / (m_Data.FarClip - m_Data.NearClip);
}

深度Pass的深度清除值为0.0f.

深度测试函数改为GreaterOrEqual

其他不变。

HZB(Hierarchical Z Buffer)

HZB 的核心思想是用 空间换时间:预先计算好多个分辨率的深度信息,让后续的遮挡查询、光线追踪等操作能够快速跳过大块的空白区域,大幅提升渲染效率。

绘制不同比例的深度图,这是查询一个物体是否被遮挡时,可以先根据物体的大小选择合适的 mip 级别,查询该区域的最远深度与物体的最近深度进行对比,如果物体的最近深度比该区域的最远深度大,说明物体被完全遮挡,否则使用更精细的 mip 进一步检查。

// 有一座大山(占屏幕400x300像素)挡在前面
// 山后面有一座小房子,我们要判断房子是否可见

// 传统方法:检查房子覆盖的每个像素(可能几万个像素)
// HZB方法:
int mipLevel = log2(400) = 8; // 选择第8级mip
float mountainDepth = SampleHZB(mountainCenter, 8); // 只需要1次纹理采样!
if (houseDepth > mountainDepth)
skipRendering(house); // 直接跳过房子的渲染

HZB 的核心思想就是分而治之:

  • 大区域用粗糙的信息快速判断
  • 小区域用精细的信息准确判断
  • 根据需要动态选择精度级别

这样既保证了准确性,又大幅提升了性能。就像你看地图时,先看全国地图找到大概方向,再看省级地图,最后看详细街道图一样。

PreIntergration

想象你要在一面湖水中看倒影,但湖面有波纹:

  • 传统方法:从每个角度都重新计算光线如何在波纹表面反射
  • 预积分方法:预先计算好不同粗糙度表面的 “平均反射效果”,需要时直接查表

SSR(Screen Square Reflection)

传统反射渲染从镜子的角度将场景渲染一遍,性能消耗翻倍,每个镜子都要这么做,场景渲染次数一直增加。

屏幕空间反射: 利用已经渲染的场景(所有像素的颜色和深度),对于需要反射的表面,计算反射光方向,沿着反射方向在已渲染的图像中查找对应的颜色,如果找到就作为反射的颜色,如果没找到,就显示天空的颜色。

SSR 的局限性,如果需要计算反射的像素在屏幕空间外,则无法拿到,需要用一些策略缓解:

  1. 智能 Fallback - 在 SSR 失效时平滑过渡到其他技术
  2. 扩展数据 - 渲染比显示更多的区域
  3. 时域复用 - 利用前几帧的信息
  4. 分层策略 - 重要的反射用高端技术,次要的用简单方法
  5. 预计算置信度 - 提前知道哪里 SSR 会失效

Light Culling

传统光照的性能灾难:如果场景中有一千盏灯,即使你无法看到所有的灯,也会计算这一千盏灯对场景的影响。

问题的本质:

(1)距离衰减: 现实中 光照的强度 = 光源强度 / (距离的平方),距离 50m 时,强度 = 100 / (50²) = 0.04,基本上看不出来了,但传统方法还是会计算这个量

(2)屏幕空间局限性: 一个像素实际上只会收到附近几盏灯的影响

光照剔除的基本原理

核心思想: 分块处理,将屏幕切成小格子,每个格子只关心影响到它的灯光。

两阶段处理:

  1. 光照剔除:着色器中,先确定当前工作组负责的屏幕块,计算这个块在世界空间中的范围,构建该块的 Frustum,测试每盏灯是否与其相交,将测试结果写入全局缓冲区;
  2. 像素着色:在像素着色器中,先确定当前像素属于哪个块,获取该块的灯光列表,只计算列表中灯光的贡献。

屏幕空间块到世界空间有一个转换,即通过近平面的范围,构建一个视锥体,该视锥体内的灯光才影响此屏幕空间块的着色。

深度范围的计算方法

float CalculateTileDepthRange(ivec2 tileID)
{
float minDepth = 1000.0; // 很远
float maxDepth = 0.0; // 很近(反向Z)

// 遍历该块中所有像素的深度
for (int y = 0; y < 16; y++)
{
for (int x = 0; x < 16; x++)
{
ivec2 pixelCoord = tileID * 16 + ivec2(x, y);
float pixelDepth = SampleDepthBuffer(pixelCoord);

minDepth = min(minDepth, pixelDepth); // 最近的物体
maxDepth = max(maxDepth, pixelDepth); // 最远的物体
}
}

return TileDepthRange(minDepth, maxDepth);
}

// 优化版本:使用HZB加速
float CalculateTileDepthRangeOptimized(ivec2 tileID)
{
// 直接从HZB的对应mip级别读取
// 16x16块对应HZB的某个mip级别
int mipLevel = CalculateMipForTileSize(16);
vec2 hzbUV = (tileID + 0.5) / numTiles;

float blockDepth = textureLod(HZBTexture, hzbUV, mipLevel).r;
return blockDepth; // HZB已经包含了该区域的深度信息
}
显示评论