一、OpenGL基礎
游戲引擎是對底層繪圖接口的包裝,Cocos2d-x 也一樣,它是對不同平台下 OpenGL 的包裝。OpenGL 全稱為 Open Graphics Library,是一個開放的、跨平台的高性能圖形接口。OpenGL ES 則是 OpenGL 在移動設備上的衍生版本,具備與 OpenGL 一致的結構,包含了常用的圖形功能。Cocos2d-x 就是一個基於 OpenGL 的游戲引擎,因此它的繪圖部分完全由 OpenGL 實現。OpenGL 是一個基於 C 語言的三維圖形 API,基本功能包含繪制幾何圖形、變換、着色、光照、貼圖等。除了基本功能,OpenGL還提供了諸如曲面圖元、光柵操作、景深、shader 編程等高級功能。
(1)狀態機:
OpenGL 是一個基於狀態的繪圖模型,我們把這種模型稱為狀態機。為了正確地繪制圖形,我們需要把 OpenGL 設置到合適的狀態,然后調用繪圖指令。(繪圖流程和狀態機優勢)。
(2)坐標系:OpenGL 是一個三維圖形接口,在程序中使用右手三維坐標系。
(3)渲染流水線:
當我們把繪制的圖形傳遞給 OpenGL 后,OpenGL 還要進行許多操作才能完成 3D 空間到屏幕的投影。通常,渲染流水線過程有如下幾步:顯示列表、求值器、頂點裝配、像素操作、紋理裝配、光柵化和片斷操作等。OpenGL 從 2.0 版本開始引入了可編程着色器(shader)。
(4)繪圖函數:
(5)矩陣與變換:OpenGL 對頂點進行的處理實際上可以歸納為接受頂點數據、進行投影、得到變換后的頂點數據這 3 個步驟。
在計算機中,坐標變換是通過矩陣乘法實現的。
注:詳細參見《cocos2d-x高級開發教程》、《OpenGL編程指南》
二、Cocos2d-x繪圖原理
void CCSprite::draw(void) { //1. 初始准備 CC_PROFILER_START_CATEGORY(kCCProfilerCategorySprite, "CCSprite - draw"); CCAssert(!m_pobBatchNode, "If CCSprite is being rendered by CCSpriteBatchNode, CCSprite#draw SHOULD NOT be called"); CC_NODE_DRAW_SETUP(); //2. 顏色混合函數 ccGLBlendFunc( m_sBlendFunc.src, m_sBlendFunc.dst ); //3. 綁定紋理 if (m_pobTexture != NULL) { ccGLBindTexture2D( m_pobTexture->getName() ); } else { ccGLBindTexture2D(0); } // // Attributes // //4. 繪圖 ccGLEnableVertexAttribs( kCCVertexAttribFlag_PosColorTex ); #define kQuadSize sizeof(m_sQuad.bl) 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(); //5. 調試相關的處理 #if CC_SPRITE_DEBUG_DRAW == 1 //調試模式 1:繪制邊框 // draw bounding box CCPoint vertices[4]={ ccp(m_sQuad.tl.vertices.x,m_sQuad.tl.vertices.y), ccp(m_sQuad.bl.vertices.x,m_sQuad.bl.vertices.y), ccp(m_sQuad.br.vertices.x,m_sQuad.br.vertices.y), ccp(m_sQuad.tr.vertices.x,m_sQuad.tr.vertices.y), }; ccDrawPoly(vertices, 4, true); #elif CC_SPRITE_DEBUG_DRAW == 2 // draw texture box //調試模式 2:繪制紋理邊緣 CCSize s = this->getTextureRect().size; CCPoint offsetPix = this->getOffsetPosition(); CCPoint vertices[4] = { ccp(offsetPix.x,offsetPix.y), ccp(offsetPix.x+s.width,offsetPix.y), ccp(offsetPix.x+s.width,offsetPix.y+s.height), ccp(offsetPix.x,offsetPix.y+s.height) }; ccDrawPoly(vertices, 4, true); #endif // CC_SPRITE_DEBUG_DRAW CC_INCREMENT_GL_DRAWS(1); CC_PROFILER_STOP_CATEGORY(kCCProfilerCategorySprite, "CCSprite - draw"); }
觀察 draw 方法的代碼可知,它包含 5 部分,其中前 4 個部分較為重要。第 1 部分主要負責設置 OpenGL 狀態,如開啟貼圖等。第 2 部分負責設置顏色混合模式,與貼圖渲染的方式有關。第 3、4 部分分別負責綁定紋理與繪圖。這與 10.1.2 節中提供的繪圖代碼流程類似,首先綁定紋理,然后分別設置頂點坐標、紋理坐標以及頂點顏色,最終繪制幾何體,其中頂點坐標、紋理坐標和頂點顏色需要在調用 draw 方法前計算出來。第 5 部分進行一些調試相關的處理操作。
同時我們也可以觀察到,在進行一次普通精靈的繪制過程中,我們需要綁定一次紋理,設置一次頂點數據,繪制一次三角形帶。對 OpenGL 的每一次調用都會花費一定的開銷,當我們需要大量繪制精靈的時候,性能就會快速下降,甚至會導致幀率降低。因此,針對不同的情況,可以采取不同的策略來降低 OpenGL 調用次數,從而大幅提高游戲性能。這些技巧我們將在后面詳細介紹,現在繼續關注 Cocos2d-x 的繪圖原理。
(1)渲染樹的繪制:
回顧 Cocos2d-x 游戲的層次:導演類 CCDirector 直接控制渲染樹的根節點--場景(CCScene),場景包含多個層CCLayer),層中包含多個精靈(CCSprite)。實際上,每一個上述的游戲元素都在渲染樹中表示為節點(CCNode),游戲元素的歸屬關系就轉換為了節點間的歸屬關系,進而形成樹結構。
CCNode 的 visit 方法實現了對一棵渲染樹的繪制。為了繪制樹中的一個節點,就需要繪制自己的子節點,直到沒有子節點可以繪制時再結束這個過程。因此,為了每一幀都繪制一次渲染樹,就需要調用渲染樹的根節點。換句話說,當前場景的visit 方法在每一幀都會被調用一次。這個調用是由游戲主循環完成的,在 cocos2d-x游戲引擎核心之一中,我們介紹了 Cocos2d-x 的調度原理,在游戲的每一幀都會運行一次主循環,並在主循環中實現對渲染樹的渲染。下面是簡化后的主循環代碼,在注釋中標明了對當前場景 visit 方法的調用:
void CCDirector::drawSceneSimplified() { _calculate_time(); if (! m_bPaused) m_pScheduler->update(m_fDeltaTime); if (m_pNextScene) setNextScene(); _deal_with_opengl(); if (m_pRunningScene) m_pRunningScene->visit(); //繪制當前場景 _do_other_things(); }
繪制父節點時會引起子節點的繪制,同時,子節點的繪制方式與父節點的屬性也有關。例如,父節點設置了放大比例,則子節點也會隨之放大;父節點移動一段距離,則子節點會隨之移動並保持相對位置不變。顯而易見,繪制渲染樹是一個遞歸的過程,下面我們來詳細探討 visit 的實現,相關代碼如下:
void CCNode::visit() { //1. 先行處理 if (!m_bIsVisible) { return; } //矩陣壓棧 kmGLPushMatrix(); //處理 Grid 特效 if (m_pGrid && m_pGrid->isActive()) { m_pGrid->beforeDraw(); } //2. 應用變換 this->transform(); //3. 遞歸繪圖 CCNode* pNode = NULL; unsigned int i = 0; if(m_pChildren && m_pChildren->count() > 0) { //存在子節點 sortAllChildren(); // draw children zOrder < 0 //繪制 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(); //繪制 zOrder > 0 的子節點 for( ; i < arrayData->num; i++ ) { pNode = (CCNode*) arrayData->arr[i]; if (pNode) { pNode->visit(); } } } else { //沒有子節點:直接繪制自身 this->draw(); } // reset for next frame //4. 恢復工作 m_nOrderOfArrival = 0; if (m_pGrid && m_pGrid->isActive()) { m_pGrid->afterDraw(this); } //矩陣出棧 kmGLPopMatrix(); }
(1)visit 方法分為 4 部分。第 1 部分是一些先行的處理,例如當此節點被設置為不可見時,則直接返回不進行繪制等。在這一步中,重要的環節是保存當前的繪圖矩陣,也就是注釋中的"矩陣壓棧"操作。繪圖矩陣保存好之后,就可以根據需要對矩陣進行任意的操作了,直到操作結束后再通過"矩陣出棧"來恢復保存的矩陣。由於所有對繪圖矩陣的操作都在恢復矩陣之前進行,因此我們的改動不會影響到以后的繪制。
(2)在第 2 部分中,visit 方法調用了 transform 方法進行一系列變換,以便把自己以及子節點繪制到正確的位置上。為了理解transform 方法,我們首先從 draw 方法的含義開始解釋。draw 方法負責把圖形繪制出來,但是從上一節的學習可知,draw方法並不關心紋理繪制的位置,實際上它僅把紋理繪制到當前坐標系中的原點(如圖 10-7a 所示)。為了把紋理繪制到正確的位置,我們需要在繪制之前調整當前坐標系,這個操作就由 transform 方法完成,經過變換后的坐標系恰好可以使紋理繪制到正確的位置(如圖 10-7b 所示)。關於 transform 方法,我們稍后將會討論。
(3)經過第 2 部分的變換后,我們得到了一個正確的坐標系,接下來的第 3 部分則開始繪圖。visit 方法中進行了一個判斷:如果節點不包含子節點,則直接繪制自身;如果節點包含子節點,則需要對子節點進行遍歷,具體的方式為首先對子節點按照 ZOrder 由小到大排序,首先對於 ZOrder 小於 0 的子節點,調用其 visit 方法遞歸繪制,然后繪制自身,最后繼續按次序把 ZOrder 大於 0 的子節點遞歸繪制出來。經過這一輪遞歸,以自己為根節點的整個渲染樹包括其子樹都繪制完了。
(4)最后是第 4 部分,進行繪制后的一些恢復工作。這一部分中重要的內容就是把之前壓入棧中的矩陣彈出來,把當前矩陣恢復成壓棧前的樣子。
以上部分構成了 Cocos2d-x 渲染樹繪制的整個框架,無論是精靈、層還是粒子引擎,甚至是場景,都遵循渲染樹節點的繪制流程,即通過遞歸調用 visit 方法來按層次次序繪制整個游戲場景。同時,通過 transform 方法來實現坐標系的變換。
三、坐標變換
在繪制渲染樹中,最關鍵的步驟之一就是進行坐標系的變換。沒有坐標系的變換,則無法在正確的位置繪制出紋理。同時,坐標系的變換在其他的場合(例如碰撞檢測中)也起着十分重要的作用。因此在這一節中,我們將介紹 Cocos2d-x 中的坐標變換功能。
void CCNode::transform() { kmMat4 transfrom4x4; // Convert 3x3 into 4x4 matrix //獲取相對於父節點的變換矩陣 transform4x4 CCAffineTransform tmpAffine = this->nodeToParentTransform(); CGAffineToGL(&tmpAffine, transfrom4x4.mat); // Update Z vertex manually //設置 z 坐標 transfrom4x4.mat[14] = m_fVertexZ; //當前矩陣與 transform4x4 相乘 kmGLMultMatrix( &transfrom4x4 ); // XXX: Expensive calls. Camera should be integrated into the cached affine matrix //處理攝像機與 Grid 特效 if ( m_pCamera != NULL && !(m_pGrid != NULL && m_pGrid->isActive()) ) { bool translate = (m_tAnchorPointInPoints.x != 0.0f || m_tAnchorPointInPoints.y != 0.0f); if( translate ) kmGLTranslatef(RENDER_IN_SUBPIXEL(m_tAnchorPointInPoints.x), RENDER_IN_SUBPIXEL(m_tAnchorPointInPoints.y), 0 ); m_pCamera->locate(); if( translate ) kmGLTranslatef(RENDER_IN_SUBPIXEL(-m_tAnchorPointInPoints.x), RENDER_IN_SUBPIXEL(-m_tAnchorPointInPoints.y), 0 ); } }
可以看到,上述代碼用到了許多以"km"為前綴的函數,這是 Cocos2d-x 使用的一個開源幾何計算庫 Kazmath,它是 OpenGL ES 1.0 變換函數的代替,可以為程序編寫提供便利。在這個方法中,首先通過nodeToParentTransform 方法獲取此節點相對於父節點的變換矩陣,然后把它轉換為 OpenGL 格式的矩陣並右乘在當前繪圖矩陣之上,最后進行了一些攝像機與 Gird 特效相關的操作。把此節點相對於父節點的變換矩陣與當前節點相連,也就意味着在當前坐標系的基礎上進行坐標系變換,得到新的合適的坐標系。這個過程中,變換矩陣等價於坐標系變換的方式.
"節點坐標系"指的是以一個節點作為參考而產生的坐標系,換句話說,它的任何一個子節點的坐標值都是由這個坐標系確定的,通過以上方法,我們可以方便地處理觸摸點,也可以方便地計算兩個不同坐標系下點之間的方向關系。例如,若我們需要判斷一個點在另一坐標系下是否在同一個矩形之內,則可以把此點轉換為世界坐標系,再從世界坐標系轉換到目標坐標系中,此后只需要通過 contentSize 屬性進行判斷即可,相關代碼如下:
bool IsInBox(CCPoint point) { CCPoint pointWorld = node1->convertToWorldSpace(point); CCPoint pointTarget = node2->convertToNodeSpace(pointWorld); CCSize contentSize = node2->getContentSize(); if(0 <= pointTarget.x && pointTarget.x <= contentSize.width && 0 <= pointTarget.y && pointTarget.y <= contentSize.height) return true; }
注:上面代碼中的point坐標是相對當前節點(也即相對自己)的坐標,比如當前節點A在父節點B中的坐標為(50, 100), 當取A的坐標為(0, 0)時,取到的是節點A的左下角位置,與節點A在父節點B中的位置無關。 得到在目標節點中的坐標同樣也是相對目標節點,所以當要判斷節點A是否在目標節點中的時候,只要判斷轉換得到的坐標的x, y是否在目標節點(0, 0)和(width, height)之間。
四、繪圖瓶頸:
(1)紋理過小:OpenGL 在顯存中保存的紋理的長寬像素數一定是 2 的冪,對於大小不足的紋理,則在其余部分填充空白,這無疑是對顯存極大的浪費;另一方面,同一個紋理可以容納多個精靈,把內容相近的精靈拼合到一起是一個很好的選擇。
(2)紋理切換次數過多:當我們連續使用兩個不同的紋理繪圖時,GPU 不得不進行一次紋理切換,這是開銷很大的操作,然而當我們不斷地使用同一個紋理進行繪圖時,GPU 工作在同一個狀態,額外開銷就小了很多,因此,如果我們需要批量繪制一些內容相近的精靈,就可以考慮利用這個特點來減少紋理切換的次數。
(3)紋理過大:顯存是有限的,如果在游戲中不加節制地使用很大的紋理,則必然會導致顯存緊張,因此要盡可能減少紋理的尺寸以及色深。
(1-)碎圖壓縮與精靈框幀:使用各自的紋理來創建精靈,由此導致的紋理過小和紋理切換次數過多是產生瓶頸的根源。針對這個問題,一個簡單的解決方案是碎圖合並與精靈框幀。(碎圖合並工具 TexturePacker)
(2-)批量渲染:有了足夠大的紋理圖后,就可以考慮從渲染次數上進一步優化了。如果不需要切換綁定紋理,那么幾個 OpenGL 的渲染請求是可以批量提交的,也就是說,在同一紋理下的繪制都可以一次提交完成。在 Cocos2d-x 中,我們提供了 CCSpriteBatchNode來實現這一優化。
(3-)色彩深度優化:默認情況下,我們導出的紋理圖片是 RGBA8888 格式的,它的含義是每個像素的紅、藍、綠、不透明度 4 個值分別占用 8 比特(相當於 1 字節),因此一個像素總共需要使用 4 個字節表示。若降低紋理的品質,則可以采用 RGBA4444 格式來保存圖片。RGBA4444 圖片的每一個像素中每個分量只占用 4 比特,因此一個像素總共占用 2 字節,圖片大小將整整減少一半。對於不透明的圖片,我們可以選擇無 Alpha 通道的顏色格式,例如 RGB565,可以在不增加尺寸的同時提高圖像品質。各種圖像編輯器通常都可以修改圖片的色彩深度,TexturePacker 也提供了這個功能。
五、繪圖技巧
(1)遮罩效果
遮罩效果又稱為剪刀效果,允許一切的渲染結果只在屏幕的一個指定區域顯示:開啟遮罩效果后,一切的繪制提交都是正常渲染的,但最終只有屏幕上的指定區域會被繪制。形象地說,我們將當前屏幕截圖成一張固定的畫布蓋在屏幕上,只挖空指定的區域使之能活動,而屏幕上的其他位置盡管如常更新,但都被掩蓋住了。 於是,我們可以在表盤上順序排列所有的數字,不該顯示的部分用遮罩效果蓋住,滾動的表盤效果可以借助遮罩得到快速的實現。
我們在數字類中添加遮罩效果,將不應該出現的數字隱藏起來。重載NumberScrollLabel::visit 方法,相關代碼如下所示:
void visit() { //啟動遮罩效果 glEnable(GL_SCISSOR_TEST); CCPoint pos = CCPointZero; pos = visibleNode->getParent()->convertToWorldSpace(pos); //獲取屏幕絕對位置 CCRect rect = CCRectMake(pos.x, pos.y, m_numberSize, m_numberSize); //設置遮罩效果 glScissor(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height); CCNode::visit(); //關閉遮罩效果 glDisable(GL_SCISSOR_TEST); }
這里我們選擇重寫 visit 函數來設置遮罩效果,對於"僅在 draw 中設置繪圖效果"原則是個小小的破例。這樣做是為了能成功遮擋所有子節點的無效繪圖。回想一下引擎中渲染樹的繪制過程,draw 方法並不是遞歸調用的,而 visit 方法是遞歸的,並且 visit 方法通過調用 draw 來實現繪圖。因此,我們在設置了遮罩效果后調用了父類的 visit,使繪制流程正常進行下去,最后在繪制完子節點后關閉遮罩效果。(詳細參見《cocos2d-x高級開發教程》11章)
(2)小窗預覽(截屏功能)
我們再為游戲添加一個小小的截屏功能,借此討論游戲中涉及的底層的數據交流。底層的數據交流必須介紹兩個類:CCImage 和 CCTexture2D,這是引擎提供的描述紋理圖片的類,也是我們和顯卡進行數據交換時主要涉及的數據結構。
CCImage 在"CCImage.h"中定義,表示一張加載到內存的紋理圖片。在其內部的實現中,紋理以每個像素的顏色值保存在內存之中。CCImage 通常作為文件和顯卡間數據交換的一個工具,因此主要提供了兩個方面的功能:一方面是文件的加載與保存,另一方面是內存緩沖區的讀寫。
我們可以使用 CCImage 輕松地讀寫圖片文件。目前,CCImage 支持 PNG、JPEG 和 TIFF 三種主流的圖片格式。下面列舉與文件讀寫相關的方法:
bool initWithImageFile(const char* strPath, EImageFormat imageType = kFmtPng); bool initWithImageFileThreadSafe(const char* fullpath, EImageFormat imageType = kFmtPng); bool saveToFile(const char* pszFilePath, bool bIsToRGB = true);
CCImage 也提供了讀寫內存的接口。getData 和 getDataLen 這兩個方法提供了獲取當前紋理的緩沖區的功能,而initWithImageData 方法提供了使用像素數據初始化圖片的功能。相關的方法定義如下:
unsigned char* getData(); int getDataLen(); bool initWithImageData(void* pData, int nDataLen, EImageFormat eFmt = kFmtUnKnown, int nWidth = 0, int nHeight = 0, int nBitsPerComponent = 8);
注意,目前僅支持從內存中加載 RGBA8888 格式的圖片。
另一個重要的類是 CCTexture2D,之前已經反復提及,它描述了一張紋理,知道如何將自己繪制到屏幕上。通過該類還可以設置紋理過濾、抗鋸齒等參數。該類還提供了一個接口,將字符串創建成紋理。
這里需要特別重提的兩點是:該類所包含的紋理大小必須是 2 的冪次,因此紋理的大小不一定就等於圖片的大小;另外,有別於 CCImage,這是一張存在於顯存中的紋理,實際上並不一定存在於內存中。
了解了 CCImage 和CCTexture2D 后,我們就可以添加截屏功能了。截屏應該是一個通用的功能,不妨寫成全局函數放在 MTUtil庫中,使其不依賴於任何一個類。首先,我們使用 OpenGL 的一個底層函數 glReadPixels 實現截圖:
void glReadPixels (GLint x, GLint y,GLsizei width, GLsizei height, GLenum format, GLenum type, GLvoid* pixels);
這個函數將當前屏幕上的像素讀取到一個內存塊 pixels 中,且 pixels 指針指向的內存必須足夠大。為此,我們設計一個函數 saveScreenToCCImage 來實現截圖功能,相關代碼如下:
unsigned char screenBuffer[1024 * 1024 * 8]; CCImage* saveScreenToCCImage(bool upsidedown = true) { CCSize winSize = CCDirector::sharedDirector()->getWinSizeInPixels(); int w = winSize.width; int h = winSize.height; int myDataLength = w * h * 4; GLubyte* buffer = screenBuffer; glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, buffer); CCImage* image = new CCImage(); if(upsidedown) { GLubyte* buffer2 = (GLubyte*) malloc(myDataLength); for(int y = 0; y <h; y++) { for(int x = 0; x <w * 4; x++) { buffer2[(h - 1 - y) * w * 4 + x] = buffer[y * 4 * w + x]; } } bool ok = image->initWithImageData(buffer2, myDataLength, CCImage::kFmtRawData, w, h); free(buffer2); } else { bool ok = image->initWithImageData(buffer, myDataLength, CCImage::kFmtRawData, w, h); } return image; }
這里我們使用 glReadPixels 方法將當前繪圖區的像素都讀取到了一個內存緩沖區內,然后用這個緩沖區來初始化 CCImage並返回。注意,我們設置了一個參數 upsidedown,當這個參數為 true 時,我們將所有像素倒序排列了一次。這是因為 OpenGL的繪制是從上到下的,如果直接使用讀取的數據,再次繪制時將上下倒置。
在這個函數的基礎上,我們在游戲菜單層中添加相關按鈕和響應操作就完成了截屏功能,相關代碼如下:
void GameMenuLayer::saveScreen(CCObject* sender) { CCImage* image = saveScreenToCCImage(); image->saveToFile("screen.png"); image->release(); }
實際上,引擎還提供了另一個很有趣的方法讓我們完成截圖功能。在 Cocos2d-x 中,我們實現了一個渲染紋理類CCRenderTexture,其作用是將繪圖從設備屏幕轉移到一張紋理上,從而使得一段連續的繪圖被保存到紋理中。這在 OpenGL的底層中並不罕見,有趣的地方就在於,我們可以使用這個渲染紋理類配合主動調用的繪圖實現截圖效果。下面的函數saveScreenToRenderTexture 同樣實現了截圖功能:
CCRenderTexture* saveScreenToRenderTexture() { CCSize winSize = CCDirector::sharedDirector()->getWinSize(); CCRenderTexture* render = CCRenderTexture::create(winSize.height, winSize.width); render->begin(); CCDirector::sharedDirector()->drawScene(); render->end(); return render; }
在上述代碼中,CCRenderTexture 的 begin 和 end 接口規定了繪圖轉移的時機,在這兩次函數調用之間的 OpenGL 繪圖都會被繪制到一張紋理上。注意,這里我們主動調用了導演類的繪制場景功能。但是根據引擎的接口規范,我們不建議這樣做,因為每次繪制都產生了 CCNode 類的 visit 函數的調用,但只要遵守不在 visit 中更改繪圖相關狀態的規范,可以保證不對后續繪圖產生影響。
渲染紋理類提供了兩個導出紋理的接口,分別可以導出紋理為 CCImage 和文件,它們的定義如下:
CCImage* newCCImage(); bool saveToFile(const char *name, tCCImageFormat format);
感興趣的讀者可以查看 CCRenderTexture 的內部實現,其導出紋理的過程實際上也是利用 glReadPixels 函數來獲取像素信息。因此,導出紋理這一步的效率和我們自己編寫的 saveScreenToCCImage 函數是一致的。然而如果采用重新繪制的方式來導出紋理則與此不同,繪制一次屏幕的過程較為費時,尤其在布局比較復雜的場景上。重新繪制的強大之處在於繪制結果可以迅速被重用,非常適合做即時小窗預覽之類的效果。下面的 saveScreen 方法實現了實時的截圖功能:
void GameMenuLayer::saveScreen(CCObject* sender) { //我們注釋掉了舊的代碼,改用 saveScreenToRenderTexture 方法來實現截圖 //CCImage* image = saveScreenToCCImage(); //image->saveToFile("screen.png"); //image->release(); CCRenderTexture* render = saveScreenToRenderTexture(); this->addChild(render); render->setScale(0.3); render->setPosition(ccp(CCDirector::sharedDirector()->getWinSize().width, 0)); render->setAnchorPoint(ccp(1,0)); }
CCRenderTexture 繼承自 CCNode,我們把它添加到游戲之中,就可以在右下角看到一個動態的屏幕截圖預覽了,如下圖所示:
(3)可編程管線:
正如本章開始所說的那樣,在 Cocos2d-x 中,最大的變革就是引入了 OpenGL ES 2.0 作為底層繪圖,這意味着渲染從過去的固定管線升級到了可編程管線,我們可以通過着色器定義每一個頂點或像素的着色方式,產生更豐富的效果。着色器實際上就是一小段執行渲染效果的程序,由圖形處理單元執行。之所以說是"一小段",是因為圖形渲染的執行周期非常短,不允許過於臃腫的程序,因此通常都比較簡短。
在渲染流水線上,存在着兩個對開發者可見的可編程着色器,具體如下所示。
頂點着色器(vertex shader)。對每個頂點調用一次,完成頂點變換(投影變換和視圖模型變換)、法線變換與規格化、紋理坐標生成、紋理坐標變換、光照、顏色材質應用等操作,並最終確定渲染區域。在 Cocos2d-x 的世界中,精靈和層等都是矩形,它們的一次渲染會調用 4 次頂點着色器。
段着色器(fragment shader,又稱片段着色器)。這個着色器會在每個像素被渲染的時候調用,也就是說,如果我們在屏幕上顯示一張 320×480 的圖片,那么像素着色器就會被調用 153 600 次。所幸,在顯卡中通常存在不止一個圖形處理單元,渲染的過程是並行化的,其渲染效率會比用串行的 CPU 執行高得多。
這兩個着色器不能單獨使用,必須成對出現,這是因為頂點着色器會首先確定每一個顯示到屏幕上的頂點的屬性,然后這些頂點組成的區域被化分成一系列像素,這些像素的每一個都會調用一次段着色器,最后這些經過處理的像素顯示在屏幕上,二者是協同工作的。
引擎提供了 CCGLProgram 類來處理着色器相關操作,對當前繪圖程序進行了封裝,其中使用頻率最高的應該是獲取着色器程序的接口:
const GLuint getProgram();
該接口返回了當前着色器程序的標識符。后面將會看到,在操作 OpenGL 的時候,我們常常需要針對不同的着色器程序作設置。注意,這里返回的是一個無符號整型的標識符,而不是一個指針或結構引用,這是 OpenGL 接口的一個風格。對象(紋理、着色器程序或其他非標准類型)都是使用整型標識符來表示的。
CCGLProgram 提供了兩個函數導入着色器程序,支持直接從內存的字符串流載入或是從文件中讀取。這兩個函數的第一個參數均指定了頂點着色器,后一個參數則指定了像素着色器:
bool initWithVertexShaderByteArray(const GLchar* vShaderByteArray,const GLchar* fShaderByteArray); bool initWithVertexShaderFilename(const char* vShaderFilename,const char* fShaderFilename);
僅僅加載肯定是不夠的,我們還需要給着色器傳遞運行時必要的輸入數據。在着色器中存在兩種輸入數據,分別被標識為attribute 和 uniform。
注:詳細參見《cocos2d-x高級開發教程》11章
OpenGL繪圖技巧(遮罩層和小窗預覽,可編程着色器)