Renders shadows, mirror reflections, Snell's-law refraction, and full global illumination (color bleeding, soft indirect light) from a plain-text scene description - accelerated with a bounding volume hierarchy and parallelized across every CPU core with POSIX threads.
C11 · gcc · pthreads · zero third-party dependencies (only libm + libpthread)
- What this is
- Feature highlights
- Gallery
- Quick start
- How it works (end-to-end)
- Architecture at a glance
- The render loop
- Rendering modes
- Command-line interface & environment
- Scene file format (quick reference)
- Repository layout
- Full documentation
- Performance
This project is a ray tracer / path tracer: a program that renders a 2D image of a 3D scene by simulating the physics of light. For every pixel it shoots one or more rays out of a virtual camera, finds what those rays hit, and recursively follows how light scatters, reflects, refracts, and is emitted through the scene until it can estimate how much light - and of what color - arrives back at the camera.
It is written entirely in C11, compiles with a single make, and depends on
nothing beyond the C math library and POSIX threads. Everything - the
vector math, camera model, intersection routines, material shading, acceleration
structure, thread pool, scene parser, and image writer - is implemented
from first principles in this repository.
The renderer was built in layered, independently verifiable stages, each of
which is documented in depth in docs/.
flowchart LR
A["scene.txt<br/>(text description)"] --> B["Parser<br/>src/scene.c"]
B --> C["In-memory scene<br/>camera - materials - spheres - BVH"]
C --> D["Multithreaded<br/>render loop<br/>src/render.c"]
D --> E["Linear HDR<br/>framebuffer"]
E --> F["Gamma + quantize"]
F --> G["output.ppm<br/>(P3 image)"]
| Category | What is implemented |
|---|---|
| Camera | Configurable pinhole camera: position, look-at, up vector, vertical FOV, aspect ratio; correct top-left image origin. |
| Geometry | Analytic ray-sphere intersection; flat surfaces (walls) modeled as giant spheres. |
| Materials | Lambertian diffuse, Metal (fuzzy mirror), Dielectric (glass with Snell refraction, total internal reflection, Schlick Fresnel), and Emissive area lights. |
| Lighting | Two modes: classic direct lighting (explicit shadow rays to point lights) and full Monte Carlo path tracing (global illumination). |
| Sampling | Multisample anti-aliasing (jittered supersampling), cosine-weighted diffuse scattering, Russian-roulette path termination. |
| Color | Linear-space accumulation with gamma-2.0 correction on output. |
| Acceleration | Bounding Volume Hierarchy (AABB slab test, median split) - verified byte-identical to brute force. |
| Parallelism | pthreads row-band partitioning, lock-free shared framebuffer, deterministic per-thread PRNG. |
| Scene I/O | Human-readable, commentable text scene format with line-accurate error reporting. |
| Determinism | Identical inputs produce byte-identical output, single- or multi-threaded. |
The images below are placeholders. See Full documentation for what each one will show; final renders will replace them.
Progression of the rendering pipeline - each stage was validated before the next was added:
flowchart LR
S1["Camera rays<br/>+ background"] --> S2["Ray-sphere<br/>geometry"]
S2 --> S3["Diffuse shading<br/>+ hard shadows"]
S3 --> S4["Metal + glass<br/>reflection / refraction"]
S4 --> S5["Anti-aliasing<br/>+ gamma"]
S5 --> S6["BVH +<br/>multithreading"]
S6 --> S7["Path-traced<br/>global illumination"]
Monte Carlo convergence - noise decreases roughly as 1/sqrt(N) as samples per pixel increase:
Global illumination color bleeding - the signature proof that indirect light is being simulated:
# 1. Build (needs gcc + make; links -lm and -lpthread)
make
# 2. Render the bundled Cornell box demo to a PPM image
./raytracer scenes/cornell_box.txt output.ppm
# 3. View it (PPM opens in GIMP/IrfanView/feh, or convert with ImageMagick)
magick output.ppm output.pngWant a cleaner (less noisy) image? Crank the sample count:
RAYTRACER_SPP=1000 ./raytracer scenes/cornell_box.txt output.ppmFull build/run/troubleshooting details: docs/building-and-usage.md.
At the highest level, rendering one image is the following pipeline. Each box is
a real module in src/, documented in its own page under docs/.
flowchart TD
subgraph Load
S["scene.txt"] --> P["scene_load()<br/>parse & validate"]
P --> M["materials[]"]
P --> SP["spheres[]"]
P --> CAM["camera_init()"]
SP --> BVH["bvh_build()<br/>acceleration tree"]
end
subgraph Render["render_scene() - one pthread per row band"]
CAM --> R1["camera_get_ray(u,v)"]
R1 --> RC["ray_color() - recursive"]
BVH --> RC
M --> RC
RC -->|"hit"| SC["material_scatter() / emitted()"]
SC --> RC
RC -->|"miss"| BG["background"]
RC --> ACC["accumulate & average<br/>samples per pixel"]
end
ACC --> FB["linear framebuffer"]
FB --> OUT["framebuffer_write_ppm()<br/>gamma + quantize -> P3"]
- Parse the scene file into materials, spheres, lights, and a camera.
- Build a BVH over the spheres so intersection queries are
O(log n). - Spawn threads, each owning a contiguous band of image rows.
- For every pixel, shoot
Njittered rays and average the results (anti-aliasing). - Each ray is traced recursively by
ray_color(): intersect → shade → spawn a scattered ray → repeat, bounded by depth and Russian roulette. - Gamma-correct the accumulated linear color and write it as an ASCII PPM.
Modules and their dependencies (headers are mostly static inline for speed):
flowchart LR
main["main.c<br/>CLI, env, orchestration"]
scene["scene.c/.h<br/>parser + LoadedScene"]
render["render.c/.h<br/>threads, ray_color, PPM"]
camera["camera.c/.h"]
bvh["bvh.c/.h"]
sphere["sphere.c/.h"]
hl["hittable_list.c/.h"]
material["material.h"]
light["light.h"]
aabb["aabb.h"]
hittable["hittable.h"]
ray["ray.h"]
vec3["vec3.h<br/>math + PRNG"]
main --> scene
main --> render
scene --> camera
scene --> bvh
scene --> sphere
scene --> material
scene --> light
render --> camera
render --> bvh
render --> hl
render --> material
render --> light
bvh --> aabb
bvh --> sphere
sphere --> hittable
hl --> sphere
material --> hittable
light --> bvh
aabb --> ray
hittable --> ray
ray --> vec3
material --> vec3
aabb --> vec3
vec3.h sits at the bottom: it defines the double-precision vector type and
the thread-local PRNG that every stochastic part of the renderer draws from. A
deeper walkthrough is in docs/architecture.md.
The recursive core, ray_color(), is the heart of the program. In path-tracing
mode it evaluates the rendering equation by Monte Carlo integration:
sequenceDiagram
participant Cam as Camera
participant RC as ray_color(depth)
participant World as BVH / world
participant Mat as Material
Cam->>RC: primary ray (u, v)
loop until miss / absorbed / depth 0 / RR kill
RC->>World: nearest hit in (0.001, INF)?
alt miss
World-->>RC: none -> return background
else hit
World-->>RC: HitRecord (p, normal, material)
RC->>Mat: emitted() + scatter()
Mat-->>RC: emission, attenuation, scattered ray
Note over RC: Russian roulette past min depth<br/>color = emitted + attenuation * ray_color(scattered)
end
end
Details, including the exact unbiased Russian-roulette estimator and the
shadow-acne epsilon, are in
docs/lighting-and-path-tracing.md.
The renderer chooses its lighting model automatically from the scene:
flowchart TD
Q{"Scene contains an<br/>emissive material?"}
Q -->|Yes| PT["Path tracing<br/>(global illumination)<br/>light = emissive surfaces only<br/>cosine-weighted bounces + Russian roulette"]
Q -->|No| DL["Direct lighting<br/>explicit shadow rays to point lights<br/>+ one indirect bounce"]
- Path tracing produces physically correct indirect light (color bleeding, soft shadows) but needs hundreds to thousands of samples per pixel to converge.
- Direct lighting is fast and clean for hard-shadow previews but cannot produce indirect illumination.
See docs/lighting-and-path-tracing.md.
raytracer <scene.txt> <output.ppm>
| Variable | Effect | Default |
|---|---|---|
RAYTRACER_SPP |
Override samples per pixel (e.g. 1000). |
100 |
RAYTRACER_BRUTEFORCE |
If set, disable the BVH and use brute-force intersection (verification). | unset (BVH on) |
The program prints a one-line summary to stderr (resolution, spp, depth,
thread count, BVH/brute-force, lighting mode, wall-clock time). Full reference:
docs/building-and-usage.md.
Plain text, one directive per line. # starts a comment; blank lines are
ignored; tokens are whitespace-separated.
size 500 500
camera_pos 278 278 -800
camera_lookat 278 278 0
camera_up 0 1 0
camera_vfov 40
background 0 0 0
material white lambertian 0.73 0.73 0.73
material glass dielectric 1.5
material light emissive 12 12 12
sphere 190 90 190 90 glass
sphere 278 555 278 90 light
| Directive | Arguments |
|---|---|
size |
<width> <height> |
camera_pos / camera_lookat / camera_up |
<x> <y> <z> |
camera_vfov |
<degrees> in (0, 180) |
material ... lambertian |
<name> lambertian <r> <g> <b> |
material ... metal |
<name> metal <r> <g> <b> <fuzz> |
material ... dielectric |
<name> dielectric <ior> |
material ... emissive |
<name> emissive <r> <g> <b> |
sphere |
<cx> <cy> <cz> <radius> <material> |
light |
<px> <py> <pz> <r> <g> <b> |
background |
<r> <g> <b> (default: sky gradient) |
The complete grammar, validation rules, and error messages are in
docs/scene-format.md.
RayTracing/
├── Makefile # build: gcc -O2 -Wall -Wextra, links -lm -lpthread
├── README.md # this file
├── scenes/
│ └── cornell_box.txt # bundled demo scene
├── docs/ # in-depth documentation (start at docs/README.md)
│ └── images/ # figures (currently placeholders)
└── src/
├── vec3.h # double-precision vectors + thread-local PRNG
├── ray.h # ray type + ray_at()
├── camera.{h,c} # pinhole camera, primary-ray generation
├── hittable.h # HitRecord + set_face_normal()
├── sphere.{h,c} # ray-sphere intersection, bounding box
├── hittable_list.{h,c} # brute-force scene container
├── aabb.h # axis-aligned box + NaN-safe slab test
├── bvh.{h,c} # bounding volume hierarchy
├── material.h # scatter() + emitted() for all material types
├── light.h # point light + shadow-ray direct lighting
├── render.{h,c} # threads, ray_color(), PPM writer
├── scene.{h,c} # text scene parser -> LoadedScene
└── main.c # CLI entry point
Every subsystem has a dedicated, in-depth page:
| Document | Covers |
|---|---|
| Overview & reading guide | How the docs fit together. |
| Architecture | Module graph, data flow, memory ownership, lifecycles. |
| Building & usage | Toolchain, Makefile internals, CLI, env vars, platform notes. |
| Scene format | Full grammar, every directive, validation, errors. |
| Math & vectors | vec3 operations, sampling helpers, reflect/refract, the PRNG. |
| Camera & rays | Pinhole model, orthonormal basis, (u,v) mapping, coordinate system. |
| Geometry & intersection | Ray-sphere quadratic, hit records, face normals. |
| Materials | Lambertian, metal, dielectric, emissive; Snell, TIR, Schlick. |
| Lighting & path tracing | Rendering equation, direct vs. path tracing, Russian roulette. |
| Acceleration (BVH) | AABBs, the slab test, build & traversal, exactness verification. |
| Concurrency | pthreads partitioning, lock-free framebuffer, deterministic RNG. |
| Output & color | Linear HDR accumulation, gamma correction, PPM format. |
Representative timings for scenes/cornell_box.txt at 500×500, path
traced with the BVH across 12 threads:
| Samples / pixel | Wall-clock time |
|---|---|
| 10 | ~1.3 s |
| 100 | ~10.5 s |
| 1000 | ~99 s |
Time scales roughly linearly with sample count; Russian roulette keeps average
path length low, and multithreading is what makes high sample counts practical.
Methodology and analysis: docs/concurrency.md and
docs/acceleration-bvh.md.
libpthread. It builds with one command.
- Readable first. Hot paths are
static inline; every non-obvious decision (epsilon values, index-of-refraction ratios, box enclosure) is commented in the source and expanded indocs/.


