この記事は、Three.js Advent Calendar 2016 7日目の記事です。
#消えたり動いたするパーティクルを作ろう!
パーティクル皆さん好きですか。
色々なwebglのサンプルにてパーティクルは公開されています。どれもキレイですね。一番なんて決めなくていいですね。世界で一つだけの粒です。
さて。その数々のキレイなサンプルですが、「じゃあ、自分のサイトにも実装してみよう!」となると、うーん?となること、ありませんかね。自分はなりました。
何故かというと、パーティクルって、沢山の粒が、【点いたり消えたり】して、もっとこう、パーって動くものだと思うのですよ。
多くのサンプルが、いわば【点いたまま】なんです。消えろよと。点いたり消えたりしろよと。
という感じで無駄に敵を増やしたところで、点いたり消えたりするパーティクルを実装したいと思います。
###一番近いサンプルは?
今回の実装に一番近いthree.jsのサンプルは、instanced circle billboards になります。このサンプルで行われていることを紐解いていきましょう。
##サンプルの構成
まず、THREE.InstancedBufferGeometry と THREE.CircleBufferGeometry が宣言されています。THREE.CircleBufferGeometry(x,y) は、半径xの y角形を、中心点を持った形で作成してくれるものです。サンプルでは6角形になっています。
次に、これをInstancedBufferGeometryにしています。いんすたんすどばっふぁじおめとりとは耳慣れない言葉です。バッファは、たまに横文字大好きな人が「緩衝」という意味で使うようですが、ここでのイメージは「せき止めておいて、まとめて流す」と考えてくれればいいかなと。
ジオメトリをInstancedBufferGeometryとして割り当てると、「割り当てた数分、同じ図形を表示しろ!」と、GPUに命令をします。ベースとなったサンプルでは、particleCount の数=7万5千個も一気に表示しろ!となります。
何それ?そんなことをしても、全部同じ位置に同じものが表示されるだけじゃねーの?ってお思いでしょう。普通にやったらそうなりますよね。
そこで、この機能を意味のあるものにするために、シェーダーと、それに渡すための「InstancedBufferAttribute」というものがでてきます。
##InstancedBufferAttributeとは
InstancedBufferAttributeの宣言方法は、奇妙な形になっているかと思います。これが分かりづらい要因ですね。ここは、three.jsよりも、生のwebglにとても近いところを触っているからです。
今更なのですが、three.jsは、javascriptのライブラリです。何を今更です。
もう使いすぎて忘れることもあるのですが、Vector3という【型】は、javascriptにはありません。three.js独自の拡張です。
何故こんなことを今更書くかというと、webglでは、「厳密な型宣言」が必要になるからです。
そのため、「3次元の位置情報、xyzだオラァ」とは、そのままではwebglは認識してくれません。
そのため、データ的には、Vector3型を一回忘れて、全て分解された単なるfloat型の配列として投げられ、後から、**「あ、今のデータ、3つで1組だからヨロシク!」と、シェーダー側で解釈をしてくれるようにする必要があります。**これが156行目の引数の「3」の意味です。
これが、7万5千個をまとめて表示しても、重ならず意味を持って表示されるカラクリです。これ最初に考えた人は、変態に限りなく近い天才だったのでしょう。
ということで、GPUは7万5千*3、えっといくつだ、たくさん!そんな配列が一度に、しかも複数きても難なく処理してくれます。バケモノですね。
この仕組みさえ分かってしまえば、パーティクルを自由自在に操ることができます。
//パーティクルを初期化する
function makeParticle()
{
//重要なのはココから
//円形オブジェクトの作成
geometry = new THREE.InstancedBufferGeometry();
geometry.copy( new THREE.CircleBufferGeometry( 1, 6 ) );
//入れ物を初期化していく
//シェーダに渡すには明確に【型】が決まってる必要があるため、こういうことを行う
translateArray = new Float32Array( particleCount * 3); //Vector3を入れるため、float型が3つ
colArray = new Float32Array( particleCount * 3); //Vector3を入れるため、float型が3つ
vectArray = new Float32Array( particleCount * 3); //Vector3を入れるため、float型が3つ
scaleArray = new Float32Array( particleCount * 1);
timeArray = new Float32Array( particleCount * 1);
//カウンタでぶん回してガンガン初期化
for ( var i = 0; i < particleCount; i ++) {
//いわば、パーティクルの初期位置に該当。どうせ書き換わるから気にするな
translateArray[i * 3 + 0] = 1.0;
translateArray[i * 3 + 1] = 1.0;
translateArray[i * 3 + 2] = 1.0;
//パーティクルの大きさをセットする入れ物
scaleArray[i * 3 + 0] = 0.0;
//【色】を管理する入れ物
colArray[i * 3 + 0] = 0.0;
colArray[i * 3 + 1] = 1.0;
colArray[i * 3 + 2] = 0.0;
//出現してからの時間管理の入れ物
timeArray[i * 3 + 0] = 0.0;
}
//用意した「入れ物」をシェーダー側(VS)に渡すための宣言。
//ここで重要なのは、JSコード側「のみ」で管理が必要なものは、渡す必要はない、ということ。
//そしてもちろん、VS側のattribute に、コレと同じ変数が用意されていることが大前提となる
geometry.addAttribute( "translate", new THREE.InstancedBufferAttribute( translateArray, 3, 1 ) );
geometry.addAttribute( "col", new THREE.InstancedBufferAttribute( colArray, 3, 1 ) );
geometry.addAttribute( "movevect", new THREE.InstancedBufferAttribute( vectArray, 3, 1 ) );
geometry.addAttribute( "scale", new THREE.InstancedBufferAttribute( scaleArray, 1, 1 ) );
geometry.addAttribute( "time", new THREE.InstancedBufferAttribute( timeArray, 1, 1 ) );
//シェーダー。困ったらコピペでイケる
material = new THREE.RawShaderMaterial( {
uniforms: {
map: { value: new THREE.TextureLoader().load( "textures/sprites/circle.png" ) },
time: { value: 0.0 }
},
vertexShader: document.getElementById( 'vshader' ).textContent,
fragmentShader: document.getElementById( 'fshader' ).textContent,
depthTest: true,
depthWrite: false,
transparent:true,
blending:THREE.AdditiveBlending
} );
//
mesh = new THREE.Mesh( geometry, material );
mesh.scale.set( 1, 1, 1 );
scene.add( mesh );
}
##罠!アップデートを忘れるな!
さて、ここまで読んで「もう分かった楽勝。後は読まなくていいや」と思った貴方。おかえりなさい。おそらく貴方が書いたソースは間違っていなくても、きっと「見た目が更新されない」という事態が待っていたでしょう。
これはthree.jsの仕様で、「頂点やマテリアルは、一度宣言した後には、基本的には変わらないことを前提にしている」ということがあるっぽく、変更したら、「今、変わったから!」と明示的に教える必要があります。それが needsUpdate です。
function updateParticle(_dulTime)
{
var onCount = 0;
for(var i=0; i < particleCount;i++){
if(timeArray[i] > 0.0){
onCount++;
timeArray[i] += _dulTime;
if(timeArray[i] > 1000){
//1秒経過していたら、消滅させる。
timeArray[i] = 0.0;
scaleArray[i] = 0.0;
}
}
}
console.log(onCount);
geometry.attributes.translate.needsUpdate = true;
geometry.attributes.col.needsUpdate = true;
geometry.attributes.movevect.needsUpdate = true;
geometry.attributes.scale.needsUpdate = true;
geometry.attributes.time.needsUpdate = true;
}
ということで、出現管理&情報更新管理ができたら、最後にこの呪文を唱えて完了です。
このneedsUpdate は、メッシュ頂点やいたるところに必要になることがあるので、何か更新されずにおかしいな?と思ったら、ひとまずやってみる、という勢いでもいいかもしれません。んで、メソッドが無いって怒られたら消す。と。
#まとめ
頭に「なんでサンプルのパーティクルは動かないのばっかりなんだよ!意味ねーだろ!」と言いましたが、きっと理由は、three.jsから離れた内容になるから、だったのかもしれません。っていうか今更ですが、これthree.jsのカレンダー記事で大丈夫ですかね。
ここまで来て理解できてしまった貴方には、生webglの深い沼の中、GPUで暖をとる通路に繋がる階段が目の前に現れました。さぁ恐れず登ってみましょう。
ですが、ちゃんとthree.jsに帰ってきてくださいね。約束ですよ・・・
それが、彼が残した、最期の言葉だった。