利用 box-shadow 畫出任何圖案

『身為滯日台人,在武漢肺炎期間感受到了學會日文的好處』
「?」
『至少去別的國家時,可以用亞洲臉孔假裝日本人,不用因為說中文而被誤認為中國人…』

前言

大約在兩年前我曾經寫過一篇文章介紹如何用 CSS 繪圖 - 用 CSS 畫畫的小技巧,該文章的最後我有稍微提到我們能夠利用 CSS3 的 box-shadow 屬性來製造出 Pixel 風格的圖案。然而,所有圖案不都是由 pixel 組成的嗎?如果我們能夠用 box-shadow 畫出 Pixel Art,那只要 Pixel 數量足夠,size 夠細緻,應該是能夠繪製出任何圖形的吧?

不過在用 CSS 畫畫的小技巧這篇文章中,所製作的是比較簡單的文字,透過直接編輯 box-shadow 還在可接受範圍中,但如果是想要繪製複雜一點的人物角色,例如鋼鐵人src

iron-man

這如果一格一格手動對照,然後撰寫 box-shadow,比登天還難,更別提想繪製出比 Pixel Art 細緻一點的圖案了。

網路上對於 box-shadow 的運用,大多圍繞在 Pixel Art 的實作,例如 Una Kravets 的部落格 介紹了如何使用 SCSS 與陣列來產生 CSS Pixel Art;上面鋼鐵人原圖也是利用相同原理Pixelator 則是讓你能線上繪製自己喜歡的 Pixel Art,並且產生出對應的 box-shadow

好在 Codepen 上高手如雲,被我發現一篇利用 Angular 實作圖片轉 box-shadow 的版本,實作方式很有意思,我用 svelte 改寫了一個版本,今天就來分享一下實作細節!

先看個成果:

Gif Demo:
gif-demo

Live Demo:

可以下載這個範例圖來上傳,效果會比較好:demo-mario

Box-Shadow

在開始前,先複習一下 box-shadow,CSS3 的 box-shadow 屬性可以設定多個值,每個值代表著一個 box-shadow 的 x 位移(x-offset),y 位移(y-offset),陰影模糊半徑(shadow blur radii),陰影擴散半徑(shadow spread radii) 和顏色(color)。

由於 允許設置多個值可控制 X 與 Y 位移 這兩個特質,box-shadow 非常適合用來組合成圖片,尤其是 Pixel Art。以黑白相間的棋盤為例:

1
2
3
4
5
6
<div style="
width: 40px;
height: 40px;
box-shadow: 0 40px #000, 40px 0 #000;
border: 1px solid #000;
"></div>

box-shadow-explain

依照這個原理,就能組合成複雜一點的 Pixel Art:

但說實話,要手動撰寫 box-shadow 來組合出這個小愛心,大概就去掉半條命了,還是得依靠 Pixelator 來繪製並產生 CSS。

圖片轉 box-shadow 實作原理

在複習完 box-shadow 組成圖片的原理後,應該不難推斷出圖片轉 box-shadow 的做法。

概念上就是先定義出一個 grid system,將圖片切割成一塊一塊的單位,接著計算出每個單位區塊的 x-offset 與 y-offset,然後放入對應顏顏色,這樣就能組合出一個 unit 所對應的 box-shadow 值,依此類推把每個單位區塊都轉換完即可。

實作上的步驟比較繁瑣一些,但概念是相同的:

  • 利用 URL.createObjectURL(event.target.files[0]); 將圖片檔案轉換成 Image 物件。
  • 在 image onload 時,透過 canvas 2d context 的 drawImage() 函式將圖片繪製到 canvas 上。
  • 接著再以 canvas 2d context 的 getImageData 取得一個以一維陣列存放的圖片資訊。
  • 遍歷該一維陣列內的圖片資訊,組合出 box-shadow 的值。

上述步驟中,最關鍵的就是最後一點,canvas 2d context 的 getImageData 函式會回傳一個一維陣列 - Unit8ClampedArray,裡面包含了圖片每個 unit 的 RGBA 值(值段區間為 0 ~ 255)。

利用這個一維陣列,我們就可以知道載入的圖片有多少 unit(grid system),每個 unit 又各自是什麼顏色,進而推算出 box-shadow 每一個值的 x-offset、y-offset 與顏色。這也是為何我們需要先將圖片繪製到 Canvas 的原因。

關鍵程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const buildPixelArt = (pixelSize = 1, image, canvas, canvasContext) => {
const width = image.width;
const height = image.height;

canvas.width = width;
canvas.height = height;
canvasContext.drawImage(image, 0, 0);

const boxShadow = [];
const { data: imageData } = canvasContext.getImageData(0, 0, width, height);

for (let i = 0, n = imageData.length; i < n; i += 4) {
var a = imageData[i + 3];
if (a > 0) {
const row = Math.ceil((i + 4) / 4 / width - 1);
const col = (i + 4) / 4 - row * width + 1;
boxShadow.push(
col * pixelSize +
"px " +
row * pixelSize +
"px " +
getColor(imageData[i], imageData[i + 1], imageData[i + 2], a / 255)
);
}
}

Unit8ClampedArray 陣列裡面,每四個 indices 為一單位,分別為該 unit 的 RGBA 值,所以在迴圈中我們以 4 為遞增單位,並以此為計算二維平面中 rowcol 的基礎。

計算出一維陣列內每個 unit 在二維平面上的行與列後,個別乘上定義好的 pixelSize,就能算出該 unit 在 box-shadow 值中的 x-offset 與 y-offset,然後聯同顏色值一起 push 到 boxShadow 陣列中。

最後利用預先寫好的 css template,將 boxShadow 整合進去即可產生需要的 css style:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const generatedCss =
"#pixel-art {\n" +
" height: " +
height * pixelSize +
"px;\n" +
" width: " +
width * pixelSize +
"px;\n" +
"}\n" +
"#pixel-art:after {\n" +
' content: "";\n' +
" position: absolute;\n" +
" width: " +
pixelSize +
"px;\n" +
" height: " +
pixelSize +
"px;\n" +
" box-shadow:\n" +
" " +
boxShadow.join(",\n ") +
";\n" +
"}";
return generatedCss;

載入產生的 CSS 後,就可以看到我們上傳的圖片重新以 box-shadow 的形式被重組在頁面上,單單一個 div 就能繪製出任何圖形!蠻酷的吧!

完整程式碼請到 CodeSandbox 上翻閱,大部分邏輯都在 PixelArtArea.svelte 元件與 buildPixelArt.js 這隻檔案,其餘 svelte 部分的程式碼也很好理解,不過我是第一次用 svelte,若有使用不當的地方歡迎指教!

注意事項

利用 box-shadow 繪圖基本上沒什麼實質意義,就只是好玩而已,千萬不要把這用到正式環境,效能之差會把使用者端的瀏覽器搞當機的。這次的範例也只能吃得下像素較小的圖片,若是上傳了較大的檔案,打開 Devtool 時就會發現你的頁面 crash 了…
另外,產生完的 CSS,範例中我是直接 append 到 head 下,所以若是在沒有重整頁面的狀況下上傳別的圖片,就會再度 append 新的 css 進去,久了以後 head 也會越來越肥。

結論

CSS 真的很有趣,能做出許多意料之外的事,雖然絕大多數沒什麼用處,但這種技術上的創意應用所帶來的興奮感,正是繁忙於日常的開發者們所需要的吧!

資料來源

  1. Una Kravets 的部落格
  2. Convert an image to CSS Box Shadows
  3. iron man