Three.jsのGPGPUのサンプルが難しすぎるから解体して勉強してみる

  • 86
    いいね
  • 0
    コメント

概要

本エントリーはGPUパーティクルを飛ばすために筆者が勉強した軌跡です。

本エントリーの対象者

  • シェーダーが何なのかは知ってる
  • ちょっとくらいならGLSL書いたことある
  • だけどGPUに演算をさせるのはやったことない
  • サンプルのスクリプトがわけわからん

こんな人を想定して書いてます。
GLSLについての基本中の基本については解説しません。
そこらへんがまだわからない方は本エントリーについてはさらっと目をとおして、
初心者向けの学習資料を読み漁ってください!

GPGPUでパーティクルたくさん飛ばしたいからサンプルを覗いてみる

three.jsの素晴らしいサンプル達

screencapture-threejs-org-examples-webgl_gpgpu_birds-html-1481813036938.png
https://threejs.org/examples/webgl_gpgpu_birds.html

screencapture-threejs-org-examples-webgl_gpgpu_protoplanet-html-1481813042713.png
https://threejs.org/examples/webgl_gpgpu_protoplanet.html

screencapture-threejs-org-examples-webgl_gpgpu_water-html-1481813044941.png
https://threejs.org/examples/webgl_gpgpu_water.html

サンプルがむずかしい

  • 複雑な数学的要素がコードの読解の邪魔
  • パーティクル飛ばしたいだけだっつの

結局このなかでも比較的簡単そうな重力とかを計算してるこいつを解読してみることに。
https://threejs.org/examples/webgl_gpgpu_protoplanet.html


そもそもパーティクル専用のオブジェクトがあるからそれ使えば?っていうツッコミが来そうですけど、私は当時、一から作りたかったのです。勉強も兼ねて。

削りに削ってランダムに飛ぶだけのパーティクルにしてみた!

gpuparticle_forQiita03.gif

今回はこれを目指します。
なんと25万つぶつぶです!

シェーダーで演算するというのはどういうことか

Group 3.jpg

すごいざっくりした解説。ここまではまあわかる。

しかしシェーダーをちょこちょこいじったことが有る人ならわかるが、配列なんかを用意してさくっと情報を連番で保存することが難しい…。
ここでテクスチャの登場である。テクスチャはrgbaの情報を格納するためのものだが、今回はむりやりxyzwと言った感じで座標を保存するために利用させて頂きます。
そしてこのテクスチャが複数あれば…

Group 2.jpg

こんな感じにでワンフレームごとにピンポンを繰り返すことで、自由な演算ができます。
JavaScriptだけだと、何万という頂点情報が格納された配列をfor文で回して配列の中を書き換えていくわけですが、
これをGPU側で並列にドカンと処理してしまおうというもの。

肝心のソースコード

<!DOCTYPE html>
<html lang="en">
<head>
    <title>three.js webgl - gpgpu - protoplanet</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
    <style>
        body {
            background-color: #000000;
            margin: 0px;
            overflow: hidden;
            font-family:Monospace;
            font-size:13px;
            text-align:center;
            text-align:center;
            cursor: pointer;
        }
        a {
            color:#0078ff;
        }
        #info {
            color: #ffffff;
            position: absolute;
            top: 10px;
            width: 100%;
        }
    </style>
</head>
<body>



<script src="./js/three.js"></script>
<script src="js/Detector.js"></script>
<script src="js/stats.min.js"></script>
<script src="js/dat.gui.min.js"></script>
<script src="js/OrbitControls.js"></script>

<script src="js/GPUComputationRenderer.js"></script>


<!-- Fragment shader for protoplanet's position -->
<script id="computeShaderPosition" type="x-shader/x-fragment">
            // 現在の位置情報を決定する
            #define delta ( 1.0 / 60.0 )
            void main() {
                vec2 uv = gl_FragCoord.xy / resolution.xy;
                vec4 tmpPos = texture2D( texturePosition, uv );
                vec3 pos = tmpPos.xyz;
                vec4 tmpVel = texture2D( textureVelocity, uv );
                // velが移動する方向(もう一つ下のcomputeShaderVelocityを参照)
                vec3 vel = tmpVel.xyz;

                // 移動する方向に速度を掛け合わせた数値を現在地に加える。
                pos += vel * delta;
                gl_FragColor = vec4( pos, 1.0 );
            }
        </script>

<!-- Fragment shader for protoplanet's velocity -->
<script id="computeShaderVelocity" type="x-shader/x-fragment">

            // 移動方向についていろいろ計算できるシェーダー。
            // 今回はなにもしてない。
            // ここでVelのx y zについて情報を上書きすると、それに応じて移動方向が変わる
            #include <common>

            void main() {
                vec2 uv = gl_FragCoord.xy / resolution.xy;
                float idParticle = uv.y * resolution.x + uv.x;
                vec4 tmpVel = texture2D( textureVelocity, uv );
                vec3 vel = tmpVel.xyz;

                gl_FragColor = vec4( vel.xyz, 1.0 );
            }
        </script>

<!-- Particles vertex shader -->
<script type="x-shader/x-vertex" id="particleVertexShader">


            #include <common>
            uniform sampler2D texturePosition;
            uniform float cameraConstant;
            uniform float density;
            varying vec4 vColor;
            varying vec2 vUv;
            uniform float radius;



            void main() {
                vec4 posTemp = texture2D( texturePosition, uv );
                vec3 pos = posTemp.xyz;
                vColor = vec4( 1.0, 0.7, 1.0, 1.0 );

                // ポイントのサイズを決定
                vec4 mvPosition = modelViewMatrix * vec4( pos, 1.0 );
                gl_PointSize = 0.5 * cameraConstant / ( - mvPosition.z );

                // uv情報の引き渡し
                vUv = uv;

                // 変換して格納
                gl_Position = projectionMatrix * mvPosition;
            }
        </script>

<!-- Particles fragment shader -->
<script type="x-shader/x-fragment" id="particleFragmentShader">
            // VertexShaderから受け取った色を格納するだけ。
            varying vec4 vColor;
            void main() {

                // 丸い形に色をぬるための計算
                float f = length( gl_PointCoord - vec2( 0.5, 0.5 ) );
                if ( f > 0.1 ) {
                    discard;
                }
                gl_FragColor = vColor;
            }
        </script>


<script>
    if ( ! Detector.webgl ) Detector.addGetWebGLMessage();
    // 今回は25万パーティクルを動かすことに挑戦
    // なので1辺が500のテクスチャを作る。
    // 500 * 500 = 250000
    var WIDTH = 500;
    var PARTICLES = WIDTH * WIDTH;

    // メモリ負荷確認用
    var stats;

    // 基本セット
    var container, camera, scene, renderer, geometry, controls;





    // gpgpuをするために必要なオブジェクト達
    var gpuCompute;
    var velocityVariable;
    var positionVariable;
    var positionUniforms;
    var velocityUniforms;
    var particleUniforms;
    var effectController;

    init();
    animate();
    function init() {


        // 一般的なThree.jsにおける定義部分
        container = document.createElement( 'div' );
        document.body.appendChild( container );
        camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 5, 15000 );
        camera.position.y = 120;
        camera.position.z = 200;
        scene = new THREE.Scene();
        renderer = new THREE.WebGLRenderer();
        renderer.setClearColor( 0x000000 );
        renderer.setPixelRatio( window.devicePixelRatio );
        renderer.setSize( window.innerWidth, window.innerHeight );
        container.appendChild( renderer.domElement );
        controls = new THREE.OrbitControls( camera, renderer.domElement );
        stats = new Stats();
        container.appendChild( stats.dom );
        window.addEventListener( 'resize', onWindowResize, false );


        // ***** このコメントアウトについては後述 ***** //
//        effectController = {
//            time: 0.0,
//        };


        // ①gpuCopute用のRenderを作る
        initComputeRenderer();

        // ②particle 初期化
        initPosition();

    }


    // ①gpuCopute用のRenderを作る
    function initComputeRenderer() {

        // gpgpuオブジェクトのインスタンスを格納
        gpuCompute = new GPUComputationRenderer( WIDTH, WIDTH, renderer );

        // 今回はパーティクルの位置情報と、移動方向を保存するテクスチャを2つ用意します
        var dtPosition = gpuCompute.createTexture();
        var dtVelocity = gpuCompute.createTexture();

        // テクスチャにGPUで計算するために初期情報を埋めていく
        fillTextures( dtPosition, dtVelocity );

        // shaderプログラムのアタッチ
        velocityVariable = gpuCompute.addVariable( "textureVelocity", document.getElementById( 'computeShaderVelocity' ).textContent, dtVelocity );
        positionVariable = gpuCompute.addVariable( "texturePosition", document.getElementById( 'computeShaderPosition' ).textContent, dtPosition );

        // 一連の関係性を構築するためのおまじない
        gpuCompute.setVariableDependencies( velocityVariable, [ positionVariable, velocityVariable ] );
        gpuCompute.setVariableDependencies( positionVariable, [ positionVariable, velocityVariable ] );


        // uniform変数を登録したい場合は以下のように作る
        /*
        positionUniforms = positionVariable.material.uniforms;
        velocityUniforms = velocityVariable.material.uniforms;

        velocityUniforms.time = { value: 0.0 };
        positionUniforms.time = { ValueB: 0.0 };
        ***********************************
        たとえば、上でコメントアウトしているeffectControllerオブジェクトのtimeを
        わたしてあげれば、effectController.timeを更新すればuniform変数も変わったり、ということができる
        velocityUniforms.time = { value: effectController.time };
        ************************************
        */

        // error処理
        var error = gpuCompute.init();
        if ( error !== null ) {
            console.error( error );
        }
    }

    // restart用関数 今回は使わない
    function restartSimulation() {
        var dtPosition = gpuCompute.createTexture();
        var dtVelocity = gpuCompute.createTexture();
        fillTextures( dtPosition, dtVelocity );
        gpuCompute.renderTexture( dtPosition, positionVariable.renderTargets[ 0 ] );
        gpuCompute.renderTexture( dtPosition, positionVariable.renderTargets[ 1 ] );
        gpuCompute.renderTexture( dtVelocity, velocityVariable.renderTargets[ 0 ] );
        gpuCompute.renderTexture( dtVelocity, velocityVariable.renderTargets[ 1 ] );
    }

    // ②パーティクルそのものの情報を決めていく。
    function initPosition() {

        // 最終的に計算された結果を反映するためのオブジェクト。
        // 位置情報はShader側(texturePosition, textureVelocity)
        // で決定されるので、以下のように適当にうめちゃってOK

        geometry = new THREE.BufferGeometry();
        var positions = new Float32Array( PARTICLES * 3 );
        var p = 0;
        for ( var i = 0; i < PARTICLES; i++ ) {
            positions[ p++ ] = 0;
            positions[ p++ ] = 0;
            positions[ p++ ] = 0;
        }

        // uv情報の決定。テクスチャから情報を取り出すときに必要
        var uvs = new Float32Array( PARTICLES * 2 );
        p = 0;
        for ( var j = 0; j < WIDTH; j++ ) {
            for ( var i = 0; i < WIDTH; i++ ) {
                uvs[ p++ ] = i / ( WIDTH - 1 );
                uvs[ p++ ] = j / ( WIDTH - 1 );
            }
        }

        // attributeをgeometryに登録する
        geometry.addAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
        geometry.addAttribute( 'uv', new THREE.BufferAttribute( uvs, 2 ) );


        // uniform変数をオブジェクトで定義
        // 今回はカメラをマウスでいじれるように、計算に必要な情報もわたす。
        particleUniforms = {
            texturePosition: { value: null },
            textureVelocity: { value: null },
            cameraConstant: { value: getCameraConstant( camera ) }
        };



        // Shaderマテリアル これはパーティクルそのものの描写に必要なシェーダー
        var material = new THREE.ShaderMaterial( {
            uniforms:       particleUniforms,
            vertexShader:   document.getElementById( 'particleVertexShader' ).textContent,
            fragmentShader: document.getElementById( 'particleFragmentShader' ).textContent
        });
        material.extensions.drawBuffers = true;
        var particles = new THREE.Points( geometry, material );
        particles.matrixAutoUpdate = false;
        particles.updateMatrix();

        // パーティクルをシーンに追加
        scene.add( particles );
    }


    function fillTextures( texturePosition, textureVelocity ) {

        // textureのイメージデータをいったん取り出す
        var posArray = texturePosition.image.data;
        var velArray = textureVelocity.image.data;

        // パーティクルの初期の位置は、ランダムなXZに平面おく。
        // 板状の正方形が描かれる

        for ( var k = 0, kl = posArray.length; k < kl; k += 4 ) {
            // Position
            var x, y, z;
            x = Math.random()*500-250;
            z = Math.random()*500-250;
            y = 0;
            // posArrayの実態は一次元配列なので
            // x,y,z,wの順番に埋めていく。
            // wは今回は使用しないが、配列の順番などを埋めておくといろいろ使えて便利
            posArray[ k + 0 ] = x;
            posArray[ k + 1 ] = y;
            posArray[ k + 2 ] = z;
            posArray[ k + 3 ] = 0;

            // 移動する方向はとりあえずランダムに決めてみる。
            // これでランダムな方向にとぶパーティクルが出来上がるはず。
            velArray[ k + 0 ] = Math.random()*2-1;
            velArray[ k + 1 ] = Math.random()*2-1;
            velArray[ k + 2 ] = Math.random()*2-1;
            velArray[ k + 3 ] = Math.random()*2-1;
        }
    }



    // カメラオブジェクトからシェーダーに渡したい情報を引っ張ってくる関数
    // カメラからパーティクルがどれだけ離れてるかを計算し、パーティクルの大きさを決定するため。
    function getCameraConstant( camera ) {
        return window.innerHeight / ( Math.tan( THREE.Math.DEG2RAD * 0.5 * camera.fov ) / camera.zoom );
    }



    // 画面がリサイズされたときの処理
    // ここでもシェーダー側に情報を渡す。
    function onWindowResize() {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize( window.innerWidth, window.innerHeight );
        particleUniforms.cameraConstant.value = getCameraConstant( camera );
    }


    function animate() {
        requestAnimationFrame( animate );
        render();
        stats.update();
    }



    function render() {

        // 計算用のテクスチャを更新
        gpuCompute.compute();

       // 計算した結果が格納されたテクスチャをレンダリング用のシェーダーに渡す
        particleUniforms.texturePosition.value = gpuCompute.getCurrentRenderTarget( positionVariable ).texture;
        particleUniforms.textureVelocity.value = gpuCompute.getCurrentRenderTarget( velocityVariable ).texture;
        renderer.render( scene, camera );
    }
</script>
</body>

サンプルに目を通した方ならわかると思いますが、けっこうダイエットに成功したソースコードになっております。
適宜コメントアウトをいれておりますが、GPGPUあたりを細かく解説していくと文字量がとんでもなくなるので、
このソースコードになるように

https://threejs.org/examples/webgl_gpgpu_protoplanet.html
このサンプルのソースコードを削ってみてください!そうすればなんとなくわかってきます。
この領域までくるとソースコードを部分ごとに破壊して表示結果がどう変化するかを体験したほうが理解が速いと思います。

あとdoxasさんの資料もおすすめです。
GPGPU でパーティクルを大量に描く

とはいえ、一応ソースコードをざっくり解説

今回用意したシェーダーは3つです

  • 頂点情報のシェーダー(computeShaderPosition : fragmentのみ)
  • 移動方向を決定するシェーダー(computeShaderVelocity : fragmentのみ)
  • パーティクルを描写するためのシェーダー(particleVertexShaderとparticleFragmentShader)

重要なのは上の2つで、一番上が頂点情報をワンフレームごとに記録するメモリの役割をします。
2番め(computeShaderVelocity)で移動方向を決定します。パーティクルの動きはここで制御できるので、ためにしcomputeShaderVelocityに

vel.y = 0.0;
vel.z = 0.0;

以上のコードをvelが宣言された後に追記してみましょう。そうすると…

gpuparticle_forQiita04.gif

x方向の動きだけになりました。なんとなくわかってきましたか?

発展系

simpleGPGPU_study11.gif
simpleGPGPU_study12.gif
gpuparticle_wave_test05.gif
https://murasaki-uma.github.io/webgl_vj/frame.html

サンプルページでは

"<"キーでアニメーションスタート
">"キーを押してる間はスピードダウン

という設定になっているので、少し遊んでください。MBP2015程度なら普通に動くはずです。

解説は以上です!情報がまだ足りないという方は遠慮なくコメント欄やTwitterの方から質問を飛ばしてください。
また、シェーダーに関しては私もまだまだ素人なので、気になる部分を見つけた先生方も、遠慮なくコメント欄からご指摘いただければ幸いです!