文章

基于Unity URP实现SSGI

基于Unity URP实现SSGI

​ ​ 在完成了SSR之后,趁热打铁,紧接着实现SSGI相关的内容。对于这部分内容,我是利用AI将我自己的想法一步步实现,并总结成此文。

​ ​ 在写这篇文章之前,我用AI工具把代码实现,看了效果感觉差不多的时候。我已经用AI写了两篇博客,但AI写的文章,没有人味儿,仍然是晦涩的文字,所以我还是决定自己重新把这部分内容梳理一遍。

关于SSGI的一般概述

​ ​ 在介绍SSGI之前,还是回顾一下GI研究的对象。我们都知道三维物体的光照,分为直接光照和间接光照。

​ ​ 直接光照部分就是物体表面直接受光照射时反射的能量,这部分内容只需要考虑着色点的可见性(是否被遮挡),着色点受光照射时反射进入人眼睛的能量(颜色强度),这部分能量是直接来自光源的,如下图所示:

image-20260720040732218.png

​ ​ 间接光照就是除了直接光照之外,接收到的来自四面八方的光,它可能来自天空,也可能来自其他物体表面反射的光。如下图就是间接光照加直接光照的结果,看起来很接近真实情况。

image-20260720040746822.png

​ ​ 在实现SSGI方法之前,最好先掌握经典的 Path Tracing方法。推荐阅读一周光追三部曲。跟着书中的内容,实现一个 Path Tracing管线,并把其中的概念理解清楚之后,这样再去理解其他GI算法时,心里有谱。我个人一边阅读一边翻译,总结在了我的博客里,翻译有不准确的地方,去读原文。

​ ​ 关于SSGI,全称是 Screen Space Global Illumination。即在屏幕空间中,计算间接光照的方法。为了实现该方法,我构造了一个简单的直接光照场景,从Scene视图可以看到相机的摆放位置,内部的照明情况和几何分布。

image-20260719040756421.png

​ ​ 该场景的第一视角渲染结果和深度图如下,我们的目标就是基于这两个输入,计算黑色部分的受光情况。

image-20260719040904575.png

image-20260719041042466.png

SSGI基础版的实现流程

​ ​ SSGI的大致流程可以总结成下图,其中 Camera Color Texture被用了两次。SSGI Pass只输出一次弹射的结果,然后与历史帧进行合并,最后与直接光照结果(Camera Color Texture)进行混合。当相机或参数有变化时,则清空历史帧数据,重新进行累加。如果需要,还可以在Composite Pass之前插入一个降噪的Pass,例如高斯模糊。该流程的结果,就是蒙特卡洛积分的结果。

image-20260720025805743.png

SSGI Pass的实现

​ ​ SSGI Pass的实现前半部分与 SSR的实现一致,很多优化都可以沿用,唯一的区别就是反射光线的计算和irradiance的计算。

image-20260720031839373.png

float4 FragSSGI(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;


    float rawDepth = SampleSceneDepth(uv);
#if UNITY_REVERSED_Z
    if (rawDepth <= 0.000001) return float4(0, 0, 0, -1.0);
#else
    if (rawDepth >= 0.999999) return float4(0, 0, 0, -1.0);
#endif

    float3 viewPos = ComputeViewSpacePosition(uv, rawDepth);
    float3 normal = GetNormalFromPosition(viewPos);
    if (dot(normal, -viewPos) <= 0.0) normal = -normal; 
    float3 irradiance = 0.0;
    int rayCount = clamp(_SSGIRayCount, 1, 128);  
    [loop]
    for (int i = 0; i < rayCount; i++)
    {
        float3 rayDir = BuildHemisphereDirection(normal, input.positionCS.xy, i);
        float3 rayStartVS = viewPos + normal * _SSGIOriginBias;
        float3 rayEndVS = rayStartVS + rayDir * _SSGIMaxDistance;   
        #if defined(_SSGI_HIZ_ON)
        ScreenSpaceRayHit hit = MarchScreenSpaceRayHiZ(rayStartVS, rayEndVS, _SSGIMaxSteps, _SSGIThickness);
        #else
        // ScreenSpaceRayHit hit = MarchScreenSpaceRayDDA(rayStartVS, rayEndVS, _SSGIMaxSteps, _SSGIThickness);
        ScreenSpaceRayHit hit = MarchScreenSpaceRayBinary(rayStartVS, rayEndVS, _SSGIMaxSteps, _SSGIThickness, SCREEN_SPACE_RAY_BINARY_STEPS);
        #endif
        if (hit.hit)
        {
            float3 hitRadiance = SAMPLE_TEXTURE2D_X(_SSGIRadianceTexture, sampler_LinearClamp, hit.hitUV).rgb;
            irradiance += hitRadiance;
        }
    }   
    float3 outgoingRadiance = irradiance/rayCount;
    return float4(outgoingRadiance * _SSGIIntensity, min(viewPos.z, 65500.0));
}

​ ​ 这里我让AI参考了[1]中介绍的半球余弦光线的计算方法,但看结果还是有点出入,_SSGISampleOffset当前累积的帧数统计。

float2 HashRandom(float2 pixelPosition)
{
    float3 p3 = frac(float3(pixelPosition.xyx) * float3(0.1031, 0.1030, 0.0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return frac((p3.xx + p3.yz) * p3.zy);
}

float3 BuildHemisphereDirection(float3 normal, float2 pixelPosition, int rayIndex)
{
    uint sampleIndex = ((uint)clamp(_SSGISampleOffset, 0, 65535) + (uint)rayIndex) & 0xFFFFu;
    uint2 phase24 = (uint2(sampleIndex, sampleIndex) * SSGI_R2_STEP24) & uint2(0x00FFFFFFu, 0x00FFFFFFu);
    float2 temporalSample = float2(phase24) * SSGI_R2_SCALE24;
    float2 xi = frac(HashRandom(pixelPosition) + temporalSample);
    return SampleHemisphereCosine(xi.x, xi.y, normal);
}

​ ​ Irradiance的计算,直接取采样得到的颜色。按AI的说法,没有除以PDF(概率密度函数),是因为​n\cdot L与PDF一样,约掉了。

Accmulate Pass的实现

​ ​ Accmulate Pass需要把当前帧与历史帧进行合并,其中还按深度对某些像素的结果进行了剔除。并且通过 _SSGIHistoryWeight来控制混合结果。如果无限地累加下去,场景会越来越暗,我已经不记得AI的说法了,但是加上这个参数(范围是0-1),就可以获得一个比较稳定的结果,但是会有一些噪点。这个参数按我的理解,是控制蒙特卡洛积分公式中的N的数量。

float4 FragAccumulate(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;
    float4 current = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
    if (_SSGIHistoryValid <= 0.0)
        return curren
    float4 history = SAMPLE_TEXTURE2D_X(_SSGIHistoryTexture, sampler_LinearClamp, uv);
    bool currentHasGeometry = current.a >= 0.0;
    bool historyHasGeometry = history.a >= 0.0;
    if (currentHasGeometry != historyHasGeometry || !currentHasGeometry)
        return curren
    float currentDepth = current.a;
    float historyDepth = history.a;
    float depthThreshold = max(_SSGIHistoryDepthThreshold, currentDepth * 0.01);
    if (abs(currentDepth - historyDepth) > depthThreshold)
        return curren
    float3 accumulated = history.rgb + (current.rgb - history.rgb) * _SSGIHistoryWeight;
    return float4(accumulated, current.a);
}

Composite Pass的实现

​ ​ Composite Pass就比较简单,与Camera RT进行混合即可。

float4 FragComposite(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;
    float4 indirect = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
    float3 original = SAMPLE_TEXTURE2D_X(_OriginalTexture, sampler_LinearClamp, uv).rgb;
    return float4(original + indirect.rgb * saturate(_SSGIMaxBlend), 1.0);
}

最终结果

​ ​ 加上高斯模糊后,混合结果如下,可以看到场景被照亮了。

image-20260720033746208.png

​ ​ 但是没有渗色,左边蓝色的墙没有在球上体现,右边红色的墙也没有反射到球上以及白色的地板上,全都是白色是因为直接顶部的白色的面片的影响最大,我们没有把它当做面光源,所以直接光照中它没有把场景照亮,而大部分像素在计算间接光照时,都受它影响,黑色部分就被计算成白色了。

解决渗色的问题

​ ​ 引起这个问题的本质原因是,没有把顶部面片当做面光源来处理。那么在计算直接光照时,计算面光源的结果是否可行呢?答案当然是的。但是我另辟蹊径,在计算渲染物体直接光照时,把累积的 Irradiance Texture输入进去,作为间接光照的结果,来和直接光照直接相加,作为物体的渲染结果,而 SSGI PassAccmulate Pass只输出最新的 Irradiance Texture,这样就可以在渲染物体的时候,把物体表面本身的 albedo考虑进来,最终渲染结果如下:

image-20260720034747085.png

参考资料

[1] Unity实现SSGI

许可协议:  CC BY 4.0