The Team Color Problem

Team coloring or otherwise modifying the color of part of an object is a common use case for shaders. It’s also a use case that often has a surprisingly subtle issue that is usually either missed or left unsolved.

Lets say we have a character model and we want to allow a user to modify the team color in game.

The most straight forward solution is to create multiple textures with the team color baked in. If you only have two team colors, this is likely what you’ve already done. But lets say you want to let the players choose from a range of colors. You could choose a limited palette of colors and make textures for each. Or you might decide to get fancy and use a shader to do all the work for you!

Enter the basic team color shader! All you need is the base albedo texture, and a mask texture to define where to apply a color. Most likely you or your artists already have this mask in their source files. So you have the artist save out a version of the base albedo with out the color applied, and the mask.

So, you have the textures. But what about the shader? How do you apply the color to some areas but not others based on the mask? Enter linear interpolation, or the lerp() function.

What is a linear interpolation? It just means smoothly blending from one value to another. The math for it looks like this:

// Assume Blend Factor is a value between 0.0 and 1.0
lerp(Value A, Value B, float BlendFactor)
{
return A * (1.0 - BlendFactor) + B * BlendFactor;
}

Pretty simple right? At a blend factor of 0.0 only Value A is used as Value B is multiplied by zero, and a blend factor of 1.0 only Value B is used as Value A is multiplied by zero. Everything in between is just adding the two together after they’ve been multiplied by the blend factor and inverse blend factor. This is the basic concept behind alpha blending, and your basic layer blending in Photoshop. Or Gimp. Or really any graphics application.

So lets get back to the shader. You have the base albedo texture and mask texture. You use the lerp function to blend between the uncolored albedo and the albedo multiplied by the team color with a bit of code like this:

fixed4 col = tex2D(_MainTex, i.uv);
fixed4 mask = tex2D(_TeamColorMask, i.uv);
col.rgb = lerp(col.rgb, col.rgb * _TeamColor.rgb, mask.r);

And you’re done! Everyone is happy, and you move on.


Cracks Appear

Some time later you or your artists notice something funny. A bright edge in some places where the team color mask and a darker area meet that didn’t show up in the original colored texture version.

In the areas that are nearly black, the solution is obvious; extend the color mask into the black area. But the issue shows up other places too, areas that aren’t black, so the solution isn’t so obvious. Even the cause of the problem may be illusive as the issue doesn’t exist in the original content. Maybe it’s a texture compression issue? Did you do something wrong with your shader? Most people likely just tell the artist “deal with it” and move on, and the artist expands the mask slightly or modifies the art to make it less apparent. But there is a solution!

But first, what’s the cause?

Filling In The Gaps

The crux of the issue is in lerping multiple textures each individually using bilinear filtering. If you’re unfamiliar with bilinear filtering I’ll direct you to the Wikipedia page on the topic here:

The short version is bilinear filtering is why textures look kind of blurry instead of big blocky pixels when they get really big on screen. It’s linearly interpolating between the pixel colors in two directions. Hence the name.

Bilinear Filtering

But the original texture is using bilinear filtering too, right? So why is it now a problem that you’re using multiple textures?

Lets look at a simple example. Lets say you have a blue area right next to a team colored area that you want to be the full brightness team color. That means you have a texture that looks like this:

Base Texture Albedo

The white area in that texture you want to be team colored, so you have a mask that also looks like this:

Team Color Mask

Still not too exciting. Now if you multiply the original texture with the team color and lerp between them using the mask, you get this:

Final Team Coloring

Again, not too exciting. But lets imagine the above textures are actually 2x1 pixel textures and they’re using bilinear filtering. With bilinear filtering base texture and mask really look like this:

Base Texture Albedo (Bilinear Filtering)
Team Color Mask (Bilinear Filtering)

Now you’d expect the end result to look like this:

Expected Team Coloring

But that’s not what you’ll get, instead you’ll get this:

Actual Team Coloring

You can see there’s a white-ish area between the blue and red. This is because you’re actually blending between two gradients with another gradient! Here’s a visual representation of what’s happening.

Above you can see two things causing a problem. The most obvious is that when the albedo is multiplied by the inverted mask, there’s still some white left. The second less obvious one is the team colored albedo is getting too dark from it being a gradient multiplied by a gradient. So the albedo is too light, and the colored albedo is too dark!


So what’s the solution? There are a few options. One is to stick with usually multiple pre-colored textures, but that’s no fun. Another option is to bake the textures at runtime. As long as you’re merging the textures at their original resolution then you’ll get results identical to the pre-colored textures. You’d be replicating exactly what Photoshop or Gimp would be doing this way. Some games go this route, especially on consoles and desktop as you can use runtime compression to recompress the textures and reduce the complexity of the shaders.

But what if you want a purely shader based approach? The complicated method is to do in shader bilinear filtering, sampling both the base texture and the mask at the four pixel centers. But this doesn’t work if you want to support trilinear or anisotropic filtering, and there’s an easier way.

Pre-multiplied Color

If you’ve ever done any video compositing you might be familiar with this concept. The concept behind it is to try to remove a few steps from the linear interpolation equation. Specifically the multiplication by the mask and inverse mask. If those multiplies are already applied to the base textures, then the problem of blending them together goes away. The bilinear filtered values are correct as is and need no additional handling! This also means the team color mask isn’t so much a mask as it is the base albedo texture multiplied by the mask. The short version is instead of white, anywhere you want to be team colored should be black, like this:

Base Texture Albedo Pre-multiplied
Base Texture Albedo Pre-multiplied (Bilinear Filtering)

The mask is the same as before in this case as it was solid white in both the mask and the original base texture, but otherwise you would take the original base texture and multiply it by the mask, then use that. It’s probably better to think of it more as another albedo texture than as a mask.

Team Color Albedo Mask Pre-multiplied (Bilinear Filtering)

The shader then just looks like this:

fixed4 col = tex2D(_MainTex, i.uv);
fixed4 mask = tex2D(_TeamColorMask, i.uv);
col.rgb += mask.r * _TeamColor.rgb;
Pre-multiplied Team Coloring

And there we go. No more weird bright fringes on your team color shader! Now you just have to do the same process to your character textures.

Pre-multiplied Team Coloring
Shader "Custom/Team Color (Premultiplied)" {
Properties {
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_TeamColor ("Team Color", Color) = (1,1,1,1)
[NoScaleOffset] _TeamColorMask ("Team Color Mask", 2D) = "black" {}
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
        CGPROGRAM
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0
        sampler2D _MainTex;
sampler2D _TeamColorMask;
        struct Input {
float2 uv_MainTex;
};
        fixed4 _TeamColor;
        void surf (Input IN, inout SurfaceOutputStandard o) {
fixed4 col = tex2D(_MainTex, IN.uv_MainTex);
fixed4 mask = tex2D(_TeamColorMask, IN.uv_MainTex);
            // Apply team coloring
o.Albedo = col.rgb + mask.r * _TeamColor.rgb;
}
ENDCG
}
FallBack "Diffuse"
}

Additional Thoughts

Multi-channel Mask

In my example I’m only using the red channel of the mask texture. You could use the full RGB of the mask texture if you want to be able to tint the team color slightly, or you could use the different channels for other colors. You could also pack the single channel mask into one of the unused Metallic texture channels.

Overlay Blend

Using a multiply is by far the most common way to apply a team coloring, but your artists may also want to use overlay or some other blend. This works totally fine with this pre-multiplied technique. Be mindful that if you’re using linear space color for your project you’ll need to convert your masked albedo and team color values from linear to gamma space before doing the overlay using LinearToGammaSpace(), then convert back using GammaToLinearSpace(). You could try to be smart and save a little math by setting your mask texture to disable sRGB for your linear color space project. Then you don’t have to convert the mask from linear to gamma. Don’t do this! The bilinear interpolation for an sRGB and Linear texture do not have a the texture’s color correction applied to them, they are just linearly interpolated in whatever color space you’re rendering in. If you have a Linear texture and apply the color space correction, and this will result in darkening on the edges as the interpolated values now do not match the base albedo. Granted, the darker edge is usually less offensive than the bright edge, but is technically equally wrong. You could disable sRGB on the base albedo texture too so the interpolation in both match, which would solve it.

Alpha Channel Mask

It’s common to store the mask in the base texture’s alpha channel. It’s important to know that the alpha channel is always sampled without sRGB, thus the interpolated values from bilinear sampling will be wrong just like as explained above. However this is fine for mobile or other gamma color space projects. Alternatively you might be able to pre-transform mask from sRGB to linear before storing in the alpha, but you’ll be loosing a lot of detail. Really, if you’re using Unity, there’s probably a free sRGB color channel someplace you can use, like in the metallic texture.