著色器簡介

Paul Lewis

簡介

我先前曾介紹 Three.js。如果您尚未閱讀,建議您先行查看,因為我會在本文中以此為基礎進行說明。

我想討論的是著色器。WebGL 非常出色,正如我先前所述,Three.js (和其他程式庫) 可為您抽象化難題,不過,有時您可能會想要達到特定效果,或是想進一步瞭解這些精彩內容如何顯示在螢幕上,而著色器幾乎肯定是其中的關鍵因素。此外,如果您和我一樣,可能會想從上一個教學課程中的基本內容,轉而學習更複雜的內容。我會以您使用 Three.js 為前提,因為在啟動著色器方面,Three.js 會為我們執行許多繁重的工作。我會在前面說明著色器的背景,並在本教學課程的後半部介紹較進階的內容。這是因為著色器乍看之下很不尋常,需要稍微解釋。

1. 我們的兩個著色器

WebGL 不提供固定管道的使用方式,也就是說,它不會提供任何方式讓您直接算繪內容。不過,提供可程式化管道,雖然功能更強大,但也更難理解和使用。簡而言之,可程式設計管道意指程式設計師負責取得頂點等項目,並將其算繪至螢幕。著色器是這個管道的一環,分為兩種類型:

  1. 頂點著色器
  2. 片段著色器

我相信你也會同意,這兩個字本身並沒有任何意義。您應該知道的是,這兩種模式都完全在顯示卡的 GPU 上執行。也就是說,我們希望將所有可卸載的工作交給它們,讓 CPU 執行其他工作。新一代 GPU 經過大量最佳化處理,可支援著色器所需的函式,因此非常適合使用。

2. 頂點著色器

請使用標準原始形狀,例如球體。它是由頂點組成,對吧?頂點著色器會依序接收這些頂點,並可對其進行處理。頂點著色器會決定如何處理每個頂點,但它有一個責任:必須在某個時間點設定 gl_Position,這是 4D 浮點向量,也是頂點在螢幕上的最終位置。這本身就是一個相當有趣的程序,因為我們實際上是在討論將 3D 位置 (具有 x、y、z 的頂點) 投射到 2D 螢幕上。幸運的是,如果我們使用 Three.js 之類的工具,就能以簡寫方式設定 gl_Position,而不會造成過度負擔。

3. 片段著色器

我們有物件和其頂點,並將它們投射到 2D 畫面,但我們使用的顏色呢?紋理和燈光呢?這正是片段著色器的用途。與頂點著色器非常相似,片段著色器也只有一項必做的工作:必須設定或捨棄 gl_FragColor 變數,這是另一個 4D 浮點向量,也是片段的最終顏色。不過,什麼是片段?請想想三個頂點如何組成三角形。需要繪製三角形內的每個像素。片段是三個頂點提供的資料,用於繪製三角形中的每個像素。因此,片段會從其構成的頂點接收內插值。如果一個頂點是紅色,而相鄰的頂點是藍色,我們會看到從紅色、紫色到藍色的顏色值內插。

4. 著色器變數

談到變數,您可以宣告三種變數:統一變數屬性變數變數變數。當我第一次聽到這三個詞時,我感到非常困惑,因為它們與我曾經使用過的任何東西都不相符。但以下是您可以如何思考這些概念:

  1. Uniforms 會傳送至頂點著色器和片段著色器,並包含在整個轉譯影格中保持不變的值。燈具位置就是一個很好的例子。

  2. 屬性是套用至個別頂點的值。屬性「僅」適用於頂點著色器。例如,每個頂點都有不同的顏色。屬性與頂點之間是一對一關係。

  3. Varying 是指在頂點著色器中宣告的變數,我們希望這些變數能與片段著色器共用。為此,我們會確保在頂點著色器和片段著色器中宣告相同類型和名稱的變數。這項功能的經典用途是頂點的方向,因為這項功能可用於光照計算。

稍後我們會使用這三種類型,讓您瞭解如何實際套用這些類型。

我們已經討論了頂點著色器和片段著色器,以及它們處理的變數類型,現在來看看我們可以建立最簡單的著色器。

5. Bonjourno World

以下是頂點著色器的 Hello World:

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

以下是片段著色器的相同內容:

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

不過這並不複雜,對吧?

在頂點著色器中,我們會透過 Three.js 傳送幾個統一變數。這兩個統一條件是 4D 矩陣,稱為模型-檢視矩陣和投影矩陣。您不一定要瞭解這些運作方式的確切運作方式,但如果可以,最好還是瞭解這些運作方式。簡單來說,這些值是頂點的 3D 位置如何實際投射到螢幕上的最終 2D 位置。

我實際上已將這些屬性從上述程式碼片段中移除,因為 Three.js 會將這些屬性新增至著色器程式碼本身的頂端,因此您不必擔心要執行這項操作。事實上,它還會加入更多內容,例如光線資料、頂點顏色和頂點法線。如果您在沒有 Three.js 的情況下執行此操作,就必須自行建立並設定所有這些統一變數和屬性。真人真事。

6. 使用 MeshShaderMaterial

好,我們已設定著色器,但如何在 Three.js 中使用它?結果發現這項操作非常簡單。這類似於:

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

從這裡開始,Three.js 會編譯並執行附加至您提供該材質的網格著色器。這麼說吧,這麼簡單的操作方式,可能會,但我們討論的是瀏覽器中的 3D 運作,因此我認為您預期會有一定程度的複雜度。

我們其實可以再為 MeshShaderMaterial 新增兩個屬性:制服和屬性。兩者都能接受向量、整數或浮點值,但如同先前所述,整個影格 (即所有頂點) 的制服皆相同,因此通常會是單一值。不過,屬性是每個頂點的變數,因此應為陣列。屬性陣列中的值數量與網格中的頂點數量應為一對一關係。

7. 後續步驟

接下來,我們將花點時間新增動畫迴圈、頂點屬性和均勻變數。我們也會新增變化變數,讓頂點著色器可將部分資料傳送至片段著色器。最終結果是,原本粉紅色的球體會從上方和側面發光,並且會閃爍。這有點奇怪,但希望能讓您充分瞭解這三種變數類型,以及它們彼此之間和基礎幾何圖形的關係。

8. 假燈

讓我們更新顏色,讓它不是平面彩色物件。我們可以看看 Three.js 如何處理照明,但我相信您會發現,這比我們目前需要的更複雜,因此我們會假裝處理。您應該徹底查看 Three.js 的優異著色器,以及 Chris Milk 和 Google 近期推出的 Rome這些驚人的 WebGL 專案。回到著色器。我們會更新 Vertex Shader,為每個頂點提供 Fragment Shader 的切線。我們會透過以下方式進行:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

在片段著色器中,我們會設定相同的變數名稱,然後使用頂點法線與代表從球體上方和右側照射的光線的向量進行內積。這項操作的最終結果會產生類似 3D 套件中方向性光源的效果。

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

因此,內積運算法之所以有效,是因為它會根據兩個向量產生一個數字,告訴您兩個向量的相似程度。如果是經過標準化的向量,如果向量指向完全相同的方向,則會取得 1 的值。如果指向相反方向,則會獲得 -1 分。我們會將該數字套用至照明設備。因此,右上方的頂點值會接近或等於 1,也就是完全亮起,而側邊的頂點值會接近 0,而背面的頂點值會是 -1。我們會將任何負值限制為 0,但當您插入數字時,最終會得到我們看到的基本照明。

接下來,或許可以嘗試修改一些頂點位置。

9. 屬性

我現在想請我們透過屬性,為每個頂點附加隨機數字。我們會使用這個數字,沿著頂點的法向量將頂點推出。最終結果會是某種奇怪的尖刺球,每次重新整理網頁時都會改變。系統不會立即顯示動畫 (會在下一個步驟顯示),但幾次重新整理網頁後,您就會看到隨機顯示的圖片。

首先,我們會在頂點著色器中新增屬性:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

外觀如何?

其實沒有太大差異!這是因為屬性並未在 MeshShaderMaterial 中設定,因此著色器會改用零值。目前這項功能有點像是預留位置。我們會在下一秒將屬性新增至 JavaScript 中的 MeshShaderMaterial,而 Three.js 會自動將這兩者綁在一起。

另外值得注意的是,我必須將更新後的位置指派給 vec3 變數,因為原始屬性和所有屬性一樣,都是唯讀。

10. 更新 MeshShaderMaterial

讓我們直接開始更新 MeshShaderMaterial,並加入必要的屬性來支援位移。提醒您:屬性是每個頂點的值,因此球體中每個頂點都需要一個值。如下所示:

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

我們現在看到的是扭曲的球體,但有趣的是,所有位移都是在 GPU 上發生。

11. 為吸盤製作動畫

我們應該完全讓這項功能具備動畫效果。我們要如何做到?我們需要完成兩件事:

  1. 用於設定動畫在每個影格中應套用的位移量。我們可以使用正弦或餘弦,因為它們會從 -1 到 1 執行
  2. JS 中的動畫循環

我們將在 MeshShaderMaterial 和 Vertex Shader 中加入均勻變數。首先是 Vertex Shader:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

接下來,我們會更新 MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

我們的著色器目前已完成。但目前看來,我們似乎倒退了一步。這主要是因為振幅值為 0,且我們將其乘上位移值後,並未發現任何變化。我們也未設定動畫迴圈,因此不會看到 0 變更為其他值。

在 JavaScript 中,我們現在需要將轉譯呼叫包裝到函式中,然後使用 requestAnimationFrame 呼叫該函式。在該處,我們也需要更新均勻值。

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. 結論

大功告成!您現在可以看到動畫以奇怪 (且略顯詭異) 的脈動方式播放。

我們可以討論許多著色器主題,但希望這篇文章的介紹對您有所幫助。您現在應該可以瞭解著色器,並有信心自行創作精彩的著色器!