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
- Lab 02 complete: a white triangle renders in the centre of the window using a VAO and VBO.
- The VAO and VBO setup code from Lab 02 is present in
src/main.cppand working. - The
res/shaders/directory exists in the project root (create it if not present).
Learning Objectives
By the end of this lab you will be able to:
- Describe the roles of the vertex shader and fragment shader within the OpenGL pipeline.
- Write basic GLSL for both stages and explain the purpose of
gl_Positionand a fragment output variable. - Parse a custom
.shaderfile format to separate vertex and fragment source at runtime. - Compile shader stages and read driver error messages using
glGetShaderInfoLog. - Link a shader program and activate it before a draw call with
glUseProgram.
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.
- Find the line:
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE); - Change it back to
GLFW_OPENGL_CORE_PROFILEas 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);
}
#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.
- Add
#include <fstream>and#include <sstream>at the top of the file. - 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.
- Create a program object with
glCreateProgram(). - Compile the vertex shader:
compileShader(GL_VERTEX_SHADER, vertSrc). - Compile the fragment shader:
compileShader(GL_FRAGMENT_SHADER, fragSrc). - Attach both to the program with
glAttachShader. - Link with
glLinkProgram. - Delete both shader objects with
glDeleteShader(the linked program retains its own copy of the compiled code). - 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.
- 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);
-
Inside the render loop, add
glUseProgram(shader)beforeglBindVertexArrayandglDrawArrays:
// 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
-
In the cleanup section after the render loop, add
glDeleteProgram(shader)alongside the existingglDeleteVertexArraysandglDeleteBufferscalls.
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.