RectTransform 如何从 Screen Space 到 World Space

前言

作为研发的你工作中可能碰到过这样的场景:

某天,策划同学 J 想到一个能给玩家带来愉悦感的「送钱特效」,这是一个三维场景中很多金币模型带有曲线的飞跃效果;渲染在屏幕上时,金币模型还被要求能从屏幕的指定位置飞到屏幕的另一指定位置。第二天,美术同学 S 在三维场景中调出了最好看的金币模型渲染效果,剩下的...

当然剩下的就由作为研发的你来接手实现了。

简单实现这个需求

3D 模型渲染到已有的 Unity UI 上面,使用 Camera、Render Texture 和 Raw Image 来实现;脚本控制模型运动效果,Camera 来拍摄这些效果并渲染到 Render Texture 上,再利用 Raw Image 将 Render Texture 在 Unity UI 层展示出来。

通过上面操作,已经能将「送钱特效」渲染到屏幕上了。但是好像有一点被忽略了,如何保证「送钱特效」中金币飞跃的起始位置了?金币模型是在三维场景(世界空间)中,而屏幕上的位置属于二维场景(屏幕空间),必须得将它们联系起来。

Screen Space (屏幕空间)到 World Space (世界空间)

在谈转换之前,首先需要了解几个知识点:

  1. 当没有相机渲染 Canvas (Canvas 的 Render Mode 设置为 Screen Space - OverlayScreen Space - Camera 且不设置相机)时:

    • Canvas 左下角位于世界空间下的原点处(Canvas 此模式下世界空间下 Z 坐标为 0);

    • 位于 Canvas 中的 RectTransform 世界坐标就是其所在的屏幕坐标(X 和 Y 方向),Z 方向世界坐标是其相对于父元素世界坐标偏移 localPosition.z (或 Pos Z)的值;

  2. Canvas 的 Render Mode 设置为 Screen Space - Camera 并设置相机)时:

    • Unity 中一个 UI 单位不再是对应一个 Unity 单位;

    • Canvas 的 Pivot 处 X 和 Y 方向世界坐标和渲染 Camera X 和 Y 方向世界坐标相等;

    • Canvas 所在平面 Z 方向世界坐标由其设置的 Plane Distance 值和渲染 Camera 的 Z 方向世界坐标共同决定;

  3. Canvas 的 Render Mode 设置为 World Space 时:

    • 此时会将 Unity UI 当做 3D 物体来渲染,Canvas 可以位于空间中任何位置;

    • 位于 Canvas 中的 RectTransform 世界坐标即可根据 Canvas 位置以及其 localPosition 得出;

上面三种情况下,Unity 中 UI 单位与 Unity 单位对应关系受 RectTransform 祖先元素缩放影响,具体表达式为:

\[ PerUIUnitUnityUnit = \sum_{n=1}^N Parent(n).Scale \]

有了上面的知识点,再来看看 Screen Space 到 World Space 的转换。

对于 Canvas 的 Render Mode 为 Screen Space - OverlayScreen Space - Camera 且不设置相机 这种模式,Canvas 下 RectTransform 的世界坐标表达式为:

\[ \begin{eqnarray*} World.Position.x & = & Screen.Position.x \\ World.Position.y & = & Screen.Position.y \\ World.Position.z & = & \sum_{n=1}^N (Parent(n).LocalPosition.z \times PerUIUnitUnityUnit(n)) \\ && + Self.LocalPosition.z \times PerUIUnitUnityUnit(Self) \end{eqnarray*} \]

其中 World.Position 为 RectTransform 的世界坐标;Screen.Position 为 RectTransform 在屏幕上的坐标;Parent(n).LocalPosition 为 RectTransform 某一层祖先元素相对其父节点的坐标,PerUIUnitUnityUnit(n) 为这一层祖先元素一个 UI 单位对应的 Unity 单位的数目;Self.LocalPosition 是 RectTransform 自身相对父节点的坐标,PerUIUnitUnityUnit(Self) 为自身 RectTransform 一个 UI 单位对应的 Unity 单位的数目。

对于 Canvas 的 Render Mode 为 World Space,Canvas 下 RectTransform 的世界坐标表达式为:

\[ \begin{eqnarray*} World.Position.x & = & \sum_{n=1}^N (Parent(n).LocalPosition.x \times PerUIUnitUnityUnit(n)) \\ && + Self.LocalPosition.x \times PerUIUnitUnityUnit(Self) \\ && + Canvas.Position.x \end{eqnarray*} \]

\[ \begin{eqnarray} World.Position.y & = & \sum_{n=1}^N (Parent(n).LocalPosition.y \times PerUIUnitUnityUnit(n)) \\ && + Self.LocalPosition.y \times PerUIUnitUnityUnit(Self) \\ && + Canvas.Position.y \end{eqnarray} \]

\[ \begin{eqnarray*} World.Position.z & = & \sum_{n=1}^N (Parent(n).LocalPosition.z \times PerUIUnitUnityUnit(n)) \\ && + Self.LocalPosition.z \times PerUIUnitUnityUnit(Self) \\ && + Canvas.Position.z \end{eqnarray*} \]

其中 World.Position 为 RectTransform 的世界坐标;Parent(n).LocalPosition 为 RectTransform 某一层祖先元素相对其父节点的坐标,PerUIUnitUnityUnit(n) 为这一层祖先元素一个 UI 单位对应的 Unity 单位的数目;Self.LocalPosition 是 RectTransform 自身相对父节点的坐标,PerUIUnitUnityUnit(Self) 为自身 RectTransform 一个 UI 单位对应的 Unity 单位的数目;Canvas.Position 是 Canvas 在世界空间下的坐标。

对于 Canvas 的 Render Mode 为 Screen Space - Camera 并设置相机),这种情况相对复杂一点。

首先需要明白在这种情况下,为什么一个 Unity 单位不再固定对应一个 UI 单位。当 Canvas 的 Render Mode 设置为这种模式时,Camera 需要保证在其正前方 Plane Distance 距离处的视锥体切面大小能够完全吻合 Canvas 平面大小;有了这点限制,Camera 的 Field Of View 以及 Canvas 的 Plane Distance 都可以影响 Unity 单位和 UI 单位的对应关系。

下面就来找出 Canvas 在这种渲染模式下 Unity 单位和 UI 单位的对应关系,如下图:

从上图可以看出,屏幕高度(UI 单位)和 Plane Distance 以及 Field Of View 之间存在三角函数关系,如下:

\[ tan(\frac{FOV}{2}) = \frac{Screen Height \div 2}{Plane Distance} \]

一个 Unity 单位和 UI 单位关系表达式为:

\[ PerUnitPixels = \frac{Screen Height \div 2}{Plane Distance \times tan(\frac{FOV}{2})} \]

从上面的关系表达式中可以知道: 当屏幕分辨率(Canvas 尺寸大小)和 Camera 的 Field Of View 都固定时,Canvas 的 Plane Distance 越大,一个 Unity 单位对应的 UI 单位数越少,反之越多;当屏幕分辨率(Canvas 尺寸大小)和 Canvas 的 Plane Distance 都固定时,Camera 的 Field Of View 越大,一个 Unity 单位对应的 UI 单位数越少,反之越多。

知道了 Unity 单位和 UI 单位关系表达式,就能够得到 Canvas 中 RectTransform 在世界空间下的坐标表达式了,如下图是一个 Button Camera 拍摄切面图:

首先来计算 Canvas 的 Pivot 处的世界坐标,前面提到过这个坐标和 Camera 的世界坐标以及 Plane Distance 相关,具体表达式如下:

\[ CanvasPivot.Position.x = Camera.Position.x \]

\[ CanvasPivot.Position.y = Camera.Position.y \]

\[ CanvasPivot.Position.z = Camera.Position.z + Plane Distance \]

Canvas 的 Pivot 世界坐标最终表达式:

\[ \begin{eqnarray*} CanvasPivot.Position & = & (Camera.Position.x, Camera.Position.y, \\ && Camera.Position.z + Plane Distance) \end{eqnarray*} \]

有了 Canvas 的世界坐标计算表达式和 Unity 单位和 UI 单位的对应关系,Canvas 下 RectTransform 的世界坐标计算表达式也就可以方便得到:

\[ \begin{eqnarray*} RectTransform.Position.z & = & \frac{\sum_{n=1}^N Parent(n).LocalPosition.z + RectTransform.LocalPosition.z}{PerUnitPixels} \\ && + CanvasPivot.Position.z \end{eqnarray*} \]

其中 CanvasPivot.Position 为上面计算得到的 Canvas Pivot 的世界坐标,Parent(n).LocalPosition 为 RectTransform 第 n 个祖先节点相对其父元素的坐标,RectTransform.LocalPosition 是 RectTransform 自身相对父节点的坐标,PerUnitPixels 是前面得到的 Unity 单位和 UI 单位的对应关系表达式。

最后将 RectTransform 祖先元素的缩放影响添加到上面公式中得到最终 Z 方向的世界坐标表达式:

\[ \begin{eqnarray*} RectTransform.Position.z & = & (\sum_{n=1}^N (Parent(n).LocalPosition.z \times PerUIUnitUnityUnit(n)) \\ && + RectTransform.LocalPosition.z \times PerUIUnitUnityUnit(RectTransform)) \\ && \div PerUnitPixels \\ && + CanvasPivot.Position.z \end{eqnarray*} \]

上面的表达式是 Z 方向上的世界坐标,同理也可以求得 X 和 Y 方向的表达式。

RectTransform 世界坐标最终表达式如下:

\[ \begin{eqnarray*} RectTransform.Position & = & (\sum_{n=1}^N (Parent(n).LocalPosition \times PerUIUnitUnityUnit(n)) \\ && + RectTransform.LocalPosition \times PerUIUnitUnityUnit(RectTransform)) \\ && \div PerUnitPixels \\ && + CanvasPivot.Position \end{eqnarray*} \]

下面使用上面的公式来计算 Canvas 下 Button 的世界坐标。Camera 世界坐标为 (0, 0, 0)、FOV 为 90,Canvas 设置 Plane Distance 100,屏幕分辨率为 (750 x 1334) 像素,Button 的 anchoredPosition(localPosition) 为 (0, 0, 100),根据公式计算得到 Button 的世界坐标为 (0.0, 0.0, 115.0)。

为了验证这个结果的准确性,使用 RectTransformUtility 类的 ScreenPointToWorldPointInRectangle 方法根据同样的参数再来计算,代码如下:

1
2
3
4
5
6
RectTransformUtility.ScreenPointToWorldPointInRectangle(
button.GetComponent<RectTransform>(),
new Vector2(Screen.width / 2, Screen.height / 2),
Camera.main,
out Vector3 worldPoint
);

上面输出结果同样为 (0.0, 0.0, 115.0)。

在 Unity 的底层实现中,对于上面的从屏幕空间到世界空间的坐标转换,计算原理也是类似。