Each of the three blocks of four rows above show data in a roughly 0-10 range when this page loads. Hover the mouse over them to see that range animate from a high of 1 to a high value of 19 and back again. Hopefully the top rows, which are rendered directly to the canvas more or less, make it clear how the limitations of the standard 8 bit encoding create problems for HDR content. The next four show the same content rendered via an intermediate RGBM encoded buffer, then pushed through a tone-mapper, and the final four rows show the same content again this time rendered to a floating point buffer with linear encoding and also pushed through a tone mapper.
The lack of good support for floating point render targets in WebGL creates a potential problem for people wanting to implement a HDR rendering pipeline. Typically a modern pipeline would use such a buffer to store lighting data, and as the lights get brighter we don’t want to end up clamping the colors when we run out of range in a standard 8 bit per channel buffer. Floating point targets allow those values to break out of that range and go much higher, and so allows for a better representation how lighting works in the real world and also provide greater freedom for adjustment in post processing.
WebGL in theory supports an extension for floating point render targets. See here using the OES_texture_float extension. Perhaps we don’t want to depend on extensions if we can help it, though at the time of writing it does appear that this extension is very widely supported. There are some good stats available at webglstats.com that seem to show 96% of users might have support for this. The extension doesn’t come with support for linear filtering out of the box though, meaning you have to then use the OES_texture_float_linear extension, which appears less widely supported at 91%.
There are a few factors, leading to a few options for how we handle this, which I’m going to briefly explore here…
Aside from deciding on the format of our HDR buffers we also need to decided on an encoding format for the data. A fairly standard choice is sRGB vs linear. sRGB is commonly used in 8 bit per channel textures in order to make better use of the limited precision. sRGB stores color values raised to the power 1/2.2, the point, I believe, being to better match the distribution of the range to the way people see, so you have more precision where the viewer is most likely to perceive a lack of precision. sRGB has become the standard encoding for image files across all computer displays. A linear encoding on the other hand stores raw color (lighting) values. If we have the precision and range to work with a linear encoding it can make certain operations in computer graphics easier to manage. For example adding together two sRGB values involves converting to linear, adding, then converting back again, which isn’t something a standard blend unit is necessarily going to handle for us. Adding values together happens to be exactly what we want to do when accumulating the lighting data in a scene.
RGBM is another type of encoding we could use. Where sRGB allowed us to get better precision out of three 8 bit values, RGBM allows us to get a larger range. How it works is that we use re-purpose the alpha channel, which would often be unused anyway, and instead use it as a multiplier to be applied to the RGB channels. We are free to choose what range the 0-255 multiplier represents. We then translate from RGBM to linear and back again in order to use data held in our targets in our shaders.
There are examples of RGBM encode and decode shader code here. The code I use actually looks very similar to this, where I’ve also chosen a scale of 6 for my encoding, though in theory you can go a fair bit higher if you want to.
Another trick we can also do to get even more range is to encode into sRGB before encoding into RGBM. The sRGB encoding works just as it always does for values below 1 but above 1 we’ll find it drags down our largest values, so for example 36 becomes 6 if we are working with a power value of 2, so our 0-6 multiplier can in theory be mapped to a larger range of input values.
The final consideration is tone mapping. If we are pushing the data in our buffers outside of the 0-1 displayable range, we need to decide how to interpret that data when displaying it. The common approach is to run a tone mapper to map the data in a meaningful way for display. There are a few fairly standard approaches to this.
This is how the GLSL shader code ends up looking. Note that this includes the power(x,1/2.2) adjustment to monitors gamma so we don’t need to do that too. We can just take the linear input, push it through this code and put the result directly into the canvas.
vec3 c0 = vec3(0.0, 0.0, 0.0); vec3 c1 = vec3(0.004, 0.004, 0.004); vec3 c2 = vec3(6.2, 6.2, 6.2); vec3 c3 = vec3(0.5, 0.5, 0.5); vec3 c4 = vec3(1.7, 1.7, 1.7); vec3 c5 = vec3(0.06, 0.06, 0.06); vec3 x = max(c0, rgb - c1); rgb = (x * (c2 * x + c3) / (x * (c2 * x + c4) + c5));
There are of course other approaches, for example ACES tonemapping, described here and also Reinhard tonemapping, but I’m happy enough with this.