A complete 3D renderer built entirely by hand in Python — no OpenGL, Vulkan, DirectX, WebGL, Three.js, or any graphics library. Every stage of the modern graphics pipeline is implemented manually and executed on the CPU:
Scene → Model transform → World space → View transform → Projection
→ Clip space → Frustum clipping → Perspective divide → Screen space
→ Back-face culling → Rasterization → Barycentric interpolation
→ Perspective-correct attribute interpolation → Depth test (z-buffer)
→ Per-fragment Blinn-Phong lighting → Framebuffer → Display
The only non-standard-library dependency is tkinter, which is used purely to
open a window, display the finished RGB framebuffer, and receive keyboard/mouse
input. It performs no rendering. Image output (PNG/BMP/PPM) is written by
hand.
- Python 3.8+ (uses only the standard library)
- A display for interactive mode (tkinter ships with CPython on Windows/macOS
and most Linux installs; on Debian/Ubuntu you may need
python3-tk) - Third-party libraries: none.
No pip installs required.
The repository includes ready-made launchers for every operating system. Double-click the one for your OS:
| Operating system | File to double-click | Notes |
|---|---|---|
| Windows | run.bat |
Shows a console with logs/stats |
| Windows (no console window) | run_gui.vbs |
Starts the renderer window hidden-console |
| macOS | Run.command |
Opens Terminal automatically |
| Linux (desktop) / any | run.sh |
chmod +x run.sh on first use |
| Any OS (terminal) | start.py |
python start.py |
All launchers accept the same options, e.g.:
# Windows
run.bat --width 800 --height 600
run.bat --offscreen out.png --animated --fps 15
# macOS / Linux
./run.sh --width 800 --height 600
./run.sh --offscreen out.png
# Any OS with Python
python start.py --width 800 --height 600Scripts need the executable flag once (this is stored in git, so it usually already applies after cloning):
chmod +x run.sh Run.command start.pyOn Ubuntu/Debian, install tkinter if interactive mode complains:
sudo apt install python3-tk-
Windows — right-click
run.bat→ Send to → Desktop (create shortcut). For a shortcut without a console, userun_gui.vbsthe same way. -
macOS — double-click
Run.command, or make an Alias of it on the Desktop (right-click the file → Make Alias). -
Linux — double-click
run.sh, or create~/.local/share/applications/renderer.desktop:[Desktop Entry] Version=1.0 Type=Application Name=3D Renderer from Scratch Exec=/absolute/path/to/run.sh Terminal=false
Run the interactive demo window (default 480×360; lower resolutions update
faster because the renderer is pure Python):
python src/main.pyBetter control over resolution / feel:
python src/main.py --width 480 --height 360
python src/main.py --width 800 --height 600 # slower, sharperRender a single still frame with no window:
python src/main.py --offscreen screenshots/out.png --width 800 --height 600Render a short animated sequence (cube rotates) and save every frame:
python src/main.py --offscreen screenshots/anim.png --width 400 --height 300 \
--animated --seconds 2 --fps 15Run the automated smoke tests (48 checks covering every pipeline stage):
python tests/smoke_test.py| Key / input | Action |
|---|---|
W / S |
move forward / backward |
A / D |
move left / right |
E / Q |
move up / down |
| Mouse drag | rotate the camera (yaw / pitch) |
P |
toggle wireframe overlay |
C |
toggle back-face culling |
T |
toggle textures |
F12 |
save a PNG screenshot to ./screenshots |
Esc |
quit |
The project follows the module layout requested in the specification:
src/
├── math/ Vector2, Vector3, Vector4, Matrix4 (from scratch)
├── graphics/ Color, Framebuffer, DepthBuffer, Vertex, Triangle,
│ Mesh, Material, Texture
├── renderer/ Renderer, Rasterizer, Camera, Lighting, Clipper
├── loaders/ OBJLoader
├── scene/ Object3D, Scene, Light (directional / point)
└── main.py entry point (interactive window + offscreen modes)
assets/ demo .obj files (pyramid, UV-sphere)
tests/ smoke_test.py
screenshots/ saved renders
Vector2/Vector3/Vector4— basic arithmetic, dot/cross product, length, normalization (Vector3 cross is used to build surface normals).Matrix4— identity, ×, ×-vector, translation, scaling, rotation about each axis, perspective projection, plus an inverse-transpose 3×3 normal matrix so vertex normals stay correct under non-uniform scale.
Color— RGBA floats with clamp + byte conversion.Framebuffer— a plain RGBA buffer;set_pixel,clear, gradient clear, and hand-written PPM / BMP / PNG writers.DepthBuffer— one float per pixel, initialized to the farthest value.Vertex— position, normal, uv.Triangle— three vertex indices.Mesh— vertices + triangles, proceduralcreate_cube/create_plane, smooth and flat normal generation.Material—baseColor, ambient/diffuse/specular strength, shininess, optional texture.Texture— checkerboard generator + wrap-around bilinear sampling.
Camera— position, yaw/pitch, FOV, aspect, near/far; movement, rotation,view_matrix()andprojection_matrix().Renderer— drives the whole per-frame pipeline.Rasterizer— bounding-box scan over each triangle using barycentric edge functions, with perspective-correct interpolation of depth, world position, normals and UVs via1/w, then the z-buffer test.Clipper— Sutherland–Hodgman clipping against the six clip-space frustum planes (near, far, left, right, top, bottom) with per-attribute interpolation.Lighting— per-fragment Blinn-Phong: ambient + diffuse + specular, supporting directional and point lights (with attenuation).
scene.Object3D— position / rotation / scale → model matrix.scene.Light—DirectionalLightandPointLight.loaders.OBJLoader— parsesv,vt,vn,f(all token forms, negative indices), triangulates polygons, and generates smoothed normals when the file has none (assets/pyramid.objhas no normals on purpose to exercise this).
The frame is produced like this (in Renderer.render):
- Clear — framebuffer gets a vertical sky gradient, depth buffer is reset
to the farthest depth (
+1in NDC). - For each object: build the model matrix from position/rotation/scale,
combine
MVP = Projection × View × Model, transform every vertex to clip space and (via the model matrix + normal matrix) to world space. - Back-face culling — the world-space face normal of each triangle is tested against the view vector; back faces are skipped.
- Frustum clipping — triangles fully inside pass through; partially outside triangles are clipped to the frustum in clip space; fully outside triangles are rejected.
- Rasterize — for each resulting triangle, compute the screen-space
bounding box and fill inside pixels using barycentric edge functions.
Depth, world position, normal and UV are interpolated perspective
correctly (
attr/winterpolated, then divided by interpolated1/w). - Depth test — a pixel is only written when its interpolated NDC depth is closer than the value in the z-buffer.
- Lighting — the fragment shader normalizes the interpolated normal and computes Blinn-Phong over every light, blending the material base color or a bilinearly-sampled texture.
- Display — the finished framebuffer is pushed to the window
(
Framebuffer.to_hex_rows+tkinter.PhotoImage), or saved to disk.
tests/smoke_test.py verifies, among other things:
- vector / matrix / camera math (normals, perspective near/far mapping),
- the rasterizer fills the correct pixels,
- z-buffer occlusion (far triangles are hidden even when drawn last),
- frustum clipping (inside kept, outside rejected, near-plane correctly cut),
- cube / plane / OBJ geometry and normal generation,
- texture sampling,
- the full pipeline produces a visible framebuffer.
A known visual check is rendered into screenshots/final_800.png.
This is an educational, correctness-first renderer written in pure
interpreted Python. Every fragment goes through a Python-level inner loop, so
a full 800×600 frame takes on the order of a few seconds and interactive
rate is modest (higher at 480×360 or below). The spec intentionally lists
performance work as the final milestone; an incremental, documented path is:
- already done: bounding-box rasterization, incremental edge functions,
back-face culling, frustum clipping, a C-speed framebuffer→hex transfer,
precomputed light vectors and
color × intensityper light, inverse-transpose normal matrix; - next steps: incremental (scanline) attribute interpolation so each
per-pixel attribute cost drops from ~5 ops to 1 add, gouraud/lambert fast
paths, tiled rasterization + multithreading (subinterpreters/processes
sidestep the GIL for an embarrassingly parallel renderer), flat-indexed
buffers / SIMD via
array, and a JIT (PyPy/Numba) for near-interactive frame rates.
| Specification stage | Where implemented |
|---|---|
| Framebuffer, setPixel, clear | graphics/Framebuffer.py |
| Line rasterization (Bresenham) | renderer/Rasterizer.py |
| Triangle rasterization + bounding box | renderer/Rasterizer.py |
| Vector2 / Vector3 / Vector4 | math/* |
| Matrix4 + transformations | math/Matrix4.py |
| Camera + perspective projection | renderer/Camera.py, math/Matrix4.py |
| 3D cube | graphics/Mesh.py |
| Z-buffer / depth test | graphics/DepthBuffer.py, renderer/Rasterizer.py |
| Back-face culling | renderer/Renderer.py |
| Vertex normals | graphics/Mesh.py, math/Matrix4.py |
| Diffuse + specular (Blinn-Phong) lighting | renderer/Lighting.py |
| OBJ loading | loaders/OBJLoader.py |
| UV coordinates + texture mapping | graphics/Mesh.py, graphics/Texture.py |
| Perspective-correct interpolation | renderer/Rasterizer.py |
| Frustum clipping | renderer/Clipper.py |
| Optimization / multithreading (roadmap) | see "Performance notes" |