p5.jsでもんやりとオブジェクト指向する


2017年07月25日
With
p5.jsでもんやりとオブジェクト指向する はコメントを受け付けていません

以前のエントリ「知ってよかったp5.jsでのお絵描きテクニック3つ」で2番目に挙げたのは「オブジェクト指向の基礎」でした。

以前、FlashのActionScriptなどをちょびっと勉強していたこともあり、一応「オブジェクト指向」って言葉くらいは知っていましたし、キャラクターを動かしたいときにx座標とy座標をオブジェクトで管理する程度のこともやっていました。ただ、この「オブジェクト指向」、非常に「奥が深い」パラダイムでもあります。

私、そうしたオブジェクト指向の深淵を覗きこむ勇気も根性も持ち合わせてはおりませんが、とりあえず「ちっこいプログラム書くときに便利に使える程度には知っておきたい」とは常々思っておりました。「ジェネラティブ・アート-Processingによる実践ガイド」では、「Chapter 6 創発」でProcessingにおけるオブジェクト指向の基本的な使い方と、そのサンプルコードが紹介されています。

■ [普及版]ジェネラティブ・アート―Processingによる実践ガイド


Processingとp5.jsにおける「オブジェクト」の扱い方の違いは、ベースの言語が「Java」か「Javascript」かの違いに由来するものです。そのへんについてググったり「p5.jsプログラミングガイド」をナナメ読みしたりしながら移植したのが以下のデモです。

●[DEMO] “Color Circles” powered by p5.js(クリックすると別ウインドウが開きます)

画面上を色のついた複数の円が移動するプログラムで、表示されている円のひとつひとつがオブジェクトから生成されたインスタンスです。プロパティとしては、ざっくり

・画面上の座標
・大きさ(10~110px)
・色
・移動方向とスピード
・アルファ値

を持っていて、メソッドとしては、自身の状態を更新する「updateMe」と、自身を画面上に描画する「drawMe」を持っています。

「updateMe」では、自身の座標を更新するほかに、自分以外のインスタンスとインタラクションするための処理を行っています。具体的には、毎フレーム自分と他のインスタンスとの距離を計算し、もし自分と重なっているインスタンスがあれば、自分の中心と、そのインスタンスの中心との間に、重なりの大きさに比例したサイズの「黒い円」を描きます。また、他のインスタンスと重なっている間、自身のアルファ値(透明度)を上げる、重なりがない場合はアルファ値を下げるという処理も同時に行っています。

ブラウザ上でマウスクリックすると、画面上に新たなインスタンスが10ずつ追加されていきます。インスタンスの数が増えれば増えるほど、他のインスタンスと重なっている時間が長くなるため、画面上からは色が消え、生成、拡大、縮小、消失を繰り返す大量の「黒い円」だけが描かれる状態になります。

移植にあたっては、

・Javascriptでは変数の型が指定できないので、オブジェクト型(Circle)での変数定義を全部「var」に変更。
・「class」によるオブジェクト定義を「function」に変更。コンストラクタを「this.x」「this.radius」など、「this」を使った記述に。
・メソッドの定義を「Circle.prototype.drawMe = function(){…}」のようなプロトタイプを使ったJavascript様式に変更。プロパティの参照や操作は「this.」付きで。

といった感じで書き換えています。

サンプルコードで使われている「Circle」オブジェクトには、自身の状態を示すプロパティと、状態を更新するメソッド、画面上に自らを表示するメソッドが含まれています。また、状態の更新にあたっては、周囲の状況(他のインスタンスとの位置関係)に応じて、相互作用するアルゴリズムが含まれていて、いろいろと応用が利きそうです。実際、次章の「Chapter 7 自律性」以降では、ここでの基本的なオブジェクトの扱い方を発展させる形で「セルオートマトン」や「フラクタル」を実現しています。これらについても、一応移植していますので、そのうちこちらで紹介しようかと。

ついでにもんやりと「継承」してみる

で、オブジェクト指向について、ちょっとでもカジったことがある人なら、オブジェクトの「継承」についてご存じかと思います。元となるオブジェクトの性質を引き継ぎつつ、一部を変更したり、拡張したりした新たなオブジェクトを作れるというヤツですね。いろいろ調べたところによると、正確にはJavascriptには「継承」という概念はないらしいのですが、「プロトタイプチェーン」の仕組みを使って同じようなことができるのだとかなんだとか。

「ジェネラティブ・アート」の中では、オブジェクトの継承については解説されていなかったのですが、「p5.jsプログラミングガイド」のほうに継承のやり方が解説されていたので、主にそちらを参考にしつつ、先ほどの「Color Circles」を拡張してみました。

●[DEMO]”Color Circles Extended” powered by p5.js(クリックすると別ウインドウが開きます)

この「拡張版」では、元のプログラムにあった「Circle」オブジェクトを継承した新たなオブジェクト「Circle2」「Circle3」を追加しています。

元のプログラムでは「updateMe」メソッドの中で、自分の状態の更新と、他のオブジェクトとの位置関係の把握およびインタラクション(他のインスタンスと重なった状態の時に、相手との間に「黒い円」を描く)を同時に行っているのですが、そのうちインタラクションを行う部分を「myInterplay」という新たなメソッドとして切り出し、継承先のオブジェクトではインタラクションの内容を「重なっている円と自分の中心同士を直線で結ぶ(Circle2)」「重なっている円と自分の中心との間に長方形を描く(Circle3)」というものにそれぞれ変更しています。そして、インスタンスの生成時に乱数を発生させて、3分の1の確率でいずれかに振り分けるようにしました。

それ以外の部分は特に変更していませんが、マウスクリックでインスタンスの数を増やしていくと、元のプログラムとはだいぶ違った出力が得られます。

さらに改変後のプログラムでは、生成される絵の変化をゆっくり見られるよう、インスタンスの「移動速度」を元のプログラムよりも遅くしています。新たな2つのオブジェクトは「Circle」を継承しているので、移動速度の変化については「Circle」のコンストラクタ部分だけをいじって、他の2つにも引き継がせています。このあたり、継承を使うことで修正をシンプルにできる例かもしれませんね。「拡張版」のソースコードを以下に示します。

var _num = 10;
var _circleArr = [];

function setup() {
    createCanvas(600, 600);
    background(255);
    smooth();
    strokeWeight(1);
    fill(150, 50);
    drawCircles();
}

function draw() {
    background(255);
    for (var i=0; i<_circleArr.length; i++) {
        thisCirc = _circleArr[i];
        thisCirc.updateMe();
    }
}

function mouseReleased() {
    drawCircles();
    print(_circleArr.length);
}

function drawCircles() {
    var thisCirc;
    for (var i=0; i<_num; i++) {
        var flag = random();
        if (flag<.33) { thisCirc = new Circle();
        } else if (flag>=.33 && flag<.66) { thisCirc = new Circle2();
        } else if (flag>=.66) { thisCirc = new Circle3();}
        thisCirc.drawMe();
        _circleArr.push(thisCirc);     
    }
}

//Circle(元のオブジェクト…重なりに対応した大きさの丸を書く)
function Circle () {
        this.x = random(width);
        this.y = random(height);
        this.radius = random(100) + 10;
        this.linecol_r = random(255);
        this.linecol_g = random(255);
        this.linecol_b = random(255);
        this.fillcol_r = random(255);
        this.fillcol_g = random(255);
        this.fillcol_b = random(255);
        this.alpha = random(255);
        this.xmove = random(4) - 2;
        this.ymove = random(4) - 2;
}
Circle.prototype.drawMe = function() {
    noStroke();
    fill(this.fillcol_r, this.fillcol_g, this.fillcol_b, this.alpha);
    ellipse(this.x, this.y, this.radius*2, this.radius*2);
    stroke(this.linecol_r, this.linecol_g, this.linecol_b, 150);
    noFill();
    ellipse(this.x, this.y, 10, 10);
}
Circle.prototype.updateMe = function() {
    this.x += this.xmove;
    this.y += this.ymove;
    if (this.x > (width+this.radius)) { this.x = 0 - this.radius; }
    if (this.x < (0-this.radius)) { this.x = width + this.radius; }
    if (this.y > (height+this.radius)) { this.y = 0 - this.radius; }
    if (this.y < (0-this.radius)) { this.y = height + this.radius; }
    this.myInterplay();
    this.drawMe();        
}

Circle.prototype.myInterplay = function () {
    var touching = false;
    for (var i = 0; i < _circleArr.length; i++) {
        var otherCirc = _circleArr[i];
        if (otherCirc != this) {
            var dis = dist(this.x, this.y, otherCirc.x, otherCirc.y);
            var overlap = dis - this.radius - otherCirc.radius;
            if (overlap < 0) {
                var midx, midy;
                midx = ( this.x + otherCirc.x)/2;
                midy = ( this.y + otherCirc.y)/2;
                overlap *= -1;
                stroke(0, 100);
                noFill();
                ellipse(midx, midy, overlap, overlap);
            }
            if ((dis - this.radius - otherCirc.radius ) < 0) {
                touching = true;
                break;
            }
        }
    }
    if (touching) {
        if (this.alpha > 0) { this.alpha--; }
        } else {
        if (this.alpha < 255) { this.alpha +=2; }
        }
}

//Circle2(継承先オブジェクト1…重なっている円と自分の中心同士を線で結ぶ)
function Circle2 () {
    this.uber = Circle.prototype;
    Circle.call(this);
}
Circle2.prototype = Object.create(Circle.prototype);
Circle2.prototype.constructor = Circle2;
Circle2.prototype.myInterplay = function() {
    var touching = false;
    for (var i = 0; i < _circleArr.length; i++) {
        var otherCirc = _circleArr[i];
        if (otherCirc != this) {
            var dis = dist(this.x, this.y, otherCirc.x, otherCirc.y);
            var overlap = dis - this.radius - otherCirc.radius;
            if (overlap < 0) {
                stroke(0, 100);
                noFill();
                line(this.x, this.y, otherCirc.x, otherCirc.y);
            }
            if ((dis - this.radius - otherCirc.radius ) < 0) {
                touching = true;
                break;
            }
        }
    }
    if (touching) {
        if (this.alpha > 0) { this.alpha--; }
        } else {
        if (this.alpha < 255) { this.alpha +=2; }
        }
}

//Circle3(継承先オブジェクト2…重なっている円と自分の中心との間に四角を描く)
function Circle3 () {
    this.uber = Circle.prototype;
    Circle.call(this);
}
Circle3.prototype = Object.create(Circle.prototype);
Circle3.prototype.constructor = Circle3;
Circle3.prototype.myInterplay = function() {
    var touching = false;
    for (var i = 0; i < _circleArr.length; i++) {
        var otherCirc = _circleArr[i];
        if (otherCirc != this) {
            var dis = dist(this.x, this.y, otherCirc.x, otherCirc.y);
            var overlap = dis - this.radius - otherCirc.radius;
            if (overlap < 0) {
                var rectw, recth;
                rectw = (otherCirc.x-this.x);
                recth = (otherCirc.y-this.y);
                overlap *= -1;
                stroke(0, 100);
                noFill();
                rect(this.x, this.y, rectw, recth);
            }
            if ((dis - this.radius - otherCirc.radius ) < 0) {
                touching = true;
                break;
            }
        }
    }
    if (touching) {
        if (this.alpha > 0) { this.alpha--; }
        } else {
        if (this.alpha < 255) { this.alpha +=2; }
        }
}

次のエントリでは「noise」関数について触れようと思います。