跳到主要内容

相机与锚点缩放

无限画布、标注缩放、白板平移,本质是同一套 视图矩阵。不要每帧 translate/scale 叠乘,也不要把相机写进每个节点的 local

1. 约定

world ——camera——▶ view(CSS 像素) ——dpr——▶ backing store
export interface Camera {
/** 视口左上角对应的世界坐标 */
panX: number;
panY: number;
/** 1 = 100%,世界 1 单位 = 1 CSS 像素 */
scale: number;
}

export function worldToView(camera: Camera, x: number, y: number): { x: number; y: number } {
return {
x: (x - camera.panX) * camera.scale,
y: (y - camera.panY) * camera.scale,
};
}

export function viewToWorld(camera: Camera, x: number, y: number): { x: number; y: number } {
return {
x: x / camera.scale + camera.panX,
y: y / camera.scale + camera.panY,
};
}

export function cameraToCtm(camera: Camera, dpr: number): DOMMatrix {
return new DOMMatrix()
.scale(dpr, dpr)
.translate(-camera.panX * camera.scale, -camera.panY * camera.scale)
.scale(camera.scale, camera.scale);
}

绘制:ctx.setTransform(cameraToCtm(camera, dpr)),然后用 世界坐标 填 path。 已有 cameraToCtm。PDF 阅读器画瓦片时同样用它。
命中:事件先换成 view(CSS),再 viewToWorld,对节点局部做 inverse。不要把 camera 再乘进已经是世界坐标的 path。

scale 夹在 minScale..maxScalescale → 0 会让矩阵不可逆,命中全 NaN

2. 锚点缩放(滚轮对着光标)

缩放必须让 光标下的世界点不变

export function zoomAt(
camera: Camera,
viewX: number,
viewY: number,
factor: number,
minScale: number,
maxScale: number,
): Camera {
const world = viewToWorld(camera, viewX, viewY);
const scale = Math.min(maxScale, Math.max(minScale, camera.scale * factor));
return {
scale,
panX: world.x - viewX / scale,
panY: world.y - viewY / scale,
};
}

wheel 要用 preventDefault(非被动监听),否则页面跟着滚。deltaMode 可能是行/页,不要假设 deltaY 是像素。

export function factorFromWheel(event: WheelEvent): number {
const dy = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
return Math.exp(-dy * 0.0015);
}

触控板 pinch 在 Chromium 上常是 ctrlKey + wheel。不要和「按住 Ctrl 当快捷键」打架:画布聚焦时把 ctrl+wheel 一律当缩放。

3. 平移

中键 / 空格+左键 / 双指拖:

export function panByViewDelta(camera: Camera, dxView: number, dyView: number): Camera {
return {
...camera,
panX: camera.panX - dxView / camera.scale,
panY: camera.panY - dyView / camera.scale,
};
}

用相邻事件的 view 坐标差,不用 movementX(浏览器缩放、外接显示器 DPI、pointer lock 会漂)。

4. 视口裁剪

相机定了之后,世界 AABB 与视口求交,只画可见节点。见 瓦片与空间索引。网格线按视口范围生成,不要从 (0,0) 画到一万。

5. 失败形态

症状原因
缩放往左上角吸没锚点,只改了 scale
缩放后命中全偏命中仍用未除 scale 的 CSS 坐标
线宽随缩放爆炸线宽写死在世界像素,未除 scale
叠乘越缩越小transform 累积而不是每帧 setTransform
触控板变成页面滚动wheel 监听是 passive

权威资料

核对日期:2026-08-26