哈囉我是今年初開始在 fxhash 上發表作品的 eziraros。
最近的作品是一隻用程式(p5.js)畫的章魚,這篇文章記錄了部分製作過程。還沒看過章魚本魚的話可以先點這連結去看一下。
會開始製作這個章魚,是在 Vogue 的 Youtube 頻道看到介紹法國演員 Mathilde Warnier 的影片,然後去找到他的 Instagram,最後看到了這張章魚照片。(因為沒有授權我就不貼在文中了)
因此靈光一閃「我要用程式畫這個!」。Google 了幾張圖片存在漂亮的整理型 app Milanote 裡,我就開始實際製作了。
以下是大略的製作步驟
Head
和 Leg
classhead
和 legs
(裝著八個 Leg 的陣列)head
和 legs
的 draw
,各自完成頭和腳的畫面渲染Frame
classBubble
class補充給對這類型 generative art 不熟悉的人
這是一個逐格繪製的作品,也就是說,並不是在繪製前就先算好整體,而是一格一格即時運算後把最新的結果繪製在上一個畫面上。
以下面這個結果為例子
如果我們只看單格運算的話就會變這樣
以下記錄了 12 點
在腳的 class 裡,我用了 createGrphics
讓每隻腳都有自己的畫布,各自渲染完再放置到主畫布上,因此在主要的 canvas 裡的 draw
內容是像這樣
for (let i = 0; i < legs.length / 2; i++) {
legs[i].draw();
legs[legs.length - 1 - i].draw();
if (i == 0) {
head.draw();
}
}
bubbles.do('draw'); // 'do' is my custom function for array
我寫了兩個 class 來畫一隻腳,一個叫 Leg
,一個叫 LegNode
。一個 Leg
會有數個 LegNode
。Leg
儲存了最新的位置、尺寸、方向,在繪圖過程中,每隔幾個 frame 就會產生新的 LegNode
,來儲存當下的位置、尺寸、方向。輪廓線的產生則是把兩個 LegNode
的兩條外公切線相連得到。
為了讓輪廓線顯得不死板,並不是直接用 line()
把線畫起來,而是在兩個點中產生無數個小點,給予隨機的大小和透明度。這招我是從小鴨 Orr Kislev 身上學的。(謝謝小鴨!)他還做了很棒的動畫解說。
章魚吸盤有兩排,我一開始就預設了最後面的兩隻腳要兩排都露出來,其他腳則是依照「長在內側」為主要原則,比如右邊的腳(index < 4
)往右伸(direction.x > 0
)時長在下面,往下伸時長在左邊,往左或往上伸時不長。另外還有一個「露出度」的設定,露出度是一個會根據 frameCount
漸增或漸減的數值,當超過一定數字後,外圍的吸盤就不顯示,利用兩個吸盤的位置和壓縮程度的變化即可產生立體感。吸盤的角度則是用該 LegNode
儲存的方向來取得。這邊比較特別的是,畫橢圓與其用 ellipse
不如用 scale(1, n)
搭配 circle
比較好掌握
在 Leg
裡儲存了 position
(vec2), direction
(vec2), rotation
(float) 三個參數,每隔 10 個 frame rotation
都會小幅度的隨機變化,然後加在 direction
上,normalize direction
後,再加在 position
上。
if (frameCount % 10 == 0) {
this.rotation += fxRandom(-1, 1);
}
this.dir.rotate(this.rotation).normalize();
this.pos.add(this.dir);
同時,我把每個 frame 的 rotation
都存在一個陣列中(記錄最新數筆),藉由比對該陣列的變化可以判斷腳是不是持續往同個方向旋轉中,如果是的話,我就可以加以反轉它的方向
const n = 50; // the length of cache
this.rotationCache.push(this.rotation);
if (this.rotationCache.length > n) {
this.rotationCache.shift();
}
if (this.rotationCache.every(d => d > 0) || this.rotationCache.every(d => d < 0)) {
this.rotation *= -1;
}
}
用無數個小點來完成的,我把原本使用 p5 的 circle(x, y, size)
的地方改成了自己的 circleNoise(x, y, size)
,在 circleNoise
中,會產生數十個點分布在圓的範圍裡,藉由調配不同明度的點的分佈數量,就可以得到不同的效果
for (let i = 0; i < dotLength; i++) {
radius = fxRandom(0, this.size / 2);
x = fxRandom(-1, 1) * radius;
y = Math.sqrt(radius * radius - x * x, 2) * fxRandomSign();
pg.circle(center.x + x, center.y + y, fxRandom(2, 3));
}
translate(width/2, height/2);
scale(-1, 1);
translate(-width/2, -height/2);
頭是整個作品最困難的地方,採用了和腳一樣的畫法,然後在特定 frame 上改變旋轉的角度來完成。在透明的版本中最能看出頭的畫法。
Frame
class 存了 x
, y
, width
, height
, frameWeight
,另外在 conifg 裡有個 depth
的參數(0 是擋住全部的腳,8 是完全沒擋住)。leg 的 index
可以換算成 depth
,來確定有沒有被擋住,如果有被擋住的話,出界就不畫了。如果是抽到 Frame 2 的狀況,出界就用 erase()
把被擋住的部份擦掉。
我在 Leg
裡用了兩次 createGrphics
來產生兩個畫布,一個用於框前,一個用於框後。如果判定原本在框後而且出了框的話,就把要畫的畫布改成框前的畫布。
泡泡我選擇不採用正圓形或橢圓形,而是一個比較隨機的十邊形,我認為這樣可以讓畫面比較有機
canvas.beginShape();
for (let i = 0; i < 10; i++) {
radius = this.size * fxRandom(1, 1.4);
angle = i / 10 * 360;
x = canvas.sin(angle) * radius;
y = canvas.cos(angle) * radius * this.compressY;
canvas.vertex(x, y);
}
canvas.endShape(canvas.CLOSE);
上面有個參數 compressY,是用來讓泡泡拉長變形用的
以往我比較喜歡用隨機的方式挑色相,比如先 fxRandom(360)
挑中一個主要顏色,然後再 加減某個數字(比如 120 或 200)來當輔助色,不過這次(心血來潮)完全用指定的方式挑顏色。
我先建立了幾個分類(最後建立了 13 個)
const categorySettings = [
{ name: CATEGORY_BLACK },
{ name: CATEGORY_BLACK2 },
{ name: CATEGORY_WHITE },
...
];
然後色相(最後挑了 8 個)
const hueNameMap = [
{
name: COLOR_YELLOW,
hue: 45,
briBias: 0,
},
{
name: COLOR_BLUE,
hue: 210,
briBias: 0,
},
//....
];
這樣製作一組 presets 時,就可以產出數個不同色相的版本
hueNameMap.forEach(m => {
let hue = m.hue.length > 1 ? fxRandom(m.hue[0], m.hue[1]) : m.hue;
// pushPreset is a custom function, first param is the name of target category
pushPreset(CATEGORY_BLACK, {
name: capitalize(m.name),
hue,
briBias: -50,
noiseBias: -1,
sat: 0,
bgColor: [hue, 10, 80],
bubbleColor: [0, 0, 100, .5],
frame2Color: ['#fff'],
});
});
hueNameMap.forEach(m => {
pushPreset(CATEGORY_SMOKE, {
name: capitalize(m.name),
hue: m.hue.length > 1 ? fxRandom(m.hue[0], m.hue[1]) : m.hue,
briBias: 0 + m.briBias,
noiseBias: -2,
opacity: .06,
sat: 25,
bgColor: [0, 0, 20],
fgColor: [0, 0, 20],
bubbleColor: ['#fff'],
frameColor: ['#fff'],
satFade: -.5 + (m.satFadeBias || 0),
});
});
最後共產生 106 組 presets。
在製作過程中,fps 曾經在 10 以下。我用「只繪製被變更部分」這個方法來提高效能。每隻 Leg
都會有一個 pixelArea
this.pixelArea = {
x: this.pos.x,
y: this.pos.y,
width: 20,
height: 20,
};
當 Leg
開始移動時,就不斷確認目前繪畫的部分是否超出 pixelArea
,如果超出,就更新 pixelArea
。最後在 draw
的時候,就不是放整張圖,而是先取得實際有畫圖的 pixel。
before:
masterCanvas.image(this.canvas, 0, 0);
after:
this.pixel = this.canvas.get(pa.x, pa.y, pa.width, pa.height);
masterCanvas.image(this.pixels, pa.x, pa.y);
頭部也做了差不多的處理,做完後 fps 就在 30 左右。
我在創作上不是那種會在製作前就想清楚的類型,反而比較喜歡讓生成的結果帶著我,每次寫 creative code 都像經歷一場旅行一樣(所以 code 往往會亂七八糟)。雖然不知道為了達成這些效果,我所採取的方案是不是最佳的——應該大部分不是,但總之也是一種方法,希望對你有幫助。
另外,二級市場的地板價下降了,歡迎去挑隻喜歡的章魚喔!
歡迎追蹤我的 twitter,我的作品的最新資訊都會公布在上面:@s_r_r_z_