Lesson 4: My First Triangle Part B
In the first part of this lesson, we looked at how shaders work and implemented the simplest working example. Well, in this second part of the lesson, we're going to go back and refactor our code and do it properly.
We'll start by moving our shader source code from a hard-coded string in our main.cpp
into two new text files in our workspace.
Our program will open these files at run-time, read in the contents, and build the shaders from that.
We'll encapsulate our shader into a class, which we'll implement in a new source code file called shader.cpp
.
In our main.cpp
, we'll then create an instance of this class, and pass it the filenames of our vertex and shader source code.
This class will then take care of everything else to load in and compile the shaders, including proper error checking this time.
Because our code will be nicely encapsulated in a class, we can simply call the functions again if we want to reload the shader from the files on disk, so we can hot-reload our shaders while the program is running! This encapsulation will also make it easier in the future if we want to have other shaders for different tasks. While our main shader might work for drawing most objects, we might need very different inputs to our shader if we want to draw water for example. So rather than having one hugely complicated shader which can handle everything, we can neatly split the code between these two shaders, and then switch between them depending on what we're drawing.
Let's get to work!
GLSL files
Hard-coding the shaders into our program is a not a nice solution. It means any small modifications require a recompile and restarting the program. It also complicates our C++ source code, and requires the nasty formatting of multi-line strings.
A much easier solution is to move the GLSL source code into their own text files.
I usually create a folder within my workspace called shaders
and put all the GLSL code in there.
Inside, I call the files something like main_vertex.glsl
and main_fragment.glsl
.
It is also fairly common to name them something along the lines of main.vert
and main.frag
.
You can name these files whatever you like, there is no standard file extension to use. OpenGL dictates that it's passed a string, but doesn't say anything about how or where you get that string from. So the choice of filename is up to you.
So to start this lesson, I'm going to move our vertex shader source code from the previous lesson into a new file called shaders/main_vertex.glsl
.
Notice that we no longer need to add the explicit line endings ("\n"), but can work with the GLSL in a more natural way:
1. |
|
2. |
|
3. |
|
4. |
|
5. |
|
6. |
|
7. |
|
8. |
|
...and likewise for shaders/main_fragment.glsl
:
1. |
|
2. |
|
3. |
|
4. |
|
5. |
|
6. |
|
7. |
|
8. |
|
Designing our shader class
Next, let's begin to think about what our shader class will look like.
As we've been using the global scope to store our variables in up until now, we need to be careful about using the constructor of our class. We cannot use any OpenGL code in the constructor as it will be run before our program has initialised, and therefore before the OpenGL context has been created. Instead, we'll just use our constructor to perform some basic initialisation of our class variables, and have the shader program actually be created by an explicit function call.
Likewise, we won't rely on a deconstructor to clear up at the end, but instead use explicit functions to delete the shader program when we no longer need it, so we can tidy up before the OpenGL context is closed.
We'll also need functions to bind and unbind the shader, and to get any relevant information about the shader, such as the filenames and any error messages.
One other thing we'll do is have a function to set the input filenames of the shader, and another to actually load and create the shader program from those files. By separating these, we'll only need to set the input filenames once in our code, and can then call the function to (re-) load the shader from different places in our code without having to write out all the filenames again. This is useful for implementing the hot-reloading!
So let's start turning these ideas into a class, and lay them out in a new header file shader.h
in our workspace.
We'll start with the includes:
+ 1. |
|
+ 2. |
|
+ 3. |
|
+ 4. |
|
+ 5. |
|
+ 6. |
|
We use a Pragma Once to make the header file safe to include multiple times elsewhere in our project without re-declaration errors.
We also bring in the string library, which will be helpful for storing the contents of the files, and GLEW.
GLEW contains the type definitions for OpenGL, which we will need to store the handle to our shader (which was a GLuint
) so we need to include it too.
Our shader class doesn't contain any SDL specific code, so these headers can be omitted entirely here.
I will say I've taken a bit of a shortcut here.
Rather than type out std::string
every time we use them, I've just used the std
namespace here to keep the code concise.
You can use which ever you prefer, although the full form is usually considered better practice.
We can now define our Shader
class:
6. | using namespace std; |
7. | |
+ 8. |
|
+ 9. |
|
+ 10. |
|
+ 11. |
|
+ 12. |
|
+ 13. |
|
+ 14. |
|
+ 15. |
|
+ 16. |
|
+ 17. |
|
+ 18. |
|
+ 19. |
|
+ 20. |
|
+ 21. |
|
+ 22. |
|
+ 23. |
|
+ 24. |
|
+ 25. |
|
+ 26. |
|
+ 27. |
|
+ 28. |
|
+ 29. |
|
+ 30. |
|
Our class naturally starts with the constructor declaration, which we'll use to make sure that any private variables are initialised to safe starting values.
We then have the function to set the filenames from which the vertex and fragment shader will later be read from. Another to actually load those source files and create the shader program, returning a boolean value indicating success. This is then followed by a third function to control the deletion of the shader program after we're finished with it.
Once the shader has been loaded, we then have a pair of functions to bind and unbind the current shader program.
Then we have a few functions to allow us to access the object's private variables.
The function getFilenames()
gives us the source code filenames concatenated into a string, which is useful to know if an object fails to load them properly, along with any specific error messages, available from the getError()
function.
We also have getHandle()
to give us access to the raw shader program's handle, should we ever want it (the private variable shaderProgram
).
As the loadShader
function will need to read the contents of several files, I've broken this bit of code out into a separate function.
So the final private element we have is the function readFile()
, which simply takes a filename on disk and returns it's content, or an empty string on error.
Implementing our class
Let's create a new file, shader.cpp
, and start filling in those functions we just defined:
+ 1. |
|
+ 2. |
|
+ 3. |
|
+ 4. |
|
+ 5. |
|
+ 6. |
|
+ 7. |
|
+ 8. |
|
+ 9. |
|
+ 10. |
|
+ 11. |
|
+ 12. |
|
+ 13. |
|
+ 14. |
|
We start by including our header file, and also fstream
and sstream
, to help us with manipulating files and strings.
Our constructor just initialises our shaderProgram
variable to a safe initial value.
Remember, OpenGL uses integers to reference shader programs, where zero is understood as not pointing to any program.
Our other string variables in our class are implicitly initialised to empty strings when they were declared in the header, so there is no need to set them to an empty value here.
We also define our setFilenames
function to simply set the internal filename variables to what has been passed in.
Just before we define our loadShader
function, let's first fill in the readFile
function which we'll need first.
It will accept a filename as a parameter, and return the file's contents as a string:
13. | fFilename = fragmentFilename; |
14. | } |
15. | |
+ 16. |
|
+ 17. |
|
+ 18. |
|
+ 19. |
|
+ 20. |
|
+ 21. |
|
+ 22. |
|
+ 23. |
|
+ 24. |
|
+ 25. |
|
+ 26. |
|
+ 27. |
|
+ 28. |
|
+ 29. |
|
+ 30. |
|
+ 31. |
|
+ 32. |
|
+ 33. |
|
+ 34. |
|
+ 35. |
|
+ 36. |
|
+ 37. |
|
+ 38. |
|
For reference, there are lots of ways to read a file and convert it's contents into a string in C++. I've chosen this particular one purely because it's concise.
We attempt to open the file, and if we fail, we write an error message and return an empty string. We then use a stringstream to concisely read in the file's contents, and then convert that to a string.
We perform one more check to see if the file actually contained any text or not, as that's something easy to here, and will be problematic later on if we try to compile it. Again we write an error message if so.
Next up is the big one.
Our loadShader
function, which will actually create the shader program:
37. | return contents; |
38. | } |
39. | |
+ 40. |
|
+ 41. |
|
+ 42. |
|
+ 43. |
|
+ 44. |
|
+ 45. |
|
+ 46. |
|
+ 47. |
|
+ 48. |
|
+ 49. |
|
+ 50. |
|
+ 51. |
|
+ 52. |
|
+ 53. |
|
+ 54. |
|
+ 55. |
|
+ 56. |
|
+ 57. |
|
+ 58. |
|
The first thing we do when loading our shader is to make sure that any previous shader programs are properly deleted, and that our variables have been reset to safe starting values. We also check that both the shader's source filenames have been set before proceeding. If not, we write an error message and return false to indicate a problem during initialisation.
Then we make a call to glCreateProgram
.
Unlike in the last lesson, we check the return value of this function.
If it is zero, indicating a non-valid program, we again write an error message and return.
54. | errorMessage = "Unable to create shader program"; |
55. | return false; |
56. | } |
57. | |
+ 58. |
|
+ 59. |
|
+ 60. |
|
+ 61. |
|
+ 62. |
|
+ 63. |
|
+ 64. |
|
+ 65. |
|
+ 66. |
|
+ 67. |
|
+ 68. |
|
+ 69. |
|
+ 70. |
|
+ 71. |
|
+ 72. |
|
+ 73. |
|
+ 74. |
|
+ 75. |
|
+ 76. |
|
+ 77. |
|
+ 78. |
|
Similarly, when we create the vertex shader, we check the return value with zero again indicating a problem.
If a problem occurs, as well as setting the error message we also make a call to deleteShader()
, which ensures that the shader program we created a moment ago is properly deleted, and our variables reset before returning.
With the vertex shader created, we can then read in it's source file by making a call to our previous readFile
function.
We pass in the vertex shader source file's name, expecting a string of the file's contents back.
If anything went wrong, including the file being empty, then the returned string will be empty.
We can therefore test the string length to check for an error.
If something went wrong, the error message will be set by the readFile
function, so we don't need to set any error message here, just delete our new vertex shader and shader program as before.
If everything went OK, we then convert our string to the required type (const char*), pass it in to OpenGL as the shader's source, and try to compile it just like we did in the previous lesson. Finally, we compile the shader.
Just like compiling C++ code though, compiling shaders doesn't always go to plan. So next we need to check the output of the compiler:
75. | glShaderSource(vShader, 1, &vText, NULL); |
76. | glCompileShader(vShader); |
77. | |
+ 78. |
|
+ 79. |
|
+ 80. |
|
+ 81. |
|
+ 82. |
|
+ 83. |
|
+ 84. |
|
+ 85. |
|
+ 86. |
|
+ 87. |
|
+ 88. |
|
+ 89. |
|
+ 90. |
|
+ 91. |
|
+ 92. |
|
+ 93. |
|
+ 94. |
|
+ 95. |
|
+ 96. |
|
+ 97. |
|
+ 98. |
|
+ 99. |
|
Woah, that looks pretty complex.
Let's take it line by line.
We make a call to glGetShaderiv
to see the compile status (GL_COMPILE_STATUS
) of our shader.
glGetShaderiv
is used to request any information about the shader, with the suffix "iv" following the OpenGL convention of specifying the function's return types at the end of the function names - a vector (array) of ints in this case.
We pass in as parameters our shader, what information we want, and a place to store the result: the address of an integer which OpenGL will alter to indicate whether compiling was successful or not.
If compiling was not successful, then we can grab the compiler log and add it to our errorMessage
.
The idea here is quite old-school, but to get the log we first need to ask OpenGL how long the log is, then we can dynamically allocate that amount of memory as a string, and then copy the log into our string.
So we make another call to glGetShaderiv
, similar to before but this time asking for the length of the compiler log.
We pass in the shader, tell it we want the log length using GL_INFO_LOG_LENGTH
, and pass in an integer for it to put the result into.
Once we have the log length, then we allocate memory for a new string called compilerLog
of that length.
We can then copy the log data into that string by calling glGetShaderInfoLog
.
The first parameter of this call is the vertex shader object we tried to compile, and the second is how many characters to copy into our buffer in case we need to break the message up into parts.
This might be useful on low-memory devices, but here we just tell it to copy the whole log.
For the third parameter, OpenGL allows you pass in a variable to get how many characters were copied across.
However as we are just copying everything across into a buffer that we know it will fit into, we don't need to worry about this, in which case the specification recommends simply passing NULL.
Finally, we pass in the string which the log will be copied into.
Now that we have the compiler log of our failed vertex shader, we append it to our errorMessage
variable.
Before returning, we free the dynamically allocated memory, delete the vertex shader, and delete our shader program.
If the shader did compile correctly, fortunately our code is much simpler!
We call glAttachShader
to add our newly compiled vertex shader to our shader program.
Then we basically just do all of that again for our fragment shader:
97. | glAttachShader(shaderProgram, vShader); |
98. | |
+ 99. |
|
+ 100. |
|
+ 101. |
|
+ 102. |
|
+ 103. |
|
+ 104. |
|
+ 105. |
|
+ 106. |
|
+ 107. |
|
+ 108. |
|
+ 109. |
|
+ 110. |
|
+ 111. |
|
+ 112. |
|
+ 113. |
|
+ 114. |
|
+ 115. |
|
+ 116. |
|
+ 117. |
|
+ 118. |
|
+ 119. |
|
+ 120. |
|
+ 121. |
|
+ 122. |
|
+ 123. |
|
This code is pretty much exactly the same again, this just with some "f"s, instead of "v"s!
Note that our shader type has change to type GL_FRAGMENT_SHADER
, and now in the event of an error we also detach and delete the vertex shader.
So let's check the compiler log for our fragment shader:
121. | glCompileShader(fShader); |
122. | |
+ 123. |
|
+ 124. |
|
+ 125. |
|
+ 126. |
|
+ 127. |
|
+ 128. |
|
+ 129. |
|
+ 130. |
|
+ 131. |
|
+ 132. |
|
+ 133. |
|
+ 134. |
|
+ 135. |
|
+ 136. |
|
+ 137. |
|
+ 138. |
|
+ 139. |
|
+ 140. |
|
+ 141. |
|
+ 142. |
|
+ 143. |
|
+ 144. |
|
+ 145. |
|
+ 146. |
|
+ 147. |
|
Again, exactly as with the vertex shader.
If it failed to compile then we get the compiler log and add it to the error message. We also need to detach and delete the vertex shader before returning this time however. If it did work though, we also attach the fragment shader to our shader program.
Next, on to the linking:
145. | glAttachShader(shaderProgram, fShader); |
146. | |
+ 147. |
|
+ 148. |
|
+ 149. |
|
+ 150. |
|
+ 151. |
|
+ 152. |
|
+ 153. |
|
+ 154. |
|
+ 155. |
|
+ 156. |
|
+ 157. |
|
+ 158. |
|
+ 159. |
|
+ 160. |
|
+ 161. |
|
+ 162. |
|
+ 163. |
|
+ 164. |
|
+ 165. |
|
+ 166. |
|
+ 167. |
|
+ 168. |
|
+ 169. |
|
+ 170. |
|
+ 171. |
|
+ 172. |
|
+ 173. |
|
Despite linking only being a single line, again we go through the whole process of getting the logs and clearing up if something failed.
This time we ask OpenGL for the linking status instead of the compile status, with the GL_LINK_STATUS
flag.
We need to update our function calls as well to glGetProgramiv
and glGetProgramInfoLog
.
Note the "Program" instead of "Shader" in the function names.
This time we also need detach and delete both shaders if something went wrong.
But that's basically it!
TIP:
Don't use glValidateProgram
here!
I've seen a lot of tutorials online and questions on Stack Overflow saying again and again that after linking, you should call glValidateProgram
to "validate the shader program works properly".
This is not what this function does.
It's designed to be used for debugging and optimisation, and will tell you if you can actually run the shader given the current state of the program or not.
For example, perhaps you forgot to bind some important data or a VAO before using the shader.
This function will warn you, or tell you that your code is inefficient in some way.
But it's not for testing the building of your shader program!
The OpenGL specification is here.
With the shader program linked and ready to go, we just need to tidy up before finishing:
168. | deleteShader(); |
169. | |
170. | return false; |
171. | } |
172. | |
+ 173. |
|
+ 174. |
|
+ 175. |
|
+ 176. |
|
+ 177. |
|
+ 178. |
|
+ 179. |
|
+ 180. |
|
+ 181. |
|
With the program linked and the executable generated, we can safely detach and delete the shaders to free up their memory, we only care about the finished executable now!
We then unbind the shader, and return a value of true to indicate success.
Anyway that's the bulk of the work out the way for our shader class. The rest of the functions are pretty trivial by comparison.
Next up is the code for deleting the shader:
178. | unbind(); |
179. | |
180. | return true; |
181. | } |
182. | |
+ 183. |
|
+ 184. |
|
+ 185. |
|
+ 186. |
|
+ 187. |
|
+ 188. |
|
To delete the shader, we start by making sure the shader is not currently bound. This function will be safe to call whether the program is currently bound or not, or even if it doesn't exist, so we don't need to provide any protections here.
We then call glDeleteProgram
which deletes the shader program on the GPU, and frees any memory associated with it.
Again this is safe to call whether the program exists or not.
If it doesn't, OpenGL understands deleting a value of zero as an instruction to do nothing!
Finally, we reset the rest of our shader program handle back to a default state.
Our functions for binding and unbinding the shader program are similarly straight-forward:
187. | shaderProgram = 0; |
188. | } |
189. | |
+ 190. |
|
+ 191. |
|
+ 192. |
|
+ 193. |
|
+ 194. |
|
+ 195. |
|
+ 196. |
|
+ 197. |
|
+ 198. |
|
Like in the last tutorial, we make a call to glUseProgram
to set the currently bound shader program.
Passing in our program will make it current, and as before passing a value of zero has the effect of unbinding any programs.
We've made sure that if our shader program cannot be loaded, our shaderProgram
variable will have a value of zero.
That means that if our code attempts to bind a problematic shader, we'll effectively be binding nothing, so these functions are safe to call even if something has gone wrong.
We finish up by defining our "getter" functions:
197. | glUseProgram(0); |
198. | } |
199. | |
+ 200. |
|
+ 201. |
|
+ 202. |
|
+ 203. |
|
+ 204. |
|
+ 205. |
|
+ 206. |
|
+ 207. |
|
+ 208. |
|
+ 209. |
|
+ 210. |
|
+ 211. |
|
+ 212. |
|
+ 213. |
|
We provide a function for getting the shader source code filenames which our shader object is currently using by simply concatenating the variables together, and a function which returns the object's errorMessage
variable.
In case someone needs to get the raw handle to the actual OpenGL shader program, we provide it through the getHandle
function.
Again, if something has gone wrong, this function should always return a value of zero, indicating an invalid shader.
With that, all our functions for our shader class are now defined!
Updating Our Makefile
As we've added a new source code file to our project, we need to remember to compile it into our project too!
All we need to do is to add the extra source code filename to the OBJS
variable in our makefile:
1. | CC = g++ |
2. | |
+ 3. |
|
4. | |
5. | LIBRARIES = -lSDL3 -lSDL3_image -lGL -lGLEW |
Remember, you only need to add the source code file (.cpp), not the headers!
Using Our Shader Class
With our new class ready to load and compile shader programs, let's adjust our main code to actually make use of this class to draw the triangle from the previous lesson.
Let's begin by including our new class's header file into main.cpp
:
+ 1. |
|
2. | |
3. | #include <SDL3/SDL.h> |
4. | #include <SDL3/SDL_main.h> |
5. | #include <SDL3_image/SDL_image.h> |
6. | #include <GL/glew.h> |
7. | |
8. | #include <string> |
9. | #include <stdio.h> |
...and declaring an instance of the class:
17. | bool programRunning = true; |
18. | bool isFullscreen = false; |
19. | |
+ 20. |
|
21. | |
22. | GLuint vao; |
23. | GLuint vbo; |
Now with our mainShader
object ready to go, we can remove all of the GLSL and old shader compiling code from the previous lesson and replace it with a few simple lines.
Note that we still need the code for setting our vertices and initialising our buffers:
90. | printf("Unable to get a recent OpenGL version!\n"); |
91. | return false; |
92. | } |
93. | printf("%s\n", glGetString(GL_VERSION)); |
94. | |
+ 95. |
|
+ 96. |
|
+ 97. |
|
+ 98. |
|
+ 99. |
|
+ 100. |
|
+ 101. |
|
102. | |
103. | GLfloat vertices[] = |
104. | { |
105. | -0.5f, -0.5f, 0.0f, |
We make a call to our setFilenames
function and tell the object where it's GLSL files are located.
Then we call our loadShader
function which will actually read those files and create the shader program.
Finally we provide some simple error checking, printing the error messages and input files, and returning if the shader couldn't be initialised properly.
Note that printing the filenames can be incredibly useful for debugging problems, especially if you have multiple shaders.
We also need to update our close
function to call deleteShader
for our object when we're finished with it, rather than calling glDeleteProgram
as we did before:
129. | void close() |
130. | { |
131. | glDeleteVertexArrays(1, &vao); |
132. | glDeleteBuffers(1, &vbo); |
+ 133. |
|
134. | |
135. | SDL_GL_DestroyContext(context); |
136. | SDL_DestroyWindow(window); |
137. | SDL_Quit(); |
138. | } |
We can then finish up updating our main code by making sure we bind and unbind the object when we make our draw calls:
187. | void draw() |
188. | { |
189. | glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); |
190. | |
+ 191. |
|
192. | glBindVertexArray(vao); |
193. | glDrawArrays(GL_TRIANGLES, 0, 3); |
194. | glBindVertexArray(0); |
+ 195. |
|
196. | |
197. | SDL_GL_SwapWindow(window); |
198. | } |
Basically, we just replace the calls to glUseProgram
with our custom bind and unbind functions!
Hot Reloading
A nice side-effect of all our shader code now being nicely encapsulated in a class is that we can easily re-call the functions anywhere else in our code. So we'll finish up by reloading our shaders at run-time if the user presses "r" on the keyboard.
This is an incredibly useful feature for debugging. You can leave the program running, freely alter your GLSL files in a text editor, and then when you're ready save them and tap the keyboard to see the result!
And it's really easy to implement:
164. | else if(event.key.key == SDLK_F) |
165. | { |
166. | isFullscreen = !isFullscreen; |
167. | SDL_SetWindowFullscreen(window, isFullscreen); |
168. | } |
+ 169. |
|
+ 170. |
|
+ 171. |
|
+ 172. |
|
+ 173. |
|
+ 174. |
|
+ 175. |
|
+ 176. |
|
+ 177. |
|
178. | } |
179. | } |
180. | } |
In our handleEvents
function, we just need to detect the keypress, make a new call to mainShader.loadShader()
, and then do some error checking.
So we alter our keypress code to test for SDLK_R
, the "r" key, which I'm using simply because it's the first letter of "reload"!
For reference the full list is of keys is here if you want something else.
If it was pressed, we make the call to load the shader.
This will cause our shader object to delete the old shader program (the first thing the loadShader
function does), re-open the source files, and build a new shader program on the GPU from the contents.
If this worked, the function will return true and our program can carry on rendering like nothing happened - although perhaps with a new colour scheme!
On the other hand, if something bad happened during the re-load, the function will return false, so we print the shader's error messages and source filenames.
Finally we set programRunning
to false, meaning our program will exit before attempting another draw to the screen, so it doesn't matter that our shader program is currently broken.
Conclusion
Compile and run your code, and hopefully after all that, we still just have the exact same triangle on screen! Anyway. We now have our shaders programmed properly, can trivially add more shader objects in the future, and can hot-reload them on command.
If you have any trouble compiling the new code for this lesson, don't forget to update your makefile to tell your compiler it also needs to build shader.cpp
too!
If you want to play with your new code a little, while the program is running, go into your fragment shader and try adjusting the colour of the pixels. Hit "r" in your window and watch the results!