Skip to content

Super simple OpenGL C++ starter kit packaged with CMake. Perfect for beginners getting into computer graphics!

License

Notifications You must be signed in to change notification settings

texas-egads/EGaDS-OpenGL-StarterKit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

99 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EGaDS! Super Simple OpenGL Starter Kit

Hey everyone! This is going to be a very simple way of setting up a basic 3D application using C++ and OpenGL. This should give you a quick start in basic computer graphics concepts and allow you to start making cool games/projects using these concepts!

Installation & Setup

In this step we're going to set up the project so we can start working.

Start by cloning this repository using the below command and follow the instructions below!

git clone https://github.com/texas-egads/EGaDS-OpenGL-StarterKit.git

Note: Support for Mac builds is coming soon, for now it is not supported.

For Linux

Make sure your system has the necessary drivers for your graphics card. The installation instructions for those depend on your distribution. Also make sure you have a C/C++ compiler. You can verify this by doing a check to see if you can see the compiler information.

g++ --version

Next you want to install some dependencies on your system, use the below commands to install those third-party libraries!

sudo apt update
sudo apt install -y build-essential cmake mesa-common-dev mesa-utils freeglut3-dev libassimp-dev

Now navigate to the local repository's main project folder and run these commands to build and run the project.

  mkdir build
  cd build
  cmake ..
  make
  ./EGaDS-OpenGL-StarterKit

The last command should do nothing since the main.cpp file in the template just returns 0. So you are good to go!

For Windows

Install Visual Studio: If you haven't already, download and install Visual Studio from the official website.

Install CMake support: During the installation of Visual Studio, make sure to select the workload "Desktop development with C++", which includes CMake support. If you've already installed Visual Studio without this workload, you can modify your installation by running the Visual Studio Installer again and adding the workload.

Open your project folder in Visual Studio: Open Visual Studio and either create a new project or open an existing one where your CMakeLists.txt file resides. You can do this by selecting "Open a project or solution" from the Visual Studio start page or by going to the "File" menu and choosing "Open" > "Folder...".

Configure your build target: At the top, specify the configuration you want to build (e.g., Debug or Release) and the architecture (e.g., x64 or x86). x86 should be selected with the current project files due to the precompiled binaries. This is to remain compatible with older devices. If you want to target x64 architectures, it is recommended to replace these binaries with their x64 counterparts.

Build your project: After configuring CMake settings, you can build your project by going to the "Build" menu and selecting "Build All" (or pressing Ctrl+Shift+B). Visual Studio will generate the build files using CMake and build your project according to the specified settings.

Run and debug your project: Once the build is complete, you can run and debug your project directly from Visual Studio using the built-in debugger. Set breakpoints in your code, choose the desired startup configuration (e.g., executable), and start debugging by pressing F5 or clicking the "Start Debugging" button.

Final Setup

Creating a Window

Now that we have our project ready to go, let's start by creating a window. For that, we will be using the GLFW library.

Setting up GLFW

We can start by including GLFW and GLAD at the top of our main.cpp file, and let's include iostream as well!

#include <iostream>
#include "glad/glad.h"
#include "GLFW/glfw3.h"

For those unfamiliar with C++ include formatting,

  • #include <filename> makes the preprocessor search in an implementation-defined manner, normally in directories predefined by the compiler. It's usually used for items that are in the standard library and other header files associated with the target platform.
  • #include "filename" also makes the preprocessor search in an implementation-defined manner, but it is normally used to include our own header files and third-party libraries. Which in this case, is GLFW

Once that's settled, the next thing we need to do is initialize GLFW so we can properly use its functionality!

glfwInit();

So how GLFW works is it creates an OpenGL context, but we need to provide it with some information. This done using Window Hints. Let's give it some!

glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

What we are doing here is providing the version of our GLFW context and specifying our OpenGL context. From here let's create a GLFWwindow pointer for our window!

GLFWwindow* window = glfwCreateWindow(800, 800, "EGaDS OpenGL Starter Kit", NULL, NULL);

glfwCreateWindow() Takes in several parameters that define your typical application window. You might be familiar with some of these settings in your everyday use of desktop applications!

  • Width: This specifies the width of the window in pixels.

  • Height: This specifies the height of the window in pixels.

  • Title: This specifies the title of the window, displayed in its title bar.

  • Monitor: This specifies the monitor to use for full-screen mode. You can pass a pointer to the desired monitor. If you are creating a windowed mode window, pass NULL.

  • Share: This specifies the context to share resources with. You can pass the context of another window if you are sharing resources. For our purposes, this isn't important, so we pass NULL.

After this, we can add a bit of error logging and just print a little message to tell us if something goes wrong when creating the window. If that does happen, we can terminate GLFW.

if (window == NULL) {
	std::cout << "Error creating window" << std::endl;
	glfwTerminate();
}

Now, in order to actually use the window we just created, we can use glfwMakeContextCurrent() and pass in our GLFWwindow pointer. This introduces the window object to the current context in OpenGL.

glfwMakeContextCurrent(window);

Then at the end of our application, we can destroy our window and terminate GLFW. This just cleans things up!

glfwDestroyWindow(window);
glfwTerminate();

Now your main.cpp file should look something like this!

#include <iostream>
#include "glad/glad.h"
#include "GLFW/glfw3.h"

int main(void) {
	glfwInit();

	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

	GLFWwindow* window = glfwCreateWindow(800, 800, "EGaDS OpenGL Starter Kit", NULL, NULL);
	if (window == NULL) {
		std::cout << "Error creating window" << std::endl;
		glfwTerminate();
	}
	
	glfwMakeContextCurrent(window);

	glfwDestroyWindow(window);
	glfwTerminate();
	return 0;
}

Let's run our first window! If you have really good eyes, you might notice a window pop up for just a fraction of a second and immediately disappear! This is expected, so don't worry!

Create Window Flash

The reason this happens is that as soon as the window is created, we destroy it and terminate the program. This is why we will need a loop to keep our window open and running!

Keeping the Window Open and Running

So let's fix that! We can add a little while loop at the end of our setup that will check if the window should close, and poll for any additional events triggered by the user.

while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
}

Create Window Show

Now this window is a little boring, so let's apply a bit of color to it! First, let's load the OpenGL context and set up our OpenGL viewport to match the size of the GLFW window. In this case, that is 800 by 800!

gladLoadGL();
glViewport(0, 0, 800, 800);

Then we can define the OpenGL clear color. This is the color used to clear the screen when we clear the buffer bit. I'm going to choose some random rgb values, but feel free to pick whatever color you enjoy!

glClearColor(0.07f, 0.28f, 0.55f, 1.0f);

Now that we set the color, we can clear the OpenGL color buffer. This is the call that clears our window with our specified color!

glClear(GL_COLOR_BUFFER_BIT);

Lastly, we need to instruct OpenGL to swap the front and back buffers, and to understand this we need to talk a bit about how our graphics are rendered to the screen!

So how screens display our game is it renders images really fast to give us the illusion of motion. These images are called frames and are constantly being drawn.

Frame

While loading the pixels from the current frame to the screen, the next frame is being drawn in the background and being prepared to load to the screen as the next frame. These frames are stored in things called buffers. As you can see below, the back buffer is drawing the next frame while the front buffer is displaying the current frame on the window.

Window Buffering 1

A while later, a swap buffer call is made and the 2 buffers switch jobs and now the formerly back buffer becomes the front buffer, and vice versa. The previous frame is now being overwritten with new information to prepare for the following frame. This process is constantly repeated and is called buffer swapping.

Window Buffering 2

So let's implement this buffer swapping in our C++ application! OpenGL actually makes this really simple!

glfwSwapBuffers(window);

Our resulting main.cpp file should now look like this!

#include<iostream>
#include<GLFW/glfw3.h>

int main(void) {
	glfwInit();

	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

	GLFWwindow* window = glfwCreateWindow(800, 800, "EGaDS OpenGL Starter Kit", NULL, NULL);
	if (window == NULL) {
		std::cout << "Error creating window" << std::endl;
		glfwTerminate();
	}
	
	glfwMakeContextCurrent(window);

	gladLoadGL();
	glViewport(0, 0, 800, 800);
	glClearColor(0.07f, 0.28f, 0.55f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT);
	glfwSwapBuffers(window);
	
	while (!glfwWindowShouldClose(window)) {
		glfwPollEvents();
	}
	
	glfwDestroyWindow(window);
	glfwTerminate();
	return 0;
}

Awesome! Now if we compile and run this with CMake, we should have a nicely-colored window!

Final Window

Drawing a Triangle

Now let's add a triangle to our window! But first let's go over some graphics pipeline concepts in OpenGL!

Graphics Pipeline

So the OpenGL graphics pipeline consists of several steps that essentially takes in a bunch of data and turns it into a final output frame that our window can display! The data is formatted as an array of vertices. However, these vertices are not necessarily just points, they can contain position data, color data, or texture coordinates, etc.

The first phase of the graphics pipeline is the vertex shader. This takes the positions of all the vertices and transforms them, giving you inidividual points.

Triangle Graphics 1

This position data is then passed to the shape assembler, which will take those points and connect them based on the primitive type. A triangle primitive shape assembler would connect 3 points to form a trangle.

Triangle Graphics 2

Next, we have the geometry shader, which can add vertices and create new primitives out of existing primitives. Don't worry, this isn't really important for now!

Triangle Graphics 3

After our core geometry is laid out, we can enter the rasterization step. This turns our neat geometry into a series of pixels that approximates our shape. (Excuse my poor drawing, it's harder than it looks!) As you can see in the diagram below, the boxes represent a pixelated version of the triangle from earlier.

Triangle Graphics 4

Finally, our shape enters the fragment shader process. The rasterized triangle from earlier does not contain any color information. This step maps colors to the pixels either by using color information or by using texture information from texture coordinates.

Triangle Graphics 5

Shader Source Code

We will cover shaders and how they work shortly, but for now we will just use a default vertex and fragment shader and store them as char pointers.

const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";

const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
"   FragColor = vec4(0.8f, 0.65f, 0.97f, 1.0f);\n"
"}\n\0";

Setting up Vertices

Now what we can do is set up an array of GLfloats this is OpenGL's data type for floats when data loading. Here we have a set of 3 points that make up a triangle in 2D space. Note that the coordinate system is normalized, so (0, 0) is at the bottom left of the viewport and (1, 1) is at the top right. These coordinates show an equilateral triangle in the middle of the screen, hence the more complicated calculations. Feel free to choose any set of points for your triangle, however!

GLfloat vertices[] = {
	-0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
	0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
	0.0f, 0.5f * float(sqrt(3)) * 2 / 3, 0.0f 	
};

Loading Shaders

At the moment we do have the shader sources, but we don't exactly have shader objects. This makes them essentially useless. Therefore, we need to access, load, and compile them by reference in order to use them and render our triangle!

We can first use glCreateShader() and specify that it is a vertex shader using the GL_VERTEX_SHADER flag. We can store this in a GLuint, which is OpenGL's version of an unsigned integer.

We can then provide the shader with a reference to our source char pointer and specify that we are only giving it 1 string.

One problem, the GPU can't understand the shader from source, so we should compile this shader right now so we can use it later. Luckily OpenGL provides the glCompileShader function so we can do this easily!

GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

We can then repeat this for our fragment shader as well!

GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

So now, in order to actually use these shaders we have to wrap them up in what is called a shader programs. We can store this in a GLuint and call glCreateProgram. Aftwerwards, we can use glAttachShader() to attach our two shaders to the program, then link them with glLinkProgram().

GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

Now that these are attached we can also delete the shader objects we just generated, since they are now uploaded to the GPU.

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

Vertex Buffer Objects (VBOs)

Great work! Now we have shaders working! However, we aren't exactly doing anything at the moment. At this point, we want to upload information about our vertices to the GPU. How OpenGL handles this is using big batches called buffers.

A Vertex Buffer Object is a type of buffer that stores vertex information. Think of this as formatting the information for our GPU. Like other OpenGL elements, let's store this in a GLuint and pass it as a reference to OpenGL's glGenBuffers() function.

GLuint VBO;
glGenBuffers1, &VBO);

We should also introduce the concept of binding a buffer. In OpenGL, binding a buffer object makes that object the current buffer object. That way OpenGL modifies that buffer when performing operations.

Let's bind this VBO using glBindBuffer(), specifying it as a GL_ARRAY_BUFFER type.

glBindBuffer(GL_ARRAY_BUFFER, VBO);

OK we set this VBO up now, so let's load in our vertex information from earlier. For this, we can use the glBufferData() function. We will specify it as a GL_ARRAY_BUFFER type and provide it the size of our vertices and the value of our vertices array. We will also specify with the GL_STATIC_DRAW flag, since we are only modifying this data once and we will be drawing this on the screen.

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

Vertex Array Objects (VAOs)

We have this nicely packed information about our vertices. However, OpenGL wouldn't know where to find this information. For this we can use another type of object called a Vertex Array Object, which stores pointers to multiple VBOs and tells OpenGL how to interpret and switch between them. Let's generate that now by modifying the code above.

We first initialize it along with our VBO

GLuint VAO, VBO;

Then we use glGenVertexArrays() to generate that VAO. Make sure to do that before the VBO generate function.

glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);

Next, let's bind our new VAO using glBindVertexArray(). Again, do this before binding the VBO

glBindVertexArray(VAO);

glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

Now that all that is set up, we can reconfigure the VAO so OpenGL knows how to read the VBO. We can do this with glVertexAttribPointer(). The parameters are as follows.

  • The position of the vertex attribute, which is 0 in our case.
  • The number of vertices
  • The data type of the vertices, where we use the GL_FLOAT flag
  • Whether the data values should be normalized, we should do GL_FALSE here
  • The total size in bytes of the VBO, which is the size of each vertex times 3
  • A pointer to which the vertices in the array begin. Since we start at the 0 position of the array, we can cast the value of 0 to a void pointer In order to use this attribute array, we have to enable it with glEnableVertexAttribArray()
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

Clean Up

Great! We are so close! This step is optional, but it is generally good practice to unbind your buffer objects when you are done using them. We will use the same functions above but just pass 0 into them to unbind them.

glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);

After the window loop, let's delete the objects we created as well to do a bit of cleanup work! The functions glDeleteVertexArrays(), glDeleteBuffers(), and glDeleteProgram() will help with that!

glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteProgram(shaderProgram);

Final Steps

We are so close! Now we should move some of the logic for clearing the screen from the last section, and the GLFW swap buffers call. They should now live in the GLFW window loop to clear the screen every frame. It should now look something like this!

while (!glfwWindowShouldClose(window)) {
	glClearColor(0.07f, 0.28f, 0.55f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT);
	glfwSwapBuffers(window);
	glfwPollEvents();
}

After clearing the screen, we can use glUseProgram(), glBindVertexArray() to set up our VAO and shader program for drawing.

glUseProgram(shaderProgram);
glBindVertexArray(VAO);

Then, once those are set up, we can finally call glDrawArrays(), which will be the call that actually draws our triangle to the screen! We are specifying that we are drawing a triangle primitive using GL_TRIANGLES. We also provide it the starting position of the vertices, which is 0, and the number of vertices, which is 3, since we have 1 triangle.

glDrawArrays(GL_TRIANGLES, 0, 3);

At this point, your main.cpp should look something like what I have below. It looks a little messy, which is why we will clean it up later. But now we should have everything we need to draw a triangle to the screen!

#include<iostream>
#include <math.h>
#include "glad/glad.h"
#include "GLFW/glfw3.h"

const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";

const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
"   FragColor = vec4(0.8f, 0.65f, 0.97f, 1.0f);\n"
"}\n\0";

int main(void) {
	glfwInit();

	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

	GLFWwindow* window = glfwCreateWindow(800, 800, "EGaDS OpenGL Starter Kit", NULL, NULL);
  if (window == NULL) {
		std::cout << "Failed to create GLFW window" << std::endl;
		glfwTerminate();
		return -1;
	}
	glfwMakeContextCurrent(window);

	gladLoadGL();
	glViewport(0, 0, 800, 800);


	GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
	glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
	glCompileShader(vertexShader);

	GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
	glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
	glCompileShader(fragmentShader);

	GLuint shaderProgram = glCreateProgram();
	glAttachShader(shaderProgram, vertexShader);
	glAttachShader(shaderProgram, fragmentShader);
	glLinkProgram(shaderProgram);

	glDeleteShader(vertexShader);
	glDeleteShader(fragmentShader);

	GLfloat vertices[] = {
		-0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
		0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
		0.0f, 0.5f * float(sqrt(3)) * 2 / 3, 0.0f 	
  };

	GLuint VAO, VBO;

	glGenVertexArrays(1, &VAO);
	glGenBuffers(1, &VBO);

	glBindVertexArray(VAO);

	glBindBuffer(GL_ARRAY_BUFFER, VBO);
	glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
	glEnableVertexAttribArray(0);

	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindVertexArray(0);

	while (!glfwWindowShouldClose(window)) {
		glClearColor(0.07f, 0.28f, 0.55f, 1.0f);
		glClear(GL_COLOR_BUFFER_BIT);
		glUseProgram(shaderProgram);
		glBindVertexArray(VAO);
		glDrawArrays(GL_TRIANGLES, 0, 3);

		glfwSwapBuffers(window);
		glfwPollEvents();
	}


	glDeleteVertexArrays(1, &VAO);
	glDeleteBuffers(1, &VBO);
	glDeleteProgram(shaderProgram);

	glfwDestroyWindow(window);
	glfwTerminate();
	return 0;
}

Now finally! You can pat yourself on the back since you just drew your first triangle in OpenGL. This is notoriously the "Hello World" of computer graphics, so congratulations!

Final Triangle Window

Element Buffer Objects (EBOs)

So we are able to draw a triangle now by telling OpenGL to use the triangle primitive to draw a triangle between 3 vertices. We can label these vertices 0, 1, and 2.

Element Buffer 1

However, consider a scenario where we would want to now draw 3 separate triangles that share a few vertices. Perhaps, like this.

Element Buffer 2

As you can see, there is an overlap of vertices at the three shared points. This means that in order to upload the necessary information, we would need nine total vertices when in reality, we probably only need six if we could reuse the shared vertices.

Element Buffer 3

As a solution, let's only define six vertices, 0 through 5, as such.

Element Buffer 4

Then, we can make use of something called an Element Buffer Object that we can then use to define the order of these referenced vertices in order the draw the shapes we want. In this case, three triangles.

Therefore, using our numbering system, it would look something like [0, 4, 3, 4, 1, 5, 3, 5, 2] to draw 3 triangles with our mapping!

Element Buffer 5

Adding New Vertices

Let's jump into our code and start by adding some vertices. I am just going to add some vertices on the midpoints of the triangle we originally had.

GLfloat vertices[] = {
	-0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
	0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
	0.0f, 0.5f * float(sqrt(3)) * 2 / 3, 0.0f, 	
	-0.5f / 2, 0.5f * float(sqrt(3)) / 6, 0.0f, 	
	0.5f / 2, 0.5f * float(sqrt(3)) / 6, 0.0f, 	
	0.0f, -0.5f * float(sqrt(3)) / 3, 0.0f, 	
};

Next let's add a GLuint array of indices that will map our triangle layout using our given vertices. Note, the numbers will be in a different order than my diagram since this is based on the order from the vertices array above.

GLuint indices[] =  {
	0, 3, 5,
	3, 2, 4,
	5, 4, 1,
};

Now we can create the EBO in a similar fashion to the VAO earlier! Let's create its reference value as a GLuint and generate its value using glGenBuffers()

GLuint VAO, VBO, EBO;

glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);

Next, we need to bind the EBO using glBindBuffer() and the glBufferData() methods, but this time with the GL_ELEMENT_ARRAY_BUFFER flag instead!

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

Then we can unbind our EBO just like the other buffers. Make sure to unbind them in the same order that you bind them!

glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

And finally, we can delete the EBO at the end along with the other buffer objects!

glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteBuffers(1, &EBO);
glDeleteProgram(shaderProgram);

Draw Elements

Now, let's draw the elements that we described earlier with our vertices and indices arrays. We can replace the glDrawArrays() call with a glDrawElements() call. The parameters for glDrawElements() are as follows.

  • The shape primitive type to draw, which in our case is a triangle, so we will use GL_TRIANGLES
  • The number of indices
  • The data type of the indices, which is GL_UNSIGNED_INT in our case
  • The starting location of the beginning index for the shape, which is 0 in our case
glDrawElements(GL_TRIANGLES, 9, GL_UNSIGNED_INT, 0);

Awesome! Now you're done. If you're a bit lost at this point, the final code for main.cpp should look like this!

#include<iostream>
#include <math.h>
#include "glad/glad.h"
#include "GLFW/glfw3.h"

const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";

const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
"   FragColor = vec4(0.8f, 0.65f, 0.97f, 1.0f);\n"
"}\n\0";

int main(void) {
	glfwInit();

	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

	GLFWwindow* window = glfwCreateWindow(800, 800, "EGaDS OpenGL Starter Kit", NULL, NULL);
  	if (window == NULL) {
		std::cout << "Failed to create GLFW window" << std::endl;
		glfwTerminate();
		return -1;
	}
	glfwMakeContextCurrent(window);

	gladLoadGL();
	glViewport(0, 0, 800, 800);

	GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
	glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
	glCompileShader(vertexShader);

	GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
	glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
	glCompileShader(fragmentShader);

	GLuint shaderProgram = glCreateProgram();
	glAttachShader(shaderProgram, vertexShader);
	glAttachShader(shaderProgram, fragmentShader);
	glLinkProgram(shaderProgram);

	glDeleteShader(vertexShader);
	glDeleteShader(fragmentShader);

	GLfloat vertices[] = {
		-0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
		0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
		0.0f, 0.5f * float(sqrt(3)) * 2 / 3, 0.0f, 	
		-0.5f / 2, 0.5f * float(sqrt(3)) / 6, 0.0f, 	
		0.5f / 2, 0.5f * float(sqrt(3)) / 6, 0.0f, 	
		0.0f, -0.5f * float(sqrt(3)) / 3, 0.0f, 	
  	};

  	GLuint indices[] = {
    		0, 3, 5,
    		3, 2, 4,
    		5, 4, 1,
	};

	GLuint VAO, VBO, EBO;

	glGenVertexArrays(1, &VAO);
	glGenBuffers(1, &VBO);
  	glGenBuffers(1, &EBO);

	glBindVertexArray(VAO);

	glBindBuffer(GL_ARRAY_BUFFER, VBO);
	glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
  
 	 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
  	glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

  	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
	glEnableVertexAttribArray(0);

	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindVertexArray(0);
  	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

	while (!glfwWindowShouldClose(window)) {
		glClearColor(0.07f, 0.28f, 0.55f, 1.0f);
		glClear(GL_COLOR_BUFFER_BIT);
		glUseProgram(shaderProgram);
		glBindVertexArray(VAO);
		glDrawElements(GL_TRIANGLES, 9, GL_UNSIGNED_INT, 0);

		glfwSwapBuffers(window);
		glfwPollEvents();
	}

	glDeleteVertexArrays(1, &VAO);
	glDeleteBuffers(1, &VBO);
 	glDeleteBuffers(1, &EBO);
	glDeleteProgram(shaderProgram);

	glfwDestroyWindow(window);
	glfwTerminate();
	return 0;
}

And now we if we build and run, you can see we have 3 separate triangles that have 3 shared vertices! Cool right?

Final Element Triangle

Organizing

Shaders

Now since our main.cpp is getting a little long, let's organize some of our functionality into separate files.

First if you have not created it already, create the directory res/shaders in the project root directory. Now let's create 2 shader files: default.vert and default.frag. These are going to be our vertex shader and frament shader respectively!

#version 330 core

layout (location = 0) in vec3 aPos;

void main() {
   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
#version 330 core

out vec4 FragColor;

void main() {
   FragColor = vec4(0.8f, 0.65f, 0.97f, 1.0f);
}

Now, let's create a header file called shader.h in our src directory. At the top we will write #pragma once. This prevents our header file from being included more than once in the current project, as that would be redundant. This is very typical to include at the top of header files.

Additionlly, let's include some libraries that will help use parse the shader files we just created!

#pragma once

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
#include <cerrno>
#include "glad/glad.h"

Next we can declare a function that will help us parse the shader files and load them into our shader objects as strings.

std::string get_file_contents(const char* filename);

Now let's declare the shader class that will essentially be our OpenGL shader, but wrapped up in a nice object format for ease of use!

We can create a member variable for our shader ID and establish a constructor that will take in the vertex and fragment shader source files.

We can also declare Activate() and Delete() functions to bind and unbind the shader in OpenGL.

class Shader {
public:
	GLuint ID;
	Shader(const char* vertexFile, const char* fragmentFile);

	void Activate();
	void Delete();
};

Now let's create shader.cpp in our src directory! And include the shader.h file we just created!

#include "shader.h"

Then we can define our get_file_contents() function that will return the shader source code from the file as a string. If you are more curious about this step I would look further into file parsing in C and C++

std::string get_file_contents(const char* filename) {
	std::ifstream in(filename, std::ios::binary);
	if (in) {
		std::string contents;
		in.seekg(0, std::ios::end);
		contents.resize(in.tellg());
		in.seekg(0, std::ios::beg);
		in.read(&contents[0], contents.size());
		in.close();
		return(contents);
	}
	throw(errno);
}

Now let's define the constructor for our Shader class. This is going to be taking in the two file names and setting them up in OpenGL

Shader::Shader(const char* vertexFile, const char* fragmentFile) {
	
}

Inside this constructor, we can first use our parsing function from earlier and grab our strings from the file as c_str or character arrays!

std::string vertexCode = get_file_contents(vertexFile);
std::string fragmentCode = get_file_contents(fragmentFile);

const char* vertexSource = vertexCode.c_str();
const char* fragmentSource = fragmentCode.c_str();

From here we can just copy paste what we did in main.cpp over and it should be all set up!

Shader::Shader(const char* vertexFile, const char* fragmentFile) {
    std::string vertexCode = get_file_contents(vertexFile);
    std::string fragmentCode = get_file_contents(fragmentFile);

    const char* vertexShaderSource = vertexCode.c_str();
    const char* fragmentShaderSource = fragmentCode.c_str();

    GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);

    GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);

    ID = glCreateProgram();
    glAttachShader(ID, vertexShader);
    glAttachShader(ID, fragmentShader);
    glLinkProgram(ID);

    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
}

Now we can also implement Activate() and Delete(). These will just use OpenGL's glUseProgram() and glDeleteProgram() respectively!

void Shader::Activate() {
  glUseProgram(ID);
}

void Shader::Delete() {
  glDeleteProgram(ID);
}

VBOs

Similarly, let's organize our VBOs! We can create a new header file called VBO.h and once again write #pragma once and include glad/glad.h for OpenGL functionality!

#pragma once

#include<glad/glad.h>

Ok cool! We can now declare our VBO class. This is once again going to have a GLuint ID and a constructor. Notice that the size is of the type GLsizeiptr. This is the type that is used when creating a VBO, so let's just have the constructor use that!

We also want to provide some useful functionality for the VBO, so let's declare Bind(), Unbind(), and Delete() methods.

class VBO {
public:
	GLuint ID;
	VBO(GLfloat* vertices, GLsizeiptr size);

	void Bind();
	void Unbind();
	void Delete();
};

Cool we can now create a VBO.cpp file and fill in the functionality of the constructor and these methods similar to how we did it in main.cpp

#include "VBO.h"

VBO::VBO(GLfloat* vertices, GLsizeiptr size) {
	glGenBuffers(1, &ID);
	glBindBuffer(GL_ARRAY_BUFFER, ID);
	glBufferData(GL_ARRAY_BUFFER, size, vertices, GL_STATIC_DRAW);
}

void VBO::Bind() {
	glBindBuffer(GL_ARRAY_BUFFER, ID);
}

void VBO::Unbind() {
	glBindBuffer(GL_ARRAY_BUFFER, 0);
}

void VBO::Delete() {
	glDeleteBuffers(1, &ID);
}

EBOs

Awesome! Now we can do the same thing with EBOs. Create EBO.h and EBO.cpp files and copy over everything from VBO.h and VBO.cpp. Now just replace every VBO with EBO and every GL_ARRAY_BUFFER with GL_ELEMENT_BUFFER as a flag.

#pragma once

#include "glad/glad.h"

class EBO {
public:
	GLuint ID;
	EBO(GLuint* indices, GLsizeiptr size);

	void Bind();
	void Unbind();
	void Delete();
};
#include "EBO.h"

EBO::EBO(GLuint* indices, GLsizeiptr size) {
	glGenBuffers(1, &ID);
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ID);
	glBufferData(GL_ELEMENT_ARRAY_BUFFER, size, indices, GL_STATIC_DRAW);
}

void EBO::Bind() {
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ID);
}

void EBO::Unbind() {
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
}

void EBO::Delete() {
	glDeleteBuffers(1, &ID);
}

VAOs

Now finally, we can make a vertex array class. Create a VAO.h and a VAO.cpp file.

In VAO.h include glad and VBO.h. Now we can define our ID and constructor, as well as functionality for linking VBOs to the VAO with a GLuint layout ID.

#pragma once

#include "glad/glad.h"
#include "VBO.h"

class VAO {
public:
	GLuint ID;
	VAO();

	void LinkVBO(VBO& VBO, GLuint layout);
	void Bind();
	void Unbind();
	void Delete();
};

Cool! Let's implement these! For that all we need to do is copy over some of the VAO functionality from our main.cpp file. This time, we can use our VBO Bind() method though, since we included VBO.h in VAO.h!

#include "VAO.h"

VAO::VAO() {
	glGenVertexArrays(1, &ID);
}

void VAO::LinkVBO(VBO& VBO, GLuint layout) {
	VBO.Bind();
	glVertexAttribPointer(layout, 3, GL_FLOAT, GL_FALSE, 0, (void*)0);
	glEnableVertexAttribArray(layout);
	VBO.Unbind();
}

void VAO::Bind() {
	glBindVertexArray(ID);
}

void VAO::Unbind() {
	glBindVertexArray(0);
}

void VAO::Delete() {
	glDeleteVertexArrays(1, &ID);
}

Cleaning up main.cpp

Nice! We are getting close! All that is left to do is to modify our main.cpp file to use all these new abstractions we have created!

The result should look something like this! Look how much cleaner it looks!

#include<iostream>
#include <math.h>
#include "glad/glad.h"
#include "GLFW/glfw3.h"

#include "VAO.h"
#include "VBO.h"
#include "EBO.h"
#include "shader.h"

GLfloat vertices[] = {
	-0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
  	0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f,
  	0.0f, 0.5f * float(sqrt(3)) * 2 / 3, 0.0f, 	
	-0.5f / 2, 0.5f * float(sqrt(3)) / 6, 0.0f, 	
  	0.5f / 2, 0.5f * float(sqrt(3)) / 6, 0.0f, 	
  	0.0f, -0.5f * float(sqrt(3)) / 3, 0.0f, 	
};

GLuint indices[] = {
  	0, 3, 5,
  	3, 2, 4,
  	5, 4, 1,
};

int main(void) {
	glfwInit();

	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

	GLFWwindow* window = glfwCreateWindow(800, 800, "EGaDS OpenGL Starter Kit", NULL, NULL);
	if (window == NULL) {
		std::cout << "Failed to create GLFW window" << std::endl;
		glfwTerminate();
		return -1;
	}
	glfwMakeContextCurrent(window);

	gladLoadGL();
	glViewport(0, 0, 800, 800);

	Shader shaderProgram("res/shaders/default.vert", "res/shaders/default.frag");

  	VAO vao;
  	vao.Bind();

	VBO vbo(vertices, sizeof(vertices));
  	EBO ebo(indices, sizeof(indices));

	vao.LinkVBO(vbo, 0);
	vao.Unbind();
	vbo.Unbind();
	ebo.Unbind();

	while (!glfwWindowShouldClose(window)) {
		glClearColor(0.07f, 0.28f, 0.55f, 1.0f);
		glClear(GL_COLOR_BUFFER_BIT);
		shaderProgram.Activate();
		vao.Bind();
		glDrawElements(GL_TRIANGLES, 9, GL_UNSIGNED_INT, 0);
		glfwSwapBuffers(window);
		glfwPollEvents();
	}

  	vao.Delete();
  	vbo.Delete();
  	ebo.Delete();
  	shaderProgram.Delete();

	glfwDestroyWindow(window);
	glfwTerminate();
	return 0;
}

Also when we build and run, it runs just the same!

Final Element Triangle

Shaders

Now that that's clean and organized we can move on to some other computer graphics topics. So, let's talk about shaders!

You can kind of think of shaders as functions on a GPU. They can take inputs and have outputs.

Vertex Shader

If we take a look at this vertex shader source code here, you can actually see that this is OpenGL's shading language, called GLSL. It has a syntax that is pretty similar to C.

#version 330 core

layout (location = 0) in vec3 aPos;

void main() {
   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

The first line indicates the version of OpenGL being used. We are currently using OpenGL 3.3.0, so we should use GLSL version 330.

#version 330 core

The second line takes a vector of three values called aPos at location 0. This location is refering to the location of the vertex data it recieves. As you might remember, we set our vertices at location 0. We could theoretically store other information like color in the other locations and use them in our shader.

layout (location = 0) in vec3 aPos;

Next we have our main loop with one line. This is what is responsible for performing any calculations and giving us an output. In this code, we simple assign gl_Position with our position's x, y, and z values, plus a 1.0 for our fourth dimension. OpenGL recognizes gl_Position and knows to use it as the vertex position.

void main() {
   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

Fragment Shader

Now in this fragment shader you can see that we define an output vector of four values called FragColor. A fragment shader must output a vec4 due to OpenGL requiring color information for rendering. In this case, we just pass it our 4 floats and call it a day!

#version 330 core

out vec4 FragColor;

void main() {
   FragColor = vec4(0.8f, 0.65f, 0.97f, 1.0f);
}

Let's modify our vertex shader now to grab both our position and color information!

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;

out vec3 color;

void main() {
	gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
	color = aColor;
}

This grabs the position information from location 0 and the color information from location 1. Then it will output the color, which we assign in the main() function of the shader.

Now in our fragment shader we can use this output variable color from our vertex shader and do what we want with it. Be sure to name your variables with the same name, or it will not be able to find your attributes!

#version 330 core

in vec3 color;

out vec4 FragColor;

void main() {
	FragColor = vec4(color, 1.0f);
}

What this does is pass our imported color information into the output display as the final color!

Interleaving Data

Ok! Now we understand a little bit about shaders and what they do, let's talk about a concept called interleaving data. This essentially means grouping different pieces of data used for different things in the same array or structure. This prevents us from having to define new buffer objects for the same vertices to store different information.

If we go back to main.cpp we can demonstrate this! We can add 3 extra values for each vertex, representing the RGB coloring. Feel free to choose whatever values you'd like!

GLfloat vertices[] = {
	 -0.5f, -0.5f * float(sqrt(3)) * 1 / 3, 0.0f, 0.8f, 0.3f,  0.02f,
	 0.5f, -0.5f * float(sqrt(3)) * 1 / 3, 0.0f, 0.8f, 0.3f,  0.02f,
	 0.0f,  0.5f * float(sqrt(3)) * 2 / 3, 0.0f, 1.0f, 0.6f,  0.32f,
	 -0.25f, 0.5f * float(sqrt(3)) * 1 / 6, 0.0f, 0.9f, 0.45f, 0.17f,
	 0.25f, 0.5f * float(sqrt(3)) * 1 / 6, 0.0f, 0.9f, 0.45f, 0.17f,
	 0.0f, -0.5f * float(sqrt(3)) * 1 / 3, 0.0f, 0.8f, 0.3f,  0.02f
};

Now we have position and color information interspersed in our vertex array. It goes by the format x1, y1, z1, r1, g1, b1, x2, y2, ... The distance between a relevant piece of data and the next vertex's information of the same type is called the stride, while the distance from position 0 to the first item of the data is called the offset. In the diagram below, the offset is three times the size of a float in bytes!

Stride Offset

Now this is cool, but we have no real way to find separate out this information when reading it at the moment. So let's navigate to VAO.h and modify the LinkVBO() method to support this.

First, let's rename it to something more fitting, like LinkAttrib() and add som parameters so we can parse this, specifically numComponents, type, and offset.

void LinkAttrib(VBO& VBO, GLuint layout, GLuint numComponents, GLenum type, GLsizeiptr stride, void* offset);

Now, if we navigate to VAO.cpp we can change the method definition, and pass in our new parameters to glVertexAttribPointer().

void VAO::LinkAttrib(VBO& VBO, GLuint layout, GLuint numComponents, GLenum type, GLsizeiptr stride, void* offset) {
	VBO.Bind();
	glVertexAttribPointer(layout, numComponents, type, GL_FALSE, stride, offset);
	glEnableVertexAttribArray(layout);
	VBO.Unbind();
}

All there's left to do is to use these new parameters to define our locations! Let's navigate to main.cpp and replace vao.LinkVBO(vbo, 0) with:

vao.LinkAttrib(vbo, 0, 3, GL_FLOAT, 6 * sizeof(float), (void*)0);
vao.LinkAttrib(vbo, 1, 3, GL_FLOAT, 6 * sizeof(float), (void*)(sizeof(float) * 3));

Now, if we compile and run, you will find a nice-looking gradient of our colors from the vertices! Looks beautiful, no?

Final Shader Triangle

You might wonder why we get this gradient instead of solid colors like we defined in our indices. This is because OpenGL actually interpolates the data between 2 points, giving us a blend between our different RGB values. This doesn't only apply to colors, obviously our position data is being interpolated as well. If we were to define other data that would also be affected in a similar fashion!

Uniforms

Ok we have one more thing to talk about with shaders. A uniform is a value that we can pass to a shader program in OpenGL this way we can modify our shader values and get visual changes in real time!

We can worry about uploading uniforms later on, but for now let's define a uniform value in our vertex shader. For that, we would use the keyword uniform. Then, we can use this new uniform called scale to apply to our gl_Position calculation! This will just make our triangles bigger.

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;

out vec3 color;
uniform float scale;

void main() {
	gl_Position = vec4(aPos.x + aPos.x * scale, aPos.y + aPos.y * scale, aPos.z + aPos.z * scale, 1.0);
	color = aColor;
}

However, if we compile and run this, you'll notice that we still have the same thing being displayed. This is because the uniform is defaulted to 0, so we aren't actually adding anything to our position.

Final Shader Triangle

We could assign it a value in the shader itself, but would be more cool to upload that information from our main.cpp file so game logic could eventually tweak our shader values! Let's navigate to main.cpp and after we load our information for VBO, VAO, EBO, and shaderProgram we can assign a GLuint with the location of our uniform variable on the GPU. OpenGL has a very useful function for this called glGetUniformLocation()

GLuint scaleID = glGetUniformLocation(shaderProgram.ID, "scale");

Now that we have the location of this uniform, after calling Activate() on the shader, let's set the uniform value to something and check if our triangle looks different! What we are doing with glUniformif() is uploading one float value to the uniform location at scaleID. This is how we modify our shader values!

glUniform1f(scaleID, 0.5f);

If we run this you will find that our triangles are now slightly bigger, and this was all controlled from our main.cpp file!

Final Uniform Triangle

One little thing, let's set up a small error checking method just to make sure our shader compiles correctly. We are doing this in runtime so it is better to know if we have a typo in our shader file or something!

In shader.h and shader.cpp respectively, we can add a method called checkCompileErrors() that will do exactly that!

void checkCompileErrors(unsigned int shader, const char* type);
void Shader::checkCompileErrors(unsigned int shader, const char* type) {
	GLint hasCompiled;
	char infoLog[1024];
	if (std::strncmp(type, "PROGRAM", 7)) {
		glGetShaderiv(shader, GL_COMPILE_STATUS, &hasCompiled);
		if (hasCompiled == GL_FALSE) {
			glGetShaderInfoLog(shader, 1024, NULL, infoLog);
			std::cout << "SHADER_COMPILATION_ERROR for:" << type << "\n" << infoLog << std::endl;
		}
	}
	else {
		glGetProgramiv(shader, GL_LINK_STATUS, &hasCompiled);
		if (hasCompiled == GL_FALSE) {
			glGetProgramInfoLog(shader, 1024, NULL, infoLog);
			std::cout << "SHADER_LINKING_ERROR for:" << type << "\n" << infoLog << std::endl;
		}
	}
}

Now in the shader's constructor, let's add these error checks to make sure everything is ok!

Shader::Shader(const char* vertexFile, const char* fragmentFile) {
  std::string vertexCode = get_file_contents(vertexFile);
  std::string fragmentCode = get_file_contents(fragmentFile);

  const char* vertexShaderSource = vertexCode.c_str();
  const char* fragmentShaderSource = fragmentCode.c_str();

  GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
  glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
  glCompileShader(vertexShader);
  checkCompileErrors(vertexShader, "VERTEX");

  GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
  glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
  glCompileShader(fragmentShader);
  checkCompileErrors(fragmentShader, "FRAGMENT");

  ID = glCreateProgram();
  glAttachShader(ID, vertexShader);
  glAttachShader(ID, fragmentShader);
  glLinkProgram(ID);
  checkCompileErrors(ID, "PROGRAM");

  glDeleteShader(vertexShader);
  glDeleteShader(fragmentShader);
}

Textures

Awesome so now we know everything about shaders. So it's time to talk a bit about textures, specifically how to load them and use them!

Rendering Quads

First of all! Our 3 triangles are very cool, but when we are checking if our images are loaded properly it is better to render a quad to display this. Thus, let's reorganize our vertices and indices so that we are rendering a rainbow square!

GLfloat vertices[] = {
  -0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,
  -0.5f,  0.5f, 0.0f, 0.0f, 1.0f, 0.0f,
  0.5f,  0.5f, 0.0f, 0.0f, 0.0f, 1.0f,
  0.5f, -0.5f, 0.0f, 1.0f, 1.0f, 1.0f
};

GLuint indices[] = {
	0, 2, 1, 
	0, 3, 2
};

How quads are typically rendered is with 2 write triangles organized in the layout below. Hence, our vertices will match it in a counterclockwise fashion.

Quad Triangles

You will also need to change the glDrawElements() function in the loop, since now you have 6 indices.

Now compile and run it to make sure you get this nice looking rainbow square!

Rainbow Square

Loading the Texture

Now the library we are going to use to load our texture is called stb_image. The stb library is really useful for loading different file types, but we are specifically going to use the image module of this!

The image I have specifically selected for this is a square image of this puppy of size 512x512. Dimensions of a power of 2 are generally going to perform better, but it really isn't important, use whatever image you want! Our vertices do define a square, however, so I would recommend using a square image to keep the aspect ratio if you used my coordinates!

Dog

In our main.cpp file, let's define some integers to store our image width, height, and the number of color channels. Since this is a PNG file, it should have 4 color channels for RGBA. We can then load our PNG file into a character array with stbi_load()

  int imgWidth, imgHeight, numChannels;
  unsigned char* bytes = stbi_load("res/textures/dog.png", &imgWidth, &imgHeight, &numChannels, 0);

Next, we can create a GLuint to store the an OpenGL texture ID. Then, we can use glGenTextures() to generate the texture in memory and activate the 0 texture slot in OpenGL with glActiveTexture(). Then, we can bind the texture using glBindTexture().

GLuint texture;
glGenTextures(1, &texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);

One thing to note is that OpenGL has a number of texture slots, which allows it to render multiple textures with 1 draw call. This can then be used for batched rendering, which significantly lowers the number of draw calls per frame and drastically improves performance. Almost all GPUs can store at least 8 textures, with many able to store 16.

Texture Slots

Next we can adjust some of the parameters of the textures. GL_TEXTURE_MIN_FILTER and GL_TEXTURE_MAG_FILTER defines the algorithm that will be used to scale down or up. GL_NEAREST in this case directly scales it while GL_LINEAR will linearly interpolate to keep the image quality. For our purposes, GL_NEAREST should be fine! GL_TEXTURE_WRAP_S and GL_TEXTURE_WRAP_T define how the image behaves when it's texture coordinates go beyond the vertex coordinates. In this case we defined GL_REPEAT to let it know that it can tile the image if it runs out for information!

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

Now let's load in the image byte information by passing in our image parameters from earlier. Note that I am using GL_RGB instead of GL_RGBA because I manually converted my image from a JPG, which contains three color channels instead of four even after converting. If you use another PNG file you should use GL_RGBA. Afterwards, we can generate mipmaps for the texture.

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, imgWidth, imgHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, bytes);
glGenerateMipmap(GL_TEXTURE_2D);

Now to clean up, it is good practice to clean up our byte information and unbind the texture!

stbi_image_free(bytes);
glBindTexture(GL_TEXTURE_2D, 0);

Also we can delete the texture at the end of our application after exiting the loop!

vao.Delete();
vbo.Delete();
ebo.Delete();
glDeleteTextures(1, &texture);
shaderProgram.Delete();

Cool! Now let's add another group of information that represents the texture coordinates. This will contain two floats per vertex for texture mapping!

GLfloat vertices[] = {
	-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,
	-0.5f,  0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f,
	0.5f,  0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f,
	0.5f, -0.5f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f
};

Next, we need to reformat our VAO to account for this!

vao.LinkAttrib(vbo, 0, 3, GL_FLOAT, 8 * sizeof(float), (void*)0);
vao.LinkAttrib(vbo, 1, 3, GL_FLOAT, 8 * sizeof(float), (void*)(sizeof(float) * 3));
vao.LinkAttrib(vbo, 2, 2, GL_FLOAT, 8 * sizeof(float), (void*)(sizeof(float) * 6)); 

Cool! Now we have to modify our shaders to use this texture! Open up our vertex shader and add another slot for the texture coordinates! Then we can just pass it through our vertex shader and directly export it to the fragment shader.

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTex;

out vec3 color;
out vec2 texCoord;
uniform float scale;

void main() {
	gl_Position = vec4(aPos.x + aPos.x * scale, aPos.y + aPos.y * scale, aPos.z + aPos.z * scale, 1.0);
	color = aColor;
  	texCoord = aTex;
}

Now let's open our fragment shader and use the texture coordinates. For this, we can create a new uniform called tex0. This is going to be what points our GPU to texture slot 0 and directs it to our OpenGL texture! This uniform will have the type sampler2D which is essentially just an int that is used for textures. In the main() function, we can map tex0 with our imported texture coordinates to map these!

#version 330 core

in vec3 color;
in vec2 texCoord;

out vec4 FragColor;

uniform sampler2D tex0;

void main() {
	FragColor = texture(tex0, texCoord);
}

Awesome! Now our shaders are set up! Let's head back to main.cpp and use the same syntax from the last section to grab the uniform location! Make sure to activate the shader before uploading any information though!

GLuint tex0ID = glGetUniformLocation(shaderProgram.ID, "tex0");
shaderProgram.Activate();
glUniform1i(tex0ID, 0);

Now all that's left to do is to bind the texture in the main loop!

while (!glfwWindowShouldClose(window)) {
	glClearColor(0.07f, 0.28f, 0.55f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT);
	shaderProgram.Activate();
	glUniform1f(scaleID, 0.5f);
	glBindTexture(GL_TEXTURE_2D, texture);
	vao.Bind();
	glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
	glfwSwapBuffers(window);
	glfwPollEvents();
}

If we compile and run, we will see our cute dog picture! But wait, it's upside down!

Final Dog Inverted

This happens because OpenGL loads its texture coordinates from the bottom left at (0, 0) while stb loads the image from the top left. This makes the resulting image upside down. No worries though, all we need to do is call stbi_set_flip_vertically_on_load() to flip the imported image so it is flipped when loaded into bytes!

stbi_set_flip_vertically_on_load(true);

So now our dog is correctly flipped and happy!

Final Dog

It's kind of ridiculous that we did all this work programming, and it's all for the purpose of sharing your dog pictures...

About

Super simple OpenGL C++ starter kit packaged with CMake. Perfect for beginners getting into computer graphics!

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages