My take on shaders: Gradient Mapper

Yes, this is a horrible featured image. I know. Let’s move on.

Recently, I came across a small workflow problem as I was playing around with gradient textures. While those are small images and really easy to create on a program like Photoshop, I’d still need to go back and forth from Photoshop to Unity to see the outcome. On the other hand, Unity has a really cool gradient editor which, however, is not easily exposed in, say, a custom material editor (from what I know of it at least). It was torturous to have a simple shader that uses gradient textures and a cool editor for gradients and not have a built-in way to easily combine the two on runtime. After watching the GDC talk about the art of “Old Man’s Journey”, in which they used a similar technique for gradient mapping, I decided to make a small tool to help with this minor inconvenience.

Therefore, this post will be somewhat different. While I will demonstrate a small sprite shader that uses gradient mapping, I will also give out a script that creates gradient textures and applies them on runtime to that shader to instantly see the changes. While this script is made to work with that sprite shader, it can easily be modified to work with other effects like the “Firewatch” fog, and the generated gradient textures can obviously be used anywhere.

The sprite gradient map shader

Let’s start with the shader:

Shader "Unlit/SpriteGradientMap"
{
	 Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _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

		_GradientMap ("Gradient map", 2D) = "white" {}
    }

    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 _GradientMap;

			fixed4 frag(v2f IN) : SV_Target
			{
				fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color;
				float grayscale = dot(c.rgb, float3(0.3, 0.59, 0.11));
				fixed4 gradientCol = tex2D(_GradientMap, float2(grayscale, 0));
				return gradientCol * c.a * IN.color;
			}

        ENDCG
        }
    }

Fallback "Transparent/VertexLit"
}

Now, for a “simple sprite shader”, it certainly seems to have a bunch of stuff that we haven’t seen in other shaders. And that’s because this is a sprite shader which, in contrast to the latest built-in sprite shader, has the vertex and fragment functions exposed for us to mess with. Therefore, I won’t go into all of that and just focus on what’s important: the fragment shader.

I’ve added a new 2D property for the gradient texture, called “_GradientMap” which, as always, I redeclared before the fragment function. In line 71 I get the color of the sprite, using the “SampleSpriteTexture” method, which is declared in UnitySprites.cginc. Then, in line 72 I get the grayscale value of the sprite color using a dot product and, in line 73, I use the grayscale value to sample the gradient texture accordingly and get a color value from it, just like in the “Firewatch multi-colored fog” shader. I then return the sampled color, multiplied by the alpha channel of the original color, to get the sprite transparency, as well as the vertex color given to the shader via the sprite renderer.

That’s it pretty much from the shader. You can easily expand this by, for example, considering the alpha channel of the gradient map as a factor which determines the contribution of the gradient mapping effect, but for now I wanted to keep it as simple as possible.

The gradient mapper

This is the code for the tool to test gradient maps on runtime and then save them:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

#if UNITY_EDITOR
using UnityEditor;
#endif

[ExecuteInEditMode]
public class GradientMapper : MonoBehaviour {

	[Header("Gradient map parameters")]
	public Vector2Int gradientMapDimensions = new Vector2Int(128, 32);
	public Gradient gradient;

	[Header("Enable testing")]
	public bool testing = false;

	private SpriteRenderer spriteRenderer;
	private Material material;

	[HideInInspector]
	public Texture2D texture;

	public static int totalMaps = 0;

	private void OnEnable() {
		spriteRenderer = GetComponent<SpriteRenderer>();
		if (spriteRenderer == null) {
			Debug.LogWarning("No sprite renderer on this game object! Removing GradientMapper");
			DestroyImmediate(this);
		} else {
			material = spriteRenderer.sharedMaterial;
		}
	}

	void Update () {
		if (testing) {
			texture = new Texture2D (gradientMapDimensions.x, gradientMapDimensions.y);
			texture.wrapMode = TextureWrapMode.Clamp;
			for (int x = 0; x < gradientMapDimensions.x; x++) {
				Color color = gradient.Evaluate ((float) x / (float) gradientMapDimensions.x);
				for (int y = 0; y < gradientMapDimensions.y; y++) {
					texture.SetPixel (x, y, color);
				}
			}
			texture.Apply ();
			if (material.HasProperty ("_GradientMap")) {
				material.SetTexture ("_GradientMap", texture);
			}
		}
	}

}

#if UNITY_EDITOR
[CustomEditor(typeof(GradientMapper))]
public class GradientMapperEditor : Editor {

	 public override void OnInspectorGUI () {
		GradientMapper gradientMapper = target as GradientMapper;
		DrawDefaultInspector();
		if (gradientMapper.testing) {
			EditorGUILayout.HelpBox("Testing is active.", MessageType.Warning, true);
		}
		if (GUILayout.Button("Make Gradient Map")) {
			gradientMapper.testing = false;
			if (!AssetDatabase.IsValidFolder ("Assets/GradientMaps")) {
				AssetDatabase.CreateFolder ("Assets/", "GradientMaps");
				AssetDatabase.SaveAssets ();
			}
			if (!Directory.Exists (Application.dataPath + "GradientMaps")) {
				Directory.CreateDirectory (Application.dataPath + "/GradientMaps/");
				GradientMapper.totalMaps = 0;
			} else {
				GradientMapper.totalMaps = Directory.GetFiles (Application.dataPath + "/GradientMaps/").Length;
			}

			byte[] bytes = gradientMapper.texture.EncodeToPNG ();
			while (File.Exists (Application.dataPath + "/GradientMaps/gradient_map_" + GradientMapper.totalMaps.ToString () + ".png")) {
				GradientMapper.totalMaps++;
			}
			File.WriteAllBytes (Application.dataPath + "/GradientMaps/gradient_map_" + GradientMapper.totalMaps.ToString () + ".png", bytes);
			AssetDatabase.Refresh ();

			Debug.Log ("Gradient map saved!");
		}
	}
}
#endif

Honestly, nothing groundbreaking is happening here. A high-level description of the tool would be this:

  • The script gets the material of the sprite renderer of the object to which it’s attached. If the script cannot find a sprite renderer it destroys itself.
  • When the bool “testing” is true, in which case there will also be a message box making it clearer that testing is active, the script will generate a new gradient texture every frame based on the “gradient” object, and apply the texture to the material of the sprite renderer.
  • A button with the label “Make Gradient Map” is rendered on the bottom. Whenever that’s pressed, the script will create the actual image file for the gradient map and store it in “Assets/GradientMaps” with the name “gradient_map_x”, where x is the number of maps created so far. If there is no such folder, the script creates it.

Conclusion

This was less of a tutorial and more of a freebie, to be honest. But I found that this kind of shader, paired with a tool like that, can provide a pretty good workflow for an artist and enable more experimentation. Hopefully this can serve as a nice reference to some!

See you in the next one!




Disclaimer

The code in the shader tutorials is under the CC0 license, so you can freely use it in your commercial or non-commercial projects without the need for any attribution/credits.

These posts will never go behind a paywall. But if you really really super enjoyed this post, consider buying me a coffee, or becoming my patron to get exciting benefits!

Become a Patron!

Or don't, I'm not the boss of you.