backend/tui 设计

设计目标

TUI 后端只用字符在终端中显示前端的三角形。终端字符单元是粗糙的、非正方形的像素,在本后端中没有颜色,因此本包有三项任务:校正单元形状,使立方体看起来像立方体;把连续的亮度映射到少数几个字符上;逐单元解决遮挡。它应生成测试、文件和视频工具都能使用的纯字符串,并且不包含属于演示程序的 ANSI 转义码和终端 I/O。

数学背景

非正方形的单元

前端投影到由正方形单位组成的网格上:xx 的一个单位与 yy 的一个单位物理长度相同。典型的终端单元高度约为宽度的两倍。如果把投影的 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 宽高比渲染单元,因此两次变换在物理上相互抵消。

明暗量化

一个由 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 时才写入。画完所有三角形后,每个单元显示覆盖它的最近三角形的字符(近似平局时取最先绘制的),与绘制顺序无关。

两条渲染路径

直接路径(render_draw_list)为每个三角形选择一个字符并光栅化字符。亮度路径(draw_list_to_tui_luma 之后接 render_luma_buffer)先把亮度光栅化到前端的 LumaBuffer 中,之后再逐单元量化。对于单帧,两者在受光表面上给出相同的字符。它们有两点不同:

  • 亮度路径在任何处理之后才量化。在量化之前对 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 后端不需要校正。该系数是一个配置字段,因此本包能够支持单元形状不同的终端。

每个三角形一个字符

每个三角形只有一个平面亮度,因此直接路径只需选一次字符,然后光栅化字符。对平面着色而言,这是代价最小的正确选择。亮度路径则用于需要在量化前对亮度做运算的场合。

图案即函数

背景是单元位置和缓冲尺寸的函数,因此点阵、棋盘格、边框或渐变都只需一行代码,不需要存储图像。dotted_background 是默认值,因为它能在终端中显示帧的范围,而行尾空格在终端中是看不见的。

返回字符串,而不做终端 I/O

本包返回 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 以及其他所有后端中。
  • 抖动。 误差扩散会给平坦的面添加纹理;有十个级别时,分面着色已经清晰可辨。
  • 二进制或压缩文件格式。 文本使文件可检查、工具保持简单;如有需要,录像可以用通用工具很好地压缩。

边界

TUI 后端不会:

  • 打印、清屏、读取终端尺寸或环境变量,或者计时;
  • 输出 ANSI 转义码、颜色或 Unicode 方块字符;
  • 把三角形裁剪到缓冲范围内、抗锯齿或抖动;
  • 校验文件内容是否与声明的宽度一致;
  • 允许调用者从包外通过 TuiRenderConfig 更改字符表、背景或压缩系数(其字段是只读的);更底层的函数可以覆盖这些需求。