1. 程式人生 > >用OpenGL的Image特性實現紋理資料的直接讀寫操作

用OpenGL的Image特性實現紋理資料的直接讀寫操作

OpenGL的Image特性簡介

Image是在OpenGL 4.2成為core標準的,大概目標是用於通用計算,因此它只能在Compute Shader和Fragment Shader裡使用。它跟一個特定的紋理繫結在一起,所進行的操作會直接影響這個紋理。
紋理在glsl裡是sampler,是隻能讀不能寫的,以前要實現通用計算的資料輸出,就得建一個FBO,繫結一張跟源紋理一樣大的新紋理,把計算結果畫上去。這樣的繪製有其侷限性,比方說讀取資料可以用紋理座標指定,但是寫入資料的位置卻是不能任意指定的,只能是當前繪製的畫素,在許多方面有其不便之處。
Image的引入就使得事情變得比較簡單了。Image繫結的紋理可以直接指定Image座標寫入,使得更為複雜的操作成為可能。不過由於座標可以任意指定,就可能出現兩組資料同時指定同一個座標的可能,造成訪問衝突。因此Image還有一個Atomic操作的機制用於解決訪問衝突。
Compute Shader由於其特殊性,不會對當前的Color Buffer進行任何寫入,因此資料的輸出必然要藉助Image,大概這是Image出現的一個最重要的原因吧。

Image的使用

OpenGL環境端

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_ONLYGL_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。

Image傳入

傳入還是比較簡單的,跟紋理一樣,只不過不需要bindTexture了,直接用下面這種句式:

glUniform1i(glGetUniformLocation(program, "texImage"), 0);

最後一個引數0正是定義時的unit的數字,應該根據實際定義修改。這個數字跟傳紋理時的數字似乎並不衝突。當然,glGetUniformLocation(program, "texImage")這句也可以換成固定的值,然後在glsl裡通過layout的location來對應。

GLSL端

定義

首先,glsl的版本至少是4.2以上,因此至少需要一句#version 420。現在OpenGL和GLSL已經出到4.5了,自己顯示卡是否支援可以通過查詢GL_VERSIONGL_SHADING_LANGUAGE_VERSION得到。

然後是Image的入口,如:

layout (rgba8) uniform restrict image2D texImage;

其中,restrict是訪問修飾符,有很多形式,也可以是readonlywriteonly等,經常涉及到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的尺寸。

有了這些引數基本就可以隨心所欲地讀取和修改指定座標的值了,還是很方便的。

Atomic操作

在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位,大概只能拆通道了。