前言

MaskableGraphic 分析完之后,就可以来看 UGUI 中真正使用到的图像组件了。ImageRawImage 是 UGUI 中最常用的两个图像组件,它们分别用于显示 SpriteTexture。本文将分析这两个组件的实现。

Sprite 与 Texture

在 UGUI 中,SpriteTexture 是两个最常用的图像资源。Sprite 是 Unity 引擎中专门用于 2D 图像的资源,它包含了图像的像素数据、UV 坐标、边界信息等。Texture 是 Unity 引擎中用于存储图像像素数据的资源,它可以被 Sprite 使用,也可以被 RawImage 使用。

Unity 的官方手册中,这样描述 Sprite

  • Sprite 是一种 2D 图形对象,用于 2D 游戏中的角色、道具、飞弹和其他游戏元素。图形是从位图图像 Texture2D 获取的。Sprite 类主要标识应该用于特定精灵的图像部分。然后,GameObject 上的 SpriteRenderer 组件可以使用该信息来实际显示图形。

  • Sprite 是 2D 图形对象。如果习惯于在 3D 空间中工作,Sprite 本质上只是标准纹理,但可通过一些特殊技巧在开发过程中组合和管理精灵纹理以提高效率和方便性。

Sprite 的描述可以看出,Sprite 依然是一个 2D 图形对象,它只是对 Texture2D 的一个封装。Sprite 除了包含 Texture2D 的像素数据外,还包含了一些额外的信息,比如 UV 坐标、边界信息等。

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
// Sprite.cs

public sealed class Sprite : Object
{
public Bounds bounds
{
get
{
get_bounds_Injected(out var ret);
return ret;
}
}

public Rect rect
{
get
{
get_rect_Injected(out var ret);
return ret;
}
}

public Vector4 border
{
get
{
get_border_Injected(out var ret);
return ret;
}
}

public extern Texture2D texture
{
[MethodImpl(MethodImplOptions.InternalCall)]
get;
}

// ...
}

Sprite 的定义中也可以看出这一点,Sprite 中不仅包含了 Texture2D 的像素数据,还包含了 boundsrectborder 等信息。

RawImage

image.png

RawImage 的实现十分简单,仅仅是重写了 GraphicmainTexture 属性以及 OnPopulateMesh() 方法,用于设置显示的 Texture。此外,还提供了一个 UV Rect 属性,用于设置显示的 Texture 的 UV 坐标。

通过前面对 Graphic 的分析我们知道,mainTexture 是真正提交给 CanvasRenderer 的纹理,而 OnPopulateMesh() 方法则会在 UpdateGeometry() 方法中被调用,用于生成顶点信息。

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
public class RawImage : MaskableGraphic
{
[FormerlySerializedAs("m_Tex")]
[SerializeField] Texture m_Texture;
[SerializeField] Rect m_UVRect = new Rect(0f, 0f, 1f, 1f);

protected RawImage()
{
useLegacyMeshGeneration = false;
}

public override Texture mainTexture
{
get
{
if (m_Texture == null)
{
if (material != null && material.mainTexture != null)
{
return material.mainTexture;
}
return s_WhiteTexture;
}

return m_Texture;
}
}

protected override void OnPopulateMesh(VertexHelper vh)
{
Texture tex = mainTexture;
vh.Clear();
if (tex != null)
{
var r = GetPixelAdjustedRect();
var v = new Vector4(r.x, r.y, r.x + r.width, r.y + r.height);
var scaleX = tex.width * tex.texelSize.x;
var scaleY = tex.height * tex.texelSize.y;
{
var color32 = color;
vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(m_UVRect.xMin * scaleX, m_UVRect.yMin * scaleY));
vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(m_UVRect.xMin * scaleX, m_UVRect.yMax * scaleY));
vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(m_UVRect.xMax * scaleX, m_UVRect.yMax * scaleY));
vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(m_UVRect.xMax * scaleX, m_UVRect.yMin * scaleY));

vh.AddTriangle(0, 1, 2);
vh.AddTriangle(2, 3, 0);
}
}
}
}

OnPopulateMesh() 方法中,会将 Texture 的大小信息以及 UV Rect 信息传递给 VertexHelper,用于生成顶点数据。

此外,在 RawImage 中实现了一个 Graphic 中定义的虚函数 SetNativeSize(),用于将 RawImage 的大小设置为 Texture 的原始大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// Adjust the scale of the Graphic to make it pixel-perfect.
/// </summary>
/// <remarks>
/// This means setting the RawImage's RectTransform.sizeDelta to be equal to the Texture dimensions.
/// </remarks>
public override void SetNativeSize()
{
Texture tex = mainTexture;
if (tex != null)
{
int w = Mathf.RoundToInt(tex.width * uvRect.width);
int h = Mathf.RoundToInt(tex.height * uvRect.height);
rectTransform.anchorMax = rectTransform.anchorMin;
rectTransform.sizeDelta = new Vector2(w, h);
}
}

Image

相比于 RawImageImage 就要复杂许多。二者的区别主要在于:

  1. Image 使用 Sprite 作为显示的图像资源,而 RawImage 使用 Texture 作为显示的图像资源。

  2. Image 提供了四种不同的显示模式:SimpleSlicedTiledFilled

    相关的顶点生成方法非常复杂,使得 Image.cs 足足达到了 1800 行。

Image 的显示模式

Image 提供了四种不同的显示模式,分别是 SimpleSlicedTiledFilled。这四种显示模式是通过 Type 枚举来定义的。

1
2
3
4
5
6
7
8
9
10
public enum Type
{
Simple,

Sliced,

Tiled,

Filled
}
image.png
  • Simple 模式:直接显示 Sprite,会随着 RectTransform 的大小进行缩放。

    此时 Sprite 会随着 RectTransform 改变长宽比例,可能会造成图像的拉伸或压缩。

    image.png
  • Sliced 模式:九宫格模式,RectTransform 改变时,会根据 Spriteborder 属性进行缩放。

    这种模式下当 RectTransform 改变时,将只会缩放九宫格的中间部分,而四个角的部分不会缩放。非常适合用于对话框等 UI 元素。

    image.png
  • Tiled 模式:平铺模式,RectTransform 改变时,会根据 Spriteborder 属性进行平铺。

    这种模式下,Sprite 会根据 border 属性进行平铺,四个角的部分不会被拉伸。适合用于地砖以及墙壁等需要平铺的 UI 元素。

    image.png
  • Filled 模式:填充模式,根据 fillMethod 以及 fillAmount 属性对 Sprite 进行填充。非常适用于血条、进度条等场景。

    image.png image.png

    对比填充度满的情况可以发现,二者的顶点数据是不同的。也就是说,填充的功能是通过设置不同的顶点三角形来实现的。

mainTexture 的重写

Image 同样重写了 GraphicmainTexture 属性,用于设置显示的 Sprite

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public override Texture mainTexture
{
get
{
if (activeSprite == null)
{
if (material != null && material.mainTexture != null)
{
return material.mainTexture;
}
return s_WhiteTexture;
}

return activeSprite.texture;
}
}

activeSprite 不为空时,则由 activeSprite.texture 提供渲染的纹理。

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
[SerializeField] private Sprite m_Sprite;

[NonSerialized] private Sprite m_OverrideSprite;

private Sprite activeSprite { get { return m_OverrideSprite != null ? m_OverrideSprite : sprite; } }

public Sprite sprite
{
get { return m_Sprite; }
set
{
if (m_Sprite != null)
{
if (m_Sprite != value)
{
m_SkipLayoutUpdate = m_Sprite.rect.size.Equals(value ? value.rect.size : Vector2.zero);
m_SkipMaterialUpdate = m_Sprite.texture == (value ? value.texture : null);
m_Sprite = value;

SetAllDirty();
TrackSprite();
}
}
else if (value != null)
{
m_SkipLayoutUpdate = value.rect.size == Vector2.zero;
m_SkipMaterialUpdate = value.texture == null;
m_Sprite = value;

SetAllDirty();
TrackSprite();
}
}
}

m_Spritem_OverrideSprite 共同决定了 activeSprite 的值。此外,在设置 Sprite 时,也会设置脏标记以触发重新渲染。

OnPopulateMesh() 的重写

Image 重写了 OnPopulateMesh() 方法,用于支持不同显示模式下的顶点生成。

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
protected override void OnPopulateMesh(VertexHelper toFill)
{
if (activeSprite == null)
{
base.OnPopulateMesh(toFill);
return;
}

switch (type)
{
case Type.Simple:
if (!useSpriteMesh)
GenerateSimpleSprite(toFill, m_PreserveAspect);
else
GenerateSprite(toFill, m_PreserveAspect);
break;
case Type.Sliced:
GenerateSlicedSprite(toFill);
break;
case Type.Tiled:
GenerateTiledSprite(toFill);
break;
case Type.Filled:
GenerateFilledSprite(toFill, m_PreserveAspect);
break;
}
}

OnPopulateMesh() 方法中,根据 type 的不同,调用不同的生成顶点数据的方法。这里就不再详细展开了,有兴趣的读者可以自行查看源码。

通过 useSpriteMesh 属性可以控制是否使用 Sprite 的顶点数据,二者顶点的差别在 Graphic 中已经分析过,默认的顶点就是一个简单的两个三角形组成的矩形,而 Sprite 的顶点则会根据透明度进行裁剪。

UpdateMaterial() 的重写

Image 也重写了 UpdateMaterial() 方法,不过并没有处理太多额外的逻辑。只是针对 RGBARGB + A 的两种情况,设置了 CanvasRendereralphaTexture 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected override void UpdateMaterial()
{
base.UpdateMaterial();

// check if this sprite has an associated alpha texture (generated when splitting RGBA = RGB + A as two textures without alpha)

if (activeSprite == null)
{
canvasRenderer.SetAlphaTexture(null);
return;
}

Texture2D alphaTex = activeSprite.associatedAlphaSplitTexture;

if (alphaTex != null)
{
canvasRenderer.SetAlphaTexture(alphaTex);
}
}

ILayoutElement 接口的实现

仔细观察 ImageRawImage 的定义我们可以发现,Image 实现了 ILayoutElement 接口,而 RawImage 没有实现。这意味着 Image 可以参与到布局系统的计算中,而 RawImage 则不行。

忘记了这部分内容的读者可以查看 LayoutRebuilder

1
2
3
public virtual void CalculateLayoutInputHorizontal() {}

public virtual void CalculateLayoutInputVertical() {}

这两个函数用于计算布局信息,实际上只有 LayoutGroup 这种会随着子物体而改变 PreferredSize 等属性的组件才需要实现这两个函数,大部分的 ILayoutElement 实现都是空函数。因此 Image 也不例外。

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
public virtual float minWidth { get { return 0; } }

public virtual float preferredWidth
{
get
{
if (activeSprite == null)
return 0;
if (type == Type.Sliced || type == Type.Tiled)
return Sprites.DataUtility.GetMinSize(activeSprite).x / pixelsPerUnit;
return activeSprite.rect.size.x / pixelsPerUnit;
}
}

public virtual float flexibleWidth { get { return -1; } }

public virtual float minHeight { get { return 0; } }

public virtual float preferredHeight
{
get
{
if (activeSprite == null)
return 0;
if (type == Type.Sliced || type == Type.Tiled)
return Sprites.DataUtility.GetMinSize(activeSprite).y / pixelsPerUnit;
return activeSprite.rect.size.y / pixelsPerUnit;
}
}

public virtual float flexibleHeight { get { return -1; } }

public virtual int layoutPriority { get { return 0; } }

接下来的这些属性就是我们熟悉的 minSizepreferredSizeflexibleSize 以及 layoutPriority 了。这些属性用于布局系统的计算,Image 中,会根据 activeSprite.pixelsPerUnit 以及 activeSprite.rect.size 来计算出 minSizepreferredSize

因此,如果 ImageRawImage 同时作为子物体,当父物体带有某种 LayoutGroup 组件时并且开启了 Control Child Size 选项,那么 Image 会根据 Sprite 的大小得到正确的布局信息,而 RawImage 则会直接坍缩为 0。

Image 和 RawImage 的区别

关于这两个组件之间的区别,上文已经提到了一些。这里再总结一下:

  1. Image 使用 Sprite 作为显示的图像资源,而 RawImage 使用 Texture 作为显示的图像资源。

  2. Image 提供了四种不同的显示模式:SimpleSlicedTiledFilled,而 RawImage 只能直接显示 Texture,相当于 ImageSimple 模式。

  3. 渲染效率方面,使用 Image 相比 RawImage 在一些情况下可以减少 DrawCall 的数量。 由于 RawImage 使用的是单独的 Texture,因此每一个 RawImage 在显示时都会触发一次 DrawCall。而 Image 使用的是 Sprite,可以使用 SpriteAtlas 来进行批量渲染,如果多个 Image 组件所使用的 Sprite 在同一个 SpriteAtlas 中,那么只会触发一次 DrawCall

    但带来的问题就是整个 SpriteAtlas 都会被加载到内存中,如果 SpriteAtlas 中的 Sprite 过多,可能会导致内存占用过高。

  4. Image 实现了 ILayoutElement 接口,可以参与到布局系统的计算中,而 RawImage 则不行。

    如果需要在布局系统中使用 RawImage,可以通过在 RawImage 上添加一个 LayoutElement 组件来实现。

合批的 🌰

我们新建一个场景,运行游戏并点击 Game 窗口的 Stats,可以看到当前的 DrawCall 数量。

image.png

在没有任何 UI 元素时,基本的 DrawCall 数量为 8,可能是绘制背景、Gizmos 等。

我们导入三张独立的图片,并将 Texture Type 设置为 2D,然后新建三个 RawImage 组件,分别显示这些图片。

image.png

运行游戏,可以看到 DrawCall 数量变为 11。这说明三张 RawImage 组件分别触发了三次 DrawCall

接着,我们导入另外三张独立的图片,并将 Texture Type 设置为 Sprite(2D and UI),然后新建三个 Image 组件,分别显示这三张图片。

image.png

运行游戏,我们发现 DrawCall 数量仍然为 11。说好的减少 DrawCall 呢?

别急,让我们继续分析。

我们新建一个 SpriteAtlas,将这三张图片打包到 SpriteAtlas 中,然后将 Image 组件的 Sprite 设置为 SpriteAtlas 中的 Sprite

image.png

然后运行游戏,可以看到 DrawCall 数量变为 9。这说明三个 Image 组件只触发了一次 DrawCall

image.png

然而,即使将 RawImageTexture 设置为 SpriteAtlas 中的 Sprite,DrawCall 数量仍然为 11。这说明 RawImage 无法使用 SpriteAtlas 进行批量渲染。

image.png

此外,在 UGUI 的制作中,有许多 Sprite 实际上是使用 Sprite Editor 从一张大图中切割出来的。这种情况下,使用 Image 能够减少 DrawCall 吗?

image.png

我们如此设置,再运行游戏。就会发现,DrawCall 的数量确实减少到了 9。这说明使用 Sprite Editor 切割出来的 Sprite 依然会进行批量渲染,减少 DrawCall 的数量。

总结:在使用独立的图片时,ImageRawImageDrawCall 数量是一样的。但是当使用 SpriteAtlas 或者 Sprite Editor 切割出来的 Sprite 时,Image 会进行合批,减少 DrawCall 的数量。

总结

本文我们简单分析了 ImageRawImage 的实现。ImageRawImage 是 UGUI 中最常用的两个图像组件,它们分别用于显示 SpriteTextureImage 提供了四种不同的显示模式,可以根据需求选择。RawImage 则是一个简单的图像组件,只能直接显示 Texture

在使用 Image 时,可以使用 SpriteAtlas 来进行批量渲染,减少 DrawCall 的数量。而 RawImage 无法使用 SpriteAtlas 进行批量渲染,每一个 RawImage 都会触发一次 DrawCall

此外,Image 实现了 ILayoutElement 接口,可以参与到布局系统的计算中,而 RawImage 则不行。如果需要在布局系统中使用 RawImage,可以通过在 RawImage 上添加一个 LayoutElement 组件来实现。