1 引子
雖然是計算機科班出身,但從小對幾何方面的東西就不太感冒,空間想象能力也較差,所以從本科到研究生,基本沒接觸過《計算機圖形學》。為什么說基本沒學過呢?因為好奇(尤其是驚嘆於三維游戲的逼真,如魔獸世界、極品飛車),在研究生階段還專門選修計算機圖形學,但也只是聽了幾堂課,知道了有幀緩存、齊次坐標等零零散散的概念,之后讀了一篇論文並上台作報告(壓根沒讀懂)。總之,當時只是覺得計算機圖形學或三維渲染很牛,甚至問我什么是渲染都不知道,更不知道如何將3維幾何體顯示到2維屏幕上。令我現在想來非常可笑的是,當時以為2D圖像才是平面的,3D圖像就是立體的。
真正接觸3維繪制方面的知識是在工作后,因為是搞圖像處理與可視化的方面軟件的開發,所以開始知道了這些3D圖像是通過OpenGL/Direct3D等編程接口來做的。不過公司關於OpenGL接口的調用和繪制方面的代碼是另一個組寫的,博主基本看不到他們的代碼。不過,與相關同事的討論中還是學到了一些知識。於是,博主便考慮系統地學習一下OpenGL編程。
市面上最好的兩本OpenGL的書應該是——《OpenGL編程指南》(紅寶書)和《OpenGL編程寶典》(藍寶書)。於是,就挑選了《OpenGL編程指南(第八版)》作為我的啟蒙教材,由此踏上學習之路。本博客希望記錄學習過程中的一些新的體會。記下來的知識才是自己的。閑話不多說,讓我們踏上OpenGL學習之旅吧!
2 第一個例子
紅寶書一開始在什么是OpenGL一節,簡要介紹了OpenGL的概念、發展歷程、渲染流程等概念。說實話,一開始讀這段文字壓根就是認字,概念各種不懂。不過沒關系,博主覺得,大部分技術書籍的第一章都是這樣——羅列一大堆概念,然后寫一個簡單的小程序,對它分析分析,繼續列舉更多概念,然后說明將在第幾章第幾節詳細介紹。所以對於我這種初學者,看完第一章完全不知道說什么是也很正常,別急,慢慢的,某一刻你就理解了——所謂一通百通。
之后,咣當扔過來一個例子,這也許就是程序員的風格——先寫個Demo看看。其實,剛開始對這個例子比較藐視,太簡單了——和我腦海中高大上的游戲界面相去甚遠。不過,其實這個例子中還是包含很多東西的。首先來看看,這個程序的運行效果。
就是在一個窗口中繪制兩個藍色的三角形。下面我們就按步驟來繪制這個圖形。
第一步:搭一個框架
對於技術書籍,一開始看書就是照着書本敲代碼,敲完代碼再看代碼有沒有問題。這個例子雖然簡單,但是代碼不少。其實我們可以一步一步來寫,首先寫一個main函數,在里面填一些初始化和創建窗口的代碼,如下:
1 #include <iostream> 2 #include "StdAfx.h" 3 4 void display() 5 { 6 } 7 8 int main(int argc, char **argv) 9 { 10 glutInit(&argc, argv); 11 glutInitDisplayMode(GLUT_RGBA); 12 glutInitWindowSize(512, 512); 13 glutInitContextVersion(3, 3); 14 glutInitContextProfile(GLUT_CORE_PROFILE); 15 glutCreateWindow(argv[0]); 16 17 if (glewInit()) 18 { 19 std::cerr << "Unable to initialize GLEW... Exiting..." << std::endl; 20 std::exit(EXIT_FAILURE); 21 } 22 23 glutDisplayFunc(display); 24 glutMainLoop(); 25 }
其實這里只是涉及到OpenGL的API,只是用到了第三方庫一些函數創建了一個顯示圖像的窗口。就像我們開始寫控制台Demo的時候,先寫main函數,然后打印一個“HelloWorld”出來,看看能不能跑。上面這短短的幾行代碼是能夠運行的。運行結果就是一個大小為512×512的空白窗口。代碼很簡單,就是初始化相關的函數。這里需要說明一下的是:#include "StdAfx.h"是從紅寶書的網站上下載下來vgl.h文件,並配置了工程的屬性(如頭文件目錄、lib庫目錄);另外一個就是:display函數就是我們要調用OpenGL繪制圖像的函數。下面就是填充這個函數。
第二步:填充框架
和任何程序一樣,OpenGL程序需要輸入,然后經過渲染管線,即一系列的着色器(着色器貫穿本書的始終),最后得到一個二維圖像(像素矩陣),見下圖。
所以在調用OpenGL API進行繪制圖像之前,先將所需數據加載到顯存中,以便於OpenGL在繪制時對其進行相關處理。填充后的代碼如下:
1 #include <iostream> 2 #include "StdAfx.h" 3 4 GLuint Buffer_ID; 5 const int BUFFER_NUMBER = 1; 6 7 GLuint VAO_ID; 8 GLuint VAO_NUMBER = 1; 9 10 const int VERTICES_NUMBER = 6; 11 const int vPosition = 0; 12 13 void Initialize() 14 { 15 //---------------------准備數據------------------------------- 16 GLfloat vertices[VERTICES_NUMBER][2] = 17 { 18 { -0.90, -0.90 }, 19 { 0.85, -0.90 }, 20 { -0.90, 0.85 }, 21 22 { 0.90, -0.85 }, 23 { 0.90, 0.90 }, 24 { -0.85, 0.90 } 25 }; 26 27 // 生成緩存對象 28 glGenBuffers(BUFFER_NUMBER, &Buffer_ID); 29 30 // 綁定緩存對象 31 glBindBuffer(GL_ARRAY_BUFFER, Buffer_ID); 32 33 // 填入數據 34 glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); 35 36 //-------------------設置頂點數據屬性------------------------------ 37 // 生成頂點數組對象 38 glGenVertexArrays(VAO_NUMBER, &VAO_ID); 39 40 // 綁定頂點數組對象 41 glBindVertexArray(VAO_ID); 42 43 // 設置頂點屬性 44 glVertexAttribPointer(vPosition, 2, GL_FLOAT, GL_FALSE, 0, BUFFER_OFFSET(0)); 45 glEnableVertexAttribArray(vPosition); 46 } 47 48 void display() 49 { 50 glClear(GL_COLOR_BUFFER_BIT); 51 52 glBindVertexArray(VAO_ID); 53 glDrawArrays(GL_TRIANGLES, 0, VERTICES_NUMBER); 54 55 glFlush(); 56 } 57 58 int main(int argc, char **argv) 59 { 60 glutInit(&argc, argv); 61 glutInitDisplayMode(GLUT_RGBA); 62 glutInitWindowSize(512, 512); 63 glutInitContextVersion(3, 3); 64 glutInitContextProfile(GLUT_CORE_PROFILE); 65 glutCreateWindow(argv[0]); 66 67 glewExperimental = TRUE; 68 if (glewInit()) 69 { 70 std::cerr << "Unable to initialize GLEW... Exiting..." << std::endl; 71 std::exit(EXIT_FAILURE); 72 } 73 74 Initialize(); 75 glutDisplayFunc(display); 76 glutMainLoop(); 77 }
在原有基礎上,添加了加載數據部分和繪制圖形部分的代碼。主要分了兩個步驟:
1. 數據輸入步驟
任何系統都有輸入輸出(I/O)系統,如計算機硬件系統中有輸入設備和輸出設備;每一個編程語言都有自己的輸入命令(類)和輸出命令(類);對於一個算法來說,也有其輸入和輸出。
對於我們圖形繪制系統來說,自然也少不了輸入和輸出。由於數據的輸入只需要執行一次就可以,故寫在Initialize函數中,並在main函數是執行。
本例要繪制兩個三角形,輸入的數據自然就是兩個三角形的頂點數據。由於繪制的是平面三角形,我們可以不指定z方向的坐標值(深度值)。16~25行的二維頂點數組是存放在內存中的,圖形繪制是在顯卡中執行的,所以需要將這些數據加載到顯存中。這里出現了OpenGL編程中第一個重要的概念——緩存對象(Buffer Object)。顧名思義,這一對象主要就是用來存放數據的,在這里,我們使用緩存來存放頂點數據。下面,我們來看看程序中是怎么使用緩存對象來加載頂點數據的。
加載頂點數據到顯存用了3條OpenGL API來實現數據的加載。
I:使用glGenBuffer聲明一個緩存對象ID。編程語言中通過變量的方式來標識內存中的數據;操作系統中通過各種ID來感知各個實體,如進程標識符PID來標識進程,線程標識符TID來標識線程。OpenGL也是通過ID來標識各種對象。由於這里只要使用一個緩存對象,所以只要生成一個緩存ID即可,但要注意,這條指令可以生成多個緩存對象。
II:使用glBindBuffer來綁定其中一個緩存。剛才已經提到,緩存對象可以有多個,那OpenGL怎么知道要當前操作的是哪個緩存對象呢?這就需要使用glBindBuffer命令——這個命令的作用就是激活(Activate)其中一個緩存對象。參數很簡單,就是剛才生成的緩存ID。
III:使用glBufferData來分配內存並拷貝數據到顯存。這一步是我們最終目的——將數據從內存拷貝至顯存。這個函數和C語言中內存拷貝memcpy很類似,函數簽名為:
void glBufferData(GLenum target, GLsizeiptr size, const GLvoid *data, GLenum usage);
target ——剛才綁定(激活)的緩存對象,這可以看做memcpy的目的地址;
size ——這就是數據的大小,這和memcpy中的數據大小是一樣的;
data ——源數據的指針,這和memcpy中的源數據指針是一樣的;
usage ——這個參數指定這個數據的用法,主要是為了優化OpenGL的內存管理——根據使用方法確定最優顯存分配方案。
通過使用上述三條OpenGL API,我們就完成了數據從內存加載到緩存的功能。到此為止,故事還沒有結束,OpenGL在獲取頂點數據時並不知道緩存對象中的數據如何解析,所以需要告訴OpenGL,剛才上傳的數據的格式是怎么樣的。這就引入了第二個對象——頂點數組對象及頂點屬性的概念。頂點數組對象就是用來描述剛才上傳的頂點數據特征的一個對象,下面就繼續來分析與頂點數組對象的相關API。
I:使用glGenVertexArray聲明一個頂點數組對象ID。這和緩存對象ID是一樣的,都是為了便於OpenGL的組織管理;
II:使用glBindVertexArray來激活其中的一個頂點數組對象,和緩存對象也是類似的;
III:使用glVertexAttribPointer接口來填充當前綁定的頂點數組對象。這個函數的功能和緩存對象的glBindBuffer命令是一樣的,只是對於緩存對象來說,只要拷貝一下數據就可以了,而這里需要填充頂點屬性數據(就像填充一個結構體一樣)。這個函數的參數比較多,其函數簽名為:
void glVertexAttribPointer(GLuint index, GLsize size, GLint size, GLenum type, GLboolean normalized, GLsizei stribe, const GLvoid *pointer);
index ——這是指定在該頂點在着色器中的屬性。
size ——該參數指定了每個頂點有幾個分量,本例中二維頂點,故設為2;
type ——該參數指定了頂點中分量的數據類型,這里頂點的坐標分量是浮點型數據,故設為GL_FLOAT;
normalized ——該參數表示頂點存儲前是否需要進行歸一化;
stride ——該參數指定兩個頂點數據之間間隔的字節數,在本例中,頂點是連續存儲的,故設為0;
pointer ——頂點數據在緩存對象中起始地址,在本例中,因為緩存對象中只存放了一個頂點數組,所以這一值設為0。
IV: 使用glEnableVertexAttribArray來啟用與index索引相關聯的頂點數組。雖然前面設置了頂點數組屬性,但如果沒有啟用的話,數據依然無法被OpenGL拿到。
2. 圖形繪制步驟
數據及其格式設置后之后,就是根據這一數據進行圖形的繪制。這部分代碼是寫在display函數中的,這一函數可能會調用多次。在這個顯示函數中,最重要的一個OpenGL API就是glDrawArray函數——繪制基本圖形,其函數簽名如下:
void glDrawArray(GLenum mode, GLint first, GLsizei count);
mode ——指定你要繪制的圖元類型,比如三角形是GL_TRIANGLES,直線就是GL_LINES,閉合的直線就是GL_LINE_LOOP,頂點就是GL_POINTS。本例中要繪制三角形,故設為GL_TRIANGLES。
first ——指定繪制圖形時的起始頂點,本例中從第0個頂點開始;
count ——要繪制的頂點數,本例中設置為6。
給這個函數設置不同的值,將出現不同的效果——可以使用不同的頂點來繪制不同的圖形。
剩下的,三個接口:
glClear(GLbitfield mask);
清空指定的緩存數據。每一次新的繪制,當然需要將上一次繪制過程中產生的一些數據給清空,以防止其對后一次繪制產生影響。在OpenGL中有三種緩存數據,分別是顏色緩存,深度緩存和模板緩存。其中深度緩存只有在三維的情形中才用到。本例中清空了顏色緩存。
glFlush();
這個接口是一個同步接口——等待繪制完成再往下執行。這里需要說明的是,OpenGL采用的是客戶機-服務器模式運作的——我們的應用程序就是客戶機,顯卡就是服務器。每一次執行OpenGL API相當於給顯卡發送一條命令,一般情況下,這些命令是以異步的方式執行的。如果我們應用程序需要等顯卡命令執行完畢才能往下執行,就需要調用這個函數。
最后一個,glBindVertexArray——綁定操作對象,即glDrawArray繪制的是當前綁定的頂點數組。在本例中(只限本例)是可以不調用的,因為在Initialize函數中已經調用過了,並且display函數中沒有其他的綁定。
至此,我們運行程序,應該能夠看到繪制出來的是兩個白色的三角形。
第三步:添加着色器
我們先來看看OpenGL中的繪制管線,如下圖所示:
所謂繪制管線,就是OpenGL在繪制圖像過程中所經過的操作步驟,主要有:求值器、逐頂點操作、圖元裝配、紋理貼圖、光柵化、片元操作等等。這樣的繪制管線稱為固定繪制管線,因為一旦繪制開始,繪制過程人為無法干預。
着色器的引入,將繪制管線從固定的管線變為可編程的繪制管線。所謂可編程,是指在繪制固定繪制管線的過程上,可以加入我們的邏輯。什么意思呢?相當於OpenGL提供給我們一個編程框架(而不僅僅是一套API),我們可以定制其中的某些部分。這樣,可以更靈活的控制繪制管線,實現更好的繪制效果。這樣就得到了下面的繪制管線:
可以看出,在固定管線的基礎上,增加了頂點着色器、幾何着色器、片元着色器,其中頂點着色器和片元着色器最重要。頂點着色器是對輸入頂點進行處理的,如對頂點進行三維變換,添加顏色等;片元着色器則是對光柵化后的片元進行處理,如紋理貼圖、執行光照計算等等,也就是計算渲染顏色的(我想這也是着色器最根本的含義吧!)。這些着色器使用GLSL語言寫的,這個語言語法和C語言很類似,並定義了一些內置變量和便於我們處理的接口API。
為了將上述白色三角形變為藍色,我們可以為其添加一個片元着色器,着色器輸出的就是片元的顏色。代碼很簡單,就是輸出一個顏色值,如下:
1 #version 330 core 2 3 out vec4 fColor; 4 5 void main() 6 { 7 fColor = vec4(0.0, 0.0, 1.0, 1.0); 8 }
很簡單,第1行是GLSL的版本信息。第3行表明該着色器有一個輸出——計算后的顏色,在main函數中,對該輸出變量賦值一個4維顏色向量,R通道值為0.0,G通道值為0.0,B通道值為1.0,A通道值為1.0(透明度),所以最后顏色就是藍色的。
寫完這個着色器程序,顯卡並不知道這個着色器的存在,因此還需要一些步驟——對着色器的編譯、鏈接並加載到顯卡中。這部分內容蠻多的,我們直接使用本書提供的代碼,LoadShader函數來加載着色器程序,就是在Initialize函數的最后加入下面這段代碼:
1 ShaderInfo shaders[] = { 2 { GL_FRAGMENT_SHADER, "triangles.frag" }, 3 { GL_NONE, NULL} 4 }; 5 GLuint program = LoadShaders(shaders); 6 glUseProgram(program);
最后,書上還給出了一個頂點着色器,其實這個頂點着色器可以不用的,就是將輸入的頂點坐標設置給內置變量gl_Position,有和沒有效果都是一樣的,為了完整性還是把它貼上來吧!
1 #version 330 core 2 3 layout(location = 0) in vec4 vPosition; 4 5 void main() 6 { 7 gl_Position = vPosition; 8 }
當然,也要把它加載到顯卡中。至此,一個簡單的OpenGL程序就寫完了,其實修改一下頂點數據或者修改一下着色器程序,我們可以畫出其他一些效果出來。比如可以畫一個五角星出來,或者畫一個彩色的圖形出來。
3 總結
最后,總結一下:主要學習了緩存對象和頂點數組對象的創建,向緩存對象拷貝數組數據,設置頂點數組對象屬性的相關接口。最后了解了OpenGL渲染管線——固定渲染管線和可編程渲染管線,在我們的程序中加入了片元着色器和集合着色器。