OpenGL學習腳印:建立更多的例項(instancing object)
寫在前面
前面我們學習了模型載入的相關內容,併成功載入了模型,令人十分興奮。那時候載入的是少量的模型,如果需要載入多個模型,就需要考慮到效率問題了,例如下圖所示的是載入了400多個納米戰鬥服機器人的效果圖:
渲染一個模型更多的例項,需要使用到例項化技術,就是本節要介紹的instancing object方法。本節示例程式碼均可以從我的github下載。
渲染多個例項的方法
要渲染多個例項,基本的想法就是,在主程式中使用迴圈,在不同位置繪製多個物體,虛擬碼如下所示:
for(GLuint i = 0; i < instanceCount; ++i)
{
// 分別設定每個物體的模型變換矩陣 model matrix
// glDrawArrays(GL_TRIANGLES, ...)
}
這種方式存在的缺點是,當要渲染多個模型的例項時,需要多次呼叫glDraw這類命令,而這類命令從CPU–>GPU是需要花費時間的,因為使用繪製命令時OpenGL需要做一些工作,例如通知GPU從哪個buffer裡面讀取資料。雖然GPU繪圖很快,但是CPU–>GPU的命令傳送,當量比較大時還是會成為瓶頸。
因此OpenGL提供了glDrawArrays和glDrawElements的繪製例項版本,分別對應為glDrawArraysInstanced和glDrawElementsInstanced 。例項版本的函式,多了一個引數,就是最後一個指定渲染多少個例項的引數。
下面以一個簡單的繪製多個矩形的例子作為引例,開始熟悉繪製多個例項。
使用多個uniform傳遞例項資料
假設我們要繪製100個矩形,在頂點著色器中,我們使用一個uniform陣列:
#version 330 core
layout(location = 0) in vec2 position;
layout(location = 1) in vec3 color;
uniform vec2 offsets[100]; // 每個例項的位移量
out vec3 fColor;
void main()
{
vec2 offset = offsets[gl_InstanceID]; // 通過gl_InstanceID索引每個例項的位移量
gl_Position = vec4(position + offset, 0.5f, 1.0f);
fColor = color;
}
通過gl_InstanceID來索引每個例項,而在主程式中,我們通過迴圈設定這個uniform陣列的內容:
//準備多個例項的位移量資料
glm::vec2 translations[100];
int index = 0;
GLfloat offset = 0.1f;
for (GLint y = -10; y < 10; y += 2)
{
for (GLint x = -10; x < 10; x += 2)
{
glm::vec2 translation;
translation.x = (GLfloat)x / 10.0f + offset;
translation.y = (GLfloat)y / 10.0f + offset;
translations[index++] = translation;
}
}
// 接著 向shader傳遞這100個translate uniform
最後通過例項版本函式繪製多個矩形:
shader.use();
glBindVertexArray(quadVAOId);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100); // 使用instance方法繪製
得到的效果如下圖所示:
我們看到使用這個方法,確實渲染了多個矩形,但存在的問題時GLSL中支援的uniform受到限制,可以使用 GL_MAX_VERTEX_UNIFORM_COMPONENTS等列舉通過glGetIntegerv函式查詢。一般情況下uniforms陣列也夠用,但是對於需要例項比較多的情形,這種方案變得不合適。
使用instance array 傳遞例項資料
同頂點屬性中位置、紋理座標等其他屬性一樣,我們可以通過VBO來充當一個instance array,傳遞每個例項的資料。一般地頂點屬性,當頂點著色器執行時需要獲取每個頂點的這些屬性資訊,而充當instance array的頂點屬性需要每個例項更新一次。這是instance array與普通頂點屬性之間的差別。
建立一個instance array的包括兩個步驟,第一步同普通頂點屬性一樣,建立VBO,填充資料;第二步是通知OpenGL如何解析VBO中的資料。在頂點著色器中,我們定義一個layout=2表示這個instance array,如下:
#version 330 core
layout(location = 0) in vec2 position;
layout(location = 1) in vec3 color;
layout(location = 2) in vec2 offset; // 通過VBO傳遞位移量
// uniform vec2 offsets[100]; // 不再使用
out vec3 fColor;
void main()
{
gl_Position = vec4(position + offset, 0.5f, 1.0f);
fColor = color;
}
在主程式中,建立VBO,填充translations陣列的資料,如下:
GLuint instanceVBOId;
glGenBuffers(1, &instanceVBOId);
glBindVertexArray(quadVAOId);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBOId);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
並通知OpenGL解析這個VBO資料的方式:
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(2);
glVertexAttribDivisor(2, 1); // 注意這裡 指定1表示每個例項更新一次資料
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
這裡關鍵是使用glVertexAttribDivisor來指定資料更新方式,第一個引數2表示layout索引,第二個引數指定頂點屬性的更新方式,預設是0表示著色器每次執行時更新屬性資料,填寫1表示每個例項更新一次屬性資料,填寫2則表示每2個例項更新一次屬性資料,依次類推。上面填寫1則通知了OpenGL這是一個instance array,每個例項更新一次資料。
執行上述程式碼,我們得到的效果與上面相同,當設定:
glVertexAttribDivisor(2, 4);
每4個例項更新一次資料時,我們將會得到100 / 4 =25個矩形,因為每4個矩形的模型變換矩陣相同,因此放在了同一個位置,重合了,效果如下圖所示:
上面是一個簡單的引例,下面我們通過兩個案例,深入對比下instance array方式的效能差別。
繪製行星帶
通過載入一個行星模型和石頭模型來模擬一個行星帶,這裡我們通過下面的函式,來構造一個石頭模型隨機環繞行星的模型變換矩陣:
// 這裡通過隨機方式 構造多個石頭模型的模型變換矩陣
void prepareInstanceMatrices(std::vector<glm::mat4>& modelMatrices, const int amount)
{
srand(glfwGetTime()); // 初始化隨機數的種子
GLfloat radius = 50.0;
GLfloat offset = 2.5f;
for (GLuint i = 0; i < amount; i++)
{
glm::mat4 model;
// 1. 平移
GLfloat angle = (GLfloat)i / (GLfloat)amount * 360.0f;
GLfloat displacement = (rand() % (GLint)(2 * offset * 100)) / 100.0f - offset;
GLfloat x = sin(angle) * radius + displacement;
displacement = (rand() % (GLint)(2 * offset * 100)) / 100.0f - offset;
GLfloat y = displacement * 0.4f;
displacement = (rand() % (GLint)(2 * offset * 100)) / 100.0f - offset;
GLfloat z = cos(angle) * radius + displacement;
model = glm::translate(model, glm::vec3(x, y, z));
// 2. 縮放 在 0.05 和 0.25f 之間
GLfloat scale = (rand() % 20) / 100.0f + 0.05;
model = glm::scale(model, glm::vec3(scale));
// 3. 旋轉
GLfloat rotAngle = (rand() % 360);
model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));
// 4. 新增作為模型變換矩陣
modelMatrices.push_back(model);
}
}
上面隨機方式構造變換矩陣的計算細節,可以不用深究,我們需要重點理解的是對比使用普通方式和使用instance array的效率問題。
不使用instance array的繪製方式
構造了多個例項的矩陣後,我們使用普通的繪製方式如下:
// 這裡填寫場景繪製程式碼
shader.use();
glUniformMatrix4fv(glGetUniformLocation(shader.programId, "projection"),
1, GL_FALSE, glm::value_ptr(projection));
glUniformMatrix4fv(glGetUniformLocation(shader.programId, "view"),
1, GL_FALSE, glm::value_ptr(view));
glm::mat4 model;
model = glm::translate(model, glm::vec3(0.0f, -3.0f, 0.0f));
model = glm::scale(model, glm::vec3(4.0f, 4.0f, 4.0f));
glUniformMatrix4fv(glGetUniformLocation(shader.programId, "model"),
1, GL_FALSE, glm::value_ptr(model));
planet.draw(shader); // 先繪製行星
// 繪製多個小行星例項
for (std::vector<glm::mat4>::size_type i = 0; i < modelMatrices.size(); ++i)
{
glUniformMatrix4fv(glGetUniformLocation(shader.programId, "model"),
1, GL_FALSE, glm::value_ptr(modelMatrices[i]));
rock.draw(shader);
}
使用instance array的繪製方式
同上面使用的instance array有些不同,這裡使用的instance array是mat4型別的矩陣,因為頂點屬性允許的最大資料為vec4,因此我們需要使用4 * vec4表示這個mat4型別的instance array。在頂點著色器中定義這個mat4 instance array如下:
#version 330 core
layout(location = 0) in vec3 position;
layout(location = 1) in vec2 textCoord;
layout(location = 2) in vec3 normal;
layout(location = 3) in mat4 instanceMatrix; // 頂點屬性最多vec4 輸入 實際上有4個vec4輸入構造這個mat4
uniform mat4 projection;
uniform mat4 view;
out vec2 TextCoord;
void main()
{
gl_Position = projection * view * instanceMatrix * vec4(position, 1.0);
TextCoord = textCoord;
}
同時我們還需要在主程式中向著色器傳遞這個instance array。之前設計的mesh.h類,需要少量修改,允許獲取mesh相關資訊,修改後的mesh.h類。我們這裡不去大量修改mesh類,採用的策略是為每個mesh使用這個instance array,實現如下:
void prepareInstanceMatrices(std::vector<glm::mat4>& modelMatrices,
const int amount, const Model& instanceModel)
{
// 構造modelMatrices 同上面函式實現
// 建立instance array
GLuint modelMatricesVBOId;
glGenBuffers(1, &modelMatricesVBOId);
glBindBuffer(GL_ARRAY_BUFFER, modelMatricesVBOId);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::mat4) * amount, &modelMatrices[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 為模型裡每個mesh 傳遞model matrix
// 用4個vec4傳遞這個mat4型別
const std::vector<Mesh>& meshes = instanceModel.getMeshes();
for (std::vector<Mesh>::size_type i = 0; i < meshes.size(); ++i)
{
glBindVertexArray(meshes[i].getVAOId());
glBindBuffer(GL_ARRAY_BUFFER, modelMatricesVBOId);
// 第一列
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE,
4 * sizeof(glm::vec4), (GLvoid*)0);
// 第二列
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE,
4 * sizeof(glm::vec4), (GLvoid*)(sizeof(glm::vec4)));
// 第三列
glEnableVertexAttribArray(5);
glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE,
4 * sizeof(glm::vec4), (GLvoid*)(2 * sizeof(glm::vec4)));
// 第四列
glEnableVertexAttribArray(6);
glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE,
4 * sizeof(glm::vec4), (GLvoid*)(3 * sizeof(glm::vec4)));
// 注意這裡需要設定例項資料更新選項 指定1表示 每個例項更新一次
glVertexAttribDivisor(3, 1);
glVertexAttribDivisor(4, 1);
glVertexAttribDivisor(5, 1);
glVertexAttribDivisor(6, 1);
glBindVertexArray(0);
}
}
這個地方稍微有點繞,關鍵一點就是每個mesh都包含了這個modelMatrices資料,因此每個mesh繪製三角形時,都會在每個例項上更新modelMatrix,從而整體上繪製出的模型也用了這些模型變換矩陣。
上面繪製的效果如下圖所示:
使用上面兩種方法渲染包含1000, 10000, 100000個石頭模型的行星帶,在NVIDIA Graphics 上粗略的一個對比資料(這不是基準測試結果),如下表1所示:
例項數目 | 普通繪製 | instancing方法 |
---|---|---|
1000 | 0.05s | 0.01s |
10,000 | 0.45s | 0.12s |
100,000 | 4.0s | 1.25s |
這個計時是通過glfwGetTime來實現的,更科學的對比可能是使用幀率,暫時不細究這個問題了。通過對比,可以看到使用instance array渲染多個例項速度比普通方式快了4到5倍。
渲染更多的納米戰鬥服機器人
再給出一個使用instance方法,繪製多個機器人的方法,我們指定了要繪製的機器人數量,然後平鋪在鋼鐵紋理上。繪製9個機器人的效果如下圖所示:
121個機器人效果如下圖所示:
渲染的441個機器人效果如下圖所示:
你可以根據需要將機器人的擺放成其他形式,例如同心圓、心形圖案等,可以自己玩會兒了。
最後的說明
本節學習了instance例項的方法,並對比了普通渲染方式和它在效能上的差別。實際應用中,instance例項一般應用在草地、樹木等模型上面,來構成遊戲場景中很好的佈景。