Unity自定义渲染管线实现屏幕空间反射(SSR)全解析
1. 项目概述:为什么要在自定义管线里折腾SSR?
屏幕空间反射,也就是大家常说的SSR,几乎是现代3A游戏和高质量实时渲染的标配。它能让你在光滑的地板、湿润的街道或者金属表面上看到周围环境的动态倒影,那种真实感是传统立方体贴图反射完全无法比拟的。但如果你在Unity里用过内置渲染管线的SSR Post Processing效果,或者URP/HDRP里那个屏幕空间反射Pass,多半会跟我有一样的感受:效果不错,但可控性太差,性能开销是个黑盒,而且一旦想做一些定制化的效果,比如只让特定物体参与反射、调整不同区域的反射强度,或者整合一些特殊的扭曲效果,就感觉束手束脚。
这就是我决定在Unity自定义渲染管线里亲手实现一套SSR系统的根本原因。我不想再被封装好的效果限制住手脚。通过自定义管线,我们可以从最底层的CommandBuffer开始,完全掌控渲染的每一个步骤:从深度/法线图的获取,到射线步进追踪的算法实现,再到最后的双边滤波与合成。这个过程不仅能让你彻底吃透SSR的技术原理,更能让你获得一种“我的渲染我做主”的终极自由。无论是为了极致优化,还是为了实现某种独特的艺术风格,自己实现的SSR都是不可或缺的一环。
2. 核心原理拆解:SSR到底是怎么“算”出反射的?
在深入代码之前,我们必须把SSR的核心算法原理掰开揉碎讲清楚。它不是一个魔法黑盒,其本质是在屏幕空间内,通过模拟光线的反射行为,去“查找”反射颜色的过程。整个过程可以分解为几个清晰的步骤。
2.1 数据准备:我们需要什么样的“地图”?
SSR完全在屏幕空间(即当前帧渲染出的图像)进行操作,因此它严重依赖几张关键的缓冲区纹理。
深度图:这是最重要的数据源。它存储了每个像素在观察空间或世界空间中的深度值(Z值)。没有深度信息,我们就无法知道屏幕上的一个点对应着3D空间中的哪个位置,后续的射线计算也就无从谈起。在自定义管线中,我们通常会在不透明物体渲染完毕后,将深度信息单独写入一张RT。
法线图:同样关键。反射的方向由物体表面的法线决定。我们需要每个像素的世界空间法线或观察空间法线,来计算入射视角方向对应的反射方向。通常我们会将法线信息编码到一张RT中,或者从GBuffer中获取。
颜色图:即当前帧渲染好的场景主纹理。当我们的反射射线最终命中某个“屏幕位置”时,就需要从这个颜色图中采样,来获取反射看到的颜色。注意,这是反射内容的来源,但它不包含当前帧尚未渲染的物体(如被遮挡的物体),这是SSR的固有局限性之一。
反射遮罩图:这是一张可选的、但极其有用的控制图。我们可以通过一张额外的RT或利用现有纹理的某个通道(如Albedo的Alpha),来标记哪些物体应该产生高质量的SSR反射(如金属、水面),哪些物体不应该产生(如毛发、布料)。这能让我们进行精细的性能与效果控制。
注意:在自定义管线中管理这些RT需要格外小心。你必须清晰地规划它们的生命周期:何时创建、何时绑定、何时渲染、何时释放。一个常见的优化是复用RT,例如将法线信息编码到深度图的RG通道,但这会增加Shader解码的复杂度。
2.2 射线步进追踪:在2D屏幕上进行3D搜索
这是SSR算法的核心,也是最耗性能的部分。其目标是为当前屏幕的每一个像素(我们称之为P),找到其反射光线击中的屏幕位置(我们称之为Q)。
计算反射向量:对于像素P,我们已有其世界空间位置(通过深度图重建)和世界空间法线。根据视角方向(摄像机位置 - 表面点位置)和表面法线,利用标准的反射公式
reflect(viewDir, normal)计算出反射方向R。在屏幕空间中进行步进:我们无法直接在3D空间中无限追踪这条射线。SSR的巧妙之处在于,它在观察空间中,沿着反射向量
R一步步前进。每一步,我们得到一个3D采样点S。- 核心计算:
S = P_View + R * stepLength * stepIndex。这里P_View是像素P在观察空间中的坐标。
- 核心计算:
将3D点投影回2D屏幕:将每一步得到的3D采样点
S,通过摄像机的投影矩阵(和相同的VP矩阵)变换回屏幕空间坐标(uv, depth)。这个depth是采样点S在屏幕深度图中的预期深度值。深度测试:比较采样点S投影后的屏幕坐标
uv处,从深度图中采样得到的实际场景深度值sceneDepth,与我们计算出的采样点预期深度值sampledDepth。- 如果
sampledDepth > sceneDepth,说明我们的射线点S还在场景表“面前方”,尚未击中任何物体,继续步进。 - 如果
sampledDepth <= sceneDepth(通常需要一个微小的偏差bias来处理精度问题),则说明射线点S已经“穿透”到了场景表面之后,即发生了相交。此时,uv就被认为是反射命中点Q的屏幕坐标。
- 如果
二分搜索优化:在粗略的步进找到相交区间后(即某一步发现
sampledDepth从大于sceneDepth变为小于sceneDepth),可以在该区间内进行几次二分查找,以更精确地定位命中点,减少锯齿感。
2.3 后处理与合成:让反射看起来更真实
即使找到了命中点,直接采样颜色贴图得到的反射往往充满噪点、锯齿和错误。必须经过一系列后处理。
双边滤波:这是平滑SSR噪点的关键。普通的模糊会抹掉反射的边缘细节。双边滤波在模糊时会考虑颜色差异和深度/法线差异,从而在平滑内部区域的同时,保留物体边缘的锐利度。通常需要水平、垂直各做一次。
边缘淡化:当反射射线射向屏幕边缘或背景(深度值很大)时,命中会变得不可靠。我们需要根据射线步进的次数、命中点距离屏幕边缘的距离等因素,逐步将反射强度淡出至0。
与场景合成:最后,将处理好的反射纹理,根据反射遮罩(如金属度、光滑度)叠加到最终的渲染画面上。这里通常使用加法混合或基于菲涅尔效应的混合,让高光角度的反射更强。
3. 在自定义渲染管线中的架构设计与实现
在Unity内置管线或SRP(可编程渲染管线,如URP)中做后处理,和在自己搭建的自定义管线中做,完全是两码事。后者要求你对整个渲染流程有全局的掌控力。
3.1 管线框架选择与设置
Unity提供了两种主要的自定义管线方式:传统的CommandBuffer流和正式的Scriptable Render Pipeline。对于深入学习SSR,我强烈建议从CommandBuffer开始,因为它更底层、更直观。
创建管线资产和实例:即使使用CommandBuffer,一个好的实践也是创建一个
RenderPipelineAsset和对应的RenderPipeline实例。这能让你接管Unity的整个渲染循环。在RenderPipeline的Render方法中,你可以完全定义先渲染什么,后渲染什么。渲染目标管理:这是自定义管线中最容易出错的地方。你需要显式地创建、分配和释放所有RT。对于SSR,我们至少需要:
_CameraDepthTexture: 通过Shader.GlobalKeyword启用_CameraDepthTexture,并在相机设置中指定camera.depthTextureMode = DepthTextureMode.Depth;。_CameraNormalsTexture: 同样需要启用并渲染。我们可以编写一个单独的ShaderPass,在不透明物体渲染后,将世界法线渲染到一张RT中。_MainColorTexture: 即场景的主颜色RT。_SSRTempTexture1/2: 用于中间计算的临时RT。
我的经验是,在管线的
Render方法开始时,使用RenderTexture.GetTemporary申请所需格式和大小的RT,并在该帧渲染结束时用RenderTexture.ReleaseTemporary释放。务必注意RT的格式(RenderTextureFormat),深度图通常用RFloat或RHalf,法线图用ARGBFloat或编码到ARGB2101010。
3.2 核心Shader实现详解
SSR的算法主体在一个Fragment Shader中完成。下面我将分块解析关键代码。
a. 数据重建与反射向量计算
// 从深度纹理和屏幕UV重建世界空间位置 float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv); float4 clipPos = float4(uv * 2.0 - 1.0, rawDepth, 1.0); // 转换到NDC空间 float4 worldPos = mul(unity_CameraToWorld, mul(unity_CameraInvProjection, clipPos)); worldPos.xyz /= worldPos.w; // 获取世界空间法线(从法线纹理解码) float3 worldNormal = DecodeWorldNormal(SAMPLE_TEXTURE2D(_CameraNormalsTexture, sampler_CameraNormalsTexture, uv)); // 计算视角方向与反射方向 float3 viewDir = normalize(_WorldSpaceCameraPos - worldPos.xyz); float3 reflectDir = reflect(-viewDir, worldNormal);实操心得:矩阵乘法顺序和坐标空间转换是新手最大的坑。务必清楚你手中的深度是在哪个空间(通常是观察空间线性深度或NDC深度),并准备好正确的逆投影矩阵和相机视图矩阵。在Shader中打印中间值(如通过
return float4(worldPos.xxx, 1))到屏幕是调试的不二法门。
b. 屏幕空间射线步进循环
float3 rayPos = worldPos; float stepSize = initialStep; // 初始步长,可根据距离调整 float hit = 0; float2 hitUV = uv; for (int i = 0; i < MAX_STEPS; i++) { rayPos += reflectDir * stepSize; // 将rayPos变换到屏幕空间 float4 projectedPos = mul(UNITY_MATRIX_VP, float4(rayPos, 1.0)); projectedPos.xyz /= projectedPos.w; float2 screenUV = projectedPos.xy * 0.5 + 0.5; // 检查是否超出屏幕边界 if (any(screenUV < 0) || any(screenUV > 1)) { hit = 0; break; } // 采样深度图,重建该屏幕点处的世界空间深度 float sceneDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenUV), _ZBufferParams); float rayDepth = -mul(UNITY_MATRIX_V, float4(rayPos, 1.0)).z; // 观察空间深度 // 深度比较(带偏差) if (rayDepth > sceneDepth + depthBias) { // 尚未命中,继续 stepSize *= stepScale; // 可以逐步增加步长,加速搜索 } else { // 可能命中了,进入二分搜索细化 hit = 1; hitUV = screenUV; // ... 二分搜索代码 ... break; } }注意事项:
MAX_STEPS(总步数)和stepScale(步长缩放系数)是需要精心调校的性能与质量权衡参数。步数太少,射线走不远,远处反射会缺失;步数太多,性能开销剧增。一个常见的技巧是使用分层步进:前几步用较小步长保证近处精度,后几步用较大步长快速覆盖远处。
c. 命中后处理与颜色采样找到hitUV后,事情还没完。
if (hit > 0.5) { // 1. 边缘淡化:根据hitUV距离屏幕边界的距离进行淡化 float2 fade = saturate(hitUV * (1 - hitUV) * EDGE_FADE_SCALE); float edgeFade = fade.x * fade.y; // 2. 根据射线步进距离进行淡化(模拟距离衰减) float distanceFade = 1.0 - saturate(rayLength / MAX_RAY_DISTANCE); // 3. 采样主颜色纹理 float4 reflectionColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, hitUV); // 4. 应用淡化 reflectionColor.a *= edgeFade * distanceFade; return reflectionColor; } else { // 未命中,可以返回0,或者回退到环境立方体贴图颜色 return float4(0, 0, 0, 0); }3.3 完整的渲染Pass编排
在自定义管线的Render函数中,你需要像导演一样编排各个Pass的执行顺序:
// 1. 清空并设置主RT cmd.BeginSample("MainRendering"); cmd.SetRenderTarget(_mainColorRT, _depthRT); cmd.ClearRenderTarget(true, true, Color.clear); // ... 渲染所有不透明物体到 _mainColorRT ... cmd.EndSample("MainRendering"); // 2. 渲染法线RT(如果需要单独的Pass) cmd.BeginSample("RenderNormals"); cmd.SetRenderTarget(_normalsRT); cmd.ClearRenderTarget(true, true, Vector4.zero); // 法线初始值可以是(0,0,0,0) // ... 使用特定的Shader Pass渲染世界法线到 _normalsRT ... cmd.EndSample("RenderNormals"); // 3. 执行SSR计算Pass cmd.BeginSample("ScreenSpaceReflection"); Material ssrMaterial = GetSSRMaterial(); // 获取包含上述Shader的材质 cmd.SetGlobalTexture("_MainTex", _mainColorRT); cmd.SetGlobalTexture("_CameraDepthTexture", _depthRT); cmd.SetGlobalTexture("_CameraNormalsTexture", _normalsRT); // 设置其他参数:_MaxSteps, _StepSize, _DepthBias等 cmd.Blit(_mainColorRT, _ssrTempRT1, ssrMaterial, 0); // 将SSR结果输出到临时RT cmd.EndSample("ScreenSpaceReflection"); // 4. 对SSR结果进行双边滤波(可分离滤波,分两个Pass) cmd.BeginSample("BilateralFilterHorizontal"); Material bilateralMaterial = GetBilateralMaterial(); cmd.SetGlobalTexture("_BlurSource", _ssrTempRT1); cmd.SetGlobalFloat("_BlurDirection", 1); // 1 for horizontal cmd.Blit(_ssrTempRT1, _ssrTempRT2, bilateralMaterial, 0); cmd.EndSample("BilateralFilterHorizontal"); cmd.BeginSample("BilateralFilterVertical"); cmd.SetGlobalTexture("_BlurSource", _ssrTempRT2); cmd.SetGlobalFloat("_BlurDirection", 0); // 0 for vertical cmd.Blit(_ssrTempRT2, _ssrTempRT1, bilateralMaterial, 0); cmd.EndSample("BilateralFilterVertical"); // 5. 合成Pass:将模糊后的SSR反射叠加到主颜色 cmd.BeginSample("Composite"); Material compositeMaterial = GetCompositeMaterial(); cmd.SetGlobalTexture("_ReflectionTex", _ssrTempRT1); cmd.Blit(_mainColorRT, _finalColorRT, compositeMaterial, 0); // 叠加反射 cmd.EndSample("Composite"); // 6. 将最终结果 blit 到相机目标 cmd.Blit(_finalColorRT, BuiltinRenderTextureType.CameraTarget);这个流程清晰地展示了数据如何在不同Pass间流动。每个BeginSample和EndSample不仅有助于在Frame Debugger中查看性能,更是组织复杂渲染逻辑的好习惯。
4. 性能优化与效果调校实战录
实现基础功能只是第一步,让SSR在项目中真正可用,必须过优化和调校这一关。
4.1 性能优化策略
降低分辨率渲染:这是最有效的一招。SSR不需要全分辨率。以半分辨率(甚至1/4分辨率)进行射线追踪和初始滤波,最后再上采样合成到全分辨率画面,性能提升可达3-4倍,而视觉质量损失在多数情况下难以察觉。只需在申请临时RT时,将宽度高度除以2即可。
自适应步长与最大步数:不要对所有像素使用统一的步数和步长。对于粗糙表面(法线杂乱)或预期反射距离很远的像素,可以提前终止步进。可以在Shader中根据法线方差或深度值,动态计算
maxSteps和initialStepSize。分块剔除与延迟执行:高级技巧。利用计算着色器,先对屏幕分块,分析每块区域的深度范围、法线变化,判断该区域是否“值得”进行SSR计算(例如,全是天空盒或深度变化极小的墙面区域),对不需要计算的块直接跳过。这需要较深的Compute Shader功底。
优化Shader指令:
- 减少循环内的复杂计算,将能提前的计算移到循环外。
- 善用
mad(乘加)指令。 - 避免在射线步进循环内进行分支判断(
if),但这点在SSR中很难完全避免,可以尝试将深度比较改为使用step()等函数进行无分支近似。
4.2 效果调校参数详解
SSR的效果高度依赖一组“魔法数字”。下面这个表格是我经过多个项目调试后总结的常用参数范围和建议起点:
| 参数名 | 作用 | 建议起始值 | 调整技巧 |
|---|---|---|---|
_MaxSteps | 射线最大步进次数 | 32 - 64 | 近景特写可提高到96,远景拉低至24。性能敏感区。 |
_StepSize | 射线初始步长 | 0.05 - 0.1 | 值越小,近处反射越精确,但走不远。可尝试根据射线长度动态增加。 |
_DepthBias | 深度比较偏差 | 0.01 - 0.1 | 用于防止自相交(表面反射出自身)。过小会有噪点,过大会导致反射“漂浮”。 |
_RayHitThreshold | 命中阈值 | 0.1 - 0.3 | 在二分搜索中,当深度差小于此值时认为命中。影响反射的锐利度。 |
_EdgeFade | 屏幕边缘淡化强度 | 10.0 - 20.0 | 值越大,边缘淡出越快,能有效隐藏屏幕边缘的瑕疵。 |
_DistanceFade | 射线距离淡化 | 50.0 - 100.0 | 控制反射随距离衰减的速度。模拟现实世界中远处反射细节丢失。 |
_JitterStrength | 采样抖动强度 | 0.1 - 0.5 | 在每帧对射线起点施加微小的随机偏移,可以将固定模式的噪点转化为随时间变化的电影颗粒感,在TAA下效果更佳。 |
调校流程建议:不要同时调整所有参数。首先固定_MaxSteps和_StepSize,找到一个能覆盖你需要的最远反射距离的基线。然后调整_DepthBias消除自相交瑕疵。接着用_EdgeFade处理屏幕边缘问题。最后,再微调_DistanceFade和_JitterStrength来优化整体观感。
5. 常见问题、排查与进阶技巧
自己实现SSR,就是不断踩坑和填坑的过程。这里记录下我遇到的一些典型问题及解决方案。
5.1 问题排查清单
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 反射完全黑色或不存在 | 1. 深度/法线纹理未正确绑定。 2. 世界空间位置重建错误。 3. 射线步进方向错误。 | 1. 在Frame Debugger中检查相关Pass的输入RT是否正确。 2. 在Shader中可视化 worldPos(返回float4(worldPos.xxx, 1)),检查是否在合理范围。3. 可视化 reflectDir,检查方向是否指向预期反射方向。 |
| 反射有严重的“拉丝”或条纹状瑕疵 | 1. 步长(_StepSize)太大。2. 深度偏差( _DepthBias)太小。3. 缺少二分搜索或二分搜索迭代次数不足。 | 1. 减小_StepSize,增加_MaxSteps。2. 适当增加 _DepthBias。3. 确保实现了3-5次二分搜索迭代。 |
| 反射在物体边缘闪烁或抖动 | 1. 深度值在边缘不连续,导致重建位置跳变。 2. 缺少边缘淡化( _EdgeFade)。3. 时间性抗锯齿未正确应用。 | 1. 这是SSR固有缺陷,可尝试在边缘处基于深度差强行淡出反射。 2. 加强 _EdgeFade参数。3. 结合TAA,将历史帧的SSR结果进行混合。 |
| 性能开销巨大 | 1. 全分辨率计算。 2. _MaxSteps设置过高。3. Shader计算过于复杂。 | 1.立即改为半分辨率计算。 2. 根据场景调整 _MaxSteps,使用距离相关的步数控制。3. 简化Shader,将法线编码、矩阵计算等移至顶点着色器或预计算。 |
| 反射中缺失某些物体 | 该物体未写入深度/法线纹理。 | 检查物体的Shader是否渲染到了深度和法线Pass。透明物体通常不写入深度,这是SSR的局限。 |
5.2 进阶技巧:提升质量与鲁棒性
结合平面反射探针:对于重要的、大的平面反射体(如地面、水面),可以单独为其设置一个低分辨率的平面反射探针,然后将SSR的结果与探针的结果混合。SSR负责动态物体和细节,探针提供稳定的基底反射,效果和性能兼得。
历史帧重投影与TAA:这是消除SSR闪烁、实现高质量反射的终极武器。原理是将上一帧的SSR结果,根据相机和物体的运动,重投影到当前帧,并与当前帧的结果进行时域混合。实现复杂,但能极大提升稳定性和图像质量。
基于粗糙度的模糊:现实世界中,粗糙的表面反射是模糊的。我们可以根据物体材质的光滑度(或粗糙度)参数,在双边滤波后,额外施加一个可变半径的高斯模糊。粗糙度越高,模糊半径越大。
距离场加速:这是一个更前沿的优化思路。预计算场景的 Signed Distance Field,在射线步进时,利用SDF信息实现“超大步”跳跃,快速跳过空白区域,大幅减少步进次数。这需要额外的预处理和存储开销,适用于静态场景居多的项目。
实现自定义管线下的SSR,就像亲手打造一件精密仪器。从最初连深度图都采不对,到最终能稳定跑出丝滑的反射,中间每一步的调试和思考都是宝贵的经验。它带给你的不仅仅是屏幕上的那抹倒影,更是对实时渲染底层逻辑的深刻理解。当你能够随心所欲地控制反射的强度、模糊、距离衰减,甚至将它与其他后期效果(如屏幕空间环境光遮蔽、体积光)完美结合时,那种创造力和掌控感,是使用现成方案永远无法提供的。
