深度分析屏幕空间反射算法以及在URP中的实现(一)

深度分析屏幕空间反射算法以及在URP中的实现(一)

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


前言

湿润的地面,倒映着远方青山,什么样的技术才能造就如此绚丽的效果呢?

本文实现的效果:


使用法线贴图的效果


平滑地面

代码仓库:
GitHub: Screen Space Reflection in URP

Unity版本:2022.3.60f1c1
URP版本:14.0.12
使用延迟渲染


反射一直是实时渲染中十分重要的部分,通常来说,我们觉得画面质感单调,缺少反光,多半是因为没有反射效果。可以看下图对比:


古墓丽影,来自SSR基础介绍[1]

作为实时渲染的一种廉价的反射效果,经常被用在游戏中:


看门狗


看门狗2


巫师3


鸣潮

前置理论基础

1. DDA
DDA算法全称为数字微分分析器算法(Digital Differential Analyzer),是计算机图形学中最基础、最直观的直线光栅化算法之一。

首先要明白在显示器上画线本质是画一系列的点,即每一个像素:

如果要光栅化一个3D线段, 应该考虑的就是如何选择XY步进方向, 即确定步进规则:

这段代码翻译过来就是:

  • 先根据两个点的XY分量变化,确定使用一个主导方向。
  • 步进时,在主导方向上进行加一,在另一个方向上进行光栅化判断。

下图展示了当3D线在光栅化后,横向长度大于竖直长度时(即 X1 - X0 > Y1 - Y0),Draw的规则:

同理 X1 - X0 < Y1 - Y0。

2. Ray Tracing概述

光线追踪(Ray Tracing)是计算机图形学中基于物理光学原理的渲染技术,核心思想是模拟光线在三维场景中的传播、反射、折射与吸收过程,计算光线与物体表面的交互,最终生成具有高度真实感的图像。

光线追踪的本质是逆向模拟光的传播路径 —— 由于直接模拟从光源出发的海量光线(多数不会进入相机)效率极低,实际采用的是“从相机/眼睛向场景发射光线去找光源”的逆向思路。

一个基础的光线追踪的核心代码如下:

// 输入:光线、场景、递归深度;输出:光线最终颜色
Vector3 whitted_ray_trace(Ray ray, Scene scene, int depth) {
    // 1. 终止条件:递归深度达到上限(避免无限递归)
    if (depth > MAX_RECURSION_DEPTH) {
        return Vector3(0, 0, 0); // 超出深度,无颜色贡献
    }

    // 2. 光线与场景求交(用BVH加速,替代遍历所有物体)
    HitInfo hit = scene.bvh_root.intersect(ray);
    if (!hit.is_hit) {
        return scene.background; // 未命中物体,返回背景色
    }

    // 3. 计算直接光照(光源对交点的直接贡献)
    Vector3 direct_light = calculate_direct_light(hit, scene);

    // 4. 计算间接光照(反射+折射光线的递归贡献)
    Vector3 indirect_light = Vector3(0, 0, 0);
    Material mat = hit.material;

    // 4.1 生成反射光线(金属/漫反射物体均有反射)
    Vector3 reflect_dir = reflect(ray.direction, hit.normal);
    Ray reflect_ray = Ray(
        hit.point,          // 反射光线起点=交点(偏移微小距离避免自相交)
        reflect_dir,        // 反射方向(单位向量)
        1e-4,               // t_min=1e-4(避免与自身交点)
        INF                 // t_max=无穷大
    );
    // 反射颜色贡献=材质反射系数*反射光线递归结果
    Vector3 reflect_color = whitted_ray_trace(reflect_ray, scene, depth+1);
    indirect_light = indirect_light.add(reflect_color.multiply(mat.albedo));

    // 4.2 生成折射光线(仅透明物体如玻璃、水)
    if (mat.is_transparent) {
        Vector3 refract_dir;
        // 判断光线是从外部进入物体(法向量反向)还是内部射出
        bool is_inside = ray.direction.dot(hit.normal) > 0;
        Vector3 normal = hit.normal;
        float eta_i = 1.0;    // 入射介质折射率(空气=1.0)
        float eta_t = mat.refractive_idx; // 透射介质折射率
        if (is_inside) {
            normal = normal.multiply(-1); // 内部射出时,法向量反向
            swap(eta_i, eta_t);           // 交换折射率
        }
        // 斯涅尔定律计算折射方向(判断是否全反射)
        if (refract(ray.direction, normal, eta_i/eta_t, refract_dir)) {
            Ray refract_ray = Ray(
                hit.point,
                refract_dir,
                1e-4,
                INF
            );
            // 折射颜色贡献=材质透射系数*折射光线递归结果
            Vector3 refract_color = whitted_ray_trace(refract_ray, scene, depth+1);
            indirect_light = indirect_light.add(refract_color.multiply(mat.albedo));
        } else {
            // 全反射:无折射,仅反射(如水中看空气的全反射)
            indirect_light = indirect_light.add(reflect_color.multiply(mat.albedo));
        }
    }

    // 5. 合并直接光照与间接光照(返回最终颜色)
    return direct_light.add(indirect_light);
}

从代码中,我们可以知道,RayTracing做的事情是针对于全局的,依赖于整个场景信息,并且,求交的算法是针对于每个物体的每个表面(三角形)而言的,如果我们要在屏幕空间进行反射处理的话,首先遇到的难点就是采样什么,怎么求交。


我们怎么在X点拿到P点的信息

针对以上两个问题,我们的SSR给出了十分棒的解决方案。首先,既然是屏幕空间,那么能采样到的信息也必然是屏幕空间的信息,这就不难想到,在Fragment Shader中,针对每一个像素,我们可以在屏幕空间利用深度信息(_CameraDepthRenderTexture)计算出其对应的Shading Point的世界位置,提取法线信息(_CameraNormalRenderTexture),并计算反射后的向量,在反射向量的方向上,我们可以采用步进式采样(Ray Marching), 通过比较深度缓冲区中的深度图(_CameraDepthRenderTexture)与采样点的深度大小来判断光线是否命中场景表面,命中场景表面的颜色就从我们渲染好的场景图片(_CameraColorRenderTexture)中采样,最后叠加到原图像上。


来自Games 202

总的来说,SSR流程为:

  • 反射光线生成:从当前像素对应的3D点出发,根据表面法向量和视角方向,计算反射光线的方向(遵循反射定律)。
  • 屏幕空间投影:将反射光线的3D路径投影到2D屏幕空间,转化为屏幕像素坐标系下的线段。
  • 步进采样与命中检测:沿屏幕空间线段步进采样,对比“反射光线在该像素处的3D深度”与“深度缓冲区存储的场景深度”,判断是否命中。
  • 反射颜色提取与融合:若命中,从颜色缓冲区提取命中像素的颜色作为反射色;若未命中(如反射光线指向视口外),则使用环境贴图(Environment Map)等fallback方案,最终将反射色与原像素色按材质属性融合。

3. Ray Marching
既然我们在屏幕空间进行反射处理,那么必然就拿不到所有场景的几何信息,也就无法实现真正的Ray Tracing,好在Ray Marching给了我们不错的采样思路。

Ray Marching(光线步进)是计算机图形学中一种基于迭代采样的渲染技术,核心思想是沿光线传播方向“逐步推进”并采样场景,通过判断“当前采样点是否满足终止条件”(如命中物体、超出最大距离)来计算光线与场景的交互。

顾名思义,Ray Marching就是射线一步一步走的意思。从Shading Point开始,向我们反向向量一步一步进行采样计算,如图所示:

Games202 ScreenSpaceReflection 39分44秒[2]。

单从Ray Marching角度来说,我们需要考虑的就是步长和步进次数,如果步长越大,采样也就越不精准(容易越过交点的表面), 如果步长过小,那么就会导致过度采样(Over Sampling), 白白浪费性能,步进次数同理。

4. Efficient GPU DDA Solution
高效GPU DDA解决方案的论文中,详细介绍了具体怎么做Ray Marching,这里我们简练地说一下。

如果我们的步进是在世界空间进行的(即在世界空间中是均匀步进采样,如下图),那么会出现的两个严重问题:首先,如果我们的Shading Point距离相机远,则我们每次步进对应的其实就是一两个像素,可我们还得进行100来次的采样,这会导致严重的Over Sampling,如下图:


https://zhuanlan.zhihu.com/p/386510829

如果我们的Shading Point距离我们的相机过近,那么我们一个步长又会导致在屏幕空间中跨度太大,从而使精度大大降低,如下图:


https://zhuanlan.zhihu.com/p/386510829

那么我们如何采样才能最大程度利用起来屏幕信息呢?答案就是把我们的步进过程放到屏幕空间!


https://zhuanlan.zhihu.com/p/386510829

注意:屏幕空间进行Ray Marching丢了深度信息(我们步进到一个像素,我们不知道其步进采样点的深度),因此还需要单独保存在屏幕空间从H0变化到H1的z值(学过透视投影就知道不能用Q0到Q1进行线性插值,透视投影矩阵推导:Games101 MVP变换[3])。

为了找到步进采样点的深度,把视线空间的Q变换到裁剪空间,保留其w分量,通过透视除法转换到NDC空间中的V,这样我们就得到了与屏幕空间的成线性关系的坐标了:

之后,把步进采样屏幕空间的坐标转化为NDC空间的V,再反向投影到视线空间Q,此时就可以读取步进出来的深度了:

原文链接:
https://jcgt.org/published/0003/04/04/paper.pdf

知乎猫猫居士介绍此论文:
【论文复现】Efficient GPU Screen-Space Ray Tracing

SSR算法分析与实战

先来一个基本的SSR实现,我们可以用URP管线下的RenderFeature + Blit Shader,也可以使用MonoBehaviour(C#) + ComputeShader实现,本质都是利用GPU进行运算,本文使用URP管线的RenderFeature来实现SSR效果,对Computer Shader感兴趣的,可以简单看一下【Unity教程】小祥带你实现屏幕空间反射SSR_哔哩哔哩_bilibili[4]。

这里我使用延迟渲染管线进行渲染,我认为这种基于屏幕空间处理的,和延迟渲染十分搭配,所需要的数据只需要跑一遍pass就全都拿到了,成功把计算和数据读取解耦合,如果要新添加功能也十分容易。值得注意的是,GBuffer2的xyz储存的是世界空间法线,a通道储存的是光滑度信息。

1. 基本配置
创建Render Pipeline,创建Render Feature:

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Serialization;

namespaceScreenSpaceReflection.Render
{
    publicclassSsrRenderFeature : ScriptableRendererFeature
    {
        [Serializable]
        publicclassSsrSettings
        {
            [SerializeField] internalint StepCount = 200;
            [SerializeField] internalfloat Thickness = 0.3f;
            [SerializeField] internalfloat Stride = 0.1f;
            [SerializeField] internalfloat RayOffset = 0.1f;
        }

        [SerializeField] private Shader m_SsrShader;
        [SerializeField] private RenderPassEvent m_RenderPassEvent = RenderPassEvent.AfterRenderingOpaques;
        [SerializeField] private SsrSettings m_Settings = new();

        private Material m_SsrMaterial;
        private SsrRenderPass m_SsrRenderPass;

        public override void Create()
        {
            m_SsrRenderPass ??= new SsrRenderPass();
            m_SsrRenderPass.renderPassEvent = m_RenderPassEvent;

            if(!m_SsrShader) 
                m_SsrShader = Shader.Find("Hidden/Universal Render Pipeline/Blit");
            m_SsrMaterial = CoreUtils.CreateEngineMaterial(m_SsrShader);

            m_SsrRenderPass.Create(m_SsrMaterial);
        }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            renderer.EnqueuePass(m_SsrRenderPass);
        }

        public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
        {
            m_SsrRenderPass.Setup(m_Settings);
        }

        // 一定要做好内存管理!!!!!
        protected override void Dispose(bool disposing)
        {
            m_SsrRenderPass?.Dispose();
            m_SsrRenderPass?.ClearUp();
            m_SsrRenderPass = null;
        }
    }
}

创建C#,来个RenderPass:

using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespaceScreenSpaceReflection.Render
{
    staticclassSsrShaderConstants
    {
        publicstaticreadonlystring SsrTexName = "_SsrTex";

        publicstaticreadonlyint SsrTexID = Shader.PropertyToID(SsrTexName);
        // x: step count, y: thickness, z: step size(Stride)
        publicstaticreadonlyint SsrParametersID = Shader.PropertyToID("_SsrParameters");
    }

    publicclassSsrRenderPass : ScriptableRenderPass
    {
        privateconststring m_SsrProfilingTag = "SSRRenderPass";
        private ProfilingSampler m_ProfilingSampler = new(m_SsrProfilingTag + "_Sampler");

        private SsrRenderFeature.SsrSettings m_Settings;

        private Material m_SsrMaterial;
        private RTHandle m_SourceRT;
        private RTHandle m_DestinationRT;
        private RTHandle m_SsrRT;
        private RenderTextureDescriptor m_Descriptor;

        public void Create(Material ssrMaterial)
        {
            m_SsrMaterial = ssrMaterial;

            m_SsrRT = RTHandles.Alloc(SsrShaderConstants.SsrTexID, name: SsrShaderConstants.SsrTexName);
        }

        public void Setup(SsrRenderFeature.SsrSettings settings)
        {
            m_Settings = settings;
        }

        public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
        {
            m_SourceRT = renderingData.cameraData.renderer.cameraColorTargetHandle;
            m_DestinationRT = renderingData.cameraData.renderer.cameraColorTargetHandle;

            m_Descriptor = renderingData.cameraData.cameraTargetDescriptor;
            m_Descriptor.useMipMap = false;
            m_Descriptor.autoGenerateMips = false;

            m_SsrMaterial.SetVector(SsrShaderConstants.SsrParametersID,
                new Vector4(m_Settings.StepCount, m_Settings.Thickness / 100,
                            m_Settings.Stride / 100000, m_Settings.RayOffset));
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get(m_SsrProfilingTag + "_CommandBuffer");
            using (new ProfilingScope(cmd, m_ProfilingSampler))
            {
                var tempDesc = GetCompatibleDescriptor(m_Descriptor.width, m_Descriptor.height,
                    GraphicsFormat.B10G11R11_UFloatPack32);
                RenderingUtils.ReAllocateIfNeeded(ref m_SsrRT, tempDesc, FilterMode.Bilinear,
                    TextureWrapMode.Clamp,
                    name: m_SsrRT.name);

                Blitter.BlitCameraTexture(cmd, m_SourceRT, m_SsrRT, RenderBufferLoadAction.DontCare,
                    RenderBufferStoreAction.Store, m_SsrMaterial, 0);

                Blitter.BlitCameraTexture(cmd, m_SsrRT, m_DestinationRT);
            }

            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();
            CommandBufferPool.Release(cmd);
        }

        public override void OnCameraCleanup(CommandBuffer cmd)
        {
            m_SourceRT = null;
            m_DestinationRT = null;
        }

        // 一定要做好内存管理!!!!!
        public void Dispose()
        {
            m_SsrRT?.Release();
        }

        public void ClearUp()
        {
            CoreUtils.Destroy(m_SsrMaterial);
        }

        RenderTextureDescriptor GetCompatibleDescriptor(int width, int height, GraphicsFormat format,
            DepthBits depthBufferBits = DepthBits.None)
            => GetCompatibleDescriptor(m_Descriptor, width, height, format, depthBufferBits);

        private static RenderTextureDescriptor GetCompatibleDescriptor(RenderTextureDescriptor desc, int width,
            int height, GraphicsFormat format, DepthBits depthBufferBits = DepthBits.None)
        {
            desc.depthBufferBits = (int)depthBufferBits;
            desc.msaaSamples = 1;
            desc.width = width;
            desc.height = height;
            desc.graphicsFormat = format;
            return desc;
        }
    }
}

基本的Blit:

Shader "Hidden/SsrShader"
{
    SubShader
    {
        Tags { "RenderType" = "Opaque""RenderPipeline" = "UniversalPipeline"}
        LOD 100

        Pass
        {
            Name "SsrPass"
            ZTest Always
            ZWrite Off
            Cull Off

            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment Fragment
            #pragma multi_compile_fragment _ _LINEAR_TO_SRGB_CONVERSION
            #pragma multi_compile_fragment _ DEBUG_DISPLAY

            // Core.hlsl for XR dependencies
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Debug/DebuggingFullscreen.hlsl"
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"

            SAMPLER(sampler_BlitTexture);

            half4 Fragment(Varyings input) : SV_Target
            {
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
                float2 uv = input.texcoord;

                half4 col = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_BlitTexture, uv);

                #ifdef _LINEAR_TO_SRGB_CONVERSION
                col = LinearToSRGB(col);
                #endif

                #if defined(DEBUG_DISPLAY)
                half4 debugColor = 0;

                if(CanDebugOverrideOutputColor(col, uv, debugColor))
                {
                    return debugColor;
                }
                #endif

                return col;
            }
            ENDHLSL
        }
    }
}

运行之后看到这两个Draw Procedural,就是成功了!

视图空间Ray Marching

Shader:

// Core.hlsl for XR dependencies
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
........

// -- -- -- -- -- -- Functions -- -- -- -- -- -
// 函数都放在这里(看下文)
#include "Assets/ScreenSpaceReflection/Shaders/ShaderLib/SsrFunctions.hlsl"
// -- -- -- -- -- -- Uniform Variable -- -- -- -- -- -
SAMPLER(sampler_BlitTexture);

// x : step count, y : thickness, z : step size w : ray offset
float4 _SsrParameters;

#define RAY_OFFSET _SsrParameters.w
#define STEP_COUNT _SsrParameters.x
#define THICKNESS _SsrParameters.y
#define STEP_SIZE _SsrParameters.z

// -- -- -- -- -- -- Shader Stage -- -- -- -- --
half4 SsrFrag(Varyings input) : SV_Target
{
    // -- -- -- Base Data -- -- -- --
    float2 uv = input.texcoord;

    // rawDepth 记录NDC空间的Z信息 [0, 1]
    float rawDepth = SampleSceneDepth(uv);
    // linearDepth 为视线空间的Z坐标绝对值
    // float linear01Depth = Linear01Depth(rawDepth, _ZBufferParams);
    float3 normalWS = 0;
    float smoothness = 0;
    GetNormalAndSmoothness(uv, normalWS, smoothness);
    float3 normalVS = TransformWorldToViewDir(normalWS);

    float3 posWS = GetWorldSpacePosition(uv, rawDepth);
    float3 viewDirWS = normalize(_WorldSpaceCameraPos - posWS);
    float3 viewDirVS = TransformWorldToViewDir(viewDirWS);

    float3 reflDirWS = normalize(- reflect(viewDirWS, normalWS));

    float3 posVS = TransformWorldToView(posWS);

    // 存在自反射,需要加一定的偏移
    float3 rayVS = posVS + normalVS * RAY_OFFSET;

    // -- -- -- Shading -- -- -- -- --
    // 视图空间RayMarching
    UNITY_LOOP
    for (int i = 0; i < STEP_COUNT; i ++)
    {
        rayVS += reflDirVS * STEP_SIZE * i;
        float2 currRayUV;
        ReconstructUV(rayVS, currRayUV);
        float currSampleDepth = SampleSceneDepth(currRayUV).x;
        // 重构出相机空间的Z坐标(绝对值)进行比较,方便调参
        float currSampleZ = LinearEyeDepth(currSampleDepth, _ZBufferParams);
        float currRayZ = - rayVS.z;

        float deltaZ = currRayZ - currSampleZ;

        if (deltaZ >= 0 && deltaZ < THICKNESS)
        {
            // for debug:直接获取到反射的信息
            // return GetSource(currRayUV);
            return GetSource(currRayUV) * smoothness + GetSource(uv);
        }
    }

    // for debug:未击中的区域为黑色
    // return half4(0.0, 0.0, 0.0, 1.0);
    return GetSource(uv);
}

Shader Functions:

// 获取Blit Texture 
half4 GetSource(float2 uv)
{
    return SAMPLE_TEXTURE2D_X_LOD(_BlitTexture, sampler_LinearRepeat, uv, _BlitMipLevel);
}

// 获取normal
float3 GetNormal(float2 uv)
{
    return SAMPLE_TEXTURE2D_X(_CameraNormalsTexture, sampler_CameraNormalsTexture, uv).xyz;
}

// 在延迟渲染管线的Normal Buffer中,a通道输出的是平滑度
// 获取世界空间的法线和平滑度
void GetNormalAndSmoothness(float2 uv, out float3 normal, out float smoothness)
{
    float4 normalAndSmoothness = SAMPLE_TEXTURE2D_X(_CameraNormalsTexture, sampler_CameraNormalsTexture, uv);
    normal = normalize(normalAndSmoothness.xyz);// 在normalize时, 注意不要有w分量
    smoothness = normalAndSmoothness.w;
}

// 反投影重建世界坐标
float3 GetWorldSpacePosition(float2 uv, half depth)
{
    return ComputeWorldSpacePosition(uv.xy, depth, UNITY_MATRIX_I_VP);
}

// ---------- temp ---------------
// 从视图空间坐标重构屏幕空间的片元uv和深度
void ReconstructUVAndDepth(float3 posWS, out float2 uv, out float depth)
{
    half4 posCS = TransformWViewToHClip(posWS);
    // posCS.w = -posVS.z
    half4 posNDC = posCS / posCS.w;
    depth = posNDC.z;
    uv = float2(posCS.x, posCS.y * _ProjectionParams.x) / posCS.w * 0.5 + 0.5;
}

// 从视图空间坐标重构屏幕空间的片元uv和深度
void ReconstructUV(float3 posWS, out float2 uv)
{
    half4 posCS = TransformWViewToHClip(posWS);
    uv = float2(posCS.x, posCS.y * _ProjectionParams.x) / posCS.w * 0.5 + 0.5;
}

参数:
为了调的时候方便,我将参数进行了缩放:

m_SsrMaterial.SetVector(SsrShaderConstants.SsrParametersID,
    new Vector4(m_Settings.StepCount, m_Settings.Thickness / 100,
        m_Settings.Stride / 100000, m_Settings.RayOffset));


参数需要谨慎调试!

效果:


Debug


还算不错

1. 问题解析
问题1:相机向下看时,出现相同颜色的拉丝现象:

原因其实是我们这一部分已经步进到屏幕外了,采样的点自然也是屏幕外的CameraColor,又由于我们的Warp模式为Clamp,所以采样出来的东西会显示一大长条相同颜色的拉丝,这只需要在我们步进时,检查uv是否到屏幕外就能解决这个问题:

UNITY_LOOP
for (int i = 0; i < STEP_COUNT; i++)
{
    .........
    ReconstructUV(rayVS, currRayUV);

    if (any(currRayUV < 0.0) || any(currRayUV > 1.0))
            // for debug:超出屏幕外的返回黑色
            return half4(0.0, 0.0, 0.0, 1.0);
            // return GetSource(uv);

    float currSampleDepth = SampleSceneDepth(currRayUV).x;
    .........
}

问题2:分级拉丝

这个问题还是比较难以理解的,简单来说就是Stride(步长)太大,高频的细节对不齐,无法完全精确捕捉到像素级别的信息,而我的解决办法就是直接拉小步长,同时提高Step Count:

效果:


高频细节出来了


性能?

2. 性能分析

参数:


这是我后期改的参数,往下看文章就明白了

我用Profiler大体测了一下执行时间,基本上都是0.58ms~0.61ms。

屏幕空间Ray Marching

视图空间步进还是太浪费了,想要高频细节必须要把控很小的Stride,导致很多步骤都是没用的,也就是Over Sample,溯其本源,我们是在屏幕空间做Ray Marching,那么就最大程度地利用好屏幕信息。

先上一段核心代码(不必细抠细节,先看个大概过程),我想重点讲解几个细节:

// 视图空间 ---> 屏幕空间 (默认是点)
float2 TransformViewToScreen(float3 posVS, float2 screenSize,out float InvW)
{
    float4 posCS = TransformWViewToHClip(posVS);
    InvW = 1 / posCS.w;
    float2 uv = float2(posCS.x, posCS.y * _ProjectionParams.x) / posCS.w * 0.5 + 0.5;
    return uv * screenSize;
}

// 屏幕空间RayMarching
float3 ScreenSpaceRayMarching(float2 uv, float3 posVS, float3 reflDirVS, float3 normalVS, float smoothness,
                              float stepCount, float thickness, float stride, float rayOffset, float maxDistance)
{
    // 视图空间中射线起始位置和结束位置
    float3 startVS = posVS;
    float3 endVS = posVS + reflDirVS * maxDistance;

    // inverse w
    // w = -Vz,为了后面方便在屏幕空间插值深度
    float startInvW = 0;
    float endInvW = 0;

    // 屏幕空间中射线起始位置和结束位置 
    // 其实 startSS = uv * _ScreenSize.xy ([0, 1]^2 -> [0, w-1],[0, h-1])
    // 但是为了拿到裁剪空间的w分量, 我们必须使用自定义的TransformViewToScreen函数
    float2 startSS = TransformViewToScreen(startVS, _ScreenSize.xy, startInvW);
    float2 endSS = TransformViewToScreen(endVS, _ScreenSize.xy, endInvW);

    // DDA算法
    float2 deltaSS = endSS - startSS; // 注意方向性!
    bool isVertical = false;
    // 我们默认的是以横轴为主导向,如果纵轴为主导向,则进行反转,并在之后进行复原
    if (abs(deltaSS.x) < abs(deltaSS.y))
    {
        isVertical = true;

        deltaSS = deltaSS.yx;
        startSS = startSS.yx;
        endSS = endSS.yx;
    }

    // 计算屏幕坐标、inverse-w的线性增量
    float dir = sign(deltaSS.x); // 屏幕空间增量方向
    float invdx = dir / deltaSS.x; // 1 / 屏幕空间增量 (绝对值)
    float2 dPosSS = float2(dir, invdx * deltaSS.y);
    float dInvW = (endInvW - startInvW) * invdx;

    // 步长的调整
    dPosSS *= stride;
    dInvW *= stride;

    // 存在自反射,需要加一定的偏移
    float3 rayVS = posVS + normalVS * rayOffset;
    float preZ = rayVS.z;// 作为初始 Z 分量和缓存的作用

    // 起始数据
    float2 currPosSS = startSS;
    float currInvW = startInvW;

    // RayMarching
    UNITY_LOOP
    for (int i = 0; i < stepCount && currPosSS.x * dir <= endSS.x * dir; i++)
    {
        // 缓存前后两步视图空间的Z分量(防止跨像素太大,判断不出相交)
        float rayNearZ = preZ;
        float rayFarZ = -1 / (dInvW * 0.5 + currInvW);// 初始做一次半步长迈进
        preZ = rayFarZ;

        // 视图空间中相机朝向-Z方向
        if (rayNearZ < rayFarZ) 
            Swap(rayNearZ, rayFarZ);

        // 步近
        currPosSS += dPosSS;
        currInvW += dInvW;

        // 得到交点uv  
        float2 hitUV = isVertical ? currPosSS.yx : currPosSS; // 复原
        hitUV *= _ScreenSize.zw;

        if (any(hitUV < 0.0) || any(hitUV > 1.0))
            break;

        // 相交判断
        float surfaceZ = -LinearEyeDepth(SampleSceneDepth(hitUV), _ZBufferParams);
        bool intersecting = (rayFarZ <= surfaceZ) && (rayNearZ >= surfaceZ - thickness);

        if (intersecting)
            // return GetSource(hitUV);
            return GetSource(hitUV) * smoothness + GetSource(uv);
    }

    // return half4(0, 0, 0, 1);
    return GetSource(uv);
}


// -- -- -- -- -- -- Shader Stage -- -- -- -- --
half4 SsrFrag(Varyings input) : SV_Target
{
    .......
    float3 posVS = TransformWorldToView(posWS);

    float3 finalColor = ScreenSpaceRayMarching(uv, posVS, reflDirVS, normalVS, smoothness,
    STEP_COUNT, THICKNESS, STRIDE, RAY_OFFSET, MAX_DISTANCE);

    return half4(finalColor, 1);
}

参数:

效果:


Debug


效果达标!

1. 坐标系转换
首先,防止你忘记不同空间的坐标变换,我让豆老师跑出一份Unity中的坐标转换关系:


视图空间与裁剪空间的转换

裁剪空间中的w分量实质上就是视线空间的z分量的相反数(小知识:如果我们把视线空间的xy分量除以裁剪空间中的w分量,得到的坐标中xy分量一定与NDC坐标成线性关系!)。


NDC空间与视图空间的转换

视线空间的z分量与NDC空间中的z分量不成线性关系,但是视线空间中的1/z是与NDC空间中的z分量成线性关系的!


屏幕空间与NDC空间的转换

从上面几个式子中可以看出来,屏幕空间z分量和视图空间1/z分量成线性关系(Unity中,经过变换之后的屏幕空间坐标的原点是在左上角,故我们应该对其进行反转),这也就是为什么我在屏幕空间插值的是1/z,而不是z。我们使用1/z可以轻松复原视图空间的z分量。

2. 重点细节讲解
1)为什么屏幕空间要用[0, w-1],[0, h-1]?
其实我开始看别人代码的时候想的是为什么不用[0, 1]范围的uv坐标,如果这样也方便采样,不用重新反向映射一次,直到我看到DDA算法要算1/屏幕空间增量(float invdx = dir / deltaSS.x;),我才反应过来。实际上我们横纵范围不能看作一样的,我们屏幕分辨率是不同的(全高清1920×1080,四倍高清2560×1440 ...),想要步进屏幕空间,必须精确到像素级别!

2)为什么循环中做一次初始半步长迈进?

从我们的相交判断条件来看:

  • 判断rayFarZ这个较远点是否被表面遮挡;
  • 判断rayNearZ是否在一定阈值(Thickness)内。

只有两个都满足时,才能说明相交成功,进行采样。那么我们先迈进半步长,之后的判断都在Reflection Dir方向的半步长处进行步进,如下图:

这样是十分舒服的判断,既可以防止首次的判断失误,又确保后面的判断正确!

3)为什么要这个Thickness这个阈值?
其实就是增大容错率,如下图:

3. 问题解析
1)相机向下看时,会有一个自反射圆圈,超出MaxDistance之后会消失。

这个问题我仔细分析了一下,原因是在视图空间中,我们反射出来的最终位置,可能超出近裁面了,我们如果把这个点映射到屏幕空间就会出错(仔细思考MVP变换,如果有个点在你相机后面(不在视锥体内),变换之后的位置必然不在投影盒中,那么映射到屏幕空间必然会出错),所以我们必须要对这个最终反射点进行一次近裁剪面的判断,从而调整反射点的最终位置,如下:

// 视图空间中射线起始位置和结束位置
float3 startVS = posVS;
float3 endVS = posVS + reflDirVS * maxDistance;
if (endVS.z > -_ProjectionParams.y)
    maxDistance = (-_ProjectionParams.y - startVS.z) / reflDirVS.z;
endVS = startVS + reflDirVS * maxDistance;

// inverse w
......

2)屏幕信息缺失
很好理解,毕竟我们在屏幕空间进行反射,那么必定会缺失信息,如下图:

根据Games 202,闫老师给的思路,在屏幕空间进行一次衰减,不同的是,闫老师的思路是利用反射距离进行衰减,我的思路是利用反射点的uv进行衰减。

那么实现步骤也是十分简单的,先在Render Pass传入一个控制衰减的参数attenuation,在Shader中,我使用暗角后处理的控制方法,如下:

if (intersecting)
{
    float2 attUV = abs(hitUV - 0.5) * 1.8;
    attUV = pow(saturate(attUV), attenuation);
    float att = length(attUV);
    float edgeFading= pow(saturate(1 - att * att), 5);

    return GetSource(hitUV) * smoothness * edgeFading + GetSource(uv);
}

这个效果简单来说就是做一次屏幕边缘的衰减,我的想法就是,如果我们hitUV打到了屏幕边缘,那么就肯定给一个小的权重,若打到屏幕中心,那么肯定要给一个大点的权重,同时需要考虑到屏幕长宽的影响,综合考虑之后,我决定使用暗角作为权重。

效果:


效果理想!

在龙爪处我们还是可以看到屏幕空间缺失的问题,这个缺失就是难以Trick的了,如下图:

这一部分可以结合光线追踪,补全我们的信息。

很多材质并非完全镜面反射,我们就可以使用多根光线,结合BRDF和重要性采样实现Glossy的效果。


https://www.bilibili.com/video/BV1YK4y1T7yY?spm_id_from=333.788.videopod.episodes&vd_source=cc304228c1c7e07b176b9fbcfa3ee569&p=9

关于Glossy效果,我觉得可以使用IBL的思路,将CameraColorRenderTexture进行MipMap技术分级,将射线距离(或者屏幕空间距离)作为Level , 使用三线性插值进行采样试试。

4. 性能分析
参数:

静态基本上是0.56ms~0.60ms(这一部分我可能做的不是很好,优化不明显)。

文章有点长,还有一部分(二分搜索,Hierarchical Z Buffer)放到下一期。

参考:

[1] 古墓丽影,来自SSR基础介绍

[2] Games202 ScreenSpaceReflection 39分44秒

[3] Games101 MVP变换

[4] 【Unity教程】小祥带你实现屏幕空间反射SSR_哔哩哔哩_bilibili


这是侑虎科技第2042篇文章,感谢作者毅毅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/20-85-47-35

再次感谢毅毅的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)