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 视口,因此光轴落在帧的中央。后端把每个 坐标压缩了一半,因此在单元高度为宽度两倍的终端上,圆环看起来是圆的。
常见任务
让帧尺寸适配终端
大多数 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 接受任意系数,因此借助上面的底层路径,你可以为自己的字体使用 。
性能
一帧的开销是前端管线加上对每个三角形包围盒的一次遍历,因此随被覆盖单元的数量增长。在 80 × 32 下,演示程序每帧渲染几千个三角形也毫不吃力。让几何体远离眼睛:非常靠近的三角形会投影成巨大的包围盒,而光栅化器会遍历整个包围盒。
常见陷阱
- 两个尺寸。 投影的视口和
TuiRenderConfig要使用相同的宽度和高度;否则画面会偏离中心或被裁切。 - 未被照亮的面是空格。 在直接路径中,亮度为 0 的面用字符表的第一个字符(空格)绘制;在亮度路径中它显示背景。两者都仍会遮挡其后的内容。
- 空字符表。 对空字符表调用
shade_char会中止。请使用基本多文种平面中的字符;字符表的每一项占一个 UTF-16 代码单元。 - 只读配置。 在包外,
TuiRenderConfig的记录字面量或{ ..config, shade_ramp: ... }无法编译;请改用底层函数。 - 多余的空行。
FrameBuffer::to_string以换行结尾,而println又会添加一个,因此打印出的帧后面跟着一个空行。 - 未知的文件内容。 解码器从不失败;没有魔数行的文件会被解码为空的 80 × 32 图像或序列。若需要检测错误输入,请检查帧数。