Game Programming Patterns -- Architecture, Performance, and Games
原文地址:http://gameprogrammingpatterns.com/architecture-performance-and-games.html
原作者:Robert Nystrom
原创翻译,转载请注明出处
在我们一头扎进模式的大坑之前,我想给你们介绍一些我所理解的有关软件架构以及其如何应用在游戏中的知识,这对你们应该有所帮助。这些知识可以帮你们更好地理解本书接下来的部分。别的不说,当你被拖进一场关于设计模式和软件架构是多糟糕(或多棒)的争论中时,这些知识至少会给你提供一些可用的弹药。
注意,我并不管你们在这场战斗中站在了哪一边。和每一个武器商一样,我的武器对任何一方的战士都有出售。
什么是软件架构
如果你读完了这本书,你并不会得到任何在3D图形学中使用到的线性代数知识或者在游戏物理中使用到的微积分知识。本书也同样不会告诉你如何在你的AI搜索树中使用alpha-beta剪枝算法或者如何在你的音频播放中模拟房间混响效果。
Wow,这一段真是给本书打了一个糟糕的广告。
不过,本书中的代码在上述所有的领域中都会出现。相对于如何写代码,本书更关注的是如何去组织代码。每一个程序都对代码有一定的组织,即使只是“把所有的东西都堆到main()中然后看看到底会发生什么”,所以我想,聊一聊如何对代码做出好的组织一定会更有趣。那么如何区分出架构的好坏呢?
我已经思索了这个问题将近5年的时间。当然,和你们一样,我也有能够辨认出好的设计的直觉。我们都经历过糟糕的代码库,那个时候最想做的应该就是把这些代码全部干掉,好让它们从痛苦中解脱出来。
不得不承认,我们中的大多数要多少为上面这种情况负一些责任。
而有一些幸运的人们却有着完全不同的经历,他们有机会去使用一些有着优秀设计的代码。这种优秀的代码库感觉起来就像是一间奢华的酒店,里面的服务员们都殷勤地等待着为你的每一个心血来潮的念头服务。那么这两种代码之间的区别究竟是什么呢?
什么是好的软件架构
对于我来说,好的设计意味着每当我做出修改的时候,整个程序就像在设计时就已经预料到了我的这次修改一样。在解决一个问题时,我只需要用到几个可以完美嵌入代码库中的函数,不会让代码库平静的水面溅起一点涟漪。
“你只是在写自己那部分的代码,所以这种修改不会打扰到代码库平静的水面。”好吧,这听起来很棒,但其实并不是那么可行。
来让我稍微分析一下这是为什么吧。最关键的一点就是,架构所要考虑的正是改动。总是会有人需要修改代码库的。如果没有人会去修改代码库--不管是因为它太完善和完美了,还是因为它糟糕到没有人愿意打开以免玷污了自己的文本编辑器--那么它的设计都是无关痛痒的。衡量一个架构设计好坏的标准其实很简单,那就是它对改动的适应性。一个没有改动的架构,就像一个从来没有离开过起跑线的赛跑选手。
如何做出改动
在你开始修改代码之前(不管是添加新功能、修改bug或者任何导致你打开编辑器的原因),你必须要搞清楚现有的代码在做些什么。当然,你不需要搞清楚整个项目,但是你需要把你所要修改的地方所有相关的部分装进你灵长类的脑子里。
不可思议的是,这看起来正是一个OCR(Optical Character Recognition 光学字符识别?)的过程
我们常常掩饰这个步骤,但其实它通常是编程工作中最为耗时的那个部分。 如果你认为把数组从硬盘分页到RAM中很慢的话,可以试试把它通过视神经传进一个类人猿的脑子里。
当你将所有正确的内容装入你的脑中之后,你就可以开始思考并找出你的解决方案了。这个过程可能是曲折的,但是相对来说总是往好的方向前进的。当你搞清楚了你的问题和它所要使用到代码之后,接下来的编程工作可能就非常容易了。
你用你肉肉的手指在键盘上敲打一阵,等到颜色正确的灯在屏幕上闪起的时候你就完成了,对吗?仅仅这样还不够!在你的写入测试完成和把它发送给代码评审之前,你通常还有一些善后工作要完成。
我是说了“测试”吗?哦,是的,我是说了。为游戏代码编写单元测试是很困难的,但是对于代码库来说,大部分代码都是可测试的。
我不会在这里开始一段关于测试的长篇大论,但是如果你还没有使用一些自动化测试方法的话,我建议你要开始考虑使用它们了。难道你没有比一遍又一遍手动地测试代码更有意义的事情做了么?
简而言之,敲代码的流程图看起来大概就和下面这张图差不多:
这个循环是没有出口的,我想起来它的时候不禁有些担忧。
解耦会有什么帮助呢?
虽然不是很明显,但是我认为,很多的软件架构都是关于解耦的。把代码装进脑袋里是一个痛苦且缓慢的过程,因为它需要花费时间去寻找削减代码体量的策略。本书有一整个章节都是描述用来解耦的模式的,《设计模式》中也有很大一部分在描述解耦的相关思想。
你可以给“解耦”下很多种定义,而如果两块代码一旦耦合了,那就意味着你不能只了解其中一块代码而不去了解另一块。如果你把它们解耦了,那你就可以分开来考虑其中的每一块代码。这是很棒的,因为如果这些代码中的其中一块和你的问题相关,你只需要把这一块装进你猴子一样的大脑里而不需要考虑另外一块。
对于我来说,这就是软件架构要实现的关键目标:在你开始你的工作进度之前,最小化你所需要装进脑袋里的知识量。
这在后期的工作中同样也会起到作用。解耦的另一个定义是对一块代码的修改并不一定需要修改另外一块。很明显我们需要去修改一些都系,但是越少的耦合,意味着之后工作中越少的修改。
需要付出什么代价
这听起来很棒,对吗?把一切解耦,你就可以像风一样飘逸地敲代码。每次修改意味着只要接触仅有的一到两个方法,你可以神出鬼没般从代码库上飘过。
这就是人们为什么这么热衷于抽象、模块化、设计模式和软件架构的原因。在一个架构良好的项目里工作真的是一个非常令人愉悦的体验,每个人都喜欢工作更有效率。好的架构可以给生产力带来巨大的改变,一点也不夸张地说,它可以带来非常深远的影响。
但是,就像我们生命中的其他事情一样,这不是免费的。好的架构需要花费很多的努力和磨练。每一次你做出修改或者实现新功能的时候,你都需要花费很大的功夫去把它优雅地集成到项目的其他部分中去。你需要非常注意组织你的新代码,而且需要在你之后的开发周期中可能出现的成千上万的改动中维护它们的组织化。
这里的第二部分--维护你的设计--需要格外地注意。我见过很多开始时很不错的项目最终死于程序员们一次又一次的“只是一点小修改”上。
就像园艺一样,只是种新的植物是不够的。你还必须要除草和修剪。
你需要考虑项目的哪些部分是应该解耦的,你要在那些点上引入抽象。同样的,你也需要哪里的扩展性是需要被设计的,以使得未来的修改更加容易。
人们对这件事感到非常的兴奋。他们想象未来的开发者们(可能只是未来的他们自己)进入代码库之后,发现它时这样的开放、功能强大,就在那等着被扩展。他们想象使用一个游戏引擎去管理所有的游戏。
但是事情从这里开始变得微妙起来。每当你想要添加一个抽象层或者给一处代码增加扩展性支持的时候,你是在推测你以后会用到这样的灵活性。但这也是在给你的游戏增加额外的代码和复杂度,而这些都是需要花费时间去开发、debug和维护的。
如果你的猜测是正确的,而且之后也不会去修改这块代码的话,那么你的努力就是值得的。但是预测未来是很难的,当那个模块不再有帮助的时候,它很快就会变得非常有危害。最终,它会导致你需要去应付更多的代码。
一些人杜撰了“YAGNI”这个词--You aren't gonna need it(你不会需要它的)--作为一个咒语去遏制想要预测自己未来的渴望。
当大家在这件事上热衷过头的时候,你会得到一个架构被扭曲到超出控制的代码库。这种代码库里到处都是接口和抽象。同样也存在着大量的插件系统、抽象基类、虚方法,以及各种各样的扩展点。
这样你将一直把时间花费在从框架代码中寻找真正的功能性代码。当你需要作出改动的时候,当然,这里可能会有一个接口给你提供帮助,但前提是你能足够幸运地找到它。理论上来说,解耦意味着你可以在扩展代码之前了解较少的代码,但实际上抽象层里的代码最终将填满你大脑里的硬盘。
这种类型的代码库使得人们反感软件架构,尤其是设计模式。它让你很容易陷入到代码之中,而忘记了你其实只是想发售一款游戏。这首有关于扩展性的塞壬之歌卷入了无数花费多年时间在一个引擎上工作却从来不知道这个引擎是干什么的开发者。
性能和速度
还有另外一种对软件架构和抽象持批评观点的说法,在游戏开发中经常会听到:会降低游戏的性能。许多让你的代码更灵活的模式一般都依赖于虚拟派发、接口、指针、消息以及其他会在运行时有性能消耗的机制。
一个有趣的相反的例子是C++中的模板。模板元编程有些情况下能让你使用抽象接口而在运行时没有任何额外的消耗。
这是一个有关于灵活性的范畴。当你在一个类中调用一个具体方法时,那么你就是在编写阶段就把那个类定死了--也就是说你是在硬编码这个类。而当你使用虚方法或者接口时,这个被调用的类在运行之前是不确定的。这当然是更灵活的,但是带来了一些运行时的消耗。
模板元编程在介于这两者之间。你将在编译阶段模板被实例化时决定哪个类将被调用。
不过这样做是有原因的。大部分的软件架构都是在让你的项目更加灵活。它们让你的项目在修改时付出的代价更少。这意味着在项目中敲代码时会有比较少的假设。使用接口可以让你的代码在任何实现它的类里正常工作,而不是为了功能临时新创建一个类。使用观察者模式和消息模式让游戏中的两部分可以互通消息,以后也可以很容易地扩展到三个或四个部分互通。
但是性能表现其实基本上都是假设。优化的习惯来自于具体的限制。我们可以假设我们永远不会拥有超过256个敌人吗?这样的话,我们可以把一个ID封装到一个单字节里。我们只会在一个具体的类里调用某种方法么?好的,这样我们就可以把这个方法写死或者内嵌在这个类里。所有的实例都是同一个类?非常好,这样我们就能使用连续数组了。
但这并不意味着灵活性是不好的!它让我们可以快速地修改游戏,而开发速度对于获得有趣的游戏体验来说绝对是非常重要的。没有任何一个人,即使是Will Wright(模拟城市、模拟人生等游戏的创造者),可以在纸上就把一个非常平衡的游戏设计出来。这是一个需要迭代和试验的过程。
如果你越快地尝试新的点子并体验它们,那么相同的时间里你就可以更多地尝试,这样你就越可能发现一些很棒的东西。即使你已经找到正确的游戏机制,你仍然需要大量的时间去优化。一处小小的不平衡也会毁掉整个游戏的乐趣。
这里没有一个简单的答案。让你的项目具有更多的灵活性可以让你在游戏创作时更快速,但是需要消耗一定的性能。同样的,优化你的代码会使得它缺少一定的灵活性。
而我的经验是,更快地制作出一款有趣的游戏要比让一款快速制作出的游戏变的有趣要更容易。一种折衷的方案是,在设计完成之前让代码保持灵活性,之后剔除一些抽象概念去优化游戏的性能。
坏代码中的优点
这就引入了下一个问题,无论何时何地,总会有不同类型的代码存在。本书的大部分都是在描述如何写出可维护、纯净的代码,所以我所拥护的很清楚,就是用“正确”的方法去做事情,不过那些草草写出的代码中有些东西也是有参考价值的。
编写拥有良好架构的代码需要考虑得很仔细,而这就需要消耗时间。而且,在项目的生命周期内维护一个好的架构是需要付出很多努力的。你需要像露营者对待他们的营地一样对待你的代码库:总是尝试在离开时把它变得比你找到它时更好。
如果这些代码是你需要长期使用的话,那么这么做是很好的。但是,就像我之前提到的那样,游戏设计需要大量的试验和探索。尤其是在开发的前期阶段,经常会写一些你“明知道”之后会扔掉的代码。
如果你只是想找出一个游戏点子玩起来是怎么样的,对它进行好的架构意味着在它可以在屏幕上运行以及你可以获得一些反馈之前需要花费大量的时间。如果这个点子最终不可行的话,那么用来把代码变得优雅地时间就随着你把它删除而浪费了。
原型设计--把一些仅仅完成了设计问题中的功能的方法拼凑在一起--是一种非常合理的编程实践模式。但是这种方式存在很大的问题。如果你写了一些以后准备抛弃的代码,那你必须确认你以后能够把它们抛弃掉。我曾经见过一些不好的管理者一遍又一遍地玩这样一个游戏:
老板:“嘿,我们有了一个想法想要尝试一下。只要做一个原型就可以,不用考虑需要把它做的多完善。你多快可以把这东西弄出来?”
*开发者:“好的,如果一些细微的功能不管,不需要测试,不需要写文档,而且可能会有一堆bug的情况下,几天内我就能给出一些完成功能的临时代码。”
*老板:“太棒了!”
几天过后...
老板:“嘿,那个原型很棒。你能花几个小时把它处理一下,让它成为正式的东西么?”
有一个保证你的原型代码不会成为最终使用的代码的小技巧,就是用和你制作游戏不同的语言去编写它。这样,你就可以重写它,因而避免它最终存在于你的游戏中。
你需要确认使用这种临时代码的人明白一件事,即使临时代码看起来好像是可以正常工作的,但是它是不可维护的而且必须要被重写。如果你有任何可能会继续使用它,那么你最好还是在写的时候就严谨一些比较好。
寻求平衡
我们有以下几个方面需要考虑:
我们想要一个好的架构,这样代码在项目的整个生命周期都会很容易地去理解。
我们想要运行时的高性能。
我们想要当前的功能能尽快完成。
我认为这是非常有趣的,因为这些方面都关系到了某一种速度:我们的长期开发速度、游戏的运行速度、我们的短期开发速度。
这几个方面至少在它们的某些部分是相互对立的。好的架构提高了长期的开发效率,但是这意味着每次修改都需要多付出一些努力去维护架构。
最快写出的功能实现往往不是可以最快运行的。相反的是,优化它需要花费可观的开发时间。当优化完成后,整个代码库就会变得僵硬:高度优化的代码往往是不灵活的,而且修改起来会很困难。
现实中总会有今日事今日毕和担心明天所要完成的工作的压力。但是如果我们填鸭式地尽我们所能地快速完成功能,我们的代码库就会变得充满漏洞、bug和不一致性,而这些都会影响我们未来的开发效率。
这里没有一个简单的答案,需要的是取舍。从我收到的email来看,这让很多人感到沮丧。特别是那些想做游戏的新手们,他们最怕听到的就是,“这个没有正确答案,只有不同形式的错误的解决方法。”
但是,对我来说,这是令人兴奋的!看看其他那些人们奉献了自己的整个职业生涯去掌握的领域,你会发现那里总是充满了各种错综复杂的问题。毕竟,如果问题有一个简单的答案的话,所有人都会去照着这样去做。一个你一周就能掌握的领域是最最令人感到无趣的。你应该从来都没听说过某人因为挖沟的职业生涯而出名吧。
好吧,可能你听过;我对这个方面没什么研究。就我所知,这个世界上可能有狂热的挖沟业余爱好者,挖沟交流大会以及有关于挖沟的整个亚文化产业。我该选哪一个?
在我看来,这和游戏本身有着很多相同之处。一个像国际象棋这样的游戏永远不会被完全掌握,因为它的每一部分都和另一个部分有着完美的平衡。这意味着你能用尽一生的时间去探索所有可行的策略。而一个设计糟糕的游戏通常毁于唯一一种胜利战术被一遍又一遍地使用,直到你感到厌烦而最终弃坑。
简单性
后来,我在想,如果有一种方法可以解决这些问题的话,那它一定就是简单性了。在如今我的代码中,我会非常努力地去尝试写出最干净,最直接解决问题的代码。这种类型的代码,在你读过它之后,你就会很清楚它是用来干嘛的,而且再也想不出还有其它可能的解决方案。
我的目标是保证数据结构和算法正确(大概是这个顺序)然后以此为起点。我发现如果我可以保持事情简单的话,代码总体上也会变少。这意味着我想做出改变的时候可以少装一些代码到我的脑子里。
这种代码一般运行起来都很快,因为一切都保持简单性,没有太多的消耗,也没有很多需要运行的代码。(当然,并不总是这样的情况。你可能在一小段代码里封装了一堆循环和递归。)
不过,这里请注意,我并没有说简单的代码可以花更少的时间去写。你可能这么想过,因为你用更少的代码完成了项目,但是一个好的解决方案并不是由于代码的添加,而是对因为对代码的精炼。
Blaise Pascal一封很著名的信的结尾是,“我本想写一封更短的信,但是我没时间了。”
另一个被引用的句子来自于Antoine de Saint-Exupery:“完美的达成,不是因为没有更多的东西可以被添加了,而是因为没有东西可以被移除了。”
言归正传,我注意到每一次我修订本书中的一个章节,它都会变得更短。一些章节在它们完成之时被精简了大概20%。
我们很少会面对一个优雅的问题。取而代之的是一大堆的用例。就是你想要X在Z的情况下去做Y,但是在A的情况下要做W之类的东西。换句话说就是,一个长长的不同例子行为的列表。
最省心的解决方案就是为每一个用例编写代码。如果你看那些新手程序员工作的话,你会发现这就是他们经常在做的:为每一个他们想到的用例大量编写一堆的条件逻辑。
但是这样做是很不优雅地,而且这种类型的代码在被提供的输入数据和设计时有一点点不同的时候都有可能发生问题。当我们在思考优雅的解决方案时,我们往往能想到的就是最常见的那一种:一小段逻辑,在很多用例中使用时都是正确的。
寻找这种解决方案的过程有点像模式匹配和解谜。这需要从分散的用例中努力地看穿隐藏在它们表面下的规律。当你找到它的时候,感觉会很棒。
准备工作已经完成
几乎所有人都跳过了这些引导章节,所以我要恭喜你一直来到了这里。对于你的耐心我没有更多的可以回报,我所能给你的是一些我觉得可能会对你有帮助的建议:
抽象和解耦会让推进你的项目更快更简单,但是除非你确定某个问题中的代码需要这样的灵活性,否则不要在这上面浪费时间。
有关性能的设计需要在你的整个开发周期中都有所考虑,但是那些底层和细节上的优化,因为可能会把一些特定的假设写入代码中,所以还是越晚进行越好。
相信我,在你的游戏发售时间的两个月之前,你都不需要担心“游戏只能跑到1帧”这个小问题。
尽量快地去探索你的游戏的设计空间,但是要在保证速度的同时确定没有留下一堆坑。毕竟你以后还是要靠它吃饭的。
如果你准备丢弃某段代码的话,那就不要浪费时间去把它写的很漂亮。摇滚明星们通常会把酒店房间弄的很乱,因为他们知道第二天就要退房了。
不过,最重要的就是,如果你想做出一些有趣的东西的话,那就享受做出它的过程吧。
因为水平有限,翻译的文字会有不妥之处,欢迎大家指正
“本译文仅供个人研习、欣赏语言之用,谢绝任何转载及用于任何商业用途。本译文所涉法律后果均由本人承担。本人同意平台在接获有关著作权人的通知后,删除文章。”