プログラミングの備忘録

プログラムをつくる過程を残すもの

processingの備忘録 -ボイド-

f:id:taq2777:20220301225814p:plain

こんにちは。

今回は、「ボイドをつくってみよう」という題でやっていきます。


ボイドとは

「ボイド」は人工生命プログラムのひとつで、「bird-oid(鳥のようなもの)」という単語を略したものだそうです。
(「-oid」という接尾辞は「android」や「humanoid」などでも見られますね。)

コンピュータ上につくった鳥(のようなもの)に対して「分離(separation)」「整列(alignment)」「結合(cohesion)」という3つの規則を適用することで、群れとしての振る舞いをシミュレーションできるというものです。


「分離」「整列」「結合」はそれぞれ、

  • 分離:鳥が他の鳥とぶつからないように距離をとる。
  • 整列:鳥が他の鳥と同じ方向に飛ぶ。
  • 結合:鳥が他の鳥の群れの中心へ向かう。

というような中身です。
(後で詳しく説明します。)


プログラム化する

鳥を画面内に配置してある速度で動かすというようなプログラムをつくり、そこに「分離」「整列」「結合」の処理を加えていくという形でつくっていきます。


基礎をつくる

鳥の描画と移動

まずは、鳥を配置して動かします。

扱いやすさから、とりあえずは鳥を丸で表すことにして、コード上ではクラスを使って書くことにします。

ArrayList<Boid> boids = new ArrayList<Boid>(); //Boidクラスの配列をつくる

void setup(){
    size(750, 750);
    
    for(int i = 0; i < 100; i ++){ //100回繰り返す(=100匹のboidを追加する)
        boids.add(new Boid(random(0, width), random(0, height))); //Boidの配列にboidを追加する
    }
}

void draw(){
    background(0);
    
    for(Boid b : boids){ //boid全てに対して
        b.display(); //描画の処理
        b.move(); //移動の処理
    }
}

class Boid{ //Boidクラス
    PVector pos, vel, acc;
    float v_max, f_max;
    float dia;
  
    Boid(float _x, float _y){ //boidの初期設定
        pos = new PVector(); //boidの位置
        pos.x = _x; //追加するときに指定する
        pos.y = _y; //追加するときに指定する
        
        vel = PVector.random2D(); //ランダムな向きの速度をもつ
        v_max = 3; //速さの設定

        acc = new PVector(); //加速度のベクトル(後で使う)
        
        f_max = 0.1; //力の大きさの最大値(後で使う)

        dia = 10; //boidの大きさ
    }
    
    void display(){
        ellipse(pos.x, pos.y, dia, dia); //boidを丸で表す
    }
    
    void move(){
        vel.add(acc); //速さの変化
        vel.limit(v_max); //速さを制限
        pos.add(vel); //boidを動かす
        acc.mult(0); //加速度を0に
    }
}


新手法「ArrayList」を用いました。
ArrayList」は配列ではあるのですが、要素の追加や削除がしやすいという利点があります。
ただ、扱う要素の数が多くなると重くなるということがあるようです。


また、for文で繰り返すときに、

for(Boid b : boids){
    b.display();
    …
}

というように「Boid b : boids」とすることで全てのboidに対して繰り返すことができます。
(「b」の部分は任意に変更可能です。)

このようにしても同じことができます。

for (int i = 0; i < boids.size(); i++) {
    Boid b = boids.get(i); //i番目の要素を取り出す
    b.display();
    …
}

この方法であれば、一部のboidだけで繰り返したいというときに使えます。
例えば、

for (int i = 5; i < 10; i++) {
    Boid b = boids.get(i);
    b.display();
    …
}

このようにすれば、5番目~9番目のboidでのみ繰り返すということもできます。


先のコードを実行することで、ランダムな向きに100匹のボイドが飛ぶという形になりました。


鳥の追加・削除

続いて「ArrayList」の強みである、追加・削除のしやすさを利用してみたいと思います。


左クリックでマウスの位置に鳥を追加する、という処理であれば、

void mousePressed(){
    if(mouseButton == LEFT){
        boids.add(new Boid(mouseX, mouseY));
    }
}


右クリックした鳥を削除する、という処理であれば、

void mousePressed(){
    if(mouseButton == RIGHT){
        PVector mouse = new PVector(mouseX, mouseY);
        for(int i = 0; i < boids.size(); i ++){ //boidすべてに対して
            Boid b = boids.get(i); //boidの情報を取得
            if(PVector.dist(b.pos, mouse) < b.dia/2){ //boidとマウスの間の距離がboidの半径より小さければ
                boids.remove(i); //そのboidを消す
            }
        }
    }
}


境界条件

ここまででは、画面端に到達したときにそのまま彼方まで飛んで行ってしまいます。
それだと群れの様子が見づらいので、境界条件(=端でどうするか)を追加します。


これまでつくってきたプログラムの中で言うと、「端まで行ったら反射する」というのが境界条件にあたります。
コードで書くと、

void reflect(){
    if(pos.x < dia/2){
        pos.x = dia/2;
        vel.x *= -1;
    }
    if(pos.x > width-dia/2){
        pos.x = width-dia/2;
        vel.x *= -1;
    }
    if(pos.y < dia/2){
        pos.y = dia/2;
        vel.y *= -1;
    }
    if(pos.y > height-dia/2){
        pos.y = height-dia/2;
        vel.y *= -1;
    }
}

(クラス内に以下のコードを追加して、b.display()やb.move()と同じところで関数を呼び出せば実装できます。)


ですが、今は鳥を想定しているので壁にぶつかって跳ね返るというのはかわいそうです(し、実際に壁で跳ね返っている鳥は見たことがないと思います)。

そこで、「壁に近づいたら向きを変える」という境界条件を考えてみます。

簡単には、

void turn(){
    if(pos.x < 25){
        vel.x += 0.1;
    }
    if(pos.x > width-25){
        vel.x -= 0.1;
    }
    if(pos.y < 25){
        vel.y += 0.1;
    }
    if(pos.y > height-25){
        vel.y -= 0.1;
    }
}


「Nature of Code」にある方法をまねるならば、

void turn(){
    if(pos.x < 25){
        PVector desired = new PVector(v_max, vel.y);
        PVector steer = PVector.sub(desired, vel);
        steer.limit(f_max);
        acc.add(steer);
    }
    if(pos.x > width-25){
        PVector desired = new PVector(-v_max, vel.y);
        PVector steer = PVector.sub(desired, vel);
        steer.limit(f_max);
        acc.add(steer);
    }
    if(pos.y < 25){
        PVector desired = new PVector(vel.x, v_max);
        PVector steer = PVector.sub(desired, vel);
        steer.limit(f_max);
        acc.add(steer);
    }
    if(pos.y > height-25){
        PVector desired = new PVector(vel.x, -v_max);
        PVector steer = PVector.sub(desired, vel);
        steer.limit(f_max);
        acc.add(steer);
    }
}

f:id:taq2777:20220301195515p:plain
pos.x < 25 のときのコードの図解

どちらもやっていることは、壁と垂直な向きに加速度のベクトルをつくって、それを速度ベクトルに足すという感じです。
(たまにめり込むので改良の余地がありそうです。)


もうひとつ、「ループする」という境界条件を考えてみます。
左端に行ったら右端から出てくる、上端に行ったら下端から出てくる(逆も然り かっこよく言うとvice versa)という形になります。

void loop(){
    if(pos.x < -dia/2){
        pos.x = width+dia/2;
    }
    if(pos.x > width+dia/2){
        pos.x = -dia/2;
    }
    if(pos.y < -dia/2){
        pos.y = height+dia/2;
    }
    if(pos.y > height+dia/2){
        pos.y = -dia/2;
    }
}


さて、ここまでで基礎はつくることができたので、ボイドの最も重要なところである「分離」「整列」「結合」の処理を追加していきます。
(とは言っても、どれも似たような形なので理解はしやすいかもしれません。)


分離

分離は「鳥が他の鳥とぶつからないように距離をとる」という規則でした。

f:id:taq2777:20220301205034p:plain

プログラム上では、ある範囲内にいる鳥それぞれとの間で真反対方向のベクトルをつくり、それを足した方向に(速さv_maxで)移動するために、今の速度ベクトルとの差をとって加速度に加える、という中身になります。
(例によって、以下のコードをクラス内に追加して、draw()内で呼び出せば実装できます。)

void separation(){
    PVector sum = new PVector();
    int number = 0;
    for(Boid other : boids){ //全てのboidとの間で
        float dist = PVector.dist(pos, other.pos); //距離を計算し
        if(0 < dist && dist < dia*2){ //距離がboidの直径の2倍より小さければ
            PVector away = PVector.sub(pos, other.pos); //離れる方向のベクトルを計算し
            sum.add(away); //足していく
            number ++; //計算したboidの数を格納
        }
    }
    if(number > 0){ //周りにboidが居れば
        sum.setMag(v_max); //合力の大きさをv_maxにして
        PVector steer = PVector.sub(sum, vel); //今の速度ベクトルとの差をとる
        steer.limit(f_max); //大きさを制限
        acc.add(steer); //加速度に足す
    }
}

f:id:taq2777:20220301204612p:plain


追記(2022/03/03)

動きはそれっぽいですが、これだとawayベクトルの大きさが鳥間の距離のままになって、遠いほどsumベクトルへの寄与が大きく(遠いほど離れたく)なってしまいます。
つまり、「群れから離れる」というよりかは「群れから離れようとはするけど寂しくてちょっと近くにいる」というような形になってしまいます。
その結果、鳥が重なったまま離れなくなるという現象が起きていました。

鳥間の距離が近いほどsumベクトルへの寄与が大きい(近いほど離れたい)という計算が良いので、以下のように変更します。

void separation(){
    PVector sum = new PVector();
    int number = 0;
    for(Boid other : boids){
        float dist = PVector.dist(pos, other.pos);
        if(0 < dist && dist < dia*2){
            PVector away = PVector.sub(pos, other.pos);
            away.normalize(); //単位ベクトルにする
            away.div(dist); //距離で割る
            sum.add(away);
            number ++;
        }
    }
    if(number > 0){
        sum.setMag(v_max);
        PVector steer = PVector.sub(sum, vel);
        steer.limit(f_max);
        acc.add(steer);
    }
}


awayベクトルの大きさを同じにして距離で割ることで、距離が遠いほどsumベクトルへの寄与が小さくなります。
これで、近いほど離れたいという計算になるため、重なったまま離れなくなるという現象は解決されました。


ちなみに、下でやる「結合」の規則は遠いほど近づきたいので、closeベクトルの大きさは鳥間の距離のままで問題ありません。

以上


整列

整列は「鳥が他の鳥と同じ方向に飛ぶ」という規則でした。

f:id:taq2777:20220301210839p:plain

プログラム上では、ある範囲内にいる鳥の速度ベクトルを足し合わせ、その方向に(速さv_maxで)移動するために、今の速度ベクトルとの差をとって加速度に加える、という中身になります。

void alignment(){
        PVector sum = new PVector();
        int number = 0;
        for(Boid other : boids){
            float dist = PVector.dist(pos, other.pos);
            if(0 < dist && dist < dia*2){
                sum.add(other.vel); //周りのboidの速度ベクトルを足す
                number ++;
            }
        }
        if(number > 0){
            sum.setMag(v_max);
            PVector steer = PVector.sub(sum, vel);
            steer.limit(f_max);
            acc.add(steer);
        }
    }

f:id:taq2777:20220301210710p:plain


結合

結合は「鳥が他の鳥の群れの中心へ向かう」という規則でした。

f:id:taq2777:20220301211852p:plain

プログラム上では、ある範囲内にいる鳥それぞれの方向のベクトル(分離のときと逆のベクトル)をつくり、それを足した方向に(速さv_maxで)移動するために、今の速度ベクトルとの差をとって加速度に加える、という中身になります。

void cohesion(){
    PVector sum = new PVector();
    int number = 0;
    for(Boid other : boids){
        float dist = PVector.dist(pos, other.pos);
        if(0 < dist && dist < dia*2){
            PVector close = PVector.sub(other.pos, pos); //向かう方向のベクトルを計算する(分離の時と逆向き)
            sum.add(close);
            number ++;
        }
    }
    if(number > 0){
        sum.setMag(v_max);
        PVector steer = PVector.sub(sum, vel);
        steer.limit(f_max);
        acc.add(steer);
    }
}

f:id:taq2777:20220301212110p:plain


以上でボイドは実装できました。実行してみるとそれっぽい感じになりました。

f:id:taq2777:20220301215338g:plain


その他

基本はこの3つの規則になりますが、「Nature of Code」には「視界」という「目の前に鳥がいたときは視界を確保するように横に移動する」というような規則もありますし、「障害物を避ける」など自分で考えた規則を入れてみるのも面白いかと思います。

また、計算はある鳥を中心とした円の範囲内にいる鳥で行っていましたが、これをある鳥の視界に入った鳥のみで行うなど、既存の規則を少しいじることでも結果は変わってくると思います。


鳥の見た目

これまでは簡単のために丸で鳥を表しましたが、どの方向を向いているかがわかるとより良い感じになりそうです。


よくあるのは三角形で表すタイプです。
(クラス内のdisplay()の部分を以下のコードに置き換えれば実装できます。)

void display(){
    pushMatrix();
        translate(pos.x, pos.y);
        float theta = vel.heading(); //速度ベクトルの方向に
        rotate(theta); //傾ける

        //三角形の描画
        beginShape();
            vertex(2*dia/2, 0);
            vertex(-dia/2, -dia/2);
            vertex(-dia/2, dia/2);
        endShape();

    popMatrix();
}

(個人的にpushMatrix()-popMatrix()間とbeginShape()-endShape()間はインデントを入れたほうが見やすかったのでそうしてあるだけです。)


実行すると、向きがわかりやすくなりました。

f:id:taq2777:20220301215953g:plain


空間分割法について

分離・整列・結合の処理で、ある鳥とその他の全ての鳥との間で距離の計算を行っていましたが、鳥の数が多くなると計算数が多くなって大変です。

そこで、画面を何分割かにし、ある鳥が属する区画とそれに隣接する8つの区画にいる鳥とだけで計算を行うことよって、幾分か軽くすることができるようです。

f:id:taq2777:20220301223328p:plain

ですが、今のところは必要性を感じないのでとりあえずはここで触れるだけにしておいて、気が向いたら追加してみようと思います。


まとめ

以上でprocessingでボイドを実装することができました。

本文中でも書きましたが、自分なりの規則を入れてみたり、既存の規則をいじってみたりすると、挙動が変わって面白いと思います。


参考