backend/tui チュートリアル

このチュートリアルでは、シーンをターミナルのテキストに変える方法を説明します。1 フレームの描画、アニメーション、独自の文字と背景の選択、長時間露光、フレームとシーケンスのファイルへの保存です。

クイックスタート

シーンを作るパッケージと一緒に TUI バックエンドをインポートします。

import {
  "Luna-Flow/geometry3d/core",
  "Luna-Flow/geometry3d/view",
  "Luna-Flow/geometry3d/frontend",
  "Luna-Flow/geometry3d/backend/tui",
}

トーラスを 48 × 16 のセルに描画して出力します。

fn main {
  let viewport = @view.Viewport::new(48, 16)
  let scene = @frontend.Scene::single(
    @core.torus_mesh(1.6, 0.6, 24, 12),
    @core.Transform3::rotation(1.1, 0.3, 0.0),
    @frontend.Light::default(),
  )
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(5.0),
    @view.PerspectiveProjection::new(viewport, 24.0),
  )
  let config = @tui.TuiRenderConfig::sized(48, 16)
  println(@tui.render_scene(scene, view, config).to_string())
}

出力:

................................................
................................................
....................**%%%@@@....................
.................==*=====%%%%%@.................
...............----=..   +++++%%@...............
...............:::..         +++%%%.............
..............::--: .......    ==##.............
..............::-===.........   ==*#............
................=+**=........ ..==*.............
.................=##@@+....   ..==+.............
................. ++%%%#++=:.::--=..............
.................... ++===--::-:................
................................................
................................................
................................................
................................................

投影は設定と同じ 48 × 16 のビューポートを使うので、光軸はフレームの中央に来ます。バックエンドはすべての yy 座標を半分に圧縮したので、セルの高さが幅の 2 倍のターミナルでもトーラスは丸く見えます。

よくある作業

フレームをターミナルの大きさに合わせる

たいていのシェルはターミナルのサイズを COLUMNS と LINES として公開します。それを読み(たとえば moonbitlang/core/env の @env.get_env_vars() で)、プロンプト用に 1 行残し、ビューポートと設定に同じサイズを使います。

fn frame_for_size(columns : Int, lines : Int) -> String {
  let width = if columns > 0 { columns } else { @tui.DEFAULT_WIDTH }
  let height = if lines > 1 { lines - 1 } else { @tui.DEFAULT_HEIGHT }
  let viewport = @view.Viewport::new(width, height)
  let camera = @view.ScientificCamera::new(
    @view.Camera3::default(4.5),
    @view.SensorSpec::full_frame(),
    @view.LensSpec::new(18.0),
    @view.WorldUnit::unitless(),
  )
  let scene = @frontend.Scene::single(
    @core.cube_mesh(1.0),
    @core.Transform3::rotation(0.4, 0.6, 0.0),
    @frontend.Light::default(),
  )
  @tui.render_frame(
    @frontend.build_draw_list(scene, @frontend.RenderView::scientific(camera, viewport)),
    @tui.TuiRenderConfig::sized(width, height),
  )
}

test "terminal size" {
  let frame = frame_for_size(100, 31)
  inspect(frame.length(), content="3030")
}

アニメーション

タイムラインのサンプルごとに 1 フレームを描画し、フレームの間は ANSI シーケンス ESC [2J ESC [H で画面をクリアします。保存もしたい場合はフレームを TuiSequence に集めます。

fn spin_sequence(seconds : Double, fps : Int) -> @tui.TuiSequence {
  let config = @tui.TuiRenderConfig::sized(40, 14)
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(40, 14), 20.0),
  )
  let timeline = @frontend.Timeline::new(seconds, fps)
  let sequence = @tui.TuiSequence::new(40, 14, fps)
  for k in 0..<timeline.frame_count() {
    let t = timeline.sample(k).time_seconds
    let scene = @frontend.Scene::single(
      @core.cube_mesh(1.0),
      @core.Transform3::rotation(1.5 * t, 1.05 * t, 0.6 * t),
      @frontend.Light::default(),
    )
    sequence.push_frame(@tui.render_frame(@frontend.build_draw_list(scene, view), config))
  }
  sequence
}

fn play(sequence : @tui.TuiSequence) -> Unit {
  for frame in sequence.frames {
    println("\u{1b}[2J\u{1b}[H\{frame.content}")
    // wait 1000 / sequence.fps milliseconds here
  }
}

test "animate" {
  let sequence = spin_sequence(1.0, 12)
  inspect(sequence.frames.length(), content="12")
  inspect(sequence.frames[0].content != sequence.frames[6].content, content="true")
}

独自の文字と背景を選ぶ

TuiRenderConfig は自分のパッケージからは変えられませんが、部品は公開されています。任意の背景関数で FrameBuffer を作り、apply_terminal_y_scale で頂点を圧縮し、任意のランプから shade_char で文字を選び、draw_triangle_z でラスタライズします。

fn render_with(list : @frontend.DrawList, width : Int, height : Int, ramp : String) -> String {
  let buffer = @tui.FrameBuffer::new(width, height, fn(x, y, _, _) {
    if (x + 2 * y) % 7 == 0 { '\'' } else { ' ' }
  })
  for t in list.triangles {
    @tui.draw_triangle_z(
      buffer,
      @tui.apply_terminal_y_scale(t.p0, height, 0.5),
      @tui.apply_terminal_y_scale(t.p1, height, 0.5),
      @tui.apply_terminal_y_scale(t.p2, height, 0.5),
      @tui.shade_char(ramp, t.intensity),
    )
  }
  buffer.to_string()
}

test "custom ramp" {
  let scene = @frontend.Scene::single(
    @core.sphere_mesh(1.2, 8, 12),
    @core.Transform3::identity(),
    @frontend.Light::default(),
  )
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.0),
    @view.PerspectiveProjection::new(@view.Viewport::new(32, 12), 16.0),
  )
  let text = render_with(@frontend.build_draw_list(scene, view), 32, 12, "_-~=oO0@")
  inspect(text.contains("O") || text.contains("0"), content="true")
}

長時間露光を作る

draw_list_to_tui_luma で複数の瞬間を輝度バッファにラスタライズし、平均してから render_luma_frame で一度だけ量子化します。量子化の前に平均すると、ぼけた輪郭に中間の濃淡が付きます。

fn exposure_frame(samples : Int) -> String {
  let config = @tui.TuiRenderConfig::sized(40, 14)
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(40, 14), 20.0),
  )
  let acc = @frontend.LumaBuffer::new(40, 14)
  for k in 0..<samples {
    let angle = 0.6 + k.to_double() * 0.06
    let scene = @frontend.Scene::single(
      @core.cube_mesh(1.0),
      @core.Transform3::rotation(0.4, angle, 0.0),
      @frontend.Light::default(),
    )
    let sample = @tui.draw_list_to_tui_luma(@frontend.build_draw_list(scene, view), config)
    acc.add_weighted_sample(sample, 1.0 / samples.to_double())
  }
  @tui.render_luma_frame(acc, config)
}

test "long exposure" {
  inspect(exposure_frame(1) != exposure_frame(8), content="true")
}

フレームの保存と読み込み

.tuiimg は 1 フレーム、.tui3d はシーケンスを保持し、どちらもプレーンテキストです。エンコードした文字列は任意のファイル API で書き出せます(デモは moonbitlang/x/fs を使っています)。

test "save and load" {
  let sequence = spin_sequence(0.5, 4)
  let text = @tui.encode_tui_sequence(sequence)
  inspect(text.has_prefix("GEOMETRY3D_TUI_SEQUENCE v1\nwidth=40\nheight=14\nfps=4\nframes=2\n"), content="true")
  let back = @tui.decode_tui_sequence(text)
  inspect(back.frames.length(), content="2")
  inspect(back.frames[1].content == sequence.frames[1].content, content="true")
  let image = @tui.TuiImage::new(40, 14, sequence.frames[0].content)
  let loaded = @tui.decode_tui_image(@tui.encode_tui_image(image))
  inspect(loaded.content == image.content, content="true")
}

さらに進んで

記録を動画にする

リポジトリの tools/tui3d_to_video.py は .tui3d ファイルを H.264 の MP4 か ProRes の MOV に変換します。各セルを 1:2 の縦横比で描き、これは既定の terminal_y_scale の値 0.5 に対応するので、動画はシーンと同じ比率で表示されます。オプションは tools/README.md を見てください。

ほかの形のセル

フォントによってはセルが 1:1.8 や 1:2.2 に近いことがあります。apply_terminal_y_scale は任意の係数を受け付けるので、上の下位の経路を使えば、自分のフォントに合わせて k=wcell/hcellk = w_{\text{cell}} / h_{\text{cell}} を使えます。

性能

1 フレームのコストはフロントエンドのパイプラインと、各三角形のバウンディングボックスの走査 1 回分で、覆われるセルの数とともに増えます。80 × 32 なら、デモはフレームあたり数千の三角形を楽に描けます。幾何は視点から離しておいてください。視点にとても近い三角形は巨大なバウンディングボックスに投影され、ラスタライザはそれをすべて走査します。

よくある落とし穴

  • 2 つのサイズ。 投影のビューポートと TuiRenderConfig には同じ幅と高さを使ってください。そうしないと画が中央からずれたり切れたりします。
  • 照らされていない面は空白。 直接経路では輝度 0 の面はランプの最初の文字である空白で描かれ、輝度経路では背景が表示されます。どちらも背後のものは隠します。
  • 空のランプ。 空のランプで shade_char を呼ぶと中断します。基本多言語面の文字を使ってください。ランプの各要素は UTF-16 の 1 コード単位です。
  • 読み取り専用の設定。 パッケージの外では、TuiRenderConfig のレコードリテラルや { ..config, shade_ramp: ... } はコンパイルできません。代わりに下位の関数を使ってください。
  • 余分な空行。 FrameBuffer::to_string は改行で終わり、println がもう 1 つ加えるので、出力したフレームの後に空行が続きます。
  • 不明なファイルの内容。 デコーダは失敗しません。マジック行のないファイルは空の 80 × 32 の画像やシーケンスにデコードされます。不正な入力を見分けたい場合はフレーム数を確認してください。

次のステップ

  • TUI API にはすべての関数とファイル形式が載っています。
  • アスペクト補正、ターミナルでの画角、量子化誤差は TUI の設計で導出しています。
  • このパッケージで作った完全なターミナルプレーヤーは demo チュートリアルで紹介しています。