This time I am adding transparent materials to my ray tracer and use it to simulate a glass ball in the Cornell box scene.
Transparent Material
So far I only had once kind of material; diffuse. To model glass I need a new kind of material that describes how light rays can pass through the object.
First of all let’s introduce an abstraction for a medium.
struct Medium
{
float ior; ///< Index of refraction
};
For now it only describes the index of refraction, which I will use to figure out how light rays refract when crossing boundaries, but later I can use it to add support for volumetric rendering such as fog.
Then using the medium class I can introduce a transparent material.
struct BaseMaterial
{
Vec4 debug_color;
};
struct TransparentMaterial : BaseMaterial
{
Medium inside_medium;
};
struct DiffuseMaterial : BaseMaterial
{
Vec4 emission;
Vec4 diffuse_reflectance;
};
using Material = std::variant<TransparentMaterial, DiffuseMaterial>;
Notice that I extracted the debug color to a base class.
Refraction, Snell
To figure out how refraction affects the light ray I can use Snell’s law.
$n_1 sin(\theta_1) = n_2 sin(\theta_2)$
Where $n_1$ is the index of refraction in the current medium and $n_2$ in the medium being entered. $\theta_1$ is the angle of incidence and $\theta_2$ is the angle of the transmitted ray with respect to the inwards surface normal.
Taking into account that the incidence, transmitted and normal vectors are all on the same plane, this does uniquely define the transmitted ray direction, but does not provide a way to calculate said vector.
To calculate the transmitted light direction I use the vector form of Snell’s law, see derivation on StackExchange.
A crucial difference is that normals always point against the incoming light ray in my code. It seems in some formulas the opposite is assumed. The formula in my case there is
$k = 1 - \mu^2 (1 - d^2)$
$\bold{L_t} = -(\sqrt{k} + \mu d)\bold{n} + \mu\bold{L_i}$
$d = \bold{n}\cdot\bold{L_i}$
With $d$ in my case being a non-positive number. Putting it into code, I can simply check for the underlying type in the material variant:
if (const auto *diffuse_material = std::get_if<DiffuseMaterial>(&material)) {
// Do diffuse bounce as before
}
if (const auto *transparent_material = std::get_if<TransparentMaterial>(&material)) {
float n1;
float n2;
// Find n1 and n2
const auto mu = n1/n2;
const auto d = dot(ray.v, normal);
const auto k = 1 - mu*mu * (1 - d*d);
normal = -normal;
new_v = normal * (std::sqrt(k) + dotp * mu) + ray.v * mu;
}
I omitted the part for finding $n_1$ and $n_2$ as it is a bit ugly. I have to maintain an object stack to check if the ray is leaving the last object or entering a new one. This could be done by orienting the normals to always point outwards of objects, however when leaving an object I would still need to find the previous object’s material, so the stack would remain, while other code would get more complicated.
Rendering this out already produces some interesting effects:

Total internal reflection
When light enters an object with a lower index of refraction at grazing angle it is reflected instead of being refracted. This is called total internal reflection.
Physically this is characterized by the angle of incidence being larger than a critical angle. Luckily in the vector formulation it’s super easy to tell when total internal reflection (TIR) happens. Simply when $k < 0$ it’s the TIR case. To handle it I added a bit of extra logic:
if (k < 0) {
new_v = reflect(ray.v, normal);
} else {
// refraction code
}
Here reflect is a pretty simple function, like in GLSL.
To my surprise this does not change the output in a visible way, probably not a lot of light is exiting the sphere at a grazing angle.
Fresnel coefficients
Light doesn’t simply refract or reflect, it can do both! My simulation is missing a reflective part, that’s why there are no highlights on the glass sphere.
In physics this is described by the Fresnel equations. These are often approximated using Schlick’s approximation when the material is non-conducting, so I am employing it as well. I might revisit this later if I want to add polarization to the simulation.
Since I am only tracing a single path in each invocation, I choose whether to reflect or refract the light ray based on a biased coin flip with probability $R(\theta)$. This is clean, simple and unbiased, the code is then:
if (k < 0) {
// TIR
} else {
const float R_phi = schlick(d, n1, n2);
if (rng.random() < R_phi) {
new_v = reflect(ray.v, normal);
} else {
// refraction code
}
}
I made a little JS tool to play around with the setup, it was a lot of fun. The length of the arrows indicate the amount of light going that way;
Rendering this out produces a lot more convincing results:

As a bonus I added 100 glass balls to spice up the scene:

These renders look pretty, nice. Zooming in however reveals strong aliasing artifacts so next I will probably work on antialiasing.
