使用DOTS製作一款第三人稱殭屍射擊遊戲

遊資網發表於2019-12-05
我們正在使用面向資料技術棧DOTS重構Unity的核心基礎。許多遊戲工作室在使用C# Job System、實體元件系統ECS和Burst Compiler後,都無一例外地感受到明顯的效能提升,其中就包含了瑞典遊戲工作室Far North Entertainment。

在Unite Copenhagen大會上,我們與Far North Entertainment工作室的成員進行深入交流,瞭解他們如何在傳統的Unity專案中應用DOTS功能。

使用DOTS製作一款第三人稱殭屍射擊遊戲

Far North Entertainment

瑞典的遊戲工作室Far North Entertainment是由5位來自工程研究專業的好友共同建立。自2018年初在Gear VR平臺釋出《Down to Dungeon》遊戲之後,該公司一直致力開發一款末日殭屍生存遊戲。

這款末日殭屍生存遊戲的獨特之處在於殭屍的數量,開發團隊希望實現成千上萬個飢渴的殭屍追逐玩家的效果。然而在構建原型時,他們遇到了許多效能方面的問題。

開發中主要的瓶頸在於對龐大數量殭屍的進行生成、銷燬、更新和新增動畫,雖然開發團隊嘗試了物件池和動畫例項化等方法,但效果仍不顯著。因此,技術總監Anders Eriksson將目光投向DOTS,從物件導向(Object-oriented)設計轉為面向資料(Data-oriented)設計。

Anders Eriksson表示:促成我們思維模式發生改變的關鍵是停止考慮物件和物件層級,轉為思考資料是如何變換和訪問的。這意味著程式碼不必圍繞具體事物來編寫,不用處理過去最常見的情況。

對於同樣在試著轉換思維模式的開發者,Anders Eriksson的建議是:先弄清楚要解決的問題和解決方案的相關資料。是否會對相同資料集執行相同的處理過程?可以把多少關聯資料打包到CPU快取行中?如果想轉換現有程式碼的話,那麼要確定會給快取行加入的垃圾資料量。能否將運算過程分配到多個執行緒上,能否利用SIMD指令?

在進一步學習後,開發團隊瞭解到Unity元件系統的實體只是元件流中的查詢ID。元件只是資料,而系統包含了所有邏輯,系統會使用稱為“Archetypes(原型)”的特別元件標識來過濾實體。

Anders Eriksson表示:我們將ECS看作SQL資料庫可以幫助我們更好地理解它。每個Archetype原型是一張表格,每行代表一個元件,每列代表一個獨特的實體。我們可以使用系統查詢這些Archetype原型表,在實體上執行操作。

開始使用DOTS

為了更好地理解,Anders Eriksson研究了實體元件系統的文件和ECS示例專案,以及Unity與Nordeus合作製作的示例專案。

此外,關於面向資料設計的學習材料也對團隊有很大的幫助。CppCon 2014大會上Mike Acton關於面向資料設計的演講開闊了他們的眼界,讓開發團隊瞭解了這種程式設計方式。


Far North Entertainment的開發團隊在部落格上發表了許多學習心得,今年9月,他們在Unite Copenhagen大會上進行演講,介紹了轉換到面向資料思維的經驗。

本文的內容將以這次演講作為基礎,並且詳細地講解該團隊應用ECS、C# Job System和Burst Compiler的具體方法。

排列殭屍資料

Anders Eriksson表示:我們面臨的主要問題是客戶端的轉換資訊插入,以及對上千個實體的轉向資訊。

開發團隊最初使用物件導向的方法,編寫了ZombieView指令碼的抽象,它繼承了更為常用的EntityView父類。EntityView是附加在遊戲物件的MonoBehaviour,它會用作遊戲模型的視覺化展示。每個ZombieView指令碼會在Update函式中處理相應的資訊轉換和朝向資訊插入。

這種方法似乎挺不錯,但問題是每個實體會在記憶體中佔用隨機的位置。這意味著,在訪問數千個實體時,CPU需要從記憶體中逐個獲取實體資料,這個過程非常耗時。

如果將資料存在整齊的連續記憶體塊中,CPU則可以同時快取所有實體資料。現今大多數CPU在每個執行週期中可以從快取獲取128位元或256位元的資料量。

開發團隊決定改用DOTS系統生成敵人,希望藉此解決客戶端的效能瓶頸問題。首先,要轉換的是ZombieView指令碼中的Update函式,團隊確定了哪些程式碼要劃分到不同的系統中,以及哪些是必要的資料。

遊戲世界是一個2D網格,最首要的是對位置和朝向進行插值處理。殭屍的前進方向由兩個浮點值表示,最後的元件是一個目標位置元件,它會跟蹤敵人的伺服器位置。

  1. [Serializable]
  2. public   struct PositionData2D : IComponentData
  3. {
  4.     public float2 Position;
  5. }

  6. [Serializable]
  7. public struct HeadingData2D   : IComponentData
  8. {
  9.     public float2 Heading;
  10. }

  11. [Serializable]
  12. public struct TargetPositionData   : IComponentData
  13. {
  14.     public float2 TargetPosition;
  15. }
複製程式碼

然後是為敵人建立Archetype原型。Archetype原型是一組屬於特定實體的元件集,也可以說是一個元件標識。在專案中,由於敵人需要使用更多的元件,而且部分元件需要遊戲物件的引用,因此開發團隊使用了預製件來定義Archetype原型。

他們的方法是:在ComponentDataProxy中包裝元件資料,ComponentDataProxy會把資料轉化為可附加到預製件的MonoBehaviour。當呼叫EntityManager執行例項化操作,並傳入預製件時,系統會建立帶有預製件上所有元件資料的實體。所有元件資料都儲存在稱為“ArchetypeChunks(原型資料塊)”的16kb大小的資料塊中。

下圖展示了原型資料塊中的元件資料流的組織方式。

使用DOTS製作一款第三人稱殭屍射擊遊戲

Anders Eriksson解釋說:原型資料塊的一個主要優點是,系統不必在建立新實體時處理新的堆分配,因為記憶體已預先分配。因此在建立實體時,系統會直接在原型資料塊的元件流末尾處寫入資料。

只有當建立的實體資料不符合資料塊型別時,系統才會需要執行額外的堆分配。在這種情況下,系統會建立新的16kb原型資料塊來進行分配,如果有相同型別的空原型資料塊,則會將其重新利用。隨後,系統會將新實體的資料寫入到新資料塊的元件流中。

對殭屍進行多執行緒處理

現在資料被緊湊地打包,並在記憶體中以對快取友好的方式佈局好,開發團隊可以輕易利用C# Job System在多個CPU核心上並行執行程式碼。

下一步是建立可以在所有包含PositionData2D、HeadingData2D和TargetPositonData元件的原型資料塊中過濾掉所有實體的系統。

為此,Anders Eriksson及其團隊編寫了JobComponentSystem指令碼,在OnCreate函式上構建查詢功能。

程式碼如下所示:

  1. private EntityQuery m_Group;

  2. protected override void   OnCreate()
  3. {
  4.        base.OnCreate();

  5.        var query = new EntityQueryDesc
  6.        {
  7.               All = new []
  8.               {
  9.                      ComponentType.ReadWrite<PositionData2D>(),
  10.                      ComponentType.ReadWrite<HeadingData2D>(),
  11.                      ComponentType.ReadOnly<TargetPositionData>()
  12.               },
  13.        };

  14.        m_Group = GetEntityQuery(query);
  15. }
複製程式碼

這些程式碼會執行一次查詢,過濾掉所有包含位置、朝向和目標的實體。然後,開發團隊通過C# Job System在每幀上排程任務,將運算過程分配到多個工作執行緒上。

Andres Eriksson表示:C# Job System的優點在於,C# Job System也在Unity的原始碼中使用,因此我們不必擔心出現多個執行緒在執行過程中同時佔用相同CPU核心,產生互相阻礙各自執行的效能問題。

由於成千上萬的敵人意味著在執行時會有大量的原型資料塊要匹配查詢過程,所以開發團隊選擇使用IJobChunk,它可以在不同的工作執行緒上正確地分配各個資料塊。

在每幀上,名稱為“UpdatePositionAndHeadingJob”的新作業會處理遊戲中敵人的位置和轉向插值。

排程作業的程式碼如下所示:

  1. protected override JobHandle OnUpdate(JobHandle inputDeps)
  2. {
  3.         var positionDataType       = GetArchetypeChunkComponentType<PositionData2D>();
  4.         var headingDataType        = GetArchetypeChunkComponentType<HeadingData2D>();
  5.         var targetPositionDataType = GetArchetypeChunkComponentType<TargetPositionData>(true);

  6.         var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob
  7.         {
  8.                 PositionDataType = positionDataType,
  9.                 HeadingDataType = headingDataType,
  10.                 TargetPositionDataType = targetPositionDataType,
  11.                 DeltaTime = Time.deltaTime,
  12.                 RotationLerpSpeed = 2.0f,
  13.                 MovementLerpSpeed = 4.0f,
  14.         };

  15.         return updatePosAndHeadingJob.Schedule(m_Group, inputDeps);
複製程式碼

作業的宣告如下:

  1. public struct UpdatePositionAndHeadingJob : IJobChunk
  2. {
  3.     public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
  4.     public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;

  5.     [ReadOnly]
  6.     public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;

  7.     [ReadOnly] public float DeltaTime;
  8.     [ReadOnly] public float RotationLerpSpeed;
  9.     [ReadOnly] public float MovementLerpSpeed;
複製程式碼

當一個工作執行緒從佇列中抽調一個作業時,它會呼叫該作業的執行核心。

下面是執行核心的程式碼:

  1. public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
  2. {
  3. var chunkPositionData       = chunk.GetNativeArray(PositionDataType);
  4. var chunkHeadingData        = chunk.GetNativeArray(HeadingDataType);
  5. var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType);

  6. for (int i = 0; i < chunk.Count; i++)
  7. {
  8. var target       = chunkTargetPositionData[i];
  9. var positionData = chunkPositionData[i];
  10. var headingData  = chunkHeadingData[i];

  11. float2 toTarget = target.TargetPosition - positionData.Position;
  12. float distance  = math.length(toTarget);

  13. headingData.Heading = math.select(
  14. headingData.Heading,
  15. math.lerp(headingData.Heading,
  16. math.normalize(toTarget),
  17. math.mul(DeltaTime, RotationLerpSpeed)),
  18. distance > 0.008
  19. );

  20. positionData.Position = math.select(
  21. target.TargetPosition,
  22. math.lerp(
  23. positionData.Position,
  24. target.TargetPosition,
  25. math.mul(DeltaTime, MovementLerpSpeed)),
  26. distance <= 1
  27. );

  28. chunkPositionData[i] = positionData;
  29. chunkHeadingData[i]  = headingData;
  30. }
  31. }
複製程式碼

Anders Eriksson指出:你可能注意到我們使用了Select函式而不是Branch函式,這樣做的原因是避免所謂的分支誤預測。

Select函式會在兩種表示式中選擇匹配當前條件的一種,如果表示式並不需要很多的運算量,我建議使用Select,因為它更輕便,不必等待CPU從分支誤預測問題中恢復過來。

使用Burst Compiler提升效能

對於敵人位置和朝向的插值,完成DOTS轉換的最後一步是啟用Burst Compiler。

由於已經在連續陣列中排列好資料,又使用了Unity的全新Mathematics庫,因此只需給作業新增上BurstCompile屬性便可啟用該功能。

  1. [BurstCompile]
  2. public struct UpdatePositionAndHeadingJob : IJobChunk
  3. {
  4.     public ArchetypeChunkComponentType<PositionData2D> PositionDataType;
  5.     public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType;

  6.     [ReadOnly]
  7.     public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType;

  8.     [ReadOnly] public float DeltaTime;
  9.     [ReadOnly] public float RotationLerpSpeed;
  10.     [ReadOnly] public float MovementLerpSpeed;
複製程式碼

Burst Compiler可以提供單指令多資料流(SIMD),機器指令可以對多個輸入資料集進行操作,通過一個指令產生多個輸出資料集。這樣就可以在128位元大小的快取中加入更多正確的資料。

通過結合Burst Compiler、易於快取的資料佈局和C# Job System,開發團隊取得了很大的速度提升效果。

下面是效能對比圖表展示了在每個轉換步驟後速度的變化。

使用DOTS製作一款第三人稱殭屍射擊遊戲

結果顯示:對於客戶端上殭屍位置和朝向的插值過程上,開發團隊完全擺脫了此前遇到的瓶頸。資料的排布方式會更便於快取,而且快取行上只有相關的資料。所有的CPU核心都能夠投入工作,而Burst Compiler的輸出資料都是帶有SIMD指令的高度優化機器程式碼。

DOTS使用技巧

下面分享Far North Entertainment開發團隊對DOTS的一些使用技巧:

使用資料流的模式進行思考,因為在ECS中,實體只是用於並行元件資料流的查詢索引。

將ECS看作關係型資料庫,Archetype原型是表格,元件是行,而實體是表格內的索引(列)。

將資料組織到連續的陣列中,從而利用好CPU快取和硬體預取器。

不再以建立物件層級作為第一件事,在弄清楚真正要解決的問題前,制定通用的解決方案。

要考慮垃圾回收過程。對於效能資源緊張的位置,要避免進行過多的堆分配,並利用好Unity的Native容器。但要注意的是,此時需要手動進行清理過程。

瞭解抽象部分的開銷,注意虛擬函式的呼叫開銷。

通過使用C# Job System,利用好所有的CPU核心。

熟悉面向的硬體。Burst Compiler是否生成了SIMD指令?此時要使用Burst Inspector進行分析。

避免浪費快取行。在將資料打包為UDP資料包時,要考慮如何將資料打包存到快取行上。

針對已經在製作階段的專案,Anders Eriksson的建議是:找出遊戲中出現效能問題的具體位置,看看能否在這些位置使用DOTS。開發者沒必要轉換整個程式碼庫。

結語

Anders Eriksson總結說:Unite大會上釋出的DOTS動畫功能、Unity Physics和Live Link讓我們感到非常興奮,我們會在遊戲中更多地利用DOTS功能,希望可以將更多的遊戲物件轉換為ECS實體,而且Unity看起來在這個目標上取得了很好的進展。

相關文章