3d Projection from scratch in PyGame in 200 lines of Code!

hudbeard
10 min readFeb 15, 2024

The other day, I was using my favorite 3d software, Blender, when I realized how strange it was for me to be interacting with a 3d object on a 2d screen. How do we so convincingly display that? I had to figure it out, so I built a simple script using Python and PyGame. Is it efficient? No. Does it solve issues with existing programs? Hard no. Did I learn a lot making it? Yes. And you can too. I’ll explain everything to the best of my ability. So, without further ado, 3d PyGame.

3d Projection

Before we get to the actual coding, let’s go over how 3d projection works. Let’s say I have a list of vertices that make up a cube with its center at the origin. How do we go from this list of 3d points to an ( x, y) pair representation on the screen? First we define a “camera” in the same 3d space. From this camera there extends multiple clipping planes.

These clipping planes will create the pink trapezoidal shape shown. This is to prevent rendering geometry that is outside of the view of the camera. Like most cameras, this one has a focal length, which is crucial for projection.

Perspective projection based on focal length. Image by author.

For every vertex (v) the 2d projected point is equal to vertex x or y multiplied by focal length (f) divided by vertex z. This is a very simple way of doing this (there are certainly more complex methods) but for the sake of this article we will use this.

The last thing is basic 3d translation/rotation matrices.

Rotation Matrix
Translation Matrix

That is the gist, now let’s code it up.

Before we get started, we need to install some libraries.

pip install pygame numpy

Next, we are going to set up a very basic PyGame window. Create a new file called pygame3dengine.py

# pygame3dengine.py
import pygame

class Pygame3dEngine:
def __init__(self):
pygame.init()
self.running = True
self.display = pygame.display.Info().current_w, pygame.display.Info().current_h
self.font = pygame.font.SysFont('Comic Sans', 12)
self.screen = pygame.display.set_mode(self.display, pygame.FULLSCREEN)
self.clock = pygame.time.Clock()

def check_for_quit(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False

def flip(self):
pygame.display.flip()
self.screen.fill("black")
self.clock.tick(60)

This code creates a pygame window at full screen, limits it to 60 fps, and sets the background to black.

Now that we have this, create a new file called main.py and write this to it:

# main.py
import time
import pygame
from pygame3dengine import Pygame3dEngine

engine = Pygame3dEngine()
pygame.mouse.set_visible(False)

while engine.running:
engine.check_for_quit()
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
pygame.quit()

engine.flip()

When you run this you should get a full screen window of flat black. This will be the background. Next, we need to actually define a mesh to render. I used a cube. Create a new file called cube.csv

1,2,3,color
0,1,2,red
0,2,3,red
4,0,3,green
4,3,7,green
5,4,7,blue
5,7,6,blue
1,5,6,yellow
1,6,2,yellow
4,5,1,purple
4,1,0,purple
2,6,7,cyan
2,7,3,cyan
0.5,0.5,0.5
-0.5,0.5,0.5
-0.5,-0.5,0.5
0.5,-0.5,0.5
0.5,0.5,-0.5
-0.5,0.5,-0.5
-0.5,-0.5,-0.5
0.5,-0.5,-0.5

The first line is just defining the columns. The next chunk of the file is devoted to defining triangles. A tringle is defined by three point indexes and a color. The last chunk is the actual points the triangles are indexing. This is the cube we are making:

Image from here

Now that we have defined a cube, let’s create a function to read this file. pygame3dengine.py should now look like this:

# pygame3dengine.py
from dataclasses import dataclass
import numpy
import pygame
import csv

class Pygame3dEngine:
def __init__(self):
pygame.init()
self.running = True
self.display = pygame.display.Info().current_w, pygame.display.Info().current_h
self.font = pygame.font.SysFont('Comic Sans', 12)
self.screen = pygame.display.set_mode(self.display, pygame.FULLSCREEN)
self.clock = pygame.time.Clock()

@staticmethod
def load_model(filename: str) -> dataclass:
with open(filename) as csv_file:
reader = list(csv.DictReader(csv_file))
triangle = [Triangle(*[int(i) for i in list(row.values())[:3]], row["color"]) for row in reader if
row["color"]]
vertices = numpy.array([[float(i) for i in list(row.values())[:3]] for row in reader if not row["color"]])
return Model(vertices, triangle, [-2, 0, 0], numpy.array([0, 0, 0]))

def check_for_quit(self):
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False

def flip(self):
pygame.display.flip()
self.screen.fill("black")
self.clock.tick(60)


@dataclass
class Triangle:
a: int
b: int
c: int
color: str


@dataclass
class Model:
vertices: numpy.ndarray
triangles: list
position: list
rotation: numpy.ndarray

The load_model function takes the file name of a .csv file that represents the mesh. It gathers all of the files data about triangles and the vertices, then returns a Model object that we can use later. The function also sets the position of the mesh in 3d space to (-2, 0, 0) by default. This is so that the camera doesn’t spawn inside the cube. Now that we have something to look at let’s create a camera.

# pygame3dengine.py
import math
from dataclasses import dataclass
import numpy
import pygame
import csv


class Pygame3dEngine:
def __init__(self):
...
self.focal_length = 85
self.camera = Camera(numpy.array([0., 0., 0.]), numpy.array([0., 0., 0.]))
self.camera_clipping_planes = [
ClippingPlane((0, 0, 1), self.focal_length),
ClippingPlane((1 / math.sqrt(2), 0, 1 / math.sqrt(2)), 0),
ClippingPlane((-1 / math.sqrt(2), 0, 1 / math.sqrt(2)), 0),
ClippingPlane((0, 1 / math.sqrt(2), 1 / math.sqrt(2)), 0),
ClippingPlane((0, -1 / math.sqrt(2), 1 / math.sqrt(2)), 0),
]
...

@staticmethod
def load_model(filename: str) -> dataclass:
...

def check_for_quit(self):
...

def flip(self):
...


@dataclass
class Triangle:
...


@dataclass
class Camera:
position: numpy.ndarray
rotation: numpy.ndarray


@dataclass
class Model:
...


@dataclass
class ClippingPlane:
normal: tuple
distance_to_origin: float

The file stays the same, but I will put “…” where we have already discussed code. The camera is just a position and rotation. We also define the clipping planes that we discussed above. They are defined as a normal vector and a distance from (0, 0). The normal vector in this case is always rotated 45 degrees on various axis, hence the 1/sqrt(2). We now have a camera and an object, so let’s get to the fun part!

We need to create five helper functions before we can get to rendering the cube.

# pygame3dengine.py
import math
from dataclasses import dataclass
import numpy
import pygame
import csv


class Pygame3dEngine:
def __init__(self):
pygame.init()
self.display = pygame.display.Info().current_w, pygame.display.Info().current_h
self.running = True
self.focal_length = 85
self.scale = 5
self.center = self.to_screen_coordinates(0, 0)
self.camera = Camera(numpy.array([0., 0., 0.]), numpy.array([0., 90., 0.]))
self.camera_clipping_planes = [
ClippingPlane((0, 0, 1), self.focal_length),
ClippingPlane((1 / math.sqrt(2), 0, 1 / math.sqrt(2)), 0),
ClippingPlane((-1 / math.sqrt(2), 0, 1 / math.sqrt(2)), 0),
ClippingPlane((0, 1 / math.sqrt(2), 1 / math.sqrt(2)), 0),
ClippingPlane((0, -1 / math.sqrt(2), 1 / math.sqrt(2)), 0),
]
self.font = pygame.font.SysFont('Comic Sans', 12)
self.screen = pygame.display.set_mode(self.display, pygame.FULLSCREEN)
self.clock = pygame.time.Clock()

@staticmethod
def load_model(filename: str) -> dataclass:
...

def check_for_quit(self):
...

def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
return x + self.display[0] / 2, y + self.display[1] / 2

def to_rotation_coordinates(self, x: float, y: float) -> tuple[float, float]:
return x - self.display[0] / 2, y - self.display[1] / 2

@staticmethod
def translate(vertices, translation):
return vertices + translation

@staticmethod
def rotate(vertices, rotation: numpy.ndarray):
rotation = rotation * math.pi / 180
rotation_z_matrix = numpy.array([
[math.cos(rotation[2]), -math.sin(rotation[2]), 0],
[math.sin(rotation[2]), math.cos(rotation[2]), 0],
[0, 0, 1],
])
rotation_y_matrix = numpy.array([
[math.cos(rotation[1]), 0, math.sin(rotation[1])],
[0, 1, 0],
[-math.sin(rotation[1]), 0, math.cos(rotation[1])],
])
rotation_x_matrix = numpy.array([
[1, 0, 0],
[0, math.cos(rotation[0]), -math.sin(rotation[0])],
[0, math.sin(rotation[0]), math.cos(rotation[0])],
])
x_rotated = numpy.tensordot(rotation_x_matrix, vertices, axes=(1, 1)).T
xy_rotated = numpy.tensordot(rotation_y_matrix, x_rotated, axes=(1, 1)).T
xyz_rotated = numpy.tensordot(rotation_z_matrix, xy_rotated, axes=(1, 1)).T
return xyz_rotated

def flip(self):
...


@dataclass
class Triangle:
...


@dataclass
class Camera:
...


@dataclass
class Model:
...


@dataclass
class ClippingPlane:
...

The first function to_screen_coordinates converts any 2d coordinates’ origin to the center of the screen. The next one to_rotation_coordinates is the opposite. It takes any 2d coordinate with its origin in the center of the screen and changes the origin to the top left corner. The last two manipulate 3d objects through space. translate moves any object in the XYZ space using the matrices above. rotate performs rotation on the given object using all three rotation matrices above.

The last two helper functions deal with clipping.

# pygame3dengine.py
import math
from dataclasses import dataclass
import numpy
import pygame
import csv


class Pygame3dEngine:
def __init__(self):
...

@staticmethod
def load_model(filename: str) -> dataclass:
...

def check_for_quit(self):
...

def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
...

def to_rotation_coordinates(self, x: float, y: float) -> tuple[float, float]:
...

@staticmethod
def translate(vertices, translation):
...

@staticmethod
def rotate(vertices, rotation):
...

def clip_triangle(self, plane, triangle, vertices):
distances = numpy.array([
self.get_signed_distance(plane, vertices[triangle.a]),
self.get_signed_distance(plane, vertices[triangle.b]),
self.get_signed_distance(plane, vertices[triangle.c])
])

if all(distances > 0):
return True
elif all(distances < 0):
return False
else:
return True

@staticmethod
def get_signed_distance(plane, vertex):
normal_x, normal_y, normal_z = plane.normal
vertex_x, vertex_y, vertex_z = vertex
return vertex_x * normal_x + (vertex_y * normal_y) + (vertex_z * normal_z) + plane.distance_to_origin

def flip(self):
...


@dataclass
class Triangle:
...


@dataclass
class Camera:
...


@dataclass
class Model:
...


@dataclass
class ClippingPlane:
...

*Please note this is a very loose way of doing triangle clipping. If a single vertex is out of view, it does not render the whole triangle. I did it this way for proof of concept.

Ok, now lets put it all together in a single function project_mesh

# pygame3dengine.py
import math
from dataclasses import dataclass
import numpy
import pygame
import csv


class Pygame3dEngine:
def __init__(self):
...

@staticmethod
def load_model(filename: str) -> dataclass:
...

def check_for_quit(self):
...

def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
...

def to_rotation_coordinates(self, x: float, y: float) -> tuple[float, float]:
...

def project_mesh(self, model: dataclass):
vertices = self.rotate(model.vertices, rotation=model.rotation)
vertices = self.translate(vertices, translation=model.position)
vertices = self.translate(vertices, translation=self.camera.position)
vertices = self.rotate(vertices, rotation=self.camera.rotation)
triangles = [t for t in model.triangles if all([self.clip_triangle(p, t, vertices) for p in self.camera_clipping_planes])]
projected_x = [(vertex[0] * self.focal_length) / vertex[-1] for vertex in vertices]
projected_y = [(vertex[1] * self.focal_length) / vertex[-1] for vertex in vertices]
return ((self.to_screen_coordinates(projected_x[triangle.a] * self.scale, projected_y[triangle.a] * self.scale),
self.to_screen_coordinates(projected_x[triangle.b] * self.scale, projected_y[triangle.b] * self.scale),
self.to_screen_coordinates(projected_x[triangle.c] * self.scale, projected_y[triangle.c] * self.scale),
triangle.color) for triangle in triangles)

@staticmethod
def translate(vertices, translation):
...

@staticmethod
def rotate(vertices, rotation):
...

def clip_triangle(self, plane, triangle, vertices):
...

@staticmethod
def get_signed_distance(plane, vertex):
...

def flip(self):
...


@dataclass
class Triangle:
...


@dataclass
class Camera:
...


@dataclass
class Model:
...


@dataclass
class ClippingPlane:
...

This function combines all of the helper functions we have built and it applies the projection equation from above. It takes a model object and returns the 2d representation of the 3d object! But we still cannot see it. Let’s fix that!

# pygame3dengine.py
import math
from dataclasses import dataclass
import numpy
import pygame
import csv


class Pygame3dEngine:
def __init__(self):
...

@staticmethod
def load_model(filename: str) -> dataclass:
...

def check_for_quit(self):
...

def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
...

def to_rotation_coordinates(self, x: float, y: float) -> tuple[float, float]:
...

def project_mesh(self, model: dataclass):
...

def project_scene(self, scene):
return [self.project_mesh(mesh) for mesh in scene]

def render_mesh(self, projected_mesh):
for triangle in projected_mesh:
pygame.draw.polygon(self.screen, triangle[-1], triangle[:3], 5)

def render_scene(self, projected_scene):
for projected_mesh in projected_scene:
self.render_mesh(projected_mesh)

@staticmethod
def translate(vertices, translation):
...

@staticmethod
def rotate(vertices, rotation):
...

def clip_triangle(self, plane, triangle, vertices):
...

@staticmethod
def get_signed_distance(plane, vertex):
...

def flip(self):
...


@dataclass
class Triangle:
...


@dataclass
class Camera:
...


@dataclass
class Model:
...


@dataclass
class ClippingPlane:
...

Our engine is now able to render scenes instead of just a single model. project_scene takes a list of models and runs them through the project_mesh function. Similarly, render_scene runs the models through the render_mesh function. The last function render_mesh actually draws it to the pygame screen.

Great! It now has the capability to draw it to the screen! Now we need to go to main.py and put it all together.

# main.py
import time
import pygame
from pygame3dengine import Pygame3dEngine

engine = Pygame3dEngine()
pygame.mouse.set_visible(False)
model = engine.load_model("cube.csv")
scene = [model]

while engine.running:
engine.check_for_quit()
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
pygame.quit()

start = time.time()
projected_mesh = engine.project_scene(scene)
end = time.time()
project_latency = end - start

start = time.time()
engine.render_scene(projected_mesh)
end = time.time()
render_latency = end - start

scene[0].rotation += 1

text_surface = engine.font.render(f"Rotation: {engine.camera.rotation} | Position: {engine.camera.position} | {project_latency} | {render_latency}", True, (255, 255, 255))
engine.screen.blit(text_surface, (0, 0))
engine.flip()

When you run it you get a perfectly 3d cube that rotates in front of you! (Press Q to quit)

Wouldn’t it be better if we could move/look around? That’s exactly what we’re going to do.

# main.py

import time
import pygame
from pygame3dengine import Pygame3dEngine

engine = Pygame3dEngine()
pygame.mouse.set_visible(False)
model = engine.load_model("cube.csv")
scene = [model]

while engine.running:
engine.check_for_quit()
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
pygame.quit()

if keys[pygame.K_w]:
engine.camera.position[-1] += 0.1
if keys[pygame.K_s]:
engine.camera.position[-1] += -0.1
if keys[pygame.K_a]:
engine.camera.position[0] += -0.1
if keys[pygame.K_d]:
engine.camera.position[0] += 0.1
if keys[pygame.K_SPACE]:
engine.camera.position[1] += 0.1
if keys[pygame.K_LSHIFT]:
engine.camera.position[1] -= 0.1

x, y = engine.to_rotation_coordinates(*pygame.mouse.get_pos())
pygame.mouse.set_pos(engine.center)
engine.camera.rotation[0] += y / 2
engine.camera.rotation[1] += -x / 2
pygame.draw.circle(engine.screen, "white", engine.center, 3)

start = time.time()
projected_mesh = engine.project_scene(scene)
end = time.time()
project_latency = end - start

start = time.time()
engine.render_scene(projected_mesh)
end = time.time()
render_latency = end - start

text_surface = engine.font.render(f"Rotation: {engine.camera.rotation} | Position: {engine.camera.position} | {project_latency} | {render_latency}", True, (255, 255, 255))
engine.screen.blit(text_surface, (0, 0))
engine.flip()

Now W/A/S/D/Space/Shift will make us move, and moving the mouse makes us rotate. We also draw a white cross hair for reference in space.

We are done! If you enjoyed this article please clap, and respond. If you want to see more tutorials like this, follow me!

The full code can be found on my GitHub:

Thanks for reading! Happy Coding!

More Tutorials by hudbeard:

--

--

hudbeard

Professional software engineer since age 14. Programming since age 6. Python is my love language. 🐍