第二章 我們的第一個OpenGL程式
我們會從本章學到什么
- 如何創(chuàng)建并編譯著色器代碼
- 如何使用OpenGL繪圖
- 如何使用本書的應用框架來初始化我們的程式并進行清理
在本章中,我們引入本書中幾乎所有示例都會使用的一個簡單的應用框架。本章會向我們展示如何使用書中的應用框架創(chuàng)建主窗口并渲染簡單圖形到上面。我們還會看到一個很簡單的GLSL著色器是怎樣的,如何編譯它,以及如何用它來渲染簡單的點。本章包含了我們第一個非常簡單的OpenGL三角形。
創(chuàng)建一個簡單的應用
我們從一個超級簡單的示例應用開始介紹本書其他部分將會使用到的應用框架。當然,若要編寫一個大型的OpenGL程式你可以不使用我們的框架-事實上,這種情況下我們也不推薦它,它太簡陋。不過,它簡化了一些事情,可以讓我們更快地開始編寫OpenGL代碼。
通過包含sb7.h到源代碼中我們引入應用框架到我們的應用中。這是一個c++頭文件,它定義了一個名為sb7的命名空間,這個命名空間中包含一個名為sb7::aplication的類的聲明,我們可以在我們的示例中繼承它。這個框架中還包含了一些工具函數(shù)以及一個稱為vmath的簡單的數(shù)學庫,以期幫助我們解決OpenGL中的瑣碎事務。
要創(chuàng)建一個應用,我們包含sb7.h,從sb7::application派生出一個類,并在我們的一個源文件中,包含宏DECLARE_MAIN。這個宏定義了我們應用的主入口(譯者注:這個宏展開就是main函數(shù)啦),它創(chuàng)建我們從sb7::application派生類的實例(我們把這個類作為這個宏的參數(shù)傳入)并調(diào)用這個實例的run()方法,run方法實現(xiàn)了應用的主循環(huán)。
這個框架執(zhí)行了一些初始化,最先調(diào)用startup()方法,然后在一個循環(huán)中調(diào)用render()方法。在缺省實現(xiàn)中,這些方法都定義為空的虛函數(shù)。我們在我們的派生類中覆蓋render()方法,將我們的繪圖代碼寫進去。應用框架負責創(chuàng)建一個窗體,處理輸入,以及展示渲染結(jié)果給用戶。我們的第一個示例的完整源代碼如清單2.1,它的輸出如圖示2.1。
清單2.1: 我們的第一個OpenGL應用
// Include the "sb7.h" header file
#include "sb7.h"
// Derive my_application from sb7::application
class my_application : public sb7::application
{
public:
// Our rendering function
void render(double currentTime)
{
// Simply clear the window with red
static const GLfloat red[] = { 1.0f, 0.0f, 0.0f, 1.0f };
glClearBufferfv(GL_COLOR, 0, red);
}
};
// Our one and only instance of DECLARE_MAIN
DECLARE_MAIN(my_application);
圖示2.1

清單2.1所示的示例簡單地將整個主窗口清除為紅色。這引入我們的第一個OpenGL函數(shù): glClearBufferfv()。這個函數(shù)的原型為:
void glClearBufferfv(GLenum buffer, Glint drawBuffer, const GLfloat * value);
所有的OpenGL函數(shù)都始于gl前綴,跟隨著幾個命名約定,比如將一些參數(shù)類型編碼后做為函數(shù)名稱的后綴。這樣可以實現(xiàn)一種有限的重載形式,即使在那些不直接支持重載的語言中。在本例中,后綴fv表示這個函數(shù)使用一組(vectorv)浮點值(float-pointf),在OpenGL中數(shù)組(arrays,在類似C的語言中通過指針進行引用)和向量(vector)是等價的。
glClearBufferfv()函數(shù)告訴OpenGL要清除第一個參數(shù)指定的緩沖區(qū)(本例中即GL_COLOR)為第三個參數(shù)指定的值。第二個參數(shù),drawBuffer,在有多個輸出緩沖區(qū)可被清除時使用。因為我們在這只使用一個緩沖區(qū)并且drawBuffer是一個從0開始的索引值,本例中我們將其設(shè)置為0即可。我們在這使用數(shù)組red來存儲顏色,它包含四個浮點值-依次代表紅(red),綠(green),藍(blue)以及不透明度(alpha)。
紅、綠、藍顧名思義。alpha是一個顏色的第四個組成部分,常用來表示一個片段(fragment)的不透明性(opacity)。將alpha設(shè)置為0會使得片段完全透明,設(shè)置為1則使得它完全不透明。alpha值同樣可以被存儲在輸出圖像中并在OpenGL的某些地方被使用,即使我們看不到它??梢钥吹轿覀儗⒓t色和alpha都設(shè)置為1,其他的為0。這表示一個不透明的紅色。這個應用的運行結(jié)果如圖示2.1。
這個剛起步的應用不是特別有趣,它做的所有事情就是把窗口填為實心的紅色。我們注意到render()函數(shù)接收一個參數(shù)-currentTime。這個參數(shù)表示從應用開始以來所經(jīng)過的秒鐘數(shù),我們可以用它來創(chuàng)建一個簡單的動畫。本例中,我們可以用它來改變清除窗體的顏色。經(jīng)過我們修改過之后的render()函數(shù)如清單2.2。
// Our rendering function
void render(double currentTime)
{
const GLfloat color[] = {(float)sin(currentTime) * 0.5f + 0.5f,
(float)cos(currentTime) * 0.5f + 0.5f,
0.0f, 1.0f };
glClearBufferfv(GL_COLOR, 0, color);
}
現(xiàn)在我們的窗體從紅色漸變?yōu)辄S色、橙色、綠色,然后再次從紅色開始。呃...雖然還不是很High,但至少可以干點啥了。
使用著色器
正如我們在第一章提到過的圖形管線,OpenGL通過連接多個叫做著色器的小程序并佐以固定功能函數(shù)作為"膠水"來工作。當我們繪圖時,圖形處理器執(zhí)行我們的著色器并將它們的輸入輸出在管線中串聯(lián)起來,直到像素完成于管線末端。不管要繪制點什么,我們都需要至少編寫一對著色器。
OpenGL著色器使用一種叫做OpenGL著色語言(OpenGL Shading Language)的語言進行編寫,或者叫做GLSL。這個語言起源于C,不過長久以來都在修改以更好地適用于圖形處理器。所以如果你對C比較熟,那學習GLSL也是很容易的。這個語言的編譯器內(nèi)置在OpenGL中。我們編寫的著色器的源代碼放入到著色器對象(shader object)中并進行編譯,然后多個著色器對象鏈接在一起形成一個程式對象(program object)。每個程式對象都可以包含多個不同階段的著色器(譯者注: 原文為shader stages)。OpenGL中的著色器的階段有頂點著色器(vertex shaders)、鑲嵌控制著色器(tessellation control shader,或者叫曲面細分控制著色器)、鑲嵌運算著色器(tessellation evaluation shader),或者叫曲面細分運算著色器、幾何著色器(geometry shaders)、片段著色器(fragment shaders)以及運算著色器(compute shaders)。最小可用的管線配置只需要一個頂點著色器(或者就一個運算著色器),但如果我們想在顯示屏上看到點什么,那還需要一個片段著色器。
清單2.3 我們的第一個頂點著色器:
#version 450 core
void main(void)
{
gl_Position = vec4(0.0, 0.0, 0.5, 1.0);
}
清單2.3展示了我們的第一個頂點著色器,它簡單到不行。第一行,我們用#version 450 core聲明想要著色器編譯器使用著色語言的4.5版本。值得注意的是core表示我們只想用OpenGL核心檔案所支持的特性。
接著我們聲明了main函數(shù),和C語言一樣,這是著色器執(zhí)行的入口點,和正經(jīng)的C有區(qū)別的是GLSL的main函數(shù)沒有int返回值和int argc, char* argv[]的參數(shù)。在我們的main函數(shù)中,我們賦了一個值給gl_Position,它是貫穿OpenGL所有著色器的紐帶的一部分。所有以gl_開頭的變量都是OpenGL的一部分并且與其他著色器或者OpenGL的某些固定功能函數(shù)相連。在頂點著色器中,gl_Position表示頂點的輸出位置。我們使用的值vec4(0.0, 0.0, 0.5, 1.0)將頂點放在OpenGL裁剪空間(clip space)的中間,裁剪空間是OpenGL管線下一個階段的坐標系統(tǒng)。
清單2.4 我們的第一個片段著色器:
#version 450 core
out vec4 colour;
void main(void)
{
color = vec4(0.0, 0.8, 1.0, 1.0);
}
清單2.4再次向我們展示了一個簡單到不行的著色器,這次是一個片段著色器。同樣,它始于4.5核心檔案聲明。然后它用out關(guān)鍵字來聲明color為一個輸出變量。在片段著色器中,輸出變量的值會被發(fā)送到窗體或者顯示屏。在main函數(shù)中給這個輸出變量賦了值。缺省情況下,那個值會直接顯示到顯示屏上,那個值是四個浮點值的向量,四個浮點值分別表示紅、綠、藍以及alpha,一如glClearBufferfv()的參數(shù)。在這個著色器中,我們使用的vec4(0.0, 0.8, 1.0, 1.0f)是藍綠色。
現(xiàn)在我們有了一個頂點著色器和片段著色器,是時候編譯它們并一起鏈接到一個程式中供OpenGL運行。這跟C++程式或者其他類似語言編譯然后鏈接產(chǎn)生可執(zhí)行文件一樣。用來鏈接多個著色器到一個程式對象的代碼如清單2.5。
清單2.5 編譯簡單的著色器:
GLuint compile_shaders(void)
{
GLuint vertex_shader;
GLuint fragment_shader;
GLuint program;
// Source code for vertex shader
static const GLchar* vertex_shader_source[] =
{
"#version 450 core \n"
" \n"
"void main(void) \n"
"{ \n"
" gl_Position = vec4(0.0, 0.0, 0.5, 1.0); \n"
"} \n"
};
// Source code for fragment shader
static const GLchar* fragment_shader_source[] =
{
"#version 450 core \n"
" \n"
"out vec4 color; \n"
" \n"
"void main(void) \n"
"{ \n"
" color = vec4(0.0, 0.8, 1.0, 1.0); \n"
"} \n"
};
// Create and compile vertex shader
vertex_shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex_shader, 1, vertex_shader_source, NULL);
glCompileShader(vertex_shader);
// Create and compile fragment shader
fragment_shader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment_shader, 1, fragment_shader_source, NULL);
glCompileShader(fragment_shader);
// Create program, attach shaders to it, and link it
program = glCreateProgram();
glAttachShader(program, vertex_shader);
glAttachShader(program, fragment_shader);
glLinkProgram(program);
// Delete the shaders as the program has them now
glDeleteShader(vertex_shader);
glDeleteShader(fragment_shader);
return program;
}
在清單2.5中,我們引入了幾個新函數(shù):
- glCreateShader() 創(chuàng)建一個空的著色器對象,準備接受源代碼以及編譯。
- glShaderSource() 將著色器源代碼傳遞給著色器對象,這樣著色器對象可以保留源代碼的一份拷貝。
- glCompileShader() 編譯著色器對象包含的所有源代碼。
- glCreateProgram() 創(chuàng)建一個程式對象,然后可以附加著色器對象給它。
- glAttachShader() 附加一個著色器對象到一個程式對象上。
- glLinkProgram() 將一個程式對象上的所有著色器對象鏈接到一起。
- glDeleteShader() 刪除一個著色器對象。一旦一個著色器對象被鏈接到一個程式對象中之后,這個程式對象就包含了相應的二進制代碼,于是相關(guān)的著色器對象就不再需要了。
清單2.3和清單2.4的著色器源代碼在我們的程式中被當做常量字符串傳遞給glShaderSource()函數(shù),這個函數(shù)將這些常量字符串拷貝到我們用glCreateShader()創(chuàng)建的著色器對象中。著色器對象保存了它的源代碼的一份拷貝,然后,當我們調(diào)用glCompileShader()時,著色器對象的GLSL源代碼被編譯為一種中間的二進制表示形式,它也同樣被存儲在著色器對象中。程式對象表示鏈接后會被用來進行渲染工作的可執(zhí)行體。我們使用glAttachShader()將我們的著色器對象附加到程式對象上,然后調(diào)用glLinkProgram()將所有著色器對象鏈接到可被圖形處理器運行的代碼中。將一個著色器對象附加到一個程式對象上會創(chuàng)建一個到著色器對象的引用,然后我們可以刪除這個著色器對象,因為這個程式對象在它需要的情況下會把持這個著色器對象的內(nèi)容。清單2.5中的compile_shaders函數(shù)新創(chuàng)建的程式對象。當我們調(diào)用這個函數(shù)后我們需要將返回的程式對象在某個地方保存下來,這樣我們就可以用它來繪圖。同時,我們也不會想每次想用到這個程式對象的時候都對它進行重新編譯。所以,我們需要一個程式啟動時會且只會被調(diào)用一次的函數(shù)。sb7應用框架提供這樣一個函數(shù): application::startup(),我們可以在我們的示例應用中覆蓋它來完成所有一次性的設(shè)置工作。
在我們可以繪制任何東西之前尚有一事需要處理,創(chuàng)建一個頂點集對象(vertex array object - VAO),這個對象表現(xiàn)了OpenGL管線的頂點獲取階段并且用來給頂點著色器提供輸入數(shù)據(jù)?,F(xiàn)在我們的頂點著色器沒有任何輸入,我們不需要對VAO做什么。然而,我們還是需要創(chuàng)建VAO,這樣OpenGL才會允許我繪圖。我們調(diào)用OpenGL函數(shù)glCreateVertexArrays()來創(chuàng)建VAO,使用glBindVertexArray()將其附加到我們的上下文(context)中(譯者注: glCreateVertexArray()函數(shù)從OpenGL 4.5才可用,筆者使用glGenVertexArrays()來進行實做)。它們的原型如下:
void glCreateVertexArrays(GLsizei n, GLuint* arrays);
void glGenVertexArrays(GLsizei n, GLuint* arrays);
void glBindVertexArray(GLuint array);
頂點集對象維護了OpenGL管線輸入數(shù)據(jù)的所有相關(guān)狀態(tài)。我們在startup()函數(shù)中加入對glCreateVertexArrays()和glBindVertexArray()的調(diào)用。隨著我們對OpenGL進行更多的學習我們將會熟悉這一模式。在OpenGL中很多東西都是用對象(objects)來表達的(比如頂點集對象)。我們用一個創(chuàng)建函數(shù)(比如glCreateVertexArrays())來創(chuàng)建對象,并且用一個綁定函數(shù)(比如glBindVertexArray())來綁定它們使得OpenGL知道我們想在上下文中使用它們。
清單2.6 創(chuàng)建成員變量:
class my_application : public sb7::application
{
public:
// <snip>
void startup()
{
rendering_program = compile_shaders();
glCreateVertexArrays(1, &vertex_array_object);
glBindVertexArray(vertex_array_object);
}
void shutdown()
{
glDeleteVertexArrays(1, &vertex_array_object);
glDeleteProgram(rendering_program);
glDeleteVertexArrays(1, &vertex_array_object);
}
private:
GLuint rendering_program;
GLuint vertex_array_object;
};
在清單2.6中,我們覆蓋了sb7::application類的startup()成員函數(shù)并將我們自己的初始化代碼放進去。再次提醒一下,和render()成員函數(shù)一樣,startup()成員函數(shù)在sb7::application中被定義為一個空的虛函數(shù)(virtual function)并且在run()函數(shù)中被自動調(diào)用。我們在startup()漢中調(diào)用compile_shaders并將返回的程式對象存儲到我們的應用類的rendering_program成員變量中。當我們的應用運行完成后,我們應當將這些資源清理干凈。所以我們還覆蓋了shutdown()成員函數(shù),在其中刪除掉我們在啟動時創(chuàng)建的程式對象。一如我們使用完著色器對象后調(diào)用glDeleteShader(),我們在使用完程式對象后調(diào)用glDeleteProgram()。在我們的shutdown()函數(shù)中,我們還刪除掉在startup()中創(chuàng)建的頂點集對象。
現(xiàn)在我們有了一個程式,我們需要在里面執(zhí)行著色器并開始在顯示屏上實際繪制些什么。我們修改之前的render()函數(shù),調(diào)用glUseProgram()來指示OpenGL使用我們的程式對象進行渲染,然后再調(diào)用我們的第一個繪圖命令: glDrawArrays()。更新過的代碼如清單2.7:
清單2.7 渲染一個點:
// Our rendering function
void render(double currentTime)
{
const GLfloat color[] = { (float)sin(currentTime) * 0.5f + 0.5f,
(float)cos(currentTime) * 0.5f + 0.5f,
0.0f, 1.0f };
glClearBufferfv(GL_COLOR, 0, color);
// Use the program object we created earlier for rendering
glUseProgram(rendering_program);
// Draw one point
glDrawArrays(GL_POINTS, 0, 1);
}
glDrawArrays()函數(shù)將頂點發(fā)送到OpenGL管線。它的原型為:
void glDrawArrays(GLenum mode, Glint first, GLsizei count);
每一個頂點都會經(jīng)過頂點著色器(就像清單2.3中的那個)的洗禮。glDrawArrays()的第一個參數(shù)是mode,這個參數(shù)指示OpenGL我們想要渲染何種圖元。因為我們只想繪制一個點,所以本例中我們指定為GL_POINTS。第二個參數(shù)first跟本例無關(guān),我們設(shè)置為0就好了。最后一個參數(shù)是我們要渲染的頂點的個數(shù)。一個點用一個頂點表示,所以我們指示OpenGL只渲染一個頂點,于是我們看到一個點被渲染出來。程式的運行結(jié)果如圖示2.2。
圖示2.2 渲染一個點:

眼睛睜大大,可以看到窗體的正中間有一個小點。恭喜,我們已經(jīng)完成了我們的第一個OpenGL渲染。盡管它不是很令人印象深刻,但它為我們之后進行更有趣的渲染打好了基礎(chǔ)并它證明了我的應用框架和我們的簡單的著色器可以正常工作。
為了讓上面的點更加容易看到一點,我們可以讓OpenGL繪制一個比單個像素要大一些的點。我們可以用glPointSize()函數(shù)來達成這個目的,它的原型為:
void glPointSize(GLfloat size);
這個函數(shù)將點的直徑設(shè)置為size個像素。繪制點的最大直徑值是由OpenGL實現(xiàn)定義的。我們以后會深入這個話題和OpenGL常用功能之外的話題,但現(xiàn)在讓我們依賴于一個事實,OpenGL保證最大支持的點得尺寸至少為64像素。將如下代碼
glPointSize(40.0f);
添加到清單2.7的渲染函數(shù)中,我們即設(shè)置點得直徑為40像素了。輸出結(jié)果如圖示2.3。
圖示2.3 繪制一個大一些的點:

譯者大注:
本書譯版示例代碼: github。
正如譯者在第二章前奏中描述的一樣,譯者的機器上搭載的OpenGL最高規(guī)格為4.1版本,所以譯者的代碼都以O(shè)penGL 4.1實做,但傳達的思想我們應當能融會貫通。
繪制我們的第一個三角形
僅僅只是繪制一個很大的點并不是很帶勁--我們已經(jīng)提到過,OpenGL支持很多不同類型的圖元,其中最重要的是點、線和三角形。在我們的玩具示例中我們將GL_POINTS傳給glDrawArrays()函數(shù),從而繪制了一個點。我們真正想要繪制的是線和三角形。你可能已經(jīng)猜到,我們可以將GL_LINES或者GL_TRIANGLES傳給glDrawArrays(),但有一個問題存在: 清單2.3中的頂點著色器將每一個頂點都放在裁剪空間的中間。對于繪制頂點來說,OpenGL為我們給點設(shè)置區(qū)域是挺好的。但對于線或者三角形來說,兩個或者多個頂點放在同樣的地方會導致圖元退化(degenerate primitive),一個零長度的線或者零面積的三角形。如果我們試著用這個著色器繪制除了點之外的任何東西,我們不會得到任何輸出,因為所有的圖元都退化(degenerate)了。要修正這個問題,我們需要修改頂點著色器使得它為每個頂點設(shè)置不同的位置。
幸運的是GLSL的頂點著色器包含一個名為gl_VertexID的特殊輸入,它是著色器運行時正在被處理的頂點的索引。gl_VertexID輸入值從glDrawArrays()的first參數(shù)開始計數(shù)并且每次向上計一個頂點直到count參數(shù)個頂點。gl_VertexID輸入是GLSL提供的眾多內(nèi)置變量的其中一個,內(nèi)置變量表示由OpenGL生成或者我們要在著色器中生成傳給給OpenGL的數(shù)據(jù)。我們前面提到過的gl_Position也是一個內(nèi)置變量。我們可以使用索引gl_VertexID來給每個頂點賦予不同的位置,下示清單2.8。
清單2.8 在一個頂點著色器中生成多個頂點:
#version 450 core
void main(void)
{
// Declare a hard-coded array of positions
const vec4 vertices[3] = vec4[3](vec4(0.25, -0.25, 0.5, 1.0), vec4(-0.25, -0.25, 0.5, 1.0), vec4(0.25, 0.25, 0.5, 1.0));
// Index into our array using gl_VertexID
gl_Position = vertices[gl_VertexID];
}
使用清單2.8中的著色器,我們即可基于每個頂點gl_VertexID的值而賦予不同的位置。數(shù)組vertices中的點構(gòu)成一個三角形,并且我們將渲染函數(shù)中傳給glDrawArrays()的GL_POINTS更變?yōu)?code>GL_TRIANGLES,如清單2.9所示。
清單2.9 渲染一個三角形:
//Our rendering function
void render(double currentTime)
{
const GLfloat color[] = { 0.0f, 0.2f, 0.0f, 1.0f };
glClearBufferfv(GL_COLOR, 0, color);
// Use the program object we created earlier for rendering
glUseProgram(rendering_program);
// Draw one triangle
glDrawArrays(GL_TRIANGLES, 0, 3);
}
終得如下圖像:
圖示2.4 我們的第一個剛起步的OpenGL三角形:

總結(jié)
本章我們大致了解了我們的第一個?OpenGL程式的構(gòu)成。我們很快就會了解到如何從應用中向著色器傳遞數(shù)據(jù),如何傳遞我們的輸入到頂點著色器中,如何在著色器階段間傳遞數(shù)據(jù),等等。在本章,我們簡要介紹了sb7應用框架,編譯一個著色器,清除窗體以及繪制點和三角形。我們還看到如何使用glPointSize()函數(shù)來設(shè)置點的大小以及第一個繪圖命令--glDrawArrays()。
Copyright
本書原著為《OpenGL Super Bible》,版權(quán)歸原作者所有,本譯文僅為愛好者學習交流所用。