2018年9月25日 星期二

Unity:數值範圍屬性加強版 001

通常,如果我們宣告一個可在 Inspector 視窗設置的浮點數欄位,想要使它限制在一個範圍內,可以使用 RangeAttribute 這個屬性來標示最大和最小值,只是提供給屬性的參數,必須是常數,這在某些需求裡,好像有點不夠彈性;以下,就來說明如何使用 PropertyAttributePropertyDrawer 製作另一種做法。


(以下程式碼皆以圖片展示,點擊圖片可放大,以利閱讀)

先前曾經在 Facebook 粉絲專頁貼出一張關於強化官方 RangeAttribute 的程式碼的圖片(這裡),當時表示改天會再詳細說明內容,因此,先來簡單描述事發經過,稍後再逐步說明相關做法與原理。

曾經在「Unity 應用領域」社團,有人提出的問題需求,在 Script 中宣告了速度和加速度的欄位變數,並在加速度欄位變數上使用了官方的 RangeAttribute 標示其最大和最小值,但是希望加速度所設置的最大值不要超過速度所設置的數值,但是,因為不能直接將速度變數做為參數提供給 RangeAttribute,所以,似乎只能在程式運行時使用判斷式或 Mathf.Clamp 這類的 API 在適當的時候防呆或校正加速度的數值。

雖然,該員最後以改變設計需求來解決所遭遇的問題,不過,當時已使用 PropertyAttributePropertyDrawer 寫了一段程式碼提供給他參考,以下就來說明這些內容。

首先,我們要知道在 Unity 中的變數欄位可以在 Inspector 視窗顯示欄位來設置,是因為編輯器本身會對 public 欄位以及有使用 SerializeField 屬性來標記的 private 欄位進行序列化;而我們可以宣告個繼承 PropertyAttribute 的屬性類,並搭配繼承 PropertyDrawer 的繪製類來自定義被標示的欄位變數要在 Inspector 視窗如何顯示與操作。

針對以上的故事,可以了解需求如下:
  • 速度欄位變數可在 Inspector 視窗設置。
  • 加速度欄位設置範圍為 0 到速度值。

先建立一個名為 Test 的 C# Script,由於這些變數欄位在運行時都不需要被外部存取,只有在內部運作,所以宣告為 private 欄位;也因為要能夠顯示在 Inspector 視窗上,所以都加上 SerializeField 屬性。


此時,在 Inspector 視窗上顯示的,是大家所熟悉並預測得到的樣子:兩個浮點數欄位。


接下來,建立一個名為 SpeedLimitAttribute 的 C# Script,由於這是要用於提供給欄位變數的屬性類,同時在 Unity 中要能夠在 Inspector 視窗繪製出欄位,所以,改為繼承 PropertyAttribute;同時,為了要能夠知道是以哪個欄位變數值來限制速度,所以,這個屬性類要宣告一個 public 的屬性(Property)來紀錄目標欄位變數的名稱。關於 Attribute 的撰寫規則,可以查看微軟網站上的相關文件,例如這裡


有了這個屬性類,就可以回到 Test.cs 裡面為欄位變數標記屬性並指定目標欄位變數名稱。


此時,如果回到 Unity 編輯器中,Inspector 視窗應該看不到有任何改變,因為我們還沒有撰寫它的繪製類。

在任何一個 Editor 資料夾裡面建立一個名為 SpeedLimitDrawer 的 C# Script;由於這是要用於在編輯器中繪製欄位用的類別,因此要 using UnityEditor 並繼承 PropertyDrawer

這個繪製類是用來繪製 SpeedLimitAttribute 這個屬性類,因此使用 CustomPropertyDrawer 這個屬性來標示繪製的目標。

PropertyDrawer 中已經有個虛擬的 OnGUI() 用來處理繪製欄位,因此在這裡直接使用 override 來覆寫 OnGUI(),而我們真正要製作的繪製內容程式碼將寫在這個 OnGUI() 裡面。


在這裡,可以從 PropertyDrawerattribute 成員透過轉型來獲得要繪製的目標屬性類的物件。取得該物件之後,就可以在稍後存取到其中紀錄的目標欄位變數名稱。


此處 OnGUI() 的 property 代表的是所繪製欄位的序列化屬性,也就是所要繪製的欄位變數的序列化資料都在這裡面,我們可以從這個序列化屬性取得它本身的序列化物件,這個序列化物件代表的就是配置在 GameObject 上的 Component,因此可以將屬性類物件裡面記錄的目標欄位變數名稱提供給它,而能直接找出並取得目標欄位變數的序列化屬性,那麼後續就可以從這個取得的序列化屬性獲得其中的欄位變數值,進而用來繪製有指定限制範圍的欄位。


在這裡的 OnGUI() 中並不適用於使用 GUILayoutEditorGUILayout 這一類的方式來繪製 UI,而是需要提供具體的位置和大小來繪製 UI,因此,OnGUI() 本身帶有 position 參數來表示此時繪製的 UI 位置及大小,我們可以用它來提供即將用來繪製欄位的 UI 元素使用。

為了避免所記錄的目標欄位變數名稱錯誤而導致尋找的欄位不存在,因此要先進行簡單的判斷,如果不存在,則使用 EditorGUI.PropertyField 來依據所提供的序列化屬性內容,直接繪製出適合的欄位。

如果存在,則使用 EditorGUI.Slider 依據所提供的參數數值繪製滑動條欄位;而它的最大值則由目標欄位變數的序列化屬性來提供,這樣就能夠用指定欄位變數來做為此欄位的上限值。


此時回到 Unity 編輯器中,查看 Inspector 視窗,可以發現 Acceleration 欄位變成一個滑動條,而且會依據 Speed 欄位值改變所能設置的最大值。


到這裡,看起來好像已滿足了需求,但這只是針對特定需求而編寫的程式碼,其命名方式並不通用,而且只是讓最大值彈性些,其實用途有限。

既然是官方原本的 RangeAttribute 不能滿足需求才這樣做的,不如就來做個新的,既能具備 RangeAttribute 的基本功用,又能夠彈性的讓最大或最小值取自其它欄位變數。

新的需求如下:
  • RangeAttribute 一般,直接以數值提供最大和最小值。
  • 指定某個欄位變數名稱做為最小值來源,但直接以數值提供最大值。
  • 指定某個欄位變數名稱做為最大值來源,但直接以數值提供最小值。
  • 最大和最小值都使用欄位變數名稱來指定來源。
  • 不提供最大和最小值,也不使用欄位變數名稱來指定最大和最小值來源時,預設數值範圍為 0 到 1。
  • 當指定來源的欄位不存在(或名稱錯誤),將無作用,直接顯示欄位本身的外觀。

先來建立一個名為 RangesAttribute 的 C# Script,並改為繼承 PropertyAttribute

因為使用這個屬性(Attribute)時,可能會需要紀錄最小值、最大值、最小值來源名稱、最大值來源名稱,同時也希望它們能夠以識別字的方式來指定,所以先個別以屬性(Property)來宣告它們。


在這裡的屬性(Property)中,get 和 set 沒有內容的話,這個屬性(Property)的用途跟欄位變數(Field)基本上沒啥差別,也可以如變數般儲存資料。

為了使之後在繪製類中方便判斷,可以在這個屬性類中預先處理並記錄下目前是屬於哪種使用類型,所以,可以先宣告列舉來定義幾種類型,如一般類型、有指定最小值來源名稱、有指定最大值來源名稱、有指定最小值和最大值來源名稱;然後,另外宣告個屬性(Property)來紀錄目前使用哪種類型,並限制外部只能讀取不能存入。


因為有了這個使用類型的定義,當這個 RangesAttribute 被使用時,可能會依照最大值或最小值的來源名稱有沒有被指定而變更使用類型,所以,之後 MinName 和 MaxName 在 set 時應該會有其它事情要做;MinName 和 MaxName 將不會如 Min 和 Max 那樣只是用來儲存資料,所以,要再另外宣告欄位變數(Field)來儲存名稱。


這裡的 MinName 和 MaxName,目前只有宣告 get 來取得所儲存的名稱,稍後再來補上 set 的部分。

前面需要用到的屬性(Property)和欄位變數(Field)都準備好之後,依照需求條件,這個 RangesAttribute 在建構時,會有這幾種情況:
  • 不輸入參數時,讓最小值為 0、最大值為 1,或者以指定最大值來源名稱和最小值來源名稱為主來使用。
  • 只輸入 1 個參數,此參數可能用於最大值或最小值,此時可能就是要搭配指定最大值來源名稱或最小值來源名稱之一來使用。
  • 輸入 2 個參數,直接指定最大值和最小值。

不管如何,在建構時如果有參數輸入,則先記錄適當的最大和最小值,並且初始的使用類型為一般(Normal)。因為 C# 語法本身支援函式多載,因此,在建構函式中可以利用多載來輕鬆的達到以上需求。


此時,在 RangesAttribute 被使用時,會依據輸入的參數來紀錄初始的最大值、最小值和使用類型。

然後,如果有再個別指定最大值或最小值的來源名稱,才去記錄下名稱並變更使用類型。但考慮到有一種情況是最大值和最小值都有指定來源名稱,所以,在 MinName 的 set 中要判斷目前的使用類型是否為 MaxNamed 來決定此時的使用類型應該是要變更為 MinNamed 還是 MinMaxNamed,同樣的,在 MaxName 的 set 中也要做同樣的事情。


到這裡,這個 RangesAttribute 就算完成了。

接下來,要在任何一個 Editor 資料夾中建立一個名為 RangesDrawer 的 C# Script,並依照前面 SpeedLimitAttribute 那樣 using UnityEditor 並繼承 PropertyDrawer 和指定要繪製的目標為 RangesAttribute,並使用 override 關鍵字來表示要覆寫 OnGUI()


同樣的做法,可以從 PropertyDrawerattribute 成員透過轉型來獲得要繪製的 RangesAttribute 的物件,然後,就可以在稍後存取到其中的屬性(Property)。

能夠存取到 RangesAttribute 物件的屬性(Property),就能夠使用它的 MinName 和 MaxName 透過 OnGUI() 的 property 參數直接從它的序列化物件中找出並取得所指定名稱的序列化屬性。


接著,只要依據 RangesAttribute 中的使用類型來判斷,為 EditorGUI.Slider 提供適當的最大值和最小值來繪製出滑動條欄位;而如果在有指定來源名稱的情況下,可能會因為名稱錯誤而導致找不到正確的序列化屬性,那麼就直接繪製原本的欄位就好。


到這裡,算是大功告成了。

可以再次建立個測試用的 C# Script 來試看看各種使用情況。


以上這段程式碼,在 Unity 編輯器中的 Inspector 視窗會看到各種結果。

預設值都是 0 的時候

個欄位的最大值

個欄位的最小值
以上,雖然都有達到需求了,但其實還有些美中不足的地方,例如,如果將 RangesAttribute 用於不是浮點數的欄位變數,就會出現錯誤,這部分則要在 RangesDrawer 中進行額外的判斷處理,在此就不加以補充了。

另外,Unity 將能夠顯示於 Inspector 視窗的欄位稱為 Property,而 C# 則稱此欄位變數為 Field,並稱可以提供 get 和 set 的「屬性」為 Property,但是可以標記於欄位變數上的 Attribute,中文也稱為「屬性」,這些名詞定義上的雷同與差異,可能會帶來不少混淆與困擾,要特別留意它們在不同地方所代表的意義,才不會造成混亂。

雖然,這些程式碼是在 Unity 2018.1 編寫的,但因為是蠻基本的東西,所以也適用於較舊版本的 Unity。