片段处理与帧缓存 - 高级帧缓冲格式与点精灵

这篇文章学习 OpenGL 的高级缓冲格式和点精灵。高级缓冲格式这节主要介绍的是渲染浮点格式的图片,且实验都围绕高动态范围图片进行。高动态范围(HDR),我们在手机上肯定见过,不过手机上都是合成操作(通过几张曝光不同的参数合成)。我们就借着这节内容进一步了解一下 HDR。

点精灵这个术语,也听过很多次了,往往和粒子特效语境关联。我们也借着这节内容了解一下到底什么是点精灵。

高级帧缓冲格式

高动态范围图片的存储格式是浮点型,创建纹理的方式和之前的相同。只不过是指定存储格式的时候,需要指定对应的浮点类型,比如 GL_RGBA16F 或 GL_RGBA32F 等等。

色调映射

浮点格式的数据会有一个问题,因为 OpenGL 输出的颜色范围是 0.0~1.0,所以会存在范围问题。我们将 HDR 图片的原始数据直接渲染,结果如图 1 所示,因为图像大部分的值还是介于 0.0 和 1.0 中,所以还是能看清图片的整体面貌。但是超出范围外的高亮内容就会失去细节。

图1 原始数据直接渲染

我们需要将色调映射到可以显示的颜色空间上来。这边首要的思路就是将数据映射到 0.0 和 1.0 范围之内。代码清单 1.1 中的第 12 行是核心处理,通过基础函数 e^x,将数据较平滑的映射到 0.0~1.0 范围之内。图 2 是手动调节曝光系数得到的不同亮度的结果。

代码清单 1.1 对 HDR 图像应用曝光系数
  1. #version 430 core
  2.  
  3. layout (binding = 0) uniform sampler2D hdr_image;
  4.  
  5. uniform float exposure = 1.0;
  6.  
  7. out vec4 color;
  8.  
  9. void main(void)
  10. {
  11.     vec4 c = texelFetch(hdr_image, 2 * ivec2(gl_FragCoord.xy), 0);
  12.     c.rgb = vec3(1.0) - exp(-c.rgb * exposure);
  13.     color = c;
  14. }
图2 经缩放进行简单色调映射

以上调节曝光系数是个手动的过程,且系数作用于整幅图片。书中介绍了一种自适应色调映射的方法:着色器通过以当前纹素为中心采集 25 个纹素样本,并将其转化为亮度值。接着对亮度值进行加权求和。然后使用非线性函数将亮度转为为曝光度。最后带入先前的色调映射函数。代码清单 1.2 实现了以上过程。

代码清单 1.2 HDR-LDR 自适应转换片段着色器
  • #version 420 core
  •  
  • in vec2 vTex;
  •  
  • layout (binding = 0) uniform sampler2D hdr_image;
  •  
  • out vec4 oColor;
  •  
  • void main()
  • {
  •     int i;
  •     float lum[25];
  •     vec2 tex_scale = vec2(1.0) / textureSize(hdr_image, 0);
  •  
  •     for (i = 0; i < 25; i++)
  •     {
  •          vec2 tc = (2.0 * gl_FragCoord.xy + 3.5 * vec2(i % 5 - 2, i / 5 - 2));
  •          vec3 col = texture(hdr_image, tc * tex_scale).rgb;
  •          lum[i] = dot(col, vec3(0.3, 0.59, 0.11));
  •     }
  •  
  •     // Calculate weighted color of region
  •     vec3 vColor = texelFetch(hdr_image, 2 * ivec2(gl_FragCoord.xy), 0).rgb;
  •  
  •     float kernelLuminance = (
  •           (1.0  * (lum[0] + lum[4] + lum[20] + lum[24])) +
  •           (4.0  * (lum[1] + lum[3] + lum[5] + lum[9] +
  •                   lum[15] + lum[19] + lum[21] + lum[23])) +
  •           (7.0  * (lum[2] + lum[10] + lum[14] + lum[22])) +
  •           (16.0 * (lum[6] + lum[8] + lum[16] + lum[18])) +
  •           (26.0 * (lum[7] + lum[11] + lum[13] + lum[17])) +
  •           (41.0 * lum[12])
  •           ) / 273.0;
  •  
  •     // Compute the corresponding exposure
  •     float exposure = sqrt(8.0 / (kernelLuminance + 0.25));
  •  
  •     // Apply the exposure to this texel
  •     oColor.rgb = 1.0 - exp2(-vColor * exposure);
  •     oColor.a = 1.0f;
  • }

图 3 是自适应色调映射的结果,它对各个小区域应用了不同的曝光系数。同时以我的主观感受,目前的图片中图 3 的效果的确是最好的。

值得一提的是,对好效果的追求是永无止境的。查阅资料可以发现其他非常多的色调映射算法,所以对以上算法的原理也不做细究了,这是另一片非常广阔的领域。

图3 自适应色调映射

场景高光溢出

结合以上对 HDR 图片处理逻辑的学习,我们可以将其应用在高光溢出场景的渲染。虽然对比实验结果,我觉得效果不太显著,我们还是来看看书上是怎么处理的。

图 4 是整体的处理流程,共涉及三个着色程序。hdrbloom-scene 程序输出原始高亮结果和亮度信息的临界结果;亮度信息临界结果用作 hdrbloom-filter 程序的输入;hdrbloom-resolve 程序将 hdrbloom-scene 的原始结果和 hdrbloom-filter 的滤波结果作为输入,输出最终的渲染结果。下面我们依次看看各个步骤。

图4 整体处理流程

清单 1.3 是 hdrbloom-scene 的片段着色器代码。可以看到其根据光线逻辑计算出原始的高亮效果,输出到 color0 缓冲,其效果如图 5 所示。

color1 缓冲包含的是亮度的临界信息:首先提取亮度信息(Y 分量),小于下边界阈值的亮度设置为 0,大于上边界阈值的亮度设置为 1,中间值平滑。然后将颜色设置成最终亮度值的四倍进行输出,以强化高亮效果。最终的效果如图 6 所示。

smoothstep 用于生成 0 到 1 的平滑过渡值,其对应的处理逻辑为:

t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);

return t * t * (3.0 - 2.0 * t);

代码清单 1.3 高亮片段着色器——输出明亮数据到单独缓冲
  • #version 420 core
  •  
  • layout (location = 0) out vec4 color0;
  • layout (location = 1) out vec4 color1;
  •  
  • in VS_OUT
  • {
  •     vec3 N;
  •     vec3 L;
  •     vec3 V;
  •     flat int material_index;
  • } fs_in;
  •  
  • // Material properties
  • uniform float bloom_thresh_min = 0.8;
  • uniform float bloom_thresh_max = 1.2;
  •  
  • struct material_t
  • {
  •     vec3 diffuse_color;
  •     vec3 specular_color;
  •     float specular_power;
  •     vec3 ambient_color;
  • };
  •  
  • layout (binding = 1, std140) uniform MATERIAL_BLOCK
  • {
  •     material_t material[32];
  • } materials;
  •  
  • void main(void)
  • {
  •     // Normalize the incoming N, L and V vector
  •     vec3 N = normalize(fs_in.N);
  •     vec3 L = normalize(fs_in.L);
  •     vec3 V = normalize(fs_in.V);
  •  
  •     // Calculate R locally
  •     vec3 R = reflect(-L, N);
  •  
  •     material_t m = materials.material[fs_in.material_index];
  •  
  •     // Compute the diffuse and specular components for each fragment
  •     vec3 diffuse = max(dot(N, L), 0.0) * m.diffuse_color;
  •     vec3 specular = pow(max(dot(R, V), 0.0), m.specular_power) * m.specular_color;
  •     vec3 ambient = m.ambient_color;
  •  
  •     // Add ambient, diffuse and specular to find final color
  •     vec3 color = ambient + diffuse + specular;
  •  
  •     // Write final color to the framebuffer
  •     color0 = vec4(color, 1.0);
  •  
  •     // Calculate luminance
  •     float Y = dot(color, vec3(0.299, 0.587, 0.144));
  •  
  •     // Threshold color based on its luminance and write it to
  •     // the second output
  •     color = color * 4.0 * smoothstep(bloom_thresh_min, bloom_thresh_max, Y);
  •     color1 = vec4(color, 1.0);
  • }
图5 高亮示例的原始输出
图6 高亮示例的临界输出

清单 1.4 是 hdrbloom-filter 的片段着色器代码。书中介绍这是分离式滤波,一般水平轴上进行一次,垂直轴上进行一次。其原理不太清楚,但是可以从图 7 所示的结果上来看,它产生了虚化效果,边缘也变得更加柔和。

代码清单 1.4 虚化片段着色器
  • #version 430 core
  •  
  • layout (binding = 0) uniform sampler2D hdr_image;
  •  
  • out vec4 color;
  •  
  • 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);
  •  
  • void main(void)
  • {
  •     vec4 c = vec4(0.0);
  •     ivec2 P = ivec2(gl_FragCoord.yx) - ivec2(0, weights.length() >> 1);
  •     int i;
  •  
  •     for (i = 0; i < weights.length(); i++)
  •     {
  •         c += texelFetch(hdr_image, P + ivec2(0, i), 0) * weights[i];
  •     }
  •  
  •     color = c;
  • }
图7 虚化的临界高光颜色

清单 1.5 是 hdrbloom-resolve 的片段着色器代码,也是最终的渲染步骤。它将原始的高亮结果和滤波结果进行混合,再进行色调映射,最终的渲染结果如图 8 所示。

代码清单 1.5 虚化片段着色器
  • #version 430 core
  •  
  • layout (binding = 0) uniform sampler2D hdr_image;
  • layout (binding = 1) uniform sampler2D bloom_image;
  •  
  • uniform float exposure = 0.9;
  • uniform float bloom_factor = 1.0;
  • uniform float scene_factor = 1.0;
  •  
  • out vec4 color;
  •  
  • void main(void)
  • {
  •     vec4 c = vec4(0.0);
  •  
  •     c += texelFetch(hdr_image, ivec2(gl_FragCoord.xy), 0) * scene_factor;
  •     c += texelFetch(bloom_image, ivec2(gl_FragCoord.xy), 0) * bloom_factor;
  •  
  •     c.rgb = vec3(1.0) - exp(-c.rgb * exposure);
  •     color = c;
  • }
图8 高亮程序的结果

我在这节开头说的实验效果不太显著,指的是高光溢出的效果。图 8 的最终渲染结果对比图 5 的原始高光效果,单就高光这块,我觉得差异不大。图 8 比图 5 效果更好的点在于更加柔和,色调映射的确是让两者融合的更加自然了,但是虚化柔和的这个优化处理步骤是发生在滤波环节的,感觉应该不太能“归功”于 hdr 色调映射这块😂

点精灵

术语点精灵通常指纹理点。OpenGL 用单个顶点表示一个点,因此不可能像其他基元类型一样指定可插值的纹理坐标。为了解除这个限制,OpenGL 将生成插值纹理坐标,可以用来执行任何操作。可通过使用点精灵绘制单个 3D 点将 2D 纹理图像置于屏幕上。

点精灵最常见的一种应用是粒子系统。大量在屏幕上移动的例子可用点表示,用于产生各种视觉效果。如果没有点精灵,达到这种效果需要在屏幕上绘制大量纹理化四视图(或三角扇)。

渲染星空

我们直接看到渲染星空的例子。从代码清单 2.1 中可以看到,使用点精灵时,我们就不需要自己指定纹理坐标了,直接传入 OpenGL 自动生成的 gl_PointCoord 内置变量就可以了,十分方便。

代码清单 2.1 星空效果的片段着色器
  • #version 410 core
  •  
  • layout (location = 0) out vec4 color;
  •  
  • uniform sampler2D tex_star;
  • flat in vec4 starColor;
  •  
  • void main(void)
  • {
  •     color = starColor * texture(tex_star, gl_PointCoord);
  • }

片段着色中已经介绍完点精灵的用法了,我们再看看代码清单 2.2 顶点着色器中关于星空场景的实现。点的位置和时间相关,取其小数部分(第 15 到 16 行),并使其随着时间靠近观察者(第 22 行);点的大小也遵循近大远小的规律(第 18 行),点的大小最终通过 gl_PointSize 确定;点的颜色远处暗,近处亮(第 20 行)。

代码清单 2.2 星空效果的顶点着色器
  1. #version 410 core
  2.  
  3. layout (location = 0) in vec4 position;
  4. layout (location = 1) in vec4 color;
  5.  
  6. uniform float time;
  7. uniform mat4 proj_matrix;
  8.  
  9. flat out vec4 starColor;
  10.  
  11. void main(void)
  12. {
  13.     vec4 newVertex = position;
  14.  
  15.     newVertex.z += time;
  16.     newVertex.z = fract(newVertex.z);
  17.  
  18.     float size = (20.0 * newVertex.z * newVertex.z);
  19.  
  20.     starColor = smoothstep(1.0, 7.0, size) * color;
  21.  
  22.     newVertex.z = (999.9 * newVertex.z) - 1000.0;
  23.     gl_Position = proj_matrix * newVertex;
  24.     gl_PointSize = size;
  25. }

注意渲染时,渲染的基元为点,使用 GL_POINTS 参数指定:

  • glDrawArrays(GL_POINTS, 0, NUM_STARS);

图 9 为最终星空的渲染效果。

图9 用点精灵从空中飘过

有形点

除了使用 gl_PointCoord 对纹理坐标应用纹理外,还可以使用 gl_PointCoord 导出除纹理坐标外的许多其他信息。例如,可以使用片段着色器中的 discard 关键字生成非正方形的点,从而舍弃预期点形状外的片段。

结合清单 2.3 中的代码和图 10 的渲染结果,不难理解其中的舍弃逻辑。

代码清单 2.3 生成有形点的片段着色器
  • #version 410 core
  •  
  • layout (location = 0) out vec4 color;
  •  
  • flat in int shape;
  •  
  • void main()
  • {
  •     color = vec4(1.0);
  •     vec2 p = gl_PointCoord * 2.0 - vec2(1.0);
  •     if (shape == 0)
  •     {
  •          if (dot(p, p) > 1.0)
  •              discard;
  •     }
  •     else if (shape == 1)
  •     {
  •          if (dot(p, p) > sin(atan(p.y, p.x) * 5.0))
  •              discard;
  •     }
  •     else if (shape == 2)
  •     {
  •          if (abs(0.8 - dot(p, p)) > 0.2)
  •              discard;
  •     }
  •     else if (shape == 3)
  •     {
  •          if (abs(p.x) < abs(p.y))
  •              discard;
  •     }
  • }
图10 经分析生成的点精灵形状

总结

这篇文章其实主要介绍了 HDR 的色调映射和点精灵。同时学了这些后,第九章片段着色器也学完了。按照流程,片段着色器学完了也意味着整个渲染图形管线中的着色器都学习完毕了。后续还有一个相对独立的计算着色器,以及其他 OpenGL 特性,再往后就是实战的章节了。目前可以算是学过一遍 OpenGL 的基础知识了!