到目前為止,我們都是用three.js製作shader。
但其實three.js並不是唯一的選項,我們的選項其實很多。為了幫助大家順利應用shader在three.js以外的地方,我這邊將使用p5.js以及three.js兩個工具操作shader。
透過這個方式,我們可以稍微認識一下其他前端函式庫怎麼操作shader。
本篇內容包含:
先看看成果:
使用Shader製作光暈效果有什麼好處?
Shader也有缺點,例如:
我們快速建置P5.js環境,可以參考官方網站的建置教學
我們建立一個js檔,並用html嵌入。完成之後,將必要的程式碼填入。
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可以實作。
https://codepen.io/umas-sunavan/pen/gOzEXpx?editors=1011
由於每次算圖,都會先透過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>
設定畫面精確度為高
接收P5.js傳入的形狀參數。每個形狀的錨點Vertex Shader都會算一遍,像矩形一共會算四遍。
由於對Shader來說,整個canvas就是-1~1個大小。我們利用Vertex Shader將畫布的座標改為X,Y皆為0~1的座標範圍。
Shader的座標
修改後的Shader做標
將修改後的座標位置傳給gl_Position 。gl_Position 將代表畫面中的錨點應該要在什麼位置。
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單位座標
}
回去看看畫面,一個漂亮的漸層就完成了
如果不想看three.js的過程,可以跳過下一章,直接從大標題「建立具有光暈的粒子-原理」繼續看。
我已經準備好範本,直接從範本開始即可。
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
代表鏡頭在世界座標投影下的四個頂點。我們可以透過四個頂點建立一個平面。
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
}
釐清之後,我們就可以繼續往下研究光暈粒子的原理。
光暈,就是中間白周圍黑,事實上就是一種漸層。
我們只要能實現一個放射狀的漸層,就離光暈不遠了。
假設畫布有800x600的像素大小,左下角為第(1,1)個像素,右上角為第(800,600)個像素。
我們可以拿像素的位置,當做顏色的依據。
對於Fragment Shader來說,每個像素都要有一組rgb顏色,每組顏色都是0~1的亮度(而不是0~255的亮度)。
先讓所有像素座標變成單位座標(0~1座標)。
當像素越接近右上角,距離(0,0)原點越遠,長度越長,顏色越亮;當像素越靠近左下角,距離(0,0)原點近,長度越短,則越沒有顏色。透過這個方式,我們就能創造放射狀漸層的畫布。
只要我把沒有顏色的地方改到中心點,就可以創造出我們要的光暈了
我們從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亮度
現在我們得到了一個放射狀的漸層。看起來像是一個黑色的粒子吧?現在我們需要把黑色粒子移到畫面中心點。
雖說是「移動」黑色粒子的中心點,但概念有點不一樣。實際上fragment shader只能計算每個像素的顏色,沒辦法移動某個東西。
我們看到的黑色中心點,是因為左下角的像素距離原點最短,顏色看齊來最深。我們只要讓中間像素距離原點最短,那最暗的地方就會是中間點。
怎麼實現呢?
float length = length(st-vec2(0.5,0.5));
// 原本位於(0.5,0.5)的像素減掉(0.5,0.5)之後,變成了(0,0)。現在,原點變成了畫布中心。
效果會是這樣:
怎麼讓黑色變成白色,白色變成黑色? 倒數就可以了。
倒數的特性是,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);
一個有光暈的粒子就做出來了。
螢幕上每個像素大小都一樣,為什麼畫面會被拉寬?雖然寬度上被分配到的像素數目比高度還要多,但對於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非常厲害。
#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);
}
以上就是創造光暈粒子的過程,如果有偏誤之處還歡迎多多交流。
喜歡的話幫我拍拍手吧?