Modern OpenGL: Part 5 Feeding Vertex Data to Shaders with Vertex Buffers

In the previous chapters, we were able to…

  • create a window
  • draw a triangle
  • set up OpenGL debugging callbacks

Now we want to extend our triangle drawing, but allowing our c++ code to supply the triangle’s vertex positions. We were making things easier before by telling OpenGL what the vertex positions were directly in the shader in an array that was compiled along with the shader. We then indexed into that array with the glVertexID.

In this chapter, we will be

  • setting up out shaders to be ‘given’ the vertex position automatically (no need for glVertexID)
  • setup buffers in our c++ code to move vertex data over to the GPU to ‘feed’ them to the shaders.

I say ‘feed’ because I like to imaging the shader program as being a black box or a factory that is just given data, they are transformed by the shaders and then out the other end triangles just come out. Think of the shader program as a factory, and the vertices as the raw ingredients that go into the factory to make the product (triangles).

Setting up shaders to receive vertex input

We can remove the shaders we had previously as the vertices will now be supplied directly to the shaders. The shaders also don’t need to do any manual indexing as OpenGL will take care of that for us. All we have to do when we supply our data is to make sure that the vertex data is set up so that every 3 vertices represents the positions of our triangle vertices and then to draw the right number of vertices for our data.

… previous code (click to expand)
#include "error_handling.hpp"
#include <array>
#include <chrono>     // current time
#include <cmath>      // sin & cos
#include <cstdlib>    // for std::exit()
#include <fmt/core.h> // for fmt::print(). implements c++20 std::format
#include <unordered_map>

// this is really important to make sure that glbindings does not clash with
// glfw's opengl includes. otherwise we get ambigous overloads.
#define GLFW_INCLUDE_NONE
#include <GLFW/glfw3.h>

#include <glbinding/gl/gl.h>
#include <glbinding/glbinding.h>

#include <glbinding-aux/debug.h>

#include "glm/glm.hpp"

using namespace gl;
using namespace std::chrono;

int main() {

    auto startTime = system_clock::now();

    const auto windowPtr = []() {
        if (!glfwInit()) {
            fmt::print("glfw didnt initialize!\n");
            std::exit(EXIT_FAILURE);
        }

        glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
        glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5);

        auto windowPtr = glfwCreateWindow(1280, 720, "Chapter 5 - Vertex Data", nullptr, nullptr);

        if (!windowPtr) {
            fmt::print("window doesn't exist\n");
            glfwTerminate();
            std::exit(EXIT_FAILURE);
        }
        glfwSetWindowPos(windowPtr, 520, 180);

        glfwMakeContextCurrent(windowPtr);
        glbinding::initialize(glfwGetProcAddress, false);
        return windowPtr;
    }();

    // debugging
    {
        glEnable(GL_DEBUG_OUTPUT);
        glDebugMessageCallback(errorHandler::MessageCallback, 0);
        glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS);
        glDebugMessageControl(GL_DEBUG_SOURCE_API, GL_DEBUG_TYPE_OTHER,
                              GL_DEBUG_SEVERITY_NOTIFICATION, 0, nullptr, false);
    }

    auto program = []() -> GLuint {
        const char* vertexShaderSource = R"(
            #version 450 core
            layout (location = 0) in vec2 position;

            out vec3 vertex_colour;

            void main(){
                vertex_colour = vec3(1.0f, 0.0f, 0.0f);
                gl_Position = vec4(position, 0.0f, 1.0f);
            }
        )";

        const char* fragmentShaderSource = R"(
            #version 450 core

            in vec3 vertex_colour;
            out vec4 finalColor;

            void main() {
                finalColor = vec4(vertex_colour, 1.0);
            }
        )";


… previous code (click to expand)
        auto vertexShader = glCreateShader(GL_VERTEX_SHADER);
        glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr);
        glCompileShader(vertexShader);
        errorHandler::checkShader(vertexShader, "Vertex");

        auto fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
        glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr);
        glCompileShader(fragmentShader);
        errorHandler::checkShader(fragmentShader, "Fragment");

        auto program = glCreateProgram();
        glAttachShader(program, vertexShader);
        glAttachShader(program, fragmentShader);

        glLinkProgram(program);
        return program;
    }();

I’ve removed the colour array for this example as it will allow us to focus on the important parts. We will be adding back more attributes in later chapters but here, we have just hard coded the colour attribute that is output from the vertex shader (and interpolated across the rasterised triangles) to fixed red colour.

Here we have added the line….

layout (location = 0) in vec2 position;

This will take a bit of dissecting. There are actually two things happening here, the ‘layout qualifier’ and the ‘type qualifier’.

OpenGL can work without the layout (location = 0) part, but the in vec2 positions part is defining the data interface of the shader and states what data is coming in.

Layout Qualifier

layout

This word specifies that we are about to state a layout qualifier. Qualifiers are statements that tell the glsl compiler where storage for a variable will be and other information which we won’t look into just yet..

(location = 0)

The text inside the braces is the qualifier and states that we are going to store a variable at a particular location 0. This location is going to be something that we can also use on the c++ side and is the way that we associate the vertex data in the buffer with the variable in the GLSL shader. You will often hear about the ‘binding’ location. This is location that the attribute is bound to.

Type Qualifier

in

This ‘in’ qualifier simply states that the attribute (data) that we are stating is an ‘in’ attribute and that it is going to be supplied to the shader somehow (we will be providing the data via vertex buffers which is a region of memory from our c++ application).

vec2

This is the data type that the attribute is expected to be. There are various supported data types. This one is a vector that has two values.

position

This is the name that we want to give the attribute that we can refer to in our glsl shader (This is not a glsl keyword).

for more information, see…
https://www.khronos.org/opengl/wiki/Layout_Qualifier_(GLSL)

We could have written the shader without the layout qualifier and just stated the in vec2 positions part and then on the c++ side, asked the OpenGL runtime what locations they were assigned, as OpenGL would have done that for us if we hadn’t have stated explicitly with the layout qualifier. This way though, we have absolute control over the way that the data is laid out for our shaders. When we get to creating buffers though, I will show you how to query the location anyway, as it is a useful thing to be able to do (especially for debugging purposes).

Using a vertex attribute

In the vertex shader, we have now replaced the array indexing with directly accessing the variable supplied to the shader by using the name.

gl_Position = vec4(position, 0.0f, 1.0f);

Allocating Data on the GPU for our vertex data

Before we feed shaders with vertex data, we need to get the data onto the gpu. Thus section deals with communicating with the gpu to tell it to reserve some space in its vram for vertex data and for opengl to give us back an id for us to refer to that buffer. Then we shall show how to fill it with data .

Marshalling the vertex buffer data to our shaders

There is going to be quite a lot of information here so get ready for another brain overload. Don’t worry, once you have these concepts fully grasped, then the use of OpenGL becomes a lot easier (mostly).

Vertex Buffer Objects

These objects are where we will be storing our vertex data that will be fed into the shaders for the GPU to process and draw. typically the data will be held in GPU memory. So we have to allocate memory ON the GPU and transfer into it. Lets create one!

First let’s create the data in our cpu memory. This might be loaded from a model from disk (which we will see in chapter 11), but here we are manually specifying some locations. We have chosen to use a std::array as the data needs to be contiguous in memory.

// extra braces required in c++11.
const std::array<glm::vec2, 3> vertices{{{-0.5f, -0.7f}, {0.5f, -0.7f}, {0.0f, 0.6888f}}};

Now we use the opengl API to ‘create’ a buffer.

GLuint bufferObject;
glCreateBuffers(1, &bufferObject);

Narrator: “This did not create a buffer”.

What we have done here is only tell opengl to give us the id of a buffer. At the moment it is just an abstract handle that we will use to refer to the buffer. There is no actually data allocated on the GPU yet. This is done in the next line.

glNamedBufferStorage(bufferObject, vertices.size() * sizeof(glm::vec2), vertices.data(),
                             GL_MAP_WRITE_BIT | GL_DYNAMIC_STORAGE_BIT);

This glNamedBufferStorage() call is the one that instructs opengl to both

  • allocate the storage and
  • transfer the data

The arguments that we pass in are…

  • the buffer id we created
  • how many bytes we want allocated
  • the pointer to the data from our array so the buffer knows where to get the data to transfer
  • Some flags to tell opengl how we want to use the buffer (this can help opengl know where to place the data to make it efficient)

And we are done! Now how to we get this data into our shaders so the shaders know that this is the buffer it should be processing?

In future chapters, we will be looking at advanced ways of getting data on to the gpu to improve performance and is more in line with the modern AZDO principles. For now, this keeps things simple.

Vertex Array Objects

  • which vertex buffers are being used
  • how the data is going to be interpreted by OpenGL by setting type information of the data
  • which shader attribute locations the vertex data is associated with

Vertex Array Objects (VAOs) are the Worst Named Things In The History Of Humanity. What they actually are, are a construct that maps data from buffers into shaders. You then enable (bind) a single VAO that will control which data will be drawn when you issue a draw call.

Shaders typically have attribute inputs that we have seen just before and these inputs are defined by a location (0,1,2 etc ) and a type. Those locations make up the ‘array’ that we want to map our buffers to. VAOs are the thing that let us hook up the data in our buffers to the shader inputs.

There are 3 components we need to address.

  1. enable the array location we want to feed to
  2. which buffer is associated with with location
  3. the type, offset and stride of the data

DSA vs legacy

VAOs used to work by first having to bind the VOA to make it ‘the current one’, then you would ‘bind’ your buffers with another API call, and the VAO would ‘remember’ which buffer was bound. There is no such mechanism here.

Using Direct State Access (DSA) functions we can explicitly state the relationship between the buffers and the binding location by ‘telling’ the VAO this information, rather than having it implicitly remembered because that was the one that was bound at the time.

So lets create a VAO!

    // in core profile, at least 1 vao is needed
    GLuint vao;
    glCreateVertexArrays(1, &vao);

We do a similar thing that we did with the buffer, we create an id integer and have opengl assign it for us. There is no allocation step here, we can just use the id directly.

We now need to tell opengl how to map the buffer data to the shader locations. We start by simply enabling the first attribute location in the shader.

    glEnableVertexArrayAttrib(vao, 0);

This just ‘opens the gate’ for the data to flow through. This is the input to things like position or colour etc that are the “layout (location = 0)” qualifiers that we specified in the shader previously.

Now we provide the actual mapping between the Buffer Object and a new concept which we will call the ‘mapping slot’ or what the documentation calls a bindingindex. This is basically an intermediary location that we can use as a common agreed upon waypoint to connect the buffer and shader. The shader will never see this location or use it in the shader program. It’s just a connecting up mechanism.

This isn’t actually that useful or interesting at this point, but if we ever have a situation where we have data spread accorss multiple buffers, this mechanism gives us flexibility to pull our data from various sources. It’s especially useful if part of our data is static and never changes but we want to update another buffer per frame and we would only ever need to update one buffer, but the shader can still pull from different sources and not need to be changed.
    // buffer to index mapping
    glVertexArrayVertexBuffer(vao, 0, bufferObject, /*offset*/ 0, sizeof(glm::vec2));

We provide….

  • the vertex array object we are setting data on
  • the mapping slot (or ‘binding point’) location that we are setting the buffer to
  • the buffer object the data is coming from
  • offset and stride information so opengl knows how far apart each ‘element’ is

Because the data is contiguous, how does opengl know that the data is every 8 bytes apart (2 floats of 32 bits each. Since 32 bits is 4 bytes, 2 floats are 8 bytes ). We tell it! Thats why we pass in the sizeof(glm::vec2).

Now we need to connect up the shader attribute location to the mapping slot (binding index).

    // attribute to index mapping. can query shader for its attributes
    glVertexArrayAttribBinding(vao, glGetAttribLocation(program, "position"),
                               /*buffer slot index*/ 0);

Here we are setting the VAO to know how to associate the buffer data to the shader location by passing these arguments…

  • which VAO we are setting data on
  • the shader attribute location (which we can actually query by using the glGetAttribLocation() function. we couuld have explicitly set this based upon what we set in the (layout location = 0))
  • the intermediary binding index we want to pull our data from

Finally, the third and final piece of information we need to provide to the VAO is the vertex format.

    // data stride
    glVertexArrayAttribFormat(vao, /* shader attrobute location */ 0, 
                              glm::vec2::length(), GL_FLOAT, GL_FALSE, 0);

This tells opengl what format the vertex data is. We already passed in the byte offset previously, but this goes into more detail about what is being fed to the shader. We pass…

  • the vao
  • which attribute location we are setting the input format information to
  • how many elements are in each vector type (an integer, 2 in the case of vec2)
  • the type of each individual element in the vector (a float in this case as opposed to int or bool etc)
  • and offset into the buffer to where the data starts. (This will become useful later when we start interleaving vertex data. For now its all the same data in one packed buffer)
It is UP TO YOU as a developer to make sure that the vertex array object that you use (and hence the buffer layout) and the shader program that expects a particular vertex data layout are ‘compatible’. There are various ways to programmatically ensure this, but for now we are manually ensuring this.

Here is a quick diagram of what the 3 functions do to fix up the association between vertex buffer and shader attribute location.

And here is how we typically can use vaos by switching them to change which buffers are being used.

Now to use the buffer we created, we need to bind the vertex array object. I know that we said that we are going to be using DSA functions to avoid having to use the ‘bind’ paradigm in opengl, but alas, opengl hasn’t improved in this area. We still need to provide a ‘current’ VAO that opengl uses to do it’s drawing.

    glBindVertexArray(vao);

We can now continue to do our drawing as normal and hopefully we should see a triangle!

… previous code (click to expand)
    std::array<GLfloat, 4> clearColour;
    glUseProgram(program);

    while (!glfwWindowShouldClose(windowPtr)) {

        auto currentTime = duration<float>(system_clock::now() - startTime).count();

        clearColour = {std::sin(currentTime) * 0.5f + 0.5f, std::cos(currentTime) * 0.5f + 0.5f,
                       0.2f, 1.0f};

        glClearBufferfv(GL_COLOR, 0, clearColour.data());

        glDrawArrays(GL_TRIANGLES, 0, 3);
        glfwSwapBuffers(windowPtr);
        glfwPollEvents();
    }

    glfwTerminate();
}

Leave a Comment

Your email address will not be published. Required fields are marked *