Game Programming Patterns: #1
date
May 19, 2023
slug
gpp1
status
Published
tags
Program
summary
Architecture, Performance, and Games
type
Post
游戏编程模式 #1:架构,性能与游戏
前言
最近有在阅读Game Programming Patterns 一书。为了不枉费阅读、思考与总结的过程,决定将阅读过程中一些零散的思考和总结写成文字进行记录,将他们系统地记录下来。需要注意的是,本系列文章不单纯只是读书笔记,除了记录作者的含义之外,更多的是以个人理解对文章的剖析解读,某些观点可能理解不准确,希望各位多多指教。
系列文章:
Game Programming Patterns 其书
原书是英文原版,Game Programming Patterns,我把它译为《游戏编程模式》,后文称之为GPP。正如其名,它是一本专注于游戏编程领域的设计模式指南,它涵盖了游戏逻辑,游戏编辑器,和游戏引擎的编程中的常用技法。作者 Robert Nystrom 有二十年的从业经验,在 EA 工作 8 年有余。
"这本书将游戏开发中经常涉及到的编程模式拎出来,结合具体开发中遇到的实例一步步的引出对应的模式。"
- Web版全文阅读:Game Programming Patterns
- Web版中译:《游戏编程模式》
- 作者Twitter(@munificentbob)
软件架构
何为好的软件架构
“好的设计意味着当我作出改动,整个程序就好像正等着这种改动。我可以仅调用几个函数就完成任务,而代码库本身无需改动。”
这听起来很厉害,可实行起来很难很难。这样太理想化了,让我们回到架构,架构是关于变化的:总需要有人去修改代码,如果没有人去修改代码,那么架构的设计就失去了它的意义。评价架构设计的好坏就是评价它应对改动有多么轻松。
轻松应对变化,这就是好的软件架构的主要优点之一。
如何实现一个新的特性
当你修改代码添加新特性或是修复漏洞的时候,你需要理解当前的代码是在做什么。当然,不需要理解全部的程序,只需要理解对应相关的东西就好了。
通常我们会忽略这一步,但这往往是编程中最耗时的部分。
但一旦把所有正确的上下文都记到了你的大脑里, 只需要思考一会,就很轻松的找到解决方案了。有时候可能也需要反复斟酌,但通常不会很复杂。一旦理解了问题和需要改动的代码,实际的编码工作有时是非常轻松的。
当你成功添加特性/修改之后,在为之写测试并发送到代码评审之前,你需要清理代码。新修改的加入代码也许多多少少会出现一些小问题,你需要做一些微调新代码的工作,尽可能让其无缝对接到程序的其他部分。当你的代码足够干净的时候,那么下一个编写代码的人会难以察觉哪些代码是新进入的。
编程的流程图看起来是这样的
PS:看起来就特别像一个死循环(笑)
解耦与学习阶段
其实,很多软件架构都和学习阶段(learning phase)息息相关。 将代码载入到神经元太过缓慢,找些策略减少载入的总量是很值得做的事。GPP中有整整一章是关于解耦模式,还有很多设计模式是关于同样的主题。
解耦
为什么要解耦?前文有说到,在修改之前,需要理解相关的代码,而如果有两块代码是耦合的,就意味这不能只理解其中一个。如果解耦了它们,就可以单独地立即某一块。这样就减少了思考的工作量。我认为,这是软件构架的关键目标:最小化在编写代码前需要了解的信息。
从另一个角度来看,在后期工作中,需要修改一些代码,当两块代码耦合有耦合时,可能就不能只修改其中的一块。所以解耦的另一种定义是:当一块代码有改动时,不需要修改另一块代码。 肯定也得修改一些东西,但耦合程度越小,改动会波及的范围就越小。
过度设计的代价
解耦所有的代码,然后随心所欲的编码。每个改动只需要修改一两个特点的方法,就能完成编码。这听起来真的很酷。
这就是抽象、模块化、设计模式和软件构架让人兴奋的原因。在有好架构的程序上工作是很好的体验,每个人都希望能更有效率地工作。好架构能造成生产力上巨大的不同。
但是好的设计同样需要一些代价。每次改动或是实现特性,需要你把它规范的集成到程序的其他部分,花费大量努力去管理代码,使得程序在无数修改变化中依旧能保持它的架构。就像园艺,仅仅种植是不够的,还需要除草和修剪。
比如:你得考虑程序的哪部分需要解耦,然后再引入抽象。 同样,你需要决定哪部分能支持扩展来应对以后的更新修改。(所谓的面向未来编程)每当你添加了抽象或者扩展支持,你就是在赌以后这里需要灵活性。 你向游戏中添加的代码和复杂性是需要时间来开发、调试和维护的。如果你用上了,前期的努力就是有用的,如果没用上那么除了白费功夫之外,你还得花费时间处理多出的代码。
如此这般,后来的代码库就将会无比快放、功能极为强大、扩展性也极强。但是过度设计之后,容易得到失控的代码库:接口和抽象无处不在,插件系统、抽象基类、虚方法,还有各种各样的拓展点。
但当你真正需要添加东西的时候,你需要花费大量时间去寻找所谓的接口,找到真正需要用到的代码,能不能找到可能都是未知数。理论上,解耦意味着修改代码前只需要了解较少的代码,但是抽象层需要你更多的理解。
过度去关注设计模式和软件架构,会让人很容易地沉浸在代码中,而忽略要自己的最终目的是要发布游戏。无数的开发者听着加强可扩展性的“警世名言”,花费多年时间制作“引擎”,却没有搞清楚做引擎是为了什么。
性能和速度
构架和抽象有可能会影响性能和速度,这在游戏开发中尤甚。许多让代码更灵活的模式依靠虚拟调度、 接口、 指针、 消息和其他机制, 它们都会加大运行时开销。
举个反面例子:C++中的模板。
模板编程有时可以给你抽象接口而无需运行时开销。
这是灵活性的两极。当写代码调用类中的具体方法时,你已经硬编码了调用的是哪个类;但通过虚方法或接口,直到运行时才知道调用的类。虽然这样更加灵活,但增加了运行时开销。
而模板编程是在两者之间——在编译时初始化模板,决定调用哪些类。
很多软件架构的目的是使程序更加灵活。这让修改它时能更轻松,编码时对程序更少的假设。你可以使用接口,让代码可与任何实现它的类交互,而不仅仅是现在写的类。灵活性可以让我们快速改进游戏。
让你的程序更加灵活,在损失一点点性能的前提下更快地做出原型。但需要注意,优化现有的代码可能会让代码丧失原有的灵活性。
而一种折中的办法是保持代码灵活直到设计定下来,再抽出抽象层来提高性能。
烂代码在开发时的优势
不同风格的代码各有千秋,本书大部分内容有关保持干净可控的代码,作者也倾向于正确的代码风格。但“烂代码”也有它的优势——快。
编写保持良好架构的习惯很好,但是这是一个耗时耗力的过程。游戏编程是一个探索和试验的过程,添加或修改特性的时候经常需要修改,优秀的设计,产生效果的时效出现的会更久,编写花费的时间和功夫也会更多。这时候潦草代码的优势就体现出来了,在原型开发阶段,“烂代码”能帮助我们快速成型构建实验新的特性。
但是,烂的代码也不能从头烂到尾,不可维护的代码是没有意义的,在优化上会花费更多的时间,高度优化的代码不灵活,很难改动。所以在在原型设计完成之后一定要重写或重构“烂代码”。
动态平衡
开发周期中需要取得平衡的三大要素:
- 为了在项目的整个生命周期保持其可读性,我们需要好的架构。
- 运行时的性能要足够的好。
- 需要让现在的特性快速地实现。
三者本质上都是速度:长期开发的速度;运行时的速度;短期开发的速度。
但三者是相互对立的:
好的架构意味着保持设计花费的时间更多,也就是短期开发速度变慢。
对特性快速的实现可能会让代码运行时的性能可能不是很好,运行时的速度会变慢。
运行时性能足够好的代码,也就是优化高度优化的代码,不够灵活,不好更改,长期维护的速度会变慢。
对于这个三者的权衡,没有简单明了的解决方案,只有具体问题具体分析,按实际的项目状况去去权衡,让三者保持友好的动态平衡,让整个项目保持良好的状态。