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。这又是何苦呢。

Xbox One X硬盘更换

之前想给X1X换个SSD,一直没有找到机会下手。趁着最近正在整理硬盘文件,腾出一张256G的SSD,就想给X1X换上,还能拿到1T的旧盘用来当移动硬盘。

本来以为X1X更换硬盘和PS4差不多,找到硬盘插上去然后重新执行升级文件,完事儿!网上查了下确实也有X1X的升级文件,于是就按照教程把硬盘拆下来。此处省略1000字的吐槽,X1X拆硬盘的难度比PS4高很多,主要是需要把机器翻个底朝天才能拿到最里面的盘。这时候我就有了不详的预感,果然装好SSD之后,升级文件点了就报错E101。

接下来上网翻帖子,才发现X1X的硬盘更换是很麻烦的。坑非常多,但好处是可以直接硬盘对拷,不用像PS4一样备份后再导入。前提是知道怎么操作,网上专门搞这个的家伙是个废话特别多的主儿,youtube视频又臭又长,平均40分钟起。

其实说白了就是:X1X硬盘只有500G/1T/2T三种标准容量。根据当前你机器的硬盘型号,如果直接换同样大小的硬盘,1:1对拷之后就可以解决问题。否则就需要分成两种情况:

1、新的硬盘也是标准容量的情况下,如果不想保留旧数据,插上去直接用离线升级文件OSU1就可以安装系统,不需要分区,新硬盘直接可装。

2、如果要保留旧数据,或者新硬盘不是标准容量(例如我的情况256G,或者1.5T什么的),不能直接对拷也不能使用OSU1离线安装系统。

在第二种情况中,需要手动把硬盘分区。按照X1X的规格,硬盘需要四个NTFS分区。其中3个是固定大小的系统分区,1个是用户文件分区,可以根据自己的硬盘容量把剩余的空间都分进去。分区之后,就可以直接把旧硬盘对应分区的数据,拷贝到新硬盘。

最重要的一步,也是最后一步,要把这些分区的GUID修改成指定的值。具体修改成哪个GUID,是按照机器旧的硬盘的容量来的。500G/1T/2T各有一套GUID,如果GUID不对硬盘放进去也启动不了。虽然可以用OSU1升级,但是校验之后就会报E101的错误。

所以我的情况,是1T的X1X,更换为256G的SSD。需要确认旧的硬盘可以正常进入系统,然后断电关机,把新的SSD分区,再拷贝数据,再修改为1T对应的那套GUID。这样的顺序操作才能让新的SSD直接在X1X中启动。

分区和GUID的修改可以用任何软件进行,拷贝也可以直接用文件管理器。但是要操作很多步骤,手动做很麻烦。网上那位XFiX小哥已经做好了一套脚本,本质上是一堆分区工具+Bat批处理/shell脚本,可以在windows或者linux里,直接自动分区和修改GUID。在Windows上操作唯一要注意的就是,不能同时挂载具有相同GUID的硬盘。新旧硬盘需要修改为相同的GUID,所以要先拷贝数据,拔下旧硬盘之后再修改GUID。

脚本可以在这里下载:https://gbatemp.net/download/xbox-one-windows-and-linux-internal-hard-drive-partitioning-script.34239/

有兴趣研究的也可以看小哥充满各种废话的教程视频:https://www.youtube.com/watch?v=gPJ-sBAWeL0

当然,吐槽归吐槽,前人栽树后人乘凉,小哥的脚本还是很好用的。要怪就怪X1X的硬盘升级步骤太繁琐了。毕竟人家是安全系数极高的console,从来没有被攻破,可不是得高点事情嘛~

最后吐槽:X1X不能安装大于2T的硬盘,装上去也用不了,可能是里面虚拟内核或者加密问题?还有就是离线安装文件居然叫OSU1,而且还存在OSU2和OSU3,但是OSU1才是最新的升级包,不知道起名字的人是什么脑回路,可能是给X1X和XSX以及XSS起名的那位大神……