Our wireframe object is currently defined by a list of Node objects and a list of Edge objects, which hopefully makes things easy to understand, but it isn't very efficient. That's not a problem if we're just making cubes, but will be if we want to create more complex objects. In this tutorial, we will:
- Convert our list of nodes to a numpy array
- Simplify how edges are stored
- Create a cube using the new system
By the end of this and the following tutorial our program should function exactly the same as before, but will be more efficient.
If you're not familiar with NumPy, then this tutorial might take a bit of work to understand, but I think it's worth the effort. You can do everything without using matrices, but it does actually simplify things in the long run and your program will be a lot quicker. I'll do my best to explain NumPy, but you might also want to look at the official tutorial.
The first thing is to download NumPy if you haven't already done so.Then import it in our wireframe.py module (the as np part is a common shortcut which saves a bit of typing later):
import numpy as np
Since NumPy includes a lot of mathematical functions, we can use it to replace the math module, thus replace math.sin() with np.sin().
NumPy arrays (matrices)
Our program currently defines nodes using the Node object, which is fine when you only have eight, but if you want thousands then it will quickly become very time and memory inefficient. A node is really just three numbers, so we could convert the list of nodes to a list of lists, each containing three numbers. However, if we use a NumPy array, we get a lot of built-in mathematical functions which will prove useful later.
So, we can delete our Node object and change the Wireframe class nodes attribute to:
self.nodes = np.zeros((0, 4))
This creates a NumPy array with 0 row and 4 columns. This would be filled with zeros, but since there are no rows, there are no values. There are no rows because, to start with there are no nodes. There are four rather than three columns because it makes some transformations easier as I'll explain when we come to them. Note that we are using the NumPy array class and not the matrix class because it's easier to work with and does everything that matrices do. From a mathematical point of view they can still be considered matrices.
Next we need to change the Wireframe class addNodes() function because it currently takes a list of 3-tuples and converts each into a Node object. We want to change it to take a N x 3 NumPy array, in which each of the N rows is a vector of 3 coordinates (x, y and z).
For example, we would define the nodes of a unit square like this:
square = Wireframe() nodes = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) square.addNodes(nodes)
In order to add this N x 3 array of nodes to the array of current nodes, we first need to add an N x 1 column of ones to get an N x 4 array. Then we add the new nodes as additional rows to the current node array.
We create a N x 1 array of ones by using np.ones(N, 1). We could work out the number of rows we need (N) by looking at the shape attribute of the new node array. For example, you can try:
print nodes.shape >>> (4, 3)
Alternatively we can use the len() function, which returns the number of rows of an array, as though it were a list. Once we have a column of ones we horizontally stack onto the array of nodes, using np.hstack(). We then vertically stack that array onto the array of current nodes with np.vstack(). So we change our Wireframe addNodes() method to:
def addNodes(self, node_array): ones_column = np.ones((len(node_array), 1)) ones_added = np.hstack((node_array, ones_column)) self.nodes = np.vstack((self.nodes, ones_added))
Just as the Node object as basically three numbers, the Edge object is basically two numbers. We could also replace all the edges with a simple NumPy array, but in this case, I think it's easier to use a list of lists. Once we've defined the edges we never need to change the values, so we don't need the matrix functions available for working with arrays.
We can therefore remove the Edge object simplify the addEdges() method to:
def addEdges(self, edgeList): self.edges += edgeList
Testing the new system
To check that our addNodes() and addEdges() methods are working as we expect, we should update the outputNodes() and outputEdges() methods.
def outputNodes(self): print "\n --- Nodes --- " for i, (x, y, z, _) in enumerate(self.nodes): print " %d: (%d, %d, %d)" % (i, x, y, z)
Here we loop through the nodes, getting their x, y and z coordinates. We can ignore the final value as this will always be 1.
We should update outputEdges() too.
def outputEdges(self): print "\n --- Edges --- " for i, (node1, node2) in enumerate(self.edges): print " %d: %d -> %d" % (i, node1, node2)
We can now create a cube object in a similar way as before:
if __name__ == "__main__": cube = Wireframe() cube_nodes = [(x, y, z) for x in (0, 1) for y in (0, 1) for z in (0, 1)] cube.addNodes(np.array(cube_nodes)) cube.addEdges([(n, n + 4) for n in range(0, 4)]) cube.addEdges([(n, n + 1) for n in range(0, 8, 2)]) cube.addEdges([(n, n + 2) for n in (0, 1, 4, 5)]) cube.outputNodes() cube.outputEdges()
Fixing the display
Finally we should change the ProjectionViewer class's display() function to work with the new Wireframe nodes and edges. We update how nodes are displayed by changing node.x to node and node.y to node:
if self.displayNodes: for node in wireframe.nodes: pygame.draw.circle(self.screen, self.nodeColour, (int(node), int(node)), self.nodeRadius, 0)
To fix how the edges are displayed we change the code to:
if self.displayEdges: for n1, n2 in wireframe.edges: pygame.draw.aaline(self.screen, self.edgeColour, wireframe.nodes[n1][:2], wireframe.nodes[n2][:2], 1)
This loops through the edges, which are a list of lists containing two numbers, which we call n1, and n2. These refer to the start and end nodes of the edges, so we get those nodes, and then extract their x- and y-coordinates, which are the first two, hence the [:2] index.
To test the code, we have to import numpy as np like before:
import numpy as np
Then change the addNodes() call to:
The one difference between creating a cube with this new Wireframe object, is that we must first convert the list comprehension into a NumPy array. Now we should find that we have successfully created a cube object. In the next tutorial we'll fix the transformation functions.