本部分主要结合上一部分的Vulkan_片元着色器特效4(高动态范围HDR)来综合展示HDR+泛光场景,主要参照:LearnOpenGL中的Bloom章节。
一、基本原理
Bloom使我们能够注意到一个明亮的物体真的有种明亮的感觉。泛光可以极大提升场景中的光照效果,并提供了极大的效果提升,尽管做到这一切只需一点改变。
Bloom和HDR结合使用效果很好。常见的一个误解是HDR和泛光是一样的,很多人认为两种技术是可以互换的。但是它们是两种不同的技术,用于各自不同的目的上。可以使用默认的8位精确度的帧缓冲,也可以在不使用泛光效果的时候,使用HDR。只不过在有了HDR之后再实现泛光就更简单了。
为实现泛光,我们像上一节那样渲染一个有光场景,提取出场景的HDR颜色缓冲以及只有这个场景明亮区域可见的图片。被提取的带有亮度的图片接着被模糊,结果被添加到HDR场景上面。
二、开发步骤
2.1 提取亮色
我们首先在HDR颜色缓冲(G-Buffer片元着色器)中,提取所有超出一定亮度的fragment,然后将其存储到附件1中(之前的HDR存储在附件0中):
...
layout (location = 0) out vec4 outColor0;//附件0:HDR
layout (location = 1) out vec4 outColor1;//附件1:Bloom
void main()
{
...
// 曝光色调映射 填入附件0
outColor0.rgb = vec3(1.0) - exp(-color.rgb * ubo.exposure);
// 判断泛光阈值 填入附件1
float bloom = dot(outColor0.rgb, vec3(0.2126, 0.7152, 0.0722));
//阈值
float threshold = 0.75;
outColor1 = vec4((bloom > threshold) ? outColor0.rgb : vec3(0.0) , 1.0);
}
我们通过恰当地将其转为灰度的方式计算一个fragment的亮度,如果它超过了一定阈限,我们就把颜色输出到第二个颜色缓冲附件中,那里保存着所有高亮颜色。
经过上边的过滤,我们就可以有了两个颜色缓冲附件,一个正常场景的图像和一个提取出的亮区的图像;这些都在G-Buffer中处理得到的。
附件0:
附件1:
有了一个提取出的亮区图像,我们现在就要把这个图像进行模糊处理。我们可以使用帧缓冲教程后处理部分的那个简单的盒子过滤器,但不过我们最好还是使用一个更高级的更漂亮的模糊过滤器:高斯模糊(Gaussian blur)。
2.2 高斯模糊
高斯模糊是一种图像空间效果,用于创建原始图像的柔和模糊版本。然后,可以通过更复杂的算法使用此图像来产生类似光晕,景深,热雾或模糊玻璃的效果。在本文中,我将介绍一种可以通过利用双线性纹理滤波来减少原始高斯模糊滤波器实现的性能的技术,及尽量少的的纹理查找数。参照可见线性采样的高效高斯模糊。
图像空间高斯滤波器是一个NxN抽头卷积滤波器,它基于高斯函数对其覆盖范围内的像素进行加权:
使用从高斯函数获得的值对滤波器覆盖区的像素进行加权,从而提供模糊效果。高斯滤镜的空间表示(有时称为“钟形表面”)展示了足迹中各个像素对最终像素颜色的贡献程度。
为了获得更有效的算法,我们必须对高斯函数的一些好特性进行一些分析:
- 二维高斯函数可以通过将两个一维高斯函数相乘来计算;
- 分布为2σ的高斯函数等于分布为σ的两个高斯函数的乘积。
基于第一个属性,我们可以将二维高斯函数分成两个一维函数。在使用片段着色器的情况下,这意味着我们可以将高斯滤镜分为水平模糊滤镜和垂直模糊滤镜,在渲染后仍可获得准确的结果,这叫做两步高斯模糊。
这意味着我们如果对一个图像进行模糊处理,至少需要两步,最好使用帧缓冲对象做这件事。它的意思是,有一对儿帧缓冲,我们把另一个帧缓冲的颜色缓冲放进当前的帧缓冲的颜色缓冲中,使用不同的着色效果渲染指定的次数。基本上就是不断地切换帧缓冲和纹理去绘制。这样我们先在场景纹理的第一个缓冲中进行模糊,然后在把第一个帧缓冲的颜色缓冲放进第二个帧缓冲进行模糊,接着,将第二个帧缓冲的颜色缓冲放进第一个,循环往复。
在我们处理帧缓冲之前,先讨论高斯模糊的像素着色器:
#version 450
layout (binding = 0) uniform sampler2D samplerColor0;//HDR附件
layout (binding = 1) uniform sampler2D samplerColor1;//泛光附件
layout (location = 0) in vec2 inUV;
layout (location = 0) out vec4 outColor;
layout (constant_id = 0) const int dir = 0;
//learn OpenGL
void main()
{
const float weight[5] = float[] (0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);
vec2 tex_offset = 1.0 / textureSize(samplerColor1, 0); // gets size of single texel
vec3 result = texture(samplerColor1, inUV).rgb * weight[0]; // current fragment's contribution
if(dir == 1)
{
for(int i = 1; i < 5; ++i)
{
result += texture(samplerColor1, inUV + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
result += texture(samplerColor1, inUV - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
}
}
else
{
for(int i = 1; i < 5; ++i)
{
result += texture(samplerColor1, inUV + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
result += texture(samplerColor1, inUV - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
}
}
outColor = vec4(result, 1.0);
}
//OpenGL Super bible
void main01(void)
{
const float weights[] = float[](0.0024499299678342,0.0043538453346397,0.0073599963704157,0.0118349786570722,0.0181026699707781,
0.0263392293891488,0.0364543006660986,0.0479932050577658,0.0601029809166942,0.0715974486241365,
0.0811305381519717,0.0874493212267511,0.0896631113333857,0.0874493212267511,0.0811305381519717,
0.0715974486241365,0.0601029809166942,0.0479932050577658,0.0364543006660986,0.0263392293891488,
0.0181026699707781,0.0118349786570722,0.0073599963704157,0.0043538453346397,0.0024499299678342);
const float blurScale = 0.003;
const float blurStrength = 1.0;
float ar = 1.0;
// 垂直模糊通道的纵横比
if (dir == 1)
{
vec2 ts = textureSize(samplerColor1, 0);
ar = ts.y / ts.x;
}
vec2 P = inUV.yx - vec2(0, (weights.length() >> 1) * ar * blurScale);
vec4 color = vec4(0.0);
for (int i = 0; i < weights.length(); i++)
{
vec2 dv = vec2(0.0, i * blurScale) * ar;
color += texture(samplerColor1, P + dv) * weights[i] * blurStrength;
}
outColor = color;
}
这里我们使用一个比较小的高斯权重做例子,每次我们用它来指定当前fragment的水平或垂直样本的特定权重。你会发现我们基本上是将模糊过滤器根据我们在创建管线时候用的特殊性常量dir设置的值分割为一个水平和一个垂直部分。通过用1.0除以纹理的大小(从textureSize得到一个vec2)得到一个纹理像素的实际大小,以此作为偏移距离的根据。
此时,在创建管线的时候,区分常量管线:
//第一遍模糊
uint32_t dir = 1;
specializationInfo = vks::initializers::specializationInfo(1, specializationMapEntries.data(), sizeof(dir), &dir);
shaderStages[1].pSpecializationInfo = &specializationInfo;
vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.bloom[0]);
//第二遍模糊
pipelineCreateInfo.renderPass = filterPass.renderPass;
dir = 0;
vkCreateGraphicsPipelines(device, pipelineCache, 1, &pipelineCreateInfo, nullptr, &pipelines.bloom[1]);
之后在渲染的时候,我们在三个不同的渲染通道中分别渲染:
/*
第一次渲染通道:渲染场景到帧缓冲器
*/
...
/*
第二次渲染通道:第一次水平模糊
*/
...
vkCmdBindPipeline(drawCmdBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelines.bloom[1]);
vkCmdDraw(drawCmdBuffers[i], 3, 1, 0, 0);
...
/*
第三次渲染通道:应用第二次通道(水平模糊)进行竖直模糊及场景渲染
*/
...
vkCmdBindPipeline(drawCmdBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelines.bloom[0]);
vkCmdDraw(drawCmdBuffers[i], 3, 1, 0, 0);
...
我们也可以从工具中看出此流程:
通过模糊处理后:
从图中我们可以看到,使用高斯模糊后高亮出所有像素都得到了相应的模糊处理。
2.3 泛光效果
从渲染工具流程上来看,我们可以看到:
34行的:vkCmdDraw,我们直接采样附件0:HDR场景;
36行的:vkCmdDraw,我们直接使用第一次模糊的泛光效果;
两者叠加后,可见如下效果:
除此之外,我们在泛光片元着色器中也保留了《OpenGL超级宝典》中的高斯模糊代码,可以实现效果更好的泛光表现,有兴趣的可以尝试。