白板笔刷与压感
白板的文档是 笔画列表,不是像素。像素只是墨迹层的缓存。撤销按 stroke 弹栈,不清整张位图。
1. 数据
export interface InkPoint {
x: number;
y: number;
pressure: number;
t: number;
}
export interface Stroke {
id: string;
points: InkPoint[];
color: string;
size: number;
}
export interface BoardDocument {
strokes: Stroke[];
}
坐标是世界坐标。工具栏、页码、远程光标走 DOM overlay。
2. 采样
export function pointFromEvent(
event: PointerEvent,
camera: Camera,
canvas: HTMLCanvasElement,
): InkPoint {
const rect = canvas.getBoundingClientRect();
const viewX = event.clientX - rect.left;
const viewY = event.clientY - rect.top;
const world = viewToWorld(camera, viewX, viewY);
const pressure = event.pointerType === "mouse" ? 0.5 : (event.pressure || 0.5);
return { x: world.x, y: world.y, pressure, t: event.timeStamp };
}
export function appendCoalesced(
stroke: Stroke,
event: PointerEvent,
camera: Camera,
canvas: HTMLCanvasElement,
minDist: number,
): void {
const events = event.getCoalescedEvents?.().length
? event.getCoalescedEvents()
: [event];
for (const e of events) {
const p = pointFromEvent(e, camera, canvas);
const last = stroke.points[stroke.points.length - 1];
if (last) {
const dx = p.x - last.x;
const dy = p.y - last.y;
if (dx * dx + dy * dy < minDist * minDist) continue;
}
stroke.points.push(p);
}
}
minDist 用世界单位:1.5 / camera.scale,保证屏幕上大约 1.5px 一个点。过密会让贝塞尔爆炸;过稀会在快滑时折线。
pointerType === "touch" 的 pressure 在不少 Android 上恒为 0 或 0.5。没有压感就不要把 width 乘成 0。
3. 拟合与绘制
二次贝塞尔中点拟合比原始折线稳,成本仍是 CPU:
export function strokePath(stroke: Stroke): Path2D {
const pts = stroke.points;
const path = new Path2D();
if (pts.length === 0) return path;
const first = pts[0]!;
path.moveTo(first.x, first.y);
if (pts.length === 1) {
path.arc(first.x, first.y, 0.1, 0, Math.PI * 2);
return path;
}
for (let i = 1; i < pts.length - 1; i += 1) {
const c = pts[i]!;
const n = pts[i + 1]!;
path.quadraticCurveTo(c.x, c.y, (c.x + n.x) / 2, (c.y + n.y) / 2);
}
const last = pts[pts.length - 1]!;
path.lineTo(last.x, last.y);
return path;
}
export function paintStroke(
ctx: CanvasRenderingContext2D,
stroke: Stroke,
camera: Camera,
): void {
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = stroke.color;
const last = stroke.points[stroke.points.length - 1];
const pressure = last?.pressure ?? 0.5;
ctx.lineWidth = stroke.size * (0.4 + pressure * 0.8) / camera.scale;
ctx.stroke(strokePath(stroke));
ctx.restore();
}
可变宽书法(每段不同宽度)不要一条 Path2D + 一次 stroke:lineWidth 是常量。要做成 ribbon(左右偏移轮廓 fill)或每段一条短线。前者好看,后者便宜。产品没要求压感宽度就固定 width。
正在画的那一笔放动态层;已提交笔画烘焙进墨迹层 ImageBitmap。超过 N 笔(如 200)做一次 bake,清空矢量列表里已 bake 的部分,但 文档里 strokes 仍在(撤销需要)。
4. 橡皮
矢量橡皮:对 stroke 的 polyline 做距离测试,命中则删整笔或切段。像素橡皮:在墨迹层 destination-out 画圆——一旦像素擦了,撤销必须有墨迹层快照,不能只弹 stroke。两种橡皮不要混在同一历史里却只存一种。
5. 协作
广播的是 点增量(strokeId + points[]),不是每帧整张 PNG。远端同样 append 到 document 再画。冲突用 strokeId。位图同步只适合「权限变成只读观众」。
6. 失败形态
| 症状 | 原因 |
|---|---|
| 折线锯齿、快滑漏点 | 没用 coalesced / minDist 太大 |
| 鼠标笔画消失 | pressure=0 把线宽乘没了 |
| 撤销一笔全白板闪 | 用了全画布 ImageData 栈 |
| 缩放后笔越来越粗 | lineWidth 没除 camera.scale |
| 移动端一边写一边滚页 | 没 touch-action: none |
权威资料
核对日期:2026-08-26