Making a raytracer

At uni, I chose to take a course in computer-generated graphics. A big part of it was making a raytracer from scratch in C++. It was a very welcome assignment after so many less practical ones. Here is the final result:

A glass sphere with smooth lighting, shadows and refraction.

But this wasn't my first raytracer. When I first started working with React, I had the (not so) brilliant idea of building one in React.

I used ASCII characters for the shading, with a spinning triangle in the middle. See how it chugs! This is where I got to:

My first raytracer: ASCII shading in React.

I don't write about university assignments much, but I came away from this project with so much appreciation for the craft that I thought it would be nice to share some milestones and technical details.

I'll only cover the basics here, but the milestones below show some of the magic behind making the actual images.

The basic concept of a raytracer is shooting rays from the camera into a scene to create an image. If a ray hits a surface, we colour the corresponding pixel based on the material and the ray's relationship to the surface and lights.

Red silhouettes of a sphere, triangle and cylinder on a black background
The first intersections. Hit: red. Miss: black.

To add light and shadows, you shoot another ray from the intersection point towards a light. If something blocks it, the point is in shadow; if it reaches the light, the point is illuminated.

Several coloured primitives casting shadows across a green plane

You can make this even cooler by adding refraction: when light bends as it passes from one material into another. In a raytracer, we model this by changing the direction of rays as they enter and leave a surface.

Five glass spheres bending coloured diagonal stripes by increasing amounts
Refractive index rising from 1.0 to 1.5, left to right. Notice how the rays bend more as the refractive index increases.

You can take it further with textures. When a ray hits a surface, you look up the colour in a texture map using the surface coordinates at that point.

Cylinders, spheres and triangles wrapped in marble, granite, wood and grass textures
The same textures wrapped around three different primitives. Notice the seams where the textures repeat and the warping on the sphere.

But a material is not just its texture. Different materials have properties you can control, such as roughness and metalness. To simulate them, you tweak how rays bounce off a surface and how their contributions are combined.

A six by six grid of spheres progressing from rough and matte to glossy and metallic
Metalness versus roughness. The bottom is roughest. The right is most metallic.

If you put this all together, you can make interesting scenes with refraction, mirrors and colour. Colours can even bleed into one another. Notice how the red wall gives the white wall a slight red tint, and how the granite is smoother and more reflective than the wood.

All this random sampling can also create little bright specks known as fireflies. They appear when a pixel happens to use rays that pick up more light than their neighbours. This is possible because the direction of each per-pixel bounce is random.

A richly lit scene of textured and reflective spheres between red and blue walls

The two renders compared below use exactly the same scene and settings. The only difference is the slight randomness applied to rays as they bounce off a surface. That variation spreads the shadow transition across neighbouring pixels, producing softer shadows without a perfectly sharp outline. Every pixel that differs between the two renders is shown in red.

A red difference map showing pixels changed between two otherwise identical raytraced renders with randomised bounced rays
Red pixels differ between the two renders because each uses slightly different random bounce directions.

By using many triangles you can approximate almost any shape you want.

A raytraced chess knight with a wood-textured floor
A 258-vertex chess knight imported from Blender.

By modelling an area light or a camera aperture, then taking multiple samples, you can create soft shadows and even the depth-of-field effect that cameras with lenses have. Though this increases compute significantly.

A glossy sphere and cylinder lit by a broad soft area light
Area light and soft shadows
Colourful spheres with near and distant objects blurred by depth of field
Depth of field

Sampling a moving shape at different positions during the shutter interval can create motion blur too.

A pathtraced scene of glossy spheres streaked with motion blur
Notice the motion blur on the purple ball. Its reflection stays sharp because reflected rays did not inherit the original ray’s shutter timestamp.

Lastly, while not visual, a big chunk of the effort in this project is keeping the rendering time down. Each step in the pipeline adds realism, but also more computation. The main techniques cut out large chunks of work—for example, finding which primitives a ray might hit can be sped up by pruning branches of the scene, so we don't need to check every primitive.

I hope you enjoyed looking at these images as much as I enjoyed making them.

Here is a video of me making the raytracer in wintry Edinburgh.

Obsessively,
Tomas