(2018/01/25追記:こちらのエントリで紹介しているコードは、p5.js 0.5.16向けのものになります。0.6.0以降では正しく動作しません)
Shiffman先生のYouTubeチャンネル「The Coding Train」で、ここしばらくp5.jsのWebGLモードを扱っていたのですが、その中にこんな回が。
これ、ジオメトリックなループGIFをTwitterで公開されている方が、Shiffman先生に「このGIFと同じヤツをp5.jsで作ってみて」とリクエストし、それに応えるというものでした。
.@shiffman attempting to recreate my gif in @p5xjs live! https://t.co/tpySsB4Ozi pic.twitter.com/yDQPXR0vlp
— dave 🐝💣 (@beesandbombs) 2017年12月12日
で、このGIFアニメを見て「あ、これくらいだったら、今の俺でも作れるんじゃね?」と思い、Shiffman先生の模範解答を見るのは後回しにして、1時間くらいかけて作ったスケッチがこちら。
●自分で最初に作ってみた「Cube Wave」(クリックすると別タブが開きます)
んー。おしい。なにかが違う…。あー、波の同じ高さのところをつないだ形が、お手本では丸っぽくなっているのに、僕のでは四角っぽくなってるんだ。どうすれば丸くなるんだろ。
ちなみにコードはこちら。
const boxSize = 35; const boxHeight = 180; const boxMargin = 2; const clusterSize = 15; const clusterWH = clusterSize * boxSize + (boxMargin * (clusterSize - 1)); const sinDiff = 360 / clusterSize; const boxArr = Array.from(new Array(clusterSize), () => new Array(clusterSize).fill(null)); function setup() { createCanvas(600, 600, WEBGL); ortho(-430, 430, 430, -430, -1000, 1000); ambientLight(180); pointLight(90, 90, 150, 255, 300, 300, 300); directionalLight(255, 255, 255, 255, 1, 1, 0); ambientMaterial(100, 100, 255, 255); noStroke(); boxInit(); } function draw() { background(255); orbitControl(); rotateX(radians(-52)); rotateZ(radians(37)); for (let i = 0; i < clusterSize; i++) { for (let j = 0; j < clusterSize; j++) { boxArr[i][j].drawMe(); boxArr[i][j].updateMe(i, j); } } } function boxInit() { for (let i = 0; i < clusterSize; i++) { for (let j = 0; j < clusterSize; j++) { boxArr[i][j] = new BoxObj(i, j); } } } class BoxObj { constructor(ex, why) { this.height = boxHeight; this.size = boxSize; this.x = -clusterWH / 2 + ex * (boxSize + boxMargin); this.y = -clusterWH / 2 + why * (boxSize + boxMargin); } drawMe() { push(); translate(this.x, this.y, 0); box(this.size, this.size, this.height); pop(); } updateMe(ai, jey) { const centDiff = sinDiff * (abs(clusterSize / 2 - ai) + abs(clusterSize / 2 - jey)); this.height = (sin(radians(-frameCount * 3 - centDiff)) + 1.3) * boxHeight; } }
ここで、先ほどのビデオでShiffman先生の模範解答を見ます。それがこちら(見ばえなどは若干いじっています)。
●Shiffman先生の「Cube Wave」(クリックすると別タブが開きます)
let angle = 0; let w = 46; let ma; let maxD; function setup() { createCanvas(600, 600, WEBGL); ma = atan(.7); maxD = dist(0, 0, 200, 200); noStroke(); } function draw() { orbitControl(); background(255); ortho(-500, 500, -500, 500, 0, 1500); ambientLight(220); translate(0, 0, 0); rotateX(radians(-32)); rotateY(ma); let offset; for (let z = 0; z < height; z += w) { for (let x = 0; x < width; x += w) { push(); let d = dist(x, z, width / 2, height / 2); offset = map(d, 0, maxD, -PI, HALF_PI); let a = angle + offset; let h = floor(map(sin(a), -1, 1, 60, 550)); translate(x - width / 2, 0, z - height / 2); fill(255); directionalLight(255, 255, 255, 255, 0, -1, 0); pointLight(90, 90, 150, 255, 300, 300, 300); ambientMaterial(100, 100, 255, 255); box(w - 2, h, w - 2); pop(); } offset += 0.01; } angle += 0.05; }
ああ、なるほど。キューブを構成している四角柱の高さを「中央からの離れ具合」でずらすという考え方までは合っていたのですが、僕は2次元配列の添字でそれを何とか算出しようとしていたのでした。そりゃ、四角くなるわ(笑)。Shiffman先生の回答は「原点から四角柱を描く座標までの距離」をズレの大きさに変換するというもの。たしかにこうすれば、同じ高さの四角柱の並びは円形になります。
あと、僕は四角柱をそれぞれオブジェクトにしたのですが、先生はオブジェクトを使わないで書いていますね。このくらいのアニメーションであれば、オブジェクトを使わない方が行数も短く、パフォーマンスも高くなります。実際、僕のスケッチのほうが動作が重いです。
理屈が分かれば修正は簡単。模範解答を見てから書き直したスケッチがこちらになります。
●模範解答を見て書き直した「Cube Wave」(クリックすると別タブが開きます。重めなのでPCでの実行推奨)
まだ若干違うところがありますが、だいぶ当初のお手本に近いものになりました(たぶん、お手本では単純なサイン波にもうひとつ何かの変動を加えて、波の勾配を調整していそう)。四角柱のサイズや本数、長さの最大値、表示場所、サイン波の周期、カメラの種類などを変えると、いろんなパターンが作れて面白いです。たとえばこんなのとか。
const boxSize = 35; const boxHeight = 180; const boxMargin = 0; const clusterSize = 15; const clusterWH = clusterSize * boxSize + (boxMargin * (clusterSize - 1)); const sinDiff = 450 / clusterSize; const boxArr = Array.from(new Array(clusterSize), () => new Array(clusterSize).fill(null)); function setup() { createCanvas(600, 600, WEBGL); noStroke(); ortho(-540, 540, 540, -540, -800, 800); ambientLight(180); pointLight(90, 90, 150, 255, 300, 300, 300); directionalLight(255, 255, 255, 255, 1, 1, 0); ambientMaterial(100, 100, 255, 255); boxInit(); } function draw() { background(255); orbitControl(); rotateX(radians(-45)); rotateZ(radians(45)); for (let i = 0; i < clusterSize; i++) { for (let j = 0; j < clusterSize; j++) { boxArr[i][j].drawMe(); boxArr[i][j].updateMe(); } } } function boxInit() { for (let i = 0; i < clusterSize; i++) { for (let j = 0; j < clusterSize; j++) { boxArr[i][j] = new BoxObj(i, j); } } } class BoxObj { constructor(ex, why) { this.height = boxHeight; this.size = boxSize; this.x = -clusterWH / 2 + ex * (boxSize + boxMargin); this.y = -clusterWH / 2 + why * (boxSize + boxMargin); const distToCenter = dist(0, 0, this.x, this.y); this.boxDist = map(distToCenter, 0, clusterWH / 2, 0, clusterSize / 2); } drawMe() { push(); translate(this.x, this.y, 0); box(this.size, this.size, this.height); pop(); } updateMe() { this.height = (sin(radians(frameCount * 3 - sinDiff * this.boxDist)) + 1.3) * boxHeight; } }
同じような表現を作るのにも、いろんな書き方ができ、書き方によってパフォーマンスも変わってくる。求められる要件に応じて書き方を変えられるようなスキルを身につけたいという思いを、今回の試みで新たに致しました。
そしてWebGLモードのバグにいろいろ気付く
で、今回、Shiffman先生の模範解答に合わせてコードを書くなかで「orthoカメラを使うとstrokeでの線表示がおかしくなる」というバグに気付きました。
一般的なWebGLモードのカメラ(camera)が、奥行きを考慮した「透視法」で図形を表示するのに対し、「ortho()」では「投影法」による表示を行います。Shiffman先生は、スケッチを作っていく過程で普通にこのortho()を使いながら表示を確認していたのですが、同じように書いていったところ、僕の環境では突如、画面がこんな感じで真っ黒になってしまいました。
いろいろ試して「noStroke()」を入れれば、表示が正しくなることに気付き、その状態で作業を続けたのですが、どうやらこれ、現在僕が使っている最新版(0.5.16)でのバグのようでした。ちなみにちょっと前の「0.5.14」では、天地が反転するものの上の画像のような状態にはならず表示ができます。Shiffman先生は前のバージョンのライブラリを使っているのかもしれません。
WebGLモードに関しては、ここ1年ほどのバージョンアップで「以前はきちんと動いていた機能が正しく動かなくなった」というようなケースが散見されるようで、Shiffman先生もつい最近「複数のpointLightを指定した場合、最後に指定したものしか反映されない」というバグをGitHubのissuesに報告するというビデオを作っておられます。
ちょっと検索してみたら、先ほどの「orthoでstrokeがおかしくなる問題」についても、いくつかissueが上がっているもよう。時間ができたらきちんと調べてみようと思います。
これまで、僕が頭を悩ませた問題の中にも、もしかするとp5.js側のバグが原因で、バージョンが上がれば直るものが含まれているのかもしれませんね。ひとまず、WebGLモードでどうにも腑に落ちない挙動に出くわした場合は、以前のバージョンで動作するかを確認したり、issuesを検索したりしてみると何らかの情報が得られるかもしれません。
今さらながらp5.jsのWebGLモードが結構バギーなことに気付く はコメントを受け付けていません