A better point light attenuation function

So, recently I was adding lights support to the martian-droid-colony-building project I've been working on for a while. I used CPU-based clustered shading, storing the lights & clusters data in buffer textures, and managed to support about 4K lights at 60 FPS on my machine. *(I'm not using compute shaders and SSBO's because I'm stuck with OpenGL 3.3 so that even prehistoric microwaves can run my game; I'll probably add support for those through OpenGL extensions later.)* Here's a twitter thread with some intermediate results, and here are some final screenshots:

For light clustering to work, I need to clip the light's area of influence by a certain distance. The physically correct point light attenuation function (how much light some point in scene receives) is

\[ \frac{A}{d^2} \]where A is the light's amplitude (intensity) and d is the distance from the light to an illuminated point. However, this function introduces a singularity at \( d=0 \), meaning that points too close to the light will receive unbounded amounts of lighting. This is an artefact of the point light abstraction: real lights are not points, they are physical objects of nonzero size. We don't want to throw away this nice abstraction, though, so we add a fake term that removes this singularity:

\[ \frac{A}{1+d^2} \]This has the added benefit that A is now the maximal light intensity (achieved at \( d=0 \)). This function (red) isn't physically correct at small values of d, but is close to the correct one (blue) for large distances:

However, this attenuation formula doesn't let us control the fallof, i.e. how fast does the intensity decrease with distance. This won't be physically correct either, but it allows for a greater artistic flexibility. So, introduce another parameter R:

\[ \frac{A}{1+\left(\frac{d}{R}\right)^2} \]R is precisely the distance where the light intensity is half the maximal intensity. Larger values of R make the light attenuate (decrease in intensity) slower, increasing the effective light's area of influence. Here, the green function has \( R=1 \) and the purple one has \( R=3 \):

Finally, we can add another parameter Q, for which I don't have an intuitive explanation, but it allows us to control the shape of attenuation more precisely:

\[ \frac{A}{1+\frac{d}{Q} + \left(\frac{d}{R}\right)^2} \]Small values of Q introduce a cusp at \( d=0 \), producing a faster fallof and a slightly different shape for the attenuation function (the blue one has \( Q=1 \)):

Use can play with all this functions here.

This type of function is usually presented in APIs/engines in a slightly different, arguably cleaner form (but with less intuitive parameters):

\[ \frac{1}{C_0+C_1d+C_2d^2} \]I.e. light attenuation is the inverse of a quadratic function of distance. See e.g. here or the docs for OpenGL 1.0 lighting model.

There are other popular attenuation functions: some introduce a sharp cutoff near \( d=0 \), some replace the point light with a spherical area light, etc. What I don't like about all these options is that no formula gives a good way of constraining the light's influence to a certain radius: all these formulas are non-zero for arbirtarily large distances!

So, I set off to find a different formula that

- Looks a bit like the physically correct \( \frac{1}{d^2} \)
- Is exactly zero at a certain distance R, so that I can use that distance for light-cluster intersections
- Has zero derivative at distance R (otherwise the lightness will have a C^1 discontinuity, leading to a noticeable gradient edge)

What I came up with looks incredibly simple:

\[ A\frac{(1-s^2)^2}{1+Fs^2} \]where \( s = \frac{d}{R} \) is the normalized distance, and F allows you to control how fast it decays. It allows to separately control the maximum intensity A and the maximum radius R. You can play with this formula here. It is also fast to compute, since it doesn't use square roots, exponents, and other stuff, just a bit of arithmetic. Here's how it looks like for \( A = 2 \), \( R = 5 \), \( F = 1 \) (red) and \( F = 4 \) (blue):

For distances less than \( \frac{R}{2} \) this function looks about the same as \( \frac{A}{1+4\left(\frac{d}{R}\right)^2} \), while for larger values it gradually goes to zero and is exactly zero at distance R. Note that it doesn't have a sharp cusp at d = 0, which can be for an artistic reason of wanting the attenuation to behave roughly like a spherical area light source near the light. For a version with cusp (which I'm actually using), you can simply remove the square in the denominator:

\[ A\frac{(1-s^2)^2}{1+Fs} \] Here's how it looks like for the same values of \( A = 2 \), \( R = 5 \), \( F = 1 \) (red) and \( F = 4 \) (blue):For the sake of completeness, here's a GLSL implementation. Note that we need to check for \( d < R \) (equivalently, for \( s < 1\)), otherwise we'd get wrong lightness values at larger distances.

```
float sqr(float x)
{
return x * x;
}
float attenuate_no_cusp(float distance, float radius,
float max_intensity, float falloff)
{
float s = distance / radius;
if (s >= 1.0)
return 0.0;
float s2 = sqr(s);
return max_intensity * sqr(1 - s2) / (1 + falloff * s2);
}
float attenuate_cusp(float distance, float radius,
float max_intensity, float falloff)
{
float s = distance / radius;
if (s >= 1.0)
return 0.0;
float s2 = sqr(s);
return max_intensity * sqr(1 - s2) / (1 + falloff * s);
}
```

That's it! Hope you'll find it usefull and thanks for reading.