行列クラスを作成する

トランスフォーム情報を扱えるようにするために、まずは行列クラスが必要です。4行4列のMatrix4クラスを作成しましょう。

Matrix4クラスを作成する。

src/mathディレクトリ以下にMatrix4.tsファイルを作成し、以下のように記述してください。ちょっと長いです。

import { Vector3 } from "./Vector3.js"; import { Vector4 } from "./Vector4.js"; export class Matrix4 { private v: Float32Array; constructor( m00: number, m01: number, m02: number, m03: number, m10: number, m11: number, m12: number, m13: number, m20: number, m21: number, m22: number, m23: number, m30: number, m31: number, m32: number, m33: number ) { this.v = new Float32Array([ m00, m10, m20, m30, m01, m11, m21, m31, m02, m12, m22, m32, m03, m13, m23, m33 ]); } get raw() { return this.v; } static identity() { return new Matrix4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); } static translation(vec: Vector3 | Vector4) { return new Matrix4( 1, 0, 0, vec.x, 0, 1, 0, vec.y, 0, 0, 1, vec.z, 0, 0, 0, 1 ); } static rotationX(angle: number) { const sin = Math.sin(angle); const cos = Math.cos(angle); return new Matrix4( 1, 0, 0, 0, 0, cos, -sin, 0, 0, sin, cos, 0, 0, 0, 0, 1 ); } static rotationY(angle: number) { const sin = Math.sin(angle); const cos = Math.cos(angle); return new Matrix4( cos, 0, sin, 0, 0, 1, 0, 0, -sin, 0, cos, 0, 0, 0, 0, 1 ); } static rotationZ(angle: number) { const sin = Math.sin(angle); const cos = Math.cos(angle); return new Matrix4( cos, -sin, 0, 0, sin, cos, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); } static rotationXYZ(vec: Vector3 | Vector4) { const cosX = Math.cos(vec.x); const sinX = Math.sin(vec.x); const cosY = Math.cos(vec.y); const sinY = Math.sin(vec.y); const cosZ = Math.cos(vec.z); const sinZ = Math.sin(vec.z); const x11 = cosX; const x12 = -sinX; const x21 = sinX; const x22 = cosX; const y00 = cosY; const y02 = sinY; const y20 = -sinY; const y22 = cosY; const z00 = cosZ; const z01 = -sinZ; const z10 = sinZ; const z11 = cosZ; // Y * X const yx00 = y00; const yx01 = y02 * x21; const yx02 = y02 * x22; const yx11 = x11; const yx12 = x12; const yx20 = y20; const yx21 = y22 * x21; const yx22 = y22 * x22; // Z * Y * X const m00 = z00 * yx00; const m01 = z00 * yx01 + z01 * yx11; const m02 = z00 * yx02 + z01 * yx12; const m10 = z10 * yx00; const m11 = z10 * yx01 + z11 * yx11; const m12 = z10 * yx02 + z11 * yx12; const m20 = yx20; const m21 = yx21; const m22 = yx22; return new Matrix4( m00, m01, m02, 0, m10, m11, m12, 0, m20, m21, m22, 0, 0, 0, 0, 1 ); } static scale(vec: Vector3 | Vector4) { return new Matrix4( vec.x, 0, 0, 0, 0, vec.y, 0, 0, 0, 0, vec.z, 0, 0, 0, 0, 1 ); } multiply(mat: Matrix4) { const m00 = this.m00 * mat.m00 + this.m01 * mat.m10 + this.m02 * mat.m20 + this.m03 * mat.m30; const m01 = this.m00 * mat.m01 + this.m01 * mat.m11 + this.m02 * mat.m21 + this.m03 * mat.m31; const m02 = this.m00 * mat.m02 + this.m01 * mat.m12 + this.m02 * mat.m22 + this.m03 * mat.m32; const m03 = this.m00 * mat.m03 + this.m01 * mat.m13 + this.m02 * mat.m23 + this.m03 * mat.m33; const m10 = this.m10 * mat.m00 + this.m11 * mat.m10 + this.m12 * mat.m20 + this.m13 * mat.m30; const m11 = this.m10 * mat.m01 + this.m11 * mat.m11 + this.m12 * mat.m21 + this.m13 * mat.m31; const m12 = this.m10 * mat.m02 + this.m11 * mat.m12 + this.m12 * mat.m22 + this.m13 * mat.m32; const m13 = this.m10 * mat.m03 + this.m11 * mat.m13 + this.m12 * mat.m23 + this.m13 * mat.m33; const m20 = this.m20 * mat.m00 + this.m21 * mat.m10 + this.m22 * mat.m20 + this.m23 * mat.m30; const m21 = this.m20 * mat.m01 + this.m21 * mat.m11 + this.m22 * mat.m21 + this.m23 * mat.m31; const m22 = this.m20 * mat.m02 + this.m21 * mat.m12 + this.m22 * mat.m22 + this.m23 * mat.m32; const m23 = this.m20 * mat.m03 + this.m21 * mat.m13 + this.m22 * mat.m23 + this.m23 * mat.m33; const m30 = this.m30 * mat.m00 + this.m31 * mat.m10 + this.m32 * mat.m20 + this.m33 * mat.m30; const m31 = this.m30 * mat.m01 + this.m31 * mat.m11 + this.m32 * mat.m21 + this.m33 * mat.m31; const m32 = this.m30 * mat.m02 + this.m31 * mat.m12 + this.m32 * mat.m22 + this.m33 * mat.m32; const m33 = this.m30 * mat.m03 + this.m31 * mat.m13 + this.m32 * mat.m23 + this.m33 * mat.m33; return new Matrix4( m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33 ); } multiplyVector(vec: Vector4) { const x = this.m00 * vec.x + this.m01 * vec.y + this.m02 * vec.z + this.m03 * vec.w; const y = this.m10 * vec.x + this.m11 * vec.y + this.m12 * vec.z + this.m13 * vec.w; const z = this.m20 * vec.x + this.m21 * vec.y + this.m22 * vec.z + this.m23 * vec.w; const w = this.m30 * vec.x + this.m31 * vec.y + this.m32 * vec.z + this.m33 * vec.w; return new Vector4(x, y, z, w); } isEqual(mat: Matrix4, delta = Number.EPSILON) { return ( Math.abs(this.m00 - mat.m00) < delta && Math.abs(this.m01 - mat.m01) < delta && Math.abs(this.m02 - mat.m02) < delta && Math.abs(this.m03 - mat.m03) < delta && Math.abs(this.m10 - mat.m10) < delta && Math.abs(this.m11 - mat.m11) < delta && Math.abs(this.m12 - mat.m12) < delta && Math.abs(this.m13 - mat.m13) < delta && Math.abs(this.m20 - mat.m20) < delta && Math.abs(this.m21 - mat.m21) < delta && Math.abs(this.m22 - mat.m22) < delta && Math.abs(this.m23 - mat.m23) < delta && Math.abs(this.m30 - mat.m30) < delta && Math.abs(this.m31 - mat.m31) < delta && Math.abs(this.m32 - mat.m32) < delta && Math.abs(this.m33 - mat.m33) < delta ); } toString(delimiter = ", ") { return ( this.m00 + delimiter + this.m01 + delimiter + this.m02 + delimiter + this.m03 + "\n" + this.m10 + delimiter + this.m11 + delimiter + this.m12 + delimiter + this.m13 + "\n" + this.m20 + delimiter + this.m21 + delimiter + this.m22 + delimiter + this.m23 + "\n" + this.m30 + delimiter + this.m31 + delimiter + this.m32 + delimiter + this.m33 ); } get m00() { return this.v[0]; } set m00(value: number) { this.v[0] = value; } get m10() { return this.v[1]; } set m10(value: number) { this.v[1] = value; } get m20() { return this.v[2]; } set m20(value: number) { this.v[2] = value; } get m30() { return this.v[3]; } set m30(value: number) { this.v[3] = value; } get m01() { return this.v[4]; } set m01(value: number) { this.v[4] = value; } get m11() { return this.v[5]; } set m11(value: number) { this.v[5] = value; } get m21() { return this.v[6]; } set m21(value: number) { this.v[6] = value; } get m31() { return this.v[7]; } set m31(value: number) { this.v[7] = value; } get m02() { return this.v[8]; } set m02(value: number) { this.v[8] = value; } get m12() { return this.v[9]; } set m12(value: number) { this.v[9] = value; } get m22() { return this.v[10]; } set m22(value: number) { this.v[10] = value; } get m32() { return this.v[11]; } set m32(value: number) { this.v[11] = value; } get m03() { return this.v[12]; } set m03(value: number) { this.v[12] = value; } get m13() { return this.v[13]; } set m13(value: number) { this.v[13] = value; } get m23() { return this.v[14]; } set m23(value: number) { this.v[14] = value; } get m33() { return this.v[15]; } set m33(value: number) { this.v[15] = value; } }

Matrix4クラスの使い方

使い方を説明します。行列を作成する際は以下のようなコードを書きます。
コンストラクタへの引数は、行列の各要素を行優先で指定するようにしましたので、直感的に記述できます。

例えば、次の行列を生成したいなら

(1234 5678 9101112 13141516)\begin{pmatrix} 1 & 2 & 3 & 4 \\\ 5 & 6 & 7 & 8 \\\ 9 & 10 & 11 & 12 \\\ 13 & 14 & 15 & 16 \end{pmatrix}

以下のように書くことができます。

const mat = new Matrix4( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);

以下のように、各要素にアクセスできます。

console.log(mat.m00) // 1 console.log(mat.m01) // 2 console.log(mat.m02) // 3 console.log(mat.m03) // 4 console.log(mat.m10) // 5 ...

.rawで内部のFloat32Arrayを取得することができます。ちなみにWebGLの規約に合わせて、メモリ上の並びは列優先にしてあります。

console.log(mat.raw) // Float32Array[1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16]

また、以下の行列作成用のstatic関数があります。

const identityM = Matrix4.identity(); // 単位行列 const t = Matrix4.translation(new Vector3(1, 2, 3)); // xに1, yに2, zに3並行移動させる行列 const x = Matrix4.rotateX(Math.PI /2); // X軸周りに90度回転させる行列 const y = Matrix4.rotateY(Math.PI /2); // Y軸周りに90度回転させる行列 const z = Matrix4.rotateZ(Math.PI /2); // Z軸周りに90度回転させる行列 const zyx = Matrix4.rotateXYZ(Math.PI / 2, Math.PI / 2, Math.PI / 2); X軸周りに90度回転させ、次にY軸周りに90度回転させ、最後にZ軸周りに90度回転させる行列 const s = Matrix4.scale(new Vector3(1, 2, 3)); // x方向に1倍, y方向に2倍, z方向に3倍スケールさせる行列

行列同士は掛け算させることもできます。

const a = new Matrix4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); const b = new Matrix4(17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32); const c = a.multiply(b);

また、行列にベクトルを掛け算(変換)させることもできます。

const a = new Matrix4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); const v = new Vector4(1, 2, 3, 4); const vv = a.multiplyVector(v);

また、行列の各要素が合致しているか比較することもできます。

const a = new Matrix4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); const b = new Matrix4(17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32); console.log(a.isEqual(b)) // false

また、デバッグ表示用に、行優先で行列の各要素を表示する関数も用意しました。

const a = new Matrix4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); console.log(a); /* 1, 2, 3, 4 5, 6, 7, 8, 9, 10, 11. 12 13, 14, 15, 16 */

Matrix4クラスのユニットテストを行う

さて、結構な量のコードを書きましたが、これらのコードにミスはないでしょうか? 計算ミスがあったら、ライブラリは正しく動作しません。
動作を確かめたいところですね。そこで、ユニットテストを導入しましょう。
現在のWeb界隈では、Jestと呼ばれるテストスイートがよく使われます。これのTypeScript版のts-jestを導入しましょう。

コマンドラインで以下のコマンドを打ちましょう。Jest関連のパッケージがインストールされます。

$ npm install --save-dev jest ts-jest @types/jest

さらに、以下のコマンドを実行し、Jestの設定ファイルを生成します。

$ npx ts-jest config:init

ts-jestのバージョンによって多少の違いがあるかもしれませんが、おそらく以下のような設定ファイルが生成されるはずです。
あまり深く気にする必要はありませんが、要はテストコードをTypeScriptで書けるようにするためのts-jestの設定です。

/** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', };

さらに、ESModuleを取り扱えるようにするため、以下のように設定を追加します。

/** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', preset: 'ts-jest/presets/default-esm', // or other ESM presets moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` '^.+\\.tsx?$': [ 'ts-jest', { useESM: true, }, ], }, };

そして、コマンドラインからテストを実行できるよう、package.jsonの"test"の部分の値を"jest"と書き変えましょう。

image.png

これで、$ npm run testとコマンドを打てばテストが走るようになります(まだテストコードがないですが)。

Matrix4のテストコードを書く

それでは、Matrix4のテストコードを書いていきましょう。Jest(ts-jest)はデフォルトだと.test.tsという拡張子のファイルをテストコードと見なします。
そこで、src/mathディレクトリ以下にMatrix4.test.tsというファイルを作成し、以下のように記述してください。

import { Matrix4 } from "./Matrix4.js"; import { Vector3 } from "./Vector3.js"; import { Vector4 } from "./Vector4.js"; test("Matrix4.toString()", () => { const a = new Matrix4( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ); expect(a.toString()).toBe( "1, 2, 3, 4\n" + "5, 6, 7, 8\n" + "9, 10, 11, 12\n" + "13, 14, 15, 16" ); }); test("Matrix4.multiply()", () => { const a = new Matrix4( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ); const b = new Matrix4( 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 ); const c = a.multiply(b); expect(c.isEqual(new Matrix4( 250, 260, 270, 280, 618, 644, 670, 696, 986, 1028, 1070, 1112, 1354, 1412, 1470, 1528 ))).toBe(true); }); test("Matrix4.multiplyVector4()", () => { const a = new Matrix4( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ); const b = a.multiplyVector(new Vector4(1, 2, 3, 4)); expect(b.isEqual(new Vector4(30, 70, 110, 150))).toBe(true); }); test("Matrix4.translation()", () => { const a = Matrix4.translation(new Vector3(1, 2, 3)); const b = new Matrix4( 1, 0, 0, 1, 0, 1, 0, 2, 0, 0, 1, 3, 0, 0, 0, 1 ); expect(a.isEqual(b)).toBe(true); }); test("Matrix4.rotationX()", () => { const a = Matrix4.rotationX(Math.PI / 2); const b = new Matrix4( 1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1 ); expect(a.isEqual(b)).toBe(true); }); test("Matrix4.rotationY()", () => { const a = Matrix4.rotationY(Math.PI / 2); const b = new Matrix4( 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1 ); expect(a.isEqual(b)).toBe(true); }); test("Matrix4.rotationZ()", () => { const a = Matrix4.rotationZ(Math.PI / 2); const b = new Matrix4( 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ); expect(a.isEqual(b)).toBe(true); }); test("Matrix4.rotationXYZ()", () => { const a = Matrix4.rotationXYZ(new Vector3(Math.PI / 2, Math.PI / 2, Math.PI / 2)); const x = Matrix4.rotationX(Math.PI / 2); const y = Matrix4.rotationY(Math.PI / 2); const z = Matrix4.rotationZ(Math.PI / 2); const b = z.multiply(y).multiply(x); const c = new Matrix4( 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1 ); expect(a.isEqual(b, 0.0001)).toBe(true); expect(a.isEqual(c, 0.0001)).toBe(true); }); test("Matrix4.scale()", () => { const a = Matrix4.scale(new Vector3(1, 2, 3)); const b = new Matrix4( 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1 ); expect(a.isEqual(b)).toBe(true); });

ちなみに、このテストコード中でVector4クラス同士の値比較も行なっているので、それができるようVector4クラスに以下の記述も加えてください。

export class Vector4 { private v: Float32Array; constructor(x: number, y: number, z: number, w: number) { this.v = new Float32Array([x, y, z, w]); } isEqual(vec: Vector4, delta = Number.EPSILON) { // <----isEqual関数を追加 return ( Math.abs(this.x - vec.x) < delta && Math.abs(this.y - vec.y) < delta && Math.abs(this.z - vec.z) < delta && Math.abs(this.w - vec.w) < delta ); } ...

さて、これで$ npm run testを実行してみましょう。

image.png

無事にテストが通りましたね。

プロジェクト設定微調整

Jest関連をインストールしたからなのか、いつの間にか$ npm run build$ npm run sample-buildの実行時にビルドエラーが出るようになってしまいました。

image.png

どうもTypeScriptコンパイラがnode_modules以下のファイルまで覗いてしまっているようですね。
設定を微調整しましょう。package.jsonとtsconfig.jsonを以下のように変更してください。

image.png

image.png

ここら辺はWeb開発における細かな罠というか、私もやってて試行錯誤しながらエラー回避しています。ライブラリ開発の本質とは何の関係もないので、あまり気にしないでください。

ここまでのソースコードはこちらで公開しています 

12日目:プロジェクト構成のリファクタリングをする

14日目:クォータニオンクラスを作る