通过OpenGL ES混合模式缩放视频缓冲区来适应显示尺寸
当开发基于软件模式的游戏时,通过缩放视频缓冲区来适应显示尺寸是最棘手的问题之一。当面对众多不同的分辨率时(比如开放环境下的Android),该问题会变得更加麻烦,作为开发人员,我们必须尝试在性能与显示质量之间找到最佳平衡点。正如我们在第2章中看到的,缩放视频缓冲区从最慢到最快共有3种类型。
软件模拟:3中类型中最慢,但最容易实现,是没有GPU的老款设备上的最佳选择。但是现在大部分智能手机都支持硬件加速。
混合模式:这种方式混合使用软件模拟(创建图像缓冲区)和硬件渲染(向显示屏绘制)两种模式。这种方法速度很快,而且可以在分辨率大于256×256的任意屏幕上渲染图像。
硬件加速模式:3种类型中最快,但最难实现。这取决于游戏的复杂程度,需要更加强劲的GPU。如果有好的硬件,这种方法就可以创造出令人震撼的质量和效果。但在终端设备比较分裂的平台上,比如Android,这将是十分艰难的选择。
这里,我们选择第二种方式,也是在终端设备分裂的平台上的最佳选择。你拥有软件渲染器,并希望将游戏适配到任意分辨率的显示屏上。此方法非常适合模拟器游戏、街机游戏、简单的射击游戏等。它在各种低端、中端、高端设备上都表现很好。
下面我们开始介绍混合模式并探讨为什么这种方法更加可行。然后,将深入研究这种方法的实现,包括如何初始化surface并通过实际缩放来绘制到纹理。
1. 为什么使用混合缩放
这种缩放技术背后的原理很简单:
你的游戏根据给定的尺寸创建图像缓冲区(通常采用像素格式RGB565,即移动设备最常用的格式)。例如320×240,这是典型的模拟器尺寸。
当一张分辨率为320×240的图像需要被缩放至平板电脑的尺寸(1024×768)或其他任意相同屏幕的设备时,我们可以使用软件模拟的方式来完成缩放,但会慢的令人无法忍受。而采用混合模式进行缩放,需要创建OpenGL ES纹理并将图片(320×240)渲染到GL四边形上。
纹理会通过硬件被缩放到适合显示屏的尺寸(1024×768),从而你的游戏性能将得到显著提升。
从实现的角度看,这个过程可描述如下:
初始化OpenGL ES纹理:在游戏视频被初始化的阶段,必须创建硬件surface。其中包含简单的纹理,要显示的视频图像会被渲染至到该纹理(详见代码清单1与代码清单2)。
将图像缓冲区绘制到纹理:在游戏循环的末端,渲染要显示的视频图像到纹理,该纹理会自动缩放至适合显示屏的尺寸(详见代码清单3)。
代码清单1 创建RGB656格式的空纹理
代码如下:
<SPAN style="FONT-SIZE: 14px">// 纹理ID
static unsigned int mTextureID;
// 被用来计算图片绘制在纹理上的X、Y偏移量
static int xoffset;
static int yoffset;
/**
* 创建RGB565格式的空纹理
* 参数: (w,h) 纹理的宽, 高
* (x_offsety_offset): 图片绘制在纹理上的X、Y偏移量
*/
static void CreateEmptyTextureRGB565 (int w, int h, int x_offset, int y_offset)
{
int size = w * h * 2;
xoffset = x_offset;
yoffset = y_offset;
// 缓冲区
unsigned short * pixels = (unsigned short *)malloc(size);
memset(pixels, 0, size);
// 初始化GL状态
glDisable(GL_DITHER);
glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_FASTEST);
glClearColor(.5f, .5f, .5f, 1);
glShadeModel(GL_SMOOTH);
glEnable(GL_DEPTH_TEST);
glEnable(GL_TEXTURE_2D);
// 创建纹理
glGenTextures(1, &mTextureID);
glBindTexture(GL_TEXTURE_2D, mTextureID);
// 纹理参数
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,GL_NEAREST);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// RGB565格式的纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGB, GL_UNSIGNED_
SHORT_5_6_5 , pixels);
free (pixels);
} </SPAN>
代码清单2展示了CreateEmptyTextureRGB565的实现过程,创建RGB656格式的空纹理用于绘制,参数如下:
w和h:要显示的视频图片的尺寸。
x_offset和y_offset:坐标系中X轴、Y轴的偏移量,视频图片将会按照这个坐标被渲染到纹理。但是为什么我们需要这些参数?请继续阅读。
在OpenGL中创建纹理,我们只需要调用:
代码如下:
<SPAN style="FONT-SIZE: 14px">glGenTextures(1, &mTextureID);
glBindTexture(GL_TEXTURE_2D, mTextureID);</SPAN>
这里的mTextureID是整型变量,用于存储纹理的ID。然后需要设置下面这些纹理参数:
GL_TEXTURE_MIN_FILTER:指定纹理缩小的方式,当像素被纹理化后并映射到某个大于单个纹理元素的区域时使用的缩小方式为GL_NEAREST,返回距离像素被纹理化后的中心最近(曼哈顿距离)的纹理元素的值。
GL_TEXTURE_MAG_FILTER:指定纹理放大的方式,当像素被纹理化后并映射到某个小于或等于单个纹理元素的区域时使用的放大方式为GL_LINEAR,返回4个距离像素被纹理化后的中心最近的纹理元素的加权平均值。
GL_TEXTURE_WRAP_S:用于设置纹理坐标系中S轴方向上的纹理映射方式为GL_CLAMP,将纹理坐标限制在(0,1)范围内,当映射单张图像到对象时,可以有效防止画面重叠。
GL_TEXTURE_WRAP_T:用于设置纹理坐标系中T轴方向上的纹理映射的方式为GL_CLAMP。
最后,我们通过glTexImage2D函数及以下参数来指定二维纹理:
GL_TEXTURE_2D:指定目标纹理的类型为二维纹理。
Level:指定图像纹理的详细程度。0是最基本的图像纹理层。
Internal format:指定纹理的颜色成分,在这个例子中是RGB格式。
Width and height:纹理的尺寸,必须是2的幂。
Format:指定像素数据的格式,同时也必须与内部格式相同。
Type:指定像素数据的数据类型,在本例中使用RGB565(16位)格式。
Pixels:指向内存中图像数据的指针,必须使用RGR656编码。
注意:纹理的尺寸必须是2的幂,如256、512、1024等。但是,要显示的视频图像的尺寸可以是任意尺寸。这就意味着,纹理的尺寸必须是大于或等于要显示的视频图像尺寸的2的幂。稍后我们将进行详细介绍。
现在,让我们来看一看混合视频缩放的实际实现,接下来的两个小节将介绍如何初始化用来缩放的surface以及如何实现实际的绘制。
2. 初始化surface
要进行缩放,就必须保证纹理的尺寸大于或等于要显示的视频图像的尺寸。否则,当图像渲染的时候,会看到白色或黑色的屏幕。在代码清单2中,JNI_RGB565_SurfaceInit函数将确保产生有效的纹理尺寸。使用图像的宽度和高度为参数,然后调用getBestTexSize函数来获取最接近要求的纹理尺寸,最后通过调用CreateEmptyTextureRGB565函数来创建空的纹理。注意,如果图像尺寸小于纹理尺寸,就通过计算X、Y坐标的偏移量来将其置于屏幕的中心。
代码清单2 初始化surface
代码如下:
<SPAN style="FONT-SIZE: 14px">// 获取下一个POT纹理尺寸,该尺寸大于或等于图像尺寸(WH)
static void getBestTexSize(int w, int h, int *tw, int *th)
{
int width = 256, height = 256;
#define MAX_WIDTH 1024
#define MAX_HEIGHT 1024
while ( width < w && width < MAX_WIDTH) { width *= 2; }
while ( height < h && height < MAX_HEIGHT) { height *= 2; }
*tw = width;
*th = height;
}
/**
* 初始化RGB565 surface
* 参数: (w,h) 图像的宽高
*/
void JNI_RGB565_SurfaceInit(int w, int h)
{
//最小纹理的宽高
int texw = 256;
int texh = 256;
// 得到纹理尺寸 (必须是POT) >= WxH
getBestTexSize(w, h, &texw, &texh);
// 图片在屏幕中心?
int offx = texw > w ? (texw - w)/2 : 0;
int offy = texh > h ? (texh - h)/2 : 0;
if ( w > texw || h > texh)
printf ("Error: Invalid surface size %sx%d", w, h);
// 创建OpenGL纹理,用于渲染
CreateEmptyTextureRGB565 (texw, texh, offx, offy);
}
</SPAN>
3. 绘制到纹理
最后,为了将图像显示到屏幕上(也称作surface翻转),我们调用JNI_RGB565_Flip函数,其参数是像素数组(使用RGR656编码)和要显示的图像尺寸。JNI_RGB565_Flip函数通过调用DrawIntoTextureRGB565将图像绘制到纹理并交换缓冲区。注意交换缓冲区的函数是用Java编码的,而不是用C语言编码的,因此我们需要一个方法来调用Java的交换函数。我们可以通过使用JNI方法调用某个Java方法来完成缓冲区的交换工作(见代码清单3)。
代码清单3 用四边形将图像缓冲区绘制到纹理
代码如下:
<SPAN style="FONT-SIZE: 14px">// 四边形顶点的X、Y和Z坐标
static const float vertices[] = {
-1.0f, -1.0f, 0,
1.0f, -1.0f, 0,
1.0f, 1.0f, 0,
-1.0f, 1.0f, 0
};
// 四边形坐标(0-1)
static const float coords[] = {
0.0f, 1.0f,
1.0f, 1.0f,
1.0f, 0.0f,
0.0f, 0.0f,
};
// 四边形顶点索引
static const unsigned short indices[] = { 0, 1, 2, 3};
/**
* 使用四边形像素(RGB565的unsigned short)将像素数组绘制到全部屏幕
*
*/
static void DrawIntoTextureRGB565 (unsigned short * pixels, int w, int h)
{
// 清除屏幕
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 启用顶点和纹理坐标
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, mTextureID);
glTexSubImage2D(GL_TEXTURE_2D, 0, xoffset, yoffset, w, h, GL_RGB,
GL_UNSIGNED_SHORT_5_6_5 , pixels);
// 绘制四边形
glFrontFace(GL_CCW);
glVertexPointer(3, GL_FLOAT, 0, vertices);
glEnable(GL_TEXTURE_2D);
glTexCoordPointer(2, GL_FLOAT, 0, coords);
glDrawElements(GL_TRIANGLE_FAN, 4, GL_UNSIGNED_SHORT, indices);
}
// 翻转surface (绘制到纹理中)
void JNI_RGB565_Flip(unsigned short *pixels , int width, int height)
{
if ( ! pixels) {
return;
}
DrawIntoTextureRGB565 (pixels, width, height);
// 在这里必须交换GLES缓冲区
jni_swap_buffers ();
}
</SPAN>
使用OpenGL渲染到纹理:
(1) 使用glClear(GL_COLOR_BUFFER_BIT |GL_DEPTH_BUFFER_BIT)清除颜色与深度缓冲区。
(2) 启用客户端状态:当glDrawElements函数被调用时,写入顶点数组与纹理坐标数组。
(3) 通过glActiveTexture函数选择要激活的纹理单元,初始值是GL_TEXTURE0。
(4) 将已经生成的纹理绑定到等待被纹理化的目标。GL_TEXTURE_2D (一个二维纹理)是默认的纹理绑定目标,mTextureID是纹理的ID。
(5) 通过glTexSubImage2D函数来指定二维纹理子图,参数如下:
GL_TEXTURE_2D:指定目标纹理类型。
level:指定图像的详细程度(即层数)。0是基本的图像纹理层。
Xoffset:指定纹理像素在X轴方向上、纹理数组内的偏移量。
Yoffset:指定纹理像素在Y轴方向上、纹理数组内的偏移量。
width:指定纹理子图的宽度。
height:指定纹理子图的高度
format:指定像素数据的格式。
Type:指定像素数据的数据类型。
data:指定指向内存中图像数据的指针。
(6) 通过调用以下函数绘制四边形顶点、坐标与索引:
glFrontFace:启用四边形的正面。
glVertexPointer:定义四边形的顶点数据数组,顶点数据大小为3,数据类型是GL_FLOAT,数组中每个顶点间的间隔(步长)为0。
glTexCoordPointer:定义四边形的纹理数组,纹理坐标大小为2,数据类型是GL_FLOAT,间隔为0。
glDrawElements:通过数据数组以三角形扇(GL_TRIANGLE_FAN)的方式渲染多边形,有4个顶点,类型为短整型(GL_UNSIGNED_SHORT),外加指向索引的指针。
注意,从代码清单3中我们可以看到四边形的两个轴坐标都在[−1,1]区间内。这是因为OpenGL的坐标系统在(−1,1)之间,原点(0,0)在窗口中心(如图3-10所示)。
在理想的世界里,我们不应该过多地担心视频缓冲区的尺寸(尤其是使用软件模拟仅有的定标器/渲染器)。当在Android中使用OpenGL缩放视频时,这却是事实。在这个示例中,缓冲区的尺寸至关重要。接下来你将学习如何处理任意尺寸的视频,这一点在OpenGL中工作得不是很好。
4. 当图像的尺寸不是2的幂时会发生什么
如前所述,当图像的尺寸是2的幂时混合缩放会非常完美。但是,也有可能图像缓冲区不是2的幂。例如,在处理Demo引擎的章节中有一段320×240尺寸的视频。在这种情况下,图像仍然被缩放,但是会缩放到纹理尺寸的百分比大小。在图2和3中可以看到这个效果。
在图2中,有以下尺寸:
设备显示器:859×480
纹理:512×256
图像:320×240
正如我们看到的一样,图像被缩放到纹理宽度的62%(320/512*100)和高度的93%
(240/256*100)。因此,在任何分辨率大于256的设备上,图像都会被缩放到设备提供分辨率的62%×93%。现在我们来看看图3。
图3 缩放尺寸为2的幂的图像
在图3中,有以下尺寸:
设备显示器:859×480
纹理:512×256
图像:512×256
缩放和绘制
在图3中,我们看见图像被缩放到设备提供分辨率的100%,这正是我们想要的。但是如果图像不是2的幂,那么我们要如何做呢?为了解决这个问题,我们应该:
(1) 用软件缩放器将320×240尺寸的图像缩放到接近2的幂(这里是512×256)。
(2) 将已缩放的surface转换成RGB656格式的图像,以兼容前面介绍的DrawInto-TextureRGB565。
(3) 绘制到纹理,从而使用硬件将其缩放到显示屏的分辨率。
这种解决办法可能比前面介绍的方法慢,但仍然比纯软件缩放快,尤其是运行在高分辨率设备时更明显(如平板电脑)。
代码清单4展示了如何使用流行的SDL_gfx库来缩放SDL surface。
代码清单4 用SDL_gfx库缩放图像
代码如下:
<SPAN style="FONT-SIZE: 14px">void JNI_Flip(SDL_Surface *surface )
{
if ( zoom ) {
// 如果surface是8位缩放,就是8位,否则surface就是32的RGBA!
SDL_Surface * sized = zoomSurface( surface, zoomx, zoomy, SMOOTHING_OFF);
JNI_FlipByBPP (sized);
// 必须清理掉!
SDL_FreeSurface(sized);
}
else {
JNI_FlipByBPP (surface);
}
}</SPAN>
缩放和绘制实现
要放大/缩小SDL surface,需要简单地调用SDL_gfx库的zoomSurface:
(1) 一个SDL surface。
(2) 水平缩放因子:(0-1)
(3) 垂直缩放因子:(0-1)
(4) SMOOTHING_OFF:为了能快速绘制,禁用反锯齿处理。
接下来,让我们基于分辨率(每个像素的位数)来翻转SDL surface。代码清单5展示了如何完成8位RBG格式的surface。
代码清单5 根据分辨率翻转SDL surface
代码如下:
<SPAN style="FONT-SIZE: 14px">/**
* 通过每个像素的位数翻转SDL surface
*/
static void JNI_FlipByBPP (SDL_Surface *surface)
{
int bpp = surface->format->BitsPerPixel;
switch ( bpp ) {
case 8:
JNI_Flip8Bit (surface);
break;
case 16:
// 替换16位RGB (surface);
break;
case 32:
// 替换32为RGB (surface);
break;
default:
printf("Invalid depth %d for surface of size %dx%d", bpp, surface->w,
surface->h);
}
}
/**
* 替换8位SDL surface
*/
static void JNI_Flip8Bit(SDL_Surface *surface )
{
int i;
int size = surface->w * surface->h;
int bpp = surface->format->BitsPerPixel;
unsigned short pixels [size]; // RGB565
SDL_Color * colors = surface->format->palette->colors;
for ( i = 0 ; i < size ; i++ ) {
unsigned char pixel = ((unsigned char *)surface->pixels)[i];
pixels[i] = ( (colors[pixel].r >> 3) << 11)
| ( (colors[pixel].g >> 2) << 5)
| (colors[pixel].b >> 3); // RGB565
}
DrawIntoTextureRGB565 (pixels, surface->w, surface->h);
jni_swap_buffers ();
}
</SPAN>
指定SDL surface,然后检查每个像素的格式:surface->format->BitsPerPixel,并根据该值创建能够被DrawIntoTextureRGB565使用的RGB565像素数组:
代码如下:
<SPAN style="FONT-SIZE: 14px">for ( i = 0 ; i < size ; i++ ) {
unsigned char pixel = ((unsigned char *)surface->pixels)[i];
// RGB565
pixels[i] = ( (colors[pixel].r >> 3) << 11)
| ( (colors[pixel].g >> 2) << 5)
| (colors[pixel].b >> 3);
}</SPAN>
从surface调色板上提取每个像素包含的红、绿和蓝值:
代码如下:
<SPAN style="FONT-SIZE: 14px">SDL_Color * colors = surface->format->palette->colors;
RED: colors[pixel].r
GREEN: colors[pixel].g
BLUE: colors[pixel].b</SPAN>
为了构建RGB565像素,需要从每个颜色组件中抛弃最低有效位:
代码如下:
<SPAN style="FONT-SIZE: 14px">colors[pixel].r >> 3 (8 -3 = 5)
colors[pixel].g >> 2 (8 – 2 = 6)
colors[pixel].b >> 3 (8 – 3 = 5)</SPAN>
然后移动每个组件到16位值的正确位置(5+6+5= 16——因此是RGB656):
代码如下:
<SPAN style="FONT-SIZE: 14px">pixels[i] = (RED << 11) | (GREEN << 5) | BLUE</SPAN>
最后将新的数组和图像宽度、高度一起发送到DrawIntoTextureRGB565。最后一个问题,我们需要一种方式来告诉surface是否需要缩放。当surface在第一次被创建时将完成视频初始化。代码清单6展示了如何用SDL创建软件surface。
代码清单6 初始化缩放surface
代码如下:
<SPAN style="FONT-SIZE: 14px">// 应该被缩放?
static char zoom = 0;
// 缩放范围[0,1]
static double zoomx = 1.0;
static double zoomy = 1.0;
/**********************************************************
* 图像的构造函数
* 图像必须是2的幂 (256×256, 512×256,...)
* 以便用OpenGL纹理进行全屏渲染。如果图像不是
* POT (320×240),那么它将被缩放
**********************************************************/
SDL_Surface * JNI_SurfaceNew(int width, int height, int bpp, int flags)
{
Uint32 rmask = 0, gmask = 0, bmask =0 , amask = 0;
// 纹理尺寸和偏移量
int realw = 256, realh = 256, offx = 0, offy = 0;
// 图像必须是2的幂以便OpenGL能缩放它
if ( width > 512 ) {
Sys_Error("ERROR: INVALID IMAGE WIDTH %d (max POT 512×512)", width);
}
// 真实的W/H必须接近POT值的w/h
// 将要缩放到512×256
// 应该是256,但是512的分辨率更高(更慢)
if ( width > 256 ) realw = 512;
// 大小不是POT,就缩放到接近于POT,可选择:
// 256×256 (快/分辨率低) 512×256 (分辨率较高/较慢)
// 512×512 最慢
if ( ( width != 512 && width != 256) || ( height != 256 ) ) {
zoom = 1;
zoomx = realw / (float)width;
zoomy = realh / (float)height;
offx = offy = 0;
printf("WARNING Texture of size %dx%d will be scaled to %dx%d zoomx=%.3f
zoomy=%.3f"
, width, height, realw, realh, zoomx, zoomy);
}
// 创建渲染器使用的OpenGL纹理
CreateEmptyTextureRGB565 (realw, realh, offx, offy);
// 这是真正的被用于客户端渲染视频的surface
return SDL_CreateRGBSurface (SDL_SWSURFACE, width, height, bpp, rmask,
gmask, bmask,
amask);
}
</SPAN>
如果图像的尺寸不是2的幂,那么缩放标志将被设为1,并且水平和垂直方向的缩放因子将开始计算。然后,通过调用CreateEmptyTextureRGB565,根据宽度、高度和纹理的X、Y位移量来创建空纹理。最后调用SDL_CreateRGBSurface以创建SDL surface:
SDL_SWSURFACE:告诉SDL创建软件surface。
width和height:定义surface的尺寸。
bpp:定义surface中每个像素(分辨率)的位数(8、16、24和32)。
rmask、gmask、bmask和amask:这些是每个像素格式的红、绿、蓝和alpha(透明度)的掩码值。设置为0来让SDL注意到它(译者注:设置为0,OpenGL就可以写入;设置为1,则不能写入)。
混合缩放的经验法则
总而言之,当在游戏中像这样使用混合缩放时,请牢记以下经验法则:
如果可以,就总是设置视频的大小为2的幂:256×256或512×56。高于512对于这种技术来说代价太高。
如果不想设置视频的尺寸,但又想全屏显示,就可以像前面提到的那样,用SDL软件缩放到最接近的2的幂,然后再使用硬件进行缩放。
如果视频的尺寸大于512×512,混合缩放技术就未必有效(性能需要)。