尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

Unity URP屏幕空间高光反射(SSSR)实现:原理、代码与移动端优化 1. 项目概述为什么是SSSR而不是光追最近在社区和群里看到不少朋友尤其是刚接触图形学或者Unity不久的同学一提到“反射”第一反应就是“上光追”。诚然实时光线追踪Ray Tracing能提供物理上最精确的反射、阴影和全局光照效果但它对硬件的要求以及随之而来的性能开销在当下的移动端和大量中低端PC上依然是难以承受之重。我们做项目尤其是商业项目性能和效果的平衡永远是第一位的。这时候屏幕空间反射Screen Space Reflection SSR技术就成了一个极具性价比的选择。SSR顾名思义它的所有信息都来自于当前帧的屏幕缓冲区如深度图、法线图、颜色图。它不需要预计算能动态响应场景变化反射内容完全基于玩家当前视野所见效果真实且即时。而SSSRScreen Space Specular Reflection则更聚焦于高光反射部分这是场景中视觉冲击力最强、最需要精细处理的部分。在Unity的通用渲染管线URP中实现一套高效、高质量的SSSR是提升项目画面质感同时保持高性能的关键技术之一。今天我就把手头一个经过多个项目验证、稳定可靠的URP SSSR实现方案从原理到代码完整地拆解给大家。这套方案避开了那些过于学术化、难以调试的复杂算法采用更工程化、更易于理解的“步进二分”搜索法并针对移动端和PC端做了不同的优化策略。你会发现不用死磕光追你的项目画面也能立刻上一个档次。2. 核心原理拆解SSSR到底是怎么“算”出来的要理解SSSR我们得先忘掉那些复杂的积分方程从一个最直观的视角出发对于一个屏幕上的像素点我们称之为着色点Shading Point我们想知道它应该反射出什么颜色。2.1 第一步反射光线的生成与屏幕空间投射这个过程始于着色点本身的属性它在世界空间中的位置通过深度缓冲重建和法线方向。根据基础的反射定律我们可以计算出入射光线从摄像机到着色点关于法线的反射方向。这条反射光线就是我们去“寻找”反射颜色的探针。关键的一步来了我们不会让这条光线在真正的3D场景中无限穿梭那是光追的做法。相反我们把它“投影”回屏幕空间。想象一下我们沿着这条反射光线一步一步地向前“走”每走一步我们就记录下这个步进点对应的屏幕UV坐标和深度值。这个“屏幕UV坐标”告诉我们如果这个步进点能被摄像机看到它会出现在屏幕的哪个像素上而“深度值”则告诉我们这个步进点离摄像机有多远。2.2 第二步光线步进与深度测试——寻找交点现在我们有了一个沿着反射光线的、在屏幕空间中的点序列。我们的目标是找到第一个点使得这个点的“实际深度”即场景中该屏幕位置处物体的深度与我们从光线步进中计算出的“光线深度”非常接近。这就意味着我们的反射光线“撞”到了屏幕中的某个物体。这个过程就是光线步进Ray Marching。我们从着色点开始以一定的步长Step沿着反射方向前进。每一步我们都进行如下判断将当前步进点的世界坐标转换到屏幕UV坐标。采样当前屏幕UV坐标处的深度纹理_CameraDepthTexture得到场景在该处的实际深度Scene Depth。计算当前步进点自身的深度Ray Depth。比较 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实现为一个自定义的ScriptableRendererFeature和ScriptableRenderPass。这是URP中插入自定义渲染逻辑的标准方式。创建SSR Renderer Feature 这个Feature负责创建和管理SSR Render Pass。它通常暴露一些参数给美术或策划在编辑器里调整比如最大步进次数、最大距离、粗糙度影响等。创建SSR Render Pass 这是核心执行单元。在Execute方法中我们会设置渲染目标通常是相机的目标即最终的屏幕绑定上述的深度、颜色、法线纹理并绘制一个覆盖全屏的四边形触发我们的SSR Shader。这种设计将SSR效果模块化可以方便地在不同摄像机或不同渲染阶段启用/禁用也便于与其他后期效果如Bloom, Color Grading组合。3.3 Shader中的关键变量与函数规划在编写Shader之前我们需要规划好核心算法所需的函数重建世界坐标函数float3 ReconstructWorldPosFromDepth(float2 uv)。输入屏幕UV采样深度纹理并结合摄像机投影矩阵的逆矩阵计算出该像素在世界空间中的位置。视空间射线计算函数float3 GetViewSpaceRay(float2 uv)。计算从摄像机到屏幕上某一点的方向在视空间View Space中表示。这对于后续的步进计算更方便。光线步进循环 在片段着色器中对每个像素根据其法线和视角方向计算反射向量然后进入一个for循环进行步进。循环中需要实现上述的UV计算、深度采样与比较。二分查找函数bool BinarySearch(...)。当步进检测到穿透后调用此函数在最后一段区间内进行精确查找。颜色采样与混合 根据二分查找得到的最终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 { RenderTypeOpaque RenderPipelineUniversalPipeline } 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 } } }代码关键点解析视空间射线View Ray的计算在顶点着色器中计算一次然后传递给片元着色器。这是为了高效地在片元着色器中根据深度重建世界位置避免了每个像素都进行矩阵乘法的开销。深度处理LinearEyeDepth函数用于将采样到的深度纹理值可能是非线性的设备深度转换为线性的眼空间深度这是进行正确距离比较的基础。步进循环使用[loop]指令提示编译器但要注意在Shader Model 3.0以下或某些移动GPU上动态循环可能效率较低或不被支持。生产环境中有时会采用固定次数的展开循环或基于距离的动态步长。二分查找上述代码中的二分查找部分是伪代码逻辑。实际实现中需要根据startUV和endUV对应的世界坐标深度进行插值和比较逻辑稍复杂但能显著提升边缘质量。粗糙度与模糊真实的材质粗糙度会使反射模糊。一个简单高效的方法是根据粗糙度值在采样_CameraOpaqueTexture时使用对应的Mipmap层级tex2Dlod。更高质量的做法是进行屏幕空间的高斯模糊。回退策略当RayMarchSSR返回false时必须有一个备选方案。最简单的就是给一个常量暗色。更好的做法是采样一个天空盒或一个预放置的反射探针Reflection Probe的立方体贴图。5. 性能优化与移动端适配实战直接使用上述基础版本在PC上可能还行但在移动端大概率会直接卡爆。我们需要一系列优化策略。5.1 降低采样与计算频率半分辨率渲染最有效的优化之一。我们不在全分辨率下进行SSR计算而是将深度/法线图下采样到一半分辨率甚至1/4同样在半分辨率下进行光线步进和查找。得到反射UV后再上采样回全分辨率并与全分辨率颜色混合。这能减少近75%的像素计算量。实现要点创建一个半分辨率的Render Texture (RT)。将SSR Pass的渲染目标设置为这个半分辨率RT。在SSR Shader中所有纹理采样深度、颜色、法线都需要使用半分辨率的版本需要提前下采样好。计算出的反射结果存储在半分辨率RT中。添加一个额外的上采样/混合Pass或在一个Pass中处理将半分辨率反射图与全分辨率场景图结合通常使用双边滤波Bilateral Filter上采样来保持边缘质量。5.2 优化步进策略自适应步长与早期终止自适应步长不要使用固定步长。在光线起始段靠近着色点可以使用较小步长以提高精度当光线远离后逐步增大步长以加速遍历。可以根据当前步进点的深度来动态调整。早期深度测试在步进循环开始前先检查反射方向是否大致朝向屏幕内通过反射向量的Z分量判断。如果明显朝向屏幕外可以提前返回避免无效计算。基于粗糙度的步进控制对于粗糙度很高的表面反射模糊反射精度要求降低可以显著减少最大步进次数(_MaxSteps)或增大初始步长(_StepSize)。5.3 移动端特定优化利用硬件特性使用step、saturate等内置函数这些函数在移动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. 适当增大_Thickness0.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。在大多数情况下一个即使有些瑕疵但运行流畅的反射远比一个完美但导致卡顿的反射要好。
返回列表