p5.jsで「ブラー」や「グロー」を使う方法

With
p5.jsで「ブラー」や「グロー」を使う方法 はコメントを受け付けていません。

インスタンスが動き回るようなスケッチを作っていると、軌跡を画面上に残す「ブラー」や、物体がぼんやりと発光しているように見える「グロー」のような効果を使いたくなることがあります。

p5.jsには「ブラー」や「グロー」を直接画像に適用するような機能はないのですが、調べてみたところ、多少の工夫でそれらしい効果が出せることが分かったのでまとめておきます。

背景を透明度のある四角で塗りつぶして「ブラー」

「ブラー」は、比較的簡単に使えます。

●[DEMO]ブラーテスト-Firefly(クリックすると別ウインドウが開きます)

…本人は夏らしく「ホタル」のイメージで作ったのですが、どう見てもホタルというより精○です。本当にありがとうございました。

var _ffCount = 30;
var _ff = [];

function setup() {
    createCanvas(600, 600);
    background(0);
    noStroke();

    for (var i=0; i<_ffCount; i++) {
        _ff[i] = new FfObj();
    }

}

function draw() {
    //background(0);
    fill(0, 20);
    rect(0, 0, width, height);

    for (var i=0; i<_ffCount; i++) {
        _ff[i].drawMe();
        _ff[i].updateMe();
    }
}

function FfObj() {
    this.pX = random(0, width);
    this.pY = random(0, height);
    this.noiseX = random()*1000;
    this.noiseY = random()*1000;
    this.noiseScale = random(0.001, 0.02);
}

FfObj.prototype.updateMe = function() {
    this.pX += noise(this.noiseX)*4-1.86;
    this.pY += noise(this.noiseY)*4-1.86;
    if (this.pX < 0) { this.pX = 0;}
    if (this.pX > width) { this.pX = width;}
    if (this.pY < 0) { this.pY = 0;}
    if (this.pY > height) { this.pY = height}
    this.noiseX += this.noiseScale;
    this.noiseY += this.noiseScale;
}

FfObj.prototype.drawMe = function() {
    fill(255, 255, 200, 255);
    ellipse(this.pX, this.pY, 6);
}

「ブラー」を実現しているのは17、18行です。

p5.js(Processingも)では、プログラムの開始後、毎フレーム実行され続ける「draw()」というシステム関数があります。時間経過で変化させたい要素はその中に書いたり、その中から呼んだりするのが一般的です。

で、毎フレーム画面を更新する場合には、draw()の最初の方に「background()」という関数を書いて「背景色でカンバス全体を塗りつぶす」という形で「画面消去」の処理を行います。

「ブラー」を使いたい場合は、この「background」による消去を行わず、一度「fill」で背景色と「透明度」を設定しておき(17行目)、その後で画面サイズ一杯の「長方形」を描くという(18行目)処理に変更します。

イメージとしては、前フレームの画面の上に、背景色が薄く塗られた透明のセロハンを乗せて、その上に次のフレームを描くという作業を繰り返す感じです。こうすると、フレームが進めば進むほど、前の方で描かれた絵は背景色の重なりで塗りつぶされていき、結果的に軌跡のような残像が残るわけです。ちなみに、16行目でコメントアウトしている「background(0);」を復帰させ、代わりに17~18行目をコメントアウトすれば、ブラーのない通常の動きが見られます。

この手法で「ブラー」を使う際のポイントですが「fill」で指定するアルファ値(サンプルでは20)を小さくすればするほど、残る軌跡が長くなります。一方で、あまりアルファ値を小さくし過ぎると、背景にいつまでも「軌跡の残骸」のようなにじみが残り続けます。今回のデモでは背景を「黒」にしているのですが、このにじみは背景が白に近いほど目立ってしまい、画面が汚くなりますので、作りたいスケッチの内容に合わせて、最適な背景色とアルファ値を試行錯誤しながら「ブラー」の強さを調整するようにするといいと思います。

あと、このやり方で「ブラー」を使うと、画面上にあるすべての絵に対して効果が適用されます。特定の対象にだけ「ブラー」をかけたい場合は、重ねる長方形のサイズを調整して、個別に適用するという方法でいけそうですが、ブラーの強さや他のインスタンスとの干渉によって、細かい調整が必要になりそうです(まだ試してません)。

一筋縄ではいかない「グロー」

「グロー」ぽい効果を出すには、いくつか方法があります。今回は「キレイで速いけれど、ちょっと面倒くさい方法」と「ワリと簡単だけれど、あんまりキレイじゃない方法」の2つに触れておきます。

前者は、「createImage」を使って「光源から離れるほど弱まっていく光彩」の絵をあらかじめ作っておき、光源になる物体と重ねて表示するというやり方。後者は、同じ座標を中心としてアルファ値とサイズの異なる円を3~4個重ね描きするという方法です。

下のデモを開くと、画面上にグローっぽい効果のかかった光点が表示され、動き始めます。通常は「キレイで速いけれど、ちょっと面倒くさい」方法で表示されていますが、ブラウザ上で左クリックをしている間は後者の「ワリと簡単だけれど、あんまりキレイじゃない」方法での描画に切り替わります。

●[DEMO]グローテスト-Firefly(クリックすると別ウインドウが開きます)

p5.jsには「画像」を扱える機能があり、各ピクセルの色やアルファ値を個別に指定して画面に表示できます。サンプルではまずsetup()内から呼び出している「imgSet」という関数の中で、「createImage」を使い、「_img」というグローバル変数内に1辺800pxの光彩画像(全インスタンスで共通に利用する)を作っています。

画像を生成する式はとてもアバウトなのですが、中心周辺のピクセルでアルファ値が最も高く、外側に行けば行くほど低くなるような式を書いて、表示すると「真ん中が一番濃く、外に行くほど色が薄くなっていく円」に見えるようにしています。この画像を、フレームごとに各インスタンスの座標が中心に来るように置き、中心に「ellipse」で不透明の「小さな円」(光源)を描いているという感じです。

一方、後者の「ワリと簡単だけれど、あんまりキレイじゃない」方法は、以前のエントリ(p5.jsで「パーリンノイズ」のスゴさを思い知る)にある「気まぐれなベジェ曲線たち」でも、曲線に対して使っています。あまり美しい方法ではないのですが、形が変化するようなインスタンスに対しては「createImage」で光彩を作るやり方が使いづらいため、こちらの方法を使いました。今回のデモでは安っぽい感じが否めませんが、ベジェ曲線のデモではそれらしく見えていたのではないでしょうか。

あと、今回のデモのように「全インスタンスで同じ光彩を使い回せる」ようなスケッチの場合は、createImageによる方法が、パフォーマンス面で圧倒的に有利です。20個程度のインスタンスでは明確な違いは見られませんが、興味がある方はソースコードの3行目にある「_ffCount」(生成するインスタンス数)の値を「1000」などにして試してみてください。クリックして描画方法を切り替えると、あからさまにスピードが変わります。このあたりは場合によって使い分けるようにするしかなさそうです。

「ブラー」にしても「グロー」にしても、あんまり乱用すると絵がうるさく、単調になりますけれど、たまにアクセントとして使うと効果的ではあるので、もっと良いやり方がないかなぁと思っています。

var _imgSize = 800;
var _img;
var _ffCount = 20;
var _ff = [];

function setup() {
    createCanvas(600, 600);
    background(0);
    noStroke();

    imgSet();

    for (var i=0; i<_ffCount; i++) {
        _ff[i] = new FfObj();
    }

}

function draw() {
    background(0);

    for (var i=0; i<_ffCount; i++) {
        if (!mouseIsPressed) { _ff[i].drawMe();}
        else {_ff[i].drawMe2();}
        _ff[i].updateMe();
    }
}

function imgSet() {
    _img = createImage(_imgSize, _imgSize);
    _img.loadPixels();
    for (var i = 0; i < _img.width; i++) {
        for (var j = 0; j < _img.height; j++) {
            var pixAlpha = 255/(dist(_img.width/2, _img.height/2, i, j)-1)*1.47;
            if (pixAlpha < .94) { pixAlpha=0; }
            _img.set(i, j, color(255, 255, 250, pixAlpha));
        }
    }
    _img.updatePixels();
}

function FfObj() {
    this.pX = random(0-_img.width/2, width-_img.width/2);
    this.pY = random(0-_img.height/2, height-_img.height/2);
    this.noiseX = random()*1000;
    this.noiseY = random()*1000;
    this.noiseScale = random(0.001, 0.02);
}

FfObj.prototype.updateMe = function() {
    this.pX += noise(this.noiseX)*4-1.86;
    this.pY += noise(this.noiseY)*4-1.86;
    if (this.pX < 0-_img.width/2) { this.pX = 0-_img.width/2;}
    if (this.pX > width-_img.width/2) { this.pX = width-_img.width/2;}
    if (this.pY < 0-_img.height/2) { this.pY = 0-_img.height/2;}
    if (this.pY > height-_img.height/2) { this.pY = height-_img.height/2;}
    this.noiseX += this.noiseScale;
    this.noiseY += this.noiseScale;
}

FfObj.prototype.drawMe = function() {
    image(_img, this.pX, this.pY);
    fill(255, 255, 250, 255);
    ellipse(this.pX+_img.width/2, this.pY+_img.height/2, 5);   
}

FfObj.prototype.drawMe2 = function() {
    fill(255, 255, 250, 1);
    ellipse(this.pX+_img.width/2, this.pY+_img.height/2, _imgSize); 
    fill(255, 255, 250, 2);
    ellipse(this.pX+_img.width/2, this.pY+_img.height/2, _imgSize/2);      
    fill(255, 255, 250, 20);
    ellipse(this.pX+_img.width/2, this.pY+_img.height/2, _imgSize/40); 
    fill(255, 255, 250, 255);
    ellipse(this.pX+_img.width/2, this.pY+_img.height/2, 6);
}