Webでグリッチ演出を実現する、SVGフィルタ実装

no_?thumbnail no_?thumbnail

このページを読むと:あなたのWebページで、これ↓を作れる。

グリッチ演出。古いテレビとか壊れたプロジェクターのような、色ズレで不安感を煽る演出だ。
不安感をあおる演出が必要な時、あれを使いたいと思ったことがあるだろうと思う。ゲームエンジンを使っているならともかく、ブラウザで動くシンプルな謎解きゲームなどではなかなか難しい技術に見える。CSSのblurやinvertのような単純なfilter関数だけでは実現できないし、それこそ予めそういう演出込みのgif画像を用意しないといけないのでは、と考えるくらい。

この表現を可能にするSVGフィルターについて見ていこう。

外観に関するピンポイントな技術だけあってあまりメジャーではなく、大規模言語モデルですらあまり正確に実装してくれないどころか、提案すらしてくれないケースも多い。だからこそ知っておいて損はない。

「Gothic:Neue」や「記憶遺棄20A」ではsvgフィルターによる演出を多用しているので、実際に使用した結果についてはそちらも参照。

技術スタック(SVGフィルタプリミティブ)

実装の中心になっているのは以下のプリミティブ。

  • feColorMatrix:RGBAの各チャンネルに行列演算をかけて色を変換する
  • feOffset:画像を指定ピクセル分ずらす
  • feGaussianBlur:ガウスぼかしをかける
  • feComposite:2つの入力を指定した合成モードで重ねる
  • feTurbulence:Perlinノイズ/フラクタルノイズを生成する(入力画像に依存しない)
  • feDisplacementMap:別の画像をマップとして使い、ピクセル単位で元画像を歪ませる

聞いただけで「なんかいろいろできそうじゃん」と思ったあなたは筋がいいし、今すぐこのページから飛び出して公式ドキュメントを見に行くべきかもしれない。

これらを1つの<filter>要素の中に直列・並列に組み合わせることで、色収差と歪みを同時に表現できる。これが現状グリッチ演出を軽量に実装する最良の手段だ(と思う)。

フィルタの置き場所と適用方法

フィルタ定義はアセットファイルとしてではなく、コンポーネントのJSX内に直接埋め込んでいる。SVGを1つ用意し、その中に<filter id="...">を書き、CSS側からfilter: url(#id)で参照する。

<svg xmlns="http://www.w3.org/2000/svg" style={{ display: "none" }}>
  <filter id="displacementFilter">
    {/* ここにフィルタプリミティブを並べる */}
  </filter>
</svg>

display: noneはあまり直観的ではない気もするが、今作っている要素はあくまで任意の画像に上からかけるフィルタであって、表示させたいDOM要素ではない。実際にnone以外を指定するとsvgの大きさの邪魔な要素が画面上に出現するだけ。

また、CSSで参照するフィルタならCSSに書きたい気もするが、JSXに直接書く理由は後述するfeTurbulencefeDisplacementMapの属性をJavaScriptからリアルタイムに書き換えるために、useRefでプリミティブ要素そのものを掴んでおきたいから。こうしないとアニメーションしない、ということ。

const turbulenceRef = useRef<SVGFETurbulenceElement>(null);
const displacementRef = useRef<SVGFEDisplacementMapElement>(null);

ここからは以下のサンプル画像で見てみよう。

色収差(RGBズレ)を作る

feColorMatrixtype="matrix"は、RGBAそれぞれに対して5つの係数(R, G, B, A, オフセット)をかけて足し合わせる変換である。最初の2つは、Gチャンネルだけを残した画像(result="g")とBチャンネルだけを残した画像(result="b")を作っている。3つ目はresultを指定していないため、直後のfeOffsetにそのまま渡る「無名の中間結果」になる。この3つ目はRチャンネルだけを残す変換なので、Red単体の画像ができる。

<img src="your-image.webp" style="filter:url(#filter-red);">
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 220 220" style="display:none;">
  <filter id="filter-red">
    <feColorMatrix in="SourceGraphic" type="matrix"
      values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0" />
  </filter>
</svg>

svgフィルタで赤チャンネルだけを抽出した(上図)。これをずらして重ね直すイメージ。

このR単体の画像だけをfeOffset dx="5" dy="1"でずらし、feGaussianBlurでぼかす。そのあとの2つのfeCompositeで、ぼかして色収差用にずれたR画像に対して、Gの画像・Bの画像をoperator="lighter"(加算合成)で重ねていく。結果として、Rだけがズレて滲み、GとBは元の位置のままの合成画像rgb-layersができあがる。


<img src="your-image.webp" style="filter:url(#filter-chr);">
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 220 220" style="display:none;">
  <filter id="filter-chr">
    <feColorMatrix in="SourceGraphic" type="matrix"
      values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0" result="g" />
    <feColorMatrix in="SourceGraphic" type="matrix"
      values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0" result="b" />
    <feColorMatrix in="SourceGraphic" type="matrix"
      values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0" />
    <feOffset dx="5" dy="1" />
    <feComposite in="g" in2="" operator="lighter" />
    <feComposite in="b" in2="" operator="lighter" result="rgb-layers" />
  </filter>
</svg>

実際に使うときには<feGaussianBlur>を挟んで、ずらしたチャンネルにぼかしをかけるとよりいい感じになる。

溶けるような歪みを加える

画像に歪みを加えることもできる。

<feTurbulence type="turbulence" baseFrequency="0 0" numOctaves="1" result="turb" />
<feColorMatrix in="turb" type="matrix"
  values="
    1 -1  0 0 0.5
    0  1 -1 0 0.5
   -1  0  1 0 0.5
    0  0  0 1 1"
  result="centered"
/>
<feDisplacementMap in2="centered" in="rgb-layers"
  scale="4" xChannelSelector="R" yChannelSelector="G" />

feTurbulenceはパーリンノイズを生成するプリミティブで、baseFrequencyがノイズの細かさを決める。単体で出力させると、こういうもやもやした模様になる。

最後のfeDisplacementMapが本体で、inに指定したrgb-layers(色収差済みの画像)を、in2に指定したcentered(コントラストを持ち上げたノイズ)を使って歪ませる。xChannelSelector="R"yChannelSelector="G"は、ノイズ画像のRチャンネルの値をx方向の変位量、Gチャンネルの値をy方向の変位量として使うという指定である。scaleはその変位量の強さの倍率になる。ここまでの一連のブロックをテスト画像に適用すると、次のようになる。

ここまで動的なスクリプトは一切使わずに実現できる。使い道が思い浮かぶ人にとってはかなり有用なツールだ。

応用例

<img src="your-image.webp" style="filter:url(#filter-y);">
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 220 220" style="display: none;">
  <filter id="filter-y">
    <feTurbulence type="turbulence" baseFrequency="0 1" numOctaves="1" result="centered"></feTurbulence>
    <feDisplacementMap in2="centered" in="SourceGraphic" scale="10" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
  </filter>
</svg>

横方向にだけずらすパターン。feTurbulencebaseFrequencyはx軸方向とy軸方向を個別に設定できるので、x軸方向を0にすることで実現できる。

いろいろ重ねてみたやつ。やりすぎ?

TypeScriptによるリアルタイム制御

SVGフィルタの数値属性はCSSのtransitionやanimationの対象にならないため、これはrequestAnimationFrameでプリミティブの属性を直接書き換えることで実現している。

たとえば、演出の開始直後は強く歪ませておき、時間経過とともに歪みを収めていく、という使い方の実装は次のようになる。

const animate = () => {
  const delta = new Date().getTime() - startAnimate;
  if (turbulenceRef.current) {
    const freqY = Math.cos(delta) + 1;
    turbulenceRef.current.setAttribute('baseFrequency', `0 ${freqY}`);
  }
  if (displacementRef.current) {
    displacementRef.current.setAttribute('scale', `${Math.max((3000 - delta) / 500, 4)}`);
    displacementRef.current.setAttribute(
      'in',
      Math.cos(delta / 47) + Math.cos(delta / 151) - 1 > 0 ? 'rgb-layers' : 'SourceGraphic'
    );
  }
  requestAnimationFrame(animate);
};

なお、JavaScriptの場合はfeTurbulenceにidを振ってgetElementByIdで取得し、setAttributeすればよい。

baseFrequencyを経過時間の三角関数で揺らすことでノイズの模様が時間とともに変化し続ける。ここではconst freqY = Math.cos(delta) + 1;という定義なので、deltaの変化量が少ないと波打つような動きになる。今回のようにdeltaがミリ秒単位だと、人の目には追い付かない勢いで揺れるのでノイズのような動きが実現できる。

さらにfeDisplacementMapin属性そのものをrgb-layersSourceGraphicの間で切り替えており、色収差ありの状態とノイズ的な瞬間が不規則に入れ替わる、いわゆる「走査線が乱れる」ようなグリッチ感を作っている。

切り替えのタイミングを定義しているのは2つの三角関数の合成値で、疑似乱数になっている。

Math.cos(delta / 47) + Math.cos(delta / 151) - 1 > 0

完全ランダムだと見た目の制御が実質的に不可能になり、それはそれで見栄えが悪いことがある。できるだけ長い周期で人の感覚として受け入れやすいランダムっぽさを演出するのには数値でコントロールができる方が都合が良い。

演出のon/offトグル

CSS側で.noiseのようなクラスにfilter: url(#displacementFilter)を割り当てることで、演出のオン・オフをクラスの切り替えだけで制御することができる。

.noise {
  filter: url(#displacementFilter);
}

まとめ

  • 色収差は「RGB各チャンネルをfeColorMatrixで分離し、片方だけfeOffsetfeGaussianBlurでずらし、feCompositeの加算合成で重ね直す」ことで作れる
  • feTurbulenceの出力はそのままでは変位マップとしてコントラスト不足なので、feColorMatrixでチャンネル同士を差し引いてコントラストを持ち上げてからfeDisplacementMapに渡す
  • SVGフィルタの数値属性はCSSアニメーションの対象外なので、動きをつけるにはuseRefでプリミティブを掴みrequestAnimationFrameから直接setAttributeする必要がある
  • 同じフィルタ定義でも、JS側でどの属性をどう動かすか・feDisplacementMapinに何を渡すかを変えるだけで、複数の異なるグリッチ表現に使い回せる

CSSのfilter関数だけでは足りない表現をしたくなったとき、SVGフィルタとJavaScriptによるリアルタイム制御を組み合わせる、という選択肢は覚えておいて損はない。表現の幅を広げたいときにどうぞ。