Form3D V1 - My first version of a 3D engine, written in Python.
Around early-to-mid-October 2025, I was wondering. How easy is it to make a 3d engine? I had no experience with 3d graphics, so it would be a fun challenge.
At the time, I did not have any knowledge of C++ or any language better suited for the job (I wanted it to be offline, so JavaScript was also out of the question). I ended up choosing Python and PyGame for the job. This project was created for a YouTube video, which I posted on October 28, 2025. The video explains a bit of the code, but brushes over it quicker than I would have liked.
I started with a wireframe render and moved on after the video was finished to add shaders and lighting. This way, instead of a wireframe, it showed faces. It was really janky and honestly kind of broken, but it was my first 3d engine, and it was mine.
The code for both versions is on my GitHub. The revamped version with shaders includes comments that explain what each feature does, allowing viewers of my video to download and use it themselves.
I will also have the code for the revamped version here, as the GitHub repository may not be available forever, and I want to preserve as many elements as possible.
I am planning on revamping and rewriting the engine in C++ when I am experienced enough. As of writing this, I am decent but not at all ready for something like this in C++. The engine will be rendered differently, possibly a raycaster. At this point, i am not sure, and it is for a future project of mine.
Picture of the artwork.
import pygame
import sys
import math
# ==============================
# Basic Setup
# ==============================
WIDTH, HEIGHT = 800, 600
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Form3D Engine")
clock = pygame.time.Clock()
# ==============================
# Matrix and Math Functions
# ==============================
def mat_mul_vec(m, v):
x = v[0]*m[0][0] + v[1]*m[1][0] + v[2]*m[2][0] + m[3][0]
y = v[0]*m[0][1] + v[1]*m[1][1] + v[2]*m[2][1] + m[3][1]
z = v[0]*m[0][2] + v[1]*m[1][2] + v[2]*m[2][2] + m[3][2]
w = v[0]*m[0][3] + v[1]*m[1][3] + v[2]*m[2][3] + m[3][3]
if w != 0:
x /= w
y /= w
z /= w
return [x, y, z]
def mat_identity():
return [[1,0,0,0],
[0,1,0,0],
[0,0,1,0],
[0,0,0,1]]
def mat_rot_x(angle):
c, s = math.cos(angle), math.sin(angle)
return [[1,0,0,0],
[0,c,-s,0],
[0,s,c,0],
[0,0,0,1]]
def mat_rot_y(angle):
c, s = math.cos(angle), math.sin(angle)
return [[c,0,s,0],
[0,1,0,0],
[-s,0,c,0],
[0,0,0,1]]
def mat_projection(fov, aspect, znear, zfar):
f = 1 / math.tan(fov / 2)
return [
[f/aspect,0,0,0],
[0,f,0,0],
[0,0,zfar/(zfar-znear),1],
[0,0,(-znear*zfar)/(zfar-znear),0]
]
def rotateX(v, angle):
c, s = math.cos(angle), math.sin(angle)
x, y, z = v
return [x, y*c - z*s, y*s + z*c]
def rotateY(v, angle):
c, s = math.cos(angle), math.sin(angle)
x, y, z = v
return [x*c + z*s, y, -x*s + z*c]
def vec_sub(a, b): return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]
def vec_add(a, b): return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]
def vec_scale(v, s): return [v[0]*s, v[1]*s, v[2]*s]
def vec_cross(a, b):
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
def vec_dot(a, b): return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]
def vec_normalize(v):
l = math.sqrt(v[0]**2+v[1]**2+v[2]**2)
if l == 0: return [0,0,0]
return [v[0]/l, v[1]/l, v[2]/l]
# ==============================
# OBJ Loader
# ==============================
def load_obj(filename):
vertices = []
faces = []
with open(filename, "r") as f:
for line in f:
if line.startswith("v "):
parts = line.split()
vertices.append([float(parts[1]), float(parts[2]), float(parts[3])])
elif line.startswith("f "):
parts = line.split()
face = [int(p.split("/")[0]) - 1 for p in parts[1:]]
if len(face) == 3:
faces.append([vertices[i] for i in face])
elif len(face) == 4:
faces.append([vertices[face[0]], vertices[face[1]], vertices[face[2]]])
faces.append([vertices[face[0]], vertices[face[2]], vertices[face[3]]])
return faces
# ==============================
# Load model
# ==============================
try:
model = load_obj("chicken.obj")
except FileNotFoundError:
print("OBJ file not found! Defaulting to cube.")
model = [
[[-1,-1,-1],[1,-1,-1],[1,1,-1]],
[[-1,-1,-1],[1,1,-1],[-1,1,-1]],
[[-1,-1,1],[1,1,1],[1,-1,1]],
[[-1,-1,1],[-1,1,1],[1,1,1]],
[[-1,-1,-1],[-1,1,-1],[-1,1,1]],
[[-1,-1,-1],[-1,1,1],[-1,-1,1]],
[[1,-1,-1],[1,-1,1],[1,1,1]],
[[1,-1,-1],[1,1,1],[1,1,-1]],
[[-1,1,-1],[1,1,-1],[1,1,1]],
[[-1,1,-1],[1,1,1],[-1,1,1]],
[[-1,-1,-1],[1,-1,1],[1,-1,-1]],
[[-1,-1,-1],[-1,-1,1],[1,-1,1]]
]
# ==============================
# Camera setup
# ==============================
camera_pos = [0,1,-5]
camera_rot = [0,0] # pitch, yaw
fov = math.radians(90)
znear = 0.1
zfar = 1000
aspect = WIDTH / HEIGHT
proj_matrix = mat_projection(fov, aspect, znear, zfar)
# ==============================
# Controls / Settings
# ==============================
speed = 0.1
rot_speed = 0.03
angle = 0
light_dir = vec_normalize([0.3, 1, -0.5])
menu_active = False
rot_mode = "none"
# ==============================
# Main Loop
# ==============================
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_1:
menu_active = not menu_active
keys = pygame.key.get_pressed()
# =====================
# MENU HANDLING
# =====================
if menu_active:
screen.fill((30, 30, 30))
font = pygame.font.SysFont(None, 36)
options = ["[H] Horizontal Spin", "[V] Vertical Spin", "[B] Both", "[N] None"]
title = font.render("Rotation Mode Menu", True, (255, 255, 100))
screen.blit(title, (WIDTH // 2 - 160, HEIGHT // 2 - 140))
for i, opt in enumerate(options):
text = font.render(opt, True, (255, 255, 255))
screen.blit(text, (WIDTH // 2 - 150, HEIGHT // 2 - 80 + i * 40))
pygame.display.flip()
# Key selection
if keys[pygame.K_h]:
rot_mode = "horizontal"
menu_active = False
elif keys[pygame.K_v]:
rot_mode = "vertical"
menu_active = False
elif keys[pygame.K_b]:
rot_mode = "both"
menu_active = False
elif keys[pygame.K_n]:
rot_mode = "none"
menu_active = False
clock.tick(30)
continue # skip rest of loop when menu is open
# =====================
# CAMERA ROTATION
# =====================
if keys[pygame.K_LEFT]: camera_rot[1] -= rot_speed
if keys[pygame.K_RIGHT]: camera_rot[1] += rot_speed
if keys[pygame.K_UP]: camera_rot[0] -= rot_speed
if keys[pygame.K_DOWN]: camera_rot[0] += rot_speed
# =====================
# CAMERA MOVEMENT
# =====================
yaw = camera_rot[1]
pitch = camera_rot[0]
forward = [
math.sin(yaw) * math.cos(pitch),
-math.sin(pitch),
math.cos(yaw) * math.cos(pitch)
]
forward = vec_normalize(forward)
right = [math.cos(yaw), 0, -math.sin(yaw)]
right = vec_normalize(right)
if keys[pygame.K_w]: camera_pos = vec_add(camera_pos, vec_scale(forward, speed))
if keys[pygame.K_s]: camera_pos = vec_sub(camera_pos, vec_scale(forward, speed))
if keys[pygame.K_a]: camera_pos = vec_sub(camera_pos, vec_scale(right, speed))
if keys[pygame.K_d]: camera_pos = vec_add(camera_pos, vec_scale(right, speed))
if keys[pygame.K_q]: camera_pos[1] -= speed
if keys[pygame.K_e]: camera_pos[1] += speed
# =====================
# OBJECT ROTATION PRESETS
# =====================
angle += 0.01
if rot_mode == "horizontal":
rot_x = mat_identity()
rot_y = mat_rot_y(angle)
elif rot_mode == "vertical":
rot_x = mat_rot_x(angle)
rot_y = mat_identity()
elif rot_mode == "both":
rot_x = mat_rot_x(angle)
rot_y = mat_rot_y(angle)
else:
rot_x = mat_identity()
rot_y = mat_identity()
# =====================
# DRAWING
# =====================
screen.fill((10, 10, 20))
triangles_to_draw = []
for tri in model:
transformed = []
for v in tri:
# Apply rotation to object vertices
rv = mat_mul_vec(rot_x, v)
rv = mat_mul_vec(rot_y, rv)
# Transform to camera space
rel = vec_sub(rv, camera_pos)
rel = rotateY(rel, -camera_rot[1])
rel = rotateX(rel, -camera_rot[0])
transformed.append(rel)
normal = vec_cross(vec_sub(transformed[1], transformed[0]), vec_sub(transformed[2], transformed[0]))
normal = vec_normalize(normal)
if normal[2] >= 0:
continue
brightness = max(0.2, vec_dot(normal, light_dir))
color = (int(200 * brightness), int(200 * brightness), int(255 * brightness))
projected = [mat_mul_vec(proj_matrix, v) for v in transformed]
screen_pts = [(int((v[0] + 1) * WIDTH / 2), int((1 - v[1]) * HEIGHT / 2)) for v in projected]
avg_z = sum(v[2] for v in transformed) / 3
triangles_to_draw.append((avg_z, color, screen_pts))
triangles_to_draw.sort(key=lambda t: t[0], reverse=True)
for _, color, pts in triangles_to_draw:
pygame.draw.polygon(screen, color, pts)
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()