UE生UObject,UObject生万物

引言

在上个GamePlay专题,谈到UE创建游戏世界的时候(GamePlay架构(一)Actor和Component),简单的介绍了一下UObject的功能:

藉着UObject提供的元数据、反射生成、GC垃圾回收、序列化、编辑器可见、Class Default Object等,UE可以构建一个Object运行的世界。(后续会有一个大长篇深挖UObject)

那么从本专题开始,我们将开始慢慢的填这个大坑。正所谓,千里之堤溃于蚁穴,万丈高塔始于垒土。在阅读分析游戏引擎源码的时候,又或者是想要扩展引擎功能,如果对于引擎底层对象创建的机制不太清楚,则常常会有点力不从心,因为功能模块的运行机制、数据流程、资源的加载释放时机,往往也都是依赖于对象的生命周期。而如果想要实现一个自己的游戏引擎,从一开始,也都得设计一个完善的对象管理机制,然后慢慢的在其上叠加功能,这个对象模型设计实现的如何,往往决定了一款引擎的基因。

愿景目标

本专题的内容主要是用于提升UE开发者,或者游戏引擎研究爱好者的内功,所以并不适合所有读者。在此需要明示本专题后续篇章(大概10~20篇)的目标愿景,以后不再赘述。

  1. 专注于UE的UObject及其周边的系统,在介绍构建对象系统的时候,虽然会涉及到引擎的启动流程,但是并不详细介绍(留待后续专题探讨)。本专题的重点是介绍UE4中的UObject系统,理论上,读完该篇章,你应该可以心里比较有数的在别的引擎里也实现出一套类似的系统。
  2. 本专题的内容对于你使用蓝图实现游戏逻辑几乎“无用”,对正常的表层C++开发也基本上提升不大。本专题只是让你安心一些而已。
  3. 我,先声明,本专题为了详细的讨论机制,在91.233%的情况下我会显得比较罗嗦,会引用罗列大量的引擎源码。如同开篇所说,毕竟是在探讨最最底层的的源码,再粗旷的泛泛而谈也没啥意思。

前置知识

本专题的学习探讨门槛比较高,在开始本专题之前,需要你先掌握以下知识:

  1. 优秀的C++语言能力。虽然不需要你“精通”C++,但是需要你熟练掌握C++11、模板、宏、对象内存模型和基本的各种规则机制。
  2. 本专题也涉及到对象反射,所以也需要你对编译原理、元数据、程序结构有一定的了解。如果对其他编程语言(java,c#)的反射熟悉的话,对理解本专题也是一大助力。
  3. 对多线程编程比较熟悉,知道并掌握各种线程同步机制。UE用多线程并行优化了很多任务,其中就包括GC,所以不可避免的需要你掌握相关的知识。
  4. 对C++内存分配管理也比较熟悉,知道一些STL基本的内存分配器,也知道各种内存分配管理的意义和技术,如引用技术,GC等。

为了更好的理解本专题内容,有必要的时候,我也会在每一个篇章开头简单介绍一下C++的一些知识要点。不过还是需要你自己去搜索查阅学习其他更系统的C++资料。

对象模型

先问一个问题,为什么需要有一个统一的基类:Object?
甚至,我们在编程语言中也常常见到这种模式,比如Java中的object、C#的object,甚至一些纯对象的脚本语言(Ruby里连数字123都是对象)。刚接触UE的人,看到UE里的Object,可能觉得这没什么,好像就自然而然应该有应该是这样,但是做过游戏引擎的人就知道,这里面蕴含了很多设计思想和权衡。

大部分的游戏引擎底层都是C++,而C++作为一个下接操作系统硬件底层,上接用户逻辑的编程语言,为了适应各种环境,不为你不需要的东西付代价,C++是并没有提供原生GC的。STL库的那些智能指针更多只是在C++的语言层面上再提供一些小辅助。在最开始设计游戏引擎的时候,你不光要考虑该引擎所面对的用户群体和针对的游戏重点,更要开始考虑你所能利用到的都有什么内存管理方式。简单说一下其他游戏引擎在这方便的情况:

  • Cocos系列,最早是cocos-iphone扎根于objective-c,所以用的引用技术,后来有cocos2dx了,为了照顾老用户的使用习惯,几乎是机械翻译了objective-c的内存管理机制,搞出了一个CCObject,里面只有Retain/Release,因为还是太过粗糙,也常常还是出现各种内存泄漏问题,用的时候也是得处处小心,漏掉一个就敢泄漏给你看,而且追查起来非常困难。没看人家objective-c自己后来都搞了一个ARC来减轻大家的工作了嘛。
  • Unity底层源码我没看过不得而知,不过上层脚本C#是基于Mono的已经实现完善的语言原生管理机制,对游戏对象的内存管理倒也确实是省心了非常多,缺点也是如果想要更精细的控制的时候就有点力不从心。
  • 其他引擎,用的还是C++提供了的那些,顶多自己再定制一些管理辅助类。比如KlayGE就是利用了boost的智能指针,CryEngine用的也是智能指针,我的Medusa引擎也是比较简单的采用的C++智能指针的方案。对象的分配释放也往往需要用户手动管理控制。
  • 在这就不得不提到Qt里的QObject,Qt虽然是处于跟UE不同的GUI框架领域,但是设计的思想却有些殊途同归,Qt里根据QObject基类构建出来的ObjectModel为UI的复杂构建和通信提供了许多了非常便利急需的功能。比如信号和槽的设计就常常让人们津津乐道,而且在编辑中也可以非常方便的查看对象的属性。Qt的流行,QObject应该功不可没。
  • UE的Object系统无疑是最强大的。实际上UE能实践出这么一套UObject是非常非常了不起的,更何况还有GC和HotReload的黑科技。在大型游戏引擎的领域尝试引入一整套UObject系统,对于整个业界也都是有非常大的启发。

那么引入一个Object的根基类设计到底有什么深远的影响,我们又付出了什么代价?
得到:

  1. 万物可追踪。有了一个统一基类Object,我们就可以根据一个object类型指针追踪到所有的派生对象。如果愿意,我们都可以把当前的所有对象都遍历出来。按照纯面向对象的思想,万物皆是对象,所以有一个基类Object会大大方便管理。如果再加上一些机制,我们甚至可以把系统中的所有对象的引用图给展示出来。
  2. 通用的属性和接口。得益于继承机制,我们可以在object里加上我们想应用于所有对象的属性和接口,包括但不限于:Equals、Clone、GetHashCode、ToString、GetName、GetMetaData等等。代码只要写一遍,所有的对象就都可以应用上了。
  3. 统一的内存分配释放。实际上Cocos2dx里的CCObject的目的就是如此,可惜就是实现得不够好而已。用引用计数方案的话,你可以在Object上添加Retain+1/Release-1的接口;用GC的方案,你也有了一个统一Object可以引用,所以这也是为何几乎所有支持GC的语言都会设计出来一个Object基类的原因了。
  4. 统一的序列化模型。如果想要让系统里的各种类型对象支持序列化,那么你要嘛针对各种类型分别写一套(如protobuf就是用程序生成了序列化代码),要嘛就得利用模板和宏各种标记识别(我自己Medusa引擎里实现的序列化模块Siren就是如此实现的),而如果有了一个Object基类,最差的我们就可以利用上继承机制把统一的序列化代码放到Object里面去。而如果再加上设计良好的反射机制,实现序列化就更加的方便了。
  5. 统计功能。比如说我们想统计看看整个程序跑下来,哪种对象分配了最多次,哪种对象分配的时间最长,哪种对象存活的时间最长。等等其他很便利的功能,在有了可追踪和统一接口的基础上,我们也能方便的实现出来。
  6. 调试的便利。比如对于一块泄漏了的内存数据,如果是多类型对象,你可能压根没法知道它是哪个对象。但是如果你知道它是Object基类下的一个子类对象,你可以把地址转换为一个Object指针,然后就可以一目了然的查看对象属性了。
  7. 为反射提供便利。如果没有一个统一Object,你就很难为各种对象实现GetType接口,否则你就得在每个子类里都定义实现一遍,用宏也只是稍微缓解治标不治本。
  8. UI编辑的便利。和编辑器集成的时候,为了让UI的属性面板控件能编辑各种对象。不光需要反射功能的支持,还需要引用一个统一Object指针。否则想象一下如果用一个void* Object,你还得额外添加一个ObjectType枚举用来转换成正确类型的C++对象,而且只能支持特定类型的C++类型对象。

代价:

  1. 臃肿的Object。这算是继承的祖传老毛病了,我们越想为所有对象提供额外功能,我们就越会在Object里堆积大量的函数接口和成员属性。久而久之,这个Object身上就挂满了各种代码,可理解性就大大降低。Java和C#里的Object比较简单,看起来只有个位数的接口,那是因为有C++在JVM和CLR的背后默默的干着那些脏活累活,没显示出来给你看而已。而UE在原生的的C++基础上开始搭建这么一套系统,就是如今这么一个重量级的UObject了,大几十个接口,很少有人能全部掌握。
  2. 不必要的内存负担。有时候有些属性并不是所有对象都用的到,但是因为不确定,为了所有对象在需要的时候就可以有,你还是不得不放在Object里面。比如说一个最简单的void* UserData,看起来为所有对象附加一个void*数据也挺合理的,用的时候设置取出就好了。但是其实有些类型对象可能一辈子都用不到,用不到的属性,却还占用着内存,就是浪费。所以在一个统一的Object里加数据,就得非常的克制,不然所有的对象都不得不得多一份占用。
  3. 多重继承的限制。比如C多重继承于A和B,以前A和B都不是Object的时候还好,虽然大家对C++里的多重继承不太推荐使用,但是基本上也是不会有大的使用问题的。然后现在A和B都继承于Object了,现在让C想多重继承于A和B,就得面临一个尴尬的局面,变成菱形继承了!而甭管用不用得上全部用虚继承显然也是不靠谱的。所以一般有object基类的编程语言,都是直接限制多重继承,改为多重实现接口,避免了数据被继承多份的问题。
  4. 类型系统的割裂。除非是像java和C#那样,对用户隐藏整个背后系统,否则用户在面对原生C++类型和Object类型时,就不得不去思考划分对象类型。两套系统在交叉引用、互相加载释放、消息通信、内存分配时采用的机制和规则也是大不一样的。哪些对象应该继承于Object,哪些不用;哪些可以GC,哪些只能用智能指针管理;C++对象里new了Object对象该怎么管理,Object对象里new了C++对象什么时候释放?这些都是强加给用户思考的问题。

著名的沃斯基·索德曾经说过,设计就是权衡的艺术。那些得到的UE已经想要攥在手里了,而那些代价我们也得想办法去尽量降低和规避:

  1. 针对太过复杂的Object基类,虽然我常常夸UE的设计优雅卓越,但是我这里要黑一下UE,感觉UE的Object基类已经有点破罐子破摔了,能非常明显的感觉到了进化留下的痕迹,一个UObject你给我分了三层继承:(UObjectBase->UObjectBaseUtility->UObject),关键是头两层你还都没有子类。而Object相关的Flags常常竟然把32位都给占完了也是牛。念在UE提供了那么多的UObject功能模块实现,类声明里大几十个方法我们也只好忍了吧。这一块太过底层,估计也不敢大刀阔斧的整改,只能期待UE5再说了。
  2. sizeof(UObject)==56。56个字节相对来说应该还是可以接受,关掉Stat的话还能再少一个指针大小。当然这里并没有考虑到外围Class系统的内存占用,但是光光一个对象基础的数据占用56字节起步的话,我觉得已经非常优秀了。10000个对象是546K,1百万个对象是53M。一方面游戏里的对象其实数量没有那么多,对于百万粒子那种也可以用原生的C++对象优化,另一方面现在各个平台内存也越来越宽裕了,所以这个问题已经解决得在可接受范围内了。
  3. 规避多重继承,UE在BP里提供的也是多重继承Interface的方案。在C++层面上,我们只能尽量规避不要多重继承多个UObject子类,实在想要实现功能复用,也可以采用组合的组件模式,或者把共同逻辑写在C++的类型上,比如UE中众多的F开头的类就是如此的功能类。总之这个问题,好在我们可以用方式规避掉。
  4. 只能多学习了。没办法,现实就是不完美的。越是设计精巧的系统就越是难以理解。不过一方面UE提倡在BP里实现游戏逻辑,C++充当BP的VM,就可以完全对用户隐藏掉复杂性。另一方面,UE在UObject上也提供了大量的辅助设计,如UCLASS等各种宏的便利,NewObject方便接口,UHT的自动分析生成代码,尽量避免用户直接涉及到UObject的内部细节。所以单从一个使用者的角度来说,如今的状态已经挺友好的了,Object工作的挺好,几乎不需要去操心或者帮它补漏。至于想理解的更深层次的话,就只能靠开发者们更用心的学习了。

权衡的结果大家也都知道了,UE下定雄心选择了开始搭建Object,提供了那么多我们日常使用的功能。我的Medusa引擎也是非常艳羡UE那么多便利的功能,但是让我从头开始去再去搭建一套,限于精力有限,我是不敢去做的。光一个GC就得有大量的算法权衡,多线程处理的各种细节和各种优化,更何况再融合了反射、序列化、CDO、统计,想实现得既优雅又性能优越就真的是一件非常不容易的事,代码写完之后还得需要大量的测试和修复才能慢慢稳定下来能用。信任感的建立是很难的,一旦出现对象被释放掉了或者没有释放,你第一时间怀疑的应该是你的使用有问题,而如果UE给你的印象是怀疑UE的Object实现内部有bug,那你就会逐渐的倾向于弃用UE的那一套,开始撸起袖子自己管理C++对象了。

总结

本文作为专题的开篇,唠了些书写背景的闲话,也闲聊了一下其他游戏引擎是怎么看待游戏内对象管理这回事的。每款游戏引擎都有自己的产生背景和侧重点,再加上设计的理念也不一样,所以就会产生各种各样的架构。接着探讨了设计一个Object系统有哪些好处和缺点,我不知道UE最初的UObject设计是从何而来的,但是如果没有UObject,没有了富饶的土壤,想要有繁茂的森林就比较困难了。各引擎的开发团队竞赛的时候,大家其实水平都差不了多少,同样想支持一个最新功能的时候,我利用上了统一的Object机制开发用了一周上线;你因为少了一些代码上的便利,还得自己手动管理内存,写序列化,再撸编辑器支持,代码写了两周,修复Bug用了2周,交付用户使用的时候,代码的接口因为不能反射也不是那么易用,慢慢的竞争优势就弱了。没那么方便调试统计,开发者修复bug起来就费劲,埋的Bug多了,用户觉得你越来越不稳定,引擎的生命力就是这么一步步一点点枯萎掉的。所以不要觉得引擎只要堆积功能就行了,一开始有个好的结构是重中之重。

闲话说完才可以开始之后的一个个功能的详细叙述。那么亲爱的读者们,请跟着我的脚步,重走UE曾经走过的路,让我们试着从头开始搭建一个Object系统,一步步的让她羽翼丰满多才多艺。
敬请期待下篇:UObject(二)类型系统

致谢

本专题的写作离不开技术同好们的分享讨论,以及珍贵的指点建议。

  • 特别感谢“黄河水”(QQ123258314)大哥为我最初启蒙讲解了UObject的序列化机制,作为UE3使用过来的“老人”,常常能提供许多UE历史演化的佐证。
  • 特别感谢“女施主,使不得啊”(QQ1441842473)朋友一起讨论学习,你最初的样稿里关于UObject的详细研究也是我重要的参考,提供了重要的思路反向。
  • 特别感谢“Dest1ny”(QQ125210991),书写的一系列高质量的文章UE4入门与精通,其中的"UE4源码解读与实现分析"章节讲解反射和垃圾回收,蓝图编译的文章都是我学习的重要资料。也非常感谢他授权我之后引用他的文章。他的博客地址是凡事看本质,强烈推荐前去围观。
  • 也非常俗气的感谢那些点赞评论,特别是打赏我的读者们,是你们的支持,让我有动力继续写下去。

引用

  1. Unreal Object Handling
  2. Garbage Collection & Dynamic Memory Allocation
  3. Garbage Collection Overview
  4. UNREAL PROPERTY SYSTEM

UE4.14.1


知乎专栏:InsideUE4

UE4深入学习QQ群: 456247757(非新手入门群,请先学习完官方文档和视频教程)

个人原创,未经授权,谢绝转载!

《InsideUE4》UObject(一)开篇的更多相关文章

  1. 《InsideUE4》UObject(二)类型系统概述

    曾子曰:吾日三省吾身--为人谋而不忠乎?与朋友交而不信乎?传不习乎? 引言 上一篇我们谈到了在游戏引擎,或者在程序和高级编程语言中,设计一个统一对象模型得到的好处,和要付出的代价,以及在UE里是怎么对 ...

  2. 《InsideUE4》UObject(三)类型系统设定和结构

    垃圾分类,从我做起! 引言 上篇我们谈到了为何设计一个Object系统要从类型系统开始做起,并探讨了C#的实现,以及C++中各种方案的对比,最后得到的结论是UE采用UHT的方式搜集并生成反射所需代码. ...

  3. 《InsideUE4》UObject(四)类型系统代码生成

    你想要啊?想要你就说出来嘛,你不说我怎么知道你想要呢? 引言 上文讲到了UE的类型系统结构,以及UHT分析源码的一些宏标记设定.在已经进行了类型系统整体的设计之后,本文将开始讨论接下来的步骤.暂时不讨 ...

  4. 《InsideUE4》UObject(五)类型系统信息收集

    在一起!在一起! 引言 前文中我们阐述了类型系统构建的第一个阶段:生成.UHT分析源码的宏标记并生成了包含程序元信息的代码,继而编译进程序,在程序启动的时候,开始启动类型系统的后续构建阶段.而本文我们 ...

  5. 《InsideUE4》UObject(六)类型系统代码生成重构-UE4CodeGen_Private

    读的不如写的快 引言 在之前的<InsideUE4>UObject(四)类型系统代码生成和<InsideUE4>UObject(五)类型系统收集章节里,我们介绍了UE4是如何根 ...

  6. 《InsideUE4》GamePlay架构(十)总结

    世界那么大,我想去看看 引言 通过对前九篇的介绍,至此我们已经了解了UE里的游戏世界组织方式和游戏业务逻辑的控制.行百里者半九十,前述的篇章里我们的目光往往专注在于特定一个类或者对象,一方面固然可以让 ...

  7. 《InsideUE4》-10-GamePlay架构(九)GameInstance

    一人之下,万人之上 引言 上篇我们讲到了UE在World之上,继续抽象出了Player的概念,包含了本地的ULocalPlayer和网络的UNetConnection,并以此创建出了World中的Pl ...

  8. 《InsideUE4》-9-GamePlay架构(八)Player

    你们对力量一无所知 引言 回顾上文,我们谈完了World和Level级别的逻辑操纵控制,如同分离组合的AController一样,UE在World的层次上也采用了一个分离的AGameMode来抽离了游 ...

  9. 《InsideUE4》-6-GamePlay架构(五)Controller

    <InsideUE4>-6-GamePlay架构(五)Controller Tags: InsideUE4 GamePlay 那一天 Pawn又回想起了 被Controller所支配的恐惧 ...

随机推荐

  1. jQuery AutoComplete在AJAX UpdatePanel环境中PostBack之后无法工作

    前些日子,Insus.NET有实现<ASP.NET MVC使用jQuery实现Autocomplete>http://www.cnblogs.com/insus/p/5638895.htm ...

  2. VS 常用快捷键

    区域代码选择:按Shift选择整(行)块代码,可配合四个方向键(左右键:选择单个字符,上下键:上下行的当前列).Home(当前行首).End(当前行尾).PgUp(当前页首)和PgDn(当前页尾)使用 ...

  3. iOS阶段学习第34天笔记(UI小组件 UISegment-UISlider-UIStepper-UIProgressView-UITextView介绍)

    iOS学习(UI)知识点整理 一.UI小组件 1.UISegmentedControl 分段选择器  实例代码 - (void)viewDidLoad { [super viewDidLoad]; / ...

  4. NPOI操作Excel辅助类

    /// <summary> /// NPOI操作excel辅助类 /// </summary> public static class NPOIHelper { #region ...

  5. div的显示和隐藏以及点击图标的更改

  6. 连接输出 如果存在在php中多次echo输出js的时候

  7. PHP正则表达式

    1.PHP中两个常用的正则函数 a.preg_match 正则函数,以perl语言为基础 语法:preg_match( mode,string subject,array matches) 说明:mo ...

  8. mybatis中的#和$的区别(转)

    #相当于对数据 加上 双引号,$相当于直接显示数据 1. #将传入的数据都当成一个字符串,会对自动传入的数据加一个双引号.如:order by #user_id#,如果传入的值是111,那么解析成sq ...

  9. 推荐15款制作 SVG 动画的 JavaScript 库

    在当今时代,SVG是最流行的和正在被众多的设计人员和开发人员使用,创建支持视网膜和响应式的网页设计.绘制SVG不是一个艰巨的任务,因为大量的 JavaScript 库可与 SVG 图像搭配使用.这些J ...

  10. Bootstrap分为几部分?

    Bootstrap分为五部分: (1)起步(Startup) (2)全局CSS样式(Global CSS) (3)组件(Component) (4)插件(Plugin) (5)定制(Customize ...