学习目标:学完这篇,你会给"图层越积越多"的渲染器做一次增量架构改造——每笔独立光栅成 stamp、source-over 合成进持久 base 缓冲,让单笔提交成本与总笔数解耦,外加一条"增量结果必须与全量像素等价"的回归纪律。这套方法能直接搬到任何 Canvas 2D / 自研光栅管线里。
0. 先说问题:20 笔之后,画布开始"思考人生"
boundless 是我的 Rust 手绘白板。早期版本有个经典毛病:刚打开画几笔,丝般顺滑;画到 20 笔开外,每落一笔,画布都要卡一下——实测 612×428 的画布,20 笔宽水彩累计光栅 3053ms,实时帧直接掉到约 3 秒一帧。用户画到后面,笔都抬起来了,墨还没晕开。
根因一句话:全量重光栅。每提交一笔,把画布上所有 N 笔从头到尾重新光栅一遍。第 N 笔的成本是 O(N),N 笔累计就是 O(N²)。笔越多,每笔越贵——"越画越卡"不是玄学,是复杂度。
2026-09-10,我用一个 commit(d2cb513,“stamp 增量光栅 + 跨边界一笔连续”)把这套架构换掉了。改造后:20 笔宽水彩累计 144ms(原来 3053ms+),实时帧 11ms(原来约 3s/帧)。这篇教程把改造的四步完整拆给你。
1. 核心思想:成本与 N 解耦,每笔只花"自己的 footprint"
翻译成人话:以前是"每来一笔,全城重盖";现在是"每来一笔,只盖自己那块砖"。
具体架构三件套:
- 持久 base 缓冲(
CanvasCache):一块长期存在的位图,存"已落定"的全部笔迹。 - stamp:每笔独立光栅成一张小位图(BGRA),只覆盖这笔自己的像素足迹。
- composite:把 stamp 用 source-over 合成进 base。
提交第 N 笔时,只光栅这一笔、合成这一笔——成本是 O(这笔的像素数),与 N 无关。N 笔累计就是 O(N),越画越卡的问题从复杂度层面根除。
2. 第一步:算每笔的 stamp box——足迹 + 边距
stamp 不是整画布那么大,而是这笔的"像素足迹"。stroke_stamp_box 做这件事:取这笔所有采样点的包围盒,再向外扩 margin——水彩笔有光晕(halo 约 1.5 倍、层晕 1.15 倍),还要留抗锯齿边距和笔尖游移量,最后裁剪到画布缓冲边界内。
这一步为什么关键?stamp 越小,合成越便宜;但绝不能小到把笔迹的光晕裁掉。足迹算小了,边缘会出现生硬的切断线——这是增量架构最常见的翻车点。所以 margin 宁可保守:halo、层晕、AA 边距,一项都不能省。
3. 第二步:独立光栅成 stamp
raster_stamp 把单笔光栅成一张独立的 BGRA 小位图(straight alpha)。注意它是完全独立的:不依赖 base 里的内容,不读回任何像素,纯函数式地把"这笔长什么样"算出来。
独立性带来两个好处:第一,可测试——单笔的光栅结果可以单独断言;第二,可并行——理论上多笔 stamp 可以并发光栅(boundless 目前是串行提交,但架构上没锁死这条路)。
4. 第三步:source-over 合成进 base
composite 把 stamp 按 source-over 合成到 base 缓冲的对应位置。source-over 就是"新像素盖在旧像素上,按 alpha 混合"——水彩的叠色、半透明,都靠这一步自然实现。
这里有个真实修过的 bug:合成时有个"跳过阈值",本意是跳过完全透明的像素省点事,结果把 alpha=1/255 的边缘贡献也吃掉了——笔迹边缘最淡的那一圈凭空消失。教训:性能优化里的"跳过"逻辑,必须先证明跳过的是真零,1/255 不是零。
5. 第四步:实时帧 = base 副本 + 尾笔 stamp
正在画的那一笔(湿笔/尾笔)还没"落定",不能进 base。实时帧的画法是:base 复制一份,把尾笔的 stamp 合成上去——11ms 一帧,就是这么来的。笔落定(抬笔)后,尾笔 stamp 正式合进 base,base 更新,下一笔继续。
翻译成人话:base 是"已出版",尾笔是"草稿",屏幕上看到的是"出版 + 草稿"的叠加。草稿随时可改,出版只增不改——这就是增量架构的心智模型。
6. 等价性纪律:增量结果必须与全量像素等价
增量架构最大的风险是"慢性跑偏":每次合成差一点点,100 笔之后画面悄悄变了样,肉眼还不一定看得出来。所以必须有一条铁律:增量合成的结果,要与全量重光栅逐像素等价。
boundless 用一个回归测试守住这条线:同一组笔迹,分别走增量路径和全量路径光栅,逐字节对比。为什么不是"完全相等"?因为 stamp 中间经过了一次 8 位量化(每个通道多一次 /255 取整),±2/255 是量化噪声的上界,肉眼不可见。所以断言是 worst <= 2,超了就打印第一个差异像素的坐标和通道值,方便定位。
这条测试的价值在于:以后任何人改光栅代码,跑一下就知道增量路径有没有跑偏。没有这条测试,增量架构就是一颗定时炸弹。
7. 什么时候该做增量,什么时候不该
最后给一个判断标准,帮你决定自己的渲染器要不要上这套:
- 笔迹/图层数量会随使用增长(白板、画布、标注工具)→ 上增量,全量重光栅的 O(N²) 必然崩。
- 内容基本静态、一次性渲染(海报导出、缩略图)→ 别折腾,全量一次算完最简单。
- 单笔本身极重(比如超大笔刷)→ 增量也救不了,先优化单笔光栅(boundless 是把
for_each_near_path从逐点扫描换成 x 区间扫描,137ns/px → ~40ns/px,单宽笔 108ms → 54ms,单笔和增量是两笔账)。
8. 总结
stamp 增量光栅四步:算足迹(stamp box)→ 独立光栅(stamp)→ 合成进 base(composite)→ 实时帧 = base 副本 + 尾笔,外加一条"增量与全量像素等价(±2/255)"的回归纪律。20 笔从 3053ms 到 144ms,靠的不是奇技淫巧,是把 O(N²) 换成 O(N)。
给开发者的建议:
- 先给你的渲染器加个"全量 vs 增量"的像素对比测试,再动手改架构——没有这条测试,增量就是慢性跑偏。
- stamp box 的 margin 宁可保守:halo、层晕、抗锯齿边距一项不能省,裁掉光晕的代价比多算几个像素贵得多。
- "跳过"类优化先证明跳过的是真零:1/255 的 alpha 也是贡献,吃掉它边缘会凭空消失一圈。
最后说一句:性能优化里最值钱的不是某行代码快了多少,而是把成本的增长阶数降下来。O(N²) 到 O(N),20 笔差 21 倍,200 笔差 200 倍——这才是"越画越卡"根除的真正原因。
项目开源在 GitHub:tsingliuwin/boundless,觉得有用欢迎点个 star。
本文介绍一种基于stamp的增量光栅渲染架构,通过将每笔独立光栅为仅覆盖其像素足迹的小位图(stamp),再以source-over方式合成至持久base缓冲,使单笔渲染成本与总笔数解耦,将时间复杂度从O(N²)降至O(N)。该方法适用于Canvas 2D或自研光栅管线,配合像素级等价性回归测试保障渲染正确性。
245

被折叠的 条评论
为什么被折叠?



