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.
Checking the stable version of the documentation...
你的第一個 3D 著色器
你決定開始編寫自己的自訂 Spatial 著色器。或許你在網路上看到了很酷的著色器特效,又或是發現 StandardMaterial3D 無法完全滿足你的需求。無論原因為何,現在你決定自己動手寫一個,只是不知道該從哪裡開始。
本教學將說明如何編寫 Spatial 著色器,並且涵蓋比 CanvasItem 教學更多的主題。
Spatial 著色器比 CanvasItem 著色器擁有更多內建功能。對 Spatial 著色器來說,Godot 已為常見用例提供了豐富功能,使用者只需要於著色器內設定適當參數即可。這一點在 PBR(基於物理的算繪)工作流程中特別明顯。
這是一個分為兩部分的教學。在第一部分中,我們將透過頂點函式中的高度圖來進行頂點位移,製作簡單地形。在 第二部分 中,我們將延伸本教學的概念,在片段著色器中設定自訂材質,並編寫海洋水面著色器。
備註
本教學假設你已具備一些著色器的基礎知識,例如型別(vec2、float、sampler2D)以及函式。如果你對這些概念還不熟悉,建議在完成本教學前,先參考 《著色器之書》 入門。
在哪裡設定材質
在 3D 中,物件是透過 Mesh 來繪製的。Mesh 是一種資源,會以「表面(surface)」的單位儲存幾何資訊(物件的形狀)與材質(物件的顏色與受光方式)。一個 Mesh 可以有多個表面,也可以只有一個。一般來說,你會從其他軟體(如 Blender)匯入 Mesh,但 Godot 也有一些 PrimitiveMesh 可讓你直接在場景中新增基本幾何體而不需匯入。
你可用多種節點型別來繪製 Mesh,主要是 MeshInstance3D,但你也可以使用 GPUParticles3D、MultiMesh)等其他節點。
通常情況下,材質會與 Mesh 的特定表面綁定,但有些節點(如 MeshInstance3D)允許你覆寫單一表面或所有表面的材質。
如果你在表面或 Mesh 本身設定材質,所有共用該 Mesh 的 MeshInstance3D 都會共用同一材質。但若你想讓多個 Mesh 實例共用同一 Mesh,且每個實例有不同材質,就應該直接在 MeshInstance3D 上設定材質。
本教學將直接把材質設定在 Mesh 本身,不使用 MeshInstance3D 的材質覆寫功能。
設定
在場景中新增一個 MeshInstance3D 節點。
於屬性面板中,點擊 MeshInstance3D 的 Mesh 屬性右側的 <空> ,選擇新建 PlaneMesh,然後點擊出現的平面圖示以展開該資源。
這會在場景中新增一個平面。
接著,在視窗左上角點擊 透視 按鈕,在跳出的選單中選擇 顯示線框。
這樣你就能看到構成平面的三角形了。
現在將 PlaneMesh 的 Subdivide Width 和 Subdivide Depth 都設為 32。
你會發現 MeshInstance3D 中出現了更多三角形。這樣我們就有更多頂點可以操作,能增加更多細節。
像 PlaneMesh 這類 PrimitiveMesh 只會有一個表面,因此材質也只有一個,不是陣列。將 Material 設為新的 ShaderMaterial,然後點擊出現的球體圖示展開材質。
備註
所有繼承自 Material 的材質資源(例如 StandardMaterial3D、ParticleProcessMaterial),都可以轉換為 ShaderMaterial,現有的屬性會轉為對應的文字著色器。要進行轉換,只需在檔案系統面板中右鍵點選該材質並選擇 轉換成 ShaderMaterial。你也可以在屬性檢視器中,對任何指向材質的屬性右鍵進行同樣操作。
接下來把材質的 Shader 設定為新的 Shader,點擊 <空> 並選擇 新建 Shader...。保留預設設定,輸入著色器名稱後按 建立。
在屬性面板點擊該 Shader,著色器編輯器就會彈出。現在你就可以開始編寫你的第一個 Spatial 著色器了!
著色器魔法
新建立的著色器已經自動產生了 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);
}
加上這一行後,你應該會看到如下圖所示的效果。
來解釋一下這行程式碼。這裡我們增加了 VERTEX 的 y 值,並把 VERTEX 的 x 和 z 組件分別作為 cos() 和 sin() 的參數,這會讓平面在 x 與 z 軸上呈現波浪狀外觀。
我們的目的是讓表面看起來像小山丘;而 cos() 和 sin() 的曲線本來就很像丘陵。我們只需縮放傳入 cos() 與 sin() 的參數即可調整山丘的形狀。
void vertex() {
VERTEX.y += cos(VERTEX.x * 4.0) * sin(VERTEX.z * 4.0);
}
這樣確實好看多了,但還是太尖銳且重複,讓我們讓它變得更有趣一些。
雜訊高度圖
雜訊是模擬地形外觀時很常用的工具。可以想像它像餘弦函式那樣產生一連串小山,但雜訊可以讓每座山的高度都不同。
Godot 提供 NoiseTexture2D 資源,可用來產生雜訊貼圖並供著色器存取。
若要在著色器中存取貼圖,請在著色器檔案頂端、vertex() 函式外面加入以下程式碼。
uniform sampler2D noise;
這樣就能傳送雜訊貼圖給著色器了。現在回到屬性面板,在材質底下你會看到 著色器參數 區塊,裡面會有一個名為「Noise」的參數。
將這個 Noise 參數設為新的 NoiseTexture2D,然後在 NoiseTexture2D 裡,將 Noise 屬性設為新的 FastNoiseLite。FastNoiseLite 會用來產生高度圖。
設定好後,應該會像這樣。
接下來透過 texture() 函式來取用雜訊貼圖:
void vertex() {
float height = texture(noise, VERTEX.xz / 2.0 + 0.5).x;
VERTEX.y += height;
}
texture() 的第一個參數是貼圖,第二個參數是 vec2 位置。我們用 VERTEX 的 x 與 z 分量來決定查詢貼圖的座標。
由於 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 文件。
使用這段程式碼後,你會看到貼圖產生了隨機的小山丘。
目前山丘還太尖銳,我們想讓它更平滑一些。這時可以用 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;
現在看起來好多了。
透過 uniform,我們甚至可以每個影格都改變數值,讓地形高度產生動畫。結合 Tween,這對動畫特效特別實用。
與光源互動
首先,關閉線框顯示。再次點擊視窗左上角的 透視,選擇 顯示標準。另外,也可以在 3D 場景工具列中關閉預覽太陽光。
你會發現網格顏色變得很平坦。這是因為光照也很平坦,讓我們加盞燈來看看效果!
首先,在場景中新增一個 OmniLight3D,並將它拖到地形上方。
你會看到燈光確實影響地形,但看起來很奇怪,因為光線還是照著平面來計算。這是因為光照計算時用的是 Mesh 裡的法線。
法線原本儲存在 Mesh 內,但我們在著色器裡改變了 Mesh 形狀,導致法線已經不正確。要修正這問題,可以在著色器內重算法線,或是使用和雜訊對應的法線貼圖。Godot 都能輕鬆做到這兩種方法。
你可以在頂點函式內手動計算出新的法線,然後設定給 NORMAL。有了 NORMAL,Godot 會自動幫你完成所有複雜的光照計算。這個方法會在教學下一部分詳細介紹,這裡我們先用貼圖的方式獲取法線。
我們這邊會繼續利用 NoiseTexture 來幫我們產生法線。只要再傳入第二個雜訊貼圖即可。
uniform sampler2D normalmap;
將第二個 uniform 紋理設為另一個新的 NoiseTexture2D,內部也指向一個新的 FastNoiseLite ,但這次要勾選 作為法線圖 (As Normal Map)。
如果有對應特定頂點的法線,可以直接設定 NORMAL,但若是來自紋理的法線貼圖,請在 fragment() 函式內設定 NORMAL_MAP。如此一來,Godot 會自動處理法線貼圖的包覆。
最後,為確保我們從雜訊貼圖與法線貼圖的同一位置讀取資料,我們會把 VERTEX.xz 從 vertex() 函式傳入 fragment()。這可以用 varying 來做到。
在 vertex() 之上定義一個名為 tex_position 的 varying 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;
}
這樣一來,法線正確,燈光就會根據網格高度動態反應。
你可以直接拖曳光源,燈光效果會自動即時更新。
完整程式碼
以下是本教學的完整程式碼。可以看到,因為 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 中頂點著色器的基本用法。在教學的下一部分,我們將編寫與這個頂點函式配合的片段函式,並介紹更進階的技巧,將這片地形變成有動態波浪的海洋。