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

Lab 03 - Shaders

Overview

In the First Triangle lab (Lab 02) you relied on OpenGL's built-in fixed-function pipeline to colour the triangle white. This lab replaces that with your own GLSL shader program. You will write a vertex shader and a fragment shader, store them together in a single .shader file, write a C++ parser to load them, compile both stages with error checking, and link them into a program that drives every pixel of the triangle.

The project also returns to Core Profile, which disables the fixed-function fallback entirely. From Lab 03 onward, all rendering requires an explicit shader program.

Prerequisites

Learning Objectives

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

Concept

Switching back to Core Profile removes the fixed-function pipeline entirely. Every draw call now requires an active shader program or nothing renders. A program contains at minimum two stages. The vertex shader runs once for each vertex the draw call submits. It receives the position from attribute slot 0 and must write a clip-space position to the built-in output gl_Position. OpenGL then determines which screen pixels the assembled triangle covers.

The fragment shader runs once for each of those covered pixels. It must write a colour to a user-declared out vec4 variable. Both shaders are written in GLSL and compiled at runtime by the GPU driver via glCreateShader, glShaderSource, and glCompileShader. Compile errors are returned as strings via glGetShaderInfoLog. The two compiled shaders are attached to a program object with glAttachShader and linked with glLinkProgram. Calling glUseProgram before a draw call activates that program for the pipeline.

Rather than managing two separate source files per shader, this project uses a single .shader file with custom section markers. The lines #shader vertex and #shader fragment are not valid GLSL. They are directives read by a C++ parser that distributes each subsequent line to the correct source string. The OpenGL compile API receives two clean GLSL strings with no trace of the markers.

Tasks

Task 1 - Switch Back to Core Profile

Open src/main.cpp and revert the profile hint you changed in the First Triangle lab (Lab 02). Core Profile is required from this lab onward.

  1. Find the line: glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);
  2. Change it back to GLFW_OPENGL_CORE_PROFILE as shown below.
// file: src/main.cpp -- update the profile hint
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

If you run the project now, the window will open but the triangle will disappear. That is expected: Core Profile demands a shader program, and none is bound yet.

Task 2 - Create the Shader File

In the project root, create a new text file at res/shaders/triangle.shader. Copy the content below exactly. The #shader vertex and #shader fragment lines are custom markers read by the C++ parser you will write in Task 3. They are never passed to the GLSL compiler.

The fragment shader outputs a flat blue colour defined by the vec4 constructor: red 0.2, green 0.6, blue 1.0, alpha 1.0. Change these values to any colour you prefer.

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

#shader fragment
#version 430 core
out vec4 FragColour;
void main() {
    FragColour = vec4(0.2, 0.6, 1.0, 1.0);
}
GLSL quick reference. #version 430 core selects GLSL 4.30 with Core Profile semantics. layout (location = 0) in vec3 aPos declares an input attribute at slot 0, matching the index you passed to glVertexAttribPointer in Lab 02. gl_Position is a built-in vec4 output of the vertex shader that holds the final clip-space position. out vec4 FragColour declares the fragment shader's colour output. vec4(r, g, b, a) constructs a four-component float vector.

Task 3 - Write the Shader File Parser

Add the includes and the parseShader function to src/main.cpp, above main(). The function reads the .shader file line by line and routes each line to either the vertex or fragment string depending on which #shader marker was seen most recently.

  1. Add #include <fstream> and #include <sstream> at the top of the file.
  2. Copy the struct and function signature below, then implement the loop body marked with // TODO.
// file: src/main.cpp -- add above main()
#include <fstream>
#include <sstream>

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)) {
        // TODO: if line contains "#shader vertex", set type = 0
        // TODO: if line contains "#shader fragment", set type = 1
        // TODO: otherwise if type is 0 or 1, write line + '\n' to ss[type]
    }
    return { ss[0].str(), ss[1].str() };
}

Hint: use line.find("#shader vertex") != std::string::npos to test whether the marker appears anywhere in the current line.

Task 4 - Add the Shader Compiler with Error Checking

Copy the function below into src/main.cpp, above main() and after the parser. It compiles one shader stage and prints any GLSL error message the driver reports. The function is provided in full because the error-checking API is boilerplate you will reuse unchanged in every project.

// file: src/main.cpp -- provided for reference, add above main()
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;
}

To test the error reporting, try introducing a deliberate typo in the fragment shader (for example, remove the semicolon after FragColour = ...), run the project, and read the console output. Fix the typo before continuing.

Task 5 - Write the Program Linker

Add the createProgram function below the compiler. It takes the two GLSL source strings, compiles each stage, attaches them to a program object, links the program, then deletes the now-redundant shader objects.

  1. Create a program object with glCreateProgram().
  2. Compile the vertex shader: compileShader(GL_VERTEX_SHADER, vertSrc).
  3. Compile the fragment shader: compileShader(GL_FRAGMENT_SHADER, fragSrc).
  4. Attach both to the program with glAttachShader.
  5. Link with glLinkProgram.
  6. Delete both shader objects with glDeleteShader (the linked program retains its own copy of the compiled code).
  7. Return the program ID.
// file: src/main.cpp -- add above main(), after compileShader
static unsigned int createProgram(const std::string& vertSrc, const std::string& fragSrc) {
    // TODO: create a program with glCreateProgram
    // TODO: compile vertex shader using compileShader(GL_VERTEX_SHADER, vertSrc)
    // TODO: compile fragment shader using compileShader(GL_FRAGMENT_SHADER, fragSrc)
    // TODO: attach both shaders to the program with glAttachShader
    // TODO: call glLinkProgram
    // TODO: delete both shader objects with glDeleteShader
    // TODO: return the program ID
}

Task 6 - Load and Use the Shader Program

Wire everything together in main(). Call the parser and linker after the VAO and VBO setup, then activate the program each frame before drawing.

  1. After the VAO and VBO setup code (and before the render loop), parse the shader file and create the program:
// file: src/main.cpp -- add after VAO/VBO setup, before the render loop
ShaderSource src = parseShader("res/shaders/triangle.shader");
unsigned int shader = createProgram(src.vertex, src.fragment);
  1. Inside the render loop, add glUseProgram(shader) before glBindVertexArray and glDrawArrays:
// TODO: call glUseProgram(shader)
// TODO: call glBindVertexArray(vao)  -- already present from Lab 02
// TODO: call glDrawArrays(GL_TRIANGLES, 0, 3)  -- already present from Lab 02
  1. In the cleanup section after the render loop, add glDeleteProgram(shader) alongside the existing glDeleteVertexArrays and glDeleteBuffers calls.

Expected Result

The triangle now renders in a solid blue colour (or whichever colour you chose in the fragment shader) against the dark background. It is no longer white: the GLSL fragment shader is controlling the output instead of the fixed-function pipeline.

If the triangle is missing, check the console for shader compile errors. The most common cause is a path mismatch: verify that the working directory in Visual Studio is the project root and that the file exists at exactly res/shaders/triangle.shader. If the file opens but the shader fails to compile, the console will print the driver's error message with a line number.

Extension Challenge

Optional: Give the triangle a colour gradient by passing the vertex position from the vertex shader to the fragment shader. Declare out vec3 vPos in the vertex shader and assign vPos = aPos after writing gl_Position. In the fragment shader, declare the matching in vec3 vPos input and compute the colour from position: FragColour = vec4(vPos.x * 0.5 + 0.5, vPos.y * 0.5 + 0.5, 0.5, 1.0). The x and y NDC coordinates (ranging from roughly -0.5 to +0.5 across the triangle) are remapped to 0-1 for the colour channels, producing a gradient that shifts from dark at the lower-left to warm at the upper-right. Variables declared as out in the vertex shader and in in the fragment shader are called varyings. The GPU interpolates their values smoothly across the surface of the triangle between each vertex.