出たら消える!実用的?パーティクル実装例

  • 8
    いいね
  • 0
    コメント

この記事は、Three.js Advent Calendar 2016 7日目の記事です。

消えたり動いたするパーティクルを作ろう!

パーティクル皆さん好きですか。
色々なwebglのサンプルにてパーティクルは公開されています。どれもキレイですね。一番なんて決めなくていいですね。世界で一つだけの粒です。

さて。その数々のキレイなサンプルですが、「じゃあ、自分のサイトにも実装してみよう!」となると、うーん?となること、ありませんかね。自分はなりました。
何故かというと、パーティクルって、沢山の粒が、【点いたり消えたり】して、もっとこう、パーって動くものだと思うのですよ。
多くのサンプルが、いわば【点いたまま】なんです。消えろよと。点いたり消えたりしろよと。

という感じで無駄に敵を増やしたところで、点いたり消えたりするパーティクルを実装したいと思います。

完成品サンプルはこちら
zipでくれ

一番近いサンプルは?

今回の実装に一番近い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に帰ってきてくださいね。約束ですよ・・・

それが、彼が残した、最期の言葉だった。