University of Wales Trinity Saint David
ACCA6021 - Graphics and Games Programming

Lab 05 - Index Buffers and Uniforms [SOLUTION]

Overview

Lab 04 exposed two problems. The vertex buffer wasted memory repeating two corner positions, and the quad's colour was fixed at compile time inside the GLSL source. This lab solves both. An Index Buffer Object (IBO) lets the GPU reuse vertex positions by number rather than repeating them, shrinking the quad from 6 stored vertices to 4. A uniform variable opens a data channel from C++ into the running shader, so the colour can be chosen and changed at runtime without touching the GLSL file.

This lab follows directly from the lecture on shaders and uniforms. The lecture covered the theory. This lab is the practical implementation.

Prerequisites

Learning Objectives

By the end of this lab you will be able to:

Concept

Lab 04 stored 6 vertices to draw a quad, repeating two corner positions to satisfy glDrawArrays. An Index Buffer Object (IBO) removes the duplication. Instead of listing every vertex in draw order, you store only the 4 unique positions and supply a separate array of integer indices. Each index is the 0-based slot of a position in the VBO. The GPU assembles triangles by looking up positions by index rather than reading them sequentially. A quad needs 6 indices but only 4 stored positions: the saving grows substantially for dense meshes where every vertex is shared by an average of six triangles.

An IBO is a standard buffer object bound to GL_ELEMENT_ARRAY_BUFFER instead of GL_ARRAY_BUFFER. The VAO records the element buffer binding as part of its state, so rebinding the VAO in the render loop restores it automatically. The draw call changes from glDrawArrays to glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0): the second argument is the number of indices, not vertices.

A uniform is a value passed from C++ to a shader that stays constant for every vertex and fragment processed in a single draw call. Unlike a vertex attribute, which changes per vertex, a uniform is global to the program. You declare it in GLSL with uniform type name, then from C++ retrieve its location once with glGetUniformLocation and write a value each frame with the matching glUniform* variant. The location must be retrieved after the program is linked. The value must be set while the program is active, so call glUseProgram first. A return value of -1 from glGetUniformLocation means the name was not found - check for typos or confirm the uniform is actually used in the shader.

Tasks

Task 1 - Add a Uniform to the Shader

Open res/shaders/triangle.shader and update the fragment section so the colour comes from a uniform rather than a hard-coded constant. The vertex section does not change.

  1. Add the line uniform vec4 uColour; before the out vec4 FragColour; declaration.
  2. Replace the hard-coded vec4(0.2, 0.6, 1.0, 1.0) assignment with uColour.
#shader fragment
#version 430 core
// TODO: declare uniform vec4 uColour here
out vec4 FragColour;
void main() {
    // TODO: assign uColour to FragColour instead of the hard-coded colour
}
Why before the out declaration? Uniform declarations are global to the shader and conventionally placed at the top of the source, before any out or function definitions. The GLSL compiler does not enforce the order, but keeping uniforms at the top makes them easy to find.

Task 2 - Reduce to 4 Unique Vertices

Replace the 6-entry vertex array in src/main.cpp with the 4-entry array below. Remove the duplicate entries that were needed by glDrawArrays. The index array (Task 3) will tell the GPU which positions to use and in what order.

// file: src/main.cpp -- replace the existing vertices[] array
float vertices[] = {
    -0.5f, -0.5f, 0.0f,   // 0: bottom-left
     0.5f, -0.5f, 0.0f,   // 1: bottom-right
     0.5f,  0.5f, 0.0f,   // 2: top-right
    -0.5f,  0.5f, 0.0f    // 3: top-left
};

Task 3 - Define the Index Array

Add the index array immediately after the vertex array. Each row defines one triangle by listing three vertex slot numbers. The positions themselves are not repeated - only the slot references are.

// file: src/main.cpp -- add after vertices[]
unsigned int indices[] = {
    0, 1, 2,   // triangle 1: bottom-left -> bottom-right -> top-right
    2, 3, 0    // triangle 2: top-right -> top-left -> bottom-left
};

Task 4 - Create and Upload the IBO

Add the IBO setup after the glVertexAttribPointer and glEnableVertexAttribArray calls, while the VAO is still bound. The VAO stores the element buffer binding as part of its state.

  1. Declare unsigned int ibo.
  2. Call glGenBuffers(1, &ibo).
  3. Call glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo).
  4. Call glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW).
  5. Now unbind the VAO with glBindVertexArray(0).
// file: src/main.cpp -- add after glEnableVertexAttribArray, while VAO is still bound
// TODO: declare unsigned int ibo
// TODO: generate one buffer and store in ibo
// TODO: bind as GL_ELEMENT_ARRAY_BUFFER
// TODO: upload sizeof(indices) bytes with GL_STATIC_DRAW

glBindVertexArray(0);  // unbind VAO after the IBO is bound, not before
Binding order matters. The IBO must be bound while the VAO is active. The VAO records which element buffer is attached. If you call glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0) while the VAO is still bound, you remove the reference and the draw call will read from the wrong location. Unbind the VAO first, then optionally unbind other buffers.

Also add glDeleteBuffers(1, &ibo) to the cleanup section after the render loop, alongside the existing VBO cleanup.

Task 5 - Switch to glDrawElements

Inside the render loop, replace the glDrawArrays call. The new call reads 6 indices from the bound IBO and uses them to assemble two triangles from the 4 positions in the VBO.

  1. Remove: glDrawArrays(GL_TRIANGLES, 0, 6);
  2. Add the call below.
// TODO: replace glDrawArrays with glDrawElements
//       primitive: GL_TRIANGLES
//       count: 6  (number of indices, not vertices)
//       type: GL_UNSIGNED_INT
//       offset: (void*)0

Task 6 - Set the Uniform Value from C++

Wire the C++ side of the uniform. The location is queried once after the program is created and cached for reuse. The value is written each frame after the program is activated.

  1. After the createProgram call and before the render loop, retrieve and store the uniform location:
// file: src/main.cpp -- add after createProgram, before the render loop
// TODO: call glGetUniformLocation(shader, "uColour") and store the result in int colourLoc
//       if colourLoc is -1, the name was not found - check the spelling matches the shader exactly
  1. Inside the render loop, after glUseProgram(shader) and before the draw call, set the colour:
// TODO: call glUniform4f(colourLoc, r, g, b, a) with a colour of your choice
//       glUniform4f takes four floats in the range 0.0 to 1.0 (red, green, blue, alpha)
Two rules for uniforms. First: always retrieve the location after the program has been linked - not before. Second: always call glUseProgram before any glUniform call. If either rule is broken the value is silently ignored and the quad appears black (the default for an unset vec4 uniform is all zeros).

Expected Result

A solid coloured quad fills the same region as Lab 04, but now displaying whatever colour you passed to glUniform4f. Try values like (1.0f, 0.5f, 0.0f, 1.0f) for orange or (0.1f, 0.9f, 0.4f, 1.0f) for green.

If the quad is black, glUniform4f is either not being called or is being called before glUseProgram. If the quad is missing entirely, check that the IBO was bound while the VAO was still active (Task 4). If the console shows a shader compile error, check the uniform declaration in triangle.shader for typos.

Extension Challenge

Optional: Animate the quad's colour over time using a second uniform. Declare uniform float uTime in the fragment shader and compute the colour from it:

uniform float uTime;
out vec4 FragColour;
void main() {
    float r = sin(uTime * 1.0) * 0.5 + 0.5;
    float g = sin(uTime * 0.7 + 2.0) * 0.5 + 0.5;
    float b = sin(uTime * 1.3 + 4.0) * 0.5 + 0.5;
    FragColour = vec4(r, g, b, 1.0);
}

In src/main.cpp, retrieve the uTime location the same way as uColour. Inside the render loop, pass the elapsed time with glUniform1f(timeLoc, (float)glfwGetTime()). The quad should cycle through a range of colours smoothly. Notice that different phase offsets and frequencies on each channel produce a richer animation than cycling all three channels at the same rate.

Solution

Try to complete the tasks above before reading this section.

The updated shader file is shown first, then the complete src/main.cpp.

res/shaders/triangle.shader

#shader vertex
#version 430 core
layout (location = 0) in vec3 aPos;
void main() {
    gl_Position = vec4(aPos, 1.0);
}

#shader fragment
#version 430 core
uniform vec4 uColour;
out vec4 FragColour;
void main() {
    FragColour = uColour;
}

src/main.cpp

// file: src/main.cpp -- complete solution for Lab 05

#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <fstream>
#include <sstream>
#include <iostream>
#include <string>

struct ShaderSource { std::string vertex; std::string fragment; };

ShaderSource parseShader(const std::string& filepath) {
    std::ifstream file(filepath);
    std::stringstream ss[2];
    int type = -1;
    std::string line;
    while (std::getline(file, line)) {
        if (line.find("#shader vertex") != std::string::npos) type = 0;
        else if (line.find("#shader fragment") != std::string::npos) type = 1;
        else if (type != -1) ss[type] << line << '\n';
    }
    return { ss[0].str(), ss[1].str() };
}

static unsigned int compileShader(unsigned int type, const std::string& source) {
    unsigned int id = glCreateShader(type);
    const char* src = source.c_str();
    glShaderSource(id, 1, &src, nullptr);
    glCompileShader(id);
    int result;
    glGetShaderiv(id, GL_COMPILE_STATUS, &result);
    if (!result) {
        int length;
        glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
        char* message = new char[length];
        glGetShaderInfoLog(id, length, &length, message);
        std::cerr << "Shader compile error: " << message << '\n';
        delete[] message;
        return 0;
    }
    return id;
}

static unsigned int createProgram(const std::string& vertSrc, const std::string& fragSrc) {
    unsigned int program = glCreateProgram();
    unsigned int vs = compileShader(GL_VERTEX_SHADER, vertSrc);
    unsigned int fs = compileShader(GL_FRAGMENT_SHADER, fragSrc);
    glAttachShader(program, vs);
    glAttachShader(program, fs);
    glLinkProgram(program);
    glDeleteShader(vs);
    glDeleteShader(fs);
    return program;
}

int main()
{
    if (!glfwInit()) { std::cerr << "GLFW init failed\n"; return -1; }
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL Window", nullptr, nullptr);
    if (!window) { glfwTerminate(); return -1; }
    glfwMakeContextCurrent(window);
    glewExperimental = GL_TRUE;
    if (glewInit() != GLEW_OK) { glfwTerminate(); return -1; }
    std::cout << "OpenGL: " << glGetString(GL_VERSION) << '\n';
    glfwSwapInterval(1);

    // 4 unique vertex positions (down from 6 in Lab 04)
    float vertices[] = {
        -0.5f, -0.5f, 0.0f,   // 0: bottom-left
         0.5f, -0.5f, 0.0f,   // 1: bottom-right
         0.5f,  0.5f, 0.0f,   // 2: top-right
        -0.5f,  0.5f, 0.0f    // 3: top-left
    };

    // 6 indices reference the 4 positions without repeating position data
    unsigned int indices[] = {
        0, 1, 2,   // triangle 1
        2, 3, 0    // triangle 2
    };

    // VAO records the vertex layout and the IBO reference
    unsigned int vao;
    glGenVertexArrays(1, &vao);
    glBindVertexArray(vao);

    // VBO holds the 4 unique vertex positions
    unsigned int vbo;
    glGenBuffers(1, &vbo);
    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);

    // IBO holds the 6 indices - must be bound while the VAO is active
    unsigned int ibo;
    glGenBuffers(1, &ibo);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // unbind VAO first to preserve the IBO reference stored in it
    glBindVertexArray(0);

    // load shader and cache the uniform location once after linking
    ShaderSource src = parseShader("res/shaders/triangle.shader");
    unsigned int shader = createProgram(src.vertex, src.fragment);
    int colourLoc = glGetUniformLocation(shader, "uColour");

    while (!glfwWindowShouldClose(window))
    {
        glClearColor(0.1f, 0.1f, 0.15f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        // activate the program before setting uniforms
        glUseProgram(shader);
        glUniform4f(colourLoc, 1.0f, 0.5f, 0.0f, 1.0f);  // orange

        glBindVertexArray(vao);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    glDeleteVertexArrays(1, &vao);
    glDeleteBuffers(1, &vbo);
    glDeleteBuffers(1, &ibo);
    glDeleteProgram(shader);
    glfwDestroyWindow(window);
    glfwTerminate();
    return 0;
}