URP 渲染实战 / 第 28 章 屏幕空间技术 / 案例 28.6
案例 28.6屏幕空间反射(SSR)原理与最简实现高阶
目标:让光滑地面映出场景里的物体。URP 14 没有内置 SSR,平面反射(第 19 章)又要多渲染一遍场景;SSR 只用已有的深度和颜色,就能得到动态的、跟随场景变化的反射。
原理:对每个“需要反射”的像素,由深度和法线算出反射方向 r = reflect(v, n),然后沿 r 在三维空间里一步步前进,每一步把光线上的点投影回屏幕,比较它和深度缓冲:光线点比深度缓冲里的表面更远,说明光线“钻到了表面后面”,交点就在这一步和上一步之间。在交点处取屏幕颜色,就是反射色。
Assets/Cookbook/Ch28/SimpleSSR.shader(节选:trace)
// 光线上一点 → (屏幕 uv, 光线点自己的观察深度, 该处场景的观察深度) float3 ProjectRay(float3 p, out float2 uv) { uv = ComputeNormalizedDeviceCoordinates(p, UNITY_MATRIX_VP); float rayEye = LinearEyeDepth(p, UNITY_MATRIX_V); float sceneEye = LinearEyeDepth(SampleSceneDepthLod(uv), _ZBufferParams); return float3(uv, rayEye - sceneEye); // z>0:光线跑到了场景表面后面 } bool TraceReflection(float3 originWS, float3 dirWS, float jitter, out float2 hitUV, out float hitFade) { hitUV = 0; hitFade = 0; float3 prev = originWS; int steps = (int)_MaxSteps; for (int i = 1; i <= steps; i++) { float3 p = originWS + dirWS * (_StepSize * (i + jitter)); float2 uv; float diff = ProjectRay(p, uv).z; if (any(uv < 0) || any(uv > 1)) return false; // 走出屏幕:屏幕里没有信息了 if (diff > 0 && diff < _Thickness) // 穿过了一个“有厚度”的表面 { // 二分细化:在 prev 与 p 之间逼近交点,减少台阶感 float3 a = prev, b = p; for (int k = 0; k < 5; k++) { float3 m = (a + b) * 0.5; float2 muv; if (ProjectRay(m, muv).z > 0) b = m; else a = m; } ProjectRay(b, hitUV); // 越靠屏幕边缘、越接近最大步数,越淡出,避免硬切 float2 edge = smoothstep(0, 0.08, hitUV) * smoothstep(1, 0.92, hitUV); hitFade = edge.x * edge.y * (1 - (float)i / steps); return true; } prev = p; } return false; }
// 光线上一点 → (屏幕 uv, 光线点自己的观察深度 - 该处场景的观察深度) vec3 ProjectRay(vec3 p, out vec2 uv) { vec4 cs = UNITY_MATRIX_VP * vec4(p, 1.0); // ComputeNormalizedDeviceCoordinates uv = cs.xy / cs.w * 0.5 + 0.5; float rayEye = -(UNITY_MATRIX_V * vec4(p, 1.0)).z; // LinearEyeDepth(p, UNITY_MATRIX_V) float sceneEye = LinearEyeDepth(SampleSceneDepthLod(uv), _ZBufferParams); return vec3(uv, rayEye - sceneEye); // z>0:光线跑到了场景表面后面 } bool TraceReflection(vec3 originWS, vec3 dirWS, float jitter, out vec2 hitUV, out float hitFade) { hitUV = vec2(0.0); hitFade = 0.0; vec3 prev = originWS; int steps = int(_MaxSteps); for (int i = 1; i <= steps; i++) { vec3 p = originWS + dirWS * (_StepSize * (float(i) + jitter)); vec2 uv; float diff = ProjectRay(p, uv).z; if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return false; // 走出屏幕:屏幕里没有信息了 if (diff > 0.0 && diff < _Thickness) // 穿过了一个“有厚度”的表面 { // 二分细化:在 prev 与 p 之间逼近交点,减少台阶感 vec3 a = prev, b = p; for (int k = 0; k < 5; k++) { vec3 m = (a + b) * 0.5; vec2 muv; if (ProjectRay(m, muv).z > 0.0) b = m; else a = m; } ProjectRay(b, hitUV); // 越靠屏幕边缘、越接近最大步数,越淡出,避免硬切 vec2 edge = smoothstep(0.0, 0.08, hitUV) * smoothstep(1.0, 0.92, hitUV); hitFade = edge.x * edge.y * (1.0 - float(i) / float(steps)); return true; } prev = p; } return false; }
Assets/Cookbook/Ch28/SimpleSSR.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 uv = input.texcoord; half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv); float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return color; float3 normalWS = normalize(SampleSceneNormals(uv)); if (normalWS.y < _MinNormalY) return color; // 只让“地面”反射 float3 positionWS = ReconstructPositionWS(uv, depth); float3 viewDirWS = normalize(positionWS - _WorldSpaceCameraPos); float3 reflDirWS = reflect(viewDirWS, normalWS); float jitter = InterleavedGradientNoise(input.positionCS.xy, 0); float2 hitUV; float fade; if (!TraceReflection(positionWS + normalWS * 0.02, reflDirWS, jitter, hitUV, fade)) return color; half3 reflColor = SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_LinearClamp, hitUV, 0).rgb; float NoV = saturate(dot(normalWS, -viewDirWS)); float fresnel = _F0 + (1 - _F0) * pow(1 - NoV, 5); // Schlick color.rgb = lerp(color.rgb, reflColor, saturate(fresnel * fade * _Intensity)); return color; }
vec4 Frag() { vec2 uv = texcoord; vec4 color = SamplePointClamp(_BlitTexture, uv); float depth = SampleDeviceDepth(uv); if (IsSkyDepth(depth)) return color; vec3 normalWS = normalize(SampleSceneNormals(uv)); if (normalWS.y < _MinNormalY) return color; // 只让“地面”反射 vec3 positionWS = ReconstructPositionWS(uv, depth); vec3 viewDirWS = normalize(positionWS - _WorldSpaceCameraPos); vec3 reflDirWS = reflect(viewDirWS, normalWS); float jitter = InterleavedGradientNoise(gl_FragCoord.xy, 0); vec2 hitUV; float fade; if (!TraceReflection(positionWS + normalWS * 0.02, reflDirWS, jitter, hitUV, fade)) return color; vec3 reflColor = textureLod(_BlitTexture, hitUV, 0.0).rgb; float NoV = saturate(dot(normalWS, -viewDirWS)); float fresnel = _F0 + (1.0 - _F0) * pow(1.0 - NoV, 5.0); // Schlick color.rgb = mix(color.rgb, reflColor, saturate(fresnel * fade * _Intensity)); return color; }
几个实现上的取舍:
- 厚度(Thickness):深度缓冲不知道物体有多厚,光线从一个细杆后面穿过时也会被判为“穿过表面”。所以只有当光线点在表面后方、且深度差小于一个厚度阈值时才算命中。阈值太小会漏掉命中(反射出现断裂),太大会让反射“粘”在物体背后。
- 二分细化:线性步进找到“穿过”的那一步后,在上一步和这一步之间二分 5 次,交点精度提高 32 倍,台阶状的锯齿大幅减轻。
- 淡出:命中点越靠近屏幕边缘越淡,步数越接近上限越淡。没有淡出时,反射会在屏幕边缘被生硬地切断——这是 SSR 最常见的穿帮。
- 菲涅尔:反射强度按 Schlick 近似随掠射角增强,正对地面看几乎没有反射,贴着地面看反射最强,与真实世界一致。

SSR 的天然局限与工程化方向
只能反射屏幕上看得见的东西:物体背面、屏幕外的物体、被前景挡住的部分都反射不出来;相机一转,反射内容会突然出现或消失。工程上通常把 SSR 当作反射探针之上的一层“增强”:命中失败时回退到反射探针,命中成功时淡入 SSR。
性能:世界空间等步长步进很浪费——远处一步可能只跨过不到一个像素,近处一步又跳过好几个像素。成熟的实现在屏幕空间按像素做 DDA 步进,并用 Hi-Z(深度金字塔,每级存下一级 2×2 区域的最近深度)跳过大片空白区域,配合半分辨率和时间累积,开销才能控制在可接受范围。粗糙表面的模糊反射还需要按粗糙度选择颜色金字塔的 Mip 或做重要性采样。
本案例出自《URP 渲染实战》第 28 章「屏幕空间技术」。在章节中阅读上下文 →