书接上回:【Unity编辑器扩展】(一)PSD转UGUI Prefab, Aspose.PSD和Harmony库的使用_TopGames的博客-CSDN博客
解放UI程序/美术? psd文件一键转ui prefab 支持所有ui类型 支持textmeshpro
psd一键转ugui prefab工具 设计原理和详细使用方法
工具使用预览:
工具目标:
1. 实现将psd解析生成为UI预制体,并导出UI图片。需支持UGUI和TextMeshProGUI, 如Button、Toggle、ScrollView、Text、Slider、Dropdown、Image、RawImage以及纯色填充图等UGUI元素(ps:纯色填充图:例如PS的纯色图层,通过调整Unity的Color可以达到相同效果,无需导出为纯色图片,以节省资源大小)
2. 要求工具使用简单便捷、并且能满足不同需求。支持自动解析类型和手动调节UI类型和层级。
3. 并且工具不能高度依赖或要求UI设计师必须遵守某种规范。
4. 工具只需要psd文件。不能依赖PS或Unity以外的第三方软件(目前已有的psd转ugui工具中通常依赖安装PS脚本,且对PS版本有严格要求,需强制UI设计师安装使用脚本,这很不方便且难以维护)
工具和工作流设计:
psd图层结构如图所示:
工具实现主要需解决以下问题:
1. 节点层级
从psd图层结构可以看出,psd图层与Unity Transform树状节点完全不同,psd只有"LayerGroup(组)"的概念,一个图层不能成为另一个图层的子图层,只能成为组的子图层。而UGUI对节点的层级有严格的要求,比如Button节点下通常会有个Text显示按钮文字,Text需作为Button的子节点进行布局。而psd中Button图片图层和文字图层无法以父子节点的形式存在。
所以,工具需要把psd图层解析为Unity 节点,每个节点绑定一个psd图层或组,这样程序就可以任意调整节点层级以满足自己的需求。
2. UI类型
psd图层类型最终只能归为两种,图片或文本。然而图片可以是Button、可以是Image,也可以是ScrollView的背景图,所以就需求为psd图层做类型标记,以便自动解析为对应的UI类型。图层名字是存放这一信息的最佳选择,但由于种种原因,你不能强制要求UI设计师遵守某种规范。
所以工具不能过于依赖UI设计师规范化,解析psd图层时先按照命名规范初始化UI类型,同时支持手动以下拉菜单的形式选择UI类型。
psd图层名称以.button(.btn)结尾命名规范,从而实现UI类型标记,如果没有标记则图像图层默认为常用的Image类型(可配RawImage),文本图层默认为Text类型(可配TextMeshProUGUI)。UI类型标识不区分大小写、并且支持自定义配置多种匹配字符,如:Button对应的标识可以配置为bt、btn、button等。
UI类型解析配置界面:
3. 复合型UI类型
例如ScrollView,有多个UI节点组成,一个ScrollView有背景图、Viewport遮罩图、水平滚动条/垂直滚动条(包含背景和滑块两张图)。这种情况就不是标记一个psd图层能够解决的了。因此可以当成一个组处理,比如把组当成ScrollView,ScrollView的背景图、遮罩图、滚动条对应的图层都包含在这个组中。
4. 实时预览
psd转ugui编辑界面需支持预览效果,如支持预览每个图层或组的图像、UI类型、是否导出图片等设置。
工作流总结:
1. 将psd文件导入Unity, 右键psd文件,选择Psd2UIForm Editor菜单,自动把psd解析为prefab节点树状图,并打开prefab
2. 若UI设计师未按照规范制作,可手动根据需求调节UI类型或UI层级(可选)
3. 点击扩展按钮,重新解析psd、导出碎图或生成UI界面prefab。(工具会自动记录psd文件最后修改时间,若发生变更,自动弹窗提示是否重新解析)
功能实现:
一,图层解析(psd转节点树状图prefab)
由于PS图层没有树状节点的概念,只有LayerGroup(组)类似树状节点。并且图层遮挡顺序自下而上,与Unity节点相反。Aspose.PSD解析出的layers数组是根据psd图层栏自下而上遍历所有图层。并且如果下一图层属于其它组内图层,还会出现一个SectionDividerLayer层,然后继续遍历图层。而这个SectionDividerLayer作用类似开始标签,如<SectionDividerLayer>和<LayerGroup>是组的开始/结束标签。
Aspose.PSD解析图层数组layers的顺序和索引如下:
1. 定义一个PsdLayerNode MonoBehavior脚本,用于关联到每个psd图层节点,每个PsdLayerNode绑定着一个PS图层(Layer)
2. 把psd文件解析为PsdLayerNode节点树状图:
/// <summary>
/// 把Psd图层解析成节点prefab
/// </summary>
/// <param name="psdPath"></param>
/// <returns></returns>
public static bool ParsePsd2LayerPrefab(string psdFile, Psd2UIFormConverter instanceRoot = null)
{
if (!File.Exists(psdFile))
{
Debug.LogError($"Error: Psd文件不存在:{psdFile}");
return false;
}
var texImporter = AssetImporter.GetAtPath(psdFile) as TextureImporter;
if (texImporter.textureType != TextureImporterType.Sprite)
{
texImporter.textureType = TextureImporterType.Sprite;
texImporter.mipmapEnabled = false;
texImporter.alphaIsTransparency = true;
texImporter.SaveAndReimport();
}
var prefabFile = GetPsdLayerPrefabPath(psdFile);
var rootName = Path.GetFileNameWithoutExtension(prefabFile);
bool needDestroyInstance = instanceRoot == null;
Psd2UIFormConverter rootLayer;
if (instanceRoot != null)
{
rootLayer = instanceRoot;
//清空已有节点重新解析
for (int i = rootLayer.transform.childCount - 1; i >= 0; i--)
{
GameObject.DestroyImmediate(rootLayer.transform.GetChild(i).gameObject);
}
}
else
{
rootLayer = CreatePsdLayerRoot(rootName);
}
rootLayer.SetPsdAsset(AssetDatabase.LoadAssetAtPath<UnityEngine.Sprite>(psdFile));
rootLayer.psdAssetChangeTime = GetAssetChangeTag(psdFile);
var psdOpts = new PsdLoadOptions()
{
LoadEffectsResource = true
};
using (var psd = Aspose.PSD.Image.Load(psdFile, psdOpts) as PsdImage)
{
List<GameObject> layerNodes = new List<GameObject> { rootLayer.gameObject };
for (int i = 0; i < psd.Layers.Length; i++)
{
var layer = psd.Layers[i];
var curLayerType = layer.GetLayerType();
if (curLayerType == PsdLayerType.SectionDividerLayer)
{
var layerGroup = (layer as SectionDividerLayer).GetRelatedLayerGroup();
var layerGroupIdx = ArrayUtility.IndexOf(psd.Layers, layerGroup);
var layerGropNode = CreatePsdLayerNode(layerGroup, layerGroupIdx);
layerNodes.Add(layerGropNode.gameObject);
}
else if (curLayerType == PsdLayerType.LayerGroup)
{
var lastLayerNode = layerNodes.Last();
layerNodes.Remove(lastLayerNode);
if (layerNodes.Count > 0)
{
var parentLayerNode = layerNodes.Last();
lastLayerNode.transform.SetParent(parentLayerNode.transform);
}
}
else
{
var newLayerNode = CreatePsdLayerNode(layer, i);
newLayerNode.transform.SetParent(layerNodes.Last().transform);
newLayerNode.transform.localPosition = Vector3.zero;
}
}
}
PrefabUtility.SaveAsPrefabAsset(rootLayer.gameObject, prefabFile, out bool savePrefabSuccess);
if (needDestroyInstance) GameObject.DestroyImmediate(rootLayer.gameObject);
AssetDatabase.Refresh();
if (savePrefabSuccess && AssetDatabase.GUIDFromAssetPath(StageUtility.GetCurrentStage().assetPath) != AssetDatabase.GUIDFromAssetPath(prefabFile))
{
PrefabStageUtility.OpenPrefab(prefabFile);
}
return savePrefabSuccess;
}
private void SetPsdAsset(Sprite texture2D)
{
this.psdAsset = texture2D;
if (string.IsNullOrWhiteSpace(this.uiFormName))
{
this.uiFormName = this.psdAsset.name;
}
}
/// <summary>
/// 获取解析好的psd layers文件
/// </summary>
/// <param name="psd"></param>
/// <returns></returns>
public static string GetPsdLayerPrefabPath(string psd)
{
return UtilityBuiltin.ResPath.GetCombinePath(Path.GetDirectoryName(psd), Path.GetFileNameWithoutExtension(psd) + "_psd_layers_parsed.prefab");
}
private static Psd2UIFormConverter CreatePsdLayerRoot(string rootName)
{
var node = new GameObject(rootName);
node.gameObject.tag = "EditorOnly";
var layerRoot = node.AddComponent<Psd2UIFormConverter>();
return layerRoot;
}
private static PsdLayerNode CreatePsdLayerNode(Layer layer, int bindLayerIdx)
{
string nodeName = layer.Name;
if (string.IsNullOrWhiteSpace(nodeName))
{
nodeName = $"PsdLayer-{bindLayerIdx}";
}
else
{
if (Path.HasExtension(layer.Name))
{
nodeName = Path.GetFileNameWithoutExtension(layer.Name);
}
}
var node = new GameObject(nodeName);
node.gameObject.tag = "EditorOnly";
var layerNode = node.AddComponent<PsdLayerNode>();
layerNode.BindPsdLayerIndex = bindLayerIdx;
InitLayerNodeData(layerNode, layer);
return layerNode;
}
二、显示预览图
事实上步骤一的解析只是把psd图层转换为可修改层级的GameObject节点,此时处于编辑阶段, 节点上也没有任何Render组件,并没有(也不应该)把图层导出成图片给节点显示。
编辑阶段还不明确用户需要导出图片的图层(而且若导出图片会触发Unity自动导入资源,影响体验),出于工具运行效率和用户体验的考量,不能直接生成图片文件。而是设计为用户首次点选节点时,把节点对应的图层转换为Texture2D实例以便预览,用户没点击的节点就不会转换。当点击导出碎图按钮时,标记为导出但没有转换为Texture2D实例的图层再执行转换操作。这样就大大提高了工具解析速度和效率。
1. 点选节点时,把psd图层的Bitmap转成Unity的Texture2D类型作为预览图(支持把psd整个组转换成一张图):
/// <summary>
/// 把psd图层转成Texture2D
/// </summary>
/// <param name="psdLayer"></param>
/// <returns>Texture2D</returns>
public Texture2D ConvertPsdLayer2Texture2D()
{
if (BindPsdLayer == null || BindPsdLayer.Disposed) return null;
var parentNode = transform.parent.GetComponent<PsdLayerNode>();
Rectangle bounds;
if (parentNode == null || (this.LayerType == PsdLayerType.LayerGroup && parentNode.LayerType != PsdLayerType.LayerGroup))
{
bounds = BindPsdLayer.GetFixedLayerBounds();
}
else
{
bounds = BindPsdLayer.Bounds;
}
MemoryStream ms = new MemoryStream();
var pngOpt = new PngOptions()
{
ColorType = Aspose.PSD.FileFormats.Png.PngColorType.TruecolorWithAlpha
};
BindPsdLayer.Save(ms, pngOpt, bounds);
var buffer = new byte[ms.Length];
ms.Position = 0;
ms.Read(buffer, 0, buffer.Length);
Texture2D texture = new Texture2D(bounds.Width, bounds.Height);
texture.alphaIsTransparency = true;
texture.LoadImage(buffer);
texture.Apply();
ms.Dispose();
return texture;
}
这里把psd组转成一张图会有一个坑,当组为最外层组时,Aspose.PSD库无法获取正确的图片区域(Rectangle),导致生成的图片有位置偏移。为了绕过这个坑我是通过遍历计算组的所有子图层区域,从而得到一个合并区域:
/// <summary>
/// 修复当LayerGroup为第一层时,对应Bounds错位
/// </summary>
/// <param name="layerGroup"></param>
/// <returns></returns>
public static Rectangle GetFixedLayerBounds(this Layer layerGroup)
{
if (layerGroup.GetLayerType() != PsdLayerType.LayerGroup)
{
return layerGroup.Bounds;
}
//根据子图层算出包围所有子图层的最小包围盒
var subLayers = (layerGroup as LayerGroup).Layers;
int minLeft = int.MaxValue;
int minTop = int.MaxValue;
int maxRight = int.MinValue;
int maxBottom = int.MinValue;
foreach (var item in subLayers)
{
var itemTp = item.GetLayerType();
if (itemTp == PsdLayerType.Unknown || itemTp == PsdLayerType.LayerGroup || itemTp == PsdLayerType.SectionDividerLayer) continue;
var itemBounds = item.Bounds;
if (item.Left < minLeft) minLeft = item.Left;
if (item.Top < minTop) minTop = item.Top;
if (item.Right > maxRight) maxRight = item.Right;
if (item.Bottom > maxBottom) maxBottom = item.Bottom;
}
//var result = new Rectangle(new Point(minLeft, minTop), new Size(maxRight - minLeft, maxBottom - minTop));
var result = new Rectangle()
{
Top = minTop,
Left = minLeft,
Right = maxRight,
Bottom = maxBottom
};
return result;
}
2. 重写HasPreviewGUI、OnPreviewGUI、GetInfoString函数以自定义显示Inspector面板的预览界面:
[CanEditMultipleObjects]
[CustomEditor(typeof(PsdLayerNode))]
public class PsdLayerNodeInspector : Editor
{
PsdLayerNode targetLogic;
private void OnEnable()
{
targetLogic = target as PsdLayerNode;
targetLogic.RefreshLayerTexture();
}
public override void OnInspectorGUI()
{
serializedObject.Update();
base.OnInspectorGUI();
EditorGUI.BeginChangeCheck();
{
targetLogic.UIType = (GUIType)EditorGUILayout.EnumPopup("UI Type", targetLogic.UIType);
if (EditorGUI.EndChangeCheck())
{
targetLogic.SetUIType(targetLogic.UIType);
}
}
serializedObject.ApplyModifiedProperties();
}
public override bool HasPreviewGUI()
{
return targetLogic.PreviewTexture != null;
}
public override void OnPreviewGUI(Rect r, GUIStyle background)
{
GUI.DrawTexture(r, targetLogic.PreviewTexture, ScaleMode.ScaleToFit);
//base.OnPreviewGUI(r, background);
}
public override string GetInfoString()
{
//var size = targetLogic.PreviewTexture.Size();
return targetLogic.LayerInfo;
}
}
3. Scene界面显示图层边界debug框
PS的坐标系左上角为原点(0,0),而Unity Scene界面坐标系原点为视口中心。把Aspose.PSD Rectangle转换为Unity Rect:
/// <summary>
/// 获取psd图层的Rect边框(Unity坐标系)
/// </summary>
/// <param name="layer"></param>
/// <returns></returns>
public static Rect GetLayerRect(this Layer layer)
{
var layerTp = layer.GetLayerType();
int left, right, top, bottom;
if (layerTp == PsdLayerType.LayerGroup)
{
var bounds = layer.GetFixedLayerBounds();
left = bounds.Left;
right = bounds.Right;
top = bounds.Top;
bottom = bounds.Bottom;
}
else
{
left = layer.Left;
right = layer.Right;
top = layer.Top;
bottom = layer.Bottom;
}
float halfWidth = Mathf.Abs(right - left) * 0.5f;
float halfHeight = Mathf.Abs(bottom - top) * 0.5f;
var canvasSize = layer.Container.Size;
Rect result = new Rect(left + halfWidth - canvasSize.Width * 0.5f, canvasSize.Height - (top + halfHeight) - canvasSize.Height * 0.5f, right - left, bottom - top);
return result;
}
为PsdLayerNode写一个CustomEditor,当点选节点时就会触发OnEnable方法,在首次OnEnable时生成预览Texture2D:
[CanEditMultipleObjects]
[CustomEditor(typeof(PsdLayerNode))]
public class PsdLayerNodeInspector : Editor
{
PsdLayerNode targetLogic;
private void OnEnable()
{
targetLogic = target as PsdLayerNode;
targetLogic.RefreshLayerTexture();
}
}
[ExecuteInEditMode]
[DisallowMultipleComponent]
public class PsdLayerNode : MonoBehaviour
{
[ReadOnlyField] public int BindPsdLayerIndex = -1;
[ReadOnlyField][SerializeField] PsdLayerType mLayerType = PsdLayerType.Unknown;
[SerializeField] public bool ExportImage;
[HideInInspector] public GUIType UIType;
public Texture2D PreviewTexture { get; private set; }
public string LayerInfo { get; private set; }
public Rect LayerRect { get; private set; }
public PsdLayerType LayerType { get => mLayerType; }
/// <summary>
/// 绑定的psd图层
/// </summary>
private Layer mBindPsdLayer;
public Layer BindPsdLayer
{
get => mBindPsdLayer;
set
{
mBindPsdLayer = value;
mLayerType = mBindPsdLayer.GetLayerType();
LayerRect = mBindPsdLayer.GetLayerRect();
LayerInfo = $"{LayerRect}";
}
}
public bool RefreshLayerTexture(bool forceRefresh = false)
{
if (!forceRefresh && PreviewTexture != null)
{
return true;
}
if (BindPsdLayer == null || BindPsdLayer.Disposed) return false;
var pngOpt = new PngOptions
{
ColorType = Aspose.PSD.FileFormats.Png.PngColorType.TruecolorWithAlpha
};
if (BindPsdLayer.CanSave(pngOpt))
{
if (PreviewTexture != null)
{
DestroyImmediate(PreviewTexture);
}
PreviewTexture = this.ConvertPsdLayer2Texture2D();
}
return PreviewTexture != null;
}
}
在根节点的Psd2UIFormConverter脚本的OnGizmos方法中统一绘制所有图层节点边框:
private void OnDrawGizmos()
{
if (drawLayerRectGizmos)
{
var nodes = this.GetComponentsInChildren<PsdLayerNode>();
Gizmos.color = drawLayerRectGizmosColor;
foreach (var item in nodes)
{
if (item.NeedExportImage())
{
Gizmos.DrawWireCube(item.LayerRect.position * 0.01f, item.LayerRect.size * 0.01f);
}
}
}
}
三,导出UI碎图
可通过勾选控制需要导出的图片,被其它UI元素引用的图层即使不勾选也会导出图片。导出的图片Unity默认为Texture类型,所以还需要用代码转换为Sprite类型。
代码实现:
/// <summary>
/// 导出psd图层为Sprites碎图
/// </summary>
/// <param name="psdAssetName"></param>
internal void ExportSprites()
{
var exportLayers = this.GetComponentsInChildren<PsdLayerNode>().Where(node => node.NeedExportImage());
var exportDir = UtilityBuiltin.ResPath.GetCombinePath(Path.GetDirectoryName(PsdAssetName), this.uiFormName);
if (!Directory.Exists(exportDir))
{
Directory.CreateDirectory(exportDir);
}
int exportIdx = 1;
int totalCount = exportLayers.Count();
foreach (var layer in exportLayers)
{
if (layer.RefreshLayerTexture())
{
var bytes = layer.PreviewTexture.EncodeToPNG();
var imgName = string.IsNullOrWhiteSpace(layer.name) ? $"UI_Layer_{layer.BindPsdLayerIndex}" : layer.name;
var imgFileName = UtilityBuiltin.ResPath.GetCombinePath(exportDir, imgName + ".png");
if (File.Exists(imgFileName))
{
imgFileName = UtilityBuiltin.ResPath.GetCombinePath(exportDir, imgName + $"_{layer.BindPsdLayerIndex}.png");
}
EditorUtility.DisplayProgressBar($"导出({exportIdx}/{totalCount})", $"导出Image文件:{imgFileName}", exportIdx / (float)totalCount);
File.WriteAllBytes(imgFileName, bytes);
}
}
EditorUtility.ClearProgressBar();
AssetDatabase.Refresh();
ConvertTextureToUIByDir(exportDir);
}
/// <summary>
/// 把指定目录下所有Texture转换为Sprite类型
/// </summary>
/// <param name="dir"></param>
public static void ConvertTextureToUIByDir(string dir)
{
var guidArr = AssetDatabase.FindAssets("t:Texture", new string[] { dir });
foreach (var item in guidArr)
{
var assetName = AssetDatabase.GUIDToAssetPath(item);
var texImporter = AssetImporter.GetAtPath(assetName) as TextureImporter;
if (texImporter == null) continue;
texImporter.textureType = TextureImporterType.Sprite;
texImporter.spriteImportMode = SpriteImportMode.Single;
texImporter.alphaSource = TextureImporterAlphaSource.FromInput;
texImporter.alphaIsTransparency = true;
texImporter.mipmapEnabled = false;
texImporter.SaveAndReimport();
}
}