WebGLのライブラリと言えばThree.js。サンプルも豊富だしドキュメントも揃っていて学習しやすそう!ということで最近になって勉強しはじめました。
ちょこちょこサンプルを眺めているうちにだんだん自分でも作ってみたくなり…そこにちょうど良くアドベントカレンダーがやってきた!よし!この機会に頑張って何か作ってみよう!
ということでThree.jsでSound Visualizerを作ってみました。
実装手順をざっくり。流れは簡単です。
- DOMにcanvasタグを埋め込む
-
new THREE.AudioLoader()
で音源をロード - ロードした音源から周波数データを配列で取得
- 取得した値を変数にして、モデルを描画したり移動させたり
実装にかかった時間
実質3~4日ほどかかりました…。「Three.jsって最適化どうやるの…?読み込みにめっちゃ時間かかる…」「うーん…ハイハットの音だけ拾いたいんだけどどうやるんだろう…」といった感じで、実装したい理想のイメージと知識量の差に絶望愕然したり、全然関係ないherokuのデプロイでコケてハマったりと、全くもって生産性の低い開発でした。
なにはともあれデモ
スクショ
なんかスクショだけみるとマトリ◯クスみたいな感じですが、周波数情報として取得してきた1024個の配列データ(それぞれ0~255の値が格納)をTHREE.FontLoader
で取り込んだあるフォントの数字の頂点情報を元にgeometryを作成してmeshにアタッチしていくだけです。
URLはこちら
https://sound-visualizer-with-threejs.herokuapp.com/
オーディオの読み込みに20秒くらいかかってしまうのはどうにもできなかったので、結構ロードに時間がかかるかもしれません。あとスマホでは未検証です…。本当に申し訳ございませんm(_ _)m締め切りまでに間に合わなかったので、あとで再度解決方法を調査しようと思います…。
リポジトリはこちら
お世話になった/元気づけられたソース
https://threejs.org/
もちろん公式ドキュメントが一番参考になりました。
初めてのゲームが作れない君(僕)へ
Twitterのタイムラインで偶然こちらの記事を発見したのですが、非常に励みになりました。うだうだ悩んでないでまずは制作物を出す。アウトプットを出す。うーんつまらない。でもココとココをこうするとどうなるんだろうか?ほうほう…なるほど(以下略)。
といった感じで、まずはやってみるのが一番!!この精神が素晴らしい!うん!
実装手順
Three.jsのサンプルをコピーする
https://threejs.org/examples/#webaudio_visualizer
↑↑実際にコピーしてみたソース。意外と少ないコード量で表現できるなんて感動!
なにはともあれまずは先人の知恵を学ぶことが良いと思ったので、公式サイトのサンプルをコピー。使われているメソッドやオブジェクトで知らないものがあったら(殆ど知らんかった)、MDNでググりまくる。ホント最近気づいたんですがMDNって最高ですね。
サンプルコードを参考に自分でアレンジしてみる
上記のサンプルを作ってみたら、それを参考に他のモデル(キューブやスフィアなど)と組み合わせて、色々と遊んでみる。やはり実装したコードが動くのは楽しい…。
お題を決める
ひと通り遊んだ(ハマった)ところで、そろそろ何を作ろうか決める。普通の立体モデルはチュートリアルなどで勉強していたんですが、文字を表現するのはまだやったことがなかったので、new THREE.FontLoader
をつかって何かをすることに決めました。周波数の高さを文字の大きさ、色の変化で表現してみるのもおもしろいかもしれません(面白くないかも)。
DOMが描画された後にフォントのJSONデータ、オーディオなどをロード
まずはあらかじめJSONファイルにしておいたフォントをロードしてきます。今回は数字しか使わないので、0-9までの数字のみを含んだJSONファイルを用意しておきます。
JSONファイルの生成は公式ドキュメントのREADMEに記載してあった以下のサイトを使用。指定した文字のみを抽出することもできるので便利です。
http://gero3.github.io/facetype.js/
フォントファイルをロードした後、GeometryとMaterialを生成してMeshにあらかじめ格納しておきます。このあたりの配列データはあとあとアニメーションでループ処理する際に使用する予定ですが、後述しますが、周波数データは0~255の値の組み合わせで表されるので、0~255までの数字を表したGeometryを作成することになります。
window.onload = function(){
var fontLoader = new THREE.FontLoader();
fontLoader.load('assets/fonts/plstk-min.json', function(font){
fontObj = font;
for(let l = 0; l < 256; l++){
fontGeometries.push(new THREE.ShapeGeometry(fontObj.generateShapes(l, 1.5, 0)));
fontMaterials = new THREE.MeshBasicMaterial({
color: 0x006699,
opacity: 0.4,
side: THREE.DoubleSide
});
}
for(let i = 0; i < grid; i++){
for(let j = 0; j < grid; j++){
mesh = new THREE.Mesh(fontGeometries[0], fontMaterials[0]);
mesh.position.set( -(WIDTH / 2) + i * (WIDTH / grid + grid / 3) + grid * 2, (HEIGHT / 2) - j * (HEIGHT / grid + 2), 0 );
mesh.rotateX(Math.PI / 2.5);
scene.add(mesh);
}
}
init();
});
オーディオから取得した周波数情報を変数にアニメーションをループ
フォントファイルのロードが終わったら、canvas要素を生成して初期化し、cameraやrendererなどを用意していきます。最後にオーディオファイルをロードしていきます。ロード後のコールバック処理の中でanimate関数を実行させて、アニメーションをスタートさせます。
function init(){
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x000000);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
window.addEventListener('resize', () => {
WIDTH = window.innerWidth;
HEIGHT = window.innerHeight;
renderer.setSize(WIDTH, HEIGHT);
}, false);
camera = new THREE.OrthographicCamera( WIDTH / - 2, WIDTH / 2, HEIGHT / 2, HEIGHT / - 2, 0.1, 1000 );
controls = new THREE.OrbitControls(camera);
controls.autoRotate = true;
camera.position.set( 0, -500, 500.0 );
camera.lookAt( new THREE.Vector3() );
var audioLoader = new THREE.AudioLoader();
var listener = new THREE.AudioListener();
camera.add(listener);
var audio = new THREE.Audio(listener);
analyser = new THREE.AudioAnalyser(audio, fftSize);
audioLoader.load("assets/audios/bensound-goinghigher-min.mp3", function(buffer){
audio.setBuffer(buffer);
audio.setLoop(true);
audio.play();
animate();
});
};
function animate(){
requestAnimationFrame(animate);
renderNumberMap();
};
analyser.getFrequencyData();
で取得した周波数の情報は1024個の配列データが渡されてきます。この一つ一つのデータには0~255の値が格納されているので、冒頭で用意したGeometry(0~255までの数字を表現する頂点情報たち)を代入して更新していきます。その他、データの大きさに比例して座標やサイズ、色を変えるなどの処理を加えていきます。特に200を超える値のときは指数関数を使って少しサイズ拡大を強調するようにしてみました。
function renderNumberMap(){
data = analyser.getFrequencyData();
for(let k = 0; k < data.length; k++){
scene.children[k].geometry = fontGeometries[data[k]];
scene.children[k].scale.setScalar((data[k] + 1) * 0.06);
scene.children[k].position.setZ(data[k] * 0.5);
scene.children[k].rotation.set(Math.PI / 2.5, 0, 0);
scene.children[k].material.color.setRGB(0.0 + (data[k] / 256), 1.0 - (data[k] / 256), 1.0 - (data[k] / 256));
if(data[k] > 200){
scene.children[k].scale.setScalar(Math.pow((data[k] + 1) * 0.06, 1.1));
}
}
renderer.render(scene, camera);
};
};
ロード時間がくっそ長い…
さて、これで実装は終わりなんですが、オーディオファイルのロードが長いためか、再生スタートするまでに20秒くらいかかってしまいました。なんであなたはそんなに時間がかかるの…。
- オーディオファイルを圧縮(3.9MB => 2.0MB)
- フォントファイルの英字部分の情報を削除(42KB => 7KB)
などなどやってみたのですが、全然解決せず。ですよね…。検証ツールで確認してみたところ Network Request
にかなりの時間がかかっている模様。ただこのNetwork Requestを短くするのも良いんだけど、ストリーミング再生みたいなのできたりしないのかなあ…。調べてもまだ解決策は見つからず。。
残った課題
- ロード時間くっそ長い
-
[Violation] 'requestAnimationFrame' handler took 405ms
というwarningが結構出てくる - もっとフォント軽くできる気がする
- 創造性が乏しい()
とにかく動くものが作れたので今回は良しとするものの、やってみて新たな疑問点や勉強すべき点が出てきたのでそれは万々歳!エンジニア楽しい!ものづくり最高!
実装したコードをみていただけると分かると思いますが、結構なクソコードを書いていると思うので、もし「こうした方が良いよ」などのご指摘がありましたら容赦なく突っ込んでいただけるとありがたいですm(_ _)m