backend/tui 教程

本教程介绍如何把场景变成终端文本:渲染一帧、让它动起来、选择自己的字符和背景、制作长曝光,以及把帧和序列保存到文件。

快速开始

把 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 坐标压缩了一半,因此在单元高度为宽度两倍的终端上,圆环看起来是圆的。

常见任务

让帧尺寸适配终端

大多数 shell 会以 COLUMNS 和 LINES 导出终端尺寸。读取它们(例如用 moonbitlang/core/env 中的 @env.get_env_vars()),为提示符留出一行,并让视口和配置使用相同的尺寸:

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")
}

动画

每个时间轴采样渲染一帧,并在帧之间用 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 保存一帧,.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}}。

性能

一帧的开销是前端管线加上对每个三角形包围盒的一次遍历,因此随被覆盖单元的数量增长。在 80 × 32 下,演示程序每帧渲染几千个三角形也毫不吃力。让几何体远离眼睛:非常靠近的三角形会投影成巨大的包围盒,而光栅化器会遍历整个包围盒。

常见陷阱

  • 两个尺寸。 投影的视口和 TuiRenderConfig 要使用相同的宽度和高度;否则画面会偏离中心或被裁切。
  • 未被照亮的面是空格。 在直接路径中,亮度为 0 的面用字符表的第一个字符(空格)绘制;在亮度路径中它显示背景。两者都仍会遮挡其后的内容。
  • 空字符表。 对空字符表调用 shade_char 会中止。请使用基本多文种平面中的字符;字符表的每一项占一个 UTF-16 代码单元。
  • 只读配置。 在包外,TuiRenderConfig 的记录字面量或 { ..config, shade_ramp: ... } 无法编译;请改用底层函数。
  • 多余的空行。 FrameBuffer::to_string 以换行结尾,而 println 又会添加一个,因此打印出的帧后面跟着一个空行。
  • 未知的文件内容。 解码器从不失败;没有魔数行的文件会被解码为空的 80 × 32 图像或序列。若需要检测错误输入,请检查帧数。

下一步

  • TUI API 列出了每个函数和文件格式。
  • TUI 设计推导了宽高比校正、终端中的视场角以及量化误差。
  • demo 教程展示了基于本包构建的完整终端播放器。