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
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 guide, you'll need an understanding of Python. I'll be using Python 3, but other
versions are 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 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 start pygame with pygame.init()
, then create
the display and obtain 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, since otherwise our window would never change:
from time import sleep
# --snip--
done = False
while not done:
for e in pygame.event.get(): # for every event on the window (mouse click, keyboard press..)
if e.type == pygame.QUIT: # if the event is a 'quit' signal (the user presses the cross)
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 display and ensure all our instructions
we write are drawn to the screen. sleep(1/45)
is used to prevent the program
cooking our computers by locking the framerate of the program to 45Hz.
At this point you should be able to run your program and be greeted with a black screen
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 |
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)
x |
y |
a | b |
c | d |
ax + by |
cx + dy |
cos θ | -sin θ |
sin θ | cos θ |
x | x | x |
y | y | y |
z | z | z |
cos θ | -sin θ | 0 |
sin θ | cos θ | 0 |
0 | 0 | 1 |
cos θ | 0 | sin θ |
0 | 1 | 0 |
-sin θ | 0 | cos θ |
1 | 0 | 0 |
0 | cos θ | -sin θ |
0 | sin θ | cos θ |
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
about the origin. If we wanted to rotate about some other
point, we may need to translate (move) the shape's vertices around first, rotate,
and then move back. Let's 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-commutative- the
order you multiply them is important (i.e, A×B ≠ B×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.
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 make enough sense of it.
If you have feedback for me, I'm opening a new channel in the Discord linked
in the top bar 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!