几个月不见，我现在能用Golang画圆了！最近想着要再把Go捡起来，但是手头上确实没有什么能用上Go的地方，前些日子用SpringBoot，发现他那个自动配置是真的舒服。而且Java本身用来构建大型应用就很方便。Go更偏向于中层，底层还是C/C++的天下。那么什么算作中层呢？基础设施算一个，比如IPFS，区块链节点这些软件。再就是需要与C打交道，但是不一定要用C的地方，比如OpenGL和OpenCL这些。由于Java的内存模型是面向JVM的，所以C那块的内存交互就不能像管理Java对象那样方便，更具体地说，C那块的内存也是封装成一个Java对象来操作的，一个经典的操作就是先把数据生成到数组里，然后从数组拷贝到NIO Buffer里，再通过这个Buffer拷贝到OpenGL的内存中。这个过程如果使用Go的话，就可以省略中间Buffer的一步。那么闲话少说，书归正传，下面我们来看看用Go怎么搞OpenGL。首先是依赖，好在Go语言有现成的语言绑定，直接用就可以了：module opengl-demo go 1.17 require ( github.com/go-gl/gl v0.0.0-20211025173605-bda47ffaa784 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20211024062804-40e447a793be // indirect ) 我用的是1.17版本的Go，原则上不需要这么新。代码组织也相对简单：cmd/demo/demo.go是程序入口，internal则是一些工具代码。整体流程参考了我的另一篇文章：【代码札记】使用 LWJGL 和 OpenGL 最优雅的画圆，我直接使用了Shader去计算图元的坐标。谈起着色器，那肯定要在运行时编译着色器，我把着色器代码放到了internal/constant.go文件中：package internal const VertexShaderSource = ` #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); } ` + "\x00" const FragmentShaderSource = ` #version 330 core varying vec3 color; void main() { gl_FragColor = vec4((color.x + 1) / 2, (color.y + 1) / 2, color.z, 1.0f); } ` + "\x00" 这里与Java不同的地方在于，这里定义的字符串是要丢给C的，而C的字符串，要么你指定长度，要么就得是NULL结尾。所以最后的\x00就相当于是NULL结尾。这里用的代码和参考文章一模一样，节点着色器根据当前index计算出坐标并给出一个color，后面片段着色器根据这个color计算实际颜色。与Java不同的是，Go里面没有权限修饰符，如果想要其他包调用包内成员，那这个成员就必须以大写字母开头。有了着色器的代码，还得考虑如何把着色器编译成OpenGL程序。类似的，我也仿照Kotlin写了个工具类，算是简单体验了一下Go的“面向对象”，这部分是internal/shader.go：package internal import ( "fmt" "github.com/go-gl/gl/v3.3-core/gl" "strings" ) type DemoShader struct { program uint32 IndexAttribLocation uint32 vertexShader uint32 fragmentShader uint32 } func NewProgram(vertexShaderSource, fragmentShaderSource string) (*DemoShader, error) { vertexShader, err := compileShader(vertexShaderSource, gl.VERTEX_SHADER) if err != nil { return nil, err } fragmentShader, err := compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER) if err != nil { return nil, err } program := gl.CreateProgram() gl.AttachShader(program, vertexShader) gl.AttachShader(program, fragmentShader) gl.BindAttribLocation(program, 0, gl.Str("index\x00")) gl.LinkProgram(program) var status int32 gl.GetProgramiv(program, gl.LINK_STATUS, &status) if status == gl.FALSE { var logLength int32 gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength) log := strings.Repeat("\x00", int(logLength+1)) gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log)) return nil, fmt.Errorf("failed to link program: %v", log) } gl.ValidateProgram(program) gl.GetProgramiv(program, gl.VALIDATE_STATUS, &status) if status == gl.FALSE { var logLength int32 gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength) log := strings.Repeat("\x00", int(logLength+1)) gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log)) return nil, fmt.Errorf("failed to validate program: %v", log) } return &DemoShader{ program: program, IndexAttribLocation: uint32(gl.GetAttribLocation(program, gl.Str("index\x00"))), vertexShader: vertexShader, fragmentShader: fragmentShader, }, nil } func compileShader(shaderSource string, shaderType uint32) (uint32, error) { shader := gl.CreateShader(shaderType) cSources, free := gl.Strs(shaderSource) gl.ShaderSource(shader, 1, cSources, nil) //gl.INVALID_OPERATION free() gl.CompileShader(shader) var status int32 gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status) if status == gl.FALSE { var logLength int32 gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLength) fmt.Printf("Log length %v\n", logLength) // pre-allocate space + the last null-termination log := strings.Repeat("\x00", int(logLength+1)) gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log)) return 0, fmt.Errorf("failed to compile shader: source: %v, log: %v", shaderSource, log) } return shader, nil } func (demoShader *DemoShader) SetUniformInt(name string, value int32) { location := gl.GetUniformLocation(demoShader.program, gl.Str(name)) if location == -1 { panic("Invalid location returned") } gl.Uniform1i(location, value) } func (demoShader *DemoShader) SetUniformFloat(name string, value float32) { location := gl.GetUniformLocation(demoShader.program, gl.Str(name)) if location == -1 { panic("Invalid location returned") } gl.Uniform1f(location, value) } func (demoShader *DemoShader) Bind() { gl.UseProgram(demoShader.program) } func (demoShader *DemoShader) Unbind() { gl.UseProgram(0) } func (demoShader *DemoShader) Free() { gl.DeleteShader(demoShader.vertexShader) gl.DeleteShader(demoShader.fragmentShader) gl.DeleteProgram(demoShader.program) } 这里我定义了个一个名叫DemoShader的结构体，它里面存储了所有必要的信息，比如两个着色器和最终编译产物的句柄（指针），还有一个index参数的位置指针。同时提供了NewProgram，这个方法类似于Java里面的构造函数，或者说是静态工厂会更准确一些。这个方法会调用compileShader方法编译着色器。在错误处理这块更能够体现出OpenGL是一个状态机的理念。Java中可能还不是特别明显，因为面向对象的库帮助我们自动封装了一些操作，使得使用者会舒服一些。但Go的话，就以简单的取错误信息为例：if status == gl.FALSE { var logLength int32 gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLength) fmt.Printf("Log length %v\n", logLength) // pre-allocate space + the last null-termination log := strings.Repeat("\x00", int(logLength+1)) gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log)) return 0, fmt.Errorf("failed to compile shader: source: %v, log: %v", shaderSource, log) }首先得取出来字符串的长度，然后初始化一个字符串，再把这个字符串转换成C的*char指针，最后才能用gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log))取出来真正的字符串。对比Java，代码是这样的（实际是Kotlin）：require(glGetShaderi(vertexesShader, GL_COMPILE_STATUS) == 1) { "Unable to compile vertex shader: '/shaders/$filename.vert':" + "\n${glGetShaderInfoLog(vertexesShader)}" }glGetShaderi(vertexesShader, GL_COMPILE_STATUS) == 1判断着色器是否编译成功，不成功的话直接一个glGetShaderInfoLog(vertexesShader)就可以了。在之后还有一些方法，他们作用在一个DemoShader指针上，这个接近Java的成员方法，比如设置Uniform变量，这个可以解释为OpenGL程序的全局变量，以及还有绑定和解绑着色器程序，还有最必不可少的释放内存用的Free()。这个代码整体与Java的似乎一样，只不过操作上会更精细一些，因为没有JVM帮你擦屁股了，所以程序员自己就得多在细节上费心。最后是具有Go特色的Utils函数，文件是internal/utils.go：package internal func Max(x, y int32) int32 { if x < y { return y } else { return x } } func Min(x, y int32) int32 { if x > y { return y } else { return x } } 真的，当我得知Go本身不提供整型数的最大值最小值的时候，我人都惊了。我不理解，但我备受震撼。后来去网上查了查，有说法称像是Float这种使用IEEE754标准存储的小数不方便直接用if x> y return x else return y的方法比较，所以Go提供了float64类型的最大值最小值比较，而普通的整型数很容易实现，因此Go官方就没管。彳亍口巴。经过了这番准备阶段，终于可以写主函数了。整体思路还是和Kotlin类似，但是不同库的初始化时机不同，而且初始化搞错了，和Java一样——Go也不会打印panic栈，而是直接一个丢一个0xC0000005程序就停了。由于入口代码比较多，所以这里先拆开解释，最后给出完整的代码清单。与Kotlin版本一样，我希望用户可以通过键盘来控制渲染参数，所以除了只能画圆之外，还能够响应用户输入，同时还有个fps统计功能方便对比性能。所以大体框架是这样：var radius = 0.75 var segments int32 = 30e5 func main() { runtime.LockOSThread() if err := initGlfw(); err != nil { panic(err) } window, err := initWindow() if err != nil { panic(err) } // Initialize Glow, right after we have current context if err := gl.Init(); err != nil { panic(err) } fmt.Printf("Using OpenGL version: %v\n", gl.GoStr(gl.GetString(gl.VERSION))) fmt.Printf("Using OpenGL vender: %v\n", gl.GoStr(gl.GetString(gl.VENDOR))) setupWindow(window) fmt.Println("ESC: Stop the program") fmt.Println(" W : radius + 0.001") fmt.Println(" A : radius - 0.001") fmt.Println(" S : segments - 1000") fmt.Println(" D : segments + 1000") render(window) window.Destroy() glfw.Terminate() }这里redius和segments是圆的半径和分段数量。随后就是Main函数了，我把具体的操作封装到了子函数里面，所以整体就成相当于：初始化GLFW，失败则panic 初始化窗口，失败则panic 初始化OpenGL，失败则panic 设置窗口（键盘的监听器就是在这里设置的） 打印控制信息 渲染窗口 资源回收（销毁窗口，结束GLFW）一开始的runtime.LockOSThread()是官方说一定要加的，我不太清楚具体的作用。初始化GLFW的代码比较简单：func initGlfw() error { if err := glfw.Init(); err != nil { return err } fmt.Printf("Using glfw version: %s\n", glfw.GetVersionString()) // hide window after creation glfw.WindowHint(glfw.Visible, glfw.False) return nil }就是调用GLFW自己的初始化，有问题就直接抛异常。这里设置了窗口默认不可见，同时这里允许调整窗口大小，因为我简单了解了一下他那个viewport，大概整明白了怎么在调整窗口后能够保持图形居中且比例不变。随后是初始化窗口：func initWindow() (*glfw.Window, error) { // create the window window, err := glfw.CreateWindow(600, 600, "Hello world!", nil, nil) if err != nil { return nil, err } window.MakeContextCurrent() glfw.SwapInterval(1) return window, nil }创建一个600像素见方的窗口，因为默认比例是1:1，不然后面画出来的就是椭圆了。比较关键的就是window.MakeContextCurrent()，因为这个方法返回后我们就要初始化OpenGL了，而OpenGL的上下文就是依赖于这个窗口的。之后可以简单的设置一下窗口，比如加事件回调什么的：func setupWindow(window *glfw.Window) { // set key handler window.SetKeyCallback(func(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) { //fmt.Printf("Key event: key: %v, action: %v\n", key, action) if key == glfw.KeyEscape && action == glfw.Press { window.SetShouldClose(true) fmt.Println("Exit.") } else if key == glfw.KeyW && action != glfw.Release { radius = math.Min(1.0, radius+0.001) } else if key == glfw.KeyS && action != glfw.Release { radius = math.Max(0, radius-0.001) } else if key == glfw.KeyA && action != glfw.Release { segments = internal.Max(1000, segments-1000) } else if key == glfw.KeyD && action != glfw.Release { segments = internal.Min(math.MaxInt32-1000, segments+1000) } }) // set resize handler window.SetSizeCallback(func(w *glfw.Window, width int, height int) { l := internal.Min(int32(width), int32(height)) // center the view port gl.Viewport( (int32(width)-l)/2, (int32(height)-l)/2, l, l) }) // set window position monitor := glfw.GetPrimaryMonitor() videoMode := monitor.GetVideoMode() weight, height := window.GetSize() window.SetPos( (videoMode.Width-weight)/2, (videoMode.Height-height)/2, ) // This will center the window window.Show() }这里加了个键盘回调，用于处理用户的键盘输入。额外的，我还加了针对窗口大小的回调，这样当用户调整窗口大小的时候，通过设置ViewPort就可以让圆自动居中。最后是让窗口显示到主显示器的中间，最后显示窗口。接下来就可以进入渲染循环了：func render(window *glfw.Window) { // set a background color // specifies the red, green, blue, and alpha values gl.ClearColor(0.2, 0.3, 0.3, 1.0) // compile our shaders program, err := internal.NewProgram(internal.VertexShaderSource, internal.FragmentShaderSource) if err != nil { panic(err) } // prepare buffers var vertexVBO uint32 gl.GenBuffers(1, &vertexVBO) fmt.Println("Start rendering loop") var lastTime = glfw.GetTime() var frameCount = 0 for !window.ShouldClose() { // measure fps currentTime := glfw.GetTime() frameCount++ if currentTime-lastTime >= 1.0 { window.SetTitle(fmt.Sprintf("Hello world! (radius %v, segments %v, fps %v)", radius, segments, frameCount)) frameCount = 0 lastTime += 1.0 } // clear color buffer gl.Clear(gl.COLOR_BUFFER_BIT) // generate buffer data var floatBuffer []float32 for i := 0; int32(i) < segments; i++ { floatBuffer = append(floatBuffer, float32(i)) } // send data to buffer gl.BindBuffer(gl.ARRAY_BUFFER, vertexVBO) // float32 -> 4B gl.BufferData(gl.ARRAY_BUFFER, len(floatBuffer)*4, gl.Ptr(floatBuffer), gl.DYNAMIC_DRAW) // unbind gl.BindBuffer(gl.ARRAY_BUFFER, 0) // set up the shader program.Bind() program.SetUniformInt("segments\x00", segments) program.SetUniformFloat("radius\x00", float32(radius)) // render data gl.EnableVertexAttribArray(0) gl.BindBuffer(gl.ARRAY_BUFFER, vertexVBO) gl.VertexAttribPointer(0, 1, gl.FLOAT, false, 0, nil) // draw the vertexes as TRIANGLE FAN // vertexes data start from index 0 and there are $segments vertexes gl.DrawArrays(gl.TRIANGLE_FAN, 0, segments) // done with buffer gl.BindBuffer(gl.ARRAY_BUFFER, 0) // end render gl.DisableVertexAttribArray(0) program.Unbind() //swap color buffer, means that next frame is done window.SwapBuffers() // poll key events glfw.PollEvents() } program.Free() }首先设置了一个背景色，然后编译着色器程序。完成之后创建一个VBO。同时记录一下当前时间，这个用于统计FPS。在渲染循环中首先测量FPS，每秒钟将FPS显示到窗口标题上。然后清空颜色缓存，准备生成index数据，并送到VBO中。然后配置着色器，计算结点坐标，按照TRIANGLE_FAN绘制出来。随后交换颜色缓存，然后处理输入事件。当用户按了ESC或者关闭了窗口，循环退出，着色器程序被释放。随后主程序销毁窗口，程序退出。完整的程序清单如下（cmd/demo/demo.go）：package main import ( "fmt" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/glfw/v3.3/glfw" "github.com/hurui200320/opengl-demo/internal" "math" "runtime" ) var radius = 0.75 var segments int32 = 30e5 func main() { runtime.LockOSThread() if err := initGlfw(); err != nil { panic(err) } window, err := initWindow() if err != nil { panic(err) } // Initialize Glow, right after we have current context if err := gl.Init(); err != nil { panic(err) } fmt.Printf("Using OpenGL version: %v\n", gl.GoStr(gl.GetString(gl.VERSION))) fmt.Printf("Using OpenGL vender: %v\n", gl.GoStr(gl.GetString(gl.VENDOR))) setupWindow(window) fmt.Println("ESC: Stop the program") fmt.Println(" W : radius + 0.001") fmt.Println(" A : radius - 0.001") fmt.Println(" S : segments - 1000") fmt.Println(" D : segments + 1000") render(window) window.Destroy() glfw.Terminate() } func initGlfw() error { if err := glfw.Init(); err != nil { return err } fmt.Printf("Using glfw version: %s\n", glfw.GetVersionString()) // hide window after creation glfw.WindowHint(glfw.Visible, glfw.False) // disable resize //glfw.WindowHint(glfw.Resizable, glfw.False) return nil } func initWindow() (*glfw.Window, error) { // create the window window, err := glfw.CreateWindow(600, 600, "Hello world!", nil, nil) if err != nil { return nil, err } window.MakeContextCurrent() glfw.SwapInterval(1) return window, nil } func setupWindow(window *glfw.Window) { // set key handler window.SetKeyCallback(func(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) { //fmt.Printf("Key event: key: %v, action: %v\n", key, action) if key == glfw.KeyEscape && action == glfw.Press { window.SetShouldClose(true) fmt.Println("Exit.") } else if key == glfw.KeyW && action != glfw.Release { radius = math.Min(1.0, radius+0.001) } else if key == glfw.KeyS && action != glfw.Release { radius = math.Max(0, radius-0.001) } else if key == glfw.KeyA && action != glfw.Release { segments = internal.Max(1000, segments-1000) } else if key == glfw.KeyD && action != glfw.Release { segments = internal.Min(math.MaxInt32-1000, segments+1000) } }) // set resize handler window.SetSizeCallback(func(w *glfw.Window, width int, height int) { l := internal.Min(int32(width), int32(height)) // center the view port gl.Viewport( (int32(width)-l)/2, (int32(height)-l)/2, l, l) }) // set window position monitor := glfw.GetPrimaryMonitor() videoMode := monitor.GetVideoMode() weight, height := window.GetSize() window.SetPos( (videoMode.Width-weight)/2, (videoMode.Height-height)/2, ) // This will center the window window.Show() } func render(window *glfw.Window) { // set a background color // specifies the red, green, blue, and alpha values gl.ClearColor(0.2, 0.3, 0.3, 1.0) // compile our shaders program, err := internal.NewProgram(internal.VertexShaderSource, internal.FragmentShaderSource) if err != nil { panic(err) } // prepare buffers var vertexVBO uint32 gl.GenBuffers(1, &vertexVBO) fmt.Println("Start rendering loop") var lastTime = glfw.GetTime() var frameCount = 0 for !window.ShouldClose() { // measure fps currentTime := glfw.GetTime() frameCount++ if currentTime-lastTime >= 1.0 { window.SetTitle(fmt.Sprintf("Hello world! (radius %v, segments %v, fps %v)", radius, segments, frameCount)) frameCount = 0 lastTime += 1.0 } // clear color buffer gl.Clear(gl.COLOR_BUFFER_BIT) // generate buffer data var floatBuffer []float32 for i := 0; int32(i) < segments; i++ { floatBuffer = append(floatBuffer, float32(i)) } // send data to buffer gl.BindBuffer(gl.ARRAY_BUFFER, vertexVBO) // float32 -> 4B gl.BufferData(gl.ARRAY_BUFFER, len(floatBuffer)*4, gl.Ptr(floatBuffer), gl.DYNAMIC_DRAW) // unbind gl.BindBuffer(gl.ARRAY_BUFFER, 0) // set up the shader program.Bind() program.SetUniformInt("segments\x00", segments) program.SetUniformFloat("radius\x00", float32(radius)) // render data gl.EnableVertexAttribArray(0) gl.BindBuffer(gl.ARRAY_BUFFER, vertexVBO) gl.VertexAttribPointer(0, 1, gl.FLOAT, false, 0, nil) // draw the vertexes as TRIANGLE FAN // vertexes data start from index 0 and there are $segments vertexes gl.DrawArrays(gl.TRIANGLE_FAN, 0, segments) // done with buffer gl.BindBuffer(gl.ARRAY_BUFFER, 0) // end render gl.DisableVertexAttribArray(0) program.Unbind() //swap color buffer, means that next frame is done window.SwapBuffers() // poll key events glfw.PollEvents() } program.Free() } 最后关于效率，我本来以为Go语言的效率会比Java高一些，没想到两种语言得到的FPS基本一致，并没有太多的差距。但是Go调用C库确实方便不少，至少不用从数组倒腾到NIO缓冲，然后再倒腾到OpenGL的内存里。-全文完-
上一篇文章通过使用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传进来的数据，根据传入数值的不同，它可以使float、vec2、vec3等。下面一个变量是color，它的修饰符是varying，它将作为我们向片段着色器传递数据的变量。后面两个uniform修饰的变量就是我们传递的半径和总段数。计算是使用的cos和sin等是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之间，因此做一个简单的运算将其线性变换到正确的区间，颜色会显得更加好看一些。结果如下：左边是之前的程序，右边是本文的程序，为了对比期间的差异，这里并没有做修正。修正的色彩看起来是这样子的：一些细节如果仔细看的话，会发现左右两张图不太一样。如果大家还记得前几篇文章说的，左图用的是OpenGL默认实现的Fixed Pipeline，那么一定就是因为它和我们的着色器实现不同。实际上的话，这是由于顶点着色器计算出顶点，装配成图元之后（本例中是三角形），有一个光栅化的过程。为了支持诸如光影等要求渲染十分细腻的场景，直接按照我们给出的图元渲染肯定不行，要不然你想渲染一个精细的模型，得计算多少个不必要的三角形？这些三角形全都位于一个平面上，而只是因为要更精细的光影就得付出这么多，颇有些得不偿失的感觉。于是光栅化就是一个能自动的将图元分解为更精细的上色单位的过程。然而这也带来了一个问题：光栅化之后我们的图元变成了更碎的单位，但我们之前在顶点着色器中传给片段着色器的数据只在顶点那里有定义，如果要对每一个片段运行片段着色器，这数据从何而来呢？原来OpenGL会帮我们自动进行插值操作。但是这种插值并不智能。如果你尝试在顶点着色器中传递角度值给片段着色器，然后在片段着色器中将收到的角度转换为颜色，那么就会得到如下图的效果。为了方便说明，我在上面画了参考的坐标系。图片中黑色部分表示接收到的角度值偏向0，而红色越强烈，则表示该点处收到的角度值更偏向$2\pi$。如果作为圆来看的话，这个图应当是扇形的，毕竟极坐标系下给定角度应该得到一条射线。OpenGL并不会智能识别到这是个圆，实际上这个圆是这样画出来的：可见其三角形的共同顶点并不是圆的圆心，而是以我们计算出来的前三个点形成第一个三角形，然后在其基础上产生更多的三角形而已。随后OpenGL会以三角形为单位进行线性插值，这也是为什么上图中箭头所指之处三角形的边界如此明显。所以如果一开始你和我一样，想要通过传递角度值和索引值，让片段着色器计算颜色，你会发现最终的结果和预期大相径庭，而线性插值则是罪魁祸首：因此推荐在传递数据时选择能够被线性插值的数据。诸如上文中使用计算好的颜色，如果我们想让颜色产生一种渐变效果，那么线性插值最适合我们不过了。可是角度并不适合线性插值，而前面几张图刚好也展现了角度进行线性插值的结果。性能提升虽然着色器有些难以捉摸（需要花费很大精力去彻底理解其背后的原理），但其带来的性能提升是其他方法所不能达到的。条件最大 FPS最小 FPS平均 FPS使用 glVertex 无色渲染5.03.94.6使用 VBO 无色渲染8.65.97.5使用 VBO 有色渲染5.43.55.4CPU计算坐标，着色器计算颜色105.48.5全部使用着色器计算12.59.1111.7测试时全部取segments为300万。可以看到按本文所述全部使用着色器计算是，程序可以达到11.7帧每秒。实际上将圆分为几百段时误差就已经无法被人眼分辨了，这里取三百万段完全是为了测试性能。至此OpenGL系列可以暂且告一段落了。因为我发现要实现最初的目的，OpenGL似乎并不适合：OpenGL的主要作用还是将数据通过计算产生图像，而我还需要一种能够并行计算并更新数据的操作。毕竟模拟蚁群的行为并不是无状态的，我们必须先计算出前一状态，然后根据每个单位的周围环境计算出该单位的下一步动作，然后更新状态，周而复始。为了达到这个目的，我将尝试使用OpenCL将每个单位的决策利用显卡并行化计算。-全文完-
上一篇文章记录了我初学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，它负责实现所有过去的接口（我们调用的固定流水线的函数实际是在这里实现的），以便保证使用以前版本构建的代码能够在更新版本上顺利运行。就目前而言，我们距离着色器还差一些。如开头所言，本文的目的是要将代码改写成将数据按批次送给显卡，而不是你给一个坐标我就立刻送一个。使用VBOVBO全称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.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() }-全文完-另注：由于撰写本文的时候突发感冒，甚至有些低烧，脑袋晕晕乎乎的，以至于文中一些文字不够流畅，思维有不通或怪异之处，还请各位读者谅解。欢迎在评论区提出这些问题，日后我会修改。
前些日子在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()) }绘制过程中横坐标决定颜色的红色分量，纵坐标决定颜色的绿色分量，而段数则决定了蓝色分量：有点炫酷。至此我们就完成了画圆工作。但只是画圆的话，未免有些太过单调了？交互式画圆我决定在这个圆的基础上追加一些交互性，诸如调整圆的分段数量，半径等，利用键盘来改变这些参数： // 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) }这里我定义W和S控制半径的大小，A和D控制分段的数量，需要注意的是必须要对半径和分段数做出检查。半径至少不能为负数，但是如果等于0的话就没有意义了，最大是1，因为OpenGL的坐标系是归一化的；分段数取决于绘画模式，如果使用GL_LINE_LOOP，则最少两个点才能出一条线；而GL_TRIANGLE_FAN则至少需要3个点才能产生出一个三角形；原则上分段可以无限大，但是取决于电脑的具体性能，如果分段太大，一方面Float型承载不了那种精度（显卡执行的全都是Float型运算，要执行Double型运算多半得上贵的离谱的专业卡），另一方面计算起来耗时很大，没有必要。这样一来就可以通过键盘控制画圆的参数了，还挺有意思的。-全文完- 来源：https://gamedev.stackexchange.com/a/133817 这个Stackoverflow回答中提到以glBegin()和glEnd()为代表的渲染技术已经被废弃，虽然还可以使用，但是并不推荐过多使用。由于我没能深入了解他们被废弃的原因，但即便如此LWJGL的官方教程中还是用了这些代码，也许就是用作教程吧。 ↩