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

Lab 12 - Shader Class

Overview

In earlier labs, the shader was created by a standalone parseShader function and a raw unsigned int program handle stored directly in main.cpp. Setting a uniform required calling glGetUniformLocation every frame, which is slow because it has to search the compiled program's symbol table on each call. This lab wraps the full shader lifecycle - parsing, compiling, linking, and uniform management - into a single Shader class. You will also study the custom #shader vertex and #shader fragment file format that lets both stages live in one .shader file, and learn how the class caches uniform locations so the expensive driver lookup happens only once per uniform name.

Learning Objectives

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

Concept

Creating a shader program in raw OpenGL involves many steps: open the file, split it into vertex and fragment source strings, call glCreateShader twice (once per stage), upload the source with glShaderSource, compile with glCompileShader, check the compile status with glGetShaderiv, retrieve the info log if it failed, create a program with glCreateProgram, attach both compiled shaders, call glLinkProgram, and eventually delete the program in cleanup. This same boilerplate appears in every OpenGL tutorial. The Shader class encapsulates all of it so the call site can be a single constructor invocation.

The file format used in this project puts both shader stages in one .shader file, separated by the lines #shader vertex and #shader fragment. The parseShaders method scans the file line by line. It tracks which stage is active using an enum, routes each line into the appropriate std::stringstream, and returns a ShaderProgramSource struct containing both completed source strings. This format is not a standard OpenGL feature - it is purely a convenience for keeping related code together. The #shader directives are stripped during parsing and never reach the GLSL compiler.

Every setUniform* method calls a private helper getUniformLocation, which looks up the uniform name in an std::unordered_map. On the first call for a given name, it invokes glGetUniformLocation, stores the resulting integer in the map, and returns it. On every subsequent call, it returns the cached value directly - glGetUniformLocation is never called again for that name. This matters in the render loop where setUniformMat4f("u_MVP", mvp) is called 60 times per second. Without caching, each call would search the compiled shader program's symbol table. With the cache, it is a single hash-map lookup.

Tasks

Task 1 - Study the Shader Header

Create src/Shader.h. Read through every line of the reference block below and understand what each section does before typing it out.

  1. Create src/Shader.h and add #pragma once. Include <iostream>, <string>, <fstream>, <sstream>, <filesystem>, and <unordered_map>. Also include the GLM headers needed for glm::mat4.
  2. Define the ShaderProgramSource struct with two std::string members: VertexSource and FragmentSource.
  3. Declare the Shader class. The three data members in the protected section (m_Filepath, m_RendererID, m_uniformLocationCache) are protected rather than private so that the ComputeShader subclass added in a later lab can access them.
  4. Note the protected default constructor Shader() : m_RendererID(0) {}. This lets ComputeShader construct the base without triggering the vertex+fragment parsing path.
  5. Declare the two public constructors: one that takes a filepath string, and one that takes raw C-string sources (note: argument order is fragment first, then vertex).
  6. Declare the six setUniform* methods for the uniform types you have used in earlier labs.
src/Shader.h
#pragma once

#include <iostream>
#include <string>
#include <fstream>
#include <sstream>
#include <filesystem>
#include <unordered_map>

#include "glm/glm.hpp"
#include "glm/gtc/matrix_transform.hpp"

// Holds the raw GLSL source for both shader stages after parsing
struct ShaderProgramSource
{
    std::string VertexSource;
    std::string FragmentSource;
};

class Shader
{
protected:
    // Protected so subclasses (e.g. ComputeShader) can access the program ID
    // and uniform cache without going through public getters
    std::string m_Filepath;
    unsigned int m_RendererID;
    std::unordered_map<std::string, int> m_uniformLocationCache;

    // Protected default constructor - lets subclasses handle their own compilation
    // without triggering the vertex+fragment parsing path
    Shader() : m_RendererID(0) {}

public:
    // Load and compile from a single file containing both #shader sections
    Shader(const std::string& filepath);

    // Compile directly from source strings (useful for hard-coded test shaders)
    Shader(const char* fragmentShaderSource, const char* vertexShaderSource);

    virtual ~Shader();

    void Bind()   const;
    void Unbind() const;

    // Uniform setters - each caches the location on first call
    void setUniform1i(const std::string& name, int i);
    void setUniformMat4f(const std::string& name, const glm::mat4& matrix);
    void setUniform4f(const std::string& name, float v0, float v1, float v2, float v3);
    void setUniform3f(const std::string& name, float v0, float v1, float v2);
    void setUniform2f(const std::string& name, float v0, float v1);
    void setUniform1f(const std::string& name, float v0);

    unsigned int GetID() const { return m_RendererID; }

private:
    unsigned int compileShader(unsigned int type, const std::string& source);
    ShaderProgramSource parseShaders(const std::string& filepath);
    unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader);
    unsigned int getUniformLocation(const std::string& name);
};

Task 2 - Implement the Shader Parser and Compiler

Create src/Shader.cpp. The implementation contains four distinct systems: the file parser, the per-stage compiler, the program linker, and the uniform cache. Read the reference block below and understand each private function before looking at the public methods that call them.

  1. Create src/Shader.cpp and include "Shader.h" and "Renderer.h".
  2. Implement parseShaders: open the file as an std::ifstream, declare a two-element std::stringstream array (index 0 = vertex, index 1 = fragment), and a ShaderType enum starting at NONE = -1. Loop over lines: on a #shader directive, update the type enum. On any other line, append to the matching stream. Return both streams as .str() in a ShaderProgramSource.
  3. Implement compileShader: call glCreateShader, upload source with glShaderSource, call glCompileShader, then check GL_COMPILE_STATUS with glGetShaderiv. If compilation failed, retrieve the info log, print it, delete the shader, and return 0.
  4. Implement CreateShader: create a program, compile both stages by calling compileShader, attach both with glAttachShader, link with glLinkProgram, validate with glValidateProgram, and return the program ID.
  5. Implement getUniformLocation: check the cache first. If the name is not found, call glGetUniformLocation, warn if the result is -1, store it in the cache, and return it.
  6. Add both files to your project. Replace the standalone parseShader and createProgram functions in main.cpp with a Shader shader("res/shaders/transform.shader") declaration. Replace all glGetUniformLocation and glUniform* calls with the corresponding shader.setUniform* calls.
  7. Build and run. The output must be identical to Lab 08.
src/Shader.cpp
#include "Shader.h"
#include "Renderer.h"

// Load from a .shader file containing both #shader vertex and #shader fragment sections
Shader::Shader(const std::string& filepath)
    : m_Filepath(filepath), m_RendererID(0)
{
    ShaderProgramSource source = parseShaders(filepath);
    m_RendererID = CreateShader(source.VertexSource, source.FragmentSource);
}

// Compile directly from C-string sources (note: argument order is fragment, vertex)
Shader::Shader(const char* fragmentShaderSource, const char* vertexShaderSource)
{
    m_RendererID = CreateShader(vertexShaderSource, fragmentShaderSource);
}

Shader::~Shader()
{
    GlCall(glDeleteProgram(m_RendererID));
}

void Shader::Bind() const
{
    GlCall(glUseProgram(m_RendererID));
}

void Shader::Unbind() const
{
    GlCall(glUseProgram(0));
}

// --- Uniform setters ---

void Shader::setUniform4f(const std::string& name, float v0, float v1, float v2, float v3)
{
    GlCall(glUniform4f(getUniformLocation(name), v0, v1, v2, v3));
}

void Shader::setUniform3f(const std::string& name, float v0, float v1, float v2)
{
    GlCall(glUniform3f(getUniformLocation(name), v0, v1, v2));
}

void Shader::setUniform2f(const std::string& name, float v0, float v1)
{
    GlCall(glUniform2f(getUniformLocation(name), v0, v1));
}

void Shader::setUniform1f(const std::string& name, float v0)
{
    GlCall(glUniform1f(getUniformLocation(name), v0));
}

void Shader::setUniform1i(const std::string& name, int i)
{
    GlCall(glUniform1i(getUniformLocation(name), i));
}

void Shader::setUniformMat4f(const std::string& name, const glm::mat4& matrix)
{
    GlCall(glUniformMatrix4fv(getUniformLocation(name), 1, GL_FALSE, &matrix[0][0]));
}

// --- Private implementation ---

// Returns the cached location, querying the driver only on first access
unsigned int Shader::getUniformLocation(const std::string& name)
{
    if (m_uniformLocationCache.find(name) != m_uniformLocationCache.end())
        return m_uniformLocationCache[name];

    GlCall(int location = glGetUniformLocation(m_RendererID, name.c_str()));
    if (location == -1)
        std::cout << "WARNING:: Uniform " << name << " is not defined\n";

    m_uniformLocationCache[name] = location;
    return location;
}

// Scan the file line by line, routing each line to the vertex or fragment stream
// depending on which #shader directive was seen most recently
ShaderProgramSource Shader::parseShaders(const std::string& filepath)
{
    if (!std::filesystem::exists(filepath))
        std::cerr << "Shader file does not exist: " << filepath << std::endl;

    std::ifstream stream(filepath);
    std::string line;
    std::stringstream ss[2]; // ss[0] = vertex, ss[1] = fragment

    enum class ShaderType { NONE = -1, VERTEX = 0, FRAGMENT = 1 };
    ShaderType type = ShaderType::NONE;

    while (getline(stream, line))
    {
        if (line.find("#shader") != std::string::npos)
        {
            if      (line.find("vertex")   != std::string::npos) type = ShaderType::VERTEX;
            else if (line.find("fragment") != std::string::npos) type = ShaderType::FRAGMENT;
        }
        else
        {
            ss[(int)type] << line << "\n";
        }
    }
    return { ss[0].str(), ss[1].str() };
}

// Compile one shader stage; prints the info log if compilation fails
unsigned int Shader::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 == GL_FALSE)
    {
        int length;
        glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
        char* message = (char*)_malloca(length * sizeof(char));
        glGetShaderInfoLog(id, length, &length, message);
        std::cout << "Failed to compile "
                  << (type == GL_VERTEX_SHADER ? "vertex" : "fragment") << " shader\n"
                  << message << "\n";
        glDeleteShader(id);
        return 0;
    }
    return id;
}

// Link vertex and fragment shaders into a complete program
unsigned int Shader::CreateShader(const std::string& vertexShader,
                                  const std::string& fragmentShader)
{
    unsigned int program = glCreateProgram();
    unsigned int vs = compileShader(GL_VERTEX_SHADER,   vertexShader);
    unsigned int fs = compileShader(GL_FRAGMENT_SHADER, fragmentShader);
    glAttachShader(program, vs);
    glAttachShader(program, fs);
    glLinkProgram(program);
    glValidateProgram(program);
    return program;
}

Solution

Try the tasks above before revealing the solution.