Unity编辑器扩展: 程序化打图集工具
开始前的声明:该案例中图集所使用图片资源均来源于网络,仅限于学习使用
一、前言
关于编辑器扩展相关的知识,在前面的两篇内容中做了详细的描述,链接地址:
第一篇 :Unity编辑器扩展 UI控件篇
前两篇着重于介绍编辑器界面扩展相关控件接口的使用方式。作为系列文章的第三篇,会更偏重于引擎内编辑器扩展承担 的提升开发效率的功能模块设计
通过程序化打图集减少工作量的同时可以稳定全局的管理图集,避免随着项目膨胀手动管理产生资源上的混乱。从图集整个生命周期来说,对于图集管理通常需要下面的模块支持:
- 程序化图集打包
- UI界面引用图集检测工具
- 图集资源自动/手动加载、卸载框架支持
本篇文章介绍第一部分,即图集程序化打包的逻辑执行
1、关于图集
官方文档对精灵图集的描述:
在2D
项目使用精灵和其他图形来创建其场景的视觉效果。这意味着单个项目可能包含许多纹理文件。Unity
通常会为场景中的每个纹理发出一个绘制调用。但是,在具有许多纹理的项目中,多个绘制调用会占用大量资源,并会对项目的性能产生负面影响
精灵图集 (Sprite Atlas
) 是一种将多个纹理合并为一个组合纹理的资源。Unity
可以调用此单个纹理来发出单个绘制调用而不是发出多个绘制调用,能够以较小的性能开销一次性访问压缩的纹理。此外,精灵图集 API
还可以控制如何在项目运行时加载精灵图集
图集对性能开销的正向影响:
从文档描述中可以看出,图集主要在两方面影响影响性能开销:
第一,减少绘制调用,即提升合批数量,减少Draw Call
从文档的描述可以看出,图集概念出现与渲染的绘制调用相关,衡量绘制调用通常以Draw Call数量为标准。而在Unity中UI合批策略,不同的Image
控件要执行合批必须要有相同的材质、Texture
。为了满足该条件,将一些小图合并成为大图片,就可以在渲染时,尽可能的一次性的将渲染数据提交给GPU
第二,合理的图集打包策略利于资源的压缩
在前面的性能优化文章中有提到过,某些压缩策略通常只会对规则大小的图片资源生效。如下图提示,当导入的图片资源非2倍数时,引擎会弹出对应警告信息。Unity
引擎对Texture
资源的常用的DXT
压缩处理需要满足其宽度与高度的必须为4的倍数(这是因为DXT压缩策略是以4X4的像素块为基本单位做处理)
而对于图集而言,其尺寸设定策略时基于是以2的次方为基本数值,即图集是可以执行压缩的。而资源的压缩无疑会提升资源的载入速度
图集对性能开销的负面影响:
如果图集使用不当,也可能会额外占用大量的内存,举例来说,如果当前界面只使用了某一图集中很小的一张图片,却不得已将整张图集加载到内存中。亦或者说由于打入图集的Sprite
的尺寸不合理,使得图集产生大量的空白,产生额外的性能消耗
2、Unity中图集打包方法
在Unity中手动打出图集的方法,在之前的文章有描述,这里稍微做一些描述,如果想了解详细的操作过程,可以查看该文章,链接地址:
Sprite Packer:
在2020.1
或更早的版本中,Unity
提供了Sprite Packer
图集纹理的生成和使用方法。相比于其他的打图集方式,Sprite Packer
是封装性较高的方式。通常只会对相应的Sprite
预设好指定图集标签。后续图集本身的资源管理基本由引擎自设定。这样做的优势可以减少开发者的工作量,但同时也牺牲了开发者对资源管理的灵活性。而不同项目的资源利用策略的不同又很需要这样的灵活性来定制
Sprite Atlas:
通过Sprite Atlas打出图集会生成对应的序列化配置文件,并且在资源面板是可见与可编辑的,可以灵活的控制图集资源的载入与卸载,当然也可以默认使用Unity自设定的加载与删除策略
动态图集:
动态图集是相对比较高阶的打图集解决方案,由于Unity没有提供与之对应的处理方法,意味着要自己实现一套集合动态资源管理、高效的图集生成算法等等
抛弃实现难度,动态图集目前是对于某些动态UI元素(如王者荣耀英雄头像)界面少有的解决方案
二、代码结构
在对图集的理论知识做完解释后,开启核心的程序设计阶段。后面的内容主要集中于对图集程序化过程的代码解释,主要是路径围绕编辑器内资源的遍历查询、创建删除与对图集打出参数操作方面的功能模块做设计
1、数据结构设计:
该图集生成工具的编辑器的界面操作逻辑不是很复杂,不过也需要维护一个简单的数据类。来记录编辑的缓存数据
除了一些常规的标识ID
字段与简单的资源索引字段。稍微需要注意的是,在于对本地文件索引后SpriteAtlas
的对象的直接保存在某些操作后造成索引丢失而出现空引用。所以这里的atlas
字段会指向本地资源的实例话数据的缓存,来避免指向丢失,影响后续的数据操作
public class AtlasData
{
public string atlasName;
public string assetPath;
/// <summary>
/// 缓存中的SpriteAtlas,不直接指向本地资源
/// </summary>
public SpriteAtlas atlas;
public List<Sprite> sprites;
//编辑器界面数据
public bool isShowDital;
}
2、获取某文件夹下的所有符合图集生成规范的Sprite类型的资源:
通过遍历该文件夹下所有文件(不包括子文件下的文件),并筛选出满足条件的Sprite
文件,得到该路径文件夹下的Sprite
列表。
这部分代码的逻辑设计集中于资源路径的读取遍历,基于DirectoryInfo
得到所有的文件信息,得到文件路径并通过AssetDataBase
来加载特定的Sprite
类型文件资源
在对特定类型资源文件执行遍历时,可以优先排除数量较大的Meta
文件,减少查询数量,提升文件遍历的操作效率。Unity
中文件类型通常有资源文件、代码文件、文本文件、序列化文件、Meta
文件等,通常来说,所有在Unity
中Asset
面板中可见的文件资源都会对应一份同名的meta
文件,用来记录类似于GUID
等标识身份相关的信息或者与对应资源相关的设定数据
List<Sprite> GetFileSprites(string relativePath)
{
if (Directory.Exists(relativePath))
{
DirectoryInfo direction = new DirectoryInfo(relativePath);
FileInfo[] files = direction.GetFiles("*");//只查找本文件夹下
if (files == null) return null;
List<Sprite> sprites = new List<Sprite>();
foreach (var file in files)
{
if(file.Name.EndsWith(".meta")) continue;
var item = AssetDatabase.LoadAssetAtPath<Sprite>(relativePath + file.Name);
if (item != null && ChackSpritePackerState(item))
{
sprites.Add((Sprite)item);
}
}
return sprites;
}
return null;
}
3、打入图集策略
打图集需要考虑合批效率与图集空间利用率之间的平衡,通常与项目的具体设定有关
筛选策略主要集中于对Sprite
尺寸的限制,避免在图集中打入过大的纹理,之所以这么说,是因为越大尺寸的纹理,会有更高的概率产生更多的空白空间浪费。即尺寸越大,对空间的利用率越低
Sprite
的宽度小于设定值Sprite
的高度小于设定值Sprite
的像素点小于设定值
对Sprite
打入图集时,会对整个UI资源文件中的Sprite
资源执行遍历,可以依托这个机会对图片资源的美术规范执行监测。对不符合条件的资源及时检测与处理。由于该操作不是本文的重点,所以做了简单的日志打印处理
private bool ChackSpritePackerState(Sprite sprite)
{
if (sprite.rect.width > maxSpriteSize)
{
if (sprite.rect.width % 2 != 0|| sprite.rect.height % 2 != 0)
{
Debug.LogError($"{sprite.name}尺寸不符合压缩规范(宽高均为2的倍数),请注意");
}
return false;
}
if (sprite.rect.height > maxSpriteSize)
{
if (sprite.rect.width % 2 != 0 || sprite.rect.height % 2 != 0)
{
Debug.LogError($"{sprite.name}宽度不符合压缩规范(宽高均为2的倍数),请注意");
}
return false;
}
if (sprite.rect.width * sprite.rect.height > maxSpritepixelNum *1024)
{
return false;
}
return true;
}
上述的打图集策略是对于单个UI面板内的Sprite执行管理。而对于整个项目而言,有一个比较重要的概念就是通用图集。其存在的意义是为了解决项目中存在的在很多UI界面都有使用的Srpite资源,如返回Button
控件的Texture
,可能游戏中的每个界面使用的都是同一张。为了避免图集的低效率引用,有下面两种打图集策略:
- 每个界面都单独复制一张,优点合批效率高,缺点会过多的增加包体的占用空间
- 以通用图集方式管理多界面使用纹理,优点减少资源载入(和资源管理策略有关)
对通用图集中也有一定的利弊权衡,首先最直接的是通用图集与当前界面图集中的纹理无法合批,其次就是同一时间内对通用图集内的资源使用率可能很低,造成一定的内存浪费
4、递归遍历文件夹并根据获取信息创建数据节点:
简单来说,该过程是通过遍历文件夹下得到当前文件夹下的Sprite
资源与其子文件夹,同时对存在所有的子文件夹执行递归遍历,来得到该文件夹打成图集需要的数据
对于项目而言,资源不可能都保存在一个层级下面,通常会根据UI功能模块产生多级文件夹划分。即某一文件夹下除了有当前文件夹对应界面UI元素的Texture
资源,还有可能有其子功能界面的资源文件夹。为了可以完整的对资源文件执行查询与图集打出,需要对整个文件夹的树形结构执行遍历,方式最简单的就是递归调用
文件夹的操作遍历依旧是基于DirectoryInfo
来完成的,通过其接口GetDirectories
可以得到该文件夹下所有子文件夹的信息,
void ChackAssetFile(string relativePath)
{
List<Sprite> sprites = GetFileSprites(relativePath);
if (sprites != null && sprites.Count > 1)
{
string atlasname = GetAtlasNameFromPath(relativePath);
string atlasPath = relativePath + atlasname;
CreateSpriteAtlas(atlasname, atlasPath, sprites);
}
DirectoryInfo direction = new DirectoryInfo(relativePath);
if (direction == null) return;
DirectoryInfo[] dirChild = direction.GetDirectories();
foreach (var item in dirChild)
{
ChackAssetFile(relativePath + item.Name + "/");
}
}
在得到根据路径索引的图集设定数据后,创建实例数据类AtlasData
并初始化相关字段保存到atlasDatas
字典中,以便在后续的逻辑中使用
void CreateSpriteAtlas(string atlasname, string atlasPath, List<Sprite> sprites)
{
if(atlasDatas.ContainsKey(atlasPath))
{
Debug.LogError("警告,有相同名字的Sprite资源文件夹!!!");
return;
}
AtlasData data = new AtlasData()
{
atlasName = atlasname.Replace(".asset",""),
assetPath = atlasPath,
sprites = sprites
};
atlasDatas.Add(atlasPath,data);
}
5、全局设定图集状态:
在上面的图片中,标识了对于图集状态设定的三个区域,分别用来设定打包资源策略的Packing
、图集资源设定Texture
与图集尺寸和压缩格式的设定。 该其各项参数可以参考Unity
官方文档的解释:
Include in build:
Unity
官方描述对Include in build
的描述文字”选中此复选框可在当前构建中包含精灵图集资源“`比较简洁,可能会让开发者忽略其功能作用,但是实际上该参数选项代表着两套较为复杂的图集资源管理逻辑的切换系统
在Asset
路径下创建的Sprite Atlas
是响应图集生成策略所对应的序列化配置文件。在编辑器状态下,真正的图集纹理资源文件会暂时存储于项目Asset
文件夹同级的缓存Library
文件夹下的AtlasCache
目录下,会游戏构建中时将其打包进游戏内。编辑器状态下项目文件目录如下图所示:
如果项目中图集资源生命周期的管理依托引擎自设定策略,编辑器状态下的图集使用需确保勾选Include in build
。如果未勾选,图集纹理不会被自动载入到内存中,游戏中的UI
会显示白图状态。不过此状态下运行时会触发SpriteAtlasManager.atlasRequested
事件,开发可以注册事件到该函数来手动管理Sprite Atlas
的加载、卸载
Use Crunch Compression:
文档描述:Crunch
是一种基于 DXT
或 ETC
纹理压缩的有损压缩格式。Unity
在 CPU
上将纹理解压缩为 DXT
或 ETC
,然后在运行时将其上传到 GPU
。Crunch
压缩有助于纹理在磁盘上使用尽可能少的空间并方便下载。Crunch
纹理可能需要很长时间进行压缩,但在运行时的解压缩速度非常快
可以简单的将其理解基于DXT
的二次压缩,该阶段发生于CPU
阶段对Texure
的载入,然后由CPU
解码为 DXT
或 ETC
压缩格式的资源并传给GPU
。即通过少量额外解压缩计算提升资源的加载速度,但是需要注意该压缩方式是有损压缩,需要权衡性能与效果
sRGB:
可以默认勾选,通常来说Color Space
为Linear
空间时,勾选sRGB
会对在伽马空间内绘制的图片做一个伽马矫正,来确保最终的显示效果与美术绘制的效果相同
之所以要默认勾选是因为美术通过显示器绘制图片时,是经过被伽马矫正后显示的。而实际的图片资源数据是处于伽马空间下的。当资源导入引擎内后,如果直接按照Linear
空间处理,色彩亮度就会出现偏差。就需要sRGB
选项控制对其做伽马矫正
代码结构:
设定图集的代码如下。因为稍微偷懒了一下,对于核心功能参数利用编辑器UI
接口输入控制,非核心参数就直接代码写死了
void SetUpAtlasInfo(ref SpriteAtlas atlas)
{
atlas.SetIncludeInBuild(isIncludeInBuild);
//A区域参数设定
SpriteAtlasPackingSettings packSetting = new SpriteAtlasPackingSettings()
{
blockOffset = 1,
enableRotation = false,
enableTightPacking = false,
padding = 2,
};
atlas.SetPackingSettings(packSetting);
//B区域参数设定
SpriteAtlasTextureSettings textureSetting = new SpriteAtlasTextureSettings()
{
readable = false,
generateMipMaps = false,
sRGB = true,
filterMode = FilterMode.Bilinear,
};
atlas.SetTextureSettings(textureSetting);
//C区域参数设定
TextureImporterPlatformSettings platformSetting = new TextureImporterPlatformSettings()
{
maxTextureSize = (int)maxSpriteAtlasSize,
format = TextureImporterFormat.Automatic,
crunchedCompression = true,
textureCompression = TextureImporterCompression.Compressed,
compressionQuality = 50,
};
atlas.SetPlatformSettings(platformSetting);
}
6、将缓存数据本地化保存:
根据前面操作得到的atlasDatas
缓存数据,来对本地SpriteAtlas
资源执行修改或创建操作。
通过使用路径信息对图集资源执行索引,如果返回结果不为空,则对返回SpriteAtlas
执行操作,设定图集参数格式,并将符合条件未打入该图集的Sprite添加到其中,如果不存在图集资源,就设定好参数与数据后,利用AssetDataBase
的接口来创建本地序列化文件SpriteAtlas
void SaveAtlasData()
{
foreach (var item in atlasDatas.Values)
{
string path = item.assetPath;
SpriteAtlas atlas = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(path);
if (atlas != null)
{
SetUpAtlasInfo(ref atlas);
List<Sprite> sprites = new List<Sprite>();
foreach (var sprite in item.sprites)
{
if(atlas.GetSprite(sprite.name)==null)
{
sprites.Add(sprite);
}
}
atlas.Add(sprites.ToArray());
item.atlas = Instantiate(atlas);
continue;
}
atlas = new SpriteAtlas();
SetUpAtlasInfo(ref atlas);
atlas.Add(item.sprites.ToArray());
item.atlas = atlas;
AssetDatabase.CreateAsset(atlas, path);
}
}
7、编辑工具界面绘制:
参数控制区域:
对于要设定调整的参数通过编辑器扩展的相关接口,这里挑选了几个相对比较重要的参数通过编辑器控件APIU设计了UI输入控制接口
void DrawSpriteAtlasSetting()
{
EditorGUILayout.LabelField("图集相关设定:",titleStyle); GUILayout.Space(4);
maxSpriteAtlasSize = EditorGUILayout.IntPopup("图集最大尺寸为",maxSpriteAtlasSize, sizeStrs,sizes);
isIncludeInBuild = EditorGUILayout.Toggle("是否将图集打入包内", isIncludeInBuild);
GUILayout.Space(5);
EditorGUILayout.LabelField("Sprite资源限制策略:",titleStyle); GUILayout.Space(4);
maxSpritepixelNum = EditorGUILayout.Slider("Sprite最大像素量(单位K):", maxSpritepixelNum ,0,1024);
maxSpriteSize = EditorGUILayout.IntPopup("Sprite最大尺寸限制", maxSpriteSize, sizeStrs, sizes);
}
打包数据列表显示:
在atlasDatas
里面记录了所有图集的状态信息,为了更好的检测管理,将其显示在界面上:
在上图的编辑器界面中,涉及到两个特殊的编辑器效果
第一个是资源选中路径索引与状态展示,是通过对编辑器接口中的Selection.activeObject
设定要选中的Object
来实现,注意要指向的对象是通过路径加载的本地资源文件,而不是由之前编辑状态所保存的实例缓存数据
第二个效果需要通过BeginFoldoutHeaderGroup
与EndFoldoutHeaderGroup
编辑器扩展接口来完成界面UI的展开收起效果
代码结构为:
void DrawAtlasInfo()
{
GUILayout.Label("图集资源列表: ",titleStyle);
GUILayout.Space(10);
foreach (var item in atlasDatas.Values)
{
item.isShowDital = EditorGUILayout.BeginFoldoutHeaderGroup(item.isShowDital, "展开: " + item.atlasName);
if (item.isShowDital)
{
EditorGUILayout.TextField(" 图集名字:", item.atlasName);
EditorGUILayout.TextField(" 资源路径:", item.assetPath);
EditorGUILayout.IntField(" 图集中Sprite数量", item.atlas.spriteCount);
if(GUILayout.Button("打开并查看该图集资源"))
{
AssetDatabase.MakeEditable(item.assetPath);
Selection.activeObject = AssetDatabase.LoadAssetAtPath<SpriteAtlas>(item.assetPath);
}
}
EditorGUILayout.EndFoldoutHeaderGroup();
GUILayout.Space(5);
}
}
三、总结
程序化打出图集,本质是对本地资源的操作处理,逻辑上的东西不复杂。主要是通过DirectoryInfo
与AssetDataBase
两个文件操作类执行本地文件的查询编辑与对SpriteAtlas
的创建删除等操作。核心还是对于图集的功能理解与参数设定以及对打出图集策略的考虑