使用 SubViewport 作為紋理

前言

本教學將介紹如何使用 SubViewport 作為可應用於 3D 物件的紋理。為此,我們將帶你一步步製作下方這種程式化行星:

../../_images/planet_example.png

備註

本教學不會介紹如何編寫如圖中星球那樣的動態大氣。

本教學假設你已熟悉如何建立基本場景,包括:一個 Camera3D 、一個 光源、一個帶有 Primitive MeshMeshInstance3D ,並將 StandardMaterial3D 應用於該網格。本篇重點在於如何使用 SubViewport 動態產生可套用於網格的紋理。

在本教學中,我們將介紹以下主題:

  • 如何使用 SubViewport 作為算繪紋理

  • 使用等距矩形投影(equirectangular mapping)將紋理貼到球體

  • 程式化行星的片段著色器技術

  • Viewport 紋理 設定粗糙度貼圖

設定場景

建立新場景並依下方範例新增以下節點。

../../_images/viewport_texture_node_tree.webp

進入 MeshInstance3D,將其網格設為 SphereMesh

設定 SubViewport

點選 SubViewport 節點,並將其尺寸設為 (1024, 512)SubViewport 其實可以是任何尺寸,只要寬度為高度的兩倍即可。寬度需要是高度的兩倍,這樣圖像才能正確對應到球體上,因為我們將使用等距矩形投影(equirectangular projection)。後續會進一步說明。

接著關閉 3D 功能。我們將使用 ColorRect 來繪製表面,因此不需要啟用 3D。

../../_images/planet_new_viewport.webp

選取 ColorRect,於屬性面板(Inspector)將 Anchors 預設值設為 Full Rect,這樣可以確保 ColorRect 會填滿整個 SubViewport

../../_images/planet_new_colorrect.webp

接著,為 ColorRect 新增 Shader Material <class_ShaderMaterial>`(ColorRect > CanvasItem > Material > Material > ``New ShaderMaterial`)。

備註

建議對著色器有基本認識再學習本教學。不過即使你是新手,所有程式碼都會提供,跟著操作應該沒有問題。

點擊 ShaderMaterial 的下拉選單按鈕並選擇 Edit。然後前往 Shader > New Shader,命名後點選「Create」。在屬性面板中點擊該著色器以開啟著色器編輯器,刪除預設程式碼並輸入下方內容:

shader_type canvas_item;

void fragment() {
    COLOR = vec4(UV.x, UV.y, 0.5, 1.0);
}

儲存著色器程式碼後,你會在屬性面板看到上述程式碼呈現出如下圖的漸層。

../../_images/planet_gradient.png

現在,我們已經完成一個可算繪內容的 SubViewport,並且擁有可以套用到球體的專屬圖像。

套用紋理

現在進入 MeshInstance3D ,並新增一個 StandardMaterial3D 給它。不需要特別的 Shader Material (不過若要實作更進階效果,如前述範例的大氣,則可以考慮)。

MeshInstance3D > GeometryInstance > Geometry > Material Override > New StandardMaterial3D

接著點擊 StandardMaterial3D 的下拉選單並選擇「Edit」

到「Resource」區塊勾選 Local to scene ,然後到「Albedo」區塊,點選「Texture」屬性旁的按鈕以新增 Albedo 紋理。這裡請選擇「New ViewportTexture」,將我們剛做的紋理套用上去

../../_images/planet_new_viewport_texture.webp

點選剛建立的 ViewportTexture,然後點擊「Assign」。在跳出的選單中選擇我們前面算繪內容的 SubViewport。

../../_images/planet_pick_viewport_texture.webp

現在,你的球體應該已經套用上我們算繪進 Viewport 的顏色。

../../_images/planet_seam.webp

你有注意到在紋理環繞時產生的醜陋縫隙嗎?這是因為我們根據 UV 座標取色,但 UV 座標本身不會環繞紋理,這是 2D 地圖投影時的經典問題。遊戲開發時經常需要將 2D 紋理投射到球體,而這種投影方式會產生明顯的接縫。這個問題其實有個優雅的解法,我們會在下一節介紹。

製作行星紋理

現在,當我們將內容算繪到 SubViewport 時,它就會神奇地顯示在球體上。不過目前因為紋理座標的關係產生了醜陋的接縫。那要怎麼讓座標在球體上自然環繞呢?一種作法是使用會重複的數學函式,例如 sincos。我們來將這些函式套用到紋理上,看看會發生什麼事。請將著色器中的原有色彩程式碼換成下方內容:

COLOR.xyz = vec3(sin(UV.x * 3.14159 * 4.0) * cos(UV.y * 3.14159 * 4.0) * 0.5 + 0.5);
../../_images/planet_sincos.webp

還不錯。你會發現接縫已經消失,但兩極出現了夾點。這是因為 Godot 的 StandardMaterial3D 將紋理貼到球體時採用了一種稱為等距矩形投影(equirectangular projection)的技術,它會將球面座標轉換成 2D 平面,因此極點會被擠壓。

備註

如果你想了解更多技術細節,我們其實會將球面座標轉換為直角座標。球面座標描述的是球體的經度與緯度,而直角座標則是從球心指向該點的向量。

對於每個像素,我們會計算其在球體上的 3D 位置,然後用 3D 噪聲來決定顏色。透過在 3D 空間計算噪聲,就能解決極點夾點的問題。原因在於:如果噪聲是在球面上計算,你永遠不會撞到邊界,因此不會產生接縫或極點夾點。以下程式碼會將 UV 轉換成直角座標。

float theta = UV.y * 3.14159;
float phi = UV.x * 3.14159 * 2.0;
vec3 unit = vec3(0.0, 0.0, 0.0);

unit.x = sin(phi) * sin(theta);
unit.y = cos(theta) * -1.0;
unit.z = cos(phi) * sin(theta);
unit = normalize(unit);

如果我們將 unit 作為輸出的 COLOR 值,會得到:

../../_images/planet_normals.webp

既然我們能計算球體表面的 3D 位置,就能用 3D 雜訊來製作星球表面。這裡直接套用來自 Shadertoy 的 noise 函式:

vec3 hash(vec3 p) {
    p = vec3(dot(p, vec3(127.1, 311.7, 74.7)),
             dot(p, vec3(269.5, 183.3, 246.1)),
             dot(p, vec3(113.5, 271.9, 124.6)));

    return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
}

float noise(vec3 p) {
  vec3 i = floor(p);
  vec3 f = fract(p);
  vec3 u = f * f * (3.0 - 2.0 * f);

  return mix(mix(mix(dot(hash(i + vec3(0.0, 0.0, 0.0)), f - vec3(0.0, 0.0, 0.0)),
                     dot(hash(i + vec3(1.0, 0.0, 0.0)), f - vec3(1.0, 0.0, 0.0)), u.x),
                 mix(dot(hash(i + vec3(0.0, 1.0, 0.0)), f - vec3(0.0, 1.0, 0.0)),
                     dot(hash(i + vec3(1.0, 1.0, 0.0)), f - vec3(1.0, 1.0, 0.0)), u.x), u.y),
             mix(mix(dot(hash(i + vec3(0.0, 0.0, 1.0)), f - vec3(0.0, 0.0, 1.0)),
                     dot(hash(i + vec3(1.0, 0.0, 1.0)), f - vec3(1.0, 0.0, 1.0)), u.x),
                 mix(dot(hash(i + vec3(0.0, 1.0, 1.0)), f - vec3(0.0, 1.0, 1.0)),
                     dot(hash(i + vec3(1.0, 1.0, 1.0)), f - vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z );
}

備註

所有功勞歸於作者 Inigo Quilez。這段程式碼以 MIT 授權釋出。

現在要使用 noise,請在 fragment 函式中加入以下內容:

float n = noise(unit * 5.0);
COLOR.xyz = vec3(n * 0.5 + 0.5);
../../_images/planet_noise.webp

備註

為了凸顯紋理效果,我們將材質設為無陰影(unshaded)。

你可以看到噪聲現在已經無縫環繞整個球體,雖然目前看起來還不像行星,所以我們接下來要讓它更有色彩。

星球著色

接下來要為行星上色。雖然有很多種方式,這裡我們先做一個水域與陸地的顏色漸層。

要在 GLSL 裡做漸層,我們使用 mix 函式。mix 需要兩個數值進行插值,第三個參數決定插值比例;本質上,就是 混合 這兩個值。在其他 API 裡這個功能通常稱作 lerp。但 lerp 多用於混合兩個浮點數,而 mix 可以混合浮點數或向量類型。

COLOR.xyz = mix(vec3(0.05, 0.3, 0.5), vec3(0.9, 0.4, 0.1), n * 0.5 + 0.5);

第一個顏色是海洋的藍色,第二個顏色是偏紅的顏色(畢竟外星行星都要紅色地表)。最後我們用 n * 0.5 + 0.5 混合它們。n-11 之間平滑變化,因此我們將它轉換到 0-1 的範圍給 mix 使用。你會發現顏色會在藍色與紅色之間變化。

../../_images/planet_noise_color.webp

這樣的效果比我們想像中還模糊。行星通常陸地與海洋的分界清楚。要達到這樣的效果,我們將最後的參數換成 smoothstep(-0.1, 0.0, n)。這樣整行程式碼就會變成:

COLOR.xyz = mix(vec3(0.05, 0.3, 0.5), vec3(0.9, 0.4, 0.1), smoothstep(-0.1, 0.0, n));

smoothstep 的作用是:當第三個參數比第一個還小時回傳 0,比第二個還大時回傳 1,如果第三個值介於前兩者之間,則在 01 間平滑混合。所以在這一行,當 n 小於 -0.1 時回傳 0,大於 0 時回傳 1

../../_images/planet_noise_smooth.webp

再來讓行星看起來更像行星一點。陸地不應該是圓滑的塊狀,我們要讓邊緣更粗糙。著色器中常用的技巧是用不同頻率的雜訊疊加,一層決定大陸的主體形狀,再用額外幾層破壞邊緣,如此類推。我們會用四行程式碼來計算 n,而不是只有一行,如下所示:

float n = noise(unit * 5.0) * 0.5;
n += noise(unit * 10.0) * 0.25;
n += noise(unit * 20.0) * 0.125;
n += noise(unit * 40.0) * 0.0625;

現在,行星看起來像這樣:

../../_images/planet_noise_fbm.webp

製作海洋

最後一點,讓它更像一顆行星。海洋與陸地反光程度不同,我們希望海洋比陸地更閃亮。可以把第四個值傳給輸出的 COLORalpha 通道,並將它作為粗糙度(Roughness)貼圖使用。

COLOR.a = 0.3 + 0.7 * smoothstep(-0.1, 0.0, n);

這一行對水域回傳 0.3,對陸地回傳 1.0。這代表陸地會非常粗糙,水域會很光滑。

然後在材質的「Metallic」區塊,將 Metallic 設為 0Specular 設為 1。這是因為水域會大量反射光線但不是金屬。這些數值雖不完全符合物理現實,但對這個展示來說已經足夠。

接著,在「Roughness」區塊,將粗糙度貼圖(Roughness Texture)設為指向我們行星紋理的 Viewport Texture,也就是指到我們的 SubViewport。最後將 Texture Channel 設為 Alpha,這樣算繪器就會使用我們輸出 COLORalpha 通道作為 Roughness 的值。

../../_images/planet_ocean.webp

你會發現除了星球不再反射天空外,幾乎沒有其他變化。這是因為預設情況下,具有 alpha 值的物件會以透明方式繪製在背景上,而 SubViewport 的預設背景是不透明的,導致 Viewport Texturealpha 通道都是 1,所以行星紋理顏色會較淡,且 Roughness 值到處都是 1。為了解決這個問題,請切換到 SubViewport,啟用「Transparent Bg」屬性。由於現在是在另一個透明物件上繪製,我們還要啟用 blend_premul_alpha

render_mode blend_premul_alpha;

這會將顏色預先與 alpha 相乘,再正確混合。通常,當你在另一個透明顏色上疊加時,即使背景的 alpha0``(就像這裡),也可能出現奇怪的顏色滲出現象。設定 ``blend_premul_alpha 就能解決這個問題。

現在星球應該只會在海洋區域反射光線,陸地則不會。你可以移動場景中的 OmniLight3D,觀察海洋反射效果。

../../_images/planet_ocean_reflect.webp

就是這樣!你現在已經用 SubViewport 產生了程式化行星。