Unity Canvas 自适应探究

前言

Canvas 是所有 UI 组件被布局和渲染的空间,这些 UI 组件都必须位于 Canvas 子节点。在 Unity 编辑器中,我们可以方便的进行 Canvas UI 编辑。

一个 Canvas 系统通常包含以下几个组件:

  • Canvas 组件(Canvas 对象的基础组件)

  • CanvasScaler 组件(控制所有 UI 元素的缩放)

  • GraphicRaycaster 组件(用于 Event System 中的射线检测)

接下来,我们就一起来看看每个组件的分工与作用。

先来简单介绍 Canvas 组件

Canvas 组件(类)继承自 Behaviour 类,因此它可以被 enable 或 disable,同时它继承了祖先类的属性和方法。在其内部包含一些属性,可以在 Unity 编辑器中直接设置。这些属性配置用来描述 Canvas 系统是如何运作的,如下:

属性 描述
Render Mode UI 的渲染方式,包含 Screen Space-OverlayScreen Space-CameraWorld Space 三个值
Pixel Perfect (仅 Screen Space-xx 模式) 是否在渲染 UI 元素时进行像素对齐,启用后元素看起来会更清晰
Render Camera(仅 Screen Space-Camera 模式) Screen Space-Camera 模式下渲染 UI 元素的 Camera
Event Camera(仅 World Space 模式) 处理 UI 事件的 Camera
Plane Distance(Screen Space-Camera 模式可设置) UI Plane 距离 Camera 的距离
Target Display(仅 Screen Space-Overlay 模式) Canvas 渲染目标展示的窗口展示器
Sort Order(仅 Screen Space-Overlay 模式) 在渲染或 Event System 射线检测时用到,值越大表示越靠前展示
Sorting Layer(Screen Space-CameraWorld Space 模式) Canvas 所在的排序层,值为 Tags and Layers 中的一个,设置为越靠前的值越先渲染(越靠后展示)
Order in Layer(Screen Space-CameraWorld Space 模式) 如果 Sorting Layer 相同,则可以使用这个值来细分渲染顺序,该值越小越先渲染
Additional Shader Channels 配置为 Canvas 生成 Mesh 时携带额外的数据

这里主要说明一下 Render Mode 这个属性,用来设置 UI 的渲染方式,有 Screen Space-OverlayScreen Space-CameraWorld Space 三个值可以设置。

  • Screen Space-Overlay

这种模式设置让所有 UI 直接显示在屏幕的最上方(包括 Camera 渲染的场景图像)。Canvas 通过缩放来适配不同的屏幕分辨率,当屏幕尺寸改变或分辨率改变,会自动重新计算并布局。

  • Screen Space-Camera

这种模式和 Screen Space - Overlay 有点相似,不同的是在这种模式下需要设定一个 Camera,所有的 UI 元素由该 Camera 渲染(如果未指定 Render Camera,就会以 Screen Space - Overlay 模式去渲染)。这种模式下 Canvas 朝向 Camera,通过 Plane Distance 可以调整 Canvas 和 Camera 的距离。当屏幕尺寸、分辨率或 Camera 的视锥体大小发生变化,Canvas 都会通过改变自身的 scale 去自动适配。如果场景中有 3D 物体并且离 Camera 近,那么 3D 物体会渲染在 Canvas 前面,反之 Canvas 会渲染在 3D 物体的前面。

  • World Space

这种模式下,Canvas 被当成世界空间下一个普通的 3D 对象去渲染,所以它既可以渲染在某些对象的前面,也可以渲染在某些对象的后面。和 Screen Space-Camera 模式的区别是 Canvas 不需要再朝向 Camera,Canvas 可以通过设置自身的不同属性调整位置、大小、角度等。

Canvas 组件(类)中,还有一些其他属性,但是它们不可以在编辑器中配置,例如 scaleFactor 属性就是 Canvas 自适应会用到的一个变量,使用它来缩放整个 Canvas 以使其适配屏幕。还有更多属性的作用与介绍请参考 Unity Scripting API - Canvas,这里不再一一介绍。

Canvas 如何自动适配了?

上面讲到,在 Render Mode 设置为 Screen Space-OverlayScreen Space-Camera 时,当屏幕尺寸、分辨率变换 Canvas 都会自动重新计算来进行自动适配,那么适配工作是如何进行的了?

  • Screen Space-Overlay

在这种模式下,Canvas 所在的 Rect Transform 的宽高分别为 400 和 400,Scale 均为 1,当前游戏屏幕设定的分辨率也是 400*400,因此 Canvas 的宽高在这种模式下就会自动跟随物理分辨率自动设置。

  • Screen Space-Camera

在这种模式下,首先来看看 Canvas 的配置以及计算得到的适配结果:

图中 Rect Transform 的 Width 和 Height 的值分别为 400 和 400,Scale 的三个值都被计算 0.05;Canvas 的 Plane Distance 被设置为 10,并添加了一个 Render Camera。

当前游戏屏幕设定的分辨率为 400*400,所以 Canvas 宽高都被设置为 400 很好理解(布满屏幕),但是这里的 Scale 为什么都被计算成了 0.05 了?

带着疑问,再来看看 Render Camera 的一些配置:

其中 Projection 为 Perspective (透视投影),Field of View 设置为 90 度,Target Display 中的值就是设置的 400*400 的屏幕。

根据上面的配置以及视锥体和三角函数,其实可以计算得到 Canvas 的真实高度为:

\[ height = tan(\frac{FOV}{2}) \times planeDistance \times 2 \]

由于 Target Display 的宽高比例为 1:1,因此 Canvas 的宽就等于高:

\[ width = height \]

对 Canvas 进行缩放就得到了真实的宽高,所以缩放系数:

\[ scaleX = \frac{width}{canvasOriginWidth} \]

\[ scaleY = \frac{height}{canvasOriginHeight} \]

带入数字计算得到 Canvas 的 Scale 就是 0.05(当 Camera 投影方式设置为 Orthographic,也可使用对应的参数去计算)。

CanvasScaler 起到了什么作用?

对于多分辨率的适配问题,究竟什么样的适配才算得上完美,也许依游戏不同适配的要求也有所区别。在 Unity 中,适配的时候我们常常离不开 CanvasScaler 这个组件,它用来帮助我们的游戏“适应”不同尺寸的屏幕。

The Canvas Scaler component is used for controlling the overall scale and pixel density of UI elements in the Canvas. This scaling affects everything under the Canvas, including font sizes and image borders.

官方文档所说,Canvas Scaler 用来控制 Canvas 下所有 UI 元素的缩放以及像素密度,它的缩放比例会影响 Canvas 下所有的元素。

CanvasScaler 组件在编辑器中也有很多可以配置的属性,首先最重要的就是 UI Scale Mode,有三种模式可以设置,用来控制 UI 元素在 Canvas 下是如何缩放的。下面就结合源码分别来分析一下究竟是如何进行缩放的了?

Constant Pixel Size

设置为这种模式时,所有 UI 元素的位置和大小都保持为原来的像素大小,但是会根据设置的 Scale Factor 缩放。若一个 Canvas 没有绑定 CanvasScaler 组件(上一部分讲到的情况),其适配过程就是使用这种模式。

在这种模式下,有两个参数可以手动配置: Scale Factor 和 Reference Pixels Per Unit。在 CanvasScaler 类的 Handle() 方法中,使用 HandleConstantPixelSize() 对这两个配置进行使用,代码如下:

1
2
3
4
5
protected virtual void HandleConstantPixelSize()
{
SetScaleFactor(m_ScaleFactor);
SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit);
}

其中 m_ScaleFactor 就是配置项 Scale Factor,m_ReferencePixelsPerUnit 就是配置项 Reference Pixels Per Unit。SetScaleFactor 方法和 SetReferencePixelsPerUnit 方法就是设置当前 Canvas 的这两个值。

  1. 配置 Scale Factor 后,Canvas 组件中的属性 scaleFactor 也被更新为设置的这个值,然后用这个缩放系数来缩放 Canvas 下所有的 UI 元素。

例如屏幕大小为 400*400,Scale Factor 设置为 2,Canvas 为了适配屏幕的宽高就会通过公式 \(ScreenSize \div Scale Factor\) 计算得到宽高值为都 200,同时 Canvas 下所有的 UI 元素也都被缩放 2 倍。

  1. Reference Pixels Per Unit 配置就是 Canvas 组件中的成员变量 referencePixelsPerUnit表示每个 Unity 单位包含的屏幕(UI)像素。这个参数需要与 Sprite 设置下的 Pixel Per Unit(每 Unity 单位中包含多少个 Sprite 像素) 一起使用。

在 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
public float pixelsPerUnit
{
get
{
float spritePixelsPerUnit = 100;
if (activeSprite)
spritePixelsPerUnit = activeSprite.pixelsPerUnit;
if (canvas)
m_CachedReferencePixelsPerUnit = canvas.referencePixelsPerUnit;
return spritePixelsPerUnit / m_CachedReferencePixelsPerUnit;
}
}
public override void SetNativeSize()
{
if (activeSprite != null)
{
float w = activeSprite.rect.width / pixelsPerUnit;
float h = activeSprite.rect.height / pixelsPerUnit;
rectTransform.anchorMax = rectTransform.anchorMin;
rectTransform.sizeDelta = new Vector2(w, h);
SetAllDirty();
}
}

在上面的代码 pixelsPerUnit 方法中,spritePixelsPerUnit 被设置为了 Sprite 的 pixelsPerUnit,m_CachedReferencePixelsPerUnit 被设置成了 Canvas 的 referencePixelsPerUnit(这个值就是我们再 Canvas Scaler 中配置的值)。最后这里的计算返回的 pixelsPerUnit 为 spritePixelsPerUnit / m_CachedReferencePixelsPerUnit

再看看 SetNativeSize,这个方法用来设置 Image 使其更加 pixel-perfect (更加清晰),设置后 Image 的 rectransform.sizedelta 就有可能和 Sprite 的尺寸相等。将 Sprite 转换到 Unity 单位下:

\[ spriteUnitySize = \frac{sprite.size}{pixelPerUnit} \]

然后将 Sprite 由 Unity 单位尺寸转换为屏幕 UI 尺寸:

\[ spriteDimensions = spriteUnitySize \times referencePixelsPerUnit \]

\[ spriteDimensions = sprite.size \div pixelPerUnit \times referencePixelsPerUnit \]

比如: Sprite 的原始尺寸为 400400,Pixel Per Unit 为 100,那么 Sprite 在 Unity 中的大小就是 44 个单位;Canvas Scaler 的 Reference Pixels Per Unit 为 100(一个 Unity 单位对应屏幕 UI 100 个像素),因此最后 Sprite pixel-perfect 的尺寸就是 400*400。

在 Constant Pixel Size 模式下,所有的 UI 元素的大小和位置都使用屏幕像素来指定计算。通过 Scale Factor 指定缩放系数,Reference Pixels Per Unit 用来指定每 Unity 单位又会以多少像素渲染在屏幕上。

Scale With Screen Size

使用这种模式,UI 元素能够根据参考分辨率以及配置的参数达到自适应(计算最合适的 Scale Factor)不同分辨率屏幕的目的。首先还是先看一下这种模式下可以配置的一些参数:

  1. Reference Resolution - 参考分辨率,通常为设计所用的分辨率。

  2. Reference Pixels Per Unit - 这个值在 Constant Pixel Size 模式下讲过,计算方式都相同,这里不再讲解。

  3. Screen Match Mode - 屏幕适配模式,当屏幕分辨率的比例和参考分辨率的比例不同时,就会根据这个参数来计算 Scale Factor(缩放系数,其实在比例相同的时候也会使用以下配置计算,只是对于下面不同的设置值计算结果都相同)。它有以下几个值:

    • Match Width or Height - 在这种屏幕适配模式下,Canvas 会根据某个方向(Width、 Height 或他们中间的某个值)单独(混合)的计算缩放系数来适配;

      • Match - 通过改变这个值(0-1 之间。0 是代表 Width、1 是代表 Height)计算缩放系数。
    • Expand - 设置为这个值的时候 Canvas 会水平或垂直展开以缩放,Canvas 的大小永远不会比参考分辨率小;

    • Shrink - 设置为这个值的时候 Canvas 会水平或垂直裁剪以缩放,Canvas 的大小永远不会比参考分辨率大。

下面看看具体是如何计算 Scale Factor 来达到以上的适配要求。在 Handle() 方法中,如果 Scale Mode 是 Scale With Screen Size,会使用 HandleScaleWithScreenSize() 计算 Scale Factor,代码如下:

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
protected virtual void HandleScaleWithScreenSize()
{
// get screen size...
float scaleFactor = 0;
switch (m_ScreenMatchMode)
{
case ScreenMatchMode.MatchWidthOrHeight:
{
float logWidth = Mathf.Log(screenSize.x / m_ReferenceResolution.x, kLogBase);
float logHeight = Mathf.Log(screenSize.y / m_ReferenceResolution.y, kLogBase);
float logWeightedAverage = Mathf.Lerp(logWidth, logHeight, m_MatchWidthOrHeight);
scaleFactor = Mathf.Pow(kLogBase, logWeightedAverage);
break;
}
case ScreenMatchMode.Expand:
{
scaleFactor = Mathf.Min(screenSize.x / m_ReferenceResolution.x, screenSize.y / m_ReferenceResolution.y);
break;
}
case ScreenMatchMode.Shrink:
{
scaleFactor = Mathf.Max(screenSize.x / m_ReferenceResolution.x, screenSize.y / m_ReferenceResolution.y);
break;
}
}
// set scale factor & reference pixels per unit
}
  • ScreenMatchMode.MatchWidthOrHeight

在这个设置下,在对数空间计算 scaleFactor 值。通过代码可以看出,首先将所在的屏幕宽度与参考分辨率宽度比例转换到对数空间,高度比例也转换到对数空间,然后在对数空间下通过 Match 的配置对宽度比例和高度比例进行插值得到对数空间下的 scaleFactor,最后在将对数空间下的 scaleFactor 转换回原始空间下(对数空间下计算有更好的表现)。

根据上面的代码,可以得到 scaleFactor 计算公式。

对数空间下屏幕宽度和参考分辨率的宽度比例:

\[ logWidthRatio = \log_{2}(\frac{screenSize.x}{m_ReferenceResolution.x}) \]

对数空间下屏幕高度和参考分辨率的宽度比例:

\[ logHeightRatio = \log_{2}(\frac{screenSize.y}{m_ReferenceResolution.y}) \]

最终 scaleFactor 等于:

\[ scaleFactor = 2^{(1 - Match) \times logWidthRatio + Match \times logHeightRatio} \]

由此可以看出当 Match 设置为 0 (在最左边)时,带入公式计算得

\[ scaleFactor = 2^{\log_{2}(\frac{screenSize.x}{m_ReferenceResolution.x})} = \frac{screenSize.x}{m_ReferenceResolution.x} \]

scaleFactor 就是屏幕宽度与参考分辨率宽度比例,这种情况下高度对 Canvas 缩放没有任何影响。同理如果 Match 设置为 1 (拖到最右边),那么 scaleFactor 就是屏幕高度与参考分辨率高度比例。

那么在什么情况下设置为这个值比较好了?当你的游戏发布后可能面对的分辨率千奇百怪(比各种如 Android 手机),这个时候 Screen Match Mode 设置为 ScreenMatchMode.MatchWidthOrHeight,通过按宽度、高度或是混合宽度高度来计算缩放系数,以尽可能达到各种分辨率屏幕适配。朝着一个方向缩放,能够保证这个方向上位置布局不会出现问题,但是另一个方向会根据适配屏幕适配这个要求去拉伸;这时也许设置一个混合值 Match 可以更好的达到适配缩放显示效果。

  • ScreenMatchMode.Expand

通过上面代码可以看出,当 Screen Match Mode 设置为 ScreenMatchMode.Expand 时,最终得到的 scaleFactor 是屏幕分辨率与参考分辨率宽高比中较小的一个,然后根据 scaleFactor 计算 Canvas 的宽高的公式为:

\[ canvasSize = \frac{screenSize}{scaleFactor} \]

假如上面计算的到的 scaleFactor 是 screenSize.x / m_ReferenceResolution.x (宽的分辨率比例小),则有:

\[ \frac{screenSize.x}{m_ReferenceResolution.x} \leqslant \frac{screenSize.y }{m_ReferenceResolution.y} \]

带入计算 Canvas 的公式得到 Canvas 宽高分别为:

\[ canvasWidth = \frac{screenSize.x}{(\frac{screenSize.x}{m_ReferenceResolution.x})} = m_ReferenceResolution.x \]

\[ canvasHeight = \frac{screenSize.y}{(\frac{screenSize.x}{ m_ReferenceResolution.x})} \]

可以看到 Canvas 的宽就是参考分辨率的宽,高度是一个计算值。根据计算 scaleFactor 的不等式以及不等式的基本性质可以得到:

\[ canvasHeight = \frac{screenSize.y}{(\frac{screenSize.x}{ m_ReferenceResolution.x})} \geqslant m_ReferenceResolution.y \]

因此最终得到的 Canvas 的高度不小于参考分辨率的高度;又因为缩放后 Canvas 的宽是参考分辨率的宽,因此当 Screen Match Mode 设置为 ScreenMatchMode.Expand 时,缩放后的 Canvas 的大小永远不会比参考分辨率小。

Q: 放大流程到底是怎么样的?

A: 首先此时的参考分辨率的长宽都使用缩放系数放大,但是这只能满足较小的那个方向的适配,对于较大比例方向,Canvas 还需要进行拉伸扩大以适配屏幕。

Q: 那么什么情况下使用这个设置比较好了?

A: 由于此种情况下得到的 Canvas 的尺寸肯定是大于或等于参考分辨率的,因此适合屏幕分辨率两个方向都大于参考分辨率的情形;当屏幕的分辨率比参考分辨率要大,说明这种情况下需要的 UI 元素进行放大处理最好,因此 Screen Match Mode 设置为 ScreenMatchMode.Expand

Q: 为什么此时要使用分辨率宽度比和高度比中较小的一个(这个比例就是 UI 元素的放大比例)?

A: 取小的比例合适是因为对布局来说更安全(元素放大导致的遮挡问题)。比如如果取了比较大的那个比例值作为 Canvas 的缩放系数,那么 UI 元素在水平和垂直两个方向上都会使用该比例进行放大处理,此时只是满足了较大比例的那个方向的适配,对于较小比例方向,Canvas 还需要通过缩小以适配屏幕,此时被放大的 UI 元素由于 Canvas 被缩小而位置发送变化就极有可能彼此遮挡;但是取较小比例时这种情况就不会发生,因为对于参考分辨率来说首先 Canvas 两个方向都以这个小的比例放大,此时不会遮挡,然后对于比例较大的方向通过拉伸适配屏幕,拉伸让这个方向上的元素彼此离得更远了,更不会遮挡。

  • ScreenMatchMode.Shrink

用同样的计算方式也可得到在设置 ScreenMatchMode.Shrink 时,计算得到的 scaleFactor 是屏幕分辨率与参考分辨率宽高比中较大的一个,因此缩放后的 Canvas 的大小永远不会比参考分辨率大(证明过程和上面类似)。

Q: 缩小流程到底是怎么样的?

A: 首先此时的参考分辨率的长宽都使用缩放系数缩小,但是这只能满足较大比例的那个方向的缩小适配(比例越大这里缩小的像素就越小),对于较小比例方向就缩小还不能达到适配屏幕的目的,因此 Canvas 还需要缩小。

Q: 那么什么情况下使用这个设置比较好了?

A: 由于此种情况下得到的 Canvas 的尺寸肯定是小于或等于参考分辨率的,因此适合屏幕分辨率两个方向都小于参考分辨率的情况;当屏幕的分辨率比参考分辨率要小,说明这种情况下需要的 UI 元素进行缩小处理最好,因此 Screen Match Mode 设置为 ScreenMatchMode.Shrink

总结一下,当 Canvas Scaler 的 UI Scale Mode 设置为 Scale With Screen Size 时自适应屏幕的几种情况:

  1. 屏幕分辨率比例和参考分辨率相同,但是比参考分辨率大。这种情况下,Canvas 尺寸保持参考分辨率的大小,但会缩放(大)来适配屏幕,UI 也会被放大。

  2. 屏幕分辨率比例和参考分辨率相同,但是比参考分辨率小。在这种情况下,Canvas 尺寸同样也会保持参考分辨率的大小,但会缩(小)放来适配屏幕,UI 也会被缩小。

  3. 屏幕分辨率比例和参考分辨率不同。在这种情况下,如果屏幕分辨率都两个方向都大于参考分辨率,使用 Expand 较好;如果屏幕分辨率两个方向都小于参考分辨率,使用 Shrink 更能达到自适应目的;当设置为 Match Width Or Height 时,可以通过调节 Match 参数来计算最佳缩放系数以自适应,这种方式最为灵活。

Constant Physical Size

使用这种模式,UI 元素的位置和大小信息都是用物理单位来表示。同样它也可以配置一些参数:

  1. Physical Unit - 指定的武力单位。

  2. Fallback Screen DPI - 设置的默认 DPI。

  3. Default Sprite DPI - 设置的默认 Sprite DPI。

  4. Reference Pixels Per Unit,同 Constant Pixel Size 中这个参数的设置。

在回到代码中,看到 HandleConstantPhysicalSize 方法,这个方法同样会计算 Scale Factor,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected virtual void HandleConstantPhysicalSize()
{
float currentDpi = Screen.dpi;
float dpi = (currentDpi == 0 ? m_FallbackScreenDPI : currentDpi);
float targetDPI = 1;
switch (m_PhysicalUnit)
{
case Unit.Centimeters: targetDPI = 2.54f; break;
case Unit.Millimeters: targetDPI = 25.4f; break;
case Unit.Inches: targetDPI = 1; break;
case Unit.Points: targetDPI = 72; break;
case Unit.Picas: targetDPI = 6; break;
}
SetScaleFactor(dpi / targetDPI);
SetReferencePixelsPerUnit(m_ReferencePixelsPerUnit * targetDPI / m_DefaultSpriteDPI);
}

从代码中可以看到计算 Scale Factor 和 Reference Pixels Per Unit 都是使用 DPI 去计算的,targetDPI 根据使用的 Physical Unit 不同而不同。

World Space

当 Canvas 的 Render Mode 设置为 World Space,Canvas Scaler 用来控制 UI 元素的像素密度。

参考