はじめに
ダイスをころがせ!
p5の行列は使いづらいので、自分のライブラリを使わせてもらう。今回は行列しか使わないです。
やりたいのはサイコロを転がすこと。以前作ったんですが、
バージョン古いし、オビコン使ってないし、一番不満なのはコードが汚いことですね。あと回転させるたびに位相をリセットしているので6面すべて異なる場合にサイコロが転がっているように見えないという致命的な欠点があります。そういうわけで作り直そうと思いました。行列を使ったら割と簡単に実装出来たので、それを今回は紹介します。
フォーチュンクエストの同名のゲームは特に関係ないです(知ってる人がいない)。
実行結果:
コード全文
// というわけで
// z軸正方向から見た場合に
// y軸を下として
// x軸を右とする。
// 初期化
// 進む方向を決めて、modelとbaseで相殺して見た目を変えない
// localを回転させてセットして変形
// 終わったらそのときのlocalをmodelに吸収させてふたたびlocalはinitされて
// baseとmodelで相殺して再びmodelが最初の形になるように以下略
let font;
let loopFunction = () => {};
function preload() {
font = loadFont(
'https://inaridarkfox4231.github.io/assets/fonts/LuxuriousScript-Regular.ttf'
);
}
const {MT4} = fox3Dtools;
const duration = 16;
const DICE_SIZE = 32;
let properFrameCount = 0;
let direction = 0; // 0,1,2,3 かける HALF_PI
let posX = 0;
let posY = 0;
function setup() {
createCanvas(window.innerWidth, window.innerHeight, WEBGL);
createCubemapShader();
const gl = this._renderer.GL;
const cubemapSources = getCubemapSources();
registCubemap(gl, 3, cubemapSources);
// 一応0に戻しておく
gl.activeTexture(gl.TEXTURE0);
shader(myShader);
const pg = myShader._glProgram;
gl.useProgram(pg);
const loc = gl.getUniformLocation(pg, "uCubemap");
gl.uniform1i(loc, 3);
gl.useProgram(null);
camera(400, -400, 400, 0, 0, 0, 0, 1, 0);
perspective(PI/3, width/height, 4*sqrt(3),4000*sqrt(3));
const baseMatrix = new MT4();
const localMatrix = new MT4();
const modelMatrix = new MT4();
// global = base * local * model.
const globalMatrix = new MT4();
// 初期設定
modelMatrix.setTranslation(0,-DICE_SIZE,0);
loopFunction = () => {
orbitControl();
background(0);
noStroke(); // これが無いとstrokeのshaderで上書きされてしまう
shader(myShader);
matrixUpdate(baseMatrix, localMatrix, modelMatrix);
globalMatrix.set(baseMatrix).multM(localMatrix).multM(modelMatrix).transpose();
applyMatrix(...globalMatrix.array());
box(DICE_SIZE*2);
resetMatrix();
resetShader();
push();
translate(0,1,0); // 微妙にずらすことでz-fightを防ぐ処理
rotateX(PI/2);
fill("teal");
plane(640-DICE_SIZE*2);
pop();
// 軸チェック
//stroke("red");line(0,0,0,300,0,0);
//stroke("lime");line(0,0,0,0,300,0);
//stroke("blue");line(0,0,0,0,0,300);
}
}
function draw() {
loopFunction();
}
function matrixUpdate(b, l, m){
if(properFrameCount === 0){
// 方向を決める
direction = Math.floor(Math.random()*10000)%4;
// 外に出るのを防ぐ処理。外に出るようなら逆方向にする。
const nextPosX = posX + DICE_SIZE*2*cos(direction*HALF_PI);
const nextPosY = posY + DICE_SIZE*2*sin(direction*HALF_PI);
if((nextPosX > 320-DICE_SIZE && direction === 0) || (nextPosX < -320+DICE_SIZE && direction === 2)){
direction = (direction + 2) % 4; console.log("x-back");
}else if((nextPosY > 320-DICE_SIZE && direction === 1) || (nextPosY < -320+DICE_SIZE && direction === 3)){
direction = (direction + 2) % 4; console.log("y-back");
}
b.localRotation(0,1,0,direction*HALF_PI).localTranslation(DICE_SIZE,0,0);
m.globalRotation(0,1,0,-direction*HALF_PI).globalTranslation(-DICE_SIZE,0,0);
}
// 恒常update
l.setRotation(0,0,1,HALF_PI*properFrameCount/duration);
properFrameCount++;
if(properFrameCount === duration){
// 更新処理
l.init();
m.globalRotation(0,0,1,HALF_PI);
b.localTranslation(DICE_SIZE,0,0).localRotation(0,1,0,-direction*HALF_PI);
m.globalTranslation(-DICE_SIZE,0,0).globalRotation(0,1,0,direction*HALF_PI);
properFrameCount = 0;
// グリッド上の位置を更新することで外に出るのを防ぐ
posX += DICE_SIZE*2*cos(direction*HALF_PI);
posY += DICE_SIZE*2*sin(direction*HALF_PI);
}
}
// 以下、cubemap関連。
function createCubemapShader(){
const sh = baseMaterialShader();
myShader = sh.modify({
vertexDeclarations: `OUT vec3 vLocalPosition;`,
fragmentDeclarations: `
IN vec3 vLocalPosition;
uniform samplerCube uCubemap;
`,
'void afterVertex':`(){vLocalPosition = aPosition;}`,
'vec4 getFinalColor':`(vec4 color){
vec3 v = vLocalPosition;
v.y *= -1.0;
return texture(uCubemap, v);
}`
});
}
function getCubemapSources(){
const grs = [];
const texts = ["1","6","2","5","3","4"];
for(let i=0; i<6; i++){
const gr = createGraphics(512, 512);
gr.textFont(font);
gr.textAlign(CENTER,CENTER).textSize(784);
const r = Math.random();
gr.background(64 + r*192, 128 + r*128, 255);
gr.fill(0);gr.noStroke();
gr.text(texts[i],220,100);
grs.push(gr.elt);
}
return grs;
}
function registCubemap(gl, index, srcArray){
const tex = gl.createTexture();
// 3番に入れる
gl.activeTexture(gl.TEXTURE0 + index);
gl.bindTexture(gl.TEXTURE_CUBE_MAP, tex);
const cubeTargets = [
gl.TEXTURE_CUBE_MAP_POSITIVE_X, gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
gl.TEXTURE_CUBE_MAP_POSITIVE_Y, gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
gl.TEXTURE_CUBE_MAP_POSITIVE_Z, gl.TEXTURE_CUBE_MAP_NEGATIVE_Z
];
for(let i=0; i<6; i++){
const src = srcArray[i];
gl.texImage2D(
cubeTargets[i], 0, gl.RGBA, src.width, src.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, src
);
}
gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
}
割と余裕があったので装飾多めですが、本質的な内容は100行程度に抑えました。
フォントについて
1,2,3,4,5,6の数字はフリーフォントを使いました。
なんかおしゃれにしたかったので。特に意味はないです。
キューブマップについて
前回説明したので割愛...あと見栄えのためにmipmapを使っています。今回スロットは3しか使いません。
そういうわけで、事前にプログラムを走らせてシェーダーにセットしておいてあります。
shader(myShader);
const pg = myShader._glProgram;
gl.useProgram(pg);
const loc = gl.getUniformLocation(pg, "uCubemap");
gl.uniform1i(loc, 3);
gl.useProgram(null);
これはループ外の処理です。なお、 createShader()はプログラムを作らない というwebGLに詳しい人ほど引っかかりやすい罠があるので、shader()を実行することでプログラムを作っています。この関数も見た目通りの挙動ではなく 実はプログラムを走らせない ので、そのうえでプログラムを走らせてユニフォームをセットしています。ややこしいなぁ...
カメラの設定
p5はy軸が逆向きなので、理想的なカメラの配置とかもろもろがちょっとめんどくさいです。最終的にカメラはこうなりました。
camera(400, -400, 400, 0, 0, 0, 0, 1, 0);
perspective(PI/3, width/height, 4*sqrt(3),4000*sqrt(3));
加えてキューブマップ用のシェーダーでは採取に使うローカルポジションの$y$軸方向を逆にしています。
vec3 v = vLocalPosition;
v.y *= -1.0;
return texture(uCubemap, v);
これで、$x$軸が右、$y$軸が下、$z$軸が手前という座標系になります。なぜわかるかというと、具体的に線を引いて確かめたからです。
// 軸チェック
stroke("red");line(0,0,0,300,0,0);
stroke("lime");line(0,0,0,0,300,0);
stroke("blue");line(0,0,0,0,0,300);
もちろん確認したらあとはもう不要です。こういうときlineとstrokeは便利だなって思いますね(もっとも座標系が自然なら確認は不要ですが...)。
行列の取り扱い
サイコロの描画方法について触れます。まず、行列を3つ用意します。ひとつは描画の際のベースとなる行列、次にそこにローカル変換を重ねて実行するための行列、最後にモデル(この場合はサイコロ)を描画するための行列です。それぞれbaseMatrix,localMatrix,modelMatrixと名付けました。これらをmodel,local,baseの順に適用することでglobalMatrixが計算され、applyMatrixで適用されてサイコロが描画されます。
const baseMatrix = new MT4();
const localMatrix = new MT4();
const modelMatrix = new MT4();
// global = base * local * model.
const globalMatrix = new MT4();
/* 中略 */
/* 恒常ループ内 */
matrixUpdate(baseMatrix, localMatrix, modelMatrix);
globalMatrix.set(baseMatrix).multM(localMatrix).multM(modelMatrix).transpose();
applyMatrix(...globalMatrix.array());
列ベクトルへの適用を想定した通常の行列の掛け算であるゆえ、こういった順序にはなります。で、行列ができるんですが、p5と流儀が逆なので、transposeで転置して適用しています。
このうち初期設定が必要なのはmodelMatrixだけです。サイコロは画面で上方向にサイズの半分だけ浮いているので、それを表現する必要があります。DICE_SIZEは辺の長さの半分です。
// 初期設定
modelMatrix.setTranslation(0,-DICE_SIZE,0);
「上」とは$y$軸下方なので、マイナスです。
賽子の回転について
♪なんどで~~もっ
まず、ざっくりいうとこんな感じですね。
デフォルトでbaseはもともとの座標系を維持しています。これを保ちつつ、回転部分を担うのはlocalです。localをいじるだけで回転が実現するように、適切にbaseとmodelを調整します。回転している間、動くのはlocalだけです。回転が終わったらmodelにlocalの回転終了の情報を吸収させ、最後にbaseを元の向きになるように然るべく調節して終わりですね。それをカウントの増加で制御します。duration(間隔)でプログレスなどを計算して実装します。
function matrixUpdate(b, l, m){
if(properFrameCount === 0){
// 方向を決める
direction = Math.floor(Math.random()*10000)%4;
// 外に出るのを防ぐ処理。外に出るようなら逆方向にする。
const nextPosX = posX + DICE_SIZE*2*cos(direction*HALF_PI);
const nextPosY = posY + DICE_SIZE*2*sin(direction*HALF_PI);
if((nextPosX > 320-DICE_SIZE && direction === 0) || (nextPosX < -320+DICE_SIZE && direction === 2)){
direction = (direction + 2) % 4; console.log("x-back");
}else if((nextPosY > 320-DICE_SIZE && direction === 1) || (nextPosY < -320+DICE_SIZE && direction === 3)){
direction = (direction + 2) % 4; console.log("y-back");
}
b.localRotation(0,1,0,direction*HALF_PI).localTranslation(DICE_SIZE,0,0);
m.globalRotation(0,1,0,-direction*HALF_PI).globalTranslation(-DICE_SIZE,0,0);
}
// 恒常update
l.setRotation(0,0,1,HALF_PI*properFrameCount/duration);
properFrameCount++;
if(properFrameCount === duration){
// 更新処理
l.init();
m.globalRotation(0,0,1,HALF_PI);
b.localTranslation(DICE_SIZE,0,0).localRotation(0,1,0,-direction*HALF_PI);
m.globalTranslation(-DICE_SIZE,0,0).globalRotation(0,1,0,direction*HALF_PI);
properFrameCount = 0;
// グリッド上の位置を更新することで外に出るのを防ぐ
posX += DICE_SIZE*2*cos(direction*HALF_PI);
posY += DICE_SIZE*2*sin(direction*HALF_PI);
}
}
まずカウントが0のときに初期設定をします。baseをぐるっと進みたい方向に$x$軸の正方向が来るように回転し、さらに$z$軸の周りの回転でサイコロが回転するように全体を平行移動します。それとともに、描画結果が同じになるようにmodelの方をいじって相殺させます。localとglobalはそれぞれ右乗算と左乗算です。ゆえにこのやり方で相殺します。逆行列でもいいんですが誤差がシャレにならないので一般的な状況でなければ使う必要はないでしょう。
次に回転中ですが、準備は済んでるのでlocalをプログレスに基づいていじってやるだけです。文字通り、ローカル回転ですね。$z$軸から見て$x$は右、$y$は下なので、回転の方向は正方向でオッケーです。
回転が終わったので、baseを元の位相に戻します。まず最初にlocalの最後の回転、要するに90°の回転ですが、これをmodelに適用することで変化の吸収を実行します。あとはカウントが0の時とちょうど逆のことをして、最終的に位相が然るべき形に戻るようにlocalとglobalの回転を相殺するように適用します。できました。
装飾部分ですが、まずサイコロが外に出ないように中心位置の座標を回転が終わるごとに記録しています。それに基づいて、次の移動方向がはみ出しそうな場合に方向をいじっています。やったことはそのくらいですね。
際限なく転がり続けてどっか行っちゃったら悲しいので。
平面について
resetShader();
push();
translate(0,1,0); // 微妙にずらすことでz-fightを防ぐ処理
rotateX(PI/2);
fill("teal");
plane(640-DICE_SIZE*2);
pop();
下から見る必要はないんですが、浮かせないとz-fightingが気になるのでちょっとだけ浮かせています。こういうのを処理する方法が分かんないので。もっときれいな方法があればいいんですが。あと平面のサイズはサイコロの移動範囲に収まるように調整しています。
おわりに
「チ。」がもうすぐ最終回なのですごくわくわくしています。いろんな人がいろんな感想を残しているんですが自分はまだどういう感想を持ったらいいのかちょっとわかんないので傍観に徹しています。今何となく思うことは、ここでなんか、稚拙な文章でも、何かしら残したとして、遠い将来なんらかのきっかけでそれを読んだ人が、「このコード面白そうだから真似して遊んでみよう」とか思うことがあったら
すごくうれしいですね。
ここまでお読みいただいてありがとうございました。
追記
そういう風に考えたら記事書くのが怖くなくなったのでこれからもぼちぼち書いていきます。
15週連続だって。すごい。52週目指して頑張りたいですね。
制作意図
なんかつくるのに特に理由は無いんですが、今回これを作ったのは行列に関する理解が自分の中で進んだのでそれを確かめるのが目的ですね。クォータニオンは本質的には回転行列なので。補間がなければ行列で充分ですからね。右乗算がローカルで左乗算がグローバルというのを以前説明しましたが、あれも本来はトランスフォームの方でそうなってるのを反映してるだけですから。webGL-3Dは行列の理解ができていないと何にもできないですね。2Dは行列出てこないのでいいんですが、3Dは行列分かんないと色々と大変だと思います。今回ちょっとでもそこら辺の経験値が積めたならこのコードを書いた意義もあるというものですね。