p5.jsのWebGLモードで「グロー」を使おうとしてハマる


2018年01月08日
With
p5.jsのWebGLモードで「グロー」を使おうとしてハマる はコメントを受け付けていません。

(2018/01/25追記:こちらのエントリで紹介しているコードは、p5.js 0.5.16向けのものになります。0.6.0以降では正しく動作しません)

前回の「ブラー」に続き、今回はp5.js WebGLモードで「グロー」っぽい表現を使いたい時にどうすればいいかを考えてみます。

2Dモードの時には、「グロー」を使う方法として

・方法1:サイズと透明度を変えたオブジェクトを複数重ね描き
・方法2:光彩の画像を用意しておいてオブジェクトと重ねて表示

のいずれかが良さそうでした。

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

それぞれの方法がWebGLモードでもイケそうかどうか試してみます。

「オブジェクト重ね描き」はやっぱりお手軽

まずは方法1の「サイズと透明度を変えたオブジェクトを複数重ね描き」を試してみます。2Dモードの時は「fill()」でアルファ値を指定しましたが、今回はライティングを反映する3Dオブジェクトを設定できる「ambientMaterial()」内で指定しました。

●WebGLモードでグローっぽい効果 その1-オブジェクト重ね描き版(クリックすると別タブが開きます)

テスト用には、ブラーの時と同じく600×600×600の空間にランダムにsphereをばらまいて動かすスケッチを使います。今回、sphereの移動はz軸方向のみにしています。

2Dモードの時には「手軽だけれど、あんまりキレイじゃない方法」として紹介しましたが、3Dでもそんな感じですね(笑)。

var sphereArr = [];
var sphereMax = 80;
var camX = 0;
var camY = 0;
var camZ = 350;

function setup() {
	createCanvas(600, 600, WEBGL);
	camera(camX, camY, camZ, 0, 0, 0, 0, 1, 0);
	noStroke();
	for (var i = 0; i < sphereMax; i++) {
		sphereArr[i] = new PointObj(i);
	}
}

function draw() {
	background(0);
	orbitControl();
	sphereArr.forEach(spObj => { spObj.drawSphere(); spObj.update(); });
}
class PointObj {
	constructor(depth) {
		this.x = random(-300, 300);
		this.y = random(-300, 300);
		this.z = random(-300, 300);
		this.xmove = random(-1, 1);
		this.ymove = random(-1, 1);
		this.zmove = random(-1, 1);
	}
	drawSphere() {
		push();
		translate(this.x, this.y, this.z);
		ambientLight(120);
		pointLight(255, 255, 255, 255, -600, -600, 300);
		ambientMaterial(255, 255, 255, 2);
		sphere(25);
		ambientMaterial(255, 255, 255, 2);
		sphere(10);
		ambientMaterial(255, 255, 255, 10);
		sphere(5);
		ambientMaterial(255, 255, 255, 255);
		sphere(3);
		pop();
	}
	update() {
		this.z = this.z + this.zmove;
		if (this.z > 300 || this.z < -300) { this.zmove = -this.zmove; }
	}
}

気を付けるポイントとしては、重ね描きの際に、外側の光彩にあたる部分から内側に向かって順に書いていき、中央部の実体は最後に描くこと。この順番を逆にすると、理由はよく分からないのですが、ブラーの失敗例でも出てきた「謎の暗黒物質」が軌跡として残ってしまいます。

この方法の利点は、表現意図にさえ合えば非常に手軽に使えること。見かけの動きを作ったり、見ばえを整えたりするにあたって「rotateX」「rotateY」「rotateZ」や「orbitControl」などのp5.js作り付けの関数を、あまり深く考えずに使えます(なぜこれが利点になるのかについては方法2のところで説明します)。デメリットとしては、2Dの時と同じく、光彩をよりキレイに表現しようとすると描画するオブジェクト数が増え、パフォーマンスが犠牲になるところでしょうか。上の例では、1つの実体につき光彩用のオブジェクトを3つ描いています。

「光彩画像」を使う場合は「カメラからの距離」に注意

次に方法2を試します。WebGLモードでは、画像を直接座標上に置くことができないので、読み込んだ画像をまずテクスチャとして設定し、実体と同じ座標を中心に持つ「plane()」を描くことで表示させます。

なお、2Dモードの時には「createImage」を使ってプログラム的に画像を生成する方法を使いましたが、今回はあらかじめグラフィックソフトで作っておいたpng形式の光彩画像を「loadImage()」で読み込ませています。

さて、この方法でいけるでしょうか?

ん。おしいっ。一見うまくいっているように見えるのですが、よく見ると、うまいこと前後の光彩が透過で重なっているところと、なぜか透過が反映されずplane()の外枠が出てしまっているところとが混在しています。

これ、当初理由が分からず悩んだのですが、うまくいっている部分もあることや、方法1では描き順によって失敗したことを考え合わせて「もしかして『描き順』が関係しているのでは?」と思い当たります。

当初、このスケッチでは初期のx、 y、 z座標をいずれもランダムで決定していたのですが、試しにz座標(奥行きに相当)だけを、配列(sphereArr)の添字が少ないものから徐々に増やしていく形に変更したところ、

できたっ! うまいこと全部に透過処理が利いた状態で表示されました。方法2でグロー効果をつける場合は「カメラから見て遠くにあるオブジェクトから、近くにあるものの順になるように配列へ格納」すればうまくいきそうです。

さて、オブジェクトとカメラのいずれも位置が変わらないスケッチであれば、初期設定の時点で配列の添字がより小さいところに、z座標の数値が小さいオブジェクトを置くようにしておけば「for」あるいは「Array.forEach」を使って描画処理を順次実行すれば問題ないわけです。

しかし、テスト用に作った今回のスケッチもそうなのですが「オブジェクトがz座標を変えながら逐次移動する」ような場合は、実行中に位置関係が変化してしまい、その時点で透過に失敗するオブジェクトが出てきます。

これを解決するためには、オブジェクトの各変数をアップデートしたタイミングで、各オブジェクトのz座標を比較して、配列内でのオブジェクトの並びを変えてやる必要があります。で、その処理を加えたものが次のスケッチになります。

●WebGLモードでグローっぽい効果 その2-光彩画像版β(クリックすると別タブが開きます)

(2018/01/26追記:このスケッチはp5.js 0.5.16向けのものです。2018年1月中旬にリリースされた0.6.0向けに書き直したものをこちらのエントリにアップしました)

とりあえずイイ感じです。ソースコードはこちら。

var sphereArr = [];
var sphereMax = 80;
var glowImg;

function setup() {
	glowImg = loadImage('./images/glow.png');
	createCanvas(600, 600, WEBGL);
	noStroke();
	camera(0, 0, 350, 0, 0, 0, 0, 1, 0);
	for (var i = 0; i < sphereMax; i++) {
		sphereArr[i] = new PointObj(i);
	}
}

function draw() {
	background(0);
	orbitControl();
	sphereArr.forEach(allDraw => { allDraw.drawPlane(); allDraw.drawSphere(); allDraw.update(); });
	sphereArr.sort(zOrder);
}

function zOrder(obj1, obj2) {
	return obj1.z - obj2.z;
}

class PointObj {
	constructor(depth) {
		this.x = random(-300, 300);
		this.y = random(-300, 300);
		this.z = random(-300, 300);
		this.xmove = random(-1, 1);
		this.ymove = random(-1, 1);
		this.zmove = random(-3, 3);
	}
	drawPlane() {
		push();
		translate(this.x, this.y, this.z);
		texture(glowImg);
		plane(50, 50);
		pop();
	}
	drawSphere() {
		push();
		translate(this.x, this.y, this.z);
		ambientLight(120);
		pointLight(255, 255, 255, 255, -600, -600, 300);
		ambientMaterial(255, 255, 255, 255);
		sphere(3);
		pop();
	}
	update() {
		this.z = this.z + this.zmove;
		if (this.z > 300 || this.z < -300) { this.zmove = -this.zmove; }
	}
}

ポイントは、19行目に追加した「Array.sort()」です。これ、引数として与えた関数での処理を基準にして、配列内のすべての要素を並べ替えてくれるという大変便利なメソッドです。ここで呼び出している「zOrder」という関数では、取り出した2つのオブジェクトが持っているz座標の値を引き算で比較し、結果的に毎フレーム「z座標の値が小さい順に、sphereArr内のオブジェクトを並べ替える」という処理を行っています。これで、オブジェクトのz座標が変化するようなスケッチでも光彩画像によるグロー効果が適用できるようになりました。

この「配列要素並べ替え」を使って作ったスケッチをもうひとつ紹介します。

●回転する立方体と球面上のグロー付きsphere(クリックすると別タブが開きます)

(2018/01/26追記:このスケッチはp5.js 0.5.16向けのものです。2018年1月中旬にリリースされた0.6.0向けに書き直したものをこちらのエントリにアップしました)

このスケッチでのsphereは、中央の原点から半径180ユニットを持つ球面の上をランダムに回転しています。当然、z座標(奥行き)は頻繁に入れ替わっているのですが、Array.sortのおかげで、正しく透過処理ができていますね。また、透過の効果を際立たせるために中央で青い立方体を回転させてみたのですが、やはり方法1よりもこちらのほうがキレイで使いやすいのではないかと思います。

const sphereArr = [];
const sphereMax = 60;
let RADIUS = 180;
let glowImg;

function setup() {
	glowImg = loadImage('./images/glow.png');
	createCanvas(600, 600, WEBGL);
	noStroke();
	perspective(radians(80), width / height, 0, 1000);
	camera(0, 0, 310, 0, 0, 0, 0, 1, 0);
	for (var i = 0; i < sphereMax; i++) {
		sphereArr[i] = new PointObj();
	}
}

function draw() {
	background(0, 0, 20);
	drawCenterSphere();
	sphereArr.forEach(allDraw => { allDraw.drawPlane(); allDraw.drawSphere(); allDraw.update(); });
	sphereArr.sort(zOrder);
}

function zOrder(obj1, obj2) {
	return obj1.z - obj2.z;
}

function drawCenterSphere() {
	ambientLight(80);
	pointLight(255, 255, 255, 255, -600, -600, 300);
	ambientMaterial(120, 120, 255, 255);
	push();
	rotateX(frameCount / 100);
	rotateY(frameCount / 100);
	box(150);
	pop();
}

class PointObj {
	constructor() {
		this.deg1 = random(0, 359);
		this.deg2 = random(0, 359);
		this.x = cos(radians(this.deg2)) * sin(radians(this.deg1)) * RADIUS;
		this.y = sin(radians(this.deg2)) * sin(radians(this.deg1)) * RADIUS;
		this.z = cos(radians(this.deg1)) * RADIUS;
		if (random() < .5) {
			this.deg1Add = -.5;
		} else {
			this.deg1Add = .5;
		}
		if (random() < .5) {
			this.deg2Add = -.5;
		} else {
			this.deg2Add = .5;
		}
	}

	drawPlane() {
		push();
		translate(this.x, this.y, this.z);
		texture(glowImg);
		plane(50, 50);
		pop();
	}
	drawSphere() {
		push();
		translate(this.x, this.y, this.z);
		ambientLight(230);
		pointLight(255, 255, 255, 255, -600, -600, 300);
		ambientMaterial(200, 200, 255, 255);
		sphere(3);
		pop();
	}
	update(rad) {
		this.deg1 += this.deg1Add;
		this.deg2 += this.deg2Add;
		this.x = cos(radians(this.deg1)) * sin(radians(this.deg2)) * RADIUS;
		this.y = sin(radians(this.deg1)) * sin(radians(this.deg2)) * RADIUS;
		this.z = cos(radians(this.deg2)) * RADIUS;
	}
}

こちらの方法のデメリットについては、既に何となく察しはついているかと思いますが、コーダー側で気を付けなければならない部分が増えること。この方法でグローを適用したい場合にはオブジェクトの座標とカメラの座標を意識したプログラミングが必要です。p5.jsで便利に使える「rotateX、rotateY、rotateZ」「orbitControl」などを無自覚に使えなくなるので手間が増えるといった感じでしょうか。

グローで表現する「Sphere Light」

とりあえずここまでの成果のまとめとして、こんなスケッチを作ってみました。

●Sphere Light(クリックすると別タブが開きます)

クリックすると上のような球状に回転するsphereが表示されると思います。ブラウザ上で1回クリックすると次の画像に切り替わります。

各sphereに「方法1」のやり方でグロー効果をつけたものです。これはこれで悪くないですね。そしてもう1回クリックすると…

「方法2」の光彩画像によるグローになります。やはりこちらのほうがキレイで使い途が多そうです。もう一度クリックすると最初の状態に戻ります。

var sphereArr = [];
var rad = 250;
var iDef = 10;
var jDef = 12;
var glowImg;
var jCount = 0;
var glowFlag = 0;

function setup() {
	createCanvas(600, 600, WEBGL);
	glowImg = loadImage('./images/glow.png');
	noStroke();
	perspective(radians(75), width / height, 0, 1000);
	camera(0, 0, 450, 0, 0, 0, 0, 1, 0);
	ambientLight(150);
	pointLight(255, 255, 255, 255, -300, -300, 300);
}

function draw() {
	background(setBg(glowFlag));
	sphereArr = [];
	var count = 0;
	for (var i = 180; i > -1; i -= iDef) {
		for (var j = -180 + jCount; j < 180 + jCount; j += jDef) {
			sphereArr[count] = new PointObj(i, j, rad);
			count++;
		}
	}
	if (glowFlag == 2) { sphereArr.sort(zOrder); }
	for (var k = 0; k < sphereArr.length; k++) {
		push();
		translate(sphereArr[k].x, sphereArr[k].y, sphereArr[k].z);
		if (glowFlag == 1) {
			ambientMaterial(230, 180, 255, 8);
			sphere(25);
			ambientMaterial(230, 180, 255, 10);
			sphere(15);
			ambientMaterial(230, 180, 255, 30);
			sphere(5)
		}
		if (glowFlag == 2) {
			texture(glowImg);
			plane(60, 60);
		}
		ambientMaterial(230, 180, 255, 255);
		sphere(3);
		pop();

	}
	jCount += 0.5;
}

function setBg(flag) {
	switch (flag) {
		case 0: return color(0, 0, 0); break;
		case 1: return color(40, 10, 10); break;
		case 2: return color(10, 10, 40); break;
	}
}
function zOrder(obj1, obj2) {
	return obj1.z - obj2.z;
}

function PointObj(ai, jay, rado) {
	this.x = sin(radians(jay)) * sin(radians(ai)) * rado;
	this.y = cos(radians(ai)) * rado;
	this.z = cos(radians(jay)) * sin(radians(ai)) * rado;
}

function mouseReleased() {
	glowFlag++;
	if (glowFlag > 2) { glowFlag = 0; }
}

描き順問題も解決して、これで「グロー」については一段落……かと思いきや、実はまだ未解決の問題が残っています。それは「カメラの位置や向き」が変化する場合です。

カメラ位置のz座標や注視点の方向が変わらない場合は、基本、ここまでのやり方で光彩画像によるグロー効果が使えるのですが、これらが変化する可能性がある場合は、さらにもう一手間かけてやる必要があります。一応、その方法も考えてみたので次のエントリで紹介します。

次回につづく