DEV Community

yubin yang
yubin yang

Posted on

Software Rendering Pipeline with Backface Culling

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
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode
  • 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]
    ])
Enter fullscreen mode Exit fullscreen mode
  • 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]
    ])
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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()
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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.


Preview gif

Full Video

YouTube Video

Github Repository

Github - Graphics


Top comments (0)