I think a good way to get started with ray tracing is to begin with refraction. This is a basic operation that happens in almost every ray tracer. Refraction happens when a light ray hits another medium as shown in the following image:

Refraction

Of course, this is just a simplified model of how light travels through space and how light is modeled, but this is fine enough to render some pleasant pictures.

According to Snell’s law, the following equation holds:

$$ \eta = \frac{sin(\alpha)}{sin(\beta)} = \frac{n_2}{n_1} $$

The index of refraction in vacuum is $1.0$ and for some glass material it might be for instance $1.6$. Assuming that $n_1 = 1.0$ and $n_2 = 1.6$ we can compute for an given incident ray the direction of the refracted ray. E.g. if $\alpha = 45°$ (angle of incidence) the corresponding $\beta$ angle (angle of refraction) will be approximately $26.23°$. The refractive index is defined as $n = \frac{c}{v}$ where $c$ is the speed of light in vacuum and $v$ is the speed of light in the corresponding medium.

Total internal reflection describes the behavior that at some angles no light is refracted (starting at the critical angle) but instead is only reflected. Total internal reflection is the reason for Fata Morganas, e.g. a wet-looking road on a hot day.

Critical Angle

The critcal anlge at which total internal reflection happens can be computed using this formula:

$$ \theta_c = sin^{-1}(\frac{n_1}{n_2}) $$

Total internal reflection only happens if $n_1 > n_2$ holds, which means it does not happen if we go from an optical “sparser” medium to an optical “denser” medium.

Examples

The following table list some example for a 2D scenario where we have an incident ray form “above” and hitting the interface between two different medium types.

Case Normalized normal vector $\alpha$ Expected Incident vector Refraction index medium 1 Refraction index medium 2 Expected $\beta$ Expected normalized refracted vector
A (0, 1) $45°$ (-1, 1) 1 1.6 26.23° (0.441975594,-0.897027075)
B (0, 1) $0°$ (0,-1) 1 1.6 (0,-1)
C (0, 1) $45°$ (-1, 1) 1.6 1 Not available (total internal reflection) Not available (total internal refelction)

A test-driven development approach to implement ray refraction

According to test-driven development, we write first a test before starting with the implementation.

Thomas Willberger Founder, CEO and realtime rendering developer at Enscape3D once wrote in a Twitter tweet: “Software wisdom: Start with the best-debuggable, most simple implementation. Iron out early mistakes and add tests BEFORE adding bells and whistles. It‘s invaluable to know that the core part works and you do not have to suspect the whole codebase when hunting a bug.”

Lets write a test for a refract function. First we have to think about a proper design. The refract function expects three input parameters: $\omega_i$, $n$ (a normal vector) and $\eta$. $\omega_i$ is the vector that points in the opposite direction of the incident vector. This is a common convention: $\omega_i$ points always towards the light source direction. So to say, $\omega_i$ is the negative direction of the incoming light. $n$ is the normal vector that points towards the direction from which the incoming ray is originating. $\eta$ is computed as the fraction of $n_2$ and $n_1$ where $n_1$ is refraction index of the medium form which the incoming ray is coming from. As a output parameter the refraction function is expected to compute the refraction vector, if there is no total internal reflection.

When looling at the source code of PBRT the refraction function as four parameters.

inline bool Refract(const Vector3f &wi, const Normal3f &n, Float eta,
                    Vector3f *wt) 

PBRT uses the fourth parameter as an output parameter for the computed refracted vector. PBRT has the convention that for output parameters always pointers are used. Furthermore, the function returns true if there was no total internal reflection.

I decided to use a similar form for my refraction function. Instead of using a pointer for the computed refraction vector, I decided to use a reference value.

This is the test I came up with after several iterations:

TEST(RefractionVacuumToGlass, When_IncidentVectorIs45Degrees_Then_RefratedVectorIsAbout26Degrees) {
    // Arrange
    const Normal2f normal(0.0f, 1.0f);
    Vector2f incident(1.0f, -1.0f);
    Vector2f wi = -incident;
    wi.normalize();

    const auto refractionIndexVacuum = 1.0f;
    const auto refractionIndexGlass = 1.6f;

    // Act
    Vector2f refractedDirection;
    bool validRefraction = refract(wi, normal, refractionIndexVacuum / refractionIndexGlass, refractedDirection);

    // Assert
    EXPECT_TRUE(validRefraction);
    EXPECT_TRUE(refractedDirection.x() > 0.0f);
    EXPECT_TRUE(refractedDirection.y() < 0.0f);

    EXPECT_THAT(refractedDirection.norm(), ::testing::FloatEq(1.0f));

    float theta = degreeToRadian(26.23);
    Eigen::Rotation2Df rotationTransform(theta);
    Vector2f rotatedVector = rotationTransform.toRotationMatrix() * Vector2f(0.0, -1.0);

    EXPECT_THAT(refractedDirection.x(), ::testing::FloatNear(rotatedVector.x(), 0.0001f));
    EXPECT_THAT(refractedDirection.y(), ::testing::FloatNear(rotatedVector.y(), 0.0001f));
}

The test makes use of the Arrange-Act-Assert pattern.

To keep it simple the unit test considers only the 2D case, but the test can be extended easily to 3D. Actually, my refract function is a template function that can handle 2D and 3D vectors at the same time. In test-driven development it is always a good idea to start first with the most simple test and then switch later on to more complex scenarios. This will probably safe you some headaches when it comes to debugging.

The fun part now is to implement this unit test. Now its your turn.