我聽過一種說法:用亂數的人比較懶,「電腦在幫你決定」。這個說法把決策面看小了。
傳統繪畫的決策面是「這一筆畫在哪」。寫機率的決策面是「什麼常出現、什麼偶爾出現、什麼前後相連」。一個是物件,一個是分布。我做的事不比畫一張畫的人少,只是工作對象不同。
兩個極端都不會是作品。完全寫死,只是一張畫;完全亂數,是白噪聲,沒人會在上面簽名。藝術家在中間挑位置。挑出來的那個位置就是分布的形狀,它比任何單一張輸出更能代表我。
用機率做美術不是新事。六十年前 Vera Molnár 在 IBM 主機上跑迴圈,Frieder Nake 把指令打到 plotter 上畫圖,Sol LeWitt 把規則寫在牆上讓助理執行。當代 fxhash 與 Art Blocks 上的生成藝術,是同一個母題的延續。
沒變的是核心立場:規則就是作品,分布就是簽名,單一作者卻多元輸出。變的是材料的來源與分發方式。當 seed 從藝術家的時鐘換成區塊鏈的 hash,責任就轉移了:藝術家不能挑喜歡的版本,只能把分布設計好,讓每一張 mint 出來的都成立。
random(N) 會在 0 到 N 之間回傳一個數值。重複幾千次去畫點,畫布會被沒有明顯方向的雜訊鋪滿。
John von Neumann 1949 年在 Monte Carlo 研討會上講了一段話,1951 年收進《Monte Carlo Method》論文集:「任何用算數方法產生隨機數的人,當然是處在罪惡狀態。」電腦其實做不出真正的亂數,只能用一個確定性的函數演出隨機。當時是純數學家的自嘲,七十年後變成了生成藝術的全部基礎。
空白不是另一種亂數,而是多一個門檻。位置先被抽出來,再決定要不要畫。門檻設 1.0 全部留下,設 0.3 大約剩三成,設 0.05 只剩零星幾顆。
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);
}
}
先把 random(N) 想成一台抽號機:給它一個最大值 N,它就在 0 到 N 之間隨手丟一個數字回來,每個數字的機會都一樣。
巢狀的意思是抽兩次。第一次抽出一個數字 A,把 A 當成第二次的最大值,再抽一次。第二次的範圍會被第一次卡住。
大數字要連中兩次籤才會出現,小數字幾乎每次都有機會。整體分布就被往「小」的那一邊拉,越靠近 0 越密集,越靠近最大值越稀疏。畫面上的點也因此從均勻變成漸層。
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);
}
}
random() * R 會讓中心偏密;sqrt(random()) * R 才接近整個面積的均勻。
random() * R。半徑是平均的,面積不是。中心會比較擠。角度平均繞一圈,半徑從 0 到 R 平均抽。這是半徑均勻,不是面積均勻。
random() * R 會讓一半的點落在 0 到 R/2。那塊內圈只占整個圓 1/4 的面積,所以中心自然變密。
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);
}
}
1.0 - ... 反轉。點被推向外圍,圓盤變成光環。角度不變,只改半徑這行:
巢狀亂數偏小,前面加上 1.0 -,小值被翻到接近 1。半徑被推遠,中間空出來,外圈留下來。
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);
}
}
sqrt(random()) * R 把半徑往外補。不是為了做光環,而是讓每一圈拿到和面積相稱的點。角度不變,半徑換成這一行:
sqrt 把半徑往外補。半徑一半的內圈只占 1/4 面積,所以也只該拿到 1/4 的點。
這三張圖只看一件事:
random() * R:半徑均勻,中心密。1.0 - ...:半徑反轉,外圈亮。sqrt(random()) * R:面積修正,圓盤均勻。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);
}
}
把點用 random() 撒上畫布,會看到結團與空洞。沒有壞掉,點只是彼此不避開。
Poisson disk sampling 只多加一條規則:太靠近舊點的候選點不收。這不是更「隨機」,而是更接近人眼期待的均勻。
看起來均勻,不等於數學上的均勻。
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();
}
1.5 用 sqrt(random()) 修正半徑。Rejection sampling 改用另一種方法:先在外面的正方形均勻取樣,再檢查樣本是否位於圓內,不在圓內的樣本就丟掉。
圓裡留下的點仍然均勻。代價是會浪費一部分樣本,圓盤大約丟掉 21.5%。
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();
}
高斯亂數有中心位置,也有擴散值。多數點靠近中心,少數點慢慢退到外圍。
x = randomGaussian(centerPosition, spreadValue); // 中心位置決定靠近哪裡,擴散值決定散開多遠
Carl Friedrich Gauss 1809 年出版《Theoria Motus》時,為了解釋天文觀測誤差,把這條鐘形曲線寫進公式。後人才把它命名為「高斯分布」。其實 Abraham de Moivre 1733 年就提早算出來了,只是當時沒人在意。
1991 到 2001 年流通的德國 10 馬克紙鈔正面,印著高斯本人、鐘形曲線、以及他工作過的哥廷根。一張紙鈔同時放數學家跟分布函數,在貨幣設計史上很罕見。這也讓高斯分布成為大眾可見的科學圖像。
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();
}
randomGaussian() 抽到的數字,會繞著一個中心晃。中心位置決定點雲圍繞哪裡,擴散值決定散開多遠。
這裡只需要掌握一個重點:高斯分布會把多數樣本集中在中心,少數樣本分布在外圍。
中心位置把山搬到哪裡,擴散值把山揉成什麼形狀。畫面上的「聚焦感」是擴散值寫出來的。
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();
}
如果每一格都用 random(),每一禎都會重新抽樣,前後數值沒有關係,畫面看起來只是雜訊。
把 random() 換成 noise(i * 空間, t * 時間),整排柱子的高度會出現連續變化。原因是 noise 不是重新抽樣,而是從連續噪聲場取值(3.2 會展開)。t 慢慢往前走時,取樣位置也跟著平移;相鄰位置的值差不多,所以前後的高度差很小。
spaceScale 控制空間頻率:值越大,相鄰柱子差越多。timeScale 控制時間頻率:值越大,動得越快、越神經質。上方那條歷史曲線記錄了中央那一根柱子隨時間的值。它畫出來不是一連串突刺,而是一條平滑曲線。整排柱子的連續變化來自這種取樣方式。
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;
}
名字叫 noise,很容易誤會它跟 random() 是同一家。其實它們做的事完全不同。
random():每叫一次都重抽,前後沒有關係。noise(0.5):今天叫、明天叫、換一台電腦叫,永遠回同一個值。它不是序列,它是一個函數。輸入是一個位置 x,輸出是一個落在 0 到 1 之間的數字。你可以把它想成一張看不見的長條地圖。地圖在格子上先預先放好隨機方向的小箭頭,這些叫梯度向量。給一個 x,它先看 x 落在哪兩個格子之間,再用兩端的箭頭做平滑插值,算出一個值。
所謂的「連續性」是從這裡來的。不是它記得上一刻,是它跟上一刻同樣去查那張地圖,地圖本身就連續,所以結果也連續。換成藝術語言:random 是擲骰,noise 是讀譜。
Ken Perlin 1985 年發表這個函數,最早是為了 1982 年《Tron》的視覺特效寫的,1997 年拿到奧斯卡技術成就獎。它能變成 noise(t) 做時間動畫、noise(x, y) 做地形、noise(x, y, t) 做演化的雲,都是因為這個性質:給同樣輸入永遠給同樣輸出。可以採樣,可以倒帶,可以拼接。
章節一 1.1 的 random() 是擲一次少一次的籤。Perlin 是另一條材料線,不在那條線的延伸上。
地形、雲、火、海浪、水紋通常同時包含大尺度輪廓、中尺度變化與小尺度細節。單一尺度的 noise 只能描述其中一層。
用單一次 noise(x) 只能拿到一個尺度。若要加入多尺度細節,就需要疊加多層 noise。先給 noise 兩個參數:
noise(x * f)。f 越大,等於把 x 拉開來看,波就變密。noise(...) * a。a 越大,這層擾動越強。標準做法:第一層提供主要輪廓,f = 1、a = 1。下一層 f 乘 2 變兩倍密、a 乘 0.5 變一半強。再下一層再乘一次。一般疊 4 到 6 層。這個疊加的結果叫 fractal Brownian motion,簡稱 fBm。
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,等於在頻譜上加一段高頻。
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();
}
Perlin 在格子上放梯度向量再插值,適合生成雲狀的平滑紋理。把「怎麼放、怎麼算」改掉,視覺特徵也會改變。藝術家工具箱裡常用的另外三種:
挑哪一種,取決於需要的視覺效果。需要連續平滑的紋理,可以選 Perlin 或 Simplex。需要分割成塊、有邊界感,可以選 Worley。需要流場方向,可以選 Curl。它們也可以混合使用,例如用 Perlin 位移 Worley 種子、用 Curl 控制粒子位置,再用 Perlin 控制顏色,產生多層次的紋理。
把一段訊號拆成不同頻率的疊加,就是頻譜。低頻是慢慢起伏的大區塊,高頻是密集的細抖。每種噪聲就是把能量分到不同頻段的不同配方。
random()。把 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 早十年。
1.6 的 Poisson disk 是空間版的 blue noise,3.3 的 fBm 落在 pink 附近,5.5 的 PRNG 累加做出的軌跡是 brown。選擇噪聲類型,也是在選擇畫面的頻譜分布與視覺特徵。
左邊那團是 randomGaussian()。多數點聚在中心,越往邊越稀,沒有點會跑很遠。視覺效果是中心集中的點群。
中間那團是 -log(random()),幾何上是 Exponential 分布的半徑。中心爆滿,邊緣有少數零星點,那是長尾。多數值靠近起點,少數值被推到很遠。
右邊那群一直在動的是 noise(x, y, t) 推動的粒子。每一格的方向不是抽籤,是查表。前一刻的方向決定下一刻往哪走,所以粒子畫出來是流線而不是亂飛。時間維度被放進來了。
同一個畫面,三個面板,三種視覺特徵。這一章先用黑白比較它們的分布差異。
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;
}
}
左邊沿用 5.1 的 Perlin 粒子。方向從 noise(x, y, t) 來,粒子會慢慢接著上一刻走,形成平滑連續的流動。
中間是 Worley noise。畫面先撒一些種子點,每個位置看自己離最近的種子有多遠。距離值鋪開後,會形成細胞、石紋、龜裂那種邊界感。
右邊是簡化的 Curl noise。先取 Perlin 場的梯度,再把方向旋轉 90 度。粒子不是往高低處衝,而是繞著場流動,所以會出現漩渦和回旋。
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();
}
分布描述的是密度怎麼落下。有的平均鋪開,有的往中心聚,有的拖出長尾,有的把事件切成一段一段。
random() 就是這個。差別在尾巴。Gaussian 的尾巴退得很快,超出三倍擴散範圍後幾乎沒有點。Exponential 的尾巴慢一些。Power law 的尾巴幾乎不退,最大值可以是中位數的幾百倍幾千倍。
它們的共通點是,都可以從一個 uniform 經過一條公式換過來。這條換的方式叫 inverse CDF。這裡先記住形狀:中心、尾巴、脈衝。
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,把這套設計變成這個世代的標準。