11. 座標空間とカメラ
此処数章で、かなり実用的な道具箱が揃いました。
物を拡大縮小・回転・移動させる行列(第8章と第9章)、そして其れを元に戻す逆行列(第10章)です。
此の章では、其の道具箱を3Dプログラムで最も重要な物、つまりカメラに使います。
1つの頂点がモデルファイルからピクセルになる迄の旅を追い掛け、「座標空間を変える」とは本当は何なのかを学び、有名なlookAtビュー行列を一から作ります。
最後には、一人称カメラとオービットカメラを自分で書ける様になります。
11.1 頂点の旅
友達に自分のマグカップの場所を教える場面を想像して下さい。
「キーボードの10cm左」とも言えますし、「3階の302号室」とも言えますし、GPS座標を伝える事も出来ます。
どれも同じマグカップです。
変わったのは基準座標系だけです。
つまり、原点を何処に置くかと、どの方向を軸と呼ぶかです。
3Dプログラムは、全ての頂点に対して毎フレーム何度も此れと同じ事をしています。
其々の基準座標系を空間と呼び、行列が点を或る空間から次の空間へ運びます:
| 空間 | 原点は何処? | 何に使う? |
|---|---|---|
| オブジェクト空間(ローカル空間) | モデルのピボット(アーティストが決める) | ファイルに保存された頂点データ |
| ワールド空間 | シーンの或る固定点 | 物体の配置、物理、ライト |
| ビュー空間(カメラ空間) | カメラの目の位置 | 此処ではカメラは−Z方向を向く |
| クリップ空間 | 単純な原点は無い(4Dの同次座標) | GPUが画面外の部分を切り取る |
| NDC(正規化デバイス座標) | 画面の中心 | xとyが−1〜1に収まる |
| スクリーン空間 | ウィンドウの角 | 実際のピクセル |
最初の3つの矢印は、頂点シェーダーで自分で書きます。
最後の2つはGPUがやってくれます。
モデル行列は既に知っていますね。
第9章の行列です。
投影行列は次の章で丸ごと扱います。
此の章のテーマはビュー行列と、上の図の全ての矢印の裏に隠れている1つの考え方、基底変換です。
11.2 基底変換:公式
まずは図です。
下のグリッドには普通のx軸とy軸がありますが、別の人は傾いた軸とを使っているとします。
2人とも同じ点Pを表せますが、書き出す数字が違うだけです。
新しい基底ベクトルを古い座標で書き、其れを列として行列に並べます:
新しい軸に沿って測った座標がである点の、古い座標は次の通りです:
逆方向に行くには、逆行列(第10章)でを元に戻します:
更に、新しい軸が正規直交(どの軸も長さが1で、互いに全て垂直)なら、逆行列は単なる転置になります:
新しい座標系が自分の原点(古い座標で表した物)も持っている場合は、第9章の4×4の技を使い、を4列目に入れます:
11.3 基底変換の仕組み
とは何を意味するのでしょうか。
「に沿って2歩、其の後に沿って1歩進む」という意味です。
古い座標では、其の歩みはです。
第6章を思い出して下さい。
行列とベクトルの積は、正に行列の列の線型結合でした。
従ってとなり、此れが公式の全てです。
逆方向は「此の点はに沿って何歩の所にある?」という質問です。
正規直交基底なら、第4章が既に答えてくれています。
単位ベクトルとの内積は、ベクトルが其の方向にどれだけ伸びているかを測ります。
従って新しい座標は単にとです。
複数の内積を一度に計算するのは、とを行に持つ行列で、其の行列こそがです。
此れが、正規直交行列を転置で元に戻せる理由です。
第10章で回転に使ったのと同じ事実ですね。
2Dの計算例
新しい軸を次の様にします:
まず正規直交である事を確認しましょう:
良いですね。
どちらも長さ1で、互いに垂直です。
(普通のx軸とy軸を約53°回転させただけの物です。)
では、新しい座標がの点を考えます。
新から旧へ:
そして転置(行がと)で旧から新へ戻します:
に戻りました。
往復は成功です。
もし新しい軸が正規直交でなければ(例えば片方の長さが2なら)、転置では間違った答えになり、第10章の本物の逆行列が必要になります。
此の節で覚えて欲しい一文は此れです:
座標系Aの軸(と原点)を座標系Bの座標で書き、其れを列に並べた行列は、点をAからBへ変換し、其の逆行列はBからAへ戻します。
モデル行列は正に此れです。
其の列はワールド座標で書いた物体の軸と位置なので、オブジェクト空間からワールド空間へ変換します。
11.4 物体としてのカメラ
カメラはワールドの中にある物体の1つに過ぎません。
位置、つまり目の位置(eye)と、3つの単位ベクトルで表す向きを持っています:
- :右(right)
- :上(up)
- :前(forward)、カメラが見ている方向
此の3つは正規直交基底です。
此処で、シリーズ全体の約束を思い出して下さい。
ビュー空間では、カメラは−Z方向を向きます。
+Xが右、+Yが上の右手座標系では、+Zは画面からこちら側、つまりあなたの方を向きます。
従って「画面の奥」は−Zで、カメラ自身の+Z軸は後ろ、つまりの方向を向きます。
手系も確認出来ます。
はと同じで後ろを向くので、です。
前の節のルールを使うと、カメラ座標をワールド座標に変換する行列は、カメラの軸と位置を列に持ちます:
此れがカメラのワールド行列で、他の物体のモデル行列と全く同じ物です。
回転(最初の3列)の後に目の位置への平行移動が続くので、です。
11.5 ビュー行列とlookAt:公式
でも欲しいのはカメラからワールドへの変換ではありません。
手元にあるのはワールド空間の頂点で、知りたいのは其れがカメラから見て何処にあるか、つまりワールドからカメラへの変換です。
其れは逆行列です:
此れがビュー行列です。
面白い考え方があります。
GPUはカメラを動かしません。
代わりにビュー行列がワールド全体を動かし、カメラが原点に来て−Zを向く様にします。
カメラを1m右に動かす事は、ワールド全体を1m左にずらす事と同じなのです。
此処では一般的な4×4の逆行列は要りません。
第10章で安い方法を学んだからです。
「靴下と靴」の、回転の逆行列は転置、平行移動の逆行列は逆向きの平行移動です:
の行は、、です。
を掛けると、まず目の位置を引き、其の結果と各行の内積を取るので、平行移動の列はになります:
行毎に読むと意味が分かります。
点の新しいxは「目からどれだけ右にあるか」、新しいyは「どれだけ上にあるか」、新しいzは「どれだけ後ろにあるか」(負なら前)です。
勿論、、、を手で打ち込みたい人は居ません。
lookAt関数は、3つの簡単な情報から其れらを作ります。
カメラが何処にあるか()、何を見ているか(ターゲット)、そして大体どちらが上か(、普通はワールドの)です:
11.6 lookAtの仕組みを一歩ずつ
3行を1つずつ説明します。
- 前は目からターゲットへの矢印(第2章:ターゲット引く目)を、長さ1に正規化した物です(第3章)。
- 右は前とも上とも垂直でなければならず、外積(第5章)は正に両方に垂直なベクトルをくれます。前と上のヒントは普通垂直ではない(斜め下を見ているカメラ等)ので、外積の長さは1より小さいになり、其の為に正規化します。
- 本当の上は右とも前とも垂直です。とは90°を成す単位ベクトルでなので、最初から長さ1です。此れはワールドの上ではなく、カメラと一緒に傾いたカメラ本来の上です。
一番単純なカメラ、つまり−Zを向いて上が+Yのカメラで方向を確認しましょう。
するとで、+X、つまり右です。
そしてで、上です。
外積の中の順番は大事です(第5章:入れ替えると符号が反転します)。
此の順番が、あたし達の右手座標系に合う物です。
計算例
カメラはにあり、原点を見ていて、上のヒントはです。
ステップ1、前。
ステップ2、右。 外積の公式にを入れます:
長さは既にです(此のカメラは完全に水平を見ているからです)。
従ってです。
ステップ3、本当の上。
ステップ4、平行移動の列。
ステップ5、組み立てる。 行は、、です:
ステップ6、確認する。
ターゲット(原点)は距離5の真正面にある筈です。
を掛けると最後の列が其の儘出て来るので、です。
真正面に5、ぴったりです。
目の位置自体は、、となり、カメラはビュー空間の原点にあります。
では点はどうでしょう:
従ってビュー空間ではにあります。
右に5、上に2、前に5です。
此の点を覚えておいて下さい。
第12章で、此の点がどのピクセルに映るかを求めます。
lookAtが壊れる時
カメラが真上又は真下を向くと、前が上のヒントと平行になります。
するとはゼロベクトルになります(第5章:平行なベクトルの外積はゼロ)。
第3章のnormalizeはゼロ除算の代わりにゼロベクトルを返すので、ももゼロになり、行列は全てを平らに潰し(行列式が0、第10章)、画面は真っ暗になるか、ちらつきます。
対策は簡単です。
カメラを上のヒントと完全に同じ方向に向けない(下のピッチ制限を参照)か、其の場合だけ別の上のヒント、例えば真上から見下ろすマップカメラならを渡します。
11.7 一人称カメラとオービットカメラ
ヨーとピッチから作る一人称カメラ
一人称のゲームでは、ターゲットの点は保存しません。
代わりにマウスで変わる2つの角度を保存します:
- ヨー:左右を向く事、ワールドの上軸Y周りの回転
- ピッチ:上下を見る事、カメラの右軸周りの回転
前方向から始めて、X周りにピッチ、次にY周りにヨーを掛けます。
第8章のrotateXとrotateYを使うとです。
ピッチの段階でになり、ヨーの段階で其れをY周りに回します。
掛け算を展開すると:
第1章のを2回使うと、常に長さ1である事が分かります:
計算例:ヨー60°、ピッチ30°。
必要なのは、、、です。
確認:です。
カメラは−Zより左側(右手の法則により、正のヨーは上から見て反時計回りだから)、そして30°上を向いています。
つまり、マウスを右に動かした時はヨーを減らす必要があります。
ピッチの制限。 ピッチが±90°になると、前はになり、ワールドの上と平行になります。
正に前の節の壊れるケースです。
90°を超えると視界が上下逆さまになります。
其の為、一人称カメラは必ずピッチを90°より少し小さい値に制限します。
小さなカメラ更新関数は次の通りです(forwardFromYawPitchは上の公式をコードにした物で、11.10節にあります):
struct FpsCamera {
Vec3 eye{0, 2, 0};
float yaw = 0.0f, pitch = 0.0f;
};
// mouseDx, mouseDy: how far the mouse moved this frame, in pixels
inline Mat4 updateFpsCamera(FpsCamera &cam, float mouseDx, float mouseDy, float sensitivity) {
cam.yaw -= mouseDx * sensitivity; // mouse right -> turn right
cam.pitch -= mouseDy * sensitivity; // screen y grows downwards
const float limit = radians(89.0f);
if (cam.pitch > limit) cam.pitch = limit;
if (cam.pitch < -limit) cam.pitch = -limit;
Vec3 forward = forwardFromYawPitch(cam.yaw, cam.pitch);
return lookAt(cam.eye, cam.eye + forward, {0, 1, 0});
}
歩く時のちょっとしたコツです。
ピッチを含まない前方向、forwardFromYawPitch(yaw, 0.0f)に沿って移動して下さい。
そうしないと床を見ると歩くのが遅くなります。
第3章の斜め移動のバグと同じ種類のバグです。
横移動の方向はnormalize(cross(forward, up))です。
オービットカメラ
モデルビューアーやストラテジーゲームでは、ターゲットの周りを回るカメラが良く使われます。
同じ2つの角度に距離を加え、目の位置をターゲットの後ろ、前方向と逆側に置きます:
其の後lookAt(eye, target, up)でビュー行列を作ります。
マウスのドラッグでヨーとピッチを、ホイールでを変えます。
負のピッチは下を見るので、目の位置はターゲットより上になります。
例えばターゲット、、ヨー0、ピッチ−30°なら、でとなり、ターゲットの上かつ後ろです。
11.8 グラフィックスプログラミングでの使い方:P V M
全ての頂点が連鎖全体を通ります。
何時も通り右から左へ読みます(第7章):
3つの行列は変わる頻度が違います:
- は物体毎に違います。
- はカメラが動く度、つまり1フレームに1回変わります。
- はウィンドウのサイズか視野角が変わった時だけ変わります。
行列の積は結合法則を満たす(第7章)ので、一部をCPUで掛けておき、送る行列を減らせます。
良くある構成は、を1フレームに1回、を物体毎に送る物です。
シェーダーはライティングの為にワールド座標が必要な事が殆どだからです。
3つ全部を使う素直な版は次の通りです:
struct Matrices {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<Matrices> matrices;
struct VertexOut {
float4 position : SV_Position;
float3 worldPos : TEXCOORD0;
};
[shader("vertex")]
VertexOut vertexMain(float3 position : POSITION) {
VertexOut o;
float4 world = mul(matrices.model, float4(position, 1.0));
float4 viewPos = mul(matrices.view, world);
o.position = mul(matrices.proj, viewPos);
o.worldPos = world.xyz;
return o;
}#version 450
layout(set = 0, binding = 0) uniform Matrices {
mat4 model;
mat4 view;
mat4 proj;
};
layout(location = 0) in vec3 inPosition;
layout(location = 0) out vec3 outWorldPos;
void main() {
vec4 world = model * vec4(inPosition, 1.0);
vec4 viewPos = view * world;
gl_Position = proj * viewPos;
outWorldPos = world.xyz;
}struct Matrices {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<Matrices> matrices;
struct VertexOut {
float4 position : SV_Position;
float3 worldPos : TEXCOORD0;
};
VertexOut vertexMain(float3 position : POSITION) {
VertexOut o;
float4 world = mul(matrices.model, float4(position, 1.0));
float4 viewPos = mul(matrices.view, world);
o.position = mul(matrices.proj, viewPos);
o.worldPos = world.xyz;
return o;
}今作った物の日常的な使い道を他にも挙げます:
- ライティング用のカメラ位置。 スペキュラーハイライトにはワールド空間の目の位置が必要です。其れは、つまりカメラのワールド行列の4列目です。CPUで計算してuniformとして送ります。
- ビルボード。 パーティクルや名札は常にカメラの方を向くべきです。ビュー行列の最初の2行はワールド空間でのカメラの右と上なので、其れらから四角形の角を作れます。
- スカイボックス。 空は歩いても近付いてはいけません。ビュー行列をコピーして4列目をに置き換えれば、回転だけが残ります:
sky.c[3] = {0, 0, 0, 1}; - フォグとソート。 ビュー空間ではがカメラの前方の距離です。フォグや、半透明の物体を奥から手前へ並べ替える時に便利です。
ビルボードの関数は次の通りです。
第6章のインデックスの違いに注意して下さい。
SlangとHLSLでは行をview[0]で読みますが、GLSLは列が先なので、同じ行がview[0][0]、view[1][0]、view[2][0]に散らばっています。
float3 billboardCorner(float4x4 view, float3 center, float2 corner, float size) {
float3 right = float3(view[0][0], view[0][1], view[0][2]);
float3 up = float3(view[1][0], view[1][1], view[1][2]);
return center + (corner.x * right + corner.y * up) * size;
}vec3 billboardCorner(mat4 view, vec3 center, vec2 corner, float size) {
vec3 right = vec3(view[0][0], view[1][0], view[2][0]);
vec3 up = vec3(view[0][1], view[1][1], view[2][1]);
return center + (corner.x * right + corner.y * up) * size;
}float3 billboardCorner(float4x4 view, float3 center, float2 corner, float size) {
float3 right = float3(view[0][0], view[0][1], view[0][2]);
float3 up = float3(view[1][0], view[1][1], view[1][2]);
return center + (corner.x * right + corner.y * up) * size;
}11.9 其の他の使い道
基準座標系の変換は、気を付けて見ると何処にでもあります:
- ロボット工学。 ロボットの関節、グリッパー、カメラは其々自分の座標系を持っています。「グリッパーから見てカップは何処?」は基底変換です。ROSはtfライブラリで此れらの座標系の木構造を丸ごと管理しています。
- AR及びVR。 ヘッドセットは頭の位置と向きを追跡し、毎フレーム其処からビュー行列を作ります。左右の目に1つずつ、目の間隔の半分ずらして作ります。
- GPSとドローン。 GPSは地球中心の座標系で位置を返します。ドローンや自動運転車は其れを基準点周りのローカルな東・北・上(ENU)座標系に変換します。回転と平行移動です。
- 天文学。 星の位置は赤道座標、黄道座標、銀河座標で表され、決まった回転行列で互いに変換されます。
- 航空機。 パイロットや飛行ソフトウェアは機体に固定された機体座標系(前、右翼、下)を使います。其の向きはヨー・ピッチ・ロールで表され、其れが第13章に繋がります。
11.10 コード
linalg.hhにあるlookAtは次の通りです。
此れを11.5節と1行ずつ比べてみて下さい。
行は、、で、平行移動の列は、、です。
// ビュー行列:カメラがeyeからtargetを見る。カメラは自分の-Z方向を向く。
// View matrix: camera at eye looking at target. The camera looks down its own -Z axis.
inline Mat4 lookAt(Vec3 eye, Vec3 target, Vec3 up) {
Vec3 f = normalize(target - eye); // 前 / forward
Vec3 r = normalize(cross(f, up)); // 右 / right
Vec3 u = cross(r, f); // 上 / up
Mat4 m = Mat4::identity();
m.at(0, 0) = r.x; m.at(0, 1) = r.y; m.at(0, 2) = r.z; m.at(0, 3) = -dot(r, eye);
m.at(1, 0) = u.x; m.at(1, 1) = u.y; m.at(1, 2) = u.z; m.at(1, 3) = -dot(u, eye);
m.at(2, 0) = -f.x; m.at(2, 1) = -f.y; m.at(2, 2) = -f.z; m.at(2, 3) = dot(f, eye);
return m;
}
そして11.7節のforwardFromYawPitchヘルパーを使ったテストプログラムです。
ビュー行列の逆行列(第10章)が本当にカメラのワールド行列なのかも、4列目から目の位置を読んで確認しています。
#include <cstdio>
#include "linalg.hh"
// yaw: turn around +Y, pitch: look up (+) or down (-). yaw = pitch = 0 looks down -Z.
inline Vec3 forwardFromYawPitch(float yaw, float pitch) {
float cp = std::cos(pitch);
return {-std::sin(yaw) * cp, std::sin(pitch), -std::cos(yaw) * cp};
}
static void printVec(const char *name, Vec4 v) {
printf("%-8s (%6.3f, %6.3f, %6.3f, %6.3f)\n", name, v.x, v.y, v.z, v.w);
}
static void printMat(const Mat4 &m) {
for (int row = 0; row < 4; ++row)
printf(" [%6.3f %6.3f %6.3f %6.3f]\n", m.at(row, 0), m.at(row, 1), m.at(row, 2), m.at(row, 3));
}
int main() {
// a camera at (3, 0, 4) looking at the origin
Mat4 view = lookAt({3, 0, 4}, {0, 0, 0}, {0, 1, 0});
printf("view matrix:\n");
printMat(view);
printVec("origin", view * Vec4{0, 0, 0, 1});
printVec("eye", view * Vec4{3, 0, 4, 1});
printVec("p", view * Vec4{4, 2, -3, 1});
// the camera's world matrix is the inverse of the view matrix
Mat4 camWorld = inverse(view);
printVec("camPos", camWorld.c[3]);
// first-person camera: yaw 60 degrees, pitch 30 degrees
Vec3 f = forwardFromYawPitch(radians(60.0f), radians(30.0f));
printf("forward (%6.3f, %6.3f, %6.3f) length %.3f\n", f.x, f.y, f.z, length(f));
Vec3 eye{0, 2, 0};
Mat4 fps = lookAt(eye, eye + f, {0, 1, 0});
printVec("ahead", fps * Vec4{eye.x + 2 * f.x, eye.y + 2 * f.y, eye.z + 2 * f.z, 1});
return 0;
}
何時も通りg++ -std=c++20 main.cc -o mainでコンパイルして実行します:
view matrix:
[ 0.800 0.000 -0.600 -0.000]
[ 0.000 1.000 0.000 -0.000]
[ 0.600 -0.000 0.800 -5.000]
[ 0.000 0.000 0.000 1.000]
origin ( 0.000, 0.000, -5.000, 1.000)
eye ( 0.000, 0.000, 0.000, 1.000)
p ( 5.000, 2.000, -5.000, 1.000)
camPos ( 3.000, 0.000, 4.000, 1.000)
forward (-0.750, 0.500, -0.433) length 1.000
ahead ( 0.000, 0.000, -2.000, 1.000)
-0.000は気にしないで下さい。
浮動小数点数はゼロにも符号ビットがあるので、は「負のゼロ」になりますが、0と等しいと判定されます。
コンパイラーによっては其処が0.000と表示される事もあります。
最後の行は良い確認です。
カメラの前方向に2進んだ点が、つまり−Zの真正面に来ています。
次の章では、ビュー行列の前に投影行列を置き、次の様に1つのビュー投影行列に纏めます:
Mat4 viewProj = perspectiveGl(radians(60.0f), 16.0f / 9.0f, 0.1f, 100.0f) * view;
11.11 まとめ
- 空間とは、原点と軸の選び方の事です。頂点はM、V、P、wでの除算、ビューポート変換を通って、オブジェクト→ワールド→ビュー→クリップ→NDC→スクリーンと旅します。
- 座標系Aの軸(と原点)を座標系Bで書いて列に並べた行列は、AからBへ変換します。其の逆行列はBからAへ変換します。
- 正規直交基底なら逆行列は転置です。新しい座標は軸との内積に過ぎないからです。
- カメラは目の位置と、右・上・前の軸を持つ物体です。カメラは自分の−Z方向を向きます。
- ビュー行列はカメラのワールド行列の逆行列です。行は、、、平行移動は、、です。
lookAtは前をターゲット引く目から、右をcross(f, up)から、本当の上をcross(r, f)から作ります。前が上と平行だと壊れます。- ヨーとピッチからsinとcosで前ベクトルが作れます。ピッチは90°未満に制限します。
- 全ての頂点はで右から左へ変換されます。
11.12 課題
- ★ 2Dの座標系の軸がとです。此の座標系でにある点の古い座標は?次に、古い点を新しい座標系に変換して下さい。
- ★ 、、が正規直交で右手系()である事を確かめ、此の基底でのの座標を求めて下さい。
- ★ 目、ターゲット、上のビュー行列を手で作って下さい。正規化する前のの長さを書き出し、何故1にならないのか説明して下さい。
- ★ ヨー90°、ピッチ−45°の
forwardFromYawPitchを正確な値(を使う)で計算して下さい。カメラはどちらを向いていますか? - ★★
orbitEye(Vec3 target, float distance, float yaw, float pitch)を書いて下さい。ターゲット、距離4、ヨー0、ピッチ−30°なら約を返し、lookAt(eye, target, {0, 1, 0}) * Vec4{0, 1, 0, 1}はを表示する筈です。 - ★★ ビュー行列の行から3つの軸を読み出す
void cameraAxes(const Mat4 &view, Vec3 &right, Vec3 &up, Vec3 &forward)を書いて下さい。11.6節のカメラなら、右、上、前と表示される筈です。 - ★★★
lookAtは常にワールドの上を使うので、カメラをロール(地平線を傾ける)出来ません。ロール角を追加して下さい。とを計算した後、第8章の2D回転で其の2つを其れらの平面の中で回します。代わりに上としてを渡すとどうなりますか?其れは何故ですか?
コメント(0)
まだコメントはありません。
ログインするとコメント出来ます。