Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 40

Schlick's Approximation for Fresnel Reflectance

Recommended resources

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.

Fresnel Reflectance for Conductive and Dielectric Materials
These graphs show Fresnel Reflectance for a conductor (like metal) and a dielectric (like glass). Focus on the **dielectric** graph on the right. Notice how the reflectance is very low when looking straight on (0° angle from normal) but climbs to 100% at a grazing angle (90°).

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:

  1. Check for Total Internal Reflection first. If it occurs, we must reflect. The reflection probability is 100%.
  2. If TIR does not occur, then we calculate the reflection probability using our schlick function.
  3. 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.

Ray Tracing in One Weekend

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_prob is set by our schlick function. If it's not possible (TIR), reflect_prob is set to 1.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 ().

  1. Which if/else block will be executed to set outward_normal, ni_over_nt, and cosine?
  2. 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 ).
  3. Is it more likely that the final scattered ray will be a reflected ray or a refracted ray?
Show answer
  1. The ray is outside, so dot(r_in.direction(), rec.normal) will be negative. The else block will execute, setting outward_normal = rec.normal and ni_over_nt = 1.0 / 1.5. cosine will be close to 1.
  2. The schlick function will calculate . Since cosine is near 1, the pow((1.0 - cosine), 5) term will be near zero. The function will return a value very close to 0.04, which is a low reflection probability.
  3. Since the reflection probability is low (~4%), it is far more likely that random_double() will be greater than reflect_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 scatter method, 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.