A ray tracing renderer written in Python, built step by step following the classic
Ray Tracing in One Weekend
tutorial. The current stage supports: camera viewport and pixel grid, ray-sphere
intersection, a material system (Lambertian / Metal / Dielectric), physically based
shading with recursive light bounces (max_depth), sky-gradient background, and
antialiasing with multiple samples.
- Vector and geometry math:
Vec3provides addition / subtraction / scalar multiplication / dot product / cross product / unit vector operations. - Camera model:
Cameraencapsulates the viewport, pixel deltas and rendering logic, following thecamerachapter of the book. It supports per-ray shading, multi-bounce recursion (max_depth) and antialiased sampling. - Sphere intersection:
Sphere.hit()solves the ray-sphere equation with the discriminant method and returns the nearest hit. - Material system:
material.Materialis an abstract base class whosescatterreturns(attenuation, scattered_ray)on a bounce orNonewhen the ray is absorbed. Three concrete materials are provided:- Lambertian (
material.Lambertian): ideal diffuse reflection. Scatters into the hemisphere around the surface normal with cosine weighting; attenuation is the surfacealbedo. - Metal (
material.Metal): specular reflection with adjustablefuzz(clamped to[0,1]) for frosted/blurry mirrors. ReturnsNonewhen the perturbed reflection points into the surface (ray absorbed). - Dielectric (
material.Dielectric): transparent refraction (e.g. glass) with total internal reflection and Schlick-approximation Fresnel reflection; attenuation is pure white.
- Lambertian (
- Sky-gradient background: on miss, interpolates a blue-white gradient by the ray direction's y component.
- Antialiasing: each pixel is randomly jittered and sampled multiple times
(
samples_per_pixel) then averaged.
.
├── main.py # Entry point: build scene (ground + Lambertian/Metal/Dielectric spheres) and call camera.render()
├── Camera.py # Camera: rendering, viewport math, per-ray shading, antialiased sampling, multi-bounce
├── Sphere.py # Sphere: implements Hittable, ray-sphere intersection
├── Hittable.py # Abstract base class (ABC) for hittable objects
├── HittableList.py # Scene container: aggregates objects, returns the nearest hit
├── HitRecord.py # Hit record: point / normal / t / front_face / material
├── Ray.py # Ray: origin + direction
├── Vec3.py # 3D vector (mutable components, read-only x/y/z properties)
├── Point3.py # 3D point (frozen dataclass)
├── Color.py # Color and write_color (PPM output)
├── Interval.py # Interval: clamps the valid t range of a ray
├── Number.py # Numeric type alias (float | int)
├── Random.py # Random number utilities
├── material/ # Material subsystem (shading logic)
│ ├── Material.py # Abstract base: scatter() -> (attenuation, scattered) | None
│ ├── Lambertian.py # Ideal diffuse (albedo)
│ ├── Metal.py # Specular reflection with fuzz
│ ├── Dielectric.py # Refraction + Fresnel (Schlick) reflection
│ └── __init__.py
├── draw_image.sh # Convenience script: run main.py and open the timestamped image.PPM
└── image_*.PPM # Rendered output(s)
- Python
>= 3.14 - Dependencies:
matplotlib,numpy,pillow(managed viauv, seepyproject.toml)
Use the bundled script (requires uv installed):
./draw_image.shThis runs main.py, writes the PPM image to a timestamped file image_<YYYY_MM_DD_HH_MM_SS>.PPM
and opens it for preview.
You can also run it manually:
uv run python main.py > image.PPMRender progress is printed to standard error (stderr); the PPM image stream goes to standard output (stdout).
Each core module file has a __main__ self-test block (print + assert) at the
bottom. Run the file directly to verify it:
uv run python Vec3.py
uv run python Sphere.py
uv run python Camera.py
uv run python material/Lambertian.py
uv run python material/Metal.py
uv run python material/Dielectric.py
# ... and so onOn success it prints "所有测试通过" (all tests passed).
Cameracomputes, ininitialize(), the image height, viewport size and pixel deltas fromaspect_ratioandimage_width; the camera sits at the origin looking down -Z.- For each pixel
(i, j), it shootssamples_per_pixelrandomly jittered rays (get_ray+sample_square) for antialiasing. ray_colortests the scene along the ray. If the recursion depth (max_depth) is exhausted it returns black. On hit, shading is delegated to the surface material:rec.material.scatter()returns(attenuation, scattered_ray), and the final color isattenuation * ray_color(scattered_ray, depth - 1); if the material absorbs the ray (returnsNone) the result is black. On miss it returns the sky-gradient background.- After averaging the multiple samples, it writes the result in PPM (P3) format via
write_color.
The renderer writes a PPM (P3) image to the output/ directory. The bundled
output/world_5.PPM is the final big scene (main_world_5()) rendered at 1920×1080 with samples_per_pixel=500,
max_depth=50, a 20° FOV and a
slight defocus (depth-of-field) blur. A converted PNG preview is also provided
for viewers that cannot display PPM directly.
The scene contains:
- A large diffuse ground plane (
Lambertian, grey albedo0.5). - A 22×22 grid of small spheres, each randomly assigned one of three materials:
- Lambertian (diffuse) — darker randomized colors.
- Metal — colored with a random
fuzzin[0, 0.5]for frosted mirrors. - Dielectric (glass) — refraction with Schlick Fresnel reflection.
- Three hero spheres: a glass sphere at the origin, a brown diffuse sphere at
(-4, 1, 0), and a perfect mirror at(4, 1, 0).
The image above demonstrates antialiasing (soft edges), the bluish sky-gradient background, glass refraction/frosted-metal highlights, and the defocus blur that keeps the focal plane sharp while foreground/background soften.
Note:
output/world_5.PPMis the raw PPM output;output/world_5.pngis the same image converted for easy preview. To regenerate, run the renderer (see Running) and point an image converter at the produced PPM.
This project is for learning purposes and follows the open spirit of the original tutorial.
