0%

实时渲染学习笔记—光晕效果(bloom)

本系列主要为作者本人在学习实时渲染过程中,针对特定问题或技术做的研究笔记。其中所有涉及的点,都会实现在作者本人的学习向渲染引擎MyCGLab上,仓库地址如下,欢迎围观:https://github.com/WingerZeng/MyCGLab

先上效果图


以上图片由MyCGLab渲染。

简介

光晕(Bloom)是相机和人眼观察明亮事物时时常会出现的现象。表现为光源处向外扩散的明亮带,产生主要原因为镜片系统间的散射,和传感器单元之间的电流溢出。

由于现代显示器的显示亮度有限,对于一些较亮光源的显示亮度远达不到现实世界中的亮度,因而也不会产生光晕现象。为了让光源在亮度不足的情况下看起来更真实,我们就要在光源周围“捏造”出光晕效果。这就是本文的由来。

简单思路

  1. 提取亮部
  2. 将亮部Blur,叠加到原图上

一幅图概括算法:

提取亮部

在OpenGL中可以使用MRT(Multiple Render Targets,多渲染目标),将场景亮部额外渲染到另一个纹理附件上。

代码取自LearnOpenGL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 帧缓冲初始化代码
GLuint hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
GLuint colorBuffers[2];
glGenTextures(2, colorBuffers);
for (GLuint i = 0; i < 2; i++)
{
glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// attach texture to framebuffer
glFramebufferTexture2D(
GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
);
}
// 设定渲染到多个颜色附件
GLuint attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, attachments);


// 用于分离亮部的片段着色器
#version 330 core
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;

[...]

void main()
{
[...] // first do normal lighting calculations and output results
FragColor = vec4(lighting, 1.0f);
// Check whether fragment output is higher than threshold, if so output as brightness color
float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if(brightness > 1.0)
BrightColor = vec4(FragColor.rgb, 1.0);
}

高斯模糊

目前的Bloom效果广泛使用高斯核函数(高斯模糊)

我们的目标是对图像的亮部进行高斯模糊,启发式的方法为:对每一像素,基于二维高斯函数,采样亮部纹理图的周围像素进行加权和:

如果采用上述方法,如果我们要实现32×32的高斯kernel,则要进行1024次纹理采样!
为了进行加速,我们可以使用二维高斯函数的以下特性:

1. 可将二维高斯函数化为两个如下的一维高斯函数的乘积,从而将一次二维高斯滤波化为两次一维高斯滤波。

2. 连续应用高斯模糊等价于应用单个具有更大kernel的高斯模糊。

利用特性1,我们只需要两次的32次纹理采样,就能完成32×32的高斯kernel。

高斯权重的选择

高斯函数实际上是正态分布的分布函数,而正态分布的离散表示具有按二项式分布的权重:

我们可以用上表来计算滤波权重。进一步的,我们可以丢弃每一行两端的几项来优化性能,因为这几项在计算中的贡献较小,丢弃他们可以减少纹理采用次数。

以下代码实现了9×9的一维高斯滤波,系数上取了第12行的中间9个,丢弃了两端的1和12。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
uniform sampler2D image;

out vec4 FragmentColor;

uniform float offset[5] = float[](0.0, 1.0, 2.0, 3.0, 4.0);
uniform float weight[5] = float[](0.2270270270, 0.1945945946, 0.1216216216,
0.0540540541, 0.0162162162);

void main(void) {

FragmentColor = texture2D(image, vec2(gl_FragCoord) / 1024.0) * weight[0];

for (int i=1; i<5; i++) {

FragmentColor +=
texture2D(image, (vec2(gl_FragCoord) + vec2(0.0, offset[i])) / 1024.0)
* weight[i];

FragmentColor +=
texture2D(image, (vec2(gl_FragCoord) - vec2(0.0, offset[i])) / 1024.0)
* weight[i];
}
}

应用双线性纹理过滤进行优化

在GPU中,我们进行一次双线性纹理采样的代价总是小于两次临近纹理采样。而在(2N+1)×(2N+1)的高斯滤波中,我们可以利用双线性采样,将单次一维滤波的采样次数从2N+1降低到N+1次。

对于两个邻近样本点t1、t2,权重分别为weightD(t1)、weightD(t2),坐标分别为offsetD(t1)、offsetD(t2)。我们可以对两样本点中间的某一特定位置点进行采样,从而进行等价替换。该等价样本点的权重和坐标分别为:

优化后的GLSL代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
uniform sampler2D image;

out vec4 FragmentColor;

uniform float offset[3] = float[](0.0, 1.3846153846, 3.2307692308);
uniform float weight[3] = float[](0.2270270270, 0.3162162162, 0.0702702703);

void main(void) {

FragmentColor = texture2D(image, vec2(gl_FragCoord) / 1024.0) * weight[0];

for (int i=1; i<3; i++) {

FragmentColor +=
texture2D(image, (vec2(gl_FragCoord) + vec2(0.0, offset[i])) / 1024.0)
* weight[i];

FragmentColor +=
texture2D(image, (vec2(gl_FragCoord) - vec2(0.0, offset[i])) / 1024.0)
* weight[i];
}
}

降采样+升采样方法

之前的算法是在原图的亮部上施加一定范围的高斯滤波,从而达到Bloom的效果。如果要实现大范围的Bloom效果,只能通过增大滤波宽度、增加滤波次数来实现。这样会导致性能存在瓶颈。更加实用的方法是先将全分辨率原图降采样为不同更小分辨率的纹理(相当于Mipmaps),再将这些图片升采样为原分辨率并叠加到原图上,形成具有较大范围的Bloom。根据算法的不同,会在降采样和升采样之间进行不同的滤波操作,进一步提升画质。

可以参考下图

本文顶端的效果图就是用该方法实现的

Pros and Cons

这种方法的好处是可以在低分辨率图像上进行小范围滤波,从而近似达到在高分辨率图像上大范围滤波等价的效果。比如在128x128分辨率上进行5x5的滤波,近似于在1024x1024分辨率上进行40x40滤波的效果,大幅地提升了性能;但该方法也有一个缺点就是时域上并不稳定,这也是非常好理解的,如果有一块非常亮的区域集中在几个像素上,那么随着这个区域位置的变化,在Mipmaps中可能会突然从某个像素跳动到另外一个像素,而Mipmaps上的一个像素对应原图的多个像素,从而形成帧之间的flick。

NEXT GENERATION POST PROCESSING IN CALL OF DUTY: ADVANCED WARFARE这篇ppt介绍了相关的一些解决方法,并应用在了《使命召唤:高级战争》中。概括起来,有以下几点:

  1. 不进行亮部过滤,直接使用全部的HDR颜色纹理作为输入;
  2. 优化的的降采样滤波器;
  3. 优化的的升采样滤波器。

降采样

该文章中的降采样方式如下所示

图中每个细黑线方框代表Mip N中的一个texel,中心的粗黑线方框代表Mip N+1中的一个texel,对应Mip N中的4个texel。算法目标是求出该texel的值。

图中的每个圆圈代表了一次指定位置的双线性过滤纹理查询,我们注意到每个圆圈都落在四个texel的中心处,因此查询结果也就代表了四个texel的平均值。上方的彩色的数字代表了每种颜色的权重,实际上每种颜色有四个圆圈,每个圆圈的权重值还要除以四。

整个过程可以理解为5个“4次双线性纹理查询平均值”的加权平均和。由于查询位置有重复,实际上只要做13次纹理查询。

解决Firefiles(过亮点)

为了压制过亮点产生的时域不稳定性,该文中在Mip0到MIP1之间的降采样中引入了“Karis Average”:

具体就是对于每一个“4次双线性纹理查询平均”,对于四个查询值按照上述的weight进行加权平均(权重值需要归一化),达到将过亮texel权重值降低的目的。

升采样

对于升采样,要实现光滑重建全分辨率图像,需要进行一定的滤波。如果直接将N个Mip逐个重建到全分辨率,那我们就需要N次滤波,而将高阶Mip直接重建所需的滤波函数代价是很高的。因此该文作者提出“progressively”的升采样方式。如下图所示:

简单来说,就是,将Mip5重建到Mip4分辨率,并与Mip4叠加;叠加后的Mip4重建到Mip3分辨率,与Mip3叠加,以此类推。这样做的好处在于,每次重建时可以采用较为简单的滤波函数,比如采用tent函数,最终的效果就相当于对Mip5进行了5次tent滤波升采样,近似于对Mip5直接用高斯滤波到Mip0,而对所有Mips升采样的开销,明显是后者更高。

该文章就采用了以下的tent kernel进行升采样:

注意点

笔者在复现上述算法的过程中发现了几个需要注意的点:

  1. 算法中去除了亮部过滤这一步,但笔者实践发现,如果不进行亮部过滤,光源处的bloom效果就容易被场景的其余部分遮盖住。笔者分析其原因在于该算法更适用于具有大hdr范围的场景,也就是说光源的辐射度远高于场景的其余部分,自然也就不需要进行亮部过滤了。但其实这也是更物理正确的,因为在我们日常生活中,只有哪些亮度远高于场景的光源才会产生bloom,而一般较暗的光源,其实并不会产生bloom。
  2. 算法中在第一次降采样时引入了“Karis Average”解决过亮点造成的时域不稳定性。但笔者发现在没有过亮点的情况下,这个平均反而会造成bloom效果不明显。其实也正是如1所说的,该算法是针对高辐射度光源的bloom的,笔者所渲染的场景并没有这么大的HDR范围,为了增强bloom的效果,就去除了这一步骤。

代码

实现降采样+升采样的GLSL代码如下:

FragShader_BloomFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#version 430 core
out vec4 FragColor;
in vec2 texCoord;
uniform sampler2D prevTex; //滤波的输入图像
uniform sampler2D curTex; //目标Mipmap等级的图像(升采样时使用)
uniform bool isDownSample;
// 为true则对于降采样采用"Karis Average",目前只针对Mip0->Mip1开启
uniform bool firstDownSample=false;
// 目标图像的长宽
uniform vec2 size;

const float threshold = 1;

//计算相对亮度函数
float lum(vec3 c){
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
}

/* 降采样算法 */
const int DownSampleCnt = 13;
const vec2 DownSampleQryOffset[DownSampleCnt] = {
{0.0,0.0},
{-1.0,-1.0},{1.0,-1.0},{1.0,1.0},{-1.0,1.0},
{-2.0,-2.0},{0.0,-2.0},{2.0,-2.0},{2.0,0.0},{2.0,2.0},{0.0,2.0},{-2.0,2.0},{-2.0,0.0}
};
const float DownSampleQryWeights[DownSampleCnt] = {
0.125, 0.125, 0.125, 0.125, 0.125, 0.03125, 0.0625,
0.03125, 0.0625, 0.03125, 0.0625, 0.03125, 0.0625
};
const int DownSampleGroupCnt = 5;
const int SamplePerGroup = 4;
const int DownSampleGroups[DownSampleGroupCnt][SamplePerGroup] = {
{1,2,3,4},{5,6,0,12},{6,7,8,0},{0,8,9,10},{12,0,10,11}
};
const float DownSampleGroupWeights[DownSampleGroupCnt] = {
0.5,0.125,0.125,0.125,0.125
};
vec3 DownSample(){
// 13次纹理查询
vec3 qrys[DownSampleCnt];
vec2 unitStepInPevTex = 0.5/size;
for(int i=0;i<DownSampleCnt;i++){
qrys[i] = texture(prevTex, texCoord + DownSampleQryOffset[i]*unitStepInPevTex).xyz;
//第一次降采样时,进行亮部过滤
if(firstDownSample){
if(lum(qrys[i])<threshold)
qrys[i] = vec3(0);
}
}

vec3 ret = vec3(0);
// 为了增强bloom效果,笔者关闭了"Karis Average"
//if(firstDownSample){
if(false){
// 将每个查询值计算Karis Average权重
float qrysKarisWeight[DownSampleCnt];
for(int i=0;i<DownSampleCnt;i++){
qrysKarisWeight[i] = 1.0 / (1.0 + lum(qrys[i]));
}
for(int i=0;i<DownSampleGroupCnt;i++){
//权重求和以归一化
float sumedKarisWeight=0;
for(int j=0;j<SamplePerGroup;j++){
sumedKarisWeight += qrysKarisWeight[DownSampleGroups[i][j]];
}
//各组加权平均和
for(int j=0;j<SamplePerGroup;j++){
ret += DownSampleGroupWeights[i] * qrysKarisWeight[DownSampleGroups[i][j]] / sumedKarisWeight * qrys[DownSampleGroups[i][j]];
}
}
}
else{
for(int i=0;i<DownSampleCnt;i++){
ret += qrys[i] * DownSampleQryWeights[i];
}
}
return ret;
}

/* 升采样算法 */
const int UpSampleCnt = 9;
const vec2 UpSampleQryOffset[UpSampleCnt] = {
{0,0},{-1,-1},{0,-1},{1,-1},{1,0},{1,1},{0,1},{-1,1},{-1,0}
};
const float UpSampleQryWeights[UpSampleCnt] = {
0.25,0.0625,0.125,0.0625,0.125,0.0625,0.125,0.0625,0.125
};
vec3 UpSample(){
vec3 ret = texture(curTex, texCoord).xyz;
vec2 unitStepInOriTex = 2/size;
for(int i=0;i<UpSampleCnt;i++){
ret += texture(prevTex, texCoord + UpSampleQryOffset[i]*unitStepInOriTex).xyz * UpSampleQryWeights[i];
}
return ret;
}

void main(){
if(isDownSample){
FragColor = vec4(DownSample(),1);
}
else{
FragColor = vec4(UpSample(),1);
}
return;
}

在最后的tone map阶段,将Bloom Mip1升采样后叠加到原图上。

FragShader_ToneMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#version 430 core
out vec4 FragColor;
in vec2 texCoord;
uniform sampler2D finalHdrTex;
uniform sampler2D bloomMip0;
uniform vec2 size;
const float gamma = 2.2;
const float exposure = 0.5;
//Bloom 效果缩放系数
const float bloomFactor = 0.02;

/* 升采样Bloom Mip1 */
const int UpSampleCnt = 9;
const vec2 UpSampleQryOffset[UpSampleCnt] = {
{0,0},{-1,-1},{0,-1},{1,-1},{1,0},{1,1},{0,1},{-1,1},{-1,0}
};
const float UpSampleQryWeights[UpSampleCnt] = {
0.25,0.0625,0.125,0.0625,0.125,0.0625,0.125,0.0625,0.125
};
vec3 UpSample(){
vec3 ret;
vec2 unitStepInOriTex = 2/size;
for(int i=0;i<UpSampleCnt;i++){
ret += texture(bloomMip0, texCoord + UpSampleQryOffset[i]*unitStepInOriTex).xyz * UpSampleQryWeights[i];
}
return ret;
}

void main(){
FragColor.a = 1;
vec3 hdrColor = texture(finalHdrTex,texCoord).rgb;
// exposure tone mapping
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
// up sample bloom mip1
mapped += UpSample() * bloomFactor;
// gamma correction
mapped = pow(mapped, vec3(1.0 / gamma));

FragColor = vec4(mapped, 1.0);
}

参考资料

LearnOpenGL:光晕在OpenGL中的初步实现
Efficient Gaussian blur with linear sampling:如何使用双线性插值优化高斯滤波的性能
NEXT GENERATION POST PROCESSING IN CALL OF DUTY: ADVANCED WARFARE:介绍了对“降采样+升采样”的方法的一些改进