iT邦幫忙

2022 iThome 鐵人賽

DAY 25
0

到目前為止,我們都是用three.js製作shader。

但其實three.js並不是唯一的選項,我們的選項其實很多。為了幫助大家順利應用shader在three.js以外的地方,我這邊將使用p5.js以及three.js兩個工具操作shader。

透過這個方式,我們可以稍微認識一下其他前端函式庫怎麼操作shader。

本篇內容包含:

  1. 一、為什麼要用Shader?
  2. 二、快速建置Shader環境—透過P5.js
  3. 三、快速建置Shader環境—透過three.js
  4. 四、建立具有光暈的粒子-原理
  5. 五、建立具有光暈的粒子-實作放射狀漸層
  6. 六、建立具有光暈的粒子-移動中心點
  7. 七、建立具有光暈的粒子-反白整個畫面
  8. 八、更多探索

先看看成果:

https://ithelp.ithome.com.tw/upload/images/20221015/20142505Uj3MmWnJhD.png

一、為甚麼要用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環境—透過P5.js

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

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

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

讓js讀取Shader

let theShader;
 
function setup() {

		// 建立canvas,並指定使用WEBGL
    createCanvas(windowWidth, windowHeight, WEBGL);
 
    // load vert/frag defined below
	  theShader = createShader(vertex, fragment);

		// 螢幕像素固定為1物理像素
    pixelDensity(1)
}
 
 
function draw() {
		// 傳送參數給Shader
    theShader.setUniform("u_resolution", [windowWidth, windowHeight]);

    theShader.setUniform('u_time', frameCount*.01);
		// 每次渲染幀(frame)時套用Shader
    shader(theShader)
		// 利用p5.js的rect()建立一個矩形,這個矩形將被shader拿來處理
    rect(0, 0, windowWidth, windowHeight)
}
 
 
const vertex = document.getElementById('vertexShader').innerHTML
const fragment = document.getElementById('fragmentShader').innerHTML

Codepen

我這邊也準備好了CodePen可以實作。

https://ithelp.ithome.com.tw/upload/images/20221015/20142505PP0ZLqHtZ4.png

https://codepen.io/umas-sunavan/pen/gOzEXpx?editors=1011

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

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

<--- index.html --->
<p id="vertexShader">
	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 是終點,代表每個錨點應該要在哪個位置呈現
		gl_Position = positionVec4;
	}
</p>
  1. 設定畫面精確度為高

  2. 接收P5.js傳入的形狀參數。每個形狀的錨點Vertex Shader都會算一遍,像矩形一共會算四遍。

  3. 由於對Shader來說,整個canvas就是-1~1個大小。我們利用Vertex Shader將畫布的座標改為X,Y皆為0~1的座標範圍。

    https://ithelp.ithome.com.tw/upload/images/20221015/20142505uEHIXFxwJ0.png

    Shader的座標

    https://ithelp.ithome.com.tw/upload/images/20221015/20142505sgXL3ooiMh.png

    修改後的Shader做標

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

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

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

#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. 設定畫面精確度為中
  2. 接收javascript傳過來的參數
  3. 算出每個像素在0~1座標的位置。由於Fragment Shader會計算每一個像素的顏色,所以一個1920*1080大小的螢幕來說,Fragment Shader會跑2073600遍,每一遍都可以得到不同的像素座標位置。
  4. 利用每個像素的位置資訊填入顏色。X座標將成為顏色R的大小,Y座標將成為G的大小。像素越左邊,X越趨近0,像素越上面,Y越趨近1。

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505yM2pVWYExH.png

如果不想看three.js的過程,可以跳過下一章,直接從大標題「建立具有光暈的粒子-原理」繼續看。

三、快速建置Shader環境—透過three.js

我已經準備好範本,直接從範本開始即可。

CodePen

https://ithelp.ithome.com.tw/upload/images/20221015/20142505Kkr5IO6MfC.png

https://codepen.io/umas-sunavan/pen/wvjOPrx?editors=1010

簡介範本程式碼

我節錄addSquare() 函式,可以看到,我建立四個三維向量:lb, lt, rt, rb 分別代表左下角、左上角、右上角、右下角。四個皆透過函式.unproject(camera) 轉成世界座標。這件事情我們在「Day21: three.js 前端3D視覺特效開發實戰—智慧工廠:鏡頭投影、追蹤與飄移特效」也有實作過。

const addSquare = () => {
	const vertex = document.getElementById('vertexShader').innerHTML
	const fragment = document.getElementById('fragmentShader').innerHTML
	// 將螢幕座標的左下角、左上角、右上角、右下角轉成世界座標
	const lb = new THREE.Vector3(-1, -1, 0.8).unproject(camera)
	const lt = new THREE.Vector3(-1, 1, 0.8).unproject(camera)
	const rt = new THREE.Vector3(1, 1, 0.8).unproject(camera)
	const rb = new THREE.Vector3(1, -1, 0.8).unproject(camera)
}

lb, lt, rt, rb代表鏡頭在世界座標投影下的四個頂點。我們可以透過四個頂點建立一個平面。

https://ithelp.ithome.com.tw/upload/images/20221015/20142505sm9mRcEZed.png


const addSquare = () => {
	//	...承接上一段程式碼
	// 在世界座標建立一個平面
	const vertices = new Float32Array([
		// 一個矩形平面至少要有兩個三角面,以下是第一個三角面的三個頂點
		...lb.toArray(),
		...rb.toArray(),
		...rt.toArray(),
		// 以下是第二個三角面的三個頂點
		...rt.toArray(),
		...lt.toArray(),
		...lb.toArray()
	]);
	// 將世界座標組成一個平面
	const geo = new THREE.BufferGeometry()
	// 將三角面組合成形狀
	geo.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
}

製作出形狀之後,我們將螢幕的寬高傳送到shader中。

const addSquare = () => {
	//	...承接上一段程式碼
	const mat = new THREE.ShaderMaterial({
		vertexShader: vertex,
		fragmentShader: fragment,
		uniforms: {
			u_resolution: {
				value: [window.innerHeight,window.innerWidth]
			}
		}
	})
	const mesh = new THREE.Mesh(geo, mat)
	scene.add(mesh)
	return mesh
}

釐清之後,我們就可以繼續往下研究光暈粒子的原理。

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

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

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

1. 原理

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505oD7ag1ksD9.png

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

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

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505Isn1P4nvDw.png

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505WP5uxLCc1c.png

https://ithelp.ithome.com.tw/upload/images/20221015/201425058nSHN4i6SK.png

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

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

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

// p5.js:
myShader.setUniform("u_resolution", [windowWidth, windowHeight]);

// three.js:
new THREE.ShaderMaterial({
	...,
	uniforms: {
		u_resolution: {
			value: [window.innerHeight,window.innerWidth]
		}
	}
})

在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亮度

https://ithelp.ithome.com.tw/upload/images/20221015/20142505Z56jZpIkOg.png

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

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

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

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

怎麼實現呢?

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505cs0goWDpPc.png

效果會是這樣:

https://ithelp.ithome.com.tw/upload/images/20221015/20142505HJtCUaPbgc.png

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

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

倒數的特性是,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);

https://ithelp.ithome.com.tw/upload/images/20221015/20142505p7iTal8LcC.png

一個有光暈的粒子就做出來了。

八、更多探索

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505Hnkrj5haLE.png

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

https://ithelp.ithome.com.tw/upload/images/20221015/20142505cs1069Z2Sk.png

https://ithelp.ithome.com.tw/upload/images/20221015/20142505YzBwfVJi79.png

讓長寬比例保持一致

我的方法是,讓每寬度的比例從「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乘上長寬比,中心點的比例也會變化

https://ithelp.ithome.com.tw/upload/images/20221015/20142505c5yENpFzAm.png

https://ithelp.ithome.com.tw/upload/images/20221015/20142505sspAGT5aHJ.png

短短幾行可以做出那麼好的效果,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);
}

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

喜歡的話幫我拍拍手吧?


上一篇
Day24: WebGL Shader——透過自製環境光實作shader傳值
下一篇
Day26: WebGL Shader—透過Shader製作光暈:速成篇
系列文
30天成為鍵盤麥可貝:前端視覺特效開發實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Mizok
iT邦新手 3 級 ‧ 2022-10-10 23:54:57

給哥一個讚XD!

我要留言

立即登入留言