backend/tui 设计
设计目标
TUI 后端只用字符在终端中显示前端的三角形。终端字符单元是粗糙的、非正方形的像素,在本后端中没有颜色,因此本包有三项任务:校正单元形状,使立方体看起来像立方体;把连续的亮度映射到少数几个字符上;逐单元解决遮挡。它应生成测试、文件和视频工具都能使用的纯字符串,并且不包含属于演示程序的 ANSI 转义码和终端 I/O。
数学背景
非正方形的单元
前端投影到由正方形单位组成的网格上: 的一个单位与 的一个单位物理长度相同。典型的终端单元高度约为宽度的两倍。如果把投影的 直接用作行号,所有形状都会在纵向上被拉长,拉伸比例为单元宽高比 。以单元宽度度量物理长度时, 个单位的竖直线段必须覆盖 行。apply_terminal_y_scale 以中间行为中心施加这一缩放,使图像保持居中:
和深度保持不变。该映射在屏幕坐标上是仿射的,因此保持重心坐标,从而也保持 view 设计中的透视校正深度规则:压缩之后插值 得到的深度与压缩之前相同。
终端中的视场角
使用 ScientificCamera 时,投影比例 使垂直视场角覆盖 个单位,而压缩把它变成 行。图像没有变形,但垂直视场只占中间一半的行。等价地说,终端的全部高度所显示的垂直角度为
而不是 。演示程序在选择焦距时考虑了这一点。tools/ 中的视频导出工具以同样的 宽高比渲染单元,因此两次变换在物理上相互抵消。
明暗量化
一个由 个字符 组成、按其在单元中占用墨量排序的字符表,通过舍入到 个等间距级别中最近的一个来表示亮度:
默认字符表 " .:-=+*#%@" 有 ,因此量化误差至多为 。亮度 映射到第一个字符,即空格,因此未被照亮的面仍会被绘制,并仍会遮挡其后的内容。由浮点舍入导致的略大于 1 的亮度会被截断。
光栅化与深度
draw_triangle_z 使用 frontend 设计中推导的边函数规则和深度不变量:当单元中心 位于闭三角形内时,该单元被覆盖,此处深度为 ,并且只有在它比已存深度近超过 DEPTH_EPSILON 时才写入。画完所有三角形后,每个单元显示覆盖它的最近三角形的字符(近似平局时取最先绘制的),与绘制顺序无关。
两条渲染路径
直接路径(render_draw_list)为每个三角形选择一个字符并光栅化字符。亮度路径(draw_list_to_tui_luma 之后接 render_luma_buffer)先把亮度光栅化到前端的 LumaBuffer 中,之后再逐单元量化。对于单帧,两者在受光表面上给出相同的字符。它们有两点不同:
- 亮度路径在任何处理之后才量化。在量化之前对 次曝光求平均,可以得到对字符求平均所无法得到的中间明暗。
- 亮度路径只绘制值大于 的单元,因此未被照亮的面显示背景图案而不是空格。
文件格式
两种格式都是面向行的文本:
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'、读取文件头各行 = 之后的数字(失败时回退为默认值),并取每个标记之后的 行。往返性质:如果每帧的内容恰好由 行组成,每行以 '\n' 结尾且不含 '\r',则 decode(encode(s)) 与 s 有相同的尺寸、帧率和帧内容。编码器写出文件头,然后逐字写出每个标记及其后的 行,而解码器恰好读回每个标记之后的 行并重新加上行尾。frames= 计数仅供参考;解码器改为数标记,这使 --record-stdout 可以在不预先知道帧数的情况下流式输出帧。
设计决策
宽高比校正放在后端
非正方形单元是输出设备的属性,而不是场景或相机的属性,因此校正只放在这里。core、view 和 frontend 保持正方形单位,像素为正方形的 Canvas 和 SVG 后端不需要校正。该系数是一个配置字段,因此本包能够支持单元形状不同的终端。
每个三角形一个字符
每个三角形只有一个平面亮度,因此直接路径只需选一次字符,然后光栅化字符。对平面着色而言,这是代价最小的正确选择。亮度路径则用于需要在量化前对亮度做运算的场合。
图案即函数
背景是单元位置和缓冲尺寸的函数,因此点阵、棋盘格、边框或渐变都只需一行代码,不需要存储图像。dotted_background 是默认值,因为它能在终端中显示帧的范围,而行尾空格在终端中是看不见的。
返回字符串,而不做终端 I/O
本包返回 String 值,从不打印。清屏、计时、读取 COLUMNS/LINES 和写文件都是演示程序的事,这使后端可以用于测试(行为断言,而非脆弱的整帧快照),并可在每个 MoonBit 目标上使用。
宽松的纯文本文件格式
这些格式可以在文本编辑器中阅读、可以做差异比较,并且 Python 视频导出工具可以轻松解析。解码器从不失败,而是回退到默认值,因为它们唯一的使用者——演示播放器——宁可显示些什么也不愿中止。
正确性与不变量
FrameBuffer::to_string恰好有height * (width + 1)个字符。shade_char对任何输入都返回字符表中的字符;在 上其误差至多为 。- 纵向压缩保持透视校正深度,因此有无压缩时遮挡关系相同。
- 每个单元显示覆盖它的最近三角形;背景单元保持深度
FAR_DEPTH。 - 在上述条件下,序列和图像可以无损往返。
绘制一个三角形的开销与其包围盒覆盖的单元面积成正比。包围盒不会被裁剪到缓冲范围内,因此一个巨大的屏幕外三角形(来自离眼睛很近的几何体)即使什么也不写,也会耗费时间。
被否决的方案
- ANSI 颜色或 Unicode 方块与盲文字符。 它们能提供更高的分辨率或颜色,但依赖终端能力和字体。纯 ASCII 字符表在任何地方都能工作,包括文件和视频导出工具中。
- 在投影中校正宽高比。 非均匀的投影比例会把设备属性泄漏到
view以及其他所有后端中。 - 抖动。 误差扩散会给平坦的面添加纹理;有十个级别时,分面着色已经清晰可辨。
- 二进制或压缩文件格式。 文本使文件可检查、工具保持简单;如有需要,录像可以用通用工具很好地压缩。
边界
TUI 后端不会:
- 打印、清屏、读取终端尺寸或环境变量,或者计时;
- 输出 ANSI 转义码、颜色或 Unicode 方块字符;
- 把三角形裁剪到缓冲范围内、抗锯齿或抖动;
- 校验文件内容是否与声明的宽度一致;
- 允许调用者从包外通过
TuiRenderConfig更改字符表、背景或压缩系数(其字段是只读的);更底层的函数可以覆盖这些需求。