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 日志管线实战,日志系统对时间戳的执念和白板是一回事。
想在云端把这套白板跑起来,不妨蹲一个 腾讯云云产品合集特惠,服务器与缓存一起配齐。
不同画笔的差异
笔触模型不是铁板一块,画笔种类不同,采样策略也跟着变。铅笔要细密(压感还原),荧光笔可大步简化(半透明叠加天然遮丑),橡皮擦则近似「区域擦除」——甚至可以只传擦除框的两个角点,不传轨迹。
| 画笔 | 存什么 | 简化力度 |
|---|---|---|
| 铅笔 / 钢笔 | 全量点 + 压感 | 弱 |
| 荧光笔 | 稀疏点 | 强 |
| 橡皮擦 | 框选角点或粗轨迹 | 最强 |
| 图形 / 文本 | 参数本身 | 无 |
一句话:模型为渲染服务,传输为模型瘦身。
