Color Swap Shader for Sprites and UI

Hey guys!

I have this color swap shader which allows me to select the R values of any image and then from a helper script pass any colors I want over colors with matching R values on a sprite. It is working perfectly on Windows / Mac / iOS and MOST android devices. For some reason it doesn’t work on my Galaxy S10 or my buddies Pixel4.

It DOES work on my S10 when using different sprites. Which is the real head scratcher. So I’m not sure if it is something to do with the shader itself, or a setting on the imported texture. I also tried including the shaders with the build shaders so they are explicitly included in the build process, but i’m not sure if that makes a difference as it still doesn’t work with the sprites I am trying to have get re-colored.

Here is the Sprite version of the color swap shader:

Shader "ColorSwap/Sprite"
{
   Properties
   {
       [PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {}
       _SwapTex("Color Data", 2D) = "transparent" {}
       _Color("Tint", Color) = (1,1,1,1)
       [MaterialToggle] PixelSnap("Pixel snap", Float) = 0
       [HideInInspector] _RendererColor("RendererColor", Color) = (1,1,1,1)
       [HideInInspector] _Flip("Flip", Vector) = (1,1,1,1)
       [PerRendererData] _AlphaTex("External Alpha", 2D) = "white" {}
       [PerRendererData] _EnableExternalAlpha("Enable External Alpha", Float) = 0
   }

       SubShader
       {
           Tags
           {
               "Queue" = "Transparent"
               "IgnoreProjector" = "True"
               "RenderType" = "Transparent"
               "PreviewType" = "Plane"
               "CanUseSpriteAtlas" = "True"
           }

           Cull Off
           Lighting Off
           ZWrite Off
           Blend One OneMinusSrcAlpha

           Pass
           {
           CGPROGRAM
               #pragma vertex vert
               #pragma fragment frag
               #pragma target 2.0
               #pragma multi_compile_instancing
               #pragma multi_compile _ PIXELSNAP_ON
               #pragma multi_compile _ ETC1_EXTERNAL_ALPHA
               #include "UnitySprites.cginc"

               v2f vert(appdata_t IN)
               {
                   v2f OUT;

                   UNITY_SETUP_INSTANCE_ID(IN);
                   UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);

               #ifdef UNITY_INSTANCING_ENABLED
                   IN.vertex.xy *= _Flip.xy;
               #endif

                   OUT.vertex = UnityObjectToClipPos(IN.vertex);
                   OUT.texcoord = IN.texcoord;
                   OUT.color = IN.color * _Color * _RendererColor;

                   #ifdef PIXELSNAP_ON
                   OUT.vertex = UnityPixelSnap(OUT.vertex);
                   #endif

                   return OUT;
               }

               sampler2D _SwapTex;

               fixed4 frag(v2f IN) : SV_Target
               {
                   fixed4 c = SampleSpriteTexture(IN.texcoord);
                   fixed4 swapCol = tex2D(_SwapTex, float2(c.x, 0));
                   fixed4 final = lerp(c, swapCol, swapCol.a);
                   final.a = c.a * IN.color.a;
                   final.rgb *= final.a * IN.color.rgb;
                   return final;
               }
           ENDCG
           }
       }
}

Here is the UI Image version of the color swap shader:

Shader "ColorSwap/Image"
{
   Properties
   {
       [PerRendererData] _MainTex("Sprite Texture", 2D) = "white" {}
       _Color("Tint", Color) = (1,1,1,1)
       _SwapTex("Color Data", 2D) = "transparent" {}
       _StencilComp("Stencil Comparison", Float) = 8
       _Stencil("Stencil ID", Float) = 0
       _StencilOp("Stencil Operation", Float) = 0
       _StencilWriteMask("Stencil Write Mask", Float) = 255
       _StencilReadMask("Stencil Read Mask", Float) = 255

       _ColorMask("Color Mask", Float) = 15

       [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip("Use Alpha Clip", Float) = 0
   }

       SubShader
       {
           Tags
           {
               "Queue" = "Transparent"
               "IgnoreProjector" = "True"
               "RenderType" = "Transparent"
               "PreviewType" = "Plane"
               "CanUseSpriteAtlas" = "True"
           }

           Stencil
           {
               Ref[_Stencil]
               Comp[_StencilComp]
               Pass[_StencilOp]
               ReadMask[_StencilReadMask]
               WriteMask[_StencilWriteMask]
           }

           Cull Off
           Lighting Off
           ZWrite Off
           ZTest[unity_GUIZTestMode]
           Blend One OneMinusSrcAlpha
           ColorMask[_ColorMask]

           Pass
           {
               Name "Default"
           CGPROGRAM
               #pragma vertex vert
               #pragma fragment frag
               #pragma target 2.0

               #include "UnityCG.cginc"
               #include "UnityUI.cginc"

               #pragma multi_compile_local _ UNITY_UI_CLIP_RECT
               #pragma multi_compile_local _ UNITY_UI_ALPHACLIP

               struct appdata_t
               {
                   float4 vertex   : POSITION;
                   float4 color    : COLOR;
                   float2 texcoord : TEXCOORD0;
                   UNITY_VERTEX_INPUT_INSTANCE_ID
               };

               struct v2f
               {
                   float4 vertex   : SV_POSITION;
                   fixed4 color : COLOR;
                   float2 texcoord  : TEXCOORD0;
                   float4 worldPosition : TEXCOORD1;
                   half4  mask : TEXCOORD2;
                   UNITY_VERTEX_OUTPUT_STEREO
               };

               sampler2D _MainTex;
               sampler2D _SwapTex;
               fixed4 _Color;
               fixed4 _TextureSampleAdd;
               float4 _ClipRect;
               float4 _MainTex_ST;
               float _UIMaskSoftnessX;
               float _UIMaskSoftnessY;

               v2f vert(appdata_t v)
               {
                   v2f OUT;
                   UNITY_SETUP_INSTANCE_ID(v);
                   UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
                   float4 vPosition = UnityObjectToClipPos(v.vertex);
                   OUT.worldPosition = v.vertex;
                   OUT.vertex = vPosition;

                   float2 pixelSize = vPosition.w;
                   pixelSize /= float2(1, 1) * abs(mul((float2x2)UNITY_MATRIX_P, _ScreenParams.xy));

                   float4 clampedRect = clamp(_ClipRect, -2e10, 2e10);
                   float2 maskUV = (v.vertex.xy - clampedRect.xy) / (clampedRect.zw - clampedRect.xy);
                   OUT.texcoord = TRANSFORM_TEX(v.texcoord.xy, _MainTex);
                   OUT.mask = half4(v.vertex.xy * 2 - clampedRect.xy - clampedRect.zw, 0.25 / (0.25 * half2(_UIMaskSoftnessX, _UIMaskSoftnessY) + abs(pixelSize.xy)));

                   OUT.color = v.color * _Color;
                   return OUT;
               }

               fixed4 frag(v2f IN) : SV_Target
               {
                   fixed4 texColor = tex2D(_MainTex, IN.texcoord);
                   fixed4 swapColor = tex2D(_SwapTex, float2(texColor.r, 0));

                   fixed4 color = IN.color * (lerp(texColor, swapColor, swapColor.a) + _TextureSampleAdd);
                   color.a = texColor.a;

                   #ifdef UNITY_UI_CLIP_RECT
                   half2 m = saturate((_ClipRect.zw - _ClipRect.xy - abs(IN.mask.xy)) * IN.mask.zw);
                   color.a *= m.x * m.y;
                   #endif

                   #ifdef UNITY_UI_ALPHACLIP
                   clip(color.a - 0.001);
                   #endif

                   color.rgb *= color.a;

                   return color;
               }
           ENDCG
           }
       }
}

Here is the basic implementation / helper script that sits on either a sprite renderer or an image to spin up and inject pre-determined colors over the R values of said base sprite:

[RequireComponent(typeof(SpriteRenderer))]

    public class ColorSprite : MonoBehaviour{

        private SpriteRenderer mSpriteRenderer;
        private Texture2D mColorSwapTex;
        private Color[] mSpriteColors;

        private void Awake(){
            mSpriteRenderer = this.GetComponent<SpriteRenderer>();
            InitColorSwapTex();
        }
      
        public void Recolor(Chicken target){
        
            List<int> R_KEY_VALUES = new List<int> { 240, 248, 252 };
            List<Color> TARGET_COLORS = new List<Color>
            {
                target.colorTheme.skinColor,
                target.colorTheme.cluckColor,
                target.colorTheme.accentColor
            };

            for(int i = 0; i < R_KEY_VALUES.Count; i++){
                SwapColor(R_KEY_VALUES[i], TARGET_COLORS[i]);
            }

            mColorSwapTex.Apply();

        }
      
        private void InitColorSwapTex(){

            Texture2D colorSwapTex = new Texture2D(256, 1, TextureFormat.RGBA32, false, false);
            colorSwapTex.filterMode = FilterMode.Point;

            for (int i = 0; i < colorSwapTex.width; ++i)
                colorSwapTex.SetPixel(i, 0, new Color(0.0f, 0.0f, 0.0f, 0.0f));

            colorSwapTex.Apply();

            mSpriteRenderer.material.SetTexture("_SwapTex", colorSwapTex);
            mSpriteColors = new Color[colorSwapTex.width];
            mColorSwapTex = colorSwapTex;

        }

        private void SwapColor(int val, Color color){
            mSpriteColors[(int)val] = color;
            mColorSwapTex.SetPixel((int)val, 0, color);
        }
    }

Also, attached are images of the import settings that WORK on my S10 and one that does NOT work on my S10.

Thank you in advance for any direction or tips you guys! @bgolus


So after much headache and several refactoring sessions, turned out that the shader and my helper class were fine.

For some reason some android devices were not seeing the proper R channel color values. I’m still not sure why, maybe something with colorspace? Either way, the ‘fix’ was changing the base spite (where the original R channels had been mapped from) - to greyscale, then generating new R values and now it works on all of my devices.

Super strange, but I guess not all devices were able to ‘see’ the exact R color values that the recolor class was looking for so it was just not coloing anything.