一、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();
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。在 10.1.3 节
的末尾曾提到过,它是 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 for(int x = 0; 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 中更改绘图相关状态的规范,可以保证不对后续绘图产生影响。
渲染纹理类提供了两个导出纹理的接口,分别可以导出纹理为 CGImage 和文件,它们的定义如下:
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。