构建高性能数字墨水非常困难。Apple Pencil 通过手势识别器提供 UITouch 输入 - 目前为止都很简单 - 然而,来自 Pencil 的一些数据会在初始触摸输入之后到达。UITouch
的许多属性都是估计值,并且在初始 UITouch
发送后,会使用更高精度的值进行更新。为了减少输入中的感知延迟,Pencil 还提供预测的 UITouch
事件。
记录这些对先前触摸的异步更新并非易事,并且从这些更新后的触摸事件中有效率地重新计算贝塞尔路径会消耗宝贵的 CPU 周期。对于实时墨水,尽可能减少重新计算非常重要,同时尽可能快地响应 Pencil 传感器数据。
以下示例事件数据展示了 Pencil UITouch
事件的细微之处
回调 #1
(100, 100)
,力度为 0.2
回调 #2
(150, 100)
,力度为 0.3
(100, 105)
(160, 110)
,力度为 0.2
回调 #3
(180, 120)
,力度为 0.4
0.4
(155, 108)
,力度为 0.45
(180, 115)
,力度为 0.6
考虑到触摸更新和预测,输出的 TouchPath 将是
(100, 105)
,力度为 0.4
(155, 108)
,力度为 0.45
(180, 120)
,力度为 0.4
(180, 115)
,力度为 0.6
忽略 UIGestureRecognizer 的 coalescedTouches(for:)
和 predictedTouches(for:)
以及 touchesEstimatedPropertiesUpdated()
将导致不太准确的路径数据
(100, 100)
,力度为 0.2
(150, 100)
,力度为 0.3
(180, 120)
,力度为 0.4
请注意,预测的触摸已丢失,以及触摸 A 和 B 的位置和力度是如何变化的。当使用 Pencil 时,这些对触摸的位置和力度的更新会对笔迹的平滑度和准确性产生重大影响。
从 UITouches
幼稚地重新生成整个 UIBeizerPath
会大大减少 Pencil 可以发送到应用程序的事件数量。尽可能快地处理触摸事件非常重要,这样 Pencil 才能发送更多它本来会发送的事件。
此外,过滤和平滑输入点可以减少最终 UIBezierPath
中的元素数量,从而减少内存和存储(并且对于实时墨水的网络带宽非常重要)。幼稚地重新过滤和重新平滑整个笔画会花费太多的 CPU 并影响墨水的帧速率。
Inkable
简化了 UITouch
数据的收集方式,提供一个包含所有 UITouch
事件、更新和预测的单个回调。此事件流经过多个步骤处理,以生成平滑的 UIBezierPaths
,并尽可能减少重新计算。处理的每个步骤都会缓存其计算结果,以便仅重新计算由新事件更新的路径部分。
对于实时墨水,每一毫秒都很重要,并且这种高度缓存的流处理架构允许每次新的 UITouch
事件发生时进行最小的重新计算。
提供了一个示例应用程序,该应用程序设置了一个基本管道来将 UITouch
处理为 UIBezierPath
,包括原始事件数据的导入/导出,以及重放事件数据以查看笔画期间如何构建路径的能力。
下面的流程图描述了如何将 UITouch 事件处理成贝塞尔路径。该代码非常模块化,允许在算法的任何点进行轻松自定义。可以在将过程的任何步骤的输出发送到下一步之前对其进行过滤和修改。例如,请参阅 NaiveSavitzkyGolay
和其他 Polyline 过滤器。
由于 UITouch
信息的到达速度可能比手势识别器处理和回调触摸信息的速度更快,因此 UITouches
通过 UIGestureRecognizer
子类上的各种方法分批发送到手势识别器。Inkable
通过提供单个回调来处理整个批次的 UITouch
数据,从而简化了这些触摸事件的处理。此外,事件流然后通过 Streams
处理为点、折线,最后是贝塞尔曲线。
这种 Stream
架构允许在过程的每个步骤中缓存计算,因此无需在每次新的 UITouch
事件到达时重新计算整个 UIBezierPath
。相反,仅计算最小的工作量并更新缓存的路径,从而实现极其高效的 UIBezierPath
构建。
首先,创建一个 TouchEventStream
- 这将保存手势,该手势会将所有 UITouches
转换为 TouchEvents
以供管道的其余部分处理。然后,为您的事件构建处理管道。每个步骤都是可选的,具体取决于您要生成的路径类型。下面将生成平滑的 UIBezierPath
输出。
最后,确保将 TouchEventStream
手势识别器添加到您的 UIView
。来自手势识别器的所有事件都将自动由 TouchStream
处理,无需任何额外的工作。
// Create streams to process:
// `UITouch` -> `TouchEvent` -> `TouchPath` -> `Polyline` -> `UIBezierPath`
let touchEventStream = TouchEventStream()
let touchPathStream = TouchPathStream()
let lineStream = PolylineStream()
let bezierStream = BezierStream(smoother: AntigrainSmoother())
// setup each stream to consume the previous step's output
touchEventStream
.nextStep(touchPathStream)
.nextStep(lineStream)
.nextStep(bezierStream)
.nextStep({ (output) in
let beziers: [UIBezierPath] = output.paths
// use the bezier paths
let changes: [BezierStream.Delta] = output.deltas
// inspect how the paths changed since the last callback
})
// Finally, add the gesture to the UIView
myView.addGestureRecognizer(touchEventStream.gesture)
上面的管道将
UITouches
/更新/预测处理为可以编码/解码为/从 JSON 的 TouchEvents
TouchEvents
处理为 TouchPaths
TouchPaths
处理为 Polylines
Polylines
处理为 UIBezierPaths
UIBezierPaths
发送到管道末尾的消费者块任何新的触摸事件都将立即被整个管道处理,每个步骤仅完成所需的最小计算并尽可能依赖其缓存。
您可以将 block
消费者添加到任何步骤来检查其输出。每个 Stream 可以支持任意数量的消费者。
Inkable
streams 被设置为遵循生产者/消费者架构。您可以创建自定义的 Producer
、Consumer
或组合的 ProducerConsumer
streams。查看现有的 TouchPathStream
、PolylineStream
、BezierStream
作为示例。像 NaiveSavitzkyGolay
这样的过滤器设置类似于 Streams,并且只生成和消耗相同的类型。
UITouches 有几种类型
UITouch
信息通过 UIGestureRecognizers
到达,它提供有关新触摸、coalesced
触摸(也提供有关先前触摸的更新信息)和 predicted
触摸的信息。
TouchEventGestureRecognizer
为每个传入的 UITouch
创建 TouchEvent
对象。 这些可以序列化为 json,以便可以重放原始触摸数据。 这种序列化使得重现特定的墨水行为更加容易,因为用户可以导出他们的原始触摸数据,并且可以在开发中或在单元测试中加载和重放它。
TouchPathStream
处理所有 TouchEvents
并将它们分成 TouchPath
。 每个 TouchPath
代表 iPad 上的一个手指或 Pencil,并且与该手指关联的所有事件都收集到单个 TouchPath.Point
中。 此外,由于许多 UITouches 可能代表同一时刻(预测触摸、具有估计数据的实际触摸以及使用更准确的数据更新触摸),因此 TouchPath
还会将所有匹配的事件合并到单个 TouchPoints.Point
对象中。
TouchPath
还会跟踪触摸是否仍预期进行更新,要么是因为阶段尚未 .ended
,要么是因为现有的 .Point
仍在等待更准确的数据作为更新的事件到达。 如果仍有任何事件预期,则无论 phase
如何,isComplete
都将为 false
。
TouchPaths
是对象,并且保存对每个生成的 TouchPath.Point
的每个 UITouch
的引用。
PolylineStream
创建 Polyline
来包装 TouchPath
和 TouchPath.Point
在结构体中,以便它们可以在过滤器中按值处理。 这样,每个 Polyline Filters 都可以保存其输入的副本,并且任何修改后的数据都将与其他过滤器修改隔离。 这使得在过滤器内进行缓存比使用引用类型 TouchPath
更加简单。
Polyline
本质上只是 TouchPath
引用类型的值类型。
过滤器是一种通过任何修改来转换 PolylineStream.Output
的简便方法。 例如,Savitzky-Golay 过滤器将平滑这些点,从而修改 Polyline.Point
的位置属性。 Douglas-Peucker 过滤器将删除与其相邻点共线的点。
这些过滤器是一种在将原始 Polyline 流的密集 Polyline 输出平滑到贝塞尔路径之前对其进行简化的方法,从而生成具有更少元素的类似外观的贝塞尔路径。
BezierStream 将 PolylineStream
输出处理为 UIBezierPaths
。 此流采用一个 Smoother
作为输入,它会影响输入折线如何转换为贝塞尔路径曲线元素。 简单的 LineSmoother
将 Polyline
直接转换为完全由 lineTo
元素组成的 UIBezierPath
。 AntigrainSmoother
将 Polyline
转换为更平滑的 curveTo
元素。
这将使用力、速度或角度将单宽度描边路径贝塞尔曲线转换为可变宽度填充路径贝塞尔曲线,以通知笔画宽度。
功能的粗略路线图在 TODO.md 中跟踪。
Inkable 为您节省了时间吗? 成为 Github 赞助者 并请我喝杯咖啡 ☕️ 😄