OpenGL ES: iPhone 上的批处理渲染






4.90/5 (4投票s)
简单来说,我在开发游戏时遇到了一些帧率问题,于是我想出了一个方法,将我所有的绘制操作批量化,尽可能地减少 OpenGL 调用次数。
这花了我不少时间,仅仅是因为我做了大量的研究。这篇不是一个教程帖,而是“我发现的东西”,并请求有人能审阅一下,看看我有什么可以改进的地方,然后告诉我!:)
简单来说,我在开发游戏时遇到了一些帧率问题,于是我想出了一个方法,将我所有的绘制操作批量化,尽可能地减少 OpenGL 调用次数。我发现了一些有趣的小技巧,包括斯坦福大学的一个“iTunes U”课程(可以在 iTunes 里搜索一下,非常值得观看),尤其是 Tim Omernick 关于 OpenGL 的课程(幻灯片可以在 这里找到)。
代码副本可以在 google code 仓库找到,TextureController 的直接链接是 这里。那么,让我们来看看它!
我首先尝试做的是创建一个包含顶点、颜色信息和 UV 信息的结构体。这是 Tim 讲座的结果,并受到他代码的很大影响。遇到的问题是我希望能够基于正在绘制的 GLuint 来批量化,这样我只需要绑定一次纹理,然后绘制与该纹理关联的所有顶点。我提出了以下解决方案:
struct Vertex
{
short v[2];
unsigned color;
float uv[2];
};
struct VertexInfo
{
Vertex vertex[MAX_VERTICES];
int _vertexCount;
};
这里会发生的是,VertexInfo 将存储在一个映射表中,而 Vertex 结构体包含了所有将要传递给 OpenGL 进行绘制的信息。MAX_VERTICES 在头文件的顶部进行了定义,我并没有对其进行太多调整,所以不确定在运行完整游戏时它的速度会快还是慢。目前设置为 100,000,但这只是我随便填的一个数字 :D 我估计应该可以减少。
包含顶点信息的映射表如下所示:
typedef std::map<GLuint, VertexInfo> VertexMap;
VertexMap vertices;
稍后您将看到如何使用它。我的这个类的想法是模仿 XNA 框架的 SpriteBatch 的工作方式。这是我对这个框架的赞赏,以及我多么喜欢使用 XNA 的又一例证。我希望能够调用一个 begin 函数,让 TextureController 知道我是否计划使用混合(或其他我可能添加的参数,例如排序),然后通过 Textures->draw 函数提供的大量内容进行绘制。每次调用 Textures->draw 函数都会接收要绘制的纹理和任何需要的信息(例如位置、源矩形、颜色等),并将该信息打包到 VertexMap 中,等待被传递给 OpenGL。一旦我所有的绘制命令都执行完毕,Textures->end() 将启动批量绘制。
请记住,这是组织此类事物的最佳方式吗?可能不是……尽管这是我第一次处理如此有趣的任务…… :) 让我们看看它是如何工作的!
注意:我将添加换行符来分隔一些注释……如果您想要完整文件,请务必访问代码仓库 code.google.com。
void TextureController::draw( const Texture2D& texture, const Rectangle& destination,
const Rectangle& source, const Color& color, const GLfloat depth )
{
正如您所见,我们正在插入一些信息片段,但这只是我实现的绘图命令之一。最简单的绘图命令,您只需要提供纹理和目标矩形……而“默认”值将被用于填充调用此函数时的空白。
GLuint glid = texture.getId();
// if we don't have any vertices with the texture being drawn, create a
// vertex map for it.
VertexMap::iterator it = vertices.find(glid);
if (it == vertices.end())
vertices[glid]._vertexCount = 0;
// find all of the vertices we'll need for this sprite
float topLeftX = destination.x;
float topLeftY = destination.y;
float topRightX = destination.x + destination.width;
float topRightY = destination.y;
float bottomLeftX = destination.x;
float bottomLeftY = destination.y + destination.height;
float bottomRightX = destination.x + destination.width;
float bottomRightY = destination.y + destination.height;
以上部分很有趣,因为它首先会找出我们是否已经使用该纹理批量绘制了一个精灵。如果我们已经进行了,我们只需将顶点添加到该批次中。否则,我们将创建一个新的顶点批次并开始这个新批次。接下来,我们找到所有的顶点!这是通过传入的目标矩形完成的。目标矩形将是绘制纹理的精灵框。换句话说,它是您的画布,而您的纹理就是颜料。
// Texture atlas
float minUV[2];
float maxUV[2];
// if the source rectangle of ZERO was passed in, it means the client want to just
// draw the texture as is.. otherwise, the client wishes to draw a portion of
// the rectangle
if (source == Rectangle::ZERO())
{
float maxS = texture.getMaxS();
float maxT = texture.getMaxT();
float minS = 0;
float minT = 0;
minUV[0] = minS;
minUV[1] = minT;
maxUV[0] = maxS;
maxUV[1] = maxT;
}
else
{
float minS = source.x / texture.getWidth();
float minT = source.y / texture.getHeight();
float maxS = source.width / texture.getWidth();
float maxT = source.height / texture.getHeight();
minUV[0] = minS;
minUV[1] = minT;
maxUV[0] = maxS;
maxUV[1] = maxT;
}
这部分花了我最长的时间来真正弄明白,因为我之前没有研究过纹理图集的工作原理。正如我所说,这很大程度上受到了 Tim 代码的影响,在我继续之前我真的很想理解这一部分。我们在这里做的是,如果函数中传入了一个空白矩形(意味着客户端没有指定矩形,因此希望“按原样”使用纹理),我们将使用纹理坐标的最大 S 和最大 T 值。我们找到最大 S 和最大 T 值的方法是,在图像经过其幂次方转换为 2 的幂纹理(请参阅我上一篇文章)后,最大 S 将变为 imageWidth / newTextureWidth。换句话说:
如果纹理以 30 像素 x 30 像素传入到您的游戏中,它将由 Texture2D 类重新调整为 32x32。最大 S 将是 30 / 32,或 0.9375f。该值表示在使用默认值时,绘制了图像的 93%,而剩余的 7% 图像仅包含填充,以确保它是幂次方纹理。
如果我们想使用一个子图像,只绘制一部分怎么办?这在我们将多个图像放在一个纹理上时很有用。假设您有一个包含 2 个动画帧的纹理,每个帧都是 24x24 像素。这意味着您的纹理宽度为 48 像素,高度为 24 像素。如果您只想绘制纹理的后半部分(您的“第二个”动画帧),则源矩形将是:
x=24, y=0, width=24, height=24
这意味着您从图片中开始偏移 24 像素,在图片的顶部像素处,并且您将绘制 24 像素的宽度和 24 像素的高度。这将是您要绘制的子图像。
为了将这些数字转换为纹理坐标,我们需要除以图像的宽度或高度。例如,24 / 48 是 0.5,所以您的 x 纹理坐标是 0.5,然后是 0,然后是 0.5 和 0.5。
// Convert the colors into bytes
unsigned char red = color.red * 255.0f;
unsigned char green = color.green * 255.0f;
unsigned char blue = color.blue * 255.0f;
unsigned char shortAlpha = color.alpha * 255.0f;
// pack all of the color data bytes into an unsigned int
unsigned _color = (shortAlpha << 24) | (blue << 16) | (green << 8) | (red << 0);
// Triangle #1
addVertex(glid, topLeftX, topLeftY, minUV[0], minUV[1], _color);
addVertex(glid, topRightX, topRightY, maxUV[0], minUV[1], _color);
addVertex(glid, bottomLeftX, bottomLeftY, minUV[0], maxUV[1], _color);
// Triangle #2
addVertex(glid, topRightX, topRightY, maxUV[0], minUV[1], _color);
addVertex(glid, bottomLeftX, bottomLeftY, minUV[0], maxUV[1], _color);
addVertex(glid, bottomRightX, bottomRightY, maxUV[0], maxUV[1], _color);
}
啊,位打包……我多么爱你,让我数数方法!当我第一次学会如何操作数据位时,我问自己“这有什么用处?”……猜猜我为此付出了代价,它是一项非常有用的技能。我并没有像我想的那样完全理解它,但我每天都会做一些研究。
我们在上面的代码中做的是,获取红/绿/蓝/ alpha 颜色值(以 0 到 1 的值传入),乘以 255,并将位打包到一个名为 _color 的单一值中。显然,OpenGL 以这种方式使用数据会快得多,所以嘿嚯,嘿嚯……我们去打包吧!
void TextureController::addVertex(GLuint glid, float x, float y, float uvx, float uvy, unsigned color)
{
VertexInfo *vertexInfo = &vertices[glid];
Vertex *vert = &vertexInfo->vertex[vertexInfo->_vertexCount];
vert->v[0] = x;
vert->v[1] = y;
vert->uv[0] = uvx;
vert->uv[1] = uvy;
vert->color = color;
vertexInfo->_vertexCount++;
}
我们继续打包!这就是批量处理的地方。我们做的是传递一个单一的 Vertex 数据(x 位置、y 位置、纹理坐标、颜色和使用的纹理),并将其打包到我上面给您展示的结构体中。一旦我们这样做,我们就增加 _vertexCount,这样我们就可以跟踪我们正在批量处理的顶点数量。
那么……这就是我们如何批量处理,如何渲染?很简单!
注意:请记住,这只是代码的一部分。这个函数是从我之前提到的“end()”函数调用的。end() 将确保 beginning 已被调用,然后将任务交给 render 函数。
void TextureController::renderToScreen()
{
glPushMatrix();
glMatrixMode(GL_MODELVIEW);
// Texture Blending fuctions
if ( blendAdditive )
{
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
// needed to draw textures using Texture2D
glEnable(GL_TEXTURE_2D);
// enables alpha for transparent textures
// I forget where I got these commands, iDevGames.net I think
glAlphaFunc(GL_GREATER, 0.1f);
glEnable(GL_ALPHA_TEST);
// Enable the various arrays used to draw texture to screen
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glEnableClientState(GL_COLOR_ARRAY);
glLoadIdentity();
上面只是准备渲染的标准步骤。事实上,我认为这只是从我的 Texture2D 类中复制/粘贴过来的。真正有趣的部分在后面!
// loop through all of the elements of the map and draw the vertices
VertexMap::iterator it = vertices.begin();
for (/* none */; it != vertices.end(); it++)
{
// easy access to our data
VertexInfo *vertexInfo = &it->second;
Vertex *vert = vertexInfo->vertex;
// bind the texture for the following vertices
bindTexture( (*it).first );
// throw everything to OpenGL
glVertexPointer(2, GL_SHORT, sizeof(Vertex), &vert->v);
glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), &vert->uv);
glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex), &vert->color);
glDrawArrays(GL_TRIANGLES, 0, vertexInfo->_vertexCount);
// reset this batches vertex count
vertexInfo->_vertexCount = 0;
}
我们在这里做的是遍历每一个键(如果您还记得,那就是纹理的 GLuit),绑定该纹理,以便 OpenGL 知道要用哪个纹理进行绘制,然后传递所有用于渲染批次的顶点、纹理坐标和其他所有信息。一些人可能不知道的一些说明(在我研究时我不知道):
glVertexPointer(2, GL_SHORT, sizeof(Vertex), &vert->v);
这里的 2 表示将有 2 个顶点,一个 x 和一个 y。这个数字可以是 3,如果您也存储了 z 值。我们将使用 GL_SHORT(记住在结构体中,所有顶点都声明为 short),这里有趣的部分是 sizeof(Vertex)。这被称为 STRIDE,基本上告诉 OpenGL 内存中数据之间的距离。(希望我解释得正确!)最后一个值是顶点数组!
glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), &vert->uv);
这与上面非常相似……除了我们使用了不同的值。这里的 2 指的是每个顶点有两个纹理坐标点(一个 x 和一个 y),但如果您要使用 z 值,这个数字将变为 3。请记住,我们使用的是浮点数(检查结构体),STRIDE 值保持不变。然后只需将其传入信息数组。
glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex), &vert->color);
同样的概念,只是我们在这里对它做了一些不同的处理。请记住,我们的颜色方案是 4 种颜色(红、绿、蓝和 alpha),但我们将所有信息打包到一个单一值中?我们通过 GL_UNSIGNED_BYTE 来指定。否则,其他所有内容都保持相似。
glDrawArrays(GL_TRIANGLES, 0, vertexInfo->_vertexCount);
这就是我们跟踪顶点数量的原因!我们在这里做的是告诉 OpenGL 使用三角形绘制数组,0 指的是起始索引(显然我们将从索引 0 开始,就像我们自己遍历索引一样),以及数组中有多少个元素。
这样做将把信息组合成一个单一的信息流……有点像
XY RGBA USUV, XY RGBA USUV, XY RGBA USUV
这对于 OpenGL 渲染来说效率要高得多,因此它会以比您一次渲染每个纹理的数据快得多的速度处理数据。(也就是说,我旧的方法)
现在剩下要做的就是清理,禁用我们启用的任何状态
// disable all the stuff we enabled eariler
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisableClientState(GL_COLOR_ARRAY);
glDisable( GL_BLEND );
glDisable( GL_TEXTURE_2D );
glDisable( GL_ALPHA_TEST );
glPopMatrix();
}
然后就完成了!
再说一遍,这是我第一次编写这样的系统……我仍在学习图形编程的技巧。我强烈建议您从 svn 仓库 (http://code.google.com/p/djinnengine) 下载代码并进行尝试。如果您发现任何我可以改进的地方,或者有助于我更好地理解这个过程的东西,请随时发布,以便大家从中学习!
祝大家编码愉快!