为什么我们用印记替换了三个荧光笔几何引擎

张高歌张高歌

真实的 Apple Pencil 数据暴露了合成测试遗漏的缺陷,让 Lulucat Notes 从三个轮廓引擎转向 MaLiang 印记模型。

更新于

Lulucat Notes 最初采用传统的荧光笔渲染器:围绕采样路径构建几何轮廓,再用半透明颜色填充一次。一次填充可以避免半透明线段重叠处出现深色带,也让连接、自交、端帽和短笔画都成为同一个几何问题。

三代自定义几何方案各自消除了一类缺陷,又暴露出另一类缺陷。当前渲染器使用基于 MaLiang 的柔和圆形印记。MaLiang 是 Harley-xk 开发的 MIT 许可开源绘图库。这种做法消除了一整类轮廓故障,但会让自交处变深,并使末端随压力变细。

在 iPad 上捕获的蓝色荧光笔手写内容,几笔的起点和终点都能看到细小的钩状端部。

原始设备截图,放大至 228%。几笔的两端都出现了细小的钩状端部。

真实手写产生了合成场景没有的钩状端部

我们的合成场景覆盖了直线、弧线、短促点击、回描、压力变化和急转弯,而且所有场景都能正常渲染。真实的中文手写仍然在许多笔画的起点和终点产生了约 1–3 个点长的钩状端部。

因此,我们在应用中加入保存按钮,记录了 29 个完整笔画。每个样本都保留了位置、半径、压力、方位角、仰角、时间戳和 UIKit 估计标志。一个离线程序解析了该 JSON,并编译了应用使用的同一组 Stroke.swiftStrokeRenderer.swiftHighlighterStrokeBuilder.swift 文件。输出结果与设备截图中的笔画逐一匹配,包括这些钩状端部。

离线渲染器只凭记录的输入就重现了缺陷,说明原因在样本和几何中,而不在显示行为中。我们因此可以读取进入渲染器的确切样本。

我们最初的假设将问题归咎于 Pencil 在落笔和抬笔时的估算方向。数据否定了这一点。在全部 29 个笔画中,前八个和后八个样本内的方位角变化最多为 0.09 弧度,仰角变化最多为 0.02 弧度,而且那台 iPad 上每个估计标志都是 false。方向保持稳定,而压力和笔画方向却发生了剧烈变化。

压力下限来自数据中的清晰间隔

每个记录笔画的最后一个样本压力都是 0.000。书写样本的压力为 0.01 及以上,而触笔离开表面后记录的样本压力为 0.002 及以下。这些空中样本通常还会持续 2–7 个点,移动 0.8–9.0 个点,其方向还可能偏离书写笔画约 90 度。

笔画 2 展示了这一机制。它的主体结束时方向处于 143 到 180 度之间。低压尾部随后转向 −90 到 −67 度,又移动了 8.5 个点。轮廓渲染器把最后一个空中点当作最终端点,于是朝那个点拉伸出一小段狭窄的几何形状。

测得的压力在书写和空中移动之间留下了接近一个数量级的间隔,因此我们把截断阈值放在这个间隔内:

private static let pressureFloor: CGFloat = 0.005

只移除两端的低压样本。笔画中间的低压样本仍然属于笔画的一部分。这条规则遵循测得的事件边界,不修改书写路径。

裁剪前的仪器化特写:绿色输入样本偏离笔画,而红色轮廓形成了一个狭窄的钩状端部。

裁剪前,渲染器将端帽锚定在空中样本上。绿色圆点是原始样本;红线是生成的轮廓。

裁剪后的仪器化特写:蓝色填充在最后的书写样本处结束,低压绿色圆点仍留在填充区域之外。

裁剪掉低于 0.005 的样本后,端帽在最后一个书写点处结束。当前的印记渲染器仍保留这个压力下限。

这个下限去除了长的空中钩状段。压力保持在 0.005 以上的短暂落笔拖动和抬笔弧线得以保留,因为它们是书写运动。在轮廓模型中处理这些运动,促成了接下来的几轮几何改造。

三个几何引擎只是让缺陷转移

第一代:一条轮廓,一次填充

第一代压感引擎采用如下流程:平滑中心线,计算左右偏移,将两者连接成一个多边形,再一次性填充该多边形。笔尖是一个由 Apple Pencil 方位角和仰角驱动的椭圆,因此其横截面会随书写方向变化。短促点击使用单独的形状,扁平端帽则使用小幅不对称来避免零绕数空洞。

一次填充解决了笔画内部的透明度累积问题,同时要求每个转弯处都有一个有效的多边形。落笔拖动会在端帽与主体之间形成楔形区域。回描可能产生白色新月形空洞。急转弯可能使内侧轮廓自交,并留下未填充的缺口。

第二代:修复靠近端部的轮廓

第二代引擎保留了轮廓,并加入局部修复:回描裁剪、早期估计属性阻尼、端点拉直、瞬时方向裁剪和小幅端帽延伸。每个补丁都修复了一个已经重现的失败案例。它们合在一起,让端点行为取决于多个阈值和窗口。

真实书写随后产生了超出这些窗口的情况。固定的 24 点拉直窗口会抹掉短笔画中有意保留的钩状端部。自适应窗口保留了这些钩状端部,但抬笔弧线仍可能变成可见的拐角。方向感知裁剪去除了更多瞬时运动,但代价是,它在一条记录笔画中丢弃了真正弯曲的起点约 2.5 个点。

每个补丁都修复了各自的目标案例,但底层模型仍然需要一个能在有噪声的弯曲输入下保持有效的硬轮廓。

第三代:带裁剪方形端帽的覆盖并集

第三代引擎不再构建单一轮廓。它把每个线段绘制成梯形,把每个内部样本绘制成圆盘,加入灰度遮罩,取它们的覆盖并集,然后只应用一次荧光笔颜色。回描新月、自交变深和零绕数空洞都消失了,因为渲染器不再依赖多边形绕数。

并集会让端部变圆,因此我们按照两个端点半平面裁剪覆盖区域来恢复方形端帽。这个裁剪修复了恒定压力下的直线,却引入了一个更严重的缺陷:弯曲笔画的主体可能合理地穿过端点半平面,而端帽裁剪会因此删掉笔画中段的一部分。实时输入时,端点方向持续变化,所以被裁剪的区域会移动,笔画也会闪烁。

修复端帽裁剪前的弯曲蓝色笔画,其上部沿对角线被截掉;绿色圆点显示完整的采样路径。

全局端帽约束切穿了弯曲笔画的主体。

加入局部弧长门控后的同一条弯曲蓝色笔画,绿色采样路径周围的两条分支都恢复了。

将端帽裁剪限制在距离端点不超过一个半宽的图元上,恢复了这条笔画。

弧长门控使端帽裁剪变为局部操作,并修复了所有记录的案例。一个极端的合成钩状场景仍会在低压尖端与裁剪端帽交汇处出现一个小白色缺口。第三代引擎再次用一种几何失败换来了另一种。

MaLiang 消除了硬边界

MaLiang 通过重复的纹理印记,而不是一个闭合轮廓来构建笔画。它的路径生成器使用二次 Bézier 线段,这些线段经过相邻样本的中点;它的线渲染器则沿这条路径以固定步长放置印记。印记的大小、旋转、颜色和不透明度都可以沿笔画变化。

柔和的印记没有独立的端帽、连接或绕数规则。方向的急剧变化仍然只是一串相互重叠的标记。回描无法形成空的多边形空洞,端点半平面也无法切掉笔画中段,因为这个模型不包含端点裁剪。

我们采用了这种模型,没有照搬代码。当前的 HighlighterStrokeBuilder 执行四项操作:

  1. 只移除压力低于 0.005 的端点样本。
  2. 使用中点二次 Bézier 线段平滑位置。
  3. 沿弧长逐点放置一个圆形印记。
  4. 根据归一化压力设置印记直径,并使用 source-over alpha 合成这些印记。

构建器从 686 行缩减到了 190 行。方位角、仰角、轮廓构建、并集遮罩、端点方向窗口、端帽延伸和端帽裁剪都从荧光笔路径中移除了。我们仍然记录 Pencil 方向,因为输入模型支持这些信息,而荧光笔本身不再读取这些字段。

MaLiang 的压力公式需要设备校准

MaLiang 使用等价于以下公式的方式设置印记大小:

这个公式假设 force 使用了 0 到 1 范围内有用的部分,但我们的设备数据并非如此。在约 3,400 个 Apple Pencil 样本中,压力中位数为 0.047,最大值为 0.178。直接将这些值代入公式,会让正常书写比标称笔刷尺寸细得多。

因此,我们围绕观测到的中位数进行归一化,再应用指数并限制结果范围:

现在,中位书写压力会映射到标称的 12 点直径。非常轻的触碰仍以标称尺寸的 35% 保持可见,而较大的压力会在 120% 处停止增大。

这种校准属于输入设备和笔刷行为,而不是 MaLiang。若不测量我们自己的压力分布,只照搬原始公式,虽然保留了架构,却会得到错误的笔刷。

每个印记的 alpha 来自已知的重叠数

MaLiang 使用经验表达式 alpha ÷ overlapping × 2.5 来补偿半透明印记的重叠。我们的笔刷具有固定的 12 点标称直径和 1 点印记步长,因此其中心线会接收到约 12 层。在我们的情况下,重叠数是已知的,可以求解补偿值,而不必估算。

对于目标笔画不透明度 个相同的 source-over 层,一个印记的不透明度 会产生

因此一个印记需要

时,每个印记使用约为 的不透明度。中心在反复合成后达到预期颜色,而边缘接收到的印记更少,并保留一到两点宽的羽化过渡。

最终柔和印记模型渲染出的各种蓝色荧光笔笔画,包括环、钩状端部、短笔画和自交。

最终渲染器重放记录的设备路径。钩状端部、对角线切口和白色空洞都不见了。

我们接受的两个代价

自交区域会变得更深。source-over 合成会计算每个印记,包括此前经过同一区域时放置的印记,而覆盖并集引擎避免了这一点。我们接受了更深的重叠,因为它是可预测的,并且能保持笔画形状不变。

端点会随压力变细。真实的抬笔会降低压力,从而减小印记直径,所以笔画会逐渐变细,而不是以完整宽度的方形端帽结束。在三个几何引擎中,我们一直把方形端帽视为必须具备的特性。放弃它也移除了导致上述几项失败的裁剪和端点机制,因此我们接受了渐细的端部。

因此,当前发布的模型比之前的模型做得更少。它保留测得的书写事件,在设备校准后响应压力,并且无需全局有效的轮廓,就能渲染弯曲和自交的路径。凿形笔尖、完全方形的端帽,以及原始样本之间的精确折线,都是我们放弃的特性。

这次分析显示,压力下限最容易直接迁移到其他项目。0.005 这个值来自真实输入中两个簇之间的间隔:书写压力为 0.01 及以上,空中跟踪压力为 0.002 及以下。同一批记录的笔画随后显示出每项几何修复在哪里失败,这为模型变更提供了依据,也确定了我们保留下来的两个代价。