Shade More Efficiently

Martin Palko Tutorials, Unity

Target Audience: Intermediate devs, comfortable with both creating shaders, and scripting in Unity.

Implementation: Unity 4

Last Updated: Jan 2014

Note: All example scripts in this tutorial are written in c#, but the same effects can be accomplished in both java and boo.

Index:

The Big Idea

Multi-Compile shaders are something unity has used internally since the beginning, but has recently (as of version 4.1.0) made practical to use in high level surface shaders with the ability to extend the material editor class. What this allows you to do is define a feature that you would like to toggle on and off with a preprocessor directive like:

#pragma multi_compile FEATURE_OFF FEATURE_ON

And then in your shader, you can use a preprocessor conditional statement to dictate what to do if the feature is on or off.

    o.Albedo = float3(0,0,1,1);
    #if FEATURE_ON
    // nonsensical feature example code here.
    o.Albedo += float3(1,0,0,0);
    #endif

What this will do is actually compile two shaders. If the material has “FEATURE_OFF
defined, it will use a shader that looks like this:

    o.Albedo = float3(0,0,1,1);

And if it has “FEATURE_ON
defined, it will use a shader that looks like this:

    o.Albedo = float3(0,0,1,1);
    o.Albedo += float3(1,0,0,0);

So why would you want to do this? It means that instead of writing many seperate shaders for each specific case, you can write a few generalized, but customizable shaders, and re-use them throughout your project. You also gain a performance benefit over using one shader which may or may not be taking advantage of all of it’s features (A shader with a fresnel effect set to 0 is still doing a fresnel calculation, and then discarding it).

A Basic Implementation

So let’s apply this to something that we might actually want to do in a real world scenario, like enabling or disabling a normal map in our shader.

    Shader "Multi-CompileTutorial/NormalToggle"
{
	Properties
	{
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_NormalMap ("Normal Map", 2D) = "bump" {}
	}

	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 300

		CGPROGRAM
		#pragma surface surf Lambert
		#pragma multi_compile NORMALMAP_ON NORMALMAP_OFF

		sampler2D _MainTex;
		sampler2D _NormalMap;

		struct Input
		{
			float2 uv_MainTex;
			float2 uv_NormalMap;
		};

		void surf (Input IN, inout SurfaceOutput o)
		{
			fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
			o.Albedo = c.rgb;
			o.Alpha = c.a;
			#if NORMALMAP_ON
			o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap));
			#endif
		}
		ENDCG
	}
	CustomEditor "NormalToggleInspector"
}

This is a simple surface shader that samples from a diffuse and normal map, but the normal sample and unpacking has been put inside a precompiler if statement, so if NORMALMAP_ON is not defined, it won’t sample from the normal map. Having no normal map assigned would have the same effect visually, but the shader would still do a texture lookup, and and run the UnpackNormal function.

Now we need to provide a means to toggle between NORMALMAP_ON and NORMALMAP_OFF. If you look at the second last line of the previous shader, you can see that it’s referencing a custom editor. This is the name of the c# class that we want to use in place of unity’s default material editor.

Note: This script needs to be inside a folder within your unity project named “Editor” for it to be recognized by unity as an editor script.

C#:

using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.Linq;

public class NormalToggleInspector : MaterialEditor
{
	public override void OnInspectorGUI ()
	{
		// Draw the default inspector.
		base.OnInspectorGUI ();

		// If we are not visible, return.
		if (!isVisible)
			return;

		// Get the current keywords from the material
		Material targetMat = target as Material;
		string[] keyWords = targetMat.shaderKeywords;

		// Check to see if the keyword NORMALMAP_ON is set in the material.
		bool normalEnabled = keyWords.Contains ("NORMALMAP_ON");
		EditorGUI.BeginChangeCheck();
		// Draw a checkbox showing the status of normalEnabled
		normalEnabled = EditorGUILayout.Toggle ("Normal Enabled", normalEnabled);
		// If something has changed, update the material.
		if (EditorGUI.EndChangeCheck())
		{
			// If our normal is enabled, add keyword NORMALMAP_ON, otherwise add NORMALMAP_OFF
			List<string> keywords = new List<string> { normalEnabled ? "NORMALMAP_ON" : "NORMALMAP_OFF"};
			targetMat.shaderKeywords = keywords.ToArray ();
			EditorUtility.SetDirty (targetMat);
		}
	}
}

Now if you throw our newly created shader into a material, you should see a shiny new tickbox, and if you throw a normal map on, toggling it should enable and disable the normal map. While it appears that you’re just turning the normal map on and off, you’re actually flipping between two seperate compiled shaders, how neat is that!?

NormalToggleInspector

A More Flexible Implementation

While that’s the implementation that’s given in the unity documentation, it has some major drawbacks.

  • Time consuming to add multiple feature toggles in the editor, since they need to be manually coded in.
  • Implementing toggles for additional shaders requires copy-pasting code for the inspectors.
  • All shader properties are shown at all times (even if the normal map is disabled, the texture property is still visible) making it unintuitive and possibly confusing for artists to use.

My solution was to create an abstract material editor base class that handles the grouping, and hiding of properties that are linked to a toggle automatically. What I wanted to accomplish was this:

  • Group properties by what toggleable feature they are used with. (Normal map should display below the “Normal Enabled” check box.)
  • Hide properties that are used with a toggleable feature if they are currently disabled. (Normal map property should not be visible if normals are not enabled.)
  • Implement additional toggles and properties within existing material editors, and create new material editors for new shaders with minimal hassle.

The code below looks a bit hectic, but basically whats going on is this:

  1. Creating a list of “FeatureToggles”, which represent a toggleable feature, and composed of:
    • A name that will be displayed in the editor.
    • A string that is used to determine which properties are owned by this toggle. (Normal Enabled might use the world “normal”, so any properties that contain the world “normal” in their description would be owned by this toggle.)
    • The keywords in the shader when this property is enabled or disabled (NORMALMAP_ON NORMALMAP_OFF)
    • The whether the toggle is enabled or not.
  2. Draw all the shader properties that are not “owned” by a toggle.
  3. For each toggle, first draw the toggle, and then if the toggle is enabled, draw all the properties that it owns.
  4. If any toggles have been changed, compile a new array of keywords, using the _ON or _OFF version depending on the toggle’s state.

C#:

using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.Linq;
using System.Text.RegularExpressions;

public abstract class CustomMaterialEditor : MaterialEditor
{
	public class FeatureToggle
	{
		// The name the toggle will have in the inspector.
		public string InspectorName;
		// We will look for properties that contain this word, and hide them if we're not enabled.
		public string InspectorPropertyHideTag;
		// The keyword that the shader uses when this feature is enabled or disabled.
		public string ShaderKeywordEnabled;
		public string ShaderKeywordDisabled;
		// The current state of this feature.
		public bool Enabled;

		public FeatureToggle(string InspectorName, string InspectorPropertyHideTag, string ShaderKeywordEnabled, string ShaderKeywordDisabled)
		{
			this.InspectorName = InspectorName;
			this.InspectorPropertyHideTag = InspectorPropertyHideTag;
			this.ShaderKeywordEnabled = ShaderKeywordEnabled;
			this.ShaderKeywordDisabled = ShaderKeywordDisabled;
			this.Enabled = false;
		}
	}

	// A list of all the toggles that we have in this material editor.
	protected List<FeatureToggle> Toggles = new List<FeatureToggle>();
	// This function will be implemented in derived classes, and used to populate the list of toggles.
	protected abstract void CreateToggleList();	

	public override void OnInspectorGUI ()
	{
		// if we are not visible... return
		if (!isVisible)
			return;

		// Get the current keywords from the material
		Material targetMat = target as Material;
		string[] oldKeyWords = targetMat.shaderKeywords;

		// Populate our list of toggles
		//Toggles.Clear();
		Toggles = new List<FeatureToggle>();
		CreateToggleList();

		// Update each toggle to enabled if it's enabled keyword is present. If it's enabled keyword is missing, we assume it's disabled.
		for(int i = 0; i < Toggles.Count; i++)
		{
			Toggles[i].Enabled = oldKeyWords.Contains (Toggles[i].ShaderKeywordEnabled);
		}

		// Begin listening for changes in GUI, so we don't waste time re-applying settings that haven't changed.
		EditorGUI.BeginChangeCheck();

		serializedObject.Update ();
		var theShader = serializedObject.FindProperty ("m_Shader");
		if (isVisible && !theShader.hasMultipleDifferentValues &&; theShader.objectReferenceValue != null)
		{
			float controlSize = 64;
			EditorGUIUtility.labelWidth = Screen.width - controlSize - 20;
			EditorGUIUtility.fieldWidth = controlSize;

			Shader shader = theShader.objectReferenceValue as Shader;

			EditorGUI.BeginChangeCheck();

			// Draw Non-toggleable values
			for (int i = 0; i < ShaderUtil.GetPropertyCount(shader); i++)
			{
				ShaderPropertyImpl(shader, i, null);
			}
			// Draw toggles, then their values.
			for (int s = 0; s < Toggles.Count; s++)
			{
				EditorGUILayout.Separator();
				Toggles[s].Enabled = EditorGUILayout.BeginToggleGroup(Toggles[s].InspectorName, Toggles[s].Enabled);

				if (Toggles[s].Enabled)
				{
					for (int i = 0; i < ShaderUtil.GetPropertyCount(shader); i++)
					{
						ShaderPropertyImpl(shader, i, Toggles[s]);
					}
				}
				EditorGUILayout.EndToggleGroup();
			}

			if (EditorGUI.EndChangeCheck())
				PropertiesChanged ();
		}

		// If changes have been made, then apply them.
		if (EditorGUI.EndChangeCheck())
		{
			// New list of key words.
			List<string> newKeyWords = new List<string>();

			// If true, add the enabled keyword (ending with _ON), if false, add the disabled keyword(ending with _OFF).
			for(int i = 0; i < Toggles.Count; i++)
			{
				newKeyWords.Add(Toggles[i].Enabled ? Toggles[i].ShaderKeywordEnabled : Toggles[i].ShaderKeywordDisabled);
			}

			// Send the new list of keywords to the material, this will define what version of the shader to use.
			targetMat.shaderKeywords = newKeyWords.ToArray ();
			EditorUtility.SetDirty (targetMat);
		}
	}

	// This runs once for every property in our shader.
	private void ShaderPropertyImpl(Shader shader, int propertyIndex, FeatureToggle currentToggle)
	{
		string propertyDescription = ShaderUtil.GetPropertyDescription(shader, propertyIndex);

		// If current toggle is null, we only want to show properties that aren't already "owned" by a toggle,
		// so if it is owned by another toggle, then return.
		if (currentToggle == null)
		{
			for (int i = 0; i < Toggles.Count; i++)
 			{
 				if (Regex.IsMatch(propertyDescription, Toggles[i].InspectorPropertyHideTag , RegexOptions.IgnoreCase))
 				{
 					return;
 				}
 			}
 		}
 		// Only draw if we the current property is owned by the current toggle.
 		else if (!Regex.IsMatch(propertyDescription, currentToggle.InspectorPropertyHideTag , RegexOptions.IgnoreCase))
 		{
 			return;
 		}
 		// If we've gotten to this point, draw the shader property regulairly.
 		ShaderProperty(shader,propertyIndex);
 	}
}

Now, our
Normal Toggle inspector will just look like this:

C#:

using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
public class NormalToggleInspector : CustomMaterialEditor
{
	protected override void CreateToggleList()
	{	
 		Toggles.Add(new FeatureToggle("Normal Enabled","normal","NORMALMAP_ON","NORMALMAP_OFF"));
	}
}

If you check out the inspector for our normal toggle shader, it should now show the normal map underneath our toggle, and if you uncheck it, it should hide.

NormalToggleInspector2

Usage Examples

Here, I’ve expanded upon the normal toggle shader, and added toggleable specularity, fresnel, and rim lighting.

Shader "Multi-CompileTutorial/MoreToggles"
{
	Properties
	{
		_DiffuseColor ("Diffuse Colour",color) = (1.0,1.0,1.0,1.0)
		_DiffuseMultiply ("Diffuse Brightness",float) = 1.0
		_DiffuseMap ("Diffuse (RGB)", 2D) = "white" {}

		_NormalMap ("Normal Map(RGB)", 2D) = "bump" {}

		_SpecColor ("Specular Color", Color) = (0.5, 0.5, 0.5, 1)
		_SpecularMultiply ("Specular Brightness",float) = 1.0
		_SpecAdd ("Specular Boost", float) = 0
		_SpecMap ("Specular Map (RGB)", 2D) = "grey" {}
		_Gloss ("Specular Glossiness", float) = 0.5

		_FresnelPower ("Fresnel Power",float) = 1.0
		_FresnelMultiply ("Fresnel Multiply", float) = 0.2
		_FresnelBias ("Fresnel Bias", float) = -0.1	

		_RimPower ("RimLight Power",float) = 1.0
		_RimMultiply ("RimLight Multiply", float) = 0.2
		_RimBias ("RimLight Bias", float) = 0
	}

	SubShader
	{
		Tags { "RenderType"="Opaque" }
		LOD 300

		CGPROGRAM
		#pragma surface surf BlinnPhong
		#pragma target 3.0
		#pragma multi_compile NORMALMAP_ON NORMALMAP_OFF
		#pragma multi_compile SPECULAR_ON SPECULAR_OFF
		#pragma multi_compile FRESNEL_ON FRESNEL_OFF
		#pragma multi_compile RIMLIGHT_ON RIMLIGHT_OFF

		float3 _DiffuseColor;
		float _DiffuseMultiply;
		sampler2D _DiffuseMap;

		sampler2D _NormalMap;		

		float _SpecularMultiply;
		float _SpecAdd;
		sampler2D _SpecMap;

		float _Gloss;

		float _FresnelPower;
		float _FresnelMultiply;
		float _FresnelBias;

		float _RimPower;
		float _RimMultiply;
		float _RimBias;

		struct Input
		{
			float2 uv_DiffuseMap;
			#if SPECULAR_ON
			float2 uv_SpecMap;
			#endif
			#if NORMALMAP_ON
			float2 uv_NormalMap;
			#endif
			#if FRESNEL_ON || RIMLIGHT_ON
			float3 viewDir;
			#endif
		};

		void surf (Input IN, inout SurfaceOutput o)
		{
			o.Albedo.rgb = _DiffuseMultiply * _DiffuseColor.rgb * tex2D (_DiffuseMap, IN.uv_DiffuseMap);

			#if SPECULAR_ON
			o.Specular = _Gloss;
			o.Gloss = _SpecAdd + _SpecularMultiply * tex2D (_SpecMap, IN.uv_SpecMap);
			#endif

			#if NORMALMAP_ON
			o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap));
			#endif

			#if FRESNEL_ON && SPECULAR_ON || RIMLIGHT_ON
			float facing = saturate(1.0 - max(dot( normalize(IN.viewDir.xyz), normalize(o.Normal)), 0.0));		

				#if FRESNEL_ON && SPECULAR_ON
				float fresnel = max(_FresnelBias + (1.0-_FresnelBias) * pow(facing, _FresnelPower), 0);
				fresnel = fresnel * o.Specular * _FresnelMultiply;
				o.Gloss *= 1+fresnel;
				#endif			

				#if RIMLIGHT_ON
				float rim = max(_RimBias + (1.0-_RimBias) * pow(facing, _RimPower), 0);
				rim = rim * o.Specular * _RimMultiply;
				o.Albedo *= 1+rim;
				#endif
			#endif
		}
		ENDCG
	}
	CustomEditor "MoreTogglesInspector"
}

And accompanying inspector:

C#:

using System.Collections.Generic;
using UnityEngine;
using UnityEditor; 

public class MoreTogglesInspector : CustomMaterialEditor
{
	protected override void CreateToggleList()
 	{
		Toggles.Add(new FeatureToggle("Normal Enabled","normal","NORMALMAP_ON","NORMALMAP_OFF"));
 		Toggles.Add(new FeatureToggle("Specular Enabled","specular","SPECULAR_ON","SPECULAR_OFF"));
 		Toggles.Add(new FeatureToggle("Fresnel Enabled","fresnel","FRESNEL_ON","FRESNEL_OFF"));
 		Toggles.Add(new FeatureToggle("Rim Light Enabled","rim","RIMLIGHT_ON","RIMLIGHT_OFF"));
 	}
}

The inspector interface will now look like this, and you can enable or disable the effects in any combination.

MoreToggles

Keep in mind that unity will compile a shader for every possible combination of toggles. So one toggle will compile 2 shaders, while two toggles will compile 4, and ten toggles will compile to a whopping 1024 shader variants, so it’s best to keep the toggles to broad features instead of individual settings.