行列をもう少し極める

 前回は北はどっち?でiOSで方位をどう決定するかを実験した。 しばらく実例がないが、飽きたわけじゃない(たぶん)。 この後のデモで色々と必要になってくるので、とりあえず一般的に通用する話は先に説明しておくことにした。

 まずは行列のリファクタリングというかまとめというか。 全体がだいぶ巨大になってしまったので、とりあえず機能ごとにブツ切りに説明する。 全体のコードを見たければ vanilla-mat2.js で見られる。 予告なく更新するかもしれない。 気が向いたらGitHubにでも上げるかもしれない(たぶんやらないと思うけど)。

コンストラクタまわり

 クラスの作成には基本的に プロトタイプによる継承 / new・create兼用buildClassを使っている。 ただし、互換性のためにinitWithHooktakeoverの別名として追加してある。

VectorFromArrayミックスイン

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次正方行列としての設定をしている。 一応、rowscolsも設定していて、将来的には他の次数の行列にも使えるかもしれない(実は中途半端に一部対応している)。 要素が16よりも多いと16に切り詰められる。 少ない場合は16に増やされ、歯抜けの部分は0で埋められる。 forEachと違い、keysは歯抜けの要素も列挙される。

 clonecreateを呼び、initMeを呼び出す。 initMecreatetakeoverinitinitMeの経路で既に呼ばれているが、createVanillaMat4のインスタンスを引数に指定しても、それは単なる配列として扱うので、VanillaMat4クラスに現在設定されている値での初期化になる。

 これに対し、cloneinitMeの呼び出しでは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のような書式になる。

 配列をそのまま表示すると行列の同じ列が横に並ぶが、それでは見にくいので行列の同じ行が横に並ぶようにしてある(普通の行列の記法と同じ)。

 logthistoStringしてログに流すだけだが、this を返すので、演算チェーンに挟むことができる。

mat.scale(2).trans(1, 1).log().rotZ(90);

とすれば、trans直後の値をログに出力して、何食わぬ顔でrotZの計算が行われる。

三角関数

TrigonometricFunctionsミックスイン

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という関数を通してから三角関数を求めている。 toRadiandeg2radに置き換えてしまえば度単位で引数を渡すことができる。 デフォルトでは引数がそのまま返ってくるのでラジアン単位である。 これでthis.sin(x)などと書けば、設定した単位で三角関数の計算が行われる。

toRadianスタック

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はふたつあることになる。つまり、クラスとインスタンスで別々に変換を指定できる。 インスタンスの初期値は現在クラスに設定されている変換関数になり、その後、クラスの変換関数を再設定しても既に生成したインスタンスに影響はなく、生成したインスタンスの変換関数を再設定しても他のインスタンスやクラスに影響はない。

 setToRadianTrigonometricFunctionsミックスインのtoRadianを設定する。 引数には関数のほか、何かtoRadianを持っているオブジェクトも指定可能になっていて、これはVanillaMat4やそのインスタンスを想定している。 nullだった場合は恒等変換、つまりラジアン単位に戻る。

 pushToRadianは現在設定されているtoRadiantoRadianStackに退避してから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は指定された列を抜き出し、新しい配列として返す。

 putthisを返すので、演算チェーンの中にズラズラとつなげて書ける。

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

 affectは引数で指定した行列ひとつを自分自身に左から乗じて自分自身を更新し、自分自身を返す。 つまり、引数に書いた順序と演算順序は逆になる。 あとで説明するprodとは逆で、PostScriptとも逆である。 PostScriptが座標を変換してから図形を書くのに対し、GLの場合はすでにあるモデルの頂点座標を変換することが多い。 \(M_2\cdot M_1\cdot\bvec{x}\)の順序で乗じて頂点を変換していくため、後から指定した方が左側にくる。 この方が都合がいい場合が多い。

 自分自身を返すので、演算チェーンに混ぜて書ける。 自分自身を更新してしまうので、値を取っておきたい場合は一度 cloneする必要がある。

mat.clone().affect(mat2);

とすれば、mat.clone()が返したオブジェクトに対して演算が行われ、最終的な結果もクローンしたオブジェクトになる。

 4次正方行列以外にも対応しているが、このために行数と列数を得る必要があり、引数に渡す行列も単なる配列ではダメで、VanillaMat4のインスタンスでなければならない。

クラスメソッドprod

 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の対象となっている配列にまだ説明していない関数名が含まれるが、これはこのあと説明する。

trans(x, y, z)
 X軸方向にx、Y軸方向にy、Z軸方向にzだけ移動する変換行列を返す。 各引数のデフォルトは0である。 つまり、すべて省略すると単位行列を返す。
scale(x, y, z)
 X軸方向にx倍、Y軸方向にy倍、Z軸方向にz倍する変換行列を返す。 引数がない(あるいは未定義の)場合、X・Y・Zとも1倍の変換行列、つまり単位行列を返す。 xだけ指定されている場合はすべての軸についてx倍する行列を返す。 zだけが指定されていない場合はZ軸方向は1倍になる。 それ以外の場合は例外を放り投げる。
rotX(ang)
 X軸の周りにangだけ回転する変換行列を返す。 angの単位はthis.toRadian関数で決まり、デフォルトはインスタンスを生成した時のVanillaMat4.toRadianと同じ値になる。 VanillaMat4.toRadianのデフォルトは恒等変換であり、これはラジアン単位を意味する。
rotY(ang)
 Y軸の周りにangだけ回転する変換行列を返す。 角度の単位はrotXと同じ。
rotZ(ang)
 Z軸の周りにangだけ回転する変換行列を返す。 角度の単位はrotXと同じ。

投影変換関数

 今まで散々逃げ回っていたが、このあと深度テストを使う予定なので、深度込みの投影変換行列を求めておく。 準備としてふたつの変換行列を作る。

aspect

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の場合、

となる行列を返す。

 静的メソッドしか書いていないが、実際には基本変換関数で説明したとおり、機械的にインスタンスメソッドも定義されている。

 aspectFactorのモード引数にthis.aspectModeを渡しているが、aspect関数が静的メソッドとして使われた場合は、クラスの静的メンバーaspectModeになる。 これはinitのときにインスタンスメンバーaspectModeの初期値として設定され、また、cloneのときはclone元のインスタンスからコピーされる。 aspect関数がインスタンスメソッドとして使われた場合はこちらのaspectModeが使われる。

depth

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\)がfarの時の結果が最終結果と合うようにスケーリングし、nearに相当する点を最終結果と合う位置に動かす。 最初にnear点を原点に持っていくので、スケーリングしてもnear点に影響がないのがミソである。

 入力が-nearから-farのときに、最終結果は-nearからfarなので、両方ともnear点が0になるように動かすと、入力がnear-farのときに出力がnear+farになればよい。 つまり、入力をnear-farで割って、それをnear+far倍すれば所望の出力になるだろう。

 これをガチャっと組み合わせるとコードのようになる。

 インスタンスメソッドの定義が書いてないがインスタンスメソッドも存在する。 最初のtransのときにthisを設定し直しているが、この関数がインスタンスメソッドとして呼び出されたときに、呼び出しの対象となったインスタンスのtoRadianaspectModeを引き継ぐためである。

透視投影変換行列

 ラスボスだが、きちんと準備してきたので意外とあっさり片付く。

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\)になるように仕向ける。 putthisを返すため、この例のようにズラズラとつなげて書ける。

 最初のdepththisを設定し直しているのは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: applyaffectに変更

15 Aug 2024: 初稿

Copyright (C) 2024 akamoz.jp