MENU

【代码札记】使用LWJGL和OpenGL画个圆

April 4, 2021 • 瞎折腾

前些日子在YouTube上看到一个模仿蚁群行为的视频,感觉十分炫酷。视频中提到进行大尺寸模拟时,通过使用着色器(Shader)在显卡上并行计算,能够有效提高计算速度。所以我也想复刻一个,当然还是用Java/Kotlin,其他语言半斤八两的不是很熟。于是为了达成这个目的,开始了OpenGL的学习,本文作为学习OpenGL的Hello World,尽我所能的利用GLFW在窗口中画一个(椭)圆。

前言

一般而言我是不喜欢和GUI打交道的,因为处理各种界面的东西实在是太繁琐了。截至目前,能够直接调用GPU的框架不多,无外乎就是OpenGL、DirectX、Vulkan,而这三者对比之下,OpenGL或许更对我口味一些。由于我在C/C++方面学艺不精,虽然C语言期末上机拿了个满分,但那只是基础的编程题,真要是用到一些底层的东西,还是蛮困难的。尤其考虑在Windows上开发C(不使用WSL的话)还得搞Visual Studio的编译器,并且在微软的魔改下C代码通常不具有可移植性。虽然这个项目只是做着玩玩,但我并不希望它和某一个具体的平台实现耦合在一起,那样很不优雅,而Vulkan实在是太底层了,最终我选择OpenGL,它在JVM平台上的绑定有两个,一个是JOGL,另一个是LWJGL,我更倾向后者一些,因为后者的官网清晰明了。

在配置方面,我选择的Minimal OpenGL,实际上的话只引用OpenGL就可以了,因为不是做游戏,所以OpenAL什么的完全可以不必理会。

画个圆

由于OpenGL基于C/C++实现,所以也继承了C系列语言的惯例,往往做一个事情有很多种做法,在一开始的尝试过程中我达到了一种每次运行必把JVM搞崩的境界,后来又查了查教程,发现只是画个圆竟然还有好多种做法。这里参考LWJGL的官方教程,使用了一种据说已经被废弃的技术1,名叫fixed pipeline,在一个窗口上画一个圆/椭圆——取决于窗口的形状。

窗口

在画圆之前,至少得先有个窗口,类似于Java AWT中的Canvas,要想画点什么,至少得现有一个画布,在OpenGL中这个画布就是窗口。在OpenGL中维护一个窗口大概有如下三个步骤:初始化——循环——释放资源,初始化阶段将初始化OpenGL库,并在该阶段对窗体做出一些定义(诸如能否改变大小,是否可见等),成功创建窗体后进入一个循环,在这个循环内程序将接收用户对窗a体的输入,并对此做出响应,同时渲染出下一帧画面;而一旦用户提出想要退出程序,程序将会打破循环,进入到收尾阶段,这个阶段程序需要结束窗口并释放窗口占用的资源,如果必要的话还要在退出之前告诉OpenGL将占用的资源归还给操作系统。其中有关资源管理的部分最为繁琐,尤其是JVM中OpenGL是作为外部代码,通过JNI调用的,有时候在Java中看明明是几行之内的代码,可是对于C语言来说这也许就是两个线程之间的代码,如果不做好资源规划,很有可能会因为访问了权限外的内存地址,进而导致JVM崩溃。

参考LWJGL的官方教程,创建一个窗口的代码如下:

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.GL11.*
import org.lwjgl.system.MemoryUtil.NULL
import org.slf4j.LoggerFactory
import kotlin.math.*


fun main() {
    val logger = LoggerFactory.getLogger("Application")

    println("App start with OpenGL version ${Version.getVersion()}")

    // ------------------------------------------------------------
    // init external openGL
    // ------------------------------------------------------------

    // 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
    val windowHandler: Long = 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)
        // 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)

    // ------------------------------------------------------------
    // 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)

    logger.info("Start rendering loop")

    // 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)

        // start drawing frame
        // TODO RENDER

        //swap color buffer, means that next frame is done
        glfwSwapBuffers(windowHandler)
    }

    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()
}

下面我们逐行解释代码:

初始化

首先我们定义了一个错误处理的回调函数,这个函数的作用是把OpenGL产生的错误重定向到Logger中。这个是我自己实现的,如果使用System.err或者其他自定义流的话,可以使用自带的GLFWErrorCallback.createPrint(System.err).set()来代替,本质上我的实现与自带的无异,只是把错误信息打印到Logger中罢了:

package info.skyblond.antSimu.utils.opengl

import org.lwjgl.glfw.GLFW
import org.lwjgl.glfw.GLFWErrorCallback
import org.lwjgl.system.APIUtil
import org.slf4j.Logger

/**
 * Create a [GLFWErrorCallback] instance which print error
 * into given logger. Same as [GLFWErrorCallback.createPrint] but
 * rather than writing into output stream, it print through logger.
 * */
fun glfmCreateSlf4jLogger(logger: Logger): GLFWErrorCallback {
    return object : GLFWErrorCallback() {
        // Map GLFM error code to Description
        // according to https://www.glfw.org/docs/latest/group__errors.html
        // GLFM error code range from 0x10001 to 0x1000A
        // but lwjgl demo use range 0x10001 to 0x1ffff
        private val ERROR_CODES = APIUtil.apiClassTokens(
            { _, value: Int -> value in 0x10001..0x1ffff }, null,
            GLFW::class.java
        )

        // print message to logger when error occur
        override fun invoke(error: Int, description: Long) {
            val msg = getDescription(description)
            val stringBuilder = StringBuilder()
            // parse error code
            stringBuilder.append(String.format("LWJGL error %d: %s\n", error, ERROR_CODES[error]))
            // print msg
            stringBuilder.append("\tDescription : $msg\n")
            // print stack
            stringBuilder.append("\tStacktrace  :\n")
            val stack = Thread.currentThread().stackTrace
            for (i in 4 until stack.size) {
                stringBuilder.append("\t\t")
                stringBuilder.append(stack[i].toString() + "\n")
            }
            logger.error(stringBuilder.toString())
        }
    }
}

在确保错误回调函数设置妥当之后,便可以开始OpenGL相关库的初始化工作了,如果期间出错,也会通过刚刚设置的回调函数在日志中打印出错误信息:

require(glfwInit()) { "Unable to initialize GLFW" }

如果成功初始化,那么接下来就可以使用OpenGL提供的诸多功能了,这里使用GLFW创建窗体,顺带一提,GLFW的全称是Graphics Library FrameWork,这缩写真是深得C的精髓。在创建窗体之前,我们先要规定窗体的一些具体属性,比如创建的窗体一开始是否可见,能否修改大小之类的:

    // 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)

以上代码,首先将各种设置回复成默认设置,然后在创建窗口的时候先隐藏,因为窗体被创建的时候,位置由操作系统决定,我们想要让创建的窗体处于屏幕正中,因此需要先隐藏,然后手动调整位置之后在显示出来。最后为了保证我们画的圆不会变形,要求窗体不能够改变大小(作为教程,鼓励各位读者将上面的GLFW_FALSE改为GLFW_TRUE,看看改变窗口大小会对绘制的图片产生什么样的影响)。

设定好上面的各种Hint之后,我们就可以真正的创建窗口了:

    // create window
    val windowHandler: Long = 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" }

由于创建窗口可能失败,所以调用创建窗口的函数之后要检查窗口句柄不为空(Java里的null对应这个框架里的0,这里NULL是LWJGL提供的一个常量,值就是0),创建成功后即可对窗口进行操作了。值得一提的是前两个参数是窗口的宽度和高度,第三个参数是窗口的标题,第四个参数是使用哪一个屏幕,如果指定屏幕的话则会全屏占用这个屏幕,使用窗口模式则传入NULL,最后一个参数表示资源共享,这是为了方便多个窗口共享一个资源,节省内存。这里我们不与其他窗口共享资源,传入NULL即可。

创建窗口之后我们还需要对窗口进行一些设置,除了之前说的调整窗口位置,还要设置一系列的事件回调,对应Java UI中的监听器:

    // 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)
        // this should close signal will handled in rendering loop
    }

这个函数将会为给定的窗口设定按键回调,当键盘有按键按下时将会调用这个函数,这个函数有五个参数,分别是事件发生的窗口句柄(这里不推荐用已有的windowHandler做闭包使用,原因是Java的代码可能会与OpenGL的代码不在一个线程,当然这里都是主线程,但未来可能会在独立的线程中,这样乱访问内存会出问题)、按下的key编号、按下的键的扫描码(scan code,每个键在键盘上有唯一的scan code)、动作和模式;动作表示这个键的动作:按下、重复、释放;模式表征了shift、ctrl之类的状态。

这里我们设计当用户按下ESC的时候,立刻发出WindowsShouldClose信号,表示窗口应该关闭了。这个信号将会在后面的渲染循环中处理。

接下来我们调整窗口的位置:

    // 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)

OpenGL的工作原理相当于一个巨大的状态机,因此要对某一个窗口进行操作(即后续需要在这个窗口中渲染),首先得把那个窗口的context(上下文)设置为当前的上下文,然后为了避免刷新太快导致画面撕裂,我们指定每次交换缓冲区之前必须等待屏幕刷新一次,这个东西在有几种经常叫作「垂直同步」,因为要等到屏幕在垂直方向扫描到同步区(参考以前的行扫描电视信号,或者VGA标准)的时候才会更换缓冲区,因此得名垂直同步。

一切妥当之后,告诉OpenGL显示窗口,初始化阶段至此完成。

循环渲染窗口

截至目前我们只是构造了一个空空如也的窗口。下面我们至少要把渲染的框架搭建出来,然后再聊画圆或者其他什么更高级的事情。

首先是一个LWJGL很关键的调用:

    // 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()

这一行代码有好几行注释,足见其重要性。简单的翻译一下就是,为了让LWJGL框架知晓并能够管理OpenGL创建的窗口上下文(避免内存泄漏或访问异常),需要在当前线程中调用这个函数以让LWJGL感知到当前正在使用的上下文,这样就可以自动创建GLCapabilities实例供后续OpenGL函数绑定使用。

之所以不把它放到初始化中,是因为这个调用需要在渲染线程伊始调用的,如果放在初始化中调用,很可能会造成主线程调用后没有对窗口更新,而需要更新窗口的渲染线程则因为没有调用该函数而出错。

之后还要为窗口设置一个底色,默认底色是纯黑色,这里我们换一个漂亮的颜色:

    // 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)

这个颜色的格式是(Red, Green, Blue, Alpha)。准备妥当之后就可以开始执行更新窗体的循环了:

    logger.info("Start rendering loop")

    // 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)

        // start drawing frame
        // TODO RENDER

        //swap color buffer, means that next frame is done
        glfwSwapBuffers(windowHandler)
    }

这个循环的条件是!glfwWindowShouldClose(windowHandler),即只要没有收到退出信号,就一直执行更新。每次更新窗口前先收集窗口的输入事件,包括键盘事件、鼠标事件、窗口大小改变的事件等,之前设置的相应的回调函数也会在这行代码执行时被调用。

随后我们要清空当前的缓冲区。一般而言熟悉GUI的读者可能知道双缓冲这回事儿。双缓冲即一个窗口有两个缓冲区,一个缓冲区正在被显示在屏幕上,而另一个缓冲区正在被写入新的图像数据,完成后交换缓冲区,这样新写入的图像数据就被显示到屏幕上,而更新的数据则写入到刚刚换下来的缓冲区中。这样的作法是为了将显示和渲染分离开,一方面提高了程序的性能(不必等待屏幕显示完毕才能写入新的数据),另一方面则是避免画面撕裂(屏幕尚未显示,新的数据就已经覆盖了要显示的数据)。

清空缓冲区后就可以开始我们的渲染工作了。由于目前只是准备一个窗口的框架,因此这里有一个TODO,将在后文补上。

渲染完成后就是如上所说的,要交换缓冲区。至此本次渲染循环结束,如果窗口没有收到退出信号,那么程序将继续执行下一次渲染循环。

资源释放

虽然JVM在退出时会将全部资源归还操作系统,但是为了避免外部C代码导致内存泄漏,还是有必要手动进行资源回收的:

    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()

首先是回收当前的窗口,先释放所有的回调函数指针,这样JVM在下一次垃圾回收的时候就可以释放对应的函数对象;然后摧毁窗口,这是保证OpenGL那边不会一直占用窗口的上下文。窗口关闭之后还要继续关闭OpenGL,最后释放我们一开始创建的出错回调程序。

至此OpenGL的调用全部结束,程序可以安全的退出或者执行其他任务,而不必担心外部C代码导致内存泄漏。

截至目前,如果运行上述代码的话,将会出现一个空白(有底色)的窗口,按一下ESC能够退出窗口。

画圆

现在有了窗体的架子,我们就可以开始画圆了。

坐标

与Java AWT或其他UI库不同,我们没法直接调用一个函数,告诉它圆心和半径,然后就万事大吉。在OpenGL的世界中,我们需要至少计算出圆上的坐标,然后交给OpenGL连成一条闭曲线(使用GL_LINE_LOOP),或者让它切分成三角形变成一块封闭的面积(使用GL_TRIANGLE_FAN)。无论使用哪种方法,都需要我们计算出圆上的坐标。

谈到坐标,OpenGL的坐标系统和Java里的坐标还不太一样。如果用过Java的UI库的话,绘制元素都是根据像素坐标来的,而OpenGL则使用标准化设备坐标(Normalized Device Coordinates),它的范围是[-1,1],其中-1表示窗口的最左侧(用在x上)或最下侧(用在y上),而1则表示窗口的最右侧(用在x上)或最上侧(用在y上),(0,0)表示窗口的正中心,这与传统的根据像素定位的坐标系统不一样,在像素定位的坐标系统中(0,0)往往表示窗口的左上角。

为了计算圆上的坐标,我们可以利用参数方程:

假设我们将圆切分成$n$段,则第$i$段对应的起始角度为:

$$ \theta_i = \frac{2 \pi i}{n} $$

由此我们可以通过三角函数计算出该角度对应的圆上的坐标$(x_i, y_i)$:

$$ \left\{ \begin{array}{ll} x_i = r \cdot \cos (\theta_i)\\ y_i = r \cdot \sin (\theta_i)\\ \end{array} \right. $$

当$n$足够大时,由于像素是离散的,因此通过这种方法画出来的多边形也就足够像圆了。

代码

这部分代码需要在负责更新的窗口中实现:

    // radius of the circle
    var radius = 0.75
    // how fine the circle should be
    var segments = 2000

    // 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)

        // start drawing frame
        glColor3f(1.0f, 1.0f, 1.0f)
        // set vertexes on the line to form a circle
        glBegin(GL_TRIANGLE_FAN)
        for (i in 0 until segments) {
            val theta = 2 * PI * i / segments
            val x = radius * cos(theta)
            val y = radius * sin(theta)
            glVertex2f(x.toFloat(), y.toFloat())
        }
        // end drawing
        glEnd()

        //swap color buffer, means that next frame is done
        glfwSwapBuffers(windowHandler)
    }

在清空颜色缓冲区之后(OpenGL中的缓冲区实际有好几个,每一对儿负责不同的工作,其中颜色缓冲区主要是负责存储颜色数据的),就可以开始绘制圆了。这里需要说明的是,OpenGL的工作原理除了是一个大的状态机,他在渲染的时候还是个流水线:程序员首先需要指定要画的基础图形和顶点(Vertex)信息,随后在顶点着色器中对顶点的坐标进行变换坐标,然后这些变换后的坐标信息和代码中指定的信息(这里我们使用GL_TRIANGLE_FAN),将顶点组装成图元,对于显卡来说,它的图元就是三角形,也正是这一步,OpenGL将我们给的圆上的诸多坐标拼成许多个三角形,然后再通过片段着色器等一系列着色器的处理,最终形成一个十分近似圆的多边形,而后这个多边形在经过不同的着色器处理,最终呈现在窗口中。

在目前这个阶段,我们不需要实现任何着色器,上述着色器的默认行为已经在Fixed pipeline中预先定义好了,我们通过glBegin(GL_TRIANGLE_FAN)告诉OpenGL:我们接下来提供的点信息是用于构造TRIANGLE_FAN的。在后面的循环中我们计算出每一个点的坐标,并通过glVertex2f函数提交给OpenGL。当我们定义完定点之后就可以结束这个定义过程了:调用glEnd

但是画圆总得有个颜色吧,如果用片段着色器给圆上色,未免有些太复杂了,好在Fixed Pipeline中也提供了默认的片段着色器:glColor3f即可指定我们当前想要使用的颜色。如果在一次绘制过程中提供了多次glColor3f,那么默认的着色器将会为我们加一个渐变效果。

上面的代码只是把圆涂成了白色,这样未免有些无聊,截图放在文章里也不够炫酷,不如我们把它改成彩色的:

        for (i in 0 until segments) {
            val theta = 2 * PI * i / segments
            val x = radius * cos(theta)
            val y = radius * sin(theta)
            glColor3f(cos(theta).toFloat(), sin(theta).toFloat(), i / segments.toFloat())
            glVertex2f(x.toFloat(), y.toFloat())
        }

绘制过程中横坐标决定颜色的红色分量,纵坐标决定颜色的绿色分量,而段数则决定了蓝色分量:

屏幕截图 2021-04-04 222110.png

有点炫酷。

至此我们就完成了画圆工作。但只是画圆的话,未免有些太过单调了?

交互式画圆

我决定在这个圆的基础上追加一些交互性,诸如调整圆的分段数量,半径等,利用键盘来改变这些参数:

    // radius of the circle
    var radius = 0.75
    // how fine the circle should be
    var segments = 20

    // 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)
            // in case causing performance issue, limit segments up to 10e5
            segments = min(10e5.toInt(), segments + 1)
    }

这里我定义WS控制半径的大小,AD控制分段的数量,需要注意的是必须要对半径和分段数做出检查。半径至少不能为负数,但是如果等于0的话就没有意义了,最大是1,因为OpenGL的坐标系是归一化的;分段数取决于绘画模式,如果使用GL_LINE_LOOP,则最少两个点才能出一条线;而GL_TRIANGLE_FAN则至少需要3个点才能产生出一个三角形;原则上分段可以无限大,但是取决于电脑的具体性能,如果分段太大,一方面Float型承载不了那种精度(显卡执行的全都是Float型运算,要执行Double型运算多半得上贵的离谱的专业卡),另一方面计算起来耗时很大,没有必要。

这样一来就可以通过键盘控制画圆的参数了,还挺有意思的。

-全文完-


  1. 来源:https://gamedev.stackexchange.com/a/133817 这个Stackoverflow回答中提到以glBegin()glEnd()为代表的渲染技术已经被废弃,虽然还可以使用,但是并不推荐过多使用。由于我没能深入了解他们被废弃的原因,但即便如此LWJGL的官方教程中还是用了这些代码,也许就是用作教程吧。

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

Archives QR Code
QR Code for this page
Tipping QR Code