画像をCSSの box-shadow に変換してピクセルアートを表示する

はじめに

CSSだけでドット絵っぽいものを表示できないかなと思って調べていたところ、box-shadow を大量に並べることで1px単位の絵を描ける、というやり方を見かけた。
せっかくなので、画像を読み込んでそのままCSSに変換する簡単なツールを作ってみた。

やっていることはかなり単純で、画像を1ピクセルずつ走査して、色がある場所だけ box-shadow に変換している。
小さめの画像であれば、思ったより素直にピクセルアートっぽい見た目を再現できて面白い。

今回は、この変換スクリプトの使い方と、どういう仕組みでCSSを組み立てているのかをまとめておく。

環境

Node.js 22.22.1
npm 10.9.4
Jimp 1.6.0
HTML / CSS

作ったもの

構成はかなりシンプルで、以下の3ファイルだけで動く。

.
├── img2css.js
├── index.html
└── package.json

img2css.js で画像をCSSに変換し、その結果を output.css として保存する。
あとは index.html からその output.css を読み込めば、ブラウザ上でピクセルアートを表示できる。

index.html
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="output.css">
</head>
<body>
  <div id="pixel"></div>
</body>
</html>

表示用の要素は #pixel ひとつだけで、実際の見た目は全部CSS側で作る。

img2css.js
const { Jimp } = require('jimp');

async function img2css(imagePath) {
  const image = await Jimp.read(imagePath);
  const shadows = [];

  image.scan(0, 0, image.width, image.height, (x, y, idx) => {
    const r = image.bitmap.data[idx + 0];
    const g = image.bitmap.data[idx + 1];
    const b = image.bitmap.data[idx + 2];
    const a = image.bitmap.data[idx + 3];

    if (a > 0) {
      shadows.push(`${x}px ${y}px 0 rgb(${r},${g},${b})`);
    }
  });

  const css = `
#pixel {
  width: 1px;
  height: 1px;
  box-shadow: ${shadows.join(',\n    ')};
}`;

  console.log(css);
}

img2css(process.argv[2]);
package.json
{
  "dependencies": {
    "jimp": "^1.6.0"
  }
}

使い方

まずは jimp をインストールする。

npm install jimp

そのあと、変換したい画像を引数に渡して実行する。

node img2css.js [変換対象のファイル] > output.css

たとえば sample.png を変換するなら以下のようになる。

node img2css.js sample.png > output.css

生成された output.cssindex.html から読み込めば、その画像が box-shadow の集合として描画される。

なお、この方法はピクセル数が増えるほど box-shadow の行数も増えるため、大きい画像には向かない。
実際に使うなら、アイコンやドット絵のような小さめの画像を対象にするのが良さそうだった。

仕組み

やっていることは大きく分けると以下の4段階になる。

  1. Jimp.read() で画像を読み込む
  2. scan() で全ピクセルを走査する
  3. RGBA値を取り出して、透明でないピクセルだけを採用する
  4. 各ピクセルを box-shadow の1要素に変換して連結する

Jimp で画像を読む

まずは Jimp.read() で対象画像を読み込む。
これで画像サイズやピクセルデータにアクセスできるようになる。

const image = await Jimp.read(imagePath);

scan() で全ピクセルを走査する

scan() を使うと、左上から右下まで画像全体を順番に走査できる。
コールバックでは x, y, idx が渡ってくるので、座標とRGBAの開始位置が分かる。

image.scan(0, 0, image.width, image.height, (x, y, idx) => {
  const r = image.bitmap.data[idx + 0];
  const g = image.bitmap.data[idx + 1];
  const b = image.bitmap.data[idx + 2];
  const a = image.bitmap.data[idx + 3];
});

ここで image.bitmap.data から r, g, b, a を取り出している。

透明ピクセルをスキップする

全部のピクセルをそのまま box-shadow にすると、透明部分まで描画対象にしてしまう。
そのため、アルファ値が 0 より大きいものだけを採用している。

if (a > 0) {
  shadows.push(`${x}px ${y}px 0 rgb(${r},${g},${b})`);
}

この1行でやっているのは、例えば座標 (3, 5) に赤いピクセルがあれば、以下のような文字列を配列に積むことになる。

3px 5px 0 rgb(255,0,0)

box-shadow にまとめる

最後に、集めた文字列をカンマ区切りでつなげれば box-shadow が完成する。
ベースになる要素は width: 1px; height: 1px; としておき、その1pxの影を大量に並べることで画像を再現する。

const css = `
#pixel {
  width: 1px;
  height: 1px;
  box-shadow: ${shadows.join(',\n    ')};
}`;

このやり方だと、HTML側に大量の要素を作らなくても、1つの div だけでドット絵を描ける。
仕組みとしてはかなり単純だが、見た目はちゃんとピクセルアートになるのでなかなか面白い。

出力結果

たとえば、かなり小さいサンプル画像を変換すると、出力されるCSSは以下のような形になる。

#pixel {
  width: 1px;
  height: 1px;
  box-shadow: 0px 0px 0 rgb(255,0,0),
    1px 0px 0 rgb(0,255,0),
    2px 0px 0 rgb(0,0,255),
    1px 1px 0 rgb(255,255,255);
}

この例だと、上段に赤、緑、青の3ピクセルが並び、その下に白が1ピクセル置かれる形になる。
ブラウザで表示すると、1pxの色付きブロックがそのまま並んで、小さなドット絵として見える。

実際に画像を変換してみると、入力画像がそのまま box-shadow の列に展開されるので、出力結果を見るだけでもちょっと面白い。
ただし、画像サイズが大きくなるとCSSがかなり長くなるため、記事に載せる場合は全文ではなく抜粋にしておいたほうが見やすい。

長い出力を確認したい場合は、こんな感じで output.css を眺めるだけでも、どの座標にどの色が置かれているかが分かる。

output.css のイメージ
#pixel {
  width: 1px;
  height: 1px;
  box-shadow: 0px 0px 0 rgb(34,34,34),
    1px 0px 0 rgb(34,34,34),
    2px 0px 0 rgb(255,255,255),
    3px 0px 0 rgb(255,255,255),
    0px 1px 0 rgb(34,34,34),
    1px 1px 0 rgb(255,0,0),
    2px 1px 0 rgb(255,0,0),
    3px 1px 0 rgb(255,255,255);
}

少し前に行った海ほたるの画像を入力画像としておく。

01

で、これをピクセルアートとして出力してみる。 元画像は8.20MBだったけど、CSSに変換したら424MBくらいになった。
まあ、ピクセル数が多いとCSSもその分長くなるのは仕方ない。

ただこのままだとブラウザ側のメモリ制限で表示できないので、元画像を縮小してから変換することにした。 256*192に縮小した。

01_re

CSSに変換してブラウザで表示した結果が以下の通り。

02

できてる~!
なんかでも、元画像そのままって感じで(まあ当たり前なんだけど)、ピクセルアートっぽさはあまりないかも。

参考

おわりに

画像をそのままCSSに変換するだけなので実装自体はかなり単純だったが、box-shadow だけでドット絵が表示されるのは見ていてなかなか面白かった。
特に、HTML側は div ひとつだけで済むので、仕組みを理解しやすいのも良いところだと思う。

一方で、ピクセル数が増えるとCSSの量もそのまま増えていくため、実用面では小さなアイコンや小さめのピクセルアート向けになりそうだった。
もし次に手を入れるなら、画像サイズを縮小してから変換する処理や、同系色をまとめるような工夫も試してみたい。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。