Android OpenGL ES 2.0(八)--- 索引緩衝區對象

原文鏈接:https://www.learnopengles.com/android-lesson-eight-an-introduction-to-index-buffer-objects-ibos/

本文從下面鏈接翻譯過來:

Android Lesson Eight: An Introduction to Index Buffer Objects (IBOs)

在上一課中,我們學習瞭如何在Android上使用頂點緩衝對象。 我們瞭解了客戶端內存和GPU專用內存之間的區別,以及將紋理,位置和法線數據存儲在單獨的緩衝區中,或者存儲在一個緩衝區中的區別。
在本課中,我們將學習索引緩衝區對象,並查看如何使用它們的實際示例。 以下是我們要介紹的內容:

  • 僅使用頂點緩衝區對象和將頂點緩衝區對象與索引緩衝區對象一起使用之間的區別。
  • 如何使用退化三角形將三角形帶連接在一起,並在單獨的渲染器渲染一個高度圖。

讓我們開始討論頂點緩衝區對象和索引緩衝區對象之間的根本區別:

頂點緩衝區對象和索引緩衝區對象

在上一課中,我們瞭解到頂點緩衝區對象只是一個由OpenGL直接渲染的頂點數據數組。 我們可以爲每個屬性使用單獨的緩衝區,例如位置和顏色,或者我們可以使用單個緩衝區並將所有數據交錯在一起。 目前建議將數據放在一個緩衝區中保存並確保它與4字節邊界對齊以獲得更好的性能。

當我們一遍又一遍地使用許多相同的頂點時,頂點緩衝區的缺點就出現了。 例如,高度圖可以分解爲一系列三角形帶。 因此存在相鄰條帶共享相同行頂點的情況,最終頂點緩衝區將重複許多頂點。

您可以看到頂點緩衝區對象將包含兩個三角形帶相同行的頂點。 上圖中顯示了頂點的順序,還顯示了當使用glDrawArrays(GL_TRIANGLE_STRIP,...)繪製的由這些頂點定義的三角形。 在這個例子中,我們假設繪製每行三角形時都單獨調用glDrawArrays()。 每個頂點包含如下數據:

vertexBuffer = {
    // Position - Vertex 1
    0, 0, 0,
    // Color
    1,1,1
    // Normal
    0,0,1,
    // Position - Vertex 2
    1, 0, 0,
    ... 
}

如您所見,中間的頂點行需要發送兩次,這也會發生在高度圖的每一行中。 隨着我們的高度圖變大,我們的頂點緩衝區可能最終不得不重複大量的位置,顏色和普通數據,並消耗大量額外的內存。

我們怎樣才能改善這種狀況? 我們可以使用索引緩衝區對象。 我們將定義每個頂點一次,而不是在頂點緩衝區中重複頂點。 我們將使用偏移量將這些頂點引用到此頂點緩衝區中,當我們需要重用頂點時,我們將重複偏移而不是重複整個頂點。 這是新頂點緩衝區的可視化圖示:

請注意,我們的頂點不再連接成三角形。 我們將不再直接傳遞頂點緩衝區對象; 相反,我們將使用索引緩衝區將頂點綁定在一起。 索引緩衝區將僅包含頂點緩衝區對象的偏移量。 如果我們想使用上面的緩衝區繪製一個三角形條帶,我們的索引緩衝區將包含如下數據:

indexBuffer = {
    1, 6, 2, 7, 3, 8, ...
}

這就是我們將所有東西聯繫在一起的方式。 當我們重複中間的頂點行時,我們只重複該數字,而不是重複整個數據塊。 我們將通過調用glDrawElements(GL_TRIANGLE_STRIP,...)來繪製索引緩衝區。

使用退化三角形將三角形帶鏈接到一起

上面的例子假設我們將通過調用glDrawArrays()或glDrawElements()來單獨渲染高度圖的每一行。 我們如何將每一行連接到下一行? 畢竟,第一行的結尾一直在右邊,第二行的開頭在左邊。 我們如何將這兩者聯繫起來?

我們不需要開始從右到左畫類似的東西。 我們可以使用所謂的退化三角形。 退化三角形是沒有區域的三角形,當GPU遇到這樣的三角形時,它將簡單地跳過它們。

讓我們再看一下我們的索引緩衝區:

indexBuffer = {
    1, 6, 2, 7, 3, 8, 4, 9, ...
}

當使用GL_TRIANGLE_STRIP繪圖時,OpenGL將通過獲取每組三個頂點來構建三角形,每個三角形前進一個頂點。 每個後續三角形與前一個三角形共享兩個頂點。 例如,以下是將分組爲三角形的頂點集:

    三角形 1 = 1, 6, 2
    三角形 2 = 6, 2, 7
    三角形 3 = 2, 7, 3
    三角形 4 = 7, 3, 8
    三角形 5 = 3, 8, 4
    三角形 6 = 8, 4, 9
    …

OpenGL還維護一個特定的順序,或在構建三角形時迴繞。最開始的3個頂點的順序決定了其餘頂點的順序。 如果第一個三角形是逆時針方向,則其餘三角形也是逆時針方向。 OpenGL通過交換每偶數個三角形的前兩個頂點來做到這一點(交換的頂點以粗體顯示):

  • 三角形 1 = 1, 6, 2
  • 三角形 2 = 2, 6, 7
  • 三角形 3 = 2, 7, 3
  • 三角形 4 = 3, 7, 8
  • 三角形 5 = 3, 8, 4
  • 三角形 6 = 4, 8, 9

讓我們顯示整個索引緩衝區,包括高度圖的每一行之間缺少的鏈接:

indexBuffer = {
    1, 6, 2, 7, 3, 8, 4, 9, 5, 10, ..., 6, 11, 7, 12, 8, 13, 9, 14, 10, 15
}

爲了連接三角形,我們需要介入什麼? 爲了保持繞組,我們需要偶數個新三角形。 我們可以通過重複第一行的最後一個頂點和第二行的第一個頂點來完成此操作。 這是下面的新索引緩衝區,重複的頂點以粗體顯示:

indexBuffer = {
    1, 6, 2, 7, 3, 8, 4, 9, 5, 10, 10, 6, 6, 11, 7, 12, 8, 13, 9, 14, 10, 15
}

以下是新的三角形序列:

    …
    三角形 8 = 5, 9, 10
    三角形 9 (degenerate) = 5, 10, 10
    三角形 10 (degenerate) = 10, 10, 6
    三角形 11 (degenerate) = 10, 6, 6
    三角形 12 (degenerate) = 6, 6, 11
    三角形 13 = 6, 11, 7
    …

通過重複最後一個頂點和第一個頂點,我們創建了四個將被跳過的退化三角形,並將高度圖的第一行與第二行鏈接起來。 我們可以用這種方式鏈接任意數量的行,並通過一次調用glDrawElements()來繪製整個高度圖。 讓我們直觀地看一下:

退化三角形將每一行與下一行鏈接。

退化的三角形以錯誤的方式完成

我們需要重複第一行的最後一個頂點和第二行的第一個頂點。 如果我們不這樣做會怎麼樣? 假設我們只重複了一個頂點:

indexBuffer = {
    1, 6, 2, 7, 3, 8, 4, 9, 5, 10, 10, 6, 11, 7, 12, 8, 13, 9, 14, 10, 15
}

這是三角形序列的樣子:

  • 三角形 8 = 5, 9, 10
  • 三角形 9 (degenerate) = 5, 10, 10
  • 三角形 10 (degenerate) = 10, 10, 6
  • 三角形 11 = 10, 6, 11
  • 三角形 12 = 11, 6, 7

三角形11從右側開始,一直向左切割,這不是我們想要發生的。 由於插入了3個新三角形,偶數和奇數交換,因此下一行三角形的繞組現在也不正確。

一個實際的例子

讓我們通過代碼來實現它。 我強烈建議您繼續之前先閱讀Android OpenGL ES 2.0(七)--- 頂點緩衝區對象

你還記得那些可以在屏幕上繪製拋物線和其他東西的圖形計算嗎? 在這個例子中,我們將使用高度圖繪製一個3d拋物線。 我們將遍歷所有代碼來構建和繪製高度圖。 首先,讓我們開始定義:

class HeightMap {
    static final int SIZE_PER_SIDE = 32;
    static final float MIN_POSITION = -5f;
    static final float POSITION_RANGE = 10f;
 
    final int[] vbo = new int[1];
    final int[] ibo = new int[1];
 
    int indexCount;
  • 我們將高度貼圖設置爲每邊32個單位,總共1,024個頂點和1,922個三角形,不包括退化三角形(高度貼圖中的三角形總數等於(2 *(units_per_side  -  1)*(units_per_side  -  1))。 高度圖位置的範圍爲-5到+5。
  • 我們的頂點緩衝區對象和索引緩衝區對象的OpenGL引用將分別用於vbo和ibo。
  • indexCount將保存生成的索引的總數

構建頂點數據

讓我們看一下構建頂點緩衝區對象的代碼。 請記住,我們仍然需要一個位置來容納我們所有的頂點,並且每個頂點將被定義一次,並且只能定義一次。

HeightMap() {
    try {
        final int floatsPerVertex = POSITION_DATA_SIZE_IN_ELEMENTS + NORMAL_DATA_SIZE_IN_ELEMENTS
                + COLOR_DATA_SIZE_IN_ELEMENTS;
        final int xLength = SIZE_PER_SIDE;
        final int yLength = SIZE_PER_SIDE;
 
        final float[] heightMapVertexData = new float[xLength * yLength * floatsPerVertex];
 
        int offset = 0;
 
        // First, build the data for the vertex buffer
        for (int y = 0; y < yLength; y++) {
            for (int x = 0; x < xLength; x++) {
                final float xRatio = x / (float) (xLength - 1);
 
                // Build our heightmap from the top down, so that our triangles are 
                // counter-clockwise.
                final float yRatio = 1f - (y / (float) (yLength - 1));
 
                final float xPosition = MIN_POSITION + (xRatio * POSITION_RANGE);
                final float yPosition = MIN_POSITION + (yRatio * POSITION_RANGE);
 
                ...
            }
        }

這段代碼設置循環以生成頂點。 在我們將數據發送到OpenGL之前,我們需要在Java的內存中構建它,因此我們創建一個浮點數組來保存高度圖頂點。 每個頂點將包含足夠的浮點數以包含所有位置,法線和顏色信息。

在循環內部,我們計算了一個比率在0到1之間來生成x和y。 然後,此xRatio和yRatio將用於計算下一個頂點的當前位置。

我們來看看循環中的實際計算:

// Position
heightMapVertexData[offset++] = xPosition;
heightMapVertexData[offset++] = yPosition;
heightMapVertexData[offset++] = ((xPosition * xPosition) + (yPosition * yPosition)) / 10f;

首先是位置。 由於這是一個3D拋物線,我們將Z計算爲X*X + Y*Y。 我們將結果除以10,因此得到的拋物線不是那麼陡峭。

// Cheap normal using a derivative of the function.
// The slope for X will be 2X, for Y will be 2Y.
// Divide by 10 since the position's Z is also divided by 10.
final float xSlope = (2 * xPosition) / 10f;
final float ySlope = (2 * yPosition) / 10f;
 
// Calculate the normal using the cross product of the slopes.
final float[] planeVectorX = {1f, 0f, xSlope};
final float[] planeVectorY = {0f, 1f, ySlope};
final float[] normalVector = {
        (planeVectorX[1] * planeVectorY[2]) - (planeVectorX[2] * planeVectorY[1]),
        (planeVectorX[2] * planeVectorY[0]) - (planeVectorX[0] * planeVectorY[2]),
        (planeVectorX[0] * planeVectorY[1]) - (planeVectorX[1] * planeVectorY[0])};
 
// Normalize the normal
final float length = Matrix.length(normalVector[0], normalVector[1], normalVector[2]);
 
heightMapVertexData[offset++] = normalVector[0] / length;
heightMapVertexData[offset++] = normalVector[1] / length;
heightMapVertexData[offset++] = normalVector[2] / length;

接下來是法線計算。 正如在我們的光照課程中記得的那樣,法線將用於計算光照。 表面的法線定義爲垂直於該特定點處的切平面的向量。 換句話說,法線應該是指向表面的箭頭。 這是一個拋物線的視覺示例:

我們需要的第一件事是表面的切線。 使用一點微積分,(別擔心,我也不記得它並去搜索在線計算器;))我們知道我們可以從函數的導數得到正切或斜率。 由於我們的功能是X*X + Y*Y,因此我們的斜率將是2*X和2*Y。 我們將斜率向下縮放10,因爲對於該位置,我們還將該函數的結果縮減了10。

爲了計算法線,我們爲每個斜率創建兩個向量來定義平面,我們計算交叉乘積以得到法線,即垂直向量

然後,我們通過計算其長度並將每個分量除以長度來規範化法線。 這可確保總長度等於1。

// Add some fancy colors.
heightMapVertexData[offset++] = xRatio;
heightMapVertexData[offset++] = yRatio;
heightMapVertexData[offset++] = 0.5f;
heightMapVertexData[offset++] = 1f;

最後,我們將設置一些奇特的顏色。 紅色將在X軸上從0縮放到1,綠色將在Y軸上從0縮放到1。 我們添加一些藍色來增亮效果,併爲alpha分配1。

構建索引數據

下一步是使用索引緩衝區將所有這些頂點鏈接在一起。

// Now build the index data
final int numStripsRequired = yLength - 1;
final int numDegensRequired = 2 * (numStripsRequired - 1);
final int verticesPerStrip = 2 * xLength;
 
final short[] heightMapIndexData = new short[(verticesPerStrip * numStripsRequired)
        + numDegensRequired];
 
offset = 0;
 
for (int y = 0; y &lt; yLength - 1; y++) {      if (y &gt; 0) {
        // Degenerate begin: repeat first vertex
        heightMapIndexData[offset++] = (short) (y * yLength);
    }
 
    for (int x = 0; x &lt; xLength; x++) {
        // One part of the strip
        heightMapIndexData[offset++] = (short) ((y * yLength) + x);
        heightMapIndexData[offset++] = (short) (((y + 1) * yLength) + x);
    }
 
    if (y &lt; yLength - 2) {
        // Degenerate end: repeat last vertex
        heightMapIndexData[offset++] = (short) (((y + 1) * yLength) + (xLength - 1));
    }
}
 
indexCount = heightMapIndexData.length;

在OpenGL ES2.0中,索引緩衝區需要是無符號字節(unsigned byte)或短整形(short)的數組,所以我們在這裏使用short。 我們將一次從兩行頂點讀取,並使用這些頂點構建三角形條帶。 如果我們在第二行或後續行上,我們將複製該行的第一個頂點,如果我們在任何行而不是最後一行,我們也將複製該行的最後一個頂點。 這樣我們可以使用退化三角形將行鏈接在一起,如前所述。

我們只是在這裏分配偏移量。 想象一下,我們有如前所示的高度圖:

使用索引緩衝區,我們希望得到這樣的結果:

我們的緩衝區將包含如下數據:

heightMapIndexData = {1, 6, 2, 7, 3, 8, 4, 9, 5, 10, 10, 6, 6, 11, 7, 12, 8, 13, 9, 14, 10, 15}

請記住,儘管我們的示例以1開頭,然後繼續執行2,3,等等......在實際代碼中,我們的數組應該從0開始,從0開始。

將數據上載到頂點和索引緩衝區對象中

下一步是將數據從Dalvik的堆複製到native堆上的直接緩衝區:

final FloatBuffer heightMapVertexDataBuffer = ByteBuffer
    .allocateDirect(heightMapVertexData.length * BYTES_PER_FLOAT).order(ByteOrder.nativeOrder())
    .asFloatBuffer();
heightMapVertexDataBuffer.put(heightMapVertexData).position(0);
 
final ShortBuffer heightMapIndexDataBuffer = ByteBuffer
    .allocateDirect(heightMapIndexData.length * BYTES_PER_SHORT).order(ByteOrder.nativeOrder())
    .asShortBuffer();
heightMapIndexDataBuffer.put(heightMapIndexData).position(0);

請記住,索引數據需要短整形緩衝區(short buffer)或字節緩衝區(byte buffer)。 現在我們可以創建OpenGL緩衝區,並將數據上傳到緩衝區:

GLES20.glGenBuffers(1, vbo, 0);
GLES20.glGenBuffers(1, ibo, 0);
 
if (vbo[0] &gt; 0 &amp;&amp; ibo[0] &gt; 0) {
    GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbo[0]);
    GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, heightMapVertexDataBuffer.capacity()
                            * BYTES_PER_FLOAT, heightMapVertexDataBuffer, GLES20.GL_STATIC_DRAW);
 
    GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, ibo[0]);
    GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, heightMapIndexDataBuffer.capacity()
                            * BYTES_PER_SHORT, heightMapIndexDataBuffer, GLES20.GL_STATIC_DRAW);
 
    GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
    GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0);
} else {
    errorHandler.handleError(ErrorType.BUFFER_CREATION_ERROR, &quot;glGenBuffers&quot;);
}

我們使用GL_ARRAY_BUFFER來指定我們的頂點數據,使用GL_ELEMENT_ARRAY_BUFFER來指定我們的索引數據。

繪製高度圖

繪製高度圖的大部分代碼與之前的課程類似。 我不會在這裏介紹矩陣設置代碼; 相反,我們只是看看綁定數組數據的調用並使用索引緩衝區繪製:

GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbo[0]);
 
// Bind Attributes
glEs20.glVertexAttribPointer(positionAttribute, POSITION_DATA_SIZE_IN_ELEMENTS, GLES20.GL_FLOAT,
        false, STRIDE, 0);
GLES20.glEnableVertexAttribArray(positionAttribute);
 
glEs20.glVertexAttribPointer(normalAttribute, NORMAL_DATA_SIZE_IN_ELEMENTS, GLES20.GL_FLOAT,
        false, STRIDE, POSITION_DATA_SIZE_IN_ELEMENTS * BYTES_PER_FLOAT);
GLES20.glEnableVertexAttribArray(normalAttribute);
 
glEs20.glVertexAttribPointer(colorAttribute, COLOR_DATA_SIZE_IN_ELEMENTS, GLES20.GL_FLOAT,
        false, STRIDE, (POSITION_DATA_SIZE_IN_ELEMENTS + NORMAL_DATA_SIZE_IN_ELEMENTS)
        * BYTES_PER_FLOAT);
GLES20.glEnableVertexAttribArray(colorAttribute);
 
// Draw
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, ibo[0]);
glEs20.glDrawElements(GLES20.GL_TRIANGLE_STRIP, indexCount, GLES20.GL_UNSIGNED_SHORT, 0);
 
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0);

我們需要將這種綁定用於IBO。 就像在上一課中一樣,我們將頂點緩衝區中的位置,法線和顏色數據綁定到着色器中的匹配屬性,注意傳遞適當的步幅並開始每個屬性的偏移。 步幅和起始偏移都是以字節爲單位定義的。

主要區別在於什麼時候畫畫。 我們調用glDrawElements()而不是glDrawArrays(),並傳入索引計數和數據類型。 OpenGL ES2.0只接受GL_UNSIGNED_SHORT和GL_UNSIGNED_BYTE,因此我們必須確保使用short或bytes定義索引數據。

渲染和光照三角形的兩個面

在本課程中,我們不啓用GL_CULL_FACE,以便三角形的正面和背面都可見。 我們需要對片元着色器代碼稍作更改,以便照明適用於我們三角形的兩個面:

float diffuse;
 
if (gl_FrontFacing) {
    diffuse = max(dot(v_Normal, lightVector), 0.0);
} else {
    diffuse = max(dot(-v_Normal, lightVector), 0.0);
}

我們使用特殊變量gl_FrontFacing來確定當前片段是三角形正面還是三角形背面的一部分。 如果它是正面的,那麼就不需要做任何特別的事了:代碼和以前一樣。 如果它是背面,那麼我們簡單地反轉曲面法線(因爲三角形的背面朝向相反的方向)並繼續進行如前的計算。

進一步練習

什麼時候使用索引緩衝區有缺陷呢? 請記住,當我們使用索引緩衝區時,GPU必須對頂點緩衝區對象進行額外的提取,因此我們引入了一個額外的步驟。

可以從GitHub上的項目站點下載本課程的完整源代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章