點選藍字關注我們吧!
目錄 | 1 如何建構分形 2 展示内容 3 構造子節點 4 塑造子節點 5 建立多個子節點 6 更多的子節點,更好的代碼 7 爆炸性生長 8 添加顔色 9、随機化Mesh 10 使分形不規則 11 旋轉分形 12 添加更多的不确定 |
本文重點:
1、執行個體化遊戲對象
2、了解遞歸
3、使用協程
4、添加随機性
分形是一個非常有意思的東西,而且大部分時候都很漂亮。在本教程中,我們将編寫一個小的C#腳本,讓它完成一些類似分形的行為。
這裡假設你已經能夠了解一些Unity的基本操作,并且能夠建立基本的C#腳本了。如果這些還不熟悉的話,可以再複習一下第一章 時鐘 相關的内容。
這是一篇比較舊的教程,裡面提到的漫反射和鏡面材質可能已經不使用與Unity2017了,是以可以忽略這些,但除此之外,這篇教程所展示的内容還是很有意思的。
(建立随機的3D分形)
1 如何建構分形
在開始建構3D分形之前,先要了解分形的概念。
簡單的來說就是一個粗糙的幾何物體,可以分為若幹部分,每個部分都是(或者近似)該物體縮小後的形狀。可以将其應用到Unity中的對象hierarchy中來實作這個效果。比如從某個根對象開始,然後向其中添加較小但在其他方面相同的子對象。
手動完成該操作将會非常麻煩,是以建立腳本來完成。
建立一個新項目和一個新場景。在裡面放了一個方向光,把相機移到一個合适的角度,也可以随意設定。
繼續建立一個用于分形的材質。材質很簡單,僅僅使用specular 着色器與預設設定即可,比起漫反射,這個看起來更舒服一些。
建立一個新的空遊戲對象并将其放置在原點。這将是分形的母體。然後建立一個名為Fractal的新C#腳本,并将其添加到對象上。
(工程建立)
2 展示内容
腳本有了,那麼分形是什麼樣子的呢?這裡通過在 Fractal 元件腳本中添加一個公共的Mesh和材料material
來實作它的可配置性。然後插入一個Start方法,在其中添加一個新的MeshFilter元件和一個新的MeshRenderer元件。同時,直接配置設定對應的網格和材料給它們。
什麼是mesh?
按照傳統了解,mesh是圖形硬體用來繪制複雜東西的結構。它是一個3D對象,要麼從外部導入到Unity中,這是Unity的預設形狀之一,要麼是由代碼生成。mesh需要包含3D空間中的點集合,以及由這些點定義的一組三角形(最基本的2D形狀)。由三角形構成網格所代表的任何表面。
大部分時候,你不會意識到你看到的其實是一堆三角形。
什麼是材質?
材質用來定義物體的視覺特性。它們可以是非常簡單(比如一個恒定的顔色),也可以非常複雜。材質一般要包括一個着色器和任何着色器需要的資料。
着色器基本作用是告訴顯示卡如何繪制物體的多邊形。标準漫射着色器使用單一的顔色和可選的紋理,結合場景中的光源,來确定多邊形的外觀。這裡使用的是稍微複雜的鏡面着色器,同時模拟了一個亮點。
Start函數什麼時候調用元件建立之後,處于active狀态,并且在第一次調用它的Update方法之前(如果它有的話),Start方法會被Unity調用。而且隻調用一次。
AddComponent 怎麼用?
AddComponent方法可以建立特定類型的新元件,并将其附加到遊戲對象,傳回對其的引用。這就是為什麼我們可以立即通路元件的值。當然也可以使用中間變量。
MeshFilter Filter=gameObject.AddComponent();
filter.mesh=Mesh;
這裡展示了一個特殊的文法。因為它是一個通用方法,實際上是可以處理一系列類型的模闆。你可以通過在尖括号中傳入參數它來告訴它應該使用什麼類型。
現在可以把我們定制的材質配置設定給fractal元件了。還可以通過單擊屬性旁邊的點并從彈出視窗中選擇Unity預設的立方體來配置設定Mesh。弄完之後,進入播放模式時,就會顯示一個立方體了。當然,也可以在代碼裡手動添加元件。
(運作時可以看到元件了)
3 構造子節點
該如何為這個分形創作子節點呢?最簡單的方法就是在Start函數裡建立一個新的Game Object并向其添加一個Fractal元件,試一下。
new 幹了什麼事情?
new 關鍵字用于構造對象或結構體的新執行個體。然後調用一個特殊的構造函數方法,該方法與它所屬的類或結構的名字相同。
現在問題是,每一個新的分形執行個體都會産生另一個分形執行個體。每一幀都會發生,無窮無盡,導緻死循環。如果不手動關閉,運作一段時間,當它把記憶體耗盡了之後,你的電腦就會當機了。
但大部分時候,無法停止的遞歸算法幾乎會立即消耗完機器的資源,并導緻堆棧溢出異常或崩潰。但在這個示例中,相對來說沒那麼快,因為它的遞歸的比較慢。
為了防止這種情況發生,需要引入一個最大深度的概念。最開始的分形執行個體的深度為零。每個它的後代節點都會有一個深度值。比如它的孫節點會有一個2的深度值,以此類推,直到達到最大的深度。
在inspector 視窗中添加一個公共maxDepth整數變量并将其設定為4。再添加一個私有深度整數。然後,隻有當我們在最大深度以下時,才建立一個新的子級。
(最大深度)
現在進入播放模式時會如何呢?
隻有一個子節點被創造出來了。這是為什麼呢?因為我們從來沒有給 depth 值,它總是零。因為零小于4,我們的根分形對象建立了一個子對象。孩子的深度值也是零。又因為,也沒有設定子節點的maxDepth,是以它也是零。是以,該子節點并沒有創造另一個。
除此之外,子節點也沒有配置設定材質和Mesh。這些引用可以直接從它的父級複制。現在添加一個處理所有必要初始化的新方法。
this是什麼意思?
this此關鍵字引用正在調用其方法的目前對象或結構。在引用同一個類的内容時,它一直被隐式地使用。例如,每當我們通路深度時,我們也可以通過this.depth來完成。通常隻在需要傳遞對對象本身的引用時才需要使用此方法,就像對Initialization所做的那樣。那又是為什麼要這樣做呢?因為需要調用的是新的子對象的Initialization方法,而不是父對象的初始化方法。
Initialize 調用是否在 Start 之前?
是的。首先建立新的遊戲對象。然後建立并添加一個新的分形元件。此時,如果存在其Awake和OnEnable方法,則将調用它們。然後AddComponent方法完成。在此之後,直接調用Initialization。Start的調用要到下一幀才會執行了。
進入遊戲模式,如預期的邏輯,這一次會建立四個子孫代。但它們現在還不是真正的孩子,因為它們都出現在層次根節點中。遊戲對象之間的父子關系是由它們的轉換層次來定義的。是以,一個孩子需要使它的transform元件的parent等于它的分形父transform 。
(兩種不同的層次結構)
4 塑造子節點
到目前為止,子節點已經被疊加在父節點上了,這意味着仍然隻看到一個立方體。現在需要把他們移動到他們的本地空間中,讓它們也能被看到。
每個子節點都應該比它們的父母小,是以我們也必須縮小它們的Scale值。
第一個要解決的是縮放。那麼應該縮放多少呢?用一個名為child Scale的新變量來配置它,并在inspector中給它指派0.5。别忘了把這個值也從父節點傳給子節點。然後用它來設定子節點的local scale。
接下來,該把這些孩子節點搬到哪裡去呢?那就直接向上移動吧,這樣它們就能接觸到它們的父節點。假設父節點在所有方向上的大小的機關是1,對于現在正在使用的立方體來說正好合适。向上移動一半,使父節點和子節點正好接觸在一起。是以,我們還需要移動一個額外的距離,距離相當于子節點的一半大小。
(子節點縮放值為0.5,從0.3至0.7)
5 建立多個子節點
現在我們做出來的東西有點像一座塔,還不是真正的分形,要完成分形還需要将它分支化。每個父節點建立多個子節點比較容易。但它們必須朝着不同的方向發展。是以,需要向Initialization方法中添加一個方向參數,并使用它将第二個子節點定位到右邊而不是上面。
…是什麼意思?
這意味着我省略了一段沒有改變的代碼。應該清除或更改代碼的位置,或者它的确切位置并不重要。
(每個父節點擁有2個子節點)
這看起來已經有點感覺了!那麼光從結果來看你能知道它是按照什麼順序來建造的嗎?因為它們都是在幾幀之内建立的,速度太快,無法看到它的建立的過程。如果能放慢這個過程應該會很有意思,因為這樣就能看到它的發生的過程。要如何去完成放慢的過程呢?答案是可以通過協同線建立子節點來實作。
協程可以看做是可以插入暫停語句的方法。當方法調用暫停時,程式的其餘部分繼續進行。雖然這個類比不太恰當,太過于簡單化,但我們現在隻需要利用這個特點就可以了。
将建立兩個子節點的代碼行移動到一個名為CreateChildren的新方法中。此方法需要将IEnumerator作為傳回類型,該類型存在于System.Collection命名空間中。這就是為什麼Unity在他們預設的腳本模闆中包含它,以及為什麼本示例在一開始也包括它的原因。
改變了方法類型之後,調用的方式也要調整,這裡不能再用直接調用的方式了,取而代之,要使用Unity的StartCoroutine方法。
然後在建立每個子節點之前添加一個暫停指令。如代碼所示,每半秒鐘内建立一個新的WaitForSecond對象,然後将其傳回給Unity。
enumerator是什麼?
枚舉是一次周遊某個集合的概念,就像循環周遊數組中的所有元素一樣。enumerator(枚舉器)或iterator(疊代器)是為此功能提供接口的對象。System.Collections.IEnumerator描述了這樣的接口。
為什麼我們需要用這個呢?因為協程需要用。這也是Unity在預設腳本模闆中包含System.Collection的原因,也是本示例将它包括在内的原因。
return 做了什麼?
return關鍵字可以表示一個方法中斷或者已經完成,把響應的結果傳回給調用者。傳回的内容必須與方法的類型比對。如果它是一個空方法,那麼也隻需要傳回空。
對于一個函數定義為空,可以省略return關鍵。
同樣的,一個方法中可能有多個return語句。在這種情況下,有多個可能的傳回點。通常使用if語句來确定使用了哪些return。
yield有什麼用?
yield語句被疊代器用來控制協程的生命周期。要使枚舉,就需要跟蹤它的進度。這涉及到一些基本相同的樣模闆碼。你真正想要的是隻編寫類似于 return firstItem; return secondItem這樣的代碼,直到函數執行結束。yield語句允許你準确地做到這一點。
是以,無論何時使用yield,都會在幕後建立枚舉器對象,以處理繁瑣的部分。這就是為什麼我們的CreateChildren方法将IEnumerator作為其傳回類型的原因。
順便說一下,你還可以生成另一個疊代器。在這個示例裡,另一個疊代器會被完全的處理,是以你其實可以用創造性的方式将它們縫合在一起。
協程怎麼工作?
當你在Unity中建立協程時,真正做的其是建立一個疊代器。當你将它傳遞給StartCooutine方法時,它将被存儲,并被要求每幀都要它的下一個Item,直到它完成為止。
yield語句會産生Item。而這中間的部分就是你可以發揮的地方了。
當你自己的代碼繼續運作時,你也可以産生一些特殊的協程,比如WaitForSecond,這樣就可以更好地控制代碼邏輯,但是總的來說都是一個疊代器而已。
現在可以看着它生長了!你能看出來這樣做有什麼問題嗎?可能現在還不明顯,現在為每個父節點添加第三個子節點,這一次放在左邊。
(每個父節點3個子節點,正常和overdraw視角)
如果檢視overdraw效果?
場景視圖的工具欄有一個下拉清單,預設設定為RGB。它的另一個選擇是 Overdraw 。
其實問題是子節點和他們的父節點有着相同的參考點。這意味着,其父母本身就是右子節點的左子節點。可能有點繞,就是說,父節點和子節點在某些方向上重合了。
為了解決這個問題,需要對子節點進行旋轉,這樣他們的向上方向就會遠離他們的父節點。
我通過向Initialization添加一個方向參數來解決這個問題。它将是一個四元數,用于設定新子節點的local rotation。向上的子節點不需要旋轉,右邊的子節點需要順時針旋轉90度,左邊的子節點需要向相反的方向旋轉。
(旋轉後的效果)
現在子節點已經被旋轉了,但它們生成出來的卻不是分形了。一些最小的子節點最終仍然會消失在根立方體裡面。這是因為如果Scale因子為0.5,這個分形将在四個步驟中産生了自相交。你可以通過減少縮放來解決這個問題,也可以使用球體代替立方體。
(子節點縮放為0.5的球體并沒有産生自相交)
6 更多的子節點,更好的代碼
現在的代碼已經有些笨重了。可以通過将方向和方位資料移動到靜态數組來優化。然後,再将CreateChildren簡化為一個短循環,并使用子索引作為Initialization的參數。
數組如何工作?
數組是長度固定的對象,包含一個線性變量序列。在聲明變量時,将方括号放在其類型後面表示需要該類型的數組。是以int myVariable;讓你獲得一個整數,而int[]myVariable;讓你獲得一個整數數組。
通路數組中的一個條目的方法是将數組索引(而不是位置)放在變量後面的方括号中。MyVariable[0]擷取數組中的第一個條目,myVariable[1]擷取第二個條目,依此類推。
實際上,建立一個數組并将其指派給變量是使用myVariable=newint[10]完成的;在本例中,該數組建立了一個包含10個條目空間的新數組。或者,您可以通過在花括号中列出它的初始值來隐式地建立一個,比如myVariable={1,2,3};。
for循環怎麼工作?
for循環是編寫周遊某些循環的一種緊湊方式。在本例中,我們使用一個名為i的整數作為疊代器。第一部分聲明疊代器整數,第二部分檢查循環的條件,第三部分增加疊代器。您可以使用while循環來獲得完全相同的結果,但是疊代器代碼不友善分組。
對于(int i=0;i<10;i++){doStuff(I);}
與int i=0;
while(i<10){doStuff(I);i++}效果相同。
順便說一句,i++是i+=1的縮寫,它是i=i+1的縮寫。
現在,讓我們通過簡單地将資料添加到數組中,再引入兩個子元素。一個向前,另一個向後。
(完整的分形,每個父節點擁有5個子節點)
現在有了完整的分形結構。但是根立方體的底部為什麼沒有呢?可以這樣想,分形是從某種東西中生長出來的,比如一種植物。雖然我沒有,但如果你想的話,可以添加一個特殊的第六個子節點向下,但隻是添加到根節點就好。添加到所有子節點的話又會變成第6個子分形了。
7 爆炸性生長
剛才的示例,我們實際建立了多少個立方體?因為我們總是為每個父節點建立五個子節點,當完全成長的時候,立方體的總數将取決于最大的深度。最大深度為零隻産生一個立方體,即初始的根節點。最大深度為一個,産生五個額外的孩子,總共有六個立方體。由于它是分形的,這個圖案重複,我們可以把它寫成函數f(0)=1,f(N)=5×f(n-1)+1。
上述函數産生序列1、6、31、156、781、3906、19531、97656等。你将看到這些數字顯示為Unity遊戲視圖中統計資料中的DrawCall的數量。如果啟用了動态批處理,則它将是DrawCall 和 Saved by batching 的總和。
Unity處理四五層的深度還綽綽有餘。再高的話,你的幀率将急速下降。
除了數量,持續時間也是一個問題。現在,我們在建立一個新的子節點之前暫停了半秒鐘。這會産生幾秒鐘的同步增長。我們可以通過随機延遲來更均勻地配置設定增長。這也導緻了一個更不可預測和有機的模式,讓觀察更有意思。
把固定的延遲替換為0.1到0.5之間的随機範圍。我還增加了最大深度到5,使效果更加明顯。
随機範圍是如何工作的?
Random是一個實用工具類,它包含一些接口來建立随機值。它的 Range 方法可用于在一定範圍内生成随機值。Range方法有兩個版本。可以使用兩個浮點數來調用它,在這種情況下,它會在最小值和最大值之間傳回一個浮點數,這兩者都包括在内。或者,可以用兩個整數調用Range,在這種情況下,它傳回一個整數,介于最小、排除最大值之間的某個值。這個版本的典型用例是随機選擇一個索引,比如某某數組[Random.Range(0,omeArray.Length)]。
8 添加顔色
這個分形沒有什麼生氣。通過添加一些顔色變化來搞點氣氛。通過從根部的白色插入到最小的子節點的黃色來實作吧。Color.Lerp 接口是一種友善的方式。内插器從0到1,我們通過将目前深度除以最大深度來實作。因為這裡不能用整數除法,是以我們首先将深度轉換為浮點數。
Lerp是幹什麼的?
LERP是線性插值的簡稱。它的典型特征是Lerp(a,b,t),它計算a+(b-a)*t,t在0-1範圍内。有不同類型存在多個版本,包括浮點數、向量和顔色。
(上色了,但是沒有動态批處理)
這看起來有内味了!但另一件事也發生了。動态批處理過去是起作用的,但現在不行了。我們該如何解決這個問題呢?
什麼是動态批處理?
動态批處理是由Unity執行的一種drawcall批處理形式。簡而言之,它将共享相同材料的網格組合成更大的網格。這樣做減少了CPU和GPU之間的通信量。
你可以通過 Edit/Projects Settings/Player/, 在 Other Settings 啟用或禁用它。
它隻适用于小網格。比如,你會發現它适用于Unity預設的立方體,但不适用于預設的球面。
導緻這個結果的問題是,因為調整子節點的材質顔色,Unity默默地創造了一個複制的材質。這其實是必要的,不然一切使用該材質的都将以相同的顔色結束繪制。然而,批處理隻有在相同的材質被用于多個物體時才有效。不相等的不檢查也不合并--因為要檢查的話就太耗性能了,而且結果也不一定就滿足合批條件--是以它必須是同一種材質。
那在每個深度都建立一個材質的副本,而不是每個立方體。添加一個新的數組字段來儲存材質。然後Start時檢查是否存在數組,如果沒有,則調用一個新的InitializeMaterials方法。在這種方法中,我們将顯式複制我們的材料和改變每一深度的顔色。
null是什麼?
非簡單值的變量的預設值為NULL。這意味着變量沒有引用任何内容。試圖從變量中調用或通路任何為NULL的内容都會導緻錯誤。你需要判斷這個值,以確定不會發生這種情況。
你也可以自己将這樣的變量設定為NULL,以便處理你不再需要它所引用的任何内容。注意,當将對對象的引用設定為NULL時,對象并不會自動被銷毀。隻有當所有地方都不引用他們的時候,他們才會成為垃圾收集器收集。
還請注意,此方法适用于私有元件字段,但不适用于公共元件字段。這是因為Unity的序列化系統會為它建立一個空數組,而本例中它不會是空數組。
現在,不要将材料引用從父節點傳遞到子節點,而是隻傳遞材料數組的引用。如果不這麼做的話,每個子節點将被迫創造自己的材料數組,我們就不能解決問題了。
為什麼不把 materials 設定為靜态?
之是以不把materials數組設定為靜态,是因為它取決于最大深度,這可能不同于分形和分形之間。同一時間你可以有多個分形但他們可以有不同的最大深度。
(上色了 并且有了動态批處理)
批次合并又回來了,但是已經和之前的不一樣了。但顔色還是沒那麼豐富。一個很好的調整是給最深的層次一個完全不同的顔色。這可以揭示分形的模式,可能你這樣也沒注意到吧。
簡單地改變最後的顔色到洋紅之後。此外,調整内插器,使我們仍然看到完全過渡到黃色。當我們在做它的時候,它的平方會帶來一個稍微好一些的轉變。
(有洋紅色的提示了)
再添加第二個顔色級數,例如從白色到青色的紅色提示。我們将使用一個單一的二維數組來容納它們,然後在需要材質時随機選擇一個。這樣,當我們進入遊戲模式時,我們的分形看起來就會有所不同。如果願意,可以随意添加第三步。
(随機顔色)
9、随機化Mesh
除了顔色,我們還可以随機選擇使用哪個Mesh。用數組替換公共網格變量,并從其中随機選擇一個。
如果要在檢查器中的新數組屬性中隻放置一個立方體,那麼結果将和以前一樣。但是如果加上一個球體,你就會突然得到50%的幾率,形成一個立方體,或者每個分形元素中的一個球體。
随意填充此數組。我把球體放了兩次,是以它被使用的可能性是立方體的兩倍。你也可以添加其他Mesh,膠囊和圓柱體不太好,因為它們是拉長的。
(随機選擇立方體和球體)
10 使分形不規則
現在的分形完成的很好,很完整,但是可以通過切斷它的一些分支來使它更加有獨特。通過引入一個新的公共spawnProbability變量來實作。傳遞這個值,然後用它随機地決定我們是産生一個子節點還是跳過。0的機率意味着根本沒有孩子會生長,而1的機率意味着所有的孩子都會産卵。即使數值略低于一個,也會大大改變我們分形的形狀。
靜态Random.value屬性在0到1之間産生一個随機值。将它與 spawnProbability 相比較可以告訴我們是否應該建立一個新的子節點。
(70%機率産生的分形效果)
11 旋轉分形
一我們的分形一直是個好孩子,一動不動。但是如果有一點動作是不是會更有趣。添加一個非常簡單的Update方法,它以每秒30度的速度圍繞目前的Y軸旋轉。
有了這個簡單的方法,所有的分形部分現在都在快樂地旋轉。都是以同樣的速度。那麼再次随機化!并使最大速度也可配置。
注意,我們必須在start(而不是Initialization)中初始化我們的旋轉速度,因為根元素也應該旋轉。
(配置速度)
12 添加更多的不确定
我們還能做更多的調整,以微妙的方式打破分形嗎?當然!有很多!其中之一是通過增加一個微妙的旋轉來破壞分形元素的排列。我們稱之為扭曲。
(看起來不錯的扭曲)
另一種選擇是把子節點的比例弄得亂七八糟。或者有時跳過深度。擺造型?那就自己來嘗試下吧!