MENU

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

April 13, 2021 • 瞎折腾

上一篇文章通过使用VBO来改善程序的运行效率,但程序的计算还是在CPU侧执行。本文将使用着色器,将运算放在GPU上,并获得一定的性能提升。

前言

着色器是运行在GPU上的一段程序。按照OpenGL对渲染流水线的定义,本文我们将自定义两个着色器:顶点着色器用于计算圆上的每一个点的坐标,片段着色器用于计算所有待呈现片段的颜色。通过操作这两个着色器,我们能够实现利用显卡计算每一个点的坐标,并给最终画出来的圆上色。在这个过程中CPU不再进行一大堆三角函数的运算,而更注重程序运行流程的控制。

预备着色器

在使用着色器之前,先创建一个工具类:

package info.skyblond.antSimu.opengl

import org.lwjgl.opengl.GL20.*
import org.lwjgl.system.MemoryUtil
import java.nio.FloatBuffer
import java.nio.charset.StandardCharsets

class Shader(
    private val filename: String
) {
    private val program: Int
    private val vertexesShader: Int
    private val fragmentShader: Int

    init {
        program = glCreateProgram()

        vertexesShader = glCreateShader(GL_VERTEX_SHADER)
        glShaderSource(vertexesShader, readShaderFileContent("/shaders/$filename.vert"))
        glCompileShader(vertexesShader)
        require(glGetShaderi(vertexesShader, GL_COMPILE_STATUS) == 1) {
            "Unable to compile vertex shader: '/shaders/$filename.vert':" +
                    "\n${glGetShaderInfoLog(vertexesShader)}"
        }

        fragmentShader = glCreateShader(GL_FRAGMENT_SHADER)
        glShaderSource(fragmentShader, readShaderFileContent("/shaders/$filename.frag"))
        glCompileShader(fragmentShader)
        require(glGetShaderi(fragmentShader, GL_COMPILE_STATUS) == 1) {
            "Unable to compile fragment shader: '/shaders/$filename.frag'" +
                    "\n${glGetShaderInfoLog(fragmentShader)}"
        }

        glAttachShader(program, vertexesShader)
        glAttachShader(program, fragmentShader)

        glBindAttribLocation(program, 0, "index")

        glLinkProgram(program)
        require(glGetProgrami(program, GL_LINK_STATUS) == 1) {
            "Failed to link program:" +
                    "\n${glGetProgramInfoLog(program)}"
        }
        glValidateProgram(program)
        require(glGetProgrami(program, GL_VALIDATE_STATUS) == 1) {
            "Failed to validate program:" +
                    "\n${glGetProgramInfoLog(program)}"
        }
    }

    fun setUniform(name: String, value: Int) {
        val location = glGetUniformLocation(program, name)
        require(location != -1) {"Invalidate location returned"}
        glUniform1i(location, value)
    }


    fun setUniform(name: String, value: Double) {
        val location = glGetUniformLocation(program, name)
        require(location != -1) {"Invalidate location returned"}
        glUniform1f(location, value.toFloat())
    }

    fun bind() {
        glUseProgram(program)
    }

    fun unbind(){
        glUseProgram(0)
    }

    private fun readShaderFileContent(filename: String): String {
        return this.javaClass.getResourceAsStream(filename)!!.bufferedReader(StandardCharsets.UTF_8).lineSequence()
            .joinToString("\n")
    }
}

这个工具类在实例化的过程中读取我们给定的文件名,读入内容并试图将其编译为着色器,然后试图将两个着色器链接成程序,并最终验证着色器是否存在错误。除此之外该类还提供诸如设置全局变量(Uniform类型,在一次渲染中全局可用的数据)、绑定/解绑编译程序的功能。

编译着色器

将源码变为着色器的流程如下:

首先创建一个程序glCreateProgram(),然后创建一个顶点着色器glCreateShader(GL_VERTEX_SHADER)。将源程序读入OpenGL:glShaderSource(vertexesShader, readShaderFileContent("/shaders/$filename.vert"))。这里需要说明,关于不同着色器的后缀名并没有统一规定,全凭个人喜好。因此我选择vert作为顶点着色器的后缀名,frag作为片段着色器的后缀名。源程序读入后即可开始编译glCompileShader(vertexesShader),编译后需要检查编译是否成功,如果不成功的话需要查看出错的原因:

require(glGetShaderi(vertexesShader, GL_COMPILE_STATUS) == 1) {
    "Unable to compile vertex shader: '/shaders/$filename.vert':" +
    "\n${glGetShaderInfoLog(vertexesShader)}"
}

片段着色器也是同理。两个着色器全部编译通过后,通过glAttachShader(program, shader)将着色器与程序关联起来,之后我们需要定义顶点着色器的参数。顶点着色器作为渲染流水线的第一个阶段,它将接受我们通过VBO传递的数据,并计算出每个顶点的坐标值。这里我们的顶点着色器只有一个参数:glBindAttribLocation(program, 0, "index"),它的名字是index,这个名字需要与顶点着色器中使用到的变量名完全一致,否则OpenGL将不知道要把数据给哪个变量,进而导致程序出错。

一切准备妥当之后就可以链接并验证程序了:

glLinkProgram(program)
require(glGetProgrami(program, GL_LINK_STATUS) == 1) {
    "Failed to link program:" +
    "\n${glGetProgramInfoLog(program)}"
}
glValidateProgram(program)
require(glGetProgrami(program, GL_VALIDATE_STATUS) == 1) {
    "Failed to validate program:" +
    "\n${glGetProgramInfoLog(program)}"
}

类似地,每一步都必须查询结果,出错时还需要查询出错原因。

着色器的参数

除了前面提到的顶点着色器的参数,在渲染过程中着色器还可以使用一种类似全局变量的参数。与前文中提到的顶点着色器的参数不同,顶点着色器的参数只能在顶点着色器中使用;而这种参数则可以在所有着色器中使用,并且该参数保证在每一次渲染过程中保持不变。

fun setUniform(name: String, value: Int) {
    val location = glGetUniformLocation(program, name)
    require(location != -1) {"Invalidate location returned"}
    glUniform1i(location, value)
}


fun setUniform(name: String, value: Double) {
    val location = glGetUniformLocation(program, name)
    require(location != -1) {"Invalidate location returned"}
    glUniform1f(location, value.toFloat())
}

在着色器类中我们编写了两种设置这种变量的方法,一个是设置整型,另一个设置小数。由于GPU运算的精度有限,所以在Java中常用的Double类型将被转换为Float后再作为参数。需要注意的是这里的变量名必须和着色器中声明的名字对应,并且编译时如果着色器定义了这个变量却没有使用,则编译器会默认删掉这个变量,导致程序出现问题。

渲染时使用着色器

现在我们的着色器已经具备编译、设置参数的功能了,就差在渲染时调用它了。为了简化渲染循环中的代码,绑定和解绑着色器的代码在着色器类中提供,由于OpenGL是一个大状态机,所以只要保证渲染的代码和绑定的代码在同一个线程内执行即可:

fun bind() {
    glUseProgram(program)
}

fun unbind(){
    glUseProgram(0)
}

通过传入我们的程序即可让OpenGL在接下来的渲染中使用我们的着色器,而通过传入0可以取消这个设定。

编写着色器

现在工具已经准备妥当,就差着色器了。

着色器的设计可以很灵活,所以距离最终的成品总会有一个迭代和探索的过程。最初我们可以根据现有的计算流程来分析出哪些代码可以放在着色器中。

目前为止我们的计算还都是在CPU这边完成的:

for (i in 0 until segments) {
    val theta = 2 * PI * i / segments
    val x = radius * cos(theta)
    val y = radius * sin(theta)
    // 将计算结果写入相关Buffer
}

可以看出这个计算坐标的过程是一个串行的过程。CPU必须先计算第一个点,然后才能计算第二个点,以此类推。但就圆的坐标来说,我们没必要这样串行计算,因为每一个点的坐标之间并无关联,实际上我们可以先计算第5个点,然后再计算任意一个点。每个点只要确定了它的索引i,结合半径和总段数我们就可以计算出该段对应的角度值,进而计算出每一个点的颜色。

因此我们可以只给出每一个点的索引i、半径和总段数,然后剩下的计算由显卡来完成。

于是我们的渲染循环变成了这个样子:

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

        for (i in 0 until segments) {
            floatBuffer.put(i , i.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)

        shader.bind()
        shader.setUniform("segments", segments)
        shader.setUniform("radius", radius)

        // render the data
        glEnableVertexAttribArray(0)
        // select buffer
        glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
        glVertexAttribPointer(0, 1, GL_FLOAT, false, 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
        glDisableVertexAttribArray(0)
        shader.unbind()

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

CPU只给出每个顶点的索引,每个顶点从原来的5个分量(两个坐标分量+三个颜色分量)减少到了1个分量,减少了CPU与显存之前传送的数据量。再将数据送到显卡之后,调用bind()绑定渲染程序,然后通过setUniform将半径和总段数作为全局变量交给着色器使用,之后的渲染过程与之前无异,只是要注意现在每个顶点的数据从两个变成了1个,同时也不再需要颜色数据了。

顶点着色器如下:

#version 330 core
#define PI 3.1415926535897932384626433832795

attribute float index;
varying vec3 color;
uniform float radius;
uniform int segments;

void main() {
    float seg = float(segments);
    float t = index / seg;
    float theta = 2 * PI * t;
    float x0 = cos(theta);
    float y0 = sin(theta);

    float x = radius * x0;
    float y = radius * y0;

    gl_Position = vec4(x, y, 0, 1);
    color = vec3(x0, y0, t);
}

着色器使用的变量之一是index,它的修饰符是attribute,表示这是我们通过VBO传进来的数据,根据传入数值的不同,它可以使floatvec2vec3等。下面一个变量是color,它的修饰符是varying,它将作为我们向片段着色器传递数据的变量。后面两个uniform修饰的变量就是我们传递的半径和总段数。计算是使用的cossin等是GLSL内置提供的函数。

为了减少重复计算,由于计算顶点产生的三角函数值刚好也被用作颜色值,因此直接传递给片段着色器即可。

然后在片段着色器中直接使用这些颜色值即可:

#version 330 core

varying vec3 color;

void main() {
    gl_FragColor = vec4((color.x + 1) / 2, (color.y + 1) / 2, color.z, 1.0f);
}

这里对三角函数值有一个修正,是前两篇文章中一直没有注意到的问题:sin和cos的值在-1到1之间,而颜色则在0到1之间,因此做一个简单的运算将其线性变换到正确的区间,颜色会显得更加好看一些。结果如下:左边是之前的程序,右边是本文的程序,为了对比期间的差异,这里并没有做修正。

屏幕截图 2021-04-13 195434.png

修正的色彩看起来是这样子的:

屏幕截图 2021-04-13 203237.png

一些细节

如果仔细看的话,会发现左右两张图不太一样。如果大家还记得前几篇文章说的,左图用的是OpenGL默认实现的Fixed Pipeline,那么一定就是因为它和我们的着色器实现不同。实际上的话,这是由于顶点着色器计算出顶点,装配成图元之后(本例中是三角形),有一个光栅化的过程。为了支持诸如光影等要求渲染十分细腻的场景,直接按照我们给出的图元渲染肯定不行,要不然你想渲染一个精细的模型,得计算多少个不必要的三角形?这些三角形全都位于一个平面上,而只是因为要更精细的光影就得付出这么多,颇有些得不偿失的感觉。于是光栅化就是一个能自动的将图元分解为更精细的上色单位的过程。然而这也带来了一个问题:光栅化之后我们的图元变成了更碎的单位,但我们之前在顶点着色器中传给片段着色器的数据只在顶点那里有定义,如果要对每一个片段运行片段着色器,这数据从何而来呢?原来OpenGL会帮我们自动进行插值操作。

但是这种插值并不智能。如果你尝试在顶点着色器中传递角度值给片段着色器,然后在片段着色器中将收到的角度转换为颜色,那么就会得到如下图的效果。为了方便说明,我在上面画了参考的坐标系。

屏幕截图 2021-04-09 205235.png

图片中黑色部分表示接收到的角度值偏向0,而红色越强烈,则表示该点处收到的角度值更偏向$2\pi$。如果作为圆来看的话,这个图应当是扇形的,毕竟极坐标系下给定角度应该得到一条射线。OpenGL并不会智能识别到这是个圆,实际上这个圆是这样画出来的:

屏幕截图 2021-04-13 204923.png

可见其三角形的共同顶点并不是圆的圆心,而是以我们计算出来的前三个点形成第一个三角形,然后在其基础上产生更多的三角形而已。随后OpenGL会以三角形为单位进行线性插值,这也是为什么上图中箭头所指之处三角形的边界如此明显。

所以如果一开始你和我一样,想要通过传递角度值和索引值,让片段着色器计算颜色,你会发现最终的结果和预期大相径庭,而线性插值则是罪魁祸首:

屏幕截图 2021-04-13 205738.png

因此推荐在传递数据时选择能够被线性插值的数据。诸如上文中使用计算好的颜色,如果我们想让颜色产生一种渐变效果,那么线性插值最适合我们不过了。可是角度并不适合线性插值,而前面几张图刚好也展现了角度进行线性插值的结果。

性能提升

虽然着色器有些难以捉摸(需要花费很大精力去彻底理解其背后的原理),但其带来的性能提升是其他方法所不能达到的。

条件最大 FPS最小 FPS平均 FPS
使用 glVertex 无色渲染5.03.94.6
使用 VBO 无色渲染8.65.97.5
使用 VBO 有色渲染5.43.55.4
CPU计算坐标,着色器计算颜色105.48.5
全部使用着色器计算12.59.1111.7

测试时全部取segments为300万。

可以看到按本文所述全部使用着色器计算是,程序可以达到11.7帧每秒。实际上将圆分为几百段时误差就已经无法被人眼分辨了,这里取三百万段完全是为了测试性能。

至此OpenGL系列可以暂且告一段落了。因为我发现要实现最初的目的,OpenGL似乎并不适合:OpenGL的主要作用还是将数据通过计算产生图像,而我还需要一种能够并行计算并更新数据的操作。毕竟模拟蚁群的行为并不是无状态的,我们必须先计算出前一状态,然后根据每个单位的周围环境计算出该单位的下一步动作,然后更新状态,周而复始。为了达到这个目的,我将尝试使用OpenCL将每个单位的决策利用显卡并行化计算。

-全文完-


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

Archives QR Code
QR Code for this page
Tipping QR Code
Leave a Comment

3 Comments
  1. chenyh chenyh

    圆*3?@(黑线)

    1. @chenyh至少每次的性能提升了(逃

  2. 北京艺术培训 北京艺术培训

    感谢分享 赞一个