3D With Pygame + Numpy

This is my first try at a tutorial-style thing, so please leave any feedback for me in my Discord (linked above) or email it to me at judewrs@gmail.com

Introduction


Speed isn't Python's speciality as a scripting-style language, and the capabilities for 3D are relatively limited. However, with learning how to use matrices to perform transformations in a 3D space, I figured I'd try and pick up some python 2D packages and try and make something interesting. And I did! It was much easier than I thought it'd be, and despite the result not being massively impressive, it's a nice proof-of-concept.

For this tutorial, you'll need an understanding of Python. I'll be using Python 3, but other versions are completely viable. You'll need to have Pygame and Numpy installed too. These can be installed with ease using PIP

You might also want a basic understanding of Pygame and matrices, although this isn't necessary to follow the guide. Ok, enough hassle. Let's get started!

Initializing Pygame


Initializing Pygame is the first step. By the end of this, we should have a black window.

First, we want to create a new Python file and create some constants for our screen to use:


WHITE = (255, 255, 255) # Color white in RGB
BLACK = (0, 0, 0) # Color black in RGB

WIDTH = 500 # Width of window in px
HEIGHT = 500 # Height of window in px
    

The two color definitions are for later, but the width and height definitions we'll use right now. Add the import for pygame to the top of your file, and run the init() method:


import pygame

# --snip--

pygame.init()
display = pygame.display
surface = display.set_mode((WIDTH, HEIGHT))
display.set_caption("3D Cube")
    

We initialize pygame with pygame.init(), then grab the display and a surface from the display that we can draw onto later. Now, let's write some more code to make a window that's refreshing and can be repeatedly drawn to:


from time import sleep

# --snip--

done = False

while not done:
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            done = True

    display.flip()
    sleep(1/45)

# end while
    

This code is some basic stub of a pygame program. The flag done is used to quit the program if the user requests the program to quit, usually by pressing the cross at the top of the window. The for loop checks for any recent events that have occured, including key presses and other user actions.
display.flip() is used to update the surface and ensure all our instructions we write are drawn to the screen. sleep(1/45) is used to prevent the program cooking our computers! This does however lock the framerate of the program to 45.
At this point you should be able to run your program and be greeted with a black screen



Some Maths...

So, we have some of the basics down for making a screen. Now lets think about how this is going to work. We're going to be using matrices to produce a cube in our window, so let's talk about matrices. For starters, what are matrices? Here's an example:
5 14
7 0

This Matrix is known as a 2×2 matrix (that's rows×columns). When multiplying matrices, there's a rule you need to follow: the amount of columns on the first matrix must match the amount of rows on the second matrix (I think of this as RC×RC; see the C and R are next to each other across the multiplication sign)
All sorts of operations can be performed on matrices, but we're only going to need multiplication to do what we're doing. You don't need to know how to multiply matrices, because Numpy does it for us, but you can read about it here.

What we do want to know however, is how to perform a transformation with matrices. In 2D space, we use a 2×2 (rows × columns) matrix to perform transformations. We represent each 2D point in a vector, structured

P =
x
y


Given a transformation matrix:

M =
a b
c d


our ending points will be: M × P =
ax + by
cx + dy


Great... but what can we do with this? There's a lot of different possible transformations given a 2×2 matrix, but we're only bothered about rotation. And luckily for us, rotation has a generalized form!

M =
cos θ -sin θ
sin θ cos θ

... where θ (Theta) is the angle we want to rotate by (anticlockwise). We can then multiply a set of points by this matrix to produce a rotated image.

But what about in 3D? Let's do some drawing...

Look the same, right? They are, more or less. The axis labels are different however. The object drawn by the points is a 3D cube, and each graph is a plane at z = 0, y = 0, and x = 0 respectively.
So, knowing this, we can produce 3 different rotation matrices in 2D to represent the full rotation in 3D. Then, we can chain these transformations to produce our final image! Let's look at the first graph:
Our overall matrix is of format

x x x
y y y
z z z


So, since we're rotating around the z axis, our z coordinates won't change and also won't affect the rest of the transformation. Therefore, our new transformation matrix for a rotation around z will look like so:

cos θ -sin θ 0
sin θ cos θ 0
0 0 1


Let's quickly produce the others:
cos θ 0 -sin θ
0 1 0
sin θ 0 cos θ
1 0 0
0 cos θ -sin θ
0 sin θ cos θ


Great! Now let's get back to coding.


Some Modularity

With this maths, we're going to use Numpy to make some functions to help us in a bit. Here's our 3 functions we're going to need:


import pygame
from numpy import matrix
from math import cos, sin, pi

# --snip--

def generate_x(theta):
    return matrix([
        [1, 0, 0],
        [0, cos(theta), -sin(theta)],
        [0, sin(theta), cos(theta)]
    ])

def generate_y(theta):
    return matrix([
        [cos(theta), 0, -sin(theta)],
        [0, 1, 0],
        [sin(theta), 0, cos(theta)]
    ])

def generate_z(theta):
    return matrix([
        [cos(theta), -sin(theta), 0],
        [sin(theta), cos(theta), 0],
        [0, 0, 1]
    ])

while not done:
    ...
    

These functions make us matrices that represent our rotations in the angle theta. Let's also define some variables:


points = (
    (   0,   0,   0),
    ( 100,   0,   0),
    (   0, 100,   0),
    (   0,   0, 100),
)

rotation = [0, 0, 0]
    

These variables define a set of points in format (x, y, z) and the rotation we want to apply. Now let's start writing some logic!


while not done:
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            done = True

    render_points = []

    # for each point...
    for p in points:

        # make a vector of the 3 pieces (x, y, z)
        m = matrix([
            [p[0]],
            [p[1]],
            [p[2]],
        ])

        # apply every rotation
        for method, angle in zip((generate_x, generate_y, generate_z), rotation):
            # multiply the vector out to chain the rotations
            m = method(angle) * m

    display.flip()
    sleep(1/45)

    

The above code is commented and contains the basis of the maths. The zip() function combines two lists, so we can unpack them in the for loop as method and angle. The line m = method(angle) * m multiplies the set of points by the rotation we got from one of our generate functions. It's important that we don't use m *= method(angle), because matrix multiplication is non-commutitive- the order you multiply them is important (i.e, A×BB×A). In fact, the code won't run if you put *= !
Let's start drawing.



for p in points:

    # make a vector of the 3 pieces (x, y, z)
    m = matrix([
        [p[0]],
        [p[1]],
        [p[2]],
    ])

    # apply every rotation
    for method, angle in zip((generate_x, generate_y, generate_z), rotation):
        # multiply the vector out to chain the rotations
        m = method(angle) * m

    # turn it to an int and displace it from the center of the screen
    x, y = map(lambda x: int(WIDTH/2 - x), (m[0,0], m[1,0]))

    # add it to the list of points to draw
    render_points.append((x, y))

# for each drawing point...
for x, y in render_points:
    # draw a circle radius=2
    pygame.draw.circle(surface, WHITE, (x, y), 2)

rotation[0] += pi / 100
rotation[1] += pi / 150

display.flip()
    

Now you can run it! You'll end up with some dots but they leave trails. Let's fix that!


while not done:
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            done = True

    # clear the screen with blackness
    pygame.draw.rect(surface, BLACK, pygame.Rect(0, 0, WIDTH, HEIGHT))

    render_points = []
    

Much better! At this point, our full code should look something like this...


import pygame
from time import sleep
from numpy import matrix
from math import cos, sin, pi


WHITE = (255, 255, 255) # Color white in RGB
BLACK = (0, 0, 0) # Color black in RGB

WIDTH = 500 # Width of window in px
HEIGHT = 500 # Height of window in px

pygame.init()
display = pygame.display
surface = display.set_mode((WIDTH, HEIGHT))
display.set_caption("3D Cube")

done = False

points = (
    (   0,   0,   0),
    ( 100,   0,   0),
    (   0, 100,   0),
    (   0,   0, 100),
)

rotation = [0, 0, 0]

def generate_x(theta):
    return matrix([
        [1, 0, 0],
        [0, cos(theta), -sin(theta)],
        [0, sin(theta), cos(theta)]
    ])

def generate_y(theta):
    return matrix([
        [cos(theta), 0, -sin(theta)],
        [0, 1, 0],
        [sin(theta), 0, cos(theta)]
    ])

def generate_z(theta):
    return matrix([
        [cos(theta), -sin(theta), 0],
        [sin(theta), cos(theta), 0],
        [0, 0, 1]
    ])

while not done:
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            done = True

    pygame.draw.rect(surface, BLACK, pygame.Rect(0, 0, WIDTH, HEIGHT))

    render_points = []

    for p in points:

        m = matrix([
            [p[0]],
            [p[1]],
            [p[2]],
        ])

        for method, angle in zip((generate_x, generate_y, generate_z), rotation):
            m = method(angle) * m

        x, y = map(lambda x: int(WIDTH/2 - x), (m[0,0], m[1,0]))

        render_points.append((x, y))

    for x, y in render_points:
        pygame.draw.circle(surface, WHITE, (x, y), 2)

    rotation[0] += pi / 100
    rotation[1] += pi / 150

    display.flip()
    sleep(1/45)
    

Now let's make a cube. You can do this by changing the points tuple. Each row represents a point in a 3D space, but whilst our maths uses all the points, we only render using the X and Y value.


points = (
    ( 50,  50,  50),
    (-50,  50,  50),
    ( 50, -50,  50),
    ( 50,  50, -50),
    (-50,  50, -50),
    ( 50, -50, -50),
    (-50, -50, -50),
    (-50, -50,  50),
)
    

Wonderful! Final step: let's replace the dots with some lines, to make a mesh. Pygame has a function draw.lines(), so let's have a go with that. Put this code at the bottom of your while loop, but above the display.flip():


pygame.draw.lines(surface, WHITE, True, render_points)
    

Hmmm... not quite what we want. So let's work on doing it manually:


for point in render_points:
    for point_2 in render_points:
        pygame.draw.line(surface, WHITE, point, point_2)
    

Perfect! But inefficient. If you think, each point needs connecting once to each other point. Currently, this code connects each point to itself and every other point twice. Here's a better solution:


for p1 in range(len(render_points) - 1):
    for p2 in render_points[p1 + 1:]:
        pygame.draw.line(surface, WHITE, render_points[p1], p2)
    

Better. This code connects the first point to the other 7, then the next to the other 6 and so on, to prevent redrawing lines.


Some final improvements...

There's a couple of changes I made as I wrote this, and I'll outline them all now. Here's the code I changed:


import pygame
from time import sleep
from numpy import matrix
from math import cos, sin, pi


WHITE = (255, 255, 255) # Color white in RGB
BLACK = (0, 0, 0) # Color black in RGB

WIDTH = 500 # Width of window in px
HEIGHT = 500 # Height of window in px

pygame.init()
display = pygame.display
surface = display.set_mode((WIDTH, HEIGHT))
display.set_caption("3D Cube")

done = False

points = matrix((
    ( 50,  50,  50),
    (-50,  50,  50),
    ( 50, -50,  50),
    ( 50,  50, -50),
    (-50,  50, -50),
    ( 50, -50, -50),
    (-50, -50, -50),
    (-50, -50,  50),
)).transpose()

rotation = [0, 0, 0]

def generate_x(theta):
    return matrix([
        [1, 0, 0],
        [0, cos(theta), -sin(theta)],
        [0, sin(theta), cos(theta)]
    ])

def generate_y(theta):
    return matrix([
        [cos(theta), 0, -sin(theta)],
        [0, 1, 0],
        [sin(theta), 0, cos(theta)]
    ])

def generate_z(theta):
    return matrix([
        [cos(theta), -sin(theta), 0],
        [sin(theta), cos(theta), 0],
        [0, 0, 1]
    ])

while not done:
    for e in pygame.event.get():
        if e.type == pygame.QUIT:
            done = True

    pygame.draw.rect(surface, BLACK, pygame.Rect(0, 0, WIDTH, HEIGHT))

    p = points.copy()

    for method, angle in zip((generate_x, generate_y, generate_z), rotation):
        p = method(angle) * p

    render_points = (WIDTH/2 + p.transpose()).tolist()

    for p1 in range(len(render_points) - 1):
        for p2 in render_points[p1 + 1:]:
            pygame.draw.line(surface, WHITE, render_points[p1][:2], p2[:2])


    rotation[0] += pi / 100
    rotation[1] += pi / 150

    display.flip()
    sleep(1/45)
    

The changes I made are that I changed the points tuple into a matrix. I transpose it to be of proper dimensions for multiplying. Then, I copy the set of points to the variable p in the loop. This is so we don't end up modifying the actual matrix of original points. In practice, copying data isn't usually a good idea since it consumes extra memory and uses resources in copying the resource, however for this example it's okay. We then multiply it by the rotations, and add the scalar of half the width to center the shape. Then we draw the lines as normal, and we use the splice [:2] to only select the x and y point of the shape.



And this concludes the guide! Thank you for reading and I hope you learnt something interesting in the process. This is a first for me, and it feels a little rushed but I hope you can all make enough sense of it. If you have feedback for me, I'm opening a new channel in the Discord linked in the navbar for discussing the blog in general. Otherwise, if you have any comments, you can place them in the issues tracker on GitHub or email me at judewrs@gmail.com.
Thank you!