Game Programming Patterns -- Architecture, Performance, and Games
原文地址:http://gameprogrammingpatterns.com/architecture-performance-and-games.html
原作者:Robert Nystrom
原創翻譯,轉載請注明出處
在我們一頭扎進模式的大坑之前,我想給你們介紹一些我所理解的有關軟件架構以及其如何應用在游戲中的知識,這對你們應該有所幫助。這些知識可以幫你們更好地理解本書接下來的部分。別的不說,當你被拖進一場關于設計模式和軟件架構是多糟糕(或多棒)的爭論中時,這些知識至少會給你提供一些可用的彈藥。
注意,我并不管你們在這場戰斗中站在了哪一邊。和每一個武器商一樣,我的武器對任何一方的戰士都有出售。
什么是軟件架構
如果你讀完了這本書,你并不會得到任何在3D圖形學中使用到的線性代數知識或者在游戲物理中使用到的微積分知識。本書也同樣不會告訴你如何在你的AI搜索樹中使用alpha-beta剪枝算法或者如何在你的音頻播放中模擬房間混響效果。
Wow,這一段真是給本書打了一個糟糕的廣告。
不過,本書中的代碼在上述所有的領域中都會出現。相對于如何寫代碼,本書更關注的是如何去組織代碼。每一個程序都對代碼有一定的組織,即使只是“把所有的東西都堆到main()中然后看看到底會發生什么”,所以我想,聊一聊如何對代碼做出好的組織一定會更有趣。那么如何區分出架構的好壞呢?
我已經思索了這個問題將近5年的時間。當然,和你們一樣,我也有能夠辨認出好的設計的直覺。我們都經歷過糟糕的代碼庫,那個時候最想做的應該就是把這些代碼全部干掉,好讓它們從痛苦中解脫出來。
不得不承認,我們中的大多數要多少為上面這種情況負一些責任。
而有一些幸運的人們卻有著完全不同的經歷,他們有機會去使用一些有著優秀設計的代碼。這種優秀的代碼庫感覺起來就像是一間奢華的酒店,里面的服務員們都殷勤地等待著為你的每一個心血來潮的念頭服務。那么這兩種代碼之間的區別究竟是什么呢?
什么是好的軟件架構
對于我來說,好的設計意味著每當我做出修改的時候,整個程序就像在設計時就已經預料到了我的這次修改一樣。在解決一個問題時,我只需要用到幾個可以完美嵌入代碼庫中的函數,不會讓代碼庫平靜的水面濺起一點漣漪。
“你只是在寫自己那部分的代碼,所以這種修改不會打擾到代碼庫平靜的水面。”好吧,這聽起來很棒,但其實并不是那么可行。
來讓我稍微分析一下這是為什么吧。最關鍵的一點就是,架構所要考慮的正是改動。總是會有人需要修改代碼庫的。如果沒有人會去修改代碼庫--不管是因為它太完善和完美了,還是因為它糟糕到沒有人愿意打開以免玷污了自己的文本編輯器--那么它的設計都是無關痛癢的。衡量一個架構設計好壞的標準其實很簡單,那就是它對改動的適應性。一個沒有改動的架構,就像一個從來沒有離開過起跑線的賽跑選手。
如何做出改動
在你開始修改代碼之前(不管是添加新功能、修改bug或者任何導致你打開編輯器的原因),你必須要搞清楚現有的代碼在做些什么。當然,你不需要搞清楚整個項目,但是你需要把你所要修改的地方所有相關的部分裝進你靈長類的腦子里。
不可思議的是,這看起來正是一個OCR(Optical Character Recognition 光學字符識別?)的過程
我們常常掩飾這個步驟,但其實它通常是編程工作中最為耗時的那個部分。 如果你認為把數組從硬盤分頁到RAM中很慢的話,可以試試把它通過視神經傳進一個類人猿的腦子里。
當你將所有正確的內容裝入你的腦中之后,你就可以開始思考并找出你的解決方案了。這個過程可能是曲折的,但是相對來說總是往好的方向前進的。當你搞清楚了你的問題和它所要使用到代碼之后,接下來的編程工作可能就非常容易了。
你用你肉肉的手指在鍵盤上敲打一陣,等到顏色正確的燈在屏幕上閃起的時候你就完成了,對嗎?僅僅這樣還不夠!在你的寫入測試完成和把它發送給代碼評審之前,你通常還有一些善后工作要完成。
我是說了“測試”嗎?哦,是的,我是說了。為游戲代碼編寫單元測試是很困難的,但是對于代碼庫來說,大部分代碼都是可測試的。
我不會在這里開始一段關于測試的長篇大論,但是如果你還沒有使用一些自動化測試方法的話,我建議你要開始考慮使用它們了。難道你沒有比一遍又一遍手動地測試代碼更有意義的事情做了么?
簡而言之,敲代碼的流程圖看起來大概就和下面這張圖差不多:
這個循環是沒有出口的,我想起來它的時候不禁有些擔憂。
解耦會有什么幫助呢?
雖然不是很明顯,但是我認為,很多的軟件架構都是關于解耦的。把代碼裝進腦袋里是一個痛苦且緩慢的過程,因為它需要花費時間去尋找削減代碼體量的策略。本書有一整個章節都是描述用來解耦的模式的,《設計模式》中也有很大一部分在描述解耦的相關思想。
你可以給“解耦”下很多種定義,而如果兩塊代碼一旦耦合了,那就意味著你不能只了解其中一塊代碼而不去了解另一塊。如果你把它們解耦了,那你就可以分開來考慮其中的每一塊代碼。這是很棒的,因為如果這些代碼中的其中一塊和你的問題相關,你只需要把這一塊裝進你猴子一樣的大腦里而不需要考慮另外一塊。
對于我來說,這就是軟件架構要實現的關鍵目標:在你開始你的工作進度之前,最小化你所需要裝進腦袋里的知識量。
這在后期的工作中同樣也會起到作用。解耦的另一個定義是對一塊代碼的修改并不一定需要修改另外一塊。很明顯我們需要去修改一些都系,但是越少的耦合,意味著之后工作中越少的修改。
需要付出什么代價
這聽起來很棒,對嗎?把一切解耦,你就可以像風一樣飄逸地敲代碼。每次修改意味著只要接觸僅有的一到兩個方法,你可以神出鬼沒般從代碼庫上飄過。
這就是人們為什么這么熱衷于抽象、模塊化、設計模式和軟件架構的原因。在一個架構良好的項目里工作真的是一個非常令人愉悅的體驗,每個人都喜歡工作更有效率。好的架構可以給生產力帶來巨大的改變,一點也不夸張地說,它可以帶來非常深遠的影響。
但是,就像我們生命中的其他事情一樣,這不是免費的。好的架構需要花費很多的努力和磨練。每一次你做出修改或者實現新功能的時候,你都需要花費很大的功夫去把它優雅地集成到項目的其他部分中去。你需要非常注意組織你的新代碼,而且需要在你之后的開發周期中可能出現的成千上萬的改動中維護它們的組織化。
這里的第二部分--維護你的設計--需要格外地注意。我見過很多開始時很不錯的項目最終死于程序員們一次又一次的“只是一點小修改”上。
就像園藝一樣,只是種新的植物是不夠的。你還必須要除草和修剪。
你需要考慮項目的哪些部分是應該解耦的,你要在那些點上引入抽象。同樣的,你也需要哪里的擴展性是需要被設計的,以使得未來的修改更加容易。
人們對這件事感到非常的興奮。他們想象未來的開發者們(可能只是未來的他們自己)進入代碼庫之后,發現它時這樣的開放、功能強大,就在那等著被擴展。他們想象使用一個游戲引擎去管理所有的游戲。
但是事情從這里開始變得微妙起來。每當你想要添加一個抽象層或者給一處代碼增加擴展性支持的時候,你是在推測你以后會用到這樣的靈活性。但這也是在給你的游戲增加額外的代碼和復雜度,而這些都是需要花費時間去開發、debug和維護的。
如果你的猜測是正確的,而且之后也不會去修改這塊代碼的話,那么你的努力就是值得的。但是預測未來是很難的,當那個模塊不再有幫助的時候,它很快就會變得非常有危害。最終,它會導致你需要去應付更多的代碼。
一些人杜撰了“YAGNI”這個詞--You aren't gonna need it(你不會需要它的)--作為一個咒語去遏制想要預測自己未來的渴望。
當大家在這件事上熱衷過頭的時候,你會得到一個架構被扭曲到超出控制的代碼庫。這種代碼庫里到處都是接口和抽象。同樣也存在著大量的插件系統、抽象基類、虛方法,以及各種各樣的擴展點。
這樣你將一直把時間花費在從框架代碼中尋找真正的功能性代碼。當你需要作出改動的時候,當然,這里可能會有一個接口給你提供幫助,但前提是你能足夠幸運地找到它。理論上來說,解耦意味著你可以在擴展代碼之前了解較少的代碼,但實際上抽象層里的代碼最終將填滿你大腦里的硬盤。
這種類型的代碼庫使得人們反感軟件架構,尤其是設計模式。它讓你很容易陷入到代碼之中,而忘記了你其實只是想發售一款游戲。這首有關于擴展性的塞壬之歌卷入了無數花費多年時間在一個引擎上工作卻從來不知道這個引擎是干什么的開發者。
性能和速度
還有另外一種對軟件架構和抽象持批評觀點的說法,在游戲開發中經常會聽到:會降低游戲的性能。許多讓你的代碼更靈活的模式一般都依賴于虛擬派發、接口、指針、消息以及其他會在運行時有性能消耗的機制。
一個有趣的相反的例子是C++中的模板。模板元編程有些情況下能讓你使用抽象接口而在運行時沒有任何額外的消耗。
這是一個有關于靈活性的范疇。當你在一個類中調用一個具體方法時,那么你就是在編寫階段就把那個類定死了--也就是說你是在硬編碼這個類。而當你使用虛方法或者接口時,這個被調用的類在運行之前是不確定的。這當然是更靈活的,但是帶來了一些運行時的消耗。
模板元編程在介于這兩者之間。你將在編譯階段模板被實例化時決定哪個類將被調用。
不過這樣做是有原因的。大部分的軟件架構都是在讓你的項目更加靈活。它們讓你的項目在修改時付出的代價更少。這意味著在項目中敲代碼時會有比較少的假設。使用接口可以讓你的代碼在任何實現它的類里正常工作,而不是為了功能臨時新創建一個類。使用觀察者模式和消息模式讓游戲中的兩部分可以互通消息,以后也可以很容易地擴展到三個或四個部分互通。
但是性能表現其實基本上都是假設。優化的習慣來自于具體的限制。我們可以假設我們永遠不會擁有超過256個敵人嗎?這樣的話,我們可以把一個ID封裝到一個單字節里。我們只會在一個具體的類里調用某種方法么?好的,這樣我們就可以把這個方法寫死或者內嵌在這個類里。所有的實例都是同一個類?非常好,這樣我們就能使用連續數組了。
但這并不意味著靈活性是不好的!它讓我們可以快速地修改游戲,而開發速度對于獲得有趣的游戲體驗來說絕對是非常重要的。沒有任何一個人,即使是Will Wright(模擬城市、模擬人生等游戲的創造者),可以在紙上就把一個非常平衡的游戲設計出來。這是一個需要迭代和試驗的過程。
如果你越快地嘗試新的點子并體驗它們,那么相同的時間里你就可以更多地嘗試,這樣你就越可能發現一些很棒的東西。即使你已經找到正確的游戲機制,你仍然需要大量的時間去優化。一處小小的不平衡也會毀掉整個游戲的樂趣。
這里沒有一個簡單的答案。讓你的項目具有更多的靈活性可以讓你在游戲創作時更快速,但是需要消耗一定的性能。同樣的,優化你的代碼會使得它缺少一定的靈活性。
而我的經驗是,更快地制作出一款有趣的游戲要比讓一款快速制作出的游戲變的有趣要更容易。一種折衷的方案是,在設計完成之前讓代碼保持靈活性,之后剔除一些抽象概念去優化游戲的性能。
壞代碼中的優點
這就引入了下一個問題,無論何時何地,總會有不同類型的代碼存在。本書的大部分都是在描述如何寫出可維護、純凈的代碼,所以我所擁護的很清楚,就是用“正確”的方法去做事情,不過那些草草寫出的代碼中有些東西也是有參考價值的。
編寫擁有良好架構的代碼需要考慮得很仔細,而這就需要消耗時間。而且,在項目的生命周期內維護一個好的架構是需要付出很多努力的。你需要像露營者對待他們的營地一樣對待你的代碼庫:總是嘗試在離開時把它變得比你找到它時更好。
如果這些代碼是你需要長期使用的話,那么這么做是很好的。但是,就像我之前提到的那樣,游戲設計需要大量的試驗和探索。尤其是在開發的前期階段,經常會寫一些你“明知道”之后會扔掉的代碼。
如果你只是想找出一個游戲點子玩起來是怎么樣的,對它進行好的架構意味著在它可以在屏幕上運行以及你可以獲得一些反饋之前需要花費大量的時間。如果這個點子最終不可行的話,那么用來把代碼變得優雅地時間就隨著你把它刪除而浪費了。
原型設計--把一些僅僅完成了設計問題中的功能的方法拼湊在一起--是一種非常合理的編程實踐模式。但是這種方式存在很大的問題。如果你寫了一些以后準備拋棄的代碼,那你必須確認你以后能夠把它們拋棄掉。我曾經見過一些不好的管理者一遍又一遍地玩這樣一個游戲:
老板:“嘿,我們有了一個想法想要嘗試一下。只要做一個原型就可以,不用考慮需要把它做的多完善。你多快可以把這東西弄出來?”
*開發者:“好的,如果一些細微的功能不管,不需要測試,不需要寫文檔,而且可能會有一堆bug的情況下,幾天內我就能給出一些完成功能的臨時代碼。”
*老板:“太棒了!”
幾天過后...
老板:“嘿,那個原型很棒。你能花幾個小時把它處理一下,讓它成為正式的東西么?”
有一個保證你的原型代碼不會成為最終使用的代碼的小技巧,就是用和你制作游戲不同的語言去編寫它。這樣,你就可以重寫它,因而避免它最終存在于你的游戲中。
你需要確認使用這種臨時代碼的人明白一件事,即使臨時代碼看起來好像是可以正常工作的,但是它是不可維護的而且必須要被重寫。如果你有任何可能會繼續使用它,那么你最好還是在寫的時候就嚴謹一些比較好。
尋求平衡
我們有以下幾個方面需要考慮:
我們想要一個好的架構,這樣代碼在項目的整個生命周期都會很容易地去理解。
我們想要運行時的高性能。
我們想要當前的功能能盡快完成。
我認為這是非常有趣的,因為這些方面都關系到了某一種速度:我們的長期開發速度、游戲的運行速度、我們的短期開發速度。
這幾個方面至少在它們的某些部分是相互對立的。好的架構提高了長期的開發效率,但是這意味著每次修改都需要多付出一些努力去維護架構。
最快寫出的功能實現往往不是可以最快運行的。相反的是,優化它需要花費可觀的開發時間。當優化完成后,整個代碼庫就會變得僵硬:高度優化的代碼往往是不靈活的,而且修改起來會很困難。
現實中總會有今日事今日畢和擔心明天所要完成的工作的壓力。但是如果我們填鴨式地盡我們所能地快速完成功能,我們的代碼庫就會變得充滿漏洞、bug和不一致性,而這些都會影響我們未來的開發效率。
這里沒有一個簡單的答案,需要的是取舍。從我收到的email來看,這讓很多人感到沮喪。特別是那些想做游戲的新手們,他們最怕聽到的就是,“這個沒有正確答案,只有不同形式的錯誤的解決方法。”
但是,對我來說,這是令人興奮的!看看其他那些人們奉獻了自己的整個職業生涯去掌握的領域,你會發現那里總是充滿了各種錯綜復雜的問題。畢竟,如果問題有一個簡單的答案的話,所有人都會去照著這樣去做。一個你一周就能掌握的領域是最最令人感到無趣的。你應該從來都沒聽說過某人因為挖溝的職業生涯而出名吧。
好吧,可能你聽過;我對這個方面沒什么研究。就我所知,這個世界上可能有狂熱的挖溝業余愛好者,挖溝交流大會以及有關于挖溝的整個亞文化產業。我該選哪一個?
在我看來,這和游戲本身有著很多相同之處。一個像國際象棋這樣的游戲永遠不會被完全掌握,因為它的每一部分都和另一個部分有著完美的平衡。這意味著你能用盡一生的時間去探索所有可行的策略。而一個設計糟糕的游戲通常毀于唯一一種勝利戰術被一遍又一遍地使用,直到你感到厭煩而最終棄坑。
簡單性
后來,我在想,如果有一種方法可以解決這些問題的話,那它一定就是簡單性了。在如今我的代碼中,我會非常努力地去嘗試寫出最干凈,最直接解決問題的代碼。這種類型的代碼,在你讀過它之后,你就會很清楚它是用來干嘛的,而且再也想不出還有其它可能的解決方案。
我的目標是保證數據結構和算法正確(大概是這個順序)然后以此為起點。我發現如果我可以保持事情簡單的話,代碼總體上也會變少。這意味著我想做出改變的時候可以少裝一些代碼到我的腦子里。
這種代碼一般運行起來都很快,因為一切都保持簡單性,沒有太多的消耗,也沒有很多需要運行的代碼。(當然,并不總是這樣的情況。你可能在一小段代碼里封裝了一堆循環和遞歸。)
不過,這里請注意,我并沒有說簡單的代碼可以花更少的時間去寫。你可能這么想過,因為你用更少的代碼完成了項目,但是一個好的解決方案并不是由于代碼的添加,而是對因為對代碼的精煉。
Blaise Pascal一封很著名的信的結尾是,“我本想寫一封更短的信,但是我沒時間了。”
另一個被引用的句子來自于Antoine de Saint-Exupery:“完美的達成,不是因為沒有更多的東西可以被添加了,而是因為沒有東西可以被移除了。”
言歸正傳,我注意到每一次我修訂本書中的一個章節,它都會變得更短。一些章節在它們完成之時被精簡了大概20%。
我們很少會面對一個優雅的問題。取而代之的是一大堆的用例。就是你想要X在Z的情況下去做Y,但是在A的情況下要做W之類的東西。換句話說就是,一個長長的不同例子行為的列表。
最省心的解決方案就是為每一個用例編寫代碼。如果你看那些新手程序員工作的話,你會發現這就是他們經常在做的:為每一個他們想到的用例大量編寫一堆的條件邏輯。
但是這樣做是很不優雅地,而且這種類型的代碼在被提供的輸入數據和設計時有一點點不同的時候都有可能發生問題。當我們在思考優雅的解決方案時,我們往往能想到的就是最常見的那一種:一小段邏輯,在很多用例中使用時都是正確的。
尋找這種解決方案的過程有點像模式匹配和解謎。這需要從分散的用例中努力地看穿隱藏在它們表面下的規律。當你找到它的時候,感覺會很棒。
準備工作已經完成
幾乎所有人都跳過了這些引導章節,所以我要恭喜你一直來到了這里。對于你的耐心我沒有更多的可以回報,我所能給你的是一些我覺得可能會對你有幫助的建議:
抽象和解耦會讓推進你的項目更快更簡單,但是除非你確定某個問題中的代碼需要這樣的靈活性,否則不要在這上面浪費時間。
有關性能的設計需要在你的整個開發周期中都有所考慮,但是那些底層和細節上的優化,因為可能會把一些特定的假設寫入代碼中,所以還是越晚進行越好。
相信我,在你的游戲發售時間的兩個月之前,你都不需要擔心“游戲只能跑到1幀”這個小問題。
盡量快地去探索你的游戲的設計空間,但是要在保證速度的同時確定沒有留下一堆坑。畢竟你以后還是要靠它吃飯的。
如果你準備丟棄某段代碼的話,那就不要浪費時間去把它寫的很漂亮。搖滾明星們通常會把酒店房間弄的很亂,因為他們知道第二天就要退房了。
不過,最重要的就是,如果你想做出一些有趣的東西的話,那就享受做出它的過程吧。
因為水平有限,翻譯的文字會有不妥之處,歡迎大家指正
“本譯文僅供個人研習、欣賞語言之用,謝絕任何轉載及用于任何商業用途。本譯文所涉法律后果均由本人承擔。本人同意簡書平臺在接獲有關著作權人的通知后,刪除文章。”