用 Canvas 做科技藍圖網格背景:呼吸亮點與沿格線行走的光痕
- Canvas
- 動畫
- 前端
這個站的背景是一張「工程藍圖」:近黑的藍底、細格線、交叉點上的十字記號,少數亮點緩慢呼吸,偶爾有光沿著格線走過、在交叉點轉個彎。它不是圖片,是一個全螢幕的 <canvas>,因為只有程式化繪製才能讓這些元素動起來。記錄幾個實作上的關鍵決定。
靜態層與動態層分離
格線、上千個十字、微光點,每一幀都重畫太浪費。所以把不會動的部分畫在一個離屏 canvas上,只在視窗改變大小或切換主題時重建;每一幀真正的工作只有:
ctx.drawImage(staticLayer, 0, 0, W, H); // 把靜態層整張貼上
// 再疊上會動的:呼吸亮點、光痕
貼一張圖遠比畫一千個十字便宜,逐幀成本因此低到可以忽略。
穩定亂數:讓 resize 不重洗圖樣
哪些交叉點常亮、哪些格子有呼吸點,是「隨機」決定的。但如果用 Math.random(),視窗每縮放一次整個圖樣就重洗一次,很出戲。解法是用格座標當種子的雜湊函數:
const rand = (x: number, y: number, salt: number) => {
let h = (x * 374761393 + y * 668265263 + salt * 1442695041) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
同一個格子永遠得到同一個亂數,圖樣看起來是隨機的,行為卻是確定的。salt 參數讓同一格可以取出多個互不相關的值——是否亮起、呼吸相位、週期長短,各用一個 salt。
呼吸亮點
約一成的格子中央有呼吸點,每顆的相位和週期(2.6 到 6 秒)都來自上面的雜湊,所以閃爍是錯落的。亮度就是一條 sine:
const wave = 0.5 + 0.5 * Math.sin((t / d.period) * Math.PI * 2 + d.phase);
畫的時候先鋪一個大而淡的圓當光暈,再點一顆小而亮的核心,兩個圓就有發光感,不需要昂貴的 shadowBlur。
沿格線行走的光痕
畫面上同時有五條光痕,像電路板上的訊號。每條的狀態只有位置、軸向速度,和一串「轉彎點」:頭部沿格線前進,走到交叉點時擲骰子決定要不要左轉或右轉;尾跡則是從頭部往回走 150px 的路徑,逐段畫透明度遞減的線性漸層。
有個細節值得記:尾跡末端要沿路徑收攏,而不是永遠留著出生點,否則一條直線行走的光痕永遠有一個點留在畫面裡,「整條離場後重生」的判斷就永遠不會成立。這是我實際踩到的 bug。
尊重使用者偏好
prefers-reduced-motion: reduce 時,整個動畫迴圈不啟動,只畫一張靜態的網格(呼吸點固定在中間亮度)。背景是氛圍,不該是負擔。