首先,产生问题
在Unity中经常会遇见导入图片文件资源然后进行切割的情况,大部分时候是随着游戏制作的逐步进行而逐渐导入图片的,因此图片的切割与切割后Sprite的重命名的工作看起来并没有那么可怕。
但是还是有一些例外的情况存在,比如说此次的开发过程中,开发的主要目的是做出一款开源游戏,因此打算直接为创作者准备一套别人的开源的角色立绘,总共将近200个角色,立绘总数大约10000个左右,一次性导入,如果再手动切割并手动重命名那实在是太恐怖了,所以学习了如何编写代码实现自动切割,这里分享一下方法
然后,解决方法有其局限性
探索出来的解决方法是有局限性的,主要再批量重命名方面。对于自动切割而言,需要确保本张图片切割下来的内容大小都是相同的,至少也需要是有规律的。批量重命名方面,则需要准备好一个与切割后图片顺序相对应的命名文本才可以进行。
接着,来开始具体实施,首先是切割
首先是自动切割。
在新版本的Unity中,对于Sprite Editor的自动化流程有了较大的变动,原先的部分API已经完全移除无法使用,需要使用更新的内容。
而更新的内容指的就是ISpriteDataProviderFactory这个接口及其附属接口与类,通过这个接口相关的类我们最终可以获得到某个纹理文件的对应的Sprite文件的元数据,接着对元数据进行修改。
那么具体实施的第一步就是获取这个元数据:
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using UnityEditor.U2D.Sprites;
public class BatchSliceSprites
{
//设置菜单项目
[MenuItem("Tools/Sprites/AutoSliceSprites")]
public static void AutoSliceSprites()
{
//切割后图片长宽
int slicedWidth = 703;
int slicedHeight = 1000;
//对选中的每个物体进行遍历
foreach(var obj in Selection.objects)
{
if(obj is Texture2D tex)
{
//获取Sprite的数据
var factory = new SpriteDataProviderFactories();
factory.Init();
var dataProvider = factory.GetSpriteEditorDataProviderFromObject(obj);
dataProvider.InitSpriteEditorDataProvider();
}
}
}
}
如代码所示,首先是创建了一个Unity的菜单项目方便运行该Editor脚本,然后设置了切割后的精灵长宽,接着就是获取数据。此处的获取数据的方法是对选中的资源文件进行遍历,从中挑出为Texture2D的资源文件,也就是导入的图片文件,随后首先获取这个Factory,接着对其进行初始化,然后从这个Factory中获取DataProvider,也就是我们所需的数据,接着对这个DataProvider同样进行初始化以获取该资源文件对应的Sprite的DataProvider(数据提供者)。
在获取数据提供者之后,就可以开始具体的操作了。我们本次的目的是自动切分,换到代码中,就是创建多个用于表示切分出的Sprite区域的SpriteRect,将这些Rect放入一个列表中,接着用这个新的Rect列表替换DataProvider原有的列表即可。
在图片资源文件的切割过程中,并没有实际的Sprite被创造出来,至少并没有单独的Sprite被保存下来,而是以SpriteRect的方式存储在该图片的元数据当中,需要使用时,可以简单粗暴地理解为按照这个Rect截取了原图片的一部分作为Sprite来使用,因此DataProvider中存储的是SpriteRect的数组而不是直接的Sprite。
那么既然是创建出多个SpriteRect,这个数据与平时的Rect基本相同,只不过多出了几个用于Sprite的数据而已,因此我们就需要定位Rect的四角,此处的定位方式有很多,我选择了计算出X轴方向的切割次数和Y轴方向的切割次数,然后根据上文存储的切割后的图片大小进行了手动计算。
同时,此处还多出一个步骤,因为大部分的此类图片都不可避免地在切割完成后产生完全空白的Sprite区域,因此需要额外验证当前SpriteRect切出的区域是否为空白,一个简单的方法是检查背景色,如果当前像素块颜色不等于背景色,则说明不是空白,可继续执行。如果遍历结束均为背景色,则略过此次遍历。 我这里背景采用的是透明像素,因此我选择计算区域内所有像素的阿尔法值,如果为零,则略过此次循环,达到筛选的目的。代码如下:
//进行处理
//X轴方向切割次数
int slicedX = tex.width / slicedWidth;
//Y轴方向切割次数
int slicedY = tex.height / slicedHeight;
//切割后的SpriteRect列表
List<SpriteRect> rects = new();
//循环
for(int i = 0; i < slicedY; i++)
{
for(int j = 0; j < slicedX; j++)
{
//检测范围内是否为透明图层
float alpha = 0;
for (int x = j * slicedWidth; x < (j + 1) * slicedWidth; x++)
{
for(int y = tex.height - (i + 1) * slicedHeight; y < tex.height - i * slicedHeight; y++)
{
Color pixelColor = tex.GetPixel(x, y);
alpha += pixelColor.a;
}
}
if(alpha == 0)
{
//如果为纯透明图层,略过
break;
}
//检测结束后创建新的SpriteRect并添加到列表中
var newSprite = new SpriteRect()
{
//重命名
name = tex.name + "_" + i + "_" + j,
//生成ID
spriteID = GUID.Generate(),
//创建对应的Rect对象
rect = new Rect(j * slicedWidth, tex.height - (i + 1) * slicedHeight, slicedWidth, slicedHeight)
};
rects.Add(newSprite);
}
}
这里比较难理解的可能是创建Rect对象时数值的设置,此处可以查阅一下Rect具体是个什么东西,建议问AI,可以得到很详细的回答,或者最简单的方法,手动切割几下找找规律。
那么此时我们实际上已经完成了整个切割,接下来就是实际应用该切割,具体而言就是使用DataProvider的方法,将原有的SpriteRects进行替换,接着确认修改,随后要对资源进行重载以在资源层级上确认修改。代码如下:
//完事后更改
dataProvider.SetSpriteRects(rects.ToArray());
//确认修改
dataProvider.Apply();
//确认修改后输出修改信息
Debug.Log(tex.name + "已被切割,切割份数为" + rects.Count);
//重新载入以确保修改被应用
var assetImporter = dataProvider.targetObject as AssetImporter;
assetImporter.SaveAndReimport();
好的,那么事已至此,切割步骤已经完全完成,使用方法为预先设置好切割的长与宽,接着选中要切割的资源,多个资源也可以,点击新的菜单项下的选项即可。
如果有更复杂的需求,实际上只要需求是有规律性的,那么就一样可以实现自动化,此处的规律性指的是,比如说这10张图片,都是前5行按照480*960切割,后5行按照960*1920进行切割,那么就可以通过计数的方式来实现。但如果是无规律的,那么还请在摸鱼的同时手动处理吧。
完整代码(多了几个命名空间以及忘删除的using,忽略即可):
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Collections.Generic;
using UnityEditor.U2D.Sprites;
using JetBrains.Annotations;
namespace YYY
{
//用来自动切分大PNG图片为小精灵图的
//使用方法为选中需要切割的PNG图片,开始切割即可
//切割前需要手动更改切割后的图片长宽
public class BatchSliceSprites
{
//设置菜单项目
[MenuItem("Tools/Sprites/AutoSliceSprites")]
public static void AutoSliceSprites()
{
//切割后图片长宽
int slicedWidth = 703;
int slicedHeight = 1000;
//对选中的每个物体进行遍历
foreach(var obj in Selection.objects)
{
if(obj is Texture2D tex)
{
//获取Sprite的数据
var factory = new SpriteDataProviderFactories();
factory.Init();
var dataProvider = factory.GetSpriteEditorDataProviderFromObject(obj);
dataProvider.InitSpriteEditorDataProvider();
//进行处理
//X轴方向切割次数
int slicedX = tex.width / slicedWidth;
//Y轴方向切割次数
int slicedY = tex.height / slicedHeight;
//切割后的SpriteRect列表
List<SpriteRect> rects = new();
//循环
for(int i = 0; i < slicedY; i++)
{
for(int j = 0; j < slicedX; j++)
{
//检测范围内是否为透明图层
float alpha = 0;
for (int x = j * slicedWidth; x < (j + 1) * slicedWidth; x++)
{
for(int y = tex.height - (i + 1) * slicedHeight; y < tex.height - i * slicedHeight; y++)
{
Color pixelColor = tex.GetPixel(x, y);
alpha += pixelColor.a;
}
}
if(alpha == 0)
{
//如果为纯透明图层,略过
break;
}
//检测结束后创建新的SpriteRect并添加到列表中
var newSprite = new SpriteRect()
{
name = tex.name + "_" + i + "_" + j,
spriteID = GUID.Generate(),
rect = new Rect(j * slicedWidth, tex.height - (i + 1) * slicedHeight, slicedWidth, slicedHeight)
};
rects.Add(newSprite);
}
}
//完事后更改
dataProvider.SetSpriteRects(rects.ToArray());
//确认修改
dataProvider.Apply();
//确认修改后输出修改信息
Debug.Log(tex.name + "已被切割,切割份数为" + rects.Count);
//重新载入以确保修改被应用
var assetImporter = dataProvider.targetObject as AssetImporter;
assetImporter.SaveAndReimport();
}
}
}
}
}
继续,来批量重命名吧
进行切割后获取了一大片文件,如果需要重命名的话那肯定是一个繁琐还没有成就感的工作,所以这时候就又一次轮到了自动化的登场。
通过前面的切割处理。我们获取的图片是有顺序的,这个顺序就是从左到右从上到下依次排列,那么我们只需要准备一个对应顺序的文件名列表进行重命名即可。
本次案例中由于是人物立绘,我是首先利用Python的批处理工具将单独的人物立绘整合到一张图上,再将这张图导入到Unity中,再切割为单独的人物立绘(这么做的目的是减少文件总大小,原本总立绘大概8G,用这种方法存储减少到了1G),所以,切割后的图片的名称是可以与原文件的人物立绘的名称一一对应的,因此我在Python自动整合图片的过程中顺便按照规律输出了所有的角色名称得到了命名列表,接着进行处理。
核心代码如下:
//获取每个文件,再获取每个Sprite并开始重命名
foreach(Texture2D tex in texture2Ds)
{
//获取dataProvider
var factory = new SpriteDataProviderFactories();
factory.Init();
var dataProvider = factory.GetSpriteEditorDataProviderFromObject(tex);
dataProvider.InitSpriteEditorDataProvider();
//获取内部的所有的Sprite
SpriteRect[] spriteRects = dataProvider.GetSpriteRects();
//遍历
foreach(SpriteRect rect in spriteRects)
{
//更改名称
rect.name = names[nameIndex];
//重置ID
rect.spriteID = GUID.Generate();
//序号自增
nameIndex++;
}
//写回数据
dataProvider.SetSpriteRects(spriteRects);
//应用更改
dataProvider.Apply();
//重新载入以确保修改被应用
var assetImporter = dataProvider.targetObject as AssetImporter;
assetImporter.SaveAndReimport();
}
其实和切割差不多,不过切割是手动创建一个SpriteRect的列表,每个SpriteRect都是手动创建并修改的,这里是从dataProvider中直接获取了一个数组进行修改,然后再将修改后的数组覆盖回去而已。
完整代码(包括了一小部分排序的内容):
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using UnityEditor.U2D.Sprites;
using UnityEngine;
using UnityEditor;
namespace YYY
{
//批量重命名大PNG切分后的Sprite
//需要指明重命名时要用到的名称文件
//名称文件和需要命名的总文件数需要相同,以及排列也需要相同,可能需要手动调整排列方式
public class BatchRenameSprites
{
//菜单项
[MenuItem("Tools/Sprites/AutoRenameSprites")]
public static void AutoRenameSprites()
{
//名称文件存放位置
string namesPath = "Assets/2D Assets/Sprites/Characters/Dairi/QVersion/SpriteNameList.txt";
//名称索引
int nameIndex = 0;
//读取名称文件到列表中
List<string> names = new(File.ReadAllLines(namesPath));
//获取需要重命名的大PNG图片
List<Texture2D> texture2Ds = new();
foreach(var obj in Selection.objects)
{
if(obj is Texture2D tex)
{
texture2Ds.Add(tex);
}
}
//排序方式一
对获取到的图片进行重排序
此处由于文件名已经经过处理,所以直接按需求排序即可
//texture2Ds.Select(obj =>
//{
// //对每个对象使用Select创建匿名类型,包括了原始对象,主编号和次编号
// //随后排序
// var parts = obj.name.Split("_");
// return new
// {
// Obj = obj,
// MainOrder = int.Parse(parts[0]),
// SubOrder = int.Parse(parts[^1])
// };
//})
// .OrderBy(item => item.MainOrder)
// .ThenBy(item => item.SubOrder)
// .Select(item => item.Obj).ToList();
//排序方式二,通过后面的单词划定权重来排序
texture2Ds.Select(obj =>
{
var parts = obj.name.Split("_");
var mainOrderString = parts[0].Substring(1);
var suffix = parts[^1];
int suffixOrder = suffix == "Icon" ? 1 : suffix == "Standing" ? 2 : 3;
return new
{
Obj = obj,
MainOrder = int.Parse(mainOrderString),
SuffixOrder = suffixOrder,
};
})
.OrderBy(item => item.MainOrder)
.ThenBy(item => item.SuffixOrder)
.Select(item => item.Obj)
.ToList();
//获取每个文件,再获取每个Sprite并开始重命名
foreach(Texture2D tex in texture2Ds)
{
//获取dataProvider
var factory = new SpriteDataProviderFactories();
factory.Init();
var dataProvider = factory.GetSpriteEditorDataProviderFromObject(tex);
dataProvider.InitSpriteEditorDataProvider();
//获取内部的所有的Sprite
SpriteRect[] spriteRects = dataProvider.GetSpriteRects();
//遍历
foreach(SpriteRect rect in spriteRects)
{
//更改名称
rect.name = names[nameIndex];
//重置ID
rect.spriteID = GUID.Generate();
//序号自增
nameIndex++;
}
//写回数据
dataProvider.SetSpriteRects(spriteRects);
//应用更改
dataProvider.Apply();
//重新载入以确保修改被应用
var assetImporter = dataProvider.targetObject as AssetImporter;
assetImporter.SaveAndReimport();
}
}
}
}
最后,来点小小的总结
其实主要的步骤很简单来着,就是如何获取要处理的元数据,以及如何处理,前者是获取纹理图的Factory,随后获取DataProvider,后者是针对DataProvider进行操作,就是这么一回事。
不过不清楚为什么,如果尝试询问AI,那么得到的是一个旧API的答案,这个API已经不是凑活着用的程度了,而是已经被移除无法使用,所以查询文档得到此方法,分享在此处,希望能为大家提供帮助。