炫酷粒子表白,雙十一脫單靠它了!
雙十一光棍節又要來臨了,每年這個時候都是本人最苦悶的時刻。日漸消瘦的錢包,愈發乾涸的雙手,雖然變強了,頭卻變涼了。今年一定要搞點事情!

最近聽女神說想談戀愛了,✧(≖ ◡ ≖) 嘿嘿,一定不能放過這個機會,給她來個不一樣的表白。

作為整天搞視覺化的前端攻城獅,最先想到的就是常玩的各種粒子。那麼咱們就一起來把這個粒子系統玩出花來吧。演示地址
用粒子組成文字
首先,咱們想下要如何將一系列的粒子組成一句表白呢?
實現原理其實很簡單,Canvas 中有個 getImageData 的方法,可以得到一個矩形範圍所有畫素點資料。那麼我們就試試來獲取一個文字的形狀吧。
第一步,用 measureText 的方法來計算出文字適當的尺寸和位置。
// 建立一個跟畫布等比例的 canvas const width = 100; const height = ~~(width * this.height / this.width); // this.width , this.height 說整個畫布的尺寸 const offscreenCanvas = document.createElement('canvas'); const offscreenCanvasCtx = offscreenCanvas.getContext('2d'); offscreenCanvas.setAttribute('width', width); offscreenCanvas.setAttribute('height', height); // 在這離屏 canvas 中將我們想要的文字 textAll 繪製出來後,再計算它合適的尺寸 offscreenCanvasCtx.fillStyle = '#000'; offscreenCanvasCtx.font = 'bold 10px Arial'; const measure = offscreenCanvasCtx.measureText(textAll); // 測量文字,用來獲取寬度 const size = 0.8; // 寬高分別達到螢幕0.8時的size const fSize = Math.min(height * size * 10 / lineHeight, width * size * 10 / measure.width);// 10畫素字型行高 lineHeight=7 magic offscreenCanvasCtx.font = `bold ${fSize}px Arial`; // 根據計算後的字型大小,在將文字擺放到適合的位置,文字的座標起始位置在左下方 const measureResize = offscreenCanvasCtx.measureText(textAll); // 文字起始位置在左下方 let left = (width - measureResize.width) / 2; const bottom = (height + fSize / 10 * lineHeight) / 2; offscreenCanvasCtx.fillText(textAll, left, bottom); 複製程式碼
咱們可以 appendChild 到 body 裡看眼

好的。同學們注意,我要開始變形了 [推眼鏡] 。
getImageData 獲取的畫素資料是一個 Uint8ClampedArray (值是 0 - 255 的陣列),4 個數一組分別對應一個畫素點的 R G B A 值。我們只需要判斷 i * 4 + 3 不為 0 就可以得到需要的字型形狀資料了。
// texts 所有的單詞分別獲取 data ,上文的 textAll 是 texts 加一起 Object.values(texts).forEach(item => { offscreenCanvasCtx.clearRect(0, 0, width, height); offscreenCanvasCtx.fillText(item.text, left, bottom); left += offscreenCanvasCtx.measureText(item.text).width; const data = offscreenCanvasCtx.getImageData(0, 0, width, height); const points = []; // 判斷第 i * 4 + 3 位是否為0,獲得相對的 x,y 座標(使用時需乘畫布的實際長寬, y 座標也需要取反向) for (let i = 0, max = data.width * data.height; i < max; i++) { if (data.data[i * 4 + 3]) { points.push({ x: (i % data.width) / data.width, y: (i / data.width) / data.height }); } } // 儲存到一個物件,用於後面的繪製 geometry.push({ color: item.hsla, points }); }) 複製程式碼
制定場景,繪製圖形
文字圖形的獲取方式以及搞定了,那麼咱們就可以把內容整體輸出了。咱們定義一個簡單的指令碼格式。
// hsla 格式方便以後做色彩變化的擴充套件 const color1 = {h:197,s:'100%',l:'50%',a:'80%'}; const color2 = {h:197,s:'100%',l:'50%',a:'80%'}; // lifeTime 禎數 const Actions = [ {lifeTime:60,text:[{text:3,hsla:color1}]}, {lifeTime:60,text:[{text:2,hsla:color1}]}, {lifeTime:60,text:[{text:1,hsla:color1}]}, {lifeTime:120,text:[ {text:'I',hsla:color1}, {text:':heart:',hsla:color2}, {text:'Y',hsla:color1}, {text:'O',hsla:color1}, {text:'U',hsla:color1} ]}, ]; 複製程式碼
根據預設的指令碼解析出每個場景的圖形,加一個 tick 判斷是否到了 lifeTime 切換到下一個圖形重新繪製圖形。
function draw() { this.tick++; if (this.tick >= this.actions[this.actionIndex].lifeTime) { this.nextAction(); } this.clear(); this.renderParticles(); // 繪製點 this.raf = requestAnimationFrame(this.draw); } function nextAction() { ....//切換場景 balabala.. this.setParticle(); // 隨機將點設定到之前得到的 action.geometry.points 上 } 複製程式碼

這樣咱們基本的功能已經完成了。
能不能再給力一點
說好的粒子系統,現在只是 context.arc 簡單的畫了一點。那咱們就來加個粒子系統吧。
class PARTICLE { // x,y,z 為當前的座標,vx,vy,vz 則是3個方向的速度 constructor(center) { this.center = center; this.x = 0; this.y = 0; this.z = 0; this.vx = 0; this.vy = 0; this.vz = 0; } // 設定這些粒子需要運動到的終點(下一個位置) setAxis(axis) { this.nextX = axis.x; this.nextY = axis.y; this.nextZ = axis.z; this.color = axis.color; } step() { // 彈力模型 距離目標越遠速度越快 this.vx += (this.nextX - this.x) * SPRING; this.vy += (this.nextY - this.y) * SPRING; this.vz += (this.nextZ - this.z) * SPRING; // 摩擦係數 讓粒子可以趨向穩定 this.vx *= FRICTION; this.vy *= FRICTION; this.vz *= FRICTION; this.x += this.vx; this.y += this.vy; this.z += this.vz; } getAxis2D() { this.step(); // 3D 座標下的 2D 偏移,暫且只考慮位置,不考慮大小變化 const scale = FOCUS_POSITION / (FOCUS_POSITION + this.z); return { x: this.center.x + (this.x * scale), y: this.center.y - (this.y * scale), }; } } 複製程式碼

大功告成!
既然是 3D 的粒子,其實這上面還有不是文章可做,同學們可以發揮想象力來點更酷炫的。
還有什麼好玩的
上面是將粒子擺成文字。那咱們當然也可以直接寫公式擺出個造型。
// Actions 中用 func 代替 texts { lifeTime: 100, func: (radius) => { const i = Math.random() * 1200; let x = (i - 1200 / 2) / 300; let y = Math.sqrt(Math.abs(x)) - Math.sqrt(Math.cos(x)) * Math.cos(30 * x); return { x: x * radius / 2, y: y * radius / 2, z: ~~(Math.random() * 30), color: color3 }; } } 複製程式碼
再把剛才文字轉換形狀的方法用一下
{ lifeTime: Infinity, func: (width, height) => { if(!points.length){ const img = document.getElementById("tulip"); const offscreenCanvas = document.createElement('canvas'); const offscreenCanvasCtx = offscreenCanvas.getContext('2d'); const imgWidth = 200; const imgHeight = 200; offscreenCanvas.setAttribute('width', imgWidth); offscreenCanvas.setAttribute('height', imgHeight); offscreenCanvasCtx.drawImage(img, 0, 0, imgWidth, imgHeight); let imgData = offscreenCanvasCtx.getImageData(0, 0, imgWidth, imgHeight); for (let i = 0, max = imgData.width * imgData.height; i < max; i++) { if (imgData.data[i * 4 + 3]) { points.push({ x: (i % imgData.width) / imgData.width, y: (i / imgData.width) / imgData.height }); } } } const p = points[~~(Math.random() * points.length)] const radius = Math.min(width * 0.8, height * 0.8); return { x: p.x * radius - radius / 2, y: (1 - p.y) * radius - radius / 2, z: ~~(Math.random() * 30), color: color3 }; } } 複製程式碼

完美 :stuck_out_tongue_closed_eyes:。
然後咱也可以用 drawImage 繪製圖片來代替 arc 畫點。

等等!!前面的效果總覺得哪裡不對勁,好像有些卡 。

優化小提示
- 分層。如果還需要增加一些其他的內容到 Canvas 中的話,可以考慮拆出多個 Canvas 來做。
- 減少屬性設定。包括 lineWidth、fillStyle 等等。Canvas 上下文是很複雜的一個物件,當你調它的一些屬性設定時消耗的效能還是不少的。arc 之類的畫圖方法也要減少。
- 離屏繪製。不用 arc 畫點,要怎麼辦。上面的延遲其實就是每次畫點時都呼叫了一遍 fillStyle、arc。但是當我們繪製圖片 drawImage 時,效能明顯會好上很多。drawImage 除了直接繪圖片外,還能繪製另一個 Canvas,所以我們提前將這些點畫到一個不在螢幕上的 Canvas 裡就可以了。
- 減少 js 計算,避免堵塞程序,可以使用 web worker。當然咱們目前的計算完全用不上這個。
我這使用了 3000 個粒子,對比下使用離屏繪製前後的幀率。

