This series of articles focuses on 2D game development with C++ and OpenGL for Windows platform. The target is to provide a game like the classic block puzzle game by the end of the series. We will not only focus on OpenGL but also talk about the designs that are commonly used in game programming with a full object oriented approach. You should already be familiar with the C++ language in order to get the maximum out of this series. There is a message board at the bottom of the article that you can use if you have questions, remarks or suggestions.
The series is divided into three articles:
- Part 1: covers the win32 message loop, the window creation and the setting-up of OpenGL. You will also learn how to draw some simple shapes.
- Part 2 : Covers resources handling and displaying simple animations.
- Part 3: groups everything together and talk about the game logic.
Contents
This part of the article focuses on setting up an OpenGL window in a Windows environment. We will learn how to create a message loop to receive notifications and how to create the main window that will be used for drawing. Then, we will see how to configure OpenGL properly for a 2 dimensions game. Finally, when everything is ready to start, we will learn how to display some basic shapes in the newly created OpenGL window.
We will start by creating a new project and configuring the different options. The tutorial project has been created with Visual Studio 2005 but it can be easily applied for another compiler. Start by creating a new project of type "Win32 Console Application" and giving it an appropriate name, then click Ok. In the creation wizard, select the type "Windows application" (not console) and check the "Empty project" option (we don't really need code that is generated for us).
When this is done, add a new source file Main.cpp to the project (if there are no source files in the project, some options are not accessible). Now open the project options and go to the "Linker" category -> "Input". In the "Addition Dependencies" option, add opengl32.lib. This tells the linker that it has to use the OpenGL library when linking the project.
Next, we will disable UNICODE because we don't need it and it makes things a bit more complicated. Go into "C/C++" -> "Preprocessor" and click on "Preprocessor Definitions". A button will appear on the right, click on it and in the dialog that pops up, uncheck the "Inherit from parent or project defaults". This will disable UNICODE which is inherited from the project default.
Now that the project settings are properly configured, we are ready to look at some code. Let's first examine how a Win32 application receives and processes events (keyboard, mouse, ...).
The system (Windows) creates a message queue for each application and pushes messages in this queue whenever an event occurs on a window of that specific application. Your application should then retrieve and process those messages in order to react upon them. This is what is called the message loop and it is the heart of all Win32 applications.
A typical message loop looks like this:
MSG Message;
Message.message = (~WM_QUIT);
while (Message.message != WM_QUIT)
{
if (PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&Message);
DispatchMessage(&Message);
}
else
{
}
}
PeekMessage
retrieves a message from the queue if any; the PM_REMOVE
tells PeekMessage
that messages should be removed from the queue. The message will be stored in the first argument and the function returns nonzero if a message was retrieved. The second argument of the function lets you specify a window handle for which the messages have to be retrieved. If NULL is supplied, messages for all windows of the application will be retrieved. The third and fourth parameters let you specify a range for the messages that should be retrieved. If 0 is supplied for both, all messages will be retrieved.
The purpose of the TranslateMessage
function is to translate virtual-keys messages (WM_KEYDOWN
and WM_KEYUP
) into character messages (WM_CHAR
). A WM_CHAR
message will be generated by a combination of WM_KEYDOWN
and WM_KEYUP
messages.
Finally the DispatchMessage
will redirect the message to the correct window procedure. As we will see later, each window in your application has a specific function (called a window procedure) that processes those messages.
So, this snippet of code tries to extract a message from the queue. If a message was available, it will be dispatched to the correct window procedure. If no message was available, we do some processing specific to the application. Once a WM_QUIT
message is retrieved, the loop is exited, which terminates the application.
If we look at the code of this first tutorial, we can see that the message loop is wrapped into a class called CApplication
. Let's take a closer look at this class. First the class declaration:
class CApplication
{
public:
CApplication(HINSTANCE hInstance);
~CApplication();
void ParseCmdLine(LPSTR lpCmdLine);
void Run();
private:
HINSTANCE m_hInstance;
bool m_bFullScreen;
};
The ParseCmdLine
function is quite straightforward: it simply checks if an argument "-fullscreen" is present in the command line. In that case, the flag m_bFullScreen
is set to true
.
Let's look at the Run
function:
void CApplication::Run()
{
CMainWindow mainWindow(800,600,m_bFullScreen);
MSG Message;
Message.message = ~WM_QUIT;
DWORD dwNextDeadLine = GetTickCount() + FRAME_TIME;
DWORD dwSleep = FRAME_TIME;
bool bUpdate = false;
while (Message.message != WM_QUIT)
{
DWORD dwResult = MsgWaitForMultipleObjectsEx(0,NULL,dwSleep,QS_ALLEVENTS,0);
if (dwResult != WAIT_TIMEOUT)
{
while (PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&Message);
DispatchMessage(&Message);
}
if (GetTickCount() >= dwNextDeadLine)
bUpdate = true;
else
bUpdate = false;
}
else
bUpdate = true;
if (bUpdate)
{
DWORD dwCurrentTime = GetTickCount();
mainWindow.Update(dwCurrentTime);
mainWindow.Draw();
dwNextDeadLine = dwNextDeadLine + FRAME_TIME;
}
dwSleep = dwNextDeadLine - GetCurrentTime();
if (dwSleep>FRAME_TIME)
{
dwSleep = FRAME_TIME;
dwNextDeadLine = GetCurrentTime() + FRAME_TIME;
}
}
}
The first line of the function simply creates the main window. We will see in the next chapter what it does exactly. For now, just imagine that this creates and displays the main window with a specific width and height and in fullscreen or not. As you might see, the loop itself is a bit different than what we saw before. The reason is simple: in general for a 2D game, you don't need to refresh the screen as fast as you can. Refreshing it at a constant rate, is sufficient to display animation and do the processing stuff. In our case, we defined a constant (FRAME_TIME
) that specifies the time in msec between two frames.
We could do something simpler: in the first message loop example we saw, we could replace the "// Do processing stuff here..." by a check to see if 30 msec elapsed since the last update:
else
{
if(GetCurrentTime() >= dwLastUpdate+30)
{
dwLastUpdate = GetCurrentTime();
mainWindow.Update(dwCurrentTime);
mainWindow.Draw();
}
}
That will work fine except for the fact that it is busy waiting: if no messages are received, we will loop continuously and eat all available CPU time. This is not really nice because the CPU is used for doing nothing.
A best approach would be to wait until a message arrives or until we reached the next refresh deadline. That's what the MsgWaitForMultipleObjectsEx
function does. In brief, we can specify multiple objects on which we would like to wait, but we are only interested in messages (so, that's why we specify 0
objects in the first argument and a NULL
for the second argument). This function will wait without consuming CPU cycles until either the timeout period expires (specified in the 3rd argument) or when a message has been received. You can specify a filter for messages to be received in the 4th parameter, but we are interested in all messages. When the function times out, it returns WM_TIMEOUT
, which is used in the code to detect when it is time to refresh the screen and update the game logic. If the function didn't time out, it means that one or more messages are waiting in the queue, so we extract all of them using PeekMessage (the function returns FALSE when no messages are in the queue anymore). Whe then determine if the application should be processed or not. At the end of the function, we recalculate the sleep time depending on the next deadline. If this sleep time is bigger then the frame time, it means that the current time was bigger than the next deadline (negative overflow). This typically happens when the window is moved or resized: during this time, the application is not processed anymore. In that case, we simply recalculate a new deadline and sleep time based on the current time.
Great, so now we have a message loop to dispatch the messages to the correct window. But there's something missing: the window itself. So let's look at how this window is created and how the messages sent to it are processed.
As we saw before, we only had to create an instance of the CMainWindow
class in the Run()
method of our application class to create the main window. So let's take a look at the constructor, that's where all the stuff is handled.
CMainWindow::CMainWindow(int iWidth, int iHeight, bool bFullScreen)
: m_hWindow(NULL), m_hDeviceContext(NULL), m_hGLContext(NULL),
m_bFullScreen(bFullScreen)
{
RegisterWindowClass();
RECT WindowRect;
WindowRect.top = WindowRect.left = 0;
WindowRect.right = iWidth;
WindowRect.bottom = iHeight;
DWORD dwExStyle = 0;
DWORD dwStyle = 0;
if (m_bFullScreen)
{
DEVMODE dmScreenSettings;
memset(&dmScreenSettings,0,sizeof(dmScreenSettings));
dmScreenSettings.dmSize = sizeof(dmScreenSettings);
dmScreenSettings.dmPelsWidth = iWidth;
dmScreenSettings.dmPelsHeight = iHeight;
dmScreenSettings.dmBitsPerPel = 32;
dmScreenSettings.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_BITSPERPEL;
if (ChangeDisplaySettings(&dmScreenSettings,CDS_FULLSCREEN)
!= DISP_CHANGE_SUCCESSFUL)
{
throw CException("Unable to switch to fullscreen mode");
}
dwExStyle = WS_EX_APPWINDOW;
dwStyle = WS_POPUP;
ShowCursor(FALSE);
}
else
{
dwExStyle = WS_EX_APPWINDOW | WS_EX_WINDOWEDGE;
dwStyle = WS_OVERLAPPEDWINDOW;
}
AdjustWindowRectEx(&WindowRect, dwStyle, FALSE, dwExStyle);
m_hWindow = CreateWindowEx(dwExStyle,TEXT(WINDOW_CLASSNAME),
TEXT("Tutorial1"),
WS_CLIPSIBLINGS | WS_CLIPCHILDREN | dwStyle,
0, 0, WindowRect.right-WindowRect.left,
WindowRect.bottom-WindowRect.top,
NULL, NULL,
GetModuleHandle(NULL),
this);
if (m_hWindow==NULL)
throw CException("Cannot create the main window");
CreateContext();
InitGL();
ShowWindow(m_hWindow,SW_SHOW);
OnSize(iWidth,iHeight);
}
It looks like a lot of code but it is not that complicated. The first thing we do is call RegisterWindowClass
which will, as its name states, registers the window class for our application. So what is a window class? Basically, it is a template that is used to define a window: you can specify an icon, a background brush, a cursor and other things. Every window is an instance of such a class. Let's take a look at the implementation of this function:
void CMainWindow::RegisterWindowClass()
{
WNDCLASS WindowClass;
WindowClass.style = 0;
WindowClass.lpfnWndProc = &CMainWindow::OnEvent;
WindowClass.cbClsExtra = 0;
WindowClass.cbWndExtra = 0;
WindowClass.hInstance = GetModuleHandle(NULL);
WindowClass.hIcon = NULL;
WindowClass.hCursor = 0;
WindowClass.hbrBackground = 0;
WindowClass.lpszMenuName = NULL;
WindowClass.lpszClassName = WINDOW_CLASSNAME;
RegisterClass(&WindowClass);
}
What it does is register a new class instance (which is called Tutorial1
) and the only thing we specify is the window procedure that will be called when messages are retrieved for that window. This is the OnEvent
function of the class. If you look closely at the function declaration, you will notice that it is a static
function. The reason for that is very simple: non-static member functions don't have the same prototype as global functions even if they have the same argument list. It is because an implicit parameter is passed to the function: the this
parameter which identifies the instance of the class on which the function is called. Static
member functions do not follow the same rule, because they don't belong to a specific instance (they are shared among all instances of the class). The WNDCLASS
structure accepts only global or static
member functions for the lpfnWndProc
parameter. We will see later the consequences of that.
Now, back to the CMainWindow
constructor. The next thing we do there is check if the window should be in fullscreen. If that is the case, we switch to fullscreen mode (by calling ChangeDisplaySettings
). If this function call fails, we throw an exception. We will talk more in detail about exceptions and exception handling in a following chapter.
We will now create the main window but first, we need to adjust the rectangle size because the window caption and borders are eating up a bit of the size. To correct that, we simply call AdjustWindowRectEx
. This function doesn't have any effect if we are in fullscreen mode. We finally call CreateWindowEx
which will create the window with the required style. The second parameter of the function specifies the window class to use (which will of course be the window class we registered earlier). In the last parameter of the function, we pass the this
pointer (the pointer to this CMainWindow
instance). We will see later why we do so. If the window creation fails, we also throw an exception. The CreateContext
and InitGL
functions will initialize OpenGL properly, but we will see that in a following chapter.
We just created a new window by calling CreateWindowEx
and we specified that the window should use the window class we registered earlier. This window class uses the OnEvent
function as a window procedure. Let's take a look at this function:
LRESULT CMainWindow::OnEvent(HWND Handle, UINT Message, WPARAM wParam, LPARAM lParam)
{
if (Message == WM_NCCREATE)
{
CREATESTRUCT* pCreateStruct = reinterpret_cast<CREATESTRUCT*>(lParam);
SetWindowLongPtr(Handle, GWLP_USERDATA,
reinterpret_cast<long>(pCreateStruct->lpCreateParams));
}
CMainWindow* pWindow = reinterpret_cast<CMainWindow*>
(GetWindowLongPtr(Handle, GWLP_USERDATA));
if (pWindow)
pWindow->ProcessEvent(Message,wParam,lParam);
return DefWindowProc(Handle, Message, wParam, lParam);
}
As you remember, this function is a static
function. The function will be called when a message is received and dispatched to our main window. It accepts four parameters:
Handle
: The handle of the window to which the message is sent to Message
: The message Id wParam
: Optional message parameter lParam
: Optional message parameter
Depending on the type of message, some additional information will be stored in the wParam
, lParam
or both (e.g. a mouse move message contains the mouse coordinates, a key down event contains the key code...).
As this function is static
, we don't have access to other non-static class member, which is of course not very useful in our situation. But, don't panic, there's an easy solution for that, and it's the reason why we passed the this
pointer in the last argument of CreateWindowEx
. One of the first message that will be sent to your window procedure is the WM_NCCREATE
message. When this message is received, the lParam
argument contains a pointer to a CREATESTRUCT
structure, which contains information about the window creation, which are in fact the parameters that were passed in the CreateWindowEx
call. The lpCreateParams
field contains the additional data, which is in our case the pointer to the CMainWindow
instance. Unfortunately, this additional data is not sent with every message, so we need a way to store this pointer for later use. That's what we are doing by calling SetWindowLongPtr
: this function lets you save some user data (GWLP_USERDATA
) for a specific window (identified by its handle). In this case, we save the pointer to the class instance. When other messages are received, we will simply retrieve this pointer by calling (GetWindowLongPtr
), and then call a non-static function on the pointer: ProcessEvent
, which is in charge of processing the message. The WM_NCCREATE
message is not the first one that is sent, that's why we need to check if the call to GetWindowLongPtr
did return something else than NULL.
Let's look at the ProcessEvent
function:
void CMainWindow::ProcessEvent(UINT Message, WPARAM wParam, LPARAM lParam)
{
switch (Message)
{
case WM_CLOSE :
PostQuitMessage(0);
break;
case WM_SIZE:
OnSize(LOWORD(lParam),HIWORD(lParam));
break;
case WM_KEYDOWN :
break;
case WM_KEYUP :
break;
}
}
Not too much code here, but this function will be filled in the next tutorials as we need to handle some events. The WM_CLOSE
message is sent when the user clicks on the red cross of the window. At this time, we need to send a WM_QUIT
message in order to exit the main loop and quit the program. A WM_SIZE
message is sent whenever the window is resized, with the new size contained in the lParam
(LOWORD
and HIWORD
are two macros that extract the first 2 bytes and the last 2 bytes from the parameter). When such message is received, we delegate the resizing handling to our OnSize
member function. Some other messages will be handled later: WM_KEYDOWN
when a key is pressed, WM_KEYUP
when a key is released, ...
Up to now, the only thing our program does is create an empty window and display it on the screen (in fullscreen mode or not).
Error management is an important point for all programs, and this is also true for games: you don't want your game to crash because a resource is missing. My preferred way to handle errors for games is to use exceptions. It is much more convenient than returning error codes from functions (and routing them where I want the error to be handled). The main reason is that I can delegate the error handling in one single place: in my main function, where all my exceptions will be caught. Let's first take a look at our exception class, which is quite basic:
class CException : public std::exception
{
public:
const char* what() const { return m_strMessage.c_str(); }
CException(const std::string& strMessage="") : m_strMessage(strMessage) { }
virtual ~CException() { }
std::string m_strMessage;
};
So, nothing fancy here: our exception class inherits from std::exception
(which is not mandatory but is considered good practice). We simply override the what()
function which returns the error message. I kept the scenario quite simple here, but for a bigger game, you might want to specialize this exception into specific ones: out of memory, resource missing, file loading failed, ... This could prove handy because sometimes it is useful to filter the exceptions. A typical example is when the user of your game wants to load a file (containing a previous saved game) which is corrupted. In that case, the load file function will throw an exception but you don't want to exit the program because of that. Displaying a message to the user telling him that the file is corrupted is what you would like to do. You can then easily catch all 'file corrupted' exceptions at an early stage and let all the others be routed to your main exception handling function. After all, if some resources are missing when loading the file, this is probably a critical error and you might want to exit the program.
So, how does my main function look like and how do I handle the exceptions ?
int WINAPI WinMain(HINSTANCE Instance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, INT)
{
try
{
CApplication theApp(Instance);
theApp.ParseCmdLine(lpCmdLine);
theApp.Run();
}
catch(CException& e)
{
MessageBox(NULL,e.what(),"Error",MB_OK|MB_ICONEXCLAMATION);
}
return 0;
}
Pretty easy to understand, isn't it ? We already saw what the CApplication
class is doing and for the exception handling, we simply wrap everything inside a little try
/catch
block. When an exception is thrown somewhere in the program, we simply display an error message with the text of the exception and we nicely exit the program. Note that as theApp
is local to the function, it will be destroyed at the end of the function and its destructor will be called.
If you remember, in our CMainWindow
constructor, we were calling two functions: CreateContext
and InitGL
. I didn't explain yet what those functions do, so let's correct that now. CreateContext
will initialize the rendering context so that OpenGL primitives can be drawn on the window:
void CMainWindow::CreateContext()
{
PIXELFORMATDESCRIPTOR pfd;
memset(&pfd, 0, sizeof(PIXELFORMATDESCRIPTOR));
pfd.nSize = sizeof(PIXELFORMATDESCRIPTOR);
pfd.nVersion = 1; pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; pfd.iPixelType = PFD_TYPE_RGBA; pfd.cColorBits = 32;
if (!(m_hDeviceContext=GetDC(m_hWindow)))
throw CException("Unable to create rendering context");
int PixelFormat;
if (!(PixelFormat=ChoosePixelFormat(m_hDeviceContext,&pfd)))
throw CException("Unable to create rendering context");
if(!SetPixelFormat(m_hDeviceContext,PixelFormat,&pfd))
throw CException("Unable to create rendering context");
if (!(m_hGLContext=wglCreateContext(m_hDeviceContext)))
throw CException("Unable to create rendering context");
if(!wglMakeCurrent(m_hDeviceContext,m_hGLContext))
throw CException("Unable to create rendering context");
}
The first part of the function fills a PIXELFORMATDESCRIPTOR
with the correct information: the buffer is used to draw to a window, must support OpenGL and uses double buffering (to avoid flickering). We then call ChoosePixelFormat
to see if this pixel format is supported. The function returns a pixel format index (or 0
if no matching pixel format was found). Once we have the index of the pixel format, we set the new format by calling SetPixelFormat
. We then create the OpenGL rendering context by calling wglCreateContext
. Finally, by calling wglMakeCurrent
, we specify that all subsequent OpenGL calls made by the thread are drawn on this device context. You can also see that if an error is encountered while creating the context, an exception is thrown and will be handled in our main function.
The InitGL
function is rather simple:
void CMainWindow::InitGL()
{
glEnable(GL_TEXTURE_2D);
glShadeModel(GL_SMOOTH);
glClearColor(0.0, 0.0, 0.0, 0.0);
glEnable(GL_ALPHA_TEST);
glAlphaFunc(GL_GREATER, 0.0f);
}
We first enable the 2D texturing. Without this call, we won't be able to apply textures to shapes on the screen. Those textures will be loaded from file and used to display the different game elements. We then choose a smooth shading model. This is not really important in our case, but it simply tells OpenGL if the points of a primitive (a basic shape, like a triangle or a rectangle) have different colors, they will be interpolated. We'll see later what it does on a concrete example.We then specify a clear color. This color is used to clear the color buffer before drawing anything to it. Finally, we enable the alpha testing. This is needed if we want to render some parts of a texture transparent. Suppose for example that you want to draw a ship on the screen and that this ship is loaded from a file. The ship doesn't have a rectangular shape so, you would like to make the texture around the ship transparent so that you don't have a white rectangle in which you have your ship. This is done by using an alpha channel that specifies the opacity of a pixel (this will be covered more in details in the second article). Once the alpha testing has been enabled, we need also to select which function will be used to discard pixels depending on their alpha channel. This is done through the glAlphaFunc
: we specify that all pixels with an alpha channel greater (GL_GREATER
) than the specified threshold (0) will be discarded (not drawn). Other alpha functions also exist (GL_LESS
, GL_EQUAL
, ...).
Let's now take a look at the OnSize
function. If you remember, this function is called whenever the window is resized (and at least once, at the window creation):
void CMainWindow::OnSize(GLsizei width, GLsizei height)
{
glViewport(0,0,width,height);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glOrtho(0.0,width,height,0.0,-1.0,1.0);
glMatrixMode(GL_MODELVIEW);
}
It receives as parameter the new size of the window. The first thing we do here is call glViewport
. This function specifies which section of the window will be used by OpenGL for drawing. You can for example limit the drawing to a portion of the full window. In our case, we will use the full window as the viewport
. By default, OpenGL will use the full window size so this call is not necessary (only for educational purposes).
Now we'll glMatrixMode
. In order to understand what it does, let me first explain that OpenGL uses three matrix stacks at different stages of the process. These stacks are:
GL_MODELVIEW
: This matrix stack affects the objects in your scene. In case of our tutorial, these objects will simply be textured rectangles. By manipulating this matrix stack, you will be able to translate, rotate and scale the objects in your scene. GL_PROJECTION
: This matrix stack affects how the objects in your scene will be projected on the viewport
. By manipulating this stack, you can specify which kind of projection should be applied to your objects. GL_TEXTURE
: This matrix stack defines how the textures will be manipulated before being applied to objects. We won't manipulate this stack in this tutorial.
OpenGL will always work with the matrix that is currently on top of each stack, but using a stack might be useful because you can then push the current matrix down the stack to be used later. We will see at the end of this tutorial a more concrete example of that.
After this little explanation, we are back to our code: what glMatrixMode
does is that it simply tells OpenGL which matrix stack will be affected by the next operations. In your code, we select the projection stack. We then load the identity matrix in the stack (which simply resets the current matrix to the identity matrix) and then we specify that we would like an orthographic projection of the objects on the viewport
. We finally switch back to the default matrix, which is the model view matrix.
You might wonder what is this orthographic projection? Let's take a deeper look at what it does. You can have two different projections in OpenGL: the perspective or the orthographic projection. Instead of going into a detailed explanation, I'll put two pictures showing the two projections.
Orthographic projection.
Perspective projection.
As you can see, a perspective projection is the way to go if you develop a 3D game: it will be similar as what your eyes can see as objects that are far from the camera will look small. An orthographic projection on the other hand won't distort objects: a cube at a distance will look the same size as a cube just in front of the camera (given they are the same size). For a 2D game, I prefer to use an orthographic projection because then I don't have to take the z position into account: I can give whatever value and the object won't be smaller or bigger depending of this value.
The arguments you pass to glOrtho
are the coordinates of the viewing volume (left
, right
, bottom
, top
, nearVal
and farVal
). The values you choose here will in fact define the 'units' you will be working with: OpenGL doesn't define any units on its own. For example, I've chosen the window width as the width of my viewing volume. It means that if I move an object 1 unit to the left, it will move 1 pixel. You will also often see values from 0.0 to 1.0 for left/bottom and right/top. In that case, one unit is the width of window in the horizontal direction and is the height of the window in the vertical direction. In 2D games, I prefer to use the first option because if I want to draw two textures next to each other, I know exactly how much I have to move my second texture: it is the width of the first texture (e.g. if my textures are 24 pixels width, my second texture will be moved 24 units to the right). On the other hand, if I want to position something in the middle of my window, I have to take into consideration the width of the window. For the other option, 0.5 units is the middle of the window. That's just a matter of choice but as I am familiar with MFC and GDI, I tend to use the first option to have the same feeling. You might also have noticed another point: I gave a value of height
for the bottom and of 0
for the top. It means that my top and bottom are inverted. Here also, it is just a matter of choice: the Y axis in OpenGL goes from the bottom to the top, which is the opposite as what I'm used to do (window coordinates start at the top of the window to the bottom of the window).
Now that everything is set-up correctly, we will finally be able to draw some basic shapes on our window. We are using double buffering to avoid flickering, this means that everything will be written to an off-screen buffer and once the image is composed, the buffers will be swapped, bringing the off-screen buffer to the screen and vice-versa. This avoids having to draw directly on the buffer that is displayed on the screen. Let's look at our CMainWindow::Draw()
function where the drawing code should be:
void CMainWindow::Draw()
{
glClear(GL_COLOR_BUFFER_BIT);
SwapBuffers(m_hDeviceContext);
}
The first line of code simply clears the buffer using the clear color that was specified earlier in our InitGL
function (black). At the end of the function, we swap the buffers by calling SwapBuffers
. Our drawing code will be placed between these calls.
OpenGL allows you to draw some simple shapes, called primitives which can be points, lines and polygons (most of the times, triangles and rectangles). These primitives are described by their vertices, the coordinates of the points themselves, the endpoints of the line segments or the corners of the polygons. For 2D games, we will probably limit ourselves to rectangles: when textured, they allow you to display bitmaps which is almost all we need for a 2D game. For more complex games (like 3D games), complex shapes can be created by assembling triangles together to form a mesh. Let's draw a rectangle and a triangle on the screen: we will put this code between the two function calls in our drawing function.
glBegin(GL_QUADS);
glVertex3i(50,200,0);
glVertex3i(250,200,0);
glVertex3i(250,350,0);
glVertex3i(50,350,0);
glEnd();
glBegin(GL_TRIANGLES);
glVertex3i(400,350,0);
glVertex3i(500,200,0);
glVertex3i(600,350,0);
glEnd();
Specifying vertices (calls to glVertex3i
) should always be wrapped inside a glBegin
/glEnd
pair. The argument supplied to glBegin
defines the type of shape we are drawing. You can draw multiple shapes within the same glBegin
/glEnd
pair, you simply have to provide enough vertices: e.g. if you want to draw two rectangles, you have to provide 8 vertices. The arguments you provide to glVertex3i
are the coordinates of the vertex, which depend on how the projection was defined (remember what we did in the CMainWindow::OnSize()
method). I've chosen to stick to window coordinates for this example. The '3i
' at the end of the function specifies the number and type of arguments to the function. Several versions of this function exist: from two to four arguments which can be integers, floats, doubles, signed, unsigned, arrays, ... Simply select the one that is the most suited to your needs.
You can also specify a color for each of the vertices of your shape, so let's try some nice things here:
glBegin(GL_QUADS);
glColor3f(1.0,0.0,0.0); glVertex3i(50,200,0);
glColor3f(0.0,1.0,0.0); glVertex3i(250,200,0);
glColor3f(0.0,0.0,1.0); glVertex3i(250,350,0);
glColor3f(1.0,1.0,1.0); glVertex3i(50,350,0);
glEnd();
glBegin(GL_TRIANGLES);
glColor3f(1.0,0.0,0.0); glVertex3i(400,350,0);
glColor3f(0.0,1.0,0.0); glVertex3i(500,200,0);
glColor3f(0.0,0.0,1.0); glVertex3i(600,350,0);
glEnd();
Specifying the current color is done by calling glColor3f
, here also, several versions of the function exist. For the floating point version, the full intensity corresponds to 1.0, and no intensity corresponds to 0.0. If you run the code, you will see that the colors of each vertex blend nicely together (it is the image that is on top of this article). That is because we've chosen the GL_SMOOTH
shading model when calling glShadeModel
in our CMainWindow::InitGL()
function. If you change it into GL_FLAT
, you'll see that the shapes have only one color, which is the last supplied one.
I will finish this tutorial by showing you what can be done by manipulating the model view matrix stack. This won't be used in next tutorials (or even in the final game) but it is nice to understand these concepts. That's the reason why I'll be quite brief on this subject.
I already talked a bit about the model view matrix stack and said that you can apply transformations to this matrix which will affect the objects in your scene. I also explained that using a stack instead of a single matrix can be useful when you want to save the current matrix for later use. By calling glPushMatrix
, you push the top matrix down the current selected stack (which is the model view stack by default) and create a duplicate of this matrix on the top of the stack. Once you have manipulated the model view matrix to affect certain objects in your scene, you can pop back to the previous pushed matrix by calling glPopMatrix
. This is particularly useful when you have to draw elements that have children elements: the position and rotation of the children depends on the position and rotation of the parent (e.g. a finger on a robot hand depends on the position of the hand, which in turn depends on the position of the robot's arm). In that case you apply the transformation for the parent element, push the matrix down the stack, apply the transformations for the first child and draw it, then pop the first matrix to reset to the position and rotation of the parent element. You can then draw the second child by using the same method. Of course, those child elements can themselves have child elements in which case you apply the same technique.
Applying transformations to the objects in your scene is done by loading a specific matrix in the model view matrix stack. You can compound this matrix by hand but I guess that's something you would like to avoid. That's why OpenGL provides three routines that can be used for modeling transformations: glTranslate
,glRotate
and glScale
. One thing you have to take into consideration is that each call to such functions is equivalent to creating the corresponding translation, rotation or scaling matrix and then multiply the current model view matrix with this matrix (and storing the result in the model view matrix). It means that you can 'chain' these calls to produce the transformation you like. You might also know (or remember from your math lessons) that matrix multiplication is not commutative. It means that the order in which you call your functions is important. In fact, the last transformation command called in your program is the first which is applied. You can look at it by imagining that you have to call the transformations in the reverse order in which you would like them to be applied. Suppose that you want to position an object at location (100,100) (we don't take z into account here) and have it rotated 180� around the z axis (but still centered at the same location), then you would need to apply the translation first and then rotate the object. If you do the opposite, the translation would be applied first and then the rotation would be applied, which means your object will be moved at location (100,100) and then rotated 180� around (0,0). Which means it will end up in position (-100,-100).
I don't want to go into too much detail here because matrix manipulation and modeling transformations are worth a full article on their own. I simply wanted to show you that manipulating the model view matrix could prove quite powerful, for example if you want to add some simple special effects (like rotation and scaling).
In this article, I've provided a basic framework that can be reused for writing 2D games. It creates the main window and set-up OpenGL accordingly. We will see in the next article how to texture the shapes with images that are loaded from files and how to efficiently manage those resources.
I would like to thanks Andrew Vos for the nice projection images.
Thanks to the reviewers: Vunic, Andrew.
Thanks also to Jeremy Falcon, El Corazon and Nemanja Trifunovic for their advice and help.
- 23rd June, 2008:
- 23rd August, 2008:
- Added link to the second article in the Foreword section.
- The
Run
method of the CApplication
class has been adapted. - The
OnEvent
method of the CMainWindow
class has been adapted. - Added support for blending in the
InitGL
method of the CMainWindow
class.