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 hard-coded strings in our main.cpp
into two new separate source code files in our workspace.
Our program will open these files at run-time, read in the contents, and build the shaders from them.
We'll encapsulate our shader code into a class, which we'll implement in another 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 fragment 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 into a class, it will be fairly easy to then add on hot-reloading of our shaders while our program is running. So we'll also add some additional code so that when a certain key is pressed, we'll reload and rebuild the shaders, and then carry on rendering with the new shader.
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 shader programs if we want to draw water for example. So with the code encapsulated into a class, we can just create two objects and give them two different sets of GLSL source code and then we can just switch between them depending on what we want to draw.
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 ugly formatting of multi-line strings.
A much easier solution is to move the GLSL source code into their own 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
- both styles are quite common.
You can name these files whatever you like, there is no standard file extension to use.
OpenGL dictates that it's passed a string containing the shader source code, but doesn't actually say anything about how or where you get that string from.
So the choice of filename is entirely up to you.
So to start this lesson, I'm going to move our vertex shader source code from the first part of this 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 then have the shader program actually be created by a separate 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.
Thinking about our function to create the shader, what we can do is have one function to set the input filenames of the source code, and another to actually load those files and create a shader program from them. 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 simplifying shader hot-reloading!
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.
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. |
|
+ 7. |
|
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 include SDL which we'll need for making the paths to the shader files absolute paths, like we discussed when we implemented the window's icon, as absolute paths are safer.
We then include GLEW which contains the type definitions for OpenGL, which we will need to store the handle to our shader (which was a GLuint
).
Finally we include the string library, which will be helpful for working with the filenames and the contents of the source files.
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:
7. | using namespace std; |
8. | |
+ 9. |
|
+ 10. |
|
+ 11. |
|
+ 12. |
|
+ 13. |
|
+ 14. |
|
+ 15. |
|
+ 16. |
|
+ 17. |
|
+ 18. |
|
+ 19. |
|
+ 20. |
|
+ 21. |
|
+ 22. |
|
+ 23. |
|
+ 24. |
|
+ 25. |
|
+ 26. |
|
+ 27. |
|
+ 28. |
|
+ 29. |
|
+ 30. |
|
+ 31. |
|
+ 32. |
|
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 source code will be read from.
This is followed by loadShader
which will do the actual loading of 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 shader program.
Then we have a few more functions to allow us to access the object's private variables should we need to.
The function getFilenames()
gives us the source code filenames concatenated into a string, which is useful to know in case there was an error while loading the shader, 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 called readFile
, which simply takes a filename and returns it's content.
Finally there is a private function called createShader
which takes a filename and a shader type.
This will load the filename passed in using the readFile
function, and attempt to use the contents to create either a vertex shader or a fragment shader, depending on the shaderType
variable.
The idea is that the loadShader
program will call this for both shader types, and then try to link them together to produce the final complete shader program.
Implementing our class
With the shader class planned out, let's create a new file, shader.cpp
, and start defining those functions we just declared:
+ 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 then define our setFilenames
function, which takes the filenames of the vertex and fragment source code, prefixes the path of the executable to make them absolute paths, and stores the result.
Before we define our loadShader
function, let's 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. | fragmentFilename = SDL_GetBasePath() + newFragmentFilename; |
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. If it opened without a problem, we then use a stringstream to read in the file's entire contents, convert it into a string, and then close the file.
We perform one more check to see if the file actually contained any text or not, as that's something that's easy to catch here, and will be problematic later on if we try to compile it. Again we write an error message and return the empty string if so. If not, we return the file's contents and we're done.
Next up, let's take a look at the class's private createShader
function.
This will take as input the filename of the shader we want to create, and use the function we just wrote to get the contents of that file.
It also expects to be passed the shader type, either GL_VERTEX_SHADER
or GL_FRAGMENT_SHADER
, and from these will generate and return the corresponding shader:
37. | return contents; |
38. | } |
39. | |
+ 40. |
|
+ 41. |
|
+ 42. |
|
+ 43. |
|
+ 44. |
|
+ 45. |
|
+ 46. |
|
47. | |
48. | ... |
The function starts by making a call to the readFile
function we just wrote and checking that we got something back.
If the returned value is empty then something went wrong and the error message has already been set, so we can just return zero to indicate a problem.
Again, zero is understood for shader handles (and many other OpenGL variables) to represent an invalid or null state, so it's safe to return zero as the handle when problems have occurred.
With the GLSL code loaded, we can then attempt to create a shader from it:
45. | return 0; |
46. | } |
47. | |
+ 48. |
|
+ 49. |
|
+ 50. |
|
+ 51. |
|
+ 52. |
|
+ 53. |
|
+ 54. |
|
+ 55. |
|
+ 56. |
|
+ 57. |
|
+ 58. |
|
+ 59. |
|
We make a call to glCreateShader
, passing in the shaderType
variable so OpenGL knows what kind of shader we're creating.
This will return a handle to the created shader, or zero if it could not be created, in which case we set an error message and return zero.
When we have the shader we can then set it's source code to the contents of the file and try to compile it.
Just as in the first part of this lesson, the source code is set by passing OpenGL a const char**
, so first of all we store our string's raw data as a const char*
called shaderText
, an old school C-style string.
We then make a call to glShaderSource
to pass OpenGL the source code, passing in the shader we created, the number one as we're just passing in a single string, the source code itself, and finally NULL as the string length which we're allowed to do because the strings are NULL-terminated.
We then tell OpenGL to compile the shader with a call to glCompileShader
.
Just like compiling C++ code though, compiling shaders doesn't always go to plan. So next we'll check the output of the compiler:
57. | glCompileShader(shader); |
58. | |
+ 59. |
|
+ 60. |
|
+ 61. |
|
+ 62. |
|
63. | ... |
To do this, we create a variable to hold the compile status, a GLint
, and make a call to glGetShaderiv
to see the compile status (GL_COMPILE_STATUS
) of our shader.
The function 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 append it to the shader's errorMessage
variable:
61. | if(compileStatus == GL_FALSE) |
62. | { |
+ 63. |
|
+ 64. |
|
+ 65. |
|
+ 66. |
|
+ 67. |
|
+ 68. |
|
+ 69. |
|
+ 70. |
|
+ 71. |
|
+ 72. |
|
+ 73. |
|
+ 74. |
|
+ 75. |
|
+ 76. |
|
+ 77. |
|
+ 78. |
|
+ 79. |
|
The idea here is quite old-school, but to get the compiler's error 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 ask OpenGL to copy the log into our string.
To do this, we start by making another call to glGetShaderiv
, similar to before but this time asking for the length of the compiler log.
We pass in the shader to the function, but this time tell OpenGL we want the compiler's log length using GL_INFO_LOG_LENGTH
, and pass in an integer for the result to be stored into.
Once we have the log length, we then allocate memory for a new string called compilerLog
of that length.
The compiler's log can then be copied into it by making a call to glGetShaderInfoLog
.
The first parameter of this call is the 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 several 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're just copying everything into our buffer, which we already know has enough room for the entire log, 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.
Once we have the compiler log, we then append it to our errorMessage
variable.
Before returning, we free the dynamically allocated memory, and delete the failed shader, before returning zero.
If the shader did compile correctly, then we can just return the handle to our newly compiler shader!
Great so now we have a way to turn a filename into a fully compiled shader on the GPU.
Let's now define the public loadShader
function which will call this for the vertex and fragment shaders, and then link the resulting objects together into a shader program:
78. | return shader; |
79. | } |
80. | |
+ 81. |
|
+ 82. |
|
+ 83. |
|
+ 84. |
|
+ 85. |
|
+ 86. |
|
+ 87. |
|
+ 88. |
|
+ 89. |
|
+ 90. |
|
+ 91. |
|
+ 92. |
|
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. Remember we want to be able to call this function repeatedly while our program is running to implement hot-reloading!
We also start by checking that both the shader's filenames have been set before proceeding. If not, we write an error message and return false to indicate a problem during initialisation.
Once we're good to go, we can then try load our files and turn them into shaders:
88. | errorMessage = "Shader source filenames not set"; |
89. | return false; |
90. | } |
91. | |
+ 92. |
|
+ 93. |
|
+ 94. |
|
+ 95. |
|
+ 96. |
|
+ 97. |
|
+ 98. |
|
+ 99. |
|
+ 100. |
|
+ 101. |
|
+ 102. |
|
+ 103. |
|
+ 104. |
|
+ 105. |
|
We make a call to our createShader
function we just wrote, passing in the vertex filename and that we want to create a shader of type GL_VERTEX_SHADER
from it.
A handle to the resulting compiled shader is then stored in the class's vertexShader
variable.
We check the result, and if something bad happened the error message will already be set, so we can just return false to indicate a problem.
We then do exactly the same for the fragment shader, this time passing in GL_FRAGMENT_SHADER
as the shader type.
If there was a problem loading the fragment shader, we handle it the same as for the vertex shader, but this time need to remember to delete the vertex shader that was created.
With the shaders ready, we can create a shader program and begin building it:
101. | glDeleteShader(vertexShader); |
102. | return false; |
103. | } |
104. | |
+ 105. |
|
+ 106. |
|
+ 107. |
|
+ 108. |
|
+ 109. |
|
+ 110. |
|
+ 111. |
|
+ 112. |
|
+ 113. |
|
+ 114. |
|
+ 115. |
|
+ 116. |
|
+ 117. |
|
+ 118. |
|
+ 119. |
|
We make a call to glCreateProgram
, but unlike in the last lesson we actually check the return value this time!
If it is zero, indicating a non-valid program, we delete our shaders, write an error message, and return.
If it worked we attach our vertex and fragment shaders to the program, and then try to link the program together with the glLinkProgram
function.
With the program linked, we can immediately flag the shaders for deletion:
117. | glLinkProgram(shaderProgram); |
118. | |
+ 119. |
|
+ 120. |
|
+ 121. |
|
+ 122. |
|
+ 123. |
|
+ 124. |
|
If the program failed to link, then we don't need the shaders any more so can happily detach and delete them. If the program did link successfully, then it's now ready for use - the shader program is done. We no longer need our shader objects as they've already been compiled in to the finished program.
Therefore, we can immediately detach the shaders from the program using glDetachProgram
, and then delete them with glDeleteProgram
.
This isn't done automatically as it is possible to re-use a compiled vertex shader for example with a second fragment shader to create a different shader program.
But our code isn't set up for that, so we'll just delete the compiled shaders immediately which frees up any GPU memory from them.
Anyway, just like when compiling, our linking can also fail, so let's check the link status of our program and then try to get the log if there was a problem:
122. | glDeleteShader(fragmentShader); |
123. | |
+ 124. |
|
+ 125. |
|
+ 126. |
|
+ 127. |
|
+ 128. |
|
+ 129. |
|
+ 130. |
|
+ 131. |
|
+ 132. |
|
+ 133. |
|
+ 134. |
|
+ 135. |
|
+ 136. |
|
+ 137. |
|
+ 138. |
|
+ 139. |
|
+ 140. |
|
+ 141. |
|
+ 142. |
|
+ 143. |
|
+ 144. |
|
Despite linking being a single command, that's a lot of error handling!
Much of the code should be familiar though.
We again create a GLint
to hold the linker status, and make a call to glGetProgramiv
to get this, this time using GL_LINK_STATUS
for our request.
Note subtle change to "Program" instead of "Shader" in the function name though!
If this equals GL_FALSE
then we know there was a problem with linking, and just as before we can start by getting the linker's log length.
This is again the same as we did with the shader, but this time we pass the shader program's handle to glGetProgramiv
.
We allocate a string with sufficient size to hold the log and then make a call to glGetProgramInfoLog
to copy it into our buffer.
Again note the changes in the names of the functions!
Just as before we append this error message to the class's errorMessage
variable and then free the memory.
This time though, before returning, we need to do a bit of tidying.
We finish up handling linker errors by making a call to our deleteShader
function, which will tidy up the class's variables including deleting the shaderProgram
variable if it is set, so we don't need to worry about that.
We can then finish by returning false.
Of course if linking was successful, then our shader program is ready for use so we can return true!
That's basically it!
TIP:
Don't use glValidateProgram
here!
I've seen a lot of tutorials online and questions on Stack Overflow claiming 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 draw things with the shader given the current state of the program or not.
For example, perhaps you forgot to bind some important data or a VAO.
This function will warn you, or can 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.
Next up is the code for deleting the shader:
143. | return true; |
144. | } |
145. | |
+ 146. |
|
+ 147. |
|
+ 148. |
|
+ 149. |
|
+ 150. |
|
+ 151. |
|
To delete the shader, we start by making sure that it is not currently bound by calling our unbind
function.
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 extra 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 our shader program handle back to it's default state.
Our functions for binding and unbinding the shader program are similarly straight-forward:
150. | shaderProgram = 0; |
151. | } |
152. | |
+ 153. |
|
+ 154. |
|
+ 155. |
|
+ 156. |
|
+ 157. |
|
+ 158. |
|
+ 159. |
|
+ 160. |
|
+ 161. |
|
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.
Should our program have any issues while loading, the shaderProgram
variable will always be set back to zero, so we don't need to worry about handling scenarios where we accidentally try to bind non-valid shaders.
We finish up by defining our "getter" functions:
160. | glUseProgram(0); |
161. | } |
162. | |
+ 163. |
|
+ 164. |
|
+ 165. |
|
+ 166. |
|
+ 167. |
|
+ 168. |
|
+ 169. |
|
+ 170. |
|
+ 171. |
|
+ 172. |
|
+ 173. |
|
+ 174. |
|
+ 175. |
|
+ 176. |
|
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 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 triangles 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 have some simple error handling, printing the error message and input file names, 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 shader 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 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 and make another call to mainShader.loadShader()
.
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, after all that we now have our shaders programmed properly, can trivially add more shaders 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!