Textures and Extension Libraries
Last Updated: Oct 6th, 2024
In this tutorial we'll be rendering with textures instead of surfaces because textures are hardware accelerated and therefore faster to render. We'll also be loading PNG files using SDL_image./* Headers */ //Using SDL, SDL_image, and STL string #include <SDL3/SDL.h> #include <SDL3/SDL_main.h> #include <SDL3_image/SDL_image.h> #include <string>
For this tutorial we'll be using the SDL_image extension library. Make sure to reuse your SDL3 project (otherwise you'll have to set it up again for a new SDL3 project) and download and install SDL_image much like you did with SDL3.
Also, from now on we will not be covering the entire source code, just highlighting the pieces relevant to the lesson.
Also, from now on we will not be covering the entire source code, just highlighting the pieces relevant to the lesson.
/* Class Prototypes */ class LTexture { public: //Initializes texture variables LTexture(); //Cleans up texture variables ~LTexture(); //Loads texture from disk bool loadFromFile( std::string path ); //Cleans up texture void destroy(); //Draws texture void render( float x, float y ); //Gets texture dimensions int getWidth(); int getHeight(); private: //Contains texture data SDL_Texture* mTexture; //Texture dimensions int mWidth; int mHeight; };
Here is our lazy texture class which will be wrapping the SDL_Texture class. We have our constructor and destructors,
In terms of data members
loadFromFile
which will load the image and destroy
which will free the texture when we're done with it.
The render
function will draw the image at the specified position. The getWidth
and getHeight
functions get the image dimensions.In terms of data members
mTexture
(which starts with m because it's a member variables much like how global variables start with a g) hold the texture data and mWidth
and mWidth
holds the dimensions.
/* Global Variables */ //The window we'll be rendering to SDL_Window* gWindow{ nullptr }; //The renderer used to draw to the window SDL_Renderer* gRenderer{ nullptr }; //The PNG image we will render LTexture gPngTexture;
In terms of global variables we have the window like before but instead of a screen surface we have an SDL_Renderer that we'll used to draw to the screen. We also have our wrapped texture object with
LTexture
.
/* Class Implementations */ //LTexture Implementation LTexture::LTexture(): //Initialize texture variables mTexture{ nullptr }, mWidth{ 0 }, mHeight{ 0 } { } LTexture::~LTexture() { //Clean up texture destroy(); }
Here is the constructor and destructor. The constructor initializes the variables (which you should always do because it can lead to some nasty release only bugs if you don't) and as you can see we set the values in the member initializer list as opposed to inside of the constructor itself. This is
considered a good habit as it allows for better compiler optimization.
The destructor just calls the function to clean up the texture after we're done with it.
The destructor just calls the function to clean up the texture after we're done with it.
bool LTexture::loadFromFile( std::string path ) { //Clean up texture if it already exists destroy(); //Load surface if( SDL_Surface* loadedSurface = IMG_Load( path.c_str() ); loadedSurface == nullptr ) { SDL_Log( "Unable to load image %s! SDL_image error: %s\n", path.c_str(), SDL_GetError() ); } else { //Create texture from surface if( mTexture = SDL_CreateTextureFromSurface( gRenderer, loadedSurface ); mTexture == nullptr ) { SDL_Log( "Unable to create texture from loaded pixels! SDL error: %s\n", SDL_GetError() ); } else { //Get image dimensions mWidth = loadedSurface->w; mHeight = loadedSurface->h; } //Clean up loaded surface SDL_DestroySurface( loadedSurface ); } //Return success if texture loaded return mTexture != nullptr; }
Our texture loading function first cleans up the texture just in case it already has data loaded. It then tries to load the image with the SDL_image function IMG_Load.
If the image loads successfully, we then try to create a texture from it using SDL_CreateTextureFromSurface which takes in the renderer and the surface we want to create a texture from. If the texture creation fails, we print an error, otherwise we set the texture dimensions.
Whether or not the texture creation fails, we want to destroy the surface after we're done with it. Lastly, we return whether or not the texture was loaded.
If you're wondering why we aren't IMG_LoadTexture it's because while that is a faster way to load the image, in future tutorials we will be making alterations to the loaded surface before creating a texture from it. If you want to eliminate an extra step and you don't need to alter the loaded image before creating a texture from it, you can use
IMG_Load
can load many types of image and in these tutorials we'll
configure it to load PNG images. If the image loading fails, we print an error to the console.If the image loads successfully, we then try to create a texture from it using SDL_CreateTextureFromSurface which takes in the renderer and the surface we want to create a texture from. If the texture creation fails, we print an error, otherwise we set the texture dimensions.
Whether or not the texture creation fails, we want to destroy the surface after we're done with it. Lastly, we return whether or not the texture was loaded.
If you're wondering why we aren't IMG_LoadTexture it's because while that is a faster way to load the image, in future tutorials we will be making alterations to the loaded surface before creating a texture from it. If you want to eliminate an extra step and you don't need to alter the loaded image before creating a texture from it, you can use
IMG_LoadTexture
.
void LTexture::destroy() { //Clean up texture SDL_DestroyTexture( mTexture ); mTexture = nullptr; mWidth = 0; mHeight = 0; }
In our clean up function we call SDL_DestroyTexture to release the texture after we're done with it and reinitialize the member variables.
void LTexture::render( float x, float y ) { //Set texture position SDL_FRect dstRect = { x, y, static_cast<float>( mWidth ), static_cast<float>( mHeight ) }; //Render texture SDL_RenderTexture( gRenderer, mTexture, nullptr, &dstRect ); }
Here is the code we use to draw our texture. First we define the destination rectangle (of type SDL_FRect) which defines where on the screen we're going to draw it. Because
Then we call SDL_RenderTexture to draw the texture. The first argument is the renderer we need to draw stuff. The second argument is the texture itself. The third argument we'll cover in a future tutorial so we'll set it to null for now. The last argument defines the destination rectangle of the texture, so it defines it's position and width/height.
SDL_FRect
is made of floating point types, we want to static cast
the image dimensions (which are integers) to float types to get rid of any warnings and because you want to make sure to be explicit with your type conversions. It can lead to some nasty behavior if you aren't careful with how you convert your data types (like it can get you hacked by incorrectly setting
your user ID to 0 nasty).Then we call SDL_RenderTexture to draw the texture. The first argument is the renderer we need to draw stuff. The second argument is the texture itself. The third argument we'll cover in a future tutorial so we'll set it to null for now. The last argument defines the destination rectangle of the texture, so it defines it's position and width/height.
int LTexture::getWidth() { return mWidth; } int LTexture::getHeight() { return mHeight; }
As you can see, these are simple accessor functions that give us the texture dimensions.
/* Function Implementations */ bool init() { //Initialization flag bool success{ true }; //Initialize SDL if( !SDL_Init( SDL_INIT_VIDEO ) ) { SDL_Log( "SDL could not initialize! SDL_Error: %s\n", SDL_GetError() ); success = false; } else { //Create window with renderer if( !SDL_CreateWindowAndRenderer( "SDL3 Tutorial: Textures and Extension Libraries", kScreenWidth, kScreenHeight, 0, &gWindow, &gRenderer ) ) { SDL_Log( "Window could not be created! SDL_Error: %s\n", SDL_GetError() ); success = false; } else { //Initialize PNG loading int imgFlags = IMG_INIT_PNG; if( !( IMG_Init( imgFlags ) & imgFlags ) ) { SDL_Log( "SDL_image could not initialize! SDL_image error: %s\n", SDL_GetError() ); success = false; } } } return success; }
Our initialization function should look familiar with some key changes. Now the window is created with SDL_CreateWindowAndRenderer which as you would expect creates the window and the renderer we need to draw to the window. We also
call IMG_Init to initialize SDL_image. We give it set of bitflags for the image formats we want to support and it gives us back the bits that it was able to initialize. If it does not give us back the bits we requested, we write an error to the
console.
Also because this was an issue in the SDL2 tutorials:
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
It's not. IMG_INIT_PNG is 2. If you init with IMG_INIT_PNG and get back IMG_INIT_PNG you get 2 & 2 which is 2. 2 will evaluate to true, the ! will negate it which means it will evaluate to false which will not cause the error line to execute.
If you were to get back 4 back from IMG_Init when you wanted 2, 4 & 2 is 0, which evaluates to false, which is negated by the ! to evaluate to true which will cause the error printing code to execute.
If you were to get back 6 back from IMG_Init (both the 4 and 2 bit) when you wanted 2, 6 & 2 is 2, which evaluates to true, which is negated by the ! to evaluate to false which will not cause the error line to execute.
The reason the code is like that is because we only care about the PNG loading bit. If we get that, that means we can continue. In other cases this code would be different, but we're not dealing with that here.
So make sure to brush up on your binary math and DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!. It was a big problem for the SDL2 tutorials and I don't want it to happen here.
Also because this was an issue in the SDL2 tutorials:
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!
It's not. IMG_INIT_PNG is 2. If you init with IMG_INIT_PNG and get back IMG_INIT_PNG you get 2 & 2 which is 2. 2 will evaluate to true, the ! will negate it which means it will evaluate to false which will not cause the error line to execute.
If you were to get back 4 back from IMG_Init when you wanted 2, 4 & 2 is 0, which evaluates to false, which is negated by the ! to evaluate to true which will cause the error printing code to execute.
If you were to get back 6 back from IMG_Init (both the 4 and 2 bit) when you wanted 2, 6 & 2 is 2, which evaluates to true, which is negated by the ! to evaluate to false which will not cause the error line to execute.
The reason the code is like that is because we only care about the PNG loading bit. If we get that, that means we can continue. In other cases this code would be different, but we're not dealing with that here.
So make sure to brush up on your binary math and DO NOT E-MAIL ME TELLING ME THAT THAT CALL TO IMG_Init IS A BUG!. It was a big problem for the SDL2 tutorials and I don't want it to happen here.
bool loadMedia() { //File loading flag bool success{ true }; //Load splash image if( success = gPngTexture.loadFromFile( "02-textures-and-extension-libraries/loaded.png" ); !success ) { SDL_Log( "Unable to load png image!\n"); } return success; } void close() { //Clean up texture gPngTexture.destroy(); //Destroy window SDL_DestroyRenderer( gRenderer ); gRenderer = nullptr; SDL_DestroyWindow( gWindow ); gWindow = nullptr; //Quit SDL subsystems IMG_Quit(); SDL_Quit(); }
As you would expect, our media loading function loads our image. The clean up function cleans up the loaded texture, cleans up the renderer with SDL_DestroyRenderer and cleans up the window like before. We also make sure to quit SDL_image with
IMG_Quit.
int main( int argc, char* args[] ) { //Final exit code int exitCode{ 0 }; //Initialize if( !init() ) { SDL_Log( "Unable to initialize program!\n" ); exitCode = 1; } else { //Load media if( !loadMedia() ) { SDL_Log( "Unable to load media!\n" ); exitCode = 2; } else { //The quit flag bool quit{ false }; //The event data SDL_Event e; memset( &e, 0, sizeof( e ) ); //The main loop while( quit == false ) { //Get event data while( SDL_PollEvent( &e ) ) { //If event is quit type if( e.type == SDL_EVENT_QUIT ) { //End the main loop quit = true; } } //Fill the background white SDL_SetRenderDrawColor( gRenderer, 0xFF, 0xFF, 0xFF, 0xFF ); SDL_RenderClear( gRenderer ); //Render image on screen gPngTexture.render( 0.f, 0.f ); //Update screen SDL_RenderPresent( gRenderer ); } } } //Clean up close(); return exitCode; }
Our
If you're wondering why we're using 0.f as opposed to just 0 or 0.0 for the position of the image, it's because when we write it as 0.f the compiler knows that this is explicitly a floating point number. 0 can be treated like an interger and 0.0 will be treated as a double. By explicitly defining it as a float, you avoid any of the potential problems that come with converting a integer to a float or a double to a float.
main
function works much like it did before. This time we call SDL_SetRenderDrawColor to set the color to clear the screen with and SDL_RenderClear to clear the
screen. Then we draw the PNG image and update the screen with SDL_RenderPresent.If you're wondering why we're using 0.f as opposed to just 0 or 0.0 for the position of the image, it's because when we write it as 0.f the compiler knows that this is explicitly a floating point number. 0 can be treated like an interger and 0.0 will be treated as a double. By explicitly defining it as a float, you avoid any of the potential problems that come with converting a integer to a float or a double to a float.
Addendum: Deleting auto generated class functions
When you create a class, C++ will auto generate:
However, since we are not copying LTextures in this demo and the deletion does clutter up the code, I decided to skip doing it for the sake of making the code easier to read. However, in a real application you should watch out for behavior that auto generated functions cause.
- The default constructor
- The copy constructor
- The copy assignment operator
- The move constructor (in modern C++)
- The move assignment operator (in modern C++)
class LTexture
{
public:
//Initializes texture variables
LTexture();
//Cleans up texture variables
~LTexture();
//Remove copy constructor
LTexture( const LTexture& ) = delete;
//Remove copy assignment
LTexture& operator=( const LTexture& ) = delete;
//Remove move constructor
LTexture( LTexture&& ) = delete;
//Remove move assignment
LTexture& operator=( LTexture&& ) = delete;
//Loads texture from disk
bool loadFromFile( std::string path );
//Cleans up texture
void destroy();
//Draws texture
void render( float x, float y );
//Gets texture dimensions
int getWidth();
int getHeight();
private:
//Contains texture data
SDL_Texture* mTexture;
//Texture dimensions
int mWidth;
int mHeight;
};
A big thing about writing good C++ code is not only making code that works well, but making code that is hard to use badly. For example in this tutorial the default copy constructor does a shallow copy if we were to copy the LTexture which is not going to lead to desirable behavior.However, since we are not copying LTextures in this demo and the deletion does clutter up the code, I decided to skip doing it for the sake of making the code easier to read. However, in a real application you should watch out for behavior that auto generated functions cause.
Addendum: Inline functions
Inline functions are a way to optimize your code when used properly. When used improperly, they can also make your code slower.
Certain functions like
Certain functions like
getWidth
and getHeight
are prime candidates for optimization through inlining. However, the line between what should be inlined and what shouldn't be inlined is blurry and rather than having to test each function to see if they would benefit from inlining,
I just won't bother. You shouldn't worry about inlining until you finish your computer architecture course.