ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity URP屏幕空间高光反射(SSSR)实现:原理、代码与移动端优化

2026/8/7 5:32:04 拓冰建站 浏览量
Unity URP屏幕空间高光反射(SSSR)实现:原理、代码与移动端优化

1. 项目概述:为什么是SSSR,而不是光追?

最近在社区和群里,看到不少朋友,尤其是刚接触图形学或者Unity不久的同学,一提到“反射”,第一反应就是“上光追”。诚然,实时光线追踪(Ray Tracing)能提供物理上最精确的反射、阴影和全局光照效果,但它对硬件的要求,以及随之而来的性能开销,在当下的移动端和大量中低端PC上,依然是难以承受之重。我们做项目,尤其是商业项目,性能和效果的平衡永远是第一位的。这时候,屏幕空间反射(Screen Space Reflection, SSR)技术就成了一个极具性价比的选择。

SSR,顾名思义,它的所有信息都来自于当前帧的屏幕缓冲区(如深度图、法线图、颜色图)。它不需要预计算,能动态响应场景变化,反射内容完全基于玩家当前视野所见,效果真实且即时。而SSSR(Screen Space Specular Reflection)则更聚焦于高光反射部分,这是场景中视觉冲击力最强、最需要精细处理的部分。在Unity的通用渲染管线(URP)中,实现一套高效、高质量的SSSR,是提升项目画面质感,同时保持高性能的关键技术之一。

今天,我就把手头一个经过多个项目验证、稳定可靠的URP SSSR实现方案,从原理到代码,完整地拆解给大家。这套方案避开了那些过于学术化、难以调试的复杂算法,采用更工程化、更易于理解的“步进+二分”搜索法,并针对移动端和PC端做了不同的优化策略。你会发现,不用死磕光追,你的项目画面也能立刻上一个档次。

2. 核心原理拆解:SSSR到底是怎么“算”出来的?

要理解SSSR,我们得先忘掉那些复杂的积分方程,从一个最直观的视角出发:对于一个屏幕上的像素点(我们称之为着色点,Shading Point),我们想知道它应该反射出什么颜色。

2.1 第一步:反射光线的生成与屏幕空间投射

这个过程始于着色点本身的属性:它在世界空间中的位置(通过深度缓冲重建)和法线方向。根据基础的反射定律,我们可以计算出入射光线(从摄像机到着色点)关于法线的反射方向。这条反射光线,就是我们去“寻找”反射颜色的探针。

关键的一步来了:我们不会让这条光线在真正的3D场景中无限穿梭(那是光追的做法)。相反,我们把它“投影”回屏幕空间。想象一下,我们沿着这条反射光线,一步一步地向前“走”,每走一步,我们就记录下这个步进点对应的屏幕UV坐标和深度值。这个“屏幕UV坐标”告诉我们,如果这个步进点能被摄像机看到,它会出现在屏幕的哪个像素上;而“深度值”则告诉我们,这个步进点离摄像机有多远。

2.2 第二步:光线步进与深度测试——寻找交点

现在,我们有了一个沿着反射光线的、在屏幕空间中的点序列。我们的目标,是找到第一个点,使得这个点的“实际深度”(即场景中该屏幕位置处物体的深度)与我们从光线步进中计算出的“光线深度”非常接近。这就意味着,我们的反射光线“撞”到了屏幕中的某个物体。

这个过程就是光线步进(Ray Marching)。我们从着色点开始,以一定的步长(Step)沿着反射方向前进。每一步,我们都进行如下判断:

  1. 将当前步进点的世界坐标,转换到屏幕UV坐标。
  2. 采样当前屏幕UV坐标处的深度纹理(_CameraDepthTexture),得到场景在该处的实际深度(Scene Depth)。
  3. 计算当前步进点自身的深度(Ray Depth)。
  4. 比较 Ray Depth 和 Scene Depth。如果 Ray Depth > Scene Depth,说明我们的光线点已经“穿入”了场景物体内部,那么上一个步进点很可能就是交点附近。

但是,步进是一个比较粗糙的过程。直接取上一个点作为交点,精度不够,反射边缘会闪烁和锯齿。所以,我们通常会在检测到穿透(Ray Depth > Scene Depth)后,在最后一步的区间内,进行一次二分查找(Binary Search)。二分查找能快速地将交点位置收敛到一个非常精确的范围内,极大地提升反射边缘的质量。

2.3 第三步:采样与后处理——得到最终颜色

一旦通过步进+二分找到了精确的交点屏幕UV坐标,剩下的事情就简单了。我们直接从这个UV坐标采样屏幕颜色纹理(_CameraOpaqueTexture),得到的颜色就是反射颜色。然后,根据着色点的粗糙度(Roughness)或高光强度,将这个反射颜色与原本的着色结果进行混合。

这里有一个非常重要的细节:屏幕空间的局限性。SSR只能反射那些在当前摄像机视野内的物体。如果反射光线指向摄像机背后,或者指向一个虽然在世界中存在但当前帧没有被渲染的物体(比如被墙挡住的物体),那么SSR将无法找到有效的交点。这是SSR技术的固有缺陷,通常的应对策略是:当搜索失败时,回退到一个预制的立方体贴图(Cubemap)或一个模糊的底色,以避免出现难看的黑色或错误反射。

3. URP下的实现蓝图:渲染流程与资源准备

在URP中实现SSSR,我们通常选择在后期处理(Post Processing)阶段进行。因为SSR需要完整的场景深度和颜色信息,这些信息在渲染不透明物体之后才完全可用。

3.1 渲染纹理(Render Texture)的配置

我们需要在URP渲染管线中,确保以下几张关键纹理被正确生成和传递:

  • _CameraDepthTexture: 这是最重要的。在URP渲染器设置中,务必开启“Depth Texture”选项。它存储了每个像素的线性眼空间深度或设备深度(取决于平台和设置)。
  • _CameraOpaqueTexture: 同样需要在URP渲染器设置中开启“Opaque Texture”。它是在所有不透明物体渲染完成后,捕获的屏幕颜色图,是SSR反射颜色的来源。
  • _CameraNormalsTexture(可选但推荐): 屏幕空间法线图。虽然我们可以从深度重建世界位置后再计算法线,但直接使用法线纹理更高效、更准确,尤其是在有法线贴图的物体上。这需要在URP Asset中配置一个Renderer Feature来绘制法线到纹理。

注意:开启这些纹理会产生额外的显存带宽和采样开销。对于性能极度敏感的平台(如低端移动设备),需要仔细评估。一个常见的优化是,将SSR效果限制在特定的高清层级(如PC/主机),而在移动端使用更廉价的方案,如平面反射探针(Reflection Probe)或简单的环境贴图。

3.2 自定义渲染器特性(Renderer Feature)

我们将SSR实现为一个自定义的ScriptableRendererFeatureScriptableRenderPass。这是URP中插入自定义渲染逻辑的标准方式。

  1. 创建SSR Renderer Feature: 这个Feature负责创建和管理SSR Render Pass。它通常暴露一些参数给美术或策划在编辑器里调整,比如最大步进次数、最大距离、粗糙度影响等。
  2. 创建SSR Render Pass: 这是核心执行单元。在Execute方法中,我们会设置渲染目标(通常是相机的目标,即最终的屏幕),绑定上述的深度、颜色、法线纹理,并绘制一个覆盖全屏的四边形,触发我们的SSR Shader。

这种设计将SSR效果模块化,可以方便地在不同摄像机或不同渲染阶段启用/禁用,也便于与其他后期效果(如Bloom, Color Grading)组合。

3.3 Shader中的关键变量与函数规划

在编写Shader之前,我们需要规划好核心算法所需的函数:

  1. 重建世界坐标函数float3 ReconstructWorldPosFromDepth(float2 uv)。输入屏幕UV,采样深度纹理,并结合摄像机投影矩阵的逆矩阵,计算出该像素在世界空间中的位置。
  2. 视空间射线计算函数float3 GetViewSpaceRay(float2 uv)。计算从摄像机到屏幕上某一点的方向,在视空间(View Space)中表示。这对于后续的步进计算更方便。
  3. 光线步进循环: 在片段着色器中,对每个像素,根据其法线和视角方向计算反射向量,然后进入一个for循环进行步进。循环中需要实现上述的UV计算、深度采样与比较。
  4. 二分查找函数bool BinarySearch(...)。当步进检测到穿透后,调用此函数在最后一段区间内进行精确查找。
  5. 颜色采样与混合: 根据二分查找得到的最终UV(或判定为搜索失败),采样_CameraOpaqueTexture,并结合高光强度、距离衰减、粗糙度等因素,与原始输入颜色混合。

4. 手把手Shader代码实现与逐行解析

下面,我将给出一个完整的、带有详细注释的URP SSSR Shader代码框架。这个框架包含了核心算法,并考虑了基本的性能与质量平衡。

Shader "Hidden/URP/SSSR" { Properties { // 参数可以通过Renderer Feature的MaterialPropertyBlock传递,这里定义用于调试 _MaxSteps ("Max Steps", Int) = 128 _MaxDistance ("Max Distance", Float) = 100 _StepSize ("Step Size", Float) = 1.0 _Thickness ("Depth Thickness", Float) = 0.1 _RoughnessFade ("Roughness Fade", Range(0,1)) = 0.5 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { Name "SSSR Pass" ZTest Always ZWrite Off Cull Off HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl" // 从Renderer Feature传入的参数 int _MaxSteps; float _MaxDistance; float _StepSize; float _Thickness; float _RoughnessFade; // 摄像机矩阵 float4x4 _InverseViewMatrix; float4x4 _InverseProjectionMatrix; struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 viewRay : TEXCOORD1; // 视空间射线 }; // 顶点着色器:计算视空间射线 Varyings Vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); output.uv = input.uv; // 重建视空间射线方向(指向远平面) float3 viewRay = mul(unity_CameraInvProjection, float4(input.uv * 2 - 1, 1, 1)).xyz; output.viewRay = viewRay / viewRay.z; // 归一化到视空间深度为1的平面 return output; } // 核心函数:从深度和UV重建世界位置 float3 ReconstructWorldPosFromDepth(float2 uv, float depth) { // 将深度转换为线性眼空间深度(如果是设备深度) float linearDepth = LinearEyeDepth(depth, _ZBufferParams); // 计算视空间位置 float3 viewPos = linearDepth * _viewRay; // _viewRay需要从顶点着色器传递过来,这里示意 // 转换到世界空间 return mul(_InverseViewMatrix, float4(viewPos, 1.0)).xyz; } // 核心函数:屏幕空间光线步进+二分查找 bool RayMarchSSR(float3 worldPos, float3 worldNormal, float3 viewDir, float roughness, out float2 hitUV) { // 1. 计算反射方向 float3 reflectDir = reflect(-viewDir, worldNormal); // 2. 初始化步进 float stepLength = _StepSize; float totalLength = 0.0; float3 currentPos = worldPos; float2 lastUV = 0; float lastDepth = 0; [loop] for(int i = 0; i < _MaxSteps && totalLength < _MaxDistance; ++i) { // 向前步进 currentPos += reflectDir * stepLength; totalLength += stepLength; // 将世界坐标转换到屏幕UV float4 clipPos = mul(UNITY_MATRIX_VP, float4(currentPos, 1.0)); float3 ndc = clipPos.xyz / clipPos.w; float2 uv = ndc.xy * 0.5 + 0.5; // 检查UV是否在屏幕内 if(any(uv < 0) || any(uv > 1)) { return false; // 光线射出屏幕 } // 采样屏幕深度 float sceneDepth = SampleSceneDepth(uv); float rayDepth = clipPos.w; // 当前步进点的深度(裁剪空间w分量) // 深度比较:如果光线深度大于场景深度,说明可能发生碰撞 if(rayDepth > sceneDepth) { // 进入二分查找,精确定位交点 float2 startUV = lastUV; float2 endUV = uv; float startDepth = lastDepth; float endDepth = rayDepth; for(int j = 0; j < 8; ++j) // 通常4-8次二分查找足够 { float2 midUV = (startUV + endUV) * 0.5; float midSceneDepth = SampleSceneDepth(midUV); // 计算中点对应的世界坐标深度(这里需要根据midUV和反射光线方向反算,简化处理可用插值) // ... 简化处理:比较逻辑 if(...) { /* 更新区间 */ } } hitUV = midUV; // 二分查找后的最终UV return true; } lastUV = uv; lastDepth = rayDepth; } return false; // 未找到交点 } // 片段着色器 half4 Frag(Varyings input) : SV_Target { // 采样GBuffer或当前帧纹理获取信息(此处简化,实际需从对应纹理采样) float depth = SampleSceneDepth(input.uv); float3 worldPos = ReconstructWorldPosFromDepth(input.uv, depth); // 假设我们从法线纹理采样世界法线 float3 worldNormal = tex2D(_CameraNormalsTexture, input.uv).xyz * 2 - 1; float roughness = 0.1; // 从材质参数获取 // 计算视角方向 float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - worldPos); // 执行SSR光线步进 float2 hitUV = 0; bool hit = RayMarchSSR(worldPos, worldNormal, viewDir, roughness, hitUV); half4 sceneColor = SampleSceneColor(input.uv); // 原始场景颜色 half4 reflectionColor = sceneColor; if(hit) { // 采样交点处的颜色 reflectionColor = SampleSceneColor(hitUV); // 根据粗糙度进行模糊(可采样Mipmap或使用高斯模糊) float blurFactor = roughness * _RoughnessFade; // ... 应用模糊 // 距离衰减 float fade = 1.0 - saturate(length(worldPos - ReconstructWorldPosFromDepth(hitUV, SampleSceneDepth(hitUV))) / _MaxDistance); reflectionColor.rgb *= fade; } else { // 搜索失败,回退到环境贴图或底色 // reflectionColor = texCUBE(_FallbackCubemap, reflectDir); reflectionColor.rgb = 0.1; // 简单暗色 } // 混合反射颜色(简化:直接加在高光上) float specularStrength = 1.0 - roughness; // 粗糙度越高,高光越弱 half4 finalColor = sceneColor + reflectionColor * specularStrength * 0.5; return finalColor; } ENDHLSL } } }

代码关键点解析:

  1. 视空间射线(View Ray)的计算:在顶点着色器中计算一次,然后传递给片元着色器。这是为了高效地在片元着色器中根据深度重建世界位置,避免了每个像素都进行矩阵乘法的开销。
  2. 深度处理LinearEyeDepth函数用于将采样到的深度纹理值(可能是非线性的设备深度)转换为线性的眼空间深度,这是进行正确距离比较的基础。
  3. 步进循环:使用[loop]指令提示编译器,但要注意在Shader Model 3.0以下或某些移动GPU上,动态循环可能效率较低或不被支持。生产环境中,有时会采用固定次数的展开循环或基于距离的动态步长。
  4. 二分查找:上述代码中的二分查找部分是伪代码逻辑。实际实现中,需要根据startUVendUV对应的世界坐标深度进行插值和比较,逻辑稍复杂,但能显著提升边缘质量。
  5. 粗糙度与模糊:真实的材质粗糙度会使反射模糊。一个简单高效的方法是,根据粗糙度值,在采样_CameraOpaqueTexture时,使用对应的Mipmap层级(tex2Dlod)。更高质量的做法是进行屏幕空间的高斯模糊。
  6. 回退策略:当RayMarchSSR返回false时,必须有一个备选方案。最简单的就是给一个常量暗色。更好的做法是采样一个天空盒或一个预放置的反射探针(Reflection Probe)的立方体贴图。

5. 性能优化与移动端适配实战

直接使用上述基础版本,在PC上可能还行,但在移动端大概率会直接卡爆。我们需要一系列优化策略。

5.1 降低采样与计算频率:半分辨率渲染

最有效的优化之一。我们不在全分辨率下进行SSR计算,而是将深度/法线图下采样到一半分辨率(甚至1/4),同样在半分辨率下进行光线步进和查找。得到反射UV后,再上采样回全分辨率,并与全分辨率颜色混合。这能减少近75%的像素计算量。

实现要点

  1. 创建一个半分辨率的Render Texture (RT)。
  2. 将SSR Pass的渲染目标设置为这个半分辨率RT。
  3. 在SSR Shader中,所有纹理采样(深度、颜色、法线)都需要使用半分辨率的版本(需要提前下采样好)。
  4. 计算出的反射结果存储在半分辨率RT中。
  5. 添加一个额外的上采样/混合Pass(或在一个Pass中处理),将半分辨率反射图与全分辨率场景图结合,通常使用双边滤波(Bilateral Filter)上采样来保持边缘质量。

5.2 优化步进策略:自适应步长与早期终止

  • 自适应步长:不要使用固定步长。在光线起始段(靠近着色点),可以使用较小步长以提高精度;当光线远离后,逐步增大步长以加速遍历。可以根据当前步进点的深度来动态调整。
  • 早期深度测试:在步进循环开始前,先检查反射方向是否大致朝向屏幕内(通过反射向量的Z分量判断)。如果明显朝向屏幕外,可以提前返回,避免无效计算。
  • 基于粗糙度的步进控制:对于粗糙度很高的表面(反射模糊),反射精度要求降低,可以显著减少最大步进次数(_MaxSteps)或增大初始步长(_StepSize)。

5.3 移动端特定优化:利用硬件特性

  • 使用stepsaturate等内置函数:这些函数在移动GPU上通常有优化。
  • 避免动态循环:部分老式移动GPU对Shader中的for/while循环支持很差。可以考虑使用“展开”的静态循环,或者将最大步进次数设置得非常小(如16或32),并依赖较大的初始步长。
  • 纹理压缩与格式:确保用于SSR的深度、法线纹理使用了合适的压缩格式(如深度用R16_UNORM或平台特定深度格式),减少带宽占用。
  • 分帧渲染:对于非关键角色或静态场景,可以考虑将SSR计算分摊到多帧中进行,但这会引入延迟,需要权衡。

5.4 质量与性能的平衡参数表

以下是一组经过调优的参数,可以作为PC端和移动端的起点:

参数PC端 (高质量)移动端 (性能优先)说明
渲染分辨率全分辨率半分辨率 (或动态根据设备)移动端必选项,效果损失可接受
_MaxSteps128 - 25632 - 64最大步进次数,直接影响最远反射距离和精度
_StepSize0.5 - 1.02.0 - 4.0初始步长,越大性能越好,但可能错过细小物体
二分查找次数6 - 83 - 5提升边缘精度,移动端可减少
启用粗糙度模糊是 (采样Mipmap)否 (或极低质量)模糊非常耗性能,移动端可关闭或使用简单模糊
深度厚度 (_Thickness)0.05 - 0.10.2 - 0.5容忍度,值越大越不容易出现反射断裂,但可能造成错误混合

6. 常见问题、调试技巧与效果打磨

即使代码写对了,SSR效果也可能出现各种问题。这里分享一些实战中排查问题和提升效果的心得。

6.1 典型问题与解决方案速查表

问题现象可能原因排查与解决思路
反射边缘闪烁、抖动1. 二分查找精度不足或次数太少。
2. 深度厚度(_Thickness)设置过小。
3. 步进时UV计算有精度误差。
1. 增加二分查找迭代次数(6-8次)。
2. 适当增大_Thickness(0.1-0.3)。
3. 确保在裁剪空间或NDC空间进行插值计算,避免在屏幕UV空间直接线性插值世界坐标。
反射在物体边缘突然消失屏幕空间局限性。反射光线指向了当前摄像机看不到的区域。这是SSR固有缺陷。优化方案:
1. 在搜索失败时,平滑地淡出到回退颜色(如天空盒)。
2. 结合使用一个低精度的全局反射探针作为底色。
反射出现“拉丝”或条纹状瑕疵1. 光线步进时,相邻像素的步进路径因数值误差走向不同。
2. 半分辨率渲染后,上采样滤波质量不够。
1. 在步进前对反射方向或起始位置施加微小的随机偏移(蓝噪声),但需注意帧间抖动。
2. 使用更高质量的上采样滤波器,如联合双边上采样(Joint Bilateral Upsampling),它能考虑深度和法线差异,保持边缘。
性能开销巨大1. 全分辨率渲染。
2._MaxSteps设置过高。
3. 在不需要的物体(如远处、小物体)上也计算了SSR。
1. 强制使用半分辨率。
2. 根据粗糙度动态调整_MaxSteps
3. 增加一个深度或屏幕空间占比较的阈值,低于阈值的像素跳过SSR计算。
透明物体无法被反射_CameraOpaqueTexture不包含透明物体。SSR默认基于不透明纹理。如果需要反射透明物体,需要额外绘制透明物体的颜色到另一张RT,并在SSR采样时混合,复杂度剧增,通常不建议。

6.2 调试工具:可视化关键中间数据

在开发阶段,将中间步骤可视化至关重要。我习惯在Shader中预留一些调试模式:

// 在Frag函数末尾,通过一个调试开关输出不同信息 #ifdef DEBUG_SSSR // 模式1:显示反射UV(用于检查UV是否有效) if(_DebugMode == 1) return float4(hitUV, 0, 1); // 模式2:显示光线步进次数(热力图) if(_DebugMode == 2) return float4(stepCount / (float)_MaxSteps, 0, 0, 1); // 模式3:显示是否命中(命中绿,未命中红) if(_DebugMode == 3) return hit ? float4(0,1,0,1) : float4(1,0,0,1); #endif

通过一个Material Property(_DebugMode)在编辑器里切换,可以直观地看到光线搜索的范围、效率以及问题区域。

6.3 效果融合与后处理

单纯的SSR反射颜色直接叠加会显得很生硬。需要做几件事让它融入场景:

  • 距离衰减:反射强度应随着反射点与着色点距离的增加而衰减。这能模拟现实世界中反射的强度随距离减弱的现象,计算公式可以很简单:attenuation = 1.0 - saturate(distance / _MaxDistance)
  • 根据粗糙度模糊:如前所述,使用roughness值来对采样到的反射颜色进行模糊。最快捷的方法是使用tex2Dlod并指定一个基于粗糙度的LOD层级。
  • 边缘淡化(Fresnel效应):在掠射角(视线与法线夹角很大)时,反射应该更强。我们可以使用Schlick近似公式来模拟这一点:fresnel = reflectance + (1 - reflectance) * pow(1 - saturate(dot(viewDir, normal)), 5)。将fresnel因子乘到反射强度上。
  • 与场景光照结合:反射颜色不应简单地加到最终颜色上,而应该作为高光项的一部分,参与到基于物理的着色(PBR)计算中。在URP的Lit Shader中,这意味着我们需要修改光照计算,将SSR得到的反射颜色作为环境镜面反射(Specular Occlusion)的一部分输入。

最后,别忘了性能分析。在Unity Profiler的GPU模块中,观察你的SSR Pass占用的时间。确保它在你的目标帧率预算之内。如果超标,毫不犹豫地启用半分辨率,并大刀阔斧地削减_MaxSteps。在大多数情况下,一个即使有些瑕疵但运行流畅的反射,远比一个完美但导致卡顿的反射要好。