Game Programming Patterns 游戏编程模式

平生不笑

贡献于2015-03-30

字数:0 关键词: 游戏开发

1. Introduction 2. 介绍 i. 架构,性能和游戏 3. 再探设计模式 i. 命令模式 ii. 享元模式 iii. 观察者模式 iv. 原型模式 v. 单例模式 vi. 状态模式 4. 序列模式 i. 双缓冲 ii. 游戏循环 iii. 更新方法 5. 行为模式 i. 字节码 ii. 子类沙盒 iii. 对象类型 6. 解耦模式 i. 组件 ii. 事件队列 iii. 服务定位器 7. 优化模式 i. 数据局部性 ii. 脏标记 iii. 对象池 iv. 空间划分 Table of Contents Gitbook地址 你是否还在为代码整体规划而苦苦挣扎? 是否发现随着代码库的增长却不容易做出些改动? 是否感觉到你的游戏就是一个纷乱交杂的巨大的毛球? 又或者不知如何将设计模式应用到游戏? 听说过“缓存一致性”和“对象池”,但却不知道如何使用它们来提升你的游戏的性能? 你们的救星来啦!我撰写了这本书来解答这些问题。这是我在游戏中所使用的模式总结,这些模式能让我们的代码更整洁, 更清晰易懂,以及运行更快! 当我开始编写游戏时,我希望我有一本这样的书。现在,我希望你能够有一本。 开始阅读 我叫 Bob Nystrom。当我在EA工作的时候,我便开始写这本书了。在EA工作的8年时间里,我 见过很多优美的代码,也见过很多着实可怕的代码。我希望我能够将我从这些优美的代码设计中 学到的东西,在这里写下来,并教给大家如何写出这样好的代码来。 如果你想要联系我,你可以在网站写email给我,或者直接在 twitter 上 @munificentbob 都可 以。 在线写作书籍的一大好处就是方便修改。如果你发现了错误或者有什么建议,不要犹豫,给我报 告bug或者发送一个 pull request。 介绍 架构,性能和游戏 再探设计模式 命令模式 享元模式 观察者模式 原型模式 单例模式 状态模式 序列模式 Game-Programming-Patterns-CN 游戏编程模式中文 嘿,游戏开发伙伴们! 免费在线阅读 我是谁? 反馈 目录 双缓冲 游戏循环 更新方法 行为模式 字节码 子类沙盒 对象类型 解耦模式 组件 事件队列 服务定位器 优化模式 数据局部性 脏标记 对象池 空间划分 ========================== 欢迎朋友们阅读并斧正,提交 Issue 或者 send pull request :) 。 能有所收获,便是我们翻译中文的意义所在。 翻译:(目前已经都领取完毕) 在Issue列表中查看尚未领取的章节 然后在Issue1中留言回复,我会更新Issue列表状态。 Fork项目后开始翻译,提交PR。 校正:由于能力所及,总有些地方翻译欠妥,所以校正会一直进行中,朋友们看到有翻译错误,可以在 Issue列表 中新 建 issue 提出来,更鼓励欢迎直接发送 pull request。 术语参考 注:所有贡献人的名字都会在此列出,欢迎大家踊跃参与翻译、校正。 子龙山人 kislyl Henry-T lazyqiang zhizhen Gwill Tsiannian coneo Gizmosir jptiancai ChildhoodAndy 翻译 如何参与? 参考资料 贡献人列表(排名不分先后) 在五年级的时候,我和我的小伙伴们被获准使用一个容放着一些非常破旧的 TRS-80s (译者注:见维基百科TRS-80s) 的 废弃教室。为了激励我们,一个老师找到了一些简单的 BASIC 程序的打印输出让我们鼓捣玩耍。 电脑上的音频盒式磁带驱动器当时是坏掉的,所以每次我们想要运行一些代码的时候,我们不得不仔细的从头开始键入。这 使得我们更喜欢那些只有几行代码的程序: 10 PRINT "BOBBY IS RADICAL!!!" 20 GOTO 10 注解 如果计算机打印足够多的次数,或许它会神奇的变成现实哦。(译者注:这里指的是计算机反复打印第10行代码的语 句 BOBBY IS RADICAL!!! ,作者开玩笑的说会变成现实。) 即便如此,整个过程还是比较艰辛。我们不懂得如何去编程,所以一个小的语法错误便让我们感到很费解。程序出毛病是家 常便饭,而那是我们只能重头再来。 在有了这些小程序的经验积累之后,我们遇到了个大BOSS:一个代码密密麻麻占去好几页纸的程序。我们光是鼓起勇气决定 去尝试它就花了不少时间,光是它的标题“隧道与巨人”("Tunnels and Trolls")就令人捉摸不透。这听起来像是个游戏,而还 有什么事比亲手编写一款电脑游戏更酷? 我们从没让它实际运行起来过。一年后,我们搬出了那个教室。(后来随着我更多地接触BASIC,才意识到它只是一个为桌 面游戏使用的角色生成器,而并非整个游戏。)但木已成舟,从那之后,我立志要成为一个游戏开发者。 在我十几岁时,我的家人搞了一台装有 QuickBASIC 的 Macintosh,之后又装了 THINK C。我几乎整个暑假都在那上面倒腾 游戏。自学是缓慢而痛苦的。我希望能让程序轻松地跑一些功能-一张地图或者是个小的猜谜游戏-但是随着程序的扩大,这 越来越难了。 注解 我的许多夏天都是在路易斯安那州南部的沼泽中捕蛇和乌龟来渡过的。如果户外不是这样酷热,很有可能,这将是一 本爬虫学的书,而不是编程书。 起初,我的挑战在于让程序跑起来。后来,我开始思考如何让程序做一些超越我脑袋所想的工作。除了阅读一些关于“如何用 C++编程”的书籍,我开始试图寻找一些关于如何组织程序的书籍。 又过了几年,一个朋友给了我一本书:《设计模式:可复用面向对象软件的基础》。终于来了!这就是我从青少年开始便一 直寻找的那本书!我们刚碰面,我就把书从头到尾读了一遍。我之前仍然在为我自己写的程序挣扎犯愁,但是看到别人也如此 挣扎并提出了解决方案,我便解脱了。我感觉我终于有了一些武器用来挥舞而不再是赤手空拳了。 注解 这是我们第一次见面,5分钟自我介绍之后,我坐在他的沙发上,并在接下来的几个小时里,我聚精会神地阅读而完全 忽视了他。我感觉那时候自己的社交能力还是稍有那么一丁点提升的。 在2001年,我得到了我梦想中的工作:EA(Electronic Arts) 的软件工程师。我迫不及待的想看下真正的游戏,以及工程师是 如何组织它们的。像 Madden Football 这样的大型游戏到底是一个什么样的架构?他们是怎么让一套代码库在不同平台上运 行的? 破解开放的源码是一个震撼人心和令人惊奇的体验。图形、人工智能、动画和视觉效果等各个方面的代码都十分出众。我们 公司有人懂得如何榨取CPU的每一个周期并得以善用。甚至一些我认为不可能的东西,这些家伙一个早上就能搞定。 但这种优秀代码的结构往往是事后想出来的。他们太专注于功能以至于忽视了组织架构。模块之间耦合很严重。只要是有作 序言 为的新功能都会被扔进代码库里。我的幻想破灭了,在我看来,恐怕很多程序员,从没翻过设计模式,不曾了解单例模式。 当然,实际并非想象的那么糟。我曾设想游戏程序员们坐在放满白板的象牙塔中,从头到尾的几周时间里都在想当然地讨论 代码架构细节。实际上,我所见到的代码是出自那些被上司催着进度的人之手。他们竭尽了全力,而且,我逐渐意识到,他 们竭尽全力的结果往往是很棒的。我越深入这些代码,便越是这么觉得。 不幸的是,“隐藏”一词恰恰反映了这样的情况:宝藏埋在代码深处,而许多人只从地表踏过。我看到同事在努力重塑更好的解 决方案时,他们所寻求的办法正藏在他们脚下的代码库之中。 这样的问题正是这本书所关注的。我挖掘并打磨出自己在游戏中所发现的最好的设计模式,在此一一呈现给大家,以便我们 将时间节省下来发现新大陆,而不是重新造轮子。 目前市面已经有数十多本游戏编程的书籍。为什么还要再写一本? 我见过的大多数游戏编程书籍无非下列两类: 关于特定领域的书籍。这些针对性较强的书籍为你的游戏开发打开一个独特而深刻的视角。他们会教你3D图形,实时渲 染,物理模拟,人工智能,或音频处理。这些是多数游戏程序员在自己的职业生涯中所专注的领域。 关于整个游戏引擎的书籍。相反,这些书尝试涵盖整个游戏引擎的各部分。它们被组织起来形成一个完整的引擎以适用 于一些特定类型的游戏,通常是3D第一人称射击游戏。 我喜欢这两类书,但我觉得它们仍留有一些空白。特定领域的书很少会写你的代码块如何与游戏的其他部分交互。你可能擅 长物理和渲染(译者注:这里指的是在某个领域特别擅长的人),是你知道如何优雅的将它们拼合起来? 第二类书籍写到了这些,但我通常发现这类书都太单一,太泛泛而谈。特别是随着移动和休闲游戏的兴起,我们正处在充斥 着大量不同类型游戏的时代。我们不再只是克隆 Quake(译者注:雷神之锤,第一个真3D实时演算的FPS游戏)了。当你的 游戏不适合这个模型时,这类阐述单个引擎的书籍就不再合适了。 相反,这里我想要做的,更倾向于“引导”。在这本书中,每个章节都是一个独立的思想,而你可以将它应用到你的代码里。 藉此,你可以混用它们以令其在你制作的游戏中发挥最好的效果。 注解 这种“向导”风格的另外一个例子,就是广受大家喜爱的《游戏编程精粹》系列。 任何名字中带有“模式”的编程书籍都和经典图书《设计模式:可复用面向对象软件的基础》脱不了干系。这本书由 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides 完成。(俗称“Gang of Four”--四人组) 注解 设计模式一书本身也源自前人的灵感。使用模式语言来描述开放式解决问题的想法来自《A Pattern Language》,由 Christopher Alexander(以及Sarah Ishikawa,Murray Silverstein)完成。 他们的书是关于架构(就像真正的建筑结构有着建筑和围墙之类的东西),但他们希望他人会使用相同的结构来描述 在其他领域的解决方案。设计模式(Design Patterns)是 Gang of Four 在软件领域的一个尝试。 本书以“游戏编程模式”命名,并不是说 Gang of Four 的书不适用于游戏。恰恰相反,在再探设计模式一节中覆盖众多来自 GoF著作的设计模式,同时强调了在游戏开发中的运用。 从另一面说,我觉得这本书也适用于非游戏软件。我也可以把这本书命名为 More Design Patterns,但我认为游戏制作例子 更吸引人。难道你真的想要阅读的一本关于员工记录和银行账户例子的书么? 市面上的书籍 设计模式相关 话虽这么说,这里介绍的模式在其他软件中是有用的,我觉得他们是特别适合于软件开发,就像在游戏中经常遇到的挑战一 样: 时间和序列化往往是一个游戏的架构的核心部分。事情必须依照正确的顺序和正确的时间发生。 开发周期被高度压缩,程序员们需要能够快速构建和迭代一组丰富且相异的行为,同时不牵涉到他人或者弄乱代码库。 所有这些行为被定义后,游戏便开始互动。怪物撕咬英雄,药水混合在一起,炸弹炸到敌人和朋友...诸如此类。这些交 互必须很好地进行下去,同时代码库需要保持干净不能纷乱无章得像个交织错乱的毛线球。 最后,性能是游戏的关键。游戏开发者们总在不断比赛着看谁能够充分利用平台的性能。游戏周期处理的不同可能意味 着产品是成为数以百万计销售的A级游戏,或者满是掉帧且贴满愤怒评论的废铁。 游戏编程模式分为三大部分。第一部分是序言和书的概括。这正是你现在阅读的章节以及下一章节。 第二部分,再探设计模式,回顾了 Gang of Four 中的一些设计模式。在每个章节中,我会提及自己对该模式的认识,及将该 模式运用到游戏中的方法。 最后部分是这本书的重头戏。这部分代表了我认为十分有用的13种设计模式。它们分为四类:序列模式,行为模式,解耦模 式,优化模式。 这些模式使用一致的文本组织结构来讲述,以便你将该书作为参考并能快速找到你所需要的内容: 目的 部分简述了此模式旨在解决的问题。这样你很容易根据自己遇到的问题来快速确定该用哪个模式。 动机 部分描述了一个示例、该示例存在问题、我们将要对之采用设计模式。不同于具体的算法,模式通常是无形的,除 非针对一些特定的问题。学设计模式离不开示例正如学烘培离不开面团一样。这个部分提供面团,之后的部分将会教你 如何烘培。 模式 部分会提炼出前面示例中的模式本质。如果你想了解该模式的书面描述,就是这部分了。如果你已经熟悉了,这部 分也是一个很好的复习,确保你没有忘记该模式。 到目前为止,该模式只是就一个单一的例子来解释的。但你怎么该模式是否适用于你的问题呢?使用情境 针对模式使用 的情境以及何时避免使用它提供一些指引。使用须知 部分会指出使用该模式时面临的后果和风险。 如果像我一样,需要借助具体的实例才能真正的理解,那么示例 正满足你的需要。示例会一步步实现模式,所以你可以 清楚的看到模式是如何工作的。 模式和单一的算法不同,因为模式是开放式的。每次使用模式的时候,你实现的方式有可能会不同。接下来设计决策 部 分,会探讨这个问题,并告诉你在应用模式时要考虑的不同因素。 结束部分,参考 部分会告诉你该模式和其他模式的关联并指出使用该模式的一些真实世界中的开源代码。 这本书中的示例代码用 C++ 编写,但是这并不意味着这些模式仅在该语言中有用或者说 C++ 比其他语言要好。几乎所有的 语言都适用,虽然有些模式确实倾向于面向对象语言。 我选择 C++ 有几个原因。首先,它是商业游戏中最流行的语言,是该行业的通用语言。 另外,C++ 基于 C 的语法也是 Java,C#,JavaScript和许多其他语言的基础。即使你不懂 C++,也没有关系,你可以很轻松的明白示例代码的含义。 这本书的目的,不是教你学习 C++。示例会尽可能保持简单,但是这并不代表良好的 C++ 编码风格或使用就是这样。阅读 代码时要理解代码所传达的思想,而不是代码本身的表达。 特别一提的是,示例代码没有采用“现代” C++ -- C++11 或更高版本风格。它没使用标准库并很少使用模板。这是“糟糕”的 如何阅读本书 关于示例代码 C++ 代码,但我仍希望保留下来,这样会对那些从C,Objective-C,Java和其他语言转来的人们更加的友好。 为了避免浪费页面空间,你已经看到了,和模式不相关的代码,有时会在例子中省略,通常用省略号来表示省去的代码。 举个例子,有一个函数,它会处理一些工作,并且会返回一个值。被解释的模式是只关心返回值,不关心处理的工作。在这 种情况下,示例代码看起来像这样: bool update() { // Do work... return isDone(); } 模式是软件开发中一个不断变化和扩展的部分。这本书从 Gang of Four 文献开始,并分享他们了解的软件设计模式,当书页 发布之后过程仍然会继续。 你是这个过程的核心部分。只要你开发了你自己的模式并细化(或者反驳!)这本书中提到的模式,你就是在为软件社区贡 献力量。如果你关于书中内容有任何建议,修正或者其他反馈,请与我联系。 =============================== 目录 下一节 下一步 Before we plunge headfirst into a pile of patterns, I thought it might help to give you some context about how I think about software architecture and how it applies to games. It may help you understand the rest of this book better. If nothing else, when you get dragged into an argument about how terrible (or awesome) design patterns and software architecture are, it will give you some ammo to use. note: Note that I didn’t presume which side you’re taking in that fight. Like any arms dealer, I have wares for sale to all combatants. If you read this book cover to cover, you won’t come away knowing the linear algebra behind 3D graphics or the calculus behind game physics. It won’t show you how to alpha-beta prune your AI’s search tree or simulate a room’s reverberation in your audio playback. note:Wow, this paragraph would make a terrible ad for the book. Instead, this book is about the code between all of that. It’s less about writing code than it is about organizing it. Every program has some organization, even if it’s just “jam the whole thing into main() and see what happens”, so I think it’s more interesting to talk about what makes for good organization. How do we tell a good architecture from a bad one? I’ve been mulling over this question for about five years. Of course, like you, I have an intuition about good design. We’ve all suffered through codebases so bad, the best you could hope to do for them is take them out back and put them out of their misery. note:Let’s admit it, most of us are responsible for a few of those. A lucky few have had the opposite experience, a chance to work with beautifully designed code. The kind of codebase that feels like a perfectly appointed luxury hotel festooned with concierges waiting eagerly on your every whim. What’s the difference between the two? For me, good design means that when I make a change, it’s as if the entire program was crafted in anticipation of it. I can solve a task with just a few choice function calls that slot in perfectly, leaving not the slightest ripple on the placid surface of the code. That sounds pretty, but it’s not exactly actionable. “Just write your code so that changes don’t disturb its placid surface.” Right. Let me break that down a bit. The first key piece is that architecture is about change. Someone has to be modifying the codebase. If no one is touching the code — whether because it’s perfect and complete or so wretched no one will sully their text editor with it — its design is irrelevant. The measure of a design is how easily it accommodates changes. With no changes, it’s a runner who never leaves the starting line. Before you can change the code to add a new feature, to fix a bug, or for whatever reason caused you to fire up your editor, you have to understand what the existing code is doing. You don’t have to know the whole program, of course, but you need to load all of the relevant pieces of it into your primate brain. note: It’s weird to think that this is literally an OCR process. Architecture, Performance, and Games What is Software Architecture? What is good software architecture? How do you make a change? We tend to gloss over this step, but it’s often the most time-consuming part of programming. If you think paging some data from disk into RAM is slow, try paging it into a simian cerebrum over a pair of optical nerves. Once you’ve got all the right context into your wetware, you think for a bit and figure out your solution. There can be a lot of back and forth here, but often this is relatively straightforward. Once you understand the problem and the parts of the code it touches, the actual coding is sometimes trivial. You beat your meaty fingers on the keyboard for a while until the right colored lights blink on screen and you’re done, right? Not just yet! Before you write tests and send it off for code review, you often have some cleanup to do. note: Did I say “tests”? Oh, yes, I did. It’s hard to write unit tests for some game code, but a large fraction of the codebase is perfectly testable. I won’t get on a soapbox here, but I’ll ask you to consider doing more automated testing if you aren’t already. Don’t you have better things to do than manually validate stuff over and over again? You jammed a bit more code into your game, but you don’t want the next person to come along to trip over the wrinkles you left throughout the source. Unless the change is minor, there’s usually a bit of reorganization to do to make your new code integrate seamlessly with the rest of the program. If you do it right, the next person to come along won’t be able to tell when any line of code was written. In short, the flow chart for programming is something like: note: The fact that there is no escape from that loop is a little alarming now that I think about it. While it isn’t obvious, I think much of software architecture is about that learning phase. Loading code into neurons is so painfully slow that it pays to find strategies to reduce the volume of it. This book has an entire section on decoupling patterns, and a large chunk of Design Patterns is about the same idea. You can define “decoupling” a bunch of ways, but I think if two pieces of code are coupled, it means you can’t understand one without understanding the other. If you de-couple them, you can reason about either side independently. That’s great because if only one of those pieces is relevant to your problem, you just need to load it into your monkey brain and not the other half too. To me, this is a key goal of software architecture: minimize the amount of knowledge you need to have in-cranium before How can decoupling help? you can make progress. The later stages come into play too, of course. Another definition of decoupling is that a change to one piece of code doesn’t necessitate a change to another. We obviously need to change something, but the less coupling we have, the less that change ripples throughout the rest of the game. This sounds great, right? Decouple everything and you’ll be able to code like the wind. Each change will mean touching only one or two select methods, and you can dance across the surface of the codebase leaving nary a shadow. This feeling is exactly why people get excited about abstraction, modularity, design patterns, and software architecture. A well-architected program really is a joyful experience to work in, and everyone loves being more productive. Good architecture makes a huge difference in productivity. It’s hard to overstate how profound an effect it can have. But, like all things in life, it doesn’t come free. Good architecture takes real effort and discipline. Every time you make a change or implement a feature, you have to work hard to integrate it gracefully into the rest of the program. You have to take great care to both organize the code well and keep it organized throughout the thousands of little changes that make up a development cycle. note: The second half of this — maintaining your design — deserves special attention. I’ve seen many programs start out beautifully and then die a death of a thousand cuts as programmers add “just one tiny little hack” over and over again. Like gardening, it’s not enough to put in new plants. You must also weed and prune. You have to think about which parts of the program should be decoupled and introduce abstractions at those points. Likewise, you have to determine where extensibility should be engineered in so future changes are easier to make. People get really excited about this. They envision future developers (or just their future self) stepping into the codebase and finding it open-ended, powerful, and just beckoning to be extended. They imagine The One Game Engine To Rule Them All. But this is where it starts to get tricky. Whenever you add a layer of abstraction or a place where extensibility is supported, you’re speculating that you will need that flexibility later. You’re adding code and complexity to your game that takes time to develop, debug, and maintain. That effort pays off if you guess right and end up touching that code later. But predicting the future is hard, and when that modularity doesn’t end up being helpful, it quickly becomes actively harmful. After all, it is more code you have to deal with. note: Some folks coined the term “YAGNI” — You aren’t gonna need it — as a mantra to use to fight this urge to speculate about what your future self may want. When people get overzealous about this, you get a codebase whose architecture has spiraled out of control. You’ve got interfaces and abstractions everywhere. Plug-in systems, abstract base classes, virtual methods galore, and all sorts of extension points. It takes you forever to trace through all of that scaffolding to find some real code that does something. When you need to make a change, sure, there’s probably an interface there to help, but good luck finding it. In theory, all of this decoupling means you have less code to understand before you can extend it, but the layers of abstraction themselves end up filling your mental scratch disk. Codebases like this are what turn people against software architecture, and design patterns in particular. It’s easy to get so wrapped up in the code itself that you lose sight of the fact that you’re trying to ship a game. The siren song of extensibility sucks in countless developers who spend years working on an “engine” without ever figuring out what it’s an engine for. At What Cost? There’s another critique of software architecture and abstraction that you hear sometimes, especially in game development: that it hurts your game’s performance. Many patterns that make your code more flexible rely on virtual dispatch, interfaces, pointers, messages, and other mechanisms that all have at least some runtime cost. note: One interesting counter-example is templates in C++. Template metaprogramming can sometimes give you the abstraction of interfaces without any penalty at runtime. There’s a spectrum of flexibility here. When you write code to call a concrete method in some class, you’re fixing that class at author time — you’ve hard-coded which class you call into. When you go through a virtual method or interface, the class that gets called isn’t known until runtime. That’s much more flexible but implies some runtime overhead. Template metaprogramming is somewhere between the two. There, you make the decision of which class to call at compile time when the template is instantiated. There’s a reason for this. A lot of software architecture is about making your program more flexible. It’s about making it take less effort to change it. That means encoding fewer assumptions in the program. You use interfaces so that your code works with any class that implements it instead of just the one that does today. You use observers and messaging to let two parts of the game talk to each other so that tomorrow, it can easily be three or four. But performance is all about assumptions. The practice of optimization thrives on concrete limitations. Can we safely assume we’ll never have more than 256 enemies? Great, we can pack an ID into a single byte. Will we only call a method on one concrete type here? Good, we can statically dispatch or inline it. Are all of the entities going to be the same class? Great, we can make a nice contiguous array of them. This doesn’t mean flexibility is bad, though! It lets us change our game quickly, and development speed is absolutely vital for getting to a fun experience. No one, not even Will Wright, can come up with a balanced game design on paper. It demands iteration and experimentation. The faster you can try out ideas and see how they feel, the more you can try and the more likely you are to find something great. Even after you’ve found the right mechanics, you need plenty of time for tuning. A tiny imbalance can wreck the fun of a game. There’s no easy answer here. Making your program more flexible so you can prototype faster will have some performance cost. Likewise, optimizing your code will make it less flexible. My experience, though, is that it’s easier to make a fun game fast than it is to make a fast game fun. One compromise is to keep the code flexible until the design settles down and then tear out some of the abstraction later to improve your performance. That brings me to the next point which is that there’s a time and place for different styles of coding. Much of this book is about making maintainable, clean code, so my allegiance is pretty clearly to doing things the “right” way, but there’s value in slapdash code too. Writing well-architected code takes careful thought, and that translates to time. Moreso, maintaining a good architecture over the life of a project takes a lot of effort. You have to treat your codebase like a good camper does their campsite: always try to leave it a little better than you found it. This is good when you’re going to be living in and working on that code for a long time. But, like I mentioned earlier, game design requires a lot of experimentation and exploration. Especially early on, it’s common to write code that you know you’ll throw away. Performance and Speed The Good in Bad Code If you just want to find out if some gameplay idea plays right at all, architecting it beautifully means burning more time before you actually get it on screen and get some feedback. If it ends up not working, that time spent making the code elegant goes to waste when you delete it. Prototyping — slapping together code that’s just barely functional enough to answer a design question — is a perfectly legitimate programming practice. There is a very large caveat, though. If you write throwaway code, you must ensure you’re able to throw it away. I’ve seen bad managers play this game time and time again: Boss: “Hey, we’ve got this idea that we want to try out. Just a prototype, so don’t feel you need to do it right. How quickly can you slap something together?” Dev: “Well, if I cut lots of corners, don’t test it, don’t document it, and it has tons of bugs, I can give you some temp code in a few days.” Boss: “Great!” A few days pass… Boss: “Hey, that prototype is great. Can you just spend a few hours cleaning it up a bit now and we’ll call it the real thing?” You need to make sure the people using the throwaway code understand that even though it kind of looks like it works, it cannot be maintained and must be rewritten. If there’s a chance you’ll end up having to keep it around, you may have to defensively write it well. note: One trick to ensuring your prototype code isn’t obliged to become real code is to write it in a language different from the one your game uses. That way, you have to rewrite it before it can end up in your actual game. We have a few forces in play: 1. We want nice architecture so the code is easier to understand over the lifetime of the project. 2. We want fast runtime performance. 3. We want to get today’s features done quickly. note: I think it’s interesting that these are all about some kind of speed: our long-term development speed, the game’s execution speed, and our short-term development speed. These goals are at least partially in opposition. Good architecture improves productivity over the long term, but maintaining it means every change requires a little more effort to keep things clean. The implementation that’s quickest to write is rarely the quickest to run. Instead, optimization takes significant engineering time. Once it’s done, it tends to calcify the codebase: highly optimized code is inflexible and very difficult to change. There’s always pressure to get today’s work done today and worry about everything else tomorrow. But if we cram in features as quickly as we can, our codebase will become a mess of hacks, bugs, and inconsistencies that saps our future productivity. There’s no simple answer here, just trade-offs. From the email I get, this disheartens a lot of people. Especially for novices who just want to make a game, it’s intimidating to hear, “There is no right answer, just different flavors of wrong.” But, to me, this is exciting! Look at any field that people dedicate careers to mastering, and in the center you will always find a set of intertwined constraints. After all, if there was an easy answer, everyone would just do that. A field you can master in a week is ultimately boring. You don’t hear of someone’s distinguished career in ditch digging. note: Maybe you do; I didn’t research that analogy. For all I know, there could be avid ditch digging hobbyists, ditch Striking a Balance digging conventions, and a whole subculture around it. Who am I to judge? To me, this has much in common with games themselves. A game like chess can never be mastered because all of the pieces are so perfectly balanced against one another. This means you can spend your life exploring the vast space of viable strategies. A poorly designed game collapses to the one winning tactic played over and over until you get bored and quit. Lately, I feel like if there is any method that eases these constraints, it’s simplicity. In my code today, I try very hard to write the cleanest, most direct solution to the problem. The kind of code where after you read it, you understand exactly what it does and can’t imagine any other possible solution. I aim to get the data structures and algorithms right (in about that order) and then go from there. I find if I can keep things simple, there’s less code overall. That means less code to load into my head in order to change it. It often runs fast because there’s simply not as much overhead and not much code to execute. (This certainly isn’t always the case though. You can pack a lot of looping and recursion in a tiny amount of code.) However, note that I’m not saying simple code takes less time to write. You’d think it would since you end up with less total code, but a good solution isn’t an accretion of code, it’s a distillation of it. note: Blaise Pascal famously ended a letter with, “I would have written a shorter letter, but I did not have the time.” Another choice quote comes from Antoine de Saint-Exupery: “Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” Closer to home, I’ll note that every time I revise a chapter in this book, it gets shorter. Some chapters are tightened by 20% by the time they’re done. We’re rarely presented with an elegant problem. Instead, it’s a pile of use cases. You want the X to do Y when Z, but W when A, and so on. In other words, a long list of different example behaviors. The solution that takes the least mental effort is to just code up those use cases one at a time. If you look at novice programmers, that’s what they often do: they churn out reams of conditional logic for each case that popped into their head. But there’s nothing elegant in that, and code in that style tends to fall over when presented with input even slightly different than the examples the coder considered. When we think of elegant solutions, what we often have in mind is a general one: a small bit of logic that still correctly covers a large space of use cases. Finding that is a bit like pattern matching or solving a puzzle. It takes effort to see through the scattering of example use cases to find the hidden order underlying them all. It’s a great feeling when you pull it off. Almost everyone skips the introductory chapters, so I congratulate you on making it this far. I don’t have much in return for your patience, but I’ll offer up a few bits of advice that I hope may be useful to you: Abstraction and decoupling make evolving your program faster and easier, but don’t waste time doing them unless you’re confident the code in question needs that flexibility. Think about and design for performance throughout your development cycle, but put off the low-level, nitty-gritty optimizations that lock assumptions into your code until as late as possible. note: Trust me, two months before shipping is not when you want to start worrying about that nagging little “game only runs at 1 FPS” problem. Simplicity Get On With It, Already Move quickly to explore your game’s design space, but don’t go so fast that you leave a mess behind you. You’ll have to live with it, after all. If you are going to ditch code, don’t waste time making it pretty. Rock stars trash hotel rooms because they know they’re going to check out the next day. But, most of all, if you want to make something fun, have fun making it. 《设计模式:可复用面向对象软件的基础》一书已经出版了将近20年。不过通过阅读本章,你将有机会重温和理解设计模 式。软件行业发展迅速,这本书确实有些古老了。这本书经久不衰说明比起许多框架和方法来说,设计模式更加永恒。 然而我认为设计模式到了今天仍然重要,我们从过去几十年中学习到了许多东西。在这个章节中,我们将重温四人帮(Gang of Four)的几个原作设计模式。针对每一种模式,我都希望能写出一些有用、有趣的东西。 我认为有些模式被过度使用(单例模式),而另一些却又被冷落(命令模式)。书中还涉及到两个,我想探讨他们与游戏的 相关性(享元模式和观察者模式)。最后,有时候我只是觉得了解设计模式在更大点的编程领域的表现是蛮有趣的(原型模式 和状态模式)。 命令模式 享元模式 观察者模式 原型模式 单例模式 状态模式 =============================== 上一节 目录 下一节 再探设计模式 模式 命令模式是我最喜爱的模式之一。在我写过的许多大型游戏或者其他程序中,都有用到它。正确的使用它,会让你的代码变 得更加优雅。对于这个模式,Gang of Four 有着一个预见性的深奥说明: 将一个请求(request)封装成一个对象,从而让你使用不同的请求,请求队列或请求日志来参数化客户端,同时支持请 求操作的撤销与恢复。 我想你也和我一样觉得这句话晦涩难明。首先,它分割了自己试图建立的物象。在软件世界之外,一词往往多义。“客户 (client)”就是一个人的意思-一个你与它做生意的人。据我查证,人类(human beings)是不可“参数化”的。(译者注:作者 在这里的意思是Gang of Four 的说明因为太具概括性,涵盖了软件开发之外的一些定义,使得句子很难理解。) 然后,句子的剩余部分就像你可能使用的模式的一串列表一样。不是特别明朗,除非你的用例恰巧在列表中。我对命令模式 的精炼(pithy)概括如下: 命令就是一个对象化(实例化)的方法调用。(A command is a reified method call.) 当然,“精炼”(pithy)通常意味着“令人费解的简洁”,所以我可能改得不是很好。让我解释一下:你可能没听过“Reify”一词, 意即“具象化”(make real)。另一个术语reifying的意思是 使一些事物成为“第一类”(first-class)。(译者注:你可能在其他书 籍中见到说是“第一类值”的类似说法) 注解 “Reify”出自拉丁文“res”,意思为“thing”,加上英语后缀“-fy”,所以就成为了“thingify”,坦白说,这是个很有趣的单词。 这两个术语都意味着,将某个概念(concept)转化为一块数据(data),即一个对象,你可以认为是传入函数的变量等等。所 以说命令模式是一个“对象化的方法调用”,我的意思就是封装在一个对象的一个方法调用。 你可能对“回调“(callback),”第一类函数“(first-class function),”函数指针“(function pointer),”闭 包“(closure),。”局部函数“(partially applied function)更耳熟,至于耳熟哪个就取决于你所使用的语言,而它们都具共性。 The Gang of Four 之后这样阐述: 命令就是回调的面向对象化。(Commands are an object-oriented replacement for callbacks.) 这个比他们对模式的概括要好多了。 注解 一些语言的反射系统(译者注: 如.NET)可以让你在运行时使用类型处理。你可以得到一个对象,它代表着某些其他对象 的类,你也可以玩玩看类型可以处理哪些问题。换句话说,反射是一个具体化的类型系统。 但是这些都比较抽象和模糊。正如我所推崇的那样,我喜欢用一些具体点的东西来开头。为弥补这点,现在开始我将举例,它 们都非常适合命令模式。 每个游戏都有一处代码块用来读取用户原始输入-按钮点击,键盘事件,鼠标点击,或者其他等等。它记录每次的输入,并将 命令模式 目的 动机 输入配置 之转换为游戏中一个有意义的动作(action): 注解 专业级贴士:请勿常按B键。 下面是个简单的实现: void InputHandler::handleInput() { if (isPressed(BUTTON_X)) jump(); else if (isPressed(BUTTON_Y)) fireGun(); else if (isPressed(BUTTON_A)) swapWeapon(); else if (isPressed(BUTTON_B)) lurchIneffectively(); } 这个函数通常会通过游戏循环被每帧调用,我想你能理解这段代码在干些什么。如果我们将用户的输入硬关联到游戏的动作 (game actions),上面的代码是有效的,但是许多游戏允许用户配置他们的按钮与动作的映射。 为了支持自定义配置,我们需要把那些对 jump() 和 fireGun() 的直接调用转换为我们可以换出(swap out)的东西。”换 出“(swapping out)听起来很像分配变量,所以我们需要个对象来代表一个游戏动作。这就用到了命令模式。 我们定义了一个基类用来代表一个可激活的游戏命令: class Command { public: virtual ~Command() {} virtual void execute() = 0; }; 注解 当你的接口仅有一个无返回值的方法时,很有可能就会用到命令模式。 然后我们为每个不同的游戏动作创建一个子类: class JumpCommand : public Command { public: virtual void execute() { jump(); } }; class FireCommand : public Command { public: virtual void execute() { fireGun(); } }; // You get the idea... 在我们的输入处理中,我们为每个按钮存储一个指针指向他们。 class InputHandler { public: void handleInput(); // Methods to bind commands... private: Command* buttonX_; Command* buttonY_; Command* buttonA_; Command* buttonB_; }; 现在输入处理成了下面这样: void InputHandler::handleInput() { if (isPressed(BUTTON_X)) buttonX_->execute(); else if (isPressed(BUTTON_Y)) buttonY_->execute(); else if (isPressed(BUTTON_A)) buttonA_->execute(); else if (isPressed(BUTTON_B)) buttonB_->execute(); } 注解 注意到我们这里没有检查命令是否为 null 没?这里假设了每个按钮有某个命令与之对应关联。 如果你想要支持不处理任何事情的按钮,而不用明确检查是否为null,我们可以定义一个命令类,这个命令类中 的 execute() 方法不做任何事情。然后,我们将按钮处理器(button handler)指向一个空对象(null object)代替指向 null。这个模式叫空对象(Null Object)。 以前每个输入都会直接调用一个函数,现在则会有一个间接调用层。 简而言之,这就是命令模式。如果你已经看到了它的优点,不妨看完本章的剩余部分。 我们刚才定义的命令类在上个例子中是有效的,但他们很受限。问题在于,他们假设存在 jump() , fireGun() 等这样的能找 到玩家的头像,使得玩家像木偶一样进行动作处理的顶级函数。 模式 关于角色的说明 这种假设耦合限制了这些命令的的效用。JumpCommand类唯一能做的事情就是使得 player 进行跳跃。让我们放宽限制。 我们传进去一个我们想要控制的对象而不是用命令对象自身来调用函数: class Command { public: virtual ~Command() {} virtual void execute(GameActor& actor) = 0; }; 这里,GameActor是我们用来表示游戏世界中的角色的”游戏对象“类。我们将它传入 execute() 中,以便子类化的命令可以 针对我们选择的角色进行调用,就像这样: class JumpCommand : public Command { public: virtual void execute(GameActor& actor) { actor.jump(); } }; 现在,我们可以使用这个类来让游戏中的任何角色进行来回跳动。在输入处理和记录命令以及调用正确的对象之间,我们缺 少了一部分。首先,我们改变下 handleInput() ,像下面这样返回一个命令(commands): Command* InputHandler::handleInput() { if (isPressed(BUTTON_X)) return buttonX_; if (isPressed(BUTTON_Y)) return buttonY_; if (isPressed(BUTTON_A)) return buttonA_; if (isPressed(BUTTON_B)) return buttonB_; // Nothing pressed, so do nothing. return NULL; } 它不能直接执行命令,因为它并不知道该传入那个角色对象。命令是一个具体化的调用,这里正是我们可以利用的地方-我们 可以延迟调用。 然后,我们需要一些代码来保存命令并且执行对玩家角色的调用。像下面这样: Command* command = inputHandler.handleInput(); if (command) { command->execute(actor); } 假设 actor 是玩家角色的一个引用,这将会基于用户的输入来驱动角色,所以我们可以赋予角色与前例一致的行为。在命令 和角色之间加入的间接层使得我们可以让玩家控制游戏中的任何角色,只需通过改变命令执行时传入的角色对象即可。 在实践中,这并不是一个常见的功能,但是有一种情况却经常见到。迄今为止,我们只考虑了玩家驱动角色(player-driven character),但是对于游戏世界中的其他角色呢?他们由游戏的AI来驱动。我们可以使用相同的命令模式来作为AI引擎和角 色的接口;AI代码部分提供命令(Command)对象用来执行。(译者注: command->execute(AI对象); ) AI选择命令,角色执行命令,它们之间的解耦给了我们很大的灵活性。我们可以为不同的角色使用不同的AI模块。或者我们 可以为不同种类的行为混合AI。你想要一个更加具有侵略性的敌人?只需要插入一段更具侵略性的AI代码来为它生成命令。 事实上,我们甚至可以将AI使用到玩家的角色身上,这对于像游戏需要自动运行的demo模式是很有用的。 通过将控制角色的命令作为第一类对象,我们便去掉了直接的函数调用这样的紧耦合。相反的,把它想象成一个队列或者一 个命令流(queue or stream of commands): 注解 关于队列更多信息,见事件队列 注解 为什么我感觉有必要通过图片来解释“流(stream)”呢?为什么它看起来就像一个管道(tube)一样? 一些代码(输入处理(the input handler)或者AI)生成命令并将它们放置于命令流中,一些代码(发送者(the dispatcher)或者角色自身(actor))执行命令并且调用它们。通过中间的队列,我们解耦了一端的生产者和另一端的消费 者。 注解 如果我们把这些命令序列化,我们便可以通过互联网发送数据流。我们可以把玩家的输入,通过网络发送到另外一台 机器上,然后进行回放。这是多人网络游戏很重要的一块。 最后这个例子(译者注:作者指的是撤销和重做)是命令模的成名应用了。如果一个命令对象可以 do 一些事情,那么应该 可以很轻松的 undo(撤销) 它们。撤销这个行为经常在一些策略游戏中见到,在游戏中如果你不喜欢的话可以回滚一些步 骤。在创建游戏时这是一个很常见的工具。如果你想让你的游戏设计师们讨厌你,最可靠的办法就是在关卡编辑器中不要提 供撤销命令,让他们不能撤销不小心犯的错误。 注解 这里可能是我的经验之谈。 如果没有命令模式,实现撤销是很困难的。有了它,小菜一碟啊。我们假定一个情景,我们在制作一款单人回合制的游戏, 我们想让我们的玩家能够撤销一些行动以便他们能够更多的专注于策略而不是猜测。 我们已经可以很方便的使用命令模式来抽象输入处理,所以每次对角色的移动要封装起来。例如,像下面这样来移动一个单 位: class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), x_(x), y_(y) {} virtual void execute() { unit_->moveTo(x_, y_); } private: Unit* unit_; int x_, y_; }; 注意到这个和我们上一个命令不太相同。在上个例子中,我们想要抽象出命令,执行命令时可以针对不同的角色。在这个例 撤销和重做(Undo and Redo) 子中,我们特别希望将命令绑定到移动的单位上。这个命令的实例不是一般性质的”移动某些物体“这样适用于很多情境下的 的操作,在游戏的回合次序中,它是一个特定具体的移动。 这凸显了命令模式在实现时的一个变化。在某些情况下,像我们的第一对的例子,一个命令代表了一个可重用的对象,表示 a thing that can be done(一件可完成的事情)。我们前面的输入处理程序仅针对单一的命令对象,并要求在对应按钮被按 下的时候其 execute() 方法被调用。 这里,这些命令更加具体。他们表示a thing that can be done at a specific point in time(一件可在特定时间点完成的事 情)。这意味着每次玩家选择移动,输入处理程序代码都会创建一个命令实例。像下面这样: Command* handleInput() { // Get the selected unit... Unit* unit = getSelectedUnit(); if (isPressed(BUTTON_UP)) { // Move the unit up one. int destY = unit->y() - 1; return new MoveUnitCommand(unit, unit->x(), destY); } if (isPressed(BUTTON_DOWN)) { // Move the unit down one. int destY = unit->y() + 1; return new MoveUnitCommand(unit, unit->x(), destY); } // Other moves... return NULL; } 一次性命令的特质很快能为我们所用。为了撤销命令,我们定义了一个操作,每个命令类都需要来实现它: class Command { public: virtual ~Command() {} virtual void execute() = 0; virtual void undo() = 0; }; 注解 当然了,在没有垃圾回收的语言如C++中,这意味着执行命令的代码也要负责释放它们申请的内存。 undo() 方法会反转由对应的 execute() 方法改变的游戏状态。下面我们针对上一个移动命令加入了撤销支持: class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), xBefore_(0), yBefore_(0), x_(x), y_(y) {} virtual void execute() { // Remember the unit's position before the move // so we can restore it. xBefore_ = unit_->x(); yBefore_ = unit_->y(); unit_->moveTo(x_, y_); } virtual void undo() { unit_->moveTo(xBefore_, yBefore_); } private: Unit* unit_; int xBefore_, yBefore_; int x_, y_; }; 注意到我们在类中添加了一些状态。当单位移动时,它会忘记它刚才在哪。如果我们要撤销移动,我们得记录单位的上一次 位置,正是 xBefore_ 和 yBefore_ 变量的功能。 注解 这看起来挺像备忘录模式的,但是我发现备忘录模式用在这里并不能有效的工作。因为命令试图去修改一个对象状态 的一小部分,而为对象的其他数据创建快照是浪费内存。只手动存储被修改的部分相对来说就节省很多内存了。 持久化数据结构是另一个选择。通过它们,每次对一个对象进行修改都会返回一个新的对象,保留原对象不变。通过 这样明智的实现,这些新对象与原对象共享数据,所以比拷贝整个对象的代价要小的多。 使用持久化数据结构,每个命令存储着命令执行前对象的一个引用,所以撤销意味着切换到原来老的对象。 为了让玩家能够撤销一次移动,我们保留了他们执行的上一个命令。当他们敲击 Control-Z 时,我们便会调用 undo() 方法。 (如果他们已经撤销了,那么会变为”重做“,我们会再次执行那个命令。) 支持多次撤销并不难。这次我们不再保存最后一个命令,取而代之的是,我们保存了一个命令列表和”current“(当前)命令 的一个引用。当玩家执行了一个命令,我们将这个命令添加到列表中,并将”current“指向它。 当玩家选择”撤销“时,我们撤销当前的命令并且将当前的指针移回去。当他们选择”重做“,我们将指针前移然后执行命令。如 果他们在撤销之后选择了一个新的命令,列表中位于当前命令之后的所有命令被舍弃掉。 我第一次在一个关卡编辑器中实现了这一点,顿时自我感觉良好。我很惊讶它是如此的简单而且高效。我们需要指定规则来 确保每个数据的更改都经由一个命令实现,但只要定了规则,剩下的就容易得多。 注解 重做在游戏中并不常见,但回放(re-play)却不是。一个很老实的实现方法就是记录每一帧的游戏状态以便能够回 放,但是这样会使用大量的内存。 相反,许多游戏会记录每一帧每个实体所执行的一系列命令。为了回放游戏,引擎只需要运行正常游戏的模拟,执行 预先录制的命令。 设计决策 类风格化还是函数风格化? 此前,我说命令(commands)和第一类函数或者闭包相似,但是这里我举的每个例子都用了类定义。如果你熟悉函数式编 程,你可能想知道如何用函数式风格实现命令模式。 我用这种方式写例子是因为 C++ 对于第一类函数的支持非常有限。函数指针无须过多阐述,仿函数(译者注:关于仿函数可 以看百科的介绍)看起来比较怪异,还需要定义一个类,C++11 中的闭包使用起来比较棘手因为要手动管理内存。 这并不是说在其他语言中你不应该使用函数来实现命令模式。如果你使用的语言中有闭包的实现,无论怎样,使用它们!在 某些方面(In some ways),命令模式对于没有闭包的语言来说是模拟闭包的一种方式。 注解 我说在某些方面(In some ways),是因为即使在有闭包的语言中为命令构建实际的类或结构仍然是有用的。如果你的 命令有多个操作(如可撤销命令),映射到一个单一函数是比较尴尬的。 定义一个实际的附带字段的类也有助于读者很容易分辨该命令中包含哪些数据。闭包自动包装一些状态是比较简洁, 但它们太过于自动化了以至于很难分辨出它们实际上持有的状态。 举个例子,如果我们在用 JavaScript 编写游戏,我们可以像下面这样创建一个单位移动命令: function makeMoveUnitCommand(unit, x, y) { // This function here is the command object: return function() { unit.moveTo(x, y); } } 我们也可以通过闭包来添加对撤销的支持: function makeMoveUnitCommand(unit, x, y) { var xBefore, yBefore; return { execute: function() { xBefore = unit.x(); yBefore = unit.y(); unit.moveTo(x, y); }, undo: function() { unit.moveTo(xBefore, yBefore); } }; } 如果你熟悉函数式风格,上面这么做你会感到很自然。如果不熟悉,我希望这个章节能够帮助你了解一些。对于我来说,命 令模式的作用能够真正的显示函数式编程在解决许多问题时是多么的高效。 你可能最终会有很多不同的命令类。为了更容易地实现这些类,定义一个具体的基类,里面有着一些方便的高层次的方 法,这样派生的命令可以将它们组合来定义自身的行为,这么做通常是有帮助的。它会将命令的主要方法 execute() 变 成子类沙盒。 在我们的例子中,我们明确地选择了那些角色会执行一个命令。在某些情况下,尤其是在对象模型是分层的情况下,它 可能没这么直观。一个对象可以响应一个命令,或者它可以决定于关闭一些从属对象。如果你这样做,你需要了解下责 任链(Chain of Responsibility)。 一些命令如第一个例子中的 JumpCommand 是一些纯行为的代码块,无需过多阐述。在类似情况下,拥有不止一个这样命 令类的实例会浪费内存,因为所有的实例是等价的。享元模式就是解决这个问题的。 注解 你可以用单例模式实现它,但我奉劝你别这么做。 参考 =============================== 上一节 目录 下一节 云雾散去,露出一片巨大而又古老、绵延的深林。数不清的远古铁杉,高出你而形成一个绿色的大教堂。叶子像彩色玻璃窗一 样,将阳光打碎成一束束金黄色的雾气。巨大的树干之间,你能在远出制作出巨大的深林。 对于我们游戏开发者来说,这是 一种超凡脱俗的梦境设置,这样的场景我们经常通过一个模式来实现,它的名字可能并不常见,那就是享元模式。 我能用简短的语句描述绵延不绝的深林,然而在一个虚拟现实游戏中去实现它却又是另一回事。当你看到各种形状不一的树 填满整个屏幕时,在图形程序员眼里看到的,却是每隔60分之一秒就必须渲染进GPU的数以百万计的多边形。 我们谈到成千上万的树,每一颗的几何结构又都包含了成千上万的多边形。即便你有足够的内存来描述这片深林,到了需要 渲染的时候,这些数据还必须像搭乘巴士一样从CPU传输到GPU。 每一颗树都有一堆与之相关联的数据: 交织成网状的定义了树的主干、树枝以及树叶的多边形。 树皮和树叶的贴图。 它在深林中的位置以及朝向。 大量的参数像大小、颜色等,这样每棵树看起来都不一样。 如果你将它在代码中表述出来,那么你将得到下面的东西: class Tree { private: Mesh mesh_; Texture bark_; Texture leaves_; Vector position_; double height_; double thickness_; Color barkTint_; Color leafTint_; }; 数据很多,并且网格和贴图数据还挺大。整个深林的物体在一帧中被扔进去GPU就太多了,所幸的是,有一个很古老的窍门 来处理这个。 关键的是,即便深林中有成千上万的树,它们大部分看起来相似。它们大部分都用同样的网格和贴图。这就意味着远近许多 物体间都有着许多共性。 我们通过把物体分块能很明显地模拟这一切,首先,我们取出所有树共有的数据放到一个单独的类: 享元模式 构成深林的树 class TreeModel { private: Mesh mesh_; Texture bark_; Texture leaves_; }; 游戏只需要一个这样的类,因为没有道理在内存中存有成千上万个同样的网格和贴图。因此,每一个树的实例,都用一个指 向共享的TreeModel这个类的引用。这就意味着,在Tree这个类里有这么一个指针: class Tree { private: TreeModel* model_; Vector position_; double height_; double thickness_; Color barkTint_; Color leafTint_; }; 你可以这样形象地描述: 这样能很好的在主内存中存储物体,不过这却对渲染没太大帮助。在树林搬到屏幕上之前,它必须自己搬运到GPU,我们必 须用显卡能理解的方式来表达资源共享。 一千个实例 ^title Observer ^section Design Patterns Revisited You can't throw a rock at a computer without hitting an application built using the Model-View-Controller architecture, and underlying that is the Observer pattern. Observer is so pervasive that Java put it in its core library ( java.util.Observer ) and C# baked it right into the language (the event keyword). Like so many things in software, MVC was invented by Smalltalkers in the seventies. Lispers probably claim they came up with it in the sixties but didn't bother writing it down. Observer is one of the most widely used and widely known of the original Gang of Four patterns, but the game development world can be strangely cloistered at times, so maybe this is all news to you. In case you haven't left the abbey in a while, let me walk you through a motivating example. Say we're adding an achievements system to our game. It will feature dozens of different badges players can earn for completing specific milestones like "Kill 100 Monkey Demons", "Fall off a Bridge", or "Complete a Level Wielding Only a Dead Weasel". Achievement: Weasel Wielder I swear I had no double meaning in mind when I drew this. This is tricky to implement cleanly since we have such a wide range of achievements that are unlocked by all sorts of different behaviors. If we aren't careful, tendrils of our achievement system will twine their way through every dark corner of our codebase. Sure, "Fall off a Bridge" is somehow tied to the physics engine, but do we really want to see a call to unlockFallOffBridge() right in the middle of the linear algebra in our collision resolution algorithm? This is a rhetorical question. No self-respecting physics programmer would ever let us sully their beautiful mathematics with something as pedestrian as gameplay. What we'd like, as always, is to have all the code concerned with one facet of the game nicely lumped in one place. The challenge is that achievements are triggered by a bunch of different aspects of gameplay. How can that work without coupling the achievement code to all of them? That's what the observer pattern is for. It lets one piece of code announce that something interesting happened without actually caring who receives the notification. For example, we've got some physics code that handles gravity and tracks which bodies are relaxing on nice flat surfaces and which are plummeting toward sure demise. To implement the "Fall off a Bridge" badge, we could just jam the achievement code right in there, but that's a mess. Instead, we can just do: ^code physics-update All it does is say, "Uh, I don't know if anyone cares, but this thing just fell. Do with that as you will." Observer Achievement Unlocked The physics engine does have to decide what notifications to send, so it isn't entirely decoupled. But in architecture, we're most often trying to make systems better, not perfect. The achievement system registers itself so that whenever the physics code sends a notification, the achievement system receives it. It can then check to see if the falling body is our less-than-graceful hero, and if his perch prior to this new, unpleasant encounter with classical mechanics was a bridge. If so, it unlocks the proper achievement with associated fireworks and fanfare, and it does all of this with no involvement from the physics code. In fact, we can change the set of achievements or tear out the entire achievement system without touching a line of the physics engine. It will still send out its notifications, oblivious to the fact that nothing is receiving them anymore. Of course, if we permanently remove achievements and nothing else ever listens to the physics engine's notifications, we may as well remove the notification code too. But during the game's evolution, it's nice to have this flexibility. If you don't already know how to implement the pattern, you could probably guess from the previous description, but to keep things easy on you, I'll walk through it quickly. We'll start with the nosy class that wants to know when another object does something interesting. These inquisitive objects are defined by this interface: ^code observer The parameters to onNotify() are up to you. That's why this is the Observer pattern and not the Observer "ready-made code you can paste into your game". Typical parameters are the object that sent the notification and a generic "data" parameter you stuff other details into. If you're coding in a language with generics or templates, you'll probably use them here, but it's also fine to tailor them to your specific use case. Here, I'm just hardcoding it to take a game entity and an enum that describes what happened. Any concrete class that implements this becomes an observer. In our example, that's the achievement system, so we'd have something like so: ^code achievement-observer The notification method is invoked by the object being observed. In Gang of Four parlance, that object is called the "subject". It has two jobs. First, it holds the list of observers that are waiting oh-so-patiently for a missive from it: ^code subject-list In real code, you would use a dynamically-sized collection instead of a dumb array. I'm sticking with the basics here for people coming from other languages who don't know C++'s standard library. The important bit is that the subject exposes a public API for modifying that list: ^code subject-register That allows outside code to control who receives notifications. The subject communicates with the observers, but it isn't coupled to them. In our example, no line of physics code will mention achievements. Yet, it can still talk to the achievements system. That's the clever part about this pattern. It's also important that the subject has a list of observers instead of a single one. It makes sure that observers aren't implicitly coupled to each other. For example, say the audio engine also observes the fall event so that it can play an appropriate sound. If the subject only supported one observer, when the audio engine registered itself, that would un- register the achievements system. How it Works The observer The subject That means those two systems would interfere with each other -- and in a particularly nasty way, since the second would disable the first. Supporting a list of observers ensures that each observer is treated independently from the others. As far as they know, each is the only thing in the world with eyes on the subject. The other job of the subject is sending notifications: ^code subject-notify Note that this code assumes observers don't modify the list in their onNotify() methods. A more robust implementation would either prevent or gracefully handle concurrent modification like that. Now, we just need to hook all of this into the physics engine so that it can send notifications and the achievement system can wire itself up to receive them. We'll stay close to the original Design Patterns recipe and inherit Subject : ^code physics-inherit This lets us make notify() in Subject protected. That way the derived physics engine class can call it to send notifications, but code outside of it cannot. Meanwhile, addObserver() and removeObserver() are public, so anything that can get to the physics system can observe it. In real code, I would avoid using inheritance here. Instead, I'd make Physics have an instance of Subject . Instead of observing the physics engine itself, the subject would be a separate "falling event" object. Observers could register themselves using something like: ^code physics-event To me, this is the difference between "observer" systems and "event" systems. With the former, you observe the thing that did something interesting. With the latter, you observe an object that represents the interesting thing that happened. Now, when the physics engine does something noteworthy, it calls notify() like in the motivating example before. That walks the observer list and gives them all the heads up. Pretty simple, right? Just one class that maintains a list of pointers to instances of some interface. It's hard to believe that something so straightforward is the communication backbone of countless programs and app frameworks. But the Observer pattern isn't without its detractors. When I've asked other game programmers what they think about this pattern, they bring up a few complaints. Let's see what we can do to address them, if anything. I hear this a lot, often from programmers who don't actually know the details of the pattern. They have a default assumption that anything that smells like a "design pattern" must involve piles of classes and indirection and other creative ways of squandering CPU cycles. The Observer pattern gets a particularly bad rap here because it's been known to hang around with some shady characters named "events", "messages", and even "data binding". Some of those systems can be slow (often deliberately, and for good reason). They involve things like queuing or doing dynamic allocation for each notification. This is why I think documenting patterns is important. When we get fuzzy about terminology, we lose the ability to communicate clearly and succinctly. You say, "Observer", and someone hears "Events" or "Messaging" because either no one bothered to write down the difference or they didn't happen to read it. That's what I'm trying to do with this book. To cover my bases, I've got a chapter on events and messages too: Event Queue. But, now that you've seen how the pattern is actually implemented, you know that isn't the case. Sending a notification is simply walking a list and calling some virtual methods. Granted, it's a bit slower than a statically dispatched call, but that cost is negligible in all but the most performance-critical code. I find this pattern fits best outside of hot code paths anyway, so you can usually afford the dynamic dispatch. Aside from Observable physics "It's Too Slow" that, there's virtually no overhead. We aren't allocating objects for messages. There's no queueing. It's just an indirection over a synchronous method call. In fact, you have to be careful because the Observer pattern is synchronous. The subject invokes its observers directly, which means it doesn't resume its own work until all of the observers have returned from their notification methods. A slow observer can block a subject. This sounds scary, but in practice, it's not the end of the world. It's just something you have to be aware of. UI programmers -- who've been doing event-based programming like this for ages -- have a time-worn motto for this: "stay off the UI thread". If you're responding to an event synchronously, you need to finish and return control as quickly as possible so that the UI doesn't lock up. When you have slow work to do, push it onto another thread or a work queue. You do have to be careful mixing observers with threading and explicit locks, though. If an observer tries to grab a lock that the subject has, you can deadlock the game. In a highly threaded engine, you may be better off with asynchronous communication using an Event Queue. Whole tribes of the programmer clan -- including many game developers -- have moved onto garbage collected languages, and dynamic allocation isn't the boogie man that it used to be. But for performance-critical software like games, memory allocation still matters, even in managed languages. Dynamic allocation takes time, as does reclaiming memory, even if it happens automatically. Many game developers are less worried about allocation and more worried about fragmentation. When your game needs to run continuously for days without crashing in order to get certified, an increasingly fragmented heap can prevent you from shipping. The Object Pool chapter goes into more detail about this and a common technique for avoiding it. In the example code before, I used a fixed array because I'm trying to keep things dead simple. In real implementations, the observer list is almost always a dynamically allocated collection that grows and shrinks as observers are added and removed. That memory churn spooks some people. Of course, the first thing to notice is that it only allocates memory when observers are being wired up. Sending a notification requires no memory allocation whatsoever -- it's just a method call. If you hook up your observers at the start of the game and don't mess with them much, the amount of allocation is minimal. If it's still a problem, though, I'll walk through a way to implement adding and removing observers without any dynamic allocation at all. In the code we've seen so far, Subject owns a list of pointers to each Observer watching it. The Observer class itself has no reference to this list. It's just a pure virtual interface. Interfaces are preferred over concrete, stateful classes, so that's generally a good thing. But if we are willing to put a bit of state in Observer , we can solve our allocation problem by threading the subject's list through the observers themselves. Instead of the subject having a separate collection of pointers, the observer objects become nodes in a linked list: To implement this, first we'll get rid of the array in Subject and replace it with a pointer to the head of the list of observers: ^code linked-subject Then we'll extend Observer with a pointer to the next observer in the list: It's too fast? "It Does Too Much Dynamic Allocation" Linked observers ^code linked-observer We're also making Subject a friend class here. The subject owns the API for adding and removing observers, but the list it will be managing is now inside the Observer class itself. The simplest way to give it the ability to poke at that list is by making it a friend. Registering a new observer is just wiring it into the list. We'll take the easy option and insert it at the front: ^code linked-add The other option is to add it to the end of the linked list. Doing that adds a bit more complexity. Subject has to either walk the list to find the end or keep a separate tail_ pointer that always points to the last node. Adding it to the front of the list is simpler, but does have one side effect. When we walk the list to send a notification to every observer, the most recently registered observer gets notified first. So if you register observers A, B, and C, in that order, they will receive notifications in C, B, A order. In theory, this doesn't matter one way or the other. It's a tenet of good observer discipline that two observers observing the same subject should have no ordering dependencies relative to each other. If the ordering does matter, it means those two observers have some subtle coupling that could end up biting you. Let's get removal working: ^code linked-remove Removing a node from a linked list usually requires a bit of ugly special case handling for removing the very first node, like you see here. There's a more elegant solution using a pointer to a pointer. I didn't do that here because it confuses at least half the people I show it to. It's a worthwhile exercise for you to do, though: It helps you really think in terms of pointers. Because we have a singly linked list, we have to walk it to find the observer we're removing. We'd have to do the same thing if we were using a regular array for that matter. If we use a doubly linked list, where each observer has a pointer to both the observer after it and before it, we can remove an observer in constant time. If this were real code, I'd do that. The only thing left to do is send a notification. That's as simple as walking the list: ^code linked-notify Here, we walk the entire list and notify every single observer in it. This ensures that all of the observers get equal priority and are independent of each other. We could tweak this such that when an observer is notified, it can return a flag indicating whether the subject should keep walking the list or stop. If you do that, you're pretty close to having the Chain of Responsibility pattern. Not too bad, right? A subject can have as many observers as it wants, without a single whiff of dynamic memory. Registering and unregistering is as fast as it was with a simple array. We have sacrificed one small feature, though. Since we are using the observer object itself as a list node, that implies it can only be part of one subject's observer list. In other words, an observer can only observe a single subject at a time. In a more traditional implementation where each subject has its own independent list, an observer can be in more than one of them simultaneously. You may be able to live with that limitation. I find it more common for a subject to have multiple observers than vice versa. If it is a problem for you, there is another more complex solution you can use that still doesn't require dynamic allocation. It's too long to cram into this chapter, but I'll sketch it out and let you fill in the blanks... Like before, each subject will have a linked list of observers. However, those list nodes won't be the observer objects themselves. Instead, they'll be separate little "list node" objects that contain a pointer to the observer and then a pointer to the next node in the list. A pool of list nodes Since multiple nodes can all point to the same observer, that means an observer can be in more than one subject's list at the same time. We're back to being able to observe multiple subjects simultaneously. Linked lists come in two flavors. In the one you learned in school, you have a node object that contains the data. In our previous linked observer example, that was flipped around: the data (in this case the observer) contained the node (i.e. the next_ pointer). The latter style is called an "intrusive" linked list because using an object in a list intrudes into the definition of that object itself. That makes intrusive lists less flexible but, as we've seen, also more efficient. They're popular in places like the Linux kernel where that trade-off makes sense. The way you avoid dynamic allocation is simple: since all of those nodes are the same size and type, you pre-allocate an Object Pool of them. That gives you a fixed-size pile of list nodes to work with, and you can use and reuse them as you need without having to hit an actual memory allocator. I think we've banished the three boogie men used to scare people off this pattern. As we've seen, it's simple, fast, and can be made to play nice with memory management. But does that mean you should use observers all the time? Now, that's a different question. Like all design patterns, the Observer pattern isn't a cure-all. Even when implemented correctly and efficiently, it may not be the right solution. The reason design patterns get a bad rap is because people apply good patterns to the wrong problem and end up making things worse. Two challenges remain, one technical and one at something more like the maintainability level. We'll do the technical one first because those are always easiest. The sample code we walked through is solid, but it side-steps an important issue: what happens when you delete a subject or an observer? If you carelessly call delete on some observer, a subject may still have a pointer to it. That's now a dangling pointer into deallocated memory. When that subject tries to send a notification, well... let's just say you're not going to have a good time. Not to point fingers, but I'll note that Design Patterns doesn't mention this issue at all. Destroying the subject is easier since in most implementations, the observer doesn't have any references to it. But even then, sending the subject's bits to the memory manager's recycle bin may cause some problems. Those observers may still be expecting to receive notifications in the future, and they don't know that that will never happen now. They aren't observers at all, really, they just think they are. You can deal with this in a couple of different ways. The simplest is to do what I did and just punt on it. It's an observer's job to unregister itself from any subjects when it gets deleted. More often than not, the observer does know which subjects it's observing, so it's usually just a matter of adding a removeObserver() call to its destructor. As is often the case, the hard part isn't doing it, it's remembering to do it. If you don't want to leave observers hanging when a subject gives up the ghost, that's easy to fix. Just have the subject send one final "dying breath" notification right before it gets destroyed. That way, any observer can receive that and take whatever action it thinks is appropriate. Mourn, send flowers, compose elegy, etc. People -- even those of us who've spent enough time in the company of machines to have some of their precise nature rub off on us -- are reliably terrible at being reliable. That's why we invented computers: they don't make the mistakes we so often do. A safer answer is to make observers automatically unregister themselves from every subject when they get destroyed. If you implement the logic for that once in your base observer class, everyone using it doesn't have to remember to do it themselves. This does add some complexity, though. It means each observer will need a list of the subjects it's observing. You end up with pointers going in both directions. Remaining Problems Destroying subjects and observers All you cool kids with your hip modern languages with garbage collectors are feeling pretty smug right now. Think you don't have to worry about this because you never explicitly delete anything? Think again! Imagine this: you've got some UI screen that shows a bunch of stats about the player's character like their health and stuff. When the player brings up the screen, you instantiate a new object for it. When they close it, you just forget about the object and let the GC clean it up. Every time the character takes a punch to the face (or elsewhere, I suppose), it sends a notification. The UI screen observes that and updates the little health bar. Great. Now what happens when the player dismisses the screen, but you don't unregister the observer? The UI isn't visible anymore, but it won't get garbage collected since the character's observer list still has a reference to it. Every time the screen is loaded, we add a new instance of it to that increasingly long list. The entire time the player is playing the game, running around, and getting in fights, the character is sending notifications that get received by all of those screens. They aren't on screen, but they receive notifications and waste CPU cycles updating invisible UI elements. If they do other things like play sounds, you'll get noticeably wrong behavior. This is such a common issue in notification systems that it has a name: the lapsed listener problem. Since subjects retain references to their listeners, you can end up with zombie UI objects lingering in memory. The lesson here is to be disciplined about unregistration. An even surer sign of its significance: it has a Wikipedia article. The other, deeper issue with the Observer pattern is a direct consequence of its intended purpose. We use it because it helps us loosen the coupling between two pieces of code. It lets a subject indirectly communicate with some observer without being statically bound to it. This is a real win when you're trying to reason about the subject's behavior, and any hangers-on would be an annoying distraction. If you're poking at the physics engine, you really don't want your editor -- or your mind -- cluttered up with a bunch of stuff about achievements. On the other hand, if your program isn't working and the bug spans some chain of observers, reasoning about that communication flow is much more difficult. With an explicit coupling, it's as easy as looking up the method being called. This is child's play for your average IDE since the coupling is static. But if that coupling happens through an observer list, the only way to tell who will get notified is by seeing which observers happen to be in that list at runtime. Instead of being able to statically reason about the communication structure of the program, you have to reason about its imperative, dynamic behavior. My guideline for how to cope with this is pretty simple. If you often need to think about both sides of some communication in order to understand a part of the program, don't use the Observer pattern to express that linkage. Prefer something more explicit. When you're hacking on some big program, you tend to have lumps of it that you work on all together. We have lots of terminology for this like "separation of concerns" and "coherence and cohesion" and "modularity", but it boils down to "this stuff goes together and doesn't go with this other stuff". The observer pattern is a great way to let those mostly unrelated lumps talk to each other without them merging into one big lump. It's less useful within a single lump of code dedicated to one feature or aspect. That's why it fits our example well: achievements and physics are almost entirely unrelated domains, likely implemented by different people. We want the bare minimum of communication between them so that working on either one doesn't require much knowledge of the other. Don't worry, I've got a GC What's going on? Design Patterns came out in the 90s. Back then, object-oriented programming was the hot paradigm. Every programmer on Earth wanted to "Learn OOP in 30 Days," and middle managers paid them based on the number of classes they created. Engineers judged their mettle by the depth of their inheritance hierarchies. That same year, Ace of Base had not one but three hit singles, so that may tell you something about our taste and discernment back then. The Observer pattern got popular during that zeitgeist, so it's no surprise that it's class-heavy. But mainstream coders now are more comfortable with functional programming. Having to implement an entire interface just to receive a notification doesn't fit today's aesthetic. It feels heavyweight and rigid. It is heavyweight and rigid. For example, you can't have a single class that uses different notification methods for different subjects. This is why the subject usually passes itself to the observer. Since an observer only has a single onNotify() method, if it's observing multiple subjects, it needs to be able to tell which one called it. A more modern approach is for an "observer" to be only a reference to a method or function. In languages with first-class functions, and especially ones with closures, this is a much more common way to do observers. These days, practically every language has closures. C++ overcame the challenge of closures in a language without garbage collection, and even Java finally got its act together and introduced them in JDK 8. For example, C# has "events" baked into the language. With those, the observer you register is a "delegate", which is that language's term for a reference to a method. In JavaScript's event system, observers can be objects supporting a special EventListener protocol, but they can also just be functions. The latter is almost always what people use. If I were designing an observer system today, I'd make it function-based instead of class-based. Even in C++, I would tend toward a system that let you register member function pointers as observers instead of instances of some Observer interface. [Here's][delegate] an interesting blog post on one way to implement this in C++. [delegate]: http://molecularmusings.wordpress.com/2011/09/19/generic-type-safe-delegates-and-events-in-c/ Event systems and other observer-like patterns are incredibly common these days. They're a well-worn path. But if you write a few large apps using them, you start to notice something. A lot of the code in your observers ends up looking the same. It's usually something like: 1. Get notified that some state has changed. 2. Imperatively modify some chunk of UI to reflect the new state. It's all, "Oh, the hero health is 7 now? Let me set the width of the health bar to 70 pixels." After a while, that gets pretty tedious. Computer science academics and software engineers have been trying to eliminate that tedium for a long time. Their attempts have gone under a number of different names: "dataflow programming", "functional reactive programming", etc. While there have been some successes, usually in limited domains like audio processing or chip design, the Holy Grail still hasn't been found. In the meantime, a less ambitious approach has started gaining traction. Many recent application frameworks now use "data binding". Unlike more radical models, data binding doesn't try to entirely eliminate imperative code and doesn't try to architect your entire application around a giant declarative dataflow graph. What it does do is automate the busywork where you're tweaking a UI element or calculated property to reflect a change to some value. Observers Today Observers Tomorrow Like other declarative systems, data binding is probably a bit too slow and complex to fit inside the core of a game engine. But I would be surprised if I didn't see it start making inroads into less critical areas of the game like UI. In the meantime, the good old Observer pattern will still be here waiting for us. Sure, it's not as exciting as some hot technique that manages to cram both "functional" and "reactive" in its name, but it's dead simple and it works. To me, those are often the two most important criteria for a solution. ^title Prototype ^section Design Patterns Revisited The first time I heard the word "prototype" was in Design Patterns. Today, it seems like everyone is saying it, but it turns out they aren't talking about the design pattern. We'll cover that here, but I'll also show you other, more interesting places where the term "prototype" and the concepts behind it have popped up. But first, let's revisit the original pattern. I don't say "original" lightly here. Design Patterns cites Ivan Sutherland's legendary Sketchpad project in 1963 as one of the first examples of this pattern in the wild. While everyone else was listening to Dylan and the Beatles, Sutherland was busy just, you know, inventing the basic concepts of CAD, interactive graphics, and object-oriented programming. Watch the demo and prepare to be blown away. Pretend we're making a game in the style of Gauntlet. We've got creatures and fiends swarming around the hero, vying for their share of his flesh. These unsavory dinner companions enter the arena by way of "spawners", and there is a different spawner for each kind of enemy. For the sake of this example, let's say we have different classes for each kind of monster in the game -- Ghost , Demon , Sorcerer , etc., like: ^code monster-classes A spawner constructs instances of one particular monster type. To support every monster in the game, we could brute-force it by having a spawner class for each monster class, leading to a parallel class hierarchy: I had to dig up a dusty UML book to make this diagram. The means "inherits from". Implementing it would look like this: ^code spawner-classes Unless you get paid by the line of code, this is obviously not a fun way to hack this together. Lots of classes, lots of boilerplate, lots of redundancy, lots of duplication, lots of repeating myself... The Prototype pattern offers a solution. The key idea is that an object can spawn other objects similar to itself. If you have one ghost, you can make more ghosts from it. If you have a demon, you can make other demons. Any monster can be treated as a prototypal monster used to generate other versions of itself. To implement this, we give our base class, Monster , an abstract clone() method: ^code virtual-clone Each monster subclass provides an implementation that returns a new object identical in class and state to itself. For example: ^code clone-ghost Once all our monsters support that, we no longer need a spawner class for each monster class. Instead, we define a single one: ^code spawner-clone It internally holds a monster, a hidden one whose sole purpose is to be used by the spawner as a template to stamp out Prototype The Prototype Design Pattern more monsters like it, sort of like a queen bee who never leaves the hive. To create a ghost spawner, we create a prototypal ghost instance and then create a spawner holding that prototype: ^code spawn-ghost-clone One neat part about this pattern is that it doesn't just clone the class of the prototype, it clones its state too. This means we could make a spawner for fast ghosts, weak ghosts, or slow ghosts just by creating an appropriate prototype ghost. I find something both elegant and yet surprising about this pattern. I can't imagine coming up with it myself, but I can't imagine not knowing about it now that I do. Well, we don't have to create a separate spawner class for each monster, so that's good. But we do have to implement clone() in each monster class. That's just about as much code as the spawners. There are also some nasty semantic ratholes when you sit down to try to write a correct clone() . Does it do a deep clone or shallow one? In other words, if a demon is holding a pitchfork, does cloning the demon clone the pitchfork too? Also, not only does this not look like it's saving us much code in this contrived problem, there's the fact that it's a contrived problem. We had to take as a given that we have separate classes for each monster. These days, that's definitely not the way most game engines roll. Most of us learned the hard way that big class hierarchies like this are a pain to manage, which is why we instead use patterns like Component and Type Object to model different kinds of entities without enshrining each in its own class. Even if we do have different classes for each monster, there are other ways to decorticate this Felis catus. Instead of making separate spawner classes for each monster, we could make spawn functions, like so: ^code callback This is less boilerplate than rolling a whole class for constructing a monster of some type. Then the one spawner class can simply store a function pointer: ^code spawner-callback To create a spawner for ghosts, you do: ^code spawn-ghost-callback By now, most C++ developers are familiar with templates. Our spawner class needs to construct instances of some type, but we don't want to hard code some specific monster class. The natural solution then is to make it a type parameter, which templates let us do: I'm not sure if C++ programmers learned to love them or if templates just scared some people completely away from C++. Either way, everyone I see using C++ today uses templates too. ^code templates Using it looks like: ^code use-templates How well does it work? Spawn functions Templates The Spawner class here is so that code that doesn't care what kind of monster a spawner creates can just use it and work with pointers to Monster . If we only had the SpawnerFor class, there would be no single supertype the instantiations of that template all shared, so any code that worked with spawners of any monster type would itself need to take a template parameter. The previous two solutions address the need to have a class, Spawner , which is parameterized by a type. In C++, types aren't generally first-class, so that requires some gymnastics. If you're using a dynamically-typed language like JavaScript, Python, or Ruby where classes are regular objects you can pass around, you can solve this much more directly. In some ways, the Type Object pattern is another workaround for the lack of first-class types. That pattern can still be useful even in languages with them, though, because it lets you define what a "type" is. You may want different semantics than what the language's built-in classes provide. When you make a spawner, just pass in the class of monster that it should construct -- the actual runtime object that represents the monster's class. Easy as pie. With all of these options, I honestly can't say I've found a case where I felt the Prototype design pattern was the best answer. Maybe your experience will be different, but for now let's put that away and talk about something else: prototypes as a language paradigm. Many people think "object-oriented programming" is synonymous with "classes". Definitions of OOP tend to feel like credos of opposing religious denominations, but a fairly non-contentious take on it is that OOP lets you define "objects" which bundle data and code together. Compared to structured languages like C and functional languages like Scheme, the defining characteristic of OOP is that it tightly binds state and behavior together. You may think classes are the one and only way to do that, but a handful of guys including Dave Ungar and Randall Smith beg to differ. They created a language in the 80s called Self. While as OOP as can be, it has no classes. In a pure sense, Self is more object-oriented than a class-based language. We think of OOP as marrying state and behavior, but languages with classes actually have a line of separation between them. Consider the semantics of your favorite class-based language. To access some state on an object, you look in the memory of the instance itself. State is contained in the instance. To invoke a method, though, you look up the instance's class, and then you look up the method there. Behavior is contained in the class. There's always that level of indirection to get to a method, which means fields and methods are different. For example, to invoke a virtual method in C++, you look in the instance for the pointer to its vtable, then look up the method there. Self eliminates that distinction. To look up anything, you just look on the object. An instance can contain both state and behavior. You can have a single object that has a method completely unique to it. No man is an island, but this object is. If that was all Self did, it would be hard to use. Inheritance in class-based languages, despite its faults, gives you a useful mechanism for reusing polymorphic code and avoiding duplication. To accomplish something similar without classes, Self has delegation. First-class types The Prototype Language Paradigm Self To find a field or call a method on some object, we first look in the object itself. If it has it, we're done. If it doesn't, we look at the object's parent. This is just a reference to some other object. When we fail to find a property on the first object, we try its parent, and its parent, and so on. In other words, failed lookups are delegated to an object's parent. I'm simplifying here. Self actually supports multiple parents. Parents are just specially marked fields, which means you can do things like inherit parents or change them at runtime, leading to what's called dynamic inheritance. Parent objects let us reuse behavior (and state!) across multiple objects, so we've covered part of the utility of classes. The other key thing classes do is give us a way to create instances. When you need a new thingamabob, you can just do new Thingamabob() , or whatever your preferred language's syntax is. A class is a factory for instances of itself. Without classes, how do we make new things? In particular, how do we make a bunch of new things that all have stuff in common? Just like the design pattern, the way you do this in Self is by cloning. In Self, it's as if every object supports the Prototype design pattern automatically. Any object can be cloned. To make a bunch of similar objects, you: 1. Beat one object into the shape you want. You can just clone the base Object built into the system and then stuff fields and methods into it. 2. Clone it to make as many... uh... clones as you want. This gives us the elegance of the Prototype design pattern without the tedium of having to implement clone() ourselves; it's built into the system. This is such a beautiful, clever, minimal system that as soon as I learned about it, I started creating a prototype-based language to get more experience with it. I realize building a language from scratch is not the most efficient way to learn, but what can I say? I'm a bit peculiar. If you're curious, the language is called Finch. I was super excited to play with a pure prototype-based language, but once I had mine up and running, I discovered an unpleasant fact: it just wasn't that fun to program in. I've since heard through the grapevine that many of the Self programmers came to the same conclusion. The project was far from a loss, though. Self was so dynamic that it needed all sorts of virtual machine innovations in order to run fast enough. The ideas they invented for just-in-time compilation, garbage collection, and optimizing method dispatch are the exact same techniques -- often implemented by the same people! -- that now make many of the world's dynamically-typed languages fast enough to use for massively popular applications. Sure, the language was simple to implement, but that was because it punted the complexity onto the user. As soon as I started trying to use it, I found myself missing the structure that classes give. I ended up trying to recapitulate it at the library level since the language didn't have it. Maybe this is because my prior experience is in class-based languages, so my mind has been tainted by that paradigm. But my hunch is that most people just like well-defined "kinds of things". In addition to the runaway success of class-based languages, look at how many games have explicit character classes and a precise roster of different sorts of enemies, items, and skills, each neatly labeled. You don't see many games where each monster is a unique snowflake, like "sort of halfway between a troll and a goblin with a bit of snake mixed in". While prototypes are a really cool paradigm and one that I wish more people knew about, I'm glad that most of us aren't actually programming using them every day. The code I've seen that fully embraces prototypes has a weird mushiness to it that I find hard to wrap my head around. It's also telling how little code there actually is written in a prototypal style. I've looked. How did it go? OK, if prototype-based languages are so unfriendly, how do I explain JavaScript? Here's a language with prototypes used by millions of people every day. More computers run JavaScript than any other language on Earth. Brendan Eich, the creator of JavaScript, took inspiration directly from Self, and many of JavaScript's semantics are prototype-based. Each object can have an arbitrary set of properties, both fields and "methods" (which are really just functions stored as fields). An object can also have another object, called its "prototype", that it delegates to if a field access fails. As a language designer, one appealing thing about prototypes is that they are simpler to implement than classes. Eich took full advantage of this: the first version of JavaScript was created in ten days. But, despite that, I believe that JavaScript in practice has more in common with class-based languages than with prototypal ones. One hint that JavaScript has taken steps away from Self is that the core operation in a prototype-based language, cloning, is nowhere to be seen. There is no method to clone an object in JavaScript. The closest it has is Object.create() , which lets you create a new object that delegates to an existing one. Even that wasn't added until ECMAScript 5, fourteen years after JavaScript came out. Instead of cloning, let me walk you through the typical way you define types and create objects in JavaScript. You start with a constructor function: :::javascript function Weapon(range, damage) { this.range = range; this.damage = damage; } This creates a new object and initializes its fields. You invoke it like: :::javascript var sword = new Weapon(10, 16); The new here invokes the body of the Weapon() function with this bound to a new empty object. The body adds a bunch of fields to it, then the now-filled-in object is automatically returned. The new also does one other thing for you. When it creates that blank object, it wires it up to delegate to a prototype object. You can get to that object directly using Weapon.prototype . While state is added in the constructor body, to define behavior, you usually add methods to the prototype object. Something like this: :::javascript Weapon.prototype.attack = function(target) { if (distanceTo(target) > this.range) { console.log("Out of range!"); } else { target.health -= this.damage; } } This adds an attack property to the weapon prototype whose value is a function. Since every object returned by new Weapon() delegates to Weapon.prototype , you can now call sword.attack() and it will call that function. It looks a bit like this: Let's review: What about JavaScript? The way you create objects is by a "new" operation that you invoke using an object that represents the type -- the constructor function. State is stored on the instance itself. Behavior goes through a level of indirection -- delegating to the prototype -- and is stored on a separate object that represents the set of methods shared by all objects of a certain type. Call me crazy, but that sounds a lot like my description of classes earlier. You can write prototype-style code in JavaScript (sans cloning), but the syntax and idioms of the language encourage a class-based approach. Personally, I think that's a good thing. Like I said, I find doubling down on prototypes makes code harder to work with, so I like that JavaScript wraps the core semantics in something a little more classy. OK, I keep talking about things I don't like prototypes for, which is making this chapter a real downer. I think of this book as more comedy than tragedy, so let's close this out with an area where I do think prototypes, or more specifically delegation, can be useful. If you were to count all the bytes in a game that are code compared to the ones that are data, you'd see the fraction of data has been increasing steadily since the dawn of programming. Early games procedurally generated almost everything so they could fit on floppies and old game cartridges. In many games today, the code is just an "engine" that drives the game, which is defined entirely in data. That's great, but pushing piles of content into data files doesn't magically solve the organizational challenges of a large project. If anything, it makes it harder. The reason we use programming languages is because they have tools for managing complexity. Instead of copying and pasting a chunk of code in ten places, we move it into a function that we can call by name. Instead of copying a method in a bunch of classes, we can put it in a separate class that those classes inherit from or mix in. When your game's data reaches a certain size, you really start wanting similar features. Data modeling is a deep subject that I can't hope to do justice here, but I do want to throw out one feature for you to consider in your own games: using prototypes and delegation for reusing data. Let's say we're defining the data model for the shameless Gauntlet rip-off I mentioned earlier. The game designers need to specify the attributes for monsters and items in some kind of files. I mean completely original title in no way inspired by any previously existing top-down multi-player dungeon crawl arcade games. Please don't sue me. One common approach is to use JSON. Data entities are basically maps, or property bags, or any of a dozen other terms because there's nothing programmers like more than inventing a new name for something that already has one. We've re-invented them so many times that Steve Yegge calls them "The Universal Design Pattern". So a goblin in the game might be defined something like this: :::json { "name": "goblin grunt", "minHealth": 20, "maxHealth": 30, "resists": ["cold", "poison"], "weaknesses": ["fire", "light"] } This is pretty straightforward and even the most text-averse designer can handle that. So you throw in a couple of sibling branches on the Great Goblin Family Tree: Prototypes for Data Modeling :::json { "name": "goblin wizard", "minHealth": 20, "maxHealth": 30, "resists": ["cold", "poison"], "weaknesses": ["fire", "light"], "spells": ["fire ball", "lightning bolt"] } { "name": "goblin archer", "minHealth": 20, "maxHealth": 30, "resists": ["cold", "poison"], "weaknesses": ["fire", "light"], "attacks": ["short bow"] } Now, if this was code, our aesthetic sense would be tingling. There's a lot of duplication between these entities, and well- trained programmers hate that. It wastes space and takes more time to author. You have to read carefully to tell if the data even is the same. It's a maintenance headache. If we decide to make all of the goblins in the game stronger, we need to remember to update the health of all three of them. Bad bad bad. If this was code, we'd create an abstraction for a "goblin" and reuse that across the three goblin types. But dumb JSON doesn't know anything about that. So let's make it a bit smarter. We'll declare that if an object has a "prototype" field, then that defines the name of another object that this one delegates to. Any properties that don't exist on the first object fall back to being looked up on the prototype. This makes the "prototype" a piece of metadata instead of data. Goblins have warty green skin and yellow teeth. They don't have prototypes. Prototypes are a property of the data object representing the goblin, and not the goblin itself. With that, we can simplify the JSON for our goblin horde: :::json { "name": "goblin grunt", "minHealth": 20, "maxHealth": 30, "resists": ["cold", "poison"], "weaknesses": ["fire", "light"] } { "name": "goblin wizard", "prototype": "goblin grunt", "spells": ["fire ball", "lightning bolt"] } { "name": "goblin archer", "prototype": "goblin grunt", "attacks": ["short bow"] } Since the archer and wizard have the grunt as their prototype, we don't have to repeat the health, resists, and weaknesses in each of them. The logic we've added to our data model is super simple -- basic single delegation -- but we've already gotten rid of a bunch of duplication. One interesting thing to note here is that we didn't set up a fourth "base goblin" abstract prototype for the three concrete goblin types to delegate to. Instead, we just picked one of the goblins who was the simplest and delegated to it. That feels natural in a prototype-based system where any object can be used as a clone to create new refined objects, and I think it's equally natural here too. It's a particularly good fit for data in games where you often have one-off special entities in the game world. Think about bosses and unique items. These are often refinements of a more common object in the game, and prototypal delegation is a good fit for defining those. The magic Sword of Head-Detaching, which is really just a longsword with some bonuses, can be expressed as that directly: :::json { "name": "Sword of Head-Detaching", "prototype": "longsword", "damageBonus": "20" } A little extra power in your game engine's data modeling system can make it easier for designers to add lots of little variations to the armaments and beasties populating your game world, and that richness is exactly what delights players. This chapter is an anomaly. Every other chapter in this book shows you how to use a design pattern. This chapter shows you how not to use one. 这节有点反常。其他章节都是告诉你如何使用一个模式。本节却是告诉你如何不使用一个模式。 Despite noble intentions, the Singleton pattern described by the Gang of Four usually does more harm than good. They stress that the pattern should be used sparingly, but that message was often lost in translation to the game industry. 尽管一再告诫,在四人帮的单件模式描述中,它通常缺点大于优点。他们一再强调这个模式应当谨慎的使用,但是当应用在 游戏行业时,这个强调通常被忽略了。 Like any pattern, using Singleton where it doesn't belong is about as helpful as treating a bullet wound with a splint. Since it's so overused, most of this chapter will be about avoiding singletons, but first, let's go over the pattern itself. 和其他模式一样,在不合适的地方使用单件模式,就像用夹板来治疗枪伤一样毫无用处。既然它已经被过度使用了,本节的 大部分内容都是关于避免使用单件。不过首先,我们来看看模式本身。 >When much of the industry moved to object-oriented programming from C, one problem they ran into was "how do I get an instance?" They had some method they wanted to call but didn't have an instance of the object that provides that method in hand. Singletons (in other words, making it global) were an easy way out. >自从工业界大部分从C转向面向对象 编程之后,一个摆在面前的问题就是“如何得到一个实例?”,他们有一些想要调用的方法,但是手上却没有这个对象的实 例。单件(或者,使之全局化)是最简单的解决方法。 Design Patterns summarizes Singleton like this: 设计模式这样总结单件: Ensure a class has one instance, and provide a global point of access to it. 确保一个类只有一个实例,并提供一个全局的指针访问它。 We'll split that at "and" and consider each half separately. 我们将分别讨论“并”前后的两点。 There are times when a class cannot perform correctly if there is more than one instance of it. The common case is when the class interacts with an external system that maintains its own global state. 在有些情况下,一个类如果有多个实例就不能正常运作。最常见的情况就是这个类和一些关联全局状态的额外类进行交互。 Consider a class that wraps an underlying file system API. Because file operations can take a while to complete, our class performs operations asynchronously. This means multiple operations can be running concurrently, so they must be coordinated with each other. If we start one call to create a file and another one to delete that same file, our wrapper needs to be aware of both to make sure they don't interfere with each other. 比如说一个封装了底层文件API的类。因为文件操作需要一定时间去完成,我们的类将异步地处理。这意味着许多操作可以 同时进行,所以他们必须相互协调。如果我们一方面创建文件,一方面去删除这个文件,我们的封装类就必须全部感知,并 确保他们不会相互干扰。 Singleton 单件模式 The Singleton Pattern 单件模式 Restricting a class to one instance 确保一个类只有一个实例 To do this, a call into our wrapper needs to have access to every previous operation. If users could freely create instances of our class, one instance would have no way of knowing about operations that other instances started. Enter the singleton. It provides a way for a class to ensure at compile time that there is only a single instance of the class. 为了做到这点,对封装类的调用必须能够访问之前的操作。如果使用者能够自由的创建这个类的实例,一个实例就不能够知 道其他实例所做的操作。在单件模式中,他提供了一个编译期能确保某个类只有一个实例的方法。 Several different systems in the game will use our file system wrapper: logging, content loading, game state saving, etc. If those systems can't create their own instances of our file system wrapper, how can they get ahold of one? 游戏中一些其他系统需要用到我们的文件系统封装:日志、文件加载、游戏保存等等。如果这些系统不能够创建他们各自的 文件封装实例,他们如果去得到一个呢? Singleton provides a solution to this too. In addition to creating the single instance, it also provides a globally available method to get it. This way, anyone anywhere can get their paws on our blessed instance. All together, the classic implementation looks like this: 单件提供了一个解决方法。除了创建一个单独的实例外,他还提供一个全局的方法去得到这个实例。这样,就能在其他任何 地方都能够等到这个实例了。总体说来,这个类的实现起来像如下这个样子: class FileSystem { public: static FileSystem& instance() { // Lazy initialize. if (instance_ == NULL) instance_ = new FileSystem(); return *instance_; } private: FileSystem() {} static FileSystem* instance_; }; The static instance_ member holds an instance of the class, and the private constructor ensures that it is the only one. The public static instance() method grants access to the instance from anywhere in the codebase. It is also responsible for instantiating the singleton instance lazily the first time someone asks for it. instance_ 这个静态成员保存这这个类的一个实例,私有的构造函数确保他是唯一的一个实例。静态函数 instance() 提供了 一个方法能在其他地方得到这个实例。它也负责在第一次访问的时候初始化这个实例,这也叫延时创建。 A modern take looks like this: 实现起来如下: class FileSystem { public: static FileSystem& instance() { static FileSystem *instance = new FileSystem(); return *instance; } private: FileSystem() {} }; C++11 mandates that the initializer for a local static variable is only run once, even in the presence of concurrency. So, Providing a global point of access 提供一个全局指针访问 assuming you've got a modern C++ compiler, this code is thread-safe where the first example is not. C++11 初始化一个局部静态变量时只会运行一次,哪怕是在多线程的情况下也是一样。所以,如果你有一个现代C++编译器 的话,下面的代码是线程安全的,而上面的例子却不是: >Of course, the thread-safety of your singleton class itself is an entirely different question! This just ensures that its initialization is. >当然,你的单件类本身的线程安全行完全是另外一个问题!这只是确保他的初始化是线程安全的。 It seems we have a winner. Our file system wrapper is available wherever we need it without the tedium of passing it around everywhere. The class itself cleverly ensures we won't make a mess of things by instantiating a couple of instances. It's got some other nice features too: 看起来我们取得了成效。我们的文件封装能够在任何地方使用而不必将它传递的到处都是。这个类本身机智的保证了我们不 会初始化多个实例而将事情弄糟。它还具有一些额外的优良特性。 It doesn't create the instance if no one uses it. Saving memory and CPU cycles is always good. Since the singleton is initialized only when it's first accessed, it won't be instantiated at all if the game never asks for it. 如果我们不使用,就不会创建实例 节省内存和CPU周期始终是好的。既然单件只在第一次访问的时 候初始化,如果我 们游戏始终不使用就不会初始化。 It's initialized at runtime. A common alternative to Singleton is a class with static member variables. I like simple solutions, so I use static classes instead of singletons when possible, but there's one limitation static members have: automatic initialization. The compiler initializes statics before main() is called. This means they can't use information known only once the program is up and running (for example, configuration loaded from a file). It also means they can't reliably depend on each other -- the compiler does not guarantee the order in which statics are initialized relative to each other. 他在运行期初始化一个单件的变种是包含多个静态成员的类。我喜欢简单的解决方案,所以我多会使 用静态类而不是单 件。但是静态类有一个缺点:自动初始化。编译器早在 main() 函数调用之前就初始 化静态成员了。这以为着他们不能 使用只有游戏运行起来才能知道的信息(比如,文件配置)。它还意味 着他们之间不能相互依赖——编译器不能保证他 们之间的初始化的顺序。 Lazy initialization solves both of those problems. The singleton will be initialized as late as possible, so by that time any information it needs should be available. As long as they don't have circular dependencies, one singleton can even refer to another when initializing itself. 延时初始化解决了以上所有问题。单件会尽可能的延时创建,所以他们需要的信息都是可以得到的。只要 不是循环依 赖,一个单件在初始化的时候可以依赖另外一个单件。 You can subclass the singleton. This is a powerful but often overlooked capability. Let's say we need our file system wrapper to be cross-platform. To make this work, we want it to be an abstract interface for a file system with subclasses that implement the interface for each platform. Here is the base class: 你可以继承单件 这是一个强大但是过多使用的能力。假设我们需要我们的文件封装跨平台。为了实 现这一点,我们将它 实现为一个抽象接口,他的子类提供各个平台上的实现。下面是基本的结构: class FileSystem { public: virtual ~FileSystem() {} virtual char* readFile(char* path) = 0; virtual void writeFile(char* path, char* contents) = 0; }; Then we define derived classes for a couple of platforms: 之后,我们为不同平台定义派生类: Why We Use It 为什么需要使用 class PS3FileSystem : public FileSystem { public: virtual char* readFile(char* path) { // Use Sony file IO API... } virtual void writeFile(char* path, char* contents) { // Use sony file IO API... } }; class WiiFileSystem : public FileSystem { public: virtual char* readFile(char* path) { // Use Nintendo file IO API... } virtual void writeFile(char* path, char* contents) { // Use Nintendo file IO API... } }; Next, we turn FileSystem into a singleton: 接下来,我们将 FileSystem 变为一个单件: class FileSystem { public: static FileSystem& instance(); virtual ~FileSystem() {} virtual char* readFile(char* path) = 0; virtual void writeFile(char* path, char* contents) = 0; protected: FileSystem() {} }; The clever part is how the instance is created: 机智之处是如何创建实例的: FileSystem& FileSystem::instance() { #if PLATFORM == PLAYSTATION3 static FileSystem *instance = new PS3FileSystem(); #elif PLATFORM == WII static FileSystem *instance = new WiiFileSystem(); #endif return *instance; } With a simple compiler switch, we bind our file system wrapper to the appropriate concrete type. Our entire codebase can access the file system using FileSystem::instance() without being coupled to any platform-specific code. That coupling is instead encapsulated within the implementation file for the FileSystem class itself. 随着一个简单的编译跳转,我们将文件封装编译到正确的系统上。我们整个代码可以通过 FileSystem::instance() 来访 问文件系统,而不必加上任何平台相关的代码。关联的代码封装在实现 FileSystem 这个类的文件之中了。 This takes us about as far as most of us go when it comes to solving a problem like this. We've got a file system wrapper. It works reliably. It's available globally so every place that needs it can get to it. It's time to check in the code and celebrate with a tasty beverage. 它花费了我们我们之中绝大数人解决这类问题所花费的时间(译注:绝大部分人解决这类问题到此为止)。我们得到了一个文 件封装。他工作的很好,它全局可用,每处需要使用的地方都能访问它。是时候提交代码,来点美味的饮料庆祝了。 In the short term, the Singleton pattern is relatively benign. Like many design choices, we pay the cost in the long term. Once we've cast a few unnecessary singletons into cold hard code, here's the trouble we've bought ourselves: 在短期内,单件模式是相对温和的。像其他一些设计取舍一样,我们会在长时间内付出代价。一旦我们将一些不必要的单件 扔到了冰硬的代码之中,我们就为自己带来了一系列的麻烦。 When games were still written by a couple of guys in a garage, pushing the hardware was more important than ivory-tower software engineering principles. Old-school C and assembly coders used globals and statics without any trouble and shipped good games. As games got bigger and more complex, architecture and maintainability started to become the bottleneck. We struggled to ship games not because of hardware limitations, but because of productivity limitations. 当游戏还是车库里的借个家伙写的时候,硬件要比软件工程准则更为总要。随着游戏变得更大更复,架构和开始变为短板。 我们挣扎这放弃游戏不是应为硬件限制,而是因为开发效率 So we moved to languages like C++ and started applying some of the hard-earned wisdom of our software engineer forebears. One lesson we learned is that global variables are bad for a variety of reasons: 所以我们开始学习C++这样的语言,并且应用我们软件开发前驱总结的智慧。我们学到的一个教训就是,全局变量是有害 的。理由如下: They make it harder to reason about code. Say we're tracking down a bug in a function someone else wrote. If that function doesn't touch any global state, we can wrap our heads around it just by understanding the body of the function and the arguments being passed to it. 他们导致能更难理解的代码 假设我们正在跟踪一个bug。如果这个函数不使用全局状态,我们可以 将精力集中起来,只 要理解他的函数体就可以了,和传递给他的参数就可以了。 >Computer scientists call functions that don't access or modify global state "pure" functions. Pure functions are easier to reason about, easier for the compiler to optimize, and let you do neat things like memoization where you cache and reuse the results from previous calls to the function. >计算机科学家称不访问或者不修改全局状态的函数为“纯函数”。纯 函数易于理解,利于编译器优化。 >While there are challenges to using purity exclusively, the benefits are enticing enough that computer scientists have created languages like Haskell that only allow pure functions. >因为全部使用纯 函数有不少挑战,但是足够诱使计算机科学家发明Haskell这样只允许存函数的语言。 Now, imagine right in the middle of that function is a call to SomeClass::getSomeGlobalData() . To figure out what's going on, we have to hunt through the entire codebase to see what touches that global data. You don't really hate global state until you've had to grep a million lines of code at three in the morning trying to find the one errant call that's setting a static variable to the wrong value. 现在,让我们来看这个函数中间的 SomeClass::getSomeGlobalData() 这个调用。为了搞清楚其中发生了什么,我们需要查 看整个代码库来看是谁访问了全局状态。在你不得不大清晨 grep 百万行代码来找出究竟是那一个错误的调用将一个静态 变量设置错了之前,你是不会真正痛恨全局状态的。 They encourage coupling. The new coder on your team isn't familiar with your game's beautifully maintainable loosely coupled architecture, but he's just been given his first task: make boulders play sounds when they crash onto the ground. You and I know we don't want the physics code to be coupled to audio of all things, but he's just trying to get his task done. Unfortunately for us, the instance of our AudioPlayer is globally visible. So, one little #include later, and our new guy has compromised a carefully constructed architecture. Why We Regret Using It 为什么后悔使用 It's a global variable 他是一个全局变量 这了促进了耦合。 你团队的开发新手不熟悉游戏优美的松耦合架构,但是他却有了第一项任务:让石头撞在地上时发出 声音。你我都知道,我们不想让物理引擎代码和音频代码耦合起来,但是新手只是一心想完成任务。不幸的是,我们 的 AudioPlayer 这个类实例是全局可见的。所以,在一小段 #include 之后,我们的新伙伴搞乱了一个仔细构建的架构。 Without a global instance of the audio player, even if he did #include the header, he still wouldn't be able to do anything with it. That difficulty sends a clear message to him that those two modules should not know about each other and that he needs to find another way to solve his problem. By controlling access to instances, you control coupling. 如果没有音频播放器的全局实例,即使他确实 #include 头文件,他也不能做任何事情。这个困难度给他传递了一个明确 的消息,这两个模块不应该相互了解,他应该找另外的方式去解决这个问题。通过控制实例的访问,你控制了耦合。 They aren't concurrency-friendly. The days of games running on a simple single-core CPU are pretty much over. Code today must at the very least work in a multi-threaded way even if it doesn't take full advantage of concurrency. When we make something global, we've created a chunk of memory that every thread can see and poke at, whether or not they know what other threads are doing to it. That path leads to deadlocks, race conditions, and other hell-to-fix thread-synchronization bugs. 它对并发不友好现在在单核上运行游戏的日子已经很远了。即使他们有利用到并发的全部优势。当我们设置为全局时, 我们创建了一段内存,每个线程都能够查看和修改它,不管他们时候知道其他线程正在操作它。这有可能导致死锁,条 件竞争,和其他一些难以修复的线程同步的Bug。 Issues like these are enough to scare us away from declaring a global variable, and thus the Singleton pattern too, but that still doesn't tell us how we should design the game. How do you architect a game without global state? 上面这些问题足够吓退我们去声明一个全局变量了,同样也适用于单件模式,但是现在还是没有告诉我们该如何设计游戏。 在没有全局状态的情况下,该如何构建游戏呢? There are some extensive answers to that question (most of this book in many ways is an answer to just that), but they aren't apparent or easy to come by. In the meantime, we have to get games out the door. The Singleton pattern looks like a panacea. It's in a book on object-oriented design patterns, so it must be architecturally sound, right? And it lets us design software the way we have been doing for years. 这个问题有几个拓展的答案(本书的绝大部分从某些方面来说就是这个),但是他们不是和明显或者简单能够得到。 与此同 时,我们需要发布我们的游戏。单件模式就像一帖万能药。他在一本关于面向对象设计模式中,所以它肯定是架构合理的, 对吧?并且他能像之前我们开发了N年那样去设计软件。 Unfortunately, it's more placebo than cure. If you scan the list of problems that globals cause, you'll notice that the Singleton pattern doesn't solve any of them. That's because a singleton is global state -- it's just encapsulated in a class. 不幸的是,它更多的是一种宽慰而不是解决方法。如果你浏览一边全局对象造成的问题,你会注意到单件模式没有解决任何 一个。这是因为,一个单件就是全局状态——他只是被封装到了一个类中而已。 The word "and" in the Gang of Four's description of Singleton is a bit strange. Is this pattern a solution to one problem or two? What if we have only one of those? Ensuring a single instance is useful, but who says we want to let everyone poke at it? Likewise, global access is convenient, but that's true even for a class that allows multiple instances. 在四人帮的单件模式中那个“和”这个词有点奇怪。这个模式解决的是一个问题还是两个问题?如果我们只用其中的一个问题 怎么办?确保一个实例是很有用的,但是谁说我们需要所有的东西都像这样?就好比,全局访问是很方便,但是允许有多个 实例却是很常见的。 The latter of those two problems, convenient access, is almost always why we turn to the Singleton pattern. Consider a logging class. Most modules in the game can benefit from being able to log diagnostic information. However, passing an instance of our Log class to every single function clutters the method signature and distracts from the intent of the code. 这两个问题的后者,便利的访问,是我们使用单件模式的主要原因。比如一个日志类。许多游戏中的模块都能够从日子模块 It solves two problems even when you just have one 即便你只有一个问题,它却解 决了两个 中获得好处,但是, The obvious fix is to make our Log class a singleton. Every function can then go straight to the class itself to get an instance. But when we do that, we inadvertently acquire a strange little restriction. All of a sudden, we can no longer create more than one logger. 很显然,修正这点就是让我们的 Log 变为一个单件。每个函数都能直接通过这个类得到这个类的实例。但是当我们这样做 是,我们奇怪的得到了一个限制。突然的,我们不能够创建更多的日志器了。 At first, this isn't a problem. We're writing only a single log file, so we only need one instance anyway. Then, deep in the development cycle, we run into trouble. Everyone on the team has been using the logger for their own diagnostics, and the log file has become a massive dumping ground. Programmers have to wade through pages of text just to find the one entry they care about. 起初,这并不是一个问题,我们只写一个日志文件,所以我们只需要一个日志实例。之后,随着开发的深入,我们陷入了麻 烦。团队的每个人都使用这个日志器来记录他们自己的日志。 We'd like to fix this by partitioning the logging into multiple files. To do this, we'll have separate loggers for different game domains: online, UI, audio, gameplay. But we can't. Not only does our Log class no longer allow us to create multiple instances, that design limitation is entrenched in every single call site that uses it: 我们可以通过将日子分割为不同的文件来修正。我们将日志分为不同的游戏区域:在线、界面、音频、游戏。但是我们不能 够。不仅仅是应为我们的 Log 类不允许我们创建多个实例,还有这个模式的每个单次调用都是像如下这样使用的。 Log::instance().write("Some event."); In order to make our Log class support multiple instantiation (like it originally did), we'll have to fix both the class itself and every line of code that mentions it. Our convenient access isn't so convenient anymore. 为了是我们的 Log 类能够支持多个初始化(想他原来的那样)。我们需要修改这个类的本身和每处调用这个类的地方。我们 便利的访问也不那么便利了。 >It could be even worse than this. Imagine your Log class is in a library being shared across several games. Now, to change the design, you'll have to coordinate the change across several groups of people, most of whom have neither the time nor the motivation to fix it. >情况也许会比这样更为糟糕。假如你的 Log 内在多个游戏共享的一个库文件中。现在,修改 设计,你需要考虑的不同团队的人,他们之中的大部分人都没有时间也没有动机去修改它。 In the desktop PC world of virtual memory and soft performance requirements, lazy initialization is a smart trick. Games are a different animal. Initializing a system can take time: allocating memory, loading resources, etc. If initializing the audio system takes a few hundred milliseconds, we need to control when that's going to happen. If we let it lazy-initialize itself the first time a sound plays, that could be in the middle of an action-packed part of the game, causing visibly dropped frames and stuttering gameplay. 为了满足PC游戏内存和软件效率的需求,延时实例化是一个聪明的技巧。游戏是个不同的怪兽。实例化一个系统需要花费时 间:分配内存,加载资源等等。如果实例化音频系统需要花费几百毫秒,我们需要控制住何时实例化。如果我们让他在第一次 播放声音的时候延时实例化,这有可能在游戏正酣的时候,导致明显的掉帧和游戏卡顿。 Likewise, games generally need to closely control how memory is laid out in the heap to avoid fragmentation. If our audio system allocates a chunk of heap when it initializes, we want to know when that initialization is going to happen, so that we can control where in the heap that memory will live. 同样的,游戏通常需要仔细的控制内存在堆中的布局来防止分段。如果我们的音频系统在实例化是分配了内存,我们需要知 道实例化发生的时间,以便让我们控制它在堆中的内存布局。 >See Object Pool for a detailed explanation of memory fragmentation. >查看 对象池 活的内存分段的详细解释。 Lazy initialization takes control away from you 延迟初始化脱离了你的控制 Because of these two problems, most games I've seen don't rely on lazy initialization. Instead, they implement the Singleton pattern like this: 介于这两点问题,我见过的大部分游戏都不依赖延时初始化。相反,他们想这样实现单件模式。 class FileSystem { public: static FileSystem& instance() { return instance_; } private: FileSystem() {} static FileSystem instance_; }; That solves the lazy initialization problem, but at the expense of discarding several singleton features that do make it better than a raw global variable. With a static instance, we can no longer use polymorphism, and the class must be constructible at static initialization time. Nor can we free the memory that the instance is using when not needed. 这解决的延时初始化的问题,但是这也丢失了单件比一个全局变量更好的几个特性。随着一个静态实例,我们不能够使用多 态了,并且这个类必须能够在静态初始化的时候构造。我们也不能够在不需要这个类的时候释放这段内存。 Instead of creating a singleton, what we really have here is a simple static class. That isn't necessarily a bad thing, but if a static class is all you need, why not get rid of the instance() method entirely and use static functions instead? Calling Foo::bar() is simpler than Foo::instance().bar() , and also makes it clear that you really are dealing with static memory. 与创建单件相反,这里我们真正需要的是一个静态类。这不完全是一件坏事,如果你想要的仅仅是静态类,何不消 除 instance() 这个方法而使用简单函数呢?调用 Foo::bar() 要比 Foo::instance().bar() 简单不说,还能澄清你正在使用静 态内存。 The usual argument for choosing singletons over static classes is that if you decide to change the static class into a non- static one later, you'll need to fix every call site. In theory, you don't have to do that with singletons because you could be passing the instance around and calling it like a normal instance method. 通常关于静态类和单件的争论是,如果之后你决定 将一个静态类转变为非静态类,你必须修改没处调用的地方。理论上,对于单件,你可以不必这样做,因为你可以将实例相 互传递并且像一个普通实例一样去调用。 In practice, I've never seen it work that way. Everyone just does Foo::instance().bar() in one line. If we changed Foo to not be a singleton, we'd still have to touch every call site. Given that, I'd rather have a simpler class and a simpler syntax to call into it. 在实践中,我从没有见过这么做过。每个人都 是 Foo::instance().bar() 这样调用的。如果我们将 Foo 改为非单件,我们必须修改每处调用的地方。有鉴于此,我更倾向于 使用一个简单的类和一个简单的语法去调用它。 If I've accomplished my goal so far, you'll think twice before you pull Singleton out of your toolbox the next time you have a problem. But you still have a problem that needs solving. What tool should you pull out? Depending on what you're trying to do, I have a few options for you to consider, but first... 如果现在我完成了目标,在下次你遇到问题时,在你祭出单件大发是会多考虑两次。但你还有一个问题有待解决。你需要什 么样的工具?这要取决于你想要做什么,我有几个建议可供参考,不过首先... Many of the singleton classes I see in games are "managers" -- those nebulous classes that exist just to babysit other objects. I've seen codebases where it seems like every class has a manager: Monster, MonsterManager, Particle, ParticleManager, Sound, SoundManager, ManagerManager. Sometimes, for variety, they'll throw a "System" or "Engine" in there, but it's still the same idea. 游戏中的许多单件类都是"managers"——这些保姆类存在就是为了管理其他对象。我见识过一个代码库,里面好像每个类都 What We Can Do Instead 有何替代 See if you need the class at all 看你究竟是否需要类 有一个管理者:Monster, MonsterManager, Particle, ParticleManager, Sound, SoundManager, ManagerManager。有时,为 了区别,他们叫做"System'或者"Engine",却是换汤不换药。 While caretaker classes are sometimes useful, often they just reflect unfamiliarity with OOP. Consider these two contrived classes: 尽管保姆类有时是有用的,通常这反应他们对OOP不熟悉。考虑这两个我构造的类: class Bullet { public: int getX() const { return x_; } int getY() const { return y_; } void setX(int x) { x_ = x; } void setY(int y) { y_ = y; } private: int x_, y_; }; class BulletManager { public: Bullet* create(int x, int y) { Bullet* bullet = new Bullet(); bullet->setX(x); bullet->setY(y); return bullet; } bool isOnScreen(Bullet& bullet) { return bullet.getX() >= 0 && bullet.getX() < SCREEN_WIDTH && bullet.getY() >= 0 && bullet.getY() < SCREEN_HEIGHT; } void move(Bullet& bullet) { bullet.setX(bullet.getX() + 5); } }; Maybe this example is a bit dumb, but I've seen plenty of code that reveals a design just like this after you scrape away the crusty details. If you look at this code, it's natural to think that BulletManager should be a singleton. After all, anything that has a Bullet will need the manager too, and how many instances of BulletManager do you need? 或许这个例子有点愚蠢,如果你查看这段代码,将 BulletManager 当作单件是很自然的事情。毕竟,一个 Bullet 需要用一个 东西来管理,而你需要有多个管理器呢? The answer here is zero, actually. Here's how we solve the "singleton" problem for our manager class: 答案是零,实际上,我们是这样解决我们管理类的"单件"问题的: class Bullet { public: Bullet(int x, int y) : x_(x), y_(y) {} bool isOnScreen() { return x_ >= 0 && x_ < SCREEN_WIDTH && y_ >= 0 && y_ < SCREEN_HEIGHT; } void move() { x_ += 5; } private: int x_, y_; }; There we go. No manager, no problem. Poorly designed singletons are often "helpers" that add functionality to another class. If you can, just move all of that behavior into the class it helps. After all, OOP is about letting objects take care of themselves. 就这样。没有管理器也没有问题。错误的设计单件通常会“帮助”你将功能添加到别的类中。如果可以,你只需将这些功能移 动到它帮助的类中去就可以了。比较,面向对象就是让对象自己管理自己。 Outside of managers, though, there are other problems where we'd reach to Singleton for a solution. For each of those problems, there are some alternative solutions to consider. 除了管理器,毕竟,这里还有别的问题我们需要求助单件模式去解决。对于这些问题,这里有一些额外的解决方案可供考 虑。 This is one half of what the Singleton pattern gives you. As in our file system example, it can be critical to ensure there's only a single instance of a class. However, that doesn't necessarily mean we also want to provide public, global access to that instance. We may want to restrict access to certain areas of the code or even make it private to a single class. In those cases, providing a public global point of access weakens the architecture. 这是单件模式给你解决的另外一个问题。在我们的文件系统例子中,保证这个类只有一个实例是很有必要的。但是,这不意 味这我们也想提供这个实例公共的全局的访问。我们也许想要严格限制在莫一部分代码中,或者干脆将它作为一个类的私有 成员。在这种情况下,提供一个全局的指针访问削弱了整体框架。 For example, we may be wrapping our file system wrapper inside another layer of abstraction. 比如,我们可以将我们的文 件系统包装在另外一个抽象层中。 We want a way to ensure single instantiation without providing global access. There are a couple of ways to accomplish this. Here's one: 我们提供一种方法来保证单个实例,并且不提供全局访问。这里有几种方法可以达到这点,下面就是一例: class FileSystem { public: FileSystem() { assert(!instantiated_); instantiated_ = true; } ~FileSystem() { instantiated_ = false; } private: static bool instantiated_; }; bool FileSystem::instantiated_ = false; This class allows anyone to construct it, but it will assert and fail if you try to construct more than one instance. As long as the right code creates the instance first, then we've ensured no other code can either get at that instance or create their own. The class ensures the single instantiation requirement it cares about, but it doesn't dictate how the class should be used. 这个类允许所有人创建它,但是如果你想要创建操作一个实例,它会断言并且失败。只要代码创建了第一个实例,我们保证 其他代码要么得到这个实例要么创建一个自己的实例。这个类保证了它的单个实例,但是它不能保证这个类如何使用。 >An assertion function is a way of embedding a contract into your code. When assert() is called, it evaluates the To limit a class to a single instance 限制类只有一个实例 expression passed to it. If it evaluates to true , then it does nothing and lets the game continue. If it evaluates to false , it immediately halts the game at that point. In a debug build, it will usually bring up the debugger or at least print out the file and line number where the assertion failed. >一个断言函数就是在我们代码中嵌入一份契约。当 assert() 调用是,他计算传 递给它的表达式。当表达式为 true 时,它什么都不做,并让游戏继续。当表达式为 false 时,它在此处立刻挂断游戏。在一 个debug构建中,它通常会启动调试器或者至少将断言失败的文件名和行号打印出来。 >An assert() means, "I assert that this should always be true. If it's not, that's a bug and I want to stop now so you can fix it." This lets you define contracts between regions of code. If a function asserts that one of its arguments is not NULL , that says, "The contract between me and the caller is that I will not be passed NULL ." >一个 assert() 意味着,“我确保这个应该始终为true,如果不是,这就是一 个bug,并且我想立刻停止以便让我修复它。”这可以让你在代码区间定义约定。如果一个函数断言它的某个参数不为 NULL , 也就是说,“函数和调用着之间的契约就是不能够传递 NULL 。” >Assertions help us track down bugs as soon as the game does something unexpected, not later when that error finally manifests as something visibly wrong to the user. They are fences in your codebase, corralling bugs so that they can't escape from the code that created them. >断言帮助我们在游戏 做一些未预料的事情时离开开始追踪bug,而不是等到错误体现在用户可见的错误上。它们是代码库的围栏,以防bug在发生 的代码之处逃离出去。 The downside with this implementation is that the check to prevent multiple instantiation is only done at runtime. The Singleton pattern, in contrast, guarantees a single instance at compile time by the very nature of the class's structure. 这份实现的不足之处在它只在运行期检测来防止多个实例。单件模式,相反的,在编译期就通过类结构自然的保证了单个实 例。 Convenient access is the main reason we reach for singletons. They make it easy to get our hands on an object we need to use in a lot of different places. That ease comes at a cost, though -- it becomes equally easy to get our hands on the object in places where we don't want it being used. 便利的访问是我们使用单件额主要原因。它让我们在许多不同地方得到一个对象变得简单。这种便利也有代价,——它也是 的我们在不想使用的地方也可以轻松的得到这个对象。 The general rule is that we want variables to be as narrowly scoped as possible while still getting the job done. The smaller the scope an object has, the fewer places we need to keep in our head while we're working with it. Before we take the shotgun approach of a singleton object with global scope, let's consider other ways our codebase can get access to an object: 通用的原则是,在保证功能的情况下将变量限制在一个狭窄的范围内。对象的作用域越小,我们需要用到它的地方就越少。 在我们直接了当的通过全局作用域来访问一个单件对象时,让我们考察一下我们代码访问一个对象的其他方式: Pass it in. The simplest solution, and often the best, is to simply pass the object you need as an argument to the functions that need it. It's worth considering before we discard it as too cumbersome. 传递进去 最简单,通常也是最好的方法就是简单的将这个对象当作一个参数传递给需要他的函数。 >Some use the term "dependency injection" to refer to this. Instead of code reaching out and finding its dependencies by calling into something global, the dependencies are pushed in to the code that needs it through parameters. Others reserve "dependency injection" for more complex ways of providing dependencies to code. >有些人使用术语“依赖注 入”来指代这点。与在外部通过调用全局对象来查找依赖不同,依赖通过参数传递到需要的代码“里面”。其他通过储备”依 赖注入“来为代码依赖提供更复杂的方式。 Consider a function for rendering objects. In order to render, it needs access to an object that represents the graphics device and maintains the render state. It's very common to simply pass that in to all of the rendering functions, usually as a parameter named something like context . 假设一个渲染物体的函数。为了渲染,他需要访问这个物体的图形设备的表象并维持渲染状态。简单地将他们全部传递 到渲染函数中是很普遍的做法,通常这个参数叫做 context 。 On the other hand, some objects don't belong in the signature of a method. For example, a function that handles AI may need to also write to a log file, but logging isn't its core concern. It would be strange to see Log show up in its argument list, so for cases like that we'll want to consider other options. To provide convenient access to an instance 提供一个便利的方法访问实例 另一方面,一个对象不属于某个函数的签名。举个例子,一个操作AI的函数可能也需要写一个日志文件,但是记录日志 并不是它主要关系的东西。在它的参数列表中发现有 Log 会很奇怪,所以为了这些情况,我们需要参考其他方法。 >The term for things like logging that appear scattered throughout a codebase is "cross-cutting concern". Handling cross-cutting concerns gracefully is a continuing architectural challenge, especially in statically typed languages. >描述 想日志这种分散的出现在代码库的术语称为”横切关注点“。优雅的处理横切关注点是可持续架构的挑战。尤其是在静态 类型语言中。 >Aspect-oriented programming was designed to address these concerns. >面向方面程序设计就是设计用 来解决这些问题。 Get it from the base class. Many game architectures have shallow but wide inheritance hierarchies, often only one level deep. For example, you may have a base GameObject class with derived classes for each enemy or object in the game. With architectures like this, a large portion of the game code will live in these "leaf" derived classes. This means that all these classes already have access to the same thing: their GameObject base class. We can use that to our advantage: 在基类中访问它。 许多游戏架构有浅层次但是广泛的继承,通常只有一层继承。举个例子,你可能有一个 GameObject 基 类,每个地方或者游戏物体都派生只这个类。有了这样的架构,游戏代码的绝大部分都在资额些派生类的“叶子”上。这 意味着所有这些类都能访问同样的东西:他们的 GameObject 基类。我们可以利用这点: class GameObject { protected: Log& getLog() { return log_; } private: static Log& log_; }; class Enemy : public GameObject { void doSomething() { getLog().write("I can log!"); } }; This ensures nothing outside of GameObject has access to its Log object, but every derived entity does using getLog() . This pattern of letting derived objects implement themselves in terms of protected methods provided to them is covered in the Subclass Sandbox chapter. 这保证了在 GameObject 之外没有访问 Log 对象的代码,但是每个派生类能够通过 getLog() 访问。这种让派生类在所提 供的保护方法中提供实现的模式在 子类沙盒 章节中讨论. This raises the question, "how does GameObject get the Log instance?" A simple solution is to have the base class simply create and own a static instance. 这提出了新的问题。“ GameObject 如何得到 Log 实例?”一个简单的方案是,将基类创建出来,并拥有一个自己的 实例。 If you don't want the base class to take such an active role, you can provide an initialization function to pass it in or use the Service Locator pattern to find it. 如果我们不想让基类承当这个角色,你可以提供一个初始化函数将它传递进去,或者使用 服务定位器模式来得到 它。 Get it from something already global. The goal of removing all global state is admirable, but rarely practical. Most codebases will still have a couple of globally available objects, such as a single Game or World object representing the entire game state. 通过其他全局对象访问它。 将所有全局状态都移除令人敬佩,但是不够实际。许多代码库仍然有一些全局对象,比如一 个单独的 Game 或者 World 对象来代表整个游戏状态。 We can reduce the number of global classes by piggybacking on existing ones like that. Instead of making singletons out of Log , FileSystem , and AudioPlayer , do this: 我们能够通过打包到一个已知的全局对象类中来减少全局对象的数 量。与将 Log , FileSystem ,和 AudioPlayer 变为单件不同: class Game { public: static Game& instance() { return instance_; } // Functions to set log_, et. al. ... Log& getLog() { return *log_; } FileSystem& getFileSystem() { return *fileSystem_; } AudioPlayer& getAudioPlayer() { return *audioPlayer_; } private: static Game instance_; Log *log_; FileSystem *fileSystem_; AudioPlayer *audioPlayer_; }; With this, only Game is globally available. Functions can get to the other systems through it: 通过这点,只有 Game 是全局可见的。函数能够通过这个来访问其他系统: Game::instance().getAudioPlayer().play(VERY_LOUD_BANG); >Purists will claim this violates the Law of Demeter. I claim that's still better than a giant pile of singletons. >纯粹主义者 会声称这违反了迪米特法则。我坚持这仍然要比一对单件要好。 If, later, the architecture is changed to support multiple Game instances (perhaps for streaming or testing purposes), Log , FileSystem , and AudioPlayer are all unaffected -- they won't even know the difference. The downside with this, of course, is that more code ends up coupled to Game itself. If a class just needs to play sound, our example still requires it to know about the world in order to get to the audio player. 如果,随后,架构会变得支持多个 Game 实例(也许是为了流或者测试目的), Log , FileSystem 和 AudioPlayer 都不会影 响。——他们甚至不知道任何不同。这个副作用,当然,就是更多的代码耦合在了 Game 当中。如果一个类只是为了播放 声音,我们的例子仍然需要知道全部,以便能够得到声音播放器。 We solve this with a hybrid solution. Code that already knows about Game can simply access AudioPlayer directly from it. For code that doesn't, we provide access to AudioPlayer using one of the other options described here. 我们通过一个混合方案解决这点。如果代码已经知道了 Game 就直接通过它来访问 AudioPlayer 。如果代码不知道,我们 通过这里讨论的其他方法来访问 AudioPlayer 。 Get it from a Service Locator. So far, we're assuming the global class is some regular concrete class like Game . Another option is to define a class whose sole reason for being is to give global access to objects. This common pattern is called a Service Locator and gets its own chapter. 通过服务定位器来访问。 到现在位置,我们假设全局类就是像 Game 那样的具体类。另外一个选择就是定义一个类专门 用来给对象做全局访问。这个模式被称之为 服务定位器并有单独的章节。 The question remains, where should we use the real Singleton pattern? Honestly, I've never used the full Gang of Four implementation in a game. To ensure single instantiation, I usually simply use a static class. If that doesn't work, I'll use a What's Left for Singleton 剩下的问题 static flag to check at runtime that only one instance of the class is constructed. 我们还有一个问题,我们什么情况下使用真正的单件呢?老实说,我重来没有在游戏中使用四人帮的全部实现。为了简单原 则,我一般简单的使用一个静态类。如果这不能够满足,我将会使用一个静态的标志来在运行期检查只有一个类被创建了。 There are a couple of other chapters in this book that can also help here. The Subclass Sandbox pattern gives instances of a class access to some shared state without making it globally available. The Service Locator pattern does make an object globally available, but it gives you more flexibility with how that object is configured. 本书的一些其他章节也会有所帮助。 沙箱模式能够提供一些共享状态的访问指针而不必全局可见。本地模式是的一个对象全 局可见,但是他给你这个物体更多弹性的配置。 Confession time: I went a little overboard and packed way too much into this chapter. It’s ostensibly about the State design pattern, but I can’t talk about that and games without going into the more fundamental concept of finite state machines (or “FSMs”). But then once I went there, I figured I might as well introduce hierarchical state machines and pushdown automata. 交待时间: 我有点走远了,我往本章里面添加了太多东西。表面上这一章是介绍状态模式的,但是我不能抛开游戏 里面的FSM(有限状态机)而单独只谈“状态模式”。不过,当我讲到FSM的时候,我发觉我还有必要再介绍一下层级状态机 (hierarchical state machine)和下推自动机(pushdown automata). That’s a lot to cover, so to keep things as short as possible, the code samples here leave out a few details that you’ll have to fill in on your own. I hope they’re still clear enough for you to get the big picture. 因为有太多东西需要讲,所以,我试图压 缩本章的内容。本章中的代码片断没有涉及很细节的东西,所以,这些省略的部分需要靠读者自己脑补一下。我希望它们还 是足够清楚到能让你掌握关键点(big picture). Don’t feel sad if you’ve never heard of a state machine. While well known to AI and compiler hackers, they aren’t that familiar to other programming circles. I think they should be more widely known, so I’m going to throw them at a different kind of problem here. 如果你从未听说过状态机,也不要感到沮丧。它们对于人工智能领域和编译器黑客来讲非常熟悉,不 过对于其它编程领域的人可能不是那么被人熟知了。我觉得它们应该被更多的人所了解,因此,我将通过一个新的问题领域 的应用来介绍它。 我们曾经相遇过 We’re working on a little side-scrolling platformer. Our job is to implement the heroine that is the player’s avatar in the game world. That means making her respond to user input. Push the B button and she should jump. Simple enough: 假如我们现在正在开发一款横版游戏。我们的任务是实现女主角--即我们游戏世界里面的主人翁。我们需要根据玩家 的输入来控件主角的行为。当按下B键的时候,她应该跳跃。我们可以这样实现: void Heroine::handleInput(Input input) { if (input == PRESS_B) { yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } } Spot the bug? 找找看,bug在哪里? There’s nothing to prevent “air jumping” — keep hammering B while she’s in the air, and she will float forever. The simple fix is to add an isJumping_ Boolean field to Heroine that tracks when she’s jumping, and then do: 我们没有防止主角“在空中跳 跃”--当主角跳起来后持续按下B键。这样会导致她一直飘在空中,简单地修复方法可以是:添加一个 isJumping布尔值变量。 当主角跳起来后,就把该变量设置为True.只有当该变量为False时,才让主角跳跃。 void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_) { isJumping_ = true; // Jump... } } } Next, we want the heroine to duck if the player presses down while she’s on the ground and stand back up when the button is released: 接下来,我们想实现主角的闪避动作。当主角站在地面上的时候,如果玩家按下方向“下键”,则躲避,如果松开 状态模式 We’ve All Been There 此键,则起立。 void Heroine::handleInput(Input input) { if (input == PRESS_B) { // Jump if not jumping... } else if (input == PRESS_DOWN) { if (!isJumping_) { setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { setGraphics(IMAGE_STAND); } } Spot the bug this time? 找找看,有bug在哪里? With this code, the player could: 通过上面的代码,玩家可以: 1. Press down to duck. 2. 按下键->闪避 3. Press B to jump from a ducking position. 4. 按B键从闪避的状态直接跳起来 5. Release down while still in the air. 6. 玩家还在空中的时候松开下键 The heroine will switch to her standing graphic in the middle of the jump. Time for another flag… 此时,当女主角在跳跃的 状态的时候,显示的是站立的图像。是时候添加另外一个flag来解决该问题了... void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Jump... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } } else if (input == RELEASE_DOWN) { if (isDucking_) { isDucking_ = false; setGraphics(IMAGE_STAND); } } } Next, it would be cool if the heroine did a dive attack if the player presses down in the middle of a jump: 接下来,如果我们的 主角可以在跳起来的时候按Down键进行一次俯冲攻击那就太帅了,代码如下: void Heroine::handleInput(Input input) { if (input == PRESS_B) { if (!isJumping_ && !isDucking_) { // Jump... } } else if (input == PRESS_DOWN) { if (!isJumping_) { isDucking_ = true; setGraphics(IMAGE_DUCK); } else { isJumping_ = false; setGraphics(IMAGE_DIVE); } } else if (input == RELEASE_DOWN) { if (isDucking_) { // Stand... } } } Bug hunting time again. Find it? 又是Bug解决时间到了。找到了吗? We check that you can’t air jump while jumping, but not while diving. Yet another field… 我们需要添加一个判断,让主角在 跳跃状态的时候不能再跳,但是在俯冲攻击的时候是可以跳跃的。又要添加一个成员变量。。。 Something is clearly wrong with our approach. Every time we touch this handful of code, we break something. We need to add a bunch more moves — we haven’t even added walking yet — but at this rate, it will collapse into a heap of bugs before we’re done with it. 很明显,我们的这种做法有问题。每次我们添加一些功能的时候,都会不经意地破坏已有代码的功能。而 且,我们还没有添加“行走”的状态。如果我们还是采用类似的做法,那bug可能会更多。 Finite State Machines to the Rescue In a fit of frustration, you sweep everything off your desk except a pen and paper and start drawing a flowchart. You draw a box for each thing the heroine can be doing: standing, jumping, ducking, and diving. When she can respond to a button press in one of those states, you draw an arrow from that box, label it with that button, and connect it to the state she changes to. 为了消除你心中的疑惑,你可以准备一张纸和一支笔,让我们一起来画一张流程图。对于,女主角能够进行的动 作画一个“矩形”:站立、跳跃、躲避和俯冲。当你可以按下一个键让主角从一个状态切换到另一个状态的时候,我们画一个 箭头,让它从一个矩形指向另一个矩形。同时在箭头上面添加文本,表示我们按下的按钮。 Congratulations, you’ve just created a finite state machine. These came out of a branch of computer science called automata theory whose family of data structures also includes the famous Turing machine. FSMs are the simplest member of that family. 恭喜,你刚刚已经成功创建了一个有限状态机。FSM是借鉴了计算机科学里的自动机理论(automata theory) 中的一种数据结构(图灵机)的思想。FSM可以看作是最简单的图灵机。 The gist is: 这个FSM表达的是: You have a fixed set of states that the machine can be in. For our example, that’s standing, jumping, ducking, and diving. 你拥有一组状态,并且可以在这组状态之间进行切换。比如:站立、跳跃、躲避和俯冲。 The machine can only be in one state at a time. Our heroine can’t be jumping and standing simultaneously. In fact, preventing that is one reason we’re going to use an FSM. 这个机器一次只能处于一种状态。 A sequence of inputs or events is sent to the machine. In our example, that’s the raw button presses and releases. 有 一组输入或者事件发送给状态机。在我们这个例子中,它们就是按钮的按下和释放。 Each state has a set of transitions, each associated with an input and pointing to a state. When an input comes in, if it matches a transition for the current state, the machine changes to the state that transition points to. 每一个状态有一组转 换,每一个转换都关联着一个输入并指向另一个状态。当有一个输入进来的时候,如果有一个转换与此状态和输入事件对 应,则状态机便会转换状态到输入事件所指的状态。 For example, pressing down while standing transitions to the ducking state. Pressing down while jumping transitions to diving. If no transition is defined for an input on the current state, the input is ignored. 在我们的例子中,在站立状态的时候 如果按下down键,则状态转换到躲避状态。如果在跳跃状态的时候按下down键,则会转换到俯冲攻击状态。如果对于每一 个输入事件没有对应的转换,则这个转入会被忽略。 In their pure form, that’s the whole banana: states, inputs, and transitions. You can draw it out like a little flowchart. Unfortunately, the compiler doesn’t recognize our scribbles, so how do we go about implementing one? The Gang of Four’s State pattern is one method — which we’ll get to — but let’s start simpler. 简而言之,整个状态机可以分为:状态,输入和转 换。你可以通过画状态流程图来表示它们。但是,我们的编译器并不认识状态图,所以,我们接下来要介绍一个实现。四人 帮(Gof)的状态模式是一种实现方法,但是让我们先从简单的方法开始。 One problem our Heroine class has is some combinations of those Boolean fields aren’t valid: isJumping and isDucking should never both be true, for example. When you have a handful of flags where only one is true at a time, that’s a hint that what you really want is an enum. 我们的女主角类有一些布尔类型的成员变量:isJumping_和isDucking,但是这两个变量永 远不可能同时为True. 当你有一系列的标记成员变量,而它们只能有且仅有一个为True时,这表明我们需要把它们定义成枚 举(enum). In this case, that enum is exactly the set of states for our FSM, so let’s define that: 在这个例子当中,我们的FSM的每一个状 态可以用一个枚举来表示,所以,让我们定义以下枚举: Enums and Switches 枚举和分支 enum State { STATE_STANDING, STATE_JUMPING, STATE_DUCKING, STATE_DIVING }; Instead of a bunch of flags, Heroine will just have one state field. We also flip the order of our branching. In the previous code, we switched on input, then on state. This kept the code for handling one button press together, but it smeared around the code for one state. We want to keep that together, so we switch on state first. That gives us: 这里没有大量的flags,女主 角类只有一个state成员。我们也需要调换分支语句的顺序。在前面的代码中,我们先判断输入事件,然后才是状态。那种代 码可以让我们集中处理与按键相关的逻辑,但是,它也让每一种状态的处理代码变得很乱。我们想把它们放在一起来处理, 因此,我们先判断状态。代码如下: void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_B) { state_ = STATE_JUMPING; yVelocity_ = JUMP_VELOCITY; setGraphics(IMAGE_JUMP); } else if (input == PRESS_DOWN) { state_ = STATE_DUCKING; setGraphics(IMAGE_DUCK); } break; case STATE_JUMPING: if (input == PRESS_DOWN) { state_ = STATE_DIVING; setGraphics(IMAGE_DIVE); } break; case STATE_DUCKING: if (input == RELEASE_DOWN) { state_ = STATE_STANDING; setGraphics(IMAGE_STAND); } break; } } This seems trivial, but it’s a real improvement over the previous code. We still have some conditional branching, but we simplified the mutable state to a single field. All of the code for handling a single state is now nicely lumped together. This is the simplest way to implement a state machine and is fine for some uses. 这样看起来也挺普通的,但是它却是对前面的代 码的一个提升。我们仍然有一些条件分支语句,但是我们简化了状态的处理。所有处理单个状态的代码都集中在一起了。这 样做是最简单的方法来实现状态机,而且在某些情况下面,这样做也挺好的。 In particular, the heroine can no longer be in an invalid state. With the Boolean flags, some sets of values were possible but meaningless. With the enum, each value is valid. Your problem may outgrow this solution, though. Say we want to add a move where our heroine can duck for a while to charge up and unleash a special attack. While she’s ducking, we need to track the charge time. 不过,这样我们的女主角就不可能处于一个无效的状态。通过布尔值标识,我们可以设置一些没有意 思的值。但是,使用枚举,每一个枚举值都是有意义的。你的问题可能也会超过此方案能解决的范围。比如,我们想在主角 下蹲躲避的时候“蓄能”,然后等蓄满能量之后可以释放出一个特殊的技能。那么,当主角处理躲避状态的时候,我们需要添 加一个变量来记录蓄能时间。 We add a chargeTime field to Heroine to store how long the attack has charged. Assume we already have an update() that gets called each frame. In there, we add: 我们可以添加一个chargeTime成员来记录主角蓄能的时间长短。假设,我们已经 有一个update方法了,并且这个方法会在每一帧被调用。在那里,我们可以使用如下代码片断能记录蓄能的时间: void Heroine::update() { if (state_ == STATE_DUCKING) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { superBomb(); } } } We need to reset the timer when she starts ducking, so we modify handleInput(): 我们需要在主角躲避的时候重置这个蓄能 时间,所以,我们还需要修改handleInput方法: void Heroine::handleInput(Input input) { switch (state_) { case STATE_STANDING: if (input == PRESS_DOWN) { state_ = STATE_DUCKING; chargeTime_ = 0; setGraphics(IMAGE_DUCK); } // Handle other inputs... break; // Other states... } } All in all, to add this charge attack, we had to modify two methods and add a chargeTime field onto Heroine even though it’s only meaningful while in the ducking state. What we’d prefer is to have all of that code and data nicely wrapped up in one place. The Gang of Four has us covered. 总之,为了添加蓄能攻击,我们不得不修改两个方法,并且添加一个 chargeTime 成员给主角,尽管这个成员变量只有在主角处于躲避状态的时候才有效。我们其实真正想要的是把所有这些与状态相关的数 据和代码封装起来。接下来,我们介绍四人帮的状态模式来解决这个问题。 For people deeply into the object-oriented mindset, every conditional branch is an opportunity to use dynamic dispatch (in other words a virtual method call in C++). I think you can go too far down that rabbit hole. Sometimes an if is all you need. 对于熟知面向对象方法的人来说,每一个条件分支都可以用动态分发来解决(换句话说,都可以用c++里面的虚函数来解 决)。但是,如果你这样做,可能会走远了。有时候,一个简单的if语句就足够了。 There’s a historical basis for this. Many of the original object-oriented apostles like Design Patterns‘ Gang of Four, and Refactoring‘s Martin Fowler came from Smalltalk. There, ifThen: is just a method you invoke on the condition, which is implemented differently by the true and false objects. 状态模式的由来也有一些历史原因。许多面向对象设计的信徒--四人帮 和重构的作者Martin Fowler都是Smalltalk出生。在那里,如果有一个if语句,我们便可以用一个表示true和false的对象来操 作。 But in our example, we’ve reached a tipping point where something object-oriented is a better fit. That gets us to the State pattern. In the words of the Gang of Four: 但是,在我们这个例子当中,我们也达到了设计模式的应用场景。在四人帮的状 态模式中是这么描述的: Allow an object to alter its behavior when its internal state changes. The object will appear to change its class. 当一 The State Pattern 状态模式 个对象在其内部状态改变时改变它的行为,对象看起来好像是修改其类 That doesn’t tell us much. Heck, our switch does that. The concrete pattern they describe looks like this when applied to our heroine: 这句话并没有给我们太多信息。真见鬼,我们的switch却做到了。这个模式应用到我们的主角中,恰好和模式定 义中描述的差不多。 First, we define an interface for the state. Every bit of behavior that is state-dependent — every place we had a switch before — becomes a virtual method in that interface. For us, that’s handleInput() and update(): 首先,我们为状态定义一个接 口。每一个与状态相关的行为都定义成虚函数。对于我们而言,就是handleInput和update函数。 class HeroineState { public: virtual ~HeroineState() {} virtual void handleInput(Heroine& heroine, Input input) {} virtual void update(Heroine& heroine) {} }; For each state, we define a class that implements the interface. Its methods define the heroine’s behavior when in that state. In other words, take each case from the earlier switch statements and move them into their state’s class. For example: 对于每一个状态,我们定义了一个类并继承至此状态接口。它覆盖的方法定义主角对应此状态的行为。换句话说, 把之前的switch语句里面的每一个case语句里的内容放置到它们对应的状态类里面去。比如: class DuckingState : public HeroineState { public: DuckingState() : chargeTime_(0) {} virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Change to standing state... heroine.setGraphics(IMAGE_STAND); } } virtual void update(Heroine& heroine) { chargeTime_++; if (chargeTime_ > MAX_CHARGE) { heroine.superBomb(); } } private: int chargeTime_; }; Note that we also moved chargeTime out of Heroine and into the DuckingState class. This is great — that piece of data is only meaningful while in that state, and now our object model reflects that explicitly. 注意,我们这里把 chargeTime也放到了 DuckingState(躲避状态)中。这样非常好,因为这个变量只是对躲避状态有意义,现在把它定义在这里,正好显式地反应了 我们的对象模型。 A state interface 一个状态接口 Classes for each state 为每一个状态定义一个类 Next, we give the Heroine a pointer to her current state, lose each big switch, and delegate to the state instead: 接下来,我 们在主角类中定义一个指针变量,让它指向当前的状态。我们把之前那个很大的switch语句去掉了,然后让它去调用状态接 口的虚函数,最终这些虚方法就会动态地调用具体子状态的相应函数了. class Heroine { public: virtual void handleInput(Input input) { state_->handleInput(*this, input); } virtual void update() { state_->update(*this); } // Other methods... private: HeroineState* state_; }; In order to “change state”, we just need to assign state to point to a different HeroineState object. That’s the State pattern in its entirety. 为了修改状态,我们需要把state指针指向另一个不同的状态对象。至此,我们的状态模式就讲完了。 I did gloss over one bit here. To change states, we need to assign state to point to the new one, but where does that object come from? With our enum implementation, that was a no-brainer — enum values are primitives like numbers. But now our states are classes, which means we need an actual instance to point to. There are two common answers to this: 我这里忽 略了一些细节。为了修改一个状态,我们需要给state指针赋值为一个新的状态,但是这个新的状态对象要从哪里来呢?我们 的之前的枚举方法是一些数字定义。但是,现在我们的状态是类,我们需要获取这些类的实例。通常来说,有两种实现方 法: If the state object doesn’t have any other fields, then the only data it stores is a pointer to the internal virtual method table so that its methods can be called. In that case, there’s no reason to ever have more than one instance of it. Every instance would be identical anyway. 如果一个状态对象没有任何数据成员,那么它的惟一数据成员便是虚表指针了。那样的话,我们 就没有必要创建此状态的多个实例了,因为它们的每一个实例都是相等的。 If your state has no fields and only one virtual method in it, you can simplify this pattern even more. Replace each state class with a state function — just a plain vanilla top-level function. Then, the state field in your main class becomes a simple function pointer. 如果你的状态类没有任何数据成员,并且它只有一个函数方法在里面。那么我们还可以进一步简化此模式。 我们可以通过一个状态函数来替换状态类。这样的话,我们的state变量只需要变成一个状态函数指针就可以了。 In that case, you can make a single static instance. Even if you have a bunch of FSMs all going at the same time in that same state, they can all point to the same instance since it has nothing machine-specific about it. 在那种情部下,我们可以 定义一个静态实例。即使你有一系列的FSM在同时运转,所有的状态机都同时指向这一个惟一的实例。 Delegate to the state 状态委托 Where Are the State Objects? 状态对象应该放在哪里呢? Static states 静态状态 This is the Flyweight pattern. 这个就是享元模式。 Where you put that static instance is up to you. Find a place that makes sense. For no particular reason, let’s put ours inside the base state class: 你把静态方法放置在哪里,这个由你自己来决定 。如果没有任何特殊原因的话,我们可以把它放 置到基类状态类中: class HeroineState { public: static StandingState standing; static DuckingState ducking; static JumpingState jumping; static DivingState diving; // Other code... }; Each of those static fields is the one instance of that state that the game uses. To make the heroine jump, the standing state would do something like: 每一个静态成员变量都是对应状态类的一个实例。如果我们想让主角跳跃,那么站立状态应 该是这样子: if (input == PRESS_B) { heroine.state_ = &HeroineState::jumping; heroine.setGraphics(IMAGE_JUMP); } Sometimes, though, this doesn’t fly. A static state won’t work for the ducking state. It has a chargeTime field, and that’s specific to the heroine that happens to be ducking. This may coincidentally work in our game if there’s only one heroine, but if we try to add two-player co-op and have two heroines on screen at the same time, we’ll have problems. 有时候上面的方 法可能不行。一个静态状态对于躲避状态而言是行不通的。因为它有一个 chargeTime成员变量,这个是专属于每一个主角类 在躲避状态下的。如果我们的游戏里面只有一个主角的话,那么定义一个静态类也是没有啥问题的。但是,如果我们想加入 多个玩家,那么此方法就行不通了。 In that case, we have to create a state object when we transition to it. This lets each FSM have its own instance of the state. Of course, if we’re allocating a new state, that means we need to free the current one. We have to be careful here, since the code that’s triggering the change is in a method in the current state. We don’t want to delete this out from under ourselves. 在那种情况下面,我们不得不在状态切换的时候动态地创建一个躲避状态实例。这样,我们的FSM并拥有了它自 己的实例。当然,如果我们又动态分配了一个新的状态实例,我们需要负责清理老的状态实例。我们这里必须要相当小心, 因为当前状态修改的函数是处在当前状态里面,我们需要小心地处理删除的顺序。 Instead, we’ll allow handleInput() in HeroineState to optionally return a new state. When it does, Heroine will delete the old one and swap in the new one, like so: 另外,我们也会在handleInput方法里面可选地返回一个新的状态。当这个状态返回的 时候,主角将会删除老的状态并切换到这个新的状态,如下所示: void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; } } That way, we don’t delete the previous state until we’ve returned from its method. Now, the standing state can transition to Instantiated states 实例化状态 ducking by creating a new instance: 那样的话,我们只有在从handleInput方法返回的时候才有可能去删除前面的对象。现 在,站立状态可以通过创建一个躲避状态的实例来切换状态了。 HeroineState* StandingState::handleInput(Heroine& heroine, Input input) { if (input == PRESS_DOWN) { // Other code... return new DuckingState(); } // Stay in this state. return NULL; } When I can, I prefer to use static states since they don’t burn memory and CPU cycles allocating objects each state change. For states that are more, uh, stateful, though, this is the way to go. 当我在做选择的时候,我倾向于使用静态状态。 因数它们不会占用太多的CPU和内存资源。 The goal of the State pattern is to encapsulate all of the behavior and data for one state in a single class. We’re partway there, but we still have some loose ends. 状态模式的目标就是封装所有的数据和行为到一个状态类里面。万里长征,我们仅 仅是迈出去了一步,我们还可以走地更远。 When the heroine changes state, we also switch her sprite. Right now, that code is owned by the state she’s switching from. When she goes from ducking to standing, the ducking state sets her image: 当主角更改状态的时候,我们也会切换它 的精灵。现在,这段代码是包含在它要切换的状态的上一个状态里面。当她从躲避状态切换到站立状态的时候,躲避状态将 会修改它的图像: HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { heroine.setGraphics(IMAGE_STAND); return new StandingState(); } // Other code... } What we really want is each state to control its own graphics. We can handle that by giving the state an enter action: 我们希 望的是,每一个状态来自己控件自己的图像。我们可以通过给每一个状态添加一个enter行为。 class StandingState : public HeroineState { public: virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); } // Other code... }; Back in Heroine, we modify the code for handling state changes to call that on the new state: 回到女主角的例子,我们修改 代码来处理状态切换的情况: Enter and Exit Actions 进入状态和退出状态的行为 void Heroine::handleInput(Input input) { HeroineState* state = state_->handleInput(*this, input); if (state != NULL) { delete state_; state_ = state; // Call the enter action on the new state. state_->enter(*this); } } This lets us simplify the ducking code to: 这样也可以让我们简化躲避状态的代码: HeroineState* DuckingState::handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { return new StandingState(); } // Other code... } All it does is switch to standing and the standing state takes care of the graphics. Now our states really are encapsulated. One particularly nice thing about entry actions is that they run when you enter the state regardless of which state you’re coming from. 它所做的就是切换到站立状态,然后站立状态会自己设置自己的图像。现在,我们的状态已经封装好了。entry 动作的一个最大的好处就是它一个状态进来的时候,它不用关心上一个状态是什么,它只需要根据自己的状态来处理图像和 行为就ok了。 Most real-world state graphs have multiple transitions into the same state. For example, our heroine will also end up standing after she lands a jump or dive. That means we would end up duplicating some code everywhere that transition occurs. Entry actions give us a place to consolidate that. 大部分的真实状态图里面,我们有多个状态对应同一个状态。比 如,我们的女主角会在她俯冲或者跳跃之后站立在地面上。这意味着,我们可能会在每一个状态发生变化的时候重复写很多 代码。但是,entry动作帮我们很好地解决了这个问题。 We can, of course, also extend this to support an exit action. This is just a method we call on the state we’re leaving right before we switch to the new state. 当然,我们也可以扩展这个功能来支持退出状态的行为。我们可以定义一个exit函数来定 义一些在状态改变后的处理。 I’ve spent all this time selling you on FSMs, and now I’m going to pull the rug out from under you. Everything I’ve said so far is true, and FSMs are a good fit for some problems. But their greatest virtue is also their greatest flaw. 我已经花了大量的时 间来向你兜售FSM了。现在,我将要把你拉回来。到目前为止,我跟你讲的任何事情都是对的,FSM对于某些应用来讲是非 常合适的。但是,往往最大的优点也是最大的缺点。 State machines help you untangle hairy code by enforcing a very constrained structure on it. All you’ve got is a fixed set of states, a single current state, and some hardcoded transitions. 状态机帮助你把千丝万缕的逻辑判断代码封装起来了。你需 要的只是一组状态,一个当前状态和一些硬编码的状态切换。 A finite state machine isn’t even Turing complete. Automata theory describes computation using a series of abstract models, each more complex than the previous. A Turing machine is one of the most expressive models. 一个有限状态机甚 至都不是一个图灵机。自动化理论使用一系列抽象的模型来描述计算,并且每一个模型都比先前的模型更复杂。而图灵机只 是这里面最具有表达力的模型。 “Turing complete” means a system (usually a programming language) is powerful enough to implement a Turing machine in it, which means all Turing complete languages are, in some ways, equally expressive. FSMs are not flexible enough to be 发现什么问题了吗? in that club. “图灵完备”意味着一个系统(通常指的是一门编程语言)是足够强大的,强大到它可以实现一个图灵机。这也意 味着,所有图灵完毕的编程语言,在某些程度上来也是一种FSM。 If you try using a state machine for something more complex like game AI, you will slam face-first into the limitations of that model. Thankfully, our forebears have found ways to dodge some of those barriers. I’ll close this chapter out by walking you through a couple of them. 如果你想要用一个状态机来表示一些复杂的游戏AI,你可能会面临这个模型的一些限制。幸运的 是,我们的先辈们已经发现一些解决方案可以解决些问题。我将会在本章的最后简单地提到它们。 We’ve decided to give our heroine the ability to carry a gun. When she’s packing heat, she can still do everything she could before: run, jump, duck, etc. But she also needs to be able to fire her weapon while doing it. 我们已经决定给我们的主角添 加持枪功能。当她手持枪的时候,她仍然可以:跑、跳和躲避。但是,她同时也能够在这些状态过程中开火。 If we want to stick to the confines of an FSM, we have to double the number of states we have. For each existing state, we’ll need another one for doing the same thing while she’s armed: standing, standing with gun, jumping, jumping with gun, you get the idea. 如果你执着于传统的FSM,我们可能需要把之前的状态加倍。对于每一个已经存在的状态,我们需要定义 另一个状态,它做的事情也差不多,不过就是多了持枪的操作。比如站立状态和状态开火状态,跳跃状态和跳跃开火状态 等。 Add a couple of more weapons and the number of states explodes combinatorially. Not only is it a huge number of states, it’s a huge amount of redundancy: the unarmed and armed states are almost identical except for the little bit of code to handle firing. 如果我们添加更多的武器种类,那么这个状态种数会爆炸的。而且不仅仅是增加了大量的状态类实例而已,它 还会重复编写相当多的重复代码。 The problem is that we’ve jammed two pieces of state — what she’s doing and what she’s carrying — into a single machine. To model all possible combinations, we would need a state for each pair. The fix is obvious: have two separate state machines. 这里的问题是,我们把两种状态杂合在一起了。我们把两种不同的状态硬塞到一个状态机里面去了。为了建模所 有可能的组合,我们可能需要为每一种状态准备一组状态。解决方法比较直观 ,就是我们提供两个状态机。 If we want to cram n states for what she’s doing and m states for what she’s carrying into a single machine, we need n × m states. With two machines, it’s just n + m. 如果我们可以为主角定义n种状态,那么就可以为它所持装备定义m种状态,并把 它们分别放入不同的状态机。因此,我们只需要n * m个状态就够了。如果有两个状态机,那么状态组合是n+m. We keep our original state machine for what she’s doing and leave it alone. Then we define a separate state machine for what she’s carrying. Heroine will have two “state” references, one for each, like: 为了保持我们原来的状态机,我们这里先不 管。接下来,我们定义了一个单独的状态机,用来处理主角携带的武器。现在,我们的主角会有两个状态索引,其中一个看 起来如下所示: class Heroine { // Other code... private: HeroineState* state_; HeroineState* equipment_; }; When the heroine delegates inputs to the states, she hands it to both of them: 当主角派发输入事件给状态类的,需要给两 种状态都派发一下。 void Heroine::handleInput(Input input) { state_->handleInput(*this, input); equipment_->handleInput(*this, input); } 并发状态机 Each state machine can then respond to inputs, spawn behavior, and change its state independently of the other machine. When the two sets of states are mostly unrelated, this works well. 这样每一个状态机都可以响应输入事件并以此切换状态而 不用考虑其它状态机的内部。当两个状态没什么关系的时候,这种方法工作地很好。 In practice, you’ll find a few cases where the states do interact. For example, maybe she can’t fire while jumping, or maybe she can’t do a dive attack if she’s armed. To handle that, in the code for one state, you’ll probably just do some crude if tests on the other machine’s state to coordinate them. It’s not the most elegant solution, but it gets the job done. 在实际中, 你可能会发现你需要对某些状态处理进行干预。比如,如果主角不能够在跳跃的过程中开火,或者她在装备武器的时候不能 俯冲。为了处理这种情况,在代码里面,对于每一个状态,你可能需要做一些简单的if判断并做出特殊处理。虽然这可能不是 最好的解决方案,但是至少它可以完成任务。 After fleshing out our heroine’s behavior some more, she’ll likely have a bunch of similar states. For example, she may have standing, walking, running, and sliding states. In any of those, pressing B jumps and pressing down ducks. 在我们把 主角的行为更加具象化以后,她可能会包含大量相似的状态。比如,她可能有站立、走路、跑步和滑动状态。在这些状态中 的任何一个状态按下B键,我们的主角要跳跃,按下down键,我们的主角要躲避。 With a simple state machine implementation, we have to duplicate that code in each of those states. It would be better if we could implement that once and reuse it across all of the states. 如果只是使用一个简单的状态机实现,我们可能会在这些状 态中重复不少代码。更好的解决方案是,我们只需要实现一次那么它便可以在所有的状态下都有效。 If this was just object-oriented code instead of a state machine, one way to share code across those states would be using inheritance. We could define a class for an “on ground” state that handles jumping and ducking. Standing, walking, running, and sliding would then inherit from that and add their own additional behavior. 如果我们抛开状态机来谈面向对象,有一种共 享代码的方式便是继承。我们可以定义一个类来代码在地上的状态,它用来处理跳跃状态和躲避状态。站立,走跳,跑步和 滑行状态从这个在地面上的状态继承而来,并且在其类里面实现一些特殊行为。 This has both good and bad implications. Inheritance is a powerful means of code reuse, but it’s also a very strong coupling between two chunks of code. It’s a big hammer, so swing it carefully. It turns out, this is a common structure called a hierarchical state machine. A state can have a superstate (making itself a substate). When an event comes in, if the substate doesn’t handle it, it rolls up the chain of superstates. In other words, it works just like overriding inherited methods. 这可能既是一个好的设计,也可能是一个坏的设计。继承是一种强大的代码重用方式,但是,它也会使得子类与基类之间的 代码变得紧耦合。它是一个很大的锤子,需小心使用才行。这里,我们通常把这种状态机叫做层级状态机。一个状态有一个 父状态。当有一个事件进来的时候,如果子状态不处理它,那么并沿着继承链传给它的父状态来处理。换句话说,它有点像 覆盖继承的方法。 in fact, if we’re using the State pattern to implement our FSM, we can use class inheritance to implement the hierarchy. Define a base class for the superstate: 实际上,如果我们正在使用状态模式来实现FSM,我们可以使用类层次来实现层级状 态机。我们首先定义一个基类来表示父状态: class OnGroundState : public HeroineState { public: virtual void handleInput(Heroine& heroine, Input input) { if (input == PRESS_B) { // Jump... } else if (input == PRESS_DOWN) { // Duck... } } }; And then each substate inherits it: 然后,每一个子状态都继承至它: 层级状态机 class DuckingState : public OnGroundState { public: virtual void handleInput(Heroine& heroine, Input input) { if (input == RELEASE_DOWN) { // Stand up... } else { // Didn't handle input, so walk up hierarchy. OnGroundState::handleInput(heroine, input); } } }; This isn’t the only way to implement the hierarchy, of course. If you aren’t using the Gang of Four’s State pattern, this won’t work. Instead, you can model the current state’s chain of superstates explicitly using a stack of states instead of a single state in the main class. 当然,这不是实现层级状态机的惟一方式。如果你没有使用GoF的状态模式,这种做法可能并不奏 效。相反,你也可以使用栈结构来显示地模拟当前状态的父级状态的状态链。 The current state is the one on the top of the stack, under that is its immediate superstate, and then that state’s superstate and so on. When you dish out some state-specific behavior, you start at the top of the stack and walk down until one of the states handles it. (If none do, you ignore it.) 我们当前的状态总是处于栈顶,栈顶下面的第一个元素则是它的父状态,再下一 个状态则是它的爷爷状态,等等。如果你要进行一些与状态相关的行为操作,那么首先从栈顶状态开始。如果它不处理,则 往上继承寻找到一个能处理此事件的状态为止。(如果找遍整个栈了,还是没人处理,则此事件忽略掉). There’s another common extension to finite state machines that also uses a stack of states. Confusingly, the stack represents something entirely different, and is used to solve a different problem. 这里还有一种有限状态机的变种,它们使用 状态栈。可能听起来有些奇怪,栈完全是另外一种截然不同的东西。 The problem is that finite state machines have no concept of history. You know what state you are in, but have no memory of what state you were in. There’s no easy way to go back to a previous state. 这里要解决的问题是,因为有限状态机是没 有历史记录这个概念的。我们知道一个状态进来了,但是,我们并不知道这个状态的上一个状态是什么。而且,我们也没有 简便地方法可以获取当前状态的上一个状态。 Here’s an example: Earlier, we let our fearless heroine arm herself to the teeth. When she fires her gun, we need a new state that plays the firing animation and spawns the bullet and any visual effects. So we slap together a FiringState and make all of the states that she can fire from transition into that when the fire button is pressed. 这里有一个例子:之前,我 们让我们无畏的主角全副武装。when她开枪的时候,我们需要一种新的状态来播放开枪的动画,但是发射子弹并显示一些特 效。因此,我们需要定义一个FiringState,并且所有的状态都可以切换到这个状态,只要有玩家按下开火按键就行了。 Since this behavior is duplicated across several states, it may also be a good place to use a hierarchical state machine to reuse that code. The tricky part is what state she transitions to after firing. She can pop off a round while standing, running, jumping, and ducking. When the firing sequence is complete, she should transition back to what she was doing before. 因 为这个行为在许多状态里面都重复了,所以,我们需要使用层级状态机来解决这个问题。 这里的问题来了,当她开完枪后, 她要回到什么状态呢?主角可以处于站立、躲避、俯冲和跳跃状态。但开火的动画播放完以后,她应该要回到之前的状态。 If we’re sticking with a vanilla FSM, we’ve already forgotten what state she was in. To keep track of it, we’d have to define a slew of nearly identical states — firing while standing, firing while running, firing while jumping, and so on — just so that each one can have a hardcoded transition that goes back to the right state when it’s done. 如果我们仍然坚持使用以前的 FSM,那么我们将无法获得上一个状态的信息。为了保留上一个状态的信息,我们不得不定义一些几乎对等的状态,比如站 立开火状态,跑步开火状态等。这样的话,当我们的开火状态完成以后,就可以切换回之前的状态了。 What we’d really like is a way to store the state she was in before firing and then recall it later. Again, automata theory is here to help. The relevant data structure is called a pushdown automaton. 我们需要的仅仅是提供一种方式,让我们可以保 存开火前的状态,这样在开火状态完成之后可以回去。好了,自动机理论可以帮助我们。相关的数据结构叫做下推自动机 下推自动机 (pushdown automata). Where a finite state machine has a single pointer to a state, a pushdown automaton has a stack of them. In an FSM, transitioning to a new state replaces the previous one. A pushdown automaton lets you do that, but it also gives you two additional operations: 本来,一个有限状态机有一个指针指向一个状态。而下推自动机则有一个状态栈。在一个FSM里面, 当有一个状态切进来,则替换掉之前的状态。下推自动机可以让你这样做,同时它还提供其它选择: You can push a new state onto the stack. The “current” state is always the one on top of the stack, so this transitions to the new state. But it leaves the previous state directly under it on the stack instead of discarding it. 你可以把这个新的状态放入 栈里面。那么当前的状态就永远存在栈顶,当你需要回退到上一个状态的时候,只需要栈顶出栈就可以得到上一个状态了。 此时,上一个状态就变成了新的栈顶状态了。 This is just what we need for firing. We create a single firing state. When the fire button is pressed while in any other state, we push the firing state onto the stack. When the firing animation is done, we pop that state off, and the pushdown automaton automatically transitions us right back to the state we were in before. 这个就是我们的开火状态所需要的。当开火 按钮在任何一种状态下被按下的时候,我们把开火状态push到栈顶。当开火动画结束的时候,我们把这个开火状态pop出 去。此时,状态机会自动切换到我们开火前的上一个状态。 Even with those common extensions to state machines, they are still pretty limited. The trend these days in game AI is more toward exciting things like behavior trees and planning systems. If complex AI is what you’re interested in, all this chapter has done is whet your appetite. You’ll want to read other books to satisfy it. 即使有了这些通用的状态机扩展,它们 的使用范围仍然是有限的。在游戏的AI领域的最近的趋势是越来越倾向于行为树和规划系统。如果你对复杂的AI感兴趣的 话,那么本章所有这些内容只是在吊你的胃口。你可能还想通过阅读其它的书籍来了解它们。 This doesn’t mean finite state machines, pushdown automata, and other simple systems aren’t useful. They’re a good modeling tool for certain kinds of problems. Finite state machines are useful when: 但是这并不意味着有限状态机,下推自 动机和其它简单的状态机没有用。它们对于解决某些特定的问题是一个很好的建模工具。当你的问题满足以下几点要求的时 候,有限状态机将会非常有用: You have an entity whose behavior changes based on some internal state. 你有一个游戏裸体,它的行为基于它的内部状态而改变 That state can be rigidly divided into one of a relatively small number of distinct options. 这些状态被严格划为为小个有限的小集合。 The entity responds to a series of inputs or events over time. 游戏实体随着时间的变化会响应用户输入和一些游戏事件。 现在知道它们有多有用了吧? In games, they are most known for being used in AI, but they are also common in implementations of user input handling, navigating menu screens, parsing text, network protocols, and other asynchronous behavior. 在游戏里面,它们在AI里面被 广泛使用,但是它们也经常被应用于"用户输入处理",“浏览菜单屏幕”,“解析文件”,“网络协议”和其它异步的行为。 Videogames are exciting in large part because they take us somewhere else. For a few minutes (or, let’s be honest with ourselves, much longer), we become inhabitants of a virtual world. Creating these worlds is one of the supreme delights of being a game programmer. 视频游戏很大程度会让我们兴奋是因为它们把我们带到了其他地方。在几分钟(或者,坦率讲,时间更长)里,我们成为了 虚拟世界的人。创建这些世界是作为游戏程序员的最高乐趣之一。 One aspect that most of these game worlds feature is time — the artificial world lives and breathes at its own cadence. As world builders, we must invent time and craft the gears that drive our game’s great clock 从一方面来讲,大多数游戏世界的特征便是时间--虚拟世界按照自己的节奏运行着。作为世界的建造者,我们必须创造时间 和打磨用来驱动游戏巨大时钟的齿轮。(译者注:这里作者用了比喻来说明问题) The patterns in this section are tools for doing just that. A Game Loop is the central axle that the clock spins on. Objects hear its ticking through Update Methods. We can hide the computer’s sequential nature behind a facade of snapshots of moments in time using Double Buffering so that the world appears to update simultaneously. 在本节中的模式便是用来做那样工作的工具。游戏循环是时钟旋转的中心轴,对象通过更新方法来聆听它的滴答声。我们可 以通过双缓冲来及时的将计算机的连续性隐藏在时刻快照之后,从而使得游戏世界能够同步更新。 双缓冲 游戏循环 更新方法 序列模式 本章模式 进行一系列序列化操作来表现出瞬发性或同步性。 计算机的心脏里藏着凶残的序列化处理能力。其力量源于它们能够将庞大的任务分解为许多细小的步骤以便逐个处理。 尽管 通常对于我们的用户而言,他们希望看到的是问题能够即刻被处理,或者多个任务能同时被执行。 注解: 虽然线程和多核技术在不断进步,但即便在多核环境下,也仅有少数操作能真正同步地执行 举个典型的例子,每个游戏引擎所必会涉及的——渲染。当引擎为用户渲染出可见的世界时,它是分步骤来完成渲染任务的:远 处的山峰,起伏的山脉,树木,这些被轮流渲染。假如用户也跟着逐步地观察引擎的渲染,那么这个连续游戏世界的幻像将会破 碎。场景必须快速而平滑地进行更新,显示一系列完整的帧,而每帧都应当瞬间显示出来。 双缓冲模式解决了上述问题,但为便于理解,首先让我们回顾一下计算机是如何显示图形的。 诸如计算机显示屏的显示设备在每一时刻仅绘制一个像素。显示设备从左至右地扫描屏幕第一行的每个像素,并如此从上至下 地扫描屏幕上的每一行。直到扫描至屏幕的右下角,它将重置回屏幕地左上角并如前述那样地重复扫描屏幕。这一扫描过程是 如此地快速(大概每秒60次),以至于我们的眼睛无法察觉这一过程。于我们而言,扫描的结果就是一块静态的彩色像素区域,即 一张图片。 注解: 这样的阐述不太妥当——它过于简单了。假如你从事底层硬件开发我想你大概已经笑了,你可以轻松地跳过后面 的部分,并完全能够理解本章余下的内容。但假如你并非这样的人物,那么在此我的目的是给予你足够的背景知识以便你 能理解我们随后要讨论的设计模式。 你可以将上述过程想象成一根细小的软管在向显示区域不断喷洒出像素。单个颜色像素到达软管的末端,软管将它们喷射到显 示区域中,每次往每个像素上喷洒一点。那么它如何知道哪个像素该往哪喷呢? 在多数计算机中答案是:它从帧缓冲区(framebuffer)中获知这些信息。帧缓冲区是一块内存,是存储着像素的数组(它是RAM中 的一个块,其中每两个字节表示一个像素)。当软管往显示区域喷洒时,它从这个数组中读取颜色值,每次读取1字节。 注解: 字节值与颜色之间的特殊映射关系是通过系统中的像素格式以及色彩深度来描述的。在当今的多数控制台游戏平 台,每个像素占32位:红绿蓝色彩通道各占8位,剩余的8位则用于其他多种用途。 基本上讲,为了让游戏在屏幕上显示出来,我们只需要往这个数组里写东西。我们熬夜折腾出来的那些先进图形算法,其根本都 只是在往帧缓冲区里设置字节的值。但这里有个小问题。 前面我说计算机的处理是序列化的。假设计算机正在处理我们的一段渲染代码,我们便不希望计算机同时在做其他不相干的 事。这几乎是对的,然而在我们的程序运行过程中间还是会穿插着许多其他的事情:比如当我们的游戏在运行时,显示设备会从 帧缓存中读取内存中的像素信息。这就为我们带来了问题。 比如我们希望在屏幕上显示一张笑脸。我们的程序开始循环访问帧缓存并对像素进行渲染。出乎我们意料的是,显卡正是在我 们往帧缓存中写入数据的同时进行数据读取的。一开始它扫描到那些我们已经写入的数据,笑脸便开始在屏幕上浮现,但它渐渐 超过我们的写入速度而访问了帧缓存中那些未写入数据的部分——悲惨的结局,屏幕上留下了一个半成品,这是个能看得一清二 楚的BUG。 双缓冲模式 # 目的 动机 计算机图形系统工作原理概述 注解: 如图,我们在显卡设备开始从帧缓存读取数据的同时进行像素数据的写入(图16.1)。最终显卡赶上并超过了渲染器 并访问了我们尚未写入数据的帧缓存区域(图16.2)。我们结束绘制(图16.3)时,那些在被显卡读取后才写入的数据就没有 被它读取到。结果用户看到的是渲染的半成品(图16.4)。我称它是”哭丧脸“——笑脸的下半边像是被撕掉了一样。 这就是我们需要本设计模式的原因。我们的程序一次性渲染所有的像素,同时我们要求显示器也一次性将其显示出来——可能 这一帧看不到任何东西,但下一帧显示的就是完整的笑脸。双缓冲解决了这一问题。下面我会以类比的形式来阐述。 设想我们的用户正在观看我们创作的一场表演。当第一个场景谢幕后第二个场景跟着上映,这时候我们需要切换场景。如果我 们在场景后台控制舞台管理设备直接开始收起场景道具,那么场景在视觉上的连续性会被破坏。我们可以在收拾场景的同时将 灯光变暗(这也正是影剧院所做的),而观众们依然知道黑暗中戏剧仍在继续。我们希望在剧幕之间不会产生间隙。 在资源允许的情况下,我们想到了这个好办法:我们建立两个舞台以便它们都能为观众所见。它们各有各的光源设置。我们称其 为A舞台和B舞台。场景1正在A舞台上上演,同时舞台B正处在黑暗中并正由场景后台进行着场景2的准备。一旦场景1结束,我 们就关掉A舞台的灯光并将灯光转移到B舞台,观众们便立即聚焦到新舞台并看到了第二幕场景上映。 与此同时,我们的场景后台正在清理舞台A,它清理场景1并为场景3做准备。一旦场景2结束,我们再将光线聚焦到A舞台上。我 们在整场表演过程中重复上述过程,将黑暗中的舞台作为工作区来为下个场景做准备。每次场景切换,我们只是将灯光在两个舞 台之间来回切换。我们的观众于是就看到了衔接流畅而无缝的场景转换。他们从不会看到舞台的后台。 注解: 借助单面镜以及其他一些巧妙的布局,实际上你能够在同一个舞台进行场景之间的无缝切换。当灯光转移时,观众 们可能会聚焦到另一个舞台上,但他们并不一定要转移视线。如何做到这一点就留给读者思考吧。 上面就是双缓冲的工作原理,你所见到的任何一款游戏其渲染系统中都重复着这样的过程,我们也是。如我们所类比的,双缓冲 中的一个缓存用于展示当前帧,即A舞台。它就是显示硬件读取像素数据的地方,GPU对其进行扫描,整个缓冲区的数据都是它 的。 注解: 然而并非所有的游戏和控制台都这么做。早前比较简单的控制台游戏受到内存的局限,小心翼翼地将渲染与机器 刷新操作进行同步来取代双缓冲,这可是要技巧的。 于此同时,我们的渲染代码正在往另一个帧缓冲区中写入数据,它就是我们黑暗中的B舞台。当渲染代码完成场景2的渲染时,它 通过交换两个缓冲区来”切换光线”。这使得显卡驱动开始从第一个缓冲区转向第二个缓冲区以读取其数据。只要它掌握好时 机在每次刷新显示结束时进行切换,我们就不会看到任何衔接的裂隙,且整个场景能一次性显示出来。这时候,旧的帧缓冲变得 可用了,我们就开始往它的内存区域渲染入下一帧。这真棒! 定义一个缓冲区类来代表一个缓冲区:一系列能被修改的状态。这块缓冲区能被一步步地修改,但我们希望任何外部的代码对该 缓冲区的修改都是原子操作。为实现这一点,此类中维护两个缓冲区实例:当前缓冲区和后台缓冲区。 场景1,幕1 回到图形上 (双缓冲)模式 当要从缓冲区中读取信息时,总是从当前缓冲区读取。当要往缓冲区中写数据时,则总在后台缓冲区上进行。当改动完成后,则 执行”交换”操作来讲当前缓冲区与后台缓冲区交换,以便让新的缓冲区为我们所见,同时刚被换下来的当前缓冲区则成为现在的 后台缓冲区以供复用。 这是个到需要时你自然会想起的设计模式之一。假如你的系统不支持双缓冲,那么显然是没法用了(比如会出现”撕裂”现象),或 者显示将表现出异常。但是说”需要的时候你自然会想起”还是太宽泛了,更准确地说,当下面这些条件都成立时,适用双缓冲模 式: 我们需要维护一些能够不断被修改的状态量。 同个状态可能会在其被修改的同时被访问到。 我们希望改变状态的工作进程对正在访问这些状态的外部代码透明。 我们希望能够读取到这些状态,而无需在其被写入时等待。 不同于其他大架构的设计模式,双缓冲模式的实现处于较底层。因此,它对代码库的影响较少——甚至多数游戏都不会在意 这些差别。当然,下面这些附加说明还是值得一提的。 双缓冲模式需要在状态写入完成后进行一个交换缓冲区的动作。这个操作必须是原子性的:也就是说任何代码都无法在这 个操作其间对任何一块缓冲区内的状态进行访问。通常这个交换过程和分配一个指针的速度差不多,但万一交换花去了比 修改初始状态更多的时间,那这模式就毫无助益了。 使用此模式的另一结果是导致内存占用率增加。正如其名,此模式要求你在任何时刻都维护着两份存储着状态的内存区 域。在内存受限的硬件上,这可是个很苛刻的要求。假如你无法分配出两份内存,你就必须想其他办法来避免你的状态在 修改时被访问。 说完理论,让我们来结合实践,看看它是如何工作的。我们将写一个及其简单的图形系统以供我们在帧缓存上绘制像素。在多数 控制台和PC上,显卡驱动提供了这一底层部分的图形系统,而这里通过手动实现它,我们将能窥其全貌。首先是缓冲区: class Framebuffer { public: Framebuffer() { clear(); } void clear() { for (int i = 0; i < WIDTH * HEIGHT; i++) { pixels_[i] = WHITE; } } void draw(int x, int y) { pixels_[(WIDTH * y) + x] = BLACK; } const char* getPixels() { return pixels_; } 使用情境 使用须知 交换操作本身是耗时的 必须要有两个缓冲区 示例 private: static const int WIDTH = 160; static const int HEIGHT = 120; char pixels_[WIDTH * HEIGHT]; }; 缓冲区拥有一些基本操作:将整个缓冲区清理为默认颜色,对指定位置的像素颜色值进行设置。它还包含了 getPixels() 函数,用 于外部访问缓冲区持有的整个原始像素数组。我们并不会在例子中看到它,但实际中,显卡驱动会频繁地调用这个函数来将缓冲 区的内存流式地输出到屏幕上。 我们在 Scene 类里包装这个原始的缓冲区。此类的任务在于对其缓冲区进行一系列的 draw() 函数调用来渲染出图形。 class Scene { public: void draw() { buffer_.clear(); buffer_.draw(1, 1); buffer_.draw(4, 1); buffer_.draw(1, 3); buffer_.draw(2, 4); buffer_.draw(3, 4); buffer_.draw(4, 3); } Framebuffer& getBuffer() { return buffer_; } private: Framebuffer buffer_; }; 注解: 具体来说,它画出了这样一幅杰作: 游戏在每帧通知场景进行绘制。场景清理缓冲区接着一次性地绘制一系列像素。它也通过方法 getBuffer() 提供了对内部缓 冲区的访问,以便显卡驱动能够获取到它。 这听起来直接了当,但假如我们就这么结束了,那么就会出现问题:显卡驱动可以在任何时刻对缓冲区调用 getPixels() ,甚至是 在下面这样的时机调用: buffer_.draw(1, 1); buffer_.draw(4, 1); // <- Video driver reads pixels here! buffer_.draw(1, 3); buffer_.draw(2, 4); buffer_.draw(3, 4); buffer_.draw(4, 3); 当上述情况发生时,用户将看到笑脸的眼睛部分,但单对这一帧而言它的嘴巴却没了。在下一帧它又可能在其他某个地方受到干 扰。结果是可怕的频闪图像。我们可以用双缓冲来修正它: class Scene { public: Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {} void draw() { next_->clear(); next_->draw(1, 1); // ... next_->draw(4, 3); swap(); } Framebuffer& getBuffer() { return *current_; } private: void swap() { // Just switch the pointers. Framebuffer* temp = current_; current_ = next_; next_ = temp; } Framebuffer buffers_[2]; Framebuffer* current_; Framebuffer* next_; }; 现在 Scene 拥有两个缓冲区,它们置于 buffers_ 数组中。我们并不直接从数组中引用它们,而是通过 next_ 和 current_ 这两个 成员来指向数组。当我们绘图时,我们往next这个缓冲区(通过 next_ 访问)里绘制,而当显卡驱动需要获取像素信息时,它总是 从 current_ 所指向的current缓冲区中获取。 由此,显卡驱动将不会访问到我们所正在进行处理的缓冲区。剩下的问题就在于在场景完成帧绘制后,对 swap() 方法的调用。 它简单地通过交换 next_ 与 current_ 这两个指针的指向来交换两个缓冲区。当下一次显卡驱动调用 getBuffer() 函数时,它将 获取到我们刚刚完成绘制的那块新的缓冲区,并将其内容绘制到屏幕上。再也不会有图形撕裂和不美观的问题了。 双缓冲模式所解决的核心问题在于对状态同时进行修改与访问的冲突。造成此问题的情况通常有两个,我们已经通过上述图形 例子描述了第一种情况——状态直接被另一个线程的代码所直接访问或者打断。 还有另一种很类似且常见的情况。一个状态同时被两段代码进行修改。这会在很多地方发生:尤其是实体的AI和物理部分,在它 与其他实体进行交互时会发生这样的情况,双缓冲模式往往能在此能奏效。 并非只针对图形 没智商的AI 假设我们在为所有实体构建行为系统,这是个基于打斗漫画的游戏。游戏包含一个舞台,许多角色在其中追逐打闹。下面是我们 的演员角色类: class Actor { public: Actor() : slapped_(false) {} virtual ~Actor() {} virtual void update() = 0; void reset() { slapped_ = false; } void slap() { slapped_ = true; } bool wasSlapped() { return slapped_; } private: bool slapped_; }; 游戏需要在每一帧对演员实例调用 update() 以让其进行自身的处理。从用户的角度严格来说,所有的角色必须看起来是同步 地进行更新。 注解: 这是一个Update Method的例子 演员也可以通过”相互作用”与其他角色进行交互,这里的相互作用指他们可以互相扇对方巴掌。当更新时,角色可以对其他角色 调用自身的 slap() 方法来扇巴掌并通过调用 wasSlapped() 方法来获知对方是否已经被扇过巴掌。 这些角色需要一个可以交互的舞台,我们下面构建它: class Stage { public: void add(Actor* actor, int index) { actors_[index] = actor; } void update() { for (int i = 0; i < NUM_ACTORS; i++) { actors_[i]->update(); actors_[i]->reset(); } } private: static const int NUM_ACTORS = 3; Actor* actors_[NUM_ACTORS]; }; Stage允许我们往里添加角色,并提供一个简单的 update() 方法来更新所有角色。对于用户而言,角色开始同步地各自移动,但 从内部看,一个时刻仅有一个角色被更新。 另一点需要注意的是,每个角色”被扇巴掌”的状态在其更新结束后立即被清空重置。这是为了确保一个角色只会对一个巴掌作 出响应。 为了推动事情的进展,我们来为角色创建一个具体的子类。我们的内容很简单,它面对一个角色,不论谁给了它一巴掌,它就冲着 这个角色扇巴掌。 class Comedian : public Actor { public: void face(Actor* actor) { facing_ = actor; } virtual void update() { if (wasSlapped()) facing_->slap(); } private: Actor* facing_; }; 现在,让我们往舞台里放一些角色来看看会发生什么。对三个角色进行恰当的设置,使他们每个都面对着下一个,而最后一个面 向第一个,组成一个圈。(译者注: 即构成一个小的单循环链表, facing_成员即为next) Stage stage; Comedian* harry = new Comedian(); Comedian* baldy = new Comedian(); Comedian* chump = new Comedian(); harry->face(baldy); baldy->face(chump); chump->face(harry); stage.add(harry, 0); stage.add(baldy, 1); stage.add(chump, 2); 现在舞台的布局如下图所示。箭头指明了角色所面朝的另一个角色,而数字表示角色在舞台数组中的索引号。 现在我们往harry脸上扇一巴掌来启动舞台,看看现在会发生些什么: harry->slap(); stage.update(); 切记 Stage 中的 update() 方法轮流对每个角色进行更新,所以假如我们浏览一遍代码,我们将推测舞台上表演的进展过程: Stage updates actor 0 (Harry) Harry was slapped, so he slaps Baldy Stage updates actor 1 (Baldy) Baldy was slapped, so he slaps Chump Stage updates actor 2 (Chump) Chump was slapped, so he slaps Harry Stage update ends 在单独一帧内,我们最开始给Harry的一巴掌传递给了所有演员。现在为了让事情更复杂些,我们把舞台上的这些演员在数组中 的顺序打乱但不改变他们脸的朝向。 我们将剩余的部分交给舞台自己处理,但要将上面添加三个角色的代码替换为以下: stage.add(harry, 2); stage.add(baldy, 1); stage.add(chump, 0); 让我们再来实验看看会发生什么: Stage updates actor 0 (Chump) Chump was not slapped, so he does nothing Stage updates actor 1 (Baldy) Baldy was not slapped, so he does nothing Stage updates actor 2 (Harry) Harry was slapped, so he slaps Baldy Stage update ends 哦!完全不一样了。问题很明显,当我们更新角色时,我们修改它们的”被掴巴掌”状态,我们也在更新中同时读取这些状态。因此 在同一次舞台更新循环中,状态的修改仅仅会影响到在其后更新的那些角色。 注解: 假如你继续更新舞台,你将看到扇巴掌的动作开始在角色之间传递,每帧传递一个。在第一帧, Harry扇了Baldy一巴 掌,下一帧Baldy扇了Chump一巴掌,如此递推。 最终的结果是某个角色可能不会在被扇巴掌的这一帧做出反应也不会在下一帧做出反应——这完全取决于两个角色在舞台中 的顺序。这违背了我们对角色的需求:我们希望它们同步地运转,而他们在某帧更新中的顺序是不应该对结果产生影响的。 幸运的是,我们的双缓冲模式能帮上忙。这一次,我们将缓存一系列粒度更恰当的数据:每个角色的”被掴”状态,而不是先前的那 两个巨大的缓冲区对象: class Actor { public: Actor() : currentSlapped_(false) {} virtual ~Actor() {} virtual void update() = 0; void swap() { // Swap the buffer. currentSlapped_ = nextSlapped_; // Clear the new "next" buffer. nextSlapped_ = false; } void slap() { nextSlapped_ = true; } 缓存这些巴掌 bool wasSlapped() { return currentSlapped_; } private: bool currentSlapped_; bool nextSlapped_; }; 现在每个角色有两个 slapped_ 状态而不是一个。正如先前图形的例子一样,当前的状态用于读取,下一个状态用于写入。 reset() 函数被 swap() 方法所替换。现在,在清除交换的状态之前,角色先将下一状态复制到当前状态中,使其成为当前状态,这 里还需要在 Stage 中进行一些小改动: class Stage { void update() { for (int i = 0; i < NUM_ACTORS; i++) { actors_[i]->update(); } for (int i = 0; i < NUM_ACTORS; i++) { actors_[i]->swap(); } } // Previous Stage code... }; 现在 update() 函数更新所有的角色接着对他们的状态进行交换。 这样的结果是,每个角色在其被扇巴掌的那一帧中仅会看到一个巴掌。这样一来,这些角色就会表现一致而不受他们在舞台上顺 序的影响。对于用户和外部的代码而言,这些角色在一帧中就是同步更新的。 双缓冲模式很直白,我们上面所看到的例子也几乎将你可能遇到的问题都涵盖到了。当实现这种模式时主要会有如下两点的讨 论: 交换缓冲区的操作是整个过程最关键的一步,因为在这一过程中我们必须封锁对两个缓冲区所有的读写操作。为达到最优性能, 我们希望这个过程越快越好。 交换指向两个缓冲区的指针。 这是我们图形例子中的做法,也是处理图形双缓冲最通用的解决方案。 这很快。它无视缓冲区的大小,交换操作只是两个指针分配的动作。没办法让这个过程更加简化或更快了。 外部代码无法永久存储指向某块缓冲区的指针。这是该方法主要的约束。因为我们并没有实际移动数据,我们实际上 做的是周期性地告诉其他代码库去另外一些地方找缓冲区,就像我们最初所比的舞台那样。这意味着其他代码库无法 直接存储指向某个缓冲区的指针,因为过一会儿它就可能指向错误的缓冲区了。 这对于那些显卡希望帧缓冲区在内存中固定地址的系统来说尤其会造成麻烦。如果是那样,我们就不能采用这种办 法。 缓冲区中现存的数据会来自两帧之前而不是上一帧的。连续几帧里在交替的两个缓冲区中进行绘制而不在它们之间 进行数据复制,如下: - 你将会注意到当我们要绘制第三帧时,在缓冲区中的数据来自第一帧的,而不是来自最近的第二帧。在多数情况下,这并不是问题——我们往往在绘制前会清理整个缓冲区。但假如我们企图复用某些缓冲区现存的数据,那么就必须考虑到那些数据是比我们所预期的更提早一帧。 设计决策 缓冲区如何进行交换? 注解: 双缓冲一个经典的用法是处理动态模糊。当前帧与先前渲染帧的一部分进行混合,以便让产生的图像更接近于真 实摄像机拍摄产生的效果。 在两个缓冲区之间进行数据的拷贝: 假如我们无法对缓冲区进行指针重定向,那么唯一的办法就是将数据从后台缓冲区实实在在地拷贝到当前缓冲区。这就是 我们在打斗喜剧里所做的。在这一情况下,我们选择此方法是因为其缓冲区仅仅是一个简单的布尔值标志位——它并不会 比复制指向缓冲区的指针花去更长的时间。 位于后台缓冲区里的数据与当前的数据就只差一帧时间。这是拷贝数据方法的优点,它就像打乒乓球那样一来一回通过两 个缓冲区的翻转来推进画面。假如我们需要访问先前缓冲区的数据,此方法会提供更加实时的数据以供我们使用。 交换操作可能会花去更多时间。这当然是个大缺点。这里的交换就意味着拷贝内存中的整个缓冲区数据块。假如缓 冲区很大,比如是一整个帧缓冲区,那么进行交换就会很明显地花去一整块时间。由于在交换期间无法对缓冲区进行任 何读写操作,故这是个很大的局限。 另一个问题在于缓冲区其自身是如何组织的:它是单个庞大数据块还是分布在某个集合里的每个对象之中?我们在图形的例子 中使用了前一形式而演员类中使用了后者。多数时候,你所要缓存的内容将会告诉你答案,当然也会有些变数。例如,我们的演 员也都可以将他们的信息集中存储在一个独立的信息块中,并让演员们通过他们的索引指向其中各自的状态。 假如缓冲区是单个大块 交换操作很简单,因为全局只有一对缓冲区,只需要进行一次交换操作。假如你通过交换指针来交换缓冲区,那么你就可以 交换整个缓冲区而无视其大小,只是两次指针分配而已。 假如许多对象都持有一块数据 交换较慢。为实现交换,我们需要遍历对象集合并通知每个对象进行交换。 在我们的打斗喜剧中,这是没啥问题的,因为我们总需要清理后台”被扇巴掌”的状态——每帧都必须访问到每个对象所 缓存的状态。假如我们不需要访问缓存的状态,那么我们就可以对其进行优化来使其达到与使用单块大缓冲区存储一 系列对象状态一样的效率。——此时的办法就是使用”当前”和”下一个”指针的概念并以此建立对象之间的联系(译者 注: 类似建立链表)。如下: class Actor { public: static void init() { current_ = 0; } static void swap() { current_ = next();} void slap() { slapped_[next()] = true; } bool wasSlapped() { return slapped_[current_]; } private: static int current_; static int next() { return 1 - current_;} bool slapped_[2]; }; 演员们通过current变量来访问状态数组。下个状态总是数组中的另一个索引,故我们可以通过next()来计算它。此时交换 状态只需变换current的索引。聪明的地方在于swap()现在是一个静态方法——只需要调用一次,则每个演员的状态都会被 交换。 你几乎能在任何一个图形API中找到双缓冲模式的应用。例如OpenGl中的 swapBuffers() 函数, Direct3D中的“swap 缓冲区的粒度如何? 参考 chains”,微软XNA框架在 endDraw() 方法中也进行的帧缓冲区交换。 实现用户输入和处理器速度在游戏行进时间上的分离。 假如有哪个模式是本书无法删去的,那么非游戏循环模式莫属。游戏循环模式是游戏编程模式中的精髓。几乎所有的游戏都 包含着它,无一雷同,相比而言那些非游戏程序却难见它的身影。 为了解循环模式是如何大有作为,我们先来快速回顾一下内存的发展史。在那个大家都还留着络腮胡的编程年代,程序工作 起来就像你家里的洗碗机——你塞进一段代码给机器,按下按钮,等待,获得输出结果,完成。那就是批处理程序——活干 完了,程序也就终止了。 注解 络腮胡:Ada Lovelace(十九世纪中期的数学家,世界上第一位程序设计师)和海军少将 Grace Hopper(二十世纪的海军 将军和计算机科学家)都留着极具纪念意义的络腮胡 今天你依然见得到它们,幸运的是今天的我们不再使用穿孔卡片来写代码。Shell脚本,命令行程序,甚至是将一堆标记性语 言(Markdown)转变成这本书的那些小Python脚本都属于批处理程序。 最终程序员们意识到,这种把批处理代码丢给计算机,离开几个小时后再回来查看结果的方式在程序排错上简直慢得可怕。 他们需要实时的反馈——于是交互式编程诞生了。最早的一批交互式程序就包括了像下面这样的游戏: YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK BUILDING . AROUND YOU IS A FOREST. A SMALL STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY. > GO IN YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING. 注解 洞穴冒险:上面这个被称为”洞穴探险”(Colossal Cave Adventure),史上首个冒险游戏。 你可以和这个程序面对面地交谈。它等待你的输入,并对你的操作进行响应。你也许还会回应它的反馈,你们就这么一唱一 和,就像你在幼儿园里所学的那样。当轮到你时,机器就静静地呆在那儿啥也不做,就像: while (true) { char* command = readCommand(); handleCommand(command); } 注解 退出游戏:这个程序永远地循环着,因此你无法退出游戏。真实的游戏会改为诸如while (!done) 并通过设置done标志 的值来退出游戏。我省去了这些来让例子看上去更简单。 游戏循环 目的 动机 CPU探秘 如果剥去现代的图形UI应用程序的外衣,你将发现它们和旧的冒险游戏是如此地相似。你的文字处理器通常什么也不做地呆 着,直到你按下了某个键或者点击了鼠标: while (true) { Event* event = waitForEvent(); dispatchEvent(event); } 这与文本指令的主要差异在于,事件循环程序等待用户的输入事件,包括鼠标点击和键盘按键。基本上它还是像旧的文字冒 险游戏那样运作,阻塞着自己等待用户输入,这是个大问题。 不同于其他大多数软件,游戏即便在用户不提供输入时也一直在跑。假如你坐下来愣盯着屏幕,游戏也不该卡住。动画依旧 在播放,各种效果也在闪动跳跃,假如你运气不佳,怪物们则可能在不断地啃咬你的英雄! 注解 空闲状态:多数事件循环都包含一个”空闲”(“idle”)事件以便在没有用户输入时也能间歇性地处理事务,这对于闪烁的光 标或者一个进度条而言已经足够了,但对于游戏而言远远不够。 这是实际游戏循环的第一个关键点:它处理用户的输入,但并不等待输入。游戏循环始终在运转: while (true) { processInput(); update(); render(); } 上面是最基本的结构,我们稍后再改善它。processInput()处理相邻两次循环调用之间的所有用户输入。接着update()让游戏 (数据)模拟迭代一步,它执行游戏AI和物理计算(这是常见顺序)。最后render()对游戏进行渲染以将游戏内容展现给玩家。 注解 见名知意,你可能已经猜到了,update()方法里正是个使用Update模式的好地方。 假如循环不因输入而阻塞,那么试问:它运转得多快呢?游戏循环的每次执行通过某些值更新了游戏状态,从游戏世界中某 个人物的视角来看,他们的时钟便往前走了一个单位。 注解 帧:游戏循环的一次更新可以用术语”tick”(“tick”)或“帧”(“frame”)来描述。 与此同时,玩家的实际时间也在流逝。假如用现实时间来衡量游戏循环的速度,我们就得到了游戏的”帧率”(FPS,frames per second)。假如游戏循环得很快,FPS的值便很高,游戏将会运行得十分快而流畅。反之,游戏就会拖拉得像场定格电影 (stop motion movie)。 对于现在这个粗糙的游戏循环,它以其尽可能快的速度在运转。两个因素决定了帧率: 1.循环每一帧要处理的信息量。复杂的物理运算,一堆对象的数据更新,许多图形细节等等都将让你的CPU和GPU忙个不 停,这都会让一帧消耗更多的时间。 2.平台的底层速度。越快的芯片在相同时间内处理更多的代码。多核,多GPU,专用声卡以及操作系统的定时器都影响着你 事件循环 时间之外的世界 在一帧里能干多少事情。 在早期的电视游戏中,这个秒数因子是被固定的。假如你为红白机(NES)或者苹果二代电脑(Apple IIe)写游戏,你就必须对执 行你游戏的CPU有深度的了解,而且你要能(且必须)为它写专门的代码。你需要好好考虑游戏的每一帧都该做些什么。 早些的游戏被精心地编写成每帧仅执行必须的任务,以便它能够在开发者期望的速度下运行。但假如你在更快或更慢一些的 机器上跑这样的游戏,游戏本身会发生变速的现象。 注解 “turbo”:这也就是那些旧的个人电脑总带着“加速”(“turbo”)按钮的原因。新一代的个人电脑变得更快,它们将无法运行 那些旧的游戏——因为这些游戏跑起来会变得很快。关闭加速按钮可以减缓它们的运行速度以便进行游戏。 而今很少有开发者对他们游戏所运行的硬件平台有深度的了解。取而代之的是,我们必须要让游戏智能地(在速度上)适配于 多种硬件机型。 这就是游戏循环模式的另一个要点:这一模式让游戏在一个与硬件无关的速度常量下运行。 游戏循环在游戏过程中持续运转。每循环一次,它非阻塞地处理用户的输入,更新游戏状态,并渲染游戏。它跟踪流逝的时 间并控制游戏的速率。 对于设计模式,宁可不用也不能错用,故每一章你都能看到这一部分,以便让我们冷静下来思考。设计模式的目标可不是为 了让你毫无节制地往你的程序里塞代码。 但这一模式有所不同。我拍着胸脯说你会在你的游戏里使用它。假如你使用了游戏引擎,那么这一模式无需你亲手实现,但 它依然存在(于引擎中)。 注解 于我而言,这就是“引擎”和”库”之间的差别。使用库时,你自己把握游戏循环并在其中调用库函数,而使用引擎时它自 己掌握着游戏主循环并调用你的代码。 你可能会想,我的回合制游戏应该不需要这家伙吧?不,尽管回合制游戏中,游戏状态总是随着双方回合的轮转而更新,但 游戏中视觉和听觉的模块却也一直在运转,即便当你正在自己的回合犹豫着下一步行动时,动画和音效也依旧在运转。 我们这里所讨论的循环是游戏中举足轻重的部分。正所谓程序90%的时间都花在10%的代码上——而游戏循环部分的代码就 在这10%之中。你必须小心翼翼,并时刻考虑它的效率。 注解 谈论这些听起来不靠谱的统计,正是那些正牌机械或电气工程师不把我们当回事的原因吧! 假如你在一个带有图形UI和内置事件循环的操作系统或平台上构建游戏,那么在游戏运行时就有两个应用程序循环在执行。 秒的长短 (游戏循环)模式 使用情境 使用须知 你可能需要与操作系统的事件循环进行协调 它们需要很好地协作。 有时你可以对其进行控制使得游戏只执行你的游戏循环。例如,你放弃珍贵的WindowsAPI来开发游戏,那么你的main()函数 可以简化为一个游戏循环。其中你可以调用PeekMessage()处理并从操作系统中分派事件。不同于GetMessage(), PeekMessage()并不阻塞等待用户输入,所以你的游戏循环会持续地运转。 其他平台并不会轻易地让你退出事件循环。假如你以浏览器为平台,事件循环也已根植在浏览器执行模式的底层。其中事件 循环负责显示,你同样要使用它来作为你的游戏循环。你可能会调用requestAnimationFrame()之类的函数以便浏览器回调回 你的程序,并维持游戏的运转。 做了这么长的介绍,游戏循环模式的代码已经不言而喻。我们将看到两个不同的实现版本,并比较它们的好坏。 游戏循环驱动着AI,渲染,和其他游戏系统,但这并不是模式本身的关键,所以这里我们将这些部分都虚化。实际上 render(),update() 等这些部分留给读者作为练习(值得一试!)。 我们已经看到最简单的游戏循环: while (true) { processInput(); update(); render(); } 它的问题在于你无法控制游戏运转的快慢。在较快的机器上游戏循环可能会快得令玩家看不清游戏在做些什么,在慢的机器 上游戏则会一样变慢变卡。假如你还加入了重量级的模块或者进行AI,物理运算,游戏实际上会更卡。 我们首先来看看做一点小改动会如何。假设你希望让游戏以60帧/秒运行,也就是说你大概有16毫秒的时间来处理每一帧。假 如你确实能够在这16毫秒以内进行所有的游戏更新与渲染工作,你就可以以一个稳定的帧率来跑游戏。你所需要做的就是处 理这一帧,接着等待下一帧的到来,如下图: 注解 1000 ms/FPS = 毫秒每帧 代码如下: 示例 跑,能跑多快就跑多快 小睡一会儿 while (true) { double start = getCurrentTime(); processInput(); update(); render(); sleep(start + MS_PER_FRAME - getCurrentTime()); } 这里sleep()的方法确保即便过快地处理完一帧,游戏也不会运转得太快。但这办法在游戏运行过慢时并无作为。假如一帧的 更新渲染时间超过了16毫秒,睡眠的时间为负——如果我们有让时间逆流的电脑,那许多事情都会很容易,遗憾的是并没 有。 这时候游戏便慢下来。你可以通过减少每帧的工作量——减少图形处理量或者在AI上耍点小聪明,甚至直接砍了AI。但即便 是在一台很快的机器上,这样做也会影响游戏的质量。 让我们再试试稍复杂点的办法。我们目前的问题可以归结为: 1.每次更新游戏花去一个固定的时间值。 2.需要花些实际的时间来进行更新。(译者注:而这个“实际时间”是机器相关的) 假如第二步的时间长于第一步,那么游戏就会变慢。例如当需要16毫秒以上的时间来更新帧速为16毫秒每帧的游戏时,就可 能无法维持运行速度。但假如我们能在每一帧中进行超过16毫秒的游戏状态更新,那么我们可以不那么频繁地更新游戏并且 能够追赶上游戏的行进速度。 具体想法是计算这一帧距离上一帧的实际时间间隔以作为更新步长。帧处理花费的实际时间越长,这个步长也就越长(译者 注:这个步长实际上等值于帧处理花费的实际时间)。这个办法使得游戏总会越来越接近于实际时间。他们称此为浮动时间迭 代(或者变值时间迭代),代码如下: double lastTime = getCurrentTime(); while (true) { double current = getCurrentTime(); double elapsed = current - lastTime; processInput(); update(elapsed); render(); lastTime = current; } 在每一帧里,我们计算出自上次更新至今所花费的实际时间,即变量elapsed。当我们更新游戏状态时,将这个时间值传入。 接下来游戏引擎必须负责将游戏世界更新到这个时间增量的下一个状态。 假设我们从屏幕左边向右边开了一枪。在固定时间迭代方法下,每帧中你根据子弹的速度移动它。在浮动时间迭代方法下, 你通过时间差可以调整这个子弹的速度。随着迭代步长增加,子弹在每一帧越飞越快。于是子弹将在等同的实际时间中移动 同样的距离,不论它花了20小步(较快的机器上)来完成的或4大步(较慢的机器上)来完成。 这办法看起来成功了: 1.这样一来游戏在不同的硬件上以相同的速率运行。 2.高端机器的玩家能够得到一个更流畅的游戏体验。 但,哎,我们面前还埋着个大坑:我们使得游戏变得不确定且不稳定。举个例子来说说我们自埋的坑吧: 注解 “确定性”表示每次你运行程序,假如给与同样的输入,那么你将得到完全一致的输出。如你所想,在具有确定性的程序 小改动大进步 上排错要容易多了——一旦找到导致错误的输入,那么它每次都能重现BUG。 计算机天生具有确定性,它们机械地执行程序。当混乱的现实世界参杂进来时它们就比变得不确定。例如,网络,系 统时钟,线程定时器等都很大程度地依赖于程序控制之外的真实世界。 假设在一个双玩家的网络游戏中,Fred使用的是强大的游戏机而George用的是他祖母的古董PC机,我们之前讨论的子弹在 他们的屏幕上飞来飞去。在Fred的机器上,游戏运行得飞快,也就是说每一帧处理所需的时间都极短。让我们把帧填满:假 设在Fred的机器上子弹飞过屏幕共执行了50帧,那么George那苦逼的机器可能只能在这样的时间里执行5帧。 这意味着在Fred的机器上,游戏的物理引擎更新了子弹的位置50次,而George的机器只执行了5次。多数游戏采用浮点数, 而它们会带来舍入误差。 你每次将两个浮点数相加,其返回的结果都可能出现左右偏差。Fred的机器做了比George机器10 倍多的运算,所以他累计了更多的误差。在他们的机器上,子弹将在不同的位置消失。 这只是变时迭代方法可能导致的麻烦之一,问题还多着呢。为了以实时来运行,游戏的物理引擎会做实际物理规则的近似。 为了防止这近似计算”炸飞上天”,系统进行了减幅运算。这个减幅运算被小心地安排成以某个固定时长迭代进行。因此,物 理引擎也将变得不稳定。 注解 “炸飞上天”(“Blowing up”)在这里取字面意思。当物理引擎出问题时,游戏中的对象可能已完全错误的速度飞到天上 去。 这个例子其不稳定性只是作为一个警醒我们的例子,它会引导我们更进一步。 渲染,是引擎中通常不会受变时迭代影响的部分。由于渲染引擎表现的是游戏时间中的一瞬间,所以它并不关心距离上次渲 染过去了多少时间。它只是把当前的游戏状态渲染出来而已。 注解 这很大程度上是成立的。诸如动态模糊等效果可能受到时间迭代的影响,但假如它们出现一些偏差,玩家往往也注意 不到。 这一事实可以利用。我们将使用固定时间更新,因为它使得物理引擎和AI都更加稳定。但我们允许在渲染的时候进行一些机 动的调整以释放出一些处理器时间。 它这样运作:距离上次的游戏循环已经过去了一段(真实的)时间。这一段时间就是我们需要模拟的游戏”当前时间”,以便赶上 玩家的实际时间。我们通过一系列的定时步骤来实现它。代码大致如下: double previous = getCurrentTime(); double lag = 0.0; while (true) { double current = getCurrentTime(); double elapsed = current - previous; previous = current; lag += elapsed; processInput(); while (lag >= MS_PER_UPDATE) { update(); lag -= MS_PER_UPDATE; } render(); } 上述代码可分为几部分:在每帧的开始,我们基于实际流逝的时间更新变量lag。这一变量表示了游戏时钟相对现实时间落后 把时间追回来 的量。接着我们使用一个内部循环来更新游戏,每次以固定时间进行,直到它追赶上现实时间。一旦赶上,我们渲染并进行 下一次游戏循环。你可以将上述过程画图如下: 注意此时的时间步长不再是视觉上的帧率。常量MS_PER_UPDATE只是我们更新游戏的间隔。这一间隔越短,追赶上实际 时间所花费的处理次数就越多。间隔越大,游戏跳帧越明显。理论上,你希望它足够短,通常快于60FPS,以使游戏在快的 机器上维持高保真度。 但要注意的是别让它过短。你必须保证这个时间步长大于每次update()函数的处理时间,即便在最慢的机器上也须如此。否 则,你的游戏便跟不上现实时间。 注解 我把它就这么留在这,但你可以对其采取一些安全措施:当内部更新循环次数超出一定迭代上限时,让循环终止。这 样游戏可能会变慢,但总比完全卡死好。 幸运的是,我们给予了自己一些喘息的空间。我们通过将渲染拉出更新循环之外来实现了这一点。这一方法解放了大量的 CPU时间。最后的结果是,游戏通过定时步长更新,实现了在多硬件平台上以恒定速率进行游戏模拟。只不过在低端机器上 玩家会看到游戏窗口里出现跳帧的情况。 眼下还留有一个问题,也就是残留的延迟。我们以固定的时间步长更新游戏,但在随机的时间点进行渲染。这意味着从玩家 的角度来看,游戏常会在两次更新之间展现出完全相同的画面。 让我们看看时间线: 如你所见,我们的更新十分紧凑而固定,同时我们在任何可能的时间进行渲染。渲染的频度低于更新,且不稳定。这些都没 有问题。问题在于我们并不总在更新的时间点进行渲染。看看第三次渲染,它介于两次更新之间: 留在两帧之间 设想一个子弹正横穿屏幕,首次更新时它在左侧,而第二次更新将它移动到屏幕右端。渲染在两次更新之间的某个时间点进 行。以我们现在的实现方式,它将依然在屏幕左端。这意味着动作看起来会显得卡顿而不流畅。 顺带一提,我们实际上知道渲染时相邻两帧之间的间隔长度:也就是变量lag。当这个值小于更新时间步长时,我们跳出更新 循环,而不是当lag为0时跳出。那么此时lag剩余的量呢?其实这个量就是我们进入下一帧的时间间隔。 当进行渲染时,我们将其传入: render(lag / MS_PER_UPDATE); 注解 标准化:这里我们将它除以MS_PER_UPDATE是为了将值标准化。这样传入render()的值将在0(恰好在前一帧)到1(恰 好在后一帧)之间(忽略更新时间步长)。通过这一方法,渲染引擎无需担心帧率。它仅仅处理0-1值之间的情况。 渲染器知道每个游戏对象的属性以及其当前速度。假设子弹在距离屏幕左侧20像素的地方并以400像素每帧的速率向右移 动,假设我们在两帧的正中间渲染,传入render()的参数值即为0.5。故它绘制了下半帧的子弹飞行情况,也就是在距离屏幕 左侧220的位置。锵!流畅的动作。 当然,可能会遇到推断错误的情况。当计算下一帧时,子弹可能撞上了障碍物,或者减速了等等。我们只是设想其前一帧的 位置以及下一帧可能所在的位置并在两者之间插值地渲染其位置。但除非物理引擎和AI更新完成,否则我们并不能确切地知 道子弹究竟会在哪。 故推断含有猜测的成分,有时将会出错。幸运的是,这些程度的修正通常并不引人注目。至少,比起你完全不做预测时的卡 顿要不起眼得多。 尽管这章已经写得够长了,但我还是留下了许多额外的问题。一旦你考虑诸如与显示刷新速率的同步,多线程,GPU等因 素,实际的游戏循环将会变得复杂许多。在这样的高级层面上,你可能需要考虑以下这些问题: 这是你或多或少都要回答的一个问题。假如你的游戏嵌入在浏览器里,那么你往往无法自己来写游戏循环。浏览器自带基于 事件的机制已经预先包含了这一循环。类似地,假如你使用了现成的游戏引擎,你也将依赖于它的游戏循环而不是自己来控 制。 使用平台的事件循环: 这相对简单,你无须担心游戏核心循环的代码和优化问题。 它与平台协作得很好。你显然无需担心它何时处理事件,如何捕获事件,或者如何处理平台与你的输入模型之间不 匹配的问题等等。 你失去了对时间的控制。平台将在其认为合适的时间调用你的代码。假如其频度无法达到你所预期,很遗憾。更糟 设计决策 谁来控制游戏循环,你还是平台? 的是,许多应用程序的事件循环在概念上的设计并不同与游戏——它们通常很慢并且不连续。 使用游戏引擎的游戏循环: 你无需自己写代码。写游戏循环需要不少技巧。由于其核心代码每一帧都会执行,其微小的错误或性能问题都可能 对你的游戏产生很大的影响。具有一个紧凑靠谱的游戏循环是考虑使用现存引擎的重要原因。 你不需要亲自来写。当然,坏消息是当出现一些与引擎循环不那么合拍的需求时,你却无法获得循环的控制权。 自己写游戏循环: 你来掌控一切。你可以做你想做的任何事。你可以完全依照你游戏的需求来设计它。 你需要实现平台的接口。应用程序框架和操作系统通常希望你能划分出一些时间来供它们处理事件并做一些其他 事。假如你掌控你程序的核心循环,那么它们便得不到这些时间。你显然周期性地将控制权交给系统以保证应用程 序的框架不会混乱。 五年前我们无须讨论这个问题。那时游戏运行在电视设备或专用手持设备上。但随着智能手机,笔记本电脑,移动游戏的大 力发展,你现在是该好好考虑这个问题了。一个跑起来很炫的游戏,但它却将玩家的手机变成一个3分钟将果汁蒸发的加热 器,这可不是个让人们开心的好游戏啊。 现在你需要考虑不但要让你的游戏看来很棒,并且尽可能低减少CPU的使用率。当你完成了一帧中所做的所有工作时,你可 能需要一个性能的上限来控制CPU进行休眠。 让它能跑多快跑对快: 你最好只在PC游戏上这么做(尽管越来越多的玩家在笔记本上跑PC游戏)。你的游戏循环从不明确地让系统休眠。任何空余的 循环都要用于避免FPS或者图形保真度的不稳定。 这给予你最好的游戏体验,但它会吞噬电量。假如玩家在笔记本电脑上玩,他们需要一个很好的供电设备。 限制帧率: 移动游戏通常更关注游戏的运转质量而不是最高的画质。许多移动游戏会设置帧率上限(60或30FPS)。假如游戏循环在本时 间片内已经完成了处理,剩余的时间它将休眠。 这给予了玩家一个足够好的体验并帮他们节省的设备能耗。 一个游戏循环具有两个关键部分:非阻塞的用户输入和帧时间适配。输入的问题好解决。所以关键在于你如何解决时间的问 题。游戏可运行的平台数目是有限的,且多数游戏只能在其中几个平台上跑。如何适应平台变化便是关键。 注解 做游戏看起来像是人类的天赋之一,因为每创造出一个能进行计算的机器,我们最先做的就是在它上面开发游戏。 PDP-1是一台主频2kHz的机器,仅有4096字的内存,即便如此Steve Russell和他的几个同学还是在它身上创造出了 Spacewar!(译者注:世界上第一款真正意义上的娱乐性游戏,双人飞行射击游戏)。 非同步的定时迭代:见我们的第一个代码样例。你只需要尽可能快地执行游戏循环。 简单。这是这一情况的主要(呃,也是唯一的)优点。 游戏速度直接取决于硬件和游戏的复杂程度。其主要缺点是假如硬件出现任何变化,将直接影响游戏速度。它就像 带着死飞的游戏循环。 同步的定时迭代:在复杂平台上所要做的下一步是让游戏进行定时迭代,同时在循环的末尾增加一个延时,或者是同步 点以防游戏运行得过快。 依然很简单。比起最简单的例子,只需要追加一行代码。在多数游戏中,你都希望进行同步。或许你会为图形引擎 增加双缓存并让翻转缓存的操作与显示的刷新率同步。 你如何解决能量耗损? 如何来控制游戏速度? 这是省电的。这是移动游戏十分在意的一点。你不会希望非必要地耗损用户的电量。通过几毫秒的休眠而不是将每 一帧都塞满操作,你能够省电。 游戏不会跑得很快。它的速度可能是填满操作的游戏循环的一半。 游戏可能会跑得很慢。假如更新一帧的更新和渲染花去过多的时间,游戏反馈将会变慢。由于这一模式并不将更新 与渲染分离,在没有进一步优化的情况下它将很容易显露出这一缺陷。不进行外置帧渲染并同步时,游戏会变慢。 变时迭代:我在此提到诸多解决方法中的这一种以警示那些我曾经建议避免使用它的游戏开发者们。记住这个方法为何 不好,总有助益。 它能适应过快或过慢的硬件平台。加入游戏无法跟上现实速度,它将以越来越快的步伐跟上。 它使得游戏变得不确定且不稳定。当然这才是根本问题。物理和网络模块往往在变时迭代下变得运转困难。 定时更新迭代,变时渲染:我们提及的例子中的最后一个办法也是最复杂,最具适配性的一个。它以固定时间步长进行 更新,但将渲染与更新分离,并让渲染来跟进玩家的时钟。 它也能适应过快或过慢的硬件平台。因为游戏能够实时更新,所以游戏状态不会落后于现实时间。假如玩家拥有顶 尖的机器,它则将带来一个十分流畅的游戏体验。 它很复杂。它的主要缺陷在于实际的实现还有需要工作要做。你需要协调更新迭代的步长使其在高端机上足够小(足 够平滑),同时在低端机上不会让游戏跑得太慢。 讲述游戏循环模式的一篇经典文章是来自Glenn Fiedler的“Fix Your Timestep“。没有它这一章节就没法写成现在这样。 Witters的文章 game loops 也值得一看。 Unity的框架具有一个复杂的游戏循环,这里有一个对其很详尽的阐述。 参考 通过对所有对象实例同时进行帧更新来模拟一系列相互独立的游戏对象。 玩家所操控的强大女武神在执行任务,目标是从法师之王所长眠的埋骨地里盗取珍贵珠宝。她试探性地接近法师那法力强大 的地穴入口,并将受到攻击——可实际上什么也没有,没有被诅咒的雕像向她发射光线,也没有亡灵士兵在入口巡逻。她长 驱直入,轻取珠宝,然后你赢了,然后游戏结束。 嗯,这真没劲。 这个地穴需要一些守卫来绊住我们的英雄。首先,我们希望让一个复活的骷髅兵在门口来回巡逻。我想你已经猜到该怎么写 代码了,你可以这样要让它来回巡逻: 注解 假如法师之王希望仆从们有更机智的表现,那么他需要复活一些聪明的家伙们 while (true) { // Patrol right. for (double x = 0; x < 100; x++) { skeleton.setX(x); } // Patrol left. for (double x = 100; x > 0; x--) { skeleton.setX(x); } } 此代码的问题在于,虽然怪物来回走着但玩家却看不到它。程序被一个死循环锁住,这显然会带来很差劲的游戏体验。我们 所希望的是骷髅兵每一帧走一步,以保证在骷髅守卫巡逻时.游戏能持续地进行渲染并对玩家的输入做出反应。如: 注解 当然,游戏循环(Game Loop)是本书介绍的另一种设计模式 Entity skeleton; bool patrollingLeft = false; double x = 0; // Main game loop: while (true) { if (patrollingLeft) { x--; if (x == 0) patrollingLeft = false; } else { x++; if (x == 100) patrollingLeft = true; } 更新方法 目的 动机 skeleton.setX(x); // Handle user input and render game... } 我之所以列出前后两个版本,是为了告诉读者代码是如何变复杂的。向左,右巡逻本是两个相互独立的循环,骷髅依赖于循环 的执行来保持对自己巡逻方向的跟踪。为达到逐帧处理的目的,我们必须逐帧跳出游戏循环并随后(在下一帧时)返回循环内 以继续,在此必须借助变量 patrollingLeft 以在循环内外维持对其方向的跟踪。 但这至少奏效,我们接着前进。一堆无脑的骨头可不会对你的女武神造成什么威胁,于是接下来我们为它加入一些魔法状 态,这将使它能频繁地向我们的女武神释放闪电和火光,让她措手不及。 时刻记得我们的风格——“最简单地写代码”,于是我们这么写: // Skeleton variables... Entity leftStatue; Entity rightStatue; int leftStatueFrames = 0; int rightStatueFrames = 0; // Main game loop: while (true) { // Skeleton code... if (++leftStatueFrames == 90) { leftStatueFrames = 0; leftStatue.shootLightning(); } if (++rightStatueFrames == 80) { rightStatueFrames = 0; rightStatue.shootLightning(); } // Handle user input and render game... } 你会发现这代码的可维护性不高。我们维护着一堆其值不断增长的变量,并不可避免地将所有代码都塞进游戏循环里,每段 代码处理一个游戏中特殊的实体。为达到让所有实体同时运转的目的,我们把它们给糊成一团了。 注解 一旦当你的代码构架可以确切地用“糊作一团”来形容,那你可遇到麻烦了。 你可能猜到我们所要运用的设计模式该干些什么了:它要为游戏中的每个实体封装其自身的行为。这将使游戏循环保持整洁 并便于往循环中增加或移除实体。 为了做到这一点,我们需要一个抽象层,为此定义一个 update()的抽象方法。游戏循环维护对象集合,但它并不关心这些对 象的具体类型。它只是更新它们。这将每个对象的行为从游戏循环以及其他对象那里分离了出来。 每一帧,游戏循环遍历游戏对象集合并调用它们的update()。这在每帧都给予每个对象一次更新自己行为的机会。通过逐帧 调用update方法,这些对象的表现得到同步。 注解 有些爱挑刺的人会说,它们并不是真正意义上的行为同步,因为一个对象更新时其他对象都不在更新——让我们后面 再来深入这个问题。 游戏循环维护一个动态对象集合,这使得向关卡里添加或移除对象十分便捷——只要往集合里增加或移除就好。问题已解 决,我们甚至可以将关卡文件用某种文件格式存储,以供我们的关卡设计师们使用。 游戏世界维护一个对象集合。每个对象实现一个更新方法来在每帧模拟自己的行为。而游戏循环在每帧对集合中所有的对象 调用其更新方法以实现同步的游戏世界更新。 假如把游戏循环比作有史以来最好的东西,那么更新方法模式就会让它锦上添花。许多游戏都通过这样或那样的形式来使用 这一设计模式,以构造出许多鲜活的游戏实体来与玩家进行交互。像游戏里的太空战士,龙,火星人,幽灵或者运动员们, 他们正适合使用这一设计模式。 然而,假如这个游戏更加抽象,那些移动的对象并不像是生物而更像是西洋棋子,那么这一模式就不那么适用了。在一个类 似西洋棋的游戏里,你并不需要同时模拟所有对象,而且你很可能也没必要让棋子们逐帧地更新自身。 注解 或许你无须逐帧更新它们的行为,但即便是在棋类游戏中,你也很可能需要逐帧更新它们的动画。这一设计模式同样 可以帮到你。 更新方法模式在如下情境最为适用: 你的游戏中含有一系列对象或系统需要同步地运转。 各个对象之间的行为几乎是相互独立的。 对象的行为与时间相关。 这一设计模式相当简单,所以它并没有什么值得发现的惊喜。当然,每行代码也都有它的意义在。 比较先前的两个代码块,第二个显得更加复杂。二者都只是让骷髅守卫来回行走,但第二个代码块将控制权分派给了游戏循 环的每一帧。 这一变化几乎在处理用户输入,渲染以及其它游戏循环所关心的事情时是必不可少的,所以第一个例子并不实用。例一的价 值在于让我们牢记,这样处理你对象的表现,你将面临着复杂而巨大的开销。 注解 我所说“几乎”,是因为有时你也可以兼得鱼与熊掌。你可以直接为你的对象行为编码而不让这些函数返回,同时使这样 一系列的对象与游戏循环保持同步运转。 要想实现这一点,你就必须使用多线程来让这些对象同时运转。假如一个对象可以在处理时中途暂停并继续,你可以 用更强制的方式来执行而不必完全让函数结束返回。 实际中的线程往往对我们的例子而言过于繁重,但假如你的语言支持轻量的并发性组件诸如生成器,协程, Fibers(Node.js),那可以考虑使用它们。 Bytecode设计模式是在应用程序层创建多线程的另一种选择。 在第一段示例代码中,我们并无任何指明守卫移动方向的变量。方向完全取决于当前执行的是哪一段代码。 (更新方法)模式 使用情境 使用须知 将代码划分至单帧之中使其变得复杂 你需要在每帧结束前存储游戏状态以便下一帧继续 当我们将其改造为逐帧更新的形式时,我们需要创建一个patrollingLeft变量来跟踪这个行走方向。当我们脱离内部代码,我 们就无法获知行走的朝向,所以说我们需要存储足够的帧信息以便下一帧能够继续执行。 State设计模式在这里通常能帮上忙,因为状态机(正如其名)存储了那些能够让你在下一帧继续处理的游戏信息。 在本设计模式中,游戏循环在每帧遍历对象集并逐个更新对象。在 update() 的调用中,多数对象能够访问到游戏世界的其他 部分,包括那些正在被更新的其他对象。这意味着,游戏循环遍历更新对象的顺序意义重大。 假如A对象在对象列表中位于B对象的前面,那么当A更新时,它将会看到B停留在前一帧的状态。但当B更新时,它看到的却 是A在这一帧的新状态,因为A在这一帧已经被更新了。尽管从玩家的视角来看,所有的事物都同时在运转,但游戏的核心仍 然是回合制的——只不过这时两回合之间的间隔只有一帧的时间。 注解 假如由于某些原因你希望回避这一有序性,你可能会需要Double Buffer模式的帮助。这一模式将使得A,B的更新顺序 不再重要,因为它们都能够获取到前一帧的状态。 考虑到游戏逻辑,更新分先后顺序这是件好事。平行地更新所有对象会将你带向语义死角,设想西洋棋盘上黑白棋子同时移 动,它们都想往一个当前空白的位置移动,这该怎么办? 序列化地更新解决了这一问题——每次更新增量式地改变游戏世界,从一个有效的状态到下一个,不会产生对象状态的歧义 而需要去进行调解。 注解 这同样在在线游戏模式起作用,因为你需要一串序列化的动作数据来在网际间进行传输。 当你使用这一模式时,大量的游戏表现将在这些更新方法中完成。这里面常常包含着从游戏中增加或移除对象的代码。 例如,假设一个骷髅卫兵被杀死时会掉落一个物品,对于一个新对象,你通常可以直接将它加入到列表的尾部而不会产生问 题。循环继续,最终你能够在循环的末尾找到这个新对象并更新它。 但这意味着在这个新对象产生的那一帧它也有机会进行更新,而此时玩家尚未看到这个物品。假如你不希望这样的情况发 生,一个简单的办法就是在遍历之前存储当前对象列表的长度,而在这一次循环仅更新列表前面这么多的对象: int numObjectsThisTurn = numObjects_; for (int i = 0; i < numObjectsThisTurn; i++) { objects_[i]->update(); } 上例中,objects是游戏中可更新对象的数组而numObjects是它的长度。当增加新的对象时,这个长度变量增长。我们在循环 的一开始将长度缓存在numObjectsThisTurn变量中,从而使这一帧的循环迭代在遍历到任何新增对象之前停止。 一个令人担忧的问题是在迭代时移除对象。你希望让一只肮脏的野兽从游戏中消失,而这时候需要从对象列表中移除它。假 如在对象表中,它碰巧位于你当前所更新的对象之前,你可以投机地跳过它。 for (int i = 0; i < numObjects_; i++) { objects_[i]->update(); } 所有对象都在每帧进行模拟,但并非真正同步 在更新期间修改对象列表时必须谨慎 这一简单的循环通过对象下标索引的递增来更新每个对象。下面的示例图中左侧展示了当我们更新女主角时对象数组的变 化。 我们更新她时,i等于1,她斩杀了肮脏的野兽所以它从数组中被移除。而女武神移动到i为0的位置,而倒霉的农夫被前移到1 的位置。在女武神更新结束后,i增长到2。如上图右侧所示,倒霉的农夫在循环中被跳过并且永远也不会更新了。 注解 一个简便的解决方法是当你更新时从表的末尾开始遍历。此方法下移除对象,只会让已经更新的物品发生移动。 另一方法是在移除对象时多加小心,并在更新任何计数器时把被移除的对象也算在内。还有一个办法是将移除操作推迟到本 次循环遍历结束之后。将要被移除的对象标记为“死亡”,但并不从列表中移除它。在更新期间,确保跳过那些被标记死亡的 对象,接着等到遍历更新结束,再次遍历列表来移除这些“尸体”。 假如在更新循环中你加入了多线程,采用延迟修改的方法较好,因为这可以避免更新期间线程同步带来的巨大开销。 这一模式十分浅显,从例子里我们就能看出其要点。这并不意味着它没用,而正因为它的简单才使得它好用——它是一个简 明而不加任何修饰的解决方案。。但为了更具体地阐明此方法,我们还是来看一个基本的实现例子。让我们从这个代表着骷 髅和雕像的Entity类来开始吧: class Entity { public: Entity() : x_(0), y_(0) {} virtual ~Entity() {} virtual void update() = 0; double x() const { return x_; } double y() const { return y_; } void setX(double x) { x_ = x; } void setY(double y) { y_ = y; } private: double x_; double y_; }; 在这个类里我并没有加入太多东西,只有那些后面能用到的成员。实际的项目中还将包含有诸如图形和物理的部分。而上面 的类中最重要的部分就是这一设计模式所要求的update()抽象方法。 示例 游戏维护一系列这样的实体,在我们的例子中,我们将它们置入一个代表游戏世界的类中: class World { public: World() : numEntities_(0) {} void gameLoop(); private: Entity* entities_[MAX_ENTITIES]; int numEntities_; }; 注解 在一个实际的游戏项目中,你可能会用到一个实际的集合类,但在此我仅使用普通的数组来让事情简单些。 一切准备就绪,遍历实体逐帧更新的游戏实现如下: void World::gameLoop() { while (true) { // Handle user input... // Update each entity. for (int i = 0; i < numEntities_; i++) { entities_[i]->update(); } // Physics and rendering... } } 注解 见名知意,这就是游戏循环(Game Loop)模式的例子。 肯定有些读者现在很不舒服,因为我在这里对主要的Entity类采用了继承的方式来定义不同的行为。假如你碰巧遇到了问题, 我将会提供一些文章。 随着游戏产业从最初6502汇编语言和VBLANK(老式的阴极射线管)显示器的海洋到OOP(面向对象)上岸,开发者们陷入了一 场软件架构的狂热,其中之一就是对继承的使用。高耸而错综复杂的类继承大厦被建立起来,遮天盖地。 而事实证明继承真是个恐怖的想法,没人能够在不拆解的情况下维护一个庞大的继承关系,甚至连GoF(最杰出的的4个软件 设计师,合著有《设计模式:可复用面向对象软件的基础》一书)都在1994年发现了这一点,并写道: Favor ‘object composition’ over ‘class inheritance’.(组合对象,而不是类继承) 注解 在你我之间,我想子类继承的问题离我们甚远。我几乎避开了它,但执着于避免使用继承就和执着于使用它一样糟。 你完全可以适度使用它而不必完全禁用。 当游戏产业中的人们纷纷意识到类继承糟糕的一面时,Component设计模式应运而生。借此,update()方法能够置于实体的 组件之中而非依附Entity本身。这将帮助你避免为了定义和复用不同表现的实体类而构建出复杂的实体类继承关系。取而代之 的是用各种组件来组装这些子类。 子类化的实体?! 假如我在实际开发一款游戏,我也会这么做。但这一章并不讨论组件模式而是update()方法,我尽可能简洁并快速地表达出 它们,而将这个方法直接放在Entity类里并进行一两个子类的继承就是最快的方法。 注解 组件模式请看这里 回到正题,我们最初的动机是要定义一个骷髅守卫,和能放出电光石火的魔法雕像。从我们的骷髅朋友开始吧。为了定义其 巡逻行为,我们通过恰当地实现 update() 方法来创建新的实体类。 class Skeleton : public Entity { public: Skeleton() : patrollingLeft_(false) {} virtual void update() { if (patrollingLeft_) { setX(x() - 1); if (x() == 0) patrollingLeft_ = false; } else { setX(x() + 1); if (x() == 100) patrollingLeft_ = true; } } private: bool patrollingLeft_; }; 如你所见,我们所做仅仅是从游戏循环中复制代码并将它粘贴到Skeleton类的 update() 方法中。一个小差异在于这 里 patrollingLeft_ 被变成了一个类成员而非局部变量。借此便能保证,这一变量在 update() 方法调用期间有效。 我们对Statue类如法炮制: class Statue : public Entity { public: Statue(int delay) : frames_(0), delay_(delay) {} virtual void update() { if (++frames_ == delay_) { shootLightning(); // Reset the timer. frames_ = 0; } } private: int frames_; int delay_; void shootLightning() { // Shoot the lightning... } }; Defining entities定义实体 再一次,最大的改动就是从游戏循环中移出一些代码以及对变量重命名。这样一来,我们让代码更加简洁——在原来不可避 免杂乱的代码中,本会出现许多存储不同雕像帧计数器和开火频率的局部变量。 既然这些都已经被移动到Statue类之中,你可以随心所欲地创建Statue的实例,而它们各有自己的计时器。这正是本设计方 法背后的本意——现在向游戏世界中添加实体更加容易了,因为每个实体都携带着所有自己所必须的东西,自给自足。 这一模式让我们避免了在扩展游戏时采用继承。这一模式反倒让我们可以单独地使用数据文件或者关卡编辑器来扩展游戏世 界。 注解 还有人关心UML图么?如果还有那上图就对应着我们所创建的类结构的UML图。 这是游戏核心的设计模式,但我只是做了其最常用部分的提炼。至此,我们都假设每次对update()的调用都让整个游戏世界 向前推进相同(固定)的时间长度。 我更喜欢这种方式,但多数游戏使用变时长的方式。那样做,每次游戏循环可能会占用更多或更少的时间,具体取决于其处 理更新和渲染前一帧所的开销。 注解 Game Loop 这一章详述了定时和变时步长的优劣。 这意味着每次 update() 的调用需要知道虚拟时钟所流逝的时间,于是你常会看到流逝的时间会被作为参数传入。例如,我们 可以像下面那样让骷髅卫兵处理一个变时步长更新: void Skeleton::update(double elapsed) { if (patrollingLeft_) 逝去的时间 { x -= elapsed; if (x <= 0) { patrollingLeft_ = false; x = -x; } } else { x += elapsed; if (x >= 100) { patrollingLeft_ = true; x = 100 - (x - 100); } } } 现在,骷髅移动的距离随着时间间隔而增长。你同样能看到处理变时步长时额外增加的复杂度。骷髅可能在很长的时间差下 超出其巡逻范围,我们需要小心地对这一情况进行处理。 这样一个简单的设计模式,并无太多可选项。但它也仍有选择的余地~ 你显然必须决定好该把update()放哪。 1.实体类(父类)中 假如你已经创建了实体类,那么这是最简单的选项。因为这不会往游戏中增加额外的类。假如你不需要很多种类的实体,那 么这种方法可行,但实际项目很少这么做。 每当希望实体有新的表现时就创建子类,这会积累大量的类而导致项目难以维护。你最终会发现你希望通过一种单一继承层 次的优雅映射方式来复用代码模块,那时候你就该傻眼了。 2.组件类中 假如你已经使用了Component模式,那么傻瓜也知道怎么做了——让每个组件更新其自身。和更新方法模式一样地将每个游 戏实体从游戏世界中分离出来,组件方法也让各个组件部分分离开来。渲染,物理,AI可以各自顾好自己。 3.代理类中 将一个类的表现代理给另一个类这涉及到其他几种设计模式。应用State 设计模式可以让你通过改变一个对象的代理来改变 其行为。Type Object设计模式可以让你在多个同类型的实体之间共享行为表现。 假如你使用上述任一种设计模式,那么自然而然地需要将 update() 方法置于代理类中。这么一来,你可能在主(父)类中仍保 留 update() 方法,但它成为非虚的方法并将指向代理类对象的update方法,如: void Entity::update() { // Forward to state object. state_->update(); } 这么做让你能在代理类之外定义新的行为方式。正像使用Component模式那样,这为你开辟了灵活定义新类和新的行为方式 的途径。 设计决策 update方法依存于何类中? 那些未被利用的对象该如何处理? 你常需要在游戏中维护这样一些对象:不论出于何种原因,它们暂时无需被更新。它们可能被禁用,被移除出屏幕,或者至 今尚未解锁。假如大量的对象处于这种状态,这可能会导致CPU每一帧都浪费许多时间来遍历这些对象同时又对他们毫无作 为。 一种方法是单独维护一个需要被更新的“存活”对象表。当一个对象被禁用时,将它从其中移除。当它重新被启用时,把它加 回表中。这样做,你只需遍历那些实际上有作为的对象。 假如你使用单个集合来存储所有游戏对象: 你在浪费时间。对于暂时无用的对象,你需要检查它们”是否死亡”的标志,或者调用一个空方法。 注解 除了浪费CPU循环来检查对象是否被激活并跳过它的问题,空指针问题还可能搞坏你的缓存区。CPU通过加载读取 RAM中的数据到更快的单片缓存来加载内存,这是基于投机地假设在一段时间内你读取的内存是连续的情况下进行 的。当你跳过一个对象时,你可能会跳过缓存区(存储当前这个对象)的末尾,而让CPU再去另一块内存寻址。 假如你对活跃对象单独用一个集合来维护: 你将使用额外的内存来维护这第二个集合。因为往往你需要一个主集合来维护所有的对象,以便在需要所有对象能 够访问它们全部。这么说来,这额外的集合在技术上是多余的。当游戏对速度的要求比对内存的要求高时(往往是这 样的),这样的取舍还是值得的。 另一种缓和此问题的办法是,同样维护两个集合,但另一个只维护那些未被激活的对象,而不是维护所有对象。 你必须保持两个集合同步。当对象被创建或者销毁(并非临时禁用而是永久销毁)时,你必须记住同时修改主集合和活 跃对象集合。 这里该使用什么方法,取决于你对非激活对象数目的预估。其数目越多,就越需要创建一个独立的集合来在存储他们以便在 游戏循环时避免处理这些非激活对象。 这一模式与Game Loop和Component模式共同构成了多数游戏引擎的核心部分。 当你开始考虑实体集合或循环中组件在更新时的缓存效能,并希望它们更快地运转,Data Locality模式将会有所帮助。 Unity的引擎框架在许多类模块中使用了本模式,包括MonoBehaviour类。 微软的XNA平台在Game和GameComponent类中均使用了这一模式。 Quintus的基于JavaScript的游戏引擎在其主Sprite类中使用了这一模式。 参考 搭好了游戏框架,并加入各种角色和物件之后,剩下的事就是让场景活动起来。为此你需要定义行为——让游戏中的每个实 体知道该做些什么。 当然了,任何代码都是行为,所有的软件都在定义行为,但游戏中的行为如呼吸一样必要。一个文本编辑软件有各种各样的 功能,但比起一般RPG游戏中的各种人物、物品和任务来说,就显得无趣多了。 本章中的模式,可以帮助你快速提炼并定义大量可维护的行为。类型对象无需定义实际的类,就可以创建各种类型的行为。 子类沙箱提供了一批安全的原子方法去定义各种行为。最高级的选择是字节码,它可以将行为从代码完全转移到数据中去。 字节码 子类沙箱 类型对象 =============================== 上一节 目录 下一节 行为模式 模式 通过将行为编码成虚拟机指令,而使其具备数据的灵活性。 我曾参与一款有600万行C++代码的游戏。比较起来火星探测车“好奇号”的控制软件的代码量还不及它的一半。 制作游戏很有趣,但也不容易。现在的游戏需要庞大复杂的代码库。主机厂商和应用商店有严格的质量要求,一个造成崩溃 的Bug就可能导致你的游戏无法发布。 同时,我们希望将平台的性能发挥到极致。游戏的发展推动着硬件发展,我们当然要不遗余力地进行优化来赶上发展的脚步。 为了提高高稳定性和效率,我们会选择像C++这样的重量级语言。它们兼具充分利用硬件的能力以及可以阻止或拦截Bug的 强类型系统。 我们可以为此感到骄傲,但它也有代价。多年的专业训练才能造就一个精通的程序员,随后你又必须面对庞大的代码库。大 型游戏的编译时间可以短到“喝杯咖啡”,也可以长到把“自己煮咖啡豆、磨咖啡豆、倒咖啡、打奶泡、练练拿铁的拉花”都给搞 定。 除了这些挑战外,游戏还有个额外的苛求:有趣。玩家需要的是既新奇又具平衡性的体验。这就需要持续迭代。如果每个小 修小改都都得一个工程师去动底层代码,然后等待漫长的重编译,那实际上你已经毁了整个创作流程。 比如说,我们在开发一款基于魔法的战斗游戏。两个对峙的法师不断向对方释放法术直到分出胜负。我们可以在代码中定义 法术,但这意味着每次修改都需要工程师介入。当一个设计师想要改些数值并测试效果,就需要重新编译整个游戏,重启然 后重新进入战斗。 在游戏发布之后,我们得像其他游戏一样去更新它,包括修正Bug以及添加内容等。如果所有的法术都被硬编码,一次更新 就等价于发一次可执行文件的补丁。 进一步,设想提供一个MOD系统以供用户自己创建法术。如果它们都在代码里面,那这些用户都需要有完整的编译工具链去 构建游戏,我们得公开所有源码。况且,如果他们的法术有Bug,其他玩家可能受到殃及而造成游戏崩溃。 很明显,我们引擎所使用的编程语言不适合解决这个问题。我们需要把法术从游戏核心移动到安全沙箱中。我们要给让它们 易于修改,易于重新加载并且在物理上与游戏的可执行文件相分离。 这种形式在我看来更像是种数据,你或许也会这么想。我们可以在单独的数据文件中定义行为,游戏引擎可以某种方式加载 并“执行”它们,那么问题就解决了。 我们只需要弄明白对于数据,何谓“执行”。怎样才能让文件中的字节表示行为呢?有好几种方法。参照一下解释器模式,你 就能对此模式的优缺点全貌有个大致了解。 本来这个模式我可以写成一整章的,但是Gof早已替我写了。所以这里我仅做简述。我们从一个语言开始——比如某种编程 语言——你要执行它。例如它支持下面的数学表达式: 字节码 目的 动机 魔法大战! 先数据后编码 解释器模式 (1 + 2) * (3 - 4) 然后,你拿出表达式中的每个片段、语言语法中的每个规则,将它们变成对象。数字的对象就是它们的字面值。 简单来说,它们是在原始数值的基础上,做了个小封装。运算符也是对象,它们拥有对操作数的引用。如果你使用括号来控 制优先级的话,这个表达式又变成了一棵小对象树: 这个“变化”究竟是什么?很简单——解析。解析器接受输入文本字符串,然后将它变成抽象的语法树,即一组用于表示 文本语法结构的对象。 把上述内容堆积起来,你就完成了编译器一半的工作。 解释器模式与创建语法树无关,它只关心如何执行它。它的处理很聪明,树中的每个对象都是表达式或子表达式。在面向对 象风格中,表达式会计算它们自己的值。 首先,定义一个所有表达式都要实现的基础接口。 class Expression { public: virtual ~Expression() {} virtual double evaluate() = 0; }; 然后为每一个语法定义实现这个接口的类。其中,最简单的是数字: class NumberExpression : public Expression { public: NumberExpression(double value) : value_(value) {} virtual double evaluate() { return value_; } private: double value_; }; 一个字面数字表达式的值就等同于它的数值。加法和乘法要稍微复杂一些,因为他们包含子表达式。它们需要先递归计算出 所有子表达式的值,之后才能计算出它们自己的值。像这样: class AdditionExpression : public Expression { public: AdditionExpression(Expression* left, Expression* right) : left_(left), right_(right) {} virtual double evaluate() { // Evaluate the operands. double left = left_->evaluate(); double right = right_->evaluate(); // Add them. return left + right; } private: Expression* left_; Expression* right_; }; 显然,只要几个简单的类,就能够表达任何复杂的算术表达式了。我们只用创建几个对象,并将它们正确得关联起来。 Ruby在大概15年前就是这么实现的。到了1.9版本,它们改成了本章所说的字节码。看我替你省了多少时间! 这个模式虽然简单漂亮,但是也有些问题。回头看看上面的插图,你看到了些什么?很多小盒子,以及它们之间的箭头。代 码用一个微小对象构成的蔓生分形树来表达,会有一些副作用: 从磁盘加载需要实例化并串联成堆的小对象。 如果你想自己算算的话,别忘了算上虚函数表。 些对象和它们之间的指针占用大量内存。在32位机上,即使不考虑内存对齐,这个小小的表达式也要占用68字节(4字节/ 指针*17个指针)。 要了解更多关于缓存以及它如何影响性能的原理,看看数据局部性这一章。 从每个指针遍历出表达式都会废了你的数据缓存,而虚函数调用也会对指令缓存造成很大压力。 一个字概括,慢!大量的编程语言不采用解释器模式,就是因为它又慢又占内存。 回到我们的游戏。当它运行时,计算机并不会去遍历C++语法结构树,而是执行我们在编译期编译成的机器码。那么为什么 要采用机器码呢? 高密度。它是坚实持续的二进制数据块,不浪费任何一个字节。 线性。指令被打包在一起顺序执行。不会在内存中跳跃访问(当然了,除非你确实在做流程控制) 虚拟机器码 底层。每个单独的指令仅仅完成一小个动作,各种有趣行为都是这些小动作的组合。 高速。以上几点让机器码疾行如风(当然还得算上机器码由硬件实现这一点了)。 听上去激动人心,但我们不想直接用机器码来编写法术。为用户提供游戏执行的机器码,简直是自找麻烦,这会带来很多安 全问题。我们只能在机器码的效率和解释器模式的安全性之间取一个折中。 这就是为什么很多主机和iOS系统禁止程序在运行时生成或载入机器码的原因。这反倒是个拖累,因为最快的编程语言 就是基于这个原理实现的。它们包含一个准时(“just-in-time”)编译器,或者叫JIT。它能飞快地把语言翻译成优化的机器 码。 我们不要去加载执行真正的机器码,而去定义自己的虚拟机器码,会怎样呢?我们在游戏中实现一个执行它们的模拟器。这 些虚拟机器码与机器码相似——高密度、线性、相对底层——同时它完全接受游戏安全的管理。 在编程语言的语境下,“虚拟机”和“解释器”是同义词,我正是交替地使用它们。如果要说Gof的解释器模式的话,我会 强调“模式”这个词,以避免混淆。 我们将这个小模拟器称为虚拟机(简称VM),这个虚拟机所执行的语义上的“二进制机器码”称为字节码。它具备从数据定义 事物的灵活性和易用性,它也比解释器模式这种高级呈现方式更高效。 听上去挺吓人的。我在本章里剩下的目标,就是要给你展示一下,如果你控制好自己的功能清单,这个方案非常可行。即使 最终你自己也没把这个模式用起来,至少你能对Lua以及别的采用这个原理的语言有更好的了解。 指令集定义可以执行的底层操作。一系列指令被编码为字节序列。虚拟机逐条执行指令栈上的指令。通过组合指令,即可完 成很多高级行为。 这是本书中最复杂的模式,它可是不是轻易就能放进你的游戏里。仅当你的游戏中需要定义大量行为,并实现游戏的语言没 法处理好下列事情时可以使用: 编程语言太底层了,编写起来繁琐易错 因编译时间太长或工具问题,导致迭代缓慢 它的安全性太依赖编码者。你想确保定义的行为不会让程序崩溃,就得把它们放进安全沙箱里。 当然,这个列表符合大 多数游戏的情况。谁不想提高迭代速度,让程序更安全?但那是有代价的。字节码比本地码要慢,所以它并不适合用作 对性能要求极高的核心部分。 建立你自己的语言或内嵌系统是一件很有吸引力的事。这里我只做个最小化示例,但在实际项目中,麻烦可多多了。 这也正是游戏开发吸引我的地方,二者不论是哪个,我都在努力创建虚拟世界,让别人进来玩或做创意。 每当我看到有人创造出一种小语言或脚本,他们会说“别担心,它会很小巧”。没法控制的是,他们会不断往里面添加小功 能,直到它变成一个成熟的语言。但不像其他语言,它的发展是一些临时功能的有机组合,就像个精致的棚屋小镇。 任何一种的模板语言都是这样 当然,做个成熟的语言没什么错。只要保证你目标明确。否则,就控制好你的字节码要表达的东西,在它超出你控制之前设 定好范围。 低级的字节码对性能提升很大,但你没法让你的用户直接编写它们。我们将行为从代码中移出来的一个原因是想在更高一级 (解释器)模式 使用情境 注意 你需要个前端 的层面表述它。C++已经很底层了,如果让你的用户用更高效的汇编语言编写——这根本不是种进步! 一个反例是有名的RoboWar。在这个游戏里,玩家使用一种类似汇编的语言编写小程序,来控制机器人。我们这里也 会讨论指令集这种方式。 这是我的第一篇汇编类语言的指南。 就像Gof的解释器模式一样,它假定你能够以某种方式生成字节码。通常,用户会在更高级的层次上编辑,一个工具负责将 它转换成虚拟机能够理解的字节码。这个工具的名字,就是编译器。 我知道,听上去很可怕对不对。所以这里我先提出来了。如果你没有足够的资源去完成一个编辑工具,那么字节码不适合 你。但你先别急继续往下看,也许没你想象中那么坏。 编程很难。我们知道自己想让机器做什么,但是我们很难用正确的方式与之沟通——我们会写出bug。为此,我们搜集了一 大堆工具来找出代码错在哪里,如何去改正。我们有调试器、静态分析器、反编译工具等等。所有这些工具都是为某种已经 存在的语言而设计的:机器码或者是高级语言。 当你定义自己的字节码虚拟机时,你就没法用这些工具了。当然了,你可以用调试器步进到虚拟机的代码里,但那只能告诉 你虚拟机在做什么,与它正在解释的字节码没什么关系。它也没法替你把字节码映射回对应的原始高级语言。 如果你定义的行为很简单,你可以勉强回避掉做各种辅助调试工具的事儿。但是随着内容规模增长,你得规划好如何让用户 能实时看到他们的字节码有什么效果。这些功能可能不会随游戏发布,但是它们能确保你的游戏可以发布。 当然,如果你想让游戏支持MOD,你就得发布这些功能,这相当重要。 在上面几节讨结束之后,你可能会很好奇如何直接实现它。首先,要为虚拟机设计一个指令集。在真正考虑字节码之类的东 西前,可以先把它们当成是API。 假设我们要直接用C++代码去实现各种法术,我们需要让代码调用哪些API呢?为了定义法术,引擎中要定义哪些基础操作 呢? 绝大多数法术会改变巫师的一个状态,我们就从这里开始: void setHealth(int wizard, int amount); void setWisdom(int wizard, int amount); void setAgility(int wizard, int amount); 第一个参数定义受到影响的巫师,比如说用0代表玩家,用1代表对手。这样以来,治疗法术就能够施加到玩家自己的巫师身 上,同时也可以伤害到对手。毋庸置疑,这三个小函数会神奇得支持非常广泛的法术效果。 然而如果法术只是静默得改变状态,游戏逻辑上不会有问题,但是玩这样的游戏会让玩家无聊到哭的。我们来做些调整: void playSound(int soundId); void spawnParticles(int particleType); 这些不会影响到玩法,但是会增加游戏的深度。我们还会添加摄像机抖动、动画等等。但是这些就足够我们开始了。 现在让我们看看如何将这些程序API转换成数据可控的形式。让我们由简入繁来完成整件事。首先拿掉这些函数中所有的参 数。假设所有的set _()函数都会影响玩家控制的法师并强化其对应属性。类似的,FX操作们会播放一个硬编码的音效或者粒 你会想念调试器的 示例 法术API 法术指令集 子特效。 在这个前提之下,法术就是一系列的指令。每个指令定义一个你想要执行的操作。我们可以枚举他们: enum Instruction { INST_SET_HEALTH = 0x00, INST_SET_WISDOM = 0x01, INST_SET_AGILITY = 0x02, INST_PLAY_SOUND = 0x03, INST_SPAWN_PARTICLES = 0x04 }; 为了将法术编码成数据,我们存储一系列枚举值在数组中。我们的仅有几种基本操作,所以枚举值长度取一个字节足矣,这 意味着法术的代码都是一个字节列表——所谓的字节码。 一些字节码虚拟机使用多个字节去存储单个指令,这需要有更加复杂的解码规则。实际上常见芯片上的机器码,比如x86, 就更加复杂了。 但是单字节对于Java Virtual Machine 以及 Microsoft .NET 平台的基石Common Language Runtime来说已经很够用了,所以 对我们来说已经可以了。 执行一条指令时,我们首先找到对应的基础方法,然后调用正确的API: class VM { public: void interpret(char bytecode[], int size) { for (int i = 0; i < size; i++) { char instruction = bytecode[i]; switch (instruction) { // Cases for each instruction... } } } }; 把这段代码写进去,你就完成了你的第一个虚拟机。可惜它还不够灵活。我们没办法去定义一个能够伤害到对手或者削弱某 个属性的法术。我们只能播放段声音而已。 为了多一点真正语言的感觉,我们这里需要加入参数。 要执行一个复杂的嵌套表达式,你从最内层的子表达式开始。计算完的内层表达式的结果,就将结果作为包含它们的外层表 达式的参数传给外层表达式进行计算,以此类推直到整个表达式计算完毕。 解释器模式将这一过程显式建模成一棵嵌套对象树,但我们想要获得像指令列表一样的高速度。同时要保证自表达式的结果 能够正确的传入外层表达式。但由于我们的数据是被展平的,我们得通过指令的顺序去控制。我们会采用与你的CPU相同的 方式——一个堆栈。 理所当然,这个架构就称为栈机。例如Forth、PostScript和Factor这类编程语言将这个模型直接暴露给了用户。 class VM { public: VM() : stackSize_(0) {} // Other stuff... private: static const int MAX_STACK = 128; int stackSize_; int stack_[MAX_STACK]; }; 这个虚拟机内部包含了一个值堆栈。在我们的例子中,与指令相关的唯一数据类型是数字,所以我们可以使用一个int型数 栈机 组。当一段数据要求指令一个一个执行下去时,实际上就是在遍历堆栈。 字面意思,数值可以被压入或者弹出这个堆栈。因此,让我们添加些方法来实现这个功能: class VM { private: void push(int value) { // Check for stack overflow. assert(stackSize_ < MAX_STACK); stack_[stackSize_++] = value; } int pop() { // Make sure the stack isn't empty. assert(stackSize_ > 0); return stack_[--stackSize_]; } // Other stuff... }; 当哪一个指令需要输入参数时,它会按照下面的方式从堆栈中弹出来: switch (instruction) { case INST_SET_HEALTH: { int amount = pop(); int wizard = pop(); setHealth(wizard, amount); break; } case INST_SET_WISDOM: case INST_SET_AGILITY: // Same as above... case INST_PLAY_SOUND: playSound(pop()); break; case INST_SPAWN_PARTICLES: spawnParticles(pop()); break; } 为了向堆栈中添加一些数值,我们需要一个新的指令:字面值。它表示一个字面上的整数数值。但是它又从哪里获得这个值 呢?这里究竟该如何避免无限循环呢? 这个小技巧就是利用指令流是字节序列的特性--我们可以将数字直接塞进字节数组。我们用如下方式定义一个字面数字的 指令类型: case INST_LITERAL: { // Read the next byte from the bytecode. int value = bytecode[++i]; push(value); break; } 这里,为了避开处理多字节整型的情况,我仅读取单字节整数,但是在实际实现中,你肯定想要支持所有你所需范围 的整数参数。 它读取了字节码流中的下一个字节,将它当作一个数字写入堆栈。 为了能够对堆栈的工作方式有个直观感受,我们把几条指令串起来,看看它们如何被解释器执行。从一个空栈开始,解释器 指向第一个指令: 首先,它执行第一个 INST_LITERAL。他会读取从bytecode(0)开始的下一个字节,并将它压入堆栈。 然后,它执行第二个 INST_LITERAL。它读取数字10,并将其压入堆栈。 最后,它执行 INST_SET_HEALTH。它会出栈10并将其存储到变量 amount 中,然后出栈0将其存储到 wizard 中。之后,使 用这两个参数调用setHealth()。 嗒哒!我们完成了一个将玩家巫师的生命值设定为10点的法术。现在,我们就拥有了足够的灵活性,来把任何巫师的状态设 定到任何想要的值。我们也可以播放不同的音效以及发粒子。 但是,这感觉更像是数据结构。我们没法做到诸如将巫师的生命提高其法力值的一半。我们的设计师想要制定法术的计算规 则,而不仅仅是数值。 如果将我们的虚拟机看做是一种编程语言,它所支持的仅仅是些内置函数,以及它们的常量参数。为了让字节码感觉更像是 行为,我们得加上组合。 我们的设计师想要创建一些表达式,能够将不同的值通过有趣的方式组合起来。举个简单的例子,他们想让一个法术对某种 属性造成一个相对量的变化,而不是改变到一个绝对的量。 那就需要考虑状态的当前值。我们已经有了写入状态的指令,但还得加上些读取它们的指令: 组合就能得到行为 case INST_GET_HEALTH: { int wizard = pop(); push(getHealth(wizard)); break; } case INST_GET_WISDOM: case INST_GET_AGILITY: // You get the idea... 显然,它对堆栈做了双向操作。它首先出栈一个参数,来确定要获取哪个巫师的状态,然后找到这个状态值并入栈。 这使得我们能够编写任意拷贝状态值的法术。我们能够创造一个将巫师的敏捷设定为其智力甚至是复制对手生命值的古怪巫 术。 好了一点,但是仍然很有限。接下来,我们需要算术。是时候让我们牙牙学语的虚拟机学1+1了。我们得添加些新的指令。 到现在为止,你应该已经发现它的规律并能够猜到它会是怎样的了。下面是加法: case INST_ADD: { int b = pop(); int a = pop(); push(a + b); break; } 和其他的指令一样,它出栈了一些数值,做了一些处理,然后将结果入栈。到现在为止,每个指令都提高了一点儿我们对表 达式的支持,但这是个很大的跨越。它看起来不起眼,但我们能够处理各种复杂的,深层嵌套算术表达式。 让我们看看一个稍微复杂点的例子。比如说,要制作一个法术,能够将玩家法师的生命设定成他们敏捷和智力的平均值。在 代码里面,是这样的: simplicitlyetHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2); 你可能会认为我们需要指令来控制这个表达式里面由于括号造成的显式分组。但堆栈已经隐式支持它了。下面是手工求值的 方法: 获取并保存法师当前的生命值。获取并保存法师的当前敏捷度。对智慧做同样的操作。获取后两个值,将他们相加并保留结 果。除以2后保留结果。取回巫师的生命并加到结果里面去。获取结果,并赋值到巫师的生命属性。 你看到那些”保留“和”取回“了吗?每个“保留”对应于一个push,每个“取回”对应于一个pop。这意味着我们可以轻易将其转换为 字节码。例如,第一行获取巫师的当前生命值: LITERAL 0 GET_HEALTH 这段字节码将巫师的生命值入栈。如果我们重复这样的操作,最终会得到一段能计算出原表达式的字节码。为了让你体会指 令是怎样组合的,我已经帮你做好了。 为了演示堆栈如何随时间变化,权且将巫师的初始状态设置为45点生命、7点敏捷和11点智力。跟在每个指令后面的是执行 后的堆栈状态,以及这个指令作用的注释: LITERAL 0 [0] # Wi zard i ndex LITERAL 0 [0, 0] # Wi zard i ndex GET_HEALTH [0, 45] # getHealth() LITERAL 0 [0, 45, 0] # Wi zard i ndex GET_AGILITY [0, 45, 7] # getAgi li ty() LITERAL 0 [0, 45, 7, 0] # Wi zard i ndex GET_WISDOM [0, 45, 7, 11] # getWi sdom() ADD [0, 45, 18] # Add agi li ty and wi sdom LITERAL 2 [0, 45, 18, 2] # Di vi sor DIVIDE [0, 45, 9] # Average agi li ty and wi sdom ADD [0, 54] # Add average to current health SET_HEALTH [] # Set health to result 如果你一步一步看完这个堆栈,你就会发现数据像魔法一样在它内部流动。我们在一开始入栈巫师的索引0,然后做了很多不 同的操作,直到最后栈低设置巫师生命值时用到它。 也许我这里对“魔法”的定义有点宽。 我可以继续前进,添加更多各种各样的指令,但这是个停下来的好地方。像它现在这样,我们有了一个不错的小虚拟机,好 让我们能使用简单又可压缩的数据根式来定义相对可扩展的指令。虽然“字节码”和“虚拟机”听起来有点吓人,但你会发现它们 往往简单到一个堆栈、一个循环或是一个switch语句。 还记得我们最初目标是让字节码得到很好的沙箱化?现在你看过虚拟机的整个实现过程,很明显我们已经做到了。字节码没 法深入引擎的各个部分做有恶意的事情,因为我们只定义了少量访问引擎局部的指令。 我们通过控制堆栈尺寸来限制它的可用内存,我们要当心以免内存溢出。我们甚至可以限制它的执行时间。在指令循环中, 我们可以记录已经执行了多久,在它超出某个时间限制时,将它取消掉。 限制执行时间在我们的例子中并非必要,因为我们没有任何循环指令。我们可以通过限制字节码的总尺寸来限制执行 时间。这也意味着字节码并非图灵完备。 只剩下一个问题了:真正去创建字节码。眼下我们将一段伪代码编译成了字节码。除非你真的很闲,否则这在实践中根本行 不通。 我们的一个最初目标是在较高的层次上编写行为,但是我们已经做了些比C++还底层的东西。它能兼顾我们需要的运行时性 能和安全性,但是彻底缺乏对设计师友好的可用性。 为了填补这个缺陷,我们需要些工具。我们需要一个程序,让用户在高层次上定义法术的行为,并能够生成对应的低层次字 节码。 这听起来比创建一个虚拟机还难。很多程序员在大学的时候被扔到一个编译器课程中,其中所得除了看到封面上有条龙或 者"lex"和"yacc"这些词时就犯的创伤后应激障碍之外,什么也没有。 我所说的,当然是这篇经典的编译器:原则、技术和工具 其实,编译一个基于文本的语言并非不能,只是这里篇幅有限。但是,你没必要这么做。我说你需要一个工具——并不一定 得是个能编译输入文本的编译器。 恰恰相反,我希望你考虑做一个图形界面来让用户定义行为,他别是对于那些不太擅长技术的人。对于一个没有多年面对编 译器各种报错的经验的人来说,书写语法正确的文本太难了。 反之,你可以创建一个应用,让用户通过点击和拖拽一些小方块、点选菜单或者其他任何对创建行为有意义的事情。 一个虚拟机 语法转换工具 我为Henry Hatsworth in the Puzzling Adventure编写的脚本系统的原理就是这样的。 这么做的好处是你的UI让用户几乎难以创建“非法的”程序。 你可以前瞻性地禁用按钮或者提供默认值来保证他们创建的东西 在任何时候都是合法的,而不是丢出一大堆错误信息。 我要强调下错误处理的重要性。作为程序员,我们倾向于把人为错误看做是耻辱的人性缺陷而竭尽全力避免发生在自 己身上。 为了做出一个用户喜欢的系统,你得拥抱他们的人性,这就包括了不可靠性。人们总是犯错,它是创造活动的组成部 分。通过一些撤销之类的功能来优雅地处理掉这些问题能让你的用户更有创造力并更好地完成任务。 这让你免于为一个小语言设计语法并编写语法分析器。但我也清楚,有些人对UI编程同样很不习惯。那么,这里我也没别的 办法。 最终,这个模式还是关于如何用一种用户友好并能在高层次编辑的条件下表达行为的。你得去创建用户表达式。为了获得高 执行效率,你得把它翻译成低级形式。这就是真正的工作,但如果你接受这个挑战,它会给你回报的。 我试图让这一章尽可能简单,但是我们真的是在创建一种语言。它是一个很开放的设计空间。在其中尝试非常有趣,所以, 别忘了完成你的游戏。 设计决定 因为这是本书中最长的一章,这个任务我失败了。 字节码虚拟机有两种大风格:基于栈和基于寄存器。在基于栈的虚拟机中,指令总是操作栈顶,正如我们的示例代码一样。 例如,INST_ADD 出栈两个值,将它们相加,然后将结果入栈。 基于寄存器的虚拟机也有一个堆栈。唯一的区别是指令可以从栈的更深层次中读取输入。不像INST_ADD那样总是出栈操作 数,它在字节码中存储两个索引来表示应该从堆栈的哪个位置读取操作数。 基于栈的虚拟机: 指令很小。因为每个指令都从隐式从栈顶寻找它的参数,你无需对任何数据做编码。这意味着每个指令都非常小, 通常只用一个字节。 代码生成更简单。当你要编写一个编译器或生成字节码输出的工具时,你会发现机遇栈的虚拟机更简单。每个指令 都隐式操作栈顶,你只需要以正确的顺序输出指令,来实现参数传递。 指令数更多。每个指令都只操作栈顶。这意味着生成类似a = b + c这样的代码。你就得用分离指令把b和c分别放到 栈顶,执行操作,然后将结果存入a。 基于寄存器的虚拟机: 指令更大。因为它需要记录参数在栈中的偏移量,单个指令需要更多的位数。例如,众所周知的寄存器虚拟机Lua 中,每个指令占用32位。6位存储指令类型,剩下的存储参数。 指令更少。因为每个指令都能做更多的事情,相应的就没那么多。因为你无需把堆栈中的值拖来拖去,也可以说你 获得了性能提升。 Lua的开发者并未明确Lua的字节码格式,它每个版本都在变化。我这里讲的是Lua5.1。想要看一篇精彩的Lua内部剖 析,读读这个 那么你应该怎么选呢?我的建议是实现基于栈的虚拟机。它们更容易实现,生成代码也更加简单。寄存器虚拟机因Lua转换 为它的格式之后执行效率更高而受到称赞,但这实际上依赖于你的虚拟机的实际指令集设计和其他很多东西。 你的指令集划定能用字节码表达与不能用字节码表达的边界,它也对虚拟机的性能有影响。 外部基本操作. 它们是虚拟机之外、引擎内部的,做一些玩家能看到的事情的东西。它们决定字节码能够表达的真正行 为。如果没有它们,你的虚拟机除了在循环中烧CPU之外,没有任何用处。 内部基本操作. 它们操作虚拟机内部的值——例如字面值、算术运算符、比较运算符和操作栈的指令。 流程控制. 我们的例子中没有这部分,但如果你想要让指令有选择地执行或是循环重复执行,那你就需要流程控制。在字 节码这样的底层语言中,它们及其简单——跳转。 在我们的指令循环中,我们有一个索引指向字节码堆栈的当前位置。 换句话说,它是个goto。你可以用它来实现任何高级流程。 抽象. 如果你的用户开始定义很多数据,最终他们会希望能重用字节码而不是反复复制粘贴。你也许会用到可调用过程。 最简情况下,过程仅仅是一个跳转。唯一的不同是虚拟机维护另一个返回堆栈。当它执行到一个“call”指令时,它将当前 指令压入返回栈中然后跳转到被调用的字节码。当它遇到一个“return”时,虚拟机从返回栈中弹出索引并跳转回索引的位 置。 我们的实例虚拟机只助理一种值,整数。这让答案变得很简单——这个堆栈仅仅是个存放int的栈。一个功能完善的虚拟机应 当支持不同的数据类型:字符串、对象、列表等等。你必须决定如何在内部存储它们。 单一数据类型: 它很简单。你不用担心标签、转换或者类型检查。 你无法使用不同的数据类型。这个缺陷太明显了。将不同的类型填入到一种单一的呈现方式中——例如将数字存储 成字符串——这是自找麻烦。 标签的一个变体: 这是动态类型语言通用的形式。每个值都由两部分组成。第一部分是一个标签——一个枚举——用来 标志所存储数据的类型。剩下的位根据这个类型来解析,例如: 指令如何访问堆栈? 应该有哪些指令? 值应当如何表示? enum ValueType { TYPE_INT, TYPE_DOUBLE, TYPE_STRING }; struct Value { ValueType type; uni on { int intValue; double double Value; char* string Value; }; }; 值知道他们的类型。这种呈现方式的好处是,能够在运行时对值的类型做检查。这对动态调用很重要并能够保证你 不会把操作执行到不支持它们的类型上。 占用内存更多。每一个值必须携带标志它们类型的额外位。在虚拟机这样的底层中,这几个位的占用增长得很快。 不代标签的联合体: 与前一种方式类似,但使用联合体,它不会在每个值上携带一个累型标签。你有一个小数据块去表 示多种类型,你需要自行确保值能得到正确解析,你不需要在运行时检查类型。 这也是无类型语言比如汇编和Forth的储值方式。这些语言让用户自己保证解析值的方式是正确的。玻璃心伤不 起! 紧密。没有比只存储值本身更加高效的储值方式了。 快速。没有类型标签意味着你也无需再运行时检查它们。这也是静态类型语言比动态类型语言快的原因。 不安全。当然,这是真正的代价。一段错误的字节码,让你把一个数字当做指针或者反过来,都会违反游戏的安全 性而造成崩溃。 如果你的字节码是从静态类型语言编译而来的,你可能会因编辑器不会生成不安全字节码而认为它是安全 的。这也许是正确的,但是不要忘了用户可能绕过你的编译器去手工编写一些恶意的字节码。 这就是Java虚 拟机等要在加载程序时执行字节码检查的原因。 一个接口: 确定值是一些类型中的一种的面向对象的解决方案是多态。一个接口提供进行各种类型测试和转换的虚方 法,像下面这样: class Value { public: virtual ~Value() {} virtual ValueType type() = 0; virtual int asInt() { // Can only call this on ints. assert(false); return 0; } // Other conversi on methods... }; 你可能像下面这样定义数据的类: class IntValue : public Value { public: IntValue(int value):value_(value) {} virtual ValueType type() { return TYPE_INT; } virtual int asInt() { return value_; } private: int value_; }; 开放式。你可以再核心虚拟机之外定义任何实现基础接口的数据类型。 面向对象。如果你采用面向对象的准则,正确的做法是对类型采取多态性调度,而不是对类型标签是用switch。 累赘。你得为每一个数据类型定义一个类,并在里面填写一些重复性的内容。在前一个例子中,我们定义了所有的 类型,这个例子里才只定义了一个! 低效。为了实现多态,你得借助于指针。这意味着像布尔和数字这种微小的值也要被封装到对象中,并在堆上面分 配。每次访问一个值,你都是在做虚函数调用。 在虚拟机核心中,这样影响效率的点会不断累加。事实上正因为这 些问题,导致我们避免解释器模式,唯一的区别是解释器处理的是代码而我们处理的是值。 我的建议是,如果你能坚持使用单一数据类型,那就这么做。否则,使用标签联合体。这是几乎所有编程语言的解析的方 式。 我把最重要的问题留到了最后。我带你消化并分析了字节码,但是轮到你做些东西来生成它们了。标准的解决方案是编写一 个编译器,但这并不是唯一的途径。 如果你定义了一种基于文本的语言: 你得定义一种语法。无论业余或专业的设计师都容易想当然得低估这件事的难度。定义一种对分析器友好的语法很 容易,但是定义一种对用户友好的很难。 语法设计也是种用户界面设计,及时用户界面变成了一串字符,也容易不 到哪儿去。 你要实现一个分析器。不管它们的名声怎么样,这部分很简单。你可以使用ANTLR或Bison这样的解析器生成器, 或者——跟我一样——自己写一个好用的递归分析,这样就行了。 你必须处理语法错误。这是整个过程中最重要也是最难的部分。当用户出现语法或语义错误的时候——他们当然 会,而且一直出错——将它们领回到正确的道路上是你的事情。当你都不知道解释器处于一个意外符号上时,提供 帮助性的反馈并不容易。 对非技术人员没有亲和力。程序员喜欢文本文件。配合强大的命令行工具,我们将它们当做计算机里的乐高块—— 简单,却有无数种组合方式。 多数非程序员并不这样看待纯文本。对他们来说,文本文件如同给一个机器稽核员填 写的纳税表,即使少填一个分好,它也会朝你大叫。 如果你定义一个图形化编辑工具: 你要实现一个用户界面。按钮、点击、拖拽等诸如此类。这个方法感觉有点低三下四,但是我个人很喜欢它。如果 你选择这个方向,设计好用户界面就是做好这件事情的关键——不是一件能应付了事的无聊事。 这里你做的没一点 儿额外工作都会使得工具更加易用而友好,这会直接提高你游戏内容的质量。如果你回头看看很多你喜欢的游戏, 你常常会发现它们的秘密是有一个又去的编辑工具。 不易出错。因为用户一步步交互式得构建行为,你的程序能够在发现错误时立刻引导他们改正。 使用文本语言时, 工具只有在提交整个文件时才能看到用户内容。这使得避免和控制错误都变得困难。 可移植性差。文本编译器的一点好处是它是通用的。一个简单的编译器仅仅读取一个文件并输出另一个文件。在操 作系统间移植是很容易的。 除了换行符和编码。 当你制作UI时,你得选择使用什么框架,很多框架都依赖于一种操作系统。也有一些跨平台的UI工具包,但是他们 的代价在于亲切感——他们在所有的平台上都让人感到陌生。 这个模式是四人帮解释器模式的姐妹版。它们都会给你一种用数据来组合行为的方法。 事实上,你经常会两个模式一起 使用。你用来生成字节码的工具通常会有一个内部对象树来表达代码。这正是解释器模式能做的事情。 为了将它编译成 字节码,你需要递归遍历整棵树,正如你在解释器模式中解析它那样。唯一的不同是你并不是直接执行一段代码而是将 它们输出成字节码指令并在以后执行它们。 Lua编程语言是游戏中广泛使用的编程语言。它内部实现了一个紧凑的基于寄存器的字节码虚拟机。 Kismet是内置在UnrealEd(Unreal Engine 的编辑器)中的图形化脚本工具。 我自己的小脚本语言,Wren,是一个简单的基于堆栈的字节码解释器。 =============================== 上一节 目录 如何生成字节码? 参考 下一节 使用一个基类提供的操作集合进而在子类中定义行为 每个小孩都有一个成为超级英雄的梦想,但是很不幸,宇宙射线在地球上供应不足。游戏或许是令你成为超级英雄的最佳之 地。因为我们的游戏设计师从来不会说,“不”, 我们的超级英雄游戏意在提供至少十种或百种不同的能力以供玩家选择。 我们的计划是将有一个Superpower基类,然后,我们将有一个实现各个超级力量的继承类。我们将把设计文档分摊给团队中 的程序员并进行编码。当我们完成的时候,我们将有数以百计的超级力量的类。 注解 当你发现自己像这个例子一样有大量的子类的时候,这意味着一种数据驱动的方法可能更适合。试着找到一种定义数 据的行为的方法,而不是用大量的代码来定义不同的力量。 像模式Type Object, Bytecode 和 Interpreter 或许能有所帮助。 我们想让玩家沉寖在一个复杂多变的世界里。无论他们小时候梦想过的什么力量,在我们的游戏里都有。这就意味着这些超 级力量子类能够几乎做任何事情:播放音效,产生视觉效果,与AI交互,创建和销毁其他游戏实体以及产生物理效果。它们 将涉及代码库的绝大部分内容。 让我们的团队就这么放手开始写这些子类,会发生什么呢? 会充满大量的冗余代码。尽管不同的力量将有所不同,我们也能料到其中必有不少冗余。他们中的多数将以同样的方式 来产生视觉效果和播放音效。当你完成冰冻射线,热射线,第戎芥末射线这些射线时,会发现它们在实现上极其相似。 如果人们在实现它们时没有整合起来,那么将会有大量重复的代码和付出。 游戏引擎的每个部分将与这些类产生耦合。在未深入了解之前,人们所写的代码会调用到那些可能与超级力量类毫无绑 定关系的系统。如果我们的渲染器被组织成一些漂亮优雅的分层,只有其中的一层能够被图形引擎之外的代码使用,我 们可以打赌最后将留下侵入到他们所有层的超级力量代码。 当这些外部系统需要改变的时候,超级力量代码将很可能被随机性地破坏。一旦我们的各种超级力量类与游戏引擎的各 个零散部分产生耦合,改变这些系统无疑将影响这些超级力量类。这可不好玩,因为你的图形,音效,UI程序员可不想同 时做游戏程序员的工作。 定义所有超级力量都遵守的约束条件很困难。例如说我们想保证所有我们超级力量播放的音效得到合理的排队和优先级 处理。如果我们的百来个类都自己直接地调用音效引擎的话,这将很难实现。 我们需要的是给每个实现一个超级力量的游戏程序员一系列可用的基本元。你想要你的力量播放音效吗?那就提供给 你 playSound() 函数。想要粒子效果吗?这里有 spawnParticles() 。我们将保证这些操作覆盖你所有的需求,这样一来你就 不必滥用 #include 来包含某些力量类,也不必去探究代码基的余下部分。 我们通过把这些操作设置成 Superpower 基类的保护方法来实现。把它们放在基类就能让每个力量子类直接简单地访问这些方 法。把它们设置为保护状态(并且可能是非虚拟的)来交互,使得它们仅仅作为子类可调用的方法而存在。 我们已经有了玩偶,现在是时候把它们置入游戏中了。为此我们定义一个沙盒方法,这是一个子类必须实现的抽象保护方 法。在有了这些之后,为了实现一种新的力量,你要做的就是: 1. 创建一个继承自 Superpower 的新类。 2. 覆盖沙盒函数 activate() 。 子类沙盒 目的 动机 3. 通过调用 Superpower 提供的保护函数来实现新类方法的函数体。 我们通过尽可能地提高将可用操作的层面来解决代码冗余的问题。当我们发现在大量子类中存在重复代码,我们可以把它向 上移到 Superpower 中作为一个可用的新操作。 我们已经通过把耦合限制在一处来集中耦合问题。 Superpower 最终将与不同的游戏系统耦合,但我们的上百个子类不会,它们 仅与基类耦合。当这些游戏系统中的一个变化时,对 Superpower 进行修改可能是必须的,但是这些大量的子类不应被改动。 这个设计模式会催生一种浅而宽的类层次架构。你的继承链不会深,但是会有大量的类挂在 Superpower 上。通过生成一个有 大量直接子类的单个类,我们在代码基里就有一只单点杠杆。我们在 Superpower 中所付出的心血都将对游戏中大量的类带来 益处。 注解 近来,你发现人们对面向对象语言的继承进行批判。继承是有问题的 -- 在代码基中没有比基类与子类之间更深的耦合 了 -- 但是我发现宽的继承树比深的要表现的更好。 一个基类定义了一个抽象的沙盒方法和一些提供的操作。通过设置他们为保护状态来保证它们仅供子类使用。每个继承的沙 盒子类针对父类提供的操作来实现沙盒函数。 沙盒模式是运用在多数代码库里甚至游戏之外的一种非常简单通用的模式。如果你有一个非虚拟的保护函数,那么你很有可 能正在使用与之相类似的模式。沙盒模式在以下情况比较适用: 你有一个带有大量子类的基类。 基类能够提供所有子类可能需要执行的操作集合。 在子类之间有重叠的代码,你想让它们之间更容易地共享代码。 你想使这些继承类与程序的其他代码之间的耦合最小化。 “继承”一词在近代的一些程序圈子里被诟病,其中一个原因是基类会滋生越来越多的代码。这个模式尤其受这个因素的影 响。 由于子类是通过它们的基类来实现剩下的游戏,基类最终会与那些需要与其子类交互的系统产生耦合。当然,这些子类也与 他们的基类绑定。这个蜘蛛网式的耦合使得无损地改变基类是很困难的 -- 你遇到了脆弱的基类问题。 而从好的角度来说,你所有的耦合都被聚集到了基类,子类现在明显地与其他世界更加独立了。理想状态下,你的绝大部分 操作都在子类中。这意味着你的大量的代码库是独立的,并且更易于维护。 如果你仍然发现这个模式正把你的基类浸入一大锅代码种时,请考虑把一些提供的操作提取到一个基类能够管理的独立的类 中。这里组件模式能够有所帮助。 由于这是一个如此简单的设计模式,并没有多少的示例代码。这不意味着它没有用 -- 这个模式的实现关乎的是其意义而不是 其复杂度。 (沙盒)模式 使用情境 使用须知 示例 我们将从我们的 Superpower 基类开始: class Superpower { public: virtual ~Superpower() {} protected: virtual void activate() = 0; void move(double x, double y, double z) { // Code here... } void playSound(SoundId sound, double volume) { // Code here... } void spawnParticles(ParticleType type, int count) { // Code here... } }; 函数 activate() 就是沙盒函数。由于它是虚拟和抽象的,子类必须要覆盖它。这是为了让子类使用者能够明确他们该对自己 的特殊力量子类做些什么。 另外的保护函数 move() , playSound() 和 spawnParticles() 都是提供的操作。这些就是子类需要在 activate() 函数实现时将 调用的函数。 我们没有在这个示例中实现提供的操作,但是一个实际的游戏需要有真实的代码在那儿。这个函数是 Superpower 在游戏中与 其他系统耦合的地方 -- move() 函数也许会调用物理引擎代码, playSound() 将与音效引擎通讯等等。由于所有的这些都是在 基类的实现中,这就使得所有的耦合都封装在 Superpower 自己中。 好啦,现在让我们放出放射性蜘蛛并创建一个力量。这就有一个: class SkyLaunch : public Superpower { protected: virtual void activate() { // Spring into the air. playSound(SOUND_SPROING, 1.0f); spawnParticles(PARTICLE_DUST, 10); move(0, 0, 20); } }; 注解 好啦,也许能够跳跃并不足以算是超能力,但是这里我尝试保持事情基础化。 这个力量把超级英雄弹向空中,播放一段恰当的音效并踢开一缕拂尘。如果所有的超级力量都如此简单 -- 仅仅是音效,粒子 效果和动作的组合,那么我们就不再需要这个模式了。反而, Superpower 可以自带一个 activate() 的实现,这 个 activate() 是访问音效ID,粒子类型和移动的部分。但是这个在仅当所有的力量基本上以同样的方式来工作仅仅在数据上 有一些差异的地方才有效。让我们更详细的看一下: class Superpower { protected: double getHeroX() { // Code here... } double getHeroY() { // Code here... } double getHeroZ() { // Code here... } // Existing stuff... }; 这里我们添加了一个方法用于获取英雄的位置。我们的 SkyLaunch 子类现在可以使用这些: class SkyLaunch : public Superpower { protected: virtual void activate() { if (getHeroZ() == 0) { // On the ground, so spring into the air. playSound(SOUND_SPROING, 1.0f); spawnParticles(PARTICLE_DUST, 10); move(0, 0, 20); } else if (getHeroZ() < 10.0f) { // Near the ground, so do a double jump. playSound(SOUND_SWOOP, 1.0f); move(0, 0, getHeroZ() - 20); } else { // Way up in the air, so do a dive attack. playSound(SOUND_DIVE, 0.7f); spawnParticles(PARTICLE_SPARKLES, 1); move(0, 0, -getHeroZ()); } } }; 由于我们可以使用一些状态,现在我们的沙盒函数可以做一些实际的有趣的控制流。这里仍然仅仅是一些简单的if语句,但是 你可以做任何你想做的事情。通过使沙盒函数成为一个包含任意代码的切实丰富的函数,将具备无限的潜力。 注解 起初,我建议对力量类采用数据驱动的方式。此处就是一个你决定不采用它的原因。如果你的行为是复杂和紧急的, 定义数据将更困难。 正如你所见,子类沙盒模式是一个相当“弱化”的模式。它描述了一个基本的思想,但并没有给出过于详细的机制。这就意味 着你每次应用它的时候将面临一些抉择,可能就是如下的几个问题: 这是最大的问题。这深深地影响了这个模式的样貌以及它的表现如何。从小来说,基类不提供任何操作。它仅仅有一个沙盒 函数。为了实现它,你将不得不调用基类之外的系统。如果从这个角度来说,说你正在用这个模式恐怕有些牵强。 而从大来讲,基类提供了子类需要的所有操作。子类仅仅与基类耦合并且不调用任何外部系统。 注解 设计决策 需要提供什么操作? 具体来说,这意味着每个子类的源文件仅仅需要#include其基类的头文件即可。 在这两种极端之间,有一个很宽阔的中间地带。在这个空间里,一些操作由基类提供,另外一些则通过定义它的外部系统直 接访问。基类提供越多的操作,子类与外部系统耦合越少,但是基类就耦合得越多。它去掉了继承类的耦合,但是它是通过 把耦合聚集到基类自己来实现的。 如果你有一堆与外部系统耦合的继承类的话,那么就可以使用这个模式,通过把耦合向上移到一个提供的操作,你就把它聚 集到了一个地方:基类。但是你这样做得越多,基类就变得越大和越来越难于维护。 因此你的准绳应该摆在何处?这里有一些经验法则: 如果所提供的操作仅仅被一个或者少数的子类使用,那么不必将它加入基类这只会给基类增加复杂度,同时将影响每个 子类,而仅有少数子类从中受益。 使这个操作与其他操作保持一致或许有价值,而者使这些特殊情况的子类直接调用外部系统或许更简单清晰。 当你调用游戏中一些其他部分的函数的时候,如果那个函数不修改任何状态那么它就不会具备侵入性。它仍然创建了耦 合,但是这是一个“安全”的耦合,因为在游戏中它不带来任何破坏。 注解 带引号的"安全"意指,即使是访问数据也能引起问题。如果你的游戏是多线程的,你可以在数据被修改的同时读取 数据。如果你不小心,最终得到的将是错误的数据。 另一个令人不快的情况是如果你的游戏状态是严格准确的(许多在线游戏为了保持玩家同步),而你访问一些同 步游戏状态之外的东西,则将引起非常严重的非确定性bug。 而另一方面,如果这些调用确实改变了状态,则将与代码库产生更深层次的绑定,你需要对它有更多的了解,因为这些 方法更适合于成为在可见的基类中来提供。 如果提供的操作其实现仅仅是对一些外部系统调用的二次封装,那么它并没有带来多少价值。在这种情况下,直接调用 外部系统更为简单。 然而,极其简单的转向调用也仍有用 -- 这些函数通常访问基类不想直接暴露给子类的状态。例如,让我们看 看 Superpower 提供的这个: void playSound(SoundId sound, double volume) { soundEngine_.play(sound, volume); } 它仅仅在Superpower中转向调用一些soundEngine_区域。这样的好处是把这种区域封装在 Superpower ,以免子类访问 它。 这个设计模式的挑战在于最终你的基类塞满了大量的方法。你能够通过转移一些函数到其他类中来缓解这种情况。在基类中 提供的函数然后仅仅返回这些对象之一。 例如,为了使一个力量类播放音效,我们能够直接添加这些到 Superpower 中: class Superpower { protected: void playSound(SoundId sound, double volume) { // Code here... } 是否直接提供函数,还是通过包含它们的对象来提供? void stopSound(SoundId sound) { // Code here... } void setVolume(SoundId sound) { // Code here... } // Sandbox method and other operations... }; 但是如果 Superpower 已经变得臃肿不堪,我们或许想避免这样做。反而,我们创建一个 SoundPlayer 类来暴露这种功能: class SoundPlayer { void playSound(SoundId sound, double volume) { // Code here... } void stopSound(SoundId sound) { // Code here... } void setVolume(SoundId sound) { // Code here... } }; 然后 Superpower 提供它的访问: class Superpower { protected: SoundPlayer& getSoundPlayer() { return soundPlayer_; } // Sandbox method and other operations... private: SoundPlayer soundPlayer_; }; 把提供的操作分流到一个像这样的辅助类中能给你带来一些东西: 减少基类的函数数量。在这里的例子中,我们从三个函数变成仅仅一个获取函数。 在帮助类中的代码通常更容易维护。像 Superpower 这样的核心基类,不论是否处于我们的意思,都因大量的依赖于它们 而难于修改。通过把功能转移到一个耦合更少的第二候选类,我们可以使它的代码在不破坏的情况下更易于访问。 减少了基类和其他系统之间的耦合。当 playSound() 是一个直接定义在 Superpower 上的函数时,无论实现中调用了什么 音效代码,我们基类就直接地与 SoundId 绑定了。把它转移到 SoundPlayer 减少了 Superpower 对单个 SoundPlayer 类的耦 合, SoundPlayer 会封装其他的依赖。 你的基类经常需要一些数据来封装和保持对子类的隐藏。在我们的第一个例子中, Superpower 类提供了一 个 spawnParticles() 方法。如果这个方法的实现需要一些粒子系统对象,它该如何获得? 把它传递到基类构造函数: 基类如何获取需要的状态? 最简单的方案是让基类把粒子系统作为一个构造函数参数: class Superpower { public: Superpower(ParticleSystem* particles) : particles_(particles) {} // Sandbox method and other operations... private: ParticleSystem* particles_; }; 这安全地保证了每个 superpower 在它构造的时候有一个粒子系统。但是让我们看看一个子类: class SkyLaunch : public Superpower { public: SkyLaunch(ParticleSystem* particles) : Superpower(particles) {} }; 这里我们看到了问题。每个继承类将需要一个构造函数,这个构造函数调用基类的构造函数并传递那个参数。这样就向一些 我们所不期望的状态暴露了每个子类。 同样也存在维护负担。如果稍后我们在基类中添加另一份状态,每个继承类的构造函数将不得不被修改来传递它。 进行二级初始化: 为了避免通过构造函数传递所有的东西,我们可以把初始化拆分为两个步骤。构造函数将不带参数仅仅创建对象。然后,我 们调用一个直接定义在基类中的函数来传递它需要的余下部分数据。 Superpower* power = new SkyLaunch(); power->init(particles); 这里注意我们没有为SkyLaunch的构造函数传递任何东西,它并没有与我们希望在Superpower保持隐藏的东西产生耦合。采 用这种方法困难的地方在于你必须确保你记得调用init()。如果你忘记了,你将拥有一个潜藏的半创建状态不能工作的力量实 例。 你可以通过封装整个过程到单个函数中来修改它。像这样: Superpower* createSkyLaunch(ParticleSystem* particles) { Superpower* power = new SkyLaunch(); power->init(particles); return power; } 注解 通过一点小技巧比如私有化构造函数和友元函数,你可以保证createSkylaunch()函数是唯一能够实际创建力量实例的 函数。通过那种方式,你就不会忘记任何的初始化步骤。 使状态静态化: 在之前的例子中,我们用一个粒子系统实例来初始化每个 Superpower 实例。当每个力量实例需要它们唯一的状态时这是有意 义的。但是让我们看看粒子系统是一个单例,每一个力量实例都将共享同样的状态。 在这种情况下,我们可以使这个状态对基类来说是私有的,同样也是静态的。游戏将仍然不得不保证初始化了状态,但是它 仅仅需要针对整个游戏初始化 Superpower 类一次,而不是每个实例。 注解 请记住,单例仍然有许多的问题。你已经使一些状态在大量的对象之前共享(所有的 Superpower 实例)。粒子系统被 封装,因此它不是全局可见,这很棒,但是仍然使得合理化力量实例更困难,因为它们可以访问同一个对象。 class Superpower { public: static void init(ParticleSystem* particles) { particles_ = particles; } // Sandbox method and other operations... private: static ParticleSystem* particles_; }; 此处注意 init() 和 particles_ 都是静态的。只要游戏调用 Superpower::init() 稍早调用一次,所有的力量实例都可以访问粒 子系统。与此同时, Superpower 实例可以通过调用正确的继承类构造函数被自由创建。 更棒的是,现在 particles_ 是静态变量,我们不必为每个 Superpower 实例储存它,因此我们使得类占用更少的内存。 使用服务定位器: 之前的办法需要外部代码明确地牢记在使用基类说需的状态前将这些状态传递进去,这给周围代码的初始化工作带来了负 担。另外一个选择是让基类通过把它需要的状态拉进去来处理。实现这个的一个方法是使用服务定位器模式。 class Superpower { protected: void spawnParticles(ParticleType type, int count) { ParticleSystem& particles = Locator::getParticles(); particles.spawn(type, count); } // Sandbox method and other operations... }; 这里, spawnParticles() 需要一个粒子系统。它从服务定位器获取了一个,而不是通过外部代码获取。 当你使用Update Method模式的时候,你的更新函数通常也是一个沙盒函数。 模板函数模式正与本模式相反。在这两个模式中,你使用一些列原始操作实现一个函数。通过子类沙盒模式,函数在继 承类中,原始操作则在基类中。通过模板函数,基类有函数,原始操作被继承类实现。 你也能够在Facade模式中看到一种变化。那个模式把一些不同的系统隐藏在一个简单API后。通过子类沙盒,基类扮演 了一个隐藏整个游戏引擎使子类不可见的外观。 参考 允许 感叹号让一些都更加激动人心! ??? 每个从Monster派生的类传入初始生命并重写getAttack()来返回这个品种的攻击字符串。一些都和预想的一样,不久之后,我 们的主人公就能够跑来跑去杀死各种怪物。我们继续编写代码,在我们意识到之前,就会发现有成堆的怪物派生类,从酸性 史莱姆到僵尸山羊。 然后,事情很奇怪地变得缓慢起来。我们的设计师最终想要有上百个品种,我们会发现自己花费了所有的时间去编写那7行代 码的小派生类。更糟糕的是——设计师要开始调整代码中已经有的品种。我们的日常工作流程变成了这样: 1. 收到设计师的邮件,要把巨魔的攻击力从48修改成52。 1. 签出并修改Troll.h。 1. 重新编译游戏。 1. 签入修改。 1. 邮件通知。 1. 重复上述步骤。 我们整天都很茫然,因为我们变成了填数据的猴子。我们的设计师也很茫然,因为要调整好一个数字就要花费大量的时间。 我们需要的能力是在无需重新编译整个游戏的情况下,去修改品种的数值。如果设计师能在无需程序员介入的情况下创建并 调整品种,那就更好了。 在一个比较高的层次上,我们要解决的问题非常简单。我们的游戏中有一堆不同的怪物,我们想要让它们共享一些特性。一 个部落中的怪物想要击败主人公,我们想要它们在攻击时使用相同的文本。我们通过将它们定义成同样的“品种”来实现,那 个品种决定了攻击字符串。 因为它们属于直觉上的类,因此我们决定使用派生来实现这个概念。一头龙是一只怪物,游戏中的没头龙是这个龙的“类”的 实例。将每个种族定义成抽象基类Monster的派生类,让游戏中的每个怪物成为派生的品种类的实例来影射???他。我们最终 会有下面这样的类层次: ??? 这里的意思是“从什么派生” 游戏中每个怪物的实例,都属于一个派生的怪物类型。我们拥有的品种越多,这个类层级就越加庞大。这就是i问题的成因: 添加新的品种意味着添加新的代码,每个品种必须被编译成它自己的类型。 这是可行的,但并不是唯一的选择。我们也可以将代码的架构调整成每个怪物具有一个品种。而不是为每个品种做一次从 Monster的派生,我们有一个唯一的Monster类和一个唯一的品种类: 类型对象 目的 一个类的类 ??? 这里,的意思是“被什么引用” 完成了。就两个类。注意这里没有任何派生。在这个系统里,游戏中的每个怪物是一个简单的Monster类的实例。Breed类 包含了同一品种的所有怪物之间共享的信息:初始生命值和攻击字符串。 为了将怪物与品种关联起来,我们给每个Monster一个到包含了品种信息的Breed的引用。为了获得攻击字符串,一个怪物 在只需在它的品种上调用一个方法。这个品种本质上定义了怪物的“类型”。每个品种实例是一个表述不类型概念差异的对 象,即这个模式的名字:类型对象。 这个模式的特殊能力是可以在无需重新编译代码的情况下,添加新的类型。我们本质上是把硬编码的类型继承系统移动了位 置,放到一个我们可以在运行时定义的数据里。 我们可以通过实例化更多的Breed实例来创建成百上千的不同品种。如果我们通过一些配置文件中的数据初始化品种,我们 就能够完全在数据里定义新的怪物类型。这简单到设计师都能做! 定义一个类型对象类和一个被指定类型对象类。每个类型对象实例表示一个不同的逻辑类型。每个被指定类型的对象存储一 个描述它的类型的类型对象的引用。 实例相关的数据被存储在被指定类型对象实例中,而所有同概念类型所共享的数据和行为被存储在类型对象中。引用同一个 类型对象的对象会表现得好像他们是同类。这让我们能够在一个相似对象集合中共享数据和行为,很像是类派生让我们做到 的事,但是无需一批硬编码的派生类。 这个模式在任何你需要定义一系列不同“种”东西,但是又不想把那些种类硬写进类型系统中的时候都有用。详细来说,只要 下列任意一项成立时它就有用: 你不知道将来会有什么类型。(例如,我们的游戏是否需要支持下载包含怪物新品种的内容?) 能够在不重新编译或修改代码的情况下,修改或添加类型。 这个模式是关于把“类型”的定义从命令式???而僵硬的语言代码转移到更加灵活而且低行为式???的内存对象。灵活性是好 的,但是把类型移动到数据里还是会失去些东西。 一个使用类似C++类型系统的好处是编译器自动处理所有的类登记。定义类的数据自动编译到可执行程序的静态内存分段 中,就能工作了。 通过类型对象模式,我们现在不仅与责任管理内存中的怪物,还包括它们的类型——我们要保证只要有怪物需要它们,所有 的品种对象就应该初始化并保留在内存中。当我们创建一个新的怪物时,我们有责任保证他由一个对合法品种的引用正确得 初始化。 我们将自己从编译器的的一些限制中解放出来,但代价是我们得重新实现一些它曾为我们做的事情。 ??? 在内部,C++ 虚方法通过一种叫做“虚函数表”(virtual function table)东西实现,简称“vtable”。一个虚函数表是一个 包含了一个函数指针集合的简单结构体,每个函数指针指向类中的一个虚方法。每个类在内存中都有一个虚函数表。每 个类实例都有一个指向其类虚函数表的指针。 ??? 当你调用虚函数的时候,代码首先从对象的虚函数表中查找,然后调用存储在表里的恰当的函数。 ??? 听起来很相似?虚函数表就是我们的品种对象,指向虚函数表的指针是怪物对其品种的引用。C++类是类型对象模 式在C上的应用,由编译器自动处理。 模式 何时使用它 记住 类型对象必须手工跟踪 未每个类型定义行为很难 通过类派生,你可以重写一个方法然后做任何你想让它做的事——用程序计算数值,调用其他代码等等。没有任何界限。如 果我们想,我们可以定义一个怪物子类,使它的攻击字符串根据月相而变化。(对狼人来说很方便,我觉得。) 而当我们改用类型对象的时候,我们用成员变量替代了方法重写。不是写一个重写父类方法去计算攻击字符串的怪物派生 类,而是定义一个品种对象把攻击字符串存进另一个变量里面。 这使得通过类型对象去定义类型相关的数据非常容易,但是定义类型相关的行为却很难。如果,比如说,不同的怪物品种需 要使用不同的AI算法,使用这种模式就很有挑战性。 有几种方法可以绕过这个限制。一个简单的解决方案是有一个固定的预定义行为集并使用类型对象中的数据去选择其一。例 如,我们的怪物AI总是处于“站着不动”、“追逐主人公”或者“在恐惧中瑟瑟发抖”(嘿,它们可不会都是巨龙)。我们可以定义 函数来实现每种行为。然后,我们可以把品种通过一个指向特定方法的指针与AI算法关联起来。 ??? 听起来也很熟悉?现在我们真正在类型对象中实现了虚函数表。 另一个更强的解决方案是支持完全在数据中定义行为。解释器模式和字节码模式都让我们编译代表行为的对象。如果我们读 取数据文件并使用它来为其中一种模式创建一个数据结构,我们将行为定义完全移动到了代码之外,放进内容之中。 ??? 随着时间前进???,游戏变得越来越数据驱动。硬件变得更加强大,我们发现自己更多受到自己所能编辑的内容而不 是硬件所困扰。使用一个64K卡带的挑战是把游戏塞进去,使用一个双面DVD的挑战是把里面塞满游戏。 ??? 脚本语言和其他高级的定义游戏行为的方式能够为我们带来必要的生产力提升,其代价是运行时性能无法达到最 优。因为硬件在变得越来越好,但是脑力并没有,这项交换变得越来越有意义。 在我们的第一个实现中,我们从简单入手,实现动机一节中所描述的基础系统。我们首先从Breed类开始。: class Breed { public: Breed(int health, const char* attack) : health_(health), attack_(attack) {} int getHealth() { return health_; } const char* getAttack() { return attack_; } private: int health_; // Starting health. const char* attack_; }; 非常简单。它只是一个包含了两个数据字段的容器:初始生命值和攻击字符串。让我们看看怪物如何使用它: class Monster { public: Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {} const char* getAttack() { return breed_.getAttack(); } private: int health_; // Current health. Breed& breed_; }; 实例代码 当我们构造一个怪物时,我们给它一个品种对象的引用。它定义怪物的品种,而不是用我们之前的类派生。在构造函数中, 怪物使用品种来确定它的初始生命值。要获得攻击字符串,怪物只要转而调用它的品种。 这段简单的代码是这个模式的核心思想。从这里开始所有的东西都是额外奖励。 用我们现有的东西,我们直接构造了一个怪物并负责把它的品种传进去。这与大多数面向对象语言实例化对象的过程有点相 反——我们通常不会分配一段空内存然后给它一个类型。反之,我们先在类上面调用了构造函数,它负责给我们一个新的实 例。 我们可以将这个模式应用到类型对象上面: class Breed { public: Monster* newMonster() { return new Monster(*this); } // Previous Breed code... }; ???“模式”在这里是个正确的字眼。我们说其实是经典设计模式中的一种:工厂方法 ???在一些语言中,这个模式用来创建所有的对象。在Ruby、Smalltalk、Objective-C和其他语言里,类也是对象,你通 过调用类对象上的的方法去构造新的实例。 使用它们的类: class Monster { friend class Breed; public: const char* getAttack() { return breed_.getAttack(); } private: Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {} int health_; // Current health. Breed& breed_; }; 关键的区别是Breed类里面的newMonster()函数。他是我们的“构造”工厂方法。在我们的原始实现中,创建一个怪物看起来 是这样的: ??? 这里有另一个小区别。因为在C++实例代码中,我们可以使用一个方便的小特性:友元类。 ??? 我们将怪物的构造函数定为私有,使得任何人都不能直接调用它。友元类绕开了这个限制,因此Breed仍然能够访 问到它。这意味着创建怪物的唯一方法是通过newMonster()。 Monster* monster = new Monster(someBreed); 在修改过之后,它看起来是这样的: Monster* monster = someBreed.newMonster(); 那么,为什么要这么做呢?创建一个对象分为两步:分配内存和初始化。怪物构造函数让我们能够做所有的初始化操作。在 例子里它被存进了品种里,但是整个游戏会加载图形、初始化怪物的AI然后做一些其他设定工作。 但是,这都发生在内存分配之后。我们在怪物的构造函数被调用之前,就已经有了一段内存。在游戏里,我们也希望能控制 使类型对象更加像类型:构造函数 对象创建的另一方面:我们通常使用一些自定义分配器或者对象池模式来控制对象在内存中的哪个地方结束。 在Breed里定义一个“构造”函数让我们有个地方放置这个逻辑。并非简单得调用new,newMonster()函数能够在把控制权移 交到初始化函数之前从一个池或者自定义堆栈里拉取。通过把此逻辑放进唯一能创建怪物的Breed里,我们保证所有的怪物 都经过我们预想的内存管理体系。 我们现在已经实现了一个完全可用的类型对象系统,但是它还很基本。我们的游戏最终会有上千个种族,每一个都有一堆属 性。如果一个设计师想要调整30多个巨魔品种,使他们更强一点而,她将要面对的会是一段无聊的工作。 一个有效的办法是像多个怪物通过品种共享多种特性一样,让品种之间也能够共享特性。就像我们在最初的面向对象方案那 样,我们可以通过派生来实现。只是,我们不采用语言本身的派生机制,而是自己在类型对象内部实现它。 简单起见,我们只支持单继承。和一个用于基类的类一样,我们允许品种拥有一个基品种: ???? 代码 当我们构造一个品种时,我们给它一个传入一个基品种。我们可以传入NULL表示它没有祖先。 为了让它更有用,一个品种需要控制哪些特性从父类继承,哪些特性需要用它自己的。举个例子,只继承基品种中非零生命 值的以及非NULL的攻击字符串。 有两种实现方式。一个是在属性每次被请求的时候执行代理调用,像这样: ???? 代码 这么做可以当品种在运行时修改后,即使不再有继承关系也能够正确执行。但另一方面,它占用更多的内存(必须保留一个 指向父级的指针),而且更加慢。它必须在派生链上走一遍来查找一个属性。 如果我们确定品种的属性不会改变,一个更快的解决方案是在构造时应用继承。这被称为“复制”代理,因为我们在创建一个 类型的时候把继承的属性复制到了这个类型内部。代码如下: ???? 代码 注意我们不再需要基类中的字段。一旦构造结束,我们就可以忘掉基类,因为他的属性已经被拷贝下来了。要访问一个品种 的特性,现在我们只要返回它的字段。 ???? 代码 又好又快! 假设游戏引擎从JSON文件创建品种。示例如下: ???? 代码 我们要写一段代码去读取每个品种项,然后用它里面的数据去创建实例。例子里巨魔基类是“Troll”,Throll Archer和Troll Wizard都是派生类。 因为这两个派生类的生命值都是0,所以这个值从父类继承。这意味着设计师能在Troll类中调整这个值,所有的三个品种都 会一起更新。随着品种的数量和每个品种内部属性的增加,这能够节省很多时间。现在,通过一个非常小的代码段,我们完 成了控制权在设计师手让他们能有效利用时间的一个开放系统。同时,我们可以不被打扰得编写其他功能。 类型对象模式让我们像在设计自己的编程语言一样射击一个类型系统。设计空间非常广阔,我们可以做很多有趣的事情。 事实上,有些事情限制了我们的美好期盼。时间和可维护性会阻止我们向任何特别复杂的方向走。更重要的是,无论我们设 计了怎样的类型系统,我们的用户(通常是非程序员)需要能很容易地理解它。我们做的越简单,它就更加可用。所以,我 通过继承共享数据 设计决定 们这里讲到的其实是个被反复践踏了的领域,把更深入的方向交给学者和爱探索的人吧。 我们的简单实现里,Monster 有一个对品种的引用,但这个引用不是公开的。外面的代码无法直接访问到怪物的品种。从核 心代码????的角度来说,怪物都是无类型的,它们有品种这件事是实现细节。 我们可以做个修改,让Monster返回它的品种: ???? 代码 本书的另一个例子里,我们紧跟着进行了一个转换,返回引用而不是指针来告诉用户,永远不会返回NULL。 这么做修改了Monster的设计。这样怪物有品种这件事就在API中可见了。这对双方都有好处。 如果类型对象被封装: 类型对象模式的复杂性对代码库的其他部分不可见。它成为一个设计细节,只有有类型对象才关心它。 有类型对象可以选择性地重写类型对象的行为。比如说我们想把怪物濒死时的攻击字符串改掉。由于攻击字符串都 是从Monster访问的,我们有个现成的位置可以写: ???? 代码 如果外部代码直接调用品种上的getAttack(),我们 就没有机会插入这段逻辑。 我们得给类型对象暴露的所有内容提供外部访问接口。这部分很乏味。如果我们的类型对象有一大堆方法,对象类 为了公开,也必须提供一一对应的一大堆方法。 如果类型对象被公开: 类型对象现在是对象公共API的一部分。通常,窄接口比宽接口更容易维护——你暴露给代码库的越少,你要面对 的复杂性和维护共组就越少。 --- TODO 现在,我们假定一旦对象创建完成,就与其类型对象进行绑定,并不再改变。并不是一定要这样 。我们可以允许一个对象随时间改变类型。 回头看我们的例子。当一个怪物死的时候,设计师告诉我们他们希望尸体能够变成会动的僵尸。 我们可以通过重新产生一个带有僵尸品种的新怪,但另一个更简单的选择是获取现有的怪物并把 它的品种修改成僵尸。 如果类型不变: 无论编码还是理解起来都更简单。在概念层面上,“类型”是大多数人都不希望改变 的东西。这么做符合这条假定。 - 易于调试。如果我们在跟踪一个让怪物陷入奇怪状态的Bug时,能够直观地确定正在看 的品种肯定是怪物始终不变的品种,这件事就相对简单了。 如果类型改变: 更少的对象创建。在我们的例子里,如果类型不能改变,我们不得不在CPU循环中创建 新的僵尸怪物,把原怪物中需要保留的属性逐个拷贝过来,随后删除它。如果我们能改变类型, 所有的工作就是个简单的赋值。 - 做假定时要更加小心。对象和其类型之间存在相对紧的耦合。例如,一个品种可能假 类型对象应该封装还是暴露? 类型能否改变? 定怪物的当前血量永远不会超过初始血量。 如果我们允许改变品种,我们需要确保现有对象能符合新类型的要求。当我们修 改类型 时,我们可能会需要执行一些验证代码来保证对象现在的状态对新类型来说有意义。 没有派生: 更简单。简单是最好的选择。如果你没有成堆的需要共享的类型对象,何必自找麻烦 呢? - 可能会导致重复劳动。我曾见过给设计师用的不支持派生的编辑系统。当你有50中精 灵,必须去50个地方把它们的血量修改成相同的数字非常无趣。 单继承: 仍然相对简单。更容易实现,但是,更重要的是,它很容易理解。如果非技术用户使 用这个系统,会动的部分越少,就越好。很多编程语言只支持单继承是有原因的。它看起来是强 大和简单之间的不错的平衡点。 - 属性查找更慢。要获得类型对象中的特定数据,我们需要在派生链中找到其类型,才 能最终确定它的值。如果我们在编写性能苛刻的代码,我们可能不想在这里浪费时间。 多重派生: 绝大多数的数据重复都能被避免。通过一个好的多继承系统,用户能够创建一个几乎 没有冗余的继承体系。比如做调整数值这件事,我们可以避免大量的复制粘贴。 - 复杂。很不幸的是,它的优点更多停留在理论上而不是实践上。多重派生很难理解或 说明。 如果我们的僵尸龙类型从僵尸和龙派生,哪些属性从僵尸获得,哪些从龙获得呢?为了 使用这个系统,用户必须理解派生图如何遍历并要有预见性地射击一个聪明的体系。 我所见到的大多数现代C++编码标准倾 向于禁用多重派生,Java和C#则完全不支持。这承 认了一件不幸的事情:太难让它正确地工作以至于干脆不要用它。虽然它值得考虑,但是你很少 会希望在游戏的类型对象中使用多继承。常言道,越简单越好。 这个模式引出的高级问题是如何在不同对象之间共享数据。另一个从另一个角度引出这个问题 的模式是原型 类型对象与享元很接近。它们都让你在实例间共享数据。享元模式倾向于节约内存,并且 共享的数据可能不会以实际的“类型”呈现。类型对象模式的重点在于组织性和灵活性。 这个模式与状态模式也有很多相似性。它们都把对象的部分定义交给另一个代理对象实现 支持何种类型的派生? 参考 。在类型对象中,我们通常代理的对象是: 宽泛地描述对象的恒定数据。在状态中,我们代理的是对象现在是什么样的, 即:描述对象当前 配置的临时数据。 当我们讨论到可改变类型对象的时候,你会发现此时的类型对象兼任了状态的任务。 Once you get the hang of a programming language, writing code to do what you want is actually pretty easy. What's hard is writing code that's easy to adapt when your requirements change. Rarely do we have the luxury of a perfect feature set before we've fired up our editor. 当你掌握了一门编程语言,你会发现写代码来实现某个你想要实现的功能是件相当容易的事情。难的是写出在此基础上容易 添加或更改功能的代码,因为我们几乎没有可能不更改程序的功能或者特性。 A powerful tool we have for making change easier is decoupling. When we say two pieces of code are "decoupled", we mean a change in one usually doesn't require a change in the other. When you change some feature in your game, the fewer places in code you have to touch, the easier it is. Components decouple different domains in your game from each other within a single entity that has aspects of all of them. Event Queues decouple two objects communicating with each other, both statically and in time. Service Locators let code access a facility without being bound to the code that provides it. 组件将游戏的不同方面互相分离开却仍然具备它们的特性。事件队列能够静态而且及时的将两个对象通信分离开。服务定位 器让代码能够访问到设备却不需要被绑定到提供服务的代码上。 组件 事件队列 服务定位器 解耦模式 本章模式 Allow a single entity to span multiple domains without coupling the domains to each other. 允许一个单独的实体跨多个不同域而不耦合它们。 Let's say we're building a platformer. The Italian plumber demographic is covered, so ours will star a Danish baker, Bjørn. It stands to reason that we'll have a class representing our friendly pastry chef, and it will contain everything he does in the game. 举个例子,假设我们准备要制作一个平台类游戏。意大利水管工(译者注:作者指的是超级玛丽,超级玛丽是个水管工,在 最初设计时被设定为了意大利人)已经有了,那我们设计一个丹麦面包师 Bjorn。显而易见,我们将设计一个能够很好的代 表面包师的类,这个类包含了面包师的所有动作跟特性。 注:我之所以是个程序猿而非设计师就是因为我总想要去实现这些很棒的想法。 Since the player controls him, that means reading controller input and translating that input into motion. And, of course, he needs to interact with the level, so some physics and collision go in there. Once that's done, he's got to show up on screen, so toss in animation and rendering. He'll probably play some sounds too. 因为玩家控制他,这就意味着需要读取控制器的输入并且将输入转换成动作。当然,角色类还需要跟平台相互作用,所以还 需要一些物理和碰撞方面的东西。当这些都完成了,角色通过动画和渲染就显示在屏幕上了。角色可能还会播放一些音效。 Hold on a minute; this is getting out of control. Software Architecture 101 tells us that different domains in a program should be kept isolated from each other. If we're making a word processor, the code that handles printing shouldn't be affected by the code that loads and saves documents. A game doesn't have the same domains as a business app, but the rule still applies. 且慢,事情似乎在往失控的方向发展。在第一章软件架构中我们曾经提到,一个程序中的不同域应该互相隔离。在我们设计 一个文字处理器时,处理打印部分的代码不应该受到保存,读取文档的代码的任何影响。也许游戏的域与应用的域不完全相 同,但这个道理是相通的。 As much as possible, we don't want AI, physics, rendering, sound and other domains to know about each other, but now we've got all of that crammed into one class. We've seen where this road leads to: a 5,000-line dumping ground source file so big that only the bravest ninja coders on your team even dare to go in there. 所以尽可能的,我们不应让AI,物理,渲染,声效已经其他域互相影响,但我们又必须将所有这些包含在一个类中。我们已 经看到了,这个是一个代码量能达5000行以上的巨大的源文件,以至于只有最勇敢的程序员才胆敢去尝试。 This is great job security for the few who can tame it, but it's hell for the rest of us. A class that big means even the most seemingly trivial changes can have far-reaching implications. Soon, the class collects bugs faster than it collects features. 如此庞大的工作量对于那些能够驯服它的人来说这件很棒的事情,但是对无能为力的其余我们来说则如同地狱。一个如此大 的类意味着即使最微不足道的修改都可能产生深远的影响。所以很快,在这个类中你将会得到比功能更多的错误。 Even worse than the simple scale problem is the coupling one. All of the different systems in our game have been tied into a giant knotted ball of code like: 组件模式 Intent 意图 Motivation 动机 The Gordian knot 难题 比简单规模问题更复杂的是耦合问题。我们游戏中所有不同的系统都被绑成一个像巨大的结球一样的代码,就像: if (collidingWithFloor() && (getRenderState() != INVISIBLE)) { playSound(HIT_FLOOR); } Any programmer trying to make a change in code like that will need to know something about physics, graphics, and sound just to make sure they don't break anything. 任何试图想要修改以上代码的程序都必须要知道相关物理,图像以及声音的知识以避免破坏任何功能。 注:While coupling like this sucks in any game, it's even worse on modern games that use concurrency. On multi- core hardware, it's vital that code is running on multiple threads simultaneously. One common way to split a game across threads is along domain boundaries -- run AI on one core, sound on another, rendering on a third, etc.当这种 耦合的设计在任何游戏中都是一种糟糕的设计,但是在使用并发性的现代游戏中尤其糟糕。代码是否能够运行在多个 线程上对拥有多核的硬件来说至关重要。而一个常见的实现多线程并行设计的方法就是设置域隔阂,比如让AI计算在 一个核中完成,声效在另外一核,渲染在第三个,以此类推。 Once you do that, it's critical that those domains stay decoupled in order to avoid deadlocks or other fiendish concurrency bugs. Having a single class with an UpdateSounds() method that must be called from one thread and a RenderGraphics() method that must be called from another is begging for those kinds of bugs to happen.而要实现以 上所说的设置不同域之间的隔阂,最至关重要的就是让不同的域之间保持去耦来避免产生死锁以及其他致命的并发错 误。一个单类中,尝试在一个线程上调用 UpdateSounds() 方法而在另一个线程上调用 RenderGraphics() 方法,这无 疑就是自取灭亡。 These two problems compound each other; the class touches so many domains that every programmer will have to work on it, but it's so huge that doing so is a nightmare. If it gets bad enough, coders will start putting hacks in other parts of the codebase just to stay out of the hairball that this Bjorn class has become.这两个问题互相复合,一个包 含了很多域的类将要求每个想要修改他的程序员做大量的工作,而这毫无疑问就是个噩梦。当代码变得足够糟糕,程 序员都开始因为不想去理这团杂乱的毛团而开始放弃它。 We can solve this like Alexander the Great -- with a sword. We'll take our monolithic Bjorn class and slice it into separate parts along domain boundaries. For example, we'll take all of the code for handling user input and move it into a separate InputComponent class. Bjorn will then own an instance of this component. We'll repeat this process for each of the domains that Bjorn touches. 想要解决这个问题,我们应该像执剑的亚历山大大帝一样。将独立的Bjorn 类依着不用的域切成相互独立的部分。举个例 子,我们将所有用来处理用户输入的代码放到一个类中。而Bjorn将拥有这个类的一个实例。我们将循环对所有Bjorn类包含 的领域做同样的工作。 When we're done, we'll have moved almost everything out of Bjorn . All that remains is a thin shell that binds the components together. We've solved our huge class problem by simply dividing it up into multiple smaller classes, but we've accomplished more than just that. 但我们完成这件工作后,我们几乎将Bjorn类中的所有东西都清除了。剩下的是一个将所有组件绑在一起的外壳。我们通过简 单的将代码分割成小类的方式解决了这个复杂的巨大类问题。但是我们却又不仅仅只是解决了这个问题。 Our component classes are now decoupled. Even though Bjorn has a PhysicsComponent and a GraphicsComponent , the two don't know about each other. This means the person working on physics can modify their component without needing to know anything about graphics and vice versa. 现在我们的内容类是去耦的了。尽管Bjorn类仍然有物理以及图像不同两块的内容,但是这两块内容互不干涉。这意味着想要 Cutting the knot 解决难题 Loose ends 宽松的末端 修改物理块内容的程序员不再需要了解图像块的知识,反之亦然。 In practice, the components will need to have some interaction between themselves. For example, the AI component may need to tell the physics component where Bjørn is trying to go. However, we can restrict this to the components that do need to talk instead of just tossing them all in the same playpen together. 在实践中,这些组件需要互相之间有一些互动。例如,AI组件可以会告知物理组件 Bjorn将去哪里。然而,我们可以限制的只 让组件之间进行交流而不是将他们全部放到一起。 Another feature of this design is that the components are now reusable packages. So far, we've focused on our baker, but let's consider a couple of other kinds of objects in our game world. Decorations are things in the world the player sees but doesn't interact with: bushes, debris and other visual detail. Props are like decorations but can be touched: boxes, boulders, and trees. Zones are the opposite of decorations -- invisible but interactive. They're useful for things like triggering a cutscene when Bjørn enters an area. 这个设计的另一种特性是可重用的组件包。到目前为此,我们都只是考虑了面包师一个角色,但游戏中可能出现的别的物 品。例如灌木,碎片和其他的视觉细节等装饰是游戏中玩家能看到却不能交互的对象。而像盒子、巨石、树木等道具则是玩 家可以与之交互的对象。分区则与装饰品正好相反——玩家看不到却能与之交互。上述这些对象将会在玩家的角色Bjorn进入 一个区域时起作用。 注:When object-oriented programming first hit the scene, inheritance was the shiniest tool in its toolbox. It was considered the ultimate code-reuse hammer, and coders swung it often. Since then, we've learned the hard way that it's a heavy hammer indeed. Inheritance has its uses, but it's often too cumbersome for simple code reuse.当我们使 用面对对象编程的时候,继承就是我们手上最有力的武器。它被认为是程序猿最喜欢用的终极武器。然而我们发现这 个武器很多时候是吧双刃剑,继承有它的用途,但是对某些代码重用来说实现起来太麻烦了。 Instead, the growing trend in software design is to use composition instead of inheritance when possible. Instead of sharing code between two classes by having them inherit from the same class, we do so by having them both own an instance of the same class.相反,软件设计的趋势应该是尽可能的使用组合而不是继承。 我们应该让两个类拥有同 一个类的实例而不是继承同一个类。 Now, consider how we'd set up an inheritance hierarchy for those classes if we weren't using components. A first pass might look like: 现在我们考虑如何在不用组件的情况下建立这些类的继承层次结构,它应该像: Tying back together 捆绑在一起 We have a base GameObject class that has common stuff like position and orientation. Zone inherits from that and adds collision detection. Likewise, Decoration inherits from GameObject and adds rendering. Prop inherits from Zone , so it can reuse the collision code. However, Prop can't also inherit from Decoration to reuse the rendering code without running into the Deadly Diamond. 我们有一个基本游戏类,它包含像位置跟方向这种基本的元素。而区域继承了这个基本类并在其基础上加了碰撞。另外的, 装饰也继承了基本类但是却添加了渲染。支柱继承区域,但是它重用了碰撞的代码。然后支柱不能继承装饰并重用渲染的代 码,否则程序可能产生“致命的钻石”。 注:The "Deadly Diamond" occurs in class hierarchies with multiple inheritance where there are two different paths to the same base class. The pain that causes is a bit out of the scope of this book, but understand that they named it "deadly" for a reason.“致命的钻石”发生在对同一基类有不用路径的多重继承的类层次结构中。但是该错误的诱因不在 这本书的讨论范畴内,但是请相信叫它致命的不是没有原因的。 We could flip things around so that Prop inherits from Decoration , but then we end up having to duplicate the collision code. Either way, there's no clean way to reuse the collision and rendering code between the classes that need it without resorting to multiple inheritance. The only other option is to push everything up into GameObject , but then Zone is wasting memory on rendering data it doesn't need and Decoration is doing the same with physics. 我们可以做些转变让支柱能够继承装饰类。但是我们将不能够复制碰撞的代码。无论如何,都没有办法不通过多重继承而在 同一个类中重用碰撞跟渲染部分的代码。唯一的选择就是将这两段代码同时放到基本类中,然后这么做的结果就是区域类将 会不需要渲染以及装饰类的代码而浪费了不少的内存。 Now, let's try it with components. Our subclasses disappear completely. Instead, we have a single GameObject class and two component classes: PhysicsComponent and GraphicsComponent . A decoration is simply a GameObject with a GraphicsComponent but no PhysicsComponent . A zone is the opposite, and a prop has both components. No code duplication, no multiple inheritance, and only three classes instead of four. 现在,让我们试着用组件的方法。所有的子类完全消失,取而代之的是一个简单的基本类和两个组件:物理组件以及图像组 件。所以装饰类就是一个同时包含基本类和图像组件的类,而区域则恰恰相反,支柱则是同时包含这两个组件,没有代码重 复,没有多重继承,只有简单的三个类而不是四个。 注:A restaurant menu is a good analogy. If each entity is a monolithic class, it's like you can only order combos. We need to have a separate class for each possible combination of features. To satisfy every customer, we would need dozens of combos.这好比一个餐厅的菜单,如果每个实体都是一个单独的类,那么也许你就只能点设定好的几个套 餐。但是我们需要的一个能够结合不同特性的独立类。为了满足客户,我们可能需要数十个套餐。 Components are à la carte dining -- each customer can select just the dishes they want, and the menu is a list of the dishes they can choose from.而组件就像按着菜单点菜用餐,每个客户能够选择那些他们喜欢的菜,而菜单则是一个 他们选择菜品的列表。 Components are basically plug-and-play for objects. They let us build complex entities with rich behavior by plugging different reusable component objects into sockets on the entity. Think software Voltron. A single entity spans multiple domains. To keep the domains isolated, the code for each is placed in its own component class. The entity is reduced to a simple container of components. 样式是一个跨多个域的单一实体。为了能够保持域之间相互隔离,每个域的代码都独立的放在自己的组件类中。实体则可以 减少到一个的组件的容器。 > 注:"Component", like "Object", is one of those words that means everything and nothing in programming. Because of that, it's been used to describe a few concepts. In business software, there's a "Component" design pattern that describes decoupled services that communicate over the web.“组件”就如同“对象”,它即代表所有事情,但是也不代表任何事情。就因 为如此,它用来描述一些概念。在商业软件中,有一种“组件”设计模式,它描述了解耦服务,并能够通过网络进行通讯。 > I tried to find a different name for this unrelated pattern found in games, but "Component" seems to be the most common term for it. Since design patterns are about documenting existing practices, I don't have the luxury of coining a new term. So, following in the footsteps of XNA, Delta3D, and others, "Component" it is.我试图找到一个不同的名字来跟本文所说的游 戏中的样式进行区别,但是“组件”仍然是最合适的名称。既然设计模式是用于记录已经存在的的东西,我也没有那个荣幸能 够创造一个新的术语。所以“组件”就随XNA,Delta3D之后,变成它现在的这个含义。 Components are most commonly found within the core class that defines the entities in a game, but they may be useful in other places as well. This pattern can be put to good use when any of these are true: 组件一个最普遍的用法是在核心类中定义了一个游戏的实体,但是它们也能够用在别的地方。当如下条件成立时,样式就能 够发挥它的作用: 你有一个涉及到多个域的类,但是你想保持互相解耦。 You have a class that touches multiple domains which you want to keep decoupled from each other.你有一个涉及到多 个域的类,但是你想保持互相解耦。 A class is getting massive and hard to work with.一个类越来越庞大,很难处理。 You want to be able to define a variety of objects that share different capabilities, but using inheritance doesn't let you pick the parts you want to reuse precisely enough.你希望定义能够共用不同功能的不同的类但却不通过继承来精确的重 用代码。 The Component pattern adds a good bit of complexity over simply making a class and putting code in it. Each conceptual "object" becomes a cluster of objects that must be instantiated, initialized, and correctly wired together. Communication The Pattern 模式 When to Use It Keep in Mind 注意事项 between the different components becomes more challenging, and controlling how they occupy memory is more complex. 组件模式每次通过简单添加一点点的复杂的代码的方法来构成一个类。每个概念上的“对象”成为一个集群是必须被实例化, 初始化以及正确地连接在一起。不同组件之间的通讯变得更具挑战性,并且限制它们占用内存将更加复杂。 For a large codebase, this complexity may be worth it for the decoupling and code reuse it enables, but take care to ensure you aren't over-engineering a "solution" to a non-existent problem before applying this pattern. 对于一个大型代码库,它的复杂性会让解耦以及代码重用变得有价值,但是请注意你没有在不存在问题的代码库中过度设计 而使用这样一个“解决方案”。 Another consequence of using components is that you often have to hop through a level of indirection to get anything done. Given the container object, first you have to get the component you want, then you can do what you need. In performance- critical inner loops, this pointer following may lead to poor performance. 使用组件的另外一个后果是如果你需要经常跳过一个间接层来处理任何事情,考虑到容器对象,首先你必须得到你需要的组 件,然后你才可以做你需要做的事情,在一些性能要求较高的代码中,这个指针可能会导致低劣的性能。 注:There's a flip side to this coin. The Component pattern can often improve performance and cache coherence. Components make it easier to use the Data Locality pattern to organize your data in the order that the CPU wants it. 就如硬币有正反面,而这就如同硬币的另外一面。组件模式通常能够提高性能和缓存一致性。组件让使用数据本地化 模型来组织CPU所需要的数据顺序变得简单。 One of the biggest challenges for me in writing this book is figuring out how to isolate each pattern. Many design patterns exist to contain code that itself isn't part of the pattern. In order to distill the pattern down to its essence, I try to cut as much of that out as possible, but at some point it becomes a bit like explaining how to organize a closet without showing any clothes. 写这本书对我来说最大的挑战是找到如何隔离每个模式的方法。许多设计模式都包含了不属于与本模式无关的代码。为了提 取模式的精华,我试着尽可能的简化,但是这就变得有点像是在展示一个没有任何衣服的衣柜 The Component pattern is a particularly hard one. You can't get a real feel for it without seeing some code for each of the domains that it decouples, so I'll have to sketch in a bit more of Bjørn's code than I'd like. The pattern is really only the component classes themselves, but the code in them should help clarify what the classes are for. It's fake code -- it calls into other classes that aren't presented here -- but it should give you an idea of what we're going for. 而组件模式则尤其困难。如果你没有见过任何解耦的代码,你将可能对组件的设计模式毫无头绪。所以我在我们Bjorn的代码 上扩展开来向你们描述下。模式的确是只有组件本身,但是这其中的代码应该能够解释这个类。它是一段伪代码,它调用了 其它不属于这里的类,但是它应该能够让你明白我们正在干什么。 To get a clearer picture of how this pattern is applied, we'll start by showing a monolithic Bjorn class that does everything we need but doesn't use this pattern: 以便更清楚的了解这种模式是如何应用的,我们将从一个完成所有事情但是不使用组件模式的巨大单类Bjorn开始。 注:I should point out that using the actual name of the character in the codebase is usually a bad idea. The marketing department has an annoying habit of demanding name changes days before you ship. "Focus tests show males between 11 and 15 respond negatively to ‘Bjørn’. Use ‘Sven’ instead."我应该指出,使用实际名称中的字符代码 库通常都是一个糟糕的想法。市场部有一个恼人的习惯就是要求你在发布应用前修改名字。“专注力测试的结果表示11 到15岁之间的男生对Bjorn反响平平,请改为Sven。” This is why many software projects use internal-only codenames. Well, that and because it's more fun to tell people you're working on "Big Electric Cat" than just "the next version of Photoshop."这也是为什么许多软件项目使用只面向 Sample Code 范例代码 A monolithic class 一个单类 内部的代号。因为告诉别人你正在开发一个“大电力猫”的程序比“新版本的Photoshop”要有趣多了。 class Bjorn { public: Bjorn() : velocity_(0), x_(0), y_(0) {} void update(World& world, Graphics& graphics); private: static const int WALK_ACCELERATION = 1; int velocity_; int x_, y_; Volume volume_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; }; Bjorn has an update() method that gets called once per frame by the game: Bjorn中有个update()方法来调用游戏中的每一帧: void Bjorn::update(World& world, Graphics& graphics) { // Apply user input to hero's velocity. switch (Controller::getJoystickDirection()) { case DIR_LEFT: velocity_ -= WALK_ACCELERATION; break; case DIR_RIGHT: velocity_ += WALK_ACCELERATION; break; } // Modify position by velocity. x_ += velocity_; world.resolveCollision(volume_, x_, y_, velocity_); // Draw the appropriate sprite. Sprite* sprite = &spriteStand_; if (velocity_ < 0) { sprite = &spriteWalkLeft_; } else if (velocity_ > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite, x_, y_); } It reads the joystick to determine how to accelerate the baker. Then it resolves its new position with the physics engine. Finally, it draws Bjørn onto the screen.它通过读取操纵杆来决定如何加速面包师。然后通过物理引擎来解决新位置的问题。 最后将面包师显示到屏幕上。 The sample implementation here is trivially simple. There's no gravity, animation, or any of the dozens of other details that make a character fun to play. Even so, we can see that we've got a single function that several different coders on our team will probably have to spend time in, and it's starting to get a bit messy. Imagine this scaled up to a thousand lines and you can get an idea of how painful it can become.这个示例实现非常简单。没有重力,动画或者其他几十个能够让游戏变得有趣 的细节。但即便如此,我们可以看到,我们有一个单一的函数让团队中不同的程序员都得花点时间,而且也开始有点混乱。 试想下如果代码扩展到一千行这将会是多么痛苦的一件事情。 Starting with one domain, let's pull a piece out of Bjorn and push it into a separate component class. We'll start with the first domain that gets processed: input. The first thing Bjorn does is read in user input and adjust his velocity based on it. Let's move that logic out into a separate class:从一个域开始,我们将一部分的Bjorn代码抽离出来并将它封装到一个独立的 组件类中。我们首先从输入这个域开始。Bjorn类做的第一件事情就是读入用户的输入并相应的调整速度。让我们将这个逻辑 封装到一个独立的类中: class InputComponent { public: void update(Bjorn& bjorn) { switch (Controller::getJoystickDirection()) { case DIR_LEFT: bjorn.velocity -= WALK_ACCELERATION; break; case DIR_RIGHT: bjorn.velocity += WALK_ACCELERATION; break; } } private: static const int WALK_ACCELERATION = 1; }; Pretty simple. We've taken the first section of Bjorn ’s update() method and put it into this class. The changes to Bjorn are also straightforward:非常简单,我们只需要将Bjorn类中update方法放到一个新的类中就好了,而更改Bjorn类也相当简 单: class Bjorn { public: int velocity; int x, y; void update(World& world, Graphics& graphics) { input_.update(*this); // Modify position by velocity. x += velocity; world.resolveCollision(volume_, x, y, velocity); // Draw the appropriate sprite. Sprite* sprite = &spriteStand_; if (velocity < 0) { sprite = &spriteWalkLeft_; } else if (velocity > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite, x, y); } private: InputComponent input_; Volume volume_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; }; Splitting out a domain 分割域 Bjorn now owns an InputComponent object. Where before he was handling user input directly in the update() method, now he delegates to the component:现在Bjorn拥有一个输入组件类,之前它通过调用update方法来处理用户的输入,现在它 只需代理组件即可: input_.update(*this); We've only started, but already we've gotten rid of some coupling -- the main Bjorn class no longer has any reference to Controller . This will come in handy later.我们仅仅才开始就已经摆脱了一些耦合——我们将逐步使得核心Bjorn类不再设计 到任何控制器。 Now, let's go ahead and do the same cut-and-paste job on the physics and graphics code. Here's our new PhysicsComponent :现在,让我们对物理以及图形的代码继续做同样的工作。这里给出了新的物理组件的代码: class PhysicsComponent { public: void update(Bjorn& bjorn, World& world) { bjorn.x += bjorn.velocity; world.resolveCollision(volume_, bjorn.x, bjorn.y, bjorn.velocity); } private: Volume volume_; }; In addition to moving the physics behavior out of the main Bjorn class, you can see we've also moved out the data too: The Volume object is now owned by the component.除了将物理行为从核心类Bjorn中移除外,你还能看到我们同时将数据也 移除了:现在音量对象是组件所拥有的。 Last but not least, here's where the rendering code lives now:最后但是同样重要的,是渲染部分的代码: class GraphicsComponent { public: void update(Bjorn& bjorn, Graphics& graphics) { Sprite* sprite = &spriteStand_; if (bjorn.velocity < 0) { sprite = &spriteWalkLeft_; } else if (bjorn.velocity > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite, bjorn.x, bjorn.y); } private: Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; }; We've yanked almost everything out, so what's left of our humble pastry chef? Not much:我们几乎将所有东西都移除了,只 剩下没有多少代码的Bjorn类: Splitting out the rest 分割其余部分 class Bjorn { public: int velocity; int x, y; void update(World& world, Graphics& graphics) { input_.update(*this); physics_.update(*this, world); graphics_.update(*this, graphics); } private: InputComponent input_; PhysicsComponent physics_; GraphicsComponent graphics_; }; The Bjorn class now basically does two things: it holds the set of components that actually define it, and it holds the state that is shared across multiple domains. Position and velocity are still in the core Bjorn class for two reasons. First, they are "pan-domain" state -- almost every component will make use of them, so it isn't clear which component should own them if we did want to push them down.现在Bjorn类只做两件很基础的事情,拥有一些能够真正工作起来的组件,以及拥有 能够在不同的域共享的状态信息。位置和速度的信息之所以还保留在Bjorn类中主要有两个原因,首先它们是“pan- domain(最基础?)”状态,几乎所有的组件都必须使用它们,所以如果将它们放到组件中是不明智的。 Secondly, and more importantly, it gives us an easy way for the components to communicate without being coupled to each other. Let's see if we can put that to use.第二点也是最重要的点是将位置与速度这两个状态信息保留在Bjorn类中使得我们轻 松的在组件中传递信息而不需要耦合组件。让我们来看看应该如何使用。 So far, we've pushed our behavior out to separate component classes, but we haven't abstracted the behavior out. Bjorn still knows the exact concrete classes where his behavior is defined. Let's change that.到目前为止,我们已经将行为封装到 单独的组件类中,但是我们没有将这些行为抽象化。Bjorn仍然精确的知道行为是在哪个类中被定义的。让我们来修改下。 We'll take our component for handling user input and hide it behind an interface. We'll turn InputComponent into an abstract base class:我们将处理用户输入的组件隐藏到一个接口下,这样就能够将输入组件变成一个抽象的基类: class InputComponent { public: virtual ~InputComponent() {} virtual void update(Bjorn& bjorn) = 0; }; Then, we'll take our existing user input handling code and push it down into a class that implements that interface:然后,我 们将已经存在的用于处理用户输入的代码封装到一个实现了接口的类中: class PlayerInputComponent : public InputComponent { public: virtual void update(Bjorn& bjorn) { switch (Controller::getJoystickDirection()) { case DIR_LEFT: bjorn.velocity -= WALK_ACCELERATION; break; case DIR_RIGHT: bjorn.velocity += WALK_ACCELERATION; break; } } Robo-Bjørn 重构Bjorn private: static const int WALK_ACCELERATION = 1; }; We'll change Bjorn to hold a pointer to the input component instead of having an inline instance:我们改变Bjorn类,让它拥 有一个指向输入组件的指针而不是拥有一个内联实例: class Bjorn { public: int velocity; int x, y; Bjorn(InputComponent* input) : input_(input) {} void update(World& world, Graphics& graphics) { input_->update(*this); physics_.update(*this, world); graphics_.update(*this, graphics); } private: InputComponent* input_; PhysicsComponent physics_; GraphicsComponent graphics_; }; Now, when we instantiate Bjorn , we can pass in an input component for it to use, like so:现在,当我们实例化Bjorn是,我 们可以通过一个输入组件使用,像这样 Bjorn* bjorn = new Bjorn(new PlayerInputComponent()); This instance can be any concrete type that implements our abstract InputComponent interface. We pay a price for this -- update() is now a virtual method call, which is a little slower. What do we get in return for this cost?这个实例可以是任何实 现了我们抽象输入组件接口的具体类型。但是我们也因此付出代价,现在update方法是一个抽象方法调用方法,相对有点 慢。我们应该反思,付出了这个代价我们得到了什么? Most consoles require a game to support "demo mode." If the player sits at the main menu without doing anything, the game will start playing automatically, with the computer standing in for the player. This keeps the game from burning the main menu into your TV and also makes the game look nicer when it's running on a kiosk in a store.大多数主机需要游戏支 持“演示模式”。如果玩家停留在主菜单并且不做任何事情,电脑则会代替玩家让游戏则会自动的播放起来。这么做的目的是 为了不要让游戏长时间的停留在主菜单画面,同时也为了在销售商店展示给顾客留下更好的印象。 Hiding the input component class behind an interface lets us get that working. We already have our concrete PlayerInputComponent that's normally used when playing the game. Now, let's make another one:而将输入组件类隐藏到一 个接口下有助于我们完成这个工作。我们已经有一个可供玩家正常游戏时的玩家输入组件。现在我们来制作另外一个输入组 件: class DemoInputComponent : public InputComponent { public: virtual void update(Bjorn& bjorn) { // AI to automatically control Bjorn... } }; When the game goes into demo mode, instead of constructing Bjørn like we did earlier, we'll wire him up with our new component:当游戏进入演示模式时,我们像之前那样构建Bjorn类,取而代之的是将它连接到新的组件上: ^code 13 And now, just by swapping out a component, we've got a fully functioning computer-controlled player for demo mode. We're able to reuse all of the other code for Bjørn -- physics and graphics don't even know there's a difference. Maybe I'm a bit strange, but it's stuff like this that gets me up in the morning.现在,仅仅只是交换了一个组件,我们就得到了一个功能完备的 完全由电脑控制的展示模式。我们能够重用所有其他Bjorn的代码,包括物理以及图形而让Bjorn知道这两者这件有什么区 别。也许是我有些奇怪,但是像这样的东西能让我在早上精神起来。 注:That, and coffee. Sweet, steaming hot coffee.这个,还有甜的热气腾腾的咖啡。 If you look at our Bjorn class now, you'll notice there's nothing really "Bjørn" about it -- it's just a component bag. In fact, it looks like a pretty good candidate for a base "game object" class that we can use for every object in the game. All we need to do is pass in all the components, and we can build any kind of object by picking and choosing parts like Dr. Frankenstein. 现在让我们看看的Bjorn类,你会发现基本上没有Bjorn独有的代码,它更像是个组件包。事实上,它是一个能够用到游戏中 所有对象身上的游戏基本类的最佳候选。我们需要做的只是将它传递给所有的组件,然后我们就像选择博士Frankenstein一 起去构建任何类型的对象。 Let's take our two remaining concrete components -- physics and graphics -- and hide them behind interfaces like we did with input:让我们把剩下的两个具体组件—物理以及图形隐藏到接口下: class PhysicsComponent { public: virtual ~PhysicsComponent() {} virtual void update(GameObject& obj, World& world) = 0; }; class GraphicsComponent { public: virtual ~GraphicsComponent() {} virtual void update(GameObject& obj, Graphics& graphics) = 0; }; Then we re-christen Bjorn into a generic GameObject class that uses those interfaces:然后我们重构Bjorn类,并将它改造 成一个使用了以上接口的通用游戏类 class GameObject { public: int velocity; int x, y; GameObject(InputComponent* input, PhysicsComponent* physics, GraphicsComponent* graphics) : input_(input), physics_(physics), graphics_(graphics) {} void update(World& world, Graphics& graphics) { input_->update(*this); physics_->update(*this, world); graphics_->update(*this, graphics); } private: InputComponent* input_; PhysicsComponent* physics_; GraphicsComponent* graphics_; }; No Bjørn at all? 删掉Bjorn 注:Some component systems take this even further. Instead of a GameObject that contains its components, the game entity is just an ID, a number. Then, you maintain separate collections of components where each one knows the ID of the entity its attached to.有一些组件系统在此基础上更发展一步,整个游戏就是一个ID,一个数字而不是一 个包含组件的游戏类。然后你可以使用ID来维持一个实体所拥有的组件套之间互相独立。 These entity component systems take decoupling components to the extreme and let you add new components to an entity without the entity even knowing. The Data Locality chapter has more details.这些实体组件系统将解耦组件的设 计发挥到了极限。让你能够对一个实体进行添加新的组件而不让实体知道。局部数据的章节将更详细的讲述这个细 节。 Our existing concrete classes will get renamed and implement those interfaces:我们将现有的具体类重命名并且实现以上接 口: class BjornPhysicsComponent : public PhysicsComponent { public: virtual void update(GameObject& obj, World& world) { // Physics code... } }; class BjornGraphicsComponent : public GraphicsComponent { public: virtual void update(GameObject& obj, Graphics& graphics) { // Graphics code... } }; And now we can build an object that has all of Bjørn's original behavior without having to actually create a class for him, just like this:现在我们可以构建一个拥有所有Bjorn原本行为的对象,但是却不需要因此生成一个类,就像: GameObject* createBjorn() { return new GameObject(new PlayerInputComponent(), new BjornPhysicsComponent(), new BjornGraphicsComponent()); } 注:This createBjorn() function is, of course, an example of the classic Gang of Four Factory Method pattern.当 然,createBjorn方法是一个典型的四种工厂设计模式的实例 By defining other functions that instantiate GameObjects with different components, we can create all of the different kinds of objects our game needs.通过定义其他的函数来实例化拥有不同组件的游戏类,我们能够创建所有我们游戏中所需的对象。 The most important design question you'll need to answer with this pattern is, "What set of components do I need?" The answer there is going to depend on the needs and genre of your game. The bigger and more complex your engine is, the more finely you'll likely want to slice your components.关于这个设计模式的最重要的问题是:我需要的组件套是什么?答案 是取决于你游戏的需求与风格。更大更复杂的引擎需要你将组件切分的更细。 Beyond that, there are a couple of more specific options to consider:除此之外,有一些更具体的选择需要考虑: Once we've split up our monolithic object into a few separate component parts, we have to decide who puts the parts back together.一旦我们将一个单独的对象分割成数个独立的组件,我们就必须决定谁在背后来联系这些组件。 Design Decisions 设计决定 How does the object get its components? 对象如何获得组件 If the object creates its own components:如果这个类创建了自己的组件: It ensures that the object always has the components it needs. You never have to worry about someone forgetting to wire up the right components to the object and breaking the game. The container object itself takes care of it for you.它确保了这个类一定有它所需要的组件。你不要担心有人忘记了将类链接到组件上而导致游戏崩溃。容器类将 会负责这件事。 It's harder to reconfigure the object. One of the powerful features of this pattern is that it lets you build new kinds of objects simply by recombining components. If our object always wires itself with the same set of hard-coded components, we aren't taking advantage of that flexibility.但是这么做将导致很难再重新配置这个类。此设计模式一 个强大的特性就是能够让你通过简单的组合组件来构建任何你需要的对象。如果我们的对象总是连着一组组件,我 们将失去了这种灵活性。 If outside code provides the components: 如果由外部代码提供组件: The object becomes more flexible. We can completely change the behavior of the object by giving it different components to work with. Taken to its fullest extent, our object becomes a generic component container that we can reuse over and over again for different purposes.对象将变得灵活。我们完全可以通过添加不同的组件来改变类 的行为。我们甚至能把这个类当做一个通用的组件容器,一遍又一遍的为不同的目重用代码。 The object can be decoupled from the concrete component types. If we're allowing outside code to pass in components, odds are good that we're also letting it pass in derived component types. At that point, the object only knows about the component interfaces and not the concrete types themselves. This can make for a nicely encapsulated architecture.对象可以从具体的组件类型中解耦出来,we’re allowing outside code to pass in components, odds are good that we’re also letting it pass in derived component types. 对象只是知道组件的接口而 不知道具体的类型,这能够很好的封装结构。 Perfectly decoupled components that function in isolation is a nice ideal, but it doesn't really work in practice. The fact that these components are part of the same object implies that they are part of a larger whole and need to coordinate. That means communication.完美的将组件互相解耦并且保证功能隔离是个很好的想法,但它通常是不现实的。事实就是组件是相 同对象的一个部分,所以组件与组件之间需要传递信息。 So how can the components talk to each other? There are a couple of options, but unlike most design "alternatives" in this book, these aren't exclusive -- you will likely support more than one at the same time in your designs.所以组件之间又是如何 传递信息的呢?有好几个选择,但是不像大多数这边书中设计模式,它们不是唯一的,所以你可以同时使用好几种不同的方 法。 By modifying the container object's state:通过修改容器对象的状态: It keeps the components decoupled. When our InputComponent set Bjørn's velocity and the PhysicsComponent later used it, the two components had no idea that the other even existed. For all they knew, Bjørn's velocity could have changed through black magic.它是的组件保持解耦。当我们的输入组件设置Bjorn的速度和物理组件使用它的时 候,这两个组件甚至都不知道对方的存在,它们知道的是Bjorn类的速度已经改变了。 It requires any information that components need to share to get pushed up into the container object. Often, there's state that's really only needed by a subset of the components. For example, an animation and a rendering component may need to share information that's graphics-specific. Pushing that information up into the container object where every component can get to it muddies the object class.它需要任何组件需要知道的信息都在更高一级 的容器中进行共享。通常,某些状态只是一少部分组件所需要。举个例子,动画已经渲染的组件可能需要与图形组 件共享信息,但是将这些信息放到所有组件都能够获取到的容器类中则会混乱了这个对象类。 Worse, if we use the same container object class with different component configurations, we can end up wasting memory on state that isn't needed by any of the object's components. If we push some rendering-specific data into the container object, any invisible object will be burning memory on it with no benefit.更糟糕的是,如果我们使用相 同的容器类以及不同的组件配置,我们将会把宝贵的内存浪费在可能完全不需要的那些状态信息的对象组件上。如 How do components communicate with each other?组件之间如何传递信息? 果我们将一些特定的渲染数据放到容器类汇总,任何不可见的对象就会浪费大量内存,而且这不带来任何好处。 It makes communication implicit and dependent on the order that components are processed. In our sample code, the original monolithic update() method had a very carefully laid out order of operations. The user input modified the velocity, which was then used by the physics code to modify the position, which in turn was used by the rendering code to draw Bjørn at the right spot. When we split that code out into components, we were careful to preserve that order of operations.这使得信息传递变得隐秘以及依赖组件执行的顺序。在我们的样例代码中,最原 始的update方法有一个非常仔细的操作顺序。用户输入修改了速度,然后物理代码修改位置,然后渲染代码在屏幕 上显示Bjorn。当我们将代码分割成不同的组件后,我们需要小心翼翼的保留操作的顺序。 If we hadn't, we would have introduced subtle, hard-to-track bugs. For example, if we'd updated the graphics component first, we would wrongly render Bjørn at his position on the last frame, not this one. If you imagine several more components and lots more code, then you can get an idea of how hard it can be to avoid bugs like this.如果我们不这么做的话,可以会导致一些很细小的难以追踪的bug。举个例子,如果我们首先加载了图形组件, 我们极有可能会将Bjorn显示在错误的位置上。即便不是这个,如果加入更多的组件已经更大的代码,你就会发现避 免执行顺序发生错乱是件多么困难的事情。 注:Shared mutable state like this where lots of code is reading and writing the same data is notoriously hard to get right. That's a big part of why academics are spending time researching pure functional languages like Haskell where there is no mutable state at all.大量的像这样共享可变的状态信息的代码无论对阅读还是写来说都是非常难保持正确 的。这也是为什么学者会花时间研究像Haskell这样没有可变状态纯函数语言的主要原因。 By referring directly to each other: 直接联系 The idea here is that components that need to talk will have direct references to each other without having to go through the container object at all.有一个想法就是当组件需要与其他的组件进行信息传递时,它不通过容器类而是直接 将信息传递到目标组件。 Let's say we want to let Bjørn jump. The graphics code needs to know if he should be drawn using a jump sprite or not. It can determine this by asking the physics engine if he's currently on the ground. An easy way to do this is by letting the graphics component know about the physics component directly:假设我们想让Bjorn跳起来。图形代码需要知道它 是否应该使用一个跳跃的动作。一个比较简单方法就是让图形组件与物理组件取得直接的联系: class BjornGraphicsComponent { public: BjornGraphicsComponent(BjornPhysicsComponent* physics) : physics_(physics) {} void Update(GameObject& obj, Graphics& graphics) { Sprite* sprite; if (!physics_->isOnGround()) { sprite = &spriteJump_; } else { // Existing graphics code... } graphics.draw(*sprite, obj.x, obj.y); } private: BjornPhysicsComponent* physics_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; Sprite spriteJump_; }; When we construct Bjørn's `GraphicsComponent`, we'll give it a reference to his corresponding `PhysicsComponent`.当我们构建Bjorn的图形组件时,我们给它一个对应的物理组件的引用。 * *It's simple and fast.* Communication is a direct method call from one object to another. The component can call any method that is supported by the component it has a reference to. It's a free-for-all.这很简单而且很快捷。组件之间的信息传递是通过一个对象调用另一个对象的方法。组件能够调用其代码中含有引用的组件的所有方法。这是个混战。 * *The two components are tightly coupled.* The downside of the free-for-all. We've basically taken a step back towards our monolithic class. It's not quite as bad as the original single class though, since we're at least restricting the coupling to only the component pairs that need to interact.组件之间紧密耦合。缺点就是会变得相当混乱。我们好像又回到了当初一个巨大的单类的时候,但其实这远没有那来的糟糕,起码我们将耦合限制在需要交流的组件之间。 By sending messages:通过传递信息 This is the most complex alternative. We can actually build a little messaging system into our container object and let the components broadcast information to each other.这是选项中最复杂的一个。我们可以在容器类中建立一个 小的消息传递系统,让需要传递信息的组件通过广播的方式去建立组件间的联系。 Here's one possible implementation. We'll start by defining a base Component interface that all of our components will implement:以下是一个实现的可能。我们将首先定义一个所有组件都能实现的基本组件接口: class Component { public: virtual ~Component() {} virtual void receive(int message) = 0; }; It has a single receive() method that component classes implement in order to listen to an incoming message. Here, we're just using an int to identify the message, but a fuller implementation could attach additional data to the message.它有一个receive方法,组件通过实现它来监听传入信息。在这里我们将信息定义成int型,通过更加全 面的实现我们也可以将额外的数据附加到信息中。 Then, we'll add a method to our container object for sending messages:然后,我们在容器类中添加一个方法来发 送消息 ```c++ class ContainerObject { public: void send(int message) { for (int i = 0; i < MAXCOMPONENTS; i++) { if (components[i] != NULL) { components_[i]->receive(message); } } } private: static const int MAXCOMPONENTS = 10; Component* components[MAX_COMPONENTS]; }; ``` Now, if a component has access to its container, it can send messages to the container, which will rebroadcast the message to all of the contained components. (That inclues the original component that sent the message; be careful that you don't get stuck in a feedback loop!) This has a couple of consequences:现在,如果一个组件访问它的容器,它能够将信息发送给容器,并且通过容器将信息广播给容器所包含的所有组件。 > 注:If you really want to get fancy, you can even make this message system *queue* messages to be delivered later. For more on this, see Event Queue.如果你真的乐意,你甚至可以将这个消息洗头膏改成成可以延迟发送的队列消息。更多细节请查看事件队列这一个章节。 * *Sibling components are decoupled.* By going through the parent container object, like our shared state alternative, we ensure that the components are still decoupled from each other. With this system, the only coupling they have is the message values themselves.兄弟组件之间是解耦的。就好像我们之前选择状态传递信息的方法一样,我们通过上层容器类来确保组件之间是解耦的。使用传递信息系统的方法,唯一的耦合就是消息本身。 > 注:The Gang of Four call this the Mediator pattern -- two or more objects communicate with each other indirectly by routing the message through an intermediate object. In this case, the container object itself is the mediator.“四人帮”称之为中介模式,两个或两个以上的对象通过将信息传递到一个中介的方法来取得相互之间的联系。而本章节中,容器类则充当了中间的角色。 * *The container object is simple.* Unlike using shared state where the container object itself owns and knows about data used by the components, here, all it does is blindly pass the messages along. That can be useful for letting two components pass very domain-specific information between themselves without having that bleed into the container object.容器对象十分简单。不像状态共享那样容器类能够获知应该传递给组件的信息,在这里,容器类的工作只是将信息发送出去。这对两个类之间传递非常特定的信息而不让容器类获知来说是个非常有用的方法。 Unsurprisingly, there's no one best answer here. What you'll likely end up doing is using a bit of all of them. Shared state is useful for the really basic stuff that you can take for granted that every object has -- things like position and size.意料之外的 是,没有那个选择是最好的。你最终有可能将上述所说的三种方法都使用到。状态共享对每个对象都拥有的基本状态如位置 和尺寸等非常管用。 Some domains are distinct but still closely related. Think animation and rendering, user input and AI, or physics and collision. If you have separate components for each half of those pairs, you may find it easiest to just let them know directly about their other half.有些域虽然不同但是仍然紧密相关。就比如说动画和渲染,用户输入与AI,又或者物理与碰撞。如果你 有上述这些强关联的组件的话,最简单的方法就是在他们之间建立直接的联系。 Messaging is useful for "less important" communication. Its fire-and-forget nature is a good fit for things like having an audio component play a sound when a physics component sends a message that the object has collided with something.消息传 递是个对“不太重要”的通信有用的机制。其“即发即弃”的特性非常适合类似于当对象与物体发生碰撞时,物理组件想让声音组 件发出一个声音的这种行为。 As always, I recommend you start simple and then add in additional communication paths if you need them.与往常一样,我 建议你从简单的开始,然后在你需要组件通信的时候再考虑应该添加那种信息传递的方法。 The Unity framework's core GameObject class is designed entirely around components.整个游戏类的核心框架完全由组 件设计完成。 The open source Delta3D engine has a base GameActor class that implements this pattern with the appropriately named ActorComponent base class.开源引擎Delta3D 有一个实现了这种设计模式的基类GameActor和一个 ActorComponent的基类。 Microsoft's XNA game framework comes with a core Game class. It owns a collection of GameComponent objects. Where our example uses components at the individual game entity level, XNA implements the pattern at the level of the main game object itself, but the purpose is the same.微软的XNA游戏框架附带了一个核心游戏类。它拥有一系列游戏组件对 象。本文中的举例是在单个游戏层面上使用组件,而XNA则实现了主要游戏对象的设计模式,但是本质是一样的。 This pattern bears resemblance to the Gang of Four's Strategy pattern. Both patterns are about taking part of an object's behavior and delegating it to a separate subordinate object. The difference is that with the Strategy pattern, the separate "strategy" object is usually stateless -- it encapsulates an algorithm, but no data. It defines how an object behaves, but not what it is.这种设计模式与“四人帮”中的战略模式很类似。都是通过将对象的行为委托给一个独立的从对 象。不同的是战略模式的“战略”对象通常都是无状态的,它封装了一个算法,但是没有数据。它定义了一个对象的行为 方式,而不是对象本身。 Components are a bit more self-important. They often hold state that describes the object and helps define its actual identity. However, the line may blur. You may have some components that don't need any local state. In that case, you're free to use the same component instance across multiple container objects. At that point, it really is behaving more akin to a strategy.组件是有点高傲。它们经常保持状态,描述和定义对象。然而,这可能有点模糊。你可能有一些 不知道任何状态的组件。在这种情况下,你可以在跨多个容器对象的情况下使用相同的组件实例。在这一点上,它的确 表现的像是一个策略对象。 See Also 另请参阅 对消息或事件的发送与受理进行时间上的解耦。 除非你生活在那些少有的几个脱离互联网的地区里,否则你很可能已经对“事件队列”有所耳闻。如果对这个词不熟悉, 那么 也许“消息队列”,“事件循环”或者“消息泵”能令你想起些什么。为了让你的记忆更清晰,让我们先一起来看看这一模式的两个 常见应用吧。 在本章中我将“事件”和“消息”替换着使用,如果需要区分它们我会另外提醒大家。 如果你曾从事过用户界面编程,那你肯定对事件不陌生了。每当用户与你的程序交互时:比如点击按钮,下拉菜单,或者按 下一个键,操作系统都会生成一个事件。系统将这个事件对象抛给你的应用程序,你的任务就是获取到这些事件并将其与对 应的自定义行为挂钩。 这种应用程序风格很常见,它被视为一种程序样式:事件驱动式编程。 为了能收到这些事件,在你代码的底层设施中必然有个事件循环。它的大致结构如下: while (running){ Event event = getNextEvent(); // Handle event...} 对 getNextEvent() 的调用为你的应用程序拽来了一大把未经处理的用户输入。你将它导向一个事件句柄,于是你的应用程序 魔法般地活了起来。有趣的地方在于应用程序会在它需要时才引入事件,操作系统并不在用户操作外设时就立即跳转入你的 程序内部。 类似地,操作系统的中断也是这样运转的。当中断发生时,操作系统终止你应用程序的一切运转,并强制让程序跳转入一个 中断处理句柄中。这样粗野的做法也正是中断之所以难处理的缘故。 这意味着当用户的输入到来时,必须要有个位置安置这些输入,以防它们在硬件报告输入时与你的应用程序调 用 getNextEvent() 期间被操作系统漏掉。这里所谓的“安置位置”正是一个队列。 当用户输入到来时,操作系统将它添加到一个未处理事件队列中。当你调用 getNextEvent() 时,函数会将最早的事件取出并 将它交给你的应用程序。 多数游戏的事件驱动机制并非如此,当然一个游戏维护它自身的事件队列作为其神经系统的主心骨这是很常见的。你将常常 听到“中心式“,“全局的”,“主要的”这类对它的描述。它被用于那些希望保持模块间低耦合的游戏,起到游戏内部高级通讯模 块的作用。 如果你想知道为何他们不是事件驱动的,可以打开Game Loop这一章节看看。 假设你的游戏有一个新手教程,在完成指定的游戏内事件后弹出帮助框。例如,玩家首次击败一个蠢怪物,你希望弹出一个 小气球框上面写着“按下X以拾取战利品”。 新手教程系统往往是优雅继承设计的硬伤,而且多数玩家仅会在系统帮助上花去极少的时间,于是这看起来吃力不讨好。然 事件队列 目的 动机 用户图形界面的事件循环 中心事件总线 而这短暂的引导时间却是将玩家导入你游戏的宝贵机会。 你的游戏玩法以及战斗相关的代码会很复杂。最后你想做的就是往这些复杂的代码里塞入一系列检查以用于触发引导。当然 你可以用一个中心事件队列来取而代之。游戏的任何一个系统都可以向它发送事件,于是战斗模块的代码可以在你每次消灭 一个敌人后向该队列添加一个“敌人死亡”的事件。 相似地,游戏的任一系统都能从队列中收到事件。新手引导模块向事件队列注册自身,并向其声明该模块希望接收“敌人死 亡”事件。借此,敌人死亡的消息可以在战斗系统和新手引导模块不进行直接交互的情况下在两者之间传递。 这个共享空间能够让实体向其发送消息并能收到它的通知,这一模式与AI领域的平blackboard systems有相似之处。 我本想将此作为本章后续的一个例子,但实际上我并不对大型全局系统很感兴趣。事件队列所负责的通讯并不一定要横跨整 个游戏引擎,它也可以仅在一个类或一定范围内发挥作用。 所以来说说别的,让我们往游戏中加入音乐。人类是强视觉化的动物,而听觉则将我们与自身情感以及对物理空间的知觉深 刻地联系在一起。恰当的回音模拟可以让漆黑的屏幕有巨大洞穴的感觉,而一段时机恰当的抒情小提琴旋律会拨动你的心弦 令你产生共鸣而随之轻声哼唱。 为了让我们的游戏在音乐方面有突出的表现,我们从最简易的方法入手来看看它是如何运作的。我们将向游戏中添加一个小 的“音效引擎”,它包含根据标识和音量来播放音乐的API: 由于我总是回避Singleton模式——这是一种可行的方案,好比一台机箱只配一副喇叭那样。我将采取一个更简单的方法:仅 仅将方法声明为静态: class Audio{public: static void playSound(SoundId id, int volume);}; 这个类要做的是,加载恰当的声音资源,找到可用的声道来提供播放,并开始将它播放出来。本文与具体平台的音效API无 关,所以我任意采用一个,你可以假设它适用于任何平台。借此我们的方法可以实现如下: void Audio::playSound(SoundId id, int volume){ ResourceId resource = loadSound(id); int channel = findOpenChannel(); if (channel == -1) return; startSound(resource, channel, volume);} 我们检查音效,并创建一些声音文件,并在游戏代码种加入少量的 playSound() 调用进行播放,它们就像一些带着魔法的小 喇叭。例如在UI代码中,当菜单的选中项改变时我们播放一个小音效: class Menu{public: void onSelect(int index) { Audio::playSound(SOUND_BLOOP, VOL_MAX); // Other stuff... }}; 在此之后,我们注意到有时切换菜单项时,整个屏幕会卡顿几帧,这便触及了我们的第一个话题: 问题1:在音效引擎完全处理完播放请求前,API的调用一直阻塞着调用者。 我们的 playSound() 方法是同步执行的:它只有在音效被完全播放出来后才会返回至调用者的代码。假如一个声音文件需要 先从磁盘中加载,那么这次调用就要花去一些时间。此时游戏的其他部分便都卡住了。 现在我们暂时不考虑它,继续往下看。在人工智能代码中,我们增加一个调用可以让玩家攻击敌人造成伤害时发出痛苦的哀 号声。 没有比模拟生命遭受伤害更能温暖一个玩家的心了。 说些啥好呢 它会执行,但有时同一帧中,英雄猛烈攻击两个敌人的情况发生。这就引起游戏同时发出两次哀号声。如果你了解一些音效 知识,那你就会知道多个声音混合在一起会叠加它们的声波。也就是说,当遇到相同的声波时,声音听起来和一个声音一 样,但声量会大两倍。 在亨利海茨沃斯大冒险游戏中偶然遇到该情况。和我们刚才的解决方案是类似的。 和boss战斗中,有许多小喽啰跑来跑去会引起冲突,也会遇到相关问题。硬件一次只能播放这么多声音。当我们超过那个临 界值以后,声音听起来要么没有要么会中断。 为了处理这些问题,我们需要观察整个的声音集合,并加以汇总和区分。不幸的是,我们的声音API 每次单独处理一 个 playSound() 函数。看起来像是请求一次一个地穿过针孔。 问题2:不能一起处理请求 跟我们遇到的下个问题相比有一点烦恼.目前,代码库中在许多不同的游戏系统中到处调用 playSound() 函数.但是我们的游戏引 擎运行在现代多核硬件上面.为了充分利用上多核,我们分配它们在不同的线程中--一个渲染,另一个执行人工智能,等等. 由于我们的API是同步的,会打开调用者的线程. 从不同的游戏系统中调用它时,会多线程同步的调用API.看示例代码.看见任何 的线程同步了吗?反正我没有看见. 特别惊人的是,我们试图有一个单独的声音线程.当其他的线程互相忙碌和破坏一些事情时,它会一直空闲下去. 问题3:请求被执行在错误的线程 这些问题的共同点是声音引擎调用 playSound() 函数的意思是"放下所有事情,马上播放音乐!"马上处理就是问题.其他游戏系统 在它们合适的时候调用 playSound() 函数,而声音引擎不是必须要处理这个需求.为了修复这一情况,我们会在处理接受请求中解 耦. 队列按照先进先出的顺序存储一串通知或者请求.发送一个请求入列然后返回通知.请求处理器稍后会从队列中处理该项目. 请 求会直接处理掉或者转交给对它感兴趣的部分.静态且及时的解耦接受者和发送者 如果你只想对谁从发送者接受信息解耦,模式类似于观察者和命令,减少复杂性.想要及时解耦某事的时候,只需要一个队列即可. 最近的每章节中我都有提到这个模式,但是它是值得强调的.复杂性会让你慢下来,所以遇到简洁的时候会是一个非常宝贵的资 源. 按照推拉的方式思考.代码A打算另一个代码块B做一些事情.自然的方式是通过推给请求给B来让A初始化. 同时,对B的自然方式是通过在它们运行周期中方便的时候拉处理请求.当你在一端推此模型和另一端拉此模型,在两者之间需要 一个缓冲.这就是队列能提供的简单解耦模型. 队列会控制被拉进来的代码--接受者会延迟处理,集合请求或者全部废除.但是队 列把控制权从发送者撤离.全部的发送者希望给队列扔一个请求.发送者需要相应的时候,会让队列响应不是很好. 不像本书中其他更温和的模式,事件队列会更复杂一些和让你对游戏框架有了广泛深远的影响.也就意味着你会弄明白它如何-- 如果的话--你使用它. 该模式普遍的作用是中央车站,游戏的所有部分可以传递消息.它是游戏中强大的结构,但是强大通常不意味着不错. 需要花费一些时间,但是我们大部分会认为全局变量是不好的.当你有程序任何部分的状态时,各种各种细小部分不知不觉的互 (事件队列)模式 使用情境 使用须知 中心事件队列是个全局变量 相依赖.模式封装这些状态成为一种不错的饿小协议,但仍然是全局性的,需要伴有危险性. 当一个虚拟宠物摆脱他的烦恼时,人工智能代码会布置一种"实体死亡"事件给队列.这是事件不知多少帧在队列中闲置,直到最终 调到前面得到处理工作起来. 与此同时,经验系统想要记录女英雄身体数量和奖励它可怕的效率.它会收到每个"实体死亡"事件和决定某种的实体死亡,根据杀 死难度,来分发合适的奖励. 世界需要不同种类的状态.我们需要实体死亡.所以我们能看见它是多么的粗糙.我们可能想要检查周围,看看附近其他的障碍或 宠物.但如果事件到后来没有被接收到,那么物品会消失.实体可能会解除分配,其他附近的敌人也会分散. 当你接受一个事件,你会非常小心不要假设当前世界的状态怎样反射的世界是什么时候事件会发生.这就意味着队列事件视图比 同步系统中的事件更多沉重的数据.稍后,通知会说"某事发生了"和接受者四周看看细节.对于队列,这些细节当事件发送稍后会 被用到的必须要捕捉. 全部的事件和消息系统需要关注以下周期: 1. A发送一个事件. 2. B 接收它,之后发送一个响应事件. 3. 事件发生在A关心的地方,所以接收它.A也会发送一个响应事件... 4. 见2. 你的消息系统是同步的,你会发现周期非常快--它们会栈溢出造成游戏崩溃.对于队列来说,异步的放开栈处理,即使假的事件会 前前后后冲击,但游戏会依然运行.一个通常的规律来避免这些避免发送事件的代码来处理. 在事件系统中使用一个很小的调试 日志也会是一个不错的主意. 我们已经见到一些代码.不是很完美,但是有基本的正确功能-- 有我们想要的公用API和正确的低级别声音调用.现在剩下我们做 的事情就是要修复这些问题. 首先我们的API会阻塞.当一段代码播放声音时,不可以做任何事情,直到 playSound() 函数加载完资源后,实际上会让扬声器响 动. 我们想推迟工作这样 playSound() 可以快速返回.为了做到这些,我们需要具体化需求来播放声音.我们需要一些结构来存储 等待期间的请求.这样稍后可以让他们保持活动. struct PlayMessage { SoundId id; int volume; }; 接下来,我们需要给 音频 一些空间好让它可以追踪这些播放的消息.现在,你的算法老师可能会告诉你用一些令人兴奋的数据结 构.比如斐波那契或者跳跃列表.实在不行,至少链表也行.但实践中,存储一群同类视图,最佳方式是,几乎通常的做法是简单的数 组: Algorithm researchers get paid to publish analyses of novel data structures. They aren't exactly incentivized to stick to the basics. 无动态分配. 没有为记录信息的存储开销或指针. 可缓存的连续存储空间. 游戏世界的状态任你掌控 你会停滞在反馈系统循环 示例 关于"可缓存"的更多信息,查看数据局部性章节 我们这样做: class Audio { public: static void init() { numPending_ = 0; } // Other stuff... private: static const int MAX_PENDING = 16; static PlayMessage pending_[MAX_PENDING]; static int numPending_; }; 调节数组的大小来覆盖我们最坏情况.为了播放声音,我们简单的在结束的位置放置一个新的消息: void Audio::playSound(SoundId id, int volume) { assert(numPending_ < MAX_PENDING); pending_[numPending_].id = id; pending_[numPending_].volume = volume; numPending_++; } 让 playSound() 函数几乎马上返回,但仍然需要播放音乐,当然,这段代码需要在某处运行,而且是一个 update() 方法: class Audio { public: static void update() { for (int i = 0; i < numPending_; i++) { ResourceId resource = loadSound(pending_[i].id); int channel = findOpenChannel(); if (channel == -1) return; startSound(resource, channel, pending_[i].volume); } numPending_ = 0; } // Other stuff... }; 正如名字表明的一样,这是更新方法模式 现在,我们需要在某处适时的调用它,适时意味着它依赖游戏.它在主要的游戏循环或者一个专用的声音线程调用. 它运行的很好,但很难推测可以单独调用 update() 函数,执行每一个声音请求. 如果你做一些类似于声音资源加载后,异步处理 请求的事情,它就不会运行.对于 update() 函数一次运行在一个请求上,它需要从剩下的缓冲区中拉出请求.换句话说,我们需要 一个真实的队列. 有很多方法可以实现队列,但我最喜欢的是环状缓冲区它用数组来保存所有的事情.可以让我们对前面的队列递增的移动元素. 现在,我知道你在想什么.如果我们从数组的开始移动元素,难道不会移动剩下所有的元素吗?不会很慢吗? 环状缓冲区 这是为什么让我们学习链表的原因-- 你可以移动节点,但没有移动周围的任何元素.很好,这表明你可以在数组中实现一个队列 也没有移动周围的任何元素.我会带你了解它,但首先让我们明确一些术语: 队列的头部是请求读取的地方.头部中存储的是最老的请求. 队列的尾巴是另一端. 是下一个排队请求写入的位置.注意仅仅是越过队列的结束.如果有帮助的话,你可以认为它相当于一 个半开的范围. 由于 playSound() 会在数组的结束追加新的需求,头部下标以0开始, 向右增长. 让代码来展示.首先,我们在类中清楚地声明字段,两个清楚的标志: class Audio { public: static void init() { head_ = 0; tail_ = 0; } // Methods... private: static int head_; static int tail_; // Array... }; 在 playSound() 函数实现中, numPending_ 被替换成 tail_ , 其他地方是一样的: void Audio::playSound(SoundId id, int volume) { assert(tail_ < MAX_PENDING); // Add to the end of the list. pending_[tail_].id = id; pending_[tail_].volume = volume; tail_++; } 更有趣的变化在 update() 函数: void Audio::update() { // If there are no pending requests, do nothing. if (head_ == tail_) return; ResourceId resource = loadSound(pending_[head_].id); int channel = findOpenChannel(); if (channel == -1) return; startSound(resource, channel, pending_[head_].volume); head_++; } 我们会处理队列头部,通过移动头指针来废弃它。通过观察头到尾部是否有任何距离来检测空序列。 这就是为什么我们让尾巴经过最后一项。如果头和尾拥有相同的索引,意味着队列就是空的。 现在我们的有了一个队列--我们可以从尾部增加元素然后从头部移除。那么有一个明显的问题。当我们通过队列运行请求 时,头部和尾部慢慢向右边移动。最终, tail_ 到达数组的最后,然后派对时间就结束了。这就是聪明的地方。 你想要派对时间结束吗?不,你不想。 注意尾部一直向前移动,头部也是。这就意味着得到的数组开始元素就不能再使用了。当移动到最后我们要做的就是把尾部 到绕回到头部。这就是为什么叫做环状缓冲区--它扮演类似一个圆形细胞阵列。 实现它是非常容易。当我们入列一个项目,仅需要确认到达底部时绕回到数组的开始: void Audio::playSound(SoundId id, int volume) { assert((tail_ + 1) % MAX_PENDING != head_); // Add to the end of the list. pending_[tail_].id = id; pending_[tail_].volume = volume; tail_ = (tail_ + 1) % MAX_PENDING; } 用增量模数组的数组大小,尾部绕回来代替 tail_++ 。其他改变的额地方时断言。我们需要保证队列不能溢出。一旦出现大 于 MAX_PENDING 队列请求时,就会出现头部到位之间的未使用单元的间隙。如果队列填补进来,那它就会消失。像一些奇怪 的倒退毒蛇,尾巴会和头部冲突进而开始覆盖掉它。声明保证了该情况不会发生。 在 update() 函数中,我们同样对头部做了封装: void Audio::update() { // If there are no pending requests, do nothing. if (head_ == tail_) return; ResourceId resource = loadSound(pending_[head_].id); int channel = findOpenChannel(); if (channel == -1) return; startSound(resource, channel, pending_[head_].volume); head_ = (head_ + 1) % MAX_PENDING; } 这下你应该懂了--一个队列是没有动态分配,没有向周围拷贝元素,可缓存性的一个简单数组。 如果最大容量会有问题,你可以使用可增长的数组。当队列满了以后,分配一个新的数组,大小是当前数组的二倍(或其他 的倍数),之后把剩下的项目拷贝过去。 即使在数组增长的时候拷贝,入列一个元素仍然有常数均摊的复杂性。 现在我们已经有了一个队列, 可以移到其他的问题上。第一个是多个请求要播放相同的音乐会很大声。原因我们知道,请求正 等待着处理,我们需要做的是如果匹配到一个已经等待的请求,就合并它: void Audio::playSound(SoundId id, int volume) { // Walk the pending requests. for (int i = head_; i != tail_; i = (i + 1) % MAX_PENDING) { if (pending_[i].id == id) { // Use the larger of the two volumes. pending_[i].volume = max(volume, pending_[i].volume); // Don't need to enqueue. 汇总请求 return; } } // Previous code... } 当我们得到两个请求播放相同的音乐时,拆散他们为一个单独的请求,按两者中声音最大为准。“汇总”是相当初步的,但我们 可以用同样的想法批量处理做更多有趣的事情。 注意当请求入列时才合并它,而不是处理时。对于队列来说简单的多,因为不会浪费在多余的请求上,而导致稍后的崩溃结 束。非常简单的实现它。 地确很容易实现,但是,会给调用者增加处理负担。调用 playSound() 返回之前会遍历全部的队列,一旦队列非常大,就会 很慢。可能使用 update() 函数汇总请求会更有意义。 另一种避免O(n) 扫描成本的方式是用一个不同的数据结构,如果我们对 SoundId 使用哈希表,之后就可以快速检查重复。 这里有一些重要的事情要记住。我们可以汇总“同步发生”的窗口请求只和队列一般大小。如果我们更快的处理请求,队列尺 寸保持很小,那么我们有较少的机会可以一起批量处理请求。同样,如果处理请求滞后,队列充满,我们将会发现更多的崩 溃事情。 这种模式从知道何时会处理请求时,隔离请求。但是当你把整个队列作为一个动态的数据结构去操作时,提出请求和处理请 求之间会发生滞后。它可以明显影响行为。所以,确认这么做之前你做好了准备。 最后,最严重的问题。出现在同步音频API,无论什么线程调用 playSound() 函数,线程就会处理该请求。这通常不是我们想 要的。 在今天的多核硬件时代,如果你想最大利用你的芯片,需要不止一个线程。有无穷种方式可以分发代码跨越线程,但是一个普 遍的策略是在它自己的线程移动游戏的各个领域--声音,渲染,人工智能等等。 一行代码一次只能运行在单核上面。如果你不使用线程,即使你用正时兴的异步编程,这是发挥CPU的能力的一小部分。最 好的你做的是保持一个单核忙碌。 服务器程序员通过把他们的应用程序分解为多个独立的进程来弥补单核忙碌忙碌的情况。这就让操作系统可以同步运行在不 同的核上。游戏大部分通常是单进程,所以使用一些线程真的会有帮助。 良好的情形下,现在我们有三个关键部分: 1. 请求声音的代码可以和播放声音解耦。 2. 两者之间有一个队列来封送处理。 3. 队列封装程序的其余部分。 所有剩下做的事情是修改队列 playSound() 函数和 update() 函数--线程安全。一般,我会用一些具体的代码来实现,但由于 这是一本关于框架的书,所以我不打算陷入   任何特定的API或锁定机制的细节。 站在高级别来看,所有我们需要做的是要保证队列不被同步修改。因为 playSound() 函数做非常小量的工作--基本上分配一些 字段--可以锁它,也不长时间的阻塞处理。在 update() 函数中,我们等待条件变量所以我们不消耗CPU周期,直到有一个请求 处理。 许多游戏使用事件队列作为沟通的一个关键部分的结构,你可以花大量的时间来设计各种复杂的路由和过滤消息。建立类似 于洛杉矶电话交换机,但在你进行之前,我鼓励你简单开始。这里有几个起初思考的问题: 跨越线程 设计决策 什么会在队列中? 迄今为止,“事件”和“消息”总是替换着使用,它主要是没关系。队列中你填塞什么,都会得到相同的解耦和汇总能力,但仍然 有一些概念上的不同。 如果队列中是事件: 一个“事件”或“通知描述已经发生的事情。你排队,其他对象可以响应事件”,有几分像一个异步的观察者模式。 你可能会允许多个监听器. 由于队列包含的事件已经发生,发送者不关心谁会接收到它。从这个角度来看,这个事件 在过去已经被忘记了。 队列往往有更广的边界 。事件队列经常用于给任何和所有感兴趣部分广播事件。为了允许感兴趣的部分有最大的灵 活性,这些队列往往有更多的全局可见性。 如果队列中是消息: 一个“消息”或“请求”描述一种想要发生在将来的行为,类似于“播放音乐”。你可以认为这是的一个异步API服务。 另一种关于“请求”是“命令”的表达在命令模式 中,也可以使用队列。 你更可能有个单一的监听器。 示例中,消息排队专门为音频API 播放声音请求。如果其他游戏的随机部分开始从队列中偷 窃消息,它不会工作的很好。 我说过它们“更相似”,是因为你可以入列消息,只要期望它如何处理,而不关心哪些代码会处理它。这种情况下,你做的事 情类似于服务定位器。 在我们的示例中,队列被封装和只有 Audio 类可以读取它。用户接口的事件系统中,你可以尽情的注册监听器。有时会得知 单播和广播去分发这些,所有的方式都是有用的。 一个单播队列: 当一个队列是一个类的API本身的一部分时。类似我们的声音示例,站在调用者的角度,他们只能看见 playSound() 方法 能调用。 队列成为阅读器的实现细节 所有的发送者知道是它发送了一条消息。 队列是封装的 所有其他条件相同的情况下,更多的封装通常是更好的。 你不必担心和多个监听器的竞争情况,你不得不决定他们是否全部得到每一项(广播)或者是否每一个队列中的项 分配给一个监听器(更像一个工作队列) 在其他情况,监听器可能会做重复的工作或者互相干扰,所以必须仔细思考你想要做的。对于一个单一的监听器, 这种复杂性会消失 广播队列: 这是大多数“事件”系统的做的事情。当一个事件进来时,如果你有十个监听器,它们都能看见该事件。 事件可以被删除 先前观点的推论是如果你有零个监听器,所有都会看见事件。在大多数的广播系统中,如果某一时 刻处理事件没有监听器,事件就会被废弃。 可能需要过滤事件 播放队列通常是广泛的可见的程序,你可以关闭一些监听器。多事件需要多个监听器,结束大量的 事件处理程序调用。 为了缩减规模,大部分广播事件系统会让一个监听器过滤他们收到的事件集合。例如,它们会说他们想要接受鼠标 事件或者用户界面一定区域内的UI事件。 工作队列: 谁能从队列读取? 类似于一个广播队列,此时你也有多个监听器。不同的是队列中的每一项只能得到一个。这是一种常见,分配的工作模 式额,并发运行的线程池。 你必须安排. 因为一个项目只有一个监听器,队列逻辑需要找出最好的选择。这可能是简单循环或随机选择,或者是 一些更复杂的优先级系统。 这是以前设计选择的另一面。该模式适用于所有可能的读/写配置:一对一,一对多,多对一,多对多。 你有时会听说用于描述多对一的“输入端”通信系统和用于描述一对多的“输出端”通信系统。 一个写入者: 这种风格是最类似于同步观察者模式。你有一个特权对象可以接收对象,生成事件。 你心里知道事件来自哪里 因为只有一个对象可以增加到队列,任何监听器可以安全地假设是发送者。 通常允许多个读取者 你有一个发送一个接收的队列,但是,开始觉得这一模式不太像通信系统,更像是一个香草队列 数据结构。 多个写入者: 这是我们的音频引擎如何工作的的例子。因为 playSound() 函数是一个公共方法,任何代码库部分都可以添加一个请求 给队列。“全局”或“集中”事件总线也是这样工作。 你必须小心周期 因为任何东西都可能放到队列中,处理时间期间很容易突然入列一些东西。如果你不小心,可能会 出发反馈循环。 你可能会想要一些发送方在事件本身的引用 当监听器得到一个事件,它不知道是谁发送的,因为它可能是任何人。 如果这是他们需要知道的,你要打包进事件对象,监听器就可以使用它了。 周期有一种同步的通知,执行不会返回给发送者,直到所有的消息处理完毕。这就意味着消息本身可以安全的存在栈中的本 地变量中。对于一个队列,消息入列后仍然可以调用它。 如果你使用一个垃圾回收机制的语言,你不需要过多担心这个。填满队列中的消息,只要是必要的时候就会逗留在内存里。 C或者C++中,它取决于你保证对象存活时间足够的长。 转移所有权: 手动管理内存,这是一种传统的方法。当一个消息排队时,队列发出声称,发送者不再拥有它。当消息处理时,接收者 取走所有权并负责释放它。 C++中,`unique_ptr` 解释了非常确切的语义。 分享所有权: 目前即使C++程序员适合垃圾回收,但分享所有权会更容易接受。这样一来,只要任何事情对它有一个引用,消息就会 逗留。当被忘记时就会自动释放。 同样地,C++类型中针对分享所有权的是 shared_ptr . 队列拥有它: 另一个观点是消息总是存在队列中。不用自己释放消息,发送者会从队列中请求一个新的消息。队列返回一个已经存在 于队列内存的消息引用,接着发送者会填充它。消息处理时,接收者参考队列中相同消息的操作。 谁可以写入队列? 队列中对象的生命周期是什么? 换句话说,支持该存储队列的是一个[对象池](object-pool.html)。 我已经提到事件队列许多次了,但在很多方面,这个模式可以看成是一个熟知的观察者模式的异步姊妹。 和很多模式一样,事件队列有过一系列的术语。刚创建时期叫做“消息队列”。通常在更高层次的表现会提到。当事件队 列用于一个应用程序时,消息队列总是用于之间的通信。 另一个术语是“发布/订阅”,有时缩写为“订阅”。类似于“消息队列”,它总是在大型分布式系统中提及,而不专用于简陋的 编码模式中。 一个有限状态机, 类似于四人帮的 状态模式,需要一个输入流。如果你想要异步地响应它们,把他们入列就好。 当你有一 堆状态机互相发送消息的时候,每个都有一个小的队列等待输入(称为邮箱),之后你重新发明了计算角色模式。 Go编程语言内置“通道”类型 ,本质上是一个事件或消息队列。 参考 Provide a global point of access to a service without coupling users to the concrete class that implements it. 提供一个全局的访问服务的指针,并且使用者不会和具体实现类耦合 Some objects or systems in a game tend to get around, visiting almost every corner of the codebase. It's hard to find a part of the game that won't need a memory allocator, logging, or random numbers at some point. Systems like those can be thought of as services that need to be available to the entire game. 在游戏里的一些系统或者对象倾向于到处被使用,在代码库的每个角落都能看见。很难在游戏中找到某个部分, 不会使用内 存分配器,记录日志,或者获取随机数。想这样的系统可以考虑作为一个服务,时能整个游戏都能够访问。 For our example, we'll consider audio. It doesn't have quite the reach of something lower-level like a memory allocator, but it still touches a bunch of game systems. A falling rock hits the ground with a crash (physics). A sniper NPC fires his rifle and a shot rings out (AI). The user selects a menu item with a beep of confirmation (user interface). 对我们而言,我举音频系统作为一个例子。它不会想内存分配器那么底层,但是任然要访问一批系统。掉落的石块碰到 地面 上(物理系统)。一个狙击NPC开枪,发出短暂的枪声(AI系统)。用户选择一个菜单,伴随一个确认的音效(用户交互系统)。 Each of these places will need to be able to call into the audio system with something like one of these: 每个这样的地方都需要想这样调用音频系统: // Use a static class? AudioSystem::playSound(VERY_LOUD_BANG); // Or maybe a singleton? AudioSystem::instance()->playSound(VERY_LOUD_BANG); Either gets us where we're trying to go, but we stumbled into some sticky coupling along the way. Every place in the game calling into our audio system directly references the concrete AudioSystem class and the mechanism for accessing it -- either as a static class or a singleton. 尽管我们到达了我们想要的目的,但是我们是在耦合的情况下一路走过来。在游戏每个调用音频系统的地方,都直接访问了 具体的 AudioSystem 类和访问它的机制——要么作为一个静态类或者一个单件 These call sites, of course, have to be coupled to something in order to make a sound play, but letting them poke at the concrete audio implementation directly is like giving a hundred strangers directions to your house just so they can drop a letter on your doorstep. Not only is it a little bit too personal, it's a real pain when you move and you have to tell each person the new directions. 这些调用的地方,为了能够播放声音,都和某些东西耦合起来了,但是让它们能够直接操作具体的音频实现类就像为了 能够 让信箱的信投递出去而让一百个陌生人能直接进入你家。不仅是这有点太私人了,而且当你搬家你必须高数每个 人你的新地 址,这有点太痛苦了。 There's a better solution: a phone book. People that need to get in touch with us can look us up by name and get our current address. When we move, we tell the phone company. They update the book, and everyone gets the new address. In fact, we don't even need to give out our real address at all. We can list a P.O. box or some other "representation" of ourselves instead. By having callers go through the book to find us, we have a convenient single place where we control Service Locator 服务定位器 Intent 目的 Motivation 动机 how we're found. 这里有个更好的解决办法:一个电话薄。每个想要联系你的人能够通过名字查看来得到新的地址。当我们搬家时,我们 能够 告诉电话公司。它们更新电话薄,这样每个人都能得到新的地址了。世界上,我们甚至不必给出我们真正的地址。 我们能够 列出一个P.O信箱,或者其他能够“代表”我们的东西。通过有一个扎到电话薄来联系我们的访问者,我们能够 方便的单独我们 控制的能够查找的地方。 This is the Service Locator pattern in a nutshell -- it decouples code that needs a service from both who it is (the concrete implementation type) and where it is (how we get to the instance of it). 这就是服务定位器的简单介绍——它将一个服务的“是什么”和“在什么地方”与需要这个服务的代码解耦了。 A service class defines an abstract interface to a set of operations. A concrete service provider implements this interface. A separate service locator provides access to the service by finding an appropriate provider while hiding both the provider's concrete type and the process used to locate it. 一个服务类为一系列操作定义了一个抽象的接口。一个具体的服务提供者实现这个接口。一个单独的 服务定位器通过查找一 个合适的提供者来提供这个服务的访问。它同时屏蔽了提供者的具体类型和定位这个服务的过程。 Anytime you make something accessible to every part of your program, you're asking for trouble. That's the main problem with the Singleton pattern, and this pattern is no different. My simplest advice for when to use a service locator is: sparingly. 每当你将东西变得全局都能访问的时候,你就是在自找麻烦。这就是单件模式主要的问题,但是这个模式不同。我对何时使 用服务定 位器的简单建议就是:谨慎地使用。 Instead of using a global mechanism to give some code access to an object it needs, first consider passing the object to it instead. That's dead simple, and it makes the coupling completely obvious. That will cover most of your needs. 与提供一个全局机制来给需要使用的地方去访问一个对象不同,首先考虑将这个对象传递进去。这简单死了,而且明显将代 码 耦合起来了。这件满足你绝大部分需求。 But... there are some times when manually passing around an object is gratuitous or actively makes code harder to read. Some systems, like logging or memory management, shouldn't be part of a module's public API. The parameters to your rendering code should have to do with rendering, not stuff like logging. 但是... 有时手动的将一个对象传来传去显得毫无理由也将代码变得难以阅读。游戏系统,比如日志系统和内存管理系统, 不 应该是某个模块的公开API的一部分。你渲染代码的参数是和渲染相关的,而不是像日志那样的东西。 Likewise, other systems represent facilities that are fundamentally singular in nature. Your game probably only has one audio device or display system that it can talk to. It is an ambient property of the environment, so plumbing it through ten layers of methods just so one deeply nested call can get to it is adding needless complexity to your code. 同样的,也适用于一些单独的代表基础设施的系统。你的游戏很可能只有一个音频设备或者显示体统能够打交道。他是 一项 环境属性,所以将它传递10层以便让一个底层的函数能够访问,为代码增加了毫无必要的复杂度。 In those kinds of cases, this pattern can help. As we'll see, it functions as a more flexible, more configurable cousin of the Singleton pattern. When used well, it can make your codebase more flexible with little runtime cost. 在这些情况下,这个模式能够起到作用。它用起来像一个更有弹性,更可配置的单间模式表亲。当我们良好地使用时, 它能 让你的代码更有弹性,而且几乎没有运行损失。 >Conversely, when used poorly, it carries with it all of the baggage of the Singleton pattern with worse runtime performance. >相反的,当我们使用不当时,它带来了所有单间模式的短板和糟糕的运行开销。 The Pattern 模式 When to Use It 何时使用 The core difficulty with a service locator is that it takes a dependency -- a bit of coupling between two pieces of code -- and defers wiring it up until runtime. This gives you flexibility, but the price you pay is that it's harder to understand what your dependencies are by reading the code. 服务定位器的关键困难在于,它要有所依赖——连接两份代码——并且在运行期才连接起来。这给你弹性,代价是阅读代码 时有点难以理解你依赖的是什么? With a singleton or a static class, there's no chance for the instance we need to not be available. Calling code can take for granted that it's there. But since this pattern has to locate the service, we may need to handle cases where that fails. Fortunately, we'll cover a strategy later to address this and guarantee that we'll always get some service when you need it. 当使用单间或者一个静态类时,我们需要的实例没有机会变得不可用。调用代码就保证了它必须在哪里。但是,既然 这个模 式定位服务,我们必须处理定位失败的情况。幸运的是,我们将讨论一个策略来 处理这个问题,并且保证我们始终在使用的 时候得到某个服务。 Since the locator is globally accessible, any code in the game could be requesting a service and then poking at it. This means that the service must be able to work correctly in any circumstance. For example, a class that expects to be used only during the simulation portion of the game loop and not during rendering may not work as a service -- it wouldn't be able to ensure that it's being used at the right time. So, if a class expects to be used only in a certain context, it's safest to avoid exposing it to the entire world with this pattern. 既然定位器是全局可见的,游戏中的任何代码都有可能请求一个服务然后操作它。这意味着这个服务在任何情况下都 必须正 确工作。举个例子,一个类只应当在游戏循环的仿真部分使用,而不是在渲染期间。就不能当做服务——它不能 保证它在正 确的时机被使用。因此,如果一个类希望只在摸个特性的上下文种被使用,避免用这种模式将它暴露全局 是最安全的。 Getting back to our audio system problem, let's address it by exposing the system to the rest of the codebase through a service locator. 回到我们的音频系统问题,让我们通过服务定位器来讲他暴露给其他部分的代码。 We'll start off with the audio API. This is the interface that our service will be exposing: 我们从音频API开始。这就是我们服务将要暴露的接口: class Audio { public: virtual ~Audio() {} virtual void playSound(int soundID) = 0; virtual void stopSound(int soundID) = 0; virtual void stopAllSounds() = 0; }; A real audio engine would be much more complex than this, of course, but this shows the basic idea. What's important is that it's an abstract interface class with no implementation bound to it. 一个真正的音频引擎将比这个复杂的多,当然,这份代码表达了基本的原理。重要的一点就是它使一个虚 接口类,没有实现 Keep in Mind 牢记于心 The service actually has to be located 服务本身被定位 The service doesn't know who is locating it 服务不知道被谁定位 Sample Code 简单代码: The service 服务 和它绑定。 By itself, our audio interface isn't very useful. We need a concrete implementation. This book isn't about how to write audio code for a game console, so you'll have to imagine there's some actual code in the bodies of these functions, but you get the idea: 仅是自己,我们的音频接口没有什么用处。我们需要一份具体的实现。本书不讨论怎样为一个游戏写音频代码,所以你 只能 想象这些函数体中有一些真正的代码,不过你了解了原理。 class ConsoleAudio : public Audio { public: virtual void playSound(int soundID) { // Play sound using console audio api... } virtual void stopSound(int soundID) { // Stop sound using console audio api... } virtual void stopAllSounds() { // Stop all sounds using console audio api... } }; Now we have an interface and an implementation. The remaining piece is the service locator -- the class that ties the two together. 现在我们有了一个接口和一份实现。剩下的部分就是服务定位器了——这个类将两者绑在一起。 The implementation here is about the simplest kind of service locator you can define: 下面的实现是你能够定义的最简单的服务定位器: class Locator { public: static Audio* getAudio() { return service_; } static void provide(Audio* service) { service_ = service; } private: static Audio* service_; }; >The technique this uses is called dependency injection, an awkward bit of jargon for a very simple idea. Say you have one class that depends on another. In our case, our Locator class needs an instance of the Audio service. Normally, the locator would be responsible for constructing that instance itself. Dependency injection instead says that outside code is responsible for injecting that dependency into the object that needs it. >这里使用的技术叫做依赖注入,~TO-DO~。假设你 有一个类,依赖另外一个。在我们的例子中,我们 的 Locator 类需要 Audio 服务的一个实例。通常,这个定位器应该负责为 自己构建这个实例。依赖注入却说外部 代码应该负责为这个对象注入它所需要的这个依赖。 The static getAudio() function does the locating. We can call it from anywhere in the codebase, and it will give us back an instance of our Audio service to use: The service provider 服务提供者 A simple locator 简单的定位器 简单函数 getAudio() 做定位。我们能再代码的任何地方调用它,它能返回一个 Audio 实例来供我们使用。 Audio *audio = Locator::getAudio(); audio->playSound(VERY_LOUD_BANG); The way it "locates" is very simple -- it relies on some outside code to register a service provider before anything tries to use the service. When the game is starting up, it calls some code like this: 它“定位”的方法十分简单——它依赖一些外围代码在任何使用这个服务之前,注册一个服务提供者。当游戏启动之时, 它调 用类似下面的代码: ConsoleAudio *audio = new ConsoleAudio(); Locator::provide(audio); The key part to notice here is that the code that calls playSound() isn't aware of the concrete ConsoleAudio class; it only knows the abstract Audio interface. Equally important, not even the locator class is coupled to the concrete service provider. The only place in code that knows about the actual concrete class is the initialization code that provides the service. 这里关键需要注意的地方时调用 playSound() 的代码对 ConsoleAudio 具体实现毫不知情。同样重要的是, 甚至是定位器本身 和具体服务提供者也没有联系。代码中唯一知道具体实现类的地方时,提供这个服务的初始化代码。 There's one more level of decoupling here: the Audio interface isn't aware of the fact that it's being accessed in most places through a service locator. As far as it knows, it's just a regular abstract base class. This is useful because it means we can apply this pattern to existing classes that weren't necessarily designed around it. This is in contrast with Singleton, which affects the design of the "service" class itself. 这里有还有更深一层的解耦——通过服务定位器 Audio 接口在绝大数地方不知道自己正在被访问。一旦它知道了, 它就是一 个普通的抽象基类了。这十分有用,因为它意味着我们可以将这个模式应用到一些已经纯在的但并 不是围绕这个来设计的类 上。这和单件有个对比,后者影响了“服务”类本身的设计。 Our implementation so far is certainly simple, and it's pretty flexible too. But it has one big shortcoming: if we try to use the service before a provider has been registered, it returns NULL . If the calling code doesn't check that, we're going to crash the game. 目前为止,我们的实现还很简单,不过也十分灵活。但是它有一个大的缺陷:如果我们尝试在一个服务提供者注册 之前使用 它,它返回一个 NULL 。如果我们的调用代码没有检查这一点,我们的游戏就会崩溃。 >I sometimes hear this called "temporal coupling" -- two separate pieces of code that must be called in the right order for the program to work correctly. All stateful software has some degree of this, but as with other kinds of coupling, reducing temporal coupling makes the codebase easier to manage. >我有时听说这叫“时序耦合”——两份代码必须按正确的顺序调用 来保证程序正确工作。每个状态软件都有不同程度的这个 问题,但是和其他耦合比较起来,消除时序耦合使得代码易于管 理。 Fortunately, there's another design pattern called "Null Object" that we can use to address this. The basic idea is that in places where we would return NULL when we fail to find or create an object, we instead return a special object that implements the same interface as the desired object. Its implementation basically does nothing, but it allows code that receives the object to safely continue on as if it had received a "real" one. 庆幸的是,这里有一个称之为“NULL Object”的模式来解决这个问题。基本的思想是在我们查找或者创建失败, 返 回“NULL”的地方,我们返回一个实现同样接口的特殊对象作为替代。它地实现就是什么也不做,但是它能让 或者这个对象的 代码正确的走下去,就好像它获得了一个“真正的”对象一样。 To use this, we'll define another "null" service provider: 为了使用它,我们定义另外一个“null”服务器。 A null service 空服务 class NullAudio: public Audio { public: virtual void playSound(int soundID) { /* Do nothing. */ } virtual void stopSound(int soundID) { /* Do nothing. */ } virtual void stopAllSounds() { /* Do nothing. */ } }; As you can see, it implements the service interface, but doesn't actually do anything. Now, we change our locator to this: 如你所见,它实现了服务结构,但是实际上什么也不做。现在我们来修改定位器: class Locator { public: static void initialize() { service_ = &nullService_; } static Audio& getAudio() { return *service_; } static void provide(Audio* service) { if (service == NULL) { // Revert to null service. service_ = &nullService_; } else { service_ = service; } } private: static Audio* service_; static NullAudio nullService_; }; >You may notice we're returning the service by reference instead of by pointer now. Since references in C++ are (in theory!) never NULL , returning a reference is a hint to users of the code that they can expect to always get a valid object back. >你 可能注意到现在我们返回一个引用而不是一个指针。因为在C++中(理论上)一个引用永远不可能为 NULL , 返回一个引用可以 提示使用者它可以期望任何时候都返回一个有效的对象。 >The other thing to notice is that we're checking for NULL in the provide() function instead of checking for the accessor. That requires us to call initialize() early on to make sure that the locator initially correctly defaults to the null provider. In return, it moves the branch out of getAudio() , which will save us a couple of cycles every time the service is accessed. >另外需要注意的地方时,我们在 provide() 函数中检查是否 为 NULL 不不是在访问者中检查。这 要求我们尽早的调用 initialize() 函数来保证定位器正确的初始化,默认指向空服务 器。作为回报,它将 这个分支从 getAudio() 中移开,为我们每次访问服务器节省了几次CPU循环周期。 Calling code will never know that a "real" service wasn't found, nor does it have to worry about handling NULL . It's guaranteed to always get back a valid object. 调用代码永远也不会知道一个“真”的服务器没有找到,它也不必担心处理 NULL 。它保证始终返回一个有效的对象。 This is also useful for intentionally failing to find services. If we want to disable a system temporarily, we now have an easy way to do so: simply don't register a provider for the service, and the locator will default to a null provider. 这也在有意的查找服务失败时有用。如果我们想要暂时的禁用一个系统,我们能够轻易的做到——简单的不为 这个服务注册 服务器,然后定位器将默认返回一个空服务器。 >Turning off audio is handy during development. It frees up some memory and CPU cycles. More importantly, when you break into a debugger just as a loud sound starts playing, it saves you from having your eardrums shredded. There's nothing like twenty milliseconds of a scream sound effect looping at full volume to get your blood flowing in the morning. > 在开发过程中关闭音频是很便利的,它节约了一些内存和CPU周期。更重要的时,但你断进调试器的时候, 他会播发一个巨 大的声音,它能防止你的耳膜破裂。再也没有什么在早晨能比20毫秒的一个满音量的音效尖叫让你的血液涌动了。 Logging decorator 日志装饰器 Now that our system is pretty robust, let's discuss another refinement this pattern lets us do -- decorated services. I'll explain with an example. 现在我们的系统十分强健,让我们讨论另外一项这个模式的优雅之处——装修服务器。我将举个例子做说明。 During development, a little logging when interesting events occur can help you figure out what's going on under the hood of your game engine. If you're working on AI, you'd like to know when an entity changes AI states. If you're the sound programmer, you may want a record of every sound as it plays so you can check that they trigger in the right order. 在开发中,一小段感兴趣的事件日志能够让你估摸出在游戏引擎外表之下发生了什么。如果你在开发AI系统, 你很想要知道 一个单位的AI状态什么时候发生了变化。如果你是音频程序要,你可能想要知道每次声音播放的 记录,以便你能够检测触发 器都在正确的位置上。 The typical solution is to litter the code with calls to some log() function. Unfortunately, that replaces one problem with another -- now we have too much logging. The AI coder doesn't care when sounds are playing, and the sound person doesn't care about AI state transitions, but now they both have to wade through each other's messages. 典型的解决方法是调用一些 log() 函数。不幸的时,它用另一个问题替代了一个问题——现在我们有太多日志了。 AI程序员 不关心声音什么时候播放,声音程序要不想知道AI状态的切换,但是现在他们都必须过滤各自的日志信息。 Ideally, we would be able to selectively enable logging for just the stuff we care about, and in the final game build, there'd be no logging at all. If the different systems we want to conditionally log are exposed as services, then we can solve this using the Decorator pattern. Let's define another audio service provider implementation like this: 理想状态下,我们能够为要关心的时间选择日志开启,并在游戏最后构建时,将没有任何日志。如果不同的系统的 条件日志 作为服务器暴露出去,现在我们可以使用装饰器 模式解决这个问题。让我们像这样 定义另外一个音频实现: class LoggedAudio : public Audio { public: LoggedAudio(Audio &wrapped) : wrapped_(wrapped) {} virtual void playSound(int soundID) { log("play sound"); wrapped_.playSound(soundID); } virtual void stopSound(int soundID) { log("stop sound"); wrapped_.stopSound(soundID); } virtual void stopAllSounds() { log("stop all sounds"); wrapped_.stopAllSounds(); } private: void log(const char* message) { // Code to log message... } Audio &wrapped_; }; As you can see, it wraps another audio provider and exposes the same interface. It forwards the actual audio behavior to the inner provider, but it also logs each sound call. If a programmer wants to enable audio logging, they call this: 如你所见,它包装了另外一个音频器并暴露了同样的接口。它将实际的音频操作转发给内嵌的服务器,但是它同时 记录了每 次音频调用。如果一个程序要需要开启音频日志,他这样调用代码: void enableAudioLogging() { // Decorate the existing service. Audio *service = new LoggedAudio(Locator::getAudio()); // Swap it in. Locator::provide(service); } Now, any calls to the audio service will be logged before continuing as before. And, of course, this plays nicely with our null service, so you can both disable audio and yet still log the sounds that it would play if sound were enabled. 现在,任何音频服务的调用在之前运行之前会被记录。同时,当然,这和我们的空服务器合作良好,所以你可以 即关闭音频 又仍然开启声音日志,如果声音开启,它将会播放声音。 We've covered a typical implementation, but there are a couple of ways that it can vary based on differing answers to a few core questions: 我们讨论了一个典型的实现,对一些核心问题,不同的方式会有不同的答案。 Outside code registers it: 在外部代码注册: This is the mechanism our sample code uses to locate the service, and it's the most common design I see in games: 这是我们简答的代码用来定位服务器的机制,同时这也是我在游戏中最常见的设计。 It's fast and simple. The getAudio() function simply returns a pointer. It will often get inlined by the compiler, so we get a nice abstraction layer at almost no performance cost. 它简单快捷。 getAudio() 函数简单的返回一个指正,它通常被编译器内联,所以我们 得到了一个良好的抽象层最好 没有性能损失。 We control how the provider is constructed. Consider a service for accessing the game's controllers. We have two concrete providers: one for regular games and one for playing online. The online provider passes controller input over the network so that, to the rest of the game, remote players appear to be using local controllers. 我们能共控制服务器如何构建。考虑一个服务访问游戏的控制者。我们有两个具体的服务器:一个 是通常游戏,一个是在线游戏。在线服务器将控制者操作传递到网络上,以便,对其他部分,远程玩家 就像使用本地控制器一样。 To make this work, the online concrete provider needs to know the IP address of the other remote player. If the locator itself was constructing the object, how would it know what to pass in? The Locator class doesn't know anything about online at all, much less some other user's IP address. 为了达到这点,在线服务器实现需要知道IP其他远程玩家的地址。如果定位器构建这个对象,它如何知道 什么需要 传递进去呢? Locator 这个类对在线一无所知,更何况其他用户的IP地址了。 Externally registered providers dodge the problem. Instead of the locator constructing the class, the game's networking code instantiates the online-specific service provider, passing in the IP address it needs. Then it gives that to the locator, who knows only about the service's abstract interface. Design Decisions 设计讨论 How is the service located? 服务是如何定位的 外部注册服务器避开了这个问题。与其在定位器初始化这个类,游戏的网络代码初始化在线服务器, 将它需要的IP 地址传递进去。然后将它转给定位器,而定位器只知道这个服务的抽象接口。 We can change the service while the game is running. We may not use this in the final game, but it's a neat trick during development. While testing, we can swap out, for example, the audio service with the null service we talked about earlier to temporarily disable sound while the game is still running. 我们可以在游戏运行额时候更换服务器。我们可能在最后游戏中不利用这一点,但是在开发中这是一个 很贴心的技 巧。当测试时,我们可以切换。举个例子,我们之前讨论的代空服务的音频服务器可以在游戏 仍在运行的时间暂时 禁止音频。 The locator depends on outside code. This is the downside. Any code accessing the service presumes that some code somewhere has already registered it. If that initialization doesn't happen, we'll either crash or have a service mysteriously not working. 定位器依赖外部代码。这是短板。访问服务的任何代码都假设其他代码已经注册过这个服务了。如果 没有发生初始 化,我么要么崩溃,要么服务神秘地无法工作。 Bind to it at compile time: 在编译器绑定: The idea here is that the "location" process actually occurs at compile time using preprocessor macros. Like so: 这里的想法是“定位”这个工作实际上发生在编译器,使用条件编译。想这样: class Locator { public: static Audio& getAudio() { return service_; } private: #if DEBUG static DebugAudio service_; #else static ReleaseAudio service_; #endif }; Locating the service like this implies a few things: 像这样定位服务器指明了几点: It's fast. Since all of the real work is done at compile time, there's nothing left to do at runtime. The compiler will likely inline the getAudio() call, giving us a solution that's as fast as we could hope for. 它十分快速。既然所有的实际工作都发生在编译期,在运行期就没什么事情了。编译器很可能 内联 getAudio() 调 用,这是我们能够到达最快。 You can guarantee the service is available. Since the locator owns the service now and selects it at compile time, we can be assured that if the game compiles, we won't have to worry about the service being unavailable. 你能保证服务可用。既然定位器现在拥有服务器并在编译器选择它,我们能保证如果游戏编译,我们 不比担心服务 不可用。 You can't change the service easily. This is the major downside. Since the binding happens at build time, anytime you want to change the service, you've got to recompile and restart the game. 你不能方便的更改服务器。这是主要的缺点。应为绑定发生在构建期,任何你想要变动服务器,你必须 重新编译再 重启游戏。 Configure it at runtime: 在运行期配置: Over in the khaki-clad land of enterprise business software, if you say "service locator", this is what they'll have in mind. When the service is requested, the locator does some magic at runtime to hunt down the actual implementation requested. 在企业级商业软件中,如果你说“服务定位器”,这是它们需要要知道的。当服务被请求时,定位器做一些 在运行时魔法 操作来定位时间被请求的实现。 >Reflection is a capability of some programming languages to interact with the type system at runtime. For example, we could find a class with a given name, find its constructor, and then invoke it to create an instance. >反射是一些语言 在运行期能和类型系统交互的能力。比如,我们能通过给定的名查找一个类,找到它的 构造器,然后调用构造器来创建 一个实例。 >Dynamically typed languages like Lisp, Smalltalk, and Python get this by their very nature, but newer static languages like C# and Java also support it. >动态类型语言,比如Lisp,Smalltalk,和Python能够十分自然的处理 这点,但是新的静态类型语言比如C# 和Java也支持它。 Typically, this means loading a configuration file that identifies the provider and then using reflection to instantiate that class at runtime. This does a few things for us: 通常来说,这表示加载一份配置文件来决定定位器,然后使用反射来在运行期初始化这个类。这为我们 做了一些事情。 We can swap out the service without recompiling. This is a little more flexible than a compile-time-bound service, but not quite as flexible as a registered one where you can actually change the service while the game is running. 我们不需重编译就能切换服务器。这要比编译期绑定更具有弹性,但是比不上一个注册的服务器。它实际上 能在游 戏运行的时候更换服务器。 Non-programmers can change the service. This is nice for when the designers want to be able to turn certain game features on and off but aren't comfortable mucking through source code. (Or, more likely, the coders aren't comfortable with them mucking through it.) 非程序员能够更换服务器。 这在设计人员想要开关游戏的某项特性,但是不能够安然地摆弄代码 时十分有用。(后 者,更可能是,程序员对他们摆弄代码感到不安) The same codebase can support multiple configurations simultaneously. Since the location process has been moved out of the codebase entirely, we can use the same code to support multiple service configurations simultaneously. 一份代码库能够同时支持多份配置。因为定位过程完全移除代码库,我们能够使用同样的代码同时支持 多个服务配 置文件。 This is one of the reasons this model is appealing over in enterprise web-land: you can deploy a single app that works on different server setups just by changing some configs. Historically, this was less useful in games since console hardware is pretty well-standardized, but as more games target a heaping hodgepodge of mobile devices, this is becoming more relevant. 这也是这个模式在企业级web开发中应用的原因:你能够发布单个app就能在不同耳朵服务器上工作, 只需要修改 几个配置。历史上,这在游戏中没有什么用处,因为游戏终端硬件都是十分标准化的,但是 随着更多游戏开始瞄向 杂乱的移动设备,这变得越来越有意义。 It's complex. Unlike the previous solutions, this one is pretty heavyweight. You have to create some configuration system, possibly write code to load and parse a file, and generally do some stuff to locate the service. Time spent writing this code is time not spent on other game features. 这比较复杂。不像前几个解决方案,这十分重量级。你必须创建摸个配置系统,很可能写代码 去加载解析文件,并 通常做某些操作定位服务器。化在写这写代码上的时间就不能用来写别的 游戏特性了。 Locating the service takes time. And now the smiles really turn to frowns. Going with runtime configuration means you're burning some CPU cycles locating the service. Caching can minimize this, but that still implies that the first time you use the service, the game's got to go off and spend some time hunting it down. Game developers hate burning CPU cycles on something that doesn't improve the player's game experience. 定位服务需要时间。 现在,是到真正皱眉了。使用运行期配置意味着你在定位服务时燃烧CPU周期。 缓存能减缓 这点,但是仍然影响你第一次使用这个服务的时候,游戏需要挂起花费时间来处理它。游戏 程序要痛恨浪费CPU周 期在不能提高游戏体验的事情上。 Let the user handle it: 让使用者处理: The simplest solution is to pass the buck. If the locator can't find the service, it just returns NULL . This implies: 简单的方法就是转移责任。如果定位器找不到服务器,它就返回 NULL 。这表示: It lets users determine how to handle failure. Some users may consider failing to find a service is a critical error that should halt the game. Others may be able to safely ignore it and continue. If the locator can't define a blanket policy that's correct for all cases, then passing the failure down the line lets each call site decide for itself what the right response is. 它让使用者决定如何处理失败。 有些使用者可能认为查找服务失败是一个严重错误,需要终止游戏。 其他或许认为 能安全地忽略它并继续执行。如果定位器不能定义一个全面的策略,对每种情况都正确, 那么将失败传递给调用 者,来决定正确的相应。 Users of the service must handle the failure. Of course, the corollary to this is that each call site must check for failure to find the service. If almost all of them handle failure the same way, that's a lot duplicate code spread throughout the codebase. If just one of the potentially hundreds of places that use the service fails to make that check, our game is going to crash. 服务使用者必须处理失败。当然,必然的结果就是每处调用点必须检测查找服务失败。如果几乎每处处理 失败方式 都一样,这就是重复代码传播在代码库中。如果几百处潜在的地方又一次没有做错误检测,我们游戏 就可能会崩 溃。 Halt the game: 终止游戏: I said that we can't prove that the service will always be available at compile-time, but that doesn't mean we can't declare that availability is part of the runtime contract of the locator. The simplest way to do this is with an assertion: 我说过,我们不能证明服务器在编译器不能始终有效,但这并不意味这我能不能声明可用性是定位器的 责任。最简单的 方法是写一个断言: class Locator { public: static Audio& getAudio() { Audio* service = NULL; // Code here to locate service... assert(service != NULL); return *service; } }; What happens if the service can't be located? 当服务不能被定位时发生了什么? If the service isn't located, the game stops before any subsequent code tries to use it. The assert() call there doesn't solve the problem of failing to locate the service, but it does make it clear whose problem it is. By asserting here, we say, "Failing to locate a service is a bug in the locator." 如果服务没有找到,游戏在任何后续代码使用之前停止。 assert() 调用并没有解决查找服务失败的问题,但是 它明确 了这是谁的问题。通过断言,我们说,“定位服务失败是定位器的一个bug”。 >The Singleton chapter explains the assert() function if you've never seen it before. >如果你之前没有看见 过 assert() 这个函数 单件模式 这章它的解释 So what does this do for us? 所以对我们来说该怎么做呢? Users don't need to handle a missing service. Since a single service may be used in hundreds of places, this can be a significant code saving. By declaring it the locator's job to always provide a service, we spare the users of the service from having to pick up that slack. 使用者不需要处理一个遗失的服务。应为一个服务可能用到上百处,这能节省很多代码。通过 声明总是提供一个服 务是定位器的工作。我们让服务使用者清闲下来。 The game is going to halt if the service can't be found. On the off chance that a service really can't be found, the game is going to halt. This is good in that it forces us to address the bug that's preventing the service from being located (likely some initialization code isn't being called when it should), but it's a real drag for everyone else who's blocked until it's fixed. With a large dev team, you can incur some painful programmer downtime when something like this breaks. 如果服务没有找到,游戏将会中断。 在极少的情况下,如果服务真的早不到,游戏就会关闭。它强制我们 去寻找那 些阻止服务被发现的Buf来说是很好的(比如一些初始化代码没有被正确调用),但是这对那些堵在 修复的那些人 确实是个泥潭。如果有一个大的开发组,当这些东西发生时,你可以增加一些苦逼程序员的停工时间。 Return a null service: **返回一个空服务: We showed this refinement in our sample implementation. Using this means: 我们在我们的简单实现中展示了这种优雅 的实现。这种方案意味着: Users don't need to handle a missing service. Just like the previous option, we ensure that a valid service object will always be returned, simplifying code that uses the service. 使用者不需要处理丢失的服务。就先之前的一个选项一样,我们确保始终返回一个有效的服务,简化 了使用者的代 码。 The game will continue if the service isn't available. This is both a boon and a curse. It's helpful in that it lets us keep running the game even when a service isn't there. This can be really helpful on a large team when a feature we're working on may be dependent on some other system that isn't in place yet. 当服务不可用时,游戏还能继续。这是一把双刃剑。这是我们的游戏在没有服务的时候也能运行。这对一个 大团 队,当我们依赖的一个特性还没有被其他人开发出来是特别有用。 The downside is that it may be harder to debug an unintentionally missing service. Say the game uses a service to access some data and then make a decision based on it. If we've failed to register the real service and that code gets a null service instead, the game may not behave how we want. It will take some work to trace that issue back to the fact that a service wasn't there when we thought it would be. 这个副作用就是,在非特意的丢失服务时难以跟踪。假设游戏使用一个服务来访问某些数据然后根据 这些数据做一 些决定。如果我们没有注册真正的服务,让代码得到了一个空服务,游戏就不会像预计那样运作。 找到真相需要花 费一些时间,原来是服务没有想我们想的那样可用。 >We can alleviate this by having the null service print some debug output whenever it's used. >我们可以让空服务 在任何使用的时候打印debug日子来缓解这点。 Among these options, the one I see used most frequently is simply asserting that the service will be found. By the time a game gets out the door, it's been very heavily tested, and it will likely be run on a reliable piece of hardware. The chances of a service failing to be found by then are pretty slim. 在这些选项中,我见到使用最多的就是断言服务能够找到。当游戏发布的时候,游戏被仔细测试过了,并且会在一个可靠 的 设备上运行。服务没有找到的机会十分渺小。 On a larger team, I encourage you to throw a null service in. It doesn't take much effort to implement, and can spare you from some downtime during development when a service isn't available. It also gives you an easy way to turn off a service if it's buggy or is just distracting you from what you're working on. 在大点的团队中,我推荐你使用一个空服务。它不需要花费什么功夫就能实现,而且可以让你在其他服务不可用时的停工 中 解脱出来。它也给你提供了便利的方式来关闭服务,如果这个服务有bug或者就是打扰了你的工作。 Up to this point, we've assumed that the locator will provide access to the service to anyone who wants it. While this is the typical way the pattern is used, another option is to limit access to a single class and its descendants, like so: 到目前为止,我们假设定位器为每个使用它的代码提供访问。即使这是这个模式典型的使用方式,另外一个选项是 限制它的 访问到单个类和他的依赖类中,比如: class Base { // Code to locate service and set service_... protected: // Derived classes can use service static Audio& getAudio() { return *service_; } private: static Audio* service_; }; With this, access to the service is restricted to classes that inherit Base . There are advantages either way: 通过这点,访问服务被定向到继承了 Base 的类中。这不管怎么说都有两点好处: If access is global: 如果是全局访问: It encourages the entire codebase to all use the same service. Most services are intended to be singular. By allowing the entire codebase to have access to the same service, we can avoid random places in code instantiating their own providers because they can't get to the "real" one. 它鼓励了整个代码库使用同一个服务。大部分服务都应该是单独的。允许整个代码库访问同一个服务,我们 能避免 代码中随机的初始化它们各自的提供者,应为他们不能得到一个”真正“的服务。 We lose control over where and when the service is used. This is the obvious cost of making something global -- anything can get to it. The Singleton chapter has a full cast of characters for the horror show that global scope can spawn. 我们对何时何地使用完全失去了控制。这是将东西变为全局的代价——任何人都能访问。 单件这章将花费一整章来 讨论全局 作用域带来的可怕后果。 If access is restricted to a class: 如果访问定向到类中: What is the scope of the service? 服务的作用域多大? We control coupling. This is the main advantage. By limiting a service to a branch of the inheritance tree, we can make sure systems that should be decoupled stay decoupled. 我们控制耦合。这是主要的优势。通过限制服务到一个继承数上,我们能确保系统改解耦的地方解耦了。 It can lead to duplicate effort. The potential downside is that if a couple of unrelated classes do need access to the service, they'll each need to have their own reference to it. Whatever process is used to locate or register the service will have to be duplicated between those classes. 它可能导致重复的工作。潜在的缺点是,如果有去多不相干的类确实需要访问服务,它们需要有各自的 引用。那些 定位和注册服务的工作在这些类中都要重复的处理。 (The other option is to change the class hierarchy around to give those classes a common base class, but that's probably more trouble than it's worth.) (其他的选项就是修改类的继承,给这些类一个公共的基类,但是这会导致更 多的问题。) My general guideline is that if the service is restricted to a single domain in the game, then limit its scope to a class. For example, a service for getting access to the network can probably be limited to online classes. Services that get used more widely like logging should be global. 我通常的准则就是,如果服务被定向到一个单独的域中,就限制到它的作用域到类中。比如,获取网络访问的服务就 很可能 限制在联网的类中。更广泛使用的服务,比如日志服务应该是全局的。 The Service Locator pattern is a sibling to Singleton in many ways, so it's worth looking at both to see which is most appropriate for your needs. 服务定位器模式在很多方面和单件是表亲,所以值得观察两者来决定谁更贴合你的需求。 The Unity framework uses this pattern in concert with the Component pattern in its GetComponent() method. Unity框架把这个模式和 组件 模式结合起来,使用在 GetComponent() 方法中。 Microsoft's XNA framework for game development has this pattern built into its core Game class. Each instance has a GameServices object that can be used to register and locate services of any type. Microsoft的 XNA游戏开发框架将这个模式内嵌到它的核心 Game 类中。每个实例有一个 GameServices 对象,能够用来注 册和定位任何类型的服务。 See Also 参考 随着硬件的越来越快,大部分软件不用再担心性能问题,但游戏是少数剩余中的一个例外。玩家总是希望更丰富,更逼真和 令人兴奋的体验。各种各样的游戏充斥着屏幕吸引着玩家的注意力和金钱!而通常赢的就是那些将硬件性能发挥到很不错的 游戏。 性能优化是一门很深的艺术,它涉及到了软件的各个方面。底层编码人员掌握着硬件架构的无数特性,同时,算法研究者用 数学来证明程序的高效性。 在这一章节中,我列举了一些经常用来优化加速游戏的几个中层模式。局部性数据向您介绍了现代计算机的存储层次以及如 何利用它的优势。脏标记模式)帮助您避免不必要的计算,而对象池帮助您避免不必要的内存分配。空间分区会加快虚拟世界 和其中元素的空间布局。 数据局部性 脏标记 对象池 空间分区 优化模式 本章模式 通过合理组织数据利用CPU的缓存机制来加快内存访问速度。 我们被骗了。 他们总拿着CPU速度增长的年度报表来让人以为摩尔定律不只是历史观测的结果甚至是某种真理!我们做软件 的就这么毫不费力地看着自己的程序光凭着新硬件的优势就莫名地飞奔起来! 芯片确实变快了(但现在滞缓趋势),但硬件巨头们并没有提及另一些事情——我们当然能更快地处理数据,但我们并不能更快地 获取这些数据。 注解: 处理器和RAM的速度分别与它们在1900年的速度有关。如你所见,CPU的速度飞速增长,但RAM访问速度却增长 迟缓。上图数据来自John L. Hennessy, David A. Patterson, Andrea C. Arpaci-Dusseau借由Tony Albrecht的“Pitfalls of Object-Oriented Programming”统计于《Computer Architecture: A Quantitative Approach》一书中 为使你超快的CPU进行大量的计算,实际上它需要从主存中取出数据并置入寄存器中。如你所见,RAM的存取速度远远跟不上 CPU的速度,根本就没沾过边。 今天的硬件设备可能要花去数百次的循环来从RAM中获取一个字节的数据。加入许多指令需要访问数据,且每次需要数百次循 环来获取这数据,要是我们的CPU在等候数据的这段时间里的99%都处于忙碌状态,该怎么办? 实际上,当今的CPU会花去惊人的时间片段来等候内存传输数据,但这也还没糟到谷底。为进行解释,让我们进行一次稍长的比 喻吧... 注解: 叫它RAM(随机存储器)的原因是,不同于光盘驱动器,理论上你可以超越其他任何存储介质的速度访问到RAM中任 何一块内存数据,你无需担心光盘上需要连续读取的问题——或者至少你至今这么认为。正如我们所将看到的,RAM不 再是可以任意地随机访问了 数据区域化 # 目的 动机 设想你是一个小办公室里的会计。你的工作是采集一盒子的单子并对它们进行一些核查统计或其他的计算。你需要根据一些 晦涩到的会计专业逻辑来对这样的一系列打了标签的盒子进行处理。 注解:我似乎不该拿个自己完全不在行的工作来打比方... 多亏你的努力工作,出色的才能以及进取心,你可以在(比方说)一分钟内完成一个盒子里的所有任务。当然,这里有个小问题。这 些盒子都被分别存放于一个一栋楼里的不同地方。为了拿到这些盒子,你必须询问仓储人员来获取这些盒子。他去开来叉车并 在过道之间寻找直至找到你想要的那个盒子。 这花了他一整天的时间来取一个盒子。不像你,他很快就要在这个月结束后走人了,这意味着不论你办事效率有多高,你一天只 能搞定一个盒子。而你剩余的时间,就只能坐在办公椅上思考自己怎么就干了这样一份吸食灵魂的工作。 某天,一堆工业设计师出现了。他们的任务是提高工作效率,比如提高流水线效率啥的。在观察你工作了几天之后,他们注意到 以下几点: 往往当你完成某个盒子里的任务时,你所需要的下个盒子就放在仓储间中与这盒子所属的同个架子上。 开着叉车来取个小盒子真是蠢哭了。 其实在你的办公室角落里有一些空闲的空间。 注解:对刚访问数据的邻近数据进行访问的术语叫做引用局部性。 他们想到了一个聪明的办法。不论何时当你向仓库管理员提出一个盒子的需求时,他将取来一整车的盒子:他为你带来你所要的 盒子,并将与它相邻的那些盒子也都一起带来。他并不知道你是否需要他们(当然,基于他的职业道德,能容忍一次搬这么多),他 只是尽可能地让盒子填满叉车并带来给你。 他卸下这些盒子并给你。先不管工作所在地的安全性,他把叉车直接开进你的办公室并把盒子都卸到那个空闲的角落里。 现在当你需要一个新盒子时,你首先从你办公室角落的那些盒子里找。假如找到那就太棒了,你只需要几秒的时间把它拿过来然 后继续算你的算数。假如一台叉车能容纳50和盒子并且碰巧你所需要的50个盒子都在其中,你就可以完成比从前多50倍的工 作了! 但假如你需要的盒子不在叉车里,那你就必须把一个已经处理完的盒子退回去。由于你的办公室只能容纳一车的盒子,所以你的 仓管同志会来帮你带回那个盒子并为你带来一个新的。 奇怪的是,上面的过程与当今计算机内CPU的工作原理类似。也许这不太显然,你却扮演者CPU的角色。你的桌面是CPU的寄 存器,装着单子的盒子是你所要处理的数据。仓库是你机器的RAM,那个讨厌的仓管员是从主存往寄存器中拉取数据的总线。 假如我在三十年前写这一章,比喻恐怕就此结束。但随着处理器速度的飞跃(以及RAM速度的落后),硬件工程师开始寻找解决方 案,而他们想到的就是CPU缓存技术。 当代计算机在其芯片内部的内存十分有限。CPU从芯片中读取数据的速度要快于它从主存中读取数据的速度。它很小,以便于 嵌在芯片上,而且由于它使用更快的内存类型(静态RAM或称SRAM),所以更贵。 当代计算机有多级缓存,也就是你所听到的那些“L1”,“L2”,“L3”等等。它们的大小按照其等级递增,但速度也越来越慢。在本章 中,我们并不区分缓存的层级(hierarchy),但知道还是有必要的。 这一小块内存被称为缓存(特别地,芯片上的那块叫做你的L1缓存),在我那个啰嗦的比喻里,它的角色就是那装满盒子的叉车。 任何时候当你需要RAM中的数据时,它自动将一整块连续的内存(通常在64到128字节)取出来并置入缓存中。这块内存被称为 缓存线cache line)。 数据仓库 CPU的叉车 假如你需要的下一个数据恰巧在这个块中,那么CPU直接从缓存中读取数据,这比命中RAM要快多了。成功地在缓存中找到数 据被称为一次命中。假如它没有找到数据而需要访问主存,那么称之为未命中。 注解: 让我对比喻中的一些细节做下解释。在你的办公室里,仅有能容纳一辆叉车或者说缓存线的空间。实际中的缓存 包含了一系列的缓存线。其工作原理在此超纲,但你可以搜索“缓存关联性”来脑补。 当缓存未命中时,CPU就停止运转:它因为缺少数据而无法执行下一条指令。CPU坐在地上进行几百次空循环发呆,直到取得数 据。我们的任务就是避免这一情况发生。设想你正试图通过改进一些关键性的游戏代码来提高性能,比如下面这样: for (int i = 0; i < NUM_THINGS; i++) { sleepFor500Cycles(); things[i].doStuff(); } 对这段代码你首先可以做些啥?是的,显然循环里的函数调用开销很大。这样的调用等价于缓存未命中带来的性能损失。每次 跳入主存中,就意味着往你的代码里塞了一段延时。 着手写这一章时,我花了些时间整理了一些类似游戏的小程序,他们能制造最好和最坏的缓存使用情况。我想为缓存的徒劳无功 划定基准,以便能第一时间确知它究竟造成了多少性能损失。 当我对一些程序进行测试时,大为吃惊。我无法形容问题之夸张,耳听为虚眼见为实!我写了两段代码来进行相同的运算,二者唯 一的差别在于它们造成的缓存未命中次数不同。而较慢者竟然在速度上比另一段代码慢了50倍! 注解: 你需要注意许多警告。尤其是,不同的计算机有不同的缓存步骤,所以我的机器可能与你的机制不同,而专用的控制 台游戏与PC又有很大不同,当然在移动设备上的差别也不言而喻。总之因人而异 这真让我开了眼界。我曾经以为性能是代码的一部分,而非数据。一字节的数据并无快慢之分,它只是一个静态的事物。但由于 缓存机制的存在,你组织数据的方式直接影响了性能。 现在的挑战是将上面这些转为本章节的相关内容。对缓存使用的优化是个大话题。我还从没有涉及过指令缓存。请记住,代码 也是在内存中的,并且需要被载入到CPU中才能够被执行。那些更精通于这课题的人能够为此写出一整本书来。 注解: 事实上,就有人为此写了本书:Richard Fabian的Data-Oriented Design 不论你是否已经读了这本书,我这介绍一些基本的技巧,以便你在关于数据结构是如何影响程序性能这一问题上展开思考。 这些都可以归结为一件简单的事情:不论芯片何时读取多少内存,它都整块地获取缓存线。这快缓存线你的可用数据越多,程序 就跑得越快。所以优化的目标就是将你的数据结构进行组织,以使需要处理的数据对象在内存中两两相邻。 注解: 这里需要一个关键性的假设:单线程。假如你在多线程中对当前数据附近的内存进行修改,如果每个线程在不同的 缓存线上处理数据,那么速度会更快,但如果两个线程对同一缓存线上的数据进行改动,那么两条线程上的代码都不得不 花些开销来对它们的缓存进行同步。 换句话说,假如你的代码正在处理Thing,接着Another,然后Also这三个数据,你就希望它们在内存里是这样布局的: 啥?数据即性能? 请注意,并没有Thing,Another或Also指向的指针。这就是它们的实际数据,在各自恰当的位置,一个接一个线性排列。只要CPU 读取完Thing,它将接着开始读取Another 和Also,(具体取决于他们的大小以及缓存线的尺寸)。当你开始对它们进行处理时,他 们已经在缓存中准备就绪了。你的芯片和你都笑了。 当代CPU带有多级缓存以提高内存访问速度。这一机制加快了对最近访问过的数据其邻近内存的访问速度。通过增加数据区 域化来利用这一点以提高性能:保持数据位于连续的内存中以供你的程序进行处理。 如同多数优化措施,指引我们使用数据区域化模式的第一条准则就是找到出现性能问题的地方。不要在那些代码库里非频繁执 行的部分浪费时间,它们不需要本模式。对那些非必要的代码进行优化将使你的人生变得艰难——因为结果总是更加复杂且笨 拙的。 由于此模式的特殊性,你可能还希望确定你的性能问题是否是由缓存未命中引起的,如果不是,那么这个模式也帮不到忙。 最简单的估算办法就是人为地添加一系列的测量工具以计量一段代码执行所花费的时间,最好能够使用一些精确的计时器。为 了获悉缓存的使用情况,你需要一些更复杂的手段——你希望能够确知有多少次的缓存未命中,并对它们进行定位。 幸运的是,有现成的工具来做这些工作。在正式深入你的数据结构前,些时间来架构一个这样的工具并搞懂那些统计数据的含义 (相当复杂!)是值得的。 注解: 然而不幸的是这些工具多数十分昂贵。假如你在一个主机游戏开发团队里,大概你已经有这些工具的证书啦。 假 如你不在这样的团队里,Cachegrind是个很不错且免费的选择。它将你的程序置于一个虚拟CPU上运行并进行分层级的 缓存,最终展示这些缓存的表现。 如前所述,缓存未命中将影响到你的游戏性能。由于你无法花费大量的时间预先对缓存的使用进行优化,是该想想再设计的过程 中如何让你的数据结构变得更加缓存友好。 软件架构的一大特征是抽象化。本书的很大一部分讨论的是如何将代码进行分块并对在各个块之间进行解耦,以使它们变得更 易于修改。在面向对象的语言中,这往往意味着接口化。 在C++中,使用接口则意味着要通过指针或引用来访问对象。而使用指针进行访问也就是要在内存里这儿那儿地跳转,这就引发 了本设计模式在极力规避的缓存未命中现象。 注解: 接口的另一个要点就是虚方法的调用。而这要求CPU检索一个对象的虚表(vtable)并找到表所指向的实际方法以 进行函数调用。所以你又得追踪指针了,又要缓存未命中了。 为了做到缓存友好,你可能需要牺牲一些之前所做的抽象化。你越是在程序的数据区域上化下功夫,你就越要牺牲继承,接口以 及这些手段所带来的好处。要时刻记得没有银弹(译者注:一句话说就是技术上什么包治百病且立竿见影的良药,参见这里),只有 充满着挑战的牺牲与交换。当然拆东墙补西墙也是很有趣的事情! (数据区域化)模式 使用情境 使用须知 示例 假如你真的钻研到数据区域化优化的深处,你将发现有无数种办法,将你的数据结构拆解成片段以供CPU更好地进行处理。为 了让你知道如何下手,我将在对几个最常见的组织数据的方法各做一个简单的实例。我们将在特定的游戏引擎环境下来完成它 们,但(正如其他设计模式一样)要牢记只要符合条件,这一技术在任何情境中都是通用的。 让我们从一个处理一系列游戏实体的游戏循环(Game loop)开始。每个实体通过组件模式(Component)被拆解为不同的部 分:AI,物理,渲染。 GameEntity 类如下: class GameEntity { public: GameEntity(AIComponent* ai, PhysicsComponent* physics, RenderComponent* render) : ai_(ai), physics_(physics), render_(render) {} AIComponent* ai() { return ai_; } PhysicsComponent* physics() { return physics_; } RenderComponent* render() { return render_; } private: AIComponent* ai_; PhysicsComponent* physics_; RenderComponent* render_; }; 每个组件都包含一系列相关的状态属性,或许是一些向量或矩阵,且组件具有一个更新这些状态的方法。在此其细节并不重要, 但我们可以根据这些粗略设想出如下的组件结构: 注解: 正如其名,这些例子正是来自Update Method模式。甚至连render()方法也采用这一模式,只是换了个名字而已。 class AIComponent { public: void update() { /* Work with and modify state... */ } private: // Goals, mood, etc. ... }; class PhysicsComponent { public: void update(){ /* Work with and modify state... */ } private: // Rigid body, velocity, mass, etc. ... }; class RenderComponent { public: void render() { /* Work with and modify state... */ } private: // Mesh, textures, shaders, etc. ... }; 游戏维护一个很大的指针数组,它们包含了对游戏世界中所有实体的引用。每次游戏循环我们需要做以下工作: 1.为所有实体更新AI组件。 2.为所有实体更新其物理组件。 3.使用渲染组件对它们进行渲染。 连续的数组 许多游戏实体将这样进行实现: while (!gameOver) { // Process AI. for (int i = 0; i < numEntities; i++) { entities[i]->ai()->update(); } // Update physics. for (int i = 0; i < numEntities; i++) { entities[i]->physics()->update(); } // Draw to screen. for (int i = 0; i < numEntities; i++) { entities[i]->render()->render(); } // Other game loop machinery for timing... } 在你耳闻CPU缓存机制之前,上面的代码看不出什么毛病。但现在,我想你已经察觉到有些不妥了。这样的代码不仅伤害着缓 存,甚至将它来回给搅成了一团浆糊。看看它都干了些啥吧: 1.数组存储着指向游戏实体的指针,因此对于数组中的每个元素而言,我们需要遍历这些指针(所指向的内存)——这就引发了缓 存未命中。 2.然后游戏实体又维护着指向自己组件们的指针,再一次缓存未命中。 3.接着我们更新组件。 4.再然后我们回到步骤1,对游戏里每个实体的每个组件都这么干。 最可怕的是我们不知道这些对象在内存中的布局情况,我们完全任由内存管理器摆布。由于实体随着时间被分配、释放,堆空间 会倾向于变得随机离散化。 注解: 在每帧里,游戏循环需要把上图所有的箭头都跑一遍来获取它所关心的数据。 假如我们的目标是在游戏地址空间进行快速纵览(比如“256兆RAM的四晚廉价游套餐”!),那还是蛮划算的。然而我们的目标却 是让游戏更快地运转,并且在整个主存中游荡可不是个理想的办法。还记得 sleepFor500Cycles() 这个函数吗?我们上面代码在 效率上相当于无时无刻地在调用这家伙! 注解: 在遍历一系列指针上耗费时间,可以用术语”指针雕镂”(pointer chasing)来表述。然而它却没有名字上听起来那么 好笑。 让我们做一些改进吧。首先可以发现的是,我们追踪游戏实体的指针是为了找到这个实体内指向其组件的指针以便访问这些组 件。GameEntity类本身并没有什么要紧的状态或者方法。游戏循环仅关心这些组件。 为了对这一堆游戏实体以及散乱在地址 空间各个角落的组件做改进,我们将从头来过——我们有一个容纳着各类组件的大数组:存放所有AI组件的一维数组,当然还有 存放物理和渲染组件的数组,如下: AIComponent* aiComponents = new AIComponent[MAX_ENTITIES]; PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES]; RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES]; 注解: 在关于使用组件模式我最反感的一点就是component这个词的长度... 这里需要强调一下,这些是存储组件的数组而非组件指针的数组。数组里直接包含了所有组件的实际数据,逐个字节地在内存中 分布。游戏循环可以直接遍历它们: while (!gameOver) { // Process AI. for (int i = 0; i < numEntities; i++) { aiComponents[i].update(); } // Update physics. for (int i = 0; i < numEntities; i++) { physicsComponents[i].update(); } // Draw to screen. for (int i = 0; i < numEntities; i++) { renderComponents[i].render(); } // Other game loop machinery for timing... } 注解: 我们会注意到在新的代码里我们已经不再使用”->”操作符,假如你希望增强数据的区域性,就尽可能想办法去掉那些 间接性的(尤其是指针的)操作吧。 我们抛弃了所有指针跟踪。我们采用直接对三个连续数组进行遍历的办法来取代在内存中进行跳跃性的访问。 这一方法往空闲的CPU中输入一块连续的字节,在我的测试中它为更新循环带来了比之前版本快50倍的速度。 有趣的是,我们这么做并没有放弃太多的封装性。当然,现在游戏循环直接对组件进行遍历更新而不是通过遍历游戏实体,但在 此之前它还是必须遍历游戏实体来确保它们是按照正确的顺序被更新的。尽管如此,每个组件本身依然具有很好的封装性。它 持有自身的数据和方法。我们只是改变了使用它的方式而已。 这也并不意味着我们需要放弃GameEntity类。我们可以将它放在一边,并保持它身上对组件的那些指针。它们只是指向这三个 数组而已。而当你在游戏的其他部分中需要传入一个类似游戏实体概念的对象及其所有内容时,依然可以使用它们。重要的是 减少了性能开支的游戏循环避开了这些游戏实体而直接访问了其内部的数据。 假设我们在制作一个粒子系统。顺着上一部分的思路,我们将所有的粒子置入一个大的连续数组中,我们也将它做成一个类来看 看: 注解: ParticleSystem 类是根据Object Pool模式为某个类型的对象集合创建的类。 class Particle { public: void update() { /* Gravity, etc. ... */ } // Position, velocity, etc. ... }; class ParticleSystem { public: ParticleSystem() : numParticles_(0) {} void update(); private: static const int MAX_PARTICLES = 100000; int numParticles_; Particle particles_[MAX_PARTICLES]; }; 同时粒子系统的一个简单的更新方法如下: void ParticleSystem::update() { for (int i = 0; i < numParticles_; i++) { particles_[i].update(); } } 但实际上我们并不需要总是更新所有的粒子。粒子系统维护一个固定大小的对象池,但它们并不总是同时都被激活而在屏幕上 闪烁。下面的方法会更加合适: for (int i = 0; i < numParticles_; i++) { if (particles_[i].isActive()) { particles_[i].update(); } } 我们赋予 Particle 类一个标志来表示其是否处于激活状态。在更新循环中,我们挨个粒子地检查其标志。这使得该标志随着 对应粒子的其他数据一起被加载到缓存中。假如粒子并未被激活,那么我们就跳向下一个。这时将该粒子的其他数据加载到缓 存中就是一种浪费。 活跃的粒子越少,我们就会越多次地在内存中跳转。假如粒子数组太大而活跃的粒子又太少,我们就回到了浪费缓存的起点。 将对象存入连续的数组,但我们实际处理的那些对象却并不连续时,这个办法就无效了。假如为了这些非活跃的粒子而要在内存 中跳来跳去,那么我们就回到了问题的起点。 包装数据 注解: 懂得底层编程的人也许能看到更多的问题。为所有的粒子执行if判断将会引发CPU的分支预测失准和流水线停 顿。当代CPU中,单条指令实际上需要好几次时钟周期来完成。为了让CPU保持忙碌,指令被处理成流水线模式以便多 条指令可以被并行地处理。 为实现流水线模式,CPU必须猜测哪些指令接下来将会被执行。在顺序结构的代码中这很简单,但在控制流结构中,就麻 烦了。当它执行相关的if语句时,它该猜测粒子是处于激活状态继而为其调用update()方法呢,还是猜测它未被激活而跳 过它呢? 为了回答这个问题,芯片就进行分支预测——它分析前一次你的代码走向,然后猜想嗯这次也该这么走。但要是这些粒子 按顺序一个激活一个未激活穿插地排列,那么预测就总是失败。 当预测失败时,CPU要对先前投机执行的指令进行撤销(流水线清理)并重新执行正确的指令,这样的性能损耗在计算机运 转过程中是很常见的,而这也是为什么你有时也会看到开发者们在关键代码中避开控制流语句。 再看看这小节的标题,我想你可能已经猜到了答案。我们将根据这个标志对粒子进行排序,而不是去判断这些标志。我们总是将 那些被激活的例子维持在列表的前端。假如我们知道它们都处于激活状态,就根本不必去检测标志了。 我们也可以时刻跟踪被激活粒子的数目。这样我们就可以美化一下代码了: for (int i = 0; i < numActive_; i++) { particles[i].update(); } 现在我们不跳过任何数据。每个塞进缓存的粒子都是被激活的,也都正是我们要处理的。 当然我可没说你得在每帧对整个粒子集合进行快速排序,这样是得不偿失的。我们希望的是时刻保持数组有序。 假设数组已经排好序——并且一开始所有的粒子都处于非激活状态。数组仅当某个粒子被激活或者反激活时处于乱序状态。 我们很容易就能对这两种情况进行处理:当粒子被激活时,我们通过把它与数组中第一个未激活的例子进行交换来将其移动到所 有激活粒子的末端: void ParticleSystem::activateParticle(int index) { // Shouldn't already be active! assert(index >= numActive_); // Swap it with the first inactive particle // right after the active ones. Particle temp = particles_[numActive_]; particles_[numActive_] = particles_[index]; particles_[index] = temp; // Now there's one more. numActive_++; } 反激活粒子就只要反其道而行之: void ParticleSystem::deactivateParticle(int index) { // Shouldn't already be inactive! assert(index < numActive_); // There's one fewer. numActive_--; // Swap it with the last active particle // right before the inactive ones. Particle temp = particles_[numActive_]; particles_[numActive_] = particles_[index]; particles_[index] = temp; } 许多程序员(包括我在内)都很厌恶在内存中移动数据。把内存里的字节移来移去让人觉得比为指针分配内存开销更大。但当 你再加上遍历指针的开销时,会发现我们的直觉有时会失灵。在某些情况下,假如你能保持缓存数据满,在内存中移动数据的开 销是很小的。 注解: 这将是当你做这类决定时可以参考的一个贴士 结论就是,我们可以保持粒子依照其激活状态有序,而无需保存激活状态本身。这可以通过粒子在数组中的位置 和 numActive_ 计数器来确定。这使得我们的粒子结构变小,也就意味着缓存线上能存储更多数据,从而提高速度。 当然并非万事都称心如意。正如你从API文档中看到的,我们在此放弃了许多面向对象的思想。 Particle 类不再控制其自身的 状态,你也无法对粒子对象调用诸如 activate() 之类的方法因为它确定自身在数组内的索引(即无法确定自身激活状态)。而所 有与激活粒子相关的代码都必须通过粒子系统来执行。 对于这样的情况,我倒是不介意 ParticleSystem 和 Particle 之间的紧关联。概念上我将它们视为由两个物理类组成的一个整 体。当然这么说来,喷射和销毁粒子都是粒子系统的工作。 这是最后一个帮助你代码变得缓存友好的技术案例。假设我们为某个游戏实体配置了AI组件,其中包含了一些状态:它当前所播 放的动画,它当前所走向的目标位置,能量值等等...总之这些是它在每帧都要检查和修改的量。如下: class AIComponent { public: void update() { /* ... */ } private: Animation* animation_; double energy_; Vector goalPos_; }; 而它还存储着一些并非每帧都用到的处理意外情况的量。比如存储一些关于当这家伙被开枪打死后掉落宝物的数据。掉落数 据仅仅在实体的生命周期结束时才被使用,我们将其置于上面的那些状态属性之后: class AIComponent { public: void update() { /* ... */ } private: // Previous fields... LootType drop_; int minDrops_; int maxDrops_; double chanceOfDrop_; }; 假设我们采用前述方法,当更新这些AI组件时,我们遍历一个已经包装好,且连续的数组中的数据。然而这些数据中包含着所有 的掉落信息。这使得每个组件都变得更庞大,也就导致我们在一条缓存线上能放入的组件更少。我们将引发更多的缓存未命中, 因为我们遍历的总内存增加了。对每帧的每个组件,其掉落物品的数据都要被置入缓存,尽管我们根本不会去碰它们。 对此问题的解决办法我们称之为”热/冷分解”。其思路为将我们的数据结构划分为两部分。第一个部分为“热数据”,也就是我们 每帧需要用到的数据,另一部分为”冷数据”,也就是并不会被频繁用到的那些剩余的数据。 这里我们的热数据为主AI组件。它是我们处理的关键,所以我们不希望通过指针来访问它。冷组件可以放到一边,但我们还是需 要访问它,所以就为它分配一个指针,如下: class AIComponent { public: 热/冷分解 // Methods... private: Animation* animation_; double energy_; Vector goalPos_; LootDrop* loot_; }; class LootDrop { friend class AIComponent; LootType drop_; int minDrops_; int maxDrops_; double chanceOfDrop_; }; 现在当我们遍历AI组件时,载入到缓存中的那些数据就是我们实际要处理的(当然指向冷数据的那部分的指针是一个小小的意 外) 注解: 可以通过维护两个平行的数组分别存放冷热数据,来抛弃这个指针,接着我们可以让两个数组中同一组件的索引保 持一致,以便通过热数据数组的索引来访问对应的冷数据 然而你将会开始对冷热觉得有些模糊。我这里的例子其数据的冷热之分是明显的,但实际游戏中很少有这样鲜明的划分。如果 某些实体在某个模式下需要这部分数据而在其他模式下无需这些数据该怎么办?或者它们只是在某个等级阶段使用这些数据 呢? 做这样的优化就像是在挖老鼠洞和黑色艺术之间徘徊。我们很容易花费大量陷在对数据与速度的测试上,但要相信你的努力总 会换来收获的。 这种设计模式更适合叫做一种思维模式。它提醒着你,数据的组织方式乃是游戏性能的一个关键部分。这一块的实际拓展空间 很大,你可以让你的数据区域化影响到游戏的整个架构,又或者它只是应用在一些核心模块的数据结构上。 对这一模式的应用,你最需要关心的就是该何时何地使用它。而随着这个问题我们也会看到一些新的顾虑。 注解: Noel Llopis在他的著作中称此为”面相数据的设计模式”,这让许多人开始思考如何在游戏中利用缓存。 就这一点,我们此前避开了子类进程和虚方法,并假设我们已经将同质的对象都很好地置入了数组,此时我们知道它们每个的尺 寸都一样大。然而多态和方法的动态调用也是非常有用的工具,我们如何来在二者之间进行协调? 避开继承 最简单的方法就是避开子类化,或者说至少在你进行缓存优化的地方避开继承。软件工程中也较为排斥重度的继承。 注解: 如果想避开子类继承而保持多态的灵活性,那么可以使用Type Object模式。 这安全而容易。你知道自己正在处理什么类,而且显然所有的对象其尺度都是一样的。 这样速度很快。方法的动态调用意味着在vtable中寻找实际需要调用的方法,并通过指针来访问实际代码,由于此操作 在不同硬件平台呈现很大的性能差异,故动态调用意味着一些开销。 注解: 当然还是那句话,反事没有绝对。在许多情况下,c++编译器需要使用间接引用来调用一个虚函数。但在 某些情况下,当编译器知道调用者的确切类型时,它会进行非虚拟化来静态调用正确的方法。非虚拟化在诸如 Java和JavaScript这类实时编译语言中更为常见。 这样灵活性差。当然,我们使用方法动态调用的原因正是在于它能够给与我们强大的对象多态能力,让对象表现出不同 设计决策 你如何处理多态? 的行为。假如你希望游戏中的不同实体拥有各自的渲染风格或者特殊的移动与攻击等表现,虚方法正是为此而准备 的。想要避免使用虚方法而做到这一点,那你可能就要维护一个庞大的switch逻辑块,很快你就会陷入混乱。 为不同的对象类型使用相互独立的数组。 我们使用多态来实现在对象类型未知的情况下调用其行为。换句话说,我们有个装着一堆对象的包,我们希望当我们一声令 下时它们能够各做各的事情。但这带来的问题是,为什么从一个龙蛇混杂的背包开始,而不是维护一系列按照类型分放的集 合? 这样的一系列集合让对象紧密地封包。由于每个数组仅包含一个类型的对象,也就不存在填充或者其他古怪了。 - 你可以进行静态的调用分发。你能按照类型将对象划分,也就不再需要多态了,你可以进行常规的,非虚的方法调用。 - 你必须时刻追踪这些集合。假如你有许多不同类型的对象,那么逐个维护这些数组集合的繁杂工作将是复杂而代价高昂的。 - 你必须注意每一个类型,由于你要维护每个类型的对象集合,你无法从这些类型集合中解耦它们。多态的一个神奇作用就在于它是可扩展的,通过使用接口来进行外部操作,多态将调用这些接口的代码从潜在的那些类型(它们均实现这一接口)中完全地解耦出来。 使用指针集合 假如你不担心缓存的性能,那么这自然是个好办法。你只需维护一个指向基类或接口的指针数组,你可以很好地利用多态 性,而且对象的大小也无须一致。 这样做灵活性高。只要能适配接口,访问这个集合的代码就能够处理你关心的任何类型的对象。这是完全可扩展的。 这样做并不缓存友好。当然,我们在此讨论其他方案的原因就在于解决这样指针间接访问数据的缓存不友好局面,然而 请记住,如果这些代码并不对性能苛求,使用多态是完全没问题的。 假如你将本模式与Component模式一起使用,你将会拥有一系列相邻的组件数组来组成你的游戏实体。游戏循环直接对组件数 组进行迭代,也就是说实体本身是不重要的,当然在游戏的其他代码模块你还是可能会需要这些概念性的实体。 接下来问题是这该如何表现?实体如何跟踪自己的组件? 假如游戏实体通过类中的指针来索引其组件: 我们的第一个例子看起来就是如此。这是相对普通的面向对象的办法。你有一个GameEntity类,而它内部有指向其组件的 指针。由于它们只是指针,故它们并不知道那些组件在内存中的确切位置或者它们是如何组织的。 你可以将组件存于相邻的数组中。由于游戏实体并不关心组件的存储,你可以将它们组织到一个封包过的数组中来对 迭代过程进行优化。 对于给定实体,你可以很容易地获取它的组件。只需通过指针访问即可。 这样在内存中移动组件很困难。当组件被启用或禁用时,你可能会希望将这些组件进行移动以保持那些激活的组件总 排在数组的前端并彼此相邻。假如你移动一个与某实体通过裸指针关联的组件,你可能一不小心就会弄坏这一指针关 联。你必须确保同时对实体的相应指针进行更新。 假如游戏实体通过一系列ID来索引其组件: 在内存中移动指向组件的裸指针是一大挑战。你可以使用更抽象的表示来取代指针:一个能够检索到指定组件的ID或 索引。 ID的实际语义以及索引的过程完全取决于你。可能是简单地为每个组件存储一个唯一ID并进行数组遍历,也可能是在 一个哈希表上将ID对组件所在的数组索引进行映射。 这更加地复杂。你的ID系统无需做到过度复杂,但总得比直接使用指针要麻烦。你需要实现并调试它,当然用id记录也 游戏实体是如何定义的? 需要额外的内存空间。 这样做更慢。要想比遍历裸指针速度更快是很难的。通过实体获取其组件的过程将涉及到哈希查找等问题。 你需要访问组件管理器。最简单的想法就是用一些抽象的ID来定义组件。你可以通过它来获取到实际的组件对象。 但为了做到正确索引,你必须让这些id有办法对应到组件上。这也就是存储着你组件数组的那个管理类所要做的。 使用裸指针,假如你有一个游戏实体,你就可以找到其组件。而使用ID的方法,你则需要同时对游戏实体和组件进行注 册。 注解: 你可能会想,我只需要写个单例就完事了!嗯,只能说部分情况是的。你可以先点开这篇文章看看。 假如游戏实体本身就只是个ID 这是一些新的游戏引擎所采用的风格。一旦你将你游戏实体的所有行为和状态从主类移动到组件中,那游戏实体还剩什么 呢?结果是剩不了什么,游戏实体唯一做的就是将自己与其组件绑定。它的存在就意味着其AI,物理和渲染组件构成了这个 游戏世界中的实体。 这一点很重要,因为组件之间要进行交互。渲染组件需要知道实体位于何处,而这个位置信息就很可能位于其物理组件中。 AI希望移动实体,于是它需要对物理组件施加一个力。在一个实体内,需要为每个组件提供一个访问其兄弟组件的办法。 某些聪明人意识到我们所需要的就是个ID。这使得组件能知道它所属的实体是哪个,而不是让实体来确定其组件位置。当 AI组件需要其同属实体的物理组件时,它只需访问与自身相同实体ID的那个物理组件即可。 你的游戏实体类完全消失了,取而代之的是一个优雅的数值包装。 实体变得很小。当你需要传入一个实体的引用时,你只需传入一个数值。 实体类本身是空的。当然这一方法的负面是你必须把所有东西都扫出游戏实体。你不再有地方来存放那些非组件构 成的实体状态和行为。这样做更加依赖于Component模式。 你无须管理其生命周期。由于现在实体只是某些内置类型的值,它们无需进行显式的分配或释放。实际上当某个实体 的所有组件都销毁时,这个实体也就随之自动消失了。 检索一个实体的所有组件会很慢。这与前一个方案的问题类似,但处于相反的一面。为某个实体寻找其组件,你需要对 一个对象进行ID映射,这个过程会带来开销。 这一次性能方面也存在着问题。组件在更新过程中频繁与其兄弟组件交互,于是你需要频繁地检索组件。一个解决方 案是将实体的ID对应为其组件所在数组的索引。 假如所有的实体都包含相同的组件集,那么你的组件数组之间是完全平行的。AI组件数组中的第三个组件将与物理组 件数组中的第三个组件对应着同一个实体。 请牢记,这个办法迫使你保持这些数组平行。当你希望对数组进行排序或者按照某种规则进行封包时就很难做到平行 了,你的某些实体可能禁用了物理引擎,而其他的实体不可见。在保持它们平行的情况下,你无法兼顾物理组件和渲染 组件来同时满足这两种情况。 本章节的许多内容涉及到Component模式,而此模式中的数据结构是在优化缓存使用时几乎最常用的。事实上,使用组件 模式使得这一优化更加简单。因为实体一次只是更新他们的一个域(AI模块,物理模块等等),将这些模块划分为组件使得你 可以将一系列实体合理地划为缓存友好的几部分。 但这并不意味着你只能选择组件模式实现本模式!不论何时你遇到涉及大量数据的性能问题,考虑数据的区域化都是很重要 的。 Tony Albrecht著的“Pitfalls of Object-Oriented Programming”一书大概是介绍关于游戏内数据结构设计来实现缓存友好性 的材料中最被广泛阅读的了。它使得许多人(包括我!)意识到这样对数据结构的设计是多么地重要。 与此同时,Noel Llopis就同一话题撰写了一篇广为流传的博客。 本设计模式几乎完全地利用了一个同类型对象的连续数组。随着时间流逝,你将会往这个数组中添加和移除对象。Object 参考 Pool模式恰恰阐释了这一内容。 Artemis游戏引擎是首个也是最为知名的对游戏实体使用简单ID的框架。 Avoid unnecessary work by deferring it until the result is needed. 使用延时计算避免不必要的工作。 Many games have something called a scene graph. This is a big data structure that contains all of the objects in the world. The rendering engine uses this to determine where to draw stuff on the screen. 许多游戏都有一个称之为场景图的东西.这是一个大的数据结构,包含了游戏世界中所有的物体。渲染引擎使用他来决定将物体 绘制到屏幕的什么地方。 At its simplest, a scene graph is just a flat list of objects. Each object has a model, or some other graphic primitive, and a transform. The transform describes the object's position, rotation, and scale in the world. To move or turn an object, we simply change its transform. 简单来说,一个场景图是包含多个物体的列表。每个物体都含有一个模型或者其他图元,和一个变换。变换描述了物体在世界中 的位置,旋转角度和缩放大小。想要移动或者旋转物体,我们可以修改他的变换。 The mechanics of how this transform is stored and manipulated are unfortunately out of scope here. The comically abbreviated summary is that it's a 4x4 matrix. You can make a single transform that combines two transforms -- for example, translating and then rotating an object -- by multiplying the two matrices. 变换的存贮和应用的机制不在我们的讨论范围之内.概括的说他是一个4x4的矩阵。你可以将两个转换 矩阵合并为一个 ——举个例子,移动一个物体再旋转——可以将这两个转换矩阵相乘得到一个等价的新矩阵。 How and why that works is left as an exercise for the reader. 这么做的方法和原理作为一个练习留给读者。 When the renderer draws an object, it takes the object's model, applies the transform to it, and then renders it there in the world. If we had a scene bag and not a scene graph, that would be it, and life would be simple. 当渲染器绘制一个物体时,它将这个物体的转换作用到这个物体的模型上,然后将它渲染出来。如果我们有场景包而不是场 景图的话,生活也会简单的多。 However, most scene graphs are hierarchical. An object in the graph may have a parent object that it is anchored to. In that case, its transform is relative to the parent's position and isn't an absolute position in the world. 然而,许多场景图是继承结构的。场景中的一个物体会附着在一个父物体上。在这种情况下,他的变换就依赖于它父物体的 变换,而不是世界中的一个绝对位置。 For example, imagine our game world has a pirate ship at sea. Atop the ship's mast is a crow's nest. Hunched in that crow's nest is a pirate. Clutching the pirate's shoulder is a parrot. The ship's local transform positions the ship in the sea. The crow's nest's transform positions the nest on the ship, and so on. 举个例子,想象我们的游戏中有一艘橡木船,桅杆的顶部是一个乌鸦槽。一个海盗站在这个乌鸦嘴上。一只鹦鹉抓 在海盗的 肩膀上。这艘船的变化标记了它在海中的位置。乌鸦槽的变换标记了它在船上的位置。。。 脏标记模式 Intent 目的 Motivation 动机 Programmer art! This way, when a parent object moves, its children move with it automatically. If we change the local transform of the ship, the crow's nest, pirate, and parrot go along for the ride. It would be a total headache if, when the ship moved, we had to manually adjust the transforms of all the objects on it to keep them from sliding off. 这样,当一个父物体移动时,它的子物体也会跟着移动。如果我们修改船的局部变换,海盗,鹦鹉,也会变动。如果在船移 动是要手动修改船上所有物体的变换来防止相对滑动,这会是一件很头疼的事情。 To be honest, when you are at sea you do have to keep manually adjusting your position to keep from sliding off. Maybe I should have chosen a drier example. 老实说,但你在海里是,你需要手动的调整你的位置来防止滑动。 But to actually draw the parrot on screen, we need to know its absolute position in the world. I'll call the parent-relative transform the object's local transform. To render an object, we need to know its world transform. 但是实际上绘制海盗到屏幕上时,我们需要知道它在世界中的绝对位置。我们需要依次方位他们相对于父物体的本地变换。 为了渲染一个物体,我们需要知道它的世界变换。 Calculating an object's world transform is pretty straightforward -- you just walk its parent chain starting at the root all the way down to the object, combining transforms as you go. In other words, the parrot's world transform is: 计算一个物体的时间变换是相当直观的--只要从根节点沿着它的父链将变换组合起来就是。也就是说,鹦鹉的世界变换就 是: In the degenerate case where the object has no parent, its local and world transforms are equivalent. 在退化的情况 下,当物体没有父物体时,它的本地变换和世界变换相等。 Local and world transforms 本地变换和世界变换 We need the world transform for every object in the world every frame, so even though there are only a handful of matrix multiplications per model, it's on the hot code path where performance is critical. Keeping them up to date is tricky because when a parent object moves, that affects the world transform of itself and all of its children, recursively. 每个游戏帧,我们都需要世界中每个物体的世界变换。所以即使每个模型中只有少数的几个矩阵相乘,都是代码中 影响性能 的关键所在。保持他们及时更新是棘手的。因为当一个父物体移动,他会影响他自己的世界变化和他所有的子 物体,以及子 物体的子物体... The simplest approach is to calculate transforms on the fly while rendering. Each frame, we recursively traverse the scene graph starting at the top of the hierarchy. For each object, we calculate its world transform right then and draw it. 最简单的途径是在渲染的过程中计算变换。每一帧中,我们从顶层开始,递归便利场景图。对每个物体,我们计算他们的 世 界坐标并绘制它。 But this is terribly wasteful of our precious CPU juice! Many objects in the world are not moving every frame. Think of all of the static geometry that makes up the level. Recalculating their world transforms each frame even though they haven't changed is a waste. 这是,这对我们宝贵的CPU资源是一种可怕的浪费。许多物体并不是每一帧都移动。想想关卡中那些静止的几何体。每一帧 都要重计算没有移动的物体的时间变换是一种浪费。 The obvious answer is to cache it. In each object, we store its local transform and its derived world transform. When we render, we only use the precalculated world transform. If the object never moves, the cached transform is always up to date and everything's happy. 一个明显的解决方法是将它缓存起来。在每个物体中,我们保存它的本地变换和它的时间变换。但我们渲染时,我们只使用 预先计算好的时间变换。如果物体没有移动,那么缓存的变换就是最新的,皆大欢喜。 When an object does move, the simple approach is to refresh its world transform right then. But don't forget the hierarchy! When a parent moves, we have to recalculate its world transform and all of its children's, recursively. 当一个物体移动了。简单的方法就是立即刷新它的世界变换。但是不要忘了继承链!当一个父物体移动时,我们需要重计算 它的时间变换和递归的计算它所有子物体的世界变换。 Imagine some busy gameplay. In a single frame, the ship gets tossed on the ocean, the crow's nest rocks in the wind, the pirate leans to the edge, and the parrot hops onto his head. We changed four local transforms. If we recalculate world transforms eagerly whenever a local transform changes, what ends up happening? 现象一个激烈的游戏。船被扔进海里,乌鸦槽在风中晃动。海盗靠在槽边,鹦鹉在他的头上 跳动。我们修改了4个本地变 换。如果我们在每个本地变换修改时都急忙忙的重新计算世界变换,结果会发生 什么? Cached world transforms 缓存世界变换 You can see on the lines marked ★ that we're recalculating the parrot's world transform four times when we only need the result of the final one. 我们可以看到 ★ 这行。我们重新计算了4次鹦鹉的世界变换才得到我们想要的最终结果。 We only moved four objects, but we did ten world transform calculations. That's six pointless calculations that get thrown out before they are ever used by the renderer. We calculated the parrot's world transform four times, but it is only rendered once. 我们只移动了4个物体,但是我们做了10次世界变换计算。这6次无意义的计算在渲染器使用之前就被扔掉了。 我们计算了4 次鹦鹉的世界变换,但是只渲染了一次。 The problem is that a world transform may depend on several local transforms. Since we recalculate immediately each time one of the transforms changes, we end up recalculating the same transform multiple times when more than one of the local transforms it depends on changes in the same frame. 问题的关键是一个世界变换可能依赖好几个本地变换。由于我们在每次变换修改时都立刻重计算,结果是如果一帧内有好几 个依赖的本地变换改变了,我们将这个变换重新计算了好多变。 We'll solve this by decoupling changing local transforms from updating the world transforms. This lets us change a bunch of local transforms in a single batch and then recalculate the affected world transform just once after all of those modifications are done, right before we need it to render. 我们通过将修改本地变换和更新世界变换解耦来解决这个问题。这让我们在单次作业中修改多个本机变换,然后 在所有变动 完成之后,仅仅需要计算一次被影响的世界变换。 It's interesting how much of software architecture is intentionally engineering a little slippage. Deferred recalculation 延时重算 To do this, we add a flag to each object in the graph. "Flag" and "bit" are synonymous in programming -- they both mean a single micron of data that can be in one of two states. We call those "true" and "false", or sometimes "set" and "cleared". I'll use all of these interchangeably. 要做到这点,我们为图找那个的每个物体添加一个flag。“Flag”和“bit”在编程中是同义词——他们都表示单个微笑的数据能够 储存两个状态中的一个。我们称之为“true”和“falsely”,有时也叫“set”和“cleared”。我会交互的使用他们。 When the local transform changes, we set it. When we need the object's world transform, we check the flag. If it's set, we calculate the world transform and then clear the flag. The flag represents, "Is the world transform out of date?" For reasons that aren't entirely clear, the traditional name for this "out-of-date-ness" is "dirty". Hence: a dirty flag. "Dirty bit" is an equally common name for this pattern, but I figured I'd stick with the name that didn't seem as prurient. 当本地变换改动时,我们设置它。当我们需要这个物体 的世界变换时,我们检查这个flag。如果它被设置了,我们计算这个 世界变换,然后清除这个flag。这个flag 代表这“这个世界变换是不是过期了?”由于一些模糊的原因,传统上这个“过期的”被 称作“dirty”。“Dirty bit"也是这个模式常见的名字。但是我想我会坚持使用这种看起来没什么特殊的名字。 Wikipedia's editors don't have my level of self-control and went with dirty bit 维基百科的编辑没有我这么强的自制力,将它称之为dity bit If we apply this pattern and then move all of the objects in our previous example, the game ends up doing: 如果我们运用这个模式,然后将我们例子中的所有物体移去。 That's the best you could hope to do -- the world transform for each affected object is calculated exactly once. With only a single bit of data, this pattern does a few things for us: 这是你能期望的最好的办法。每个被影响的物体的世界变换只需要计算一次。只需要一个简单的位数据,这个模式 为我们做 了不少事: It collapses modifications to multiple local transforms along an object's parent chain into a single recalculation on the object. 它将父链上物体的多个本地世界变换的改动分解为每个物体的一次重计算。 It avoids recalculation on objects that didn't move. 他避免了没有移动的物体的重计算 And a minor bonus: if an object gets removed before it's rendered, it doesn't calculate its world transform at all. 一个额外的好处:如果一个物体在渲染之前移除了,就根本不计算他的世界变换。 A set of primary data changes over time. A set of derived data is determined from this using some expensive process. A "dirty" flag tracks when the derived data is out of sync with the primary data. It is set when the primary data changes. If the flag is set when the derived data is needed, then it is reprocessed and the flag is cleared. Otherwise, the previous cached derived data is used. 一系列的 primary data 随时间变化。一系列的 derived data 根据些数据由一些昂贵操作 得到。 一个dirty flag 跟踪这个派 生数据是否和原生数据同步。他在元数据做改动时设置。如果它被设置 了,当需要派生数据时,他们就重新计算并清楚标 记。否则就使用缓存的数据。 Compared to some other patterns in this book, this one solves a pretty specific problem. Also, like most optimizations, you should only reach for it when you have a performance problem big enough to justify the added code complexity. 想对于本书中的其他模式,这个模式解决一个相当特定的问题。同时,就像大多数优化那样,仅当性能问题大到 不惜增加代 码复杂度时才使用它。 Dirty flags are applied to two kinds of work: calculation and synchronization. In both cases, the process of going from the primary data to the derived data is time-consuming or otherwise costly. 脏位涉及到两个关键词:“计算”和“同步”。在两种情况下,在处理元数据到派生数据过程中是费时或其他 大的开销。 In our scene graph example, the process is slow because of the amount of math to perform. When using this pattern for synchronization, on the other hand, it's more often that the derived data is somewhere else -- either on disk or over the network on another machine -- and simply getting it from point A to point B is what's expensive. 在我们的场景图例子中,过程很慢是应为计算量很大。当使用这个模式做同步时,相反,派生数据通常在别的地方。 要么在 磁盘上,要么通过网络上的其他机器上。简单的得到它就很昂贵。 There are a couple of other requirements too: 这里也有些其他的要求: The primary data has to change more often than the derived data is used. This pattern works by avoiding processing derived data when a subsequent primary data change would invalidate it before it gets used. If you find yourself always needing that derived data after every single modification to the primary data, this pattern can't help. 元数据的修改次数比衍生数据的使用次数多在真正使用之前,数据被随后的修改而失效。这个模式 通过避免计算之前的 改动而工作。如果你需要在每次改动原数据时都立刻需要衍生数据,这个模式就没有效果。 It should be hard to update incrementally. Let's say the pirate ship in our game can only carry so much booty. We need to know the total weight of everything in the hold. We could use this pattern and have a dirty flag for the total weight. Every time we add or remove some loot, we set the flag. When we need the total, we add up all of the booty and clear the flag. But a simpler solution is to keep a running total. When we add or remove an item, just add or remove its weight from the current total. If we can "pay as we go" like this and keep the derived data updated, then that's often a better choice than using this pattern and calculating the derived data from scratch when needed. 累次的更新数据十分困难我们假设游戏的小船能运载众多的战利品。我们需要知道每个东西的重量。我们 能够使用这个 模式,为总量设置一个赃位。每当我们增加或者减少战利品时,我们设置赃位。当我们需要总量 时,我们将所有战利品 的重量加起来并清楚赃位。 一个简单的解决方法是保持一个动态的总量。当我们增加或者减少物体是,就从总量上增加或者减去 他的重量。我们可 The Pattern 模式 When to Use It 何时使用 想像这样保持我们的衍生数据更新时,这种方法要比使用这个模式要好。 This makes it sound like dirty flags are rarely appropriate, but you'll find a place here or there where they help. Searching your average game codebase for the word "dirty" will often turn up uses of this pattern. 这些要求看起来觉得赃位没有合适的时候,但是你总能发现他能帮上忙的地方。在你的每个游戏的代码中搜索 ’dirty‘这个单 词,通常会发现这种模式的使用。 From my research, it also turns up a lot of comments apologizing for "dirty" hacks. 从我的调查来看,他也导致了很多使用"dirty"技巧的批评。 Even after you've convinced yourself this pattern is a good fit, there are a few wrinkles that can cause you some discomfort. 即使当你有相当的自信认为这个模式十分适用,这里还有一些小的瑕疵让你感到不便。 This pattern defers some slow work until the result is actually needed, but when it is, it's often needed right now. But the reason we're using this pattern to begin with is because calculating that result is slow! 这个模式把某些耗时的工作推迟到真正需要时,但是但需要是,他经常立马需要。但是,我们使用这个这个模式 的初衷是计 算出结果很慢。 This isn't a problem in our example because we can still calculate world coordinates fast enough to fit within a frame, but you can imagine other cases where the work you're doing is a big chunk that takes noticeable time to chew through. If the game doesn't start chewing until right when the player expects to see the result, that can cause an unpleasant visible name="gc">pause. 这在我们的例子中不是问题,因为计算世界坐标足够在一帧内完成。你可想想象其他情景,工作量大到需要一个 能够察觉的 时间才能完成。如果游戏直到玩家想要看到结果是才开始计算,这会导致一个不好的视觉暂停。 Another problem with deferring is that if something goes wrong, you may fail to do the work at all. This can be particularly problematic when you're using this pattern to save some state to a more persistent form. 另外一个延时的问题是如果某个东西出错,你可能陷入完全无法工作的境地。当你将状态保存在一个更加持久化的 形式中使 用这个模式时,问题会尤其突出。 For example, text editors know if your document has "unsaved changes". That little bullet or star in your file's title bar is literally the dirty flag visualized. The primary data is the open document in memory, and the derived data is the file on disk. 举个例子,文本编辑器知道文档是否还有“未保存的修改”。在你文件标题栏上的小子弹或者星星标识这个状态。 元数据是在 内存中的打开文档,衍生数据是磁盘上的文件。 Many programs don't save to disk until either the document is closed or the application is exited. That's fine most of the time, but if you accidentally kick the power cable out, there goes your masterpiece. Keep in Mind 牢记在心 There is a cost to deferring for too long 延时太长会有代价 许多程序在文档关键或者程序退出之前都不会存盘。这在大部分情况下都十分良好,但是如果你意外的将 电源线缆踢出,你 的工作就付之东流。 Editors that auto-save a backup in the background are compensating specifically for this shortcoming. The auto-save frequency is a point on the continuum between not losing too much work when a crash occurs and not thrashing the file system too much by saving all the time. 编辑器为了减缓这种损失会在后台自动保存一个备份。自动保存的频率是在既不丢失太多数据,也不造成文件 系统繁忙的一 个折中点。 This mirrors the different garbage collection strategies in systems that automatically manage memory. Reference counting frees memory the second it's no longer needed, but it burns CPU time updating ref counts eagerly every time references are changed. 与之类似的是自动内存管理系统中不同的垃圾回收策略。引用技术在没有使用时释放内存,但是每次引用技术 改变是 都刷新技术会十分消耗CPU时间。 Simple garbage collectors defer reclaiming memory until it's really needed, but the cost is the dreaded "GC pause" that can freeze your entire game until the collector is done scouring the heap. 简单垃圾回收策略将内存回收推迟到需要时再进行,但是代价是可怕的“GC 暂停”,他将是整个游戏冻结起来, 直到回 收器清理完了堆数据。 In between the two are more complex systems like deferred ref-counting and incremental GC that reclaim memory less eagerly than pure ref-counting but more eagerly than stop-the-world collectors. 在这两者之间的是更复杂的系统,如延时引用计数和增量式GC。他们比纯粹的引用计数更少的回收内存,但是比 暂 停世界的回收器更加频繁。 Since the derived data is calculated from the primary data, it's essentially a cache. Whenever you have cached data, the trickiest aspect of it is cache invalidation -- correctly noting when the cache is out of sync with its source data. In this pattern, that means setting the dirty flag when any primary data changes. 既然衍生数据是通过原始数据计算而来,它基本上就是一份缓存。单一修改数据时,棘手的问题是缓存失效。——当缓存 和 原始数据不同步是,什么都不正确了。在这个模式只能怪,它意味着当任何元数据变动是,都要设置脏标记。 Phil Karlton famously said, "There are only two hard things in Computer Science: cache invalidation and naming things." Phil Karlton 有句名言。"在计算机科学中只有两件难事:缓存失效和命名" Miss it in one place, and your program will incorrectly use stale derived data. This leads to confused players and bugs that are very hard to track down. When you use this pattern, you'll have to take care that any code that modifies the primary state also sets the dirty flag. 在一个地方错过,你的程序就会不正确的使用陈旧的数据。这回导致玩家困惑和十分难以跟踪的bug。当你使用这个模式 时, 你需要小心,在任何改动数据的地方都要设置脏标记。 One way to mitigate this is by encapsulating modifications to the primary data behind some interface. If anything that can change the state goes through a single narrow API, you can set the dirty flag there and rest assured that it won't be missed. 一个减轻这点的方法是将原始数据的改动封装起来。任何可能的变动都通过一个狭窄的API,你可以在这里这是脏标记,并 且不用担心会遗失。 You have to make sure to set the flag every time the state changes 必须保证每次 状态改动时都设置脏位 You have to keep the previous derived data in memory 必须在内存中保存上次的衍 When the derived data is needed and the dirty flag isn't set, it uses the previously calculated data. This is obvious, but that does imply that you have to keep that derived data around in memory in case you end up needing it later. 当需要衍生数据而赃位没有设置时,就会使用之前计算的数据。这是显然易见的,但是这不意味着你你必须你 必须将衍生数 据保存在内存中以防你之后会用到它。 This isn't much of an issue when you're using this pattern to synchronize the primary state to some other place. In that case, the derived data isn't usually in memory at all. 当你使用这个模式来同步原数据到其他地方是,这不是什么问题。在这种情况下,衍生数据更本不在内存中。 If you weren't using this pattern, you could calculate the derived data on the fly whenever you needed it, then discard it when you were done. That avoids the expense of keeping it cached in memory at the cost of having to do that calculation every time you need the result. 如果你没有使用这个模式,你可以在需要的时候即可计算衍生数据,然后在使用完之后丢弃。这避免了将 它缓存在内存中的 损失,代价是每次需要结果时都要计算一次。 Like many optimizations, then, this pattern trades memory for speed. In return for keeping the previously calculated data in memory, you avoid having to recalculate it when it hasn't changed. This trade-off makes sense when the calculation is slow and memory is cheap. When you've got more time than memory on your hands, it's better to calculate it as needed. 就行其他优化那样。这个模式在空间和时间上做平衡。当返回内存中之前计算的数据时,你避免了未修改数据 的重计算。这 在内存便宜而计算费时的情况下是合算的。当内存比时间更加宝贵是,按需计算会比较好。 Conversely, compression algorithms make the opposite trade-off: they optimize space at the expense of the processing time needed to decompress. 相反的,压缩算法做了相反的取舍,他利用耗时的解码来优化空间大小。 Let's assume we've met the surprisingly long list of requirements and see how the pattern looks in code. As I mentioned before, the actual math behind transform matrices is beyond the humble aims of this book, so I'll just encapsulate that in a class whose implementation you can presume exists somewhere out in the æther: 让我们假设我们我们满足了"超"长的要求列表,来看看这个模式在代码中是怎样的。如同我之前提到的,矩形计算的数学原 理不是本书目标,所以我封装在类里,它的实现你可以假设在其他地方。 class Transform { public: static Transform origin(); Transform combine(Transform& other); }; The only operation we need here is combine() so that we can get an object's world transform by combining all of the local transforms along its parent chain. It also has a method to get an "origin" transform -- basically an identity matrix that means no translation, rotation, or scaling at all. 这里我需要的唯一操作就是 combine() , 这样我们可以通过组合父链中所有的本地变换得到它的世界变换。它还有一个方法 来得到一个原本的变换。————一个简单的单元矩阵表示没有转换,旋转,缩放。 Next, we'll sketch out the class for an object in the scene graph. This is the bare minimum we need before applying this pattern: 生数据 Sample Code 示例代码 接下来,我们来定义场景图中的物体的类。这是我们运用这个模式的基础。 class GraphNode { public: GraphNode(Mesh* mesh) : mesh_(mesh), local_(Transform::origin()) {} private: Transform local_; Mesh* mesh_; GraphNode* children_[MAX_CHILDREN]; int numChildren_; }; Each node has a local transform which describes where it is relative to its parent. It has a mesh which is the actual graphic for the object. (We'll allow mesh_ to be NULL too to handle non-visual nodes that are used just to group their children.) Finally, each node has a possibly empty collection of child nodes. 每一个节点包含一个本地变换,描述它相对于父物体的变换。它还有一个单元,代表这个物体的真正图元。(我们允许 mesh_ 为 NULL 来处理只是为了组合子物体的不可见的节点)。最后,每个节点都包含一个可能为空的子物体集合。 With this, a "scene graph" is really only a single root GraphNode whose children (and grandchildren, etc.) are all of the objects in the world: 有了这个,一个”场景图“就是一个单一的根 GraphNode ,它的子节点(子子节点,...)就是世界中的所有物体。 GraphNode* graph_ = new GraphNode(NULL); // Add children to root graph node... In order to render a scene graph, all we need to do is traverse that tree of nodes, starting at the root, and call the following function for each node's mesh with the right world transform: 为了绘制一个场景图,我们需要做的就是遍历节点树,从根节点开始,为每个节点图元结合正确的世界变换调用下面的方 法。 void renderMesh(Mesh* mesh, Transform transform); We won't implement this here, but if we did, it would do whatever magic the renderer needs to draw that mesh at the given location in the world. If we can call that correctly and efficiently on every node in the scene graph, we're happy. 我们这里不 实现它,它做的都是一些将图元在给定的地方绘制出来的工作。如果我们能共正确并高效的在每个节点上调用它,那将十分 快乐。 To get our hands dirty, let's throw together a basic traversal for rendering the scene graph that calculates the world positions on the fly. It won't be optimal, but it will be simple. We'll add a new method to GraphNode : 开始做一份脏活,我们通过简单的遍历在渲染的时候计算世界坐标。它没有优化,但是简单。 我们为 GraphNode 添加一个新 方法。 void GraphNode::render(Transform parentWorld) { Transform world = local_.combine(parentWorld); if (mesh_) renderMesh(mesh_, world); An unoptimized traversal 未优化的遍历 for (int i = 0; i < numChildren_; i++) { children_[i]->render(world); } } We pass the world transform of the node's parent into this using parentWorld . With that, all that's left to get the correct world transform of this node is to combine that with its own local transform. We don't have to walk up the parent chain to calculate world transforms because we calculate as we go while walking down the chain. 我们用通过 parentWorld 将父节点的世界变化传给它。有了这个,剩下的工作就是将它和本地变化结合起来得到正确的世界 变换。我们不需要回溯到父节点去计算世界坐标,应为我们沿着父链下来已经计算过了。 We calculate the node's world transform and store it in world , then we render the mesh, if we have one. Finally, we recurse into the child nodes, passing in this node's world transform. All in all, it's a tight, simple recursive method. 我们计算节点的世界变换然后保存在 world 中,然后我们渲染非空图元。最有我们递归的进入子节点中,将当前节点的世界 变换传递进去。最后,这是一个紧凑,简单的递归方法。 To draw an entire scene graph, we kick off the process at the root node: 为了绘制整个场景图,我们空根节点开始渲染: graph_->render(Transform::origin()); So this code does the right thing -- it renders all the meshes in the right place -- but it doesn't do it efficiently. It's calling local_.combine(parentWorld) on every node in the graph, every frame. Let's see how this pattern fixes that. First, we need to add two fields to GraphNode : 这样,这份代码做了正确的操作——它在正确的地方渲染图元——但并不高效。它每帧都在每个node上调 用 local_.combine(parentWorld) 。让我们看脏模式是如何修正这点的,我们需要添加两个变量到 GraphNode 中。 class GraphNode { public: GraphNode(Mesh* mesh) : mesh_(mesh), local_(Transform::origin()), dirty_(true) {} // Other methods... private: Transform world_; bool dirty_; // Other fields... }; The world_ field caches the previously calculated world transform, and dirty_ , of course, is the dirty flag. Note that the flag starts out true . When we create a new node, we haven't calculated its world transform yet. At birth, it's already out of sync with the local transform. world_ 变量缓存了上次计算了的时间变换, dirty_ 变量就是脏标记。注意,这个变量用 true 初始化。当我们创建一个新 节点时,我们没有计算过他的世界变换。在开始,他就没有和本地变换同步。 The only reason we need this pattern is because objects can move, so let's add support for that: Let's get dirty 让我们‘脏’起来 我们需要这个模式的唯一理由是物体能够移动,所以我们来提供这个功能。 void GraphNode::setTransform(Transform local) { local_ = local; dirty_ = true; } The important part here is that it sets the dirty flag too. Are we forgetting anything? Right -- the child nodes! 这里重要的一点是同时设置dirty位。我们忘记什么了吗?哦 -- 子节点。 When a parent node moves, all of its children's world coordinates are invalidated too. But here, we aren't setting their dirty flags. We could do that, but that's recursive and slow. Instead, we'll do something clever when we go to render. Let's see: 当一个父节点移动时,它所有的子节点的世界坐标都无效了。但是这里,我们不设置他们的赃位。我们能做 这点,但是需要 递归而且缓慢。相反,我们在渲染是做点聪明的事。来看: void GraphNode::render(Transform parentWorld, bool dirty) { dirty |= dirty_; if (dirty) { world_ = local_.combine(parentWorld); dirty_ = false; } if (mesh_) renderMesh(mesh_, world_); for (int i = 0; i < numChildren_; i++) { children_[i]->render(world_, dirty); } } There's a subtle assumption here that the if check is faster than a matrix multiply. Intuitively, you would think it is; surely testing a single bit is faster than a bunch of floating point arithmetic. 这里有一个微妙的假设, if 检查要比矩阵乘法快。这是一个直观的想法;单bit测试要比一批浮点数计算快。 However, modern CPUs are fantastically complex. They rely heavily on pipelining -- queueing up a series of sequential instructions. A branch like our if here can cause a branch misprediction and force the CPU to lose cycles refilling the pipeline. 然而,现代CPU十分复杂,它们严重依赖流水线操作——一系列的操作指令队列。像我们这里的一份 if 分支会导致分 支预测错误强制CPU丢失流水线的循环填充。 The Data Locality chapter has more about how modern CPUs try to go faster and how you can avoid tripping them up like this. Data Locality 这一节有更多关于现代CPU是如何加快运行和你如何避免像这样妨碍它快速运行的细节。 This is similar to the original naïve implementation. The key changes are that we check to see if the node is dirty before calculating the world transform and we store the result in a field instead of a local variable. When the node is clean, we skip combine() completely and use the old-but-still-correct world_ value. 这和之前的原始实现很相似。关键的变动在于在计算世界变换之前,我们先检查脏位,并且我们将结果保存在成员中而不是 局部变量中。当节点没有改动时,我们完全跳过 combine() ,使用老的但是仍然正确的 world_ 值。 The clever bit is that dirty parameter. That will be true if any node above this node in the parent chain was dirty. In much the same way that parentWorld updates the world transform incrementally as we traverse down the hierarchy, dirty tracks the dirtiness of the parent chain. 聪明的位就是 dirty 参数。如果父链上他之上的任何物体标记为脏,它将被置为 true 。在我们递归的时候通过相同的方式通 过 parentWorld 渐进的更新世界变换。 dirty 跟踪父链是否为脏。 This lets us avoid having to recursively mark each child's dirty_ flag in setTransform() . Instead, we pass the parent's dirty flag down to its children when we render and look at that too to see if we need to recalculate the world transform. 这让我们避免在'setTransform()'中递归的标记每个子节点的‘dirty_’位。相反,我们在渲染是传递父节点的 dirty为到他的子节 点中,并查看它来确认是否有需要重新计算世界变换。 The end result here is exactly what we want: changing a node's local transform is just a couple of assignments, and rendering the world calculates the exact minimum number of world transforms that have changed since the last frame. 最终结果就是我们想要的:修改一个节点的本地变换只是几条赋值语句,渲染世界只计算了最少的变动的世界变换。 Note that this clever trick only works because render() is the only thing in GraphNode that needs an up-to-date world transform. If other things accessed it, we'd have to do something different. 注意,这个聪明的技巧能工作是因为 render() 是 GraphNode 中 唯一 需要实时世界坐标的操作。如果其他 操作访问 它,我们需要做一些不同的操作。 This pattern is fairly specific, so there are only a couple of knobs to twiddle: 这个模式是想的特定的,所以只需要注意几点: When the result is needed: 当需要计算结果时 It avoids doing calculation entirely if the result is never used. For primary data that changes much more frequently than the derived data is accessed, this can be a big win. 当结果重不使用时,它完全避免了计算当原数据变动的平率远大于衍生数据访问的频率是,优化 效果显著。 If the calculation is time-consuming, it can cause a noticeable pause. Postponing the work until the player is expecting to see the result can affect their gameplay experience. It's often fast enough that this isn't a problem, but if it is, you'll have to do the work earlier. 如果计算十分耗时,会造成明显的卡顿把工作推迟到玩家需要查看结果是才做会影响游戏体验。通常 计算足够快捷 而没什么问题,但是一旦计算十分,最好提前开始计算。 At well-defined checkpoints: 在精心设定的检查点 Sometimes, there is a point in time or in the progression of the game where it's natural to do the deferred processing. For example, we may want to save the game only when the pirate sails into port. Or the sync point may not be part of the game mechanics. We may just want to hide the work behind a loading screen or a cut scene. 有时,在游戏过程中有一个时间点十分适合做延时计算工作。举个例子,我们可能只想在船靠岸时才存档。或者 存档点 就是游戏机制的一部分。我们可能在一个加载界面或者一个截屏下做这些工作。 Doing the work doesn't impact the user experience. Unlike the previous option, you can often give something to distract the player while the game is busy processing. Design Decisions 设计讨论 When is the dirty flag cleaned? 何时清除脏位? 在不影响用户体验下工作不同之前的考虑,当游戏忙于处理时你可以通知玩家。 You lose control over when the work happens. This is sort of the opposite of the earlier point. You have micro- scale control over when you process, and can make sure the game handles it gracefully. What you can't do is ensure the player actually makes it to the checkpoint or meets whatever criteria you've defined. If they get lost or the game gets in a weird state, you can end up deferring longer than you expect. 当工作执行时,你失去了控制权 这和之前一点有些相反。在处理时,你有轻微的控制,保证游戏优雅的处理。 你不能确保玩家实际上到达检查点,或者达到任何你设定的标准。如果他们丢失或者游戏进入了奇怪的状态,你可 以将 预期的操作进一步延迟。 In the background: 在后台 Usually, you start a fixed timer on the first modification and then process all of the changes that happen between then and when the timer fires. 通常,你可以在最初变动的时候启动一个固定的计时器,并计算计时器到达之间的所有变动。 The term in human-computer interaction for an intentional delay between when a program receives user input and when it responds is hysteresis. 术语滞后 是人机交互中,故意将用户的输入和计算机响应推迟一段时间。 * *You can tune how often the work is performed.* By adjusting the timer interval, you can ensure it happens as frequently (or infrequently) as you want. * *你可以调整工作执行的频率。* 通过调整定时器的间隔,你可以按照你想要的频率处理。 * *You can do more redundant work.* If the primary state only changes a tiny amount during the timer's run, you can end up processing a large chunk of mostly unchanged data. * *你可以做更多冗余的工作。* 如果在定时器期间原始的改动很少,你可以中断处理很多没有修改的数据。 * *You need support for doing work asynchronously.* Processing the data "in the background" implies that the player can keep doing whatever it is that they're doing at the same time. That means you'll likely need threading or some other kind of concurrency support so that the game can work on the data while it's still being played. Since the player is likely interacting with the same primary state that you're processing, you'll need to think about making that safe for concurrent modification too. * *需要要进行异步操作。* 在后台处理数据意味着玩家可以同事做他正在做的事情。这意味着你需要线程或者其他并发支持,以便你能够在游戏进 行时处理数据。 Imagine our pirate game lets players build and customize their pirate ship. Ships are automatically saved online so the player can resume where they left off. We're using dirty flags to determine which decks of the ship have been fitted and need to be sent to the server. Each chunk of data we send to the server contains some modified ship data and a bit of metadata describing where on the ship this modification occurred. 想象一下我们的海盗游戏允许玩家建造和定制他们的海盗船。船会自动线上保存以便在玩家离线之后能恢复。我们 使用脏标 记来决定船的那一部分被改动了需要发送到服务器。每一份我们发送给服务器的数据包含了一些船的数据改动和 一份元数据 描述这份改动是在什么地方变动的。 How fine-grained is your dirty tracking? 脏标记追踪的粒度多大? If it's more fine-grained: 更精细的粒度: Say you slap a dirty flag on each tiny plank of each deck. 你将夹板上的每一份小木块加上脏标记。 You only process data that actually changed. You'll send exactly the facets of the ship that were modified to the server. 你只需要处理真正变动了的数据 你将船的真正变动发送给服务器 If it's more coarse-grained: 更粗糙的粒度: Alternatively, we could associate a dirty bit with each deck. Changing anything on it marks the entire deck dirty. 另外,我们可以为每一个甲板关联一个脏标记。在它之上的每份改动将整个甲板标记为脏。 I could make some terrible joke about it needing to be swabbed here, but I'll refrain. You end up processing unchanged data. Add a single barrel to a deck and you'll have to send the whole thing to the server. 你最终需要处理未变动的数据 在甲板上放置一个酒桶,你需要把整个东西发送给服务器。 Less memory is used for storing dirty flags. Add ten barrels to a deck and you only need a single bit to track them all. 存储脏标记消耗更少的内存 添加10个酒桶在甲板上只需要一个位来跟踪他们。 Less time is spent on fixed overhead. When processing some changed data, there's often a bit of fixed work you have to do on top of handling the data itself. In the example here, that's the metadata required to identify where on the ship the changed data is. The bigger your processing chunks, the fewer of them there are, which means the less overhead you have. 固定开销花费更少的时间 当处理修改后的数据时,通知有一套固定的流程要预先处理这些数据。在这个例子中, 就 是标识船上那出地方改动了的数据。处理越大块,标识就越少,通用开支就少。 This pattern is common outside of games in browser-side web frameworks like Angular. They use dirty flags to track which data has been changed in the browser and needs to be pushed up to the server. 这种模式在游戏外的领域也是常见的,比如像Angular这种BS框架。他利用赃标记来跟踪浏览器中变动 的数据以及需要 提交到服务端的数据。 Physics engines track which objects are in motion and which are resting. Since a resting body won't move until an impulse is applied to it, they don't need processing until they get touched. This "is moving" bit is a dirty flag to note which objects have had forces applied and need to have their physics resolved. 物理引擎跟踪着那些物理在运动那些空闲。应为一个空闲的物理知道收到力的作用才会移动,他在受力 之前不需要处 理。这个“是否在移动”是一个赃位,来标记那些物体收到了力的作用并需要计算他们的 物理状态。 See Also 参考 使用固定池重用对象,取代手动分配,释放对象,以此来达到提升性能和内存使用的目的 我们正致力于我们游戏的视觉效果优化。当一个英雄施放魔法时,我们想让一个闪烁的火花在屏幕中崩裂。这一特效将调用 粒子系统——一个用来生成微小发光图形并在它们生存周期内产生动画的引擎。 由于光是一个魔棒就会引发数以百计的粒子生成,故我们的系统需要快速地生成它们。更重要的是,我们需要确保创建和销 毁它们时不会带来内存碎片。 控制台游戏编程(比如XBox360)从多方面而言都比传统的PC编程要更接近于嵌入式编程。就像嵌入式编程一样,控制台游戏 必须在很长的一段时间内运行而不能有崩溃或是内存泄露的情况,而且多数情况下无法使用高效的内存压缩管理器。在这样 的情况下内存碎片是致命的。 碎片化意味着我们空闲着的堆空间被破坏成了许多小的内存碎屑,而不是一整块连续的内存块。或许这些小碎屑构成的可访 问内存总量是很大的,但其中最连续的区域却可能小得可怜。假如我们有14字节的空闲内存,但它被一个已使用内存分割为 了两个7字节的片段——假如这时我们想分配一个12字节的对象,则会失败,屏幕上也不会出现任何闪光画面了。 注解 碎片化:这就像在一条杂乱散布着车辆的热闹街区里尝试停车一样,如果他们首尾紧挨着,那么就能腾得出空间,但 在乱停放的情况下这些空间却只是众车辆之间的碎片空间。 上图解释了一个堆如何变得碎片化,以及这是如何导致内存分配失败(尽管在理论上有足够的空间供其分配)。 就算碎片化的 情况很少,它仍然在削减着堆并使其成为一个千疮百孔而不可用的泡沫块,严重局限了整个游戏的表现力。 对象池 目的 动机 碎片化的害处 注解 浸泡测试: 许多控制台游戏制作者都要求游戏通过“浸泡测试”(soak tests)——他们将游戏置于demo模式连续地跑上 好几天。假如游戏崩溃了,他们则不会让游戏投入市场。尽管浸泡测试的失败有时会来自极罕见的意外bug,但多数情 况下碎片化的扩张或者是内存泄露才是导致游戏当机的原因。 由于碎片化,以及内存分配缓慢的缘故,在游戏中何时以及如何管理内存需要十分谨慎。一个常用而有效的办法是:在游戏 启动时分配一大块内存,直到游戏结束才释放它。但当我们在游戏运行过程中创建或销毁东西时,这一方法会成为系统的硬 伤。 使用对象池使得我们二者兼顾:对于内存管理器而言,我们仅分配一大块内存直到游戏结束才释放它,对于内存池的使用者 而言,我们可以按照自己的意愿来分配和释放对象。 定义一个保持着可重用对象集合的对象池类。其中的每个对象支持对其”使用中”(“in use”)状态的访问,以确定这一对象目前 是否“存活”(“alive”)。当对象池初始化时,它预先创建整个对象的集合(通常为一块连续堆区域),并将它们都置为”未使用” (“not in use”) 状态。 当你想要创建一个新对象时就向对象池请求。它将搜索到一个可用的对象,将其初始化为”使用中”(“in use”)状态并返回给 你。当该对象不再被使用时,它将被置回”未使用” (“not in use”) 状态。使用该方法,对象便可以在无需进行内存(或其他资源 的)分配的情况下进行任意的创建和销毁。 这一设计模式被广泛地应用在游戏中,如游戏实体对象,各种视觉特效,甚至是非可视化的数据结构,如当前播放的声音。 我们在以下情况使用对象池: 当你需要频繁地创建和销毁对象时。 对象的大小一致时。 在堆上进行对象内存分配较慢或者会产生内存碎片时。 每个对象包含着较昂贵且可重用的资源(如数据库,网络的连接)时。 你一般依赖于一个垃圾回收器或只是简单地new/delete来为项目进行内存管理。而通过使用对象池,你就是在告诉系统:“我 更明白这些字节应该如何处理。”也就意味这个模式的规则是完全由你来负责制定的。 对象池的大小需要根据游戏的需求量身定制。在确定大小时,分配过小的情况往往很明显(任何一个崩溃都能告诉你这一 点),但要同时注意不能让池子过大。一个大小适中的池可以腾出空余的内存来供其它模块使用。 从某些角度上说这是件好事。将内存划分为几个独立的对象池用于不同类型的对象管理,这一点保证诸如下面的这些情况不 会发生:例如,一大连串的爆炸动画不会致使你的粒子系统把所有的可有内存全部占用,也能避免创建敌人时的类似情况。 然而,这也意味着你要为如下情况做好准备:当你希望向对象池申请重用某个对象时,可能会失败,因为它们都在被使用。 以下是一些针对此问题的常见对策: 彻底根除。这也是最常见的方法:约束对象池的大小令其不论使用者如何分配都不会致使溢出。对于重要的对象池,如 怪物或游戏道具池,这往往是行之有效的。并没有什么所谓正确的方法来处理当玩家到达关卡尾部时没有任何空闲的空 二者兼顾 (对象池)模式 使用情境 使用须知 对象池可能在闲置的对象上浪费内存 任意时刻处于”使用中”状态的对象数目恒定 间来创建”大Boss”这样的情况,所以最聪明的办法还是从根本上避免其发生。 上述方法的负面是,它会令你仅仅为了十分罕见的边际情况而腾出许多空闲的对象空间。鉴于此,单一的固定大小的对 象池并不适用于所有的游戏状态。例如,有些关卡显著偏重于特效而另一些则偏重于音效。在此情况下,可以考虑针对 不同的场景将池调整至不同尺寸。 不创建对象。这听起来很残忍,但它在诸如粒子系统中十分奏效。假如所有的粒子对象都处于使用状态,那么屏幕将可 能被闪光的图元所覆盖。玩家将不会注意到下一次的爆炸效果是否和当前的效果是否一样炫。 强行清理现存对象。以一个音效对象池为例,并假设你想要播放新的一段音效但对象池满了。你并不希望直接忽视掉这 个新的音效:玩家会注意到他们的魔杖在施法时有时带着咒语而有时却不听话地沉默了。解决方案是,检索当前播放的音 效中最不引人注意的并以我们的新音效替换之。新的音效将掩盖旧音效的中断。 一般来说,如果新对象的出现能让我们无法觉察到既有对象的消失,那么清理现存对象的方法会是一个好选择。 增加对象池的大小。假如游戏允许你调配更多的内存,你可以在运行时对对象池扩容,或者增设一个二级的溢出池。假 如你通过上述任一种方法获取到更多内存,那么当这些额外空间不再被占用时你就必须考虑是否将池的大小恢复到扩容 之前。 多数对象池在实现时将对象原地存入一个数组中。假如你的所有的对象都属于同一类型,没问题。然而假如你希望在池中存 入不同类型的对象,或者子类型(带有额外的类成员),那么你就必须保证对象池中的每个槽都能容纳这些类型中尺寸最大 者。否则一个未知的大对象将占去相邻对象的空间,并导致内存崩溃。 与此同时,当你的对象大小不一时,将浪费内存——因为每个对象槽的大小都被要求容得下尺寸最大的那个。假如多数对象 的尺寸不那么大,那么每当你置入一个小对象时就在浪费内存。就像你在机场为自己的钱包拉了个大托运一样。 当你发现自己像这样浪费掉许多内存时,考虑根据对象的尺寸将一个池划分为多个——大的装行李,小的装口袋里的杂物。 注解 多个对象池: 这是一个实现内存高效利用的通用设计模式。管理器持有许多块尺寸不同的池。当你向它们申请一块时, 管理器将从池里挑选合适大小的块并返回给你。 多数内存管理器都有一个排错特性:它们会将刚分配或者刚释放的内存置成某些特定值(比如0xdeadbeef),这一做法将帮助 你找到那些由”未初始化的变量”或者”使用了已释放的内存块”引发的致命错误。 由于我们的对象池并不通过内存管理器来重用对象,故我们将脱离这张安全网。更可怕的是,这些”新”对象使用的内存先前 存储着另一个同类型的对象。这将使你几乎无法分辨自己是否在创建对象时已将它们初始化——这块存储新对象的内存可能 在其先前的生命周期中已经包含了完全相同的数据。 鉴于此,需要特别注意在对象池中初始化对象的部分要对新创建的对象完整地初始化。甚至值得花些功夫为在为清理对象槽 内存时增设一个排错功能。 注解 推荐清空后将其内存值置为 0x1deadb0b 对象池在那些支持垃圾回收机制的系统中较少被使用,因为内存管理器通常会替你进行内存碎片处理。当然对象池在节省分 配和释放时开销方面依然有所作为,在CPU处理速度较慢且回收机制叫简单的移动平台上尤为如此。 假如你使用了对象池,请注意一个潜在的矛盾:由于对象池在对象不再被使用时并不真正地释放它们,故它们仍占用内存。 假如它们包含了指向其他对象的引用,这也将阻碍回收机制对它们进行释放。为避免这些问题,当对象池中的对象不再被需 要时,应当清空它指向其他任何对象的引用。 每个对象的内存大小是固定的 重用对象不会被自动清理 未使用的对象将占用内存 示例 模拟现实的粒子系统常常会应用到重力,风力,摩擦力以及其他物理效果。在简化的示例中,我们只是在几帧的时间内将粒 子沿着直线移动一些距离,并在结束后销毁它们。虽不比标准的电影水准,但足以为我们展示对象池的应用。 让我们从最简单的实现开始,首先是粒子类: class Particle { public: Particle() : framesLeft_(0) {} void init(double x, double y, double xVel, double yVel, int lifetime) { x_ = x; y_ = y; xVel_ = xVel; yVel_ = yVel; framesLeft_ = lifetime; } void animate() { if (!inUse()) return; framesLeft_--; x_ += xVel_; y_ += yVel_; } bool inUse() const { return framesLeft_ > 0; } private: int framesLeft_; double x_, y_; double xVel_, yVel_; }; 默认构造函数将粒子初始化为”未使用”状态。(“not in use”),接下来调用init()将其状态置为”使用中”。 粒子随着时间播放动 画,并逐帧调用函数animate()。 对象池需要知道哪些粒子可被重用——通过粒子实例的inUse()方法来获取粒子的状态。它利用粒子的生命周期有限这一点, 同时我们使用变量_framesLeft来检查哪些粒子正在被使用(而不是使用一个分隔标志)。 对象池类也很简单: class ParticlePool { public: void create(double x, double y, double xVel, double yVel, int lifetime); void animate() { for (int i = 0; i < POOL_SIZE; i++) { particles_[i].animate(); } } private: static const int POOL_SIZE = 100; Particle particles_[POOL_SIZE]; }; create()函数使用内部代码创建粒子群。游戏逐帧调用对象池的animate() 方法,它遍历池中所有粒子并调用它们的animate() 函数。 注解 这里的 animate() 方法是 Update Method 设计模式的一个例子。 对象池简单地使用一个固定大小的数组来存储粒子。在本例的实现中,这个数组的大小在其类声明中被硬编码地写死,当然 也可以通过根据给定的大小使用动态数组,或者使用值模板参数来定义。 创建粒子是直接明了的: void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) { // Find an available particle. for (int i = 0; i < POOL_SIZE; i++) { if (!particles_[i].inUse()) { particles_[i].init(x, y, xVel, yVel, lifetime); return; } } } 我们通过遍历池来寻找首个可用(闲置)的粒子。一旦找到,我们将它初始化并立即返回。注意在这个版本的实现中,假如没 有找到可用的粒子,我们就不再创建新粒子。 以上全部就是一个简单的粒子系统,当然不包括粒子的渲染啦~。我们现在可以创建一个粒子池,并通过它创建一些粒子。当 粒子的生命周期结束时它们会自动地将自己反激活。 这已经足以在游戏中使用,但细心的读者会发现,创建一个新粒子需要在池内部遍历粒子数组直到找到一个空槽。假设这个 池数组很大且几乎已满,此时创建粒子将会十分缓慢。让我们来看看如何由此提升性能: 注解 时间开销:创建一个粒子的时间开销为O(n),上过算法课的你一定还记得时间复杂度吧。 假设我们不花时间去检索空闲的粒子槽,那么显然我们得跟踪它们。我们可以单独维护一个指向每个未被使用粒子的指针列 表。此时,当我们需要创建粒子时,我们只需移除这个列表的第一项并将这第一项指针指向的粒子进行重用即可。 不幸的是,这可能要求我们管理如同整个对象池对象数组一样庞大的指针列表。毕竟,当我们首次创建对象池时,所有的粒 子都是未被使用的,也就是说此时这个列表包含了指向对象池中每个粒子的指针。 假如能不牺牲任何内存来修补我们的性能问题那就太好了。方便的是,我们身边就有一些可利用的资源:正是那些未被利用 的粒子它们自己。 当某个粒子未被使用时,它们中的绝大多数是状态异常的,它们的位置和速度都未被使用。它唯一需要的就是用于表示自身 是否被销毁的状态,也就是我们的例子中的framesLeft 成员。除此之外的其他空间都是可利用的,修改后的例子如下: class Particle { public: // ... Particle* getNext() const { return state_.next; } void setNext(Particle* next) { state_.next = next; } private: int framesLeft_; union { // State when it's in use. struct { double x, y; double xVel, yVel; } live; // State when it's available. 空闲表 Particle* next; } state_; }; 例子如下:我们将除了framesLeft之外的成员变量置入一个live 结构体中,并将它置入一个state联合体中。该结构包括了粒 子在播放动画时的状态。当粒子未被使用时(也就是联和结构的其他情况下),成员next 将被激活。该成员存储了一指向下其 一个可用粒子的指针。 注解 联合体union: 在联和体似乎并不那么常用的今天,这个符号可能对你而言有些陌生。假如你在一个游戏团队工作,你 可能会成为一个”内存问题专家”:也就是个当游戏不可避免地遇上内存吃紧问题时忙着想办法的受难同胞。想想联合体 吧,说到节省字节的办法它们可谓了如指掌。 我们可以利用这些指针(next成员)来创建一个对象池中未被使用的粒子列表。我们持有所需的可用粒子列表,且无需额外的内 存——我们将那些已死亡粒子占用的空间划分过来以存储这个列表。 这机智的技术被称作空闲表(free list),为使其正常运作,我们需要保证指针的正确初始化以及粒子在被创建和销毁时能够保 持住它们。当然,我们也需要时刻跟踪这个列表的头指针: class ParticlePool { // ... private: Particle* firstAvailable_; }; 当对象池首次被创建时,所有的粒子处于可用状态,故我们的空闲表贯穿了整个对象池。对象池的构造函数如下: ParticlePool::ParticlePool() { // The first one is available. firstAvailable_ = &particles_[0]; // Each particle points to the next. for (int i = 0; i < POOL_SIZE - 1; i++) { particles_[i].setNext(&particles_[i + 1]); } // The last one terminates the list. particles_[POOL_SIZE - 1].setNext(NULL); } 现在创建一个新粒子时我们跳转到第一个空闲的粒子: 注解 O(1)复杂度,宝贝!万事顺利! void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) { // Make sure the pool isn't full. assert(firstAvailable_ != NULL); // Remove it from the available list. Particle* newParticle = firstAvailable_; firstAvailable_ = newParticle->getNext(); newParticle->init(x, y, xVel, yVel, lifetime); } 我们需要获知粒子何时死亡以将它置回空闲表中。于是我们将粒子类中的 animate() 改为当这个存活的粒子在某一帧死掉时 函数返回true 。 bool Particle::animate() { if (!inUse()) return false; framesLeft_--; x_ += xVel_; y_ += yVel_; return framesLeft_ == 0; } 一帧死掉此时,我们就把这个粒子串回空闲表: void ParticlePool::animate() { for (int i = 0; i < POOL_SIZE; i++) { if (particles_[i].animate()) { // Add this particle to the front of the list. particles_[i].setNext(firstAvailable_); firstAvailable_ = &particles_[i]; } } } 这就是了,一个漂亮的,在创建和删除时具有常量时间开销的小对象池。 如你所见,最简单的对象池实现几乎没什么特别的:创建一个对象数组并在它们被需要时重新初始化。实际项目中的代码可 不会这么简单。还有许多扩展对象池的方法,来使其更加通用,安全,便于管理。当你在自己的游戏中使用对象池时,你需 要回答以下问题: 当你在编写一个对象池时首先要问的一个问题就是这些对象自身是否能知道自己处于一个对象池中。多数时间它们是知道 的,但你不需要在一个可以存储任意对象的通用对象池类中做这项工作。 01.实现很简单,你可以简单地为那些池中的对象增加一个”使用中”的标志或者函数,这就能解决问题了。 02.你可以保证对象只能通过对象池创建。在C++中,只需简单地将对象池类作为对象类的友元类,并将对象的构造函数私有 化即可: class Particle { friend class ParticlePool; private: Particle() : inUse_(false) {} bool inUse_; }; class ParticlePool { Particle pool_[100]; 设计决策 对象是否被加入对象池? 假如对象被加入对象池: }; 上述代码中表述的关系指出了使用该对象类的方法(只能通过对象池创建对象),确保了开发者不会创建出脱离对象池管理的 对象。 03.你可以避免存储一个”使用中”标志,许多对象已经维护了可以表示自身是否仍然存活的状态。例如,粒子可以通过”位置已 离开屏幕范围”来表示自身可被重用。假如对象类知道自己可能被对象池使用,它可以提供inUse()方法来检查这一状态。这避 免了对象池使用额外的空间来存储那些”使用中”标志。 01.任意类型的对象可以被置入池中。这是个巨大的优点。通过对象与对象池的解绑,你将能够实现一个通用,可重用的对象 池类。 02.“使用中”状态可能会在对象外部被追踪。最简单的做法是在对象池中额外创建一块独立的空间: template class GenericPool { private: static const int POOL_SIZE = 100; TObject pool_[POOL_SIZE]; bool inUse_[POOL_SIZE]; }; 为了重用现存的对象,它需要被重新初始化成新的状态。一个关键的问题在于是在对象池中初始化它还是在外部初始化。 01.对象池可以完全地封装它管理的对象。这取决于你定义的对象类的其他功能,你或许能够将它们完全置于对象池内部。这 样可以确保外部代码不会引用到这些对象而引致意外的重用。 02.对象池与对象如何被初始化密切相关。一个置入池中的对象可能会提供多个初始化函数。假如由对象池进行初始化管理, 其接口必须支持所有的对象初始化方法,并相应地初始化对象。 class Particle { // Multiple ways to initialize. void init(double x, double y); void init(double x, double y, double angle); void init(double x, double y, double xVel, double yVel); }; class ParticlePool { public: void create(double x, double y) { // Forward to Particle... } void create(double x, double y, double angle) { // Forward to Particle... } void create(double x, double y, double xVel, double yVel) { // Forward to Particle... } }; 假如对象不被加入池中: 谁来初始化那些被重用的对象? 假如在对象池内部初始化重用对象 01.此时对象池的接口会简单一些,池只要简单地返回新对象的引用,而无需像上面那样提供不同的初始化接口来应付对象不 同的初始化方法了。 class Particle { public: // Multiple ways to initialize. void init(double x, double y); void init(double x, double y, double angle); void init(double x, double y, double xVel, double yVel); }; class ParticlePool { public: Particle* create() { // Return reference to available particle... } private: Particle pool_[100]; }; 调用者可以使用粒子类暴露的任何初始化接口来初始化对象: ParticlePool pool; pool.create()->init(1, 2); pool.create()->init(1, 2, 0.3); pool.create()->init(1, 2, 3.3, 4.4); 02.外部编码可能需要处理新对象创建失败的情况。先前的例子假设了create() 函数总会成功地返回一个指向对象的指针。假 如对象池满,它应当返回NULL。安全起见,你需要在初始化对象之前检查指向新对象的指针是否为空: Particle* particle = pool.create();if (particle != NULL) particle->init(1, 2); 对象池模式与Flyweight模式看起来很相似。它们都管理着一系列可重用对象。其差异在于”重用”的含义。Flyweight模式 中的对象通过在多个持有者中并发地共享相同的实例以实现重用。它避免了因在不同上下文中使用相同对象而导致的内 存使用重叠。 对象池中的对象也被重用,但此”重用”是针对一段时间而言的。在对象池中,”重用”意味着在原对象持有者使用完它之 后,将其内存释放。对象池里的对象在其生命周期中不存在着因为被共享而引致的异常。 将那些类型相同的对象在内存上进行打包整合,能够帮助你的CPU缓冲区时刻保持满载,以供游戏迭代这些对象。Data Locality设计模式阐释了这一点。 =============================== 上一节 目录 下一节 假如对象在外部被初始化 参考 将对象存储在根据位置组织的数据结构中来高效的定位对象。 游戏允许我们来探寻其他世界,但这些世界和我们的世界有着明显的不同。他们通常与我们宇宙中共享着相同的基本物理规 则和形体。这就是为什么他们看起来是那么的真实,尽管被制作成了位和像素点。 我们在这虚拟现实中将要关注的一点就是位置。游戏世界具有空间感,对象则分布于空间中的某处。这个体现于很多方式。 一个明显的例子就是物理--对象的移动,碰撞和相互影响,但也有其他的例子。比如音频引擎会考虑声源与那些角色相关, 从而更远的声音要相对安静点。在线聊天可能被限制在附近的玩家之间。 这意味着你的游戏引擎通常需要解决这个问题,“对象的附近有什么物体?”如果在每一帧中它不得不花费足够的时间来解决 这个问题的话,那么就是遇到性能瓶颈了。 假设我们在制作一款即时策略游戏。数百个敌军单位将聚集在战场上。勇士们需要知道他们附近有哪些敌军,简单的方式处 理就是查看每一对单位看看他们彼此有多靠近。 void handleMelee(Unit* units[], int numUnits) { for (int a = 0; a < numUnits - 1; a++) { for (int b = a + 1; b < numUnits; b++) { if (units[a]->position() == units[b]->position()) { handleAttack(units[a], units[b]); } } } } 这里我们用了一个双重循环,每一个循环遍历了战场上的所有单位。这意味着我们每一帧成对检验的次数随着单位个数的平 方数而增加。每增加一个额外的单位,都要于所有前面的单位进行比较。当单位个数非常大时,便会失控。 注:内循环并没有遍历所有的对象。它只是遍历了外循环还没有访问过的对象。这样就避免了对每一对单位进行两次 比较,每一次顺序不同而已。如果我们已经处理过了 A和 B 之间的碰撞,我们就 不再需要再次检测 B 和 A 之间的碰 撞。 在 Big-O 方面,尽管仍然是 O(n²)。 我们遇到的问题是单位数组没秩序可循。为了找到某处位置附近的单位,我们不得不遍历整个数组。现在,想象一下我们简 化下这个游戏。我们将战场想象成1维战场线,而不是2维的战场。 空间分区 目的 动机 战场单元 绘制战线 在这种情况下,我们通过单位在战场线上的位置来将数组排序可以让事情变得更简单点。一旦我们做到了这点,我们变可以 使用类似二分查找的方式来寻找附近的单位而不是遍历扫描整个数组。 注:二分查找复杂度为 O(log n),意味着寻找所有战场单位复杂度从 O(n²) 降到了 O(n log n)。像鸽巢排序的算法可以 将复杂度降到 O(n)。 我们来举一反三下:如果我们将我们的对象用他们的位置信息来组织存储为一个数据结构,我们就能更快的查找到他们。这 个模式便是将这个想法应用到了超越1维的空间。 对于一组对象而言,每一个对象在空间都有一个位置。将对象存储在一个根据对象的位置来组织对象数据结构中,该数据结 构可以让你高效的查询靠近某处的对象。当对象的位置变化时,更新该空间数据结构以便可以持续查找对象。 这是一个常见的模式,用来存储存活的,移动的游戏对象以及静态图像和游戏世界的几何形状。复杂的游戏常常有多个空间 分区来应对不同类型的内容。 该模式的基本要求是你有一组对象,每个对象都有某种位置信息,而你因为要根据位置做大量的查询来查找对象从而遇到了 性能问题。 空间分区将 O(n) 或者 O(n²) 操作变得更易于管理。对象越多,模式的价值就越大。相反,如果你的 n 很小,可能不值得使用 这个模式。 由于这种模式要根据对象的位置来组织对象,对象改变位置就变得难以处理。你必须重新组织数据结构来跟踪物体的新位 置,这会增加了代码的复杂性和花费CPU周期。确保这么做是值得的。 注:想象一下,一个哈希表如果哈希对象的键可以自发的改变,你就会感觉到为什么棘手了。 空间分区会使用额外的内存来保存数据结构。就像许多的优化一样,它是以空间换取速度。如果在时钟周期内内存吃紧的 话,这可能是个亏本生意。 模式的本质就在于他们的变化性--每一个实现都有所不同,虽然不像其他的模式,许多这样的变化都文档丰富。学术界喜欢 发表论文以此来证明在性能上的提升。因为我只关心模式背后的概念,所以我准备为你展示最简单的空间分区:一个固定的 网格。 注:查看本章节最后一部分列举的游戏中最常见的一些空间分区结构。 关于该模式 使用情境 使用须知 范例代码 设想一下战场的整个区域。现在,将固定大小的正方形拼接起来就像一张方格纸一样。我们不将我们的单位存储在一个单一 的数组中,相反的,我们将他们放在这个网格的单元格中。每一个单元格从存储着一系列单位,他们的位置就在单元格的边 界之内。 我们在处理战斗时,只考虑在同一个单元格内的单位。我们不会将每个单位与游戏中的其他单位一一比较,取而代之的是, 我们已经将战场划分成了一堆更小的小型战场,每一个小战场里有着较少的单位。 好的。让我们开始编码。首先,做些准备工作。下面是 Unit 类: class Unit { friend class Grid; public: Unit(Grid* grid, double x, double y) : grid_(grid), x_(x), y_(y) {} void move(double x, double y); private: double x_, y_; Grid* grid_; }; 每一个单位都有一个位置(二维空间中)和一个指向 Grid 的指针。我们将 Grid 做为友元类,因为就像我们看到的,当一个 单位的位置发生改变时,我们不得不对 grid 进行处理确保一切都更新正常。 下面是 Grid 的大体样子: class Grid { public: Grid() { // Clear the grid. 一张方格纸 链接单位,形成网格 for (int x = 0; x < NUM_CELLS; x++) { for (int y = 0; y < NUM_CELLS; y++) { cells_[x][y] = NULL; } } } static const int NUM_CELLS = 10; static const int CELL_SIZE = 20; private: Unit* cells_[NUM_CELLS][NUM_CELLS]; }; 注意到每一个 cell 都是指向一个 unit 的一个指针。下面我们将用 next 和 prev 指针来扩展 Unit : class Unit { // Previous code... private: Unit* prev_; Unit* next_; }; 这下我们就能用一个双重链表来组织单位来取代数组了。 网格中的每个 cell (单元格)都会指向单元格之内的单位列表的第一个单位,而每个单位都有指针用来指向列表中之前和之后 的单位。我们很快就了解为什么这么做。 注:在这本书中,我避免使用了 C++ 标准库的任何内建集合类型。我想要用尽可能少的外部知识来理解这些例子,而 且,就像魔术师的“nothing up my sleeve”,我想描述更清楚代码做了 什么。细节很重要,尤其设计到性能相关时。 但这是我解释模式时的一个选择。如果你在实际编码使用时,自己搞定所用语言的内建集合类型。生命短暂无需从头 开始编写链表。 我们需要做的第一件事就是将新创建出来的单位放到网格中。我们使用 Unit 类的构造函数来处理: Unit::Unit(Grid* grid, double x, double y) : grid_(grid), x_(x), y_(y), prev_(NULL), next_(NULL) { grid_->add(this); } add() 方法实现如下: void Grid::add(Unit* unit) { 进入战场 // Determine which grid cell it's in. int cellX = (int)(unit->x_ / Grid::CELL_SIZE); int cellY = (int)(unit->y_ / Grid::CELL_SIZE); // Add to the front of list for the cell it's in. unit->prev_ = NULL; unit->next_ = cells_[cellX][cellY]; cells_[cellX][cellY] = unit; if (unit->next_ != NULL) { unit->next_->prev_ = unit; } } 注:除以单元格的尺寸将世界坐标转换到了单元格坐标。然后使用int类型来截断小数部分,就到了单元格的索引。 代码有点像链表代码一样挑剔,但基本思想很简单。我们找到单位所处的单元格然后将它添加到链表的前面。如果单位列表 已经存在,我们将它链接到新单位的后面。 当所有单位被置入单元格后,我们便让他们开始相互攻击。在 Grid 类中,处理战斗的主要函数如下: void Grid::handleMelee() { for (int x = 0; x < NUM_CELLS; x++) { for (int y = 0; y < NUM_CELLS; y++) { handleCell(cells_[x][y]); } } } 上面的方法遍历了每一个单元格,并且调用 handleCell() 。正如你所见,我们确实已经将大战场切分成了一些孤立的小规模 冲突。每个单元格处理战斗函数如下: void Grid::handleCell(Unit* unit) { while (unit != NULL) { Unit* other = unit->next_; while (other != NULL) { if (unit->x_ == other->x_ && unit->y_ == other->y_) { handleAttack(unit, other); } other = other->next_; } unit = unit->next_; } } 除了指针处理遍历一个链表,注意到,这是完全像原来我们处理战斗的方法。它会比较每对单位,看看他们是否处在了相同 的位置。 唯一的区别是,我们不再需要比较战斗中的所有对方单位--只是比较在同一个单元格内足够接近的单位。这便是优化的核心 所在。 注意:简单分析下,看起来我们这么做使得性能变得更差。我们将单元格遍历一个双重嵌套循环变成了三重嵌套循 环。但这里的窍门是,这两个内部循环现在遍历的数目已经很少了,这将足以 抵消外部循环遍历的单元格的代价。 刀光剑影的战斗 不过,这个并不取决于我们单元格的颗粒度。让单元格变得更小对于外部循环来说无关紧要。 我们已经解决了性能问题,但却遇到了一个新的问题。单位现在都被困在了单元格里面。如果将单位从它所在的单元格移动 出去,那么这个单元格中的其他单位将不会再看到这个单位,而其他任何单位也不会再看到。我们战场分区分的有些过了。 为了修复这个问题,我们还需要在单位每次移动的时候做一点工作。如果单位越过了单元格的边界线,我们需要将单位从单 元格移除掉并且添加到新的单元格中。首先,我们给 Unit 类添加一个方法来改变它的位置: void Unit::move(double x, double y) { grid_->move(this, x, y); } 从使用上看,这段代码可以被计算机控制的单位的AI代码调用,也可以被玩家控制单位的用户输入代码控制。它所做就是将 控制权交给 grid,grid 的 move 如下: void Grid::move(Unit* unit, double x, double y) { // See which cell it was in. int oldCellX = (int)(unit->x_ / Grid::CELL_SIZE); int oldCellY = (int)(unit->y_ / Grid::CELL_SIZE); // See which cell it's moving to. int cellX = (int)(x / Grid::CELL_SIZE); int cellY = (int)(y / Grid::CELL_SIZE); unit->x_ = x; unit->y_ = y; // If it didn't change cells, we're done. if (oldCellX == cellX && oldCellY == cellY) return; // Unlink it from the list of its old cell. if (unit->prev_ != NULL) { unit->prev_->next_ = unit->next_; } if (unit->next_ != NULL) { unit->next_->prev_ = unit->prev_; } // If it's the head of a list, remove it. if (cells_[oldCellX][oldCellY] == unit) { cells_[oldCellX][oldCellY] = unit->next_; } // Add it back to the grid at its new cell. add(unit); } 上面代码较多,但是却很简单。我们首先检查单位是否越过了单元格的边界。如果没有,我们只需要更新单位的位置就完成 了。 如果单位离开了所在的单元格,我们将它从单元格的链表中移除掉,然后将之添加到 grid 上。就像添加一个新单位一样,这 样会将单位插入到链表中对应着新的单元格。 这就是为什么我们会使用一个双重链表--我们通过设定少量几个指针就可以非常快速的从链表中添加和移除单位。在每一帧 有着大量的单位移动时,这样就显得非常重要。 移动 近在咫尺,短兵相接 这个似乎看起来很简单,但是我用某种方式作了弊。在例子中,当单位出在相同位置时才会相互作用。对于跳棋和国际象棋 是没问题的,但是对于更逼真的游戏来说就不适用了。那些通常要考虑到攻击距离。 这种模式仍然工作良好。不需要检查位置是否精确的匹配时,我们这么做: if (distance(unit, other) < ATTACK_DISTANCE) { handleAttack(unit, other); } 当范围足够接近,我们需要考虑到一种情况:在不同的单元格内的单位也可以足够靠近来相互作用。 像上图中,B在A的攻击范围内即便他们的中心点位于不同的单元格。为了处理这种情况,我们不仅需要相同单元格的单位, 还要比较相邻单元格的单位。为达目标,首先我们将 handleCell() 的内循环切分开来。 void Grid::handleUnit(Unit* unit, Unit* other) { while (other != NULL) { if (distance(unit, other) < ATTACK_DISTANCE) { handleAttack(unit, other); } other = other->next_; } } 现在我们的函数会采用一个单一的单位和链表的其他单位来判断是否有相互作用。然后我们用 handleCell() 这么做: void Grid::handleCell(int x, int y) { Unit* unit = cells_[x][y]; while (unit != NULL) { // Handle other units in this cell. handleUnit(unit, unit->next_); unit = unit->next_; } } 注意到我们将单元格的坐标也传入了进去,而不只是单位链表。眼下,这个和上面的例子做的事情没有什么不同,但我们将 会稍微的扩展下: void Grid::handleCell(int x, int y) { Unit* unit = cells_[x][y]; while (unit != NULL) { // Handle other units in this cell. handleUnit(unit, unit->next_); // Also try the neighboring cells. if (x > 0 && y > 0) handleUnit(unit, cells_[x - 1][y - 1]); if (x > 0) handleUnit(unit, cells_[x - 1][y]); if (y > 0) handleUnit(unit, cells_[x][y - 1]); if (x > 0 && y < NUM_CELLS - 1) { handleUnit(unit, cells_[x - 1][y + 1]); } unit = unit->next_; } } `handleUnit()``函数用来处理当前单位和相邻8个单元格中的4个单位之间的战斗。如果在相邻单元格中的任何单位离当前单位 的攻击半径足够近,它将会处理战斗。 注:单位所在的单元格标记为 U, 相邻单元格标记为了 X。 我们只查看一半相邻的单元格,和内部循环在当前单位开始的原因一样----为了避免比较同对单位两次。考虑下如果我们检查 8个相邻单元格会发生什么。 我们仅看一半的邻居,该内环在当前单元之后开始同一原因 - 为了避免比较各对单元两次。试 想,如果我们做检查所有八个相邻小区会发生什么。 比方说,就像前面的例子一样,在相邻的单元格内,我们有两个单位足够接近击中对方。如果我们查看单位周围所有的8个单 元格,以下就是会发生的事情: 1.当要寻找 A 的攻击时,我们会查看它右边相邻单元格,并且发现了 B。所以我们登记下 A 和 B 之间的攻击。 2.然后,当寻找 B 的攻击时,我们会查看它左边相邻单元格,并且发现了 A,所以我们登记下了 A 和 B 之间的第二次攻击。 仅仅查看一半的相邻单元格便可修复这个问题。至于哪一半并不要紧。 还有一个情况我们也需要考虑下。在这里,我们假设最大的攻击距离要比一个单元格小。如果我们有着较小的单元格但却较 大的攻击距离时,我们可能需要扫描一堆相邻的单元格,它们可能横跨了 好几行。 设计决策 明确定义的空间分区的数据结构有一个相对简短的一个列表,这里本可以一个一个来看下。相反,我试图根据他们的本质特 征来组织这一点。我希望一旦你了解到四叉树和二叉空间分割(BSP) 之类时,这将有助于你了解他们是如何工作,为什么 这么工作以及在这之间做出选择。 在网格例子中,我们将网格划分成了一个单一扁平的单元格集合。与此相反,层级空间分区只是将空间划分成几个区域。然 后,如果这些区域中仍然包含着许多的对象,会继续划分。这个过程递归 持续到每个区域的对象数目要比某些有着最大数目 对象的区域内的要少。 注:他们通常会切分2、4、8个区域,这些数字对程序员而言非常漂亮。 1.相对简单点。扁平的数据结构相对来说更容易推理和实现。 注:我几乎在每个章节中都会提到这点,理由也是充分的。无论何时,采取相对简单点的方案。软件工程大部分都是 在和复杂性对抗。 2.内存使用恒定。由于添加新对象不需要创建新的分区,所以空间分区使用的内存通常可以提前固定。 3.当对象改变位置时可以更为快速的更新。当一个对象移动,数据结构需要更新以找到对象的新位置。使用层级空间分区, 这可能意味着调整层次结构的若干层。 1.它可以更有效的处理空的空间。想想一下,在我们前面的例子中,如果战场的一整面是空白的,我们就会有大量的空白单 元格,而我们不得不在每帧中分配内存和遍历。 因为层级空间分区不会细分稀疏区域,所以一个大的空白空间仍然是一个单独的分区,而不是大量细小的分区。 2.它处理对象稠密区域时更为有效。这是硬币的另一面:如果你有一堆对象成群的在一起,非层级分区是低效的。你最终会 有一个有着许多对象的分区而你可能却无法对之进行分割。层级分区将会 自适应细分成更小的分区,使得你一次只需考虑少 数几个对象。 在我们的示例代码中,网格的间距是预先固定的,并且我们将单位放置进入了单元格中。其他的分区方案是自适应的,他们 根据实际的对象集合以及他们在世界中的位置来选择分区的边界。 我们的目标是有一个均衡的分区,每一个分区都有着大致相同的对象个数以获得最佳的性能。以我们的网格为例考虑下,如 果所有单位都集中在了战场的一个角落,他们将会处在同一个单元格内,找寻 单位间攻击的代码又会回到原来的 O(n²) 问 题,而这个正是我们试图要解决的问题。 1.对象可以逐步被添加。添加一个对象意味着要找到正确的分区并且将对象放置进去,所以你可以一次完成这个而没有任何 性能问题。 2.对象可以快速的移动。对于固定的分区,移动一个单位意味着要将单位从一个单元格中移除然后添加到另外一个单元格。 如果分区边界本身基于对象集合来改变,那么移动对象会引起边界的移动,这样会引起大量其他的对象需要被移动到不同的 分区。 注意:这个很类似于红黑树或者AVL树这样的二叉搜索树:当你添加一个单一的item时,你可能最终需要对数进行重新 排序并且对周围的一堆节点洗牌移动。 分区是层级的还是扁平的? 如果它是一个扁平的分区: 如果它是一个层级的分区: 分区依赖于对象集合吗? 如果分区依赖于对象: 3.分区可以不平衡。当然,这么做会让你对均匀分布的分区只有较少的控制。如果对象拥挤到一起,你会得到一个糟糕的性 能表现因为在空白区域浪费了内存。 像二叉空间分割和 k-d 树这样的空间分区会递归的将世界分割开来,以使得每部分包含着数目相同的对象。要做到这点,你 必须要计算当选择分区部分时在每一边对象的数目。边界体积层次结构是空间分区中的另外一种类型,用于优化世界中的特 定对象集合。 1.你可以确保分区平衡。这不仅仅带来优秀的性能表现,而且会是持续稳定的表现:如果每个分区有着相同数量的对象,你 便可以确保对世界中的分区的查询需要大约相同的时间量。当需要维持一个稳定的帧速率,这种稳定性比原始的性能更为重 要。 2.对整个对象集合一次性的进行分区更为有效。当对象集合影响到边界时,最好在分区之前对所有对象进行审视。这就是为 什么这种类型的分区越来越多的应用于游戏中需要保持固定的图形和静态几何部分。 有一个空间分区特别值得一提,因为它在固定分区和自适应上有着最好的特性:四叉树。 注:四叉树分割了2维空间。3维模拟的是八叉树,这个需要体积和将之分割成8个立方体。除了额外的唯独,它工作的 原理和四叉树一样。 四叉树从将整个空间作为一个单一的分区开始。如果空间中对象的的数目超过了某一个阈值,空间便被切分成四个较小的正 方形。这些正方形的边界是固定的:他们总是将空间对半切分。 然后,对于四个正方形中的每一个而言,我们再一次做了同样的过程,递归下去直到每一个正方形内部只有少量的对象。由 于我们只是递归的将高密度对象区域切分开,这个分区会自适应于对象集合,但分区是不会移动的。 在下图中,从左往右阅读,你可以看到分区的过程: 1.对象可以逐步的增加。添加一个新对象意味着要寻找合适的区域并且放置进去。如果对象放入区域中超过了最大的数目, 那么区域会被继续细分。在区域中的其他对象也会被分到更细小的区域中去。 这需要一些工作,但是努力是值得的:你必须 要移动的对象的数目始终要比最大的对象数目要少。添加一个单一的对象永远也不会触发多个细分动作。 删除对象同样简单。你将对象从它所在区域中移除,如果它的父区域的对象总数低于了一个阈值,你就可以合并这些细分的 区域。 2.对象可以快速的移动。这个,当然,和上面一样。“移动”一个对象只是一个添加和一个删除,两者和四叉树一样,相当快。 3.分区是平衡的。由于任何给定的区域中的对象数目都比对象的最大的数目要小,即使对象聚集在一起,你也不必用一个单 一的分区来容纳这些大量的对象。 如果分区自适应于对象集合: 如果分区不依赖于对象,而层级却依赖于对象: 你可以将空间分区看作是游戏中对象存活的地方,或者你可以将它考虑只当作是二级缓存,相比直接持有对象列表的集合而 言,查询能够更快速。 这个避免了两个集合的内存开销和复杂性。当然,将东西存成一份要比两份代价要小。另外,如果你有两个集合,你必须确 保集合间的同步。每次当一个对象被创建或者被删除,将不得不从两者中 添加或者删除。 遍历所有的对象更为快速。如果问题中对象是“存活”的并且他们需要做一些处理,你可能会发现自己要频繁的访问每一个对 象,无论对象的位置在哪。试想一下,在我们前面的例子中,大部分单元格 都是空的。遍历网格中所有单元格找到非空的那 些单元格是在浪费时间。 第二个集合只存储那些可以让你直接遍历访问的对象。你有两个数据结构,其中一个针对每个用例进行了优化。 在这章中我试图不去详细讨论空间分区的结构,以保持章节的高层次概括性(并且也不会太长!),但是下一步你应该 要去了解一些常见的结构。尽管他们的名字吓人,但却出奇的简单。常见的有: 网格) 四叉树 二叉空间分割 k-dimensional树 层次包围盒 每一个空间数据结构基本都从一个现有已知的1维数据结构扩展到多维,了解他们的线性结构会帮助你判断他们是否适合 解决你的问题: 网格是一个持续的桶排序。 二叉空间分割, k-d 树, 以及层次包围盒都是二叉查找树。 四叉树和八叉树都是Trie树(译者注:Trie树是哈希树的变种)。 对象只存储在分区中吗? 如果它是对象唯一的存储的地方: 如果存在存储对象的另外一个集合: 其他参考

下载文档,方便阅读与编辑

文档的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享文档获得金币 ] 43 人已下载

下载文档

相关文档