Lazy Foo' Productions

SDL Forums external link SDL Tutorials 🐰SDL3 Tutorials🥚 Articles OpenGL Tutorials OpenGL Forums external link
Follow BlueSky Follow Facebook Follow Twitter Follow Threads
Donate
News FAQs Contact Bugs

Textures and Extension Libraries

Textures and Extension Libraries screenshot

Last Updated: Oct 19th, 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.
/* 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, 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.
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. 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 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.
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;
            SDL_zero( 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 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:
  • The default constructor
  • The copy constructor
  • The copy assignment operator
  • The move constructor (in modern C++)
  • The move assignment operator (in modern C++)
It is considered good (modern) C++ coding to delete default class functions you're not going to use:
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 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.