Our ImageComponent
can now render images, but they always appear at their original size, defined by the dimensions of the image file our AssetManager
loads into the surface.
We want more control over the size of our entities. Specifically, we want to control the size in two different ways:
ImageComponent
, we need the ability to intervene and specify what size each individual images should be rendered at.ImageComponent
s renderat, but it has wider implications too. Larger entities also have larger bounding boxes for physics calculations. If our entities had audio, larger entities might be louder, and so on. The overall size of an entity will be defined within it’s TransformComponent
.We’ll implement both of these in this lesson!
In the previous lesson, we successfully integrated our ImageComponent
with an AssetManager
, rendered images using SDL_BlitSurface()
, handled coordinate transformations, and added offsets for precise positioning.
Here's the ImageComponent
code we finished with:
#pragma once
#include <memory>
#include <string>
#include <SDL.h>
#include "Component.h"
#include "Vec2.h"
class ImageComponent : public Component {
public:
using Component::Component;
ImageComponent(
Entity* Owner,
const std::string& FilePath
);
void Initialize() override;
void Render(SDL_Surface* Surface) override;
void DrawDebugHelpers(SDL_Surface*) override;
bool LoadNewImage(const std::string& NewPath);
int GetSurfaceWidth() const;
int GetSurfaceHeight() const;
void SetOffset(const Vec2& NewOffset) {
Offset = NewOffset;
}
private:
std::shared_ptr<SDL_Surface> ImageSurface{nullptr};
std::string ImageFilePath;
Vec2 Offset{0, 0};
};
// ImageComponent.cpp
#include <SDL.h>
#include "ImageComponent.h"
#include "Entity.h"
#include "AssetManager.h"
#include "Utilities.h"
ImageComponent::ImageComponent(
Entity* Owner,
const std::string& FilePath
) : Component(Owner),
ImageFilePath(FilePath
) {
ImageSurface = GetAssetManager()
.LoadSurface(ImageFilePath);
}
void ImageComponent::Render(
SDL_Surface* Surface
) {
if (!ImageSurface) return;
auto [x, y]{
GetOwnerScreenSpacePosition() + Offset
};
SDL_Rect Destination{
Utilities::Round({x, y, 0, 0})};
if (SDL_BlitSurface(
ImageSurface.get(),
nullptr,
Surface,
&Destination
) < 0) {
std::cerr << "Error: Blit failed: "
<< SDL_GetError() << '\n';
}
}
void ImageComponent::DrawDebugHelpers(
SDL_Surface* Surface
){
if (!ImageSurface) return;
auto [x, y]{
GetOwnerScreenSpacePosition() + Offset
};
SDL_Rect DebugRect{Utilities::Round({
x - 5, y - 5, 10, 10
})};
SDL_FillRect(Surface, &DebugRect, SDL_MapRGB(
Surface->format, 0, 0, 255));
}
bool ImageComponent::LoadNewImage(
const std::string& NewPath
) {
ImageFilePath = NewPath;
ImageSurface = GetAssetManager()
.LoadSurface(NewPath);
return ImageSurface != nullptr;
}
int ImageComponent::GetSurfaceWidth() const {
if (!ImageSurface) {
std::cerr << "Warning: Attempted to get "
"width from null ImageSurface.\n";
return 0;
}
return ImageSurface->w;
}
int ImageComponent::GetSurfaceHeight() const {
if (!ImageSurface) {
std::cerr << "Warning: Attempted to get "
"height from null ImageSurface.\n";
return 0;
}
return ImageSurface->h;
}
void ImageComponent::Initialize() {
Entity* Owner{GetOwner()};
if (!Owner->GetTransformComponent()) {
std::cout <<
"Error: ImageComponent requires"
" TransformComponent on its Owner\n";
Owner->RemoveComponent(this);
}
}
Let's allow the ImageComponent
to have a desired rendering width and height, independent of the underlying SDL_Surface
dimensions. We'll add Width
and Height
members (as floats, for potential fractional scaling later) and corresponding getter/setter methods.
By default, if these aren't set, we'll fall back to the image's natural dimensions. We use std::optional<float>
to represent this "maybe set" state. std::optional
holds either a value of the specified type (float
) or no value (std::nullopt
).
First, include the <optional>
header and add the members and methods to ImageComponent.h
:
// ImageComponent.h
// ...
#include <optional>
class ImageComponent : public Component {
public:
// ...
// New Getters/Setters for Width/Height
void SetWidth(float NewWidth);
void SetHeight(float NewHeight);
void ResetWidth();
void ResetHeight();
float GetWidth() const;
float GetHeight() const;
private:
// ...
std::optional<float> Width{std::nullopt};
std::optional<float> Height{std::nullopt};
};
Now, implement these in ImageComponent.cpp
. The setters simply assign the provided value. ResetWidth
/ResetHeight
set the optional
back to std::nullopt
.
The getters check if a value has been explicitly set using the value_or()
method on std::optional
. This method returns the optional value if it has one, or the value we provide as an argument otherwise:
// ImageComponent.cpp
// ...
#include <optional>
// ...
void ImageComponent::SetWidth(float NewWidth) {
Width = NewWidth;
}
void ImageComponent::SetHeight(float NewHeight) {
Height = NewHeight;
}
void ImageComponent::ResetWidth() {
Width = std::nullopt;
}
void ImageComponent::ResetHeight() {
Height = std::nullopt;
}
float ImageComponent::GetWidth() const {
// If Width has a value, return it.
// Otherwise, return surface width.
return Width.value_or(GetSurfaceWidth());
}
float ImageComponent::GetHeight() const {
// If Height has a value, return it.
// Otherwise, return surface height.
return Height.value_or(GetSurfaceHeight());
}
// ...
Now our component knows its target rendering size. Next, we need to handle how the image fits into that size.
std::optional
The std::optional
utility was added to the standard library in C++17, so should be available to most projects.
If we don’t want to use std::optional
, we can simply adopt another convention to represent the absence of a specific width or height. Using 0
or -1
would likely work:
// ImageComponent.h
// ...
#include <optional>
class ImageComponent : public Component {
// ...
private:
// ...
std::optional<float> Width{std::nullopt};
std::optional<float> Height{std::nullopt};
// Use -1.0 to indicate 'not set'
float Width{-1.0f};
float Height{-1.0f};
};
// ImageComponent.cpp
// ...
#include <optional>
// ...
void ImageComponent::SetWidth(float NewWidth) {
// Add validation if desired (e.g., disallow negative)
Width = NewWidth;
}
void ImageComponent::SetHeight(float NewHeight) {
// Add validation if desired (e.g., disallow negative)
Height = NewHeight;
}
void ImageComponent::ResetWidth() {
Width = -1.0; // Set back to sentinel value
}
void ImageComponent::ResetHeight() {
Height = -1.0; // Set back to sentinel value
}
float ImageComponent::GetWidth() const {
// Before: Using std::optional
return Width.value_or(GetSurfaceWidth());
// After: Using sentinel value
if (Width >= 0.0f) {
return Width;
} else {
return GetSurfaceWidth();
}
}
float ImageComponent::GetHeight() const {
// Before: Using std::optional
return Height.value_or(GetSurfaceHeight());
// After: Using sentinel value
if (Height >= 0.0f) {
return Height;
} else {
return GetSurfaceHeight();
}
}
// ...
When the target width and height - GetWidth()
, GetHeight()
- don't match the image's natural aspect ratio, how should we render it?
We could stretch it, fit it entirely within the bounds while preserving aspect ratio, or cover the bounds completely, potentially cropping parts of the image. We'll implement four common scaling modes:
Width
/ Height
. This is our current behavior, but we'll formalize it through the same system that our other scaling modes will use.Width
/ Height
in our ImageComponent
has a different aspect ratio from our image, this will cause our image to be stretched. This is a sensible default as it tends to be most intuitive - if someone specifies they want the image rendered at a specific width and height, the image will be stretched to fit that specification.Width
/ Height
, it won’t fill the entire space.Width
/ Height
, parts of the image will be cropped.To manage these, let's define an enum class
in ImageComponent.h
:
// ImageComponent.h
// ...
enum class ScalingMode {
None, Fill, Contain, Cover
};
class ImageComponent : public Component {
public:
// ...
void SetScalingMode(ScalingMode Mode);
private:
// ...
// Default to None
ScalingMode ScaleMode{ScalingMode::Fill};
};
And implement the simple setter in ImageComponent.cpp
:
// ImageComponent.cpp
// ...
void ImageComponent::SetScalingMode(ScalingMode Mode) {
ScaleMode = Mode;
}
// ...
Now, our component can track which scaling mode to use. The real work happens in the Render()
function.
object-fit
When designing APIs, especially for common problems like image scaling, it's often helpful to look at how other established systems solve them. The scaling modes we're implementing (Fill, Contain, Cover) are directly inspired by the object-fit
property in CSS (Cascading Style Sheets), used in web development.
ScalingMode::None
≈ object-fit: none
ScalingMode::Fill
≈ object-fit: fill
ScalingMode::Contain
≈ object-fit: contain
ScalingMode::Cover
≈ object-fit: cover
Borrowing concepts from existing, established systems makes your API more intuitive for developers familiar with those systems and ensures we’re using battle-tested design patterns.
SDL_BlitScaled()
To implement these scaling modes, SDL_BlitSurface()
isn't sufficient as it doesn't handle resizing. We need its more powerful sibling: SDL_BlitScaled()
.
SDL_BlitScaled()
works similarly but takes destination width and height into account, scaling the source rectangle to fit the destination rectangle. Here is its signature:
int SDL_BlitScaled(
SDL_Surface* src,
const SDL_Rect* srcrect,
SDL_Surface* dst,
SDL_Rect* dstrect
);
src
: The source surface (our ImageSurface
).srcrect
: The portion of the source to copy (nullptr
for the whole surface, or a specific SDL_Rect
).dst
: The destination surface (the window).dstrect
: The target area on the destination surface. SDL_BlitScaled()
will scale the content from srcrect
to fit exactly into dstrect
.The core idea for implementing our scaling modes will be calculating the correct srcrect
and dstrect
based on the ScaleMode
, the image dimensions, and the target Width
/Height
.
Calculating the source and destination rectangles for each scaling mode within the Render()
function will make it very cluttered. Let's create a helper function to encapsulate this logic.
We'll define a simple struct
to hold the pair of rectangles, possibly in an anonymous namespace as it's only needed locally, within this same file:
// ImageComponent.cpp
// ...
namespace {
struct BlitInfo {
SDL_Rect SourceRect;
SDL_Rect DestRect;
};
}
// ...
To calculate the source and destination rectangle, we’ll need the following information:
ScalingMode
we want to useSDL_Surface
returned from our AssetManager
. We’ll call this SurfaceW
and SurfaceH
.TargetX
and TargetY
TargetW
and TargetH
Let’s set it up a helper function that receives this information, and returns the required BlitInfo
:
// ImageComponent.cpp
// ...
namespace {
struct BlitInfo {/*...*/};
// Helper function to calculate source and
// destination rectangles
BlitInfo CalculateBlitInfo(
ScalingMode Mode,
// Natural surface dimensions
int SurfaceW, int SurfaceH,
// Target top-left screen position
float TargetX, float TargetY,
// Target rendering dimensions
float TargetW, float TargetH
) {
BlitInfo Info;
// Default to rendering the whole image
Info.SourceRect = {
0, 0, SurfaceW, SurfaceH
};
// We will implement the logic for each
// scaling mode here later...
Info.DestRect = Utilities::Round({
TargetX, TargetY, TargetW, TargetH
});
return Info;
}
}
// ...
Now, let's refactor ImageComponent::Render()
to gather all the information that CalculateBlitInfo()
requires, and pass it along to generate our source and destination rectangles.
We’ll also switch from using SDL_BlitSurface()
to SDL_BlitScaled()
to support our future scaling modes:
// ImageComponent.cpp
// ...
void ImageComponent::Render(SDL_Surface*) {/*...*/}
void ImageComponent::Render(
SDL_Surface* Surface
) {
if (!ImageSurface) return;
auto [TargetX, TargetY]{
GetOwnerScreenSpacePosition() + Offset
};
float TargetW{GetWidth()};
float TargetH{GetHeight()};
int SurfaceW{GetSurfaceWidth()};
int SurfaceH{GetSurfaceHeight()};
BlitInfo Info{CalculateBlitInfo(
ScaleMode,
SurfaceW, SurfaceH,
TargetX, TargetY,
TargetW, TargetH
)};
if (SDL_BlitScaled(
ImageSurface.get(),
&Info.SourceRect,
Surface,
&Info.DestRect
) < 0) {
std::cerr << "Error: Blit failed: "
<< SDL_GetError() << '\n';
}
}
// ...
Our Render()
function is now much more flexible. Additionally, it doesn’t need to get any more complicated to support different scaling modes. All the complexity of scaling is delegated to CalculateBlitInfo()
.
Let's implement the logic for each mode inside that helper.
None
In None
mode, we want to render the image at its natural size. The TargetW
and TargetH
are ignored for the size of the blit, but we might use them to center the image if the target area is larger. For simplicity here, we'll just render at the natural size at the TargetX
, TargetY
, exactly as we were before:
// ImageComponent.cpp
// ...
namespace {
struct BlitInfo {/*...*/};
BlitInfo CalculateBlitInfo(
ScalingMode Mode,
int SurfaceW, int SurfaceH,
float TargetX, float TargetY,
float TargetW, float TargetH
) {
BlitInfo Info;
Info.SourceRect = {
0, 0, SurfaceW, SurfaceH
};
if (Mode == ScalingMode::None) {
// Render at natural image size
// and at the target position
Info.DestRect = Utilities::Round({
TargetX,
TargetY,
static_cast<float>(SurfaceW),
static_cast<float>(SurfaceH)
});
return Info;
}
// Handle other modes later...
return Info;
}
}
// ...
Fill
The Fill
mode is the simplest: stretch the source image to exactly match the target dimensions, ignoring aspect ratio. SDL_BlitScaled()
does this by default if you provide the full source rect and the target destination rect.
// ImageComponent.cpp
// ...
namespace {
struct BlitInfo {/*...*/};
BlitInfo CalculateBlitInfo(
ScalingMode Mode,
int SurfaceW, int SurfaceH,
float TargetX, float TargetY,
float TargetW, float TargetH
) {
BlitInfo Info;
Info.SourceRect = {
0, 0, SurfaceW, SurfaceH
};
if (Mode == ScalingMode::None) {/*...*/}
if (Mode == ScalingMode::Fill) {
// Stretch source to fill the exact
// target dimensions
Info.DestRect = Utilities::Round({
TargetX, TargetY, TargetW, TargetH
});
return Info;
}
// Handle other modes later...
return Info;
}
}
// ...
Contain
To implement Contain
, we need to figure out the largest possible size the image can be while fitting inside the TargetW
and TargetH
dimensions, without distorting the image's aspect ratio.
First, we calculate the scaling factor needed to fit the width (TargetW / SurfaceW
) and the scaling factor needed to fit the height (TargetH / SurfaceH
).
To ensure the entire image fits within the target bounds, we must use the smaller of these two scaling factors. If we used the larger one, one dimension would fit, but the other would exceed the target boundary. std::min()
gives us this smaller scale.
We apply this Scale
to the original SurfaceW
and SurfaceH
to get the final dimensions for our DestRect
. The SourceRect
remains the entire original image surface, as we want to draw the whole image, just scaled down. The DestRect
position is set to TargetX
, TargetY
.
// ImageComponent.cpp
// ...
namespace {
struct BlitInfo {/*...*/};
BlitInfo CalculateBlitInfo(
ScalingMode Mode,
int SurfaceW, int SurfaceH,
float TargetX, float TargetY,
float TargetW, float TargetH
) {
BlitInfo Info;
Info.SourceRect = {
0, 0, SurfaceW, SurfaceH
};
if (Mode == ScalingMode::None) {/*...*/}
if (Mode == ScalingMode::Fill) {/*...*/}
if (Mode == ScalingMode::Contain) {
float Scale{std::min(
TargetW / SurfaceW,
TargetH / SurfaceH
)};
Info.DestRect = Utilities::Round({
TargetX, TargetY,
SurfaceW * Scale,
SurfaceH * Scale
});
return Info;
}
// Handle remaining mode next...
return Info;
}
}
// ...
Cover
For Cover
mode, we want the image to preserve its aspect ratio but scale up just enough to completely fill the TargetW
and TargetH
. This might mean some parts of the image are cropped.
Similar to Contain
, we calculate the horizontal (TargetW / SurfaceW
) and vertical (TargetH / SurfaceH
) scaling factors. However, this time, we need the image to be at least as large as the target bounds in both dimensions. Therefore, we choose the larger of the two scaling factors using std::max()
.
Using this larger Scale
, the scaled image will perfectly match the target bounds in one dimension but potentially exceed them in the other. To handle this, we don't change the DestRect
(it stays as the full TargetW
, TargetH
), but instead, we adjust the SourceRect
.
// ImageComponent.cpp
// ...
namespace {
struct BlitInfo {/*...*/};
BlitInfo CalculateBlitInfo(
ScalingMode Mode,
int SurfaceW, int SurfaceH,
float TargetX, float TargetY,
float TargetW, float TargetH
) {
BlitInfo Info;
Info.SourceRect = {
0, 0, SurfaceW, SurfaceH
};
if (Mode == ScalingMode::None) {/*...*/}
if (Mode == ScalingMode::Fill) {/*...*/}
if (Mode == ScalingMode::Contain) {/*...*/}
if (Mode == ScalingMode::Cover) {
float Scale{std::max(
TargetW / SurfaceW,
TargetH / SurfaceH
)};
if (Scale * SurfaceW > TargetW) {
float ClipW{TargetW / Scale};
Info.SourceRect = Utilities::Round({
0, 0,
ClipW,
static_cast<float>(SurfaceH)
});
} else if (Scale * SurfaceH > TargetH) {
float ClipH{TargetH / Scale};
Info.SourceRect = Utilities::Round({
0, 0,
static_cast<float>(SurfaceW),
ClipH
});
}
Info.DestRect = Utilities::Round({
TargetX,
TargetY,
TargetW,
TargetH
});
return Info;
}
std::cerr << "Error: Unknown Scaling Mode\n";
return Info;
}
}
// ...
We now have all four scaling modes implemented!
Our images can now scale based on the ImageComponent
's Width
, Height
, and ScaleMode
. But what if we want to scale the entire entity – affecting not just its visuals but potentially its collision bounds, audio volume, or other properties?
This is distinct from the ImageComponent
's scaling. For instance, scaling an entity might make its character model larger and increase its physics footprint, while ImageComponent
scaling only affects how that model is drawn within its component bounds.
Let's add a uniform scaling factor to TransformComponent
.
// TransformComponent.h
// ...
class TransformComponent : public Component {
public:
// ...
float GetScale() const { return Scale; }
void SetScale(float NewScale) {
Scale = NewScale;
}
private:
// ...
float Scale{1.0};
};
Component
ClassTo make this scale easier to access from an entity’s components, let’s add a GetOwnerScale()
helper function to the Component
base class:
// Component.h
// ...
class Component {
public:
// ...
float GetOwnerScale() const;
// ...
};
// Component.cpp
// ...
float Component::GetOwnerScale() const {
TransformComponent* Transform{
GetOwner()->GetTransformComponent()};
if (!Transform) {
std::cerr << "Error: attempted to get scale"
" of an entity with no transform component\n";
return 1.0;
}
return Transform->GetScale();
}
Now, ImageComponent::Render
needs to account for the entity's scale. We should apply the TransformComponent
's scale to the ImageComponent
's target dimensions before calculating the blit information.
// ImageComponent.cpp
// ...
void ImageComponent::Render(
SDL_Surface* Surface
) {
if (!ImageSurface) return;
auto [TargetX, TargetY]{
GetOwnerScreenSpacePosition() + Offset};
// Before
float TargetW{GetWidth()};
float TargetH{GetHeight()};
// After
float TargetW{GetWidth() * GetOwnerScale()};
float TargetH{GetHeight() * GetOwnerScale()};
}
// ...
Now, setting the TransformComponent
's scale will uniformly scale the visual representation provided by the ImageComponent
.
Let's test this in Scene.h
. We'll make the player smaller through the transform component, and we’ll specify the size we want the enemy to be rendered at using its ImageComponent
.
We’ll set the scaling mode to Cover
so the image component will respect both the size we specified and the image’s aspect ratio, but will crop the image:
// Scene.h
// ...
class Scene {
public:
Scene() {
EntityPtr& Player{Entities.emplace_back(
std::make_unique<Entity>(*this))};
Player->AddTransformComponent()
->SetPosition({2, 2});
Player->GetTransformComponent()
->SetScale(0.75);
Player->AddImageComponent("player.png");
EntityPtr& Enemy{Entities.emplace_back(
std::make_unique<Entity>(*this))};
Enemy->AddTransformComponent()
->SetPosition({8, 5});
Enemy->GetTransformComponent()
->SetScale(1.25);
ImageComponent* EnemyImage{
Enemy->AddImageComponent("dragon.png")};
EnemyImage->SetWidth(150);
EnemyImage->SetHeight(150);
EnemyImage->SetScalingMode(ScalingMode::Cover);
}
// ...
};
Running this should show our player scaled down to 75% of his previous size, and our dragon cropped to a 150x150 pixel area, and then scaled up by 25%.
In this case, the requested 150x150 pixel size has a different aspect ratio to our original dragon image so. Because we’ve set the Cover
scaling mode, our dragon image gets scaled just enough to cover the 150x150 pixel area, and excess pixels get cropped:
When we see a repeating pattern in how our components are used, such as a lot of setters being called in sequence, we should consider improving our API to make that process easier.
A common technique is to have our setters return a reference or pointer to the object they’re updating, through the this
pointer:
// ImageComponent.h
// ...
class ImageComponent : public Component {
public:
// Before:
void SetScalingMode(ScalingMode Mode) {
ScaleMode = Mode;
};
// After:
ImageComponent* SetScalingMode(ScalingMode Mode) {
ScaleMode = Mode;
return this;
};
// Other setters updated in the same way
};
Our ImageComponent
can still be used as it was previously:
ImageComponent* EnemyImage{
Enemy->AddImageComponent(
"dragon.png")};
EnemyImage->SetWidth(250);
EnemyImage->SetHeight(150);
EnemyImage->SetScalingMode(ScalingMode::Cover);
However, it can now also be used like this, which many people will prefer:
Enemy->AddImageComponent("dragon.png")}
->SetWidth(250);
->SetHeight(150);
->SetScalingMode(ScalingMode::Cover);
We're using a single float
for uniform scaling (scaling equally in X and Y) in our TransformComponent
. Sometimes, non-uniform scaling (stretching more horizontally than vertically, or vice-versa) is needed.
To implement this, you could replace the float Scale
with a Vec2 Scale{1.0, 1.0}
in TransformComponent
.
// TransformComponent.h
// ...
class TransformComponent : public Component {
public:
// ...
Vec2 GetScale() const { return Scale; }
Vec2 SetScale(Vec2 NewScale) {
Scale = NewScale;
}
private:
// ...
Vec2 Scale{1.0, 1.0};
};
Then, anywhere else that depends on scale, we’d handle those two components appropriately. In ImageComponent::Render()
, for example:
// ImageComponent.cpp
// ...
void ImageComponent::Render(
SDL_Surface* Surface
) {
// ...
// Before:
float Scale{GetOwnerScale()};
float TargetW{GetWidth() * Scale};
float TargetH{GetHeight() * Scale};
// After:
auto [sx, sy]{GetOwnerScale()};
float TargetW{GetWidth() * sx};
float TargetH{GetHeight() * sy};
// ...
}
In this course, we’ll stick with uniform scales for our entities to keep things simple.
Finally, let’s update our debug drawing to help us visualize what is going on with our images.
Our current DrawDebugHelpers()
function just draws a small square at the entity's origin. Let's update it to visualize the scaling we've implemented. We’ll draw draw two rectangles:
Width
, Height
, and ScaleMode
, also affected by entity scale).Since SDL surfaces don't have a built-in rectangle outline function, we'll create a small helper function for this. We introduced a technique for doing this in our earlier lesson on bounding boxes:
We’ll use the same technique (and essentially the same code) from that lesson, drawing our rectangular outline as four thin rectangles - one for each edge. Other components may need the ability to draw these outlines too, so let’s add that code to our Utilities.h
header:
// Utilities.h
// ...
#pragma once
#include <SDL.h>
namespace Utilities{
// ...
inline void DrawRectOutline(
SDL_Surface* Surface,
const SDL_Rect& Rect,
Uint32 Color,
int Thickness = 3
) {
SDL_Rect Top{
Rect.x,
Rect.y,
Rect.w,
Thickness
};
SDL_FillRect(Surface, &Top, Color);
SDL_Rect Bottom{
Rect.x,
Rect.y + Rect.h - Thickness,
Rect.w,
Thickness
};
SDL_FillRect(Surface, &Bottom, Color);
int SideHeight{Rect.h - 2 * Thickness};
SDL_Rect Left{
Rect.x,
Rect.y + Thickness,
Thickness,
SideHeight
};
SDL_FillRect(Surface, &Left, Color);
SDL_Rect Right{
Rect.x + Rect.w - Thickness,
Rect.y + Thickness,
Thickness,
SideHeight
};
SDL_FillRect(Surface, &Right, Color);
}
}
Then, within DrawDebugHelpers()
, we’ll combine the helper functions and techniques we covered in this lesson to draw our two rectangles:
// ImageComponent.cpp
// ...
void ImageComponent::DrawDebugHelpers(
SDL_Surface* Surface
) {
using Utilities::DrawRectOutline;
if (!ImageSurface) return;
// Gather our position and dimensions for
// the CalculateBlitInfo() function,
// similar to what we did in Render()
auto [TargetX, TargetY]{
GetOwnerScreenSpacePosition() + Offset
};
float OwnerScale{GetOwnerScale()};
float TargetW{GetWidth() * OwnerScale};
float TargetH{GetHeight() * OwnerScale};
int SurfaceW{GetSurfaceWidth()};
int SurfaceH{GetSurfaceHeight()};
// 1. Draw Natural Bounds (Green Outline)
SDL_Rect NaturalBounds{
Utilities::Round({
TargetX, TargetY,
SurfaceW * OwnerScale,
SurfaceH * OwnerScale
})};
DrawRectOutline(
Surface, NaturalBounds,
SDL_MapRGB(Surface->format, 0, 255, 0)
);
// 2. Draw Rendered Bounds (Red Outline)
BlitInfo Info{CalculateBlitInfo(
ScaleMode,
SurfaceW, SurfaceH,
TargetX, TargetY,
TargetW, TargetH
)};
DrawRectOutline(
Surface, Info.DestRect,
SDL_MapRGB(Surface->format, 255, 0, 0)
);
// Continue to draw the position
// marker from before (Blue Square)
auto [x, y]{
GetOwnerScreenSpacePosition() + Offset
};
SDL_Rect DebugRect{Utilities::Round({
TargetX - 5, TargetY - 5, 10, 10
})};
SDL_FillRect(
Surface, &DebugRect,
SDL_MapRGB(Surface->format, 0, 0, 255)
);
}
Now, when debug helpers are enabled, you'll see a green rectangle showing where the image would be if rendered at its natural size (but scaled by the entity's transform), and a red rectangle showing the final bounds calculated by CalculateBlitInfo()
.
These rendered bounds reflect the chosen ScaleMode
, Width
, and Height
. This helps visualize how ScalingMode::Contain
might leave gaps or how ScalingMode::Cover
clips the source.
Our completed ImageComponent
and Utilities
header, including all of the updates we made in this lesson, is provided below:
#pragma once
#include <memory>
#include <string>
#include <SDL.h>
#include <optional>
#include "Component.h"
#include "Vec2.h"
enum class ScalingMode {
None, Fill, Contain, Cover
};
class ImageComponent : public Component {
public:
using Component::Component;
ImageComponent(
Entity* Owner,
const std::string& FilePath
);
void Initialize() override;
void Render(SDL_Surface* Surface) override;
void DrawDebugHelpers(SDL_Surface*) override;
bool LoadNewImage(const std::string& NewPath);
int GetSurfaceWidth() const;
int GetSurfaceHeight() const;
void SetWidth(float NewWidth);
void SetHeight(float NewHeight);
void ResetWidth();
void ResetHeight();
float GetWidth() const;
float GetHeight() const;
void SetScalingMode(ScalingMode Mode);
void SetOffset(const Vec2& NewOffset) {
Offset = NewOffset;
}
private:
std::shared_ptr<SDL_Surface> ImageSurface{nullptr};
std::string ImageFilePath;
Vec2 Offset{0, 0};
std::optional<float> Width{std::nullopt};
std::optional<float> Height{std::nullopt};
ScalingMode ScaleMode{ScalingMode::Fill};
};
// ImageComponent.cpp
#include <SDL.h>
#include "ImageComponent.h"
#include "Entity.h"
#include "AssetManager.h"
#include "Utilities.h"
namespace{
struct BlitInfo {
SDL_Rect SourceRect;
SDL_Rect DestRect;
};
BlitInfo CalculateBlitInfo(
ScalingMode Mode,
int SurfaceW, int SurfaceH,
float TargetX, float TargetY,
float TargetW, float TargetH
) {
BlitInfo Info;
Info.SourceRect = {
0, 0, SurfaceW, SurfaceH
};
if (Mode == ScalingMode::None) {
Info.DestRect = Utilities::Round({
TargetX,
TargetY,
static_cast<float>(SurfaceW),
static_cast<float>(SurfaceH)
});
return Info;
}
if (Mode == ScalingMode::Fill) {
Info.DestRect = Utilities::Round({
TargetX, TargetY, TargetW, TargetH
});
return Info;
}
if (Mode == ScalingMode::Contain) {
float Scale{
std::min(TargetW / SurfaceW,
TargetH / SurfaceH)};
Info.DestRect =
Utilities::Round({
TargetX, TargetY,
SurfaceW * Scale,
SurfaceH * Scale
});
return Info;
}
if (Mode == ScalingMode::Cover) {
float Scale{
std::max(TargetW / SurfaceW,
TargetH / SurfaceH)};
if (Scale * SurfaceW > TargetW) {
float ClipW{TargetW / Scale};
Info.SourceRect =
Utilities::Round({
0, 0, ClipW,
static_cast<float>(SurfaceH)});
} else if (Scale * SurfaceH > TargetH) {
float ClipH{TargetH / Scale};
Info.SourceRect =
Utilities::Round({
0, 0, static_cast<float>(SurfaceW),
ClipH});
}
Info.DestRect = Utilities::Round({
TargetX, TargetY, TargetW, TargetH});
return Info;
}
std::cerr <<
"Error: Unknown Scaling Mode\n";
return Info;
}
}
ImageComponent::ImageComponent(
Entity* Owner,
const std::string& FilePath
) : Component(Owner),
ImageFilePath(FilePath
) {
ImageSurface = GetAssetManager()
.LoadSurface(ImageFilePath);
}
void ImageComponent::Render(
SDL_Surface* Surface
) {
if (!ImageSurface) return;
auto [TargetX, TargetY]{
GetOwnerScreenSpacePosition() + Offset
};
float TargetW{GetWidth() * GetOwnerScale()};
float TargetH{GetHeight() * GetOwnerScale()};
int SurfaceW{GetSurfaceWidth()};
int SurfaceH{GetSurfaceHeight()};
BlitInfo Info{
CalculateBlitInfo(
ScaleMode,
SurfaceW, SurfaceH,
TargetX, TargetY,
TargetW, TargetH
)};
if (SDL_BlitScaled(
ImageSurface.get(),
&Info.SourceRect,
Surface,
&Info.DestRect
) < 0) {
std::cerr << "Error: Blit failed: "
<< SDL_GetError() << '\n';
}
}
void ImageComponent::DrawDebugHelpers(
SDL_Surface* Surface
) {
using Utilities::DrawRectOutline;
if (!ImageSurface) return;
auto [TargetX, TargetY]{
GetOwnerScreenSpacePosition() + Offset
};
float OwnerScale{GetOwnerScale()};
float TargetW{GetWidth() * OwnerScale};
float TargetH{GetHeight() * OwnerScale};
int SurfaceW{GetSurfaceWidth()};
int SurfaceH{GetSurfaceHeight()};
SDL_Rect NaturalBounds{
Utilities::Round({
TargetX, TargetY,
SurfaceW * OwnerScale,
SurfaceH * OwnerScale
})};
DrawRectOutline(
Surface, NaturalBounds,
SDL_MapRGB(Surface->format, 0, 255, 0)
);
BlitInfo Info{CalculateBlitInfo(
ScaleMode,
SurfaceW, SurfaceH,
TargetX, TargetY,
TargetW, TargetH
)};
DrawRectOutline(
Surface, Info.DestRect,
SDL_MapRGB(Surface->format, 255, 0, 0)
);
auto [x, y]{
GetOwnerScreenSpacePosition() + Offset};
SDL_Rect DebugRect{
Utilities::Round({
TargetX - 5, TargetY - 5, 10, 10})
};
SDL_FillRect(
Surface, &DebugRect,
SDL_MapRGB(Surface->format, 0, 0, 255)
);
}
bool ImageComponent::LoadNewImage(
const std::string& NewPath
) {
ImageFilePath = NewPath;
ImageSurface = GetAssetManager()
.LoadSurface(NewPath);
return ImageSurface != nullptr;
}
int ImageComponent::GetSurfaceWidth() const {
if (!ImageSurface) {
std::cerr << "Warning: Attempted to get "
"width from null ImageSurface.\n";
return 0;
}
return ImageSurface->w;
}
int ImageComponent::GetSurfaceHeight() const {
if (!ImageSurface) {
std::cerr << "Warning: Attempted to get "
"height from null ImageSurface.\n";
return 0;
}
return ImageSurface->h;
}
void ImageComponent::Initialize() {
Entity* Owner{GetOwner()};
if (!Owner->GetTransformComponent()) {
std::cout <<
"Error: ImageComponent requires"
" TransformComponent on its Owner\n";
Owner->RemoveComponent(this);
}
}
void ImageComponent::SetWidth(float NewWidth) {
Width = NewWidth;
}
void ImageComponent::SetHeight(
float NewHeight
) {
Height = NewHeight;
}
void ImageComponent::ResetWidth() {
Width = std::nullopt;
}
void ImageComponent::ResetHeight() {
Height = std::nullopt;
}
float ImageComponent::GetWidth() const {
return Width.value_or(GetSurfaceWidth());
}
float ImageComponent::GetHeight() const {
return Height.value_or(GetSurfaceHeight());
}
void ImageComponent::SetScalingMode(
ScalingMode Mode
) {
ScaleMode = Mode;
}
#pragma once
#include <SDL.h>
namespace Utilities{
inline SDL_Rect Round(const SDL_FRect& R) {
return {
static_cast<int>(SDL_round(R.x)),
static_cast<int>(SDL_round(R.y)),
static_cast<int>(SDL_round(R.w)),
static_cast<int>(SDL_round(R.h)),
};
}
inline void DrawRectOutline(
SDL_Surface* Surface,
const SDL_Rect& Rect,
Uint32 Color,
int Thickness = 3
) {
SDL_Rect Top{
Rect.x, Rect.y, Rect.w, Thickness};
SDL_FillRect(Surface, &Top, Color);
SDL_Rect Bottom{
Rect.x, Rect.y + Rect.h - Thickness,
Rect.w, Thickness};
SDL_FillRect(Surface, &Bottom, Color);
int SideHeight{Rect.h - 2 * Thickness};
SDL_Rect Left{
Rect.x, Rect.y + Thickness, Thickness,
SideHeight};
SDL_FillRect(Surface, &Left, Color);
SDL_Rect Right{
Rect.x + Rect.w - Thickness,
Rect.y + Thickness, Thickness,
SideHeight};
SDL_FillRect(Surface, &Right, Color);
}
}
We also made smaller changes to the Component
and TransformComponent
classes. We’ve provided these below, with our changes highlighted:
#pragma once
#include <SDL.h>
#include "Utilities.h"
#include "Vec2.h"
#include "Component.h"
class TransformComponent : public Component {
public:
using Component::Component;
Vec2 GetPosition() const { return Position; }
void SetPosition(const Vec2& NewPosition) {
Position = NewPosition;
}
float GetScale() const { return Scale; }
void SetScale(float NewScale) {
Scale = NewScale;
}
void DrawDebugHelpers(SDL_Surface* S) override {
auto [x, y]{ToScreenSpace(Position)};
SDL_Rect Square{Utilities::Round({
x - 10, y - 10, 20, 20
})};
SDL_FillRect(S, &Square, SDL_MapRGB(
S->format, 255, 0, 0));
}
private:
Vec2 Position{0, 0};
float Scale{1.0f};
};
#pragma once
#include <SDL.h>
class Entity;
class Scene;
class AssetManager;
class Vec2;
class Component {
public:
Component(Entity* Owner) : Owner(Owner) {}
virtual void Initialize() {}
virtual void HandleEvent(const SDL_Event& E) {}
virtual void Tick(float DeltaTime) {}
virtual void Render(SDL_Surface* Surface) {}
virtual void DrawDebugHelpers(
SDL_Surface* Surface) {}
virtual ~Component() = default;
Entity* GetOwner() const { return Owner; }
Scene& GetScene() const;
AssetManager& GetAssetManager() const;
Vec2 ToScreenSpace(const Vec2& Pos) const;
Vec2 GetOwnerPosition() const;
void SetOwnerPosition(const Vec2& Pos) const;
Vec2 GetOwnerScreenSpacePosition() const;
float GetOwnerScale() const;
private:
Entity* Owner{nullptr};
};
#include "Component.h"
#include "Entity.h"
#include "Scene.h"
Scene& Component::GetScene() const {
return GetOwner()->GetScene();
}
AssetManager& Component::GetAssetManager() const {
return GetScene().GetAssetManager();
}
Vec2 Component::ToScreenSpace(const Vec2& Pos) const {
return GetScene().ToScreenSpace(Pos);
}
Vec2 Component::GetOwnerPosition() const {
TransformComponent* Transform{
GetOwner()->GetTransformComponent()};
if (!Transform) {
std::cerr << "Error: attempted to get position"
" of an entity with no transform component\n";
return {0, 0};
}
return Transform->GetPosition();
}
void Component::SetOwnerPosition(const Vec2& Pos) const {
TransformComponent* Transform{
GetOwner()->GetTransformComponent()};
if (!Transform) {
std::cerr << "Error: attempted to set position"
" of an entity with no transform component\n";
} else {
Transform->SetPosition(Pos);
}
}
Vec2 Component::GetOwnerScreenSpacePosition() const {
return ToScreenSpace(GetOwnerPosition());
}
float Component::GetOwnerScale() const {
TransformComponent* Transform{
GetOwner()->GetTransformComponent()};
if (!Transform) {
std::cerr << "Error: attempted to get scale"
" of an entity with no transform component\n";
return 1.0;
}
return Transform->GetScale();
}
In this lesson, we completed the ImageComponent
by adding scaling capabilities. It now supports setting a target Width
and Height
.
Using SDL_BlitScaled()
and a ScalingMode
enum, we provided options for handling image aspect ratios within those dimensions. We also added entity-level scaling through our TransformComponent
, and updated our ImageComponent
to respect that scaling
Key takeaways:
std::optional
is useful for representing properties that might not be explicitly set, allowing fallback to default behavior (like using natural image dimensions).SDL_BlitScaled()
allows rendering a source rectangle to a destination rectangle of a different size, performing the scaling.CalculateBlitInfo
) help keeping rendering code clean when implementing complex logic like scaling modes.TransformComponent::Scale
) should be considered when rendering, but it may have additional effects too, such as changing the physical bounding boxes once we implement collisions.Add width, height, and scaling modes to our entities and images
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games