Lesson 3: Window Mastery
At this point I'm sure you're itching to start coding some actual graphics, but I want to spend a tiny bit more time looking at setting our window up before we start, while it's still fresh in our minds!
In this lesson, we're going to look at how to load an icon and display it on our window. We'll also look at changing the window's title, and allowing the user to toggle fullscreen mode too!
Icons
We'll start by setting the icon of our program.
Windows displays an icon for each program in the titlebar of the window, as well as in the taskbar, task manager, and various other places too. We can set this icon from within our code by first loading an image from the hard drive, and then passing it to SDL.
SDL is capable of reading image files from disk, but SDL itself is only capable of loading bitmap (.bmp) images, which is quite limiting and they're quite a memory intensive format. However, there is a separate but closely related SDL_image library made by the same developers, which contains functions for loading a large number of common image formats. To get access to SDL_image, we need to install the library, add it to our makefile, and then include it's header file just as we did for SDL itself.
If this feels a bit like overkill just to set an icon, don't worry! This library will be extremely useful to us later on, so why not set it up now?
To install it we'll again need to get a copy of the code from the repository's GitHub page.
Just like before, click the "Code" button to get a zip of the latest version of the code, and extract it somewhere convenient.
When we installed SDL itself before, we had to also download a compiled version of the library as the official version 3 of the library hasn't been released yet. Fortunately, that compiled version also includes everything we need for SDL_image as well, so we can skip having to update our linker for this library.
Therefore to get it working, after downloading the source code, we just need to include it's header files in our Makefile:
3. | OBJS = main.cpp |
4. | |
+ 5. |
|
6. | |
7. | LINKER_DIRS = -LC:\SDL3_precompiled\lib -LC:\glew\lib\Release\x64 |
Again we don't need to touch the LINKER_DIRS
variable as that path already has the SDL_image library inside of it.
We do need to specify the name of the library though:
7. | LINKER_DIRS = -LC:\SDL3_precompiled\lib -LC:\glew\lib\Release\x64 |
8. | |
+ 9. |
|
10. | |
11. | FLAGS = -Wall -Wl,-subsystem,console |
Finally, to get SDL_image working, you need to make sure that it's DLL file is either placed alongside your executable, or installed system-wide.
With that in place, we should now be able to use the library in our code.
To use it, we need to start by including it's header files in our main.cpp
:
1. | #include <SDL3/SDL.h> |
2. | #include <SDL3/SDL_main.h> |
+ 3. |
|
4. | #include <GL/glew.h> |
5. | |
6. | #include <string> |
7. | #include <stdio.h> |
Now with SDL_image ready, we can start loading images into our program.
After we create our window in our init
function, let's load in a PNG image to use as our logo.
To do that, the first thing we'll need to do is create a logo.
That's your job!
I've created one and placed it inside a new folder called "resources" in my workspace, just to keep these files separate from my source code.
The relative file path to my image from my executable is therefore resources/icon.png
.
Usually, using this path is completely sufficient to load the image.
However it's possible that your users run the program from a different directory.
For example compare starting the compiled program using ./my_program
versus being in the folder above and running ./some_path/my_program
.
In the second case, the program will complain that the image could not be found.
What happens is that the program's "working directory" is not where the executable file is, but where the user ran the program from. Relative paths are actually relative to this working directory, and therefore fail to work. Well that's frustrating.
There are two approaches to solving this to make all relative paths relative to the executable's location. The first is to alter the current working directory. This is possible through a system call, but can have weird side effects and the call is not cross-platform.
The second approach, and the one recommended by SDL itself, is to use exclusively absolute file paths. Of course, the reason we don't use absolute file paths in the first place is that we don't necessarily know the full filesystem structure of the user in the first place. But fortunately SDL provides a helper function to get the path of the executable.
We can then append our icon's relative path to the executable's absolute path to get a safe and fully portable way to locate the icon on disk. When you distribute your application, you can then copy your executable and resources folder to give people a program which works wherever and however they run it on their filesystem.
Let's code it!
44. | printf("Unable to create a window: %s\n", SDL_GetError()); |
45. | return false; |
46. | } |
47. | |
+ 48. |
|
+ 49. |
|
+ 50. |
|
51. | ... |
The executable's absolute path can be obtained by calling the SDL_GetBasePath()
function.
This gives us the path as a const char*
, which we convert to an std::string
called iconPath
.
We perform this conversion as it is much easier to work with especially for things like appending the rest of the icon's path.
We then do the appending, passing in the rest icon's relative path from the executable as a constant.
The call to SDL_GetBasePath()
is guaranteed to end with a file path separator (the "/" denoting levels of folders), so we can just directly append it.
We pass this now absolute path to the function IMG_Load
from SDL_image, which will load the requested image and return us a pointer to an SDL_Surface, the SDL variable type for holding images.
Regardless of what format the image was on disk, the returned SDL_Surface
will always look the same.
For example, you can use SDL_Surface->w
and SDL_Surface->h
to get the image's width and height, and SDL_Surface->pixels
to access the decompressed pixel values.
Should our code be unable to find the image and load it for any reason, the pointer will be NULL. So as is tradition at this point, we perform a test to see if it failed to load, and if so show an error message and exit:
48. | std::string iconPath(SDL_GetBasePath()); |
49. | iconPath.append("resources/icon.png"); |
50. | SDL_Surface* icon = IMG_Load(iconPath.c_str()); |
+ 51. |
|
+ 52. |
|
+ 53. |
|
+ 54. |
|
+ 55. |
|
56. | ... |
If the image loads correctly, we can then set it as our icon:
51. | if(!icon) |
52. | { |
53. | printf("Unable to load icon: %s\n", SDL_GetError()); |
54. | return false; |
55. | } |
+ 56. |
|
+ 57. |
|
+ 58. |
|
+ 59. |
|
+ 60. |
|
+ 61. |
|
+ 62. |
|
63. | |
64. | context = SDL_GL_CreateContext(window); |
65. | if(!context) |
66. | { |
67. | printf("Unable to create an OpenGL context: %s\n", SDL_GetError()); |
The call to set the window's icon is fairly straight-forward, you just pass in the window and the icon image, and check the return value to see if it set correctly or not. It will return true on success, else false if anything bad happened.
Regardless of if it worked or not, we immediately call SDL_DestroySurface
to free the image's memory.
If we failed to set the icon then we no longer need the image, and if the call was successful then our icon gets internally copied so we don't need to retain it.
Operating systems can be a bit picky over what kind of image you use as your icon. As I mentioned, SDL_image will bring any image you pass in into a common decompressed format, so whether you're loading a PNG or a JPEG, the operating system won't know, so that won't matter. But what does matter is size; passing large icon images can cause this function to fail.
For best compatibility, your icons should be small perfectly square images. If my memory is correct, I've seen sizes as low as 64x64 pixels fail on some systems. Obviously, too small and your icon will look awful as well. I think a 48x48 pixel image is about the largest that is reliably displayed. You can always load several images and if the high-res fails fall back to a lower resolution!
Changing the title
OK, this probably doesn't warrant an entire section, but I just want to show you another window modification at your disposal before we move on.
You can use the command SDL_SetWindowTitle
to change the title of your window.
I'm not actually going to use it in our code, because we already set the title when we created our window, and I'm happy with what we set. However, I know that some people like to use the titlebar of their windows to display information. In the past, I've definitely seen people display how many lives or how many hitpoints the character has left in the titlebar, and even the number of frames per second rendered.
This isn't something I'm a fan of, I think the titlebar should be for the title only, and any additional information should be either displayed in the terminal if it's debug information, or in a user interface if the user should be aware of it. But that's just me. The full function call is fairly straight-forward:
|
Use it as you please!
Fullscreen mode
Next, let's have a look at making our window fullscreen. Especially for applications like games, fullscreening allows you to use the maximum possible amount of screen space and fully immerse your users in what you are rendering, without any distracting clutter around the sides.
We're going to alter our code so that when the user presses the 'f' key, our program will toggle between fullscreen and windowed mode.
To keep track of the current state of the window, we'll create a boolean variable at the start of our code:
12. | SDL_Window* window = NULL; |
13. | SDL_GLContext context = NULL; |
14. | |
15. | bool programRunning = true; |
+ 16. |
|
17. | |
18. | bool init() |
19. | { |
We can now adjust our event handler code to listen if the 'f' key was pressed:
116. | else if(event.type == SDL_EVENT_KEY_DOWN) |
117. | { |
118. | printf("Key pressed down: %s\n", SDL_GetKeyName(event.key.key)); |
119. | if(event.key.key == SDLK_ESCAPE) |
120. | { |
121. | programRunning = false; |
122. | return; |
123. | } |
+ 124. |
|
+ 125. |
|
+ 126. |
|
+ 127. |
|
+ 128. |
|
129. | } |
We flip the value of isFullscreen
and pass the result in to SDL_SetWindowFullscreen
, along with the window.
This function simply takes a boolean and will make the window fullscreen if true, and revert it back to a window if false.
This means the code here will cause the window to toggle between being fullscreen and a window each time the key is pressed.
Viewport
To wrap up this lesson, let's talk about viewports. A viewport is the area of the window which OpenGL is rendering to. By default, when you open a window, the viewport is set to be the entire window, so we haven't had to deal with this up to now.
When our window is fullscreened, maximised, or otherwise changes size on our screen, OpenGL does not automatically adjust the viewport as perhaps you've set it to a particular size for a reason. It is fairly common in some programs not to set the viewport to the whole window, as they might have a certain portion of the window occupied by a user interface from another library for example. Therefore it's up to the developer to control which portion of the window OpenGL will draw to.
If the window is shrunk and the viewport isn't altered, the image will essentially be cropped to fit the window. Likewise if the window is made larger, all the pixels in the new area of the window will be in an undefined state. So it's important we take the viewport into consideration whenever the window changes size.
To know when we need to update our viewport, we'll listen for the event SDL_EVENT_WINDOW_RESIZED
in our event handling function.
When this is detected, we'll then get the new window size and update the viewport accordingly:
107. | programRunning = false; |
108. | return; |
109. | } |
+ 110. |
|
+ 111. |
|
+ 112. |
|
+ 113. |
|
+ 114. |
|
+ 115. |
|
116. | else if(event.type == SDL_EVENT_KEY_DOWN) |
117. | { |
When a window related event happens, additional relevant information can be found in event.window
, and the data1
and data2
fields may get populated depending on what kind of event happened.
For example, if the event was that the window moved, these fields contain the new x and y position of the window.
On a window resize event, they contain the new size of the window.
A full list of window events can be found here.
We update our windowWidth
and windowHeight
variables with the new window size so we have some record of how big the window now is.
We then make a call to glViewport
to define the new size of the viewport.
In this case, the parameters indicate that we will render from the origin of the window (0, 0) and cover the full width and height of the newly resized window, the third and fourth parameters.
Note that the origin for the coordinate system in OpenGL is the bottom left of the window, as a consequence of it's mathematical roots (the x and y axes run like those of graphs), unlike the more common top-left origin used for most desktop windows.
When we enter fullscreen mode, the SDL_EVENT_WINDOW_RESIZED
event will be triggered, and we'll now be rendering to the full screen in OpenGL.
When we change back to windowed mode, we'll switch back to only rendering to the size of the window.
This will affect regular window resizing too, if the user clicks and drags an edge of our window to make the window larger or smaller.
Compile and run your code, and you should now see a beautiful window with icons in the title and taskbar. If you press 'f' on your keyboard, you will toggle between fullscreen and windowed mode, and regardless of which mode you're in, you'll be rendering to the full area.
With our window fully fleshed out, we can finally switch to doing some actual graphics in the next lesson. See you there!