p5.jsで万華鏡風のスケッチを描いてみた


2018年03月14日
With
p5.jsで万華鏡風のスケッチを描いてみた はコメントを受け付けていません

p5.jsを勉強しはじめてから、昔からある幾何学おもちゃをモチーフにしたスケッチをいくつか描いています。

p5.jsで追憶の「スピログラフ」を再現する

いろんなゲームを思い出しながら「10 PRINT」を作り替えてみた(模様作り積み木)

モチーフとして、前々から使ってみたかったものに「万華鏡」があります。結果的に「万華鏡風」のものになってしまったのですが、今回はそれを。

●サイケデリックな万華鏡その1(クリックすると別タブが開きます)

マウスとキーボードでいくつかの要素を変えられます。

・マウスクリック: カンバスの背景色を「黒」「白」のいずれかに切替
・スペースキー: ブラーモードの変更
・「H」キー: ステージの回転を止める(停止時に押すと再開)
・「P」キー: カンバスをPNG画像として保存(Chrome、FireFoxで確認)

このスケッチには4つの「ブラーモード」があります。

・ブラーなし
・ブラー(弱)
・ブラー(強)
・サイケデリック(前フレームの描画を消さない)

起動時は「ブラーなし」ですが、実行中に一定の確率で他のモードに切り替わります。スペースキーを押すと、強制的にブラーモードを次のものに切り替えます。「サイケデリック」状態からは「ブラーなし」に戻ります。

背景色はブラウザ上のクリックで「白」「黒」のトグルで切り替わります。ブラーの強さと背景色の組み合わせで、かなり印象の違う絵が出てきますので、いろいろ試して見てください。なんとなく、ブラーが付いている時は黒背景のほうがキレイに見える気がします。「サイケデリック」は昔のビデオドラッグ的な表現で、長いこと凝視しているとクラクラしますのでご注意を。コードはこんな感じです。

const bitsArray = [];
const bitsCount = 90;
const projAngle = 60;
const bgAlphaArray = [255, 64, 16, 0];
let stageAngle = 0;
let stageAngleNoise = Math.random() * 1000;
let bgColor = 255;
let bgAlphaNum = 0;
let bgAlpha = bgAlphaArray[bgAlphaNum];
let stageAngleFlag = true;

function setup() {
	createCanvas(600, 600);
	initBits();
	colorMode(HSB, 359, 255, 255, 255);
	noStroke();
}

function draw() {
	fill(bgColor, bgAlpha);
	rect(0, 0, width, height);

	for (let angle = 0; angle < 360; angle += projAngle) {
		push();
		translate(width / 2, height / 2);
		rotate(radians(stageAngle));
		rotate(radians(angle));
		if (angle % (projAngle * 2) == 0) {
			scale(-1, 1);
		}
		bitsArray.forEach(thisbit => { thisbit.drawMe(); });
		pop();
	}
	bitsArray.forEach(thisbit => { thisbit.updateMe(); });

	let stageAngleTrans = noise(stageAngleNoise) - .5;
	if (stageAngleFlag) { stageAngle += stageAngleTrans; }
	stageAngleNoise += .001;

	if (random(0, 4096) < 1) { bgAlpha = bgAlphaArray[bgAlphaChange()]; }
}

function initBits() {
	for (let i = 0; i < bitsCount; i++) {
		let j = random();
		if (j < .35) {
			bitsArray[i] = new EllipseBit();
		} else if (j >= .35 && j < .75) {
			bitsArray[i] = new TriangleBit();
		} else if (j >= .75 && j < .9) {
			bitsArray[i] = new QuadBit();
		} else if (j >= .9) {
			bitsArray[i] = new StarBit();
		}
	}
}

function bgAlphaChange() {
	let j = random();
	if (j < .5) { bgAlphaNum = 0; return 0; }
	else if (j >= .5 && j < .7) { bgAlphaNum = 1; return 1; }
	else if (j >= .7 && j < .9) { bgAlphaNum = 2; return 2; }
	else if (j >= .9) { bgAlphaNum = 3; return 3; }
}

function boolPicker(prob) {
	if (random() < prob) { return true; }
	else { return false; }
}

function mouseClicked() {
	bgColor = abs(bgColor - 255);
}

function keyPressed() {
	if (keyCode === 72) {
		stageAngleFlag = !stageAngleFlag;
	}
	if (keyCode === 32) {
		bgAlphaNum++;
		if (bgAlphaNum > 3) { bgAlphaNum = 0; }
		bgAlpha = bgAlphaArray[bgAlphaNum];
	}
	if (keyCode === 80) {
		saveCanvas();
	}
}

class Bit {
	constructor() {
		this.x = random(-width / 2, width / 2);
		this.y = random(-height / 2, height / 2);
		this.xSize = 20;
		this.ySize = 20;
		this.scale = random(1, 4);
		this.col = random(0, 360);
		this.alpha = 92;
		this.weight = random(1, 3);
		this.rot = 0;
		this.rotDif = 0;
		this.noiseRot = random(-1000, 1000);
		this.noiseX = random(-1000, 1000);
		this.noiseY = random(-1000, 1000);
		this.noiseXdif = random(-.01, .01);
		this.noiseYdif = random(-.01, .01);
	}
	updateMe() {
		this.x += noise(this.noiseX) * 2 - 1;
		this.y += noise(this.noiseY) * 2 - 1;
		this.rot += this.rotDif;
		this.noiseX += this.noiseXdif;
		this.noiseY += this.noiseYdif;
		this.rotDif = noise(this.noiseRot) * 4 - 2;
		this.noiseRot += .006
		if (this.x < -width / 1.2 || this.x > width / 1.2) { this.x = -this.x; }
		if (this.y < -height / 1.2 || this.y > height / 1.2) { this.y = -this.y; }
	}
}

class EllipseBit extends Bit {
	constructor() {
		super();
		this.shapeFlag = boolPicker(.7);
		this.strokeFlag = boolPicker(.3);
		if (this.shapeFlag) { this.xSize = random(5, 10); this.ySize = random(10, 30); }
	}
	drawMe() {
		push();
		if (this.strokeFlag) { noFill(); stroke(this.col, 192, 255, this.alpha); strokeWeight(this.weight); }
		else { noStroke(); fill(this.col, 192, 255, this.alpha); }
		translate(this.x, this.y);
		rotate(radians(this.rot));
		scale(this.scale);
		ellipse(0, 0, this.xSize, this.ySize);
		pop();
	}
}

class TriangleBit extends Bit {
	constructor() {
		super();
		this.strokeFlag = boolPicker(.3);
		this.pAx = random(this.xSize / 5, this.xSize), this.pAy = random(-this.ySize, -this.ySize / 5);
		this.pBx = random(this.xSize / 5, this.xSize), this.pBy = random(this.ySize / 5, this.ySize);
	}

	drawMe() {
		push();
		if (this.strokeFlag) { noFill(); stroke(this.col, 192, 255, this.alpha); strokeWeight(this.weight); }
		else { noStroke(); fill(this.col, 192, 255, this.alpha); }
		translate(this.x, this.y);
		rotate(radians(this.rot));
		scale(this.scale);
		triangle(0, 0, this.pAx, this.pAy, this.pBx, this.pBy);
		pop();
	}
}

class QuadBit extends Bit {
	constructor() {
		super();
		this.shapeFlag = boolPicker(.5);
		this.strokeFlag = boolPicker(.3);
		this.pAx = random(-this.xSize / 2, this.xSize / 2), this.pAy = random(-this.ySize, 0);
		this.pBx = random(-this.xSize / 2, this.xSize / 2), this.pBy = random(0, this.ySize);
		this.pCx = random(-this.xSize, this.xSize), this.pCy = random(-this.ySize, this.ySize);
		if (!this.shapeFlag) { this.xSize = random(2, 20); this.ySize = random(15, 30); }
	}

	drawMe() {
		push();
		if (this.strokeFlag) { noFill(); stroke(this.col, 192, 255, this.alpha); strokeWeight(this.weight); }
		else { noStroke(); fill(this.col, 192, 255, this.alpha); }
		translate(this.x, this.y);
		rotate(radians(this.rot));
		scale(this.scale);
		if (this.shapeFlag) { quad(0, 0, this.pAx, this.pAy, this.pBx, this.pBy, this.pCx, this.pCy); }
		else { rect(0, 0, this.xSize, this.ySize); }
		pop();
	}
}

class StarBit extends Bit {
	constructor() {
		super();
		this.strokeFlag = boolPicker(.3);
		this.scale = random(1, 2);
		this.pArray = [];
		for (let i = 0; i < 360; i += 36) {
			let j;
			if (i % 72 != 0) { j = this.xSize; }
			else { j = this.xSize * .36; }
			this.pArray.push(new StarPoints(i, j));
		}
	}

	drawMe() {
		push();
		if (this.strokeFlag) { noFill(); stroke(this.col, 192, 255, this.alpha); strokeWeight(this.weight); }
		else { noStroke(); fill(this.col, 192, 255, this.alpha); }
		translate(this.x, this.y);
		rotate(radians(this.rot));
		scale(this.scale);
		beginShape();
		for (let i = 0; i < 10; i++) {
			vertex(this.pArray[i].x, this.pArray[i].y);
		}
		vertex(this.pArray[0].x, this.pArray[0].y);
		endShape();
		pop();
	}
}

class StarPoints {
	constructor(ai, jey) {
		this.x = sin(radians(ai)) * jey;
		this.y = cos(radians(ai)) * jey;
	}
}

かなり長くなっちゃいましたね。

オブジェクトの「色」については、96行目にある「this.col」で起動時にランダムに指定しています。HSBカラーモデルの「Hue」値として持っていますので、random関数に与える引数の範囲を変えることで、ある程度暖色系や寒色系にまとめることもできます。ぜひお試しを。

「合わせ鏡」に苦悩する

このスケッチが「万華鏡風」であるとしたゆえんは、万華鏡の最大の特徴である「合わせ鏡」の表現がp5.jsでは難しく、かなり妥協したためです。

万華鏡的な表現を作るにあたって、子どものころに万華鏡を自作したときのことを思い出しながら、それに合わせてコードを書いてみることにしました。プロセスとしては、

1.いろんな色のセロファンをさまざまな形に小さく切る
2.3枚の長細い鏡を貼り合わせて両端の開いた三角柱を作る
3.丸く平べったい透明のプラスティック容器に切ったセロファンを入れ、鏡の片端に貼りつける

といった感じです。1については「Bit」という基本のクラスを作り、その継承で「EllipseBit」「TriangleBit」「QuadBit」「StarBit」というさまざまな形のオブジェクトを作ることで再現しています。描画については、それぞれに持っている「drawMe」メソッドで定義していますので、Bitの継承としてほかの形を描くオブジェクトを用意してやることで追加もできます。

各オブジェクトはsetup内で呼んでいる「initBits」という関数で、「bitsArray」という配列内にランダムに格納しています。新しいオブジェクトを作ったら、ここをいじってbitsArray内に入るようにしておくことを忘れずに。

で、問題は2番目の「合わせ鏡」をどう再現するかという点。最初の方針としては「マスク」を使ってスケッチの一部を三角形に切り出し、それを回転させたり裏返したりしながらいくつか並べてみることを考えていました。

p5.jsでは、「createGraphics」という関数を使って、スケッチを書けるオブジェクトをメモリ上に生成できます。このオブジェクトは、image関数を使ってcanvas上に表示することが可能です。

ただ、このグラフィックオブジェクトには直接マスクをかけることができません。maskメソッドを使ってマスクをかけるためには、対象がイメージオブジェクトである必要があります。

いろいろ調べてみて、どうやら「Processing」では、両オブジェクトが持つ「pixels」という配列プロパティをコピーしてやることで「グラフィックスオブジェクトのイメージ化」ができるらしいというところまでは分かったのですが、それをp5.jsで同じようにやろうとすると、微妙に仕様が違うのか、エラーが出てしまい、うまくいきません。

結局、当初の方針を変更して、より簡単にそれっぽい見栄えを作ることにしました。オブジェクトがばらまかれた1枚の絵を、回転(rotate)と反転(scale)を繰り返しながら、6回重ね描きするという方法です。scaleは本来、グラフィックを描くときの倍率を指定するメソッドですが「-1」などと指定してやることで上下や左右を反転させて描画することができます。結果、何となく各オブジェクトが鏡に映っているように見えるスケッチになりました。

createGraphicsでもうちょっとだけがんばってみる

いちおう↑のスケッチも、それらしい見栄えにはなったのですが、1点納得いかないところが残ります。本物の万華鏡の場合「鏡の面から少しだけ出ている図形」が鏡の反射によって実際の図形とは違う形に見え、その変化も予想外のものになるという効果を生むのですが、↑では、そのあたりの驚きが若干足りません。

せっかく「createGraphics」の存在を知ったので、これを使ってもう1パターン作ってみました。ただ「createGraphics」を使ったスケッチはブラウザのメモリを激しく消費するようで、PC上で動かしている時には特に問題ないのですが、iOSやAndroidのモバイルブラウザで動かそうとするとかなりの確率でブラウザが強制終了してしまいます。次のスケッチは、PC上のブラウザで動かすことを強くオススメします。

2021/12/16追記:この「createGraphics()がメモリ食いすぎてブラウザ落ちる問題」には有効な対処法があるようです。こちらの記事で、remove()による方法が説明されていますので、同様の問題にお悩みの方は参考にしてください。
●p5.js の createGraphics() がメモリを放してくれない!-クリエイティブ・コーディング・天国!

●サイケデリックな万華鏡その2(クリックすると別タブが開きます。PCブラウザでの起動を推奨。モバイルブラウザで動かすとたぶん落ちます)

マウスとキーボードによる変化は「その1」と同じです。

こちらのスケッチでは、いったん「createGraphics」で作ったオブジェクト(stBlock)上に絵を描き、それを「scale」で反転させながら、canvasの中央に角を合わせるようにして4枚貼り合わせています。中央や各面の境目あたりを見ていると、図形が鏡合わせの効果で別の形に見えたり、急に現れたり、消失したりといった効果が出ていると思います。

const bitsArray = [];
const bitsCount = 400;
const bgAlphaArray = [255, 64, 16, 0];
let stageAngle = [0, 0];
let stageAngleNoise = [Math.random() * 1000, Math.random() * 1000];
let stageAngleTrans = [];
let bgColor = 255;
let bgAlphaNum = 0;
let bgAlpha = bgAlphaArray[bgAlphaNum];
let stageAngleFlag = true;
let stBlock;

function setup() {
	createCanvas(600, 600);
	pixelDensity(displayDensity());
	stBlock = createGraphics(width / 2 * sqrt(3), height / 2 * sqrt(3));
	initBits();
	stBlock.colorMode(HSB, 359, 255, 255, 255);
	background(120);
}

function draw() {
	blockDraw();

	translate(width / 2, height / 2);
	rotate(radians(stageAngle[1]));
	let stScaleX = -1, stScaleY = 1;
	for (let i = 0; i < 4; i++) {
		image(stBlock, 0, 0);
		scale(stScaleX, stScaleY);
		stScaleX = -stScaleX;
		stScaleY = -stScaleY;
	}

	bitsArray.forEach(thisbit => { thisbit.updateMe(); });

	for (let i = 0; i < 2; i++) {
		stageAngleTrans[i] = noise(stageAngleNoise[i]) - .5;
		if (stageAngleFlag) { stageAngle[i] += stageAngleTrans[i]; }
		stageAngleNoise[i] += .001;
	}

	if (random(0, 4096) < 1) { bgAlpha = bgAlphaArray[bgAlphaChange()]; }
}

function initBits() {
	for (let i = 0; i < bitsCount; i++) {
		let j = random();
		if (j < .35) {
			bitsArray[i] = new EllipseBit();
		} else if (j >= .35 && j < .75) {
			bitsArray[i] = new TriangleBit();
		} else if (j >= .75 && j < .9) {
			bitsArray[i] = new QuadBit();
		} else if (j >= .9) {
			bitsArray[i] = new StarBit();
		}
	}
}

function blockDraw() {
	stBlock.noStroke();
	stBlock.fill(bgColor, bgAlpha);
	stBlock.rect(0, 0, stBlock.width, stBlock.height);
	stBlock.push();
	stBlock.translate(stBlock.width / 2, stBlock.height / 2);
	stBlock.rotate(radians(stageAngle[0]));
	bitsArray.forEach(thisbit => { thisbit.drawMe(); });
	stBlock.pop();
}

function bgAlphaChange() {
	let j = random();
	if (j < .5) { bgAlphaNum = 0; return 0; }
	else if (j >= .5 && j < .7) { bgAlphaNum = 1; return 1; }
	else if (j >= .7 && j < .9) { bgAlphaNum = 2; return 2; }
	else if (j >= .9) { bgAlphaNum = 3; return 3; }
}

function boolPicker(prob) {
	if (random() < prob) { return true; }
	else { return false; }
}

function mouseClicked() {
	bgColor = abs(bgColor - 255);
}

function keyPressed() {
	if (keyCode === 72) {
		stageAngleFlag = !stageAngleFlag;
	}
	if (keyCode === 32) {
		bgAlphaNum++;
		if (bgAlphaNum > 3) { bgAlphaNum = 0; }
		bgAlpha = bgAlphaArray[bgAlphaNum];
	}
	if (keyCode === 80) {
		saveCanvas();
	}
}

class Bit {
	constructor() {
		this.x = random(-stBlock.width / 2, stBlock.width / 2);
		this.y = random(-stBlock.height / 2, stBlock.height / 2);
		this.xSize = 20;
		this.ySize = 20;
		this.scale = random(1, 4);
		this.col = random(0, 360);
		this.alpha = 92;
		this.weight = random(1, 3);
		this.rot = 0;
		this.rotDif = 0;
		this.noiseRot = random(-1000, 1000);
		this.noiseX = random(-1000, 1000);
		this.noiseY = random(-1000, 1000);
		this.noiseXdif = random(-.01, .01);
		this.noiseYdif = random(-.01, .01);
	}
	updateMe() {
		this.x += noise(this.noiseX) * 2 - 1;
		this.y += noise(this.noiseY) * 2 - 1;
		this.rot += this.rotDif;
		this.noiseX += this.noiseXdif;
		this.noiseY += this.noiseYdif;
		this.rotDif = noise(this.noiseRot) * 4 - 2;
		this.noiseRot += .006
		if (this.x < -stBlock.width / 1.2 || this.x > stBlock.width / 1.2) { this.x = -this.x; }
		if (this.y < -stBlock.height / 1.2 || this.y > stBlock.height / 1.2) { this.y = -this.y; }
	}
}

class EllipseBit extends Bit {
	constructor() {
		super();
		this.shapeFlag = boolPicker(.7);
		this.strokeFlag = boolPicker(.3);
		if (this.shapeFlag) { this.xSize = random(5, 10); this.ySize = random(10, 30); }
	}
	drawMe() {
		stBlock.push();
		if (this.strokeFlag) { stBlock.noFill(); stBlock.stroke(this.col, 192, 255, this.alpha); stBlock.strokeWeight(this.weight); }
		else { stBlock.noStroke(); stBlock.fill(this.col, 192, 255, this.alpha); }
		stBlock.translate(this.x, this.y);
		stBlock.rotate(radians(this.rot));
		stBlock.scale(this.scale);
		stBlock.ellipse(0, 0, this.xSize, this.ySize);
		stBlock.pop();
	}
}

class TriangleBit extends Bit {
	constructor() {
		super();
		this.strokeFlag = boolPicker(.3);
		this.pAx = random(this.xSize / 5, this.xSize), this.pAy = random(-this.ySize, -this.ySize / 5);
		this.pBx = random(this.xSize / 5, this.xSize), this.pBy = random(this.ySize / 5, this.ySize);
	}

	drawMe() {
		stBlock.push();
		if (this.strokeFlag) { stBlock.noFill(); stBlock.stroke(this.col, 192, 255, this.alpha); stBlock.strokeWeight(this.weight); }
		else { stBlock.noStroke(); stBlock.fill(this.col, 192, 255, this.alpha); }
		stBlock.translate(this.x, this.y);
		stBlock.rotate(radians(this.rot));
		stBlock.scale(this.scale);
		stBlock.triangle(0, 0, this.pAx, this.pAy, this.pBx, this.pBy);
		stBlock.pop();
	}
}

class QuadBit extends Bit {
	constructor() {
		super();
		this.shapeFlag = boolPicker(.5);
		this.strokeFlag = boolPicker(.3);
		this.pAx = random(-this.xSize / 2, this.xSize / 2), this.pAy = random(-this.ySize, 0);
		this.pBx = random(-this.xSize / 2, this.xSize / 2), this.pBy = random(0, this.ySize);
		this.pCx = random(-this.xSize, this.xSize), this.pCy = random(-this.ySize, this.ySize);
		if (!this.shapeFlag) { this.xSize = random(2, 20); this.ySize = random(15, 30); }
	}

	drawMe() {
		stBlock.push();
		if (this.strokeFlag) { stBlock.noFill(); stBlock.stroke(this.col, 192, 255, this.alpha); stBlock.strokeWeight(this.weight); }
		else { stBlock.noStroke(); stBlock.fill(this.col, 192, 255, this.alpha); }
		stBlock.translate(this.x, this.y);
		stBlock.rotate(radians(this.rot));
		stBlock.scale(this.scale);
		if (this.shapeFlag) { stBlock.quad(0, 0, this.pAx, this.pAy, this.pBx, this.pBy, this.pCx, this.pCy); }
		else { stBlock.rect(0, 0, this.xSize, this.ySize); }
		stBlock.pop();
	}
}

class StarBit extends Bit {
	constructor() {
		super();
		this.strokeFlag = boolPicker(.3);
		this.scale = random(1, 2);
		this.pArray = [];
		for (let i = 0; i < 360; i += 36) {
			let j;
			if (i % 72 != 0) { j = this.xSize; }
			else { j = this.xSize * .36; }
			this.pArray.push(new StarPoints(i, j));
		}
	}

	drawMe() {
		stBlock.push();
		if (this.strokeFlag) { stBlock.noFill(); stBlock.stroke(this.col, 192, 255, this.alpha); stBlock.strokeWeight(this.weight); }
		else { stBlock.noStroke(); stBlock.fill(this.col, 192, 255, this.alpha); }
		stBlock.translate(this.x, this.y);
		stBlock.rotate(radians(this.rot));
		stBlock.scale(this.scale);
		stBlock.beginShape();
		for (let i = 0; i < 10; i++) {
			stBlock.vertex(this.pArray[i].x, this.pArray[i].y);
		}
		stBlock.vertex(this.pArray[0].x, this.pArray[0].y);
		stBlock.endShape();
		stBlock.pop();
	}
}

class StarPoints {
	constructor(ai, jey) {
		this.x = sin(radians(ai)) * jey;
		this.y = cos(radians(ai)) * jey;
	}
}

この「createGraphics」をうまく使うと面白い効果がいろいろ作れそうです。例えば、以前作った「荒ぶるベジェ曲線」のようなスケッチを今回の方法で並べると、また全然違ったスケッチのように見えます。

●「荒ぶるベジェ曲線」の万華鏡(クリックすると別タブが開きます。PCブラウザでの起動を推奨。モバイルブラウザで動かすとたぶん落ちます)

作ってはみたもののなんかもの足りないスケッチを、createGraphicsでもうひとつ別のレイヤの上に載っけてアレンジするとかできそうですよね。…まぁ、モバイルブラウザがほぼ確実に落ちる現象については何とかしたいところですが。

※2018/3/26追記…この時に保留していた「p5.jsでのグラフィックオブジェクトのイメージ化」の方法がその後分かったので、当初の計画どおりの方式でより万華鏡っぽさが増したスケッチを作りました。よろしければそちらもご覧頂ければ|「サイケデリックな万華鏡」リベンジ