Unity DOTS 蜻蜓點水

本文還在不斷完善,可能不會及時同步在 SegmentFault,源文章在個人博客中:螢火之森 - Unity DOTS 蜻蜓點水html

The Big Picture

簡單介紹 Data-Oriented Technology Stack (DOTS, 數據導向型技術棧) ,其包含了 C# Job System、the Entity Component System (ECS) 和 Burst。git

特色

DOTS 要實現的特色有:程序員

  • 性能的準確性。咱們但願的效果是:若是循環由於某些緣由沒法向量化,它應該會出現編譯器錯誤,而不是使代碼運行速度慢8倍,並獲得正確結果,徹底不報錯。
  • 跨平臺架構特性。咱們編寫的輸入代碼不管是面向 iOS 系統仍是 Xbox,都應該是相同的。
  • 咱們應該有不錯的迭代循環。在修改代碼時,能夠輕鬆查看爲全部架構生成的機器代碼。機器代碼「查看器」應該很好地說明或解釋全部機器指令的行爲。
  • 安全性。大多數遊戲開發者不把安全性放在很高的優先級,但咱們認爲,解決 Unity 出現內存損壞問題是關鍵特性之一。在運行代碼時應該有一個特別模式,若是讀取或寫入到內存界限外或取消引用 Null 時,它可以提供咱們明確的錯誤信息。

其中向量化指的是 Vectorization。github

向量化的相關介紹:編程

Burst

Unity 構建了名爲 Burst 的代碼生成器和編譯器。數組

當使用 C# 時,咱們對整個流程有完整的控制,包括從源代碼編譯到機器代碼生成,若是有咱們不想要的部分,咱們會找到並修復它。咱們會逐漸把 C++ 語言的性能敏感代碼移植爲 HPC# (高性能 C#,下文會提到)代碼,這樣會更容易獲得想要的性能,更難出現 Bug,更容易進行處理。安全

若是 Asset Store 資源插件的開發者在資源中使用 HPC# 代碼,資源插件在運行時代碼會運行得更快。除此以外,高級用戶也會經過使用 HPC# 編寫出自定義高性能代碼而受益。數據結構

ECS Track: Deep Dive into the Burst Compiler - Unite LA多線程

Burst 對於 HPC# 更詳細的支持能夠在下面找到:架構

Burst User Guide

深刻棧

向量化(Vectorization)沒法進行的常見狀況是,編譯器沒法確保二個指針不指向相同的內存,即混淆狀況(Alias)。Alias 的問題在 Unity GDC 中也有一個演講提到過:Unity at GDC - C# to Machine Code

Collections 類就是爲了解決這個問題而誕生的,裏面包含 NativeList<T>、NativeHashMap<TKey, TValue>、NativeMultiHashMap<TKey, TValue> 和 NativeQueue<T> 四種額外的數據結構。

兩個 NativeArray 之間從不會發生混淆這種狀況,這也是爲何咱們將會常用這些數據結構。咱們能夠在 Burst 中運用這個知識,使它不會因爲懼怕兩個數組指針指向相同內存而放棄優化。

Unity 還編寫了 Unity.Mathemetics 數學庫,提供了不少像 Shader 代碼的數據結構。Burst 也能和這數學庫很好的工做,將來 Burst 將可以爲 math.sin() 等計算做出犧牲精度的優化。

對於 Burst 而言,math.sin() 不只是要編譯的 C# 方法,Burst 還能理解出 sin() 的三角函數屬性,同時知道 x 值較小時會出現 sin(x) 等於 x 的狀況,並瞭解它能替換爲泰勒級數展開,以便犧牲特定精度。

跨平臺和架構的浮點準確性是 Burst 將來的目標。

傳統模式的問題

傳統模式指的是什麼呢?

  • 跟 MonoBehaviours 打交道
  • 數據和其處理過程耦合在一塊兒
  • 高度依賴引用類型

問題一:數據分佈在內存的各個角落

離散的數據致使搜索效率十分低下,還有 Cache Miss 的問題,這個問題能夠參考下面的連接:

ECS的泛泛之談

問題二:不少沒必要要的數據也被提供了

例如當咱們要調用 Transform 時,可能實際上咱們只須要 position 和 rotation 兩個屬性來移動 gameObject,可是其餘不須要的數據也被提供給了 gameObject。

問題三:低效的單線程數據處理

傳統模式只使用單線程來按順序一個一個地處理數據和操做,這樣十分低效。

高性能 C#(HPC#)

當咱們使用 C# 語言時,仍然沒法控制數據在內存中如何進行分佈,但這是咱們提高性能的關鍵點。

除此以外,標準庫面向的是「堆上的對象」和「具備其它對象指針引用的對象」。

也就是意味着,當處理性能敏感代碼時,咱們能夠放棄使用大部分標準庫,例如:Linq、StringFormatter、List、Dictionary。禁止內存分配,即不使用類,只使用結構、映射、垃圾回收器和虛擬調用,並添加可以使用的部分新容器,例如:NativeArray 和其餘集合類型。

咱們能夠在越界訪問時獲得錯誤和錯誤信息,以及使用 C++ 代碼時的調試器支持和編譯速度。咱們一般把該子集稱爲高性能 C# 或 HPC#。

它能夠被總結爲:

  • 大部分的原始類型(float、int、uint、short、bool...),enums,structs 和其餘類型的指針
  • 集合:用 NavtiveArray<T> 代替 T[]
  • 全部的控制流語句(除了 try、finally、foreach、using)
  • throw new XXXException(...) 給予基礎支持

Job System

Job System 是針對上述傳統模式問題的一種解決方式。例以下圖能夠把發射子彈當作一個 Job,從而用多線程來並行地處理髮射操做。

目前主流的 CPU 有 4-6 個物理核心,8-12 個邏輯核心,多線程處理將可以更好地發揮 CPU 的性能。

傳統的多線程問題也有不少:

  • 線程安全的代碼十分難寫
  • 競態條件,也就是計算結果依賴於兩個或更多進程被調度的順序
  • 低效的上下文切換,切換線程的時候十分耗時

而 Job System 就是專一解決上面問題的一個方案,這樣咱們就能享受着多線程的好處來開發遊戲。固然了,咱們也要寫出正確的 ECS 代碼,熟悉新的開發模式。

解決的多線程問題

C++ 和 C# 都沒法爲開發者編寫線程安全代碼提供太多幫助。即便在今天,擁有多個核心遊戲消費級硬件發展至今已通過去了十年,但依舊很難有效處理使用多個核心的程序。

數據衝突,不肯定性和死鎖是使多線程代碼難以編寫的挑戰。Unity 想要的特性是「確保代碼調用的函數和全部內容不會在全局狀態下讀取或寫入」。Unity 但願應該讓編譯器拋出錯誤來提醒,而不是屬於「程序員應遵照的準則」,Burst 則會提供編譯器錯誤。

Unity 鼓勵 Unity 用戶編寫 「Jobified」 代碼:將「全部須要發生的數據轉換」劃分爲 Job。

Job 會明確指定使用的只讀緩衝區和讀寫緩衝區,嘗試訪問其它數據會獲得編譯器錯誤。Job 調度程序會確保在 Job 運行時,任何程序都不會寫入只讀緩衝區。Unity 也會確保在 Job 運行時,任何程序都不會讀取讀寫緩衝區。

若是調度的 Job 違反了這些規則,咱們會獲得運行時錯誤(一般這種錯誤會在競態條件出現時獲得)。錯誤信息會說明,你正在嘗試調度的 Job 想要讀取緩衝區 A,但你以前已經調度了會寫入緩衝區 A 的 Job ,因此若是想要執行該操做,須要把以前的 Job 指定爲依賴。

Entity Component System

Unity 一直以組件的概念爲中心,例如:咱們能夠添加 Rigidbody 組件到遊戲對象上,使對象可以向下掉落。咱們也能夠添加 Light 組件到遊戲對象上,使它能夠發射光線。咱們添加 AudioEmitter 組件,可使遊戲對象發出聲音。

咱們實現組件系統的方法並無很好地演變。過去咱們使用面向對象的思惟編寫組件系統,致使組件和遊戲對象都是「大量使用 C++ 代碼」的對象,建立或銷燬它們須要使用互斥鎖修改「id 到對象指針」的全局列表。

經過使用面向數據的思惟方式,咱們能夠更好地處理這種狀況。咱們能夠保留用戶眼中的優良特性,即只需添加組件就能夠實現功能,而同時經過新組件系統取得出色的性能和並行效果。

這個全新的組件系統就是實體組件系統 ECS。簡單來講,現在咱們對遊戲對象進行的操做可用於處理新系統的實體,組件仍稱做組件。那麼區別是什麼?區別在於數據佈局。

ECS 數據佈局

ECS 使用的數據佈局會把這些狀況看做一種很是常見的模式,並優化內存佈局,使相似操做更加快捷。

原型(Archetype)

ECS 會在內存中對帶有相同組件(Component)集的全部實體(Entity)進行組合。ECS 把這類組件集稱爲原型(Archetype)。

下圖的原型就是由 Position 組件、Velocity 組件、Rigidbody 組件和 Renderer 組件組成的。

若是一個實體只有三個組件(不一樣於前面提到的原型),那麼那三個組件就組成了一個新的原型。

下面的圖來自 Unite LA 的一次演講的講義, 很遺憾那次演講沒有錄製下來。講義能夠在這裏找到。

ECS 以 16k 大小的塊(Chunk)來分配內存,每一個塊僅包含單個原型中全部實體組件數據。

一個帖子中有人提供了更加形象的內存佈局圖,例如上半部分的原型由 Position 組件和 Rock 組件組成,其中整個原型佔了一個塊(Chunk),兩個組件的數據分別存在兩個數組中,裏面還帶着組件數據對應的實體的信息。

每一個原型都有一個 Chunks 塊列表,用來保存原型的實體。咱們會循環全部塊,並在每一個塊中,對緊湊的內存進行線性循環處理,以讀取或寫入組件數據。該線性循環會對每一個實體運行相同的代碼,同時爲 Burst 創造向量化(Vectorization,能夠參考 StackOverflow 的問題)處理的機會。

每一個塊會被安排好內存中的位置,以便於快速從內存獲得想要的數據,詳情能夠參考下面的文章。

Unity2018 ECS框架Entities源碼解析(二)組件與Chunk的內存佈局 - 大鵬的專欄 - CSDN博客

實體(Entity)

實體是什麼?實體只是一個 32 位的整數 key (和一些額外的數據例如 index 和 version 實體版本,不過在這裏不重要),因此除了實體的組件數據外,沒必要爲實體保存或分配太多內存。實體能夠實現遊戲對象的全部功能,甚至更多功能,由於實體很是輕量。

實體的性能消耗很低,因此咱們能夠把實體用在不適合遊戲對象的狀況,例如:爲粒子系統內的每一個單獨粒子使用一個實體。

實體自己不是對象,也不是一個容器,它的做用是把其組件的數據關聯到一塊兒。

系統(System)

咱們沒必要使用用戶的 Update 方法搜索組件,而後在運行時對每一個實例進行操做,使用 ECS 時咱們只需靜態地聲明:我想對同時附帶 Velocity 組件和 Rigidbody 組件的全部實體進行操做。爲了找到全部實體,咱們只需找到全部符合特定「組件搜索查詢」的原型便可,而這個過程就是由系統(System)來完成的。

不少狀況下,這個過程會分紅多個 Job ,使處理 ECS 組件的代碼達到幾乎 100% 的核心利用率。ECS 會完成全部工做,咱們只須要提供對每一個實體運行的代碼便可。咱們也能夠手動處理塊迭代過程(IJobChunk)。

當咱們從實體添加或移除組件時,ECS會切換原型。咱們會把它從當前塊移動到新原型的塊,而後交換以前塊的最後實體來「填補空缺」。

在 ECS 中,咱們還要靜態聲明要對組件數據進行什麼處理,是 ReadOnly 只讀仍是 ReadWrite 讀寫(Job System 一小節提到過的兩種緩衝區)。經過肯定僅對 Position 組件進行讀取,ECS 能夠更高效地調度 Job ,其它須要讀取 Position 組件的 Job 沒必要進行等待。

大致上,實體提供純粹的數據給系統,系統根據本身所須要的組件來得到相應的知足條件的實體,最後系統再經過多線程來基於 Job System 來處理數據。

這種數據佈局也解決了 Unity 長期以來的困擾,即:加載時間和序列化的性能。如今從大型場景加載或流式處理 ECS 數據的時間,不會比從硬盤加載和使用原始字節多多少。

優勢

總的來講,ECS 有如下好處:

  • 爲性能而生
  • 更容易寫出高度優化和可重用的代碼
  • 更能充分利用硬件的性能
  • 原型的數據被緊密地排列在內存中
  • 享受 Burst 編譯器帶來的魔法

缺點

對 ECS 的常見觀點是:ECS 須要編寫不少代碼。所以,實現想要的功能須要處理不少樣板代碼。如今針對移除多數樣板代碼需求的大量改進即將推出,這些改進會使開發者更簡單地表達本身的目的。

Unity 暫時沒有實現太多這類改進,由於 Unity 如今正專一於處理基礎性能。

太多樣板代碼對 ECS 遊戲代碼沒有好處,咱們不能讓編寫 ECS 代碼比編寫 MonoBehaviour 更麻煩。
——Unity

而爲網頁遊戲而生的基於 ECS 的 Project Tiny 已經實現了部分改進,例如:基於 lambda 函數的迭代 API。

最後

因爲本身空閒時間很少,只能囫圇吞棗地拼湊出這樣一篇筆記。上面大部分文字都是來自 Unity 的博文介紹,本身加了其餘的內容幫助理解。本文從內存佈局介紹了 ECS 的概念,也介紹了 Job System 和 Burst。我相信走過一遍文章以後,能清楚 Unity 對數據驅動的將來開發趨勢的佈局,也能更加容易從 Unity ECS Sample 中理解如何實踐 ECS。

參考

相關文章
相關標籤/搜索