#include "prism/renderer.hpp" #include #include #include #include #include #include #include #include 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 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 intersect(const Ray& ray, const prism::Scene& scene) { std::optional hit; double nearest = std::numeric_limits::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::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::lround(display * 255.0)); } } // namespace namespace prism { std::expected render(const Scene& scene) { if (auto checked = validate_scene(scene); !checked) return std::unexpected(checked.error()); const auto width = static_cast(scene.width); const auto height = static_cast(scene.height); const auto count = width * height; // validate_scene established the product bound. if (count > std::numeric_limits::max() / 3) return std::unexpected(Error{ErrorCode::invalid_scene, "RGB byte count would overflow."}); try { Image image{scene.width, scene.height, std::vector(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(width) / static_cast(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(x) + 0.5) / static_cast(width) - 1.0) * half_width; const double v = (1.0 - 2.0 * (static_cast(y) + 0.5) / static_cast(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 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(image.width); const auto height = static_cast(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(image.rgb.data()), static_cast(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