再談vbo

胖Po發表於2021-05-20

我們之前都是通過glNamedBufferData初始化buffer object,初始化的意思是為buffer object開闢視訊記憶體空間,並填充資料:

GLfloat position[] =
{
    -1.0f, -1.0f,
    0.0f, 1.0f,
    1.0f,  -1.0f,
};
GLuint vbo = 0;
glCreateBuffers(1, &vbo);
glNamedBufferData(vbo, sizeof(position), position, GL_STATIC_DRAW);

...
drawTriangle();

glNamedBufferData一個比較方便的地方就是,如果我們在drawTriangle()完成之後還想再繪製一個方形,可以複用這個vbo。

...		//after drawing triangle
GLfloat position[] =
{
    -0.5f, 0.5f,
    -0.5f, -0.5f,
     0.5f,  0.5f,
     0.5f, -0.5f,
};

glNamedBufferData(vbo, 8 * sizeof(GLfloat), position, GL_STATIC_DRAW);	//只能等drawTriangle完成之後才能執行

...
drawRect();

一個buffer object可以複用很多次,但是不推薦這麼做。因為這樣會降低GPU的並行力度,起碼我們可以推斷:glNamedBufferData(vbo, 8 * sizeof(GLfloat), position, GL_STATIC_DRAW)要等待drawTriangle()完成之後才能執行。有經驗的OpenGL程式設計師都會建立兩個VBO完成這兩次render操作。

但是我想表達的是:對於同一個buffer object,可以多次呼叫glNamedBufferData為它重新分配空間和指定usage。

那有沒有一種API,一旦初始化完buffer object之後,其size和usage就不得發生改變呢?有,它就是我們今天要講的glNamedBufferStorage

glNamedBufferStorage

先看一下這個命令的原型:

void glNamedBufferStorage(GLuint buffer,
 	GLsizeiptr size,
 	const void *data,
 	GLbitfield flags);

前三個引數與glNamedBufferData相同,最後一個引數是以位組合來表達此buffer的usage,它的值可能是以下標識位組合:

  • GL_DYNAMIC_STORAGE_BIT
  • GL_MAP_READ_BIT
  • GL_MAP_WRITE_BIT
  • GL_MAP_PERSISTENT_BIT
  • GL_MAP_COHERENT_BIT

我不會去機械的羅列出這些標識位的意思,因為那樣會很無趣。我的理念是用到哪個東西,再講哪個東西。

首先,再次明確一下,官方的說法是:一旦用glNamedBufferStorage為buffer object分配好空間並指定usage flag之後,該buffer object的size和usage flag就不能再發生改變,不過資料的內容倒是可以改變。

在我看來,這句話說的實在是太輕了,因為哪怕是這樣程式碼:

//以相同的引數呼叫glNamedBufferStorage兩次
glNamedBufferStorage(vbo, sizeof(trianglePosition), trianglePosition, 0);
glNamedBufferStorage(vbo, sizeof(trianglePosition), trianglePosition, 0);

OpenGL都會抱怨產生了一個錯誤:Cannot modify immutable buffer。

哇,您還不如和我說對於同一個buffer object,只能呼叫一次glNamedBufferStorage呢。不過這樣的嚴格限制,帶來的好處是:比起glNamedBufferDataglNamedBufferStorage能夠帶來更好的效能。而且glNamedBufferStorage的usage flag引數更加現代化,準確的判斷usage flag引數,對於效能的優化以及程式的正確執行都至關重要。比如你呼叫了glNamedBufferStorage為某個buffer object開闢了空間並初始化了其中的資料,之後你再也不想對這個buffer object進行讀取或者寫入,那麼就可以把usage flag設定為0,這將帶來最好的效能優化。

但是如果我們還想修改這個buffer object中的資料,或者是想把buffer object中的資料讀取至記憶體,該怎麼辦呢?

讀取buffer中的資料

在把buffer object的usage設定為0的情況下,我們可以呼叫glNamedBufferSubData來讀取buffer中的資料:

glNamedBufferStorage(vbo, sizeof(trianglePosition), trianglePosition, 0);	//最後一個引數設定為0
...
glGetNamedBufferSubData(vbo, 0, 9*sizeof(GLfloat), positionPointer);

這樣,vbo中的資料就會從視訊記憶體對映到positionPointer指向的記憶體空間。

不過這裡還是有一個小細節:呼叫glNamedBufferStorage之後,OpenGL告訴我這個buffer object的usage hint是GL_STATIC_DRAW,然後我們在某個時機再呼叫glGetNamedBufferSubData之後,OpenGL就會告訴我這個buffer object的usage hint是GL_DYNAMIC_DRAW。我們都知道GL_STATIC_DRAW是要比GL_DYNAMIC_DRAW的效能更好。

向buffer中寫入資料

我們可以呼叫glNamedBufferSubData來向buffer object中寫入資料:

glNamedBufferSubData(vbo, 0, 9*sizeof(GLfloat), newTrianglePoints);

不過,初始化buffer object的時候,要把usage設定為GL_DYNAMIC_STORAGE_BIT

glNamedBufferStorage(vbo, sizeof(trianglePosition), trianglePosition, GL_DYNAMIC_STORAGE_BIT);

而且,與"讀取buffer中的資料"中得到的結論一致:呼叫glNamedBufferSubData之後,OpenGL也會告訴我buffer object的usage hint是GL_DYNAMIC_DRAW。

所以各位同學,哥哥奉勸你們:使用glNamedBufferStorage初始化buffer object空間之後,儘量就不要再對buffer object進行讀寫了吧。

我嘗試過把usage設定為0,然後向buffer寫入資料:

glNamedBufferStorage(vbo, sizeof(trianglePosition), trianglePosition, 0);
...
glNamedBufferSubData(vbo, 0, 9*sizeof(GLfloat), newTrianglePoints);

OpenGL又告訴我發生了一個錯誤: Buffer contents cannot be modified because the buffer was created without the GL_DYNAMIC_STORAGE_BIT set。雖然儘可能精簡usage flag是一個好習慣,不過前提還是要保證程式的正確性。

另外,雖然glNamedBufferSubData看起來與glNamedBufferData有點像,但是它們確實不是同一類API。

glNamedBufferDataglNamedBufferStorage算是同一類API,而glNamedBufferSubData是配合glNamedBufferStorage使用的。

glMapNamedBufferRange

在早期的OpenGL,我們可以使用glMapBufferglUnmapBuffer來讀寫buffer object的資料。modern OpenGL有更為高階的API來做這件事:

void *glMapNamedBufferRange(GLuint buffer,
 	GLintptr offset,
 	GLsizeiptr length,
 	GLbitfield access);

此君與glMapNamedBuffer相比,有兩個優點:

  1. 能夠將buffer object的一部分(而非全部)資料對映到記憶體空間。
  2. 能夠以位組合的形式描述訪問策略,access引數必須與glNamedBufferStorageusage配合起來使用。

比如我想讀取三角形的頂點位置buffer中的資料,然後進行射線求交:

GLfloat position[] =
{
    ...
};
glNamedBufferStorage(vbo, 9 * sizeof(GLfloat), position, GL_MAP_READ_BIT);	//初始化buffer時,usage flag要包含GL_MAP_READ_BIT才能讀取buffer的內容

...

GLfloat* trianglePosition = (GLfloat*)glMapNamedBufferRange(vbo, 0, 9 * sizeof(GLfloat), GL_MAP_READ_BIT);//最後一個參數列示僅對buffer進行讀操作

//射線求交的虛擬碼
Ray ray;
ray.intersectWithTriangle(trianglePosition);

GLboolean unMap = glUnmapNamedBuffer(vbo);	//操作完成,告訴OpenGL我不會再使用trianglePosition指標指向的內容了
assert(unMap);

需要反覆強調的:glNamedBufferStorage的usage flag引數要與glMapNamedBufferRange的access flag引數配合起來。

另外,當不再需要glMapNamedBufferRange返回的資料之後,應儘快進行glUnmapNamedBuffer。然後檢查其返回值,在某些極端的情況下,可能會unmap失敗。

當我進行讀操作的時候,OpenGL沒有通知我關於buffer的usage hint的訊息。當我進行寫操作的時候,OpenGL通知我此buffer的usage hint為GL_DYNAMIC_DRAW。

到底該使用哪個

既然glNamedBufferSubData(glGetNamedBufferSubData)glMapNamedBufferRange都能讀寫buffer的資料,那問題來了,我們應該用哪個呢?

首先,glMapNamedBufferRange更具效能優勢。我不去講什麼專業名詞(譬如什麼asynchronous DMA transfer之類的東東),僅從程式碼的角度來分析為什麼glMapNamedBufferRange速度更快。

首先,我們為glNamedBufferSubData傳入的指標所指向的記憶體空間是我們自己分配出來的。譬如:

GLfloat* trangleColor = new GLfloat[9];
for (int i = 0; i < 3; ++i)
{
    for (int j = 0; j < 3; ++j)
	trangleColor[i * 3 + j] = 1.0f;
}
glNamedBufferSubData(vbo, 0, sizeof(GLfloat) * 9, trangleColor);
delete[] trangleColor;		//傳輸到buffer之後直接銷燬

如上程式碼,OpenGL無法預料到開發者在呼叫完glNamedBufferSubData之後,怎麼處理trangleColor指向的記憶體空間,比如我呼叫完glNamedBufferSubData之後直接銷燬掉了這部分資料。所以OpenGL只能讓CPU在glNamedBufferSubData先停下來,集中精力把triangleColor指向的記憶體空間的內容拷貝到視訊記憶體空間,等glNamedBufferSubData返回之後(拷貝完成),才能繼續往下執行。

glMapNamedBufferRange返回記憶體空間是OpenGL自己管理的,當我們呼叫glUnmapNamedBuffer之後,可以立即返回,然後在比較閒的時候偷偷的執行拷貝操作,並且這個操作也可以與其它的命令並行執行。

所以如果只是很偶爾地讀寫的很小的資料量,兩者區別可能沒有那麼明顯。但是如果頻繁的讀寫,或者一次讀寫的資料量很大,那麼glMapNamedBufferRange glUnmapNamedBuffer的效能優勢就非常明顯了。

glMapNamedBufferRange還有一個優點,比如三角形的頂點資料存放到了磁碟的某個檔案中,現在要用這個檔案的頂點來建立vbo:

//使用glNamedBufferSubData
FILE* f = fopen("position.dat", "rb");
fseek(f, 0, SEEK_END);
long fileSize = std::ftell(f);
fseek(f, 0, SEEK_SET);

glNamedBufferStorage(vbo[0], fileSize, nullptr, GL_DYNAMIC_STORAGE_BIT);

GLchar* position = new GLchar[fileSize];	//申請記憶體空間用來存放檔案的頂點資料
fread(position, 1, fileSize, f);
glNamedBufferSubData(vbo[0], 0, fileSize, position);
delete[] position;
fclose(f);
//使用glMapNamedBufferRange
FILE* f = fopen("position.dat", "rb");
fseek(f, 0, SEEK_END);
long fileSize = std::ftell(f);
fseek(f, 0, SEEK_SET);

glNamedBufferStorage(vbo[0], fileSize, nullptr, GL_MAP_WRITE_BIT);

void* data = glMapNamedBufferRange(vbo[0], 0, fileSize, GL_MAP_WRITE_BIT);
fread(data, 1, fileSize, f);
fclose(f);
glUnmapNamedBuffer(vbo[0]);

利用glMapNamedBufferRange就可以避免先把檔案的內容拷貝到記憶體中這一步驟。

小結

  1. 推薦使用glNamedBufferStorage初始化buffer object。
  2. 推薦使用glMapNamedBufferRange glUnmapNamedBuffer讀寫buffer中的資料。

相關文章