Unity可视化原理剖析---如何最有效地发挥Unity引擎的优势

发表于2015-04-29
评论1 5.6k浏览

一、引言

1.  可视化的来龙去脉:

信息时代中,可视化在很多领域引发着变革。思维导图(MindMap)的理论指出,视觉刺激能不断强化大脑中的思路,从而迸发出新的顿悟。在游戏引擎领域,最早让 “可视化/所见即所得”这些概念进入业界视野应该算是CryEngine,大约是在05,06年左右。它称之为“沙箱”的东西,就是一个随时能够启动并进行实际交互的编辑环境。如下图所示。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image001.jpg

在这数年间,伴随着游戏画面效果的不断提升,游戏资源量不断庞大起来。高效的编辑工具对资源生产效率的重要性越发突出。另一方面,游戏的交互性越来越复杂和深化。富场景,动态场景,可破坏场景等等概念纷纷冒出。游戏资源不在是单纯的模型与贴图,它们越来越多地包含了各样的交互信息和交互逻辑。制作这样的资源必然会要求在编辑器中能够即时体验交互流程。这不断刺激着可视化技术的发展,这一趋势短时间内恐怕不容易发生改变。

2.  Unity3d引擎提供优秀的可视化方案:

对于具体游戏,通常会有各样的编辑器需求。开发自定的编辑器通常是复杂而耗时的工作。现状是,中小型的项目很可能因为时间和人力因素考虑在编辑器开发上妥协,这并不利于发挥可视化的优势。笔者在《QQ乐团》项目的实践表明,Unity3d提供了很便捷而功能强大的编辑器扩展机制。开发者能用很少的代码实现核心需求,就能获得可视化的好处。同时Unity的可视化方案很清晰而巧妙,具备一定的学习和参考价值。值得深入了解。

二、      Unity编辑器扩展之直观印象:

遵循具象到抽象的研究习惯。这里先来看看《QQ乐团》项目中实际开发的一些编辑器扩展功能。值得一提的,这里看到的几个编辑器的开发时间均在一人周以内。镜头编辑器编辑部分代码行数为1k行左右。Avatar编辑器的代码行数在600行左右。均重用了运行时的模块来缩短开发周期。

1.      基于“物体/组件模型”的扩展示例:

预制物(Prefab)中挂上了一个自定义组件(CTriExecutorKeepingEff)。其中包含角色头顶光效的触发和表现逻辑。美术便能够直接在Inspector编辑窗口中编辑颜色和其它参数。在编辑器中直接播放,便可以看到人物头顶的实际效果。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image002.jpghttp://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image003.jpghttp://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image004.gif

2.      《QQ乐团》镜头编辑器:

这是扩展自定义编辑窗口的一个例子。图片左边的镜头编辑窗口中显示了每首歌的乐谱信息。美术能够此信息编排镜头动画,以及事件的触发。编辑的同时,可以即时在右侧的游戏窗口看到实际效果。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image005.jpg

3.      《QQ乐团》Avatar编辑器:

Avatar编辑器能够方便美术察看换装效果和测试动作。编辑器使用Unity提供的控件进行开发,编辑相关交互逻辑开发简便。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image006.gif

4.      《QQ乐团》的行为编辑器:

行为编辑器能够配置角色整套的由动作,表情,特效所组成的行为。相比配表而言,这样的界面直观且更易编辑。修改之后通过一个触发命令能够马上能看到实际效果。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image007.jpg

5.      场景编辑的辅助工具。

图中的编辑工具能够方便场景美术高效的编辑场景,由《QQ乐团》的技术美术编写。Unity引擎使技术美术的作用能够得以很好的发挥,在《QQ乐团》项目中,不少美术效果相关的代码都出自技术美术之手。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image008.jpg

 

有了上述一些直观的印象之后,我们将详细介绍Unity引擎下的编辑器扩展的方法,进而归纳其可视化的机制和原理。

三、       “物体/组件模型”及相应编辑器扩展:

“物体/组件模型”是Unity引擎下场景资源的基本结构。通过“物体/组件模型”进行编辑器扩展也是编辑器开发最便利的方式。接下来将会分析这个基础结构,并讨论它对编辑器扩展性作出的贡献。

1.      Unity与可视化相关的窗口简介:

打开Unity编辑器,默认情况下便可以看到几个重要的窗口。Scene(场景)窗口用于对场景物体的位置编辑;Game(游戏)窗口显示实际游戏画面表现;Project窗口显示项目中所有的资源(Assets);接下来是与可视化关系比较紧密的两个窗口。其一是Hierarchy(层次)窗口,它显示出当前场景中的物体层次关系,如下图所示。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image009.jpg

另一个窗口是Inspector(察看)窗口,它显示的是当前选中对象中所有可供编辑的属性,包括必备的Transform属性以及各种挂载到对象上的组件的可编辑属性。并能够在编辑时及运行时随时修改这些属性。如图:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image010.jpg

由于Inspector窗口是直接编辑组件参数的窗口。因此是它提供了主要的基于组件扩展的可视化支持。

2.      “物体/组件模型”(Object Component Model)的概念:

从Hieracrchy窗口中我们能够看到,Unity的场景是由场景物体(GameObject[i])按父子层次组织而成的。场景保存的时候,就是将场景物体按层次关系存放在场景文件中。在另一个维度上,场景对象又是由挂载在其上的各种组件组成。每个组件负责某一项特别的功能[ii]。这便是“物体/组件模型”的基本形式。

3.      编写自定义组件:

在Unity中,自定义一个组件格外便利。我们仅需要编写一个继承于UnityEngine.MonoBehaviour的类,就能够直接将此类型作为一个组件拖拽到场景物体上。无需任何配置,即可在Inspector窗口上显示这个组件,并且这个类的公有成员(public field)将作为可编辑的属性。如以下示例:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image011.gif

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image012.jpg

4.      组件扩展的实现原理——反射:

现在我们来看看Unity下的“物体/组件模型”是如何实现的,为什么扩展一个组件能够如此容易?答案就在C#[iii]的强大动态特性——“反射”中。“反射”具有动态创建和调用类型的能力。因此,使用反射能使逻辑脱离与具体类型的关联,而是基于类型的特性编程。这里具体的流程是这样的:当Project窗口中的某个代码被拖到一个场景物体上时,Unity编辑器会根据代码名称在项目的程序集中查找对应的类型,并使用反射方法判断此类型是否继承于MonoBehaviour。如果是,则通过反射方法创建这个类型的实例并加入到该场景物体的组件列表中。Inspector窗口上的属性的显示也是同样道理——使用反射方法扫描这个类型的所有成员[iv],得到其中“可编辑的成员”,根据成员的类型在Inspector窗口上显示编辑控件,并通过反射方法将编辑后的值设置到组件实例上。

5.      Inspector中“可编辑的成员”:

现在具体解释下前一小节提到的“可编辑的成员”的含义。最简单的情况,一个基础数据类型的或是Unity内置类型[v]的公有变量是可编辑的。(正如第3小节所示。)而一个被标注了[UnityEngine.SerializeField]属性[vi]的私有变量同样也是可编辑的。相反,如果想让一个公有成员不被编辑,则可用[System.NonSerizlized]属性标识它。如示例:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image013.gif

接下来说下复合类型的情况:当一个成员是复合类型时,只要在这个复合类型上标注了[System.Serializable]属性,那这个成员也是一个可编辑的成员。Inspector窗口会按树的形式,依次罗列这个类型中的可编辑成员。如示例:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image014.gif 

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image015.jpg

这些属性之所以能够影响到Inspector窗口的显示,正是因为Inspector窗口的显示流程中使用反射检测了各个成员和成员类型上附加的属性。这些属性也被称为类型上的“元数据”[vii]。这本是一个极好的反射和元数据应用的实例。

6.      “物体/组件”的存储格式与存储过程:

Scene(场景)和Prefab(预制物)是Unity引擎下比较特别的资源格式。本质上,他们就是存储“物体/组件 ”的实际资源[viii]。这里值得关注的是,我们并不需要在编写一个组件之后手工编写组件的数据保存和加载逻辑,信息便能妥善保存,格外便利。这里的奥妙还是之前的道理——(保存和加载这样的)序列化流程是基于类型元数据的。通过反射可以递归任意一个类型中的可序列化成员,并根据成员类型来保存/读取数据。正因为如此,这个存储流程能够推广到任意自定义的组件上。保证了组件扩展的便利。实际上Unity引擎的序列化流程与C#内置序列化原理相似。[ix]后文还将继续介绍如何使用C#的内置序列化来开发类似的通用存储功能。

7.      CustomEditor——组件的设计器模式:

之前提到,Inspector窗口会自动显示任何自定义组件中的可编辑元素。但这种默认的交互界面未必是最理想的。一些情况下我们需要自定义Inspector窗口下编辑某个组件的交互界面。这就需要在Editor目录[x]下编写一个继承于UnityEditor.Editor的编辑器类。重载它的OnInspectorGUI函数即可编写自己的交互界面逻辑[xi]。如下方示例代码。

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image016.gif

我们可以看到Unity编辑器利用CustomEditor属性注册与所要编辑的组件的关系。这样,在Inspector窗口显示某个组件的时候,会先使用反射在Editor程序集[xii]中查找是否存在此组件的CustomEditor。如果存在,使用此Editor处理相关的编辑流程;若不存在,就使用默认的Editor来执行第3小节中描述的缺省流程。CustomEditor和组件之间这种设计模式跟VisualStudio中C#的WinForm开发中的控件设计器(Designer)是类似的。这被称为“设计器模式”。也是一种在编辑器中运用广泛的模式,可作为参考。

四、      独立编辑窗口(EditorWindow)的开发:

基于组件的扩展是一个很便利的编辑器扩展方案,它能便捷地满足各种细小的编辑需求。但某些情况下我们需要独立的编辑窗口来满足特定的需要。比如某些编辑器的操作界面复杂,并不能用Inspector窗口实现。某些情况我们更期望将编辑器结果保存在自定的格式中,而不是保存在Prefab或Scene中。这一节大致介绍在Unity下如何开发这样的独立编辑器。之前列举了几个《QQ乐团》项目开发的编辑器作为示例,网上也能找到不少例子进行参考,所以这里就不再赘述相关接口的使用了。

1.      编写自定义的EditorWindow类型:

在Editor目录下编写一个继承于EditorWindow的类型CustomEditor,接着只要调用EditorWindow.GetWindow();即可显示这个编辑窗口。通常是使用属性[MenuItem(“Windows/CustomEditor”)]标识函数,来添加编辑器菜单项。如以下代码:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image017.gif

2.      绘制相关:

在CustomEditor类的OnGUI函数中编写绘制流程。Unity提供一系列基础控件,使用起来比较容易上手。布局上,既可以直接使用GUI,EditorGUI两个GUI工具类指定每个控件的位置,也可以通过GUILayout,EditorGUILayout这两个排布器指定控件的排列关系。另外GUIUtility中有一些工具函数可供使用。网上也可以找到不少现有的代码能够方便开发,比如绘制直线,曲线的工具。

3.      处理编辑窗口操作:

多数控件在绘制函数返回时就带有操作结果。比如GUI.Button(..)的返回值标识了这个按键是否按下。另外可以从Event.current中获得当前的鼠标和键盘的输入事件。

4.      数据存储:

使用C#的标准库System.IO能够很容易操作文件。System.Xml.Serialization和System.Runtime.Serialization提供便利的序列化支持,减少在数据存储和读取流程的编码工作。

五、      驾驭Unity编辑器的语言兵器:

1.      反射(Reflection)与自定义属性(CustomAttribute):

从上文我们可以看到Unity编辑器如何利用反射方法来支持编辑器扩展性。实际上,C#语言的这项高级特性特别适用于编辑器扩展领域——反射的局限性在于效率,但编辑操作的执行效率并不是太紧要。而使用反射的优点在于能够更好的提炼编辑上的通用规则,避免为不同编辑对象重复开发。C#下开发自定义属性及其容易。只需要继承Attribute类,即可以得到我们自定属性。接着就可以用这个属性来标识类型或是类型的成员,并通过反射获得自定义属性的数据。如以下示例:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image018.gif

从另一个角度来看,反射提供了一种不与类型关联的协约方式。比如说他能够实现一种特别的插件模式——依赖注入(Dependence Injection)。这种模式能将依赖关系的耦合降到最低:A在流程上依赖B,但A与B都不需要知道对方具体的名称。实现方式为,A通过查找Attribute来发现B类型并实例化B。这样可以在运行时才确定依赖关系,提供了更大的灵活性。[xiii]可以说,反射实在是提供良好扩展性之利器。

2.      语言内建序列化:

C#对序列化工作提供了标准库。使用C#,在结构化数据和流数据之间转换简单到只需要数行代码。如下示例:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image019.gif

之所以能够如此简单,原理上还是反射的功劳。标准库中的代码使用反射得到类型的成员进行相应的序列化转换,根本不需要开发者编写冗余的代码。它能够自动处理类型的包含关系。更强大的是,它能够根据对象在继承关系链上的具体类型序列化成数据流,又从数据流中还原原本的类型。只需要在基类上使用[XmlInclude]属性标注子类。如示例:

http://avocado.oa.com/fconv/files/201208/0a0ccd41a460bad7374e2cd7ac9bca1b.files/image020.gif

这意味着我们能够更自然的管理数据类型的分化。甚至我们不再需要专门的数据类型来维护对象中数据的存储,对象本身能够很自然的进行存储。注意到其中运行时的数据使用Xmllgnore和NonSerizlized属性标识,使序列化忽视他们。[xiv]这样我们也就不必担心存储的数据脱离我们的控制。可以看到,C#提供的序列化功能能够帮助开发者便捷地处理数据存储,使序列化的操作成为面向对象编程习惯当中很自然的一环。这样的特性在编辑器扩展的时候善加利用,实在是能够事半功倍。

六、      可视化领域的思维方式:

个人以为,“思维决定代码而不是相反”是程序员方法论中精髓的一条。说起来,游戏引擎的可视化是一个新的时代。C#语言的一些高级特性也是相对新一些的东西。那么,我们的思维该如何进步来适应这些新的东西,从而指导我们平日对技术的应用?这一节就尝试说明个人对这个问题的理解。

1.      对象生命期的延伸:

面向对象的思想适应了人脑对事物的抽象,世界中存在各种不同的类型。而一个具体的物体,则是类型的一个实例,称之为对象。传统的理解上,对象的概念或许只是存在于内存中,编辑器编辑的东西更多的定义为数据。现今,在这样的“物体/组件”模式下,接受编辑的各个组件,同样是某个类型的一份实例。它经历了序列化之后成为流格式,游戏运行时又被反序列化成为内存中的一个对象。并不像传统编辑开发中,从数据到对象的过程。而是同一个对象经历设计时与运行时两个不同时域的过程。换句话来说,对象的生命期,从运行时,扩展到了设计时。可视化就是赋予了对象在设计时鲜活的生命力。这种思路的意义在于,设计时相关的开发同样可能符合面向对象的抽象方式,也能够更好的促进代码重用。而语言内置序列化,极好的联结了设计时与运行时下的对象,是这种思想的最好佐证和实现基础。

2.      “面向元数据”的编程思想:

程序员始终想偷懒的动机不断推动着程序语言向高阶发展。从面向过程到面向对象,编程方法迈进了一大步。从本质上来说,反射式编程是比面向对象编程更高阶的编程方式——“面对对象编程操作具体的类型和接口,而反射式编程脱离了与具体类型的耦合,关注的是依附在类型信息上元数据。”这种新的逻辑描述形式就好比是:有一棵树和一只猫,和一个作画的人。作画者需要把握他俩呈现出的形貌细节,将其画在一幅画中。树有叶子和枝干,猫有五官和四肢,但他们并没有一个统一的方法叫做“画到纸上”。而作画者也并不需要这样的统一方法。他只需要通过眼睛捕捉这些树与猫各个组成部分呈现出的色彩。这里的色彩就是元数据。“通过眼睛捕捉”实际上就是反射的过程。有了眼睛这样的工具,作画者能够绘制任何可见的东西,而根本不需要了解树与猫的实际概念。如果要类似的用“面向XX”来总结,这种编程方法可以称为“面向元数据”。这样的更高阶的抽象,合理应用也将提升相当的开发效率。

七、      相关技术的整合:

1.      适应Unity编辑器扩展开发的程序框架:

按照之前的思路,我们在编辑模式下执行的不少代码应该是与运行时公用的。我们需要按功能划分为不同模块,并通过一个程序框架来定义运行时和不同编辑模式下需要加载的模块。(比如角色编辑器的只需要加载角色换装相关的模块。)这种方式能够很好的实现之前的思路,更好的重用模块。另一方面,这个框架需要处理编辑窗口对对象的操作,以及数据被编辑后的生效流程。

2.      资源构建与加载:

在Unity这样的物体/组件模型下。对象中可能引用了其它的资源,而这个对象本身将序列化成资源(Prefab或Scene)。这样在打包过程中带入引用资源打包的复杂度。最通用的解决方案是在打包过程中将引用关系脱离,资源独立打包,在运行时加载的时候又将引用关系重新连接。这需要有一整套资源构建和加载的模块来实施。

八、      总结与展望:

1.      思路与技术达成一致:

Unity的可视化方案围绕者“物体/组件模型”展开,使用了设计器模式组织组件与对应编辑器的关系。它的实现思路可以理解为“扩展对象的生命期到设计时”。它运用了反射技术以提供良好的扩展性。我们在进行编辑器扩展的时候应该与这样的思路一致。以达到提升代码重用,减少编辑器开发工作量的目的。

2.      继续挖掘交互体验空间:

便于资源堆量开发是可视化编辑的优势之一。另一方面,可视化编辑有利于游戏交互体验的深入挖掘,比如角色与角色,角色与场景,动态场景等等近年来不断发展的更高级的交互特性。这样的交互性逻辑与时间相关,开发和调试难度相对较大,同时要兼顾表现效果。笔者认为可视化的编辑和调试是实现此类特性的一个很好的思路。也尝试过在Unity3d上开发了一个小规模的可以进行可视化编辑和可视化调试的行为管理框架。但从实际而言,公司对Unity3d的使用还在相对初级的阶段。将来是否能用它建筑出更好的交互体验,和更敏捷的开发流程,就是个人对未来的希望吧。也希望文中介绍的思路能够不断发展,形成实际的具备竞争力的技术。

 


[i] 场景对象“UnityEngine.GameObject”继承于“UnityEngine.Object”,后者包括了项目中的资源。

[ii] 如Transform组件负责对象的位置,旋转,和缩放。MeshFilter负责处理面网格,MeshRenderer负责面网格的绘制,Rigidbody负责刚体物理计算,等等。

[iii] Unity引擎的并非基于.NET Framework。而是基于Mono,一个第三方的.NET工具,两者均符合ECMA标准。Mono具备较好的跨平台特性,支持即时编译(JIT)。

[iv] 类型的成员是指:一个类型中声明的变量(field)/方法(method)/属性(property)/事件(event)。

[v] 如GameObject,TextAsset,Mesh,AnimationClip等等,包括所有的Unity可操作资源类型。它们都继承于UnityEngine.Object。

[vi] 属性(attribute)是C#用来描述数据的数据(元数据)。语法上是用方括号括起,放在声明的上方。注意与(property)的中文翻译都是属性,本文不标注的情况下指的是attribute。

[vii] 元数据含义上是指“描述数据的数据”,在数据库等领域也有这样的概念。

[viii] Prefab中直接就是存储了物体层次和物体上的各个组件,场景资源中除此之外还包括一些场景相关的全局信息。

[ix] 编辑器并不像C#内置序列化那样会处理ISerializable接口。但还是支持将资源序列化成二进制格式或是文本格式,这点在ProjectSetting/Editor面板中有Asset Serilization项可以设置。

[x] 目前,一个Unity项目被分为三个程序集:Firstpass,CSharp,Editor。Plugins目录下的代码会编译到Firstpass程序集中,它最先编译。处于Editor目录下的代码会编译到Editor程序集中,所有的编辑器扩展代码都处在这个程序集中,它最后编译,并且只在编辑器下加载。其余的代码编译在CSharp程序集中,它在FirstPass之后编译。三个程序集依次之前的程序集。

[xi] 也可以通过重载OnSceneGUI 函数自定义组件在Scene窗口下辅助线框的绘制。

[xii] 同尾注x

[xiii] 《QQ乐团》使用这种方式来注入经过代码混淆后的程序集。

[xiv] 这里举出的例子是Xml序列化,C#同样支持二进制序列化。可以考虑在开发阶段使用Xml序列化以保证扩展性,在正式发布的时候使用二进制序列化以减少空间开销。

 

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引