多 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) { 执行片段着色器(); if (depthWrite) { depthBuffer[x][y] = currentDepth; } 写入颜色到帧缓冲区(); } else { 丢弃片段(); } }
|
预深度渲染的深度测试流程
第一步:预深度通道
depthBuffer[所有像素] = 1.0f;
DepthOperator = LessOrEqual; DepthWrite = true;
for (鸭子的每个片段) { float duckDepth = 0.6f; if (duckDepth <= 1.0f) { depthBuffer[x][y] = 0.6f; } }
|
第二步:几何体通道
DepthOperator = LessOrEqual; DepthWrite = false;
for (鸭子的每个片段) { float duckDepth = 0.6f; if (duckDepth <= 0.6f) { 执行完整的PBR片段着色器(); 写入颜色到帧缓冲区(); } }
|
为什么预深度渲染能提高性能?
传统渲染:
渲染所有物体 → 执行昂贵的片段着色器 → 深度测试 → 丢弃被遮挡的片段
|
问题:被遮挡的片段浪费了片段着色器的计算
预深度渲染:
预深度通道 → 建立深度缓冲区 → 几何体通道 → Early-Z剔除大部分片段 → 只对可见片段执行昂贵的着色器
|
优势:昂贵的片段着色器只在必要时执行
ShadowMap
阴影贴图,通过深度信息来判断物体距离光源的前后遮挡关系,
ShadowMapPass(); SpotShadowMapPass();
|
层级阴影贴图
传统的阴影贴图使用单一的深度纹理来存储整个场景的阴影信息,但会遇到以下问题:
- 近处阴影质量差:由于一张贴图要覆盖整个场景,近处物体分配到的像素很少
- 远处浪费精度:远处不重要的区域却占用了大量贴图空间
- 透视混叠:在视角变化时会出现明显的阴影锯齿
CSM将相机的视锥体(viewing frustum)分割成多个层级,每个层级使用独立的阴影贴图:
- 分层渲染:
- 第1层:覆盖相机附近0-50米,使用高精度阴影
- 第2层:覆盖50-200米,使用中等精度
- 第3层:覆盖200-800米,使用较低精度
- 第4层:覆盖800米以上,使用最低精度
- 动态调整:
- 根据相机位置动态调整每层的覆盖范围
- 近处获得更多的阴影细节
- 远处保持基本的阴影效果
- 平滑过渡:
- 在层级边界处进行混合,避免突兀的质量跳跃
- 使用淡入淡出效果
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; 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 优化:
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
的区别:
**invariant gl_Position;
**:
- 只保证相同输入产生相同输出
- 但允许编译器进行优化重排序
- 可能导致不同着色器间的微小差异
**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能够:
- 提高近距离精度:大部分渲染内容位于相机附近,获得更好的精度分配
- 改善远距离表现:远距离物体也能获得足够的精度
- 减少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]
修改的矩阵元素:
P_standard[2][2] = -(f+n)/(f-n) P_standard[3][2] = -2fn/(f-n)
P_reversed[2][2] = -n/(f-n) P_reversed[3][2] = fn/(f-n)
|
反向Z的一个额外优势是天然支持无限远平面:
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); m_Data.ProjectionMatrix = m_Data.UnReversedDepthProjectionMatrix; 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 进一步检查。
int mipLevel = log2(400) = 8; float mountainDepth = SampleHZB(mountainCenter, 8); if (houseDepth > mountainDepth) skipRendering(house);
|
HZB 的核心思想就是分而治之:
- 大区域用粗糙的信息快速判断
- 小区域用精细的信息准确判断
- 根据需要动态选择精度级别
这样既保证了准确性,又大幅提升了性能。就像你看地图时,先看全国地图找到大概方向,再看省级地图,最后看详细街道图一样。
PreIntergration
想象你要在一面湖水中看倒影,但湖面有波纹:
- 传统方法:从每个角度都重新计算光线如何在波纹表面反射
- 预积分方法:预先计算好不同粗糙度表面的 “平均反射效果”,需要时直接查表
SSR(Screen Square Reflection)
传统反射渲染从镜子的角度将场景渲染一遍,性能消耗翻倍,每个镜子都要这么做,场景渲染次数一直增加。
屏幕空间反射: 利用已经渲染的场景(所有像素的颜色和深度),对于需要反射的表面,计算反射光方向,沿着反射方向在已渲染的图像中查找对应的颜色,如果找到就作为反射的颜色,如果没找到,就显示天空的颜色。
SSR 的局限性,如果需要计算反射的像素在屏幕空间外,则无法拿到,需要用一些策略缓解:
- 智能 Fallback - 在 SSR 失效时平滑过渡到其他技术
- 扩展数据 - 渲染比显示更多的区域
- 时域复用 - 利用前几帧的信息
- 分层策略 - 重要的反射用高端技术,次要的用简单方法
- 预计算置信度 - 提前知道哪里 SSR 会失效
Light Culling
传统光照的性能灾难:如果场景中有一千盏灯,即使你无法看到所有的灯,也会计算这一千盏灯对场景的影响。
问题的本质:
(1)距离衰减: 现实中 光照的强度 = 光源强度 / (距离的平方)
,距离 50m 时,强度 = 100 / (50²) = 0.04
,基本上看不出来了,但传统方法还是会计算这个量
(2)屏幕空间局限性: 一个像素实际上只会收到附近几盏灯的影响
光照剔除的基本原理
核心思想: 分块处理,将屏幕切成小格子,每个格子只关心影响到它的灯光。
两阶段处理:
- 光照剔除:着色器中,先确定当前工作组负责的屏幕块,计算这个块在世界空间中的范围,构建该块的 Frustum,测试每盏灯是否与其相交,将测试结果写入全局缓冲区;
- 像素着色:在像素着色器中,先确定当前像素属于哪个块,获取该块的灯光列表,只计算列表中灯光的贡献。
屏幕空间块到世界空间有一个转换,即通过近平面的范围,构建一个视锥体,该视锥体内的灯光才影响此屏幕空间块的着色。
深度范围的计算方法
float CalculateTileDepthRange(ivec2 tileID) { float minDepth = 1000.0; float maxDepth = 0.0; 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); }
float CalculateTileDepthRangeOptimized(ivec2 tileID) { int mipLevel = CalculateMipForTileSize(16); vec2 hzbUV = (tileID + 0.5) / numTiles; float blockDepth = textureLod(HZBTexture, hzbUV, mipLevel).r; return blockDepth; }
|