上一篇文章记录了我初学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 处获得。