基于Unity URP实现SSGI
基于Unity URP实现SSGI
在完成了SSR之后,趁热打铁,紧接着实现SSGI相关的内容。对于这部分内容,我是利用AI将我自己的想法一步步实现,并总结成此文。
在写这篇文章之前,我用AI工具把代码实现,看了效果感觉差不多的时候。我已经用AI写了两篇博客,但AI写的文章,没有人味儿,仍然是晦涩的文字,所以我还是决定自己重新把这部分内容梳理一遍。
关于SSGI的一般概述
在介绍SSGI之前,还是回顾一下GI研究的对象。我们都知道三维物体的光照,分为直接光照和间接光照。
直接光照部分就是物体表面直接受光照射时反射的能量,这部分内容只需要考虑着色点的可见性(是否被遮挡),着色点受光照射时反射进入人眼睛的能量(颜色强度),这部分能量是直接来自光源的,如下图所示:

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

在实现SSGI方法之前,最好先掌握经典的 Path Tracing方法。推荐阅读一周光追三部曲。跟着书中的内容,实现一个 Path Tracing管线,并把其中的概念理解清楚之后,这样再去理解其他GI算法时,心里有谱。我个人一边阅读一边翻译,总结在了我的博客里,翻译有不准确的地方,去读原文。
关于SSGI,全称是 Screen Space Global Illumination。即在屏幕空间中,计算间接光照的方法。为了实现该方法,我构造了一个简单的直接光照场景,从Scene视图可以看到相机的摆放位置,内部的照明情况和几何分布。

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


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

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

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);
}
最终结果
加上高斯模糊后,混合结果如下,可以看到场景被照亮了。

但是没有渗色,左边蓝色的墙没有在球上体现,右边红色的墙也没有反射到球上以及白色的地板上,全都是白色是因为直接顶部的白色的面片的影响最大,我们没有把它当做面光源,所以直接光照中它没有把场景照亮,而大部分像素在计算间接光照时,都受它影响,黑色部分就被计算成白色了。
解决渗色的问题
引起这个问题的本质原因是,没有把顶部面片当做面光源来处理。那么在计算直接光照时,计算面光源的结果是否可行呢?答案当然是的。但是我另辟蹊径,在计算渲染物体直接光照时,把累积的 Irradiance Texture输入进去,作为间接光照的结果,来和直接光照直接相加,作为物体的渲染结果,而 SSGI Pass和 Accmulate Pass只输出最新的 Irradiance Texture,这样就可以在渲染物体的时候,把物体表面本身的 albedo考虑进来,最终渲染结果如下:

参考资料
[1] Unity实现SSGI