はじめに
前々からCanvasに興味はありましたが、なんとなく難しそうだなと敬遠していたところがありました。
気持ちを奮い立たたせて、WebGLで2Dグラフィックスの描画を行うのに便利なライブラリであるPixiJSのお勉強をしつつ、これを用いて**近日公開予定の映画『マトリックス』**の宣伝をしていきたいと思います。
ちなみに、いろいろCanvasのライブラリを調べた上でPixiJSを学ぶに至った経緯はありますが、ここではPixiJSの仕組みがどうとか、WebGL使うとメリットがどうとか、そのような話は避けて、実践的な内容を書いていきたいと思います。
作ったもの
まずは、今回作ったものを御覧ください。
これの実装を元にPixiJSの基本的な部分を説明します。
別窓で開いて、1300x800px以上の画面で閲覧すると私の意図した通りに表示されます(れすぽんしぶむずかちい)
See the Pen Matrix by heeroo-ymsw (@heeroo-ymsw) on CodePen.
開発環境
- webpack 4.43.0
- PixiJS 6.1.3
- GSAP 3.8.0
説明
一番最初シーンから、図形、テキスト、フィルタと、いろいろな要素が詰まっています。
まずは、この画面ができるまでを見ていけば(画像以外の)一通りの機能を見られると思いますので、ひとつずつ見ていきましょう。
Pixiアプリケーションの作成
PIXI.Application - API Document
まずは、何はともあれ枠作りから入ります。
大きさは画面いっぱいに広げてみます。
<main>
<div id="app"></div>
</main>
const app = new PIXI.Application({
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: 0x000000,
});
document.getElementById('app').appendChild(app.view);
app
というIDを持つ要素に対して、Pixiアプリケーションクラスのインスタンス(ここではappとしています)のレンダラが生成したCanvas要素を追加しています。
PIXI.Application
は、いろいろな機能(メンバ)をギュッとひとつにまとめて、レンダラを作成したりなんやかんやを簡略化してくれる便利クラスです。
例えば、レンダリングを行うレンダラ(renderer)、レンダラが生成するCanvas要素への参照(view)、レンダリングされるコンテンツの親コンテナ(stage)などの機能が詰まっています。
backgroundColor
に指定した、0x
から始まる値はJavaScriptにおける16進数記法になります。CSSでいつも指定しているようにカラーコードをこのあとに続けて書けば背景色を指定することが出来ます。
コンテナの生成
今回作ったものはシーンがいくつか分かれているので、それぞれのシーンをPremiere Proのシーケンスのようにひとまとめにしておきたいです。
そこでコンテナを使用します。読んで字の如く、子要素をまとめる入れ物になります。
後々出てくるフィルタなどをかけたり、これをapp.stage
に追加すればシーンに必要なオブジェクトをまるごと追加できたり何かと便利なので、とりあえずコンテナに入れときます。
const consoleContainer = new PIXI.Container();
図形の描画
それではいよいよ描画していきます。
図形を描画する際は、PIXI.Graphics
クラスを使います。
ひとつ勘違いしてはいけないことは、「PIXI.Graphics
は図形を描画するためのものではない」ということです。
たしかに図形描画の際に使いますが、本質はそこではありません。
例えば、矩形を描くためにはdrawRect()
メソッドを呼び出しますが、この時点でPixiJSは描画はしておらず、代わりに、描画した矩形をジオメトリ1リストに保存し、後で使用できるように準備しています。
その後に、Graphicsオブジェクトをシーンに追加することで、レンダラがGraphicsオブジェクトにレンダリングをリクエストします。ここまできてやっと、ジオメトリリストに追加した矩形が(もし他にdrawしていればそれも)実際に描画されます。
よって、PixiJSでは、Graphics
は描画するものではなく、構築するものである、という表現をしています。
Graphics
で構築したオブジェクトは、マスキングに使ったり、複雑な当たり判定を設定する時に使うことができます。
少し脱線しましたが、重要なポイントでしたので説明しました。
このポイントを抑えた上で、背景に配置する黒の四角形を描画していきましょう。
const bg = new PIXI.Graphics()
.beginFill(0x000000)
.drawRect(0, 0, window.innerWidth, window.innerHeight)
.endFill();
bg.cacheAsBitmap = true;
consoleContainer.addChild(bg);
メソッドチェーンで実行していきます。
塗りつぶす色を指定して(beginFill()
)、図形を描画して(drawHoge()
)、塗りつぶす(endFill()
)、の順で書きます。
cacheAsBitmap
をtrue
にすることで、テクスチャメモリを使用する代わりに、レンダリングが高速化されます。
ただし、頻繁にGraphics要素が変更される場合は使用が推奨されません。
テキストの表示
テキストを表示する際は、PIXI.Text
クラスを使います。
ビットマップフォントを用いる方法もありますが、今回は紹介しません。
今回はWebフォントを使いたいので、Loaderの追加プラグインであるpixi-webfont-loaderを使用します。
フォント自体は、天下のGoogle Fontsを利用させてもらいます。
import { WebfontLoaderPlugin } from 'pixi-webfont-loader';
PIXI.Loader.registerPlugin(WebfontLoaderPlugin);
app.loader
.add({ name: 'Courier Prime', url: 'https://fonts.googleapis.com/css2?family=Courier+Prime' }
.load(setup);
function setup() {
const chatTextStyle = new PIXI.TextStyle();
chatTextStyle.fontFamily = 'Courier Prime';
chatTextStyle.fontSize = 20;
chatTextStyle.align = 'left';
chatTextStyle.fill = '#46b843';
chatTextStyle.dropShadowColor = '#46b843';
chatTextStyle.dropShadowBlur = 10;
chatTextStyle.lineHeight = 1;
const chat1 = new PIXI.Text('Wake up, Neo...', chatTextStyle);
chatContainer.addChild(chat1);
}
PIXI.Text
では、表示したいテキスト内容とテキストスタイルを指定できます。
テキストスタイルは、直接オブジェクトを指定することもできますが、複数のテキストでこのスタイルを使用することが分かっていたので、PIXI.TextStyle
で別途TextStyleオブジェクトを生成しています。
インタラクションの有効化
PIXI.DisplayObject interactive - API Document
今回は右下のボタンをクリックすることで、チャットが進むようにしました。
説明を端折っていたのですが、PIXI.DisplayObject
クラスという、レンダリングされる全てのオブジェクトの基本クラスがあります。
Container
、Graphics
、Text
、そしてまだ説明していませんが画像等のテクスチャオブジェクトのベースであるSprite
など、全てのオブジェクトはこのDisplayObject
の派生クラスとなります。
つまり、DisplayObject
で提供される機能や属性は、全てのオブジェクトで使用できる共通のものであるということになります。
例えば、位置を指定する属性であるposition
、オブジェクトを削除するメソッドdestroy()
など、それらが使えます。
その中にインタラクションで使うメンバがあるので、これを利用します。
const btnContainer = new PIXI.Container();
btnContainer.interactive = true;
btnContainer.buttonMode = true;
interactive
をtrue
にすることで、インタラクティブになります。
これを、クリックやタップに反応させるには発生したイベントに対して行わせたい動きをバインドします。
注意点として、通常のDOMと異なりバブリングしません。バブリングをサポートしたい場合は、子オブジェクトのイベント処理部で親オブジェクトのイベントを明示的に再トリガする必要があります。
btnContainer.on('pointerdown', () => { alert('clicked!'); });
また、buttonMode
をtrue
にすることで、マウスオーバーしたときにマウスカーソルがポインタ(指のやつ)になります。
ポインタイベントの使用
PixiJSでは、マウス、タッチ、ポインタの3種類のインタラクションイベントがサポートされています。
マウスイベントは、マウスの移動、クリックなどによって、タッチイベントは、タッチ対応デバイスで発生しますが、ポインタイベントは両方の場合で発生します。
ほとんどの場合、ポインタイベントを使用して良いと思います。
ポインタイベント以外を使用するパターンとしては、入力タイプに応じて異なる操作モードをサポートするか、マルチタッチのインタラクションを実装したいときかと思います。
最適化
当たり判定のテストは、オブジェクトツリー全体を確認するので、複雑なプロジェクトでは最適化におけるボトルネックになります。
この問題を軽減するために、interactiveChildren
というプロパティがあります。
このプロパティは、インタラクティブにならないことが予めわかっている子ツリーを持つコンテナやその他のオブジェクトに対して、falseを設定することで、当たり判定テストをスキップさせることができます。
GSAPでのアニメーション
アニメーション制作ライブラリであるGSAPには、PixiJS用のプラグインPixiPluginが用意されています。
GSAPに慣れている人はアニメーションが非常に作りやすくなると思います。
例えば、コンソール画面でのカーソルのチカチカ点滅もGSAPのTimelineで実現しています。
const consoleCursorContainer = new PIXI.Container();
const consoleCursor = new PIXI.Graphics()
.beginFill(0x46b843)
.drawRect(0, 0, 15, 5)
.endFill();
consoleCursorContainer.addChild(consoleCursor);
const tl = gsap.timeline({ repeat: -1 });
tl.set(consoleCursor, { pixi: { alpha: 0 } }, '>0.6')
.set(consoleCursor, { pixi: { alpha: 1 } }, '>0.6');
フィルタ
よくよく見ると画面全体にノイズのようなものが走っているのが分かるかと思います。
PixiJSのブラーやノイズなどはビルトインされていますが、その他多くのフィルタはPixiJS Filtersから追加して使います。
今回は、@pixi/filter-crt、@pixi/filter-advanced-bloom、@pixi/filter-pixelateを利用しました。
フィルタ | 内容 |
---|---|
@pixi/filter-crt | CRTモニタ(ブラウン管モニタ)を再現するフィルタ |
@pixi/filter-advanced-bloom | ブルーム効果を付与するフィルタ |
@pixi/filter-pixelate | オブジェクトを“ピクセル”状に見せるフィルタ |
コンソール画面全体にこれらのフィルタを適用します。
const crtFilter = new CRTFilter({
curvature: 0.4,
lineWidth: 0.5,
lineContrast: 0.8,
vignetting: 0.2,
vignettingBlur: 0.3,
noise: 0.1,
});
consoleContainer.filters = [
crtFilter,
new AdvancedBloomFilter({ brightness: 1.5 }),
new PixelateFilter({ x: 1, y: 1 })
];
app.ticker.add(
() => {
crtFilter.seed = Math.random();
crtFilter.time = Math.random();
}
);
たったこれだけでいい感じのエフェクトがついちゃいます。非常に便利ですね。
ここまでで最初のシーンが出来上がりました。
スプライトとテクスチャ
さて、最初のシーンでは使いませんでしたが、まだ重要な要素が残っています。画像の表示です。
ここは少し時間をかけて説明していきます。
青い薬(のようなもの)をマウスホバーすると、ぼんやりニューヨークの街並みが現れます。
この部分を引用して説明します。
const nyScene = new PIXI.Container();
const nyBgTex = PIXI.Texture.from('/img/newyork.jpg');
const nyBg = new PIXI.Sprite(nyBgTex);
nyBg.width = window.innerWidth;
nyBg.height = window.innerHeight;
nyBg.anchor.set(0.5);
nyBg.x = window.innerWidth / 2;
nyBg.y = window.innerHeight / 2;
nyBg.alpha = 0;
nyBg.tint = 0x141e14;
nyScene.addChild(nyBg);
container.addChild(nyScene);
テクスチャ
テクスチャは、DisplayObjectで使用されるコアリソースの1つです。
テクスチャとしてロードしたものを、Spriteに渡し表示する流れです。
リソースのロードには、PIXI.Texture.from()
が使えます。このメソッドに渡せるものは、画像や動画のURL、canvas要素などいろいろあります。
ただ、これはプロトタイプ用のメソッドらしく、実際の制作物として使う場合はLoader
クラスを使います。これを使うことで、「リソース読み込みが終わるまで待つ」という動きを簡単に実装できます。
前述した、様々な処理を簡略化したヘルパークラスであるPIXI.Application
には、このloaderが含まれています。
const app = new PIXI.Application();
const sprites = {};
app.loader
.add('newyork', '/img/newyork.jpg')
.load((loader, resources) => {
sprites.newyork = new PIXI.Sprite(resources.newyork.texture);
});
スプライト
スプライトは、画面に表示されるイメージを表し、描画されるテクスチャと表示状態を含みます。
前述の通り、DisplayObjectのプロパティを使用することができます。
スプライトを作成するには、前述のテクスチャが必要です。テクスチャをプリロードしていない場合は、ロード後にスプライトがポップインするらしいです。
今回制作したものでは、スプライトの各メンバを以下のように設定しました。
nyBg.width = window.innerWidth;
nyBg.height = window.innerHeight;
nyBg.anchor.set(0.5);
nyBg.x = window.innerWidth / 2;
nyBg.y = window.innerHeight / 2;
nyBg.alpha = 0;
nyBg.tint = 0x141e14;
anchor
はスプライト、テキスト、テクスチャで使用できるメンバです。
(0, 0)
なら左上、(1, 1)
なら右下のように、パーセンテージ的な感じで、アンカーの座標を設定することができます。つまり、ピクセルサイズが分からずともアンカーを設定できるのでとても便利です。
その他のオブジェクトでは、pivot
を使用して基準点を決めます。こちらはピクセル指定になります。スプライト、テキスト、テクスチャでもこちらのメンバを使用することができます。
どちらもオブジェクトの大きさ以上の値を設定することが可能です。
tint
は、スプライトの色合いを設定できます。
今回はマトリックスっぽく緑に染めてみました。
文字が降ってくるやつ
雨のように文字が降ってくる、「マトリックスといったらこれだ!」的なアニメーションは、↑で公開されているものを参考にいたしました。
パフォーマンス向上のために文字はPIXI.Textではなくcanvasイメージとして扱ったりなど、めちゃくちゃ勉強になりましたし、かなり理解が深まったのでご興味がある方はこちらを一度参照してみると良いと思います。
まとめ
マトリックスらしきアニメーションを、PixiJSを使って表現してみました。
ガイドとAPIドキュメントを読み、作りたい映像を画面を考えながら作ったので、ソース全体はぐちゃぐちゃになってしまいましたが、PixiJSの仕組みを大体理解することが出来ました。
何かを表示するだけなら非常に簡単ですが、全体の設計を考えるとメモリリーク2やカリング3等、やはり気にしなければいけないことはいろいろありそうです。その点についても、親切にガイドに記載があるので非常に勉強になります。
使ってみた所感としては、フィルタが非常に魅力的に感じました。
今回使い切れていないフィルタも多くあり、効果的に使用できそうなものばかりでした。
もっと理解を深めて、また気づきがあれば記事にしていきたいと思います。