多Pass渲染
Dionysen

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

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

ShadowMap

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

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

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

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已经包含了该区域的深度信息
}
显示评论