說到夏天,就想到海邊;說到海邊,就想到日劇裡的西瓜跟煙火。一束束的煙火短暫但繽紛,燃燒自己的生命點燃絢麗的光譜,珍惜每次綻放都是不同的樣貌。最近(2021年8月)有一群日本Creative Coder在Processing Community Day時串連起社群,在Twitter上辦起虛擬的花火大會,透過各自的作品在版面上綻放了大大小小的煙火,替最近被疫情拉開實體距離的生活中,增添一些夏天的顏色。
讓我們抓住夏天的尾巴,一起用粒子系統與漸變顏色創作煙火吧 🎆
今天要使用的是OpenProcessing搭配p5.js函式庫的大禮包組合,如果對這兩個工具還不太熟悉在這篇文章可以看到更多介紹 👉🏻 p5.js 快速上手
讓我們用草稿規劃一下煙火的概念,如果要做以粒子為基礎、從中心炸開的煙火,應該是一顆粒子從畫面水平線的底部往上移動特定距離,在上方炸開很多不同的粒子、且粒子各自擁有不同的運動方向。
根據以上的概念,我們今天會切分為以下步驟來進行:
- 粒子系統
- 動態延伸(移動、爆炸分裂)
- 顏色變換
製作粒子系統
首先第一個步驟我們先完成煙火的核心——粒子系統,以單顆粒子的物理模型來說會有位置(P)、速度(V)、加速度(a)和顏色(Color)、大小(r)等變數。在OpenProcessing先把畫布設成1000×1000、黑色的夜空之後,另外新增一個Tab,用來放置我的們的Class particle
,在初始化時我們希望引入一些變數args
裡面帶入一些固定的參數做使用,如果使用者有特別設定,把使用者引入的參數args
蓋到預設值def
上,再把客製化後的設定值蓋到這個物件本體this
上。
//Tab2 class Particle { constructor(args){ let def = { p: createVector(0,0), //位置 v: createVector(0,0), //速度 a: createVector(0,0), //加速度 color: color('red'), //顏色 r: 10, //大小、半徑 } Object.assign(def,args) Object.assign(this,def) } }
接下來介紹兩個關鍵的method分別是draw()
和update()
,分別負責顯示和更新,切分成兩個部分是為了在更新的時後不動到最初始的顯示,把邏輯層區分出來,這樣對模組化的製作與管理也比較容易。
在同一個 tab2,先來處理draw()
,push()
會保留目前的drawing style、而pop()
則會回復這些設定,兩個必須搭配使用。假設粒子移動到this.p
位置、顏色this.color
、尺寸是this.r
。
class Particle{ ... draw(){ //顯示 push() noStroke() translate(this.p) //processing可以只給向量,不一定要x,y fill(this.color) circle(0,0,this.r) pop() } update(){ //資料更新 } }
在主要的程式定義一個陣列particles
把粒子都裝進去,我們來初始化一顆粒子試試看,讓objParticle
根據剛剛的規範來製作,放在外面並用let比較不會有全域打架的問題objParticle = new Particle()
,成像的位置在畫布寬高一半處,這時在畫布中間就可以看到我們千辛萬苦的第一顆隨機色粒子啦。
let particles = [] let objParticle function setup() { createCanvas(1000, 1000); background(0); objParticle = new Particle({ p: createVector(width/2,height/2), r: 100, color: color(random(255),random(255),random(255)) }) } function draw() { objParticle.update() objParticle.draw() }
單一粒子動態軌跡
在update()
處理位置(P)、速度(V)、加速度(a)和大小(r)的變化,每一顆的位置都會加上速度、而速度都會加上加速度。
update(){ //資料更新 this.p.add(this.v) this.v.add(this.a) this.r*=0.993 //由大變小 }
有物理模型後,我們來處理速度(v)和加速度(a),這邊介紹一個函式random2D()
,可以在vector上隨機產生一個2D的向量。套用到速度上隨機產生方向,在爆炸初始時粒子會往原先的方向衝再往下掉,乘5倍讓初始速度>加速度,設定加速度為0.1,這樣我們就得到單一粒子的運動軌跡,也就是煙火炸開時的單一根花瓣。
製作束狀粒子群
有了一個粒子後,我們可以來做一束的煙火,用這些粒子加起來做成陣列。我們先把objParticle
拿進來,用for
迴圈做出50個粒子objParticle
,再push
到陣列particles
內。在draw
的地方把清單一個一個抓出來,我們就得到初步的美麗煙火了。
let particles = [] function setup() { createCanvas(1000, 1000); background(100); fill(0) rect(0,0,width,height) for(let i=0;i<50;i++){ let objParticle = new Particle({ p: createVector(width/2,height/2), v: p5.Vector.random2D().mult(5), a: createVector(0,0.1), r: 20, color: color(random(255),random(255),random(255)) }) particles.push(objParticle) } } function draw() { fill(0,5) //留下煙火軌跡 rect(0,0,width,height) for(let objParticle of particles){ objParticle.update() objParticle.draw() } }
模組化並自動發射
完成了一束煙火後,我們要接著做此起彼落發射的夏日花火祭,把發射的動作包成一個function firework
就可以重複呼叫它。包起來後先在setUp
呼叫一次,也可以引入位置參數(p),如果該參數有值就顯示、沒有則出現在畫面中央。接著設定他產生的頻率,每隔100個frame放一次煙火。
大家可以發現我們調高了煙火的數量,從50到100個,在這個情況中為了預防畫面因為生成的東西越來越慢,我們來消除超出畫面的煙火,用filter()
留下小於畫面的物件。
仔細觀察煙火的粒子除了大小不同外,每顆的初始速度也不同,如果初始速度相同就會較規則,看起來像下垂的海葵(?),這邊用random()
給予任意值處理,煙火的顏色也調整成HSB模式,相較於RGB模式有更彈性的明度暗度可以使用,色調的變數請參考下圖。
let particles = [] function firework(p){ push() let baseHue = random(300) colorMode(HSB) for(let i=0;i<100;i++){ let hue = random(0,120) let objParticle = new Particle({ p: p || createVector(width/2,height/2), //有位置p時取用p,沒有時就從畫面中央 v: p5.Vector.random2D().mult(random(1,10)), a: createVector(0,0.1), r: random(40), color: color((baseHue+hue)%360,360,360) //避免>360的數字都是紅色 }) particles.push(objParticle) } pop() } function setup() { createCanvas(1000, 1000); background(100); fill(0) rect(0,0,width,height) firework() } function draw() { fill(0,5) //留下煙火軌跡 rect(0,0,width,height) for(let objParticle of particles){ objParticle.update() objParticle.draw() } if (frameCount%100==0){ firework() } particles = particles.filter(obj=>obj.p.y<height) //留下小於畫面的物件 fill(0) rect(0,0,100,50) //計算畫面中粒子數 fill(255) textSize(20) text(particles.length,50,50) }
基礎版:滑鼠觸發煙火
接下來加入mouse的互動。首先註解掉自動產生的frameCount
,每當滑鼠按壓時就在該位置呼叫firework
,為了避免重複參數造成順序混亂,在呼叫時把p包成一個物件{p}
,記得setUp
時也要回傳一個空的物件firework({})
。
這時候會產生一個問題,因為p引入firework
被所有的粒子共用,所以有幾顆粒子他就會被update幾次,我們用copy()
複製p出來給當下的粒子,避免所有的粒子共用位置。再加入fireR
、praticleR
等參數做出隨機粒子大小和隨機煙火大小。
function mousePressed(){ firework({ p: createVector(mouseX,mouseY), fireR: random(1,100), //煙火的大小 particleR: random(1,10) //粒子的大小 }) }
function firework({p, fireR, particleR}){ push() let baseHue = random(300) colorMode(HSB) for(let i=0;i<100;i++){ let hue = random(0,120) let objParticle = new Particle({ p: (p && p.copy()) || createVector(width/2,height/2), //複製新的位置給當下的粒子,讓它重複100遍 v: p5.Vector.random2D().mult(random(1,fireR || 5)), a: createVector(0,0.1), r: particleR || random(40), color: color((baseHue+hue)%360,360,360) }) particles.push(objParticle) } pop() }
進階篇:用聲音觸發煙火
做完基礎煙火互動後,如果可以用聲音來觸發煙火那一定很酷,p5.js裡有一些關於「聲音」相關的函式,今天介紹的是Mic Input可以截取電腦麥克風的聲音,我們先開啟p5.sound的library。
套用官方語法在setUp加上input = new p5.AudioIn()開始取用聲音。
let input function setup() { createCanvas(1000,1000); background(100); fill(0) rect(0,0,width,height) firework({}) input = new p5.AudioIn() input.start() }
在draw()
加上觸發條件,如果有聲音,即在畫布上放煙火。這樣我們聲控的煙火大會就大功告成啦!
function draw() { let volume = input.getLevel() let speaking = voulume>0.15 fill(0,8) //留下煙火軌跡 rect(0,0,width,height) for(let objParticle of particles){ objParticle.update() objParticle.draw() } particles = particles.filter(obj=>obj.p.y<height) //留下小於畫面的物件 fill(0) rect(0,0,400,200) fill(255) textSize(20) text(speaking,50,50) if (speaking){ firework({ p: createVector(mouseX,mouseY), fireR: random(1,5), particleR: random(1,10) }) } }
小試身手
做完上面的煙火後,這邊提供幾個大家可以繼續嘗試看看的方向,希望大家可以長出各式各樣的煙火,讓夏天的夜晚更為熱鬧!
- 粒子的顏色漸層
可以在def
的地方新增endColor: color('yellow')
,利用lerpColor()
這個漸變函式在Update()
指定顏色的變化跟階數。
this.color = lerpColor(this.color, this.endColor, 0.05) //每次變換0.05
- 扭曲的粒子運動軌跡
在粒子translate
的時候如果根據sin/cos偏移,可以做出扭曲的煙火效果會更漂亮。
curve: random(5), curveFreq: random(2,40), translate(this.p.x+sin(this.p.y/this.curveFreq)*this.curve,this.p.y+cos(this.p.x/this.curveFreq)*this.curve)
- 製造煙火的霧氣
透過在Particle
中的draw()
增加一些半透明且半徑較大的粒子,來增加模糊的光影。Color
先複製一份避免動到原先的設定,用函示setAlpha()製作透明度。
let copyColor = color(this.color.toString()) copyColor.setAlpha(10) for(var i=0;i<100;i+=10){ //重複畫圓形 fill(copyColor) circle(0,0,this.r*i/20) }
- 混合模式
加入混合效果,blendMode()
調整顏色呈現。
push() blendMode(SCREEN) for(let objParticle of particles){ objParticle.update() objParticle.draw() } pop()
- 效能處理
設定當r小於某個半徑時就不顯現,減少效能負擔。
particles = particles.filter(obj=> obj.p.y<height && obj.r>0.01 )
以上就是這次的教學,成品請參考這裡,希望大家還玩得開心,那我們就下次再見啦!
也許你對互動生成式藝術比較有興趣?來看看老闆的《互動藝術程式創作入門》課程,跟著將近兩千位同學一起把程式碼當作畫筆創作,或是先看看這篇文章,欣賞同學們完成的作品吧!
此篇直播筆記由幫手 Jeudi Kuo 協助整理