1. 项目概述为什么我们需要一个UGUI调色板工具在Unity项目里做UI尤其是涉及到大量需要动态调整颜色的界面元素时美术和策划的需求总是千变万化。“这个按钮的悬停色能不能再暖一点”“这个进度条的颜色要和角色状态联动需要实时改。”每次听到这种需求如果你的第一反应是打开代码找到对应的Image组件然后硬编码一个new Color(0.8f, 0.6f, 0.2f, 1.0f)那不仅效率低下后期维护也是个噩梦。更别提让非程序同事比如策划或美术去参与颜色调试了他们面对一串0到1的浮点数基本是懵的。这就是我决定动手开发一个UGUI调色板工具的直接原因。它不是一个简单的颜色选择器而是一个集成到Unity编辑器、面向工作流、可配置、可扩展的实用工具。核心目标就三个第一让颜色调整可视化、即时化在编辑器运行模式下甚至游戏运行时都能实时调整并看到效果第二将颜色参数数据化、配置化告别硬编码方便策划通过配置文件或简单的界面进行修改第三提升团队协作效率降低沟通成本让颜色的调整权部分移交到更合适的人手里。市面上当然有现成的颜色选择器插件但要么功能过于庞杂要么无法深度融入我们自己的UI框架和项目管理流程。自己开发意味着我们可以量身定制比如直接绑定我们项目内定义的ColorTheme脚本able object或者与我们的本地化、换肤系统打通。这个实战项目我会从头拆解如何利用UGUI和Editor脚本来构建这样一个工具其中会涉及事件系统、序列化、编辑器扩展等核心知识点最终产出一个即插即用、思路清晰的生产力工具。2. 核心设计思路与架构规划2.1 需求分析与功能定义在动手写第一行代码之前明确工具要解决的具体问题和功能边界至关重要。我将其核心需求归纳为以下几点可视化颜色选取提供一个直观的调色板色相环饱和度明度方块或RGB/HSV滑块让用户通过点击或拖拽选取颜色。实时预览与赋值选取颜色后能实时预览效果并能将颜色值一键赋给场景中选定的一个或多个UGUI组件如Image、Text、RawImage等。颜色值管理与持久化支持保存自定义颜色到调色盘支持输入十六进制颜色码如#FF5733并能将颜色数据如Color、Color32以友好格式复制到剪贴板。编辑器集成作为自定义编辑器窗口EditorWindow存在方便在Unity编辑器中随时呼出使用。运行时支持可选但推荐可以封装成一个独立的运行时组件在游戏内提供调试用的调色板方便测试和调整。基于这些需求我规划了工具的简易架构主要分为三大块编辑器窗口层ColorPaletteEditorWindow负责界面绘制、用户交互逻辑。这是工具的门面。数据与逻辑层包含颜色转换算法RGB/HSV/Hex、颜色历史记录管理、当前选中颜色状态管理等核心逻辑。这部分应尽量与UI表现分离。工具桥接层负责与Unity编辑器交互例如获取当前选中的游戏对象和UI组件并将选中的颜色应用上去。这部分会用到Selection类和反射或更优雅的接口来操作组件。2.2 关键技术选型与原因为什么用UGUI而不是IMGUI来画界面虽然IMGUI写编辑器工具更传统但UGUI在制作复杂、美观、交互丰富的界面时优势明显特别是需要自定义样式、动画和复杂布局时。我们的调色板工具界面元素较多色相环、方块、滑块、按钮列表使用UGUI的Canvas、RectTransform和标准控件SliderImage来构建会更加直观和易于控制其锚点布局系统也能更好地适应窗口缩放。更重要的是我们可以直接使用UGUI强大的事件系统EventTrigger来处理鼠标点击、拖拽等交互这比IMGUI的手动事件处理要方便和可靠得多。对于颜色空间我选择同时支持RGB和HSV。RGB是计算机显示的基础但HSV色相、饱和度、明度模型更符合人类对颜色的直观感知。调整色相就是沿着彩虹色环转一圈调整饱和度是让颜色从灰色变鲜艳调整明度是让颜色从黑变白。在调色板设计中通常用一个圆形表示色相H用一个方形表示饱和度S和明度V这种交互方式非常经典和高效。我们需要实现RGB与HSV之间的相互转换算法。关于颜色应用为了灵活性我设计了两套方案方案A直接反射赋值。通过Selection.activeGameObject获取选中对象遍历其GetComponentsGraphic()Graphic是Image、Text等的基类然后通过反射设置其color属性。这种方式简单粗暴但通用性强。方案B接口驱动赋值。定义一个IColorSettable接口让需要接收颜色的UI组件脚本实现这个接口包含一个SetColor(Color c)方法。工具只对实现了该接口的组件进行操作。这种方式更安全、更面向对象耦合度低适合在大型项目或框架中使用。本项目将同时展示两种方式并推荐方案B。3. 核心模块实现与细节解析3.1 构建编辑器窗口与基础UI首先我们创建一个继承自EditorWindow的类。通过[MenuItem(“Tools/UI Color Palette”)]在Unity菜单栏添加一个入口。using UnityEditor; using UnityEngine; public class ColorPaletteEditorWindow : EditorWindow { [MenuItem(“Tools/UI/Color Palette”)] static void Init() { var window GetWindowColorPaletteEditorWindow(); window.titleContent new GUIContent(“Color Palette”); window.Show(); } private void OnGUI() { // 这里暂时用IMGUI绘制一个简单标签后续将替换为UGUI EditorGUILayout.LabelField(“UGUI Color Palette Tool (Under Construction)”); } }但这只是开始。我们要的是一个UGUI窗口。关键步骤是在窗口内创建一个隐藏的、常驻的Canvas。我们可以在OnEnable生命周期中完成这个初始化。private Canvas m_Canvas; private GameObject m_CanvasGO; private void OnEnable() { if (m_Canvas null) { // 创建一个GameObject作为Canvas的载体 m_CanvasGO new GameObject(“PaletteCanvas”, typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); m_CanvasGO.hideFlags HideFlags.HideAndDontSave; // 防止出现在场景层级 m_Canvas m_CanvasGO.GetComponentCanvas(); m_Canvas.renderMode RenderMode.ScreenSpaceOverlay; // 将Canvas的根变换设置为编辑器窗口的“子物体”概念上 // 注意这里需要更复杂的逻辑来将UGUI内容正确绘制到EditorWindow中。 // 一个更可行的方案是使用 GUI.BeginGroup/GUI.EndGroup 配合 GUI.Window // 或者利用 EditorGUIUtility.Load 加载预设体并实例化到窗口中。 // 鉴于复杂度另一种更主流和推荐的做法是工具主界面仍用IMGUI // 但将核心的调色板交互部件色相环、SV方块单独制作成一个Prefab // 这个Prefab使用UGUI并可以在游戏运行时和编辑器下复用。 // 我们将在‘运行时调色板组件’一节详细实现这个Prefab。 } }注意直接在EditorWindow中嵌入一个完整的、可交互的UGUICanvas是一个高级话题涉及到自定义渲染和事件转发实现起来较为复杂且容易出问题。对于编辑器工具很多情况下使用IMGUI来绘制控件是更标准、更轻量的选择。因此本项目的策略进行调整编辑器窗口的框架和控件如按钮、标签、历史记录列表使用IMGUI绘制以保证与Unity编辑器风格的统一和开发的便捷性而核心的、需要丰富交互的色相环和SV方块我们将制作一个独立的UGUI Prefab这个Prefab既可以被编辑器窗口以某种形式引用和显示例如在OnGUI中绘制一个EditorGUI.ObjectField来关联并预览更重要的是它可以作为一个Runtime组件直接用于游戏内的调试UI。这样兼顾了开发效率和运行时复用性。3.2 色相环与SV方块的UGUI实现这是工具最核心的视觉交互部分。我们创建一个名为UIColorPicker的MonoBehaviour脚本并挂载到一个空对象上其下构建UGUI层级HueRing一个Image组件使用一张360度渐变的环形纹理作为色相环。通过EventTrigger监听Drag和Point事件根据点击位置相对于圆心的角度计算出色相H 0-1。SVRect一个Image组件背景色为当前选中色相下的全饱和度、全明度颜色。其上面叠加一个表示当前SV值的小指示器如一个小圆圈。同样通过EventTrigger监听事件根据点击位置相对于矩形左下角的归一化坐标计算出饱和度S 0-1和明度V 0-1 通常V从上到下为1到0。UIColorPicker脚本负责管理这两个核心部件并暴露一个Color类型的属性CurrentColor。当色相环或SV方块上的交互改变时脚本内部更新HSV值并同步更新SVRect的背景色反映色相变化以及CurrentColor属性。同时它应该提供事件如UnityEventColor以便外部订阅颜色变化。using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; using UnityEngine.EventSystems; public class UIColorPicker : MonoBehaviour, IPointerDownHandler, IDragHandler { [SerializeField] private Image m_HueRingImage; [SerializeField] private Image m_SVRectImage; [SerializeField] private RectTransform m_SVIndicator; [SerializeField] private ColorEvent m_OnColorChanged; // 自定义的UnityEventColor [System.Serializable] public class ColorEvent : UnityEventColor {} private float m_Hue 0f; // 0-1 private float m_Saturation 1f; // 0-1 private float m_Value 1f; // 0-1 public Color CurrentColor { get Color.HSVToRGB(m_Hue, m_Saturation, m_Value); set { Color.RGBToHSV(value, out m_Hue, out m_Saturation, out m_Value); UpdateVisuals(); m_OnColorChanged?.Invoke(CurrentColor); } } // 初始化时为色相环和SV矩形添加事件监听也可以通过Inspector挂载EventTrigger void Start() { // ... 事件绑定逻辑 } public void OnPointerDown(PointerEventData eventData) { HandleInput(eventData); } public void OnDrag(PointerEventData eventData) { HandleInput(eventData); } private void HandleInput(PointerEventData eventData) { RectTransform rectTransform eventData.pointerCurrentRaycast.gameObject?.transform as RectTransform; if (rectTransform null) return; Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localPos); Rect rect rectTransform.rect; // 归一化坐标 (0-1) Vector2 normalizedPos new Vector2((localPos.x - rect.x) / rect.width, (localPos.y - rect.y) / rect.height); normalizedPos.x Mathf.Clamp01(normalizedPos.x); normalizedPos.y Mathf.Clamp01(normalizedPos.y); if (eventData.pointerCurrentRaycast.gameObject m_HueRingImage.gameObject) { // 计算角度得到Hue Vector2 center new Vector2(0.5f, 0.5f); Vector2 dir normalizedPos - center; m_Hue (Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg / 360f); if (m_Hue 0) m_Hue 1f; } else if (eventData.pointerCurrentRaycast.gameObject m_SVRectImage.gameObject) { m_Saturation normalizedPos.x; m_Value 1f - normalizedPos.y; // 通常矩形底部V0黑顶部V1 } UpdateVisuals(); m_OnColorChanged?.Invoke(CurrentColor); } private void UpdateVisuals() { // 1. 更新SV矩形的背景色纯当前色相S1, V1 m_SVRectImage.color Color.HSVToRGB(m_Hue, 1f, 1f); // 2. 更新SV指示器的位置 Vector2 svPos new Vector2(m_Saturation, 1f - m_Value); Rect svRect m_SVRectImage.rectTransform.rect; m_SVIndicator.anchoredPosition new Vector2(svRect.x svPos.x * svRect.width, svRect.y svPos.y * svRect.height); // 3. 可选更新色相环上的指示器 } }将这个脚本和UI层级保存为Prefab我们的核心调色板部件就完成了。它可以在游戏UI中直接使用也可以通过编辑器脚本将其纹理或颜色信息“桥接”到IMGUI的编辑器窗口中。3.3 编辑器窗口的IMGUI整合与功能完善现在我们回到ColorPaletteEditorWindow使用IMGUI来构建完整的工具界面。我们会利用EditorGUILayout和GUI来绘制滑块、颜色字段、按钮和历史记录。首先我们需要引用或模拟核心颜色数据。我们可以直接创建一个UIColorPicker的实例但不一定渲染或者自己管理一套HSV颜色数据。为了简单我们选择后者并在OnGUI中绘制RGB/HSV滑块。private Color m_CurrentColor Color.white; private float m_Hue, m_Saturation, m_Value; private ListColor m_ColorHistory new ListColor(); private Vector2 m_HistoryScrollPos; private string m_HexInput “FFFFFF”; private void OnGUI() { EditorGUILayout.LabelField(“Color Picker”, EditorStyles.boldLabel); EditorGUILayout.Space(); // 1. 颜色预览区域 EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(“Preview”, GUILayout.Width(80)); Rect previewRect GUILayoutUtility.GetRect(100, 100); EditorGUI.DrawRect(previewRect, m_CurrentColor); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(); // 2. HSV/RGB滑块控制 EditorGUI.BeginChangeCheck(); m_Hue EditorGUILayout.Slider(“Hue”, m_Hue, 0f, 1f); m_Saturation EditorGUILayout.Slider(“Saturation”, m_Saturation, 0f, 1f); m_Value EditorGUILayout.Slider(“Value”, m_Value, 0f, 1f); if (EditorGUI.EndChangeCheck()) { m_CurrentColor Color.HSVToRGB(m_Hue, m_Saturation, m_Value); UpdateHexFromColor(); } EditorGUILayout.Space(); // 3. 十六进制输入 EditorGUILayout.BeginHorizontal(); EditorGUILayout.PrefixLabel(“Hex Color”); m_HexInput EditorGUILayout.TextField(m_HexInput); if (GUILayout.Button(“Apply”, GUILayout.Width(60))) { if (ColorUtility.TryParseHtmlString(“#” m_HexInput, out Color parsedColor)) { m_CurrentColor parsedColor; Color.RGBToHSV(m_CurrentColor, out m_Hue, out m_Saturation, out m_Value); } else { EditorUtility.DisplayDialog(“Error”, “Invalid Hex Color Code”, “OK”); } } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(); // 4. 应用颜色到选中对象的按钮 EditorGUILayout.BeginHorizontal(); if (GUILayout.Button(“Apply to Selected UI”)) { ApplyColorToSelection(); } if (GUILayout.Button(“Pick from Selected”)) { PickColorFromSelection(); } EditorGUILayout.EndHorizontal(); // 5. 颜色历史记录 EditorGUILayout.LabelField(“History”, EditorStyles.boldLabel); m_HistoryScrollPos EditorGUILayout.BeginScrollView(m_HistoryScrollPos, GUILayout.Height(100)); EditorGUILayout.BeginHorizontal(); for (int i 0; i m_ColorHistory.Count; i) { if (GUILayout.Button(“”, GUILayout.Width(40), GUILayout.Height(40))) { m_CurrentColor m_ColorHistory[i]; Color.RGBToHSV(m_CurrentColor, out m_Hue, out m_Saturation, out m_Value); UpdateHexFromColor(); } // 这里需要自定义绘制一个颜色方块可以用GUI.DrawTexture配合一个纯色纹理 // 简化处理我们可以用一个Box并设置GUI.backgroundColor但效果不完美。 // 更好的做法是使用 EditorGUI.DrawRect 在一个定制的区域绘制。 Rect colorRect GUILayoutUtility.GetLastRect(); EditorGUI.DrawRect(colorRect, m_ColorHistory[i]); } EditorGUILayout.EndHorizontal(); EditorGUILayout.EndScrollView(); // 6. 将当前颜色加入历史的按钮 if (GUILayout.Button(“Add Current to History”) !m_ColorHistory.Contains(m_CurrentColor)) { m_ColorHistory.Add(m_CurrentColor); // 限制历史记录数量比如最多20个 if (m_ColorHistory.Count 20) m_ColorHistory.RemoveAt(0); } } private void UpdateHexFromColor() { m_HexInput ColorUtility.ToHtmlStringRGB(m_CurrentColor); }3.4 颜色应用与拾取功能的实现上面代码中的ApplyColorToSelection和PickColorFromSelection是两个核心功能方法。ApplyColorToSelection的实现方案A反射方式private void ApplyColorToSelection() { if (Selection.activeGameObject null) { EditorUtility.DisplayDialog(“No Selection”, “Please select a GameObject with a Graphic component (e.g., Image, Text).”, “OK”); return; } Graphic[] graphics Selection.activeGameObject.GetComponentsGraphic(); if (graphics.Length 0) { EditorUtility.DisplayDialog(“No Graphic”, “The selected GameObject has no Graphic component.”, “OK”); return; } Undo.RecordObjects(graphics, “Apply Color from Palette”); foreach (var graphic in graphics) { graphic.color m_CurrentColor; EditorUtility.SetDirty(graphic); // 标记为脏确保更改被保存 } // 强制刷新场景视图和Inspector UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); }ApplyColorToSelection的实现方案B接口方式 首先定义接口public interface IColorSettable { void SetColor(Color color); }让需要接收颜色的UI组件脚本实现此接口。然后工具端代码修改为private void ApplyColorToSelection() { if (Selection.activeGameObject null) return; IColorSettable[] settables Selection.activeGameObject.GetComponentsIColorSettable(); Undo.RecordObjects(settables as Object[], “Apply Color from Palette”); foreach (var settable in settables) { settable.SetColor(m_CurrentColor); } // ... 刷新视图 }接口方式更清晰也便于扩展比如未来可以支持设置材质颜色、粒子颜色等。PickColorFromSelection的实现private void PickColorFromSelection() { if (Selection.activeGameObject null) return; Graphic graphic Selection.activeGameObject.GetComponentGraphic(); if (graphic ! null) { m_CurrentColor graphic.color; Color.RGBToHSV(m_CurrentColor, out m_Hue, out m_Saturation, out m_Value); UpdateHexFromColor(); Repaint(); // 重绘编辑器窗口更新UI显示 } }4. 高级功能扩展与性能优化4.1 运行时调色板组件的封装为了让美术和策划在游戏运行时也能微调UI颜色我们需要将之前制作的UIColorPickerPrefab封装成一个完整的运行时调试面板。创建一个RuntimeColorPalette脚本它管理一个UIColorPicker实例、几个显示当前颜色值的Text组件以及应用颜色的按钮。这个面板可以设置为默认隐藏通过一个特定的快捷键例如F8呼出。在Awake或Start中使用DontDestroyOnLoad让它在场景切换时保留。RuntimeColorPalette监听UIColorPicker的OnColorChanged事件实时更新预览和数值显示。它的“应用”按钮逻辑与编辑器工具类似但目标是通过FindObjectOfType或事件系统来寻找场景中需要改色的UI对象可以设计一个调试模式点击运行时UI元素即将其注册为当前目标。4.2 颜色主题Color Theme的集成与保存一个更工程化的做法是将调色板工具与项目的“颜色主题”系统结合。我们可以创建ScriptableObject资源来定义一套颜色主题例如PrimaryColorSecondaryColorTextColor等。调色板工具在应用颜色时不仅可以应用到具体UI组件还可以将颜色值保存到当前激活的ColorTheme资产中。这样所有引用该主题颜色的UI都会自动更新实现了全局配色管理。在编辑器工具中可以增加一个ObjectField用来关联一个ColorTheme资产并列出其所有的颜色属性点击即可用调色板修改并保存。4.3 性能注意事项与优化点OnGUI调用频率EditorWindow的OnGUI在每帧都可能被调用尤其是窗口处于焦点时。要避免在其中进行昂贵的计算或分配大量临时对象。我们的代码目前是轻量的。颜色历史记录的渲染在历史记录部分我们为每个颜色按钮都调用了GUILayout.Button和EditorGUI.DrawRect。当历史记录很多时这可能会影响性能。可以考虑使用GUI.Button和GUI.DrawTexture进行更手动的、非自动布局的绘制或者对历史记录列表进行分页。UIColorPicker的交互在拖拽色相环或SV方块时OnDrag会每帧触发导致频繁的颜色计算和事件触发。要确保UpdateVisuals方法内的计算是轻量的。对于复杂的颜色预览比如在指示器周围实时渲染阴影可能需要考虑优化。编辑器刷新ApplyColorToSelection中调用RepaintAllViews()会强制刷新所有编辑器视图在频繁操作时可能感觉卡顿。可以改为只标记场景视图为需要重绘SceneView.RepaintAll()或者仅在必要时刷新。5. 常见问题排查与实操心得5.1 开发过程中遇到的典型问题问题1色相环点击/拖拽时颜色计算不准确指示器乱跳。排查问题通常出在坐标转换上。RectTransformUtility.ScreenPointToLocalPointInRectangle返回的localPos是基于RectTransform的轴心Pivot的。而我们的归一化计算假设矩形左下角是(0,0)。需要根据RectTransform的pivot和rect进行校正。解决在HandleInput方法中获取rectTransform.rect后计算归一化坐标的公式应修正为Vector2 normalizedPos new Vector2( (localPos.x - rect.xMin) / rect.width, (localPos.y - rect.yMin) / rect.height );同时确保色相环Image的RectTransform轴心在中心(0.5, 0.5)SV方块的轴心在左下角(0,0)这样计算逻辑最直观。问题2应用颜色后场景中的UI没有立即刷新显示新颜色。排查Unity编辑器不会自动每帧刷新非运行模式下的场景视图。修改组件属性后需要通知编辑器视图重绘。解决在ApplyColorToSelection方法中除了Undo.RecordObject和EditorUtility.SetDirty务必调用SceneView.RepaintAll()来刷新场景视图。如果Inspector中正在查看该对象可能还需要调用EditorApplication.DirtyHierarchyWindowItems()或简单地通过Selection.activeObject null; Selection.activeObject graphic;来触发Inspector刷新。问题3运行时调色板面板在切换场景后被销毁。排查面板GameObject是当前场景的一部分默认会随场景卸载而销毁。解决在面板根GameObject的初始化脚本如RuntimeColorPalette的Awake方法中调用DontDestroyOnLoad(this.gameObject)。同时要做好单例管理防止重复创建。5.2 实操心得与技巧分享分离数据与表现UIColorPicker脚本只负责交互和颜色数据HSV的管理以及触发OnColorChanged事件。它不关心颜色用在哪里。这种设计使得它可以被轻松复用于编辑器工具、运行时调试工具甚至游戏内的自定义颜色选择界面。善用Undo在编辑器工具中修改任何场景对象或资源务必使用Undo.RecordObject或Undo.RecordObjects。这是实现撤销/重做功能的关键也是专业编辑器工具的基本素养。忘记加Undo策划或美术误操作后无法回退体验极差。为颜色历史记录使用PlayerPrefs或EditorPrefs为了让工具状态在Unity编辑器重启后得以保留可以将m_ColorHistory列表序列化如转换成Color32数组再转成string后保存到EditorPrefs中。在OnEnable时加载。这大大提升了工具的实用性。提供多种颜色输入方式除了HSV滑块和调色板点击务必支持十六进制输入。这是和设计师沟通的“通用语言”。可以再加入RGB0-255整数输入满足不同习惯的用户。考虑色盲模式作为一个专业的UI工具可以考虑在界面上增加一个复选框模拟不同色盲类型下的颜色显示效果。这能帮助开发者创建更具包容性的UI配色。实现上可以查找标准的色盲模拟矩阵在颜色预览时应用该矩阵进行转换。这个UGUI调色板工具从构思到实现涉及了从底层交互处理到上层编辑器集成的完整链条。它可能不是功能最全的但通过这个实战项目我们清晰地走通了一条自定义Unity工具的开发路径。最终产出的工具不仅能立刻提升日常开发效率其模块化的设计如UIColorPicker组件也能为项目其他功能所用。开发工具的本质就是将重复、繁琐的操作自动化、可视化把时间留给更有创造性的工作。
Unity UGUI调色板工具开发实战:从原理到工程实践
1. 项目概述为什么我们需要一个UGUI调色板工具在Unity项目里做UI尤其是涉及到大量需要动态调整颜色的界面元素时美术和策划的需求总是千变万化。“这个按钮的悬停色能不能再暖一点”“这个进度条的颜色要和角色状态联动需要实时改。”每次听到这种需求如果你的第一反应是打开代码找到对应的Image组件然后硬编码一个new Color(0.8f, 0.6f, 0.2f, 1.0f)那不仅效率低下后期维护也是个噩梦。更别提让非程序同事比如策划或美术去参与颜色调试了他们面对一串0到1的浮点数基本是懵的。这就是我决定动手开发一个UGUI调色板工具的直接原因。它不是一个简单的颜色选择器而是一个集成到Unity编辑器、面向工作流、可配置、可扩展的实用工具。核心目标就三个第一让颜色调整可视化、即时化在编辑器运行模式下甚至游戏运行时都能实时调整并看到效果第二将颜色参数数据化、配置化告别硬编码方便策划通过配置文件或简单的界面进行修改第三提升团队协作效率降低沟通成本让颜色的调整权部分移交到更合适的人手里。市面上当然有现成的颜色选择器插件但要么功能过于庞杂要么无法深度融入我们自己的UI框架和项目管理流程。自己开发意味着我们可以量身定制比如直接绑定我们项目内定义的ColorTheme脚本able object或者与我们的本地化、换肤系统打通。这个实战项目我会从头拆解如何利用UGUI和Editor脚本来构建这样一个工具其中会涉及事件系统、序列化、编辑器扩展等核心知识点最终产出一个即插即用、思路清晰的生产力工具。2. 核心设计思路与架构规划2.1 需求分析与功能定义在动手写第一行代码之前明确工具要解决的具体问题和功能边界至关重要。我将其核心需求归纳为以下几点可视化颜色选取提供一个直观的调色板色相环饱和度明度方块或RGB/HSV滑块让用户通过点击或拖拽选取颜色。实时预览与赋值选取颜色后能实时预览效果并能将颜色值一键赋给场景中选定的一个或多个UGUI组件如Image、Text、RawImage等。颜色值管理与持久化支持保存自定义颜色到调色盘支持输入十六进制颜色码如#FF5733并能将颜色数据如Color、Color32以友好格式复制到剪贴板。编辑器集成作为自定义编辑器窗口EditorWindow存在方便在Unity编辑器中随时呼出使用。运行时支持可选但推荐可以封装成一个独立的运行时组件在游戏内提供调试用的调色板方便测试和调整。基于这些需求我规划了工具的简易架构主要分为三大块编辑器窗口层ColorPaletteEditorWindow负责界面绘制、用户交互逻辑。这是工具的门面。数据与逻辑层包含颜色转换算法RGB/HSV/Hex、颜色历史记录管理、当前选中颜色状态管理等核心逻辑。这部分应尽量与UI表现分离。工具桥接层负责与Unity编辑器交互例如获取当前选中的游戏对象和UI组件并将选中的颜色应用上去。这部分会用到Selection类和反射或更优雅的接口来操作组件。2.2 关键技术选型与原因为什么用UGUI而不是IMGUI来画界面虽然IMGUI写编辑器工具更传统但UGUI在制作复杂、美观、交互丰富的界面时优势明显特别是需要自定义样式、动画和复杂布局时。我们的调色板工具界面元素较多色相环、方块、滑块、按钮列表使用UGUI的Canvas、RectTransform和标准控件SliderImage来构建会更加直观和易于控制其锚点布局系统也能更好地适应窗口缩放。更重要的是我们可以直接使用UGUI强大的事件系统EventTrigger来处理鼠标点击、拖拽等交互这比IMGUI的手动事件处理要方便和可靠得多。对于颜色空间我选择同时支持RGB和HSV。RGB是计算机显示的基础但HSV色相、饱和度、明度模型更符合人类对颜色的直观感知。调整色相就是沿着彩虹色环转一圈调整饱和度是让颜色从灰色变鲜艳调整明度是让颜色从黑变白。在调色板设计中通常用一个圆形表示色相H用一个方形表示饱和度S和明度V这种交互方式非常经典和高效。我们需要实现RGB与HSV之间的相互转换算法。关于颜色应用为了灵活性我设计了两套方案方案A直接反射赋值。通过Selection.activeGameObject获取选中对象遍历其GetComponentsGraphic()Graphic是Image、Text等的基类然后通过反射设置其color属性。这种方式简单粗暴但通用性强。方案B接口驱动赋值。定义一个IColorSettable接口让需要接收颜色的UI组件脚本实现这个接口包含一个SetColor(Color c)方法。工具只对实现了该接口的组件进行操作。这种方式更安全、更面向对象耦合度低适合在大型项目或框架中使用。本项目将同时展示两种方式并推荐方案B。3. 核心模块实现与细节解析3.1 构建编辑器窗口与基础UI首先我们创建一个继承自EditorWindow的类。通过[MenuItem(“Tools/UI Color Palette”)]在Unity菜单栏添加一个入口。using UnityEditor; using UnityEngine; public class ColorPaletteEditorWindow : EditorWindow { [MenuItem(“Tools/UI/Color Palette”)] static void Init() { var window GetWindowColorPaletteEditorWindow(); window.titleContent new GUIContent(“Color Palette”); window.Show(); } private void OnGUI() { // 这里暂时用IMGUI绘制一个简单标签后续将替换为UGUI EditorGUILayout.LabelField(“UGUI Color Palette Tool (Under Construction)”); } }但这只是开始。我们要的是一个UGUI窗口。关键步骤是在窗口内创建一个隐藏的、常驻的Canvas。我们可以在OnEnable生命周期中完成这个初始化。private Canvas m_Canvas; private GameObject m_CanvasGO; private void OnEnable() { if (m_Canvas null) { // 创建一个GameObject作为Canvas的载体 m_CanvasGO new GameObject(“PaletteCanvas”, typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); m_CanvasGO.hideFlags HideFlags.HideAndDontSave; // 防止出现在场景层级 m_Canvas m_CanvasGO.GetComponentCanvas(); m_Canvas.renderMode RenderMode.ScreenSpaceOverlay; // 将Canvas的根变换设置为编辑器窗口的“子物体”概念上 // 注意这里需要更复杂的逻辑来将UGUI内容正确绘制到EditorWindow中。 // 一个更可行的方案是使用 GUI.BeginGroup/GUI.EndGroup 配合 GUI.Window // 或者利用 EditorGUIUtility.Load 加载预设体并实例化到窗口中。 // 鉴于复杂度另一种更主流和推荐的做法是工具主界面仍用IMGUI // 但将核心的调色板交互部件色相环、SV方块单独制作成一个Prefab // 这个Prefab使用UGUI并可以在游戏运行时和编辑器下复用。 // 我们将在‘运行时调色板组件’一节详细实现这个Prefab。 } }注意直接在EditorWindow中嵌入一个完整的、可交互的UGUICanvas是一个高级话题涉及到自定义渲染和事件转发实现起来较为复杂且容易出问题。对于编辑器工具很多情况下使用IMGUI来绘制控件是更标准、更轻量的选择。因此本项目的策略进行调整编辑器窗口的框架和控件如按钮、标签、历史记录列表使用IMGUI绘制以保证与Unity编辑器风格的统一和开发的便捷性而核心的、需要丰富交互的色相环和SV方块我们将制作一个独立的UGUI Prefab这个Prefab既可以被编辑器窗口以某种形式引用和显示例如在OnGUI中绘制一个EditorGUI.ObjectField来关联并预览更重要的是它可以作为一个Runtime组件直接用于游戏内的调试UI。这样兼顾了开发效率和运行时复用性。3.2 色相环与SV方块的UGUI实现这是工具最核心的视觉交互部分。我们创建一个名为UIColorPicker的MonoBehaviour脚本并挂载到一个空对象上其下构建UGUI层级HueRing一个Image组件使用一张360度渐变的环形纹理作为色相环。通过EventTrigger监听Drag和Point事件根据点击位置相对于圆心的角度计算出色相H 0-1。SVRect一个Image组件背景色为当前选中色相下的全饱和度、全明度颜色。其上面叠加一个表示当前SV值的小指示器如一个小圆圈。同样通过EventTrigger监听事件根据点击位置相对于矩形左下角的归一化坐标计算出饱和度S 0-1和明度V 0-1 通常V从上到下为1到0。UIColorPicker脚本负责管理这两个核心部件并暴露一个Color类型的属性CurrentColor。当色相环或SV方块上的交互改变时脚本内部更新HSV值并同步更新SVRect的背景色反映色相变化以及CurrentColor属性。同时它应该提供事件如UnityEventColor以便外部订阅颜色变化。using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; using UnityEngine.EventSystems; public class UIColorPicker : MonoBehaviour, IPointerDownHandler, IDragHandler { [SerializeField] private Image m_HueRingImage; [SerializeField] private Image m_SVRectImage; [SerializeField] private RectTransform m_SVIndicator; [SerializeField] private ColorEvent m_OnColorChanged; // 自定义的UnityEventColor [System.Serializable] public class ColorEvent : UnityEventColor {} private float m_Hue 0f; // 0-1 private float m_Saturation 1f; // 0-1 private float m_Value 1f; // 0-1 public Color CurrentColor { get Color.HSVToRGB(m_Hue, m_Saturation, m_Value); set { Color.RGBToHSV(value, out m_Hue, out m_Saturation, out m_Value); UpdateVisuals(); m_OnColorChanged?.Invoke(CurrentColor); } } // 初始化时为色相环和SV矩形添加事件监听也可以通过Inspector挂载EventTrigger void Start() { // ... 事件绑定逻辑 } public void OnPointerDown(PointerEventData eventData) { HandleInput(eventData); } public void OnDrag(PointerEventData eventData) { HandleInput(eventData); } private void HandleInput(PointerEventData eventData) { RectTransform rectTransform eventData.pointerCurrentRaycast.gameObject?.transform as RectTransform; if (rectTransform null) return; Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out localPos); Rect rect rectTransform.rect; // 归一化坐标 (0-1) Vector2 normalizedPos new Vector2((localPos.x - rect.x) / rect.width, (localPos.y - rect.y) / rect.height); normalizedPos.x Mathf.Clamp01(normalizedPos.x); normalizedPos.y Mathf.Clamp01(normalizedPos.y); if (eventData.pointerCurrentRaycast.gameObject m_HueRingImage.gameObject) { // 计算角度得到Hue Vector2 center new Vector2(0.5f, 0.5f); Vector2 dir normalizedPos - center; m_Hue (Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg / 360f); if (m_Hue 0) m_Hue 1f; } else if (eventData.pointerCurrentRaycast.gameObject m_SVRectImage.gameObject) { m_Saturation normalizedPos.x; m_Value 1f - normalizedPos.y; // 通常矩形底部V0黑顶部V1 } UpdateVisuals(); m_OnColorChanged?.Invoke(CurrentColor); } private void UpdateVisuals() { // 1. 更新SV矩形的背景色纯当前色相S1, V1 m_SVRectImage.color Color.HSVToRGB(m_Hue, 1f, 1f); // 2. 更新SV指示器的位置 Vector2 svPos new Vector2(m_Saturation, 1f - m_Value); Rect svRect m_SVRectImage.rectTransform.rect; m_SVIndicator.anchoredPosition new Vector2(svRect.x svPos.x * svRect.width, svRect.y svPos.y * svRect.height); // 3. 可选更新色相环上的指示器 } }将这个脚本和UI层级保存为Prefab我们的核心调色板部件就完成了。它可以在游戏UI中直接使用也可以通过编辑器脚本将其纹理或颜色信息“桥接”到IMGUI的编辑器窗口中。3.3 编辑器窗口的IMGUI整合与功能完善现在我们回到ColorPaletteEditorWindow使用IMGUI来构建完整的工具界面。我们会利用EditorGUILayout和GUI来绘制滑块、颜色字段、按钮和历史记录。首先我们需要引用或模拟核心颜色数据。我们可以直接创建一个UIColorPicker的实例但不一定渲染或者自己管理一套HSV颜色数据。为了简单我们选择后者并在OnGUI中绘制RGB/HSV滑块。private Color m_CurrentColor Color.white; private float m_Hue, m_Saturation, m_Value; private ListColor m_ColorHistory new ListColor(); private Vector2 m_HistoryScrollPos; private string m_HexInput “FFFFFF”; private void OnGUI() { EditorGUILayout.LabelField(“Color Picker”, EditorStyles.boldLabel); EditorGUILayout.Space(); // 1. 颜色预览区域 EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(“Preview”, GUILayout.Width(80)); Rect previewRect GUILayoutUtility.GetRect(100, 100); EditorGUI.DrawRect(previewRect, m_CurrentColor); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(); // 2. HSV/RGB滑块控制 EditorGUI.BeginChangeCheck(); m_Hue EditorGUILayout.Slider(“Hue”, m_Hue, 0f, 1f); m_Saturation EditorGUILayout.Slider(“Saturation”, m_Saturation, 0f, 1f); m_Value EditorGUILayout.Slider(“Value”, m_Value, 0f, 1f); if (EditorGUI.EndChangeCheck()) { m_CurrentColor Color.HSVToRGB(m_Hue, m_Saturation, m_Value); UpdateHexFromColor(); } EditorGUILayout.Space(); // 3. 十六进制输入 EditorGUILayout.BeginHorizontal(); EditorGUILayout.PrefixLabel(“Hex Color”); m_HexInput EditorGUILayout.TextField(m_HexInput); if (GUILayout.Button(“Apply”, GUILayout.Width(60))) { if (ColorUtility.TryParseHtmlString(“#” m_HexInput, out Color parsedColor)) { m_CurrentColor parsedColor; Color.RGBToHSV(m_CurrentColor, out m_Hue, out m_Saturation, out m_Value); } else { EditorUtility.DisplayDialog(“Error”, “Invalid Hex Color Code”, “OK”); } } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(); // 4. 应用颜色到选中对象的按钮 EditorGUILayout.BeginHorizontal(); if (GUILayout.Button(“Apply to Selected UI”)) { ApplyColorToSelection(); } if (GUILayout.Button(“Pick from Selected”)) { PickColorFromSelection(); } EditorGUILayout.EndHorizontal(); // 5. 颜色历史记录 EditorGUILayout.LabelField(“History”, EditorStyles.boldLabel); m_HistoryScrollPos EditorGUILayout.BeginScrollView(m_HistoryScrollPos, GUILayout.Height(100)); EditorGUILayout.BeginHorizontal(); for (int i 0; i m_ColorHistory.Count; i) { if (GUILayout.Button(“”, GUILayout.Width(40), GUILayout.Height(40))) { m_CurrentColor m_ColorHistory[i]; Color.RGBToHSV(m_CurrentColor, out m_Hue, out m_Saturation, out m_Value); UpdateHexFromColor(); } // 这里需要自定义绘制一个颜色方块可以用GUI.DrawTexture配合一个纯色纹理 // 简化处理我们可以用一个Box并设置GUI.backgroundColor但效果不完美。 // 更好的做法是使用 EditorGUI.DrawRect 在一个定制的区域绘制。 Rect colorRect GUILayoutUtility.GetLastRect(); EditorGUI.DrawRect(colorRect, m_ColorHistory[i]); } EditorGUILayout.EndHorizontal(); EditorGUILayout.EndScrollView(); // 6. 将当前颜色加入历史的按钮 if (GUILayout.Button(“Add Current to History”) !m_ColorHistory.Contains(m_CurrentColor)) { m_ColorHistory.Add(m_CurrentColor); // 限制历史记录数量比如最多20个 if (m_ColorHistory.Count 20) m_ColorHistory.RemoveAt(0); } } private void UpdateHexFromColor() { m_HexInput ColorUtility.ToHtmlStringRGB(m_CurrentColor); }3.4 颜色应用与拾取功能的实现上面代码中的ApplyColorToSelection和PickColorFromSelection是两个核心功能方法。ApplyColorToSelection的实现方案A反射方式private void ApplyColorToSelection() { if (Selection.activeGameObject null) { EditorUtility.DisplayDialog(“No Selection”, “Please select a GameObject with a Graphic component (e.g., Image, Text).”, “OK”); return; } Graphic[] graphics Selection.activeGameObject.GetComponentsGraphic(); if (graphics.Length 0) { EditorUtility.DisplayDialog(“No Graphic”, “The selected GameObject has no Graphic component.”, “OK”); return; } Undo.RecordObjects(graphics, “Apply Color from Palette”); foreach (var graphic in graphics) { graphic.color m_CurrentColor; EditorUtility.SetDirty(graphic); // 标记为脏确保更改被保存 } // 强制刷新场景视图和Inspector UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); }ApplyColorToSelection的实现方案B接口方式 首先定义接口public interface IColorSettable { void SetColor(Color color); }让需要接收颜色的UI组件脚本实现此接口。然后工具端代码修改为private void ApplyColorToSelection() { if (Selection.activeGameObject null) return; IColorSettable[] settables Selection.activeGameObject.GetComponentsIColorSettable(); Undo.RecordObjects(settables as Object[], “Apply Color from Palette”); foreach (var settable in settables) { settable.SetColor(m_CurrentColor); } // ... 刷新视图 }接口方式更清晰也便于扩展比如未来可以支持设置材质颜色、粒子颜色等。PickColorFromSelection的实现private void PickColorFromSelection() { if (Selection.activeGameObject null) return; Graphic graphic Selection.activeGameObject.GetComponentGraphic(); if (graphic ! null) { m_CurrentColor graphic.color; Color.RGBToHSV(m_CurrentColor, out m_Hue, out m_Saturation, out m_Value); UpdateHexFromColor(); Repaint(); // 重绘编辑器窗口更新UI显示 } }4. 高级功能扩展与性能优化4.1 运行时调色板组件的封装为了让美术和策划在游戏运行时也能微调UI颜色我们需要将之前制作的UIColorPickerPrefab封装成一个完整的运行时调试面板。创建一个RuntimeColorPalette脚本它管理一个UIColorPicker实例、几个显示当前颜色值的Text组件以及应用颜色的按钮。这个面板可以设置为默认隐藏通过一个特定的快捷键例如F8呼出。在Awake或Start中使用DontDestroyOnLoad让它在场景切换时保留。RuntimeColorPalette监听UIColorPicker的OnColorChanged事件实时更新预览和数值显示。它的“应用”按钮逻辑与编辑器工具类似但目标是通过FindObjectOfType或事件系统来寻找场景中需要改色的UI对象可以设计一个调试模式点击运行时UI元素即将其注册为当前目标。4.2 颜色主题Color Theme的集成与保存一个更工程化的做法是将调色板工具与项目的“颜色主题”系统结合。我们可以创建ScriptableObject资源来定义一套颜色主题例如PrimaryColorSecondaryColorTextColor等。调色板工具在应用颜色时不仅可以应用到具体UI组件还可以将颜色值保存到当前激活的ColorTheme资产中。这样所有引用该主题颜色的UI都会自动更新实现了全局配色管理。在编辑器工具中可以增加一个ObjectField用来关联一个ColorTheme资产并列出其所有的颜色属性点击即可用调色板修改并保存。4.3 性能注意事项与优化点OnGUI调用频率EditorWindow的OnGUI在每帧都可能被调用尤其是窗口处于焦点时。要避免在其中进行昂贵的计算或分配大量临时对象。我们的代码目前是轻量的。颜色历史记录的渲染在历史记录部分我们为每个颜色按钮都调用了GUILayout.Button和EditorGUI.DrawRect。当历史记录很多时这可能会影响性能。可以考虑使用GUI.Button和GUI.DrawTexture进行更手动的、非自动布局的绘制或者对历史记录列表进行分页。UIColorPicker的交互在拖拽色相环或SV方块时OnDrag会每帧触发导致频繁的颜色计算和事件触发。要确保UpdateVisuals方法内的计算是轻量的。对于复杂的颜色预览比如在指示器周围实时渲染阴影可能需要考虑优化。编辑器刷新ApplyColorToSelection中调用RepaintAllViews()会强制刷新所有编辑器视图在频繁操作时可能感觉卡顿。可以改为只标记场景视图为需要重绘SceneView.RepaintAll()或者仅在必要时刷新。5. 常见问题排查与实操心得5.1 开发过程中遇到的典型问题问题1色相环点击/拖拽时颜色计算不准确指示器乱跳。排查问题通常出在坐标转换上。RectTransformUtility.ScreenPointToLocalPointInRectangle返回的localPos是基于RectTransform的轴心Pivot的。而我们的归一化计算假设矩形左下角是(0,0)。需要根据RectTransform的pivot和rect进行校正。解决在HandleInput方法中获取rectTransform.rect后计算归一化坐标的公式应修正为Vector2 normalizedPos new Vector2( (localPos.x - rect.xMin) / rect.width, (localPos.y - rect.yMin) / rect.height );同时确保色相环Image的RectTransform轴心在中心(0.5, 0.5)SV方块的轴心在左下角(0,0)这样计算逻辑最直观。问题2应用颜色后场景中的UI没有立即刷新显示新颜色。排查Unity编辑器不会自动每帧刷新非运行模式下的场景视图。修改组件属性后需要通知编辑器视图重绘。解决在ApplyColorToSelection方法中除了Undo.RecordObject和EditorUtility.SetDirty务必调用SceneView.RepaintAll()来刷新场景视图。如果Inspector中正在查看该对象可能还需要调用EditorApplication.DirtyHierarchyWindowItems()或简单地通过Selection.activeObject null; Selection.activeObject graphic;来触发Inspector刷新。问题3运行时调色板面板在切换场景后被销毁。排查面板GameObject是当前场景的一部分默认会随场景卸载而销毁。解决在面板根GameObject的初始化脚本如RuntimeColorPalette的Awake方法中调用DontDestroyOnLoad(this.gameObject)。同时要做好单例管理防止重复创建。5.2 实操心得与技巧分享分离数据与表现UIColorPicker脚本只负责交互和颜色数据HSV的管理以及触发OnColorChanged事件。它不关心颜色用在哪里。这种设计使得它可以被轻松复用于编辑器工具、运行时调试工具甚至游戏内的自定义颜色选择界面。善用Undo在编辑器工具中修改任何场景对象或资源务必使用Undo.RecordObject或Undo.RecordObjects。这是实现撤销/重做功能的关键也是专业编辑器工具的基本素养。忘记加Undo策划或美术误操作后无法回退体验极差。为颜色历史记录使用PlayerPrefs或EditorPrefs为了让工具状态在Unity编辑器重启后得以保留可以将m_ColorHistory列表序列化如转换成Color32数组再转成string后保存到EditorPrefs中。在OnEnable时加载。这大大提升了工具的实用性。提供多种颜色输入方式除了HSV滑块和调色板点击务必支持十六进制输入。这是和设计师沟通的“通用语言”。可以再加入RGB0-255整数输入满足不同习惯的用户。考虑色盲模式作为一个专业的UI工具可以考虑在界面上增加一个复选框模拟不同色盲类型下的颜色显示效果。这能帮助开发者创建更具包容性的UI配色。实现上可以查找标准的色盲模拟矩阵在颜色预览时应用该矩阵进行转换。这个UGUI调色板工具从构思到实现涉及了从底层交互处理到上层编辑器集成的完整链条。它可能不是功能最全的但通过这个实战项目我们清晰地走通了一条自定义Unity工具的开发路径。最终产出的工具不仅能立刻提升日常开发效率其模块化的设计如UIColorPicker组件也能为项目其他功能所用。开发工具的本质就是将重复、繁琐的操作自动化、可视化把时间留给更有创造性的工作。