Image是在OpenGL 4.2成为core标准的,大概目标是用于通用计算,因此它只能在Compute Shader和Fragment Shader里使用。它跟一个特定的纹理绑定在一起,所进行的操作会直接影响这个纹理。
纹理在glsl里是sampler,是只能读不能写的,以前要实现通用计算的数据输出,就得建一个FBO,绑定一张跟源纹理一样大的新纹理,把计算结果画上去。这样的绘制有其局限性,比方说读取数据可以用纹理坐标指定,但是写入数据的位置却是不能任意指定的,只能是当前绘制的像素,在许多方面有其不便之处。
Image的引入就使得事情变得比较简单了。Image绑定的纹理可以直接指定Image坐标写入,使得更为复杂的操作成为可能。不过由于坐标可以任意指定,就可能出现两组数据同时指定同一个坐标的可能,造成访问冲突。因此Image还有一个Atomic操作的机制用于解决访问冲突。
Compute Shader由于其特殊性,不会对当前的Color Buffer进行任何写入,因此数据的输出必然要借助Image,大概这是Image出现的一个最重要的原因吧。
首先一张纹理是必须的。纹理生成之后,使用glBindImageTexture
命令来建立一个Image。原型如下:
void glBindImageTexture(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format)
这里有几个重要参数。
其中要重点注意的是unit
,这个参数可以看成是Image的编号。Wiki里说只有Compute Shader和Fragment Shader支持非零个Image Unit,意思就是只有这两个Shader才能用Image的意思了吧。具体支持几个可以用GL_MAX_*_IMAGE_UNIFORMS
来查询,星号指的是Shader。一般而言,编号从0开始呗,至少能用8个,一般也够了吧。
另外一个就是access
,有GL_READ_ONLY
, GL_WRITE_ONLY
和GL_READ_WRITE
三个选项,说实在的,我是不知道ReadOnly的选项有什么用,如果单纯只是读入的话,用普通的纹理Sampler就可以呀?也许是插值的问题?
最后的那个format
是要跟纹理格式对上的,如果Image的格式是GL_RGBA8
,对应的纹理格式应该是GL_UNSIGNED_BYTE
型的GL_RGBA
。相应的,如果是GL_R32UI
,那么纹理格式应该是GL_UNSIGNED_INT
型的GL_RED_INTEGER
。具体可以查阅Wiki。
因此,比方说要生成一个Image,在创建好纹理之后,可以使用:
glBindImageTexture(0, texID, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8);
创建了一个0号无layer的同时用于读取和写入采用8位RGBA格式的Image。
传入还是比较简单的,跟纹理一样,只不过不需要bindTexture了,直接用下面这种句式:
glUniform1i(glGetUniformLocation(program, "texImage"), 0);
最后一个参数0正是定义时的unit
的数字,应该根据实际定义修改。这个数字跟传纹理时的数字似乎并不冲突。当然,glGetUniformLocation(program, "texImage")
这句也可以换成固定的值,然后在glsl里通过layout的location来对应。
首先,glsl的版本至少是4.2以上,因此至少需要一句#version 420
。现在OpenGL和GLSL已经出到4.5了,自己显卡是否支持可以通过查询GL_VERSION
和GL_SHADING_LANGUAGE_VERSION
得到。
然后是Image的入口,如:
layout (rgba8) uniform restrict image2D texImage;
其中,restrict是访问修饰符,有很多形式,也可以是readonly
、writeonly
等,经常涉及到memoryBarrier之类的东西,具体的其实没怎么看明白,一般用restrict就可以。
layout修饰符括号内的rgba8是指读取image数据时的格式,因此实际上当使用writeonly
修饰符时,由于并没有数据读取,这个参数可以省略掉。当然,如果在传入时直接指定了传入位置,这里也可以写上location = 0
之类的语句。
image2D
标明这是一个二维的image,在glsl内部使用的是归一化过的float型的像素格式。假如是uimage2D
,就是未归一化过的uint格式了。
最基本的读取和写入Image的函数是:
gvec4 imageLoad(gimage image, IMAGE_COORD);
void imageStore(gimage image, IMAGE_COORD, gvec4 data);
其用法类似sampler时的texture(sampler, texcoord)
函数,但是注意IMAGE_COORD
参数是一个ivec
型的不会被归一化的像素坐标,也就是说传进去的永远是实实在在的像素坐标整数值。
当然也可以通过ivec imageSize(gimage image)
函数得到image的尺寸。
有了这些参数基本就可以随心所欲地读取和修改指定坐标的值了,还是很方便的。
在GPU内部采用并行方式进行各种操作,对于读取image时不会有什么问题,但是在直接写入时就会遇到一些情况。假如某一些操作需要写入一个指定的image坐标,如果若干并行线程同时需要写入同一个坐标的值,就会发生访问冲突,有可能某些线程写入失败,OpenGL并不能保证最后得到的值是什么。
因此Image还有一种操作方式叫做Atomic操作(应该翻译成“原子操作”吗?),这种操作作用在同一个数值上时,虽然不能保证操作的先后次序,但是能够保证每一个操作都能够排好队,依次得到执行。
以加法为例:
gint imageAtomicAdd(gimage image, IMAGE_COORDS, gint data);
函数将先读取IMAGE_COORDS
位置的值,再把data
的值加上去写进image里。也就是说,函数会返回没进行运算之前的原值。
这也就意味着,Atomic是需要读取原值的,也就是说环境端配置Image时必须是GL_READ_WRITE
访问权限。
但是大家可能发现了,这个函数传入和传出的值都是gint
,也就是int
或者uint
。可是上面imageLoad用的可是vec4
啊?
是的,Atomic操作只支持32位的int或者unsigned int值,意味着image只能是r32i或者r32ui格式。对于通用计算而言,其实也并没什么。可是如果希望进行一些颜色的运算,想用三通道,怎么办呢?
一个办法是把三通道拆成三张Image来运算,当然得增加一些运算量和储存空间。
另外还可以用一个讨巧的办法。比如将OpenGL环境端的纹理和Image格式设成GL_RGB8UI
,那么纹理用的是GL_UNSIGNED_INT
格式。
而在GLSL端layout里却设成r32ui
。这么一来,实际上一个32位单色uint就刚好对应4个8位uint。前文提及,layout针对读取格式,写入时依然可以按照rgb8ui的格式来写uvec4
。
但是读取和Atomic运算时只能用uint了。没关系,我们可以进行一个转换。定义两个函数:
uint color2Atom(uvec4 color)
{
return color.r | (color.g << 8) | (color.b << 16) | (color.a << 24);
}
uvec4 atom2Color(uint atom)
{
uvec4 result;
result.r = atom & uint(0xff);
result.g = (atom >> 8) & uint(0xff);
result.b = (atom >> 16) & uint(0xff);
result.a = (atom >> 24) & uint(0xff);
return result;
}
我们完全可以采用位操作(在OpenGL 4.0之后支持)来对uvec4和uint进行变换。可能在color2Atom函数里还需要对uvec4的边界进行限定,限定在[0,255]之间。
显然,这个方法只能处理8位RGBA。假如要处理16位或者32位,大概只能拆通道了。
参考文献:OpenGL Wiki