12. 投影変換
第11章では、ワールド全体をカメラの前に動かしました。
今、全てはビュー空間にあります。
目は原点にあり、−Z方向を向いています。
でもモニターは平らなので、まだ1つ足りない段階があります。
3Dを2Dに潰す事です。
其れが投影変換、のPです。
此の章ではlinalg.hhの投影行列を1行ずつ導き、第9章の同次座標が何故苦労に見合う物だったのかを確かめ、OpenGLとVulkanの違いを見て、最後に3Dの点をピクセル迄運びます。
12.1 投影変換とは
投影変換は3Dの位置を画面上の2Dの位置に変えます(GPUがどちらが手前かを分かる様に、奥行きの値も付けます)。
常に使うのは2種類です。
正射影は平行な線を平行の儘にし、物は遠くにあっても小さくなりません。
見える範囲は箱です。
製図、2Dゲーム、UI、昔ながらの「アイソメトリック」な見た目で使います。
透視投影は目やカメラと同じ仕組みです。
遠くの物は小さく見え、平行な線(線路等)は消失点で交わります。
見える範囲は先端を切り取ったピラミッドで、視錐台(フラスタム)と呼びます。
どちらも、カメラの前にある2つの平面で決まります。
距離にあるニア平面と、距離にあるファー平面です。
より近い物とより遠い物は切り取られます。
カメラは−Zを向いているので、ニア平面は、ファー平面はにあります。
但しと自体は正の数です。
投影行列の仕事は、其の範囲をNDC(正規化デバイス座標)という決まった箱に押し込む事です。
OpenGLでは其の箱はで、yは上向きです。
Vulkanの箱は12.7節で扱います。
12.2 正射影:公式
見える箱は、xがから(左、右)、yがから(下、上)、zがからです。
正射影行列は其れをNDCの立方体に写します:
此れはアフィン行列(第9章)です。
対角線に拡大縮小、最後の列に平行移動、そして何時ものの最下行。
新しい物は何も無く、数字が正しいだけです。
12.3 正射影の仕組み
まずxだけを考えましょう。
をに、をにしたいです。
2つの操作で行います:
- 範囲の中心を0にずらす:を引きます。すると範囲はからになります。
- 半分の幅を1に拡大縮小する:を掛けます。
正に1行目です。
対角線の拡大縮小と、最後の列のずらしです。
yの行もとで同じです。
zの行には1つ捻りがあります。
ニア平面はにあってになるべきで、ファー平面はにあってになるべきです。
距離が大きい程NDCのzが大きくなる、つまり向きが反転するので、のマイナスは其処から来ています。
(そう、此れはOpenGLのNDCが実は左手系だという事です。+zが画面の奥を向きます。)
両端を確認しましょう:
計算例
800×600のウィンドウ用、ピクセル単位のUIカメラです。
、、、、そして綺麗な数字にする為に、とします。
行列の要素は:
では点を変換します:
従ってNDCではです。
横は4分の3の所、縦は下から4分の1の所、奥行きは丁度真ん中です。
はニア平面(−1)とファー平面(−11)の丁度真ん中で、其れが丁度真ん中の0に来た事に注目して下さい。
正射影では奥行きは線型です。
透視投影では違うので、覚えておいて下さい。
12.4 透視投影:公式
linalg.hhの透視投影行列は4つの数を受け取ります。
縦の視野角(カメラが上から下までどれだけ広く見えるか、ラジアン)、アスペクト比(幅÷高さ)、そしてニアとファーの距離です。
次の様に置きます:
(コードでは変数tです。)
するとOpenGLの透視投影行列は:
最下行を見て下さい。
です。
此のシリーズで初めて、其処がではない行列が出て来ました。
出力のwは1になりません。
此れが仕掛けの全てなので、何故なのかを見て行きましょう。
12.5 透視投影の仕組み:相似な三角形とw
相似な三角形
カメラを横から見ます。
点は目の前方、距離の所にあります。
目からPへ線を引きます。
其の線は距離のニア平面の何処を通るでしょうか?
小さい三角形(目、n、y')と大きい三角形(目、−z、y)は同じ形なので、辺の比も同じです:
透視投影は此の1行に尽きます。
距離で割るのです。
2倍遠くにある点は、中心からの距離が半分になります。
例えばなら、点はに、xとyが同じで2倍遠い点はに来ます。
行列の中では割り算が出来ない
此処で問題があります。
行列が出来るのは、座標に定数を掛けて足し合わせる事だけです。
は線型ではないので、zで割る事は出来ません。
此処で第9章の同次座標が役に立ちます。
第9章では常にw = 1でした。
でも、0でない任意のwを持つ4Dベクトルは、3Dの点を表します。
そしてGPUは頂点シェーダーの後で、正に其の割り算をしてくれます。
此れを透視除算と呼びます。
なので作戦は、行列で距離をwに入れ、割り算はGPUに任せる事です。
最下行が正に其れをします。
です。
視野角とアスペクト比
次はxとyです。
ニア平面で考えるより、NDCで欲しい物を言う方が簡単です。
見える範囲の上端がに来て欲しいのです。
目の前方、距離の所では、見える高さの半分はです(目の所の角度がの直角三角形、第1章)。
従って欲しいのは:
GPUがで割ってくれるので、行列はを作るだけで済みます。
其れが2行目のです。
画面はアスペクト比の分だけ横長なので、見える幅の半分は倍大きく、xは更にで割ります:です。
zの行
深度バッファ用の奥行きの値も必要です。
zの行が作れるのはだけで(xとyは奥行きと関係無いので使えません)、除算の後は次の様になります:
ニア平面が−1に、ファー平面が+1に行く様にとを選びます。
条件が2つ、未知数が2つです:
2つ目の式から1つ目の式を引くと、つまりなので:
1つ目の式に戻して:
此れがperspectiveGlの3行目にある2つの要素其の物です。
計算例
視野角90°、アスペクト比2(例えば1600×800のウィンドウ)、、とします。
上のビュー空間の点を使います:
其の後GPUがで割ります:
12.6 奥行きは線型ではない
計算例では、距離4の点の奥行きは0.6875になりました。
距離4は1と9の真ん中より手前なのに、奥行きは−1と1の真ん中より奥です。
zの行はで割られるので、NDCの奥行きはです。
ではなくに従うのです。
奥行きの範囲の殆どが、カメラの直ぐ前で使い切られてしまいます。
良くあるゲームの設定、とで、0〜1の深度バッファに入る値を見てみましょう:
| 距離 | NDCのz(OpenGL) | 深度バッファ |
|---|---|---|
| 0.1(ニア) | −1 | 0 |
| 0.2 | 0.001 | 0.5005 |
| 0.5 | 0.6016 | 0.8008 |
| 1 | 0.8018 | 0.9009 |
| 10 | 0.982 | 0.991 |
| 100(ファー) | 1 | 1 |
深度バッファの半分が0.1mから0.2mの間で使われています!
10mから100m迄の全ては、其の1%未満を分け合わなければなりません。
精度は限られているので、遠くにある2つの面が同じ深度で保存され、互いにちらつく事があります。
此れをZファイティングと呼びます。
其処から実用的なルールが2つ出て来ます。
ニア平面は出来るだけ遠くに置く事(0.001より0.1の方がずっと良い)、そしてファー平面を必要以上に大きくしない事です。
リバースZという人気の技もあります。
代わりにニアを1、ファーを0に写し、浮動小数点の深度バッファを使います。
浮動小数点数は0の近くで精度が高く、其れが丁度ニア平面付近のの混雑を打ち消すので、奥行きの精度が範囲全体で殆ど均等になります。
其の場合、深度バッファを0でクリアし、「大きい方」の深度テストを使います。
0〜1の深度範囲で一番効果があり、Vulkanは最初から其の範囲で、OpenGLはglClipControl(OpenGL 4.5)で其の範囲に出来ます。
12.7 OpenGLとVulkanの違い
VulkanのNDCはOpenGLと2点違います:
| 範囲 | OpenGL | Vulkan |
|---|---|---|
| NDCのx | −1(左)〜1(右) | −1(左)〜1(右) |
| NDCのy | −1(下)〜1(上)、y上向き | −1(上)〜1(下)、y下向き |
| NDCのz | −1(ニア)〜1(ファー) | 0(ニア)〜1(ファー) |
従ってperspectiveVkが変えるのは丁度2つです。
1. yの拡大率の符号を反転する。 なので、中心より上の点はNDCのyが負になり、Vulkanでは其れが上です。
2. 新しいzの行。 考え方は同じですが、目標が0と1になります:
前と同じ例(90°、アスペクト比2、、)では、です。
点は、、となるので:
画面上の位置はOpenGLと同じで(中心より上が今度は負のy)、奥行きは0〜1に入っています。
Vulkanのyの向きには、もう1つの対処法があります。
yの拡大率はOpenGLと同じく正の儘にし(但しzの行は上の0〜1の物を使います)、ビューポートに負の高さを与える方法です(yをウィンドウの高さに、高さをウィンドウの高さのマイナスにします)。
すると代わりにビューポート変換の時に絵が反転します。
負のビューポートの高さはVulkan 1.1から使えます(其れ以前はVK_KHR_maintenance1拡張機能で)。
どちらか一方だけを選んで下さい。
両方使うと、絵がまた逆さまになります。
12.8 クリップ空間、NDC、ビューポート
各段階にちゃんと名前を付けましょう。
頂点シェーダーが出力するのは、まだ何も割っていないクリップ空間の座標です。
GPUは此処で三角形をクリップ(切り取り)します。
判定が簡単だからです。
点は次の時に見える範囲の内側にあります:
(Vulkanでは最後がです。)
クリップは除算の前でなければなりません。
カメラの後ろにある点はで、負のwで割ると上下逆さまで前に飛んで来てしまいますし、ならゼロ除算です。
例の点は、1、1、2.75が全て−4と4の間なので内側です。
次に透視除算でNDCになり、最後にビューポート変換がNDCをウィンドウのピクセルに引き伸ばします。
幅ピクセル、高さピクセルのウィンドウでは:
yはAPIによって違います。
Vulkanのフレームバッファは左上から始まりyが下向きで、NDCと合っています:です。
OpenGLのウィンドウ座標は左下から始まり、y上向きのNDCと合っています。
でもマウスの位置や殆どの画像形式は左上からピクセルを数えるので、OpenGL式のNDCで自分で計算する時はyを反転します:
奥行きもNDCから深度バッファへ行きます。
OpenGLは(デフォルトでは)を保存し、Vulkanは(デフォルトの深度範囲0〜1なら)を其の儘保存します。
12.9 グラフィックスプログラミングでの使い方
3Dの点からピクセルへ
HPバー、名札、クエストマーカーは、3Dの物体の画面上の位置に2Dで描きます。
一度パイプライン全体を手でやってみましょう。
カメラは第11章の物で、目は、原点を見ています。
投影はperspectiveGlで90°、アスペクト比2、、、ウィンドウは1600×800です。
敵の頭はワールド座標にあります。
- ビュー行列(第11章):点はビュー空間でになります。
- 投影:、、、です。
- クリップ判定:なのでカメラの前にあり、2.5、2、4は全て−5と5の間なので見えます。
- 除算:NDCはです。
- ビューポート:、です。
名札はピクセルに描きます。
もしなら点はカメラの後ろなので、名札は描きません。
ピクセルからワールドへ戻る:マウスピッキング
物体をクリックするのは逆方向です。
マウスはピクセルをくれるので、ビューポート変換を元に戻してNDCにします:
ピクセルなら、またです。
でもピクセルは点ではなく、前後に並んだ点の線全体です。
其処で其の線上のNDCの点を2つ、ニア平面上()とファー平面上()に取り、両方に(第10章)を掛け、出て来たwで割ります。
此のカメラでは、ニア平面上が、ファー平面上がになります。
1つ目から2つ目へのレイは、マウスの下にある全てを通ります。
確認:其の丁度真ん中は、敵の頭です。
後は其のレイを物体のバウンディングボックスや球と判定します。
他の使い方
- シャドウマップ。 太陽は非常に遠いので光線は平行です。其の為、平行光源のシャドウマップは正射影で描きます。スポットライトは代わりに透視投影を使います。
- 2D及びUI。
orthographicGl(0, width, 0, height, -1, 1)でスプライトをピクセル単位で置けます。下と上を入れ替えてorthographicGl(0, width, height, 0, -1, 1)にすると、画像編集ソフトの様にyが下向きになります。 - ズーム。 視野角を小さくするとが大きくなり、全てが拡大されます。90°()から30°()にすると約3.7倍のズームで、スナイパースコープが1行で出来ます。マウス感度も同じ倍率で下げて下さい。
- アイソメトリックのゲーム。 Y周りに45°回し、約35.26°下に傾けた正射影カメラで、昔ながらのアイソメトリックな見た目になります。
シェーダーで線型な奥行きを得る
フォグ、ソフトパーティクル、スクリーンスペースアンビエントオクルージョン等のエフェクトには、線型でない深度バッファの値ではなく本当の距離が必要です。
を距離について解くと、Vulkanの0〜1の深度では:
OpenGLではとして:
Vulkanの例で確認します。
深度0.84375、、ならで、最初の距離に戻りました。
// depth: a value from a Vulkan depth buffer (0 at near, 1 at far)
float linearizeDepth(float depth, float n, float f) {
return (f * n) / (f - depth * (f - n));
}// depth: a value from a Vulkan depth buffer (0 at near, 1 at far)
float linearizeDepth(float depth, float n, float f) {
return (f * n) / (f - depth * (f - n));
}// depth: a value from a Vulkan depth buffer (0 at near, 1 at far)
float linearizeDepth(float depth, float n, float f) {
return (f * n) / (f - depth * (f - n));
}12.10 其の他の使い道
- コンピュータビジョン。 ピンホールカメラモデルは同じ相似な三角形です。カメラの内部パラメータ行列はピクセル単位の焦点距離(、)と画像の中心(、)を持ち、ピクセルはです。カメラキャリブレーション(例えばOpenCVの
calibrateCamera)は、チェッカーボードの写真から其れらの数を測ります。但し、コンピュータビジョンでは普通カメラは+Zを向き、yは下向きです。 - 写真。 レンズの焦点距離が視野角を決めます。センサーの高さ、焦点距離ならです。フルサイズセンサー(36×24mm)では、50mmレンズは縦に約27°、横に約39.6°、24mmレンズは縦に約53°、200mmの望遠レンズは僅か約6.9°しか見えません。
- 美術。 ルネサンスの画家達は15世紀に、消失点を使う線遠近法を確立しました。同じ「距離で割る」です。
- 地図。 地図投影法は丸い地球を紙の上に平らにします。線型ではありません(メルカトル図法は極を引き伸ばします)が、多少の歪みと引き換えに平らな絵を得るという考え方は同じです。
- 建築及び工学。 平面図、立面図、断面図は図面から直接長さを測れる様に正射影で描き、透視図は施主に完成形を見せる為に使います。
12.11 コード
linalg.hhにある3つの投影関数は次の通りです。
此れらを12.2節、12.5節、12.7節と照らし合わせて下さい。
tが此の章ので、Mat4 m;は全てゼロで始まるので、透視投影行列では0でない5つの要素だけを設定しています。
// 正射影(OpenGL:NDCのzは-1〜1) / orthographic projection (OpenGL: NDC z in -1..1)
inline Mat4 orthographicGl(float left, float right, float bottom, float top, float n, float f) {
Mat4 m = Mat4::identity();
m.at(0, 0) = 2.0f / (right - left);
m.at(1, 1) = 2.0f / (top - bottom);
m.at(2, 2) = -2.0f / (f - n);
m.at(0, 3) = -(right + left) / (right - left);
m.at(1, 3) = -(top + bottom) / (top - bottom);
m.at(2, 3) = -(f + n) / (f - n);
return m;
}
// 透視投影(OpenGL:NDCのzは-1〜1、yは上向き) / perspective (OpenGL: NDC z in -1..1, y up)
// fovY:縦の視野角(ラジアン) / vertical field of view in radians
inline Mat4 perspectiveGl(float fovY, float aspect, float n, float f) {
float t = 1.0f / std::tan(fovY * 0.5f);
Mat4 m;
m.at(0, 0) = t / aspect;
m.at(1, 1) = t;
m.at(2, 2) = -(f + n) / (f - n);
m.at(2, 3) = -(2.0f * f * n) / (f - n);
m.at(3, 2) = -1.0f;
return m;
}
// 透視投影(Vulkan:NDCのzは0〜1、yは下向き) / perspective (Vulkan: NDC z in 0..1, y down)
inline Mat4 perspectiveVk(float fovY, float aspect, float n, float f) {
float t = 1.0f / std::tan(fovY * 0.5f);
Mat4 m;
m.at(0, 0) = t / aspect;
m.at(1, 1) = -t;
m.at(2, 2) = f / (n - f);
m.at(2, 3) = (f * n) / (n - f);
m.at(3, 2) = -1.0f;
return m;
}
そして、12.9節のステップ2〜5を行うworldToScreenヘルパーを使ったテストプログラムです。
OpenGL式の行列(NDCのyが上向き)を前提にしています。
perspectiveVkの行列なら、yには代わりにを使います。
#include <cstdio>
#include "linalg.hh"
// Projects a world-space point to pixel coordinates (origin at the top-left).
// viewProj must be an OpenGL-style matrix (NDC y up), e.g. perspectiveGl(...) * view.
// Returns false if the point is behind the camera.
inline bool worldToScreen(const Mat4 &viewProj, Vec3 p, float width, float height, Vec2 *out) {
Vec4 clip = viewProj * Vec4{p.x, p.y, p.z, 1.0f};
if (clip.w <= 0.0f) return false;
float ndcX = clip.x / clip.w;
float ndcY = clip.y / clip.w;
out->x = (ndcX + 1.0f) * 0.5f * width;
out->y = (1.0f - ndcY) * 0.5f * height; // pixel y grows downwards
return true;
}
static void printVec(const char *name, Vec4 v) {
printf("%-6s (%7.4f, %7.4f, %7.4f, %7.4f)\n", name, v.x, v.y, v.z, v.w);
}
int main() {
Vec4 p{2, 1, -4, 1}; // a point in view space
Mat4 gl = perspectiveGl(radians(90.0f), 2.0f, 1.0f, 9.0f);
Vec4 clip = gl * p;
printVec("clip", clip);
printVec("ndc", (1.0f / clip.w) * clip);
Mat4 vk = perspectiveVk(radians(90.0f), 2.0f, 1.0f, 9.0f);
clip = vk * p;
printVec("vk", (1.0f / clip.w) * clip);
Mat4 ortho = orthographicGl(0.0f, 800.0f, 0.0f, 600.0f, 1.0f, 11.0f);
printVec("ortho", ortho * Vec4{600, 150, -6, 1});
// name tag above an enemy: camera from chapter 11, 1600x800 window
Mat4 view = lookAt({3, 0, 4}, {0, 0, 0}, {0, 1, 0});
Mat4 viewProj = gl * view;
Vec2 px;
if (worldToScreen(viewProj, {4, 2, -3}, 1600.0f, 800.0f, &px))
printf("pixel (%.1f, %.1f)\n", px.x, px.y);
if (!worldToScreen(viewProj, {6, 0, 8}, 1600.0f, 800.0f, &px))
printf("behind the camera\n");
// mouse picking: back from NDC to world space
Mat4 invViewProj = inverse(viewProj);
Vec4 nearH = invViewProj * Vec4{0.5f, 0.4f, -1.0f, 1.0f};
Vec4 farH = invViewProj * Vec4{0.5f, 0.4f, 1.0f, 1.0f};
printVec("near", (1.0f / nearH.w) * nearH);
printVec("far", (1.0f / farH.w) * farH);
return 0;
}
期待される出力:
clip ( 1.0000, 1.0000, 2.7500, 4.0000)
ndc ( 0.2500, 0.2500, 0.6875, 1.0000)
vk ( 0.2500, -0.2500, 0.8438, 1.0000)
ortho ( 0.5000, -0.5000, 0.0000, 1.0000)
pixel (1200.0, 240.0)
behind the camera
near ( 3.2000, 0.4000, 2.6000, 1.0000)
far ( 4.8000, 3.6000, -8.6000, 1.0000)
どの行も、此の章で手計算した数字と一致しています。
点はターゲットから見て目の反対側にあるので、worldToScreenは其れを拒否します。
12.12 まとめ
- 投影変換は見える範囲をNDCに写します。正射影は箱を使い大きさを保ち、透視投影は視錐台を使い遠くの物を小さくします。
- 正射影行列は各軸で「中心を0にずらし、半分の幅を1に拡大縮小する」だけです。奥行きは線型です。
- 透視投影は相似な三角形、つまり距離で割る事です。行列は割り算が出来ないので、最下行でをwに入れ、GPUが透視除算をします。
- がyを、がxを拡大縮小します。zの行はニアとファーの2つの条件から決まります。
- 透視投影の奥行きはに従うので、精度がニア平面付近に集中します。は出来るだけ大きくします。リバースZが大いに役立ちます。
- VulkanのNDCはyが下向きでzが0〜1です。
perspectiveVkはyの拡大率の符号を反転し、別のzの行を使います。yについては負のビューポートの高さという代わりの方法もあります。 - クリップはクリップ空間()で行われ、其の後除算でNDC、ビューポート変換でピクセルになります。
- で、ピッキング用にマウスのピクセルをレイに戻せます。
12.13 課題
- ★
orthographicGl(100, 300, 0, 100, 2, 6)で、点はNDCの何処に来ますか? - ★ ニア平面がの時、点はニア平面の何処を通りますか(相似な三角形)?はどうですか?
- ★
perspectiveGl(90°, 1, 1, 3)とperspectiveVk(90°, 1, 1, 3)を書き出し、ビュー空間の点を両方でNDC迄投影して下さい。此の点はニアとファーの丁度真ん中にあります。奥行きは何処に来ますか? - ★ 1920×1080のウィンドウで、OpenGL式のNDCは左上から数えて何番目のピクセルですか?
- ★★ yとzの行を導いて、
orthographicVk(NDCのyが下向き、zはニアで0、ファーで1)を書いて下さい。orthographicVk(-2, 2, -1, 1, 0.1f, 10)で、点はに、はになる筈です。 - ★★ マウスのピクセルをレイ(ニア平面上の始点と正規化された方向)に変える関数を書いて下さい。12.9節のカメラと投影でピクセルなら、始点、方向は約になる筈です。
- ★★★ ニア平面が深度1に、ファー平面が深度0に写るVulkan用のリバースZ透視投影行列を導いて下さい。次にを無限大にすると、zの行はどうなりますか?何故ゲームがファー平面を全く持たなくても良くなるのでしょうか?
コメント(0)
まだコメントはありません。
ログインするとコメント出来ます。