view の設計

設計目標

view が答える問いは 1 つです。ワールド空間の点は画面のどこに現れ、どれだけ遠いか。その答えは、どのバックエンド(ターミナルのセル、キャンバスのピクセル、SVG のポリゴン)でも同じ画を示せるほど正確で、3 つの式で述べられるほど単純で、抽象的な画角の数値ではなく、写真家が考えるとおりセンサー、レンズ、距離でパラメータ化されている必要があります。

数学的背景

パイプラインには 3 つの段階があり、それぞれが座標系の間の写像です。

pworld⏟core→ V pcamera⏟x right, y up, z forward→ project (xs,ys)⏟viewport units, depth=z.\underbrace{p_{\text{world}}}_{\texttt{core}} \xrightarrow{\ V\ } \underbrace{p_{\text{camera}}}_{x \text{ right},\ y \text{ up},\ z \text{ forward}} \xrightarrow{\ \text{project}\ } \underbrace{(x_s, y_s)}_{\text{viewport units}},\ \mathit{depth} = z .

カメラの座標系

Camera3 は視点 ee、注視点 tt、おおよその上向きベクトル aa で与えられ、次を導きます。

f=t−e∥t−e∥,r=a×f∥a×f∥,u=f×r.f = \frac{t - e}{\lVert t - e \rVert},\qquad r = \frac{a \times f}{\lVert a \times f \rVert},\qquad u = f \times r .

これら 3 つのベクトルは正規直交の座標系をなします。外積の作り方から rr は ff に直交し、u=f×ru = f \times r は両方に直交します。∥u∥=∥f∥∥r∥sin⁡90°=1\lVert u \rVert = \lVert f \rVert \lVert r \rVert \sin 90° = 1 なので、コードの最後の正規化は丸め以外何も変えません。向きは三重積から得られます。

r×u=r×(f×r)=f (r⋅r)−r (r⋅f)=f.r \times u = r \times (f \times r) = f\,(r \cdot r) - r\,(r \cdot f) = f .

uu は aa の ff に直交する成分を正規化したものです。同じ恒等式から f×(a×f)=a−(a⋅f) ff \times (a \times f) = a - (a \cdot f)\, f なので、画面上の「上」は視線方向が許す範囲で aa に最も近くなります。a∥fa \parallel f なら外積は 0 になり、normalize_vec は零を返して座標系は退化します。これは look_at の前提条件であり、検査されるエラーではありません。

ビュー変換

R=[ r∣u∣f ]R = [\,r \mid u \mid f\,] を座標系を列とする行列とします。これは直交行列なので R−1=RTR^{-1} = R^\mathsf{T} です。カメラからワールドへの写像はカメラ座標 cc を e+Rce + R c に送り、その逆がワールドからカメラへの写像です。

c=RT(p−e)=RTp−RTe,V=(RT−RTe0T1)=(rT−r⋅euT−u⋅efT−f⋅e0T1),c = R^\mathsf{T} (p - e) = R^\mathsf{T} p - R^\mathsf{T} e, \qquad V = \begin{pmatrix} R^\mathsf{T} & -R^\mathsf{T} e \\ 0^\mathsf{T} & 1 \end{pmatrix} = \begin{pmatrix} r^\mathsf{T} & -r \cdot e \\ u^\mathsf{T} & -u \cdot e \\ f^\mathsf{T} & -f \cdot e \\ 0^\mathsf{T} & 1 \end{pmatrix},

これはまさに Camera3::view_transform が作る行列です。確認は 2 つです。Ve=0V e = 0(視点は原点へ)と Vt=(0,0,∥t−e∥)V t = (0, 0, \lVert t - e \rVert)(注視点は正の zz 軸上の真正面)です。VV は剛体運動(det⁡RT=+1\det R^\mathsf{T} = +1)なので、長さ、角度、内積を保ちます。特にランバート項 n^⋅ℓ\hat n \cdot \ell はワールド空間とカメラ空間で同じ値になるので、フロントエンドはビュー変換の後で照明を計算できます。

座標系の左右

画面には rr が右向き、uu が上向きに表示され、f=r×uf = r \times u は画面の奥を向きます。右手系として軸を読むと、右 ×\times 上は視聴者の方を向くはずですが、ここでは遠ざかる向きです。したがって規約は Direct3D の左手系です。Camera3::default ではワールドの +x+x が右、+y+y が上に見え、+z+z は視聴者から遠ざかります。生成されたメッシュの前面は、Direct3D と同様に画面上で時計回りに見えます。geometry3d のすべてがこの読み方と整合しており、右手系向けにモデル化したシーンは左右反転して見えます。

透視投影

原点にあって +z+z の方向を見るピンホールカメラは、点 (x,y,z)(x, y, z) の像をピンホールの前方距離 φ\varphi の平面上に結びます。相似な三角形から、像の位置は

(φxz, φyz).\left( \varphi \frac{x}{z},\ \varphi \frac{y}{z} \right).

です。PerspectiveProjection::project_point はこれを単位あたりのピクセル数で拡大し、原点を W×HW \times H のビューポートの中心へ移し、画面の行と同じく下へ増えるよう yy を反転します。φ\varphi とピクセル密度を 1 つのスケール ss にまとめると、

xs=W2+s xz,ys=H2−s yz.x_s = \frac{W}{2} + s\,\frac{x}{z},\qquad y_s = \frac{H}{2} - s\,\frac{y}{z} .

同次座標では、これは内部パラメータ行列 KK と、それに続く透視除算です。

K=(s0W/20−sH/2001),K(xyz)=(sx+W2z−sy+H2zz) → ÷z  (xsys1).K = \begin{pmatrix} s & 0 & W/2 \\ 0 & -s & H/2 \\ 0 & 0 & 1 \end{pmatrix},\qquad K \begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} s x + \tfrac{W}{2} z \\ -s y + \tfrac{H}{2} z \\ z \end{pmatrix} \ \xrightarrow{\ \div z\ }\ \begin{pmatrix} x_s \\ y_s \\ 1 \end{pmatrix}.

通常のグラフィックスパイプラインは KK を、正規化デバイス座標 [−1,1]2[-1, 1]^2 への投影と別のビューポート変換に分けます。view は間の段階で正規化座標を必要としないので、2 つを融合しています。また、正規化された深度ではなくカメラ空間の zz を深度として保持します。a+b/za + b/z の形の正規化深度は zz の単調関数なので、深度テストの結果は同じです。zz を保持すると深度の値をワールド単位で読め、ラスタライザが 1/z1/z を直接補間できます(後述)。

正射影

除算をなくすと平行投影 xs=W/2+sxx_s = W/2 + s x、ys=H/2−syy_s = H/2 - s y になり、ss はワールド単位あたりのピクセル数になります。大きさは距離によって縮まなくなります。OrthographicProjection は点や図のためにあり、フロントエンドはこれでラスタライズしません。

透視補正された深度

ラスタライザは投影された頂点 p0,p1,p2p_0, p_1, p_2 と、各ピクセルについて ∑λi=1\sum \lambda_i = 1、p=∑λipip = \sum \lambda_i p_i を満たす画面空間の重心座標 λi\lambda_i を知っています。必要なのはそのピクセルに投影される 3D の点の深度 zz です。投影は直線上の比を保たないので、λ\lambda で zz を線形補間するのは誤りです。正しい規則は次のとおりです。

投影された三角形上では 1/z1/z は画面上の位置のアフィン関数であり、したがって 1z=∑iλizi\dfrac{1}{z} = \displaystyle\sum_i \frac{\lambda_i}{z_i} です。

導出。P=∑μiPiP = \sum \mu_i P_i を 3D の重心座標 μi\mu_i(∑μi=1\sum \mu_i = 1)を持つ 3D の点とすると、その深度は z=∑μiziz = \sum \mu_i z_i です。画面上の位置を中心から測り、x~s=xs−W/2\tilde x_s = x_s - W/2 とすると、

x~s(P)=s xz=sz∑iμixi=∑iμiziz⋅s xizi=∑iμiziz x~s(Pi),\begin{aligned} \tilde x_s(P) = s\,\frac{x}{z} = \frac{s}{z} \sum_i \mu_i x_i = \sum_i \frac{\mu_i z_i}{z} \cdot s\,\frac{x_i}{z_i} = \sum_i \frac{\mu_i z_i}{z}\, \tilde x_s(P_i), \end{aligned}

であり、yy についても同じ計算が成り立ちます。重み μizi/z\mu_i z_i / z の和は (∑μizi)/z=1\big(\sum \mu_i z_i\big)/z = 1 なので、これは pp の画面空間での重心座標で、退化していない三角形では一意です。

λi=μiziz.\lambda_i = \frac{\mu_i z_i}{z} .

ziz_i で割って和をとると、

∑iλizi=∑iμiz=1z.■\sum_i \frac{\lambda_i}{z_i} = \sum_i \frac{\mu_i}{z} = \frac{1}{z}. \qquad\blacksquare

interpolate_perspective_depth はまさにこれを計算します。線形補間の誤差は大きくなりえます。深度 11 から 33 への辺では、画面上の中点の本当の深度は (12⋅1+12⋅13)−1=1.5(\tfrac12 \cdot 1 + \tfrac12 \cdot \tfrac13)^{-1} = 1.5 ですが、線形の平均は 22 になります。交差する面やほとんど接する面の間では、この差がどちらが見えるかを決めます。リポジトリのテストで、深度バッファが正しい値を使うことを確かめています。

導出から 3 つのことがわかります。

  • 重心座標はアフィン不変量なので、画面座標のどんなアフィン変換でも λi\lambda_i は変わりません。TUI バックエンドのターミナル用の yy の拡大縮小はそのような写像なので、その後でも規則は正確なままです。
  • 正射影では zz そのものが画面上の位置についてアフィンなので、1/z1/z の規則はかえって正確ではありません。そのためフロントエンドは(正射影の)シャドウマップでは線形の深度を使い、正射影した三角形をこれらのラスタライザに渡すことはありません。
  • この規則には zi>0z_i > 0 が必要です。頂点が視点の位置かその後ろにあると、コードは線形補間に切り替え、少なくとも結果は有限になります。もっとも、何もクリッピングしないので、その場合の画像はいずれにしても正しくありません。

物理カメラ

焦点距離 ff(mm)、高さ hh(mm)のセンサーを持つ実際のカメラは、φ=f\varphi = f のピンホールの式により、(x,y,z)(x, y, z) の像をセンサー上の高さ f y/zf\,y/z mm に結びます。センサーの高さをビューポートの HH 行に表示するなら 1 ミリメートルあたり H/hH/h ピクセルなので、

ys−H2=−Hh⋅f yz⟹s=Hfh,y_s - \frac{H}{2} = -\frac{H}{h} \cdot f\,\frac{y}{z} \quad\Longrightarrow\quad s = \frac{H f}{h},

これが ScientificCamera::projection_scale です。センサーの寸法 dd にわたる画角は、ピンホール、センサーの中心、センサーの端がなす直角三角形から得られます。

tan⁡θd2=d/2f⟹θd=2arctan⁡d2f,\tan\frac{\theta_d}{2} = \frac{d/2}{f} \quad\Longrightarrow\quad \theta_d = 2 \arctan\frac{d}{2 f},

これが LensSpec::horizontal_fov、vertical_fov、diagonal_fov の式です。このスケールでは、垂直方向の視野の端にある点(y/z=tan⁡(θh/2)=h/2fy/z = \tan(\theta_h/2) = h/2f)は ys=H/2−s h/2f=0y_s = H/2 - s\,h/2f = 0、つまり最上行に来ます。垂直方向の画角はちょうどビューポートの高さに収まります。水平方向にはビューポートは 2arctan⁡ ⁣(WH⋅h2f)2\arctan\!\big(\tfrac{W}{H} \cdot \tfrac{h}{2f}\big) にわたり、これがセンサーの水平方向の視野と等しくなるのは W/H=w/hW/H = w/h のときだけです。そのため 640 × 480(4:3)のキャンバスとフルサイズセンサー(3:2)では、レンズの視野より少し狭い水平方向の視野が表示されます。

ss では単位が打ち消し合います。ff と hh はどちらもミリメートルで、x/zx/z はワールドの長さの比です。シーン全体とカメラ距離を同じ倍率で拡大縮小しても画像は変わらないので、WorldUnit は投影に入りません。これを使うのは focal_length_world_units と sensor_height_world_units で、必要な呼び出し側のために光学系をシーンの単位で表します。

ドリーズーム

距離 dd にある高さ YY の物体は s Y/d=HfY/(hd)s\,Y/d = H f Y / (h d) ピクセルの高さに見えます。カメラが動く間その大きさを保つには f/df/d が一定、つまり f(d)=f0 d/d0f(d) = f_0\, d / d_0 である必要があります。すると別の距離 d+Δd + \Delta にある物体は係数 dd+Δ⋅d0+Δd0\frac{d}{d + \Delta} \cdot \frac{d_0 + \Delta}{d_0} で大きさが変わります。これが demo パッケージが ScientificCamera::with_lens でアニメーションさせる「めまい」効果です。

設計上の判断

座標系を導くルックアットカメラ

課題は、呼び出し側が正規直交の座標系を計算せずにカメラを向けられるようにすることです。ルックアットの記述 (e,t,a)(e, t, a) は人が考える形そのもので、上のグラム・シュミット風の構成がそれを剛体変換に変えます。行列ではなく 3 つのベクトルを保持するので、Camera3 はアニメーションさせやすくなります。eye を動かし、target はそのままにすればよいのです。代償は world_to_camera_* の呼び出しごとに座標系を作り直すことで、フロントエンドは頂点ごとにこれを呼びますが、デモの規模なら許容できます。

投影とビューポートを融合し、カメラ深度を保持する

正規化デバイス座標は、GPU がクリッピングして任意のフレームバッファに写せるようにするためにあります。geometry3d にはクリッパがなく、どのバックエンドもビューポート単位で描くので、別の NDC の段階は手順を増やすだけで機能は増えません。zz を深度として保持すると、深度バッファは視線軸に沿った距離を持ち、透視補正の規則は上の短い式になります。

透視を物理パラメータで表す

画角の数値は、写真家が別々に扱う 2 つのもの、レンズとセンサーを隠してしまいます。ScientificCamera は両方を受け取るので、ドリーズームは焦点距離を 1 つ変えるだけで済み、スケールも第一原理から導かれます。スケールはビューポートの高さに結び付けてあるので、ビューポートの幅が変わっても、たいていのカメラやグラフィックス API の規約である垂直方向の画角は保たれます。

寛容なコンストラクタ

センサー、レンズ、ワールド単位のコンストラクタは、エラーを返さずに正でない値を 1.0 に置き換えます。これらの値はユーザーではなくコードから来るものであり、レンダラでは毎フレームのエラー処理より、見えるが間違った画のほうがデバッグしやすいからです。Viewport::new と Camera3::look_at は検証を一切しません。

正しさと不変条件

  • up が視線方向と平行でなければ、(r,u,f)(r, u, f) は r×u=fr \times u = f を満たす正規直交の座標系です。
  • view_transform は剛体運動です。視点を原点に、注視点を (0,0,∥t−e∥)(0, 0, \lVert t - e \rVert) に写し、距離と内積を保ちます。
  • z>0z > 0 では PerspectiveProjection::project_point は KK とそれに続く透視除算に一致します。depth はカメラ空間の zz なので、深度の比較は視線軸に沿った距離の比較です。
  • 上で導いたとおり、3 頂点の深度がすべて DEPTH_EPSILON を超えるとき、interpolate_perspective_depth はピクセルの下にある 3D の点の正確な深度(丸めを除く)を返します。
  • projection_scale は垂直方向の画角をビューポートの高さに対応させます:stan⁡(θh/2)=H/2s \tan(\theta_h/2) = H/2。
  • すべての関数は定数時間で、まとめて投影する関数は点の数に比例します。

採用しなかった案

  • Transform3 で 4×4 の投影行列を適用すること。 動きはします(apply_point は ww で割ります)が、深度が正規化された値に置き換わり、その後にビューポートの段階が必要になります。3 つの明示的な式のほうが明快です。
  • ニア平面によるクリッピング。 視点の平面をまたぐ幾何を扱う正しい方法ですが、三角形を多角形に分割し、すべての経路にクリッパが必要になります。デモでは代わりにすべての幾何をカメラの前方に置いています。
  • 画角のパラメータ。 LensSpec と SensorSpec から導けますが、センサーの交換やドリーズームを同じように直接表現することはできません。

境界

view は次のことをしません。

  • 幾何のクリッピング、ニア平面やファー平面の定義、視点の位置かその後ろにある点の処理。
  • 画面上の点を光線へ逆投影すること。
  • 被写界深度、レンズの歪み、露出、中心からずれた主点のモデル化。
  • 正方形でないピクセルやターミナルのセルの補正(TUI バックエンドが行います)。
  • ラスタライズ、シェーディング、出力形式を持つこと。