【WebGL】【MSDF】 イブにお父さんがサンタに変身する話

この記事はTech KAYAC Advent Calendar 2022の24日目の記事です。


こんにちは。
意匠部アートディレクターのおばらです。

普段は受託案件の

  • アートディレクション
  • テクニカルディレクション
  • デザイン
  • 3Dモデリング
  • フロントエンド実装

などをしています。

また、社内フロントエンジニア向けに 生 WebGL オンライン勉強会を週5で1年間、開催したりしていました。
今は優秀な後輩に引き継いでいます。

techblog.kayac.com


そんなわけで今日は WebGL のお話です。


はじめに

さて、今日はクリスマス・イブ。

そんなイブに私は WebGL の記事が書きたい。
どうしても書きたい。
イブと WebGL をどうにかしてこじつけられないか。
悩みました。

そうだ、イブといえば世の中のお父さん がこっそりサンタに変身する日。
というわけで WebGL で「お父さん」を「サンタ」にしてみましょう。


※ もちろんお母さんも。しかし紙面の関係上本記事では「お父さん」を「サンタ」にしてみます
※ 本記事は JavaScript、WebGL API、GLSL の知識がある前提の記事です


目次

本記事で解説する2Dモーフィングアニメーション


テクスチャの作成

(後ほど SVG が必要になるため)まずは Adobe Illustrator で普通のテクスチャ(PNG)を作成しました。
黒の背景に白い塗りの「お父さん」と「サンタ」。
不透明度を 0〜255 の 8 bit で RGB チャンネルそれぞれに格納したテクスチャ と捉えても良いでしょう。
※ その場合はRGBのいずれか1つのチャンネルで十分ですが

「お父さん」と「サンタ」の普通のテクスチャ(PNG)

英語のほうがなんとなく格好いいので "Daddy" と "Santa!" にしました。
"Daddy" よりも "Santa" のほうが若干幅が短かったのでなんとなく "Santa" に "!" をつけました。
フォントはなんとなく美味しそうでイブっぽい Lobster です。
画像の解像度(縦横サイズ)もなんとなくイブっぽく 1224 x 1224 px としました。

※ 本記事で掲載するサンプルは WebGL1 ですが、①(描画サイズよりも大きな)テクスチャを縮小表示することはないのでミップマップは不要です。また②テクスチャもリピートもしません。①②よりテクスチャの解像度(縦横サイズ)に制限はありません。各辺が 2 の累乗である必要もありません


不透明度の線形補間

まずは普通に不透明度を線形補間してみましょう。


WebGL での描画結果(1224 x 1224 px)

See the Pen santa-daddy.sample.alpha-interpolation by Tohl SMALLFIELD (@tsmallfield) on CodePen.


頂点シェーダ

See the Pen santa-daddy.vs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

2Dで表示するだけなので、頂点シェーダの処理は以下の2点のみです。

  • 頂点座標 position は JavaScript から渡す時点で正規化デバイス座標としているため、座標変換せず(2次元 から 4次元にして) gl_Position に代入する
  • UV 座標 uv を頂点間で補間させるため varying 変数 vUv としてフラグメントシェーダに渡す


フラグメントシェーダ

See the Pen santa-daddy.alpha-mix.fs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

フラグメントシェーダもシンプルです。
GLSL のビルトイン関数 mix()

  • alphaDaddy:「お父さん」のテクスチャの R チャンネルから取り出した不透明度
  • alphaSanta:「サンタ」のテクスチャの R チャンネルから取り出した不透明度

  • uSantaLevel: JavaScript から uniform 変数としてシェーダに渡している正弦波(0〜1)

で線形補間しています。

gl_FragColor.a = mix(alphaDaddy, alphaSanta, uSantaLevel);


しかしこれってただのクロスフェード。
CSS でもできちゃいます。

もっと WebGL っぽいことをしたい。
不透明度ではなく形状を線形補間できないでしょうか。
いわゆる gooey なエフェクトで「お父さん」を「サンタ」に2Dモーフィングさせるイメージ。

そこで SDF という概念を導入します。


SDF とは

SDF は Signed Distance Field(もしくは Signed Distance Function)の略。
日本語でいうと「符号付き距離場」もしくは「符号付き距離関数」。
簡単に言うとパスのエッジ(輪郭)からの距離(distance)情報です。

パスの内側か外側かで符号(sign)が変わります。
なので「符号付き」(signed)です。
パスの内側なら正、外側なら負という感じ。(逆でも良いです)

en.wikipedia.org


SDF テクスチャの作成

SDF 情報を 0〜255 の範囲に正規化し RGB チャンネル(のすべてもしくはいずれか)に格納したものが SDF テクスチャです。
SDF テクスチャというとなんだか凄そうですが、ただの画像です。普通の PNG です。
もちろん他の(できれば可逆圧縮の)フォーマットでも良いでしょう。
JPG は(圧縮率にも依りますが)こういった画像には向かず情報が劣化するのでおすすめしません。

SDF テクスチャは3次元/2次元の形状データやフォントファイルなど様々なファイルから生成できます。
今回は SVG(2次元形状)から SDF テクスチャ(PNG)を生成してみます。

(SVG などの2次元のパスデータからなら)(頑張れば)Adobe Photoshop だけでも SDF テクスチャを作れますが、本記事では以下のコマンドラインツール msdfgen を使います。
github.com

まずは「お父さん」と「サンタ」の SVG を作成します。

Adobe Illustrator で作成した「お父さん」と「サンタ」の SVG

msdfgen を用いて SVG から (M)SDF テクスチャを生成する際、SVG の構造に関して msdfgen 特有の注意点があります。
msdfgenGitHub ページを良く読むと以下の記載が。

Note that only the last vector path in the file will be used.

(SVG ファイルにパスが複数ある場合は最後のパスのみが使われます)

というわけで、SVG の<path>タグの数は必ず1つにしましょう。
そうしないと「犬」が「大」になったり、「玉」が「王」になったりしてしまいます。
※ Adobe Illustrator では複数パスを選択した状態で Command + 8 を押すと複合パスに変換できます


SVG ができたら msdfgen で SDF テクスチャを生成します。
msdfgen の詳しい使用方法は割愛しますが、ターミナルからコマンド一発 で生成できます。

$ msdfgen sdf -svg daddy.svg -scale 1 -size 1224 1224 -o daddy-sdf.png

※ 事前にコンパイルし、パスを通しておく必要があります


msdfgen で SVG から生成した「お父さん」と「サンタ」の SDF テクスチャが以下です。

「お父さん」と「サンタ」のSDF テクスチャ(1224 x 1224 px PNG)


SDF テクスチャの描画方法

SDF テクスチャを使い「お父さん」を WebGL で表示してみました。


SDF テクスチャ(1224 x 1224 px)

See the Pen Untitled by Tohl SMALLFIELD (@tsmallfield) on CodePen.


WebGL での描画結果(1224 x 1224 px)

See the Pen Untitled by Tohl SMALLFIELD (@tsmallfield) on CodePen.


頂点シェーダ

See the Pen santa-daddy.vs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

※ 特に変更なし


フラグメントシェーダ

See the Pen daddy-santa.sdf-fill-smoothstep.fs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.


SDF テクスチャに格納された符号付き距離情報を不透明度に変換するのは簡単です。
GLSL のビルトイン関数 step() を使った実装例を以下に示します。

float sdf2alpha(float sdf) {
    const float THRESHOLD = 0.5;

    float alpha = step(THRESHOLD, sdf);

    return alpha;
}

パスの内側なら正、外側なら負の距離情報が 0〜255、シェーダで読み出すときには 0〜1 に正規化されています。
距離が 0(パスの輪郭上)の場合シェーダでは(今回は)0.5 となります。
内側(0.5 未満 or 以下)なら不透明、それ以外、つまり外側なら透明という感じ。

よって、

  • sdf: SDF テクスチャに格納されたパスの輪郭からの距離情報

  • THRESHOLD: パスの内側か外側かを判断するためのしきい値(今回は0.5)

で2値化すれば

  • alpha: 不透明度

に変換できます。
※ しきい値を変えると「お父さん」と「サンタ」が痩せたり太ったりします


しかし、単純な2値化では「お父さん」と「サンタ」がガビガビになってしまいます。

SDF を step() で2値化した例

「お父さん」と「サンタ」をなめらかにしたい場合は step() の代わりに smoothstep() を使います。

float sdf2alpha(float sdf) {
    const float THRESHOLD = 0.5;
    const float BLUR_RADIUS = 0.01;

    float alpha = smoothstep(
        THRESHOLD - BLUR_RADIUS,
        THRESHOLD + BLUR_RADIUS,
        sdf
    );

    return alpha;
}
  • BLUR_RADIUS: ぼかし半径

を変えると「お父さん」と「サンタ」がぼんやりしたりシャキッとしたりします。

step() の代わりに smoothstep() を用い、輪郭をなめらかにした例


SDF の長所

SDF の長所の1つはデータが(部分的に)線形であることです。
線形なデータは線形補間ができます。

写真やロゴなど大抵の画像は拡大表示するとぼやけてしまいます。
線形ではないデータを線形と仮定して補間しているからです。


SDF テクスチャの解像度が描画結果に与える影響の検証

SDF テクスチャの解像度(画像の縦横サイズ)を徐々に小さくしそれを拡大して描画した際、描画結果にどう影響するか検証してみましょう。
なお、テクスチャを拡大表示(Magnify)する際のフィルターは gl.LINEAR (Linear Interpolation: 線形補間)としています。

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);


SDF テクスチャの解像度を描画サイズの 1/2 倍(50%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1/4 倍(25%)に縮小した例


よーし、さらに半分にして 153 px に、、と思ったのですが、大変です。
なんと 153 は奇数です。2 で割り切れません。
まぁ、割り切れなくてもいいんですが、割り切れたほうが気持ちいい。

元の SDF テクスチャの解像度(画像の縦横サイズ)を素直に 2 の累乗にしておけば、ミップマップみたいに 1/1, 1/2, 1/4, 1/8, 1/16, ...2-n と小さくしていけたのに。
今日はクリスマス・イブだからという安易な理由で 1224 px にしてしまったことを今激しく後悔しています。
1224 px ではなく 1024 px にしておけばよかった。

しかし時既に遅し。明日はもうクリスマスです。
1224 は 17 の倍数なので、気を取り直して 17 x 16 = 272 px から再スタートします。
素数じゃなくてよかった。
1日前の 1223 だったら大変でした。

SDF テクスチャの解像度を描画サイズの 2 / 9 倍(約22%)に縮小した例


7 / 36 倍(約19%)で "Daddy" の "y" が崩れてきました。


SDF テクスチャの解像度を描画サイズの 1 / 6 倍(約17%)に縮小した例


SDF テクスチャの解像度を描画サイズの 5 / 36 倍(約14%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1 / 8 倍(12.5%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1 / 9 倍(約11%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1 / 12 倍(約8%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1 / 18 倍(約6%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1 / 36 倍(約3%)に縮小した例


SDF テクスチャの解像度を描画サイズの 1 / 72 倍(約1%)に縮小した例

「お父さん」もう限界です。でもだいぶ頑張った。すごい。

SDF テクスチャは(普通のテクスチャと比較し)描画サイズに対して解像度をかなり小さくできることがわかりました。
※ ここで示した % はあくまで参考値です。テクスチャをどれだけ縮小できるかはパス形状やテクスチャ生成時のパラメータ次第で変わります


SDF の短所

SDF の短所はテクスチャの解像度(縦横サイズ)を小さくしていくと角がダレやすいことです。
完全に線形なデータではなくあくまでも部分的に線形であることの影響が角のダレとして表れてきます。

SDF テクスチャの解像度と角のダレ具合の関係

この短所を複数の SDF を組みわせることで改善したのが MSDF です。


MSDF とは

MSDF は Multi-channel Signed Distance Field の略です。
RGB チャンネルに3つの(異なる) SDF 情報が格納されています。
MSDF は複数の SDF の重ね合わせでシャープな角を表現します。

複数の曲線の重ね合わせでシャープな角を表現するイメージ


MSDF テクスチャの作成

MSDF テクスチャは上述の msdfgen で生成できます。
(というかむしろ MSDF を生成するためのツールですね)

$ msdfgen msdf -svg daddy.svg -scale 1 -size 1224 1224 -o daddy-msdf.png

「お父さん」と「サンタ」の MSDFテクスチャ

msdfgen で「お父さん」と「サンタ」の SVG ファイルから「お父さん」と「サンタ」の MSDF テクスチャを生成しました。
なんだかファンキーな「お父さん」と「サンタ」。格好いい。
RGB の3チャンネルに別々の SDF 情報が格納されているので色が鮮やかです。
RGB の3チャンネルにしか格納できないという制限を回避するため、赤緑青の領域がそれぞれ互いを避けるように面白い形をしています。


MSDF テクスチャの描画方法

MSDF テクスチャを使って「お父さん」を WebGLで描画してみました。


MSDF テクスチャ(1224 x 1224 px)

See the Pen Untitled by Tohl SMALLFIELD (@tsmallfield) on CodePen.


WebGL での描画結果(1224 x 1224 px)

See the Pen Untitled by Tohl SMALLFIELD (@tsmallfield) on CodePen.


頂点シェーダ

See the Pen santa-daddy.vs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

※ 特に変更なし


フラグメントシェーダ

See the Pen daddy-santa.msdf-fill-smoothstep.fs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

フラグメントシェーダでは MSDF テクスチャの

  • Rチャンネル
  • Gチャンネル
  • Bチャンネル

に格納された合計3つの SDF 情報からどれか1つを選ぶ必要があります。
ロジックはシンプル。
3つの値の中点(median)つまり「一番小さくもなく一番大きくもない値」を採用します。
中点を求める処理は msdfgenGitHub ページ に記載されています。

float median(float r, float g, float b) {
    return max(
        min(r, g),
        min(max(r, g), b)
    );
}

これで中点が求められるんですね。
ロジックを紐解こうとしましたが頭が混乱してきたので深く考えるのは来年にしておきます。

3つの SDF から中点の SDF を求めたら、不透明度への変換は SDF テクスチャの場合と同様です。


MSDF テクスチャの解像度が描画結果に与える影響の検証

SDF で「お父さん」が崩れはじめた解像度: 両辺 204 px から試しましょう。

204 px(約19%)崩れなし!角もシャープ!


170 px(約14%)も問題なし。相変わらず角もシャープ。


136px(約11%)で "y" がちょっと崩れてきました。しかし角は相変わらずシャープです。


102px(約8%)"y" がそろそろ限界です。しかし角はシャープなままです。


3種類(通常、SDF、MSDF)のテクスチャの解像度が描画結果に与える影響の比較

MSDF テクスチャで綺麗な描画結果が得られたぎりぎりの解像度: 両辺 170 px(約14%)で

  • 通常テクスチャ
  • SDF テクスチャ
  • MSDF テクスチャ

の描画結果を比較してみます。


通常のテクスチャはボケボケ。しかし step()smoothstep() で2値化すればもうちょっと良い結果になるかもしれません。


SDF。輪郭はきれいですが "y" の形も崩れ始めており角もダレています。


優勝候補のMSDF。角もシャープで輪郭も崩れなし!

  • 輪郭の綺麗さ
  • 角のシャープさ

で MSDF が一番優れた描画結果ですね。


SDF 情報の線形補間

(M)SDF の説明が長くなってしまいましたがようやく本題です。
SDF が線形補間に適している(データがほぼ線形なので)ことがわかったので、

  • 「お父さん」の SDF 情報
  • 「サンタ」の SDF 情報

を線形補間して gooey に2Dモーフィングしてみましょう。
角のシャープさを担保しながらテクスチャの解像度を小さくするため MSDF テクスチャを使います。

MSDF テクスチャ(306 x 306 px)

See the Pen Daddy and Santa MSDF Textures 306x306px by Tohl SMALLFIELD (@tsmallfield) on CodePen.


WebGL での描画結果(1224 x 1224 px)

See the Pen Daddy 2 Santa gooey Animation Interpolated between MSDF Textures by Tohl SMALLFIELD (@tsmallfield) on CodePen.


頂点シェーダ

See the Pen santa-daddy.vs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.

※ 特に変更なし


フラグメントシェーダ

See the Pen daddy2santa.msdf-mix.fs.glsl.jsm by Tohl SMALLFIELD (@tsmallfield) on CodePen.


いい感じに gooey になりました!


2Dモーフィングのロジックはシンプルです。

  • sdfDaddy: 「お父さん」 の MSDF テクスチャから取り出した SDF 情報(パスの輪郭からの符号付き距離情報)
  • sdfSanta: 「サンタ」 の MSDF テクスチャから取り出した SDF 情報

mix() で線形補間しているだけです。

線形補間した SDF 情報を2値化し不透明度に変換します。


JavaScript からシェーダに渡している uniform 変数は以下の2つです。

  • uSantaLevel:「お父さん」なのか「サンタ」なのかを表す 0〜1 の係数
  • uWeight: モーフィングする間に一瞬太らせるための 0〜1 の係数

※ オーバーシュートするようにしているのでちょっとはみ出ます


JavaScript から uniform 変数としてシェーダに渡している uSantaLeveluWeight の波形

モーフィングの処理より、この波形を生成する処理のほうが面倒なくらいです。
波形の操作に関しては一昨年のクリスマス・イブに書いた記事がありますので興味ある方はぜひ。

techblog.kayac.com


座標に応じてアニメーションに時間差をつける

ちょっとグレードアップしてみました。

  • 左下から右上に向かって時間差で徐々に変化していくようにしてみた
  • おまけで背景のアニメーションを追加

時間差は、uniform 変数からシェーダに送る2つの波形 uSantaLeveluWeight の位相を(板ポリの4つの)頂点ごとに少しずつずらすことで実現しています。
グラデーションのテクスチャを用意して、それを元に位相差を計算しても良いかもしれません。

WebGL での描画結果

See the Pen Untitled by Tohl SMALLFIELD (@tsmallfield) on CodePen.


まとめ

「お父さん」と「サンタ」の (M)SDF テクスチャを SVG から作成し、それぞれから「パスの輪郭からの符号付き距離情報」を取り出してそれらを線形補間することで、gooey に 2D モーフィングするアニメーションをご紹介しました。

(M)SDF はモーフィング以外にも

  • 文字やアイコンなどのパスを WebGL で描画したいけどテクスチャの解像度を抑えたい
  • パスの境界線を描きたい
  • 境界線をアニメーションさせたい
  • パスをぼかしたい

などなど応用次第でいろんな表現が可能です。
(M)SDF ぜひ使ってみてください。


そして、、
カヤック意匠部では
デザインもモデリングもフロントエンドもやってみたい!
そんなチャレンジングなアートディレクター&デザイナーを募集しています!

www.kayac.com


それでは、
Merry Christmas!


参考文献