Resolution Independence in 2D Direct3D Games – An Alternative Approach
IntroductionWe all understand that writing resolution-independent 2D games can be a lot more challenging than 3D games. This article outlines an approach I have been experimenting with recently that I believe is producing far better results than the more usual method of scaling vertices. The following two screenshots are taken from an old platform game of mine. In both cases, the “native” resolution of the game is 1024x768, but the game is running at 1280x1024 on my LCD, so the graphics have been scaled up accordingly. The first image is as a result of storing a scaling factor based on 1024/ActualScreenWidth, which all vertices are then multiplied by prior to being rendered.
The next image uses the alternative method that I describe in more detail below. Note the smoother main sprite, the lack of the edge error on the door and the cleaner looking stars on the boxes.
The Basic IdeaThe basic idea is not complicated and is also no silver bullet – there are some obvious cons to this approach which I will discuss later on. In essence, I am using a “fake” back buffer to render to, in the same dimensions as the game’s “native” resolution. At the end of the rendering look, I am then copying this onto the actual back buffer using
The ImplementationHere’s my GraphicsDevice interface, omitting all functions not actually relevant to this technique:
class GraphicsDevice
{
private:
IDirect3DDevice9 *Device;
IDirect3DSurface9 *BackSurface,*DrawSurface;
RECT DestRect;
public:
GraphicsDevice() : Direct3D(0),Device(0),DrawSurface(0) { }
~GraphicsDevice(){ Release(); }
void Acquire(HWND Hw,const GraphicsOptions &Options);
void Release();
void Begin(D3DCOLOR Clear);
void End();
};
You can see that in addition to my device pointer, I need to store two IDirect3DSurface9 pointers; one for the fake buffer we will create and one to hold onto the actual back buffer while we are rendering to the fake one.
We also calculate the destination rectangle when we create the device to save some processing every loop, so this becomes another member of
The first task is in
void GraphicsDevice::Acquire(HWND Hw,const GraphicsOptions &Options)
{
// set up device as normal, code omitted
R=Device->CreateRenderTarget(1024,768,D3DFMT_A8R8G8B8,
D3DMULTISAMPLE_NONE,0,FALSE,
&DrawSurface,NULL);
if(FAILED(R)) throw Error("Unable to create draw surface");
DestRect=CreateAspectRect(Params.BackBufferWidth,
Params.BackBufferHeight);
}
CreateAspectRect() is a helper function that just calculates the largest 4:3 rectangle that will fit on the current resolution. If the resolution is 4:3 as well, the resolution is returned as the rectangle, otherwise the rectangle is smaller than the resolution along one or the other dimension and the result is letterboxed on to the back buffer.
RECT CreateAspectRect(int Width,int Height)
{
int Fx,Fy;
float X,Y;
Y=float(Height)/3;
X=Y*4;
if(Width>=int(X)){ Fx=int(X); Fy=Height; }
else
{
X=float(Width)/4;
Y=X*3;
Fx=Width; Fy=int(Y);
}
int Ox=0,Oy=0;
if(Fx<Width) Ox=(Width-Fx)/2;
if(Fy<Height) Oy=(Height-Fy)/2;
RECT R; SetRect(&R,Ox,Oy,Ox+Fx,Oy+Fy);
return R;
}
Obviously if you prefer to stretch your game to the screen regardless of the aspect ratio, or give the user the option, changing this RECT is all that is required.
Hand in hand with the acquiring is the releasing, which is trivial:
void GraphicsDevice::Reset()
{
if(DrawSurface){ DrawSurface->Release(); DrawSurface=0; }
// release device as normal, code omitted
}
Finally we move on to implementing the Begin() and End() pair:
void GraphicsDevice::Begin(D3DCOLOR Clear)
{
Device->GetRenderTarget(0,&BackSurface);
Device->SetRenderTarget(0,DrawSurface);
Device->Clear(0,NULL,D3DCLEAR_TARGET,Clear,0,0);
Device->BeginScene();
}
Nothing complicated – we get a reference to the existing back buffer and store it for later, then we set our fake back buffer as the rendering target. Then we clear and BeginScene() like normal.
The rest of the program now does its rendering, pretending that we are running at 1024x768. There is no need to scale any images from their “natural” size, worry about clipping or even be aware at this point that the game is running at a different resolution. Finally, we implement
void GraphicsDevice::End()
{
Device->EndScene();
RECT SourceRect=CreateSimpleRect(1024,768);
Device->SetRenderTarget(0,BackSurface);
BackSurface->Release();
Device->Clear(0,NULL,D3DCLEAR_TARGET,D3DCOLOR_XRGB(0,0,0),0,0);
Device->StretchRect(DrawSurface,&SourceRect,BackSurface,&DestRect,
D3DTEXF_NONE);
Device->Present(NULL,NULL,NULL,NULL);
}
The idea is that we restore the original back buffer, ensuring that we call Release() on our store pointer since grabbing the back buffer increments its reference count.
We can then clear the real back buffer to black and use We can then The DownsidesThere are a couple of issues with this approach, although I believe that the advantages outweigh these. Firstly, on a PC with a slow fill-rate, running the game at 640x480 will give no speed boost over running at 1024x768. Secondly, there is obviously some additional overhead in doing the extra Lastly, if you are using a depth or stencil buffer in your 2D game, you’ll need to create a new buffer to go with your fake back buffer that matches its size as you’ll get into big trouble if your fake back buffer is larger than your actual screen resolution. But in essence, these are all performance trade-offs in exchange for a better looking result and simpler rendering code. Whether it presents a project with an advantage depends very much on the type of graphics the project uses, but I’ve found for games using textured quads as sprites, this method is producing far nicer looking and more robust results.
|
|