MENU

【代码札记】Golang的OpenGL入门初体验

November 19, 2021 • Read: 46 • 瞎折腾

几个月不见,我现在能用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()
}

这里rediussegments是圆的半径和分段数量。随后就是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的内存里。

-全文完-


生活不易,一点广告


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

Archives QR Code Tip
QR Code for this page
Tipping QR Code