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

Lab 06 - Textures

Overview

The Index Buffers and Uniforms lab (Lab 05) gave the quad a runtime colour through a uniform vec4. The colour was still a flat solid. This lab replaces that flat colour with a sampled image by introducing the texture pipeline: UV coordinates stored per vertex, an image loaded from disk with stb_image, an OpenGL texture object on the GPU, and a sampler2D uniform that lets the fragment shader look up the right pixel for each fragment.

By the end of the lab your quad will display a loaded PNG with correct orientation and alpha blending.

Learning Objectives

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

Concept

So far every fragment has been coloured by a value computed entirely in the shader. A texture is a 2D image stored on the GPU that the fragment shader can query to retrieve colour data. Without textures, representing surface detail such as wood grain, skin, or printed labels would require thousands of extra vertices. With textures, a single quad can display an arbitrarily detailed image while remaining just four vertices in the VBO.

The connection between vertices and image pixels is made through UV coordinates (also called texture coordinates). Each vertex carries a vec2 UV value in addition to its position. UV coordinates range from 0.0 to 1.0 across each axis regardless of the image resolution: (0,0) is the bottom-left corner and (1,1) is the top-right. The GPU interpolates the UV across each triangle so that every fragment receives a unique coordinate, which the shader uses to look up the correct texel. This lookup is performed with the built-in GLSL function texture(sampler, uv), where sampler is a sampler2D uniform bound to a texture unit.

On the C++ side, stb_image is a single-header library that decodes common image formats (PNG, JPG, BMP) into a raw byte array. You call stbi_set_flip_vertically_on_load(1) before loading because OpenGL expects the first row of pixel data to be the bottom of the image, while most image files store rows top-to-bottom. The decoded bytes are uploaded to the GPU with glTexImage2D. You then configure how OpenGL samples the texture when it is displayed at a different size with glTexParameteri: the GL_TEXTURE_MIN_FILTER and GL_TEXTURE_MAG_FILTER parameters control interpolation, and the GL_TEXTURE_WRAP_S and GL_TEXTURE_WRAP_T parameters control what happens when UV coordinates fall outside the 0-1 range. Once the texture is on the GPU, the CPU-side image data can be freed with stbi_image_free.

Tasks

Task 1 - Add UV Coordinates to Vertex Data

The vertex array currently stores 3 floats per vertex (x, y, z). Extend each vertex to 4 floats by appending a U and V coordinate. These will map the corners of the quad to the corners of the image.

  1. Replace the vertices array in src/main.cpp with the 4-float-per-vertex layout below. Each row contains x, y, u, v.
  2. Update the glBufferData call so the size argument still uses sizeof(vertices) rather than a hard-coded number.
  3. The existing glVertexAttribPointer for attribute 0 (position) must now declare a stride of 4 * sizeof(float) (16 bytes) because each vertex now occupies 4 floats, not 3. Update the stride and change the component count from 3 to 2 (the position is now 2D).
  4. After the position attribute call, add a second glVertexAttribPointer for attribute 1 (UV). The offset is (void*)(2 * sizeof(float)) because the UV data starts 2 floats into each vertex.
  5. Enable attribute 1 with glEnableVertexAttribArray(1).
src/main.cpp
// Each vertex: position (x, y) followed by UV coordinates (u, v)
float vertices[] = {
    -0.5f, -0.5f,  0.0f, 0.0f,  // bottom-left
     0.5f, -0.5f,  1.0f, 0.0f,  // bottom-right
     0.5f,  0.5f,  1.0f, 1.0f,  // top-right
    -0.5f,  0.5f,  0.0f, 1.0f   // top-left
};

// ... (keep the indices array and IBO setup from Lab 05 unchanged)

// Attribute 0: position - 2 floats, stride = 4 floats, offset = 0
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);

// TODO: declare and enable attribute 1 for UV coordinates
//       2 components, same stride, offset = 2 * sizeof(float)

Task 2 - Write a Texture Shader

Create a new shader file res/shaders/texture.shader. The vertex section must accept the UV attribute and pass it to the fragment section as a varying. The fragment section must sample a sampler2D uniform and output the result as the fragment colour.

  1. Create the file res/shaders/texture.shader.
  2. In the #shader vertex section, add a second input attribute at location 1: layout(location = 1) in vec2 texCoords;.
  3. Declare out vec2 v_TexCoords and assign texCoords to it in main(). The GPU interpolates this across the triangle for every fragment.
  4. In the #shader fragment section, declare in vec2 v_TexCoords and uniform sampler2D u_Texture.
  5. In the fragment main(), call texture(u_Texture, v_TexCoords) and assign the result to FragColour.
  6. Update the parseShader call in main.cpp to load "res/shaders/texture.shader".
res/shaders/texture.shader
#shader vertex
#version 430 core
layout(location = 0) in vec2 position;
// TODO: declare layout(location = 1) in vec2 texCoords

// TODO: declare out vec2 v_TexCoords

void main() {
    gl_Position = vec4(position, 0.0, 1.0);
    // TODO: pass texCoords to the fragment stage via v_TexCoords
}

#shader fragment
#version 430 core
out vec4 FragColour;

// TODO: declare in vec2 v_TexCoords
// TODO: declare uniform sampler2D u_Texture

void main() {
    // TODO: assign texture(u_Texture, v_TexCoords) to FragColour
}

Task 3 - Load a Texture with stb_image

Before the render loop, load the image from disk and upload it to the GPU as an OpenGL texture object. Place any PNG or JPG at res/Textures/1.png.

  1. Add #include "vendor/stb_image.h" near the top of src/main.cpp. The implementation is already compiled in src/vendor/stb_image.cpp, so you do not need the #define STB_IMAGE_IMPLEMENTATION guard here.
  2. After the VAO and shader setup, call stbi_set_flip_vertically_on_load(1). This corrects the vertical flip because OpenGL reads rows bottom-up while PNG files store rows top-down.
  3. Call stbi_load with the path and 4 as the channel count to force RGBA output regardless of the source format.
  4. Generate and configure an OpenGL texture using the skeleton below. GL_CLAMP_TO_EDGE prevents a thin border artifact at the quad edges. GL_LINEAR blends neighbouring texels for a smooth result when the quad is scaled.
  5. Free the CPU-side image data: if (imgData) stbi_image_free(imgData);
  6. Add glDeleteTextures(1, &texture) to the cleanup section after the render loop.
src/main.cpp
stbi_set_flip_vertically_on_load(1);
int imgWidth, imgHeight, imgChannels;
unsigned char* imgData = stbi_load("res/Textures/1.png", &imgWidth, &imgHeight, &imgChannels, 4);

unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

// TODO: call glTexImage2D to upload imgData to the GPU
//       target: GL_TEXTURE_2D, level: 0, internal format: GL_RGBA8
//       width/height: imgWidth/imgHeight, border: 0
//       format: GL_RGBA, type: GL_UNSIGNED_BYTE, data: imgData

glBindTexture(GL_TEXTURE_2D, 0);
if (imgData) stbi_image_free(imgData);

Task 4 - Bind the Texture and Set the Sampler Uniform

OpenGL uses texture units to let a single shader sample multiple textures simultaneously. You activate a unit with glActiveTexture(GL_TEXTURE0), bind your texture to it, and tell the shader which unit to read from by setting the sampler2D uniform to the unit index (0 for GL_TEXTURE0).

  1. After the shader program is created but before the render loop, enable alpha blending so transparent pixels render correctly:
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
  2. Still before the loop, link the sampler uniform to texture unit 0. The uniform must be set while the program is active:
    glUseProgram(shader);
    int texLoc = glGetUniformLocation(shader, "u_Texture");
    glUniform1i(texLoc, 0);
    glUseProgram(0);
  3. Inside the render loop, before the draw call, activate unit 0 and bind your texture:
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, texture);
  4. Build and run. Your image should appear on the quad. If it is upside-down, confirm that stbi_set_flip_vertically_on_load(1) is called before stbi_load.

Solution

Try the tasks above before revealing the solution.