//-------------------------------------------------------------------------------------------------------------------------------- // Cartoon FX // (c) 2012-2020 Jean Moreno //-------------------------------------------------------------------------------------------------------------------------------- using UnityEngine; using UnityEditor; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; // Custom material inspector for Stylized FX shaders // - organize UI using comments in the shader code // - more flexibility than the material property drawers // version 2 (dec 2017) namespace CartoonFX { public class MaterialInspector : ShaderGUI { //Set by PropertyDrawers to defined if the next properties should be visible static private Stack ShowStack = new Stack(); static public bool ShowNextProperty { get; private set; } static public void PushShowProperty(bool value) { ShowStack.Push(ShowNextProperty); ShowNextProperty &= value; } static public void PopShowProperty() { ShowNextProperty = ShowStack.Pop(); } //-------------------------------------------------------------------------------------------------- const string kGuiCommandPrefix = "//#"; const string kGC_IfKeyword = "IF_KEYWORD"; const string kGC_IfProperty = "IF_PROPERTY"; const string kGC_EndIf = "END_IF"; const string kGC_HelpBox = "HELP_BOX"; const string kGC_Label = "LABEL"; Dictionary> guiCommands = new Dictionary>(); bool initialized = false; AssetImporter shaderImporter; ulong lastTimestamp; void Initialize(MaterialEditor editor, bool force) { if((!initialized || force) && editor != null) { initialized = true; guiCommands.Clear(); //Find the shader and parse the source to find special comments that will organize the GUI //It's hackish, but at least it allows any character to be used (unlike material property drawers/decorators) and can be used along with property drawers var materials = new List(); foreach(var o in editor.targets) { var m = o as Material; if(m != null) materials.Add(m); } if(materials.Count > 0 && materials[0].shader != null) { var path = AssetDatabase.GetAssetPath(materials[0].shader); //get asset importer shaderImporter = AssetImporter.GetAtPath(path); if(shaderImporter != null) { lastTimestamp = shaderImporter.assetTimeStamp; } //remove 'Assets' and replace with OS path path = Application.dataPath + path.Substring(6); //convert to cross-platform path path = path.Replace('/', Path.DirectorySeparatorChar); //open file for reading var lines = File.ReadAllLines(path); bool insideProperties = false; //regex pattern to find properties, as they need to be counted so that //special commands can be inserted at the right position when enumerating them var regex = new Regex(@"[a-zA-Z0-9_]+\s*\([^\)]*\)"); int propertyCount = 0; bool insideCommentBlock = false; foreach(var l in lines) { var line = l.TrimStart(); if(insideProperties) { bool isComment = line.StartsWith("//"); if(line.Contains("/*")) insideCommentBlock = true; if(line.Contains("*/")) insideCommentBlock = false; //finished properties block? if(line.StartsWith("}")) break; //comment if(line.StartsWith(kGuiCommandPrefix)) { string command = line.Substring(kGuiCommandPrefix.Length).TrimStart(); //space if(string.IsNullOrEmpty(command)) AddGUICommand(propertyCount, new GC_Space()); //separator else if(command.StartsWith("---")) AddGUICommand(propertyCount, new GC_Separator()); //separator else if(command.StartsWith("===")) AddGUICommand(propertyCount, new GC_SeparatorDouble()); //if keyword else if(command.StartsWith(kGC_IfKeyword)) { var expr = command.Substring(command.LastIndexOf(kGC_IfKeyword) + kGC_IfKeyword.Length + 1); AddGUICommand(propertyCount, new GC_IfKeyword() { expression = expr, materials = materials.ToArray() }); } //if property else if(command.StartsWith(kGC_IfProperty)) { var expr = command.Substring(command.LastIndexOf(kGC_IfProperty) + kGC_IfProperty.Length + 1); AddGUICommand(propertyCount, new GC_IfProperty() { expression = expr, materials = materials.ToArray() }); } //end if else if(command.StartsWith(kGC_EndIf)) { AddGUICommand(propertyCount, new GC_EndIf()); } //help box else if(command.StartsWith(kGC_HelpBox)) { var messageType = MessageType.Error; var message = "Invalid format for HELP_BOX:\n" + command; var cmd = command.Substring(command.LastIndexOf(kGC_HelpBox) + kGC_HelpBox.Length + 1).Split(new string[] { "::" }, System.StringSplitOptions.RemoveEmptyEntries); if(cmd.Length == 1) { message = cmd[0]; messageType = MessageType.None; } else if(cmd.Length == 2) { try { var msgType = (MessageType)System.Enum.Parse(typeof(MessageType), cmd[0], true); message = cmd[1].Replace(" ", "\n"); messageType = msgType; } catch { } } AddGUICommand(propertyCount, new GC_HelpBox() { message = message, messageType = messageType }); } //label else if(command.StartsWith(kGC_Label)) { var label = command.Substring(command.LastIndexOf(kGC_Label) + kGC_Label.Length + 1); AddGUICommand(propertyCount, new GC_Label() { label = label }); } //header: plain text after command else { AddGUICommand(propertyCount, new GC_Header() { label = command }); } } else //property { if(regex.IsMatch(line) && !insideCommentBlock && !isComment) propertyCount++; } } //start properties block? if(line.StartsWith("Properties")) { insideProperties = true; } } } } } void AddGUICommand(int propertyIndex, GUICommand command) { if(!guiCommands.ContainsKey(propertyIndex)) guiCommands.Add(propertyIndex, new List()); guiCommands[propertyIndex].Add(command); } public override void AssignNewShaderToMaterial(Material material, Shader oldShader, Shader newShader) { initialized = false; base.AssignNewShaderToMaterial(material, oldShader, newShader); } public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties) { //init: //- read metadata in properties comment to generate ui layout //- force update if timestamp doesn't match last (= file externally updated) bool force = (shaderImporter != null && shaderImporter.assetTimeStamp != lastTimestamp); Initialize(materialEditor, force); var shader = (materialEditor.target as Material).shader; materialEditor.SetDefaultGUIWidths(); //show all properties by default ShowNextProperty = true; ShowStack.Clear(); for(int i = 0; i < properties.Length; i++) { if(guiCommands.ContainsKey(i)) { for(int j = 0; j < guiCommands[i].Count; j++) { guiCommands[i][j].OnGUI(); } } //Use custom properties to enable/disable groups based on keywords if(ShowNextProperty) { if((properties[i].flags & (MaterialProperty.PropFlags.HideInInspector | MaterialProperty.PropFlags.PerRendererData)) == MaterialProperty.PropFlags.None) { DisplayProperty(properties[i], materialEditor); } } } //make sure to show gui commands that are after properties int index = properties.Length; if(guiCommands.ContainsKey(index)) { for(int j = 0; j < guiCommands[index].Count; j++) { guiCommands[index][j].OnGUI(); } } //Special fields Styles.MaterialDrawSeparatorDouble(); materialEditor.RenderQueueField(); materialEditor.EnableInstancingField(); } virtual protected void DisplayProperty(MaterialProperty property, MaterialEditor materialEditor) { float propertyHeight = materialEditor.GetPropertyHeight(property, property.displayName); Rect controlRect = EditorGUILayout.GetControlRect(true, propertyHeight, EditorStyles.layerMaskField, new GUILayoutOption[0]); materialEditor.ShaderProperty(controlRect, property, property.displayName); } } // Same as Toggle drawer, but doesn't set any keyword // This will avoid adding unnecessary shader keyword to the project internal class MaterialToggleNoKeywordDrawer : MaterialPropertyDrawer { private static bool IsPropertyTypeSuitable(MaterialProperty prop) { return prop.type == MaterialProperty.PropType.Float || prop.type == MaterialProperty.PropType.Range; } public override float GetPropertyHeight(MaterialProperty prop, string label, MaterialEditor editor) { float height; if (!MaterialToggleNoKeywordDrawer.IsPropertyTypeSuitable(prop)) { height = 40f; } else { height = base.GetPropertyHeight(prop, label, editor); } return height; } public override void OnGUI(Rect position, MaterialProperty prop, GUIContent label, MaterialEditor editor) { if (!MaterialToggleNoKeywordDrawer.IsPropertyTypeSuitable(prop)) { EditorGUI.HelpBox(position, "Toggle used on a non-float property: " + prop.name, MessageType.Warning); } else { EditorGUI.BeginChangeCheck(); bool flag = Mathf.Abs(prop.floatValue) > 0.001f; EditorGUI.showMixedValue = prop.hasMixedValue; flag = EditorGUI.Toggle(position, label, flag); EditorGUI.showMixedValue = false; if (EditorGUI.EndChangeCheck()) { prop.floatValue = ((!flag) ? 0f : 1f); } } } } // Same as KeywordEnum drawer, but uses the keyword supplied as is rather than adding a prefix to them internal class MaterialKeywordEnumNoPrefixDrawer : MaterialPropertyDrawer { private readonly GUIContent[] labels; private readonly string[] keywords; public MaterialKeywordEnumNoPrefixDrawer(string lbl1, string kw1) : this(new[] { lbl1 }, new[] { kw1 }) { } public MaterialKeywordEnumNoPrefixDrawer(string lbl1, string kw1, string lbl2, string kw2) : this(new[] { lbl1, lbl2 }, new[] { kw1, kw2 }) { } public MaterialKeywordEnumNoPrefixDrawer(string lbl1, string kw1, string lbl2, string kw2, string lbl3, string kw3) : this(new[] { lbl1, lbl2, lbl3 }, new[] { kw1, kw2, kw3 }) { } public MaterialKeywordEnumNoPrefixDrawer(string lbl1, string kw1, string lbl2, string kw2, string lbl3, string kw3, string lbl4, string kw4) : this(new[] { lbl1, lbl2, lbl3, lbl4 }, new[] { kw1, kw2, kw3, kw4 }) { } public MaterialKeywordEnumNoPrefixDrawer(string lbl1, string kw1, string lbl2, string kw2, string lbl3, string kw3, string lbl4, string kw4, string lbl5, string kw5) : this(new[] { lbl1, lbl2, lbl3, lbl4, lbl5 }, new[] { kw1, kw2, kw3, kw4, kw5 }) { } public MaterialKeywordEnumNoPrefixDrawer(string lbl1, string kw1, string lbl2, string kw2, string lbl3, string kw3, string lbl4, string kw4, string lbl5, string kw5, string lbl6, string kw6) : this(new[] { lbl1, lbl2, lbl3, lbl4, lbl5, lbl6 }, new[] { kw1, kw2, kw3, kw4, kw5, kw6 }) { } public MaterialKeywordEnumNoPrefixDrawer(string[] labels, string[] keywords) { this.labels= new GUIContent[keywords.Length]; this.keywords = new string[keywords.Length]; for (int i = 0; i < keywords.Length; ++i) { this.labels[i] = new GUIContent(labels[i]); this.keywords[i] = keywords[i]; } } static bool IsPropertyTypeSuitable(MaterialProperty prop) { return prop.type == MaterialProperty.PropType.Float || prop.type == MaterialProperty.PropType.Range; } void SetKeyword(MaterialProperty prop, int index) { for (int i = 0; i < keywords.Length; ++i) { string keyword = GetKeywordName(prop.name, keywords[i]); foreach (Material material in prop.targets) { if (index == i) material.EnableKeyword(keyword); else material.DisableKeyword(keyword); } } } public override float GetPropertyHeight(MaterialProperty prop, string label, MaterialEditor editor) { if (!IsPropertyTypeSuitable(prop)) { return EditorGUIUtility.singleLineHeight * 2.5f; } return base.GetPropertyHeight(prop, label, editor); } public override void OnGUI(Rect position, MaterialProperty prop, GUIContent label, MaterialEditor editor) { if (!IsPropertyTypeSuitable(prop)) { EditorGUI.HelpBox(position, "Toggle used on a non-float property: " + prop.name, MessageType.Warning); return; } EditorGUI.BeginChangeCheck(); EditorGUI.showMixedValue = prop.hasMixedValue; var value = (int)prop.floatValue; value = EditorGUI.Popup(position, label, value, labels); EditorGUI.showMixedValue = false; if (EditorGUI.EndChangeCheck()) { prop.floatValue = value; SetKeyword(prop, value); } } public override void Apply(MaterialProperty prop) { base.Apply(prop); if (!IsPropertyTypeSuitable(prop)) return; if (prop.hasMixedValue) return; SetKeyword(prop, (int)prop.floatValue); } // Final keyword name: property name + "_" + display name. Uppercased, // and spaces replaced with underscores. private static string GetKeywordName(string propName, string name) { // Just return the supplied name return name; // Original code: /* string n = propName + "_" + name; return n.Replace(' ', '_').ToUpperInvariant(); */ } } //================================================================================================================================================================================================ // GUI Commands System // // Workaround to Material Property Drawers limitations: // - uses shader comments to organize the GUI, and show/hide properties based on conditions // - can use any character (unlike property drawers) // - parsed once at material editor initialization internal class GUICommand { public virtual bool Visible() { return true; } public virtual void OnGUI() { } } internal class GC_Separator : GUICommand { public override void OnGUI() { if(MaterialInspector.ShowNextProperty) Styles.MaterialDrawSeparator(); } } internal class GC_SeparatorDouble : GUICommand { public override void OnGUI() { if(MaterialInspector.ShowNextProperty) Styles.MaterialDrawSeparatorDouble(); } } internal class GC_Space : GUICommand { public override void OnGUI() { if(MaterialInspector.ShowNextProperty) GUILayout.Space(8); } } internal class GC_HelpBox : GUICommand { public string message { get; set; } public MessageType messageType { get; set; } public override void OnGUI() { if(MaterialInspector.ShowNextProperty) Styles.HelpBoxRichText(message, messageType); } } internal class GC_Header : GUICommand { public string label { get; set; } GUIContent guiContent; public override void OnGUI() { if(guiContent == null) guiContent = new GUIContent(label); if(MaterialInspector.ShowNextProperty) Styles.MaterialDrawHeader(guiContent); } } internal class GC_Label : GUICommand { public string label { get; set; } GUIContent guiContent; public override void OnGUI() { if(guiContent == null) guiContent = new GUIContent(label); if(MaterialInspector.ShowNextProperty) GUILayout.Label(guiContent); } } internal class GC_IfKeyword : GUICommand { public string expression { get; set; } public Material[] materials { get; set; } public override void OnGUI() { bool show = ExpressionParser.EvaluateExpression(expression, (string s) => { foreach(var m in materials) { if(m.IsKeywordEnabled(s)) return true; } return false; }); MaterialInspector.PushShowProperty(show); } } internal class GC_EndIf : GUICommand { public override void OnGUI() { MaterialInspector.PopShowProperty(); } } internal class GC_IfProperty : GUICommand { string _expression; public string expression { get { return _expression; } set { _expression = value.Replace("!=", "<>"); } } public Material[] materials { get; set; } public override void OnGUI() { bool show = ExpressionParser.EvaluateExpression(expression, EvaluatePropertyExpression); MaterialInspector.PushShowProperty(show); } bool EvaluatePropertyExpression(string expr) { //expression is expected to be in the form of: property operator value var reader = new StringReader(expr); string property = ""; string op = ""; float value = 0f; int overflow = 0; while(true) { char c = (char)reader.Read(); //operator if(c == '=' || c == '>' || c == '<' || c == '!') { op += c; //second operator character, if any char c2 = (char)reader.Peek(); if(c2 == '=' || c2 == '>') { reader.Read(); op += c2; } //end of string is the value var end = reader.ReadToEnd(); if(!float.TryParse(end, out value)) { Debug.LogError("Couldn't parse float from property expression:\n" + end); return false; } break; } //property name property += c; overflow++; if(overflow >= 9999) { Debug.LogError("Expression parsing overflow!\n"); return false; } } //evaluate property bool conditionMet = false; foreach(var m in materials) { float propValue = 0f; if(property.Contains(".x") || property.Contains(".y") || property.Contains(".z") || property.Contains(".w")) { string[] split = property.Split('.'); string component = split[1]; switch(component) { case "x": propValue = m.GetVector(split[0]).x; break; case "y": propValue = m.GetVector(split[0]).y; break; case "z": propValue = m.GetVector(split[0]).z; break; case "w": propValue = m.GetVector(split[0]).w; break; default: Debug.LogError("Invalid component for vector property: '" + property + "'"); break; } } else propValue = m.GetFloat(property); switch(op) { case ">=": conditionMet = propValue >= value; break; case "<=": conditionMet = propValue <= value; break; case ">": conditionMet = propValue > value; break; case "<": conditionMet = propValue < value; break; case "<>": conditionMet = propValue != value; break; //not equal, "!=" is replaced by "<>" to prevent bug with leading ! ("not" operator) case "==": conditionMet = propValue == value; break; default: Debug.LogError("Invalid property expression:\n" + expr); break; } if(conditionMet) return true; } return false; } } }