From 0062ec57366b177c5414b35ca47922cfd63cb77e Mon Sep 17 00:00:00 2001 From: Mia Rolfe Date: Thu, 19 Feb 2026 17:38:21 +0000 Subject: [PATCH 1/3] Added much more complete test coverage --- lib/Materials/Texture.cpp | 1 + lib/Maths/Vec3Int.cpp | 35 +++- tests/AxisAlignedBoxTest.cpp | 160 +++++++++++++++++ tests/BSPTreeTest.cpp | 205 ++++++++++++++++++++++ tests/ColourTest.cpp | 56 ++++++ tests/HierarchicalUniformGridTest.cpp | 242 +++++++++++++++++++++++++ tests/KDTreeTest.cpp | 216 +++++++++++++++++++++++ tests/MaterialTest.cpp | 240 +++++++++++++++++++++++++ tests/OctreeTest.cpp | 200 +++++++++++++++++++++ tests/RandomTest.cpp | 152 ++++++++++++++++ tests/RayHitResultTest.cpp | 33 ++++ tests/RayTest.cpp | 30 ++++ tests/TextureTest.cpp | 166 ++++++++++++++++++ tests/TimerTest.cpp | 55 ++++++ tests/TraversalStatsTest.cpp | 81 +++++++++ tests/UniformGridTest.cpp | 244 ++++++++++++++++++++++++++ tests/UtilityTest.cpp | 73 ++++++++ tests/Vec3IntTest.cpp | 151 ++++++++++++++++ 18 files changed, 2339 insertions(+), 1 deletion(-) create mode 100644 tests/AxisAlignedBoxTest.cpp create mode 100644 tests/BSPTreeTest.cpp create mode 100644 tests/HierarchicalUniformGridTest.cpp create mode 100644 tests/KDTreeTest.cpp create mode 100644 tests/MaterialTest.cpp create mode 100644 tests/OctreeTest.cpp create mode 100644 tests/RandomTest.cpp create mode 100644 tests/TextureTest.cpp create mode 100644 tests/TimerTest.cpp create mode 100644 tests/TraversalStatsTest.cpp create mode 100644 tests/UniformGridTest.cpp create mode 100644 tests/Vec3IntTest.cpp diff --git a/lib/Materials/Texture.cpp b/lib/Materials/Texture.cpp index be50e97..2aefc2c 100644 --- a/lib/Materials/Texture.cpp +++ b/lib/Materials/Texture.cpp @@ -43,6 +43,7 @@ Colour ImageTexture::Value(double u, double v, const Point3& point) const { if (m_image.Width() <= 0) { + // Cyan return Colour(0.0, 1.0, 1.0); } diff --git a/lib/Maths/Vec3Int.cpp b/lib/Maths/Vec3Int.cpp index c5a3d20..45987fa 100644 --- a/lib/Maths/Vec3Int.cpp +++ b/lib/Maths/Vec3Int.cpp @@ -81,7 +81,10 @@ Vec3Int& Vec3Int::operator*=(int t) Vec3Int& Vec3Int::operator/=(int t) { - return *this *= static_cast(1.0 / t); + m_x /= t; + m_y /= t; + m_z /= t; + return *this; } double Vec3Int::Length() const @@ -104,4 +107,34 @@ Vec3Int Vec3Int::Random(int min, int max) return static_cast(RandomDouble(min, max + 1.0)); } +Vec3Int operator+(const Vec3Int& vec1, const Vec3Int& vec2) +{ + return Vec3Int(vec1.m_x + vec2.m_x, vec1.m_y + vec2.m_y, vec1.m_z + vec2.m_z); +} + +Vec3Int operator-(const Vec3Int& vec1, const Vec3Int& vec2) +{ + return Vec3Int(vec1.m_x - vec2.m_x, vec1.m_y - vec2.m_y, vec1.m_z - vec2.m_z); +} + +Vec3Int operator*(const Vec3Int& vec1, const Vec3Int& vec2) +{ + return Vec3Int(vec1.m_x * vec2.m_x, vec1.m_y * vec2.m_y, vec1.m_z * vec2.m_z); +} + +Vec3Int operator*(const Vec3Int& vec, int t) +{ + return Vec3Int(vec.m_x * t, vec.m_y * t, vec.m_z * t); +} + +Vec3Int operator*(int t, const Vec3Int& vec) +{ + return vec * t; +} + +Vec3Int operator/(const Vec3Int& vec, int t) +{ + return Vec3Int(vec.m_x / t, vec.m_y / t, vec.m_z / t); +} + } // namespace ART diff --git a/tests/AxisAlignedBoxTest.cpp b/tests/AxisAlignedBoxTest.cpp new file mode 100644 index 0000000..c510121 --- /dev/null +++ b/tests/AxisAlignedBoxTest.cpp @@ -0,0 +1,160 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include + +namespace ART +{ + +// Utility helper function. +// Unit box centred at origin: [-1, 1] on all axes +static AxisAlignedBox MakeUnitBox(Material* material) +{ + return AxisAlignedBox(Point3(-1.0), Point3(1.0), material); +} + +TEST_CASE("AxisAlignedBox Hit detects ray from +z hitting front face", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box = MakeUnitBox(&material); + + // Ray from (0, 0, 5) going in -z direction hits the +z face at t=4 + const Ray ray(Point3(0.0, 0.0, 5.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = box.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(4.0)); + REQUIRE(result.m_material == &material); + REQUIRE(result.m_is_front_facing == true); +} + +TEST_CASE("AxisAlignedBox Hit detects ray from -x hitting left face", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box = MakeUnitBox(&material); + + // Ray from (-5, 0, 0) going in +x direction hits the -x face at t=4 + const Ray ray(Point3(-5.0, 0.0, 0.0), Vec3(1.0, 0.0, 0.0)); + RayHitResult result; + const bool hit = box.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(4.0)); + REQUIRE(result.m_is_front_facing == true); +} + +TEST_CASE("AxisAlignedBox Hit misses a ray that does not intersect the box", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box = MakeUnitBox(&material); + + // Ray going in +y direction from below offset misses entirely + const Ray ray(Point3(10.0, -5.0, 0.0), Vec3(0.0, 1.0, 0.0)); + RayHitResult result; + const bool hit = box.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); +} + +TEST_CASE("AxisAlignedBox Hit returns false for ray origin inside the box", "[AxisAlignedBox]") +{ + // Interior intersections are not supported by AxisAlignedBox. + + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box = MakeUnitBox(&material); + + // Ray origin inside the box going in +z + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + RayHitResult result; + const bool hit = box.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); +} + +TEST_CASE("AxisAlignedBox Hit produces UV coordinates in [0, 1]", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box = MakeUnitBox(&material); + + // Several rays hitting different faces + const Ray rays[] = + { + Ray(Point3(0.0, 0.0, 5.0), Vec3( 0.0, 0.0, -1.0)), // +z face + Ray(Point3(0.0, 0.0, -5.0), Vec3( 0.0, 0.0, 1.0)), // -z face + Ray(Point3(5.0, 0.0, 0.0), Vec3(-1.0, 0.0, 0.0)), // +x face + Ray(Point3(0.0, 5.0, 0.0), Vec3( 0.0, -1.0, 0.0)), // +y face + }; + + for (const Ray& ray : rays) + { + RayHitResult result; + const bool hit = box.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_u >= 0.0); + REQUIRE(result.m_u <= 1.0); + REQUIRE(result.m_v >= 0.0); + REQUIRE(result.m_v <= 1.0); + } +} + +TEST_CASE("AxisAlignedBox Hit respects interval bounds", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box = MakeUnitBox(&material); + + const Ray ray(Point3(0.0, 0.0, 5.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + + // Hit is at t=4; interval starting at t=10 should miss + const bool hit = box.Hit(ray, Interval(10.0, infinity), result); + + REQUIRE(hit == false); +} + +TEST_CASE("AxisAlignedBox BoundingBox returns correct min and max (extents)", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + const AxisAlignedBox box(Point3(-2.0, -3.0, -4.0), Point3(2.0, 3.0, 4.0), &material); + + const AABB aabb = box.BoundingBox(); + + REQUIRE(aabb.m_x.m_min == Approx(-2.0)); + REQUIRE(aabb.m_x.m_max == Approx(2.0)); + REQUIRE(aabb.m_y.m_min == Approx(-3.0)); + REQUIRE(aabb.m_y.m_max == Approx(3.0)); + REQUIRE(aabb.m_z.m_min == Approx(-4.0)); + REQUIRE(aabb.m_z.m_max == Approx(4.0)); +} + +TEST_CASE("AxisAlignedBox BoundingBox constructor from AABB matches min/max constructor", "[AxisAlignedBox]") +{ + SolidColourTexture texture(Colour(0.5)); + LambertianMaterial material(&texture); + + const AABB aabb(Point3(-1.0, -1.0, -1.0), Point3(1.0, 1.0, 1.0)); + const AxisAlignedBox box_from_aabb(aabb, &material); + const AxisAlignedBox box_from_min_and_max_points(Point3(-1.0, -1.0, -1.0), Point3(1.0, 1.0, 1.0), &material); + + const AABB result_aabb = box_from_aabb.BoundingBox(); + const AABB result_min_and_max_points = box_from_min_and_max_points.BoundingBox(); + + REQUIRE(result_aabb.m_x.m_min == Approx(result_min_and_max_points.m_x.m_min)); + REQUIRE(result_aabb.m_x.m_max == Approx(result_min_and_max_points.m_x.m_max)); + REQUIRE(result_aabb.m_z.m_min == Approx(result_min_and_max_points.m_z.m_min)); + REQUIRE(result_aabb.m_z.m_max == Approx(result_min_and_max_points.m_z.m_max)); +} + +} // namespace ART diff --git a/tests/BSPTreeTest.cpp b/tests/BSPTreeTest.cpp new file mode 100644 index 0000000..4033efa --- /dev/null +++ b/tests/BSPTreeTest.cpp @@ -0,0 +1,205 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include + +namespace ART +{ + +TEST_CASE("BSPTreeNode constructor with vector of objects", "[BSPTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -1.0), 0.5, material)); + + BSPTreeNode bsp_tree(objects); + const AABB box = bsp_tree.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-0.5)); + REQUIRE(box.m_x.m_max == Approx(0.5)); + REQUIRE(box.m_y.m_min == Approx(-0.5)); + REQUIRE(box.m_y.m_max == Approx(0.5)); + REQUIRE(box.m_z.m_min == Approx(-1.5)); + REQUIRE(box.m_z.m_max == Approx(-0.5)); + } + + SECTION("Multiple objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 0.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 2.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 1.0, 1.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(-1.0, -1.0, -1.0), 0.5, material)); + + BSPTreeNode bsp_tree(objects); + const AABB box = bsp_tree.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-1.5)); + REQUIRE(box.m_x.m_max == Approx(2.5)); + REQUIRE(box.m_y.m_min == Approx(-1.5)); + REQUIRE(box.m_y.m_max == Approx(1.5)); + } +} + +TEST_CASE("BSPTreeNode Hit detects intersections", "[BSPTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Ray hits single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + BSPTreeNode bsp_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + } + + SECTION("Ray misses all objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 10.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(-10.0, 0.0, -5.0), 1.0, material)); + + BSPTreeNode bsp_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); + } + + SECTION("Ray hits closest object among multiple") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -10.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 0.5, material)); + + BSPTreeNode bsp_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(2.5)); + } + + SECTION("Ray respects interval bounds") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + BSPTreeNode bsp_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + + // Sphere at z = -5 with radius = 1, hit at t = 4, interval starting at t = 10 must miss + const bool hit = bsp_tree.Hit(ray, Interval(10.0, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("BSPTreeNode Hit finds spanning object from both sides of the split plane", "[BSPTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + // The spanning sphere straddles x=0, add enough well-separated spheres so + // FindSplitPlane can find a worthwhile x-axis split near x = 0 + std::vector objects; + objects.push_back(allocator.Create(Point3( 0.0, 0.0, 0.0), 1.5, material)); // straddles x = 0, y = 0, and z = 0 + objects.push_back(allocator.Create(Point3( 6.0, 0.0, 0.0), 0.5, material)); // front (+x) + objects.push_back(allocator.Create(Point3(-6.0, 0.0, 0.0), 0.5, material)); // back (-x) + objects.push_back(allocator.Create(Point3( 6.0, 6.0, 0.0), 0.5, material)); // front (+x) + objects.push_back(allocator.Create(Point3(-6.0, 6.0, 0.0), 0.5, material)); // back (-x) + + BSPTreeNode bsp_tree(objects); + + // Ray from +x side going through the spanning sphere + { + const Ray ray(Point3(10.0, 0.0, 0.0), Vec3(-1.0, 0.0, 0.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == true); + } + + // Ray from -x side going through the spanning sphere + { + const Ray ray(Point3(-10.0, 0.0, 0.0), Vec3(1.0, 0.0, 0.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == true); + } +} + +TEST_CASE("BSPTreeNode Hit works when all objects are on the same side (index-split fallback)", "[BSPTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + for (int i = 0; i < 10; i++) + { + // Offset by epsilon only — all centroids are essentially at (0, 0, -5) + const double tiny_offset = static_cast(i) * 1e-10; + objects.push_back(allocator.Create(Point3(tiny_offset, 0.0, -5.0), 0.5, material)); + } + + BSPTreeNode bsp_tree(objects); + + // Ray aimed at cluster must hit + { + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == true); + } + + // Ray aimed away from cluster must miss + { + const Ray ray(Point3(10.0, 10.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = bsp_tree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("BSPTreeNode BoundingBox encloses all objects", "[BSPTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(-5.0, -5.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3( 5.0, 5.0, 5.0), 1.0, material)); + + BSPTreeNode bsp_tree(objects); + const AABB box = bsp_tree.BoundingBox(); + + REQUIRE(box.m_x.m_min <= -6.0); + REQUIRE(box.m_x.m_max >= 6.0); + REQUIRE(box.m_y.m_min <= -6.0); + REQUIRE(box.m_y.m_max >= 6.0); + REQUIRE(box.m_z.m_min <= -6.0); + REQUIRE(box.m_z.m_max >= 6.0); +} + +} // namespace ART diff --git a/tests/ColourTest.cpp b/tests/ColourTest.cpp index ef2c1db..2170cc2 100644 --- a/tests/ColourTest.cpp +++ b/tests/ColourTest.cpp @@ -36,4 +36,60 @@ TEST_CASE("LinearToGamma converts linear colour values correctly", "[Colour]") } } +TEST_CASE("Colour arithmetic operations", "[Colour]") +{ + SECTION("Colour addition") + { + const Colour a(1.0, 0.5, 0.25); + const Colour b(0.5, 0.5, 0.75); + const Colour result = a + b; + + REQUIRE(result.m_x == Approx(1.5)); + REQUIRE(result.m_y == Approx(1.0)); + REQUIRE(result.m_z == Approx(1.0)); + } + + SECTION("Colour scalar multiply (pixel sample scale)") + { + const Colour c(4.0, 2.0, 1.0); + const Colour result = c * 0.25; + + REQUIRE(result.m_x == Approx(1.0)); + REQUIRE(result.m_y == Approx(0.5)); + REQUIRE(result.m_z == Approx(0.25)); + } + + SECTION("Colour component-wise multiply (attenuation)") + { + const Colour attenuation(0.8, 0.6, 0.4); + const Colour incoming(1.0, 0.5, 2.0); + const Colour result = attenuation * incoming; + + REQUIRE(result.m_x == Approx(0.8)); + REQUIRE(result.m_y == Approx(0.3)); + REQUIRE(result.m_z == Approx(0.8)); + } + + SECTION("Colour += accumulation over samples") + { + Colour sum(0.0, 0.0, 0.0); + sum += Colour(0.3, 0.3, 0.3); + sum += Colour(0.3, 0.3, 0.3); + sum += Colour(0.4, 0.4, 0.4); + + REQUIRE(sum.m_x == Approx(1.0)); + REQUIRE(sum.m_y == Approx(1.0)); + REQUIRE(sum.m_z == Approx(1.0)); + } +} + +TEST_CASE("LinearToGamma clamps negative input to zero", "[Colour]") +{ + // Must not produce NaN or negative values + const double result = LinearToGamma(-0.5); + + REQUIRE(!std::isnan(result)); + REQUIRE(result == Approx(0.0)); +} + } // namespace ART diff --git a/tests/HierarchicalUniformGridTest.cpp b/tests/HierarchicalUniformGridTest.cpp new file mode 100644 index 0000000..d900684 --- /dev/null +++ b/tests/HierarchicalUniformGridTest.cpp @@ -0,0 +1,242 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include +#include + +namespace ART +{ + +TEST_CASE("HierarchicalUniformGrid constructor with vector of objects", "[HierarchicalUniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -1.0), 0.5, material)); + + HierarchicalUniformGrid grid(objects); + const AABB box = grid.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-0.5)); + REQUIRE(box.m_x.m_max == Approx(0.5)); + REQUIRE(box.m_y.m_min == Approx(-0.5)); + REQUIRE(box.m_y.m_max == Approx(0.5)); + REQUIRE(box.m_z.m_min == Approx(-1.5)); + REQUIRE(box.m_z.m_max == Approx(-0.5)); + } + + SECTION("Multiple objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 0.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 2.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 1.0, 1.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(-1.0, -1.0, -1.0), 0.5, material)); + + HierarchicalUniformGrid grid(objects); + const AABB box = grid.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-1.5)); + REQUIRE(box.m_x.m_max == Approx(2.5)); + REQUIRE(box.m_y.m_min == Approx(-1.5)); + REQUIRE(box.m_y.m_max == Approx(1.5)); + } +} + +TEST_CASE("HierarchicalUniformGrid Hit detects intersections", "[HierarchicalUniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Ray hits single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + HierarchicalUniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + } + + SECTION("Ray misses all objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 10.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(-10.0, 0.0, -5.0), 1.0, material)); + + HierarchicalUniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); + } + + SECTION("Ray hits closest object among multiple") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -10.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 0.5, material)); + + HierarchicalUniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(2.5)); + } + + SECTION("Ray respects interval bounds") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + HierarchicalUniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + + // Sphere at z=-5 with r=1, hit at t=4; interval starting at t=10 must miss + const bool hit = grid.Hit(ray, Interval(10.0, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("HierarchicalUniformGrid produces the same closest hit as UniformGrid", "[HierarchicalUniformGrid]") +{ + // Both structures use identical 3DDDA traversal logic + // The same scene should give the same result. + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + // Use enough objects so both structures build multi-cell grids + std::vector objects_uniform_grid; + objects_uniform_grid.push_back(allocator.Create(Point3( 0.0, 0.0, -10.0), 1.0, material)); + objects_uniform_grid.push_back(allocator.Create(Point3( 0.0, 0.0, -5.0), 1.0, material)); + objects_uniform_grid.push_back(allocator.Create(Point3( 0.0, 0.0, -3.0), 0.5, material)); + objects_uniform_grid.push_back(allocator.Create(Point3( 3.0, 0.0, -5.0), 0.5, material)); + objects_uniform_grid.push_back(allocator.Create(Point3(-3.0, 0.0, -5.0), 0.5, material)); + + // Need separate copies of the vector because both constructors take non-const ref + std::vector objects_hierarchical_uniform_grid(objects_uniform_grid); + + UniformGrid uniform_grid(objects_uniform_grid); + HierarchicalUniformGrid hierarchical_grid(objects_hierarchical_uniform_grid); + + const Ray rays[] = + { + Ray(Point3(0.0, 0.0, 0.0), Vec3( 0.0, 0.0, -1.0)), + Ray(Point3(3.0, 0.0, 0.0), Vec3( 0.0, 0.0, -1.0)), + Ray(Point3(5.0, 0.0, 0.0), Vec3( 0.0, 0.0, -1.0)) + }; + + for (const Ray& ray : rays) + { + RayHitResult uniform_result; + RayHitResult hierarchical_result; + + const bool did_ray_hit_uniform_grid = uniform_grid.Hit(ray, Interval(0.001, infinity), uniform_result); + const bool did_ray_hit_hierarchical_uniform_grid = hierarchical_grid.Hit(ray, Interval(0.001, infinity), hierarchical_result); + + REQUIRE(did_ray_hit_uniform_grid == did_ray_hit_hierarchical_uniform_grid); + if (did_ray_hit_uniform_grid) + { + REQUIRE(hierarchical_result.m_t == Approx(uniform_result.m_t)); + } + } +} + +TEST_CASE("HierarchicalUniformGrid MemoryUsedBytes is non-zero (subgrid allocation)", "[HierarchicalUniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + for (int i = 0; i < 10; i++) + { + objects.push_back(allocator.Create(Point3(static_cast(i), 0.0, -5.0), 0.5, material)); + } + + HierarchicalUniformGrid grid(objects); + + REQUIRE(grid.MemoryUsedBytes() > 0); +} + +TEST_CASE("HierarchicalUniformGrid destructor does not crash (subgrid cleanup)", "[HierarchicalUniformGrid]") +{ + // Exercises Destroy() path that iterates all cells and deletes non-null subgrids + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Non-trivial scene") + { + std::vector objects; + for (int i = 0; i < 20; i++) + { + const int i_divided_by_five = i / 5; + objects.push_back(allocator.Create + ( + Point3(static_cast(i % 5), static_cast(i_divided_by_five), -5.0), + 0.4, + material + )); + } + + // Constructor + destructor via scope exit + { + HierarchicalUniformGrid grid(objects); + } + REQUIRE(true); // Reached without crashing + } + + SECTION("Single-object scene (minimal subgrid)") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 1.0, material)); + + // Constructor + destructor via scope exit + { + HierarchicalUniformGrid grid(objects); + } + REQUIRE(true); + } +} + +TEST_CASE("HierarchicalUniformGrid BoundingBox encloses all objects", "[HierarchicalUniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(-5.0, -5.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3( 5.0, 5.0, 5.0), 1.0, material)); + + HierarchicalUniformGrid grid(objects); + const AABB box = grid.BoundingBox(); + + REQUIRE(box.m_x.m_min <= -6.0); + REQUIRE(box.m_x.m_max >= 6.0); + REQUIRE(box.m_y.m_min <= -6.0); + REQUIRE(box.m_y.m_max >= 6.0); + REQUIRE(box.m_z.m_min <= -6.0); + REQUIRE(box.m_z.m_max >= 6.0); +} + +} // namespace ART diff --git a/tests/KDTreeTest.cpp b/tests/KDTreeTest.cpp new file mode 100644 index 0000000..45f31f4 --- /dev/null +++ b/tests/KDTreeTest.cpp @@ -0,0 +1,216 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include + +namespace ART +{ + +TEST_CASE("KDTreeNode constructor with vector of objects", "[KDTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -1.0), 0.5, material)); + + KDTreeNode kd_tree(objects); + const AABB box = kd_tree.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-0.5)); + REQUIRE(box.m_x.m_max == Approx(0.5)); + REQUIRE(box.m_y.m_min == Approx(-0.5)); + REQUIRE(box.m_y.m_max == Approx(0.5)); + REQUIRE(box.m_z.m_min == Approx(-1.5)); + REQUIRE(box.m_z.m_max == Approx(-0.5)); + } + + SECTION("Multiple objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 0.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 2.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 1.0, 1.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(-1.0, -1.0, -1.0), 0.5, material)); + + KDTreeNode kd_tree(objects); + const AABB box = kd_tree.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-1.5)); + REQUIRE(box.m_x.m_max == Approx(2.5)); + REQUIRE(box.m_y.m_min == Approx(-1.5)); + REQUIRE(box.m_y.m_max == Approx(1.5)); + } +} + +TEST_CASE("KDTreeNode Hit detects intersections", "[KDTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Ray hits single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + KDTreeNode kd_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = kd_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + } + + SECTION("Ray misses all objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 10.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(-10.0, 0.0, -5.0), 1.0, material)); + + KDTreeNode kd_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = kd_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); + } + + SECTION("Ray hits closest object among multiple") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -10.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 0.5, material)); + + KDTreeNode kd_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = kd_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(2.5)); + } + + SECTION("Ray respects interval bounds") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + KDTreeNode kd_tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + + // Sphere at z = -5 with radius = 1, hit at t = 4, so interval starting at t = 10 must miss + const bool hit = kd_tree.Hit(ray, Interval(10.0, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("KDTreeNode Hit uses SplitLongestAxis fallback when all centroids are in the same plane", "[KDTreeNode]") +{ + // Place all sphere centroids at the same point on every axis (within fp_tolerance, 1e-10) + // SplitSAH will compute extent < 1e-10 for all 3 axes and skip them, falling back to SplitLongestAxis + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + for (int i = 0; i < 10; i++) + { + // All centroids within 1e-12 of (0, 0, -5) — well below the 1e-10 tolerance + const double sub_tolerance_offset = static_cast(i) * 1e-12; + objects.push_back(allocator.Create(Point3(sub_tolerance_offset, 0.0, -5.0), 0.5, material)); + } + + KDTreeNode kd_tree(objects); + + // Ray aimed at the cluster must hit + { + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = kd_tree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == true); + } + + // Ray aimed away from the cluster must miss + { + const Ray ray(Point3(10.0, 10.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = kd_tree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("KDTreeNode Hit traverses in correct order for negative-direction ray (should_swap_child_order)", "[KDTreeNode]") +{ + // After SAH splits along an axis (x in this case, objects are spread along x) + // ray going -x satisfies (ray_direction_along_axis < 0) so should_swap_order = true + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + // Spread along x so SAH picks an x-axis split + std::vector objects; + objects.push_back(allocator.Create(Point3(-4.0, 0.0, 0.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(-2.0, 0.0, 0.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 2.0, 0.0, 0.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 4.0, 0.0, 0.0), 0.5, material)); + + KDTreeNode kd_tree(objects); + + // Ray from +x going -x hits the rightmost sphere first + const Ray ray(Point3(10.0, 0.0, 0.0), Vec3(-1.0, 0.0, 0.0)); + RayHitResult result; + const bool hit = kd_tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(5.5)); // t = 10.0 - 4.5 = 5.5 +} + +TEST_CASE("KDTreeNode MemoryUsedBytes is non-zero for a non-trivial tree", "[KDTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + for (int i = 0; i < 10; i++) + { + objects.push_back(allocator.Create(Point3(static_cast(i), 0.0, -5.0), 0.5, material)); + } + + KDTreeNode kd_tree(objects); + + REQUIRE(kd_tree.MemoryUsedBytes() > 0); +} + +TEST_CASE("KDTreeNode BoundingBox encloses all objects", "[KDTreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(-5.0, -5.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3( 5.0, 5.0, 5.0), 1.0, material)); + + KDTreeNode kd_tree(objects); + const AABB box = kd_tree.BoundingBox(); + + REQUIRE(box.m_x.m_min <= -6.0); + REQUIRE(box.m_x.m_max >= 6.0); + REQUIRE(box.m_y.m_min <= -6.0); + REQUIRE(box.m_y.m_max >= 6.0); + REQUIRE(box.m_z.m_min <= -6.0); + REQUIRE(box.m_z.m_max >= 6.0); +} + +} // namespace ART diff --git a/tests/MaterialTest.cpp b/tests/MaterialTest.cpp new file mode 100644 index 0000000..745c2ab --- /dev/null +++ b/tests/MaterialTest.cpp @@ -0,0 +1,240 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include + +namespace ART +{ + +// Utility helper for tests. +// Constructs a RayHitResult with a known front-facing surface at the origin, +// its normal pointing in +z. +static RayHitResult MakeFrontFacingResult(const Point3& point, const Vec3& outward_normal) +{ + RayHitResult result; + result.m_point = point; + result.m_t = 1.0; + result.m_u = 0.5; + result.m_v = 0.5; + result.m_material = nullptr; + + // Ray coming from +z toward the surface sets dot < 0, so front-facing + const Ray incoming_ray(point + outward_normal, -outward_normal); + result.SetFaceNormal(incoming_ray, outward_normal); + + return result; +} + +TEST_CASE("Base Material Scatter always returns false", "[Material]") +{ + // Base material shouldn't do anything, really + const Material material; + const Ray ray(Point3(0.0, 0.0, 1.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + + Colour attenuation; + Ray out_ray; + REQUIRE(material.Scatter(ray, result, attenuation, out_ray) == false); +} + +TEST_CASE("Base Material Emitted returns black", "[Material]") +{ + // Base material shouldn't do anything, really + const Material material; + const Colour emitted = material.Emitted(0.5, 0.5, Point3(0.0, 0.0, 0.0)); + + REQUIRE(emitted.m_x == Approx(0.0)); + REQUIRE(emitted.m_y == Approx(0.0)); + REQUIRE(emitted.m_z == Approx(0.0)); +} + +TEST_CASE("LambertianMaterial Scatter returns true and sets attenuation from texture", "[Material]") +{ + SolidColourTexture texture(Colour(0.1, 0.2, 0.3)); + const LambertianMaterial material(&texture); + + const Ray ray(Point3(0.0, 0.0, 1.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + + Colour attenuation; + Ray out_ray; + const bool scattered = material.Scatter(ray, result, attenuation, out_ray); + + REQUIRE(scattered == true); + REQUIRE(attenuation.m_x == Approx(0.1)); + REQUIRE(attenuation.m_y == Approx(0.2)); + REQUIRE(attenuation.m_z == Approx(0.3)); +} + +TEST_CASE("LambertianMaterial Scatter ray should have origin at hit point", "[Material]") +{ + SolidColourTexture texture(Colour(0.5)); + const LambertianMaterial material(&texture); + + const Point3 hit_point(1.0, 2.0, 3.0); + const Ray ray(Point3(1.0, 2.0, 13.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(hit_point, Vec3(0.0, 0.0, 1.0)); + + Colour attenuation; + Ray out_ray; + material.Scatter(ray, result, attenuation, out_ray); + + REQUIRE(out_ray.m_origin.m_x == Approx(hit_point.m_x)); + REQUIRE(out_ray.m_origin.m_y == Approx(hit_point.m_y)); + REQUIRE(out_ray.m_origin.m_z == Approx(hit_point.m_z)); +} + +TEST_CASE("LambertianMaterial Scatter direction is on the correct hemisphere", "[Material]") +{ + SolidColourTexture texture(Colour(0.5)); + const LambertianMaterial material(&texture); + + const Vec3 normal(0.0, 1.0, 0.0); // surface pointing up + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), normal); + const Ray ray(Point3(0.0, 1.0, 0.0), Vec3(0.0, -1.0, 0.0)); + + // Scattered direction must be on the same side as the normal (upper hemisphere) + bool scattered_direction_always_on_same_side_as_normal = true; + + // Run several times since direction is random + for (int i = 0; i < 10000; i++) + { + Colour attenuation; + Ray out_ray; + material.Scatter(ray, result, attenuation, out_ray); + + double dot = Dot(out_ray.m_direction, normal); + if (dot < 0.0) + { + scattered_direction_always_on_same_side_as_normal = false; + } + } + + REQUIRE(scattered_direction_always_on_same_side_as_normal); +} + +TEST_CASE("MetalMaterial Scatter reflects ray correctly when fuzz is zero", "[Material]") +{ + const MetalMaterial material(Colour(0.9, 0.9, 0.9), 0.0); + + // Ray going straight down (-y), surface normal pointing up (+y) + const Ray ray(Point3(0.0, 1.0, 0.0), Vec3(0.0, -1.0, 0.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 1.0, 0.0)); + + Colour attenuation; + Ray out_ray; + const bool scattered = material.Scatter(ray, result, attenuation, out_ray); + + const double fp_epsilon = 1e-10; + + REQUIRE(scattered == true); + // Reflected ray should go upward (+y), with zero x and z components + REQUIRE(out_ray.m_direction.m_y > 0.0); + REQUIRE(out_ray.m_direction.m_x == Approx(0.0).margin(fp_epsilon)); + REQUIRE(out_ray.m_direction.m_z == Approx(0.0).margin(fp_epsilon)); +} + +TEST_CASE("MetalMaterial Scatter sets attenuation to albedo", "[Material]") +{ + const MetalMaterial material(Colour(0.1, 0.2, 0.3), 0.0); + + const Ray ray(Point3(0.0, 1.0, 0.0), Vec3(0.0, -1.0, 0.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 1.0, 0.0)); + + Colour attenuation; + Ray out_ray; + material.Scatter(ray, result, attenuation, out_ray); + + REQUIRE(attenuation.m_x == Approx(0.1)); + REQUIRE(attenuation.m_y == Approx(0.2)); + REQUIRE(attenuation.m_z == Approx(0.3)); +} + +TEST_CASE("DielectricMaterial Scatter always returns true", "[Material]") +{ + const DielectricMaterial material(1.5); + + const Ray ray(Point3(0.0, 0.0, 1.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + + Colour attenuation; + Ray out_ray; + REQUIRE(material.Scatter(ray, result, attenuation, out_ray) == true); +} + +TEST_CASE("DielectricMaterial Scatter always produces white attenuation", "[Material]") +{ + const DielectricMaterial material(1.5); + + const Ray ray(Point3(0.0, 0.0, 1.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + + bool dielectric_attenuation_is_always_white = true; + + // Run several times since reflect vs refract is probabilistic (Schlick) + for (int i = 0; i < 10000; i++) + { + Colour attenuation; + Ray out_ray; + material.Scatter(ray, result, attenuation, out_ray); + + const bool dielectric_attenuation_is_white = + (attenuation.m_x == Approx(1.0)) && + (attenuation.m_y == Approx(1.0)) && + (attenuation.m_z == Approx(1.0)); + + if (!dielectric_attenuation_is_white) + { + dielectric_attenuation_is_always_white = false; + } + } + + REQUIRE(dielectric_attenuation_is_always_white); +} + +TEST_CASE("DielectricMaterial Scatter produces non-NaN valid direction", "[Material]") +{ + const DielectricMaterial material(1.5); + + const Ray ray(Point3(0.0, 0.0, 1.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + + Colour attenuation; + Ray out_ray; + material.Scatter(ray, result, attenuation, out_ray); + + REQUIRE(!std::isnan(out_ray.m_direction.m_x)); + REQUIRE(!std::isnan(out_ray.m_direction.m_y)); + REQUIRE(!std::isnan(out_ray.m_direction.m_z)); +} + +TEST_CASE("DiffuseLightMaterial Emitted returns texture value", "[Material]") +{ + SolidColourTexture texture(Colour(0.1, 0.2, 0.3)); + const DiffuseLightMaterial material(&texture); + + const Colour emitted = material.Emitted(0.5, 0.5, Point3(0.0, 0.0, 0.0)); + + REQUIRE(emitted.m_x == Approx(0.1)); + REQUIRE(emitted.m_y == Approx(0.2)); + REQUIRE(emitted.m_z == Approx(0.3)); +} + +TEST_CASE("DiffuseLightMaterial Scatter returns false", "[Material]") +{ + SolidColourTexture texture(Colour(1.0)); + const DiffuseLightMaterial material(&texture); + + const Ray ray(Point3(0.0, 0.0, 1.0), Vec3(0.0, 0.0, -1.0)); + const RayHitResult result = MakeFrontFacingResult(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, 1.0)); + + Colour attenuation; + Ray out_ray; + REQUIRE(material.Scatter(ray, result, attenuation, out_ray) == false); +} + +} // namespace ART diff --git a/tests/OctreeTest.cpp b/tests/OctreeTest.cpp new file mode 100644 index 0000000..1841389 --- /dev/null +++ b/tests/OctreeTest.cpp @@ -0,0 +1,200 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include + +namespace ART +{ + +TEST_CASE("OctreeNode constructor with vector of objects", "[OctreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -1.0), 0.5, material)); + + OctreeNode octree(objects); + const AABB box = octree.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-0.5)); + REQUIRE(box.m_x.m_max == Approx(0.5)); + REQUIRE(box.m_y.m_min == Approx(-0.5)); + REQUIRE(box.m_y.m_max == Approx(0.5)); + REQUIRE(box.m_z.m_min == Approx(-1.5)); + REQUIRE(box.m_z.m_max == Approx(-0.5)); + } + + SECTION("Multiple objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 0.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 2.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 1.0, 1.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(-1.0, -1.0, -1.0), 0.5, material)); + + OctreeNode octree(objects); + const AABB box = octree.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-1.5)); + REQUIRE(box.m_x.m_max == Approx(2.5)); + REQUIRE(box.m_y.m_min == Approx(-1.5)); + REQUIRE(box.m_y.m_max == Approx(1.5)); + } +} + +TEST_CASE("OctreeNode Hit detects intersections", "[OctreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Ray hits single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + OctreeNode octree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = octree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + } + + SECTION("Ray misses all objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3( 10.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(-10.0, 0.0, -5.0), 1.0, material)); + + OctreeNode octree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = octree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); + } + + SECTION("Ray hits closest object among multiple") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -10.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 0.5, material)); + + OctreeNode tree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = tree.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(2.5)); + } + + SECTION("Ray respects interval bounds") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + OctreeNode octree(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + + // Sphere at z=-5 with r=1, hit at t=4; interval starting at t=10 must miss + const bool hit = octree.Hit(ray, Interval(10.0, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("OctreeNode Hit finds spheres in all 8 octants", "[OctreeNode]") +{ + // Place one sphere per octant around the origin + // The split centre will be at the origin, so each sphere's centroid sits cleaning in octant + + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + const double distance_from_origin = 3.0; + // Small enough that spheres are well-separated + const double radius = 0.4; + + std::vector objects; + objects.push_back(allocator.Create(Point3(-distance_from_origin, -distance_from_origin, -distance_from_origin), radius, material)); // octant 0 + objects.push_back(allocator.Create(Point3(+distance_from_origin, -distance_from_origin, -distance_from_origin), radius, material)); // octant 1 + objects.push_back(allocator.Create(Point3(-distance_from_origin, +distance_from_origin, -distance_from_origin), radius, material)); // octant 2 + objects.push_back(allocator.Create(Point3(+distance_from_origin, +distance_from_origin, -distance_from_origin), radius, material)); // octant 3 + objects.push_back(allocator.Create(Point3(-distance_from_origin, -distance_from_origin, +distance_from_origin), radius, material)); // octant 4 + objects.push_back(allocator.Create(Point3(+distance_from_origin, -distance_from_origin, +distance_from_origin), radius, material)); // octant 5 + objects.push_back(allocator.Create(Point3(-distance_from_origin, +distance_from_origin, +distance_from_origin), radius, material)); // octant 6 + objects.push_back(allocator.Create(Point3(+distance_from_origin, +distance_from_origin, +distance_from_origin), radius, material)); // octant 7 + + OctreeNode octree(objects); + + const Ray rays[] = + { + Ray(Point3(-distance_from_origin, -distance_from_origin, -20.0), Vec3(0.0, 0.0, 1.0)), // towards octant 0 + Ray(Point3(+distance_from_origin, -distance_from_origin, -20.0), Vec3(0.0, 0.0, 1.0)), // towards octant 1 + Ray(Point3(-distance_from_origin, +distance_from_origin, -20.0), Vec3(0.0, 0.0, 1.0)), // towards octant 2 + Ray(Point3(+distance_from_origin, +distance_from_origin, -20.0), Vec3(0.0, 0.0, 1.0)), // towards octant 3 + Ray(Point3(-distance_from_origin, -distance_from_origin, +20.0), Vec3(0.0, 0.0, -1.0)), // towards octant 4 + Ray(Point3(+distance_from_origin, -distance_from_origin, +20.0), Vec3(0.0, 0.0, -1.0)), // towards octant 5 + Ray(Point3(-distance_from_origin, +distance_from_origin, +20.0), Vec3(0.0, 0.0, -1.0)), // towards octant 6 + Ray(Point3(+distance_from_origin, +distance_from_origin, +20.0), Vec3(0.0, 0.0, -1.0)), // towards octant 7 + }; + + for (const Ray& ray : rays) + { + RayHitResult result; + const bool hit = octree.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == true); + } +} + +TEST_CASE("OctreeNode MemoryUsedBytes is non-zero for a non-trivial tree", "[OctreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + for (int i = 0; i < 10; i++) + { + objects.push_back(allocator.Create(Point3(static_cast(i), 0.0, -5.0), 0.5, material)); + } + + OctreeNode octree(objects); + + REQUIRE(octree.MemoryUsedBytes() > 0); +} + +TEST_CASE("OctreeNode BoundingBox encloses all objects", "[OctreeNode]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(-5.0, -5.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3( 5.0, 5.0, 5.0), 1.0, material)); + + OctreeNode octree(objects); + const AABB box = octree.BoundingBox(); + + REQUIRE(box.m_x.m_min <= -6.0); + REQUIRE(box.m_x.m_max >= 6.0); + REQUIRE(box.m_y.m_min <= -6.0); + REQUIRE(box.m_y.m_max >= 6.0); + REQUIRE(box.m_z.m_min <= -6.0); + REQUIRE(box.m_z.m_max >= 6.0); +} + +} // namespace ART diff --git a/tests/RandomTest.cpp b/tests/RandomTest.cpp new file mode 100644 index 0000000..c333cb7 --- /dev/null +++ b/tests/RandomTest.cpp @@ -0,0 +1,152 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include + +namespace ART +{ + +static constexpr int NUM_ITERATIONS = 100000; + +TEST_CASE("RandomCanonicalDouble returns values in [0, 1)", "[Random]") +{ + double v_low = 1.1; + double v_high = -0.1; + + for (int i = 0; i < NUM_ITERATIONS; i++) + { + const double v = RandomCanonicalDouble(); + v_low = std::min(v_low, v); + v_high = std::max(v_high, v); + } + + REQUIRE(v_low < 1.0); + REQUIRE(v_high >= 0.0); +} + +TEST_CASE("RandomDouble returns values in [min, max)", "[Random]") +{ + SECTION("Positive range") + { + const double min = 2.0; + const double max = 5.0; + + double v_low = max + 0.1; + double v_high = min - 0.1; + + for (int i = 0; i < NUM_ITERATIONS; i++) + { + const double v = RandomDouble(min, max); + v_low = std::min(v_low, v); + v_high = std::max(v_high, v); + } + + REQUIRE(v_low < max); + REQUIRE(v_high >= min); + } + + SECTION("Range spanning zero") + { + const double min = -5.0; + const double max = 10.0; + + double v_low = max + 0.1; + double v_high = min - 0.1; + + for (int i = 0; i < NUM_ITERATIONS; i++) + { + const double v = RandomDouble(min, max); + v_low = std::min(v_low, v); + v_high = std::max(v_high, v); + } + + REQUIRE(v_low < max); + REQUIRE(v_high >= min); + } +} + +TEST_CASE("SeedColourRNG produces deterministic sequence", "[Random]") +{ + SeedColourRNG(12345); + const double a = RandomColourDouble(); + const double b = RandomColourDouble(); + + SeedColourRNG(12345); + const double c = RandomColourDouble(); + const double d = RandomColourDouble(); + + REQUIRE(a == Approx(c)); + REQUIRE(b == Approx(d)); +} + +TEST_CASE("SeedPositionRNG produces deterministic sequence", "[Random]") +{ + SeedPositionRNG(99999); + const double a = RandomPositionDouble(0.0, 100.0); + const double b = RandomPositionDouble(0.0, 100.0); + + SeedPositionRNG(99999); + const double c = RandomPositionDouble(0.0, 100.0); + const double d = RandomPositionDouble(0.0, 100.0); + + REQUIRE(a == Approx(c)); + REQUIRE(b == Approx(d)); +} + +TEST_CASE("Colour and position RNG streams are independent", "[Random]") +{ + SeedColourRNG(42); + SeedPositionRNG(42); + + // Draw from colour stream first + const double colour_first = RandomColourDouble(); + + SeedColourRNG(42); + SeedPositionRNG(42); + RandomPositionDouble(0.0, 1.0); + const double colour_after_position = RandomColourDouble(); + + // Drawing from position stream must not affect the colour stream + REQUIRE(colour_first == Approx(colour_after_position)); +} + +TEST_CASE("RandomColourDouble returns values in [0, 1)", "[Random]") +{ + SeedColourRNG(1); + + double v_low = 1.1; + double v_high = -0.1; + + for (int i = 0; i < NUM_ITERATIONS; i++) + { + const double v = RandomColourDouble(); + v_low = std::min(v_low, v); + v_high = std::max(v_high, v); + } + + REQUIRE(v_low < 1.0); + REQUIRE(v_high >= 0.0); +} + +TEST_CASE("RandomPositionDouble returns values in [min, max)", "[Random]") +{ + SeedPositionRNG(1); + + const double min = -10.0; + const double max = 10.0; + + double v_low = max + 0.1; + double v_high = min - 0.1; + + for (int i = 0; i < NUM_ITERATIONS; i++) + { + const double v = RandomDouble(min, max); + v_low = std::min(v_low, v); + v_high = std::max(v_high, v); + } + + REQUIRE(v_low < max); + REQUIRE(v_high >= min); +} + +} // namespace ART diff --git a/tests/RayHitResultTest.cpp b/tests/RayHitResultTest.cpp index b69a313..2b14b0d 100644 --- a/tests/RayHitResultTest.cpp +++ b/tests/RayHitResultTest.cpp @@ -37,4 +37,37 @@ TEST_CASE("RayHitResult SetFaceNormal works correctly", "[RayHitResult]") } } +TEST_CASE("RayHitResult SetFaceNormal with a 45-degree incoming ray", "[RayHitResult]") +{ + SECTION("Ray approaching at 45 degrees to surface, front-facing") + { + // Ray direction: diagonal down-left, outward normal: +z + // dot((1, 0, -1), (0, 0, 1)) = -1 < 0 -> front-facing, normal unchanged + const Ray ray(Point3(0.0, 0.0, 0.0), Normalised(Vec3(1.0, 0.0, -1.0))); + const Vec3 outward_normal(0.0, 0.0, 1.0); + RayHitResult result; + result.SetFaceNormal(ray, outward_normal); + + REQUIRE(result.m_is_front_facing == true); + REQUIRE(result.m_normal.m_x == Approx(0.0)); + REQUIRE(result.m_normal.m_y == Approx(0.0)); + REQUIRE(result.m_normal.m_z == Approx(1.0)); + } + + SECTION("Ray leaving at 45 degrees from surface, back-facing") + { + // Ray direction: diagonal up-right, outward normal: +z + // dot((1, 0, 1), (0, 0, 1)) = 1 > 0 -> back-facing, normal flipped + const Ray ray(Point3(0.0, 0.0, 0.0), Normalised(Vec3(1.0, 0.0, 1.0))); + const Vec3 outward_normal(0.0, 0.0, 1.0); + RayHitResult result; + result.SetFaceNormal(ray, outward_normal); + + REQUIRE(result.m_is_front_facing == false); + REQUIRE(result.m_normal.m_x == Approx(0.0)); + REQUIRE(result.m_normal.m_y == Approx(0.0)); + REQUIRE(result.m_normal.m_z == Approx(-1.0)); + } +} + } // namespace ART diff --git a/tests/RayTest.cpp b/tests/RayTest.cpp index e60e01d..41a9b8c 100644 --- a/tests/RayTest.cpp +++ b/tests/RayTest.cpp @@ -35,6 +35,36 @@ TEST_CASE("Ray constructor", "[Ray]") REQUIRE(ray.m_direction.m_z == Approx(6.0)); } +TEST_CASE("Ray inverse direction is precomputed correctly", "[Ray]") +{ + SECTION("Positive direction components") + { + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(2.0, 4.0, 0.5)); + + REQUIRE(ray.m_inverse_direction.m_x == Approx(0.5)); + REQUIRE(ray.m_inverse_direction.m_y == Approx(0.25)); + REQUIRE(ray.m_inverse_direction.m_z == Approx(2.0)); + } + + SECTION("Negative direction components") + { + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(-1.0, -2.0, -0.5)); + + REQUIRE(ray.m_inverse_direction.m_x == Approx(-1.0)); + REQUIRE(ray.m_inverse_direction.m_y == Approx(-0.5)); + REQUIRE(ray.m_inverse_direction.m_z == Approx(-2.0)); + } + + SECTION("Zero direction component produces infinity") + { + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(1.0, 0.0, 1.0)); + + REQUIRE(ray.m_inverse_direction.m_x == Approx(1.0)); + REQUIRE(std::isinf(ray.m_inverse_direction.m_y)); + REQUIRE(ray.m_inverse_direction.m_z == Approx(1.0)); + } +} + TEST_CASE("Ray At(t) returns correct point along ray", "[Ray]") { const Point3 origin(1.0, 2.0, 3.0); diff --git a/tests/TextureTest.cpp b/tests/TextureTest.cpp new file mode 100644 index 0000000..140e8b6 --- /dev/null +++ b/tests/TextureTest.cpp @@ -0,0 +1,166 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include + +namespace ART +{ + +TEST_CASE("SolidColourTexture returns the same colour for any UV and point", "[Texture]") +{ + SECTION("Colour constructor") + { + const SolidColourTexture t(Colour(0.4, 0.6, 0.8)); + + const Colour a = t.Value(0.0, 0.0, Point3(0.0, 0.0, 0.0)); + const Colour b = t.Value(0.25, 0.75, Point3(5.0, -3.0, 2.0)); + const Colour c = t.Value(1.0, 1.0, Point3(-100.0, 100.0, 0.5)); + + REQUIRE(a.m_x == Approx(0.4)); + REQUIRE(a.m_y == Approx(0.6)); + REQUIRE(a.m_z == Approx(0.8)); + + REQUIRE(b.m_x == Approx(0.4)); + REQUIRE(b.m_y == Approx(0.6)); + REQUIRE(b.m_z == Approx(0.8)); + + REQUIRE(c.m_x == Approx(0.4)); + REQUIRE(c.m_y == Approx(0.6)); + REQUIRE(c.m_z == Approx(0.8)); + } + + SECTION("Component constructor") + { + const SolidColourTexture t(0.1, 0.2, 0.3); + + const Colour a = t.Value(0.0, 0.0, Point3(0.0, 0.0, 0.0)); + const Colour b = t.Value(0.25, 0.75, Point3(5.0, -3.0, 2.0)); + const Colour c = t.Value(1.0, 1.0, Point3(-100.0, 100.0, 0.5)); + + REQUIRE(a.m_x == Approx(0.1)); + REQUIRE(a.m_y == Approx(0.2)); + REQUIRE(a.m_z == Approx(0.3)); + + REQUIRE(b.m_x == Approx(0.1)); + REQUIRE(b.m_y == Approx(0.2)); + REQUIRE(b.m_z == Approx(0.3)); + + REQUIRE(c.m_x == Approx(0.1)); + REQUIRE(c.m_y == Approx(0.2)); + REQUIRE(c.m_z == Approx(0.3)); + } +} + +TEST_CASE("CheckerTexture alternates between even and odd textures", "[Texture]") +{ + // scale=1.0 means m_inverse_scale=1.0, transitions at every integer boundary + // For point (x, y, z): sum = floor(x) + floor(y) + floor(z) + // even sum -> even texture (red), odd sum -> odd texture (blue) + + // Red + SolidColourTexture even_texture(Colour(1.0, 0.0, 0.0)); + // Blue + SolidColourTexture odd_texture(Colour(0.0, 0.0, 1.0)); + const CheckerTexture checker_texture(1.0, &even_texture, &odd_texture); + + SECTION("Point in even cell: floor(0.5) + floor(0.5) + floor(0.5) = 0 + 0 + 0 = 0 (even)") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(0.5, 0.5, 0.5)); + REQUIRE(result.m_x == Approx(1.0)); // Red + REQUIRE(result.m_z == Approx(0.0)); // Red + } + + SECTION("Point in odd cell: floor(1.5) + floor(0.5) + floor(0.5) = 1 + 0 + 0 = 1 (odd)") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(1.5, 0.5, 0.5)); + REQUIRE(result.m_z == Approx(1.0)); // Blue + REQUIRE(result.m_x == Approx(0.0)); // Blue + } + + SECTION("Point in even cell: floor(1.5) + floor(1.5) + floor(0.5) = 1 + 1 + 0 = 2 (even)") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(1.5, 1.5, 0.5)); + REQUIRE(result.m_x == Approx(1.0)); // Red + } + + SECTION("Point in odd cell: floor(1.5)+floor(1.5)+floor(1.5) = 1 + 1 + 1 = 3 (odd)") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(1.5, 1.5, 1.5)); + REQUIRE(result.m_z == Approx(1.0)); // Blue + } + + SECTION("UV coordinates are ignored, only world point matters") + { + // Same point, different UVs should give same result + const Colour a = checker_texture.Value(0.0, 0.0, Point3(0.5, 0.5, 0.5)); + const Colour b = checker_texture.Value(0.99, 0.99, Point3(0.5, 0.5, 0.5)); + REQUIRE(a.m_x == Approx(b.m_x)); + REQUIRE(a.m_y == Approx(b.m_y)); + REQUIRE(a.m_z == Approx(b.m_z)); + } +} + +TEST_CASE("CheckerTexture scale parameter controls transition frequency", "[Texture]") +{ + // scale=2.0 means m_inverse_scale=0.5, transitions every 2 units + // floor(0.5 * x), so transitions at x = 0, 2, 4, and so on + SolidColourTexture even_texture(Colour(1.0, 1.0, 1.0)); // White + SolidColourTexture odd_texture(Colour(0.0, 0.0, 0.0)); // Black + const CheckerTexture checker_texture(2.0, &even_texture, &odd_texture); + + SECTION("Point at x = 0.5: floor(0.5 * 0.5) = floor(0.25) = 0, sum = 0 (even) -> white") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(0.5, 0.0, 0.0)); + REQUIRE(result.m_x == Approx(1.0)); + } + + SECTION("Point at x = 3.0: floor(0.5 * 3.0) = floor(1.5) = 1, sum = 1 (odd) -> black") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(3.0, 0.0, 0.0)); + REQUIRE(result.m_x == Approx(0.0)); + } + + SECTION("Point at x = 5.0: floor(0.5 * 5.0) = floor(2.5) = 2, sum = 2 (even) -> white") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(5.0, 0.0, 0.0)); + REQUIRE(result.m_x == Approx(1.0)); + } +} + +TEST_CASE("CheckerTexture with negative coordinates", "[Texture]") +{ + // C++ % operator on negative values: (-1) % 2 == -1 (not 1) + // Test documents the actual behaviour for negative coords + SolidColourTexture even_texture(Colour(1.0, 0.0, 0.0)); // Red + SolidColourTexture odd_texture(Colour(0.0, 0.0, 1.0)); // Blue + const CheckerTexture checker_texture(1.0, &even_texture, &odd_texture); + + // floor(-0.5) = -1: sum = -1 + 0 + 0 = -1, (-1 % 2) == -1 in C++ not 0 + // So, is_even = (-1 == 0) = false -> odd texture (Blue) + SECTION("Negative x coordinate: floor(-0.5) = -1, sum = -1, (-1 % 2) = -1, not even -> blue") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(-0.5, 0.5, 0.5)); + REQUIRE(result.m_z == Approx(1.0)); // Blue (odd) + } + + // floor(-1.5) = -2: sum = -2 + 0 + 0 = -2, (-2 % 2) == 0 in C++ -> even texture (Red) + SECTION("Negative x coordinate: floor(-1.5)=-2, sum=-2, (-2%2)=0, even -> red") + { + const Colour result = checker_texture.Value(0.0, 0.0, Point3(-1.5, 0.5, 0.5)); + REQUIRE(result.m_x == Approx(1.0)); // Red (even) + } +} + +TEST_CASE("ImageTexture returns fallback colour when image fails to load", "[Texture]") +{ + const ImageTexture t("no_image_here.png"); + + const Colour result = t.Value(0.5, 0.5, Point3(0.0, 0.0, 0.0)); + + REQUIRE(result.m_x == Approx(0.0)); + REQUIRE(result.m_y == Approx(1.0)); + REQUIRE(result.m_z == Approx(1.0)); +} + +} // namespace ART diff --git a/tests/TimerTest.cpp b/tests/TimerTest.cpp new file mode 100644 index 0000000..1e7f093 --- /dev/null +++ b/tests/TimerTest.cpp @@ -0,0 +1,55 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include + +#include +#include + +namespace ART +{ + +TEST_CASE("Timer ElapsedMilliseconds is non-negative after immediate stop", "[Timer]") +{ + // Surpassing this would be concerning lol + static constexpr double MAX_TIME_ELAPSED_MS = 50.0; + + Timer t; + t.Start(); + t.Stop(); + + REQUIRE(t.ElapsedMilliseconds() >= 0.0); + REQUIRE(t.ElapsedMilliseconds() < MAX_TIME_ELAPSED_MS); +} + +TEST_CASE("Timer ElapsedMilliseconds reflects actual elapsed time", "[Timer]") +{ + static constexpr double MAX_TIME_ELAPSED_MS = 100.0; + + Timer t; + t.Start(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + t.Stop(); + + REQUIRE(t.ElapsedMilliseconds() >= 10.0); + REQUIRE(t.ElapsedMilliseconds() < MAX_TIME_ELAPSED_MS); +} + +TEST_CASE("Timer can be reused across multiple start/stop cycles", "[Timer]") +{ + Timer t; + + t.Start(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + t.Stop(); + const double first = t.ElapsedMilliseconds(); + + t.Start(); + t.Stop(); + const double second = t.ElapsedMilliseconds(); + + // First measurement has delay, second doesn't + REQUIRE(first > second); +} + +} // namespace ART diff --git a/tests/TraversalStatsTest.cpp b/tests/TraversalStatsTest.cpp new file mode 100644 index 0000000..0497e8e --- /dev/null +++ b/tests/TraversalStatsTest.cpp @@ -0,0 +1,81 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include + +namespace ART +{ + +TEST_CASE("TraversalCounters Reset zeroes all fields", "[TraversalStats]") +{ + TraversalCounters traversal_counters; + traversal_counters.nodes_traversed = 100; + traversal_counters.intersection_tests = 200; + traversal_counters.rays_cast = 50; + + traversal_counters.Reset(); + + REQUIRE(traversal_counters.nodes_traversed == 0); + REQUIRE(traversal_counters.intersection_tests == 0); + REQUIRE(traversal_counters.rays_cast == 0); +} + +TEST_CASE("TraversalCounters operator+= accumulates correctly", "[TraversalStats]") +{ + TraversalCounters a; + a.nodes_traversed = 10; + a.intersection_tests = 20; + a.rays_cast = 5; + + TraversalCounters b; + b.nodes_traversed = 3; + b.intersection_tests = 7; + b.rays_cast = 2; + + a += b; + + REQUIRE(a.nodes_traversed == 13); + REQUIRE(a.intersection_tests == 27); + REQUIRE(a.rays_cast == 7); +} + +TEST_CASE("TraversalStats averages compute correctly", "[TraversalStats]") +{ + TraversalStats stats; + stats.total_nodes_traversed = 100; + stats.total_intersection_tests = 50; + stats.total_rays_cast = 10; + + REQUIRE(stats.AvgNodesTraversedPerRay() == Approx(10.0)); + REQUIRE(stats.AvgIntersectionTestsPerRay() == Approx(5.0)); +} + +TEST_CASE("TraversalStats averages return zero when no rays cast", "[TraversalStats]") +{ + TraversalStats stats; + stats.total_nodes_traversed = 99; + stats.total_intersection_tests = 42; + stats.total_rays_cast = 0; + + REQUIRE(stats.AvgNodesTraversedPerRay() == Approx(0.0)); + REQUIRE(stats.AvgIntersectionTestsPerRay() == Approx(0.0)); +} + +TEST_CASE("Record helpers increment the thread-local traversal counters", "[TraversalStats]") +{ + tl_traversal_counters.Reset(); + + RecordNodeTraversal(); + RecordNodeTraversal(); + RecordIntersectionTest(); + RecordRayCast(); + + REQUIRE(tl_traversal_counters.nodes_traversed == 2); + REQUIRE(tl_traversal_counters.intersection_tests == 1); + REQUIRE(tl_traversal_counters.rays_cast == 1); + + // Reset for other tests, not constrained to this scope + tl_traversal_counters.Reset(); +} + +} // namespace ART diff --git a/tests/UniformGridTest.cpp b/tests/UniformGridTest.cpp new file mode 100644 index 0000000..08bde1e --- /dev/null +++ b/tests/UniformGridTest.cpp @@ -0,0 +1,244 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include +#include +#include +#include +#include + +namespace ART +{ + +TEST_CASE("UniformGrid constructor with vector of objects", "[UniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -1.0), 0.5, material)); + + UniformGrid grid(objects); + const AABB box = grid.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-0.5)); + REQUIRE(box.m_x.m_max == Approx(0.5)); + REQUIRE(box.m_y.m_min == Approx(-0.5)); + REQUIRE(box.m_y.m_max == Approx(0.5)); + REQUIRE(box.m_z.m_min == Approx(-1.5)); + REQUIRE(box.m_z.m_max == Approx(-0.5)); + } + + SECTION("Multiple objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(2.0, 0.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(1.0, 1.0, -1.0), 0.5, material)); + objects.push_back(allocator.Create(Point3(-1.0, -1.0, -1.0), 0.5, material)); + + UniformGrid grid(objects); + const AABB box = grid.BoundingBox(); + + REQUIRE(box.m_x.m_min == Approx(-1.5)); + REQUIRE(box.m_x.m_max == Approx(2.5)); + REQUIRE(box.m_y.m_min == Approx(-1.5)); + REQUIRE(box.m_y.m_max == Approx(1.5)); + } +} + +TEST_CASE("UniformGrid Hit detects intersections", "[UniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + SECTION("Ray hits single object") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + UniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + } + + SECTION("Ray misses all objects") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(10.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(-10.0, 0.0, -5.0), 1.0, material)); + + UniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); + } + + SECTION("Ray hits closest object among multiple") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -10.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 0.5, material)); + + UniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(2.5)); + } + + SECTION("Ray respects interval bounds") + { + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -5.0), 1.0, material)); + + UniformGrid grid(objects); + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + + // Sphere is at z = -5 with radius = 1, so hit at t=4; starting interval at t=10 must miss + const bool hit = grid.Hit(ray, Interval(10.0, infinity), result); + REQUIRE(hit == false); + } +} + +TEST_CASE("UniformGrid Hit detects negative-direction ray (step_x = -1)", "[UniformGrid]") +{ + // Tests 3DDDA negative-step branch: ray travelling in the -x direction + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, 0.0), 1.0, material)); + + UniformGrid grid(objects); + + // Ray from +x going in -x direction; hits sphere at t = 4 (surface at x = 1, origin at x = 5) + const Ray ray(Point3(5.0, 0.0, 0.0), Vec3(-1.0, 0.0, 0.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(4.0)); +} + +TEST_CASE("UniformGrid Hit detects large sphere spanning multiple cells", "[UniformGrid]") +{ + // A sphere large enough to span multiple grid cells must register in all of them + // Any ray aimed at sphere's centre must still produce a hit regardless of which cells the ray traverses + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + // Large sphere plus several small ones to ensure a reasonable cell size + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -10.0), 5.0, material)); + objects.push_back(allocator.Create(Point3(-8.0, 0.0, 0.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 8.0, 0.0, 0.0), 0.5, material)); + objects.push_back(allocator.Create(Point3( 0.0, 8.0, 0.0), 0.5, material)); + + UniformGrid grid(objects); + + // Ray aimed at the centre of the large sphere from several origins + // Each passes through different cell columns but must still hit. + const Point3 ray_origins[] = + { + Point3( 0.0, 0.0, 0.0), + Point3( 2.0, 0.0, 0.0), + Point3(-2.0, 0.0, 0.0), + }; + + for (const Point3& ray_origin : ray_origins) + { + const Ray ray(ray_origin, Normalised(Vec3(0.0, 0.0, -1.0) + Vec3(ray_origin.m_x * -0.05, 0.0, 0.0))); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + REQUIRE(hit == true); + } +} + +TEST_CASE("UniformGrid Hit works with a single-object scene", "[UniformGrid]") +{ + // With only 1 object, DetermineCellSize produces a cell size of 3*max_extent, + // so the whole scene fits in single 1x1x1 grid (one cell). + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(0.0, 0.0, -3.0), 1.0, material)); + + UniformGrid grid(objects); + + SECTION("Ray hits the single object") + { + const Ray ray(Point3(0.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == true); + REQUIRE(result.m_t == Approx(2.0)); + } + + SECTION("Ray misses the single object") + { + const Ray ray(Point3(5.0, 0.0, 0.0), Vec3(0.0, 0.0, -1.0)); + RayHitResult result; + const bool hit = grid.Hit(ray, Interval(0.001, infinity), result); + + REQUIRE(hit == false); + } +} + +TEST_CASE("UniformGrid MemoryUsedBytes is non-zero for a non-trivial scene", "[UniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + for (int i = 0; i < 10; i++) + { + objects.push_back(allocator.Create(Point3(static_cast(i), 0.0, -5.0), 0.5, material)); + } + + UniformGrid grid(objects); + + REQUIRE(grid.MemoryUsedBytes() > 0); +} + +TEST_CASE("UniformGrid BoundingBox encloses all objects in grid", "[UniformGrid]") +{ + ArenaAllocator allocator(ONE_MEGABYTE); + Texture* texture = allocator.Create(Colour(0.5)); + Material* material = allocator.Create(texture); + + std::vector objects; + objects.push_back(allocator.Create(Point3(-5.0, -5.0, -5.0), 1.0, material)); + objects.push_back(allocator.Create(Point3( 5.0, 5.0, 5.0), 1.0, material)); + + UniformGrid grid(objects); + const AABB box = grid.BoundingBox(); + + REQUIRE(box.m_x.m_min <= -6.0); + REQUIRE(box.m_x.m_max >= 6.0); + REQUIRE(box.m_y.m_min <= -6.0); + REQUIRE(box.m_y.m_max >= 6.0); + REQUIRE(box.m_z.m_min <= -6.0); + REQUIRE(box.m_z.m_max >= 6.0); +} + +} // namespace ART diff --git a/tests/UtilityTest.cpp b/tests/UtilityTest.cpp index 48ad1a6..202474f 100644 --- a/tests/UtilityTest.cpp +++ b/tests/UtilityTest.cpp @@ -4,6 +4,8 @@ #include #include +#include + namespace ART { @@ -35,4 +37,75 @@ TEST_CASE("DegreesToRadians converts degrees to radians correctly", "[Utility]") } } +TEST_CASE("AccelerationStructureToString returns non-empty string for each value", "[Utility]") +{ + REQUIRE(AccelerationStructureToString(AccelerationStructure::NONE) != ""); + REQUIRE(AccelerationStructureToString(AccelerationStructure::UNIFORM_GRID) != ""); + REQUIRE(AccelerationStructureToString(AccelerationStructure::HIERARCHICAL_UNIFORM_GRID) != ""); + REQUIRE(AccelerationStructureToString(AccelerationStructure::OCTREE) != ""); + REQUIRE(AccelerationStructureToString(AccelerationStructure::BSP_TREE) != ""); + REQUIRE(AccelerationStructureToString(AccelerationStructure::K_D_TREE) != ""); + REQUIRE(AccelerationStructureToString(AccelerationStructure::BOUNDING_VOLUME_HIERARCHY) != ""); +} + +TEST_CASE("AccelerationStructureToString returns distinct strings", "[Utility]") +{ + const std::string none_str = AccelerationStructureToString(AccelerationStructure::NONE); + const std::string uniform_grid_str = AccelerationStructureToString(AccelerationStructure::UNIFORM_GRID); + const std::string hierarchical_uniform_grid_str = AccelerationStructureToString(AccelerationStructure::HIERARCHICAL_UNIFORM_GRID); + const std::string octree_str = AccelerationStructureToString(AccelerationStructure::OCTREE); + const std::string bsp_tree_str = AccelerationStructureToString(AccelerationStructure::BSP_TREE); + const std::string k_d_tree_str = AccelerationStructureToString(AccelerationStructure::K_D_TREE); + const std::string bounding_volume_hierarchy_str = AccelerationStructureToString(AccelerationStructure::BOUNDING_VOLUME_HIERARCHY); + + REQUIRE(none_str != uniform_grid_str); + REQUIRE(none_str != hierarchical_uniform_grid_str); + REQUIRE(none_str != octree_str); + REQUIRE(none_str != bsp_tree_str); + REQUIRE(none_str != k_d_tree_str); + REQUIRE(none_str != bounding_volume_hierarchy_str); + REQUIRE(uniform_grid_str != hierarchical_uniform_grid_str); + REQUIRE(bsp_tree_str != k_d_tree_str); + REQUIRE(k_d_tree_str != bounding_volume_hierarchy_str); +} + +TEST_CASE("RenderStats TotalTimeMilliseconds sums construction and render times", "[Utility]") +{ + SECTION("Both times zero") + { + RenderStats stats; + stats.m_construction_time_ms = 0.0; + stats.m_render_time_ms = 0.0; + + REQUIRE(stats.TotalTimeMilliseconds() == Approx(0.0)); + } + + SECTION("Typical values") + { + RenderStats stats; + stats.m_construction_time_ms = 10.5; + stats.m_render_time_ms = 99.5; + + REQUIRE(stats.TotalTimeMilliseconds() == Approx(110.0)); + } + + SECTION("Construction-only cost") + { + RenderStats stats; + stats.m_construction_time_ms = 42.0; + stats.m_render_time_ms = 0.0; + + REQUIRE(stats.TotalTimeMilliseconds() == Approx(42.0)); + } + + SECTION("Render-only cost") + { + RenderStats stats; + stats.m_construction_time_ms = 0.0; + stats.m_render_time_ms = 100.55; + + REQUIRE(stats.TotalTimeMilliseconds() == Approx(100.55)); + } +} + } // namespace ART diff --git a/tests/Vec3IntTest.cpp b/tests/Vec3IntTest.cpp new file mode 100644 index 0000000..c5991e0 --- /dev/null +++ b/tests/Vec3IntTest.cpp @@ -0,0 +1,151 @@ +// Copyright Mia Rolfe. All rights reserved. +#include + +#include + +namespace ART +{ + +TEST_CASE("Vec3Int default constructor initialises to zero", "[Vec3Int]") +{ + const Vec3Int v; + + REQUIRE(v.m_x == 0); + REQUIRE(v.m_y == 0); + REQUIRE(v.m_z == 0); +} + +TEST_CASE("Vec3Int scalar constructor sets all components to the same value", "[Vec3Int]") +{ + const Vec3Int v(7); + + REQUIRE(v.m_x == 7); + REQUIRE(v.m_y == 7); + REQUIRE(v.m_z == 7); +} + +TEST_CASE("Vec3Int component constructor sets x, y, z correctly", "[Vec3Int]") +{ + const Vec3Int v(1, 2, 3); + + REQUIRE(v.m_x == 1); + REQUIRE(v.m_y == 2); + REQUIRE(v.m_z == 3); +} + +TEST_CASE("Vec3Int unary negation flips all components", "[Vec3Int]") +{ + const Vec3Int v(1, -2, 3); + const Vec3Int neg = -v; + + REQUIRE(neg.m_x == -1); + REQUIRE(neg.m_y == 2); + REQUIRE(neg.m_z == -3); +} + +TEST_CASE("Vec3Int operator[] accesses components by index", "[Vec3Int]") +{ + const Vec3Int v(4, 5, 6); + + REQUIRE(v[0] == 4); + REQUIRE(v[1] == 5); + REQUIRE(v[2] == 6); +} + +TEST_CASE("Vec3Int operator[] by reference allows mutation", "[Vec3Int]") +{ + Vec3Int v(1, 2, 3); + v[0] = 10; + v[1] = 20; + v[2] = 30; + + REQUIRE(v.m_x == 10); + REQUIRE(v.m_y == 20); + REQUIRE(v.m_z == 30); +} + +TEST_CASE("Vec3Int operator+= accumulates component-wise", "[Vec3Int]") +{ + Vec3Int a(1, 2, 3); + const Vec3Int b(4, 5, 6); + a += b; + + REQUIRE(a.m_x == 5); + REQUIRE(a.m_y == 7); + REQUIRE(a.m_z == 9); +} + +TEST_CASE("Vec3Int operator*= scales all components", "[Vec3Int]") +{ + Vec3Int v(2, 3, 4); + v *= 3; + + REQUIRE(v.m_x == 6); + REQUIRE(v.m_y == 9); + REQUIRE(v.m_z == 12); +} + +TEST_CASE("Vec3Int operator/= divides all components by scalar", "[Vec3Int]") +{ + SECTION("Even division") + { + Vec3Int v(4, 6, 8); + v /= 2; + + REQUIRE(v.m_x == 2); + REQUIRE(v.m_y == 3); + REQUIRE(v.m_z == 4); + } + + SECTION("Integer truncation towards zero") + { + Vec3Int v(5, 7, 9); + v /= 2; + + REQUIRE(v.m_x == 2); + REQUIRE(v.m_y == 3); + REQUIRE(v.m_z == 4); + } +} + +TEST_CASE("Vec3Int free operator+ adds component-wise", "[Vec3Int]") +{ + const Vec3Int a(1, 2, 3); + const Vec3Int b(4, 5, 6); + const Vec3Int result = a + b; + + REQUIRE(result.m_x == 5); + REQUIRE(result.m_y == 7); + REQUIRE(result.m_z == 9); +} + +TEST_CASE("Vec3Int free operator- subtracts component-wise", "[Vec3Int]") +{ + const Vec3Int a(5, 7, 9); + const Vec3Int b(1, 2, 3); + const Vec3Int result = a - b; + + REQUIRE(result.m_x == 4); + REQUIRE(result.m_y == 5); + REQUIRE(result.m_z == 6); +} + +TEST_CASE("Vec3Int scalar multiply produces correct result", "[Vec3Int]") +{ + const Vec3Int v(1, 2, 3); + const Vec3Int result = v * 4; + + REQUIRE(result.m_x == 4); + REQUIRE(result.m_y == 8); + REQUIRE(result.m_z == 12); +} + +TEST_CASE("Vec3Int LengthSquared and Length are correct", "[Vec3Int]") +{ + const Vec3Int v(3, 4, 0); + + REQUIRE(v.LengthSquared() == Approx(25.0)); + REQUIRE(v.Length() == Approx(5.0)); +} + +} // namespace ART From 50398dd787836ec335d6c13f22ee4edd54953ac9 Mon Sep 17 00:00:00 2001 From: Mia Rolfe Date: Thu, 19 Feb 2026 17:41:40 +0000 Subject: [PATCH 2/3] Attempt to fix TextureTest issue --- tests/TextureTest.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TextureTest.cpp b/tests/TextureTest.cpp index 140e8b6..972abc9 100644 --- a/tests/TextureTest.cpp +++ b/tests/TextureTest.cpp @@ -154,7 +154,7 @@ TEST_CASE("CheckerTexture with negative coordinates", "[Texture]") TEST_CASE("ImageTexture returns fallback colour when image fails to load", "[Texture]") { - const ImageTexture t("no_image_here.png"); + const ImageTexture t("__does_not_exist__/no_image_here.png"); const Colour result = t.Value(0.5, 0.5, Point3(0.0, 0.0, 0.0)); From ede8d87932f82316d88db806ee326fa7d99f1a25 Mon Sep 17 00:00:00 2001 From: Mia Rolfe Date: Thu, 19 Feb 2026 17:47:07 +0000 Subject: [PATCH 3/3] Add default values for ImageTexture fields --- lib/Materials/Image.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Materials/Image.cpp b/lib/Materials/Image.cpp index 346c03e..1a2a700 100644 --- a/lib/Materials/Image.cpp +++ b/lib/Materials/Image.cpp @@ -15,6 +15,7 @@ Image::Image() } Image::Image(const char* file_name) + : m_image_width(0), m_image_height(0) { const std::string image_file_name(file_name);