← 返回投影片
MD

演算法 亂數、隨機、形狀

Algorithm · Randomness · Local Sketches
亂數不是失控。它是在固定規則內產生可控變化的方法。
噪聲
種子
規則
重播

章節零 — 亂數不是失控

聲音藝術有句名言:噪音為聲音之母。演算法有千百萬種,本章節透過解析亂數、機率,來說明為什麼亂數也是生成藝術之母。
我使用亂數,不是為了放棄控制。我把控制放在可能性的設計上。
chapter 0.1
結構
我先決定哪些東西不能動。畫布尺寸、座標系、迴圈、邊界、規則。確定性提供共同結構,讓每一次生成都使用同一套條件。
structure rules
chapter 0.2
變體
機率讓同一套規則產生不同版本。分布會影響視覺特徵。均勻分布平均散布樣本,高斯分布集中重心,長尾分布保留少數極端值。
variation distribution
chapter 0.3
重播
Seed 讓隨機結果可以重現。同一個 seed 會產生同一張圖、同一段聲音、同一組 metadata。結果因此可以被編號與驗證。
seed replay
chapter 0.4
稀缺
Rarity 不是事後貼上去的稀有標籤。它一開始就在機率裡。少數顏色、少數形狀、少數聲音,透過門檻或權重設定為低機率事件。
rarity features
seed
分布
rarity
Good Vibrations
到 Good Vibrations 的時候,這些不只是單張畫面的技術。位置、顏色、線段、聲音與 metadata,都由同一套機率系統產生。
先從最簡單的 random() 開始。
機率不是把控制權交出去

我聽過一種說法:用亂數的人比較懶,「電腦在幫你決定」。這個說法把決策面看小了。

傳統繪畫的決策面是「這一筆畫在哪」。寫機率的決策面是「什麼常出現、什麼偶爾出現、什麼前後相連」。一個是物件,一個是分布。我做的事不比畫一張畫的人少,只是工作對象不同。

兩個極端都不會是作品。完全寫死,只是一張畫;完全亂數,是白噪聲,沒人會在上面簽名。藝術家在中間挑位置。挑出來的那個位置就是分布的形狀,它比任何單一張輸出更能代表我。

1960s 演算法藝術 → 2020s 生成藝術

用機率做美術不是新事。六十年前 Vera Molnár 在 IBM 主機上跑迴圈,Frieder Nake 把指令打到 plotter 上畫圖,Sol LeWitt 把規則寫在牆上讓助理執行。當代 fxhash 與 Art Blocks 上的生成藝術,是同一個母題的延續。

1960s–80s
2020s
plotter、實體輸出
瀏覽器、鏈上 metadata
真隨機(時鐘、外部訊號)
PRNG,可重播
單件物件、複本
token + 算法,公開可驗證的綁定
藝術家自己挑作品展出
mint 隨機,藝術家無法挑

沒變的是核心立場:規則就是作品,分布就是簽名,單一作者卻多元輸出。變的是材料的來源與分發方式。當 seed 從藝術家的時鐘換成區塊鏈的 hash,責任就轉移了:藝術家不能挑喜歡的版本,只能把分布設計好,讓每一張 mint 出來的都成立。

seed = hash(token) // 區塊鏈契約 random_with_seed(seed) // 同樣 seed 永遠回同樣作品

章節一 — 均勻亂數

先觀察點的均勻散布。此時尚未加入方向、中心或長尾分布。
example 1.1
均勻亂數,「均勻散點」
每個點都有同樣的機會出現。結果接近平均散布的雜訊,沒有明顯方向。
random() 是什麼?怎麼讓畫面出現空白?

random(N) 會在 0 到 N 之間回傳一個數值。重複幾千次去畫點,畫布會被沒有明顯方向的雜訊鋪滿。

John von Neumann 1949 年在 Monte Carlo 研討會上講了一段話,1951 年收進《Monte Carlo Method》論文集:「任何用算數方法產生隨機數的人,當然是處在罪惡狀態。」電腦其實做不出真正的亂數,只能用一個確定性的函數演出隨機。當時是純數學家的自嘲,七十年後變成了生成藝術的全部基礎。

空白不是另一種亂數,而是多一個門檻。位置先被抽出來,再決定要不要畫。門檻設 1.0 全部留下,設 0.3 大約剩三成,設 0.05 只剩零星幾顆。

for (let i = 0; i < 1000; i++) { let x = random(width); // 第一個 random:位置 let y = random(height); if (random() < 0.3) { // 第二個 random:要不要畫 point(x, y); } }
uniform random cosmic dust
查看程式碼 · page-1 sketch.js
function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  noStroke();
}

function draw() {
  background(0);
  for (let i = 0; i < 8000; i++) {
    fill(random(255));
    circle(random(width), random(height), 2);
  }
}
LOCAL · PAGE 1
滑到此處載入本地 sketch。
example 1.2
巢狀亂數
給亂數再加一次亂數,機率開始傾斜。均勻的點慢慢聚成漸層。
為什麼兩個 random 包起來,會變成這個形狀?

先把 random(N) 想成一台抽號機:給它一個最大值 N,它就在 0 到 N 之間隨手丟一個數字回來,每個數字的機會都一樣。

巢狀的意思是抽兩次。第一次抽出一個數字 A,把 A 當成第二次的最大值,再抽一次。第二次的範圍會被第一次卡住。

  • 第一次抽到 90,第二次的範圍是 0 到 90,還有機會抽到很大的數字。
  • 第一次抽到 5,第二次的範圍就只剩 0 到 5,怎麼抽都不會超過 5。

大數字要連中兩次籤才會出現,小數字幾乎每次都有機會。整體分布就被往「小」的那一邊拉,越靠近 0 越密集,越靠近最大值越稀疏。畫面上的點也因此從均勻變成漸層。

random(100) // 0 到 100,每個數字機會一樣 random(random(100)) // 第二次的最大值 = 第一次抽到的數
nested random biased distribution
查看程式碼 · page-2 sketch.js
function setup() {
  createCanvas(500, 500);
  noStroke();
}

function draw() {
  background(0);
  for (let i = 0; i < 8000; i++) {
    fill(random(255));
    circle(random(random())*width, random(random())*height, 2);
  }
}
LOCAL · PAGE 2
滑到此處載入本地 sketch。
圓盤問題:半徑不是面積 同一個圓盤,差別只在半徑怎麼抽。random() * R 會讓中心偏密;sqrt(random()) * R 才接近整個面積的均勻。
example 1.3
圓盤問題,一:半徑均勻
角度平均繞一圈,半徑直接用 random() * R。半徑是平均的,面積不是。中心會比較擠。
第一個版本:random() * R

角度平均繞一圈,半徑從 0 到 R 平均抽。這是半徑均勻,不是面積均勻。

random() * R 會讓一半的點落在 0 到 R/2。那塊內圈只占整個圓 1/4 的面積,所以中心自然變密。

function setup() { createCanvas(500, 500); noStroke(); } function draw() { background(0); for (let i = 0; i < 8000; i++) { fill(random(255)); let r = random() * width/2; let x = cos(i / 8000 * 6.28) * r; let y = sin(i / 8000 * 6.28) * r; circle(x + width/2, y + height/2, 2); } }
polar coords uniform radius area bias
查看程式碼 · page-3 sketch.js
function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  noStroke();
}

function draw() {
  background(0);
  for (let i = 0; i < 8000; i++) {
    fill(random(255));
    let r = random() * width / 2;
    let x = cos(i / 8000 * 6.28) * r;
    let y = sin(i / 8000 * 6.28) * r;

    circle(x + width / 2, y + height / 2, 2);
  }
}
LOCAL · PAGE 3
滑到此處載入本地 sketch。
example 1.4
圓盤問題,二:半徑反轉
半徑先被巢狀亂數壓小,再用 1.0 - ... 反轉。點被推向外圍,圓盤變成光環。
第二個版本:1.0 - ...

角度不變,只改半徑這行:

let r = (1 - random(random(random()))) * width/2;

巢狀亂數偏小,前面加上 1.0 -,小值被翻到接近 1。半徑被推遠,中間空出來,外圈留下來。

for (let i = 0; i < 8000; i++) { let r = (1 - random(random(random()))) * width/2; // 半徑:被推到最遠 let x = cos(i / 8000 * 6.28) * r; // 角度均勻繞一圈 let y = sin(i / 8000 * 6.28) * r; circle(x + width/2, y + height/2, 2); // 移到畫布中心 }
polar coords nested random halo
查看程式碼 · page-4 sketch.js
function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  noStroke();
}

function draw() {
  background(0);
  for (let i = 0; i < 8000; i++) {
    fill(random(255));
    let r = (1 - random(random(random()))) * width / 2;
    let x = cos(i / 8000 * 6.28) * r;
    let y = sin(i / 8000 * 6.28) * r;

    circle(x + width / 2, y + height / 2, 2);
  }
}
LOCAL · PAGE 4
滑到此處載入本地 sketch。
example 1.5
圓盤問題,三:面積尺度
sqrt(random()) * R 把半徑往外補。不是為了做光環,而是讓每一圈拿到和面積相稱的點。
第三個版本:sqrt(random()) * R

角度不變,半徑換成這一行:

let r = sqrt(random()) * width/2;

sqrt 把半徑往外補。半徑一半的內圈只占 1/4 面積,所以也只該拿到 1/4 的點。

這三張圖只看一件事:

  • random() * R:半徑均勻,中心密。
  • 1.0 - ...:半徑反轉,外圈亮。
  • sqrt(random()) * R:面積修正,圓盤均勻。
for (let i = 0; i < N; i++) { let r = sqrt(random()) * width/2; // 開平方根,補上面積偏差 let x = cos(i / N * 6.28) * r; // 角度均勻繞一圈 let y = sin(i / N * 6.28) * r; circle(x + width/2, y + height/2, 2); }
polar coords uniform disk sqrt
查看程式碼 · page-5 sketch.js
function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  noStroke();
}

function draw() {
  background(0);
  for (let i = 0; i < 8000; i++) {
    fill(random(255));
    let r = sqrt(random()) * width / 2;
    let x = cos(i / 8000 * 6.28) * r;
    let y = sin(i / 8000 * 6.28) * r;

    circle(x + width / 2, y + height / 2, 2);
  }
}
LOCAL · PAGE 5
滑到此處載入本地 sketch。
example 1.6
視覺均勻,Blue Noise
純 random 會自然結團。Blue Noise 讓點彼此留出距離,所以看起來更均勻。
為什麼真隨機看起來不像均勻?

把點用 random() 撒上畫布,會看到結團與空洞。沒有壞掉,點只是彼此不避開。

Poisson disk sampling 只多加一條規則:太靠近舊點的候選點不收。這不是更「隨機」,而是更接近人眼期待的均勻。

// 最小距離法的核心邏輯 function tryPoint() { let p = createVector(random(width), random(height)); for (let q of accepted) { if (p.dist(q) < r) return null; // 太近,丟掉 } return p; // 通過,加入名單 }

看起來均勻,不等於數學上的均勻。

blue noise poisson disk visual uniformity
查看程式碼 · page-6 sketch.js
let targetCount = 260;
let minDistance = 14;
let randomPoints = [];
let poissonPoints = [];

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  noLoop();
  generatePoints();
}

function draw() {
  background(0);
  drawPanel(0, randomPoints, 'pure random');
  drawPanel(width / 2, poissonPoints, 'poisson disk');
  drawDivider();
}

function generatePoints() {
  randomPoints = makeRandomPoints(targetCount, 0, width / 2);
  poissonPoints = makePoissonPoints(targetCount, width / 2, width, minDistance);
  redraw();
}

function makeRandomPoints(count, minX, maxX) {
  let pts = [];
  for (let i = 0; i < count; i++) {
    pts.push(createVector(random(minX + 12, maxX - 12), random(36, height - 14)));
  }
  return pts;
}

function makePoissonPoints(count, minX, maxX, radius) {
  let pts = [];
  let attempts = 0;
  let maxAttempts = count * 280;

  while (pts.length < count && attempts < maxAttempts) {
    let p = createVector(random(minX + 12, maxX - 12), random(36, height - 14));
    if (isFarEnough(p, pts, radius)) {
      pts.push(p);
    }
    attempts++;
  }

  return pts;
}

function isFarEnough(p, pts, radius) {
  let r2 = radius * radius;
  for (let q of pts) {
    let dx = p.x - q.x;
    let dy = p.y - q.y;
    if (dx * dx + dy * dy < r2) return false;
  }
  return true;
}

function drawPanel(offsetX, pts, label) {
  noFill();
  stroke(38);
  rect(offsetX + 8, 28, width / 2 - 16, height - 36);

  noStroke();
  fill(235);
  for (let p of pts) {
    circle(p.x, p.y, 3);
  }

  fill(150);
  textSize(10);
  text(label, offsetX + 12, 18);
  text(`${pts.length} pts`, offsetX + width / 2 - 64, 18);
}

function drawDivider() {
  stroke(70);
  line(width / 2, 0, width / 2, height);

  noStroke();
  fill(120);
  textSize(9);
  text(`click: regenerate  [ / ] distance: ${minDistance}  - / + count: ${targetCount}`, 12, height - 8);
}

function mousePressed() {
  generatePoints();
}

function keyPressed() {
  if (key === '[') minDistance = max(6, minDistance - 1);
  if (key === ']') minDistance = min(32, minDistance + 1);
  if (key === '-' || key === '_') targetCount = max(60, targetCount - 20);
  if (key === '=' || key === '+') targetCount = min(520, targetCount + 20);
  generatePoints();
}
LOCAL · PAGE 6
滑到此處載入本地 sketch。點擊重生,鍵盤可調整點數與最小距離。
supplement 1.7
補充:Rejection Sampling
另一種做圓盤均勻的方法:先在正方形裡撒點,圓外丟掉,圓內留下。
補充:先撒,再切掉

1.5 用 sqrt(random()) 修正半徑。Rejection sampling 改用另一種方法:先在外面的正方形均勻取樣,再檢查樣本是否位於圓內,不在圓內的樣本就丟掉。

圓裡留下的點仍然均勻。代價是會浪費一部分樣本,圓盤大約丟掉 21.5%。

let x, y; do { x = random(-R, R); // 正方形內均勻撒 y = random(-R, R); } while (x*x + y*y > R*R); // 出圓就重抽 circle(x + width/2, y + height/2, 2);
rejection sampling uniform disk general method
查看程式碼 · page-7 sketch.js
let radius = 210;
let maxAttempts = 1400;
let samplesPerFrame = 22;
let accepted = [];
let rejected = [];
let attempts = 0;

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  resetSampling();
}

function draw() {
  if (attempts < maxAttempts) {
    for (let i = 0; i < samplesPerFrame && attempts < maxAttempts; i++) {
      sampleCandidate();
    }
  }

  background(0);
  drawBounds();
  drawPoints(rejected, color(115, 70, 70, 150), 2);
  drawPoints(accepted, color(235, 235, 235, 210), 2.5);
  drawStats();
}

function sampleCandidate() {
  let x = random(-radius, radius);
  let y = random(-radius, radius);
  let p = createVector(x, y);

  if (x * x + y * y <= radius * radius) {
    accepted.push(p);
  } else {
    rejected.push(p);
  }

  attempts++;
}

function drawBounds() {
  push();
  translate(width / 2, height / 2);

  noFill();
  stroke(80);
  strokeWeight(1);
  rectMode(CENTER);
  rect(0, 0, radius * 2, radius * 2);

  stroke(190);
  strokeWeight(1.2);
  circle(0, 0, radius * 2);
  pop();
}

function drawPoints(points, c, size) {
  push();
  translate(width / 2, height / 2);
  noStroke();
  fill(c);
  for (let p of points) {
    circle(p.x, p.y, size);
  }
  pop();
}

function drawStats() {
  let rate = attempts === 0 ? 0 : accepted.length / attempts;
  let waste = 1 - rate;

  noStroke();
  fill(235);
  textSize(11);
  text(`accepted ${accepted.length}`, 16, 24);
  fill(155);
  text(`rejected ${rejected.length}`, 16, 42);
  text(`rate ${nf(rate, 1, 3)}  target PI/4 ${nf(PI / 4, 1, 3)}`, 16, height - 28);
  text(`waste ${nf(waste, 1, 3)}  click: resample`, 16, height - 12);
}

function resetSampling() {
  accepted = [];
  rejected = [];
  attempts = 0;
}

function mousePressed() {
  resetSampling();
}
LOCAL · PAGE 7
滑到此處載入本地 sketch。白點保留,紅灰點丟掉;點擊重新取樣。

章節二 — 高斯亂數──中心集中

高斯亂數有中心位置,也有擴散值。多數點靠近中心,少數點慢慢退到外圍。

x = randomGaussian(centerPosition, spreadValue); // 中心位置決定靠近哪裡,擴散值決定散開多遠
example 2.1
高斯亂數,「中心集中」
中心位置決定點雲靠近哪裡,擴散值決定散開多遠。用在重心、尺寸、亮度、主色附近的偏移,畫面會形成集中的視覺重心。
為什麼這個鐘形曲線會出現在德國馬克紙鈔上?

Carl Friedrich Gauss 1809 年出版《Theoria Motus》時,為了解釋天文觀測誤差,把這條鐘形曲線寫進公式。後人才把它命名為「高斯分布」。其實 Abraham de Moivre 1733 年就提早算出來了,只是當時沒人在意。

1991 到 2001 年流通的德國 10 馬克紙鈔正面,印著高斯本人、鐘形曲線、以及他工作過的哥廷根。一張紙鈔同時放數學家跟分布函數,在貨幣設計史上很罕見。這也讓高斯分布成為大眾可見的科學圖像。

gaussian random soft mountains
查看程式碼 · page-1 sketch.js
let centerX = 250;
let baseY = 360;
let spreadX = 78;
let spreadY = 34;

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  noLoop();
  drawScene();
}

function drawScene() {
  background(5, 7, 12);
  drawMountainGlow();
  drawRidge();
  drawGuide();
}

function mountainY(x) {
  let d = abs(x - centerX) / centerX;
  let peak = 168 * exp(-d * d * 2.4);
  return baseY - peak + sin(x * 0.026) * 14 + sin(x * 0.071) * 6;
}

function drawMountainGlow() {
  blendMode(ADD);
  noStroke();

  for (let i = 0; i < 9000; i++) {
    let x = randomGaussian(centerX, spreadX);
    let y = randomGaussian(mountainY(x), spreadY);
    if (x < 0 || x > width || y < 0 || y > height) continue;

    let alpha = map(y, 70, height, 20, 6, true);
    fill(120, 170, 255, alpha);
    circle(x, y, random(1.2, 3.6));
  }

  for (let i = 0; i < 900; i++) {
    let x = randomGaussian(centerX, spreadX * 2.15);
    let y = randomGaussian(mountainY(x), spreadY * 1.7);
    if (x < 0 || x > width || y < 0 || y > height) continue;

    fill(240, 230, 185, 10);
    circle(x, y, random(0.8, 2.4));
  }

  blendMode(BLEND);
}

function drawRidge() {
  noFill();
  stroke(235, 240, 255, 105);
  strokeWeight(1.2);
  beginShape();
  for (let x = 0; x <= width; x += 4) {
    vertex(x, mountainY(x));
  }
  endShape();

  stroke(75, 105, 160, 90);
  strokeWeight(1);
  for (let y = 405; y < height; y += 18) {
    line(0, y, width, y);
  }
}

function drawGuide() {
  noStroke();
  fill(230);
  textSize(11);
  text('randomGaussian(center, spread)', 16, 24);
  fill(140);
  text('most samples stay near the ridge; a few drift away', 16, height - 16);
}

function mousePressed() {
  drawScene();
}
LOCAL · PAGE 1
滑到此處載入本地 sketch。點擊重新取樣。
example 2.2
中心位置與擴散值,把山的位置和寬度交出去
高斯亂數有兩個旋鈕:中心位置決定點雲圍繞哪裡,擴散值決定散開多遠。擴散值一小,點收成一團。擴散值一大,山攤開,幾乎變成均勻。
擴散值到底在控制什麼?

randomGaussian() 抽到的數字,會繞著一個中心晃。中心位置決定點雲圍繞哪裡,擴散值決定散開多遠。

  • 擴散值小:點收在中心附近,畫面緊。
  • 擴散值中:有重心,也有外圈。
  • 擴散值大:山被攤開,畫面接近均勻。

這裡只需要掌握一個重點:高斯分布會把多數樣本集中在中心,少數樣本分布在外圍。

let centerPosition = 0; let spreadValue = 40; // 換這個就換鬆緊 for (let i = 0; i < 1000; i++) { let x = randomGaussian() * spreadValue + centerPosition; let y = randomGaussian() * spreadValue + centerPosition; point(x + width/2, y + height/2); }

中心位置把山搬到哪裡,擴散值把山揉成什麼形狀。畫面上的「聚焦感」是擴散值寫出來的。

gaussian random center spread soft center
查看程式碼 · page-2 sketch.js
let panels = [
  { label: 'small spread', centerX: 95, spread: 13, tone: [120, 180, 255] },
  { label: 'medium spread', centerX: 250, spread: 38, tone: [190, 220, 255] },
  { label: 'large spread', centerX: 405, spread: 78, tone: [245, 220, 170] }
];
let centerY = 250;

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  noLoop();
  drawScene();
}

function drawScene() {
  background(5, 7, 12);
  drawPanels();
  drawGaussianClouds();
  drawGuide();
}

function drawPanels() {
  noFill();
  stroke(55);
  strokeWeight(1);
  for (let i = 1; i < panels.length; i++) {
    line(i * width / 3, 46, i * width / 3, height - 42);
  }

  for (let p of panels) {
    stroke(90, 110, 145, 95);
    line(p.centerX, 70, p.centerX, height - 66);
    line(p.centerX - p.spread, centerY, p.centerX + p.spread, centerY);
    circle(p.centerX, centerY, p.spread * 2);

    noStroke();
    fill(230);
    circle(p.centerX, centerY, 4);
    fill(150);
    textSize(10);
    text(`${p.label}`, p.centerX - 48, 26);
    text(`center=${p.centerX}  spread=${p.spread}`, p.centerX - 70, height - 22);
  }
}

function drawGaussianClouds() {
  blendMode(ADD);
  noStroke();

  for (let p of panels) {
    for (let i = 0; i < 2200; i++) {
      let x = randomGaussian(p.centerX, p.spread);
      let y = randomGaussian(centerY, p.spread);
      if (!insidePanel(x, p.centerX) || y < 58 || y > height - 58) continue;

      let d = dist(x, y, p.centerX, centerY);
      let alpha = map(d, 0, p.spread * 3.2, 24, 4, true);
      fill(p.tone[0], p.tone[1], p.tone[2], alpha);
      circle(x, y, random(1.2, 3.2));
    }
  }

  blendMode(BLEND);
}

function insidePanel(x, centerX) {
  let panelW = width / 3;
  let panelIndex = floor(centerX / panelW);
  let minX = panelIndex * panelW + 8;
  let maxX = (panelIndex + 1) * panelW - 8;
  return x >= minX && x <= maxX;
}

function drawGuide() {
  noStroke();
  fill(230);
  textSize(11);
  text('randomGaussian(center, spread)', 16, 24);
  fill(140);
  text('center sets the cloud position; spread sets how far it opens', 16, height - 8);
}

function mousePressed() {
  drawScene();
}
LOCAL · PAGE 2
滑到此處載入本地 sketch。三組擴散值,三種聚焦;點擊重新取樣。

章節三 — 一維 Perlin Noise──跳動的音量柱

隨機開始有連續性,前一刻會影響下一刻。
example 3.1
一維 Perlin Noise,「跳動的音量柱」
噪聲沿著時間移動。它不是每一格重新抽樣,而是讓下一刻延續上一刻的值,因此跳動具有連續性,不會變成碎裂的閃爍。
為什麼音量柱跳動會有連續性?

如果每一格都用 random(),每一禎都會重新抽樣,前後數值沒有關係,畫面看起來只是雜訊。

random() 換成 noise(i * 空間, t * 時間),整排柱子的高度會出現連續變化。原因是 noise 不是重新抽樣,而是從連續噪聲場取值(3.2 會展開)。t 慢慢往前走時,取樣位置也跟著平移;相鄰位置的值差不多,所以前後的高度差很小。

  • spaceScale 控制空間頻率:值越大,相鄰柱子差越多。
  • timeScale 控制時間頻率:值越大,動得越快、越神經質。
  • 參數適當時,畫面變化會更平滑。

上方那條歷史曲線記錄了中央那一根柱子隨時間的值。它畫出來不是一連串突刺,而是一條平滑曲線。整排柱子的連續變化來自這種取樣方式。

1D perlin noise volume bars continuity
查看程式碼 · page-1 sketch.js
let numBars = 36;
let barWidth = 8;
let barGap = 4;
let spaceScale = 0.18;
let timeScale = 0.012;
let t = 0;
let history = [];
let historyLen = 240;

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  noStroke();
  noiseDetail(4, 0.5);
}

function draw() {
  background(5, 7, 12);

  let totalWidth = numBars * barWidth + (numBars - 1) * barGap;
  let startX = (width - totalWidth) / 2;
  let baseY = 380;
  let maxH = 280;

  let centerNoise = noise(numBars / 2 * spaceScale, t);
  history.push(centerNoise);
  if (history.length > historyLen) history.shift();

  drawHistory(history, startX, totalWidth, maxH);

  for (let i = 0; i < numBars; i++) {
    let n = noise(i * spaceScale, t);
    let h = map(n, 0, 1, 12, maxH);
    let x = startX + i * (barWidth + barGap);

    fill(28, 46, 42, 90);
    rect(x, baseY - maxH, barWidth, maxH);

    let alpha = map(n, 0, 1, 130, 230);
    fill(120, 200, 160, alpha);
    rect(x, baseY - h, barWidth, h);

    fill(230, 245, 235, 230);
    rect(x, baseY - h - 2, barWidth, 2);
  }

  push();
  noFill();
  stroke(180, 230, 200, 120);
  strokeWeight(1);
  beginShape();
  for (let i = 0; i < numBars; i++) {
    let n = noise(i * spaceScale, t);
    let x = startX + i * (barWidth + barGap) + barWidth / 2;
    let y = baseY - map(n, 0, 1, 12, maxH);
    vertex(x, y);
  }
  endShape();
  pop();

  drawStats();
  t += timeScale;
}

function drawHistory(arr, startX, totalWidth, maxH) {
  let topY = 60;
  let h = 70;
  push();
  noFill();
  stroke(80, 100, 95, 150);
  strokeWeight(1);
  rect(startX, topY, totalWidth, h);

  stroke(160, 220, 190, 200);
  strokeWeight(1.2);
  beginShape();
  for (let i = 0; i < arr.length; i++) {
    let x = startX + (i / (historyLen - 1)) * totalWidth;
    let y = topY + h - arr[i] * h;
    vertex(x, y);
  }
  endShape();
  pop();

  noStroke();
  fill(120);
  textSize(10);
  text('history of noise(center, t)', startX, topY - 6);
}

function drawStats() {
  noStroke();
  fill(220);
  textSize(11);
  text(`noise(i * ${spaceScale.toFixed(2)}, t)`, 16, 24);
  fill(155);
  text(`t = ${t.toFixed(2)}`, 16, 42);
  text(`bars ${numBars}   click: re-seed`, 16, height - 14);
}

function mousePressed() {
  noiseSeed(int(random(99999)));
  history = [];
  t = 0;
}
LOCAL · PAGE 1
滑到此處載入本地 sketch。柱頂連線顯示 noise 的連續性,上方曲線記錄中央柱的歷史;點擊重新 seed。
example 3.2
Perlin 其實不是隨機,是一張查得到的表
同一個 x 叫一次回什麼值,叫第二次還是回一樣的值。它不會記得上一刻,是因為它根本不在抽籤,是在查一張看不見的表。
為什麼叫 noise,卻不是隨機?

名字叫 noise,很容易誤會它跟 random() 是同一家。其實它們做的事完全不同。

  • random():每叫一次都重抽,前後沒有關係。
  • noise(0.5):今天叫、明天叫、換一台電腦叫,永遠回同一個值。

它不是序列,它是一個函數。輸入是一個位置 x,輸出是一個落在 0 到 1 之間的數字。你可以把它想成一張看不見的長條地圖。地圖在格子上先預先放好隨機方向的小箭頭,這些叫梯度向量。給一個 x,它先看 x 落在哪兩個格子之間,再用兩端的箭頭做平滑插值,算出一個值。

所謂的「連續性」是從這裡來的。不是它記得上一刻,是它跟上一刻同樣去查那張地圖,地圖本身就連續,所以結果也連續。換成藝術語言:random 是擲骰,noise 是讀譜。

noise(0.5); // 0.4632... noise(0.5); // 0.4632... 一樣 noise(0.5); // 0.4632... 還是一樣 random(); // 0.7281... random(); // 0.1149... 每次不同

Ken Perlin 1985 年發表這個函數,最早是為了 1982 年《Tron》的視覺特效寫的,1997 年拿到奧斯卡技術成就獎。它能變成 noise(t) 做時間動畫、noise(x, y) 做地形、noise(x, y, t) 做演化的雲,都是因為這個性質:給同樣輸入永遠給同樣輸出。可以採樣,可以倒帶,可以拼接。

章節一 1.1 的 random() 是擲一次少一次的籤。Perlin 是另一條材料線,不在那條線的延伸上。

deterministic gradient noise perlin 1985
example 3.3
Octave 疊加,多尺度輪廓與細節
一次 noise 只給一個尺度的擾動。把不同密度的 noise 加起來,可以同時產生大尺度輪廓與小尺度紋理。同一條曲線可以包含多種尺度。
為什麼自然紋理會有多個尺度?

地形、雲、火、海浪、水紋通常同時包含大尺度輪廓、中尺度變化與小尺度細節。單一尺度的 noise 只能描述其中一層。

用單一次 noise(x) 只能拿到一個尺度。若要加入多尺度細節,就需要疊加多層 noise。先給 noise 兩個參數:

  • frequencynoise(x * f)。f 越大,等於把 x 拉開來看,波就變密。
  • amplitudenoise(...) * a。a 越大,這層擾動越強。

標準做法:第一層提供主要輪廓,f = 1、a = 1。下一層 f 乘 2 變兩倍密、a 乘 0.5 變一半強。再下一層再乘一次。一般疊 4 到 6 層。這個疊加的結果叫 fractal Brownian motion,簡稱 fBm。

let v = 0, f = 1, a = 1; for (let i = 0; i < 4; i++) { v += noise(x * f) * a; // 把這層加進去 f *= 2; // 下一層波密兩倍 a *= 0.5; // 下一層擾動弱一半 }

a 衰減的速度有個名字叫 persistence。0.5 是標準,給出溫和的山。把它調高,例如 0.7,小尺度的擾動會留得更多,紋理變粗糙。調低成 0.3,小尺度幾乎消失,剩下一條乾淨的山稜。生成藝術裡常用這條旋鈕在「自然」跟「乾淨」之間切換。

fBm 背後的分形數學是 Benoit Mandelbrot 一系列研究發展出來的。他 1975 年從拉丁文 fractus 造了「fractal」這個字,寫進《Les Objets Fractals》。多尺度疊加之所以能解釋海岸線、山脈、雲為什麼放大、縮小、再放大都長得差不多,就靠這套幾何。

1980 年 SIGGRAPH,Loren Carpenter 發表了兩分鐘短片 Vol Libre,全片地形都是用分形即時生成。他當場被 Lucasfilm 招攬,1982 年《星艦迷航記 II》裡那段岩石轉變為植物的 Genesis 特效,就是他在 Lucasfilm Computer Division 做的,電影史上第一次完全程式生成的鏡頭。這個部門 1986 年被 Steve Jobs 買下,分拆成 Pixar,Carpenter 成為共同創辦人。

地形、雲、火焰、水面光斑、procedural texture,這種疊加方法都很常見。章節四 4.3 會把它跟頻譜接起來:每多疊一層 octave,等於在頻譜上加一段高頻。

fBm octave frequency amplitude persistence

章節四 — 二維 Perlin Noise──雲狀/大理石紋理

噪聲從線變成面,紋理開始有方向。
example 4.1
二維 Perlin Noise,「雲狀/大理石紋理」
同一組噪聲鋪成平面,連續性從一條線變成一片表面。它可以用來生成雲狀、地形或石紋等紋理:相鄰位置的值相近,但不完全相同。
2D perlin noise cloud / marble
查看程式碼 · page-1 sketch.js
let seed;
let scale = 0.012;
let marbleStrength = 7.5;

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  noLoop();
  seed = int(random(99999));
  drawScene();
}

function drawScene() {
  noiseSeed(seed);
  noiseDetail(5, 0.5);
  loadPixels();

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let n = fbm(x * scale, y * scale);
      let marble = sin(x * 0.035 + n * marbleStrength);
      let cloud = map(n, 0.2, 0.85, 0, 1, true);
      let vein = map(marble, -1, 1, 0, 1);
      let shade = cloud * 0.72 + vein * 0.28;

      let idx = 4 * (x + y * width);
      pixels[idx + 0] = lerp(18, 238, shade);
      pixels[idx + 1] = lerp(30, 232, shade);
      pixels[idx + 2] = lerp(45, 218, shade);
      pixels[idx + 3] = 255;
    }
  }

  updatePixels();
  drawOverlay();
}

function fbm(x, y) {
  let value = 0;
  let amp = 0.5;
  let freq = 1;

  for (let i = 0; i < 5; i++) {
    value += noise(x * freq, y * freq) * amp;
    freq *= 2;
    amp *= 0.5;
  }

  return value;
}

function drawOverlay() {
  noStroke();
  fill(0, 90);
  rect(0, 0, width, 42);
  rect(0, height - 34, width, 34);

  fill(235);
  textSize(11);
  text('noise(x * scale, y * scale)', 16, 24);
  fill(180);
  text(`seed ${seed}   click: re-seed`, 16, height - 14);
}

function mousePressed() {
  seed = int(random(99999));
  drawScene();
}
LOCAL · PAGE 1
滑到此處載入本地 sketch。同一片 2D noise 生成雲狀底紋與大理石條紋;點擊重新 seed。
example 4.2
Noise 家族,雲、石頭、流
Perlin 是 noise 家族中的一種。更換生成規則後,畫面可以呈現雲狀紋理、龜裂石紋或旋轉粒子流。每種 noise 對應不同的視覺特徵。
同樣叫 noise,為什麼看起來差這麼多?

Perlin 在格子上放梯度向量再插值,適合生成雲狀的平滑紋理。把「怎麼放、怎麼算」改掉,視覺特徵也會改變。藝術家工具箱裡常用的另外三種:

  • Simplex noise(Ken Perlin 2001)。Perlin 的優化版。視覺效果接近 Perlin,但方向偏差更小,在 3D、4D 計算更快。多數的 fragment shader 跑的其實是這個。
  • Worley noise(cellular noise / Voronoi noise)。先在空間撒一些種子點,每個位置回傳「到最近種子的距離」。近的暗、遠的亮,或反過來。常用於細胞、龜裂、石頭、鱗片等邊界型紋理。
  • Curl noise。把 Perlin 的梯度旋轉 90 度,得到一個無散度的向量場。粒子跟著流動會繞圈、會繞過障礙、不會聚成一點。煙、流體、墨水都靠它。
// Perlin / Simplex:紋理 let v = noise(x, y); // Worley:到最近種子的距離 let d = distanceToNearestPoint(x, y); // Curl:旋轉梯度成向量場 let g = gradient(noise, x, y); let flow = createVector(g.y, -g.x);

挑哪一種,取決於需要的視覺效果。需要連續平滑的紋理,可以選 Perlin 或 Simplex。需要分割成塊、有邊界感,可以選 Worley。需要流場方向,可以選 Curl。它們也可以混合使用,例如用 Perlin 位移 Worley 種子、用 Curl 控制粒子位置,再用 Perlin 控制顏色,產生多層次的紋理。

simplex worley curl noise
example 4.3
噪聲的美學光譜,從吵到柔的頻譜
把噪聲拆成頻率,觀察各頻段的能量分布,就能解釋不同噪聲的粗糙度與平滑度。選擇噪聲類型,也是在選擇頻譜分布。
為什麼有些噪聲較平滑,有些較粗糙?

把一段訊號拆成不同頻率的疊加,就是頻譜。低頻是慢慢起伏的大區塊,高頻是密集的細抖。每種噪聲就是把能量分到不同頻段的不同配方。

  • White noise。每個頻段能量都一樣。看起來、聽起來都太吵。等於每點獨立的 random()
  • Pink noise (1/f)。能量隨頻率降一倍。中段為主,低頻提供主要結構,高頻提供細節。Perlin、fBm、許多自然訊號(心跳、河流、樂句的旋律線)都在這附近。
  • Brown noise (1/f²)。能量衰減更快,低頻佔大宗。視覺平滑、聽覺偏低頻,章節五 5.5 的 PRNG 序列累加就是這個。
  • Blue noise。反過來,高頻多、低頻少。沒有大區塊的不均,視覺乾淨均勻。Poisson disk(1.6)就是空間版的 blue noise。

把 white noise 累加起來會變 brown,因為累加是一個低通濾波,把高頻壓掉。把 white noise 微分會變 blue,因為微分是高通。Perlin 介於兩者,靠近 pink。

1/f 在許多自然與文化訊號中都能觀察到,例如河流的流量、樂曲的音高變化、心跳間隔、城市噪音、海岸線彎曲。低頻太多會接近 brown 的平滑漸層,高頻太多會接近 white 的鋸齒。1/f 介於兩者之間,因此同時保留主要結構與細節。

1975 年 Richard Voss 跟 John Clarke 在《Nature》上發了一篇短論文 「1/f noise in music and speech」,量了巴哈、披頭四、藍調、爵士,發現它們的音高與音量起伏幾乎都落在 1/f 頻譜。同一年 Brian Eno 也開始用幾段彼此不同步的 loop 做 ambient 音樂,這條思路他後來在 1996 一場演講裡命名為 generative music。Voss/Clarke 是把 1/f 頻譜從生物物理移到藝術觀察裡的第一篇實證論文,比 Perlin 早十年。

// 從 white 出發,疊出近似 pink let v = 0, f = 1, a = 1; for (let i = 0; i < 6; i++) { v += noise(x * f) * a; f *= 2; a *= 0.5; // 0.5 ≈ pink;0.7 偏 brown } // 章節三 3.3 的 fBm 就是這條配方

1.6 的 Poisson disk 是空間版的 blue noise,3.3 的 fBm 落在 pink 附近,5.5 的 PRNG 累加做出的軌跡是 brown。選擇噪聲類型,也是在選擇畫面的頻譜分布與視覺特徵。

white / pink / brown / blue 1/f noise spectrum

章節五 — 分布的形狀

同一個畫面放進三種分布。中心集中、長尾、連續流動,會形成不同密度分布。
example 5.1
亂數模式
程式並列 Gaussian、Exponential、Perlin noise:一個往中心聚,一個在近處密集、遠處偶發,一個把時間放進噪聲裡。黑白三欄只看密度、大小與流動。
三種分布,並排看就懂了

左邊那團是 randomGaussian()。多數點聚在中心,越往邊越稀,沒有點會跑很遠。視覺效果是中心集中的點群。

中間那團是 -log(random()),幾何上是 Exponential 分布的半徑。中心爆滿,邊緣有少數零星點,那是長尾。多數值靠近起點,少數值被推到很遠。

右邊那群一直在動的是 noise(x, y, t) 推動的粒子。每一格的方向不是抽籤,是查表。前一刻的方向決定下一刻往哪走,所以粒子畫出來是流線而不是亂飛。時間維度被放進來了。

同一個畫面,三個面板,三種視覺特徵。這一章先用黑白比較它們的分布差異。

gaussian exponential perlin noise side-by-side
查看程式碼 · page-1 sketch.js
const CW = 500;
const CH = 500;
const PANEL_W = 140;
const PANEL_H = 320;
const PANEL_GAP = 20;
const STATIC_COUNT = 700;
const PARTICLE_COUNT = 240;

let gaussianPts = [];
let exponentialPts = [];
let particles = [];
let t = 0;
let showGuides = true;

function setup() {
  createCanvas(CW, CH);
  pixelDensity(1);
  colorMode(HSB, 360, 100, 100, 100);
  textFont('monospace');
  resampleAll();
}

function resampleAll() {
  generateGaussian();
  generateExponential();
  generateParticles();
}

function generateGaussian() {
  gaussianPts = [];
  for (let i = 0; i < STATIC_COUNT; i++) {
    gaussianPts.push({
      x: randomGaussian(0, 30),
      y: randomGaussian(0, 76)
    });
  }
}

function generateExponential() {
  exponentialPts = [];
  for (let i = 0; i < STATIC_COUNT; i++) {
    let r = -log(max(random(), 1e-6)) * 26;
    let theta = random(TWO_PI);
    exponentialPts.push({
      x: cos(theta) * r,
      y: sin(theta) * r * 1.45
    });
  }
}

function generateParticles() {
  particles = [];
  for (let i = 0; i < PARTICLE_COUNT; i++) {
    let x = random(-PANEL_W / 2, PANEL_W / 2);
    let y = random(-PANEL_H / 2, PANEL_H / 2);
    particles.push({x, y, px: x, py: y});
  }
}

function draw() {
  background(0, 0, 5);

  let panels = getPanels();
  drawPanelGaussian(panels[0]);
  drawPanelExponential(panels[1]);
  drawPanelPerlin(panels[2]);
  drawTitles(panels);
  drawHud();

  t += 0.008;
}

function getPanels() {
  let totalWidth = PANEL_W * 3 + PANEL_GAP * 2;
  let startX = (CW - totalWidth) / 2;
  let topY = 74;
  let cy = topY + PANEL_H / 2;

  return [
    {label: 'Gaussian', sub: 'center weight', fn: 'randomGaussian()', cx: startX + PANEL_W / 2, cy, topY},
    {label: 'Exponential', sub: 'long tail', fn: '-log(random())', cx: startX + PANEL_W + PANEL_GAP + PANEL_W / 2, cy, topY},
    {label: 'Perlin', sub: 'continuous flow', fn: 'noise(x, y, t)', cx: startX + (PANEL_W + PANEL_GAP) * 2 + PANEL_W / 2, cy, topY}
  ];
}

function drawPanelGaussian(panel) {
  drawFrame(panel);
  push();
  translate(panel.cx, panel.cy);
  noStroke();
  for (let p of gaussianPts) {
    fill(0, 0, 100, 48);
    circle(p.x, p.y, 1.7);
  }
  pop();
}

function drawPanelExponential(panel) {
  drawFrame(panel);
  push();
  translate(panel.cx, panel.cy);
  noStroke();
  for (let p of exponentialPts) {
    if (abs(p.x) > PANEL_W / 2 || abs(p.y) > PANEL_H / 2) continue;
    fill(0, 0, 70, 46);
    circle(p.x, p.y, 2.1);
  }
  pop();
}

function drawPanelPerlin(panel) {
  drawFrame(panel);
  push();
  translate(panel.cx, panel.cy);

  for (let p of particles) {
    p.px = p.x;
    p.py = p.y;

    let n = noise(
      (p.x + PANEL_W / 2) * 0.014,
      (p.y + PANEL_H / 2) * 0.014,
      t
    );
    let angle = n * TWO_PI * 2;
    p.x += cos(angle) * 0.9;
    p.y += sin(angle) * 0.9;

    if (p.x > PANEL_W / 2) { p.x = -PANEL_W / 2; p.px = p.x; }
    if (p.x < -PANEL_W / 2) { p.x = PANEL_W / 2; p.px = p.x; }
    if (p.y > PANEL_H / 2) { p.y = -PANEL_H / 2; p.py = p.y; }
    if (p.y < -PANEL_H / 2) { p.y = PANEL_H / 2; p.py = p.y; }

    stroke(0, 0, 45, 72);
    strokeWeight(0.9);
    line(p.px, p.py, p.x, p.y);
  }
  pop();
}

function drawFrame(panel) {
  push();
  noFill();
  stroke(0, 0, 36, 80);
  strokeWeight(1);
  rect(panel.cx - PANEL_W / 2, panel.cy - PANEL_H / 2, PANEL_W, PANEL_H);

  if (showGuides) {
    stroke(0, 0, 55, 30);
    line(panel.cx, panel.cy - PANEL_H / 2, panel.cx, panel.cy + PANEL_H / 2);
    line(panel.cx - PANEL_W / 2, panel.cy, panel.cx + PANEL_W / 2, panel.cy);
    ellipse(panel.cx, panel.cy, 52, 52);
    ellipse(panel.cx, panel.cy, 104, 104);
  }
  pop();
}

function drawTitles(panels) {
  noStroke();
  textAlign(CENTER);

  for (let panel of panels) {
    fill(0, 0, 90, 95);
    textSize(12);
    text(panel.label, panel.cx, panel.topY - 24);

    fill(0, 0, 58, 90);
    textSize(10);
    text(panel.sub, panel.cx, panel.topY - 10);

    fill(0, 0, 45, 90);
    textSize(9);
    text(panel.fn, panel.cx, panel.topY + PANEL_H + 15);
  }

  textAlign(LEFT);
}

function drawHud() {
  noStroke();
  fill(0, 0, 82, 95);
  textSize(10);
  text('Random Showcase', 16, 24);

  fill(0, 0, 52, 90);
  text(`click: resample   D: guides ${showGuides ? 'on' : 'off'}`, 16, CH - 14);
}

function mousePressed() {
  resampleAll();
}

function keyPressed() {
  if (key === 'd' || key === 'D') {
    showGuides = !showGuides;
  }
}
LOCAL · 亂數模式
滑到此處載入本地 sketch。黑白三欄:左 Gaussian 聚中心,中 Exponential 拖長尾,右 Perlin 是會流動的粒子;點擊重新抽樣,D 切換輔助線。
三種分布,三種性格 Gaussian 把多數值留在中心。Exponential 有長尾,多數點靠近起點,少數點被推到遠處。Perlin 不急著抽下一個值,它保留連續性,讓時間與表面慢慢變形。
example 5.2
Noise family:Perlin / Worley / Curl
同樣是 noise,換一條規則,質地就會改變。Perlin 產生平滑連續紋理,Worley 產生細胞或石紋邊界,Curl 產生旋轉流場。
三種 noise,並排看質地

左邊沿用 5.1 的 Perlin 粒子。方向從 noise(x, y, t) 來,粒子會慢慢接著上一刻走,形成平滑連續的流動。

中間是 Worley noise。畫面先撒一些種子點,每個位置看自己離最近的種子有多遠。距離值鋪開後,會形成細胞、石紋、龜裂那種邊界感。

右邊是簡化的 Curl noise。先取 Perlin 場的梯度,再把方向旋轉 90 度。粒子不是往高低處衝,而是繞著場流動,所以會出現漩渦和回旋。

// Perlin:直接把 noise 變方向 let angle = noise(x, y, t) * TWO_PI * 2; // Worley:看每個點離最近種子多遠 let d = nearestSeedDistance(x, y); // Curl:把 noise 梯度轉 90 度 let gradient = createVector(dx, dy); let flow = createVector(gradient.y, -gradient.x);
perlin noise worley noise curl noise vector field
查看程式碼 · page-2 sketch.js
let panelW = 140;
let panelH = 320;
let panelGap = 20;
let topY = 74;
let t = 0;

let perlinParticles = [];
let curlParticles = [];
let worleySeeds = [];

function setup() {
  createCanvas(500, 500);
  pixelDensity(1);
  textFont('monospace');
  resetSketch();
}

function resetSketch() {
  noiseSeed(floor(random(100000)));
  randomSeed(floor(random(100000)));
  makeParticles();
  makeWorleySeeds();
}

function makeParticles() {
  perlinParticles = [];
  curlParticles = [];
  for (let i = 0; i < 170; i++) {
    perlinParticles.push(makeParticle());
    curlParticles.push(makeParticle());
  }
}

function makeParticle() {
  return {
    x: random(-panelW / 2, panelW / 2),
    y: random(-panelH / 2, panelH / 2),
    px: 0,
    py: 0
  };
}

function makeWorleySeeds() {
  worleySeeds = [];
  for (let i = 0; i < 14; i++) {
    worleySeeds.push({
      x: random(-panelW / 2, panelW / 2),
      y: random(-panelH / 2, panelH / 2),
      ox: random(1000),
      oy: random(1000)
    });
  }
}

function draw() {
  background(5, 7, 12);

  let totalW = panelW * 3 + panelGap * 2;
  let startX = (width - totalW) / 2;
  let cy = topY + panelH / 2;
  let cx1 = startX + panelW / 2;
  let cx2 = startX + panelW + panelGap + panelW / 2;
  let cx3 = startX + panelW * 2 + panelGap * 2 + panelW / 2;

  drawPerlinPanel(cx1, cy);
  drawWorleyPanel(cx2, cy);
  drawCurlPanel(cx3, cy);
  drawLabels(cx1, cx2, cx3);

  t += 0.008;
}

function drawPerlinPanel(cx, cy) {
  drawFrame(cx, cy);
  push();
  translate(cx, cy);
  for (let p of perlinParticles) {
    p.px = p.x;
    p.py = p.y;

    let n = noise(
      (p.x + panelW / 2) * 0.014,
      (p.y + panelH / 2) * 0.014,
      t
    );
    let angle = n * TWO_PI * 2;
    moveParticle(p, cos(angle) * 0.85, sin(angle) * 0.85);

    stroke(165, 210, 235, 145);
    strokeWeight(0.9);
    line(p.px, p.py, p.x, p.y);
  }
  pop();
}

function drawWorleyPanel(cx, cy) {
  drawFrame(cx, cy);
  push();
  translate(cx, cy);
  noStroke();

  let step = 5;
  for (let x = -panelW / 2; x < panelW / 2; x += step) {
    for (let y = -panelH / 2; y < panelH / 2; y += step) {
      let d1 = 999;
      let d2 = 999;
      for (let s of worleySeeds) {
        let sx = s.x + (noise(s.ox, t * 0.55) - 0.5) * 34;
        let sy = s.y + (noise(s.oy, t * 0.55) - 0.5) * 34;
        let d = dist(x, y, sx, sy);
        if (d < d1) {
          d2 = d1;
          d1 = d;
        } else if (d < d2) {
          d2 = d;
        }
      }

      let cell = constrain(d1 / 50, 0, 1);
      let edge = constrain((d2 - d1) / 22, 0, 1);
      let shade = 35 + (1 - cell) * 105 + (1 - edge) * 70;
      fill(65, shade, 120 + shade * 0.45, 210);
      rect(x, y, step + 1, step + 1);
    }
  }

  fill(245, 210, 150, 210);
  for (let s of worleySeeds) {
    let sx = s.x + (noise(s.ox, t * 0.55) - 0.5) * 34;
    let sy = s.y + (noise(s.oy, t * 0.55) - 0.5) * 34;
    circle(sx, sy, 2.8);
  }
  pop();
}

function drawCurlPanel(cx, cy) {
  drawFrame(cx, cy);
  push();
  translate(cx, cy);
  for (let p of curlParticles) {
    p.px = p.x;
    p.py = p.y;

    let v = curlVector(p.x, p.y);
    moveParticle(p, v.x * 1.25, v.y * 1.25);

    stroke(210, 180, 245, 145);
    strokeWeight(0.9);
    line(p.px, p.py, p.x, p.y);
  }

  stroke(140, 110, 220, 70);
  strokeWeight(0.7);
  for (let x = -50; x <= 50; x += 25) {
    for (let y = -125; y <= 125; y += 25) {
      let v = curlVector(x, y);
      line(x, y, x + v.x * 10, y + v.y * 10);
    }
  }
  pop();
}

function curlVector(x, y) {
  let e = 0.8;
  let scale = 0.012;
  let n1 = noise((x + e) * scale + 20, y * scale, t);
  let n2 = noise((x - e) * scale + 20, y * scale, t);
  let n3 = noise(x * scale + 20, (y + e) * scale, t);
  let n4 = noise(x * scale + 20, (y - e) * scale, t);

  let dx = (n1 - n2) / (2 * e);
  let dy = (n3 - n4) / (2 * e);
  let v = createVector(dy, -dx);
  v.mult(160);
  if (v.mag() > 0.001) v.normalize();
  return v;
}

function moveParticle(p, vx, vy) {
  p.x += vx;
  p.y += vy;
  if (p.x > panelW / 2) { p.x = -panelW / 2; p.px = p.x; }
  if (p.x < -panelW / 2) { p.x = panelW / 2; p.px = p.x; }
  if (p.y > panelH / 2) { p.y = -panelH / 2; p.py = p.y; }
  if (p.y < -panelH / 2) { p.y = panelH / 2; p.py = p.y; }
}

function drawFrame(cx, cy) {
  push();
  noFill();
  stroke(60, 70, 75);
  strokeWeight(1);
  rect(cx - panelW / 2, cy - panelH / 2, panelW, panelH);
  pop();
}

function drawLabels(cx1, cx2, cx3) {
  noStroke();
  textAlign(CENTER);
  fill(225);
  textSize(12);
  text('Perlin', cx1, topY - 22);
  text('Worley', cx2, topY - 22);
  text('Curl', cx3, topY - 22);

  fill(145);
  textSize(10);
  text('柔順地形', cx1, topY - 8);
  text('細胞 / 石紋', cx2, topY - 8);
  text('旋轉流場', cx3, topY - 8);

  fill(120);
  textSize(9);
  text('noise(x, y, t)', cx1, topY + panelH + 14);
  text('nearest seed distance', cx2, topY + panelH + 14);
  text('rotate gradient', cx3, topY + panelH + 14);

  textAlign(LEFT);
  fill(155);
  textSize(10);
  text('noise family   click: reseed', 16, 24);
  fill(120);
  text(`t ${t.toFixed(2)}`, 16, height - 12);
}

function mousePressed() {
  resetSketch();
}
LOCAL · NOISE FAMILY
滑到此處載入本地 sketch。左 Perlin 柔順流動,中 Worley 細胞石紋,右 Curl 旋轉流場;點擊重新 seed。
example 5.3
分布家族,從中心到長尾
Gaussian 把點推到中心。Exponential、Power law 把尾巴拉長。Poisson 讓事件變成一格一格的脈衝。每種分布都是一種形狀。
先看形狀,不急著談用途

分布描述的是密度怎麼落下。有的平均鋪開,有的往中心聚,有的拖出長尾,有的把事件切成一段一段。

  • Uniform。每個值機會一樣,柱子接近同高。1.1 的 random() 就是這個。
  • Gaussian。多數靠中心,少數在邊。尾巴退得很快。
  • Exponential。多數靠近起點,少數延伸到遠處。長尾從這裡出現。
  • Power law / Pareto。尾巴更長,最大值和中位數可能差很多。「長尾」這個詞在這裡最清楚。
  • Poisson。事件以整數出現,像一格一格的脈衝。

差別在尾巴。Gaussian 的尾巴退得很快,超出三倍擴散範圍後幾乎沒有點。Exponential 的尾巴慢一些。Power law 的尾巴幾乎不退,最大值可以是中位數的幾百倍幾千倍。

// 把 uniform 轉成不同分布的方法(inverse CDF) let u = random(); let expo = -log(u) / rate; // Exponential:長尾 let pow = pow(1 - u, -1 / alpha); // Power law:長尾 let pois = poissonSample(lambda); // Poisson:脈衝數 // 先看形狀,下一章再翻成創作參數

它們的共通點是,都可以從一個 uniform 經過一條公式換過來。這條換的方式叫 inverse CDF。這裡先記住形狀:中心、尾巴、脈衝。

exponential power law / pareto poisson inverse cdf

轉接 — Seed

同一個 seed 讓同一次隨機結果可以重現。這裡從分布轉向 Good Vibrations。
分布決定形狀。seed 決定這一次從哪裡開始,也決定它能不能被重播。
seed
PRNG
random sequence
artwork + metadata
seed bridge 5.5.1
PRNG
我把它當成一台小型亂數機。給它一個起點,它會吐出一長串數字。起點換了,整串就換。
pseudo-random sequence
seed bridge 5.5.2
Seed
seed 是起點。同一個 seed 會產生同一串結果。畫面、聲音、metadata 都可以回到同一個版本。
seed replay
seed bridge 5.5.3
Hash
在 Good Vibrations 裡,tokenData.hash 被讀成 seed。同一個 token,會回到同一張圖、同一組音、同一份 metadata。
為什麼是 hash?這個契約從哪裡來?

2020 年 11 月 27 日 Erick "Snowfro" Calderon 在以太坊上啟動 Art Blocks,首作 Chromie Squiggle 是 Project #0。他做了一件很簡單卻顛覆規則的事:藝術家把演算法上鏈,鑄造的人付錢時拿到一串 hex hash,這串 hash 直接餵進 PRNG 當 seed,演算法立刻生成對應的圖像。藝術家不能挑、收藏家不能挑,鏈決定。

這個契約解決了生成藝術一直以來的麻煩:哪一張作品才算「正版」?以前藝術家可以挑幾百張裡最漂亮的展出。Snowfro 把這個權利交還給算法。Tyler Hobbs 的 Fidenza、Dmitri Cherniak 的 Ringers、我自己的 Good Vibrations,都採用或延續這條契約。fxhash 2021 年用同樣的模式擴張到 Tezos,把這套設計變成這個世代的標準。

tokenData.hash Good Vibrations Art Blocks 2020
Seed 把一次抽樣固定下來。下一章開始,這串數字會變成位置、尺寸、顏色與稀缺。

章節六 — 轉成創作參數

前面看分布的形狀,這裡把它們翻成操作問題:位置、尺寸、顏色、稀缺。
要調的不是「要不要隨機」,而是哪個參數要集中、哪個參數要拉長尾、哪個參數要保持連續。
example 6.1
位置
位置可以平均散開,也可以靠近重心。需要流動感時,讓 Perlin noise 接手方向。
position gaussian perlin noise
example 6.2
尺寸
尺寸可以集中在一個穩定範圍,也可以拉出長尾,讓少數物件突然變大。
scale exponential long tail
example 6.3
顏色
顏色可以在主色附近變化,也可以用門檻切出少數不同色相。
color palette rare hue
example 6.4
稀缺
稀缺在這裡正式進來:把少數 trait 放在尾巴或門檻後面,讓它真的會發生,但不常發生。
rarity probability feature
章節六是一張參數表:位置怎麼散、尺寸怎麼變、顏色怎麼選、稀缺如何設定。
這些參數接到同一個 seed,就能在每次重播時回到同一個版本。

章節七 — Good Vibrations:用機率控制圖像與聲音

位置、尺寸、顏色、稀缺,在這裡變成一組可重播的視覺與聲音系統。
Good Vibrations 先把 token hash 轉成 seed,再讓線段、旋律、音色、metadata 從同一組亂數展開。
hash
seed
visual traits
sound events
metadata
good vibrations 7.1
Seed
tokenData.hash 被切成數字,餵進 xorshift,變成 seed。Random 和 RND 每次吐出的序列都一樣。同一個 token,會回到同一張圖、同一組音。
對應:章節零 · 可重播
tokenData.hash seed xorshift Random / RND
good vibrations 7.2
位置
defult() 決定 MelodyNum、線段方向、長度、位置與距離。average() 和 MelodydDistX/Y 讓物件不只是散開,也會靠近某些重心。
對應:章節六 · 位置
MelodyNum average() MelodydDistX/Y geometry
good vibrations 7.3
稀缺
colorSetting() 把色系寫成 weighted threshold。Ocean、Sunset、Forest 是常態;Cosmological 和 Monochromical 只在 rc 跨過 0.92、0.97 之後出現。
對應:章節六 · 稀缺
palette rc > 0.92 rc > 0.97 weighted threshold
good vibrations 7.4
聲音
Melody 有半徑、形狀、角度、速度、亮度層與縮放層。它們在畫面中移動,intersection() 找到線段交點,交點就變成聲音事件。
對應:事件 · 聲音事件
intersection() frqArray waveforms
code summary
同一份 hash 的幾種讀法
我把視覺結構和聲音結構一起寫進 metadata。Surface、GeoPos、MelodyNum、LineNum、OscSine、OscSquare、OscTriangle、OscSawtooth、Ring,都是程式執行後記錄下來的特徵。
對應:metadata · 視覺與聲音結構
tokenData.hash -> seed -> Random / RND // 可重播 seed -> defult() -> average() / MelodydDistX/Y // 位置 seed -> colorSetting() -> rc > 0.92 / rc > 0.97 // 稀缺 Melody + line -> intersection() -> note / length / waveform // 事件 features[] -> Surface / GeoPos / Osc* / Ring // metadata
features metadata score
作品案例 · GOOD VIBRATIONS
滑到此處載入本地 GV 子頁。500 × 500 視窗保留作品連結與互動說明。

Coda — 機率是材料

回到頁首那句:亂數不是失控。
使用亂數不等於把作品交給運氣。亂數提供初始變化來源。

Seed 讓隨機結果可以重現。分布決定哪些結果常見、哪些結果少見。Rarity 定義少數事件的出現條件。Metadata 記錄結果,讓後續使用者可以重播、辨認與查詢。

機率不是省略決策,而是生成系統中分配可能結果的方法。