前回は北はどっち?でiOSで方位をどう決定するかを実験した。 しばらく実例がないが、飽きたわけじゃない(たぶん)。 この後のデモで色々と必要になってくるので、とりあえず一般的に通用する話は先に説明しておくことにした。
まずは行列のリファクタリングというかまとめというか。
全体がだいぶ巨大になってしまったので、とりあえず機能ごとにブツ切りに説明する。
全体のコードを見たければ
vanilla-mat2.js
で見られる。
予告なく更新するかもしれない。
気が向いたらGitHubにでも上げるかもしれない(たぶんやらないと思うけど)。
クラスの作成には基本的に
プロトタイプによる継承
/
new・create兼用
のbuildClass
を使っている。
ただし、互換性のためにinitWithHook
をtakeover
の別名として追加してある。
const VectorFromArray = { fromArray(...args) { if (args.length == 0) return null; if (args.length == 1 && Array.isArray(args[0])) return args[0].slice(); // make copy return Array(...args); // create new one } };
ほぼ元々VanillaMat4
あったコード。
fromArray
に引数がひとつだけ指定されており、それが配列に見えるならば、配列のコピーを作成する。
配列に見えなかったり、引数がふたつ以上指定されている場合は、それをそのままArray
コンストラクタに渡す。
どちらの場合も戻り値は配列である。
このあとコンストラクタ側でtakeover
してプロトタイプをつなげる。
引数がなかった場合は戻り値がnull
になるので、コンストラクタ側で初期値を設定する。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- ...VectorFromArray, create(...args) { const me = this.fromArray(...args) ?? [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]; return this.takeover(me, ...args); }, ... }, //===== instance members ===== init() { this.length = 16; this.rows = 4; this.cols = 4; for (const k of this.keys()) this[k] ??= 0; this.toRadianStack = []; this.initMe(this.factory); }, initMe(from) { this.toRadian = from.toRadian; this.aspectMode = from.aspectMode; return this; }, clone() { return this.factory.create(this).initMe(this); }, ... return cls; })();
create
はだいたいVectorFromArray
ミックスインで説明したとおり。
引数がない場合は単位行列になる。
takeover
を呼び出すと最終的にinit
が呼び出されるが、4次正方行列としての設定をしている。
一応、rows
やcols
も設定していて、将来的には他の次数の行列にも使えるかもしれない(実は中途半端に一部対応している)。
要素が16よりも多いと16に切り詰められる。
少ない場合は16に増やされ、歯抜けの部分は0で埋められる。
forEach
と違い、keys
は歯抜けの要素も列挙される。
clone
はcreate
を呼び、initMe
を呼び出す。
initMe
はcreate
→takeover
→init
→initMe
の経路で既に呼ばれているが、create
はVanillaMat4
のインスタンスを引数に指定しても、それは単なる配列として扱うので、VanillaMat4
クラスに現在設定されている値での初期化になる。
これに対し、clone
のinitMe
の呼び出しではthis
に設定されている値で初期化するので、clone
の呼び出しに使ったオブジェクトの値がコピーされる。
文字どおりクローンである。
this.factory
というのが出てくるが、これがbuildClass
の戻り値になっていてVanillaMat4
に設定されるので、要するにVanilaMat4
クラスそれ自身である。
実際、そう書き換えても動くが、継承されると動作が違ってくる(派生クラス側のファクトリを指すことになる)。
toRadian
まわりは後述。
aspectMode
まわりは射影変換周りで使うが、すごくあとで説明する。
また、即時実行関数になっている理由もあとで分かる。
function gennumfmt(w, frac) { const opt = { minimumFractionDigits: frac, maximumFractionDigits: frac } const formatter = new Intl.NumberFormat(undefined, opt); return (...args) => formatter.format(...args).padStart(w); } const VanillaMat4 = (function () { const cls = buildClass(Array, { ... //===== instance members ===== toString() { const f = gennumfmt(8, 3); const result = []; for (let row = 0; row < this.rows; row++) { const s = []; for (let col = 0; col < this.cols; col++) s.push(f(this.get(row, col))); result.push(s.join(" ")) } return result.join("\n"); }, log() { console.log(this.toString()); return this; },
便利なので(というかないとデバッグに不便なので)toString
も作っておいた。
gennumfmt
は数値をフォーマットしてくれる関数を作って返してくれる(変換そのものをしてくれるわけじゃない)。
最終的にはvanilla-mat2.js
ではなくvanilla.js
の方に入る。
w
は総文字数、frac
は小数点より下の桁数。
ちょうどprintf
の%w.fracf
のような書式になる。
配列をそのまま表示すると行列の同じ列が横に並ぶが、それでは見にくいので行列の同じ行が横に並ぶようにしてある(普通の行列の記法と同じ)。
log
はthis
をtoString
してログに流すだけだが、this
を返すので、演算チェーンに挟むことができる。
mat.scale(2).trans(1, 1).log().rotZ(90);
とすれば、trans
直後の値をログに出力して、何食わぬ顔でrotZ
の計算が行われる。
const TrigonometricFunctions = { toRadian(ang) { return ang; }, sin(ang) { return Math.sin(this.toRadian(ang)); }, cos(ang) { return Math.cos(this.toRadian(ang)); }, tan(ang) { return Math.tan(this.toRadian(ang)); } };
toRadian
という関数を通してから三角関数を求めている。
toRadian
をdeg2rad
に置き換えてしまえば度単位で引数を渡すことができる。
デフォルトでは引数がそのまま返ってくるのでラジアン単位である。
これでthis.sin(x)
などと書けば、設定した単位で三角関数の計算が行われる。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- ...TrigonometricFunctions, toRadianStack: [], setToRadian(f) { // f is null, function, class, or instance if (f == null) f = x => x; else if (f.toRadian != null) f = f.toRadian; else if (typeof f == "function") ; // nothing needed else throw "VanillaMat4.setToRadian: the object doesn't have toRadian, nor is function"; this.toRadian = f; return this; }, pushToRadian(f) { this.toRadianStack.push(this.toRadian); return this.setToRadian(f); }, popToRadian() { return this.setToRadian(this.toRadianStack.pop()); }, ... }, //===== instance members ===== ...TrigonometricFunctions, setToRadian(f) { return this.factory.setToRadian.call(this, f); }, pushToRadian(f) { return this.factory.pushToRadian.call(this, f); }, popToRadian(f) { return this.factory.popToRadian.call(this); },
TrigonometricFunctions
ミックスインが2回取り込まれているが、片方はVanillaMat4
クラスの静的メソッド用、もう一方はインスタンスメソッド用である。
toRadian
はふたつあることになる。つまり、クラスとインスタンスで別々に変換を指定できる。
インスタンスの初期値は現在クラスに設定されている変換関数になり、その後、クラスの変換関数を再設定しても既に生成したインスタンスに影響はなく、生成したインスタンスの変換関数を再設定しても他のインスタンスやクラスに影響はない。
setToRadian
はTrigonometricFunctions
ミックスインのtoRadian
を設定する。
引数には関数のほか、何かtoRadian
を持っているオブジェクトも指定可能になっていて、これはVanillaMat4
やそのインスタンスを想定している。
null
だった場合は恒等変換、つまりラジアン単位に戻る。
pushToRadian
は現在設定されているtoRadian
をtoRadianStack
に退避してからtoRadian
を指定された関数に指定する。
popToRadian
はスタックからひとつ前のtoRadian
を取り出して設定する。
VanillaMat4.setToRadian(deg2rad); ... function foo(deg, rad) { return mat.rotX(deg).pushToRadian().rotZ(rad).popToRadian(); }
のような使い方が可能になる。
この3つの関数はインスタンスメソッド版もあり、インスタンスメソッドが静的メソッドを呼び出しているが、静的メソッド内ではクラスソレ自身を表現するのにVanillaMat4
ではなくthis
を使っているため、this
を置き換えてしまえばそのままインスタンスメソッドとして使える。
また、あとでVanillaMat4
という名前を変更したとしても、VanillaMat4
と書いてある部分はまったくないので、修正は冒頭の変数名を変えるだけである。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { ... }, //===== instance members ===== index(row, col) { return row + col * this.rows; }, get(row, col) { return this[this.index(row, col)]; }, put(row, col, val) { this[this.index(row, col)] = val; return this; }, column(col) { const from = col * this.rows; return this.slice(from, from + this.rows); },
行・列はすべて0オリジンである。 数学の世界では行と列は1オリジンのことが多いので注意。
index
は行・列番号から配列のインデックスを求める。
get
は指定された行・列の値を取り出す。
put
は指定された行・列に値を設定する。
column
は指定された列を抜き出し、新しい配列として返す。
put
はthis
を返すので、演算チェーンの中にズラズラとつなげて書ける。
mat.put(0, 0, 1).put(1, 1, 1).put(2, 2, 1).put(3, 3, 1);
他の要素がオールゼロだったとすれば、これで単位行列が作れる。
なお、index
は昔はクラスメソッド(静的メソッド)だった。
4次正方行列決め打ちで、インスタンスを参照する必要がなかったからである。
今は4次正方行列以外のものにも対応できるようにthis.rows
を参照しているので、インスタンスメソッドにする必要がある。
なんかこれをちょっと思い出した。
C++だとテンプレートで実装した方がいいかもしれないな。 JavaScriptでも行列クラスビルダーみたいなのを作ればテンプレートっぽくなるけど、作ったクラスを管理しておかないといけないし、C++のようn(以下略
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- prod(x, ...y) { if (!(x instanceof this)) x = this.create(x); if (y.length < 1) return x.clone(); return this.prod(...y).affect(x); }, ... }, //===== instance members ===== // x * this => this affect(x) { if (x.cols != this.rows) throw "cannot make matrix product"; const y = this.clone(); this.rows = x.rows; this.length = this.rows * this.cols; for (let c = 0; c < y.cols; c++) { const col = y.column(c); for (let r = 0; r < x.rows; r++) this.put(r, c, col.reduce((s, v, k) => s + v * x.get(r, k), 0)); } return this; },
以前は静的メソッドで実際の計算を実行していが、今回はインスタンスメソッドで実際の計算を実行しているので、affect
を先に説明する。
affect
は引数で指定した行列ひとつを自分自身に左から乗じて自分自身を更新し、自分自身を返す。
つまり、引数に書いた順序と演算順序は逆になる。
あとで説明するprod
とは逆で、PostScriptとも逆である。
PostScriptが座標を変換してから図形を書くのに対し、GLの場合はすでにあるモデルの頂点座標を変換することが多い。
\(M_2\cdot M_1\cdot\bvec{x}\)の順序で乗じて頂点を変換していくため、後から指定した方が左側にくる。
この方が都合がいい場合が多い。
自分自身を返すので、演算チェーンに混ぜて書ける。
自分自身を更新してしまうので、値を取っておきたい場合は一度 clone
する必要がある。
mat.clone().affect(mat2);
とすれば、mat.clone()
が返したオブジェクトに対して演算が行われ、最終的な結果もクローンしたオブジェクトになる。
4次正方行列以外にも対応しているが、このために行数と列数を得る必要があり、引数に渡す行列も単なる配列ではダメで、VanillaMat4
のインスタンスでなければならない。
prod
は引数に複数の配列もしくは行列を指定できて、それを左から順に(数式と同じ順序で)積を計算し、結果を新しい配列に返す。
再帰呼び出しで引数を前から剥がしていき、ひとつになったらcreate
で複製して、そこに次々とaffect
しながら戻っていく。
複製はcreate
なので、toRadian
などはクラスのものが設定される。
affect
が4次正方行列以外にも対応しているので、prod
も4次正方行列以外の計算ができるが
、そのためには引数はVanillaMat4
のインスタンスでなければならない。
しかし、prod
は以前からある関数で、その時は配列を引数として使えたため、互換性を維持するためにVanillaMat4
ではないものが混ざっていた場合はcreate
で複製される。
つまり、強制的に4次正方行列として扱われる。
以前はprod(x)
と呼び出した場合はx
をそのまま返していたが、引数がひとつだと必ずcreate
される関係で、引数がひとつの場合でもそれをcreate
した新しい配列が返ってくる。
こちらは互換性を維持していないが、prod
を1引数で呼び出すことはまずないので問題はないだろう。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- getFactory() { return this; }, trans(x = 0, y = 0, z = 0) { return this.getFactory().create( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1 ); }, scale(x, y, z) { if (x == null) { if (y != null || z != null) throw "VanillaMat4.scale: x is null"; x = y = z = 1; } else if (y == null) { if (z != null) throw "VanillaMat4.scale: y is null"; y = z = x; } else if (z == null) z = 1; return this.getFactory().create( x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1 ); }, rotX(ang) { return this.getFactory().create( 1, 0, 0, 0, 0, this.cos(ang), this.sin(ang), 0, 0, -this.sin(ang), this.cos(ang), 0, 0, 0, 0, 1 ); }, rotY(ang) { return this.getFactory().create( this.cos(ang), 0, -this.sin(ang), 0, 0, 1, 0, 0, this.sin(ang), 0, this.cos(ang), 0, 0, 0, 0, 1 ); }, rotZ(ang) { return this.getFactory().create( this.cos(ang), this.sin(ang), 0, 0, -this.sin(ang), this.cos(ang), 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); }, ... }, //===== instance members ===== getFactory() { return this.factory; }, ... }); // end of buildClass [ "trans", "scale", "rotX", "rotY", "rotZ", "aspect", "depth", "pers" ] .forEach(v => cls.prototype[v] = function (...args) { return this.affect(this.factory[v].call(this, ...args)); });
静的メソッドで演算を定義し、インスタンスメソッドは静的メソッドを使って機械的に実装されている。
toRadian
の関係で、インスタンスメソッドとして呼んだ場合、三角関数はインスタンスのものを使わなければならない。
そうすると静的メソッド内でクラス自身のことをthis
と書けない。
このため、クラス自身を得るためにgetFactory
という関数を作っている。
静的メソッドから呼んだ場合は自分自身、つまりVanillaMat4
クラスそれ自身を返す。
インスタンスメソッドの場合はthis.factory
を返す。
これはbuildClas
が作ったcreate
関数が自動的に設定していて、create
の呼び出しに使われたクラスオブジェクトを指している。
これで静的メソッド・インスタンスメソッドどちらから呼ばれても、this.getFactory().create
で行列を生成できる。
getFactory
は単なるプロパティにすることもできるが、インスタンスメソッドの場合はinit
で設定可能なのに対し、静的メソッドの定義時にはthis
は使えないので関数にしてある。
インスタンスメソッドはforEach
で定義していて、ここはもうbuildClass
の外なので、buildClass
の結果を一度変数cls
に入れて処理している。
このcls
を隠すために即時実行関数になっている。
forEach
の中身は、v
が"trans"
ならば以下のようになる。
cls.prototype["trans"] = function (...args) { return this.affect(this.factory["trans"].call(this, ...args)); });
JavaScriptではobj["key"]
はobj.key
と同じ効果になる(もちろんkey
は有効なJavaScriptの識別子でなければならない)ので、
cls.prototype.trans = function (...args) { return this.affect(this.factory.trans.call(this, ...args)); });
と同じ意味である。
cls.prototype
にはbuildClass
に渡したオブジェクトが設定されているので、buildClass
の中でこんな風にズラズラと書いたのと同じ結果になる。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { ... }, //===== instance members ===== trans(...args) { return this.affect(this.factory.trans.call(this, ...args)); }, ... }); // end of buildClass
forEach
の対象となっている配列にまだ説明していない関数名が含まれるが、これはこのあと説明する。
x
、Y軸方向にy
、Z軸方向にz
だけ移動する変換行列を返す。
各引数のデフォルトは0である。
つまり、すべて省略すると単位行列を返す。
x
倍、Y軸方向にy
倍、Z軸方向にz
倍する変換行列を返す。
引数がない(あるいは未定義の)場合、X・Y・Zとも1倍の変換行列、つまり単位行列を返す。
x
だけ指定されている場合はすべての軸についてx
倍する行列を返す。
z
だけが指定されていない場合はZ軸方向は1倍になる。
それ以外の場合は例外を放り投げる。
ang
だけ回転する変換行列を返す。
ang
の単位はthis.toRadian
関数で決まり、デフォルトはインスタンスを生成した時のVanillaMat4.toRadian
と同じ値になる。
VanillaMat4.toRadian
のデフォルトは恒等変換であり、これはラジアン単位を意味する。
ang
だけ回転する変換行列を返す。
角度の単位はrotX
と同じ。
ang
だけ回転する変換行列を返す。
角度の単位はrotX
と同じ。
今まで散々逃げ回っていたが、このあと深度テストを使う予定なので、深度込みの投影変換行列を求めておく。 準備としてふたつの変換行列を作る。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- aspectMode: "diag", aspect(w, h, mode) { const mag = aspectFactor(w, h, mode ?? this.aspectMode); return this.getFactory().create( mag/w, 0, 0, 0, 0, mag/h, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); },
アスペクト比を調整する行列を返す。
幅がw
、高さがh
である座標系を、幅・高さが1:1になるような変換行列を返す。
mode
はどこを基準にするかを示す。
関数aspectFactor
は
太さのある線、再び
に出てきており、mode
でアスペクト比の基準(倍率が1になる方向)を指定する。
ただし、abs
は使用できない。
たとえば、w
が400、h
が300の場合、
mode
が"horz"
の場合、X方向の倍率が1、Y方向の倍率が4/3
mode
が"vert"
の場合、X方向の倍率が3/4、Y方向の倍率が1
mode
が"diag"
の場合、X方向の倍率が5/4、Y方向の倍率が5/3
となる行列を返す。
静的メソッドしか書いていないが、実際には基本変換関数で説明したとおり、機械的にインスタンスメソッドも定義されている。
aspectFactor
のモード引数にthis.aspectMode
を渡しているが、aspect
関数が静的メソッドとして使われた場合は、クラスの静的メンバーaspectMode
になる。
これはinit
のときにインスタンスメンバーaspectMode
の初期値として設定され、また、clone
のときはclone
元のインスタンスからコピーされる。
aspect
関数がインスタンスメソッドとして使われた場合はこちらのaspectMode
が使われる。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- depth(near, far) { return this.getFactory() .trans.call(this, 0, 0, near) .scale(1, 1, (near + far) / (near - far)) .trans(0, 0, -near); },
Z座標から深度値を計算する行列を返す。
Z座標が視点から奥に向かってnear
の距離の時に\(z/w\)が-1、far
の時に\(z/w\)が1となるような行列を返す。
サラっと説明を書いたが、この説明はだいぶごまかしを含んでいるのでもう少し正確に説明する。
near
の距離の時、\(z\)は-near
である
-near
になる
-far
のとき、\(Z\)はfar
になる
で、このあと方程式を解いているページが多いが、移動とスケーリングだけで計算できる。
方針は、near
に相当する点を原点に持ってきて、\(z\)がfar
の時の結果が最終結果と合うようにスケーリングし、near
に相当する点を最終結果と合う位置に動かす。
最初にnear
点を原点に持っていくので、スケーリングしてもnear
点に影響がないのがミソである。
入力が-near
から-far
のときに、最終結果は-near
からfar
なので、両方ともnear
点が0になるように動かすと、入力がnear-far
のときに出力がnear+far
になればよい。
つまり、入力をnear-far
で割って、それをnear+far
倍すれば所望の出力になるだろう。
これをガチャっと組み合わせるとコードのようになる。
-near
の場合
trans
で0になる
scale
は影響がない
trans
で-near
になる
near
なので、最終結果は-1になる
-far
の場合
trans
でnear-far
になる
scale
でまずnear-far
で割られて1に、さらにnear+far
倍されてnear+far
になる
trans
でnear
が引かれてfar
になる
far
なので、最終結果は+1になる
インスタンスメソッドの定義が書いてないがインスタンスメソッドも存在する。
最初のtrans
のときにthis
を設定し直しているが、この関数がインスタンスメソッドとして呼び出されたときに、呼び出しの対象となったインスタンスのtoRadian
とaspectMode
を引き継ぐためである。
ラスボスだが、きちんと準備してきたので意外とあっさり片付く。
const VanillaMat4 = (function () { const cls = buildClass(Array, { factory: { //----- static members ----- pers(w, h, fov, near, far, mode) { const scale = 1 / this.tan(fov/2); return this.getFactory() .depth.call(this, near, far) .scale(scale, scale) .aspect(w, h, mode) .put(3, 2, -1) // z -> w .put(3, 3, 0); // w -> w }, pers0(w, h, fov, mode) { return this.getFactory() .pers.call(this, w, h, fov, 1, 0, mode) .put(2, 2, 0); // z -> z }, persY0(w, h, fovy) { return this.getFactory() .pers0.call(this, w, h, fovy, "vert"); }
最初のtanについては視点移動・透視投影を参照。
このページでは視野角の基準は縦方向固定だったが、ここでは先に拡大率だけを求め、aspect
で適用する軸を決めている。
先のページで「Y座標」と書いてあるところを「基準座標」と読みかえ、「X座標」を「その他の座標」と読み替えればよい。
その他の座標をaspect
が一気に引き受けている。
scale
はXとYだけ、depth
はZだけに影響があるため、この両者の順序は入れ替えても構わない。
しかし、aspect
を適用すると横と縦で異なる縮尺になるため、拡大縮小や回転はそれより前に済ませる必要があり、aspect
は拡大縮小・回転を済ませた後にする必要がある。
最後に行列を少し書き換えて、出力の\(W\)が入力の\(z\)を使って\(-z\)になるように仕向ける。
put
がthis
を返すため、この例のようにズラズラとつなげて書ける。
最初のdepth
でthis
を設定し直しているのはdepth
関数と同じ理由。
互換性のためにpersY0
も実装しておく。
その前に深度テストをやらない場合にやはり便利なので、pers0
も実装しておく。
persY0
は基準軸がY軸固定で、Z出力が0固定になるのであった。
基準軸を選べるようにしたものがpers0
である。
pers0
を実装して、mode
に"vert"
を指定してやればpersY0
になる。
20 Aug 2024
ここではZ方向の移動量が0になるようにpers
を呼んで、そのあとでZ方向の倍率を強制的に0にして実現している。
pers
の移動量はdepth
の移動量で決まるので、これを0にしなければならない。
depth
は最初にnear
だけ動かして、最後に-near
だけ動かすので、間に挟まったscale
成分が1ならば前後のtrans
が打ち消しあって移動量0になる。
far
を0にすればnear
に関係なくscale
成分が1になることが分かるだろう。
near
を0にするとscale
の分母が0になって計算できなくなるので、0以外にする必要がある。
0以外ならば極端な値でない限りなんでもよい。
ここでは1にしている。
scale
成分は1で残るので、それをput(2, 2, 0)
で強制的に0にしている。
これで大体の計算には不自由しないはずだが、ビュー変換行列だけ結局実装してない。 実は最終的な用途がちょっと特殊なので、最後まで多分出てこない。
次回はフラグメントシェーダー芸の予定
09 Sep 2024: apply
をaffect
に変更
15 Aug 2024: 初稿
Copyright (C) 2024 akamoz.jp