Theme Catppuccin
Make yourself at homeCatppuccin

A softer light for long learning sessions.

Appearance

Loading your preference…

Dark palette

Choose a dark palette to try it.

Light uses Latte. Your choices stay in this browser.

Back to your source shelf

Inside the companion

First Light

Make your first CPU image, rasterize triangles, and trace an allocation budget.

Project files 15

Follow a pixel into the world

Ray intersections, shadows, shading, image allocation, and PPM output.

143 lines renderer.cpp
#include "prism/renderer.hpp"
#include <algorithm>
#include <cmath>
#include <fstream>
#include <limits>
#include <locale>
#include <new>
#include <numbers>
#include <optional>

namespace {
using prism::Vec3;
Vec3 add(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
Vec3 subtract(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
Vec3 scale(Vec3 a, double s) { return {a.x * s, a.y * s, a.z * s}; }
double dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
Vec3 cross(Vec3 a, Vec3 b) { return {a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x}; }
// All callers establish a nonzero finite length: validate_scene checks camera
// and light, and sphere normals have a strictly positive radius.
Vec3 unit(Vec3 a) { return scale(a, 1.0 / std::sqrt(dot(a, a))); }
struct Ray { Vec3 origin, direction; };
struct Hit { double distance; Vec3 point, normal; prism::Color color; };
constexpr double epsilon = 1e-5;

std::optional<double> sphere_distance(const Ray& ray, const prism::Sphere& sphere, double nearest) {
    const auto offset = subtract(ray.origin, sphere.center);
    // Unit ray direction makes the quadratic coefficient a = 1.
    const double half_b = dot(offset, ray.direction);
    const double c = dot(offset, offset) - sphere.radius * sphere.radius;
    const double discriminant = half_b * half_b - c;
    if (discriminant < 0) return std::nullopt;
    const double root = std::sqrt(discriminant);
    double distance = -half_b - root;
    if (distance <= epsilon) distance = -half_b + root; // Ray may start inside.
    if (distance <= epsilon || distance >= nearest) return std::nullopt;
    return distance;
}

std::optional<Hit> intersect(const Ray& ray, const prism::Scene& scene) {
    std::optional<Hit> hit;
    double nearest = std::numeric_limits<double>::infinity();
    for (const auto& sphere : scene.spheres) {
        if (const auto distance = sphere_distance(ray, sphere, nearest)) {
            nearest = *distance;
            const auto point = add(ray.origin, scale(ray.direction, nearest));
            hit = Hit{nearest, point, unit(subtract(point, sphere.center)), sphere.color};
        }
    }
    if (std::abs(ray.direction.y) > epsilon) {
        const double distance = (scene.ground_y - ray.origin.y) / ray.direction.y;
        if (distance > epsilon && distance < nearest) {
            const auto point = add(ray.origin, scale(ray.direction, distance));
            // Bounded hit distance is unnecessary for shading. Avoid conversion
            // of a large floor(double) to an integer; fmod stays in floating point.
            const bool alternate = std::fmod(std::floor(point.x) + std::floor(point.z), 2.0) != 0.0;
            auto color = scene.ground_color;
            const double factor = alternate ? 0.87 : 1.0;
            color = {color.r * factor, color.g * factor, color.b * factor};
            hit = Hit{distance, point, {0, ray.direction.y < 0 ? 1.0 : -1.0, 0}, color};
        }
    }
    return hit;
}

prism::Color shade(const Ray& ray, const prism::Scene& scene, Vec3 light) {
    const auto hit = intersect(ray, scene);
    if (!hit) return scene.background;
    const Ray shadow{add(hit->point, scale(hit->normal, epsilon * 4)), light};
    bool occluded = false;
    // Plane shading is one-sided; sphere geometry casts the useful shadows in
    // this small direct-light renderer. No recursive bounces or hidden workers.
    for (const auto& sphere : scene.spheres) {
        if (sphere_distance(shadow, sphere, std::numeric_limits<double>::infinity())) {
            occluded = true;
            break;
        }
    }
    const double diffuse = occluded ? 0 : std::max(0.0, dot(hit->normal, light));
    const double brightness = 0.18 + 0.82 * diffuse;
    return {hit->color.r * brightness, hit->color.g * brightness, hit->color.b * brightness};
}

std::uint8_t encode(double linear) {
    // A simple gamma-2 display transform, explicitly not a full color pipeline.
    const auto display = std::sqrt(std::clamp(linear, 0.0, 1.0));
    return static_cast<std::uint8_t>(std::lround(display * 255.0));
}
} // namespace

namespace prism {
std::expected<Image, Error> render(const Scene& scene) {
    if (auto checked = validate_scene(scene); !checked) return std::unexpected(checked.error());
    const auto width = static_cast<std::size_t>(scene.width);
    const auto height = static_cast<std::size_t>(scene.height);
    const auto count = width * height; // validate_scene established the product bound.
    if (count > std::numeric_limits<std::size_t>::max() / 3)
        return std::unexpected(Error{ErrorCode::invalid_scene, "RGB byte count would overflow."});
    try {
        Image image{scene.width, scene.height, std::vector<std::uint8_t>(count * 3)};
        const auto forward = unit(subtract(scene.camera.look_at, scene.camera.position));
        const auto right = unit(cross(forward, {0, 1, 0}));
        const auto up = cross(right, forward);
        const double half_height = std::tan(scene.camera.vertical_fov_degrees * std::numbers::pi / 360.0);
        const double half_width = half_height * static_cast<double>(width) / static_cast<double>(height);
        const auto light = unit(scene.light_direction);
        for (std::size_t y = 0; y < height; ++y) {
            for (std::size_t x = 0; x < width; ++x) {
                const double u = (2.0 * (static_cast<double>(x) + 0.5) / static_cast<double>(width) - 1.0) * half_width;
                const double v = (1.0 - 2.0 * (static_cast<double>(y) + 0.5) / static_cast<double>(height)) * half_height;
                const Ray ray{scene.camera.position, unit(add(forward, add(scale(right, u), scale(up, v))))};
                const auto color = shade(ray, scene, light);
                const auto offset = (y * width + x) * 3;
                image.rgb[offset] = encode(color.r);
                image.rgb[offset + 1] = encode(color.g);
                image.rgb[offset + 2] = encode(color.b);
            }
        }
        return image;
    } catch (const std::bad_alloc&) {
        return std::unexpected(Error{ErrorCode::allocation, "Image allocation failed."});
    }
}

std::expected<void, Error> write_ppm(const std::filesystem::path& path, const Image& image) {
    // Public boundaries validate their own contract, including caller-built Image.
    if (image.width == 0 || image.height == 0 || image.width > max_dimension || image.height > max_dimension)
        return std::unexpected(Error{ErrorCode::invalid_scene, "Invalid image dimensions."});
    const auto width = static_cast<std::size_t>(image.width);
    const auto height = static_cast<std::size_t>(image.height);
    if (height > max_pixels / width || image.rgb.size() != width * height * 3)
        return std::unexpected(Error{ErrorCode::invalid_scene, "Image RGB storage does not match its dimensions."});
    std::ofstream output{path, std::ios::binary | std::ios::trunc};
    if (!output) return std::unexpected(Error{ErrorCode::io, "Could not open the output file."});
    // PPM's numeric header is ASCII, regardless of the caller's global locale.
    output.imbue(std::locale::classic());
    output << "P6\n" << image.width << ' ' << image.height << "\n255\n";
    output.write(reinterpret_cast<const char*>(image.rgb.data()), static_cast<std::streamsize>(image.rgb.size()));
    output.close(); // Check close errors as well as buffered writes.
    if (!output) return std::unexpected(Error{ErrorCode::io, "PPM write failed; output may be incomplete."});
    return {};
}
} // namespace prism

This is the file included with the course. Copy and download give you its complete source.

Taking your work offline? Download all course companions