[OpenGL] 屏幕空间反射效果

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ZJU_fish1996/article/details/89007236

        

概念引入

        屏幕空间反射又称SSR,是SS系列算法之一。该算法基于延迟渲染,也就是说,在动手实现SSR前,需要具备延迟渲染相关的知识,主要包括:G-Buffer相关信息的写入,根据G-Buffer还原出位置、视线向量、法线等一系列信息等。

        算法本身的原理非常简单,也就是对于屏幕空间上的物体的每个像素,根据该像素对应的法线和视线信息,求解出反射向量。然后从当前点开始沿着反射向量的方向步进,判断步进后的坐标深度与G-Buffer中存储的物体深度是否相等,如果相等意味着出现了交点,取交点处的物体颜色作为最终的反射颜色。

        

        这个算法本身并不是真正的反射,而是一个比较粗糙的模拟反射,因此它的效果和实时光追相比要差很多,根据实验,纯粹地实现SSR而不加任何优化或者掩饰的话,效果实际上非常糟糕。

        首先有一个本质上的局限问题:因为这是一个屏幕空间算法,所以它只能获取到当前屏幕上能看到的像素颜色,如果反射碰撞的部分是物体被遮挡的部分,相机之外的部分之类的地方,那么是无法取到的。对于hit test失败的像素,只能使用立方体映射(从背景cubemap或者预处理过的周围场景cubemap)采样来弥补不足,或者有可能的话,可以在运动画面中从上一帧获取到这一数据。

        另一个非常严重的问题在于,我们的相交检测因为是光线步进算法,而步进是有一定步长的,它本身不可能非常精确。因为步进的时候有一定间隔,因此会出现带状的伪影,或者有些不应该出现反射的地方,因为浮点数相等判定有误差,可能被误判为命中反射,因此出现奇怪的颜色。对于前者,我们如果对光线步进的起始位置做一个随机扰动,效果会稍微好一点(但实际上也只是把带状的失真图像变成了带有颗粒噪点的失真图像,相当于把失真在Ray-match方向”抹匀“了)。

        还有一个常用的可以掩盖一部分SSR缺陷的方法是对反射颜色加模糊。更为物理正确的做法是根据表面粗糙度和反射的长度来确定模糊的程度,要么对G-Buffer中的颜色做降采样生成mipmap金字塔图像,然后根据模糊程度来选择对应采样图像,要么直接对采样到的像素做高斯模糊处理。

        这种局限也就限制了SSR的应用场景。比如,如果我们想要实现非常光滑、清晰的镜子效果,那么最优的选择应该是平面投影,也就是用一个额外的相机单独渲染镜子里的场景(光线追踪也可以,但是在游戏中流畅应用目前估计还有点困难)。而对于SSR,我们通常用在一些对反射质量要求不那么高的地方,比如下雨后地面上出现了积水,或者带着光污染玻璃的高楼等。对于前者而言,从积水中能够隐隐约约地看到反射的物体颜色和轮廓,由于积水本身就带有一些法线扰动以及高光的干扰,会在一定程度上扭曲图像,此时反射的图像的精确程度也就不那么重要了。

        我们在游戏中引入SSR,是一种相对而言比较廉价的实时反射方式,会在一定程度上提升画面的质感,也会随着物体的运动而改变反射的内容。此时再加上传统的反射方式,即立方体贴图做配合,就能初步构建起反射系统。

效果演示

        本身做这个demo是因为有一次看书无意中看到了对屏幕空间反射的算法描述,本身只有短短几行,但是看过之后基本能够理解意思。于是就想着先实现一遍这个算法,看下我的理解有没有问题。当然做出来之后我就非常后悔了,因为单纯地实现SSR后效果会令人非常失望,基本上能看到的画面大概是这样的:

        

最初的版本,非常惨

        要么是这样的:

采样步长太小时

        

加了偏移(有噪点),没做高斯模糊的,模拟光滑地面

        不管怎样,看起来都不像是能够很好地应用到游戏内容中的效果。我对于结果没有做太多的优化,最终勉强能够达到最开始图片的效果,但仍然避免不了一些错误识别的反射(如球体下部左右有一些白色的噪点),以及特定角度下出现的可能获取不到的反射颜色。

        以上效果仅处理了地面的反射,如果把所有物体的反射都开启,效果则是这样的,感觉还是不开比较好……

        

反射全开的

具体实现

        目前整个计算是在视图空间中完成的,可能使用屏幕空间的恒定步长结果会更准确。

        首先,计算一下反射向量,并把反射向量转换到视图空间。加上mat3是为了消除位移影响。

vec3 reflectDir = reflect(-viewDir, normal);
vec3 viewReflectDir = mat3(ViewMatrix) * reflectDir;

        之后,获取随机起始步长,这个jitter我是从体积光项目里拿来的,没有确认Crytex一开始是怎么做的。

mat4 dither = mat4(
   0,       0.5,    0.125,  0.625,
   0.75,    0.25,   0.875,  0.375,
   0.1875,  0.6875, 0.0625, 0.5625,
   0.9375,  0.4375, 0.8125, 0.3125
);
int sampleCoordX = int(mod((ScreenX * v_texcoord.x),4));
int sampleCoordY = int(mod((ScreenY * v_texcoord.y),4));
float offset = dither[sampleCoordX][sampleCoordY];

         从g-buffer中得到物体的基本颜色,然后对于需要反射的像素,基本颜色和cubemap采样的反射颜色做一次混合,作为默认反射值颜色。

vec3 albedo = texture2D(Color, v_texcoord).xyz;
albedo = mix(albedo,textureCube(CubeMap, reflectDir).xyz,0.2);

        设定步长和总步数,此处步数也可以根据需求写成定值,相当于太远的东西属于次要信息,就不去计算反射了。

float step = 1;
int stepNum = int(distance(farPos - viewPos) / step);

        开始进入Ray-Match循环,首先计算出沿着反射向量方向的步进长度,然后进一步得到最终的步进位置pos:

for(int i = 1;i <= stepNum;i++)
{
    float delta = step * i + offset;
    vec3 pos = viewPos + viewReflectDir * delta;

        记录当前的深度,对于相机前物体,深度为负数,我们对其取个反:

float d = -pos.z;

        然后,将视图空间的位置转换到屏幕空间,此时x,y取值范围为[-1,1],将其重映射到[0,1]之间,则正好对应G-buffer的纹理坐标:

vec4 tmp = ProjectMatrix * vec4(pos,1);
vec3 screenPos = tmp.xyz / tmp.w;
vec2 uv = vec2(screenPos.x, screenPos.y) * 0.5 + vec2(0.5, 0.5);

        判断当前纹理坐标是否在屏幕空间内(如果已经出去了就认为命中失败了),并用该uv从线性深度纹理中采样,得到的结果乘以zFar做反解码(存储深度纹理是写法是-depth/zFar),因为正数的深度比较好看,所以这里就不取反还原回原深度数据了。此时得到的深度是视图空间深度。

if(uv.x > 0 && uv.x < 1 && uv.y > 0 && uv.y < 1)
{
    float depth = texture2D(NormalAndDepth, uv).w * zFar;

        检测到射线深度已经大于场景物体深度时,进一步判断两者的差是否小于一定阈值,通过判断则认为该位置是反射位置,我们采样该位置的高斯模糊颜色,然后跳出循环。

if(d > depth)
{
    if(abs(d - depth) < eps)
    {
        float newId = texture2D(Param, uv).w;
        albedo = GetGaussColor(uv);
        break;
    }
}

        计算模糊颜色的偷懒版本(因为最好用mipmap降采样或者做水平竖直方向的模糊:)

float gauss[] = float[]
(
    0.00000067, 0.00002292, 0.00019117, 0.00038771, 0.00019117, 0.00002292, 0.00000067,
    0.00002292, 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633, 0.00002292,
    0.00019117, 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965, 0.00019117,
    0.00038771, 0.01330373, 0.11098164, 0.22508352, 0.11098164, 0.01330373, 0.00038771,
    0.00019117, 0.00655965, 0.05472157, 0.11098164, 0.05472157, 0.00655965, 0.00019117,
    0.00002292, 0.00078633, 0.00655965, 0.01330373, 0.00655965, 0.00078633, 0.00002292,
    0.00000067, 0.00002292, 0.00019117, 0.00038771, 0.00019117, 0.00002292, 0.00000067
);


vec3 GetGaussColor(vec2 uv)
{
    const int size = 7;

    vec3 finalColor = vec3(0,0,0);

    int idx = 0;
    for(int i = -3;i <= 3;i++)
    {
        for(int j = -3; j <= 3;j++)
        {
            vec2 offset_uv = uv + vec2(5.0 * i /ScreenX, 5.0 * j /ScreenY);
            vec3 color = texture2D(Color, offset_uv).xyz;
            float weight = gauss[idx++];
            finalColor = finalColor + weight * color;
        }
    }

    return finalColor;
}

        本地还做了一个二分搜索优化,主要思想是在检测到射线深度大于场景物体深度时,将当前采样位置和上一采样位置作为二分的两个端点,然后检测中间的采样位置中的深度与场景物体深度差是否小于一定阈值,如果没有则根据两个深度的关系将起点设为当前中点,或者终点设为当前中点。

        最好设置一个循环次数限制(之前没有设置的时候会卡死)。我猜想是因为场景深度并不具有单调性,它的深度只具有局部的单调性。否则我们会在一开始就做二分搜索,而不是找到交点的取值范围,再在这个小范围内做二分,我们能在这一小范围做二分也正是因为我们默认了这段可能满足了局部单调性。但是这不是绝对的,所以我们依然可能找不到最终的命中位置,因此有必要设置一个最大循环次数。

       加了二分搜索后,虽然反射地命中确实多了不少,但反而还在很多不应该有反射命中的地方出现了反射颜色……我不确定是不是自己的实现有问题还是这算法本身就有这毛病,所以就没把代码放上来了。

猜你喜欢

转载自blog.csdn.net/ZJU_fish1996/article/details/89007236