シェーダーを書くなら理解しておきたい「簡易透視射影」― 行列なしでGPUの透視投影を理解する
はじめに
3Dグラフィックスの学習では、いきなり「モデル行列」「ビュー行列」「射影行列」などが登場し、初心者が混乱しがちです。
しかし、3D座標を画面へ投影する処理を幾何学的に考えてみると、その基本は驚くほどシンプルです。
- 回転(ローカル/ワールド)
- 平行移動
- 透視投影
- 透視除算
- スクリーン座標への変換
この記事では、これらを行列を使わずに自力で実装することで、3D座標変換の基本を理解します。
特に重要なのが、
「遠くにある物体ほど小さく見える」
という透視投影の仕組みです。
これは複雑な射影行列を使わなくても、非常に単純な相似関係から導くことができます。
ベクトル・座標は理解できるけれど、行列になると思考が飽和してしまう方への道しるべになればと思います。
なお、この記事は「行列は不要」と主張するものではありません。
むしろ、
行列の中で何が計算されているのかを、行列を使わずに先に理解する
ことを目的としています。
🎨 1. 透視投影の本質は「奥行きに応じて縮む」
まず、カメラを原点に置き、視線方向を +Z とします。
3D空間中の点を、
(X, Y, Z)
とします。
近平面を Z = n とすると、カメラから見た投影点 (x,y) は、相似な三角形から、
$$
x=\frac{n}{Z}X
$$
$$
y=\frac{n}{Z}Y
$$
と書けます。
したがって、
$$
x\propto\frac{X}{Z}
$$
$$
y\propto\frac{Y}{Z}
$$
となります。
つまり透視投影では、XとYを奥行きZで割ることによって、遠くにある物体ほど小さくなります。
これが透視投影の非常に重要な部分です。
2. 最小構成の「簡易透視投影」
上の式をそのままJavaScriptにすると、
function frustum(n, p) {
return [
(n / p[2]) * p[0],
(n / p[2]) * p[1]
];
}
となります。
ここでは、
- カメラは原点
- 視線は
+Z - 近平面は
Z=n
ちなみにopengl系では - 視線は
-Z
なので符号の取り扱いは慎重にしてください。
例えば、
n = 1
Z = 5
X = 10
Y = 10
なら、
$$
x=-\frac{1}{5}10=2
$$
$$
y=-\frac{1}{5}10=2
$$
となります。
Z=-10まで遠ざけると、
$$
x=1,\qquad y=1
$$
となります。
つまり、
近い
Z = 5 → 大きく見える
遠い
Z = 10 → 小さく見える
という、透視投影の基本がそのまま確認できます。
🔄 3. これはGPUの「透視除算」とどうつながるのか?
ここで、GPUの射影処理との関係を考えてみます。
GPUでは、射影変換後のクリップ座標を、
$$
(x_c,y_c,z_c,w_c)
$$
とすると、透視除算によって、
$$
x_{NDC}=\frac{x_c}{w_c}
$$
$$
y_{NDC}=\frac{y_c}{w_c}
$$
とします。
一方、この記事の簡易モデルでは、
$$
x=\frac{n}{Z}X
$$
$$
y=\frac{n}{Z}Y
$$
です。
つまり、
$$
X/Z,\quad Y/Z
$$
という形になっています。
ここで重要なのは、
この簡易モデルでは、3D空間の奥行き
Zが透視除算の分母として直接現れている
ということです。
したがって、概念的には、
簡易モデル
X / Z
Y / Z
↓
GPU
Xc / Wc
Yc / Wc
と対応させて考えることができます。
ここで「W=Z と仮定した」というより、
相似関係から導いた簡易透視投影では、奥行きZが分母になる。その構造がGPUの透視除算
x/w,y/wと対応している
と理解するのが分かりやすいでしょう。
実際のGPUでは射影行列によってクリップ座標が作られるため、一般には W=Z そのものではありません。
この記事では、その複雑な部分を省略して、透視除算の幾何学的な意味を最小構成で理解します。
🔄 4. 逆に考えると「ピッキング」になる
この透視投影式は、逆方向にも利用できます。
$$
x=\frac{n}{Z}X
$$
$$
y=\frac{n}{Z}Y
$$
なので、
$$
X=\frac{Z}{n}x
$$
$$
Y=\frac{Z}{n}y
$$
となります。
ここが3Dピッキングを考えるときに重要です。
スクリーン上の1点 (x,y) が分かっても、それだけでは3D空間上の一点を決定できません。
なぜなら、同じ (x,y) に投影される3D座標が無数に存在するからです。
例えば n=1、スクリーン座標が (x,y) の場合、
Z = 1
→ X = x
→ Y = y
Z = 2
→ X = 2x
→ Y = 2y
Z = 5
→ X = 5x
→ Y = 5y
Z = 10
→ X = 10x
→ Y = 10y
となります。
つまりスクリーン上の1点は、
3D空間中の一本の視線
を表しています。
🎯 5. この視線を使って3Dオブジェクトをピックする
例えば、ピックしたい3Dオブジェクトが、
近平面 n = 1
Z = 5
のXY平面上にあるとします。
すると、
$$
X=\frac{5}{1}x=5x
$$
$$
Y=\frac{5}{1}y=5y
$$
なので、
(X,Y,Z) = (5x, 5y, 5)
が求まります。
一般的には、
スクリーン座標 (x,y)
↓
Zを決める
↓
X = (Z/n)x
Y = (Z/n)y
↓
3D座標 (X,Y,Z)
という順序になります。
つまり、
Zを一つ決めれば、XとYは芋づる式に決まる
わけです。
🧭 6. Zを変化させれば3D空間を探索できる
ピッキングでは、3DオブジェクトのZ座標が最初から分かっているとは限りません。
そこで、
Z = n
↓
Zを遠ざける
↓
X = (Z/n)x
Y = (Z/n)y
↓
3D座標を求める
↓
オブジェクトとの交差を調べる
という方法で、スクリーン座標 (x,y) に対応する視線上を探索できます。
例えば、
3Dオブジェクト
███
█████
███████
↑
/
/
/
/
●
カメラ
─────────
近平面
(x,y)
という関係になります。
これは現在の3Dグラフィックスで一般的に使われる、
スクリーン座標から3D空間中のレイを求め、オブジェクトとの交差を調べる
という考え方を、非常に単純化したものです。
🔄 7. 回転はRodriguesの回転公式で書ける
次に3Dオブジェクトを回転させます。
行列を使わなくても、軸ベクトル a、角度 th、回転対象の点 P があれば、Rodriguesの回転公式によって3D回転を記述できます。
function rot(a, th, p) {
var c = Math.cos(th), s = Math.sin(th);
return [
(c+a[0]*a[0]*(1-c))*p[0]
+ (a[0]*a[1]*(1-c)-a[2]*s)*p[1]
+ (a[0]*a[2]*(1-c)+a[1]*s)*p[2],
(a[1]*a[0]*(1-c)+a[2]*s)*p[0]
+ (c+a[1]*a[1]*(1-c))*p[1]
+ (a[1]*a[2]*(1-c)-a[0]*s)*p[2],
(a[2]*a[0]*(1-c)-a[1]*s)*p[0]
+ (a[2]*a[1]*(1-c)+a[0]*s)*p[1]
+ (c+a[2]*a[2]*(1-c))*p[2]
];
}
これは、回転行列による回転と同じ線形変換を、Rodriguesの公式から直接計算していると考えることができます。
つまり、
Rodriguesの回転公式
↓
回転
↓
回転行列と同じ結果
です。
ここでも、行列を否定しているわけではありません。
行列の中で行われている処理を、まず直接計算しているだけです。
🧭 8. 行列なしで3D → 2Dを実装する
ここまでの処理を組み合わせると、
回転
↓
平行移動
↓
透視投影
↓
スクリーン座標変換
という3D → 2D変換を実際に実装できます。
// 回転 → 平行移動 → 透視射影 → スクリーン変換
for(i = 0; i < p.length; i++)
rp[i] = rot([0,0,1], 1 * time, p[i]);
for(i = 0; i < rp.length; i++)
rp[i] = rot([0,1,0], 2 * time, rp[i]);
for(i = 0; i < rp.length; i++)
rp[i] = rot([1,0,0], 3 * time, rp[i]);
for(i = 0; i < rp.length; i++)
rp[i][2] += 40;
for(i = 0; i < rp.length; i++)
pp[i] = frustum(1, rp[i]);
ここでは、
ローカル座標
↓
回転
↓
平行移動
↓
簡易透視投影
↓
スクリーン座標
という流れになっています。
実際のGPUでは、これらの処理を行列やクリップ座標などを使って一般化しています。
しかし、座標がどのように変化しているのかという基本的な考え方は、この程度のコードでも十分に体験できます。
📦 9. 自力計算のワイヤーフレーム回転アニメーション
以下は、実際に8頂点の立方体を回転させ、簡易透視投影してCanvasへ描画する例です。
var TMan = new N6LTimerMan();
var p = [];
var pp = [];
var time;
function GLoop(id) {
var TMan = new N6LTimerMan(); //タイマーマネージャー
var p=[];
var pp=[];
var time;
function GLoop(id) {
time+=Math.PI/180;//1°のラジアン
var i;
var rp = [];
pp = [];
//回転平行移動&透視変換
for(i = 0; i < p.length; i++) rp[i] = rot([0,0,1], 1 * time, p[i]);//z軸回り1°ずつ回転
for(i = 0; i < rp.length; i++) rp[i] = rot([0,1,0], 2 * time, rp[i]);//y軸回り2°ずつ回転
for(i = 0; i < rp.length; i++) rp[i] = rot([1,0,0], 3 * time, rp[i]);//x軸回り3°ずつ回転
for(i = 0; i < rp.length; i++) rp[i][2] += 40;//z軸平行移動
for(i = 0; i < rp.length; i++) pp[i] = frustum(1, rp[i]);//透視
//ワイヤーフレーム表示
var canvas = document.getElementById('cnv3');
if (canvas.getContext) {
var context = canvas.getContext('2d');
context.fillStyle = 'rgb(0,0,0)';
var sx = 600, sy = 600;
context.fillRect(0,0,sx,sy);
context.lineWidth = 3;
context.fillStyle = 'rgb(128,192,128)';
context.strokeStyle = 'rgb(0,192,0)';
var zoom = 250;
context.beginPath()
context.moveTo(pp[4][0] * zoom + sx / 2, pp[4][1] * zoom + sy / 2);
for(i = 1; i < 4; i++){
context.lineTo(pp[i+4][0] * zoom + sx / 2, pp[i+4][1] * zoom + sy / 2);
}
context.lineTo(pp[4][0] * zoom + sx / 2, pp[4][1] * zoom + sy / 2);
context.closePath();
context.stroke();
context.fill();
context.beginPath()
context.moveTo(pp[0][0] * zoom + sx / 2, pp[0][1] * zoom + sy / 2);
for(i = 1; i < 4; i++){
context.lineTo(pp[i][0] * zoom + sx / 2, pp[i][1] * zoom + sy / 2);
}
context.lineTo(pp[0][0] * zoom + sx / 2, pp[0][1] * zoom + sy / 2);
context.closePath();
context.stroke();
context.beginPath()
for(i = 0; i < 4; i++){
context.moveTo(pp[i][0] * zoom + sx / 2, pp[i][1] * zoom + sy / 2);
context.lineTo(pp[i+4][0] * zoom + sx / 2, pp[i+4][1] * zoom + sy / 2);
}
context.closePath();
context.stroke();
}
TMan.timer[0].setalerm(function() { GLoop(0); }, 50); //メインループセット
}
function rot(a, th, p) {
var c = Math.cos(th), s = Math.sin(th);
return [
(c+a[0]*a[0]*(1-c))*p[0]
+ (a[0]*a[1]*(1-c)-a[2]*s)*p[1]
+ (a[0]*a[2]*(1-c)+a[1]*s)*p[2],
(a[1]*a[0]*(1-c)+a[2]*s)*p[0]
+ (c+a[1]*a[1]*(1-c))*p[1]
+ (a[1]*a[2]*(1-c)-a[0]*s)*p[2],
(a[2]*a[0]*(1-c)-a[1]*s)*p[0]
+ (a[2]*a[1]*(1-c)+a[0]*s)*p[1]
+ (c+a[2]*a[2]*(1-c))*p[2]
];
}
function frustum(n, p) {
return [
(n / p[2]) * p[0],
(n / p[2]) * p[1]
];
}
function init() {
p = [
[10,10,10],
[-10,10,10],
[-10,-10,10],
[10,-10,10],
[10,10,-10],
[-10,10,-10],
[-10,-10,-10],
[10,-10,-10]
];
time = 0;
}
function enter3() {
init();
TMan.add();
TMan.timer[0].setalerm(
function() { GLoop(0); },
50
);
return true;
}
🎯 10. このコードで確認できること
このプログラムでは、3Dオブジェクトを、
回転
↓
平行移動
↓
透視投影
↓
Canvas
という順番で処理しています。
特に重要なのは最後の、
pp[i] = frustum(1, rp[i]);
です。
ここで、
$$
x=-\frac{n}{Z}X
$$
$$
y=-\frac{n}{Z}Y
$$
という透視投影を直接計算しています。
つまり、射影行列を使わなくても、透視投影がどのように働いているかを自分で確認できます。
🧩 11. 実際のGPUでは、この処理が行列でまとめられている
ここまで読むと、
「では、なぜGPUではわざわざ行列を使うのか?」
という疑問が出てくると思います。
答えは、複数の座標変換を一つの数学的な表現にまとめられるからです。
例えば、
モデル座標
↓
モデル変換
↓
ワールド座標
↓
ビュー変換
↓
カメラ座標
↓
射影変換
↓
クリップ座標
↓
透視除算
↓
NDC
という処理を、行列を使って効率よく表現できます。
したがって、
行列は3Dグラフィックスを難しくするためのものではなく、すでに理解した座標変換をまとめて扱うための便利な表現
頂点シェーダーで gl_Position に渡す前の処理が、本質的にこの透視除算と同じことをしている
と考えると分かりやすくなります。
🔥 12. なぜこの「簡易透視投影」が重要なのか?
理由は明確です。
- 行列を使う前に透視投影の本質を理解できる
-
X/Z、Y/Zがなぜ現れるのか分かる - GPUの透視除算
x/w、y/wとの関係が分かる - 遠近感が相似関係から自然に導ける
- スクリーン座標から3D空間への逆変換も理解できる
- ピッキングの「レイ」の考え方につながる
- 回転行列についても、その中身を直接計算できる
- 行列を「謎の公式」ではなく座標変換の表現として理解できる
特に重要なのは、
透視投影とピッキングは、同じ幾何学を順方向と逆方向から見ている
という点です。
3D → 2Dでは、
$$
x=\frac{n}{Z}X
$$
$$
y=\frac{n}{Z}Y
$$
となり、
2D → 3Dでは、
$$
X=\frac{Z}{n}x
$$
$$
Y=\frac{Z}{n}y
$$
となります。
つまり、
3D
│
│ 透視投影
▼
2D
│
│ Zを決めて逆変換
▼
3D
という関係です。
🏁 まとめ
この記事で扱ったことは、非常に単純です。
回転
Rodriguesの回転公式
によって、行列を使わずに3D回転を計算できます。
透視投影
x = (n/Z)X
y = (n/Z)Y
によって、奥行きに応じて画面上の大きさが変化する理由を直接理解できます。
透視除算
X/Z
Y/Z
という形が、GPUの
x/w
y/w
という透視除算につながります。
ピッキング
X = (Z/n)x
Y = (Z/n)y
と逆変換することで、スクリーン上の一点から3D空間中の視線を構成できます。
そして最も重要なのは、
行列を使わなくても、行列が何をしているのかを理解できる
ということです。
行列はゴールではありません。
その中で行われている、
回転・平行移動・射影・透視除算
という個々の処理を理解した上で行列を見ると、3Dグラフィックスの見え方は大きく変わります。
「行列が苦手だから3Dグラフィックスが分からない」という方は、まず行列を覚えるのではなく、3D座標がどのように2Dへ投影されるのかを自分で計算してみることをおすすめします。
デモ
透視射影公式テスト
作者
GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net
気に入っていただけたら GitHub に ⭐ をいただけると嬉しいです!