backend/canvas 设计
设计目标
Canvas 后端在浏览器中以像素分辨率、彩色并带精确遮挡地显示与终端后端相同的 DrawList。它应复用前端的软件深度缓冲,而不是再写一个光栅化器,并且要让每帧的 DOM 调用次数保持较少,因为每次从 MoonBit 调用 Canvas API 都要跨越 JavaScript 边界。
数学背景
从亮度到颜色
每个被覆盖的像素都有来自经过深度测试的 LumaBuffer 的亮度 。后端把它量化为 个明暗级别之一,并按该级别缩放前景色 :
与 TUI 字符表一样,亮度的量化误差至多为 ;在默认 时它低于 8 位通道的分辨率,因此结果在视觉上是连续的。缩放在存储的通道值上是线性的。显示器会应用 sRGB 传递曲线,发出的亮度大致按存储值的 2.2 次方增长,因此中间亮度看起来比物理线性渲染更暗。这与 TUI 字符表的观感一致,不做校正。
背景是单独的颜色,而不是明暗级别 0。 的被覆盖像素(背向光源的面)被画成纯黑,仍可与背景区分;决定是否被覆盖的是 LumaBuffer 的深度而不是值(深度低于 LUMA_FAR_DEPTH / 2)。
游程绘制
逐像素绘制需要每个像素一次 fillRect。后端改为扫描每一行,把明暗级别 相同的连续被覆盖像素归并为极大像素段:
把每个像素段画成一个高为 1 的矩形,得到的图像与逐个绘制每个被覆盖像素完全相同。各像素段互不相交,它们的并就是该行被覆盖像素的集合,并且像素段中的每个像素都得到其自身明暗级别的颜色 。矩形数等于像素段数,至多等于被覆盖像素数,通常要少得多,因为平面着色的面只有一个亮度并覆盖整段像素。只有当明暗级别与前一段不同时才设置填充样式,因此单一明暗的大面每行至多改变一次样式。
遮挡
可见性来自前端的深度缓冲,其不变量在 frontend 设计中推导:每个像素显示覆盖它的最近三角形,与绘制顺序无关。因此 Canvas 的输出逐像素精确,这与 GSAP SVG 后端的画家排序不同。
设计决策
复用前端光栅化器
Canvas 2D API 自己就能填充多边形,但它没有深度缓冲,因此遮挡需要排序,并且对相交的三角形会失败。光栅化到 LumaBuffer 能得到精确的遮挡以及与 TUI 后端相同的透视校正深度,代价是逐像素的工作要在 MoonBit 中完成。在 640 × 480 下,这对动画演示来说已经足够快。
一种颜色,多级明暗
按亮度缩放的单一前景色保持了前端的约定(每个三角形一个标量亮度),并以彩色再现了终端版本的单色观感。材质和按对象着色需要扩展 DrawTriangle。
用像素段代替 ImageData
写入 ImageData 缓冲并调用一次 putImageData 每帧只需一次 DOM 调用,但需要类型化数组的绑定以及在 JavaScript 一侧逐像素写入。游程式的 fillRect 调用只使用 rabbita/dom 暴露的稳定 2D 上下文 API,而且对像素段很长的平面着色场景开销很小。
每次调用都规范化
每次渲染调用内部都会重新规范化配置。无论以何种途径构造的配置(包括本包内部代码构造的),都不会让渲染器除以零或输出无效的 CSS 颜色。
正确性与不变量
- 配置区域中的每个像素都会被绘制:先绘背景,再用各自的明暗级别绘制被覆盖的像素。
- 按像素段绘制与逐个绘制被覆盖像素逐像素相同。
- 每个被覆盖的像素显示最近的三角形(前端深度缓冲的不变量)。
- 规范化之后,通道始终位于 ,
shade_levels始终位于 。
每帧的开销为前端光栅化,加上一次 的像素段扫描,再加上每个像素段一次 fillRect。
被否决的方案
- WebGL。 它会把光栅化移到 GPU 上,但需要着色器和缓冲区,并会重复前端已在 MoonBit 中实现的管线。
- 按深度排序的多边形填充。 调用更少,但画家排序对相交或循环重叠的三角形是错误的;SVG 后端接受这种取舍,Canvas 后端不接受。
- 伽马校正着色。 它会改变相对于终端输出的观感,而终端输出是本仓库的参考画面。
边界
Canvas 后端不会:
- 在
js以外的目标上运行,或在没有 DOM 的情况下工作; - 调度动画帧、读取输入或查找元素(这些由
demo_canvas包负责); - 支持按对象着色、纹理、透明、抗锯齿或 GPU 加速;
- 允许调用者从包外更改颜色或明暗级数:
CanvasRenderConfig的字段在包外是只读的,只有default和sized能构造它; - 进行任何宽高比校正(画布像素是正方形的)。