77
71

More than 5 years have passed since last update.

LiquidFunでJavaScript流体シミュレーション.

Posted at

LiquidFunって?

LiquidFunはGoogleが開発している2次元流体演算エンジン.
C++で開発されているが、Emscriptenでの変換により、JavaScriptでも利用可能である。
(似たような話が3次元物理演算エンジンのBulletとammo.jsがありますね。ammo.jsはポーティング方法をEmascriptenから見直すらしいですが...)

LiquidFun.jsを触ってみよう

さて、ここからが本題.

http://google.github.io/liquidfun/ にもliquidfun.jsのデモ(testbed)が埋め込まれているが、自分のお勉強もかねて、サンプルコードをガリガリと作成したので、学んだことを書き連ねてみる.

作成したブツ

first_liquidfun.png

正三角形・長方形の剛体を液体(紫色と青色のツブツブ部分)にむけて落下させるだけのサンプルである(jsfiddleで動作確認できます).

コードは少し長いので、末尾に付記。

LiquidFun.js本体の取得

https://github.com/google/liquidfun/releases/ から最新版のzipなりをDL・展開すると、liquidfun/Box2D/lfjs/liquidfun.js に.jsファイルがある.

liquidfun.js以外にも同フォルダに.jsファイルがあるが、無視してよいっぽい.

全体の流れ

他のシミュレータと同様、LifuidFun利用時の大まかな流れは下記。

  1. 初期化
    • 環境(=b2World)の定義
  2. シミュレーション対象物体配置
    • 剛体(=b2Body)の定義, 環境への剛体追加
    • 流体(=b2ParticleGroup)の定義, 環境への流体追加
  3. ループ
    • 環境の更新
    • 環境から剛体や流体情報を取得、描画

LiquidFun自体には、描画系の機構は一切含まれていないので、

  • 環境内形状の座標や角度、大きさ等の形状情報取得までをLiquidFun.
  • 取得した情報の出力(描画)は別途描画系のプログラムが必要(WebGLライブラリやCanbas, SVG等)

ガイドを読む限りは、OpenGLを意識した作りになっているため、クライアントJavaScriptであれば、Three.jsなどWebGLのライブラリを使うのが真っ当か.

環境

環境の定義

何はともあれ、環境定義しないと始まらない。

var gravity = new b2Vec2(0, -10);
var world = new b2World(gravity);

といっても、重力の向きと強さを指定した上で、b2Wolrdのインスタンスを作成するだけ。

但し、一点注意がある.
b2World インスタンスは、絶対にworldの名前でグローバル変数宣言すること.
他の変数名にしたり、グローバルから参照出来ないスコープに入れると、後述のCreateFixtureFromShape()メソッドでReferenceErrorがスローされる(どう考えても只のバグだろうが...).

環境の更新

アプリケーションのループ処理の中で、シミュレーション状態を更新していくために呼び出す処理。
ブラウザなら、window.requestAnimationFrameと組み合わせることが多いはず。

function render(){
  world.Step(1.0/60.0, 8, 3);
  requiestAnimationFrame(render);
}

Step()メソッドの引数は、「時刻差分」、「速度決定のイテレーション回数」, 「位置決定のイテレーション回数」である.
第2, 第3引数のイテレーション回数については、大きくすれば精度が上がるが、計算時間もその分上がる類のパラメータ。
一回のステップ内にて、個々の粒子や剛体速度/位置の決定に何回のイテレーションを行うか、を意味している。

本家の開発ガイドによると、イテレーション回数は第1引数とのトレードオフもある。
30FPSで20回イテレーションするよりも、60FPSで10回イテレーションした方が良い、とのこと。

剛体

「剛体」は形状が変化しない物体のこと.
今回のサンプルでは、「全体の境界となっている箱」、「落下してくる多角形」が剛体.

剛体の作成

LiquidFunでは剛体の種類は幾つか用意されていて、 b2BodyDef.prototype.typeで指定する。

  • b2_staticBody
    静的剛体. 明示的にポジションを変更しない限り動かない. デフォルト.
  • b2_kinematicBody
    自身の速度により、移動する剛体. 重力影響や衝突判定は計算されない.
  • 動的(b2_dynamicBody)
    フルシミュレーション. 重力影響や剛体間の衝突判定, 流体 - 剛体挙動の全てが計算される.
var bodyDef = new b2BodyDef();
bodyDef.type = b2_dynamicBody;

var body = world.CreateBode(bodyDef);

b2World.prptotype.CreateBodybodyDefを喰わせることで、剛体オブジェクトが作成できる。

剛体形状

剛体の形状を決定するには、幾つか方法がありそうだけど、形状を作成→形状をFixtureとして剛体にセット、のみ把握。

LiquidFunにおいて、「形状」はb2****Shapeという名前のコンストラクタで用意されている(b2CircleShape(円)とかb2ChainShape(ポリゴン)とか).
形状種類毎に形状定義方法は異なるが、形状インスタンスを作って、以下のようにb2Bodyに喰わせればよい。

body.CreateFixtureFromShape(shape, 0.5);

なお、第二引数は密度パラメータとのこと。

剛体形状にはworld.bodies[3].fixtures[0].shapeのように、fixtures経由でアクセス出来る。

剛体の質量

剛体には、質量情報を設定できる。

var massData = new b2MassData(1.2, new b2Vec(0, 0), 0.3);
body.SetMassData(massData);

b2MassDataのコンストラクタ引数は、順に質量(スカラ)、剛体の重心座標ベクトル、慣性モーメント値である。

慣性モーメント値のデフォルト値は0であるが、これは「この剛体は一切回転しません」という意味.

剛体の位置情報

b2Bodyのメンバ関数、GetPosition(), GetAngle()で位置座標、傾斜角(ラジアン)が取得できる(角度情報はGetRotate()で、cos, sinの値を直に取得することもできる. 回転行列が欲しいときはこっちが便利).

位置を指定する場合は、b2Body.prototype.SetPosition()を叩けば良い。引数は座標ベクトルと回転角である。

流体

さて、LiquidFunというだけあって、流体はこのライブラリの大きな特徴だ.
「流体」といっているが、イメージは微小粒子の集合体だ.

LiquidFunでは、この「微小粒子の集合体」を b2ParticleSystem なるクラスで管理する.

流体の作成

剛体と同様、流体の場合も定義体が存在する. b2ParticleSystemDefだ.
下記のように使う.

var psdef = new b2ParticleSystemDef(), ps;
psdef.radius = 0.01;
psdef.dampingStrength = 0.1;

ps = world.CreateParticleSystem(psdef);

上記は、粒子半径0.02, 減衰度合い0.1の流体を表す.
他にも、psdef.densityは流体の密度であったりと、流体の物理パラメータが存在する(一覧は本家APIリファレンスにて).

流体への粒子配置

b2ParticleSystemは作成しただけでは、空っぽなので、粒子を追加していく必要がある.
2通りの方法があるのだが、ここではb2ParticleGroupを利用する方法を記載する.

var pgdef = new b2ParticleGroupDef(), shape;

shape = new b2CircleShape();
shape.radius = 1.0;
pgdef.shape = shape;
pgdef.position = new b2Vec(3.0, 2.0);

ps.CreateParticleGroup(pgdef);

これは、「中心座標(3.0, 2.0)に半径1.0の円形状」の中にみっちり粒子がつまっている状態を初期形状とした粒子群を作成する、という例である。

ここで、注意が必要なのは、粒子数はps.radiusの値とshapeの形状から逆算されて自動で決定される点である.
もちろん、粒子数が多い(=半径が小さい)方が高精度だが、当然、その分の計算コストがついてくることになる.

流体粒子の位置取得

各粒子の座標位置を取得するには、b2ParticleSystem.prototype.GetPositionBuffer()で取得されるFloat32Array配列を用いる.
この配列は、下記の構造で粒子の(x, y)座標がペアの形で保存されている.

[x0, y0, x1, y1, x2, y2, x3, y3,...]

したがって、粒子を描画する類の処理イメージは次のようになる.

var ps = world.particleSystems[0], buf = ps.GetPositionBuffer(), count = buf.length;

for(var i = 0; i < count; i = i + 2){
  console.log('x, y:', buf[i], buf[i+1]);
}

b2ParticleSystemに複数のb2ParticleGroupをセットしている場合に、2番目のグループ情報のみの粒子に対するループを行いたければ、次のようにすればよい.

var ps = world.particleSystem[0], pg = ps.particleGroups[1];

var offset = pg.GetPositionIndex();
var length = pg.GetParticleCount();

for(var i = offset * 2; i < (offset + length) * 2; i = i + 2){
  console.log('x, y:', buf[i], buf[i+1]);
}

流体粒子の振る舞い

LiquidFunでは液体だけでなく、砂塵のようなパウダーやグニャッとした軟体等、色々な振る舞いをb2ParticleSystemでシミュレーションすることができる.

粒子の挙動は、b2ParicleGroupDef(b2ParticleDefでも可)のflagsという属性で指定することが出来る.
指定可能な値は "b2_*****Paricle"という名前で定数化されている(厳密にはC++では列挙体であったが、Emscriptenにより只の定数に落とされてます...)

  • b2_waterParticle
    所謂液体. デフォルト.
  • b2_elasticParticle
    軟体. ブヨブヨとしたグミのような物体となる.
  • b2_powderParticle
    砂塵のような粉っぽい粒子.
  • b2_biscousParticle
    油のような、粘性のある液体.
  • b2_colorMixingParticle
    色が混ざっていく液体.

なお、| 演算子で結合することで、複数のタイプの組み合わせ指定も可能.

pgdef.flags = b2_elasticParticle | b2_colorMixingParticle;

この辺りの説明は本家ガイドにより詳細があり、またタイプの一覧についてはここを参照されたし.

その他のトピック

自分ではまだ全然触ってないが、ゲームとか作ろうとする際に便利そうな機能もLiquidFunにある模様.

  • 剛体同士に束縛を与える機構(Joint).
  • ある領域内の剛体を高速検索するためのAABBクエリ.
  • 衝突判定と衝突時リスナ.

参考

いずれも、C++のリファレンスなので、JavaScriptでは少し読み替えが必要だが、あまり気にならないレベル.

コード

ちなみに、描画系にD3.jsを使っているが、特に深い意味があってのことではない。
本家のtestbedはWebGL(Three.js)でRendererを実装していたが、全く同じ方法で実装しても面白くない、というのと、D3.jsの方が慣れているだけである。

なので、動作させるときは、<script src="http://d3js.org/d3.v3.min.js"></script>を忘れずに。

'use strict';

//  注! グローバル変数にworldという名前でb2World用変数を用意しておかないと落ちる.
var world;
var boundsNodes = [[-2, 0], [2, 0], [2, 4], [-2, 4]]; //  境界形状
var floaters = [
    {nodes:[[-0.1, -0.2],[0.1, -0.2],[0.1, 0.2],[-0.1, 0.2]], pos:[0.5, 2]},
    {nodes:[[0, 0.2],[0.1732, -0.0866],[-0.1732, -0.0866]], pos:[-1.5, 3]}
];
var pgDefs = [      //  particleGroup毎の初期形状
    {nodes:[[0.5, 0.1], [1.9, 0.1], [1.9, 2.5], [0.5, 1.0]]},
    {nodes:[[-0.5, 0.1], [-1.9, 0.1], [-1.9, 2.5], [-0.5, 1.0]]}
];
var timeStep = 1.0 / 60.0, velocityIterations = 8, positionIterations = 3;

var liquidFunWorld = {

    init: function() {
        var gravity = new b2Vec2(0, -10);
        var boundsBody, boxShape;
        var psd, particleSystem;

        // 環境定義
        world = new b2World(gravity);

        // 剛体(static) 関連
        boundsBody= world.CreateBody(new b2BodyDef());
        boxShape = new b2ChainShape();
        boxShape.vertices = boundsNodes.map(function(node){
            return new b2Vec2(node[0], node[1]);
        });
        boxShape.CreateLoop();
        boundsBody.CreateFixtureFromShape(boxShape, 0);

        // 剛体(dyanmic)関連ここから
        floaters.forEach(function(floaterDef){
            var dynamicBodyDef = new b2BodyDef(), body, shape;
            dynamicBodyDef.type = b2_dynamicBody;
            body = world.CreateBody(dynamicBodyDef);
            shape = new b2ChainShape();
            shape.vertices = floaterDef.nodes.map(function(node){
                return new b2Vec2(node[0], node[1]);
            });
            shape.CreateLoop();
            body.CreateFixtureFromShape(shape, 1);
            body.SetTransform(new b2Vec2(floaterDef.pos[0], floaterDef.pos[1]), 0);
            // 質量定義
            body.SetMassData(new b2MassData(0.1, new b2Vec2(0, 0), 0.03));
        });

        // Particle モジュール関連ここから
        psd = new b2ParticleSystemDef();
        psd.radius = 0.05;               // 粒子半径
        psd.dampingStrength = 0.1; // 減衰の強さ

        particleSystem = world.CreateParticleSystem(psd);

        pgDefs.forEach(function(def){
            var shape = new b2PolygonShape(), pd = new b2ParticleGroupDef();
            shape.vertices = def.nodes.map(function(node){
                return new b2Vec2(node[0], node[1]);
            });
            pd.shape = shape;
            particleSystem.CreateParticleGroup(pd);
        });

    },
    update: function(){
        world.Step(timeStep, velocityIterations, positionIterations);
    }
};

var init = function(){
    liquidFunWorld.init();
    d3Renderer.init();
    window.onresize = d3Renderer.resize;
    render();
};

var render = function(){
    liquidFunWorld.update();
    d3Renderer.render(world);
    window.requestAnimationFrame(render);
};

var d3Renderer = {
    init: function(){
        var viz = d3.select('body').append('svg').attr('id', 'viz').append('g').classed('world', true);
        d3Renderer.resize();
    },
    render: function(world){
        var viz = d3.select('svg#viz g.world');
        d3Renderer.drawBodies(viz, world.bodies);
        d3Renderer.drawParicles(viz, world.particleSystems[0]);
    },
    drawBodies: function(selection, bodies){ // 剛体描画用
        var bounds = d3.svg.line().x(function(vec){return vec.x;}).y(function(vec){return vec.y;});
        var bodyGroups = selection.selectAll('g.body').data(bodies, function(b){
            return b.ptr;
        });
        bodyGroups.enter().append('g').classed('body', true).attr('fill', 'none').attr('stroke', 'black').attr('stroke-width', 0.01);
        bodyGroups.each(function(b){
            d3.select(this).selectAll('path').data(b.fixtures).enter().append('path').attr('d', function(fixture){
                return bounds(fixture.shape.vertices);
            });
        });
        bodyGroups.attr('transform', function(b){
            var pos = b.GetPosition(), angle = b.GetAngle() * 180 / Math.PI;
            return 'translate(' + pos.x + ', ' + pos.y + '), rotate(' + angle + ')';
        });
        bodyGroups.exit().remove();
    },
    drawParicles: function(selection, system){ // 流体粒子描画用
        var particleGroup = selection.selectAll('g.particle').data(system.particleGroups)
        var positionBuf = system.GetPositionBuffer();
        particleGroup.enter().append('g').classed('particle', true).attr('fill', function(d, i){
            return d3.hsl((i * 77 + 200) % 360, 0.8, 0.8);
        });
        particleGroup.each(function(pg){
            var dataSet = d3.select(this).selectAll('circle').data(new Array(pg.GetParticleCount()));
            var offset = pg.GetBufferIndex();
            dataSet.enter().append('circle').attr('r', system.radius * 0.75);
            dataSet.attr('cx', function(d, i){
                return positionBuf[(i + offset) * 2];
            }).attr('cy', function(d, i){
                return positionBuf[(i + offset) * 2 + 1];
            });
            dataSet.exit().remove();
        });
        particleGroup.exit().remove();
    },
    resize: function(){
        var w = window.innerWidth, h = window.innerHeight;
        var scale = (w < h ? w : h) * 0.23;
        var viz = d3.select('svg#viz');
        viz.style('width', '100%').style('height', h + 'px');
        var translate = 'translate(' + (w/2) + ', ' + (h/2 + scale*2) + ')';
        var scale = 'scale(' + scale + ', ' + (-scale) + ')';
        viz.select('g').attr('transform', [translate, scale].join());
    }
};

window.onload = init;
77
71
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
77
71