Unity的Canvas坐标系统

Unity在游戏中为Scene和UI提供了不同的渲染组件。Scene的Mesh/Sprite的渲染采用了MeshRenderer/SpriteRenderer,而UI的2D组件则通过CanvasRenderer渲染。

我个人不喜欢这套组件的命名方式。本质上所有的Renderer都并非渲染操作的执行者,而是采集渲染数据的容器组件。最终实际的渲染工作由Engine中的渲染代码完成。不过名字只是个代号,知道背后的原理就好。

对于基本的渲染流程来说,被渲染的数据来源都是相同的。Mesh负责提供顶点和颜色数据,Material提供Shader和它的各种参数,再加上用于提供位置信息的Transform Matrix,被渲染物体就算描述清楚了。

在Scene里面所有的物体,无论是2D的Sprite或者3D的Cube,它们在World中的位置都是通过Transform来描述的,Mesh的顶点数据是在Object Local坐标系。只有遇到需要合并批次的情况才会被转换为World坐标系。单独渲染的情况下,在RenderDoc中可以看到Vertex数据和Mesh中的数据完全一致。整个流程就是教科书上的标准流程,Local->World->View->Clip(Projection)->NDC。

在Unity Engine内部会做一个简单的优化。为了减少渲染批次,会把Material相同的Mesh组合成同一个批次。大概过程就是先将这些Mesh的顶点坐标乘以Transform Matrix,转化为个世界坐标系中的位置,然后放在同一个batch中一口气画出来。合批之后,RenderDoc中看到的World Matrix就变成了标准矩阵,因为每个Mesh已经乘上了各自的World Matrix。

通过CanvasRender组织的数据,则稍微有些不同。

Unity里面大部分UI是对各种运行时的UI元件抽象的描述,没有预先制作好的Mesh,这种情况下Mesh会在运行时直接生成。以最简单的RawImage为例,需要通过4个顶点2个三角形组成的矩形Mesh来描述。只要知道UI也是Mesh组成的,想要自定义UI的形状,就可以通过自己创建Mesh来实现,具体可以参考Graphic.cs等UI的代码。

// 自定义的情况下,更新mesh后调用SetVerticesDirty();
// 然后override UnityEngine.UI.Graphic.UpdateGeometry传入自定义的mesh。
protected override void UpdateGeometry()
{
    canvasRenderer.SetMesh(_custom_mesh); 
}

// 或者在Dirty之后,通过OnPopulateMesh()中直接生成顶点数据
// 基类会把这些顶点放进Mesh里,省去自己创建mesh对象。
protected override void OnPopulateMesh(VertexHelper vh)
{
  vh.AddVert(v0);
  vh.AddVert(v1);
  vh.AddVert(v2);
  vh.AddTriangle(0, 1, 2);
  ...
}

如何生成UI Mesh的顶点,就需要牵扯到RectTransform。它是Transfrom在UI组件中的特化,这个概念我不太喜欢。一般情况下,Transform只应该负责“位置和变换”相关的信息,但RectTransform同时定义了“形状”,因此它包含Width/Height属性。更加混乱的是,它还囊括了Anchors和Pivot的概念。Anchor会和UI组件上层的UI组件产生关联,以此修改自身的位置和形状。而Pivot则定义了Mesh中顶点的Local坐标系原点。我之所以讨厌这个组件,就是因为它把太多的东西杂糅到了一起,牵一发而动全身。

以RawImage为例,本来可以简单的定义中心作为原点,通过Width/Height即可生成Mesh需要的4个顶点在Local坐标系的位置。但是在Unity中,UI对象的Local坐标原点是通过Pivot来定义的。因此Pivot改变之后,Mesh的顶点数据也需要重新计算。

知道Pivot和Width/Height,生成了Mesh之后还不算完,这些数据并不会直接提交到渲染的流水线中。可能Unity假定UI基本上拥有相同的Material,所以UIMesh都会被强制做合并批次处理。即使只有1个UI组件,它也会被合并批次。

因此在RenderDoc中看到的顶点数据,都不是UI的Mesh中储存的原始数据。这些顶点会被转化到“Canvas Local坐标系”中,可以说UI的World就是包含它的Canvas。这也是为什么Unity需要把UI对象要放到Canvas的树型结构中,才能将其绘制出来。

对于UI组件来说,它的World Matrix是“自身的RectTransform”和“它所有Parent的RectTransform”相乘的结果:

// 合并批次之后的顶点数据:
// mul( GetCanvasTransform(this.RectTransform) , Mesh顶点 )
Matrix4x4 GetCanvasTransform(Transform t)
{
	Matrix4x4 current = Matrix4x4.identity;
	while (true)
	{
		var canvas = t.gameObject.GetComponent<canvas>();
		if (canvas != null &amp;&amp; canvas.isRootCanvas)
			return current;

		var m = Matrix4x4.TRS(t.localPosition, t.localRotation, t.localScale) * current;
		if (t.parent == null)
			return m;

		current = m;
		t = t.parent;
	}
}

UI的顶点变换相当于(Local->CanvasLocal)->CanvasWorld->View->Clip(Projection)->NDC,前两步对应UI Mesh合并批次的结果。

对于World,View和Projection三个变换矩阵,Scene里面的对象很直接,World是对象各自的Transform,View和Projection通过Camera上的设置生成。UI和Scene并没有本质区别,因为在shader中数据的处理方式都是相同的,主要区别在于送入渲染管线之前如何得到这些矩阵。

具体计算方法和Canvas的Render Mode相关,总共3种模式:

1、Screen Space – Overlay模式:此模式将在屏幕最上方直接渲染UI,忽略任何Camera。在Editor中这个模式的表现很奇怪,编辑器中的UI不能和当前场景的对象对齐,可能强制设定了一套坐标,我一般避免使用它,但官方范例爱用这个模式,即使游戏中只有一个Camera。

Canvas Local坐标系相当于View坐标系的数据。在RenderDoc中可以看到,World和View矩阵都被强制设为单位矩阵,而Projection为全屏范围的正交投影,和任何Camera都无关。在这个模式下,UI会在所有Camera渲染完毕之后,单独被放在在“UGUI.Rendering.RenderOverlays”批次中渲染。

如果设置了自动缩放的CanvasScaler,只有Projection矩阵会根据缩放比例产生变化,World/View依然是单位矩阵。因为CanvasScaler的代码只修改Canvas.scaleFactor,这个属性只会影响Projection矩阵。

2、Screen Space – Camera模式:此模式将在Camera正对的平面上渲染UI,Canvas的范围是该Camera的Viewport,相当于在billboards上渲染UI。在编辑器中可以看到,Canvas会跟着Camera旋转,始终正对Camera。

World矩阵用于把Canvas缩放旋转之后摆在World里正对Camera的位置。接下来View和Projection矩阵和Scene中其他物体的情况相同,按照同样的计算方式,最终得到Clip坐标系中的顶点。

值得注意的是,RenderDoc中可能看不到View矩阵的正确值。unity_MatrixV始终显示为单位矩阵,但unity_MatrixVP的结果是Projection*View,和渲染Scene中其他物体时候的MatrixVP保持一致。

3、World Space – Camera模式:此模式把Canvas当作World中的一个平面来渲染。在编辑器中可以看到Canvas的位置不会随着Camera转动,因此可以渲染出带有3D空间感觉的UI。

和前一个模式唯一的区别在于,World矩阵始终为单位矩阵。因为Canvas Loca坐标系被当作World坐标系来对待,所以Canvas的Width/Height不再是Pixel Size,而是World坐标系的单位。

============================

上述3种模式中,前两种模式中Canvas的RectTransform的各项数值由Canvas自己设定,只有第3种可以自己设定。RectTransform再次承担了特殊功能。我猜测引擎内部根据模式和RectTransform的数值,计算View Matrix,以及UIMesh合批的时候转换到Canvas Local坐标系的计算。

总结一下就是:

1、无论何种模式,传入渲染管线的定点数据坐标系没有变化,均为Canvas Local坐标系。这也是Canvas渲染模式切换不影响material的缘故。

2、根据Canvas渲染模式的不同,View和Projection会被分别设置。

3、除了World Space – Camera模式,所有Canvas树形结构中的对象,其Transform均不受Canvas层以上的父节点影响。这和普通的Scene对象有区别。

4、如果希望在shader中获取UI Mesh的Local坐标,可以通过传入GetCanvasTransform()计算结果的逆矩阵,然后和顶点坐标相乘来获得。

// material.SetMatrix("canvas_transform_inverse", GetCanvasTransform(this.rectTransform).inverse);
float2 local = mul( canvas_transform_inverse, float4(vertex, 0.0, 1.0) );

以上均为个人对Canvas坐标系统的理解,如果以后发现错误,再来订正。

针对RectTransform的吐槽就不再重复了,最后想说得是,Unity完全没有必要设计这些东西,单独为UI开一条渲染路径。所有的UI都应该并入Scene中,和其他对象一样进行渲染。或者反过来说,针对UI的这些“优化”和“坐标转换”,都应该允许场景中其他对象参与。否则Graphic相关的实现,都得单独 给UI做一套,例如particle,effect,animation。这又是何苦呢。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.