Unity - Use tight sprite atlas in canvas image

问题概述

项目在制作角色换装时候遇到一个问题, 就是如何将现有2D模型打组到Atlas之中. 因为Unity Canvas Image 使用的是一个Quad 绘制的一个图形,所以打组到Atlas中的png图也必须使用Full Rect才可以

但是,为了方便美术编辑

所有五官图片都是预留空白用来对位置的,这样出图就无需每个进行调整锚点对位置. PS直接导出png即可直接使用.

但是这样存图再使用FullRect打组到Atlas必然产生极大的浪费. 所以必须想办法让Canvas中支持以Tight方式存储的Sprite才可以

现有解决方案

Google到的已有解决方案有两个, 一个是 Assets Store 售卖的插件 PolyImage,还有一个是国内大神在Github上面开源的库AdvancedImage.cs

两个组件我都有尝试,相比来说 PolyImage的解法比较独特,但是从功能上来说AdvancedImage要完善很多(不过有个锚点偏移的Bug).

自制 Tight Image Component

出于研(wu)究(liao)的目的,我还是准备自己实现一版. 不过后来发现 自己花了大量的时间在研究PIP PIT问题…😓

Unity Sprite 中包含哪些信息

在写Canvas的组件之前,首先要了解的是

如何把一张图渲染到屏幕上来的

这个问题我之前有在 手动创建Mesh及相关理论知识 中说过, 整个渲染的基础要素有

  • 顶点(vertices)
  • 三角形(triangles)
  • 法线-非必须(normals)
  • 切线-非必须(tangents)
  • 每个顶点的UV信息
  • 纹理(texture)
  • 渲染所使用的Shader

对于一个3D物体的渲染,前面几项(vertices,triangles,normals,tangents,uv)的集合就是一个Mesh文件,texture和shader的集合就是一个Material文件.

对于2D物体的渲染,由于不考虑光照问题,所以把 vertices,triangles,uv,texture 放在一起就是一个Sprite文件 , 而Material中只需要包含一个Shader即可

重写SpriteRender

知道了以上概念后,我们就可以自己写一个简化版的SpriteRender,将一个png图渲染到屏幕之上.

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System;
using System.Reflection;
using UnityEditor;
using UnityEngine;

public class SpriteRenderV : MonoBehaviour
{
public Sprite sp;

private MeshRenderer mRender;
private MeshFilter mMeshFilter;

private void Start()
{
mRender = GetComponent<MeshRenderer>();
mMeshFilter = GetComponent<MeshFilter>();
var mesh = new Mesh();

var vertices = new Vector3[sp.vertices.Length];

for (var i = 0; i < sp.vertices.Length; i++)
{
var eachPointPos = new Vector3(sp.vertices[i].x, sp.vertices[i].y, 0);
vertices[i] = eachPointPos;
}

var tris = new int[sp.triangles.Length];
for (var i = 0; i < sp.triangles.Length; i++)
{
tris[i] = sp.triangles[i];
}

mesh.vertices = vertices; //Mesh - 顶点
mesh.triangles = tris; //Mesh - 三角
mesh.uv = sp.uv; // Mesh - UV
mRender.material.mainTexture = sp.texture; // 纹理
mMeshFilter.mesh = mesh;
}
}

有几点需要说明一下的

  • 从代码中可以看出 一个Sprite文件包含了(vertices,triangles,uv,texture)信息
  • 当png图被打入Atlas时候,所引用到的Sprite文件texture是Atlas文件,且uv,vertices信息变为 针对该atlas的uv信息了
  • Sprite的渲染和Mesh的渲染本质上无区别
  • Sprite/DefaultShader使用的是双面渲染,应该是考虑到Sprite可以绕Y轴旋转所以才这样设计的吧

重载Image组件

Unity的UGUI是开源项目,项目地址在这里

对UGUI源码分析的文章不少,比较推荐 aillieo.cn

ClassDiagra

组件可以直接继承自MaskableGraphic重新写,但是直接继承自Image去写还是最方便的.

aillieo 在Graphic分析中已经有指出了 Graphic组件的刷新方式

1
2
3
4
5
6
7
8
9
CanvasUpdate.PreRender
-> UpdateGeometry() or UpdateMaterial()

UpdateGeometry
-> OnPopulateMesh 子类提供需要使用的Mesh
-> IMeshModifier.ModifyMesh 所有IMeshModifier组件修改Mesh
-> canvasRenderer.SetMesh 将Mesh传给CanvasRender

CanvasRenderer 渲染Mesh

初版 TightImage Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TightImage : Image
{

protected override void OnPopulateMesh(VertexHelper toFill)
{
if (type == Type.Simple)
{
toFill.Clear();
DoPopulateSimpleImage(toFill);
}
else
{
base.OnPopulateMesh(toFill);
}
}

private void DoPopulateSimpleImage(VertexHelper toFill)
{

var imgRect = GetPixelAdjustedRect();
var spBounds = overrideSprite.bounds;

var scaleX = imgRect.width / spBounds.size.x;
var scaleY = imgRect.height / spBounds.size.y;

if (preserveAspect)
{
var minScale = Mathf.Min(scaleX, scaleY);
scaleX = scaleY = minScale;
}

var imgColor = color;

for (var i = 0; i < overrideSprite.vertices.Length; i++)
{
var x = overrideSprite.vertices[i].x * scaleX ;
var y = overrideSprite.vertices[i].y * scaleY ;
toFill.AddVert(new Vector3(x, y, 0), imgColor, overrideSprite.uv[i]);
}

var triangles = overrideSprite.triangles;
for (var i = 0; i < triangles.Length; i += 3)
{
toFill.AddTriangle(triangles[i], triangles[i + 1], triangles[i + 2]);
}
}
}

初版的实现起始就是把 SpriteRenderV 部分的代码 整合到了 Canvas 之下. 有两点需要说明一下就是

rectTransform 无需Cache 随取随用即可, 其内部实现已经Cache了一份

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// <para>The RectTransform component used by the Graphic.</para>
/// </summary>
public RectTransform rectTransform
{
get
{
if (object.ReferenceEquals((object) this.m_RectTransform, (object) null))
this.m_RectTransform = this.GetComponent<RectTransform>();
return this.m_RectTransform;
}
}

还有一点就是 缩放问题

rectTransform.rect 与 overrideSprite.bounds

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <summary>
/// <para>Returns a pixel perfect Rect closest to the Graphic RectTransform.</para>
/// </summary>
/// <returns>
/// <para>Pixel perfect Rect.</para>
/// </returns>
public Rect GetPixelAdjustedRect()
{
if (!(bool) ((UnityEngine.Object) this.canvas) ||
this.canvas.renderMode == RenderMode.WorldSpace ||
((double) this.canvas.scaleFactor == 0.0 ||
!this.canvas.pixelPerfect))
return this.rectTransform.rect;
return RectTransformUtility.PixelAdjustRect(this.rectTransform, this.canvas);
}

GetPixelAdjustedRect函数内部实现 其实返回的就是rectTransform.rect, 也就是在Editor中编辑模式下的外框大小

单位是已经转化为像素了的值

overrideSprite.bounds 取得的就是 整个png图的外框unit尺寸(包含空白区域)

解决 Image/Sprite中锚点问题

上面代码 在Image中的锚点和Sprite中的锚点都是中心时候是正确的,但是如果修改了其中一个锚点 整个图像会发生偏移. 这里也就是 AdvancedImage有点小Bug的地方,它只处理的Sprite中的锚点偏移,没有处理 Image中的锚点偏移问题.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Image center offset
var offsetImgX = rectTransform.rect.center.x;
var offsetImgY = rectTransform.rect.center.y;

//Sprite center offset
var offsetSpX = -scaleX * spBounds.center.x;
var offsetSpY = -scaleY * spBounds.center.y;

for (var i = 0; i < overrideSprite.vertices.Length; i++)
{
var x = overrideSprite.vertices[i].x * scaleX + offsetImgX + offsetSpX;
var y = overrideSprite.vertices[i].y * scaleY + offsetImgY + offsetSpY;
...
}

注意就是 Sprite中要乘上一个倍率,然后有个正负号问题. 我后来也没想明白为啥两个偏移一个正一个反. 反正试了一下这样结果是正确的 😂

PolyImage核心逻辑解析

版权问题,也就不粘贴PolyImage的代码了,不过它解决缩放和平移的问题是另外一个思路. 是从uv入手的. 这样也就不用考虑锚点的问题了.

其内部用到了几个函数 值得说一下的

Image.cs line227

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/// Image's dimensions used for drawing. X = left, Y = bottom, Z = right, W = top.
private Vector4 GetDrawingDimensions(bool shouldPreserveAspect)
{
var padding = activeSprite == null ? Vector4.zero : Sprites.DataUtility.GetPadding(activeSprite);
var size = activeSprite == null ? Vector2.zero : new Vector2(activeSprite.rect.width, activeSprite.rect.height);

Rect r = GetPixelAdjustedRect();
// Debug.Log(string.Format("r:{2}, size:{0}, padding:{1}", size, padding, r));

int spriteW = Mathf.RoundToInt(size.x);
int spriteH = Mathf.RoundToInt(size.y);

var v = new Vector4(
padding.x / spriteW,
padding.y / spriteH,
(spriteW - padding.z) / spriteW,
(spriteH - padding.w) / spriteH);

if (shouldPreserveAspect && size.sqrMagnitude > 0.0f)
{
var spriteRatio = size.x / size.y;
var rectRatio = r.width / r.height;

if (spriteRatio > rectRatio)
{
var oldHeight = r.height;
r.height = r.width * (1.0f / spriteRatio);
r.y += (oldHeight - r.height) * rectTransform.pivot.y;
}
else
{
var oldWidth = r.width;
r.width = r.height * spriteRatio;
r.x += (oldWidth - r.width) * rectTransform.pivot.x;
}
}

v = new Vector4(
r.x + r.width * v.x,
r.y + r.height * v.y,
r.x + r.width * v.z,
r.y + r.height * v.w
);

return v;
}

该函数直接返回了Image的Bounds区域 其内部的

Sprites.DataUtility.GetPadding 可以取得Sprite中的空白外框区域.


具体的参考aillieo 在Image的分析

然后再用

Sprites.DataUtility.GetOuterUV(overrideSprite)

取得UV外框的区域,这样也就有了 Sprite 和 Image之间的比例关系. 剩下的就是 加加减减的计算了.

碰撞检测

没想多最终折在了碰撞检测上😑… 导致我重修了一遍 初中数学, 具体的直接参考 再论向量在游戏中的应用-x 特集就好了

Image自身的碰撞检测

Image 其实是有一个比较硬核的 忽略透明区域的 碰撞检测的.

Image.cs line1090

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public virtual bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
if (alphaHitTestMinimumThreshold <= 0)
return true;

if (alphaHitTestMinimumThreshold > 1)
return false;

if (activeSprite == null)
return true;

Vector2 local;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local))
return false;

Rect rect = GetPixelAdjustedRect();

// Convert to have lower left corner as reference point.
local.x += rectTransform.pivot.x * rect.width;
local.y += rectTransform.pivot.y * rect.height;

local = MapCoordinate(local, rect);

// Normalize local coordinates.
Rect spriteRect = activeSprite.textureRect;
Vector2 normalized = new Vector2(local.x / spriteRect.width, local.y / spriteRect.height);

// Convert to texture space.
float x = Mathf.Lerp(spriteRect.x, spriteRect.xMax, normalized.x) / activeSprite.texture.width;
float y = Mathf.Lerp(spriteRect.y, spriteRect.yMax, normalized.y) / activeSprite.texture.height;

try
{
return activeSprite.texture.GetPixelBilinear(x, y).a >= alphaHitTestMinimumThreshold;
}
catch (UnityException e)
{
Debug.LogError("Using alphaHitTestMinimumThreshold greater than 0 on Image whose sprite texture cannot be read. " + e.Message + " Also make sure to disable sprite packing for this sprite.", this);
return true;
}
}

原理度娘上已经很多人写过了 比如这个: UGUI小贴士:使用不规则按钮

比较硬核 就像文章中说的 内存会加倍. 但就我看来 应该理解为这样更合适:

原有纹理只是放在显存中,使用这种方式后 就需要在内存中Cache一份纹理了

所以alphaHitTestMinimumThreshold并未设置在Editor的Inpetector中,需要手动使用代码打开.

优化方案

替代方案,一种是设置 Polygon Collider , 点可以直接使用Sprite中的vertices, 然后判断直接用

1
2
3
4
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
return polygon.OverlapPoint( eventCamera.ScreenToWorldPoint(screenPoint));
}

或者直接把问题拆解为PIT问题 然后判断即可.

终版 TightImage Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
using UnityEngine;
using UnityEngine.UI;

public class TightImage : Image
{
private Vector2[] mAllScaledVertices;

protected override void OnPopulateMesh(VertexHelper toFill)
{
if (type == Type.Simple)
{
toFill.Clear();
DoPopulateSimpleImage(toFill);
}
else
{
base.OnPopulateMesh(toFill);
}
}

private void DoPopulateSimpleImage(VertexHelper toFill)
{
var imgRect = GetPixelAdjustedRect();
var spBounds = overrideSprite.bounds;


var scaleX = imgRect.width / spBounds.size.x;
var scaleY = imgRect.height / spBounds.size.y;

if (preserveAspect)
{
var minScale = Mathf.Min(scaleX, scaleY);
scaleX = scaleY = minScale;
}

//Image center offset
var offsetImgX = rectTransform.rect.center.x;
var offsetImgY = rectTransform.rect.center.y;

//Sprite center offset
var offsetSpX = -scaleX * spBounds.center.x;
var offsetSpY = -scaleY * spBounds.center.y;

var imgColor = color;

mAllScaledVertices = new Vector2[overrideSprite.vertices.Length];

for (var i = 0; i < overrideSprite.vertices.Length; i++)
{
var x = overrideSprite.vertices[i].x * scaleX + offsetImgX + offsetSpX;
var y = overrideSprite.vertices[i].y * scaleY + offsetImgY + offsetSpY;
toFill.AddVert(new Vector3(x, y, 0), imgColor, overrideSprite.uv[i]);
mAllScaledVertices[i] = new Vector2(x, y);
}

var triangles = overrideSprite.triangles;
for (var i = 0; i < triangles.Length; i += 3)
{
toFill.AddTriangle(triangles[i], triangles[i + 1], triangles[i + 2]);
}
}

public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
if (type != Type.Simple)
{
return base.IsRaycastLocationValid(screenPoint, eventCamera);
}

Vector2 local;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local);

var triangles = overrideSprite.triangles;
for (var i = 0; i < triangles.Length; i += 3)
{
if (IsInTriangle(mAllScaledVertices[triangles[i]],
mAllScaledVertices[triangles[i + 1]],
mAllScaledVertices[triangles[i + 2]],
local))
{
return true;
}
}

return false;
}

// see more info at: http://oldking.wang/20ae1da0-d6d5-11e8-b3e6-29fe0b430026/
private static bool IsInTriangle(Vector2 A, Vector2 B, Vector2 C, Vector2 P)
{
var v0 = C - A;
var v1 = B - A;
var v2 = P - A;

var dot00 = Vector2.Dot(v0, v0);
var dot01 = Vector2.Dot(v0, v1);
var dot02 = Vector2.Dot(v0, v2);
var dot11 = Vector2.Dot(v1, v1);
var dot12 = Vector2.Dot(v1, v2);

var inverDeno = 1 / (dot00 * dot11 - dot01 * dot01);

var u = (dot11 * dot02 - dot01 * dot12) * inverDeno;
if (u < 0 || u > 1)
{
return false;
}

var v = (dot00 * dot12 - dot01 * dot02) * inverDeno;
if (v < 0 || v > 1)
{
return false;
}

return u + v <= 1;
}
}

需要说明的是 IsRaycastLocationValid 是已经经过AABB测试以后才会被调用的,所以其实现内无需再做一次了.

坚持原创技术分享,您的支持将鼓励我继续创作!