互動藝術程式教學簡介
這次的直播要來製作具有逼哩逼哩效果的電路,當中可以分為三部分內容,分別為背景的格線、連接的線條以及細節上的裝飾,在創作過程中會各自去調整,有可能調整了線條後,再去修改背景,而後又再回去修改線條,透過來來回回的在細節上修正來完成最終的作品。
主要會用到的 API :
- push() / pop() 保存與還原畫布的狀態
- 用 loadPixels() 來製作材質
- 使用 Array.from() 來設定線條的基本屬性
- 使用 forEach() 來決定線條要如何呈現
還是新手?想要快速上手p5.js請來看p5.js 快速上手:互動網頁教學
1. 建立網格點點
一開始我們這邊先用雙層的 for 迴圈來建立垂直與水平間隔上都是 50 的格線
let ww, hh function setup() { createCanvas(800, 800); background(100); ww = int(width/50) hh = int(height/50) } function draw() { for(let i=0; i<ww; i++){ for(let o=0; o<hh; o++){ push() translate(i*50,o*50) rect(0,0,10,10) pop() } } }
2. 隨機連彩色的線
- colors 設定: 在顏色上,雖然可以直接使用指定內建的顏色來使用,但這邊我們使用外部的選色工具來協助。首先先連到 coolors 這個配色的網站,當開始使用的時候,可以發現上面的網址列會呈現像是
https://coolors.co/ed6a5a-f4f1bb-9bc1bc-5ca4a9-e6ebe0
,仔細發現這串網址後面的字串就是顏色的代碼,這時只要將後面的顏色字串複製下來,接著經過字串的處理後就可以變成讓使用的顏色了。 - 標點建立與連線,分為二個部分
- 首先是先
Array.from
來建立 50 個隨機的位置點的陣列,如果我們將它列印出來的話,會產生出像是[start: {x: 13, y:8}, end {x: 3, y:5}]
的陣列 - 接下來用
forEach
來將 links 這個陣列的每一個元素來去提取出來。不過這裡有一個小問題,就是現在 links 所存的數字是表示方格上的第幾個點,而非實際的位子,所以這邊要使用getPos()
來將位子的點轉換成實際的位子。舉個例子來說,像是原本點是 (3,5),而這個點在畫布上的實際位子是 (150, 250)。在位子轉換後,就使用line(st.x, st.y,ed.x, ed.y)
將線條連接起來。
- 首先是先
這邊可以建議大家稍微停下來思考一下透過 Array.from() 來建立陣列的細節以及 links.forEach() 提取陣列來做操作的架構,因為在後續我們會增加一些屬性,像是波的波型,亦或是波的振幅都是一樣透過在 Array.from() 中進行屬性的設定,接著在 links.forEach() 中進行實際的繪製。對了,這裡除了畫線條之外,也在後面加上一個黑色的方塊,來讓我們很像電路板的作品跟最後面的背景來做區別
let links = [] let ww, hh let colors = "fff-437f97-849324-ffb30f-fd151b".split("-").map(a=>"#"+a) function getPos(gridIndex){ return createVector(gridIndex.x*50,gridIndex.y*50) } function setup() { createCanvas(800, 800); background(100); ww = int(width/50) hh = int(height/50) links = Array.from({length: 50}, (d,i) => ({ start: createVector(int(random(ww)), int(random(hh))), end: createVector(int(random(ww)), int(random(hh))), color: random(colors) })) print(links) } function draw() { //黑底 fill(0) rectMode(CORNER) rect(0,0,width,height) stroke(255) strokeWeight(3) rectMode(CENTER) // 網格 for(let i=0; i<ww; i++){ for(let o=0; o<hh; o++){ push() translate(i*50,o*50) rect(0,0,10,10) pop() } } stroke(255) strokeWeight(5) //線條 links.forEach(link=>{ stroke(link.color) let st = getPos(link.start) let ed = getPos(link.end) line(st.x, st.y, ed.x, ed.y) }) noStroke() }
3. 將線條加上光暈/特定點放大
目前線條看上去有點太硬了,感覺死板板的,所以這裡透過加上模糊的效果來讓整體上看起來柔和一些,使用 drawingContext
來去設定線條模糊的顏色 (當然是跟實體直線的線條顏色一樣) 以及模糊的程度。設定完後就把它加在繪製線條的位子之前。
除了調整線條外,我們也調整一下背後的點點。如果點點在垂直以及水平的方向都是五的倍數的話,就將那個點設定的更加明顯一些,這樣子背景看上去就稍微比較有韻律感了。
// 網格 for(let i=0; i<ww; i++){ for(let o=0; o<hh; o++){ push() translate(i*50,o*50) // 這裡透過去看看是不是五的倍數後,也去更動了 rect 的參數數值 let ww = (i%5==0 && o%5==0) ? 10 : 3 rect(0,0,ww) pop() } } stroke(255) strokeWeight(5) //線條 links.forEach(link=>{ // 使線條有光暈 drawingContext.shadowColor = color(link.color); drawingContext.shadowBlur = 30; stroke(link.color) let st = getPos(link.start) let ed = getPos(link.end) line(st.x, st.y, ed.x, ed.y) })
4. 移動的 Siri 波型
這裡要持續的去改變線條,將原本的直線變成波型,同時讓它動起來,看起來更活潑一些。
在波的繪製上,首先要先找出兩個重要的數字來協助我們,一個是距離,另一個則是角度。在距離上是使用 dist()
來取得,而角度稍微比較複雜一些些,必須要先將終點減去起點得到向量後,再用 heading()
來取得角度。這裡有個要注意的是,heading()
它的單位是 radians 弧度,並非一般我們認知的 360度 / 90 度的單位。
有了距離以及角度後,就可以開始畫波浪囉。一開始先將畫筆移動到起始點的位子,並根據所得出的角度旋轉面向終點的位子,接著就開始透過 for 迴圈一小段一小段來畫出現線條了。這裡有個地方要注意的是,由於每次畫線條的時候,都會透過 translate(st.x, st.y)
去移動畫筆,而間接影響到畫布的設定,所以這邊要記得用 push() / pop ()
給包起來。
let st = getPos(link.start) let ed = getPos(link.end) // 透過一條條短短的區段去畫線 // rr 計算起點與終點的位子 let rr = st.dist(ed) // 計算夾角 let ang = ed.copy().sub(st).heading() // 因為每一次都必須要 translate 到畫線的起點,所以這裡我們要用 Push()/Pop() 包起來 push() translate(st.x, st.y) rotate(ang) beginShape() for(var i=0; i<rr; i+=2){ vertex(i, sin(i/5)*5) } endShape() pop()
接著要使靜態的東西動起來,最常使用的小技巧就是 – 加入 frameCount
,因為它是系統上會隨著改變的系統變數,但是全部都只加上 frameCount
的話,這樣就會全部都線條一起同個頻率一起扭動,這樣的畫面有點不太自然,所以加上了 freq
屬性,並且放到 vertex()
,這樣線條就會隨著自己的頻率各自搖擺囉。
// 新增屬性 freq links = Array.from({length: 50}, (d,i) => ({ start: createVector(int(random(ww)), int(random(hh))), end: createVector(int(random(ww)), int(random(hh))), freq: random(1,50), color: random(colors) })) // 加上 frameCount 與 link.freq 來畫線條,產生動態 push() translate(st.x, st.y) rotate(ang) beginShape() for(var i=0; i<rr; i+=2){ vertex(i, sin((i+frameCount)/link.freq)*5) } endShape() pop()
再來就是要浪波看起來就像是 Siri 般,兩邊頭尾的地方相對於中間的地方振福較小,所以增加了變數 ratio 後,再相乘到 vertex 的第二個參數中。一開始你看到可能會想,這個變數看上去也太複雜了吧,不過實際上老闆在製作過程中也是嘗試了非常多不同的組合跟方式才慢慢試出來的。大家也可以玩玩看不同的效果,找到你最喜歡的樣貌。
for(var i=0; i<rr; i+=2){ // 新增 ratio 後,乘到 vertex,使線條的頭跟尾會的波會變比較小 let ratio = 5*(rr/2-abs(i-rr/2))/rr vertex(i, sin((i+frameCount)/link.freq)*5*ratio) }
5. 外觀調整
目前波的部分處理到一個段落了,接下來要來調整背景,以及替它加上材質。
雖然說先前已經有對後面的點點進行設定了,但是看上去似乎有點不太明顯,所以除了大小,在顏色上也要有所區別。這裡先透過 (i%5==0 && o%5==0) 判定同時垂直與水平都是5倍數的點才是 isGridPoint ,接著在下面設定如果是 isGridPoint 的話,顏色會比較深,反之其餘會比較淡。
// backgroung grid for(let i=0; i<ww; i++){ for(let o=0; o<hh; o++){ push() translate(i*50,o*50) // 跟 ww 的概念一樣,判定同時垂直與水平都是5倍數的點才是 isGridPoint let isGridPoint = (i%5==0 && o%5==0) // 這裡透過去看看是不是五的倍數後,變去更動了 rect 的參數數值 let ww = (i%5==0 && o%5==0) ? 10 : 3 // 是 isGridPoint 的話,顏色會比較深,反之其餘會比較淡 stroke(isGridPoint?255:100) rect(0,0,ww) pop() } }
在加材質上,可以分為三大區塊,分別為定義材質、設定材質以及使用材質,這三個都是設定在不同地方,這是要特別注意的地方。
// 定義材質,在全域的位子 let overAllTexture // 設定材質,放在 setup 裡面 overAllTexture=createGraphics(width,height) overAllTexture.loadPixels() // noStroke() for(var i=0;i<width+50;i++){ for(var o=0;o<height+50;o++){ overAllTexture.set(i,o,color(100,noise(i/3,o/3,i*o/50)*random([0,50,100]))) } } overAllTexture.updatePixels() // 使用材質,在 draw 裡面,而且通常是放在最尾巴的地方 // 這邊要注意的是要加上 push 以 pop push() blendMode(MULTIPLY) image(overAllTexture,0,0) pop()
6. 加入不同種類的波形
除了現在的 sin 波外,這裡再來多加上一個方波,這個算是波裡面的一個**屬性,**所以與前面提到的一樣,當想要再多加上一個功能需要屬性時,就加在 Array.from()
中。這裡多加上 type 屬性,並且設定兩種波型,分別是 sin 波以及方波。
設定好了之後,就是到下面 vertex(i, sin((i+frameCount)/link.freq)*5)
的位子來畫波,可以觀察在影響波的形狀是透過設定 y 的數值,現在由於要來畫不同形狀的波,所以這裡把 y 位子的地方抽出來,設定名為 yy
的變數,接著根據不同的波型去設定 yy
的數值,最後在尾端再將yy
帶入 vertex()
中。
links = Array.from({length: 50}, (d,i) => { let start = createVector(int(random(ww)), int(random(hh))) let end: createVector(int(random(ww)), int(random(hh))) return { start, end, freq: random(1,50), color: random(colors), type: random(['sine','square']) } })
beginShape() for(var i=0; i<rr; i+=2){ let ratio = 5*(rr/2-abs(i-rr/2))/rr // yy 抽出來變成變數 let yy if (link.type=="square"){ yy=(i+frameCount + mouseY)%100<50?1:-1 }else{ yy = sin((i+frameCount + mouseX)/link.freq) } vertex(i, yy*5*ratio) } endShape()
7. 加入起始/結束點
加入起點與終點的部分不難,要注意的是,這段是要加在 links.forEach()={}
之中。
🔔 在這裡有個蠻重要的地方要提醒大家,由於 canvas 在開發的時候其實蠻吃效能的,所以可以嘗試將效能先關起來,另外也可以將線條的數量暫時調小,從原本的 50 調整成 30,這樣電腦才不會使負荷那麼大。
push() strokeWeight(5) ellipse(st.x,st.y,20,20) ellipse(ed.x,ed.y,20,20) pop()
到目前為止,作品上架構都已經完成得差不多了,接下來就是一些細節上的調整,讓整體更加具有科技感
8. 格線與方格
這裡要替背景加上格線,形成九宮格,而這九宮格的線條跟 isGridPoint 一樣,是以五的倍數去做繪製的。而方格部分則是將其旋轉後,製作出一個向外有如呼吸般的方格。
- 九宮格: 在畫九宮格上,可以細分為畫直線與橫線。這裡比較直觀的地方是畫橫線時是在第一層控制左右寬度的 for 迴圈,而畫橫線時,則是在第二層控制垂直的 for 迴圈中,但有個要注意的是,在第二層中不僅僅限制
o%5==0
,還多加了i==0
,原因在於,如果不加上的話,直線會因為上一層的 for 迴圈關係,而重複畫了好幾次。 - 方格: 在方格的三個設計 – 位子標示、旋轉以及呼吸燈的效果,製作上都不難,但這邊的前後位子順序相當重要,如果把旋轉擺放到最前面的話,這樣一來就會連同字也一個旋轉了,要避免這樣的狀況,除了可以用最簡單的方式就是把位子標示往前移,或是使用
push() / pop()
來保存與還原畫布的狀態。
// 網格 for(let i=0; i<ww; i++){ // 畫上橫線 if (i%5==0){ push() strokeWeight(1) stroke(255,100) noFill() line(0,i*50,(ww-1)*50,i*50) pop() } for(let o=0; o<hh; o++){ push() translate(i*50,o*50) let isGridPoint = (i%5==0 && o%5==0) let ww = (i%5==0 && o%5==0) ? 10 : 3 stroke(isGridPoint?255:100) //加上位子標示 if (isGridPoint){ push() noStroke() fill(255) textStyle(BOLD) textSize(15) text("("+i+","+o+")",15,10) pop() } // 是 isGridPoint 的話,旋轉 if(isGridPoint) rotate(PI/4) rect(0,0,ww) // 是 isGridPoint 的話,向外如呼吸般擴散的方格 if(isGridPoint){ noFill() stroke(255,100) strokeWeight(1) rect(0,0,ww*(3+sin((frameCount)/10 + i + o))) } pop() // 畫上直線 if (i==0 && o%5==0){ push() strokeWeight(1) stroke(255,100) line(o*50,0,o*50,(hh-1)*50) pop() } } }
9. 加入裝飾用的文字
既然前面都加上格點位子,那再來替線條加上各自的標示,顯示著是甚麼類型的波形以及編號,雖然這不太起眼,人們不一定會去細看,但是當畫面資訊量很多,而且很整齊的時候,那個裝飾加分的效果就出來了。
與先前一樣,要加上屬性就到上面的 links = Array.from({})
來做設定,由於文字上要做一點加工,所以 type 拉到上面去做宣告,接著在 label 上設定顯示線條的種類與編號。設定好後,就去下面地方寫 label 的樣式,這裡一樣要注意文字放的位子,這裡可以回頭看看第四章移動的 Siri 波型畫上線條的架構, label text 的位子要被放在移動到線條起始點的位子以及旋轉方向之間。
links = Array.from({length: 50}, (d,i) => { let start = createVector(int(random(ww)), int(random(hh))) let end: createVector(int(random(ww)), int(random(hh))) let type = random(['sine','square']) return { start, end, freq: random(1,50), color: random(colors), label: type + " #"+i, } })
// label text push() rotate(PI/8) rect(20,-5,2,2) noStroke() fill(255,180) text(link.label,15,10) pop()
// 因為每一次都必須要 translate 到畫線的起點,所以這裡我們要用 Push()/Pop() 包起來 push() translate(st.x, st.y) // label text 放的位子在這 rotate(ang) beginShape() for(var i=0; i<rr; i+=2){ vertex(i, sin(i/5)*5) } endShape() pop()
這是目前整體看上去的樣子,看上去稍微顯得有點雜亂,線條上比較偏向各自發展,所以接下來會針對波的長度與方向來修正。
10. 調整波的方向以及種類
在這裡有三個地方要調整,分別是波的行走方向、波形的種類以及改變波的振福
- 波的行走方向 : 為了限制距離及方向,要先新增 randomDelta,接著結束點的位子取決於起點加上 randomDelta 的數值。
- 波形的種類: 原本波的設定上是
random(['sine','square'])
,但老闆覺得多一點 sin 波比較好看,所以多增加了 sin 波的數量。 - 波的振福: 新增波的屬性 amp,設定好後在下方乘上 yy 的數值,這裡要寫成
yy*=link.amp
或是yy = yy * link.amp
都可以
links = Array.from({length: 50}, (d,i) => { let start = createVector(int(random(ww)), int(random(hh))) let randomDelta = random([-2,2,-5,5,-10,10]) // 結束點的位子取決於起點加上 randomDelta 的數值 let end = start.copy().add(createVector(random(randomDelta),random(randomDelta))) // 把 sine 與 square 的比例調整為 3比1 let type = random(['sine','sine','sine','square']) return { start, end, freq: random(1,50), color: random(colors), label: type + " #"+i, // 新增振幅 amp: random(1,3)*random(), } })
beginShape() for(var i=0; i<rr; i+=2){ let ratio = 5*(rr/2-abs(i-rr/2))/rr let yy if (link.type=="square"){ yy=(i+frameCount + mouseY)%100<50?1:-1 }else{ yy=sin((i+frameCount + mouseX)/link.freq) } // 乘上振幅 yy*=link.amp vertex(i, yy*5*ratio) } endShape()
11. 增加逼哩逼哩閃爍效果
這次的創作主題是與電有關,想像是與電有關的閃電或是電腦機台運作的時候,都會一閃一閃的,這邊就要來製造這樣的效果。因此要在繪製線條的外面再包一層 if ,限制在特定的情況之下才會顯示線條,而在 if 裡面所帶的參數有跟時間相關 frameCount,也有自行設定隨機的變數 activeMod ,以及 forEach 中的編號 linkId 。為了要能夠存取 linkId ,記得要設定在 forEach 的第二個參數。
links= Array.from({length: 50},(d,i)=>{ let start = createVector(int(random(ww)),int(random(hh))) let randomDelta = random([-2,2,-5,5,-10,10]) let end = start.copy().add(createVector(random(randomDelta),random(randomDelta))) let type = random(['sine','sine','sine','square']) return { start, end, freq: random(0,50)*random(), color: random(colors), amp: random(1,3)*random(), type, label: type + " #"+i, activeMod: random(50,100) } })
if((frameCount+linkId*30)%100<link.activeMod && random()>0.01){ beginShape() for(var i=0; i<rr; i+=2){ let ratio = 5*(rr/2-abs(i-rr/2))/rr let yy = sin((i+frameCount + mouseX)/link.freq) if (link.type=="square"){ yy=(i+frameCount + mouseY)%100<50?1:-1 } yy*=link.amp vertex(i, yy*5*ratio) } endShape() }
// 原本的 forEach 只有一個參數 links.forEach((link)=>{} // 加入第二個表示 index 的參數 links.forEach((link,linkId)=>{}
12. 加上文字
最後做一點修飾,加上在科幻電影裡面會出現白底黑字的效果在畫面上的左上方。在畫白底的時候可以透過 textWidth() 來幫助我們動態的依據文字來計算出長度。
// 加上白底黑字 push() translate(width-50,50) rotate(PI/2) textSize(14) fill(255) let tx1 = " System ?: " + frameCount/10 let tx2 = " Active Count: " + links.filter(link=>link.active).length rectMode(CORNER) rect(8,-5,textWidth(tx1)+5,15) rect(8,15,textWidth(tx2)+5,15) fill(0) text(tx1,10,5) text(tx2,10,25) pop()
結語
起初在創作上老闆其實也沒有打算想要做逼哩逼哩的效果,一開始也僅僅想是嘗試看看將線條隨意連連看隨呈現什麼樣的效果。是到後半段去讓背景的網格點更加明顯,以及加上逼哩逼哩的效果才看起來有電波訊號得感覺,與最後文字的畫龍點睛才讓它讓整體變得更有科幻感。
還意猶未盡?看看這一篇製作色散海葵的教學讓你功力再進階。
如果你因此對互動藝術程式創作產生興趣,歡迎加入老闆開的 Hahow 課程互動藝術程式創作入門,讓老闆跟你分享不同的創作!
互動藝術程式創作入門是為了不會程式的人設計的課程,課程中會帶你看看不一樣的作品,並從基礎引導大家一步步完成作品,透過每次的賞析、實作到修正作品,讓大家覺得寫 code 不是這麼難的事情,將這個過程想像成,拿一隻比較難的畫筆在進行創作,如果有機會使用它,便能夠做出和與眾不同的創作。
此篇直播筆記由幫手 阮柏燁 協助整理