GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

画像 ドット絵 変換 9パターン — キャンバスでドット絵を作る

画像 ドット絵 変換とは、写真をブロック計算で四角いドットに変える表現です。拡大した写真はなぜ色つきの四角に変わるのか? 01番はブロックの値ひとつでその強さを決め、05番はポインターのそばでそのブロックをさらに大きくし、09番はスライダーひとつで元写真とドット絵を分ける。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つの項目はすべて同じ手続き的に描かれたシーンから始まります。drawScene()がseedRand()というシード付き擬似乱数生成器を使って図形とグラデーションを描くので、ここにアップロードされた写真は一枚もありません。以下の項目は同じシーンをgetImageData()/putImageData()でそれぞれ違う方法に作り替えていて、ピクセルの色だけを変える計算(ブロック平均・パレット量子化・誤差拡散・明度バンド)から、ポインターやタイマーも読み取る計算へ進み、最後は元写真とドット絵変換結果を並べる比較シーンで終わります。

01ブロック値ひとつで決まるブロック平均ダウンサンプル

01番は元画像を正方形ブロックに分け、ブロック内の色を平均して一つの大きなピクセルに変えます。プロフィール写真をドット絵アイコンに変えるときに使える手法です。値を決めているのはPIXEL_BLOCK_SIZE_PXで、キャンバスバッファを走査する毎フレームのループの中、ブロックごとに一度blockAverage()へ渡されます。

ブロック平均ダウンサンプル
新しいタブで開く

01 · ブロック平均ダウンサンプル — 計測 累積面積 39.534% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PIXEL_BLOCK_SIZE_PXblockAveragegetImageData
  function blockAverage(buf, w, h, blockPx, outBuf) {
    for (var by = 0; by < h; by += blockPx) {
      var bh = Math.min(blockPx, h - by);
      for (var bx = 0; bx < w; bx += blockPx) {
        var bw = Math.min(blockPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += buf[idx]; gs += buf[idx + 1]; bs += buf[idx + 2]; count += 1;
          }
        }
        var r = Math.round(rs / count), g = Math.round(gs / count), b = Math.round(bs / count);
        for (var y2 = 0; y2 < bh; y2 += 1) {
          for (var x2 = 0; x2 < bw; x2 += 1) {
            var idx2 = ((by + y2) * w + (bx + x2)) * 4;
            outBuf[idx2] = r; outBuf[idx2 + 1] = g; outBuf[idx2 + 2] = b; outBuf[idx2 + 3] = 255;
          }
        }
      }
    }
  }

測定値: ループ内でフレームの39.5%が書き換わり、強度は平均42.5、動いたフレームは15/23です。

02色数ひとつで決まるパレット色量子化

02番は各ピクセルの色を決まった数のパレット色の中から一番近い色に置き換え、画面をひと握りの平坦な色だけにまとめます。8ビットゲーム風バナーに向く手法です。値を決めているのはPALETTE_COLOR_COUNTで、キャンバスバッファを走査する毎フレームのループの中、ピクセルごとに一度nearestPaletteColor()へ渡されます。

パレット色量子化
新しいタブで開く

02 · パレット色量子化 — 計測 累積面積 59.009% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PALETTE_COLOR_COUNTnearestPaletteColorquantizePalette
  function nearestPaletteColor(r, g, b, count) {
    var best = 0, bd = Infinity;
    for (var i = 0; i < count; i += 1) {
      var p = PALETTE_RGB[i];
      var dr = r - p[0], dg = g - p[1], db = b - p[2];
      var d = dr * dr + dg * dg + db * db;
      if (d < bd) { bd = d; best = i; }
    }
    return PALETTE_RGB[best];
  }

  function quantizePalette(buf, w, h, count, outBuf) {
    for (var i = 0; i < w * h; i += 1) {
      var idx = i * 4;
      var c = nearestPaletteColor(buf[idx], buf[idx + 1], buf[idx + 2], count);
      outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
    }
  }

測定値: ループ内でフレームの59.0%が書き換わり、強度は平均163.0、動いたフレームは17/23です。

03誤差拡散の広げ方ひとつで変わるディザリング

03番は量子化で生じた色の誤差を右隣と下側のピクセルへ広げ、点のパターンでなめらかな階調を表現します。グレースケール写真を白黒のドットに変えたいときに向いています。値を決めているのはDITHER_LEVELSで、キャンバスバッファを走査するピクセルごとのループの中、チャンネルごとに一度diffuseError()へ渡されます。

誤差拡散ディザリング
新しいタブで開く

03 · 誤差拡散ディザリング — 計測 累積面積 75.294% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DITHER_LEVELSdiffuseErrorputImageData
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 3;
        for (var c = 0; c < 3; c += 1) {
          var old = work[idx + c];
          var q = Math.max(0, Math.min(255, Math.round(Math.round(old / step) * step)));
          var err = old - q;
          work[idx + c] = q;
          if (x + 1 < w) work[idx + 3 + c] += err * 7 / 16;
          if (y + 1 < h) {
            if (x > 0) work[idx + rowStride - 3 + c] += err * 3 / 16;
            work[idx + rowStride + c] += err * 5 / 16;
            if (x + 1 < w) work[idx + rowStride + 3 + c] += err * 1 / 16;
          }
        }
      }

測定値: ループ内でフレームの75.3%が書き換わり、強度は平均88.6、動いたフレームは18/23です。

04帯の数ひとつで段階が変わる明度バンド・ポスタリゼーション

04番は各チャンネルの明るさを決まった数の帯に区切り、同じ帯の値をひとつの色にまとめます。イラストカードの陰影を段階的に見せたいときに向く手法です。値を決めているのはPOSTERIZE_BAND_COUNTで、キャンバスバッファを走査する毎フレームのループの中、ピクセルごとに一度quantizeBand()へ渡されます。

明度バンド・ポスタリゼーション
新しいタブで開く

04 · 明度バンド・ポスタリゼーション — 計測 累積面積 70.713% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

POSTERIZE_BAND_COUNTquantizeBandgetImageData
  function quantizeBand(buf, w, h, bands, outBuf) {
    var step = 255 / (bands - 1);
    for (var i = 0; i < w * h; i += 1) {
      var idx = i * 4;
      for (var c = 0; c < 3; c += 1) {
        var v = buf[idx + c];
        outBuf[idx + c] = Math.max(0, Math.min(255, Math.round(Math.round(v / step) * step)));
      }
      outBuf[idx + 3] = 255;
    }
  }

測定値: ループ内でフレームの70.7%が書き換わり、強度は平均63.8、動いたフレームは18/23です。

05半径ひとつで範囲が変わるポインター半径ドットレンズ

05番はポインター周りの半径内だけ大きくブロック化した画像に切り替え、その場所だけドットを大きく見せます。商品写真の上に置く虫眼鏡インタラクションに向いています。値を決めているのはPIXEL_LENS_RADIUS_PXで、ポインターとの距離を比較する毎フレームのループの中、ピクセルごとに一度lensCompose()へ渡されます。

ポインター半径ドットレンズ
新しいタブで開く

05 · ポインター半径ドットレンズ — 計測 累積面積 41.126% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PIXEL_LENS_RADIUS_PXlensComposeaddEventListener
  function lensCompose(bigBuf, smallBuf, w, h, px, py, radius, outBuf) {
    var r2 = radius * radius;
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 4;
        var dx = x - px, dy = y - py;
        var src = (dx * dx + dy * dy) < r2 ? bigBuf : smallBuf;
        outBuf[idx] = src[idx]; outBuf[idx + 1] = src[idx + 1]; outBuf[idx + 2] = src[idx + 2]; outBuf[idx + 3] = 255;
      }
    }
  }

測定値: ループ内でフレームの41.1%が書き換わり、強度は平均47.2、動いたフレームは23/23です。

06段階リストひとつで速さが変わる段階的ドットリビール遷移

06番はブロックサイズを大きい値から小さい値へタイマーで数段階に縮め、同じブロック平均の計算を段ごとに走らせることで、鮮明な写真が徐々に現れるように見せます。ギャラリー画像の読み込み演出に向く手法です。値を決めているのはREVEAL_STEPSで、リビール開始からの経過時間を測る毎フレームのループの中、一度stepIndexAt()へ渡されます。

段階的ドットリビール遷移
新しいタブで開く

06 · 段階的ドットリビール遷移 — 計測 累積面積 56.008% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REVEAL_STEPSblockAveragerequestAnimationFrame
  function stepIndexAt(sinceStart) {
    var cycleMs = REVEAL_STEPS.length * STEP_DURATION_MS;
    var pos = sinceStart % cycleMs;
    return Math.min(REVEAL_STEPS.length - 1, Math.floor(pos / STEP_DURATION_MS));
  }

  function renderFrame(t) {
    var sinceStart = t - replayStart;
    if (sinceStart < 0) sinceStart += LOOP_MS;
    var idx = stepIndexAt(sinceStart);
    blockAverage(baseBuf, CALC_W, CALC_H, REVEAL_STEPS[idx], outBuf);
  }

測定値: ループ内でフレームの56.0%が書き換わり、強度は平均45.6、動いたフレームは15/23です。

07切り替えタイマーひとつで変わるレトロコンソール・パレット固定

07番は色を自前定義の8色パレットと16色パレットだけに絞り込み、タイマーでどちらのパレットを使うかを切り替えて、昔のゲーム画面のように見せます。レトロイベントページのヒーロー画像に向いています。値を決めているのはRETRO_BLOCK_PXで、二つのパレットの切り替えも担う毎フレームのループの中、ブロックごとに一度lockRetroPalette()へ渡されます。

レトロコンソール・パレット固定
新しいタブで開く

07 · レトロコンソール・パレット固定 — 計測 累積面積 33.972% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RETRO_PALETTE_8RETRO_PALETTE_16lockRetroPalette
  var outBuf = outImg.data;

  function renderFrame(t) {
    var phase = Math.floor((t % LOOP_MS) / 160) % 2;
    var set = phase === 0 ? RGB_8 : RGB_16;
    lockRetroPalette(baseBuf, CALC_W, CALC_H, RETRO_BLOCK_PX, set, outBuf);
  }

測定値: ループ内でフレームの34.0%が書き換わり、強度は平均91.1、動いたフレームは16/23です。

08文字列ひとつで濃淡が変わる明度→アスキー文字マッピング

08番は各ブロックの明るさの値を、濃さの違う一文字が並ぶ固定の文字列から選び、その文字をブロックの色で描いて写真を文字絵に変えます。紹介ページのテキストアート背景に向く手法です。値を決めているのはBRIGHTNESS_RAMPで、塗り色を決めてからfillText()を呼ぶ毎フレームのループの中、ブロックごとに一度brightnessToChar()へ渡されます。

明度→アスキー文字マッピング
新しいタブで開く

08 · 明度→アスキー文字マッピング — 計測 累積面積 59.368% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BRIGHTNESS_RAMPbrightnessToCharfillText
  function brightnessToChar(baseBuf, w, h, cellPx, ramp, outCtx) {
    outCtx.fillStyle = '#0b0b10';
    outCtx.fillRect(0, 0, w, h);
    outCtx.font = Math.max(5, cellPx) + 'px monospace';
    outCtx.textBaseline = 'top';
    for (var by = 0; by < h; by += cellPx) {
      var bh = Math.min(cellPx, h - by);
      for (var bx = 0; bx < w; bx += cellPx) {
        var bw = Math.min(cellPx, w - bx);
        var rs = 0, gs = 0, bs = 0, count = 0;
        for (var y = 0; y < bh; y += 1) {
          for (var x = 0; x < bw; x += 1) {
            var idx = ((by + y) * w + (bx + x)) * 4;
            rs += baseBuf[idx]; gs += baseBuf[idx + 1]; bs += baseBuf[idx + 2]; count += 1;
          }
        }
        var r = rs / count, g = gs / count, b = bs / count;
        var lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
        var ci = Math.min(ramp.length - 1, Math.floor(lum * ramp.length));
        var ch = ramp.charAt(ci);
        outCtx.fillStyle = 'rgb(' + Math.round(r) + ',' + Math.round(g) + ',' + Math.round(b) + ')';
        outCtx.fillText(ch, bx, by);
      }
    }
  }

測定値: ループ内でフレームの59.4%が書き換わり、強度は平均87.1、動いたフレームは19/23です。

09スライダー値ひとつで割合が変わるビフォーアフター・ヒーロー比較シーン

09番はパーセンテージの値から横方向のしきい値を決め、その左側にドット絵変換結果を、右側に元写真を同じキャンバスへ描き、スライダーの位置ひとつで見える割合を変えます。ランディングページのヒーロー比較スライダーに向いています。値を決めているのはCOMPARE_SLIDER_PERCENTで、各ピクセルのx位置としきい値を比較する毎フレームのループの中、ピクセルごとに一度splitCompare()へ渡されます。

ビフォーアフター・ヒーロー比較シーン
新しいタブで開く

09 · ビフォーアフター・ヒーロー比較シーン — 計測 累積面積 10.295% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COMPARE_SLIDER_PERCENTsplitCompareaddEventListener
  function splitCompare(beforeBuf, afterBuf, w, h, percent, outBuf) {
    var threshold = Math.round((percent / 100) * w);
    for (var y = 0; y < h; y += 1) {
      for (var x = 0; x < w; x += 1) {
        var idx = (y * w + x) * 4;
        var src = x < threshold ? afterBuf : beforeBuf;
        outBuf[idx] = src[idx]; outBuf[idx + 1] = src[idx + 1]; outBuf[idx + 2] = src[idx + 2]; outBuf[idx + 3] = 255;
      }
    }
  }

測定値: ループ内でフレームの10.3%が書き換わり、強度は平均54.3、動いたフレームは17/23です。

アクセシビリティと reduced-motion で止まる場所

prefers-reduced-motion: reduceが有効なとき、01番から08番はis-demoクラスが外れてアニメーションのフレームループが止まりますが、どの項目も先頭のフレームではなく、それぞれのループの中間地点—LOOP_MSの半分が経過した状態—で止まります。そのため動きに敏感な読者にも、各パターンの典型的な見た目が空白の初期状態の代わりに残ります。09番だけは仕組みが違い、しきい値の値がフレームごとではなくポインター・キーボード・レンジ入力の操作でしか変わりません。区切り線はすでにArrowLeftとArrowRightのキー入力に反応して4ポイントずつ位置を動かし、デモ上部の<input type="range">も同じsetPercent()を呼ぶので、ポインターに一度も触れない読者でもキーボードだけでスライダーを動かせます。getImageData()とrequestAnimationFrame()はこの9つすべてが土台にしているAPIで、prefers-reduced-motionメディアクエリとあわせてMDNに解説があります。

9つのパターンを面積と強度で測る

項目 面積% 強度 動いたフレーム
01 pixelart-block-downsample 39.5 42.5 15/23
02 pixelart-palette-quantize 59.0 163.0 17/23
03 pixelart-dither-diffuse 75.3 88.6 18/23
04 pixelart-posterize-band 70.7 63.8 18/23
05 pixelart-pointer-lens-radius 41.1 47.2 23/23
06 pixelart-reveal-transition-step 56.0 45.6 15/23
07 pixelart-retro-palette-lock 34.0 91.1 16/23
08 pixelart-ascii-brightness-map 59.4 87.1 19/23
09 pixelart-before-after-hero-scene 10.3 54.3 17/23

この画像 ドット絵 変換は画像 ドット絵 変換 サイトと何が違うのか?

画像 ドット絵 変換 サイトの多くはブロックサイズ・パレット・ディザリングをボタンひとつといくつかのプリセットの裏に隠しています。このページに並んでいるのは、そうしたサイトが裏側で走らせている9つの計算そのもの—ブロック平均・パレット量子化・誤差拡散ディザリング・明度バンドほか—を、最後まで読める短い関数ひとつずつとして見せたものです。02番・04番・07番は新しい層を足すのではなく既存の色をパレットへ固定したり帯にまとめたりするので、ネイティブのCSSfilter()関数よりも画像の上にかけるフィルター処理に近く、01番・03番・05番・06番・08番・09番はピクセルの並びそのものを作り替えるので、CSSのフィルターだけでは届きません。同じ「毎フレーム値をひとつ読む」という考え方でまとめた編としては、css 画像 フィルター 9パターン — 値ひとつで色が変わるが色だけを変える半分をより詳しく扱っていて、パーティクル アニメーション canvas 9種 — ライブラリなしの作り方は静止した変換ではなく動く形が欲しいときの近い編で、画像 編集 javascript のトリミングUI 9選はこの9つが走る前に写真を切り抜く場所です。

画像 ドット絵 変換が壊れる場所 — 大きな写真と低スペック端末

上の9つはどれもCALC_W × CALC_Hのピクセルをアニメーションフレームごとに少なくとも一度getImageData()/putImageData()で読み書きします。デモの160×100のキャンバスなら1回あたり16,000回の読み取りで済みますが、同じピクセルごとのループを4000pxの写真アップロードにかけるとその200倍を超え、画像 ドット絵 変換 aiを謳うオンラインツールが本番解像度でつまずくのと同じ壁にぶつかります。05番と09番はこの毎フレームのコストにもう一段のコストを重ねていて、05番はポインターの位置が変わるたびに表示するバッファを計算し直し、09番はキー操作やレンジ入力のたびに分割のしきい値を計算し直すので、元の写真自体が大きいならこの二つを先に計測する価値があります。このピクセル単位のコストは、ダウンロードが役に立つ場所でもあります—9つすべてのindex.html/style.scssを開く895nmedwはこの段落にあるので、どのパターンを先に試すか決めてから残りを解凍してください。

画像 ドット絵 変換が実際に使われる場所

この9つの多くは、訪問者がずっと眺め続けるものではなく、静的な素材に一度だけ焼き込む変換として使われます—画像 ドット絵 変換 アプリとして検索する人が実際に欲しいのもこの使い方です。01番のブロック平均はプロフィール写真をアイコンに向くブロックサイズでドット絵アイコンに変え、02番のパレット量子化はヒーロー画像を8ビットゲーム風バナーと同じ色数に固定し、04番の明度バンドはイラストカードの陰影を滑らかな階調ではなく段階的な帯にし、07番のレトロパレット固定はレトロイベントページのヒーロー画像を昔のゲーム画面の色数に絞り、08番の明度→文字マッピングは紹介ページのテキストアート背景に写真を変えます。

残りの4つはページが読み込まれた後も値が変わり続けるので、インタラクティブなままです。03番の誤差拡散ディザリングは、素材がすでにグレースケールで白黒のドットとして見せたいときに向いています。05番のポインター半径レンズはカーソル周りの円の内側だけブロックサイズを大きくするので、商品写真の上に重ねる虫眼鏡インタラクションに合います。06番の段階的リビールはギャラリー画像の読み込み演出でブロックサイズを縮め、09番のビフォーアフターシーンはランディングページのヒーローで元写真とドット絵変換結果が見える割合をスライダーの位置ひとつに結びつけます。

3つの値を振り切ってドット絵の限界を見る

バリエーション 値の変更 感触
ブロックサイズの値を調整する 01 PIXEL_BLOCK_SIZE_PX 4 → 24 ブロックサイズを4から24に上げると一つ一つのドットが大きくなり、元の形がより崩れて見えます。
レンズ半径の値を調整する 05 PIXEL_LENS_RADIUS_PX 40 → 160 レンズ半径を40から160に広げるとポインター周りでドットが大きく見える範囲が広がります。
比較スライダーの値を調整する 09 COMPARE_SLIDER_PERCENT 20 → 80 比較スライダー値を20から80に動かすと画面でドット変換結果が見える割合が増えます。

この画像 ドット絵 変換に関するよくある質問

この9つはアップロードした写真や外部の画像を読み込みますか?

いいえ。drawScene()はキャンバスの図形とグラデーションだけでシーンを一から描き、seedRand()で種を固定しているので同じシード値なら常に同じ配置になります。9つの計算はすべてそのキャンバスバッファの上だけで動き、アップロードされた画像もライブラリの読み込みもゼロです。

フルサイズの写真でいちばん負荷が高いのはどれですか?

03番の誤差拡散ディザリングは強度の測定値が88.6ともっとも高く、フレームの75.3%も書き換えます。すべてのピクセルのすべてのチャンネルが隣からの誤差を読み取り、さらに次へ誤差を書き込むためで、02番・04番・08番より先に本番解像度で試す価値があります。

これはピクセルアートのパーティクル表現と何が違いますか?

この9つはどれも一枚の写真のようなシーンから始まり、すでにあるピクセルの色を作り替えたり間引いたりするだけです。パーティクル表現は火花や粒子のような小さな図形を個別に増やしたり消したりして動かすもので、上の9つのブロック・パレット・ディザリングの計算とは別の考え方です。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。