为粉笔工具进行调优的工程实践

张高歌张高歌

Lulucat Notes 的粉笔工具在密集的手写区域变慢了。瓶颈不是 3,571 个输入采样点,而是每帧 70 次全屏暂存纹理绘制。一种被否决的低分辨率缓存,以及按笔画限制的 scissor 矩形,构成了后续的故事。

Lulucat Notes 在 iPad 上 255% 缩放的裁剪画面,显示红蓝粉笔书写的一段中国古典文字,应用工具栏的一部分也在画面中。

最终设备构建中的红蓝粉笔,255% 缩放下的 155 道笔画。

Lulucat Notes 的粉笔工具遇到了一种很具体的性能问题:在空白区域书写时感觉流畅,但笔尖进入已经填满粉笔笔画的区域后,就会落后于笔的移动。继续在同一区域书写,还会逐渐拖慢画布平移。

一页普通的手写内容就足以触发这个问题:300% 缩放,本地范围内有 70 道可见粉笔笔画,共计 3,571 个输入采样点。空白区域仍然流畅;只有这些笔画集中的区域变慢了。

修复后,同一页面可以在 255% 缩放下继续接收新的书写,而已有笔画在落笔和进行平移时都保持完整清晰度。

一台 iPad 上的 Lulucat Notes,缩放至 255%,显示红蓝粉笔手写内容。文字是“天行健,君子以自强不息;地势坤,君子以厚德载物”——一段中国古典文字。右上角有一只蓝色的 Lulucat 吉祥物。底部工具栏显示笔画计数 155、Save、Clear,以及 255% 的缩放滑杆。

最终设备截图,共 155 道笔画。在这个缩放级别下,无论落笔还是平移,已有笔画都不会临时切换清晰度。

为什么粉笔需要暂存纹理

普通的笔可以用 source-over 混合,把每个圆形印记直接合成到墨迹纹理上。粉笔多了一层颗粒门控:渲染器先为整道笔画累积笔身覆盖率和深度,然后用固定的颗粒纹理决定哪些位置接收粉尘,最后再把结果合成到已有墨迹上。

这张暂存纹理会隔离单独的一道粉笔笔画。隔离很重要,因为同一道笔画中的印记会大量重叠;如果每个印记都单独经过颗粒门控,笔画中心线就会累积重复颜色,粉笔孔隙也会随采样密度变化。

在高倍率缩放下,Lulucat Notes 会重绘当前视口中可见的矢量笔画。旧实现会对每一道可见粉笔笔画执行以下步骤:

  1. 结束主渲染编码器;
  2. 清空暂存纹理;
  3. 把这一道粉笔笔画绘制到暂存纹理中;
  4. 重新打开主渲染编码器;
  5. 用一个全屏三角形把暂存纹理合成回 drawable。

这些语义对单道笔画来说是正确的,但工作范围远远过大。iPad 的 drawable 是 2732×2048,约 5.6 million pixels。每道粉笔笔画都会触发一次暂存绘制和一次全屏合成。70 道粉笔笔画大约意味着 141 个渲染编码器和 70 次全屏合成。

令可见粉笔笔画的数量为 ,drawable 的像素数量为 。只考虑随像素覆盖范围增长的工作,旧实现接近

每道粉笔笔画还带有固定的渲染通道开销,因此这项成本也随 线性增长。3,571 个输入点只贡献了次要成本。随局部笔画数量增长的是每道笔画触发的全屏工作范围。

测量使用的是一台运行 iPadOS 18.6.2 的 12.9-inch iPad Pro(5th generation,M1)。我们在同一视口中比较改动前后的 GPU 时间戳,使用的是这台特定 iPad 上同一个 Debug 设备构建中的 command-buffer 时间戳,下面称它为 LucasPad。下面的范围是多帧日志中的常见波动,不是正式版本的帧率承诺。在 70 道可见粉笔笔画下,单帧通常需要 52–60 ms 的 GPU 时间;在约 120 道笔画的区域,GPU 时间会上升到 77–80 ms。

按照暂存和合成通道的全屏矩形面积估算,每帧的理论工作范围从约 783 million pixels 增长到 1.34 billion pixels。这个数字是矩形面积之和,不等同于片元调用次数、显存读写字节数或 GPU 硬件计数器。Metal 的 fast clear、attachment load/store 和通道切换仍由 GPU 与驱动控制。

这也解释了为什么空白区域保持流畅。可见性剔除会跳过视口之外的笔画;空白区域里的 接近零,而密集区域里的 会持续增加。

0.85 ms 的错误答案

应用原本已经有一张按每个点 2 个像素烘焙的整页墨迹纹理。我们尝试在书写、平移和缩放期间直接显示这张纹理,只保留当前的 Apple Pencil 笔画作为实时矢量;交互结束后,再用一帧额外重绘高分辨率矢量结果。

这个方案的性能非常好。在 300% 缩放的同一密集区域中,GPU 时间降到了 0.84–0.85 ms,并且不再随已有粉笔笔画的数量增加。

但在真实设备上,问题同样清楚。在 300% 缩放下,每个点大约需要 6 个屏幕像素,而缓存只提供了 2 个。Apple Pencil 一落笔,所有已有笔画就会变成柔软的低分辨率图像;抬起 Pencil 后,它们才恢复为完整清晰度。

测试者说:“写字的时候,整个画布都变得模糊了。放手之后就又清晰了。”

我们移除了这个优化。0.85 ms 是测得的最低结果,但它不是一个可以接受的粉笔工具。已有笔画是书写反馈的一部分;落笔时不能改变它们的清晰度。

把每道粉笔笔画限制在自己的矩形内

最终修复保留了按笔画设置的暂存与按笔画合成,只缩小它们的像素工作范围。每道笔画本来就有一个画布包围盒,它由所有印记半径的并集得到。渲染器会把这个包围盒变换到当前视口的 drawable 坐标中,并为抗锯齿边缘留出 2 个像素的边距:

同一个 scissor 矩形随后会用于三件事:清空暂存纹理、绘制笔画,以及把结果合成回主表面。

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

同样的逻辑也用于在 4096² 墨迹纹理上进行烘焙和局部重放,因此高倍率显示与稳定后的墨迹层不会产生两种不同的粉笔行为。

这里有两个容易忽略的细节。

第一,渲染通道的 loadAction = .clear 发生在附件加载阶段,不受光栅化 scissor 的限制。继续使用它仍然会清空整张暂存纹理。固定后的通道使用 .dontCare,然后在 scissor 内绘制一个 clear_fragment。这个矩形随后会被完整写入,合成也只读取同一个矩形,因此不需要加载旧的附件内容。

第二,每道粉笔笔画的合成完成后,都必须恢复外层 scissor。如果省略这行状态恢复,后续的笔、图片或选区就会继续受到上一道粉笔笔画边界的裁剪,表现为笔画缺失或图片缺失。

粉笔颗粒仍然从绝对画布坐标采样,而不是从矩形内的局部 UV 采样。移动 scissor 只会改变 GPU 处理哪些像素,不会改变每个像素读取的颗粒纹理位置。因此相邻矩形之间不会产生纹理接缝,拖动画布也不会让颗粒漂移。

只考虑像素工作量时,新的工作范围接近

其中 是第 道粉笔笔画在当前屏幕上的轴对齐包围盒面积。渲染编码器的数量没有减少,但现在每次清空和合成都被限制在笔画的屏幕包围盒内。

为什么没有批量合成同色粉笔笔画

页面上的大多数粉笔笔画共享相同的颜色和密度,很容易让人想把几十道笔画一次绘制到暂存纹理中,再只合成一次。这样可以进一步减少渲染通道,但会改变重叠区域的颜色和颗粒语义。

考虑一个刻意简化的情况:两道笔画在某个像素处恰好共享同一个颗粒门控值 ,笔身覆盖率分别为 。实际着色器中的门控值还取决于每道笔画的压力深度;这个简化情形已经足以说明批量处理通常不等价。当前的逐笔画合成会产生

而先合并笔身、再应用一次门控会产生

两者的差异是 。只要两道笔画发生重叠,并且颗粒门控既不是纯零也不是纯一,结果就会不同。直接批量处理会改变粉尘落在交叉处的方式。

要进行精确批处理,必须证明笔画像素彼此不相交,或者为每道笔画分配独立的图集区域,并按原始顺序合成。最终设备验收保留了 scissor 方案,因此这一轮没有引入图集或其管理复杂度。

从十亿像素回到几百万像素

最终设备测试的测量结果:

场景修复前精确 scissor
70 道可见粉笔笔画,300% 缩放,书写GPU 52–60 ms≈ 9–10 ms
≈ 121 道可见粉笔笔画,300% 缩放GPU 77–80 ms13.7–15.6 ms
每帧的理论矩形范围(暂存 + 合成)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

我们还使用一份设备文稿中的 3,452 道笔画和 202,710 个采样点验证了裁剪边界。在 0.5×、1×、2×、3×、5× 和 8× 缩放下,共生成了 186,408 个视口案例;所有可能产生非零覆盖的点精灵都落在计算出的 scissor 内。这项检查覆盖了画布边缘、视口边缘和各种偏移组合。

测试中的代码不会根据交互状态切换到低分辨率 LOD。低倍率仍显示整页墨迹纹理;高倍率仍将可见笔画作为矢量重绘。在阈值的同一侧,落笔和平移都不会用另一种清晰度替换已有笔画。在高倍率矢量重绘期间,每道粉笔笔画的暂存清空和合成都只覆盖自己的屏幕包围盒。

GPU 时间没有捕捉到清晰度

GPU 性能问题不一定会随着数据结构中最显眼的数量增长。在这个案例里,3,571 个输入点很容易成为怀疑对象;真正决定帧时间的,是 70 道粉笔笔画各自触发的全屏工作,以及随之而来的渲染通道切换。

视觉语义也限制了可用的优化。按笔画设置的暂存、原始合成顺序,以及绝对画布颗粒坐标,都不能随意移除。同色同密度只说明参数相同,并不能证明重叠结果可以合并。

真实设备反馈否决了 GPU 时间最低的版本。“整个画布都变得模糊了”这句话补上了单靠测量无法表达的产品约束:Apple Pencil 落笔时,用户也在观察已有笔画。

验收实现没有引入新的交互缓存层,也没有降低清晰度。高倍率矢量重绘只是把每道粉笔笔画的工作限制在自己的屏幕包围盒内。再次将测试构建装到 LucasPad 后,反馈变成了:“效果很不错。”