几个月不见,我现在能用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的内存里。
-全文完-
【代码札记】Golang的OpenGL入门初体验 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。
期待下Rust画圆,hhhh