OpenGL 與 Go 教程(一)Hello, OpenGL
這篇教程的所有源代碼都可以在 GitHub 上找到。
介紹
OpenGL 是一門相當好的技術,適用於從桌面的 GUI 到遊戲,到移動應用甚至 web 應用的多種類型的繪圖工作。我敢保證,你今天看到的圖形有些就是用 OpenGL 渲染的。可是,不管 OpenGL 多受歡迎、有多好用,與學習其它高級繪圖庫相比,學習 OpenGL 是要相當足夠的決心的。
這個教程的目的是給你一個切入點,讓你對 OpenGL 有個基本的了解,然後教你怎麼用 Go 操作它。幾乎每種編程語言都有綁定 OpenGL 的庫,Go 也不例外,它有 go-gl 這個包。這是一個完整的套件,可以綁定 OpenGL ,適用於多種版本的 OpenGL。
這篇教程會按照下面列出的幾個階段進行介紹,我們最終的目標是用 OpenGL 在桌面窗口繪製遊戲面板,進而實現康威生命遊戲。完整的源代碼可以在 GitHub github.com/KyleBanks/conways-gol 上獲得,當你有疑惑的時候可以隨時查看源代碼,或者你要按照自己的方式學習也可以參考這個代碼。
在我們開始之前,我們要先弄明白 康威生命遊戲 到底是什麼。這裡是 Wikipedia 上面的總結:
《生命遊戲》,也可以簡稱為 Life,是一個細胞自動變化的過程,由英國數學家 John Horton Conway 於 1970 年提出。
這個「遊戲」沒有玩家,也就是說它的發展依靠的是它的初始狀態,不需要輸入。用戶通過創建初始配置文件、觀察它如何演變,或者對於高級「玩家」可以創建特殊屬性的模式,進而與《生命遊戲》進行交互。
規則
《生命遊戲》的世界是一個無窮多的二維正交的正方形細胞的格子世界,每一個格子都有兩種可能的狀態,「存活」或者「死亡」,也可以說是「填充態」或「未填充態」(區別可能很小,可以把它看作一個模擬人類/哺乳動物行為的早期模型,這要看一個人是如何看待方格里的空白)。每一個細胞與它周圍的八個細胞相關聯,這八個細胞分別是水平、垂直、斜對角相接的。在遊戲中的每一步,下列事情中的一件將會發生:
- 當任何一個存活的細胞的附近少於 2 個存活的細胞時,該細胞將會消亡,就像人口過少所導致的結果一樣
- 當任何一個存活的細胞的附近有 2 至 3 個存活的細胞時,該細胞在下一代中仍然存活。
- 當任何一個存活的細胞的附近多於 3 個存活的細胞時,該細胞將會消亡,就像人口過多所導致的結果一樣
- 任何一個消亡的細胞附近剛好有 3 個存活的細胞,該細胞會變為存活的狀態,就像重生一樣。
不需要其他工具,這裡有一個我們將會製作的演示程序:
在我們的運行過程中,白色的細胞表示它是存活著的,黑色的細胞表示它已經死亡。
概述
本教程將會涉及到很多基礎內容,從最基本的開始,但是你還是要對 Go 由一些最基本的了解 —— 至少你應該知道變數、切片、函數和結構體,並且裝了一個 Go 的運行環境。我寫這篇教程用的 Go 版本是 1.8,但它應該與之前的版本兼容。這裡用 Go 語言實現沒有什麼特別新奇的東西,因此只要你有過類似的編程經歷就行。
這裡是我們在這個教程里將會講到的東西:
- 第一節: Hello, OpenGL: 安裝 OpenGL 和 GLFW,在窗口上繪製一個三角形。
- 第二節: 繪製遊戲面板: 用三角形拼成方形,在窗口上用方形繪成格子。
- 第三節: 實現遊戲功能: 實現 Conway 遊戲
最後的源代碼可以在 GitHub 上獲得,每一節的末尾有個回顧,包含該節相關的代碼。如果有什麼不清楚的地方或者是你感到疑惑的,看看每一節末尾的完整代碼。
現在就開始吧!
安裝 OpenGL 和 GLFW
我們介紹過 OpenGL,但是為了使用它,我們要有個窗口可以繪製東西。 GLFW 是一款用於 OpenGL 的跨平台 API,允許我們創建並使用窗口,而且它也是 go-gl 套件中提供的。
我們要做的第一件事就是確定 OpenGL 的版本。為了方便本教程,我們將會使用 OpenGL v4.1
,但要是你的操作系統不支持最新的 OpenGL,你也可以用 v2.1
。要安裝 OpenGL,我們需要做這些事:
# 對於 OpenGL 4.1
$ go get github.com/go-gl/gl/v4.1-core/gl
# 或者 2.1
$ go get github.com/go-gl/gl/v2.1/gl
然後是安裝 GLFW:
$ go get github.com/go-gl/glfw/v3.2/glfw
安裝好這兩個包之後,我們就可以開始了!先創建 main.go
文件,導入相應的包(我們待會兒會用到的其它東西)。
package main
import (
"log"
"runtime"
"github.com/go-gl/gl/v4.1-core/gl" // OR: github.com/go-gl/gl/v2.1/gl
"github.com/go-gl/glfw/v3.2/glfw"
)
接下來定義一個叫做 main
的函數,這是用來初始化 OpenGL 以及 GLFW,並顯示窗口的:
const (
width = 500
height = 500
)
func main() {
runtime.LockOSThread()
window := initGlfw()
defer glfw.Terminate()
for !window.ShouldClose() {
// TODO
}
}
// initGlfw 初始化 glfw 並且返回一個可用的窗口。
func initGlfw() *glfw.Window {
if err := glfw.Init(); err != nil {
panic(err)
}
glfw.WindowHint(glfw.Resizable, glfw.False)
glfw.WindowHint(glfw.ContextVersionMajor, 4) // OR 2
glfw.WindowHint(glfw.ContextVersionMinor, 1)
glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile)
glfw.WindowHint(glfw.OpenGLForwardCompatible, glfw.True)
window, err := glfw.CreateWindow(width, height, "Conway's Game of Life", nil, nil)
if err != nil {
panic(err)
}
window.MakeContextCurrent()
return window
}
好了,讓我們花一分鐘來運行一下這個程序,看看會發生什麼。首先定義了一些常量, width
和 height
—— 它們決定窗口的像素大小。
然後就是 main
函數。這裡我們使用了 runtime
包的 LockOSThread()
,這能確保我們總是在操作系統的同一個線程中運行代碼,這對 GLFW 來說很重要,GLFW 需要在其被初始化之後的線程里被調用。講完這個,接下來我們調用 initGlfw
來獲得一個窗口的引用,並且推遲(defer
)其終止。窗口的引用會被用在一個 for
循環中,只要窗口處於打開的狀態,就執行某些事情。我們待會兒會講要做的事情是什麼。
initGlfw
是另一個函數,這裡我們調用 glfw.Init()
來初始化 GLFW 包。然後我們定義了 GLFW 的一些全局屬性,包括禁用調整窗口大小和改變 OpenGL 的屬性。然後創建了 glfw.Window
,這會在稍後的繪圖中用到。我們僅僅告訴它我們想要的寬度和高度,以及標題,然後調用 window.MakeContextCurrent
,將窗口綁定到當前的線程中。最後就是返回窗口的引用了。
如果你現在就構建、運行這個程序,你看不到任何東西。很合理,因為我們還沒有用這個窗口做什麼實質性的事。
定義一個新函數,初始化 OpenGL,就可以解決這個問題:
// initOpenGL 初始化 OpenGL 並且返回一個初始化了的程序。
func initOpenGL() uint32 {
if err := gl.Init(); err != nil {
panic(err)
}
version := gl.GoStr(gl.GetString(gl.VERSION))
log.Println("OpenGL version", version)
prog := gl.CreateProgram()
gl.LinkProgram(prog)
return prog
}
initOpenGL
就像之前的 initGlfw
函數一樣,初始化 OpenGL 庫,創建一個 程序 。「程序」是一個包含了 著色器 的引用,稍後會用 著色器 繪圖。待會兒會講這一點,現在只用知道 OpenGL 已經初始化完成了,我們有一個程序的引用。我們還列印了 OpenGL 的版本,可以用於之後的調試。
回到 main
函數里,調用這個新函數:
func main() {
runtime.LockOSThread()
window := initGlfw()
defer glfw.Terminate()
program := initOpenGL()
for !window.ShouldClose() {
draw(window, program)
}
}
你應該注意到了現在我們有 program
的引用,在我們的窗口循環中,調用新的 draw
函數。最終這個函數會繪製出所有細胞,讓遊戲狀態變得可視化,但是現在它做的僅僅是清除窗口,所以我們只能看到一個全黑的屏幕:
func draw(window *glfw.Window, program uint32) {
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.UseProgram(prog)
glfw.PollEvents()
window.SwapBuffers()
}
我們首先做的是調用 gl.clear
函數來清除上一幀在窗口中繪製的東西,給我們一個乾淨的面板。然後我們告訴 OpenGL 去使用我們的程序引用,這個引用還沒有做什麼事。最終我們告訴 GLFW 用 PollEvents
去檢查是否有滑鼠或者鍵盤事件(這一節里還不會對這些事件進行處理),告訴窗口去交換緩衝區 SwapBuffers
。 交換緩衝區 很重要,因為 GLFW(像其他圖形庫一樣)使用雙緩衝,也就是說你繪製的所有東西實際上是繪製到一個不可見的畫布上,當你準備好進行展示的時候就把繪製的這些東西放到可見的畫布中 —— 這種情況下,就需要調用 SwapBuffers
函數。
好了,到這裡我們已經講了很多東西,花一點時間看看我們的實驗成果。運行這個程序,你應該可以看到你所繪製的第一個東西:
完美!
在窗口裡繪製三角形
我們已經完成了一些複雜的步驟,即使看起來不多,但我們仍然需要繪製一些東西。我們會以三角形繪製開始,可能這第一眼看上去要比我們最終要繪製的方形更難,但你會知道這樣的想法是錯的。你可能不知道的是三角形或許是繪製的圖形中最簡單的,實際上我們最終會用某種方式把三角形拼成方形。
好吧,那麼我們想要繪製一個三角形,怎麼做呢?我們通過定義圖形的頂點來繪製圖形,把它們交給 OpenGL 來進行繪製。先在 main.go
的頂部里定義我們的三角形:
var (
triangle = []float32{
0, 0.5, 0, // top
-0.5, -0.5, 0, // left
0.5, -0.5, 0, // right
}
)
這看上去很奇怪,讓我們分開來看。首先我們用了一個 float32
切片 ,這是一種我們總會在向 OpenGL 傳遞頂點時用到的數據類型。這個切片包含 9 個值,每三個值構成三角形的一個點。第一行, 0, 0.5, 0
表示的是 X、Y、Z 坐標,是最上方的頂點,第二行是左邊的頂點,第三行是右邊的頂點。每一組的三個點都表示相對於窗口中心點的 X、Y、Z 坐標,大小在 -1
和 1
之間。因此最上面的頂點 X 坐標是 0
,因為它在 X 方向上位於窗口中央,Y 坐標是 0.5
意味著它會相對窗口中央上移 1/4 個單位(因為窗口的範圍是 -1
到 1
),Z 坐標是 0。因為我們只需要在二維空間中繪圖,所以 Z 值永遠是 0
。現在看一看左右兩邊的頂點,看看你能不能理解為什麼它們是這樣定義的 —— 如果不能立刻就弄清楚也沒關係,我們將會在屏幕上去觀察它,因此我們需要一個完美的圖形來進行觀察。
好了,我們定義了一個三角形,但是現在我們得把它畫出來。要畫出這個三角形,我們需要一個叫做 頂點數組對象 或者叫 vao 的東西,這是由一系列的點(也就是我們定義的三角形)創造的,這個東西可以提供給 OpenGL 來進行繪製。創建一個叫做 makeVao
的函數,然後我們可以提供一個點的切片,讓它返回一個指向 OpenGL 頂點數組對象的指針:
// makeVao 執行初始化並從提供的點裡面返回一個頂點數組
func makeVao(points []float32) uint32 {
var vbo uint32
gl.GenBuffers(1, &vbo)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.BufferData(gl.ARRAY_BUFFER, 4*len(points), gl.Ptr(points), gl.STATIC_DRAW)
var vao uint32
gl.GenVertexArrays(1, &vao)
gl.BindVertexArray(vao)
gl.EnableVertexAttribArray(0)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 0, nil)
return vao
}
首先我們創造了 頂點緩衝區對象 或者說 vbo 綁定到我們的 vao
上,vbo
是通過所佔空間(也就是 4 倍 len(points)
大小的空間)和一個指向頂點的指針(gl.Ptr(points)
)來創建的。你也許會好奇為什麼它是 4 倍 —— 而不是 6 或者 3 或者 1078 呢?原因在於我們用的是 float32
切片,32 個位的浮點型變數是 4 個位元組,因此我們說這個緩衝區以位元組為單位的大小是點個數的 4 倍。
現在我們有緩衝區了,可以創建 vao
並用 gl.BindBuffer
把它綁定到緩衝區上,最後返回 vao
。這個 vao
將會被用於繪製三角形!
回到 main
函數:
func main() {
...
vao := makeVao(triangle)
for !window.ShouldClose() {
draw(vao, window, program)
}
}
這裡我們調用了 `makeVao` ,從我們之前定義的 `triangle` 頂點中獲得 `vao` 引用,將它作為一個新的參數傳遞給 `draw` 函數:
func draw(vao uint32, window *glfw.Window, program uint32) {
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.UseProgram(program)
gl.BindVertexArray(vao)
gl.DrawArrays(gl.TRIANGLES, 0, int32(len(triangle) / 3))
glfw.PollEvents()
window.SwapBuffers()
}
然後我們把 OpenGL 綁定到 vao
上,這樣當我們告訴 OpenGL 三角形切片的頂點數(除以 3,是因為每一個點有 X、Y、Z 坐標),讓它去 DrawArrays
,它就知道要畫多少個頂點了。
如果你這時候運行程序,你可能希望在窗口中央看到一個美麗的三角形,但是不幸的是你還看不到。還有一件事情沒做,我們告訴 OpenGL 我們要畫一個三角形,但是我們還要告訴它怎麼畫出來。
要讓它畫出來,我們需要叫做 片元著色器 和 頂點著色器 的東西,這些已經超出本教程的範圍了(老實說,也超出了我對 OpenGL 的了解),但 Harold Serrano 在 Quora 上對對它們是什麼給出了完美的介紹。我們只需要理解,對於這個應用來說,著色器是它內部的小程序(用 OpenGL Shader Language 或 GLSL 編寫的),它操作頂點進行繪製,也可用於確定圖形的顏色。
添加兩個 import
和一個叫做 compileShader
的函數:
import (
"strings"
"fmt"
)
func compileShader(source string, shaderType uint32) (uint32, error) {
shader := gl.CreateShader(shaderType)
csources, free := gl.Strs(source)
gl.ShaderSource(shader, 1, csources, nil)
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)
log := strings.Repeat("x00", int(logLength+1))
gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log))
return 0, fmt.Errorf("failed to compile %v: %v", source, log)
}
return shader, nil
}
這個函數的目的是以字元串的形式接受著色器源代碼和它的類型,然後返回一個指向這個編譯好的著色器的指針。如果編譯失敗,我們就會獲得出錯的詳細信息。
現在定義著色器,在 makeProgram
里編譯。回到我們的 const
塊中,我們在這裡定義了 width
和 hegiht
。
vertexShaderSource = `
#version 410
in vec3 vp;
void main() {
gl_Position = vec4(vp, 1.0);
}
` + "x00"
fragmentShaderSource = `
#version 410
out vec4 frag_colour;
void main() {
frag_colour = vec4(1, 1, 1, 1);
}
` + "x00"
如你所見,這是兩個包含了 GLSL 源代碼字元串的著色器,一個是 頂點著色器 ,另一個是 片元著色器 。唯一比較特殊的地方是它們都要在末尾加上一個空終止字元,x00
—— OpenGL 需要它才能編譯著色器。注意 fragmentShaderSource
,這是我們用 RGBA 形式的值通過 vec4
來定義我們圖形的顏色。你可以修改這裡的值來改變這個三角形的顏色,現在的值是 RGBA(1, 1, 1, 1)
或者說是白色。
同樣需要注意的是這兩個程序都是運行在 #version 410
版本下,如果你用的是 OpenGL 2.1,那你也可以改成 #version 120
。這裡 120
不是打錯的,如果你用的是 OpenGL 2.1,要用 120
而不是 210
!
接下來在 initOpenGL
中我們會編譯著色器,把它們附加到我們的 program
中。
func initOpenGL() uint32 {
if err := gl.Init(); err != nil {
panic(err)
}
version := gl.GoStr(gl.GetString(gl.VERSION))
log.Println("OpenGL version", version)
vertexShader, err := compileShader(vertexShaderSource, gl.VERTEX_SHADER)
if err != nil {
panic(err)
}
fragmentShader, err := compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER)
if err != nil {
panic(err)
}
prog := gl.CreateProgram()
gl.AttachShader(prog, vertexShader)
gl.AttachShader(prog, fragmentShader)
gl.LinkProgram(prog)
return prog
}
這裡我們用頂點著色器(vertexShader
)調用了 compileShader
函數,指定它的類型是 gl.VERTEX_SHADER
,對片元著色器(fragmentShader
)做了同樣的事情,但是指定的類型是 gl.FRAGMENT_SHADER
。編譯完成後,我們把它們附加到程序中,調用 gl.AttachShader
,傳遞程序(prog
)以及編譯好的著色器作為參數。
現在我們終於可以看到我們漂亮的三角形了!運行程序,如果一切順利的話你會看到這些:
總結
是不是很驚喜!這些代碼畫出了一個三角形,但我保證我們已經完成了大部分的 OpenGL 代碼,在接下來的章節中我們還會用到這些代碼。我十分推薦你花幾分鐘修改一下代碼,看看你能不能移動三角形,改變三角形的大小和顏色。OpenGL 可以令人心生畏懼,有時想要理解發生了什麼很困難,但是要記住,這不是魔法 - 它只不過看上去像魔法。
下一節里我們講會用兩個銳角三角形拼出一個方形 - 看看你能不能在進入下一節前試著修改這一節的代碼。不能也沒有關係,因為我們在 第二節 還會編寫代碼, 接著創建一個有許多方形的格子,我們把它當做遊戲面板。
最後,在第三節 里我們會用格子來實現 Conway』s Game of Life!
回顧
本教程 main.go
文件的內容如下:
package main
import (
"fmt"
"log"
"runtime"
"strings"
"github.com/go-gl/gl/v4.1-core/gl" // OR: github.com/go-gl/gl/v2.1/gl
"github.com/go-gl/glfw/v3.2/glfw"
)
const (
width = 500
height = 500
vertexShaderSource = `
#version 410
in vec3 vp;
void main() {
gl_Position = vec4(vp, 1.0);
}
` + "x00"
fragmentShaderSource = `
#version 410
out vec4 frag_colour;
void main() {
frag_colour = vec4(1, 1, 1, 1.0);
}
` + "x00"
)
var (
triangle = []float32{
0, 0.5, 0,
-0.5, -0.5, 0,
0.5, -0.5, 0,
}
)
func main() {
runtime.LockOSThread()
window := initGlfw()
defer glfw.Terminate()
program := initOpenGL()
vao := makeVao(triangle)
for !window.ShouldClose() {
draw(vao, window, program)
}
}
func draw(vao uint32, window *glfw.Window, program uint32) {
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.UseProgram(program)
gl.BindVertexArray(vao)
gl.DrawArrays(gl.TRIANGLES, 0, int32(len(triangle)/3))
glfw.PollEvents()
window.SwapBuffers()
}
// initGlfw 初始化 glfw 並返回一個窗口供使用。
func initGlfw() *glfw.Window {
if err := glfw.Init(); err != nil {
panic(err)
}
glfw.WindowHint(glfw.Resizable, glfw.False)
glfw.WindowHint(glfw.ContextVersionMajor, 4)
glfw.WindowHint(glfw.ContextVersionMinor, 1)
glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile)
glfw.WindowHint(glfw.OpenGLForwardCompatible, glfw.True)
window, err := glfw.CreateWindow(width, height, "Conway's Game of Life", nil, nil)
if err != nil {
panic(err)
}
window.MakeContextCurrent()
return window
}
// initOpenGL 初始化 OpenGL 並返回一個已經編譯好的著色器程序
func initOpenGL() uint32 {
if err := gl.Init(); err != nil {
panic(err)
}
version := gl.GoStr(gl.GetString(gl.VERSION))
log.Println("OpenGL version", version)
vertexShader, err := compileShader(vertexShaderSource, gl.VERTEX_SHADER)
if err != nil {
panic(err)
}
fragmentShader, err := compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER)
if err != nil {
panic(err)
}
prog := gl.CreateProgram()
gl.AttachShader(prog, vertexShader)
gl.AttachShader(prog, fragmentShader)
gl.LinkProgram(prog)
return prog
}
// makeVao 執行初始化並從提供的點裡面返回一個頂點數組
func makeVao(points []float32) uint32 {
var vbo uint32
gl.GenBuffers(1, &vbo)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.BufferData(gl.ARRAY_BUFFER, 4*len(points), gl.Ptr(points), gl.STATIC_DRAW)
var vao uint32
gl.GenVertexArrays(1, &vao)
gl.BindVertexArray(vao)
gl.EnableVertexAttribArray(0)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 0, nil)
return vao
}
func compileShader(source string, shaderType uint32) (uint32, error) {
shader := gl.CreateShader(shaderType)
csources, free := gl.Strs(source)
gl.ShaderSource(shader, 1, csources, nil)
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)
log := strings.Repeat("x00", int(logLength+1))
gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log))
return 0, fmt.Errorf("failed to compile %v: %v", source, log)
}
return shader, nil
}
請在 Twitter @kylewbanks 上告訴我這篇文章對你是否有幫助,或者點擊下方的關注,以便及時獲取最新文章!
via: https://kylewbanks.com/blog/tutorial-opengl-with-golang-part-1-hello-opengl
作者:kylewbanks 譯者:GitFuture 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive