GameTech/URP 渲染实战

案例 28.6屏幕空间反射(SSR)原理与最简实现高阶

目标:让光滑地面映出场景里的物体。URP 14 没有内置 SSR,平面反射(第 19 章)又要多渲染一遍场景;SSR 只用已有的深度和颜色,就能得到动态的、跟随场景变化的反射。

原理:对每个“需要反射”的像素,由深度和法线算出反射方向 r = reflect(v, n),然后沿 r 在三维空间里一步步前进,每一步把光线上的点投影回屏幕,比较它和深度缓冲:光线点比深度缓冲里的表面更远,说明光线“钻到了表面后面”,交点就在这一步和上一步之间。在交点处取屏幕颜色,就是反射色。

光滑地面 物体 相机 视线 v 反射射线 r:等距步进 穿过表面 → 二分细化 → 取颜色 n
图 28-13屏幕空间反射:沿反射方向步进,每一步投影回屏幕与深度比较,穿过表面后用二分法逼近交点
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;
}

几个实现上的取舍:

图 28-14左:普通的光滑深色地面,只有环境反射;右:加上 SSR,球、方块、圆柱和纽结在地面上留下倒影,倒影随离开物体的距离和屏幕边缘逐渐淡出

SSR 的天然局限与工程化方向

只能反射屏幕上看得见的东西:物体背面、屏幕外的物体、被前景挡住的部分都反射不出来;相机一转,反射内容会突然出现或消失。工程上通常把 SSR 当作反射探针之上的一层“增强”:命中失败时回退到反射探针,命中成功时淡入 SSR。

性能:世界空间等步长步进很浪费——远处一步可能只跨过不到一个像素,近处一步又跳过好几个像素。成熟的实现在屏幕空间按像素做 DDA 步进,并用 Hi-Z(深度金字塔,每级存下一级 2×2 区域的最近深度)跳过大片空白区域,配合半分辨率和时间累积,开销才能控制在可接受范围。粗糙表面的模糊反射还需要按粗糙度选择颜色金字塔的 Mip 或做重要性采样。