前言
長頸鹿的斑紋、乾裂的泥土、蜻蜓的翅膀、肥皂泡沫的接縫——這些看似毫不相關的自然現象,背後都藏著同一個數學結構:Voronoi Diagram(沃羅諾伊圖)。
第一次在 shader 裡跑出 Voronoi 的時候,我盯著螢幕看了好久。那種像生物細胞一樣的網格,明明只是「每個像素找最近的種子點」這麼簡單的規則,卻產生出如此有機、如此像活的東西的圖案。這就是數學與自然的共鳴。
這篇文章會從 Voronoi Diagram 的基本原理講起,用 p5.js 做一個直觀的實作,然後進入 GLSL shader 的高效版本,最後探索它在自然紋理模擬中的應用。
Voronoi Diagram 是什麼?
給定一組「種子點」(seed points),Voronoi Diagram 把平面分割成若干區域:每個區域包含所有「離該種子點最近」的點。
用白話說:想像你在一片空地上灑了 20 顆種子,然後下雨了。每滴雨水會流向離它最近的那顆種子。雨水的分界線——就是 Voronoi 的邊。
數學定義
對於種子點集合 P = {p₁, p₂, …, pₙ},點 pᵢ 的 Voronoi 區域是:
V(pᵢ) = { x | d(x, pᵢ) ≤ d(x, pⱼ) 對所有 j ≠ i }
其中 d 是距離函數。最常用的是歐幾里得距離,但換成曼哈頓距離或切比雪夫距離也可以,會產生不同風格的圖案。
p5.js 暴力法實作
最直覺的做法是:對每個像素,計算它到所有種子點的距離,找出最近的那個,然後根據最近的種子點來著色。
let seeds = [];
let numSeeds = 20;
let colors = [];
function setup() {
createCanvas(600, 600);
for (let i = 0; i < numSeeds; i++) {
seeds.push(createVector(random(width), random(height)));
colors.push(color(random(60, 200), random(60, 200), random(60, 200)));
}
// 暴力法:逐像素計算
loadPixels();
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let minDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < numSeeds; i++) {
let d = dist(x, y, seeds[i].x, seeds[i].y);
if (d < minDist) {
minDist = d;
closestIdx = i;
}
}
let idx = (x + y width) 4;
let c = colors[closestIdx];
pixels[idx] = red(c);
pixels[idx + 1] = green(c);
pixels[idx + 2] = blue(c);
pixels[idx + 3] = 255;
}
}
updatePixels();
// 畫種子點
fill(255);
noStroke();
for (let s of seeds) {
ellipse(s.x, s.y, 6, 6);
}
}
這段程式碼很好理解,但效能不佳——600×600 的畫布有 36 萬個像素,每個要檢查 20 個種子點。不過對於靜態圖來說勉強可以接受。
加上邊緣檢測
Voronoi 的邊界線在藝術上很重要。我們可以透過比較相鄰像素的最近種子點來檢測邊緣:
function drawVoronoiEdges() {
loadPixels();
// 先建一張 "最近種子索引" 的表
let closestMap = [];
for (let x = 0; x < width; x++) {
closestMap[x] = [];
for (let y = 0; y < height; y++) {
let minDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < numSeeds; i++) {
let d = dist(x, y, seeds[i].x, seeds[i].y);
if (d < minDist) {
minDist = d;
closestIdx = i;
}
}
closestMap[x][y] = closestIdx;
}
}
// 檢測邊緣
for (let x = 1; x < width - 1; x++) {
for (let y = 1; y < height - 1; y++) {
let c = closestMap[x][y];
// 如果鄰居的最近種子不同,就是邊緣
if (c !== closestMap[x+1][y] ||
c !== closestMap[x][y+1] ||
c !== closestMap[x-1][y] ||
c !== closestMap[x][y-1]) {
let idx = (x + y width) 4;
pixels[idx] = 255;
pixels[idx + 1] = 255;
pixels[idx + 2] = 255;
pixels[idx + 3] = 255;
}
}
}
updatePixels();
}
互動式 Voronoi
讓種子點可以移動,Voronoi 就會即時變化。為了效能,我們改用「跳過像素」的方式或降低解析度:
let seeds = [];
let numSeeds = 15;
let seedColors = [];
function setup() {
createCanvas(600, 600);
for (let i = 0; i < numSeeds; i++) {
seeds.push(createVector(random(width), random(height)));
seedColors.push([random(80, 220), random(80, 220), random(80, 220)]);
}
}
function draw() {
// 其中一個種子跟著滑鼠
seeds[0].x = mouseX;
seeds[0].y = mouseY;
// 其他種子緩慢隨機運動
for (let i = 1; i < numSeeds; i++) {
seeds[i].x += sin(frameCount 0.01 + i) 0.5;
seeds[i].y += cos(frameCount 0.013 + i 1.3) * 0.5;
seeds[i].x = constrain(seeds[i].x, 0, width);
seeds[i].y = constrain(seeds[i].y, 0, height);
}
// 低解析度渲染
let step = 4;
noStroke();
for (let x = 0; x < width; x += step) {
for (let y = 0; y < height; y += step) {
let minDist = Infinity;
let secondDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < numSeeds; i++) {
let d = dist(x, y, seeds[i].x, seeds[i].y);
if (d < minDist) {
secondDist = minDist;
minDist = d;
closestIdx = i;
} else if (d < secondDist) {
secondDist = d;
}
}
let c = seedColors[closestIdx];
// 邊緣處顏色變暗
let edgeFactor = (secondDist - minDist) / 30.0;
edgeFactor = constrain(edgeFactor, 0, 1);
fill(c[0] edgeFactor, c[1] edgeFactor, c[2] * edgeFactor);
rect(x, y, step, step);
}
}
// 畫種子點
fill(255);
for (let s of seeds) {
ellipse(s.x, s.y, 5, 5);
}
}
這裡用了一個技巧:secondDist - minDist(第二近和最近的距離差)越小表示越靠近邊界,我們用這個值來讓邊界變暗,產生自然的「溝壑」效果。
GLSL Shader 實作
在 shader 中,Voronoi 的效能不是問題——每個像素平行計算。經典的做法(由 Inigo Quilez 推廣)是使用網格加速:
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 random2(vec2 p) {
return fract(sin(vec2(
dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3))
)) * 43758.5453);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.y;
// 網格縮放
float scale = 8.0;
vec2 st = uv * scale;
// 整數部分和小數部分
vec2 i_st = floor(st);
vec2 f_st = fract(st);
float minDist = 1.0;
float secondMinDist = 1.0;
vec2 closestPoint;
// 檢查 3x3 鄰域
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
// 該網格的隨機種子點
vec2 point = random2(i_st + neighbor);
// 讓種子點動起來
point = 0.5 + 0.5 sin(u_time + 6.2831 point);
// 到該點的距離
vec2 diff = neighbor + point - f_st;
float d = length(diff);
if (d < minDist) {
secondMinDist = minDist;
minDist = d;
closestPoint = point;
} else if (d < secondMinDist) {
secondMinDist = d;
}
}
}
// 著色
// 方式一:距離場
vec3 color = vec3(minDist);
// 方式二:邊緣強調
float edge = secondMinDist - minDist;
color = vec3(smoothstep(0.0, 0.05, edge));
// 方式三:混合
vec3 cellColor = vec3(closestPoint.x, closestPoint.y, 0.5);
color = cellColor * smoothstep(0.0, 0.05, edge);
gl_FragColor = vec4(color, 1.0);
}
程式碼解說
關鍵技巧是 網格分割:我們不需要和所有種子點比較距離,只需要檢查當前像素所在網格和周圍 3×3 的鄰居格。因為每個格子只有一個種子點,所以最多比較 9 個點。
random2() 函數用一個 hash 函數為每個網格生成一個「偽隨機」的 2D 位置作為種子點。加上時間變數就能讓種子點動起來。
不同距離函數的視覺效果
換掉距離函數會產生截然不同的風格:
// 歐幾里得距離(圓形)
float d = length(diff);
// 曼哈頓距離(菱形)
float d = abs(diff.x) + abs(diff.y);
// 切比雪夫距離(方形)
float d = max(abs(diff.x), abs(diff.y));
// 閔可夫斯基距離(可調整形狀)
float p = 3.0; // p=1 是曼哈頓,p=2 是歐幾里得,p=∞ 是切比雪夫
float d = pow(pow(abs(diff.x), p) + pow(abs(diff.y), p), 1.0/p);
自然紋理模擬
龜裂紋理
乾裂的泥土可以用 Voronoi 的邊緣來模擬:
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 st = uv * 6.0;
// ... (Voronoi 計算同上)
// 龜裂效果
float crack = smoothstep(0.0, 0.03, secondMinDist - minDist);
vec3 baseColor = vec3(0.6, 0.45, 0.3); // 土色
vec3 crackColor = vec3(0.15, 0.1, 0.05); // 裂縫深色
vec3 color = mix(crackColor, baseColor, crack);
// 加一些雜訊讓表面不那麼均勻
float noise = fract(sin(dot(floor(st), vec2(12.9898, 78.233))) * 43758.5453);
color = 0.85 + 0.15 noise;
gl_FragColor = vec4(color, 1.0);
}
長頸鹿斑紋
長頸鹿的斑紋本質上就是 Voronoi 細胞加上粗邊線:
// 長頸鹿斑紋
float edge = smoothstep(0.0, 0.08, secondMinDist - minDist);
vec3 spotColor = vec3(0.55, 0.3, 0.1); // 棕色斑點
vec3 lineColor = vec3(0.95, 0.85, 0.6); // 淺黃色網線
vec3 color = mix(lineColor, spotColor, edge);
肥皂泡沫
肥皂泡沫需要薄薄的邊線和彩虹色澤:
float edge = secondMinDist - minDist;
float outline = 1.0 - smoothstep(0.0, 0.02, edge);
// 彩虹干涉色
vec3 rainbow = 0.5 + 0.5 cos(6.2831 (minDist * 3.0 + vec3(0.0, 0.33, 0.67)));
vec3 color = rainbow (1.0 - outline 0.5);
color += vec3(1.0) outline 0.3; // 邊線高光
多層 Voronoi 疊加
像 iq 的文章中常見的技巧——把不同尺度的 Voronoi 疊加起來,產生更豐富的紋理:
float voronoi(vec2 st) {
vec2 i_st = floor(st);
vec2 f_st = fract(st);
float minDist = 1.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 point = random2(i_st + neighbor);
point = 0.5 + 0.5 sin(u_time 0.5 + 6.2831 * point);
float d = length(neighbor + point - f_st);
minDist = min(minDist, d);
}
}
return minDist;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// 多層疊加(類似碎形的 octave)
float v = 0.0;
v += 0.5 voronoi(uv 4.0);
v += 0.25 voronoi(uv 8.0);
v += 0.125 voronoi(uv 16.0);
vec3 color = vec3(v);
gl_FragColor = vec4(color, 1.0);
}
小結
Voronoi Diagram 是一個簡單到不能再簡單的概念——「最近的那個」——但它產生的視覺結果卻如此豐富和有機。我覺得這正是數學之美的一個縮影:最簡單的規則,往往能產生最複雜的結構。
在 shader 中,Voronoi 是一個非常實用的建構模組。它可以單獨使用來模擬各種自然紋理,也可以和 noise、碎形等其他技術組合,產生無窮無盡的視覺可能性。
延伸閱讀
- Inigo Quilez 的 Voronoi 教學:iquilezles.org/articles/voronoilines
- The Book of Shaders 第 12 章:Cellular Noise
- Shadertoy 上搜尋 “voronoi” 查看社群作品
- Stefan Gustavson 的論文 “Simplex noise demystified” 中也討論了 cellular noise
- 嘗試結合 Voronoi 和法線貼圖來做 3D 表面紋理