1. Overview
- In this project, I implemented a simple software renderer using Python and Pygame.
- The renderer follows the fundamental stages of a 3D rendering pipeline.
Local Space
↓
World Space
↓
View Space
↓
Clip Space
↓
Screen Space
- Model Transformation (Local Space → World Space)
- View Transformation (World Space → View Space)
- Perspective Projection (View Space → Clip Space)
- Viewport Transformation (Clip Space → Screen Space)
- Backface Culling
- Painter's Algorithm for depth sorting
2. Rendering Pipeline Implementation
- The original code is too long, so I only posted the important parts.
-Please check GitHub.
Transforming World Space into View Space using the View Matrix
def GetViewMatrix(CamPos, TargetPos, Up):
ViewZ = TargetPos - CamPos
ViewZ = ViewZ / np.linalg.norm(ViewZ)
ViewX = np.cross(Up, ViewZ)
ViewX = ViewX / np.linalg.norm(ViewX)
ViewY = np.cross(ViewZ, ViewX)
CamInv = np.array([
[ViewX[0], ViewX[1], ViewX[2], -np.dot(ViewX, CamPos)],
[ViewY[0], ViewY[1], ViewY[2], -np.dot(ViewY, CamPos)],
[ViewZ[0], ViewZ[1], ViewZ[2], -np.dot(ViewZ, CamPos)],
[0, 0, 0, 1]
])
FlipY = np.array([
[-1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, -1, 0],
[0, 0, 0, 1]
])
return np.matmul(FlipY, CamInv)
-
GetViewMatrix()creates a View Matrix that transforms objects from World Space into View Space. - The camera position, target position, and up vector are used to calculate the camera's local coordinate system. Once the View Matrix is applied, all objects are transformed into coordinates relative to the camera.
Transforming View Space into Clip Space using the Projection Matrix
def GetProjectionMatrix(FovDeg, Width, Height, Near=0.1, Far=1000):
Fov = math.radians(FovDeg)
Aspect = Width / Height
Distance = 1 / math.tan(Fov / 2)
return np.array([
[Distance / Aspect, 0, 0, 0],
[0, Distance, 0, 0],
[0, 0, (Near + Far) / (Near - Far), (2 * Near * Far) / (Near - Far)],
[0, 0, -1, 0]
])
-
GetProjectionMatrix()creates a Perspective Projection Matrix. - This matrix applies perspective to the scene, making distant objects appear smaller and nearby objects appear larger. It transforms coordinates from View Space into Clip Space.
Transforming Normalized Coordinates into Screen Coordinates using the Viewport Matrix
def GetViewportMatrix(Width, Height):
return np.array([
[Width / 2, 0, 0, Width / 2],
[0, -Height / 2, 0, Height / 2],
[0, 0, 0.5, 0.5],
[0, 0, 0, 1]
])
-
GetViewportMatrix()converts normalized device coordinates (NDC) into actual screen coordinates. - After projection, coordinates exist in a normalized range of -1 to 1. This matrix maps those coordinates into the screen resolution so that they can be rendered.
Determining Visible Faces using Backface Culling
def IsFrontFace(V0, V1, V2):
Edge1 = V1[:3] - V0[:3]
Edge2 = V2[:3] - V0[:3]
Normal = np.cross(Edge1, Edge2)
Center = (V0[:3] + V1[:3] + V2[:3]) / 3
# Camera is placed at the origin in view space
ViewDirection = -Center
# Visible when the face normal points toward the camera
return np.dot(Normal, ViewDirection) > 0
-
IsFrontFace()determines whether a triangle is facing the camera. - The function calculates the face normal using a cross product and compares it with the camera direction using a dot product. If the triangle is facing away from the camera, it is discarded before rendering.
- This process is known as Backface Culling and helps reduce unnecessary rendering work.
Transforming Cube Vertices and Rendering Visible Triangles
ViewVertices = []
ProjectedVertices = []
for Vertex in Vertices:
# Local space -> World space
WorldVertex = np.matmul(Translate, Vertex)
# World space -> View space
ViewVertex = np.matmul(ViewMatrix, WorldVertex)
ViewVertices.append(ViewVertex)
# View space -> Clip space
ClipVertex = np.matmul(ProjectionMatrix, ViewVertex)
# Perspective divide
if ClipVertex[3] != 0:
NdcVertex = ClipVertex / ClipVertex[3]
else:
NdcVertex = ClipVertex
# NDC -> Screen space
ScreenVertex = np.matmul(ViewportMatrix, NdcVertex)
ProjectedVertices.append((int(ScreenVertex[0]), int(ScreenVertex[1]), ScreenVertex[2]))
FaceDepths = []
for Face in Faces:
V0 = ViewVertices[Face[0]]
V1 = ViewVertices[Face[1]]
V2 = ViewVertices[Face[2]]
# Skip triangles facing away from the camera
if not IsFrontFace(V0, V1, V2):
continue
AvgDepth = sum(ProjectedVertices[Index][2] for Index in Face) / 3
FaceDepths.append((AvgDepth, Face))
FaceDepths.sort(key=lambda Item: Item[0], reverse=True)
for _, Face in FaceDepths:
Points = [
(ProjectedVertices[Index][0],
ProjectedVertices[Index][1])
for Index in Face
]
pygame.draw.polygon(Screen, Color, Points)
pygame.draw.polygon(Screen, (0, 0, 0), Points, 2)
# Update screen immediately
pygame.display.flip()
-
DrawCube()contains the main software rendering pipeline. - The cube vertices are first created in Local Space and then transformed through multiple coordinate spaces.
Local Space
→ World Space
→ View Space
→ Clip Space
→ NDC
→ Screen Space
- After the transformations, Backface Culling is applied to remove invisible faces.
- The remaining triangles are depth-sorted using Painter's Algorithm and finally rendered.
Results
The final renderer displays multiple cubes in the 3D scene by applying perspective projection and back culling.
Although simple compared to modern pipelines, manually implementing these steps allowed me to gain a much deeper understanding of how the rendering system works internally.
Through this project, I was able to learn various knowledge necessary for rendering, such as computer graphics mathematics, matrix transformations, coordinate space, and visibility.

Top comments (0)