backend/tui API

包 Luna-Flow/geometry3d/backend/tui 把前端的 DrawList 画成文本:带深度缓冲的字符帧缓冲、把亮度映射为字符的明暗字符表、背景图案、针对高终端字符单元的校正,以及用于单幅图像(.tuiimg)和动画序列(.tui3d)的纯文本文件格式。它可在所有目标上运行并生成字符串;如何打印由调用者决定。

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

TUI 设计解释了宽高比校正和量化;TUI 教程一步步渲染场景。

配置

DEFAULT_WIDTH, DEFAULT_HEIGHT

回退的输出尺寸:80 列 × 32 行。

pub const DEFAULT_WIDTH : Int = 80
pub const DEFAULT_HEIGHT : Int = 32

DEFAULT_TERMINAL_Y_SCALE

默认的纵向压缩系数 0.5,适用于高度约为宽度两倍的终端字符单元。

pub const DEFAULT_TERMINAL_Y_SCALE : Double = 0.5

DEFAULT_SHADE_RAMP

默认明暗字符表 " .:-=+*#%@",共十个字符,从最暗到最亮。

pub const DEFAULT_SHADE_RAMP : String = " .:-=+*#%@"

TuiRenderConfig

TuiRenderConfig 包含输出尺寸、纵向压缩系数、明暗字符表和背景图案。

pub struct TuiRenderConfig {
  width : Int
  height : Int
  terminal_y_scale : Double
  shade_ramp : String
  background_pattern : (Int, Int, Int, Int) -> Char
}

这些字段在包外是只读的;请通过下面的两个构造函数获得配置。若要使用其他字符表或背景,请直接使用 FrameBuffer、shade_char 和 draw_triangle_z,如教程所示。

TuiRenderConfig::default, TuiRenderConfig::sized

TuiRenderConfig::default() 为 80 × 32,使用默认压缩系数、默认字符表和 dotted_background;TuiRenderConfig::sized(w, h) 与之相同但使用给定尺寸,非正的维度会被替换为默认值。

pub fn TuiRenderConfig::default() -> Self
pub fn TuiRenderConfig::sized(Int, Int) -> Self

default 是普通的构造函数,而不是 Default trait 的实现。

渲染

render_draw_list

render_draw_list(list, config) 创建一个用背景图案填充的帧缓冲,并绘制列表中的每个三角形:先纵向压缩顶点,再按三角形的亮度选取字符,最后带深度测试进行光栅化。

pub fn render_draw_list(@frontend.DrawList, TuiRenderConfig) -> FrameBuffer

绘制列表应当已投影到与配置尺寸相同的视口中。亮度为 0 的三角形用字符表的第一个字符(默认是空格)绘制,因此未被照亮的面仍会遮挡其后的内容。

render_frame

render_frame(list, config) 等价于 render_draw_list(list, config).to_string()。

pub fn render_frame(@frontend.DrawList, TuiRenderConfig) -> String

render_scene

render_scene(scene, view, config) 在一次调用中运行 build_draw_list 和 render_draw_list。

pub fn render_scene(@frontend.Scene, @frontend.RenderView, TuiRenderConfig) -> FrameBuffer
test "render a scene" {
  let config = @tui.TuiRenderConfig::sized(20, 8)
  let scene = @frontend.Scene::single(
    @core.cube_mesh(1.0),
    @core.Transform3::rotation(0.5, 0.7, 0.0),
    @frontend.Light::default(),
  )
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.0),
    @view.PerspectiveProjection::new(@view.Viewport::new(20, 8), 10.0),
  )
  let frame = @tui.render_scene(scene, view, config).to_string()
  inspect(
    frame,
    content=(
      #|....................
      #|....................
      #|........===##.......
      #|.......===####......
      #|......====###.......
      #|..........  #.......
      #|....................
      #|....................
      #|
    ),
  )
}

draw_list_to_tui_luma

draw_list_to_tui_luma(list, config) 把列表光栅化到配置尺寸的前端 LumaBuffer 中,并应用同样的纵向压缩。可用它在转换为字符之前累积多次曝光。

pub fn draw_list_to_tui_luma(@frontend.DrawList, TuiRenderConfig) -> @frontend.LumaBuffer

render_luma_buffer

render_luma_buffer(buffer, config) 把亮度缓冲转换为帧缓冲:值大于 0.0 的每个像素得到对应其值的字符表字符并保留其深度;其他像素显示背景。

pub fn render_luma_buffer(@frontend.LumaBuffer, TuiRenderConfig) -> FrameBuffer

只使用缓冲与配置尺寸重叠的部分。

render_luma_frame

render_luma_frame(buffer, config) 等价于 render_luma_buffer(buffer, config).to_string()。

pub fn render_luma_frame(@frontend.LumaBuffer, TuiRenderConfig) -> String
test "luma path" {
  let config = @tui.TuiRenderConfig::sized(10, 4)
  let list = @frontend.DrawList::new()
  list.push_triangle(
    @frontend.DrawTriangle::new(
      @view.ProjectedVertex::new(0.0, 0.0, 1.0),
      @view.ProjectedVertex::new(10.0, 0.0, 1.0),
      @view.ProjectedVertex::new(0.0, 8.0, 1.0),
      1.0,
    ),
  )
  let luma = @tui.draw_list_to_tui_luma(list, config)
  inspect(
    @tui.render_luma_frame(luma, config),
    content=(
      #|..........
      #|@@@@@@@@@.
      #|@@@@@@....
      #|@@@@......
      #|
    ),
  )
}

帧缓冲

FAR_DEPTH

FAR_DEPTH 是背景单元的深度,103010^{30}。

pub const FAR_DEPTH : Double = 1.0e30

FrameBuffer

FrameBuffer 是按行主序存储的字符网格,每个单元带一个深度。

pub struct FrameBuffer {
  width : Int
  height : Int
  cells : Array[Char]
  depths : Array[Double]
}

FrameBuffer::new

FrameBuffer::new(width, height, pattern) 用 pattern(x, y, width, height) 填充每个单元 (x,y)(x, y),深度为 FAR_DEPTH。

pub fn FrameBuffer::new(Int, Int, (Int, Int, Int, Int) -> Char) -> Self

FrameBuffer::index

buffer.index(x, y) 返回 y * width + x,不做边界检查。

pub fn FrameBuffer::index(Self, Int, Int) -> Int

FrameBuffer::set_pixel_if_closer

buffer.set_pixel_if_closer(x, y, depth, ch) 在 depth + DEPTH_EPSILON 小于已存深度时写入 ch;缓冲之外的写入会被忽略。

pub fn FrameBuffer::set_pixel_if_closer(Self, Int, Int, Double, Char) -> Unit

FrameBuffer::to_string

buffer.to_string() 返回各行拼接的结果,每行后跟 '\n';结果有 height * (width + 1) 个字符。

pub fn FrameBuffer::to_string(Self) -> String

它是一个方法,而不是 Show 的实现。

test "frame buffer" {
  let buffer = @tui.FrameBuffer::new(4, 2, @tui.blank_background)
  buffer.set_pixel_if_closer(1, 0, 5.0, 'a')
  buffer.set_pixel_if_closer(1, 0, 7.0, 'b') // farther: ignored
  buffer.set_pixel_if_closer(9, 9, 1.0, 'c') // outside: ignored
  inspect(buffer.to_string(), content=" a  \n    \n")
}

背景

dotted_background, blank_background, checker_background

这些图案分别用 '.'、用 ' '、或在 x+yx + y 为偶数和奇数时交替用 '.' 与 ' ' 填充每个单元。

pub fn dotted_background(Int, Int, Int, Int) -> Char
pub fn blank_background(Int, Int, Int, Int) -> Char
pub fn checker_background(Int, Int, Int, Int) -> Char

图案就是任意形如 (x, y, width, height) -> Char 的函数,因此你可以自己编写。

test "backgrounds" {
  inspect(@tui.FrameBuffer::new(4, 2, @tui.checker_background).to_string(), content=". . \n . .\n")
  let border = fn(x : Int, y : Int, w : Int, h : Int) -> Char {
    if x == 0 || y == 0 || x == w - 1 || y == h - 1 { '#' } else { ' ' }
  }
  inspect(@tui.FrameBuffer::new(4, 3, border).to_string(), content="####\n#  #\n####\n")
}

光栅化辅助函数

apply_terminal_y_scale

apply_terminal_y_scale(p, height, k) 把投影后的顶点向中间行压缩:y′=H/2+(y−H/2) ky' = H/2 + (y - H/2)\,k;x 和 depth 不变。

pub fn apply_terminal_y_scale(@view.ProjectedVertex, Int, Double) -> @view.ProjectedVertex

clamp01

clamp01(v) 把一个数截断到 [0,1][0, 1]。

pub fn clamp01(Double) -> Double

shade_char

shade_char(ramp, intensity) 返回含 nn 个字符的字符表中下标为 round⁡(clamp01⁡(I)⋅(n−1))\operatorname{round}(\operatorname{clamp01}(I) \cdot (n - 1)) 的字符。

pub fn shade_char(String, Double) -> Char

字符表不能为空,且应由基本多文种平面中的字符组成(每个字符占一个 UTF-16 单元)。

edge_function

edge_function(ax, ay, bx, by, px, py) 返回 (px−ax)(by−ay)−(py−ay)(bx−ax)(p_x - a_x)(b_y - a_y) - (p_y - a_y)(b_x - a_x),即带符号的两倍面积,用于判断点 pp 位于直线 abab 的哪一侧。

pub fn edge_function(Double, Double, Double, Double, Double, Double) -> Double

draw_triangle_z

draw_triangle_z(buffer, p0, p1, p2, ch) 用字符 ch 把一个三角形光栅化到帧缓冲中,在单元中心采样,并用透视校正深度进行深度测试。

pub fn draw_triangle_z(FrameBuffer, @view.ProjectedVertex, @view.ProjectedVertex, @view.ProjectedVertex, Char) -> Unit

两种环绕方向都可接受;面积不超过 DEPTH_EPSILON 的三角形会被跳过。顶点按原样使用,不做纵向压缩。

test "rasterization helpers" {
  inspect(@tui.shade_char(@tui.DEFAULT_SHADE_RAMP, 0.0), content=" ")
  inspect(@tui.shade_char(@tui.DEFAULT_SHADE_RAMP, 0.5), content="+")
  inspect(@tui.shade_char("ab", 2.0), content="b")
  let p = @tui.apply_terminal_y_scale(@view.ProjectedVertex::new(3.0, 20.0, 1.0), 20, 0.5)
  inspect(p.y, content="15")
  inspect(@tui.edge_function(0.0, 0.0, 1.0, 0.0, 0.0, 1.0), content="-1")
  let buffer = @tui.FrameBuffer::new(4, 2, @tui.dotted_background)
  @tui.draw_triangle_z(
    buffer,
    @view.ProjectedVertex::new(0.0, 0.0, 1.0),
    @view.ProjectedVertex::new(4.0, 0.0, 1.0),
    @view.ProjectedVertex::new(0.0, 2.0, 1.0),
    '#',
  )
  inspect(buffer.to_string(), content="###.\n#...\n")
}

图像与序列

TuiImage

TuiImage 是一帧渲染结果及其尺寸。

pub struct TuiImage {
  width : Int
  height : Int
  content : String
}

TuiImage::new

TuiImage::new(width, height, content) 构造一幅图像,小于 1 的维度会被提升为 1。

pub fn TuiImage::new(Int, Int, String) -> Self

encode_tui_image, decode_tui_image

encode_tui_image(image) 写出 .tuiimg 文本格式;decode_tui_image(text) 把它读回。

pub fn encode_tui_image(TuiImage) -> String
pub fn decode_tui_image(String) -> TuiImage

该格式依次为:一行 GEOMETRY3D_TUI_IMAGE v1,width=W 和 height=H 两行,一行 ---image---,以及以换行结尾的内容。解码器最多读取 H 行内容并丢弃 '\r'。它从不失败:无法读取的数字回退为默认值,缺少魔数行时得到一幅空的 80 × 32 图像。

TuiFrame

TuiFrame 是序列中的一帧。

pub struct TuiFrame {
  content : String
}

TuiFrame::new

TuiFrame::new(content) 包装一帧。

pub fn TuiFrame::new(String) -> Self

TuiSequence

TuiSequence 是一段动画:尺寸、帧率和帧。

pub struct TuiSequence {
  width : Int
  height : Int
  fps : Int
  frames : Array[TuiFrame]
}

TuiSequence::new, TuiSequence::push_frame

TuiSequence::new(width, height, fps) 构造一个空序列,小于 1 的值会被提升为 1;sequence.push_frame(content) 原地追加一帧。

pub fn TuiSequence::new(Int, Int, Int) -> Self
pub fn TuiSequence::push_frame(Self, String) -> Unit

encode_tui_sequence, decode_tui_sequence

encode_tui_sequence(sequence) 写出 .tui3d 文本格式;decode_tui_sequence(text) 把它读回。

pub fn encode_tui_sequence(TuiSequence) -> String
pub fn decode_tui_sequence(String) -> TuiSequence

该格式依次为:一行 GEOMETRY3D_TUI_SEQUENCE v1,width=W、height=H、fps=F 和 frames=N 各一行,以及每帧一行 ---frame--- 后接该帧内容和换行。解码器把每个标记之后至多 H 行作为一帧,并忽略 frames= 计数。缺少魔数行时得到一个空的 80 × 32、30 fps 序列。

test "files" {
  let sequence = @tui.TuiSequence::new(3, 1, 12)
  sequence.push_frame("abc\n")
  sequence.push_frame("def\n")
  let text = @tui.encode_tui_sequence(sequence)
  inspect(
    text,
    content=(
      #|GEOMETRY3D_TUI_SEQUENCE v1
      #|width=3
      #|height=1
      #|fps=12
      #|frames=2
      #|---frame---
      #|abc
      #|---frame---
      #|def
      #|
    ),
  )
  let back = @tui.decode_tui_sequence(text)
  inspect(back.frames[1].content, content="def\n")
  let image = @tui.decode_tui_image(@tui.encode_tui_image(@tui.TuiImage::new(3, 1, "xyz")))
  inspect(image.content, content="xyz\n")
}