p5.js 的世界非常有趣,我們還有很多 基礎的語法 能豐富我們的作品,但這就留待讀者們自行探索了。
那接下來我們要講什麼呢?我們要開始提升一個層級,介紹另一個可以和 p5.js 互相結合,卻能讓作品提升更高檔次的語言,叫做 OpenGL Shading Language(簡稱 GLSL)。
怎麼描述 GLSL 和 p5.js 的差別呢?
我們要先從顯卡說起,知道電腦的顯卡嗎?顯卡又稱 GPU,你可以把 GPU 認為是好多好多的小 CPU,每個小 CPU 都只能進行簡單的運算,而這些小 CPU 主要的功能就是計算螢幕上的每一個像素點要呈現什麼樣的顏色。
GLSL 就是可以運行在 GPU 上每個小 CPU 的程式語言!因此 GLSL 顯而易見的優點就是:
比如說看看某些大師的作品:
https://openprocessing.org/sketch/2143598
這個效果根本沒辦法依靠簡單的 p5.js 函數生成出來,就連人腦都很難想像這效果是怎麼計算出來的。
但有優點就有缺點:
GLSL 的學習曲線非常高,除了需要著色器相關的基本知識,還有更好的程式能力,畢竟要想像出怎麼控制每個像素點的顏色來呈現自己想要的效果。
因為 GLSL 是直接運作在每個 pixel 的計算上,因此每個 pixel 彼此之間是獨立的,完全不知道彼此狀態,所以有些效果注定很難用 GLSL 實現。
GLSL 非常難 debug,很多時候程式寫錯了,他就只是不顯示畫面,根本不會給你吐出什麼有用的 error。
光看文字還是霧煞煞,到底寫程式控制每個像素的計算是什麼意思,p5.js 不也是這樣的原理嗎?比如說:
function setup() {
createCanvas(600, 600);
background(200);
circle(300, 300, 25);
}
你看!我也是叫每個像素呈現出一個圓給我看。
但如果想用 GLSL 來呈現類似的效果,程式需要這樣寫:
#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float u_diameter;
uniform float u_edge_width;
out vec4 FragColor;
#define PI 3.14159265358979323846
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float radius = u_diameter / 2.0 / u_resolution.x;
float edge = u_edge_width / u_resolution.x;
vec2 circleCenter = vec2(0.5, 0.5);
float dist = distance(uv, circleCenter);
if (dist < radius - edge) {
FragColor = vec4(1.0, 1.0, 1.0, 1.0); // White
} else if (dist < radius) {
FragColor = vec4(0.0, 0.0, 0.0, 1.0); // Black
} else {
FragColor = vec4(200.0 / 255.0, 200.0 / 255.0, 200.0 / 255.0, 1.0); // Gray
}
}
竟然變得如此複雜,而且這個檔案只是專案中其中一個程式碼(這只是片段著色器的程式碼),他必須搭配其他的程式碼才能正確的呈現效果(還需搭配頂點著色器和 p5.js 的串接程式),怎麼會這樣呢?
首先我們來研究一下這段 GLSL 程式碼到底描述了什麼,我先用註解說明了整個程式是如何運作的:
#version 300 es
// 我們可以把這整個檔案當成一個 glsl_circle 函數的內容
precision highp float;
// 下面這三個是 glsl_circle 函數的參數
// u_resolution 和 glsl_circle 函數的功能本身並不相關,先不說明
// u_diameter 就是直徑長度
// u_edge_width 就是 glsl_circle 函數的圓邊框寬度,可以認定為 p5.js 的 strokeWeight
uniform vec2 u_resolution;
uniform float u_diameter;
uniform float u_edge_width;
// FragColor 為片段著色器的輸出,用來決定目標像素最終的顏色
out vec4 FragColor;
#define PI 3.14159265358979323846
void main() {
// glsl_circle 函數還有一個參數是目標像素點的位置座標 gl_FragCoord.xy
// 原本畫布的大小為 600x600,但從 glsl 通常會將畫布大小視為 1x1
// 所以每個傳入的參數都要用 u_resolution 調整比例
vec2 uv = gl_FragCoord.xy / u_resolution;
float radius = u_diameter / 2.0 / u_resolution.x;
float edge = u_edge_width / u_resolution.x;
// 因為畫布大小為 1x1,所以 (0.5, 0.5) 為畫布中心點
vec2 circleCenter = vec2(0.5, 0.5);
// 計算目標像素點與中心點距離
float dist = distance(uv, circleCenter);
// 根據目標像素點與中心點距離大小,填上不同顏色
// gl_FragColor 代表最後目標像素點的顏色
if (dist < radius - edge) {
FragColor = vec4(1.0, 1.0, 1.0, 1.0); // White
} else if (dist < radius) {
FragColor = vec4(0.0, 0.0, 0.0, 1.0); // Black
} else {
FragColor = vec4(200.0 / 255.0, 200.0 / 255.0, 200.0 / 255.0, 1.0); // Gray
}
}
我們可以將這個 GLSL 程式視為一個 glsl_circle
函數,且其傳入參數有三個:
u_diameter
: 代表 circle 的直徑u_edge_width
: 代表 circle 邊線寬度gl_FragCoord.xy
: 代表目標像素點的位置特別的是,這個 glsl_circle
函數執行的次數,竟然是 600x600 次,也就是跟畫布上的像素點個數是一樣的,相對於原本的 p5.js 程式,整個 setup
函數只會執行一次。
雖然 glsl_circle 在每一幀的畫布上執行了非常多次,但因為是在 gpu 上面運行,上面有相當多的核心數(數千到數萬個),所以是用平行的方式瞬間完成這些數量的工作。
因為對於每一點像素的計算,都需要呼叫 glsl_circle
,所以 glsl_circle
必須傳入目標像素點的位置,glsl_circle
才知道現在是在計算哪一個像素點。
可以看出 glsl 是以像素層面進行計算,但原本的 p5.js 卻能以整體的視角來進行繪製。
以目前這個功能 繪製直徑 25,圓心位於畫布中心點的圓圈
來說:
繪製直徑 25,圓心位於畫布中心點的圓圈
這個結果之下,會是什麼樣的顏色,計算方法為,計算該點和中心點的距離,並以此距離來決定顏色。circle(300, 300, 25);
來達成目的,完全不需要計算點與點之間距離。舉另一個例子,如果要完成另一個功能 在畫布中心寫上: p5.js 好棒棒
:
text("p5.js 好棒棒", 300, 300);
就可完成任務p5.js 好棒棒
的筆跡上實在是太困難了,畫圓還可以用圓心的距離來判斷,但畫出字型完全沒有一個簡單的數學法則來達成這件事。我們再回到速度的議題,為什麼說 glsl 在繪製複雜動畫的運行速度,會比 p5.js 來的快許多呢?有沒有什麼例子來佐證這件事,也就是說某些效果在 p5.js 中會相當卡頓,但在 glsl 會正常運行呢?
有的,如果涉及到圖案模糊化的效果,那在 p5.js 上面實現可能會有一些效能的瓶頸,以下舉一個粒子系統的動畫效果作為範例,模擬 200 個粒子在畫布中自由移動,並且每個粒子都經過模糊化的處理。
let particles = [];
function setup() {
createCanvas(600, 600);
noStroke();
for (let i = 0; i < 200; i++) {
particles.push(new Particle(random(width), random(height)));
}
}
function draw() {
background(0);
for (let p of particles) {
p.update();
p.display();
}
filter(BLUR, 3);
}
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.vel = p5.Vector.random2D();
this.size = random(10, 20);
}
update() {
this.pos.add(this.vel);
this.edges();
}
display() {
fill(255, 150);
ellipse(this.pos.x, this.pos.y, this.size);
}
edges() {
if (this.pos.x < 0 || this.pos.x > width) this.vel.x *= -1;
if (this.pos.y < 0 || this.pos.y > height) this.vel.y *= -1;
}
}
以下是動畫連結:
https://openprocessing.org/sketch/2333795
let blurShader;
let particles = [];
function preload() {
blurShader = loadShader('shader.vert', 'shader.frag');
}
function setup() {
pixelDensity(1);
createCanvas(600, 600, WEBGL);
noStroke();
for (let i = 0; i < 200; i++) {
particles.push(new Particle(random(width), random(height)));
}
}
function draw() {
shader(blurShader);
blurShader.setUniform('u_resolution', [width, height]);
for (let p of particles) {
p.update();
}
let positions = [];
for (let p of particles) {
positions.push(p.pos.x, p.pos.y);
}
blurShader.setUniform('positions', positions);
blurShader.setUniform('particleSize', 15.0);
blurShader.setUniform('numParticles', particles.length);
rect(0, 0, width, height);
}
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.vel = p5.Vector.random2D();
}
update() {
this.pos.add(this.vel);
this.edges();
}
edges() {
if (this.pos.x < 0 || this.pos.x > width) this.vel.x *= -1;
if (this.pos.y < 0 || this.pos.y > height) this.vel.y *= -1;
}
}
#version 300 es
precision highp float;
in vec3 aPosition;
void main() {
vec4 positionVec4 = vec4(aPosition, 1.0);
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
gl_Position = positionVec4;
}
#version 300 es
precision highp float;
uniform vec2 u_resolution;
uniform float particleSize;
uniform vec2 positions[200];
const int numParticles = 200;
out vec4 FragColor;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float blur = 0.0;
for (int i = 0; i < numParticles; i++) {
vec2 pos = positions[i] / u_resolution.xy;
float dist = distance(uv, pos);
float s = particleSize / u_resolution.x;
blur += exp(-3.0 * dist * dist / (s * s));
}
blur = clamp(blur, 0.0, 1.0);
FragColor = vec4(vec3(blur) * 0.75, 1.0);
}
以下是動畫連結:
https://openprocessing.org/sketch/2333805
之所以不用 gif 動圖呈現動畫效果,而是附上連結,就是為了讓讀者可以直接感受這兩種寫法在自己的電腦上跑起來效果如何,假設很不幸的,你的電腦 硬體非常好,可能這兩個程式跑起來都很順,那也可以用手機打開這兩個連結,相信可以看出兩者的運行速度有相當大的差別。
今天稍微展示了 p5.js 和 glsl 兩者寫法的差別與優缺點比較,明天會繼續講解 glsl 的程式結構和細節原理。