The geometry to render most natural objects is usually quite complicated, e.g. every blade of grass within a field or the surface variations of the bricks in a wall. Therefore, most graphics systems provide an incredibly powerful way of simulating complex geometry by painting a texture onto a geometrically simple surface, e.g. an image of grass on a flat rectangle. This technique is called texture mapping (think of wallpapering a surface). Since OpenGL does not provide a way to load images from files, we will be using the stb_image.h library to provide routines to load our images. Once we have loaded the image data from the file, we will create corresponding textures from these images (in this lab an earth and moon) which will then be passed as sampler shader variables. OpenGL has capabilities to handle 1D, 2D, and even 3D textures, however we will only be concerned with 2D textures (images) in this course.

The image is represented as a texture map which has normalized extents (0,0) in the lower left corner and (1,1) in the upper right corner of the image (scaled to the square regardless of the original aspect ratio). Each element of the texture map is known as a texel. To apply a texture to a surface, we simply need to associate with each vertex a corresponding texel and allow the graphics system to perform any necessary distortions to place the texture (or part of the texture) onto the rendered surface. Fortunately, the .obj files generated from Blender will include texture coordinates for the models we will be using.

Getting Started

Download CS370_Lab13.zip, saving it into the CS370_Fa24 directory.

Double-click on CS370_Lab13.zip and extract the contents of the archive into a subdirectory called CS370_Lab13

Open CLion, select CS370_Fa24 from the main screen (you may need to close any open projects), and open the CMakeLists.txt file in this directory (not the one in the CS370_Lab13 subdirectory). Uncomment the line

	add_subdirectory("CS370_Lab13" "CS370_Lab13/bin")

Finally, select Reload changes which should build the project and add it to the dropdown menu at the top of the IDE window.

Solution

Download CS370_Lab13_Solution.zip, saving it into the CS370_Fa24 directory.

Double-click on CS370_Lab13_Solution.zip and extract the contents of the archive into a subdirectory called CS370_Lab13_Solution

Open CLion, select CS370_Fa24 from the main screen (you may need to close any open projects), and open the CMakeLists.txt file in this directory (not the one in the CS370_Lab13_Solution subdirectory). Uncomment the line

	add_subdirectory("CS370_Lab13_Solution" "CS370_Lab13_Solution/bin")

Finally, select Reload changes which should build the project and add it to the dropdown menu at the top of the IDE window.

Loading Images

To simplify the process of creating a texture from an image, we will be using a helper function

void load_texture(const char * filename, GLuint texID, GLint magFilter, GLint minFilter, GLint sWrap, GLint tWrap, bool useMipMaps, bool invertImage);

where filename is a string containing the name of the image file (typically defined at the top of the code), texID is the index from the texture id array that we wish to load the texture into (corresponding to a Textures enum value at the top of the code), magFilter is the magnification filter setting, minFilter is the minification filter (and mipmapping) setting, sWrap is the horizontal wrapping mode setting, tWrap is the vertical wrapping mode setting, useMipMaps is a flag specifying if mipmaps should be created, and invertImage is a flag specifying if the image needs to be flipped vertically after being loaded (as some image formats are stored bottom-to-top). All of these settings will be subsequently discussed in detail.

In order to create a texture from an image, we will import images into memory using the stb_image.h library which will allow loading of a variety of file formats (including .jpg, .png, .bmp and others) via the command

unsigned char *data = stbi_load(char *filename, int &x, int &y, int &n, int chan);

where filename is a string containing the name of the image file, x is the returned width of the image, y is the returned height of the image, n is the returned number of color channels per pixel, and chan is the number of desired color channels per pixel (or 0 for the native number of channels in the image).

While the library provides a simple way to import images, it works best if the image dimensions are powers of 2.

Note: Take a look at the beginning of the load_texture() function to see the usage of the loader.

Creating Texture Maps

All textures, regardless of resolution, are mapped to a grid of texture coordinates (texels) known as the s-t plane with the s-axis representing the horizontal direction and the t-axis representing the vertical direction as shown below:

Texture Plane

The texture coordinates thus are in the range [0,1] for both axes. They are then applied to the objects in the fragment processor through a mapping of vertices to texture coordinates.

Just like with the other buffers we have seen, e.g. vertex positions, colors, normals, etc., we will need to create references to our texture buffers using

void glGenTextures(GLsizei n, GLuint *textures);

where n is the number of textures we wish to create and textures will be the array where the generated texture id’s will be stored.

OpenGL supports applying multiple textures to a single object (which we will cover in a later lab) through multiple texture units. Thus we need to specify which texture unit will be active using

void glActiveTexture(GLenum texture);

where texture is a symbolic constant of the form GL_TEXTUREi where i is the number of the texture unit we wish to make active, e.g. GL_TEXTURE0.

Loading Image Data into a Texture

Once we have loaded the image data, created the texture id’s, and selected the active texture unit, need to bind the texture that we wish to use into the texture unit using

void glBindTexture(GLenum target, GLuint texture);

where target is a symbolic constant denoting the type of texture we are binding (e.g. for image data GL_TEXTURE_2D) and texture is the texture id for the texture we are binding.

We are now ready to load the image data into the currently bound texture using

void glTexImage2D(GLenum target, GLint level, GLint internalFormat, GLsizei width, GLsizei height, GLint border, GLenum format, Glenum type, const void *data);

where target is again the type of texture we are using (typically GL_TEXTURE_2D), level is the mipmap level we are loading the data into (to be discussed later), internalFormat specifies the format OpenGL should represent the texture with, width is the width of the image we are loading into the texture, height is the height of the image we are loading into the texture, border specifies if there is a padding around the image (useful for wrapping discussed later), format is the format of the image data, type is data type of the color channels in the image, and data is the image data.

Texture Scaling and Mipmaps

Since textures are applied in the fragment processor, they are placed on the rendered surfaces of objects. Many times the rendered resolution will not match the resolution of the texture map, so OpenGL needs to know how to account for this difference. Two cases can occur - either the rendered surface is higher resolution (requiring the texture to be magnified) or lower resolution (requiring the texture to be minified) - and corresponding modes must be set for both cases.

Magnification

In the case of maginification, the system must upscale the image using either the nearest corresponding texel or linear extrapolation from surrounding texels. Often times if scaling is severe, the texture will appear pixelated in which case an additional higher resolution texture should be created by the programmer and loaded into the application. Small magnifications, however, can be handled by setting

void glTexParameteri(GLenum target, GLenum pname, Type param);

where target is again the type of texture we are using (typically GL_TEXTURE_2D), pname is the parameter we wish to set (e.g. GL_TEXTURE_MAG_FILTER for magnification), and param is the value we wish to set the parameter to (e.g. GL_NEAREST or GL_LINEAR).

Minification

Minification, when the texture is larger than the rendered surface, typically occurs more frequently, e.g. high-res textures for small (in pixels) objects. One possibility is to simply use a reduction filter similar to magnification (GL_TEXTURE_MIN_FILTER with GL_NEAREST or GL_LINEAR) to produce reduced resolution images, however repeatedly performing this resizing for numerous objects is inefficient. OpenGL provides an alternative mechanism known as mipmapping which precomputes reduced resolution textures (also storing them on the graphics card) and then applies the one that most closely matches the rendered object’s resolution. We can have the system generate mipmap levels automatically using

void glGenerateMipmap(GLenum target);

where target is again the type of texture we are using (typically GL_TEXTURE_2D).

Mipmaps

Mipmaps work best when the original image dimensions are powers of 2 (but they are not required to be). The mipmaps create reduced resolution textures by repeatedly dividing each dimension in half either a prespecified number of times or until one of the dimensions becomes 1.

Occasionally, auto generating mipmaps can produce undesirable images (particularly for ones with fine detail), thus we can load alternative images into specific mipmap levels for the same texture using the level parameter with glTexImage2D() - which is particularly useful for better controlling the appearance at very small resolutions.

The minification and mipmap mode are then set via the glTexParameteri() command for the GL_TEXTURE_MIN_FILTER parameter to either nearest or linear for both the texel and mipmap level using either GL_NEAREST_MIPMAP_NEAREST, GL_LINEAR_MIPMAP_NEAREST, GL_NEAREST_MIPMAP_LINEAR, or GL_LINEAR_MIPMAP_LINEAR.

BE CAREFUL: mipmaps can use a substantial amount of graphics memory and produce significant artifacting, so it is best to try to load textures that are roughly the necessary size for the objects being rendered, i.e. avoid loading high resolution textures for low resolution objects.

Texture Wrapping

Lastly, we need to specify how textures will wrap, i.e. repeat, when applied to objects whose geometry is larger than the texture map extents. Two alternatives are to repeat the entire texture map (i.e. tile the surface) or to simply clamp (i.e. extend) the coloring of the edge. Each of these options can be set separately for the s and t directions (refer to the texture plane discussion above) via the glTexParameteri() command for the GL_TEXTURE_WRAP_S and GL_TEXTURE_WRAP_T parameters to either GL_REPEAT or GL_CLAMP.

Tasks

Using Texture Maps

Textures are associated with an object on a per vertex basis (similar to normals for lighting) by assigning a texture coordinate to each vertex (often known as a uv-coordinate). This assignment will relate which parts of the texture map is connected to which vertices in the object. Texture coordinates outside the range [0,1] are mapped to points on the extended texture map determined by the wrapping properties discussed in the previous section. Once each vertex in the object is assigned a corresponding texture coordinate, OpenGL will copy the appropriate portion of the texture map (or mipmap) onto the rendered surface, interpolating or extrapolating as needed based on the scaling properties discussed in the previous section. The assignment of texture coordinates is done (similar to setting the normal) through an attribute buffer.

Note: The model loader we are using will return a vector of uv-coordinates for each vertex.

Prior to rendering the object, we must make the desired texture unit (0 for now) active, and then bind the texture coordinate buffer along with the texture we wish to use (which will associate it with a shader sampler variable discussed below) again using

void glActiveTexture(GLenum tex_unit);

where tex_unit is a symbolic constant of the form GL_TEXTUREi where i is the number of the texture unit we wish to make active, e.g. GL_TEXTURE0, and

void glBindTexture(GLenum target, GLuint texture);

where target is a symbolic constant denoting the type of texture we are binding (e.g. for image data GL_TEXTURE_2D) and texture is the texture id for the texture we are binding.

We will use a helper function to draw texture mapped objects

void draw_tex_object(GLuint obj, GLuint texture);

where obj is the constant from the VAO_IDs enum for the object we wish to use and texture is the constant from the Textures enum for the texture we wish to apply to the object.

Tasks

Again, like colors, we can reuse the same geometry with different textures.

Texture Maps in Shaders

The currently bound texture maps are associated with a shader variable known as a sampler of type uniform sampler2D for our image data. This sampler variable will retrieve, i.e. sample, the image at the specified texel and apply any scaling and wrapping modes to generate the pixel color. This is done using the shader function

vec4 texture(sampler2D texture, vec2 texCoord);

where texture is the sampler variable and texCoord is the texture coordinate (passed from the vertex shader).

Tasks

At this point you should see a texture mapped moon orbiting a texture mapped rotating earth.

Backgrounds

One way to add a background image to our scenes is to use a simple quad that is the same size as the window. This can be accomplished by adjusting the projection matrix extents and camera position back to their default values and then rendering a square with corners (-1,-1) and (1,1). Since we will want the background to be behind any rendered geometry, typically we will render the background image first without updating the depth buffer (similar to translucent objects).

Tasks

Compiling and running the program

You should be able to build and run the program by selecting earthMoon from the dropdown menu and clicking the small green arrow towards the right of the top toolbar.

At this point you should see a textured moon orbiting a textured earth (note: they are not astronomically correct) with a tiled space background.

Earth Moon Scene Window

To quit the program simply close the window.

Congratulations, you have now written an application with texture mapping.

Next we will investigate how to create scenes using a scene graph data structure.