Loading and Displaying Images

Learn how to load, display, and optimize image rendering in your applications
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

In this lesson, we’ll see how we can load images into SDL, and then display them in our window.

We’ll build upon the concepts we introduced in the previous chapters. Our main.cpp looks like below.

For this lesson, the key thing to note is that we have an Image object called Example, which is being asked to Render() onto the window surface every frame:

// main.cpp
#include <SDL.h>
#include "Image.h" 

class Window {/*...*/}; int main(int argc, char** argv){ SDL_Init(SDL_INIT_VIDEO); Window GameWindow; Image Example; SDL_Event Event; bool shouldQuit{false}; while (!shouldQuit) { while (SDL_PollEvent(&Event)) { GameUI.HandleEvent(Event); if (Event.type == SDL_QUIT) { shouldQuit = true; } } GameWindow.Render(); Example.Render(GameWindow.GetSurface()); GameWindow.Update(); } SDL_Quit(); return 0; }

We’ll build out this Image class across the next two lessons. Our starting point is simply this:

// Image.h
#pragma once
#include <SDL.h>

class Image {
public:
  void Render(SDL_Surface* WindowSurface){
    // ...
  }
};

Loading an Image

By default, SDL only supports the basic (.bmp) format. In the next lesson, we’ll introduce the SDL_image extension which understands many more image types, but we’ll stick with bitmaps for now to establish the basics.

To follow along, an example bitmap image is available here.

SDL_LoadBMP()

To load a bitmap image, we call the SDL_LoadBMP() function, passing the location of the file we want to load:

SDL_LoadBMP("SomeFile.bmp");

This path is relative to the base path of our program. The base path is typically the directory containing our executable, such as MyProgram.exe.

In the previous example, we’re asking SDL to load a file called SomeFile.png, stored in the same location as our executable. If our image was in some subdirectory, /assets, for example, we would load it like this:

SDL_LoadBMP("Assets/SomeFile.bmp");

Where are our Executables Stored?

If we're using an IDE to launch our programs, it may not be entirely obvious where it is storing our compiled files.

There's no standard here - it depends on what editor you're using, and how it is configured. Generally, they will be stored in the same location as your source code files. Within that directory, It may be in a folder with a name such as binbuild, or debug.

Alternatively, you can ask SDL to log out the directory your application is running from, using SDL_GetBasePath():

std::cout << SDL_GetBasePath();

Managing the Image Surface

The SDL_LoadBMP() function creates an SDL_Surface using the dimensions and colors of the file we loaded, and it returns a SDL_Surface* - that is, a pointer to the surface.

Let’s update our Image class to make use of this. We’ll add three things:

  • An SDL_Surface* so we can remember what surface is associated with our Image object
  • A constructor that creates this surface. We’ll accept the location of the image we want to load as a string
  • A destructor that asks SDL to free the surface associated with an Image object when that object is destroyed

To simplify memory management, we’ll also prevent our Image objects from being copied. We’ll learn how to implement copy constructors and copy assignment operators for classes that manage SDL_Surface objects later in the course.

Our changes look like this:

// Image.h
#pragma once
#include <string>
#include <SDL.h>

class Image {
public:
  Image(std::string File)
    : ImageSurface{SDL_LoadBMP(File.c_str())} {}

  void Render(SDL_Surface* DestinationSurface) {
    // ...
  }

  ~Image() {
    SDL_FreeSurface(ImageSurface);
  }
  
  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;

private:
  SDL_Surface* ImageSurface{nullptr};
};

Let’s update our main.cpp to pass the path of our example image to this new constructor:

// main.cpp
#include <SDL.h>
#include "Image.h"

class Window {/*...*/}; int main(int argc, char** argv) { SDL_Init(SDL_INIT_VIDEO); SDL_Event Event; bool shouldQuit{false}; Window GameWindow; Image ExampleImg{"example.bmp"};
while (!shouldQuit) {/*...*/} SDL_Quit(); return 0; }

Handling Errors

The SDL_LoadBMP() function can fail, in which case it will return a nullptr and populate the SDL_CheckError() return value with an explanation. We won’t attempt to handle this scenario gracefully in this section, as it adds a lot of noise to our code examples and distracts from the key content.

If the image fails to load, our application will likely crash. However, to help us debug, we can at least log out a message explaining why. Let’s update our Image class accordingly:

// Image.h
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>

class Image {
public:
  Image(std::string File)
  : ImageSurface{SDL_LoadBMP(File.c_str())} {
    if (!ImageSurface) {
      std::cout << "Failed to load image "
        << File << ":\n  "  << SDL_GetError();
    }
  }

  // ...
};

With our Image class updates complete, let’s temporarily change the argument provided from main.cpp to see our error handling in action:

// main.cpp

// ...

int main(int argc, char** argv) {
  // ...
  Image ExampleImg{"fake.bmp"};
  // ...
}
Failed to load image fake.bmp:
  Parameter 'src' is invalid

Surface Blitting

SDL_Surface objects manage an area of memory where they store the color information of each of their pixels. We won’t interact with that data directly in this lesson, but we can see where it is by accessing the pixels member of an SDL_Surface:

// main.cpp
int main(int argc, char** argv) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;

  std::cout << "GameWindow Surface is storing "
    "its pixel data at:"
    << GameWindow.GetSurface()->pixels;

  return 0;
}
GameWindow Surface is storing its pixel data at:
  000002307E090000

To render the image within our program, we need to copy this pixel data to the area of memory where it will end up on our screen. In this context, that means copying data from the ImageSurface to the window surface.

In our main.cpp, we are already passing the window surface to our Image object’s Render() method:

// main.cpp
#include <SDL.h>
#include "Image.h"

class Window {/*...*/}; int main(int argc, char** argv) {
while (!shouldQuit) {
while (SDL_PollEvent(&Event)) {/*...*/} GameWindow.Render(); ExampleImg.Render(GameWindow.GetSurface()); GameWindow.Update(); } SDL_Quit(); return 0; }

So, within that Render() function, we have access to both of the surfaces we need - the ImageSurface member variable, and the DestinationSurface parameter which, in this program, will always be the the window surface:

// Image.h
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>

class Image {
public:
  // ...
  
  void Render(SDL_Surface* DestinationSurface) {
    std::cout << "\nDestinationSurface Surface Pixels: "
      << DestinationSurface->pixels;
    std::cout << "\nImage Surface Pixels: "
      << ImageSurface->pixels;
  }
  // ...

private:
  SDL_Surface* ImageSurface;
};
DestinationSurface Pixels: 00007FF45DB30020
WindowSurface Pixels: 00000292E26E0000

If the data we’re copying from one memory location to another represents pixels, that copying operation is often referred to as blitting. SDL provides the SDL_BlitSurface() function to make it easier to copy pixel data from one surface to another.

The function accepts 4 arguments:

  1. Source: The surface we’re copying from
  2. Source Rectangle: Which area of the source we’re copying from
  3. Destination: The surface we’re copying to
  4. Destination Rectangle: Which area of the destination we’re copying to

Both of the rectangles are optional, and we’ll explore them in more detail in the next lesson. For now, we’ll pass nullptr to those parameters:

// Image.h
// ...

class Image {
public:
  // ...

  void Render(SDL_Surface* DestinationSurface) {
    SDL_BlitSurface(
      ImageSurface, nullptr,
      DestinationSurface, nullptr
    );
  }
  // ...
};

Running our program, we should now see our program rendering the image:

Screenshot of our program output

Overview

It’s important to understand exactly what is going on here, as this architecture is foundational for a lot of more advanced concepts. Let’s review what is happening.

In our main.cpp, we’re creating a Window and an Image:

// main.cpp
int main(int argc, char** argv) {
  SDL_Init(SDL_INIT_VIDEO);
  SDL_Event Event;
  bool shouldQuit{false};
  Window GameWindow; 
  Image ExampleImg; 

while (!shouldQuit) {/*...*/} // Cleanup SDL_Quit(); return 0; }

The constructors for both of these classes result in an SDL_Surface being created. The GameWindow surface is created automatically as a side effect of the SDL_CreateWindow() function, whilst the ExampleImg surface is created by the call to SDL_LoadBMP().

Later, in our application loop, we’re performing three actions after all of our events are processed:

// main.cpp
int main(int argc, char** argv) {
// Application Loop while (!shouldQuit) {
while (SDL_PollEvent(&Event)) {/*...*/} GameWindow.Render(); ExampleImg.Render(GameWindow.GetSurface()); GameWindow.Update(); } // Cleanup SDL_Quit(); return 0; }
  • GameWindow.Render() fills our window surface with a solid gray color. This is done through SDL_FillRect().
  • ExampleImg.Render() fills a portion of our window surface with data from our image surface. This overwrites most of the gray pixels with colors from our image. In this case, our image surface is slightly smaller than the window surface, so not all of the gray pixels are overwritten.
  • We call GameWindow.Update(). This calls SDL_UpdateWindowSurface(), telling SDL we’ve completed rendering our frame, and it can be shown on the screen. This triggers the buffer swap we described in our earlier lesson on double buffering.

There are two additional things to note about these three actions.

Firstly, the order is important. If we call GameWindow.Render() after blitting our image onto the window surface, we’d never see the image. This is because the gray color from SDL_FillRect() would overwrite everything we blitted.

This is perhaps obvious in this case but, as our programs become more complex, we should be mindful of the order our blits happen. We can imagine later blits as "painting over" previous ones.

Secondly, as we’re calling these functions within the application loop, they’re being performed on every frame. It’s particularly important to understand GameWindow.Render() in this context.

On every iteration of our application loop, the window surface still has the content of the previous frame. By kicking off the render process with GameWindow.Render(), we’re effectively clearing out that content by replacing it all with a solid color. This gives us a fresh canvas, ensuring artifacts from the previous frame don’t stick around.

Why do we redraw everything on every frame?

The process of throwing away every frame and creating a new one from scratch may seem wasteful. This is especially likely to be a concern in situations where each new frame is very similar to the previous. Perhaps only part of the frame has changed or, in the case of our simple program, nothing at all has changed.

However, this discard-and-redraw approach is widely adopted, meaning our operating systems, drivers, and hardware are highly optimized to handle applications that work this way. As such, it is generally not worth trying to optimize this aspect.

Optimizing Blit Performance

Behind the scenes, surfaces can represent their pixel data in different ways in memory. This makes blitting slower, as we can no longer just copy the pixel data from the source to the destination. We additionally need to transform the data to match the format used by the destination.

SDL functions like SDL_BlitSurface() perform this transformation for us, so we don’t need to implement it. However, we still incur the performance cost every time it happens.

We cover pixel formats in much more detail later but, for now, we can note that the format an SDL_Surface uses is available from the format member variable:

// Image.h
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>

class Image {
public:

  // ...

  void Render(SDL_Surface* DestinationSurface) {
    if (DestinationSurface->format == 
        ImageSurface->format
    ) {
      std::cout << "Matched Format Blit\n";
    } else {
      std::cout << "Reformat Required\n";
    }
    SDL_BlitSurface(
      ImageSurface, nullptr,
      DestinationSurface, nullptr
    );
  }
  
  // ...
};
Reformat Required
Reformat Required
Reformat Required
...

If one of our surfaces is frequently used to blit onto surfaces that use a different format, we should consider doing that transformation only once, rather than on every blit. We can do that using the SDL_ConvertSurface() function. It accepts a pointer to the surface we want to convert, and a pointer to the SDL_PixelFormat we want to convert it to.

There is an additional, unused third parameter that exists for backward compatibility. We can simply pass 0 there:

SDL_ConvertSurface(Surface, Format, 0)

This function returns a pointer to a new surface with the pixel data in the correct format, or a nullptr if the conversion failed.

Let’s add this format as an optional parameter to our Image constructor. If provided, our ImageSurface will use that format:

// Image.h
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>

class Image {
public:
  Image(
    std::string File,
    SDL_PixelFormat* PreferredFormat = nullptr
  ) : ImageSurface{SDL_LoadBMP(File.c_str())} {
    if (!ImageSurface) {
      std::cout << "Failed to load image: "
        << File << ":\n" << SDL_GetError();
    }
    if (PreferredFormat) {
      SDL_Surface* Converted{
        SDL_ConvertSurface(
          ImageSurface, PreferredFormat, 0
        )
      };
      if (Converted) {
        SDL_FreeSurface(ImageSurface);
        ImageSurface = Converted;
      } else {
        std::cout << "Error converting surface: "
          << SDL_GetError();
      }
    }
  }
  
  // ...
};

Performance Profiling

Before we update our main.cpp to provide this PreferredFormat argument, let’s establish a baseline so we can ensure our changes are improving the performance.

Let’s profile how long our image is currently taking to render. We can do this using SDL_GetPerformanceCounter(), which returns a high-accuracy timer. By calling this function before and after the thing we’re profiling, we can get a platform-specific representation of how long it is taking:

// main.cpp
#include <SDL.h>
#include "Image.h"

class Window {/*...*/}; int main(int argc, char** argv) {
while (!shouldQuit) {
while (SDL_PollEvent(&Event) {/*...*/} GameWindow.Render(); Uint64 Start{SDL_GetPerformanceCounter()}; ExampleImg.Render(GameWindow.GetSurface()); Uint64 Delta{SDL_GetPerformanceCounter() - Start}; std::cout << "\nTime to Render Image: " << Delta; GameWindow.Update(); } // Cleanup SDL_Quit(); return 0; }

Running our program, we can now get some indication of how long the image is taking to render on each frame. On my system, it is taking approximately 4,900 units of time:

Time to Render Image: 4874
Time to Render Image: 4977
Time to Render Image: 4922
...

Note that the "units" here are not well defined. SDL_GetPerformanceCounter() is intended to compare the performance of different implementations, so this 4,900 result is only useful if we have something to compare it against.

Let’s update our main.cpp to provide the PreferredFormat argument, so we can confirm our updated Image constructor is making a positive difference:

// main.cpp
#include <SDL.h>
#include "Image.h"

class Window {/*...*/}; int main(int argc, char** argv) { SDL_Init(SDL_INIT_VIDEO); SDL_Event Event; bool shouldQuit{false}; Window GameWindow; Image ExampleImg{ "example.bmp", GameWindow.GetSurface()->format };
while (!shouldQuit) {/*...*/} // Cleanup SDL_Quit(); return 0; }

Now, our pixel format conversion happens only once - in the Image constructor. The Render() function that is called every frame has less work to do, and that is reflected in our profiling. On my machine, the image blitting is approximately 10 times faster:

Time to Render Image: 498
Time to Render Image: 479
Time to Render Image: 585
...

Should I always convert the format?

Given these results, it may seem reasonable to always convert our surfaces to the same format. However, this is not always a good idea.

Some capabilities require surfaces to be in a specific format. For example, if we want to blend surfaces or images using transparency, we need to use a pixel format that supports it.

If we load a semi-transparent png image onto a surface and then convert it to match the window surface, we’ll likely lose the transparency data. This means we can no longer blend our surfaces in the way we may have intended.

We cover transparency and other forms of blending in more detail later in the course.

Complete Code

Complete versions of the files we created in this lesson are available below:

#include <SDL.h>
#include "Image.h"

class Window {
public:
  Window() {
    SDLWindow = SDL_CreateWindow(
      "My Program", SDL_WINDOWPOS_UNDEFINED,
      SDL_WINDOWPOS_UNDEFINED, 600, 300, 0);
  }

  void Render() {
    SDL_FillRect(
      GetSurface(), nullptr, SDL_MapRGB(
        GetSurface()->format, 50, 50, 50
      )
    );
  }

  void Update() {
    SDL_UpdateWindowSurface(SDLWindow);
  }

  SDL_Surface* GetSurface() {
    return SDL_GetWindowSurface(SDLWindow);
  }

  ~Window() { SDL_DestroyWindow(SDLWindow); }
  Window(const Window&) = delete;
  Window& operator=(const Window&) = delete;

private:
  SDL_Window* SDLWindow;
};

int main(int argc, char** argv) {
  SDL_Init(SDL_INIT_VIDEO);
  SDL_Event Event;
  bool shouldQuit{false};

  Window GameWindow;
  Image ExampleImg{
    "example.bmp",
    GameWindow.GetSurface()->format
  };

  while (!shouldQuit) {
    while (SDL_PollEvent(&Event)) {
      if (Event.type == SDL_QUIT) {
        shouldQuit = true;
      }
    }
    GameWindow.Render();
    ExampleImg.Render(GameWindow.GetSurface());
    GameWindow.Update();
  }

  SDL_Quit();
  return 0;
}
#pragma once
#include <iostream>
#include <SDL.h>
#include <string>

class Image {
public:
  Image(
    std::string File,
    SDL_PixelFormat* PreferredFormat = nullptr
  ) : ImageSurface{SDL_LoadBMP(File.c_str())} {
    if (!ImageSurface) {
      std::cout << "Failed to load image: "
        << File << ":\n" << SDL_GetError();
    }
    if (PreferredFormat) {
      SDL_Surface* Converted{
        SDL_ConvertSurface(
          ImageSurface, PreferredFormat, 0
        )
      };
      if (Converted) {
        SDL_FreeSurface(ImageSurface);
        ImageSurface = Converted;
      } else {
        std::cout << "Error converting surface: "
          << SDL_GetError();
      }
    }
  }

  void Render(SDL_Surface* DestinationSurface) {
    SDL_BlitSurface(
      ImageSurface, nullptr,
      DestinationSurface, nullptr
    );
  }

  ~Image() {
    if (ImageSurface) {
      SDL_FreeSurface(ImageSurface);
    }
  }
  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;

private:
  SDL_Surface* ImageSurface{nullptr};
};

Summary

Throughout this lesson, we covered the key techniques for working with images in C++ and SDL2. We learned how to:

  • Load bitmap images into SDL surfaces
  • Render images on the screen using blitting
  • Optimize performance through pixel format conversion
  • Profile and measure rendering performance

These skills form a solid foundation that we’ll build on through the rest of this section.

In the next lesson, we’ll see how we can replace the nullptr arguments we’re currently passing to SDL_BlitSurface(). This will allow us to select which part of the image to copy, and which position to copy it to.

Was this lesson useful?

Next Lesson

Cropping and Positioning Images

Learn to precisely control image display using source and destination rectangles.
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
Rendering Images and Text
  • 53.GPUs and Rasterization
  • 54.SDL Renderers
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, unlimited access

This course includes:

  • 55 Lessons
  • 100+ Code Samples
  • 91% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Cropping and Positioning Images

Learn to precisely control image display using source and destination rectangles.
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved