If you're making a game that you want to be used on a wide variety of systems, your game has to be scalable, especially if it is in a very small resolution. This is usually pretty straightforward. Most graphics libraries and the like have things built in for doing the scaling for you. SDL2 does, but only to a certain extent. If you want your game to be scaled without letter-boxing, you have to write this yourself. It took me a while to figure out, so I though I would spread the knowledge.
Using this form of scaling can create some serious distortion as well as introduce scaling artifacts to the game with different aspect ratios.
The code included is written in C++ and uses SDL2, which can be found here:
http://libsdl.org/. The documentation which describes the functions that I use is located here:
http://wiki.libsdl.org/CategoryRender. And to set up SDL2, some easy instructions can be found at the awesome site
LazyFoo
Scaling On Up
The theory here is this: You don't want letterboxing. You want the game to be taken from its original size and stretched to fit the window no matter what. So you just render everything to a back buffer by setting the target to it, instead of to the window's buffer, then at the end of your rendering cycle, switch the target back to the screen and
RenderCopy the back buffer onto the screen, rescaling if neccessary, then set the target back to your back buffer and repeat. It's slightly trickier than it sounds.
Enter The Code
So in order to accomplish scaling without letter-boxing, we will need some SDL rectangles variables.
SDL_Rect nativeSize;
SDL_Rect newWindowSize;
One is for storing the original size of the game,
nativeSize, and one is for storing the size of the window when it changes size,
newWindowSize.
float scaleRatioW;
float scaleRatioH;
SDL_Window * window;
SDL_Renderer * renderer;
SDL_Texture * backBuffer;
SDL_Texture * ballImage;
bool resize;
We also will need an
SDL_Window, an
SDL_Renderer for that window, and last but not least a
backBuffer for rendering everything to before copying it,
properly scaled, to the window's buffer. Also there is
ballImage which will be used to demonstrate the actual scaling. The two floats,
scaleRatioW and
scaleRatioH are optional. They are used if you need to scale the values of certain things that rely on the coordinates of the mouse, e.g. a button. You would multiply the x coord and width of its bounding-box by
scaleRatioW and the y coord and height by
scaleRatioH.
resize is used to tell the render function to call the resize function. It is neccessary to do it this way because there is a stall in the program after resizing the window that can sometimes cause problems if you call the resize functions immediately upon receiving a resize event.
So I have set up a fairly simple main function to run the program.
int main(int argc, char * argv[])
{
InitValues();
InitSDL();
bool quit = false;
while(!quit)
{
quit = HandleEvents();
Render();
}
return 0;
}
InitValues does what you might expect it to do, gives all the variables their starting values.
InitSDL does most of the importnat work, setting up the window and renderer as well as set up the back buffer and load the ball image.
HandleEvents returns false if the user clicks the x button on the window, however it also captures the window resize event and resizes the screen using a function called
Resize.
Render handles a very important part of the process, changing from the back buffer to the window buffer. I'll explain each of the functions in turn.
void InitValues()
{
nativeSize.x = 0;
nativeSize.y = 0;
nativeSize.w = 256;
nativeSize.h = 224;
newWindowSize.x = 0;
newWindowSize.y = 0;
newWindowSize.w = nativeSize.w;
newWindowSize.h = nativeSize.h;
scaleRatioW = 1.0f;
scaleRatioH = 1.0f;
window = NULL;
renderer = NULL;
backBuffer = NULL;
ballImage = NULL;
resize = false;
}
I set the native size to that of the SNES, 256 by 224.
void InitSDL()
{
if(SDL_Init(SDL_INIT_EVERYTHING) < 0)
{
cout << "Failed to initialize SDL" << endl;
}
//Set the scaling quality to nearest-pixel
if(SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "0") < 0)
{
cout << "Failed to set Render Scale Quality" << endl;
}
//Window needs to be resizable
window = SDL_CreateWindow("Rescaling Windows!",
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
nativeSize.w,
nativeSize.h,
SDL_WINDOW_RESIZABLE);
//You must use the SDL_RENDERER_TARGETTEXTURE flag in order to target the backbuffer
renderer = SDL_CreateRenderer(window,
-1,
SDL_RENDERER_ACCELERATED |
SDL_RENDERER_TARGETTEXTURE);
//Set to blue so it's noticeable if it doesn't do right.
SDL_SetRenderDrawColor(renderer, 0, 0, 200, 255);
//Similarly, you must use SDL_TEXTUREACCESS_TARGET when you create the texture
backBuffer = SDL_CreateTexture(renderer,
SDL_GetWindowPixelFormat(window),
SDL_TEXTUREACCESS_TARGET,
nativeSize.w,
nativeSize.h);
//IMPORTANT Set the back buffer as the target
SDL_SetRenderTarget(renderer, backBuffer);
//Load an image yay
SDL_Surface * image = SDL_LoadBMP("Ball.bmp");
ballImage = SDL_CreateTextureFromSurface(renderer, image);
SDL_FreeSurface(image);
}
This is a fairly large chunk of code. First
SDL_Init is called to set up all the SDL subsystems. Next, something that one might find important,
SDL_SetHint is called to set the render scale quality to nearest-pixel. Linear quality makes scaling very small images to very large images hazy, and this is unwanted behavior.
Next the window is created with the
SDL_WINDOW_RESIZABLE flag to allow it to be resized. Then,
very important, the renderer is created with the
SDL_RENDERER_TARGETTEXTURE flag which allows a texture to be targeted, and
backBuffer must be created using the
SDL_TEXTUREACCESS_TARGET flag for it to be used as a back buffer. The
render target is then set to
backBuffer so that all drawing will happen on it, not on the window.
bool HandleEvents()
{
while(SDL_PollEvent(&event) )
{
if(event.type == SDL_QUIT)
{
return true;
}
else if(event.type == SDL_WINDOWEVENT)
{
if(event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED)
{
resize = true;
}
}
}
return false;
}
The
HandleEvents function for clarification.
Resize is not called all the time, just when the window's size changes.
void Resize()
{
int w, h;
SDL_GetWindowSize(window, &w, &h);
scaleRatioW = w / nativeSize.w;
scaleRatioH = h / nativeSize.h; //The ratio from the native size to the new size
newWindowSize.w = w;
newWindowSize.h = h;
//In order to do a resize, you must destroy the back buffer. Try without it, it doesn't work
SDL_DestroyTexture(backBuffer);
backBuffer = SDL_CreateTexture(renderer,
SDL_GetWindowPixelFormat(window),
SDL_TEXTUREACCESS_TARGET, //Again, must be created using this
nativeSize.w,
nativeSize.h);
SDL_Rect viewPort;
SDL_RenderGetViewport(renderer, &viewPort);
if(viewPort.w != newWindowSize.w || viewPort.h != newWindowSize.h)
{
//VERY IMPORTANT - Change the viewport over to the new size. It doesn't do this for you.
SDL_RenderSetViewport(renderer, &newWindowSize);
}
}
So here is the most important part of scaling and where it became tricky. There are quite a few things that happen here. First, get the new window size (using
GetWindowSize for clarity). Then calculate the new ratio based on the new size and the native size. Then,
Very Important you must destory and recreate your back buffer. I honestly don't know why, but if you try without doing this it will not work. Then, also
Very Important, you must set the
view port to the new window size because it will not do it for you, or do it right anyways.
void Render()
{
SDL_RenderCopy(renderer, ballImage, NULL, NULL); //Render the entire ballImage to the backBuffer at (0, 0)
SDL_SetRenderTarget(renderer, NULL); //Set the target back to the window
if(resize) //If a resize is neccessary, do so.
{
Resize();
resize = false;
}
SDL_RenderCopy(renderer, backBuffer, &nativeSize, &newWindowSize); //Render the backBuffer onto the
//screen at (0,0)
SDL_RenderPresent(renderer);
SDL_RenderClear(renderer); //Clear the window buffer
SDL_SetRenderTarget(renderer, backBuffer); //Set the target back to the back buffer
SDL_RenderClear(renderer); //Clear the back buffer
}
And last but not least the
Render function. The comments explain this one pretty well.
For some reason, whenever you first resize the window, there is about a three-second delay and after that it resizes without a hitch. Not sure why on that one.
Conclusion
SDL2 is a pretty straight-forward API for doing just about anything. However, getting around letter-boxing was slightly tricky. Some of the things that occur in the background of SDL aren't obvious which led to a sort of trial and error process to figure out the way around. I hope this helps if anyone has the same problem with letter-boxing that I did.
Here is the source. You can run it from inside the bin folder:
Some feedback:
While this is an article about how to stretch your target resolution to native resolution "no matter what" to avoid letterboxing, it still probably warrants some discusion regarding the pros and cons of both approaches.
Specifically, nowhere that I see does it state the the primary drawback of blindly stretching to full screen is that some rather wild distortion can be introduced when you disallow letterboxing. That 256x240 target resolution might not look so bad on an old 1280x1024 (5:4) lcd panel, but it looks a bit weird on common old 4:3 screens, and really weird on the 16:9 and 16:10 screens predominant today.
Also you don't discus the other primary drawback of blindly filling the full screen, which is to introduce scaling artifacts. In many kinds of games, the scaling artifacts may not be an issue. But they definately are an issue for the retro-styled, pixel-perfect 2D games that are most likely to implement the kind of resolution-fixing scaling you are prescribing (regardless of whether they accept letter boxing or not).
Finally, 256x224 is not the resolution of the gameboy (that gameboy was 144x160), but the standard display resolution of the SNES.