Devlog 15 - Palette Shaders

Published: October 22nd, 2021

Just wanted to do a look at a small technical thing today, so here's a post about the shader(s) that are used to render the level graphics in Rhythm Quest.

Context

I've used limited color palettes in a lot of my previous games -- in fact, this is one of the defining characteristics of most pixel art. It's also great if you're very unexperienced with color (as I used to be) as it allows you to focus more on values, and establishes a consistent and pleasing style. I started this practice back in 2014 when I made Ripple Runner, which uses 4-color palettes:


Hue Shifting

In the past, videogame consoles would only allow for using a certain number of colors at once, so sometimes games would use "palette modulation" to change the colors used in the palette, to provide a different look for different sections of the game, or for transition effects. Nowadays, Game Boy-styled games will often provide a number of different 4-color palettes for use, each with their own look and feel:


I was inspired by this aspect of old pixel art, and wanted to create color shifts inside my games. However, modern rendering doesn't actually make use of color palettes internally (colors are usually just represented as RGB values), so instead I applied a rendering effect which simply shifts the hue of all of the colors. Here's that in action for Rhythm Quest:


This was actually a built-in effect back when I was using FlashPunk, so it was incredibly easy to put in. It was as simple as this:

// (These are built-in FlashPunk classes)
var adjustFx:AdjustFX = new AdjustFX();
var fxScreen:FXImage = new FXImage();

fxScreen.effects.add(adjustFx);
addGraphic(fxScreen, -9000);

adjustFx.hue = 90;

Somehow this type of functionality has gotten =harder= to implement over the years rather than easier, so now you have to actually write a custom shader to do it. =( Yay hardware acceleration...?

My first attempt at this (fragment/pixel) shader was a flexible shader that converted each pixel from the RGB color space to the HSV color space, performed whatever shifting was desired, then converted back to RGB. I found that the operations required to do all that were too expensive for some mobile GPUs, so I ended up going with a much simpler algorithm instead that deals with only the hue alone.

Problems with Hue Shifting

That all worked "more or less fine", and I could honestly probably ship with that, but there were a couple of things that I wanted to make better. Most notably, naively shifting hue doesn't lead to the best colors. This is because of two reasons.

First, hue shifts (in HSV/HSL space) can change perceived brightness. This is due to human perceptual differences between colors -- green seems much brighter than blue for example. These two rows of colors are identical aside from hue, but look at how much brighter the top row seems:


Secondly, good palettes are formed differently depending on their hues. As a general rule, colors look more natural when brighter colors are shifted towards yellow and darker colors are shifted towards blue/purple (this mirrors how many things appear in nature). Doing a straight hue-shift doesn't really take this into account, so you can get messed-up color relations:


Palette Shader

So instead of just starting with a set of colors and then shifting the hue, I want to actually handcraft multiple different color palettes and then swap between then. The brute-force approach to this would just be to make separate texture exports for each palette, then just crossfade between them, but that's a huge headache in multiple ways, so I definitely didn't want to do that.

Instead I made a fragment/pixel shader that takes the input colors and then clamps them to a given color palette. There's a couple of different ways you could speciy the different color palettes for the shader...for example, you could just have the shader take 4 different color values as inputs, and then modify those programatically whenever you want to switch the palette. However, I chose to be a little more clever and instead have all of the color information for an entire level specified in a "palette lookup texture":


Here, each row represents a different color palette. This texture is super small, and easy to edit using something like Aseprite. And, as another advantage, I can add as many rows or colmuns as I end up needing on a case-by-case basis. For example, I might want to expand beyond 4 colors, so I'd just add some additional columns to the texture.

For doing the palette mapping, I'm having the shader just take the average RGB value of the input pixel and then mapping it to each of the 4 output colors based on that. That might be a little awkward if I decide to start having more complicated palettes with lots of colors (I probably won't...), but for now this is fine. I can just have my source graphics be grayscale and then have the shader pull from that. Here's the fragment shader code:

fixed4 frag(v2f IN) : SV_Target
{
    // Normal texture sampling, to get the input pixel color.
    fixed4 texcol = SampleSpriteTexture(IN.texcoord);
    
    // Get the average RGB value (no need to worry about perceptual brightness here)
    // We use this as the "U" coordinate in the UV texture mapping.
    fixed u = (texcol.r + texcol.g + texcol.b) * 0.333;
    
    // Convert the palette index to a "V" coordinate.
    // (_PaletteTex_TexelSize is 1 divided by the texture height)
    // Add 0.5 so we're at the "middle" of each pixel, not the edge.
    float v = _PaletteTex_TexelSize.y * (_PaletteIndex + 0.5);
    // We need to invert since v = 0 is the bottom of the texture, not the top.
    v = 1.0 - v;

    // Sample from the palette to get the final resulting color.
    texcol.rgb = tex2D(_PaletteTex, half2(u, v));

    // Apply tint like normal (mostly for alpha effects).
    return texcol * IN.color;
}

The cool part about this is that if we set the texture to use bilinear filtering, we can transition between different palettes by using fractional palette indexes! Using a _PaletteIndex of 0.5, for example, will give us colors that are halfway between palette 0 and palette 1. Nifty! Here's the final result, working with the palette texture shown above:


Again, not a huge difference from what I originally had, but it's cleaner in the ways that I mentioned before, and allows me to experiment more with creative palettes, which will probably be important as I look towards giving everything a graphical makeover. (It also ought to be super lightweight in terms of performance)

<< Back: Devlog 14 - Closed Alpha Test Feedback
>> Next: Devlog 16 - Level Graphics Experiments