6. 行列と行列・ベクトル積
ここまではベクトル、つまり矢印と数字のリストを扱ってきました。
此の章では、線型代数のもう一人の主役である行列に出会います。
行列とは、ベクトルを受け取って別のベクトルに変える物です。
ゲームで見た事のある全てのモデルの全ての頂点は、画面に届く前に少なくとも一つ、普通は幾つもの行列を毎フレーム通っています。
良い知らせは、必要な知識はもう全て持っているという事です。
行列とベクトルの掛け算は、第4章の内積を並べただけの物です。
別の見方をすれば、第2章の基底ベクトルの線型結合でもあります。
此の章の終わりには、何故此の二つが同じ事なのかが分かり、行列の列を見るだけで其の行列が何をするのか読める様になります。
6.1 行列とは?
行列(matrix)は、数字を長方形に並べた物です。
本当に其れだけです。
例えば:
横に並んだ数字の並びを行(row)、縦に並んだ数字の並びを列(column)と呼びます。
此の行列は2行3列なので、2 × 3行列(「2かける3」又は「2行3列」と読みます)と呼びます。
一般に、行列の行列をm × n行列と言います。
順番は必ず「行が先、列が後」です。
行と列がどっちだったか分からなくなったら、漢字を見て下さい。
「行」の右側には横線が二本あり、「列」の右側(刂)には縦線が二本あります。
横に並ぶのが行、縦に並ぶのが列です。
ベクトルをの様な太字の小文字で書くのと同じ様に、行列はやの様な太字の大文字で書きます。
要素と添字の書き方
行列の中の一つ一つの数字を要素(又は成分)と呼びます。
一つの要素を指す時は、行番号と列番号を此の順番で小さな添字として書きます。
行列の要素には小文字のを使います:
従って、上の例では:
- (1行1列、左上の角)
- (1行2列)
- (2行1列)
- (2行3列、右下の角)
数学では1から数えます。
コードでは0から数えるので、はC++のライブラリではa.at(1, 2)になります。
どちらの意味で言っているのかは毎回はっきり書きます。
正方行列、対角成分、単位行列
行の数と列の数が同じ行列を正方行列と呼びます。
グラフィックスで見る行列は、殆どが2 × 2、3 × 3、4 × 4の三種類です。
左上から右下へ並ぶ要素を対角成分(主対角線)と呼びます。
対角成分が1で、其れ以外が全て0の特別な正方行列があります:
此れが単位行列で、と書きます。
普通の掛け算での数字の1と同じ役割で、何も変えません。
何故そうなるのかは、もう少し後で分かります。
ベクトルは細長い行列
第2章で、ベクトルは縦に書くと決めました。
すると3Dベクトルは、3行1列の3 × 1行列だと考える事が出来ます:
此れを覚えておいて下さい。
6.4節のサイズのルールは此の考え方から来ています。
6.2 公式
3 × 3行列と3Dベクトルの掛け算は次の通りです:
行列を左に、ベクトルを右に、間に何も挟まずにと書きます。
結果は新しいベクトルで、良く(「ブイダッシュ」)と呼びます:
第1章のシグマ記号を使うと、結果の番目の成分は次の様に書けます:
怖そうに見えますが、言っている事は単純です。
行列の行目を左から右へ辿り、各要素にベクトルの対応する成分を掛けて、全部足すだけです。
図を描く時に使う2 × 2版はこちらです:
6.3 仕組み
此の公式には二つの見方があります。
どちらも同じ数字になりますが、教えてくれる事が違います。
上手なグラフィックスプログラマーは、此の二つを常に切り替えながら考えています。
見方1:結果の各成分は内積
結果の1行目を見て下さい。
此れは正に第4章の内積で、行列の1行目とベクトルの内積です。
各行をと呼ぶと:
此の例をゆっくり計算してみましょう。
次の行列とベクトルを使います:
1行目は:。
2行目は:。
3行目は:。
此の見方は手で計算するのに最適です。
1行ずつ、内積を一つずつ計算すれば良いのです。
「行列とは内積を縦に積み重ねた物」と良く言われるのも此の為です。
見方2:結果は列を混ぜた物
今度は同じ公式を、行ごとではなくの成分ごとにまとめ直してみます。
結果の中のは全て1列目の要素に、は全て2列目の要素に、は全て3列目の要素に掛かっています:
各列をと呼ぶと:
此れは列の線型結合です。
第2章で全てのベクトルをと書いたのと全く同じ形です。
同じ例で確かめてみましょう。
の列は、、で、です:
当然ですが、答えは同じです。
一番大事な事:列は基底ベクトルの行き先
此れは此のシリーズ全体で一番大事な考え方なので、独立した節にしました。
に基底ベクトルを掛けるとどうなるでしょうか?
1列目がそのまま出てきます。
同じ様に、、です。
つまり:
行列の1列目はiの行き先、2列目はjの行き先、3列目はkの行き先です。
そして全てのベクトルはなので、三つの基底ベクトルの行き先さえ分かれば、全てのベクトルの行き先が分かります。
新しい矢印を同じ量だけ混ぜれば良いだけです。
行列はレシピの様な物です。
「iを此れに、jを此れに、kを此れに置き換えて、量はそのまま」と書いてあるのです。
単位行列が何も変えない理由も此れで分かります。
単位行列の列は、、なので、iはiに、jはjに、kはkに行きます。
何も動かないので、全てのベクトルでです。
図で見る:単位正方形の変換
絵が描ける2Dで見てみましょう。
次の行列を使います:
列を読むと、はへ、はへ行きます。
では、からまでの単位正方形の四つの角を変換してみます:
| 角 | 計算 | 結果 |
|---|---|---|
正方形が斜めの平行四辺形になりました。
底辺(元の)は2倍の長さになり、左の辺(元の)は右に傾いています。
正方形の中の全ての点も一緒に付いて行きます。
例えば点はへ行きます。
見方1でもと同じになります。
此れが行列を「読める」様にするコツです。
誰かのコードで行列を見掛けたら、数字をじっと見つめるのではなく、列を読んで「i、j、kはどこへ行く?」と考えて下さい。
6.4 サイズのルール
どんな行列とどんなベクトルでも掛けられるのでしょうか?
いいえ。
見方1では各行とベクトルの内積を取りますが、内積は同じ長さのリスト同士でないと計算出来ません。
従って、行列の列の数とベクトルの成分の数が同じでなければなりません。
内側の二つの数字が一致する必要があり、外側の二つの数字が結果のサイズになります。
m × n行列はn次元のベクトルを受け取り、m次元のベクトルを返します。
行列は正方行列である必要はありません。
次の2 × 3行列は3Dベクトルを受け取って2Dベクトルを返します:
3Dから2Dへ落とす事は、正に3Dの世界を平らな画面に映す時に必要な事です。
其れは第12章で、正方形の4 × 4行列とある工夫を使ってちゃんとやります。
逆に、同じ2 × 3行列に2Dベクトルを掛けるのは定義されていません。
1行目には数字が三つあるのに、ベクトルには二つしか無いので、三つ目の数字の相手が居ないのです。
6.5 メモリとシェーダーの中の行列
上の数学はどこでも同じです。
然し、コンピューターが行列をどう保存するか、各言語でどう書くかは同じではありません。
誰もが一度はここで痛い目に遭うので、ゆっくり進みましょう。
行優先と列優先
メモリは一本の長い数字の列ですが、行列は格子です。
其の為、並べる順番を決める必要があります。
the matrix row-major memory
(row after row)
[ 1 2 3 ] 1 2 3 4 5 6 7 8 9
[ 4 5 6 ]
[ 7 8 9 ] column-major memory
(column after column)
1 4 7 2 5 8 3 6 9
此の二つの並びは同じ行列を表しています。
1行目から順に並べるのが行優先(row-major)、1列目から順に並べるのが列優先(column-major)です。
どちらが「正しい」という事は無く、車が左側通行か右側通行かの様な、単なる決まり事です。
危ないのは、片方が行優先で書いて、もう片方が列優先で読んだ時です。
すると読む側は行を列として受け取り、別の行列(第7章で出てくる転置行列)を見る事になります。
あたし達のC++のMat4は、GLSLやOpenGLと同じ列優先です。
c[0]からc[3]までの四つの列を持ち、其れぞれが一つのVec4です。
見方2を考えると此れはとても自然な選択で、c[0]は文字通りx軸の行き先です。
CPUからGPUへ行列を送る時、シェーダーはCPUが書いたのと同じ順番で読まなければなりません。
GLSLのユニフォームブロックはデフォルトで列優先なので、Mat4をそのまま送れます。
Slangでは、バッファ内の行列のレイアウトはコンパイラーやAPIの設定で決まります。
其の設定でC++のコードに合わせる方法は、Slangシリーズで説明します。
HLSLも同様で、レイアウトはコンパイラーのオプション、又は行列に付けるrow_major / column_major修飾子で決まります。
大事なルールは一つ:決まり事を一つ選び、CPUとシェーダーで必ず揃える事。
添字:GLSLとSlangは逆
ここが本当の罠です。
GLSLとSlangは同じ数学を扱いますが、行列の添字の付け方が違います。
- GLSLでは
m[i]は列iです。つまりm[col][row]で、列が先、行が後です。 - Slangでは
M[i]は行iです。つまりM[row][col]で、数学と同じく行が先、列が後です。 - HLSLもSlangと同じで、
M[row][col]です。
要素(数学では1行2列、0始まりのコードでは行0・列1)は、SlangとHLSLではM[0][1]、GLSLではm[1][0]と書きます。
あたし達のC++のat()は数学に従ってm.at(0, 1)です。
コンストラクターでも同じ事が起きます。
数字を並べて行列を作る時、SlangとHLSLは行ごとに埋め、GLSLは列ごとに埋めます。
従って、見た目が同じ数字の並びでも、GLSLとSlang・HLSLでは別の行列になります。
下のセピアの例で実際に見てみましょう。
掛け算:Slangはmul、GLSLはアスタリスク
GLSLでは、行列とベクトルの積を普通の掛け算記号でm * vと書きます。
SlangとHLSLではmul(M, v)という関数を使わなければなりません。
此れは典型的なバグの原因です。
SlangとHLSLの*は、行列であっても常に成分ごと(要素同士)の掛け算です。
二つのfloat4x4でA * Bと書くと、同じ位置の要素同士を掛けただけの全く別の行列になり、変換としては意味がありません。
M * vと書いても、変換されたベクトルは得られません。
GLSLからSlangやHLSLへシェーダーを移植したらメッシュが消えたり爆発したりした時は、まず行列の隣の*を探して下さい。
| 項目 | SlangとHLSL | GLSL | C++ |
|---|---|---|---|
| 行列の型 | float3x3、float4x4 |
mat3、mat4 |
Mat4 |
| 行r・列cの要素 | M[r][c] |
m[c][r] |
m.at(r, c) |
| 添字一つで得られる物 | 行 | 列 | m.c[i]は列 |
| 数字からのコンストラクター | 行ごとに埋める | 列ごとに埋める | c[0]〜c[3]を設定 |
| 行列とベクトルの積 | mul(M, v) |
m * v |
m * v |
| 行列同士のアスタリスク | 成分ごと | 行列の積 | 行列の積(第7章) |
6.6 グラフィックスプログラミングでの使い方
全ての頂点は行列を通る
3Dモデルは頂点のリストで、各頂点はモデル自身の座標系での位置を持っています。
其れを描画するには、頂点シェーダーが其の位置を一つ残らず画面上の正しい場所へ移す必要があります。
其の仕事をするのが、各位置と行列の掛け算です。
次は役に立つ最小の頂点シェーダーです。
4 × 4行列が一つ、掛け算が一回だけです。
struct Transform {
float4x4 mvp;
};
ConstantBuffer<Transform> transform;
struct VertexOutput {
float4 position : SV_Position;
};
[shader("vertex")]
VertexOutput vertexMain(float3 position : POSITION) {
VertexOutput output;
output.position = mul(transform.mvp, float4(position, 1.0));
return output;
}#version 450
layout(set = 0, binding = 0) uniform Transform {
mat4 mvp;
};
layout(location = 0) in vec3 position;
void main() {
gl_Position = mvp * vec4(position, 1.0);
}struct Transform {
float4x4 mvp;
};
ConstantBuffer<Transform> transform;
struct VertexOutput {
float4 position : SV_Position;
};
VertexOutput vertexMain(float3 position : POSITION) {
VertexOutput output;
output.position = mul(transform.mvp, float4(position, 1.0));
return output;
}
3Dの位置の最後に1.0を付けて4Dベクトルにしている事に注目して下さい。
此の4番目の数字が行列で物を移動させる鍵で、其れが第9章のテーマです。
mvpはモデル(model)・ビュー(view)・プロジェクション(projection)の略です。
此の三つの行列は第7章で一つにまとめ、第9章から第12章で作ります。
今のポイントは、行列一つ、mul(又は*)一回、全ての頂点、毎フレームという事です。
GPUは此の小さなプログラムを頂点ごとに一回、1フレームに何百万回も実行します。
4 × 4行列と4Dベクトルの積は掛け算16回と足し算12回で、GPUは此れが非常に得意です。
色の行列:グレースケールとセピア
行列は位置だけの物ではありません。
色もというベクトルなので、3 × 3行列で色を変換出来ます。
出力の各チャンネルは、行列の一つの行と入力の色の内積です(見方1)。
つまり各行は「此のチャンネルに赤・緑・青を其れぞれどれだけ入れるか」を表しています。
古典的なグレースケールフィルターでは、全てのチャンネルに明るさの重み0.299、0.587、0.114を使います。
人間の目は緑に一番敏感で、青に一番鈍感なので、重みが違うのです。
オレンジ色に掛けてみましょう。
全ての行が同じなので、全てのチャンネルが同じ値になります:
結果はで、中くらいの灰色です。
(グレースケールだけならdot一つで済みますが、行列の形にしておけば、コードを変えずに他のフィルターに差し替えられます。)
良く使われるセピアフィルターは、三つの違う行を持っています:
またオレンジ色で、1行ずつ計算します:
オレンジ色は暖かい茶色になりました。
の最初の二行は合計が1より大きいので、真っ白なはになってしまいます。
其の為、結果を0〜1の範囲に収める必要があります。
シェーダー関数にすると次の様になります。
コンストラクターを良く見て下さい。
Slang版とHLSL版は行ごとに、GLSL版は列ごとに書いてあり、何れも同じ行列を作っています。
float3 toSepia(float3 color) {
// float3x3(...) fills row by row
float3x3 sepia = float3x3(
0.393, 0.769, 0.189,
0.349, 0.686, 0.168,
0.272, 0.534, 0.131);
return saturate(mul(sepia, color));
}vec3 toSepia(vec3 color) {
// mat3(...) fills column by column
mat3 sepia = mat3(
0.393, 0.349, 0.272,
0.769, 0.686, 0.534,
0.189, 0.168, 0.131);
return clamp(sepia * color, 0.0, 1.0);
}float3 toSepia(float3 color) {
// float3x3(...) fills row by row
float3x3 sepia = float3x3(
0.393, 0.769, 0.189,
0.349, 0.686, 0.168,
0.272, 0.534, 0.131);
return saturate(mul(sepia, color));
}
Slangの数字をそのままGLSLのmat3(...)にコピーすると、別の間違ったフィルターになり、しかも誰も警告してくれません。
写真アプリ、動画プレーヤー、ポストプロセスには、此の様な行列が沢山あります。
彩度、色相の回転、チャンネルの入れ替え、色覚シミュレーション等です。
6.7 其の他の使い道
入力を混ぜて出力を作る数字の表がある所なら、どこにでも行列が出てきます。
連立一次方程式
学校で二つの方程式を解いた事を覚えていますか?
との前の数字を行列に、未知数をベクトルにすると、方程式全体が一行になります:
答えはです。
見方1で確かめると、、です。
エンジニアは、橋、電気回路、天気、流体シミュレーション等で、未知数が何千、何百万もある此の様な方程式を解いています。
行列を「元に戻して」を求める方法は第10章で扱います。
マルコフ連鎖
天気が晴れか雨のどちらかだとします。
今日が晴れなら、明日は確率0.8で晴れ、0.2で雨です。
今日が雨なら、明日は0.4で晴れ、0.6で雨です。
「晴れから」を1列目に、「雨から」を2列目に入れます:
今日は晴れなので、今日のベクトルはです。
明日はで、見方2の通り1列目そのものです。
明後日はです。
同じ考え方が、文字入力の予測、ボードゲームの分析、Googleの初期のPageRankにも使われています。
表計算、画像、経済
- 価格の表(行がお店、列が商品)に数量のベクトルを掛けると、全てのお店での合計金額が一度に出ます。行ごとに内積一つです。
- 画像編集ソフトやウェブでは色の行列が直接使われています。SVGには
feColorMatrixというフィルターがあり、AndroidにはColorMatrixクラスがあります。 - ぼかしやシャープのフィルターはカーネルと呼ばれる小さな数字の格子を使います。見た目は行列ですが、画像の上を滑らせて使うので、別の演算です。
- 経済学では、レオンチェフの産業連関表が経済全体を行列で表します。各産業が製品を一単位作るのに、鉄、電力、労働がどれだけ必要かを並べた物です。
6.8 コード
linalg.hhに行列を追加しましょう。
次はヘッダーに書かれている通りのMat4構造体です:
// 4×4行列。列優先:c[列]が一つの列ベクトル。
// 4×4 matrix. Column-major: c[column] is one column vector.
struct Mat4 {
Vec4 c[4];
// 行row・列colの要素(0始まり) / element at row, col (0-based)
float &at(int row, int col) {
float *p = &c[col].x;
return p[row];
}
float at(int row, int col) const {
const float *p = &c[col].x;
return p[row];
}
static Mat4 identity() {
Mat4 m;
m.c[0] = {1, 0, 0, 0};
m.c[1] = {0, 1, 0, 0};
m.c[2] = {0, 0, 1, 0};
m.c[3] = {0, 0, 0, 1};
return m;
}
};
幾つかのポイントです:
- データは四つの
Vec4の列です。m.c[0]は1列目、つまりx軸の行き先で、GLSLのm[0]と同じです。 at(row, col)は数学と同じ順番(行が先)で、0始まりの番号を使います。列を見付けてから、其の中でrow個分のfloatを進みます。Vec4の四つのfloatがメモリ上で隣り合っているから出来る事です。at()は二つあります。片方は参照を返すのでm.at(1, 2) = 3;の様に書き込めます。もう片方はconstな行列用で、値を返すだけです。identity()は列をi、j、kと4番目の基底ベクトルにするので、何も動きません。
そして行列とベクトルの積です:
// 行列×ベクトル:列の線型結合 / matrix × vector: a linear combination of the columns (chapter 6)
inline Vec4 operator*(const Mat4 &m, Vec4 v) {
return v.x * m.c[0] + v.y * m.c[1] + v.z * m.c[2] + v.w * m.c[3];
}
此の一行は見方2そのものです。
xかける1列目、足すyかける2列目、足すzかける3列目、足すwかける4列目。
第2章で作ったfloat * Vec4とVec4 + Vec4の演算子を使っています。
テストプログラム
6.3節の3 × 3の例を4 × 4単位行列の左上に入れて、になるか確かめましょう。
#include "linalg.hh"
#include <cstdio>
void printVec4(const char *name, Vec4 v) {
printf("%s = (%g, %g, %g, %g)\n", name, v.x, v.y, v.z, v.w);
}
void printMat4(const char *name, const Mat4 &m) {
printf("%s =\n", name);
for (int row = 0; row < 4; ++row) {
printf(" [%6g %6g %6g %6g ]\n", m.at(row, 0), m.at(row, 1), m.at(row, 2), m.at(row, 3));
}
}
int main() {
// The 3x3 example from section 6.3, inside a 4x4 identity.
Mat4 m = Mat4::identity();
m.at(0, 1) = 2;
m.at(1, 2) = 3;
m.at(2, 0) = 4;
printMat4("m", m);
// c[0] is the first column: where i lands.
printVec4("m.c[0]", m.c[0]);
Vec4 v = {2, 1, 3, 1};
printVec4("m * v", m * v);
// The identity matrix changes nothing.
printVec4("I * v", Mat4::identity() * v);
return 0;
}
期待される出力:
m =
[ 1 2 0 0 ]
[ 0 1 3 0 ]
[ 4 0 1 0 ]
[ 0 0 0 1 ]
m.c[0] = (1, 0, 4, 0)
m * v = (4, 10, 11, 1)
I * v = (2, 1, 3, 1)
メモリは列ごとに保存されていますが、printMat4がat()を使って行ごとに出力するので、表示は数学と同じ見た目になります。
m.c[0]は1行目ではなく、1列目のを出力します。
vの4番目の成分は1で、mの4列目はなので、w成分にだけ足されます。
其の為、最初の三つの数字は丁度になります。
6.9 まとめ
- 行列はm行n列の数字の格子です。要素はi行j列にあります(数学では1始まり、コードでは0始まり)。
- 行列とベクトルの積:結果の各成分は、行列の一つの行とベクトルの内積です。
- 同じ結果は列の線型結合でもあります:。
- 行列の列は基底ベクトルi、j、kの行き先です。行列を理解するには列を読みます。
- 単位行列は対角成分が1で、何も変えません。
- m × n行列はn次元ベクトルを受け取り、m次元ベクトルを返します。
Mat4はGLSLと同じ列優先です。CPUとシェーダーでメモリのレイアウトを揃える必要があります。- GLSLの添字は
m[col][row]で、コンストラクターは列を埋めます。SlangとHLSLの添字はM[row][col]で、コンストラクターは行を埋めます。 - SlangとHLSLでは
mul(M, v)を使います。両言語の*は成分ごとの掛け算で、移植時の典型的なバグです。
6.10 課題
- ★ のサイズは?又、、、は幾つですか?
- ★ を見方1で計算し、次に見方2で計算して、答えが一致する事を確かめて下さい。
- ★ 行ごとの計算をせずに、列を使ってを計算して下さい。
- ★ iをへ、jをへ送る2 × 2行列を書いて下さい。はどこへ行きますか?単位正方形の変換前と変換後を描いて、此の行列が何をしたのか答えて下さい。
- ★★ 列を、、、にした
Mat4を作り、を掛けて結果を出力して下さい。(12, 15, 18, 1)になれば正解です。次にprintMat4で行列を出力し、何故最初の行が1 4 7 0になるのか説明して下さい。 - ★★ 3 × 3行列を使って色の赤と青のチャンネルを入れ替えるシェーダー関数を、SlangとGLSLの両方で(良ければHLSLでも)書いて下さい(SlangとHLSLでは
mul、GLSLでは*)。色がになれば正解です。此の行列の場合、コンストラクターの順番は関係あるでしょうか?其れは何故ですか? - ★★★ 好きな3 × 3行列にゼロベクトルを掛けてみて下さい。結果はいつも何になりますか?此の事から、3 × 3行列で物体をある場所から別の場所へ移動させる事は出来るでしょうか?(答えは第9章で。)
コメント(0)
まだコメントはありません。
ログインするとコメント出来ます。