OpenGL is a cross-platform application programming interface (API) specification. What this means is that OpenGL consists of a set of language & hardware independent functions that by itself is unable to interact with any system. For this reason, OpenGL serves as a standard that graphics drivers will then utilise & in some cases extend upon for additional functionality.
OpenGL is limited only to rendering, providing no features such as input, audio or even the ability to utilise a window to render to. For this reason, additional libraries will be needed in this lab. The download process for them is detailed in the Setup section.
A good resource for learning OpenGL is LearnOpenGL. The LearnOpenGL material will bare similarity to the material in these labs, however not all libraries used are the same, so proceed with caution.
In Visual Studio, create an Empty Project
that uses C++.
Next, create a new file named main.cpp in the Source Files
filter. For GLSL shader functionality, you will also need to acquire the LoadShaders
header & cpp files from Files. Once they have been downloaded, right click your project file in the Solution Explorer
& select Open folder in File Explorer
. Create a new folder named 'shaders' & place the LoadShaders
header & cpp files inside it. Next, go back to Visual Studio, right click the project file, select Add
& then select New Filter
. Name this filter shaders
. Lastly, right click the shaders
filter, hover over Add
& select Existing Item
. In the popup window, navigate to both of the LoadShaders
files & select them.
It should not be necessary to install OpenGL, since it comes preinstalled on Windows, MacOS & Linux. However, if there are issues with OpenGL drivers, the OpenGL Getting Started page will direct to the appropriate graphics driver sites.
In order to use OpenGL, you must ensure that Visual Studio can locate OpenGL on your computer & therefore you must add it as an additional dependency. In Visual Studio, right click your project file, select Properties
, unfold Linker
, select Input
& edit Additional Dependencies
by adding opengl32.lib
.
Library & include files that will be downloaded in this lab will need to be placed in a new subfolder in the C:\Users\Public
directory. Create a folder in this directory named OpenGL
& inside this folder create two folders named include
& lib
.
Your Visual Studio project's linker needs to know the locations of the library files that are going to be downloaded. The include files will be located at C:\Users\Public\OpenGL\include
& the library files will be located at C:\Users\Public\OpenGL\lib
. In order to link to these locations, go to your Visual Studio project, navigate to the Solution Explorer
, right click the project file, select Properties
, unfold Configuration Properties
& select VC++ Properties
.
Add the include directory C:\Users\Public\OpenGL\include
to the Include Directories
section & add the library directory C:\Users\Public\OpenGL\lib
to the Library Directories
section respectively.
The GLFW library provides the ability to use OpenGL to render to windows, to receive input & also provides other useful events. In order to acquire GLFW, navigate to the GLFW Download Page & download the Windows pre-compiled 64-bit binaries.
Open a file explorer & navigate to C:\Users\Public\OpenGL
& open a second file explorer & navigate to the GLFW folder in the Downloads
folder. In the GLFW folder, open the include
subfolder & move the GLFW
subfolder within that into the include
folder in the OpenGL
directory. In the GLFW folder in the Downloads
folder, many lib-vc
folders exist. These are library folders that correspond to different versions of Visual C++. Open the lib-vc2022
folder & move all the files into the lib
folder in the OpenGL
directory.
In Visual Studio, right click your project file, select Properties
, unfold Linker
, select Input
& edit Additional Dependencies
by adding glfw3.lib
. Lastly, in your main.cpp file, insert the #include <GLFW/glfw3.h>
include. If Visual Studio fails to retrieve glfw3.h
, then something has gone wrong in any of the aforementioned processes.
GLEW is an extension loading library. It provides checks to determine what extensions are able to be supported on any particular targeted platform. To download it, go to the GLEW Download Page & select the GLEW Binaries for Windows 32-bit & 64-bit. After this, you will be redirected to another site where you will have to wait a few seconds for your download to start.
Once you have downloaded GLEW, navigate to your Downloads
folder. Open the include
folder & move the GL
folder into your C:\Users\Public\OpenGL\include
directory. Additionally, in the GLEW folder, navigate to the Release/x64
folder & move the files into the C:\Users\Public\OpenGL\lib
directory. Then, in the GLEW folder, navigate to bin/Release/x64
& move the glew32.dll
file into your Visual Studio project's directory where your main.cpp
file is located.
In Visual Studio, right click your project file, select Properties
, unfold Linker
, select Input
& edit Additional Dependencies
by adding glew32.lib
& glew32s.lib
. Lastly, in your Visual Studio Project's main.cpp file, add the #include <GL/glew.h>
include. In this instance, make sure that the #include
is located above all other OpenGL related includes, since GLEW must run before all other OpenGL related libraries. If Visual Studio fails to retrieve glew.h
, then something has gone wrong in any of the aforementioned processes.
In the LearnOpenGL tutorial, the GLAD library is used as opposed to GLEW. If you are following the LearnOpenGL material, you may wish to use GLAD, however I do not recommend you do so yet, even if you are following said tutorial. This is because for LearnOpenGL's earlier tutorials, GLAD will require one to follow LearnOpenGL's error-prone system of implementing GLSL shaders into projects. Shaders are needed at a basic level in this module & are the focus of the succeeding COMP3015 module. The LoadShaders
header & cpp files in Files use GLEW & will allow for easier implementing of GLSL shaders. If you still wish to use GLAD, the process of retrieval is still available below. Note that starting from lab 9, GLAD will be used instead of GLEW due to the introduction of model loading.
GLAD can be downloaded from the GLAD Loader-Generator Web Service in multiple different forms depending upon the individual's requirements. If one is to use GLAD for this lab, set the Language
to C++
, the gl
to an OpenGL version of at least Version 3.3
& lastly set the Profile
to Core
.
In the Downloads
folder, open the glad
folder & navigate to the include
subfolder. Move both the internal glad
& KHR
folders to the C:\Users\Public\OpenGL\include
folder. Then, in the glad
folder's lib
folder, move the glad.c
file into your Visual Studio Project's project directory where your main.cpp
file is located.
In your main.cpp
file, add the #include <glad/glad.h>
include. Like with GLEW, make sure that the #include
is located above all other OpenGL related includes, since GLAD must run before all other OpenGL related libraries. If Visual Studio fails to retrieve glad.h
, then something has gone wrong in any of the aforementioned processes.
The GLM library provides extended mathematics for OpenGL. GLM's functions also follow the same naming conventions & functionality as GLSL. While GLM is specifically intended for use with OpenGL, it is also able to be utilised elsewhere.
Navigate to the GLM Repository & download the latest release in zip format. In the Downloads
folder, open the glm
folder & move the internal glm
folder to the C:\Users\Public\OpenGL\include
include directory. No glm #include
directives are needed in your Visual Studio project for this lab. It will be specified as to whether you require them in future labs.
In order to begin, a window must be instantiated. Since GLFW will be used for this, it must be initialised with glfwInit()
. Then, to instantiate a window, the GLFWwindow
object is initialised with the glfwCreateWindow()
constructor. The dimensions in the following example are 1280x720 & the name of the window is Lab5
. The fourth parameter specifies which monitor to fullscreen on & the last parameter specifies which window's context to share resources with. These last two parameters do not need to be manually specified & therefore both can be set to NULL
. window
is then checked to see if it has been successfully instantiated & if so glfwMakeContextCurrent()
is called to bind OpenGL to the window:
CPP
//STD
#include <iostream>
//GLEW
#include <GL/glew.h>
//GLFW
#include <GLFW/glfw3.h>
using namespace std;
int main()
{
//Initialisation of GLFW
glfwInit();
//Initialisation of 'GLFWwindow' object
GLFWwindow* window = glfwCreateWindow(1280, 720, "Lab5", NULL, NULL);
//Checks if window has been successfully instantiated
if (window == NULL)
{
cout << "GLFW Window did not instantiate\n";
glfwTerminate();
return -1;
}
//Binds OpenGL to window
glfwMakeContextCurrent(window);
return 0;
}
In order to render to the newly instantiated window, we need to set the GLEW viewport dimensions within the window. To do this, GLEW must first be initialised with the glewInit()
function. Next, the viewport dimensions can be defined with the glViewport()
function; the first two parameters specify the position of the window & the last two parameters specify the dimensions:
CPP
//Initialisation of GLEW
glewInit();
//Sets the viewport size within the window to match the window size of 1280x720
glViewport(0, 0, 1280, 720);
The window's rendering dimensions currently cannot be modified during runtime, which means expanding & contracting the window will produce odd results. In order to allow for dynamic adjustment, the function framebuffer_size_callback()
needs to be created. After this, the function needs to be set as the callback for the GLFW window resizing event with the glfwSetFramebufferSizeCallback()
function. The first parameter is the window & the second is the function to call on a window resize:
Header
#pragma once
//framebuffer_size_callback() needs GlFW, so include moved here
#include <GLFW/glfw3.h>
//Called on window resize
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
CPP main()
//Sets the framebuffer_size_callback() function as the callback for the window resizing event
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
CPP framebuffer_size_callback()
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
//Resizes window based on contemporary width & height values
glViewport(0, 0, width, height);
}
In order to prevent the program from closing immediately & to allow for perpetual rendering, we need to create the render loop. In order to do this, we check if the window is set to close with the glfwWindowShouldClose() function
. This becomes true when the x
button at the top right of the window is clicked.
Inside of the render loop, the glfwSwapBuffers()
function is called in order to perpetually swap the colour buffer being rendered to the window. The colour buffer is a 2-dimensional buffer that contains colour values per pixel for the window it is dedicated to rendering to. The glfwPollEvents()
function queries for whether any GLFW events have been triggered, such as the framebuffer_size_callback()
function.
Lastly, once the render loop is exited, the glfwTerminate()
function is called in order to terminate GLFW safely:
CPP
//Render loop
while (glfwWindowShouldClose(window) == false)
{
glfwSwapBuffers(window); //Swaps the colour buffer
glfwPollEvents(); //Queries all GLFW events
}
//Safely terminates GLFW
glfwTerminate();
In order to take & process user input, we need to create a ProcessUserInput()
function. This function needs to take in a GLFWwindow
object pointer in order to acquire the input that was applied to said window. The glfwGetKey()
function can be used to check if a key was interacted with within a window's context. We want to check if a key was pressed with GLFW_PRESS
& that the key in question is the escape key, so in this case we check for GLFW_KEY_ESCAPE
. In order to perpetually check for user input, the ProcessUserInput()
function needs to be called within the render loop:
Header
//Processes user input on a particular window
void ProcessUserInput(GLFWwindow* WindowIn);
CPP main()
//Render loop
while (glfwWindowShouldClose(window) == false)
{
//Input
ProcessUserInput(window); //Takes user input
//Refreshing
glfwSwapBuffers(window); //Swaps the colour buffer
glfwPollEvents(); //Queries all GLFW events
}
CPP processInput()
void ProcessUserInput(GLFWwindow* WindowIn)
{
//Closes window on 'exit' key press
if (glfwGetKey(WindowIn, GLFW_KEY_ESCAPE) == GLFW_PRESS)
{
glfwSetWindowShouldClose(WindowIn, true);
}
}
We are able to render a colour to the window by clearing the window's colour buffer & setting a default colour. In order to do this, within the render loop we need to first call the glClearColor()
function in order to specify the colour to default to. The parameters are RGBA; red, green, blue & alpha. For clarification on what 'alpha' refers to, that is transparency, so a value of 1.0f
would set the screen to be entirely opaque. Lastly, the glClear()
functions must be called in order to clear a specific window buffer. We need to clear the colour buffer, so the function's parameter needs to be set to GL_COLOR_BUFFER_BIT
:
CPP
//Render loop
while (glfwWindowShouldClose(window) == false)
{
//Input
ProcessUserInput(window); //Takes user input
//Rendering
glClearColor(0.25f, 0.0f, 1.0f, 1.0f); //Colour to display on cleared window
glClear(GL_COLOR_BUFFER_BIT); //Clears the colour buffer
//Refreshing
glfwSwapBuffers(window); //Swaps the colour buffer
glfwPollEvents(); //Queries all GLFW events
}
From a high level perspective, there are 3 stages that must take place in order to render objects in 3D space to a window.
- The instantiation of spacial information in C++ | CPU
- The transition of data to the shader with OpenGL | CPU → GPU
- The rendering stage with GLSL (OpenGL Shading Language) | GPU
First, we must initialise any coordinates in C++ that will ultimately be rendered to a window in some form. These coordinates must be contained within floating point arrays. For this reason, at this stage it is ambiguous as to what these coordinates relate to. Their purpose is only defined upon being converted into vertex buffer objects (VBOs). The coordinates in question could be manifested as the vertices of an object, its colours or other things. To keep things simple, we are only going to be initialising the vertices at this stage:
CPP
Below glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
vv
float vertices[] = {
-0.5f, -0.5f, 0.0f, //pos 0 | x, y, z
0.5f, -0.5f, 0.0f, //pos 1
0.0f, 0.5f, 0.0f //pos 2
};
Above while (glfwWindowShouldClose(window) == false)
^^
The vertices array is currently mapped to CPU memory, as opposed to GPU memory. For this reason, we need to convert the vertices array into OpenGL vertex buffer objects (VBOs) so that they can be sent to the GPU. VBOs are able to store a large number of vertices, which allows for a significant amount of data to be sent to the GPU at once. This is beneficial as this reduces the relatively significant bottleneck of transitioning CPU memory to GPU memory.
First, a VBO must be created & assigned a buffer index with the glGenBuffers()
function. Next, the glBindBuffer()
function must be called in order to determine what buffer to assign the VBO to. In this case, we want to assign it to the GL_ARRAY_BUFFER
, since this is the buffer that contains vertices, as opposed say to colours. Then, we need to allocate memory for the vertices that we have sent to the VBO, which we do with the glBufferData()
function:
CPP
//Declaration of index of VBO
unsigned int vertexBufferObject;
//Sets index of VBO
glGenBuffers(1, &vertexBufferObject);
//Binds VBO to array buffer for drawing vertices
glBindBuffer(GL_ARRAY_BUFFER, vertexBufferObject);
//Allocates buffer memory for the vertices
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
Lastly, we need to give our vertex shader on the GPU access to the VBO. In order to do this, we need to assign memory for a vertex attribute array that will be sent to the GPU. This is done with the glVertexAttribPointer()
function. There are many parameters that this function needs to take:
- #1: Vertex attribute index
- #2: Vertex attribute size, which equates to the size of one element of the vertices array
- #3: Type of data, so a float
- #4: Whether to normalise the data; this can be useful for integers, but not for floats
- #5: The stride, which determines the space between each vertex attribute in the array; in our case the size of 3 floating point numbers
- #6: The offset of where the coordinate data should begin in the buffer; position 0 is the start of the VBO
CPP
//Allocates vertex attribute memory for vertex shader
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
//Index of vertex attribute for vertex shader
glEnableVertexAttribArray(0);
Like VBOs, vertex array objects (VAOs) can be bound to an OpenGL buffer. VAOs are arrays of vertex attributes. For this reason, they can be used to store related vertex arrays & therefore are able to contain all the spacial information related to one object. For example, a VAO could contain a vertex attribute array for an object's coordinates, as well as another for its colours.
The benefit of using VAOs is that you only need to bind to one of them when setting up multiple vertex attribute arrays. A VBO is only able to contain one vertex attribute array.
CPP Globals
//VAO vertex attribute positions in correspondence to vertex attribute type
enum VAO_IDs { Triangles, Indices, Colours, Textures, NumVAOs = 2 };
//VAOs
GLuint VAOs[NumVAOs];
//Buffer types
enum Buffer_IDs { ArrayBuffer, NumBuffers = 4 };
//Buffer objects
GLuint Buffers[NumBuffers];
The setup process for VAOs is similar to VBOs. First, we call glGenVertexArrays()
in order to setup the VAO. Next, we call the glBindVertexArray()
function to bind the VAO to OpenGL & lastly we index our buffer objects against our buffer with the glGenBuffers()
function.
In order to setup the desired VBO within our VAO, we have to call glBindBuffer()
& in this case access the Triangles
buffer object. Then, like before we allocate memory to the VBO based on the vertices array, setup our vertex attribute array & lastly unbind our VAO & VBO:
CPP main()
//Sets index of VAO
glGenVertexArrays(NumVAOs, VAOs);
//Binds VAO to a buffer
glBindVertexArray(VAOs[0]);
//Sets indexes of all required buffer objects
glGenBuffers(NumBuffers, Buffers);
//Binds VAO to array buffer
glBindBuffer(GL_ARRAY_BUFFER, Buffers[Triangles]);
//Allocates buffer memory for the vertices
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
//Allocates vertex attribute memory for vertex shader
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
//Index of vertex attribute for vertex shader
glEnableVertexAttribArray(0);
//Unbinding
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
In order to render our attribute arrays to a window, we need to send them through the OpenGL Graphics Pipeline. Depending upon the stage in the pipeline, we ourselves either cannot implement, have the option to, or must implement how the rendering is accomplished. The stages that we need to implement are the vertex & fragment shaders.
In order to do this, we need to create two files, which we will place inside the shaders
folder & Visual Studio shaders
filter. They will be named vertexShader.vert
& fragmentShader.frag
, however the names & extensions can be anything.
The code below is placed during the OpenGL initialisation process in the main()
function. Its purpose is to retrieve the shaders that we wish to implement, set them up with the LoadShaders()
function & lastly activate them with the glUseProgram()
function:
CPP Globals & Includes
#include "LoadShaders.h"
GLuint program;
CPP main()
Below glewInit();
vv
//Load shaders
ShaderInfo shaders[] =
{
{ GL_VERTEX_SHADER, "shaders/vertexShader.vert" },
{ GL_FRAGMENT_SHADER, "shaders/fragmentShader.frag" },
{ GL_NONE, NULL }
};
program = LoadShaders(shaders);
glUseProgram(program);
Above glViewport(0, 0, 1280, 720);
^^
Shaders do not run serially as most code would on a CPU. Instead, many instances of them run in parallel on the GPU. However, while each instance of a particular type of shader shares the same logic, they do not necessarily share the same values. Each shader running on the GPU is responsible for one component of a stage of the rendering process.
Open our vertex shader. Shaders are written in GLSL, the syntax of which is based on the C programming language, therefore also baring a resemblance to C++. Note that Visual Studio does not support GLSL syntax highlighting by default, however there are extensions for this. Regardless, it is easy to install GLSL syntax highlighting extensions within Visual Studio Code, so it may be beneficial to use Visual Studio Code for GLSL.
The vertex shader determines all the positions of the vertices of an object. Therefore, each instance of a running vertex shader is responsible for one particular vertice. To get started, first we should specify our GLSL version at the top of the file.
Notice the type of our variable position
. The layout
qualifier allows for a variable's value to be retrieved from a vertex attribute from OpenGL. This is possible because the vertex shader is the first stage of the pipeline, meaning the previous stage was OpenGL. Therefore, we are able to use the layout
qualifier to retrieve data from the CPU.
The location
determines an index for our vertex shader variable position
that an equivalent vertex attribute variable generated on the CPU is expected to match. The index is 0, as we set the vertex attribute index to be 0 through the first parameter of the glVertexAttribPointer()
function in the Vertex Buffer Objects section.
The use of in
asserts that the value must be retrieved from the last stage. The use of in
is not limited only to layout
qualified variables & can be used with user defined variables. Lastly, vec3
is the actual type of the variable, which is a 3-dimensional vector:
Globals
#version 460
//Triangle position with values retrieved from main.cpp
layout (location = 0) in vec3 position;
We need a main()
function within the vertex shader in order to automatically allow the shader to be run. The gl_Position
variable is predefined & is used to set the shader's output. This is automatically sent to the next stage of the graphics pipeline. The input we need to give gl_Position
is the position
variable's x, y & z values. We also need to provide the w value as the fourth parameter, which in this case will be hardcoded as opposed to provided by the position
variable. The w value is used for perspective division, which is not discussed in this lab:
main()
void main()
{
//Triangle vertice sent through gl_Position to next stage
gl_Position = vec4(position.x, position.y, position.z, 1.0);
}
The fragment shader is responsible for mapping the correct colours to the correct pixels after rasterisation has taken place. Therefore, each instance of a running fragment shader is responsible for one pixel & its specific colour.
In order to make use of the fragment shader, we need to create a variable named FragColor
. This variable will be outputted to the next stage of the graphics pipeline, therefore we need to specify out
behind its type so that its value is sent to the next stage in the pipeline. Like with in
, out
may be used with any user defined variable.
The next stage will expect FragColor
to have sent four float values, therefore we need its type to be vec4
. Within the main()
function, we are simply going to specify our RGBA values. The colour set in the code below should always be white:
fragmentShader.vert
#version 460
//Colour value to send to next stage
out vec4 FragColor;
void main()
{
//RGBA values
FragColor = vec4(1.0f, 1.0f, 1.0f, 1.0f);
}
Lastly, in our main.cpp
file, we need to call two functions within the render loop in order to state that we wish to draw to the window. The first function is the glBindVertexArray()
function, which determines which vertex array we wish to bind to & therefore are able to draw. In order to draw our triangle, we call the glDrawArrays()
function. We specify GL_TRIANGLES
so that our vertices are able to connect & for a surface to be drawn within them. For example, as opposed to a wireframe. The next parameter is the starting index & the last parameter is the amount of vertices to render. Make sure that the last parameter is set to at least the amount we need to render.
//Render loop
while (glfwWindowShouldClose(window) == false)
{
//Input
ProcessUserInput(window); //Takes user input
//Rendering
glClearColor(0.25f, 0.0f, 1.0f, 1.0f); //Colour to display on cleared window
glClear(GL_COLOR_BUFFER_BIT); //Clears the colour buffer
glBindVertexArray(VAOs[0]); //Bind buffer object to render
glDrawArrays(GL_TRIANGLES, 0, 3); //Render buffer object
//Refreshing
glfwSwapBuffers(window); //Swaps the colour buffer
glfwPollEvents(); //Queries all GLFW events
}
Now, you should be able to run your program & an indigo background with a white triangle should appear!