Deferred Rendering, Transparency & Alpha Blending
For all the advantages of deferred rendering, perhaps its biggest setback is the difficulty of handling transparent and alpha blended materials. Here we'll look at how to go about incorporating transparency and alpha blending into a deferred rendering pipeline using multiple passes and the stencil buffer.
Deferred Rendering Overview
Deferred rendering (or deferred shading) is a technique whereby the surface data for visible pixels are stored in several intermediate buffers (collectively called the g-buffer) and are subsequently used in a separate, 'deferred' shading pass to compute the final result. This differs from 'forward' rendering in which shading is performed at the same time that geometry is rendered to the framebuffer. Shawn Hargreaves has an excellent overview here
A deferred rendering pipeline typically looks like this:
Geometry and surface properties are rendered into the render targets of the g-buffer, then lighting passes accumulate results at each pixel into a results buffer, which I call the la-buffer (light accumulation buffer). More often than not some sort of additional post-processing (e.g. motion blur, depth of field, bloom) is then applied, but for now we're only concerned with the core stages.
An important concept to note is that the pixels in the la-buffer are 'complete'; they are the result of the lighting/material function. We can think of 'complete' pixels as representing the value/intensity of the light reaching our eye, which might help us visualise exactly what the problem is.
What The Problem Is
Transparency is a generic problem in rasterisation, since the local models used to approximate lighting can't account for the transmission of light. If light can pass through a material, we need to know the value/intensity of the light as it enters the material and how it is affected by the material as it passes through in order to compute the final, 'complete' pixel which is the result in the la-buffer. Take the example of a stained glass window:
The typical forward rendering approach would be to first draw the opaque teapot, then the (semi) transparent window using blending to combine the incoming transparent pixels with the destination opaque pixels. This works because immediately after the opaque stage the pixels in the framebuffer are 'complete'; we can think of them as being an input to the transparent stage where they represent light which has bounced off the teapot and is entering the window material to be modified by it.
Transparency with a deferred renderer is problematic because only data for the foremost pixels in the scene are stored during the g-buffer stage. 'Complete' pixels aren't available until after the deferred lighting stage is done, so the required information about the light entering the transparent material isn't available.
There are a number of methods already out there which approach this problem:
- Generate a 'deep' g-buffer which contains data for a number of layers in the scene (see Emil Persson's implementation). The drawback of this method is the memory cost of the inflated g-buffer, which is high already and could become astronomical as the number of allowable layers per-pixel increases.
- Interlace transparent and opaque data, then de-interlace the result in a later composition step (as per David Pangerl's article in ShaderX7). This is cheap but can suffer from artefacts and only supports a small number of transparent layers per-pixel.
- Use a traditional forward rendering pipeline to render transparent materials in a separate pass (used in Tabula Rasa, as described in GPU Gems 3). Of course, this has the disadvantage of requiring that a separate pipeline be written and maintained and can lead to discrepancies between the results achieved by the separate render paths.
The technique we'll look at here is similar to the forward rendering approach, except that instead of using a separate renderer we'll update the deferred pipeline to be re-entrant, thereby allowing us to sneak transparent and alpha-blended materials through it.
The first step is to set up the pipeline such that we can do repeated passes of the g-buffer and light accumulation stages. To this end, we must use a stencil buffer to mask the light accumulation stage and prevent re-lighting pixels:
For each layer there are three stages:
- Clear the stencil buffer then generate the g-buffer as usual, additionally setting bits in the stencil buffer. The result is that the stencil buffer contains a record of which pixels were written to the g-buffer in the current pass.
- Clear the la-buffer using the stencil buffer as a mask, setting all pixels in the la-buffer which correspond to pixels written in the current g-buffer stage to black, in preparation for light accumulation.
- Perform light accumulation normally, additionally using the stencil buffer as a mask to ensure that only pixels being updated in this pass are affected.
This method could also be used to render a scene in layers, for dynamic skyboxes, etc.
This multi-pass technique has the benefit of making the 'complete' pixels from the previous pass available during the g-buffer stage of the current pass, via the la-buffer. Hence we can render transparent materials in their own pass, after the opaque materials have been rendered and shaded.
To incorporate transparency, there are four stages in the pipeline:
- Clear the stencil buffer as before, then generate the g-buffer, writing stencil bits. Material shaders may access the la-buffer to get 'complete' pixels from the previous, opaque stage. These pixels may be modified by the material shader (i.e. multiplied with a colour) then written to an additional g-buffer target, which we'll call the 'background' target. Note that the g-buffer colour target is written as black; we'll come back to why this is a bit later.
- Clear the la-buffer to black, using the stencil buffer as a mask.
- Perform light-accumulation as before, again using the stencil buffer as a mask. The results output here represent light contributions at the transparent surface (e.g. specular reflection on a glass window).
- Using the stencil buffer as a mask, additively blend the background g-buffer target into the la-buffer.
We can think of the 'background' pixels as being the value/intensity of the light transmitted through the transparent material to reach our eyes. Hence the background buffer can be combined linearly with the lighting results, just as for a light source.
In order to incorporate alpha blending, we need only make a few modifications to the pipeline:
- Material shaders should output an alpha value to the background g-buffer target in addition to the modified 'background' pixels.
- Use the alpha value when blending the background buffer back into the la-buffer. Since the alpha value represents the opacity of the pixels already in the la-buffer, we should use 1-alpha so that the value represents the opacity of the incoming background pixels (i.e. source and destination are swapped).
As before, the 'background' pixels represent the light transmitted through the transparent material. However, alpha blending results in a different material effect since we want the light contributions at the transparent surface to be affected by the material's opacity.
So, in order to render a glass-like material, the colour target of the g-buffer should be written as black, the alpha value set to 1 and additive blending used. For a smoke-like material, the material diffuse colour should be written to the g-buffer colour target along with the alpha value and alpha blending used.
This technique is relatively simple and iterative and leverages the chief advantage of deferred rendering: cheap lighting. Probably the most costly aspect of the method is the repeated light accumulation pass, but with sufficient optimisation the cost of performing lighting multiple times per frame can be reduced:
- render light volume geometry to minimise shaded pixels per-light, reducing the fill-rate burden
- use intersection culling between geometry and light volumes to minimise the number of lights actually rendered in a pass
- manipulate the view/projection transformations to render a viewport fitting the geometry being rendered, again reducing the fill-rate burden
The performance of this method is also scalable: we can choose to render all transparent objects in one pass, accepting only a single level of transparency (complexity 2nlights), or we can depth-sort transparent objects and render each in its own pass, performing lighting once per transparent object (complexity nlights + nlights * ntransparent). In this worst case we fall back to the same complexity as forward rendering.
As with the other approaches, there are some drawbacks:
- Since the lighting pass is repeated, shadow maps can't be multiplexed between lights, unless the cost of re-rendering the shadow maps per lighting pass is acceptable
- Refractive materials may exhibit 'halos' from occluding objects. Because the 'background' pixels are written during the gbuffer stage (when the depth buffer is in use) we can't use the depth buffer to correct this.
- Post-processing stages which access the g-buffer may be affected, since the g-buffer is 'multiplexed' between passes. For example, a screen space motion blur which relies on a velocity buffer (as per Killzone) would produce incorrect results if transparent objects simply overwrote velocities.
- Similarly, use of the stencil buffer might complicate other techniques which rely on stencilling.
- A fully HDR pipeline may be tricky (depending on the implementation) as it might not be possible to store HDR values into the 'background' target of the g-buffer.
Overall this method has somewhat limited real-world applicability due to the complications it introduces into the pipeline, as well as the halo artifacts for refractive materials which can't easily be avoided. If the renderer is flexible enough to support it, a forward rendering approach seems still to be the most flexible solution.