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:
- Explain the four-stage lifecycle of an OpenGL shader program: source parsing, stage compilation, program linking, and deletion
- Describe the custom #shader vertex / #shader fragment file format and trace how parseShaders splits the file into two source strings
- Explain why glGetUniformLocation is cached in an unordered_map and what happens on repeated calls to the same uniform
- Use the Shader class to load a .shader file and set mat4, vec3, and sampler2D uniforms without any raw glUniform calls in main.cpp
- Identify the two constructors and explain when to use each
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.
- Create
src/Shader.hand add#pragma once. Include<iostream>,<string>,<fstream>,<sstream>,<filesystem>, and<unordered_map>. Also include the GLM headers needed forglm::mat4. - Define the
ShaderProgramSourcestruct with twostd::stringmembers:VertexSourceandFragmentSource. - Declare the
Shaderclass. The three data members in theprotectedsection (m_Filepath,m_RendererID,m_uniformLocationCache) are protected rather than private so that theComputeShadersubclass added in a later lab can access them. - Note the protected default constructor
Shader() : m_RendererID(0) {}. This letsComputeShaderconstruct the base without triggering the vertex+fragment parsing path. - 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).
- Declare the six
setUniform*methods for the uniform types you have used in earlier labs.
#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.
- Create
src/Shader.cppand include"Shader.h"and"Renderer.h". - Implement
parseShaders: open the file as anstd::ifstream, declare a two-elementstd::stringstreamarray (index 0 = vertex, index 1 = fragment), and aShaderTypeenum starting atNONE = -1. Loop over lines: on a#shaderdirective, update the type enum. On any other line, append to the matching stream. Return both streams as.str()in aShaderProgramSource. - Implement
compileShader: callglCreateShader, upload source withglShaderSource, callglCompileShader, then checkGL_COMPILE_STATUSwithglGetShaderiv. If compilation failed, retrieve the info log, print it, delete the shader, and return 0. - Implement
CreateShader: create a program, compile both stages by callingcompileShader, attach both withglAttachShader, link withglLinkProgram, validate withglValidateProgram, and return the program ID. - Implement
getUniformLocation: check the cache first. If the name is not found, callglGetUniformLocation, warn if the result is-1, store it in the cache, and return it. - Add both files to your project. Replace the standalone
parseShaderandcreateProgramfunctions inmain.cppwith aShader shader("res/shaders/transform.shader")declaration. Replace allglGetUniformLocationandglUniform*calls with the correspondingshader.setUniform*calls. - Build and run. The output must be identical to Lab 08.
#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.