Android Lesson Seven: An Introduction to Vertex Buffer Objects (VBOs)
在这节课中,我们将介绍如何定义和如何去使用 1.如何定义顶点缓冲区以及利用顶点缓冲区进行渲染。 |
到目前为止,我们所有的课程都是将对象数据存储在客户端内存中,只有在渲染时将其传输到GPU中。没有大量数据传输时,这很好,但随着我们的场景越来越复杂,会有更多的物体和三角形,这会给CPU和内存增加额外的成本。
我们能做些什么呢?我们可以使用顶点缓冲对象,而不是每帧从客户端内存传输顶点信息,信息将被传输一次,然后渲染器将从该图形存储器缓存中得到数据。
请阅读Android OpenGL ES 2.0(一)---入门,介绍如何从客户端的内存上传顶点数据。了解OpenGL ES如何与顶点数组一起工作对于理解本课至关重要。
一但了解了如何使用客户端内存进行渲染,切换到使用VBO实际上并不太难。其主要的不同在于添加了一个上传数据到图形内存的额外步骤,以及渲染时绑定这个缓冲区的额外调用。
本节课将使用四种不同的模式:
无论我们是否使用顶点缓冲对象,我们都需要先将我们的数据存储在客户端本地缓冲区。回顾第一课,OpenGL ES是一个本地用C语言实现的native系统库,而java是运行在Android上的一个虚拟机中。为了在虚拟机与Native系统之间搭建一座桥梁,我们需要使用一组特殊的缓冲区类来在native堆上分配内存,并使其可供OpenGL访问:
// Java array.
float[] cubePositions;
...
// Floating-point buffer
final FloatBuffer cubePositionsBuffer;
...
// Allocate a direct block of memory on the native heap,
// size in bytes is equal to cubePositions.length * BYTES_PER_FLOAT.
// BYTES_PER_FLOAT is equal to 4, since a float is 32-bits, or 4 bytes.
cubePositionsBuffer = ByteBuffer.allocateDirect(cubePositions.length * BYTES_PER_FLOAT)
// Floats can be in big-endian or little-endian order.
// We want the same as the native platform.
.order(ByteOrder.nativeOrder())
// Give us a floating-point view on this byte buffer.
.asFloatBuffer();
将Java堆上数据转换到native堆上,调用两个方法就可以:
// Copy data from the Java heap to the native heap.
cubePositionsBuffer.put(cubePositions)
// Reset the buffer position to the beginning of the buffer.
.position(0);
缓冲位置的目是什么?通常,Java没有为我们提供一种在内存中使用指针,任意指定位置的方法。然而,设置缓冲区的位置在功能上等同于更改指向内存块指针的值。通过改变缓冲区的位置,我们可以将缓冲区中任意的内存位置传递给OpenGL调用。当我们使用打包的缓冲作业时,这将派上用场。
一但数据存放到native堆上,我们就不需要持有float[]数组了,我们可以让垃圾回收器清理它。
使用客户端缓冲区进行渲染非常简单,我们仅需要启动对应属性的顶点素组,并将指针传递给我们的数据:
// Pass in the position information
GLES20.glEnableVertexAttribArray(mPositionHandle);
GLES20.glVertexAttribPointer(mPositionHandle, POSITION_DATA_SIZE,
GLES20.GL_FLOAT, false, 0, mCubePositions);
glVertexAttribPointer方法的参数说明:
使用打包缓冲区是非常相似的,替换了每个位置、法线等的缓冲区,现在一个缓冲区将包含所有这些数据。不同点看下面:
使用单缓冲区
positions = X,Y,Z, X, Y, Z, X, Y, Z, …
colors = R, G, B, A, R, G, B, A, …
textureCoordinates = S, T, S, T, S, T, …
使用打包缓冲区
buffer = X, Y, Z, R, G, B, A, S, T, …
使用打包缓冲区的好处是它将会使GPU更高效的渲染,因为渲染三角形所需的所有信息都位于内存同一块地方。缺点是,如果我们使用动态数据,更新可能会更困难,更慢。
当我们使用打包缓冲区时,我们需要以下几种方式更改渲染调用。首先,我们需要告诉OpenGL跨度(stride) ,定义一个顶点的字节数。
final int stride = (POSITION_DATA_SIZE + NORMAL_DATA_SIZE + TEXTURE_COORDINATE_DATA_SIZE)
* BYTES_PER_FLOAT;
// Pass in the position information
mCubeBuffer.position(0);
GLES20.glEnableVertexAttribArray(mPositionHandle);
GLES20.glVertexAttribPointer(mPositionHandle, POSITION_DATA_SIZE,
GLES20.GL_FLOAT, false, stride, mCubeBuffer);
// Pass in the normal information
mCubeBuffer.position(POSITION_DATA_SIZE);
GLES20.glEnableVertexAttribArray(mNormalHandle);
GLES20.glVertexAttribPointer(mNormalHandle, NORMAL_DATA_SIZE,
GLES20.GL_FLOAT, false, stride, mCubeBuffer);
...
这个跨度告诉OpenGL ES下一个顶点的同样的属性要再跨多远才能找到。例如:如果元素0是第一个顶点的开始位置,并且这里每个顶点有8个元素,然后这个跨度将是8个元素,也就是32个字节。下一个顶点的位置将找到第8个元素,下下个顶点的位置将找到第16个元素,以此类推。
请记住,传递给glVertexAttriPointer的跨度单位是字节,而不是元素,因此请记住进行转换。
注意,当我们从指定位置切换到指定法线时,我们要更改缓冲区的位置(调用position方法)。这是我们之前提到的指针算法,在使用OpengGL ES时使用Java的方式。我们仍然使用同一个缓冲区mCubeBuffer,但是我们告诉OpenGL从位置数据后的第一个元素开始读取法线信息。我们也告诉OpenGL下一个法线要跨越8个元素(也可以说是32个字节)开始。
如果在native堆上分配大量内存并将其释放,则迟早会遇到OutOfMemoryError。 背后有几个原因:
如何能避免这些问题?除了希望Google在未来的版本中改进Dalvik的行为之外,并不多。或者通过本地代码进行分配或预先分配一大块内存来自行管理堆,并根据此分离缓冲区。
注意:这些信息最初写于2012年初,现在Android使用了一个名为ART的不同运行时,它可能在相同程度上不会遇到这些问题。
现在我们已经回顾了使用客户端缓冲区,让我们继续讨论顶点缓冲区对象!首先,我们需要回顾几个非常重要的问题:
1. 缓冲区必须创建在一个有效的OpenGL上下文中
这似乎是一个明显的观点,但是它仅仅提醒你必须等到onSurfaceCreated()执行,并且你必须注意OpenGL ES调用是在GL线程上完成的。
2. 顶点缓冲区对象使用不当会导致图形驱动程序崩溃
当你使用顶点缓冲对象时,需要注意传递的数据。不当的值将会导致OpenGL ES系统库或图形驱动库本地崩溃。在我的Nexus S上,一些游戏完全卡在我的手机上或导致手机重启,因为在执行这些指令时图形驱动崩溃了。并非所有的崩溃都会锁定您的设备,但至少您不会看到“此应用已停止工作”的对话框。您的Activity将在没有警告的情况下重新启动,您唯一将获得信息的方法可能是从日志中的本地调试进行跟踪。
要上传数据到GPU,我们需要像以前一样创建客户端缓冲区的相同步骤:
...
cubePositionsBuffer = ByteBuffer.allocateDirect(cubePositions.length * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder()).asFloatBuffer();
cubePositionsBuffer.put(cubePositions).position(0);
...
一旦我们有了客户端缓冲区,我们就可以创建一个顶点缓冲区对象,并使用以下指令将数据从客户端内存上传到GPU:
// First, generate as many buffers as we need.
// This will give us the OpenGL handles for these buffers.
final int buffers[] = new int[3];
GLES20.glGenBuffers(3, buffers, 0);
// Bind to the buffer. Future commands will affect this buffer specifically.
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, buffers[0]);
// Transfer data from client memory to the buffer.
// We can release the client memory after this call.
GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, cubePositionsBuffer.capacity() * BYTES_PER_FLOAT,
cubePositionsBuffer, GLES20.GL_STATIC_DRAW);
// IMPORTANT: Unbind from the buffer when we're done with it.
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
一旦数据上传到了OpenGL ES,我们就可以释放这个客户端内存,因为我们不需要再继续保留它。这是glBufferData的解释:
我们对glVertexAttribPointer的调用看起来有点儿不同,因为最后一个参数现在是偏移量而不是指向客户端内存的指针:
// Pass in the position information
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mCubePositionsBufferIdx);
GLES20.glEnableVertexAttribArray(mPositionHandle);
mGlEs20.glVertexAttribPointer(mPositionHandle, POSITION_DATA_SIZE, GLES20.GL_FLOAT, false, 0, 0);
...
像以前一样,我们绑定到缓冲区,然后启用顶点数组。由于缓冲区早已绑定,当从缓冲区读取数据时,我们仅需要告诉OpenGL开始的偏移。因为我们使用的特定的缓冲区,我们传入偏移量0。另请注意,我们使用自定义绑定来调用glVertexAttribPointer,因为官方SDK缺少此特定函数调用。
一旦我们用缓冲区绘制完成,我们应该解除它:
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
当我们不想再保留缓冲区时,我们可以释放内存:
final int[] buffersToDelete = new int[] { mCubePositionsBufferIdx, mCubeNormalsBufferIdx,
mCubeTexCoordsBufferIdx };
GLES20.glDeleteBuffers(buffersToDelete.length, buffersToDelete, 0);
我们还可以使用单个缓冲区打包顶点缓冲区对象的所有顶点数据。打包顶点缓冲区的创建和上面相同,唯一的区别是我们从打包客户端缓冲区开始。打包缓冲区渲染也是一样的,除了我们需要传偏移量,就像在客户端内存中使用打包缓冲区一样:
final int stride = (POSITION_DATA_SIZE + NORMAL_DATA_SIZE + TEXTURE_COORDINATE_DATA_SIZE)
* BYTES_PER_FLOAT;
// Pass in the position information
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mCubeBufferIdx);
GLES20.glEnableVertexAttribArray(mPositionHandle);
mGlEs20.glVertexAttribPointer(mPositionHandle, POSITION_DATA_SIZE,
GLES20.GL_FLOAT, false, stride, 0);
// Pass in the normal information
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mCubeBufferIdx);
GLES20.glEnableVertexAttribArray(mNormalHandle);
mGlEs20.glVertexAttribPointer(mNormalHandle, NORMAL_DATA_SIZE,
GLES20.GL_FLOAT, false, stride, POSITION_DATA_SIZE * BYTES_PER_FLOAT);
...
注意:偏移量需要以字节为单位指定。与之前一样解除绑定和删除缓冲区的相同注意事项也适用。
这节课构建了立方体组成的集合,每个维度的立方体数量相同。它将在1x1x1立方体和16x16x16立方体之间构建立方体。由于每个立方体共享相同的法线和纹理数据,因此在初始化客户端缓冲区时将重复复制此数据。所有立方体都将在同一个缓冲区对象中结束。
您可以查看课程中的代码并查看使用和不使用VBO,以及使用和不使用打包缓冲区进行渲染的示例。检查代码以查看如何处理一下某些操作:
您何时使用顶点缓冲区?什么时候从客户端内存传输数据更好?使用顶点缓冲区对象有哪些缺点?您将如何改进异步加载代码?
可以从GitHub上的项目站点下载本课程的完整源代码。