Led Shader Tutorial   

  Led Shader Tutorial

part I
part II
part III
part IV
Source and Demo

[Previous: part III] [Next: Source and Demo]

Led Shader Tutorial - Part IV

Color Boosting and Burnt Out LEDs

We discussed the idea of Luminance Stepping in LED Shader Tutorial - Part III. The idea was to mimic the limited color spectrum that most LEDs have. We will now introduce another way to tweak this effect called Color Boosting. We will also look at adding some random burnt-out LEDs to give it a less perfect look.

New Uniforms used in the fragment program:
  • float colorBoost - adjust the color intensity of the picture. It's sort of equivalent to adjusting the contrast of the image. It is very useful for limiting the color spectrum of the LED display to make it look more realistic.
  • float burntOutPercent - not exactly a real "percent" but it affects how many burnt out lights we get. The higher this number the more LEDs will be turned off.
  • sampler2D noiseTexture - used with burntOutPercent to randomize which lights get burnt out while also making them fixed (not changing from frame to frame).

Be careful when you are picking a noiseTexture to use. It must have a very random distribution of light intensity since that is the factor I use to compare with burntOutPercent as you will see later. Feel free to use the one that comes with the demo I have written. Let's look at some code.

vec4 applyColorBoost(in vec4 color)
    vec4 boostedColor = color;
    float max = max(color.r,max(color.g, color.b));
    bvec3 maxes = equal(vec3(color),vec3(max));

        boostedColor += vec4(2.0*colorBoost,-colorBoost,-colorBoost,0.0);

        boostedColor += vec4(-colorBoost,2.0*colorBoost,-colorBoost,0.0);

        boostedColor += vec4(-colorBoost,-colorBoost,2.0*colorBoost,0.0);

    return boostedColor;


First we find what the maximum intensity we have coming from our individual color channels. We then find out which channels have this maximum as their value. It's important that we do it this way because more than one channel could share the maximum and should therefore both be boosted. We then apply our boost (more on this below) on each color channel equal to max.

There are multiple reasons why I applied the boost the way I did. I first tried to use complicated if/else statements to accomplish this same task but the shader slow down to almost a stand still. It seems to me that branching statements (especially nested ones) cause the biggest performance hits on shader code. By using functions provided by GLSL to do the equivalent of writing my own if/else statements, this degredation of speed seems to be completely thwarted.

Now, why am I adding "2.0*colorBoost" to the max channel and subtracting colorBoost from the other channels. The reason for this is so that if two or more channels share the max value, the boostedColor will maintain their relationship to each other. This is best seen in the figure below:

The figure on the left shows two examples of colors being modified with a colorBoost = 0.2. The columns corresponding to the numbers 1,2,3 represent the value of r,g,b after the 1st, 2nd, and 3rd "if" statement has been executed.

First, notice what happens when two or more channels share the maximum value. The method I use maintains their relationship to each other in the new color. If they were equal going into the method they will be equal coming out. This is very desirable.

The second point I have is actually a negative side effect of the method. Notice in the example on the left how the blue channel gets reduced to zero. It over penalizes the blue channel in this case. Not only did the other two channels get boosted but blue got reduced twice! I believe this is an acceptable trade off, however, and the results are pleasing.

Lastly, we will look at how we make some of our LEDs burnt out. It is fairly simple really.

 if(getIntensity(texture2D(noiseTexture, texCoords[4].st)) <= burntOutPercent)
    gl_FragColor = vec4(0.1,0.1,0.1,1.0) + (0.3*(tolerance + luminanceBoost));
    gl_FragColor = mix of pixel region color (with luminance & color boosting effects) 
				and black buffer region (previously discussed code);

So we take the intensity of our position in the noise texture and compare that to our burntOutPercent variable. If we are under this threshold we are burnt out, otherwise we go on to calculate the color as before.

You may be scratching your head at the color we assign to our burnt out LEDs. I came up with this after some trial & error. Basically, we want this burnt out color to be close to the color of the buffer region of our neighbors. If our LED screen is really bright, the buffer regions around our lit LEDs will be more of a gray than a black. And the reverse is of course true as well. So the calculation of color tries to approximate what the buffer region color of our neighbors will be so the change isn't super abrupt. Since tolerance and luminanceBoost greatly affect this color, it made sense to include them. The 0.3 is completely arbitrary and was decided upon through testing.

It is relavent to note that we moved the computation for texCoords[4]. This is because it is the sample coordinates for the very center of our pixel region. These seemed like the best coordinates for our noise texture lookup. It is also nice to have this value earlier in the code for possible later implementations of transition effects and things of that sort.

Displays Galore!
Using all these variables you can make vastly different LED displays. A lot of it has to do with what sort of textures you choose. For instance, if you wanted to do some solid color LED displays, like a yellow text display, you could simply use some yellow text with a black background and add some colorBoost and luminanceBoost to get some bright yellow LEDs! The possibilities are endless really. Also be wary that imperfections of the texture can be brought out by pixelation so be sure you have a texture you will be happy with. Below I have included some fun combinations that I have come up with. The neon colored one actually is a simple animation! You can do that with this shader to some extent by dynamically playing with what texture is assigned. Blinking effects are especially simple...check out the demo!!! (some negative artifacts show up in the print screen process, you really should mess with the demo. Also the below pictures are not a result of the default variables, you HAVE to play around with the shader variables to get the pictures below. Please enjoy!!!):

Conceptual summary of shader steps for each fragment (The current fragment being processed is referred to in the first person):

  • find out if I am in a burnt out LED region and assign a reasonably dark color if I am, otherwise proceed
  • compute base case sample locations in texture coordinates
  • find out which pixel region I am in and apply that offset to the base case to get my sample locations
  • use texture coordinates I have computed to get 9 color values from my pixel region
  • store the average of those color values
  • modify this color's shade by passing it to the applyLuminanceStepping function
  • modify the color further by passing it to the applyColorBoost function
  • determine where I am in relation to the circle defining my pixel region
  • use tolerance uniform and my position to get a gradient coeffecient
  • I get assigned a blending of my pixel region color and dark gray based on the gradient coeffecient

Thank you for joining me on this adventure. I hope you enjoyed yourself and learned something along the way. - Jason

Advanced Topic Section
This section is not needed to understand the tutorial.

Why do the burnt out pixel regions seem to shrink and expand?
If you didn't notice this quark allow me to be upfront with you and tell you about it. Sometimes when moving around the scene the burnt out pixel regions will aquire a border as if some of the fragments in the region don't know it is burnt out! To be honest I'm not sure why this side-effect is occuring. I have couple of ideas. It could be a floating-point error issue, a depth issue, or an anti-aliasing issue. If anyone has any ideas how to fix this, please email me!

Why animating the billboard using texture coordinates doesn't work right?
There may be a way to do this with some additional code added to the shader and/or special consideration on the OpenGL side of things. But if you are trying to animate the texture by dynamically assigning different texture coordinates rather than the standard ones -- (0.0,0.0), (0.0,1.0), (1.0,1.0), (1.0,0.0) -- the pixel regions will move too! This is because we are using the texture coordinates from the beginning to do all of our offsets and things. To get the pixel regions to stay static (as they would on an LED screen), the texture coordinates must remain static. This is one of the best reasons for possibly implementing the offsets in pixel coordinates and converting to texture space just before sampling as described in the advanced topic section of LED Shader Tutorial - Part I.

The code (if you are going to use the code you should refer to the Source and Demo page):

void main (void)
    gl_TexCoord[0] = gl_MultiTexCoord0;
    gl_Position = ftransform();

*Shader by: Jason Gorski
*Email: jasejc 'at'
*CS594 University of Illinios at Chicago
*LED Shader Tutorial
*For more information about this shader view the tutorial page
*at or email me

#define KERNEL_SIZE 9

uniform int pixelSize; //size of bigger "pixel regions". These regions are forced to be square
uniform ivec2 billboardSize; //dimensions in pixels of billboardTexture
uniform sampler2D billboardTexture; //texure to be applied to billboard quad

//uniforms added since billboard1

//a tolerance used to determine the amount of blurring along the edge of the circle defining our "pixel region"
uniform float tolerance; 
//the radius of the circle that will be our "pixel region", values > 0.5 hit the edge of the "pixel region"
uniform float pixelRadius; 

//uniforms added since billboard2

//number of shades of color our LED billboard will display
uniform int luminanceSteps; 
//used to brighten or darken image
uniform float luminanceBoost; 

//uniforms added since billboard3

uniform float colorBoost; //used to adjust the color intensity
uniform float burntOutPercent; //not exactly a "percent", but it determines how many LEDs are burnt out
uniform sampler2D noiseTexture; //noise texture used with burntOutPercent

vec2 texCoords[KERNEL_SIZE]; //stores texture lookup offsets from a base case

//gets the light intensity of the color (same as luminance in applyLuminanceStepping)
float getIntensity(in vec4 color)
{ return (color.r + color.g + color.b)/3.0; }

//apply colorBoost
vec4 applyColorBoost(in vec4 color)
    vec4 boostedColor = color;
    float max = max(color.r,max(color.g, color.b)); //determine max intensity of channels
    bvec3 maxes = equal(vec3(color),vec3(max)); //contains which channels == max

     //any channels == max are boosted by the colorBoost
         boostedColor += vec4(2.0*colorBoost,-colorBoost,-colorBoost,0.0);

         boostedColor += vec4(-colorBoost,2.0*colorBoost,-colorBoost,0.0);

         boostedColor += vec4(-colorBoost,-colorBoost,2.0*colorBoost,0.0);

     return boostedColor;

//apply luminanceSteps & luminanceBoost
vec4 applyLuminanceStepping(in vec4 color)
     float sum = color.r + color.g + color.b;
     float luminance = sum/3.0; //brightness or luminance of color
     //ratio stores each channel's contribution to the luminance
     vec3 ratios = vec3(color.r/luminance, color.g/luminance, color.b/luminance); 

     float luminanceStep = 1.0/float(luminanceSteps); //how big each luminance bin is
     float luminanceBin = ceil(luminance/luminanceStep); //figure out which bin the color is in
     //store the luminance of the color we are making including luminanceBoost
     float luminanceFactor = luminanceStep * luminanceBin + luminanceBoost; 
     //use ratios * luminanceFactor as our new color so that original color hue is maintained
     return vec4(ratios * luminanceFactor,1.0); 

void main(void)
{   //will hold our averaged color from our sample points
     vec4 avgColor; 
     //width of "pixel region" in texture coords
     vec2 texCoordsStep = 1.0/(vec2(float(billboardSize.x),float(billboardSize.y))/float(pixelSize)); 
     //x and y coordinates within "pixel region"
     vec2 pixelRegionCoords = fract(gl_TexCoord[0].st/texCoordsStep); 
     //"pixel region" number counting away from base case
     vec2 pixelBin = floor(gl_TexCoord[0].st/texCoordsStep); 
     float offset = pixelBin * texCoordsStep;
     //width of "pixel region" divided by 3 (for KERNEL_SIZE = 9, 3x3 square)
    vec2 inPixelStep = texCoordsStep/3.0; 
     vec2 inPixelHalfStep = inPixelStep/2.0;

     //the center of our "pixel region" is computed earlier now 
     // so we don't waste time computing other colors if we are in a burnt out region
     texCoords[4] = vec2(inPixelStep.x + inPixelHalfStep.x, inPixelStep.y + inPixelHalfStep.y) + offset;

     //if light intensity of our noise texture <= burntOutPercent we are burnt out, 
     // otherwise continue computing "pixel region" color
     if(getIntensity(texture2D(noiseTexture, texCoords[4].st)) <= burntOutPercent)
         //try to match up color-wise with the edge of our "pixel region"
         gl_FragColor = vec4(0.1,0.1,0.1,1.0) + (0.3*(tolerance + luminanceBoost)); 

    else {
         //use offset (pixelBin * texCoordsStep) from base case 
	// (the lower left corner of billboard) to compute texCoords
	float offset = pixelBin * texCoordsStep;
	texCoords[0] = vec2(inPixelHalfStep.x, inPixelStep.y*2.0 + inPixelHalfStep.y) + offset;
	texCoords[1] = vec2(inPixelStep.x + inPixelHalfStep.x, inPixelStep.y*2.0 + inPixelHalfStep.y) + offset;
	texCoords[2] = vec2(inPixelStep.x*2.0 + inPixelHalfStep.x, inPixelStep.y*2.0 + inPixelHalfStep.y) + offset;
	texCoords[3] = vec2(inPixelHalfStep.x, inPixelStep.y + inPixelHalfStep.y) + offset;
	texCoords[4] = vec2(inPixelStep.x + inPixelHalfStep.x, inPixelStep.y + inPixelHalfStep.y) + offset;
	texCoords[5] = vec2(inPixelStep.x*2.0 + inPixelHalfStep.x, inPixelStep.y + inPixelHalfStep.y) + offset;
	texCoords[6] = vec2(inPixelHalfStep.x, inPixelHalfStep.y) + offset;
	texCoords[7] = vec2(inPixelStep.x + inPixelHalfStep.x, inPixelHalfStep.y) + offset;
	texCoords[8] = vec2(inPixelStep.x*2.0 + inPixelHalfStep.x, inPixelHalfStep.y) + offset;

         //take average of 9 pixel samples
         avgColor = texture2D(billboardTexture, texCoords[0]) +
                             texture2D(billboardTexture, texCoords[1]) +
                             texture2D(billboardTexture, texCoords[2]) +
                             texture2D(billboardTexture, texCoords[3]) +
                             texture2D(billboardTexture, texCoords[4]) +
                             texture2D(billboardTexture, texCoords[5]) +
                             texture2D(billboardTexture, texCoords[6]) +
                             texture2D(billboardTexture, texCoords[7]) +
                             texture2D(billboardTexture, texCoords[8]);

         avgColor /= float(KERNEL_SIZE);

         //get a new color with the discretized luminance value
         avgColor = applyLuminanceStepping(avgColor);
         //adjust the color
         avgColor = applyColorBoost(avgColor);

         //blend between fragments in the circle and out of the circle defining our "pixel region"
         //Equation of a circle: (x - h)^2 + (y - k)^2 = r^2
         vec2 powers = pow(abs(pixelRegionCoords - 0.5),vec2(2.0));
         float radiusSqrd = pow(pixelRadius,2.0);
         float gradient = smoothstep(radiusSqrd-tolerance, radiusSqrd+tolerance, powers.x+powers.y);

         gl_FragColor = mix(avgColor, vec4(0.1,0.1,0.1,1.0), gradient);

[Previous: part III] [Next: Source and Demo]