本文从下面链接翻译过来:
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 < yLength - 1; y++) { if (y > 0) {
// Degenerate begin: repeat first vertex
heightMapIndexData[offset++] = (short) (y * yLength);
}
for (int x = 0; x < xLength; x++) {
// One part of the strip
heightMapIndexData[offset++] = (short) ((y * yLength) + x);
heightMapIndexData[offset++] = (short) (((y + 1) * yLength) + x);
}
if (y < 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] > 0 && ibo[0] > 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, "glGenBuffers");
}
我们使用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上的项目站点下载本课程的完整源代码。