Skip to content

Unlocking extra image information using shaders

February 19, 2014

Sometimes when working on a game, you just need to pack a little more information stored in an image but you don’t necessarily want to include an additional image.  Recently I wanted to cram 5 channels worth of information into the 4 available (Red, Green, Blue, Alpha).  What I put together were a couple handy shader routines.  The idea is to bit shift the data in the alpha channel to store more pixel data at the expense of the colour depth.  One rather large problem with GLES is that there aren’t any bit shifting operators available on the current iPhones and shaders don’t normally work with integers so you have to jump through a couple hoops to do it.

Unfortunately in the process of creating the texture packing routines I discovered some drawbacks that made them useful for the task at hand, but they may be of use to someone else.  You cannot use linear interpolation on your textures, so they wind up looking blocky.  This was a show stopper, but I didn’t discover this until I put together 3 different versions that use different bit depths.  Also I’m good at writing shaders but there are lots of people out there that are better and knowing my luck there might be a one line solution to the issue that I’m unaware of.  With that said, lets get on with the show.

1-Bit Channel

Steal one bit of information from the alpha channel to create a basic mask, while maintaining the majority of the bit depth so that it can still be used with very little visual loss.

1bit result

Painting this image in Photoshop is the easiest of all the methods.

  • Create a layer.  This will be the layer with the majority of the colour data on it.  So “Alpha” or “AO”.
  • Paint a black and white image with all the shades of grey that you want to use.  This could be the alpha channel of the object, or an ambient occlusion pass, etc.
  • Use the Output levels portion of the Levels adjustment tool to make the brightest shade 127.  This will darken the image substantially and is where you lose the colour data but gain space for a new mask.levels
  • Create a new layer and fill it with pure black. Call this layer “Mask”
  • Select the Pencil tool.  Make sure that all anti-aliasing is off so the brush has hard jaggy edges.
  • Set the color to R: 128, G: 128, B: 128.
  • Paint your mask.
  • Set the layer style of to “Linear Dodge (Add)”.

If you take this image and put it into the Alpha channel of your texture, the shader will be able to have RGBA + a mask.

1bit1bit closeup

highp vec2 unpackColor1bit(highp float f)
{
    highp vec2 finalColour = vec2(0.0);
    bool shadow = (f >= 0.5);      // 1/2.  Divide the colour range into two.
    finalColour.r = float(shadow); // Set the red channel to 0.0 or 1.0 if there is a mask
    finalColour.g = f;
    if (shadow)                    // Remove the mask data if it needs to be removed
    {
        finalColour.g -= 0.5;
    }
    finalColour.g /= 0.5;          // Stretch it back out to 0.0 .. 1.0 range
    return finalColour;
}

How it works

AddingColour

Imagine an image that is 1 pixel x 9 pixels in size.  In it you have a gradient from black to white and the shades of grey go from (0, 0, 0) to (127, 127, 127).  That’s the bar graph on the left.  Then on a new layer you paint some of the pixels with a value of (128, 128, 128) and leave some at (0, 0, 0).  When you set the blending mode to Linear Dodge (Add) or Additive blending in shader speak you’re adding the two shades of grey together.  What you wind up with is what is pictured on the right.  Some pixels are greater than or equal to 128 and others are below it.  That is how you can tell if the mask is on that pixel or not.  If it’s greater than or equal to 128, that means that the pixel on the 1 bit mask is set, and if you subtract 128 from the value, you have recovered the shade of grey on the alpha channel (Blue bar).

The 2-bit and 4-bit shaders work in the exact same fashion, but they are more granular.  With a 2 bit mask, instead of dividing the colour space into 2 chunks, you divide it into 4.  That gives you 4 shades of grey to work with on one side and 64 colours on the other.  With the 4-bit mask you’re dividing it into 16 chunks and you have two images that contain 16 colours each.

2-Bit Channel

2bit result

This is very similar to the 1-Bit Channel, but 2-Bits are used so that you can have 3 values of grey on the mask.  This allows for a tiny amount of anti-aliasing on the edges.  On the low pass use the Levels filter to limit the range from 0 to 63.  The shades of grey on the High pass increase by 16 every time starting at zero, so (0, 0, 0) (16, 16, 16) (128, 128, 128) and (192, 192, 192) are the values available to you.  This is still usable by an artist without any tools to convert images.  If you just make sure that you’re painting one of those 4 shades, everything should work out fine.

2bit2bit closeup

highp vec2 unpackColor2bit(highp float f)
{
    const highp float splitter = 0.25; // 1/4.  The 0.0 .. 1.0 range is divided into quarters.
    highp vec2 finalColour = vec2(0.0);

    // Unrolled loop that just sets the 4 mask colours explicitly
    if (f >= splitter * 3.0)
    {
        finalColour.r = 1.0;
        finalColour.g = f - splitter * 3.0; // Remove 0.75 from the colour
    }
    else if (f >= splitter * 2.0)
    {
        finalColour.r = 0.66;
        finalColour.g = f - splitter * 2.0; // Remove 0.50 from the colour
    }
    else if (f >= splitter)
    {
        finalColour.r = 0.33;
        finalColour.g = f - splitter;       // Remove 0.25 from the colour
    }
    else
    {
        finalColour.r = 0.0;
        finalColour.g = f;
    }

    finalColour.g /= splitter;    // Bring the green channel back into the 0.0 .. 1.0 range.
    return finalColour;
}

4-Bit Channel

4bit result

When creating a 4-Bit Channel you’re splitting the 8-Bits that are normally used for the Alpha channel in half.  This limits both packed images to 16 shades of grey.  It also gets a little more difficult to hand paint in Photoshop, but the trick is to paint the High layer with greys that are powers of two. (0, 0, 0), (16, 16, 16), (32, 32, 32), (64, 64, 64), …, (240, 240, 240).  The lower layer gets the colours (0, 0, 0), (1, 1, 1), (2, 2, 2), … (15, 15, 15).  Set the higher layer to additive and you should see a result similar to the attached screenshots.

4bit result4bit

Displaying just the lower layer looks like this: 4bit result pass2

Displaying just the higher layer looks like this:

4bit result pass1

You will notice the grid like steps because each image is only 16 shades of grey and the interpolation is set to Nearest Neighbour.  The problem shows up when you change the filter modes to Linear.  Then you start to see interpolation errors between the pixels and I’m not sure there is anything that can be done about it.

4bit problem

highp vec2 unpackColor4bit(highp float f)
{
    highp vec2 finalColour = vec2(0.0, f);       // Start the green channel with full colour, and the red channel with no colour.
    const highp float splitter = 0.0625; // 1/16th
    while( f > splitter )
    {
        finalColour.r += splitter; // Increment the red channel by 1/16th
        finalColour.g -= splitter; // Decrement the green channel by 1/16th
        f -= splitter;     // Remove this 1/16th chunk of colour from the float containing all the colours and repeat if necessary.
    }
    finalColour.r /= (1.0 - splitter); // Flip the values so that they're in the right order from 0 .. 15 and then divide them by 1/16th to get it back into the 0.0 .. 1.0 range.
    finalColour.g /= splitter;         // Get the green channel back into the 0.0 .. 1.0 range.

    return col;
}

All in one

Here is the final fragment shader done up all nicely so that it can be used in your project.  There are helper functions to grab the bit depth that you want and then on generalized function that does all the work.

uniform highp sampler2D Diffuse;
varying highp vec2 texcoord0;

highp vec2 unpackColour1Bit(highp float f)
{
    const highp float splitter = 0.5; // 1/2
    return unpackColourVariable(f, splitter);
}
highp vec2 unpackColour2Bit(highp float f)
{
    const highp float splitter = 0.25; // 1/4
    return unpackColourVariable(f, splitter);
}

highp vec2 unpackColour4Bit(highp float f)
{
 const highp float splitter = 0.0625; // 1/16
 return unpackColourVariable(f, splitter);
}
highp vec2 unpackColourVariable(highp float f, highp float splitter)
{
    highp vec2 finalColour = vec2(0.0, f);       // Start the green channel with full colour, and the red channel with no colour.
    while( f > splitter )
    {
        finalColour.r += splitter; // Add a colour chunk to the red channel
        finalColour.g -= splitter; // Remove a colour chunk from the green channel
        f -= splitter;             // Remove this colour chunk from the float containing all the colours and repeat if necessary.
    }
    finalColour.r /= (1.0 - splitter); // Normalize the high bits to 15 / 15.
    finalColour.g /= splitter;         // Get the green channel back into the 0.0 .. 1.0 range.

    return finalColour;
}
void main()
{
    highp vec4 diffuse = texture2D(Diffuse, texcoord0);
    highp float packedColour = diffuse.a;
    highp vec2 extracted = unpackColor4bit(packedColour);
    highp vec4 result = vec4(extracted.rg, 0.0, 1.0);
    gl_FragColor = result;
}

Raw Code (Ignore this)

This is the raw code that I wrote before typing up this article.  It’s just here as a reference in case I screwed something up that I need to fix later.

uniform highp sampler2D Diffuse;

varying highp vec2 texcoord0;

// 24 bit colour
highp float packColor(highp vec3 color) {
    return color.r + color.g * 256.0 + color.b * 256.0 * 256.0;
}

// 24 bit colour
highp vec3 unpackColor(highp float f) {
    highp vec3 color;
    color.b = floor(f / 256.0 / 256.0);
    color.g = floor((f - color.b * 256.0 * 256.0) / 256.0);
    color.r = floor(f - color.b * 256.0 * 256.0 - color.g * 256.0);
    // now we have a vec3 with the 3 components in range [0..256]. Let's normalize it!
    return color / 256.0;
}

highp vec2 unpackColor4bit(highp float f)
{
    int fIn8Bit = int(f * 255.0);
    int val = fIn8Bit;
    val /= 16;
    highp vec2 col = vec2(0.0);
    col.r = float(val) / 16.0;
    int val2 = fIn8Bit - val * 16;
    col.g = float(val2) / 16.0;

    return col;
}

highp vec2 unpackColor4bitV2(highp float f)
{
    highp vec2 col = vec2(0.0, f);
    const highp float splitter = 0.25; // 0.0625; 1/16 1/4 1/2
    while( f > splitter )
    {
        col.r += splitter;
        col.g -= splitter;
        f -= splitter;
    }
    col.r /= (1.0 - splitter); // Normalize the high bits to 15 / 15.
    col.g /= splitter;

    return col;
}

highp vec2 unpackColor1bit(highp float f)
{
    highp vec2 col = vec2(0.0);
    bool shadow = (f >= 0.5);
    col.r = float(shadow);
    col.g = f;
    if (shadow)
    {
        col.g -= 0.5;
    }
    col.g /= 0.5;
    return col;
}

highp vec2 unpackColor2bit(highp float f)
{
    const highp float splitter = 0.25;
    highp vec2 col = vec2(0.0);
    if (f >= splitter * 3.0)
    {
        col.r = 1.0;
        col.g = f - splitter * 3.0;
    }
    else if (f >= splitter * 2.0)
    {
        col.r = 0.66;
        col.g = f - splitter * 2.0;
    }
    else if (f >= splitter)
    {
        col.r = 0.33;
        col.g = f - splitter;
    }
    else
    {
        col.r = 0.0;
        col.g = f;
    }

    col.g /= splitter;
    return col;
}

void main()
{
    highp vec4 diffuse = texture2D(Diffuse, texcoord0);
    //highp float upper = diffuse.r / 16.0;
    //highp vec4 result = vec4(upper, upper, upper, 1.0);
    highp float f = diffuse.r;
    highp vec2 x = unpackColor1bit(f);
    highp vec4 result = vec4(x.rg, 0.0, 1.0);
    gl_FragColor = result;
}
Advertisements
Leave a Comment

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: