【WebGL】シェーダーを使って3D空間でスプライトアニメーションさせる

この記事は、Tech KAYAC Advent Calendar 2017 の19日目の記事です。


こんにちは!カヤックのクライアントワークチーム・フロントエンドエンジニアのふかぽん です。WebGLを用いた3Dコンテンツを制作させていただくことが多いです。

過去のアドベントカレンダーではこんな記事を書かせていただきました。

2015 ... WebGLも怖くない!canvasライブラリを効率良く学ぶオススメの順番

2016 ... 【脱・gulp】npm-scriptsでシンプルなフロントエンド開発環境を作る


目次

はじめに

今回はタイトルのように、WebGLでスプライトアニメーションを実装する方法をご紹介していきます。

先日お仕事でWebGL開発をしている際、3D空間上にエフェクトとしてスプライトアニメーションを多用する機会がありいろいろと方法を調べていたので、順を追って解説していければなと思います。

完成形はこちらです!

f:id:takumifukasawa:20171218154647g:plain

jsdo.it

f:id:takumifukasawa:20171218154349p:plain

↑↑ こちらがスプライト画像です。1-16までの連番になっています。

WebGLでスプライトアニメーションさせる方法

他にもいくつかあるかと思いますが、ぱっと思いつく方法はこれらです。

  1. コマ画像(スプライト画像のフレームごと)を使いたい枚数分読み込んで画像ごとに板ポリゴンを作り、時間経過で板ポリゴンの表示非表示を切り替えていく

  2. 板ポリゴンにコマ画像のテクスチャを複数渡し、時間経過ごとにテクスチャ自体を切り替えていく

  3. スプライト画像のテクスチャを用い、テクスチャ座標(以下、UV座標)を時間経過ごとにずらしてスプライトアニメーションさせる


1番の場合、実装によってはドローコールが余計に増えてしまう上に、動かしたいコマ画像が多ければ多いほど画像のリクエスト数やテクスチャ数が増えて、メモリを圧迫する一要因になってしまうデメリットがあります。

2番の場合も、リクエスト数やテクスチャ数が増えてしまう点で同様のデメリットがあります。

サイト制作をする上でリクエスト数や描画負荷は抑えていきたい部分なので、これらのデメリットは避けたいところですよね。

1. UV座標をずらしてフレームを表示する

3番の、スプライト画像を用いてUV座標を時間経過ごとにずらしていく方法であれば、画像枚数は抑えられるかつ、ドローコールが多大に増加することもないので、ブラウザの負荷を抑えることのできる実装となります。

今回のサンプルではWebGLの一般的なライブラリであるThreejsを使っているのですが、スプライト画像によるアニメーション機能は含まれていないため自分でシェーダーを書く必要があります。

順番を追うためにまずはアニメーションさせず、「6」だけ表示させてみましょう。

1-1. UV座標を拡大縮小させる

f:id:takumifukasawa:20171218165226p:plain

まずUV座標のスケールを各フレームの大きさに合わせるために拡大縮小をします。サンプルの画像では縦横4個ずつなので、縦横それぞれ1/4に縮小する拡大縮小行列を用意します。

WebGLにおいてUV座標の原点は左下にあるので、上図のように縮小されることになります。

mat3 scaleMat = mat3(
  1.0 / 4.0, 0.0, 0.0, // 横列のセルの数分、横に縮小(フレーム4つ分縮小)
  0.0, 1.0 / 4.0, 0.0, // 縦列のセルの数分、縦に縮小(フレーム4つ分縮小)
  0.0, 0.0, 1.0
);

1-2. UV座標を平行移動させる

f:id:takumifukasawa:20171218170222p:plain

つづいて、UV座標を指定のセルの位置に平行移動させる行列を作ります。「6」のフレームは横列の左から2つ目に、縦列の下から3つ目に位置するので、拡大縮小後のUV座標は上図のような位置に移動されます。

注意するのは、縦列で下から数えている点です。これは前述のように、UV座標の原点が左下に位置しているためです。

mat3 translateMat = mat3(
  1.0, 0.0, 1.0, // 横に平行移動(横列の左から2つ目のフレームに平行移動)
  0.0, 1.0, 2.0, // 縦に平行移動(縦列の下から3つ目のフレームに平行移動)
  0.0, 0.0, 1.0
);    

1-3. 座標変換を適用する

先に求めた拡大縮小行列と平行移動行列の2つの行列をかけ合わせて、UV座標をずらす用の変換行列を作ります。WebGLの行列計算は列オーダーなので、掛け合わせる順番に注意が必要です。

こちらが頂点シェーダーの全容になります。今回は、変換行列ともともとのUV座標をフラグメントシェーダーに渡すようにしました。

varying vec2 v_uv;
varying mat3 v_spriteMat;
  
void main(void) {
  mat3 scaleMat = mat3(
    1.0 / 4.0, 0.0, 0.0, // 横列のフレームの数分、横に縮小(フレーム4つ分縮小)
    0.0, 1.0 / 4.0, 0.0, // 縦列のフレームの数分、縦に縮小(フレーム4つ分縮小)
    0.0, 0.0, 1.0
  );
    
  mat3 translateMat = mat3(
    1.0, 0.0, 1.0, // 横に平行移動(横列の左から2つ目のフレームに平行移動)
    0.0, 1.0, 2.0, // 縦に平行移動(縦列の下から3つ目のフレームに平行移動)
    0.0, 0.0, 1.0
  );    
    
  v_spriteMat = translateMat * scaleMat; // UV座標をずらす用の変換行列を作成
  v_uv = uv;
  
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}

1-4. 座標変換後のUV座標を元に画像の色を取得する

こちらはフラグメントシェーダーをまるっと記載したものです。頂点シェーダーから渡されたUV座標と変換行列を掛け合わせて各セルのUV座標を算出し、texture2Dで色を取得しています。頂点シェーダーと比べてシンプルですね。

これでまずは「6」のフレームを表示することができました!!!!!

「6」を表示するサンプルのリンクは以下になります。

uniform sampler2D u_texture;
  
varying vec2 v_uv;
varying mat3 v_spriteMat;

void main(void) {
  vec3 uv = vec3(v_uv, 1.0);
  uv *= v_spriteMat;
  vec4 smpColor = texture2D(u_texture, uv.xy / uv.z);

  gl_FragColor = smpColor;
}

f:id:takumifukasawa:20171218154142g:plain

jsdo.it

2. ループでアニメーション

いよいよアニメーションをさせる実装に入ります。さきほどの頂点シェーダーの一部を変更したものがこちらです。フラグメントシェーダーに変更点はありません。

javascript側では、uniform に u_time(requestAnimationFrameの現在時間)とu_startTime(アニメーションを始めた時間)を追加して、アニメーションループ内で u_timeを更新しています。そのu_timeとu_startTimeの差分を元に現在のフレームを求め、座標変換もそれに対応させました。

uniform float u_time;
uniform float u_startTime;

varying vec2 v_uv;
varying mat3 v_spriteMat;
  
void main(void) {
  float elapsedTime = u_time - u_startTime;
        
  float colNum = 4.0;
  float rowNum = 4.0;
  float fps = 4.0;
  float frameNum = 16.0;  
    
  float frameIndex = floor(mod(elapsedTime * fps, frameNum));    
    
  mat3 scaleMat = mat3(
    1.0 / colNum, 0.0, 0.0,
    0.0, 1.0 / rowNum, 0.0,
    0.0, 0.0, 1.0
  );

  mat3 translateMat = mat3(
    1.0, 0.0, mod(frameIndex, colNum),
    0.0, 1.0, rowNum - (floor(frameIndex / colNum) + 1.0),
    0.0, 0.0, 1.0
  );    
    
  v_spriteMat = translateMat * scaleMat;
  v_uv = uv;
  
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} 

3. 完成!

これでスプライト画像のアニメーションが完成しました!!!!!!!

完成形は以下のリンクになります。前述のjavascript側の変更もリンク先からご確認いただけます。

jsdo.it

f:id:takumifukasawa:20171218154647g:plain

最後に

カヤックではWebGLを書きたいフロントエンドエンジニアを大大大募集中です。

明日はフロントエンドエンジニアの大先輩 ゴンさん の記事です。

ありがとうございました!