Cyanilux

Game Dev Blog & Tutorials

Swapping Colours

Intro

Recently I’ve seen questions about adjusting/swapping colours of textures in the shader. Depending on the use-case there’s a few methods we can use, and while there are some resources out there I thought it would be nice to write a post of my own (or at least provide examples & links to other resources for additional reading).

Shader Graph examples are used, but I’ve also provided some links to the Shader Graph docs (Node Library) where you can view generated HLSL code.

Some specific use-cases I can think of include :

Typically these methods would involve some texture(s) as an input (though some may work with noise or procedural shapes produced from signed distance functions too). It also doesn’t matter if it’s intended for a 3D model, 2D sprite, UI, etc.

Some pros/cons :

Sections


Tint

One of the simplest forms of adjusting colour is a tint, which typically involves a Multiply between our input and a given colour (Color node or property). Our input here would typically be a greyscale texture (a float value between 0 and 1). Values should ideally be close to 1 (white) so we get the full range of colour we can tint towards.

(Image)

(Grass texture from Kenney assets - Foliage Sprites)

The colour here could use HDR mode, so we have control over the intensity too. (Though that would be more common for glowy particle effects, not grass!)

You’d typically also convert the Color node here to be a color property - that way it can be exposed to the material or allow setting it from C#. But I’ll be keeping them as nodes in the post so you can clearly see what colour has been assigned.

(If you aren’t familiar with properties, see my post : Intro to Shader Graph - Properties)


Lerp

In the above example, since we are multiplying, a value of 0 (black) in the input texture always results in black - but it is possible to have control over this colour too.

Assuming our greyscale/float input ranges between 0 and 1, we could invert it using a One Minus and tint with another colour (via Multiply), then Add the two tints together.

This is the same calculation as a linear interpolation, so it’s easier to use the Lerp node. Our greyscale input here goes into the T input, and A and B are the two colour nodes/properties.

(Image)

(Also note the grass texture has been edited to contain a darker gradient)

The “background” of the preview here looks cyan as the RGB data originally contained black pixels, but we don’t need to worry as the texture also contains an alpha channel (A output, connected to Alpha port on the Master Stack). This makes those pixels transparent in the final result - shown in Main Preview, as well as in scene/game. (Assuming the graph is set to Transparent surface mode, or using Opaque & Alpha Clipping)

As a quick side note, the Lerp node is also useful for layering multiple textures / masking areas too. In this case we’d use RGBA outputs from textures rather than Color node/properties. For more info/examples see : FAQ - Layering textures/colours

The above examples are interpolating through sRGB/linear color space (depending on the colour space set under project settings, see the docs page : Linear or gamma workflow). But there are other colour spaces we could interpolate through for different results. There’s no “correct” space to use here really, it depends on the result you want. These conversions between different colour spaces would make the shader slightly more expensive though.

For example, we could interpolate in HSV space. In shader graph, this could be handled with the Colorspace Conversion node. For example compare the following results :

(Image)

The project here is using Linear colour space. Lerping between blue/yellow results in a grey transition, while lerping through Hue (X input of Vector3, since we convert from HSV space) results in more of the colour spectrum. We could also interpolate other components but in this case the Saturation stays at 0.8 and Brightness at 1.

Can see the HLSL code each conversion uses in the Node Library - Colorspace Conversion or use/see functions under render-pipelines.core/ShaderLibrary/Color.hlsl.

For a much more in-depth look at this, also see this article by Alan Zucconi : The Secrets of Colour Interpolation


Tint Color Channels

We could also extend the tint method to obtain up to 3 or 4 colours by packing multiple greyscale/float inputs in each colour channel of the texture (red, green, blue, alpha).

In some cases the alpha channel may be required for transparency so can’t be used (quite common for sprites). But for other channels we can apply a (using a Multiply with a Color node or property) and then combine those together using Add nodes (or alternatively, Maximum).

(Image)

Spaceship is an edited sprite from Kenney Assets - Space Shooter Redux

If the alpha channel isn’t required for transparency (may be more common for 3D models), then we could that for tinting too. I find it easier to have this channel inverted as we can then use the rubber/eraser tool to “paint” areas with 0 alpha, and still see coloured areas. Programs typically also don’t store RGB data in areas with 0 alpha, and while there may be ways to override this behaviour, inverting the channel is easier.

To account for this in the shader we’d use a One Minus node before the Multiply

(Image)

Another tint example where texture is applied to 3D character, contains some darker shades to fake some shading. Alpha channel is used to mask feet/shoes.


Mask Texture

Okay, but what if you don’t want to adjust the whole texture? In this case we could use another texture to mask the effect.

A common (though somewhat naïve) way to handle this is using a value of 1 (aka 255) in a channel of the mask texture (for the area you want to colour), and 0 elsewhere. This works okay with Point filter mode on the texture, but when using Bilinear (or Trilinear) it can cause white artifacts/seams to leak through around the edge. For textures on 3D models this may not be a problem if UV islands stay inside the mask (for example).

(Image)

(Male Adventurer Sprite from Kenney assets - Toon Characters 1, though edited)
Note the white edges around the shirt in the final preview :(

For details on exactly why this occurs see this article by Ben Golus : The Team Color Problem. The alternative mentioned there is using a “pre-multiplied” input & mask - or in other words, separate the area you want coloured into a second texture, leaving black pixels in the regular one :

(Image)

With these as inputs, we can then tint the mask texture and Add it to the other.

(Image)

Better method of masking the tint - no white edges!

The mask is only using the red channel here - we could use the other channels to mask other areas. Or if only the red channel is required, the texture could be imported as a “Single Channel” type (set to “Red”) to save memory. Though then you may also need a Colorspace Conversion (RGB to Linear) on the texture output, if in Linear colour space (rather than Gamma).

When importing Sprites (or Default textures where “Alpha Is Transparency”) colours in fully transparent areas can stretch out in previews in Shader Graph. Don’t be worried - this is intentional as it helps to avoid artifacts, as explained here in faq.

If you’re wondering why this hasn’t occurred in these examples, I imported the main texture without an alpha channel as I wanted the example to be clearer. But even with an alpha channel and this colour stretching, the final result should look correct provided the A output from the Sample Texture 2D is connected to the Alpha port on the Master Stack.

However, the mask texture should still stay with a black background to avoid the colours stretching out. Though if it did have an alpha channel we could instead Multiply the R and A outputs before applying the tint.


Replace Color Node

Let’s also go over the Replace Color node that Shader Graph includes… (also Color Mask, which is essentially the same thing but without providing a colour to lerp to).

1
2
3
4
5
6
7
8
9
float3 ReplaceColor(float3 In, float3 From, float3 To, float Range, float Fuzziness){
    float Distance = distance(From, In);
    return lerp(To, In, saturate((Distance - Range) / max(Fuzziness, 1e-5)));
}

float3 ColorMask(float3 In, float3 MaskColor, float Range, float Fuzziness){
    float Distance = distance(MaskColor, In);
    return saturate((Distance - Range) / max(Fuzziness, 1e-5));
}

As the name suggests, the node can be used to replace an existing colour with another. It can also select similar shades using the Range and Fuzziness inputs, but it can still pretty difficult to get it to do what you want. In practice, I’ve found it only really works well with pixel art.

(Image)

Node worked well on pixel art spaceship (that I made), but has left some edges/artifacts on the other sprite (from Kenney Assets - Space Shooter Redux). Perhaps with one of the alternative spaceship colours or edits to the sprite would make it work better - but the point is the Tint examples above produce better results and is cheaper!

One main advantage with using this method is you don’t need to edit the colours of the input texture, which may be why beginners are drawn to using it.

I’ve definitely seen a few tutorials out there using this node, but it’s actually not cheap as it calculates the distance between the In and From inputs (which uses a square root internally). For a few colours this may not be a big deal, especially if targetting high end platforms. But for mobile, the other methods in this post may work better.


Blend Node

We could also use a Blend node for different ways of mixing colours, similar to combining texture layers in image manipulation software like Photoshop, GIMP, etc. Behind the scenes these all use a Lerp but involve additional math which calculates the B input before the lerp.

The Overwrite mode in particular is directly equivalent to Out = lerp(Base, Blend, Opacity);

Some examples :

(Image)

Blend nodes using Overlay, Linear Light and Screen mode.

In this example the texture is being put into the Base layer, and a colour is used as the Blend layer. The Opacity is set to 1 for full-strength of the blend (though it can also go above 1 as the interpolation is not clamped). An Opacity of 0 will always result in the same as the Base input.

If you’re interested in the HLSL code the different blend modes use, see Node Library - Blend.


Gradient Ramp

We can also use a gradient (also sometimes known as a ramp - especially when using solid colour bands rather than interpolated colour keys) to have better control over the resulting colours from a greyscale (0-1 float) input.

This could be seen a series of remaps & lerps that combine together, which is how the Gradient object and Sample Gradient node works in Shader Graph behind the scenes. You can see some example HLSL code in the Node Library - Sample Gradient.

(Image)

However Shader Graph does not support exposing these gradient objects to the material or C# - which makes them much less useful. I have listed a few alternatives in my post : Intro to Shader Graph - Gradient, but the one I’d recommend is storing the gradient as a Texture2D and use Sample Texture 2D instead which can also been seen in the image above. Note that the texture should also be set to Clamp wrap mode.

Some examples where this specifically might be used :


Palette Texture/Array

The concept of this Gradient/Ramp could also work for pixel art, but rather than having colours interpolate, we could have neighbouring values point to completely different colours. In this case the input texture uses specific values which corresponds to the x coordinate of a texture (or index of an array) that contains our palette.

Assuming the input texture is a RGBA32 format we’ll be dealing with 8-bit channels, meaning it can store values 0-255 (though in the shader it’s still 0-1). That means 256 unique indices we could support, so the palette (texture or array) could contain up to 256 colours. (Using red channel only. If you were to use the red and green channels to sample a 2D palette, it could support up to 65536 colours, though that seems quite uncessarily large imo)

(This also assumes the input texture settings use Point filter mode and Compression : None)

Here’s an example with just 5 colours :

(Image)

Note that sRGB has been disabled on the input texture. The palette texture also uses Clamp wrap mode.

The palette texture size here is 5x1 (containing black, lightGrey, lightBlue, yellow and white)

Values stored in the input texture are multiples of 255/(5-1) (so 0, 63.8, 127.5, 191.3, and 255). The input texture here still contains an alpha channel though you could consider “fully transparent” to be another colour for the palette (so 6x1 and multiples of 255/(6-1) instead), then the input could be imported as a single-channel texture to potentially save on memory space.

One problem with this method is it’s not really easy to manually create textures storing these indices (especially with large palettes as the shades would be very close to eachother). But you could use an editor C# script to bake/generate them. I haven’t got a script to share here, but I imagine it would be something along the lines of :

If storing the palette as an array :



Thanks for reading! 😊

If this post helped, consider sharing a link with others!


License / Usage Cookies & Privacy RSS Feed