Linux中國

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 上獲得,當你有疑惑的時候可以隨時查看源代碼,或者你要按照自己的方式學習也可以參考這個代碼。

在我們開始之前,我們要先弄明白 康威生命遊戲 Conway's Game of Life 到底是什麼。這裡是 Wikipedia 上面的總結:

《生命遊戲》,也可以簡稱為 Life,是一個細胞自動變化的過程,由英國數學家 John Horton Conway 於 1970 年提出。

這個「遊戲」沒有玩家,也就是說它的發展依靠的是它的初始狀態,不需要輸入。用戶通過創建初始配置文件、觀察它如何演變,或者對於高級「玩家」可以創建特殊屬性的模式,進而與《生命遊戲》進行交互。

規則

《生命遊戲》的世界是一個無窮多的二維正交的正方形細胞的格子世界,每一個格子都有兩種可能的狀態,「存活」或者「死亡」,也可以說是「填充態」或「未填充態」(區別可能很小,可以把它看作一個模擬人類/哺乳動物行為的早期模型,這要看一個人是如何看待方格里的空白)。每一個細胞與它周圍的八個細胞相關聯,這八個細胞分別是水平、垂直、斜對角相接的。在遊戲中的每一步,下列事情中的一件將會發生:

  1. 當任何一個存活的細胞的附近少於 2 個存活的細胞時,該細胞將會消亡,就像人口過少所導致的結果一樣
  2. 當任何一個存活的細胞的附近有 2 至 3 個存活的細胞時,該細胞在下一代中仍然存活。
  3. 當任何一個存活的細胞的附近多於 3 個存活的細胞時,該細胞將會消亡,就像人口過多所導致的結果一樣
  4. 任何一個消亡的細胞附近剛好有 3 個存活的細胞,該細胞會變為存活的狀態,就像重生一樣。

不需要其他工具,這裡有一個我們將會製作的演示程序:

Conway's Game of Life - 示例遊戲

在我們的運行過程中,白色的細胞表示它是存活著的,黑色的細胞表示它已經死亡。

概述

本教程將會涉及到很多基礎內容,從最基本的開始,但是你還是要對 Go 由一些最基本的了解 —— 至少你應該知道變數、切片、函數和結構體,並且裝了一個 Go 的運行環境。我寫這篇教程用的 Go 版本是 1.8,但它應該與之前的版本兼容。這裡用 Go 語言實現沒有什麼特別新奇的東西,因此只要你有過類似的編程經歷就行。

這裡是我們在這個教程里將會講到的東西:

最後的源代碼可以在 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
}

好了,讓我們花一分鐘來運行一下這個程序,看看會發生什麼。首先定義了一些常量, widthheight —— 它們決定窗口的像素大小。

然後就是 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 庫,創建一個 程序 program 。「程序」是一個包含了 著色器 shader 的引用,稍後會用 著色器 shader 繪圖。待會兒會講這一點,現在只用知道 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 函數。

好了,到這裡我們已經講了很多東西,花一點時間看看我們的實驗成果。運行這個程序,你應該可以看到你所繪製的第一個東西:

Conway's Game of Life - 第一個窗口

完美!

在窗口裡繪製三角形

我們已經完成了一些複雜的步驟,即使看起來不多,但我們仍然需要繪製一些東西。我們會以三角形繪製開始,可能這第一眼看上去要比我們最終要繪製的方形更難,但你會知道這樣的想法是錯的。你可能不知道的是三角形或許是繪製的圖形中最簡單的,實際上我們最終會用某種方式把三角形拼成方形。

好吧,那麼我們想要繪製一個三角形,怎麼做呢?我們通過定義圖形的頂點來繪製圖形,把它們交給 OpenGL 來進行繪製。先在 main.go 的頂部里定義我們的三角形:

var (
    triangle = []float32{
        0, 0.5, 0, // top
        -0.5, -0.5, 0, // left
        0.5, -0.5, 0, // right
    }
)

這看上去很奇怪,讓我們分開來看。首先我們用了一個 float32 切片 slice ,這是一種我們總會在向 OpenGL 傳遞頂點時用到的數據類型。這個切片包含 9 個值,每三個值構成三角形的一個點。第一行, 0, 0.5, 0 表示的是 X、Y、Z 坐標,是最上方的頂點,第二行是左邊的頂點,第三行是右邊的頂點。每一組的三個點都表示相對於窗口中心點的 X、Y、Z 坐標,大小在 -11 之間。因此最上面的頂點 X 坐標是 0,因為它在 X 方向上位於窗口中央,Y 坐標是 0.5 意味著它會相對窗口中央上移 1/4 個單位(因為窗口的範圍是 -11),Z 坐標是 0。因為我們只需要在二維空間中繪圖,所以 Z 值永遠是 0。現在看一看左右兩邊的頂點,看看你能不能理解為什麼它們是這樣定義的 —— 如果不能立刻就弄清楚也沒關係,我們將會在屏幕上去觀察它,因此我們需要一個完美的圖形來進行觀察。

好了,我們定義了一個三角形,但是現在我們得把它畫出來。要畫出這個三角形,我們需要一個叫做 頂點數組對象 Vertex Array Object 或者叫 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
}

首先我們創造了 頂點緩衝區對象 Vertex Buffer Object 或者說 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 我們要畫一個三角形,但是我們還要告訴它怎麼畫出來。

要讓它畫出來,我們需要叫做 片元著色器 fragment shader 頂點著色器 vertex shader 的東西,這些已經超出本教程的範圍了(老實說,也超出了我對 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 塊中,我們在這裡定義了 widthhegiht

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 源代碼字元串的著色器,一個是 頂點著色器 vertex shader ,另一個是 片元著色器 fragment shader 。唯一比較特殊的地方是它們都要在末尾加上一個空終止字元,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)以及編譯好的著色器作為參數。

現在我們終於可以看到我們漂亮的三角形了!運行程序,如果一切順利的話你會看到這些:

Conway's Game of Life - Hello, Triangle!

總結

是不是很驚喜!這些代碼畫出了一個三角形,但我保證我們已經完成了大部分的 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

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國