If you find this article contains errors or problems rendering it unreadable (missing images or files, mangled code, improper text formatting, etc) please contact the editor so corrections can be made. Thank you for helping us improve this resource
When Paul Debevec came up with HDR Rendering in 1998, his revolutionary idea could only be implemented in non-real-time. With the advent of powerful graphics hardware over the past few years, thanks to Moore's law of course, the process can now be implemented on graphics hardware in real-time. Apart from a few demos (including ATI's cool "RNL"), and a couple of articles, there is hardly any info on HDR in real-time.
This tutorial is just an introduction that'll help you get your first HDR app running. I won't show you how to make scenes with shiny little spheres on marble tables. What I will teach you is how to load a .hdr file and use it to texture a simple quad, which will then be rendered with "natural light". This tutorial works only on cards that support floating point textures in DX and pixel shaders 2.0, which rules out nVIDIA cards till the Detonator drivers expose floating point textures for DX.
So What is "High Dynamic Range"?
High Dynamic Range is just a neat term for "storing color values much greater than the usual 0.0f to 1.0f used in graphics". Unlike conventional colors used in graphics, where color values cannot exceed 1.0f, the color values here can be anything you like, as high as you like. The advantage of HDR is that your scenes will look more realistic. The process of rendering a scene in HDR is show below :-
Render the scene with HDR values into a floating point buffer.
Downsample this buffer to 1/4th size (1/2 width and 1/2 height) and suppress LDR values.
Run a bloom filter over the downsampled image blurring it along x and y axes.
Tone map the blurred image after compositing it with the original image.
We need to suppress LDR values so that we don't blur those parts of the image. A bloom filter simply bleeds color from one pixel to it's neighboring pixels. We use a Gaussian filter in this case, but you can use any bloom filter.
What are We Waiting For?
Without further ado, let's begin programming our first HDR app. Here I assume that your familiar with DirectX and HLSL and know how to open a window and start a rendering loop.
If you open up the source code (you can find the link at the end of the article), you'll see a lot of global variables. These are used to hold textures and surfaces to render and post-process the scene. You'll also find a couple of effect pointers, which are used to create and handle the HLSL shaders. Apart from these variables, there are a couple of app specific variables like the window handle, vertex structs for the quads, etc...
Shown below are the texture and surface pointers which we use as render targets and textures (explained below).
// Global variables
HWND g_hWnd = NULL; // Window handle
LPDIRECT3D9 g_pd3d = NULL; // D3D object
LPDIRECT3DDEVICE9 g_pd3dDevice = NULL; // D3D device
LPDIRECT3DVERTEXBUFFER9 g_pQuadBuffer = NULL; // Quad vertex buffer
LPDIRECT3DVERTEXBUFFER9 g_pHalfBuffer = NULL; // Half-screen quad vertex buffer
LPDIRECT3DVERTEXBUFFER9 g_pFullBuffer = NULL; // Full-screen quad vertex buffer
LPDIRECT3DTEXTURE9 g_pQuadTexture = NULL; // Light probe texture
#define FORMAT64 D3DFMT_A16B16G16R16F // Indicates 64-bit format
#define FORMAT128 D3DFMT_A32B32G32R32F // Indicates 128-bit format
LPDIRECT3DSURFACE9 g_pBackBuffer = NULL; // Pointer to the back buffer
LPDIRECT3DTEXTURE9 g_pFullTexture = NULL; // Full screen texture - original
// scene is rendered to this
LPDIRECT3DSURFACE9 g_pFullSurface = NULL; // Full screen surface
LPDIRECT3DTEXTURE9 g_pDownSampleTexture = NULL; // Downsample texture - used
// to downsample "FullTexture"
LPDIRECT3DSURFACE9 g_pDownSampleSurface = NULL; // Downsample surface
LPDIRECT3DTEXTURE9 g_pBlurXTexture = NULL; // Texture used for horizontal blurring
LPDIRECT3DSURFACE9 g_pBlurXSurface = NULL; // Corresponding surface
LPDIRECT3DTEXTURE9 g_pBlurYTexture = NULL; // Texture used for vertical blurring
LPDIRECT3DSURFACE9 g_pBlurYSurface = NULL; // Corresponding surface
LPD3DXEFFECT g_pDownSample = NULL; // Effect for Downsample.fx
LPD3DXEFFECT g_pBlurX = NULL; // Effect for BlurX.fx
LPD3DXEFFECT g_pBlurY = NULL; // Effect for BlurY.fx
LPD3DXEFFECT g_pToneMapping = NULL; // Effect for Tonemapping.fx
The radiance format is one of the formats that allows you to store floating point images. Paul Debevec has a couple of light probes (just a fancy name for high dynamic range images) on his website. As I said earlier, in this tutorial I show you how to load .hdr files and use them as textures. Since they're in a binary format, to make reading them simpler, I've provided a simple "utility" which I found on the net. It contains some code (rgbe.h and rgbe.cpp) to simplify reading .hdr files.
The Actual Process
We start off by initializing D3D, creating the device, etc... All this is done in the "init()" function. Now, we need to create 3 vertex arrays. The first called "g_QuadVertices", which is used to render the 2D textured quad into. The second is called "g_HalfVertices", which is used to render the original image into the "downsample render target quad" and the downsampled render target into the "blur target quad", and so on. I chose to call it "g_HalfVertices" because this quad covers 1/4 of the screen (1/2 width and 1/2 height). The final one is called "g_FullVertices", which is used for the "tonemapped render target quad". Then we create corresponding vertex buffers and copy the vertices into them.
Next, we have to load our light probe. To do this, we simply read in the array of floats which are actually the RGB components of every pixel in the light probe. Now we need to copy these values to a floating point render target. But to do that, we also need the alpha component since the floating point render target needs 4 components (RGBA). Also, since we're using the 64-bit surface format (D3DFMT_A16B16G16R16F) we need convert the 32-bit floats into 16-bit floats. To do this, we simply use "D3DXFLOAT16" instead of "float".
// Read in the HDR light probe.
FILE* fp = fopen( "RNL.hdr", "rb" );
RGBE_ReadHeader( fp, &g_iWidth, &g_iHeight, &info );
// We really don't need this
float fExposure = info.exposure;
float fGamma = info.gamma;
// Create a float array to read in the RGB components
float* m_fHDRPixels = new float[3 * g_iWidth * g_iHeight];
memset( m_fHDRPixels, 0, 3 * g_iWidth * g_iHeight * sizeof( float ) );
RGBE_ReadPixels_RLE( fp, m_fHDRPixels, g_iWidth, g_iHeight );
if( FAILED( D3DXCreateTexture( g_pd3dDevice, g_iWidth, g_iHeight, 1, D3DUSAGE_DYNAMIC,
FORMAT64, D3DPOOL_DEFAULT, &g_pQuadTexture ) ) )
MessageBox( g_hWnd, "Unable to create texture", "Error", MB_ICONINFORMATION );
// Convert the 32-bit floats into 16-bit floats and include the alpha component
D3DXFLOAT16* m_fHDR = new D3DXFLOAT16[4 * g_iWidth * g_iHeight];
int j = 0;
for( int i = 0; i < 4 * g_iWidth * g_iHeight; i += 4 )
m_fHDR[i] = m_fHDRPixels[i - j];
m_fHDR[i + 1] = m_fHDRPixels[i + 1 - j];
m_fHDR[i + 2] = m_fHDRPixels[i + 2 - j];
m_fHDR[i + 3] = 0.0f;
// Lock the texture and copy the pixel data into it
if(FAILED( g_pQuadTexture->LockRect( 0, &lr, NULL, 0 ) ) )
MessageBox( g_hWnd, "Unable to lock texture", "Error", MB_ICONERROR );
memcpy( (D3DXFLOAT16*)lr.pBits, m_fHDR, 4 * g_iWidth * g_iHeight
* sizeof( D3DXFLOAT16 ) );
if(FAILED( g_pQuadTexture->UnlockRect( 0 ) ) )
MessageBox( g_hWnd, "Unable to unlock texture", "Error", MB_ICONERROR );
// Set the sampler states
g_pd3dDevice->SetSamplerState(0, D3DSAMP_MINFILTER, D3DTEXF_LINEAR);
g_pd3dDevice->SetSamplerState(0, D3DSAMP_MAGFILTER, D3DTEXF_LINEAR);
Then, we initialize the render targets and create the effects and shaders.
Render the scene to a floating point render target
Downsample that render target and suppress LDR values
Take the downsampled target and blur it horizontally
Blur this vertically
Tone map the original and blurred targets to get a displayable image
So, now we get to the interesting part, how things exactly work. We begin by rendering the textured "quad vertices" into a floating point render target. Now, we need to downsample this to 1/4th its size, suppressing the LDR values, so that we don't blur the entire scene. To do this, we simply use the original render target as a texture and render this onto another render target 1/4th the "original" size. To suppress the LDR values, the below function "SuppressLDR" is used. Kd is the material's diffuse color.
Next, we need to blur the downsampled image in order to "bleed" colors from the bright pixels into neighboring pixels. To do this, as mentioned earlier, we use a separable Gaussian filter. In the first pass, we blur pixels along the x-axis, we then take this image and blur it along the y-axis. Pretty simple, huh? The below code fragment is from BlurX.fx.
// Vertex shader
float4 Pos: POSITION;
float2 Tex: TEXCOORD0;
VS_OUT vs_main( float3 inPos: POSITION, float2 inTex: TEXCOORD0 )
// Output the transformed vertex
OUT.Pos = mul( matMVP, float4( inPos, 1 ) );
// Output the texture coordinates
OUT.Tex = inTex + ( PixelOffset * 0.5 );
// Pixel shader
float4 ps_main( float2 inTex: TEXCOORD0 ) : COLOR0
float4 color = tex2D( BlurXSampler, inTex );
// Sample pixels on either side
for( int i = 0; i < 8; ++i )
color += tex2D( BlurXSampler, inTex + ( BlurOffset * i ) )
color += tex2D( BlurXSampler, inTex - ( BlurOffset * i ) )
We just create a loop that adds weighted neighboring pixels. We sample 8 pixels from either side. BlurOffset is actually the per-pixel (or texel) width, and we multiply it by the iteration number "i" to get the coordinates of the "i"th pixel.
Blurring along the y-axis is the same, only we provide the per pixel height this time.
Now comes the most important part, compositing the original and blurred images and tone mapping them to get a displayable image. The below code sample is from ToneMapping.fx, which shows how to combine the 2 images.
First, we simply lerp between the original and blurred colors. Then, we calculate the vignette (which is the square of the distance of the current pixel from the center of the screen) and multiply the lerped color by vignette raised to the fourth power. Finally, we multiply by the exposure level, which determines how "exposed" your finally image should be, just like you can set the exposure in a camera, and add a gamma correction.
Shown above are images of the light probe at different exposures.
Left-Top - Under exposed (0.5)
Right-Top - Properly exposed (2.5)
Left-Bottom - Over exposed (10.0)
Right-Bottom - Extremely over exposed (20.0)
To properly observe "glow" effects, you'll need either the grace-cube texture or the grace light probe from www.debevec.org.
Voila! Your first HDR app is done. This may not be the most efficient way to do things, since I only wanted to show the basics. So I leave the optimizing part to you. Masaki Kawase has implemented a different method for post-processing, and after implementing it I found out that it actually gives better performance, albeit not the same quality. There are other types of glows, like star, afterimage, etc... which I didn't discuss. You could try and implement them as well. Now let's see if your game comes out with HDR Rendering before Half-Life 2 does!
If you have any doubts, questions or suggestions, you can mail me at firstname.lastname@example.org. Here is the source code RNL.zip.
Real-Time 3D Scene Post-processing. Jason L. Mitchell.
DirectX®9 Shading. Jason L. Mitchell.
Frame Buffer Post-processing Effects in DOUBLE-S.T.E.A.L (Wreckless). Masaki Kawase.