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:
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:
Let’s cover some goals we should have in mind for our API design
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.
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.
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:
We’ll use the first option here, but other techniques are valid.
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.
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());
}
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 validmAppliedSrcRectangle
is the rectangle we’ll use for our blitting and calculationsRemembering 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);
}
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);
}
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 rectangleLet’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)
};
// ...
}
// main.cpp
// ...
int main(int argc, char** argv) {
// ...
Image ExampleImage{
"example.png",
Src, Dest,
ScalingMode::Fill)
};
// ...
}
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:
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.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)
};
// ...
}
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.
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;
}
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.
Designing a flexible component for handling images in SDL-based applications
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games