Android OpenGL ES 2.0(七)--- 顶点缓冲区对象

本文从下面链接翻译过来:

Android Lesson Seven: An Introduction to Vertex Buffer Objects (VBOs)

在这节课中,我们将介绍如何定义和如何去使用
顶点缓冲对象(VBO)。下面是我们要讲到的几点:

1.如何定义顶点缓冲区以及利用顶点缓冲区进行渲染。
2.使用单个缓冲区打包所有的数据(比如位置,颜色,
法线,纹理)与使用多个缓冲区分别保存数据的区别。
3.问题和陷阱,以及如何处理它们。

Android OpenGL ES 2.0(七)--- 顶点缓冲区对象_第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方法的参数说明:

  • mPositionHandle: 着色器程序的位置属性索引
  • POSITION_DATA_SIZE: 定义这个属性需要多少个float元素
  • GL_FLOAT: 每个元素的类型
  • false: 定点数据因该标准化吗?由于我们使用的是浮点数据,因此不适用。
  • 0: 跨度,设置0,表示位置应该按照顺序读取。
  • mCubePositions: 指向缓冲区的的指针,包含所有位置数据

使用打包缓冲区(Working with packed buffers)

使用打包缓冲区是非常相似的,替换了每个位置、法线等的缓冲区,现在一个缓冲区将包含所有这些数据。不同点看下面:

使用单缓冲区

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个字节)开始。

Dalvik和本地堆上的内存

如果在native堆上分配大量内存并将其释放,则迟早会遇到OutOfMemoryError。 背后有几个原因:

  1. 您可能认为通过让引用超出范围而自动释放了内存,但是本地内存似乎需要一些额外的GC周期才能完全清理,如果没有足够可用的内存并且尚未释放本地内存,Dalvik将抛出异常。
  2. 本地堆可能会碎片化,调用allocateDirect()将会莫名其妙失败,尽管似乎有足够的内存可用。有时它有助于进行较小的分配,释放它,然后再次尝试更大的分配。

如何能避免这些问题?除了希望Google在未来的版本中改进Dalvik的行为之外,并不多。或者通过本地代码进行分配或预先分配一大块内存来自行管理堆,并根据此分离缓冲区。

注意:这些信息最初写于2012年初,现在Android使用了一个名为ART的不同运行时,它可能在相同程度上不会遇到这些问题。

移动到顶点缓冲区对象

现在我们已经回顾了使用客户端缓冲区,让我们继续讨论顶点缓冲区对象!首先,我们需要回顾几个非常重要的问题:

1. 缓冲区必须创建在一个有效的OpenGL上下文中

这似乎是一个明显的观点,但是它仅仅提醒你必须等到onSurfaceCreated()执行,并且你必须注意OpenGL ES调用是在GL线程上完成的。

2. 顶点缓冲区对象使用不当会导致图形驱动程序崩溃

当你使用顶点缓冲对象时,需要注意传递的数据。不当的值将会导致OpenGL ES系统库或图形驱动库本地崩溃。在我的Nexus S上,一些游戏完全卡在我的手机上或导致手机重启,因为在执行这些指令时图形驱动崩溃了。并非所有的崩溃都会锁定您的设备,但至少您不会看到“此应用已停止工作”的对话框。您的Activity将在没有警告的情况下重新启动,您唯一将获得信息的方法可能是从日志中的本地调试进行跟踪。

将顶点数据上传到GPU

要上传数据到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的解释:

  • GL_ARRAY_BUFFER: 这个缓冲区包含顶点数据数组
  •  cubePositionsBuffer.capacity() * BYTES_PER_FLOAT: 这个缓冲区因该包含的字节数
  • cubePositionsBuffer: 将要拷贝到这个顶点缓冲区对象的源
  • GL_STATIC_DRAW: 这个缓冲区不会动态更新

我们对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,以及使用和不使用打包缓冲区进行渲染的示例。检查代码以查看如何处理一下某些操作:

  • 通过runOnUiThread()将事件从OpenGL线程发布回UI主线程
  • 异步生成顶点数据
  • 处理内存溢出异常
  • 我们移除了glEnable(GL_TEXTURE_2D)的调用,因为它实际在OpenGL ES2.0是一个无效枚举。这是以前的固定写法延续下来的,在OpenGLES2.0中,这些东西由着色器处理,因此不需要使用glEnable或glDisable
  • 如何使用不同的路径进行渲染,而不添加太多的if语句和条件。

进一步练习

您何时使用顶点缓冲区?什么时候从客户端内存传输数据更好?使用顶点缓冲区对象有哪些缺点?您将如何改进异步加载代码?

可以从GitHub上的项目站点下载本课程的完整源代码。

你可能感兴趣的:(移动开发,Android,Android,OpenGL,ES2.0)