3D Software Renderer from Scratch
"Building a full 3D rendering pipeline in C using SDL2 — from window initialization to triangle rasterization and memory cleanup. No GPU. No OpenGL. Just math and pixels."
1. Why Build This
Modern graphics APIs — OpenGL, Vulkan, Metal, DirectX — are extraordinary abstractions. They take the insane complexity of GPU programming and reduce it to a handful of function calls. But that abstraction comes at a cost: you stop understanding what's actually happening. Vertices become magic. Shaders become incantations. The pixel on screen feels disconnected from the math that produced it.
This project was a deliberate step backwards. Build a renderer entirely in C, with SDL2 handling only the window and pixel buffer. Every matrix multiply, every projection, every triangle fill — written by hand. The goal wasn't to ship a game engine. It was to truly understand what a GPU does, by doing it manually first.
It also connects directly to the kind of work I do at Amadeus — low-level C code, explicit memory management, no safety nets. Same discipline, different domain.
2. Bootstrapping: SDL2, Buffers, and a Coordinate System
The foundation is deliberately minimal. SDL2 creates the window and exposes a texture that maps directly to a uint32_t color buffer in memory. Every frame, the CPU writes pixels into that buffer, then SDL flushes it to the screen. No hardware acceleration, no shader pipeline — just a flat array of ARGB values and a loop.
The coordinate system is left-handed: X right, Y up, Z into the screen. This is an arbitrary choice that affects every cross product and projection formula downstream — making it explicit early avoids subtle bugs later. The color buffer and a separate depth buffer (Z-buffer) are allocated once at startup and freed exactly once at shutdown.
3. The Main Loop
The render loop follows the classic game architecture: process input, update state, render frame. The key addition is frame timing — a fixed delta keeps updates deterministic regardless of how fast the host machine runs.
while (is_running) {
process_input();
update();
render();
} SDL polls keyboard events — rotation, rendering mode toggles, quit — while all math and rendering logic remain fully custom. No engine, no framework. The loop is the engine.
4. OBJ Loading and the 3D Pipeline
Meshes are loaded from OBJ files — a plain-text format listing vertices, texture coordinates, normals, and face indices. The parser reads each line, identifies the record type (v for vertex, f for face), and builds the internal mesh representation. Nothing exotic: just sscanf, dynamic arrays, and careful index handling since OBJ uses 1-based indexing.
Once a mesh is loaded, each vertex goes through the transformation pipeline every frame: world transform (rotation, translation, scale via 4×4 matrices), then view transform (camera space), then perspective projection to 2D screen coordinates.
// Perspective projection: 3D → 2D
float projected_x = (fov_factor * point.x) / point.z;
float projected_y = (fov_factor * point.y) / point.z;
// Convert to screen space
screen_x = (int)(projected_x + (window_width / 2));
screen_y = (int)(projected_y + (window_height / 2)); The perspective divide — dividing X and Y by Z — is what creates the illusion of depth. Objects further away become smaller. It's three lines of math, but it's the core of every 3D renderer ever written.
5. Back-Face Culling
Before rasterizing a triangle, we check whether it's actually facing the camera. If it isn't — if it's the back of a face — there's no point drawing it. This optimization cuts the number of triangles to process roughly in half for closed meshes, and it's the same check a GPU performs in its geometry stage.
The math: compute the surface normal via cross product of two edges, then take the dot product with the camera ray. Negative dot product means the face points away from the camera — skip it.
// Back-face culling: skip triangles facing away from camera
vec3_t normal = vec3_cross(vec3_sub(b, a), vec3_sub(c, a));
vec3_t camera_ray = vec3_sub(camera_pos, a);
// If dot product < 0, face points away → skip
if (vec3_dot(normal, camera_ray) < 0) {
continue;
} 6. Triangle Rasterization
Rasterization is the process of turning a triangle defined by three 2D screen-space vertices into a set of filled pixels. The approach used here splits every triangle into a flat-bottom and a flat-top half (guaranteed by sorting vertices by Y), then fills each half scanline by scanline using inverse slopes to track the left and right edges.
// Flat-bottom triangle fill
void fill_flat_bottom_triangle(
int x0, int y0,
int x1, int y1,
int x2, int y2,
uint32_t color
) {
float inv_slope_1 = (float)(x1 - x0) / (y1 - y0);
float inv_slope_2 = (float)(x2 - x0) / (y2 - y0);
float x_start = x0;
float x_end = x0;
for (int y = y0; y <= y2; y++) {
draw_horizontal_line(x_start, x_end, y, color);
x_start += inv_slope_1;
x_end += inv_slope_2;
}
} Depth ordering is handled by the painter's algorithm — triangles sorted by average Z value, drawn back to front. It breaks on intersecting geometry, which is why a proper Z-buffer is the natural next step: instead of sorting primitives, you compare per-pixel depth values and discard occluded fragments. The infrastructure for it is already in place.
7. Runtime Controls
Rendering modes can be toggled at runtime via keyboard: wireframe only, filled triangles only, wireframe over fill, or vertices as dots. Back-face culling can be enabled or disabled on the fly. This made debugging intuitive — when something looked wrong, switching to wireframe immediately revealed whether the issue was in geometry, projection, or rasterization.
8. Clean Exit and What It Teaches
When the loop ends, every allocated buffer is freed, every SDL resource is destroyed, and memory is returned cleanly. Valgrind confirms zero leaks. This isn't just hygiene — it's the discipline that carries directly into production C++ work, where memory errors in long-running services don't announce themselves until much later.
The real takeaway from this project isn't graphics. It's that the abstractions we rely on — in graphics, in operating systems, in networking — are not magic. They're math and memory, carefully organized. Building them manually once makes you a much better consumer of them forever.
"Understanding graphics begins when you stop relying on the GPU."
F-22 Raptor OBJ mesh — perspective projection, back-face culling, triangle rasterization. No GPU involved.