diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a1a206..7e5fdc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,16 +116,16 @@ option(BUILD_TESTS "Build test suite" ON) if(BUILD_TESTS) enable_testing() - - add_executable(test_framebuffer tests/test_framebuffer.cpp) - target_link_libraries(test_framebuffer PRIVATE soft_render) - add_test(NAME test_framebuffer COMMAND test_framebuffer) - - add_executable(test_vec3 tests/test_vec3.cpp) - target_link_libraries(test_vec3 PRIVATE soft_render) - add_test(NAME test_vec3 COMMAND test_vec3) - - add_executable(tests tests/test_math.cpp) - target_link_libraries(tests PRIVATE soft_render) - add_test(NAME test_math COMMAND tests) + function(sr_add_test name source) + add_executable(${name} ${source}) + target_link_libraries(${name} PRIVATE soft_render) + add_test(NAME ${name} COMMAND ${name}) + endfunction() + + sr_add_test(test_framebuffer tests/test_framebuffer.cpp) + sr_add_test(test_vec3 tests/test_vec3.cpp) + sr_add_test(tests tests/test_math.cpp) + sr_add_test(test_math_invariants tests/test_math_invariants.cpp) + sr_add_test(test_rasterizer tests/test_rasterizer.cpp) + sr_add_test(test_pipeline_integration tests/test_pipeline_integration.cpp) endif() diff --git a/tests/test_helpers.hpp b/tests/test_helpers.hpp new file mode 100644 index 0000000..42fe003 --- /dev/null +++ b/tests/test_helpers.hpp @@ -0,0 +1,84 @@ +#pragma once + +// ============================================================================= +// test_helpers.hpp — Shared test utilities and tolerance constants +// +// All floating-point comparisons use these two tiers: +// EPS (1e-4) — tight: single-operation results, exact math identities +// EPS_LOOSE (1e-2) — loose: chained transforms, accumulated floating-point error +// ============================================================================= + +#include +#include +#include "soft_render/math/vec3.hpp" +#include "soft_render/core/framebuffer.hpp" + +namespace sr_test { + +using sr::math::Vec3; +using sr::core::Framebuffer; +using sr::core::Pixel; + +// --------------------------------------------------------------------------- +// Tolerance constants +// --------------------------------------------------------------------------- +constexpr float EPS = 1e-4f; // Single-operation precision +constexpr float EPS_LOOSE = 1e-2f; // Multi-operation / chained transforms +constexpr float PI = 3.14159265358979f; + +// --------------------------------------------------------------------------- +// Floating-point comparison helpers +// --------------------------------------------------------------------------- +inline bool approx(float a, float b, float eps = EPS) { + return std::abs(a - b) < eps; +} + +inline bool approxVec3(const Vec3& a, const Vec3& b, float eps = EPS) { + return approx(a.x, b.x, eps) && approx(a.y, b.y, eps) && approx(a.z, b.z, eps); +} + +// --------------------------------------------------------------------------- +// Deterministic PRNG (xorshift32) — reproducible but not hardcodeable +// --------------------------------------------------------------------------- +inline uint32_t& rng_state() { + static uint32_t s = 0xDEADBEEF; + return s; +} + +inline float randf() { + auto& s = rng_state(); + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + return (s & 0xFFFFFF) / float(0xFFFFFF); +} + +inline float randf_range(float lo, float hi) { return lo + randf() * (hi - lo); } + +inline Vec3 randVec3(float range = 10.f) { + return { randf_range(-range, range), randf_range(-range, range), randf_range(-range, range) }; +} + +inline Vec3 randUnitVec3() { return randVec3().normalized(); } + +// --------------------------------------------------------------------------- +// Framebuffer inspection helpers +// --------------------------------------------------------------------------- +inline int countNonBlack(const Framebuffer& fb) { + int count = 0; + const Pixel* px = fb.pixels(); + for (int i = 0; i < fb.width() * fb.height(); ++i) + if (px[i].r > 0 || px[i].g > 0 || px[i].b > 0) ++count; + return count; +} + +inline Pixel getPixel(const Framebuffer& fb, int x, int y) { + return fb.pixels()[y * fb.width() + x]; +} + +inline bool isLit(const Framebuffer& fb, int x, int y) { + Pixel p = getPixel(fb, x, y); + return p.r > 0 || p.g > 0 || p.b > 0; +} + +} // namespace sr_test diff --git a/tests/test_math_invariants.cpp b/tests/test_math_invariants.cpp new file mode 100644 index 0000000..564580b --- /dev/null +++ b/tests/test_math_invariants.cpp @@ -0,0 +1,615 @@ +// ============================================================================= +// test_math_invariants.cpp — Uncheateable mathematical property tests +// +// Strategy: Test INVARIANTS that hold for ALL inputs, not specific values. +// A stub implementation cannot pass these without implementing the real math. +// Uses a deterministic PRNG so tests are reproducible but inputs are varied. +// +// Tolerances: +// EPS (1e-4) — single operations (dot, cross, normalize, etc.) +// EPS_LOOSE (1e-2) — chained transforms (rotation composition, MVP chains) +// ============================================================================= + +#include +#include +#include + +#include "test_helpers.hpp" +#include "soft_render/math/vec3.hpp" +#include "soft_render/math/vec4.hpp" +#include "soft_render/math/mat4.hpp" + +using namespace sr::math; +using namespace sr_test; + +// ============================================================================= +// Vec3 invariant tests +// ============================================================================= + +void test_vec3_cross_perpendicularity() { + // INVARIANT: cross(a, b) is perpendicular to both a and b + for (int i = 0; i < 200; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + Vec3 c = a.cross(b); + + if (c.length() < 1e-3f) continue; + + float dot_a = std::abs(c.dot(a)); + float dot_b = std::abs(c.dot(b)); + assert(dot_a < EPS_LOOSE && "cross product must be perpendicular to first operand"); + assert(dot_b < EPS_LOOSE && "cross product must be perpendicular to second operand"); + } + std::cout << " cross perpendicularity: PASS" << std::endl; +} + +void test_vec3_cross_magnitude() { + // INVARIANT: |a x b|^2 + (a . b)^2 = |a|^2 * |b|^2 (Lagrange identity) + for (int i = 0; i < 200; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + Vec3 c = a.cross(b); + + float lhs = c.lengthSq() + a.dot(b) * a.dot(b); + float rhs = a.lengthSq() * b.lengthSq(); + // Use relative tolerance for large values + float tol = std::max(EPS_LOOSE, std::abs(rhs) * EPS); + assert(approx(lhs, rhs, tol) && "Lagrange identity must hold"); + } + std::cout << " cross magnitude (Lagrange identity): PASS" << std::endl; +} + +void test_vec3_cross_anticommutativity() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + Vec3 ab = a.cross(b); + Vec3 ba = b.cross(a); + assert(approxVec3(ab, ba * -1.0f, EPS) && "cross must be anti-commutative"); + } + std::cout << " cross anti-commutativity: PASS" << std::endl; +} + +void test_vec3_cross_self_is_zero() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 c = a.cross(a); + assert(c.length() < EPS && "cross product with self must be zero"); + } + std::cout << " cross self = zero: PASS" << std::endl; +} + +void test_vec3_dot_commutative() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + assert(approx(a.dot(b), b.dot(a), EPS) && "dot must be commutative"); + } + std::cout << " dot commutativity: PASS" << std::endl; +} + +void test_vec3_dot_self_equals_lengthsq() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + assert(approx(a.dot(a), a.lengthSq(), EPS) && "a.a must equal |a|^2"); + } + std::cout << " dot(a,a) = lengthSq(a): PASS" << std::endl; +} + +void test_vec3_normalize_unit_length() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + if (a.length() < 1e-6f) continue; + Vec3 n = a.normalized(); + assert(approx(n.length(), 1.0f, EPS) && "normalized vector must have unit length"); + } + std::cout << " normalize produces unit length: PASS" << std::endl; +} + +void test_vec3_normalize_preserves_direction() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + if (a.length() < 1e-6f) continue; + Vec3 n = a.normalized(); + Vec3 c = n.cross(a); + assert(c.length() < EPS_LOOSE && "normalized vector must be parallel to original"); + assert(n.dot(a) > 0 && "normalized must preserve direction"); + } + std::cout << " normalize preserves direction: PASS" << std::endl; +} + +void test_vec3_lerp_endpoints() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + assert(approxVec3(a.lerp(b, 0.0f), a, EPS) && "lerp(0) must return start"); + assert(approxVec3(a.lerp(b, 1.0f), b, EPS) && "lerp(1) must return end"); + } + std::cout << " lerp endpoints: PASS" << std::endl; +} + +void test_vec3_lerp_midpoint() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + Vec3 mid = a.lerp(b, 0.5f); + Vec3 expected = { (a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f, (a.z + b.z) * 0.5f }; + assert(approxVec3(mid, expected, EPS) && "lerp(0.5) must be midpoint"); + } + std::cout << " lerp midpoint: PASS" << std::endl; +} + +void test_vec3_arithmetic_identities() { + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 zero(0, 0, 0); + + assert(approxVec3(a + zero, a, EPS) && "a + 0 must equal a"); + assert(approxVec3(a - a, zero, EPS) && "a - a must equal 0"); + assert(approxVec3(a * 1.0f, a, EPS) && "a * 1 must equal a"); + assert(approxVec3(a * 0.0f, zero, EPS) && "a * 0 must equal 0"); + } + std::cout << " arithmetic identities: PASS" << std::endl; +} + +void test_vec3_distributive() { + // a . (b + c) = a.b + a.c + for (int i = 0; i < 100; ++i) { + Vec3 a = randVec3(); + Vec3 b = randVec3(); + Vec3 c = randVec3(); + float lhs = a.dot(b + c); + float rhs = a.dot(b) + a.dot(c); + // Use relative tolerance — large vectors produce large dot products + float tol = std::max(EPS, std::abs(rhs) * EPS); + assert(approx(lhs, rhs, tol) && "dot must distribute over addition"); + } + std::cout << " dot distributive: PASS" << std::endl; +} + +// ============================================================================= +// Mat4 invariant tests +// ============================================================================= + +static float mat4_determinant_3x3(const Mat4& m) { + return m(0,0) * (m(1,1)*m(2,2) - m(1,2)*m(2,1)) + - m(0,1) * (m(1,0)*m(2,2) - m(1,2)*m(2,0)) + + m(0,2) * (m(1,0)*m(2,1) - m(1,1)*m(2,0)); +} + +void test_rotation_determinant_is_one() { + for (int i = 0; i < 100; ++i) { + float angle = randf_range(-PI, PI); + Mat4 rx = Mat4::rotationX(angle); + Mat4 ry = Mat4::rotationY(angle); + Mat4 rz = Mat4::rotationZ(angle); + + assert(approx(mat4_determinant_3x3(rx), 1.0f, EPS) && "rotationX det must be 1"); + assert(approx(mat4_determinant_3x3(ry), 1.0f, EPS) && "rotationY det must be 1"); + assert(approx(mat4_determinant_3x3(rz), 1.0f, EPS) && "rotationZ det must be 1"); + } + std::cout << " rotation determinant = 1: PASS" << std::endl; +} + +void test_rotation_orthogonality() { + // R^T * R = I — uses EPS_LOOSE because of chained multiply + for (int i = 0; i < 100; ++i) { + float angle = randf_range(-PI, PI); + Mat4 R = Mat4::rotationX(angle) * Mat4::rotationY(randf_range(-PI, PI)); + Mat4 Rt = R.transposed(); + Mat4 RtR = Rt * R; + + for (int r = 0; r < 3; ++r) { + for (int c = 0; c < 3; ++c) { + float expected = (r == c) ? 1.0f : 0.0f; + assert(approx(RtR(r, c), expected, EPS_LOOSE) && "R^T * R must be identity"); + } + } + } + std::cout << " rotation orthogonality (R^T*R = I): PASS" << std::endl; +} + +void test_rotation_preserves_length() { + // Uses EPS_LOOSE: 3 chained rotations + matrix-vector multiply + for (int i = 0; i < 100; ++i) { + float angle = randf_range(-PI, PI); + Mat4 R = Mat4::rotationX(angle) * Mat4::rotationY(randf_range(-PI, PI)) + * Mat4::rotationZ(randf_range(-PI, PI)); + Vec3 v = randVec3(); + Vec4 v4(v, 0.0f); + Vec4 rotated = R * v4; + Vec3 r3 = rotated.xyz(); + + assert(approx(v.length(), r3.length(), EPS_LOOSE) && "rotation must preserve length"); + } + std::cout << " rotation preserves length: PASS" << std::endl; +} + +void test_rotation_composition() { + // R(a) * R(b) = R(a + b) — EPS_LOOSE for chained ops + for (int i = 0; i < 100; ++i) { + float a = randf_range(-PI, PI); + float b = randf_range(-PI, PI); + + Mat4 Ra = Mat4::rotationZ(a); + Mat4 Rb = Mat4::rotationZ(b); + Mat4 Rab = Mat4::rotationZ(a + b); + Mat4 composed = Ra * Rb; + + for (int r = 0; r < 3; ++r) + for (int c = 0; c < 3; ++c) + assert(approx(composed(r, c), Rab(r, c), EPS_LOOSE) && "rotation composition must match"); + } + std::cout << " rotation composition R(a)*R(b) = R(a+b): PASS" << std::endl; +} + +void test_translation_applies_to_points() { + for (int i = 0; i < 100; ++i) { + Vec3 offset = randVec3(); + Vec3 point = randVec3(); + Mat4 T = Mat4::translation(offset); + Vec4 result = T * Vec4(point, 1.0f); + + assert(approxVec3(result.xyz(), point + offset, EPS) && "translation must offset points"); + assert(approx(result.w, 1.0f, EPS) && "translation must preserve w=1"); + } + std::cout << " translation applies to points: PASS" << std::endl; +} + +void test_translation_ignores_directions() { + for (int i = 0; i < 100; ++i) { + Vec3 offset = randVec3(); + Vec3 dir = randVec3(); + Mat4 T = Mat4::translation(offset); + Vec4 result = T * Vec4(dir, 0.0f); + + assert(approxVec3(result.xyz(), dir, EPS) && "translation must not affect directions"); + assert(approx(result.w, 0.0f, EPS) && "translation must preserve w=0"); + } + std::cout << " translation ignores directions (w=0): PASS" << std::endl; +} + +void test_scale_multiplies_components() { + for (int i = 0; i < 100; ++i) { + Vec3 s = randVec3(); + Vec3 v = randVec3(); + Mat4 S = Mat4::scale(s); + Vec4 result = S * Vec4(v, 1.0f); + Vec3 expected = { v.x * s.x, v.y * s.y, v.z * s.z }; + + assert(approxVec3(result.xyz(), expected, EPS) && "scale must multiply components"); + } + std::cout << " scale multiplies components: PASS" << std::endl; +} + +void test_mat4_multiplication_associative() { + // (A * B) * C = A * (B * C) — EPS_LOOSE for triple chain + for (int i = 0; i < 50; ++i) { + float a1 = randf_range(-PI, PI), a2 = randf_range(-PI, PI); + Mat4 A = Mat4::rotationX(a1); + Mat4 B = Mat4::translation(randVec3()); + Mat4 C = Mat4::rotationY(a2); + + Mat4 lhs = (A * B) * C; + Mat4 rhs = A * (B * C); + + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + assert(approx(lhs(r, c), rhs(r, c), EPS_LOOSE) && "matrix multiplication must be associative"); + } + std::cout << " matrix multiplication associativity: PASS" << std::endl; +} + +void test_mat4_identity_is_neutral() { + for (int i = 0; i < 50; ++i) { + Mat4 A = Mat4::rotationX(randf_range(-PI, PI)) * + Mat4::translation(randVec3()) * + Mat4::scale(randVec3()); + Mat4 I = Mat4::identity(); + + Mat4 AI = A * I; + Mat4 IA = I * A; + + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) { + assert(approx(AI(r, c), A(r, c), EPS) && "A * I must equal A"); + assert(approx(IA(r, c), A(r, c), EPS) && "I * A must equal A"); + } + } + std::cout << " identity is neutral element: PASS" << std::endl; +} + +void test_mat4_vec_multiplication_distributes() { + // M * (a + b) = M*a + M*b — EPS_LOOSE for chained ops with large values + for (int i = 0; i < 50; ++i) { + Mat4 M = Mat4::rotationZ(randf_range(-PI, PI)) * Mat4::scale(randVec3()); + Vec4 a(randVec3(), 0.0f); + Vec4 b(randVec3(), 0.0f); + + Vec4 lhs = M * (a + b); + Vec4 rhs = (M * a) + (M * b); + + for (int j = 0; j < 4; ++j) + assert(approx(lhs[j], rhs[j], EPS_LOOSE) && "M*(a+b) must equal M*a + M*b"); + } + std::cout << " matrix-vector distributivity: PASS" << std::endl; +} + +void test_transpose_involution() { + for (int i = 0; i < 50; ++i) { + Mat4 A = Mat4::rotationX(randf_range(-PI, PI)) * Mat4::translation(randVec3()); + Mat4 Att = A.transposed().transposed(); + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + assert(approx(Att(r, c), A(r, c), EPS) && "double transpose must be identity"); + } + std::cout << " transpose involution: PASS" << std::endl; +} + +void test_transpose_product() { + // (A * B)^T = B^T * A^T — EPS_LOOSE for chained ops + for (int i = 0; i < 50; ++i) { + Mat4 A = Mat4::rotationX(randf_range(-PI, PI)); + Mat4 B = Mat4::rotationY(randf_range(-PI, PI)); + Mat4 lhs = (A * B).transposed(); + Mat4 rhs = B.transposed() * A.transposed(); + + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + assert(approx(lhs(r, c), rhs(r, c), EPS_LOOSE) && "(AB)^T must equal B^T * A^T"); + } + std::cout << " transpose product rule: PASS" << std::endl; +} + +// ============================================================================= +// Perspective projection invariants +// ============================================================================= + +void test_perspective_maps_near_to_neg1() { + for (int i = 0; i < 50; ++i) { + float near = randf_range(0.01f, 10.0f); + float far = near + randf_range(1.0f, 100.0f); + float fov = randf_range(0.3f, 2.5f); + float aspect = randf_range(0.5f, 2.0f); + + Mat4 P = Mat4::perspective(fov, aspect, near, far); + Vec4 nearPt(0, 0, -near, 1.0f); + Vec4 clip = P * nearPt; + float ndcZ = clip.z / clip.w; + + assert(approx(ndcZ, -1.0f, EPS_LOOSE) && "near plane must map to NDC z = -1"); + } + std::cout << " perspective: near plane -> z=-1: PASS" << std::endl; +} + +void test_perspective_maps_far_to_pos1() { + for (int i = 0; i < 50; ++i) { + float near = randf_range(0.01f, 10.0f); + float far = near + randf_range(1.0f, 100.0f); + float fov = randf_range(0.3f, 2.5f); + float aspect = randf_range(0.5f, 2.0f); + + Mat4 P = Mat4::perspective(fov, aspect, near, far); + Vec4 farPt(0, 0, -far, 1.0f); + Vec4 clip = P * farPt; + float ndcZ = clip.z / clip.w; + + assert(approx(ndcZ, 1.0f, EPS_LOOSE) && "far plane must map to NDC z = +1"); + } + std::cout << " perspective: far plane -> z=+1: PASS" << std::endl; +} + +void test_perspective_center_maps_to_origin() { + for (int i = 0; i < 50; ++i) { + float near = randf_range(0.01f, 10.0f); + float far = near + randf_range(1.0f, 100.0f); + float fov = randf_range(0.3f, 2.5f); + float aspect = randf_range(0.5f, 2.0f); + float z = randf_range(-far, -near); + + Mat4 P = Mat4::perspective(fov, aspect, near, far); + Vec4 pt(0, 0, z, 1.0f); + Vec4 clip = P * pt; + float ndcX = clip.x / clip.w; + float ndcY = clip.y / clip.w; + + assert(approx(ndcX, 0.0f, EPS) && "center point must map to NDC x=0"); + assert(approx(ndcY, 0.0f, EPS) && "center point must map to NDC y=0"); + } + std::cout << " perspective: center -> origin: PASS" << std::endl; +} + +void test_perspective_depth_monotonic() { + float near = 0.1f, far = 100.0f; + Mat4 P = Mat4::perspective(1.0f, 1.0f, near, far); + + float prevNdcZ = -2.0f; + for (int i = 0; i < 50; ++i) { + float z = -near - (far - near) * (i / 49.0f); + Vec4 clip = P * Vec4(0, 0, z, 1.0f); + float ndcZ = clip.z / clip.w; + + assert(ndcZ >= prevNdcZ - EPS && "depth must be monotonically increasing with distance"); + prevNdcZ = ndcZ; + } + std::cout << " perspective: depth monotonicity: PASS" << std::endl; +} + +// ============================================================================= +// LookAt invariants +// ============================================================================= + +void test_lookat_maps_eye_to_origin() { + // Uses EPS_LOOSE: lookAt involves cross products and normalization + for (int i = 0; i < 50; ++i) { + Vec3 eye = randVec3(); + Vec3 target = randVec3(); + if ((target - eye).length() < 0.1f) continue; + Vec3 up(0, 1, 0); + + Mat4 V = Mat4::lookAt(eye, target, up); + Vec4 result = V * Vec4(eye, 1.0f); + + assert(approx(result.x, 0, EPS_LOOSE) && "eye must map to x=0"); + assert(approx(result.y, 0, EPS_LOOSE) && "eye must map to y=0"); + assert(approx(result.z, 0, EPS_LOOSE) && "eye must map to z=0"); + } + std::cout << " lookAt maps eye to origin: PASS" << std::endl; +} + +void test_lookat_target_on_neg_z() { + // Uses EPS_LOOSE: same reasoning as above + for (int i = 0; i < 50; ++i) { + Vec3 eye = randVec3(); + Vec3 target = randVec3(); + if ((target - eye).length() < 0.1f) continue; + Vec3 up(0, 1, 0); + + Mat4 V = Mat4::lookAt(eye, target, up); + Vec4 targetView = V * Vec4(target, 1.0f); + + assert(approx(targetView.x, 0, EPS_LOOSE) && "target must be at x=0 in view space"); + assert(approx(targetView.y, 0, EPS_LOOSE) && "target must be at y=0 in view space"); + assert(targetView.z < 0 && "target must be at z<0 in view space (right-handed)"); + } + std::cout << " lookAt: target on -Z axis: PASS" << std::endl; +} + +// ============================================================================= +// Ortho invariants +// ============================================================================= + +void test_ortho_maps_center_to_origin() { + for (int i = 0; i < 50; ++i) { + float l = randf_range(-10, -0.1f); + float r = randf_range(0.1f, 10); + float b = randf_range(-10, -0.1f); + float t = randf_range(0.1f, 10); + float n = randf_range(0.01f, 5.0f); + float f = n + randf_range(1.0f, 50.0f); + + Mat4 O = Mat4::ortho(l, r, b, t, n, f); + Vec4 center((l + r) * 0.5f, (b + t) * 0.5f, -(n + f) * 0.5f, 1.0f); + Vec4 result = O * center; + + assert(approx(result.x, 0, EPS_LOOSE) && "ortho center must map to x=0"); + assert(approx(result.y, 0, EPS_LOOSE) && "ortho center must map to y=0"); + assert(approx(result.z, 0, EPS_LOOSE) && "ortho center must map to z=0"); + } + std::cout << " ortho: center -> origin: PASS" << std::endl; +} + +// ============================================================================= +// Vec4 tests +// ============================================================================= + +void test_vec4_perspective_divide() { + for (int i = 0; i < 100; ++i) { + float w = randf_range(0.1f, 10.0f); + Vec3 xyz = randVec3(); + Vec4 v(xyz, w); + Vec3 p = v.perspective(); + + assert(approx(p.x, xyz.x / w, EPS) && "perspective x must be x/w"); + assert(approx(p.y, xyz.y / w, EPS) && "perspective y must be y/w"); + assert(approx(p.z, xyz.z / w, EPS) && "perspective z must be z/w"); + } + std::cout << " Vec4 perspective divide: PASS" << std::endl; +} + +void test_vec4_dot_matches_manual() { + for (int i = 0; i < 100; ++i) { + Vec3 a3 = randVec3(), b3 = randVec3(); + float aw = randf(), bw = randf(); + Vec4 a(a3, aw); + Vec4 b(b3, bw); + + float dot = a.dot(b); + float expected = a3.x*b3.x + a3.y*b3.y + a3.z*b3.z + aw*bw; + assert(approx(dot, expected, EPS) && "Vec4 dot must match manual computation"); + } + std::cout << " Vec4 dot matches manual: PASS" << std::endl; +} + +// ============================================================================= +// Combined transform chain tests +// ============================================================================= + +void test_mvp_pipeline_roundtrip() { + // EPS_LOOSE: chained matrix multiply + matrix-vector multiply + for (int i = 0; i < 50; ++i) { + float angle = randf_range(-PI, PI); + Vec3 trans = randVec3(); + + Mat4 combined = Mat4::translation(trans) * Mat4::rotationZ(angle); + + Vec3 pt = randVec3(); + Vec4 pt4(pt, 1.0f); + + Vec4 res1 = combined * pt4; + Vec4 res2 = Mat4::translation(trans) * (Mat4::rotationZ(angle) * pt4); + + for (int j = 0; j < 4; ++j) + assert(approx(res1[j], res2[j], EPS_LOOSE) && "combined and sequential transforms must match"); + } + std::cout << " MVP pipeline consistency: PASS" << std::endl; +} + +// ============================================================================= +// Main +// ============================================================================= + +int main() { + std::cout << "=== Math Invariant Tests ===" << std::endl; + + std::cout << "\n--- Vec3 ---" << std::endl; + test_vec3_cross_perpendicularity(); + test_vec3_cross_magnitude(); + test_vec3_cross_anticommutativity(); + test_vec3_cross_self_is_zero(); + test_vec3_dot_commutative(); + test_vec3_dot_self_equals_lengthsq(); + test_vec3_normalize_unit_length(); + test_vec3_normalize_preserves_direction(); + test_vec3_lerp_endpoints(); + test_vec3_lerp_midpoint(); + test_vec3_arithmetic_identities(); + test_vec3_distributive(); + + std::cout << "\n--- Mat4 ---" << std::endl; + test_rotation_determinant_is_one(); + test_rotation_orthogonality(); + test_rotation_preserves_length(); + test_rotation_composition(); + test_translation_applies_to_points(); + test_translation_ignores_directions(); + test_scale_multiplies_components(); + test_mat4_multiplication_associative(); + test_mat4_identity_is_neutral(); + test_mat4_vec_multiplication_distributes(); + test_transpose_involution(); + test_transpose_product(); + + std::cout << "\n--- Perspective ---" << std::endl; + test_perspective_maps_near_to_neg1(); + test_perspective_maps_far_to_pos1(); + test_perspective_center_maps_to_origin(); + test_perspective_depth_monotonic(); + + std::cout << "\n--- LookAt ---" << std::endl; + test_lookat_maps_eye_to_origin(); + test_lookat_target_on_neg_z(); + + std::cout << "\n--- Ortho ---" << std::endl; + test_ortho_maps_center_to_origin(); + + std::cout << "\n--- Vec4 ---" << std::endl; + test_vec4_perspective_divide(); + test_vec4_dot_matches_manual(); + + std::cout << "\n--- Transform Chain ---" << std::endl; + test_mvp_pipeline_roundtrip(); + + std::cout << "\n=== ALL MATH INVARIANT TESTS PASSED ===" << std::endl; + return 0; +} diff --git a/tests/test_pipeline_integration.cpp b/tests/test_pipeline_integration.cpp new file mode 100644 index 0000000..ab67a41 --- /dev/null +++ b/tests/test_pipeline_integration.cpp @@ -0,0 +1,642 @@ +// ============================================================================= +// test_pipeline_integration.cpp — Full pipeline integration tests +// +// Strategy: Test the vertex processor, fragment shader, and renderer as +// integrated units. These tests verify that the full pipeline produces +// correct rendering output — something that cannot be faked without +// implementing the actual graphics pipeline. +// ============================================================================= + +#include +#include +#include +#include + +#include "test_helpers.hpp" +#include "soft_render/math/vec2.hpp" +#include "soft_render/math/vec3.hpp" +#include "soft_render/math/vec4.hpp" +#include "soft_render/math/mat4.hpp" +#include "soft_render/core/framebuffer.hpp" +#include "soft_render/core/texture.hpp" +#include "soft_render/pipeline/vertex.hpp" +#include "soft_render/pipeline/vertex_processor.hpp" +#include "soft_render/pipeline/rasterizer.hpp" +#include "soft_render/pipeline/fragment_shader.hpp" +#include "soft_render/render/renderer.hpp" + +using namespace sr; +using namespace sr::math; +using namespace sr::core; +using namespace sr::pipeline; +using namespace sr::render; +using namespace sr_test; + +// ============================================================================= +// Vertex Processor Tests +// ============================================================================= + +void test_vp_identity_transform() { + // With identity MVP, clipPos should equal Vec4(position, 1) + VertexProcessor vp; + Uniforms u; + u.model = Mat4::identity(); + u.view = Mat4::identity(); + u.projection = Mat4::identity(); + u.normalMatrix = Mat4::identity(); + + Vertex v; + v.position = Vec3(1, 2, 3); + v.normal = Vec3(0, 1, 0); + v.uv = Vec2(0.5f, 0.5f); + v.color = Vec3(1, 0, 0); + + ClipVertex cv = vp.process(v, u); + + assert(approx(cv.clipPos.x, 1.0f) && "identity: clipPos.x must match position.x"); + assert(approx(cv.clipPos.y, 2.0f) && "identity: clipPos.y must match position.y"); + assert(approx(cv.clipPos.z, 3.0f) && "identity: clipPos.z must match position.z"); + assert(approx(cv.clipPos.w, 1.0f) && "identity: clipPos.w must be 1"); + assert(approx(cv.worldPos.x, 1.0f) && "identity: worldPos must match position"); + assert(approx(cv.normal.y, 1.0f) && "identity: normal must be preserved"); + assert(approx(cv.uv.x, 0.5f) && "UV must be passed through"); + assert(approx(cv.color.x, 1.0f) && "color must be passed through"); + + std::cout << " VP identity transform: PASS" << std::endl; +} + +void test_vp_translation() { + VertexProcessor vp; + Uniforms u; + u.model = Mat4::translation(Vec3(10, 20, 30)); + u.view = Mat4::identity(); + u.projection = Mat4::identity(); + u.normalMatrix = Mat4::identity(); + + Vertex v; + v.position = Vec3(1, 2, 3); + v.normal = Vec3(0, 1, 0); + + ClipVertex cv = vp.process(v, u); + assert(approx(cv.worldPos.x, 11.0f) && "translation must offset x"); + assert(approx(cv.worldPos.y, 22.0f) && "translation must offset y"); + assert(approx(cv.worldPos.z, 33.0f) && "translation must offset z"); + + std::cout << " VP translation: PASS" << std::endl; +} + +void test_vp_batch_matches_single() { + // INVARIANT: processBatch must produce identical results to individual process calls + VertexProcessor vp; + Uniforms u; + u.model = Mat4::rotationY(0.7f) * Mat4::translation(Vec3(1, 2, 3)); + u.view = Mat4::lookAt(Vec3(0, 0, 5), Vec3(0, 0, 0), Vec3(0, 1, 0)); + u.projection = Mat4::perspective(PI / 4, 1.0f, 0.1f, 100.0f); + u.normalMatrix = Mat4::identity(); + + const int N = 10; + Vertex verts[N]; + for (int i = 0; i < N; ++i) { + verts[i].position = Vec3(i * 0.1f, i * 0.2f, i * 0.3f); + verts[i].normal = Vec3(0, 1, 0); + verts[i].uv = Vec2(i * 0.1f, 0); + verts[i].color = Vec3(1, 1, 1); + } + + // Single processing + ClipVertex singles[N]; + for (int i = 0; i < N; ++i) + singles[i] = vp.process(verts[i], u); + + // Batch processing + ClipVertex batch[N]; + vp.processBatch(verts, batch, N, u); + + for (int i = 0; i < N; ++i) { + assert(approx(singles[i].clipPos.x, batch[i].clipPos.x, EPS_LOOSE) && + "batch clipPos.x must match single"); + assert(approx(singles[i].clipPos.y, batch[i].clipPos.y, EPS_LOOSE) && + "batch clipPos.y must match single"); + assert(approx(singles[i].clipPos.z, batch[i].clipPos.z, EPS_LOOSE) && + "batch clipPos.z must match single"); + assert(approx(singles[i].clipPos.w, batch[i].clipPos.w, EPS_LOOSE) && + "batch clipPos.w must match single"); + assert(approx(singles[i].worldPos.x, batch[i].worldPos.x, EPS_LOOSE) && + "batch worldPos must match single"); + } + + std::cout << " VP batch matches single: PASS" << std::endl; +} + +void test_vp_rotation_preserves_distance() { + // INVARIANT: Rotation should not change distance from origin (in world space) + VertexProcessor vp; + Uniforms u; + u.model = Mat4::rotationY(1.23f) * Mat4::rotationX(0.45f); + u.view = Mat4::identity(); + u.projection = Mat4::identity(); + u.normalMatrix = u.model; // For pure rotation, normal matrix = model + + Vertex v; + v.position = Vec3(3, 4, 5); + v.normal = Vec3(0, 1, 0); + + ClipVertex cv = vp.process(v, u); + float origDist = v.position.length(); + float worldDist = cv.worldPos.length(); + + assert(approx(origDist, worldDist, EPS_LOOSE) && "rotation must preserve distance from origin"); + std::cout << " VP rotation preserves distance: PASS" << std::endl; +} + +// ============================================================================= +// Fragment Shader Tests +// ============================================================================= + +void test_fs_unlit_returns_albedo() { + FragmentShader fs; + Material mat; + mat.albedo = Color(0.5f, 0.3f, 0.8f); + + auto cb = fs.buildUnlit(mat); + + Fragment frag; + frag.color = Vec3(1, 1, 1); + frag.normal = Vec3(0, 0, 1); + frag.uv = Vec2(0, 0); + + Color result = cb(frag); + // Unlit should return albedo * frag.color, clamped + assert(approx(result.x, 0.5f, EPS_LOOSE) && "unlit red must match albedo"); + assert(approx(result.y, 0.3f, EPS_LOOSE) && "unlit green must match albedo"); + assert(approx(result.z, 0.8f, EPS_LOOSE) && "unlit blue must match albedo"); + + std::cout << " FS unlit returns albedo: PASS" << std::endl; +} + +void test_fs_lit_brighter_facing_light() { + // INVARIANT: A fragment facing a light should be brighter than one facing away + FragmentShader fs; + Material mat; + mat.albedo = Color(1, 1, 1); + mat.ambient = 0.05f; + mat.diffuse = 0.9f; + mat.specular = 0.0f; + + SceneLighting lighting; + lighting.cameraPos = Vec3(0, 0, 5); + lighting.ambientColor = Color(0.1f, 0.1f, 0.1f); + lighting.lights.push_back({{0, 0, 10}, {1,1,1}, 5.0f, 100.0f}); + + auto cb = fs.build(mat, lighting); + + // Fragment facing light (normal toward +Z) + Fragment facingLight; + facingLight.worldPos = Vec3(0, 0, 0); + facingLight.normal = Vec3(0, 0, 1); + facingLight.color = Vec3(1, 1, 1); + facingLight.uv = Vec2(0, 0); + + // Fragment facing away (normal toward -Z) + Fragment facingAway; + facingAway.worldPos = Vec3(0, 0, 0); + facingAway.normal = Vec3(0, 0, -1); + facingAway.color = Vec3(1, 1, 1); + facingAway.uv = Vec2(0, 0); + + Color brightResult = cb(facingLight); + Color darkResult = cb(facingAway); + + float brightLum = brightResult.x + brightResult.y + brightResult.z; + float darkLum = darkResult.x + darkResult.y + darkResult.z; + + assert(brightLum > darkLum && "fragment facing light must be brighter than one facing away"); + + std::cout << " FS lit vs unlit brightness: PASS" << std::endl; +} + +void test_fs_light_attenuation() { + // INVARIANT: Closer light should produce brighter result + FragmentShader fs; + Material mat; + mat.albedo = Color(1, 1, 1); + mat.ambient = 0.0f; + mat.diffuse = 1.0f; + mat.specular = 0.0f; + + // Close light + SceneLighting nearLighting; + nearLighting.cameraPos = Vec3(0, 0, 5); + nearLighting.ambientColor = Color(0, 0, 0); + nearLighting.lights.push_back({{0, 0, 2}, {1,1,1}, 1.0f, 50.0f}); + + // Far light + SceneLighting farLighting; + farLighting.cameraPos = Vec3(0, 0, 5); + farLighting.ambientColor = Color(0, 0, 0); + farLighting.lights.push_back({{0, 0, 20}, {1,1,1}, 1.0f, 50.0f}); + + auto nearCb = fs.build(mat, nearLighting); + auto farCb = fs.build(mat, farLighting); + + Fragment frag; + frag.worldPos = Vec3(0, 0, 0); + frag.normal = Vec3(0, 0, 1); + frag.color = Vec3(1, 1, 1); + frag.uv = Vec2(0, 0); + + Color nearResult = nearCb(frag); + Color farResult = farCb(frag); + + float nearLum = nearResult.x + nearResult.y + nearResult.z; + float farLum = farResult.x + farResult.y + farResult.z; + + assert(nearLum > farLum && "closer light must produce brighter fragment"); + std::cout << " FS light attenuation: PASS" << std::endl; +} + +void test_fs_specular_highlight() { + // INVARIANT: Specular highlight is strongest when view direction reflects the light + FragmentShader fs; + Material mat; + mat.albedo = Color(1, 1, 1); + mat.ambient = 0.0f; + mat.diffuse = 0.0f; + mat.specular = 1.0f; + mat.shininess = 64.0f; + + // Light at (0, 0, 5), camera at (0, 0, 5), fragment at origin with normal (0,0,1) + // Perfect mirror reflection → maximum specular + SceneLighting lighting; + lighting.cameraPos = Vec3(0, 0, 5); + lighting.ambientColor = Color(0, 0, 0); + lighting.lights.push_back({{0, 0, 5}, {1,1,1}, 5.0f, 100.0f}); + + auto cb = fs.build(mat, lighting); + + Fragment perfectReflect; + perfectReflect.worldPos = Vec3(0, 0, 0); + perfectReflect.normal = Vec3(0, 0, 1); + perfectReflect.color = Vec3(1, 1, 1); + perfectReflect.uv = Vec2(0, 0); + + Fragment offAngle; + offAngle.worldPos = Vec3(0, 0, 0); + offAngle.normal = Vec3(0.5f, 0.5f, 0.707f).normalized(); + offAngle.color = Vec3(1, 1, 1); + offAngle.uv = Vec2(0, 0); + + Color perfectResult = cb(perfectReflect); + Color offResult = cb(offAngle); + + float perfectLum = perfectResult.x + perfectResult.y + perfectResult.z; + float offLum = offResult.x + offResult.y + offResult.z; + + assert(perfectLum > offLum && "perfect reflection must produce stronger specular"); + std::cout << " FS specular highlight: PASS" << std::endl; +} + +void test_fs_output_clamped() { + // INVARIANT: Fragment shader output must be in [0, 1] + FragmentShader fs; + Material mat; + mat.albedo = Color(1, 1, 1); + mat.ambient = 1.0f; + mat.diffuse = 1.0f; + mat.specular = 1.0f; + + SceneLighting lighting; + lighting.cameraPos = Vec3(0, 0, 1); + lighting.ambientColor = Color(1, 1, 1); + // Very bright, close light + lighting.lights.push_back({{0, 0, 0.01f}, {10,10,10}, 100.0f, 1.0f}); + + auto cb = fs.build(mat, lighting); + + Fragment frag; + frag.worldPos = Vec3(0, 0, 0); + frag.normal = Vec3(0, 0, 1); + frag.color = Vec3(1, 1, 1); + frag.uv = Vec2(0, 0); + + Color result = cb(frag); + assert(result.x >= 0 && result.x <= 1.0f && "output R must be clamped to [0,1]"); + assert(result.y >= 0 && result.y <= 1.0f && "output G must be clamped to [0,1]"); + assert(result.z >= 0 && result.z <= 1.0f && "output B must be clamped to [0,1]"); + std::cout << " FS output clamped: PASS" << std::endl; +} + +// ============================================================================= +// Texture Tests +// ============================================================================= + +void test_texture_checkerboard_pattern() { + // INVARIANT: Checkerboard has alternating colors + Texture tex = Texture::checkerboard(64, 64, 16); + + Color c00 = tex.sampleNearest(0.0f, 0.0f); + Color c01 = tex.sampleNearest(0.25f, 0.0f); // Different square + // c00 and c01 should be different colors (one bright, one dark) + float lum0 = c00.x + c00.y + c00.z; + float lum1 = c01.x + c01.y + c01.z; + assert(std::abs(lum0 - lum1) > 0.5f && "checkerboard must have contrasting squares"); + + std::cout << " texture checkerboard pattern: PASS" << std::endl; +} + +void test_texture_bilinear_continuity() { + // INVARIANT: Bilinear sampling should be continuous (no jumps between adjacent samples) + Texture tex = Texture::checkerboard(32, 32, 8); + + float maxJump = 0; + for (int i = 0; i < 100; ++i) { + float u = i / 100.0f; + Color c0 = tex.sample(u, 0.5f); + Color c1 = tex.sample(u + 0.001f, 0.5f); + float jump = std::abs(c0.x - c1.x) + std::abs(c0.y - c1.y) + std::abs(c0.z - c1.z); + maxJump = std::max(maxJump, jump); + } + // Bilinear should have smooth transitions — max jump should be small + assert(maxJump < 0.5f && "bilinear sampling must be approximately continuous"); + std::cout << " texture bilinear continuity: PASS" << std::endl; +} + +void test_texture_wrap() { + // INVARIANT: sample(u+1, v) = sample(u, v) (wrapping) + Texture tex = Texture::checkerboard(32, 32, 8); + for (float u = 0; u < 1.0f; u += 0.1f) { + Color c0 = tex.sample(u, 0.3f); + Color c1 = tex.sample(u + 1.0f, 0.3f); + assert(approx(c0.x, c1.x, EPS_LOOSE) && "texture must wrap in U"); + assert(approx(c0.y, c1.y, EPS_LOOSE) && "texture must wrap in U"); + } + std::cout << " texture UV wrapping: PASS" << std::endl; +} + +void test_texture_empty() { + Texture empty; + assert(!empty.valid() && "empty texture must report invalid"); + Color c = empty.sample(0.5f, 0.5f); + assert(approx(c.x, 1.0f) && "empty texture sample must return white"); + std::cout << " empty texture: PASS" << std::endl; +} + +// ============================================================================= +// Full Renderer Integration Tests +// ============================================================================= + +// Define a simple quad (two triangles) +static void makeQuad(Vertex verts[4], uint32_t indices[6]) { + verts[0] = {{-1, -1, 0}, {0, 0, 1}, {0, 0}, {1, 1, 1}}; + verts[1] = {{ 1, -1, 0}, {0, 0, 1}, {1, 0}, {1, 1, 1}}; + verts[2] = {{ 1, 1, 0}, {0, 0, 1}, {1, 1}, {1, 1, 1}}; + verts[3] = {{-1, 1, 0}, {0, 0, 1}, {0, 1}, {1, 1, 1}}; + indices[0] = 0; indices[1] = 1; indices[2] = 2; + indices[3] = 0; indices[4] = 2; indices[5] = 3; +} + +void test_renderer_basic_draw() { + // Render a quad in front of camera — should produce pixels + Renderer r(64, 64); + r.beginFrame(); + + r.setView(Mat4::lookAt(Vec3(0, 0, 3), Vec3(0, 0, 0), Vec3(0, 1, 0))); + r.setProjection(Mat4::perspective(PI / 3, 1.0f, 0.1f, 100.0f)); + r.setModel(Mat4::identity()); + r.setCameraPos(Vec3(0, 0, 3)); + r.addLight({{0, 0, 5}, {1,1,1}, 5.0f, 100.0f}); + + Vertex verts[4]; + uint32_t indices[6]; + makeQuad(verts, indices); + + Material mat; + mat.albedo = Color(1, 0.5f, 0.3f); + + r.drawMesh(verts, 4, indices, 6, mat); + + int lit = countNonBlack(r.framebuffer()); + assert(lit > 100 && "basic quad render must produce significant pixel coverage"); + std::cout << " renderer basic draw: PASS" << std::endl; +} + +void test_renderer_nothing_behind_camera() { + // Object behind camera should produce no pixels + Renderer r(64, 64); + r.beginFrame(); + + r.setView(Mat4::lookAt(Vec3(0, 0, 3), Vec3(0, 0, 0), Vec3(0, 1, 0))); + r.setProjection(Mat4::perspective(PI / 3, 1.0f, 0.1f, 100.0f)); + r.setModel(Mat4::translation(Vec3(0, 0, 10))); // Behind camera + r.setCameraPos(Vec3(0, 0, 3)); + r.addLight({{0, 0, 5}, {1,1,1}, 5.0f, 100.0f}); + + Vertex verts[4]; + uint32_t indices[6]; + makeQuad(verts, indices); + + Material mat; + mat.albedo = Color(1, 1, 1); + + r.drawMesh(verts, 4, indices, 6, mat); + + int lit = countNonBlack(r.framebuffer()); + assert(lit == 0 && "object behind camera must produce no pixels"); + std::cout << " renderer nothing behind camera: PASS" << std::endl; +} + +void test_renderer_closer_is_larger() { + // INVARIANT: Closer object should cover more pixels (perspective projection) + auto renderQuadAtZ = [](float modelZ) -> int { + Renderer r(128, 128); + r.beginFrame(); + r.setView(Mat4::lookAt(Vec3(0, 0, 5), Vec3(0, 0, 0), Vec3(0, 1, 0))); + r.setProjection(Mat4::perspective(PI / 3, 1.0f, 0.1f, 100.0f)); + r.setModel(Mat4::translation(Vec3(0, 0, modelZ))); + r.setCameraPos(Vec3(0, 0, 5)); + r.addLight({{0, 0, 10}, {1,1,1}, 5.0f, 200.0f}); + + Vertex verts[4]; + uint32_t indices[6]; + makeQuad(verts, indices); + + Material mat; + mat.albedo = Color(1, 1, 1); + mat.ambient = 0.5f; + + r.drawMesh(verts, 4, indices, 6, mat); + return countNonBlack(r.framebuffer()); + }; + + int closePixels = renderQuadAtZ(0.0f); // z=0, camera at z=5 → distance = 5 + int farPixels = renderQuadAtZ(-10.0f); // z=-10, camera at z=5 → distance = 15 + + assert(closePixels > farPixels && "closer object must cover more pixels than farther one"); + std::cout << " renderer closer is larger (perspective): PASS" << std::endl; +} + +void test_renderer_resize() { + Renderer r(32, 32); + assert(r.width() == 32 && r.height() == 32); + + r.resize(64, 128); + assert(r.width() == 64 && r.height() == 128 && "resize must update dimensions"); + + r.beginFrame(); + int lit = countNonBlack(r.framebuffer()); + assert(lit == 0 && "resized framebuffer must be cleared"); + std::cout << " renderer resize: PASS" << std::endl; +} + +void test_renderer_begin_frame_clears() { + Renderer r(32, 32); + + // Draw something + r.beginFrame(); + r.setView(Mat4::lookAt(Vec3(0, 0, 3), Vec3(0, 0, 0), Vec3(0, 1, 0))); + r.setProjection(Mat4::perspective(PI / 3, 1.0f, 0.1f, 100.0f)); + r.setModel(Mat4::identity()); + r.setCameraPos(Vec3(0, 0, 3)); + r.addLight({{0, 0, 5}, {1,1,1}, 5.0f, 100.0f}); + + Vertex verts[4]; + uint32_t indices[6]; + makeQuad(verts, indices); + Material mat; + mat.albedo = Color(1, 1, 1); + r.drawMesh(verts, 4, indices, 6, mat); + + int litBefore = countNonBlack(r.framebuffer()); + assert(litBefore > 0); + + // beginFrame should clear everything + r.beginFrame(); + int litAfter = countNonBlack(r.framebuffer()); + assert(litAfter == 0 && "beginFrame must clear all pixels"); + + std::cout << " renderer beginFrame clears: PASS" << std::endl; +} + +void test_renderer_multiple_draws() { + // Two separate draw calls should both contribute pixels + Renderer r(128, 128); + r.beginFrame(); + r.setView(Mat4::lookAt(Vec3(0, 0, 5), Vec3(0, 0, 0), Vec3(0, 1, 0))); + r.setProjection(Mat4::perspective(PI / 3, 1.0f, 0.1f, 100.0f)); + r.setCameraPos(Vec3(0, 0, 5)); + r.addLight({{0, 0, 10}, {1,1,1}, 5.0f, 200.0f}); + + Vertex verts[4]; + uint32_t indices[6]; + makeQuad(verts, indices); + + Material mat; + mat.albedo = Color(1, 1, 1); + mat.ambient = 0.5f; + + // Draw left quad + r.setModel(Mat4::translation(Vec3(-2, 0, 0))); + r.drawMesh(verts, 4, indices, 6, mat); + int afterFirst = countNonBlack(r.framebuffer()); + + // Draw right quad + r.setModel(Mat4::translation(Vec3(2, 0, 0))); + r.drawMesh(verts, 4, indices, 6, mat); + int afterSecond = countNonBlack(r.framebuffer()); + + assert(afterSecond > afterFirst && "second draw must add more pixels"); + std::cout << " renderer multiple draws: PASS" << std::endl; +} + +// ============================================================================= +// Framebuffer Additional Tests +// ============================================================================= + +void test_framebuffer_depth_test_closer_wins() { + Framebuffer fb(10, 10); + fb.clear(); + + // Write depth 0.8 + assert(fb.depthTest(5, 5, 0.8f) == true && "first write must pass"); + assert(approx(fb.getDepth(5, 5), 0.8f)); + + // Write closer depth 0.3 — must pass + assert(fb.depthTest(5, 5, 0.3f) == true && "closer depth must pass"); + assert(approx(fb.getDepth(5, 5), 0.3f)); + + // Write farther depth 0.5 — must fail + assert(fb.depthTest(5, 5, 0.5f) == false && "farther depth must fail"); + assert(approx(fb.getDepth(5, 5), 0.3f) && "depth must not change on failed test"); + + // Write equal depth — must fail (strict less-than) + assert(fb.depthTest(5, 5, 0.3f) == false && "equal depth must fail"); + + std::cout << " framebuffer depth test: PASS" << std::endl; +} + +void test_framebuffer_pixel_readback() { + Framebuffer fb(4, 4); + fb.clear(); + + // Write specific pixels and read them back + fb.setPixel(0, 0, Color(1, 0, 0)); + fb.setPixel(3, 3, Color(0, 1, 0)); + fb.setPixel(1, 2, Color(0, 0, 1)); + + Pixel p00 = fb.pixels()[0 * 4 + 0]; + assert(p00.r == 255 && p00.g == 0 && p00.b == 0 && "pixel(0,0) must be red"); + + Pixel p33 = fb.pixels()[3 * 4 + 3]; + assert(p33.r == 0 && p33.g == 255 && p33.b == 0 && "pixel(3,3) must be green"); + + Pixel p12 = fb.pixels()[2 * 4 + 1]; + assert(p12.r == 0 && p12.g == 0 && p12.b == 255 && "pixel(1,2) must be blue"); + + // Unwritten pixel should be black + Pixel p10 = fb.pixels()[0 * 4 + 1]; + assert(p10.r == 0 && p10.g == 0 && p10.b == 0 && "unwritten pixel must be black"); + + std::cout << " framebuffer pixel readback: PASS" << std::endl; +} + +// ============================================================================= +// Main +// ============================================================================= + +int main() { + std::cout << "=== Pipeline Integration Tests ===" << std::endl; + + std::cout << "\n--- Vertex Processor ---" << std::endl; + test_vp_identity_transform(); + test_vp_translation(); + test_vp_batch_matches_single(); + test_vp_rotation_preserves_distance(); + + std::cout << "\n--- Fragment Shader ---" << std::endl; + test_fs_unlit_returns_albedo(); + test_fs_lit_brighter_facing_light(); + test_fs_light_attenuation(); + test_fs_specular_highlight(); + test_fs_output_clamped(); + + std::cout << "\n--- Texture ---" << std::endl; + test_texture_checkerboard_pattern(); + test_texture_bilinear_continuity(); + test_texture_wrap(); + test_texture_empty(); + + std::cout << "\n--- Full Renderer ---" << std::endl; + test_renderer_basic_draw(); + test_renderer_nothing_behind_camera(); + test_renderer_closer_is_larger(); + test_renderer_resize(); + test_renderer_begin_frame_clears(); + test_renderer_multiple_draws(); + + std::cout << "\n--- Framebuffer ---" << std::endl; + test_framebuffer_depth_test_closer_wins(); + test_framebuffer_pixel_readback(); + + std::cout << "\n=== ALL PIPELINE INTEGRATION TESTS PASSED ===" << std::endl; + return 0; +} diff --git a/tests/test_rasterizer.cpp b/tests/test_rasterizer.cpp new file mode 100644 index 0000000..cae7155 --- /dev/null +++ b/tests/test_rasterizer.cpp @@ -0,0 +1,639 @@ +// ============================================================================= +// test_rasterizer.cpp — Uncheateable rasterization tests +// +// Strategy: Render known geometric configurations and verify structural +// properties of the output framebuffer. These tests verify: +// - Triangle coverage (pixels inside/outside the triangle) +// - Depth buffer correctness (nearer fragments win) +// - Barycentric interpolation (color gradients across triangles) +// - Near-plane clipping +// - Edge cases (degenerate triangles, single-pixel triangles) +// +// Each test constructs ClipVertex data directly (bypassing vertex processor) +// to isolate rasterizer behavior. +// ============================================================================= + +#include +#include +#include +#include +#include + +#include "test_helpers.hpp" +#include "soft_render/core/framebuffer.hpp" +#include "soft_render/pipeline/rasterizer.hpp" +#include "soft_render/pipeline/vertex.hpp" +#include "soft_render/math/vec3.hpp" +#include "soft_render/math/vec4.hpp" + +using namespace sr; +using namespace sr::math; +using namespace sr::core; +using namespace sr::pipeline; +using namespace sr_test; + +// Helper: create a ClipVertex in NDC space (x,y in [-1,1], z in [-1,1], w=1) +static ClipVertex makeNDCVertex(float ndcX, float ndcY, float ndcZ, + Vec3 normal = {0, 0, 1}, + Vec3 color = {1, 1, 1}, + Vec2 uv = {0, 0}) { + ClipVertex cv; + cv.clipPos = Vec4(ndcX, ndcY, ndcZ, 1.0f); + cv.worldPos = Vec3(ndcX, ndcY, ndcZ); + cv.normal = normal; + cv.color = color; + cv.uv = uv; + return cv; +} + +// ============================================================================= +// Test: Full-screen triangle produces pixels everywhere +// ============================================================================= +void test_fullscreen_triangle() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + // Triangle that covers the entire screen (and then some) + Triangle tri; + tri.v[0] = makeNDCVertex(-2.0f, -2.0f, 0.0f, {0,0,1}, {1,1,1}); + tri.v[1] = makeNDCVertex( 2.0f, -2.0f, 0.0f, {0,0,1}, {1,1,1}); + tri.v[2] = makeNDCVertex( 0.0f, 2.0f, 0.0f, {0,0,1}, {1,1,1}); + + auto passthrough = [](const Fragment& f) -> Color { return f.color; }; + rast.rasterize(tri, passthrough); + + int lit = countNonBlack(fb); + // With a huge triangle covering NDC [-2,2], every pixel should be covered + assert(lit == W * H && "full-screen triangle must cover all pixels"); + std::cout << " full-screen triangle coverage: PASS" << std::endl; +} + +// ============================================================================= +// Test: Triangle in center produces pixels only in center region +// ============================================================================= +void test_centered_triangle() { + const int W = 64, H = 64; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + // Small triangle near center of screen + Triangle tri; + tri.v[0] = makeNDCVertex(-0.3f, -0.3f, 0.0f, {0,0,1}, {1,0,0}); + tri.v[1] = makeNDCVertex( 0.3f, -0.3f, 0.0f, {0,0,1}, {0,1,0}); + tri.v[2] = makeNDCVertex( 0.0f, 0.3f, 0.0f, {0,0,1}, {0,0,1}); + + auto passthrough = [](const Fragment& f) -> Color { return f.color; }; + rast.rasterize(tri, passthrough); + + // Center pixel (32, 32) should be lit + assert(isLit(fb, 32, 32) && "center pixel must be lit for centered triangle"); + + // Corner pixels should NOT be lit + assert(!isLit(fb, 0, 0) && "top-left corner must not be lit"); + assert(!isLit(fb, W-1, 0) && "top-right corner must not be lit"); + assert(!isLit(fb, 0, H-1) && "bottom-left corner must not be lit"); + assert(!isLit(fb, W-1, H-1) && "bottom-right corner must not be lit"); + + // Total lit pixels should be much less than total + int lit = countNonBlack(fb); + assert(lit > 0 && lit < W * H / 2 && "centered triangle should cover partial screen"); + + std::cout << " centered triangle spatial coverage: PASS" << std::endl; +} + +// ============================================================================= +// Test: Color interpolation across triangle +// ============================================================================= +void test_color_interpolation() { + const int W = 64, H = 64; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + // Triangle with distinct vertex colors: R, G, B + Triangle tri; + tri.v[0] = makeNDCVertex(-0.9f, -0.9f, 0.0f, {0,0,1}, {1, 0, 0}); // Red (bottom-left) + tri.v[1] = makeNDCVertex( 0.9f, -0.9f, 0.0f, {0,0,1}, {0, 1, 0}); // Green (bottom-right) + tri.v[2] = makeNDCVertex( 0.0f, 0.9f, 0.0f, {0,0,1}, {0, 0, 1}); // Blue (top-center) + + auto passthrough = [](const Fragment& f) -> Color { return f.color; }; + rast.rasterize(tri, passthrough); + + // Bottom-left area should be predominantly red + if (isLit(fb, 10, 5)) { + Pixel bl = getPixel(fb, 10, 5); + assert(bl.r > bl.g && bl.r > bl.b && "bottom-left should be predominantly red"); + } + + // Bottom-right area should be predominantly green + if (isLit(fb, W - 10, 5)) { + Pixel br = getPixel(fb, W - 10, 5); + assert(br.g > br.r && br.g > br.b && "bottom-right should be predominantly green"); + } + + // Top-center should be predominantly blue + if (isLit(fb, W / 2, H - 10)) { + Pixel tc = getPixel(fb, W / 2, H - 10); + assert(tc.b > tc.r && tc.b > tc.g && "top-center should be predominantly blue"); + } + + // Center pixel should have a MIX of all three colors (none should be 0) + Pixel center = getPixel(fb, W / 2, H / 3); + if (center.r + center.g + center.b > 0) { + assert(center.r > 0 && center.g > 0 && center.b > 0 && + "center should have mix of all vertex colors"); + } + + std::cout << " color interpolation: PASS" << std::endl; +} + +// ============================================================================= +// Test: Depth buffer — nearer triangles occlude farther ones +// ============================================================================= +void test_depth_occlusion() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto passthrough = [](const Fragment& f) -> Color { return f.color; }; + + // First: draw a RED triangle at z = 0.5 (farther) + Triangle far_tri; + far_tri.v[0] = makeNDCVertex(-0.8f, -0.8f, 0.5f, {0,0,1}, {1, 0, 0}); + far_tri.v[1] = makeNDCVertex( 0.8f, -0.8f, 0.5f, {0,0,1}, {1, 0, 0}); + far_tri.v[2] = makeNDCVertex( 0.0f, 0.8f, 0.5f, {0,0,1}, {1, 0, 0}); + rast.rasterize(far_tri, passthrough); + + // Then: draw a GREEN triangle at z = 0.1 (closer) + Triangle near_tri; + near_tri.v[0] = makeNDCVertex(-0.5f, -0.5f, 0.1f, {0,0,1}, {0, 1, 0}); + near_tri.v[1] = makeNDCVertex( 0.5f, -0.5f, 0.1f, {0,0,1}, {0, 1, 0}); + near_tri.v[2] = makeNDCVertex( 0.0f, 0.5f, 0.1f, {0,0,1}, {0, 1, 0}); + rast.rasterize(near_tri, passthrough); + + // Center should be GREEN (nearer wins) + Pixel center = getPixel(fb, W / 2, H / 3); + assert(center.g > center.r && "nearer green triangle must occlude farther red triangle"); + + // Now draw in REVERSE order to verify depth test works regardless of draw order + fb.clear(); + + // Draw GREEN (close) first + rast.rasterize(near_tri, passthrough); + // Draw RED (far) second — should NOT overwrite + rast.rasterize(far_tri, passthrough); + + Pixel center2 = getPixel(fb, W / 2, H / 3); + assert(center2.g > center2.r && "depth test must work regardless of draw order"); + + std::cout << " depth occlusion: PASS" << std::endl; +} + +// ============================================================================= +// Test: Depth values in framebuffer are correct +// ============================================================================= +void test_depth_values() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto passthrough = [](const Fragment& f) -> Color { return f.color; }; + + // Draw a triangle at constant z = 0.3 + Triangle tri; + tri.v[0] = makeNDCVertex(-0.9f, -0.9f, 0.3f, {0,0,1}, {1,1,1}); + tri.v[1] = makeNDCVertex( 0.9f, -0.9f, 0.3f, {0,0,1}, {1,1,1}); + tri.v[2] = makeNDCVertex( 0.0f, 0.9f, 0.3f, {0,0,1}, {1,1,1}); + rast.rasterize(tri, passthrough); + + // Center pixel depth should be approximately 0.3 + float centerDepth = fb.getDepth(W / 2, H / 3); + assert(std::abs(centerDepth - 0.3f) < 0.05f && "depth at center should match triangle z"); + + // Unlit pixel should still have infinity depth + float cornerDepth = fb.getDepth(0, 0); + assert(cornerDepth == std::numeric_limits::infinity() && + "unlit pixels must have infinite depth"); + + std::cout << " depth values: PASS" << std::endl; +} + +// ============================================================================= +// Test: Degenerate triangle (zero area) produces no pixels +// ============================================================================= +void test_degenerate_triangle() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto passthrough = [](const Fragment& f) -> Color { return {1,1,1}; }; + + // Collinear vertices — zero-area triangle + Triangle tri; + tri.v[0] = makeNDCVertex(-0.5f, 0.0f, 0.0f); + tri.v[1] = makeNDCVertex( 0.0f, 0.0f, 0.0f); + tri.v[2] = makeNDCVertex( 0.5f, 0.0f, 0.0f); + rast.rasterize(tri, passthrough); + + int lit = countNonBlack(fb); + assert(lit == 0 && "degenerate (collinear) triangle must produce no pixels"); + + // Point triangle — all vertices at same location + Triangle point_tri; + point_tri.v[0] = makeNDCVertex(0.0f, 0.0f, 0.0f); + point_tri.v[1] = makeNDCVertex(0.0f, 0.0f, 0.0f); + point_tri.v[2] = makeNDCVertex(0.0f, 0.0f, 0.0f); + rast.rasterize(point_tri, passthrough); + + lit = countNonBlack(fb); + assert(lit == 0 && "point triangle must produce no pixels"); + + std::cout << " degenerate triangles: PASS" << std::endl; +} + +// ============================================================================= +// Test: Triangle behind camera (z > 1 in NDC) is clipped +// ============================================================================= +void test_behind_camera_clipped() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto passthrough = [](const Fragment& f) -> Color { return {1,1,1}; }; + + // Triangle with w < 0 (behind camera in clip space, z+w < 0) + Triangle tri; + tri.v[0] = makeNDCVertex(0, 0, 0, {0,0,1}, {1,1,1}); + tri.v[0].clipPos = Vec4(0, 0, -2.0f, -1.0f); + tri.v[1] = makeNDCVertex(0, 0, 0, {0,0,1}, {1,1,1}); + tri.v[1].clipPos = Vec4(1, 0, -2.0f, -1.0f); + tri.v[2] = makeNDCVertex(0, 0, 0, {0,0,1}, {1,1,1}); + tri.v[2].clipPos = Vec4(0, 1, -2.0f, -1.0f); + + rast.rasterize(tri, passthrough); + + int lit = countNonBlack(fb); + assert(lit == 0 && "triangle entirely behind camera must produce no pixels"); + + std::cout << " behind-camera clipping: PASS" << std::endl; +} + +// ============================================================================= +// Test: Winding order — both CW and CCW triangles should render +// ============================================================================= +void test_winding_order() { + const int W = 32, H = 32; + + auto passthrough = [](const Fragment& f) -> Color { return {1,1,1}; }; + + // CCW triangle + { + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + Triangle tri; + tri.v[0] = makeNDCVertex(-0.5f, -0.5f, 0.0f); + tri.v[1] = makeNDCVertex( 0.5f, -0.5f, 0.0f); + tri.v[2] = makeNDCVertex( 0.0f, 0.5f, 0.0f); + rast.rasterize(tri, passthrough); + int ccw_lit = countNonBlack(fb); + assert(ccw_lit > 0 && "CCW triangle must produce pixels"); + } + + // CW triangle (reversed winding) + { + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + Triangle tri; + tri.v[0] = makeNDCVertex(-0.5f, -0.5f, 0.0f); + tri.v[2] = makeNDCVertex( 0.5f, -0.5f, 0.0f); + tri.v[1] = makeNDCVertex( 0.0f, 0.5f, 0.0f); + rast.rasterize(tri, passthrough); + int cw_lit = countNonBlack(fb); + assert(cw_lit > 0 && "CW triangle must also produce pixels (no back-face culling)"); + } + + std::cout << " winding order (both CW/CCW render): PASS" << std::endl; +} + +// ============================================================================= +// Test: Fragment callback receives valid barycentric coordinates +// ============================================================================= +void test_barycentric_validity() { + const int W = 64, H = 64; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + bool all_valid = true; + int fragment_count = 0; + + auto checker = [&](const Fragment& f) -> Color { + ++fragment_count; + // Barycentric coords should sum to ~1 + float sum = f.w0 + f.w1 + f.w2; + if (std::abs(sum - 1.0f) > 0.02f) all_valid = false; + // Each should be non-negative + if (f.w0 < -0.01f || f.w1 < -0.01f || f.w2 < -0.01f) all_valid = false; + return {f.w0, f.w1, f.w2}; + }; + + Triangle tri; + tri.v[0] = makeNDCVertex(-0.8f, -0.8f, 0.0f); + tri.v[1] = makeNDCVertex( 0.8f, -0.8f, 0.0f); + tri.v[2] = makeNDCVertex( 0.0f, 0.8f, 0.0f); + rast.rasterize(tri, checker); + + assert(fragment_count > 0 && "must produce at least one fragment"); + assert(all_valid && "all barycentric coordinates must be valid (sum=1, non-negative)"); + + std::cout << " barycentric validity: PASS" << std::endl; +} + +// ============================================================================= +// Test: Fragment world positions are interpolated within triangle bounds +// ============================================================================= +void test_fragment_world_position() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + Vec3 wp0(-1, -1, 0), wp1(1, -1, 0), wp2(0, 1, 0); + + Triangle tri; + tri.v[0] = makeNDCVertex(-0.8f, -0.8f, 0.0f); + tri.v[0].worldPos = wp0; + tri.v[1] = makeNDCVertex( 0.8f, -0.8f, 0.0f); + tri.v[1].worldPos = wp1; + tri.v[2] = makeNDCVertex( 0.0f, 0.8f, 0.0f); + tri.v[2].worldPos = wp2; + + bool positions_valid = true; + + auto checker = [&](const Fragment& f) -> Color { + // Interpolated world position must be within the convex hull of the triangle + // Simple check: each component should be within the min/max of the vertices + float minX = std::min({wp0.x, wp1.x, wp2.x}) - 0.1f; + float maxX = std::max({wp0.x, wp1.x, wp2.x}) + 0.1f; + float minY = std::min({wp0.y, wp1.y, wp2.y}) - 0.1f; + float maxY = std::max({wp0.y, wp1.y, wp2.y}) + 0.1f; + + if (f.worldPos.x < minX || f.worldPos.x > maxX) positions_valid = false; + if (f.worldPos.y < minY || f.worldPos.y > maxY) positions_valid = false; + return {1, 1, 1}; + }; + + rast.rasterize(tri, checker); + assert(positions_valid && "fragment world positions must be within triangle bounds"); + std::cout << " fragment world positions: PASS" << std::endl; +} + +// ============================================================================= +// Test: Fragment normals are interpolated and normalized +// ============================================================================= +void test_fragment_normals() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + Triangle tri; + tri.v[0] = makeNDCVertex(-0.8f, -0.8f, 0.0f, {1, 0, 0}); + tri.v[1] = makeNDCVertex( 0.8f, -0.8f, 0.0f, {0, 1, 0}); + tri.v[2] = makeNDCVertex( 0.0f, 0.8f, 0.0f, {0, 0, 1}); + + bool normals_valid = true; + + auto checker = [&](const Fragment& f) -> Color { + // Normal should be approximately unit length + float len = f.normal.length(); + if (std::abs(len - 1.0f) > 0.05f) normals_valid = false; + return {1, 1, 1}; + }; + + rast.rasterize(tri, checker); + assert(normals_valid && "fragment normals must be approximately unit length"); + std::cout << " fragment normals normalized: PASS" << std::endl; +} + +// ============================================================================= +// Test: Multiple triangles with batch rasterization +// ============================================================================= +void test_batch_rasterization() { + const int W = 64, H = 64; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto passthrough = [](const Fragment& f) -> Color { return f.color; }; + + // Two non-overlapping triangles + Triangle tris[2]; + // Left triangle (red) + tris[0].v[0] = makeNDCVertex(-0.9f, -0.5f, 0.0f, {0,0,1}, {1,0,0}); + tris[0].v[1] = makeNDCVertex(-0.1f, -0.5f, 0.0f, {0,0,1}, {1,0,0}); + tris[0].v[2] = makeNDCVertex(-0.5f, 0.5f, 0.0f, {0,0,1}, {1,0,0}); + // Right triangle (green) + tris[1].v[0] = makeNDCVertex( 0.1f, -0.5f, 0.0f, {0,0,1}, {0,1,0}); + tris[1].v[1] = makeNDCVertex( 0.9f, -0.5f, 0.0f, {0,0,1}, {0,1,0}); + tris[1].v[2] = makeNDCVertex( 0.5f, 0.5f, 0.0f, {0,0,1}, {0,1,0}); + + rast.rasterizeBatch(tris, 2, passthrough); + + // Left side should be red + Pixel left = getPixel(fb, W / 4, H / 2); + assert(left.r > 0 && left.g == 0 && "left triangle should be red"); + + // Right side should be green + Pixel right = getPixel(fb, 3 * W / 4, H / 2); + assert(right.g > 0 && right.r == 0 && "right triangle should be green"); + + std::cout << " batch rasterization: PASS" << std::endl; +} + +// ============================================================================= +// Test: Pixel-precise triangle — verify exact coverage pattern +// ============================================================================= +void test_pixel_precise_coverage() { + // Use a very small framebuffer and a triangle that covers a known set of pixels + const int W = 8, H = 8; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto white = [](const Fragment& f) -> Color { return {1, 1, 1}; }; + + // Triangle covering roughly the right half of the screen + Triangle tri; + tri.v[0] = makeNDCVertex(0.0f, -1.0f, 0.0f); + tri.v[1] = makeNDCVertex(1.0f, -1.0f, 0.0f); + tri.v[2] = makeNDCVertex(0.5f, 1.0f, 0.0f); + rast.rasterize(tri, white); + + // Left column (x=0,1) should be entirely black + for (int y = 0; y < H; ++y) { + assert(!isLit(fb, 0, y) && "far-left column must be unlit"); + } + + // Right half should have SOME lit pixels + int rightLit = 0; + for (int y = 0; y < H; ++y) + for (int x = W / 2; x < W; ++x) + if (isLit(fb, x, y)) ++rightLit; + + assert(rightLit > 0 && "right half must have lit pixels"); + + std::cout << " pixel-precise coverage: PASS" << std::endl; +} + +// ============================================================================= +// Test: Near-plane clipping — triangle partially behind camera +// ============================================================================= +void test_near_plane_clipping() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + auto white = [](const Fragment& f) -> Color { return {1, 1, 1}; }; + + // One vertex behind near plane (z + w < 0), two in front + Triangle tri; + tri.v[0].clipPos = Vec4(-0.5f, -0.5f, 0.5f, 1.0f); // in front + tri.v[0].worldPos = Vec3(-0.5f, -0.5f, 0.5f); + tri.v[0].normal = Vec3(0, 0, 1); + tri.v[0].color = Vec3(1, 1, 1); + tri.v[0].uv = Vec2(0, 0); + + tri.v[1].clipPos = Vec4(0.5f, -0.5f, 0.5f, 1.0f); // in front + tri.v[1].worldPos = Vec3(0.5f, -0.5f, 0.5f); + tri.v[1].normal = Vec3(0, 0, 1); + tri.v[1].color = Vec3(1, 1, 1); + tri.v[1].uv = Vec2(1, 0); + + tri.v[2].clipPos = Vec4(0.0f, 0.5f, -2.0f, 0.5f); // behind (z + w = -1.5 < 0) + tri.v[2].worldPos = Vec3(0, 0.5f, -2.0f); + tri.v[2].normal = Vec3(0, 0, 1); + tri.v[2].color = Vec3(1, 1, 1); + tri.v[2].uv = Vec2(0.5f, 1); + + rast.rasterize(tri, white); + + // Should produce SOME pixels (the visible part of the triangle) + int lit = countNonBlack(fb); + assert(lit > 0 && "partially-clipped triangle must produce some pixels"); + + // But NOT as many as a fully-visible triangle of similar size + Framebuffer fb2(W, H); + fb2.clear(); + Rasterizer rast2(fb2); + Triangle full; + full.v[0] = makeNDCVertex(-0.5f, -0.5f, 0.0f); + full.v[1] = makeNDCVertex( 0.5f, -0.5f, 0.0f); + full.v[2] = makeNDCVertex( 0.0f, 0.5f, 0.0f); + rast2.rasterize(full, white); + int fullLit = countNonBlack(fb2); + + assert(lit < fullLit && "clipped triangle must cover fewer pixels than unclipped"); + + std::cout << " near-plane clipping: PASS" << std::endl; +} + +// ============================================================================= +// Test: UV interpolation +// ============================================================================= +void test_uv_interpolation() { + const int W = 32, H = 32; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + // Triangle with distinct UVs + Triangle tri; + tri.v[0] = makeNDCVertex(-0.8f, -0.8f, 0.0f, {0,0,1}, {1,1,1}, {0, 0}); + tri.v[1] = makeNDCVertex( 0.8f, -0.8f, 0.0f, {0,0,1}, {1,1,1}, {1, 0}); + tri.v[2] = makeNDCVertex( 0.0f, 0.8f, 0.0f, {0,0,1}, {1,1,1}, {0.5f, 1}); + + bool uvs_valid = true; + + auto checker = [&](const Fragment& f) -> Color { + // UVs should be within [0, 1] for this triangle + if (f.uv.x < -0.05f || f.uv.x > 1.05f) uvs_valid = false; + if (f.uv.y < -0.05f || f.uv.y > 1.05f) uvs_valid = false; + return {f.uv.x, f.uv.y, 0}; + }; + + rast.rasterize(tri, checker); + assert(uvs_valid && "UV coordinates must be interpolated within triangle's UV range"); + std::cout << " UV interpolation: PASS" << std::endl; +} + +// ============================================================================= +// Test: Screen-space pixel coordinates match framebuffer bounds +// ============================================================================= +void test_fragment_screen_coords() { + const int W = 16, H = 16; + Framebuffer fb(W, H); + fb.clear(); + Rasterizer rast(fb); + + bool coords_valid = true; + std::set unique_pixels; + + auto checker = [&](const Fragment& f) -> Color { + if (f.x < 0 || f.x >= W) coords_valid = false; + if (f.y < 0 || f.y >= H) coords_valid = false; + unique_pixels.insert(f.y * W + f.x); + return {1, 1, 1}; + }; + + Triangle tri; + tri.v[0] = makeNDCVertex(-0.8f, -0.8f, 0.0f); + tri.v[1] = makeNDCVertex( 0.8f, -0.8f, 0.0f); + tri.v[2] = makeNDCVertex( 0.0f, 0.8f, 0.0f); + rast.rasterize(tri, checker); + + assert(coords_valid && "all fragment coordinates must be within framebuffer bounds"); + // Each pixel should be written at most once (no duplicates in single triangle) + int lit = countNonBlack(fb); + assert((int)unique_pixels.size() == lit && "each pixel must be written exactly once"); + + std::cout << " fragment screen coordinates: PASS" << std::endl; +} + +// ============================================================================= +// Main +// ============================================================================= + +int main() { + std::cout << "=== Rasterizer Tests ===" << std::endl; + + test_fullscreen_triangle(); + test_centered_triangle(); + test_color_interpolation(); + test_depth_occlusion(); + test_depth_values(); + test_degenerate_triangle(); + test_behind_camera_clipped(); + test_winding_order(); + test_barycentric_validity(); + test_fragment_world_position(); + test_fragment_normals(); + test_batch_rasterization(); + test_pixel_precise_coverage(); + test_near_plane_clipping(); + test_uv_interpolation(); + test_fragment_screen_coords(); + + std::cout << "\n=== ALL RASTERIZER TESTS PASSED ===" << std::endl; + return 0; +}