Hello! In our last lesson, we tackled a key piece of physics for transparent materials: Total Internal Reflection (TIR). We taught our dielectric material to reflect a ray when refraction becomes physically impossible. This was a huge step, but our glass objects are still not quite right. They either refract or reflect, but never both at the same time.
Think about looking at a window. You can see through it, but you can also see a faint reflection of yourself. If you look at the window from a very shallow, grazing angle, it becomes almost a perfect mirror. This phenomenon is governed by the Fresnel equations.
Today, we'll implement a computationally efficient way to simulate this effect. Our learning outcome is to implement Schlick's approximation for calculating Fresnel reflectance. This will allow us to determine the probability of reflection versus refraction based on the viewing angle, bringing us much closer to photorealistic glass, water, and other dielectrics.
1. The Fresnel Effect: Why Reflectivity Varies with Angle
The core idea is that when light hits the boundary between two media (like air and glass), the percentage of light that reflects versus refracts is not constant. It depends heavily on the angle of incidence.

This curve for dielectrics perfectly matches our real-world experience. This angle-dependent reflectivity is what we need to simulate. While the full Fresnel equations are complex, a fantastic simplification exists that is widely used in computer graphics.
2. Schlick's Approximation: A Practical Model
Christophe Schlick developed a simple polynomial approximation that models the Fresnel effect for dielectrics with surprising accuracy. It's efficient to compute, making it ideal for a ray tracer.
To get a solid intuition for how this works, let's watch a short video that breaks down the formula.
TU Wien Rendering #5 - The Fresnel Equation and Schlick's Approximation
This video from a TU Wien rendering course provides an excellent, easy-to-follow explanation of Schlick's approximation. It explains each term in the formula and walks through examples at different angles, building a strong conceptual foundation.
Please watch from 01:43 to 10:31. Focus on understanding: The purpose of the equation: to find the probability of reflection. The meaning of each term: R(θ), R₀, and cos(θ). How the reflection probability changes as the angle goes from 0° (head-on) to 90° (grazing).
As the video explained, Schlick's approximation is given by:
Let's break this down:
- is the reflectance (the probability of reflection) for a given angle of incidence .
- is the angle between the incoming ray's direction and the surface normal. So, is simply the dot product of the unit vectors for the ray direction and the normal.
- is the reflectance at normal incidence (), when the ray hits the surface head-on. This is a base property calculated from the refractive indices of the two media ( and ):
This formula elegantly captures the behavior we saw in the graph: when (a 0° angle), . When (a 90° angle), .
3. Implementing the schlick Function in C++
Now, let's turn this into a reusable C++ helper function. It will take the cosine of the angle and the ratio of refractive indices as input and return the reflection probability.
float schlick(float cosine, float ref_idx_ratio) {
// Calculate R0 using the formula for normal incidence
float r0 = (1.0f - ref_idx_ratio) / (1.0f + ref_idx_ratio);
r0 = r0 * r0;
// Calculate reflectance using Schlick's approximation
return r0 + (1.0f - r0) * pow((1.0f - cosine), 5);
}
This function is a direct translation of the math. Note that we pass in the ratio of refractive indices () rather than the two indices separately. This is convenient because we already compute this ratio in our scatter method.
4. Updating the Dielectric scatter Method
Now for the main event: integrating this into our dielectric material. Our goal is to combine the logic for Total Internal Reflection (from the last lesson) with our new Fresnel probability.
The new logic will be:
- Check for Total Internal Reflection first. If it occurs, we must reflect. The reflection probability is 100%.
- If TIR does not occur, then we calculate the reflection probability using our
schlickfunction. - We then "roll the dice": we generate a random number. If it's less than our calculated reflection probability, we reflect. Otherwise, we refract.
This approach ensures physical correctness (TIR is absolute) while stochastically modeling the partial reflection for all other cases.
The book 'Ray Tracing in One Weekend' provides the canonical implementation for this. Let's study how it integrates the Schlick approximation into the dielectric material.
In Chapter 9, 'Dielectrics', find the subsection that begins 'Now real glass has reflectivity that varies with angle...'. Read from there to the end of the dielectric code block. Pay close attention to how reflect_prob is calculated and then used with a random number to decide between reflection and refraction.
Following that example, here is the updated scatter method.
virtual bool scatter(const ray& r_in, const hit_record& rec, vec3& attenuation, ray& scattered) const {
attenuation = vec3(1.0, 1.0, 1.0); // Glass is not colored
vec3 outward_normal;
float ni_over_nt;
float reflect_prob;
float cosine;
// Determine if ray is entering or exiting
if (dot(r_in.direction(), rec.normal) > 0) {
// Ray is inside the material, hitting inner surface
outward_normal = -rec.normal;
ni_over_nt = ref_idx;
cosine = ref_idx * dot(r_in.direction(), rec.normal) / r_in.direction().length();
} else {
// Ray is outside the material, hitting outer surface
outward_normal = rec.normal;
ni_over_nt = 1.0 / ref_idx;
cosine = -dot(r_in.direction(), rec.normal) / r_in.direction().length();
}
vec3 refracted;
// Try to refract, and get the reflection probability
if (refract(r_in.direction(), outward_normal, ni_over_nt, refracted)) {
// Refraction is possible, calculate Fresnel reflection probability
reflect_prob = schlick(cosine, ref_idx); // Note: ref_idx, not ni_over_nt
} else {
// Total Internal Reflection occurred, must reflect
reflect_prob = 1.0;
}
// "Roll the dice" to choose reflection or refraction
if (random_double() < reflect_prob) {
vec3 reflected = reflect(r_in.direction(), rec.normal);
scattered = ray(rec.p, reflected);
} else {
scattered = ray(rec.p, refracted);
}
return true;
}
Key points in this implementation:
- Calculating
cosine: The cosine term for the Schlick formula needs to be handled carefully depending on whether the ray is entering or leaving the medium. The implementation above handles this correctly. - TIR check: The
if (refract(...))block first checks if refraction is even possible. reflect_prob: If refraction is possible,reflect_probis set by ourschlickfunction. If it's not possible (TIR),reflect_probis set to1.0, guaranteeing reflection.- The random choice: The final
if (random_double() < reflect_prob)is where the stochastic decision is made, creating the final scattered ray.
Test your understanding!
A ray hits a glass sphere (\text{ref_idx} = 1.5) from the outside, nearly head-on ().
- Which
if/elseblock will be executed to setoutward_normal,ni_over_nt, andcosine? - Assuming TIR does not occur, what will be the approximate value returned by
schlick(cosine, ref_idx)? Will it be high or low? (Hint: Think about ). - Is it more likely that the final scattered ray will be a reflected ray or a refracted ray?
Show answer
- The ray is outside, so
dot(r_in.direction(), rec.normal)will be negative. Theelseblock will execute, settingoutward_normal = rec.normalandni_over_nt = 1.0 / 1.5.cosinewill be close to 1. - The
schlickfunction will calculate . Sincecosineis near 1, thepow((1.0 - cosine), 5)term will be near zero. The function will return a value very close to0.04, which is a low reflection probability. - Since the reflection probability is low (~4%), it is far more likely that
random_double()will be greater thanreflect_prob, and thus the ray will be refracted. This matches our intuition of looking straight through a piece of glass.
Conclusion
Excellent work! You've now implemented a crucial component for rendering realistic transparent materials. By combining the absolute rule of Total Internal Reflection with the angle-dependent probability from Schlick's approximation, your ray tracer can now produce much more nuanced and physically plausible images of glass and water.
Key Takeaways:
- The Fresnel effect describes how a surface's reflectivity changes with the viewing angle. For dielectrics, reflectivity is low when viewed head-on and approaches 100% at grazing angles.
- Schlick's approximation, , is a computationally cheap and effective formula to model this effect.
- The base reflectance, , is determined by the refractive indices of the materials at the interface.
- In our
scattermethod, we now use a random number to choose between reflection and refraction based on the probability calculated by the Schlick formula, but only when Total Internal Reflection does not occur.
Next Steps
So far, we've only worked with a single, simple geometric primitive: the sphere. To render the complex models you're used to in VFX (characters, environments, props), we need to handle geometry made of triangles. In our next lesson, we will dive into this by learning to implement the Möller-Trumbore algorithm for efficient ray-triangle intersection. This will open the door to rendering virtually any 3D model.