8. 線型変換:拡大縮小・回転・せん断・鏡映
行列が何か(第6章)、そして行列をどう繋げるか(第7章)はもう分かりました。
然し、今までの行列はただの適当な数字の格子でした。
此の章では役に立つ行列を作ります。
物を拡大縮小し、回転し、せん断し、鏡映(鏡に映す様に反転)する行列です。
此れらは全てのモデル行列、回るコイン、マウスの方を向くスプライトの部品です。
何度も使う道具は第6章の一番大事な事、行列の列は基底ベクトルの行き先です。
変換を作るには「i、j、kはどこへ行くべき?」と問い、其の答えを列として書くだけです。
8.1 「線型」とは
此の章の変換は全て線型変換です。
公式の前に、まずイメージです。
ゴムのシートに正方形の格子を描いて、シートを引っ張ったり、回したり、斜めにしたりします。
終わった後に次の条件が成り立っていれば、其の変換は線型です:
- 原点は原点のまま
- 直線は直線のまま
- 平行だった格子の線は平行のままで、間隔も均等のまま
真ん中の格子は斜めにされました。
正方形は平行四辺形になりましたが、どのマスも隣のマスと同じ形と大きさです。
右の格子は線こそ真っ直ぐですが、右へ行く程列が広くなっています。
此の様な不均等な引き伸ばし方は行列では出来ません。
正式なルール
数学では、全てのベクトルと全ての数について次の二つのルールを満たす時、変換は線型です:
言葉で言うと、先に足してから変換しても、先に変換してから足しても同じという事です。
そして、先に何倍かしても、先に変換しても同じです。
二つ目のルールでとすると、、つまり原点は決して動きません。
2Dの点の変換を幾つか試してみましょう。
- は線型です。先に足すと。先に変換すると。同じです。
- は線型ではありません。原点が動きます:。此れは平行移動で、独立した章があります。
- は線型ではありません。ですが、です。正に上の図の右側の、間隔が不均等な格子です。
全ての線型変換は行列
線型変換と行列が実は同じ物である理由は次の通りです。
全ての3Dベクトルはと書けます(第2章)。
が線型なら、二つのルールを使って分解出来ます:
此れは三つの決まったベクトルの線型結合で、正に行列とベクトルの積そのものです(第6章の見方2)。
従って、どんな線型変換も、列が、、の行列です。
逆に、行列の掛け算は二つのルールを満たすので、全ての行列は線型変換です。
此れで此の章全体のレシピが出来ました:
線型変換を作るには、i、j、kの行き先を決めて、其の三つのベクトルを列として書く。
8.2 公式
此の章の行列を一ヶ所にまとめました。
覚える必要はありません。
次の節で一つずつ一から作ります。
| 変換 | 2D行列 | iとjに起きる事 |
|---|---|---|
| 拡大縮小 | 其れぞれの軸方向に伸び縮み | |
| の回転 | 反時計回りに回る | |
| x方向のせん断 | iはそのまま、jがkだけ傾く | |
| y軸での鏡映 | iが反転、jはそのまま | |
| x軸への押し潰し | iはそのまま、jはゼロに潰れる |
3Dの拡大縮小と、座標軸周りの三つの回転は次の通りです:
角度は全てラジアンです。
正の角度は、軸のプラス側から原点の方を見た時に反時計回りです(右手の法則)。
を良く見て下さい。
マイナス記号が上ではなく下の行にあります。
誤植ではありません。
其の理由は8.3節で説明します。
8.3 仕組み
拡大縮小
物を横に2倍にしたいなら、をにして、はのままにします。
一般に、x方向に倍、y方向に倍する拡大縮小は、をへ、をへ送ります。
此れが列なので、数字は対角線上に並びます:
各成分が其れぞれの倍率を掛けられるだけです。
、なら、点はへ行きます。
- 全ての倍率が同じなら一様な拡大縮小です。物体は形を保ったまま大きく又は小さくなります。ベクトルに一つの数を掛けるのと同じです(第2章)。
- 倍率が違うなら非一様な拡大縮小です。物体は潰れたり伸びたりします。
- 倍率1の軸は変わらないので、単位行列はの拡大縮小です。
- 0と1の間の倍率は縮め、1より大きい倍率は大きくします。0と負の倍率は特別で、もうすぐ出てきます。
2Dの回転
次は角度だけ反時計回りに回転させます。
ここでも答える必要があるのは「とはどこへ行く?」だけです。
は単位円上の角度0の位置にあります。
其れを回すと単位円上の角度の点へ移り、第1章で学んだ通り其の点はです。
は単位円上の90°の位置にあります。
其れを回すと角度へ移ります。
此の点には便利な近道があります。
第7章で、4分の1回転はをへ送る事を見ました。
は常にの4分の1回転先にあるので、新しい位置はを4分の1回転させたです。
此れらを列として書けば、2Dの回転行列の出来上がりです:
、つまりラジアンで確かめましょう。
、なので、行列はです。
第7章で手作りした4分の1回転と全く同じです。
点はへ行きます。
今度はもう少し面倒な角度、(ラジアン)をに掛けます。
第1章の表から、、です。
従っては約になります。
回転はベクトルの長さを決して変えないはずなので、第3章の方法で確かめましょう:
長さは同じで、向きが変わっただけです。
(最後の桁の僅かな違いは、0.866に丸めたせいです。)
3Dの回転:X、Y、Z軸周り
3Dでは、原点を通る任意の直線の周りに回転出来ます。
其の直線を回転の軸と呼びます。
一番簡単な軸は座標軸そのもので、其の回転は残りの二つの座標に2Dの回転を掛けるだけです。
Z軸周りの回転。
z軸の周りに回すと、点はxy平面の中で動き、zは変わりません。
とは2Dと全く同じ様に回り、(軸そのもの)は動きません。
2Dの行列にzの行と列を一つずつ足した物です:
X軸周りの回転。
今度はxが動かず、yとzが回ります。
ルールは同じで、yがxの役、zがyの役をします。
正の回転はをの方へ回します。
従ってはへ、はへ行きます:
Y軸周りの回転。
ここではyが動かず、zとxが回ります。
そして、ここに落とし穴があります。
軸はx、y、z、そしてまたxという循環の順番になっています:
Z軸周りでは、xがyの方へ回ります。
X軸周りでは、yがzの方へ回ります。
循環に従うと、Y軸周りではzがxの方へ回るのであって、逆ではありません。
従って正のY回転はをへ、をへ送ります。
此れを3列目と1列目に書きます:
マイナス記号が右上ではなく左下に来たのは、行列をx、y、zの順番で書くのに、回転はzからxへ向かうからです。
此れは誰もが一度は引っ掛かる所で、ここの符号を間違えると、物体がY軸周りに逆向きに回ります。
ヘッダーのrotateX、rotateY、rotateZは正に此の三つの行列を使っています。
を右手の法則で確かめましょう。
右手の親指を+y(上)に向けて指を曲げると、指は+zから+xの方へ向かいます。
同じ事ですが、上から、つまり+yから原点の方を見下ろしてみます:
此の図では、正の回転は反時計回りです。
+x(右)は-z(上)の方へ、+z(下)は+x(右)の方へ回ります。
(、)では、は:
1列目は、3列目はを表していて、図と一致します。
をY軸周りに90°回転させる完全な例です:
高さ(y = 2)は変わっていません。
縦の軸の周りに回したのだから当然です。
斜めの軸の周りの回転も出来ます。
其の一般的な公式は第13章で作ります。
せん断
せん断は、トランプの山の上の方を横に押した時の様に、層同士を滑らせる変換です。
はそのままにして、をだけ傾けます:
高い所にある点程、遠くへ滑ります。
xは高さの倍だけ動きます。
なら、点はへ行きます。
単位正方形の上の角とは、とへ行きます。
底辺は元の場所のままです。
此れが8.1節の斜めの格子で、第6章で描いた行列と同じ種類です(第6章の方はxを2倍にも伸ばしていました)。
3Dでは6種類のせん断があります(各軸が他の各軸の方向へ傾けます)が、どれも同じ形です。
単位行列の対角線の外に、数字が一つ増えただけです。
鏡映
鏡映(反射)は、鏡を見る時の様に空間を裏返します。
一番簡単な作り方は、負の倍率の拡大縮小です。
xの倍率をにすると、はへ行き、はそのままです:
此れはy軸を鏡にした鏡映で、左右が入れ替わります。
3Dでは、がy軸とz軸が作る平面を鏡にします。
他の鏡も、列から同じ様に簡単に作れます。
対角線を鏡にすると、二つの軸が入れ替わります。
、です:
第4章の反射の公式を覚えていますか?
此れは、単位法線に垂直な直線(又は平面)を鏡にしてベクトルを反射させる式で、此れも線型です。
従って行列があり、いつもの方法、つまりとを反射させる事で求められます。
とすると、鏡の直線はです。
確認:はへ行き、此れは正に直線に対する鏡像です。
鏡映は回転には出来ない事をします。
左手を右手に変えてしまうのです。
反時計回りが時計回りになります。
此れを覚えておいて下さい。
8.4節の三角形で大問題になります。
押し潰し(軸への射影)
倍率が0だったらどうなるでしょうか?
其の軸は完全に潰れます。
次の行列はxとzを残し、yを捨てます:
全ての点が真下の地面に押し付けられ、平らになります。
此れが射影で、3Dの物体が平らな形になります。
まだ線型です(格子は均等なまま、高さがゼロになっただけ)が、新しい事が起きています。
情報が失われたのです。
押し潰した後では、もも同じになり、元がどちらだったか知る方法はありません。
此の様な行列は元に戻せません。
其れを一つの数字で見分ける方法は第10章で扱います。
3Dを画面上で3Dらしく見せる投影はもっと賢い物で、其れは第12章です。
変換を組み合わせる
どれも行列なので、第7章の行列の積で繋げられます。
右から左へ読むのでしたね。
x方向に2倍する拡大縮小と、Z軸周りの90°回転を、に掛けてみます:
- :先に拡大縮小で、次に回転で。
- :先に回転で、次に拡大縮小で。yは拡大されないからです。
第7章の長方形と同じく、結果が違います。
モデルを拡大縮小する時は、殆どの場合モデル自身の軸で拡大縮小したいので、拡大縮小を最初(一番右)にします:。
組み合わせは、古い変換から新しい変換を作る方法でもあります。
の回転を二回するとです。
同じ鏡で二回鏡映すると単位行列に戻ります。
そして鏡映の後に回転しても、其れはまだ鏡映です(左右が入れ替わったままです)。
線型ではない物:平行移動
全てのゲームに必要なのに、まだ作っていない変換が一つあります。
物を動かす事、です。
此れは原点を動かすので線型ではありません:。
そして、どんな行列でもゼロベクトルに掛けるとゼロベクトルなので、3 × 3行列では決して出来ません。
一つの行列で全部をやりたいので、此れは本当に困ります。
第9章では、4番目の座標を足すという見事な工夫で此れを解決します。
此の章の内容は全部3 × 3で足りるのに、C++の関数がMat4を返すのは其の為です。
8.4 グラフィックスプログラミングでの使い方
物体の拡大縮小、回転、スピン
物体のモデル行列(第9章)は、殆どが此の章の行列から作られます。
高さを2倍にしたい木箱:scale({1, 2, 1})。
90°開いたドア:rotateY(radians(90.0f))。
森がコピペに見えない様にランダムに回した木:rotateY(randomAngle)。
回るコインや回転するアイテムは、時間と共に増える角度を使うだけです。
CPUでは角度をtime * speed(速度はラジアン毎秒)にして、毎フレームrotateY(angle)を作り直します。
簡単なエフェクトなら、頂点シェーダーの中で直接回転させる事も出来ます。
次はY軸周りの回転をするシェーダー関数です。
またコンストラクターに注目して下さい。
GLSLだけ違う順番で書いてありますが、3つとも同じ行列を作っています:
float3 rotateAroundY(float3 p, float angle) {
float c = cos(angle);
float s = sin(angle);
// float3x3(...) fills row by row
float3x3 r = float3x3(
c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c);
return mul(r, p);
}vec3 rotateAroundY(vec3 p, float angle) {
float c = cos(angle);
float s = sin(angle);
// mat3(...) fills column by column
mat3 r = mat3(
c, 0.0, -s,
0.0, 1.0, 0.0,
s, 0.0, c);
return r * p;
}float3 rotateAroundY(float3 p, float angle) {
float c = cos(angle);
float s = sin(angle);
// float3x3(...) fills row by row
float3x3 r = float3x3(
c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c);
return mul(r, p);
}
2Dゲームでは2 × 2の回転を常に使います。
マウスの方を向くスプライト、見下ろし型レースゲームの車、時計の針、回るローディングアイコン等です。
普通はスプライトの四角形の四つの角を、CPUか頂点シェーダーで回転させます。
キャラクターの鏡映と、ワインディングの問題
多くのゲームでは、キャラクター、武器、建物を片側だけモデリングし、反対側が必要な時はscale({-1, 1, 1})で鏡映してメモリを節約します。
此れは上手く行きますが、罠があります。
第5章で、GPUは三角形のどちらが表かをワインディング順序(頂点の並び順)で決める事を見ました。
OpenGLのデフォルトでは、反時計回りの頂点が「こちらを向いている」という意味です。
鏡映は反時計回りを時計回りに変えます。
従って鏡映の後は、全ての三角形が急に裏を向いている様に見え、背面カリングが間違った側を捨て、モデルの内側が見えてしまいます。
解決策は、鏡映した物体を描く間だけ表面の設定(OpenGLのglFrontFace、Vulkanのラスタライズ状態のfrontFace)を反転するか、三角形の頂点の順番を逆にする事です。
でも、行列が鏡映を含んでいるかどうか、エンジンはどうやって知るのでしょうか?
scale({-1, 1, 1})一つなら明らかですが、回転、拡大縮小、親を組み合わせた後では分かりません。
第10章では、其れを教えてくれる一つの数字、行列式を扱います。
其れが負なら、行列は鏡映を含んでいます。
せん断:斜体の文字と斜めの形
フォントに本物の斜体が無い時、テキストレンダラーは良くグリフをせん断して偽の斜体(オブリーク)を作ります。
でを0.2くらいにすると、文字が約11°傾きます。
せん断は、草が風になびくエフェクト(各葉の先端が根元より大きく滑る)、アイソメトリックの2Dアート、昔ながらの「スキュー」ツールにも出てきます。
スカッシュ&ストレッチ
アニメーターは、跳ねる物が地面に当たった時に潰し、飛んでいる間に伸ばす事で生き生きと見せます。
此れは非一様な拡大縮小です。
質量が増えたり減ったりした様に見えない様に、体積を同じに保ちます。
yを倍に伸ばすなら、xとzを倍にします。
ならで、体積の倍率はです。
(倍率を掛け合わせると何故体積の変化になるのかは、第10章の行列式で分かります。)
テクスチャ座標の回転
フラグメントシェーダーでは、良くUV座標を回転させます。
レーダーのスイープ、渦巻くポータル、デカールの回転等です。
UVは0から1なので、中心の周りに回すには、まず中心を原点に移し(0.5を引く)、回転して、元に戻します:
float2 rotateUV(float2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
float2x2 r = float2x2(
c, -s,
s, c);
return mul(r, uv - 0.5) + 0.5;
}vec2 rotateUV(vec2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
mat2 r = mat2(
c, s,
-s, c);
return r * (uv - 0.5) + 0.5;
}float2 rotateUV(float2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
float2x2 r = float2x2(
c, -s,
s, c);
return mul(r, uv - 0.5) + 0.5;
}
皆が驚く事が一つあります。
テクスチャを読む座標を回転させると、絵は逆向きに回って見えるのです。
テクスチャをangleだけ反時計回りに回したいなら、-angleを渡して下さい。
(引いて足し戻す部分は平行移動なので、まだ行列の中に入れられません。
其れも第9章で解決します。)
8.5 其の他の使い道
- 画像編集: GIMP、Krita、Photoshopの回転、反転、スキューのツールは、ピクセルの座標に正に此れらの2D行列を掛けています。
- CADと3Dプリント: 「パーツの鏡映」や「サイズに合わせて拡大縮小」は鏡映と拡大縮小の行列です。鏡映した時は、ゲームと同じく書き出すSTLの三角形のワインディングも反転する必要があります。
- ロボット工学: ロボットアームの回転する関節は其れぞれが軸周りの回転で、アームは其れらの連鎖です(第7章)。
- 材料科学: エンジニアは、荷重を受けた材料の変形を3 × 3行列で表します。対角線上が伸び縮み、対角線の外がせん断です。
- 結晶学: 結晶の対称性(結晶を同じ見た目のままにする回転や鏡映)は、少数の回転行列と鏡映行列の集まりです。
- 統計学: 主成分分析(PCA)は、点の集まりが最も広がっている方向に軸を揃える回転を見付けます。
8.6 コード
linalg.hhの新しい関数です。
どれも単位行列から始めて、違う要素だけを設定します。
at(row, col)を使っているので、コードが数式と同じ様に読めます:
// 拡大縮小 / scale (chapter 8)
inline Mat4 scale(Vec3 s) {
Mat4 m = Mat4::identity();
m.at(0, 0) = s.x;
m.at(1, 1) = s.y;
m.at(2, 2) = s.z;
return m;
}
// 軸周りの回転 / rotation around the axes (chapter 8)
inline Mat4 rotateX(float angle) {
float c = std::cos(angle), s = std::sin(angle);
Mat4 m = Mat4::identity();
m.at(1, 1) = c; m.at(1, 2) = -s;
m.at(2, 1) = s; m.at(2, 2) = c;
return m;
}
inline Mat4 rotateY(float angle) {
float c = std::cos(angle), s = std::sin(angle);
Mat4 m = Mat4::identity();
m.at(0, 0) = c; m.at(0, 2) = s;
m.at(2, 0) = -s; m.at(2, 2) = c;
return m;
}
inline Mat4 rotateZ(float angle) {
float c = std::cos(angle), s = std::sin(angle);
Mat4 m = Mat4::identity();
m.at(0, 0) = c; m.at(0, 1) = -s;
m.at(1, 0) = s; m.at(1, 1) = c;
return m;
}
rotateYをと比べて下さい。
at(0, 2)(数学では1行3列)がで、at(2, 0)(3行1列)がです。
角度はラジアンです。
度で考えたい時は、第1章のradians()を使って下さい。
4番目の行と列は単位行列のままで、此れは第9章で意味を持ちます。
ヘッダーにはshearやreflectという関数はありません。
せん断は単位行列にat()を一回するだけで、鏡映は負の倍率のscaleだからです。
テストプログラム
8.3節の循環の三つの4分の1回転(xからy、yからz、zからx)、Y軸の追加の例、拡大縮小、「変換を組み合わせる」の二つの順番、そして鏡映を確かめます。
ここではベクトルのwを0にしています。
此の4番目の成分は第9章まで関係ありません。
#include "linalg.hh"
#include <cstdio>
void printVec4(const char *name, Vec4 v) {
printf("%-24s = (%.3f, %.3f, %.3f)\n", name, v.x, v.y, v.z);
}
int main() {
Vec4 x = {1, 0, 0, 0};
Vec4 y = {0, 1, 0, 0};
Vec4 z = {0, 0, 1, 0};
printVec4("rotateZ(90) * x", rotateZ(radians(90.0f)) * x);
printVec4("rotateX(90) * y", rotateX(radians(90.0f)) * y);
printVec4("rotateY(90) * z", rotateY(radians(90.0f)) * z);
printVec4("rotateY(90) * x", rotateY(radians(90.0f)) * x);
Vec4 p = {1, 1, 1, 0};
printVec4("scale(2,3,4) * p", scale({2, 3, 4}) * p);
// Scale first, then rotate: read right to left.
Mat4 rs = rotateZ(radians(90.0f)) * scale({2, 1, 1});
Mat4 sr = scale({2, 1, 1}) * rotateZ(radians(90.0f));
printVec4("(R * S) * x", rs * x);
printVec4("(S * R) * x", sr * x);
// A mirror: negative scale on x.
printVec4("scale(-1,1,1) * (3,2,0)", scale({-1, 1, 1}) * Vec4{3, 2, 0, 0});
return 0;
}
期待される出力:
rotateZ(90) * x = (-0.000, 1.000, 0.000)
rotateX(90) * y = (0.000, -0.000, 1.000)
rotateY(90) * z = (1.000, 0.000, -0.000)
rotateY(90) * x = (-0.000, 0.000, -1.000)
scale(2,3,4) * p = (2.000, 3.000, 4.000)
(R * S) * x = (-0.000, 2.000, 0.000)
(S * R) * x = (-0.000, 1.000, 0.000)
scale(-1,1,1) * (3,2,0) = (-3.000, 2.000, 0.000)
1行目は定番の確認で、(1, 0, 0)をZ軸周りに90°回すと(0, 1, 0)になります。
では、あの-0.000は何でしょうか?
floatはを正確に保存出来ないので、std::cos(radians(90.0f))は丁度0ではなく、約になります。
小数点以下3桁で出力すると、其の小さな負の数が-0.000と表示されるのです。
害はありませんが、良い教訓です。
浮動小数点の結果は決して==で比べず、小さな許容誤差を使って比べて下さい。
8.7 まとめ
- 線型変換は原点を動かさず、格子の線を真っ直ぐ、平行、等間隔に保ちます。正式には、です。
- 全ての線型変換は、列が、、の行列で、全ての行列は線型変換です。
- 拡大縮小:倍率は対角線上に並びます。一様なら形を保ち、非一様なら伸びたり潰れたりします。
- 2Dの回転:、。回転は長さを変えません。
- X、Y、Z軸周りの3Dの回転はxからy、yからz、zからxの循環に従います。だからのマイナス記号は左下にあります。此れらは
rotateX、rotateY、rotateZと一致します。 - せん断:単位行列に対角線の外の数字を一つ足した物。鏡映:負の拡大縮小(又は鏡に映した列で作った行列)で、三角形のワインディングを反転させます。
- 倍率0は空間を押し潰します。此れは射影で、情報が失われます。
- 組み合わせは行列の積で、右から左へ。平行移動は線型ではなく、第9章が必要です。
8.8 課題
- ★ は線型ですか?線型なら、とを求めて行列を書いて下さい。は線型ですか?
- ★ を90°と180°回転させて下さい(図ではなく回転行列を使って)。次にを使って、を45°回転させて下さい。y軸上の点になるはずですが、どれくらいの高さですか?
- ★ を数字で書き、を回転させて下さい。答えを右手の法則で確かめましょう。親指を+xに向けた時、指は+zをどちらへ押しますか?
- ★ の2Dせん断行列を書いて、に掛けて下さい。次に、x軸を鏡にする行列とy軸を鏡にする行列を書いて下さい。其の二つの鏡映を掛け合わせると何になり、其れはどんな回転ですか?
- ★★ C++で
rotateX(radians(90.0f)) * Vec4{0, 0, 1, 0}(約(0, -1, 0)になるはず)とrotateZ(radians(45.0f)) * Vec4{2, 2, 0, 0}(約(0, 2.828, 0)になるはず)を出力して下さい。次に、単位行列から始めてrotateZ(radians(30.0f))を12回掛け、結果を出力して下さい。僅かな浮動小数点の誤差を除いて、単位行列に戻るはずです。 - ★★ UV座標から市松模様を描くフラグメントシェーダーをSlang、GLSL又はHLSLで書き、時間のユニフォームと
rotateUVを使って、市松模様を画面の中心の周りに回転させて下さい。(ヒント:floorは数を整数に切り捨てます。floor(uv.x * 8.0)とfloor(uv.y * 8.0)の和が偶数なら一色、奇数ならもう一色にします。画面上でどちら回りになるかはUVのy軸が上向きか下向きかで変わるので、確認して必要なら角度の符号を反転して下さい。) - ★★★ 2Dの回転がベクトルの長さを決して変えない事を証明して下さい。を展開し、を使います。次に、ベクトルとを使って、せん断が長さを変える理由を説明して下さい。
コメント(0)
まだコメントはありません。
ログインするとコメント出来ます。