首页 / 资讯中心 / 文章详情

Unity Shader编辑器自定义ToolTip实现方案

Unity Shader编辑器自定义ToolTip实现方案 ★ FEATURED ARTICLE
1. 为什么要在Shader编辑器里加ToolTip如果你写过自定义的Shader编辑器大概率遇到过这种尴尬美术同学跑过来问“这个_MainTex_ST到底填几个数”“_Glossiness的取值范围是多少”你只能翻出代码一行行指给他看。更麻烦的是团队里新人接手你的Shader时面对一堆_Param1、_Param2完全不知道哪个参数控制高光、哪个控制法线强度。Unity原生的ShaderInspector其实已经内置了ToolTip机制——鼠标悬停在属性名上会弹出提示框。但当你用ShaderGUI或者MaterialPropertyDrawer做自定义编辑器时这个机制默认就失效了。原因很简单原生Inspector用的是MaterialProperty自带的tooltip字段而自定义绘制时你往往直接调EditorGUILayout.PropertyField或者干脆用EditorGUILayout.FloatField后者根本不认识tooltip这个概念。所以这个项目的核心目标就一句话在自定义Shader编辑器中让每个属性都能像原生Inspector一样鼠标悬停时弹出说明文字。听起来简单但实际做起来有几个坑ToolTip的触发区域怎么控制、多语言怎么处理、动态属性怎么加提示、以及最关键的——怎么在不破坏原有布局的前提下优雅地注入。适合谁来参考如果你已经写过至少一个ShaderGUI子类或者用过MaterialPropertyDrawer那这篇内容就是为你准备的。如果你还没接触过Shader编辑器扩展建议先补一下ShaderGUI的OnGUI方法和MaterialEditor的基本用法否则后面的代码会有点吃力。我前后在三个项目里实现过这套机制从最初的硬编码字符串到后来的特性驱动方案踩过的坑足够写一篇避雷指南。下面就把完整思路和实现拆开讲。2. 整体设计思路与方案选型2.1 原生ToolTip的运作机制先搞清楚Unity自己是怎么做的。在MaterialProperty类里有一个tooltip字段它的值来自Shader属性声明时的第三个参数Properties { _MainTex (Main Texture, 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 }上面这种写法没有tooltip。要加提示得这样写Properties { _Glossiness (Smoothness, Range(0,1)) 0.5 // 不对ShaderLab不支持这种注释方式 }实际上ShaderLab的Properties块原生不支持tooltip。那Unity Inspector里的提示是哪来的答案是MaterialPropertyDrawer或者ShaderGUI在代码里手动设置的。比如public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties) { MaterialProperty gloss FindProperty(_Glossiness, properties); gloss.tooltip 控制表面光滑程度0为完全粗糙1为镜面反射; materialEditor.ShaderProperty(gloss, Smoothness); }关键点在于ShaderProperty方法会读取MaterialProperty.tooltip并自动渲染提示。但如果你用EditorGUILayout.PropertyField直接画SerializedProperty这个机制就不生效了。2.2 三种可选方案对比我试过三种实现路径各有优劣方案实现方式优点缺点直接设置tooltip字段在OnGUI里给每个MaterialProperty赋值tooltip代码量最少原生支持硬编码字符串无法复用动态属性难处理自定义Drawer继承MaterialPropertyDrawer重写OnGUI可复用属性级控制需要为每种类型写Drawer工作量大特性反射注入用C#特性标注字段反射读取并注入声明式易维护反射有性能开销需要额外解析最终我选择的是方案一和方案三的混合对于静态属性直接用特性标注反射注入对于动态生成的属性在OnGUI里手动设置tooltip。这样既保持了代码的可读性又兼顾了灵活性。为什么不用纯Drawer方案因为Drawer的OnGUI回调里拿不到完整的MaterialEditor上下文做多语言或者根据条件显示不同提示时很别扭。而且Drawer对Range、Vector等复合类型的支持需要额外处理不如在ShaderGUI层面统一控制来得干净。2.3 核心设计原则整个方案围绕三个原则展开第一不侵入原有绘制逻辑。你原来的ShaderProperty调用不用改只需要在调用前给MaterialProperty.tooltip赋值即可。这意味着现有代码几乎零改动就能接入。第二提示内容与代码分离。不要把提示文字硬编码在OnGUI里而是集中管理。我习惯用一个静态类或者ScriptableObject来存所有提示文本方便后续做多语言或者批量修改。第三支持动态条件。有些属性的提示需要根据当前值变化比如当_UseNormalMap开启时_NormalMap的提示应该变成“请指定法线贴图”关闭时则显示“启用上方开关后生效”。这种场景需要每次OnGUI时重新计算tooltip。注意MaterialProperty.tooltip是实例字段每次OnGUI都会重新读取。所以动态更新是安全的不会出现缓存问题。3. 核心实现细节与代码拆解3.1 基础版手动设置tooltip最直接的写法就是在ShaderGUI.OnGUI里逐个设置public class CustomShaderGUI : ShaderGUI { public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties) { MaterialProperty mainTex FindProperty(_MainTex, properties); mainTex.tooltip 主纹理控制物体表面的基础颜色; MaterialProperty gloss FindProperty(_Glossiness, properties); gloss.tooltip 光滑度0为完全粗糙1为镜面反射; materialEditor.ShaderProperty(mainTex, Main Texture); materialEditor.ShaderProperty(gloss, Smoothness); } }这段代码能工作但问题很明显提示文字散落在各处改起来麻烦。而且如果属性很多OnGUI会变得又长又乱。3.2 进阶版特性驱动注入我定义了一个ShaderTooltipAttribute[AttributeUsage(AttributeTargets.Field)] public class ShaderTooltipAttribute : Attribute { public string Tooltip { get; private set; } public ShaderTooltipAttribute(string tooltip) { Tooltip tooltip; } }然后在ShaderGUI子类里用反射读取public class CustomShaderGUI : ShaderGUI { private Dictionarystring, string _tooltipCache; private void BuildTooltipCache() { _tooltipCache new Dictionarystring, string(); var fields GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); foreach (var field in fields) { var attr field.GetCustomAttributeShaderTooltipAttribute(); if (attr ! null) { _tooltipCache[field.Name] attr.Tooltip; } } } public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties) { if (_tooltipCache null) BuildTooltipCache(); foreach (var prop in properties) { if (_tooltipCache.TryGetValue(prop.name, out string tip)) { prop.tooltip tip; } materialEditor.ShaderProperty(prop, prop.displayName); } } }这里有个细节prop.name是属性在Shader里的名字比如_MainTex。而字段名我习惯保持一致这样反射查找最直接。如果字段名和属性名不一致可以在特性里再加一个PropertyName参数。3.3 多语言支持的处理如果项目需要出海提示文字得支持多语言。我的做法是在特性里存key运行时从本地化表里取[ShaderTooltip(shader.maintex.tip)] private MaterialProperty _MainTex;然后prop.tooltip Localization.Get(_tooltipCache[prop.name]);本地化表可以用Unity的Localization包也可以自己写一个简单的JSON加载器。关键是不要在特性里直接写死中文或英文否则后期替换成本极高。3.4 动态提示的实现有些提示需要根据当前材质状态变化。比如public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties) { MaterialProperty useNormal FindProperty(_UseNormalMap, properties); MaterialProperty normalMap FindProperty(_NormalMap, properties); bool useNormalValue useNormal.floatValue 0.5f; normalMap.tooltip useNormalValue ? 指定法线贴图用于增强表面细节 : 启用上方开关后此属性生效; materialEditor.ShaderProperty(useNormal, Use Normal Map); materialEditor.ShaderProperty(normalMap, Normal Map); }这种动态逻辑放在OnGUI里最自然因为每次重绘都会重新计算。但要注意如果提示文字来自本地化表频繁调用Localization.Get可能有性能开销。实测在Inspector这种低频重绘场景下完全没问题但如果你的Shader有上百个属性建议加个缓存。3.5 与MaterialPropertyDrawer的配合如果你用了自定义Drawer比如[Toggle]或者[KeywordEnum]这些Drawer内部会调用EditorGUILayout而EditorGUILayout不认MaterialProperty.tooltip。解决办法是在Drawer的OnGUI里手动加GUIContentpublic class TooltipToggleDrawer : MaterialPropertyDrawer { private string _tooltip; public TooltipToggleDrawer(string tooltip) { _tooltip tooltip; } public override void OnGUI(Rect position, MaterialProperty prop, string label, MaterialEditor editor) { GUIContent content new GUIContent(label, _tooltip); EditorGUI.BeginChangeCheck(); bool value EditorGUI.Toggle(position, content, prop.floatValue 0.5f); if (EditorGUI.EndChangeCheck()) { prop.floatValue value ? 1f : 0f; } } }这样Drawer绘制的属性也能有ToolTip。但缺点是每个Drawer都要改不如在ShaderGUI层面统一处理来得省事。所以我的建议是能用ShaderGUI统一处理的就不要分散到Drawer里。4. 完整实操流程与关键环节4.1 项目结构准备先建一个标准的Shader编辑器扩展目录Assets/ Shaders/ CustomLit.shader Editor/ ShaderGUI/ CustomLitGUI.cs ShaderTooltipAttribute.cs TooltipLocalization.csCustomLit.shader里定义属性CustomLitGUI.cs继承ShaderGUI并实现绘制逻辑。4.2 Shader属性声明Shader Custom/CustomLit { Properties { _MainTex (Main Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 [Toggle] _UseNormalMap (Use Normal Map, Float) 0 _NormalMap (Normal Map, 2D) bump {} } SubShader { // ... 略 } CustomEditor CustomLitGUI }注意最后一行CustomEditor指定了我们的GUI类。4.3 ShaderGUI完整实现using UnityEngine; using UnityEditor; using System; using System.Collections.Generic; using System.Reflection; public class CustomLitGUI : ShaderGUI { private Dictionarystring, string _tooltipMap; private bool _initialized; private void Initialize() { if (_initialized) return; _tooltipMap new Dictionarystring, string(); var type GetType(); var fields type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); foreach (var field in fields) { var attr field.GetCustomAttributeShaderTooltipAttribute(); if (attr ! null) { _tooltipMap[field.Name] attr.Tooltip; } } _initialized true; } public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties) { Initialize(); MaterialProperty mainTex FindProperty(_MainTex, properties); MaterialProperty baseColor FindProperty(_BaseColor, properties); MaterialProperty gloss FindProperty(_Glossiness, properties); MaterialProperty metallic FindProperty(_Metallic, properties); MaterialProperty useNormal FindProperty(_UseNormalMap, properties); MaterialProperty normalMap FindProperty(_NormalMap, properties); ApplyTooltip(mainTex); ApplyTooltip(baseColor); ApplyTooltip(gloss); ApplyTooltip(metallic); ApplyTooltip(useNormal); // 动态提示 bool useNormalValue useNormal.floatValue 0.5f; normalMap.tooltip useNormalValue ? GetLocalized(shader.normalmap.active) : GetLocalized(shader.normalmap.inactive); EditorGUILayout.LabelField(Surface Properties, EditorStyles.boldLabel); materialEditor.TexturePropertySingleLine( new GUIContent(Main Texture, mainTex.tooltip), mainTex, baseColor ); materialEditor.ShaderProperty(gloss, Smoothness); materialEditor.ShaderProperty(metallic, Metallic); EditorGUILayout.Space(); EditorGUILayout.LabelField(Normal Map, EditorStyles.boldLabel); materialEditor.ShaderProperty(useNormal, Use Normal Map); materialEditor.ShaderProperty(normalMap, Normal Map); } private void ApplyTooltip(MaterialProperty prop) { if (_tooltipMap.TryGetValue(prop.name, out string key)) { prop.tooltip GetLocalized(key); } } private string GetLocalized(string key) { // 简单实现实际项目可接入Localization包 return TooltipLocalization.Get(key); } }4.4 本地化表实现public static class TooltipLocalization { private static Dictionarystring, string _table; static TooltipLocalization() { _table new Dictionarystring, string { { shader.maintex.tip, 主纹理控制物体表面的基础颜色 }, { shader.basecolor.tip, 基础颜色与主纹理相乘 }, { shader.gloss.tip, 光滑度0为完全粗糙1为镜面反射 }, { shader.metallic.tip, 金属度0为非金属1为纯金属 }, { shader.normalmap.active, 指定法线贴图用于增强表面细节 }, { shader.normalmap.inactive, 启用上方开关后此属性生效 } }; } public static string Get(string key) { return _table.TryGetValue(key, out string value) ? value : key; } }4.5 参数选择与计算过程ToolTip的显示延迟和宽度其实有讲究。Unity默认的ToolTip延迟是0.5秒左右这个值在EditorGUI内部是硬编码的改不了。但你可以通过GUIContent的构造方式控制提示框的宽度——实际上也控制不了Unity会根据文字长度自动计算。真正能控制的是提示文字的换行。如果提示太长ToolTip会显示成一行很宽的框阅读体验差。我的做法是在文字里手动插入换行符{ shader.gloss.tip, 光滑度\n0为完全粗糙\n1为镜面反射 }实测下来每行控制在20个中文字符以内ToolTip的显示效果最好。超过30个字符Unity会自动换行但断句位置不可控可能出现“光滑度0为完全粗”这种尴尬断法。另一个细节是富文本支持。ToolTip内部用的是GUIStyle默认不支持富文本标签。如果你写b光滑度/b它会原样显示标签。所以不要试图在ToolTip里加粗或变色纯文本最稳妥。5. 常见问题与排查技巧实录5.1 ToolTip不显示这是最常见的问题。排查顺序如下现象可能原因解决方法所有属性都没提示ShaderGUI没有正确挂载检查Shader文件末尾的CustomEditor路径部分属性没提示tooltip字段为空在OnGUI里打印prop.tooltip确认提示显示但内容为空本地化key不存在检查字典里是否有对应key鼠标悬停无反应属性被禁用或隐藏禁用的控件不触发ToolTip我遇到过一次特殊情况TexturePropertySingleLine绘制的属性ToolTip只对第一个参数生效。如果你传了多个属性只有第一个能显示提示。解决办法是拆成多次调用或者用EditorGUILayout.BeginHorizontal手动布局。5.2 动态提示不更新如果你在OnGUI里根据条件设置tooltip但发现鼠标悬停时显示的还是旧值大概率是因为Unity缓存了GUIContent。ShaderProperty内部会把prop.tooltip包装成GUIContent如果这个GUIContent被复用了新值就不会生效。解决办法很简单每次OnGUI都重新赋值。我上面的代码里每次都会执行normalMap.tooltip ...所以不会有这个问题。但如果你把tooltip设置放在if (!_initialized)块里就会踩坑。5.3 性能问题反射读取特性在Inspector首次打开时会有一次开销之后走缓存就没问题了。但如果你的ShaderGUI是每次选中物体都重新实例化那反射会频繁执行。实测一个20个属性的Shader反射耗时在0.1ms左右完全可接受。但如果属性超过100个建议把tooltip映射表做成静态缓存跨实例复用。5.4 与Undo系统的兼容设置prop.tooltip不会触发Undo记录因为它不是材质数据的一部分。但如果你在设置tooltip的同时修改了floatValue记得用EditorGUI.BeginChangeCheck和ApplyModifiedProperties包裹否则撤销操作会出问题。5.5 多选编辑时的表现当选中多个材质时ShaderGUI.OnGUI会被调用多次每个材质一次。tooltip设置是幂等的所以多选不会出问题。但动态提示如果依赖材质当前值多选时每个材质的提示可能不同这是符合预期的——鼠标悬停在哪个材质上就显示哪个材质的提示。实操心得如果提示文字里包含材质特定信息比如“当前值0.5”多选时会出现提示闪烁。建议避免在tooltip里放动态数值保持提示的静态性。6. 扩展思路与个人经验这套机制跑通之后可以进一步扩展。比如把tooltip和文档系统打通在特性里存一个文档URLToolTip里显示“点击查看文档”然后通过EditorGUI的LinkButton实现点击跳转。不过Unity的ToolTip不支持点击事件这个需求得用自定义窗口实现。另一个方向是自动生成tooltip。如果你的Shader属性命名规范比如_MainTex、_NormalMap可以写一个规则引擎根据后缀自动匹配提示模板。_Tex结尾的提示“纹理贴图”_Color结尾的提示“颜色值”_Range结尾的提示“范围值”。这样新加属性时零配置就能有提示适合快速迭代的项目。我在实际项目里还遇到过一个需求美术希望ToolTip能显示参数的推荐范围。比如_Glossiness虽然声明是0到1但实际美术规范要求控制在0.2到0.8之间。这个信息放在ToolTip里比放在属性名里更合适因为属性名太长会影响Inspector的排版。最后分享一个小技巧如果你想让ToolTip支持快捷键提示比如“按住Shift点击可重置”可以在文字末尾加\n再写快捷键说明。Unity的ToolTip会自动处理换行显示效果比挤在一行好得多。这套方案在三个项目里跑了两年多从Unity 2019到2022都没出过兼容性问题。核心代码不到200行但省下的沟通成本相当可观。如果你正在做Shader编辑器扩展建议尽早把ToolTip机制加进去后期补的代价比一开始就做要高得多。
阅读完成 · 觉得有帮助?
咨询建站