Building a Versatile Image Class

Designing a flexible component for handling images in SDL-based 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
Posted

In this lesson, we’ll combine the techniques we’ve covered so far in this chapter into a cohesive Image class. We’ll focus on creating a friendly API so external code can easily control how images are rendered onto their surfaces We’ll cover:

  • Creating a flexible API for image handling
  • Implementing file loading and error checking
  • Setting up source and destination rectangles
  • Adding different scaling modes for rendering
  • Optimizing performance for game loops

As a starting point, we’ll be building upon a basic Window and application loop, built using topics we covered earlier in the course:

#include <SDL_image.h>
#include "Window.h"
#include "Image.h"

int main(int argc, char** argv) {
  SDL_Init(SDL_INIT_VIDEO);
  IMG_Init(IMG_INIT_PNG);

  Window GameWindow;
  Image ExampleImage{"example.png"};

  SDL_Event Event;
  bool shouldQuit{false};

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

  SDL_Quit();
  return 0;
}
#pragma once
#include <SDL.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;
};

The starting point for our Image class is this:

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

class Image {
public:
  Image(const std::string& File);
  void Render(SDL_Surface* Surface);
};
#include "Image.h"

Image::Image(const std::string& File) {}

void Image::Render(SDL_Surface* Surface) {}

This program relies on an image called example.png being in the same location as our executable. The examples and screenshots from this lesson are using a .png file that is available by clicking here.

Our program currently renders the following window:

Screenshot of the program running

Principles

Let’s cover some goals we should have in mind for our API design

Friendly API

Our class should be as simple as possible to use. We should give consumers control of the key aspects they’ll want to change, whilst we take care of as much of the complexity as we can.

Performance

When working in real-time applications, we should always be mindful of performance, and this can influence our API design. For example, one possible design would be to expand the parameter list and body of our Render() function:

// Image.h
// ...
class Image {
public:
  // ...
  void Render(
    SDL_Surface* Surface,
    SDL_Rect SrcRect,
    SDL_Rect DestRect,
    SDL_PixelFormat Format
  ) {
    SomeCalculation(SrcRect, DestRect);
    AdditionalExpensiveStuff(Format);
    SDL_BlitSurface(
      ImageSurface, nullptr,
      Surface, nullptr);
  }
  // ...
};

However, Render() is intended to be called every frame. We should be particularly mindful of the performance of functions that are called in areas like our application loop, or our event loop - colloquially referred to as hot loops.

Instead, the preference is typically to perform as much work as possible in other functions, so the code running in hot loops can be as efficient as possible.

Feedback

We should consider the expected inputs of our API. As a statically typed language, C++ takes care of part of this - for example, if a function parameter is SDL_Rect, the compiler will ensure an argument is an SDL_Rect, or implicitly convertable to one.

However, we also typically have requirements beyond the basic type. For example, we’d expect a rectangle used to selecting part of an image to overlap with that image. We’d also expect such a rectangle to have a positive area (that is, its w and h should both be greater than 0)

We need to establish what those expectations are, and how to deal with inputs that don’t meet those requirements. There are no correct decisions here - some options may include:

  • Logging out an error and then rendering the image with some fallback configuration
  • Logging out an error and then refusing to render the image
  • Terminating the program

We’ll use the first option here, but other techniques are valid.

Breaking Changes and Extensibility

Code evolves over time - as we encounter new requirements, we’ll want to expand our class to accommodate them.

Preparing for this involves predicting the types of additions that may come in the future, and designing the class in such a way that those capabilities can be added without disruption to code that is using the existing API.

Changes to an API that forces changes in code that uses the API is referred to as a breaking change. Deleting a function is an obvious example of a breaking change - any code that was calling that function will no longer work.

Reacting to a breaking change in smaller programs is rarely a problem. This is especially true when it’s a program worked on by a single developer as that developer knows how their module is supposed to work, and therefore validate that it still works after updating it to a new API.

However, in larger programs, and particularly if we’re making libraries (such as SDL), we should design our API with the assumption that making breaking changes later could be expensive.

File Loading

Let’s start by enabling our class to create surfaces from image files. We’ll add a private LoadFile() method, as well as mImageSurface and mFile members to store the surface and file name.

We’ll also need to free surfaces when our objects are destroyed, so we’ll add a destructor.

To simplify memory management, we’ll delete our copy constructor and copy assignment operator for now. We’ll introduce techniques for copying objects that manage SDL_Surface objects later in the course

Our updated head file looks like this:

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

class Image {
public:
  Image(const std::string& File);
  void Render(SDL_Surface* Surface) const;
  ~Image();
  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;

private:
  SDL_Surface* mImageSurface{ nullptr };
  std::string  mFile;
  void LoadFile(const std::string& File);
};

In our implementation file, we’ll define the destructor and LoadFile() method.

We want our LoadFile() method to be callable multiple times within the lifecycle of the same object. We’ll support that by ensuring the file is different and freeing the existing surface to prevent memory leaks.

We’ll also call LoadFile() from the constructor:

// Image.cpp
#include "Image.h"

Image::Image(const std::string& File) {
  LoadFile(File);
}

Image::~Image() {
  SDL_FreeSurface(mImageSurface);
}

void Image::LoadFile(const std::string& File) {
  if (File == mFile) { return; }
  SDL_FreeSurface(mImageSurface);
  mFile = File;
  mImageSurface = IMG_Load(File.c_str());
}

Source Rectangle

Let’s allow users to define the source rectangle used in blitting. It will be important for the inner working of our Image objects that this source rectangle be valid, so we’ll split this across two member variables:

  • mRequestedSrcRectangle is the rectangle the user provided, which may not be valid
  • mAppliedSrcRectangle is the rectangle we’ll use for our blitting and calculations

Remembering the rectangle the user requested gives us more flexibility in our API. For example, the user might provide a rectangle that is invalid for the current image but later updates the image to one where that rectangle is valid.

To test the validity of a provided rectangle, we’ll use two private methods - ValidateRectangle() and RectangleWithinSurface():.

Checking whether a rectangle is within a surface isn’t just related to images., Therefore, in a larger application, this function would likely be defined elsewhere, but we’ll just add it to our Image class for now:

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

class Image {
public:
  void SetSourceRectangle(const SDL_Rect& Rect);
  // ...

private:
  SDL_Rect mAppliedSrcRectangle;
  SDL_Rect mRequestedSrcRectangle;

  bool ValidateRectangle(
    const SDL_Rect& Rect,
    const SDL_Surface* Surface,
    const std::string& Context) const;

  bool RectangleWithinSurface(
    const SDL_Rect& Rect,
    const SDL_Surface* Surface) const;
    
  // ...
};

Our setter’s main job is to update mAppliedSrcRectangle. If the rectangle the user requested is valid, we’ll use that. Otherwise, we’ll fall back to setting our rectangle to simply cover the entire area of the image surface:

// Image.cpp
// ...
void Image::SetSourceRectangle(
  const SDL_Rect& Rect) {
  mRequestedSrcRectangle = Rect;
  if (ValidateRectangle(
    Rect, mImageSurface, "Source Rectangle")) {
    mAppliedSrcRectangle = Rect;
  } else {
    mAppliedSrcRectangle = {
      0, 0, mImageSurface->w, mImageSurface->h
    };
  }
}

Our ValidateRectangle() function performs two checks. It ensures the requested rectangle has at least some area - that is, its w and h are both greater than 0.

SDL provides a helper for this in the form of SDL_RectEmpty().

Secondly, we want to ensure the entire bounds of the rectangle are within the bounds of the Surface if provided. We’ll create a RectangleWithinSurface() function for that.

If both conditions are met, ValidateRectangle returns true to SetSourceRectangle, causing our Image class to use that rectangle.

// Image.cpp
// ...
bool Image::ValidateRectangle(
  const SDL_Rect&    Rect,
  const SDL_Surface* Surface,
  const std::string& Context) const {
  if (SDL_RectEmpty(&Rect)) {
    std::cout << "[ERROR] " << Context <<
      ": Rectangle has no area\n";
    return false;
  }
  if (Surface && 
      !RectangleWithinSurface(Rect, Surface)
  ) {
    std::cout << "[ERROR] " << Context <<
      ": Rectangle not within target surface\n";
    return false;
  }
  return true;
}

Checking that the rectangle is within a surface could look like this:

// Image.cpp
// ...
bool Image::RectangleWithinSurface(
  const SDL_Rect&    Rect,
  const SDL_Surface* Surface) const {
  if (Rect.x < 0)
    // Rect extends past left edge
    return false;
  if (Rect.x + Rect.w > Surface->w)
    // Rect extends past right edge
    return false;
  if (Rect.y < 0)
    // Rect extends past top edge
    return false;
  if (Rect.y + Rect.h > Surface->h)
    // Rect extends past bottom edge
    return false;
  return true;
}

Finally, let’s update our constructor to allow users to provide an initial source rectangle. For now, we’ll require consumers to provide this rectangle, but we’ll improve our API by making it optional later:

// Image.h
// ...
class Image {
public:
  Image(
    const std::string& File,
    const SDL_Rect& SourceRect);
  // ...
};
// Image.cpp
// ...
Image::Image(
  const std::string& File,
  const SDL_Rect& SourceRect
) {
  LoadFile(File);
  SetSourceRectangle(SourceRect);
}

Destination Rectangle

Setting a destination rectangle follows much the same process as setting a source rectangle. We’ll add a setter and two member variables:

// Image.h
// ...
class Image {
public:
  void SetDestinationRectangle(const SDL_Rect& Rect);
  // ...

private:
  SDL_Rect mAppliedDestRectangle;
  SDL_Rect mRequestedDestRectangle;
  // ...
};

Our setter looks like below. In this case, we have no surface to compare the destination rectangle to, so we’ll skip that check by passing a nullptr to that parameter of ValidateRectangle():

If the destination rectangle is invalid (by having no area, in this case) we’ll fall back to setting it at the top left of the destination surface, with the same width and height as the source rectangle:

// Image.cpp
// ...
void Image::SetDestinationRectangle(
  const SDL_Rect& Rect) {
  mRequestedDestRectangle = Rect;
  if (ValidateRectangle(Rect, nullptr,
        "Destination Rectangle")) {
    mAppliedDestRectangle = Rect;
  } else {
    mAppliedDestRectangle = {
      0, 0,
      mAppliedSrcRectangle.w,
      mAppliedSrcRectangle.h
    };
  }
}

Let’s update our constructor to accept an initial destination rectangle. Again, we’ll make this required for now, but optional later:

// Image.h
// ...
class Image {
public:
  Image(
    const std::string& File,
    const SDL_Rect& SourceRect,
    const SDL_Rect& DestRect);
  // ...
};
// Image.cpp
// ...
Image::Image(
  const std::string& File,
  const SDL_Rect& SourceRect,
  const SDL_Rect& DestRect
) {
  LoadFile(File);
  SetSourceRectangle(SourceRect);
  SetDestinationRectangle(DestRect);
}

Rendering and Scaling Mode

Let’s get our rendering working. We’ll let users choose between the three scaling modes, which we’ll present in an enum:

// Image.h
// ...

enum class ScalingMode{None, Fill, Contain};

// ...

These scaling modes represent the three techniques we covered earlier in the chapter:

  • ScalingMode::None blits the source rectangle into the destination rectangle with no scaling - that is, using SDL_BlitSurface()
  • ScalingMode::Fill blits the source rectangle into the destination rectangle, scaling and stretching to ensure the destination rectangle is filled using SDL_BlitScaled()
  • ScalingMode::Contain uses SDL_BlitScaled() to fill as much of the destination rectangle as possible whilst respecting the aspect ratio of the source rectangle

Let’s add the option to the constructor, and add a member variable to keep track of it:

// Image.h
// ...

class Image {
public:
  Image(
    const std::string& File,
    const SDL_Rect& SourceRect,
    const SDL_Rect& DestRect,
    ScalingMode ScalingMode
  );
  // ...

private:
  ScalingMode mScalingMode{ ScalingMode::None };
  // ...
};
// Image.cpp
// ...
Image::Image(const std::string& File,
  const SDL_Rect& SourceRectangle,
  const SDL_Rect& DestinationRectangle,
  ScalingMode ScalingMode
): mRequestedSrcRectangle{SourceRectangle},
   mRequestedDestRectangle{DestinationRectangle},
   mScalingMode(ScalingMode)
 {
   LoadFile(File);
 }

Let’s update our Render() function to make use of this. If our scaling mode is set to ScalingMode::None we use SDL_BlitSurface(), otherwise we use SDL_BlitScaled():

// Image.cpp
// ...
void Image::Render(SDL_Surface* Surface) {
  if (mScalingMode == ScalingMode::None) {
    SDL_BlitSurface(mImageSurface,
      &mAppliedSrcRectangle, Surface,
      &mAppliedDestRectangle);
  } else {
    SDL_BlitScaled(mImageSurface,
      &mAppliedSrcRectangle, Surface,
      &mAppliedDestRectangle);
  }
}

With these changes, ScalingMode::None and ScalingMode::Fill should work as intended:

// main.cpp
// ...
int main(int argc, char** argv) {
  // ...
  SDL_Rect Src{ 0, 0, 200, 200 };
  SDL_Rect Dest{
    0, 0,
    GameWindow.GetSurface()->w,
    GameWindow.GetSurface()->h
  };
  Image ExampleImage{
    "example.png",
    Src, Dest,
    ScalingMode::None)
  };
  // ...
}
Screenshot of our program
// main.cpp
// ...
int main(int argc, char** argv) {
  // ...
  Image ExampleImage{
    "example.png",
    Src, Dest,
    ScalingMode::Fill)
  };
  // ...
}
Screenshot of our program

Implementing ScalingMode::Contain

In our SetDestinationRectangle() function, we’ll add additional logic when setting mAppliedDestRectangle. If the scaling mode is set to ScalingMode::Contain, we’ll pass the user’s requested rectangle to MatchAspectRatio() instead of using it directly.

MatchAspectRatio() will return a new SDL_Rect, derived from scaling down the user’s requested rectangle (if necessary) to match the aspect ratio of the source rectangle:

// Image.cpp
// ...
void Image::SetDestinationRectangle(
  const SDL_Rect& Rect) {
  mRequestedDestRectangle = Rect;
  if (ValidateRectangle(Rect, nullptr,
    "Destination Rectangle")) {
    mAppliedDestRectangle =
      mScalingMode == ScalingMode::Contain
        ? MatchAspectRatio(
          Rect, mAppliedSrcRectangle
        ) : Rect;
  } else {
    mAppliedDestRectangle = {
      0, 0,
      mAppliedSrcRectangle.w,
      mAppliedSrcRectangle.h
    };
  }
}

The MatchAspectRatio() function uses the same logic we walked through in our earlier lesson on aspect ratios:

// Image.cpp
// ...
SDL_Rect Image::MatchAspectRatio(
  const SDL_Rect& Source,
  const SDL_Rect& Target) const {
  float TargetRatio{ Target.w
    / static_cast<float>(Target.h) };
  float SourceRatio{ Source.w
    / static_cast<float>(Source.h) };

  SDL_Rect ReturnValue = Source;

  if (SourceRatio < TargetRatio) {
    ReturnValue.h = static_cast<int>(
      Source.w / TargetRatio);
  } else {
    ReturnValue.w = static_cast<int>(
      Source.h * TargetRatio);
  }

  return ReturnValue;
}

There are two key points to note here:

  • If the rectangle the user requested is invalid, Image::SetDestinationRectangle() will fall back to using a destination rectangle with the same width and height as the source rectangle. By definition, this means the rectangles will have the same aspect ratio, so this naturally satisfies our ScalingMode::Contain scenario.
  • If the user’s requested rectangle was fully within the destination surface, the rectangle returned by MatchAspectRatio() will also be within the surface. This is because MatchAspectRatio() only ever returns a rectangle of the same size or smaller.

With these changes, our ScalingMode::Contain rendering should now be working:

// main.cpp
// ...
int main(int argc, char** argv) {
  // ...
  Image ExampleImage{
    "example.png",
    Src, Dest,
    ScalingMode::Contain)
  };
  // ...
}
Screenshot of our program

API Inspiration

When designing APIs, it can be helpful to check how other systems present the same or similar options.

The none, fill, and contain options used here are inspired by the object-fit options within the CSS specification.

CSS is how web developers style websites and the object-fit property lets them specify how an image should be scaled within a rectangular area they define.

Much like what we’re doing, the programmers who make web browsers implement the low-level calculations and complexities to make this work. Consumers can then access this behavior quickly, often with a single line of code such as object-fit: contain

If you want more practice, the CSS specification includes two additional options for the object-fit property: cover and scale-down. An explanation and demo of their effects is available here.

Complete Files

The completed files from this lesson are available here:

#pragma once
#include <SDL_image.h>
#include <string>
#include <iostream>

enum class ScalingMode { None, Fill, Contain };

class Image {
public:
  Image(
    const std::string& File,
    const SDL_Rect& SourceRect,
    const SDL_Rect& DestRect,
    ScalingMode ScalingMode = ScalingMode::None
  );

  void Render(SDL_Surface* Surface);
  void SetSourceRectangle(const SDL_Rect& Rect);
  void SetDestinationRectangle(
    const SDL_Rect& Rect);
  ~Image();
  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;

private:
  SDL_Surface* mImageSurface{ nullptr };
  std::string mFile;
  SDL_Rect mAppliedSrcRectangle;
  SDL_Rect mRequestedSrcRectangle;
  SDL_Rect mAppliedDestRectangle;
  SDL_Rect mRequestedDestRectangle;
  ScalingMode mScalingMode{ ScalingMode::None };

  void LoadFile(const std::string& File);

  SDL_Rect MatchAspectRatio(
    const SDL_Rect& Source,
    const SDL_Rect& Target) const;

  bool ValidateRectangle(
    const SDL_Rect&    Rect,
    const SDL_Surface* Surface,
    const std::string& Context) const;

  bool RectangleWithinSurface(
    const SDL_Rect&    Rect,
    const SDL_Surface* Surface) const;
};
#include "Image.h"

Image::Image(const std::string& File,
  const SDL_Rect& SourceRectangle,
  const SDL_Rect& DestinationRectangle,
  ScalingMode ScalingMode)
  : mScalingMode(ScalingMode) {
  LoadFile(File);
  SetSourceRectangle(SourceRectangle);
  SetDestinationRectangle(DestinationRectangle);
}

void Image::Render(SDL_Surface* Surface) {
  if (mScalingMode == ScalingMode::None) {
    SDL_BlitSurface(mImageSurface,
      &mAppliedSrcRectangle, Surface,
      &mAppliedDestRectangle);
  } else {
    SDL_BlitScaled(mImageSurface,
      &mAppliedSrcRectangle, Surface,
      &mAppliedDestRectangle);
  }
}

void Image::SetSourceRectangle(
  const SDL_Rect& Rect) {
  mRequestedSrcRectangle = Rect;
  if (ValidateRectangle(
    Rect, mImageSurface, "Source Rectangle")) {
    mAppliedSrcRectangle = Rect;
  } else {
    mAppliedSrcRectangle = {
      0, 0, mImageSurface->w, mImageSurface->h };
  }
}

void Image::SetDestinationRectangle(
  const SDL_Rect& Rect) {
  mRequestedDestRectangle = Rect;
  if (ValidateRectangle(Rect, nullptr,
    "Destination Rectangle")) {
    mAppliedDestRectangle =
      mScalingMode == ScalingMode::Contain
      ? MatchAspectRatio(Rect,
        mAppliedSrcRectangle)
      : Rect;
  } else {
    mAppliedDestRectangle = {
      0, 0,
      mAppliedSrcRectangle.w,
      mAppliedSrcRectangle.h
    };
  }
}

Image::~Image() {
  SDL_FreeSurface(mImageSurface);
}

void Image::LoadFile(const std::string& File) {
  if (File == mFile) { return; }

  SDL_FreeSurface(mImageSurface);
  mFile = File;
  mImageSurface = IMG_Load(File.c_str());
}

bool Image::ValidateRectangle(
  const SDL_Rect&    Rect,
  const SDL_Surface* Surface,
  const std::string& Context) const {
  if (SDL_RectEmpty(&Rect)) {
    std::cout << "[ERROR] " << Context <<
      ": Rectangle has no area\n";
    return false;
  }
  if (Surface && !
    RectangleWithinSurface(Rect, Surface)) {
    std::cout << "[ERROR] " << Context <<
      ": Rectangle not within target surface\n";
    return false;
  }
  return true;
}

bool Image::RectangleWithinSurface(
  const SDL_Rect&    Rect,
  const SDL_Surface* Surface) const {
  if (Rect.x < 0)
    return false;
  if (Rect.x + Rect.w > Surface->w)
    return false;
  if (Rect.y < 0)
    return false;
  if (Rect.y + Rect.h > Surface->h)
    return false;
  return true;
}

SDL_Rect Image::MatchAspectRatio(
  const SDL_Rect& Source,
  const SDL_Rect& Target) const {
  float TargetRatio{ Target.w
    / static_cast<float>(Target.h) };
  float SourceRatio{ Source.w
    / static_cast<float>(Source.h) };

  SDL_Rect ReturnValue = Source;

  if (SourceRatio < TargetRatio) {
    ReturnValue.h = static_cast<int>(
      Source.w / TargetRatio);
  } else {
    ReturnValue.w = static_cast<int>(
      Source.h * TargetRatio);
  }

  return ReturnValue;
}

Summary

In this lesson, we've built a comprehensive Image class for SDL-based development. We've covered file loading, rectangle manipulation, and different scaling modes.

Our class now provides a user-friendly API for rendering images with various options.

In the next lesson, we’ll cover suggestions on how this component could be expanded further. We’ll suggest improvements for how the API can be made more flexible and also cover techniques we may want to consider for error handling.

Was this lesson useful?

Next Lesson

Expanding the Image API

Key techniques for implementing class designs in more complex scenarios
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted
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
  • 44.GPUs and Rasterization
  • 45.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:

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

Expanding the Image API

Key techniques for implementing class designs in more complex scenarios
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved