遊戲開發進階Unity網格(Mesh\動態合批\骨骼動畫\蒙皮)
目錄
- 一、前言
- 二、Hello Mesh
- 三、萌新初識Mesh
- 1、引擎內建的Mesh
- 2、Mesh是什麼
- 三、Mesh的建立方式
- 1、第三方建模軟體
- 2、Unity建模外掛:ProBuilder
- 3、程式動態生成網格
- 四、Unity中如何顯示網格
- 1、MeshFilter:網格過濾器
- 2、MeshRenderer:網格渲染器
- 3、SkinnedMeshRenderer:蒙皮網格渲染器
- 3.1 骨骼動畫
- 3.2 SkinnedMeshRenderer元件
- 3.2 使用BakeMesh進行優化
- 五、純程式碼動態建立網格
- 1、建立Mesh物件
- 2、頂點座標
- 3、UV座標
- 4、三角形序列
- 5、重新計演算法線和包圍體
- 6、完整版程式碼
- 7、測試
- 8、專案原始碼
- 六、網格相關的開源專案
- 1、2D網格塗鴉
- 2、3D網格塗鴉
- 3、網格體素化
- 4、網格平滑演算法
- 5、網格切割
- 6、網格合併
- 七、未完的探險
一、前言
嗨,大家好,我是新發。
有同學私信我讓我寫一篇Unity
網格相關的教程,
那我就帶大家來一次Unity
的網格探險之旅吧~
二、Hello Mesh
我揹著旅行揹包走在Unity
的場景中,突然眼前出現了一棵樹,
我走近一看,這棵樹身上掛著MeshFilter
和MeshRenderer
元件,根據Unity
探險手冊記載,這個MeshFilter
是網格過濾器,它會引用一個網格資源,我順騰摸瓜,找到了對應的網格,
實在太美了,我久久佇立,這就是網格啊!
正當我欣賞著網格三角形時,突然世界暗了下來,眼前出現了一團火,
我又拿出了Unity
探險手冊,啊,這一定就是粒子系統了!它可以動態生成網格。
天外傳來一陣打字聲,場景中出現了一行看起來像文字的網格,作為一個具有多年Hello World
經驗的程式設計師,我看出了第一個單詞應該是Hello
,第二個單詞…我知道了,
是Hello Mesh
!
(此處為震撼人心的入場音樂)
三、萌新初識Mesh
1、引擎內建的Mesh
網格的英文名是Mesh
,Unity
萌新最先接觸的網格應該就是引擎內建的Cube
(正方體)、Capsule
(膠囊體)、Cylinder
(圓柱體)、Plane
(平面)、Sphere
(球體)、Quad
(四邊形),如下
事實上,我們在Unity
3D
模型、粒子特效、UI
、文字等等。
2、Mesh是什麼
從概念上講,網格是圖形硬體用來繪製複雜內容的構造。它至少包含一組定義3D
空間中點的頂點,以及一組連線這些點的三角形,實際上還包含法線、頂點顏色紋理座標等資訊,這些三角形構成了網格所代表的任何表面。
我們可以看下Unity
的Mesh
類,Mesh
的屬性和方法很多,我這裡列舉幾個比較常用的,如下
// 頂點座標陣列 public Vector3[] vertices { get; set; } // 法線向量陣列 public Vector3[] normals { get; set; } // 頂點顏色陣列 public Color[] colors { get; set; } // 三角形序列陣列,每三個數字為一組 public int[] triangles { get; set; } // uv座標陣列 public Vector2[] uv { get; set; } // 重新計演算法線,在修改完頂點後,通常會更新法線來反映新的變化,注意,法線是根據共享的頂點計算出來的。 public void RecalculateNormals(); // 從法線和紋理座標重新計算網格的切線。修改網格的頂點和法線之後,如果網格使用引用法線貼圖的著色器進行渲染,則切線需要更新。 public void RecalculateTangents(); // 重新計算從網格包圍體的頂點,在修改頂點後需要這個函式以確保包圍體是正確的,賦值三角形將自動重新計算這個包圍體。 public void RecalculateBounds();
畫個圖,方便大家有個直觀印象,
三、Mesh的建立方式
1、第三方建模軟體
建模本質上就是建網格,我們可以事先通過第三方建模軟體來建立模型網格,
常見的建模軟體比如
3DS MAX
官網:https://www.autodesk.com/products/3ds-max/overview
MAYA
官網:https://www.autodesk.com/products/maya/overview
blender
官網:https://www.blender.org/
2、Unity建模外掛:ProBuilder
Unity
官方提供了一個可以用來建立和自定義幾何體的工具ProBuilder
,我們可以在Unity
的Package Manager
中下載到這個外掛,
使用ProBuilder
我們可以直接在Unity
中建立或編輯簡單的幾何體,不用通過第三方建模軟體,提升了效率,方便快速搭建場景原型,
3、程式動態生成網格
網格也可以是程式動態生成的,比如粒子系統的網格就是動態生成的,
又比如文字,也是程式動態生成網格,
文章後面我還會手把手教你如何使用純程式碼來構建網格,這裡先不急著寫程式碼,我們繼續探尋網格的祕密先~
四、Unity中如何顯示網格
在Unity
中,我們要顯示一個網格,需要用到兩個元件:MeshFilter
和MeshRenderer
。
注:你也可以直接使用SkinnedMeshRenderer
元件,與MeshFilter
和MeshRenderer
的區別我下文會講。
1、MeshFilter:網格過濾器
MeshFilter
是網格過濾器,我們需要通過它設定引用的網格資源,比如這裡引用的是一個Cube
(正方體)網格。
我們可以看下MeshFilter.cs
的原始碼,
[RequireComponent(typeof(Transform))] [NativeHeader("Runtime/Graphics/Mesh/MeshFilter.h")] public sealed partial class MeshFilter : Component { [RequiredByNativeCode] // MeshFilter is used in the VR Splash screen. private void DontStripMeshFilter() {} extern public Mesh sharedMesh { get; set; } extern public Mesh mesh {[NativeName("GetInstantiatedMeshFromScript")] get; [NativeName("SetInstantiatedMesh")] set; } }
MeshFilter
只有兩個屬性:mesh
和sharedMesh
,
我們檢視Unity
的官方手冊,看看mesh
與sharedMesh
的區別:https://docs.unity3d.com/ScriptReference/MeshFilter.html
我來解讀一下,mesh
訪問的是一個Mesh
資源的例項(副本),這意味著我們修改這個mesh
並不會修改到原始資源本身,改的只是Mesh
的例項(副本)。
而sharedMesh
是原始資源的引用,如果修改了sharedMesh
,比如修改頂點座標,那麼原始資源也會被修改。
畫成圖大概是這樣子:
這裡我順手寫個隨機修改Mesh
頂點座標的,如下,將下面這個RandoMeshmVertices
指令碼掛到MeshFilter
元件所在的物體上即可,
// RandoMeshmVertices.cs // 隨機修改Mesh頂點座標 using UnityEngine; public class RandoMeshmVertices: MonoBehaviour { // Mesh的例項 MeshFilter meshFilter; // 頂點的原始座標 Vector3[] originalVertices; void Start() { meshFilter = GetComponent<MeshFilter>(); originalVertices = meshFilter.mesh.vertices; } void Update() { // 隨機修改頂點座標 Vector3[] vertices = meshFilter.mesh.vertices; for (int i = 0,len = originalVertices.Length; i < len; ++i) { var v = originalVertices[i]; vertices[i] = v + Random.Range(-0.1F,0.1F) * Vector3.one; } meshFilter.mesh.vertices = vertices; meshFilter.mesh.RecalculateNormals(); } }
執行效果如下,網格頂點座標發生了隨機偏移,
關於mesh
屬性的訪問需要特別注意一下,我們先看看Unity
官方手冊的說明,https://docs.unity3d.com/ScriptReference/MeshFilter-mesh.html
翻譯一下就是,如果一個Mesh
資源已經被分配給MeshFilter
的mesh
屬性,那麼當我們在程式碼中第一次訪問mesh
屬性時才正真建立了Mesh
的例項;再次訪問mesh
屬性時則直接返回這個例項,並且一旦mesh
屬性被訪問,則與原始共享網格的連結會丟失,此時sharedMesh
變成mesh
的別名,如果我們想避免這種自動生成Mesh
例項,可以使用sharedMesh
代替。
寫成虛擬碼的話大致是這樣子:
public class MeshFilter ... { ... private Mesh _mesh; public Mesh mesh { get { if (_mesh == null) { _mesh = new Mesh(); Copy(sharedMeh,_mesh); } return _mesh; } } ... }
還有,如果我們訪問了mesh
屬性而導致自動建立了Mesh
例項,則需要在程式碼中主動呼叫Resources.UnloadUnusedAssets
來銷燬沒有引用的Mesh
例項,建議是在場景切換時呼叫Resources.UnloadUnusedAssets
。
2、MeshRenderer:網格渲染器
MeshRenderer
,顧名思義,網格渲染器。我們依舊先來看看官方手冊的介紹:
https://docs.unity3d.com/Manual/class-MeshRenderer.html
翻譯過來就是MeshRenderer
會從MeshFilter
那裡拿到網格資料並在所在物體的位置處將其渲染出來。
如果沒有MeshRenderer
,我們就看不見網格了,如下
另外,我們還需要在MeshRenderer
的Materials
中指定一個材質球,這樣才能正常顯示,否則模型表面就是紫色的。
3、SkinnedMeshRenderer:蒙皮網格渲染器
SkinnedMeshRenderer
是蒙皮網格渲染器,可能有小夥伴就會問了,上面使用MeshFilter
和MeshRenderer
已經可以顯示模型網格了,為什麼又弄了一個SkinnedMeshRenderer
呢?
看下Unity
官方手冊的介紹:https://docs.unity3d.com/Manual/class-SkinnedMeshRenderer.html
可以看到SkinnedMeshRenderer
其實是針對帶 骨骼動畫 的模型的渲染的。
3.1 骨骼動畫
為什麼需要做骨骼動畫呢?
就好比我們人一樣,我們的骨骼會隨著我們肌肉的伸縮而動,骨骼又可以帶動它管轄的身體部位發生形變和移動,骨骼還會影響它所連線的其他骨骼一起發生聯動。對應到模型動作上,想想一個簡單的舉手動作要牽涉到多少網格頂點的移動,如果沒有骨骼,那動畫師要每幀挨個網格頂點進行調整,即使動畫做出來了,這個動畫也不能複用到其他模型上,因為不同模型的頂點資訊都不一樣,這麼低效的動畫製作肯定是不行的,於是,就有了骨骼動畫。
骨骼動畫的原理
就是將模型分為骨骼(Bone
)和蒙皮(Mesh
)兩個部分,骨骼可分為多層父子骨骼,每個骨骼都附加到周圍網格的一些頂點上,在動畫關鍵幀資料的驅動下,計算出各個父子骨骼的位置,基於骨骼的控制通過頂點混合動態計算出蒙皮網格的頂點。
動畫師可以在MAYA
軟體上給模型繫結骨骼,繫結骨骼不是本文的重點,這裡就不展講開具體操作了,感興趣的同學可以自行百科學習。
製作好匯出為fbx
格式,
將fbx
檔案匯入到Unity
中,選中它,
在Inspector
檢視中點選Rig
按鈕,
我們可以看到動畫型別Animation Type
有None
、Legacy
、Generic
和Humanoid
四個,
具體選項可以參見Unity
官方手冊:https://docs.unity3d.com/Manual/FBXImporter-Rig.html
我這裡演示一下人形骨骼動畫,選擇Humanoid
型別,Avatar Definition
選擇Create From This Model
,然後點選Configure
,
在Inspector
檢視中我們就可以看到對應的骨骼繫結資訊了,
如下,綠色的線段就是一根根骨骼,
我們調整一根骨骼,對應的網格也會跟著一起動,如下
這樣做出來的人形動畫是可以進行復用了,有請妹子上場,
骨骼動畫資源的話,我在之前的文章中也介紹過一個寶藏Mixamo
:https://www.mixamo.com/,上面有很多做好的人形骨骼動畫,
看,是不是挺好玩的,
我們可以把它的動作直接複用到我們自己的人形模型上,效果如下:
3.2 SkinnedMeshRenderer元件
骨骼動畫可以正常播放,要歸功於SkinnedMeshRenderer
元件,製作好骨骼動畫的fbx
檔案匯入Unity
中,Unity
會自動幫我們掛上SkinnedMeshRenderer
元件,
其中幾個重要的屬性我講一下,
Bounds
:骨骼資料;
Mesh
:要渲染的網格;
Root Bone
:根骨骼,其他骨骼都是相對根骨骼移動的;
BlendShapes
:一般用於製作表情融合,我之前寫過一篇文章講過BlendShapes
:
我們再來看看SkinnedMeshRenderer
指令碼的屬性和方法:
需要講的應該就是這個BakeMesh
方法了,下面我就單獨拎出來講下BakeMesh
。
3.2 使用BakeMesh進行優化
假設現在場景中有100
只皮卡丘,每隻皮卡丘的網格、貼圖、動作相同,
如果每隻皮卡丘身上都掛SkinnedMeshRenderer
,那就是100
個SkinnedMeshRenderer
在計算蒙皮,
由於SkinnedMeshRenderer
是根據骨骼動畫動態計算網格頂點座標,這個運算開銷還是不小的,有沒有辦法優化呢?
SkinnedMeshRenderer
提供了一個BakeMesh
方法,可以將一個蒙皮動畫的某個時間點上的動作,Bake
成一個不帶蒙皮的Mesh
,我們統一使用這個Mesh
來顯示其餘的皮卡丘,這樣就可以大大減少了SkinnedMeshRenderer
的計算了,
畫成圖大概是這樣子:
不過,上面這種方案的侷限性是每隻皮卡丘的動畫是相同的,如果突然某一隻皮卡丘要播放與其他皮卡丘不同的動畫,那就不行了。
另一種Bake
方案可以是這樣:
對皮卡丘的每個動畫進行遍歷取樣,把取樣到的Mesh
存到陣列中,因為這裡要Bake
很多網格,比較耗時,建議在載入場景時時就完成取樣過程;後面要播放某個動畫時直接從這個Mesh
陣列中獲取Mesh
來顯示,此時直接使用MeshFilter
加MeshRenderer
的方式來顯示網格就好了。
貼個BakeMesh
的示例指令碼:
using UnityEngine; using System.Collections.Generic; /// <summary> /// Bake Mesh 示例 /// </summary> public class BakeMeshTest : MonoBehaviour { [SerializeField] Animation m_animation; [SerializeField] SkinnedMeshRenderer m_skinnedMeshRenderer; [SerializeField] string m_clipToBake = "Idle"; List<Mesh> m_bakedMeshList = new List<Mesh>(); /// <summary> /// 取樣幀數 /// </summary> [SerializeField] int m_numFramesToBake = 30; void Start() { // 獲取要Bake的動畫片段 AnimationState clipState = m_animation[m_clipToBake]; if (clipState == null) { Debug.LogError(string.Format("Unable to get clip '{0}'",m_clipToBake),this); return; } // 開始播放動畫 m_animation.Play(m_clipToBake,PlayMode.StopAll); // 設定動畫初始時間戳 clipState.time = 0.0f; // 取樣幀間隔 float deltaTime = clipState.length / (float)(m_numFramesToBake - 1); for (int frameIndex = 0; frameIndex < m_numFramesToBake; ++frameIndex) { string frameName = string.Format("BakedFrame{0}",frameIndex); // 建立Mesh Mesh frameMesh = new Mesh(); frameMesh.name = frameName; // 動畫取樣 m_animation.Sample(); // 執行BakeMesh m_skinnedMeshRenderer.BakeMesh(frameMesh); m_bakedMeshList.Add(frameMesh); // 設定動畫時間戳 clipState.time += deltaTime; } // 停止播放動畫 m_animation.Stop(); } }
需要提醒的是,這個方案是利用空間換時間,如果模型頂點資料特別多或動畫時長特別長的時候,這時就會遇到記憶體瓶頸。
五、純程式碼動態建立網格
一般情況下,網格是事先製作好的資源,但也有一些特殊的需求需要在程式碼中動態建立網格。
比如我之前寫的一篇牙齒碎了的文章:《Unity 2D圖片任意形狀破碎碎裂效果,以此紀念我的牙光榮犧牲》
現在我來教大家如何使用程式碼從零建立網格並將網格渲染出來,下文我以建立一個正方形網格為例進行講解。
1、建立Mesh物件
第一步最簡單,就是直接new
一個Mesh
,
var mesh = new Mesh();
2、頂點座標
首先分析一下,一個四邊形有四個頂點,假設正方形邊長為1
,四個點的座標如下,
寫成程式碼就是這樣:
// 構建頂點座標
var vertices = new List<Vector3>();http://www.cppcns.com
vertices.Add(new Vector3(-0.5f,-0.5f,0));
vertices.Add(new Vector3(-0.5f,0.5f,0));
vertices.Add(new Vector3(0.5f,0));
// 將頂點座標設定給Mesh
mesh.SetVertices(vertices);
3、UV座標
UV
座標就是紋理貼圖座標,它將紋理上每一個點精確對應到模型物體的表面上,注意U
和V
的取值範圍是0~1
。
UV
座標系原點在左下角,U
軸是水平軸,V
軸是豎直軸,如下:
對應到我們的上面那個正方向網格的話,四個點的UV
座標如下:
寫成程式碼就是這樣:
// 構建UV座標
var uvs = new List<Vector2>();
uvs.Add(new Vector2(0,0));
uvs.Add(new Vector2(www.cppcns.com0,1));
uvs.Add(new Vector2(1,0));
// 將UV座標設定給Mesh
mesh.SetUVs(0,uvs);
4、三角形序列
網格需要切分成三角形,我們可以這樣切分,
當然也可以這樣切分,
兩種切分方法對應不同的三角形序列,假設 法線方向 是垂直於螢幕從內指向螢幕外的話,第一種切分方式的三角形序列如下:
注:法線的方向就決定了表面正面,如果你的材質是單面渲染的話,那麼只有從正面看才能看到網格被渲染。
即三角形序列為:{ 0,1,2,3 }
,注意序號是從0
開始的。
為什麼是這樣的順序呢?我教大家一個技巧,伸出你的左手,豎起大拇指,像這樣子,
大拇指指向法線的方向,那麼此時你的其餘四根手指頭環繞的方向就是三角形的序號的順序,三個序號為一組按順序塞入陣列中即可,即得到的陣列就是:{ 0,3}當然,以下陣列最終的效果都是等價的,只要順序一致即可:
{ 0,3 },
{ 1,3 www.cppcns.com},
{ 0,3,0 },
…
我們現在寫成程式碼,
// 重新計演算法線,注意,法線是根據共享的頂點計算出來的。 mesh.RecalculateNormals(); // 重新計算包圍體,在修改頂點後需要這個函式以確保包圍體是正確的 mesh.RecalculateBounds(); // 從法線和紋理座標重新計算網格的切線(如果網格使用引用法線貼圖的著色器進行渲染,則切線需要更新) // 因為我們這裡不使用法線貼圖,所以就不呼叫它了 // mesh.RecalculateTangents();
5、重新計演算法線和包圍體
當我們設定或修改了頂點資料後,需要呼叫Mesh
的Recalculate
方法來重新計算一些必要的資訊,比如重新計演算法線、包圍體,程式碼如下
// 重新計演算法線,注意,法線是根據共享的頂點計算出來的。 mesh.RecalculateNormals(); // 重新計算包圍體,在修改頂點後需要這個函式以確保包圍體是正確的 mesh.RecalculateBounds(); // 從法線和紋理座標重新計算網格的切線(如果網格使用引用法線貼圖的著色器進行渲染,則切線需要更新) // 因為我們這裡不使用法線貼圖,所以就不呼叫它了 // mesh.RecalculateTangents();
6、完整版程式碼
以上程式碼封裝成GenQuadMesh.cs
指令碼,完整程式碼如下:
// 使用程式碼生成四邊形網格 using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(MeshFilter))] [RequireComponent(typeof(MeshRenderer))] public class GenQuadMesh : MonoBehaviour { public MeshFilter mf; private void Start() { mf.mesh = Build(); } public static Mesh Build() { var mesh = new Mesh(); // 構建頂點座標 var vertices = new List<Vector3>(); vertices.Add(new Vector3(-0.5f,0)); vertices.Add(new Vector3(-0.5f,0)); vertices.Add(new Vector3(0.5f,0)); // 將頂點座標設定給Mesh mesh.SetVertices(vertices); // 構建UV座標 var uvs = new List<Vector2>(); uvs.Add(new Vector2(0,0)); uvs.Add(new Vector2(0,1)); uvs.Add(new Vector2(1,0)); // 將UV座標設定給Mesh mesh.SetUVs(0,uvs); // 設定三角形序列 var triangles = new int[] { 0,3 }; mesh.SetTriangles(triangles,0); mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } }
7、測試
建立一個空物體,掛上MeshFilter
和MeshRenderer
元件。
再掛上我們上面寫的GenQuadMesh
指令碼,賦值mf
變數為MeshFilter
物件,如下
執行Unity
,看到一個紫色快,
將Scene
檢視的模式設定為Wireframe
,如下
現在我們可以看到我們動態建立的網格啦,
上面之所以顯示紫色塊,是因為我們沒有給MeshFilter
設定材質球,順手做一個炮姐的材質球吧,
給MeshRenderer
設定材質球物件,
重新執行Unity
,效果如下,
8、專案原始碼
要用程式碼動態建立一個Mesh
,就是new
一個Mesh
,給它塞入頂點座標、UV
座標和三角形序列即可。再複雜的網格也可以通過這些步驟創建出來~
下面這些就是使用純程式碼創建出來的幾何體網格,感興趣的同學可以下載專案原始碼下來學習。
專案原始碼:https://codechina.csdn.net/linxinfa/unity-mesh-builder
六、網格相關的開源專案
我再推薦一些網格相關的開源專案給大家~
1、2D網格塗鴉
專案地址:https://.com/mattatz/unity-triangulation2D
2、3D網格塗鴉
專案地址:https://github.com/mattatz/unity-teddy
3、網格體素化
專案地址:https://github.com/Scrawk/Mesh-Voxelization
4、網格平滑演算法
專案地址:https://github.com/mattatz/unity-mesh-smoothing
5、網格切割
專案地址:https://github.com/hugoscurti/mesh-cutter
6、網格合併
專案地址:https://github.com/sanukin39/UniMeshCombiner
七、未完的探險
好了,這次探險之旅就暫時到這裡吧,還有很多內容需要探索,先保持體力,我們下次再見,更多關於Unity網格(Mesh\動態合批\骨骼動畫\蒙皮)的資料請關注我們其它相關文章!