前言

如果你曾看過那些粒子像河流一樣蜿蜒流動的生成式藝術作品,八成背後就是一個流場(Flow Field)在驅動。流場是我最喜歡的創意程式設計主題之一——它的概念簡潔優雅,效果卻可以無限豐富。

流場的核心想法很簡單:在畫布上佈滿一個看不見的向量場,每個位置都有一個「方向」,然後讓粒子跟著這些方向移動。當我們用 Perlin noise 來生成這些方向時,流場就會展現出一種有機、流暢、如同自然界中的水流或氣流般的美感。

我還記得第一次看到 Tyler Hobbs 的《Fidenza》系列作品時的震撼——那些色彩斑斕的線條在畫布上流淌,形成了某種介於混沌與秩序之間的紋理。而這一切的基礎,就是流場。

這篇文章會帶你理解向量場的概念、用 Perlin noise 生成流場、讓粒子沿著流場移動,並且調校出漂亮的視覺效果。


向量場概念

向量場就是一個函數,給定空間中的每一個點,回傳一個向量。在二維的情況下,每個 (x, y) 座標都對應一個方向和大小。

想像一張氣象圖上的風場:每個位置都有一個小箭頭,表示該處風的方向和強度。我們的流場就是這樣的東西。

在實作上,我們把畫布切割成一個網格,每個格子儲存一個角度值:

let cols, rows;
let scl = 20;        // 每個格子的大小
let flowField = [];

function setup() { createCanvas(800, 600); cols = floor(width / scl); rows = floor(height / scl); flowField = new Array(cols * rows); }

scl 是解析度——值越小,流場越精細,但計算量也越大。20 像素通常是個不錯的起點。


用 Perlin Noise 生成角度

Perlin noise 是流場的靈魂。它是一種連續的偽隨機函數,相鄰的輸入會產生相鄰的輸出。這意味著流場的方向變化是平滑的,不會突然跳變。

let zoff = 0;  // 第三維度,用於動態變化

function generateFlowField() { let yoff = 0;

for (let y = 0; y < rows; y++) { let xoff = 0; for (let x = 0; x < cols; x++) { let index = x + y * cols;

// noise 回傳 0~1 的值,乘以 TWO_PI 映射到完整角度 let angle = noise(xoff, yoff, zoff) TWO_PI 2;

flowField[index] = p5.Vector.fromAngle(angle); flowField[index].setMag(1); // 可調整流場的「推力」

xoff += 0.1; // noise 的 x 步進 } yoff += 0.1; // noise 的 y 步進 } zoff += 0.003; // 時間步進,讓流場緩慢變化 }

這裡有幾個關鍵的參數需要理解:

  • xoffyoff 的步進值(0.1):決定 noise 的「縮放」。值越小,流場越平滑、大尺度的旋渦;值越大,流場越混亂、細碎的紋理。
  • zoff 的步進值(0.003):決定流場隨時間變化的速度。設成 0 則流場是靜態的。
  • 角度的映射範圍noise <em> TWO_PI </em> 2 讓角度可以覆蓋完整的旋轉範圍。乘以不同的倍數會產生不同的圖案特性。

視覺化流場本身

在開發階段,把流場畫出來有助於理解和除錯:

function drawFlowField() {
  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      let index = x + y * cols;
      let v = flowField[index];

push(); translate(x scl + scl / 2, y scl + scl / 2); rotate(v.heading()); stroke(100, 150); strokeWeight(1); line(0, 0, scl * 0.6, 0); // 小箭頭 line(scl 0.6, 0, scl 0.4, -3); line(scl 0.6, 0, scl 0.4, 3); pop(); } } }


粒子沿流場移動

粒子的設計跟上一篇的粒子系統類似,但這次粒子的行為不是由重力主導,而是由流場決定:

class FlowParticle {
  constructor() {
    this.pos = createVector(random(width), random(height));
    this.vel = createVector(0, 0);
    this.acc = createVector(0, 0);
    this.maxSpeed = 3;
    this.prevPos = this.pos.copy();
  }

follow(flowField) { // 根據位置找到所在格子的向量 let x = floor(this.pos.x / scl); let y = floor(this.pos.y / scl); let index = constrain(x + y * cols, 0, flowField.length - 1);

let force = flowField[index]; this.applyForce(force); }

applyForce(force) { this.acc.add(force); }

update() { this.vel.add(this.acc); this.vel.limit(this.maxSpeed); this.prevPos = this.pos.copy(); this.pos.add(this.vel); this.acc.mult(0); }

display() { stroke(255, 10); strokeWeight(1); line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y); }

edges() { // 環形邊界:從一邊消失,另一邊出現 if (this.pos.x > width) { this.pos.x = 0; this.prevPos = this.pos.copy(); } if (this.pos.x < 0) { this.pos.x = width; this.prevPos = this.pos.copy(); } if (this.pos.y > height) { this.pos.y = 0; this.prevPos = this.pos.copy(); } if (this.pos.y < 0) { this.pos.y = height; this.prevPos = this.pos.copy(); } } }

注意幾個設計決策:

  1. 畫線而非畫點:用 line(prevPos, pos) 取代 ellipse,讓粒子的軌跡形成連續的線條
  2. 環形邊界:粒子飛出畫布後從另一側回來,保持畫面的粒子密度
  3. prevPos 重置:環形跳轉時重置前一個位置,避免畫出跨越整個畫布的線條

完整流場範例

把所有元素組合在一起:

let particles = [];
let flowField = [];
let cols, rows;
let scl = 20;
let zoff = 0;
const NUM_PARTICLES = 3000;

function setup() { createCanvas(800, 600); cols = floor(width / scl); rows = floor(height / scl);

for (let i = 0; i < NUM_PARTICLES; i++) { particles.push(new FlowParticle()); }

background(20, 20, 30); }

function draw() { // 不清除背景,讓軌跡累積 // 但加一層半透明背景來緩慢淡化舊軌跡 // background(20, 20, 30, 5); // 取消註解可啟用淡化

generateFlowField();

for (let p of particles) { p.follow(flowField); p.update(); p.edges(); p.display(); } }

function generateFlowField() { let yoff = 0; for (let y = 0; y < rows; y++) { let xoff = 0; for (let x = 0; x < cols; x++) { let index = x + y * cols; let angle = noise(xoff, yoff, zoff) TWO_PI 2; flowField[index] = p5.Vector.fromAngle(angle); flowField[index].setMag(0.5); xoff += 0.08; } yoff += 0.08; } zoff += 0.002; }

如果不清除背景,粒子的軌跡會慢慢覆蓋整個畫布,最終形成一幅完整的流場畫作。這正是許多生成式藝術作品的做法。


視覺效果調校

流場的魅力在於,改變幾個參數就能得到完全不同的視覺風格。

調整 noise 的尺度

// 大尺度、平滑的流場
xoff += 0.03;  // 小步進 → 大旋渦

// 小尺度、混沌的流場 xoff += 0.3; // 大步進 → 碎裂紋理

加入顏色

根據粒子的速度或位置來著色:

display() {
  let speed = this.vel.mag();
  let hue = map(speed, 0, this.maxSpeed, 180, 360);

colorMode(HSB, 360, 100, 100, 100); stroke(hue, 80, 90, 5); strokeWeight(1); line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y); colorMode(RGB); }

粗細變化

根據 noise 值調整線條粗細:

follow(flowField) {
  let x = floor(this.pos.x / scl);
  let y = floor(this.pos.y / scl);
  let index = constrain(x + y * cols, 0, flowField.length - 1);

let force = flowField[index]; this.applyForce(force);

// 額外的 noise 控制線條粗細 this.weight = map(noise(this.pos.x 0.01, this.pos.y 0.01), 0, 1, 0.5, 3); }

display() { stroke(255, 8); strokeWeight(this.weight); line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y); }

多層流場

可以用不同的 noise 種子疊加多層流場,製造更豐富的視覺層次:

function generateFlowField() {
  let yoff = 0;
  for (let y = 0; y < rows; y++) {
    let xoff = 0;
    for (let x = 0; x < cols; x++) {
      let index = x + y * cols;

// 兩層 noise 混合 let angle1 = noise(xoff, yoff, zoff) TWO_PI 2; let angle2 = noise(xoff + 100, yoff + 100, zoff 0.5) TWO_PI; let angle = lerp(angle1, angle2, 0.3);

flowField[index] = p5.Vector.fromAngle(angle); flowField[index].setMag(0.5); xoff += 0.08; } yoff += 0.08; } zoff += 0.002; }


進階技巧:用影像驅動流場

你甚至可以用一張圖片的亮度來影響流場的方向:

let img;

function preload() { img = loadImage('portrait.jpg'); }

function generateImageFlowField() { img.loadPixels(); for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { let index = x + y * cols;

// 取得對應像素的亮度 let px = floor(map(x, 0, cols, 0, img.width)); let py = floor(map(y, 0, rows, 0, img.height)); let c = img.get(px, py); let bright = brightness(c);

// 亮度映射到角度 let angle = map(bright, 0, 100, 0, TWO_PI); angle += noise(x 0.05, y 0.05) PI 0.5; // 加入一點隨機性

flowField[index] = p5.Vector.fromAngle(angle); flowField[index].setMag(map(bright, 0, 100, 0.2, 1.5)); } } }

這個技巧可以讓粒子在亮區和暗區有不同的行為,最終在畫布上重新「畫」出原始影像的輪廓。


小結

流場是生成式藝術中最具表現力的工具之一。它的核心概念很簡單——向量場加粒子——但透過調整 noise 參數、色彩方案、線條風格,你可以創造出千變萬化的作品。

今天我們學了:

  1. 向量場的資料結構設計
  2. 用 Perlin noise 生成平滑的角度場
  3. 粒子跟隨流場的移動邏輯
  4. 各種視覺效果的調校技巧
  5. 用影像驅動流場的進階手法

我建議你花一個下午的時間,把這個範例跑起來,然後瘋狂調參數。流場就是那種——每次改一個數字,都會帶來驚喜的東西。

延伸閱讀

  • Tyler Hobbs 的部落格文章《Flow Fields》 — 深入探討流場在藝術創作中的應用
  • Daniel Shiffman, Coding Train: “Coding Challenge #24: Perlin Noise Flow Field”
  • 《Generative Design》一書中的向量場章節
  • p5.js noise() 文件:理解 Perlin noise 的維度與參數