Skip to content
Michael Malura
570 GitHub stars 5810 npm downloads 53 Projects

Raytracing

A graphics card asks, for every triangle, which pixels it covers. A ray tracer turns that around and asks, for every pixel, what you would see looking through it from the camera. With spheres and a plane it fits in a few dozen lines. I also wanted to know what shadows and reflections cost, so in the demo you can switch each one off.

The mouse moves the light. It hangs three units above the floor, right over the spot you point at. Below the image you pick 80x45, 160x90 or 320x180 pixels (the picture is always stretched to 640x360) and turn shadows and reflections on and off. On the right the demo counts every single ray.

One ray per pixel

The camera sits at one point and every pixel gets a direction. The pixel center is mapped to the range -1 to 1 and scaled by the field of view and the aspect ratio, the z component is always 1:

const float sx = (2.0F * u - 1.0F) * aspect * cam.tanHalfFov;
const float sy = (1.0F - 2.0F * v) * cam.tanHalfFov - cam.shift;
return {cam.pos, normalize(Vec3{sx, sy, 1.0F})};

shift only moves the horizon up in the image without tilting the camera. Every object is asked whether and where the ray hits it, and the smallest positive distance t wins.

A sphere is a quadratic equation

A point on the ray is origin + t * dir. It lies on the sphere when its distance to the center equals the radius. Plugging that in gives a quadratic equation in t:

const Vec3 oc = ray.origin - s.center;
const float a = dot(ray.dir, ray.dir);
const float halfB = dot(oc, ray.dir);
const float c = dot(oc, oc) - s.radius * s.radius;
const float disc = halfB * halfB - a * c;
if (disc < 0.0F) return false;

A negative discriminant means the ray misses. Otherwise there are two solutions and the nearer one wins. If the ray starts inside the sphere, the nearer one is negative and the far one is the way out. The tests check this by hand: a ray from the center hits at t equal to the radius, a ray at y = 1.001 past a sphere of radius 1 misses, and at y = 1 it grazes exactly (discriminant 0). The floor is simpler: t = (height - origin.y) / dir.y, and a ray parallel to the floor never hits.

Normal and light

At the hit point the normal is (point - center) / radius. The dot product of the normal and the direction to the light says how bright the point gets: 1 when the surface faces the light, 0 when edge on, negative beyond that and clamped to 0. A fixed 0.2 on top keeps the back side from going black:

const float facing = std::max(0.0F, dot(normal, toLight));
Vec3 color = albedo * (kAmbient + (1.0F - kAmbient) * facing * lit);

lit is 1 or 0 and comes from the shadow ray.

Shadows

A point is in shadow when something stands between it and the light. So a second ray goes from the hit point to the light. If it hits a sphere before it reaches the light, lit is 0. The distance to the light is the upper limit for t, so a sphere behind the light casts no shadow. If the surface faces away from the light, the demo skips the ray, since only ambient light is left there anyway.

Shadow acne

The shadow ray starts on a surface. Rounding errors like to put the start a hair below it, say 0.0001. The first thing the ray finds is then the surface itself at t just above 0. The point shadows itself and the sphere ends up covered in dots and stripes. The fix is a small offset along the normal:

const Vec3 origin = point + normal * bias;   // bias = 0.001

The test rebuilds exactly that: a point on top of a unit sphere, 0.0001 under the surface, the light above it. Without the offset occluded wrongly reports a blocker, with 0.001 it does not.

Reflection

For the mirror ball the direction is mirrored at the normal, d - n * 2 * dot(d, n), and the new ray is followed like a camera ray, recursively:

if (opt.reflections && reflectivity > 0.0F && depth < opt.maxDepth) {
    const Ray bounce{point + normal * kShadowBias, normalize(reflect(ray.dir, normal))};
    color = lerp(color, trace(scene, bounce, depth + 1, opt, rays), reflectivity);
}

The ball reflects 85 percent, the floor 20, the rest is its own color. After at most 3 bounces it stops, otherwise two mirrors facing each other would never settle. The test sets up exactly that: two mirror balls opposite each other, a ray in between, and 4 rays get counted.

What it costs

At 320x180 that is 57,600 pixels. Without shadows and reflections it is exactly one ray per pixel, so 57,600. With shadows it is around 93,000, with reflections around 100,000 because the floor and the mirror ball send one more ray, and with both around 143,000, which is 2.5 per pixel.

In milliseconds it was mostly 2 to 5 on my Mac in the browser for a 320x180 image with everything on. My machine was busy with other things, so the numbers scatter. Without shadows and reflections it was fastest in my measurements, with everything on it took about 1.6 times as long.

So it does not stall on slower devices, the demo renders only as many rows from the top as fit into 8 milliseconds per frame and carries on in the next one. The orange line in the image shows where it is, below the line you still see the old picture. With the CPU throttled to a twenty-fifth in the DevTools an image takes about 50 milliseconds and is drawn over several frames. I did not try it on a real phone.

Pitfalls

The tile color of the floor comes from floor(x) + floor(z). A plain cast to int would make the tile around 0 twice as wide, because -0.5 and 0.5 both become 0. The normal must have length 1, otherwise neither the brightness nor the reflection is right. And the floor may only hit when t is positive: a ray pointing up would otherwise intersect the plane behind the camera.

You can find the code in the repo under raytracing. All demos of the series are collected in the project Game Dev Snippets.

28.08.2026 updated 06.11.2026
Share X Reddit Hacker News LinkedIn E-Mail
Subscribe to new posts RSS JSON