Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

用 MultiMeshInstance3D 動畫化數千條魚

本教學將介紹遊戲 ABZU 所採用的技術,透過頂點動畫與靜態網格實例化,來繪製並動畫化成千上萬條魚。

在 Godot 中,可以透過自訂 ShaderMultiMeshInstance3D 來實現。應用下述技術,即使在較低階硬體上也能渲染數以千計的動畫物件。

我們將先從動畫一條魚開始,接著說明如何將動畫擴展到數千條魚。

動畫化一條魚

我們先從一條魚開始。將你的魚模型載入 MeshInstance3D,然後新增一個 ShaderMaterial

以下是本教程範例使用的魚模型圖片,你也可以選用任何你喜歡的魚模型。

../../../_images/fish.png

備註

本教學所用魚模型由 QuaterniusDev 製作,並以 CC0 1.0 Universal (CC0 1.0) 公共領域貢獻協議釋出:https://creativecommons.org/publicdomain/zero/1.0/

一般來說,你會用骨骼與 Skeleton3D 進行物件動畫。不過,骨骼動畫運算是在 CPU 上執行,每一影格都需計算大量操作,因此難以同時處理大量物件。若改用頂點著色器進行頂點動畫,則可完全不依賴骨骼,只需幾行程式碼就能在 GPU 上完成整個動畫運算。

動畫主要由四個關鍵動作組成:

  1. 左右擺動

  2. 以魚中心為軸的旋轉

  3. 平移波浪運動

  4. 平移扭轉運動

所有動畫程式碼都寫在頂點著色器中,透過 uniform 變數來控制動作幅度。這讓你可以在編輯器中調整動畫強度,並即時預覽結果,而不用重新編譯著色器。

所有動作會利用餘弦波來變動模型空間的 VERTEX。我們使用模型空間,是為了讓動作總是依據魚的自身方向。例如,左右擺動時會隨魚的左右方向移動,不會跟著世界座標的 x 軸動。

為了控制動畫速度,我們會先用 TIME 定義自己的時間變數。

//time_scale is a uniform float
float time = TIME * time_scale;

我們首先實作的是左右擺動。只要用 cos(TIME) 去偏移 VERTEX.x,每次網格渲染時,所有頂點就會根據 cos(time) 的數值左右擺動。

//side_to_side is a uniform float
VERTEX.x += cos(time) * side_to_side;

這個動畫效果大致如下:

../../../_images/sidetoside.gif

接著,我們加入旋轉軸。由於魚的中心點在 (0, 0),只要將 VERTEX 乘以旋轉矩陣,就能讓魚繞自身中心旋轉。

旋轉矩陣的建構方式如下:

//angle is scaled by 0.1 so that the fish only pivots and doesn't rotate all the way around
//pivot is a uniform float
float pivot_angle = cos(time) * 0.1 * pivot;
mat2 rotation_matrix = mat2(vec2(cos(pivot_angle), -sin(pivot_angle)), vec2(sin(pivot_angle), cos(pivot_angle)));

然後將旋轉作用於 xz 軸,只要把旋轉矩陣乘上 VERTEX.xz 即可。

VERTEX.xz = rotation_matrix * VERTEX.xz;

僅套用旋轉軸後,你會看到如下效果:

../../../_images/pivot.gif

接下來的兩個動作需要沿著魚的脊椎方向平移。為此,我們要用一個新變數 bodybody 是一個浮點值,尾巴端為 0,魚頭端為 1

float body = (VERTEX.z + 1.0) / 2.0; //for a fish centered at (0, 0) with a length of 2

下一個動畫是沿著魚身長度滑動的餘弦波。為了讓波浪沿魚脊椎推移,我們用 body 這個變數來偏移 cos 的輸入值。

//wave is a uniform float
VERTEX.x += cos(time + body) * wave;

這個動作看起來跟前面的左右擺動很像,但這裡每個頂點因為 body 偏移而處在波浪曲線的不同位置,所以效果像是一個波浪順著魚身流動。

../../../_images/wave.gif

最後一個動作是扭轉,也就是沿脊椎方向的滾動。我們同樣先建立一個旋轉矩陣。

//twist is a uniform float
float twist_angle = cos(time + body) * 0.3 * twist;
mat2 twist_matrix = mat2(vec2(cos(twist_angle), -sin(twist_angle)), vec2(sin(twist_angle), cos(twist_angle)));

我們將旋轉套用在 xy 軸,使魚看起來繞著脊椎滾動。要達到這個效果,魚的脊椎必須對齊 z 軸中心。

VERTEX.xy = twist_matrix * VERTEX.xy;

這是加上扭轉後的魚:

../../../_images/twist.gif

若將上述所有動作依序套用,就會得到類似果凍般流暢的運動感。

../../../_images/all_motions.gif

一般魚類主要用身體後半部游泳。因此,我們需要將波浪和平移的動作限制在魚的後半部。為此,我們會新增一個變數 mask

mask 是個浮點值,利用 smoothstep 讓它從魚頭(0)平滑過渡到魚尾(1),用來控制動作作用區域。

//mask_black and mask_white are uniforms
float mask = smoothstep(mask_black, mask_white, 1.0 - body);

下圖為將 mask 直接用於 COLOR 時魚的樣子:

../../../_images/mask.png

對於波浪動作,只要將其乘以 mask,即可將影響範圍限制在後半部。

//wave motion with mask
VERTEX.x += cos(time + body) * mask * wave;

要將遮罩應用在扭轉動作上,我們會用 mixmix 可以在未旋轉和完全旋轉之間混合頂點位置。這裡不能直接用 mask 乘旋轉後的 VERTEX,否則會導致魚縮小,所以必須用 mix 進行混和。

//twist motion with mask
VERTEX.xy = mix(VERTEX.xy, twist_matrix * VERTEX.xy, mask);

將這四個動作結合,就能得到最終的魚動畫效果。

../../../_images/all_motions_mask.gif

你可以多調整不同 uniform 參數,改變魚的游泳週期。只靠這四種動作組合,就能創造各種不同的游泳風格。

製作魚群

Godot 利用 MultiMeshInstance3D 節點,能輕鬆渲染數以千計的同類物件。

MultiMeshInstance3D 的建立與使用方式和 MeshInstance3D 節點類似。在本教學中,我們將 MultiMeshInstance3D 節點命名為 School,因為這裡會放一整群魚。

建立 MultiMeshInstance3D 後,請新增一個 MultiMesh ,並將上面用到的 Mesh (含著色器)加入 MultiMesh。

MultiMesh 除了一般網格外,還能為每個實例設定三種屬性:變換(旋轉、平移、縮放)、顏色與自訂資料。自訂屬性可用 Color 傳遞 4 個浮點數作各種用途。

instance_count 決定要實例化幾個網格。現在可以先設成 0,因為只要大於 0,其他參數就無法修改。我們稍後會用 GDScript 設定 instance_count

transform_format 用來指定要用 3D 還是 2D 變換。本教學請選擇 3D。

color_formatcustom_data_format 都可選擇 NoneByteFloatNone 代表不傳送這些資料(無論是 COLORINSTANCE_CUSTOM)給著色器。Byte 會用 8 位元儲存每個數值,Float 則用 32 位元浮點數。Float 精度高但較慢,Byte 記憶體用量少且速度快,但可能出現畫面誤差。

現在請將 instance_count 設為你想要的魚的數量。

接著要設定每個實例的變換資訊。

MultiMesh 的每個實例變換可以有兩種設定方式:一種完全在編輯器中完成,詳見 MultiMeshInstance3D 教學

另一種做法是用程式逐一設定每個實例的變換。以下例子用 GDScript 走訪所有實例,並將它們放到隨機位置。

for i in range($School.multimesh.instance_count):
  var position = Transform3D()
  position = position.translated(Vector3(randf() * 100 - 50, randf() * 50 - 25, randf() * 50 - 25))
  $School.multimesh.set_instance_transform(i, position)

執行此腳本會將所有魚隨機分布在 MultiMeshInstance3D 節點附近的方形區域中。

備註

如果效能不佳,請嘗試減少魚的數量來執行場景。

你會發現所有魚的游泳動作都同步,看起來很機械化。接下來我們要讓每條魚在週期中處於不同的位置,讓整個魚群更自然。

動畫化魚群

利用 cos 函式為魚做動畫的一大優點是,只需一個 time 參數。只要給每條魚一個不同的時間偏移,就能讓牠們的動作週期錯開。

我們只需要將每個實例的自訂值 INSTANCE_CUSTOM 加到 time 上即可。

float time = (TIME * time_scale) + (6.28318 * INSTANCE_CUSTOM.x);

接下來要將值傳入 INSTANCE_CUSTOM。只要在前述 for 迴圈中多加一行,給每個實例隨機分配四個浮點數。

$School.multimesh.set_instance_custom_data(i, Color(randf(), randf(), randf(), randf()))

這樣每條魚在週期裡的位置都不同。你也可以透過 INSTANCE_CUSTOM 讓每條魚的速度不同,只要乘以 TIME 就行。

//set speed from 50% - 150% of regular speed
float time = (TIME * (0.5 + INSTANCE_CUSTOM.y) * time_scale) + (6.28318 * INSTANCE_CUSTOM.x);

你也可以像設定自訂值那樣,隨機指定每個實例的顏色。

這時你會發現雖然魚有動畫,但它們其實沒移動。你可以每一影格去更新每條魚的實例變換,雖然這比動態更新上千個 MeshInstance3D 要快,但仍然可能不夠流暢。

在下一個教學中,我們會介紹如何用 GPUParticles3D 來善用 GPU,不僅能獨立移動每條魚,也能維持實例化的效能優勢。