backend/gsap 设计

设计目标

GSAP 后端把 DrawList 显示为与分辨率无关的矢量图形,并让成熟的动画库 GSAP 负责播放:播放、暂停、倒放、跳转、调速和循环。场景数学留在 MoonBit 中;JavaScript 只保存多边形并运行时钟。由于 SVG 没有深度缓冲,后端也没有,因此必须通过绘制顺序决定可见性。本页说明这种顺序何时是精确的。

数学背景

画家算法

SVG 按文档顺序绘制其子节点,每个都覆盖之前绘制的内容。如果三角形按由远到近的顺序输出,较近的表面就会覆盖较远的表面。后端用每个三角形在相机空间中的平均深度作为排序键,

zˉ(T)=13(z0+z1+z2),\bar z(T) = \tfrac13 (z_0 + z_1 + z_2),

按降序排序,并列时按其在绘制列表中的位置决定,因此结果是确定的。

何时顺序是精确的。 屏幕投影不重叠的两个三角形可以按任意顺序绘制。对于重叠的三角形,只要较近的那个后画,顺序就是正确的。一个充分条件是它们的深度范围互不相交:若 max⁡izi(A)<min⁡izi(B)\max_i z_i(A) < \min_i z_i(B),则 zˉ(A)<zˉ(B)\bar z(A) < \bar z(B),于是 BB 先画,而处处更近的 AA 覆盖它。

单个凸物体总是精确的。 设网格围成一个凸体,并且前端已去掉背面。从眼睛穿过某个像素的射线至多与凸体的边界交于两点:从一个正面进入,从一个背面离开。因此每条射线上至多有一个正面,不同正面的投影至多沿公共边重叠。于是任何绘制顺序都是正确的,排序后的顺序当然也是。立方体、球、圆柱、圆锥和棱锥都是凸的。

何时会失败。 对于非凸网格(圆环)和由多个物体组成的场景,重叠的三角形可能有重叠的深度范围。此时平均深度只是一种启发式方法,而且有两种配置会让任何以整个三角形为单位的排序失败:

  • 循环重叠:AA 覆盖 BB 的一部分,BB 覆盖 CC 的一部分,CC 又覆盖 AA 的一部分;
  • 相互穿插:两个三角形相交,因此在相交线的两侧各自位于对方前面。

要得到正确结果,需要拆分三角形(Newell 算法)或使用深度缓冲。Canvas 后端有深度缓冲,在这些情况下是精确的。11 M. E. Newell, R. G. Newell and T. L. Sancha, “A solution to the hidden surface problem”, Proc. ACM National Conference, 1972.

着色

填充色使用与 Canvas 后端相同的量化:q=round⁡(clamp⁡(I) (L−1))q = \operatorname{round}(\operatorname{clamp}(I)\,(L - 1)),颜色为 round⁡(c q/(L−1))\operatorname{round}(\mathbf{c}\, q / (L - 1)),亮度误差至多为 1/(2(L−1))1/(2(L - 1))。

时间是唯一的输入

GsapPlayer 以线性缓动把一个时钟对象从 00 补间到 DD 秒。对于时间轴位置 τ\tau(以 GSAP 自己的时间计,已乘以速度因子),回调在 τ∈[0,D]\tau \in [0, D] 时收到 t=τt = \tau。MoonBit 一侧把场景渲染为 tt 的纯函数:frame(t)=render(scene(t))\text{frame}(t) = \text{render}(\text{scene}(t))。因此跳转、倒放、循环和调速都不需要额外状态:无论 GSAP 报告哪个 tt,画出的都是该 tt 对应的画面。更新以浏览器的刷新率到达,而不是固定帧率,因此后端从不假设帧间隔。

设计决策

采用画家排序的矢量输出

问题:由三角形生成清晰、可缩放、浏览器可以保留并重新设定样式的输出。SVG 多边形正好如此,但 SVG 没有深度缓冲。可选方案有:光栅化成图像(失去矢量的优点)、拆分三角形以获得精确顺序(一般情况下复杂且慢),或对整个三角形排序。最终选择了排序:它对本库的凸基本形状是精确的,对典型场景接近正确,而且只需 O(nlog⁡n)O(n \log n) 的开销。

以平均深度为键,平局保持稳定

平均深度计算便宜,且对顶点对称。按原始下标打破平局使输出确定,因此测试可以断言精确的多边形顺序,静态场景的各帧也不会闪烁。

复用 DOM 节点

每次调用都复用已有的 <polygon> 元素,只增加或删除数量上的差额。每帧创建数千个元素会主导开销并给垃圾回收器带来压力;而每个多边形更新两个属性则不会。标记 data-gsap-svg-background 和 data-gsap-svg-triangles 让后端能找到自己的节点;对于尚无这些节点的 <svg>,后端会替换其子节点。

由 GSAP 掌管时钟

播放控制在 GSAP 中已是成熟的问题。后端只暴露一个最小且经过截断的子集(seek 限制在时长内,set_progress 限制在 [0,1][0, 1],正的速度因子,重复次数 ≥−1\ge -1),使 MoonBit 调用者无法把时间轴驱动到演示界面未预料的状态。GSAP 在构造时从 globalThis.gsap 查找,而不是导入,因此由页面决定如何加载它(CDN、打包器或本地副本)。

正确性与不变量

  • render_draw_list 之后,三角形分组中多边形的数量恰好等于绘制列表中三角形的数量,并按平均深度降序、平局稳定地排列。
  • 只要重叠的三角形深度范围互不相交,画面就是精确的,特别是对背面剔除后的任何单个凸网格。
  • 填充色是有效的 rgb(r, g, b) 字符串,通道位于 [0,255][0, 255]。
  • 对于通过其方法传入的值,GsapPlayer 保证 seek∈[0,D]\text{seek} \in [0, D]、progress∈[0,1]\text{progress} \in [0, 1]、速度因子 >0> 0、重复次数 ≥−1\ge -1。

每帧开销:前端管线、对 nn 个三角形的 O(nlog⁡n)O(n \log n) 排序,以及 O(n)O(n) 次属性更新。

被否决的方案

  • 在 SVG 中逐像素保存深度(例如每个像素段一个多边形)会放弃 SVG 的所有长处。
  • BSP 树或 Newell 算法。 两者都能给出精确顺序,但会拆分三角形,并使多边形数量逐帧变化;对演示场景而言,平均深度排序的可见错误既少见又短暂。
  • 从 MoonBit 驱动 requestAnimationFrame。 这会重新实现 GSAP 已经提供的播放、暂停、倒放和跳转。

边界

GSAP 后端不会:

  • 在 js 以外的目标上运行,或在没有 DOM 和全局 gsap 的情况下工作;
  • 解决循环重叠或相交的三角形;
  • 添加描边来掩盖相邻多边形之间可能出现的细微抗锯齿接缝;
  • 构建播放器界面、加载 GSAP,或决定在某个时刻渲染什么(这些由 demo_gsap 包负责);
  • 允许调用者从包外更改颜色或明暗级数(配置字段在包外是只读的)。

Footnotes

  1. M. E. Newell, R. G. Newell and T. L. Sancha, “A solution to the hidden surface problem”, Proc. ACM National Conference, 1972. ↩