PixiJS で画像に命を吹き込もう
背景
今年の1月に弊社の新年スペシャルコンテンツとして公開したゲーム(公開終了)で、猫や犬のイラストに表情を付けるアニメーションを実装しました。
画像をアニメーションさせる方法は CSS アニメーションや GIF アニメーション( APNG )、 SVG アニメーションなどがありますが、 CSS だけでは複雑な動きをさせるのが難しかったり、 GIF アニメーションは一つ一つのコマを作成しなければならなかったりと一長一短があります。
今回はデザイナーから、動物のイラストに多彩な動きをつけたい、でもたくさんのコマを作るのは大変…、という要望があったので別の方法を検討し、 2D のアニメーション描画が得意な PixiJS というライブラリを使うことにしました。
PixiJS でどんなことができるの?
公式の Examples (心なしか気持ち悪いものが多い)やフィルターデモでいろいろな実装例を見ることができますが、単純に画像の位置を動かすだけではなく、画像を変形させたりエフェクトを加えたりと CSS や GIF では難しそうなことも簡単に実現できます。また、 WebGL 実行されるので高速で滑らかにアニメーションします。
PixiJS の基本コード
※ PixiJS バージョン 4.6.2 時点
// 描画用 canvas 作成(既存の canvas 要素を指定することも可)
// canvasWidth, canvasHeight は任意
const app = new PIXI.Application(canvasWidth, canvasHeight)
document.body.appendChild(app.view)
// 画像を読み込んでスプライトを生成
const sprite = PIXI.Sprite.fromImage('image.png')
// 初期位置を設定
// x, y は任意
sprite.x = x
sprite.y = y
// もしくは
// sprite.position.set(x, y)
// canvas に表示するためにスプライトをステージに追加
// `PIXI.Container` でスプライトのグループを作って追加することも可
app.stage.addChild(sprite)
// アニメーションフレーム毎の処理を指定
app.ticker.add(delta => {
// 毎フレームの位置を変更することでアニメーションする
sprite.y += 0.1 * delta
})
これだけでアニメーションが実装できます。主要な機能だけであれば覚えるものはそこまで多くないと思います。より詳しく学びたい方はチュートリアルやリファレンスをご覧ください。
猫の画像に動きをつける
それでは実際に筆者が作成したアニメーションをもとに具体例を見ていきましょう。
こちらのデモをご覧ください。左上のラジオボタンを切り替えるとアニメーションが切り替わります。ガイド付きのデモも合わせてご覧ください。
しっぽ
猫の通常時ではしっぽが常に揺れていますが、これはパラパラ漫画風アニメーションではなく、こちらのたった1枚の画像を変形させて動かしています。四角い画像だとは思えない動きですよね。
このしっぽの動きは PIXI.mesh.Rope
の機能で実現しています。ロープの Example を見るとよく分かりますが、点をつないだロープに沿って画像が自動で曲がります。
// ロープのポイントの位置を設定(基本的に画像の中央に配置)
// width: 画像の幅
// height: 画像の高さ
// pointCount: ロープの分割数
const ropeLength = width / (pointCount - 1)
const points = []
for (let i = 0; i < pointCount; i++) {
points.push(new PIXI.Point(ropeLength * i, height / 2))
}
// ロープのポイントをしっぽの画像に適用する
const tail = new PIXI.mesh.Rope(PIXI.Texture.fromImage('tail.png'), points)
app.stage.addChild(tail)
// アニメーション中
app.ticker.add(delta => {
// 各ポイントの位置を動かすとそれに沿うように画像が変形する
for (let i = 0; i < points.length; i++) {
// x, y は任意
points[i].set(x, y)
}
})
どうして四角い画像がこのような動きをするのかというと、以下の画像を見ると分かりますが、元の画像を細かい三角形の集まりに変換してそれぞれ角度を変えています。ロープの点の間隔を狭くするほどより滑らかな動きになります。こういった処理をライブラリが自動でやってくれるので便利ですね。
ちなみに、猫のしっぽをリアルに表現するために少し工夫をしました。こちらのガイド付きのデモをご覧ください。
このようにロープを2分割にし、前半と後半(赤と青)で向きや速さを変えることでより猫らしい可愛い動きにすることができました。最初は全体を同じ計算で動かそうとしていましたがなかなか良い動きにならず、 YouTube の猫の動画を見ながら研究し試行錯誤した結果この手法にたどり着きました。
手
(左) 気づき (右) 考える
手も同じくロープで曲げています。
耳
たたむ
「困る」もしくは「悲しみ」に切り替えると耳をたたみます。
これは 2D Quad bilinear の Example と同じ仕組みを使用しています。4つの頂点を指定すればそれに合わせて画像の形が変わるというものです。 2D Quad projective も似ていますが、前者は単純に頂点からの距離に応じて色が決まる、後者は頂点が3次元の中にあるものとして立体的な画像として表示するという違いがあるようです。(気になる方は「バイリニア法」でググってみてください)
// 変形のための頂点生成関数
function createControlPoint (x, y) {
const square = new PIXI.Sprite(PIXI.Texture.EMPTY)
square.factor = 1
square.anchor.set(0.5)
square.position.set(x, y)
return square
}
// 画像の座標と幅、高さから4角の頂点を生成する関数
function createControlSquare (x, y, width, height) {
return [
createControlPoint(x, y),
createControlPoint(x + width, y),
createControlPoint(x + width, y + height),
createControlPoint(x, y + height)
].map(square => square.position)
}
// 耳の画像の座標と幅、高さを指定して変形用頂点を生成
const earSquare = createControlSquare(earX, earY, earWidth, earHeight)
// バイリニア法を使用可能なスプライトを生成
const ear = new PIXI.projection.Sprite2s(new PIXI.Texture.fromImage('ear.png'))
app.stage.addChild(ear)
// アニメーション中
app.ticker.add(delta => {
// 頂点の一部を任意の位置に動かす
earSquare[0].set(x, y)
earSquare[1].set(x, y)
// 更新した頂点を画像に適用して変形させる
ear.proj.mapBilinearSprite(ear, earSquare)
})
白い四角が頂点
このように、閉じるときはまず開いた状態の画像を下げていき、半分の位置まできたら閉じている画像を半分上げた状態に切り替え、そのまま完全に閉じた状態まで下げていきます。この流れをつなげてアニメーションさせることで閉じる動きを滑らかに表現できます。
開いた画像から閉じた画像に切り替わるときの変化が大きいですが、凝視しなければそこまで違和感ないと思います。(たぶん)
ピクピク
耳にはもう一つの動きをつけています。通常時は定期的に耳がピクピクする瞬間があります。
(左) 通常 (右) 縮んだ状態
耳はガイドで表示している黒い線を中心に縮めることで、耳をピクピクさせている様子を表現しています。実際の猫はこのような動きをしないかもしれませんが、なんとなくリアリティは増しているのではないかと思います。(適当)
こちらは、後述するフィルターという機能を使って画像を変形させています。この動きは独自にシェーダを書きました。
目
目も耳と同様に開いた状態と閉じた状態それぞれの画像をバイリニア法で変形させながら切り替えています。
また、「笑顔」や「怒る」「悲しみ」では目を開けた状態のまま形を微妙に変えています。デモで比較してみてください。
変身
動物の種類を猫以外にすると変身するようなアニメーションが入ります。ゲームのストーリーの中では夢から覚めるシーンで使われました。これも独自に書いたシェーダのフィルターを使っています。
また、独自フィルターだけではなくビルトインの BlurFilter
を使ってぼかし表現も加えています。
フィルターを適用するためのコードは以下のとおりです。
// フラグメントシェーダの GLSL コード文字列
// この場合、 HTML 上の script タグに記述した GLSL コードを読み込む(他、外部ファイルにして読み込む方法や JS に直接書く方法もある)
const fragmentSrc = document.getElementById('transform_frag').textContent
// `PIXI.Filter` で独自に書いたフラグメントシェーダからフィルターを生成
// `PIXI.Filter(頂点シェーダ, フラグメントシェーダ, uniforms 変数)`
// 画像にエフェクトをかけるだけなら頂点シェーダは不要
const transformFilter = new PIXI.Filter(null, fragmentSrc, {
time: { // 変数名
type: '1f', // 型
value: 0 // 初期値
}
})
// ビルトインのブラーフィルター
const blurFilter = new PIXI.filters.BlurFilter()
blurFilter.blur = 0
// 2つのフィルターをステージ全体に適用
// (前述の耳では画像のスプライトにだけフィルターを適用)
app.stage.filters = [transformFilter, blurFilter]
// アニメーション中
app.ticker.add(delta => {
// 独自フィルターの uniform 変数値を更新
transformFilter.uniforms.time = delta
// ブラーフィルターの値を更新
blurFilter.blur = delta * 30
})
変身用フラグメントシェーダの GLSL コードは以下のとおりです。
<script type="x-shader/x-fragment" id="transform_frag">
precision mediump float;
uniform sampler2D uSampler; // フィルターを適用したテクスチャ画像(内部変数)
uniform float time; // 独自に追加した uniform 変数
varying vec2 vTextureCoord; // テクスチャの座標(内部変数)
// 定数
const float PI = 3.1415926;
const float PI2 = PI * 2.0;
float size = 10.0;
float amplitude = 0.3;
// フラグメントシェーダではピクセルごとに出力する色を決定する
void main() {
// 現在のピクセルで取得するテクスチャ画像の座標を指定
vec2 textureCoord = vec2(vTextureCoord.x + sin(vTextureCoord.y * PI2 * size) * sin(time * 100.0) * sin(time) * amplitude, vTextureCoord.y);
// テクスチャ画像から指定した座標の色を取得し画面に出力する
gl_FragColor = texture2D(uSampler, textureCoord);
}
</script>
GLSL については JS とは違う知識が必要になるのでここでは割愛しますが、要はピクセルごとに出力する画像の色を少し横にずらしています。Y座標によってずらす量や向きを変えており、さらにsin関数を使うことで波状に揺れているように見せることができます。
このように自分で書いたシェーダを簡単に画像に適用することができます。独自にフィルターを作成する場合 GLSL のコードを書く必要があるため知識が必要になりますが、ビルトインのフィルターだけでは物足りないときに表現の幅を増やすことができます。
また、わざわざ自分でシェーダを書かなくても、ブラーのようにすぐ使えるビルトインフィルターがたくさんあり、これだけでも十分面白い表現ができます。 PixiJS で使えるフィルター一覧はドキュメント、実際の挙動をデモで確認することができます。
このように変身アニメーションは複数のフィルターを組み合わせて実装しました。
まとめ
PixiJS の機能を活用することで、数枚の静止画の画像を用意するだけでリアリティのある動きを実装することができました。イラストのような2次元の画像をアニメーションさせたいときは PixiJS は有力な選択肢になると思います。
ただ、座標の位置を手動で計算しなければならなかったりしてなかなか直感的に実装することができないので、時間もそれなりにかかってしまいます。
その分、よりクオリティの高いものにすることができるので、時間が許すようであれば是非試してみてください。
(おまけ)その他お世話になったもの
- pixi.jsのカスタムフィルターでGLSLを学んでみる
独自のシェーダをフィルターに使う方法が公式ページでは見当たらなかったのでこちらの記事を参考にさせていただきました。 - pixi.jsを勉強する[3] -Textured Mesh-
PixiJS に関する分かりやすい記事がいくつかあります。 - しっぽふりふり
しっぽの動きの参考にさせていただきました。こちらは CSS で実装しています。ロープの一つ一つのポイントを同じ角度ずつ曲げていけばいいということに気づきました。