While we will take a closer look at rendering techniques in depth later on in the book, it always brings a sense of achievement when you can get some color on the screen. Not just a sprite and a pretty background, but painting 3D objects with light and texture.
In this chapter, I will go over the fundamental ways that OpenGL works with light and textures to give you more to experiment with as you continue to investigate these types of projects. You will learn how to add lighting and materials to the project we are developing throughout. It will provide you with the skills to develop better-rendered images that will assist you in visual confirmation that your code is working as it should.
In this chapter, we will cover the fundamentals of visualization, including the following:
In this chapter, we will be using Python, PyCharm, and Pygame, as used in previous chapters.
Before you begin coding, create a new folder in the PyCharm project for the contents of this chapter called Chapter_5.
The solution files containing the code can be found on GitHub at https://github.com/PacktPublishing/Mathematics-for-Game-Programming-and-Computer-Graphics/tree/main/Chapter05.
Lighting in computer graphics serves the same purpose as it does in the real world. It provides definition to the rendering of objects, making them seem embedded in the virtual world. The way that light is perceived depends on the surface treatment of the objects. These treatments are defined as materials and will be discussed in the next section.
Light sources consist of three primary lighting interactions:
Each of these can have a color assigned to produce differing effects. The results of each can be seen in Figure 5.1:
Figure 5.1: The components of light that make up a final render
With the exception of ambient light, which is applied uniformly over a model, the way light is seen on an object is by the way that it is reflected. There are numerous models, called shading models, that define the mathematics involved in generating this light on surfaces in graphics. The two types are called diffuse scattering and specular reflection. While these will be elucidated in later chapters, it is worth an overview of how they work before we try them in Python with OpenGL. Figure 5.2(a) illustrates the important directions involved when calculating light intensities. The light source shines at a point on a surface. The light from this point is reflected to the viewer. In the simplest illumination model, the light will be seen at its most intense when viewed at the same reflected angle around the surface normal (indicated by n).
Diffuse scattering (see Figure 5.2(b)) occurs when incoming light is partially absorbed by a surface and is re-radiated uniformly in all directions. Such light interacts with the surface material of the object and as such, the light’s color is affected by the surface color. It makes no difference which angle the surface is viewed from. In Figure 5.2(b), a viewer at v1 and a viewer at v2 will experience the same intensity of light coming from the surface:
Figure 5.2: The components of light that make up a final render
Specular reflections are highly directional. Unlike diffuse scattering, it does matter where the view is in relation to the light source. The intensity of the light will be strongest where the incoming light angle and reflected light angle are the greatest but then drop off away for angles greater or less than. For example, in Figure 5.2(c), a viewer at v1 will see a stronger light intensity than the viewer at v2.
It’s probably time to look at turning on the lights in a Python application.
In this exercise, we will explore how lights are enabled and used in OpenGL. Be sure to create a new folder called Chapter_Five in which to place the files for these exercises, and then follow these steps:
glBegin(GL_LINE_LOOP)
Change it to this:
glBegin(GL_POLYGON)
done = False
white = pygame.Color(255, 255, 255)
glMatrixMode(GL_PROJECTION)
gluPerspective(60, (screen_width / screen_height),
0.1, 100.0)
glMatrixMode(GL_MODELVIEW)
glTranslatef(0.0, 0.0, -3)
glEnable(GL_DEPTH_TEST)
mesh = Cube()
The code has become a little more complex and introduces a few new OpenGL calls, including the following:
..
glTranslatef(0, 0, -4)
glEnable(GL_DEPTH_TEST)
glEnable(GL_LIGHTING)
while not done:
..
Now, when you run the application, the lights will be on, but they might not look like they are because you will see a very dull version of the rotating cube. This is a kind of ambient lighting. Just because the lights are enabled doesn’t spontaneously create any lights. We have to do that manually.
..
glEnable(GL_DEPTH_TEST)
glEnable(GL_LIGHTING)
glLight(GL_LIGHT0, GL_POSITION, (5, 5, 5, 1))
glEnable(GL_LIGHT0)
while not done:
..
Let’s look at what we’ve done here:
..
glLight(GL_LIGHT0, GL_POSITION, (5, 5, 5, 1))
glLightfv(GL_LIGHT0, GL_AMBIENT, (1, 0, 1, 1))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (1, 1, 0, 1))
glLightfv(GL_LIGHT0, GL_SPECULAR, (0, 1, 0, 1))
glEnable(GL_LIGHT0)
..
Figure 5.3: A cube with lighting enabled
Each of the given light types is succeeded by a four-valued red, green, blue, and alpha (transparency) value. The light will be positioned in the world at (5, 5, 5) and have a magenta ambient color, a yellow diffuse color, and a green specular color. Note that OpenGL requires color channel values to be specified between 0 and 1.
You can run this application to see the effect as well as play around with turning the light types off and changing the color. Be aware that at this stage, you might find it difficult to get any specular lighting to appear with these very few settings. It’s sometimes easier to see if you revolve the model around, as there will be a glint in the corners.
Exercise A: Change the ambient color of GL_LIGHT0 to green.
Exercise B: Add a new light symbolized by GL_LIGHT1 and place it at (-5, 5, 0). Set its diffuse light to blue and don’t forget to enable the light.
There’s so much more to lighting systems in graphics than has been covered here. Although they are very complex, at this stage in the book, it’s just exciting to see some lights on the models we are rendering.
In addition to lights, the surface treatment of objects is also important to their appearance. We’ll add some materials in the next section.
Materials are the surface treatments given to polygons that make them appear solid. The coloring-in of the polygon plane gives the illusion that it has substance and is more than its surrounding edges. The surface treatment applied interacts with the lighting applied to give the final appearance.
Just like lights, materials have different ambient, diffuse, and specular colors. Each of these interacts with the corresponding lights to determine the final effect seen. For example, white diffuse light shone on a diffuse green cube will reflect the color green. Although white light is hitting the cube, the green color of the cube determines what light gets reflected. White light is when all the color channels for R, G, and B are turned on, hence the (1, 1, 1) value. If the cube is green, then its color is set to (0, 1, 0). Essentially, it’s the channel that the light and the material both have turned on that is reflected. Of course, it is a little more complex than that, as color channels can take on any value between 0 and 1, but you get the idea.
The process of adding materials is possibly even more complicated than lighting and there’s no better way of learning how to code with them than jumping right in.
The best way to see and understand this effect is to experiment with it, so let’s get started by following these steps:
glEnable(GL_LIGHTING)
glLight(GL_LIGHT0, GL_POSITION, (5, 5, 5, 1))
glLightfv(GL_LIGHT0, GL_DIFFUSE, (1, 1, 1, 1))
glEnable(GL_LIGHT0)
glMaterialfv(GL_FRONT, GL_DIFFUSE, (0, 1, 0, 1));
while not done:
Run this to see the green cube. White light falling onto a green object will display that object as green.
Besides coloring the surface of an object with lights and pure color, an external image can be used. This image is called a texture as it can be used to add a haptic quality to the object’s surface. Textures are placed on the surface of an object, polygon by polygon, through a process called texture mapping. This mapping occurs by giving each corner of the texture a two-dimensional coordinate with values ranging between 0 and 1. The horizontal values of the texture are akin to the x axis; however, in this process, they are referred to as u. The vertical values, like the y axis, are referred to as v. Hence the coordinates of a texture are called UVs. The UV values are matched to specific vertices as shown in Figure 5.4; the UVs that match the bottom-right triangle of a square are highlighted:
Figure 5.4: Texture Mapping
Working with External Files
From this point onwards in the book, we will be working with files that are external to the Python script. Throughout the book, the code assumes the external files (models, textures and shaders) are in the same folder as the code accessing them. However, in GitHub repository of this book, the external files are accessed as if one folder higher. Please keep this in mind for referencing external files in the code.
For example, if your project files are organized like this:
Root folder
- Project 1
--- Main.py
--- Models
------ Teapot.obj
It means that the models folder containing the teapot model is in the same folder as the main.py code. To reference Teapot.obj from inside Main.py the address is:
Models/Teapot.obj
However, if you would like to keep the external files in one place and outside of any particular chapter project, you can place them on the same level as each chapter code (as is referenced in GitHub repository of this book). This structure appears as:
Root folder
--- Project 1
------ Main.py
--- Models
------ Teapot.obj
In this case, the address of Teapot.obj should be:
../Models/Teapot.obj
The ../ in the file address tells the code to look up one folder from where the code is running from.
The best way to get to understand UVs is to start using them. The way that OpenGL binds the texture to vertices might seem a little long-winded, but you will soon understand the process in order to apply it elsewhere.
Work through these steps to place textures onto the models by controlling the UVs:
class Mesh3D:
def __init__(self):
…
self.triangles = [0, 2, 3, 0, 3, 1]
self.draw_type = GL_LINE_LOOP
self.texture = pygame.image.load()
self.texID = 0
These three lines create extra variables we can use to set how a mesh will be drawn and what, if any, texture to use.
Next, we modify the Cube class to set the values of these new variables:
class Cube(Mesh3D):
def __init__(self, draw_type, filename):
self.vertices = …
self.triangles = …
Mesh3D.texture = pygame.image.load(filename)
Mesh3D.draw_type = draw_type
Note that we will be passing through draw_type and a filename from main script. However, before we do that, we should add a texture file.
mesh = Cube(GL_POLYGON, "images/brick.tif")
Note that where I have the path to my image, you should replace it with yours.
def init_texture(self):
self.texID = glGenTextures(1)
textureData = pygame.image.tostring(self.texture,
"RGB", 1)
width = self.texture.get_width()
height = self.texture.get_height()
glBindTexture(GL_TEXTURE_2D, self.texID)
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MIN_FILTER,
GL_LINEAR)
glTexImage2D(GL_TEXTURE_2D, 0, 3, width, height, 0,
GL_RGB, GL_UNSIGNED_BYTE, textureData)
This method takes the image loaded with Pygame and loads it into OpenGL by doing the following:
Mesh3D.texture = pygame.image.load(filename)
Mesh3D.draw_type = draw_type
Mesh3D.init_texture(self)
self.triangles = …
self.uvs = [(0.0, 0.0),
(1.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(0.0, 1.0),
(1.0, 1.0),
(0.0, 1.0),
(1.0, 1.0),
(0.0, 0.0),
(1.0, 0.0),
(0.0, 0.0),
(1.0, 0.0),
(0.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(1.0, 0.0),
(0.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(1.0, 0.0),
(0.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(1.0, 0.0)]
Mesh3D.texture = pygame.image.load(filename)
def draw(self):
glEnable(GL_TEXTURE_2D)
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE,
GL_DECAL)
glBindTexture(GL_TEXTURE_2D, self.texID)
for t in range(0, len(self.triangles), 3):
glBegin(self.draw_type)
glTexCoord2fv(self.uvs[self.triangles[t]])
glVertex3fv(self.vertices[self.triangles[t]])
glTexCoord2fv(self.uvs[self.triangles[t + 1]])
glVertex3fv(self.vertices[self.triangles[t + 1]])
glTexCoord2fv(self.uvs[self.triangles[t + 2]])
glVertex3fv(self.vertices[self.triangles[t + 2]])
glEnd()
OpenGL draws through the enabling and disabling of facilities with differing method calls. These switch OpenGL into different processing states. In this case, at the beginning of the draw() method, texture mapping is enabled with glEnable(GL_TEXTURE_2D). The glTexEnvf()function sets up the texture environment that determines how texture values are interpreted for the purpose of generating an RGBA color value. Put simply, the GL_DECAL parameter keeps the incoming texture pixel RGBA values the same as they are in the source image. The particulars of this method are quite complex and for more information, you are encouraged to look at the following: https://www.khronos.org/registry/OpenGL-Refpages/gl2.1/xhtml/glTexEnv.xml.
Next, you will notice the use of glBindTexture() again. This tells OpenGL which texture we want to work with.
Following this, the code enters the for loop previously used to draw the wireframe and solid cubes. Notice the code remains relatively the same with the exception that the UV values are set before the vertices they match with. As we have a UV value for each vertex and each triangle keeps track of the vertices used, the code can also refer to the correct UV values.
Figure 5.5: A texture mapped onto a cube
This exercise has covered UV values and their use for mapping a texture onto a 3D object. As you can see, the process involves a lot of arrays of values. These arrays include the vertex list and another list for UVs where each vertex is assigned a UV in order to get the texture perfectly aligned with the geometry.
This chapter has been a short examination of lighting and texture that help to bring depth to a scene and make objects appear solid. Besides the lists of vertices and UVs that we’ve used, you will discover later in the book that a 3D mesh can possess many other sets of values used for a multitude of rendering effects. But for now, you have enough skill in displaying a simple mesh and adding a texture to it.
In the next chapter, we will add more flexibility to the project we are creating by working more with the main loop. In addition, we will improve the application architecture to make it more extensible by giving simple rendered objects access to more behaviors.
Exercise A:
glLightfv(GL_LIGHT0, GL_AMBIENT, (0, 1, 0, 1))
Exercise B:
glLightfv(GL_LIGHT0, GL_SPECULAR, (0, 1, 0, 1))
glEnable(GL_LIGHT0)
glLight(GL_LIGHT1, GL_POSITION, (-5, 5, 0, 1))
glLightfv(GL_LIGHT1, GL_DIFFUSE, (0, 0, 1, 1))
glEnable(GL_LIGHT1)
while not done:
52.14.240.224