A software rasterizer written from scratch in C++20 — no OpenGL, no Vulkan, no third-party libraries. It walks the whole pipeline itself: load a Wavefront OBJ, transform vertices through model-view / perspective / viewport matrices, rasterize triangles with barycentric coordinates, depth-test against a z-buffer, and shade each fragment with diffuse, normal, and specular maps. The result is written out as a TGA image.
Inspired by ssloy/tinyrenderer.
- Pure CPU rasterization — bounding-box triangle fill with barycentric interpolation
- Z-buffering for correct hidden-surface removal
- Perspective projection and a
lookAtcamera you can position from the command line - Backface culling via the sign of the screen-space triangle determinant
- Texture mapping — diffuse, object-space normal, and specular maps
- Four shading modes — Phong, Gouraud, flat, and a depth-buffer visualization
- OpenMP parallelism across scanline columns, used automatically when available
- Full TGA support — reads and writes both raw and RLE-compressed 8/24/32-bit files
- Zero dependencies beyond a C++20 compiler and CMake
| Compiler | Any C++20 compiler — GCC 11+, Clang 14+, or MSVC 19.30+ (VS 2022) |
| Build system | CMake 3.24 or newer |
| Optional | OpenMP for multi-threaded rasterization; Python 3.8+ to convert output to PNG |
git clone https://github.com/raphaeldickinson/graphics-rasterizer.git
cd graphics-rasterizer
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build buildThe binary lands at build/rasterizer.
If you have Ninja installed, -G Ninja builds noticeably faster:
cmake -S . -B build -G Ninja
cmake --build buildThe project builds with either MSVC or MinGW. Pick whichever toolchain you already have.
Route 1 — Visual Studio 2022 (or Build Tools)
CMake and Ninja ship inside Visual Studio, so nothing extra is needed. From a Developer PowerShell for VS 2022:
cmake -S . -B build
cmake --build build --config ReleaseThe binary lands at build\Release\rasterizer.exe.
If cmake is not on your PATH, Visual Studio's copy lives at:
C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe
(Swap BuildTools for Community, Professional, or Enterprise to match your install.)
Route 2 — MSYS2 / MinGW-w64
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninjaThen from the UCRT64 shell:
cmake -S . -B build -G Ninja
cmake --build buildThe binary lands at build/rasterizer.exe.
To drive a MinGW build from PowerShell using Visual Studio's bundled CMake and Ninja:
$vs = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake"
$cmake = "$vs\CMake\bin\cmake.exe"
$ninja = "$vs\Ninja\ninja.exe"
& $cmake -S . -B build -G Ninja -DCMAKE_MAKE_PROGRAM="$ninja" -DCMAKE_CXX_COMPILER=g++
& $cmake --build buildRoute 3 — standalone CMake
winget install Kitware.CMakeRestart your shell, then follow the Linux/macOS instructions above.
ctest --test-dir build --output-on-failureThis exercises the vector/matrix maths, the TGA reader and writer (raw and RLE, at every supported bit depth), and the OBJ parser.
From the repository root:
./build/rasterizer obj/african_head/african_head.objThat writes assets/output.tga — an 800×800 Phong-shaded render of the head model, using the
diffuse, normal, and specular maps that sit beside the .obj. On Windows the command is
.\build\Release\rasterizer.exe (MSVC) or .\build\rasterizer.exe (MinGW).
Expect something like this on stderr:
# v# 1258 f# 2492 vt# 1339 vn# 1258
texture file obj/african_head/african_head_diffuse.tga loading ok
texture file obj/african_head/african_head_nm.tga loading ok
texture file obj/african_head/african_head_spec.tga loading ok
rendered 2492 triangles at 800x800 with the phong shader in 210 ms -> assets/output.tga
TGA is not natively viewable in Windows Photos or a browser. Convert it to PNG with the bundled script (standard library only — no Pillow needed):
python tools/tga2png.py assets/output.tga
# -> assets/output.pngOther options: GIMP, IrfanView, Krita, and ffmpeg -i output.tga output.png all read TGA directly.
rasterizer [options] <model.obj> [model2.obj ...]
Passing several .obj files renders them into the same frame, sharing one z-buffer, so they
occlude each other correctly.
| Option | Default | Description |
|---|---|---|
-o, --output <file> |
assets/output.tga |
Where to write the image. Parent directories are created automatically. |
-w, --width <n> |
800 |
Image width in pixels. |
-H, --height <n> |
800 |
Image height in pixels. |
--shader <mode> |
phong |
One of phong, gouraud, flat, depth. |
--eye x,y,z |
-1,0,2 |
Camera position. |
--center x,y,z |
0,0,0 |
The point the camera looks at. |
--up x,y,z |
0,1,0 |
Camera up vector; controls roll. |
--light x,y,z |
1,1,1 |
Direction the light travels from. |
-h, --help |
Print usage and exit. |
Note that -H is height and -h is help.
| Mode | What it does |
|---|---|
phong |
Ambient + Lambert diffuse + specular highlight. Reads per-fragment normals from the normal map and highlight strength from the specular map. The default, and the most detailed. |
gouraud |
Lighting computed once per vertex and interpolated across the triangle. Faster, visibly smoother/softer, no specular. |
flat |
One normal per triangle, so every facet reads as a flat plane. Makes the underlying mesh topology obvious. |
depth |
Grayscale visualization of the z-buffer, normalized to the model's own near and far extents. White is nearest. Useful for confirming depth testing is behaving. |
![]() phong |
![]() gouraud |
![]() flat |
![]() depth |
Note the detail Phong recovers from the normal map — pores, forehead creases, and specular highlights on the nose and lips — none of which exist in the 2,492-triangle mesh itself. Gouraud interpolates a single intensity per vertex, so the same geometry reads as soft and matte.
# Default Phong render of the head
./build/rasterizer obj/african_head/african_head.obj
# The Diablo 3 model at 1600x1600, written somewhere specific
./build/rasterizer -w 1600 -H 1600 -o renders/diablo.tga obj/diablo3/diablo3.obj
# Compare the shading models
for s in phong gouraud flat depth; do
./build/rasterizer --shader $s -o "renders/head_$s.tga" obj/african_head/african_head.obj
done
# Orbit the camera to the other side and move the light with it
./build/rasterizer --eye 1,0.5,2 --light -1,1,1 -w 1000 -H 1000 \
-o renders/head_alt.tga obj/african_head/african_head.obj
# Look up from below
./build/rasterizer --eye 0,-1.5,1.5 --center 0,0,0 obj/diablo3/diablo3.obj
# Straight-on portrait with a hard side light
./build/rasterizer --eye 0,0,2.5 --light 1,0,0.2 obj/african_head/african_head.obj
# Both models in one frame, sharing a z-buffer
./build/rasterizer obj/african_head/african_head.obj obj/diablo3/diablo3.objThe bundled models sit inside roughly the [-1, 1] cube centred on the origin. --eye is
therefore in the same units: 0,0,2 is straight in front, two units back. The perspective
strength is derived automatically from ‖eye − center‖, so pulling the camera back flattens the
projection and moving it in exaggerates it. Setting --eye closer than about 1.2 units puts
geometry behind the camera plane and produces artifacts — this renderer has no near-plane
clipping, which is the main simplification it inherits from tinyrenderer.
Point the binary at any triangulated (or convex-polygon) Wavefront OBJ:
./build/rasterizer -o renders/mine.tga path/to/mine.objSupported OBJ directives are v, vt, vn, and f, including the v/vt/vn, v//vn, and bare
v face forms, negative (relative) indices, and polygons with more than three vertices, which are
fan-triangulated on load.
Textures are discovered by filename, next to the .obj:
| File | Purpose | If missing |
|---|---|---|
<name>_diffuse.tga |
Base colour | Model renders white |
<name>_nm.tga |
Object-space normal map, RGB ↔ XYZ | Falls back to the OBJ's own vertex normals |
<name>_spec.tga |
Specular intensity, read from the red channel | No specular highlight |
So mine.obj picks up mine_diffuse.tga, mine_nm.tga, and mine_spec.tga. Note the normal
maps must be object-space, not tangent-space — tangent-space maps will shade incorrectly.
Models are used as-is with no auto-scaling, so geometry far outside the [-1, 1] cube will need a
matching --eye distance to fit in frame.
Each triangle takes the following path, which mirrors a real GPU pipeline:
- Vertex stage —
IShader::vertex()transforms an object-space vertex byModelViewand thenPerspective, returning a clip-spacevec4. It also stashes any varyings — UVs, normals, per-vertex light intensity — that the fragment stage will interpolate. - Perspective divide — dividing by
wmaps clip space into the normalized device cube. The perspective matrix writes−z/fintow, so this is what makes distant geometry shrink. - Viewport transform —
Viewportmaps NDC onto pixel coordinates. - Culling — the screen-space triangle determinant is negative for back-facing triangles and near zero for degenerate ones. Both are dropped.
- Rasterization — for every pixel in the triangle's bounding box, barycentric coordinates come from multiplying the inverse-transpose of the vertex matrix by the pixel position. Any negative coordinate means the pixel is outside the triangle.
- Depth test — the barycentric-interpolated depth is compared against the z-buffer, and the fragment is discarded if something nearer was already drawn.
- Fragment stage —
IShader::fragment()receives the barycentric weights, interpolates its varyings, samples the textures, and returns a colour (or discards the fragment).
Adding a shading model means writing one struct that implements IShader — see
src/shaders.hpp for the four that ship.
src/
geometry.hpp Header-only vec<n> / mat<rows,cols> with determinants and inverses
tgaimage.{hpp,cpp} TGA reader and writer, raw and RLE, 8/24/32-bit
model.{hpp,cpp} OBJ parser plus automatic texture discovery
gl.{hpp,cpp} Pipeline matrices, the IShader interface, and rasterize()
shaders.hpp Phong, Gouraud, flat, and depth shaders
main.cpp Argument parsing and the render loop
tests/
test_main.cpp Dependency-free unit tests for the maths, TGA I/O, and OBJ parsing
tools/
tga2png.py Standard-library TGA to PNG converter
obj/ Models and textures from tinyrenderer
assets/ Default output directory
error: model not found: ...
Paths are relative to your current directory, not the binary's. Run from the repository root.
The image is entirely black, or the model is missing
The camera is probably inside or behind the geometry. Reset with --eye 0,0,2 and increase the
distance from there.
Windows: "An Application Control policy has blocked this file" Smart App Control is enabled on your machine and blocks unsigned executables — including ones you just compiled yourself. Check with:
(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy').VerifiedAndReputablePolicyState1 means enforced, 2 means evaluation, 0 means off. Turning it off is done in
Windows Security → App & browser control → Smart App Control, but be aware Microsoft makes
this a one-way switch: re-enabling it requires reinstalling Windows. If you would rather not,
build and run inside WSL instead (wsl --install), where the policy does not apply.
Renders are slow
Make sure you built in Release (-DCMAKE_BUILD_TYPE=Release; it is the default here for
single-config generators, but Visual Studio needs --config Release). Confirm OpenMP was found —
CMake prints Found OpenMP_CXX during configuration. AppleClang does not ship OpenMP; install it
with brew install libomp.
cmake: command not found on Windows
See Route 1 above for Visual Studio's bundled copy, or winget install Kitware.CMake.
- ssloy/tinyrenderer — the course this pipeline follows,
and the source of the models and textures under
obj/
MIT — see LICENSE. Third-party attribution for the models and textures under obj/
is in NOTICE; each model directory also keeps its upstream readme.txt.




