前言

長頸鹿的斑紋、乾裂的泥土、蜻蜓的翅膀、肥皂泡沫的接縫——這些看似毫不相關的自然現象,背後都藏著同一個數學結構: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 表面紋理