之前就對 Stylize 3D 渲染感興趣了,鋪於地面的清脆毛毯,隨風搖曳的蓬鬆樹葉,光待在賞心悅目的世界裡就是種幸福。總之,這次我製作了沿著物體表面生成大量物件的工具,說不定能在未來的某時派上用場。
起因
之前看到一部 3D Pixel Rendering 的影片,透過特別的方式渲染漂亮的像素場景,每個角度看來都像精緻的圖畫,也是我最想要追求的目標之一。當中樹葉與草地就是透過 GPU Instance 生成的,沿著地面與樹木生成植被,再使用像素著色器渲染風格化場景。
除此之外,為了擴展技美的技能樹,我也接觸了程序建模軟體 Houdini,一個使用理性進行美術設計的神奇工具。當中有個實用的節點稱作 Scatter,它可以沿著輸入模型的表面隨機生成 PointCloud,讓使用者進一步生成其他物件,相當方便。
![image display error, please report: [/devlog/technical/surface-scatter-1/houdini.gif]](/devlog/technical/surface-scatter-1/houdini.gif)
受此啟發,我也想研究背後的數學原理,並嘗試在 Unity 中實作相似功能。
成果展示
首先,先展示成果吧!我找了一個樹木的模型當基底,沿著樹葉的部份生成大量方塊,看起來就像由色塊點綴而成的圖畫。
![image display error, please report: [/devlog/technical/surface-scatter-1/result-1.gif]](/devlog/technical/surface-scatter-1/result-1.gif)
技術研究
想不到看似簡單的功能會隱藏那麼多的難題,接下來請讓我解釋其中的各種原理。
模型採樣
既然是沿著表面生成,取得模型資料就是我們的第一步。3D 模型是由許多的三角面組合而成,而每個面都會有三個頂點,電腦則透過「頂點」與「索引」的陣列儲存整個模型。
![image display error, please report: [/devlog/technical/surface-scatter-1/mesh.jpg]](/devlog/technical/surface-scatter-1/mesh.jpg)
在 Unity 裡可以透過 Mesh.vertices
與 Mesh.triangles
取得我們要的資料。每三個 triangles
為構成面的一組索引,只要其映射到 vertices
資料就能建構一個三角面,遍歷所有索引就能重建出整個模型。
csharp
void ForeachFace() { Vector3[] vertices = surface.vertices; int[] triangles = surface.triangles; for (int i = 0; i < triangles.Length; i += 3) { Vector3 pointA = vertices[triangles[i + 0]]; Vector3 pointB = vertices[triangles[i + 1]]; Vector3 pointC = vertices[triangles[i + 2]]; DrawTriangle(pointA, pointB, pointC); } }
![image display error, please report: [/devlog/technical/surface-scatter-1/mesh-wireframe.jpg]](/devlog/technical/surface-scatter-1/mesh-wireframe.jpg)
表面取點
取得模型的資料後,只要在每個面上進行隨機生成就能達成 Scatter 的效果了。在三角形上採樣最簡單方式就是雙重插值,將隨機作為權重就能產生隨機的位置了。
csharp
Vector3 rndBottom = Vector3.Lerp(pointA, pointB, UnityEngine.Random.value); Vector3 rndPoint = Vector3.Lerp(rndBottom, pointC, UnityEngine.Random.value); scatterPoints.Add(rndPoint);
![image display error, please report: [/devlog/technical/surface-scatter-1/triangle-random.gif]](/devlog/technical/surface-scatter-1/triangle-random.gif)
只要散佈大量的點,就能得到類似全息圖的樣子了…大功告成?
![image display error, please report: [/devlog/technical/surface-scatter-1/scatted-1.jpg]](/devlog/technical/surface-scatter-1/scatted-1.jpg)
不,這還只是開始而已。
機率修正
從上面的方塊可以看出角落的聚集了更多點,這是機率不平均導致的,因為二次插值時朝頂點的一邊會有較高的密度,視覺化會更好理解原因。
![image display error, please report: [/devlog/technical/surface-scatter-1/triangle-probability.jpg]](/devlog/technical/surface-scatter-1/triangle-probability.jpg)
我沒找到能在三角型裡「直接」產生機率平均的隨機算法,但是平行四邊形就不同了,它能很好的達成平均隨機,因此我把四邊形視作兩個三角形計算,轉換思路。
![image display error, please report: [/devlog/technical/surface-scatter-1/rectangle.gif]](/devlog/technical/surface-scatter-1/rectangle.gif)
先在四邊形上隨機生成,再把超出範圍的點重新映射回三角形,如此一來就能得到平均的散佈算法了。
![image display error, please report: [/devlog/technical/surface-scatter-1/rectangle-remapping.gif]](/devlog/technical/surface-scatter-1/rectangle-remapping.gif)
檢查插值的權重,當水平與垂直相加大於一時,就代表位置超出三角型的邊界,只要反轉權重就能讓它回到正確範圍中了。
csharp
float abRnd = UnityEngine.Random.value; float acRnd = UnityEngine.Random.value; if (abRnd + acRnd > 1) { abRnd = (1 - abRnd); acRnd = (1 - acRnd); } Vector3 abShift = Vector3.Lerp(Vector3.zero, b - a, abRnd); Vector3 acShift = Vector3.Lerp(Vector3.zero, c - a, acRnd); return a + abShift + acShift;
![image display error, please report: [/devlog/technical/surface-scatter-1/rectangle-probability.jpg]](/devlog/technical/surface-scatter-1/rectangle-probability.jpg)
數量修正
起初我指定生成的「數量」來控制每面進行隨機的次數,但它產生的效果並不理想,因為模型面數會直接影響生成總數,且定值會導致過大或過小的面看起來也更稀疏、擁擠。
![image display error, please report: [/devlog/technical/surface-scatter-1/instance-count.jpg]](/devlog/technical/surface-scatter-1/instance-count.jpg)
為維持結果穩定,我將輸入的參數從「數量」改變為「密度」,讓算法根據每面的「面積」決定生成數量。原本我用一堆計算求出高再換算面積,後來發現能用外積函式推導出結果,花了一點時間消化原理,感謝朋友題點。
![image display error, please report: [/devlog/technical/surface-scatter-1/triangle-area.jpg]](/devlog/technical/surface-scatter-1/triangle-area.jpg)
引用自 The vector product
在線性代數的世界中,我們能
![image display error, please report: [/devlog/technical/surface-scatter-1/determinant.jpg]](/devlog/technical/surface-scatter-1/determinant.jpg)
擷取自 3B1B 的 Essence of linear algebra 系列
而在三維空間裡,可以透過外積 corss
達成目標,將兩個向量輸入函數會獲得垂直於兩向量的法線,這個法線長度同時也代表了平行四邊型的面積。
![image display error, please report: [/devlog/technical/surface-scatter-1/corss.jpg]](/devlog/technical/surface-scatter-1/corss.jpg)
擷取自 3B1B 的 Essence of linear algebra 系列
因此,三角形面積就能透過外積長度除二取得,相當快速。
csharp
Vector3 cross = Vector3.Cross(pointB - pointA, pointC - pointA); float area = length(cross) / 2;
回到數量計算,只要將面積乘上生成的密度數值,就能確保整體密度維持平均,大面積的區域會生成更多點,而小區塊則會減少生成數量。
csharp
float density; int amount = area * density; for(int i = 0; i < amount; i++) { }
![image display error, please report: [/devlog/technical/surface-scatter-1/instance-density.gif]](/devlog/technical/surface-scatter-1/instance-density.gif)
重疊修正
完全隨機的結果可能發生重疊,這在生成大型物體時可不是理想現象,所以我把生成方式改成真正的平均分佈,將索引平均映射到矩形範圍中,產生整齊的結果。
csharp
for(int i = 0; i < amount; i++) { x = i % width y = i / width }
因為機率修正的改動,平均映射又會產生位置重疊的問題,我嘗試只生成一半再重新映射填滿三角形…但還是可能發生重疊問題,可以看到接縫處的樣子並不理想。
![image display error, please report: [/devlog/technical/surface-scatter-1/tidy-remap.jpg]](/devlog/technical/surface-scatter-1/tidy-remap.jpg)
最後決定直接在整個矩形範圍生成,捨棄一半的資料,雖然導致後續並行運算的效能對折,但至少結果是理想的。
![image display error, please report: [/devlog/technical/surface-scatter-1/tidy.gif]](/devlog/technical/surface-scatter-1/tidy.gif)
並行生成
最後一步,為了達成超大量的物件生成(和渲染),我重寫了並行版本的算法,透過計算著色器運行。內容與 C# 大致相同,只是資料的儲存與傳遞有些改變,需要透過 ComputeBuffer
將模型資料傳入 GPU 才能計算,而生成的結果也會留在 GPU Buffer 裡。
hlsl
StructuredBuffer<int> trianglesBuffer; StructuredBuffer<float3> verticesBuffer; AppendStructuredBuffer<float3> scatterBuffer;
並行是以面為單位進行的,也更貼合實際情況會使用的複雜表面。這裡就不解釋細節了,有興趣的人可以參考以前的筆記【筆記】初學指南,計算著色器。
hlsl
[numthreads(64, 1, 1)] void ScatterKernel (uint3 id : SV_DispatchThreadID) { if(id.x >= _FaceCount) return; int index = id.x * 3; float3 vertA = scatterBuffer[trianglesBuffer[index + 0]]; float3 vertB = scatterBuffer[trianglesBuffer[index + 1]]; float3 vertC = scatterBuffer[trianglesBuffer[index + 2]]; float area = length(cross(vertB - vertA, vertC - vertA)) / 2; int count = area * _Density; for(int i = 0; i < count; i++) { //... } }
透過偉大的圖學力量,每幀生成十萬個點都不是問題。一直閃是因為 seed 也不斷改變。
![image display error, please report: [/devlog/technical/surface-scatter-1/compute-istance.gif]](/devlog/technical/surface-scatter-1/compute-istance.gif)
感謝閱讀
結果渲染
將生成結果傳入渲染管線,再加上光照計算就有繪畫風的樹葉了。研究的第一階段到此為止,接下來就是朝實際應用思考,後續請見【日誌】根據地形生成場景植被。
![image display error, please report: [/devlog/technical/surface-scatter-1/result-2.gif]](/devlog/technical/surface-scatter-1/result-2.gif)
喜歡文章請幫我按讚和分享歐 :D
參考資料
Generate random points in a triangle
Vector products and the area of a triangle
Determinant | Essence of linear algebra
Cross prducts | Essence of linear algebra
Cross prducts as transformations | Essence of linear algebra