Increasing the maximum path length results in more and more sparkles showing up on my renders. Today I explore why that is and how to fix it.

Sampling Distribution

During path tracing the light ray’s path is incrementally built by randomly choosing a new direction and shooting a ray from the last vertex of the path. Wherever that ray intersects the scene geometry is where the next vertex will be.

The key word here is randomly. Recall that the Monte Carlo integration used here can be understood as a simple weighted average of samples:

$ \begin{aligned} I = \sum_i{\frac{g(x_i)}{p(x_i)}} \end{aligned} $

Where $g$ is describes how much light is transmitted in the chosen direction and $p$ is the probabilty density function of the sampling function. The choice of $p$ greatly influences the quality of the estimation.

Looking at a single sample:

$ \begin{aligned} I(\omega_i) = \frac{f(x,\omega_o,\omega_i)L_i(x,\omega_i)cos(\theta_i)}{p(\omega_i)} \end{aligned} $

This equation describes the amount of light forwarded towards $\omega_o$ at $x$ from direction $\omega_i$. Since I am working with diffuse materials only for now $f(x,\omega_o,\omega_i) = \frac{\rho}{\pi}$ where $\rho$ is the albedo of the material. Thus:

$ \begin{aligned} I(\omega_i) = \rho L_i(x,\omega_i)\frac{cos(\theta_i)}{\pi p(\omega_i)} \end{aligned} $

Uniform Random Hemisphere Sampling

As an easy first approach I used uniform hemisphere sampling for $p$. However, when I let the number of vertices go up to, say, 100, I noticed sprakling noise showing up on my renders.

I figured printing the path vertifces might help.

Extreme limunance path:
    Vertex 1: 0.385328,-0.500000,1.541842, transmission_luminance: 1.000000
    Vertex 2: 0.500000,-0.344371,1.559557, transmission_luminance: 1.443030
    Vertex 3: -0.038083,0.455076,2.500000, transmission_luminance: 0.493101
    Vertex 4: -0.080915,0.500000,2.245682, transmission_luminance: 0.862270
    Vertex 5: -0.081183,0.499000,2.245070, transmission_luminance: 1.286353
    Vertex 6: -0.081017,0.500000,2.245138, transmission_luminance: 2.531174
    Vertex 7: -0.080851,0.499000,2.246114, transmission_luminance: 3.220562
    Vertex 8: -0.079753,0.500000,2.244053, transmission_luminance: 2.513673
    Vertex 9: -0.079602,0.499000,2.243173, transmission_luminance: 3.360210
    Vertex 10: -0.079154,0.500000,2.241265, transmission_luminance: 3.029609
    Vertex 11: -0.080174,0.499000,2.240675, transmission_luminance: 3.507682
    Vertex 12: -0.078747,0.500000,2.239985, transmission_luminance: 3.716078
    Vertex 13: -0.079005,0.499000,2.239519, transmission_luminance: 5.888573
    Vertex 14: -0.080349,0.500000,2.239135, transmission_luminance: 6.807854
    Vertex 15: -0.079568,0.499000,2.238842, transmission_luminance: 9.369415
    Vertex 16: -0.079457,0.500000,2.238765, transmission_luminance: 18.565853
    Vertex 17: -0.079790,0.499000,2.238041, transmission_luminance: 26.037645
    Vertex 18: -0.081577,0.500000,2.237849, transmission_luminance: 25.126522
    Vertex 19: -0.080918,0.499000,2.235405, transmission_luminance: 16.476456
    Vertex 20: -0.081494,0.500000,2.236084, transmission_luminance: 24.503897
    Vertex 21: -0.081538,0.499000,2.237195, transmission_luminance: 29.325005
    Vertex 22: -0.082457,0.500000,2.237414, transmission_luminance: 42.426514
    Vertex 23: -0.083173,0.499000,2.237584, transmission_luminance: 61.283707
    Vertex 24: -0.082742,0.500000,2.237409, transmission_luminance: 110.944992
    Vertex 25: -0.079831,0.499000,2.236013, transmission_luminance: 58.542580
    Vertex 26: -0.080743,0.500000,2.236105, transmission_luminance: 85.939781
    Vertex 27: -0.079277,0.499000,2.236310, transmission_luminance: 85.985481
    Vertex 28: -0.078363,0.500000,2.235018, transmission_luminance: 91.193588
    Vertex 29: -0.078848,0.499000,2.235656, transmission_luminance: 127.597664
    Vertex 30: -0.078522,0.500000,2.235472, transmission_luminance: 238.728302
    Vertex 31: -0.078650,0.499000,2.236590, transmission_luminance: 283.791809
    Vertex 32: -0.078891,0.500000,2.234125, transmission_luminance: 210.638351
    Vertex 33: -0.079600,0.499000,2.234257, transmission_luminance: 306.510376
    Vertex 34: -0.080535,0.500000,2.234077, transmission_luminance: 441.792297
    Vertex 35: -0.080191,0.499000,2.233636, transmission_luminance: 692.337463
    Vertex 36: -0.081049,0.500000,2.232764, transmission_luminance: 870.771912
    Vertex 37: -0.081270,0.499000,2.233573, transmission_luminance: 1195.618652
    Vertex 38: -0.081085,0.500000,2.233489, transmission_luminance: 2342.368408

It is easy to see that the luminance blows up exponentially, why is that? The clue is in the coordiantes, the path keeps bouncing between the ceiling lamp and the ceiling itself, while keeping its X and Z coordinates relatively unchanged.

Ray getting stuck

The ray marked with red eventually explodes. Expanding the sampling distribution offers an explanation:

$p(\omega_i) = \frac{1}{2\pi}$

Substituting this into the sample intensity equation:

$ \begin{aligned} I(\omega_i) = \rho L_i(x,\omega_i)*2cos(\theta_i) \end{aligned} $

When the input angle $\theta_i$ is small and the albedo ($\rho$) is larger than $0.5$ this can result in exponential growth:

$ \begin{aligned} I(\omega_i) = L_i(x,\omega_i)*X \end{aligned} $

Where $X>1$, potentially close to $2$.

This happens rarely, but when it does, the resulting sample has such high intensity that it leaves a bright sparkle behind on the rendered image. The algorithm still converges to the true value, but it needs exponentially many samples to correct one such rare mistake.

Cosine Weighted Hemisphere Sampling

What distribution should be used for the sampling then? Generally it should be as close to $g$ as possible. And in this case this easy to achieve. Solving

$ \begin{aligned} \frac{cos(\theta_i)}{\pi p(\omega_i)} = 1 \end{aligned} $

Gives us

$ \begin{aligned} p(\omega_i) = \frac{cos(\theta_i)}{\pi} \end{aligned} $

Which is luckily a probability density function already, namely it describes a cosine weighted hemisphere sampling. For futher reading on this I highly recommend Chapter 9 of Ray Tracing In One Weekend, which suggests an easy and robust approach for drawing samples from a cosine weighted hemisphere distribution around a given normal vector in code:

Vec3 cosineWeightedHemisphereSample(const Vec3 &normal, Rng &r)
{
    const auto v = uniformSphereSample(r);

    const auto direction = v + normal;

    return direction.normalized();
}

I find this method really elegant, here is a visualization of what is going on:

Cosine Weighted Hemisphere Sampling Trick

A uniform random direction ($r$) is chosen and added to the normal vector ($n$), the result ($s$) is then normalized.

With this approach we have:

$ \begin{aligned} I(\omega_i) = \rho L_i(x,\omega_i) \end{aligned} $

Using this function to for generating new directions gets rid of the annoying sparkles and even reduces noise:

Closing thoughts

I have pondered long about why these sparkles show up, looked through my code and logic many times to no avail. Sometimes things just work as intended, and it’s our expectation that is misplaced.

It is a really nice improvement both in that it improved the rendering out and also that I now udnerstand just how important it is to use the right sampler.

Next up I am planning to look at Next Event Estimation as an entry to Multiple Importance Sampling.