CSSを使ったモザイクエフェクトの実装
100BANCHさんのプロジェクトを紹介するWebサイト『Future Explorations』で実装したモザイクエフェクトの仕組を解説します。
DEMO
Mosaicize
ボタンを押すと、白い画面からモザイクエフェクトで写真が出現します。
仕組み
- 画像の前面に正方形のセルを被せる
- それぞれのセルに、
白
→モザイク
→解除
(元画像可視化)、この3段階アニメーションを設定 - モザイクのセルを4パートに分け、段階的にアニメーション
途中、モザイクのランダムを表現するためJavaScriptも利用していますが
作成した乱数を固定値として扱い、トリガーをhoverなどスタイルシート制御にするとスクリプトなしの構成で動きます。
モザイク化画像をCSSで作成
モザイクがかかった状態の画像は、対象の画像の前面へgridを作成しbackdrop-filter
のblur
効果を重ねることで再現できます。
gridの作り方は、次の項目で解説します。
gridでモザイクのセルを作成
↓スクロールして見てください
- 画像の前面に、幅と高さの最小公倍数で正方形のセルを作成して被せる
- セルは均等に4種類に分類されている
- 不規則にするため順番をシャッフルしている
- 4段階に分けてアニメーションエフェクトを実行している
1〜3の、column
×row
のセルを4種に分類して順番をシャッフルするところは、スクリプトで実装しました。
/**
* @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でも考え方は同じです。
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 (セル)
- g (グループ) x 4
- mask (輝度のmask)
- g (グループ) x 4
- rect (セル)
- g (グループ) x 4
- image (モザイクがかかった画像) …透過度のmaskを適用させる
- rect (白く塗りつぶした矩形) …輝度のmaskを適用させる
- mask (透過度のmask)
グループは、配列の値が0のセルだけのグループ、1のセルだけのグループ、2のセルだけのグループ、3のセルだけのグループの4つで。backdrop-filter
での実装時と同様に時間差でスタイルシートのアニメーションを割り付けます。
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による迫力ある表現も、ぜひ直接ご覧になり体感していただきたいです!
携わってくださったすべての皆様に感謝いたします。
Discussion