生成章魚的製作過程
0xF7E1
March 14th, 2022

English version 日本語版

哈囉我是今年初開始在 fxhash 上發表作品的 eziraros。

最近的作品是一隻用程式(p5.js)畫的章魚,這篇文章記錄了部分製作過程。還沒看過章魚本魚的話可以先點這連結去看一下。

開始之前:為什麼是章魚?

會開始製作這個章魚,是在 Vogue 的 Youtube 頻道看到介紹法國演員 Mathilde Warnier 的影片,然後去找到他的 Instagram,最後看到了這張章魚照片。(因為沒有授權我就不貼在文中了)

因此靈光一閃「我要用程式畫這個!」。Google 了幾張圖片存在漂亮的整理型 app Milanote 裡,我就開始實際製作了。

Milanote 是整理大量情報時非常好用的工具
Milanote 是整理大量情報時非常好用的工具

製作過程

以下是大略的製作步驟

一開始是想做正面,製作途中改成做側面
一開始是想做正面,製作途中改成做側面
  1. 紙上作業:畫下章魚的基本結構,可以簡化成頭 - 八隻腳,畫完後決定程式的中心點在頭和腳的交界
  2. 實作:
    1. 建立主要 canvas
    2. 製作 HeadLeg class
    3. 在主要 canvas 把它們實體化成 headlegs (裝著八個 Leg 的陣列)
    4. 呼叫 headlegsdraw,各自完成頭和腳的畫面渲染
    5. 製作 Frame class
    6. 製作 Bubble class
    7. 設定 presets

補充給對這類型 generative art 不熟悉的人

這是一個逐格繪製的作品,也就是說,並不是在繪製前就先算好整體,而是一格一格即時運算後把最新的結果繪製在上一個畫面上。

以下面這個結果為例子

如果我們只看單格運算的話就會變這樣

難點和解法

以下記錄了 12 點

1. 讓腳有前後關係

在腳的 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
確保外圍的腳在後面,中間的腳在前面(Damien Hirst 風的示意圖)
確保外圍的腳在後面,中間的腳在前面(Damien Hirst 風的示意圖)

2. 產生輪廓線

我寫了兩個 class 來畫一隻腳,一個叫 Leg,一個叫 LegNode。一個 Leg 會有數個 LegNodeLeg 儲存了最新的位置、尺寸、方向,在繪圖過程中,每隔幾個 frame 就會產生新的 LegNode,來儲存當下的位置、尺寸、方向。輪廓線的產生則是把兩個 LegNode 的兩條外公切線相連得到。

紅色的點是 LegNode 的中心
紅色的點是 LegNode 的中心

為了讓輪廓線顯得不死板,並不是直接用 line() 把線畫起來,而是在兩個點中產生無數個小點,給予隨機的大小和透明度。這招我是從小鴨 Orr Kislev 身上學的。(謝謝小鴨!)他還做了很棒的動畫解說。

3. 吸盤的位置和角度

章魚吸盤有兩排,我一開始就預設了最後面的兩隻腳要兩排都露出來,其他腳則是依照「長在內側」為主要原則,比如右邊的腳(index < 4)往右伸(direction.x > 0)時長在下面,往下伸時長在左邊,往左或往上伸時不長。另外還有一個「露出度」的設定,露出度是一個會根據 frameCount 漸增或漸減的數值,當超過一定數字後,外圍的吸盤就不顯示,利用兩個吸盤的位置和壓縮程度的變化即可產生立體感。吸盤的角度則是用該 LegNode 儲存的方向來取得。這邊比較特別的是,畫橢圓與其用 ellipse 不如用 scale(1, n) 搭配 circle 比較好掌握

4. 腳的動作

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;
  }
}
以不同陣列長度為單位做檢查
以不同陣列長度為單位做檢查

5. 材質

用無數個小點來完成的,我把原本使用 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));
}
點點版
點點版

6. 改變方向

translate(width/2, height/2);
scale(-1, 1);
translate(-width/2, -height/2);

7. 頭

頭是整個作品最困難的地方,採用了和腳一樣的畫法,然後在特定 frame 上改變旋轉的角度來完成。在透明的版本中最能看出頭的畫法。

#85 https://www.fxhash.xyz/gentk/505131
#85 https://www.fxhash.xyz/gentk/505131

8. Frame

Frame 1
Frame 1

Frame class 存了 x, y, width, height, frameWeight,另外在 conifg 裡有個 depth 的參數(0 是擋住全部的腳,8 是完全沒擋住)。leg 的 index 可以換算成 depth,來確定有沒有被擋住,如果有被擋住的話,出界就不畫了。如果是抽到 Frame 2 的狀況,出界就用 erase() 把被擋住的部份擦掉。

Frame 2
Frame 2

9. 讓有些腳從被框擋住變成在框之前

我在 Leg 裡用了兩次 createGrphics 來產生兩個畫布,一個用於框前,一個用於框後。如果判定原本在框後而且出了框的話,就把要畫的畫布改成框前的畫布。

圖片中央的那隻腳展示了從框後到框前的狀況
圖片中央的那隻腳展示了從框後到框前的狀況

10. 製作泡泡

泡泡我選擇不採用正圓形或橢圓形,而是一個比較隨機的十邊形,我認為這樣可以讓畫面比較有機

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,是用來讓泡泡拉長變形用的

11. 色彩管理

以往我比較喜歡用隨機的方式挑色相,比如先 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。

12. 效能調整

在製作過程中,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);
紅色的是框後用的 canvas,藍色的是框前的
紅色的是框後用的 canvas,藍色的是框前的

頭部也做了差不多的處理,做完後 fps 就在 30 左右。

後記

我在創作上不是那種會在製作前就想清楚的類型,反而比較喜歡讓生成的結果帶著我,每次寫 creative code 都像經歷一場旅行一樣(所以 code 往往會亂七八糟)。雖然不知道為了達成這些效果,我所採取的方案是不是最佳的——應該大部分不是,但總之也是一種方法,希望對你有幫助。

另外,二級市場的地板價下降了,歡迎去挑隻喜歡的章魚喔!

歡迎追蹤我的 twitter,我的作品的最新資訊都會公布在上面:@s_r_r_z_

Arweave TX
huWAF3Fcwn6T9jST1uDPC6Ws28gpt906PG6FX1rgu4c
Ethereum Address
0xF7E15015D31e1Be374c21E6F1dE91147C8B5db88
Content Digest
VireGXSPQnmBTi9pP9K0Mi45BOHopDJ6SM8sBPDLL3s