前言
我們在前面的文章中寫了不少 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 開發環境:
- setUniform():把 JavaScript 端的值傳給 GLSL shader
- 支援的型別:float、int、vec2/3/4、sampler2D(紋理)
- 互動控制:滑桿、色彩選擇器、滑鼠位置、觸控點
- 紋理傳遞:圖片、影片、攝影機都可以當作 shader 的輸入
- 注意事項:y 軸翻轉、precision 宣告、WebGL 1.0 限制
有了這套工具,你可以把前面學的所有 GLSL 技巧包裝成互動式作品,讓觀眾不只是「看」shader,而是能「玩」shader。這在教學、藝術展覽、甚至是音樂視覺化中都非常有用。
延伸閱讀
- p5.js Shader Reference:官方文檔
- p5.js Shader Examples:官方範例
- The Coding Train — Shader Programming:Daniel Shiffman 的 p5.js shader 教學影片
下一篇是本系列的最後一篇——我們要來拆解幾個經典的 Shadertoy 作品,看看高手們是怎麼把這些技巧組合在一起的。