3.1 笔触模型:坐标、样式与时间戳

协同核心第一步:把「一个人画的一笔」定义清楚。笔是白板最原子的事件,笔触怎么建模,直接决定后续广播、合并、回放的可行性。本篇定下坐标、样式、时间戳三要素。

一笔包含什么

要素 内容 说明
坐标 点序列 pts[] 采样点,非整条路径
样式 颜色 / 线宽 / 画笔 决定渲染
时间戳 客户端 ts 展示用
归属 actor + seq 谁画的、排第几
帧标识 同笔的 chunk 号 一笔分多帧发

核心:一笔是「一串采样点 + 样式」,而不是一张图。

坐标怎么采样

鼠标 / 手指的移动是高频的,不能「一动发一点」——那会瞬间刷爆带宽。要做采样与简化: | 策略 | 做法 | 代价 | |---|---|---| | 时间采样 | 每 16~30ms 取一个点 | 匀速运动点变稀 | | 距离采样 | 间隔超过阈值才取点 | 急转弯点变多 | | 轨迹简化 | 去掉共线点 | 精度略降 |

白板常用「时间采样 + 端点保留」,保证起笔落笔精确、中间平滑。

一笔要分帧(chunk)

快速画一笔可能采样出上百个点,一次发太胖。把一笔切成几个 chunk,按 chunkId 顺序发、接收端按序拼: | 字段 | 作用 | |---|---| | chunkId | 本笔内分帧序号 | | chunkLast | 是否本笔最后一帧 | | 幂等键 | 同笔同帧重发不重复 |

这样一笔画得再长也是「按帧流式」,带宽可控、可断点续。

样式与归属怎么带

颜色、线宽随笔带;actor 与 seq 由服务端盖章:客户端先乐观画自己的笔(用本地临时 id),服务端接单后分配全局 seq 回填。归属和排序交给服务端,是冲突不乱的根基。

踩坑提示

最坑的是「端点精度损失」:采样太狠会丢掉起笔和落笔两个关键点,两笔衔接处出现小豁口。务必端点强制保留、中间可简化。再一个坑:用屏幕像素坐标而没用白板坐标系——窗口一变尺寸,别人画的笔就错位了。笔的坐标必须落在固定的白板坐标系里,和窗口解耦。

小结

一笔 = 采样点序列 + 样式 + 客户端时间戳 + 服务端 seq + 分帧标识。端点保留、按帧流式、坐标用白板系——笔触定了,实时广播才有可传的东西。

笔触协议长什么样

落到代码里,一笔的协议大致长这样:

{
  type: "stroke",
  boardId: "room-42",
  actor: "u-1001",
  strokeId: "s-7f3a",   // 客户端临时 id
  chunkId: 0,
  chunkLast: true,
  pts: [[12, 30], [14, 31], [18, 35]],
  style: { color: "#e24545", width: 3, brush: "pen" },
  ts: 1761234567890
}

字段不在多,在于「谁、在哪、哪一笔、第几帧」。strokeId 解决幂等,chunkId 解决拼帧,ts 只管展示和采样,排序永远不看 ts——这一点说三遍都不为过,后面冲突与回放全靠它。

字段 谁生成 作用
strokeId 客户端 幂等键,重发不重复
seq 服务端 全局定序,回放靠它
chunkId / chunkLast 客户端 分帧拼接
pts / style 客户端 内容本体
ts 客户端 仅展示与采样

采样与简化怎么落地

工程上常见做法是「定时器抽样 + 阈值过滤」两步走:

let last = null;
onPointerMove(e) {
  const p = toBoard(e);             // 屏幕坐标转白板坐标
  if (!last || dist(last, p) > 4) { // 距离阈值
    last = p;
    buffer.push(p);
  }
}

距离阈值太低,点就密、带宽炸;太高,急转弯丢细节、笔触发折。画笔(细线)阈值取小,荧光笔(粗线)可以放大——粗线本身容错高,远看根本看不出少了几个点。端点(down 与 up 两个点)无条件保留,这是笔笔衔接与落笔精度的基础。

场景 建议阈值 理由
普通画笔 2~4px 细线对折线敏感
荧光笔 / 马克笔 8~12px 粗线可容忍
橡皮擦轨迹 4~8px 擦除边界要准
平滑之后 可再过滤 二次去共线点

接收端怎么拼帧

发端分帧,收端就要拼帧:按 strokeId 开缓冲区,按 chunkId 顺序追加 pts,收到 chunkLast 才提交渲染。中途丢帧(chunkId 跳号)就整笔丢弃并请求补齐,宁可这一笔晚到,也不能渲染一条断掉的线。配合服务端操作日志,补齐直接走日志重放,而不是让客户端之间互相对讲。

高频狂画时怎么省带宽

白板最容易被「一个狂画的人」拖垮整个房间。三板斧: - 先在本地蓄 100ms 的点,再一次性发一个 chunk; - 点序列做增量编码,只传相对上一点的偏移量; - 观众端(只读成员)只推低频聚合帧,不推全量 chunk。

本节自检

  • 一笔的坐标用的是白板坐标系,还是窗口坐标系?
  • 起笔与落笔两个端点是否强制保留?
  • 分帧是否带幂等键与 chunkLast 标记?

踩完这些坑,笔触模型才算稳。更完整的白板数据协议设计,可参考 Node 协同白板实战;若想把白板笔触与音视频同步起来,看 基于 TRTC 的在线课堂。新手入门部署,看 云开发 Node 入门。

坐标系换算的细节

窗口坐标到白板坐标,穿梭着缩放与平移。换算公式本身不神秘:

function toBoard(e) {
  const { x, y } = e.offset;       // 窗口内偏移
  return [
    (x - view.x) / view.scale,     // 减去平移,除以缩放
    (y - view.y) / view.scale,
  ];
}

容易翻车的是缩放采样阈值:阈值是按「窗口像素」算还是按「白板单位」算?答案必须是窗口像素——人眼的感知单位是屏幕,而不是画布。否则放大看细节时点会密到爆炸,缩小看全局时点又稀成虚线。另外,接收端渲染时要做反向换算,两端的 view 状态必须随操作同步,否则别人看到的笔会飘。

换算环节 常见错法 正确做法
平移 忘了减 view 偏移 先减平移再除缩放
缩放 用白板单位做阈值 用窗口像素做阈值
逆变换 客户端各自硬算 view 状态随事件同步

时间戳为什么只能是客户端的

ts 来自客户端,天然不可信:设备时钟可能偏,用户可能手贱改系统时间。那为什么还要它?因为渲染插值需要:别人画笔过来时,按 ts 做平滑动画,笔才显得流畅。但凡涉及「顺序、先后、撤销定位」,一律用服务端 seq,ts 只进显示层。时钟不可靠的更多教训,可以看 Go 日志管线实战,日志系统对时间戳的执念和白板是一回事。

想在云端把这套白板跑起来,不妨蹲一个 腾讯云云产品合集特惠,服务器与缓存一起配齐。

不同画笔的差异

笔触模型不是铁板一块,画笔种类不同,采样策略也跟着变。铅笔要细密(压感还原),荧光笔可大步简化(半透明叠加天然遮丑),橡皮擦则近似「区域擦除」——甚至可以只传擦除框的两个角点,不传轨迹。

画笔 存什么 简化力度
铅笔 / 钢笔 全量点 + 压感 弱
荧光笔 稀疏点 强
橡皮擦 框选角点或粗轨迹 最强
图形 / 文本 参数本身 无

一句话:模型为渲染服务,传输为模型瘦身。

延伸阅读