從0開始的OpenGL學習(二十六)-實例數組

本文主要解決一個問題:

如何高效地將同一個物體繪制很多份?

引言

思考一個問題:如果想要在一個場景中繪制一片草地,但你手里只有一顆草的紋理,你該怎么做?

我想你可能會采取下面的這種做法:

for (int i = 0; i < 草的數量; ++i) {
  //一些預備工作
  glDrawArrays(GL_TRIANGLES, 0, 頂點數量);
}

沒錯,這是一種最簡單粗暴的做法,在大多數情況下也行的通。但是我們是有自尊心的程序員,怎么能容忍就只有這么一種笨辦法呢?用這種方法繪制,我們的程序很快會達到性能瓶頸。OpenGL在每次實現glDrawArrays或者glDrawElements繪制的時候需要做一些必要的準備工作,這大大影響了繪制的效率。和單純的繪制相比,CPU和GPU之間的通信代價十分高昂,頻繁地調用glDrawArrays等函數是將時間消耗到了通信上,完全是吃力不討好的行為。

不過不用擔心,作為地球上最聰明的一群人,發明OpenGL的人也都不是吃素的。為了應對這種情況,OpenGL提供了另外兩個函數:glDrawArraysInstanced和glDrawElementsInstanced。這就是高效繪制多個相同物體的終極方法:實例化。

實例化

由于多次調用glDrawArrays(glDrawElements)的代價高昂,我們自然就希望調用一次繪制函數就能繪制大量的物體,將物體的一些位置等不同的參數一并傳過去,給每個要繪制的物體編號,將其與自身的位置參數關聯起來繪制不就行了。

沒錯,OpenGL也是這么想的。

glDrawArraysInstanced(glDrawElementsInstanced)函數與glDrawArrays(glDrawElements)函數的不同之處在于前者多了一個數量的參數,它允許我們制定需要繪制多少個物體的實例。這樣,我們只需要調用glDrawArraysInstenced一次就可以繪制多個實例,大大節省了通信資源的消耗。

千萬別覺得調用glDrawArraysInstanced(glDrawElementsInstanced)函數的代價小,不信你可以調用個成百上千次glDrawArraysInstenced,每次都繪制一個物體試試。

有了可以繪制多個實例的函數之后,我們還需要每個實例的相關屬性。在頂點著色器找那個有個內置變量gl_InstancedID。這個變量表明了當前繪制的物體的索引(從0開始),由于這個索引的特性,我們可以將它作為屬性數組的索引來使用,方便。

為了有一個對實例化概念的直觀感受,我們來繪制100個矩形框,每行十個,鋪滿整個窗口。每個矩形框都由兩個三角形組成,其坐標如下:

float quadVertices[] = {
    -0.05f, 0.05f, 
    0.05f, -0.05f, 
    -0.05f, -0.05f,

    -0.05f, 0.05f,
    0.05f, -0.05f,
    0.05f, 0.05f 
};

頂點著色器中,我們聲明一個偏移數組,包含100個元素,指明每個實例的偏移量。將這個偏移量和輸入的位置相加,得到矩形框最終的位置:

#version 330 core
layout (location = 0) in vec2 aPos;

uniform vec2 offsets[100];

void main() {
    vec2 offset = offsets[gl_InstanceID];
    gl_Position = vec4(aPos + offset, 0.0, 1.0);
}

片元著色器不需要太多的代碼,簡單地輸出一個綠色就行了。接著,在進入渲染循環之前,我們要生成并且設置偏移信息:

glm::vec2 translations[100];
int index = 0;
float offset = 0.1f;
for(int y = -10; y < 10; y += 2)
{
    for(int x = -10; x < 10; x += 2)
    {
        glm::vec2 translation;
        translation.x = (float)x / 10.0f + offset;
        translation.y = (float)y / 10.0f + offset;
        translations[index++] = translation;
    }
}

在一個10x10的網格中,設置好每個位置的偏移值,然后調用著色器的設置函數將每個值都設置好,注意我們無法使用類似內存塊復制的函數,只能使用循環去設置每個元素:

shader.use();
for(unsigned int i = 0; i < 100; i++)
{
    stringstream ss;
    string index;
    ss << i; 
    index = ss.str(); 
    shader.setVec2(("offsets[" + index + "]").c_str(), translations[i]);
} 

這里我們用了一個小技巧,將i作為字符串與offsets拼接成uniform變量名進行設置,非常方便。一切準備就緒之后,就可以繪制了,注意要用glDrawArraysInstanced函數:

glBindVertexArray(VAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);

編譯運行,我們想要的結果出來了:

運行結果

沒時間手寫代碼的童鞋到可以下載這里的源碼看效果。

實例數組

對前面的例子來說,我們采用uniform數組的形式沒什么問題。但是uniform這種類型的變量是有上限的,而且這個上限值還很小,根本不夠用來設置大量物體的屬性。這時候,我們就要用到另外一個名叫實例數組的東西了。使用它和給物體定義一個頂點屬性一樣,不同的是實例數組中的東西只有在每次渲染新實例的時候會更新(普通頂點屬性是每個頂點都會更新)。

還是用上面的例子,我們怎么用實例數組的方式來顯示這100個矩形?首先把頂點著色器中的uniform變量去掉,換成頂點屬性輸入的格式:

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aOffset;

void main() {
    gl_Position = vec4(aPos + aOffset, 0.0, 1.0);
}

我們不需要用gl_InstanceID了,取而代之的是可以直接用offset屬性來加到位置上去。

因為在形式上實例數組就是一個頂點屬性,所以,我們也要像設置位置變量那樣把偏移數據保存到VBO中去,然后通過設置頂點屬性的方式來設置偏移值:

unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);

接著,設置頂點屬性并且啟用屬性:

glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)(0));
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribDivisor(1, 1);

這里的一個關鍵函數是glVertexAttribDivisor。這個函數告訴了OpenGL什么時候將哪個屬性更新成下一個元素。第一個參數表明要更新的是哪個屬性,第二個參數表示在什么時候更新。我們把第一個參數設置成1表示要設置的是偏移值,第二個參數設置成1表示在渲染一個新實例的時候更新此值。如果將這個參數設置成0,表示每個頂點都更新,設置成2表示每2個實例更新一次。這里我們要的是每個實例更新一次,所以設置成1最合適。

最后,我們做一點小改動:根據實例索引號逐漸增大矩形框.只要對頂點著色器稍稍修改一下就行了:

void main() {
    vec2 pos = aPos * (gl_InstanceID / 100.0);
    gl_Position = vec4(pos + aOffset, 0.0, 1.0);
}

編譯運行代碼,看到這樣的效果:


運行效果

如果還是有問題,可以參考這里的代碼

至此,實例化的原理都已經說明完畢,但這就夠了嗎?我們要來點有趣的東西才成。來實現一個小行星帶!

一個小行星帶

說起行星帶,筆者的第一反應就是土星,沒錯,土星很行星帶。要實現這樣一個行星帶,必然要用到我們之前的實例數組知識。加載一個行星模型和一個巖石模型,將行星置于世界坐標的原點,將巖石放到圍繞這行星的某個圓環位置(加上些許偏移),這樣就顯示出一個行星帶了。

先做一些準備工作,到這里下載行星模型巖石模型。將模型加載項目拷貝一份,我們就在這個副本上修改。

現在我們來考慮實現的具體細節。行星的位置放在原點,不用加什么變換。小行星(巖石)要放在一定半徑的圓上,加上一定的偏移。然后,我們海需要將所有的小行星都設置一個隨機的比例變換和一個旋轉角度,模擬小行星雜亂無章的樣子。將這些變換都保存到一個矩陣數組中傳遞到頂點著色器中就大功告成了。來看實現代碼:

unsigned int amount = 1000;     //數量1000
glm::mat4* modelMatrices = NULL;
modelMatrices = new glm::mat4[amount];
srand(glfwGetTime());
float radius = 50.0;
float offset = 2.5f;
for (int i = 0; i < amount; ++i) {
    glm::mat4 model;
    //1、平移到距離行星50,并且具有一定偏移量的位置
    float angle = (float)i / (float)amount * 360.0f;
    float displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float x = sin(angle) * radius + displacement;
    displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float y = displacement * 0.4f;  //在y軸上偏移一個小角度
    displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
    float z = cos(angle) * radius + displacement;
    model = glm::translate(model, glm::vec3(x, y, z));

    //2、將尺寸縮小一定的比例
    float scale = (rand() % 20) / 100.0f + 0.05;

    //3、旋轉一定角度
    float rotAngle = (rand() % 360);
    model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));

    //4、保存變換的矩陣
    modelMatrices[i] = model;
}

直接看代碼可能不太容易理解,我們把它一點點拆開來。首先準備工作是計算當前的小行星在行星帶上的角度(angle)。接著,計算小行星的x軸坐標,角度乘以半徑再加上一個隨機的偏移值(displacement)。然后計算y軸坐標,因為y軸上不需要非常大的上下波動,于是將隨機到的偏移值縮小到40%的大小。最后計算z軸坐標,同樣是角度加上偏移值。將x、y、z坐標都準備好之后,生成一個平移的矩陣。依次生成縮放變換和旋轉變換的矩陣,統統放到model變量中保存起來備用。

繪制工作我們先來用普通的方法繪制,后面再用實例數組的方式,這樣更能看出區別。普通方法很簡單,在繪制之前設置模型變換矩陣后在調用模型的Draw函數就可以了,代碼如下:

ourShader.use();
glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 500.0f);
glm::mat4 view = camera.GetViewMatrix();
ourShader.setMat4("projection", glm::value_ptr(projection));
ourShader.setMat4("view", 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));
ourShader.setMat4("model", glm::value_ptr(model));
planet.Draw(ourShader);

//繪制行星帶
for (unsigned int i = 0; i < amount; i++) {
    ourShader.setMat4("model", glm::value_ptr(modelMatrices[i]));
    rock.Draw(ourShader);
}
運行效果

把投影矩陣的遠裁剪面加到500,這樣才能看到整個行星帶,否則會遠處的會被裁剪掉。

如果還有問題,也可以下載這里的源碼做對比。

經過筆者的嘗試,當小行星的數量達到3000的時候,移動攝像機就已經會產生一卡一卡的效果,而這時候環繞的小行星在數量上還不足以給人一種很多的感覺。因此,這種普通的方法顯然不能實現我們想要的功能,那么實例數組的方式如何呢,我們來試試。

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in mat4 instanceMatrix;

out vec2 TexCoords;

//uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    TexCoords = aTexCoords;    
    gl_Position = projection * view * /*model*/instanceMatrix * vec4(aPos, 1.0);
}

添加實例矩陣數組作為輸入,注釋掉原有uniform類型的model變量,計算最終位置的時候使用實例矩陣數組作為模型變換矩陣。

接著來設置實例矩陣數組。同前面的例子一樣,先分配一個VBO用來保存矩陣數據(這里筆者把這個數字調大了,100000個!)。設置屬性的時候和之前的例子不一樣。因為我們模型的網格類中自帶有VAO的信息,所以我們在繪制的時候必須將實例數組綁定到每個網格的VAO:

unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::mat4) * amount, &modelMatrices[0], GL_STATIC_DRAW);
    
for (unsigned int i = 0; i < rock.meshes.size(); ++i) {
    unsigned int tVAO = rock.meshes[i].VAO;
    glBindVertexArray(tVAO);

    GLsizei vec4Size = sizeof(glm::vec4);
    glEnableVertexAttribArray(3);
    glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)0);
    glEnableVertexAttribArray(4);
    glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(vec4Size));
    glEnableVertexAttribArray(5);
    glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(2 * vec4Size));
    glEnableVertexAttribArray(6);
    glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(3 * vec4Size));
    
    glVertexAttribDivisor(3, 1);
    glVertexAttribDivisor(4, 1);
    glVertexAttribDivisor(5, 1);
    glVertexAttribDivisor(6, 1);

    glBindVertexArray(0);
}

在繪制的時候,我們要一個新的Shader用于繪制小行星帶(之前的shader用來繪制中間的行星),然后手動調用glDrawElementsInstanced函數繪制巖石的每個網格,將每個網格繪制100000份,組合起來就是100000個小行星了:

//繪制行星帶
instanceShader.use();
instanceShader.setMat4("projection", glm::value_ptr(projection));
instanceShader.setMat4("view", glm::value_ptr(view));
for (unsigned int i = 0; i < rock.meshes.size(); i++) {
    glBindVertexArray(rock.meshes[i].VAO);
    glDrawElementsInstanced(GL_TRIANGLES,rock.meshes[i].indices.size(),GL_UNSIGNED_INT, 0, amount);
}

應該沒啥問題了,編譯運行,我們的行星帶偏移范圍都已經被塞的滿滿的了:


10萬個小行星的運行效果

看著怪好笑的,哈哈!關鍵是這種繪制方式即便是達到10萬個數量筆者的機子上也不覺得卡,可見兩種繪制方式之間的差距!

最后,如果沒的顯示或者顯示不對,看這里

總結

本章我們實際上就是學習了兩個函數:glDrawArraysInstanced和glDrawElementsInstanced。我們學習了這兩個函數中的實例參數實現的背后原理以及如何去用它實現大量物體繪制功能。原理簡單,將本章中的例子吃透之后就更簡單了。

下一篇
目錄
上一篇

參考資料

www.learnopengl.com(非常好的網站,建議學習)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容