appendix C. Loading and rendering 3D Models with OpenGL and PyGame

Beyond chapter 3, when we start writing programs that transform and animate graphics, I use OpenGL and PyGame instead of Matplotlib. This appendix provides an overview of how to set up a game loop in PyGame and render 3D models in successive frames. The culmination is an implementation of a draw_model function that renders a single image of a 3D model like the teapot we used in chapter 4.

The goal of draw_model is to encapsulate the library-specific work, so you don’t have to spend a lot of time wrestling with OpenGL. But if you want to understand how the function works, feel free to follow along in this appendix and play with the code yourself. Let’s start with our octahedron from chapter 3 and recreate it with PyOpenGL, an OpenGL binding for Python and PyGame.

C.1 Recreating the octahedron from chapter 3

To begin working with the PyOpenGL and PyGame libraries, you need to install them. I recommend using pip as follows:

> pip install PyGame
> pip install PyOpenGL

The first thing I’ll show you is how to use these libraries to recreate work we’ve already done, rendering a simple 3D object.

In a new Python file called octahedron.py (which you can find in the source code for appendix C), we start with a bunch of imports. The first few come from the two new libraries, PyGame and PyOpenGL, and the rest should be familiar from chapter 3. In particular, we’ll continue to use all of the 3D vector arithmetic functions we already built, organized in the file vectors.py in the source code for this book. Here are the import statements:

import pygame
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *
import matplotlib.cm
from vectors import *
from math import *

While OpenGL has automatic shading capabilities, let’s continue to use our shading mechanism from chapter 3. We can use a blue color map from Matplotlib to compute colors for the shaded sides of the octahedron:

def normal(face):
    return(cross(subtract(face[1], face[0]), subtract(face[2], face[0])))

blues = matplotlib.cm.get_cmap('Blues')

def shade(face,color_map=blues,light=(1,2,3)):
    return color_map(1 − dot(unit(normal(face)), unit(light)))

Next, we have to specify the geometry of the octahedron and the light source. Again, this is the same as in chapter 3:

light = (1,2,3)
faces = [
    [(1,0,0), (0,1,0), (0,0,1)],
    [(1,0,0), (0,0,-1), (0,1,0)],
    [(1,0,0), (0,0,1), (0,-1,0)],
    [(1,0,0), (0,-1,0), (0,0,-1)],
    [(−1,0,0), (0,0,1), (0,1,0)],
    [(−1,0,0), (0,1,0), (0,0,-1)],
    [(−1,0,0), (0,-1,0), (0,0,1)],
    [(−1,0,0), (0,0,-1), (0,-1,0)],
]

Now it’s time for some unfamiliar territory. We’re going to show the octahedron as a PyGame game window, which requires a few lines of boilerplate. Here, we start the game, set the window size in pixels, and tell PyGame to use OpenGL as the graphics engine:

pygame.init()
display = (400,400) 
window = pygame.display.set_mode(display,            
                                 DOUBLEBUF|OPENGL)   

Asks PyGame to show our graphics in a 400 × 400 pixel window

Lets PyGame know that we’re using OpenGL for our graphics and indicates that we want to use a built-in optimization called double-buffering, which isn’t important to understand for our purposes

In our simplified example in section 3.5, we drew the octahedron from the perspective of someone looking from a point far up the z-axis. We computed which triangles should be visible to such an observer and projected them to 2D by removing the z-axis. OpenGL has built-in functions to configure our perspective even more precisely:

gluPerspective(45, 1, 0.1, 50.0)
glTranslatef(0.0,0.0, -5)
glEnable(GL_CULL_FACE)
glEnable(GL_DEPTH_TEST)
glCullFace(GL_BACK)

For the purpose of learning math, you don’t really need to know what these functions do, but I’ll give you a short overview in case you are curious. The call to gluPerspective describes our perspective looking at the scene, where we have a 45° viewing angle and an aspect ratio of 1. This means the vertical units and the horizontal units display as the same size. As a performance optimization, the numbers 0.1 and 50.0 put limits on the z -coordinates that are rendered: no objects further than 50.0 units from the observer or closer than 0.1 units will show up. Our use of glTranslatef indicates that we’ll observe the scene from 5 units up the z-axis, meaning we move the scene down by vector (0, 0, -5). Calling glEnable(GL_CULL _FACE) turns on an OpenGL option that automatically hides polygons oriented away from the viewer, saving us some work we already did in chapter 3, and glEnable (GL_DEPTH_TEST) ensures that we render polygons closer to us on top of those further from us. Finally, glCullFace(GL_BACK) enables an OpenGL option that automatically hides polygons that are facing us but that are behind other polygons. For the sphere, this wasn’t a problem, but for more complex shapes it can be.

Finally, we can implement the main code that draws our octahedron. Because our eventual goal is to animate objects, we’ll actually write code that draws the object over and over repeatedly. These successive drawings, like frames of a movie, show the same octahedron over time. And, like any video of any stationary object, the result is indistinguishable from a static picture.

To render a single frame, we loop through the vectors, decide how to shade them, draw them with OpenGL, and update the frame with PyGame. Inside of an infinite while loop, this process can be automatically repeated as fast as possible as long as the program runs:

clock = pygame.time.Clock()                            
while True:
    for event in pygame.event.get():                   
        if event.type == pygame.QUIT:
            pygame.quit()
            quit()

    clock.tick()                                       
    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
    glBegin(GL_TRIANGLES)                              
    for face in faces:
        color = shade(face,blues,light)
        for vertex in face:
            glColor3fv((color[0], 
                        color[1], 
                        color[2]))                     
            glVertex3fv(vertex)                        
    glEnd()
    pygame.display.flip()                              

Initializes a clock to measure the advancement of time for PyGame

In each iteration, checks the events PyGame receives and quits if the user closes the window

Indicates to the clock that time should elapse

Instructs OpenGL that we are about to draw triangles

For each vertex of each face (triangle), sets the color based on the shading

Specifies the next vertex of the current triangle

Indicates to PyGame that the newest frame of the animation is ready and makes it visible

Running this code, we see a 400 £ 400 pixel PyGame window appear, containing an image that looks like the one from chapter 3 (figure C.1).

Figure C.1 The octahedron rendered in a PyGame window

If you want to prove that something more interesting is happening, you can include the following line at the end of the while True loop:

print(clock.get_fps())

This prints instantaneous quotes of the rate (in frames per second, or fps) at which PyGame is rendering and re-rendering the octahedron. For a simple animation like this, PyGame should reach or exceed its default maximum frame rate of 60 fps.

But what’s the point of rendering so many frames if nothing changes? Once we include a vector transformation with each frame, we see the octahedron move in various ways. For now, we can cheat by moving the “camera” with each frame instead of actually moving the octahedron.

C.2 Changing our perspective

The glTranslatef function in the previous section tells OpenGL the position from which we want to see the 3D scene we’re rendering. Similarly, there is a glRotatef function that lets us change the angle at which we observe the scene. Calling glRotatef (theta, x, y, z) rotates the whole scene by the angle theta about an axis specified by the vector (x, y, z).

Let me clarify what I mean by “rotating by an angle about an axis.” You can think of the familiar example of the Earth rotating in space. The Earth rotates by 360° every day or 15° every hour. The axis is the invisible line that the Earth rotates around; it passes through the North and South poles−the only two points that aren’t rotating. For the Earth, the axis of rotation is not directly upright, rather it is tilted by 23.5° (figure C.2).

Figure C.2 A familiar example of an object rotating about an axis. The Earth’s axis of rotation is tilted at 23.5° relative to its orbital plane.

The vector (0, 0, 1) points along the z-axis, so calling glRotatef(30,0,0,1) rotates the scene by 30° about the z-axis. Likewise, glRotatef(30,0,1,1) rotates the scene by 30° but, instead, about the axis (0, 1, 1), which is 45° tilted between the y- and z-axes. If we call glRotatef (30,0,0,1) or glRotatef(30,0,1,1) after glTranslatef(...) in the octahedron code, we see the octahedron rotated (figure C.3).

Notice that the shading of the four visible sides of the octahedron in figure C.3 has not changed. This is because none of the vectors change; the vertices of the octahedron and the light source are all the same! We have only changed the position of the “camera” relative to the octahedron. When we actually change the position of the octahedron, we’ll see the shading change too.

Figure C3. The octahedron as seen from three different rotated perspectives using the glRotatef function from OpenGL

To animate the rotation of the cube, we can call glRotate with a small angle in every frame. For instance, if PyGame draws the octahedron at about 60 fps, and we call glRotatef(1,x,y,z) in every frame, the octahedron rotates about 60° every second about the axis (x, y, z). Adding glRotatef(1,1,1,1) within the infinite while loop before glBegin causes the octahedron to rotate by per frame about an axis in the direction (1, 1, 1) as shown in figure C.4.

Figure C.4 Every tenth frame of our octahedron rotating at per frame. After 36 frames, the octahedron completes a full rotation.

This rotation rate is only accurate if PyGame draws exactly 60 fps. In the long run, this may not be true; if a complex scene requires more than a sixtieth of a second to compute all vectors and draw all polygons, the motion actually slows down. To keep the motion of the scene constant regardless of the frame rate, we can use PyGame’s clock.

Let’s say we want our scene to rotate by a full rotation (360°) every 5 seconds. PyGame’s clock thinks in milliseconds, which are thousandths of a second. For a thousandth of a second, the angle rotated is divided by 1,000:

degrees_per_second = 360./5
degrees_per_millisecond = degrees_per_second / 1000

The PyGame clock we created has a tick() method that both advances the clock and returns the number of milliseconds since tick() was last called. This gives us a reliable number of milliseconds since the last frame was rendered, and lets us compute the angle that the scene should be rotated in that time:

milliseconds = clock.tick()
glRotatef(milliseconds * degrees_per_millisecond, 1,1,1)

Calling glRotatef like this every frame guarantees that the scene rotates exactly 360° every 5 seconds. In the file rotate_octahedron.py in the appendix C source code, you can see exactly how this code is inserted.

With the ability to move our perspective over time, we already have better rendering capabilities than we developed in chapter 3. Now, we can turn our attention to drawing a more interesting shape than an octahedron or a sphere.

C.3 Loading and rendering the Utah teapot

As we manually identified the vectors outlining a 2D dinosaur in chapter 2, we could manually identify the vertices of any 3D object, organize them into triples representing triangles, and build the surface as a list of triangles. Artists who design 3D models have specialized interfaces for positioning vertices in space and then saving them to files. In this section, we use a famous pre-built 3D model: the Utah teapot. The rendering of this teapot is the Hello World program for graphics programmers: a simple, recognizable example for testing.

The teapot model is saved in the file teapot.off in the source code, where the .off filename extension stands for Object File Format. This is a plaintext format, specifying the polygons that make up the surface of a 3D object and the 3D vectors that are vertices of the polygon. The teapot.off file looks something like what is shown in this listing.

Listing C.1 A schematic of the teapot.off file

OFF                                
480  448  926                      
0  0  0.488037                     
0.00390625  0.0421881  0.476326
0.00390625  -0.0421881  0.476326
0.0107422  0  0.575333
...
4 324 306 304 317                  
4 306 283 281 304
4 283 248 246 281
...

Indicates that this file follows the Object File Format

Contains the number of vertices, faces, and edges of the 3D model in that order

Specifies 3D vectors for each of the vertices, as x-, y-, and z-coordinate values

Specifies the 448 faces of the model

For the last lines of this file, specifying the faces, the first number of each line tells us what kind of polygon the face is. The number 3 indicates a triangle, 4 a quadrilateral, 5 a pentagon, and so on. Most of the teapot’s faces turn out to be quadrilaterals. The next numbers on each line tell us the indices of the vertices from the previous lines that form the corners of the given polygon.

In the file teapot.py in the appendix C source code, you’ll find the functions load_vertices() and load_polygons() that load the vertices and faces (polygons) from the teapot.off file. The first function returns a list of 440 vectors, which are all the vertices for the model. The second returns a list of 448 lists, each one containing vectors that are the vertices of one of the 448 polygons making up the model. Finally, I included a third function, load_triangles(), that breaks up the polygons with four or more vertices so that our entire model is built out of triangles.

I’ve left it as a mini-project for you to dig deeper into my code or to try to load the teapot.off file as well. For now, I’ll continue with the triangles loaded by teapot.py, so we can get to drawing and playing with our teapot more quickly. The other step I skip is organizing the PyGame and OpenGL initialization into a function so that we don’t have to repeat it every time we draw a model. In draw_model.py, you’ll find the following function:

def draw_model(faces, color_map=blues, light=(1,2,3)):
        ...

It takes the faces of a 3D model (assumed to be correctly oriented triangles), a color map for shading, and a vector for the light source, and draws the model accordingly. There are also a few more keyword arguments that we introduced in chapters 4 and 5. Like our code to draw the octahedron, it draws whatever model is passed in, over and over in a loop. This listing shows how I put these together in draw_teapot.py.

Listing C.2 Loading the teapot triangles and passing those to draw_model

from teapot import load_triangles
from draw_model import draw_model

draw_model(load_triangles())

The result is an overhead view of a teapot. You can see the circular lid, the handle on the left, and the spout on the right (figure C.5).

Figure C.5 Rendering the teapot

Now that we can render a shape that’s more interesting than a simple geometric figure, it’s time to play! If you read chapter 4, you learned about the mathematical transformations that you can do on all of the vertices of the teapot to move and distort it in 3D space. Here, I’ve also left you some exercises if you want to do some guided exploration of the rendering code.

C.4 Exercises

Exercise C.1: Modify the draw_model function to display the input figure from any rotated perspective. Specifically, give the draw_model function a keyword argument glRotatefArgs that provides a tuple of four numbers corresponding to the four arguments of glRotatef. With this extra information, add an appropriate call to glRotatef within the body of draw_model to execute the rotation.

Solution: In the source code for this book, see draw_model.py for the solution and draw_teapot_glrotatef.py for an example usage.

  

Exercise C.2: If we call glRotatef(1,1,1,1) in every frame, how many seconds does it take for the scene to complete a full revolution?

Solution: The answer depends on the frame rate. This call to glRotatef rotates the perspective by each frame. At 60 fps, it would rotate 60° per second and complete a full rotation of 360° in 6 seconds.

  

Exercise C.3−Mini Project: Implement the load_triangles() function shown previously, which loads the teapot from the teapot.off file and produces a list of triangles in Python. Each triangle should be specified by three 3D vectors. Then, pass your result to draw_model() and confirm that you see the same result.

Solution: In the source code, you can find load _triangles() implemented in the file teapot.py.

Hint: You can turn the quadrilaterals into pairs of triangles by connecting their opposite vertices.

Indexing four vertices of a quadrilateral, two triangles are formed by vertices 0, 1, 2 and 0, 2, 3, respectively.

  

Exercise C.4−Mini Project: Animate the teapot by changing the arguments to gluPerspective and glTranslatef. This will help you visualize the effects of each of the parameters.

Solution: In the file animated_octahedron.py in the source code, an example is given for rotating the octahedron by 360 / 5 = 72° per second by updating the angle parameter of glRotatef every frame. You can try similar modifications yourself with either the teapot or the octahedron.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.234.177.119