demo 设计

设计目标

终端演示表明,库中的各个包只需极少的粘合代码就能组合成一个可用的渲染器:一个场景、一个相机、build_draw_list 和一个后端。它还检验了库中测试无法凭肉眼判断的部分(透视、光照、阴影、滑动变焦、曝光效果),并生成可以转换为视频的录像。所有涉及操作系统的东西都放在这里:参数解析、环境变量、时钟、终端和文件。

数学背景

滑动变焦

使用科学相机时,沿观察轴距离为 dd、高度为 YY 的物体看上去有 s Y/ds\,Y/d 行高(在终端压缩之前),其中 s=Hf/hs = H f / h(见 view 设计)。要在相机移动时保持主体大小不变,需要

f(d)d=f0d0⟹f(d)=f0 dd0.\frac{f(d)}{d} = \frac{f_0}{d_0} \quad\Longrightarrow\quad f(d) = f_0 \, \frac{d}{d_0} .

各场景使用以下数值:

场景距离 dd基准 (d0,f0)(d_0, f_0)焦距范围
--hitchcock对第 kk 帧为 5.7+2.3sin⁡(0.035 k)5.7 + 2.3 \sin(0.035\,k),位于 [3.4,8][3.4, 8](4.5,18 mm)(4.5, 18\ \text{mm})13.6–32 mm
--dolly3.2+66.37⋅12(1+sin⁡(0.028 k))3.2 + 66.37 \cdot \tfrac12\big(1 + \sin(0.028\,k)\big),位于 [3.2,69.57][3.2, 69.57](3.2,23 mm)(3.2, 23\ \text{mm})23–500 mm

推拉场景的最远距离 69.565…=3.2⋅500/2369.565\ldots = 3.2 \cdot 500 / 23 是为了让焦距的峰值恰好为 500 mm。位于主体之后、距相机 d+Δd + \Delta 的物体,其缩放比例为

s/(d+Δ)s0/(d0+Δ)=d (d0+Δ)d0 (d+Δ)\frac{s/(d + \Delta)}{s_0/(d_0 + \Delta)} = \frac{d\,(d_0 + \Delta)}{d_0\,(d + \Delta)}

(相对于基准视图)。当 d→∞d \to \infty 时,该系数趋近于 (d0+Δ)/d0(d_0 + \Delta)/d_0:位于立方体后方约 Δ≈5\Delta \approx 5 个单位的圆盘墙会膨胀到其基准大小的约 2.62.6 倍,而立方体保持不变。长焦端的相机趋近于正交投影,这就是背景变扁平的原因。测试检查了主体投影大小的比例在各帧之间保持不变。

曝光

曝光模式用 N=12N = 12 个采样近似图像在快门区间内的时间积分(见 frontend 设计):

Lˉ(x)≈112∑j=011L(x,t−(11−j) δ),\bar L(x) \approx \frac{1}{12} \sum_{j=0}^{11} L\big(x, t - (11 - j)\,\delta\big),

其中 δ\delta 是一个动画步长(推拉场景为一帧,网格场景为一次旋转增量)。使用 --flow-exposure 时,每个采样 LjL_j 先通过搜索半径 4、块半径 1 的块匹配光流 dj=flow(Lj,Lcurrent)d_j = \text{flow}(L_j, L_{\text{current}}) 扭曲到当前帧上。程序会调用 ExposureSettings::auto,但它总是返回一个采样,因此采样数由演示程序自己的常量 12 决定。

设计决策

所有系统访问集中在一个包中

读取 COLUMNS 和 LINES、帧计时、用 ANSI 转义序列清屏以及通过 moonbitlang/x/fs 进行文件 I/O,是本仓库中仅有的不可移植操作,它们全部位于 src/demo/main.mbt 中。库中的各个包保持纯粹且与目标无关。演示程序留出一行给 shell 提示符,使打印整帧时不会滚屏。

小而宽容的命令行

选项都是按完全匹配识别的普通标志,具有固定的优先级和静默的回退。这使解析器只有寥寥几行,也让演示程序很难因命令行而崩溃,代价是不会报告拼写错误。出于同样的原因,数值选项只保留其中的数字。

忙等待的帧节奏控制

实时循环把 @env.now() 与上一帧的时间比较,并空转直到过去 33 ms。这不需要可移植标准库所缺少的休眠原语,并能提供稳定的节奏。代价是演示运行期间会占满一个 CPU 核心。

流式录制

--record 在内存中构建一个 TuiSequence 并在最后编码,这很简单,也检验了库中的编码器。--record-stdout 逐帧打印同样的格式,因此录制成千上万个大帧也只需一帧的内存;由于帧数可以从时间轴得知,格式中的 frames= 头可以先写出。

正确性与不变量

  • 推拉场景的主体保持其投影大小:f/df/d 按构造为常数(已测试)。
  • 每一帧都恰好具有配置的宽度和高度,因此录像能按其文件头中的尺寸解码。
  • 录像的帧数为 Timeline::frame_count() = ⌈duration⋅fps⌉\lceil \mathit{duration} \cdot \mathit{fps} \rceil。
  • 曝光权重之和为 1,因此曝光帧保持在其各采样的亮度范围之内。

被否决的方案

  • 参数解析库 会为十几个标志引入一个依赖。
  • 原始终端模式和增量重绘 能减少闪烁,但需要特定于平台的终端控制。
  • 在帧之间休眠 需要在每个目标上使用 FFI。

边界

演示程序不会:

  • 导出任何 MoonBit API,或在 native 以外的目标上运行;
  • 运行时读取输入、随终端调整尺寸或提供配置文件;
  • 校验选项或报告未知选项;
  • 以彩色或在浏览器中渲染(见 demo_canvas 和 demo_gsap)。