OpenGL 高级帧缓存特性[WIP]

知识点:

  • 如何使用帧缓存的高级特性。
  • 如何将帧缓存中的数据存储到纹理、缓存以及应用的内存中。

本篇文章主要讲一些帧缓存的高级特性。另外我们继续分享如何提升应用生成的图片质量,从使用高动态范围渲染,到渲染时可选的颜色空间我们都会有所涉及。

1 高级帧缓存格式 (Advanced Framebuffer Formats)

到目前为止我们渲染场景时使用到了默认的帧缓存对象,即和窗口系统提供的帧缓存对象,和我们自己出创建的帧缓存对象。然而我们附着到帧缓存对象上的纹理中的颜色格式都是GL_RGBA8,这是一个8位的无符号标准化格式,这意味者它只能表示0~1之间的值,并且只有256个梯度。然而在片段着色器中的输出变量声明为vec4类型,即包含4个浮点数字的向量。OpenGL能够用你能想到的任何颜色格式渲染场景,帧缓存对象的附件也可以包含1、2、3个或者4个分量,可以是浮点型也可以是整性的数据,甚至还能存储负值,也可以比8位更高从而提供更高的精度。

这个部分我们会介绍到更多帧缓存的附件能够用到的高级颜色格式,我们可以使用这些格式在着色器中捕获到更多的细节。

1.1 无附件渲染 (Rendering with No Attachments)

就行你可以创建一个帧缓存对象,并未其附着多个纹理对象,从而使得你可以使用一个着色器向这些纹理中渲染场景。你也可以创建一个帧缓存对象,但是不为其附着任何纹理。这样看书去似乎很奇怪,你可能会好奇渲染的数据去了哪里。其实在这种场景下,片段着色器中声明的所有输出属性都没有任何作用,向其中写入的数据最终都会被丢弃。其实片段着色器除了向其声明的输出变量中写入数据之外,他还有一些其他的功能。例如,它们可以使用imageStore向内存(显存memory)中写入数据,它们也可以使用函数atomicCounterIncrement和atomicCounterDecrement对原子计数做递增或者递减操作。

通常情况下,当一个帧缓存对象有一个或者多个附件时,它会根据这些附件计算出自身的最大宽和高,层数以及样本数。这些属性定义了视口将会被截断的范围以及一些其他属性。These properties define the size to which the viewport will be clamped and so on. 当一个帧缓存对象没有附着任何纹理时,由纹理可用的内存数量而决定的这些限制将会被移除。When a framebuffer object has no attachments, limits imposed by the amount of memory available for textures, for example, are removed. 但是帧缓存仍然必须使用其他的数据源计算出这些限制信息。因此每个帧缓存对象在其未附着任何纹理时,将会有一个参数集合用于这些限制信息。修改这些参数,可以调用函数glFramebufferParameteri。其原型如下。

void glFramebufferParameteri(GLenum target, GLenum pname, GLint param);

同一时间当前OpenGL上下文中某个类型的帧缓存绑定点只会存在一个帧缓存对象,参数target指定了需要修改哪种类型的帧缓存对象,可以选择GL_DRAW_FRAMEBUFFER和GL_READ_FRAMEBUFFER,或者直接使用GL_FRAMEBUFFER。和之前一样,如果你指定参数GL_FRAMEBUFFER,那么其等同于参数GL_READ_FRAMEBUFFER,绑定至该类型绑定点上的帧缓存对象将被修改。参数pname指定的是你想要修改的属性,参数param表示你想要将该属性的值改为多少。pname可以选择以下的变量。

  • GL_FRAMEBUFFER_DEFAULT_WIDTH指定当帧缓存对象不包含附件时,其宽度值。
  • GL_FRAMEBUFFER_DEFAULT_HEIGHT指定当帧缓存对象不包含附件时,其高度值。
  • GL_FRAMEBUFFER_DEFAULT_LAYERS指定当帧缓存对象不包含附件时,其层数。
  • GL_FRAMEBUFFER_DEFAULT_SAMPLES指定当帧缓存对象不包含附件时,其样本总数。
  • GL_FRAMEBUFFER_DEFAULT_FIXED_SAMPLE_LOCATIONS指定帧缓存对象是否使用默认的样本。如果设置为非0值,OpenGL将会使用默认的采样模式,反之OpenGL可能会使用一个更加高级的样本组合。otherwise, OpenGL might choose a more advanced arrangement of samples for you.

当帧缓存对象不包含任何附件时,其尺寸可以设置到非常大,因为此时并不需要真正的存储空间去存储任何数据。下面的代码演示了如果初始化一个尺寸为10000乘以10000的虚拟帧缓存对象。

// Generate a framebuffer name and bind it.
Gluint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// Set the default width and height to 10000
glFramebufferParameteri(GL_FRAMEBUFFER_DEFAULT_WIDTH, 10000); 
glFramebufferParameteri(GL_FRAMEBUFFER_DEFAULT_HEIGHT, 10000);

如果你想要使用上面创建的纹理对象渲染场景,那么你可以使用函数glViewport将视口尺寸设置为10000乘以10000。尽管这个帧缓存对象上并没有任何附件,但是OpenGL还是会按这个尺寸对图元做光栅化处理,并且片段着色器将会被调用。gl_FragCoord变量的x和y轴分量的值从0递增到9999。

1.2 浮点型帧缓存(Floating-Point Framebuffers)

帧缓存最有用的特性之一就是它可以绑定具有浮点型格式的附件。尽管OpenGL内部的渲染管线中数据的格式通常都是浮点型的,但是数据源(纹理)和绘制目标(帧缓存的附件)通常都是固定点格式,这样会明显降低数据的精度。因此管线的很多部分都会将数据映射到0和1之间使得它们能够以固定点的格式被存储。

我们可以将传递到顶点着色器中的输入变量声明成我们想要的类型,但是通常都使用vec4类型,也就是一个由4个浮点数组成的向量。同样的,对于顶点着色器中的输出变量,我们也可以指定多种数据类型。OpenGL将会使用这些输出变量在几何体内部进行插值计算,并将结果输入到片段着色器内部。尽管通常而言这些输入变量的数据类型都是浮点型的,但是你仍能将其指定为任何有效的数据类型。

但是除了使用256个梯度表示的浮点值外,我们能够使用真正的浮点型数据,其取值范围为1.18✖️10(-38)到1.18✖️10(38)。你或许想知道当你使用这种数据类型在每个颜色值仅支持8位的显示器或者窗口中渲染场景时将会发生什么。首先,这并不有趣。直到有人能够发明能够接收浮点型数据的显示器之前,我们仍然受到最终的显示设备所限制。(实际上某些非常高端的显示器已经能够解释每个颜色通道为10位设置20位的像素数据了,但是这些显示器都十分昂贵)

但是这并不意味着使用浮点型的数据进行渲染没有用。恰恰相反,你仍能够将全精度的浮点型数据渲染到纹理中。不仅如此,你还能控制这些浮点型数据如何映射至固定点格式的数据上。这种处理能够对最终渲染的结果产生巨大的影响,在高动态范围或者HDR模式中会涉足到这种处理方式。

使用浮点数据格式(Using Floating-Point Formats)

升级你的应用从而应用含浮点型数据的缓存或许比你想的更简单。实际上你并不需要调用任何新的函数,只需要在为纹理分配缓存时将颜色格式改为GL_RGBA16F或者GL_RGBA32F。

glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA16F, width, height); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA32F, width, height);

创建浮点型数据缓存时能够选用的参数为GL_RGBA32F、GL_RGBA16F、GL_RGB32F、GL_RGB16F、GL_RG32F、GL_RG16F、GL_R32F、GL_R16F和GL_R11F_G11F_B10F。GL_R11F_G11F_B10F是一个特殊的颜色格式,11位的浮点型数据由5位表示指数和6位表示小数部分组成,10位浮点型数据由5位指数部分和5位小数部分组成。

除了上面讲到的数据格式,你甚至还可以使用GL_DEPTH_COMPONENT32F或者GL_DEPTH_COMPONENT32F_STENCIL8格式。前者用于存储深度信息,这些纹理可以作为深度附件在帧缓存对象中使用。后者表示将在同一个纹理中同时存储深度和模版信息。它可以被用在深度和模版缓存中。

高动态范围

很多现代的游戏程序在渲染时都使用了浮点型数据,从而生成我们期待的花瓶色(Eye candy)。在生产光效果时,如眩光、透镜光晕、光折射、关反射、黄昏时的光辉以及一些参与媒介效果如灰尘和云雾等,想要实现现实主义等级离开浮点型缓存是无法完成的。使用浮点型缓存进行高动态范围的绘制可以使场景中明亮的区域很亮,暗的区域很暗,同时还能让我们看见这两种光线条件下的细节。实际上人眼能够分辨出的对比度比现在的显示器能够支持的对比度高出很多。

在后面的示例程序中,我们不会绘制拥有大量几何体和复杂光照的场景来演示HDR是如何的有效,相反我们会使用已经生成的HDR图像来讲解HDR特性。第一个示例程序hdr_imaging从KTX文件中加载了一些HDR图像,该类型文件存储了图像原始的浮点型数据。这些图像描述了不同曝光度的同一个场景,它们合起来可以最终输出一个HDR的图像。

低曝光度的图像捕捉了场景明亮部分的细节,同时高曝光度的图像捕捉到了场景暗色部分的细节。下图显示了同一个场景使用不同曝光度处理后的效果。左上图描述了非常低曝光度下的场景,在该图中能够看到高亮部分的细节,即灯光的细节。右上角的图片加大了曝光度,使我们能够看到树干的一些细节。左下角的图片曝光度再次增加,此时我们已经能够看到松果的轮廓了。在右下角的图像中,曝光度最高,此时我们已经能够清晰的看见松果的细节。这四张图像合在一起给我们提供了大量的场景细节,但这些细节被分散存储在四张图像中。源码传送门

想要在一张图片中存储完这些细节的唯一方式就是使用浮点型数据。对于使用OpenGL渲染的任何场景,特别是当场景中包含很暗和很亮区域时,如果使用真实的颜色值输出而不是使用被影射到0到1范围内,再进一步映射至0到255这样的颜色值输出会使得输出的图像看上去更加真实。

色调映射(Tone Mapping)

现在我们已经体验到了一些使用浮点型数据渲染的优点,但是如何使用这些数据生成一副动态的图像,并且使得该图像能够使用0到255的值表示方式显示呢?色调映射可以将颜色数据从一个集合中映射到另外一个集合中,或者说能够从一个颜色空间映射到另外一个颜色空间中。因为浮点型数据无法直接在屏幕上显示,因此浮点型数据必须通过色调映射才能正常显示。

第一个采样程序hdrtonemap使用了三种方式将高分辨率的图像映射到了低分辨率的屏幕上。第一个方式比较简单和原始,这种方式直接将含浮点型数据的纹理绘制到了屏幕上。前面的HDR图像的亮度统计直方图如下,从中我们可以清楚看见该图像的亮度值主要集中在0到1之间,但是一部分亮的区域已经超过了1,最亮的地方甚至达到了5.5。

如果我们直接将这样的图像传给一个常规8位标准化的后备缓存,该图像的颜色值将会被截断至0到1,因此所有亮的的区域看上区都是白色的。另外由于大多数的数据都集中在0到0.25或者是标准话后的0到63,那么这些区域看上去就会非常暗。下图是使用这种方式的处理结果,亮度高的区域如灯光部分能够看到部分细节,但是松果等暗色区域看上去已经几乎是黑色。源码传送门

第二种方式是改变图像的曝光度,模拟摄像机改变曝光度的效果。不同的曝光度都提供了一些不同的细节。低曝光度能够很好的显示明亮部分的细节,而高曝光度能够很好显示暗色部分的细节,但同时会减少明亮部分的细节。在示例程序hdrtonemap中,程序从一个浮点型的纹理中读取数据并将其写入到包含8位标准格式后备缓存的默认帧缓存中。这样会使得从HDR到LDR(低动态范围)的数据转换是基于单个像素的,这样能够减少纹素在明亮地区和暗色地区之间进行插值计算时的颜色走形。一旦LDR图像生产后,这个图像就能够直接展示给用户。下面是示例程序中使用到的曝光片段着色器。

#version 430 core
layout (binding = 0) uniform sampler2D hdr_image; 
uniform float exposure = 1.0;
out vec4 color;

void main(void) {
    vec4 c = texelFetch(hdr_image, ivec2(gl_FragCoord.xy), 0); 
    c.rgb = vec3(1.0) - exp(-c.rgb * exposure);
    color = c;
}

在示例程序中你可以将曝光度调整为0.01到20.0之间的任意值,注意在调整曝光度时图片中不同部分的细节改变。实际上前面的四幅不同曝光度的图片就是使用这个示例程序生成的,不同的是将处理后的纹理数据存储在了一个真实的浮点型纹理中。

最后一个色调映射着色器基于场景中不同部分的相对明亮程度对曝光度做了动态的调整。首先着色器需要知道当前映射的纹素附近相对较亮的区域。计算过程为绕着当前的纹素采样25个纹素,然后将所有的样本颜色数据转化为亮度值,然后求它们的加权和。示例程序使用了一个非线性的函数将亮度值转换为曝光度,其函数可以定义为。

函数的曲线如下图。

然后使用计算出的曝光度和之前的颜色计算公式将HDR纹素颜色转换为LDR颜色值。该实例的片段着色器代码如下。

#version 430 core 
in vec2 vTex;
layout (binding = 0) uniform sampler2D hdr_image;
out vec4 oColor;

void main(void) { 
    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;
}

当一个图像只使用一个曝光度时,你可以计算出整幅图各部分细节需要展示的曝光度范围,然后在计算出一个平均值作为整幅图的曝光度。但是这种方式在暗色和高亮部分仍然会损失大量细节。在上面的着色器中使用到的非线性转换函数能够很好的兼顾图像的暗色和高亮部分的细节,其渲染结果如下图。该函数使用了类似对数函数的方式将亮度值映射为曝光度。你可以改变该函数从而增加或者减少使用到的曝光度范围,从增加或者减少在不同动态范围内细节的数量。源码传送门

现在你已经学会如处理一副HDR图片,那么在OpenGL中这样做有什么好处呢。其实有很多,在任何照亮的OpenGL场景中,HDR图像都是一个不错的选择。很多OpenGL实现的游戏和程序现在都将HDR的场景和其他内容渲染至使用真实浮点型帧缓存附件中,并在最后使用了类似上没着色器代码块中的逻辑对数据进行处理并最终展示在屏幕上。你可以使用刚刚学到的方法渲染HDR图片,以便模拟更真实的光照环境并更好呈现出每帧图像的细节。

为场景添加光辉(Making Your Scene Bloom)

使用高动态范围图像还能很好的表现出辉光效果。你是否注意到过阳光或者太亮的光线如何吞没你和光源之间的树枝或者物体。这被称为辉光(light bloom),下图展示了一个室内的辉光场景。

在左图的低曝光度下,你能看到大多数细节,但是在高曝光的有图中,才是玻璃的网格已经被辉光所遮蔽,甚至在右下角的木栏杆也被辉光覆盖,因此看上去更小。通过在场景中加入辉光可以加强测定区域的亮度感。我们可以模拟这种由高亮光源引起的辉光效果。尽管你可以使用5位精度的缓存实现该效果,但是在高动态范围的场景内使用浮点型的缓存会更有效。

第一步时使用高动态范围绘制场景。在示例程序hdrbloom中,使用了两个浮点型纹理作为颜色附件创建了一个帧缓存对象。像平常一样将场景渲染至第一个绑定好的纹理中,第二个保持经过辉光效果处理的图像数据。示例程序在同一个片段着色器中对两个纹理的数据进行了填充。片段着色器的代码如下。像平常一样计算出输出颜色并将其赋值给输出变量color0,然后计算出片段的亮度值并将其作为临界值处理颜色数据。使用最亮的数据生成辉光效果,并将处理后的颜色数据其写入到输出变量color1中。使用的临界值等级可以通过一对统一变量bloom_thresh_min和bloom_thresh_max调整。为了过滤掉高亮部分,使用函数smoothstep将低于bloom_thresh_min的亮度数据调整为0,将高于bloom_thresh_max的亮度数据调整为4.0,再将调整后的亮度值和原始颜色做点乘得到最终的颜色。

#version 430 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 vectors 
    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);
}

上面着色器运行后我们就能够得到如下图的两张图片。我们绘制的场景是一个不同材质的球体集合。它们中间有一些发光材质,不管具体的光效果是怎么样的,它们总能比其他材质产生更大的亮度值。左侧的图像没有应用辉光效果,我们可以观察到在不同亮度值范围的边界都是突变的。右侧的图像是左侧图像的阀值处理后的版本,它将会输入到辉光滤镜中。源码传送门

然后,我们需要对光亮数据进行模糊处理。为了实现这个目的,我们用到了可分离的高斯滤镜。可分离滤镜指的是可以被分割为两个路径的滤镜,通常一个在水平轴上,一个在垂直轴上。在这个例子中,我们在每个方向上绕滤镜中心采25个样本点,并使用一个固定的权重集合来处理每个纹素。要应用一个分割滤镜,我们需要两个处理步骤。在第一步中,我们在水平方向上过滤数据。可能你已经注意到了我们使用了gl_FragCoord.yx来确定滤镜的核。这意味这我们在过滤图像是旋转了图像的方向。然而在第二个阶段时,我们应用了同样的滤镜处理。这意味着我们第二次的水平过滤和对原始图像在垂直轴上过滤是等价的,并且输出的图像被再次旋转回到了它最初的方向。实际上我们一个直径为25的二维的高斯滤镜,这样总共会有625个样本点。该片段着色器的实现如下。

#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; 
}

使用上面的着色器对阀值处理后的纹理进行模糊处理后可以得到如下的图像。源码传送门

当阈值处理纹理经过模糊处理后,我们需要将处理后的纹理和场景的全色纹理进行混合从而得到最终的图像。混合两个纹理的片段着色器源码如下。原图的纹理中的颜色值和模糊后的阈值处理纹理中的颜色值分别和一个统一变量相乘,然后再将它们的结果相加从而模拟出辉光效果。最终生成的高动态颜色数据经过和上一个例子类似的曝光函数处理就可以得到最终的纹理数据。

#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; 
}

再这个示例程序中,你可用通过键盘上的up和down键来改变辉光的强度。下图是该示例程序渲染后的效果。源码传送门

1.3 整形帧缓存(Integer Framebuffers)

默认情况下窗口系统提供给应用的是一个固定点(fixed-point)格式的后备缓存。当你在片段着色器中声明浮点型输出变量的时候,包括vec4等类型输出变量,OpenGL会在向帧缓存附件中写入数据的时候将浮点型数据转换为适合附件存储的固定点格式数据。在前面的章节中我们讲到了浮点型帧缓存附件,这种格式的附件能够使我们以任意类型的浮点型数据存储在帧缓存中。同样的我们也可以创建一个内部格式为整形的纹理,并将其附着至一个帧缓存对象上,从而准备一个整型帧缓存附件。然后你可以在片段着色器中使用ivec4或uvec4等含有整型元素的数据类型的输出变量。在使用整型帧缓存附件时,输出变量将会按照位模式逐位写入到纹理中。你不需要像使用浮点型缓存时一样担心非标准值、负零、无穷值或者其他在浮点型缓存中需要考虑的任何特殊位模式值。

在创建纹理时使用到的整型内部数据格式通常以I或者UI结尾,如GL_RGBA32UI表示每个纹素由一个32位的无符号整型数据表示,GL_R16I表示每个纹素由一个16位的有符号整型数据表示。下面的代码演示了如何创建一个内部格式为GL_RGBA32UI的帧缓存附件。

// Variables for the texture and FBO 
GLuint tex; 
GLuint fbo;
// Create the texture object 
glGenTextures(1, &tex);
// Bind it to the 2D target and allocate storage for it 
glBindTexture(GL_TEXTURE_2D, tex); 
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA32UI, 1024, 1024);
// Now create an FBO and attach the texure as normal 
glGenFrambuffers(1, &fbo); 
glBindFramebuffer(GL_FRAMEBUFFER, fbo); 
glFramebufferTexture(GL_FRAMEBFUFFER, GL_COLOR_ATTACHMENT0, tex, 0);

你可用通过调用函数glGetFramebufferAttachmentParameteriv()并将参数pname设置为GL_FRAMEBUFFER_ATTACHMENT_COM PONENT_TYPE确定帧缓存附件的组件类型。返回的值可能是GL_FLOAT、GL_INT、GL_UNSIGNED_INT、GL_SIGNED_NORM ALIZED、或者GL_UNSIGNED_NORMALIZED,具体的返回值取决于颜色附件的内部格式。OpenGL并没有限制附着至同一个帧缓存对象的所有附件都必须是同样的格式,这意味着你可以在某个帧缓存对象使用使用不同格式的附件组合。

当使用整型帧缓存附件渲染时,片段着色器中声明的输出变量数据类型需要和帧缓存附件中的数据类型匹配。例如如果帧缓存附件的格式是GL_RGBA32UI,则在片段着色器中输出变量应该声明为unsigned int、uvec2、uvec3或者uvec4。同样的如果帧缓存附件的格式为有符号整型,那么着色器中输出变量的类型应该声明为int、ivec2、ivec3或者ivec4。这里只要求颜色格式匹配,并未要求组件数量相同。

如果帧缓存附件的格式低于32位,那么在数据写入时额外的最高有效位将会被直接丢弃。你甚至可以将浮点型数据写入到整型格式的帧缓存附件中,此时需要调用函数floatBitsToInt(或者floatBitsToUint),或者使用例如packUnorm2×16的打包函数。

尽管整型格式帧缓存在传统的定点和浮点格式之外提供了更多的灵活性,特别是在处理和光相关的纹理时能够将浮点型的数据写入到整型帧缓存中。但是还是有一些缺陷,最显著的是在整型帧缓存不能应用混合效果,其次整型的内部格式意味着当你将图像渲染到这种纹理后将不能够被过滤。

1.4 sRGB色彩空间(The sRGB Color Space)

很久以前,计算机使用体积大并且厚重的显示器,这些显示器由被称为阴极射线管(CRTs)的真空玻璃瓶组成。它们通过向荧光屏幕发射电子从而使屏幕发光。不幸的是,屏幕的发光亮和驱动发光的电压之间不成线性关系。它们之间的计算公式如下。

更糟糕的是,r的取值并不总是相同的。对于NTSC系统(主要用于北美,大多数南美以及亚洲部分地区的电视标准),r的取值约等于2.2。然而在SECAM和PAL系统(用于欧洲,澳洲,非洲和亚洲其他地区)中r的取值位2.8。这意味着在基于CRTs的显示设备中,你输入了半个最大电压,你只能得到低于四分之一的最大亮度输出。

为了补偿这种损失,在计算机图形学中我们使用了伽马校正(gamma correction),这种计算提高了线性RGB的值,对颜色值进行放大并偏移。计算后的颜色值的颜色空间我们称为sRGB,下面的伪代码演示了从RGB到sRGB的计算过程。

if (cl >= 1.0) { 
    cs = 1.0; 
} else if (cl <= 0.0) { 
    cs = 0.0; 
} else if (cl < 0.0031308) { 
    cs = 12.92 * cl; 
} else { 
    cs = 1.055 * pow(cl, 0.41666) - 0.055; 
}

同样的,sRGB也能够转换位RGB,其计算过程如下。

if (cs >= 1.0) { 
    cl = 1.0; 
} else if (cs <= 0.0) { 
    cl = 0.0; 
} else if (cs <= 0.04045) { 
    cl = cs / 12.92; 
} else { 
    cl = pow((cs + 0.0555) / 1.055), 2.4) 
}

在上面两个伪代码块种,cs都是表示的在sRGB颜色空间内的值,cl表示线性的RGB空间内的颜色值。注意上面的等式中由一小部分线性区域及一个很小的偏移量。在实际使用时,这个样和转换非常接近求颜色值的2.2(sRGB转换RGB)次方和0.454545(约等于1/2.2,RGB转sRGB)次方的函数,有些实现会直接使用这两个函数。下图演示了两种方法分别用于RGB转换sRGB及其逆变换。我们几乎不能够分辨出两种方法生成的曲线之间的差距。

在OpenGL中使用sRGB的颜色,我们需要在创建纹理时将内部格式设置位SRGB。例如,GL_SRGB8_ALPHA8表示红绿蓝分量都会经过伽马校正,alpha通道是线性的。我们可以像平常一样加载纹理中的数据。当你在着色器中从sRGB的纹理中读取数据时,在纹理采样时sRGB的数据会在过滤之前被转换成RGB数据,这就是说当双线性过滤被开启时,输入的纹素会被转换位RGB数据,然后线性的样本通过混合得到最终的颜色值,并将结果返回到着色器中。当然只有RGB颜色值会被处理,alpha通道的值会保持不变。

帧缓存对象同样支持存储sRGB数据,特别是GL_SRGB8_ALPHA8。你可以将一个sRGB格式的纹理绑定至帧缓存,然后在该帧缓存内渲染场景。当数据被写入到sRGB格式的附件中时,OpenGL会自动将RGB数据转换为sRGB格式。但是,默认这个特性是关闭的,你可以通过调用函数glEnable()并传入参数GL_FRAMEBUFFER_SRGB开启这个特性。需要注意的是,这个特性只对sRGB格式的纹理生效。你可以通过函数glGetFramebufferAttachmentParameteriv()和参数GL_FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING获取当前帧缓存上的附件格式。sRGB格式的附件在调用该函数时返回值为GL_SRGB,其他格式返回值为GL_LINEAR。

2 点精灵(Point Sprites)

点精灵又可以称为点块纹理(textured points)。OpenGL使用单个顶点描述点图元,因此我们没有机会像处理其他类型图元时那样指定一组能够有效进行插值处理的纹理坐标对点图元进行纹理填充。为了绕过这个问题,OpenGL会生成一个插值后的纹理坐标。在点精灵效果中,我们可以通过绘制一个3维的点图元将一个2维的纹理放在屏幕上的任何地方。

一个最常见的点精灵应用是粒子系统。大量用点图元表示的粒子在屏幕上移动从而形成各种各样的视觉特效。然而使用更小的重叠的2纬图像来表示这些点图元可以形成流动的丝状动画。例如下图就是Macintosh上通过粒子特效产生的屏幕保护程序。

在点精灵特效出现之前,想要实现类似的效果需要在屏幕上绘制大量的纹理化四边形或者三角形扇形。这可以通过对每个独立的面执行开销巨大的旋转变化从而确保它们是面向镜头的,或者使用2维正投影绘制所有的粒子。点精灵可以使我们通过发送单个顶点生成一个完美对齐的纹理化二维四边形。并且我们不需要使用矩阵逻辑来确保3维四边形和相机对齐,总的来说点精灵是一个强大而且高效对OpenGL特性。

2.1 点纹理化(Texturing Points)

使用点精灵非常容易,在应用层面你只需要绑定一个2D纹理,然后在片段着色器种从中读取数据即可。读取数据时需要使用内建变量gl_PointCoord,这是一个包含两个成员变量对向量,它会在整个点内对纹理坐标进行插值计算。示例程序PointSprites的片段着色器代码如下。

#version 430 core
out vec4 vFragColor;
in vec4 vStarColor;
layout (binding = 0) uniform sampler2D starImage;

void main(void) {
    vFragColor = texture(starImage, gl_PointCoord) * vStarColor; 
}

同样的,对于点精灵,你不需要计算纹理坐标,因为OpenGL会自动生成gl_PointCoord变量。因为一个点图元只由一个顶点组成,因此你不能通过其他方式在点的表面进行插值计算。当然也并未禁止你提供纹理坐标数据,或者定制你自己的插值方案。

2.2 渲染星空(Rendering a Star Field)

现在让我们看一个使用了刚刚讨论的点精灵特性的示例程序。程序starfield创建了一个带动画的星空,看上去你正在飞向这片星空。程序的实现方式是在你眼前的视图中随机放上一些点,然后将一个时间值作为uniform变量传入到顶点着色器中。时间参数用于计算点的位置,使得随着时间的推移,这些点看上去就像在向你移动,当它们经过近裁剪面后,这些点又会被放回到裁剪截锥体的后面,从而不断循环。另外我们稍微放大这些点,使得它们向我们移动时大小上动变化更加真实。这样处理后将会得到一个非常逼真的效果,我们需要的只是一个天文馆或者一部星际电影的音乐。

下图为被用在一系列的点上星星模型的纹理图,这只是一个简单的.KTX文件,我们只需要像对待普通2D纹理一样加载该图片。这张纹理图也可以使用mipmap纹理替代,因为星星会从很小变得相对较大,这样处理可能是一个很好的主意。

这里不会讲到创建星空特性的所有细节,因为这里面的逻辑都仅仅是例行公事,如果你想知道如何挑选随机数,可以自行查阅源码。下面是渲染逻辑中重要场景渲染函数的实现方式。

void render(double currentTime) {
    static const GLfloat black[] = { 0.0f, 0.0f, 0.0f, 0.0f }; 
    static const GLfloat one[] = { 1.0f };
    float t = (float)currentTime;
    float aspect = (float)info.windowWidth / (float)info.windowHeight; 
    vmath::mat4 proj_matrix = vmath::perspective(50.0f, aspect, 0.1f, 1000.0f);

    t *= 0.1f;
    t -= floor(t);

    glViewport(0, 0, info.windowWidth, info.windowHeight); 
    glClearBufferfv(GL_COLOR, 0, black); 
    glClearBufferfv(GL_DEPTH, 0, one);
        
    glEnable(GL_PROGRAM_POINT_SIZE);
    glUseProgram(render_prog);

    glUniform1f(uniforms.time, t); 
    glUniformMatrix4fv(uniforms.proj_matrix, 1, GL_FALSE, proj_matrix);
    glEnable(GL_BLEND);
    glBlendFunc(GL_ONE, GL_ONE);
    glBindVertexArray(star_vao);
    glDrawArrays(GL_POINTS, 0, NUM_STARS);
}

接下来我们需要混合星星和背景的颜色。因为纹理的暗色区域背景是白色的,因此我们只需要将绘制的颜色和帧缓存附件的背景色相加即可。使用alpha值表示透明度要求星星按照深度排序,这种开销实际上我们可以避免。在开启可编程点大小模式后,我们绑定定点着色器,并设置uniform变量。有意思的是,在顶点着色器中我们使用了当前时间计算星星坐标的z轴分量值,计算后的值能够循环并且平滑的从0增加到1。定点着色器的代码如下。

#version 430 core
layout (location = 0) in vec4 position; 
layout (location = 1) in vec4 color;
uniform float time; 
uniform mat4 proj_matrix;
flat out vec4 starColor;

void main(void) { 
    vec4 newVertex = position;
    newVertex.z += time; 
    newVertex.z = fract(newVertex.z);
    float size = (20.0 * newVertex.z * newVertex.z); 
    starColor = smoothstep(1.0, 7.0, size) * color; 
    newVertex.z = (999.9 * newVertex.z) - 1000.0; 
    gl_Position = proj_matrix * newVertex; 
    gl_PointSize = size;
}

传入的时间参数会偏移顶点坐标的z轴分量。这样会使得看上去所有的星星都在向我们移动。我们只使用其坐标的小数部分,这样星星的位置就会循环变化,在靠近观察者后就会再次移动到远裁剪面。在上面的代码块中,点z轴坐标经过取小数后其值在0到1之间,再进一步处理将其映射至-0.1至-1000之间,这两个值正好对应我们定义的远近两个裁剪面。我们甚至可以对顶点坐标的z轴分量进行平方运算,这样可以使星星向我们移动的速度加快,最后通过内建变量gl_PointSize设置星星的大小即可。如果星星一直使用相同的颜色浓度,当它们回到远裁剪面的时候看上去会闪烁,因此我们通过其大小计算颜色的浓度,如果星星小于1,则浓度为0,如果大于7,则浓度为1。这样当它们回到远裁剪面时就会渐渐融入我们的视野,也不是很突然的出现。下面是该示例程序的片段着色器,它仅仅从纹理中读取了数据并对颜色值进行了浓度调整。

#version 430 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.3 点参数(Point Parameters)

点精灵的一组特性(对于普通的点也一样)就是能够通过函数glPointParameteri()对其进行微调。应用于点精灵的纹理两个可能的原点(0,0)分别位于纹理的左上角和左下角。点精灵默认的纹理原点位置为GL_UPPER_LEFT,我们使用下面的代码将纹理的原点设置为左下角。

glPointParameteri(GL_POINT_SPRITE_COORD_ORIGIN, GL_LOWER_LEFT);

当点精灵内部坐标默认左上角为原点时,坐标gl_PointCoord(0.0, 0.0)和点渲染后的左上角相对应。然而在OpenGL中,窗口坐标的原点确是在左下角,如内建变量gl_FragCoord就是以屏幕左下角为原点。因此为了使得点精灵的坐标和窗口坐标gl_FragCoord对齐,我们需要将点精灵内部的坐标原点设置为左下角。

2.4 异形点(Shaped Points)

除了使用gl_PointCoord作为纹理坐标在点精灵上渲染纹理外,你其实还可以做很多事情。gl_PointCoord除了用于纹理坐标外,还可以用在很多地方。例如,你可以在片段着色器中使用关键字discard丢弃位于你期望的点形状外部的片段,从而绘制出非四边形的点。下面的两个代码块分别生成了圆形和花形的点。

Code Block 1(Round points)
vec2 p = gl_PointCoord * 2.0 - vec2(1.0); 
if (dot(p, p) > 1.0)
    discard;

Code Block 2(Flower shape points)
vec2 temp = gl_PointCoord * 2.0 - vec2(1.0);
if (dot(temp, temp) > sin(atan(temp.y, temp.x) * 5.0))
    discard;

下面的代码块创建了更多不同形状的点精灵。


#version 430 core
layout (location = 0) out vec4 color; 
flat in int shape;
void main(void) {
    color = vec4(1.0);
    vec2 p = gl_PointCoord * 2.0 - vec2(1.0);
    if (shape == 0) {
        // Simple disc shape
        if (dot(p, p) > 1.0) {
            discard;
        }
    } else if (shape == 1) {
        // Hollow circle
        if (abs(0.8 - dot(p, p)) > 0.2) {
            discard;
        }
    } else if (shape == 2) {
        // Flower shape
        if (dot(p, p) > sin(atan(p.y, p.x) * 5.0)) {
            discard;
        }
    } else if (shape == 3) {
        // Bowtie
        if (abs(p.x) < abs(p.y)) {
            discard;
        }
    } 
}

所有绘制的点效果如下图。源码传送门

2.5 旋转点(Rotating Points)

因为OpenGL中绘制的点精灵是和坐标轴对齐的四边形,想要旋转这些点就必须修改用于读取点精灵纹理的纹理坐标,或者解析计算它的形状。为了实现这个效果,你可以在片段着色器中创建一个2D旋转矩阵,并将其和内建变量gl_PointCoord相乘,使得纹理坐标绕z轴旋转。旋转的角度可以通过顶点着色器或者几何着色器换入,这样在片段着色器中得到的值就是经过插值计算后的值。反过来,这个值也可以在顶点着色器或者几何着色器计算,甚至可以将它当作一个顶点属性。下面的代码块是一个稍微复杂一点的点精灵片段着色器代码,在该代码块中这些点会绕着它们的中心进行旋转。

#version 430
uniform sampler2D sprite_texture; 
in float angle;
out vec4 color;

void main(void) {
    const float sin_theta = sin(angle);
    const float cos_theta = cos(angle);
    const mat2 rotation_matrix = mat2(cos_theta, sin_theta, -sin_theta, cos_theta); 
    const vec2 pt = gl_PointCoord - vec2(0.5);
    color = texture(sprite_texture, rotation_matrix * pt + vec2(0.5)); 
}

在这个例子中我们生成了旋转的点精灵。但是在点精灵内部角度值angle并不会随着片段的不同而改变。这就意味这sin_theta和cos_theta将会是一个常量,对于点中的每一个片段构建出来的旋转矩阵也是相同的。因此在顶点着色器中计算sin_theta和cos_theta的值再将它们传入片段着色器比直接在片段着色器中计算这两个值更加高效。因此我们更新了顶点着色器和片段着色器,其代码如下。

#version 430 core 
uniform matrix mvp;
in vec4 position; 
in float angle;
flat out float sin_theta; 
flat out float cos_theta;
void main(void) {
    sin_theta = sin(angle);
    cos_theta = cos(angle);
    gl_Position = mvp * position;
}


#version 430 core
uniform sampler2D sprite_texture; 
flat in float sin_theta;
flat in float cos_theta; 
out vec4 color;
void main(void) {
    mat2 m = mat2(cos_theta, sin_theta, -sin_theta, cos_theta); 
    const vec2 pt = gl_PointCoord - vec2(0.5);
    color = texture(sprite_texture, rotation_matrix * pt + vec2(0.5)); 
}

在上面的代码块中我们已经将开销较大的三角函数计算从片段着色器移动到了顶点着色器,这样对于较大的点而言,这种方法的执行效率将会比之前那种在片段暴力计算旋转矩阵的方式高很多。

需要记住的是尽管我们旋转了从gl_PointCoord中的到的坐标,但是点本身仍然是一个四边形。如果你的纹理或者分析出的形状位于以点的大小为直径的圆外部,你需要放大你的点精灵或者缩小纹理使得它在所有角度下都能完整的位于纹理内部。当然,如果你的纹理就是一副圆形的图像,那么你就不需要担心这个问题。

3 获取图像 (Getting at Your Image)

将渲染完成后的场景展示给用户这部分逻辑是和平台相关的,但是我们并不总是想将渲染好的纹理展示给用户。你可能有很多理由需要直接获取到渲染完成的图像,例如你想打印这幅图像,保存一份屏幕截图,或者甚至需要对这个纹理进一步处理。

3.1 读取帧缓存 (Reading from a Framebuffer)

OpenGL提供了函数glReadPixels使你能够从帧缓存中读取像素数据。该函数原型如下。

void glReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, 
                  GLenum format, GLenum type, GLvoid * data);

如果当前的GL_READ_FRAMEBUFFER上绑定有帧缓存,该函数会从绑定的帧缓存对象中读取数据,当该靶点上未绑定帧缓存时,该函数会从默认的帧缓存中读取数据,读取的数据将会被写入到应用的内存中或者缓存对象中。参数x和y指定了想要读取数据的区域在窗口坐标系中的起点,需要记住的是整个窗口的原点位于左下角,其坐标为(0, 0)。参数width和height指定了想要读取数据区域的宽和高。参数format和type指定你想要以何种格式读取颜色数据,这两个参数的的作用和在函数glTexSubImage2D中的类似。format取值可以是GL_RED或者GL_RGBA,type的取值可以是GL_UNSIGNED_BYTE或者GL_FLOAT。参数data表示数据将要被写入的地方。

如果靶点GL_PIXEL_PACK_BUFFER上未绑定缓存对象,参数data被认为是指向内存地址的指针,但是当该靶点绑定了缓存对象后,参数data被认为是在缓存的数据存储区上的偏移值,图像数据会被写入到该缓存对象中。如果你想读取该缓存中的数据,你可以调用函数glMapBufferRange并传入参数GL_MAP_READ_BIT。总之你可以使用该缓存做任何事情。

想要指定从帧缓存的哪个附件读取颜色数据可以调用函数glReadBuffer(),传入参数GL_BACK或者GL_COLOR_ATTACHEMENTi,i为想要读取数据的颜色附件索引。该函数的原型为void glReadBuffer(GLenum mode)。如果我们使用的是默认的帧缓存而不是自定义的对象,那么参数mode需要传入GL_BACK。这个枚举值是默认的设置,因此如果你没有使用自定义的帧缓存,或者说你想从默认的帧缓存中读数据,那么你完全可以不用调用该函数。然而对于你自定义的帧缓存,因为可能存在多个附件,因此你需要指定你想要从哪个附件中读取数据,因此你必须调用该函数。

在调用函数glReadPixels(),如果将参数format指定为GL_DEPTH_COMPONENT,该函数将会从深度缓存中读取数据,如果参数format指定为GL_STENCIL_INDEX,那么该函数将会从模版缓存中读取数据。GL_DEPTH_STENCIL是一个特殊的格式参数,我们通过指定该格式可以同时从深度和模版缓存中读取数据。当指定该格式时type参数只能是GL_UNSIGNED_INT_24_8或者GL_FLOAT_32_UNSIGNED_INT_24_8_REV,这种类型将会的到一个包装好的数据,我们在使用时需要从中解析出深度和模版信息。

当OpenGL向你准备好的内存空间或者绑定到靶点GL_PIXEL_PACK_BUFFER上的纹理对象写入数据时,其写入的数据在源图像中的顺序是x轴从左向右递增,并且在写完一行后会按照从下至上的顺序写入下一行数据。默认情况下每一行图像的数据和前一行数据的起点都有一定的偏移量,这个值是4的整数倍,这样在一切正常时可以使得数据是紧凑打包在一起的。然而当出现问题时,在输出的数据中就可能留有空隙。你可以通过调用函数glPixelStorei()改变这个设定,其原型为void glPixelStorei(GLenum pname, GLint param)

当参数pname传入GL_PACK_ALIGNMENT时,参数param传入的值就是图像每行数据之间的空隙。你可以将该值设置为1,这意味着每行图像的数据之间会有1位的空隙,其他可选的值可以还有2、4和8。

屏幕截图(Taking a ScreenShot)

下面的代码块对正在运行的程序进行了屏幕截图操作并且将其存为一个.TGA图像文件。

int row_size = ((info.windowWidth * 3 + 3) & ~3);
int data_size = row_size * info.windowHeight; 
unsigned char * data = new unsigned char [data_size];
#pragma pack (push, 1) 
struct {
    unsigned char identsize;     // Size of following ID field
    unsigned char cmaptype;      // Color map type 0 = none
    unsigned char imagetype;     // Image type 2 = rgb
    short cmapstart;             // First entry in palette
    short cmapsize;              // Number of entries in palette
    unsigned char cmapbpp;       // Number of bits per palette entry
    short xorigin;               // X origin
    short yorigin;               // Y origin
    short width;                 // Width in pixels
    short height;                // Height in pixels
    unsigned char bpp;           // Bits per pixel
    unsigned char descriptor;    // Descriptor bits
} tga_header; 
#pragma pack (pop)

glReadPixels(0, 0,                                 // Origin
             info.windowWidth, info.windowHeight,  // Size
             GL_BGR, GL_UNSIGNED_BYTE,             // Format, type
             data);                                // Data

memset(&tga_header, 0, sizeof(tga_header)); 
tga_header.imagetype = 2;
tga_header.width = (short)info.windowWidth; 
tga_header.height = (short)info.windowHeight; 
tga_header.bpp = 24;
FILE * f_out = fopen("screenshot.tga", "wb"); 
fwrite(&tga_header, sizeof(tga_header), 1, f_out);
fwrite(data, data_size, 1, f_out);
fclose(f_out);

delete [] data;

TGA文件仅由一个文件头和紧随其后的原始像素数据组成。上面的代码块先是通过文件操作写入了文件头,并在其后写入像素数据。

3.2 在帧缓存之间拷贝数据 (Copying Data between Framebuffers)

在早期,图像API允许我们将像素或者缓存数据读入到系统内存中,然后将其绘制到屏幕上。尽管这些方法是有效的,但是它们都会将数据从GPU的内存拷贝到CPU中将它们转换到正确到方向,然后再将数据拷贝回GPU的内存中。这样的处理效率十分低下。现在我们能够使用位块传送命令(blit commond)快速的将数据从一个地方移动到另外一个地方。位块传送指的是直接高效的在位的层级上对数据和内存进行拷贝操作。对于这个术语的起源有很多说法,但是最可信的说法是Bit-Level-Image-Transfer或者Block-Transfer。执行位块传送的函数原型如下。

void glBlitFramebuffer(GLint srcX0, Glint srcY0, GLint srcX1, Glint srcY1, 
                       GLint dstX0, Glint dstY0, GLint dstX1, Glint dstY1,
                       GLbitfield mask, GLenum filter);

尽管这个函数的名字中有blit,但是实际上它做的事情远比按位拷贝更多。实际上,它更像一个自动纹理操作。调用函数glReadBuffer()指定需要拷贝的数据源为帧缓存对象中的某个颜色附件,拷贝的区域是点(srcX0, scrY0)和点(srcX1, srcY1)确定的唯一矩形。类似的调用函数glDrawBuffer()指定数据需要写入到的帧缓存附件,写入的区域是点(dstX0, dstY0)和点(dstX1, dstY1)确定的唯一矩形。需要注意的是这里并没有规定读取的数据区域和写入的数据区域是等大的,因此该函数还允许我们对拷贝的数据进行上采样和下采样。如果将一个帧缓存同时绑定至GL_DRAW_FRAMEBUFFER和GL_READ_FRAMEBUFFER靶点上,那么你对其中的某个附件中进行读写操作等同于你从该缓存的某个部分拷贝数据,并写入到了另外一个区域,在进行这样的操作时需要小心读数据区域和写数据区域不要重叠。

参数mask可以是GL_DEPTH_BUFFER_BIT、GL_STENCIL_BUFFER_BIT和GL_COLOR_BUFFER_BIT中的任意值,也可以同时取到它们。参数filter的取值可以是GL_LINEAR或者GL_NEAREST,如果你使用整型格式拷贝深度、或者颜色或者模版数据,那么参数filter必须选择GL_NEAREST。这些过滤模式和纹理采样时等我过滤模式工作方式相同。在实例程序中,我们仅拷贝非整型的颜色数据,并将过滤模式设置为线性过滤。

GLint width = 800;
GLint height = 600;
GLenum fboBuffs[] = { GL_COLOR_ATTACHMENT0 };

glBindFramebuffer(GL_DRAW_FRAMEBUFFER, readFBO); 
glBindFramebuffer(GL_READ_FRAMEBUFFER, drawFBO);
    
glDrawBuffers(1, fboBuffs);
glReadBuffer(GL_COLOR_ATTACHMENT0);
glBlitFramebuffer(0, 0, width, height,
                  (width *0.8), (height*0.8), width, height,
                  GL_COLOR_BUFFER_BIT, GL_LINEAR );

这里假设已经准备好的数据读入帧缓存的附件宽为800,高为600,上面代码块的逻辑是从读入帧缓存附件中拷贝了所有的数据,并且将其缩小到原来的20%,并将其绘制到写入帧缓存的第一个颜色附件的右上角。

拷贝数据到纹理中(Copying Data into a Texture)

前面已经讲到了如何通过函数glReadPixels()读取纹理数据,通过函数glBlitFrameBuffer()在纹理之间拷贝数据。当我们想要将颜色数据作为纹理使用时,可以调用函数glCopyTexSubImage2D()直接将其写入到纹理中。该函数和glTextSubImage2D()类似,不同的是后者从内存或者缓存对象中读取数据,而前者直接从帧缓存中读取数据。该函数原型如下。

void glCopyTexSubImage2D(GLenum target, GLint level, GLint xoffset, GLint yoffset,
                         GLint x, GLint y, GLsizei width, GLsizei height);

参数target是目标纹理绑定的靶点,对于一般的2D纹理,这个值是GL_TEXTURE_2D,你也可以将数据拷贝至立方体地图的某一个面上,此时该参数可以指定为GL_TEXTURE _CUBE_MAP_POSITIVE_X、GL_TEXTURE_CUBE_MAP_NEGATIVE_X、GL_TEXTU RE_CUBE_MAP_POSITIVE_Y、GL_TEXTURE_CUBE_MAP_NEGATIVE_Y、GL_TEX TURE_CUBE_MAP_POSITIVE_Z或者GL_TEXTURE_CUBE_MAP_NEGATIVE_Z。参数width和height拷贝区域的大小,x和y是在帧缓存中拷贝区域矩形的左下角顶点坐标,xoffset和yoffset是目标纹理中的矩形坐标偏移量。

如果你写的程序通过将纹理绑定至帧缓存对象,从而直接向其中写入数据,那么这个函数对你而言并没有什么用。但是当你的程序在大多数时间都是将数据写入到默认的帧缓存然后渲染到屏幕上时,你可以使用该函数将部分输出写入到纹理中。另一方面,如果你想将某个纹理中的数据拷贝到另外一个纹理中时,你可以调用函数glCopyImageSubData(),该函数的参数很多,其原型如下。

void glCopyImageSubData(GLuint srcName, GLenum srcTarget, GLint srcLevel, 
                        GLint srcX, GLint srcY, GLint srcZ,
                        GLuint dstName, GLenum dstTarget, GLint dstLevel,
                        GLint dstX, GLint dstY, GLint dstZ,
                        GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth);

和OpenGL中的大多数函数不同,该函数允许你通过指定纹理的索引而不是它们的绑定点直接操作纹理对象。参数srcName和srcTarget是源纹理的索引和类型,同样的参数dstName和dstTarget是目标纹理的索引和类型。点(srcX, srcY, srcZ)和(dstX, dstY, dstZ)分别源纹理和目标纹理内拷贝和写入区域的原点,参数srcWidth、srcHeight和srcDepth为拷贝区域的大小。

如果你要仅仅拷贝2D纹理,参数scrZ和dstZ直接设置为0,参数srcDepth设置为1即可。

如果你的纹理是分级纹理(mipmaps),参数srcLevel和dstLevel可以设置为需要操作的层索引,否则的化设置为0即可。注意这里并没有参数可以设置目标宽度、高度和深度,因此拷贝的区域和写入的区域是等大的,不会存在图像的缩放。如果你想要进行额外的缩放处理,再将其写入到另外一个纹理中,你需要将它们绑定至帧缓存上,然后调用函数glBlitFramebuffer()进行操作。

3.3 读取纹理数据 (Reading Back Texture Data)

除了能够从帧缓存中读取数据外,通过将纹理绑定至合适的靶点上我们还能够从纹理中读取图像数据。读取纹理数据的函数原型如下。

void glGetTexImage(GLenum target, GLint level, 
                   GLenum format, GLenum type, GLvoid * img);

该函数和glReadPixels()类似,除了它不允许读取一个纹理等级的部分区域,使用该函数只能获取到整个纹理数据。参数format和type和函数glReadPixels()的同名参数作用相同,参数img和glReadPixels()中的参数data作用相同,它同样有着双重含义,分别是指向一段内存地址的指针,或者在绑定点GL_PIXEL_PACK_BUFFER上存在缓存对象时表示在缓存中的内存偏移量。尽管只能获取某个等级纹理的所有数据看上去是一个缺点,但是函数glGetTexImage()仍然有着自己的优点。首先你能够直接获取到一个分级纹理的所有分级的数据,其次如果你想要从一个纹理对象中读取数据,你不需要像使用函数glReadPixels()那样创建一个帧缓存对象,然后将该纹理对象绑定到这个帧缓存对象上。

在大多数情况下我们都是通过函数glTexSubImage2D向纹理中写入数据。或者我们也可以使用帧缓存对象直接向将数据绘制到纹理中。但是仍然有很多其他方式向纹理中写入数据。例如,你可以调用函数glGenerateMipmap将数据写入到纹理中,这样会生成一些列的从低分辨率到高分辨率的图片,或者你已经回忆起了前面章节说到的在着色器中向纹理写入数据。

4 总结 (Summary)

本章内容详细的描述了高级帧缓存格式。前面的章节说过自定义的帧缓存最大的优点是它们可以包含多个附件,在本章中我们为这些附件使用高级数据格式和颜色空间,如浮点型数据,sRGB颜色空间和整型数据。

最后我们讲到了一些从获取渲染后数据的方式。传统的方式会将纹理附着到帧缓存对象上,然后直接向其中绘制数据。但是我们介绍了如何直接将数据写入到纹理中,如从帧缓存拷贝数据到纹理中。同样的我们也说到了如何在帧缓存之间拷贝数据,如何在纹理之间拷贝数据,以及如何将帧缓存中的数据拷贝到内存中或者缓存对象中。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335