I'm trying to write my own 3D engine using Python and Pygame.
I have successfully implemented rotation and projection matrices to display 3D models. However I am so far unable to implement translation and scaling matrices, I have instead been opting for manually displace the model by Z-axis (after rotation has been applied).
Also resizing the object and displacing it by X/Y-axises (after applying projection).
After trying to add scaling and translation matrices and also a camera, this is the result:
With only rotation and perspective matrices and manual displacement:
Apologiez for posting so much code but I'm truly stuck right now. I've tried to add as many comments as possible.
Very thankful for any help!
Code with camera and translation/scaling matrices:
import pygame
import math
import copy
import random
import obj_loader as obj
pygame.init()
screen_width = 1920
screen_height = 1080
screen = pygame.display.set_mode([screen_width, screen_height])
clock = pygame.time.Clock()
FPS = 60
run = True
class Renderer():
def __init__(self):
# random colors for model sides
self.colors = []
for _ in range(6):
self.colors.append([random.randint(0,255), random.randint(0,255), random.randint(0,255)])
# Models:
# "default" (cube)
# "cube"
# "pylon"
# "wireframe" (cube)
# "teapot"
model = "default"
self.rotation = [0,1,0]
self.rotation_speed = 0.015
self.wireframes = 0
self.color_fill = 1
self.rotx = 0; self.roty = 0; self.rotz = 0
# use basic cube or load model from obj-file
if model == "default":
self.size = 1
self.model = [
[[0, 0, 0, 1], [0, 1, 0, 1], [1, 1, 0, 1], self.colors[0]],
[[0, 0, 0, 1], [1, 1, 0, 1], [1, 0, 0, 1], self.colors[0]],
[[1, 0, 0, 1], [1, 1, 0, 1], [1, 1, 1, 1], self.colors[1]],
[[1, 0, 0, 1], [1, 1, 1, 1], [1, 0, 1, 1], self.colors[1]],
[[1, 0, 1, 1], [1, 1, 1, 1], [0, 1, 1, 1], self.colors[2]],
[[1, 0, 1, 1], [0, 1, 1, 1], [0, 0, 1, 1], self.colors[2]],
[[0, 0, 1, 1], [0, 1, 1, 1], [0, 1, 0, 1], self.colors[3]],
[[0, 0, 1, 1], [0, 1, 0, 1], [0, 0, 0, 1], self.colors[3]],
[[0, 1, 0, 1], [0, 1, 1, 1], [1, 1, 1, 1], self.colors[4]],
[[0, 1, 0, 1], [1, 1, 1, 1], [1, 1, 0, 1], self.colors[4]],
[[1, 0, 1, 1], [0, 0, 1, 1], [0, 0, 0, 1], self.colors[5]],
[[1, 0, 1, 1], [0, 0, 0, 1], [1, 0, 0, 1], self.colors[5]]]
for tri in self.model:
for point in tri:
point[0] -= 0.5; point[1] -= 0.5; point[2] -= 0.5
# load from obj-file
else:
self.size = 3
self.model = obj.load_obj(model)
# add random colors to model sides
self.colors = []
amount_vertices = len(self.model)
for _ in range(amount_vertices):
self.colors.append([random.randint(0,255), random.randint(0,255), random.randint(0,255)])
for i, tri in enumerate(self.model):
tri.append(self.colors[i])
# create empty projection matrix
self.projection = []
for _ in range(4):
self.projection.append([0] * 4)
self.aspect_ratio = screen_width/screen_height
n = 0.1
f = 1000
# projection matrix
self.projection[0][0] = 1 / (math.tan(math.radians(90/2)) * self.aspect_ratio)
self.projection[0][1] = 0
self.projection[0][2] = 0
self.projection[0][3] = 0
self.projection[1][0] = 0
self.projection[1][1] = 1 / math.tan(math.radians(90/2))
self.projection[1][2] = 0
self.projection[1][3] = 0
self.projection[2][0] = 0
self.projection[2][1] = 0
self.projection[2][2] = f / (f - n)
self.projection[2][3] = (-f * n) / (f - n)
self.projection[3][0] = 0
self.projection[3][1] = 0
self.projection[3][2] = 1
self.projection[3][3] = 0
# camera variables and matrix
self.camera_pos = [0,0,-1]
self.u = [1,0,0]
self.v = [0,1,0]
self.n = [0,0,1]
self.camera = [
[self.u[0], self.u[1], self.u[2], -self.camera_pos[0]],
[self.v[0], self.v[1], self.v[2], -self.camera_pos[1]],
[self.n[0], self.n[1], self.n[2], -self.camera_pos[2]],
[0, 0, 0, 1 ]
]
# create rotation matrices
self.update_roty(); self.update_roty(); self.update_rotz()
# translation matrix
self.translation = [
[1, 0, 0, 100],
[0, 1, 0, 100],
[0, 0, 1, 0],
[0, 0, 0, 1]
]
# scaling matrix
scale = 100
self.scaling = [
[scale, 0, 0, 0],
[0, scale, 0, 0],
[0, 0, scale, 0],
[0, 0, 0, 1]
]
# rotation X matrix
def update_rotx(self):
self.rotation_x = [
[1, 0, 0, 0],
[0, math.cos(self.rotx), math.sin(self.rotx), 0],
[0, -math.sin(self.rotx), math.cos(self.rotx), 0],
[0, 0, 0, 0]]
# rotation Y matrix
def update_roty(self):
self.rotation_y = [
[math.cos(self.roty), 0, math.sin(self.roty), 0],
[0, 1, 0, 0],
[-math.sin(self.roty), 0, math.cos(self.roty), 0],
[0, 0, 0, 0]]
# rotation Z matrix
def update_rotz(self):
self.rotation_z = [
[math.cos(self.rotz), -math.sin(self.rotz), 0, 0],
[math.sin(self.rotz), math.cos(self.rotz), 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 0]]
# Vector matrix multiplication
def Matrix_MultiplyVector(self, i, m):
vx = i[0] * m[0][0] + i[1] * m[0][1] + i[2] * m[0][2] + m[0][3]
vy = i[0] * m[1][0] + i[1] * m[1][1] + i[2] * m[1][2] + m[1][3]
vz = i[0] * m[2][0] + i[1] * m[2][1] + i[2] * m[2][2] + m[2][3]
vw = i[0] * m[3][0] + i[1] * m[3][1] + i[2] * m[3][2] + m[3][3]
if vw != 0:
vx /= vw
vy /= vw
vz /= vw
return [vx,vy,vz,vw]
# main render function
def draw(self):
# update rotation matrices
self.rotx += self.rotation_speed
self.roty += self.rotation_speed
self.rotz += self.rotation_speed
self.update_rotx(); self.update_roty(); self.update_rotz()
triangles_to_draw = []
# loop through triangles in model
for tri in self.model:
# create copy of triangle to project
tricopy = copy.deepcopy(tri)
proj_points = []
# skip 4th element in triangle because it's color
for i, _ in enumerate(tri):
if i == 3:
continue
# translate triangles
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.scaling)
# rotate triangles
if self.rotation[0] == 1:
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.rotation_x)
if self.rotation[1] == 1:
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.rotation_y)
if self.rotation[2] == 1:
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.rotation_z)
# scale triangles
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.translation)
# calculate normal of triangle
line1 = [
tricopy[1][0] - tricopy[0][0],
tricopy[1][1] - tricopy[0][1],
tricopy[1][2] - tricopy[0][2],
]
line2 = [
tricopy[2][0] - tricopy[0][0],
tricopy[2][1] - tricopy[0][1],
tricopy[2][2] - tricopy[0][2],
]
Nx = line1[1] * line2[2] - line1[2] * line2[1]
Ny = line1[2] * line2[0] - line1[0] * line2[2]
Nz = line1[0] * line2[1] - line1[1] * line2[0]
l = math.sqrt(Nx * Nx + Ny * Ny + Nz * Nz)
Nx /= l; Ny /= l; Nz /= l
# draw only if triangles normal compared with cameras normal is < 0
if (Nx * (tricopy[0][0] - self.camera_pos[0]) + Ny * (tricopy[0][1] - self.camera_pos[1]) + Nz * (tricopy[0][2] - self.camera_pos[2])) < 0:
for i, _ in enumerate(tri):
# skip 4th element in triangle because it's color
if i == 3:
continue
# camera and projection matrices
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.camera)
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.projection)
# calculate mean Z value and add triangles projected points to list
mean_z = (tricopy[0][2] + tricopy[1][2] + tricopy[2][2]) / 3
proj_points = [(tricopy[0][0], tricopy[0][1]), (tricopy[1][0], tricopy[1][1]), (tricopy[2][0], tricopy[2][1]), tricopy[3], mean_z]
triangles_to_draw.append(proj_points)
# sort by mean Z value
triangles_to_draw.sort(key=lambda i: (i[4]), reverse=True)
# draw triangles
for i, tri in enumerate(triangles_to_draw):
if self.color_fill == 1:
try:
pygame.draw.polygon(screen, tri[3], (tri[0], tri[1], tri[2]))
except:
print(tri[3])
if self.wireframes == 1:
pygame.draw.polygon(screen, [230,168,50], (tri[0], tri[1], tri[2]), 1)
renderer = Renderer()
# main loop
while run:
screen.fill([47,79,83])
mx, my = pygame.mouse.get_pos()
for event in pygame.event.get():
if event.type == pygame.QUIT:
run = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
run = False
keys = pygame.key.get_pressed()
# draw model
renderer.draw()
clock.tick(FPS)
pygame.display.flip()
pygame.quit()
Code with just perspective and rotation matrices:
import pygame
import math
import copy
import random
import obj_loader as obj
pygame.init()
screen_width = 1920
screen_height = 1080
screen = pygame.display.set_mode([screen_width, screen_height])
clock = pygame.time.Clock()
FPS = 60
run = True
class Renderer():
def __init__(self):
# random colors for model sides
self.colors = []
for _ in range(6):
self.colors.append([random.randint(0,255), random.randint(0,255), random.randint(0,255)])
# Models:
# "default" (cube)
# "cube"
# "pylon"
# "wireframe" (cube)
# "teapot"
model = "default"
self.rotation = [0,1,0]
self.rotation_speed = 0.015
self.wireframes = 0
self.color_fill = 1
self.rotx = 0; self.roty = 0; self.rotz = 0
# use basic cube or load model from obj-file
if model == "default":
self.model = [
[[0, 0, 0, 1], [0, 1, 0, 1], [1, 1, 0, 1], self.colors[0]],
[[0, 0, 0, 1], [1, 1, 0, 1], [1, 0, 0, 1], self.colors[0]],
[[1, 0, 0, 1], [1, 1, 0, 1], [1, 1, 1, 1], self.colors[1]],
[[1, 0, 0, 1], [1, 1, 1, 1], [1, 0, 1, 1], self.colors[1]],
[[1, 0, 1, 1], [1, 1, 1, 1], [0, 1, 1, 1], self.colors[2]],
[[1, 0, 1, 1], [0, 1, 1, 1], [0, 0, 1, 1], self.colors[2]],
[[0, 0, 1, 1], [0, 1, 1, 1], [0, 1, 0, 1], self.colors[3]],
[[0, 0, 1, 1], [0, 1, 0, 1], [0, 0, 0, 1], self.colors[3]],
[[0, 1, 0, 1], [0, 1, 1, 1], [1, 1, 1, 1], self.colors[4]],
[[0, 1, 0, 1], [1, 1, 1, 1], [1, 1, 0, 1], self.colors[4]],
[[1, 0, 1, 1], [0, 0, 1, 1], [0, 0, 0, 1], self.colors[5]],
[[1, 0, 1, 1], [0, 0, 0, 1], [1, 0, 0, 1], self.colors[5]]]
for tri in self.model:
for point in tri:
point[0] -= 0.5; point[1] -= 0.5; point[2] -= 0.5
# load from obj-file
else:
self.model = obj.load_obj(model)
# add random colors to model sides
self.colors = []
amount_vertices = len(self.model)
for _ in range(amount_vertices):
self.colors.append([random.randint(0,255), random.randint(0,255), random.randint(0,255)])
for i, tri in enumerate(self.model):
tri.append(self.colors[i])
# create empty projection matrix
self.projection = []
for _ in range(4):
self.projection.append([0] * 4)
self.aspect_ratio = screen_width/screen_height
n = 0.1
f = 1000
# projection matrix
self.projection[0][0] = 1 / (math.tan(math.radians(90/2)) * self.aspect_ratio)
self.projection[0][1] = 0
self.projection[0][2] = 0
self.projection[0][3] = 0
self.projection[1][0] = 0
self.projection[1][1] = 1 / math.tan(math.radians(90/2))
self.projection[1][2] = 0
self.projection[1][3] = 0
self.projection[2][0] = 0
self.projection[2][1] = 0
self.projection[2][2] = f / (f - n)
self.projection[2][3] = (-f * n) / (f - n)
self.projection[3][0] = 0
self.projection[3][1] = 0
self.projection[3][2] = 1
self.projection[3][3] = 0
# camera variables and matrix
self.camera_pos = [0,0,-4]
self.u = [1,0,0]
self.v = [0,1,0]
self.n = [0,0,1]
self.camera = [
[self.u[0], self.u[1], self.u[2], -self.camera_pos[0]],
[self.v[0], self.v[1], self.v[2], -self.camera_pos[1]],
[self.n[0], self.n[1], self.n[2], -self.camera_pos[2]],
[0, 0, 0, 1 ]
]
# create rotation matrices
self.update_roty(); self.update_roty(); self.update_rotz()
# translation matrix
self.translation = [
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
]
# scaling matrix
scale = 100
self.scaling = [
[scale, 0, 0, 0],
[0, scale, 0, 0],
[0, 0, scale, 0],
[0, 0, 0, 1]
]
# rotation X matrix
def update_rotx(self):
self.rotation_x = [
[1, 0, 0, 0],
[0, math.cos(self.rotx), math.sin(self.rotx), 0],
[0, -math.sin(self.rotx), math.cos(self.rotx), 0],
[0, 0, 0, 0]]
# rotation Y matrix
def update_roty(self):
self.rotation_y = [
[math.cos(self.roty), 0, math.sin(self.roty), 0],
[0, 1, 0, 0],
[-math.sin(self.roty), 0, math.cos(self.roty), 0],
[0, 0, 0, 0]]
# rotation Z matrix
def update_rotz(self):
self.rotation_z = [
[math.cos(self.rotz), -math.sin(self.rotz), 0, 0],
[math.sin(self.rotz), math.cos(self.rotz), 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 0]]
# Vector matrix multiplication
def Matrix_MultiplyVector(self, i, m):
vx = i[0] * m[0][0] + i[1] * m[0][1] + i[2] * m[0][2] + m[0][3]
vy = i[0] * m[1][0] + i[1] * m[1][1] + i[2] * m[1][2] + m[1][3]
vz = i[0] * m[2][0] + i[1] * m[2][1] + i[2] * m[2][2] + m[2][3]
vw = i[0] * m[3][0] + i[1] * m[3][1] + i[2] * m[3][2] + m[3][3]
if vw != 0:
vx /= vw
vy /= vw
vz /= vw
return [vx,vy,vz,vw]
# main render function
def draw(self):
# update rotation matrices
self.rotx += self.rotation_speed
self.roty += self.rotation_speed
self.rotz += self.rotation_speed
self.update_rotx(); self.update_roty(); self.update_rotz()
triangles_to_draw = []
# loop through triangles in model
for tri in self.model:
# create copy of triangle to project
tricopy = copy.deepcopy(tri)
proj_points = []
# skip 4th element in triangle because it's color
for i, _ in enumerate(tri):
if i == 3:
continue
# rotate triangles
if self.rotation[0] == 1:
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.rotation_x)
if self.rotation[1] == 1:
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.rotation_y)
if self.rotation[2] == 1:
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.rotation_z)
# calculate normal of triangle
line1 = [
tricopy[1][0] - tricopy[0][0],
tricopy[1][1] - tricopy[0][1],
tricopy[1][2] - tricopy[0][2],
]
line2 = [
tricopy[2][0] - tricopy[0][0],
tricopy[2][1] - tricopy[0][1],
tricopy[2][2] - tricopy[0][2],
]
Nx = line1[1] * line2[2] - line1[2] * line2[1]
Ny = line1[2] * line2[0] - line1[0] * line2[2]
Nz = line1[0] * line2[1] - line1[1] * line2[0]
l = math.sqrt(Nx * Nx + Ny * Ny + Nz * Nz)
Nx /= l; Ny /= l; Nz /= l
# draw only if triangles normal compared with cameras normal is < 0
if (Nx * (tricopy[0][0] - self.camera_pos[0]) + Ny * (tricopy[0][1] - self.camera_pos[1]) + Nz * (tricopy[0][2] - self.camera_pos[2])) < 0:
for i, _ in enumerate(tri):
# skip 4th element in triangle because it's color
if i == 3:
continue
# camera and projection matrices
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.camera)
tricopy[i] = self.Matrix_MultiplyVector(tricopy[i], self.projection)
tricopy[i][0] += 2; tricopy[i][0] *= 0.25 * screen_width
tricopy[i][1] += 2; tricopy[i][1] *= 0.25 * screen_height
# calculate mean Z value and add triangles projected points to list
mean_z = (tricopy[0][2] + tricopy[1][2] + tricopy[2][2]) / 3
proj_points = [(tricopy[0][0], tricopy[0][1]), (tricopy[1][0], tricopy[1][1]), (tricopy[2][0], tricopy[2][1]), tricopy[3], mean_z]
triangles_to_draw.append(proj_points)
# sort by mean Z value
triangles_to_draw.sort(key=lambda i: (i[4]), reverse=True)
# draw triangles
for i, tri in enumerate(triangles_to_draw):
if self.color_fill == 1:
try:
pygame.draw.polygon(screen, tri[3], (tri[0], tri[1], tri[2]))
except:
print(tri[3])
if self.wireframes == 1:
pygame.draw.polygon(screen, [230,168,50], (tri[0], tri[1], tri[2]), 1)
renderer = Renderer()
# main loop
while run:
screen.fill([47,79,83])
mx, my = pygame.mouse.get_pos()
for event in pygame.event.get():
if event.type == pygame.QUIT:
run = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
run = False
keys = pygame.key.get_pressed()
# draw model
renderer.draw()
clock.tick(FPS)
pygame.display.flip()
pygame.quit()
```