Unity编辑器脚本实现PNG图集自动切割与命名工具开发指南
1. 项目概述与核心价值在游戏开发、UI设计或者数字孪生项目中我们经常会遇到一个头疼的问题美术同学给过来一张巨大的PNG图集里面密密麻麻排布了几十甚至上百个图标、角色部件或者UI元素。手动用Photoshop或者在线工具一个个切不仅效率低下还容易出错特别是当图集更新频繁时重复劳动简直让人崩溃。更别提后续还要给每个切片按特定规则命名方便程序调用。这个痛点相信很多Unity开发者都深有体会。今天要聊的就是如何利用Unity Editor脚本彻底自动化这个“大PNG图片切割与命名”的流程。这不仅仅是写个工具那么简单它背后涉及对Unity编辑器扩展、纹理处理、文件系统操作以及项目资产管线的深度理解。通过编写一个自定义的Editor窗口工具我们可以实现一键导入图集自动识别网格Grid或自定义区域进行切割并按照我们预设的命名规则比如“Icon_01”、“UI_Button_Red”批量生成独立的Sprite或Texture2D资产。这个工具的价值在于它将美术资源导入的“最后一公里”完全自动化将人力从重复、机械的劳动中解放出来让开发者能更专注于创意和逻辑实现。无论是独立开发者还是团队协作一个稳定高效的资源预处理管线都是提升生产力的关键。2. 工具整体设计与思路拆解2.1 为什么选择Editor脚本而非运行时处理首先需要明确一个核心概念资源导入和切割是一个编辑时Edit-time行为而非运行时Run-time。我们是在Unity编辑器内准备游戏资产这个过程不应该、也通常不能依赖游戏逻辑。Editor脚本运行在Unity编辑器的上下文中拥有完整的API来操作项目窗口Project Window、资产数据库AssetDatabase和导入设置Import Settings。相比之下运行时脚本受到沙盒限制无法直接创建或修改项目中的资产文件。因此构建这样一个自动化工具Editor脚本是唯一且正确的选择。2.2 核心功能模块设计一个完整的自动切割命名工具至少需要包含以下几个核心模块图形用户界面GUI模块提供一个直观的EditorWindow让用户能够选择源PNG文件、设置切割参数如单格尺寸、间隔、轴心点、定义命名规则并触发切割操作。图像解析与切割算法模块这是工具的核心引擎。它需要读取PNG文件的像素数据根据用户设定的参数固定网格、自定义边界框等计算出每一个子图片的矩形区域Rect。资产创建与写入模块将计算出的每个矩形区域的像素数据写入到新的Texture2D对象中并调用AssetDatabase的API将其保存为项目中的独立资产文件.png或.asset。命名规则引擎模块提供一套灵活可配置的命名规则系统。例如支持前缀、后缀、索引自动递增、行列坐标、以及根据源文件名衍生等。2.3 方案选型与第三方依赖考量对于基础的网格切割我们可以完全依赖Unity自身的Texture2D类和像素级操作来实现无需任何第三方插件。这保证了工具的轻量化和可移植性。然而如果需求更复杂比如需要识别非标准网格元素大小不一、有透明间隙分隔则可能需要更复杂的图像分析算法。这时可以评估引入轻量级的图像处理库例如通过DLL调用或命令行调用ImageMagick但这会显著增加工具的复杂性和部署成本。对于绝大多数标准的UI图集、精灵动画图集基于固定网格的切割已经足够。因此我们的设计将首先聚焦于实现稳定可靠的网格切割并预留扩展接口以备未来之需。3. 核心细节解析与实操要点3.1 PNG纹理的导入设置与读写权限这是第一个容易踩坑的地方。Unity默认导入的PNG纹理其Texture2D对象可能是压缩过的如DXT、ASTC并且isReadable属性为false。这意味着我们无法直接通过GetPixels()方法读取其原始像素数据。解决方案在切割之前我们必须确保源纹理是可读的。有两种方法脚本临时修改在工具代码中通过TextureImporter获取纹理的导入器临时将其isReadable属性设置为true然后调用AssetDatabase.ImportAsset重新导入。操作完成后可以再改回去。但这会改变项目设置可能影响团队协作。推荐使用Texture2D.LoadImage在工具运行时直接使用System.IO.File.ReadAllBytes读取PNG文件的原始字节然后通过Texture2D.LoadImage(byte[] data)在内存中创建一个全新的、可读的Texture2D对象。这个对象独立于项目资产库中的纹理专供切割使用不会影响原始文件的导入设置。这是更安全、更推荐的做法。// 示例安全地加载可读纹理 string filePath “Assets/Art/UI/Atlas.png”; byte[] fileData File.ReadAllBytes(filePath); Texture2D sourceTexture new Texture2D(2, 2); // 临时尺寸会被LoadImage覆盖 sourceTexture.LoadImage(fileData); // 这个方法会自动根据字节流创建正确尺寸的可读纹理 // 现在可以对 sourceTexture 进行 GetPixels() 操作了3.2 切割算法处理透明边与像素偏移即使是标准的网格图集元素之间也可能存在1-2像素的透明间隙用于防止纹理采样时出现“ bleeding ”颜色渗漏。我们的切割算法需要能够处理这种情况。核心思路用户提供“单元格宽度”、“单元格高度”、“水平间隔”、“垂直间隔”。假设图集从左到右、从上到下排列。对于第(row, col)个元素从0开始其左上角在纹理中的像素坐标计算如下startX col * (cellWidth horizontalSpacing) startY textureHeight - (row 1) * (cellHeight verticalSpacing) // 注意Unity纹理坐标原点在左下角然后我们从(startX, startY)这个坐标开始读取cellWidth * cellHeight的像素区域。注意事项边界检查必须确保计算出的矩形区域完全在源纹理的边界内否则GetPixels会抛出异常。纹理坐标原点Unity中纹理的(0,0)点在左下角而我们在处理行列时通常习惯从上到下。上面的startY计算公式实现了这个转换。非网格切割如果需要支持不规则区域就需要设计更复杂的交互比如在Editor窗口中绘制Gizmo让用户框选或者解析JSON等配置文件来定义每个区域的Rect。这将是工具的高级功能。3.3 命名规则引擎的设计一个健壮的命名规则引擎是提升工具可用性的关键。它应该支持变量替换和格式化。基础变量{SOURCE_NAME}: 源文件名不含扩展名。{INDEX}: 自动递增的序号如001, 002。{ROW}: 行号从0或1开始。{COL}: 列号从0或1开始。{WIDTH}: 子图宽度。{HEIGHT}: 子图高度。命名模式示例“{SOURCE_NAME}_{ROW}_{COL}”-“Atlas_0_0”,“Atlas_0_1”“Icon_{INDEX:000}”-“Icon_001”,“Icon_002”“UI/Button/{SOURCE_NAME}_{INDEX}”- 甚至可以包含子目录路径工具需要自动创建文件夹。在代码中我们需要解析用户输入的格式字符串在生成每个子图时用实际值替换这些变量。使用string.Format或正则表达式可以方便地实现。4. 实操过程与核心环节实现下面我们将一步步构建这个Editor工具。我们将创建一个名为TextureSlicerWindow的编辑器窗口。4.1 创建Editor窗口与基础UI首先在项目的Editor文件夹下如果没有就创建一个Editor文件夹下的脚本不会被打进游戏包创建一个C#脚本例如TextureSlicerWindow.cs。using UnityEditor; using UnityEngine; using System.IO; using System.Collections.Generic; // 用于列表 public class TextureSlicerWindow : EditorWindow { // 序列化字段用于在窗口关闭后保存状态 [SerializeField] private Texture2D sourceTexture; [SerializeField] private string sourceTexturePath; [SerializeField] private int cellWidth 64; [SerializeField] private int cellHeight 64; [SerializeField] private int spacingX 0; [SerializeField] private int spacingY 0; [SerializeField] private string namingPattern “{SOURCE_NAME}_{ROW}_{COL}”; [SerializeField] private string outputFolder “Assets/SlicedSprites/”; private Vector2 scrollPosition; [MenuItem(“Tools/Texture Slicer”)] // 在Unity菜单栏添加入口 public static void ShowWindow() { var window GetWindowTextureSlicerWindow(); window.titleContent new GUIContent(“Texture Slicer”); window.Show(); } void OnGUI() { scrollPosition EditorGUILayout.BeginScrollView(scrollPosition); EditorGUILayout.Space(10); // 1. 选择源纹理 EditorGUI.BeginChangeCheck(); sourceTexture (Texture2D)EditorGUILayout.ObjectField(“Source Texture”, sourceTexture, typeof(Texture2D), false); if (EditorGUI.EndChangeCheck() sourceTexture ! null) { sourceTexturePath AssetDatabase.GetAssetPath(sourceTexture); } EditorGUILayout.LabelField(“Path:”, sourceTexturePath ?? “None”); // 2. 切割参数 GUILayout.Label(“Slice Settings”, EditorStyles.boldLabel); cellWidth EditorGUILayout.IntField(“Cell Width”, cellWidth); cellHeight EditorGUILayout.IntField(“Cell Height”, cellHeight); spacingX EditorGUILayout.IntField(“Horizontal Spacing”, spacingX); spacingY EditorGUILayout.IntField(“Vertical Spacing”, spacingY); // 3. 命名与输出设置 GUILayout.Label(“Output Settings”, EditorStyles.boldLabel); namingPattern EditorGUILayout.TextField(“Naming Pattern”, namingPattern); EditorGUILayout.LabelField(“Example:”, GenerateExampleName(0, 0)); outputFolder EditorGUILayout.TextField(“Output Folder”, outputFolder); // 4. 信息预览 if (sourceTexture ! null cellWidth 0 cellHeight 0) { int cols Mathf.Max(1, (sourceTexture.width spacingX) / (cellWidth spacingX)); int rows Mathf.Max(1, (sourceTexture.height spacingY) / (cellHeight spacingY)); EditorGUILayout.HelpBox($Preview: Grid of {rows}行 x {cols}列 总计约 {rows * cols} 个子图。, MessageType.Info); } EditorGUILayout.Space(20); // 5. 执行按钮 GUI.enabled sourceTexture ! null Directory.Exists(outputFolder); if (GUILayout.Button(“Slice Texture!”, GUILayout.Height(40))) { SliceTexture(); } GUI.enabled true; EditorGUILayout.EndScrollView(); } private string GenerateExampleName(int row, int col) { // 简单的示例名称生成逻辑 string sourceName sourceTexture ! null ? Path.GetFileNameWithoutExtension(sourceTexturePath) : “SOURCE”; return namingPattern .Replace(“{SOURCE_NAME}”, sourceName) .Replace(“{ROW}”, row.ToString()) .Replace(“{COL}”, col.ToString()) .Replace(“{INDEX}”, (row * 10 col).ToString()); // 简单模拟索引 } }这段代码创建了一个基本的窗口包含纹理选择、参数输入和预览。GenerateExampleName方法用于实时展示命名规则的效果。4.2 实现核心切割与保存逻辑接下来在TextureSlicerWindow类中添加最关键的SliceTexture方法。private void SliceTexture() { if (sourceTexture null || string.IsNullOrEmpty(sourceTexturePath)) { EditorUtility.DisplayDialog(“Error”, “Please select a source texture first.”, “OK”); return; } // 安全加载可读的纹理 byte[] fileData File.ReadAllBytes(sourceTexturePath); Texture2D readableTexture new Texture2D(2, 2); readableTexture.LoadImage(fileData); // 关键步骤从字节创建可读纹理 int texWidth readableTexture.width; int texHeight readableTexture.height; // 计算网格行列数 int cols (texWidth spacingX) / (cellWidth spacingX); int rows (texHeight spacingY) / (cellHeight spacingY); if (cols 0 || rows 0) { EditorUtility.DisplayDialog(“Error”, “Invalid cell size or spacing for the given texture.”, “OK”); UnityEngine.Object.DestroyImmediate(readableTexture); return; } // 确保输出目录存在 if (!Directory.Exists(outputFolder)) { Directory.CreateDirectory(outputFolder); AssetDatabase.Refresh(); } string sourceName Path.GetFileNameWithoutExtension(sourceTexturePath); int sliceIndex 0; Liststring generatedAssetPaths new Liststring(); // 开始切割 for (int row 0; row rows; row) { for (int col 0; col cols; col) { // 计算当前子图的像素区域 int x col * (cellWidth spacingX); int y texHeight - ((row 1) * (cellHeight spacingY)); // 从底部开始计算Y // 安全检查 if (x cellWidth texWidth || y cellHeight texHeight || y 0) { Debug.LogWarning($Slice at ({row},{col}) is out of texture bounds. Skipping.”); continue; } // 1. 创建新的Texture2D并复制像素 Texture2D newSlice new Texture2D(cellWidth, cellHeight, readableTexture.format, false); Color[] pixels readableTexture.GetPixels(x, y, cellWidth, cellHeight); newSlice.SetPixels(pixels); newSlice.Apply(); // 应用像素更改 // 2. 生成文件名 string fileName namingPattern .Replace(“{SOURCE_NAME}”, sourceName) .Replace(“{ROW}”, row.ToString()) .Replace(“{COL}”, col.ToString()) .Replace(“{INDEX}”, sliceIndex.ToString(“D3”)) // D3表示3位数字如001 .Replace(“{WIDTH}”, cellWidth.ToString()) .Replace(“{HEIGHT}”, cellHeight.ToString()); fileName “.png”; // 统一保存为PNG // 处理路径中的目录分隔符支持在命名规则中定义子文件夹 fileName fileName.Replace(‘\\’, ‘/’); string fullOutputPath Path.Combine(outputFolder, fileName); string directoryPath Path.GetDirectoryName(fullOutputPath); if (!Directory.Exists(directoryPath)) { Directory.CreateDirectory(directoryPath); } // 3. 将Texture2D编码为PNG字节并保存 byte[] pngData newSlice.EncodeToPNG(); File.WriteAllBytes(fullOutputPath, pngData); generatedAssetPaths.Add(fullOutputPath); // 4. 销毁内存中的临时Texture2D对象 UnityEngine.Object.DestroyImmediate(newSlice); sliceIndex; // 进度条显示 EditorUtility.DisplayProgressBar(“Slicing Texture…”, $Processing slice {sliceIndex} of {rows * cols}…”, (float)sliceIndex / (rows * cols)); } } // 清理和刷新 UnityEngine.Object.DestroyImmediate(readableTexture); EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); // 关键让Unity识别新创建的资产文件 // 可选将生成的PNG文件批量设置为Sprite类型 foreach (var assetPath in generatedAssetPaths) { TextureImporter importer AssetImporter.GetAtPath(assetPath) as TextureImporter; if (importer ! null) { importer.textureType TextureImporterType.Sprite; // 可以在这里进一步设置Sprite的轴心点Pivot、网格类型等 // importer.spritePivot new Vector2(0.5f, 0.5f); importer.SaveAndReimport(); } } AssetDatabase.Refresh(); EditorUtility.DisplayDialog(“Complete”, $Successfully sliced {sliceIndex} sprites into ‘{outputFolder}’.”, “OK”); }4.3 功能增强支持自定义轴心点与Sprite模式上面的代码将切片保存为普通的PNG纹理。但在Unity中我们更常将其用作Sprite。我们可以在保存后通过TextureImporter批量设置其属性。修改上面循环后的部分在SliceTexture方法末尾添加Sprite设置的循环。更佳的做法是在UI上增加一个TextureImporterType枚举下拉菜单让用户选择输出类型Default, Sprite, NormalMap等并增加一个Vector2字段用于设置轴心点。在类中添加字段[SerializeField] private TextureImporterType outputTextureType TextureImporterType.Sprite; [SerializeField] private Vector2 spritePivot new Vector2(0.5f, 0.5f);在OnGUI中添加对应的UI控件。然后在保存文件后的循环中使用用户的选择来配置TextureImporter。注意直接操作TextureImporter并调用SaveAndReimport()会触发资源重新导入对于大量文件可能稍慢。但它保证了设置能持久化保存到项目的.meta文件中。5. 常见问题与排查技巧实录在实际使用和开发这个工具的过程中你肯定会遇到各种问题。下面是我踩过的一些坑和解决方案。5.1 问题切割出来的图片全是粉色Missing现象工具运行完毕在Project窗口中也看到了生成的PNG文件但它们的预览图是粉色的导入到Image组件的Source Image里也不显示。原因与排查纹理不可读这是最常见的原因。确保你在切割时使用的是通过LoadImage创建的Texture2D对象而不是直接从AssetDatabase获取的、isReadable为false的纹理引用。文件保存路径错误File.WriteAllBytes保存的路径不在Unity项目的Assets目录或其子目录下。Unity只会监控Assets和Packages文件夹。检查outputFolder变量它必须以“Assets/…”开头。未调用AssetDatabase.Refresh()File.WriteAllBytes是操作系统级别的文件操作Unity编辑器不会自动感知。必须在所有文件写入操作后调用AssetDatabase.Refresh()来让Unity导入这些新文件。纹理格式不支持EncodeToPNG方法对某些纹理格式如压缩格式支持不好。确保你用于保存的newSlice纹理格式是TextureFormat.RGBA32这类无损格式。在创建newSlice时明确指定格式new Texture2D(cellWidth, cellHeight, TextureFormat.RGBA32, false)。5.2 问题切割区域错位或大小不对现象生成的子图内容看起来是错的可能包含了相邻图块的部分或者大小不是设定的cell尺寸。原因与排查纹理坐标计算错误重点检查startY的计算公式。牢记Unity纹理坐标原点在左下角。如果图集制作软件如TexturePacker的导出设置是“Y轴向下”而你的计算是按“Y轴向上”来的就会导致行序颠倒。使用Debug.Log打印出前几个子图的(x, y)坐标并与在Photoshop中观察到的位置进行对比。间隔Spacing计算错误在计算cols和rows时以及计算每个子图起始坐标时必须把间隔spacing算进去。公式x col * (cellWidth spacingX)是正确的。如果忽略了spacingX当图集有间隔时从第二列开始坐标就会错位。源纹理尺寸与网格不匹配不是所有的图集都能被整除。计算出行列数后最好在UI上显示出来让用户确认。也可以增加一个“忽略边缘余量”的选项或者支持自定义行/列数覆盖自动计算。5.3 问题批量设置Sprite属性时卡顿或报错现象在循环中设置TextureImporter并调用SaveAndReimport()时编辑器反应变慢或者有时会抛出“AssetDatabase is already being updated”的异常。优化技巧批量操作延迟刷新不要在每个文件保存后立即刷新。将所有文件的路径保存到Liststring中在所有文件都写入磁盘后只调用一次AssetDatabase.Refresh()。使用AssetDatabase.StartAssetEditing和StopAssetEditing这对API可以包裹住一大串资产导入操作让Unity在结束时才进行一次整体的刷新和编译极大提升性能。AssetDatabase.StartAssetEditing(); foreach (var assetPath in generatedAssetPaths) { TextureImporter importer AssetImporter.GetAtPath(assetPath) as TextureImporter; if (importer ! null) { importer.textureType outputTextureType; importer.spritePivot spritePivot; // 其他设置... importer.SaveAndReimport(); // 在Start/Stop块内这个操作会被批量处理 } } AssetDatabase.StopAssetEditing(); // 此时可以再调用一次AssetDatabase.Refresh()异步或协程如果切割的图片数量极其庞大比如上千张可以考虑使用EditorApplication.update回调或者协程在Editor脚本中需使用EditorCoroutine来分帧处理避免编辑器卡死无响应。可以在循环内每处理N个比如50个切片就yield return null一下。5.4 命名规则的高级用法与陷阱需求用户想按照“角色_部位_序号”的方式命名比如“Knight_Sword_01”、“Knight_Shield_02”。但图集里可能混排了不同角色的部件。解决方案基础的变量替换无法满足这种逻辑。可以考虑两种进阶方案配置文件驱动让用户提供一个CSV或JSON文件文件里明确列出每个网格位置row, col对应的最终名字。工具读取配置文件进行命名。这给了美术最大的灵活性。规则脚本提供一个简单的脚本接口例如继承一个基类实现一个GetName方法让程序员可以编写更复杂的命名逻辑。这增加了工具的扩展性但也提高了使用门槛。陷阱文件名非法字符。在生成文件名时务必过滤掉操作系统不允许的字符如\ / : * ? “ |。可以使用Path.GetInvalidFileNameChars()方法进行检查和替换。5.5 内存与性能考量大纹理处理处理4096x4096甚至更大的纹理时GetPixels()和SetPixels()会操作大量数据1600万像素的RGBA32纹理约占64MB内存。可以考虑使用GetPixels32和SetPixels32操作Color32结构体每个通道1字节或者使用Graphics.CopyTexture进行GPU加速的块复制如果硬件支持。对于极端情况可能需要将纹理分块处理。及时销毁对象在循环中创建的Texture2D newSlice在调用EncodeToPNG并保存文件后应立即调用UnityEngine.Object.DestroyImmediate(newSlice)释放内存。否则在切割大量子图时会造成巨大的内存峰值可能导致编辑器崩溃。进度反馈使用EditorUtility.DisplayProgressBar和EditorUtility.ClearProgressBar给用户明确的进度提示尤其是在处理大图集时这能有效避免用户误以为编辑器卡死而强行关闭。这个工具一旦构建完成可以集成到团队的生产管线中作为美术资源提交后的自动处理环节或者由策划、技术美术直接使用。它节省的时间是实实在在的更重要的是它消除了手动操作中难以避免的人为错误保证了资源命名和规格的一致性为后续的资源加载、管理和打包打下了良好的基础。