frontend の設計

設計目標

フロントエンドは 3D のシーンが 3D でなくなる場所です。すべての出力で共通の各段階(モデル変換とビュー変換、投影、可視判定、照明、影)を担い、バックエンドには画面空間の三角形の平らなリストを、それぞれ 1 つの明るさとともに渡します。そのため、バックエンドは半日で書けます。決めることは、ある明るさの三角形が自分のデバイスでどう見えるかだけです。同じ境界によって、TUI、Canvas、SVG のバックエンドは同じ DrawList から同じ画を表示できます。

数学的背景

パイプライン

メッシュの頂点 pp とモデル行列 MM を持つ各オブジェクトと、ビュー行列 VV と投影 π\pi を持つカメラ(view の設計を参照)について、build_draw_list は次を計算します。

pw=Mp,pc=Vpw,(xs,ys,z)=π(pc),p_w = M p,\qquad p_c = V p_w,\qquad (x_s, y_s, z) = \pi(p_c),

さらに、カメラ空間の法線 n^F\hat n_F と中心 cFc_F を持つ各面 FF について、

emit F  ⟺  n^F⋅(0−cF)>0,IF=max⁡(0,n^F⋅ℓc) (α+(1−α) vF),\text{emit } F \iff \hat n_F \cdot (0 - c_F) > 0,\qquad I_F = \max(0, \hat n_F \cdot \ell_c)\,\big(\alpha + (1 - \alpha)\, v_F\big),

を計算します。ここでカメラはカメラ空間の原点にあり、ℓc=normalize⁡(Vℓ)\ell_c = \operatorname{normalize}(V \ell) はカメラ空間での光の方向(方向なので w=0w = 0)、環境光の係数は α=0.35\alpha = 0.35、影の可視度 vF∈[0,1]v_F \in [0, 1] は後述します。VV は剛体運動なので n^F⋅ℓc\hat n_F \cdot \ell_c はワールド空間のランバート項に等しく、カメラ空間で計算すれば法線をもう 1 組持つ必要がありません。出力される各四角形は triangulate_quad の 2 つの三角形になり、どちらも IFI_F を持ちます。

シャドウマッピング

ある点と光源の間にほかの面があるとき、その点は影の中にあります。平行光源ではすべての光線が ℓ\ell に平行なので、判定は平行(正射影)投影の中で ℓ\ell に沿った距離を比べることに帰着します。11 深度マップの手法は Lance Williams の “Casting curved shadows on curved surfaces”(SIGGRAPH 1978)で導入されました。

光源カメラ。 cc をシーンのワールド空間でのバウンディングボックスの中心、ρ=max⁡(1,max⁡p∥p−c∥)\rho = \max(1, \max_p \lVert p - c \rVert) とします。フロントエンドは Camera3 を c+(2ρ+1) ℓc + (2\rho + 1)\,\ell に置いて cc を向かせ、上方向 =+y= +y(座標系が退化しないよう ∣ℓ⋅y∣>0.99|\ell \cdot y| > 0.99 のときは +z+z)とします。すべての頂点はその前方 ρ+1\rho + 1 単位以上の位置にあります。このカメラの空間では、xℓx_\ell と yℓy_\ell は光線を横切る座標、zℓz_\ell は光線に沿って光源から離れる距離です。

グリッド。 全頂点の光源空間でのバウンディングボックスを各辺で max⁡(0.05⋅span,0.25)\max(0.05 \cdot \text{span}, 0.25) だけ広げ、その上に N=128N = 128 の N×NN \times N グリッドを置きます。

X=xℓ−xmin⁡xmax⁡−xmin⁡ (N−1),Y=ymax⁡−yℓymax⁡−ymin⁡ (N−1).X = \frac{x_\ell - x_{\min}}{x_{\max} - x_{\min}}\,(N - 1),\qquad Y = \frac{y_{\max} - y_\ell}{y_{\max} - y_{\min}}\,(N - 1).

深度パス。 すべてのオブジェクトのすべての三角形を、前面か背面かにかかわらず、下の規則でグリッドにラスタライズし、各テクセルには最小の zℓz_\ell(光源に最も近い面)を残します。深度は重心座標で線形補間し、ここではそれが正確です。c≠0c \ne 0 の平面 ax+by+cz=da x + b y + c z = d 上では z=(d−ax−by)/cz = (d - a x - b y)/c は (x,y)(x, y) についてアフィンであり、グリッドへの写像もアフィンだからです。

参照。 ワールド空間の点 qq を判定するには、フロントエンドはそれをバイアス bb の 2 倍だけ光源の方へ動かし、グリッドに写して最も近いテクセル (X∗,Y∗)(X^\ast, Y^\ast) に丸めます。光源カメラの前方軸は −ℓ-\ell なので、ℓ\ell に沿って 2b2b 動かすと zℓz_\ell はちょうど 2b2b 減ります。点がグリッドの外にある場合、テクセルが空の場合、または

zℓ(q)−2b≤D(X∗,Y∗)+b  ⟺  zℓ(q)≤D(X∗,Y∗)+3b,z_\ell(q) - 2b \le D(X^\ast, Y^\ast) + b \iff z_\ell(q) \le D(X^\ast, Y^\ast) + 3b ,

の場合に点は照らされているとみなすので、実効的な許容誤差は 3b3b で、b=max⁡(0.005⋅depth span,10−4)b = \max(0.005 \cdot \text{depth span}, 10^{-4}) です。

バイアスが必要な理由。 グリッドの解像度が有限なために面が自分自身に影を落とす現象をシャドウアクネと呼びます。最も近いテクセルの中心に丸めると、参照位置は各軸で最大で半テクセル Δ/2\Delta/2 ずれます。ここで Δ\Delta は光源空間の単位でのテクセルの大きさです。qq を通る面の光源空間での深度の勾配が (zx,zy)(z_x, z_y) なら、そのテクセルに格納された深度と zℓ(q)z_\ell(q) の差は最大で

∣D−zℓ(q)∣≤(∣zx∣+∣zy∣) Δ2.|D - z_\ell(q)| \le \big(|z_x| + |z_y|\big)\,\frac{\Delta}{2} .

です。法線が ℓ\ell と角度 θ\theta をなす面では ∥(zx,zy)∥=tan⁡θ\lVert (z_x, z_y) \rVert = \tan\theta なので、すれすれの入射では誤差は際限なく大きくなります。許容誤差 3b3b は 2 tan⁡θ Δ/2≤3b\sqrt2\,\tan\theta\,\Delta/2 \le 3b のときアクネを取り除きます。そうでない場合は θ\theta が 90°90° に近く、ランバート係数 cos⁡θ\cos\theta によって面はすでに暗いので、残ったアクネは目立ちません。バイアスの代償は、接触点で影が少し遅れて始まること(ピーターパン現象)で、その量はおよそ 3b3b = シーンの深度範囲の 1.5% です。

面の可視度。 フロントエンドは各面の中心と 4 頂点を判定して平均します。

vF=15∑q∈{cF,va,vb,vc,vd}1[q lit]∈{0,0.2,0.4,0.6,0.8,1}.v_F = \tfrac15 \sum_{q \in \{c_F, v_a, v_b, v_c, v_d\}} \mathbb{1}[q \text{ lit}] \in \{0, 0.2, 0.4, 0.6, 0.8, 1\}.

影の境界をまたぐ面は中間の値になり、フラットシェーディングされた面の上にぎざぎざの境界を描く代わりに、面の解像度で境界を和らげます。環境光の項によって、完全に影に入った面もランバートの明るさの α=35%\alpha = 35\% を保つので、影の中でも形が読み取れます。光源に背を向けた面は参照せず、輝度は 00 です。

エッジ関数によるラスタライズ

LumaBuffer::draw_triangle(と同じ規則を使う TUI とシャドウマップのラスタライザ)は、エッジ関数で被覆を判定します。画面平面上の点 aa、bb、pp について

E(a,b;p)=(px−ax)(by−ay)−(py−ay)(bx−ax),E(a, b; p) = (p_x - a_x)(b_y - a_y) - (p_y - a_y)(b_x - a_x),

とします。これは pp のアフィン関数で、直線 abab 上で 0 になり、その直線をまたぐと符号が変わります。∣E(a,b;p)∣|E(a, b; p)| は三角形 abpabp の面積の 2 倍です。三角形 p0p1p2p_0 p_1 p_2 について

A=E(p0,p1;p2),w0=E(p1,p2;p),w1=E(p2,p0;p),w2=E(p0,p1;p).A = E(p_0, p_1; p_2),\qquad w_0 = E(p_1, p_2; p),\quad w_1 = E(p_2, p_0; p),\quad w_2 = E(p_0, p_1; p).

と置きます。各 wiw_i は pp についてアフィンで、pip_i 以外の 2 頂点で 0 になります。符号付き面積は頂点の巡回置換で不変なので、p=pip = p_i では AA に等しくなります。したがって w0+w1+w2−Aw_0 + w_1 + w_2 - A は同一直線上にない 3 点で 0 になるアフィン関数であり、

w0+w1+w2=Afor every p,λi=wiAw_0 + w_1 + w_2 = A \quad\text{for every } p, \qquad \lambda_i = \frac{w_i}{A}

は pp の重心座標です(λi(pj)=δij\lambda_i(p_j) = \delta_{ij}、∑λi=1\sum \lambda_i = 1)。点が閉じた三角形の中にあるのは、すべての λi≥0\lambda_i \ge 0、つまりすべての wiw_i が AA と同じ符号か 0 のときに限ります。コードは両方の符号を判定するので、画面上での三角形の巡回順は関係ありません。カリングは 3D ですでに済んでいます。ピクセルは中心 (x+12,y+12)(x + \tfrac12, y + \tfrac12) で、三角形のバウンディングボックスの中だけでサンプリングし、∣A∣≤|A| \le DEPTH_EPSILON の三角形はスキップします。覆われたピクセルの深度は透視補正された (∑λi/zi)−1\big(\sum \lambda_i / z_i\big)^{-1} です。

深度バッファ

set_if_closer は、新しい深度が格納済みのものより ε\varepsilon = DEPTH_EPSILON を超えて小さいときだけピクセルを書き込みます。描いた三角形についての帰納法により、T1,…,TkT_1, \dots, T_k を描いた後、各ピクセルはそこを覆う三角形のうち深度が最小のものの輝度を持ち、その深度から ε\varepsilon 以内の三角形が複数あれば最初に描かれたものの輝度を持ちます。基底は深度 103010^{30} の空のバッファです。帰納段階では、Tk+1T_{k+1} は ε\varepsilon を超えて厳密に近いときに限り格納済みの値を置き換えます。したがって最終的な画像は、ほぼ同点の場合を除き描画順序に依存せず、そのため描画リストは並べ替えません。

辺は含まれ、「左上」の同点規則はありません。2 つの三角形が共有する辺のちょうど上にあるピクセルの中心は両方に覆われ、深度テストがどちらか一方を残します。四角形の 2 つの三角形は同じ輝度を持つので、面の内部では見分けがつきません。

露光とオプティカルフロー

シャッター時間 TT のカメラは各ピクセルで ∫0TL(x,t) dt\int_0^T L(x, t)\,dt を記録します。フロントエンドは正規化された露光を、NN 個の描画サンプルにわたるリーマン和で近似します。

Lˉ(x)=1T∫0TL(x,t) dt≈1N∑k=0N−1L(x,tk),\bar L(x) = \frac1T \int_0^T L(x, t)\,dt \approx \frac1N \sum_{k=0}^{N-1} L(x, t_k),

これは重み 1/N1/N の LumaBuffer::add_weighted_sample で実装されています。動く輪郭はモーションブラーとして伸びます。

オプションのフローによる位置合わせは輝度一定 Lprev(x+d)≈Lcur(x)L_{\text{prev}}(x + d) \approx L_{\text{cur}}(x) を仮定し、総当たりのブロックマッチングでピクセルごとの整数の変位 dd を推定します。

d(x)=arg min⁡∥d∥∞≤R ∑∥o∥∞≤P(Lprev(x+o+d)−Lcur(x+o))2.d(x) = \operatorname*{arg\,min}_{\lVert d \rVert_\infty \le R}\ \sum_{\lVert o \rVert_\infty \le P} \big( L_{\text{prev}}(x + o + d) - L_{\text{cur}}(x + o) \big)^2 .

積算の前に各サンプルをそのフローでワープすると(align_with_flow)、動く内容が現在のフレームに位置合わせされ、ぼけの代わりにゴーストの少ない鮮明な輪郭が得られます。推定値は整数です。開口問題の影響を受け、一様なパッチではどの dd も合うので、走査順により (−R,−R)(-R, -R) が返ります。

設計上の判断

境界としての描画リスト

課題は、出力モデルがまったく異なる 3 つのバックエンド(文字のグリッド、ピクセルのキャンバス、保持される SVG ノード)が同じシーンを表示しなければならないことです。選択肢は、メッシュを渡す(各バックエンドが投影と照明を実装し直す)、ピクセルを渡す(SVG バックエンドがベクター出力を失う)、投影とシェーディングを済ませた三角形を渡す、の 3 つで、最後のものを選びました。DrawTriangle には、どのバックエンドにも必要なもの、つまり画面座標、遮蔽のためのカメラ深度、各バックエンドが自分のパレットに対応付ける [0,1][0, 1] の明るさがちょうど含まれます。投影、カリング、照明、影は 1 か所で一度だけ計算され、一度だけテストされます。

四角形ごとのフラットシェーディング

面ごとに 1 つの輝度は、デモのファセット状のローポリゴンの見た目やターミナルの粗い解像度に合い、描画リストも小さく保ちます。スムーズシェーディングには core にない頂点ごとの法線と、SVG バックエンドでは表現できないピクセルごとの補間された輝度が必要になります。

影はフロントエンドで

影にはシーン全体のワールド空間の幾何が必要で、バックエンドはそれを見ないので、影はフロントエンドに属します。レイキャスティングではなくシャドウマップを選びました。コストは光源からのシーンのラスタライズ 1 回とサンプル点ごとの参照 1 回で、既存のラスタライザを再利用でき、加速構造も不要です。可視度は面ごとに 5 回しかサンプリングしないので、解像度は 128 × 128 に固定しています。より細かいマップにしてもフラットシェーディングの出力は変わりません。範囲は毎フレームシーンに合わせるので、常に解像度をすべて使えます。

共有のスカラーバッファ

LumaBuffer がバックエンドではなくフロントエンドにあるのは、深度付きのスカラー画像を必要とする利用者が 3 つあるからです。Canvas バックエンド、露光の積算、オプティカルフローです。TUI バックエンドは直接描画用に自分の文字バッファを持ち、露光効果には(draw_list_to_tui_luma を通して)LumaBuffer を使います。

データとしての時間

Timeline、ScalarTrack、ExposureSettings、フローの関数は純粋なデータとバッファの関数で、時計を呼ぶことはありません。いつ描画し、どの時刻をサンプリングするかはデモが決めるので、フロントエンドは決定的でテストしやすいままです。

正しさと不変条件

  • 出力される三角形はカメラの方を向き(カメラ空間で n^⋅(0−c)>0\hat n \cdot (0 - c) > 0)、光の方向が単位ベクトルなら輝度は丸めを除いて [0,1][0, 1] に入ります(完全に照らされた面が 1+2−521 + 2^{-52} になることがあります)。バックエンドがそれをクランプします。
  • 描画リストはシーンの順に、オブジェクトごと、面ごとに並び、可視の四角形 1 つにつき三角形が 2 つあります。
  • 上で示したとおり、LumaBuffer は各ピクセルで、そこを覆う最も近い三角形の値(ε\varepsilon 以内の同点なら最初に描かれたもの)を保持します。
  • 影の参照は、マップの外や空のテクセルの下にある点を暗くすることはありません。可視度は 0.20.2 の倍数です。
  • ExposureSettings は常に 0<shutter≤frame_dt0 < \text{shutter} \le \text{frame\_dt} で、サンプルは 1 つ以上です。auto はちょうど 1 サンプルになります(数を求める前にシャッターがクランプされるため)。
  • Timeline::frame_count は 1 以上で、サンプルの時刻は k/fpsk/\mathit{fps}、進捗は [0,1][0, 1] にクランプされます。

フレームあたりのコストは、変換、カリング、シェーディングが O(V+F)O(V + F)、ラスタライズは各三角形が覆うバウンディングボックスの面積に比例(シャドウマップと LumaBuffer の両方)、照らされた可視の面ごとに影の参照が 5 回、オプティカルフローが O(WH(2R+1)2(2P+1)2)O(W H (2R+1)^2 (2P+1)^2) です。

採用しなかった案

  • 描画リストを深度で並べ替えること。 TUI と Canvas のバックエンドでは正しい遮蔽は深度バッファから得られ、SVG バックエンドは深度バッファがないので自分で並べ替えます。フロントエンドで並べ替えると、1 つの戦略をすべてに押し付けることになります。
  • グーローシェーディングやフォンシェーディング。 「四角形ごとのフラットシェーディング」を参照してください。TUI の出力も、良くなるどころか雑然とします。
  • 隣接テクセルでのパーセンテージクローサーフィルタリング。 面ごとの 5 サンプルで、フラットシェーディングが表現できる中間の値はすでに得られます。
  • ExposureSettings::auto のクランプを変えること。 シャッターを 1 フレームより長くできれば auto は複数のサンプルを返せますが、1 つのフレームが次のフレームの区間の光まで積分することになります。

境界

フロントエンドは次のことをしません。

  • 視錐台やニア平面による幾何のクリッピング。すべての頂点はカメラの前方になければなりません。
  • 点光源やスポットライト、色付きの光、複数の光源、マテリアル、テクスチャへの対応。
  • 文字、色、デバイス上のピクセル、ファイルの生成。
  • フレーム間での描画リストの並べ替え、バッチ処理、キャッシュ。
  • サブピクセルや密な変分法によるオプティカルフローの推定。

Footnotes

  1. 深度マップの手法は Lance Williams の “Casting curved shadows on curved surfaces”(SIGGRAPH 1978)で導入されました。 ↩