CSSを使ったモザイクエフェクトの実装

2023/07/10に公開

100BANCHさんのプロジェクトを紹介するWebサイト『Future Explorations』で実装したモザイクエフェクトの仕組を解説します。

https://fe.100banch.com

DEMO

Mosaicizeボタンを押すと、白い画面からモザイクエフェクトで写真が出現します。

仕組み

  • 画像の前面に正方形のセルを被せる
  • それぞれのセルに、モザイク解除(元画像可視化)、この3段階アニメーションを設定
  • モザイクのセルを4パートに分け、段階的にアニメーション

途中、モザイクのランダムを表現するためJavaScriptも利用していますが
作成した乱数を固定値として扱い、トリガーをhoverなどスタイルシート制御にするとスクリプトなしの構成で動きます。

モザイク化画像をCSSで作成

モザイクがかかった状態の画像は、対象の画像の前面へgridを作成しbackdrop-filterblur効果を重ねることで再現できます。
gridの作り方は、次の項目で解説します。

gridでモザイクのセルを作成

↓スクロールして見てください

  1. 画像の前面に、幅と高さの最小公倍数で正方形のセルを作成して被せる
  2. セルは均等に4種類に分類されている
  3. 不規則にするため順番をシャッフルしている
  4. 4段階に分けてアニメーションエフェクトを実行している

1〜3の、column×rowのセルを4種に分類して順番をシャッフルするところは、スクリプトで実装しました。

useMosaicEffect.ts
/**
 * @param {number} gridColumn
 * @param {number} gridRow
 * @returns {Grid} Array with column x row elements.
 * @example
 * const randomGrid = useRandomGrid();
 */
const useMosaicEffect = (gridColumn: number, gridRow: number): Grid => {
  const createRandomGrid = (): Grid => {
    const totalCells = gridColumn * gridRow;
    const parameters: GridParameters[] = [0, 1, 2, 3];
    const grid: GridParameters[] = [];

    // Divided into four categories
    for (let i = 0; i < 4; i++) {
      for (let j = 0; j < totalCells / 4; j++) {
        grid.push(parameters[i]);
      }
    }

    // order shuffle
    for (let i = grid.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [grid[i], grid[j]] = [grid[j], grid[i]];
    }

    return grid;
  };

  return createRandomGrid();
};

実行すると、このような配列をreturnします。

[
  2,0,3,1,2,0,1,2,1,2,1,0,3,2,2,3,1,2,2,2,2,2,3,3,3,1,2,2,3,1,0,1,1,3,0,2,1,3,1,2,2,0,0,1,3,1,0,1,1,3,0,2,3,2,0,2,3,1,3,1,3,2,1,3,0,2,1,3,2,0,3,1,3,3,0,0,3,0,1,1,0,3,1,3,0,2,0,1,2,2,1,1,2,3,0,2,0,0,3,1,0,0,0,2,1,2,2,2,0,0,3,3,2,1,0,2,0,0,2,3,2,0,2,2,1,1,2,0,3,3,0,2,2,0,3,1,2,0,0,3,0,3,3,1,1,3,0,0,3,3,0,3,0,1,1,1,3,1,1,1,1,3,0,3,3,2,2,1,0,0,3,0,1,3,0,3,2,1,2,2,2,3,1,3,2,2,1,0,3,3,0,0,1,0,1,0,3,2,1,1
]

配列をもとに、divをgridへ流し込み、4の時間差アニメーションをスタイルシートで設定します。
このソースはReactですが、通常のJavaScriptでも考え方は同じです。

MosaicEffect.tsx
import useMosaicEffect from "./useMosaicEffect.ts";

const MosaicEffect:FC = () => {
  const cellParams = usePixelateEffect(20, 10);

  return (
    <div
      data-intersecting={isIntersecting}
      className={["mosaicEffect", ...addClass].join(" ")}
    >
      {cellParams.map((grid, index) => (
        <div key={index} className={"cell"} data-param={grid}></div>
      ))}
    </div>
  );
}
// animation
@keyframes toClear {
  0% {
    background-color: #ffff;
    backdrop-filter: blur(50px);
  }

  50% {
    background-color: #fff0;
    backdrop-filter: blur(50px);
  }

  100% {
    background-color: #fff0;
    backdrop-filter: blur(0);
  }
}

.cell {
  background-color: #fff;
  [data-intersecting] & {
    will-change: background-color backdrop-filter;
  }
  [data-intersecting='true'] & {
    &[data-param="0"] {
      background-color: transparent;
      animation: toClear 150ms steps(3) forwards;
    }

    &[data-param="1"] {
      animation: toClear 150ms steps(3) 50ms forwards;
    }

    &[data-param="2"] {
      animation: toClear 150ms steps(3) 100ms forwards;
    }

    &[data-param="3"] {
      animation: toClear 150ms steps(3) 150ms  forwards;
    }
  }
}

これらの流れを実装したのが、最初のDEMOです。

処理の軽量化

『Future Explorations』の13の未来ページでは、1ページ内に複数のモザイクエフェクトを設置しておりIntersectionObserverでスクロール位置を判定してアニメーションを実行する処理も入っているため、backdrop-filterの実装では激しく処理落ちしてしまいました。

そこで、このような軽量化を行いました。

  • useMosaicEffectによるモザイク分布配列の即時演算をやめて固定化
  • backdrop-filterをやめ、SVGでimage要素をmask

SVGでimage要素をmask

モザイクがかかった状態の画像をもう一枚用意し、SVGのimage要素に設定。モザイクのmaskで切抜いてエフェクトをかけました。

Mosaicizeボタンを押すと、白い画面からモザイクエフェクトで写真が出現します。

モザイク済み画像

モザイク済み画像は、CSS backdrop filterのサンプルの方法でbackdrop-filterによるモザイクをかけた画面をスクリーンキャプチャして作成しました。
グリッドとずれないようにする点は気を遣いましたが、一瞬で消える画像なので解像度や画質は無視しています。ファイルサイズも小さく抑えています。

SVGの構造

useMosaicEffectで作った配列をもとに、4種類のセルが分布しているSVGを作成します。
SVGの中身はこのような階層になっています。

  • svg …viewBoxはセルの幅と高さを指定
    • mask (透過度のmask)
      • g (グループ) x 4
        • rect (セル)
    • mask (輝度のmask)
      • g (グループ) x 4
        • rect (セル)
    • image (モザイクがかかった画像) …透過度のmaskを適用させる
    • rect (白く塗りつぶした矩形) …輝度のmaskを適用させる

グループは、配列の値が0のセルだけのグループ、1のセルだけのグループ、2のセルだけのグループ、3のセルだけのグループの4つで。backdrop-filterでの実装時と同様に時間差でスタイルシートのアニメーションを割り付けます。

MosaicEffect.tsx
export const MosaicEffect: FC<PixelateEffectProps> = () => {
  // usePixelateEffectで作成した内容を都度演算ではなく固定値して渡す
  const cellParams = [2,0,3,1,2,0,1,2,1,2,1,0,3,(省略)];

  return(
    <div className={"area"}>
      <svg
        className={"mosaicEffect"}
        data-intersecting={isIntersecting}
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 20 10" {* セルの幅と高さを指定 *}
        preserveAspectRatio="none"
      >
        <defs>
          // 透過度のmask
          <mask id="mask_alpha">
            {[0, 1, 2, 3].map(group => (
              <g key={group} className={"group"}>
                {cellParams.map((grid, index) => {
                  const x = index % 20;
                  const y = Math.floor(index / 20);
                  // 1 x 1の矩形を描画 引き伸ばしたときセル同士に隙間ができるため、strokeを設定
                  return grid === group ? (
                    <rect  className={"cell"} key={index} x={x} y={y} width="1" height="1" fill="white" stroke="white" strokeWidth="0.05" />
                  ) : null;
                })}
              </g>
            ))}
          </mask>
          // 輝度のmask
          <mask id="mask_brightness">
            {[0, 1, 2, 3].map(group => (
              <g key={group} className={"group"}>
                {cellParams.map((grid, index) => {
                  const x = index % 20;
                  const y = Math.floor(index / 20);
                  // 1 x 1の矩形を描画 引き伸ばしたときセル同士に隙間ができるため、strokeを設定
                  return grid === group ? (
                    <rect  className={"cell"} key={index} x={x} y={y} width="1" height="1" fill="white" stroke="white" strokeWidth="0.05" />
                  ) : null;
                })}
              </g>
            ))}
          </mask>
        </defs>
        {* モザイク化済み画像に透過度のmaskを設定 *}
        <image href={mosaic_image} width="20" height="10" mask="url(#mask_alpha)" />
        {* 白く塗りつぶした矩形へ輝度のmaskを設定 *}
        <rect stroke="white" width="20" height="10" fill="white"
          mask="url(#mask_brightness)"
        />
      </svg>
    </div>
  );
};

アニメーション

このSVGを、アニメーションエフェクト解除後の画像の前面に重ねてアニメーションを実行します。

<figure class="figure">
  <MosaicEffect />
  <img class="image" src={image_src} alt="" />
</figure>

輝度maskを解除してから透過度maskを解除するよう、時間差をつけます。

軽快さを意識して、この実装では最初に解除するgroup 1は、輝度マスクをスキップしました。
このあたりの調整は演出によってケースバイケースですね。

.mosaicEffect {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

// animation
@keyframes fadeOut {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

$wide_duration: 150;
$wide_delay_brightness: 75;
$wide_delay_alpha: 50;

[data-intersecting="true"] {
  .group {
    animation: fadeOut forwards;
    animation-duration: #{$wide_duration * 1.5}ms;
  }

  // group 1
  [id*="mask_alpha"] .group:nth-child(1) {
    animation-delay: #{$wide_duration - $wide_delay_alpha}ms;
  }
  // group 2
  [id*="mask_brightness"] .group:nth-child(2) {
    animation-delay: #{$wide_delay_brightness * 1}ms;
  }
  [id*="mask_alpha"] .group:nth-child(2) {
    animation-delay: #{($wide_delay_brightness * 1) + $wide_duration - $wide_delay_alpha}ms;
  }
  // group 3
  [id*="mask_brightness"] .group:nth-child(3) {
    animation-delay: #{$wide_delay_brightness * 2}ms;
  }
  [id*="mask_alpha"] .group:nth-child(3) {
    animation-delay: #{($wide_delay_brightness * 2) + $wide_duration - $wide_delay_alpha}ms;
  }
  // group 4
  [id*="mask_brightness"] .group:nth-child(2) {
    animation-delay: #{$wide_delay_brightness * 3}ms;
  }
  [id*="mask_alpha"] .group:nth-child(2) {
    animation-delay: #{($wide_delay_brightness * 3) + $wide_duration - $wide_delay_alpha}ms;
  }
}

以上が、モザイクエフェクトの実装でした。
応用次第で、カルーセル切替エフェクトやhoverアニメーションにも使えそうですね。


最後になりましたが
Future Explorationsは、100BANCHさんのエネルギーと情熱を体現する、パワーあふれるコンテンツとなりました。武田雄大さんの魅力的なイラスト、Misaki NakanoさんのWEB GLによる迫力ある表現も、ぜひ直接ご覧になり体感していただきたいです!
携わってくださったすべての皆様に感謝いたします。

https://twitter.com/ryusukwww/status/1678343359679823877?s=20

TAM

Discussion