北はこっち!

 やっとiOSで本当の北がどっちかを明らかにする時が来た。

 Androidの人はwebkitCompassHeadingがないので、このページはほぼ意味がないが、プログラムがどうやって巨大になっていくかを観察するために読むのもいいだろう。

 とりあえず最初に完成品。

 深度テストと半透明で面倒なことになっているのだが、あまりも大きくなりすぎたので今回はやらない。 次回、ステンシルテストと一緒にやる予定。 今回は無からどうやって(あるいは、どうして)こんなに大きくなったかをdiffで書いていくことにした。 本筋と関係ない部分は適当に隠してあるので、適宜拡げて読んでいただければ。 Androidの人はwebkitCompassHeadingがないのでこのページはほぼ意味がないが、プログラムがどうやって巨大になっていくかを観察するために読むのもいいだろう。

最初の状態からZファイティング解決前まで

@@ -0,0 +1,3 @@

+<!doctype html>

+<meta charset="utf-8">

+<title>webkit north</title>

 一番最初の状況。 これで有効なHTML5のはず。 とりあえずキャンバス作ってWebGL初期化しとこか。

@@ -1,3 +1,19 @@

<!doctype html>

<meta charset="utf-8">

+<meta name="viewport" content="initial-scale=1.0">

<title>webkit north</title>

+<link rel="stylesheet" href="gl-sample.css">

+<script src="vanilla.js"></script>

+<script src="vanilla-gl.js"></script>

+<script>

+"use strict";

+$DCL(_ => {

+ const gl = VanillaGL.create($QS("canvas"));

+ let render = VanillaFrameRenderer.create(_ => {

+ gl.initCanvas();

+ });

+ render.request();

+});

+</script>

+<body class="fullscreen">

+<canvas></canvas>

vanilla.jsvanilla-gl.jsgl-sample.cssも追加に。 モバイル用にviewport設定も入れてある。 VanillaFrameRendererをわざわざ使っているのはあとでデバイスオリエンテーションイベントから呼び出そうと思っているから。 負荷の調整もできるし。

 サクッと座標球を書く。

@@ -6,12 +6,38 @@

<script src="vanilla.js"></script>

<script src="vanilla-gl.js"></script>

+<script src="vanilla-mat2.js"></script>

+<script src="spherical-grid.js"></script>

<script>

"use strict";

+const Mat4 = VanillaMat4;

+Mat4.setToRadian(deg2rad);

+Mat4.aspectMode = "min";

$DCL(_ => {

const gl = VanillaGL.create($QS("canvas"));

- let render = VanillaFrameRenderer.create(_ => {

- gl.initCanvas();

+ const ready = prepare(

+ gl.fetchShader("vs", "thru.vs"),

+ gl.fetchShader("fs", "fixed.fs"),

+ );

+ const grid = prepareSphericalGrid(gl);

+ let matVP = Mat4.create();

+ let render;

+ ready.then((

+ vs, fs

+ ) => {

+ const prog = gl.linkProgram(vs, fs);

+ function drawOpaque() {

+ prog.use();

+ prog.setUniform("uMat:m4", matVP);

+ prog.setUniform("uColor", 1, 1, 1, 1);

+ drawSphericalGrid(prog, grid);

+ }

+ render = VanillaFrameRenderer.create(_ => {

+ gl.initCanvas();

+ matVP = Mat4.rotX(-75)

+ .trans(0, 0, -3).affect(gl.pers0(45));

+ drawOpaque();

+ });

+ render.request();

});

- render.request();

});

</script>

vanilla-mat2.jsthru.vsfixed.fsspherical-grid.jsも追加に。 とりあえず深度テストなし。 少し上から見下ろす角度にしてある。 描画がdrawOpaqueに分かれているのは次回あたりの都合。 シェーダーの読み込みが入るので、レンダリングリクエストがプロミスのthenの中に移動。 renderは他からも叩くのでletthenの外に。

@@ -7,4 +7,5 @@

<script src="vanilla-gl.js"></script>

<script src="vanilla-mat2.js"></script>

+<script src="thick-line.js"></script>

<script src="spherical-grid.js"></script>

<script>

@@ -13,17 +14,30 @@ const Mat4 = VanillaMat4;

Mat4.setToRadian(deg2rad);

Mat4.aspectMode = "min";

+function drawVector(prog, line, vec, mat, opt) {

+ const thk = opt?.thickness ?? [ "min", 10 ];

+ prog.use();

+ if (opt?.color)

+ prog.setUniform("uColor", opt.color);

+ prog.setUniform("uMat:m4",

+ Mat4.create(vec).put(3, 0, 0).put(3, 3, 1).affect(mat));

+ drawRoundCapLine(prog, line, thk);

+}

$DCL(_ => {

const gl = VanillaGL.create($QS("canvas"));

const ready = prepare(

gl.fetchShader("vs", "thru.vs"),

+ gl.fetchShader("vs", "roundcap.vs"),

gl.fetchShader("fs", "fixed.fs"),

+ gl.fetchShader("fs", "circlepoly.fs")

);

+ const line = prepareRoundCapLine(gl);

const grid = prepareSphericalGrid(gl);

let matVP = Mat4.create();

let render;

ready.then((

- vs, fs

+ vs, vsLine, fs, fsDisk

) => {

const prog = gl.linkProgram(vs, fs);

+ const progLine = gl.linkProgram(vsLine, fsDisk);

function drawOpaque() {

prog.use();

@@ -32,4 +46,10 @@ $DCL(_ => {

drawSphericalGrid(prog, grid);

}

+ function drawForefront() {

+ // X axis, positive

+ drawVector(progLine, line, [ 1, 0, 0, 100 ], matVP, {

+ color: [ 1, 1, 1, 1 ], thickness: [ "min", 5 ]

+ });

+ }

render = VanillaFrameRenderer.create(_ => {

gl.initCanvas();

@@ -37,4 +57,5 @@ $DCL(_ => {

.trans(0, 0, -3).affect(gl.pers0(45));

drawOpaque();

+ drawForefront();

});

render.request();

 向きが分かりやすいよう、X軸の正方向に線を引いた。 roundcap.vscirclepoly.fsthick-line.js追加。 drawVector関数を作っているのはあとで他にも線を引くから。 この関数は原点からvecで指定された位置まで線を引いてくれる。 実際に引いているのは原点からX=1までの直線なので、それをモデル行列で変換して描画。 描画をdrawForefrontに分けている理由はあとで分かる。

@@ -33,4 +33,5 @@ $DCL(_ => {

const line = prepareRoundCapLine(gl);

const grid = prepareSphericalGrid(gl);

+ let matGrid = Mat4.create();

let matVP = Mat4.create();

let render;

@@ -54,11 +55,16 @@ $DCL(_ => {

render = VanillaFrameRenderer.create(_ => {

gl.initCanvas();

- matVP = Mat4.rotX(-75)

+ matVP = Mat4.rotX(-75).affect(matGrid)

.trans(0, 0, -3).affect(gl.pers0(45));

drawOpaque();

+ drawTranslucent();

drawForefront();

});

render.request();

});

+ VanillaGLTouchDrag.create(gl.canvas, 0, me => {

+ matGrid = me.mModel;

+ render?.request();

+ });

});

</script>

 タッチドラッグイベントに対応。 これだけで座標球をぐるぐる回せるようになる。

@@ -27,9 +27,15 @@ $DCL(_ => {

const ready = prepare(

gl.fetchShader("vs", "thru.vs"),

+ gl.fetchShader("vs", "tex.vs"),

gl.fetchShader("vs", "roundcap.vs"),

gl.fetchShader("fs", "fixed.fs"),

- gl.fetchShader("fs", "circlepoly.fs")

+ gl.fetchShader("fs", "circlepoly.fs"),

+ gl.fetchShader("fs", "roundtex.fs"),

+ gl.loadTexture("iphone13-front.jpg")

);

const line = prepareRoundCapLine(gl);

+ const plate = gl.createFloatBuffer([

+ [ -1, -1, 0, 1 ], [ 1, -1, 1, 1 ], [ -1, 1, 0, 0 ], [ 1, 1, 1, 0 ]

+ ]);

const grid = prepareSphericalGrid(gl);

let matGrid = Mat4.create();

@@ -37,8 +43,12 @@ $DCL(_ => {

let render;

ready.then((

- vs, vsLine, fs, fsDisk

+ vs, vsTex, vsLine, fs, fsDisk, fsTex, front

) => {

const prog = gl.linkProgram(vs, fs);

const progLine = gl.linkProgram(vsLine, fsDisk);

+ const progTex = gl.linkProgram(vsTex, fsTex);

+ function drawPlate(prog) {

+ prog.drawArrays("aPos:f2, aTex:f2", plate, gl.TRIANGLE_STRIP);

+ }

function drawOpaque() {

prog.use();

@@ -47,4 +57,11 @@ $DCL(_ => {

drawSphericalGrid(prog, grid);

}

+ function drawTranslucent() {

+ progTex.use();

+ progTex.setUniform("uRadius", 0, 0, 0, 0.7);

+ progTex.setUniform("uMat:m4", matVP);

+ progTex.setTexture("uTex", front, 0);

+ drawPlate(progTex);

+ }

function drawForefront() {

// X axis, positive

端末テクスチャをとりあえず表示しておく。 iphone13-front.jpgtex.vsroundtex.fs追加。 フラグメントシェーダーはあとで角を丸めるつもりなのでこの段階でroundtex.fsを採用。 drawTranslucentも次回あたりの都合。 まぁ関数に分けることは悪いことでなし。

 まだサイズがおかしい。

@@ -11,7 +11,17 @@

<script>

"use strict";

-const Mat4 = VanillaMat4;

-Mat4.setToRadian(deg2rad);

-Mat4.aspectMode = "min";

+const Mat4 = buildClass(VanillaMat4, {

+factory: {

+create(...args) {

+ return this.base.create.call(this, ...args);

+}

+},

+affected(...args) {

+ return this.clone().affect(...args);

+}

+});

+

+VanillaMat4.setToRadian(deg2rad);

+VanillaMat4.aspectMode = "min";

function drawVector(prog, line, vec, mat, opt) {

const thk = opt?.thickness ?? [ "min", 10 ];

@@ -34,4 +44,6 @@ $DCL(_ => {

gl.loadTexture("iphone13-front.jpg")

);

+ const devWidth = 0.35, devAspect = 2;

+ const matDev = Mat4.scale(devWidth, devWidth * devAspect);

const line = prepareRoundCapLine(gl);

const plate = gl.createFloatBuffer([

@@ -60,5 +72,5 @@ $DCL(_ => {

progTex.use();

progTex.setUniform("uRadius", 0, 0, 0, 0.7);

- progTex.setUniform("uMat:m4", matVP);

+ progTex.setUniform("uMat:m4", matDev.affected(matVP));

progTex.setTexture("uTex", front, 0);

drawPlate(progTex);

サイズを調整する。 VanillaMat4affectedという関数を追加している。 中身を見れば分かる通り、演算内容はaffectと同じだが、affectthisそのものを更新するのに対し、affectedthisをコピーしてそれに行列を乗じた新しい行列を返す。

 VanillaMat4.createArrayをフックしている関係で、buildClassが作ったcreateを差し替える必要がある。 ベースクラスのプロトタイプにアクセスする必要があるが、superが使えないので、buildClassにコッソリ手を入れてbaseというメンバーを追加している。

 デフォルトをMat4ではなくVanillaMat4に対して設定しているが、Mat4に対してデフォルト設定してもVanillaGLなどはMat4の存在を知らないため、設定を見てくれないから。

@@ -11,4 +11,5 @@

<script>

"use strict";

+function $QSA(...args) { return $D.querySelectorAll(...args); }

const Mat4 = buildClass(VanillaMat4, {

factory: {

@@ -92,4 +93,14 @@ $DCL(_ => {

render.request();

});

+ const numfmt3 = gennumfmt(8, 3);

+ setupDeviceOrientationEvent("permit-attitude", ev => {

+ $QSA("[data-num-id]").forEach(e => {

+ e.innerText = numfmt3(ev[e.dataset.numId]);

+ });

+ $QSA("[data-id]").forEach(e => {

+ e.innerText = ev[e.dataset.id];

+ });

+ render?.request();

+ });

VanillaGLTouchDrag.create(gl.canvas, 0, me => {

matGrid = me.mModel;

@@ -98,4 +109,31 @@ $DCL(_ => {

});

</script>

+<style>

+p {

+ margin: 0.5em 0.5em;

+}

+p:has(.device-orientation-enabled) {

+ display: none;

+}

+[data-num-id] {

+ font-family: monospace;

+}

+[data-num-id] {

+ white-space: pre;

+}

+[data-id] {

+ text-align: center;

+}

+</style>

<body class="fullscreen">

<canvas></canvas>

+<div class="status">

+<p><button id="permit-attitude">permit using device attitude</button>

+<table>

+<tr><td>absolute<td data-id="absolute">

+<tr><td>alpha<td data-num-id="alpha">

+<tr><td>beta<td data-num-id="beta">

+<tr><td>gamma<td data-num-id="gamma">

+<tr><td>heading<td data-num-id="webkitCompassHeading">

+</table>

+</div>

 デバイスオリエンテーションイベントに対応、とりあえずテキスト表示だけ。 Mac版のChromeはmonospaceが等幅にならないのと、標準のフォントにいい等幅数字フォントがないので、ここだけWebフォントを使う。

@@ -110,4 +110,5 @@ $DCL(_ => {

</script>

<style>

+@import url('https://fonts.googleapis.com/css2?family=Azeret+Mono&display=swap');

p {

margin: 0.5em 0.5em;

@@ -117,5 +118,5 @@ p:has(.device-orientation-enabled) {

}

[data-num-id] {

- font-family: monospace;

+ font-family: "azeret mono", monospace;

}

[data-num-id] {

 実際にデバイスを回す。

@@ -18,4 +18,10 @@ create(...args) {

}

},

+affect(mat, ...args) {

+ super.affect(mat);

+ if (args.length < 1)

+ return this;

+ return this.affect(...args);

+},

affected(...args) {

return this.clone().affect(...args);

@@ -52,4 +58,6 @@ $DCL(_ => {

]);

const grid = prepareSphericalGrid(gl);

+ let evDev = null;

+ let matAtt = Mat4.create();

let matGrid = Mat4.create();

let matVP = Mat4.create();

@@ -73,5 +81,5 @@ $DCL(_ => {

progTex.use();

progTex.setUniform("uRadius", 0, 0, 0, 0.7);

- progTex.setUniform("uMat:m4", matDev.affected(matVP));

+ progTex.setUniform("uMat:m4", matDev.affected(matAtt, matVP));

progTex.setTexture("uTex", front, 0);

drawPlate(progTex);

@@ -84,4 +92,6 @@ $DCL(_ => {

}

render = VanillaFrameRenderer.create(_ => {

+ if (evDev != null)

+ matAtt = Mat4.rotY(evDev.gamma).rotX(evDev.beta).rotZ(evDev.alpha);

gl.initCanvas();

matVP = Mat4.rotX(-75).affect(matGrid)

@@ -101,4 +111,5 @@ $DCL(_ => {

e.innerText = ev[e.dataset.id];

});

+ evDev = ev;

render?.request();

});

Mat4affectを複数引数対応にしておく。 演算順序は左にある引数が数式でも左に来る(prodと逆)。 これでaffectedの方も勝手に複数の引数に対応している。

 地面を書いておこう。

@@ -79,4 +79,8 @@ $DCL(_ => {

}

function drawTranslucent() {

+ prog.use();

+ prog.setUniform("uMat:m4", Mat4.scale(1.5).affect(matVP));

+ prog.setUniform("uColor", 0.5, 0.5, 0, 0.75);

+ prog.drawArrays("aPos:f2, :f2", plate, gl.TRIANGLE_STRIP);

progTex.use();

progTex.setUniform("uRadius", 0, 0, 0, 0.7);

深度テストをしていないので、前後関係がさっぱり分からない。 そろそろ深度テストを有効にしておく。

@@ -41,5 +41,5 @@ function drawVector(prog, line, vec, mat, opt) {

}

$DCL(_ => {

- const gl = VanillaGL.create($QS("canvas"));

+ const gl = VanillaGL.create($QS("canvas"), { useDepth: true });

const ready = prepare(

gl.fetchShader("vs", "thru.vs"),

@@ -100,8 +100,10 @@ $DCL(_ => {

gl.initCanvas();

matVP = Mat4.rotX(-75).affect(matGrid)

- .trans(0, 0, -3).affect(gl.pers0(45));

+ .trans(0, 0, -3).affect(gl.pers(45, 0.5, 5.5));

drawOpaque();

drawTranslucent();

+ gl.disable(gl.DEPTH_TEST);

drawForefront();

+ gl.enable(gl.DEPTH_TEST);

});

render.request();

drawForefrontの前後で深度テストの有効・無効を切り替えているが、この関数には指標類を集める予定で、オブジェクトの影にあっても表示したいから。 これが関数に分けた理由で、次回さらに色々切り替えるので、見通しをよくする意味がある。
Zファイティングの解決

 端末と地面が同一平面になると変な表示になる(Zファイティングと言うらしい)のをなんとかせねば。

@@ -79,8 +79,10 @@ $DCL(_ => {

}

function drawTranslucent() {

+ gl.enable(gl.POLYGON_OFFSET_FILL);

prog.use();

prog.setUniform("uMat:m4", Mat4.scale(1.5).affect(matVP));

prog.setUniform("uColor", 0.5, 0.5, 0, 0.75);

prog.drawArrays("aPos:f2, :f2", plate, gl.TRIANGLE_STRIP);

+ gl.polygonOffset(-0.1, -1);

progTex.use();

progTex.setUniform("uRadius", 0, 0, 0, 0.7);

@@ -88,4 +90,6 @@ $DCL(_ => {

progTex.setTexture("uTex", front, 0);

drawPlate(progTex);

+ gl.polygonOffset(0, 0);

+ gl.disable(gl.POLYGON_OFFSET_FILL);

}

function drawForefront() {

 ここは初めて出てくる関数を使っている。 ピクセルシェーダーにデータを渡すときに、深度値をオフセットするための機能があり、gl.enablePOLYGON_OFFSET_FILLを有効にすると使えるようになる。 polygonOffsetの第1引数は傾きのきつい面(前後方向に置かれたポリゴン)のための数字で、ポリゴンの傾きにこの数値を乗じた値だけオフセットする。 1を指定している人が多いが、0.1とか0.01で大丈夫のようだ。

 第2引数は固定オフセットだが、最低限区別がつく深度値の差にこの値を乗じた分だけオフセットする。 こちらは視線に垂直に近い(画面と並行に近い)ポリゴン用で、説明の意味から分かるように1にしておけば大丈夫。 複数の面の間でZファイティングを解決したい場合は1ずつ大きくしていけばいい。 傾きがきついポリゴンはこれより大きな演算誤差が出ることがあるのだろう。 そのために第1引数があるようだ。

 オフセットした結果で深度テストが行われ、深度バッファに書き込む値もオフセットを適用した量になる。 次回あたりこの性質を使うことになる。 また、オフセットされるのはZではなく深度値なので、普通の深度バッファの設定ならば画面上でプラスならば奥、マイナスならば手前にシフトする。 つまり、地面を上から見ても下から見ても、必ず端末が手前から見える。 これをZでプラス方向に少しだけ動かした場合は上から見れば端末が手前だが、下から見たら地面が手前になって端末が隠れてしまう。

赤道を描く(ラウンドキャップ直線の問題点)

 地面の裏表の色を変える。

@@ -9,4 +9,10 @@

<script src="thick-line.js"></script>

<script src="spherical-grid.js"></script>

+<script id="biface.fs" type="text/plain">

+uniform lowp vec4 uFront, uBack;

+void main(void) {

+ gl_FragColor = mix(uBack, uFront, float(gl_FrontFacing));

+}

+</script>

<script>

"use strict";

@@ -51,4 +57,5 @@ $DCL(_ => {

gl.loadTexture("iphone13-front.jpg")

);

+ const fsPlate = gl.loadShader("fs", $ID("biface.fs"));

const devWidth = 0.35, devAspect = 2;

const matDev = Mat4.scale(devWidth, devWidth * devAspect);

@@ -67,4 +74,5 @@ $DCL(_ => {

) => {

const prog = gl.linkProgram(vs, fs);

+ const progPlate = gl.linkProgram(vs, fsPlate);

const progLine = gl.linkProgram(vsLine, fsDisk);

const progTex = gl.linkProgram(vsTex, fsTex);

@@ -80,8 +88,9 @@ $DCL(_ => {

function drawTranslucent() {

gl.enable(gl.POLYGON_OFFSET_FILL);

- prog.use();

- prog.setUniform("uMat:m4", Mat4.scale(1.5).affect(matVP));

- prog.setUniform("uColor", 0.5, 0.5, 0, 0.75);

- prog.drawArrays("aPos:f2, :f2", plate, gl.TRIANGLE_STRIP);

+ progPlate.use();

+ progPlate.setUniform("uMat:m4", Mat4.scale(1.5).affect(matVP));

+ progPlate.setUniform("uFront", 0.5, 0.5, 0, 0.75);

+ progPlate.setUniform("uBack", 0, 0, 0.7, 0.75);

+ progPlate.drawArrays("aPos:f2, :f2", plate, gl.TRIANGLE_STRIP);

gl.polygonOffset(-0.1, -1);

progTex.use();

 赤道を書く。

@@ -37,4 +37,18 @@ affected(...args) {

VanillaMat4.setToRadian(deg2rad);

VanillaMat4.aspectMode = "min";

+function prepareRing(gl, w, m = 360) {

+ function pos(r, i) {

+ const theta = 2 * Math.PI * i / 360;

+ return [ r * Math.cos(theta), r * Math.sin(theta) ];

+ }

+ const buf = [];

+ for (let i = 0; i < m; i++) {

+ buf.push(pos(1 - w, i));

+ buf.push(pos(1 + w, i));

+ }

+ buf.push(buf[0]);

+ buf.push(buf[1]);

+ return gl.createFloatBuffer(buf);

+}

function drawVector(prog, line, vec, mat, opt) {

const thk = opt?.thickness ?? [ "min", 10 ];

@@ -65,4 +79,5 @@ $DCL(_ => {

]);

const grid = prepareSphericalGrid(gl);

+ const ring = prepareRing(gl, 1/128);

let evDev = null;

let matAtt = Mat4.create();

@@ -94,4 +109,8 @@ $DCL(_ => {

progPlate.drawArrays("aPos:f2, :f2", plate, gl.TRIANGLE_STRIP);

gl.polygonOffset(-0.1, -1);

+ prog.use();

+ prog.setUniform("uMat:m4", matVP);

+ prog.setUniform("uColor", 1, 0, 0, 0.7);

+ prog.drawArrays("aPos:f2", ring, gl.TRIANGLE_STRIP);

progTex.use();

progTex.setUniform("uRadius", 0, 0, 0, 0.7);

赤道はラウンドキャップではなく、地面にリングを描いている。 これは線と面が大規模に交差する場合、ラウンドキャップだと問題が生じるから。 今回はじめて気づいた。 実際に太さのある線で書くとこうなる。

 なんかギザギザしているが、太い・10分割・半透明にすると理由が分かる。 ジョイントの円は常にこちを向いており、他の面とこのように交差させた場合は常に下半分か上半分が隠れる。 これに対して直線は始点と終点の位置によって斜めに隠れるため、円の左右どちらかが直線と同じ側に入らない。 直線が手前、円が奥の場合は問題ないが、円が手前、直線が奥の場合は円が隠れずに単独で描画されてしまう。 これがギザギザの元で、簡単には解決できなさそうだったので、思い切ってリングにしてしまった。

 赤道は地面にくっついているのでZファイティングの問題が生じるが、端末描画時に設定したポリゴンオフセットが有効な部分で描画しているので、自然と地面より手前に見えるようになっている。 地面を裏から見ても手前に見える。

裏面を描く(カリング)

 だいたいの部品描画が落ち着いたところで裏も描いてしまおう。 当然だが、裏テクスチャ画像ファイルが追加になる。

@@ -32,4 +32,7 @@ affect(mat, ...args) {

affected(...args) {

return this.clone().affect(...args);

+},

+scaled(...args) {

+ return this.clone().scale(...args);

}

});

@@ -69,5 +72,6 @@ $DCL(_ => {

gl.fetchShader("fs", "circlepoly.fs"),

gl.fetchShader("fs", "roundtex.fs"),

- gl.loadTexture("iphone13-front.jpg")

+ gl.loadTexture("iphone13-front.jpg"),

+ gl.loadTexture("iphone13-back.jpg")

);

const fsPlate = gl.loadShader("fs", $ID("biface.fs"));

@@ -86,5 +90,5 @@ $DCL(_ => {

let render;

ready.then((

- vs, vsTex, vsLine, fs, fsDisk, fsTex, front

+ vs, vsTex, vsLine, fs, fsDisk, fsTex, front, back

) => {

const prog = gl.linkProgram(vs, fs);

@@ -113,4 +117,5 @@ $DCL(_ => {

prog.setUniform("uColor", 1, 0, 0, 0.7);

prog.drawArrays("aPos:f2", ring, gl.TRIANGLE_STRIP);

+ gl.enable(gl.CULL_FACE);

progTex.use();

progTex.setUniform("uRadius", 0, 0, 0, 0.7);

@@ -118,4 +123,8 @@ $DCL(_ => {

progTex.setTexture("uTex", front, 0);

drawPlate(progTex);

+ progTex.setUniform("uMat:m4", matDev.scaled(-1, 1).affect(matAtt, matVP));

+ progTex.setTexture("uTex", back, 0);

+ drawPlate(progTex);

+ gl.disable(gl.CULL_FACE);

gl.polygonOffset(0, 0);

gl.disable(gl.POLYGON_OFFSET_FILL);

 今まで深く触れてこなかったカリングを使っている。 cull(淘汰する)が語源だが、なんとなく「刈る」と同じイメージで覚えやすい。 カリングを有効にすると「こちら側」を向いてないポリゴンを刈り取って捨ててしまう。 描画されないだけでなく、深度バッファなどの更新もされない。 カリングしておかないと表と裏は同一ポリゴンだから、あとから描いた面は深度テストで捨てられてしまって描かれないし、深度テストを使わないと両方同時に描かれてしまい、裏から表(あるいは表から裏)が透けた状態になってしまう。

 Mat4.scaledaffectaffectedと同じ関係で、thisを拡大・縮小するが、thisは更新せずに新しい行列を返す。 これがどこで使われているかというと、座標変換でいきなりポリゴンの左右をひっくり返している。 このポリゴンの表裏は表側から見たものなので、左右をひっくり返さないと裏面が「こちら側」の扱いにならないし、テクスチャ座標も正しくならない(いわゆる裏像になる)。

 角を丸くしておく。

@@ -76,5 +76,6 @@ $DCL(_ => {

);

const fsPlate = gl.loadShader("fs", $ID("biface.fs"));

- const devWidth = 0.35, devAspect = 2;

+ const devWidth = 0.35, devAspect = 2, devRadiusX = 0.15;

+ const devRadius = [ devRadiusX, devRadiusX / devAspect ];

const matDev = Mat4.scale(devWidth, devWidth * devAspect);

const line = prepareRoundCapLine(gl);

@@ -119,5 +120,5 @@ $DCL(_ => {

gl.enable(gl.CULL_FACE);

progTex.use();

- progTex.setUniform("uRadius", 0, 0, 0, 0.7);

+ progTex.setUniform("uRadius", ...devRadius, 0, 0.7);

progTex.setUniform("uMat:m4", matDev.affected(matAtt, matVP));

progTex.setTexture("uTex", front, 0);

 devRadiusなんかはあとで枠の角を丸くするのにも使われる。

ベクトルクラス

 ここからは実際に端末の姿勢を計算するが、その前にベクトルクラスをさらっと定義しておく。

@@ -18,4 +18,38 @@ void main(void) {

"use strict";

function $QSA(...args) { return $D.querySelectorAll(...args); }

+const Vec4 = buildClass(Array, {

+factory: {

+//----- static members -----

+...VectorFromArray,

+XYZ: [ 0, 1, 2 ],

+create(...args) {

+ const me = this.fromArray(...args) ?? [ 0, 0, 0, 1 ];

+ this.takeover(me, ...args);

+ return me;

+}

+},

+//===== instance members =====

+init() {

+ this.length = 4;

+ for (const k of this.keys())

+ this[k] ??= 0;

+},

+set x(val) { this[0] = val; },

+get x() { return this[0]; },

+set y(val) { this[1] = val; },

+get y() { return this[1]; },

+set z(val) { this[2] = val; },

+get z() { return this[2]; },

+set w(val) { this[3] = val; },

+get w() { return this[3]; },

+norm(swiz = Vec4.XYZ) {

+ return Math.hypot(...swiz.map(i => this[i]));

+},

+normalize(swiz = Vec4.XYZ) {

+ const r = this.norm();

+ swiz.forEach(i => this[i] /= r);

+ return this;

+}

+});

const Mat4 = buildClass(VanillaMat4, {

factory: {

 行列をもう少し極めるで出てきたVectorFromArrayが使われているので、createの方法はだいたいVanillaMat4と同じ。 XYZというのはGLSLでいうところのスイズルである。 本当はXからXYZWまでそろえるべきだが、とりあえず.xyzしか使わないので必要最低限のものだけ定義している。

 そのあとにゲッター・セッターが続く。 ゲッター・セッターなんて要らない子ちゃん、とずっと思っていたのだが、はじめて便利だと思った(笑)。 Vec4は配列だから、Zにアクセスしたければv[2]と書く必要がある。 書くのも面倒だし、0オリジンか1オリジンかで意味も違ってくるが、v.zと書けば意味は明白である。 つまり、一種のエイリアスとして使っている。 今回は書いてないが、.rgbaに対応するエイリアスを作ることもできる。 ゲッターはv.z()で代用が効くが、セッターは代入文の左辺にできる。 C++ならreturn &v.zみたいなこともできるが、JSの普通の関数ではできない。

 normはベクトルの長さを求める。 Math.hypotが便利である。 スイズル配列をmapに渡すと、クロージャーは必要な成分が分かるため、成分の値をまんま返すと、mapが配列に組み直してくれる。 普通、Wは1固定で計算に入れるとおかしなことになるので、スイズルのデフォルトはXYZである。

 normalizeはベクトルの各成分の比率を変えずに、ベクトルの大きさを1にする。 大きさが0の場合をチェックしていないので、呼び出し側でケアする必要がある。 ここでもスイズルをforEachに渡して上手いことやっている。

回転軸の計算

 いよいよ端末の回転軸を求める。 北はどっち?の調査結果では、

ディスプレイ面(端末座標系のXY平面)上にあり、地球座標系で水平な直線を軸として、端末が水平になるまで回転した時に、端末Y軸が指している方向

なのであった。 ざっくり、水平面とディスプレイが交わってできる直線が軸である。 地球座標系で水平なら、デバイスローカル座標系でも水平なので、軸ベクトルのZ成分は0である。 また、軸はディスプレイ面上にあるので、ディスプレイに対する法線ベクトル、つまり端末姿勢のZ方向は軸と垂直に交わっている。 したがって、軸ベクトルと端末姿勢のZ方向の内積は0になる。 軸ベクトルを\(\bvec{a} = (x, y, z)\)、端末姿勢のZ方向を\(\bvec{z} = (x_z, y_z, z_z)\)とすると、

\begin{align*} \left\{\begin{aligned} x x_z + y y_z + z z_z &= 0 \\ z &= 0 \end{aligned}\right. \end{align*}
\begin{align*} x x_z + y y_z &= 0 \eol x x_z &= -y y_z \eol \frac{x}{y} &= -\frac{y_z}{x_z} \eol x\div y&=-y_z\div x_z \eol x:y&=-y_z:x_z \end{align*}

となる。 つまり、とりあえずA.x=-Z.y; A.y=Z.xとしてnormalizeすればよい。

@@ -170,4 +170,13 @@ $DCL(_ => {

color: [ 1, 1, 1, 1 ], thickness: [ "min", 5 ]

});

+

+ // rotation axis

+ const Z = Vec4.create(matAtt.column(2));

+ const A = Vec4.create(-Z.y, Z.x, 0);

+ if (A.norm() == 0)

+ A.x = 1;

+ else

+ A.normalize();

+ drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

}

render = VanillaFrameRenderer.create(_ => {

 実際に実行すと確かにディスプレイと地面が交わったところに軸が描かれる。

 Aの大きさが0になるときはどんな時かと考えると、Z.xZ.yも0の時だから、Zが真上を向いている時、つまり、端末が地面と水平の時である。 なるほど、ディスプレイ面が水平だから直線が定まらない。 この場合はどうせこのあとの回転は無意味なので、適当にX軸正方向にでもしておけばいい。

指標類の追加

 このあと端末をぐるぐる回転させることになるが、姿勢が見えないと分かりづらいので、回転後のデバイスの姿勢を書いておくことにする。 角が丸い枠を描く。 フラグメントシェーダーroundborder.fsが追加になる。

@@ -106,10 +106,12 @@ $DCL(_ => {

gl.fetchShader("fs", "circlepoly.fs"),

gl.fetchShader("fs", "roundtex.fs"),

+ gl.fetchShader("fs", "roundborder.fs"),

gl.loadTexture("iphone13-front.jpg"),

gl.loadTexture("iphone13-back.jpg")

);

const fsPlate = gl.loadShader("fs", $ID("biface.fs"));

- const devWidth = 0.35, devAspect = 2, devRadiusX = 0.15;

+ const devWidth = 0.35, devAspect = 2, devRadiusX = 0.15, devBorderX = 0.05;

const devRadius = [ devRadiusX, devRadiusX / devAspect ];

+ const devBorder = [ devBorderX, devBorderX ];

const matDev = Mat4.scale(devWidth, devWidth * devAspect);

const line = prepareRoundCapLine(gl);

@@ -125,5 +127,5 @@ $DCL(_ => {

let render;

ready.then((

- vs, vsTex, vsLine, fs, fsDisk, fsTex, front, back

+ vs, vsTex, vsLine, fs, fsDisk, fsTex, fsBorder, front, back

) => {

const prog = gl.linkProgram(vs, fs);

@@ -131,4 +133,5 @@ $DCL(_ => {

const progLine = gl.linkProgram(vsLine, fsDisk);

const progTex = gl.linkProgram(vsTex, fsTex);

+ const progBorder = gl.linkProgram(vsTex, fsBorder);

function drawPlate(prog) {

prog.drawArrays("aPos:f2, aTex:f2", plate, gl.TRIANGLE_STRIP);

@@ -178,4 +181,12 @@ $DCL(_ => {

else

A.normalize();

+

+ const color = [ 0, 0.8, 0.8 ];

+ progBorder.use();

+ progBorder.setUniform("uMat:m4", matVP);

+ progBorder.setUniform("uColor", ...color, 0.5);

+ progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

+ drawPlate(progBorder);

+

drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

}

 とりあえず描いてみた。 大きさを調整する。

@@ -113,5 +113,5 @@ $DCL(_ => {

const devWidth = 0.35, devAspect = 2, devRadiusX = 0.15, devBorderX = 0.05;

const devRadius = [ devRadiusX, devRadiusX / devAspect ];

- const devBorder = [ devBorderX, devBorderX ];

+ const devBorder = [ devBorderX, devBorderX / devAspect ];

const matDev = Mat4.scale(devWidth, devWidth * devAspect);

const line = prepareRoundCapLine(gl);

@@ -184,5 +184,5 @@ $DCL(_ => {

const color = [ 0, 0.8, 0.8 ];

progBorder.use();

- progBorder.setUniform("uMat:m4", matVP);

+ progBorder.setUniform("uMat:m4", matDev.affected(matVP));

progBorder.setUniform("uColor", ...color, 0.5);

progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

 このままだと頭がどっちか分かりづらいので、適当に三角形のマークを出すことにする。

@@ -121,4 +121,6 @@ $DCL(_ => {

const grid = prepareSphericalGrid(gl);

const ring = prepareRing(gl, 1/128);

+ const triangleVertices = [ [ -1, 0 ], [ 0, 1 ], [ 1, 0 ] ];

+ const trianglePlate = gl.createFloatBuffer(triangleVertices);

let evDev = null;

let matAtt = Mat4.create();

@@ -188,4 +190,8 @@ $DCL(_ => {

progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

drawPlate(progBorder);

+ prog.use();

+ prog.setUniform("uMat:m4", matVP);

+ prog.setUniform("uColor", ...color, 0.7);

+ prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

 位置と大きさを調整する。

@@ -115,4 +115,5 @@ $DCL(_ => {

const devBorder = [ devBorderX, devBorderX / devAspect ];

const matDev = Mat4.scale(devWidth, devWidth * devAspect);

+ const matHead = Mat4.trans(0, -1).scale(0.07).trans(0, 1);

const line = prepareRoundCapLine(gl);

const plate = gl.createFloatBuffer([

@@ -191,5 +192,5 @@ $DCL(_ => {

drawPlate(progBorder);

prog.use();

- prog.setUniform("uMat:m4", matVP);

+ prog.setUniform("uMat:m4", matHead.affected(matVP));

prog.setUniform("uColor", ...color, 0.7);

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

底辺がX軸に、頂点がY=1になっているので、一度頂点を原点に動かしてから縮小してそれからY=1の位置に戻せば、スケールが関係してくる場所が一箇所になる。 原点への移動を省くと1-縮小率という計算をしなければならない。

 デバイス枠から離れているので、デバイスの頭のすぐ内側に動かす。

@@ -115,5 +115,5 @@ $DCL(_ => {

const devBorder = [ devBorderX, devBorderX / devAspect ];

const matDev = Mat4.scale(devWidth, devWidth * devAspect);

- const matHead = Mat4.trans(0, -1).scale(0.07).trans(0, 1);

+ const matHead = Mat4.trans(0, -1.2).scale(0.07).trans(0, matDev.get(1, 1) - devBorder[1]);

const line = prepareRoundCapLine(gl);

const plate = gl.createFloatBuffer([

 さっきの理屈がここで効いてくる。 縮小後の移動量が原点から枠の内側までになる。 つまり、原点から枠の外側まで移動して、ボーダーの幅の分だけ戻す。 最初の移動が-1ではなく-1.2になっているが、これは自身の2割だけ内側に引っ込む、という微調整になっている。 3か所の数値で3つの量を独立して調整できるようになっている。

回転の実行

 デバイスの向きが見えるようになったので、実際に回転させていく。 基本的な方針は、

  • 今求めた回転軸がX軸の方向になるまで、端末をZ軸を軸として回転
  • 端末が水平になるまで、回転軸=X軸を軸として回転
  • 最初の回転をキャンセルするように端末を回転

となる。 端末を水平にするときにディスプレイを上にするか、下にするかで2通りあるが、これは後で考える。 というか、自然と考えないとあかんようになっている。

 とりあえず今求めた回転軸をX軸の方向へ回さなければならない。 この操作はrotZになるので、回転角を求めることになる。 回転角を求めること自体はそんなに難しくなくて、XY平面上にある回転軸ベクトルのX軸に対する角度だから、回転軸ベクトルのX座標とY座標をMath.atan2に突っ込めば象限まですぐに分かる。

 しかし、こうして求めた角度はrotZMath.cosMath.sinでもう一度X座標とY座標に戻すことになる。 だったら最初から回転軸ベクトルをそのまま使えばいいではないか。 X軸正方向の単位ベクトルが、XY平面上でZ軸を軸として\((x, y)\)まで回転したとしたら、その回転行列は

\begin{align*} \begin{pmatrix} x & -y & 0 & 0 \\ y & x & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \end{align*}

である。 今はX軸正方向への回転を求めたいので、逆回転にすればよい。 実際のコードではカラムメジャーなので転置されたように見える。

 逆回転は逆行列を求めればよく、詳しい説明は省くが回転行列は転置行列が逆行列なので、転置行列が逆回転の行列になる。 行列の形を見れば\(y\)の符号を変えればよいことが分かる。

@@ -56,4 +56,12 @@ factory: {

create(...args) {

return this.base.create.call(this, ...args);

+},

+rotZuvecXto(x, y) {

+ return this.getFactory().create([

+ x, y, 0, 0,

+ -y, x, 0, 0,

+ 0, 0, 1, 0,

+ 0, 0, 0, 1

+ ]);

}

},

@@ -71,4 +79,8 @@ scaled(...args) {

}

});

+[ "rotZuvecXto" ]

+.forEach(v => Mat4.prototype[v] = function (...args) {

+ return this.affect(this.factory[v].call(this, ...args));

+});

VanillaMat4.setToRadian(deg2rad);

@@ -185,15 +197,17 @@ $DCL(_ => {

A.normalize();

+ const matDir = matAtt.clone().rotZuvecXto(A.x, -A.y);

const color = [ 0, 0.8, 0.8 ];

progBorder.use();

- progBorder.setUniform("uMat:m4", matDev.affected(matVP));

+ progBorder.setUniform("uMat:m4", matDev.affected(matDir, matVP));

progBorder.setUniform("uColor", ...color, 0.5);

progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

drawPlate(progBorder);

prog.use();

- prog.setUniform("uMat:m4", matHead.affected(matVP));

+ prog.setUniform("uMat:m4", matHead.affected(matDir, matVP));

prog.setUniform("uColor", ...color, 0.7);

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

+ drawVector(progLine, line, matDir.column(2), matVP, { color: [ 0, 1, 0, 1 ] });

drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

}

@@ -206,5 +220,5 @@ $DCL(_ => {

drawOpaque();

drawTranslucent();

- gl.disable(gl.DEPTH_TEST);

+// gl.disable(gl.DEPTH_TEST);

drawForefront();

gl.enable(gl.DEPTH_TEST);

 rotZuvecXtoが回転行列を求める関数で、あまりいい名前とは思わないが、rotate unit vector X around Z to specified coordinateのつもりである。 このパターンは全部で6個あるが、とりあえず使うものだけ定義する。 クラスメソッド版とインスタンスメソッド版はどちらを使うか分からないが両方定義してある。

 回転行列を求めたら、それをデバイスの姿勢を示すmatAttに適用してやればよい。 scaledのようにthisに回転を適用して新しい行列を返す関数を作ってもいいが、そんなに頻繁に使う関数でもないのと、関数名でちょっと困ったので、素直にcloneしている。

 実際に描画させると地面との関係が分かりづらかったので、一時的に深度テストを有効にしたまま指標を描いている。 端末のフレーム指標が見事にX座標上で地面と交わる様子が分かる。 ついでに回転行列の第3カラム(matDir.column(2))も一時的に表示している。

 次に端末を地面に寝かせる。 これはX軸を中心とした回転になる。 最終的に地面と平行になるのだから、端末Z方向は真上か真下を向くことになる。 とりあえず真上(Z軸正方向)にしよう。 回転軸はディスプレイ面と平行だから、回転軸がどちらを向いていてもZ方向とは直交している。 現在、端末の回転軸はX軸の方向を向いている。 ということは、端末のZ方向はX軸と垂直になっている。 このままX軸のまわりをぐるぐる回すと、Z方向はYZ平面内をぐるぐる回ることになる。 つまり、端末のZ方向ベクトルが求まれば、さっきと同じ方法で回転行列が求まる。

 で、端末のZ方向だが、matDirは初期位置に置いたデバイス枠を所定の位置に動かす行列である。 座標変換行列は移動後のX方向・Y方向・Z方向を列ベクトルとして順に並べたものだ。 ということは、端末のZ方向はmatDirの第3列として既に求まっていることになる。 先ほどmatDir.column(2)を表示したのはこれを確認するためで、実際にぐるぐる回すと、このベクトルは見事にYZ平面をぐるぐる回る。

 ということで、さっきと同じようにこのベクトルを元に回転行列を組み立てればよい。 今度はrotX系になり、Z軸単位ベクトルを所定の方向へ回すので、rotXuvecZtoである。 実際には所定の単位ベクトルをZ軸まで回すので、Yに負号が付く。

@@ -64,4 +64,12 @@ rotZuvecXto(x, y) {

0, 0, 0, 1

]);

+},

+rotXuvecZto(y, z) {

+ return this.getFactory().create([

+ 1, 0, 0, 0,

+ 0, z, -y, 0,

+ 0, y, z, 0,

+ 0, 0, 0, 1

+ ]);

}

},

@@ -79,5 +87,5 @@ scaled(...args) {

}

});

-[ "rotZuvecXto" ]

+[ "rotZuvecXto", "rotXuvecZto" ]

.forEach(v => Mat4.prototype[v] = function (...args) {

return this.affect(this.factory[v].call(this, ...args));

@@ -190,5 +198,5 @@ $DCL(_ => {

// rotation axis

- const Z = Vec4.create(matAtt.column(2));

+ let Z = Vec4.create(matAtt.column(2));

const A = Vec4.create(-Z.y, Z.x, 0);

if (A.norm() == 0)

@@ -198,4 +206,6 @@ $DCL(_ => {

const matDir = matAtt.clone().rotZuvecXto(A.x, -A.y);

+ Z = Vec4.create(matDir.column(2));

+ matDir.rotXuvecZto(-Z.y, Z.z);

const color = [ 0, 0.8, 0.8 ];

progBorder.use();

@@ -209,5 +219,4 @@ $DCL(_ => {

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

- drawVector(progLine, line, matDir.column(2), matVP, { color: [ 0, 1, 0, 1 ] });

drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

}

@@ -220,5 +229,5 @@ $DCL(_ => {

drawOpaque();

drawTranslucent();

-// gl.disable(gl.DEPTH_TEST);

+ gl.disable(gl.DEPTH_TEST);

drawForefront();

gl.enable(gl.DEPTH_TEST);

 端末が地面と重なるので、深度テストを無効に戻す。 戻さないとZファイティングを起こしてしまう。 Z方向ベクトルもZ方向は真上と分かっているので削除する。

 続いてさっきと逆方向に回転すれば、Y方向が「端末の向き」になる。

@@ -207,5 +207,5 @@ $DCL(_ => {

const matDir = matAtt.clone().rotZuvecXto(A.x, -A.y);

Z = Vec4.create(matDir.column(2));

- matDir.rotXuvecZto(-Z.y, Z.z);

+ matDir.rotXuvecZto(-Z.y, Z.z).rotZuvecXto(A.x, A.y);

const color = [ 0, 0.8, 0.8 ];

progBorder.use();

 さらにwebkitCompassHeadingが示す量だけ時計回りすれば、それが北方向である。 とりあえず北を示す指標を適当に表示する。

@@ -143,4 +143,5 @@ $DCL(_ => {

const ring = prepareRing(gl, 1/128);

const triangleVertices = [ [ -1, 0 ], [ 0, 1 ], [ 1, 0 ] ];

+ const triangleFrame = prepareRoundCapLine(gl, triangleVertices, true);

const trianglePlate = gl.createFloatBuffer(triangleVertices);

let evDev = null;

@@ -218,4 +219,8 @@ $DCL(_ => {

prog.setUniform("uColor", ...color, 0.7);

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

+ progLine.use();

+ progLine.setUniform("uMat:m4", matVP);

+ progLine.setUniform("uColor", ...color, 1);

+ drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

線はラウンドキャップで描いてある。 大きさの調整。

@@ -145,4 +145,5 @@ $DCL(_ => {

const triangleFrame = prepareRoundCapLine(gl, triangleVertices, true);

const trianglePlate = gl.createFloatBuffer(triangleVertices);

+ const matNorth = Mat4.scale(0.1, 0.5).trans(0, 0.5);

let evDev = null;

let matAtt = Mat4.create();

@@ -220,5 +221,5 @@ $DCL(_ => {

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

progLine.use();

- progLine.setUniform("uMat:m4", matVP);

+ progLine.setUniform("uMat:m4", matNorth.affected(matVP));

progLine.setUniform("uColor", ...color, 1);

drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

この鋭角の部分がどうしても「2D空間で一定の幅にする」でうまく描けなかったのが、ラウンドキャップ描画シェーダーを作るきっかけになっている。

 デバイス枠と一緒に回す。

@@ -221,5 +221,5 @@ $DCL(_ => {

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

progLine.use();

- progLine.setUniform("uMat:m4", matNorth.affected(matVP));

+ progLine.setUniform("uMat:m4", matNorth.affected(matDir, matVP));

progLine.setUniform("uColor", ...color, 1);

drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

 webkitCompassHeadingを適用する。 webkitCompassHeadingは度単位なので、普通にrotZで回せばよい。 webkitCompassHeadingがない環境ではとりあえず固定で10度にしてある。

@@ -150,4 +150,5 @@ $DCL(_ => {

let matGrid = Mat4.create();

let matVP = Mat4.create();

+ let matHeading = Mat4.create();

let render;

ready.then((

@@ -221,5 +222,5 @@ $DCL(_ => {

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

progLine.use();

- progLine.setUniform("uMat:m4", matNorth.affected(matDir, matVP));

+ progLine.setUniform("uMat:m4", matNorth.affected(matDir, matHeading, matVP));

progLine.setUniform("uColor", ...color, 1);

drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

@@ -228,6 +229,8 @@ $DCL(_ => {

}

render = VanillaFrameRenderer.create(_ => {

- if (evDev != null)

+ if (evDev != null) {

matAtt = Mat4.rotY(evDev.gamma).rotX(evDev.beta).rotZ(evDev.alpha);

+ matHeading = Mat4.rotZ(evDev?.webkitCompassHeading ?? 10);

+ }

gl.initCanvas();

matVP = Mat4.rotX(-75).affect(matGrid)

端末を下に向けた場合を調べる

 この状態でデバイスをひっくり返してディスプレイが真下近くを向くと、デバイス枠が目まぐるしく回って北どころではなくなる。 この場合は端末Z方向をZ軸正方向ではなく、負方向に合わせればよい。 Z方向が下向き、つまりディスプレイが下を向いている場合について考えんとあかん時がやってきたわけだ。

 下を向けるにはrotXuvecZtoの引数をいじればよい。

 実線(黒)が端末の現在のディスプレイの向き(Z正方向)だとすると、ディスプレイを上に向けたければこれをZ軸プラスの方へ回せばよい。 行列を求める関数にはZ軸正方向の単位ベクトルの回転先を示す必要があるので、単位ベクトルを同じ量だけ回すと破線矢印(青)の位置までくる。 元が\((y, z)\)だったので、この位置は\((-y,z)\)である。 実際、コード上も-Z.y, Z.zになっている。

 今度はZ軸マイナスの方へ回したいので、一点鎖線(赤)のように回す。 現在位置をZ軸負方向まで回転させたのと同じ量だけ、Z軸正方向の単位ベクトルを回すと、一点鎖線矢印(赤)の位置までくるので、\((y, -z)\)になる。 図を見ると分かるとおり、180度反対側まで回せばいい。 何もこんな面倒なことをしなくても、Z軸正方向に回してから、X軸のまわりに180度回せばZ軸負方向に向くだろう。 180度回転すれば数値の上ではY座標・Z座標ともマイナスがつくので、これで合っている。 ムダなことをしたようだが、色々な見方をして正しいかどうかを検証するのは大事である。

 とりあえずmatDirの符号を反対にすればいいことが分かったので試してみる。

@@ -210,6 +210,6 @@ $DCL(_ => {

const matDir = matAtt.clone().rotZuvecXto(A.x, -A.y);

Z = Vec4.create(matDir.column(2));

- matDir.rotXuvecZto(-Z.y, Z.z).rotZuvecXto(A.x, A.y);

- const color = [ 0, 0.8, 0.8 ];

+ matDir.rotXuvecZto(Z.y, -Z.z).rotZuvecXto(A.x, A.y);

+ const color = [ 1, 0.5, 0.5 ];

progBorder.use();

progBorder.setUniform("uMat:m4", matDev.affected(matDir, matVP));

 今度は下向きでは安定していて、上向きで挙動不審になる。 合っているようならコピペして上下両方を表示する。 変数名は上向きがmatDirUp、下向きがmatDirDn

@@ -208,19 +208,37 @@ $DCL(_ => {

A.normalize();

- const matDir = matAtt.clone().rotZuvecXto(A.x, -A.y);

- Z = Vec4.create(matDir.column(2));

- matDir.rotXuvecZto(Z.y, -Z.z).rotZuvecXto(A.x, A.y);

- const color = [ 1, 0.5, 0.5 ];

+ const matDirUp = matAtt.clone().rotZuvecXto(A.x, -A.y);

+ const matDirDn = matDirUp.clone();

+ Z = Vec4.create(matDirUp.column(2));

+ matDirUp.rotXuvecZto(-Z.y, Z.z).rotZuvecXto(A.x, A.y);

+ matDirDn.rotXuvecZto(Z.y, -Z.z).rotZuvecXto(A.x, A.y);

+

+ let color = [ 0, 0.8, 0.8 ];

+ progBorder.use();

+ progBorder.setUniform("uMat:m4", matDev.affected(matDirUp, matVP));

+ progBorder.setUniform("uColor", ...color, 0.5);

+ progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

+ drawPlate(progBorder);

+ prog.use();

+ prog.setUniform("uMat:m4", matHead.affected(matDirUp, matVP));

+ prog.setUniform("uColor", ...color, 0.7);

+ prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

+ progLine.use();

+ progLine.setUniform("uMat:m4", matNorth.affected(matDirUp, matHeading, matVP));

+ progLine.setUniform("uColor", ...color, 1);

+ drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

+

+ color = [ 1, 0.5, 0.5 ];

progBorder.use();

- progBorder.setUniform("uMat:m4", matDev.affected(matDir, matVP));

+ progBorder.setUniform("uMat:m4", matDev.affected(matDirDn, matVP));

progBorder.setUniform("uColor", ...color, 0.5);

progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

drawPlate(progBorder);

prog.use();

- prog.setUniform("uMat:m4", matHead.affected(matDir, matVP));

+ prog.setUniform("uMat:m4", matHead.affected(matDirDn, matVP));

prog.setUniform("uColor", ...color, 0.7);

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

progLine.use();

- progLine.setUniform("uMat:m4", matNorth.affected(matDir, matHeading, matVP));

+ progLine.setUniform("uMat:m4", matNorth.affected(matDirDn, matHeading, matVP));

progLine.setUniform("uColor", ...color, 1);

drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

 Zを求めるところまでは同じなので、最初はmatDirUpを複製してmatDirDnを作っている。 そのあとは単純なコピペだが、「コピペするな」の原則に従い、ループにしておく。

@@ -213,34 +213,22 @@ $DCL(_ => {

matDirUp.rotXuvecZto(-Z.y, Z.z).rotZuvecXto(A.x, A.y);

matDirDn.rotXuvecZto(Z.y, -Z.z).rotZuvecXto(A.x, A.y);

-

- let color = [ 0, 0.8, 0.8 ];

- progBorder.use();

- progBorder.setUniform("uMat:m4", matDev.affected(matDirUp, matVP));

- progBorder.setUniform("uColor", ...color, 0.5);

- progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

- drawPlate(progBorder);

- prog.use();

- prog.setUniform("uMat:m4", matHead.affected(matDirUp, matVP));

- prog.setUniform("uColor", ...color, 0.7);

- prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

- progLine.use();

- progLine.setUniform("uMat:m4", matNorth.affected(matDirUp, matHeading, matVP));

- progLine.setUniform("uColor", ...color, 1);

- drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

-

- color = [ 1, 0.5, 0.5 ];

- progBorder.use();

- progBorder.setUniform("uMat:m4", matDev.affected(matDirDn, matVP));

- progBorder.setUniform("uColor", ...color, 0.5);

- progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

- drawPlate(progBorder);

- prog.use();

- prog.setUniform("uMat:m4", matHead.affected(matDirDn, matVP));

- prog.setUniform("uColor", ...color, 0.7);

- prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

- progLine.use();

- progLine.setUniform("uMat:m4", matNorth.affected(matDirDn, matHeading, matVP));

- progLine.setUniform("uColor", ...color, 1);

- drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

+ for (const v of [

+ { mat: matDirUp, color: [ 0, 0.8, 0.8 ] },

+ { mat: matDirDn, color: [ 1, 0.5, 0.5 ] }

+ ]) {

+ progBorder.use();

+ progBorder.setUniform("uMat:m4", matDev.affected(v.mat, matVP));

+ progBorder.setUniform("uColor", ...v.color, 0.5);

+ progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

+ drawPlate(progBorder);

+ prog.use();

+ prog.setUniform("uMat:m4", matHead.affected(v.mat, matVP));

+ prog.setUniform("uColor", ...v.color, 0.7);

+ prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

+ progLine.use();

+ progLine.setUniform("uMat:m4", matNorth.affected(v.mat, matHeading, matVP));

+ progLine.setUniform("uColor", ...v.color, 1);

+ drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

+ }

drawVector(progLine, line, A, matVP, { color: [ 1, 1, 0, 1 ] });

 あとは実機での上下切り替えの条件を突き止めなければならない。 とりあえず、回転前の端末Z方向ベクトルを表示しておく。

@@ -202,4 +202,9 @@ $DCL(_ => {

// rotation axis

let Z = Vec4.create(matAtt.column(2));

+ const upsideDown = Z.z < -Math.sqrt(0.5);

+ for (const v of [ "x", "y", "z" ]) {

+ const ez = $ID(`z_${v}`);

+ ez.innerText = numfmt3(Z[v]);

+ }

const A = Vec4.create(-Z.y, Z.x, 0);

if (A.norm() == 0)

@@ -275,8 +280,8 @@ p:has(.device-orientation-enabled) {

display: none;

}

-[data-num-id] {

+[data-num-id], [id^=z_] {

font-family: "azeret mono", monospace;

}

-[data-num-id] {

+[data-num-id], [id^=z_] {

white-space: pre;

}

@@ -295,4 +300,7 @@ p:has(.device-orientation-enabled) {

<tr><td>gamma<td data-num-id="gamma">

<tr><td>heading<td data-num-id="webkitCompassHeading">

+<tr><td>Z.x<td id="z_x">

+<tr><td>Z.y<td id="z_y">

+<tr><td>Z.z<td id="z_z">

</table>

</div>

ここでもVec4のゲッターをなんとなく便利に使っている。 で、実機を振り振り確認してみると、Z方向が地面下の半分までくると切り替わるようだと分かる。 つまり、\(z<-\sqrt{1/2}\)の時に切り替えればいいようだ。

@@ -203,8 +203,7 @@ $DCL(_ => {

let Z = Vec4.create(matAtt.column(2));

const upsideDown = Z.z < -Math.sqrt(0.5);

- for (const v of [ "x", "y", "z" ]) {

- const ez = $ID(`z_${v}`);

- ez.innerText = numfmt3(Z[v]);

- }

+ const ez = $ID("z_z");

+ ez.innerText = numfmt3(Z.z);

+ ez.classList[upsideDown ? "add" : "remove"]("upside-down");

const A = Vec4.create(-Z.y, Z.x, 0);

if (A.norm() == 0)

@@ -219,19 +218,20 @@ $DCL(_ => {

matDirDn.rotXuvecZto(Z.y, -Z.z).rotZuvecXto(A.x, A.y);

for (const v of [

- { mat: matDirUp, color: [ 0, 0.8, 0.8 ] },

- { mat: matDirDn, color: [ 1, 0.5, 0.5 ] }

+ { mat: matDirUp, color: [ 0, 0.8, 0.8 ], alpha: [ 1, 0.4 ] },

+ { mat: matDirDn, color: [ 1, 0.5, 0.5 ], alpha: [ 0.5, 1 ] }

]) {

+ const a = v.alpha[ upsideDown ? 1 : 0 ];

progBorder.use();

progBorder.setUniform("uMat:m4", matDev.affected(v.mat, matVP));

- progBorder.setUniform("uColor", ...v.color, 0.5);

+ progBorder.setUniform("uColor", ...v.color, 0.5 * a);

progBorder.setUniform("uBorder", ...devBorder, ...devRadius);

drawPlate(progBorder);

prog.use();

prog.setUniform("uMat:m4", matHead.affected(v.mat, matVP));

- prog.setUniform("uColor", ...v.color, 0.7);

+ prog.setUniform("uColor", ...v.color, 0.7 * a);

prog.drawArrays("aPos:f2", trianglePlate, gl.TRIANGLE_STRIP);

progLine.use();

progLine.setUniform("uMat:m4", matNorth.affected(v.mat, matHeading, matVP));

- progLine.setUniform("uColor", ...v.color, 1);

+ progLine.setUniform("uColor", ...v.color, a);

drawRoundCapLine(progLine, triangleFrame, [ "min", 10 ], "f2");

}

@@ -280,10 +280,18 @@ p:has(.device-orientation-enabled) {

display: none;

}

-[data-num-id], [id^=z_] {

+[data-num-id], #z_z {

font-family: "azeret mono", monospace;

}

-[data-num-id], [id^=z_] {

+[data-num-id], #z_z {

white-space: pre;

}

+#z_z {

+ color: #0ff;

+ background-color: #0884;

+}

+#z_z.upside-down {

+ color: #200;

+ background-color: #f88c;

+}

[data-id] {

text-align: center;

@@ -300,6 +308,4 @@ p:has(.device-orientation-enabled) {

<tr><td>gamma<td data-num-id="gamma">

<tr><td>heading<td data-num-id="webkitCompassHeading">

-<tr><td>Z.x<td id="z_x">

-<tr><td>Z.y<td id="z_y">

<tr><td>Z.z<td id="z_z">

</table>

 表示をZ.zだけにして、Z.zの値でアルファを変更する。 CSSのクラスを更新することろでちょっとヒドいことをしている。 たとえばupsideDowntrueならaddList["add"]になるが、これはaddList.addと同じである。 JavaScriptのメンバアクセスは関数もプロパティも区別がないので、こんな黒魔術ができる。 数値表示の方も反転表示して色を変えている。

 これで冒頭のコードになったはずである。 最初の目的は端末の姿勢を変えても、北の表示上の方向は変化しないことを確かめることだった。 iOS端末で確認してみると、ぐるぐる回しても北を示す中抜きの三角形は動かないので、これで合っているようだ。 切り替え点ではZ軸正負が切り替わると同時にwebkitCompassHeadingも切り替わり、正しい北の方向が維持される様子が分かる。 実際には切り替え点をゆっくり通過すると、JavaScriptが思っている状態と端末が考えている状態が食い違う瞬間があり、北がピクピク切り替わることがあるが、これは後で対策を考える。

 他にもいくつかあるのだが、それは次回に。

※次回は本当の北はこっち!!の予定


09 Sep 2024: applyaffectに変更

31 Aug 2024: 本編

20 Aug 2024: 予告編

Copyright (C) 2024 akamoz.jp