← Projects

Software rendering pipeline

May 2026 · Java, Computer graphics

Code

Every game and every 3D viewer runs the same pipeline: take triangles in 3D, decide where they land on the screen, decide which ones are visible and give them a colour. On a GPU most of this disappears behind a single draw call. This project does it by hand in Java, with no OpenGL and no scene graph. It reads an OBJ mesh, moves every triangle through a virtual camera, projects it onto the screen and fills the resulting polygons on a Swing panel.

Software-rendered deer, cube and plane Fig. 1: The three bundled models, rendered entirely in software

The idea: a chain of coordinate systems

A vertex is not drawn in one jump. It is described in a sequence of coordinate systems, and each step to the next one is a matrix multiplication or a division:

The pipeline from object space to pixels, and the projection as similar triangles Fig. 2: Top, the coordinate spaces a vertex passes through. Bottom, the perspective projection seen from the side.

SpaceWhat the coordinates meanHow to get there
Object spacepositions as stored in the OBJ fileload and normalize the mesh
Camera spacepositions relative to the camera, which looks along +z+zview matrix VV
Projective spacehomogeneous 4D coordinates (x,y,z,w)(x, y, z, w)projection matrix PP
Image plane2D positions on a plane at distance z0z_0divide by ww
Pixel spacecolumn and row on the Swing canvascamera intrinsics KK

Because the view, projection and intrinsics steps are all matrices, they can be multiplied into one 4×44 \times 4 matrix once per frame, M=K⋅P⋅VM = K \cdot P \cdot V. Every vertex then costs one matrix-vector product and one division.

Interactive demo

The demo below is a JavaScript port of the renderer with the same math: same camera, same projection, same culling test and the same painter’s algorithm, on procedurally generated meshes instead of the OBJ files.

1. Loading the mesh

An OBJ file is plain text. v x y z lines list the vertices and f i j k lines list the triangles as indices into that vertex list. The reader builds an indexed triangle mesh from them and drops degenerate triangles with almost zero area. The deer has 772 vertices and 1,503 triangles, the cube 8 and 12.

The three OBJ files come at very different scales, so after loading, every mesh is centred on its bounding box and scaled so that its largest side has a fixed length. One camera setup then works for all of them.

2. The camera and the view matrix

The camera is defined by three vectors: the eye position, the point ref it looks at and an up direction. From these the renderer builds an orthonormal camera frame:

z=ref−eye∥ref−eye∥,x=up×z∥up×z∥,y=z×xz = \frac{\text{ref} - \text{eye}}{\lVert \text{ref} - \text{eye} \rVert}, \qquad x = \frac{\text{up} \times z}{\lVert \text{up} \times z \rVert}, \qquad y = z \times x

Put as columns next to the eye position, these vectors form the matrix that maps camera coordinates into the world. The view matrix is its inverse. It moves the world so that the camera sits at the origin and looks along +z+z:

Matrix4f cameraToWorld = new Matrix4f(
        x.x, y.x, z.x, eye.x,
        x.y, y.y, z.y, eye.y,
        x.z, y.z, z.z, eye.z,
        0,   0,   0,   1);
Matrix4f view = cameraToWorld.invert();

The controls never touch the mesh. Dragging rotates eye around ref, the mouse wheel changes their distance, and Alt + drag moves both together to pan. The view matrix is rebuilt on every repaint, so the whole mesh goes through the pipeline again each frame.

3. Backface culling

On a closed mesh, the triangles facing away from the camera are always hidden behind the front ones. Culling skips them before any further work. A triangle faces the camera if its normal points towards it. In camera space the camera is at the origin, so the vector from a vertex aa to the camera is simply −a-a:

n=(b−a)×(c−a),visible if   n⋅(−a)>0n = (b - a) \times (c - a), \qquad \text{visible if } \; n \cdot (-a) > 0

This only works if all triangles are wound the same way, so that the cross product gives the outward normal. The Show normals option in the Java app and in the demo draws these normals, which is the quickest way to check the winding of a model.

4. Perspective projection

Distant objects look smaller. The side view in Fig. 2 shows why: a point at height yy and depth zz is seen along a ray through the eye, and that ray hits the image plane at distance z0z_0 at height y′=z0 y/zy' = z_0 \, y / z. Two similar triangles, nothing more.

A division by zz is not a linear map, so it cannot be a matrix on its own. Homogeneous coordinates solve this. The projection matrix copies zz into the fourth coordinate ww, and the division happens afterwards:

P(xyz1)=(z00000z00000z000010)(xyz1)=(z0xz0yz0zz)  →  ÷w    (z0x/zz0y/z)P \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} z_0 & 0 & 0 & 0 \\ 0 & z_0 & 0 & 0 \\ 0 & 0 & z_0 & 0 \\ 0 & 0 & 1 & 0 \end{pmatrix} \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} z_0 x \\ z_0 y \\ z_0 z \\ z \end{pmatrix} \;\xrightarrow{\;\div w\;}\; \begin{pmatrix} z_0 x / z \\ z_0 y / z \end{pmatrix}

The intrinsics matrix KK turns image-plane coordinates into pixels. It scales by the focal length ff, derived from the horizontal field of view, flips yy because screen rows grow downwards, and moves the origin to the centre of the canvas:

f=W2tan⁡(fovx/2),u=f x′+W2,v=−f y′+H2f = \frac{W}{2 \tan(\text{fov}_x / 2)}, \qquad u = f\,x' + \frac{W}{2}, \qquad v = -f\,y' + \frac{H}{2}

Triangles with a vertex behind the camera (w≤0w \le 0) are skipped, since dividing by a negative depth would flip them across the screen.

5. Visibility: the painter’s algorithm

After projection, several triangles can cover the same pixel, and the nearest one has to win. GPUs solve this per pixel with a depth buffer. Swing only offers fillPolygon, so the renderer uses the painter’s algorithm instead: compute one depth per triangle, the mean zz of its three vertices in camera space, sort from far to near and paint in that order, so that closer triangles cover the ones behind them.

triangles.sort(Comparator.comparing(RenderTriangle::depth).reversed());
for (RenderTriangle t : triangles) {
    g2.setColor(t.color());
    g2.fillPolygon(t.polygon());
}

This is cheap and works well for the bundled models. It fails when one depth value per triangle is not enough: when triangles intersect, as in the two-boxes demo, or when three triangles overlap each other in a cycle. No ordering of whole triangles is correct in those cases. A depth buffer has no such problem, because it decides per pixel.

6. Shading

Each triangle gets a single colour from its face normal nn and a few fixed lights:

  • a key light that defines the shape, max⁡(0,n⋅lkey)\max(0, n \cdot l_\text{key}),
  • a weaker fill light so that the shadow side does not go black,
  • a rim term that brightens faces seen at a grazing angle,
  • a Blinn-Phong highlight, max⁡(0,n⋅h)s\max(0, n \cdot h)^{s} with the half vector h=normalize(l+v)h = \text{normalize}(l + v) between the light and the view direction.

The shininess ss and the highlight strength are set per model, so the cube looks glossy and the deer matte. None of this is physically based. It is just enough shading to make the 3D shape readable. Because the colour is constant per triangle, the facets stay visible. That is flat shading.

The background is a dark gradient with a 4 × 4 Bayer dither pattern added, so that the GIF export does not show colour bands.