Steve McAuley

Energy-Conserving Wrapped Diffuse

I love physically-based rendering, and one of the most important aspects is making sure all your lighting is energy-conserving. Otherwise, you’re prone to end up with things that look too bright or too dark, and struggle with consistency in the look of your game.

One topic I’ve never seen covered when talking about energy conservation in games is wrapped diffuse lighting. It’s admittedly a bit of a hack, but it’s incredibly useful for lighting things like particles where a simple Lambert term doesn’t come close to representing light scattering through smoke. Particles are also effects that are no strangers to inconsistency – often made by a separate team who have to juggle with special case materials and lighting models, what hope do they have?

Unsurprisingly then, this issue reared its ugly head when I was implementing wrapped diffuse lighting for particles. One of our effects artists pointed out to me that they were looking far too bright compared with the rest of the scene. For example, compare these two spheres:

On the left we have our standard trusty diffuse lighting, whereas on the right we have wrapped diffuse – the wrapping is pretty extreme, in fact, as extreme as you can get, but it illustrates the point nicely. With wrapped diffuse we’ve added so much extra light onto the sphere! Thankfully, I was already sceptical about the energy-conserving nature of the default wrapped diffuse model, so I knew exactly where to start looking at the problem and pulled out a trusty pen and paper to run the maths.

First, let’s remember what performing standard diffuse lighting looks like.

float3 diffuse = LightColour * saturate(dot(N, L)); 

However, this isn’t energy conserving by itself. For it to be so, we need the integral over the hemisphere to obey the following rule:

$\int_{\Omega} \cos(\theta) d\Omega \leq 1$

If we rewrite this as a double integral in polar coordinates, we find out that

$\int_{0}^{2\pi} \int_{0}^{\frac{\pi}{2}} \cos(\theta) \sin(\theta) d\theta d\phi = \pi$

(This is explained in more detail, including why we need that mysterious extra sine term in [1]).

So actually, standard diffuse lighting doesn’t conserve energy – we actually need to divide by π. However, this is just a constant factor so we usually just ignore it and assume that our lights are just π times too bright. Yeah, this is a little confusing, but it’s one of those little annoyances you just have to remember… especially when you do energy-conserving specular and you have to remove factors of π from those equations too.

Now we can turn our eyes to wrapped diffuse lighting. We adjust our cosine term (dot product between the normal and the light) so that the light wraps around the sphere by an adjustable amount. The code is as follows:

// w is between 0 and 1 float3 wrappedDiffuse = LightColour * saturate((dot(N, L) + w) / (1 + w)); 

For this equation, we can’t just integrate this over the hemisphere to calculate our energy conservation factor – now the lighting is wrapped so it extends further around the sphere. So instead of integrating between between 0 and ½π, we need to integrate between 0 and α, where α is such that:

$\cos(\alpha) = -w$

Or in other words, we integrate around the sphere until the lighting becomes zero, then we stop.

So let’s get started! First, we’ll normalise by π right at the very beginning so we don’t have to worry about it at the end:

$\frac{1}{\pi} \int_{0}^{2\pi} \int_{0}^{\alpha} \frac{\cos(\theta) + w}{1+w} \sin(\theta) d\theta d\phi$

We can immediately evaluate the outer integral and pull a constant term out of the integral completely:

$\frac{2}{1+w} \int_{0}^{\alpha} \cos(\theta) \sin(\theta) + w\sin(\theta) d\theta$

Using a useful trigonometric identity, this becomes:

$\frac{2}{1+w} \int_{0}^{\alpha} \frac{1}{2} \sin(2\theta) + w\sin(\theta) d\theta$

$\frac{2}{1+w} \left[ -\frac{1}{4} \cos(2\theta) - w\cos(\theta) \right]_{0}^{\alpha}$

Evaluating this leaves us with:

$\frac{2}{1+w} \left( -\frac{1}{4} \cos(2\alpha) - w\cos(\alpha) + \frac{1}{4} + w \right)$

Another trigonometric identity allows us to get rid of that pesky 2α:

$\frac{2}{1+w} \left( -\frac{1}{4} (2\cos^2(\alpha) - 1) - w\cos(\alpha) + \frac{1}{4} + w \right)$

Now we can use our identity for α to remove all the cosines completely:

$\frac{2}{1+w} \left( \frac{1}{2} w^2 + w + \frac{1}{2} \right)$

This collapses down to the amazingly simple:

$1+w$

That’s a really, really nice result, and means we simply have to divide by the above to achieve energy conservation. Thus when writing code for wrapped diffuse lighting, it should really be:

// w is between 0 and 1 float3 wrappedDiffuse = LightColour * saturate((dot(N, L) + w) / ((1 + w) * (1 + w)));

If we think about it for a bit, this actually makes sense on a purely intuitive basis. If we wrap our lighting all the way around the sphere, so w = 1, then we get a normalisation factor of

$\frac{1}{1+w} = \frac{1}{2}$.

So our lighting is covering twice the surface area (the full sphere instead of just the hemisphere), and thus it is half as bright.

Let’s look at some pictures to see what it looks like with a couple of different values for w. Standard wrapped diffuse lighting is on the left, whilst our new energy-conserving model is on the right:

For w = 1.0:

For w = 0.5:

Much better!

After all that, we can use wrapped diffuse on our particle effects, or anything else we choose, fully confident that they’ll blend in properly with the rest of the scene, with the added benefit that the cost of this extra energy conservation term is minimal (or free, if you don’t need w to be variable).

Hopefully now I’ll never see the old wrapped diffuse model used again!

References

6 Responses

Hello,
Good point!

Do you think the normalized factor should be similar for another common formula of wrap lighting : saturate(dotNL * DiffuseWrap + (1 – DiffuseWrap)) ?
Taking DiffuseWrap in the range [0.5..1].

• Yes, it’s exactly the same – in your equation we just have DiffuseWrap = 1 / (1 + w), so you simply have to multiply through by DiffuseWrap to normalize:

saturate(dotNL * DiffuseWrap * DiffuseWrap + (1 – DiffuseWrap) * DiffuseWrap).

Easy!

• Nice! Thank you.