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.
#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
