4月18日至4月21日,由網易互娛學習發展舉辦的2022N.GAME遊戲開發者峯會在線上舉辦。在19號的峯會上,來自網易互娛引擎部的技術專家許飛,以《構建公平的聯機環境,服務器動畫性能優化》為主題發表了演講。
許飛提到,雖然現在真正使用服務器動畫的遊戲並不多,但這是一個比較有前景的領域,因為可以提供更公平的聯機環境,而且可以實現更細緻的交互。比如服務器一旦有了動畫之後,服務器的角色就是活的,不再只是一堆數據,它有姿態,可以進行更復雜的交互。
我們現在提倡的雲遊戲也好,元宇宙也好,很難想象裏面的東西是沒有動畫的。
以下是演講全文(有刪減):
大家好,我是來自網易互娛技術中心的許飛,很榮幸參加網易N.GAME遊戲開發者峯會。本次分享的題目是《構建公平的聯機環境,服務器動畫性能優化》,分為四個部分:
第一部分:服務器動畫的意義和現狀
第二部分:服務器動畫常見優化方向與方案
第三部分:如何優化複雜的動畫狀態機
第四部分:服務器動畫展望
服務器需要跑動畫嗎?
在座很多有經驗的開發者可能會有疑問,服務器需要跑動畫嗎?因為現在大多數遊戲服務器是沒有跑動畫的。傳統觀點認為,動畫和渲染特效一樣,屬於表現的層次,只要客户端看就可以了。
有限的幾種需要動畫來參與的邏輯,比如打擊部位的判定,服務器不跑,但客户端還是有動畫的。我們讓客户端來進行判斷,然後把結果發送給服務端,也能實現一樣的效果。這種想法是正確的,也是以前常見的做法,但有個前提——你網絡必須是可信任的。
但我們實際的網絡環境是什麼樣的?
下圖是Peter Steiner在1993年發佈於紐約客上的一副圖畫,被認為揭示了互聯網環境的複雜性。互聯網環境非常繽紛多彩,這種多彩一方面給網絡遊戲的發展提供了肥沃的土壤,另一方面,它的複雜性也給遊戲開發者造成了很大的挑戰。
比如,我們其實並不關心玩家究竟是什麼背景,有什麼特點,但我們害怕的是玩家用不用外掛。對於一個交手型的遊戲來説,一旦使用外掛,對遊戲公平性的破壞幾乎是毀滅性的。
有沒有一種辦法能夠有效地防止或反外掛呢?一個有效的方法叫做服務器權威。
它的思路其實很簡單,外掛是通過劫持遊戲客户端來實現一些非法操作,但相對於玩家的客户端來講,我們的服務器是在機房裏,機房經過非常嚴密的保護,一般的外掛開發者很難劫持我們的服務器。
但如果把關鍵的邏輯都放在服務器上面,客户端僅僅作為一個指令的輸入者,我們就可以防止大部分外掛的操作。
比如説,其中一個玩家使用外掛、或感染了病毒,他向服務器發送了一些非法的請求,或修改了一些非法的數據,服務器如果有完整邏輯的話,就可以識別出來,及時地把對數據的篡改禁止掉。這樣對於別的玩家來説依然是安全的,就維護了遊戲聯機環境的公平性。
這個方法只要把客户端邏輯搬到服務器就好,很簡單,但為什麼現在大多數遊戲沒有這麼做呢?
現實總是比較複雜。
看下圖,這是遊戲不同系統開銷的展示,橫座標是遊戲某個系統數據量的多少,縱座標是屬性更新頻率。像等級、裝備這些信息,數據量相對比較少,更新頻率也相對比較低。像技能或狀態之類,更新頻率相對高,數據量也會相對大。
很早期的服務器,其實只會保存等級信息之類的,後來發展了一段時間之後,就開始保存裝備信息、技能信息。
動畫在紅色的位置,具有非常龐大的數據量和非常高的更新頻率,這兩個軸相乘的結果才是某個功能對於算力的需求,可想而知動畫對於算力的要求是非常高的。
如果我們簡單地把動畫從客户端挪到服務器,在沒有優化的情況下,會導致服務器直接跑不起來。
這就是現實,也是為什麼很多遊戲沒有在服務器開啓動畫。
還有人説,隨着技術的發展,算力的價格其實在下降,CPU越來越強大,核數越來越多,技術的發展會不會讓服務器的動畫變得可能呢?
我收集了三個比較典型的年代:2007年、2016年和2020年,服務器每條線程成本的變化。這三年剛好也是三款典型射擊遊戲的發佈年代。
從2007年到2016年,CPU單線程的成本大概降到了原來的1/3;從2016年到2020年,更是降到了原來的1/2。
2007年發佈的《穿越火線》,服務器幾乎沒有跑任何動畫相關的東西。2016年的《守望先鋒》,它的服務器是知道動畫狀態的,但是隻跑了一部分。什麼意思呢?服務器知道這個角色,當前是跑還是跳還是釋放技能。
到了2020年發佈的《Volarant》,它的服務器是完全跑動畫的,會完全計算角色在服務器上的狀態。他們的主程在分享中也説道,這樣做就是為了反外掛。因為服務器只有有了非常全面的動畫信息,在判定受擊的時候,才不至於被客户端的外掛所欺騙。
所以可以看到,隨着技術發展,服務器動畫的邏輯執行程度是越來越高的,最近一些遊戲已經開始嘗試在服務器上跑動畫了。
業界有哪些常用的服務器動畫優化方案?
既然要在服務器跑動畫,那我們就要優化它的動畫開銷。業界常用的方法有哪些呢?
首先我們分析一下,動畫系統的開銷,大概分成三個部分:系統接收外界的輸入,更新內部狀態,最後計算出模型的姿態。
輸入部分,一般就是角色速度或者説角色狀態,比如在做什麼。內部狀態,比如角色的速度變化,可能從一個靜止的狀態變成跑的狀態,或者從跑到跳這樣的變化,最後再由這些狀態計算出我的姿態——姿態就是從美術Key幀裏面算出角色最終的樣子。
這三部分的開銷我覺得很明顯:姿態更新部分的開銷最大。
正如上文所説,我們評價一個功能開銷,可以從它的數據量和頻率兩方面來計算,姿態更新為什麼數據量非常大,因為每個人骨骼都有朝向、旋轉和位置眾多的屬性,而且幾乎每幀都要變化,所以姿態更新佔比這麼多其實並不意外,我們業界常見的優化方式也正是針對這一塊進行的。
比如最簡單的LOD,主要是減少了動畫的數據量。服務器跑動畫是為了來判斷受擊,但有些客户端用來表現的一些骨骼是不需要的,比如披風、頭髮,對於判斷毫無作用,那麼服務器就可以不跑,把這些剪掉就可以了。這就是服務器LOD的一種思路、一種做法。
像下圖裏面紅色框裏的骨骼,對於服務器判斷受擊是沒有任何作用的,把它給去掉,一般可以省20-30%的開銷。
除了減少數據量之外,還可以減少數據的更新頻率。
基於事件的姿態更新,也就是減少姿態計算的頻率。《Volarant》的技術就是這樣子。一個角色只有當被擊中的那一瞬間,才需要計算姿態是什麼樣子的,計算模型的姿態。這種優化極大地減少了姿態更新的頻率。
這樣做的效果非常驚人,可以把姿態更新的開銷從84%直接降到9%。基本上做到這一步之後,就可以在服務器上跑動畫了。可能會有一定的開銷,但不至於完全跑不起來。這是業界常見的一些優化方式。
而當我們把姿態更新的開銷降到9%,這時最高的開銷就成了動畫狀態更新,佔比11%,變得更凸顯了。
如果一個角色很多動畫邏輯非常複雜,導致狀態機也非常複雜,那麼開銷可能還會超過11%的佔比,這時候如何優化狀態更新就成了當務之急。
複雜的狀態機如何優化?
我們以UE為例,先來介紹狀態機的樣子。
首先裏面有一些狀態,比如説走跑跳,下圖是一個最簡單的Locomotion,移動的狀態機,有在地面idle/walk/Run的狀態,有起跳的JumpStart,在空中循環播放的JumpLoop,落地的JumpEnd的狀態,它們之間有一些條件連接起來。
比如外面條件説角色下一刻要騰空了,那我就會改變一個條件,讓他從idle狀態跳轉到JumpStart狀態,起跳結束就循環播放一個空中的動作。大概就是這樣的流程。
所以大概可分成三部分,第一部分叫做Find_Transitions,即從當前狀態找一個可能的跳轉條件。第二部分,如果這個條件為真的話,有一個跳轉可能發生,那麼就執行這個跳轉。第三部分,執行完了之後,兩個狀態之間可能會有一些過渡,那還要更新這兩個狀態。
三個部分的開銷佔比是怎樣的?
我們發現,尋找一個可能的跳轉,和最後的狀態更新,佔據了絕大部分的開銷。所以再來優化這部分的時候,也會選取這些開銷更大的。
我們從哪些方面去優化狀態機的更新?
第一,優化跳轉之後兩個狀態的過渡。
第二,因為狀態跳轉每次都要計算一個條件,為真才跳轉,為假就不跳轉了,所以可以優化這個條件的計算。
第三,每次都要判斷所有條件哪些為真,哪些為假,這個頻率可以降低。還是上文所説,我們既可以降低數據量也可以降低頻率。
- 狀態過渡優化
以Locomotion為例。我選取的是在空中到落地這一段時間的狀態跳轉,比如原來的角色在空中,還在那裏不停地播一個循環的動作,接下來他要着地了,這個時候因為兩個動作之間是有一些變化的,我們不能讓他瞬切,所以中間就會有一個過渡。
那這個過渡,至少有兩種模式。
一種是Cross Fade,上一個狀態權重逐漸降低,下一個狀態權重逐漸升高,這樣兩個權重會出現交叉,過渡過程中兩個狀態權重都不為0 ,所以必須更新這兩種狀態。如果這兩個狀態中間又嵌套了別的狀態機,也是一定要更新的。
第二種方式,有的引擎稱它為Immediate模式,UE叫做Inertialization模式。
假如還在空中跳,下一刻要落地了,會把空中跳的狀態拍個快照,然後直接就不再更新它了。接下來,下一個狀態的權重逐漸從0升到1。通過這種方式,只需要更新下一個狀態就ok了。這樣,當狀態跳轉的時候,開銷降了一半,因為只需要更新一個狀態。
做了這樣的優化之後,在Update_States狀態更新這一塊,會降低大概10%的開銷。這是一個很好的事情,因為其實對邏輯沒有影響,是一個無損的優化。
- 尋找跳轉優化
還是以UE、從空中到落地這段時間為例。
怎麼決定接下來需要着陸?一般來講會寫條件來判斷,有兩種寫法。
下圖上面的寫法,直接接收了一個Bool值,Bool值如果是True就跳轉。下面的寫法,是寫了一個表達式,通過判斷速度是不是變化,來決定是否跳轉。
這兩種方式有什麼區別呢?根據UE官方的提示,這兩種方法的效率大概會相差10倍。
原因比較簡單,第一種直接使用Bool值來判斷的話會編譯成本地代碼。如果用第二種寫法,編譯的是虛擬機代碼,經過藍圖虛擬機來執行才能判斷其中的結果。
實際情況中,如果不用UE也會有一樣的問題,因為這個條件也有可能不是C++寫的,可能是Python來寫的,那會面臨一樣的問題。腳本語言相比像Python、lua這樣的非本地代碼,性能本來就是低的。
如果我們過多地使用了不是本地化的語言寫的條件,就會給狀態機的更新造成很大的性能開銷,那麼我們可以把這部分的判斷變成本地化代碼,就獲得了一定的性能提升。
對UE來講,你可以用它的Nativization工具,自動化地把藍圖代碼變成本地化代碼,這是一個很好的工具,大家可以研究。
對於動畫狀態機來講,也會帶有10%左右的性能提升。
為什麼優化了10倍,整體只能優化10%呢?因為那個10倍對UE來講,僅僅是藍圖的執行和本地代碼執行的開銷,但是狀態機是一個基於節點的結構,它的執行還是依賴節點跳轉。
那就會導致一個後果,沒法像優化一個語法樹那樣,直接把某些節點給裁剪掉。所以對於狀態機這樣的情況來講,它有優化,但並沒有我們想的那麼高。
那還有沒有更有效的優化方法?我們可以分析一下狀態跳轉的頻率。
比如像下圖左上角這個狀態機,每一幀都會判斷,當前狀態有沒有一個條件可以跳到別的狀態。
像左上角這種,每一個狀態只有一個指向外面的箭頭,只要判斷一次。對於右上角這個狀態機,就比較複雜,它的idle狀態的話有三條路徑可以跳出去,每一幀對應的時候,就需要把三個條件全部判斷一遍。
那狀態機在什麼時候才需要跳轉呢?
有些時候,比如角色站在那兒不動,是不會跳轉的,只有當玩家按下了跳躍鍵,或者按下了一個行走鍵的時候,才需要來把狀態機發生跳轉。而且,玩家的輸入頻率一般是非常低的,頂尖的電競運動員的輸入頻率也就每秒鐘大概7Hz,但是我們服務器的更新頻率可能是每秒鐘是30Hz以上。
於是我們發現,這裏面有一個方法來降低跳轉條件測試的頻率。
其實跳轉條件也是分成兩類的,一類是依賴玩家的輸入,這種條件我們可以把它優化掉,因為玩家的輸入本來就沒有那麼高的頻率,用一些方法來省掉它的更新就可以了。
另一種依賴於動畫播放進度,我們可以優化前者的更新頻率,因為玩家輸入頻率不高,可以直接省掉更新。具體地,可以通過在UE藍圖中進行人工標註標明可優化的跳轉信息。
其實相對比較簡單,寫一個很快的方法來判斷玩家的輸入有沒有發生變化,如果沒變化根本不用測試,就像剛才idle突然有三條箭頭衝向外面,但如果判斷三個條件都沒變化的話(判斷Cache是不是可用的),如果Cache還是可用的話,那就直接跳過去,來省掉大部分的跳轉測試開銷。這個比較簡單。
UE會比較複雜一點,因為UE的動畫狀態機是一個動畫藍圖,這就意味着,相比較那些狀態機純粹是邏輯結構的情況(判斷某個狀態依賴的一些條件跳轉相對容易一些),UE會編譯成一些藍圖的字節碼來執行。
當然我們可以通過編譯原理的一些方法,分析藍圖的語法節點,然後來找到依賴條件。我這裏還提供一個相對比較取巧的方法,這種依賴條件其實可以人工標註。
比如當前是Jump_Loop,還空中不停地循環,它依賴的跳轉條件是下一個是不是已經着地了,我們把這個條件標註在這個狀態上面,然後在藍圖編譯的時候,利用這些標註就更容易把優化代碼插進去,這樣最後生成的代碼就是優化後的代碼。這就是一個比較簡單可行的辦法,也比較容易實現藍圖的優化。
但對於其他引擎來講,如果它的狀態機僅表示邏輯結構,沒有像動畫藍圖這麼複雜,可以用更簡單的辦法來實現這一步跳轉的優化。
這部分的優化還是挺可觀的。動畫狀態過渡優化到了10%左右,跳轉優化優化了70%。
這樣就實現了比較好的效果,因為完全不影響動畫的邏輯,只是讓狀態機跑的更快了,節省了一些不必要的計算。這就意味着服務器可以跑更多角色了,
服務器的算力沒要那麼高,也可以省錢。
未來:公平的環境,完備的邏輯,細緻的交互
服務器動畫按照現在的發展趨勢,其實好處是非常多的,雖然現在真正使用服務器動畫的遊戲並不多,但這是一個比較有前景的領域,因為可以提供更公平的聯機環境,而且可以實現更細緻的交互。
比如服務器一旦有了動畫之後,服務器的角色就是活的,就不只是一堆數據了,它有姿態,可以進行更復雜的交互。
我們現在提倡的雲遊戲也好,元宇宙也好,大概很難想象元宇宙裏的東西是沒有動畫的。
對於服務器性能優化來講,雖然做了這麼多的工作,但其實並不徹底,因為按照我們的傳統看法,動畫還是基於每幀更新的,但服務器,我們覺得比較徹底的優化要完全實現動畫系統的事件更新的驅動,這樣,服務器只需要計算它所需要的數據就可以了。
同時,我們知道服務器雖然越來越強大,但它的性能其實還是有上限的,當服務器的性能不足以支撐這麼多動畫計算的時候,就需要一個動畫的自動分級機制,讓服務器不至於雪崩。
我覺得當這兩個方向做的非常的完美了,服務器動畫可能會迎來更好的發展。
本次的分享到此結束,謝謝大家。