MENU

【代码札记】使用 LWJGL 和 OpenGL 更优雅的画圆

April 7, 2021 • 瞎折腾

上一篇文章记录了我初学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中有GL11GL15的字样,意味着我们实际使用的是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读写的内部变量主要有两个:limitposition。直接将数组写入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_ARRAYGL_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.03.94.6
使用VBO无色渲染8.65.97.5
使用VBO有色渲染5.43.55.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 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code