backend/tui の設計

設計目標

TUI バックエンドは、フロントエンドの三角形を文字だけでターミナルに表示します。ターミナルのセルは粗く正方形でないピクセルで、このバックエンドでは色もないため、このパッケージの仕事は 3 つです。立方体が立方体に見えるようセルの形を補正すること、連続的な輝度を少数の文字に対応付けること、セルごとに遮蔽を解決することです。テスト、ファイル、動画ツールが扱えるプレーンな文字列を生成し、デモに属する ANSI エスケープコードやターミナルの入出力は含みません。

数学的背景

正方形でないセル

フロントエンドは正方形の単位からなるグリッドに投影します。xx の 1 単位と yy の 1 単位は物理的に同じ長さです。一般的なターミナルのセルは高さが幅のおよそ 2 倍です。投影の yy をそのまま行番号に使うと、すべての形がセルの縦横比 κ=hcell/wcell≈2\kappa = h_{\text{cell}} / w_{\text{cell}} \approx 2 だけ縦に引き伸ばされて見えます。物理的な長さをセルの幅で測ると、Δy\Delta y 単位の垂直な線分は Δy/κ\Delta y / \kappa 行に収まらなければなりません。apply_terminal_y_scale はこの拡大縮小を中央の行を中心に適用し、画像が中央に保たれるようにします。

y′=H2+(y−H2)k,k=1κ=0.5.y' = \frac{H}{2} + \left(y - \frac{H}{2}\right) k,\qquad k = \frac{1}{\kappa} = 0.5 .

xx と深度は変わりません。この写像は画面座標についてアフィンなので重心座標を保ち、したがって view の設計の透視補正された深度の規則も保ちます。圧縮の後で 1/z1/z を補間しても、圧縮の前と同じ深度が得られます。

ターミナルでの画角

ScientificCamera を使うと、投影スケール s=Hf/hs = H f / h により垂直方向の画角は HH 単位に広がり、圧縮によってそれが H/2H/2 行になります。画像は歪みませんが、垂直方向の画角は中央の半分の行を占めます。言い換えると、ターミナルの高さ全体に表示される垂直方向の角度は

2arctan⁡ ⁣(H/2k s)=2arctan⁡hf2 \arctan\!\left(\frac{H/2}{k\,s}\right) = 2 \arctan\frac{h}{f}

となり、2arctan⁡(h/(2f))2 \arctan\big(h / (2f)\big) ではありません。デモはこれを踏まえて焦点距離を選んでいます。tools/ の動画書き出しツールは同じ 1:21 : 2 の縦横比でセルを描くので、2 つの変換は物理的に打ち消し合います。

濃淡の量子化

セルに置くインクの量で並べた nn 文字 c0…cn−1c_0 \dots c_{n-1} のランプは、輝度を等間隔の nn 段階のうち最も近いものに丸めて表します。

i(I)=round⁡(clamp⁡[0,1](I) (n−1)),∣i(I)n−1−I∣≤12(n−1)(0≤I≤1).i(I) = \operatorname{round}\big(\operatorname{clamp}_{[0,1]}(I)\,(n - 1)\big),\qquad \left| \frac{i(I)}{n - 1} - I \right| \le \frac{1}{2(n - 1)} \quad (0 \le I \le 1).

既定のランプ " .:-=+*#%@" は n=10n = 10 なので、量子化誤差は最大 1/18≈0.0561/18 \approx 0.056 です。輝度 00 は最初の文字である空白に対応するので、照らされていない面も描かれ、背後のものを隠します。浮動小数点の丸めで 1 をわずかに超えた輝度はクランプされます。

ラスタライズと深度

draw_triangle_z は frontend の設計で導出したエッジ関数の規則と深度の不変条件を使います。セルの中心 (x+12,y+12)(x + \tfrac12, y + \tfrac12) が閉じた三角形の中にあればセルは覆われ、そこの深度は (∑λi/zi)−1\big(\sum \lambda_i / z_i\big)^{-1} で、格納済みの深度より DEPTH_EPSILON を超えて近いときだけ書き込みます。すべての三角形を描いた後、各セルには描画順序に関係なく、そこを覆う最も近い三角形(ほぼ同点なら最初に描かれたもの)の文字が表示されます。

2 つの描画経路

直接経路(render_draw_list)は三角形ごとに文字を選び、文字をラスタライズします。輝度経路(draw_list_to_tui_luma に続けて render_luma_buffer)は、まず輝度をフロントエンドの LumaBuffer にラスタライズし、その後セルごとに量子化します。単一のフレームでは、照らされた面ではどちらも同じ文字になります。違いは 2 つあります。

  • 輝度経路はあらゆる処理の後で量子化します。量子化の前に NN 回の露光を平均すると、文字を平均しても得られない中間の濃淡が得られます。
  • 輝度経路は値が 00 より大きいセルだけを描くので、照らされていない面には空白ではなく背景パターンが表示されます。

ファイル形式

どちらの形式も行指向のテキストです。

GEOMETRY3D_TUI_SEQUENCE v1        GEOMETRY3D_TUI_IMAGE v1
width=W                           width=W
height=H                          height=H
fps=F                             ---image---
frames=N                          <H lines>
---frame---
<H lines>
---frame---
<H lines>
...

デコーダは '\n' で分割し、'\r' を取り除き、ヘッダの行の = の後の数字を読み(読めなければ既定値)、各マーカーに続く HH 行を取ります。往復の性質:各フレームの内容がちょうど HH 行からなり、各行が '\n' で終わり '\r' を含まないなら、decode(encode(s)) は s と同じサイズ、フレームレート、フレームの内容を持ちます。エンコーダはヘッダを書き、各マーカーとそれに続く HH 行をそのまま書き、デコーダは各マーカーの後のちょうど HH 行を読み戻して行末を付け直すからです。frames= の数は参考情報で、デコーダは代わりにマーカーを数えます。そのため --record-stdout はフレーム数を前もって知らなくてもフレームをストリーミングできます。

設計上の判断

アスペクト補正はバックエンドで行う

正方形でないセルは出力デバイスの性質であって、シーンやカメラの性質ではないので、補正はここにだけ置きます。core、view、frontend は正方形の単位を保ち、ピクセルが正方形の Canvas と SVG のバックエンドは補正を必要としません。係数は設定のフィールドなので、パッケージはほかの形のセルを持つターミナルにも対応できます。

三角形ごとに 1 文字

各三角形の輝度は一様なので、直接経路は文字を一度だけ選んで文字をラスタライズします。フラットシェーディングでは、これが最も安価で正しい選択です。輝度経路は、量子化の前に輝度に対する演算が必要な場合のためにあります。

関数としてのパターン

背景はセルの位置とバッファのサイズの関数なので、点、市松模様、枠、グラデーションはどれも 1 行で書け、画像を保持する必要もありません。dotted_background が既定なのは、行末の空白が見えないターミナルでもフレームの範囲がわかるからです。

ターミナル入出力ではなく文字列

このパッケージは String の値を返し、出力は一切しません。画面のクリア、時間計測、COLUMNS/LINES の読み取り、ファイルへの書き込みはデモの仕事です。これによりバックエンドはテスト(壊れやすいフレーム全体のスナップショットではなく振る舞いの検証)やすべての MoonBit ターゲットで使えます。

寛容なプレーンテキストの形式

これらの形式はテキストエディタで読め、差分を取れ、Python の動画書き出しツールで簡単に解析できます。デコーダは失敗せず既定値に戻ります。唯一の利用者であるデモのプレーヤーにとっては、中断するより何かを表示するほうが望ましいからです。

正しさと不変条件

  • FrameBuffer::to_string の長さはちょうど height * (width + 1) 文字です。
  • shade_char はどんな入力にもランプの文字を返し、[0,1][0, 1] での誤差は最大 1/(2(n−1))1/(2(n - 1)) です。
  • 縦方向の圧縮は透視補正された深度を保つので、圧縮の有無で遮蔽は変わりません。
  • 各セルにはそこを覆う最も近い三角形が表示され、背景のセルは深度 FAR_DEPTH のままです。
  • 上で述べた条件のもとで、シーケンスと画像は往復変換で元に戻ります。

三角形を描くコストは、そのバウンディングボックスのセル単位の面積に比例します。ボックスはバッファに切り詰められないので、画面外の巨大な三角形(視点の近くの幾何から生じる)は何も書き込まなくても時間がかかります。

採用しなかった案

  • ANSI カラーや Unicode のブロック文字、点字。 解像度や色は増えますが、ターミナルの機能やフォントに依存します。純粋な ASCII のランプは、ファイルや動画書き出しツールを含めどこでも動きます。
  • 投影でアスペクト比を補正する。 一様でない投影スケールは、デバイスの性質を view やほかのすべてのバックエンドに漏らします。
  • ディザリング。 誤差拡散は平らな面に模様を加えてしまいます。10 段階あれば、面ごとのシェーディングはすでに十分読み取れます。
  • バイナリや圧縮されたファイル形式。 テキストならファイルを調べやすく、ツールも単純に保てます。必要なら記録は汎用のツールでよく圧縮できます。

境界

TUI バックエンドは次のことをしません。

  • 出力、画面のクリア、ターミナルのサイズや環境変数の読み取り、時間計測。
  • ANSI エスケープコード、色、Unicode のブロック文字の出力。
  • 三角形のバッファへのクリッピング、アンチエイリアス、ディザリング。
  • ファイルの内容が宣言された幅と一致するかの検証。
  • 呼び出し側がパッケージの外から TuiRenderConfig を通してランプ、背景、圧縮率を変えること(フィールドは読み取り専用です)。これらの用途は下位の関数で賄えます。