p5.jsで「南京玉すだれ」的な何かを表現しようとしたいくつかの試み


2018年06月30日
With
p5.jsで「南京玉すだれ」的な何かを表現しようとしたいくつかの試み はコメントを受け付けていません。

昔遊んだ「幾何学おもちゃ」や「ビデオゲーム」から、ふとスケッチのアイデアが浮かぶことがあるのですが、今回もそんな感じで。

タイトーが1981年に発売した「QIX」(クイックス)というゲームがあります。こんなヤツです。

当時住んでいた長崎市内のとあるデパートのゲームコーナーに、この「QIX」が置いてありまして、週末になると電車代ケチって片道約1時間の道のりをほてほて歩いてそこへ行き、1プレイ50円で暗くなるまで遊んでいたものでした。

このQIX、ざっくり言うと自機から伸びる線でフィールドを囲んで陣地を広げていく「陣取りゲーム」です。当時のアーケードゲームとしては、ルール、グラフィック、サウンドのいずれもがストイックで、それがかえって独特の雰囲気を演出しており、カッコ良かったのでした。

ちなみに「QIX」というのは、↑の動画で画面上をくねくねと動き回って、プレイヤーの邪魔をしている敵の名前です。陣地を取ろうと線を伸ばしている途中で、自機か線がQIXに触れてしまうとミスになります。このQIXの動きがまた独特で、軌跡を残しつつ伸びたり縮んだりしながらプレイヤーにジワジワ寄ってくるようすから「南京玉すだれ」などとも呼ばれておりました。

QIXの動きを見ていると、

・基本は直線の連なり
・直線の長さは伸びたり縮んだりする
・方向と色も頻繁に変えながら移動する
・同時に直線は回転も(?)しているようだ

と、割とシンプルな要素の組み合わせでできているようです。ならば「このQIXの感じをp5.jsで再現できないだろうか?」と試行錯誤した過程が、以下の5つのスケッチになります。

●南京玉すだれ Ver.0(クリックすると別タブが開きます)

上の要素をボンヤリとイメージしながら最初に作った試作スケッチ。とりあえずモノクロで作ってしまったこともあって、ぜんぜんQIXぽくありません。どちらかというと「ジェネラティブ・アート-Processingによる実践ガイド」に出ていたサンプルスケッチ「Wave Clock」に近いでしょうか。

let x, y, xdif, ydif, rad, rot, col;
let radnoise, rotnoise, colnoise;
function setup() {
    createCanvas(600, 600);
    background(255);
    strokeWeight(.5);
    x = random(0, width);
    xdif = 2;
    y = random(0, height);
    ydif = 2;
    rad = random(50, 300);
    rot = random(0, 360);
    col = random(10, 230);
    radnoise = random(0, 1000);
    rotnoise = random(0, 1000);
    colnoise = random(0, 1000);
}

function draw() {
    background(255, 2);
    translate(x, y);
    rotate(radians(rot));
    stroke(col, 128);
    line(0, - rad / 2, 0, rad / 2);

    x = x + xdif;
    y = y + ydif;

    if (random() < .01 || x < 0 || x > width) {
        xdif = -xdif;
    }
    if (random() < .01 || y < 0 || y > height) {
        ydif = -ydif;
    }

    rad += noise(radnoise) * 10 - 5;
    if (rad < 50) { rad = 50; }
    if (rad > 300) { rad = 300; }
    rot += noise(rotnoise) * 14 - 7;
    col += noise(colnoise) * 4 - 2;
    if (col < 10) { col = 10; }
    if (col > 230) { col = 230; }
    radnoise += 0.03;
    rotnoise += 0.01;
    colnoise += 0.02;
}

で、よくよく観察してみると、QIXは、軌跡が尺取り虫のように伸びたり縮んだりするのと、「反射」のような形で急に移動方向が変化するというのが、動きを特徴付けているみたいです。そのあたりを意識しながら改善を加えてみたのが次。

●南京玉すだれ Ver.1(クリックすると別タブが開きます)

これで、だいぶQIXに近づいたような気がします。軌跡については、最大の長さを決めておき、一定の確率で伸縮を切り替えるような感じにしています。そのために、過去の軌跡は配列で管理するようにしました。

そのほか、色や回転、動きがより激しく変化するように調整。同時に、反射時に移動と回転の方向を変えられるよう「方向」と「大きさ」を別の変数で持つようにしました。

let lines = [];
let isPush = true;
let x, y, xdif, ydif, xdifvec, ydifvec, rad, rot, rotvec, col;
let radnoise, rotnoise, colnoise, xdifnoise, ydifnoise;
function setup() {
	createCanvas(600, 600);
	colorMode(HSB);
	background(0, 0, 5, 1);
	strokeWeight(4);
	x = random(0, width);
	y = random(0, height);
	rot = random(0, 360);
	col = random(0, 360);
	rad = random(100, 300);
	xdifnoise = random(0, 1000);
	ydifnoise = random(0, 1000);
	radnoise = random(0, 1000);
	rotnoise = random(0, 1000);
	colnoise = random(0, 1000);
	xdif = noise(xdifnoise) * 4 + 1;
	xdifvec = 1;
	ydif = noise(ydifnoise) * 4 + 1;
	ydifvec = 1;
	rotvec = 1;
	lines.push({
		tx: x,
		ty: y,
		trot: rot,
		tcol: col,
		trad: rad
	});
}

function draw() {
	background(0, 100, 0, 1);
	lines.forEach(aline => {
		push();
		translate(aline.tx, aline.ty);
		rotate(radians(aline.trot));
		stroke(aline.tcol, 100, 100, .8);
		line(0, - aline.trad / 2, 0, aline.trad / 2);
		pop();
	});

	if (random() < .005 && isPush == true) {
		isPush = !isPush;
	}

	if (isPush) {
		x = x + xdif * xdifvec;
		y = y + ydif * ydifvec;
		if (random() < .005 || x < 0 || x > width) {
			xdifvec = -xdifvec;
			rotvec = -rotvec;
		}
		if (random() < .005 || y < 0 || y > height) {
			ydifvec = -ydifvec;
			rotvec = -rotvec;
		}
		xdif = noise(xdifnoise) * 20;
		ydif = noise(ydifnoise) * 20;
		rad += noise(radnoise) * 10 - 5;
		if (rad < 100) { rad = 100; }
		if (rad > 500) { rad = 500; }
		rot += rotvec * noise(rotnoise) * 12 + 1;
		col += noise(colnoise) * 2 - 1;
		if (col < 0) { col = 360; }
		if (col > 360) { col = 0; }
		xdifnoise += 0.02;
		ydifnoise += 0.02;
		radnoise += 0.01;
		rotnoise += 0.05;
		colnoise += 0.01;

		lines.push({
			tx: x,
			ty: y,
			trot: rot,
			tcol: col,
			trad: rad
		});
		if (lines.length > 50) {
			lines.shift();
		}
	} else {
		lines.shift();
		if (lines.length < 2) {
			isPush = !isPush;
		}
	}
}

で、何となくイイカンジになったので、複数個の玉すだれを同時に動かせるように書きかえたのが次のスケッチ。

●南京玉すだれ Ver.1.5(クリックすると別タブが開きます)

玉すだれに関する変数と処理をオブジェクトとしてまとめて、複数個同時に動かせるように変更。見栄えもだいぶ派手に。

let linesArr = [];

function setup() {
    createCanvas(600, 600);
    colorMode(HSB);
    background(0, 0, 0, 1);
    strokeWeight(3);
    for (let i = 0; i < 6; i++) {
        linesArr[i] = new lineObj();
    }
}

function draw() {
    background(0, 100, 0, 1);
    linesArr.forEach(aline => {
        aline.drawMe();
        aline.updateMe();
    });
}

class lineObj {
    constructor() {
        this.lines = [];
        this.x = random(0, width);
        this.y = random(0, height);
        this.rot = random(0, 360);
        this.col = random(0, 360);
        this.rad = random(100, 300);
        this.xdifnoise = random(0, 1000);
        this.ydifnoise = random(0, 1000);
        this.radnoise = random(0, 1000);
        this.rotnoise = random(0, 1000);
        this.colnoise = random(0, 1000);
        this.xdif = noise(this.xdifnoise) * 4 + 1;
        this.xdifvec = 1;
        this.ydif = noise(this.ydifnoise) * 4 + 1;
        this.ydifvec = 1;
        this.rotvec = 1;
        this.isPush = true;
        this.lines.push({
            tx: this.x,
            ty: this.y,
            trot: this.rot,
            tcol: this.col,
            trad: this.rad
        });
    }

    drawMe() {
        this.lines.forEach(aline => {
            push();
            translate(aline.tx, aline.ty);
            rotate(radians(aline.trot));
            stroke(aline.tcol, 100, 100, .6);
            line(0, - aline.trad / 2, 0, aline.trad / 2);
            pop();
        }
        );
    }

    updateMe() {
        if (random() < .01 && this.isPush == true) {
            this.isPush = !this.isPush;
        }
        if (this.isPush) {
            this.x = this.x + this.xdif * this.xdifvec;
            this.y = this.y + this.ydif * this.ydifvec;
            if (random() < .005 || this.x < 0 || this.x > width) {
                this.xdifvec = -this.xdifvec;
                this.rotvec = -this.rotvec;
            }
            if (random() < .005 || this.y < 0 || this.y > height) {
                this.ydifvec = -this.ydifvec;
                this.rotvec = -this.rotvec;
            }
            this.xdif = noise(this.xdifnoise) * 10;
            this.ydif = noise(this.ydifnoise) * 10;
            this.rad += noise(this.radnoise) * 10 - 5;
            if (this.rad < 100) { this.rad = 100; }
            if (this.rad > 500) { this.rad = 500; }
            this.rot += this.rotvec * noise(this.rotnoise) * 5 + 1;
            this.col += noise(this.colnoise) * 2 - 1;
            if (this.col < 0) { this.col = 360; }
            if (this.col > 360) { this.col = 0; }
            this.xdifnoise += 0.02;
            this.ydifnoise += 0.02;
            this.radnoise += 0.01;
            this.rotnoise += 0.02;
            this.colnoise += 0.01;
            this.lines.push({
                tx: this.x,
                ty: this.y,
                trot: this.rot,
                tcol: this.col,
                trad: this.rad
            });
            if (this.lines.length > 50) {
                this.lines.shift();
            }
        } else {
            this.lines.shift();
            if (this.lines.length < 2) {
                this.isPush = !this.isPush;
            }
        }
    }
}

ここまでで、一応当初の目的は達成したような気もしたのですが、せっかくなのでもうちょっと見た目を面白くしてみようと手を入れたのが次以降のスケッチになります。

●南京玉すだれ Ver.2(クリックすると別タブが開きます)

以前、ちょっと試してみていた「blendMode」の「SCREEN」で線同士の重なり合いの感じを変更。線を若干太めにすると同時に、軌跡が伸びれば伸びるほど、後ろのほうがだんだんと暗くなっていく処理を追加してみました。もはやQIXではないのですが(笑)、これはこれでキレイな感じに。

let linesArr = [];

function setup() {
	createCanvas(600, 600);
	colorMode(HSB);
	strokeCap(PROJECT);
	background(0, 0, 0, 1);
	for (let i = 0; i < 4; i++) {
		linesArr[i] = new lineObj();
	}
}

function draw() {
	blendMode(BLEND);
	background(0, 100, 0, 1);
	blendMode(SCREEN);
	linesArr.forEach(aline => {
		aline.drawMe();
		aline.updateMe();
	});
}

class lineObj {
	constructor() {
		this.lines = [];
		this.x = random(0, width);
		this.y = random(0, height);
		this.rot = random(0, 360);
		this.col = random(0, 360);
		this.rad = random(100, 300);
		this.xdifnoise = random(0, 1000);
		this.ydifnoise = random(0, 1000);
		this.radnoise = random(0, 1000);
		this.rotnoise = random(0, 1000);
		this.colnoise = random(0, 1000);
		this.xdif = noise(this.xdifnoise) * 4 + 1;
		this.xdifvec = 1;
		this.ydif = noise(this.ydifnoise) * 4 + 1;
		this.ydifvec = 1;
		this.rotvec = 1;
		this.isPush = true;
		this.lines.push({
			tx: this.x,
			ty: this.y,
			trot: this.rot,
			tcol: this.col,
			trad: this.rad
		});
	}

	drawMe() {
		this.lines.forEach((aline, index) => {
			let lineAlpha = map(index, 0, this.lines.length - 1, 0.05, 1);
			push();
			translate(aline.tx, aline.ty);
			rotate(radians(aline.trot));
			stroke(aline.tcol, 100, 85, 1 * lineAlpha);
			strokeWeight(9);
			line(0, - aline.trad / 2, 0, aline.trad / 2);
			pop();
		}
		);
	}

	updateMe() {
		if (random() < .003 && this.isPush == true) {
			this.isPush = !this.isPush;
		}
		if (this.isPush) {
			this.x = this.x + this.xdif * this.xdifvec;
			this.y = this.y + this.ydif * this.ydifvec;
			if (random() < .007 || this.x < 0 || this.x > width) {
				this.xdifvec = -this.xdifvec;
				this.rotvec = -this.rotvec;
			}
			if (random() < .007 || this.y < 0 || this.y > height) {
				this.ydifvec = -this.ydifvec;
				this.rotvec = -this.rotvec;
			}
			this.xdif = noise(this.xdifnoise) * 10;
			this.ydif = noise(this.ydifnoise) * 10;
			this.rad += noise(this.radnoise) * 20 - 10;
			if (this.rad < 50) { this.rad = 50; }
			if (this.rad > 400) { this.rad = 400; }
			this.rot += this.rotvec * noise(this.rotnoise) * 8 + 1;
			this.col += noise(this.colnoise) * 6 - 3;
			if (this.col < 0) { this.col = 360; }
			if (this.col > 360) { this.col = 0; }
			this.xdifnoise += 0.02;
			this.ydifnoise += 0.02;
			this.radnoise += 0.01;
			this.rotnoise += 0.01;
			this.colnoise += 0.01;
			this.lines.push({
				tx: this.x,
				ty: this.y,
				trot: this.rot,
				tcol: this.col,
				trad: this.rad
			});
			if (this.lines.length > 100) {
				this.lines.shift();
			}
		} else {
			this.lines.shift();
			if (this.lines.length < 2) {
				this.isPush = !this.isPush;
			}
		}
	}
}

で、次のスケッチに派生します。

●南京玉すだれ Ver.3(クリックすると別タブが開きます)

blendModeの「SCREEN」が光の感じを出すのに使えそうということに気付いたので、線の処理をちょっと増やして「発光するネオン管」のような感じを目指してみました。グロー表現については、以前このブログで試していた「大きさや明るさを変えつつ、同じ座標に線を重ね描き」を使っています。この方法、スピードがシビアなスケッチでなければ、SCREENとの相性、とても良いように思います。

let linesArr = [];

function setup() {
	createCanvas(600, 600);
	colorMode(HSB);
	strokeCap(PROJECT);
	background(0, 0, 0, 1);
	for (let i = 0; i < 4; i++) {
		linesArr[i] = new lineObj();
	}
}

function draw() {
	blendMode(BLEND);
	background(0, 100, 0, 1);
	blendMode(SCREEN);
	linesArr.forEach(aline => {
		aline.drawMe();
		aline.updateMe();
	});
}

class lineObj {
	constructor() {
		this.lines = [];
		this.x = random(0, width);
		this.y = random(0, height);
		this.rot = random(0, 360);
		this.col = random(0, 360);
		this.rad = random(100, 300);
		this.xdifnoise = random(0, 1000);
		this.ydifnoise = random(0, 1000);
		this.radnoise = random(0, 1000);
		this.rotnoise = random(0, 1000);
		this.colnoise = random(0, 1000);
		this.xdif = noise(this.xdifnoise) * 4 + 1;
		this.xdifvec = 1;
		this.ydif = noise(this.ydifnoise) * 4 + 1;
		this.ydifvec = 1;
		this.rotvec = 1;
		this.isPush = true;
		this.lines.push({
			tx: this.x,
			ty: this.y,
			trot: this.rot,
			tcol: this.col,
			trad: this.rad
		});
	}

	drawMe() {
		this.lines.forEach((aline, index) => {
			let lineAlpha = map(index, 0, this.lines.length - 1, 0.1, 1);
			push();
			translate(aline.tx, aline.ty);
			rotate(radians(aline.trot));
			stroke(aline.tcol, 100, 100, .25 * lineAlpha);
			strokeWeight(21);
			line(0, - aline.trad / 2, 0, aline.trad / 2);
			stroke(aline.tcol, 100, 100, .5 * lineAlpha);
			strokeWeight(13);
			line(0, - aline.trad / 2, 0, aline.trad / 2);
			stroke(aline.tcol, 3, 100, 1 * lineAlpha);
			strokeWeight(5);
			line(0, - aline.trad / 2, 0, aline.trad / 2);
			pop();
		}
		);
	}

	updateMe() {
		if (random() < .003 && this.isPush == true) {
			this.isPush = !this.isPush;
		}
		if (this.isPush) {
			this.x = this.x + this.xdif * this.xdifvec;
			this.y = this.y + this.ydif * this.ydifvec;
			if (random() < .02 || this.x < 0 || this.x > width) {
				this.xdifvec = -this.xdifvec;
				this.rotvec = -this.rotvec;
			}
			if (random() < .02 || this.y < 0 || this.y > height) {
				this.ydifvec = -this.ydifvec;
				this.rotvec = -this.rotvec;
			}
			this.xdif = noise(this.xdifnoise) * 10;
			this.ydif = noise(this.ydifnoise) * 10;
			this.rad += noise(this.radnoise) * 20 - 10;
			if (this.rad < 50) { this.rad = 50; }
			if (this.rad > 400) { this.rad = 400; }
			this.rot += this.rotvec * noise(this.rotnoise) * 8 + 1;
			this.col += noise(this.colnoise) * 6 - 3;
			if (this.col < 0) { this.col = 360; }
			if (this.col > 360) { this.col = 0; }
			this.xdifnoise += 0.02;
			this.ydifnoise += 0.02;
			this.radnoise += 0.01;
			this.rotnoise += 0.01;
			this.colnoise += 0.01;
			this.lines.push({
				tx: this.x,
				ty: this.y,
				trot: this.rot,
				tcol: this.col,
				trad: this.rad
			});
			if (this.lines.length > 100) {
				this.lines.shift();
			}
		} else {
			this.lines.shift();
			if (this.lines.length < 2) {
				this.isPush = !this.isPush;
			}
		}
	}
}

…と、まぁ、ここまで作って何となく満足したのでこのくらいで。今回、とにかくシンプルに試作してしまって、それを調整しつつ、手法を加えながら形を整えるという作り方をしてみたのですが、手を動かしている最中に、うまくいかない部分の解決方法や、表現のアイデアが次々浮かんできたのが面白かったです。

(2018/7/3追記:「南京玉すだれ Ver.3」を上下左右対称に表示するスケッチを追加でOpenProcessingに上げてみました。思いつきでやってみたところ、思いのほかサイバーwな感じになって面白かったので、よかったら見てください)