"Good Enough" Volumetrics for Spotlights

Originally posted on 06/01/2012

Volumetric effects are one of the perennially tricky problems in realtime graphics. They effectively simulate the scattering of light through particles suspended in the air. Since these effects can enhance both the realism and aesthetic appearance of a rendered scene, it would be nice to have a method which can produce "good enough" results cheaply and simply. As the title implies, "good enough" is the main criteria here; we're not looking for absolute photorealism, just something that's passable which adds to the aesthetic or the mood of a scene without costing the Earth to render.

I'll be describing a volumetric effect for spot lights, although the same ideas will apply to other types lights with different volume geometries.


The volume affected by a spotlight is a cone, so that's what we'll use as the basis for the technique.
How you generate the cone is up to you, but it must have per-vertex normals (they'll make life easier later on), no duplicated vertices except at the cone's tip and no base. I've found that having plenty of height segments is good for the normal interpolation and well worth the extra triangles.

The basic idea is to render this cone in an additive blending pass with no face culling (we want to see the inside and outside of the cone together), with depth writes disabled but the depth test enabled. As the screenshot below shows, on its own this looks pretty terrible:


To begin to improve things we need to at least attenuate the effect along the length of the cone. This can be done per-fragment as a simple function of the distance from the cone's tip (d) and some maximum distance (dmax):

Already things are looking a lot better:

Soft Edges

The edges of the cone need to be softened somehow, and that's where the vertex normals come in. We can use the dot product of the view space normal (cnorm) with the view vector (the normalised fragment position, cpos) as a metric describing how how near to the edge of the cone the current fragment is.

Normalising the fragment position gives us a vector from the eye to the point on the cone (cpos) with which we're dealing. We take the absolute value of the result because the back faces of the cone will be pointing away but still need to contribute to the final result in the same was as the front faces. For added control over the edge attenuation it's useful to be able to raise the result to the power n.

Using per-vertex normals like this is simple, but requires that the cone geometry be set up such that there won't be any 'seams' in the normal data, hence my previous note about not having any duplicate vertices except at the cone's tip.

One issue with this method is that when inside the cone looking up towards the tip the normals will tend to be perpendicular to the view direction, resulting in a blank spot. This can be remedied by applying a separate glow sprite at the light source position.

Soft Intersections

As you can see in the previous screenshot there is a problem where the cone geometry intersects with other geometry in the scene, including the floor. Remedying this requires access to the depth buffer from within the shader. As the cone's fragments get closer to fragments already in the buffer (i.e. as the difference between the depth buffer value and the cone fragment's depth approaches 0) we want the result to 'fade out':

The result should be clamped in [0, 1]. The radius can be set to make the edges softer or harder, depending on the desired effect and the scale of the intersecting geometry compared with the cone's size.
This does produce a slightly unusual fogging effect around the cone's boundary, but to my eye it meets the "good enough" criteria.

Another issue is that the cone geometry can intersect with the camera's near clipping plane. This results in the effect 'popping' as the camera moves across the cone boundary. We can solve this in exactly the same way as for geometry intersections; as the cone fragment's depth approaches the near plane we fade out the result.

That's it!

1 comment: