Image
APIIn this lesson, we’ll discuss some ways we can improve upon the Image
class created in the previous lesson. We’ll cover three topics:
The examples we use here are based on the Image
class we created previously:
#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, mImageSurface->w, mImageSurface->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;
}
So far, our class is handling and recovering from errors involving invalid source and destination rectangles. However, there are some situations from which we can't recover, and we should implement ways to communicate these states.
For example, the user may provide an invalid file path, causing our LoadFile()
function to fail. The SDL_Surface
pointer returned from SDL’s IMG_Load()
function is a nullptr
when this happens, and we can retrieve the associated error message by calling SDL_GetError()
.
Let’s add a ValidateSurface()
function to test a surface and log errors:
// Image.h
// ...
class Image {
// ...
private:
// ...
void ValidateSurface(
const SDL_Surface* Surface,
const std::string& Context) const;
};
// Image.cpp
// ...
void Image::ValidateSurface(
const SDL_Surface* Surface,
const std::string& Context) const {
if (!Surface) {
std::cout << "[ERROR] " << Context << ": "
<< SDL_GetError() << '\n';
}
}
We’ll call it at the end of our LoadFile()
function to report if the IMG_Load()
failed:
// Image.cpp
// ...
void Image::LoadFile(const std::string& File) {
if (File == mFile) { return; }
SDL_FreeSurface(mImageSurface);
mFile = File;
mImageSurface = IMG_Load(File.c_str());
ValidateSurface(mImageSurface, "Loading File");
}
This alerts users that an error has happened when they instantiate our class with an invalid path. However, our Image
class can be updated to handle this scenario more gracefully.
For example, we can ensure an image load is successful before we get rid of our existing surface. Let’s update ValidateSurface()
to return a bool
- true
if the surface is valid, and false
otherwise:
// Image.h
// ...
class Image {
// ...
private:
// ...
bool ValidateSurface(
const SDL_Surface* Surface,
const std::string& Context) const;
};
// Image.cpp
// ...
bool Image::ValidateSurface(
const SDL_Surface* Surface,
const std::string& Context) const {
if (!Surface) {
std::cout << "[ERROR] " << Context << ": "
<< SDL_GetError() << '\n';
return false;
}
return true;
}
We can update our LoadFile()
function to make use of this return value. We’ll attempt to create a new surface using IMG_Load()
as before, but we’ll only update our existing surface if that operation is successful:
// Image.cpp
// ...
void Image::LoadFile(const std::string& File) {
if (File == mFile) { return; }
SDL_Surface* NextSurface{ IMG_Load(
File.c_str()) };
if (ValidateSurface(
NextSurface, "Loading File")) {
SDL_FreeSurface(mImageSurface);
mFile = File;
mImageSurface = NextSurface;
}
}
We should also consider whether our public
functions should give external users the ability to react to errors. Whilst logging an error is helpful, it’s difficult for external code to react to logs.
SDL functions often return an integer representing whether the requested action succeeded. They generally return 0
on success and a negative number on failure.
We could use that same technique in our API. For example, let’s update SetSourceRectangle()
to return 0
if the provided rectangle was valid, and -1
if it wasn’t:
// Image.h
// ...
class Image {
public:
int SetSourceRectangle(const SDL_Rect& Rect);
// ...
};
// Image.cpp
// ...
int Image::SetSourceRectangle(
const SDL_Rect& Rect) {
mRequestedSrcRectangle = Rect;
if (ValidateRectangle(Rect, mImageSurface,
"Source Rectangle")) {
mAppliedSrcRectangle = Rect;
return 0;
} else {
mAppliedSrcRectangle = {
0, 0, mImageSurface->w, mImageSurface->h
};
return -1;
}
}
This makes it easier for external code to understand if their operation succeeded, and implement corrections if it didn’t.
Error checking can often have a performance cost, so it’s typically a good practice to disable it in our final release builds. We can use preprocessor directives to help us here.
For example, let’s update our Render()
method to determine if our destination rectangle has been clipped. Render()
is called in a hot loop, so we should be particularly mindful of performance here.
Accordingly, we’ll only perform these checks if the DEBUG
preprocessor directive is defined:
// Image.cpp
// ...
void Image::Render(SDL_Surface* Surface) {
#ifdef DEBUG
int InitialWidth = mAppliedDestRectangle.w;
int InitialHeight = mAppliedDestRectangle.h;
#endif
if (mScalingMode == ScalingMode::None) {
SDL_BlitSurface(
mImageSurface, &mAppliedSrcRectangle,
Surface, &mAppliedDestRectangle);
} else {
SDL_BlitScaled(
mImageSurface, &mAppliedSrcRectangle,
Surface, &mAppliedDestRectangle);
}
#ifdef DEBUG
if (InitialWidth != mAppliedDestRectangle.w) {
std::cout << "Horizontal clipping\n";
}
if (InitialHeight != mAppliedDestRectangle.h) {
std::cout << "Vertical clipping\n";
}
#endif
}
Currently, our class has a single constructor, and that constructor requires the user to provide all the key settings:
There are some sensible defaults we can choose here, so let’s make our class more flexible. We’ll make the source rectangle, destination rectangle, and scaling mode optional.
When designing APIs, it can be helpful to write the code that uses the API first. For example, if I wanted to create an Image
with a source rectangle, what would I like that expression to look like?
It might be something like this:
Image Example{"img.png", {0, 0, 200, 200}};
One challenge we’ll have is that the source and destination rectangles have the same type - SDL_Rect
. As such, the friendliest API will be ambiguous. The previous example could be defining a source rectangle, but it could just as easily be defining a destination rectangle.
One way to disambiguate this is to introduce new types to support our API. These types can typically be extremely simple:
struct SourceRect : SDL_Rect {};
struct DestRect : SDL_Rect {};
Our API can then look like this:
Image A{"A.png", SourceRect{0, 0, 200, 200}};
Let’s add some constructors to support this:
// Image.h
// ...
struct SourceRect : SDL_Rect {};
struct DestRect : SDL_Rect {};
class Image {
public:
Image(
const std::string& File,
ScalingMode ScalingMode = ScalingMode::None
);
Image(
const std::string& File,
const SourceRect& SourceRect,
ScalingMode ScalingMode = ScalingMode::None
);
Image(
const std::string& File,
const DestRect& DestRect,
ScalingMode ScalingMode = ScalingMode::None
);
Image(
const std::string& File,
const SourceRect& SourceRect,
const DestRect& DestRect,
ScalingMode ScalingMode = ScalingMode::None
);
// ...
};
Let’s implement these constructors. We’ll use the following behavior:
// Image.cpp
// ...
Image::Image(
const std::string& File,
ScalingMode Mode
) : mScalingMode{Mode} {
LoadFile(File);
SetSourceRectangle({
0, 0, mImageSurface->w, mImageSurface->h
});
SetDestinationRectangle(mAppliedSrcRectangle);
}
Image::Image(
const std::string& File,
const SourceRect& SourceRectangle,
ScalingMode Mode
) : mScalingMode(Mode) {
LoadFile(File);
SetSourceRectangle(SourceRectangle);
SetDestinationRectangle(mAppliedSrcRectangle);
}
Image::Image(
const std::string& File,
const DestRect& DestRectangle,
ScalingMode Mode
) : mScalingMode(ScalingMode) {
LoadFile(File);
SetSourceRectangle({
0, 0, mImageSurface->w, mImageSurface->h
});
SetDestinationRectangle(DestRectangle);
}
Image::Image(
const std::string& File,
const SourceRect& SourceRectangle,
const DestRect& DestRectangle,
ScalingMode Mode
) : mScalingMode(Mode) {
LoadFile(File);
SetSourceRectangle(SourceRectangle);
SetDestinationRectangle(DestRectangle);
}
In situations where initialization is more complex, it can be helpful to add a private initialization function, which many constructors can defer to. For example:
// Image.cpp
// ...
Image::Image(
const std::string& File,
ScalingMode Mode
): mScalingMode{Mode} {
Initialize(
File,
{0, 0, mImageSurface->w, mImageSurface->h },
mAppliedSrcRectangle
);
}
Image::Image(
const std::string& File,
const SourceRect& SourceRectangle,
ScalingMode Mode
) : mScalingMode{Mode} {
Initialize(
File,
SourceRectangle,
mAppliedSrcRectangle
);
}
// ...
void Image::Initialize(
const std::string& File,
const SourceRect& SourceRectangle,
const DestRect& DestRectangle
) {
LoadFile(File);
SetSourceRectangle(SourceRectangle);
SetDestinationRectangle(DestRectangle);
// ... additional initialization work
}
With these changes, consumers have a flexible and intuitive way to construct objects using our class:
using enum ScalingMode;
Image A{"A.png"};
Image B{"B.png", Contain};
Image C{"C.png", SourceRect{1,2,3,4}};
Image D{"D.png", SourceRect{1,2,3,4}, Fill};
Image E{"E.png", DestRect{1,2,3,4}};
Image F{"F.png", {1,2,3,4}, {5,6,7,8}};
Image G{"G.png", {1,2,3,4}, {5,6,7,8}, None};
Another technique to guide function selection when dealing with similar argument lists is through disambiguation tags.
This approach involves defining a simple type and creating an instance of that type in a location accessible to users of our API. Here's how it works:
struct WithSourceRect_t{};
WithSourceRect_t WithSourceRect;
We then update one of our otherwise identical functions to include a parameter of this type:
// Image.h
// ...
struct WithSourceRect_t{};
WithSourceRect_t WithSourceRect;
class Image{
public:
Image(
const SDL_Rect& DestinationRectangle,
);
Image(
WithSourceRect_t,
const SDL_Rect& SourceRectangle,
);
}
On the consumer side, it would create an API that looks like this:
// Providing destination rectangle
Image A{{1, 2, 3, 4}};
// Providing source rectangle
Image B{WithSourceRect, {1, 2, 3, 4}};
So far, our class offers fairly limited flexibility once it has been constructed. It can be helpful to give users the ability to change various things in an existing image.
For this, we’ll add some public setter methods. When working with more complex objects, setters typically go beyond simply updating a member variable.
They often need to do additional work to ensure the overall state of our object remains valid. Let’s see some examples with our Image
class.
The source rectangle and image surface of our objects are intrinsically linked. The source rectangle needs to overlap the image surface so, when our image surface changes, our source rectangle may need to change, too.
However, users may or may not want to provide a source rectangle. As with our constructors, we can overload a setter to provide both options:
// Image.h
// ...
class Image {
public:
// ...
void SetFile(const std::string& File);
void SetFile(
const std::string& File,
const SDL_Rect& SourceRectangle);
};
If the user doesn’t provide a source rectangle, we’ll default to setting one that covers the entire image surface. Our implementation might look like this:
// Image.cpp
// ...
void Image::SetFile(const std::string& File) {
LoadFile(File);
mRequestedSrcRectangle = {
0, 0, mImageSurface->w, mImageSurface->h
}
SetSourceRectangle(mRequestedSrcRectangle);
}
void Image::SetFile(
const std::string& File,
const SDL_Rect& SourceRectangle
) {
LoadFile(File);
mRequestedSrcRectangle = SourceRectangle;
SetSourceRectangle(SourceRectangle);
}
Similarly, the scaling mode and destination rectangle are closely related in our class design. Again, we can give them two options to change the scaling mode - one that uses the existing destination rectangle, and one that uses a new one that they provide:
// Image.h
// ...
class Image {
public:
// ...
void SetScalingMode(ScalingMode Mode);
void SetScalingMode(ScalingMode Mode,
const SDL_Rect& DestinationRectangle);
};
// Image.cpp
// ...
void Image::SetScalingMode(ScalingMode Mode) {
mScalingMode = Mode;
SetDestinationRectangle(mAppliedSrcRectangle);
}
void Image::SetScalingMode(
ScalingMode Mode,
const SDL_Rect& DestinationRectangle
) {
mScalingMode = Mode;
SetDestinationRectangle(DestinationRectangle);
};
As a final example, let’s allow our Image
objects to receive a preferred pixel format. We’ll add a setter and the associated member variable.
We’ll also add a private ConvertSurface()
function, which will update our image surface to match the preferred format:
// Image.h
// ...
class Image {
public:
// ...
void SetPreferredFormat(
SDL_PixelFormat* Format);
private:
void ConvertSurface();
SDL_PixelFormat* mPreferredFormat{nullptr};
// ...
};
When a preferred format is set, we’d want to convert our existing image surface to that format:
// Image.cpp
// ...
void Image::ConvertSurface() {
SDL_Surface* Converted{
SDL_ConvertSurface(
mImageSurface, Format, 0)};
if (ValidateSurface(
Converted, "Converting Surface"
)) {
SDL_FreeSurface(mImageSurface);
mImageSurface = Converted;
}
}
void Image::SetPreferredFormat(
SDL_PixelFormat* Format) {
mPreferredFormat = Format;
ConvertSurface();
};
Additionally, when a new image is loaded, we want to convert it to the preferred format. Let’s add that to the end of our LoadFile()
function:
// Image.cpp
// ...
void Image::LoadFile(const std::string& File) {
if (File == mFile) { return; }
SDL_Surface* NextSurface{ IMG_Load(
File.c_str()) };
if (ValidateSurface(
NextSurface, "Loading File")) {
SDL_FreeSurface(mImageSurface);
mFile = File;
mImageSurface = NextSurface;
}
if (mPreferredFormat) {
ConvertSurface();
}
}
A pixel format is often something a user would want to specify at object creation, so we’d likely want to it as a parameter on our constructors:
// Image.h
// ...
class Image {
public:
Image(
const std::string& File,
ScalingMode ScalingMode = ScalingMode::None,
SDL_PixelFormat* PreferredFormat = nullptr
);
// ...
};
External code would likely benefit from having access to read the current state of the object, so we’ll often add a collection of getters to our class.
Given their only purpose is to return a value - if a caller discards that return value, they’ve made a mistake. So, we’d also mark them as [[nodiscard]]
so the compiler will generate warnings in that scenario:
// Image.h
// ...
class Image {
public:
[[nodiscard]]
int GetWidth() const {
return ImageSurface->w;
}
[[nodiscard]]
int GetHeight() const {
return ImageSurface->h;
}
[[nodiscard]]
ScalingMode GetScalingMode() const {
return mScalingMode;
}
// ...
}
A lot of information that external code may be interested in is included in the SDL_Surface
that stores our image. This includes things like the width, height, and pixel format.
However, we may not want to give external code access to it. Doing so can reveal a little too much about the inner workings of our class (thereby violating encapsulation) and changes to the SDL_Surface
can place our objects in invalid states.
If we do want to expose the surface anyway, we should consider having the getter return it as a const
:
// Image.h
// ...
class Image {
public:
[[nodiscard]]
const SDL_Surface* GetSurface() {
return mImageSurface;
}
// ...
}
As a final step, we can add documentation in the form of comments to explain how our methods behave. In the introductory course, we introduced the JSDoc format:
// Image.h
// ...
class Image {
public:
/**
* @brief Sets the image file and source
* rectangle used for this object.
*
* @param File The file path to use.
* @param SourceRectangle The source rectangle
* to use when blitting.
* @returns 0 if successful, or a negative
* integer otherwise. Call SDL_GetError() for
* error details.
*/
int SetFile(const std::string& File,
const SDL_Rect& SourceRectangle);
// ...
}
Comments in this format can be understood by external tools, such as IDEs, making our class easier to use:
In this lesson, we've covered a range of techniques for improving our API, using examples from our Image
class. We covered:
Remember, good class design is an iterative process - always look for ways to improve your code!
Image
APIKey techniques for implementing class designs in more complex scenarios
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games