大約在兩年前我曾經寫過一篇文章介紹如何用 CSS 繪圖 - 用 CSS 畫畫的小技巧,該文章的最後我有稍微提到我們能夠利用 CSS3 的 box-shadow
屬性來製造出 Pixel 風格的圖案。然而,所有圖案不都是由 pixel 組成的嗎?如果我們能夠用 box-shadow 畫出 Pixel Art,那只要 Pixel 數量足夠,size 夠細緻,應該是能夠繪製出任何圖形的吧?
不過在用 CSS 畫畫的小技巧這篇文章中,所製作的是比較簡單的文字,透過直接編輯 box-shadow
還在可接受範圍中,但如果是想要繪製複雜一點的人物角色,例如鋼鐵人src:
這如果一格一格手動對照,然後撰寫 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:
Live Demo:
可以下載這個範例圖來上傳,效果會比較好:
在開始前,先複習一下 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。以黑白相間的棋盤為例:
<div style="
width: 40px;
height: 40px;
box-shadow: 0 40px #000, 40px 0 #000;
border: 1px solid #000;
"></div>
依照這個原理,就能組合成複雜一點的 Pixel Art:
但說實話,要手動撰寫 box-shadow
來組合出這個小愛心,大概就去掉半條命了,還是得依靠 Pixelator 來繪製並產生 CSS。
在複習完 box-shadow
組成圖片的原理後,應該不難推斷出圖片轉 box-shadow
的做法。
概念上就是先定義出一個 grid system,將圖片切割成一塊一塊的單位,接著計算出每個單位區塊的 x-offset 與 y-offset,然後放入對應顏顏色,這樣就能組合出一個 unit 所對應的 box-shadow
值,依此類推把每個單位區塊都轉換完即可。
實作上的步驟比較繁瑣一些,但概念是相同的:
URL.createObjectURL(event.target.files[0]);
將圖片檔案轉換成 Image
物件。onload
時,透過 canvas 2d context 的 drawImage()
函式將圖片繪製到 canvas 上。getImageData
取得一個以一維陣列存放的圖片資訊。box-shadow
的值。上述步驟中,最關鍵的就是最後一點,canvas 2d context 的 getImageData
函式會回傳一個一維陣列 - Unit8ClampedArray
,裡面包含了圖片每個 unit 的 RGBA 值(值段區間為 0 ~ 255)。
利用這個一維陣列,我們就可以知道載入的圖片有多少 unit(grid system),每個 unit 又各自是什麼顏色,進而推算出 box-shadow 每一個值的 x-offset、y-offset 與顏色。這也是為何我們需要先將圖片繪製到 Canvas 的原因。
關鍵程式碼如下:
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 的 R
、G
、B
、A
值,所以在迴圈中我們以 4 為遞增單位,並以此為計算二維平面中 row
與 col
的基礎。
計算出一維陣列內每個 unit 在二維平面上的行與列後,個別乘上定義好的 pixelSize
,就能算出該 unit 在 box-shadow
值中的 x-offset 與 y-offset,然後聯同顏色值一起 push 到 boxShadow
陣列中。
最後利用預先寫好的 css template,將 boxShadow
整合進去即可產生需要的 css style:
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 真的很有趣,能做出許多意料之外的事,雖然絕大多數沒什麼用處,但這種技術上的創意應用所帶來的興奮感,正是繁忙於日常的開發者們所需要的吧!