Canvas desynchronized 画布位图读回不可靠导致墨迹整笔丢失 (Canvas desynchronized Read-Back Loses Committed Ink)
触发条件 (Occurrence Conditions)
- 架构:双层 Canvas 手写板。
activeCanvas承载「正在写的那一笔」并对其调用getContext('2d', { desynchronized: true })以压低触控笔延迟;mainCanvas承载已提交的历史笔迹。 - 提交方式:提笔时用 位图搬运 收尾——
mainCtx.drawImage(activeCanvas, 0, 0)把活跃层 1:1 拷到主层,再clearRect清空活跃层。这是为了让「抬笔瞬间」的笔画与书写预览逐像素一致,避免矢量重放带来的抬笔跳变/变细。 - 运行环境:真实浏览器 + GPU 合成(Chrome / Edge 桌面端,
deviceScaleFactor任意)。至少桌面 Chrome 实测恒定复现。
故障现象 (Symptoms)
- 整笔凭空消失:书写过程墨迹正常显示,抬笔瞬间笔画连同活跃层一起消失,主层没有任何墨迹落地。现象上就是「写完了但字没上去 / 笔记不会下来」。
- 与输入设备强相关,看起来像设备问题:快速划动的鼠标、低延迟/高采样率笔、事件与
pointerup挤在同一帧内的写法更容易中招,慢速、事件跨度大的书写更容易侥幸成功。于是很容易被误判为「某某笔不兼容」「人家人品问题」。 drawImage与getImageData不报错:读回得到的是全透明图像而非异常。活跃层在clearRect前肉眼可见有墨,拷完却什么都没有,因此从日志与返回值上都看不出问题。- 迷惑性极强的负结果:无头 Chromium(Playwright / Puppeteer 默认)会忽略
desynchronized选项,所有自动化用例全绿;只有真实 Chrome 才复现。这会让「自动化测试通过」和「用户说丢笔」长期并存。
尝试方案与局限 (Attempted Solutions & Trade-offs)
| 方案 | 具体操作 | 结果与局限 |
|---|---|---|
| 归因到笔/系统差异 | 换 Apple Pencil 型号、换设备、换浏览器 | 无法解释,因为根因与笔无关 |
| 归因到坐标换算 | 排查 PDF 空间 ↔ 显示空间转换 | 换算正确,但整笔丢失发生在换算之前 |
先 getImageData 再 putImageData | 期望强制回读 | 读的仍是同一个 desynchronized 表面,同样不可靠 |
等一帧再 drawImage | requestAnimationFrame 后再搬运 | 仍不可靠,实测不足以恢复 |
| 无头浏览器复现 | Playwright 无头跑通 | 假阳性:无头忽略该选项,掩盖问题 |
根因 (Root Cause)
desynchronized: true 让 Canvas2D 绕过常规的合成/同步路径,直接把绘制结果交给合成器(乃至 GPU 图层),使绘制与显示解耦、延迟更低。代价是这张画布不再保证能被同步读回为一致内容。
drawImage() / getImageData() 要求源表面在调用时刻有确定的、CPU 可读的内容,而 desynchronized 表面恰恰不提供这个保证。于是提交阶段读到一个空(或旧)表面:drawImage 拷过去的是空白,紧接着的 clearRect 又把唯一可见的活跃层清掉——笔画就此彻底蒸发。
关键在于「低延迟绘制」与「可被读取的提交源」是互斥的两种用法,同一个画布不能同时承担。
解决步骤 (Resolution)
立即生效(改功能层,不动墨迹数学):把 activeCanvas 的 desynchronized: true 去掉即可,其余逻辑完全不用改。
// 提交源必须可读,不能开 desynchronized
this.activeCtx = this.activeCanvas.getContext('2d')!;代价是活跃层多约一帧的显示延迟。用 A/B 实测:同一份代码、同一组事件,只切换这一个选项,主层墨迹从 0 变为 586 / 599(鼠标 / 触控笔)。
若必须保留低延迟:让提交走矢量重放而非位图搬运——提笔时把这一笔的曲线点重画到主层。注意重放必须复用增量渲染的同一套分段与线宽取值规则(每段取相邻两点线宽均值、midpoint → 控制点 → midpoint 的二次贝塞尔),否则会退回「抬笔时笔画跳变/变细」的老问题。改动落在墨迹引擎的提交分支,属于需要先与手感负责人确认的领域。
下次如何识别 (Recognition)
- 症状是「整笔消失」而非笔迹变细/错位/分叉时,优先怀疑提交链路,而不是手感算法或坐标换算。
- 代码库里看到
getContext('2d', { desynchronized: true }),同时该画布又被drawImage/getImageData/toDataURL当源用——立刻判定为缺陷,不需要先复现。 - 做 Canvas 相关的自动化验证时,无头浏览器不能作为该选项的证据来源;涉及延迟优化、GPU 合成、
desynchronized、toDataURL的用例必须在真实有头浏览器里跑。
相关概念
- Canvas 绘图 (Canvas Drawing)
- 浏览器手写输入 (Browser Handwriting Input)
- 数字墨迹与笔刷引擎演进史 (Digital Ink & Brush Engine Evolution)
- 批注模式触控笔书写触发页面滚动导致笔画拉丝偏移 (Stylus Annotation Triggers Viewport Pan Causing Stroke Drift)
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend