前段时间对unity spriteAtlas预览功能比较感兴趣,就去查询了一下unity是如何实现的。
探查相关知识后可以发现,unity spriteAtlas的预览调用接口很隐蔽,在SpriteAtlas.GetPreviewTextures中。(有个网址可以学习unity源码,还是非常推荐的)
当然我们可以选择直接new一个Texture2D作为atlas,直接调用Texture2D.PackTextures(Texture2D[] textures, int padding, int maximumAtlasSize, bool makeNoLongerReadable)即可。
可以看到真正的算法再GetPreviewTextures里,但是unity用了NativeHeader链接了相应方法到C++编写的本地库,所以暂时是无法看到了。但是本人搜索了一遍之后看到了相关算法关键词:MaxRectsBinPack算法,并且有大佬完成并开源了相关算法流程,在这里就通过阅读这套源码详细解释spriteAtlas处理多texture的步骤。
按照贴图某个较长的边进行比较,矩形的宽高最大值越大,越在数组的前面,保证处理贴图数据的顺序是从较大贴图开始处理的。比如4x6的贴图要放在5x5的贴图前面,7x3的贴图放在4x6的贴图的前面。
我们都知道图集将图片打包为2的幂次方的素材大小,借以减少drawcall。所以我们直接取第一张贴图(也就是最大的那张贴图),生成一张最接近它的height和width的2次幂图集作为起始图集(模拟演算的数据,并非从现在开始就直接操作texture,而是一个数据块存储了必要的宽/高信息)已完成初期准备工作。
public void Init(int width, int height, bool rotations = true)
{
binWidth = width;
binHeight = height;
allowRotations = rotations;
Rect n = new Rect(); //一个矩形,左上角起始,宽高
n.x = 0;
n.y = 0;
n.width = width;
n.height = height;
usedRectangles.Clear();
freeRectangles.Clear();
freeRectangles.Add(n); //把初始化的矩形添加进来
}
比如最大的一张贴图是240x600,那第一步首先生成一张256x1024的图集,至少要能放下这张最大的贴图。
这一步是算法核心,我们需要往这个特定大小的图集里插入贴图。匹配空闲空间插入贴图的算法有很多种,开源者在这里提供了五种算法:
public enum FreeRectChoiceHeuristic
{ //匹配规则
RectBestShortSideFit, // BSSF: 短边最接近
RectBestLongSideFit, // BLSF: 长边最接近
RectBestAreaFit, // BAF: 面积最接近
RectBottomLeftRule, /// BL: 放在最左下
RectContactPointRule // CP: 尽可能与更多矩形相邻
};
代表的就是现在有一堆空闲矩形,那这个待插入的贴图应该往哪里插入比较好,至少要插入到其中一个最合适的矩形中才算准确。
在这里以“面积最接近算法”作为示例算法继续讲解,其实就是这个等待插入的贴图A的大小和所有的空闲矩形做比较,能插入的条件就是这个空闲矩形的面积首先要比A的面积要大,宽度、高度也要比A要大(这是肯定的,否则就塞不下A,就要跳过),满足这个条件下的最小面积的空闲矩形就是最终要放进去的矩形。
Rect FindPositionForNewNodeBestAreaFit(int width, int height)
{
Rect bestNode = new Rect();//创建匹配矩形
int bestAreaFit = int.MaxValue;
int bestShortSideFit = 0;
for (int i = 0; i < freeRectangles.Count; ++i)
{ //遍历空闲矩形列表
int areaFit = (int)freeRectangles[i].width * (int)freeRectangles[i].height - width * height; //计算面积匹配度,轮询空闲矩形的面积减掉目标匹配矩形的面积
// 查找到一个可以容纳目标矩形的空闲矩形
if (freeRectangles[i].width >= width && freeRectangles[i].height >= height)
{
int leftoverHoriz = Mathf.Abs((int)freeRectangles[i].width - width);//目标矩形宽度相比查找的矩形多余的部分,宽度匹配度
int leftoverVert = Mathf.Abs((int)freeRectangles[i].height - height);//目标矩形高度相比查找的矩形多余的部分,高度匹配度
int shortSideFit = Mathf.Min(leftoverHoriz, leftoverVert);//短边的匹配度
if (areaFit < bestAreaFit || (areaFit == bestAreaFit && shortSideFit < bestShortSideFit))
{ // 面积小于上次匹配的矩形,或者面积相等但短边更加合适
bestNode.x = freeRectangles[i].x;//保存匹配到的矩形数据
bestNode.y = freeRectangles[i].y;
bestNode.width = width;
bestNode.height = height;
bestShortSideFit = shortSideFit;//短边匹配度
bestAreaFit = areaFit;//面积匹配度
}
}
}
打个比方,现在有个贴图A大小是240x160,要在300x170、400x100、280x180的空闲矩形中挑选一个进行放置,那就应当选280x180的空闲矩形,因为第二个矩形放不下,第三个相比第一个面积又会更小一点,所以是候选者里最适合放下这个贴图的。
如果是2个面积同样最小的矩形,则以某条边更“适配”贴图的矩形作为最终候选者来供插入。比如这个240x160的贴图A要在260x280和280x260的空闲矩形中选,那应该选260x280的,因为做差值运算后会发现前者宽度剩20,高度剩120,后者宽度剩40高度剩100,会发现前者的宽度差值更小,也就更适配了。
选好能放入贴图的空闲矩形后,就要对这个空闲矩形进行拆分。代码里是直接把剩下的上下左右四个部分直接加入空闲矩形列表里供下次遍历的,如下图:
以上只是比喻,通常情况下是不会这么分割的,一般情况A的左下角会和空闲矩形的左下角重合:
当然,之后会越来越多的小碎片存在,所以代码块里有一个优化算法,就是每一次裁剪后判断矩形之间是否有相互包含的,如果有则剔除。这个相互包含的情况是大概率会出现的,比如上述这么划分之后有个贴图B把矩形3的大部分都占有了,留下矩形3和4的共同区域:
这时候就需要将矩形3剔除了。
判定这个有很多种方法,传送门里的算法流程里是根据最新插入图集的这个贴图返回的Rect信息来判断的。如果返回的Rect信息的长度或者宽度为0,意味着这张贴图根本没插进去,
那就代表这个图集(模拟演算出来的)大小本身不够大,就要继续加宽/加高处理。新建一个加宽/加高后的图集数据,然后继续下个循环,回到上一步,插入所有贴图,直到所有贴图插入完全,或者碰到有贴图无法插入。
for (int i = 0; i < textures.Length; ++i)
{ //忽略数组0的元素
Texture2D tex = textures[i]; //贴图数据
Rect rect = packer.Insert(tex.width, tex.height, FreeRectChoiceHeuristic.RectBestAreaFit); //使用面积最接近的方式进行矩形的插入
aryRects[i] = rect;
if (rect.width == 0 || rect.height == 0)
{
//不通过
successed = false;
break;
}
}
if (successed)
{
break;
}
//先往高度发展,如果发现高度大于1024,则向宽度发展
if (maxHeight >= AtlasPreviewer.ATLAS_MAX_SIZE && maxWidth < AtlasPreviewer.ATLAS_MAX_SIZE)
{
//不允许超过2048
maxWidth *= 2;
}
else if (maxHeight >= AtlasPreviewer.FAVOR_ATLAS_SIZE && maxWidth < AtlasPreviewer.FAVOR_ATLAS_SIZE)
{
//尽量不能超过1024
maxWidth *= 2;
}
else if (maxHeight >= maxWidth * 2)
{
//尽量使宽和高之间的比例不要相差太大,防止IOS上会扩展成更大的正方形
maxWidth *= 2;
}
else
{
maxHeight *= 2;
}
将上述计算得出的所有贴图数据(就是每个贴图的Rect数据,此数据保存了这个贴图的长度、高度、在图集的坐标)传进图集里,通过Texture.SetPixels来绘制出来,就得到一张完美的图集预览texture了。