A physically-based path tracer written in pure Python — but with a twist that matters for a data-focused portfolio: instead of looping over pixels one ray at a time, it traces millions of rays at once as NumPy arrays. The same instinct that makes vectorized feature pipelines fast (replace Python loops with batched array math) is what makes this renderer fast.
Matte, metal and glass spheres on a diffuse ground plane — global illumination, soft shadows, reflections, refraction and depth of field, all from ~600 lines of documented Python.
I work in data engineering and analytics, and ray tracing turned out to be a perfect playground for the core skill of that world: thinking in arrays.
- A ray tracer is, mathematically, a giant pile of dot products and quadratic equations. That vectorizes beautifully.
- The "naive" textbook implementation loops over every pixel in Python. The
version here expresses the whole image as
(N, 3)array operations and runs ~12× faster as a result (see the benchmark). - It reuses the exact mental model behind NumPy / pandas / vectorized ML feature code — just applied to light instead of dataframes.
The design is hybrid: a clean object-oriented API on the outside (cameras, materials, hittable objects, scenes), with a fully vectorized numerical core on the inside.
| Materials | Lambertian (matte), Metal (reflective, with fuzz), Dielectric (glass, with Fresnel/Schlick) |
| Geometry | Spheres and infinite planes — each just needs a batched hit / normal_at |
| Camera | Positionable, adjustable field of view, depth-of-field via a thin-lens aperture |
| Rendering | Multi-sample anti-aliasing, configurable bounce depth, gamma correction, sky gradient |
| Reproducible | Seeded RNG → identical renders every run |
| Tooling | Single-image CLI, orbit-animation GIF, performance benchmark, explanatory notebook |
# 1. Clone and enter the project
git clone <your-repo-url> && cd RayTracing
# 2. Create a virtual environment and install dependencies
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS / Linux
pip install -r requirements.txt
# 3. Render an image
python render.py --width 800 --samples 64 --out outputs/render.pngA quick preview render (a few seconds):
python render.py --width 400 --samples 16 --out outputs/preview.pngA portfolio-quality render (a couple of minutes):
python render.py --width 1200 --samples 200 --depth 32 --out outputs/hero.pngpython animate.py --frames 36 --width 480 --samples 24 --out docs/orbit.gifpython benchmark.py --out docs/benchmark.pngA ray tracer asks one question, millions of times: starting from the camera, where does this ray go, and what colour does it collect along the way?
The core loop never iterates over pixels in Python. Instead it keeps every ray in the image alive simultaneously and advances them bounce-by-bounce:
throughput = ones((N, 3)) # colour multiplier accumulated along each path
active = ones(N, bool) # rays still bouncing
for _ in range(max_depth):
rec = scene.hit(origins[active], directions[active], ...) # all rays at once
# → misses collect the sky colour and stop
# → hits scatter according to their material, and `throughput` is updatedIntersecting a batch of rays with a sphere is the heart of it. The condition
N
rays is a single np.einsum('ij,ij->i', ...) call.
📓 The full derivation, with diagrams and a live render, is in
notebooks/raytracing_math.ipynb.
The same scene, same maths, rendered two ways: the "obvious" pure-Python ray-by-ray tracer vs. the vectorized NumPy renderer. The vectorized version is roughly an order of magnitude faster, and the gap widens with resolution as NumPy amortizes its per-call overhead.
Reproduce it with python benchmark.py.
RayTracing/
├── raytracer/ # the library
│ ├── vectors.py # batched 3D vector math (normalize, dot, reflect, refract)
│ ├── ray.py # a batch of rays
│ ├── camera.py # positionable thin-lens camera
│ ├── materials.py # Lambertian, Metal, Dielectric
│ ├── objects.py # Hittable base, Sphere, Plane, HitRecord
│ ├── scene.py # nearest-hit query over a list of objects
│ ├── renderer.py # the vectorized path-tracing loop ← the core
│ ├── image.py # PNG / GIF output helpers
│ └── scenes.py # the demo scene & orbiting camera
├── render.py # CLI: render one image
├── animate.py # CLI: render an orbit GIF
├── benchmark.py # CLI: vectorized vs naive, with a chart
├── build_notebook.py # regenerates the math notebook from code
├── notebooks/
│ └── raytracing_math.ipynb
├── docs/ # curated images used in this README
└── requirements.txt
Hittableobjects expose two batched methods:hit(returns per-ray distancet,+infon a miss) andnormal_at. Adding a new shape means writing just those two — the renderer doesn't change.Material.scatterreceives only the subset of rays that struck a surface of that material and returns, per ray, the outgoing direction, colour attenuation, and whether the ray survived. This keeps each material self-contained and vectorized.- Iteration over recursion. Classic ray tracers recurse per ray; recursion doesn't vectorize. Here the bounce loop is iterative and operates on the live set of rays, which is what makes the array formulation possible.
-
New shapes: implement
hit/normal_aton aHittable(a triangle mesh is a natural next step). -
New materials: subclass
Materialand implementscatter(emissive lights, textured albedo, ...). -
Quality: raise
--samples(noise falls as$1/\sqrt{N}$ ) and--depth.
Python 3.10+ and the packages in requirements.txt
(numpy, pillow, matplotlib, tqdm). The notebook additionally uses
nbformat / jupyter.
Released under the MIT License — see LICENSE.
Built as a portfolio project exploring how a graphics problem becomes a linear-algebra-on-arrays problem.


