One way to avoid this is to transform the colour channel range from [0,1], to the range [0,infinity]. This is one form of HDR, High Dynamic Range. After doing light manipulations in this new range, the colour is transformed back again. This transformation is called Tone mapping. In my case, I am first using a reverse tone mapping to first expand the range: Y/(1-Y). Lighting effects can then be applied, using additive or multiplicative transformations. Afterwords, a normal tone mapping is used: Y/(1+Y) to get the range back to [0,1].
The results of adding 0.2 or multiplying with 1.2 to a colour in the normal range [0,1]:
When instead doing the same to the transformed range, the result, after transforming it back again, is:
When instead doing the same to the transformed range, the result, after transforming it back again, is:
- A colour can no longer be saturated (as it would be on the right side in the first diagram).
- The colour change is no longer linear. This is more natural in my opinion. If you light a lamp in full sunshine (in the real world), you may not notice the difference.
- Adding the same constant to R, G and B that differs will produce a result that adds more to the darker channels. This will have the effect of making the reflection more white (or only grey). When used for specular glare, for example, I think it is less interesting to preserve the original colour balance.
I tried this technique in Ephenation. The game contains sun light, lamps, ambient lights, specular glare, and other light effects. It is now possible to calibrate each of them, one at a time, and then simply combine all of them together, without any risk of saturated effects. I no longer have to estimate the worst case to avoid saturation. The pseudo code for the shader looks as follows:
vec4 hdr;
hdr.r = diffuse.r/(1-diffuse.r);
hdr.g = diffuse.g/(1-diffuse.g);
hdr.b = diffuse.b/(1-diffuse.b);
float lampAdd = 0;
for (i=0;i<numLamps;i++) {
lampAdd = lampAdd + ... // Add light depending on distance to lamp
}
// The constants on the next line are separately calibrated on their own.
vec4 hdr;
hdr.r = diffuse.r/(1-diffuse.r);
hdr.g = diffuse.g/(1-diffuse.g);
hdr.b = diffuse.b/(1-diffuse.b);
float lampAdd = 0;
for (i=0;i<numLamps;i++) {
lampAdd = lampAdd + ... // Add light depending on distance to lamp
}
// The constants on the next line are separately calibrated on their own.
float fact = lampAdd*1.5 + ambient*0.3 + sun*1.5;
// 'fact' can both be smaller than 1 or bigger than one 1. 'refl' is the
// reflection attribute of the material
vec4 step1 = fact*hdr + pow(max(dot(normal.xyz,vHalfVector),0.0), 100) * refl;
fragColour.r = step1.r/(1+step1.r);
fragColour.g = step1.g/(1+step1.g);
fragColour.b = step1.b/(1+step1.b);
A disadvantage of this technique is that colours that are already saturated (equal to 1) can not be mapped to the expanded range, so I had to clamp them down a little.