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

Unity的“新”feature

加引号是因为我从2018升级,很多feature都出来好久了,只是对我来说“新”一些。而且很多features其他引擎早就有了,在Unity的范畴内算是“新”的。

以下记录一些自己觉得有用的新玩意儿。

Unity 2019:

GC终于会分散在不同的frame里面处理了。https://blogs.unity3d.com/2018/11/26/feature-preview-incremental-garbage-collection/

使用[GenerateAuthoringComponent]自动生成对应的property代码,更好的支持面向数据的程序设计。

序列化终于支持多态,可以正确的处理null和子类。需要显示标记[SerializeReference]。

可以配置进入Playe Mode不需要重新加载代码。

Unity 2020:

独立的Profiler,可以通过Flow Event分析jobs,避免Profiler自身影响分析结果。

显示使用debug和play两种运行模式。

通过-deterministic,避免重复编译没有变化的代码(以前居然不是这样?)

更加稳定的Time.deltaTime(需要DX11+)

导入出现错误,可以先在安全模式中修正代码然后再继续导入。

Unity 2021:

号称production-ready的整套2d-pipeline,目测加了骨骼/光照/特效/复杂地形?

很多profiler的改进,看来是考虑到许多Unity游戏性能惨不忍睹。

支持了Incremental build分布式编译,真的有需要这么夸张吗?

支持IL2CPP生成更小的版本,但是性能可能受损,不知道具体差异有多大。

Addressables的接口终于有同步加载的API了(以前居然没有)

记录了一些我个人比较关注的新改进。当然,新版本肯定有新Bug,这个是逃不了了。总体来看这么多年过去还是有很多值得一试的东西,升还是得升级的,2018实在太老了。

Unity的美好与绝望

先说好的吧。可能目前最令我满意的就是IMGUI这部分API的定义,写Editor Plugin和In-game Debug Menu爽到飞起,很快就能出效果。

UI一向是很麻烦的东西,要保存状态,还要异步,还要同步View和Model的数据。很多框架动不动就是MVC,各种模式,用起来都痛苦的一比。这个IMGUI非常简单粗暴,反正也不能用来开发更复杂的玩意儿,设计和使用契合度非常高。

目前用到的其他特色的设计,比如Component Based,和别的商业引擎比起来大同小异。当然从价格和可用性的角度,Unity更胜一筹,以后有机会再多扯点。

主要还是吐槽,就说点看似美好其实比较坑的东西吧。

官方文档

全还是比较全的,这个我主要吐槽两点。

第一是组织方式,有些很重要的东西(比如对象的生命周期),或者说Unity和其他引擎比很不一样的东西(例如特殊目录,Editor/Resources/),隐藏在犄角旮旯等着你来发现。基本上都是在别处找到更直接的教程,然后拿到关键字,再到官方来看细节。

第二是官方真喜欢视频教程啊,不能搜还看得慢,动不动20分钟说点屁事。不能写点图文教程吗?比如IMGUI的各种用法,第三方写的比官方好用多了。

资源加载

三种方式,内在逻辑截然不同。

以Editor内操作为主,基于Reference的这套可能比较适合小游戏,或者非程序员开发的游戏。只要一切资源都是经过Editor操作和绑定的,那么后续过程也都是自动的。官方强力推荐这种,美术和策划友好的东西。

所有不能在Editor上绑定的数据,都需要Load-on-demand。一但牵扯到Load-on-demand,需要动态加载数据,事情就来了。

其实加载资源(Loading)是非常简单粗暴的操作。储存方面就是打个包,把数据和标识存起来。读取方面,只要给定一个标识(比如路径或者id),然后决定是同步加载还是异步即可。同步立刻返回结果,异步就给个handle或者允许callback。这样的底层设计几乎可以满足所有基本需求,其他花哨的玩意儿再上面继续包装即可。

但Unity的坑无处不在。首先是打包,Unity有两套截然不同的资源管理方式,分别对应Resources目录下的文件和Asset Bundle,姑且称为R和A。

R的打包是自动的,所有此目录下的文件都会在运行时打包,方便的很。这么好的东西,官方给出的Best Practice居然是“别用”。写文档的朋友,你以为自己很幽默吗?不能用的东西做出来吃屎呢……

A的打包是手动的,只不过没有现成UI可用,需要在Editor里调用API。但官方文档写的稀烂,打包和加载的过程同DLC的资料搅合在一起,还要牵扯到Cache和Server。乍一看仿佛要启动什么web service做host才能使用,打开还得从WWW下载。其实只要StreamingAssets目录一放,自己写脚本打包就好。

别忙,坑还没完。

R打包出来的资源是不带扩展名的,因为自动打包的时候就是根据扩展名来的,打好就全给删了。加载的时候遇到重名,请提供type。再出现重复我就不知道要怎么玩了……R的路径是相对于Resources的,这个倒还能理解。

A打包出来的资源,标识是写入的时候给定的数据,如果写的时候带了扩展,那就是有扩展名,目测不能重复。

所有“测试的时候想用R,发布的时候想用A”的同学,请自己写wrapper吧……真的好痛苦好痛苦。实际上更好的做法是,测试的时候直接读取Editor里面的AssetDatabase,并不需要使用Resources。官方说的“别用”就是字面意思,但您能提示下还有别的选择吗?直接介绍大家用AssetsBundle真的很不友好啊。

序列化和热更新 Hot-swapping

Unity一大特色就是各种自动,写个public直接就能在Editor上改了。而且改改c#,一回头自动把新的dll热更新上去,数据都还在。

听着很美好,实际情况是,改完代码出现大量Null Reference的异常……

https://gist.github.com/cobbpg/a74c8a5359554eb3daa5

这个小哥大概记录了一下,我踩了半天坑再补充一点:

1、首先是ISerializationCallbackReceiver,调用时机很诡异,Editor下如果你选中了带有这个接口的对象,Inspector会疯狂触发OnBeforeSerialize()。存起来很慢的东西就悲剧了。而OnAfterDeserialize()在Swap的时候会触发多次,目测Editor有创建多个实例,有些数据是没准备好的,还得自助检查。

2、在Editor里创建的各种引用,例如Component都会存下来。但如果动态AddComponent出来的玩意儿就悲剧了,在OnBeforeSerialize()之前就会被干掉,请自己重新动态创建。后来证明只要是Unity的Component,引用都会正确的保存下来,核心的混乱点还是Editor里有多个实例,初始化状态各不相同。

3、虽说需要动态创建,但特么的所有AddComponent这类方法,甚至GetTime都不允许在callback种调用。大概恢复数据的时候是多线程环境?所以只能自己搞个boolean做dirty check,然后在Update()里面实际恢复数据。实际上参考官方的例子,可以在OnEnable里面做恢复处理,比Update要省。

4、虽然官方号称大部分数据类型都可以自动保存,但“大部分”这种说法就意味着你需要被干很多次才能修复“少部分”的问题。比如标记为Serializable的数据,运行时可以为null,但Swap的时候会变成non-null状态。例如字符串变成了””,结构体变成了初始状态,最坑的是Array,里面的null元素会被填充。如果有逻辑依赖于null-check就悲剧了。

以后遇到新的再补充。

补充个新坑:打包的时候如果用BuildAssetBundleOptions.None这个option,看似人畜无害的默认选项居然是用LZMA压缩整个包,第一次开启会做解压缩操作,慢的一笔。最好的方式是使用ChunkBasedCompression,这个选项开包很快。港真我不知道None这个选项除了给网络下载几件衣服的DLC用以外还有什么应用场景,官方文档用这个名字(None是压缩的,咱还提供UncompressedAssetBundle选项哦~),并且用在代码范例真的好么?