three.js + Web Font + 頂点シェーダ芸 Vol.1 (準備編)
※この記事はtkmh.me上で掲載している記事 (2016.12.05 掲載) を転載、加筆・修正したものです。
---------
先日、wgld.orgとWebGL総本山の管理人の@h_doxasさんにご依頼いただき、「初心者歓迎! GLSL で始めるシェーダコーディング ワークショップ!」というイベントで、セミナー講師をやらせていただきました。
WebGL総本山でもすでに記事になっております。
僕のテーマは「three.js + Web Font + 頂点シェーダ芸」ということで、Web Fontの文字を描画した矩形を頂点シェーダでグリグリ動かすというものだったのですが、持ち時間の45分間では説明しきれず、コードの詳細な内容までしっかり説明できなかったので、記事にまとめようと思います。
以下、セミナーで使用したスライドとサンプルコードです。
↓ソースコード(セミナー時より少し修正してます)
↓スライド
https://tkmh.me/study/glslWorkshop20161203/slide/
セミナーの際はステップごとにソースを分けて説明してましたが、この記事では最終的に出来上がるもののコードを詳細に辿っていきたいと思います。
が、長くなってしまったので3本立てとします。
・Vol.1 (準備編)
・Vol.2 (Geometry編)
・Vol.3 (シェーダ編)
github上にあるサンプルコードの「step5」というものが対象です。step5はこの記事を書くにあたり修正していますが、step1 ~ step4は修正していませんので、余計なコードが入っていたりコメントが間違ってたりするので、参考程度にしてください。。あしからず。。
右上のコントローラの「animation1」「animation2」「animation3」というボタンをクリックすると、それぞれ対応するアニメーションに切り替わるようになっています。
この記事を読む前に、国内でWebGLを学習する人の150%が閲覧するwgld.orgで、シェーダの基礎的なところと、テクスチャマッピングについてくらいは読んでおくと、さらに理解が深まると思います。
あとはthree.jsの基本的な概念、と3Dの基本的な知識、ちょっとした数学の知識(ベクトル・行列、ラジアン、三角関数)とかがあるとなお良いです。
ではまずはHTMLファイルから見ていきましょう。
HTML
<div id="contents">
<section id="main">
<canvas></canvas>
</section>
</div>
この#contents > #main > canvasがWebGLを描画するcanvasです。
シェーダのプログラムはそれぞれ
<script id="vertexShader" type="x-shader/x-vertex">
<script id="fragmentShader" type="x-shader/x-fragment">
に記述されています。
読み込んでいるJavascriptファイルは以下のとおりです。
ライブラリ群 (/assets/js/lib/ 以下)
・three.js
three.jsのライブラリ本体です。
・TrackballControls.js
マウス操作を追加するためのthree.jsプラグインです。
・TweenMax.js
TweenMax。トゥイーンライブラリです。
・dat.gui.js
dat.gui。手軽にGUIを追加するライブラリです。右上のコントローラがそれです。
・jquery.js
これは説明不要かと思います。便利なので未だに使ってます。
・webfontloader.js
Web Font Loader。Web Fontの読み込み完了時に何かしらの処理を実行するために読み込むライブラリ。
自作jsファイル (/assets/js/step5/ 以下)
・FloatingCharsGeometry.js
THREE.BufferGeometryを拡張したFloatingCharsGeometryクラスを定義したjsファイル。
・FloatingChars.js
THREE.Meshを拡張したFloatingCharsクラスを定義したjsファイル
・MainVisual.js
メインの処理が書かれているMainVisualクラスを定義したjsファイル
・index.js
DOM ReadyのタイミングでMainVisualクラスをインスタンス化しているだけのjsファイル
では、jsの内容に入っていきますが、その前に。
今回、three.jsの基礎から踏み込んで応用するために、three.jsの基本的な概念を理解しておく必要があるのですが、それはこちらの@yomotsuさんの記事に簡潔にかつ的確に書いてありますので、こちらをさらっと見ておくと良いと思います。
さて、three.jsの基本的概念を理解した上で、今回やりたいことをおさらいします。
大量の正方形にWeb Fontの文字を適用して、それをグリグリ動かしたい
これが最終的な目標です。これを達成するためにはいくつかクリアする課題があります。
まず、
・大量の正方形を動かしたい
こちらは実はthree.jsのAPIを使うだけで簡単に実現できてしまいます。要はMeshをたくさん作ってSceneに追加して、それぞれの座標を動かしてやればいいわけですが、それをやってしまうとMeshの数が増えてくると重くて使い物にならなくなります。
何故かと言うと、「Meshを増やす = ドローコールを増やす」ということだからです。ドローコールとは描画の命令のことです。ドローコールが増えると、パフォーマンスはどんどん低下していきます。
それを防ぐため、Meshを1つにします。そしてそれを実現するため、THREE.BufferGeometryを使用し、大量の正方形をもつGeometryを生成し、それぞれの正方形の頂点の計算を頂点シェーダで行おう、という目論見です。
もちろん正方形の数が増えてくるとパフォーマンスは低下していきますが、ドローコールが増える場合とは比較にならないほどのパフォーマンスを発揮します。
続いて、
・Web Fontの文字を正方形に描画したい
Web Fontを使用した文字をどうやって描画するかというと、メインで描画しているcanvasとは別のcanvasを用意し、fillTextというメソッドを使用してオフスクリーンレンダリングしたものをTextureとして使用すれば実現できます。
そして、
・それぞれの正方形に別々の文字を描画したい
これはちょっと工夫が必要になってきます。もしMeshを大量に作った場合であれば、それぞれのMeshがもつMaterialに、それぞれの文字を描画したcanvasを使用したTextureを適用してやればできます。(つまり大量のオフスクリーン用のcanvasを生成する)
しかしMeshは1つです。つまりMaterialも1つ。uniform変数でテクスチャを大量に送るという手もなくはないですが、全然スマートじゃないですし、そもそもリソースにも限界があります。
というわけで、今回はオフスクリーン用のキャンバスを1つ用意し、使用するすべての文字を1つのキャンバスにグリッド状に描画し、それをTextureとして使用します。そして正方形ごとにUV座標をうまく調節して、それぞれ別の文字を描画させます。そのUV座標の調節を頂点シェーダ内で行います。
最後に、
・アニメーションをトランスフォームさせたい
これに関しては前回の記事でちょこっと触れているんですが、こちらに関してはソースを見てもらったほうがわかりやすいと思います。後ほど解説します。
流れとしてはこんな感じです。あとはおまけとして1文字ずつ色を変えたり、カメラからの距離によって透明度を変えてちょっと奥行き感を演出したりしています。
前置きが長くなってしまいましたが、実際のコードを見ていきましょう。まずはメインの処理が記述してある、MainVisual.jsです。
コンストラクタ以下のような処理を行っています。引数が省略された場合はデフォルトの値が設定されるようになっています。
ちなみにIndex.js内で引数なしでインスタンス化されています。
MainVisual.js
・コンストラクタ
/**
* メインビジュアルクラス
* @param {number} numChars - テクスチャの文字数
* @param {number} charWidth - 文字の幅 [px]
* @param {number} numTextureGridCols - テクスチャの1行文の文字列 [px]
* @param {number} textureGridSize - テクスチャの1文字分の幅 [px]
*/
sample.MainVisual = function(numChars, charWidth, numTextureGridCols, textureGridSize) {
// 文字数 = 正方形の数
this.numChars = numChars || 1000;
// 文字の幅[px] (geometryの1文字の幅)
this.charWidth = charWidth || 4;
// テクスチャの1行文の文字列
this.numTextureGridCols = numTextureGridCols || 16;
// テクスチャの1文字分の幅
this.textureGridSize = textureGridSize || 128;
// アニメーション適用度
// 頂点シェーダ内でアニメーションが3つ定義されており
// それらを切り替えるための値
this.animationValue1 = 1;
this.animationValue2 = 0;
this.animationValue3 = 0;
// イニシャライズ
this.init();
}
コンストラクタ内では各種変数の初期化と、initメソッドの呼び出しです。
charWidthは描画される文字の大きさ (正方形の1辺)、numTextureGridColsはオフスクリーンのcanvasに文字を描画する際、横方向に描画する文字数です。
textureGridSizeはオフスクリーンのcanvasに描画する際の1文字文のサイズです。例えばnumTextureGridColsが16で、textureGridSizeが128、使用する文字数が32文字の場合、テクスチャのサイズは
横幅 128 * 16 = 2048[px]
縦幅 Math.ceil(32 / 16) * 128 = 256[px]
となります。つまり、16文字が2行分ですね。
・initメソッド
続いてinitメソッド内の処理です。WebGLRenderer, Camera, Sceneと、メインのオブジェクト(FloatingChars)のイニシャライズの処理が記述されています。
ここでマウスでグリグリ動かせるようにするためにTHREE.TrackballControlsを使用しているんですが、コンストラクタを呼び出す際に注意が必要です。
THREE.TrackballControlsについてはこの記事に詳しく書いてあります。また、three.js公式のサンプルも用意されています。ご覧ください。
// controls
// 第二引数にthis.renderer.domElementを指定しておかないと、dat.guiのGUIがうまく操作できない
this.controls = new THREE.TrackballControls(this.camera, this.renderer.domElement);
コメントの通り、第二引数に操作の対象となるDOM Elementを指定してあげないと、操作の対象がwindow全体になってしまい、dat.guiのGUI(右上のコントローラー)の操作ができなくなってしまいます。
そして、initメソッドの最後の部分でFloatingCharsのイニシャライズ処理をしています。
// THREE.Meshを拡張したFloatingCharsをイニシャライズ
// 非同期処理が終了したらリサイズイベントを発火して、アニメーション開始
this.initFloatingChars()
.then(function() {
// resizeイベントを発火してキャンバスサイズをリサイズ
self.$window.trigger('resize');
// アニメーション開始
self.start();
});
・initFloatingCharsメソッド (一部抜粋)
initFloatingCharsメソッドは非同期処理(フォントのロード)が含まれており、その処理が完了したらアニメーションが始まるようになっています。
(Promiseの処理がわからない場合はこの辺を見ておくと良いかと思います。PixelGrid様々です。)
/**
* floatingCharsをイニシャライズ
*/
sample.MainVisual.prototype.initFloatingChars = function() {
var self = this;
return new Promise(function(resolve) {
// webfont load event
WebFont.load({
// Google Fontを使用
google: {
families: [ self.fontFamily ] // フォント名を指定
},
active: function(fontFamily, fontDescription) {
// ロード完了
console.log('webfonts loaded');
// FloatingCharsインスタンス化
self.floatingChars = new sample.FloatingChars(
self.numChars,
self.charWidth,
self.numTextureGridCols,
self.textureGridSize
);
// テクスチャをイニシャライズ
// 第一引数は使用する文字 (ユニーク)
// 第二引数は使用するフォント名
self.floatingChars.createTxtTexture('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|:;?<>,.', self.fontFamily);
// シーンに追加
self.scene.add(self.floatingChars);
// dat.guiのGUIを生成
self.createDatGUIBox();
// 完了
resolve();
}
});
});
}
フォントの非同期処理は以下のような感じになってます。
Web Font Loaderの詳しい使い方はgithubをご覧ください。
WebFont.load({
// Google Fontを使用
google: {
families: [ 'フォント名' ] // フォント名を指定
},
active: function(fontFamily, fontDescription) {
// ロード完了時の処理をここに記述
}
});
今回はロード完了のコールバック内でFloatingCharsのイニシャライズ、Texture生成を行って、Sceneに追加しています。
・startメソッド
アニメーションを開始するstartメソッドは、enterFrameHandlerというクロージャを定義してあり、requestAnimationFrameによって定期的に呼ばれます。
そしてenterFrameHandlerないでupdateメソッドが呼ばれています。よってループ内で実行される処理はupdateメソッドに記述すればOKです。
/**
* アニメーション開始
*/
sample.MainVisual.prototype.start = function() {
var self = this;
var enterFrameHandler = function() {
requestAnimationFrame(enterFrameHandler);
self.update();
};
enterFrameHandler();
}
・updateメソッド
updateメソッドでは、controls(TrackballControls)の更新と、floatingCharsの更新をして、WebGLRendererのrenderメソッドを実行して描画を更新しています。
/**
* アニメーションループ内で実行される
*/
sample.MainVisual.prototype.update = function() {
this.controls.update();
this.floatingChars.update();
this.renderer.render(this.scene, this.camera);
}
・resizeメソッド
resizeメソッドは、その名の通りリサイズ時に実行されるメソッドです。
/**
* リサイズ処理
* @param {jQuery.Event} e - jQueryのイベントオブジェクト
*/
sample.MainVisual.prototype.resize = function() {
this.width = this.$window.width();
this.height = this.$window.height();
// TrackballControlsのリサイズ処理を実行
this.controls.handleResize();
// カメラの設定を更新
this.camera.aspect = this.width / this.height;
this.camera.updateProjectionMatrix();
// WebGLRendererの設定を更新
this.renderer.setSize(this.width, this.height);
}
initメソッドの以下の箇所でウィンドウリサイズのイベントにバインドしています。
// ウィンドウリサイズイベント
this.$window.on('resize', function(e) {
// resizeメソッドを実行
self.resize();
});
・createDatGUIBoxメソッド
createDatGUIBoxメソッドは、右上に表示されるdat.guiの設定をしています。詳しい使い方はこちらのページで。
/**
* dat.gui
* dat.guiのコントローラーを定義
*/
sample.MainVisual.prototype.createDatGUIBox = function() {
var self = this;
// dat.gui
var gui = new dat.GUI()
// スライダーのGUIを追加
var controler1 = gui.add(this, 'animationValue1', 0, 1).listen();
var controler2 = gui.add(this, 'animationValue2', 0, 1).listen();
var controler3 = gui.add(this, 'animationValue3', 0, 1).listen();
// 値をアニメーションさせるためのボタンを設置
// それぞれをクリックすると、animation1, animation2, animation3メソッドが呼ばれる
gui.add(this, 'animation1');
gui.add(this, 'animation2');
gui.add(this, 'animation3');
// 値の変更時にuniform変数の値を変更
controler1.onChange(function(value) {
self.floatingChars.setUniform('animationValue1', value);
});
controler2.onChange(function(value) {
self.floatingChars.setUniform('animationValue2', value);
});
controler3.onChange(function(value) {
self.floatingChars.setUniform('animationValue3', value);
});
}
具体的には、アニメーションを切り替えるためのアニメーション適用度(animationValue1, 2, 3)を調節するためのGUIの設定です。このGUI上での値が変更されるたび、FloatingCharsのsetUniformメソッドが呼ばれ、シェーダに送る値が更新されます。
animation1、animation2、animation3メソッドの実態はコードを見て分かる通りanimateメソッドを引数付きで実行しているだけです。animateメソッドは、animationValue1, 2, 3の値をアニメーションさせる機能をTweenMaxを使用して実装しています。
animationValue1, 2, 3の使い方に関しては、後ほど詳しく説明します。
FloatingChars.js
・コンストラクタ
MainVisualのコンストラクタで渡す値をほとんどそのままFloatingCharsのコンストラクタにも渡しています。
/**
* THREE.Meshを拡張した独自3Dオブジェクトクラス
* @param {number} numChars - テクスチャの文字数
* @param {number} charWidth - 文字の幅 [px]
* @param {number} numTextureGridCols - テクスチャの1行文の文字列
* @param {number} textureGridSize - テクスチャの1文字分の幅
*/
sample.FloatingChars = function(numChars, charWidth, numTextureGridCols, textureGridSize) {
this.numChars = numChars;
this.charWidth = charWidth;
this.numTextureGridCols = numTextureGridCols;
this.textureGridSize = textureGridSize;
// カスタムジオメトリオブジェクトをインスタンス化
geometry = new sample.FloatingCharsGeometry(this.numChars, this.charWidth);
// RawShaderMaterial生成
material = new THREE.RawShaderMaterial({
// 文字以外の部分は透過
transparent: true,
// 正方形の両面を描画
side: THREE.DoubleSide,
// シェーダに渡すuniform変数の定義
uniforms: {
// canvasに記述した文字から作ったtextureを渡す
txtTexture: { type: 't' },
// 時間経過 updateメソッド内でフレームごとに加算していく
time: { type: '1f', value: 0 },
// 文字数 = 正方形の数
numChars: { type: '1f', value: this.numChars },
// Textureの横方向の文字数
numTextureGridCols: { type: '1f', value: this.numTextureGridCols },
// Textureの縦方向の文字数
numTextureGridRows: { type: '1f', value: 1 },
// テクスチャとして使用する文字数 (文字を何種類使うか)
textureTxtLength: { type: '1f', value: 1 },
// アニメーション適用度
animationValue1: { type: '1f', value: 1 },
animationValue2: { type: '1f', value: 0 },
animationValue3: { type: '1f', value: 0 }
},
// 頂点シェーダのプログラムをindex.htmlのscript#vertexShaderから取得
vertexShader: $('#vertexShader').text(),
// フラグメントシェーダのプログラムをindex.htmlのscript#fragmentShaderから取得
fragmentShader: $('#fragmentShader').text()
});
// 継承元のTHREE.Meshのコンストラクタを実行
THREE.Mesh.call(this, geometry, material);
}
コンストラクタ内では、FloatingCharsGeometryによるGeometryの生成と、three.jsでシェーダを使用するためのMaterialであるRawShaderMaterialの生成を行い、それらを引数として拡張元のクラスであるTHREE.Meshのコンストラクタを実行してます。
RawShaderMaterialはコンストラクタの引数のオブジェクトにuniformsというオプションがあり、これを指定することによってシェーダに値を送ることができます。
インスタンスのプロパティとしても持っているので、
this.material.uniforms.txtTexture.value = value;
このように直接値を書き換えることができます。現に、コンストラクタ実行時には決められない値をcreateTxtTextureメソッド内でセットしていたり、updateメソッド内で時間経過を格納するtimeの値を加算しています。
また、MainVisualクラスからuniformsの値を設定するためのsetUniformメソッドも定義してあります。
・setUniformメソッド
/**
* uniformの値をセット
*/
sample.FloatingChars.prototype.setUniform = function(uniformKey, value) {
this.material.uniforms[uniformKey].value = value;
}
・createTxtTextureメソッド
続いてcreateTxtTextureメソッドです。これは名前の通り。正方形に設定する文字のテクスチャを生成して、コンストラクタで生成したRawShaderMaterialのuniformsにテクスチャを設定します。
canvas2DのAPIの説明等は割愛しますが、canvasをグリッド状に分割し、そのグリッドの各正方形の中心に1文字ずつ文字を描画します。
/**
* テクスチャを生成
* @param {string} txt - テクスチャとして使用したい文字列
* @param {string} fontFamily - フォント名
*/
sample.FloatingChars.prototype.createTxtTexture = function(txt, fontFamily) {
var textureTxtLength = txt.length;
var numTextureGridRows = Math.ceil(textureTxtLength / this.numTextureGridCols);
this.txtCanvas = document.createElement('canvas');
this.txtCanvasCtx = this.txtCanvas.getContext('2d');
this.txtCanvas.width = this.textureGridSize * this.numTextureGridCols;
this.txtCanvas.height = this.textureGridSize * numTextureGridRows;
// canvasのスタイルを設定 (グリッドのサイズの80%をfontSizeとする)
this.txtCanvasCtx.font = 'normal ' + (this.textureGridSize * 0.8) + 'px ' + fontFamily;
// グリッドの中心に描画
this.txtCanvasCtx.textAlign = 'center';
// 文字色は白
this.txtCanvasCtx.fillStyle = '#ffffff';
var colIndex;
var rowIndex;
for(var i = 0, l = textureTxtLength; i < l; i++) {
// 横方向のインデックス
colIndex = i % this.numTextureGridCols;
// 縦方向のインデックス
rowIndex = Math.floor(i / this.numTextureGridCols);
// canvasに文字を描画
this.txtCanvasCtx.fillText(
txt.charAt(i),
// textAlignをcenterに設定すると、
// 基準位置が第一引数ので指定された文字列の中央になるので
// 横方向は各グリッドの中心座標を指定する
colIndex * this.textureGridSize + this.textureGridSize / 2,
// 縦方向はベースラインの位置を指定する
rowIndex * this.textureGridSize + this.textureGridSize * 0.8,
this.textureGridSize
);
}
// canvasからthree.jsのテクスチャを生成
this.txtTexture = new THREE.Texture(this.txtCanvas);
this.txtTexture.flipY = false; // UVを反転しない (WebGLのデフォルトにする)
this.txtTexture.needsUpdate = true; // テクスチャを更新
// シェーダに渡す値をセット
// テクスチャ
this.material.uniforms.txtTexture.value = this.txtTexture;
// テクスチャの縦の文字数
this.material.uniforms.numTextureGridRows.value = numTextureGridRows;
// テクスチャとして使う文字の種類 (txtは1文字ずつユニークである前提)
this.material.uniforms.textureTxtLength.value = textureTxtLength;
// document.body.appendChild(this.txtCanvas);
// $(this.txtCanvas).css('background-color', '#000');
// $('#wrapper').remove();
}
ちなみに、下記のコメントアウトされている部分をコメントアウトを解除すると、テクスチャに使用するキャンバスがDOMツリーに追加され、どういうテクスチャが生成されているかが確認できます。
デフォルトのままだと、横16文字 x 縦4文字のテクスチャが生成されるはずです。そしてグリッドの正方形のサイズは128pxです。
そして、このメソッドを実行することによって決まるuniform変数の値があるので、それらをセットします。
// シェーダに渡す値をセット
// テクスチャ
this.material.uniforms.txtTexture.value = this.txtTexture;
// テクスチャの縦の文字数
this.material.uniforms.numTextureGridRows.value = numTextureGridRows;
// テクスチャとして使う文字の種類 (txtは1文字ずつユニークである前提)
this.material.uniforms.textureTxtLength.value = textureTxtLength;
numTextureGridRows、textureTxtLengthは頂点シェーダ内でそれぞれの正方形のUV座標を決定するのに必要な値です。
準備編は個々までです。続きはVol.2 (Geometry編)で。
サポートいただければ、レッドブルを飲んでより頑張れると思います。翼を授けてください。