Home > Articles

  • Print
  • + Share This
This chapter is from the book

Basic 2D Graphics

Before you can implement the “generate outputs” phase of the game loop, you need some understanding of how 2D graphics work for games

Most displays in use today—whether televisions, computer monitors, tablets, or smartphones—use raster graphics, which means the display has a two-dimensional grid of picture elements (or pixels). These pixels can individually display different amounts of light as well as different colors. The intensity and color of these pixels combine to create a perception of a continuous image for the viewer. Zooming in on a part of a raster image makes each individual pixel discernable, as you can see in Figure 1.2.

Figure 1.2

Figure 1.2 Zooming in on part of an image shows its distinct pixels

The resolution of a raster display refers to the width and height of the pixel grid. For example, a resolution of 1920×1080, commonly known as 1080p, means that there are 1080 rows of pixels, with each row containing 1920 pixels. Similarly, a resolution of 3840×2160, known as 4K, has 2160 rows with 3840 pixels per row.

Color displays mix colors additively to create a specific hue for each pixel. A common approach is to mix three colors together: red, green, and blue (abbreviated RGB). Different intensities of these RGB colors combine to create a range (or gamut) of colors. Although many modern displays also support color formats other than RGB, most video games output final colors in RGB. Whether or not RGB values convert to something else for display on the monitor is outside the purview of the game programmer.

However, many games internally use a different color representation for much of their graphics computations. For example, many games internally support transparency with an alpha value. The abbreviation RGBA references RGB colors with an additional alpha component. Adding an alpha component allows certain objects in a game, such as windows, to have some amount of transparency. But because few if any displays support transparency, the game ultimately needs to calculate a final RGB color and compute any perceived transparency itself.

The Color Buffer

For a display to show an RGB image, it must know the colors of each pixel. In computer graphics, the color buffer is a location in memory containing the color information for the entire screen. The display can use the color buffer for drawing the contents screen. Think of the color buffer as a two-dimensional array, where each (x, y) index corresponds to a pixel on the screen. In every frame during the “generate outputs” phase of the game loop, the game writes graphical output into the color buffer.

The memory usage of the color buffer depends on the number of bits that represent each pixel, called the color depth. For example, in the common 24-bit color depth, red, green, and blue each use 8 bits. This means there are 224, or 16,777,216, unique colors. If the game also wants to store an 8-bit alpha value, this results in a total of 32 bits for each pixel in the color buffer. A color buffer for a 1080p (1920×1080) target resolution with 32 bits per pixel uses 1920×1080×4 bytes, or approximately 7.9 MB.

Some recent games use 16 bits per RGB component, which increases the number of unique colors. Of course, this doubles the memory usage of the color buffer, up to approximately 16 MB for 1080p. This may seem like an insignificant amount, given that most video cards have several gigabytes of video memory available. But when considering all the other memory usage of a cutting-edge game, 8 MB here and 8 MB there quickly adds up. Although most displays at this writing do not support 16 bits per color, some manufacturers now offer displays that support color depths higher than 8 bits per color.

Given an 8-bit value for a color, there are two ways to reference this value in code. One approach involves simply using an unsigned integer corresponding to the number of bits for each color (or channel). So, for a color depth with 8 bits per channel, each channel has a value between 0 and 255. The alternative approach is to normalize the integer over a decimal range from 0.0 to 1.0.

One advantage of using a decimal range is that a value yields roughly the same color, regardless of the underlying color depth. For example, the normalized RGB value (1.0, 0.0, 0.0) yields pure red whether the maximum value of red is 255 (8 bits per color) or 65,535 (16 bits per color). However, the unsigned integer RGB value (255, 0, 0) yields pure red only if there are 8 bits per color. With 16 bits per color, (255, 0, 0) is nearly black.

Converting between these two representations is straightforward. Given an unsigned integer value, divide it by the maximum unsigned integer value to get the normalized value. Conversely, given a normalized decimal value, multiply it by the maximum unsigned integer value to get an unsigned integer value. For now, you should use unsigned integers because the SDL library expects them.

Double Buffering

As mentioned earlier in this chapter, games update several times per second (at the common rates of 30 and 60 FPS). If a game updates the color buffer at the same rate, this gives the illusion of motion, much the way a flipbook appears to show an object in motion when you flip through the pages.

However, the refresh rate, or the frequency at which the display updates, may be different from the game’s frame rate. For example, most NTSC TV displays have a refresh rate of 59.94 Hz, meaning they refresh very slightly less than 60 times per second. However, some newer computer monitors support a 144 Hz refresh rate, which is more than twice as fast.

Furthermore, no current display technology can instantaneously update the entire screen at once. There always is some update order—whether row by row, column by column, in a checkerboard, and so on. Whatever update pattern the display uses, it takes some fraction of a second for the whole screen to update.

Suppose a game writes to the color buffer, and the display reads from that same color buffer. Because the timing of the game’s frame rate may not directly match the monitor’s refresh rate, it’s very like that the display will read from the color buffer while the game is writing to the buffer. This can be problematic.

For example, suppose the game writes the graphical data for frame A into the color buffer. The display then starts reading from the color buffer to show frame A on the screen. However, before the display finishes drawing frame A onto the screen, the game overwrites the color buffer with the graphical data for frame B. The display ends up showing part of frame A and part of frame B on the screen. Figure 1.3 illustrates this problem, known as screen tearing.

Figure 1.3

Figure 1.3 Simulation of screen tearing with a camera panning to the right

Eliminating screen tearing requires two changes. First, rather than having one color buffer that the game and display must share, you create two separate color buffers. Then the game and display alternate between the color buffers they use every frame. The idea is that with two separate buffers, the game can write to one (the back buffer) and, at the same time, the display can read from the other one (the front buffer). After the frame completes, the game and display swap their buffers. Due to the use of two color buffers, the name for this technique is double buffering.

As a more concrete example, consider the process shown in Figure 1.4. On frame A, the game writes its graphical output to buffer X, and the display draws buffer Y to the screen (which is empty). When this process completes, the game and display swap which buffers they use. Then on frame B, the game draws its graphical output to buffer Y, while the display shows buffer X on screen. On frame C, the game returns to buffer X, and the display returns to buffer Y. This swapping between the two buffers continues until the game program closes.

Figure 1.4

Figure 1.4 Double buffering involves swapping the buffers used by the game and display every frame

However, double buffering by itself does not eliminate screen tearing. Screen tearing still occurs if the display is drawing buffer X when the game wants to start writing to X. This usually happens only if the game is updating too quickly. The solution to this problem is to wait until the display finishes drawing its buffer before swapping. In other words, if the display is still drawing buffer X when the game wants to swap back to buffer X, the game must wait until the display finishes drawing buffer X. Developers call this approach vertical synchronization, or vsync, named after the signal that monitors send when they are about to refresh the screen.

With vertical synchronization, the game might have to occasionally wait for a fraction of a second for the display to be ready. This means that the game loop may not be able to achieve its target frame rate of 30 or 60 FPS exactly. Some players argue that this causes unacceptable stuttering of the frame rate. Thus, the decision on whether to enable vsync varies depending on the game or player. A good idea is to offer vsync as an option in the engine so that you can choose between occasional screen tearing or occasional stuttering.

Recent advances in display technology seek to solve this dilemma with an adaptive refresh rate that varies based on the game. With this approach, rather than the display notifying the game when it refreshes, the game tells the display when to refresh. This way, the game and display are in sync. This provides the best of both worlds as it eliminates both screen tearing and frame rate stuttering. Unfortunately, at this writing, adaptive refresh technology is currently available only on certain high-end computer monitors.

Implementing Basic 2D Graphics

SDL has a simple set of functions for drawing 2D graphics. Because the focus of this chapter is 2D, you can stick with these functions. Starting in Chapter 5, “OpenGL,” you’ll switch to the OpenGL library for graphics, as it supports both 2D and 3D.

Initialization and Shutdown

To use SDL’s graphics code, you need to construct an SDL_Renderer via the SDL_CreateRenderer function. The term renderer generically refers to any system that draws graphics, whether 2D or 3D. Because you need to reference this SDL_Renderer object every time you draw something, first add an mRenderer member variable to Game:

SDL_Renderer* mRenderer;

Next, in Game::Initialize, after creating the window, create the renderer:

mRenderer = SDL_CreateRenderer(
   mWindow, // Window to create renderer for
   -1,      // Usually -1
   SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
);

The first parameter to SDL_CreateRenderer is the pointer to the window (which you saved in mWindow). The second parameter specifies which graphics driver to use; this might be relevant if the game has multiple windows. But with only a single window, the default is -1, which means to let SDL decide. As with the other SDL creation functions, the last parameter is for initialization flags. Here, you choose to use an accelerated renderer (meaning it takes advantage of graphics hardware) and enable vertical synchronization. These two flags are the only flags of note for SDL_CreateRenderer.

As with SDL_CreateWindow, the SDL_CreateRenderer function returns a nullptr if it fails to initialize the renderer. As with initializing SDL, Game::Initialize returns false if the renderer fails to initialize.

To shut down the renderer, simply add a call to SDL_DestroyRenderer in Game::Shutdown:

SDL_DestroyRenderer(mRenderer);

Basic Drawing Setup

At a high level, drawing in any graphics library for games usually involves the following steps:

  1. Clear the back buffer to a color (the game’s current buffer).

  2. Draw the entire game scene.

  3. Swap the front buffer and back buffer.

First, let’s worry about the first and third steps. Because graphics are an output, it makes sense to put graphics drawing code in Game::GenerateOutput.

To clear the back buffer, you first need to specify a color with SDL_SetRenderDrawColor. This function takes in a pointer to the renderer, as well as the four RGBA components (from 0 to 255). For example, to set the color as blue with 100% opacity, use the following:

SDL_SetRenderDrawColor(
   mRenderer,
   0,   // R
   0,   // G
   255, // B
   255  // A
);

Next, call SDL_RenderClear to clear the back buffer to the current draw color:

SDL_RenderClear(mRenderer);

The next step—skipped for now—is to draw the entire game scene.

Finally, to swap the front and back buffers, you call SDL_RenderPresent:

SDL_RenderPresent(mRenderer);

With this code in place, if you now run the game, you’ll see a filled-in blue window, as shown in Figure 1.5.

Figure 1.5

Figure 1.5 Game drawing a blue background

Drawing Walls, a Ball, and a Paddle

This chapter’s game project is a version of the classic video game Pong, where a ball moves around the screen, and the player controls a paddle that can hit the ball. Making a version of Pong is a rite of passage for any aspiring game developer—analogous to making a “Hello World” program when first learning how to program. This section explores drawing rectangles to represent the objects in Pong. Because these are objects in the game world, you draw them in GenerateOuput—after clearing the back buffer but before swapping the front and back buffers.

For drawing filled rectangles, SDL has a SDL_RenderFillRect function. This function takes in an SDL_Rect that represents the bounds of the rectangle and draws a filled-in rectangle using the current draw color. Of course, if you keep the draw color the same as the background, you won’t see any rectangles. You therefore need to change the draw color to white:

SDL_SetRenderDrawColor(mRenderer, 255, 255, 255, 255);

Next, to draw the rectangle, you need to specify dimensions via an SDL_Rect struct. The rectangle has four parameters: the x/y coordinates of the top-left corner of the rectangle onscreen, and the width/height of the rectangle. Keep in mind that in SDL rendering, as in many other 2D graphics libraries, the top-left corner of the screen is (0, 0), positive x is to the right, and positive y is down.

For example, if you want to draw a rectangle at the top of the screen, you can use the following declaration of an SDL_Rect:

SDL_Rect wall{
   0,        // Top left x
   0,        // Top left y
   1024,     // Width
   thickness // Height
};

Here, the x/y coordinates of the top-left corner are (0, 0), meaning the rectangle will be at the top left of the screen. You hard-code the width of the rectangle to 1024, corresponding to the width of the window. (It’s generally frowned upon to assume a fixed window size, as is done here, and you’ll remove this assumption in later chapters.) The thickness variable is const int set to 15, which makes it easy to adjust the thickness of the wall.

Finally, you draw the rectangle with SDL_RenderFillRect, passing in SDL_Rect by pointer:

SDL_RenderFillRect(mRenderer, &wall);

The game then draws a wall in the top part of the screen. You can use similar code to draw the bottom wall and the right wall, only changing the parameters of the SDL_Rect. For example, the bottom wall could have the same rectangle as the top wall except that the top-left y coordinate could be 768 - thickness.

Unfortunately, hard-coding the rectangles for the ball and paddle does not work because both objects will ultimately move in the UpdateGame stage of the loop. Although it makes some sense to represent both the ball and paddle as classes, this discussion doesn’t happen until Chapter 2, “Game Objects and 2D Graphics.” In the meantime, you can just use member variables to store the center positions of both objects and draw their rectangles based on these positions.

First, declare a simple Vector2 struct that has both x and y components:

struct Vector2
{
   float x;
   float y;
};

For now, think of a vector (not a std::vector) as a simple container for coordinates. Chapter 3, “Vectors and Basic Physics,” explores the topic of vectors in much greater detail.

Next, add two Vector2s as member variables to Game—one for the paddle position (mPaddlePos) and one for the ball’s position (mBallPos). The game constructor then initializes these to sensible initial values: the ball position to the center of the screen and the paddle position to the center of the left side of the screen.

Armed with these member variables, you can then draw rectangles for the ball and paddle in GenerateOutput. However, keep in mind that the member variables represent the center points of the paddle and ball, while you define an SDL_Rect in terms of the top-left point. To convert from the center point to the top-left point, you simply subtract half the width/height from the x and y coordinates, respectively. For example, the following rectangle works for the ball:

SDL_Rect ball{
   static_cast<int>(mBallPos.x - thickness/2),
   static_cast<int>(mBallPos.y - thickness/2),
   thickness,
   thickness
};

The static casts here convert mBallPos.x and mBallPos.y from floats into integers (which SDL_Rect uses). In any event, you can make a similar calculation for drawing the paddle, except its width and height are different sizes.

With all these rectangles, the basic game drawing now works, as shown in Figure 1.6. The next step is to implement the UpdateGame phase of the loop, which moves the ball and paddle.

Figure 1.6

Figure 1.6 A game with walls, a paddle, and a ball drawing

  • + Share This
  • 🔖 Save To Your Account