概述
写这篇文章的本意,并非劝退Unity使用者,只是尽可能客观的指出Unity这个引擎的问题,并且希望众多Unity黑粉在黑的时候能够对症下药,不要仅仅盯着“渲染效果”这种显而易见但是无足轻重的部分,否则都是隔靴搔痒,相反,只有认识到自己手上的工具的实际问题才能对症下药考虑是否要用,以及如果要用需要注意哪些问题。
一句话总结概述就是:Unity 是一个优秀的功能试验器,也是一个辣鸡引擎,如果你有能力(还得有十足的精力和一份源码)只把他当作架构用(全部功能自己造)那他会是一个好工具。
资源加载系统
Unity资源加载的毛病,基本一个字就可以总结“慢”,如果纯粹是绝对时间长,倒也不是很严重,严重的地方在于它还会卡主线程。首先,虽然在2018版本以后已经推出了Job System作为多线程实现,但内置API依然不能在分线程运行,包括引擎内置的逻辑,诸如GameObject, Component的Instance等,都不能放在分线程进行,哪怕这些逻辑永远不会和客户端逻辑发生资源竞争,也依然会挤在主线程执行。这里举几个例子,Texture2D类型加载时,源码层DirectX的实现逻辑是从硬盘加载到显存的Upload Memory,而后再复制到Default Memory作为普通贴图读取,这个过程被放到了加载线程执行,而这张贴图的长宽等信息则都是在主线程序列化的,最后还会被主线程实例化C#层,这就意味着整段逻辑需要经历主线程->分线程->主线程这么一个步骤,一方面这样做会消耗大量时间进行命令周转,另一方面主线程的压力也一点都不小。更令人窒息的是,Scene的加载过程一点没有考虑到Instance效率低下的问题,常常把巨量Instance任务放到一帧内导致帧数骤降,因此如果有使用Unity开发过开放世界游戏的开发者对此应该是深恶痛绝的,因为这种逻辑根深蒂固的腐蚀整个引擎,以至于要想实现一个无缝的流式加载的开放世界游戏,资源加载系统的负担会非常重,而且常常成为性能短板,这种短板除了部分能够拿到源码的公司有能力解决以外,对于广大没有办法接触到源码的普通开发者来说如同天方夜谭,最终只能选择放弃。
同时,Scene + GameObject + 包罗万象的Component的工作流程也对加载非常不友好,虽然Component的设计对于Gameplay逻辑非常友好,可以保证在复杂的组件依赖关系不会被加载流程破坏,但是很显然渲染组件并不是Gameplay的一部分,因为渲染组件并没有多么复杂的逻辑,相反,其加载逻辑简单且重复,基本上就是Mesh + Material + Texture + Transform = Renderer的逻辑,虽然逻辑简单,加载压力却一点都不小,起码一般情况比其他部分大得多,这种时候Unity并没有独立出Renderer而是将其作为普通Component挂在GameObject上。当开发者加载一个或者同时加载多个Sub Scene的时候,所有的GameObject都会被一股脑塞进去,卡顿自然是避免不了的。而官方不仅没有任何提升,反而鼓吹自己SubScene这一套难用到窒息的工作流程,将这口引擎本身缺陷的大锅,甩给开发团队,成为程序和美术之间沟通矛盾的主要来源之一,对于美术来说,直接往场景里拖模型无疑是效率最高的工作方法,而由于Unity缺失的分块流式加载和低效的异步,又会导致美术提交的场景往往糊成一大坨,可能上万甚至上十万的GameObject堆砌在引擎里,最后性能爆炸运行迟缓。
如果说内置的资源加载效率不够优秀,倒也不是最大的问题,最最最大的问题是没有源码的情况下这一部分根本没有办法自己造轮子,而幸运的是Unity恰好是一个闭源的引擎,唯一提供的一个可以自己加载的API是Resources或者AB包下的二进制加载,而且这个加载居然是通过协程进行异步而不是直接允许分线程执行的,而Mesh, Texture这些很基本的类型都完全没有提供自主加载的API!所以只能默默地忍受内置资源加载带来的折磨!
资源管理系统
上一部分资源加载缓慢的另一部分锅则在于落后的资源管理系统上,这套资源管理系统不仅效率低下,而且其效率还会随着项目体量的提升严重下滑。一个Unity项目的资源主体由Assets和Library两个文件夹组成,外加ProjectSettings等周边文件夹,Assets存储用户导入的资源,而Library则是一个完全由Assets生成出来的贴图。经历过自研引擎的老一辈开发者可能都习惯了引擎资源的打包工具,比如将文本格式的图片,类似内部通过#00ff00这样的字符码格式,转换为引擎可读的二进制格式,比如DDS格式,在转换过程中还可以伴以压缩降低硬盘和显存的占用。而Unity则将这个过程完全自动化了,甚至一点操作空间都不留,任何新更新的组件被拖到Assets下时,都会经历一个import过程被打包到library,此时如果项目的资源体量巨大,整个文件夹的体积就会变得十分夸张,上百G甚至上T可能都不是难事,毕竟资源都已经被重新生成了,如果Cache Server做的不到位可能点开项目都需要半个星期的时间,而这种所谓的“方便”是很不必要的,因为项目中有相当一部分资源,尤其是占用体积最大的贴图资源,几乎没有了修改需求,直接以只有引擎能读懂的二进制格式存储在Assets文件夹中即可,就算要修改也可以单方面提供一个导出工具,比如将模型导出成obj或fbx,将贴图导出成png等,好用的方法有很多,而Unity偏偏选择了对做项目最最不友好的一种方法,为了满足“让用户可以最快速度的把资源导入并使用”这样无足轻重的需求,牺牲整个引擎的开发效率,这分明是一个血亏的决策,由此可见,Unity的设计人员对真正的工程开发需求拿捏不准,以一个“不要你觉得,我要我觉得”的态度开发引擎的资源管理功能。
为了提供热更新,流式加载等操作,Unity推出了不仅效率奇低而且流程反人类的AssetBundle系列,这套流程和前边提到的资源管理系统格格不入,比如Prefab嵌套在AssetBundle中会出现奇怪的现象等也不是一天两天的,笔者认为最关键的问题在于Unity企图使用一套系统解决所有的问题,一个AssetBundle既可以做场景异步加载,也可以支持逻辑加载,保罗万象,这就导致资源管理系统定位不明,仿佛做什么都可以但是做什么又都不太行,之后推出的Addressable等插件也是换汤不换药,还是使用这套老的系统,对性能不仅没有雪中送炭,反而是雪上加霜。
客户端Script逻辑
首先是开发语言的问题,C#是一个比较适合游戏开发的语言,但是这并不代表游戏引擎可以只支持C#。Unity在语言选择上的问题在于明明底层都是C++开发,却似乎对C#情有独钟,并勒令所有开发者只能使用C#开发,这就带来了两个问题。第一个问题就是语言编译的问题,在不久以前,Unity还是只支持Mono作为运行时的虚拟机,很显然,Mono的性能相当可怜,而后又发明了IL2CPP通过将C#转换为C++代码,并通过本平台编译器编译,比如在Windows平台使用MSVC。这样做首先大大增加了打包时间,本身以为C++编译慢,结果发现IL2CPP编译的时候大部分时间竟然是花在翻译过程,整个代码部分的编译可以说是超级加倍,然后从一项语言翻译到另一项语言必然会带来许多不可预测的问题,Unity的战术则是用妥协的鸵鸟法解决问题, 对翻译出的代码进行各种保护性措施,所以编译出的最终代码性能与C++依然相去甚远。第二个问题则是GC,由于只能使用C#,也就意味着所有具有面向对象和抽象属性的组件都会被GC影响,如果是纯粹的.Net的GC,对运行性能也不会有太大影响,然而由于Unity在C++层与C#层过度耦合,导致至今为止只能使用最低效最保守的Boehm GC,即使是2019版本之后有了增量式GC,对性能依然有不小的打击。而后官方推出的支持Unsafe的写法更像是一种弃疗手段,虽然Unsafe部分全部使用裸指针,静态编译后也可以“不变味”,但是这也让语言瞬间退化为C语言,甚至连基本的继承多态,RAII都没有办法实现,正可谓“屠龙者最终会比恶龙更可恨”,最初为了克服C++对工程难度的提升,后来却让开发者不得不写出比C++还原始还难维护的代码。
其次是基础架构的落后和先进需求的冲突。基础架构上,Unity对用户是一种极度不信任的状态,恨不得让逻辑架构尽可能的简单到小学生也能学会,比如大量使用反射控制生命周期等手段,让初学者“拖上脚本就能跑”,所有的API都必须在主线程调用,否则都会直接报出大红叉叉以儆效尤,在实际的工程开发中这些“方便”多是在给在客户端工作的程序和策划使绊子,很神奇的是官方一直到数年以后才后知后觉的发现自己引擎内架构产生的问题并开始着手制定DOTS,这时候老系统的用户粘性和历史包袱已经十分沉重,就出现了老系统不好用,新系统用不了的尴尬局势,在这段时间内开发出的项目,代码风格难保不会奇形怪状,一方面既有传统的生命周期的写法,另一方面又有些许的新特性穿插其中,新特性又不得不受限于老包袱,没有办法完全发挥,同时DOTS的案例也缺乏说服力,明明是一套完整的架构,目前看到的案例给人的感觉却都是“用一个很麻烦的方法算了一串数组然后塞到渲染里靠Instance一次性绘制”,甚至没有表现出用户应该花费不小的精力适应新系统的理由。
编辑器与工具流程
Unity的编辑器经常被冠以“好用”“易扩展”的称赞,然而这些称赞在项目变大之后便不再适用。受限于前部分提到的场景资源管理以及Mono VM等问题,Unity编辑器的运行效率实际上非常可怜,一个运算可能比外部打包的程序慢十几倍,而且编辑器和游戏运行时全部使用同一个线程,这就导致打开一个大场景或运行一个工具脚本的逻辑时,整个编辑器基本都是假死状态,有时需要调一个动态效果,那编辑器就是连续不断的卡死 + 执行的状态,令人极度恼火。Editor扩展部分依靠反射获取类型,这就导致Editor类在多人协作的项目里难控制且可读性很差,更不用说还会极大的延长编译时间,降低开发效率。
编辑器作为开发效率的基础保障,带来的危害是持续性的,而Unity以其对非程序人员的不友好闻名于世,故有“不会程序的别想碰Unity”这样看似感性极端但实际却不无道理的说法。作为一个游戏引擎,官方的案例要么是逻辑非常简单的游戏Demo,要么是渲染效果极佳却几乎没法落地到游戏里的渲染Demo,其提供的游戏Demo案例由于逻辑十分简单,没有办法作为工程化的参考,这一点自不必说;渲染Demo则更为过分,这里拿The Book of Dead当例子,任何一个有美术制作经验的开发者都明白,一个类似The Book of Dead这样复杂度和体量的超高质量场景,调好渲染效果和烘焙好光照最多只占1%的精力,剩下99%都是在如何最高效的摆放模型,摆放材质和验证效果并多次迭代,该案例只说了“开发团队只有几十人”并将开发效率归功于刚出的下载下来都不一定能跑得起来的HDRP,而对于制作过程有什么提升效率的工具链则绝口不提,游戏引擎区别于渲染器的根本就在于引擎的编辑器要做到渲染器和DCC的润滑剂,而现实是官方在展出Demo案例时完全没有考虑到这一层,把出效果当成理所当然,全篇都在努力说明“把这个场景拖进去用HDRP调调就能很好看”,对于用户则是一脸懵B,会出现“为什么我的Unity跟他们的不一个软件?”这样的疑惑,这不得不说是官方在教学和引导方面巨大的缺失。
内置渲染管线
内置管线基本就三个字:老,慢,迷。首先是古老,Unity自己也承认内置管线是DX9时代的设计,这样的设计一直没有得到任何形式的重构,而是直到2018年才刚刚推出当时几乎卡的不能用的HDRP,所以在包括现在在内的相当长的一段时间,可以说Unity处于一种老管线没法用,新管线不敢用的状态,更有趣的是Unity管线代码部分其实总量并不多,而在过去的几年里居然始终没有对内置管线更新过任何内容。不由得让人怀疑Unity对渲染效果和渲染性能的对待是否过于轻视,才会导致现在尴尬的局面出现。
然后是慢,内置管线的提交效率可以说是出了名的慢,比如内置的延迟渲染,本来GBuffer就是带宽占用极高的Pass,Unity居然没有对绘制做Depth prepass就直接裸画GBuffer,导致像素重复绘制,Overdraw爆炸,带宽同样也跟着爆炸。而Unity似乎也发现了这个问题,给物体来了一个从前往后的排序,保证先画面前的物体再画后边的,虽然能一定程度的,不彻底的改善Overdraw,但是依旧比Depth Prepass带来的增益小很多。而从前往后排序也是有副作用的,因为这就会让物体不能按照渲染状态进行排序。一般来讲一个API会有管线状态(Pipeline State)的概念,也就是Set Pass Call,对同一个Shader的物体,可以只设置一次Pipeline State并多次提交,在性能上会比多次修改Pipeline State强很多,这种距离排序正是导致了Unity中Drawcall很耗的重要原因之一。
而为了增强重复物体绘制而推出的GPU Instance功能现在看起来也非常的尴尬,在GPU Instance的时候Unity并没有用Compute Shader等稍微现代化一点的手段进行剔除等,而是直接当成普通物体做了一遍逻辑运算,最后再通过收集的方式把相同材质和网格的物体收集起来进行GPU Instance,因此许多时候对渲染方向研究不深的开发者会发现明明已经打开了GPU Instance,提交消耗依然高到窒息。
迷,具体表现在内置管线奇怪的行为上,比如Stencil Buffer会在Deferred Pass以后被莫名其妙的清空,这时候如果企图对Stencil Buffer进行读写,都会是失败的,在需要实现效果时因为这种问题导致效果的最后几步做不出来,开发人员的心里想必比吃了shi还要难受。更不用说内置include文件匮乏的介绍和文档,对每个入门渲染的新手都是非常劝退,各种奇奇怪怪的宏定义查不到是家常便饭,查到了是好运降临。如果开发者不幸用到了没有说明的奇怪调用,出现任何问题难以预测。
可编程渲染管线
笔者作为国内最早一批尝试使用Scriptor Rendering Pipeline开发高端平台渲染管线的开发者,曾经在文档都完全没有补齐的情况下开始了SRP的开发,而且是完全从零开始,从纯色方块开始开发,经历了极大的痛楚,并且最终在美术小伙伴的帮助下完成了一个效果看起来还凑活的工作:
当然,这些痛苦并非来自于文档缺失,因为越接近渲染底层功能越单一,因此大多数功能可以通过函数名就知道用处,而这些痛苦的主要来源是并没有被完全改善的老旧的渲染体系与一套全新的渲染理念的剧烈冲突,说的俗一点就是:烂摊子还没收拾好先想着搞事情。Unity内置管线的许多问题都被保留下来,比如Per Object Data的定制性严重受限,想要手动控制哪些物体走不同的绘制路径,只能通过改层,改材质这种对工程维护性破坏很大而且限制也同样很大的方法,甚至最基本的RenderTexture类都没有整理清楚,一个RenderTexture居然可以同时持有ColorBuffer和DepthBuffer,Color和Buffer在图形API层都是完全分开的两个资源,相当于强行把两个RenderTexture并到一个上。Mesh作为一个一维的数据格式,居然会和Compute Buffer这两个本是同根生的资源完全分家,以至于要想写个粒子特效的Shader,甚至需要在顶点Shader中手动读取Buffer。
撇开这些开发上的问题不谈,SRP在性能方面也有很大的缺陷,其中最大的缺陷在于线程并行度极差。这对于核心较多但单核心性能薄弱的主机平台是十分致命的,而Unity官方的说法是:“SRP非常适合现代高端平台图形API”并且在2018年推出了一套基于DirectX 12的HDRP渲染Demo,笔者则怀着好奇的心情亲自学习并尝试了一下在DirectX 12实现多线程并行的架构,经过对比发现了SRP最大的问题在于:工作线程永远是当帧同步且所有API都必须在主线程调用。目前无论是提到的RenderTexture等类型,还是基础提交的CommandBuffer,都只能在主线程执行,这就意味着Job System必须等待主线程或者主线程必须等待Job System,没有完全建立一个单独的逻辑层的并行,也就意味着这并没有达到最初Job System和SRP宣传的那样的效果。
同样,Shaderlab的落后性也导致使用SRP编写渲染管线时大量新功能没有办法使用,以至于开发者不得不写出Dx11 on Dx12和OpenGL on Vulkan的实现,所谓的“支持”可以说纯粹就是用兼容模式能跑得起来,而非实质的技术进步,这也是导致了SRP在更老旧的API可能跑的还比现代API速度更快的原因之一。对于Unity所谓的“支持”,一句话概括就是:
总结
通过分析以上缺陷,可以看出目前的Unity在宏观上主要问题有:常常尝试用一套系统解决多个不相关的问题,对老旧的系统更新不及时,以及制作功能时很少面向实际工程,未来的Unity如果想在继续闭源的情况下解决上述问题,势必要有一个官方自主研发的完整且体量够大的游戏项目来验证和改善新的架构和技术,并抱着壮士断腕的决心干掉现有老旧技术。