Introduction to SDL3_image

Learn to load, manipulate, and save various image formats using SDL3_image.

Ryan McCombe
Updated

In this lesson, we'll start using the SDL3_image extension library. We'll cover three main topics:

  • Using the IMG_Load() function to load and render a wide variety of image types, rather than being restricted to the basic bitmap (.bmp) format.
  • Using surface blending modes to handle transparency when blitting.
  • Creating image files from our surfaces using IMG_SaveJPG() and IMG_SavePNG().

We'll be building upon the basic application loop and surface-blitting concepts we covered in the previous lessons.

Starting Point

We'll start with a simplified version of the code from our previous lesson. To focus on SDL3_image, we have removed the aspect ratio logic from the Image class.

Our main() function is now passing a .png image path to our Image constructor. The example image for this lesson is a PNG file, which is available here. Remember to place this image in the same directory as your application's executable if you want to follow along.

Files

src
Select a file to view its content

Our Image class does not currently support .png images. It's using SDL_LoadBMP() to load image files and, as the name suggests, this function only supports .bmp images.

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

As such, our program can't render the image, and we have an error being reported in the terminal:

Failed to load image: example.png:
  File is not a Windows BMP file

Using IMG_Load()

Now that we have SDL3_image set up, we can replace SDL_LoadBMP() in our Image class with IMG_Load(). This function is a drop-in replacement - it has the same API.

It accepts the file we want to load as a C-style string and returns a pointer to the SDL_Surface where the image data was loaded.

src/Image.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <iostream>
#include <string>

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

Our program should now render our image:

We can treat the resulting surface in the same way we did before, such as blitting it onto other surfaces. For example, let's update our Render() function to use SDL_BlitSurfaceScaled() instead, and update our DestinationRectangle to set the size and position of our image:

src/Image.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <iostream>
#include <string>

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

  void Render(SDL_Surface* DestinationSurface) {
    if (!ImageSurface) return;
    SDL_BlitSurfaceScaled(
      ImageSurface, nullptr,
      DestinationSurface, &DestinationRectangle,
      SDL_SCALEMODE_LINEAR
    );
  }

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

private:
  SDL_Surface* ImageSurface{nullptr};
  SDL_Rect DestinationRectangle{200, 50, 200, 200};
};

Transparency and Blend Modes

Our PNG image has transparent sections, and we see our program is respecting that by default. In areas where our PNG was transparent, the blitting operation skipped those pixels, keeping the existing color on the destination surface.

In this example, the Window::Render() method fills the window surface with a solid gray color. The Image::Render() method then blends our image on top of it.

This blending strategy is configured on the source surface, which is the ImageSurface created by IMG_Load() in our example.

The surface created by IMG_Load() has blending enabled by default if the image it loads includes transparency. However, we can also enable it explicitly using the SDL_SetSurfaceBlendMode() function.

We pass a pointer to the surface we're configuring and the blend mode we want to use. SDL_BLENDMODE_BLEND is the mode that uses transparency:

src/Image.h

// ...
class Image {
public:
  Image(const std::string& File)
  : ImageSurface{IMG_Load(File.c_str())} {
    if (!ImageSurface) {
      std::cout << "Failed to load image: "
        << File << ":\n  " << SDL_GetError() << '\n';
    }
    SDL_SetSurfaceBlendMode(
      ImageSurface, SDL_BLENDMODE_BLEND
    );
  }
  // ...
};

We can disable blending and return to the basic blitting algorithm using SDL_BLENDMODE_NONE:

src/Image.h

// ...
class Image {
public:
  Image(const std::string& File)
  : ImageSurface{IMG_Load(File.c_str())} {
    if (!ImageSurface) {
      std::cout << "Failed to load image: "
        << File << ":\n  " << SDL_GetError() << '\n';
    }
    SDL_SetSurfaceBlendMode(
      ImageSurface, SDL_BLENDMODE_NONE
    );
  }
  // ...
};

Transparency and Pixel Formats

For blending to work, our surface must also use a pixel format that includes transparency data. IMG_Load() uses an appropriate pixel format by default.

However, if we change it - using SDL_ConvertSurface(), for example -we may lose the transparency data and, therefore, the ability to blend our surface onto others.

We can check if a pixel format includes transparency (also called alpha) by passing its format to the SDL_ISPIXELFORMAT_ALPHA() macro:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_image/SDL_image.h>
#include <iostream>
#include "Window.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  Window GameWindow;
  SDL_Surface* Surface{IMG_Load("example.png")};

  if (SDL_ISPIXELFORMAT_ALPHA(Surface->format)) {
    std::cout << "Surface has alpha";
  }

  Surface = SDL_ConvertSurface(
    Surface,
    GameWindow.GetSurface()->format
  );

  if (!SDL_ISPIXELFORMAT_ALPHA(Surface->format)) {
    std::cout << "...but not any more";
  }

  SDL_Quit();
  return 0;
}
Surface has alpha...but not any more

Taking Screenshots

SDL3_image includes two functions that let us save a surface to an image file on our hard drive. To save a PNG file, we use IMG_SavePNG(), passing a pointer to the surface and the location where we want the file saved.

The location where the file will be created is relative to the location of our executable:

src/Image.h

//...
class Image {
public:
  // ...
  void SaveToFile(const std::string& Location) const {
    if (!ImageSurface) return;
    IMG_SavePNG(ImageSurface, Location.c_str());
  }
  // ...
};

IMG_SaveJPG() works similarly but has an additional integer parameter. This integer ranges from 0 to 100 and affects the quality of the saved file. Higher values prioritize quality, while lower values prioritize compression, thereby reducing file size.

We can use either of these functions to take screenshots of our program. We simply pass our window surface as the first argument:

src/Window.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>

class Window {
public:
  //...
  void TakeScreenshot() const {
    IMG_SaveJPG(
      GetSurface(), "Screenshot.jpg", 90
    );
  }
  //...
};

Memory Management

Currently, our Image class does not allow its instances to be copied in a memory-safe way, so we've deleted the copy constructor and copy assignment operator:

src/Image.h

// Image.h
// ...

class Image {
public:
  // ...
  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;
  // ...
};

Let's finally address this. The root cause is that IMG_Load() is one of the cases where SDL allocates dynamic memory, and requires us to tell it when that memory is safe to deallocate. We've been doing this through the SDL_DestroySurface() function in the Image destructor:

src/Image.h

// Image.h
// ...

class Image {
public:
  // ...
  ~Image() {
    if (ImageSurface) {
      SDL_DestroySurface(ImageSurface);
    }
  }
  // ...
};

Allowing Image objects to be copied using the default copy constructor and operator would be problematic. If we copy one of our Image objects, the ImageSurface variable in both the original object and the copy will point to the same underlying SDL_Surface.

When one of our Image copies is destroyed, its destructor will delete that surface. This leaves the other copy with a dangling pointer, which will cause a use-after-free memory issue the next time the Image tries to use it. Also, when that second Image is later destroyed, its destructor will call SDL_DestroySurface() again with that same address, causing a double-free error.

To allow our Image objects to be copied without creating these memory issues, we need to ensure that when an Image is copied, both the original and the new object gets their own copy of the underlying SDL_Surface data, each in a distinct memory address.

Using SDL_ConvertSurface()

To create a copy of a surface, including all of its pixel data, we can use the SDL_ConvertSurface() function again. As a reminder, it requires two arguments:

  1. A pointer to the surface to copy.
  2. The format to use for the copy. If we want to use the same format as the source, we can retrieve it from the format member variable.

Below, we create an SDL_Surface called Copy, based on data from an SDL_Surface called Source:

SDL_Surface* Copy{SDL_ConvertSurface(
  Source,
  Source->format
)};

Implementing Copy Semantics

To implement copy semantics, let's first update our header file to declare a copy constructor and a copy assignment operator, instead of deleting them. This will let us define them in a new Image.cpp file.

src/Image.h

// ...

class Image {
public:
  Image(const std::string& File);
  // ...
  ~Image();
  Image(const Image&);
  Image& operator=(const Image&);
  // ...
};

Next, let's create Image.cpp and move our constructor and destructor logic there.

src/Image.cpp

#include "Image.h"

Image::Image(const std::string& File)
: ImageSurface{IMG_Load(File.c_str())} {
  if (!ImageSurface) {
    std::cout << "Failed to load image: "
      << File << ":\n  " << SDL_GetError() << '\n';
  }
}

Image::~Image() {
  if (ImageSurface) {
    SDL_DestroySurface(ImageSurface);
  }
}

src/Image.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <iostream>
#include <string>

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

  Image(const std::string& File); 

  void Render(SDL_Surface* DestinationSurface);

  ~Image();
  // ...
};

Now, in Image.cpp, we can implement the copy constructor to duplicate the SDL_Surface from the source object using SDL_ConvertSurface(). We also need to copy our other member variables.

src/Image.cpp

//...

Image::Image(const Image& Source)
: DestinationRectangle(Source.DestinationRectangle) {
  // Copy the SDL_Surface using SDL_ConvertSurface
  if (Source.ImageSurface) {
    ImageSurface = SDL_ConvertSurface(
      Source.ImageSurface,
      Source.ImageSurface->format
    );
  }
}

The copy assignment operator is similar but, in this scenario, we're updating an existing Image instance. That instance might already have an SDL_Surface, so we need to free it first.

src/Image.cpp

//...

Image& Image::operator=(const Image& Source) {
  // Early return for self-assignment
  if (this == &Source) {
    return *this;
  }

  // Free current resources
  if (ImageSurface) {
    SDL_DestroySurface(ImageSurface);
  }

  // Copy the SDL_Surface using SDL_ConvertSurface
  if (Source.ImageSurface) {
    ImageSurface = SDL_ConvertSurface(
      Source.ImageSurface,
      Source.ImageSurface->format
    );
  } else {
    ImageSurface = nullptr;
  }

  // Copy the other member variables too
  DestinationRectangle = Source.DestinationRectangle;

  return *this;
}

With these changes, we can now safely copy our Image instances as needed. Managing external resources like images is an important topic, so we'll revisit it several times throughout the course with increasingly advanced techniques.

If this section didn't entirely make sense, I'd recommend reviewing our on the topic, where we walk through this process in more detail.

Complete Code

Here is the complete code after all the changes in this lesson. We've added the SDL3_image header, switched to IMG_Load, and implemented proper memory management for our Image class.

Files

src
Select a file to view its content

Summary

In this lesson, we've introduced the SDL3_image extension, expanding the range of image formats our application can support. We also saw how some formats include transparency, allowing us to blend surfaces whilst blitting. Finally, we implemented safe copy semantics for our Image class, making it more flexible.

In the next lesson, we'll move on to SDL3_ttf, another extension library that allows us to load font files and render text.

Next Lesson
Lesson 30 of 37

Rendering Text with SDL3_ttf

Learn to render and manipulate text in SDL3 applications using the SDL3_ttf extension.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate