渲染是一個遊戲引擎最重要的部分。渲染的效率決定了遊戲的流暢度清晰度,跟前面的介紹的內容相比,渲染是最具技術含量的事情,也是一個需要很多專業知識的事情。這裏我們有這個機會,來學習下一個遊戲引擎的渲染是怎麼做的。Cocos2Dx是一個2D框架,可以簡單地看做z軸在一個平面上,Cocos2Dx採用的OpenGL技術決定了往3D渲染上面走也不是不行的。最新3.2版本已經支持3D骨骼動畫的CCSprite。3.2版本還有很多其他的變化,可謂是一次傷筋動骨的大版本,現在還不跳躍,繼續基於2.2.3進行分析。
這篇文章先分析簡單的CCSprite和CCLayer是怎麼渲染出來的,過一下整個渲染的流程。後續我們再分析一些高級功能。
要能夠使用OpenGL,肯定第一步是要先初始化OpenGL,並且創建一個可以在上面繪製的場景的窗口。OpenGL本身是跟操作系統,窗口系統無關的圖形接口。因此初始化肯定也是平臺相關。這就是爲什麼CCEGLView在每個平臺下面都有自己實現的原因。繼續使用Windows平臺進行分析,其他平臺大同小異。
回憶前面講到遊戲啓動流程的時候,我們會在AppDelegate::applicationDidFinishLaunching調用pDirector->setOpenGLView(CCEGLView::sharedOpenGLView())來設置OpenGL使用的View。sharedOpenGLView是CCEGLView的一個單例訪問接口。CCEGLView::sharedOpenGLView第一次被調用的時候會首先創建一個平臺相關的CCEGLView對象,然後調用其Create()函數。
Windows上面的Create函數我們已經看過,它會設置窗口樣式,然後創建一個窗口。窗口創建完畢後會調用CCEGLView::initGL。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
//file:\cocos2dx\platform\win32\CCEGLView.cpp bool CCEGLView::initGL() { m_hDC = GetDC(m_hWnd); SetupPixelFormat(m_hDC); m_hRC = wglCreateContext(m_hDC); wglMakeCurrent(m_hDC, m_hRC); const GLubyte* glVersion = glGetString(GL_VERSION); if ( atof (( const char *)glVersion) < 1.5 ) { return false ; } GLenum GlewInitResult = glewInit(); if (GLEW_OK != GlewInitResult) { return false ; } if (glew_dynamic_binding() == false ) { return false ; } glEnable(GL_VERTEX_PROGRAM_POINT_SIZE); return true ; } |
CCEGLView::initGL首先設置像素格式。像素格式是OpenGL窗口的重要屬性,它包括是否使用雙緩衝,顏色位數和類型以及深度位數等。像素格式可由Windows系統定義的所謂像素格式描述子結構來定義(PIXELFORMATDESCRIPTOR)。應用程序通過調用ChoosePixelFormat函數來尋找最接近應用程序所設置的象素格式,然後調用SetPixelFormat來設置使用的像素格式。WglCreateContext函數創建一個新的OpenGL渲染描述表,此描述表必須適用於繪製到由m_hDC返回的設備。這個渲染描述表將有和設備上下文(DC)一樣的像素格式。wglMakeCurrent將剛剛創建的渲染描述符表設置爲當前線程使用的渲染環境。完成這些操作之後,OpenGL的基本環境已經準備好了。後續的處理是爲了使用OpenGL擴展。glewInit初始化GLEW。GLEW(OpenGL Extension Wrangler Library)是一個跨平臺的C/C++庫,用來查詢和加載OpenGL擴展。OpenGL並存的版本比較多,有些特性並沒有存在於OpenGL核心當中,是以擴展形式存在。作爲一個跨平臺的遊戲框架,處理OpenGL的版本差異是必須要做的。glew_dynamic_binding啓用幀緩衝對象(FBO)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
static void SetupPixelFormat( HDC hDC) { int pixelFormat; PIXELFORMATDESCRIPTOR pfd = { sizeof (PIXELFORMATDESCRIPTOR), // 大小 1, // 版本 PFD_SUPPORT_OPENGL | // 像素格式必須支持OpenGL PFD_DRAW_TO_WINDOW | // 渲染到窗口中 PFD_DOUBLEBUFFER, // 支持雙緩衝 PFD_TYPE_RGBA, // 申請RGBA顏色格式 32, // 選定的顏色深度 0, 0, 0, 0, 0, 0, // 忽略的色彩位 0, // 無Alpha緩存 0, // 忽略Shift Bit 0, // 無累加緩存 0, 0, 0, 0, // 忽略聚集位 24, // 24位的深度緩存 8, // 蒙版緩存 0, // 無輔助緩存 PFD_MAIN_PLANE, // 主繪圖層 0, // 保留 0, 0, 0, // 忽略層遮罩 }; pixelFormat = ChoosePixelFormat(hDC, &pfd); SetPixelFormat(hDC, pixelFormat, &pfd); } |
從設置的像素格式上可以看出,Windows上啓用了雙緩衝,使用RGBA顏色,32位顏色深度。
不同平臺上面,OpenGL的初始化流程不完全一樣。詳細的區別可以查看平臺相關的CCEGLView類。
OpenGL初始化完成後,就可以進行繪製操作了。CCDisplayLinkDirector::mainLoop會調用drawScene()來繪製場景。drawScene本身做的工作還比較多,比如前面介紹的調度器處理。這裏我們拋開渲染無關的內容,只看跟渲染緊密相關的代碼實現。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
void CCDirector::drawScene( void ) { //清除顏色緩衝區和深度緩衝去 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); //該節點的轉換矩陣入棧 kmGLPushMatrix(); // draw the scene if (m_pRunningScene) { m_pRunningScene->visit(); } // 該節點的轉換矩陣出棧 kmGLPopMatrix(); // 交換緩衝區 if (m_pobOpenGLView) { m_pobOpenGLView->swapBuffers(); } } |
一個場景包含當前窗口可以看到的所有內容。由於OpenGL是基於狀態的,比如設置的顏色,如果不去清楚或者修改就一直會使用下去。我們當然不希望上一次繪製場景產生的緩衝區數據被繪製到現在的場景當中。所以drawScene()首先清楚顏色緩衝區和深度緩衝區。
關於kmGLPushMatrix和kmGLPopMatrix,有必要先介紹下背景。Cocos2Dx使用了模型視圖矩陣、投影矩陣和紋理矩陣。模型視圖矩陣完成模型和視圖的變換,包括平移、旋轉和縮放。投影矩陣完成三維空間的頂點映射到二維的屏幕上,有兩種投影:正射投影和透視投影。紋理矩陣用來對紋理進行變換。關於矩陣變換參考注1的資料。
OpenGL通過使用齊次座標,平移、旋轉和縮放變換都可以通過矩陣乘法完成。但是矩陣乘法是有順序的,左乘和右乘的結果是不一樣的,因此應該變換的順序很重要。由於存在多個矩陣,並且爲了簡化複雜模型變換,OpenGL使用棧來存放矩陣。kmGLPushMatrix()函數將當前棧的棧頂矩陣複製後壓棧,對應的kmGLPopMatrix()用於出棧,還有kmGLLoadIdentity()用於將棧頂矩陣初始化成單位矩陣。需要注意的是,OpenGL使用的矩陣是列優先的矩陣。
kmGLPushMatrix()內部調用lazyInitialize()來延遲初始化Cocos2Dx使用的三類矩陣:模型視圖矩陣、投影矩陣和紋理矩陣。初始化的過程中,不但爲三個類型的矩陣都創建了一個堆棧(內部通過數組實現的),還在每個堆棧中都壓入一個單位矩陣。單位矩陣左乘任何頂點,頂點不會變化。單位矩陣不會影響後續的變換。current_stack表示了當前使用的堆棧,可以通過kmGLMatrixMode(mode)來切換當前使用的矩陣堆棧。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
void lazyInitialize() { if (!initialized) { kmMat4 identity; km_mat4_stack_initialize(&modelview_matrix_stack); km_mat4_stack_initialize(&projection_matrix_stack); km_mat4_stack_initialize(&texture_matrix_stack); current_stack = &modelview_matrix_stack; initialized = 1; kmMat4Identity(&identity); km_mat4_stack_push(&modelview_matrix_stack, &identity); km_mat4_stack_push(&projection_matrix_stack, &identity); km_mat4_stack_push(&texture_matrix_stack, &identity); } } |
CCDirector::drawScene()第一次被調用的時候,會初始化模型視圖矩陣、投影矩陣和紋理矩陣分別對應的矩陣棧。然後在每個棧中壓入一個單位矩陣。隨後kmGLPushMatrix()複製一個模式視圖矩陣棧的單位矩陣,然後放到棧頂上。然後渲染當前的場景,最後將棧頂的矩陣出棧。爲什麼要這樣做呢?設想當前的節點需要做一個平移操作,對應的變換矩陣是T1(壓棧),假設當前節點的子節點Z序比當前節點大,那麼當前節點的所有頂點執行T1p以後,對於它的子節點也需要做平移,使用的同樣是變換矩陣T1。這個節點繪製完以後,就該繪製下一個節點了(出棧),下一個節點的變換矩陣爲T2(壓棧),再用T2做該節點及其子節點的變換。可以看出,這樣的處理方式非常自然、簡單。
CCScene的visit繼承的是CCNode的visit,它的實現我們前面已經分析過了。這裏再看看跟渲染相關的內容。CCNode::visit()先複製一個棧頂元素並壓棧,然後計算出一個此節點相對於父節點的變換矩陣,然後把它轉換爲OpenGL格式的矩陣並右乘在當前繪圖矩陣之上,從而得到此節點的世界變換矩陣。隨後,根據子節點的Z序確定渲染順序,然後根據渲染順序依次渲染子節點或自身。如果是渲染自身,直接調用draw函數。CCSprite的draw函數爲空,什麼都不做。當前節點及其子節點繪製完成之後,再執行出棧操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
void CCNode::visit() { kmGLPushMatrix(); this ->transform(); CCNode* pNode = NULL; unsigned int i = 0; if (m_pChildren && m_pChildren->count() > 0) { sortAllChildren(); // draw children zOrder < 0 ccArray *arrayData = m_pChildren->data; for ( ; i < arrayData->num; i++ ) { pNode = (CCNode*) arrayData->arr[i]; if ( pNode && pNode->m_nZOrder < 0 ) { pNode->visit(); } else { break ; } } // self draw this ->draw(); for ( ; i < arrayData->num; i++ ) { pNode = (CCNode*) arrayData->arr[i]; if (pNode) { pNode->visit(); } } } else { this ->draw(); } kmGLPopMatrix(); } |
CCNode::visit的核心是transform()。transform()根據當前節點的位置、旋轉角度和縮放比例等屬性計算出一個此節點相對於父節點的變換矩陣,然後把它轉換爲OpenGL格式的矩陣並右乘在當前繪圖矩陣之上。transform()是負責CCSprite處理的各種變換的核心函數。下一篇文章繼續討論。
現在繼續分析渲染的流程。前面提到了,CCScrene的draw()函數不做任何事情。下面我們依次看CCSprite和CCLayer是如何繪製出來的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
void CCSprite::draw( void ) { CC_PROFILER_START_CATEGORY(kCCProfilerCategorySprite, "CCSprite - draw" ); CC_NODE_DRAW_SETUP(); ccGLBlendFunc( m_sBlendFunc.src, m_sBlendFunc.dst ); ccGLBindTexture2D( m_pobTexture->getName() ); ccGLEnableVertexAttribs( kCCVertexAttribFlag_PosColorTex ); long offset = ( long )&m_sQuad; // vertex int diff = offsetof( ccV3F_C4B_T2F, vertices); glVertexAttribPointer(kCCVertexAttrib_Position, 3, GL_FLOAT, GL_FALSE, kQuadSize, ( void *) (offset + diff)); // texCoods diff = offsetof( ccV3F_C4B_T2F, texCoords); glVertexAttribPointer(kCCVertexAttrib_TexCoords, 2, GL_FLOAT, GL_FALSE, kQuadSize, ( void *)(offset + diff)); // color diff = offsetof( ccV3F_C4B_T2F, colors); glVertexAttribPointer(kCCVertexAttrib_Color, 4, GL_UNSIGNED_BYTE, GL_TRUE, kQuadSize, ( void *)(offset + diff)); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); CHECK_GL_ERROR_DEBUG(); CC_INCREMENT_GL_DRAWS(1); CC_PROFILER_STOP_CATEGORY(kCCProfilerCategorySprite, "CCSprite - draw" ); } |
CCSprite::draw()首先設置使用的OpenGL狀態。然後設置混合函數,綁定紋理。隨後設置頂點數據,包括頂點座標、頂點顏色和紋理座標。因爲使用的是頂點數組的方式繪圖,所以調用glDrawArrays繪製。GL_TRIANGLE_STRIP參數的意義見注2。代碼裏面用了很多EMSCRIPTEN宏,他是一個Javscript的LLVM後端處理庫,應該是跟JS相關的支持代碼。
CC_NODE_DRAW_SETUP設置繪圖環境。實際上設置了OpenGL服務器端的狀態和着色器。OpenGL是C/S架構,因此客戶端和服務器端都有狀態需要維護。ccGLEnable在當前版本上什麼都沒有做,使用服務器端默認狀態。着色器的設置,首先選擇當前頂點或片段使用的着色器,然後檢查同一着色器使用的Uniform數據是否有變化,有變化的話,重新設置着色器使用的Uniform數據。關於Uniform,參見注4。
1
2
3
4
5
6
7
8
|
#define CC_NODE_DRAW_SETUP() \ do { \ ccGLEnable(m_eGLServerState); \ { \ getShaderProgram()->use(); \ getShaderProgram()->setUniformsForBuiltins(); \ } \ } while (0) |
混合的設置在渲染的過程中也非常重要。它決定了在視景框內不同深度的模型顏色如何顯示出來,透明效果就依靠它實現。渲染的過程是有順序的,glBlendFunc函數決定後畫上去的顏色與已經存在的顏色如何混合到一起。把將要畫上去的顏色稱爲“源顏色”,把原來的顏色稱爲“目標顏色”。OpenGL 會把源顏色和目標顏色各自取出,並乘以一個係數(源顏色乘以的係數稱爲“源因子”,目標顏色乘以的係數稱爲“目標因子”),然後相加,這樣就得到了新的顏色。關於OpenGL混合的詳細計算模型,參考注5。
CCSprite混合使用的源因子和目標因子,以及使用的着色器在CCSprite::initWithTexture裏面指定的。可以看出CCSprite的源因子是GL_ONE(CC_BLEND_SRC),目標因子是GL_ONE_MINUS_SRC_ALPHA(CC_BLEND_DST)。這樣的參數組合,表示使用1.0作爲源因子,實際上相當於完全的使用了這種源顏色參與混合運算,目標顏色乘以1.0減去源顏色的alpha值。CCSprite使用的着色器是kCCShader_PositionTextureColor。
CCSprite::draw使用glVertexAttribPointer來設置定點數據。它的原型:
1
|
void glVertexAttribPointer( GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const GLvoid * pointer); |
-
index是要修改的定點屬性的索引值。實際上就是指定,現在設置的頂點數據是頂點座標,還是紋理座標,還是頂點顏色。
-
size是頂點數據的大小。
-
type是頂點數據的類型。CCSprite::draw設置的頂點座標是3個浮點數表示的;紋理座標是兩個浮點數表示的;顏色是4個無符號數表示的。
-
normalized是否進行歸一化。顏色需要歸一化操作,也就是講[0-255]整數的顏色,歸一化爲[0-1]浮點數的顏色。
-
stride是設置同一類型的數據之間的間隔。這個標誌可以讓我們把顏色數據、頂點座標數據和紋理座標數據放到一個數組裏面。
-
pointer指向數據地址。
CCSprite使用了一個四元組ccV3F_C4B_T2F_Quad來存放四個頂點ccV3F_C4B_T2F,ccV3F_C4B_T2F包含了頂點的座標、顏色和紋理座標。因此對於頂點座標數據,第一個座標與第二個座標之間,還有一個大小爲ccV3F_C4B_T2F的數據間隔。這就是爲什麼glVertexAttribPointer的stride參數設置kQuadSize。紋理座標和顏色放在頂點座標之後,在通過glVertexAttribPointer設置它們的時候需要計算偏移,offsetof就是完成這個計算的。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
typedef struct _ccV3F_C4B_T2F_Quad { ccV3F_C4B_T2F tl; //! top left ccV3F_C4B_T2F bl; //! bottom left ccV3F_C4B_T2F tr; //! top right ccV3F_C4B_T2F br; //! bottom right } ccV3F_C4B_T2F_Quad; typedef struct _ccV3F_C4B_T2F { ccVertex3F vertices; //! vertices (3F) ccColor4B colors; //! colors (4B) ccTex2F texCoords; // tex coords (2F) } ccV3F_C4B_T2F; |
由於CCSprite繼承了CCNodeRGBA,它可以設置繪製使用的顏色和不透明度。默認顏色是黑色(255, 255, 255, 255)。
CCLayer並沒有draw()的缺省實現,使用的是它繼承的CCNode的draw()函數,什麼都不做。但它的子類CCLayerColor可以自己繪製出來,它繼承了CCLayerRGBA和CCBlendProtocol(CCSprite通過CCTextureProtocol間接繼承CCBlendProtocol)。我們看看CCLayerColor是如何繪製自身的。
1
2
3
4
5
6
7
8
9
10
11
12
|
void CCLayerColor::draw() { CC_NODE_DRAW_SETUP(); ccGLEnableVertexAttribs( kCCVertexAttribFlag_Position | kCCVertexAttribFlag_Color ); // vertex glVertexAttribPointer(kCCVertexAttrib_Position, 2, GL_FLOAT, GL_FALSE, 0, m_pSquareVertices); // color glVertexAttribPointer(kCCVertexAttrib_Color, 4, GL_FLOAT, GL_FALSE, 0, m_pSquareColors); ccGLBlendFunc( m_tBlendFunc.src, m_tBlendFunc.dst ); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); CC_INCREMENT_GL_DRAWS(1); } |
繪製的步驟與CCSprite類似。只是頂點沒有開啓紋理座標,CCLayerColor只是顯示設置的顏色,並不支持紋理貼圖。因此,glVertexAttribPointer設置頂點數據的時候,沒有綁定和設置紋理座標。
CCLayerColor::initWithColor裏面設置了CCLayerColor混合方式和着色器。混合使用的源因子是GL_SRC_ALPHA,目標因子是GL_ONE_MINUS_SRC_ALPHA。這個混合組合是比較常見的組合。表示源顏色乘以自身的alpha值,目標顏色乘以1.0減去源顏色的alpha值。這樣一來,源顏色的alpha值越大,則產生的新顏色中源顏色所佔比例就越大,而目標顏色所佔比例則減小。CCLayerColor使用的着色器是kCCShader_PositionColor。
現在已經看完了怎麼渲染出CCSprite和CCLayerColor。自己派生的CCSprite渲染也是一樣的方式。遊戲退出的時候需要做OpenGL的清理工作。清理工作放在CCEGLView::destroyGL()裏面。Windows上面依次調用:
1
2
|
wglMakeCurrent(m_hDC, NULL); wglDeleteContext(m_hRC); |
設置設備描述符表使用的圖形描述符爲空,然後刪除圖形描述符。
注1:模式視圖矩陣和投影矩陣http://www.songho.ca/opengl/gl_transform.html。該網站上還有模擬矩陣變換的小程序,非常直觀。
注2:GL_TRIANGLES是以每三個頂點繪製一個三角形。第一個三角形使用頂點v0,v1,v2,第二個使用v3,v4,v5,以此類推。如果頂點的個數n不是3的倍數,那麼最後的1個或者2個頂點會被忽略。GL_TRIANGLE_STRIP則稍微有點複雜,其規律是:
-
構建當前三角形的頂點的連接順序依賴於要和前面已經出現過的2個頂點組成三角形的當前頂點的序號的奇偶性(如果從0開始):
-
如果當前頂點是奇數:組成三角形的頂點排列順序:T = [n-1 n-2 n]。
-
如果當前頂點是偶數:組成三角形的頂點排列順序:T = [n-2 n-21 n]。
以上圖爲例,第一個三角形,頂點v2序號是2,是偶數,則頂點排列順序是v0,v1,v2。第二個三角形,頂點v3序號是3,是奇數,則頂點排列順序是v2,v1,v3,第三個三角形,頂點v4序號是4,是偶數,則頂點排列順序是v2,v3,v4,以此類推。這個順序是爲了保證所有的三角形都是按照相同的方向繪製的,使這個三角形串能夠正確形成表面的一部分。對於某些操作,維持方向是很重要的,比如剔除。注意:頂點個數n至少要大於3,否則不能繪製任何三角形。GL_TRIANGLE_FAN與GL_TRIANGLE_STRIP類似,不過它的三角形的頂點排列順序是T = [n-1 n-2 n]。各三角形形成一個扇形序列。
注3:EMSCRIPTEN:Emscripten 是 Mozilla 的 Alon Zakai 開發的一個獨特 LLVM 後端,可以將任意 LLVM 中間碼編譯成 JavaScript,大大簡化了現有代碼在 Web 時代的重用。Emscripten 並非通常的 LLVM 後端,本身使用 JavaScript 寫成。它可以將任何通過 LLVM 前端(比如 C/C++ Clang )生成的 LLVMIR 中間碼編譯成 JavaScript,從而顯著降低移植現有代碼庫到 Web 環境的損耗。
注4:uniform變量是外部application程序傳遞給(vertex和fragment)shader的變量。因此它是application通過函數glUniform**()函數賦值的。在(vertex和fragment)shader程序內部,uniform變量就像是C語言裏面的常量(const ),它不能被shader程序修改。(shader只能用,不能改)。如果uniform變量在vertex和fragment兩者之間聲明方式完全一樣,則它可以在vertex和fragment共享使用。(相當於一個被vertex和fragment
shader共享的全局變量)。uniform變量一般用來表示:變換矩陣,材質,光照參數和顏色等信息。
注5:OpenGL可以設置混合運算方式,包括加、減、取兩者中較大的、取兩者中較小的、邏輯運算等。下面用數學公式來表達一下這個運算方式。假設源顏色的四個分量(指紅色,綠色,藍色,alpha值)是(Rs, Gs, Bs, As),目標顏色的四個分量是(Rd, Gd, Bd, Ad),又設源因子爲(Sr, Sg, Sb, Sa),目標因子爲(Dr, Dg, Db, Da)。則混合產生的新顏色可以表示爲:(Rs*Sr+Rd*Dr, Gs*Sg+Gd*Dg, Bs*Sb+Bd*Db, As*Sa+Ad*Da)。源因子和目標因子是可以通過glBlendFunc函數來進行設置的。glBlendFunc有兩個參數,前者表示源因子,後者表示目標因子。這兩個參數可以是多種值,下面介紹比較常用的幾種。
-
GL_ZERO: 表示使用0.0作爲因子,實際上相當於不使用這種顏色參與混合運算。
-
GL_ONE: 表示使用1.0作爲因子,實際上相當於完全的使用了這種顏色參與混合運算。
-
GL_SRC_ALPHA:表示使用源顏色的alpha值來作爲因子。
-
GL_DST_ALPHA:表示使用目標顏色的alpha值來作爲因子。
-
GL_ONE_MINUS_SRC_ALPHA:表示用1.0減去源顏色的alpha值來作爲因子。
-
GL_ONE_MINUS_DST_ALPHA:表示用1.0減去目標顏色的alpha值來作爲因子。