Blog

3D Rendering Algorithms Explained: Pipeline, Rasterization, Shading

To draw a 3D scene on a 2D screen, a renderer does two jobs: it works out where every triangle lands on the screen, and it works out what color each pixel inside it should be. Real-time engines do this with rasterization: transform vertices with matrices, project them, find the pixels each triangle covers, keep the nearest surface with a depth buffer, and shade the result. Ray tracing flips the loop: for each pixel, shoot a ray into the scene and find what it hits.

This post walks through those steps in order. Every algorithm is small enough to write in plain Python, and the code below was run, so you can copy it and see the output. It is not a replacement for a graphics course, but it gives you a working mental model of what OpenGL, Vulkan, DirectX and game engines do.

The pipeline in one picture

A rasterizing renderer pushes geometry through fixed stages:

  1. Vertex processing: move every vertex from the object’s own coordinates into world space, then camera space, then clip space (the model, view and projection matrices).
  2. Primitive assembly and clipping: group vertices into triangles and discard or cut the parts outside the view.
  3. Rasterization: decide which pixels each triangle covers.
  4. Fragment (pixel) shading: compute a color for each covered pixel from lighting and textures.
  5. Output merging: depth test, blending, write to the image.

On a GPU, steps 1 and 4 are programs you write (vertex and fragment shaders) and the rest is largely fixed hardware. The ideas below are the same on the CPU.

Step 1: transforms and perspective projection

Objects are described in their own coordinates, so a character model has its origin at its feet. Three matrices place it on screen: model (object to world), view (world to camera) and projection (camera to clip space). They are multiplied together, in the order projection, view, model, and applied to each vertex. Using 4D homogeneous coordinates is what lets translation and perspective be matrices too.

The key trick is the perspective divide. After projection, a point is (x, y, z, w) where w comes from the distance to the camera. Dividing x, y and z by w makes far things smaller. Here is a minimal version for a camera looking down the negative z axis, with the usual OpenGL-style depth mapping:

import math

def project(p, fov_deg, aspect, near, far, width, height):
    """p is in camera space; the camera looks down -z."""
    x, y, z = p
    f = 1.0 / math.tan(math.radians(fov_deg) / 2)
    a = (far + near) / (near - far)
    b = 2 * far * near / (near - far)
    cx, cy, cz, cw = f / aspect * x, f * y, a * z + b, -z
    if cw <= 0:
        return None                      # behind the camera
    nx, ny, nz = cx / cw, cy / cw, cz / cw   # perspective divide
    px = (nx + 1) / 2 * width            # NDC -> pixels
    py = (1 - ny) / 2 * height           # screen y points down
    return px, py, nz

for z in (-2, -4, -8):
    print(z, [round(v, 2) for v in project((1, 1, z), 90, 1.0, 0.1, 100, 80, 40)])
-2 [60.0, 10.0, 0.9]
-4 [50.0, 15.0, 0.95]
-8 [45.0, 17.5, 0.98]

The point (1, 1) gets closer to the screen center (40, 20) as it moves away from the camera: that is perspective. The third number is the depth value used later by the z-buffer. Note how it is squeezed toward 1 for far points: depth precision is concentrated near the camera, which is why a very small near value causes z-fighting in the distance.

Step 2: rasterization with edge functions

Now a triangle has three 2D screen positions. Which pixels are inside it? The standard answer is the edge function. For an edge from a to b and a point c, it returns a signed value that tells which side of the line c is on. A point is inside the triangle when it is on the same side of all three edges. Dividing each value by the triangle’s total area gives barycentric coordinates (w0, w1, w2), three weights that sum to 1. They are also how you interpolate any per-vertex value, such as depth, color or texture coordinates, across the triangle.

Scan only the triangle’s bounding box and test each pixel center. GPUs do essentially this, in parallel; it is simple and works well for many small triangles. The older scanline method instead walks row by row and fills between edges; it is the same idea as the scanline flood fill, and it avoids testing pixels outside the triangle.

Step 3: the z-buffer decides what is visible

Triangles arrive in any order, so a far triangle may be drawn after a near one. The z-buffer fixes this with one number per pixel: the nearest depth drawn so far. A new fragment is written only if its depth is smaller. Here is a complete rasterizer with a z-buffer that draws two overlapping triangles as ASCII:

def edge(a, b, c):
    return (c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0])

def raster(tris, W, H):
    depth = [[float("inf")] * W for _ in range(H)]
    img = [["."] * W for _ in range(H)]
    for (v0, v1, v2), ch in tris:
        area = edge(v0, v1, v2)
        if area == 0:
            continue
        minx = max(0, int(math.floor(min(v0[0], v1[0], v2[0]))))
        maxx = min(W - 1, int(math.ceil(max(v0[0], v1[0], v2[0]))))
        miny = max(0, int(math.floor(min(v0[1], v1[1], v2[1]))))
        maxy = min(H - 1, int(math.ceil(max(v0[1], v1[1], v2[1]))))
        for y in range(miny, maxy + 1):
            for x in range(minx, maxx + 1):
                p = (x + 0.5, y + 0.5)
                w0 = edge(v1, v2, p) / area
                w1 = edge(v2, v0, p) / area
                w2 = edge(v0, v1, p) / area
                if w0 >= 0 and w1 >= 0 and w2 >= 0:
                    z = w0 * v0[2] + w1 * v1[2] + w2 * v2[2]
                    if z < depth[y][x]:
                        depth[y][x] = z
                        img[y][x] = ch
    return ["".join(r) for r in img]

A = ((2, 1, 0.5), (18, 4, 0.5), (4, 14, 0.5))     # flat triangle at depth 0.5
B = ((10, 3, 0.2), (19, 12, 0.2), (7, 13, 0.9))   # tilted: near on top, far at the bottom
print("\n".join(raster([(A, "A"), (B, "B")], 22, 16)))
......................
..AAA.................
..AAAAAAAA............
..AAAAAAAABAAAA.......
...AAAAAAABBAAAAA.....
...AAAAAABBBBAAA......
...AAAAAABBBBBA.......
...AAAAAABBBBBB.......
...AAAAAAABBBBBB......
...AAAAAAABBBBBBB.....
...AAAAAABBBBBBBBB....
....AAAABBBBBBBBBBB...
....AA.BBBBBB.........
....A.................
......................
......................

Triangle B is tilted, so part of it is nearer than A’s depth of 0.5 and part is farther. B hides A where it is nearer, and A hides B where B is farther. Drawing B before A gives exactly the same image, which is the point: the z-buffer makes the result independent of draw order. (A production rasterizer also needs a tie-breaking “top-left” rule so pixels on a shared edge are drawn once, and perspective-correct interpolation, which interpolates attributes divided by w. This sketch skips both.)

The z-buffer’s limit is transparency: it keeps only one surface per pixel, so transparent objects are usually drawn afterward, sorted from far to near.

Step 4: lighting and shading

Once you know which surface a pixel shows, you compute its color from the surface normal N, the direction to the light L and the direction to the eye V.

  • Ambient: a constant term so shadowed areas are not pure black.
  • Diffuse (Lambert): proportional to the dot product N·L, clamped at 0. Surfaces facing the light are bright, surfaces at a right angle are dark.
  • Specular (Phong): the highlight, from the angle between the eye and the light’s reflection, raised to a shininess power.
def dot(a, b): return sum(x * y for x, y in zip(a, b))
def sub(a, b): return tuple(x - y for x, y in zip(a, b))
def norm(a):
    l = math.sqrt(dot(a, a)); return tuple(x / l for x in a)
def reflect(i, n):
    d = 2 * dot(i, n); return tuple(x - d * y for x, y in zip(i, n))

def phong(pos, n, light, eye, ka=0.1, kd=0.7, ks=0.5, shin=32):
    n = norm(n); l = norm(sub(light, pos)); v = norm(sub(eye, pos))
    diff = max(dot(n, l), 0)
    r = reflect(tuple(-x for x in l), n)
    spec = max(dot(v, r), 0) ** shin
    return min(1.0, ka + kd * diff + ks * spec)

print(round(phong((0, 0, 0), (0, 0, 1), (0, 0, 5), (0, 0, 5)), 3))   # light and eye straight on
print(round(phong((0, 0, 0), (0, 0, 1), (5, 0, 5), (0, 0, 5)), 3))   # light at 45 degrees
print(round(phong((0, 0, 0), (0, 0, 1), (0, 0, -5), (0, 0, 5)), 3))  # light behind the surface
1.0
0.595
0.1

Facing the light head on, the brightness hits the cap of 1. At 45 degrees the diffuse term drops to about 0.7 x 0.707 and there is almost no highlight. With the light behind the surface only the ambient 0.1 remains.

Computing this per pixel (using interpolated normals) is Phong shading. Computing it per vertex and interpolating the colors is Gouraud shading, which is cheaper but misses highlights in the middle of large triangles. Modern engines use physically based rendering (PBR): materials are described by albedo, roughness and metalness, and the specular term comes from a microfacet model (the Cook-Torrance family) instead of the Phong exponent. The structure is the same, with more physically grounded formulas.

Textures and filtering

A texture maps an image onto a surface using (u, v) coordinates, interpolated with the barycentric weights. Since u and v rarely land exactly on a texel, you filter: nearest-texel is blocky, bilinear filtering blends the four surrounding texels. When a texture is shrunk far below its size, sampling it directly flickers, so renderers precompute mipmaps, a chain of images each half the size of the last, and sample the level that matches the on-screen size. Mipmaps cost about one third extra memory and remove most texture shimmering.

def bilinear(tex, u, v):
    h, w = len(tex), len(tex[0])
    x, y = u * (w - 1), v * (h - 1)
    x0, y0 = int(x), int(y)
    x1, y1 = min(x0 + 1, w - 1), min(y0 + 1, h - 1)
    s, t = x - x0, y - y0
    top = tex[y0][x0] * (1 - s) + tex[y0][x1] * s
    bot = tex[y1][x0] * (1 - s) + tex[y1][x1] * s
    return top * (1 - t) + bot * t

tex = [[0, 100], [200, 255]]
print(bilinear(tex, 0.5, 0.5), bilinear(tex, 0.25, 0))
138.75 25.0

The center of a 2 by 2 texture is the average of the four texels, (0 + 100 + 200 + 255) / 4 = 138.75.

The other approach: ray tracing

Instead of projecting triangles to pixels, a ray tracer sends a ray from the camera through each pixel and finds the closest object it hits. For a sphere that is a quadratic equation. Once you have the hit point, you shade it with the same lighting formulas, and you can shoot more rays for shadows, mirror reflections and refraction. Here is the smallest useful ray tracer, a single sphere lit from the upper left, printed as text (the dark side of the sphere shows as blank):

def ray_sphere(o, d, c, r):
    oc = sub(o, c)
    a = dot(d, d); b = 2 * dot(oc, d); cc = dot(oc, oc) - r * r
    disc = b * b - 4 * a * cc
    if disc < 0:
        return None
    t = (-b - math.sqrt(disc)) / (2 * a)
    return t if t > 0 else None

W, H = 40, 20
shades = " .:-=+*#%@"
light = norm((-1, 1, 1))
for j in range(H):
    row = ""
    for i in range(W):
        u = (i + 0.5) / W * 2 - 1
        v = 1 - (j + 0.5) / H * 2
        d = norm((u * 0.6, v * 0.6, -1))
        t = ray_sphere((0, 0, 3), d, (0, 0, 0), 1.0)
        if t is None:
            row += " "
        else:
            p = tuple(o + t * x for o, x in zip((0, 0, 3), d))
            row += shades[int(max(dot(norm(p), light), 0) * 0.999 * len(shades))]
    print(row)
                @%%%##**
            @@@@@%%%###*++=-
           @@@@@%%%###**++=-:
         @@@@@%%%%###**++==-:.
         @@@%%%%###***++==--:.
        %%%%%####***+++==--:..
        #######***+++===--:..
         ******++++===--::..
         +++++++===---::..
           ====----:::..
            .:::::...

The cost is the difference between the two approaches. Rasterization does work per triangle and is very fast on GPUs, but effects like accurate shadows and reflections need extra tricks (shadow maps, reflection probes). Ray tracing does work per pixel and per ray, and it needs an acceleration structure such as a bounding volume hierarchy (BVH) or k-d tree so each ray tests a handful of triangles instead of every triangle in the scene. Many current games combine them: rasterize the main image and use hardware ray tracing for selected effects.

Speeding things up

  • Frustum culling: skip objects completely outside the camera’s view, using a spatial structure such as an octree or BVH.
  • Back-face culling: skip triangles facing away from the camera; for closed meshes that removes about half the triangles.
  • Occlusion culling: skip objects hidden behind other objects.
  • Level of detail (LOD): use simpler meshes for distant objects.
  • Anti-aliasing: reduce jagged edges. MSAA takes several coverage samples per pixel at triangle edges; post-process methods such as FXAA and temporal methods blur or accumulate over frames.

Common mistakes when you write your own

  1. Wrong matrix order. Whether you multiply vectors on the left or right depends on your math library’s convention. Transforms are applied right to left in the usual column-vector convention.
  2. Forgetting the perspective divide or dividing before clipping, which breaks for points behind the camera (w is zero or negative).
  3. Non-normalized vectors in lighting. Dot products only equal the cosine of an angle for unit vectors.
  4. Transforming normals with the model matrix. With non-uniform scaling, normals need the inverse transpose of the model matrix.
  5. Flipped y axis. Screen y grows downward; most math has y growing up.
  6. Triangle winding. Back-face culling depends on whether front faces are clockwise or counter-clockwise; mixing conventions makes meshes vanish.

Where to go next

Writing a small software rasterizer like the one above, then adding a transform pipeline, a texture and a light, teaches more than reading about shaders. After that, move to a real API. Our posts on OpenGL and DirectX and on 3D programming with Unity and Unreal Engine cover the next steps. If the loops, arrays and vector math above felt shaky, practice them first with step-by-step problems on AlgoCademy.

A lesson in AlgoCademy: the step to write, a picture of what it does, the line written in the editor, and the check: Step 2 passed

Before you go

Start thinking like a software engineer.

Learn to code and solve interview problems, one small, checked step at a time.

Start learning free

No account needed. You write real code in your browser.