利用Shader創造基礎的Canvas光暈效果

蘇桓晨
18 min readOct 24, 2020

--

本篇同時也寫在ithome30天鐵人賽其中一篇「Day: 25 使用Shader創造漸層」裡面,如果有興趣可以從那邊看到更多應用介紹。鐵人賽的作者也是我本人喔!

本篇藉助p5.js的力量將粒子資訊傳入至WebGL的Shader工具,真正實現光暈效果程式碼在fragment shader裡面。

本篇內容包含:

  1. 粗淺介紹使用Shader的方法
  2. 粗淺介紹GLSL語言變的數種類以及型別,以利後續程式碼的閱讀
  3. 快速建置Shader。提及每個步驟的任務,並透過註解解釋運作過程
  4. 建立具有光暈的粒子-原理
  5. 建立具有光暈的粒子-實作放射狀漸層
  6. 建立具有光暈的粒子-移動中心點
  7. 建立具有光暈的粒子-反白整個畫面
  8. 更多探索

先看看成果:

為甚麼要用Shader?

使用Shader製作光暈效果有什麼好處?

  1. 要用CSS製作也不是不可以,但要寫的CSS可不少。
  2. 流暢,由於Shader是透過顯示卡GPU去算圖,比起CPU來說,圖像處理能力非常好
  3. 能做的特效非常多變,每種特效都是一個獨特的萬花筒

Shader也有缺點,例如:

  1. 邏輯思維跟Javascript差很多,跟CSS差更多,需要花時間認識shader的運作模式
  2. 探索一個特效可能要來回調校參數很多次,你可能會放入很多Magic Number,這並不容易讓其他人閱讀
  3. 通常會需要別的API來幫忙存取Shader,例如在本篇使用P5.js,P5.js是很好入門的程式視覺創作的資源庫,你也可以用其他工具,例如WebGL API或是Three.js

一、粗淺介紹使用Shader的方法

Shader是什麼?Shader並不是一種javascript的工具,而是一個要經過compile才能匯入至javascript的語言。我們會用兩種Shader--Vertex Shader(儲存點的位置)、Fragment Shader(計算各個點並將顏色呈現在螢幕上)。

如何使用Shader?Shader是應用在WebGL的渲染方式,WebGL又只存在<Canvas/>標籤。如果我們要使用shader,就必須要javascript定義好如何使用shader。我們需要有方法匯入Shader到Javascript,並在每次渲染幀(frame)時都能不斷透過Shader更新Canvas畫面。

由於建置shader的開發環境比較繁瑣,我使用p5.js的函式幫我匯入shader就簡單多了。

透過P5.js提供的三個函式,我們能做以下事情:

  1. 透過preload()去從資料夾路徑讀取shader並compile
  2. 透過setup()建立一個Canvas,並讓Canvas使用WebGL算圖
  3. 在draw(),也就是每次渲染幀(frame)時,套用Shader算出來的畫面,並傳送參數給Shader

而每一幀Shader都會做以下事情:

  1. Vertex Shader看你在P5.js畫了什麼形狀,把那個形狀定義出來,每個形狀的頂點都跑一遍。Vertex Shader顧名思義就是處理錨點的Shader。
  2. Fragment Shader看你填了什麼顏色,就把顏色畫出來,而且每個像素都跑一遍。Fragment Shader顧名思義就是處理Fragment的Shader,Fragment指的是每個像素。

二、粗淺介紹GLSL語言變的數種類以及型別

本篇簡單帶過創造光暈粒子所需要知道的變數種類以及型別。

變數種類

1. Const

跟JS的const一樣,不會被重新指定的變數

2. #define

很像代數的功能,在compile的時候把變數名稱改成變數數值,如此一來就不需要幫define配置一個記憶體空間,加強效能。

3. Attributes

可以從Javascript傳入。在這個案例中Vertex Shader的aPosition即是透過p5.js自動傳入。每一幀裡面,一個webGL有幾個形狀共幾個頂點(vertex),aPosition就會變化幾遍。

4. Uniforms

由於每一個frame數值都能保持不變,可以透過javascript傳入。

5. Varings

一個webGL有幾個像素,Uniforms就會變化幾遍。

Vectors型別

1. Vec2, Vec3, Vec4

vec2能儲存兩個值,Vec3能儲存三個值, vec4則能儲存四個值。

這個值可以是座標值也可以是顏色值,可以透過vecN.xy取得前兩個值、vecN.z取得第三個值,或是透過vecN.r取得前一個值、vecN.gba取得最後三個值

2. float

就是浮點數,宣告時不能沒有小數點。可以用1.簡寫1.0,或是.3簡寫0.3。

2. int

整數,宣告時不能有小數點,沒辦法與float一起計算。

三、快速建置Shader

我們快速建置P5.js環境,可以參考官方網站的建置教學

https://p5js.org/get-started/

我們建立一個js檔,並用html嵌入。完成之後,將必要的程式碼填入。

讓js讀取Shader

var myShader 
// preload
function preload() {
// 利用p5.js的函式"loadShader()"來從資料夾位置取得兩個檔案,並且compile
myShader = loadShader('vertexShader.vert', 'fragmentShader.frag');
}
function setup() {
// 建立canvas,並指定使用WEBGL
createCanvas(windowWidth, windowHeight, WEBGL);
}
function draw() {
// 每次渲染幀(frame)時套用Shader
shader(myShader)
// 利用p5.js的rect()建立一個矩形,這個矩形將被shader拿來處理
rect(0, 0, windowWidth, windowHeight)
// 傳送參數給Shader
myShader.setUniform("u_resolution", [windowWidth, windowHeight]);
}

建立一個Vertex Shader,稱之為’vertexShader.vert’讓Javascript讀取

由於每次算圖,都會先透過Vertex Shader定義形狀,再透過Fragment Shader定義每個像素的顏色。我們先看看Vertex Shader做的事情:

  1. 設定畫面精確度為高
  2. 我們在JS檔的Draw()加入了rect(),是P5.js提供繪製矩形的工具。透過rect(),Vertex Shader接收P5.js傳入的形狀參數。每個形狀的錨點Vertex Shader都會算一遍。在本案例中,矩形一共四個錨點,所以本案例每一幀跑四次Vertex Shader。
  3. 由於對Shader來說,整個canvas就是-1~1個大小。我們利用Vertex Shader將畫布的座標改為X,Y皆為0~1的座標範圍。
Shader的座標
修改後的Shader做標

4. 將修改後的座標位置傳給gl_Position 。gl_Position 將代表畫面中的錨點應該要在什麼位置。

precision highp float;
//設定畫面精確度為高
attribute vec3 aPosition;
// 接收P5.js傳入的形狀參數。attribute是一種變數。每一幀有多少個錨點Vertex Shader就要跑多少次,而每一次attribute變數的數值分別代表每個錨點的位置。
//
// vec3 是一種型別,能儲存三個float的值,其他像是vec2能儲存兩個值, vex4則能儲存四個值。
void main() {
// 程式進入點
vec4 positionVec4 = vec4(aPosition, 1.0);
// 建立一個能儲存四個值的錨點,將aPosition的三個值當做positionVec4的前三個值,第四個值為1.0
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
gl_Position = positionVec4;
// gl_Position 是終點,代表每個錨點應該要在哪個位置呈現
}
Vertex Shader的工作流程

建立一個Fragment Shader,稱之為’fragmentShader.frag’讓Javascript讀取

Vertex Shader定義完形狀之後,由Fragment Shader來填色,我們看看Fragment Shader做的事情:

  1. 設定畫面精確度為中
  2. 接收javascript傳過來的參數
  3. 算出每個像素在0~1座標的位置。由於Fragment Shader會計算每一個像素的顏色,所以一個1920*1080大小的螢幕來說,Fragment Shader會跑2073600遍,每一遍都可以得到不同的像素座標位置。
  4. 利用每個像素的位置資訊填入顏色。X座標將成為顏色R的大小,Y座標將成為G的大小。像素越左邊,X越趨近0,像素越上面,Y越趨近1。
#ifdef GL_ES
precision mediump float;
//設定畫面精確度為中
#endif
uniform vec2 u_resolution;
// 接收javascript傳過來的參數,在這邊是canvas的長寬。要注意這是vec2,所以一次能儲存兩個點
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
// gl_FragCoord是Fragment Shader內建的每個像素座標資料。每個像素的長寬除上canvas長寬,可以得到每個像素的單位座標(0~1座標)
gl_FragColor = vec4(st.xy, 0.0 , 1.0);
// gl_FragColor 為程式的終點。我們設定紅色為每個像素的x單位座標,綠色為每個像素的y單位座標
}

回去看看畫面,一個漂亮的漸層就完成了

四、建立具有光暈的粒子-原理

光暈,就是中間白周圍黑,事實上就是一種漸層。

我們只要能實現一個放射狀的漸層,就離光暈不遠了。

1. 原理

假設畫布有800x600的像素大小,左下角為第(1,1)個像素,右上角為第(800,600)個像素。

我們可以拿像素的位置,當做顏色的依據。

對於Fragment Shader來說,每個像素都要有一組rgb顏色,每組顏色都是0~1的亮度(而不是0~255的亮度)。

先讓所有像素座標變成單位座標(0~1座標)。

當像素越接近右上角,距離(0,0)原點越遠,長度越長,顏色越亮;當像素越靠近左下角,距離(0,0)原點近,長度越短,則越沒有顏色。透過這個方式,我們就能創造放射狀漸層的畫布。

只要我把沒有顏色的地方改到中心點,就可以創造出我們要的光暈了

五、建立具有光暈的粒子-實作放射狀漸層

我們從javascript開始,透過p5.js提供的setUniform()函式,我們可以在每次渲染幀(frame)時帶入整個畫布的長寬大小,先稱它為u_resolution。

function draw() {
// javascript每次渲染幀(frame)時傳送參數給Shader
myShader.setUniform("u_resolution", [windowWidth,windowHeight]);
}

在Fragment Shader裡面,為了接收js用setUniform傳過來的數值,需要宣告一個變數叫u_resolution。

uniform vec2 u_resolution;
// 接收javascript傳過來的參數,在這邊是canvas的長寬。要注意這是vec2,所以一次能儲存兩個點

透過u_resolution,能知道整個畫布的長寬有多大,也能求得每個像素的單位座標

vec2 st = gl_FragCoord.xy/u_resolution;
// gl_FragCoord是Fragment Shader自己提供的變數,代表每個像素的像素位置。
// 畫布大小除上像素位置,就是每個像素的單位座標(0.0~1.0)

取得每個像素距離左下角原點(0,0)有多遠

float length = length(st);
// 每個像素距離(0,0)多遠呢?使用length就可以得到距離(0,0)的長度
// 越靠近(0,0)的值越小,越靠近(1,1)的值越大

現在每個像素的length 都不一樣。由於每個像素都會跑一遍,現在左下角的像素算出來length 比較小,右上角的像素length 比較大。依照不同的距離數值當做顏色數值。

gl_FragColor = vec4(length,length,length , 1.0);
// 每個像素離原點的距離,就是RGB亮度

目前的Fragment Shader長這樣:

#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
float length = length(st);
gl_FragColor = vec4(length,length,length , 1.0);
}

現在我們得到了一個放射狀的漸層。看起來像是一個黑色的粒子吧?現在我們需要把黑色粒子移到畫面中心點。

六、建立具有光暈的粒子-移動中心點

雖說是「移動」黑色粒子的中心點,但概念有點不一樣。實際上fragment shader只能計算每個像素的顏色,沒辦法移動某個東西。

我們看到的黑色中心點,是因為左下角的像素距離原點最短,顏色看齊來最深。我們只要讓中間像素距離原點最短,那最暗的地方就會是中間點。

怎麼實現呢?

float length = length(st-vec2(0.5,0.5));
// 原本位於(0.5,0.5)的像素減掉(0.5,0.5)之後,變成了(0,0)。現在,原點變成了畫布中心。

效果會是這樣:

看起來很像meduim還在讀取圖片,但其實這是真實圖片

七、建立具有光暈的粒子-反白整個畫面

怎麼讓黑色變成白色,白色變成黑色? 倒數就可以了。

導數的特性是,100的倒數為0.01,10的倒數為0.1,越大的數值,倒數之後越小。

但目前的畫畫布最小值是0,最大值是0.7,倒數之後一定會超過1,因此,我們需要額外乘上一個小數來縮小我們的數值。

gl_FragColor = vec4(1.0/length*0.1,1.0/length*0.1,1.0/length*0.1 , 1.0);

一個有光暈的粒子就做出來了。目前的Fragment Shader長這樣:

#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
float length = length(st-vec2(0.5,0.5));
gl_FragColor = vec4(1.0/length*0.1,1.0/length*0.1,1.0/length*0.1 , 1.0);
}

八、更多探索

頁面越寬,粒子變形越嚴重?

螢幕上每個像素大小都一樣,為什麼畫面會被拉寬?雖然寬度上被分配到的像素數目比高度還要多,但對於shader來說,畫面不過是長0~1、寬0~1的矩型罷了。顏色的亮度取決於每個像素離中心的距離,而不是像素的長寬。

讓長寬比例保持一致

我的方法是,讓每寬度的比例從「0~1」拉寬成「0長寬比~1長寬比」。

vec2 screenRatio = vec2(u_resolution.x/u_resolution.y,1.0);
// 長寬比。假設螢幕是800x600,長寬比就是1.33,screenRation就是(1.33,1.0)
st*=screenRatio;
// 讓st乘上成寬比,(0.25,0.5)的像素原本是距離中心點0.55,現在變成(0.60)
float length = length(st-vec2(0.5,0.5)*screenRatio);
// 讓st乘上成寬比,中心點的比例也會變化

短短幾行可以做出那麼好的效果,Shader非常厲害。目前的Fragment Shader長這樣:

#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
vec2 screenRatio = vec2(u_resolution.x/u_resolution.y,1.0);
st*=screenRatio;
float length = length(st-vec2(0.5,0.5)*screenRatio);
gl_FragColor = vec4(1.0/length*0.1,1.0/length*0.1,1.0/length*0.1 , 1.0);
}

最佳化效能

由於Shader讓我們直接控制GPU。fragment shader幫每個像素跑一遍,一個台13吋的macbook pro有2560*1600像素,也就是四百萬個像素要跑四百萬遍,每秒60個frame,一秒就要跑2億4千萬遍。

一秒兩億多遍,一旦GLSL沒有寫好,就會讓GPU非常非常辛苦,我們需要好好檢視我們的程式碼。

#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
float screenRatio = u_resolution.x/u_resolution.y;
// 將screenRatio改成一個浮點數,如此一來節省要儲存的數值
st.x*=screenRatio;
// 只讓x乘上長寬比
float brightness = 0.1/length(st-vec2(0.5*screenRatio,0.5));
// 除了讓中心點成上長寬比以外,也先讓length成為倒數,乘上數值縮小亮度,改名成brightness
gl_FragColor = vec4(brightness,brightness,brightness, 1.0);
}

除此之外,我們還能讓宣告的變數名稱更精簡

#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
void main() {
vec2 st = gl_FragCoord.xy/u_resolution.xy;
float sr = u_resolution.x/u_resolution.y;st.x*=sr;
float b = 0.1/length(st-vec2(0.5*sr,0.5));
gl_FragColor = vec4(b,b,b, 1.0);
}

以上就是創造光暈粒子的過程,如果有偏誤之處還歡迎多多交流。

喜歡的話幫我拍拍手吧🙈

--

--