你的第一個 3D 著色器

你決定開始編寫自己的自訂 Spatial 著色器。或許你在網路上看到了很酷的著色器特效,又或是發現 StandardMaterial3D 無法完全滿足你的需求。無論原因為何,現在你決定自己動手寫一個,只是不知道該從哪裡開始。

本教學將說明如何編寫 Spatial 著色器,並且涵蓋比 CanvasItem 教學更多的主題。

Spatial 著色器比 CanvasItem 著色器擁有更多內建功能。對 Spatial 著色器來說,Godot 已為常見用例提供了豐富功能,使用者只需要於著色器內設定適當參數即可。這一點在 PBR(基於物理的算繪)工作流程中特別明顯。

這是一個分為兩部分的教學。在第一部分中,我們將透過頂點函式中的高度圖來進行頂點位移,製作簡單地形。在 第二部分 中,我們將延伸本教學的概念,在片段著色器中設定自訂材質,並編寫海洋水面著色器。

備註

本教學假設你已具備一些著色器的基礎知識,例如型別(vec2floatsampler2D)以及函式。如果你對這些概念還不熟悉,建議在完成本教學前,先參考 《著色器之書》 入門。

在哪裡設定材質

在 3D 中,物件是透過 Mesh 來繪製的。Mesh 是一種資源,會以「表面(surface)」的單位儲存幾何資訊(物件的形狀)與材質(物件的顏色與受光方式)。一個 Mesh 可以有多個表面,也可以只有一個。一般來說,你會從其他軟體(如 Blender)匯入 Mesh,但 Godot 也有一些 PrimitiveMesh 可讓你直接在場景中新增基本幾何體而不需匯入。

你可用多種節點型別來繪製 Mesh,主要是 MeshInstance3D,但你也可以使用 GPUParticles3DMultiMesh)等其他節點。

通常情況下,材質會與 Mesh 的特定表面綁定,但有些節點(如 MeshInstance3D)允許你覆寫單一表面或所有表面的材質。

如果你在表面或 Mesh 本身設定材質,所有共用該 Mesh 的 MeshInstance3D 都會共用同一材質。但若你想讓多個 Mesh 實例共用同一 Mesh,且每個實例有不同材質,就應該直接在 MeshInstance3D 上設定材質。

本教學將直接把材質設定在 Mesh 本身,不使用 MeshInstance3D 的材質覆寫功能。

設定

在場景中新增一個 MeshInstance3D 節點。

於屬性面板中,點擊 MeshInstance3D 的 Mesh 屬性右側的 <空> ,選擇新建 PlaneMesh,然後點擊出現的平面圖示以展開該資源。

這會在場景中新增一個平面。

接著,在視窗左上角點擊 透視 按鈕,在跳出的選單中選擇 顯示線框

這樣你就能看到構成平面的三角形了。

../../../_images/plane.webp

現在將 PlaneMeshSubdivide WidthSubdivide Depth 都設為 32

../../../_images/plane-sub-set.webp

你會發現 MeshInstance3D 中出現了更多三角形。這樣我們就有更多頂點可以操作,能增加更多細節。

../../../_images/plane-sub.webp

像 PlaneMesh 這類 PrimitiveMesh 只會有一個表面,因此材質也只有一個,不是陣列。將 Material 設為新的 ShaderMaterial,然後點擊出現的球體圖示展開材質。

備註

所有繼承自 Material 的材質資源(例如 StandardMaterial3DParticleProcessMaterial),都可以轉換為 ShaderMaterial,現有的屬性會轉為對應的文字著色器。要進行轉換,只需在檔案系統面板中右鍵點選該材質並選擇 轉換成 ShaderMaterial。你也可以在屬性檢視器中,對任何指向材質的屬性右鍵進行同樣操作。

接下來把材質的 Shader 設定為新的 Shader,點擊 <空> 並選擇 新建 Shader...。保留預設設定,輸入著色器名稱後按 建立

在屬性面板點擊該 Shader,著色器編輯器就會彈出。現在你就可以開始編寫你的第一個 Spatial 著色器了!

著色器魔法

../../../_images/shader-editor.webp

新建立的著色器已經自動產生了 shader_type 變數、vertex() 函式及 fragment() 函式。Godot 著色器的第一步就是宣告著色器類型。這裡 shader_type 被設為 spatial,因為這是一個 Spatial 著色器。

shader_type spatial;

vertex() 函式決定 MeshInstance3D 各頂點在最終場景中的位置。我們將用它來偏移每個頂點的高度,讓平面看起來像小地形。

如果 vertex() 函式內沒有任何內容,Godot 會使用預設的頂點著色器。我們可以先加一行做出改變:

void vertex() {
  VERTEX.y += cos(VERTEX.x) * sin(VERTEX.z);
}

加上這一行後,你應該會看到如下圖所示的效果。

../../../_images/cos.webp

來解釋一下這行程式碼。這裡我們增加了 VERTEXy 值,並把 VERTEXxz 組件分別作為 cos()sin() 的參數,這會讓平面在 x 與 z 軸上呈現波浪狀外觀。

我們的目的是讓表面看起來像小山丘;而 cos()sin() 的曲線本來就很像丘陵。我們只需縮放傳入 cos()sin() 的參數即可調整山丘的形狀。

void vertex() {
  VERTEX.y += cos(VERTEX.x * 4.0) * sin(VERTEX.z * 4.0);
}
../../../_images/cos4.webp

這樣確實好看多了,但還是太尖銳且重複,讓我們讓它變得更有趣一些。

雜訊高度圖

雜訊是模擬地形外觀時很常用的工具。可以想像它像餘弦函式那樣產生一連串小山,但雜訊可以讓每座山的高度都不同。

Godot 提供 NoiseTexture2D 資源,可用來產生雜訊貼圖並供著色器存取。

若要在著色器中存取貼圖,請在著色器檔案頂端、vertex() 函式外面加入以下程式碼。

uniform sampler2D noise;

這樣就能傳送雜訊貼圖給著色器了。現在回到屬性面板,在材質底下你會看到 著色器參數 區塊,裡面會有一個名為「Noise」的參數。

將這個 Noise 參數設為新的 NoiseTexture2D,然後在 NoiseTexture2D 裡,將 Noise 屬性設為新的 FastNoiseLite。FastNoiseLite 會用來產生高度圖。

設定好後,應該會像這樣。

../../../_images/noise-set.webp

接下來透過 texture() 函式來取用雜訊貼圖:

void vertex() {
  float height = texture(noise, VERTEX.xz / 2.0 + 0.5).x;
  VERTEX.y += height;
}

texture() 的第一個參數是貼圖,第二個參數是 vec2 位置。我們用 VERTEXxz 分量來決定查詢貼圖的座標。

由於 PlaneMesh 的座標範圍為 [-1.0, 1.0]``(寬度為 2.0),而貼圖座標範圍是 ``[0.0, 1.0],因此我們要將座標除以 2.0 再加 0.5 來對應貼圖座標。

texture() 會回傳該位置的 r, g, b, a 四個分量(vec4)。由於雜訊貼圖是灰階的,四個分量都一樣,因此我們可以任選一個分量代表高度。這裡我們會用 r,也就是 x 分量。

備註

xyzw 和 GLSL 裡的 rgba 是一樣的,所以 texture().x 也可以寫成 texture().r。詳情可參考 OpenGL 文件

使用這段程式碼後,你會看到貼圖產生了隨機的小山丘。

../../../_images/noise.webp

目前山丘還太尖銳,我們想讓它更平滑一些。這時可以用 uniform 來調整。你已經用 uniform 傳入了雜訊貼圖,現在就來了解 uniform 的運作方式。

Uniform(統一變數)

Uniform 變數 讓你可以從遊戲傳遞資料進入著色器,這對於控制著色器效果非常實用。Uniform 幾乎可以是著色器內任何資料型別。要宣告 uniform,只需在 Shader 中用 uniform 關鍵字即可。

我們來寫一個可以調整地形高度的 uniform。

uniform float height_scale = 0.5;

Godot 允許你在宣告 uniform 時給初始值;這裡 height_scale 設為 0.5。你也可以在 GDScript 裡透過 set_shader_parameter() 函式於對應材質上設定 uniform,這樣從 GDScript 設定的值就會覆蓋著色器內的初始值。

# called from the MeshInstance3D
mesh.material.set_shader_parameter("height_scale", 0.5)

備註

在基於 Spatial 的節點上更改 uniform 的方式與 CanvasItem 型節點不同。本例是在 PlaneMesh 資源裡設定材質,其他 Mesh 資源你可能要先呼叫 surface_get_material() 取得材質,而在 MeshInstance3D 則用 get_surface_material()material_override

請記得,傳給 set_shader_parameter() 的字串必須與著色器內的 uniform 名稱完全一致。uniform 變數可以在著色器內任何地方使用。這裡我們就用它來設定高度值,不再直接乘 0.5

VERTEX.y += height * height_scale;

現在看起來好多了。

../../../_images/noise-low.webp

透過 uniform,我們甚至可以每個影格都改變數值,讓地形高度產生動畫。結合 Tween,這對動畫特效特別實用。

與光源互動

首先,關閉線框顯示。再次點擊視窗左上角的 透視,選擇 顯示標準。另外,也可以在 3D 場景工具列中關閉預覽太陽光。

../../../_images/normal.webp

你會發現網格顏色變得很平坦。這是因為光照也很平坦,讓我們加盞燈來看看效果!

首先,在場景中新增一個 OmniLight3D,並將它拖到地形上方。

../../../_images/light.webp

你會看到燈光確實影響地形,但看起來很奇怪,因為光線還是照著平面來計算。這是因為光照計算時用的是 Mesh 裡的法線。

法線原本儲存在 Mesh 內,但我們在著色器裡改變了 Mesh 形狀,導致法線已經不正確。要修正這問題,可以在著色器內重算法線,或是使用和雜訊對應的法線貼圖。Godot 都能輕鬆做到這兩種方法。

你可以在頂點函式內手動計算出新的法線,然後設定給 NORMAL。有了 NORMAL,Godot 會自動幫你完成所有複雜的光照計算。這個方法會在教學下一部分詳細介紹,這裡我們先用貼圖的方式獲取法線。

我們這邊會繼續利用 NoiseTexture 來幫我們產生法線。只要再傳入第二個雜訊貼圖即可。

uniform sampler2D normalmap;

將第二個 uniform 紋理設為另一個新的 NoiseTexture2D,內部也指向一個新的 FastNoiseLite ,但這次要勾選 作為法線圖 (As Normal Map)。

../../../_images/normal-set.webp

如果有對應特定頂點的法線,可以直接設定 NORMAL,但若是來自紋理的法線貼圖,請在 fragment() 函式內設定 NORMAL_MAP。如此一來,Godot 會自動處理法線貼圖的包覆。

最後,為確保我們從雜訊貼圖與法線貼圖的同一位置讀取資料,我們會把 VERTEX.xzvertex() 函式傳入 fragment()。這可以用 varying 來做到。

vertex() 之上定義一個名為 tex_positionvarying vec2,在 vertex() 函式內將 VERTEX.xz 賦值給 tex_position

varying vec2 tex_position;

void vertex() {
  tex_position = VERTEX.xz / 2.0 + 0.5;
  float height = texture(noise, tex_position).x;
  VERTEX.y += height * height_scale;
}

現在我們就能在 fragment() 函式中存取 tex_position 了。

void fragment() {
  NORMAL_MAP = texture(normalmap, tex_position).xyz;
}

這樣一來,法線正確,燈光就會根據網格高度動態反應。

../../../_images/normalmap.webp

你可以直接拖曳光源,燈光效果會自動即時更新。

../../../_images/normalmap2.webp

完整程式碼

以下是本教學的完整程式碼。可以看到,因為 Godot 幫你處理了大部分繁瑣細節,程式碼其實很簡潔。

shader_type spatial;

uniform float height_scale = 0.5;
uniform sampler2D noise;
uniform sampler2D normalmap;

varying vec2 tex_position;

void vertex() {
  tex_position = VERTEX.xz / 2.0 + 0.5;
  float height = texture(noise, tex_position).x;
  VERTEX.y += height * height_scale;
}

void fragment() {
  NORMAL_MAP = texture(normalmap, tex_position).xyz;
}

這就是這一部分的全部內容。希望你現在已大致了解 Godot 中頂點著色器的基本用法。在教學的下一部分,我們將編寫與這個頂點函式配合的片段函式,並介紹更進階的技巧,將這片地形變成有動態波浪的海洋。