前言

我們在前面的文章中寫了不少 GLSL 程式碼,大部分都是放在 Shadertoy 上跑的。Shadertoy 很方便,但它有一個限制——互動方式只有滑鼠和鍵盤,而且傳遞自訂參數不太方便。

如果你想要更靈活的互動控制——比如用滑桿調整 fBm 的 octave 數、用色彩選擇器即時改變色盤、或者讓 shader 回應網頁上的各種 UI 元件——那就需要在一個支援 WebGL shader 的框架中工作。

p5.js 是一個非常適合這個目的的框架。它有內建的 shader 支援,語法簡潔,而且可以輕鬆地透過 setUniform() 方法把 JavaScript 端的資料傳遞給 GLSL shader。

今天我們就來建立一個完整的互動式 shader 環境。


p5.js Shader 基礎架構

檔案結構

一個基本的 p5.js shader 專案需要三個檔案:

my-shader/
├── index.html
├── sketch.js        // p5.js 主程式
├── shader.vert      // 頂點 shader
└── shader.frag      // 片段 shader

index.html

<!DOCTYPE html>
<html>
<head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
    <style>
        body { margin: 0; overflow: hidden; background: #000; }
        canvas { display: block; }
    </style>
</head>
<body>
    <script src="sketch.js"></script>
</body>
</html>

shader.vert(頂點 shader)

p5.js 的全螢幕 shader 通常用一個最簡單的頂點 shader:

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; }

shader.frag(片段 shader)

precision mediump float;

varying vec2 vTexCoord; uniform vec2 u_resolution; uniform float u_time; uniform vec2 u_mouse;

void main() { vec2 uv = vTexCoord; uv.y = 1.0 - uv.y; // p5.js 的 y 軸是反的

vec3 col = vec3(uv.x, uv.y, 0.5 + 0.5 * sin(u_time));

gl_FragColor = vec4(col, 1.0); }

sketch.js

let myShader;

function preload() { myShader = loadShader('shader.vert', 'shader.frag'); }

function setup() { createCanvas(windowWidth, windowHeight, WEBGL); noStroke(); }

function draw() { shader(myShader);

// 傳遞 uniform myShader.setUniform('u_resolution', [width, height]); myShader.setUniform('u_time', millis() / 1000.0); myShader.setUniform('u_mouse', [mouseX / width, mouseY / height]);

// 畫一個覆蓋全螢幕的矩形來觸發 fragment shader rect(0, 0, width, height); }

function windowResized() { resizeCanvas(windowWidth, windowHeight); }


setUniform 方法詳解

setUniform() 是 p5.js 中把 JavaScript 值傳給 GLSL 的橋樑。

支援的型別

// float
myShader.setUniform('u_time', 3.14);

// int(GLSL 中要宣告為 int) myShader.setUniform('u_octaves', 6);

// vec2 myShader.setUniform('u_mouse', [0.5, 0.3]);

// vec3 myShader.setUniform('u_color', [1.0, 0.5, 0.2]);

// vec4 myShader.setUniform('u_rect', [0.0, 0.0, 1.0, 1.0]);

// bool(用 0 或 1) myShader.setUniform('u_enabled', true);

// sampler2D(紋理) myShader.setUniform('u_texture', myImage);

對應的 GLSL 宣告

uniform float u_time;
uniform int u_octaves;
uniform vec2 u_mouse;
uniform vec3 u_color;
uniform vec4 u_rect;
uniform bool u_enabled;
uniform sampler2D u_texture;

傳遞滑鼠位置

最基本的互動——讓 shader 回應滑鼠:

sketch.js

function draw() {
    shader(myShader);

// 正規化滑鼠位置到 0-1 let mx = mouseX / width; let my = 1.0 - mouseY / height; // 翻轉 y 軸

myShader.setUniform('u_resolution', [width, height]); myShader.setUniform('u_time', millis() / 1000.0); myShader.setUniform('u_mouse', [mx, my]);

rect(0, 0, width, height); }

shader.frag

precision mediump float;

varying vec2 vTexCoord; uniform vec2 u_resolution; uniform float u_time; uniform vec2 u_mouse;

void main() { vec2 uv = vTexCoord; uv.y = 1.0 - uv.y;

// 用滑鼠位置當作圓心 float d = length(uv - u_mouse) - 0.1; d = abs(d);

// 滑鼠周圍產生波紋 float rings = sin(d 50.0 - u_time 5.0); rings = smoothstep(-0.1, 0.1, rings);

vec3 col = mix(vec3(0.1, 0.1, 0.2), vec3(0.3, 0.7, 1.0), rings);

// 距離衰減 col = exp(-d 3.0);

gl_FragColor = vec4(col, 1.0); }


用 HTML 滑桿控制參數

這是 p5.js shader 互動最實用的模式:

sketch.js

let myShader;
let sliderOctaves, sliderFreq, sliderSpeed;
let colorPicker;

function preload() { myShader = loadShader('shader.vert', 'shader.frag'); }

function setup() { createCanvas(windowWidth, windowHeight, WEBGL); noStroke();

// 建立滑桿 sliderOctaves = createSlider(1, 10, 6, 1); sliderOctaves.position(10, 10); sliderOctaves.style('width', '200px');

sliderFreq = createSlider(0.1, 10.0, 3.0, 0.1); sliderFreq.position(10, 40); sliderFreq.style('width', '200px');

sliderSpeed = createSlider(0.0, 2.0, 0.5, 0.01); sliderSpeed.position(10, 70); sliderSpeed.style('width', '200px');

// 色彩選擇器 colorPicker = createColorPicker('#ff6600'); colorPicker.position(10, 100); }

function draw() { shader(myShader);

// 傳遞基本 uniform myShader.setUniform('u_resolution', [width, height]); myShader.setUniform('u_time', millis() / 1000.0); myShader.setUniform('u_mouse', [mouseX / width, 1.0 - mouseY / height]);

// 傳遞滑桿的值 myShader.setUniform('u_octaves', sliderOctaves.value()); myShader.setUniform('u_frequency', sliderFreq.value()); myShader.setUniform('u_speed', sliderSpeed.value());

// 傳遞顏色 let c = colorPicker.color(); myShader.setUniform('u_accent_color', [ red(c) / 255.0, green(c) / 255.0, blue(c) / 255.0 ]);

rect(0, 0, width, height); }

shader.frag

precision mediump float;

varying vec2 vTexCoord; uniform vec2 u_resolution; uniform float u_time; uniform vec2 u_mouse; uniform int u_octaves; uniform float u_frequency; uniform float u_speed; uniform vec3 u_accent_color;

// --- noise & fbm --- float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }

float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f f (3.0 - 2.0 * f); return mix( mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y ); }

float fbm(vec2 p, int octaves) { float value = 0.0; float amp = 0.5; mat2 rot = mat2(0.8, 0.6, -0.6, 0.8); for (int i = 0; i < 10; i++) { if (i >= octaves) break; value += amp * noise(p); p = rot p 2.0; amp *= 0.5; } return value; }

void main() { vec2 uv = vTexCoord; uv.y = 1.0 - uv.y; uv.x *= u_resolution.x / u_resolution.y;

vec2 p = uv * u_frequency; p += u_time * u_speed;

// 用滑鼠位置影響 domain warping vec2 warp = vec2( fbm(p + u_mouse * 3.0, u_octaves), fbm(p + vec2(5.2, 1.3) + u_mouse * 3.0, u_octaves) );

float f = fbm(p + 3.0 * warp, u_octaves);

// 用 accent color 上色 vec3 col = mix(vec3(0.05), u_accent_color, f); col += u_accent_color 0.3 pow(f, 3.0);

gl_FragColor = vec4(col, 1.0); }


傳遞紋理(Texture)

p5.js 也可以把圖片或影片當作紋理傳給 shader:

圖片紋理

let myShader, img;

function preload() { myShader = loadShader('shader.vert', 'shader.frag'); img = loadImage('photo.jpg'); }

function draw() { shader(myShader); myShader.setUniform('u_texture', img); myShader.setUniform('u_time', millis() / 1000.0); rect(0, 0, width, height); }

uniform sampler2D u_texture;

void main() { vec2 uv = vTexCoord; uv.y = 1.0 - uv.y;

// 讀取紋理並做後處理 vec3 col = texture2D(u_texture, uv).rgb;

// 加上波紋效果 vec2 offset = vec2(sin(uv.y 20.0 + u_time) 0.01, 0.0); col = texture2D(u_texture, uv + offset).rgb;

gl_FragColor = vec4(col, 1.0); }

攝影機作為紋理

let myShader, cam;

function setup() { createCanvas(640, 480, WEBGL); cam = createCapture(VIDEO); cam.size(640, 480); cam.hide(); myShader = loadShader('shader.vert', 'shader.frag'); }

function draw() { shader(myShader); myShader.setUniform('u_texture', cam); myShader.setUniform('u_time', millis() / 1000.0); rect(0, 0, width, height); }

這樣你就可以對即時攝影機畫面做 shader 後處理了。


傳遞陣列和多個互動資料

傳遞多個觸控點

function draw() {
    shader(myShader);

// 傳遞最多 5 個觸控點 let touchData = []; for (let i = 0; i < 5; i++) { if (i < touches.length) { touchData.push(touches[i].x / width); touchData.push(1.0 - touches[i].y / height); } else { touchData.push(-1.0); touchData.push(-1.0); } }

// p5.js 的 setUniform 不直接支援陣列 // 但可以用多個 vec2 uniform for (let i = 0; i < 5; i++) { myShader.setUniform('u_touch' + i, [touchData[i 2], touchData[i 2 + 1]]); }

rect(0, 0, width, height); }

GLSL 端

uniform vec2 u_touch0;
uniform vec2 u_touch1;
uniform vec2 u_touch2;
uniform vec2 u_touch3;
uniform vec2 u_touch4;

float touchInfluence(vec2 uv) { float d = 999.0; if (u_touch0.x >= 0.0) d = min(d, length(uv - u_touch0)); if (u_touch1.x >= 0.0) d = min(d, length(uv - u_touch1)); if (u_touch2.x >= 0.0) d = min(d, length(uv - u_touch2)); if (u_touch3.x >= 0.0) d = min(d, length(uv - u_touch3)); if (u_touch4.x >= 0.0) d = min(d, length(uv - u_touch4)); return d; }


實戰範例:互動式色盤探索器

把前幾篇學的色盤技巧做成互動式的:

sketch.js

let myShader;
let sliderA, sliderB, sliderC, sliderD;

function preload() { myShader = loadShader('shader.vert', 'palette.frag'); }

function setup() { createCanvas(windowWidth, windowHeight, WEBGL); noStroke();

// 四個參數,每個有 RGB 三個滑桿 createP('a:').position(10, 10); sliderA = [ createSlider(0, 100, 50).position(30, 30), createSlider(0, 100, 50).position(30, 50), createSlider(0, 100, 50).position(30, 70) ];

createP('d (phase):').position(10, 90); sliderD = [ createSlider(0, 100, 0).position(30, 110), createSlider(0, 100, 33).position(30, 130), createSlider(0, 100, 67).position(30, 150) ]; }

function draw() { shader(myShader);

let a = sliderA.map(s => s.value() / 100.0); let d = sliderD.map(s => s.value() / 100.0);

myShader.setUniform('u_resolution', [width, height]); myShader.setUniform('u_time', millis() / 1000.0); myShader.setUniform('u_a', a); myShader.setUniform('u_d', d);

rect(0, 0, width, height); }

palette.frag

precision mediump float;

varying vec2 vTexCoord; uniform vec2 u_resolution; uniform float u_time; uniform vec3 u_a; uniform vec3 u_d;

vec3 palette(float t, vec3 a, vec3 d) { vec3 b = vec3(0.5); vec3 c = vec3(1.0); return a + b cos(6.28318 (c * t + d)); }

void main() { vec2 uv = vTexCoord; uv.y = 1.0 - uv.y;

if (uv.y > 0.7) { // 上方 30%:純色帶 vec3 col = palette(uv.x, u_a, u_d); gl_FragColor = vec4(col, 1.0); } else { // 下方 70%:用色盤搭配 SDF 展示 vec2 p = (uv 2.0 - 1.0) vec2(u_resolution.x / u_resolution.y, 1.0); p.y = p.y / 0.7 + 0.3;

float d = length(p); vec3 col = palette(d + u_time * 0.3, u_a, u_d);

float ring = sin(d 20.0 - u_time 3.0); ring = smoothstep(-0.1, 0.1, ring);

col = 0.5 + 0.5 ring;

gl_FragColor = vec4(col, 1.0); } }


常見問題與注意事項

1. y 軸方向

p5.js 的紋理座標 y 軸和 Shadertoy 相反。記得在 shader 中翻轉:

uv.y = 1.0 - uv.y;

2. Precision 宣告

在 WebGL 中,fragment shader 需要指定精度:

precision mediump float; // 在檔案開頭加上

3. GLSL 版本

p5.js 使用的是 WebGL 1.0,對應 GLSL ES 1.00。一些語法差異:

  • 使用 attribute / varying 而非 in / out
  • 使用 texture2D() 而非 texture()
  • 沒有 gl_FragColor 之外的輸出

4. Uniform 未使用會被優化掉

如果你在 GLSL 中宣告了一個 uniform 但沒有使用它,編譯器會把它優化掉。這時候 setUniform() 可能會報錯。確保每個 uniform 都在 shader 中實際使用。

5. 整數 uniform

在 GLSL 中使用 int uniform 時要注意:

uniform int u_octaves;

// 迴圈中使用 for (int i = 0; i < 10; i++) { if (i >= u_octaves) break; // WebGL 1.0 不支援變數作為迴圈上界 // ... }


小結

p5.js + GLSL shader 的組合提供了一個非常友善的互動式 shader 開發環境:

  1. setUniform():把 JavaScript 端的值傳給 GLSL shader
  2. 支援的型別:float、int、vec2/3/4、sampler2D(紋理)
  3. 互動控制:滑桿、色彩選擇器、滑鼠位置、觸控點
  4. 紋理傳遞:圖片、影片、攝影機都可以當作 shader 的輸入
  5. 注意事項:y 軸翻轉、precision 宣告、WebGL 1.0 限制

有了這套工具,你可以把前面學的所有 GLSL 技巧包裝成互動式作品,讓觀眾不只是「看」shader,而是能「玩」shader。這在教學、藝術展覽、甚至是音樂視覺化中都非常有用。

延伸閱讀

下一篇是本系列的最後一篇——我們要來拆解幾個經典的 Shadertoy 作品,看看高手們是怎麼把這些技巧組合在一起的。