說到夏天,就想到海邊;說到海邊,就想到日劇裡的西瓜跟煙火。一束束的煙火短暫但繽紛,燃燒自己的生命點燃絢麗的光譜,珍惜每次綻放都是不同的樣貌。最近(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 協助整理




