0%

UGUI性能分析工具

有很多用于分析UGUI性能的工具。一些关键的工具是:

1.Unity Profiler
2.Unity Frame Debugger
3.XCode的Instrument或者Intel的VTune
4.XCode的Frame Debugger或者Intel GPA
外部工具提供了对CPU毫秒级(或更高精度的)性能分析方法,同时对shader和drawcall详细分析。对上述工具设置和使用的说明超出了本指南的范围。请注意,XCode Frame Debugger和Instrument仅适用于Apple平台的IL2CPP版本,因此目前只能用于iOS平台的性能分析。

Unity Profiler

  Unity Profiler的主要用途是执行性能比较分析:当Unity Profiler运行的时候进行enabling和disabling的操作,它可以迅速的缩小定位到性能问题最大的UI层级。
  要分析这个,请查看profiler输出结果中的“Canvas.BuildBatch”和“Canvas.SendWillRenderCanvases”。
  如上文所述,Canvas.BuildBatch是执行Canvas的Batch build过程的底层代码计算量。
  Canvas.SendWillRenderCanvases包含了C#脚本对Canvas组件的willRenderCanvases事件的订阅的调用。UGUI的CanvasUpdateRegistry类接收这个事件并且通过它来执行前文所描述的rebuild过程。预计所有被标dirty的UI组件都会在这个时候更新他们的Canvas Renderer。

1
2
注意:为了更容易地看到UI性能的差异,通常建议禁用除了Rendering和Scripts以外所有trace category。这可以通过
点击CPU Usage profiler左侧的名叫trace category旁边的彩色方块来完成。

还要注意,category可以在CPU profiler中重新排列,可以点击或者拖拽category向上或者向下来对他们进行重新排列。

Unity Frame Debugger

  Unity Frame Debugger是一个减少UGUI的draw call的实用工具。这个内置的工具可以通过Unity Editor中的Window菜单来访问。当它运行的时候,它将显示包括UGUI在内的所有Unity产生的draw call。
特别要注意的是,Unity Frame Debugger在Unity Editor界面就可以更新游戏视口产生的draw call信息,因此可以用来尝试不同的UI配置而无需进入游戏模式。
UGUI的drawcall产生的位置取决于Canvas组件上被设置的渲染模式:

1.Screen Space – Overlay将出现在Canvas.RenderOverlays组中。
2.Screen Space – Camera将出现在Render.TransparentGeometry子项,所选渲染相机的Camera.Render组中。
3.World Space将出现在Render.TransparentGeometry子项,每个可以看见Canvas的World Space的摄像机中。

如果UI的shader没有被自定义的shader替换的话,那么所有UI都可以被 “Shader: UI/Default”识别,列出在哪个组和drawcall的细节。在下图中请看高亮红框标注的地方。(图片见原网页)
在调整UI的时候观察Unity Frame Debugger所显示的信息,这就相对比较简单的使Canvas中的UI元素最优的合成batch。最常见的与设计相关的打断批次的原因是UI元素间不小心造成的重叠。
所有的UGUI组件将它们的几何体生成成一系列的 quad。然而,很多sprite和text只占用用于显示它们的 quad的一小部分,留下了大量的剩余空间。这样的结果就是,UI开发者无意中使多个不同的 quad互相覆盖,它们的texture来自不同的material,不能合成batch。
由于UGUI的操作完全在透明队列中,任何有不能合batch的quad在它上边的quad必须在不能合batch的quad之前绘制,因此它不能与放在不能合batch的quad上的quad合batch。(翻译这段我尽力了,但是估计还是不清楚。我讲一下大意:就是两个能合batch的quad中间夹了一个不能合batch的quad,造成这两个quad也不能合batch了)
  考虑一个情景,有三个quadA、B、C。假设这三个quad彼此覆盖,并且A和C使用了相同的Material,B使用了单独的Material。B不能和A、C合成batch。
如果在层级结构中从上到下的是A、B、C,那么A、C也不能合batch,因为B必须绘制在A的上面,C的下面。然而,如果B被放在可被合batch的quad前面或者后面,那么可以被合batch的quad就能构成batch。B只需要在batch的quad之前或者之后绘制,而不会介入其中。
关于这个问题更深入的探讨,请看Canvas章节的Child order部分。

Instruments & VTune

  XCode的Instruments和Intel的VTune各自可以非常深入的分析UGUI的rebuild和Canvas的batch计算在Apple设备和Intel CPU上的性能。方法名称几乎和我们之前介绍过的Unity Profiler的标签完全相同。它们是:
Canvas::SendWillRenderCanvases是一个C++父类调用C#中的Canvas.SendWillRenderCanvases方法,并控制 Unity Profiler中该行显示。它包含了用于进行rebuild过程的代码,这已经在上一章节详细介绍了。
Canvas::UpdateBatches几乎和Canvas.BuildBatch完全相同,但是增加了Unity Profiler页面并不包括的代码引用。它运行上文描述的Canvas的batch建立的实际过程。
  当通过IL2CPP构建一个Unity APP时,这些工具可以被用于更深入的查看C#中Canvas::SendWillRenderCanvases的编译。(注意:编译的方法的名字是近似的。)
IndexedSet_Sort和CanvasUpdateRegistry_SortLayoutList是用于排序显示在标为dirty的Layout组件被重新计算之前的一个列表。如上文所述,这包括了计算每个Layout组件的父transform数量。
ClipperRegistry.Cull调用所有IClipRegion接口注册的实现者。内置的实现者包括使用IClipRegion接口的RectMask2D组件。当ClipperRegistry.Cull被调用时,RectMask2D组件将遍历在它层级下的所有要被裁剪的UI元素,更新他们的剔除信息。
所有可嵌套元素,并要求它们更新其剔除信息。
Graphic_Rebuild包含所有要显示的Image,Text或其他Graphic派生的组件所需要的网格的实际计算性能开销。在这之下有其他一些方法,如Graphic_UpdateGeometry,最值得注意的是Text_OnPopulateMesh。
-当Best Fit勾选时,Text_OnPopulateMesh通常是一个热点。这将在本指南后面详细讨论。
-网格修饰符,比如Shadow_ModifyMesh和Outline_ModifyMesh也在这里运行。通过这些方法可以看到shadow, outline和其他特殊效果组件的计算性能开销。

Xcode Frame Debugger和Intel GPA

  底层的Frame Debugger对监测UI不同独立部分的batch性能开销和UI过度绘制开销非常重要。在后面章节我们将详细的对UI过度绘制进行讨论。
  Xcode Frame Debugger的使用
  为了测试一个给定的UI是否过度榨取GPU资源,可以使用Xcode内置的GPU诊断工具。首先将项目配置为使用Metal或OpenGLES3,然后进行构建并打开生成的Xcode项目工程。如果Unity在OpenGLES 2下运行,则Xcode不能对Unity进行分析,因此这些技术不能用于较旧的设备。
注意:在某些版本的Xcode中,为了使图形分析器工作,有必要在Build Scheme中选择适当的Graphics API。为此,请转到Xcode中的Product菜单,展开Scheme菜单项,然后选择Edit Scheme ….选择Run target并转到Options页面。更改GPU Frame Capture选项来使API适配您的工程。假设Unity工程设置了自动选择图形API,则大多数新一代的iPad将默认选择Metal。如果有疑问,请启动项目并查看Xcode中的调试日志,前面的几行应该会指出哪个渲染路径(Metal,GLES3或GLES2)正在被初始化。
注意:上述调整在Xcode 7.4中应该不是必需的,但在Xcode 7.3.1和更旧的版本中仍然偶尔会被发现是必须的。
在iOS设备上构建并运行项目。GPU profiler显示在Xcode的Navigator边栏中,点击FPS条目。(图请参见原网页)
GPU分析器中第一个重要的是屏幕中的三个条目:“Tiler”、“Renderer”、“Device”。这些表示:
“Tiler”是对GPU生成几何体(包括在顶点着色器中的花费时间)过程中压力的衡量。
——一般来讲,“Tiler”值高表明顶点着色器计算过慢或者是绘制的顶点过多。
“Renderer”是对GPU的像素流水线压力的衡量。
——一般来讲,“Renderer”值高表明应用程序超过了GPU的最大填充率,或是片段着色器效率低下。
“Device” 是GPU使用的综合衡量标准,包括“Tiler”和“Renderer”的性能分析。它通常可以被忽略,因为它大体上跟踪监测“Tiler”和“Renderer”的较高者。
有关Xcode GPU Profiler的更多信息,请参阅此文档(链接见原网页)。
Xcode’s Frame Debugger可以通过点击隐藏在GPU Profiler底部的小“相机”图标来打开。在下面的屏幕截图中,通过箭头和红色框突出显示。(截图见原网页)
暂停一下之后,Frame Debugger的摘要视图就会出现,如下所示(截图见原网页):
在使用默认UI着色器时,假设默认UI着色器没有被自定义着色器替换,那么由UGUI系统生成的渲染几何图形的开销将显示在“UI / Default”着色器通道下。在上面的截图中可以看到这个渲染管线的默认的UI着色器是“UI / Default”。
UGUI只产生quad,所以顶点着色器不太可能给GPU Tiler流水线产生压力。出现在这个着色器中的任何问题都应归结于填充率问题。

分析分析器结果

在收集分析数据之后,可以得出几个结论:
  如果Canvas.BuildBatch或Canvas :: UpdateBatches占用了过多的CPU时间,则可能的问题是单个Canvas上的Canvas Renderer组件数量过多。请参阅“Canvas”一章的“Splitting Canvases”章节。
如果GPU过度的时间花费在绘制UI上,并且frame debugger表明片段着色器流水线是瓶颈,那么应该是UI的像素填充率超过了GPU的能力,最可能的原因是UI的过渡绘制。请参考Fill-rate, Canvases and input章节的Remediating fill-rate issues部分。
如果Graphic的rebuild占用了过多的CPU,如在Canvas.SendWillRenderCanvases或者Canvas::SendWillRenderCanvases中看到了大量的CPU时间占用,那么就需要进行深层分析,应该与Graphic的rebuil过程中的一些部分有关。
如果是大量的WillRenderCanvas花费在IndexedSet_Sort或是CanvasUpdateRegistry_SortLayoutList上,时间花费在对dirty的layout组件列表进行排序,那么就要考虑减少Canvas中的Layout组件数量。请在Replacing layouts with RectTransforms和Splitting Canvases部分中也许会找到补救措施。
如果过多的时间花在Text_OnPopulateMesh上,那么Text网格的生成就是罪魁祸首。请参阅Best Fit和 Disabling Canvas Renderers部分,也许会找到补救措施。并考虑Splitting Canvases中的建议,如果正在重建的大部分文本实际上并未更改其基础字符串数据,text大量rebuild实际上并没有改变其基础的字符串数据。
如果时间花在内置的Shadow_ModifyMesh或Outline_ModifyMesh(或任何其他使用的ModifyMesh),则问题在于花费在计算修饰性网格过多的时间。考虑删除这些组件,并通过静态图像实现其视觉效果。
如果Canvas.SendWillRenderCanvas中没有特定的热点,或者它看起来每帧都在运行,那么问题可能是动态元素与静态元素混合在一起,致使整个Canvas过于频繁地重建。参见Splitting Canvases部分。

UGUI概要

  理解UGUI系统不同部分的组成非常重要。整个系统是由一些基础类和组件共同构成。本章首先定义了一些贯穿整个系列文章的专业术语,接着讨论了UGUI关键系统的底层行为。

术语

  Canvas,是Unity渲染系统给层状几何体提供的可以被画入、被放在上面或者放在世界空间的底层Unity组件。
  Canvas负责将它包含的几何体组合成batch,生成合适的渲染命令发送给Unity图形系统。这个过程在底层的C++代码中完成,这个过程被称为一次rebatch或者一次batch build。当一个Canvas被标记为包含需要rebatch的几何体时,这个Canvas被认为是dirty的。
  几何体由Canvas Renderer组件提供给Canvas。
  一个子Canvas仅仅是一个嵌套在父Canvas中的组件,子Canvas将它的子物体和它的父Canvas隔离,一个子Canvas下dirty的子物体不会触发父Canvas的rebuild,反之亦然。(这些在某些特殊情况下是不确定的,比如说改变父Canvas的大小导致子Canvas的大小改变。)
  Graphic是UGUI的C#库提供的一个基类。它是UGUI所有类的基类,给所有的UGUI类提供可以画在Canvas系统上的几何图形。大多数Unity内置的继承Graphic的类都是通过继承一个叫MaskableGraphic的子类来实现,这使得他们可以通过IMaskable接口来被隐藏。Drawable类的子类主要是image和text,已经提供了同名的组件。
  Layout组件控制着RectTransform的大小和位置,经常被用于要生成具有相似的大小和位置关系内容的复杂布局。它只依靠RectTransform,只影响与其相关的RectTransform的属性。这些layout组件不依赖于Graphic类,可以独立于UGUI的Graphic组件之外使用。
  Graphic和Layout组件都依赖于CanvasUpdateRegistry类,它不会在Unity编辑器的界面中显示。这个类追踪那些Graphic和Layout组件必须被更新的时候,还有与其对应的Canvas触发了willRenderCanvases事件的时候。
  更新Graphic类和Layout类叫做rebuild。rebuild的过程将在本文后续详细讨论。

渲染细节

  在使用UGUI制作UI时,请牢记Canvas中所有几何体的绘制都在一个透明队列中,这就意味着由UGUI制作的几何体将从始至终伴随着alpha混合,所以从多边形栅格化的每个像素都将被采样,即使它被完全不透明的物体所覆盖。在手机设备上,这种高等级的过度绘制将迅速超过GPU填充频率的承受能力。在移动设备上,这种高级别的过度绘制将迅速超过GPU填充频率的承受能力。

Batch构建过程(Canvas)

  Batch构建过程是指Canvas通过结合网格绘制它所承载的UI元素,生成适当的渲染命令发送给Unity图形流水线。Batch的结果被缓存复用,直到这个Canvas被标为dirty,当Canvas中某一个构成的网格改变的时候就会标记为dirty。
  Canvas的网格从那些Canvas下的CnavasRenderer组件中获取,但不包含任何子Canvas。
  计算Batch要求按照深度排序网格,测试它们是否有重叠,共享材质等等。这个过程是多线程的,在不同的CPU架构下性能表现非常不同,特别是在手机芯片(通常CPU核心很少)和现代桌面CPU(通常拥有四核心或者更多)之间非常不同。

Rebuild过程(Graphics)

  Rebuild过程是指Layout和UGUI的C#的Graphic组件的网格被重新计算,这是在CanvasUpdateRegistry类中执行的。请记住,这是一个C#类,它的源码可以在Unity的Bitbucket上找到。
  在CanvasUpdateRegistry类中,最有意思的就是PerformUpdate方法,当一个Canvas组件触发它的WillRenderCanvases事件时,这个方法就会被执行。这个事件每帧调用一次。
PerformUpdate函数运行的三个步骤:
1.通过ICanvasElement.Rebuild函数,请求rebuild被Dirty的Layout组件。
2.所有被注册的裁剪组件(例如Mask),对需要被裁剪的组件进行剔除。这在ClippingRegistry.Cull中执行。
3.dirty的Graphic组件被要求rebuild其图形元素。
对于Layout和Graphic的rebuild,过程被分为多个部分。Layout的rebuild有三个部分(PreLayout, Layout and PostLayout),而Graphic的rebuild有两个部分(PreRender和LatePreRender)。

Layout的rebuild

  要重新计算一个或者多个Layout组件所包含的UI组件的适当位置(以及可能的大小),有必要对Layout应用层次的排序。在GameObject的hierarchy中靠近root的Layout可能会影响改变嵌套在它里面的其他Layout的位置和大小,所以必须首先计算。   为此,UGUI根据层次结构中的深度对dirty的Layout组件列表进行排序。层次结构中较高的Layout(即拥有较少的父transform)将被移到列表的前面。
然后,排序好的Layout组件的列表将被rebuild,在这个步骤Layout组件控制的UI元素的位置和大小将被实际改变。关于独立的UI元素如何受Layout组件影响的详细细节,请参阅Unity Manual的UI Auto Layout章节。

Graphic的rebuild

  当Graphic组件被rebuild的时候,UGUI将控制传递给ICanvasElement接口的Rebuild方法。Graphic执行了这一步,并在rebuild过程中的PreRender阶段运行了两个不同的rebuild步骤:
1.如果顶点数据已经被标为Dirty(例如组件的RectTransform已经改变大小),则重建网格。
2.如果材质数据已经被标为Dirty(例如组件的material或者texture已经被改变),则关联的Canvas Renderer的材质将被更新。
Graphic的Rebuild不会按照Graphic组件的特殊顺序进行,也不会进行任何的排序操作。

  优化UGUI是一门艺术。硬性规定非常少,但是每种情况都必须参照系统的运行原理仔细评估。UGUI的优化核心是drawcalls和batching花费的平衡。尽管运用一些常识技术可以减少前者或者后者,但是一些复杂的UI必须要做权衡。

  诚然,正如其他地方的做法一样,UGUI的优化常识应该从性能分析开始。在开始尝试优化UGUI系统之前最主要的工作就是准确定位性能问题产生的原因。
UGUI的开发者经常遇到四类问题:

1.GPU片段着色器利用率过高(也就是填充率过渡使用)。
2.CPU时间过长的花费于rebuild一个Canvas的batch上。
3.过多的Canvas的batch数量引发过多的rebuild(dirty太多)。
4.过多的CPU时间花费在建立顶点上(通常来自文本)。

  理论上,可以创建一个性能完全受到发送到GPU的drawcall数量限制的UGUI,然而实际上任何GPU因为drawcall过载的项目更倾向于被填充率过度使用限制。
  本指南将讨论UGUI底层的概念、算法和代码,以及常见问题和解决方法。总共分为五章:
1.”UGUI基础理论章节”定义了UGUI的特定术语和执行渲染UI的基础过程,包括对batch的几何体的build。强烈建议读者从本章开始。
2.”UGUI的分析工具章节”,探讨通过开发者可以使用的多种工具收集性能信息。
3.”填充率、Canvas和输入章节”讨论了改善UGUI的Canvas和输入组件性能的方法。
4.”UI控制章节”讨论了Text、ScrollView及其他特殊组件的优化,连同一些不适合放在其他地方的技术。
5.”其他技术和提示章节”讨论了一些不适合放在其他地方的问题,包括一些对UI系统中”陷阱”的提示和解决方法。

lua里面实现class机制,比较关键的地方就是class的继承机制,以及class实例化的过程。

class继承机制的关键是怎么让子类拥有父类的方法集:

1
2
3
4
5
6
7
8
9
10
11
12
1. 完全使用setmetatable()的方式实现,每继承一次,都把父类的方法集设置为子类方法集的metatable。这样做应该
最符合class的继承的思想,尽可能的复用逻辑,而且可以做到动态更新父类的方法集后,所有子类都会更新自身方法集。
比如满足某些特殊的动态更新需求。但随着class继承的层次加深,会生成一个复杂的class方法集table(n层的
metatable),毕竟metatable是有额外开销的,所以这个方法不一定是最完美的方案。
2. 将父类方法集copy到子类方法集来实现继承,即每当定义一个新的子类时,都将父类中方法集完整copy到子类方法集
中。这种方法避免了使用metatable()带来的额外开销,但却造成了一些数据冗余(其实并不多),并丧失了父类更新子类
也会自动更新的特性。
3. 方案2的改进版本(也就是云风使用的比较强悍的方式):即同样是采用copy父类方法集的方案,但却改进了copy的机制。
将原本在class定义期执行的方法集copy工作转移到实例的运行期间,采用copy-on-use(等同于copy-on-write的设计
思路)的方式,在子类实例用到父类的某个方法时,才将其copy到子类的方法集中。由于这种copy只发生一次,而且不见
得子类会用到父类方法集中的所有内容(事实如此),所以这个方案相对于方案2来说减少了冗余数据,但却几乎没有增加
任何额外开销。

class实例化关键是实例如何享有class的方法集:

1
2
3
4
5
6
7
8
1. 最烂的方式,方法集copy,即class实例化时,将class的方法集直接copy给实例的数据table。这样的好处就是每个
实例创建后,外界除非直接操作实例的数据table,否则其它行为都不会影响到这个实例的所有属性和特征(也许可以满
足某些特殊的需求吧),同时也省掉了一次metatable的查找开销。缺点很明显,实例化过程低效,而且产生大量的冗余信
息(或者这里也采用copy-on-use的思想)?
2. 采用将class方法集设置为实例的metatable的方式,使实例享有class的方法集(这要求实例的数据类型必须可以拥
有自己的metatable,即只能是table或userdata)。这个方案更优雅一些,而且符合class的共享思想并且实例化开销
很小,应该是实现实例化的最佳方案了。在实现子类的初始化函数时,一般的思路都是先生成一个父类的实例,再强制将
当前子类的方法集设置为这个实例的metatable。

云风的实现:

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
30
31
32
33
34
35
36
37
38
39
40
local _class={}
function class(super)
local class_type={}
class_type.ctor=false
class_type.super=super
class_type.new=function(...)
local obj={}
do
local create
create = function(c,...)
if c.super then
create(c.super,...)
end
if c.ctor then
c.ctor(obj,...)
end
end
create(class_type,...)
end
setmetatable(obj,{ __index=_class[class_type] })
return obj
end
local vtbl={}
_class[class_type]=vtbl
setmetatable(class_type,{__newindex=
function(t,k,v)
vtbl[k]=v
end
})
if super then
setmetatable(vtbl,{__index=
function(t,k)
local ret=_class[super][k]
vtbl[k]=ret
return ret
end
})
end
return class_type
end

使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
base_type=class()		-- 定义一个基类 base_type

function base_type:ctor(x) -- 定义 base_type 的构造函数
print("base_type ctor")
self.x=x
end

function base_type:print_x() -- 定义一个成员函数 base_type:print_x
print(self.x)
end

function base_type:hello() -- 定义另一个成员函数 base_type:hello
print("hello base_type")
end

以上是基本的 class 定义的语法,完全兼容 lua 的编程习惯。增加了一个叫做 ctor 的词,作为构造函数的名字。下面是继承:

1
2
3
4
5
6
7
8
9
test=class(base_type)	-- 定义一个类 test 继承于 base_type

function test:ctor() -- 定义 test 的构造函数
print("test ctor")
end

function test:hello() -- 重载 base_type:hello 为 test:hello
print("hello test")
end

测试样例:

1
2
3
a=test.new(1)	-- 输出两行,base_type ctor 和 test ctor 。这个对象被正确的构造了。
a:print_x() -- 输出 1 ,这个是基类 base_type 中的成员函数。
a:hello() -- 输出 hello test ,这个函数被重载了。

项目中的实现:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
function class(classname, super)
--superType获取父类类型,可以使nil、function以及table.
local superType = type(super)
local cls

--如果父类既不是函数也不是table则说明父类为空
if superType ~= "function" and superType ~= "table" then
superType = nil
super = nil
end
--如果父类的类型是函数或者是C对象
if superType == "function" or (super and super.__ctype == 1) then
-- inherited from native C++ Object
cls = {}
--如果父类是表则复制成员并且设置这个类的继承信息
if superType == "table" then
-- copy fields from super
for k,v in pairs(super) do cls[k] = v end
cls.__create = super.__create
cls.super = super
else
cls.__create = super
cls.ctor = function() end
end
--设置类型的名称
cls.__cname = classname
cls.__ctype = 1

--定义该类型的创建实例的函数为基类的构造函数后复制到子类实例
--并且调用子数的ctor方法
function cls.new(...)
local instance = cls.__create(...)
-- copy fields from class to native object
for k,v in pairs(cls) do instance[k] = v end
instance.class = cls
instance:ctor(...)
return instance
end

else
--如果是继承自普通的lua表,则设置一下原型,并且构造实例后也会调用ctor方法
-- inherited from Lua Object
if super then
cls = {}
setmetatable(cls, {__index = super})
cls.super = super
else
cls = {ctor = function() end}
end

cls.__cname = classname
cls.__ctype = 2 -- lua
cls.__index = cls

function cls.new(...)
local instance = setmetatable({}, cls)
instance.class = cls
instance:ctor(...)
return instance
end
end

return cls
end

在Lua中,分为以下几种数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
(lua.h)  
72 #define LUA_TNONE (-1)
73
74 #define LUA_TNIL 0
75 #define LUA_TBOOLEAN 1
76 #define LUA_TLIGHTUSERDATA 2
77 #define LUA_TNUMBER 3
78 #define LUA_TSTRING 4
79 #define LUA_TTABLE 5
80 #define LUA_TFUNCTION 6
81 #define LUA_TUSERDATA 7
82 #define LUA_TTHREAD 8

其中的LUA_TLIGHTUSERDATA和LUA_TUSERDATA一样,对应的都是void*指针,区别在于,LUA_TLIGHTUSERDATA的分配释放是由Lua外部的使用者来完成,而LUA_TUSERDATA则是通过Lua内部来完成的,换言之,前者不需要Lua去关心它的生存期,由使用者自己去关注,后者则反之.
可以看到,LUA_TSTRING(包括LUA_TSTRING)之后的数据类型,都需要进行gc操作.
那么,对于这些需要进行gc操作的数据类型,在Lua中是如何表示的呢?
这些需要gc的数据类型,都会有一个CommonHeader的成员,并且这个成员在结构体定义的最开始部分,如:

1
2
3
4
5
6
7
8
9
10
11
12
(lobject.h)
338 typedef struct Table {
339 CommonHeader;
340 lu_byte flags; /* 1<<p means tagmethod(p) is not present */
341 lu_byte lsizenode; /* log2 of size of `node' array */
342 struct Table *metatable;
343 TValue *array; /* array part */
344 Node *node;
345 Node *lastfree; /* any free position is before this position */
346 GCObject *gclist;
347 int sizearray; /* size of `array' array */
348 } Table;

其中CommonHeader的定义如下:

1
2
3
4
5
6
(lobject.h)
39 /*
40 ** Common Header for all collectable objects (in macro form, to be
41 ** included in other objects)
42 */
43 #define CommonHeader GCObject *next; lu_byte tt; lu_byte marked

同时,还有一个名为GCheader的结构体,其中的成员只有CommonHeader:

1
2
3
4
5
6
7
(lobject.h)
46 /*
47 ** Common header in struct form
48 */
49 typedef struct GCheader {
50 CommonHeader;
51 } GCheader;

于是,在Lua中就使用了一个GCObject的union将所有可gc类型囊括了进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(lstate.h)
133 /*
134 ** Union of all collectable objects
135 */
136 union GCObject {
137 GCheader gch;
138 union TString ts;
139 union Udata u;
140 union Closure cl;
141 struct Table h;
142 struct Proto p;
143 struct UpVal uv;
144 struct lua_State th; /* thread */
145 };

我们整理一下前面提到的这么几个结构体,可以得到这样的结论:

1
2
3
4
5
1.任何需要gc的Lua数据类型,必然以CommonHeader做为该结构体定义的最开始部分.如果熟悉C++类实现原理的人,可以将
CommonHeader这个成员理解为一个基类的所有成员,而其他需要gc的数据类型均从这个基类中继承下来,所以它们的结构体
定义开始部分都是这个成员.
2.GCObject这个union,将所有需要gc的数据类型全部囊括其中,这样在定位和查找不同类型的数据时就来的方便多了,而如
果只想要它们的GC部分,可以通过GCheader gch,如:
1
2
(lobject.h)
91 #define gcvalue(o) check_exp(iscollectable(o), (o)->value.gc)

仅表示了需要gc的数据类型还不够,还有几种数据类型是不需要gc的,Lua中将GCObject和它们一起放在了union Value中:

1
2
3
4
5
6
7
8
9
10
(lobject.h)
56 /*
57 ** Union of all Lua values
58 */
59 typedef union {
60 GCObject *gc;
61 void *p;
62 lua_Number n;
63 int b;
64 } Value;

到了这一步,已经差不多可以表示所有在Lua中存在的数据类型了,但是还欠缺一点东西,就是这些数据到底是什么类型的.于是Lua代码中又有了一个TValuefields将Value和类型结合在一起:

1
2
(lobject.h)
71 #define TValuefields Value value; int tt

这些合在一起,最后形成了Lua中的TValue结构体,在Lua中的任何数据都可以通过该结构体进行表示:

1
2
3
4
(lobject.h)
73 typedef struct lua_TValue {
74 TValuefields;
75 } TValue;

本文详细讲解了Unity引擎各种类型文件的信息内容、用法和使用场景,帮助研发团队对于引擎文件进行更好的理解和掌握。

一、Unity项目中Assets目录下常见的文件类型

在Unity3d中一般存在这么几种文件:

1
2
3
4
5
6
资源文件
代码文件
序列化文件
文本文档
非序列化文件
Meta文件

1、资源文件

资源文件指一些创建好并且不再修改的文件。这样的文件一般是美术设计师和音频视频设计师创造的文件,比如FBX文件、贴图文件、音频文件、视频文件和动画文件(虽然动画文件可以被认为是配置文件,不过在由于一般不会去做修改,所以也认为是资源文件)。像这类文件,Unity中都会在导入时进行转化。每一个类型都对应一个AssetImporter,比如AudioImporter、TextureImporter、ModelImport等等。在Unity中点击这样的资源,在Inspector面板会出现设置界面。

2、代码文件

代码文件包括所有的代码文件、代码库文件、Shader文件等。在导入时,Unity会进行一次编译。

3、序列化文件(数据文件)

序列化文件通常是指Unity能够序列化的文件,一般是Unity自身的一些类型,比如Prefab(预制体)、Unity3d(场景)文件、Asset(ScriptableObject)文件、Mat文件(材质球),这些文件能够在运行时直接反序列化为对应类的一个实例。

4、文本文档

文本文档比较特殊,它不是序列化文件,但是Unity可以识别为TextAsset。很像资源文件,但是又不需要资源文件那样进行设置和转化,比如txt、xml文件等等。

5、非序列化文件

非序列文件是Unity无法识别的文件,比如一个文件夹也会被认为是一个文件,但是无法识别。

6、Meta文件

Meta文件在Unity中的作用非常关键,它有2个作用:

1
2
3
4
5
定义在它同目录下,同名的非Meta文件的唯一ID:GUID。而对于Unity的序列化文件来说,引用的对象用的就是这个GUID。
所以一旦Meta中的GUID变更了,就要注意,它很可能引起一场引用丢失的灾难。

存储资源文件的ImportSetting数据。在上文中资源文件是有ImportSetting数据的,这个数据正数存储在Meta文件中。
ImportSetting中专门有存储Assetbundle相关的数据。这些数据帮助编辑器去搜集所有需要打包的文件并分门别类。所以每一次修改配置都会修改meta文件。

二、Meta文件详解——Unity GUID/LocalID系统

由于Meta文件的重要性,这里先说说Meta文件的数据结构。Meta文件实质上是一个文本文档,只是采用的是一种叫做YAML的格式来写的(见Description of the Format)。Unity中的序列化文件都是用这个格式类写的,比如Prefab、场景等(后文会继续)。

1、GUID

Guid是Meta中最最最重要的数据。这个Guid代表了这个文件,无论这个文件是什么类型(甚至是文件夹)。换句话说,通过GUID就可以找到工程中的这个文件,无论它在项目的什么位置。在编辑器中使用AssetDatabase.GUIDToAssetPath和AssetDatabase.AssetPathToGUID进行互转。

所以在每次svn提交时如果发现有Meta文件变更,一定要打开看一下。看看这个Guid是否被更改。理论上是不需要更改的。

2、ImportSetting数据

后面比较重要的数据是ImportSetting数据。根据不同的文件类型,它的数据是不同的ImportSetting数据,比如上面的NativeFormatImporter、ModelImporter、AudioImporter等等。只要对照Inspector面板中的条目,都可以看懂每一行的意义。

所以知道这个之后,我们可以发现,假如我们把一个文件和这个文件的Meta文件从一个Unity工程复制到另一个Unity工程中,它的配置是不会变的(以前在2个工程手动裁剪同一个模型的20个动画真是傻到家了,直接将这个Fbx和它的Meta文件拷贝过去就行!)。

3、FileID(LocalID)

有一个问题是,如果是一个图集,下面有若干个图片,那么,这个GUID怎么对应一个文件呢?是的,对于一个文件下有多个文件的情况,就需要另外一个ID来表示,这就是LocalID。更习惯用Meta文件中的名字FileID。FileID存储方式有2种:

对于资源文件,非序列化文件,由于一般不会去更改源文件,所以FileID存储在meta文件中。

对于序列化文件,自身数据里面会存储自身的FileID,也会记录所有子文件的FileID(更多关于序列化文件的数据,见下文)。比如对于这样的AnimatorController。它本身的数据如下,除了本身的FileID为9100000外,记录了4个AnimatorClip的FileID。而meta文件中只有自身的FileID。

回到本节一开始的问题,如果是图集,因为是图片本身是资源文件,所以会有FileID存储在对应的Meta文件中。

至此就是整个Unity的GUID/LocalID系统的基础了。通过GUID找到任何一个文件,通过FileID找到其中的某个子文件。

三、序列化文件详解——Unity文件引用系统

上文已经提到,对于所有的序列化文件,Unity采用的是YAML来书写。所以对于一个Unity3d(场景)文件、prefab文件、材质、控制器等都可以用文本文档软件打开。这里还是用Notepad++打开。

为了能够简洁地说明问题,我们在Unity中创建一个新的场景,然后创建2个Cube,一个做成Prefab。
可以看到大概的数据:

OcclusionCullingSettings裁剪数据(菜单Window->Occlusion面板中的数据)
RenderSettings(菜单Window->Lighting->Settings面板中的部分数据)
LightmapSettings(菜单Window->Lighting->Settings面板中的其他部分数据)
NavMeshSettings(菜单Window->Navigation面板中的数据)
之后就是场景中的物件的数据

1、GameObject数据

展开第一个GameObject数据,可以看到这个的Name就是Main Camera(如果你的第一个GameObject数据不是Main Camera也没关系,下面肯定某一个是Main Camera,展开那个数据也是一样的结构)。

这个物体上有4个组件,一一对应下面的数据。这就是物体内的引用关系。每一个Unity对象都会有一个FileID,然后在需要引用时,使用这些FileID即可。所以在实例化一个这样的GameObject时,只要依照次序,依次创建物体,组件,初始化数据并进行引用绑定即可在场景中生成一个实例。

我们在Inspector面板中的右上角点击,然后选择Debug转成Debug模式下的Inspector面板:

在Hierarchy面板中选中Main Camera可以看到如图所示,所有的组件的LocalIdentfierInFile的值就是刚刚在Notepad++中看到的数据:

这里有一点,我们看到有一个叫做InstanceID的数据。这个是Unity中一个实例的ID。每一个Unity实例都会有一个InstanceID。在运行时,可以使用UnityEngine.Object的GetInstanceID获取。但是要注意的是,每一次运行,相当于重新生成了新的实例,所以这个值是可变的。

2、组件数据

在GameObject之后就是这个GameObject的组件数据(不知道次序会不会乱,理论上不影响)。每一个组件的数据基本上就是这个组件的一堆参数了。可以结合Unity中这个组件的面板来了解每一个数据的意义。

这里有一个问题,比如这里有一个组件是FlareLayer,但是在YAML里面只是一个Behaviour(所有Behaviour组件都看不到类型名字),怎么样才能知道他是一个FlareLayer?

可以看到在这个数据上方,在FileID左边我们看到一个124。对,这个就是FlareLayer。请参考YAML Class ID Reference,每一个Unity类型都有一个对应的数字。

那么自定义脚本类呢?

我们创建一个Test脚本,继承MonoBehaviour。里面什么都不写。添加到Main Camera物体上。保存场景然后回到Notepad++。

可以看到多了一个MonoBehaviour,并且这个里面有一个M_Script数据,指向对应的GUID及其FileID。上文我们已经说了,任何一个文件都可以通过GUID找到,然后通过FileID找到它内部的子文件。所以这样就能识别出这个具体是什么类了。

我们往Test中写2个字段

1
2
Public int A;
Public Test RefTest;

在Main Camera中,设置Test脚本的A值为111,RefTest设置为自身。保存后回到Notepad++;
看到数据想必都明白了。

可以往Test中写一些其他类型的数据,看看这些序列化数据放在YAML的哪个位置!这里不再展开(这些数据和编辑器的SerializedProperty息息相关)。

3、Prefab数据

在YAML的最下面有一个数据是Prefab数据:

看起来很复杂,但是实际上,它就保存了最重要的几个数据:

1
2
Modification:每个组件的修改数据列表,但凡修改的数据,都会在这里体现。
ParentPrefab:表示是哪一个Prefab。

所以上面的数据就是GUID为27f1445c35c923741a22e4948c4da980的Prefab,修改后的FileID为4126423848245890的一堆数据和FileID为1854796474342856的Name数据。

通过打开我们制作的Cube的Prefab文件及其meta文件,我们可以看到,Meta文件中的GUID就是那个,而Prefab中存在4126423848245890(Transform)和1854796474342856(GameObject)
OK,至此,序列化文件的数据和引用的原理都已完毕。
More info: 相关转载

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Hello Hexo

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment