[sdlpal]游戏开始界面背景图的显示

继续看了下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的计算就是保证画面不拉伸变形的了,具体为什么要这要计算还不太清楚,先这样吧.

猜你喜欢

转载自blog.csdn.net/ALonWol/article/details/81053052