年の瀬にp5.jsのWebGLモードを復習してみた


2017年12月26日
With
年の瀬にp5.jsのWebGLモードを復習してみた はコメントを受け付けていません。

9月の終わりごろに、p5.js(0.5.14)のWebGLモードを使ったスケッチを描いたのですが、

・p5.jsで3次元アニメーションを試してみた

そのすぐ後の10月12日にp5.jsの「0.5.16」がリリースされ、リリースノートにもあるとおり、WebGLまわりの機能がメジャーアップデートされました。上のスケッチも最新版では動かなくなってしまったので、リファレンスを流し読みしつつ、理屈は分からないながらも動く形に書き直したものを、とりあえず再アップしていました。

・p5.jsのWebGLまわりがアップデートしていた

「WebGLモードについては、そのうちちゃんと勉強しよう」と思いながらほったらかしていたのですが、このあいだ「メンガーのスポンジ」を描いた時に、ようやくある程度、基本的な使い方を把握したので、今回はそれを含むスケッチを2点ほど描きます。

●p5.js WebGLモードテスト1 - 回る弊社3Dロゴ(クリックすると別タブが開きます)

開くとクルクルと横回転する弊社の3Dロゴが出てきます。canvasの下に2本のスライダーとチェックボックスがありますが、それぞれの機能は次のとおりです。

・上のスライダー:カメラ位置の変更(ロゴの座標を中心とするyz軸平面上の円周を移動)
・下のスライダー:カメラ位置の変更(z軸方向にロゴとの距離が変化)
・チェックボックス:チェックするとロゴの回転がストップ

コードはこんな感じ。(要p5.js、p5.dom.js)

var logo;
var cameraPos = 180;
var cameraDistance = 230;
var camaraRotateSlider;
var cameraDistanceSlider;
var rotateCheck;
var rotateFlag = false;
var rotateCount = 0;
var yVec = -1;

function setup() {
	createCanvas(600, 600, WEBGL);
	logo = loadModel('./assets/infosmith-logo.obj', true);
	ambientLight(100);
	directionalLight(120, 120, 120, -1, -1, 1);
	ambientMaterial(220, 220, 255, 255);
	noStroke();
	setSlider();
}

function draw() {
	background(20);
	if (cameraPos >= 270 || cameraPos <= 90) { yVec = 1; } else { yVec = -1; }
	camera(0, sin(radians(cameraPos)) * cameraDistance, cos(radians(cameraPos)) * cameraDistance, 0, 0, 0, 0, yVec, 0);
	if (!rotateFlag) { rotateCount++; }
	rotateY(rotateCount * 0.02);
	model(logo);
	sliderValue();
}

function setSlider() {
	cameraRotateSlider = createSlider(0, 360, cameraPos);
	cameraRotateSlider.position(5, height + 10);
	cameraRotateSlider.size(600);
	cameraDistanceSlider = createSlider(50, 3000, cameraDistance);
	cameraDistanceSlider.position(5, height + 40);
	cameraDistanceSlider.size(600);
	rotateCheck = createCheckbox('stop rotation', false);
	rotateCheck.position(5, height + 70);
	rotateCheck.changed(checkEvent);
}

function sliderValue() {
	cameraPos = cameraRotateSlider.value();
	cameraDistance = cameraDistanceSlider.value();
}

function checkEvent() {
	rotateFlag = !rotateFlag;
}

前に描いたスケッチがアップデートで動かなくなったのは、どうやら「camera()」の引数指定方法が変わったことが原因のようでした。0.5.16のcamera()は、リファレンスを見ると以下のような書き方になっています。

camera([x],[y],[z],[centerX],[centerY],[centerZ],[upX],[upY],[upZ]);

これ、OpenGLの「gluLookAt()」という視点位置の設定用関数と同じ書き方らしく、より標準的なスタイルになった…ということなのでしょうね。

「x, y, z」で「視点の位置」、「centerX, centerY, centerZ」で「スケッチの中心にくる座標」(注視点)、「upX, upY, upZ」で「カメラの上になる方向(ベクトル)」を指定します。引数を入れずに単に「camera();」と書くと、デフォルトで「camera(0, 0, (height/2.0) / tan(PI*30.0 / 180.0), 0, 0, 0, 0, 1, 0);」が指定されたことになるようなのですが、視点座標を明示的に指定した場合は、ほかの引数についても省略不可になるようです(たぶん、それが前のスケッチがエラーになった理由)。

このスケッチでは、ざっくりと

1.ロゴの3Dデータをobjファイルとして読み込み
2.ambientLight(環境光)、directionalLight(方向のある光)の設定
3.スライダーを使って動的にカメラ位置を変更

といったようなことをやっています。それぞれに作法のようなものがあって、結構試行錯誤したので、気付いたことをざっと箇条書きにしておきます。

・objファイルとして読み込む3Dモデルは、面分割があまり複雑で細かいものだと読み込み時にエラーが出るようです。今回、3Dツールの「Shade」で吐き出したobjファイルを読ませたのですが、最初にエクスポートしたデータは読み込めず、Shade上でいろいろと手を入れて何とか読み込める形にしました。今回のケースで鬼門になったのはアルファベット部分で、EPSからの立体化では読み込みができず、Shade標準のテキスト立体化ツールを使って作り直しています。あと、吐き出し時には「法線情報」の付加を忘れずに。

・光源は「ambientLight」「directionalLight」「pointLight」の組み合わせで指定。いずれも指定しないとスケッチ内に光がなくなるため、オブジェクトが真っ黒に表示されます(背景色が黒以外なら存在は確認可能)。「ambientLight」は環境光、「directionalLight」は方向がある光、「pointLight」は点光源なので、まずは適当に「ambientLight」を設定してスケッチを作りはじめ、最終的な見栄えを整える段階で光量を調節しつつ「directionalLight」あるいは「pointLight」を加えて陰影を作るという進め方がよさそう。

・strokeを入れると、モデルのフチに線が出ます。ただ、パフォーマンスが大きく落ちるようなので、表現上必要でなければ「noStroke()」指定を推奨。

・WebGLモードでの「fill」の指定は、いまいち不可解な挙動をします。「noFill()」指定をすると「ambientMaterial」などでマテリアルを指定していても、オブジェクトが見えなくなってしまうようです。かといって「fill(255)」のように、塗りつぶし色を指定すると、オブジェクトが立体感のないのっぺりした塗りつぶしで表示されます。3D環境でのスケッチを2D的に見せたい場合には有効そうですが、使いどころが難しいですね。いわゆる「3DCG」的な絵を作るのであれば「WebGLモードでfill()、noFill()を書かない」というのが、ひとまず無難そうです。

追記:次のエントリで、WebGLモード時のfill()の挙動について、もうちょっとだけ調べました。2D図形を使った3Dスケッチや、開発時にfill()を使うのも良さそうです。

・上のスライダーでは、ロゴを中心にして、カメラ位置を縦方向の円周上で動かせます。gluLookAt形式では「カメラの上方向」をベクトルで指定しますが、カメラのz座標がロゴのz座標よりも奥に移動した場合に、この指定をそのままにしていると瞬時に上下が反転する、見ていて違和感のある挙動になります。そのため、23行目に

if (cameraPos >= 270 || cameraPos <= 90) { yVec = 1; } else { yVec = -1; }

という処理を入れて、カメラのz座標が特定の範囲(z>0)にある場合にカメラの上下を反転させています。

……と、まぁ、とても胸を張って「マスターした!」とは言えない理解度なのですが、ひとまずこのあたりまでの理解をもとに、もうひとつ別のスケッチを作りました。

●p5.js WebGLモードテスト2 - エセ・プラネタリウム(クリックすると別タブが開きます)

中心部にカメラを置いた、1600×1600×1600の3次元空間上にランダムに5500個の球(sphere)をばらまき、カメラ位置は中央のままで、注視点をゆっくりと動かしています。setup内にある「perspective()」は、カメラの「視野角」「アスペクト比」「視野に入る距離」を指定できる関数です。このスケッチでは、視野角と距離を標準からちょっと変えて、宇宙空間ぽい感じ(?)を強調しています。

また「ambientLight」「pointLight」の指定で、浮いているのが球であることが分かりやすくなるように影の付け方を調整してみました。球だけでなく、いろんな形のオブジェクトを混ぜても面白そうです。

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

コードはES2015準拠で書いてみました。

let stars = [];
let limStars = 5500;
let rotCamPos;


function setup() {
	createCanvas(600, 600, WEBGL);
	ambientLight(120, 120, 170);
	pointLight(200, 200, 250, -400, -400, 400);
	ambientMaterial(255, 255, 240, 255);
	perspective(radians(75), width / height, 1, 1200);
	rotCamPos = new PointObj(0, 0, 10);
	noStroke();
	initStars();
}

function draw() {
	background(0, 0, 25);
	camera(0, 0, 0, rotCamPos.x, rotCamPos.y, rotCamPos.z, 0, -1, 0);
	cameraRotate();
	drawSphere();
}

function initStars() {
	for (let i = 0; i < limStars; i++) {
		let x, y, z;
		x = random(-800, 800);
		y = random(-800, 800);
		z = random(-800, 800);
		stars[i] = new PointObj(x, y, z);
	}
}

function drawSphere() {
	for (let i = 0; i < limStars; i++) {
		push();
		translate(stars[i].x, stars[i].y, stars[i].z);
		sphere(3);
		pop();
	}
}

function cameraRotate() {
	rotCamPos.x = Math.cos(frameCount / 300);
	rotCamPos.y = Math.abs(Math.tan(frameCount / 500));
	rotCamPos.z = Math.sin(frameCount / 300);
}

class PointObj {
	constructor(ex, why, zi) {
		this.x = ex;
		this.y = why;
		this.z = zi;
	}
}

せっかく使い方が分かってきたので、また何か思いついたら3Dでスケッチしてみたいと思います。