走進現代OpenGL

作爲前言,這是一篇非常長非常長的文章——在文末,你會得到一個純色三角形,這將經歷非常漫長的努力,甚至你還會覺得氣餒,因爲花費一整天畫了個平面三角形。本文呢包括如何準備VS2019,如何開始第一個HelloWorld,渲染管線的簡介.......你需要自行學習完畢C語言,全文的代碼是C++編譯通過的,因此,你或許還需要一丁點的C++知識,但我保證不多。最後,你需要理解Win32開發裏面的東西——資源文件、預編譯頭、按鍵消息,這些有助於你對代碼的領悟,請務必熟悉基本的Win32開發,接下來將使用它的知識。最後的最後,請不要在乎最終程序的效率,我知道你會抱怨它那個while看着開銷大上天,總而言之我們纔剛開始,好了,開始吧。

對了一點點約定,我用粉色表示我認爲關鍵的地方,用藍色表示一些無所謂的強調,用綠色表示一些關鍵概念。如果有加粗,那當然是更重要啦~API名稱或者宏定義會使用紫色或者是超鏈接(超鏈接意味着它指向一篇文檔)。如果你討厭這長透頂的篇幅,或者你準備好了某些東西,可以直接跳轉:

目錄

0. OpenGL?

1. 準備你的Visual Studio,創建項目

2. 添加glfw。

3. 添加glad

4. 創建窗口

5. 純色背景

6. 準備渲染

6.1 渲染管線

6.2 頂點輸入與標準化設備座標

6.3 準備頂點數組

6.4 準備頂點着色器

6.5 準備片段着色器

6.6 鏈接着色器程序

6.7 對接頂點數據到頂點着色器

6.8 頂點數組對象

7. 繪製三角形

8. 結語


0. OpenGL?

有點好奇各位是怎麼知道它的呢。

它其實是一個API規範,它僅僅是一個由Khronos組織制定並維護的規範(Specification)。這套規範描述了每個函數的具體任務、返回值等等,負責編寫OpenGL庫的人將會按照它實現。我們將使用的4.0版本也有着規範文檔,可以點開來看看。OpenGL可以渲染3D圖形,我們也會用它來渲染3D圖形。

這些簡介不再說了。

1. 準備你的Visual Studio,創建項目

雖然這是VS2019的例子,但是它依然通用於其它的IDE。在此之前,請新建一個空項目,注意什麼也不要包含。在VS2019裏面,你需要選擇這個,注意在後續步驟中一定不要包含任何代碼:

創建完成之後,項目中應該是沒有任何代碼的。

接下來的這步可以省略——如果看不懂或者之類的......設置預編譯頭,我最開始學習C/C++的時候就愛上了預編譯頭這玩意,總而言之看着很舒服就對了。添加pch.h和pch.cpp,把項目(注意有Debug和Release兩個配置,都要設置,下面的子系統選擇也是一樣的)的預編譯頭設置爲"使用",選擇pch.cpp,把它的預編譯頭選擇爲"創建"。

關鍵一步,我想你不會愛那個黑色的控制檯窗口,因此,我們要更改子系統:在項目上右鍵單擊,選擇屬性 -> 鏈接器 -> 所有選項,找到子系統,把它改爲窗口:

子系統配置

這樣,我們的入口點函數就從main變成了winMain。在配置屬性 -> 高級裏面,你可以看到你的項目用的字符集,一般是Unicode。這意味着我們的入口點函數名稱叫做wWinMain。更改Debug和Release的配置都爲這個,然後添加一個main.cpp文件,開始編寫你的代碼。需要注意的是,因爲我喜歡預編譯頭,所以我#incldue的是pch.h,如果不會配置它或者不想使用它,把它更換成你自己需要包含的頭文件就是:

#include "pch.h"
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    return 0;
}

編譯試試?如果編譯通過了,那麼上面的所有步驟表示正確。如果你收到了無法重定位函數、無法找到符號"_XXXXX"這類的鏈接器錯誤,說明你沒有編寫正確的入口點函數。請檢查你的步驟,如果實在無法,那麼你可以丟棄掉上面的步驟——你依然可以使用傳統的方式,用main函數作爲入口點函數。這沒什麼大不了的,除了那個黑窗口。

到此,最開始的配置就完成了。

2. 添加glfw。

在繼續之前,我想說一個無奈的事實,微軟一直很推薦自家的DirectX,因此OpenGL總是被當作撿來的一樣對待——不過OpenGL有着強硬的地位,因此各大顯卡廠家都支持它。在Windows下,OpenGL的版本是1.0(還是1.1,我忘記了),這意味着哪怕你在用着Windows 10,上面可訪問的OpenGL版本依然是20年前的玩意。因此,我們需要glad——用它們去用顯卡廠商給的新版本API。

首先,你需要準備glfw——在這裏去下載glfw。爲了省略構建的煩惱,請直接下載編譯好的版本。也就是這個:

GLfw的選擇

可能你在猜測我想要64位的可以嗎?這當然是可以的,不過,要注意你在VS裏面的配置,也要用64位。不過用作學習,32位也差不多夠了,因此我們選擇32位。

把它放在你的VS的包含目錄下。或者把它扔到你的項目的源代碼目錄下去,那裏默認是一個包含目錄。具體的來說,你需要下面這些東西,點開壓縮包:

glfw目錄下的東西

然後,把include目錄下的內容丟到你的項目中去,選擇合適自己的版本,這裏是vs2019——按照自己的需求選擇。在lib-vc2019下有三個文件,一個是glfw3.lib, 一個是glfw3dll.lib,和一個glfw3.dll。glfw3.lib是靜態庫版本,另外的一個lib是dll所帶的那個。在這裏我選擇了dll,你可以選擇靜態庫版本,這沒什麼。把對應的lib添加到你的項目中,這樣就完成了。作爲測試,你可以試試編譯下面的代碼:

#include "pch.h"
#include "GLFW/glfw3.h"
#pragma comment(lib, "glfw3dll.lib")
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    return 0;
}

我強調了glfw和glfw3dll.lib,在這之後,你不會看見它們,不過要記住,那個pch.h中是一直有着這兩個的。如果編譯通過,說明上面的步驟全部正確,如果沒有通過(比如出現找不到什麼什麼的錯誤之類),請檢查你的VC++目錄,保證編譯器和鏈接器可以找到那幾個文件。自此,glfw添加完成了。

3. 添加glad

同樣的,我們在網上下載glad。glad的底層依然是調用Windows的API去取得對應函數的地址。前面說過,這些API由顯卡廠家負責,而且OpenGL常年在Windows上處於撿來的地位,因此,我們無法直接訪問它們。去這裏下載GLad。glad可以方便的幫我們搞定這個取得API的步驟。如果你好奇——在Windows上,這可以通過wglGetProcAddress完成。點開那個頁面後,你會看到下面的配置選項,按照截圖所示的配置,修改紅框中的內容,其餘都不動:

GLad配置

然後點擊"GENERATE",它在頁面最下面。對了,有個"Generate a loader"選項,一定保證它勾選上。

在這之後,有個zip文件,選擇它即可。

glad.zip

解壓之後,你會看到兩個文件夾:include和src。把src目錄下的所有文件添加到你的項目中去,把include中的所有東西添加到你的項目中。在這之後,你的項目中應該有兩個源文件:main.cpp和glad.c,你可以選擇把glad.c的名字改成glad.cpp,這沒有任何影響。如果你有預編譯頭,你還應該有一個pch.cpp,不過這無關緊要。作爲測試,可以試試如下的代碼:

#include "pch.h"
#include "GLFW/glfw3.h"
#include "glad/glad.h"
#pragma comment(lib, "glfw3dll.lib")
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    return 0;
}

如果編譯成功,說明之前所有步驟都正確了,如果失敗,那麼參照第二小節,把問題解決好。在這你可能遇到的錯誤是無法打開文件"khrplatform.h",並且會攜帶非常多的符號未定義錯誤,這是因爲我們沒有把它丟到VS默認的VC++編譯器的包含目錄下的關係,如果你真的遇到了,請打開glad.h,把裏面的#include <KHR/khrplatform.h>更改成正確的文件位置。它在include/KHR目錄下。如果你收到了沒有預編譯頭的錯誤,這多半是glad.c導致的,請修改它,爲它添加預編譯頭

4. 創建窗口

一切準備就緒,我們可以開始創建窗口了。因爲我使用了預編譯頭,所以有必要展示一下pch.h裏面的內容,它是:

#pragma once
#include <stdio.h>
#include <tchar.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <locale.h>
#include <Windows.h>
#include "glad/glad.h"
#include "GLFW/glfw3.h"
#pragma comment(lib, "opengl32.lib")
#pragma comment(lib, "glfw3dll.lib")

在之後的文章中,我默認你知道這個預編譯頭的內容。這其中一些include是無所謂的,我只是猜測它們或許會被用到。好啦,所以接下來的代碼只會有一個空落落的#include "pch.h",不要忘記它裏面包含了好些頭文件

我們需要使用下面的代碼去實例化窗口,然後創建並初始化glad。所有的信息都編寫在了註釋裏面:

#include "pch.h"
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    GLFWwindow  *win;
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);              // OpenGL 4.x
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    if ((win = glfwCreateWindow(480, 320, "Graph3D", NULL, NULL) ) == NULL)
    {                                                           // 創建窗口
        // 無法創建窗口
        goto err;
    }
    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, keyInput);
    glfwSetFramebufferSizeCallback(win, framebufferResize);
    if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0)// 初始化glad
    {
        // 無法初始化GLad, 請檢查您的設備是否支持OpenGL 4.0
        goto err;
    }
    initRender();                                               // 初始化渲染
    while (glfwWindowShouldClose(win) == 0)
    {
        renderLoop();                                           // 渲染圖形
        glfwPollEvents();                                       // 檢查, 觸發事件
        glfwSwapBuffers(win);
    }
err:
    glfwTerminate();                                            // 退出
    return 0;
}

上面的代碼有兩個回調函數,分別處理窗口尺寸變化和按鍵輸入。在前面幾行,我們初始化glfw,然後暗示glfw的OpenGL版本是4.x,最低版本要求也是4.x,當然,你可以指定別的。接着,我們表示我們的配置是CORE(如果你還記得,我們的glad是Core配置的)。接着,創建窗口,Graph3D是窗口標題,保險起見,用英文,設置當前上下文。

接下來,我們設置回調函數——一個是按鍵響應,一個是窗口尺寸改變響應。按鍵響應的回調函數看起來是下面這樣的,關於它的詳細解釋會在需要用到的時候補充,目前來說,我處理了鬆開鍵盤上Esc按鍵的情況——這個動作之後,程序將退出

void keyInput(GLFWwindow *win, int key, int scancode, int action, int mods)
{
    if (action == GLFW_RELEASE)                             // 鬆開按鍵
        if (key == GLFW_KEY_ESCAPE)
        {
            glfwSetWindowShouldClose(win, true);            // 告訴glfw應該關閉窗口
        }
}

尺寸改變中有一個glViewport,它更改OpenGL的渲染區域大小。如果你好奇去掉會怎麼樣,請不要慌,到最終我們得到那個三角形的時候,在來試試去掉它會怎麼樣:

void framebufferResize(GLFWwindow* win, int w, int h)
{
    glViewport(0, 0, w, h);
}

接着,我們初始化glad。然後初始化渲染:

void initRender()
{

}

這看起來什麼也沒有做,當然什麼也沒做,畢竟都沒寫。它是爲了我們之後編寫方便的——我討厭一天沒事老在入口點函數裏面折騰。而且這種結構看起也有利於我們寫程序。良好的結構應該從此時開始,如果你以前從不這樣

最後是一個while循環,姑且叫做渲染循環吧,所有的繪圖工作都在這完成——如果有記得,我希望不要抱怨效率,這個while看着就覺得很討厭。循環內調用了繪圖函數:

void renderLoop()
{


}

當然什麼也沒做,畢竟都沒寫。同樣的,它是爲了我們之後寫程序方便。

渲染循環的末尾處理了積累下來的消息,然後用glfwSwapBuffers交換緩衝區——這裏解釋一下。一般來說,如果我們直接在屏幕上面刷圖形,效果是很差的,因爲圖形是慢慢一步步繪製出來的,人眼對此很敏感,你會看到圖像閃爍(這點在做動態效果的時候尤其顯著,比如我們繪製音樂頻譜,它跳來跳去更新很快,這樣直接繪製就會看到圖案一會兒閃一次一會兒閃一次)。解決之道是把圖案畫在B上,然後繪製完成後,把B一次性粘貼到屏幕上,這樣一來,我們就感覺不到圖像的閃爍(比起繪製,這個過程很快而且更自然些),這項技術被稱作雙緩衝,在緩衝區A上繪製好,然後交換緩衝區,這樣就把繪製的圖案顯示出來了

在最後,渲染循環退出(即glfwWindowShouldClose爲true,表示窗口要關閉了)之後,我們使用glfwTerminate釋放掉所有資源,自此,程序結束。組合上面的代碼,你應該得到下面的結果:

結果

一個黑巴巴,無聊透頂的窗口。

如果你沒有得到這個純色背景的窗口,或者不知道組合上面的代碼,可以參考我的這份。目前來說不要灰心,我知道做了這麼多努力最後得到個黑色的玩意很難受。我添加了一個MessageBox用於錯誤信息提示,並且,我給項目裏面添加了一個資源文件,之後的着色器腳本會放到資源文件裏面,你也應該準備下,並且,我添加了調試模式下的控制檯,這可以讓你在Debug配置下看到一個控制檯窗口,並且可以使用printf等在上面輸出內容。當然,你可以扔掉它。這不是必須的

#include "pch.h"
#include "resource.h"
void  framebufferResize(GLFWwindow *, int, int);
void  keyInput(GLFWwindow *win, int key, int scancode, int action, int mods);
void  initRender();
void  renderLoop();
void  popMessageBox(const TCHAR *format, ...);
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    GLFWwindow  *win;
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);                 // OpenGL 4.x
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#if _DEBUG
    FILE *ous;
    AllocConsole();
    setlocale(LC_CTYPE, "chs");                                    // 中文字符集
    freopen_s(&ous, "CONOUT$", "w", stdout);                       // 輸出重定向
#endif
    if ((win = glfwCreateWindow(480, 320, "Graph3D", NULL, NULL) ) == NULL)
    {
        popMessageBox(_T("無法創建窗口"));
        goto err;
    }
    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, keyInput);
    glfwSetFramebufferSizeCallback(win, framebufferResize);
    if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0)   // 初始化glad
    {
        popMessageBox(_T("無法初始化GLad, 請檢查您的設備是否支持OpenGL 4.0"));
        goto err;
    }
    initRender();
    while (glfwWindowShouldClose(win) == 0)
    {
        renderLoop();
        glfwPollEvents();                                          // 檢查, 觸發事件
        glfwSwapBuffers(win);
    }
err:
    glfwTerminate();
#if _DEBUG
    FreeConsole();
#endif
    return 0;
}
// 窗口尺寸被改變
void framebufferResize(GLFWwindow* win, int w, int h)
{
    glViewport(0, 0, w, h);
}
// 鍵盤輸入
void keyInput(GLFWwindow *win, int key, int scancode, int action, int mods)
{
    if (action == GLFW_RELEASE)                                    // 鬆開按鍵
        if (key == GLFW_KEY_ESCAPE)
        {
            glfwSetWindowShouldClose(win, true);                   // 告訴glfw應該關閉窗口
        }
}
// 初始化
void initRender()
{

}
// 渲染循環
void renderLoop()
{

}
// 彈出一條消息
void popMessageBox(const TCHAR *format, ...)
{
    va_list ap;
    TCHAR   msg[512] = { 0 };
    va_start(ap, format);
#if _UNICODE
    vswprintf_s(msg, format, ap);
    MessageBoxW(0, msg, L"Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#else
    vsprintf_s(msg, format, ap);
    MessageBoxA(0, msg, "Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#endif // _UNICODE
    va_end(ap);
}

自此,創建窗口成功了。接下來我們要在上面繪製東西。出於我們程序的結構,接下來的內容只會修改initRender()函數和renderLoop()結構,因此,之後的代碼也只會展示這兩個結構。

出於廣泛的考慮,或許有讀者在使用老一些的設備,如果你使用了我的這段代碼,編譯通過,但是運行失敗,而且找不到解決之道,或許就該考慮版本問題了。在有控制檯的情況下,用下面的代碼查看一下你的OpenGL版本號。請把它放到glad初始化完成的後面。

printf("%s\n", glGetString(GL_VERSION));

檢查你的版本號,保證它是4.0及以上版本。

如果你運行的提示信息表示找不到XXX.dll,請把對應的dll放到你的exe目錄下。對於VS,應該準備一個dll在項目的源代碼目錄下。典型的問題是找不到glfw3.dll,這個dll在我們下載的glfw壓縮包裏面。把它放到合適的位置。

5. 純色背景

這個黑色的窗口當然很無聊。我們改一下背景顏色吧???

在每次迭代新開始的時候,我們總是希望清空屏幕。我知道你又要開始抱怨效率了,還是那句話,我們纔剛開始,不要關注效率。這樣全部清空的目的是爲了下次繪製的時候能夠在一個乾淨的地方繪製。

OpenGL類似於一個超大的狀態機——使用一些API設置狀態,然後另一些API按照這些狀態進行繪圖等等。這點要理解和牢記,因爲我們馬上開始使用OpenGL繪圖。爲了清空背景,我們使用glClear函數完成清空工作。這個函數的參數是一個標誌位,它表示我們希望清除什麼東西。有GL_COLOR_BUFFER_BITGL_DEPTH_BUFFER_BITGL_STENCIL_BUFFER_BIT。因爲現在我們只關心顏色問題,所以我們只用GL_COLOR_BUFFER_BIT。修改renderLoop:

void renderLoop()
{
    glClearColor(0.2f, 0.3f, 0.5f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
}

編譯運行,你應該可以看到一個深藍色背景的窗口,它看着應該是這樣的:

深藍色的背景

這段代碼可以理解爲glClearColor設置了一個狀態,然後glClear按照這個狀態去清空屏幕。可以把顏色換成自己喜歡的顏色,glClearColor的參數從左到右分別對應RGBA,最小值爲0,最大值爲1。

6. 準備渲染

OpenGL本身是可以渲染3D圖形的,現實中的物體是客觀地擁有長寬高屬性的,但是出現在人眼中不一樣——它總是一個二維的投影,對於畫家的畫也是如此,我們會覺得它有立體感,但不可否認,那張畫就是個平面的玩意。我們的顯示屏可不是三維的玩意,它只能顯示二維圖案。OpenGL可以接受三維座標,它需要經過一系列操作,才能得到一個正確的二維座標,負責這套操作的是渲染管線,它其實就像一條流水線,把三維座標送進去,期間經過各種各樣的處理,最終得到二維座標。作爲約定,下文中的座標表示一個真實的座標,它可以是1.25之類的,而像素座標(強調像素了)表示在屏幕上的座標,它總是整數

6.1 渲染管線

現在的顯卡中存在着非常大量的內核,動則幾百上千,這些內核中會運行着渲染管線上的各種變換,從而高效的幫你完成圖形呈現工作。在早期的OpenGL中(1.x版本),這些操作是固定的,稱之爲固定管線。現在一般是可編程管線,即這套操作是我們可以自己定義的。這聽起來頗爲麻煩,但卻更靈活——例如Minecraft遊戲的光影,其實就是大量的對這套操作進行新的定義。

這些GPU內核上跑着的小程序被叫做着色器,GPU沒有默認的着色器,因此我們要自己寫。OpenGL的着色器通過GLSL(OpenGL Shading Language)寫成。它酷似C語言,在不久後我們將見到它。渲染管線看起來像這樣:

渲染管線

可以看到它包含很多部分,藍色的部分是我們可以(也必須準備至少一個)自己編寫的。頂點數據描述了三維座標信息。它是一個數組,數組裏面的元素被叫做頂點。它使用頂點屬性來描述。頂點屬性可以包含很多東西,比如座標和顏色。爲了簡單考慮,假如送進去的三個頂點包含了座標和顏色。

首先進行處理的是頂點着色器,它輸入一個三維座標(回憶我們之前說的小程序,它們在各個核心上"獨立"運行,因此會有三個小程序一起處理這三個頂點),然後輸出另外一個三維座標。它們有差異,在之後我們將親自編寫一個簡單的頂點着色器程序,你將見到這個差異。

第二步進行圖元裝配,可以看到這三個頂點組成了一個三角形。注意,OpenGL不知道你輸入的東西要變成什麼樣,因此你需要指定它。比如GL_POINTSGL_TRIANGLESGL_LINE_STRIP......

緊接着,數據進入幾何着色器,它可以按照輸入的頂點構造一些新的圖案。比如我們送進去的是個正方形,但是這個正方形是略微側視的——這時,或許我們就需要兩個小平行四邊形來代替它了。通常我們不需要編寫幾何着色器,使用默認的就好。在圖上的例子,三角形變成了兩個三角形。

光柵化將幾何着色器輸出變爲具體的像素座標,它已經對應到屏幕上的像素點了。在送入片段着色器之前,超過顯示範圍的像素將被裁剪。

然後,片段着色器將對每個像素進行着色,出於此,它的英文翻譯有時候會變成像素着色器。它們是一個東西。在這裏,所有漂亮的元素會產生,例如光照、陰影、紋理等等

最後的測試與混合將對各個結果進行處理,按照透明度設置,把不可見的部分丟掉(考慮兩個正方體,第一個把第二個擋住了,很顯然第二個正方體不應該出現在屏幕上)等等

至此,你看見了屏幕上出現的圖案。爲了加強理解,我們可以想象:頂點着色器把腦海裏的正方體變成了空間中具體的座標,而圖元裝配則告訴我們這個正方體的輪廓。緊接着,幾何着色器將按照正等軸測圖之類的投影圖方式把正方體映射到紙上,光柵化使得正方體的圖映射到實際中紙張上的位置,丟棄掉紙張外的部分,我們使用片段着色器對正方體上色、補上陰影,最後把它和其它的畫疊在一起。

現代OpenGL中,我們只需要自己完成頂點着色器和片段着色器。幾何着色器通常是默認的。這段內容非常多且複雜,靜下心來親。

6.2 頂點輸入與標準化設備座標

在得到一個三角形之前,我們需要準備好頂點。頂點是三維的,包括X, Y, Z三個座標

float vetPos[] = 
{/*  X      Y     Z   */
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

因爲我們目前來說想得到一個2D的三角形,因此我把Z座標設置爲了0。這樣每個頂點的深度(可以理解爲Z座標,即Depth,它表示頂點離屏幕的距離,離得很遠就不一定看得到了)都一樣,這樣它就是個平面的。目前來說,不要氣餒,挺過這些,再玩3D就簡單很多了。

頂點們被送入頂點着色器,頂點着色器的輸出應該是在標準化設備座標上的,不在這上面的頂點會被丟棄。在OpenGL裏面,它和你的渲染窗口對應:

標準化設備座標

最左邊是-1.0,最右邊是1.0,這是X軸。最上面是1.0,最下面是-1.0,這是Y軸,注意它和屏幕座標是相反的。Z軸座標也是-1到1這個範圍的。同樣的,原點位置也在窗口客戶區中心,而不是左上角。頂點着色器輸出會按照你的glViewport提供的數據進行變換,得到對應的屏幕空間座標,然後才送入後面的步驟

6.3 準備頂點數組

我們需要把它作爲輸入發送給頂點着色器。它會在GPU上創建內存,用於儲存頂點數據,當然,我們還要配置OpenGL如何解釋它們,並且指定其如何發送給顯卡。頂點着色器會處理在內存中指定數量的頂點。這些通過頂點緩衝對象VBO(Vertex Buffer Objects)完成。它會存放非常多的頂點數據,然後一次性將這大批數據送往顯卡。我們通過glGenBuffers去創建一個緩衝,創建之後,我們用glBindBuffer綁定到頂點緩衝,最後使用glBufferData把頂點數組刷進去:

// 初始化
float vetPos[] = 
{/*  X      Y     Z   */
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
GLuint  hVBO;
#define VBO_ID      1
// 下面代碼在initRender()中
glGenBuffers(VBO_ID, &hVBO);                          // 創建並綁定頂點緩衝
glBindBuffer(GL_ARRAY_BUFFER, hVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vetPos), vetPos, GL_STATIC_DRAW);

glBufferData的最後一個參數表明我們希望顯卡如何管理數據,GL_STATIC_DRAW表示數據基本上不可能改變,GL_DYNAMIC_DRAW表示數據經常會被更改,GL_STREAM_DRAW表示數據每次繪製都會被更新。我們應該按照頂點數據實際的情況來選擇。這個例子中,三角形的頂點數據壓根不會被修改,所以我們使用GL_STATIC_DRAW

6.4 準備頂點着色器

頂點着色器是我們必須自己準備的東西之一。目前來說,討論它太早了,因此我們只做個大概瞭解。這個例子中用的頂點着色器是這樣的:

#version 400 core
layout (location = 0) in vec3 pos;

void main()
{
    gl_Position = vec4(pos.x, pos.y, pos.z, 1.0);
}

可以看到,它非常的像C語言。

第一行聲明瞭版本號,400對應於OpenGL 4.0,同樣的,450就對應OpenGL 4.5。然後我們使用in關鍵字表明瞭它的輸入。然後,把位置數據賦值給內部預定義的gl_Position變量,它是vec4類型,表示輸出。最後一個1.0其實是齊次座標,並不是什麼四維空間之類的高級玩意。這是個非常基礎的頂點着色器,它把輸入什麼都不修改直接丟給輸出了。你可能會有一些直覺的寫法,我非常建議嘗試。

接下來,我們需要編譯這個頂點着色器。目前來說,我們暫時先用一個簡單的方案——把它作爲C字符串。當然,我們應該把它丟到資源文件裏面,不過暫時爲了簡單,不這樣做:

const char vetShader[] =
{
    "#version 400 core                                \n\
     layout (location = 0) in vec3 pos;               \n\
     void main()                                      \n\
     {                                                \n\
        gl_Position = vec4(pos.x, pos.y, pos.z, 1.0); \n\
     }"
};

嗯...其實還可以對吧...

同樣的,我們需要創建一個頂點着色器對象,用glCreateShader(GL_VERTEX_SHADER)創建, 然後把頂點着色器源代碼附加到這個對象上,用glShaderSource完成,最後,再用glCompileShader編譯。在這之後,我們再使用glGetShaderiv檢查編譯錯誤,如果成功了,那麼頂點着色器代碼編譯也就成功了。整理一下得到compileShader函數,用於編譯單個的着色器程序。參數vetShader是的glCreateShader返回值:

// 編譯着色器
bool compileShader(const char *src, GLuint shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glShaderSource(shader, 1, &src, NULL);           // 綁定, 1表示只有一份源代碼字符串
    glCompileShader(shader);
    glGetShaderiv(shader, GL_COMPILE_STATUS, &err);  // 取得錯誤信息
    if (err == 0)
    {
#if _DEBUG
        glGetShaderInfoLog(shader, 512, NULL, errMsg);
        // Debug配置下打印錯誤信息
        printf("着色器編譯錯誤: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}

6.5 準備片段着色器

下面是片段着色器。回憶我們之前所講,片段着色器的輸出是具體的像素顏色。在這裏我們爲了簡單,讓它一直輸出一種顏色:

#version 400 core
out vec4 outColor;

void main()
{
    outColor = vec4(0.5f, 0.2f, 0.5f, 1.0f);
} 

同樣的,它的語法也很像C語言。

有一點不同的是,我們指定了輸出outColor,這是一個vec4類型,不難理解它對應於RGBA。編譯過程依然與頂點着色器類似,我們先用C字符串表示它:

const char pixelShader[] =
{
    "#version 400 core                                \n\
     out vec4 outColor;                               \n\
     void main()                                      \n\
     {                                                \n\
         outColor = vec4(0.5f, 0.2f, 0.5f, 1.0f);     \n\
     }"
};

然後直接調用之前我們準備的compileShader函數編譯片段着色器。所不同的是,這次需要用glCreateShader(GL_FRAGMENT_SHADER)創建片段着色器對象,並用它的返回值作爲編譯函數的第二個參數。

6.6 鏈接着色器程序

頂點着色器和片段着色器都已經編譯準備就緒。接下來是鏈接它們,得到着色器程序。着色器程序可以是很多個片段着色器和頂點着色器組成,就像你寫的C/C++程序有很多個源文件那樣。

由於OpenGL的封裝,鏈接也變得像編譯那樣簡單。我們使用glCreateProgram創建一個着色器程序對象,然後使用glAttachShader添加着色器編譯輸出,最後,用glLinkProgram鏈接它們。和編譯類似,我們可以使用那兩個函數來檢查鏈接錯誤。整理一下,得到我們的鏈接函數linkShaderProg:

// 鏈接着色器程序
bool linkShaderProg(GLuint prog, GLuint vet_shader, GLuint pixel_shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glAttachShader(prog, vet_shader);
    glAttachShader(prog, pixel_shader);
    glLinkProgram(prog);
    glGetProgramiv(prog, GL_LINK_STATUS, &err);     // 取得鏈接的結果
    if (err == 0)
    {
#if _DEBUG
        glGetProgramInfoLog(prog, 512, NULL, errMsg);
        printf("着色器鏈接錯誤: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}

接着,我們使用glUseProgram(prog)來激活這個剛創建的着色器程序對象。因爲着色器的那兩個編譯結果已經沒有再用的必要了,因此要使用函數glDeleteShader來刪除着色器對象。這個需要記得。

現在,我們已經把輸入頂點數據發送給了GPU,並指示了GPU如何在頂點和片段着色器中處理它。但是不要忘記,OpenGL還不知道怎麼處理內存中的頂點數據,以及怎麼把將頂點數據對應到頂點着色器的輸入上。很顯然,我們就快完工了。

6.7 對接頂點數據到頂點着色器

在這步我們將把頂點數據,即那個float數組,對應到頂點着色器上。我們的頂點數據在內存中看起來像這樣:

每個數據的寬度是4,每個頂點的大小是12。而且,它們之間沒有任何間隔,是緊密排布的。這樣,我們就可以告訴OpenGL了:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

第一個函數的參數非常的多:

  • 第一個參數0是頂點的位置屬性,一種理解是它爲頂點的id。還記得我們在頂點着色器中寫的代碼嗎? layout (location = 0) in vec3 pos; 0將對應到這裏的location去。

  • 第二個參數指定頂點數據的大小,很明顯,一個頂點有三個數據X, Y, Z,因此它是3。

  • 第三個參數指定數據的類型,這裏是GL_FLOAT(GLSL中的vec3, vec4等等都是由float組成)。

  • 第四個參數指定是否需要對數據進行標準化(Normalize,這樣好理解,它將數據全部縮放到[0, 1]區間中去)。如果爲GL_TRUE,那麼數據將會被Normalize。

  • 第五個參數是頂點數據的大小。

  • 最後一個參數是一個偏移量,在之後的文章記錄中會解釋。

好了,現在我們已經告訴OpenGL頂點數據是什麼樣的了,下面,我們調用glEnableVertexAttribArray,啓動它。函數的參數是頂點的location屬性值,這裏是0。到目前位置,我們使用一個VBO將頂點數據扔到一個緩衝中,準備了一個頂點着色器和一個片段着色器,並告訴了OpenGL如何對應頂點數據到頂點着色器的頂點屬性上。一切皆完成,我們可以看到我們的三角形了:

// 載入頂點數據
glBindBuffer
glBufferData
// 設置頂點數據的一些信息
glVertexAttribPointer
glEnableVertexAttribArray
// 設置着色器程序
glUseProgram
// 繪製物體

但是還差一步?

嗯是的,還有一步。堅持到這真不容易,不過確實還有一步。每當我們需要繪製的時候,上面的步驟都需要重複一遍。三個頂點倒是不覺得。但是三百萬個就不一樣的————。顯卡距離內存是很遙遠的,因此,這樣會很低效。我們需要把這些配置保存到一個對象中,然後每次都使用那個對象去繪製。那個對象叫做頂點數組對象VAO(Vertex Array Objects)

6.8 頂點數組對象

VAO可以使得切換配置和繪圖變得簡單且高效。它和VBO類似,也可以綁定。OpenGL的Core模式要求我們使用VAO。因此我們需要它。

VAO的創建和VBO類似,使用glGenVertexArrays函數完成。這樣的話,渲染過程就變成了這樣:

// 初始化代碼
// 綁定VAO
glBindVertexArray
// 複製數據
glBindBuffer
glBufferData
// 設置屬性信息
glVertexAttribPointer
glEnableVertexAttribArray
// .........
// 持續渲染
glUseProgram
glBindVertexArray
// 繪製物體

初始化的時候,我們綁定VAO和攜帶着配置屬性的VBO,然後當我們希望繪製物體的時候,使用簡單的綁定即可。

好了,我們馬上就可以見到我們的三角形了。當你希望繪製很多個物體的時候,先綁定一個VAO,然後解綁它,以綁定一個新的用於繪製下一個物體。前面做了那麼多,現在一切將成爲現實。

7. 繪製三角形

按照我們之前的思路,在繪製物體的位置添加下面的代碼:

glDrawArrays(GL_TRIANGLES, 0, 3);

編譯運行,你會看到:

結果

一個暗紫紅色的三角形。如果你不知道怎麼組合代碼,可以參考我下面的程序。

#include "pch.h"
#include "resource.h"
#define  VBO_ID        1
#define  VAO_ID        1
void  framebufferResize(GLFWwindow *, int, int);
void  keyInput(GLFWwindow *win, int key, int scancode, int action, int mods);
bool  initRender();
void  renderLoop();
bool  compileShader(const char *src, GLuint shader);
bool  linkShaderProg(GLuint prog, GLuint vet_shader, GLuint pixel_shader);
void  popMessageBox(const TCHAR *format, ...);
const float vetPos[] =
{/*  X      Y     Z   */
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
const char vetShader[] =
{
    "#version 400 core                                \n\
     layout (location = 0) in vec3 pos;               \n\
     void main()                                      \n\
     {                                                \n\
        gl_Position = vec4(pos.x, pos.y, pos.z, 1.0); \n\
     }"
};
const char pixelShader[] =
{
    "#version 400 core                                \n\
     out vec4 outColor;                               \n\
     void main()                                      \n\
     {                                                \n\
         outColor = vec4(0.5f, 0.2f, 0.5f, 1.0f);     \n\
     }"
};
GLuint  hVBO, hVAO;
GLuint  hShaderProg, hVetShader, hPixelShader;
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    GLFWwindow  *win;
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);      // OpenGL 4.x
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#if _DEBUG
    FILE *ous;
    AllocConsole();
    setlocale(LC_CTYPE, "chs");                         // 中文字符集
    freopen_s(&ous, "CONOUT$", "w", stdout);            // 輸出重定向
#endif
    if ((win = glfwCreateWindow(480, 320, "Graph3D", NULL, NULL) ) == NULL)
    {
        popMessageBox(_T("無法創建窗口"));
        goto err;
    }
    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, keyInput);
    glfwSetFramebufferSizeCallback(win, framebufferResize);
    if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0)
    {
        popMessageBox(_T("無法初始化GLad, 請檢查您的設備是否支持OpenGL 4.0"));
        goto err;
    }
    if (initRender() == false)
    {
        popMessageBox(_T("無法初始化渲染"));
        goto err;
    }
    while (glfwWindowShouldClose(win) == 0)
    {
        renderLoop();
        glfwPollEvents();                               // 檢查, 觸發事件
        glfwSwapBuffers(win);
    }
    goto succ;
err:
#if _DEBUG
    system("pause");
#endif
succ:
    glfwTerminate();
#if _DEBUG
    FreeConsole();
#endif
    return 0;
}
// 窗口尺寸被改變
void framebufferResize(GLFWwindow* win, int w, int h)
{
    glViewport(0, 0, w, h);
}
// 鍵盤輸入
void keyInput(GLFWwindow *win, int key, int scancode, int action, int mods)
{
    if (action == GLFW_RELEASE)                         // 鬆開按鍵
        if (key == GLFW_KEY_ESCAPE)
        {
            glfwSetWindowShouldClose(win, true);        // 告訴glfw應該關閉窗口
        }
}
// 初始化
bool initRender()
{
    glGenBuffers(VBO_ID, &hVBO);                        // 創建並綁定頂點緩衝
    glGenVertexArrays(VAO_ID, &hVAO);

    hVetShader = glCreateShader(GL_VERTEX_SHADER);
    if (compileShader(vetShader, hVetShader) == false)
    {
        return false;
    }
    hPixelShader = glCreateShader(GL_FRAGMENT_SHADER);
    if (compileShader(pixelShader, hPixelShader) == false)
    {
        return false;
    }

    hShaderProg = glCreateProgram();
    if (linkShaderProg(hShaderProg, hVetShader, hPixelShader) == false)
    {
        return false;
    }
    glDeleteShader(hVetShader);
    glDeleteShader(hPixelShader);

    glBindVertexArray(hVAO);                            // 綁定VAO

    glBindBuffer(GL_ARRAY_BUFFER, hVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vetPos), vetPos, GL_STATIC_DRAW);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);

    return true;
}
// 渲染循環
void renderLoop()
{
    glClearColor(0.2f, 0.3f, 0.5f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    glUseProgram(hShaderProg);
    glBindVertexArray(hVAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
}
// 編譯着色器
bool compileShader(const char *src, GLuint shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glShaderSource(shader, 1, &src, NULL);              // 綁定, 1表示只有一份源代碼字符串
    glCompileShader(shader);
    glGetShaderiv(shader, GL_COMPILE_STATUS, &err);     // 取得錯誤信息
    if (err == 0)
    {
#if _DEBUG
        glGetShaderInfoLog(shader, 512, NULL, errMsg);
        printf("着色器編譯錯誤: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}
// 鏈接着色器程序
bool linkShaderProg(GLuint prog, GLuint vet_shader, GLuint pixel_shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glAttachShader(prog, vet_shader);
    glAttachShader(prog, pixel_shader);
    glLinkProgram(prog);
    glGetProgramiv(prog, GL_LINK_STATUS, &err);         // 取得鏈接的結果
    if (err == 0)
    {
#if _DEBUG
        glGetProgramInfoLog(prog, 512, NULL, errMsg);
        printf("着色器鏈接錯誤: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}
// 彈出一條消息
void popMessageBox(const TCHAR *format, ...)
{
    va_list ap;
    TCHAR   msg[512] = { 0 };
    va_start(ap, format);
#if _UNICODE
    vswprintf_s(msg, format, ap);
    MessageBoxW(0, msg, L"Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#else
    vsprintf_s(msg, format, ap);
    MessageBoxA(0, msg, "Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#endif // _UNICODE
    va_end(ap);
}

如果你的代碼有編譯錯誤,那麼你需要仔細檢查程序,如果使用我的代碼依然存在着編譯錯誤,那麼很可能你很早就做錯了,檢查一下配置正確否。

8. 結語

可編程管線固然複雜些,這是我剛接觸OpenGL的記錄,大約有不完善的地方,歡迎批評指正。

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章