上一篇文章记录了我初学 OpenGL,使用 LWJGL 在 JVM 上试图画圆的过程。虽然结果很成功,甚至还锦上添花地弄了一个五彩斑斓的圆,但随着更加深入的学习,我了解到上一篇文章中的代码应该算得上是 OpenGL 中最低效的绘图方法。因此本文将试图使用 VBO 改进代码的效率。
前言展开目录
为什么上一篇代码就那么低效呢?原因就在于 Fixed Pipeline
中提供的绘制函数与 GPU 内存的交互方式上。glVertex2f()
函数能够将我们定义的顶点坐标写入到显卡内存,这样后续的流水线作业就可以在显卡上并行执行以加速运算速度。但是这个函数有一个问题:我们每调用一次这个函数,它就将坐标数据写给显卡,这样做是很低效的。一般而言最佳的做法是集中一批数据,然后统一送给显卡,这样更能充分发挥 PCIe 通道的并行性。
OpenGL 在一开始提供了 Fixed Pipeline
,大概是因为便于上手,虽然还是比较繁琐,但至少还能提供一些易于理解的地方。倘若上来就从窗口跳到 VBO、VAO 和着色器(例如目前 OpenGL 的教程),怕不是要劝退好多人。不过由于这种固定的流水线效率低局限多,在 OpenGL core 3.0 的更高版本已经被废弃了。如果你需要使用 OpenGL3.0 的特性,那么上一篇文章的代码实际是不能运行的。但目前使用的 LWJGL 库使用的 OpenGL 版本是 3.2.3,为什么还能不报错运行呢?也许有细心的读者已经注意到了,我们在上一篇文章中的 import
中有 GL11
和 GL15
的字样,意味着我们实际使用的是 OpenGL1.1 和 1.5 时期的特性。目前为止 OpenGL 分为两个部分,在 OpenGL 3.0 时期引入,将原本的 OpenGL 一分为二:一部分是 OpenGL core,如字面意思,它只包含本版本应该支持的特性(目前来看就是程序只能通过 Shader 编写,而不能使用固定流水线),如果试图在 core 中调用已经移除的接口,OpenGL 将会产生错误;而另一部分则是 Compatibility Profile,它负责实现所有过去的接口(我们调用的固定流水线的函数实际是在这里实现的),以便保证使用以前版本构建的代码能够在更新版本上顺利运行。
就目前而言,我们距离着色器还差一些。如开头所言,本文的目的是要将代码改写成将数据按批次送给显卡,而不是你给一个坐标我就立刻送一个。
使用 VBO 展开目录
VBO 全称 Vertex Buffer Objects,翻译成中文是「顶点缓冲对象」,该对象负责管理一部分 GPU 内存,在这部分 GPU 内存中存储大量的顶点数据,便于让 CPU 一次能够发送大量数据,使得程序更有效率。
创建 VBO 展开目录
如何使用这个 VBO 呢?首先得有一个:
- // prepare our vertex buffer, VBO
- val vertexVBO = glGenBuffers()
然而这样只是让 OpenGL 在显存中开辟了内存空间,我们的 Java 程序是没办法直接使用的,因此还需要再 Java 程序中开辟一个内存空间。LWJGL 使用 Java NIO 的 Buffer 来进行数据交换:程序将数据写入 Java NIO Buffer,然后调用 glBufferData
将给定 Buffer 中的数据复制到 VBO 管理的显存中。
创建 NIO Buffer 展开目录
于是我们还需要一个 NIO Buffer:
- // float buffer used to write into the vbo
- var floatBuffer: FloatBuffer
关于这个 NIO Buffer 还有一个使用的问题:
向 Buffer 中写入数据主要有两种方法:
- put(float[] src)
- put(int index, float f)
这两种方法虽然都能够把数据写入到 Buffer 中,但产生的副作用不同。控制 Buffer 读写的内部变量主要有两个:limit
和 position
。直接将数组写入 Buffer,将会同时更新这两个参数,于是写入数据后 position
总是指向最后的写入数据,如果这个时候把 Buffer 丢给 glBufferData
给 OpenGL,那么它读不到前面写入的数据,为了解决这个问题,Buffer 有一个 flip()
函数,这个函数的作用就是将 limit
设定为当前的 position
的值,然后把 position
的值清零,这样再交给 OpenGL 读就可以正常读到刚刚写入的数据了。所以一般经常把第一种方法和 flip()
函数配合使用。
如果使用第二种方式写入数据,则二者都不会修改,于是需要调用者手动根据写入的值的个数和开始写时 Buffer 的状态,手动更新其参数,一般而言比较麻烦,但好处是不需要另外将数据写入数组,再将数组写入 Buffer。本文为了省事儿,将使用第二种方法,如果读者使用第一种方法,请务必不要忘记在 put
之后调用 flip
。
由于 segements
会动态改变,所以 Buffer 的大小不能定死,需要根据当前的值改变。于是修改渲染循环:
- // the actual render loop
- while (!glfwWindowShouldClose(windowHandler)) {
- // poll window event, key handlers will only be called during this time
- glfwPollEvents()
- // clear color buffer
- glClear(GL_COLOR_BUFFER_BIT)
-
- // generating buffer data
- floatBuffer = BufferUtils.createFloatBuffer(segments * 2).clear()
- for (i in 0 until segments) {
- val theta = 2 * PI * i / segments
- val x = radius * cos(theta)
- val y = radius * sin(theta)
- // (x, y)
- floatBuffer.put(i * 2, x.toFloat())
- floatBuffer.put(i * 2 + 1, y.toFloat())
- }
-
- // TODO send data info buffer object
- // TODO render the data
-
- //swap color buffer, means that next frame is done
- glfwSwapBuffers(windowHandler)
- }
由于每次渲染都会创建一个全新的 FloatBuffer,因此它的 position
就是 0,而 limit
是创建 Buffer 时指定的大小。这里我们使用 (x, y)
坐标,因此每个顶点占用两个元素,所以 Buffer 大小是 segments * 2
,如果使用 (x, y, z)
坐标,那么每个顶点占用三个元素,需要改成 * 3
。对应的第 $i$ 个元素的 $x$ 坐标对应索引 $2 i$,$y$ 坐标对应索引 $2i + 1$。
将 Buffer 数据写入 VBO 展开目录
至此我们需要的数据在内存中全部准备妥当。我们需要将这些数据送到显存,这样显卡才能帮我们渲染出实际的图形。好在将数据送到显卡的步骤并不复杂:
- // send data info buffer object
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
- // write data from cpu buffer into gpu buffer
- glBufferData(GL_ARRAY_BUFFER, floatBuffer, GL_DYNAMIC_DRAW)
- // unbinding buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
由于 OpenGL 是一个大的状态机,我们不能直接把 VBO 的 id 和数据扔给他就完事儿了,事情会琐碎一些:首先通过 glBindBuffer
将之前生成的 VBO 通过编号与 GL_ARRAY_BUFFER
绑定,OpenGL 内部有好多种 Buffer,每一种决定不同的渲染数据,其中 GL_ARRAY_BUFFER
负责存储顶点的属性(即坐标数据)。随后通过 glBufferData
告诉 OpenGL 我们要把哪种数据写到哪一个 Buffer 中,最后一个参数则指定这个 Buffer 有什么样的读写属性,一般而言我们都是用 GL_DYNAMIC_DRAW
,表示其中的数据可能被读写多次,并用于绘制图形。
最后操作完成后要解绑 VBO,不然其他线程中的操作可能会影响到已经写入的数据。
使用 VBO 进行渲染展开目录
顶点数据已经被送到显存中,现在只需要告诉 OpenGL 用这些数据渲染就行了。首先还是老规矩,要先绑定 VBO 并启用顶点数组:
- // render the data
- glEnableClientState(GL_VERTEX_ARRAY)
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
之后我们需要告诉 OpenGL 数据在 Buffer 中是如何组织的:
- glVertexPointer(2, GL_FLOAT, 0, NULL)
第一个参数表示每个顶点占用 2 个元素,第二个参数表示每个元素是单精度浮点数,第三个参数表示从相邻元素之间相隔 0 个元素,即大家都是挨着的。最后一个元素为空指针,因为我们已经把数据提前写入 VBO 了,所以这里的指针给空指针即可。至此 OpenGL 已经完成了顶点信息的解读,然后就可以开始调用流水线绘制图形了。值得一提的是这里我们仍然使用的 Fixed Pipeline,因为我们尚且没有实现任何着色器,着色器部分预计将在下一篇文章中实现,现在先来将图形渲染出来:
- // draw the vertexes as TRIANGLE FAN
- // vertexes data start from index 0 and there are $segments vertexes
- glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
-
- // done with buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // end render
- glDisableClientState(GL_VERTEX_ARRAY)
调用 glDrawArrays
,让 OpenGL 以 GL_TRIANGLE_FAN
的方式从第 0 个顶点开始绘制。然后记得解绑 VBO,关闭 GL_VERTEX_ARRAY
。最后再调用 glfwSwapBuffers
来呈现本次渲染结果。
于是最终渲染循环的代码变成了这个样子:
- // the actual render loop
- while (!glfwWindowShouldClose(windowHandler)) {
- // poll window event, key handlers will only be called during this time
- glfwPollEvents()
- // clear color buffer
- glClear(GL_COLOR_BUFFER_BIT)
-
- // generating buffer data
- floatBuffer = BufferUtils.createFloatBuffer(segments * 2).clear()
- for (i in 0 until segments) {
- val theta = 2 * PI * i / segments
- val x = radius * cos(theta)
- val y = radius * sin(theta)
- // (x, y)
- floatBuffer.put(i * 2, x.toFloat())
- floatBuffer.put(i * 2 + 1, y.toFloat())
- }
- floatBuffer.position(0)
- // send data info buffer object
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
- // write data from cpu buffer into gpu buffer
- glBufferData(GL_ARRAY_BUFFER, floatBuffer, GL_DYNAMIC_DRAW)
- // unbinding buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // render the data
- glEnableClientState(GL_VERTEX_ARRAY)
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
- // size = 2 -> (x,y)
- // size = 3 -> (x, y, z)
- // stride = 0 -> data are sequential
- glVertexPointer(2, GL_FLOAT, 0, NULL)
-
- // draw the vertexes as TRIANGLE FAN
- // vertexes data start from index 0 and there are $segments vertexes
- glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
-
- // done with buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // end render
- glDisableClientState(GL_VERTEX_ARRAY)
-
- //swap color buffer, means that next frame is done
- glfwSwapBuffers(windowHandler)
- }
不使用着色器给图形添加颜色展开目录
以上代码跑起来之后画的圆是白色的,由于我们没有指定任何颜色信息,因此画出来的圆就是白的。我看网上好多教程都是通过片段着色器给渲染出来的图形加颜色,我想这样未免有些云山雾绕。经过一番搜索与摸索,使用 VBO 同样可以达到上一次文章结尾的效果。只需要再另高一个存储颜色的 VBO 即可:
- val colorVBO = glGenBuffers()
- var colorBuffer: FloatBuffer
然后在渲染时计算颜色,计算方法与上文一致:
- colorBuffer = BufferUtils.createFloatBuffer(segments * 3).clear()
- for (i in 0 until segments) {
- val theta = 2 * PI * i / segments
- // ...
- colorBuffer.put(i * 3, cos(theta).toFloat())
- colorBuffer.put(i * 3 + 1, sin(theta).toFloat())
- colorBuffer.put(i * 3 + 2, i / segments.toFloat())
- }
最后记得把数据送到对应的 VBO 当中:
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
- // write data from cpu buffer into gpu buffer
- glBufferData(GL_ARRAY_BUFFER, colorBuffer, GL_DYNAMIC_DRAW)
- // unbinding buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
然后在渲染时使用它即可:
- // render the data
- glEnableClientState(GL_VERTEX_ARRAY)
- glEnableClientState(GL_COLOR_ARRAY)
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
- // size = 2 -> (x,y)
- // size = 3 -> (x, y, z)
- // stride = 0 -> data are sequential
- glVertexPointer(2, GL_FLOAT, 0, NULL)
-
- glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
- glColorPointer(3, GL_FLOAT, 0, NULL)
-
- // draw the vertexes as TRIANGLE FAN
- // vertexes data start from index 0 and there are $segments vertexes
- glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
-
- // done with buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // end render
- glDisableClientState(GL_VERTEX_ARRAY)
- glDisableClientState(GL_COLOR_ARRAY)
这里我们同时启用了 GL_VERTEX_ARRAY
和 GL_COLOR_ARRAY
,通过先绑定(glBindBuffer
)并解析(glVertexPotiner
)完成了对顶点数据的解读,然后再绑定存储颜色的 VBO:glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
,最后使用 glColorPointer
解析颜色数据,由于每个顶点的颜色有 3 个分量(对应 RGB),所以第一个参数是 3,其余都不变。
设置好全部的状态之后,调用 glDrawArrays
即可绘制出带颜色的圆了。由于外观上和上文一致,所以这里就不重复放图了。
衡量不同实现下的 FPS 展开目录
其实纵观本文,代码做的事情没有变,都是以同样的方法绘制同样的圆,但不一样的地方在于我们传送数据从一点一传变成了一帧一传。使用 glVertex3f
,每次计算出一个点的数据,CPU 就会停下来将这个数据发送给显卡;而本文的代码则改进为计算出一批数据后,CPU 只停下一次就可以把数据送给显卡了。具体地说,这种性能提升到底有多少呢?
下面我们简单实现一个衡量 FPS 的代码。这个代码很简陋,只是通过两帧之间的时间差计算出瞬时 FPS,故以下测试数据也是凭我肉眼在控制台如洪水般的输出中瞪出来的,不具有科学性,但具有代表性。
我们先来看代码:
- var time = glfwGetTime()
- // the actual render loop
- while (!glfwWindowShouldClose(windowHandler)) {
- val currentTime = glfwGetTime()
- val fps = 1.0 / (currentTime - time)
- // to measurer the fps
- logger.info("FPS: $fps")
- time = currentTime
- // ...
- }
应该还挺简单易懂的,其中 glfwGetTime
返回一个时间,这个时间是以秒衡量的 Double 类型数据,不一定对应现实的时间,但保证在没有人为修改时总是对应现实的时间流逝。于是我们通过计算两次渲染时的时间差,便可以知道当下的 FPS。
测的结果如下:
条件 | 最大 FPS | 最小 FPS | 平均 FPS |
---|---|---|---|
使用 glVertex 无色渲染 | 5.0 | 3.9 | 4.6 |
使用 VBO 无色渲染 | 8.6 | 5.9 | 7.5 |
使用 VBO 有色渲染 | 5.4 | 3.5 | 5.4 |
测试时使用的 segments
值为 3000000
(三百万分段)。
本文完整代码展开目录
- package info.skyblond.antSimu
-
- import info.skyblond.antSimu.utils.opengl.glfmCreateSlf4jLogger
- import org.lwjgl.BufferUtils
- import org.lwjgl.Version
- import org.lwjgl.glfw.Callbacks.glfwFreeCallbacks
- import org.lwjgl.glfw.GLFW.*
- import org.lwjgl.opengl.GL
- import org.lwjgl.opengl.GL21.*
- import org.lwjgl.system.MemoryUtil.NULL
- import org.slf4j.Logger
- import org.slf4j.LoggerFactory
- import java.nio.FloatBuffer
- import kotlin.math.*
-
- private val logger: Logger = LoggerFactory.getLogger("Application")
- var windowHandler: Long = 0
-
- // radius of the circle
- var radius = 0.75
-
- // how fine the circle should be
- var segments: Int = 30e5.toInt()
-
- /**
- * init external openGL, create a window and return the handler
- * */
- fun init() {
- // GLFW stands for Graphic Library FrameWork
- // First set a error callback, the default implementation
- glfmCreateSlf4jLogger(logger).set()
- // do init things
- require(glfwInit()) { "Unable to initialize GLFW" }
-
- // set window hint to default
- // window hint is needed for window creation
- glfwDefaultWindowHints()
- // hide window after creation
- glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE)
- // disable resize on window
- glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE)
-
- // create window
- windowHandler = glfwCreateWindow(
- 1024, 1024,
- "Hello world!",
- NULL, NULL // windowed mode and don't share resource with others
- )
- // check windowHandler to ensure window is created
- require(windowHandler != NULL) { "Failed to create the GLFW window" }
-
- // set a key handler for key events
- // scan code is ignored since it only used for unknown keys
- // mods is used to identify shift, ctrl, alt .etc
- glfwSetKeyCallback(windowHandler) { window, key, _, action, _ ->
- // logger.info("Logged key event: key: $key, action: $action")
- // close window if esc if pressed and released
- if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
- glfwSetWindowShouldClose(window, true)
- else if (key == GLFW_KEY_W && action != GLFW_RELEASE)
- radius = min(1.0, radius + 0.01)
- else if (key == GLFW_KEY_S && action != GLFW_RELEASE)
- radius = max(0.01, radius - 0.01)
- else if (key == GLFW_KEY_A && action != GLFW_RELEASE)
- segments = max(3, segments - 1)
- else if (key == GLFW_KEY_D && action != GLFW_RELEASE)
- // if case causing performance issue, limit segments up to 10e5
- segments = min(Int.MAX_VALUE - 1, segments + 1)
- // this should close signal will handled in rendering loop
- }
-
- // Get the resolution of the main monitor
- val videoMode = glfwGetVideoMode(glfwGetPrimaryMonitor())!!
- val pWidth = BufferUtils.createIntBuffer(1)
- val pHeight = BufferUtils.createIntBuffer(1)
-
- // Get the window size passed to glfwCreateWindow
- glfwGetWindowSize(windowHandler, pWidth, pHeight)
-
- // Center the window
- glfwSetWindowPos(
- windowHandler,
- (videoMode.width() - pWidth[0]) / 2,
- (videoMode.height() - pHeight[0]) / 2
- )
-
- // Make window context as current context
- glfwMakeContextCurrent(windowHandler)
- // Enable V-sync
- glfwSwapInterval(1)
- // let window visible
- glfwShowWindow(windowHandler)
- }
-
- fun cleanUp() {
- logger.info("Close signal received, termination")
-
- // clean up and terminate window
- glfwFreeCallbacks(windowHandler)
- glfwDestroyWindow(windowHandler)
-
- // Terminate GLFW and free the error callback
- glfwTerminate()
- // set null for error callback
- // and free previous one
- glfwSetErrorCallback(null)?.free()
- }
-
- fun main() {
- println("App start with OpenGL version ${Version.getVersion()}")
- init()
- // ------------------------------------------------------------
- // loop for updating window
- // ------------------------------------------------------------
-
- // This line is critical for LWJGL's interoperation with GLFW's
- // OpenGL context, or any context that is managed externally.
- // LWJGL detects the context that is current in the current thread,
- // creates the GLCapabilities instance and makes the OpenGL
- // bindings available for use.
- // i.e. to let lwjgl can use our windows handler, we had to call this
- // to make sure lwjgl create proper capabilities for current context
- GL.createCapabilities()
- // specifies the red, green, blue, and alpha values used by glClear to clear the color buffers.
- // in this case we set it to a beautiful color
- glClearColor(0.2f, 0.3f, 0.3f, 1.0f)
- // prepare our vertex buffer, VBO
- val vertexVBO = glGenBuffers()
- val colorVBO = glGenBuffers()
- // float buffer used to write into the vbo
- var floatBuffer: FloatBuffer
- var colorBuffer: FloatBuffer
-
- logger.info("Start rendering loop")
-
- var time = glfwGetTime()
-
- // the actual render loop
- while (!glfwWindowShouldClose(windowHandler)) {
- val currentTime = glfwGetTime()
- val fps = 1.0 / (currentTime - time)
- // to measurer the fps
- logger.info("FPS: $fps")
- time = currentTime
- // poll window event, key handlers will only be called during this time
- glfwPollEvents()
- // clear color buffer
- glClear(GL_COLOR_BUFFER_BIT)
-
- // generating buffer data
- floatBuffer = BufferUtils.createFloatBuffer(segments * 2).clear()
- colorBuffer = BufferUtils.createFloatBuffer(segments * 3).clear()
- for (i in 0 until segments) {
- val theta = 2 * PI * i / segments
- val x = radius * cos(theta)
- val y = radius * sin(theta)
- // (x, y)
- floatBuffer.put(i * 2, x.toFloat())
- floatBuffer.put(i * 2 + 1, y.toFloat())
-
- colorBuffer.put(i * 3, cos(theta).toFloat())
- colorBuffer.put(i * 3 + 1, sin(theta).toFloat())
- colorBuffer.put(i * 3 + 2, i / segments.toFloat())
- }
- floatBuffer.position(0)
- colorBuffer.position(0)
- // send data info buffer object
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
- // write data from cpu buffer into gpu buffer
- glBufferData(GL_ARRAY_BUFFER, floatBuffer, GL_DYNAMIC_DRAW)
- // unbinding buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
- // write data from cpu buffer into gpu buffer
- glBufferData(GL_ARRAY_BUFFER, colorBuffer, GL_DYNAMIC_DRAW)
- // unbinding buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // render the data
- glEnableClientState(GL_VERTEX_ARRAY)
- glEnableClientState(GL_COLOR_ARRAY)
- // select buffer
- glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
- // size = 2 -> (x,y)
- // size = 3 -> (x, y, z)
- // stride = 0 -> data are sequential
- glVertexPointer(2, GL_FLOAT, 0, NULL)
-
- glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
- glColorPointer(3, GL_FLOAT, 0, NULL)
-
- // draw the vertexes as TRIANGLE FAN
- // vertexes data start from index 0 and there are $segments vertexes
- glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
-
- // done with buffer
- glBindBuffer(GL_ARRAY_BUFFER, 0)
-
- // end render
- glDisableClientState(GL_VERTEX_ARRAY)
- glDisableClientState(GL_COLOR_ARRAY)
-
- //swap color buffer, means that next frame is done
- glfwSwapBuffers(windowHandler)
- }
- cleanUp()
- }
- 全文完 -
另注:由于撰写本文的时候突发感冒,甚至有些低烧,脑袋晕晕乎乎的,以至于文中一些文字不够流畅,思维有不通或怪异之处,还请各位读者谅解。欢迎在评论区提出这些问题,日后我会修改。

【代码札记】使用 LWJGL 和 OpenGL 更优雅的画圆 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。