继续看了下sdlpal的源码,这节讲下代码是如何从资源文件中读取所需图片并显示到屏幕上的。
下面这张就是开始界面了,当然除了背景图外还有菜单,菜单里的两个选项其实就是绘制的两行文字(一般的菜单除了文字还会有菜单背景图的,这里没有,只是简单的文字), 菜单的原理就另外章节讲了,这里只讲下图片资源的加载和显示。
一路跟踪,发现背景是在这里绘制的:mian()->PAL_GameMain()->PAL_OpeningMenu()->PAL_DrawOpeningMenuBackground()
,当然还有很多其他代码,这里不用理,弄明白最后那个函数就可以了。这些函数具体在哪个文件就不说了,用VS Code查找很方便。看下该函数的内部实现:
/* From: sdlpal/uigame.c */
// 可以看到函数并没有多少行代码
VOID
PAL_DrawOpeningMenuBackground(
VOID
)
/*++
Purpose:
Draw the background of the main menu.
Parameters:
None.
Return value:
None.
--*/
{
// LPBYTE的原型是unsigned char*,该变量用来存放从那些mkf读取到的图片资源数据
LPBYTE buf;
// 之所以要malloc这么多个字节,是因为开始界面那张背景图的分辨率是320 * 200的
// 因此我们要用320 * 200个字节来保存图片数据
buf = (LPBYTE)malloc(320 * 200);
if (buf == NULL)
{
return;
}
//
// Read the picture from fbp.mkf.
//
// 从上面源码的注释可知道,背景图是压缩在fbp.mkf这个资源文件中的
// 函数第一个参数是用来写入数据的,第二个参数指定了写入数据的大小,第三个参数应该是用来说明该图片在资源文件中的位置信息,最后一个参数当然是fbp.mkf这个资源文件了
// 该函数的内部实现先不看,等看明白了mfk资源文件的结构后,在开一篇单独的文章记录一下
// 现在只要知道-调用这个函数后,我们就从资源文件中读取到了背景图的数据信息
PAL_MKFDecompressChunk(buf, 320 * 200, MAINMENU_BACKGROUND_FBPNUM, gpGlobals->f.fpFBP);
//
// ...and blit it to the screen buffer.
//
// 读取到图片后,当然需要将它绘制到屏幕上,下面这个函数就是专门负责将图片数据填充到屏幕上的
// gpScreen在VIDEO_Startup()中创建好了,下面这个函数只是将buf数据填充到gpScreen->pixels中
// 该函数的内部实现,下面会讲到
PAL_FBPBlitToSurface(buf, gpScreen);
// 数据填充好后,还需刷新下屏幕才可以显示到屏幕上的,该函数下面会讲
VIDEO_UpdateScreen(NULL);
// 其实调用PAL_FBPBlitToSurface(buf, gpScreen)后,已经将buf这段内存中的数据转换成了SDL_Surface*这种类型的数据(也就是gpScreen),因此可以将buf释放掉了
free(buf);
}
如果你使用过SDL2在一个窗口显示一张图片,那么你肯定知道它的大致过程是这样的:
// 加载一个bmp文件
SDL_Surface* surface = SDL_LoadBMP("foo.bmp");
// 将surface转换成texture
SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
// 拷贝到渲染器上
SDL_RenderCopy(renderer, texture, NULL, NULL);
// 刷新显示
SDL_RenderPresent(renderer);
sdlpal开始界面的背景图显示原理也和这个差不多,不过背景图并不是从一个bmp文件中加载的,而是从mkf文件中读取数据到内存,然后将内存中的数据转换成一个SDL_Surface*
。
// lpBitmapFBP是从fbp.mkf文件中读取到的bmp图片文件的数据,它其实就是一个unsigned char类型的数组
// 数组的每个元素记录着图片的像素信息。lpDstSurface就是转换后的数据了。
// 从下面的NOTE注释可以看出,该函数有个限制,它要求图片的分辨率是320*200像素的,如果读取到一个不符
// 合该分辨率的图片数据到lpBitmapFBP,再调用该函数是不会创建出plDstSurface的。
INT
PAL_FBPBlitToSurface(
LPBYTE lpBitmapFBP,
SDL_Surface *lpDstSurface
)
/*++
Purpose:
Blit an uncompressed bitmap in FBP.MKF to an SDL surface.
NOTE: Assume the surface is already locked, and the surface is a 8-bit 320x200 one.
Parameters:
[IN] lpBitmapFBP - pointer to the RLE-compressed bitmap to be decoded.
[OUT] lpDstSurface - pointer to the destination SDL surface.
Return value:
0 = success, -1 = error.
--*/
{
// 图片有宽高,相当于一个二维数组,通过x、y来遍历每行每列的数据
int x, y;
// p用来指向lpDstSurface中的pixels这块空间,因为我们要用lpBitmapFBP数据来填充这块空间
LPBYTE p;
// 不符合要求的情况下,不进行填充
if (lpBitmapFBP == NULL || lpDstSurface == NULL ||
lpDstSurface->w != 320 || lpDstSurface->h != 200)
{
return -1;
}
//
// simply copy everything to the surface
// 第一层循环控制行数,图片有200行像素
for (y = 0; y < 200; y++)
{
// 通过遍历,p会分别指向第0行到第199行的起始地址
// lpDstSurface->pixels实际是个一维数组,不过人为分为了200等分,每等分的间距为lpDstSurface->pitch个字节
// SDL官方文档中对pitch的解释是:the length of a row of pixels in bytes (read-only)
// 乘以y就会跳到第y行的起始地址
p = (LPBYTE)(lpDstSurface->pixels) + y * lpDstSurface->pitch;
// 第二个循环控制列数,每行有320个像素
for (x = 0; x < 320; x++)
{
// 每行中的每个数据都会用lpBitmapFBP中的数据进行填充,最后都填充320个像素
*(p++) = *(lpBitmapFBP++);
}
}
return 0;
}
如果上面两个for循环还不懂的话,给个图片辅助理解:
上面那个函数就相当于这句SDL_Surface* surface = SDL_LoadBMP("foo.bmp");
而下面这个函数就相当于执行了这三句SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);
当然,它里面确实有调用到这三个函数,但是并不是简单调用,还做了一些其他事。看代码:
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
// 代码为了兼容SDL1和SDL2,加了条件编译,因为SDL1和SDL2的API有些许变化
// 该函数的作用是刷新lpRect这块矩形区域的显示
VOID
VIDEO_UpdateScreen(
const SDL_Rect *lpRect
)
/*++
Purpose:
Update the screen area specified by lpRect.
Parameters:
[IN] lpRect - Screen area to update.
Return value:
None.
--*/
{
// 我们有两个surface,一个源surface,一个目标surface,目标surface将所有源surface拷贝(Blit)到一起
// 然后从目标surface创建一个texture,最后将这个texture显示到屏幕上
// srcrect是用来指定源surface的哪块区域要Blit到目标surface的dstrect区域中
SDL_Rect srcrect, dstrect;
short offset = 240 - 200;
// 分两种情况,如果是SDL2的话,screenRealHeight就是200,在VIDEO_Startup()函数里边创建了gpScreenReal
short screenRealHeight = gpScreenReal->h;
// 屏幕的y坐标
short screenRealY = 0;
// 条件编译,只有SDL2版本才会执行下面的代码
#if SDL_VERSION_ATLEAST(2,0,0)
// g_bRenderPaused定义在video.c中,原型是volatile BOOL g_bRenderPaused = FALSE;
// 该变量的作用是用来判断程序是否进入后台,如果程序在后台运行,就不用刷新界面
// volatile关键字的作用是让程序每次都直接从变量地址读取数据,搜索下该关键字的作用就知道了
if (g_bRenderPaused) // 该变量的值会在input.c中改变,我在电脑端运行游戏切换到后台,还是会继续刷新屏幕,可能是针对手机或掌机设备的。
{
return;
}
#endif
//
// Lock surface if needed
//
// 对屏幕进行填充的时候,需要先判断这块内存是否需要锁定。至于为什么要锁定,我也还
// 不太明白原理,可能是为了避免其他地方同时修改该内存的数据吧
if (SDL_MUSTLOCK(gpScreenReal))
{
// 当需要锁定却锁定失败时,直接退出整个函数
// 锁定失败,可能是其他地方正在修改该内存的数据,所以这里就不能更改了
if (SDL_LockSurface(gpScreenReal) < 0)
return;
}
// 该变量用来判断是否需要缩放屏幕,像电脑这些屏幕大的,游戏窗口也会创建的大一点,所以就需要缩放
// 像3DS这些掌机,屏幕小,就不需要缩放
if (!bScaleScreen)
{
// 不需要缩放,说明是那些小屏幕的,则屏幕高度设为200 - 40
screenRealHeight -= offset;
// 屏幕在y轴的位置40 / 2.(可能那些掌机设备的顶部无法隐藏状态栏?所以游戏屏幕要下移一点?)
screenRealY = offset / 2;
}
// 下面这个if条件判断分3种情况,第一种是传入的参数lpRect不为空,则刷新显示lpRect指定的区域
// 第二种是lpRect为空,此时刷新整个屏幕,但是这时又需要判断屏幕是否需要抖动,比如使用了技能屏幕出现抖动效果
// 第三种是lpRect为空,且屏幕不需要抖动,直接刷新整个屏幕就行了
if (lpRect != NULL) // 刷新指定区域
{
// 算出目标surface的区域,x、y轴坐标和宽高w、h(要描述一个矩形,当然需要知道它左上角的坐标和宽高了)
// lpRect就是目标surface要Blit的区域,为什么还要根据lpRect重新算一个dstrect?
// gpScreenReal就是目标surface,gpScreen是源surface,图片数据是先读取到gpScreen再Blit到gpScreenReal的
// 当gpScreenReal的w、h和gpScreen的w、h一样的时候,lpRect和dstrect一样
// 当gpScreen的w、h要大于gpScreenReal的时候,dstrect区域要比lpRect的小,反之要大
dstrect.x = (SHORT)((INT)(lpRect->x) * gpScreenReal->w / gpScreen->w);
dstrect.y = (SHORT)((INT)(screenRealY + lpRect->y) * screenRealHeight / gpScreen->h);
dstrect.w = (WORD)((DWORD)(lpRect->w) * gpScreenReal->w / gpScreen->w);
dstrect.h = (WORD)((DWORD)(lpRect->h) * screenRealHeight / gpScreen->h);
// SDL_SoftStretch被define成了SDL_UpperBlit,但是查SDL文档发现被SDL_BlitSurface替换了
// 下面这句的作用是将gpScreen的lpRect区域Blit到gpScreenReal的dstrect区域中
// 为什么Blit的区域不是lpRect,而是要经过上面的计算得到dstrect?这个目的还不是很清楚
SDL_SoftStretch(gpScreen, (SDL_Rect *)lpRect, gpScreenReal, &dstrect);
}
else if (g_wShakeTime != 0) // 刷新并抖动屏幕
{
//
// Shake the screen
//
// 抖动效果其实就是将画面上下移动,但是SDL并没有这么方便的函数供你调用,你需要自己算出每一帧的
// 源surface是如何Blit到目标surface上的。例如这一帧把源surface的上半部分Blit到目标surface
// 的上半部分,下一珍将源surface的下半部分Blit到目标surface的下半部分,当然...这不是抖动,是闪烁效果了
srcrect.x = 0;
srcrect.y = 0;
srcrect.w = 320;
// g_wShakeLevel就是抖动等级,该值越大,源surfaceBlit到目标surface的区域就越小
srcrect.h = 200 - g_wShakeLevel;
dstrect.x = 0;
dstrect.y = screenRealY;
dstrect.w = 320 * gpScreenReal->w / gpScreen->w;
// dstrect.h和srcrect.h对应的
dstrect.h = (200 - g_wShakeLevel) * screenRealHeight / gpScreen->h;
if (g_wShakeTime & 1)
{
srcrect.y = g_wShakeLevel;
}
else
{
dstrect.y = (screenRealY + g_wShakeLevel) * screenRealHeight / gpScreen->h;
}
// 算好需要Blit的区域后,将gpScreen的srcrect区域Blit到gpScreenReal的dstrect区域中
SDL_SoftStretch(gpScreen, &srcrect, gpScreenReal, &dstrect);
if (g_wShakeTime & 1)
{
dstrect.y = (screenRealY + screenRealHeight - g_wShakeLevel) * screenRealHeight / gpScreen->h;
}
else
{
dstrect.y = screenRealY;
}
dstrect.h = g_wShakeLevel * screenRealHeight / gpScreen->h;
// 将gpScreenReal的dstrect区域填充为黑色
// 当dstrect这部分并没有Blit到源surface的内容时,当然是填充黑色了,不然会出现其他残余像素
SDL_FillRect(gpScreenReal, &dstrect, 0);
#if SDL_MAJOR_VERSION == 1 && SDL_MINOR_VERSION <= 2
dstrect.x = dstrect.y = 0;
dstrect.w = gpScreenReal->w;
dstrect.h = gpScreenReal->h;
#endif
// 抖动次数减一
g_wShakeTime--;
}
else // 不抖动屏幕,直接刷新整个屏幕
{
// 指定目标surface的Blit区域
dstrect.x = 0;
// 小屏幕设备,screenRealY会下移20个像素,电脑什么的就是0了
dstrect.y = screenRealY;
// 宽度就是目标surface的宽度
dstrect.w = gpScreenReal->w;
// 如果是小屏幕设备,screenRealHeight就是160,否则是200
dstrect.h = screenRealHeight;
// SDL_SoftStretch被define成了SDL_UpperBlit,但是查SDL文档发现被SDL_BlitSurface替换了
// 下面这句的作用是将gpScreen的整个表面填充到gpScreenReal的dstrect区域中
SDL_SoftStretch(gpScreen, NULL, gpScreenReal, &dstrect);
// 条件编译,如果是SDL1版本,dstrect的大小就是gpScreenReal的整个区域。后面的SDL_UpdateRect要用到
#if SDL_MAJOR_VERSION == 1 && SDL_MINOR_VERSION <= 2
dstrect.x = dstrect.y = 0;
dstrect.w = gpScreenReal->w;
dstrect.h = gpScreenReal->h;
#endif
}
// 其实上面的代码只是将gpScreen这个表面Blit到gpScreenReal,要显示到屏幕上还要将gpScreenReal转成texture
#if SDL_VERSION_ATLEAST(2,0,0) // SDL2版,需要SDL_RenderCopy()->SDL_RenderPresent()
// 这个函数也不是简单调用那两个函数就行了的,后面单独讲下内部实现
gRenderBackend.RenderCopy();
#else // SDL1版的直接SDL_UpdateRect就行了
SDL_UpdateRect(gpScreenReal, dstrect.x, dstrect.y, dstrect.w, dstrect.h);
#endif
// 将gpScreenReal显示到屏幕上后,对这块内存进行解锁。如果你这里一直占用这块内存,其他地方就无法操作这块内存了
if (SDL_MUSTLOCK(gpScreenReal))
{
SDL_UnlockSurface(gpScreenReal);
}
}
上面两个函数的步骤总结起来就是:从图片资源创建数据到gpScreen,然后Blit到gpScreenReal,如果画面有抖动效果,还需计算Blit的区域。抖动效果只是讲了个原理,具体还需到游戏中体验一下效果是怎样的,以及那些计算为什么是那样。
下面这个函数只在SDL2版才生效的,它的作用是将gpScreenReal的像素拷贝到gpTexture,然后将gpTexture拷贝到gpRenderer,最后显示到屏幕上。平时用SDL的时候都是每次加载一张图片,都创建一个texture,然后将所有texture拷贝到renderer中,这样效率会很低,因为每创建一个texture都要分配内存的。sdlpal的做法是先创建一个空的gpTexture,然后,每加载一张图片,都先Blit到gpSurfaceReal中,最后只需将gpSurfaceReal->pixels拷贝给gpTexture->pixels中,再拷贝到renderer就行了。这样效率会高很多。
VOID
VIDEO_RenderCopy(
VOID
)
{
// 用于指向gpTexture中的pixels地址
void *texture_pixels;
// 像素分多行,这个变量描述了每行的大小
int texture_pitch;
// 锁定纹理,然后就可以修改texture_pixels这块空间的内容了
SDL_LockTexture(gpTexture, NULL, &texture_pixels, &texture_pitch);
// gTextureRect.y打印出来为0,说明下面这句并没有将texture_pixels任何一个地方清零
// 然后我改了下参数,也没发现什么变化,所以不是很清楚这句的作用
memset(texture_pixels, 0, gTextureRect.y * texture_pitch);
// gTextureRect.y是gTexture在y轴上的偏移量,所以下面这句是将pixels指向
// 第gTexture.y行,也就是除去了顶部的gTexture.y这么多行
uint8_t *pixels = (uint8_t *)texture_pixels + gTextureRect.y * texture_pitch;
// src指向了gpScreenReal->pixels,之所以要重新定义个变量,是为了避免内存泄漏
// 如果直接操作gpScreenReal->pixels的话,会把它前面的空间丢失掉
uint8_t *src = (uint8_t *)gpScreenReal->pixels;
// 下面这个是画面的左右边距,实际上在电脑的效果都为0,如果把屏幕大小改成320 * 180的话,左右两边会出现黑边
// 所以这两个的作用是为了适配屏幕的吧,防止画面拉伸变形
int left_pitch = gTextureRect.x << 2;
int right_pitch = texture_pitch - ((gTextureRect.x + gTextureRect.w) << 2);
// 将gTextureRect.h行的gpScreenReal的pixels拷贝给gTexture的pixels
// 原理可以参考下开头那个函数介绍
for (int y = 0; y < gTextureRect.h; y++, src += gpScreenReal->pitch)
{
// 如果左右边距存在,会将它设置为0,也就是黑色,然后中间的就把src内的数据拷贝给pixels
memset(pixels, 0, left_pitch); pixels += left_pitch;
// 320 << 2其实就是1280,打印texture_pitch也是1280,为什么不直接用1280就不清楚了
memcpy(pixels, src, 320 << 2); pixels += 320 << 2;
memset(pixels, 0, right_pitch); pixels += right_pitch;
}
memset(pixels, 0, gTextureRect.y * texture_pitch);
// 拷贝完pixels,将gpTexture解锁
SDL_UnlockTexture(gpTexture);
// 将创建好的Texture拷贝到renderer
SDL_RenderCopy(gpRenderer, gpTexture, NULL, NULL);
// 这个是跟触屏相关的,像手机上是通过触摸移动的。gpTouchOverlay应该是触摸区域的纹理
if (gpTouchOverlay)
{
SDL_RenderCopy(gpRenderer, gpTouchOverlay, NULL, &gOverlayRect);
}
// 最后显示出来
SDL_RenderPresent(gpRenderer);
}
开始界面的背景图显示大概就是这样了.其实有些地方讲的也不是很清楚,比如分辨率适配的问题,那些涉及到rect的计算就是保证画面不拉伸变形的了,具体为什么要这要计算还不太清楚,先这样吧.