透過WebGL製作光暈特效

蘇桓晨
10 min readJun 9, 2021

--

本篇解釋如何透過P5.js的函式調用Shader,藉此讓WebGL透過GPU所執行的Shader運行一個漸層。

可以前往https://openprocessing.org/sketch/1215268查看原始碼。

由於這些帶著光暈的光點,主要是透過Fragment shader的編碼呈現。前置的部份我就快速帶過:

前置

首先我們在Javascript使用P5.js的套件來控制Shader。在P5,我們需要定義draw()以及setup()。而未了建置shader,額外定義preload()

    function preload() {
}

function setup() {
}

function draw() {
}

接著,為了建立shader以及canvas,我們新增兩行程式碼

    function preload() {
theShader = new p5.Shader(this._renderer, vert, frag)
// 參數後兩個為我們在下方自定義的變數
// 而這變數定義了我們的fragment與vertex shader
}
function setup() {
createCanvas(800, 800 , WEBGL);
}
function draw() {
}

在draw(),我們傳入了幾個變數到shader,並且執行shader

function draw() {
theShader.setUniform("r", [width, height]);
// 第一行傳入canvas畫面寬高
shader(theShader);
// 透過p5寫好函式執行shader
rect(0, 0, width, height);
}

我們前面在preload()帶入了兩個參數,一個是vert,一個是frag,這兩個我們夠過變數來引入。雖然形式是字串,但它將作為GLSL去操作我們的shader。

首先看到vert。身為vertex shader裡面的vert,我們將vertext的位置範圍從-1~1改成0~1之間。這些都還是前置,有興趣可以閱讀https://webglfundamentals.org/webgl/lessons/webgl-how-it-works.html

const vert = `
attribute vec3 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;

void main() {
vTexCoord = aTexCoord;
vec4 positionVec4 = vec4(aPosition, 1.0);
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
gl_Position = positionVec4;
}
`

透過Fragment shader實現光暈

這是frag,代表fragment shader。在這裡我們計算很多東西,以下逐一介紹,整段是這樣:

const frag = `
precision mediump float;
varying vec2 vTexCoord;
void main(void){
vec3 destColor = vec3(0.0);
for(float i=0.0; i < 10.0; i++) {
for(float k=0.0; k < 10.0; k++) {
vec2 q = -vTexCoord + vec2(i,k)*0.25;
destColor.rgb += 0.003 / length(q);
}
}
gl_FragColor = vec4(destColor,1.0);
}
`

首先,我們看到屬性。我們定義畫面精製度為mediump,與一個varying叫做vTexCoord。它是P5提供給shader時自動產生的verying。每一個frame的每一個像像素,它都不同,每次運行時都會代表每個像素0~1之間的位置。

precision mediump float;
varying vec2 vTexCoord;

接著,我們在main定義一個皆為0的錨點,它的任務是將計算過的數值變成顏色,最後指定給gl_FragColor(最後呈現是在每個像素的顏色值)

void main(void){
vec3 destColor = vec3(0.0);
}

我們建立雙層迴圈,如此能讓我們畫出2D的光點

for(float i=0.0; i < 10.0; i++) {
for(float k=0.0; k < 10.0; k++) {
// some code
}
}

在迴圈裡面,建立一個q,等等回來介紹

vec2 q = -vTexCoord + vec2(i,k)*0.25;

不斷在迴圈裡讓destColor加上q長度相關的算式

destColor.rgb += 0.003 / length(q);

最後讓呈現每個像素顏色的gl_FragColor指定我們的destColor

gl_FragColor = vec4(destColor,1.0);

照程式碼直接解釋很方便但我們還是看不出其精髓。

為什麼每個像素經過兩層迴圈就可以跑出很多光點?每個destColor都會跑兩層次數一樣都是i,k的迴圈,為什麼能讓有些像素很亮,有些像素很暗?

解釋光點

我們先從簡單的開始--如果我們沒有迴圈,要怎麼製造出亮點呢?

1. 單一個光點怎麼產生的?

我們先讓程式簡單點,把迴圈內程式碼搬出來,並把複雜的 +vec2(i,k)*0.25拿掉

void main(void){
vec2 q = -vTexCoord;
destColor.rgb += length(q);
gl_FragColor = vec4(destColor,1.0);
}

先看結尾的gl_FragColor。它做為程式碼的終點,代表每個像素在每個frame的顏色值,分別代表R,G,B,A,且以0~1作為範圍。0最暗是黑色,1最亮是白色。

最後一行我們看到,R,G,B的位置由destColor帶入,A的位置設為1,不透明。所以每個像素的顏色是由destColor決定。而倒數第二行,destColor是由length(q)決定。倒數第三行可看到,q在宣告時為vec2,其代表它有兩個值,也就是-vTexCoord。

也就代表說,一個像素的xy位置,依據畢氏定理,能由length()算出長度,而這個長度也代表像素的顏色。

舉例來說,畫面最左下角的像素,座標趨近0,0,它的長度會是趨近0,而他的亮度也會趨近0;畫面正中央的像素,座標為0.5,0.5,長度會是0.7左右(依據畢氏定理而知),那RGB的亮度也會是0.7,也就是灰色。

從畫面上看會是這樣:

從上面被簡化的程式碼開始,我們替length(q)加上了倒數

void main(void){
vec2 q = -vTexCoord;
destColor.rgb += 1.0/length(q);
gl_FragColor = vec4(destColor,1.0);
}

現在畫面左下角的像素座標趨近0,0,長度趨近0,倒數之後,趨近無限。畫面右上角的像素座標為1,1。長度趨近1.414,倒數之後為0.7左右,也就是灰色。

接著,我們讓亮度小一點

void main(void){
vec2 q = -vTexCoord;
destColor.rgb += 0.3 / length(q);
gl_FragColor = vec4(destColor,1.0);
}

我們就會發現,第一個光點出現了

2. 多個光點是怎麼呈現在畫面上的?

「八成是透過迴圈」,這很直覺,但很難推導。

我們先回到原本呈現多個點的程式碼

for(float i=0.0; i < 10.0; i++) {
for(float k=0.0; k < 10.0; k++) {
vec2 q = -vTexCoord + vec2(i,k)*0.25;
destColor.rgb += 0.3 / length(q);
}
}

雙層的迴圈,外層跑十遍內層跑十遍,所以裡面的程式碼會被執行100遍。

destColor.rgb一直在迴圈中被加上q的倒數值一百次。前面我們知道,當q越靠近0,0,像素越亮。倒數之後,vTexCoord為0時最亮。

但現在在迴圈裡面,vTexCoord為0不一定最亮了。只有-vTexCoord + vec2(i,k)0.25為0時像素最亮,也就是說,-vTexCoord 跟vec2(i,k)*0.25要互補,length(q)才會最大,像素才會最亮。

不要忘記q是座標,包含x,y,所以仔細來說,-vTexCoord.x跟vec2(i,k).x0.25要趨近0; -vTexCoord.y跟vec2(i,k).y0.25要趨近0

簡化就可以得出,如果像素要亮:

  1. -vTexCoord.x跟i*0.25要趨近0
  2. -vTexCoord.y跟k*0.25要趨近0

我們可以將程式碼改清楚一點:

for(float i=0.0; i < 10.0; i++) {
for(float k=0.0; k < 10.0; k++) {
float a = -vTexCoord.x + i*0.25;
float b = -vTexCoord.y + k*0.25;
destColor.xyz += 0.003 / sqrt(a*a+b*b);
}
}

我把length(q)改成sqrt(aa+bb),意思完全一樣,都必須趨近0才能最亮。

也就是說,當vTexCoord為0.1,0.1,那i要為0.4且k要為0.4才會最亮,當vTexCoord為0.25,0,那i要為1且k要為0才會最亮。

每個像素都會跑100個迴圈,destColor會被加上數值100遍,如果那100遍裡面加只要q有一個是趨近0,那倒數之後會是最大值,destColor.rgb才會最亮。其他99遍迴圈得出來離0很遠也沒關係。

對於迴圈來說,迴圈丟出了100機會讓像素成為最亮,只要i,k算進去能讓q趨近0都可以。

所以在畫面上我們看到有100個亮點,因為有100個像素的位置與i0.25,k0.25互補。最後,我們才有這些光點。

超好玩的對吧?

--

--